From 8c86c988c198922a1f15b6c4148487e5d9146441 Mon Sep 17 00:00:00 2001 From: ThreadCount Date: Wed, 16 Sep 2026 04:04:31 +1000 Subject: [PATCH] ThreadCount Community edition Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 2d04e45 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2). --- .dockerignore | 12 + .env.example | 52 + .gitignore | 83 + COMMUNITY_VERSION | 1 + Dockerfile | 50 + LICENSE | 105 + README.md | 157 + app/api/2fa/route.ts | 121 + app/api/activity/route.ts | 58 + app/api/app-info/route.ts | 29 + app/api/auth/2fa/route.ts | 80 + app/api/auth/forgot/route.ts | 80 + app/api/auth/login/route.ts | 103 + app/api/auth/logout/route.ts | 22 + app/api/auth/lookup/route.ts | 28 + app/api/auth/reset/route.ts | 96 + app/api/auth/signup/route.ts | 100 + app/api/backup/route.ts | 25 + app/api/health/route.ts | 26 + app/api/logo/route.ts | 15 + app/api/lookup/route.ts | 80 + app/api/mutate/route.ts | 40 + app/api/photo/[id]/route.ts | 46 + app/api/requests/route.ts | 220 + app/api/rev/route.ts | 37 + app/api/staff/activate/route.ts | 132 + app/api/staff/decide/route.ts | 87 + app/api/staff/login/route.ts | 45 + app/api/staff/logout/route.ts | 23 + app/api/staff/mutate/route.ts | 56 + app/app/activity/page.tsx | 14 + app/app/counter/page.tsx | 8 + app/app/help/[section]/[slug]/page.tsx | 28 + app/app/help/page.tsx | 26 + app/app/issue/page.tsx | 13 + app/app/layout.tsx | 35 + app/app/orders/[id]/page.tsx | 355 ++ app/app/orders/all/page.tsx | 110 + app/app/orders/list/page.tsx | 11 + app/app/orders/page.tsx | 29 + app/app/page.tsx | 44 + app/app/report/page.tsx | 83 + app/app/requests/page.tsx | 142 + app/app/rounds/page.tsx | 73 + app/app/settings/page.tsx | 70 + app/app/staff/[id]/page.tsx | 8 + app/app/staff/page.tsx | 8 + app/app/stock/[id]/page.tsx | 414 ++ app/app/stock/page.tsx | 50 + app/app/stocktake/page.tsx | 15 + app/auth/page.tsx | 41 + app/error.tsx | 44 + app/favicon.ico | Bin 0 -> 25931 bytes app/global-error.tsx | 70 + app/globals.css | 1430 +++++ app/layout.tsx | 55 + app/m/(app)/catalogue/[id]/page.tsx | 513 ++ app/m/(app)/catalogue/new/page.tsx | 155 + app/m/(app)/catalogue/page.tsx | 91 + app/m/(app)/count/[id]/page.tsx | 220 + app/m/(app)/count/[id]/variance/page.tsx | 171 + app/m/(app)/count/page.tsx | 78 + app/m/(app)/issue/[staffId]/page.tsx | 220 + app/m/(app)/issue/page.tsx | 45 + app/m/(app)/label/page.tsx | 124 + app/m/(app)/layout.tsx | 16 + app/m/(app)/loading.tsx | 54 + app/m/(app)/more/page.tsx | 43 + app/m/(app)/page.tsx | 104 + app/m/(app)/person/[id]/exchange/page.tsx | 101 + app/m/(app)/person/[id]/page.tsx | 117 + app/m/(app)/person/[id]/return/page.tsx | 131 + app/m/(app)/pickups/page.tsx | 75 + app/m/(app)/receive/page.tsx | 121 + app/m/(app)/reorder/page.tsx | 107 + app/m/(app)/rounds/page.tsx | 110 + app/m/(app)/search/page.tsx | 89 + app/m/(app)/settings/page.tsx | 111 + app/m/(app)/signed-in/page.tsx | 86 + app/m/(app)/stock/page.tsx | 67 + app/m/(app)/variance/page.tsx | 95 + app/m/layout.tsx | 39 + app/m/login/layout.tsx | 13 + app/m/login/page.tsx | 205 + app/m/signup/layout.tsx | 12 + app/m/signup/page.tsx | 11 + app/my/(app)/account/page.tsx | 11 + app/my/(app)/approvals/[id]/page.tsx | 17 + app/my/(app)/approvals/page.tsx | 43 + app/my/(app)/damage/page.tsx | 13 + app/my/(app)/kit/page.tsx | 12 + app/my/(app)/kitcheck/page.tsx | 16 + app/my/(app)/layout.tsx | 42 + app/my/(app)/loading.tsx | 55 + app/my/(app)/not-found.tsx | 28 + app/my/(app)/orders/[id]/messages/page.tsx | 15 + app/my/(app)/orders/[id]/page.tsx | 17 + app/my/(app)/orders/page.tsx | 24 + app/my/(app)/page.tsx | 67 + app/my/(app)/raise/page.tsx | 44 + app/my/(app)/request/page.tsx | 47 + app/my/(app)/round/page.tsx | 32 + app/my/(app)/shelf/page.tsx | 13 + app/my/(app)/waitlist/page.tsx | 15 + app/my/(app)/ward/page.tsx | 16 + app/my/approve/page.tsx | 116 + app/my/layout.tsx | 34 + app/my/signin/page.tsx | 231 + app/not-found.tsx | 11 + app/page.tsx | 6 + app/print/labels/page.tsx | 148 + app/print/order-form/page.tsx | 397 ++ app/print/page.tsx | 102 + app/print/supplier-order/page.tsx | 89 + app/reset/layout.tsx | 20 + app/reset/page.tsx | 186 + app/robots.txt/route.ts | 33 + components/Analytics.tsx | 85 + components/AuthForm.tsx | 507 ++ components/AutoPrint.tsx | 34 + components/Camera.tsx | 63 + components/Checklist.tsx | 81 + components/CommandBar.tsx | 305 + components/DemoBanner.tsx | 2 + components/ErrorReporting.tsx | 45 + components/FacilityRules.tsx | 122 + components/HelpMark.tsx | 32 + components/Helpdesk.tsx | 38 + components/MAuth.tsx | 121 + components/MPerson.tsx | 81 + components/MScan.tsx | 183 + components/MSignup.tsx | 170 + components/ManualHome.tsx | 66 + components/ManualSearch.tsx | 94 + components/ManualShell.tsx | 65 + components/ManualToc.tsx | 55 + components/ManualView.tsx | 95 + components/OrderList.tsx | 2 + components/PlanBanner.tsx | 2 + components/PlanChoice.tsx | 3 + components/PlanTab.tsx | 2 + components/Shell.tsx | 324 ++ components/SsoSettings.tsx | 13 + components/Turnstile.tsx | 73 + components/TwoFactor.tsx | 187 + components/counter/AddGarments.tsx | 191 + components/counter/Counter.tsx | 204 + components/counter/Modes.tsx | 192 + components/counter/PersonPanel.tsx | 70 + components/counter/PersonPicker.tsx | 60 + components/counter/PickupCart.tsx | 110 + components/counter/counter.module.css | 94 + components/counter/lib.ts | 74 + components/counter/useCounterCart.ts | 102 + components/dialogs.tsx | 1160 ++++ components/m.tsx | 426 ++ components/my.tsx | 40 + components/orders/OnTheWay.tsx | 48 + components/orders/RecentOrders.tsx | 29 + components/orders/SupplierPanel.tsx | 256 + components/orders/ThisMonth.tsx | 31 + components/orders/ToOrder.tsx | 44 + components/orders/bits.tsx | 38 + components/orders/toOrder.ts | 77 + components/people/DetailsTab.tsx | 169 + components/people/HistoryTab.tsx | 193 + components/people/ManagerBox.tsx | 90 + components/people/Record.tsx | 111 + components/people/Register.tsx | 245 + components/people/ReportsBox.tsx | 75 + components/people/RequestsTab.tsx | 29 + components/people/SelfService.tsx | 71 + components/people/SignedToggle.tsx | 16 + components/people/Summary.tsx | 64 + components/people/UniformTab.tsx | 231 + components/people/shared.tsx | 42 + components/portal.tsx | 292 + components/reports/MonthEndStrip.tsx | 48 + components/reports/PeopleTab.tsx | 109 + components/reports/SpendTab.tsx | 145 + components/reports/StockTab.tsx | 60 + components/reports/bits.tsx | 37 + components/reports/useReportData.ts | 242 + components/requests/Damage.tsx | 47 + components/requests/KitCheck.tsx | 104 + components/requests/Queries.tsx | 38 + components/requests/RequestList.tsx | 521 ++ components/requests/csv.ts | 94 + components/screens/Account.tsx | 138 + components/screens/Approvals.tsx | 199 + components/screens/ApproveByLink.tsx | 196 + components/screens/Damage.tsx | 144 + components/screens/Desk.tsx | 352 ++ components/screens/Home.tsx | 215 + components/screens/Kit.tsx | 131 + components/screens/KitCheck.tsx | 181 + components/screens/ManagerNav.tsx | 44 + components/screens/Order.tsx | 194 + components/screens/Orders.tsx | 100 + components/screens/Request.tsx | 255 + components/screens/Review.tsx | 266 + components/screens/Round.tsx | 201 + components/screens/Shelf.tsx | 97 + components/screens/Thread.tsx | 110 + components/screens/Waitlist.tsx | 176 + components/screens/Ward.tsx | 66 + components/settings/AuditLog.tsx | 114 + components/settings/CatalogueSection.tsx | 80 + components/settings/DataAudit.tsx | 133 + components/settings/FacilitySection.tsx | 137 + components/settings/IssuingRules.tsx | 36 + components/settings/PeopleSignIn.tsx | 130 + components/settings/PlacesSection.tsx | 95 + components/settings/RouteBoard.tsx | 303 + components/settings/UserDialog.tsx | 42 + components/settings/auditLabels.ts | 132 + components/settings/backup.ts | 31 + components/settings/common.tsx | 118 + components/settings/names.ts | 93 + components/staffnav.tsx | 60 + components/staffui.tsx | 720 +++ components/stock/CountTab.tsx | 284 + components/stock/Locations.tsx | 80 + components/stock/OnHand.tsx | 258 + components/stock/StockStyles.tsx | 5 + components/stock/url.ts | 14 + components/today/CollectGroup.tsx | 41 + components/today/CountsGroup.tsx | 27 + components/today/Lines.tsx | 16 + components/today/MonthEndPanel.tsx | 56 + components/today/PickGroup.tsx | 74 + components/today/ReceiveGroup.tsx | 37 + components/today/RoundGroup.tsx | 32 + components/today/RunsOutPanel.tsx | 54 + components/today/SetupGroup.tsx | 37 + components/ui.tsx | 319 + docker-compose.yml | 72 + docker/backup.sh | 18 + docs/banner.svg | 15 + docs/manual/AUTHORING.md | 119 + docs/manual/account/delete-an-account.md | 73 + docs/manual/account/export-and-backup.md | 71 + docs/manual/account/plan-and-billing.md | 92 + docs/manual/account/single-sign-on.md | 72 + docs/manual/account/two-factor.md | 64 + docs/manual/account/users.md | 73 + docs/manual/apps/counter-app.md | 88 + docs/manual/apps/scanning-and-browsers.md | 87 + docs/manual/apps/staff-app.md | 76 + docs/manual/counter/delivery-rounds.md | 69 + docs/manual/counter/exchanges-and-returns.md | 79 + docs/manual/counter/issue-a-garment.md | 65 + docs/manual/counter/manager-approvals.md | 75 + docs/manual/counter/pickup-call-list.md | 65 + docs/manual/counter/requests-from-staff.md | 84 + docs/manual/counter/slips-and-signatures.md | 78 + .../people/deactivating-and-deleting.md | 81 + docs/manual/people/entitlement-rule.md | 83 + docs/manual/people/groups-and-routes.md | 80 + docs/manual/people/managers.md | 70 + docs/manual/people/staff-register.md | 74 + docs/manual/reference/csv-templates.md | 119 + docs/manual/reference/glossary.md | 116 + docs/manual/reference/keyboard-and-scanner.md | 93 + docs/manual/reports/cost-centres.md | 62 + docs/manual/reports/journal-export.md | 70 + docs/manual/reports/month-end-pack.md | 68 + docs/manual/reports/the-nine-reports.md | 66 + docs/manual/selfhost/backups.md | 126 + .../selfhost/configuration-reference.md | 93 + docs/manual/selfhost/email.md | 73 + docs/manual/selfhost/first-run.md | 83 + docs/manual/selfhost/install.md | 101 + docs/manual/selfhost/updating.md | 79 + docs/manual/start/set-up-in-an-afternoon.md | 77 + docs/manual/start/the-two-roles.md | 66 + docs/manual/start/threadcount-in-one-page.md | 78 + docs/manual/start/your-first-order.md | 68 + docs/manual/stock/barcodes.md | 62 + docs/manual/stock/catalogue-sizes-and-cuts.md | 68 + docs/manual/stock/order-list.md | 68 + .../manual/stock/receiving-and-back-orders.md | 69 + docs/manual/stock/reorder-levels.md | 62 + docs/manual/stock/stocktakes.md | 68 + docs/manual/stock/suppliers.md | 64 + docs/self-hosting.md | 127 + instrumentation.ts | 75 + lib/accountmail.ts | 37 + lib/analytics.ts | 173 + lib/approvallink.ts | 243 + lib/audit.ts | 117 + lib/barcode.ts | 114 + lib/billing-mail.ts | 20 + lib/client.tsx | 84 + lib/compute.ts | 1329 +++++ lib/countries.ts | 258 + lib/csrf.ts | 15 + lib/csv.ts | 79 + lib/cycledata.ts | 107 + lib/db.ts | 18 + lib/deskdata.ts | 188 + lib/edition.ts | 11 + lib/errors.ts | 47 + lib/feedback.ts | 85 + lib/glitchtip.ts | 158 + lib/helpdesk.ts | 47 + lib/hosted-defaults.ts | 14 + lib/links.ts | 14 + lib/live.ts | 135 + lib/mail-html.cjs | 139 + lib/mail.ts | 62 + lib/managerdata.ts | 197 + lib/manual-links.ts | 79 + lib/manual.ts | 236 + lib/nativescan.ts | 131 + lib/opencount.ts | 76 + lib/ops.ts | 3483 +++++++++++ lib/ops/alerts.ts | 7 + lib/orderdoc.ts | 79 + lib/photo.ts | 37 + lib/photostore.ts | 91 + lib/plan.ts | 142 + lib/portalcounts.ts | 222 + lib/print.ts | 57 + lib/ratelimit.ts | 64 + lib/reset.ts | 65 + lib/search.ts | 79 + lib/session.ts | 94 + lib/sets.ts | 293 + lib/snapshot.ts | 185 + lib/staffauth.ts | 80 + lib/staffclient.tsx | 111 + lib/staffdata.ts | 506 ++ lib/staffops.ts | 733 +++ lib/staffreq.ts | 238 + lib/staffsession.ts | 128 + lib/stripe.ts | 2 + lib/switches.ts | 56 + lib/today.ts | 137 + lib/totp.ts | 137 + lib/turnstile.ts | 58 + lib/twofactor.ts | 66 + lib/wakelock.ts | 45 + next.config.ts | 98 + package-lock.json | 5171 +++++++++++++++++ package.json | 45 + patches/@capacitor+android+6.2.2.patch | 17 + prisma.config.ts | 12 + .../20260827035001_init/migration.sql | 412 ++ .../20260828100000_update_wave/migration.sql | 48 + .../migration.sql | 13 + .../migration.sql | 4 + .../20260829010000_preloved/migration.sql | 34 + .../migration.sql | 18 + .../migration.sql | 3 + .../migration.sql | 3 + .../20260902100000_catalog_type/migration.sql | 3 + .../migration.sql | 20 + .../20260906120000_locations/migration.sql | 32 + .../migration.sql | 25 + .../20260907130000_audit_event/migration.sql | 16 + .../20260907140000_cost_history/migration.sql | 25 + .../migration.sql | 6 + .../20260907160000_two_factor/migration.sql | 21 + .../migration.sql | 34 + .../20260907190000_staff_app/migration.sql | 228 + .../20260907190500_request_seq/migration.sql | 3 + .../migration.sql | 30 + .../migration.sql | 3 + .../migration.sql | 4 + .../migration.sql | 51 + .../20260909005105_facility_rev/migration.sql | 6 + .../migration.sql | 5 + .../migration.sql | 16 + .../migration.sql | 41 + .../migration.sql | 11 + .../migration.sql | 26 + .../migration.sql | 55 + .../migration.sql | 22 + .../migration.sql | 19 + .../20260912030000_operators/migration.sql | 62 + .../migration.sql | 14 + .../migration.sql | 74 + .../migration.sql | 13 + .../migration.sql | 22 + .../20260913010000_facility_sso/migration.sql | 18 + .../20260913120000_plans/migration.sql | 22 + .../migration.sql | 99 + .../20260913200000_checkout/migration.sql | 20 + .../20260913210000_plan_mail/migration.sql | 20 + .../migration.sql | 6 + .../20260915120000_checklist/migration.sql | 3 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 1333 +++++ proxy.ts | 60 + public/media/walkthrough-poster.png | Bin 0 -> 142232 bytes public/media/walkthrough.webm | Bin 0 -> 2284992 bytes public/robots.txt | 2 + scripts/approval-mint.cjs | 33 + scripts/check-barcode.ts | 40 + scripts/check-totp.ts | 76 + scripts/e2e-app.sh | 184 + scripts/e2e-audit.sh | 85 + scripts/e2e-bulk.sh | 90 + scripts/e2e-catalogue.sh | 101 + scripts/e2e-community.sh | 49 + scripts/e2e-cost.sh | 78 + scripts/e2e-mobile.sh | 93 + scripts/e2e-orders.sh | 115 + scripts/e2e-photos.sh | 75 + scripts/e2e-preflight.sh | 52 + scripts/e2e-preloved.sh | 119 + scripts/e2e-scan.sh | 242 + scripts/e2e-security.sh | 121 + scripts/e2e-sets.sh | 817 +++ scripts/e2e-staff.sh | 222 + scripts/e2e-staffapp.sh | 718 +++ scripts/e2e.sh | 213 + scripts/photos-to-disk.cjs | 60 + scripts/reset-mint.cjs | 39 + scripts/reset-token.cjs | 27 + scripts/schema-diff.sh | 17 + scripts/totp-code.ts | 14 + tsconfig.json | 34 + 424 files changed, 53598 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 COMMUNITY_VERSION create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app/api/2fa/route.ts create mode 100644 app/api/activity/route.ts create mode 100644 app/api/app-info/route.ts create mode 100644 app/api/auth/2fa/route.ts create mode 100644 app/api/auth/forgot/route.ts create mode 100644 app/api/auth/login/route.ts create mode 100644 app/api/auth/logout/route.ts create mode 100644 app/api/auth/lookup/route.ts create mode 100644 app/api/auth/reset/route.ts create mode 100644 app/api/auth/signup/route.ts create mode 100644 app/api/backup/route.ts create mode 100644 app/api/health/route.ts create mode 100644 app/api/logo/route.ts create mode 100644 app/api/lookup/route.ts create mode 100644 app/api/mutate/route.ts create mode 100644 app/api/photo/[id]/route.ts create mode 100644 app/api/requests/route.ts create mode 100644 app/api/rev/route.ts create mode 100644 app/api/staff/activate/route.ts create mode 100644 app/api/staff/decide/route.ts create mode 100644 app/api/staff/login/route.ts create mode 100644 app/api/staff/logout/route.ts create mode 100644 app/api/staff/mutate/route.ts create mode 100644 app/app/activity/page.tsx create mode 100644 app/app/counter/page.tsx create mode 100644 app/app/help/[section]/[slug]/page.tsx create mode 100644 app/app/help/page.tsx create mode 100644 app/app/issue/page.tsx create mode 100644 app/app/layout.tsx create mode 100644 app/app/orders/[id]/page.tsx create mode 100644 app/app/orders/all/page.tsx create mode 100644 app/app/orders/list/page.tsx create mode 100644 app/app/orders/page.tsx create mode 100644 app/app/page.tsx create mode 100644 app/app/report/page.tsx create mode 100644 app/app/requests/page.tsx create mode 100644 app/app/rounds/page.tsx create mode 100644 app/app/settings/page.tsx create mode 100644 app/app/staff/[id]/page.tsx create mode 100644 app/app/staff/page.tsx create mode 100644 app/app/stock/[id]/page.tsx create mode 100644 app/app/stock/page.tsx create mode 100644 app/app/stocktake/page.tsx create mode 100644 app/auth/page.tsx create mode 100644 app/error.tsx create mode 100644 app/favicon.ico create mode 100644 app/global-error.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/m/(app)/catalogue/[id]/page.tsx create mode 100644 app/m/(app)/catalogue/new/page.tsx create mode 100644 app/m/(app)/catalogue/page.tsx create mode 100644 app/m/(app)/count/[id]/page.tsx create mode 100644 app/m/(app)/count/[id]/variance/page.tsx create mode 100644 app/m/(app)/count/page.tsx create mode 100644 app/m/(app)/issue/[staffId]/page.tsx create mode 100644 app/m/(app)/issue/page.tsx create mode 100644 app/m/(app)/label/page.tsx create mode 100644 app/m/(app)/layout.tsx create mode 100644 app/m/(app)/loading.tsx create mode 100644 app/m/(app)/more/page.tsx create mode 100644 app/m/(app)/page.tsx create mode 100644 app/m/(app)/person/[id]/exchange/page.tsx create mode 100644 app/m/(app)/person/[id]/page.tsx create mode 100644 app/m/(app)/person/[id]/return/page.tsx create mode 100644 app/m/(app)/pickups/page.tsx create mode 100644 app/m/(app)/receive/page.tsx create mode 100644 app/m/(app)/reorder/page.tsx create mode 100644 app/m/(app)/rounds/page.tsx create mode 100644 app/m/(app)/search/page.tsx create mode 100644 app/m/(app)/settings/page.tsx create mode 100644 app/m/(app)/signed-in/page.tsx create mode 100644 app/m/(app)/stock/page.tsx create mode 100644 app/m/(app)/variance/page.tsx create mode 100644 app/m/layout.tsx create mode 100644 app/m/login/layout.tsx create mode 100644 app/m/login/page.tsx create mode 100644 app/m/signup/layout.tsx create mode 100644 app/m/signup/page.tsx create mode 100644 app/my/(app)/account/page.tsx create mode 100644 app/my/(app)/approvals/[id]/page.tsx create mode 100644 app/my/(app)/approvals/page.tsx create mode 100644 app/my/(app)/damage/page.tsx create mode 100644 app/my/(app)/kit/page.tsx create mode 100644 app/my/(app)/kitcheck/page.tsx create mode 100644 app/my/(app)/layout.tsx create mode 100644 app/my/(app)/loading.tsx create mode 100644 app/my/(app)/not-found.tsx create mode 100644 app/my/(app)/orders/[id]/messages/page.tsx create mode 100644 app/my/(app)/orders/[id]/page.tsx create mode 100644 app/my/(app)/orders/page.tsx create mode 100644 app/my/(app)/page.tsx create mode 100644 app/my/(app)/raise/page.tsx create mode 100644 app/my/(app)/request/page.tsx create mode 100644 app/my/(app)/round/page.tsx create mode 100644 app/my/(app)/shelf/page.tsx create mode 100644 app/my/(app)/waitlist/page.tsx create mode 100644 app/my/(app)/ward/page.tsx create mode 100644 app/my/approve/page.tsx create mode 100644 app/my/layout.tsx create mode 100644 app/my/signin/page.tsx create mode 100644 app/not-found.tsx create mode 100644 app/page.tsx create mode 100644 app/print/labels/page.tsx create mode 100644 app/print/order-form/page.tsx create mode 100644 app/print/page.tsx create mode 100644 app/print/supplier-order/page.tsx create mode 100644 app/reset/layout.tsx create mode 100644 app/reset/page.tsx create mode 100644 app/robots.txt/route.ts create mode 100644 components/Analytics.tsx create mode 100644 components/AuthForm.tsx create mode 100644 components/AutoPrint.tsx create mode 100644 components/Camera.tsx create mode 100644 components/Checklist.tsx create mode 100644 components/CommandBar.tsx create mode 100644 components/DemoBanner.tsx create mode 100644 components/ErrorReporting.tsx create mode 100644 components/FacilityRules.tsx create mode 100644 components/HelpMark.tsx create mode 100644 components/Helpdesk.tsx create mode 100644 components/MAuth.tsx create mode 100644 components/MPerson.tsx create mode 100644 components/MScan.tsx create mode 100644 components/MSignup.tsx create mode 100644 components/ManualHome.tsx create mode 100644 components/ManualSearch.tsx create mode 100644 components/ManualShell.tsx create mode 100644 components/ManualToc.tsx create mode 100644 components/ManualView.tsx create mode 100644 components/OrderList.tsx create mode 100644 components/PlanBanner.tsx create mode 100644 components/PlanChoice.tsx create mode 100644 components/PlanTab.tsx create mode 100644 components/Shell.tsx create mode 100644 components/SsoSettings.tsx create mode 100644 components/Turnstile.tsx create mode 100644 components/TwoFactor.tsx create mode 100644 components/counter/AddGarments.tsx create mode 100644 components/counter/Counter.tsx create mode 100644 components/counter/Modes.tsx create mode 100644 components/counter/PersonPanel.tsx create mode 100644 components/counter/PersonPicker.tsx create mode 100644 components/counter/PickupCart.tsx create mode 100644 components/counter/counter.module.css create mode 100644 components/counter/lib.ts create mode 100644 components/counter/useCounterCart.ts create mode 100644 components/dialogs.tsx create mode 100644 components/m.tsx create mode 100644 components/my.tsx create mode 100644 components/orders/OnTheWay.tsx create mode 100644 components/orders/RecentOrders.tsx create mode 100644 components/orders/SupplierPanel.tsx create mode 100644 components/orders/ThisMonth.tsx create mode 100644 components/orders/ToOrder.tsx create mode 100644 components/orders/bits.tsx create mode 100644 components/orders/toOrder.ts create mode 100644 components/people/DetailsTab.tsx create mode 100644 components/people/HistoryTab.tsx create mode 100644 components/people/ManagerBox.tsx create mode 100644 components/people/Record.tsx create mode 100644 components/people/Register.tsx create mode 100644 components/people/ReportsBox.tsx create mode 100644 components/people/RequestsTab.tsx create mode 100644 components/people/SelfService.tsx create mode 100644 components/people/SignedToggle.tsx create mode 100644 components/people/Summary.tsx create mode 100644 components/people/UniformTab.tsx create mode 100644 components/people/shared.tsx create mode 100644 components/portal.tsx create mode 100644 components/reports/MonthEndStrip.tsx create mode 100644 components/reports/PeopleTab.tsx create mode 100644 components/reports/SpendTab.tsx create mode 100644 components/reports/StockTab.tsx create mode 100644 components/reports/bits.tsx create mode 100644 components/reports/useReportData.ts create mode 100644 components/requests/Damage.tsx create mode 100644 components/requests/KitCheck.tsx create mode 100644 components/requests/Queries.tsx create mode 100644 components/requests/RequestList.tsx create mode 100644 components/requests/csv.ts create mode 100644 components/screens/Account.tsx create mode 100644 components/screens/Approvals.tsx create mode 100644 components/screens/ApproveByLink.tsx create mode 100644 components/screens/Damage.tsx create mode 100644 components/screens/Desk.tsx create mode 100644 components/screens/Home.tsx create mode 100644 components/screens/Kit.tsx create mode 100644 components/screens/KitCheck.tsx create mode 100644 components/screens/ManagerNav.tsx create mode 100644 components/screens/Order.tsx create mode 100644 components/screens/Orders.tsx create mode 100644 components/screens/Request.tsx create mode 100644 components/screens/Review.tsx create mode 100644 components/screens/Round.tsx create mode 100644 components/screens/Shelf.tsx create mode 100644 components/screens/Thread.tsx create mode 100644 components/screens/Waitlist.tsx create mode 100644 components/screens/Ward.tsx create mode 100644 components/settings/AuditLog.tsx create mode 100644 components/settings/CatalogueSection.tsx create mode 100644 components/settings/DataAudit.tsx create mode 100644 components/settings/FacilitySection.tsx create mode 100644 components/settings/IssuingRules.tsx create mode 100644 components/settings/PeopleSignIn.tsx create mode 100644 components/settings/PlacesSection.tsx create mode 100644 components/settings/RouteBoard.tsx create mode 100644 components/settings/UserDialog.tsx create mode 100644 components/settings/auditLabels.ts create mode 100644 components/settings/backup.ts create mode 100644 components/settings/common.tsx create mode 100644 components/settings/names.ts create mode 100644 components/staffnav.tsx create mode 100644 components/staffui.tsx create mode 100644 components/stock/CountTab.tsx create mode 100644 components/stock/Locations.tsx create mode 100644 components/stock/OnHand.tsx create mode 100644 components/stock/StockStyles.tsx create mode 100644 components/stock/url.ts create mode 100644 components/today/CollectGroup.tsx create mode 100644 components/today/CountsGroup.tsx create mode 100644 components/today/Lines.tsx create mode 100644 components/today/MonthEndPanel.tsx create mode 100644 components/today/PickGroup.tsx create mode 100644 components/today/ReceiveGroup.tsx create mode 100644 components/today/RoundGroup.tsx create mode 100644 components/today/RunsOutPanel.tsx create mode 100644 components/today/SetupGroup.tsx create mode 100644 components/ui.tsx create mode 100644 docker-compose.yml create mode 100755 docker/backup.sh create mode 100644 docs/banner.svg create mode 100644 docs/manual/AUTHORING.md create mode 100644 docs/manual/account/delete-an-account.md create mode 100644 docs/manual/account/export-and-backup.md create mode 100644 docs/manual/account/plan-and-billing.md create mode 100644 docs/manual/account/single-sign-on.md create mode 100644 docs/manual/account/two-factor.md create mode 100644 docs/manual/account/users.md create mode 100644 docs/manual/apps/counter-app.md create mode 100644 docs/manual/apps/scanning-and-browsers.md create mode 100644 docs/manual/apps/staff-app.md create mode 100644 docs/manual/counter/delivery-rounds.md create mode 100644 docs/manual/counter/exchanges-and-returns.md create mode 100644 docs/manual/counter/issue-a-garment.md create mode 100644 docs/manual/counter/manager-approvals.md create mode 100644 docs/manual/counter/pickup-call-list.md create mode 100644 docs/manual/counter/requests-from-staff.md create mode 100644 docs/manual/counter/slips-and-signatures.md create mode 100644 docs/manual/people/deactivating-and-deleting.md create mode 100644 docs/manual/people/entitlement-rule.md create mode 100644 docs/manual/people/groups-and-routes.md create mode 100644 docs/manual/people/managers.md create mode 100644 docs/manual/people/staff-register.md create mode 100644 docs/manual/reference/csv-templates.md create mode 100644 docs/manual/reference/glossary.md create mode 100644 docs/manual/reference/keyboard-and-scanner.md create mode 100644 docs/manual/reports/cost-centres.md create mode 100644 docs/manual/reports/journal-export.md create mode 100644 docs/manual/reports/month-end-pack.md create mode 100644 docs/manual/reports/the-nine-reports.md create mode 100644 docs/manual/selfhost/backups.md create mode 100644 docs/manual/selfhost/configuration-reference.md create mode 100644 docs/manual/selfhost/email.md create mode 100644 docs/manual/selfhost/first-run.md create mode 100644 docs/manual/selfhost/install.md create mode 100644 docs/manual/selfhost/updating.md create mode 100644 docs/manual/start/set-up-in-an-afternoon.md create mode 100644 docs/manual/start/the-two-roles.md create mode 100644 docs/manual/start/threadcount-in-one-page.md create mode 100644 docs/manual/start/your-first-order.md create mode 100644 docs/manual/stock/barcodes.md create mode 100644 docs/manual/stock/catalogue-sizes-and-cuts.md create mode 100644 docs/manual/stock/order-list.md create mode 100644 docs/manual/stock/receiving-and-back-orders.md create mode 100644 docs/manual/stock/reorder-levels.md create mode 100644 docs/manual/stock/stocktakes.md create mode 100644 docs/manual/stock/suppliers.md create mode 100644 docs/self-hosting.md create mode 100644 instrumentation.ts create mode 100644 lib/accountmail.ts create mode 100644 lib/analytics.ts create mode 100644 lib/approvallink.ts create mode 100644 lib/audit.ts create mode 100644 lib/barcode.ts create mode 100644 lib/billing-mail.ts create mode 100644 lib/client.tsx create mode 100644 lib/compute.ts create mode 100644 lib/countries.ts create mode 100644 lib/csrf.ts create mode 100644 lib/csv.ts create mode 100644 lib/cycledata.ts create mode 100644 lib/db.ts create mode 100644 lib/deskdata.ts create mode 100644 lib/edition.ts create mode 100644 lib/errors.ts create mode 100644 lib/feedback.ts create mode 100644 lib/glitchtip.ts create mode 100644 lib/helpdesk.ts create mode 100644 lib/hosted-defaults.ts create mode 100644 lib/links.ts create mode 100644 lib/live.ts create mode 100644 lib/mail-html.cjs create mode 100644 lib/mail.ts create mode 100644 lib/managerdata.ts create mode 100644 lib/manual-links.ts create mode 100644 lib/manual.ts create mode 100644 lib/nativescan.ts create mode 100644 lib/opencount.ts create mode 100644 lib/ops.ts create mode 100644 lib/ops/alerts.ts create mode 100644 lib/orderdoc.ts create mode 100644 lib/photo.ts create mode 100644 lib/photostore.ts create mode 100644 lib/plan.ts create mode 100644 lib/portalcounts.ts create mode 100644 lib/print.ts create mode 100644 lib/ratelimit.ts create mode 100644 lib/reset.ts create mode 100644 lib/search.ts create mode 100644 lib/session.ts create mode 100644 lib/sets.ts create mode 100644 lib/snapshot.ts create mode 100644 lib/staffauth.ts create mode 100644 lib/staffclient.tsx create mode 100644 lib/staffdata.ts create mode 100644 lib/staffops.ts create mode 100644 lib/staffreq.ts create mode 100644 lib/staffsession.ts create mode 100644 lib/stripe.ts create mode 100644 lib/switches.ts create mode 100644 lib/today.ts create mode 100644 lib/totp.ts create mode 100644 lib/turnstile.ts create mode 100644 lib/twofactor.ts create mode 100644 lib/wakelock.ts create mode 100644 next.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 patches/@capacitor+android+6.2.2.patch create mode 100644 prisma.config.ts create mode 100644 prisma/migrations/20260827035001_init/migration.sql create mode 100644 prisma/migrations/20260828100000_update_wave/migration.sql create mode 100644 prisma/migrations/20260828160000_issue_cost_backorder_parent/migration.sql create mode 100644 prisma/migrations/20260828230000_demo_and_user_inactive/migration.sql create mode 100644 prisma/migrations/20260829010000_preloved/migration.sql create mode 100644 prisma/migrations/20260829020000_mobile_photos/migration.sql create mode 100644 prisma/migrations/20260829030000_handin_credited/migration.sql create mode 100644 prisma/migrations/20260902020000_barcode_lookup/migration.sql create mode 100644 prisma/migrations/20260902100000_catalog_type/migration.sql create mode 100644 prisma/migrations/20260905090000_contact_message/migration.sql create mode 100644 prisma/migrations/20260906120000_locations/migration.sql create mode 100644 prisma/migrations/20260907120000_password_reset/migration.sql create mode 100644 prisma/migrations/20260907130000_audit_event/migration.sql create mode 100644 prisma/migrations/20260907140000_cost_history/migration.sql create mode 100644 prisma/migrations/20260907150000_photo_on_disk/migration.sql create mode 100644 prisma/migrations/20260907160000_two_factor/migration.sql create mode 100644 prisma/migrations/20260907170000_staff_account/migration.sql create mode 100644 prisma/migrations/20260907190000_staff_app/migration.sql create mode 100644 prisma/migrations/20260907190500_request_seq/migration.sql create mode 100644 prisma/migrations/20260907191000_record_dispute/migration.sql create mode 100644 prisma/migrations/20260908095332_facility_timezone/migration.sql create mode 100644 prisma/migrations/20260908095400_staff_activate_code_at/migration.sql create mode 100644 prisma/migrations/20260908172613_request_lines/migration.sql create mode 100644 prisma/migrations/20260909005105_facility_rev/migration.sql create mode 100644 prisma/migrations/20260909070303_facility_barcode_seq/migration.sql create mode 100644 prisma/migrations/20260911000000_stamp_outstanding_activate_codes/migration.sql create mode 100644 prisma/migrations/20260911010000_uniform_entitlement/migration.sql create mode 100644 prisma/migrations/20260911020000_group_set_cap/migration.sql create mode 100644 prisma/migrations/20260911030000_approval_by_staff/migration.sql create mode 100644 prisma/migrations/20260912000000_allowance_routes/migration.sql create mode 100644 prisma/migrations/20260912010000_garment_groups/migration.sql create mode 100644 prisma/migrations/20260912020000_uniform_style/migration.sql create mode 100644 prisma/migrations/20260912030000_operators/migration.sql create mode 100644 prisma/migrations/20260912040000_operator_recovery/migration.sql create mode 100644 prisma/migrations/20260912050000_ops_ro_grants/migration.sql create mode 100644 prisma/migrations/20260912060000_ops_reveal_grants/migration.sql create mode 100644 prisma/migrations/20260912070000_platform_switches/migration.sql create mode 100644 prisma/migrations/20260913010000_facility_sso/migration.sql create mode 100644 prisma/migrations/20260913120000_plans/migration.sql create mode 100644 prisma/migrations/20260913160000_organisations/migration.sql create mode 100644 prisma/migrations/20260913200000_checkout/migration.sql create mode 100644 prisma/migrations/20260913210000_plan_mail/migration.sql create mode 100644 prisma/migrations/20260915100000_supplier_orders/migration.sql create mode 100644 prisma/migrations/20260915120000_checklist/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 proxy.ts create mode 100644 public/media/walkthrough-poster.png create mode 100644 public/media/walkthrough.webm create mode 100644 public/robots.txt create mode 100644 scripts/approval-mint.cjs create mode 100644 scripts/check-barcode.ts create mode 100644 scripts/check-totp.ts create mode 100755 scripts/e2e-app.sh create mode 100644 scripts/e2e-audit.sh create mode 100644 scripts/e2e-bulk.sh create mode 100644 scripts/e2e-catalogue.sh create mode 100755 scripts/e2e-community.sh create mode 100644 scripts/e2e-cost.sh create mode 100644 scripts/e2e-mobile.sh create mode 100755 scripts/e2e-orders.sh create mode 100644 scripts/e2e-photos.sh create mode 100644 scripts/e2e-preflight.sh create mode 100644 scripts/e2e-preloved.sh create mode 100755 scripts/e2e-scan.sh create mode 100644 scripts/e2e-security.sh create mode 100755 scripts/e2e-sets.sh create mode 100755 scripts/e2e-staff.sh create mode 100755 scripts/e2e-staffapp.sh create mode 100644 scripts/e2e.sh create mode 100644 scripts/photos-to-disk.cjs create mode 100644 scripts/reset-mint.cjs create mode 100644 scripts/reset-token.cjs create mode 100644 scripts/schema-diff.sh create mode 100644 scripts/totp-code.ts create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..439222a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +.next +.photos +.git +.claude +docs +# The manual is read by the app at request time (Help), so it has to be in the image. +!docs/manual +patches/*.orig +.env +.env.* +!.env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b49fb8e --- /dev/null +++ b/.env.example @@ -0,0 +1,52 @@ +# Every variable the Community edition reads. docker-compose.yml passes this file to the app and +# fills DATABASE_URL, PORT and PHOTO_DIR itself. +# +# The variables beginning NEXT_PUBLIC_ are compiled into the browser bundle at build time, not read +# at runtime — set them before the first `docker compose up --build`, and rebuild if they change. + +# ---- required ---- +# Signs every session cookie. Generate one per instance (`openssl rand -base64 48`). The server +# refuses to start while it still says change-me. +SESSION_SECRET=change-me +# The bundled database's password (docker-compose.yml only). +POSTGRES_PASSWORD= +# The address people open the app at, e.g. https://uniforms.example.health. Used in emailed links. +NEXT_PUBLIC_SITE_URL=http://localhost:3000 +# Always "community" on your own server. +EDITION=community + +# ---- your documents ---- +# The staff sign-in and the account screens link to a terms page and a privacy notice. Point them +# at your own; until you do they point at threadcount.tech's, which describe the hosted service. +NEXT_PUBLIC_TERMS_URL= +NEXT_PUBLIC_PRIVACY_URL= + +# ---- transactional mail (optional) ---- +# With these unset nothing is sent: password resets and approval links are handled at the counter, +# and the screens say so rather than claiming otherwise. +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +SMTP_FROM="ThreadCount " + +# ---- optional ---- +# 1 hides the create-account form and refuses the sign-up endpoint. Set it once your facility exists. +SIGNUPS_DISABLED= +# Cloudflare Turnstile on sign-in and sign-up. Set both to enforce it; leave both blank to rely on +# the per-address rate limits alone. +TURNSTILE_SECRET= +NEXT_PUBLIC_TURNSTILE_SITEKEY= +# Your own GlitchTip (Sentry-protocol) DSN for error reports. Blank = nothing is reported anywhere. +NEXT_PUBLIC_GLITCHTIP_DSN= +# Stamped on error reports so a fault can be tied to a build. +NEXT_PUBLIC_RELEASE= +# Host port docker-compose.yml publishes the app on (the container always listens on 3000). +APP_PORT=3000 +# Your own Umami, if any: the tracker script address and the site ids. Blank = no statistics sent. +NEXT_PUBLIC_UMAMI_SRC= +NEXT_PUBLIC_UMAMI_SITE_ID= +NEXT_PUBLIC_UMAMI_APP_ID= +# Your own Chatwoot, if any: a chat widget on the coordinator app. Blank = no widget. +NEXT_PUBLIC_CHATWOOT_URL= +NEXT_PUBLIC_CHATWOOT_TOKEN= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d12cde --- /dev/null +++ b/.gitignore @@ -0,0 +1,83 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# the hosted deploy hardlinks the running build and node_modules in here so a failed deploy can be +# undone, and deletes it again when the deploy ends. While one is in flight it is ~100k untracked +# files sitting in the work tree, which would otherwise drown `git status` on the production box. +/.deploy-prev/ + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +# written by the hosted deploy on the box +.release.json + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts +.env +.env.* +!.env.example + +# Android / Capacitor build output. The project itself is committed; its build products are not. +android/.gradle/ +android/build/ +android/app/build/ +android/capacitor-cordova-android-plugins/build/ +android/local.properties +android/app/src/main/assets/public/ + +# The staff app's native project. Same rules: sources are committed, build output and the +# capacitor-copied web assets are not. +android-staff/.gradle/ +android-staff/build/ +android-staff/app/build/ +android-staff/capacitor-cordova-android-plugins/build/ +android-staff/local.properties +android-staff/app/src/main/assets/public/ +*.aab +*.apk +*.jks +*.keystore +keystore.properties + +# Photo storage: signatures and damage photographs live on disk, not in the repo or the database. +.photos/ +# session scratch, never committed +.scratch/ + +# scratch trees made by the community export +/.community-build.* +.next-build/ +.next-swap/ diff --git a/COMMUNITY_VERSION b/COMMUNITY_VERSION new file mode 100644 index 0000000..f39a634 --- /dev/null +++ b/COMMUNITY_VERSION @@ -0,0 +1 @@ +community 2026-09-15 2d04e45 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..c8c21ca --- /dev/null +++ b/LICENSE @@ -0,0 +1,105 @@ +# Functional Source License, Version 1.1, ALv2 Future License + +## Abbreviation + +FSL-1.1-ALv2 + +## Notice + +Copyright 2026 ThreadCount (threadcount.tech) + +## Terms and Conditions + +### Licensor ("We") + +The party offering the Software under these Terms and Conditions. + +### The Software + +The "Software" is each version of the software that we make available under +these Terms and Conditions, as indicated by our inclusion of these Terms and +Conditions with the Software. + +### License Grant + +Subject to your compliance with this License Grant and the Patents, +Redistribution and Trademark clauses below, we hereby grant you the right to +use, copy, modify, create derivative works, publicly perform, publicly display +and redistribute the Software for any Permitted Purpose identified below. + +### Permitted Purpose + +A Permitted Purpose is any purpose other than a Competing Use. A Competing Use +means making the Software available to others in a commercial product or +service that: + +1. substitutes for the Software; + +2. substitutes for any other product or service we offer using the Software + that exists as of the date we make the Software available; or + +3. offers the same or substantially similar functionality as the Software. + +Permitted Purposes specifically include using the Software: + +1. for your internal use and access; + +2. for non-commercial education; + +3. for non-commercial research; and + +4. in connection with professional services that you provide to a licensee + using the Software in accordance with these Terms and Conditions. + +### Patents + +To the extent your use for a Permitted Purpose would necessarily infringe our +patents, the license grant above includes a license under our patents. If you +make a claim against any party that the Software infringes or contributes to +the infringement of any patent, then your patent license to the Software ends +immediately. + +### Redistribution + +The Terms and Conditions apply to all copies, modifications and derivatives of +the Software. + +If you redistribute any copies, modifications or derivatives of the Software, +you must include a copy of or a link to these Terms and Conditions and not +remove any copyright notices provided in or with the Software. + +### Disclaimer + +THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR +PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT. + +IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE +SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES, +EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE. + +### Trademarks + +Except for displaying the License Details and identifying us as the origin of +the Software, you have no right under these Terms and Conditions to use our +trademarks, trade names, service marks or product names. + +## Grant of Future License + +We hereby irrevocably grant you an additional license to use the Software under +the Apache License, Version 2.0 that is effective on the second anniversary of +the date we make the Software available. On or after that date, you may use the +Software under the Apache License, Version 2.0, in which case the following +will apply: + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. + +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..da82152 --- /dev/null +++ b/README.md @@ -0,0 +1,157 @@ +

ThreadCount

+ +# ThreadCount + +Uniform stock management for hospital, aged-care and clinic linen rooms. It tracks what is on the +shelf, who was issued what, what each ward was charged, and the supplier orders and stocktakes in +between. It was written by a uniform coordinator for their own linen room. + +This is the Community edition: the same product that runs at [threadcount.tech](https://threadcount.tech), +packaged for a facility or health service that wants to run it on its own server. Every feature a +room uses is in it. Install takes about five minutes on a server that already has Docker; the +steps are below, and the longer guide is [docs/self-hosting.md](docs/self-hosting.md). + +## What you get + +- **The coordinator app** at `/app`: catalogue, stock, issuing against entitlement, orders and + receiving, stocktakes, nine reports, CSV import and export, full JSON backup and restore. +- **The phone counter** at `/m`: camera barcode scanning, issue at the counter, count by shelf, + pickups and delivery rounds. +- **The staff app** at `/my`: staff see their own kit, request garments, managers approve. +- The two Android apps on Google Play can be pointed at your server (see below). + +Not included: the threadcount.tech website, multi-site health-service features, single sign-on, +and card payments. Those belong to the hosted service. + +## Or let us host it + +If you would rather not run a server, the hosted service is the same product with backups kept +for 35 days, a 99.5% availability target, support response times in writing, and card or invoice +billing. A room under 60 staff records is hosted free; a facility is $129 a month or $1,290 a +year after a 30-day trial with no card. See [threadcount.tech/pricing](https://threadcount.tech/pricing) +and the [Service Level Agreement](https://threadcount.tech/sla). + +## Requirements + +- A Linux server with Docker and Docker Compose (2 CPU, 2 GB RAM is plenty to start). +- A hostname pointing at it, with HTTPS in front (Caddy, nginx or Traefik). The app sets secure + cookies, so sign-in will not work over plain HTTP from another machine. + +## Install + +```sh +git clone https://github.com/pricehq/threadcount-community.git +cd threadcount-community +cp .env.example .env +``` + +Open `.env` and set these four. Everything else can stay blank. + +| Setting | What to put | +|---|---| +| `SESSION_SECRET` | A long random string. `openssl rand -base64 48` makes one. The server refuses to start with the placeholder. | +| `POSTGRES_PASSWORD` | Any long password. It is only used between the two containers. | +| `NEXT_PUBLIC_SITE_URL` | The address people will type, for example `https://uniforms.example.health`. | +| `EDITION` | `community` | + +Then build and start it: + +```sh +docker compose up -d --build +``` + +The first start takes a few minutes: it builds the image, creates the database and applies the +schema. When `docker compose ps` shows the `app` container as healthy, it is ready. + +By default the app listens on **port 3000** on the server (change it with `APP_PORT` in `.env`). +Point your HTTPS proxy at it. A Caddyfile for that is two lines: + +``` +uniforms.example.health { + reverse_proxy 127.0.0.1:3000 +} +``` + +## First run + +There is no default username or password. The first person to sign up creates the facility and +becomes its administrator. + +1. Open your address in a browser. `/` sends you to `/auth`, the sign-in page. +2. Click **Create account**. Enter your name, the facility name, your email and a password. +3. You are now signed in as the facility's admin and land on the dashboard. +4. Go to **Settings**. Name your staff groups first (for example Registered Nurse, Enrolled Nurse, + Support Services). Nothing can be issued until a facility has groups. +5. Still in Settings, open **Data** and load your catalogue, departments, staff register and + opening stock from CSV. Templates for each file are on that screen. +6. Add a second administrator under **Settings → Account → Users** before you sign out. If the + only admin forgets their password and no email is configured, nobody can get back in. +7. Once your facility exists, set `SIGNUPS_DISABLED=1` in `.env` and run `docker compose up -d` + again. Nobody else can create a facility on your server after that. + +Admins and issuers are both created under Settings → Account → Users. An admin can do everything; +an issuer works the counter but cannot change settings, reorder levels or barcodes. + +## Phones and the Android apps + +The phone counter and the staff app are the same server, on a phone: + +- `https://your-host/m` for the counter (camera scanning works in Chrome and Edge) +- `https://your-host/my` for staff + +The Play apps (**ThreadCount** for the counter, **ThreadCount Staff** for staff) can use your server +too. On the app's first screen tap "Server: threadcount.tech · Change", choose Self-hosted and enter +your hostname. The app checks it, saves it on that phone, and opens your server from then on. + +## Email + +Set the `SMTP_*` values in `.env` if you want password resets, manager approval links and +ready-to-collect notices by email. Without them the product still works; those things happen at +the counter, and the screens say so. + +## Backups + +`docker/backup.sh /path/to/backups` dumps the database and the photos into a dated folder and +keeps the last fourteen. Run it nightly from cron and copy the folder off the server. An admin can +also download the whole facility as one file from Settings → Data at any time. + +Restore steps are in [docs/self-hosting.md](docs/self-hosting.md). + +## Updating + +Each release replaces the repository's history rather than adding to it, so a plain `git pull` +refuses to merge. Fetch and move to the release instead. Your `.env` is not tracked and stays put. + +```sh +git fetch origin +git reset --hard origin/main +docker compose up -d --build +``` + +Schema changes are applied automatically before the new version starts. + +## Configuration + +Every setting is listed with a comment in `.env.example`. The ones most people touch: +`NEXT_PUBLIC_TERMS_URL` and `NEXT_PUBLIC_PRIVACY_URL` (point the product's terms and privacy links +at your own documents), `SMTP_*`, `SIGNUPS_DISABLED`, `APP_PORT`, and the two Turnstile keys if you +want Cloudflare's bot check on sign-in. + +## Licence + +Functional Source License 1.1 with Apache 2.0 as the future licence (FSL-1.1-ALv2). You can run +it for your own organisation, read it, change it and share your changes. You cannot offer it to +others as a competing uniform-management service. Each release becomes Apache 2.0 two years after +publication. Full text in [LICENSE](LICENSE). + +## Security + +Found a vulnerability? Email security@threadcount.tech rather than opening a public issue. The +[security page](https://threadcount.tech/security) describes how the hosted service is run and +what the questionnaire answers; a Community instance inherits the same code and the practices in +[docs/self-hosting.md](docs/self-hosting.md) are the ones that matter for yours. + +## Help + +Open an issue on this repository. If you would rather not run a server at all, the hosted service +is at [threadcount.tech](https://threadcount.tech). 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/app-info/route.ts b/app/api/app-info/route.ts new file mode 100644 index 0000000..a9ca6e9 --- /dev/null +++ b/app/api/app-info/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { readFileSync } from "fs"; +import path from "path"; +import { COMMUNITY } from "@/lib/edition"; + +export const dynamic = "force-dynamic"; + +/* What the Android apps ask a server before they will point at it. + * + * The apps open threadcount.tech unless told otherwise; a room running the Community edition types + * its own address into the app's first screen, and the app calls this first. It proves the address + * is a ThreadCount server (not a look-alike, not a typo), says which edition and build, and carries + * the oldest app version this build still works with, so an app can say "update me" instead of + * breaking quietly. Public and unauthenticated on purpose: nothing here is about a facility. */ +function version(): string { + try { return readFileSync(path.join(process.cwd(), "COMMUNITY_VERSION"), "utf8").trim(); } catch { /* hosted: no file */ } + return process.env.NEXT_PUBLIC_RELEASE || "hosted"; +} + +export async function GET() { + return NextResponse.json({ + product: "threadcount", + edition: COMMUNITY ? "community" : "hosted", + version: version(), + paths: { counter: "/m", staff: "/my" }, + // The oldest Play versionCode of each app this server still serves correctly. + minApp: { counter: 9, staff: 7 }, + }, { headers: { "cache-control": "no-store", "access-control-allow-origin": "*" } }); +} diff --git a/app/api/auth/2fa/route.ts b/app/api/auth/2fa/route.ts new file mode 100644 index 0000000..fc8b8cf --- /dev/null +++ b/app/api/auth/2fa/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { cookies } from "next/headers"; +import { REMEMBER_MAX_AGE, pwVersion, setSessionCookie } from "@/lib/session"; +import { sameOriginJson } from "@/lib/csrf"; +import { allow, clientIp } from "@/lib/ratelimit"; +import { decryptSecret, hashRecoveryCode, totpVerify } from "@/lib/totp"; +import { TRUST_COOKIE, TRUST_TTL_MS, mintTrust, readTicket } from "@/lib/twofactor"; +import { recordAuthEvent } from "@/lib/audit"; + +export const dynamic = "force-dynamic"; + +/* Second step of sign-in: the code from the authenticator, or one recovery code. + * + * Rate limited hard. A six-digit code is one in a million per guess, which is only meaningful if + * guessing is expensive — unthrottled, a million tries is minutes of work. */ +export async function POST(req: NextRequest) { + const csrf = sameOriginJson(req); + if (csrf) return NextResponse.json({ error: csrf }, { status: 403 }); + + const ip = clientIp(req.headers); + let body: { ticket?: unknown; code?: unknown; trust?: unknown; remember?: unknown }; + try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); } + + const t = readTicket(String(body.ticket ?? "")); + if (!t) return NextResponse.json({ error: "That sign-in has expired. Start again." }, { status: 400 }); + + // Per account and per address: one stolen ticket can't be brute-forced, and one machine can't + // work through several accounts at once. + if (!allow("2fa-user:" + t.uid, 10, 15 * 60 * 1000) || !allow("2fa-ip:" + ip, 300, 15 * 60 * 1000)) { + return NextResponse.json({ error: "Too many attempts — try again in a few minutes." }, { status: 429 }); + } + + const u = await prisma.user.findUnique({ + where: { id: t.uid }, + select: { id: true, facilityId: true, email: true, first: true, last: true, role: true, inactive: true, passwordHash: true, totpSecret: true, totpEnabledAt: true }, + }); + if (!u || u.inactive || !u.totpEnabledAt) { + return NextResponse.json({ error: "That sign-in has expired. Start again." }, { status: 400 }); + } + // The password changed between the two steps — the ticket is stale for the same reason a session + // would be. + if (pwVersion(u.passwordHash) !== t.pv) { + return NextResponse.json({ error: "That sign-in has expired. Start again." }, { status: 400 }); + } + + const raw = String(body.code ?? "").trim(); + const secret = decryptSecret(u.totpSecret); + let good = !!secret && totpVerify(secret, raw); + let usedRecovery = false; + + if (!good && raw.replace(/[^A-Za-z0-9]/g, "").length >= 10) { + // A recovery code. Single use: consumed in the same conditional update that finds it, so two + // simultaneous attempts can't both spend it. + const hash = hashRecoveryCode(raw); + const hit = await prisma.recoveryCode.findFirst({ where: { userId: u.id, codeHash: hash, usedAt: null }, select: { id: true } }); + if (hit) { + const consumed = await prisma.recoveryCode.updateMany({ where: { id: hit.id, usedAt: null }, data: { usedAt: new Date() } }); + good = consumed.count === 1; + usedRecovery = good; + } + } + + if (!good) return NextResponse.json({ error: "That code isn't right. Try the current one from your app." }, { status: 401 }); + + await setSessionCookie(u.id, u.passwordHash, false, body.remember === true ? REMEMBER_MAX_AGE : undefined); + // "Trust this computer": only ever set here, after a real code, never from the password step. + if (body.trust === true) { + const jar = await cookies(); + jar.set(TRUST_COOKIE, mintTrust(u.id, pwVersion(u.passwordHash)), { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/api/auth", maxAge: Math.floor(TRUST_TTL_MS / 1000) }); + } + // How they got in matters more here than anywhere else: a recovery code means the phone is gone, + // and a run of them means something else is going on. + recordAuthEvent( + { facilityId: u.facilityId, userId: u.id, userName: `${u.first} ${u.last}`.trim() || u.email }, + "auth:signin", ip, usedRecovery ? "recovery" : "totp", + ); + const left = await prisma.recoveryCode.count({ where: { userId: u.id, usedAt: null } }); + return NextResponse.json({ ok: true, name: `${u.first} ${u.last}`, role: u.role, usedRecovery, recoveryLeft: left }); +} diff --git a/app/api/auth/forgot/route.ts b/app/api/auth/forgot/route.ts new file mode 100644 index 0000000..ba9b75c --- /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, html } = resetEmail(user.first, resetUrl(token)); + const sent = await sendTo(email, subject, text, html); + // Only a mail that left the building counts. A send that failed gave the coordinator nothing, + // so charging them for it would shut them out for an hour over a mail outage. + if (sent) fail(mailKey, 60 * 60 * 1000); + else console.error("[forgot] reset requested but mail could not be sent for user", user.id); + } + } + + // Told to the caller regardless, so the answer carries no information about the address. + return NextResponse.json({ ok: true, mail: transactionalConfigured() }); +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..2284f02 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import { prisma } from "@/lib/db"; +import { REMEMBER_MAX_AGE, pwVersion, setSessionCookie } from "@/lib/session"; +import { TRUST_COOKIE, mintTicket, readTrust } from "@/lib/twofactor"; +import { sameOriginJson } from "@/lib/csrf"; +import { clientIp, fail, over } from "@/lib/ratelimit"; +import { signInStaff } from "@/lib/staffauth"; +import { verifyTurnstile } from "@/lib/turnstile"; +import { recordAuthEvent } from "@/lib/audit"; + +export const dynamic = "force-dynamic"; + +// Simple in-memory throttle per IP+email (per process). +const attempts = new Map(); + +/** The trail names the person, not the address they typed — see lib/audit.ts. */ +const actorFor = (u: { id: string; facilityId: string; first: string; last: string; email: string }) => + ({ facilityId: u.facilityId, userId: u.id, userName: `${u.first} ${u.last}`.trim() || u.email }); + +export async function POST(req: NextRequest) { + const csrf = sameOriginJson(req); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 }); + let body: { email?: string; password?: string; cfToken?: string; remember?: unknown }; + try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); } + const email = String(body.email || "").trim().toLowerCase().slice(0, 160); + const password = String(body.password || "").slice(0, 200); + // Spray protection independent of the per-(ip,email) counter below. Both buckets count only the + // attempts that FAILED — a whole hospital signs in from one NAT address at shift change, and a + // ceiling on attempts would have to lock that ward out to be worth anything against an attacker. + const ipKey = clientIp(req.headers); + if (over("login-ip:" + ipKey, 40, 15 * 60 * 1000) || (email && over("login-email:" + email, 25, 15 * 60 * 1000))) return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 }); + if (!email || !password) return NextResponse.json({ error: "Enter your email and password." }, { status: 400 }); + + // nginx appends the real client IP last; earlier entries are client-supplied and spoofable. + const xff = req.headers.get("x-forwarded-for")?.split(",").map((x) => x.trim()).filter(Boolean) || []; + const ip = xff[xff.length - 1] || "local"; + if (attempts.size > 5000) for (const [kk, v] of attempts) if (Date.now() - v.t > 15 * 60 * 1000) attempts.delete(kk); + const k = `${ip}|${email}`; + const a = attempts.get(k); + if (a && a.n >= 8 && Date.now() - a.t < 15 * 60 * 1000) return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 }); + + const cfErr = await verifyTurnstile(body.cfToken, ipKey); if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 }); + const u = await prisma.user.findUnique({ where: { email } }); + /* One box, both kinds of account. + * + * A wearer reaches the product the way anyone else does — the home page, then Log in — and types + * the details they set up in the staff app. So when this address has no coordinator account, the + * register is asked before the answer is called wrong. + * + * A coordinator account always wins: it is the one with the counter, the orders and the register + * behind it, and a coordinator who also wears a uniform can open their own record from inside the + * app. One address therefore has one destination, every time. + * + * This is a lookup, not a second attempt. "Try the coordinator, and if that fails try the staff + * one" would score a failure against every single staff sign-in, and these ceilings count + * failures — behind one hospital's NAT address at shift change that is a locked-out ward. + */ + if (!u) { + const s = await signInStaff(email, password, ipKey, false); + if (s.kind === "ok") return NextResponse.json({ ok: true, name: s.name, staff: true }); + if (s.kind === "error") return NextResponse.json({ error: s.error }, { status: s.status }); + // `none`: no staff account either, so this falls through to the answer below, which counts the + // failure once and says the same thing it has always said. + } + const ok = u ? await bcrypt.compare(password, u.passwordHash) : await bcrypt.compare(password, "$2b$12$C6UzMDM.H6dfI/f/IKcEeO5x3FvDS3kqB6r0Jt3g7Lz0vX4o0JZ1u"); + if (!u || !ok) { + attempts.set(k, { n: (a && Date.now() - a.t < 15 * 60 * 1000 ? a.n : 0) + 1, t: Date.now() }); + fail("login-ip:" + ipKey, 15 * 60 * 1000); + if (email) fail("login-email:" + email, 15 * 60 * 1000); + // An address with no account here is recorded nowhere: there is no facility to file it under, + // and a log of attempts on addresses that don't exist would be a list of other people's email + // addresses that nobody asked us to keep. + if (u) recordAuthEvent(actorFor(u), "auth:signin.failed", ipKey); + return NextResponse.json({ error: "Email or password doesn’t match." }, { status: 401 }); + } + attempts.delete(k); + if (u.inactive) { + // The right password on an account that has been taken away is worth knowing about. + recordAuthEvent(actorFor(u), "auth:signin.refused", ipKey, "inactive"); + return NextResponse.json({ error: "This account has been deactivated. Ask an admin at your facility to reactivate it." }, { status: 403 }); + } + const fac = await prisma.facility.findUnique({ where: { id: u.facilityId }, select: { isDemo: true, ssoEnabled: true, ssoRequired: true } }); + if (fac?.isDemo) return NextResponse.json({ error: "Demo accounts can’t log in here — open the demo from the home page." }, { status: 403 }); + // The facility has decided its people sign in through its own identity provider. The password + // was right, and it is still refused — except for the admin the facility keeps as its fire + // escape. The box sends them on to single sign-on rather than reporting a failure. + if (fac?.ssoEnabled && fac.ssoRequired && !u.ssoBreakGlass) { + recordAuthEvent(actorFor(u), "auth:signin.refused", ipKey, "sso required"); + return NextResponse.json({ error: "Your facility signs in with single sign-on.", ssoRequired: true }, { status: 403 }); + } + // With a second factor on the account the password alone opens nothing. The ticket says only + // "this password was correct", is accepted by no other endpoint, and expires in five minutes. + // A browser that entered a code within the last thirty days and asked to be trusted skips it; + // the trust token is bound to the password version, so a changed password asks again. + const trusted = !!u.totpEnabledAt && readTrust(req.cookies.get(TRUST_COOKIE)?.value, u.id, pwVersion(u.passwordHash)); + if (u.totpEnabledAt && !trusted) { + return NextResponse.json({ need2fa: true, ticket: mintTicket(u.id, pwVersion(u.passwordHash)) }); + } + + await setSessionCookie(u.id, u.passwordHash, false, body.remember === true ? REMEMBER_MAX_AGE : undefined); + recordAuthEvent(actorFor(u), "auth:signin", ipKey, trusted ? "password+trusted" : "password"); + return NextResponse.json({ ok: true, name: `${u.first} ${u.last}`, role: u.role }); +} 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/lookup/route.ts b/app/api/auth/lookup/route.ts new file mode 100644 index 0000000..df84cdd --- /dev/null +++ b/app/api/auth/lookup/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { sameOriginJson } from "@/lib/csrf"; +import { allow, clientIp } from "@/lib/ratelimit"; + +export const dynamic = "force-dynamic"; + +/* Which door does this address belong at? + * + * The sign-in screen asks for the address first and only then shows a password box, a single + * sign-on button or a pointer to the staff app. This answers the last of those: an address that + * has no coordinator account but does have a staff-app account belongs in the staff app, and + * telling the person so beats a "wrong password" they can never get past. It answers nothing about + * coordinator accounts — a coordinator address and an unknown address get the same reply, so the + * box cannot be used to test which addresses have one. Throttled per connection like the SSO lookup. */ +export async function POST(req: NextRequest) { + const csrf = sameOriginJson(req); + if (csrf) return NextResponse.json({ error: csrf }, { status: 403 }); + if (!allow("lookup:" + clientIp(req.headers), 60, 15 * 60 * 1000)) return NextResponse.json({ staff: false }); + let body: { email?: unknown }; + try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); } + const email = String(body.email ?? "").trim().toLowerCase().slice(0, 160); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return NextResponse.json({ staff: false }); + const user = await prisma.user.findUnique({ where: { email }, select: { id: true } }); + if (user) return NextResponse.json({ staff: false }); + const acc = await prisma.staffAccount.findUnique({ where: { email }, select: { id: true } }); + return NextResponse.json({ staff: !!acc }); +} 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..d26a353 --- /dev/null +++ b/app/api/auth/signup/route.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import { prisma } from "@/lib/db"; +import { setSessionCookie } from "@/lib/session"; +import { allow, clientIp } from "@/lib/ratelimit"; +import { sameOriginJson } from "@/lib/csrf"; +import { verifyTurnstile } from "@/lib/turnstile"; +import { sendTo, transactionalConfigured } from "@/lib/mail"; +import { switches } from "@/lib/switches"; +import { alertNewSignup } from "@/lib/ops/alerts"; +import { recordAuthEvent } from "@/lib/audit"; +import { TRIAL_DAYS } from "@/lib/plan"; +import { sendBillingMail, templates } from "@/lib/billing-mail"; +import { welcomeEmail } from "@/lib/accountmail"; + +export const dynamic = "force-dynamic"; + +/* Starting staff groups by healthcare setting. Generic titles only — every room renames them. */ +const GROUP_SEEDS: Record = { + hospital: { staffGroups: ["Registered Nurse", "Enrolled Nurse", "Allied Health", "Support Services", "Security"], nursingGroups: ["Registered Nurse", "Enrolled Nurse"], kitGroups: ["Support Services"] }, + aged_care: { staffGroups: ["Registered Nurse", "Enrolled Nurse", "Personal Care Worker", "Hospitality", "Maintenance"], nursingGroups: ["Registered Nurse", "Enrolled Nurse"], kitGroups: ["Hospitality", "Maintenance"] }, + community: { staffGroups: ["Registered Nurse", "Enrolled Nurse", "Allied Health", "Administration"], nursingGroups: ["Registered Nurse", "Enrolled Nurse"], kitGroups: ["Administration"] }, +}; +const STATE_ZONES: Record = { + QLD: "Australia/Brisbane", NSW: "Australia/Sydney", ACT: "Australia/Sydney", VIC: "Australia/Melbourne", TAS: "Australia/Hobart", + SA: "Australia/Adelaide", NT: "Australia/Darwin", WA: "Australia/Perth", NZ: "Pacific/Auckland", +}; + +/* Creating a facility. + * + * The address typed here is not verified, and deliberately isn't: a confirmation step in front of + * a linen room's first ten minutes is a wall, and a facility half-created behind an unclicked link + * is worse than one created. But it is the *only* way back in — /api/auth/forgot answers a + * stranger and the owner identically, so a typo produces no signal at all until the day the + * password is forgotten, and by then the facility is unreachable and undeletable. + * + * So the address is exercised immediately instead. A note goes to it saying, in as many words, + * that this is the address that recovers the account, and the answer here says whether it was + * sent — which is what lets the sign-up screen show the address back and tell someone who never + * receives it what to do about it while they are still signed in and can still act. + */ + +export async function POST(req: NextRequest) { + const sw = await switches(); + if (!sw.signupsOpen) return NextResponse.json({ error: "New facility sign-ups are closed." }, { status: 403 }); + const csrf = sameOriginJson(req); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 }); + if (!allow("signup:" + clientIp(req.headers), 5, 60 * 60 * 1000)) return NextResponse.json({ error: "Too many sign-ups from this connection — try again later." }, { status: 429 }); + let b: Record; + try { b = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); } + const first = String(b.first || "").trim().slice(0, 80), last = String(b.last || "").trim().slice(0, 80); + const facility = String(b.facility || "").trim().slice(0, 120); + const email = String(b.email || "").trim().toLowerCase().slice(0, 160); + const password = String(b.password || ""); + if (!first || !last || !facility) return NextResponse.json({ error: "Name and facility are required." }, { status: 400 }); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return NextResponse.json({ error: "Enter a valid work email." }, { status: 400 }); + if (password.length < 8) return NextResponse.json({ error: "Password must be at least 8 characters." }, { status: 400 }); + const cfErr = await verifyTurnstile(b.cfToken, clientIp(req.headers)); if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 }); + if (await prisma.user.findUnique({ where: { email } })) return NextResponse.json({ error: "That email already has an account — log in instead." }, { status: 409 }); + + const u = await prisma.$transaction(async (tx) => { + // No staff groups: the facility names its own. Any list handed over here would be one employer's + // organisation chart on another employer's register, and a group sitting on a route nobody + // chose decides who is handed a starting kit. Both route lists start empty with it, so until the + // coordinator puts a group on the FTE table or the starting kit, everybody is on manager approval + // and nobody has been promised a kit the counter would not hand over. + // Until plans are live the page still says free, so a facility created today is grandfathered: + // free with everything, for good. Once they are live a new room starts on the plan it chose — + // Hosted Small, free, or a Hosted Facility trial with its end date set now. Anything else + // sent as `plan` is Hosted Small: the free room is the safe misreading. + const trial = sw.plansLive && b.plan === "hosted_facility"; + const planData = !sw.plansLive + ? { grandfathered: true, planStatus: "free" } + : trial + ? { plan: "hosted_facility", planStatus: "trial", trialEndsAt: new Date(Date.now() + TRIAL_DAYS * 86_400_000) } + : { plan: "hosted_small", planStatus: "free" }; + // Two optional answers from the sign-up screen. The setting seeds the staff groups the room + // starts with (renamed or removed freely under Settings); the state sets the time zone counts + // and month-end are read in. Neither is required, and "other"/blank leaves the old defaults. + const seed = GROUP_SEEDS[String(b.setting || "")] || {}; + const timezone = STATE_ZONES[String(b.state || "").toUpperCase()]; + const f = await tx.facility.create({ data: { name: facility, coordinator: `${first} ${last}`, ...planData, ...seed, ...(timezone ? { timezone } : {}) } }); + return tx.user.create({ data: { facilityId: f.id, email, passwordHash: await bcrypt.hash(password, 12), first, last, title: "Uniform Coordinator", role: "ADMIN" } }); + }); + await setSessionCookie(u.id, u.passwordHash); + recordAuthEvent({ facilityId: u.facilityId, userId: u.id, userName: `${first} ${last}`.trim() || email }, "auth:signup", clientIp(req.headers)); + alertNewSignup({ id: u.facilityId, name: facility }); // the facility's name only — never the person + + const em = welcomeEmail(first, facility); + const mailed = await sendTo(email, em.subject, em.text, em.html); + // A room on a trial also gets the trial letter: what the 30 days include, when they end, and + // that no card was taken. Not awaited — the welcome above is the one sign-up waits for. + void (async () => { + const f = await prisma.facility.findUnique({ where: { id: u.facilityId }, select: { planStatus: true, trialEndsAt: true } }); + if (f?.planStatus === "trial" && f.trialEndsAt) await sendBillingMail(u.facilityId, (ctx) => templates.trialStarted(ctx, { first, endsAt: f.trialEndsAt })); + })(); + if (!mailed && transactionalConfigured()) console.error("[signup] welcome mail could not be sent for user", u.id); + // `mailed` is false when no SMTP is configured at all, which is a different thing from a bad + // address — the screen says so rather than pretending the address has been proven. + return NextResponse.json({ ok: true, email, mailed, mail: transactionalConfigured() }); +} 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/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/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/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/app/activity/page.tsx b/app/app/activity/page.tsx new file mode 100644 index 0000000..8deb9b7 --- /dev/null +++ b/app/app/activity/page.tsx @@ -0,0 +1,14 @@ +import { redirect } from "next/navigation"; + +/* The activity log now lives in Settings › Data & audit log. next.config.ts redirects this path as + * well; this stub keeps old links working if that map ever changes. Query strings carry over. */ +export default async function Activity({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) { + const sp = await searchParams; + const q = new URLSearchParams(); + for (const [k, v] of Object.entries(sp)) { + if (k === "tab" || v === undefined) continue; + for (const one of Array.isArray(v) ? v : [v]) q.append(k, one); + } + q.set("tab", "audit"); + redirect(`/app/settings?${q.toString()}`); +} diff --git a/app/app/counter/page.tsx b/app/app/counter/page.tsx new file mode 100644 index 0000000..1781993 --- /dev/null +++ b/app/app/counter/page.tsx @@ -0,0 +1,8 @@ +"use client"; +import { Suspense } from "react"; +import Counter from "@/components/counter/Counter"; + +// useSearchParams (?staff=, ?mode=) needs a Suspense boundary for static rendering. +export default function CounterPage() { + return ; +} diff --git a/app/app/help/[section]/[slug]/page.tsx b/app/app/help/[section]/[slug]/page.tsx new file mode 100644 index 0000000..0aa803a --- /dev/null +++ b/app/app/help/[section]/[slug]/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import ManualShell from "@/components/ManualShell"; +import { ManualArticle } from "@/components/ManualView"; +import { findPage, neighbours } from "@/lib/manual"; + +/* One manual page inside the app: the same Markdown the website renders at /docs, framed by the + * app's own shell. The help mark on each screen links straight here. */ + +type Params = { params: Promise<{ section: string; slug: string }> }; + +export async function generateMetadata({ params }: Params): Promise { + const { section, slug } = await params; + const p = findPage(section, slug); + return { title: p ? `${p.title} · Help` : "Help", robots: { index: false, follow: false } }; +} + +export default async function HelpPage({ params }: Params) { + const { section, slug } = await params; + const p = findPage(section, slug); + if (!p) notFound(); + const { prev, next } = neighbours(p); + return ( + + + + ); +} diff --git a/app/app/help/page.tsx b/app/app/help/page.tsx new file mode 100644 index 0000000..fed9f65 --- /dev/null +++ b/app/app/help/page.tsx @@ -0,0 +1,26 @@ +import ManualHome from "@/components/ManualHome"; +import ManualShell from "@/components/ManualShell"; +import FacilityRules from "@/components/FacilityRules"; + +export const metadata = { title: "Help", robots: { index: false, follow: false } }; + +/* Help inside the app: the manual's front page, with this facility's own rules above it. The rules + * panel reads the facility's settings, so a figure a coordinator has changed is the figure shown; + * the manual pages quote the defaults and say where each one is changed. */ +export default function Help() { + return ( + + +
+

00This facility’s rules

+

Read from your settings, so these are the figures the counter applies today.

+ +
+
+
+ ); +} diff --git a/app/app/issue/page.tsx b/app/app/issue/page.tsx new file mode 100644 index 0000000..5432bdd --- /dev/null +++ b/app/app/issue/page.tsx @@ -0,0 +1,13 @@ +import { redirect } from "next/navigation"; + +/* Issue Stock became the Counter. Old links, bookmarks and badge scans keep their query string. */ +export default async function IssueRedirect({ searchParams }: { searchParams: Promise> }) { + const sp = await searchParams; + const q = new URLSearchParams(); + for (const [k, v] of Object.entries(sp)) { + if (Array.isArray(v)) v.forEach((x) => q.append(k, x)); + else if (v !== undefined) q.set(k, v); + } + const qs = q.toString(); + redirect(`/app/counter${qs ? `?${qs}` : ""}`); +} diff --git a/app/app/layout.tsx b/app/app/layout.tsx new file mode 100644 index 0000000..c334df9 --- /dev/null +++ b/app/app/layout.tsx @@ -0,0 +1,35 @@ +import { redirect } from "next/navigation"; +import { currentUser } from "@/lib/session"; +import { buildSnapshot } from "@/lib/snapshot"; +import { prisma } from "@/lib/db"; +import { SnapshotProvider } from "@/lib/client"; +import type { ServerCounts } from "@/lib/portalcounts"; +import Shell from "@/components/Shell"; +import Analytics from "@/components/Analytics"; +import Helpdesk from "@/components/Helpdesk"; + +export const dynamic = "force-dynamic"; + +export default async function AppLayout({ children }: { children: React.ReactNode }) { + const user = await currentUser(); + if (!user) redirect("/auth"); + const facilityId = user.facilityId; + // The four rail counts the snapshot cannot make: requests, record queries and damage live in + // their own tables and are never loaded into the snapshot. mutate()'s router.refresh() re-runs + // this layout, so the badges follow every write. + const [snap, pick, stranded, queries, damage] = await Promise.all([ + buildSnapshot(user), + prisma.request.count({ where: { facilityId, status: "accepted" } }), + prisma.request.count({ where: { facilityId, status: "awaiting", managerName: "" } }), + prisma.recordDispute.count({ where: { facilityId, resolvedAt: null } }), + prisma.damageReport.count({ where: { facilityId, handedInAt: null } }), + ]); + const serverCounts: ServerCounts = { pick, stranded, queries, damage }; + return ( + + {children} + + + + ); +} diff --git a/app/app/orders/[id]/page.tsx b/app/app/orders/[id]/page.tsx new file mode 100644 index 0000000..43a6e6f --- /dev/null +++ b/app/app/orders/[id]/page.tsx @@ -0,0 +1,355 @@ +"use client"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; +import { useDerived, useSnap } from "@/lib/client"; +import { PageHead, Empty, Field, ItemSizePicker, LiveRegion } from "@/components/ui"; +import { ReceiveDialog } from "@/components/dialogs"; +import { Figures, MoreMenu, Panel, QtyStepper, Tag } from "@/components/portal"; +import { Crumb, OrdersStyles } from "@/components/orders/bits"; +import { viewPhoto } from "@/lib/photo"; +import { key, supplierCodeOf, ccBudgetNote, ccFor, ccOfOrder, csvOf, daysBetween, fmtDate, isOverdue, label, money, orderTotal, staffName, statusTag, supplierInfo, csvEsc } from "@/lib/compute"; +import { downloadCsv, esc, openPrintWindow } from "@/lib/print"; + +export default function OrderDetail() { + const { id } = useParams<{ id: string }>(); + const { s, isAdmin, mutate } = useSnap(); + const { byId, staffById } = useDerived(); + const router = useRouter(); + const o = s.orders.find((x) => x.id === id); + const [rcv, setRcv] = useState(false); + const [err, setErr] = useState(""); + const [pick, setPick] = useState(""); + const [priceDraft, setPriceDraft] = useState>({}); + 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; + }); + }); + + const [mailMsg, setMailMsg] = useState(""); + + 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 + (onScreen.ref ? "-" + onScreen.ref.replace(/[^A-Za-z0-9-]+/g, "_") : "")).toLowerCase() + ".csv", `Order,${csvEsc(onScreen.code)}\nSupplier,${csvEsc(onScreen.supplier)}\nRef,${csvEsc(onScreen.ref)}\n\n` + csvOf(["Item", "Supplier code", "SKU", "Size", "Qty", "Unit cost", "Total"], onScreen.lines.map((l) => { const it = byId[l.itemId]; return [label(it), supplierCodeOf(s, key(l.itemId, it ? it.sizes.map(String).indexOf(l.size) : -1)), it?.sku || "", l.size, l.qty, +unitOf(l).toFixed(2), lineAmt[l.id].toFixed(2)]; }))); + } + async function emailSupplier() { + setMailMsg("Sending…"); + const r = await mutate<{ sentTo: string }>("order.email", { id: o!.id }); + setMailMsg(r.ok ? `Sent to ${r.result.sentTo}` : r.error); + } + async function duplicate() { + if (!(await flushQty())) return; + const r = await mutate<{ id: string }>("order.duplicate", { id: o!.id }); + if (!r.ok) { setErr(r.error); return; } + router.push(`/app/orders/${r.result.id}`); + } + + return ( +
+ + {forLabel} · {onScreen.supplier} · placed {fmtDate(o.date)}{o.replenish ? " · replenishment" : ""}{parent && <> · back order of {parent.code}}} + below={
{overdue && Overdue}{o.status}
} + > + {o.status === "Draft" && isAdmin && } + {["Ordered", "Shipped", "Back Order"].includes(o.status) && } + {isAdmin && { void mutate("order.printed", { id: o.id }); }}>Order sheet} + { void act("order.status", { id: o.id, status: "Shipped" }); }, hidden: !(isAdmin && ["Ordered", "Back Order"].includes(o.status)) }, + { label: "Duplicate", onSelect: () => { void duplicate(); } }, + { label: "Cancel order", danger: true, hidden: !(isAdmin && ["Draft", "Ordered", "Back Order", "Shipped"].includes(o.status)), onSelect: async () => { if (confirm(`Cancel ${o.code}?`) && await flushQty()) act("order.status", { id: o.id, status: "Cancelled" }); } }, + ]} /> +
+ + + + {onScreen.lines.length} line{onScreen.lines.length === 1 ? "" : "s"} }, + { value: `${got} of ${units}`, label: "Units received" }, + { value: 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 : undefined }, + ]} /> +
+
+ +
+ {([["ref", "Supplier order no.", "text", "e.g. NW-48211"], ["invoice", "Invoice no.", "text", "e.g. INV-102938"], ["tracking", "Tracking no.", "text", "e.g. 34XY990812"], ["expected", "Expected delivery", "date", ""]] as const).map(([k, lbl, type, ph]) => ( + {(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)} />} +
+
+
+ {units} unit{units === 1 ? "" : "s"} ordered} + foot={<>Total{money(total)}}> +
+ {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)} · {l.size}
+
{supplierCodeOf(s, key(l.itemId, it ? it.sizes.map(String).indexOf(l.size) : -1)) && {supplierCodeOf(s, key(l.itemId, it ? it.sizes.map(String).indexOf(l.size) : -1))}}{rec ? <> · received {rec} : ""}{Math.abs(unit - catCost) > 0.004 && · invoice price}
+
+
+ {o.status === "Draft" + ? bumpQty(l.id, l.qty, n - 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 a line + { if (await flushQty()) act("order.lineAdd", { id: o.id, itemId: it.id, size: it.sizes[si], qty: 1 }); }} /> +
+ )} +
+ {backOrders.length > 0 &&
Back order{backOrders.length > 1 ? "s" : ""}: {backOrders.map((b) => {b.code})}
} +
+
+
+ + {ev.map((e, i) => ( +
+
{e.date ? fmtDate(e.date) : "—"}
+
+
{e.what}{e.photoId && }
+ {e.sub &&
{e.sub}
} +
+
+ ))} +
+ {st && ( + +
+
{staffName(st)} {st.num}
+
{st.dept} · {st.phone || "no phone"}
+
+
+ )} +
+
+ {rcv && { setRcv(false); router.refresh(); }} />} +
+ ); +} diff --git a/app/app/orders/all/page.tsx b/app/app/orders/all/page.tsx new file mode 100644 index 0000000..693a837 --- /dev/null +++ b/app/app/orders/all/page.tsx @@ -0,0 +1,110 @@ +"use client"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { useDerived, useSnap } from "@/lib/client"; +import { Field, PageHead } from "@/components/ui"; +import { Panel, Seg, SelectButton, Tag } from "@/components/portal"; +import { ccOfOrder, csvOf, daysBetween, fmtDate, isOverdue, isPlacedOpen, label, money, orderTotal, staffName, statusTag } from "@/lib/compute"; +import { downloadCsv } from "@/lib/print"; +import { Crumb, NewOrder, OrdersStyles, shortDate } from "@/components/orders/bits"; + +const STATUSES = ["All", "Draft", "Open", "Received"] as const; + +/* Every order: the filters, the status segment and the CSV that used to sit on the Ordering screen. */ +export default function OrderLedgerPage() { + const { s } = useSnap(); + const { byId, staffById } = useDerived(); + const [dlg, setDlg] = useState(false); + const [q, setQ] = useState(""); + const [sup, setSup] = useState(""); + const [status, setStatus] = useState<(typeof STATUSES)[number]>("All"); + const [from, setFrom] = useState(""); + const [to, setTo] = useState(""); + const [item, setItem] = useState(""); + + const supOpts = useMemo(() => [{ value: "", label: "All suppliers" }, ...[...new Set(s.orders.map((o) => o.supplier).filter(Boolean))].sort().map((x) => ({ value: x, label: x }))], [s.orders]); + const itemOpts = useMemo(() => { + const ids = new Set(); + for (const o of s.orders) for (const l of o.lines) ids.add(l.itemId); + return [{ value: "", label: "All garments" }, ...[...ids].map((id) => byId[id]).filter(Boolean).sort((a, b) => label(a).localeCompare(label(b))).map((it) => ({ value: it.id, label: label(it) }))]; + }, [s.orders, byId]); + + const rank = (o: (typeof s.orders)[number]) => (o.status === "Draft" ? 0 : isPlacedOpen(o) ? 1 : o.status === "Received" ? 2 : 3); + const ql = q.trim().toLowerCase(); + const orders = s.orders.filter((o) => { + if (sup && o.supplier !== sup) return false; + if (status === "Draft" && o.status !== "Draft") return false; + if (status === "Open" && !isPlacedOpen(o)) return false; + if (status === "Received" && o.status !== "Received") return false; + if (from && o.date < from) return false; + if (to && o.date > to) return false; + if (item && !o.lines.some((l) => l.itemId === item)) return false; + if (ql) { + const st = o.staffId ? staffById[o.staffId] : undefined; + const hay = `${o.code} ${o.ref} ${o.invoice} ${o.tracking} ${o.supplier} ${staffName(st)} ${o.lines.map((l) => label(byId[l.itemId])).join(" ")}`.toLowerCase(); + if (!hay.includes(ql)) return false; + } + return true; + }).sort((a, b) => rank(a) - rank(b) || (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)); + + const narrowed = ql !== "" || sup !== "" || status !== "All" || from !== "" || to !== "" || item !== ""; + + /* The rows on screen, filters applied. Value is orderTotal() so the file agrees with the screen and + Reports; dates stay ISO so a spreadsheet sorts them; notes stay out. */ + function exportCsv() { + const cols = ["Order no.", "Status", "Ordered", "Supplier", "Ordered for", "Staff member", "Supplier ref", "Invoice", "Tracking", "Cost centre", "Replenishment", "Expected", "Days overdue", "Received", "Lines", "Units ordered", "Units received", "Value"]; + downloadCsv(`threadcount-orders-${s.today}.csv`, csvOf(cols, orders.map((o) => { + const st = o.staffId ? staffById[o.staffId] : undefined; + const units = o.lines.reduce((t, l) => t + l.qty, 0); + const got = o.receipts.reduce((t, rc) => t + rc.lines.reduce((n, l) => n + l.qty, 0), 0); + return [o.code, o.status, o.date, o.supplier, o.orderFor === "Stock" ? "Stock" : "Staff member", staffName(st), o.ref, o.invoice, o.tracking, ccOfOrder(s, o, staffById), o.replenish ? "Yes" : "No", o.expected, isOverdue(o, s.today) ? daysBetween(o.expected, s.today) : "", o.received, o.lines.length, units, got, +orderTotal(o, byId).toFixed(2)]; + }))); + } + + return ( +
+ + {orders.length} of {s.orders.length}}> + + + + +
+ setQ(e.target.value)} /> + + + {(c) => setFrom(e.target.value)} />} + {(c) => setTo(e.target.value)} />} + +
+ + {orders.length === 0 &&
{s.orders.length === 0 ? "No orders yet." : "No orders match."}
} + {orders.map((o) => { + const st = o.staffId ? staffById[o.staffId] : undefined; + const overdue = isOverdue(o, s.today); + const late = overdue ? daysBetween(o.expected, s.today) : 0; + const due = overdue + ? late === 1 ? "due yesterday" : `${late} days overdue` + : isPlacedOpen(o) && o.expected ? (o.expected === s.today ? "due today" : `due ${shortDate(o.expected)}`) : ""; + return ( + +
+
{o.code}
+
+ {o.orderFor === "Stock" ? "For stock" : "For " + staffName(st, "staff member")} · {o.supplier} · {shortDate(o.date)}{o.ref ? " · ref " + o.ref : ""} + {due && <> · {due}} +
+
+ {o.replenish && Replenishment} + {overdue && Overdue} + {o.status} + {money(orderTotal(o, byId))} + + + ); + })} +
+ {dlg && setDlg(false)} />} +
+ ); +} diff --git a/app/app/orders/list/page.tsx b/app/app/orders/list/page.tsx new file mode 100644 index 0000000..f1f269a --- /dev/null +++ b/app/app/orders/list/page.tsx @@ -0,0 +1,11 @@ +import { redirect } from "next/navigation"; + +/* The order list is now the To order column on /app/orders. next.config.ts redirects as well; this + * stub keeps an old bookmark working if that map ever changes. */ +export default async function OrderListPage({ searchParams }: { searchParams: Promise> }) { + const sp = await searchParams; + const qs = new URLSearchParams(); + for (const [k, v] of Object.entries(sp)) for (const x of Array.isArray(v) ? v : v === undefined ? [] : [v]) qs.append(k, x); + const q = qs.toString(); + redirect(q ? `/app/orders?${q}` : "/app/orders"); +} diff --git a/app/app/orders/page.tsx b/app/app/orders/page.tsx new file mode 100644 index 0000000..451e396 --- /dev/null +++ b/app/app/orders/page.tsx @@ -0,0 +1,29 @@ +"use client"; +import { useState } from "react"; +import { useSnap } from "@/lib/client"; +import { PageHead } from "@/components/ui"; +import ToOrder from "@/components/orders/ToOrder"; +import OnTheWay from "@/components/orders/OnTheWay"; +import ThisMonth from "@/components/orders/ThisMonth"; +import RecentOrders from "@/components/orders/RecentOrders"; +import { NewOrder, OrdersStyles } from "@/components/orders/bits"; + +export default function OrdersPage() { + const { isAdmin } = useSnap(); + const [dlg, setDlg] = useState(null); + return ( +
+ + + + + +
+
{isAdmin ? : }
+
+
+
+ {dlg && setDlg(null)} initOrderFor={dlg === "staff" ? "Staff Member" : undefined} />} +
+ ); +} diff --git a/app/app/page.tsx b/app/app/page.tsx new file mode 100644 index 0000000..176bc4c --- /dev/null +++ b/app/app/page.tsx @@ -0,0 +1,44 @@ +"use client"; +import Link from "next/link"; +import { PageHead, Empty } from "@/components/ui"; +import { usePortalCounts } from "@/lib/portalcounts"; +import { todayHeadLine } from "@/lib/today"; +import SetupGroup from "@/components/today/SetupGroup"; +import CollectGroup from "@/components/today/CollectGroup"; +import RoundGroup from "@/components/today/RoundGroup"; +import PickGroup from "@/components/today/PickGroup"; +import ReceiveGroup from "@/components/today/ReceiveGroup"; +import CountsGroup from "@/components/today/CountsGroup"; +import RunsOutPanel from "@/components/today/RunsOutPanel"; +import MonthEndPanel from "@/components/today/MonthEndPanel"; + +/* Today: the work queue. Each group is a thing somebody has to go and do, and a group with nothing + in it is not drawn. Membership and the head count both come from lib/portalcounts.ts, the same + numbers the rail badge shows. */ +export default function TodayPage() { + const { today } = usePortalCounts(); + const column: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 18, minWidth: 0 }; + + return ( +
+ + Open the counter + +
+
+ + + + + + + {today.total === 0 && Nothing in the queue.} +
+
+ + +
+
+
+ ); +} diff --git a/app/app/report/page.tsx b/app/app/report/page.tsx new file mode 100644 index 0000000..b2953bb --- /dev/null +++ b/app/app/report/page.tsx @@ -0,0 +1,83 @@ +"use client"; +import { Suspense, useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useSnap } from "@/lib/client"; +import { monthLabel } from "@/lib/compute"; +import { PageHead } from "@/components/ui"; +import { Icon, Seg } from "@/components/portal"; +import { useReportData } from "@/components/reports/useReportData"; +import MonthEndStrip from "@/components/reports/MonthEndStrip"; +import SpendTab, { JOURNAL_ID } from "@/components/reports/SpendTab"; +import StockTab from "@/components/reports/StockTab"; +import PeopleTab from "@/components/reports/PeopleTab"; + +const TABS = ["spend", "stock", "people"] as const; +type Tab = (typeof TABS)[number]; +const LABELS: Record = { spend: "Spend", stock: "Stock", people: "People" }; +/* The nine reports' old names, so a link to one of them still lands on the tab that holds it. */ +const LEGACY: Record = { + overview: "spend", journal: "spend", + valuation: "stock", shrinkage: "stock", "top-stock": "stock", topstock: "stock", suppliers: "stock", + exceptions: "people", approvals: "people", "pre-loved": "people", preloved: "people", +}; + +/* Screen-local layout. Scoped to .tc-rep so nothing leaks outside this screen. */ + +export default function ReportPage() { + return ; +} + +function ReportInner() { + const { s } = useSnap(); + const router = useRouter(); + const sp = useSearchParams(); + const thisMonth = s.today.slice(0, 7); + const rawTab = (sp.get("tab") || "").toLowerCase(); + const tab: Tab = (TABS as readonly string[]).includes(rawTab) ? (rawTab as Tab) : LEGACY[rawTab] ?? "spend"; + const rawMonth = sp.get("month") || ""; + const month = /^\d{4}-(0[1-9]|1[0-2])$/.test(rawMonth) ? rawMonth : thisMonth; + const d = useReportData(month); + const [jump, setJump] = useState(false); + + function go(next: { tab?: Tab; month?: string }) { + const t = next.tab ?? tab, m = next.month ?? month; + const q = new URLSearchParams(); + q.set("tab", t); + if (m !== thisMonth) q.set("month", m); + router.replace(`/app/report?${q.toString()}`, { scroll: false }); + } + + useEffect(() => { + if (!jump || tab !== "spend") return; + document.getElementById(JOURNAL_ID)?.scrollIntoView({ block: "start" }); + setJump(false); + }, [jump, tab]); + + const exportCsv = tab === "spend" ? d.csv.overview : tab === "stock" ? d.csv.valuation : d.csv.exceptions; + + return ( +
+ + + + + + + + + + +
+ { setJump(true); if (tab !== "spend") go({ tab: "spend" }); }} /> +
+ go({ tab: t })} /> +
+ {tab === "spend" && go({ month: m })} />} + {tab === "stock" && } + {tab === "people" && } +
+
+ ); +} diff --git a/app/app/requests/page.tsx b/app/app/requests/page.tsx new file mode 100644 index 0000000..af73f5b --- /dev/null +++ b/app/app/requests/page.tsx @@ -0,0 +1,142 @@ +"use client"; +/* The full request queue: /app/requests?filter=&staff=&ward=&open= + * + * Approval is the ward's and fulfilment is the linen room's; this screen is the linen room's side. + * The rows come from components/requests/RequestList, the same list the staff record and Today + * use. One payload is fetched and every filter, count and export is a narrowing of it. */ +import { Suspense, useEffect, useMemo } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useSnap } from "@/lib/client"; +import { Empty, ErrorLine, PageHead } from "@/components/ui"; +import { MonoNum, Seg } from "@/components/portal"; +import RequestList, { + REQUEST_FILTERS, REQUEST_FILTER_LABEL, RequestStyles, isRowFilter, requestCounts, requestsFor, scopePayload, + useRequestActions, useRequests, type RequestFilter, +} from "@/components/requests/RequestList"; +import Queries from "@/components/requests/Queries"; +import Damage from "@/components/requests/Damage"; +import KitCheck from "@/components/requests/KitCheck"; +import { exportCount, exportRequestsCsv } from "@/components/requests/csv"; + +// useSearchParams needs a Suspense boundary for static rendering. +export default function RequestsPage() { + return ; +} + +const isFilter = (v: string | null): v is RequestFilter => !!v && (REQUEST_FILTERS as readonly string[]).includes(v); + +function RequestsInner() { + const { s } = useSnap(); + const router = useRouter(); + const pathname = usePathname(); + const sp = useSearchParams(); + const rawFilter = sp.get("filter"); + const staffId = sp.get("staff") || undefined; + const ward = sp.get("ward") || undefined; + const openId = sp.get("open"); + + const { data, error, reload } = useRequests(); + const { act, error: actError } = useRequestActions(reload); + + const person = staffId ? s.staff.find((x) => x.id === staffId) : undefined; + const scope = useMemo(() => ({ staffId, ward }), [staffId, ward]); + const scoped = useMemo(() => (data ? scopePayload(data, scope, person?.num) : null), [data, scope, person?.num]); + const counts = useMemo(() => (scoped ? requestCounts(scoped) : null), [scoped]); + + /* A deep link to one request with no filter named lands on the first filter that holds it, so + ?open= always shows the row expanded. */ + const filter: RequestFilter = useMemo(() => { + if (isFilter(rawFilter)) return rawFilter; + if (openId && scoped) { + for (const f of ["todo", "open", "all"] as const) if (requestsFor(scoped.requests, f).some((r) => r.id === openId)) return f; + } + return "todo"; + }, [rawFilter, openId, scoped]); + + function setParams(next: Record) { + const q = new URLSearchParams(sp.toString()); + for (const [k, v] of Object.entries(next)) { if (v === null) q.delete(k); else q.set(k, v); } + const qs = q.toString(); + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); + } + + // An unknown ?filter= is dropped rather than left in the address bar disagreeing with the screen. + useEffect(() => { + if (!rawFilter || isFilter(rawFilter)) return; + const q = new URLSearchParams(window.location.search); + q.delete("filter"); + const qs = q.toString(); + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); + }, [rawFilter, router, pathname]); + + const showing = staffId ? (person ? `${person.first} ${person.last}`.trim() : data?.requests.find((r) => r.staffId === staffId)?.staffName || "one person") : ward; + const more = data?.moreRequests ? "+" : ""; + const segCounts = counts + ? Object.fromEntries(REQUEST_FILTERS.map((f) => [f, `${counts[f]}${isRowFilter(f) ? more : ""}`])) as Record + : undefined; + + const shown = scoped ? exportCount(filter, scoped) : 0; + const narrowed = !!showing || (filter !== "all" && filter !== "cycles"); + + return ( +
+ + + + + + {showing && ( +
+ Showing {showing} + +
+ )} + + {!data ? ( + error ? ( + <> + +
+ + ) : Loading… + ) : ( +
+ {error && } + {counts && counts.noapprover > 0 && filter !== "noapprover" && ( +
+ + + +
+ )} + +
+ setParams({ filter: f })} style={{ flexWrap: "nowrap", width: "max-content" }} /> +
+ + {actError && } + + {isRowFilter(filter) ? ( + + ) : filter === "queries" ? ( + + ) : filter === "damage" ? ( + + ) : ( + + )} +
+ )} +
+ ); +} diff --git a/app/app/rounds/page.tsx b/app/app/rounds/page.tsx new file mode 100644 index 0000000..6433b99 --- /dev/null +++ b/app/app/rounds/page.tsx @@ -0,0 +1,73 @@ +"use client"; +import { useEffect, useMemo, useState } from "react"; +import { useDerived, useSnap } from "@/lib/client"; +import { PageHead, Empty, LiveRegion } from "@/components/ui"; +import { Panel, QueueRow, Seg } from "@/components/portal"; +import { DeliverDialog } from "@/components/dialogs"; +import type { PickupRec } from "@/lib/compute"; +import { plural, roundSheet } from "@/lib/today"; +import { Lines } from "@/components/today/Lines"; + +// Delivery rounds: every uncollected pickup by ward, handed over on the floor with an on-screen +// signature and a handover photo (DeliverDialog). + +const ALL = "__all__"; + +export default function RoundsPage() { + const { s } = useSnap(); + const { byId, staffById } = useDerived(); + const [deliver, setDeliver] = useState(null); + const [msg, setMsg] = useState(""); + const [ward, setWard] = useState(ALL); + useEffect(() => { const w = new URLSearchParams(window.location.search).get("ward"); if (w) setWard(w); }, []); + + const sheet = useMemo(() => roundSheet(s, byId, staffById), [s, byId, staffById]); + const bags = sheet.reduce((t, w) => t + w.rows.length, 0); + const garments = sheet.reduce((t, w) => t + w.garments, 0); + const current = ward !== ALL && sheet.some((w) => w.ward === ward) ? ward : ALL; + const shown = current === ALL ? sheet : sheet.filter((w) => w.ward === current); + + function choose(w: string) { + setWard(w); + const u = new URL(window.location.href); + if (w === ALL) u.searchParams.delete("ward"); else u.searchParams.set("ward", w); + window.history.replaceState(null, "", u.pathname + u.search); + } + + const opts = [ALL, ...sheet.map((w) => w.ward)]; + const labels: Record = { [ALL]: "All" }; + const counts: Record = { [ALL]: bags }; + for (const w of sheet) counts[w.ward] = w.rows.length; + + return ( +
+ {plural(bags, "bag")} · {plural(sheet.length, "ward")} · {plural(garments, "garment")}} /> + + {sheet.length > 1 && ( +
+ +
+ )} + {bags === 0 && Nothing waiting for delivery.} +
+ {shown.map((w) => ( + {w.cc || "—"} · {w.rows.length} to deliver}> + {w.rows.map((r) => ( + {r.phone} : r.phone) : undefined} + meta={<> · {r.p.orderCode}} + actions={} + /> + ))} + + ))} +
+ {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..44835ea --- /dev/null +++ b/app/app/settings/page.tsx @@ -0,0 +1,70 @@ +"use client"; +/* Settings: seven sections in a side list, each a ?tab= of its own so deep links and the help mark + * land on the right one. Old tab names (general, account, locations, activity…) still resolve. */ +import { Suspense, useEffect, useState } from "react"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { useSnap } from "@/lib/client"; +import { PageHead } from "@/components/ui"; +import dynamic from "next/dynamic"; +import { CSV_TEMPLATES } from "@/lib/csv"; +import { SECTIONS, SettingsStyles, resolveSection, type SectionId } from "@/components/settings/common"; +import FacilitySection from "@/components/settings/FacilitySection"; +import IssuingRules from "@/components/settings/IssuingRules"; +import CatalogueSection from "@/components/settings/CatalogueSection"; +import PlacesSection from "@/components/settings/PlacesSection"; +import PeopleSignIn from "@/components/settings/PeopleSignIn"; +import DataAudit from "@/components/settings/DataAudit"; + +// Plan pulls in Stripe; load it only when the Plan section is open. +const PlanTab = dynamic(() => import("@/components/PlanTab"), { ssr: false }); + +// useSearchParams needs a Suspense boundary for static rendering. +export default function SettingsPage() { + return ; +} + +function SettingsInner() { + const { s, isAdmin } = useSnap(); + const sp = useSearchParams(); + const planShown = isAdmin && !!s.plan?.live && !s.demo; + // #hash forms from old links (/app/settings#account). + const [hash, setHash] = useState(""); + useEffect(() => { + const read = () => setHash(window.location.hash.replace("#", "")); + read(); + window.addEventListener("hashchange", read); + return () => window.removeEventListener("hashchange", read); + }, []); + + const tabParam = sp.get("tab"); + const importParam = sp.get("import") || ""; + const importKind = CSV_TEMPLATES[importParam] ? importParam : undefined; + const resolved = resolveSection(tabParam || hash || (importKind ? "data" : "")); + let section: SectionId = resolved.section; + if (section === "plan" && !planShown) section = "facility"; + const sections = SECTIONS.filter((x) => x.id !== "plan" || planShown); + + return ( +
+ + +
+ +
+ {section === "facility" && } + {section === "issuing" && } + {section === "catalogue" && } + {section === "places" && } + {section === "people" && } + {section === "data" && } + {section === "plan" && planShown && } +
+
+
+ ); +} diff --git a/app/app/staff/[id]/page.tsx b/app/app/staff/[id]/page.tsx new file mode 100644 index 0000000..2d4e602 --- /dev/null +++ b/app/app/staff/[id]/page.tsx @@ -0,0 +1,8 @@ +"use client"; +import { Suspense } from "react"; +import StaffRecord from "@/components/people/Record"; + +// The record's tab and edit mode live in the address; useSearchParams needs a Suspense boundary. +export default function StaffProfile() { + return ; +} diff --git a/app/app/staff/page.tsx b/app/app/staff/page.tsx new file mode 100644 index 0000000..0a1c89e --- /dev/null +++ b/app/app/staff/page.tsx @@ -0,0 +1,8 @@ +"use client"; +import { Suspense } from "react"; +import Register from "@/components/people/Register"; + +// Register reads its filters from the address; useSearchParams needs a Suspense boundary. +export default function StaffPage() { + return ; +} diff --git a/app/app/stock/[id]/page.tsx b/app/app/stock/[id]/page.tsx new file mode 100644 index 0000000..61a2481 --- /dev/null +++ b/app/app/stock/[id]/page.tsx @@ -0,0 +1,414 @@ +"use client"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { Fragment, useEffect, useMemo, useState } from "react"; +import { useDerived, useSnap } from "@/lib/client"; +import { PageHead, Empty, ErrorLine, Field, Notice } from "@/components/ui"; +import { AdjustDialog, DuplicateItemDialog, GROUPS_HINT, GroupsPicker, ScanVariantsDialog } from "@/components/dialogs"; +import { Icon, MoreMenu, Panel, QtyStepper, Tag } from "@/components/portal"; +import { StockStyles } from "@/components/stock/StockStyles"; +import { wholeMoney } from "@/components/stock/url"; +import { bcBound, countsAsIssued, fmtDate, forecastFor, forecastLabel, fyStart, garmentGroups, genderLabel, issueCost, itemOrderHistory, key, lastCountMap, locTree, money, onOrderMap, onhand, plOf, reorderAt, staffName, statusTag, supplierCodeOf, touched } from "@/lib/compute"; + +export default function GarmentPage() { + const { id } = useParams<{ id: string }>(); + const { s, isAdmin, mutate } = useSnap(); + const { L, byId, staffById } = useDerived(); + const it = s.catalog.find((x) => x.id === id); + const [edit, setEdit] = useState(false); + const [f, setF] = useState({ item: "", sku: "", supplier: "", cost: "", gender: "Unisex", groups: [] as string[], notes: "" }); + const [newSize, setNewSize] = useState(""); + const [err, setErr] = useState(""); + const [msg, setMsg] = useState(""); + const [adjust, setAdjust] = useState<{ itemId: string; si: number } | null>(null); + const [scanSizes, setScanSizes] = useState(false); + const [dup, setDup] = useState(false); + // What is typed into each size's barcode box, until it is saved: a label code is as often read out + // and typed as it is scanned. + const [codes, setCodes] = useState>({}); + const [rowErr, setRowErr] = useState<{ si: number; msg: string } | null>(null); + const [flash, setFlash] = useState(null); + + // Arriving from "Create and scan sizes" (?scan=1) opens the scanner straight away; ?size= + // (a scanned garment, a size cell) brings that size's row into view and marks it for two seconds. + useEffect(() => { + const sp = new URLSearchParams(window.location.search); + if (sp.get("scan") === "1") { + if (isAdmin && it) setScanSizes(true); + sp.delete("scan"); + window.history.replaceState(null, "", window.location.pathname + (sp.toString() ? "?" + sp.toString() : "")); + } + const size = sp.get("size"); + if (size === null || !it) return; + const si = parseInt(size, 10); + if (!(si >= 0 && si < it.sizes.length)) return; + const t0 = window.setTimeout(() => { + const el = document.getElementById(`size-row-${si}`); + if (el) { el.scrollIntoView({ block: "center" }); el.focus({ preventScroll: true }); } + setFlash(si); + }, 50); + const t1 = window.setTimeout(() => setFlash(null), 2050); + return () => { window.clearTimeout(t0); window.clearTimeout(t1); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, !!it]); + + const d = useMemo(() => { + if (!it) return null; + const oo = onOrderMap(s, byId).byKey; + const lastCount = lastCountMap(s); + const sizes = it.sizes.map((sz, si) => { const k = key(it.id, si); return { si, size: String(sz), key: k, oh: onhand(s, L, k), ro: reorderAt(s, k), touched: touched(s, L, k), barcode: bcBound(s, it, si), pl: plOf(s, k), onOrd: oo[k] || 0, last: lastCount[k] || "" }; }); + const tot = sizes.reduce((t, v) => t + v.oh, 0); + const value = sizes.reduce((t, v) => t + Math.max(0, v.oh), 0) * it.cost; + const fy = fyStart(s.today); + const fyList = s.issues.filter((i) => i.itemId === it.id && i.date >= fy && countsAsIssued(i)); + const fyIssued = fyList.reduce((t, i) => t + i.qty, 0); + const fySpend = fyList.reduce((t, i) => t + i.qty * issueCost(i, byId), 0); + const onOrder = sizes.reduce((t, v) => t + v.onOrd, 0); + const hist: { date: string; kind: string; cls: string; desc: string }[] = []; + for (const i of s.issues) if (i.itemId === it.id) { + hist.push({ date: i.date, kind: i.direct ? "Collected" : "Issued", cls: "tag tag-neutral", desc: `${it.sizes[i.si]} ×${i.qty} — ${staffName(staffById[i.staffId], "—")}` }); + if (i.returned) hist.push({ date: i.returned.date, kind: i.returned.cond.replace("Returned - ", "Returned – "), cls: "tag tag-outline", desc: `${it.sizes[i.si]} ×${i.qty} — ${staffName(staffById[i.staffId], "—")}` }); + } + for (const o of s.orders) for (const rc of o.receipts) for (const l of rc.lines) if (l.itemId === it.id) hist.push({ date: rc.date, kind: "Received", cls: "tag tag-accent", desc: `${l.size} ×${l.qty} — ${o.code}${l.dest === "shelf" ? " → shelf" : " → staff pickup"}` }); + // A counted correction isn't a write-off: it's the shelf disagreeing with the ledger, either way. + for (const m of s.moves) if (m.itemId === it.id) hist.push({ date: m.date, kind: m.reason === "Counted correction" ? "Counted" : m.qty < 0 ? "Write-off" : "Added", cls: "tag tag-outline", desc: `${it.sizes[m.si] ?? ""} ${m.reason === "Counted correction" ? (m.qty < 0 ? "−" : "+") + Math.abs(m.qty) : "×" + Math.abs(m.qty)}${m.reason ? " — " + m.reason : ""}` }); + hist.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)); + return { sizes, tot, value, fyIssued, fySpend, onOrder, hist: hist.slice(0, 25) }; + }, [it, s, L, byId, staffById]); + + if (!it || !d) { + return ( +
+ + + + That garment isn't in the catalogue. +
+ ); + } + + const tagged = garmentGroups(it.groups); + const startEdit = () => { setF({ item: it.item, sku: it.sku, supplier: it.supplier, cost: String(it.cost), gender: it.gender, groups: tagged, notes: it.notes }); setErr(""); setEdit(true); }; + const invalid = !f.item.trim() || !(parseFloat(f.cost) >= 0) || f.cost === ""; + async function save() { + if (invalid) return; + const r = await mutate("catalog.update", { id: it!.id, item: f.item, sku: f.sku, supplier: f.supplier, cost: parseFloat(f.cost), gender: f.gender, groups: f.groups, notes: f.notes }); + if (!r.ok) { setErr(r.error); return; } + setEdit(false); + } + async function act(op: string, payload: unknown) { setErr(""); setRowErr(null); setMsg(""); const r = await mutate(op, payload); if (!r.ok) setErr(r.error); } + const sizeInvalid = !newSize.trim() || it.sizes.map(String).includes(newSize.trim()); + // Adding a size does not mint a barcode: most sizes arrive with the supplier's own number. + async function addSize() { + if (sizeInvalid) return; + setErr(""); + const r = await mutate("catalog.update", { id: it!.id, addSize: newSize.trim() }); + if (!r.ok) { setErr(r.error); return; } + setNewSize(""); + } + // A size row's own refusal belongs on that row. + function rowFail(si: number, m: string) { setErr(""); setRowErr({ si, msg: m }); } + async function saveCode(si: number, size: string, bound: string, force = false) { + const code = (codes[si] ?? bound).trim(); + if (code === bound) { setCodes((c) => ({ ...c, [si]: bound })); return; } + // Clearing the box is how a wrong code comes off. + if (!code) { if (confirm(`Unbind ${bound} from size ${size}?`)) await unbindCode(si, bound); return; } + setErr(""); setRowErr(null); + const r = await mutate("barcode.bind", { code, itemId: it!.id, si, force }); + if (!r.ok) { + // "Already on another garment" is the one refusal force may clear. + if (!force && r.error.includes("re-bind to move it") && confirm(`${r.error}\n\nMove ${code} onto ${it!.item} · size ${size}?`)) { await saveCode(si, size, bound, true); return; } + rowFail(si, r.error); return; + } + setCodes((c) => ({ ...c, [si]: code })); + } + async function unbindCode(si: number, code: string) { + setErr(""); setRowErr(null); + const r = await mutate("barcode.unbind", { code }); + if (!r.ok) { rowFail(si, r.error); return; } + setCodes((c) => ({ ...c, [si]: "" })); + } + // Our own number for sizes that arrived without one. Only fills gaps; the server has the last word. + async function generateAll() { + const missing = d!.sizes.filter((v) => !v.barcode).length; + if (missing && !confirm(`Generate a barcode for the ${missing} size${missing === 1 ? "" : "s"} on ${it!.item} with none? Sizes with a supplier’s code keep it.`)) return; + setErr(""); setRowErr(null); setMsg(""); + const r = await mutate<{ made: { si: number; size: string; code: string }[]; count: number }>("barcode.generate", { itemId: it!.id }); + if (!r.ok) { setErr(r.error); return; } + setCodes({}); + setMsg(`Generated ${r.result.count} barcode${r.result.count === 1 ? "" : "s"} — size${r.result.count === 1 ? "" : "s"} ${r.result.made.map((m) => m.size).join(", ")}.`); + } + async function generateOne(si: number, size: string) { + setErr(""); setRowErr(null); setMsg(""); + const r = await mutate<{ made: { si: number; size: string; code: string }[]; count: number }>("barcode.generate", { itemId: it!.id, si }); + if (!r.ok) { rowFail(si, r.error); return; } + setCodes((c) => { const n = { ...c }; delete n[si]; return n; }); + setMsg(`Size ${size} now carries ${r.result.made.map((m) => m.code).join(", ")}.`); + } + async function removeSize(si: number, size: string) { + if (!confirm(`Remove size ${size} from ${it!.item}? Its reorder level and barcode go with it.`)) return; + setErr(""); setRowErr(null); + const r = await mutate("catalog.removeSize", { id: it!.id, si }); + if (!r.ok) { rowFail(si, r.error); return; } + // Every size above the removed one shifts down a place. + setCodes({}); + } + // One label per garment on hand across the sizes that carry a code, so the count goes on the menu + // item and into the question before the print dialog opens. + const labelled = d.sizes.filter((v) => v.barcode).length; + const labels = d.sizes.reduce((t, v) => t + (v.barcode ? Math.max(0, v.oh) : 0), 0); + function printLabels() { + if (labels && !confirm(`Print ${labels} label${labels === 1 ? "" : "s"} for ${it!.item}? One for every garment on hand, across the ${labelled} size${labelled === 1 ? "" : "s"} carrying a barcode.`)) return; + window.open(`/print/labels?item=${encodeURIComponent(it!.id)}`, "_blank", "noopener"); + } + + const locOpts = locTree(s).map(({ loc, depth }) => ({ id: loc.id, name: " ".repeat(depth * 2) + loc.name })); + const sp = s.supplierDir.find((x) => x.name === it.supplier); + const hist = itemOrderHistory(s, it.id); + const prices = s.costs.filter((c) => c.itemId === it.id).sort((a, b) => b.at.localeCompare(a.at)); + const colCount = 10; + + return ( +
+ + + {tagged.length ? tagged.map((g) => {g}) : All groups} + {it.gender !== "Unisex" && {genderLabel(it.gender)}} + {it.supplier || "No supplier"} + {it.archived && Discontinued} + + } + > +
+ {d.tot} + on hand · {wholeMoney(d.value)} +
+ {isAdmin && (!edit ? ( + <> + + + setDup(true) }, + it.archived + ? { label: "Reinstate", onSelect: () => act("catalog.update", { id: it.id, archived: false }) } + : { label: "Discontinue", danger: true, onSelect: () => act("catalog.update", { id: it.id, archived: true }) }, + ]} /> + + ) : ( + <> + + + + ))} +
+ + + + +
+
+ {edit && ( + +
+ {(c) => setF({ ...f, item: e.target.value })} />} + {(c) => setF({ ...f, sku: e.target.value })} />} + = 0) ? "Give a number, or 0." : undefined}>{(c) => setF({ ...f, cost: e.target.value.replace(/[^0-9.]/g, "") })} />} + {(c) => <> setF({ ...f, supplier: e.target.value })} />{s.settings.suppliers.map((x) => } + {(c) => } + setF({ ...f, groups })} groups={s.settings.staffGroups} hint={GROUPS_HINT} /> + {(c) =>