ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 38e16eb on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import QRCode from "qrcode";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { allow, clientIp } from "@/lib/ratelimit";
|
||||
import {
|
||||
decryptSecret, encryptSecret, hashRecoveryCode, newRecoveryCodes, newTotpSecret, otpauthUrl, totpVerify,
|
||||
} from "@/lib/totp";
|
||||
import { recordAuthEvent } from "@/lib/audit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Turning a second factor on and off, for your own account only.
|
||||
*
|
||||
* Three steps rather than one, because a secret that is stored the moment it is generated leaves
|
||||
* an account half-enrolled if the person never finishes — and then their next sign-in asks for
|
||||
* codes from an app they never set up.
|
||||
*
|
||||
* setup — generate a secret and show the QR. Stored, but not yet in force.
|
||||
* enable — prove a code from it works, then switch it on and hand back recovery codes.
|
||||
* disable — password required, because turning a factor off is a privileged act.
|
||||
*/
|
||||
export async function GET() {
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
const u = await prisma.user.findUnique({ where: { id: user.id }, select: { totpEnabledAt: true } });
|
||||
const left = await prisma.recoveryCode.count({ where: { userId: user.id, usedAt: null } });
|
||||
return NextResponse.json({ enabled: !!u?.totpEnabledAt, enabledAt: u?.totpEnabledAt ?? null, recoveryLeft: left });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const csrf = sameOriginJson(req);
|
||||
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
|
||||
const ip = clientIp(req.headers);
|
||||
if (!allow("2fa-manage:" + user.id, 30, 15 * 60 * 1000)) {
|
||||
return NextResponse.json({ error: "Too many attempts — try again in a few minutes." }, { status: 429 });
|
||||
}
|
||||
|
||||
// Turning a second factor on or off is one of the few changes to an account that leaves no trace
|
||||
// in the records themselves, so it is one of the few worth recording on its own.
|
||||
const actor = {
|
||||
facilityId: user.facilityId, userId: user.id,
|
||||
userName: `${user.first} ${user.last}`.trim() || user.email,
|
||||
};
|
||||
|
||||
let body: { action?: unknown; code?: unknown; password?: unknown };
|
||||
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
|
||||
const action = String(body.action ?? "");
|
||||
|
||||
const u = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { id: true, email: true, passwordHash: true, totpSecret: true, totpEnabledAt: true },
|
||||
});
|
||||
if (!u) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
|
||||
if (action === "setup") {
|
||||
if (u.totpEnabledAt) return NextResponse.json({ error: "Two-factor is already on for this account." }, { status: 400 });
|
||||
const secret = newTotpSecret();
|
||||
await prisma.user.update({ where: { id: u.id }, data: { totpSecret: encryptSecret(secret) } });
|
||||
const url = otpauthUrl(secret, u.email);
|
||||
// SVG, generated here rather than in the browser: it keeps a QR library out of the bundle that
|
||||
// ward phones download, and the secret never has to be handed to client-side code to render.
|
||||
const qr = await QRCode.toString(url, { type: "svg", margin: 1, width: 220, errorCorrectionLevel: "M" });
|
||||
recordAuthEvent(actor, "2fa:setup", ip);
|
||||
return NextResponse.json({ ok: true, secret, url, qr });
|
||||
}
|
||||
|
||||
if (action === "enable") {
|
||||
if (u.totpEnabledAt) return NextResponse.json({ error: "Two-factor is already on." }, { status: 400 });
|
||||
const secret = decryptSecret(u.totpSecret);
|
||||
if (!secret) return NextResponse.json({ error: "Start the setup again." }, { status: 400 });
|
||||
if (!totpVerify(secret, String(body.code ?? ""))) {
|
||||
return NextResponse.json({ error: "That code isn't right. Use the current one from your app." }, { status: 400 });
|
||||
}
|
||||
const codes = newRecoveryCodes();
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({ where: { id: u.id }, data: { totpEnabledAt: new Date() } });
|
||||
await tx.recoveryCode.deleteMany({ where: { userId: u.id } });
|
||||
await tx.recoveryCode.createMany({ data: codes.map((c) => ({ userId: u.id, codeHash: hashRecoveryCode(c) })) });
|
||||
});
|
||||
recordAuthEvent(actor, "2fa:enable", ip);
|
||||
// The only time these are ever readable. They are stored hashed, so there is no second chance.
|
||||
return NextResponse.json({ ok: true, codes });
|
||||
}
|
||||
|
||||
if (action === "disable") {
|
||||
if (!u.totpEnabledAt) return NextResponse.json({ ok: true });
|
||||
const pw = String(body.password ?? "");
|
||||
if (!pw || !(await bcrypt.compare(pw, u.passwordHash))) {
|
||||
return NextResponse.json({ error: "That password isn't right." }, { status: 401 });
|
||||
}
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({ where: { id: u.id }, data: { totpEnabledAt: null, totpSecret: "" } });
|
||||
await tx.recoveryCode.deleteMany({ where: { userId: u.id } });
|
||||
});
|
||||
recordAuthEvent(actor, "2fa:disable", ip);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
if (action === "regenerate") {
|
||||
if (!u.totpEnabledAt) return NextResponse.json({ error: "Two-factor isn't on." }, { status: 400 });
|
||||
const pw = String(body.password ?? "");
|
||||
if (!pw || !(await bcrypt.compare(pw, u.passwordHash))) {
|
||||
return NextResponse.json({ error: "That password isn't right." }, { status: 401 });
|
||||
}
|
||||
const codes = newRecoveryCodes();
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.recoveryCode.deleteMany({ where: { userId: u.id } });
|
||||
await tx.recoveryCode.createMany({ data: codes.map((c) => ({ userId: u.id, codeHash: hashRecoveryCode(c) })) });
|
||||
});
|
||||
recordAuthEvent(actor, "2fa:regenerate", ip);
|
||||
return NextResponse.json({ ok: true, codes });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentUser } from "@/lib/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PAGE = 100;
|
||||
|
||||
/* The audit trail, read back.
|
||||
*
|
||||
* Admin only, and scoped to the caller's own facility by the query rather than by a filter the
|
||||
* client sends — the client never gets to say which facility it wants. Paged by cursor rather
|
||||
* than offset so a busy room's log doesn't shift under you as new rows land while you read.
|
||||
*
|
||||
* The cursor is (timestamp, id), not timestamp alone. Prisma stores DateTime at millisecond
|
||||
* precision, and two events sharing a millisecond is ordinary rather than exotic — two coordinators
|
||||
* saving at once, or two ops committed inside one transaction. A strict `at < cursor` dropped every
|
||||
* row that shared the last one's millisecond, so the log looked complete with an event missing from
|
||||
* it, which is the one failure an audit trail cannot have.
|
||||
*/
|
||||
|
||||
/** `<iso>|<id>` — one opaque string, because the client only ever hands it straight back. */
|
||||
function readCursor(raw: string | null): { at: Date; id: string } | null {
|
||||
if (!raw) return null;
|
||||
const cut = raw.lastIndexOf("|");
|
||||
const iso = cut === -1 ? raw : raw.slice(0, cut);
|
||||
const id = cut === -1 ? "" : raw.slice(cut + 1);
|
||||
if (Number.isNaN(Date.parse(iso))) return null;
|
||||
return { at: new Date(iso), id: id.slice(0, 40) };
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
// SessionUser.role is the database enum ("ADMIN"), not the snapshot's display form ("Admin").
|
||||
if (user.role !== "ADMIN") return NextResponse.json({ error: "Admin only" }, { status: 403 });
|
||||
|
||||
const cursor = readCursor(req.nextUrl.searchParams.get("before"));
|
||||
|
||||
const rows = await prisma.auditEvent.findMany({
|
||||
where: {
|
||||
facilityId: user.facilityId,
|
||||
// Everything strictly older, plus the rest of the millisecond we stopped in the middle of.
|
||||
...(cursor ? { OR: [{ at: { lt: cursor.at } }, { at: cursor.at, id: { lt: cursor.id } }] } : {}),
|
||||
},
|
||||
orderBy: [{ at: "desc" }, { id: "desc" }],
|
||||
take: PAGE + 1,
|
||||
select: { id: true, at: true, userName: true, op: true, target: true },
|
||||
});
|
||||
|
||||
const more = rows.length > PAGE;
|
||||
const page = rows.slice(0, PAGE);
|
||||
const last = page[page.length - 1];
|
||||
return NextResponse.json({
|
||||
events: page.map((r) => ({ id: r.id, at: r.at.toISOString(), who: r.userName, op: r.op, target: r.target })),
|
||||
nextBefore: more && last ? `${last.at.toISOString()}|${last.id}` : null,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { readFileSync } from "fs";
|
||||
import path from "path";
|
||||
import { COMMUNITY } from "@/lib/edition";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* What the Android apps ask a server before they will point at it.
|
||||
*
|
||||
* The apps open threadcount.tech unless told otherwise; a room running the Community edition types
|
||||
* its own address into the app's first screen, and the app calls this first. It proves the address
|
||||
* is a ThreadCount server (not a look-alike, not a typo), says which edition and build, and carries
|
||||
* the oldest app version this build still works with, so an app can say "update me" instead of
|
||||
* breaking quietly. Public and unauthenticated on purpose: nothing here is about a facility. */
|
||||
function version(): string {
|
||||
try { return readFileSync(path.join(process.cwd(), "COMMUNITY_VERSION"), "utf8").trim(); } catch { /* hosted: no file */ }
|
||||
return process.env.NEXT_PUBLIC_RELEASE || "hosted";
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
product: "threadcount",
|
||||
edition: COMMUNITY ? "community" : "hosted",
|
||||
version: version(),
|
||||
paths: { counter: "/m", staff: "/my" },
|
||||
// The oldest Play versionCode of each app this server still serves correctly.
|
||||
minApp: { counter: 9, staff: 7 },
|
||||
}, { headers: { "cache-control": "no-store", "access-control-allow-origin": "*" } });
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { cookies } from "next/headers";
|
||||
import { REMEMBER_MAX_AGE, pwVersion, setSessionCookie } from "@/lib/session";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { allow, clientIp } from "@/lib/ratelimit";
|
||||
import { decryptSecret, hashRecoveryCode, totpVerify } from "@/lib/totp";
|
||||
import { TRUST_COOKIE, TRUST_TTL_MS, mintTrust, readTicket } from "@/lib/twofactor";
|
||||
import { recordAuthEvent } from "@/lib/audit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Second step of sign-in: the code from the authenticator, or one recovery code.
|
||||
*
|
||||
* Rate limited hard. A six-digit code is one in a million per guess, which is only meaningful if
|
||||
* guessing is expensive — unthrottled, a million tries is minutes of work. */
|
||||
export async function POST(req: NextRequest) {
|
||||
const csrf = sameOriginJson(req);
|
||||
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
|
||||
const ip = clientIp(req.headers);
|
||||
let body: { ticket?: unknown; code?: unknown; trust?: unknown; remember?: unknown };
|
||||
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
|
||||
|
||||
const t = readTicket(String(body.ticket ?? ""));
|
||||
if (!t) return NextResponse.json({ error: "That sign-in has expired. Start again." }, { status: 400 });
|
||||
|
||||
// Per account and per address: one stolen ticket can't be brute-forced, and one machine can't
|
||||
// work through several accounts at once.
|
||||
if (!allow("2fa-user:" + t.uid, 10, 15 * 60 * 1000) || !allow("2fa-ip:" + ip, 300, 15 * 60 * 1000)) {
|
||||
return NextResponse.json({ error: "Too many attempts — try again in a few minutes." }, { status: 429 });
|
||||
}
|
||||
|
||||
const u = await prisma.user.findUnique({
|
||||
where: { id: t.uid },
|
||||
select: { id: true, facilityId: true, email: true, first: true, last: true, role: true, inactive: true, passwordHash: true, totpSecret: true, totpEnabledAt: true },
|
||||
});
|
||||
if (!u || u.inactive || !u.totpEnabledAt) {
|
||||
return NextResponse.json({ error: "That sign-in has expired. Start again." }, { status: 400 });
|
||||
}
|
||||
// The password changed between the two steps — the ticket is stale for the same reason a session
|
||||
// would be.
|
||||
if (pwVersion(u.passwordHash) !== t.pv) {
|
||||
return NextResponse.json({ error: "That sign-in has expired. Start again." }, { status: 400 });
|
||||
}
|
||||
|
||||
const raw = String(body.code ?? "").trim();
|
||||
const secret = decryptSecret(u.totpSecret);
|
||||
let good = !!secret && totpVerify(secret, raw);
|
||||
let usedRecovery = false;
|
||||
|
||||
if (!good && raw.replace(/[^A-Za-z0-9]/g, "").length >= 10) {
|
||||
// A recovery code. Single use: consumed in the same conditional update that finds it, so two
|
||||
// simultaneous attempts can't both spend it.
|
||||
const hash = hashRecoveryCode(raw);
|
||||
const hit = await prisma.recoveryCode.findFirst({ where: { userId: u.id, codeHash: hash, usedAt: null }, select: { id: true } });
|
||||
if (hit) {
|
||||
const consumed = await prisma.recoveryCode.updateMany({ where: { id: hit.id, usedAt: null }, data: { usedAt: new Date() } });
|
||||
good = consumed.count === 1;
|
||||
usedRecovery = good;
|
||||
}
|
||||
}
|
||||
|
||||
if (!good) return NextResponse.json({ error: "That code isn't right. Try the current one from your app." }, { status: 401 });
|
||||
|
||||
await setSessionCookie(u.id, u.passwordHash, false, body.remember === true ? REMEMBER_MAX_AGE : undefined);
|
||||
// "Trust this computer": only ever set here, after a real code, never from the password step.
|
||||
if (body.trust === true) {
|
||||
const jar = await cookies();
|
||||
jar.set(TRUST_COOKIE, mintTrust(u.id, pwVersion(u.passwordHash)), { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", path: "/api/auth", maxAge: Math.floor(TRUST_TTL_MS / 1000) });
|
||||
}
|
||||
// How they got in matters more here than anywhere else: a recovery code means the phone is gone,
|
||||
// and a run of them means something else is going on.
|
||||
recordAuthEvent(
|
||||
{ facilityId: u.facilityId, userId: u.id, userName: `${u.first} ${u.last}`.trim() || u.email },
|
||||
"auth:signin", ip, usedRecovery ? "recovery" : "totp",
|
||||
);
|
||||
const left = await prisma.recoveryCode.count({ where: { userId: u.id, usedAt: null } });
|
||||
return NextResponse.json({ ok: true, name: `${u.first} ${u.last}`, role: u.role, usedRecovery, recoveryLeft: left });
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { allow, clientIp, fail, over } from "@/lib/ratelimit";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { verifyTurnstile } from "@/lib/turnstile";
|
||||
import { sendTo, transactionalConfigured } from "@/lib/mail";
|
||||
import { RESET_TTL_MS, newResetToken, resetEmail, resetUrl } from "@/lib/reset";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Request a password reset.
|
||||
*
|
||||
* Before this existed a facility whose only admin forgot their password was locked out for good —
|
||||
* the sign-in screen told them to ask an admin, and they were the admin. Deleting the last admin
|
||||
* deletes the whole facility, so there was no way back in at all.
|
||||
*
|
||||
* The response is identical whether or not the address has an account. Anything else turns this
|
||||
* into a way to ask "does this hospital use ThreadCount, and is this person a coordinator there?"
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const csrf = sameOriginJson(req);
|
||||
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
|
||||
const ip = clientIp(req.headers);
|
||||
let body: { email?: unknown; cfToken?: unknown };
|
||||
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
|
||||
const email = String(body.email ?? "").trim().toLowerCase().slice(0, 160);
|
||||
|
||||
// A slow ceiling per IP, so one machine can't walk a staff list to find out which addresses exist
|
||||
// by watching how long each request takes. The per-address ceiling deliberately lives further
|
||||
// down, past the bot check — see the note beside it.
|
||||
if (!allow("forgot-ip:" + ip, 60, 60 * 60 * 1000)) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const cfErr = await verifyTurnstile(body.cfToken, ip);
|
||||
if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
|
||||
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return NextResponse.json({ ok: true });
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email }, select: { id: true, first: true, inactive: true, ssoBreakGlass: true, facility: { select: { ssoRequired: true } } } });
|
||||
// A facility that requires single sign-on has no password door for most of its people, so a
|
||||
// reset link would be a way round its identity provider. Break-glass admins keep theirs. The
|
||||
// answer to the caller is the same either way.
|
||||
const ssoOnly = !!user && user.facility.ssoRequired && !user.ssoBreakGlass;
|
||||
if (user && !user.inactive && !ssoOnly) {
|
||||
// The per-address ceiling counts mail actually sent, not requests received, and it is only
|
||||
// consulted once the bot check has passed. Spent on requests, it handed a stranger a way to
|
||||
// hold a facility's only coordinator out of their own account: four anonymous posts with no
|
||||
// Turnstile token filled the bucket, and every later attempt by the coordinator was answered
|
||||
// with "a reset link is on its way" and no mail. Counted this way the only way to exhaust the
|
||||
// budget is to have four reset mails delivered to that same inbox, so whoever forgot their
|
||||
// password always has a working link waiting for them.
|
||||
const mailKey = "forgot-email:" + email;
|
||||
if (over(mailKey, 4, 60 * 60 * 1000)) {
|
||||
console.warn("[forgot] four reset mails already sent this hour — suppressing another for user", user.id);
|
||||
} else {
|
||||
const { token, tokenHash } = newResetToken();
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Asking again supersedes anything outstanding, so a forwarded older email goes dead.
|
||||
await tx.passwordReset.updateMany({
|
||||
where: { userId: user.id, usedAt: null },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
await tx.passwordReset.create({
|
||||
data: { userId: user.id, tokenHash, expiresAt: new Date(Date.now() + RESET_TTL_MS), requestIp: ip },
|
||||
});
|
||||
});
|
||||
const { subject, text, html } = resetEmail(user.first, resetUrl(token));
|
||||
const sent = await sendTo(email, subject, text, html);
|
||||
// Only a mail that left the building counts. A send that failed gave the coordinator nothing,
|
||||
// so charging them for it would shut them out for an hour over a mail outage.
|
||||
if (sent) fail(mailKey, 60 * 60 * 1000);
|
||||
else console.error("[forgot] reset requested but mail could not be sent for user", user.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Told to the caller regardless, so the answer carries no information about the address.
|
||||
return NextResponse.json({ ok: true, mail: transactionalConfigured() });
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { REMEMBER_MAX_AGE, pwVersion, setSessionCookie } from "@/lib/session";
|
||||
import { TRUST_COOKIE, mintTicket, readTrust } from "@/lib/twofactor";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { clientIp, fail, over } from "@/lib/ratelimit";
|
||||
import { signInStaff } from "@/lib/staffauth";
|
||||
import { verifyTurnstile } from "@/lib/turnstile";
|
||||
import { recordAuthEvent } from "@/lib/audit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Simple in-memory throttle per IP+email (per process).
|
||||
const attempts = new Map<string, { n: number; t: number }>();
|
||||
|
||||
/** The trail names the person, not the address they typed — see lib/audit.ts. */
|
||||
const actorFor = (u: { id: string; facilityId: string; first: string; last: string; email: string }) =>
|
||||
({ facilityId: u.facilityId, userId: u.id, userName: `${u.first} ${u.last}`.trim() || u.email });
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const csrf = sameOriginJson(req); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
let body: { email?: string; password?: string; cfToken?: string; remember?: unknown };
|
||||
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
|
||||
const email = String(body.email || "").trim().toLowerCase().slice(0, 160);
|
||||
const password = String(body.password || "").slice(0, 200);
|
||||
// Spray protection independent of the per-(ip,email) counter below. Both buckets count only the
|
||||
// attempts that FAILED — a whole hospital signs in from one NAT address at shift change, and a
|
||||
// ceiling on attempts would have to lock that ward out to be worth anything against an attacker.
|
||||
const ipKey = clientIp(req.headers);
|
||||
if (over("login-ip:" + ipKey, 40, 15 * 60 * 1000) || (email && over("login-email:" + email, 25, 15 * 60 * 1000))) return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 });
|
||||
if (!email || !password) return NextResponse.json({ error: "Enter your email and password." }, { status: 400 });
|
||||
|
||||
// nginx appends the real client IP last; earlier entries are client-supplied and spoofable.
|
||||
const xff = req.headers.get("x-forwarded-for")?.split(",").map((x) => x.trim()).filter(Boolean) || [];
|
||||
const ip = xff[xff.length - 1] || "local";
|
||||
if (attempts.size > 5000) for (const [kk, v] of attempts) if (Date.now() - v.t > 15 * 60 * 1000) attempts.delete(kk);
|
||||
const k = `${ip}|${email}`;
|
||||
const a = attempts.get(k);
|
||||
if (a && a.n >= 8 && Date.now() - a.t < 15 * 60 * 1000) return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 });
|
||||
|
||||
const cfErr = await verifyTurnstile(body.cfToken, ipKey); if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
|
||||
const u = await prisma.user.findUnique({ where: { email } });
|
||||
/* One box, both kinds of account.
|
||||
*
|
||||
* A wearer reaches the product the way anyone else does — the home page, then Log in — and types
|
||||
* the details they set up in the staff app. So when this address has no coordinator account, the
|
||||
* register is asked before the answer is called wrong.
|
||||
*
|
||||
* A coordinator account always wins: it is the one with the counter, the orders and the register
|
||||
* behind it, and a coordinator who also wears a uniform can open their own record from inside the
|
||||
* app. One address therefore has one destination, every time.
|
||||
*
|
||||
* This is a lookup, not a second attempt. "Try the coordinator, and if that fails try the staff
|
||||
* one" would score a failure against every single staff sign-in, and these ceilings count
|
||||
* failures — behind one hospital's NAT address at shift change that is a locked-out ward.
|
||||
*/
|
||||
if (!u) {
|
||||
const s = await signInStaff(email, password, ipKey, false);
|
||||
if (s.kind === "ok") return NextResponse.json({ ok: true, name: s.name, staff: true });
|
||||
if (s.kind === "error") return NextResponse.json({ error: s.error }, { status: s.status });
|
||||
// `none`: no staff account either, so this falls through to the answer below, which counts the
|
||||
// failure once and says the same thing it has always said.
|
||||
}
|
||||
const ok = u ? await bcrypt.compare(password, u.passwordHash) : await bcrypt.compare(password, "$2b$12$C6UzMDM.H6dfI/f/IKcEeO5x3FvDS3kqB6r0Jt3g7Lz0vX4o0JZ1u");
|
||||
if (!u || !ok) {
|
||||
attempts.set(k, { n: (a && Date.now() - a.t < 15 * 60 * 1000 ? a.n : 0) + 1, t: Date.now() });
|
||||
fail("login-ip:" + ipKey, 15 * 60 * 1000);
|
||||
if (email) fail("login-email:" + email, 15 * 60 * 1000);
|
||||
// An address with no account here is recorded nowhere: there is no facility to file it under,
|
||||
// and a log of attempts on addresses that don't exist would be a list of other people's email
|
||||
// addresses that nobody asked us to keep.
|
||||
if (u) recordAuthEvent(actorFor(u), "auth:signin.failed", ipKey);
|
||||
return NextResponse.json({ error: "Email or password doesn’t match." }, { status: 401 });
|
||||
}
|
||||
attempts.delete(k);
|
||||
if (u.inactive) {
|
||||
// The right password on an account that has been taken away is worth knowing about.
|
||||
recordAuthEvent(actorFor(u), "auth:signin.refused", ipKey, "inactive");
|
||||
return NextResponse.json({ error: "This account has been deactivated. Ask an admin at your facility to reactivate it." }, { status: 403 });
|
||||
}
|
||||
const fac = await prisma.facility.findUnique({ where: { id: u.facilityId }, select: { isDemo: true, ssoEnabled: true, ssoRequired: true } });
|
||||
if (fac?.isDemo) return NextResponse.json({ error: "Demo accounts can’t log in here — open the demo from the home page." }, { status: 403 });
|
||||
// The facility has decided its people sign in through its own identity provider. The password
|
||||
// was right, and it is still refused — except for the admin the facility keeps as its fire
|
||||
// escape. The box sends them on to single sign-on rather than reporting a failure.
|
||||
if (fac?.ssoEnabled && fac.ssoRequired && !u.ssoBreakGlass) {
|
||||
recordAuthEvent(actorFor(u), "auth:signin.refused", ipKey, "sso required");
|
||||
return NextResponse.json({ error: "Your facility signs in with single sign-on.", ssoRequired: true }, { status: 403 });
|
||||
}
|
||||
// With a second factor on the account the password alone opens nothing. The ticket says only
|
||||
// "this password was correct", is accepted by no other endpoint, and expires in five minutes.
|
||||
// A browser that entered a code within the last thirty days and asked to be trusted skips it;
|
||||
// the trust token is bound to the password version, so a changed password asks again.
|
||||
const trusted = !!u.totpEnabledAt && readTrust(req.cookies.get(TRUST_COOKIE)?.value, u.id, pwVersion(u.passwordHash));
|
||||
if (u.totpEnabledAt && !trusted) {
|
||||
return NextResponse.json({ need2fa: true, ticket: mintTicket(u.id, pwVersion(u.passwordHash)) });
|
||||
}
|
||||
|
||||
await setSessionCookie(u.id, u.passwordHash, false, body.remember === true ? REMEMBER_MAX_AGE : undefined);
|
||||
recordAuthEvent(actorFor(u), "auth:signin", ipKey, trusted ? "password+trusted" : "password");
|
||||
return NextResponse.json({ ok: true, name: `${u.first} ${u.last}`, role: u.role });
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { clearSessionCookie, currentUser } from "@/lib/session";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { clientIp } from "@/lib/ratelimit";
|
||||
import { recordAuthEvent } from "@/lib/audit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const csrf = sameOriginJson(req, false); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
// Read the session before dropping it, so the trail can say who left. An unauthenticated call
|
||||
// still clears the cookie and still answers ok — signing out must never fail.
|
||||
const user = await currentUser();
|
||||
await clearSessionCookie();
|
||||
if (user) {
|
||||
recordAuthEvent(
|
||||
{ facilityId: user.facilityId, userId: user.id, userName: `${user.first} ${user.last}`.trim() || user.email },
|
||||
"auth:signout", clientIp(req.headers),
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { allow, clientIp } from "@/lib/ratelimit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Which door does this address belong at?
|
||||
*
|
||||
* The sign-in screen asks for the address first and only then shows a password box, a single
|
||||
* sign-on button or a pointer to the staff app. This answers the last of those: an address that
|
||||
* has no coordinator account but does have a staff-app account belongs in the staff app, and
|
||||
* telling the person so beats a "wrong password" they can never get past. It answers nothing about
|
||||
* coordinator accounts — a coordinator address and an unknown address get the same reply, so the
|
||||
* box cannot be used to test which addresses have one. Throttled per connection like the SSO lookup. */
|
||||
export async function POST(req: NextRequest) {
|
||||
const csrf = sameOriginJson(req);
|
||||
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
if (!allow("lookup:" + clientIp(req.headers), 60, 15 * 60 * 1000)) return NextResponse.json({ staff: false });
|
||||
let body: { email?: unknown };
|
||||
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
|
||||
const email = String(body.email ?? "").trim().toLowerCase().slice(0, 160);
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return NextResponse.json({ staff: false });
|
||||
const user = await prisma.user.findUnique({ where: { email }, select: { id: true } });
|
||||
if (user) return NextResponse.json({ staff: false });
|
||||
const acc = await prisma.staffAccount.findUnique({ where: { email }, select: { id: true } });
|
||||
return NextResponse.json({ staff: !!acc });
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { allow, clientIp } from "@/lib/ratelimit";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { pwVersion, setSessionCookie } from "@/lib/session";
|
||||
import { mintTicket } from "@/lib/twofactor";
|
||||
import { hashResetToken } from "@/lib/reset";
|
||||
import { recordAuthEvent } from "@/lib/audit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const MIN_PASSWORD = 8;
|
||||
|
||||
/* Complete a password reset.
|
||||
*
|
||||
* Changing the hash invalidates every existing session for that user on its own — the session
|
||||
* cookie carries a version derived from the password hash — so a reset also kicks out whoever
|
||||
* prompted it, which is the behaviour you want if the reason was a shared or stolen password. */
|
||||
export async function POST(req: NextRequest) {
|
||||
const csrf = sameOriginJson(req);
|
||||
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
|
||||
const ip = clientIp(req.headers);
|
||||
if (!allow("reset-ip:" + ip, 100, 60 * 60 * 1000)) {
|
||||
return NextResponse.json({ error: "Too many attempts — try again later." }, { status: 429 });
|
||||
}
|
||||
|
||||
let body: { token?: unknown; password?: unknown };
|
||||
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
|
||||
const token = String(body.token ?? "").trim().slice(0, 400);
|
||||
const password = String(body.password ?? "");
|
||||
|
||||
if (!token) return NextResponse.json({ error: "That link is incomplete. Ask for a new one." }, { status: 400 });
|
||||
if (password.length < MIN_PASSWORD) {
|
||||
return NextResponse.json({ error: `Use at least ${MIN_PASSWORD} characters.` }, { status: 400 });
|
||||
}
|
||||
|
||||
// Looked up by hash, so the raw token never has to be compared against stored material.
|
||||
const row = await prisma.passwordReset.findUnique({
|
||||
where: { tokenHash: hashResetToken(token) },
|
||||
select: {
|
||||
id: true, userId: true, expiresAt: true, usedAt: true,
|
||||
user: { select: { inactive: true, passwordHash: true, totpEnabledAt: true, facilityId: true, first: true, last: true, email: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const dead = !row || row.usedAt || row.expiresAt.getTime() < Date.now() || row.user.inactive;
|
||||
if (dead) {
|
||||
return NextResponse.json({ error: "That link has expired or has already been used. Ask for a new one." }, { status: 400 });
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Consume the token in the same write as the password change, so a double submit can't set the
|
||||
// password twice or leave a live token behind.
|
||||
const consumed = await tx.passwordReset.updateMany({
|
||||
where: { id: row.id, usedAt: null },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
if (consumed.count !== 1) throw new Error("token already consumed");
|
||||
await tx.user.update({ where: { id: row.userId }, data: { passwordHash: hash } });
|
||||
// Any other outstanding requests for this account die with it.
|
||||
await tx.passwordReset.updateMany({ where: { userId: row.userId, usedAt: null }, data: { usedAt: new Date() } });
|
||||
}).catch(() => null);
|
||||
|
||||
const fresh = await prisma.user.findUnique({ where: { id: row.userId }, select: { passwordHash: true } });
|
||||
if (!fresh || fresh.passwordHash !== hash) {
|
||||
return NextResponse.json({ error: "That link has expired or has already been used. Ask for a new one." }, { status: 400 });
|
||||
}
|
||||
|
||||
const actor = {
|
||||
facilityId: row.user.facilityId,
|
||||
userId: row.userId,
|
||||
userName: [row.user.first, row.user.last].filter(Boolean).join(" ").trim() || row.user.email,
|
||||
};
|
||||
|
||||
// A second factor is a second factor here too. Control of the mailbox is one proof, and on an
|
||||
// account with TOTP the front door refuses to open on one proof — so this door must not either,
|
||||
// or resetting the password would be the supported way around the authenticator, and the new
|
||||
// password would then be enough to turn it off for good.
|
||||
//
|
||||
// The same five-minute ticket the sign-in screen uses, accepted by the same endpoint: nothing new
|
||||
// to keep, nothing new to get wrong.
|
||||
if (row.user.totpEnabledAt) {
|
||||
recordAuthEvent(actor, "auth:password.reset", ip, "email-link");
|
||||
return NextResponse.json({ need2fa: true, ticket: mintTicket(row.userId, pwVersion(hash)) });
|
||||
}
|
||||
|
||||
// Otherwise sign them straight in: they have just proven control of the mailbox and chosen a
|
||||
// password, and making them type it again immediately is friction with no security value.
|
||||
await setSessionCookie(row.userId, hash);
|
||||
recordAuthEvent(actor, "auth:password.reset", ip, "email-link");
|
||||
recordAuthEvent(actor, "auth:signin", ip, "reset");
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { setSessionCookie } from "@/lib/session";
|
||||
import { allow, clientIp } from "@/lib/ratelimit";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { verifyTurnstile } from "@/lib/turnstile";
|
||||
import { sendTo, transactionalConfigured } from "@/lib/mail";
|
||||
import { switches } from "@/lib/switches";
|
||||
import { alertNewSignup } from "@/lib/ops/alerts";
|
||||
import { recordAuthEvent } from "@/lib/audit";
|
||||
import { TRIAL_DAYS } from "@/lib/plan";
|
||||
import { sendBillingMail, templates } from "@/lib/billing-mail";
|
||||
import { welcomeEmail } from "@/lib/accountmail";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Starting staff groups by healthcare setting. Generic titles only — every room renames them. */
|
||||
const GROUP_SEEDS: Record<string, { staffGroups: string[]; nursingGroups: string[]; kitGroups: string[] }> = {
|
||||
hospital: { staffGroups: ["Registered Nurse", "Enrolled Nurse", "Allied Health", "Support Services", "Security"], nursingGroups: ["Registered Nurse", "Enrolled Nurse"], kitGroups: ["Support Services"] },
|
||||
aged_care: { staffGroups: ["Registered Nurse", "Enrolled Nurse", "Personal Care Worker", "Hospitality", "Maintenance"], nursingGroups: ["Registered Nurse", "Enrolled Nurse"], kitGroups: ["Hospitality", "Maintenance"] },
|
||||
community: { staffGroups: ["Registered Nurse", "Enrolled Nurse", "Allied Health", "Administration"], nursingGroups: ["Registered Nurse", "Enrolled Nurse"], kitGroups: ["Administration"] },
|
||||
};
|
||||
const STATE_ZONES: Record<string, string> = {
|
||||
QLD: "Australia/Brisbane", NSW: "Australia/Sydney", ACT: "Australia/Sydney", VIC: "Australia/Melbourne", TAS: "Australia/Hobart",
|
||||
SA: "Australia/Adelaide", NT: "Australia/Darwin", WA: "Australia/Perth", NZ: "Pacific/Auckland",
|
||||
};
|
||||
|
||||
/* Creating a facility.
|
||||
*
|
||||
* The address typed here is not verified, and deliberately isn't: a confirmation step in front of
|
||||
* a linen room's first ten minutes is a wall, and a facility half-created behind an unclicked link
|
||||
* is worse than one created. But it is the *only* way back in — /api/auth/forgot answers a
|
||||
* stranger and the owner identically, so a typo produces no signal at all until the day the
|
||||
* password is forgotten, and by then the facility is unreachable and undeletable.
|
||||
*
|
||||
* So the address is exercised immediately instead. A note goes to it saying, in as many words,
|
||||
* that this is the address that recovers the account, and the answer here says whether it was
|
||||
* sent — which is what lets the sign-up screen show the address back and tell someone who never
|
||||
* receives it what to do about it while they are still signed in and can still act.
|
||||
*/
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const sw = await switches();
|
||||
if (!sw.signupsOpen) return NextResponse.json({ error: "New facility sign-ups are closed." }, { status: 403 });
|
||||
const csrf = sameOriginJson(req); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
if (!allow("signup:" + clientIp(req.headers), 5, 60 * 60 * 1000)) return NextResponse.json({ error: "Too many sign-ups from this connection — try again later." }, { status: 429 });
|
||||
let b: Record<string, string>;
|
||||
try { b = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
|
||||
const first = String(b.first || "").trim().slice(0, 80), last = String(b.last || "").trim().slice(0, 80);
|
||||
const facility = String(b.facility || "").trim().slice(0, 120);
|
||||
const email = String(b.email || "").trim().toLowerCase().slice(0, 160);
|
||||
const password = String(b.password || "");
|
||||
if (!first || !last || !facility) return NextResponse.json({ error: "Name and facility are required." }, { status: 400 });
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return NextResponse.json({ error: "Enter a valid work email." }, { status: 400 });
|
||||
if (password.length < 8) return NextResponse.json({ error: "Password must be at least 8 characters." }, { status: 400 });
|
||||
const cfErr = await verifyTurnstile(b.cfToken, clientIp(req.headers)); if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
|
||||
if (await prisma.user.findUnique({ where: { email } })) return NextResponse.json({ error: "That email already has an account — log in instead." }, { status: 409 });
|
||||
|
||||
const u = await prisma.$transaction(async (tx) => {
|
||||
// No staff groups: the facility names its own. Any list handed over here would be one employer's
|
||||
// organisation chart on another employer's register, and a group sitting on a route nobody
|
||||
// chose decides who is handed a starting kit. Both route lists start empty with it, so until the
|
||||
// coordinator puts a group on the FTE table or the starting kit, everybody is on manager approval
|
||||
// and nobody has been promised a kit the counter would not hand over.
|
||||
// Until plans are live the page still says free, so a facility created today is grandfathered:
|
||||
// free with everything, for good. Once they are live a new room starts on the plan it chose —
|
||||
// Hosted Small, free, or a Hosted Facility trial with its end date set now. Anything else
|
||||
// sent as `plan` is Hosted Small: the free room is the safe misreading.
|
||||
const trial = sw.plansLive && b.plan === "hosted_facility";
|
||||
const planData = !sw.plansLive
|
||||
? { grandfathered: true, planStatus: "free" }
|
||||
: trial
|
||||
? { plan: "hosted_facility", planStatus: "trial", trialEndsAt: new Date(Date.now() + TRIAL_DAYS * 86_400_000) }
|
||||
: { plan: "hosted_small", planStatus: "free" };
|
||||
// Two optional answers from the sign-up screen. The setting seeds the staff groups the room
|
||||
// starts with (renamed or removed freely under Settings); the state sets the time zone counts
|
||||
// and month-end are read in. Neither is required, and "other"/blank leaves the old defaults.
|
||||
const seed = GROUP_SEEDS[String(b.setting || "")] || {};
|
||||
const timezone = STATE_ZONES[String(b.state || "").toUpperCase()];
|
||||
const f = await tx.facility.create({ data: { name: facility, coordinator: `${first} ${last}`, ...planData, ...seed, ...(timezone ? { timezone } : {}) } });
|
||||
return tx.user.create({ data: { facilityId: f.id, email, passwordHash: await bcrypt.hash(password, 12), first, last, title: "Uniform Coordinator", role: "ADMIN" } });
|
||||
});
|
||||
await setSessionCookie(u.id, u.passwordHash);
|
||||
recordAuthEvent({ facilityId: u.facilityId, userId: u.id, userName: `${first} ${last}`.trim() || email }, "auth:signup", clientIp(req.headers));
|
||||
alertNewSignup({ id: u.facilityId, name: facility }); // the facility's name only — never the person
|
||||
|
||||
const em = welcomeEmail(first, facility);
|
||||
const mailed = await sendTo(email, em.subject, em.text, em.html);
|
||||
// A room on a trial also gets the trial letter: what the 30 days include, when they end, and
|
||||
// that no card was taken. Not awaited — the welcome above is the one sign-up waits for.
|
||||
void (async () => {
|
||||
const f = await prisma.facility.findUnique({ where: { id: u.facilityId }, select: { planStatus: true, trialEndsAt: true } });
|
||||
if (f?.planStatus === "trial" && f.trialEndsAt) await sendBillingMail(u.facilityId, (ctx) => templates.trialStarted(ctx, { first, endsAt: f.trialEndsAt }));
|
||||
})();
|
||||
if (!mailed && transactionalConfigured()) console.error("[signup] welcome mail could not be sent for user", u.id);
|
||||
// `mailed` is false when no SMTP is configured at all, which is a different thing from a bad
|
||||
// address — the screen says so rather than pretending the address has been proven.
|
||||
return NextResponse.json({ ok: true, email, mailed, mail: transactionalConfigured() });
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { exportBackup } from "@/lib/ops";
|
||||
import { facilityToday } from "@/lib/compute";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
if (user.role !== "ADMIN") return NextResponse.json({ error: "Admin only" }, { status: 403 });
|
||||
const data = await exportBackup(user);
|
||||
// The date on the filename is the day where the linen room stands, not where the box is. It has
|
||||
// to agree with the lastBackup stamp exportBackup writes against the same facility, or a room
|
||||
// taking a backup at eight in the morning ends up with a file named for yesterday sitting beside
|
||||
// a settings screen that says it was taken today.
|
||||
const fac = await prisma.facility.findUniqueOrThrow({ where: { id: user.facilityId }, select: { timezone: true } });
|
||||
return new NextResponse(JSON.stringify(data, null, 1), {
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-disposition": `attachment; filename="threadcount-backup-${facilityToday(fac.timezone)}.json"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Is this server actually able to do its job?
|
||||
*
|
||||
* The deploy probes /app, which proves the process is serving HTML — but /app renders a redirect to
|
||||
* the sign-in page whether or not Prisma can reach the database, so the one failure that takes the
|
||||
* whole product down is exactly the one that probe cannot see. This asks the database a question
|
||||
* instead, and answers 503 when it cannot.
|
||||
*
|
||||
* No auth and no cache on purpose: it is watched continuously by an uptime monitor with no account,
|
||||
* and it must never answer from a cached success. It is listed in proxy.ts's `publicApi` for the
|
||||
* same reason. Nothing about the facility, the schema or the error is returned — a monitor needs a
|
||||
* status code, and an unauthenticated caller is owed nothing more.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
return NextResponse.json({ ok: true }, { headers: { "cache-control": "no-store" } });
|
||||
} catch (e) {
|
||||
console.error("[health] database unreachable:", (e as Error).message);
|
||||
return NextResponse.json({ ok: false }, { status: 503, headers: { "cache-control": "no-store" } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Serves the signed-in user's facility logo (stored as a data URL). */
|
||||
export async function GET() {
|
||||
const user = await currentUser();
|
||||
if (!user) return new NextResponse(null, { status: 401 });
|
||||
const fac = await prisma.facility.findUnique({ where: { id: user.facilityId }, select: { logoData: true } });
|
||||
const m = /^data:(image\/(?:png|jpeg|jpg|gif|webp));base64,([A-Za-z0-9+/=]+)$/.exec(fac?.logoData || "");
|
||||
if (!m) return new NextResponse(null, { status: 404 });
|
||||
return new NextResponse(Buffer.from(m[2], "base64"), { headers: { "content-type": m[1], "cache-control": "private, no-cache", "x-content-type-options": "nosniff", "content-security-policy": "sandbox" } });
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { allow } from "@/lib/ratelimit";
|
||||
import { gtinInfo } from "@/lib/compute";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export type LookupResult = {
|
||||
code: string;
|
||||
gtin: ReturnType<typeof gtinInfo>;
|
||||
enabled: boolean; // is public lookup turned on for this facility
|
||||
found: boolean;
|
||||
name?: string;
|
||||
brand?: string;
|
||||
category?: string;
|
||||
source?: string;
|
||||
note?: string; // why there's no result, in plain words
|
||||
};
|
||||
|
||||
const TIMEOUT_MS = 4500;
|
||||
const cache = new Map<string, { at: number; v: Omit<LookupResult, "enabled" | "gtin" | "code"> }>();
|
||||
const CACHE_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
async function getJson(url: string): Promise<unknown | null> {
|
||||
const ac = new AbortController();
|
||||
const t = setTimeout(() => ac.abort(), TIMEOUT_MS);
|
||||
try {
|
||||
const r = await fetch(url, { signal: ac.signal, headers: { accept: "application/json", "user-agent": "ThreadCount/1.0 (uniform stock management)" }, cache: "no-store" });
|
||||
if (!r.ok) return null;
|
||||
return await r.json();
|
||||
} catch { return null; } finally { clearTimeout(t); }
|
||||
}
|
||||
|
||||
/** UPCitemdb's keyless trial tier — small daily quota per server IP, so misses are expected. */
|
||||
async function upcItemDb(gtin: string) {
|
||||
const j = await getJson(`https://api.upcitemdb.com/prod/trial/lookup?upc=${encodeURIComponent(gtin)}`) as { items?: { title?: string; brand?: string; category?: string }[] } | null;
|
||||
const it = j?.items?.[0];
|
||||
if (!it?.title) return null;
|
||||
return { name: String(it.title).slice(0, 160), brand: String(it.brand || "").slice(0, 80), category: String(it.category || "").slice(0, 80), source: "UPCitemdb" };
|
||||
}
|
||||
|
||||
/** Open Products Facts — the non-food sibling of Open Food Facts; open data, no key. */
|
||||
async function openProductsFacts(gtin: string) {
|
||||
const j = await getJson(`https://world.openproductsfacts.org/api/v2/product/${encodeURIComponent(gtin)}.json?fields=product_name,brands,categories`) as { status?: number; product?: { product_name?: string; brands?: string; categories?: string } } | null;
|
||||
const pr = j?.product;
|
||||
if (j?.status !== 1 || !pr?.product_name) return null;
|
||||
return { name: String(pr.product_name).slice(0, 160), brand: String(pr.brands || "").slice(0, 80), category: String(pr.categories || "").slice(0, 80), source: "Open Products Facts" };
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
if (user.role !== "ADMIN") return NextResponse.json({ error: "Admin only" }, { status: 403 });
|
||||
|
||||
const gtin = gtinInfo(req.nextUrl.searchParams.get("code") || "");
|
||||
const base = { code: gtin.code, gtin, found: false } as LookupResult;
|
||||
if (!gtin.code) return NextResponse.json({ ...base, enabled: false, note: "No barcode given." });
|
||||
|
||||
const fac = await prisma.facility.findUnique({ where: { id: user.facilityId }, select: { barcodeLookup: true } });
|
||||
const enabled = !!fac?.barcodeLookup;
|
||||
if (!enabled) return NextResponse.json({ ...base, enabled: false, note: "Product lookup is off. Turn it on in Settings → Data if you want ThreadCount to ask a public barcode database for a name." });
|
||||
// Only real retail GTINs are worth sending anywhere; a mis-read or an in-house code never matches.
|
||||
if (!gtin.valid || !["EAN-13", "UPC-A", "EAN-8", "GTIN-14"].includes(gtin.kind)) {
|
||||
return NextResponse.json({ ...base, enabled, note: gtin.kind ? "The check digit doesn't match, so this wasn't looked up — scan it again." : "Not a standard retail barcode, so there's nothing to look up. Type the details in." });
|
||||
}
|
||||
if (!allow("lookup:" + user.facilityId, 120, 60 * 60 * 1000)) return NextResponse.json({ ...base, enabled, note: "Too many lookups this hour — type the details in for now." }, { status: 429 });
|
||||
|
||||
const hit = cache.get(gtin.digits);
|
||||
if (hit && Date.now() - hit.at < CACHE_MS) return NextResponse.json({ ...base, enabled, ...hit.v });
|
||||
|
||||
let found = await upcItemDb(gtin.digits);
|
||||
if (!found) found = await openProductsFacts(gtin.digits);
|
||||
const v = found
|
||||
? { found: true, ...found }
|
||||
: { found: false, note: "No public listing for this barcode — normal for workwear and hospital uniforms. Type the details in once and the barcode stays bound." };
|
||||
cache.set(gtin.digits, { at: Date.now(), v });
|
||||
if (cache.size > 500) for (const k of [...cache.keys()].slice(0, 100)) cache.delete(k);
|
||||
return NextResponse.json({ ...base, enabled, ...v });
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { OpError, bumpRev, demoGuard, restoreBackup, runOp } from "@/lib/ops";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { allow, clientIp } from "@/lib/ratelimit";
|
||||
import { recordAudit } from "@/lib/audit";
|
||||
import { report } from "@/lib/glitchtip";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
const csrf = sameOriginJson(req); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
if (parseInt(req.headers.get("content-length") || "0", 10) > 60 * 1024 * 1024) return NextResponse.json({ error: "Request too large" }, { status: 413 });
|
||||
let body: { op?: string; payload?: unknown };
|
||||
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad JSON" }, { status: 400 }); }
|
||||
const op = String(body.op || "");
|
||||
if (!allow("mutate:" + user.id, 600, 60 * 1000)) return NextResponse.json({ error: "Slow down — too many changes in a minute." }, { status: 429 });
|
||||
if (op === "photo.put" && !allow("photo:" + user.facilityId, 120, 60 * 60 * 1000)) return NextResponse.json({ error: "Photo limit reached for this hour." }, { status: 429 });
|
||||
if ((op === "backup.restore" || op === "import.rows") && !allow("bulk:" + user.id, 20, 10 * 60 * 1000)) return NextResponse.json({ error: "Too many imports — wait a few minutes." }, { status: 429 });
|
||||
try {
|
||||
if (op === "backup.restore") demoGuard(user, op);
|
||||
const result = op === "backup.restore" ? await restoreBackup(user, body.payload) : await runOp(user, op, body.payload);
|
||||
// Only after it actually succeeded, and only from here: every one of the 57 ops passes through
|
||||
// this one function, so the trail can't be forgotten in a new case branch later.
|
||||
recordAudit(user, op, body.payload, clientIp(req.headers));
|
||||
// Handed back so the screen that made this change does not bounce again when it next polls.
|
||||
const rev = await bumpRev(user.facilityId);
|
||||
return NextResponse.json({ ok: true, result, rev });
|
||||
} catch (e) {
|
||||
if (e instanceof OpError) return NextResponse.json({ error: e.message }, { status: e.status });
|
||||
// Reported from here, not from instrumentation.ts: onRequestError only sees what Next itself
|
||||
// catches, and an exception caught in this handler never reaches it. Every write in the product
|
||||
// comes through this line, so without it the whole write path fails invisibly.
|
||||
report({ error: e, where: "server", url: "/api/mutate", tags: { op } });
|
||||
console.error(`[mutate ${op}]`, e);
|
||||
return NextResponse.json({ error: "Something went wrong — nothing was saved." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { parseDataUrl, readPhoto } from "@/lib/photostore";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Serves a stored capture or signature to a signed-in user of the same facility.
|
||||
*
|
||||
* Images live on disk now; rows written before that move still carry a base64 data URL, so both
|
||||
* are handled and old records keep working without a flag day. */
|
||||
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
const { id } = await ctx.params;
|
||||
|
||||
// Scoped by facility in the query: a photo id from another room is simply not found.
|
||||
const ph = await prisma.photo.findFirst({
|
||||
where: { id, facilityId: user.facilityId },
|
||||
select: { data: true, path: true, mime: true },
|
||||
});
|
||||
if (!ph) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
let mime = ph.mime;
|
||||
let bytes: Buffer | null = null;
|
||||
|
||||
if (ph.path) {
|
||||
bytes = await readPhoto(ph.path);
|
||||
} else if (ph.data) {
|
||||
const parsed = parseDataUrl(ph.data);
|
||||
if (parsed) { mime = parsed.mime; bytes = parsed.bytes; }
|
||||
}
|
||||
|
||||
if (!bytes) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
if (!/^image\/(jpeg|png)$/.test(mime)) return NextResponse.json({ error: "Bad photo" }, { status: 500 });
|
||||
|
||||
return new NextResponse(new Uint8Array(bytes), {
|
||||
headers: {
|
||||
"content-type": mime,
|
||||
"cache-control": "private, max-age=3600",
|
||||
"content-disposition": "inline",
|
||||
"x-content-type-options": "nosniff",
|
||||
"content-security-policy": "sandbox",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { bagLines, linesSummary, reqLines } from "@/lib/staffdata";
|
||||
import { decisionSummary, garmentCount } from "@/lib/staffreq";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* The linen room's view of staff requests.
|
||||
*
|
||||
* Its own endpoint rather than part of the snapshot, for the same reason the audit trail is: this
|
||||
* grows without limit, and putting it in the snapshot would make every page in the app heavier
|
||||
* forever to serve one screen.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
|
||||
/* One person's requests, or the whole facility's.
|
||||
*
|
||||
* `?staff=` is how a staff record asks for its own order-form history. Without it that screen
|
||||
* pulled the facility's last 400 requests — every line, message and event on each — and kept the
|
||||
* handful belonging to one person, which is a large answer to a small question on a busy
|
||||
* register. Worse, that person's older requests fell off the end of the 400 and simply were not
|
||||
* on their record any more. Scoped, the ceiling is per person, and either way it is reported
|
||||
* back so a screen can say it has been reached rather than ending a history without a word.
|
||||
*/
|
||||
const staffId = (req.nextUrl.searchParams.get("staff") || "").trim().slice(0, 64);
|
||||
const requestLimit = staffId ? 200 : 400;
|
||||
|
||||
// One more row than is returned, so "there are older ones than these" is something we know
|
||||
// rather than something guessed from a full page.
|
||||
const found = await prisma.request.findMany({
|
||||
where: { facilityId: user.facilityId, ...(staffId ? { subjectId: staffId } : {}) },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: requestLimit + 1,
|
||||
include: {
|
||||
lines: { include: { item: { select: { item: true, gender: true, sizes: true } } }, orderBy: { sort: "asc" } },
|
||||
subject: { select: { first: true, last: true, num: true, dept: true } },
|
||||
messages: { orderBy: { createdAt: "asc" }, select: { id: true, fromStaff: true, authorName: true, body: true, createdAt: true } },
|
||||
events: { orderBy: { at: "asc" }, select: { id: true, label: true, meta: true, actorName: true, at: true } },
|
||||
},
|
||||
});
|
||||
const requests = found.slice(0, requestLimit);
|
||||
const moreRequests = found.length > requestLimit;
|
||||
|
||||
const mapped = requests.map((r) => {
|
||||
/* Every line, and separately the ones that are actually a pick.
|
||||
*
|
||||
* The linen room needs both. `lines` is the record — a declined fleece still belongs on the
|
||||
* order the wearer will read — while `bag` is the work: what to take off the shelf, put in
|
||||
* the bag and hand across the counter. Picking from `lines` would put a garment the manager
|
||||
* refused into somebody's hands, so the two are never the same field. */
|
||||
const lines = reqLines(r.lines);
|
||||
const bag = bagLines(lines);
|
||||
return {
|
||||
id: r.id, code: r.code, status: r.status,
|
||||
staffId: r.subjectId,
|
||||
staffName: `${r.subject.first} ${r.subject.last}`.trim(),
|
||||
staffNum: r.subject.num, ward: r.subject.dept,
|
||||
lines, bag,
|
||||
summary: linesSummary(lines), garments: garmentCount(bag), lineCount: lines.length,
|
||||
decision: decisionSummary(lines),
|
||||
reason: r.reason, note: r.note,
|
||||
managerName: r.managerName,
|
||||
/* Who the approver is, not just how their name is spelled. A manager may now approve a
|
||||
* request raised for herself, and the only thing that can show that happened is this id
|
||||
* beside the subject's — the name on its own would have any screen comparing two spellings
|
||||
* of the same person, which is precisely how a self-approval goes unnoticed. */
|
||||
managerId: r.managerId,
|
||||
declineReason: r.declineReason,
|
||||
route: r.route, collectCode: r.collectCode, holdUntil: r.holdUntil,
|
||||
signerName: r.signerName, signerRole: r.signerRole,
|
||||
signedAt: r.signedAt?.toISOString() ?? null,
|
||||
claimedAt: r.claimedAt?.toISOString() ?? null,
|
||||
/* Who raised it, and which person on the register that is.
|
||||
*
|
||||
* The name alone is not enough for the queue screen: it builds the list of people a stranded
|
||||
* request can be handed to, and the one name certain to be refused is the person who raised
|
||||
* it — a manager asking for one of her own reports' garments is exactly why the request
|
||||
* escalated with nobody to approve it. Told only her name, the screen would have to match
|
||||
* her by spelling against a ward where two people share one, which is how the wrong person
|
||||
* drops out of a dropdown.
|
||||
*
|
||||
* Only the staff column, because only it can ever name somebody who could approve anything.
|
||||
* A raise at the counter is stamped with the coordinator's own account instead, and a
|
||||
* coordinator is not on the ward register at all; a wearer raising for herself is stamped
|
||||
* with neither. Both arrive here as null, which is right — neither is a name this queue
|
||||
* could offer. */
|
||||
raisedById: r.raisedByStaffId,
|
||||
raisedByName: r.raisedByName,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
decidedAt: r.decidedAt?.toISOString() ?? null,
|
||||
messages: r.messages.map((m) => ({ id: m.id, fromStaff: m.fromStaff, authorName: m.authorName, body: m.body, at: m.createdAt.toISOString() })),
|
||||
events: r.events.map((e) => ({ id: e.id, label: e.label, meta: e.meta, actorName: e.actorName, at: e.at.toISOString() })),
|
||||
};
|
||||
});
|
||||
|
||||
/* Everything below is the linen room's queue screen — open disputes, the kit check, the
|
||||
* waitlist, damage nobody has handed back. A staff record asks for one person's order forms and
|
||||
* reads none of it, so a scoped ask stops here instead of running four more facility-wide
|
||||
* queries whose answers are thrown away. Those keys are absent from a scoped reply rather than
|
||||
* empty: an empty list would read as "there are none", which nobody asked and nobody knows. */
|
||||
if (staffId) return NextResponse.json({ requests: mapped, requestLimit, moreRequests });
|
||||
|
||||
const [disputes, cycle, waiting, damage] = await Promise.all([
|
||||
prisma.recordDispute.findMany({
|
||||
where: { facilityId: user.facilityId, resolvedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
include: { staff: { select: { first: true, last: true, num: true, dept: true } } },
|
||||
}),
|
||||
prisma.kitCheck.findFirst({
|
||||
where: { facilityId: user.facilityId, closedAt: null },
|
||||
orderBy: { openedAt: "desc" },
|
||||
select: { id: true, dueBy: true, openedAt: true, openedBy: true, _count: { select: { answers: true } } },
|
||||
}),
|
||||
prisma.waitlistEntry.findMany({
|
||||
where: { facilityId: user.facilityId, leftAt: null, acceptedAt: null },
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: {
|
||||
staff: { select: { first: true, last: true, num: true, dept: true } },
|
||||
item: { select: { item: true, sizes: true } },
|
||||
},
|
||||
}),
|
||||
// Damage reports the counter has not yet taken the garment back for. Reporting damage and
|
||||
// asking for a replacement are two separate acts in the staff app, so a report can arrive with
|
||||
// no request behind it — and until this list existed nothing in the product ever showed one to
|
||||
// anybody, which made the Damage screen's promise ("it comes off your record when you hand it
|
||||
// in at the counter") a promise no screen could keep.
|
||||
prisma.damageReport.findMany({
|
||||
where: { facilityId: user.facilityId, handedInAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
include: {
|
||||
staff: { select: { first: true, last: true, num: true, dept: true } },
|
||||
issue: { select: { sizeIndex: true, item: { select: { item: true, sizes: true } } } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
/* What the open kit check has actually turned up.
|
||||
*
|
||||
* The cycle used to be reported to the linen room as a bare count of answers, which is the one
|
||||
* thing about it that doesn't matter: nobody opens a kit check to find out how many people
|
||||
* replied. The answers are the point — every one where somebody could not account for what the
|
||||
* record says they hold — and until this query existed no screen, export or report in the
|
||||
* product read them, so the whole cycle collected evidence into a table nothing looked at.
|
||||
*
|
||||
* Only the shortfalls, and only for the cycle still open. An answer that matches the record is
|
||||
* the record agreeing with itself; a closed cycle is history and belongs with the rest of it.
|
||||
*/
|
||||
const answers = cycle
|
||||
? await prisma.kitCheckAnswer.findMany({
|
||||
where: { kitCheckId: cycle.id, confirmed: { lt: prisma.kitCheckAnswer.fields.onRecord } },
|
||||
orderBy: { answeredAt: "desc" },
|
||||
take: 400,
|
||||
include: {
|
||||
staff: { select: { id: true, first: true, last: true, num: true, dept: true } },
|
||||
item: { select: { item: true, sizes: true } },
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
// DamageReport.requestId is a plain column rather than a relation, so the replacement's code is
|
||||
// looked up here. It is what the linen room actually needs: "torn, and she has asked for R-0042"
|
||||
// is a different job from "torn, and she has not".
|
||||
const replacementCodes = new Map<string, string>();
|
||||
const replacementIds = damage.map((d) => d.requestId).filter((x): x is string => !!x);
|
||||
if (replacementIds.length) {
|
||||
const reps = await prisma.request.findMany({
|
||||
where: { facilityId: user.facilityId, id: { in: replacementIds } },
|
||||
select: { id: true, code: true },
|
||||
});
|
||||
for (const r of reps) replacementCodes.set(r.id, r.code);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
requests: mapped, requestLimit, moreRequests,
|
||||
disputes: disputes.map((d) => ({
|
||||
id: d.id, body: d.body,
|
||||
staffName: `${d.staff.first} ${d.staff.last}`.trim(),
|
||||
staffNum: d.staff.num, ward: d.staff.dept,
|
||||
at: d.createdAt.toISOString(),
|
||||
})),
|
||||
cycle: cycle && {
|
||||
id: cycle.id, dueBy: cycle.dueBy, openedBy: cycle.openedBy,
|
||||
openedAt: cycle.openedAt.toISOString(), answers: cycle._count.answers,
|
||||
},
|
||||
shortfalls: answers.map((a) => ({
|
||||
id: a.id,
|
||||
staffId: a.staff.id,
|
||||
staffName: `${a.staff.first} ${a.staff.last}`.trim(),
|
||||
staffNum: a.staff.num, ward: a.staff.dept,
|
||||
item: a.item.item, size: String(a.item.sizes[a.sizeIndex] ?? a.sizeIndex),
|
||||
onRecord: a.onRecord, confirmed: a.confirmed, short: a.onRecord - a.confirmed,
|
||||
at: a.answeredAt.toISOString(),
|
||||
})),
|
||||
waiting: waiting.map((w) => ({
|
||||
id: w.id,
|
||||
staffName: `${w.staff.first} ${w.staff.last}`.trim(),
|
||||
staffNum: w.staff.num, ward: w.staff.dept,
|
||||
item: w.item.item, size: String(w.item.sizes[w.sizeIndex] ?? w.sizeIndex),
|
||||
since: w.createdAt.toISOString(),
|
||||
offeredAt: w.offeredAt?.toISOString() ?? null,
|
||||
})),
|
||||
damage: damage.map((d) => ({
|
||||
id: d.id, kind: d.kind, note: d.note, photoId: d.photoId,
|
||||
staffId: d.staffId,
|
||||
staffName: `${d.staff.first} ${d.staff.last}`.trim(),
|
||||
staffNum: d.staff.num, ward: d.staff.dept,
|
||||
// The garment comes off the Issue the report was raised against. That issue can be deleted
|
||||
// (a wipe, a correction) and the column is SetNull, so an older report may name no garment.
|
||||
item: d.issue ? d.issue.item.item : "",
|
||||
size: d.issue ? String(d.issue.item.sizes[d.issue.sizeIndex] ?? d.issue.sizeIndex) : "",
|
||||
requestCode: d.requestId ? replacementCodes.get(d.requestId) ?? "" : "",
|
||||
at: d.createdAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* "Has anything changed?", answered in one integer.
|
||||
*
|
||||
* Every screen in the product already knows how to reload itself — a mutation ends in
|
||||
* router.refresh(). What it could not know was that somebody ELSE had changed something, so a
|
||||
* phone left open on a ward showed whatever the catalogue looked like when it was opened, and a
|
||||
* coordinator adding a garment at the desk had to tell the counter to pull down to refresh.
|
||||
*
|
||||
* The obvious fix — poll the snapshot and diff it — is the expensive one: that is the facility's
|
||||
* catalogue, staff register, stock and history, re-read on a timer by every open device to learn,
|
||||
* almost always, that nothing happened. This returns the counter that the three mutating routes
|
||||
* bump, so the cost of asking is a primary-key lookup, and the cost of the real reload is paid only
|
||||
* when the number has actually moved.
|
||||
*
|
||||
* Both session kinds answer here. A coordinator at the desk and a wearer on a ward are watching the
|
||||
* same facility, and there is nothing in a bare revision number to keep apart — it says that
|
||||
* something changed, never what. Anyone with no session at all gets 401 rather than a number,
|
||||
* because even "this facility is busy" is not ours to hand out.
|
||||
*/
|
||||
export async function GET(_req: NextRequest) {
|
||||
const user = await currentUser();
|
||||
const facilityId = user?.facilityId || (await currentStaff())?.facilityId;
|
||||
if (!facilityId) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
|
||||
const f = await prisma.facility.findUnique({ where: { id: facilityId }, select: { rev: true } });
|
||||
if (!f) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
|
||||
// Never from a cache: a stale revision is indistinguishable from nothing having happened, which
|
||||
// is the one wrong answer this endpoint can give.
|
||||
return NextResponse.json({ rev: f.rev }, { headers: { "cache-control": "no-store" } });
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { allow, clientIp } from "@/lib/ratelimit";
|
||||
import { normaliseCode, setStaffCookie } from "@/lib/staffsession";
|
||||
import { verifyTurnstile } from "@/lib/turnstile";
|
||||
import { recordAuthEvent } from "@/lib/audit";
|
||||
import { bumpRev } from "@/lib/ops";
|
||||
import { SLIP_DAYS, facilityToday, slipLive } from "@/lib/compute";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const MIN_PW = 8;
|
||||
|
||||
/* Claiming your own record with the code the linen room printed for you.
|
||||
*
|
||||
* The code alone identifies the person, because it is presented before we know anything about them
|
||||
* — there is no facility to scope it to and no email to look up yet. That is why it is globally
|
||||
* unique, why it is 58 bits wide, and why this route is throttled to the point where working
|
||||
* through the space is not a strategy.
|
||||
*
|
||||
* It is spent in the same update that finds it, so two people racing the same slip can't both
|
||||
* claim the record; the loser gets the ordinary "code isn't right" message.
|
||||
*
|
||||
* It also goes stale on its own after fourteen days, because the far more likely way a slip is
|
||||
* misused is not a guessed code but a printed one nobody ever collected.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const csrf = sameOriginJson(req);
|
||||
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
|
||||
const ip = clientIp(req.headers);
|
||||
if (!allow("staff-activate:" + ip, 200, 15 * 60 * 1000)) {
|
||||
return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 });
|
||||
}
|
||||
|
||||
let body: { code?: unknown; email?: unknown; password?: unknown; cfToken?: unknown; agreed?: unknown };
|
||||
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
|
||||
|
||||
const code = normaliseCode(String(body.code ?? ""));
|
||||
const email = String(body.email ?? "").trim().toLowerCase().slice(0, 160);
|
||||
const password = String(body.password ?? "").slice(0, 200);
|
||||
|
||||
if (!code) return NextResponse.json({ error: "That code isn't right. It's twelve characters, in three groups." }, { status: 400 });
|
||||
if (!/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(email)) return NextResponse.json({ error: "Enter an email address you can get to." }, { status: 400 });
|
||||
if (password.length < MIN_PW) return NextResponse.json({ error: `Use at least ${MIN_PW} characters for your password.` }, { status: 400 });
|
||||
// The agreement is collected where the account is created. The screen's tick is what sets it,
|
||||
// and the door checks it too so a client that skips the box gets the same answer.
|
||||
if (body.agreed !== true) return NextResponse.json({ error: "Tick the box to agree to the terms of use and privacy policy." }, { status: 400 });
|
||||
|
||||
// Checked before the code is looked up, so a bot working through the code space is stopped by
|
||||
// Cloudflare rather than by the per-IP throttle alone.
|
||||
const cfErr = await verifyTurnstile(body.cfToken, ip);
|
||||
if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
|
||||
|
||||
const staff = await prisma.staff.findUnique({
|
||||
where: { activateCode: code },
|
||||
select: { id: true, facilityId: true, first: true, last: true, inactive: true, activateCodeAt: true, account: { select: { id: true } }, facility: { select: { timezone: true } } },
|
||||
});
|
||||
// One message for every way this can fail, so the response can't be used to tell a real code from
|
||||
// a spent one.
|
||||
const nope = () => NextResponse.json({ error: "That code isn't right, or it has already been used. Ask the linen room for a new one." }, { status: 400 });
|
||||
if (!staff || staff.inactive || staff.account) return nope();
|
||||
|
||||
/* An old slip is refused whether or not anyone ever claimed it. It is a bearer token on paper:
|
||||
* whoever picks one out of a folder months later can bind their own email and password to this
|
||||
* person's record and from then on be them — their issues, their requests, their signature on the
|
||||
* ward round, and their approvals queue if they manage anyone. Fourteen days is long enough for
|
||||
* someone on leave to come back to it and short enough that a forgotten one is dead by the time
|
||||
* it turns up.
|
||||
*
|
||||
* An unstamped code counts as stale: its age is unknown, so it has to be assumed old. This leans
|
||||
* on staff.selfCode in lib/ops.ts stamping activateCodeAt as it prints — if that stamp ever stops
|
||||
* being written, every new slip is dead on arrival.
|
||||
*
|
||||
* This says plainly that the slip has expired rather than joining the deliberately vague message
|
||||
* above. Landing here means the code was right, and a code is 58 bits behind a throttle and a
|
||||
* Turnstile — so anyone who gets this far is holding a real slip and needs to be told that a
|
||||
* reprint, not a retype, is the fix.
|
||||
*
|
||||
* The age is asked of slipLive() in lib/compute, the same test the staff register and the requests
|
||||
* queue use to say whether a slip is still worth chasing, counted in whole days on the facility's
|
||||
* own calendar. The day a coordinator's screen calls a slip expired is therefore the day this
|
||||
* refuses it — never a few hours later, with a nurse who was told it was dead finding it still
|
||||
* works, or one who was told it was fine being turned away. */
|
||||
const tz = staff.facility.timezone;
|
||||
if (!slipLive(staff.activateCodeAt, facilityToday(tz), tz)) {
|
||||
return NextResponse.json(
|
||||
{ error: `That code was printed ${SLIP_DAYS} or more days ago, so it has expired. Ask the linen room to print you a new slip.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// The email has to be free across staff accounts. Coordinator accounts live in a different table
|
||||
// and a person may legitimately be both — a linen-room supervisor who also wears the uniform.
|
||||
const taken = await prisma.staffAccount.findUnique({ where: { email }, select: { id: true } });
|
||||
if (taken) return NextResponse.json({ error: "That email is already on an account here. Sign in instead." }, { status: 400 });
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
|
||||
// Spend the code first, conditionally. If it has gone in the meantime, nothing was created.
|
||||
// The stamp goes with the code, so a spent row can't be read as a slip still waiting out there.
|
||||
const spent = await prisma.staff.updateMany({ where: { id: staff.id, activateCode: code }, data: { activateCode: null, activateCodeAt: null } });
|
||||
if (spent.count !== 1) return nope();
|
||||
|
||||
let account;
|
||||
try {
|
||||
account = await prisma.staffAccount.create({
|
||||
data: { facilityId: staff.facilityId, staffId: staff.id, email, passwordHash },
|
||||
select: { id: true, passwordHash: true },
|
||||
});
|
||||
} catch {
|
||||
// The code is gone but the account didn't happen — put the code back rather than stranding
|
||||
// someone with a dead slip. The original print date goes back with it: a failed attempt is not
|
||||
// a reprint and must not restart the fourteen days.
|
||||
await prisma.staff.update({ where: { id: staff.id }, data: { activateCode: code, activateCodeAt: staff.activateCodeAt } }).catch(() => {});
|
||||
return NextResponse.json({ error: "That didn't work — try again." }, { status: 500 });
|
||||
}
|
||||
|
||||
await setStaffCookie(account.id, account.passwordHash);
|
||||
recordAuthEvent(
|
||||
{ facilityId: staff.facilityId, userId: staff.id, userName: `${staff.first} ${staff.last}`.trim() || email },
|
||||
"staff:activate", ip,
|
||||
);
|
||||
// The fourth door that changes a facility's data, and the only one outside the three mutate
|
||||
// routes. Without this the coordinator standing over the nurse while she activates keeps seeing
|
||||
// "code outstanding" until some unrelated edit moves the revision, and reissues a code that
|
||||
// can't be reissued.
|
||||
await bumpRev(staff.facilityId);
|
||||
return NextResponse.json({ ok: true, name: `${staff.first} ${staff.last}` });
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { allow, clientIp } from "@/lib/ratelimit";
|
||||
import { readApprovalToken } from "@/lib/approvallink";
|
||||
import { StaffOpError, decideRequest } from "@/lib/staffops";
|
||||
import { recordFor } from "@/lib/audit";
|
||||
import { bumpRev } from "@/lib/ops";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Deciding a request from the emailed link, without signing in.
|
||||
*
|
||||
* This is a POST and only a POST. The link in the email is a GET that renders /my/approve, and the
|
||||
* decision is made from that page — because corporate mail scanners and link-preview crawlers
|
||||
* fetch every URL in every message, and a GET that approved a uniform request would be approved by
|
||||
* the mail gateway before the manager ever saw it.
|
||||
*
|
||||
* The decision itself is decideRequest()'s, not this route's. A request now carries a line per
|
||||
* garment, and settling it means settling every line and then rolling the request up from them;
|
||||
* an approval made here that moved only the request would leave every line `awaiting`, so the
|
||||
* linen room's bag would come out empty and the wearer's order would show no decision at all.
|
||||
* There is no room on this page for a garment-by-garment answer — there is no signed-in person to
|
||||
* check one against — so it takes the whole-request shorthand, `approveAll`, which is the reason
|
||||
* that argument exists.
|
||||
*
|
||||
* Single use falls out of the state machine rather than a table of spent tokens: decideRequest's
|
||||
* update is conditional on the request still being `awaiting`, so the approve link and the decline
|
||||
* link in the same email both stop working the moment either is used.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const csrf = sameOriginJson(req);
|
||||
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
|
||||
const ip = clientIp(req.headers);
|
||||
if (!allow("staff-decide:" + ip, 200, 15 * 60 * 1000)) {
|
||||
return NextResponse.json({ error: "Too many attempts — try again in a few minutes." }, { status: 429 });
|
||||
}
|
||||
|
||||
let body: { token?: unknown; action?: unknown; reason?: unknown };
|
||||
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
|
||||
|
||||
const claim = readApprovalToken(String(body.token ?? ""));
|
||||
if (!claim) return NextResponse.json({ error: "That link has expired. Open the app and use the approvals queue." }, { status: 400 });
|
||||
|
||||
const action = String(body.action ?? "");
|
||||
if (action !== "approve" && action !== "decline") return NextResponse.json({ error: "Unknown action" }, { status: 400 });
|
||||
|
||||
let done: Awaited<ReturnType<typeof decideRequest>>;
|
||||
try {
|
||||
done = await decideRequest({
|
||||
requestId: claim.rid,
|
||||
managerId: claim.mid,
|
||||
approveAll: action === "approve",
|
||||
reason: body.reason,
|
||||
});
|
||||
} catch (e) {
|
||||
if (!(e instanceof StaffOpError)) throw e;
|
||||
/* The refusals are worded for somebody standing in their mail client, not in the app.
|
||||
*
|
||||
* A 403 here is decideRequest re-reading the register and finding the manager off it, or the
|
||||
* wearer off it — the check that makes a fortnight-old token in a mailbox that has since been
|
||||
* closed or handed on safe. Neither the sacked manager nor a stranger reading their mail is
|
||||
* told which of the two it was; "ask the linen room" is where that conversation belongs.
|
||||
*
|
||||
* A 409 is the link already spent, and keeps the `already` flag the page reads to show the
|
||||
* decision that was made rather than an error. */
|
||||
if (e.status === 403) return NextResponse.json({ error: "That link is no longer valid — ask the linen room." }, { status: 403 });
|
||||
if (e.status === 404) return NextResponse.json({ error: "That request is no longer there." }, { status: 404 });
|
||||
if (e.status === 409) return NextResponse.json({ error: "That request has already been decided.", already: true }, { status: 409 });
|
||||
return NextResponse.json({ error: e.message }, { status: e.status });
|
||||
}
|
||||
|
||||
// Filed under the manager's own Staff id, exactly as the in-app approval is, so the log names
|
||||
// the same person either way; the op says which door the decision came through, because "an
|
||||
// email link, from an address we can't see" is part of the answer to who authorised this.
|
||||
recordFor(
|
||||
{ facilityId: done.facilityId, userId: claim.mid, userName: done.managerName },
|
||||
done.status === "accepted" ? "staff:request.approve.email" : "staff:request.decline.email",
|
||||
{ id: claim.rid }, ip,
|
||||
);
|
||||
|
||||
// The third door into the facility's data, so the third place the revision has to move: a manager
|
||||
// approving from their mail is exactly the change the linen room's screen is waiting to see.
|
||||
await bumpRev(done.facilityId);
|
||||
|
||||
return NextResponse.json({ ok: true, status: done.status, notified: done.notified });
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { clientIp } from "@/lib/ratelimit";
|
||||
import { signInStaff, staffThrottled } from "@/lib/staffauth";
|
||||
import { verifyTurnstile } from "@/lib/turnstile";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* The staff app's own door: the printed slip, the Play app's welcome, and /my/signin.
|
||||
*
|
||||
* The Log in box on the website reaches the same register through lib/staffauth.ts, so what counts
|
||||
* as a match, what a deactivated record is told, and what lands in the audit trail are decided in
|
||||
* one place for both. This route is the HTTP shape of it: the origin check, the security check, and
|
||||
* the throttle asked in that order. */
|
||||
export async function POST(req: NextRequest) {
|
||||
const csrf = sameOriginJson(req);
|
||||
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
|
||||
let body: { email?: unknown; password?: unknown; cfToken?: unknown };
|
||||
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
|
||||
const email = String(body.email ?? "").trim().toLowerCase().slice(0, 160);
|
||||
const password = String(body.password ?? "").slice(0, 200);
|
||||
|
||||
const ip = clientIp(req.headers);
|
||||
// Asked before the security check, because a Turnstile token is good for one use and somebody who
|
||||
// is already throttled should not spend theirs to be told so.
|
||||
if (staffThrottled(email, ip)) {
|
||||
return NextResponse.json({ error: "Too many attempts — try again in 15 minutes." }, { status: 429 });
|
||||
}
|
||||
if (!email || !password) return NextResponse.json({ error: "Enter your email and password." }, { status: 400 });
|
||||
|
||||
// The same bot check the coordinator door has. A ward account opens one person's uniform record,
|
||||
// and a manager's opens the approvals queue, so leaving this to the in-memory throttles alone
|
||||
// meant a list of hospital addresses and enough patience was the whole attack.
|
||||
const cfErr = await verifyTurnstile(body.cfToken, ip);
|
||||
if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
|
||||
|
||||
// `true`: this is the register's own door, so an address with no account here is a plain wrong
|
||||
// answer and is counted as one.
|
||||
const r = await signInStaff(email, password, ip, true);
|
||||
if (r.kind === "ok") return NextResponse.json({ ok: true, name: r.name });
|
||||
if (r.kind === "error") return NextResponse.json({ error: r.error }, { status: r.status });
|
||||
// Unreachable at this door: `none` is only returned when the caller asked not to be counted.
|
||||
return NextResponse.json({ error: "Email or password doesn’t match." }, { status: 401 });
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { clearStaffCookie, currentStaff } from "@/lib/staffsession";
|
||||
import { clientIp } from "@/lib/ratelimit";
|
||||
import { recordAuthEvent } from "@/lib/audit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const csrf = sameOriginJson(req);
|
||||
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
// Read the session before dropping it, so the trail can say who left. Signing out still succeeds
|
||||
// when there was nothing to sign out of.
|
||||
const sess = await currentStaff();
|
||||
await clearStaffCookie();
|
||||
if (sess) {
|
||||
recordAuthEvent(
|
||||
{ facilityId: sess.facilityId, userId: sess.staffId, userName: `${sess.first} ${sess.last}`.trim() || sess.email },
|
||||
"staff:signout", clientIp(req.headers),
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { StaffOpError, runStaffOp } from "@/lib/staffops";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { allow, clientIp } from "@/lib/ratelimit";
|
||||
import { recordStaffAudit } from "@/lib/audit";
|
||||
import { bumpRev } from "@/lib/ops";
|
||||
import { report } from "@/lib/glitchtip";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* The one door for everything a wearer, manager or ward clerk changes.
|
||||
*
|
||||
* Separate from /api/mutate, and reached only with a staff session. The two never share a handler:
|
||||
* a single endpoint that accepted either kind of caller would put the whole coordinator op table
|
||||
* one authorisation slip away from a wearer's phone.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
const csrf = sameOriginJson(req);
|
||||
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
|
||||
let body: { op?: string; payload?: unknown };
|
||||
try { body = await req.json(); } catch { return NextResponse.json({ error: "Bad JSON" }, { status: 400 }); }
|
||||
const op = String(body.op || "");
|
||||
|
||||
if (!allow("staff-mutate:" + sess.accountId, 120, 60 * 1000)) {
|
||||
return NextResponse.json({ error: "Slow down — too many changes in a minute." }, { status: 429 });
|
||||
}
|
||||
// Requests are the expensive ones: each sends an email to a manager. A tighter budget stops a
|
||||
// stuck retry loop turning into a mailbox full of the same approval.
|
||||
// damage.report and waitlist.accept raise a request (and mail the manager) through the same
|
||||
// path, so they draw on the same budget — otherwise the loop just picks a different door.
|
||||
if (["request.create", "damage.report", "waitlist.accept"].includes(op) && !allow("staff-request:" + sess.staffId, 12, 60 * 60 * 1000)) {
|
||||
return NextResponse.json({ error: "That's a lot of requests in an hour — talk to the linen room." }, { status: 429 });
|
||||
}
|
||||
|
||||
const ip = clientIp(req.headers);
|
||||
try {
|
||||
const payload = (body.payload || {}) as Record<string, unknown>;
|
||||
const result = await runStaffOp(sess, op, payload);
|
||||
// The same discipline as /api/mutate, and for the same reason: a uniform issued to a ward is
|
||||
// authorised here as often as it is in the linen room, and "who approved this" is the question
|
||||
// the trail exists to answer. Recorded only after the op actually succeeded.
|
||||
recordStaffAudit(sess, op, payload, ip, result);
|
||||
// Handed back so the screen that made this change does not bounce again when it next polls.
|
||||
const rev = await bumpRev(sess.facilityId);
|
||||
return NextResponse.json({ ok: true, result, rev });
|
||||
} catch (e) {
|
||||
if (e instanceof StaffOpError) return NextResponse.json({ error: e.message }, { status: e.status });
|
||||
report({ error: e, where: "server", url: "/api/staff/mutate", tags: { op } });
|
||||
console.error(`[staff mutate ${op}]`, e, "ip=", ip);
|
||||
return NextResponse.json({ error: "Something went wrong — nothing was saved." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { parseDataUrl, readPhoto } from "@/lib/photostore";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* The signature on one of a staff member's own signed slips, for the staff app.
|
||||
*
|
||||
* Only the caller's own slip, and only one the counter sent to their app (toStaff). Any other id is
|
||||
* simply not found, whoever's it is. Served with the same headers as /api/photo/[id]. */
|
||||
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) return NextResponse.json({ error: "Not signed in" }, { status: 401 });
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const slip = await prisma.slip.findFirst({
|
||||
where: { id: String(id || "").slice(0, 40), staffId: sess.staffId, facilityId: sess.facilityId, toStaff: true },
|
||||
select: { sigId: true, facilityId: true },
|
||||
});
|
||||
if (!slip?.sigId) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const ph = await prisma.photo.findFirst({
|
||||
where: { id: slip.sigId, facilityId: slip.facilityId },
|
||||
select: { data: true, path: true, mime: true },
|
||||
});
|
||||
if (!ph) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
let mime = ph.mime;
|
||||
let bytes: Buffer | null = null;
|
||||
if (ph.path) {
|
||||
bytes = await readPhoto(ph.path);
|
||||
} else if (ph.data) {
|
||||
const parsed = parseDataUrl(ph.data);
|
||||
if (parsed) { mime = parsed.mime; bytes = parsed.bytes; }
|
||||
}
|
||||
if (!bytes) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
if (!/^image\/(jpeg|png)$/.test(mime)) return NextResponse.json({ error: "Bad photo" }, { status: 500 });
|
||||
|
||||
return new NextResponse(new Uint8Array(bytes), {
|
||||
headers: {
|
||||
"content-type": mime,
|
||||
"cache-control": "private, max-age=3600",
|
||||
"content-disposition": "inline",
|
||||
"x-content-type-options": "nosniff",
|
||||
"content-security-policy": "sandbox",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/* The activity log now lives in Settings › Data & audit log. next.config.ts redirects this path as
|
||||
* well; this stub keeps old links working if that map ever changes. Query strings carry over. */
|
||||
export default async function Activity({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
|
||||
const sp = await searchParams;
|
||||
const q = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(sp)) {
|
||||
if (k === "tab" || v === undefined) continue;
|
||||
for (const one of Array.isArray(v) ? v : [v]) q.append(k, one);
|
||||
}
|
||||
q.set("tab", "audit");
|
||||
redirect(`/app/settings?${q.toString()}`);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
import { Suspense } from "react";
|
||||
import Counter from "@/components/counter/Counter";
|
||||
|
||||
// useSearchParams (?staff=, ?mode=) needs a Suspense boundary for static rendering.
|
||||
export default function CounterPage() {
|
||||
return <Suspense fallback={null}><Counter /></Suspense>;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import ManualShell from "@/components/ManualShell";
|
||||
import { ManualArticle } from "@/components/ManualView";
|
||||
import { findPage, neighbours } from "@/lib/manual";
|
||||
|
||||
/* One manual page inside the app: the same Markdown the website renders at /docs, framed by the
|
||||
* app's own shell. The help mark on each screen links straight here. */
|
||||
|
||||
type Params = { params: Promise<{ section: string; slug: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: Params): Promise<Metadata> {
|
||||
const { section, slug } = await params;
|
||||
const p = findPage(section, slug);
|
||||
return { title: p ? `${p.title} · Help` : "Help", robots: { index: false, follow: false } };
|
||||
}
|
||||
|
||||
export default async function HelpPage({ params }: Params) {
|
||||
const { section, slug } = await params;
|
||||
const p = findPage(section, slug);
|
||||
if (!p) notFound();
|
||||
const { prev, next } = neighbours(p);
|
||||
return (
|
||||
<ManualShell base="/app/help" current={{ section, slug }} headings={p.headings}>
|
||||
<ManualArticle page={p} base="/app/help" prev={prev} next={next} />
|
||||
</ManualShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import ManualHome from "@/components/ManualHome";
|
||||
import ManualShell from "@/components/ManualShell";
|
||||
import FacilityRules from "@/components/FacilityRules";
|
||||
|
||||
export const metadata = { title: "Help", robots: { index: false, follow: false } };
|
||||
|
||||
/* Help inside the app: the manual's front page, with this facility's own rules above it. The rules
|
||||
* panel reads the facility's settings, so a figure a coordinator has changed is the figure shown;
|
||||
* the manual pages quote the defaults and say where each one is changed. */
|
||||
export default function Help() {
|
||||
return (
|
||||
<ManualShell base="/app/help">
|
||||
<ManualHome
|
||||
base="/app/help"
|
||||
title="Help"
|
||||
lede="The ThreadCount manual, and this facility's own rules. Every screen also has a help mark beside its title that opens the page about that screen."
|
||||
>
|
||||
<section className="mn-section" id="rules">
|
||||
<h2 className="mn-h2"><span className="n">00</span><span>This facility’s rules</span></h2>
|
||||
<p className="mn-p">Read from your settings, so these are the figures the counter applies today.</p>
|
||||
<FacilityRules />
|
||||
</section>
|
||||
</ManualHome>
|
||||
</ManualShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/* Issue Stock became the Counter. Old links, bookmarks and badge scans keep their query string. */
|
||||
export default async function IssueRedirect({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
const sp = await searchParams;
|
||||
const q = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(sp)) {
|
||||
if (Array.isArray(v)) v.forEach((x) => q.append(k, x));
|
||||
else if (v !== undefined) q.set(k, v);
|
||||
}
|
||||
const qs = q.toString();
|
||||
redirect(`/app/counter${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { buildSnapshot } from "@/lib/snapshot";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { SnapshotProvider } from "@/lib/client";
|
||||
import type { ServerCounts } from "@/lib/portalcounts";
|
||||
import Shell from "@/components/Shell";
|
||||
import Analytics from "@/components/Analytics";
|
||||
import Helpdesk from "@/components/Helpdesk";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = await currentUser();
|
||||
if (!user) redirect("/auth");
|
||||
const facilityId = user.facilityId;
|
||||
// The four rail counts the snapshot cannot make: requests, record queries and damage live in
|
||||
// their own tables and are never loaded into the snapshot. mutate()'s router.refresh() re-runs
|
||||
// this layout, so the badges follow every write.
|
||||
const [snap, pick, stranded, queries, damage] = await Promise.all([
|
||||
buildSnapshot(user),
|
||||
prisma.request.count({ where: { facilityId, status: "accepted" } }),
|
||||
prisma.request.count({ where: { facilityId, status: "awaiting", managerName: "" } }),
|
||||
prisma.recordDispute.count({ where: { facilityId, resolvedAt: null } }),
|
||||
prisma.damageReport.count({ where: { facilityId, handedInAt: null } }),
|
||||
]);
|
||||
const serverCounts: ServerCounts = { pick, stranded, queries, damage };
|
||||
return (
|
||||
<SnapshotProvider snap={snap}>
|
||||
<Shell serverCounts={serverCounts}>{children}</Shell>
|
||||
<Analytics site="app" />
|
||||
<Helpdesk />
|
||||
</SnapshotProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { PageHead, Empty, Field, ItemSizePicker, LiveRegion } from "@/components/ui";
|
||||
import { ReceiveDialog } from "@/components/dialogs";
|
||||
import { Figures, MoreMenu, Panel, QtyStepper, Tag } from "@/components/portal";
|
||||
import { Crumb, OrdersStyles } from "@/components/orders/bits";
|
||||
import { viewPhoto } from "@/lib/photo";
|
||||
import { key, supplierCodeOf, ccBudgetNote, ccFor, ccOfOrder, csvOf, daysBetween, fmtDate, isOverdue, label, money, orderTotal, staffName, statusTag, supplierInfo, csvEsc } from "@/lib/compute";
|
||||
import { downloadCsv, esc, openPrintWindow } from "@/lib/print";
|
||||
|
||||
export default function OrderDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { s, isAdmin, mutate } = useSnap();
|
||||
const { byId, staffById } = useDerived();
|
||||
const router = useRouter();
|
||||
const o = s.orders.find((x) => x.id === id);
|
||||
const [rcv, setRcv] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [pick, setPick] = useState("");
|
||||
const [priceDraft, setPriceDraft] = useState<Record<string, string>>({});
|
||||
const [draft, setDraft] = useState<Record<string, string>>({});
|
||||
// What the coordinator has tapped on the quantity steppers but the server hasn't confirmed yet.
|
||||
// The state is what the screen shows; the ref is what the next tap adds to while it is set,
|
||||
// because it is current the instant a tap happens, where the state and the snapshot are both a
|
||||
// render (or a whole round trip) behind.
|
||||
const [qtyDraft, setQtyDraft] = useState<Record<string, number>>({});
|
||||
const qtyWanted = useRef<Record<string, number>>({});
|
||||
// What the snapshot said about a line as its write came back, and the lines the latest snapshot
|
||||
// has. Both are read by the backstop below, from a timer: a timer armed two renders ago still
|
||||
// closes over that render's copy of the order, and judging the screen out of date from a copy
|
||||
// that is itself out of date is exactly how the pre-tap quantity gets back under a finger.
|
||||
const qtySeen = useRef<Record<string, number>>({});
|
||||
const snapLines = useRef(o?.lines);
|
||||
// The field edits a debounce is still sitting on, so leaving the page can send them (see below).
|
||||
const fieldWanted = useRef<Record<string, string>>({});
|
||||
const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||
/* Anything still in a debounce when this screen goes away is sent, not thrown away.
|
||||
*
|
||||
* Clearing the timers on unmount was silent data loss: tap + on a quantity, or type the invoice
|
||||
* number, then click straight through to another page inside the debounce window and the change
|
||||
* vanished — it was on the screen as the coordinator left, and the supplier got the old figure.
|
||||
* The writes go out bare because the component is already gone: there is nothing left to show an
|
||||
* error in, and the record is one refresh away for whoever opens it next. */
|
||||
const flush = useRef<() => void>(() => {});
|
||||
useEffect(() => {
|
||||
flush.current = () => {
|
||||
for (const [k, v] of Object.entries(fieldWanted.current)) void mutate("order.update", { id, [k]: v });
|
||||
for (const [lineId, qty] of Object.entries(qtyWanted.current)) void mutate("order.lineQty", { id, lineId, qty });
|
||||
};
|
||||
});
|
||||
useEffect(() => { const t = timers.current, f = flush; return () => { Object.values(t).forEach(clearTimeout); f.current(); }; }, []);
|
||||
useEffect(() => { snapLines.current = o?.lines; });
|
||||
// Hand a line back to the snapshot once the refreshed snapshot agrees with what was tapped (or
|
||||
// the line is gone). Waiting for agreement rather than for the write to return matters: the
|
||||
// provider re-renders on its own the moment a write lands, still carrying the old snapshot, and
|
||||
// dropping the tapped number there would flick the counter back to the old quantity and again
|
||||
// look like the taps had been lost.
|
||||
useEffect(() => {
|
||||
setQtyDraft((d) => {
|
||||
const n = Object.fromEntries(Object.entries(d).filter(([k, v]) => {
|
||||
const line = o?.lines.find((l) => l.id === k);
|
||||
return qtyWanted.current[k] !== undefined || (!!line && line.qty !== v);
|
||||
}));
|
||||
return Object.keys(n).length === Object.keys(d).length ? d : n;
|
||||
});
|
||||
});
|
||||
|
||||
const [mailMsg, setMailMsg] = useState("");
|
||||
|
||||
if (!o) return <section><OrdersStyles /><PageHead title="Order not found" /><Crumb href="/app/orders/all" parent="Orders" current="Not found" /><Empty><Link href="/app/orders/all">All orders</Link></Empty></section>;
|
||||
|
||||
const st = o.staffId ? staffById[o.staffId] : undefined;
|
||||
const overdue = isOverdue(o, s.today);
|
||||
const forLabel = o.orderFor === "Stock" ? "For stock" : "For " + staffName(st, "staff member");
|
||||
const ccCode = ccOfOrder(s, o, staffById);
|
||||
const ccNote = ccBudgetNote(s, byId, staffById, ccCode, " (incl. this one)");
|
||||
|
||||
function saveField(k: string, v: string) {
|
||||
setDraft((d) => ({ ...d, [k]: v }));
|
||||
fieldWanted.current[k] = v;
|
||||
clearTimeout(timers.current[k]);
|
||||
timers.current[k] = setTimeout(async () => {
|
||||
// Off the pending list the moment it is on its way: a keystroke that lands after this point
|
||||
// has already put its own value back and scheduled its own timer.
|
||||
if (fieldWanted.current[k] === v) delete fieldWanted.current[k];
|
||||
const r = await mutate("order.update", { id: o!.id, [k]: v });
|
||||
if (!r.ok) setErr(r.error);
|
||||
}, 400);
|
||||
}
|
||||
const val = (k: keyof typeof o) => (draft[k] !== undefined ? draft[k] : String(o[k] ?? ""));
|
||||
/* The order as the coordinator can actually see it: the taps and the typing still sitting in a
|
||||
* debounce, laid over the snapshot that has not caught up with them yet.
|
||||
*
|
||||
* Everything that puts this order in front of a person reads it — the lines, the total, the
|
||||
* printed purchase order, the CSV, the receive dialog — so what leaves the building says what the
|
||||
* screen said when it was asked for. Printing from the snapshot sent the supplier a tunic count
|
||||
* one tap behind. Sending the pending write first would not have fixed it: the refreshed snapshot
|
||||
* lands some time after the write returns, and the print window has to open on the click itself
|
||||
* or the browser blocks it. Actions the server answers out of its own copy go through flushQty()
|
||||
* instead — that is what the database has to be right about. */
|
||||
const onScreen = { ...o, ref: val("ref"), invoice: val("invoice"), tracking: val("tracking"), expected: val("expected"), supplier: val("supplier"), notes: val("notes"), lines: o.lines.map((l) => (qtyDraft[l.id] !== undefined ? { ...l, qty: qtyDraft[l.id] } : l)) };
|
||||
async function act(op: string, payload: unknown) { setErr(""); const r = await mutate(op, payload); if (!r.ok) setErr(r.error); return r.ok; }
|
||||
/* Steppers count from what has been tapped, never from the snapshot.
|
||||
*
|
||||
* order.lineQty takes an absolute quantity and the snapshot only catches up once a write comes
|
||||
* back, so reading l.qty on every tap meant six quick taps on the size-14 tunic all posted
|
||||
* qty: 2: the line settled at 2 or 3 and the purchase order went to the supplier four tunics
|
||||
* short. Each tap now adds to the pending figure and the debounce sends whatever it reached.
|
||||
*
|
||||
* `shown` is the number on the screen, which is the one the coordinator is counting from. It
|
||||
* matters in the gap between a write landing and the refreshed snapshot arriving: the pending
|
||||
* figure is cleared the moment the write returns, so a tap in that gap would otherwise fall back
|
||||
* to the snapshot and count from the old quantity again — the very defect this exists to stop.
|
||||
* The pending figure still wins where it exists, because two taps in one frame both read the same
|
||||
* already-rendered number. */
|
||||
function bumpQty(lineId: string, shown: number, by: number) {
|
||||
clearTimeout(timers.current["qtyclear:" + lineId]);
|
||||
const next = (qtyWanted.current[lineId] ?? shown) + by;
|
||||
qtyWanted.current[lineId] = next;
|
||||
setQtyDraft((d) => ({ ...d, [lineId]: next }));
|
||||
clearTimeout(timers.current["qty:" + lineId]);
|
||||
timers.current["qty:" + lineId] = setTimeout(() => { void sendQty(lineId); }, 300);
|
||||
}
|
||||
async function sendQty(lineId: string) {
|
||||
const want = qtyWanted.current[lineId];
|
||||
if (want === undefined) return true;
|
||||
clearTimeout(timers.current["qty:" + lineId]);
|
||||
const ok = await act("order.lineQty", { id: o!.id, lineId, qty: want });
|
||||
// A tap that landed while this write was in the air has already raised the target; leaving it
|
||||
// pending lets the timer that tap scheduled send the higher number instead of losing it here.
|
||||
if (qtyWanted.current[lineId] === want) {
|
||||
delete qtyWanted.current[lineId];
|
||||
// Nothing was saved, so the tapped number must come off the screen now rather than sit there
|
||||
// above the error looking like a quantity the supplier is going to be sent.
|
||||
if (!ok) dropPendingQty(lineId);
|
||||
else {
|
||||
qtySeen.current[lineId] = snapLines.current?.find((l) => l.id === lineId)?.qty ?? want;
|
||||
timers.current["qtyclear:" + lineId] = setTimeout(() => dropOverriddenQty(lineId, want), 2000);
|
||||
}
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
/* The backstop for the case where the snapshot never comes to agree — someone else editing the
|
||||
* same draft line. Without it this screen would keep showing our number over theirs.
|
||||
*
|
||||
* It runs on a clock, so it must never act on a snapshot that is merely late. Dropping the draft
|
||||
* the moment the two seconds were up put the pre-tap quantity back on the screen whenever the
|
||||
* refreshed snapshot was slower than that, and the next tap counted on from it — the miscount all
|
||||
* of this exists to stop. A snapshot still showing the figure it had when our write came back,
|
||||
* and not the figure we wrote, has not caught up yet: the tapped number stays and this waits
|
||||
* another two seconds. Once it moves — to ours, or to whatever the other coordinator saved — the
|
||||
* draft has nothing left to protect and goes. */
|
||||
function dropOverriddenQty(lineId: string, wrote: number) {
|
||||
const line = snapLines.current?.find((l) => l.id === lineId);
|
||||
if (line && line.qty !== wrote && line.qty === qtySeen.current[lineId]) { timers.current["qtyclear:" + lineId] = setTimeout(() => dropOverriddenQty(lineId, wrote), 2000); return; }
|
||||
dropPendingQty(lineId);
|
||||
}
|
||||
// Send anything still sitting in the debounce before an action the server answers out of its own
|
||||
// copy of the order — it reads the lines the database holds, not the ones on this screen — or
|
||||
// before one that closes the draft to edits and would have the pending write refused.
|
||||
async function flushQty() {
|
||||
for (const lineId of Object.keys(qtyWanted.current)) if (!(await sendQty(lineId))) return false;
|
||||
return true;
|
||||
}
|
||||
function dropPendingQty(lineId: string) {
|
||||
clearTimeout(timers.current["qty:" + lineId]);
|
||||
clearTimeout(timers.current["qtyclear:" + lineId]);
|
||||
delete qtyWanted.current[lineId];
|
||||
delete qtySeen.current[lineId];
|
||||
setQtyDraft((d) => { const n = { ...d }; delete n[lineId]; return n; });
|
||||
}
|
||||
async function removeLine(lineId: string) { dropPendingQty(lineId); await act("order.lineRemove", { id: o!.id, lineId }); }
|
||||
/* Every line is priced the way orderTotal() prices it — delivered units at the cost the delivery
|
||||
* was invoiced at, whatever is still outstanding at today's catalogue price — so the rows a
|
||||
* coordinator ticks off against the invoice add up to the total printed under them. Pricing the
|
||||
* rows from the catalogue while the total came from orderTotal() left the two visibly disagreeing
|
||||
* as soon as a delivery arrived at a different price, on the one screen where that sum is checked.
|
||||
*
|
||||
* The amounts come out of orderTotal() itself rather than a second copy of its arithmetic: what
|
||||
* line n contributes is the total of the first n lines less the total of the first n−1. Asking it
|
||||
* about a line on its own would not do, because it draws each delivery down across the lines in
|
||||
* order — two lines for the same size would then both claim the same delivery. */
|
||||
const lineAmt: Record<string, number> = {};
|
||||
let runTotal = 0;
|
||||
for (let i = 0; i < onScreen.lines.length; i++) { const t = orderTotal({ ...onScreen, lines: onScreen.lines.slice(0, i + 1) }, byId); lineAmt[onScreen.lines[i].id] = t - runTotal; runTotal = t; }
|
||||
const unitOf = (l: { id: string; itemId: string; qty: number }) => (l.qty > 0 ? lineAmt[l.id] / l.qty : byId[l.itemId]?.cost || 0);
|
||||
const received = (itemId: string, size: string) => o.receipts.reduce((t, r) => t + r.lines.filter((x) => x.itemId === itemId && x.size === size).reduce((a, x) => a + x.qty, 0), 0);
|
||||
|
||||
/* What the delivery docket gets checked against: the units this order asked for and the units
|
||||
that have actually turned up. Both are read off the same lines the total is priced from, so the
|
||||
figure above the table can never disagree with the table. */
|
||||
const units = onScreen.lines.reduce((t, l) => t + l.qty, 0);
|
||||
const got = o.receipts.reduce((t, rc) => t + rc.lines.reduce((n, l) => n + l.qty, 0), 0);
|
||||
const total = orderTotal(onScreen, byId);
|
||||
|
||||
const ev: { date: string; what: string; sub: string; photoId?: string | null }[] = [{ date: o.date, what: "Order created", sub: o.replenish ? "Auto-built replenishment draft" : o.source }];
|
||||
if (o.status !== "Draft" && o.status !== "Cancelled") ev.push({ date: o.date, what: "Placed with " + onScreen.supplier + (onScreen.ref ? " — ref " + onScreen.ref : ""), sub: "" });
|
||||
for (const rc of o.receipts) ev.push({ photoId: rc.photoId, date: rc.date, what: "Delivery received" + (rc.invoice ? " — invoice " + rc.invoice : ""), sub: rc.lines.map((x) => `${label(byId[x.itemId])} ${x.size} ×${x.qty}${x.dest === "pickup" ? " → pickup" : " → shelf"}`).join(", ") + (rc.note ? " · " + rc.note : "") });
|
||||
if (o.status === "Cancelled") ev.push({ date: "", what: "Order cancelled", sub: "" });
|
||||
const backOrders = s.orders.filter((x) => x.parentId === o.id);
|
||||
const parent = o.parentId ? s.orders.find((x) => x.id === o.parentId) : undefined;
|
||||
|
||||
function printPO() {
|
||||
const sp = supplierInfo(s, onScreen.supplier);
|
||||
const rows = onScreen.lines.map((l) => { const it = byId[l.itemId]; return `<tr><td>${esc(label(it))}</td><td>${esc(it?.sku || "—")}</td><td>${esc(l.size)}</td><td class="r">${l.qty}</td><td class="r">${esc(money(unitOf(l)))}</td><td class="r">${esc(money(lineAmt[l.id]))}</td></tr>`; }).join("");
|
||||
const css = ".hd{display:flex;justify-content:space-between;align-items:flex-end;border-bottom:2px solid #201e1d;padding-bottom:8px}.hd h1{border:none;padding:0;font-size:20px}.meta2{display:grid;grid-template-columns:1fr 1fr;gap:4px 24px;margin:12px 0;font-size:12px;line-height:1.7}.tot{text-align:right;font-size:16px;font-weight:800;margin-top:10px}.notes{margin-top:14px;font-size:12px;color:#444}";
|
||||
const body = `<div class="hd"><h1><span class="sq"></span>Purchase order — ${esc(onScreen.code)}</h1><div style="font-size:12px">${esc(s.settings.facility)} · ${esc(s.settings.location)}</div></div>` +
|
||||
`<div class="meta2"><div>Supplier: <b>${esc(onScreen.supplier)}${sp && (sp.contact || sp.phone) ? " · " + esc([sp.contact, sp.phone].filter(Boolean).join(" · ")) : ""}</b></div><div>Date: <b>${esc(fmtDate(onScreen.date || s.today))}</b></div><div>Supplier ref: <b>${esc(onScreen.ref || "—")}</b></div><div>Expected: <b>${esc(onScreen.expected ? fmtDate(onScreen.expected) : "—")}</b></div><div>Account: <b>${esc(sp?.account || "—")}</b> · ${esc(forLabel)}</div><div>Cost centre: <b>${esc(ccCode || "—")}</b></div></div>` +
|
||||
`<table><tr><th>Item</th><th>SKU</th><th>Size</th><th class="r">Qty</th><th class="r">Unit</th><th class="r">Total</th></tr>${rows}</table><div class="tot">Total ${esc(money(orderTotal(onScreen, byId)))}</div>` +
|
||||
(onScreen.notes ? `<div class="notes">Notes: ${esc(onScreen.notes)}</div>` : "") + `<div class="notes">Ordered by ____________________ Date ____________</div>`;
|
||||
openPrintWindow(onScreen.code, body, { page: "size:A4;margin:16mm", css, width: 780, height: 920 });
|
||||
}
|
||||
function exportCsv() {
|
||||
downloadCsv((onScreen.code + (onScreen.ref ? "-" + onScreen.ref.replace(/[^A-Za-z0-9-]+/g, "_") : "")).toLowerCase() + ".csv", `Order,${csvEsc(onScreen.code)}\nSupplier,${csvEsc(onScreen.supplier)}\nRef,${csvEsc(onScreen.ref)}\n\n` + csvOf(["Item", "Supplier code", "SKU", "Size", "Qty", "Unit cost", "Total"], onScreen.lines.map((l) => { const it = byId[l.itemId]; return [label(it), supplierCodeOf(s, key(l.itemId, it ? it.sizes.map(String).indexOf(l.size) : -1)), it?.sku || "", l.size, l.qty, +unitOf(l).toFixed(2), lineAmt[l.id].toFixed(2)]; })));
|
||||
}
|
||||
async function emailSupplier() {
|
||||
setMailMsg("Sending…");
|
||||
const r = await mutate<{ sentTo: string }>("order.email", { id: o!.id });
|
||||
setMailMsg(r.ok ? `Sent to ${r.result.sentTo}` : r.error);
|
||||
}
|
||||
async function duplicate() {
|
||||
if (!(await flushQty())) return;
|
||||
const r = await mutate<{ id: string }>("order.duplicate", { id: o!.id });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
router.push(`/app/orders/${r.result.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<OrdersStyles />
|
||||
<PageHead
|
||||
title={o.code}
|
||||
sub={<>{forLabel} · {onScreen.supplier} · placed {fmtDate(o.date)}{o.replenish ? " · replenishment" : ""}{parent && <> · back order of <Link href={`/app/orders/${parent.id}`} style={{ color: "inherit" }}>{parent.code}</Link></>}</>}
|
||||
below={<div style={{ display: "flex", gap: 6, marginTop: 8, flexWrap: "wrap" }}>{overdue && <Tag tone="accent">Overdue</Tag>}<span className={statusTag(o.status)}>{o.status}</span></div>}
|
||||
>
|
||||
{o.status === "Draft" && isAdmin && <button type="button" className="btn btn-primary" onClick={async () => { if (await flushQty()) act("order.status", { id: o.id, status: "Ordered" }); }} disabled={o.lines.length === 0}>Mark ordered</button>}
|
||||
{["Ordered", "Shipped", "Back Order"].includes(o.status) && <button type="button" className="btn btn-primary" onClick={async () => { if (await flushQty()) setRcv(true); }}>Receive delivery</button>}
|
||||
{isAdmin && <a className="btn btn-onink" href={`/print/supplier-order?id=${o.id}`} target="_blank" rel="noreferrer" title="The A4 sheet with the supplier's product codes" onClick={() => { void mutate("order.printed", { id: o.id }); }}>Order sheet</a>}
|
||||
<MoreMenu tone="ink" items={[
|
||||
{ label: "Print order", onSelect: printPO },
|
||||
{ label: "CSV", onSelect: exportCsv },
|
||||
{ label: "Email supplier", onSelect: emailSupplier, hidden: !(isAdmin && o.status !== "Draft" && o.status !== "Cancelled") },
|
||||
{ label: "Mark shipped", onSelect: () => { void act("order.status", { id: o.id, status: "Shipped" }); }, hidden: !(isAdmin && ["Ordered", "Back Order"].includes(o.status)) },
|
||||
{ label: "Duplicate", onSelect: () => { void duplicate(); } },
|
||||
{ label: "Cancel order", danger: true, hidden: !(isAdmin && ["Draft", "Ordered", "Back Order", "Shipped"].includes(o.status)), onSelect: async () => { if (confirm(`Cancel ${o.code}?`) && await flushQty()) act("order.status", { id: o.id, status: "Cancelled" }); } },
|
||||
]} />
|
||||
</PageHead>
|
||||
<Crumb href="/app/orders/all" parent="Orders" current={o.code} />
|
||||
<LiveRegion tone="alert" className="notice tc-flag" msg={err} style={{ marginBottom: 16, color: "var(--color-accent-700)", fontWeight: 700 }} />
|
||||
<LiveRegion msg={mailMsg} className="notice" style={{ marginBottom: 16 }} />
|
||||
<Figures items={[
|
||||
{ value: money(total), label: "Order value", note: <span className="tc-mono">{onScreen.lines.length} line{onScreen.lines.length === 1 ? "" : "s"}</span> },
|
||||
{ value: `${got} of ${units}`, label: "Units received" },
|
||||
{ value: onScreen.expected ? fmtDate(onScreen.expected) : "—", label: "Expected", flag: overdue, note: overdue ? <span className="tc-mono">{daysBetween(onScreen.expected, s.today)} day{daysBetween(onScreen.expected, s.today) === 1 ? "" : "s"} overdue</span> : undefined },
|
||||
]} />
|
||||
<div className="tc-orders-detail">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 24, minWidth: 0 }}>
|
||||
<Panel title="Order details" aside="Saves as you type">
|
||||
<div className="tc-orders-fields">
|
||||
{([["ref", "Supplier order no.", "text", "e.g. NW-48211"], ["invoice", "Invoice no.", "text", "e.g. INV-102938"], ["tracking", "Tracking no.", "text", "e.g. 34XY990812"], ["expected", "Expected delivery", "date", ""]] as const).map(([k, lbl, type, ph]) => (
|
||||
<Field key={k} label={lbl}>{(c) => <input {...c} className="input" type={type} placeholder={ph} value={val(k)} onChange={(e) => saveField(k, e.target.value)} />}</Field>
|
||||
))}
|
||||
<Field label="Supplier">
|
||||
{(c) => s.settings.suppliers.length ? <select {...c} className="input" value={val("supplier")} onChange={(e) => saveField("supplier", e.target.value)}>{[...new Set([o.supplier, ...s.settings.suppliers])].filter(Boolean).map((x) => <option key={x}>{x}</option>)}</select> : <input {...c} className="input" value={val("supplier")} onChange={(e) => saveField("supplier", e.target.value)} />}
|
||||
</Field>
|
||||
<Field label="Order for">
|
||||
{(c) => (
|
||||
<select {...c} className="input" value={o.staffId || ""} onChange={(e) => act("order.update", { id: o.id, staffId: e.target.value })}>
|
||||
<option value="">Stock (linen room)</option>
|
||||
{s.staff.filter((x) => !x.inactive || x.id === o.staffId).map((x) => <option key={x.id} value={x.id}>{x.first} {x.last} ({x.num})</option>)}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Cost centre">
|
||||
{(c) => (
|
||||
<select {...c} className="input" value={val("cc") || (st ? st.dept : "")} onChange={(e) => act("order.update", { id: o.id, cc: e.target.value })}>
|
||||
<option value="">— none —</option>
|
||||
{s.depts.map((d) => <option key={d.id} value={d.name}>{d.name}{d.cc ? ` (${d.cc})` : ""}</option>)}
|
||||
{o.cc && !s.depts.find((d) => d.name === o.cc) && <option value={o.cc}>{o.cc}{ccFor(s, o.cc) ? "" : " (code)"}</option>}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
{ccNote && <div style={{ gridColumn: "1 / -1", fontSize: 12, color: "var(--color-neutral-700)", borderLeft: "4px solid var(--color-text)", paddingLeft: "var(--space-2)" }}>{ccNote}</div>}
|
||||
<Field label="Notes" style={{ gridColumn: "1 / -1" }}>{(c) => <input {...c} className="input" placeholder="e.g. rang the supplier re back order 12/8" value={val("notes")} onChange={(e) => saveField("notes", e.target.value)} />}</Field>
|
||||
</div>
|
||||
</Panel>
|
||||
<div>
|
||||
<Panel title="Lines" aside={<span className="tc-mono">{units} unit{units === 1 ? "" : "s"} ordered</span>}
|
||||
foot={<><span className="tc-lbl">Total</span><span className="tc-mono" style={{ marginLeft: "auto", fontSize: 18, fontWeight: 600 }}>{money(total)}</span></>}>
|
||||
<div>
|
||||
{onScreen.lines.map((l) => {
|
||||
const it = byId[l.itemId]; const rec = received(l.itemId, l.size); const cKey = l.id;
|
||||
const catCost = it ? it.cost : 0;
|
||||
// What this line is actually worth per unit once a delivery has been invoiced.
|
||||
const unit = unitOf(l);
|
||||
return (
|
||||
<div key={l.id} className="tc-orders-row" style={{ fontSize: 13 }}>
|
||||
<div className="tc-orders-rowmain" style={{ minWidth: 150 }}>
|
||||
<div style={{ fontWeight: 600 }}>{label(it)} · <span className="tc-mono">{l.size}</span></div>
|
||||
<div className="tc-orders-rowmeta">{supplierCodeOf(s, key(l.itemId, it ? it.sizes.map(String).indexOf(l.size) : -1)) && <span className="tc-mono">{supplierCodeOf(s, key(l.itemId, it ? it.sizes.map(String).indexOf(l.size) : -1))}</span>}{rec ? <> · received <span className="tc-mono">{rec}</span></> : ""}{Math.abs(unit - catCost) > 0.004 && <span title={`Delivered units are priced at what the invoice charged: ${money(unit)} a unit across this line`}> · invoice price</span>}</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-2)", flex: "none" }}>
|
||||
{o.status === "Draft"
|
||||
? <QtyStepper size="sm" min={1} value={l.qty} label={`${label(it)} size ${l.size}`} onChange={(n) => bumpQty(l.id, l.qty, n - l.qty)} />
|
||||
: <span className="tc-mono" style={{ fontWeight: 600 }}>×{l.qty}</span>}
|
||||
<span className="tc-mono">@ $</span>
|
||||
<input className="input tc-mono" style={{ minHeight: 28, padding: "2px 8px", width: 70, textAlign: "right" }} inputMode="decimal" aria-label={`Unit cost of ${label(it)} size ${l.size}`} title={isAdmin ? "Editing a price updates that item's catalogue cost everywhere." : "Prices are set by an admin."} disabled={!isAdmin} value={priceDraft[cKey] !== undefined ? priceDraft[cKey] : String(catCost)}
|
||||
onChange={(e) => setPriceDraft({ ...priceDraft, [cKey]: e.target.value.replace(/[^0-9.]/g, "") })}
|
||||
onBlur={async () => { const v = parseFloat(priceDraft[cKey]); if (!isNaN(v) && v >= 0 && Math.abs(v - catCost) > 0.004 && it) { await act("catalog.update", { id: it.id, cost: v }); } const d = { ...priceDraft }; delete d[cKey]; setPriceDraft(d); }} />
|
||||
{o.status === "Draft" && o.lines.length > 1 && <button type="button" className="btn btn-ghost" style={{ minHeight: 26, padding: "0 4px" }} aria-label={`Remove ${label(it)} size ${l.size} from this order`} title="Remove" onClick={() => removeLine(l.id)}>×</button>}
|
||||
</div>
|
||||
<div className="tc-mono" style={{ minWidth: 86, textAlign: "right", fontWeight: 500 }}>{money(l.qty * unit)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{o.status === "Draft" && (
|
||||
<div className="tc-orders-add">
|
||||
<span className="tc-lbl" style={{ alignSelf: "center" }}>Add a line</span>
|
||||
<ItemSizePicker s={s} itemId={pick} onItem={setPick} placeholder="Choose an item…" maxWidth={280} onSize={async (it, si) => { if (await flushQty()) act("order.lineAdd", { id: o.id, itemId: it.id, size: it.sizes[si], qty: 1 }); }} />
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
{backOrders.length > 0 && <div className="tc-orders-rowmeta" style={{ marginTop: 8 }}>Back order{backOrders.length > 1 ? "s" : ""}: {backOrders.map((b) => <Link key={b.id} href={`/app/orders/${b.id}`} className="tc-mono" style={{ marginRight: 8 }}>{b.code}</Link>)}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 24, minWidth: 0 }}>
|
||||
<Panel title="History">
|
||||
{ev.map((e, i) => (
|
||||
<div key={i} className="tc-orders-row" style={{ alignItems: "flex-start", flexWrap: "nowrap" }}>
|
||||
<div className="tc-mono" style={{ minWidth: 92, flex: "none", paddingTop: 1, fontSize: 12, color: "#57534f" }}>{e.date ? fmtDate(e.date) : "—"}</div>
|
||||
<div className="tc-orders-rowmain">
|
||||
<div style={{ fontWeight: 600 }}>{e.what}{e.photoId && <button type="button" className="btn btn-ghost" style={{ minHeight: 22, padding: "0 6px", marginLeft: 8 }} onClick={() => viewPhoto(e.photoId!)}>Invoice photo</button>}</div>
|
||||
{e.sub && <div className="tc-orders-rowmeta">{e.sub}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Panel>
|
||||
{st && (
|
||||
<Panel title="Staff member">
|
||||
<div style={{ fontSize: 13, lineHeight: 1.7, padding: "12px 16px" }}>
|
||||
<div style={{ fontWeight: 600 }}><Link href={`/app/staff/${st.id}`} className="link-name">{staffName(st)}</Link> <span className="tc-mono" style={{ fontWeight: 400, color: "var(--color-neutral-700)" }}>{st.num}</span></div>
|
||||
<div>{st.dept} · {st.phone || "no phone"}</div>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{rcv && <ReceiveDialog order={onScreen} onClose={() => { setRcv(false); router.refresh(); }} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { Field, PageHead } from "@/components/ui";
|
||||
import { Panel, Seg, SelectButton, Tag } from "@/components/portal";
|
||||
import { ccOfOrder, csvOf, daysBetween, fmtDate, isOverdue, isPlacedOpen, label, money, orderTotal, staffName, statusTag } from "@/lib/compute";
|
||||
import { downloadCsv } from "@/lib/print";
|
||||
import { Crumb, NewOrder, OrdersStyles, shortDate } from "@/components/orders/bits";
|
||||
|
||||
const STATUSES = ["All", "Draft", "Open", "Received"] as const;
|
||||
|
||||
/* Every order: the filters, the status segment and the CSV that used to sit on the Ordering screen. */
|
||||
export default function OrderLedgerPage() {
|
||||
const { s } = useSnap();
|
||||
const { byId, staffById } = useDerived();
|
||||
const [dlg, setDlg] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const [sup, setSup] = useState("");
|
||||
const [status, setStatus] = useState<(typeof STATUSES)[number]>("All");
|
||||
const [from, setFrom] = useState("");
|
||||
const [to, setTo] = useState("");
|
||||
const [item, setItem] = useState("");
|
||||
|
||||
const supOpts = useMemo(() => [{ value: "", label: "All suppliers" }, ...[...new Set(s.orders.map((o) => o.supplier).filter(Boolean))].sort().map((x) => ({ value: x, label: x }))], [s.orders]);
|
||||
const itemOpts = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const o of s.orders) for (const l of o.lines) ids.add(l.itemId);
|
||||
return [{ value: "", label: "All garments" }, ...[...ids].map((id) => byId[id]).filter(Boolean).sort((a, b) => label(a).localeCompare(label(b))).map((it) => ({ value: it.id, label: label(it) }))];
|
||||
}, [s.orders, byId]);
|
||||
|
||||
const rank = (o: (typeof s.orders)[number]) => (o.status === "Draft" ? 0 : isPlacedOpen(o) ? 1 : o.status === "Received" ? 2 : 3);
|
||||
const ql = q.trim().toLowerCase();
|
||||
const orders = s.orders.filter((o) => {
|
||||
if (sup && o.supplier !== sup) return false;
|
||||
if (status === "Draft" && o.status !== "Draft") return false;
|
||||
if (status === "Open" && !isPlacedOpen(o)) return false;
|
||||
if (status === "Received" && o.status !== "Received") return false;
|
||||
if (from && o.date < from) return false;
|
||||
if (to && o.date > to) return false;
|
||||
if (item && !o.lines.some((l) => l.itemId === item)) return false;
|
||||
if (ql) {
|
||||
const st = o.staffId ? staffById[o.staffId] : undefined;
|
||||
const hay = `${o.code} ${o.ref} ${o.invoice} ${o.tracking} ${o.supplier} ${staffName(st)} ${o.lines.map((l) => label(byId[l.itemId])).join(" ")}`.toLowerCase();
|
||||
if (!hay.includes(ql)) return false;
|
||||
}
|
||||
return true;
|
||||
}).sort((a, b) => rank(a) - rank(b) || (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
|
||||
|
||||
const narrowed = ql !== "" || sup !== "" || status !== "All" || from !== "" || to !== "" || item !== "";
|
||||
|
||||
/* The rows on screen, filters applied. Value is orderTotal() so the file agrees with the screen and
|
||||
Reports; dates stay ISO so a spreadsheet sorts them; notes stay out. */
|
||||
function exportCsv() {
|
||||
const cols = ["Order no.", "Status", "Ordered", "Supplier", "Ordered for", "Staff member", "Supplier ref", "Invoice", "Tracking", "Cost centre", "Replenishment", "Expected", "Days overdue", "Received", "Lines", "Units ordered", "Units received", "Value"];
|
||||
downloadCsv(`threadcount-orders-${s.today}.csv`, csvOf(cols, orders.map((o) => {
|
||||
const st = o.staffId ? staffById[o.staffId] : undefined;
|
||||
const units = o.lines.reduce((t, l) => t + l.qty, 0);
|
||||
const got = o.receipts.reduce((t, rc) => t + rc.lines.reduce((n, l) => n + l.qty, 0), 0);
|
||||
return [o.code, o.status, o.date, o.supplier, o.orderFor === "Stock" ? "Stock" : "Staff member", staffName(st), o.ref, o.invoice, o.tracking, ccOfOrder(s, o, staffById), o.replenish ? "Yes" : "No", o.expected, isOverdue(o, s.today) ? daysBetween(o.expected, s.today) : "", o.received, o.lines.length, units, got, +orderTotal(o, byId).toFixed(2)];
|
||||
})));
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<OrdersStyles />
|
||||
<PageHead title="All orders" sub={<span className="tc-mono">{orders.length} of {s.orders.length}</span>}>
|
||||
<button type="button" className="btn btn-onink" onClick={exportCsv} disabled={orders.length === 0}>{narrowed ? `Export CSV (${orders.length} shown)` : "Export CSV"}</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => setDlg(true)}>New order</button>
|
||||
</PageHead>
|
||||
<Crumb href="/app/orders" parent="Orders" current="All orders" />
|
||||
<div className="tc-orders-filters">
|
||||
<input className="input" style={{ width: 240 }} aria-label="Search orders by number, reference or invoice" placeholder="Order no., ref, invoice" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<SelectButton label="Supplier" value={sup} options={supOpts} onChange={setSup} anyValue="" />
|
||||
<SelectButton label="Garment" value={item} options={itemOpts} onChange={setItem} anyValue="" />
|
||||
<Field label="From">{(c) => <input {...c} className="input" style={{ width: 150 }} type="date" value={from} onChange={(e) => setFrom(e.target.value)} />}</Field>
|
||||
<Field label="To">{(c) => <input {...c} className="input" style={{ width: 150 }} type="date" value={to} onChange={(e) => setTo(e.target.value)} />}</Field>
|
||||
<Seg label="Status" opts={STATUSES} value={status} onChange={setStatus} />
|
||||
</div>
|
||||
<Panel title="Orders" aside={`${orders.length} shown`}>
|
||||
{orders.length === 0 && <div className="tc-orders-row"><span className="tc-orders-rowmeta">{s.orders.length === 0 ? "No orders yet." : "No orders match."}</span></div>}
|
||||
{orders.map((o) => {
|
||||
const st = o.staffId ? staffById[o.staffId] : undefined;
|
||||
const overdue = isOverdue(o, s.today);
|
||||
const late = overdue ? daysBetween(o.expected, s.today) : 0;
|
||||
const due = overdue
|
||||
? late === 1 ? "due yesterday" : `${late} days overdue`
|
||||
: isPlacedOpen(o) && o.expected ? (o.expected === s.today ? "due today" : `due ${shortDate(o.expected)}`) : "";
|
||||
return (
|
||||
<Link key={o.id} href={`/app/orders/${o.id}`} className={"tc-orders-row" + (overdue ? " urgent" : "")}>
|
||||
<div className="tc-orders-rowmain" style={{ minWidth: 200 }}>
|
||||
<div className="tc-orders-rowtitle">{o.code}</div>
|
||||
<div className="tc-orders-rowmeta" title={fmtDate(o.date)}>
|
||||
{o.orderFor === "Stock" ? "For stock" : "For " + staffName(st, "staff member")} · {o.supplier} · {shortDate(o.date)}{o.ref ? " · ref " + o.ref : ""}
|
||||
{due && <> · <span style={{ color: "var(--color-accent-700)", fontWeight: 600 }}>{due}</span></>}
|
||||
</div>
|
||||
</div>
|
||||
{o.replenish && <Tag>Replenishment</Tag>}
|
||||
{overdue && <Tag tone="accent">Overdue</Tag>}
|
||||
<span className={statusTag(o.status)}>{o.status}</span>
|
||||
<span className="tc-mono" style={{ minWidth: 90, textAlign: "right", fontWeight: 500 }}>{money(orderTotal(o, byId))}</span>
|
||||
<span aria-hidden="true" style={{ fontSize: 12, color: "#6c6764" }}>→</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</Panel>
|
||||
{dlg && <NewOrder onClose={() => setDlg(false)} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/* The order list is now the To order column on /app/orders. next.config.ts redirects as well; this
|
||||
* stub keeps an old bookmark working if that map ever changes. */
|
||||
export default async function OrderListPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
const sp = await searchParams;
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(sp)) for (const x of Array.isArray(v) ? v : v === undefined ? [] : [v]) qs.append(k, x);
|
||||
const q = qs.toString();
|
||||
redirect(q ? `/app/orders?${q}` : "/app/orders");
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { PageHead } from "@/components/ui";
|
||||
import ToOrder from "@/components/orders/ToOrder";
|
||||
import OnTheWay from "@/components/orders/OnTheWay";
|
||||
import ThisMonth from "@/components/orders/ThisMonth";
|
||||
import RecentOrders from "@/components/orders/RecentOrders";
|
||||
import { NewOrder, OrdersStyles } from "@/components/orders/bits";
|
||||
|
||||
export default function OrdersPage() {
|
||||
const { isAdmin } = useSnap();
|
||||
const [dlg, setDlg] = useState<null | "stock" | "staff">(null);
|
||||
return (
|
||||
<section>
|
||||
<OrdersStyles />
|
||||
<PageHead title="Orders">
|
||||
<button type="button" className="btn btn-onink" onClick={() => setDlg("staff")}>Order for a person</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => setDlg("stock")}>New order</button>
|
||||
</PageHead>
|
||||
<div className="tc-orders-grid">
|
||||
<div className="tc-orders-toorder">{isAdmin ? <ToOrder /> : <RecentOrders />}</div>
|
||||
<div className="tc-orders-otw"><OnTheWay /></div>
|
||||
<div className="tc-orders-month"><ThisMonth /></div>
|
||||
</div>
|
||||
{dlg && <NewOrder onClose={() => setDlg(null)} initOrderFor={dlg === "staff" ? "Staff Member" : undefined} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { PageHead, Empty } from "@/components/ui";
|
||||
import { usePortalCounts } from "@/lib/portalcounts";
|
||||
import { todayHeadLine } from "@/lib/today";
|
||||
import SetupGroup from "@/components/today/SetupGroup";
|
||||
import CollectGroup from "@/components/today/CollectGroup";
|
||||
import RoundGroup from "@/components/today/RoundGroup";
|
||||
import PickGroup from "@/components/today/PickGroup";
|
||||
import ReceiveGroup from "@/components/today/ReceiveGroup";
|
||||
import CountsGroup from "@/components/today/CountsGroup";
|
||||
import RunsOutPanel from "@/components/today/RunsOutPanel";
|
||||
import MonthEndPanel from "@/components/today/MonthEndPanel";
|
||||
|
||||
/* Today: the work queue. Each group is a thing somebody has to go and do, and a group with nothing
|
||||
in it is not drawn. Membership and the head count both come from lib/portalcounts.ts, the same
|
||||
numbers the rail badge shows. */
|
||||
export default function TodayPage() {
|
||||
const { today } = usePortalCounts();
|
||||
const column: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 18, minWidth: 0 };
|
||||
|
||||
return (
|
||||
<section>
|
||||
<PageHead title="Today" sub={todayHeadLine(today.total, today.overdue)}>
|
||||
<Link href="/app/counter" className="btn btn-primary">Open the counter</Link>
|
||||
</PageHead>
|
||||
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.75fr) minmax(0, 1fr)", gap: 24, alignItems: "start", marginTop: 24 }}>
|
||||
<div style={column}>
|
||||
<SetupGroup />
|
||||
<CollectGroup />
|
||||
<RoundGroup />
|
||||
<PickGroup />
|
||||
<ReceiveGroup />
|
||||
<CountsGroup />
|
||||
{today.total === 0 && <Empty pad={2}>Nothing in the queue.</Empty>}
|
||||
</div>
|
||||
<div style={column}>
|
||||
<RunsOutPanel />
|
||||
<MonthEndPanel />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { monthLabel } from "@/lib/compute";
|
||||
import { PageHead } from "@/components/ui";
|
||||
import { Icon, Seg } from "@/components/portal";
|
||||
import { useReportData } from "@/components/reports/useReportData";
|
||||
import MonthEndStrip from "@/components/reports/MonthEndStrip";
|
||||
import SpendTab, { JOURNAL_ID } from "@/components/reports/SpendTab";
|
||||
import StockTab from "@/components/reports/StockTab";
|
||||
import PeopleTab from "@/components/reports/PeopleTab";
|
||||
|
||||
const TABS = ["spend", "stock", "people"] as const;
|
||||
type Tab = (typeof TABS)[number];
|
||||
const LABELS: Record<Tab, string> = { spend: "Spend", stock: "Stock", people: "People" };
|
||||
/* The nine reports' old names, so a link to one of them still lands on the tab that holds it. */
|
||||
const LEGACY: Record<string, Tab> = {
|
||||
overview: "spend", journal: "spend",
|
||||
valuation: "stock", shrinkage: "stock", "top-stock": "stock", topstock: "stock", suppliers: "stock",
|
||||
exceptions: "people", approvals: "people", "pre-loved": "people", preloved: "people",
|
||||
};
|
||||
|
||||
/* Screen-local layout. Scoped to .tc-rep so nothing leaks outside this screen. */
|
||||
|
||||
export default function ReportPage() {
|
||||
return <Suspense fallback={null}><ReportInner /></Suspense>;
|
||||
}
|
||||
|
||||
function ReportInner() {
|
||||
const { s } = useSnap();
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
const thisMonth = s.today.slice(0, 7);
|
||||
const rawTab = (sp.get("tab") || "").toLowerCase();
|
||||
const tab: Tab = (TABS as readonly string[]).includes(rawTab) ? (rawTab as Tab) : LEGACY[rawTab] ?? "spend";
|
||||
const rawMonth = sp.get("month") || "";
|
||||
const month = /^\d{4}-(0[1-9]|1[0-2])$/.test(rawMonth) ? rawMonth : thisMonth;
|
||||
const d = useReportData(month);
|
||||
const [jump, setJump] = useState(false);
|
||||
|
||||
function go(next: { tab?: Tab; month?: string }) {
|
||||
const t = next.tab ?? tab, m = next.month ?? month;
|
||||
const q = new URLSearchParams();
|
||||
q.set("tab", t);
|
||||
if (m !== thisMonth) q.set("month", m);
|
||||
router.replace(`/app/report?${q.toString()}`, { scroll: false });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!jump || tab !== "spend") return;
|
||||
document.getElementById(JOURNAL_ID)?.scrollIntoView({ block: "start" });
|
||||
setJump(false);
|
||||
}, [jump, tab]);
|
||||
|
||||
const exportCsv = tab === "spend" ? d.csv.overview : tab === "stock" ? d.csv.valuation : d.csv.exceptions;
|
||||
|
||||
return (
|
||||
<section className="tc-rep">
|
||||
<PageHead title="Reports">
|
||||
<span className="tc-selectbtn btn btn-onink" style={{ gap: 10 }}>
|
||||
<span aria-hidden="true">{monthLabel(month)}</span>
|
||||
<Icon name="chevronDown" size={16} />
|
||||
<select aria-label="Reporting month" value={month} onChange={(e) => go({ month: e.target.value })}>
|
||||
{d.R.repMonths.map((m) => <option key={m} value={m}>{monthLabel(m)}</option>)}
|
||||
</select>
|
||||
</span>
|
||||
<button type="button" className="btn btn-onink" onClick={exportCsv}>Export CSV</button>
|
||||
<button type="button" className="btn btn-primary" onClick={d.printEomPack}>Month-end pack</button>
|
||||
</PageHead>
|
||||
|
||||
<div className="tc-rep-stack">
|
||||
<MonthEndStrip month={month} onPrint={d.printEomPack} onJournal={() => { setJump(true); if (tab !== "spend") go({ tab: "spend" }); }} />
|
||||
<div>
|
||||
<Seg label="Report" opts={TABS} value={tab} labels={LABELS} onChange={(t) => go({ tab: t })} />
|
||||
</div>
|
||||
{tab === "spend" && <SpendTab d={d} onMonth={(m) => go({ month: m })} />}
|
||||
{tab === "stock" && <StockTab d={d} />}
|
||||
{tab === "people" && <PeopleTab d={d} />}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
/* The full request queue: /app/requests?filter=&staff=&ward=&open=
|
||||
*
|
||||
* Approval is the ward's and fulfilment is the linen room's; this screen is the linen room's side.
|
||||
* The rows come from components/requests/RequestList, the same list the staff record and Today
|
||||
* use. One payload is fetched and every filter, count and export is a narrowing of it. */
|
||||
import { Suspense, useEffect, useMemo } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { Empty, ErrorLine, PageHead } from "@/components/ui";
|
||||
import { MonoNum, Seg } from "@/components/portal";
|
||||
import RequestList, {
|
||||
REQUEST_FILTERS, REQUEST_FILTER_LABEL, RequestStyles, isRowFilter, requestCounts, requestsFor, scopePayload,
|
||||
useRequestActions, useRequests, type RequestFilter,
|
||||
} from "@/components/requests/RequestList";
|
||||
import Queries from "@/components/requests/Queries";
|
||||
import Damage from "@/components/requests/Damage";
|
||||
import KitCheck from "@/components/requests/KitCheck";
|
||||
import { exportCount, exportRequestsCsv } from "@/components/requests/csv";
|
||||
|
||||
// useSearchParams needs a Suspense boundary for static rendering.
|
||||
export default function RequestsPage() {
|
||||
return <Suspense fallback={null}><RequestsInner /></Suspense>;
|
||||
}
|
||||
|
||||
const isFilter = (v: string | null): v is RequestFilter => !!v && (REQUEST_FILTERS as readonly string[]).includes(v);
|
||||
|
||||
function RequestsInner() {
|
||||
const { s } = useSnap();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const sp = useSearchParams();
|
||||
const rawFilter = sp.get("filter");
|
||||
const staffId = sp.get("staff") || undefined;
|
||||
const ward = sp.get("ward") || undefined;
|
||||
const openId = sp.get("open");
|
||||
|
||||
const { data, error, reload } = useRequests();
|
||||
const { act, error: actError } = useRequestActions(reload);
|
||||
|
||||
const person = staffId ? s.staff.find((x) => x.id === staffId) : undefined;
|
||||
const scope = useMemo(() => ({ staffId, ward }), [staffId, ward]);
|
||||
const scoped = useMemo(() => (data ? scopePayload(data, scope, person?.num) : null), [data, scope, person?.num]);
|
||||
const counts = useMemo(() => (scoped ? requestCounts(scoped) : null), [scoped]);
|
||||
|
||||
/* A deep link to one request with no filter named lands on the first filter that holds it, so
|
||||
?open= always shows the row expanded. */
|
||||
const filter: RequestFilter = useMemo(() => {
|
||||
if (isFilter(rawFilter)) return rawFilter;
|
||||
if (openId && scoped) {
|
||||
for (const f of ["todo", "open", "all"] as const) if (requestsFor(scoped.requests, f).some((r) => r.id === openId)) return f;
|
||||
}
|
||||
return "todo";
|
||||
}, [rawFilter, openId, scoped]);
|
||||
|
||||
function setParams(next: Record<string, string | null>) {
|
||||
const q = new URLSearchParams(sp.toString());
|
||||
for (const [k, v] of Object.entries(next)) { if (v === null) q.delete(k); else q.set(k, v); }
|
||||
const qs = q.toString();
|
||||
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
|
||||
}
|
||||
|
||||
// An unknown ?filter= is dropped rather than left in the address bar disagreeing with the screen.
|
||||
useEffect(() => {
|
||||
if (!rawFilter || isFilter(rawFilter)) return;
|
||||
const q = new URLSearchParams(window.location.search);
|
||||
q.delete("filter");
|
||||
const qs = q.toString();
|
||||
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
|
||||
}, [rawFilter, router, pathname]);
|
||||
|
||||
const showing = staffId ? (person ? `${person.first} ${person.last}`.trim() : data?.requests.find((r) => r.staffId === staffId)?.staffName || "one person") : ward;
|
||||
const more = data?.moreRequests ? "+" : "";
|
||||
const segCounts = counts
|
||||
? Object.fromEntries(REQUEST_FILTERS.map((f) => [f, `${counts[f]}${isRowFilter(f) ? more : ""}`])) as Record<RequestFilter, string>
|
||||
: undefined;
|
||||
|
||||
const shown = scoped ? exportCount(filter, scoped) : 0;
|
||||
const narrowed = !!showing || (filter !== "all" && filter !== "cycles");
|
||||
|
||||
return (
|
||||
<section>
|
||||
<RequestStyles />
|
||||
<PageHead title="Requests">
|
||||
<button type="button" className="btn btn-ghost" disabled={!scoped || shown === 0}
|
||||
onClick={() => { if (scoped) exportRequestsCsv(s, filter, scoped, showing); }}>
|
||||
{narrowed && filter !== "cycles" ? `Export CSV (${shown} shown)` : "Export CSV"}
|
||||
</button>
|
||||
</PageHead>
|
||||
|
||||
{showing && (
|
||||
<div className="tc-req-actions" style={{ marginBottom: 12 }}>
|
||||
<span className="tc-meta-line" style={{ fontSize: 13 }}>Showing <b style={{ color: "var(--color-text)" }}>{showing}</b></span>
|
||||
<button type="button" className="btn btn-ghost" style={{ minHeight: 30, padding: "2px 8px" }}
|
||||
aria-label={`Clear, show every ${staffId ? "person" : "ward"}`}
|
||||
onClick={() => setParams(staffId ? { staff: null, open: null } : { ward: null, open: null })}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!data ? (
|
||||
error ? (
|
||||
<>
|
||||
<ErrorLine msg={error} />
|
||||
<div style={{ marginTop: 12 }}><button type="button" className="btn btn-secondary" onClick={() => void reload()}>Try again</button></div>
|
||||
</>
|
||||
) : <Empty>Loading…</Empty>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
{error && <ErrorLine msg={error} />}
|
||||
{counts && counts.noapprover > 0 && filter !== "noapprover" && (
|
||||
<div className="tc-flag tc-req-actions" style={{ border: "2px solid var(--color-text)", borderLeft: "4px solid var(--color-accent)", padding: "10px 16px", justifyContent: "space-between" }}>
|
||||
<b style={{ fontSize: 13.5 }}>
|
||||
<span className="tc-mark" aria-hidden="true" />
|
||||
<MonoNum weight={600} tone="accent" size={15}>{counts.noapprover}{more}</MonoNum> with no approver
|
||||
</b>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setParams({ filter: "noapprover" })}>Address them</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="tc-req-scroll">
|
||||
<Seg label="Which requests" opts={REQUEST_FILTERS} labels={REQUEST_FILTER_LABEL} counts={segCounts} value={filter}
|
||||
onChange={(f) => setParams({ filter: f })} style={{ flexWrap: "nowrap", width: "max-content" }} />
|
||||
</div>
|
||||
|
||||
{actError && <ErrorLine msg={actError} />}
|
||||
|
||||
{isRowFilter(filter) ? (
|
||||
<RequestList filter={filter} openId={openId} data={scoped} reload={reload} />
|
||||
) : filter === "queries" ? (
|
||||
<Queries rows={scoped?.disputes ?? []} act={act} />
|
||||
) : filter === "damage" ? (
|
||||
<Damage rows={scoped?.damage ?? []} act={act} />
|
||||
) : (
|
||||
<KitCheck cycle={scoped?.cycle ?? null} shortfalls={scoped?.shortfalls ?? []} waiting={scoped?.waiting ?? []} act={act} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { PageHead, Empty, LiveRegion } from "@/components/ui";
|
||||
import { Panel, QueueRow, Seg } from "@/components/portal";
|
||||
import { DeliverDialog } from "@/components/dialogs";
|
||||
import type { PickupRec } from "@/lib/compute";
|
||||
import { plural, roundSheet } from "@/lib/today";
|
||||
import { Lines } from "@/components/today/Lines";
|
||||
|
||||
// Delivery rounds: every uncollected pickup by ward, handed over on the floor with an on-screen
|
||||
// signature and a handover photo (DeliverDialog).
|
||||
|
||||
const ALL = "__all__";
|
||||
|
||||
export default function RoundsPage() {
|
||||
const { s } = useSnap();
|
||||
const { byId, staffById } = useDerived();
|
||||
const [deliver, setDeliver] = useState<PickupRec | null>(null);
|
||||
const [msg, setMsg] = useState("");
|
||||
const [ward, setWard] = useState<string>(ALL);
|
||||
useEffect(() => { const w = new URLSearchParams(window.location.search).get("ward"); if (w) setWard(w); }, []);
|
||||
|
||||
const sheet = useMemo(() => roundSheet(s, byId, staffById), [s, byId, staffById]);
|
||||
const bags = sheet.reduce((t, w) => t + w.rows.length, 0);
|
||||
const garments = sheet.reduce((t, w) => t + w.garments, 0);
|
||||
const current = ward !== ALL && sheet.some((w) => w.ward === ward) ? ward : ALL;
|
||||
const shown = current === ALL ? sheet : sheet.filter((w) => w.ward === current);
|
||||
|
||||
function choose(w: string) {
|
||||
setWard(w);
|
||||
const u = new URL(window.location.href);
|
||||
if (w === ALL) u.searchParams.delete("ward"); else u.searchParams.set("ward", w);
|
||||
window.history.replaceState(null, "", u.pathname + u.search);
|
||||
}
|
||||
|
||||
const opts = [ALL, ...sheet.map((w) => w.ward)];
|
||||
const labels: Record<string, string> = { [ALL]: "All" };
|
||||
const counts: Record<string, number> = { [ALL]: bags };
|
||||
for (const w of sheet) counts[w.ward] = w.rows.length;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<PageHead title="Delivery rounds" sub={<span className="tc-mono">{plural(bags, "bag")} · {plural(sheet.length, "ward")} · {plural(garments, "garment")}</span>} />
|
||||
<LiveRegion msg={msg} style={{ marginTop: 16, fontSize: 13, fontWeight: 600 }} />
|
||||
{sheet.length > 1 && (
|
||||
<div style={{ marginTop: 16, overflowX: "auto" }}>
|
||||
<Seg label="Ward" opts={opts} value={current} onChange={choose} labels={labels} counts={counts} />
|
||||
</div>
|
||||
)}
|
||||
{bags === 0 && <Empty>Nothing waiting for delivery.</Empty>}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 18, marginTop: 18 }}>
|
||||
{shown.map((w) => (
|
||||
<Panel key={w.ward} title={w.ward} aside={<span className="tc-mono">{w.cc || "—"} · {w.rows.length} to deliver</span>}>
|
||||
{w.rows.map((r) => (
|
||||
<QueueRow
|
||||
key={r.p.id}
|
||||
age={`${r.days}d`}
|
||||
ageLabel="waiting"
|
||||
urgent={r.late}
|
||||
title={r.name}
|
||||
titleMeta={r.phone ? (r.tel ? <a href={r.tel} style={{ color: "inherit" }}>{r.phone}</a> : r.phone) : undefined}
|
||||
meta={<><Lines lines={r.lines} /> · {r.p.orderCode}</>}
|
||||
actions={<button type="button" className="btn btn-primary" aria-label={`Sign for the delivery to ${r.name}`} onClick={() => setDeliver(r.p)}>Delivered — sign</button>}
|
||||
/>
|
||||
))}
|
||||
</Panel>
|
||||
))}
|
||||
</div>
|
||||
{deliver && <DeliverDialog pickup={deliver} onClose={() => setDeliver(null)} onDone={(m) => setMsg(m)} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
/* Settings: seven sections in a side list, each a ?tab= of its own so deep links and the help mark
|
||||
* land on the right one. Old tab names (general, account, locations, activity…) still resolve. */
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { PageHead } from "@/components/ui";
|
||||
import dynamic from "next/dynamic";
|
||||
import { CSV_TEMPLATES } from "@/lib/csv";
|
||||
import { SECTIONS, SettingsStyles, resolveSection, type SectionId } from "@/components/settings/common";
|
||||
import FacilitySection from "@/components/settings/FacilitySection";
|
||||
import IssuingRules from "@/components/settings/IssuingRules";
|
||||
import CatalogueSection from "@/components/settings/CatalogueSection";
|
||||
import PlacesSection from "@/components/settings/PlacesSection";
|
||||
import PeopleSignIn from "@/components/settings/PeopleSignIn";
|
||||
import DataAudit from "@/components/settings/DataAudit";
|
||||
|
||||
// Plan pulls in Stripe; load it only when the Plan section is open.
|
||||
const PlanTab = dynamic(() => import("@/components/PlanTab"), { ssr: false });
|
||||
|
||||
// useSearchParams needs a Suspense boundary for static rendering.
|
||||
export default function SettingsPage() {
|
||||
return <Suspense fallback={null}><SettingsInner /></Suspense>;
|
||||
}
|
||||
|
||||
function SettingsInner() {
|
||||
const { s, isAdmin } = useSnap();
|
||||
const sp = useSearchParams();
|
||||
const planShown = isAdmin && !!s.plan?.live && !s.demo;
|
||||
// #hash forms from old links (/app/settings#account).
|
||||
const [hash, setHash] = useState("");
|
||||
useEffect(() => {
|
||||
const read = () => setHash(window.location.hash.replace("#", ""));
|
||||
read();
|
||||
window.addEventListener("hashchange", read);
|
||||
return () => window.removeEventListener("hashchange", read);
|
||||
}, []);
|
||||
|
||||
const tabParam = sp.get("tab");
|
||||
const importParam = sp.get("import") || "";
|
||||
const importKind = CSV_TEMPLATES[importParam] ? importParam : undefined;
|
||||
const resolved = resolveSection(tabParam || hash || (importKind ? "data" : ""));
|
||||
let section: SectionId = resolved.section;
|
||||
if (section === "plan" && !planShown) section = "facility";
|
||||
const sections = SECTIONS.filter((x) => x.id !== "plan" || planShown);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SettingsStyles />
|
||||
<PageHead title="Settings" />
|
||||
<div className="tc-set">
|
||||
<nav aria-label="Settings sections" className="tc-set-nav">
|
||||
{sections.map((x) => (
|
||||
<Link key={x.id} href={`/app/settings?tab=${x.id}`} scroll={false} aria-current={x.id === section ? "page" : undefined}>{x.label}</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="tc-set-body" key={section}>
|
||||
{section === "facility" && <FacilitySection />}
|
||||
{section === "issuing" && <IssuingRules />}
|
||||
{section === "catalogue" && <CatalogueSection />}
|
||||
{section === "places" && <PlacesSection />}
|
||||
{section === "people" && <PeopleSignIn />}
|
||||
{section === "data" && <DataAudit importKind={importKind} scrollAudit={resolved.audit} />}
|
||||
{section === "plan" && planShown && <PlanTab />}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
import { Suspense } from "react";
|
||||
import StaffRecord from "@/components/people/Record";
|
||||
|
||||
// The record's tab and edit mode live in the address; useSearchParams needs a Suspense boundary.
|
||||
export default function StaffProfile() {
|
||||
return <Suspense fallback={null}><StaffRecord /></Suspense>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
import { Suspense } from "react";
|
||||
import Register from "@/components/people/Register";
|
||||
|
||||
// Register reads its filters from the address; useSearchParams needs a Suspense boundary.
|
||||
export default function StaffPage() {
|
||||
return <Suspense fallback={null}><Register /></Suspense>;
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { PageHead, Empty, ErrorLine, Field, Notice } from "@/components/ui";
|
||||
import { AdjustDialog, DuplicateItemDialog, GROUPS_HINT, GroupsPicker, ScanVariantsDialog } from "@/components/dialogs";
|
||||
import { Icon, MoreMenu, Panel, QtyStepper, Tag } from "@/components/portal";
|
||||
import { StockStyles } from "@/components/stock/StockStyles";
|
||||
import { wholeMoney } from "@/components/stock/url";
|
||||
import { bcBound, countsAsIssued, fmtDate, forecastFor, forecastLabel, fyStart, garmentGroups, genderLabel, issueCost, itemOrderHistory, key, lastCountMap, locTree, money, onOrderMap, onhand, plOf, reorderAt, staffName, statusTag, supplierCodeOf, touched } from "@/lib/compute";
|
||||
|
||||
export default function GarmentPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { s, isAdmin, mutate } = useSnap();
|
||||
const { L, byId, staffById } = useDerived();
|
||||
const it = s.catalog.find((x) => x.id === id);
|
||||
const [edit, setEdit] = useState(false);
|
||||
const [f, setF] = useState({ item: "", sku: "", supplier: "", cost: "", gender: "Unisex", groups: [] as string[], notes: "" });
|
||||
const [newSize, setNewSize] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [msg, setMsg] = useState("");
|
||||
const [adjust, setAdjust] = useState<{ itemId: string; si: number } | null>(null);
|
||||
const [scanSizes, setScanSizes] = useState(false);
|
||||
const [dup, setDup] = useState(false);
|
||||
// What is typed into each size's barcode box, until it is saved: a label code is as often read out
|
||||
// and typed as it is scanned.
|
||||
const [codes, setCodes] = useState<Record<number, string>>({});
|
||||
const [rowErr, setRowErr] = useState<{ si: number; msg: string } | null>(null);
|
||||
const [flash, setFlash] = useState<number | null>(null);
|
||||
|
||||
// Arriving from "Create and scan sizes" (?scan=1) opens the scanner straight away; ?size=<si>
|
||||
// (a scanned garment, a size cell) brings that size's row into view and marks it for two seconds.
|
||||
useEffect(() => {
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
if (sp.get("scan") === "1") {
|
||||
if (isAdmin && it) setScanSizes(true);
|
||||
sp.delete("scan");
|
||||
window.history.replaceState(null, "", window.location.pathname + (sp.toString() ? "?" + sp.toString() : ""));
|
||||
}
|
||||
const size = sp.get("size");
|
||||
if (size === null || !it) return;
|
||||
const si = parseInt(size, 10);
|
||||
if (!(si >= 0 && si < it.sizes.length)) return;
|
||||
const t0 = window.setTimeout(() => {
|
||||
const el = document.getElementById(`size-row-${si}`);
|
||||
if (el) { el.scrollIntoView({ block: "center" }); el.focus({ preventScroll: true }); }
|
||||
setFlash(si);
|
||||
}, 50);
|
||||
const t1 = window.setTimeout(() => setFlash(null), 2050);
|
||||
return () => { window.clearTimeout(t0); window.clearTimeout(t1); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id, !!it]);
|
||||
|
||||
const d = useMemo(() => {
|
||||
if (!it) return null;
|
||||
const oo = onOrderMap(s, byId).byKey;
|
||||
const lastCount = lastCountMap(s);
|
||||
const sizes = it.sizes.map((sz, si) => { const k = key(it.id, si); return { si, size: String(sz), key: k, oh: onhand(s, L, k), ro: reorderAt(s, k), touched: touched(s, L, k), barcode: bcBound(s, it, si), pl: plOf(s, k), onOrd: oo[k] || 0, last: lastCount[k] || "" }; });
|
||||
const tot = sizes.reduce((t, v) => t + v.oh, 0);
|
||||
const value = sizes.reduce((t, v) => t + Math.max(0, v.oh), 0) * it.cost;
|
||||
const fy = fyStart(s.today);
|
||||
const fyList = s.issues.filter((i) => i.itemId === it.id && i.date >= fy && countsAsIssued(i));
|
||||
const fyIssued = fyList.reduce((t, i) => t + i.qty, 0);
|
||||
const fySpend = fyList.reduce((t, i) => t + i.qty * issueCost(i, byId), 0);
|
||||
const onOrder = sizes.reduce((t, v) => t + v.onOrd, 0);
|
||||
const hist: { date: string; kind: string; cls: string; desc: string }[] = [];
|
||||
for (const i of s.issues) if (i.itemId === it.id) {
|
||||
hist.push({ date: i.date, kind: i.direct ? "Collected" : "Issued", cls: "tag tag-neutral", desc: `${it.sizes[i.si]} ×${i.qty} — ${staffName(staffById[i.staffId], "—")}` });
|
||||
if (i.returned) hist.push({ date: i.returned.date, kind: i.returned.cond.replace("Returned - ", "Returned – "), cls: "tag tag-outline", desc: `${it.sizes[i.si]} ×${i.qty} — ${staffName(staffById[i.staffId], "—")}` });
|
||||
}
|
||||
for (const o of s.orders) for (const rc of o.receipts) for (const l of rc.lines) if (l.itemId === it.id) hist.push({ date: rc.date, kind: "Received", cls: "tag tag-accent", desc: `${l.size} ×${l.qty} — ${o.code}${l.dest === "shelf" ? " → shelf" : " → staff pickup"}` });
|
||||
// A counted correction isn't a write-off: it's the shelf disagreeing with the ledger, either way.
|
||||
for (const m of s.moves) if (m.itemId === it.id) hist.push({ date: m.date, kind: m.reason === "Counted correction" ? "Counted" : m.qty < 0 ? "Write-off" : "Added", cls: "tag tag-outline", desc: `${it.sizes[m.si] ?? ""} ${m.reason === "Counted correction" ? (m.qty < 0 ? "−" : "+") + Math.abs(m.qty) : "×" + Math.abs(m.qty)}${m.reason ? " — " + m.reason : ""}` });
|
||||
hist.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
|
||||
return { sizes, tot, value, fyIssued, fySpend, onOrder, hist: hist.slice(0, 25) };
|
||||
}, [it, s, L, byId, staffById]);
|
||||
|
||||
if (!it || !d) {
|
||||
return (
|
||||
<section>
|
||||
<StockStyles />
|
||||
<PageHead title="Garment not found" />
|
||||
<nav aria-label="Breadcrumb" className="tc-stk-crumb"><Icon name="chevronLeft" size={16} /><Link href="/app/stock">Stock</Link></nav>
|
||||
<Empty>That garment isn't in the catalogue.</Empty>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const tagged = garmentGroups(it.groups);
|
||||
const startEdit = () => { setF({ item: it.item, sku: it.sku, supplier: it.supplier, cost: String(it.cost), gender: it.gender, groups: tagged, notes: it.notes }); setErr(""); setEdit(true); };
|
||||
const invalid = !f.item.trim() || !(parseFloat(f.cost) >= 0) || f.cost === "";
|
||||
async function save() {
|
||||
if (invalid) return;
|
||||
const r = await mutate("catalog.update", { id: it!.id, item: f.item, sku: f.sku, supplier: f.supplier, cost: parseFloat(f.cost), gender: f.gender, groups: f.groups, notes: f.notes });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setEdit(false);
|
||||
}
|
||||
async function act(op: string, payload: unknown) { setErr(""); setRowErr(null); setMsg(""); const r = await mutate(op, payload); if (!r.ok) setErr(r.error); }
|
||||
const sizeInvalid = !newSize.trim() || it.sizes.map(String).includes(newSize.trim());
|
||||
// Adding a size does not mint a barcode: most sizes arrive with the supplier's own number.
|
||||
async function addSize() {
|
||||
if (sizeInvalid) return;
|
||||
setErr("");
|
||||
const r = await mutate("catalog.update", { id: it!.id, addSize: newSize.trim() });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setNewSize("");
|
||||
}
|
||||
// A size row's own refusal belongs on that row.
|
||||
function rowFail(si: number, m: string) { setErr(""); setRowErr({ si, msg: m }); }
|
||||
async function saveCode(si: number, size: string, bound: string, force = false) {
|
||||
const code = (codes[si] ?? bound).trim();
|
||||
if (code === bound) { setCodes((c) => ({ ...c, [si]: bound })); return; }
|
||||
// Clearing the box is how a wrong code comes off.
|
||||
if (!code) { if (confirm(`Unbind ${bound} from size ${size}?`)) await unbindCode(si, bound); return; }
|
||||
setErr(""); setRowErr(null);
|
||||
const r = await mutate("barcode.bind", { code, itemId: it!.id, si, force });
|
||||
if (!r.ok) {
|
||||
// "Already on another garment" is the one refusal force may clear.
|
||||
if (!force && r.error.includes("re-bind to move it") && confirm(`${r.error}\n\nMove ${code} onto ${it!.item} · size ${size}?`)) { await saveCode(si, size, bound, true); return; }
|
||||
rowFail(si, r.error); return;
|
||||
}
|
||||
setCodes((c) => ({ ...c, [si]: code }));
|
||||
}
|
||||
async function unbindCode(si: number, code: string) {
|
||||
setErr(""); setRowErr(null);
|
||||
const r = await mutate("barcode.unbind", { code });
|
||||
if (!r.ok) { rowFail(si, r.error); return; }
|
||||
setCodes((c) => ({ ...c, [si]: "" }));
|
||||
}
|
||||
// Our own number for sizes that arrived without one. Only fills gaps; the server has the last word.
|
||||
async function generateAll() {
|
||||
const missing = d!.sizes.filter((v) => !v.barcode).length;
|
||||
if (missing && !confirm(`Generate a barcode for the ${missing} size${missing === 1 ? "" : "s"} on ${it!.item} with none? Sizes with a supplier’s code keep it.`)) return;
|
||||
setErr(""); setRowErr(null); setMsg("");
|
||||
const r = await mutate<{ made: { si: number; size: string; code: string }[]; count: number }>("barcode.generate", { itemId: it!.id });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setCodes({});
|
||||
setMsg(`Generated ${r.result.count} barcode${r.result.count === 1 ? "" : "s"} — size${r.result.count === 1 ? "" : "s"} ${r.result.made.map((m) => m.size).join(", ")}.`);
|
||||
}
|
||||
async function generateOne(si: number, size: string) {
|
||||
setErr(""); setRowErr(null); setMsg("");
|
||||
const r = await mutate<{ made: { si: number; size: string; code: string }[]; count: number }>("barcode.generate", { itemId: it!.id, si });
|
||||
if (!r.ok) { rowFail(si, r.error); return; }
|
||||
setCodes((c) => { const n = { ...c }; delete n[si]; return n; });
|
||||
setMsg(`Size ${size} now carries ${r.result.made.map((m) => m.code).join(", ")}.`);
|
||||
}
|
||||
async function removeSize(si: number, size: string) {
|
||||
if (!confirm(`Remove size ${size} from ${it!.item}? Its reorder level and barcode go with it.`)) return;
|
||||
setErr(""); setRowErr(null);
|
||||
const r = await mutate("catalog.removeSize", { id: it!.id, si });
|
||||
if (!r.ok) { rowFail(si, r.error); return; }
|
||||
// Every size above the removed one shifts down a place.
|
||||
setCodes({});
|
||||
}
|
||||
// One label per garment on hand across the sizes that carry a code, so the count goes on the menu
|
||||
// item and into the question before the print dialog opens.
|
||||
const labelled = d.sizes.filter((v) => v.barcode).length;
|
||||
const labels = d.sizes.reduce((t, v) => t + (v.barcode ? Math.max(0, v.oh) : 0), 0);
|
||||
function printLabels() {
|
||||
if (labels && !confirm(`Print ${labels} label${labels === 1 ? "" : "s"} for ${it!.item}? One for every garment on hand, across the ${labelled} size${labelled === 1 ? "" : "s"} carrying a barcode.`)) return;
|
||||
window.open(`/print/labels?item=${encodeURIComponent(it!.id)}`, "_blank", "noopener");
|
||||
}
|
||||
|
||||
const locOpts = locTree(s).map(({ loc, depth }) => ({ id: loc.id, name: " ".repeat(depth * 2) + loc.name }));
|
||||
const sp = s.supplierDir.find((x) => x.name === it.supplier);
|
||||
const hist = itemOrderHistory(s, it.id);
|
||||
const prices = s.costs.filter((c) => c.itemId === it.id).sort((a, b) => b.at.localeCompare(a.at));
|
||||
const colCount = 10;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<StockStyles />
|
||||
<PageHead
|
||||
title={it.item}
|
||||
below={
|
||||
<div className="tc-stk-tags">
|
||||
{tagged.length ? tagged.map((g) => <Tag key={g}>{g}</Tag>) : <Tag>All groups</Tag>}
|
||||
{it.gender !== "Unisex" && <Tag>{genderLabel(it.gender)}</Tag>}
|
||||
<Tag>{it.supplier || "No supplier"}</Tag>
|
||||
{it.archived && <Tag tone="accent">Discontinued</Tag>}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="tc-stk-headfig">
|
||||
<span className="tc-stk-mono">{d.tot}</span>
|
||||
on hand · <span className="tc-mono">{wholeMoney(d.value)}</span>
|
||||
</div>
|
||||
{isAdmin && (!edit ? (
|
||||
<>
|
||||
<button type="button" className="btn btn-primary" onClick={startEdit}>Edit garment</button>
|
||||
<button type="button" className="btn btn-onink" onClick={() => setScanSizes(true)}>Scan sizes</button>
|
||||
<MoreMenu tone="ink" items={[
|
||||
{ label: `Print labels (${labels})`, onSelect: printLabels },
|
||||
{ label: "Generate barcodes", onSelect: generateAll },
|
||||
{ label: "Duplicate", onSelect: () => setDup(true) },
|
||||
it.archived
|
||||
? { label: "Reinstate", onSelect: () => act("catalog.update", { id: it.id, archived: false }) }
|
||||
: { label: "Discontinue", danger: true, onSelect: () => act("catalog.update", { id: it.id, archived: true }) },
|
||||
]} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className="btn btn-primary" onClick={save} disabled={invalid}>Save changes</button>
|
||||
<button type="button" className="btn btn-onink" onClick={() => setEdit(false)}>Cancel</button>
|
||||
</>
|
||||
))}
|
||||
</PageHead>
|
||||
<nav aria-label="Breadcrumb" className="tc-stk-crumb">
|
||||
<Icon name="chevronLeft" size={16} /><Link href="/app/stock">Stock</Link><span aria-hidden="true">/</span><span style={{ fontWeight: 600, color: "var(--color-text)" }} aria-current="page">{it.item}</span>
|
||||
</nav>
|
||||
<ErrorLine msg={err} />
|
||||
<Notice msg={msg} />
|
||||
|
||||
<div className="tc-stk-grid" style={{ marginTop: 8 }}>
|
||||
<div className="tc-stk-col">
|
||||
{edit && (
|
||||
<Panel title="Edit details">
|
||||
<div className="tc-stk-pad tc-grid" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
||||
<Field label="Item name" style={{ gridColumn: "1 / -1" }} error={!f.item.trim() ? "Needed — the garment has to have a name." : undefined}>{(c) => <input {...c} className="input" value={f.item} onChange={(e) => setF({ ...f, item: e.target.value })} />}</Field>
|
||||
<Field label="SKU / style code">{(c) => <input {...c} className="input" value={f.sku} onChange={(e) => setF({ ...f, sku: e.target.value })} />}</Field>
|
||||
<Field label="Unit cost ($)" error={f.cost !== "" && !(parseFloat(f.cost) >= 0) ? "Give a number, or 0." : undefined}>{(c) => <input {...c} className="input" inputMode="decimal" value={f.cost} onChange={(e) => setF({ ...f, cost: e.target.value.replace(/[^0-9.]/g, "") })} />}</Field>
|
||||
<Field label="Supplier">{(c) => <><input {...c} className="input" list="tc-suppliers" value={f.supplier} onChange={(e) => setF({ ...f, supplier: e.target.value })} /><datalist id="tc-suppliers">{s.settings.suppliers.map((x) => <option key={x} value={x} />)}</datalist></>}</Field>
|
||||
<Field label="Gender">{(c) => <select {...c} className="input" value={f.gender} onChange={(e) => setF({ ...f, gender: e.target.value })}><option value="Unisex">Unisex</option><option value="Male">Men's</option><option value="Female">Women's</option></select>}</Field>
|
||||
<GroupsPicker style={{ gridColumn: "1 / -1" }} value={f.groups} onChange={(groups) => setF({ ...f, groups })} groups={s.settings.staffGroups} hint={GROUPS_HINT} />
|
||||
<Field label="Notes" style={{ gridColumn: "1 / -1" }}>{(c) => <textarea {...c} className="input" rows={2} value={f.notes} onChange={(e) => setF({ ...f, notes: e.target.value })} placeholder="Fit notes, replacement style, supplier quirks…" />}</Field>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
<Panel title="Sizes" aside={<span className="tc-mono">{labelled} of {it.sizes.length} carry a barcode</span>}
|
||||
foot={isAdmin ? (
|
||||
<div className="tc-stk-row" style={{ alignItems: "flex-end", width: "100%" }}>
|
||||
<Field label="Add a size" hint="Starts with no barcode." style={{ flex: 1, minWidth: 160 }} error={newSize.trim() && it.sizes.map(String).includes(newSize.trim()) ? "That size is already on this garment." : undefined}>{(c) => <input {...c} className="input" value={newSize} onChange={(e) => setNewSize(e.target.value)} placeholder="e.g. 6XL or 127" onKeyDown={(e) => { if (e.key === "Enter") addSize(); }} />}</Field>
|
||||
<button type="button" className="btn btn-secondary" onClick={addSize} disabled={sizeInvalid}>Add size</button>
|
||||
</div>
|
||||
) : undefined}>
|
||||
<div className="table-wrap">
|
||||
<table className="tc-table tc-stk-table" style={{ minWidth: isAdmin ? 1040 : 860 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Size</th><th>Barcode</th><th>Status</th><th className="num">On hand</th><th className="num">Pre-loved</th><th className="num">On order</th><th>Last counted</th><th>Location</th><th className="num">Reorder at</th><th><span className="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{d.sizes.map((v) => {
|
||||
const status = v.oh <= 0 ? (v.touched ? "out" : "none") : v.oh <= v.ro ? "reorder" : "ok";
|
||||
const draft = codes[v.si] ?? v.barcode;
|
||||
const dirty = draft.trim() !== v.barcode;
|
||||
return (
|
||||
<Fragment key={v.si}>
|
||||
<tr id={`size-row-${v.si}`} tabIndex={-1} className={flash === v.si ? "tc-stk-flash" : undefined}>
|
||||
<td className="tc-mono" style={{ fontWeight: 600 }}>{v.size}</td>
|
||||
<td>
|
||||
{isAdmin ? (
|
||||
<span className="tc-stk-row" style={{ gap: 4, flexWrap: "nowrap" }}>
|
||||
<input className="input tc-stk-tight tc-mono" style={{ width: 150 }} value={draft} maxLength={64} inputMode="numeric" placeholder="Not bound"
|
||||
aria-label={`Barcode for size ${v.size}`} title="Type or scan the label code. Clear the box to unbind."
|
||||
onChange={(e) => setCodes({ ...codes, [v.si]: e.target.value })}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") saveCode(v.si, v.size, v.barcode); }} />
|
||||
{dirty
|
||||
? <button type="button" className="btn btn-secondary tc-stk-tight" aria-label={`Save the barcode for size ${v.size}`} onClick={() => saveCode(v.si, v.size, v.barcode)}>Save</button>
|
||||
: v.barcode
|
||||
? <button type="button" className="btn btn-ghost btn-icon" title="Unbind this barcode" aria-label={`Unbind barcode ${v.barcode} from size ${v.size}`} onClick={() => { if (confirm(`Unbind ${v.barcode} from size ${v.size}?`)) unbindCode(v.si, v.barcode); }}>×</button>
|
||||
: <button type="button" className="btn btn-ghost tc-stk-tight" aria-label={`Generate a barcode for size ${v.size}`} onClick={() => generateOne(v.si, v.size)}>Generate</button>}
|
||||
</span>
|
||||
) : (
|
||||
<span className="tc-mono" style={{ fontSize: 12, color: v.barcode ? undefined : "var(--color-neutral-600)" }}>{v.barcode || "Not bound"}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{status === "out" ? <Tag tone="accent">Out</Tag> : status === "reorder" ? <Tag tone="low">Reorder</Tag> : status === "ok" ? <Tag tone="quiet">OK</Tag> : <Tag tone="quiet">—</Tag>}</td>
|
||||
<td className="num" style={{ fontWeight: 600, color: status === "out" || status === "reorder" ? "var(--color-accent-700)" : undefined }}>{v.oh}</td>
|
||||
<td className="num">{v.pl > 0 ? v.pl : "–"}</td>
|
||||
<td className="num">{v.onOrd > 0 ? v.onOrd : "–"}</td>
|
||||
<td className="tc-mono" style={{ fontSize: 12 }}>{v.last ? fmtDate(v.last) : "never"}</td>
|
||||
<td>
|
||||
<select className="input tc-stk-tight" value={s.placed[v.key] || ""} aria-label={`Where size ${v.size} lives`}
|
||||
onChange={(e) => act("location.place", { itemId: it.id, si: v.si, locationId: e.target.value })}
|
||||
disabled={s.locations.length === 0} title={s.locations.length === 0 ? "No locations yet" : undefined}>
|
||||
<option value="">{s.locations.length === 0 ? "—" : "Unplaced"}</option>
|
||||
{locOpts.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td className="num">
|
||||
{isAdmin
|
||||
? <QtyStepper size="sm" label={`reorder level for size ${v.size}`} value={v.ro} onChange={(n) => act("stock.reorder", { itemId: it.id, si: v.si, reorder: Math.max(0, n) })} />
|
||||
: v.ro}
|
||||
</td>
|
||||
<td style={{ whiteSpace: "nowrap", textAlign: "right" }}>
|
||||
<button type="button" className="btn btn-ghost tc-stk-tight" aria-label={`Adjust the quantity of size ${v.size}`} onClick={() => setAdjust({ itemId: it.id, si: v.si })}>Adjust</button>
|
||||
{isAdmin && <> <button type="button" className="btn btn-ghost tc-stk-tight" aria-label={`Remove size ${v.size} from this garment`} onClick={() => removeSize(v.si, v.size)}>Remove</button></>}
|
||||
</td>
|
||||
</tr>
|
||||
{rowErr?.si === v.si && <tr><td colSpan={colCount} style={{ borderTop: 0, paddingTop: 0 }}><ErrorLine msg={rowErr.msg} /></td></tr>}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Ordering" aside={<>{it.supplier || "no supplier"}{sp?.lead ? <> · <span className="tc-mono">{sp.lead}</span>-day lead</> : null}</>}>
|
||||
<div className="table-wrap">
|
||||
<table className="tc-table" style={{ minWidth: 620 }}>
|
||||
<thead>
|
||||
<tr><th>Size</th><th>Supplier code</th><th title="Weekly issues over 13 weeks (26 if none) × (lead time + 2 weeks)">Usage · suggested reorder</th><th className="num">Reorder at</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{d.sizes.map((v) => {
|
||||
const fc = forecastFor(s, L, byId, v.key);
|
||||
const code = supplierCodeOf(s, v.key);
|
||||
return (
|
||||
<tr key={"ord" + v.si}>
|
||||
<td className="tc-mono" style={{ fontWeight: 600 }}>{v.size}</td>
|
||||
<td>{isAdmin
|
||||
? <input key={code} className="input tc-stk-tight tc-mono" style={{ width: 180 }} defaultValue={code} placeholder="e.g. NW-10422-M" maxLength={60} aria-label={`Supplier code for size ${v.size}`}
|
||||
onBlur={(e) => { if (e.target.value.trim() !== code) act("stock.supplierCode", { itemId: it.id, si: v.si, code: e.target.value.trim() }); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} />
|
||||
: <span className="tc-mono">{code || "—"}</span>}</td>
|
||||
<td>
|
||||
<span className="tc-stk-row" style={{ gap: 8 }}>
|
||||
<span>{fc.suggestedReorder !== null ? <><b>Suggested <span className="tc-mono">{fc.suggestedReorder}</span></b> · {forecastLabel(fc)}</> : forecastLabel(fc)}</span>
|
||||
{fc.runsOutBeforeDelivery && <Tag tone="low" title="At the current rate the shelf runs out before a delivery placed today would arrive">runs out before delivery</Tag>}
|
||||
{isAdmin && fc.suggestedReorder !== null && fc.suggestedReorder !== v.ro && <button type="button" className="btn btn-ghost tc-stk-tight" aria-label={`Set the reorder level for size ${v.size} to ${fc.suggestedReorder}`} onClick={() => act("stock.reorder", { itemId: it.id, si: v.si, reorder: fc.suggestedReorder })}>Use</button>}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num" style={{ fontWeight: 600 }}>{v.ro}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Orders" aside={<span className="tc-mono">{hist.length} line{hist.length === 1 ? "" : "s"}</span>}>
|
||||
{hist.length === 0 ? <div className="tc-stk-pad"><Empty pad={2}>Never ordered.</Empty></div> : (
|
||||
<div className="table-wrap">
|
||||
<table className="tc-table" style={{ minWidth: 820 }}>
|
||||
<thead>
|
||||
<tr><th>Date</th><th>Order</th><th>Supplier</th><th>Size</th><th className="num">Qty</th><th className="num">Unit then</th><th>Supplier ref</th><th>Invoice</th><th>Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{hist.map((h, i) => (
|
||||
<tr key={i}>
|
||||
<td className="tc-mono">{fmtDate(h.date)}</td>
|
||||
<td className="tc-mono"><Link href={`/app/orders/${h.orderId}`}>{h.code}</Link></td>
|
||||
<td>{h.supplier || "—"}</td>
|
||||
<td className="tc-mono">{h.size}</td>
|
||||
<td className="num" style={{ fontWeight: 600 }}>{h.qty}</td>
|
||||
<td className="num">{h.unit ? money(h.unit) : "—"}</td>
|
||||
<td className="tc-mono">{h.ref || "—"}</td>
|
||||
<td className="tc-mono">{h.invoice || "—"}</td>
|
||||
<td><span className={statusTag(h.status)}>{h.status}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Price history" aside={<>now <span className="tc-mono">{money(it.cost)}</span></>}>
|
||||
{prices.length === 0 ? <div className="tc-stk-pad"><Empty pad={2}>No price changes recorded.</Empty></div> : (
|
||||
<div>
|
||||
{prices.map((c) => (
|
||||
<div key={c.id} className="tc-stk-kv">
|
||||
<span><span className="tc-mono">{fmtDate(c.at.slice(0, 10))}</span>{c.byName ? ` · ${c.byName}` : ""}</span>
|
||||
<span className="tc-mono">{c.previous !== null ? `${money(c.previous)} → ` : ""}{money(c.cost)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{it.notes && !edit && (
|
||||
<Panel title="Notes">
|
||||
<div className="tc-stk-pad" style={{ fontSize: 13, lineHeight: 1.6, whiteSpace: "pre-wrap" }}>{it.notes}</div>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="tc-stk-col">
|
||||
<Panel title="This financial year">
|
||||
<div>
|
||||
{([["Issued this FY", `${d.fyIssued}`], ["FY spend (at issue price)", money(d.fySpend)], ["On open orders", `${d.onOrder}`], ["Unit cost", money(it.cost)], ["Sizes carried", String(it.sizes.length)]] as const).map(([k, v]) => (
|
||||
<div key={k} className="tc-stk-kv"><span>{k}</span><span className="tc-mono" style={{ fontWeight: 600 }}>{v}</span></div>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel title="Recent movement" aside={d.hist.length > 0 ? "newest first" : undefined}>
|
||||
{d.hist.length === 0 ? <div className="tc-stk-pad"><Empty pad={2}>No movement recorded yet.</Empty></div> : (
|
||||
<div>
|
||||
{d.hist.map((h, i) => (
|
||||
<div key={i} className="tc-stk-kv" style={{ justifyContent: "flex-start" }}>
|
||||
<span className="tc-mono tc-stk-meta" style={{ flex: "none", width: 84 }}>{fmtDate(h.date)}</span>
|
||||
<span className={h.cls} style={{ flex: "none" }}>{h.kind}</span>
|
||||
<span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{h.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{adjust && <AdjustDialog init={adjust} onClose={() => setAdjust(null)} />}
|
||||
{/* Drafts go when the scanner closes: it binds codes to these same sizes. */}
|
||||
{scanSizes && <ScanVariantsDialog item={it} onClose={() => { setScanSizes(false); setCodes({}); }} />}
|
||||
{dup && <DuplicateItemDialog item={it} onClose={() => setDup(false)} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
import { Suspense, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { PageHead } from "@/components/ui";
|
||||
import { Seg } from "@/components/portal";
|
||||
import { ItemDialog, ScanAddDialog } from "@/components/dialogs";
|
||||
import OnHand, { asStockFilter } from "@/components/stock/OnHand";
|
||||
import CountTab from "@/components/stock/CountTab";
|
||||
import Locations from "@/components/stock/Locations";
|
||||
import { StockStyles } from "@/components/stock/StockStyles";
|
||||
import { ALL_GROUPS } from "@/lib/compute";
|
||||
|
||||
const TABS = ["onhand", "count", "locations"] as const;
|
||||
type StockTab = (typeof TABS)[number];
|
||||
const TAB_LABELS: Record<StockTab, string> = { onhand: "On hand", count: "Count", locations: "Locations" };
|
||||
const TAB_HREFS: Record<StockTab, string> = { onhand: "/app/stock?tab=onhand", count: "/app/stock?tab=count", locations: "/app/stock?tab=locations" };
|
||||
|
||||
function StockScreen() {
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
const { isAdmin } = useSnap();
|
||||
const raw = sp.get("tab");
|
||||
const tab: StockTab = raw === "count" || raw === "locations" ? raw : "onhand";
|
||||
const [newItem, setNewItem] = useState(false);
|
||||
const [scanAdd, setScanAdd] = useState(false);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<StockStyles />
|
||||
<PageHead title="Stock">
|
||||
<Seg tone="ink" label="Stock view" opts={TABS} value={tab} onChange={() => {}} labels={TAB_LABELS} hrefs={TAB_HREFS} />
|
||||
{isAdmin && <button type="button" className="btn btn-onink" onClick={() => setScanAdd(true)}>Scan to add</button>}
|
||||
{isAdmin && <button type="button" className="btn btn-primary" onClick={() => setNewItem(true)}>Add garment</button>}
|
||||
</PageHead>
|
||||
{tab === "onhand" && (
|
||||
<OnHand init={{ filter: asStockFilter(sp.get("filter")), q: sp.get("q") || "", group: sp.get("group") || ALL_GROUPS, supplier: sp.get("supplier") || "" }} />
|
||||
)}
|
||||
{tab === "count" && <CountTab initLocation={sp.get("location") || ""} />}
|
||||
{tab === "locations" && <Locations />}
|
||||
{newItem && <ItemDialog onClose={() => setNewItem(false)} onSaved={(id) => router.push(`/app/stock/${encodeURIComponent(id)}`)} />}
|
||||
{scanAdd && <ScanAddDialog onClose={() => setScanAdd(false)} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// useSearchParams wants a Suspense boundary above it.
|
||||
export default function StockPage() {
|
||||
return <Suspense fallback={null}><StockScreen /></Suspense>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/* The stock take moved into Stock as its Count tab. next.config.ts redirects this path too; the
|
||||
stub keeps an old bookmark working (with its query) if that redirect is ever missing. */
|
||||
export default async function StocktakeRedirect({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
const sp = await searchParams;
|
||||
const q = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(sp)) {
|
||||
if (k === "tab") continue;
|
||||
if (Array.isArray(v)) v.forEach((x) => q.append(k, x));
|
||||
else if (v !== undefined) q.append(k, v);
|
||||
}
|
||||
const rest = q.toString();
|
||||
redirect(`/app/stock?tab=count${rest ? "&" + rest : ""}`);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { switches } from "@/lib/switches";
|
||||
import AuthForm from "@/components/AuthForm";
|
||||
import { COMMUNITY } from "@/lib/edition";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Its own identity, and out of the index.
|
||||
*
|
||||
* Without this the page inherited the root layout's title and its canonical, so the sign-in screen
|
||||
* announced itself as the marketing homepage in the browser tab, in a bookmark and in a shared
|
||||
* link — and told search engines it *was* the homepage, which is the one thing a canonical must
|
||||
* never say about a different page. Nothing here is any use in a search result either: it is a door
|
||||
* for people who already have an account. */
|
||||
export const metadata: Metadata = {
|
||||
title: "Log in",
|
||||
alternates: { canonical: "/auth" },
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default async function AuthPage({ searchParams }: { searchParams: Promise<{ mode?: string; next?: string; error?: string }> }) {
|
||||
const sp = await searchParams;
|
||||
const user = await currentUser();
|
||||
// Only ever redirect within the app (never to an absolute or protocol-relative URL).
|
||||
// The phone app lives under /m; anything else off-site is refused so ?next= can't be an open redirect.
|
||||
const next = sp.next && (sp.next.startsWith("/app") || sp.next === "/m" || sp.next.startsWith("/m/")) && !sp.next.startsWith("//") ? sp.next : "/app";
|
||||
if (user) redirect(next);
|
||||
const { signupsOpen, plansLive } = await switches();
|
||||
return (
|
||||
// The whole page is the sign-in form, so it is the main landmark. There is nothing in front of
|
||||
// it to bypass, which is why there is no skip link here.
|
||||
<main>
|
||||
<Suspense>
|
||||
<AuthForm initialMode={sp.mode === "signup" && signupsOpen ? "signup" : "login"} next={next} signupsOpen={signupsOpen} plansLive={plansLive} sso={!COMMUNITY} ssoError={typeof sp.error === "string" && sp.error.startsWith("sso_") ? sp.error : ""} />
|
||||
</Suspense>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
/* The route-level error boundary. Something threw while rendering a page; the shell survives, so
|
||||
this keeps the site's own chrome and offers the two things that actually help — try again, and
|
||||
a way to tell someone.
|
||||
The error's message is deliberately not printed: it is written by the server, can carry internal
|
||||
detail, and means nothing to a linen services manager. The digest is shown because it is the one
|
||||
string that lets a report be matched to a log line. */
|
||||
import Link from "next/link";
|
||||
import { HAS_SITE } from "@/lib/links";
|
||||
import { useEffect } from "react";
|
||||
import { reportError } from "@/lib/errors";
|
||||
|
||||
export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
useEffect(() => { reportError(error, "route"); }, [error]);
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: "var(--font-body)", color: "var(--color-text)", background: "var(--color-bg)", minHeight: "100vh", display: "flex", alignItems: "center" }}>
|
||||
<div style={{ maxWidth: 640, margin: "0 auto", padding: "clamp(32px,6vw,64px) clamp(20px,5vw,40px)" }}>
|
||||
<div style={{ fontSize: 12, letterSpacing: "0.15em", textTransform: "uppercase", fontWeight: 800, color: "var(--color-accent-700)" }}>
|
||||
Something went wrong
|
||||
</div>
|
||||
<h1 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: "clamp(30px,5vw,52px)", lineHeight: 1.0, letterSpacing: "-0.03em", margin: "16px 0 0" }}>
|
||||
This page didn’t load.
|
||||
</h1>
|
||||
<div style={{ width: 60, height: 4, background: "var(--color-accent)", margin: "22px 0 0" }} />
|
||||
<p style={{ fontSize: 16.5, lineHeight: 1.7, color: "var(--color-neutral-800)", margin: "22px 0 0" }}>
|
||||
The fault is ours, not yours, and nothing you were doing has been lost — ThreadCount only
|
||||
changes a record when you commit it. Try again, and if it keeps happening, tell us and
|
||||
we’ll go and look.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap", marginTop: 28 }}>
|
||||
<button onClick={reset} className="btn btn-primary" style={{ cursor: "pointer", font: "inherit" }}>Try again</button>
|
||||
<Link href="/" className="btn">Back to the start</Link>
|
||||
{HAS_SITE && <Link href="/support" className="btn">Tell us</Link>}
|
||||
</div>
|
||||
{error.digest ? (
|
||||
<p style={{ fontSize: 12.5, color: "var(--color-neutral-700)", marginTop: 26, fontFamily: "monospace" }}>
|
||||
Reference {error.digest} — quote this and we can find it in the log.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
/* The last resort: the root layout itself failed, so this replaces <html> entirely. Nothing from
|
||||
the app is available here — not the font, not globals.css, not the site components — so every
|
||||
style is inline and the type falls back to a system stack rather than Archivo. Keeping it
|
||||
self-contained is the point: this page has to render when everything else has not. */
|
||||
import { useEffect } from "react";
|
||||
import { safeLocation } from "@/lib/errors";
|
||||
import { report } from "@/lib/glitchtip";
|
||||
|
||||
const INK = "#201e1d";
|
||||
const PAPER = "#f3f2f2";
|
||||
const ACCENT = "#ec3013";
|
||||
const SANS = "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
|
||||
|
||||
export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
/* Reported straight to the reporter, not through lib/errors' window seam.
|
||||
*
|
||||
* That seam is published by components/ErrorReporting, which is mounted inside the root layout —
|
||||
* the very layout that has just failed. When this boundary renders, that effect has by definition
|
||||
* not run, so window.__tcReporter is undefined and reportError falls through to a no-op in
|
||||
* production: the one boundary that means "everything is broken" was the only one sending
|
||||
* nothing. Importing the reporter directly costs a couple of kilobytes in the bundle that renders
|
||||
* this page and removes the dependency on a component that cannot have mounted. */
|
||||
useEffect(() => { report({ error, where: "global", url: safeLocation() }); }, [error]);
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body style={{ margin: 0, background: PAPER, color: INK, fontFamily: SANS }}>
|
||||
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center" }}>
|
||||
<div style={{ maxWidth: 620, margin: "0 auto", padding: "48px 24px" }}>
|
||||
<div style={{ display: "inline-flex", alignItems: "center", gap: 10, marginBottom: 30 }}>
|
||||
<span style={{ width: 15, height: 15, background: ACCENT, display: "inline-block" }} />
|
||||
<span style={{ fontWeight: 800, fontSize: 19, letterSpacing: "-0.01em" }}>ThreadCount</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, letterSpacing: "0.15em", textTransform: "uppercase", fontWeight: 800, color: ACCENT }}>
|
||||
Service error
|
||||
</div>
|
||||
<h1 style={{ fontWeight: 800, fontSize: "clamp(28px,5vw,46px)", lineHeight: 1.05, letterSpacing: "-0.03em", margin: "14px 0 0" }}>
|
||||
ThreadCount is having a moment.
|
||||
</h1>
|
||||
<div style={{ width: 60, height: 4, background: ACCENT, margin: "22px 0 0" }} />
|
||||
<p style={{ fontSize: 16.5, lineHeight: 1.7, margin: "22px 0 0" }}>
|
||||
Something failed before the page could be built. Your records are untouched — this is
|
||||
the website falling over, not the linen room. Try again in a moment.
|
||||
</p>
|
||||
<div style={{ marginTop: 28, display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||||
<button
|
||||
onClick={reset}
|
||||
style={{ font: "inherit", fontWeight: 800, letterSpacing: "0.02em", textTransform: "uppercase", fontSize: 13, background: ACCENT, color: "#fff", border: 0, padding: "14px 22px", cursor: "pointer" }}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
<a
|
||||
href="/"
|
||||
style={{ font: "inherit", fontWeight: 800, letterSpacing: "0.02em", textTransform: "uppercase", fontSize: 13, background: "transparent", color: INK, border: `2px solid ${INK}`, padding: "12px 20px", textDecoration: "none" }}
|
||||
>
|
||||
Back to the start
|
||||
</a>
|
||||
</div>
|
||||
{error.digest ? (
|
||||
<p style={{ fontSize: 12.5, color: "#6b6764", marginTop: 26, fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" }}>
|
||||
Reference {error.digest}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
+1433
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Archivo, IBM_Plex_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import ErrorReporting from "@/components/ErrorReporting";
|
||||
|
||||
const archivo = Archivo({
|
||||
variable: "--font-archivo",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "600", "700", "800", "900"],
|
||||
display: "swap",
|
||||
});
|
||||
// The marketing site sets product codes, size runs, times and cost centres in a monospace face,
|
||||
// the way a coordinator reads them off a slip. Only the public pages use it (globals.css scopes
|
||||
// --font-mono to .tcm-site); the app and the two phone apps never see it.
|
||||
const plexMono = IBM_Plex_Mono({
|
||||
variable: "--font-plex-mono",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "600"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const viewport = { width: "device-width", initialScale: 1, viewportFit: "cover" as const };
|
||||
|
||||
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://threadcount.tech";
|
||||
const DESC = "Uniform stock management for hospitals, aged care, clinics and community care. What's on the shelf, who took it and what it cost the ward or clinic — orders and stocktakes in one place.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(SITE),
|
||||
title: { default: "ThreadCount — Uniform management for hospitals, aged care and clinics", template: "%s — ThreadCount" },
|
||||
description: DESC,
|
||||
applicationName: "ThreadCount",
|
||||
alternates: { canonical: "/" },
|
||||
// Without these a link pasted into Slack, Teams or an email renders as a bare URL.
|
||||
openGraph: {
|
||||
type: "website", siteName: "ThreadCount", url: SITE, locale: "en_AU",
|
||||
title: "ThreadCount — Uniform management for hospitals, aged care and clinics",
|
||||
description: DESC,
|
||||
images: [{ url: "/og.png", width: 1200, height: 630, alt: "ThreadCount — every garment out the door, accounted for." }],
|
||||
},
|
||||
twitter: { card: "summary_large_image", title: "ThreadCount — Uniform management for hospitals, aged care and clinics", description: DESC, images: ["/og.png"] },
|
||||
robots: { index: true, follow: true },
|
||||
formatDetection: { telephone: false },
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className={`${archivo.variable} ${plexMono.variable}`}>
|
||||
<body>
|
||||
{children}
|
||||
{/* Global handlers for the client crashes that never reach a React boundary. */}
|
||||
<ErrorReporting />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
"use client";
|
||||
/* The product card, on the phone.
|
||||
*
|
||||
* Two halves, because they are two different jobs. The top is the garment's description, which is
|
||||
* typed once and rarely changed. The bottom is per size — par level, barcode, what's on hand —
|
||||
* which is what someone standing at a shelf actually came here to adjust.
|
||||
*
|
||||
* The size index — a position in that list — is what every issue, order line and barcode points at,
|
||||
* so the order of the list is never offered for editing: shuffling it would silently repoint years
|
||||
* of records. One size can be taken off, though, and the server does the deciding: it shifts every
|
||||
* later size down across the ten tables that store a position, in one transaction, and refuses
|
||||
* outright when the size being removed has anything recorded against it. So this screen offers the
|
||||
* removal on every size and shows whatever comes back.
|
||||
*
|
||||
* `?bind=<code>` arrives from a scan that found nothing: each size offers to take that code. */
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { bcBound, formatInZone, key as vkey, label, money, onhand, reorderAt, splitKey, type CostRec, type Item } from "@/lib/compute";
|
||||
import { printItemLabels } from "@/lib/nativeprint";
|
||||
import MScan from "@/components/MScan";
|
||||
import {
|
||||
ACCENT, GROUND, INK, ON_DARK, MBar, MBody, MButton, MEmpty, MError, MField, MONO, MPill, MRule, MSection, MTop, MTopCount, inputStyle, useToast,
|
||||
} from "@/components/m";
|
||||
|
||||
/* The two ways to get a code onto a size, side by side. Scanning stays the primary act, ink-filled:
|
||||
it is the fastest when the camera cooperates. Typing sits beside it rather than behind it — a
|
||||
label in your hand beats a camera that won't focus. */
|
||||
const codeBtn: React.CSSProperties = {
|
||||
flex: 1, minHeight: 48, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800,
|
||||
fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer",
|
||||
};
|
||||
/* Undoing rather than doing: the quieter kind of action on a size row. No border, so it reads as a
|
||||
link; 44px tall, so it is still a target you can hit with gloves on. */
|
||||
const quietAction: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", width: "100%", minHeight: 44, background: "none", border: 0,
|
||||
padding: 0, font: "inherit", fontSize: 13, fontWeight: 700, color: "var(--color-neutral-700)",
|
||||
textAlign: "left", cursor: "pointer",
|
||||
};
|
||||
|
||||
/* A freshly minted number exists in ThreadCount at once, but nothing is on the garment until it is
|
||||
printed, so the confirmation carries the print with it. */
|
||||
function MMade({ made, labels, onPrint }: { made: { size: string; code: string }[]; labels: number; onPrint: () => void }) {
|
||||
if (!made.length) return null;
|
||||
return (
|
||||
<div style={{ margin: "10px 0 0", padding: 16, background: INK, color: GROUND, fontSize: 14 }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 16 }}>
|
||||
{made.length === 1 ? `Size ${made[0].size} has a barcode` : `${made.length} sizes have a barcode`}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, color: ON_DARK, fontSize: 13, fontFamily: MONO }}>
|
||||
{made.map((m) => <div key={m.code}>{m.size} · {m.code}</div>)}
|
||||
</div>
|
||||
{labels > 0 && (
|
||||
<button onClick={onPrint}
|
||||
style={{ width: "100%", minHeight: 48, marginTop: 12, border: "2px solid " + GROUND, background: GROUND, color: INK, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer" }}>
|
||||
Print {labels} label{labels === 1 ? "" : "s"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MProductCard() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { s, isAdmin, mutate, busy } = useSnap();
|
||||
const { L, byId } = useDerived();
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
const toast = useToast();
|
||||
const bindRaw = (sp.get("bind") || "").trim().slice(0, 80);
|
||||
const bindCode = isAdmin ? bindRaw : "";
|
||||
|
||||
const it = s.catalog.find((x: Item) => x.id === id);
|
||||
|
||||
const [err, setErr] = useState("");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [f, setF] = useState(() => ({
|
||||
item: it?.item ?? "", type: it?.type ?? "", group: it?.group ?? "All",
|
||||
supplier: it?.supplier ?? "", sku: it?.sku ?? "", cost: it ? String(it.cost) : "", notes: it?.notes ?? "",
|
||||
}));
|
||||
const [newSize, setNewSize] = useState("");
|
||||
const [scanFor, setScanFor] = useState<number | null>(null);
|
||||
const [typeFor, setTypeFor] = useState<number | null>(null);
|
||||
const [typed, setTyped] = useState("");
|
||||
/* Which run is in flight, so only the button that was pressed says so. -1 is the whole garment. */
|
||||
const [genFor, setGenFor] = useState<number | null>(null);
|
||||
const [made, setMade] = useState<{ si: number; size: string; code: string }[]>([]);
|
||||
|
||||
// Binding is admin-only; an Issuer who arrives with a code goes back to the catalogue.
|
||||
useEffect(() => { if (bindRaw && !isAdmin) router.replace("/m/catalogue"); }, [bindRaw, isAdmin, router]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const set = new Set<string>(["All"]);
|
||||
for (const st of s.staff) if (st.group) set.add(st.group);
|
||||
for (const i of s.catalog) if (i.group) set.add(i.group);
|
||||
return [...set].sort();
|
||||
}, [s.staff, s.catalog]);
|
||||
|
||||
if (!it) {
|
||||
return (
|
||||
<>
|
||||
<MTop title="Garment" back />
|
||||
<MRule />
|
||||
<MBody pad><MEmpty title="No such garment" sub="It isn’t in the catalogue any more." /></MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const name = label(byId[it.id] ?? it);
|
||||
// Newest first; the snapshot already caps how many it carries.
|
||||
const costs: CostRec[] = s.costs.filter((c) => c.itemId === it.id);
|
||||
const readOnly = !isAdmin;
|
||||
// How many sizes a whole-garment run would cover, and how many labels a print run would produce —
|
||||
// one per garment on the shelf in a size that carries a code.
|
||||
const unlabelled = it.sizes.filter((_: string, si: number) => !bcBound(s, it, si)).length;
|
||||
const labels = it.sizes.reduce((n: number, _: string, si: number) =>
|
||||
n + (bcBound(s, it, si) ? Math.max(0, onhand(s, L, vkey(it.id, si))) : 0), 0);
|
||||
|
||||
/* Fill the boxes from the garment as it stands right now, not as it stood when the screen was
|
||||
* opened: the card refreshes underneath without remounting, and saving a stale form would put an
|
||||
* old price back in someone else's name. */
|
||||
function startEdit() {
|
||||
setErr("");
|
||||
setF({
|
||||
item: it!.item, type: it!.type, group: it!.group,
|
||||
supplier: it!.supplier, sku: it!.sku, cost: String(it!.cost), notes: it!.notes,
|
||||
});
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
async function saveDetails() {
|
||||
setErr("");
|
||||
if (!f.item.trim()) { setErr("The garment needs a name."); return; }
|
||||
const c = f.cost.trim() ? Number(f.cost) : 0;
|
||||
if (!(c >= 0) || Number.isNaN(c)) { setErr("Cost has to be a number."); return; }
|
||||
const r = await mutate("catalog.update", {
|
||||
id: it!.id, item: f.item.trim(), type: f.type.trim(), group: f.group,
|
||||
supplier: f.supplier.trim(), sku: f.sku.trim(), cost: c, notes: f.notes,
|
||||
});
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
async function addSize() {
|
||||
const sz = newSize.trim();
|
||||
if (!sz) return;
|
||||
setErr("");
|
||||
const r = await mutate("catalog.update", { id: it!.id, addSize: sz });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setNewSize("");
|
||||
}
|
||||
|
||||
async function setPar(si: number, next: number) {
|
||||
const r = await mutate("stock.reorder", { itemId: it!.id, si, reorder: Math.max(0, next) });
|
||||
if (!r.ok) setErr(r.error);
|
||||
}
|
||||
|
||||
/** Where a code already sits, named the way a person would name it, or "" if this snapshot has
|
||||
* never seen it. */
|
||||
function boundElsewhere(code: string): string {
|
||||
const at = s.barcodes[code];
|
||||
if (!at) return "";
|
||||
const { itemId, si } = splitKey(at);
|
||||
const other = byId[itemId];
|
||||
return other ? `${label(other)} · size ${other.sizes[si] ?? si}` : "";
|
||||
}
|
||||
|
||||
/* Binding, including the refusal that used to be a dead end: when the code already sits on another
|
||||
* garment, offer the move rather than printing the message and stopping there. A generated
|
||||
* 93XXXXXXX code is refused with or without force and is shown as it came. */
|
||||
async function bind(si: number, raw: string): Promise<boolean> {
|
||||
const code = raw.trim();
|
||||
if (!code) return false;
|
||||
setErr(""); setMade([]);
|
||||
const r = await mutate("barcode.bind", { code, itemId: it!.id, si });
|
||||
if (r.ok) { setTypeFor(null); setTyped(""); return true; }
|
||||
const at = boundElsewhere(code);
|
||||
if (!at && !/is already on/.test(r.error)) { setErr(r.error); return false; }
|
||||
const ask = at
|
||||
? `${code} is on ${at}. Take it off there and put it on ${name} · size ${it!.sizes[si]}?`
|
||||
: `${r.error}\n\nMove it onto ${name} · size ${it!.sizes[si]}?`;
|
||||
if (!confirm(ask)) { setErr(r.error); return false; }
|
||||
const moved = await mutate("barcode.bind", { code, itemId: it!.id, si, force: true });
|
||||
if (!moved.ok) { setErr(moved.error); return false; }
|
||||
setTypeFor(null); setTyped("");
|
||||
return true;
|
||||
}
|
||||
|
||||
async function bindScanned(si: number) {
|
||||
if (!(await bind(si, bindCode))) return;
|
||||
toast(`${bindCode} bound to size ${it!.sizes[si]}`);
|
||||
router.replace(`/m/catalogue/${encodeURIComponent(it!.id)}`);
|
||||
}
|
||||
|
||||
async function unbind(si: number, code: string) {
|
||||
if (!confirm(`Unbind ${code} from ${name} · size ${it!.sizes[si]}? Scanning that label won't find this size any more.`)) return;
|
||||
setErr(""); setMade([]);
|
||||
const r = await mutate("barcode.unbind", { code });
|
||||
if (!r.ok) setErr(r.error);
|
||||
}
|
||||
|
||||
/* The server decides whether a size can go; removing shifts later sizes down a place, so anything
|
||||
* held open against a position lets go of it. */
|
||||
async function removeSize(si: number) {
|
||||
if (!confirm(`Remove size ${it!.sizes[si]} from ${name}? Its par level and any barcode on it go with it.`)) return;
|
||||
setErr("");
|
||||
const r = await mutate("catalog.removeSize", { id: it!.id, si });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setScanFor(null); setTypeFor(null); setTyped(""); setMade([]);
|
||||
}
|
||||
|
||||
/* Our own barcode for stock that arrived without one. The server fills only the gaps and refuses
|
||||
* when there is nothing to do, so the offer is made and whatever comes back is shown. */
|
||||
async function generate(si?: number) {
|
||||
setErr(""); setMade([]);
|
||||
setGenFor(si ?? -1);
|
||||
const r = await mutate<{ made: { si: number; size: string; code: string }[]; count: number }>(
|
||||
"barcode.generate", si === undefined ? { itemId: it!.id } : { itemId: it!.id, si },
|
||||
);
|
||||
setGenFor(null);
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setMade(r.result.made);
|
||||
}
|
||||
|
||||
async function generateAll() {
|
||||
const ask = `Generate a barcode for ${unlabelled} size${unlabelled === 1 ? "" : "s"} on ${name}? Sizes that already carry a supplier's code keep theirs, and nothing is on a garment until the labels are printed.`;
|
||||
if (unlabelled > 0 && !confirm(ask)) return;
|
||||
await generate();
|
||||
}
|
||||
|
||||
/* A whole garment's labels: one per garment on hand, every size that carries a code. Android
|
||||
printing in the app; a printable page in a browser. */
|
||||
async function printLabels() {
|
||||
const r = await printItemLabels({ itemId: it!.id });
|
||||
if (!r.ok) setErr(r.error);
|
||||
}
|
||||
|
||||
async function archive() {
|
||||
const r = await mutate("catalog.update", { id: it!.id, archived: !it!.archived });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
if (!it!.archived) router.replace("/m/catalogue");
|
||||
}
|
||||
|
||||
const bigBtn = (tone: "line" | "accent" | "ink"): React.CSSProperties => ({
|
||||
width: "100%", minHeight: 52, border: "2px solid " + (tone === "accent" ? ACCENT : INK),
|
||||
background: tone === "accent" ? ACCENT : tone === "ink" ? INK : "transparent", color: tone === "line" ? INK : tone === "ink" ? GROUND : "#fff",
|
||||
font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title={it.archived ? "Archived" : "Garment"} right={<MTopCount>{it.sizes.length} size{it.sizes.length === 1 ? "" : "s"}</MTopCount>} back />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody pad>
|
||||
{(it.archived || bindCode) && (
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 10 }}>
|
||||
{it.archived && <MPill tone="accent">Archived</MPill>}
|
||||
{bindCode && <MPill tone="accent" mono>Binding {bindCode}</MPill>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- the description ---- */}
|
||||
{!editing ? (
|
||||
<>
|
||||
<div style={{ paddingBottom: 14, borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<h2 style={{ fontWeight: 900, fontSize: 24, lineHeight: 1.1, margin: 0 }}>{name}</h2>
|
||||
<div style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 6 }}>
|
||||
{[it.type, it.group === "All" ? "Anyone" : it.group, it.supplier, it.sku].filter(Boolean).join(" · ") || "No details yet"}
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontFamily: MONO, marginTop: 4 }}>{it.cost ? `${money(it.cost)} each` : "No unit cost"}</div>
|
||||
{it.notes && <div style={{ fontSize: 13, color: "var(--color-neutral-800)", marginTop: 8 }}>{it.notes}</div>}
|
||||
</div>
|
||||
{!readOnly && !bindCode && (
|
||||
<div style={{ padding: "14px 0", borderBottom: "2px solid " + INK }}>
|
||||
<button onClick={startEdit} style={bigBtn("line")}>Edit details</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MField label="Garment">
|
||||
<input value={f.item} onChange={(e) => setF({ ...f, item: e.target.value })} autoCapitalize="words" style={inputStyle} />
|
||||
</MField>
|
||||
<MField label="Type">
|
||||
<input value={f.type} onChange={(e) => setF({ ...f, type: e.target.value })} style={inputStyle} />
|
||||
</MField>
|
||||
<MField label="Who wears it">
|
||||
<select value={f.group} onChange={(e) => setF({ ...f, group: e.target.value })} style={{ ...inputStyle, appearance: "none" }}>
|
||||
{groups.map((g) => <option key={g} value={g}>{g === "All" ? "Anyone" : g}</option>)}
|
||||
</select>
|
||||
</MField>
|
||||
<MField label="Supplier">
|
||||
<input value={f.supplier} onChange={(e) => setF({ ...f, supplier: e.target.value })} style={inputStyle} />
|
||||
</MField>
|
||||
<MField label="Supplier code">
|
||||
<input value={f.sku} onChange={(e) => setF({ ...f, sku: e.target.value })} autoCapitalize="characters" autoCorrect="off" spellCheck={false} style={inputStyle} />
|
||||
</MField>
|
||||
<MField label="Unit cost">
|
||||
<input value={f.cost} onChange={(e) => setF({ ...f, cost: e.target.value })} inputMode="decimal" style={inputStyle} />
|
||||
</MField>
|
||||
<MField label="Notes">
|
||||
<input value={f.notes} onChange={(e) => setF({ ...f, notes: e.target.value })} placeholder="Optional" style={inputStyle} />
|
||||
</MField>
|
||||
<div style={{ padding: "14px 0", display: "grid", gap: 10, borderBottom: "2px solid " + INK }}>
|
||||
<button onClick={saveDetails} disabled={busy} style={bigBtn("accent")}>{busy ? "Saving…" : "Save details"}</button>
|
||||
<button onClick={() => { setEditing(false); setErr(""); setF({ item: it.item, type: it.type, group: it.group, supplier: it.supplier, sku: it.sku, cost: String(it.cost), notes: it.notes }); }}
|
||||
style={{ width: "100%", minHeight: 48, border: 0, background: "none", color: "var(--color-neutral-700)", font: "inherit", fontSize: 14, fontWeight: 700, cursor: "pointer" }}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ---- per size ---- */}
|
||||
<MSection label="Sizes" right="on hand · par" />
|
||||
{it.sizes.map((sz: string, si: number) => {
|
||||
const k = vkey(it.id, si);
|
||||
const oh = onhand(s, L, k);
|
||||
const par = reorderAt(s, k);
|
||||
// The bound supplier code only; bcFor()'s generated fallback is printed on no garment.
|
||||
const code = bcBound(s, it, si);
|
||||
return (
|
||||
<div key={si} style={{ padding: "14px 0", borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ fontFamily: MONO, fontWeight: 600, fontSize: 18, minWidth: 54 }}>{sz}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, color: "var(--color-neutral-700)" }}>
|
||||
<b style={{ color: oh <= par ? "var(--color-accent-700)" : INK, fontSize: 15, fontFamily: MONO }}>{oh}</b> on hand
|
||||
</div>
|
||||
<div style={{ fontSize: 12, fontFamily: MONO, color: code ? "var(--color-neutral-700)" : "var(--color-neutral-600)", marginTop: 2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{code || "No barcode bound"}
|
||||
</div>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 0 }}>
|
||||
<button onClick={() => setPar(si, par - 1)} aria-label={`Lower par for ${sz}`}
|
||||
style={{ width: 44, height: 44, border: "2px solid " + INK, background: "transparent", color: INK, fontSize: 20, fontWeight: 800, cursor: "pointer" }}>−</button>
|
||||
<div aria-label={`Par for ${sz}: ${par}`} style={{ minWidth: 44, height: 44, background: INK, color: GROUND, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: MONO, fontSize: 16 }}>{par}</div>
|
||||
<button onClick={() => setPar(si, par + 1)} aria-label={`Raise par for ${sz}`}
|
||||
style={{ width: 44, height: 44, border: "2px solid " + INK, background: "transparent", color: INK, fontSize: 20, fontWeight: 800, cursor: "pointer" }}>+</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!readOnly && bindCode && code !== bindCode && (
|
||||
<MButton small tone="ink" label={`Bind to size ${sz}`} disabled={busy} onClick={() => bindScanned(si)} />
|
||||
)}
|
||||
{!readOnly && !bindCode && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
{typeFor === si ? (
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
{/* A numeric keypad hint only: whatever arrives is taken as typed. */}
|
||||
<input value={typed} onChange={(e) => setTyped(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") bind(si, typed); }}
|
||||
placeholder="Barcode on the label" inputMode="numeric" autoFocus
|
||||
autoCapitalize="characters" autoCorrect="off" spellCheck={false}
|
||||
aria-label={`Barcode for size ${sz}`} style={inputStyle} />
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button onClick={() => bind(si, typed)} disabled={busy || !typed.trim()}
|
||||
style={{ ...codeBtn, border: "2px solid " + ACCENT, background: ACCENT, color: "#fff", opacity: typed.trim() ? 1 : 0.4 }}>
|
||||
{busy ? "Binding…" : "Bind"}
|
||||
</button>
|
||||
<button onClick={() => { setTypeFor(null); setTyped(""); }}
|
||||
style={{ ...codeBtn, border: "2px solid var(--color-neutral-400)", background: "transparent", color: "var(--color-neutral-700)" }}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button onClick={() => setScanFor(si)} aria-label={`Scan a barcode for size ${sz}`}
|
||||
style={{ ...codeBtn, border: "2px solid " + INK, background: INK, color: GROUND }}>
|
||||
{code ? "Scan a new one" : "Scan"}
|
||||
</button>
|
||||
<button onClick={() => { setErr(""); setTyped(""); setTypeFor(si); }} aria-label={`Type a barcode for size ${sz}`}
|
||||
style={{ ...codeBtn, border: "2px solid " + INK, background: "transparent", color: INK }}>
|
||||
Type it in
|
||||
</button>
|
||||
</div>
|
||||
{/* Offered only where nothing is bound: a supplier's printed code always stays. */}
|
||||
{!code && (
|
||||
<button onClick={() => generate(si)} disabled={busy} aria-label={`Generate a barcode for size ${sz}`}
|
||||
style={{ ...codeBtn, width: "100%", marginTop: 8, border: "2px solid " + INK, background: "transparent", color: INK, opacity: busy ? 0.5 : 1 }}>
|
||||
{genFor === si ? "Generating…" : "Generate a barcode"}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{code && <button onClick={() => unbind(si, code)} style={quietAction}>Unbind {code}</button>}
|
||||
<button onClick={() => removeSize(si)} style={quietAction}>Remove size {sz}</button>
|
||||
{made.length === 1 && made[0].si === si && <MMade made={made} labels={labels} onPrint={printLabels} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!readOnly && !bindCode && (
|
||||
<div style={{ padding: "14px 0", borderBottom: "2px solid " + INK, display: "flex", gap: 10 }}>
|
||||
<input value={newSize} onChange={(e) => setNewSize(e.target.value)} placeholder="Add a size" aria-label="New size"
|
||||
autoCapitalize="characters" autoCorrect="off" spellCheck={false}
|
||||
style={{ ...inputStyle, flex: 1 }} />
|
||||
<button onClick={addSize} disabled={busy || !newSize.trim()}
|
||||
style={{ minWidth: 96, border: "2px solid " + INK, background: INK, color: GROUND, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer", opacity: newSize.trim() ? 1 : 0.4 }}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!readOnly && !bindCode && (
|
||||
<>
|
||||
<MSection label="Barcodes" right={unlabelled ? `${unlabelled} without` : "All labelled"} />
|
||||
{made.length > 1 && <MMade made={made} labels={labels} onPrint={printLabels} />}
|
||||
<div style={{ padding: "14px 0", display: "grid", gap: 10, borderBottom: "2px solid " + INK }}>
|
||||
<button onClick={generateAll} disabled={busy} style={{ ...bigBtn("line"), opacity: busy ? 0.5 : 1 }}>
|
||||
{genFor === -1 ? "Generating…" : "Generate for every unlabelled size"}
|
||||
</button>
|
||||
<button onClick={printLabels} disabled={labels === 0}
|
||||
style={{ ...bigBtn("accent"), cursor: labels === 0 ? "not-allowed" : "pointer", opacity: labels === 0 ? 0.4 : 1 }}>
|
||||
{labels ? `Print ${labels} label${labels === 1 ? "" : "s"}` : "Nothing to print"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!readOnly && !bindCode && (
|
||||
<div style={{ padding: "14px 0" }}>
|
||||
<button onClick={archive} style={{ ...quietAction, color: it.archived ? "var(--color-accent-700)" : "var(--color-neutral-700)" }}>
|
||||
{it.archived ? "Put this garment back in the catalogue" : "Archive this garment"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* What we used to pay: a price rise would otherwise erase the old figure. */}
|
||||
{costs.length > 0 && (
|
||||
<>
|
||||
<MSection label="What it has cost" right="changed by" />
|
||||
{costs.map((c) => (
|
||||
<div key={c.id} style={{ display: "flex", alignItems: "baseline", gap: 12, padding: "12px 0", borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<div style={{ fontFamily: MONO, fontWeight: 600, fontSize: 15, minWidth: 78 }}>{money(c.cost)}</div>
|
||||
<div style={{ flex: 1, minWidth: 0, fontSize: 13, color: "var(--color-neutral-700)" }}>
|
||||
{c.previous === null ? "Opening price" : `${c.previous > c.cost ? "Down" : "Up"} from ${money(c.previous)}`}
|
||||
{" · "}
|
||||
{/* The facility's zone, not the device's, so server and browser render the same day. */}
|
||||
{formatInZone(c.at, s.tz)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--color-neutral-600)", whiteSpace: "nowrap" }}>{c.byName}</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</MBody>
|
||||
|
||||
{!readOnly && !it.archived && !bindCode && <MBar label="Done" glyph="check" tone="ink" onClick={() => router.push("/m/catalogue")} />}
|
||||
|
||||
{scanFor !== null && (
|
||||
<MScan
|
||||
title={`Barcode for ${it.sizes[scanFor]}`}
|
||||
onClose={() => setScanFor(null)}
|
||||
onHit={(raw) => { const si = scanFor; setScanFor(null); if (si !== null) bind(si, raw); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
/* Create a garment from the counter.
|
||||
*
|
||||
* The only genuinely required things are a name and at least one size — the server enforces exactly
|
||||
* that — so everything else can be filled in later from the product card. Sizes are entered as a
|
||||
* run rather than one at a time, because that is how they arrive. */
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import type { Item } from "@/lib/compute";
|
||||
import { INK, MBar, MBody, MEmpty, MError, MField, MRule, MTop, inputStyle } from "@/components/m";
|
||||
|
||||
const COMMON_RUNS: [string, string][] = [
|
||||
["XS S M L XL", "XS · S · M · L · XL"],
|
||||
["S M L XL 2XL", "S · M · L · XL · 2XL"],
|
||||
["8 10 12 14 16 18", "8 – 18"],
|
||||
["77R 82R 87R 92R", "77R – 92R"],
|
||||
];
|
||||
|
||||
export default function MCatalogueNew() {
|
||||
const { s, isAdmin, mutate, busy } = useSnap();
|
||||
const router = useRouter();
|
||||
|
||||
const [item, setItem] = useState("");
|
||||
const [type, setType] = useState("");
|
||||
const [group, setGroup] = useState("All");
|
||||
const [supplier, setSupplier] = useState("");
|
||||
const [sku, setSku] = useState("");
|
||||
const [cost, setCost] = useState("");
|
||||
const [sizeText, setSizeText] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
// Split on commas, slashes or whitespace so a run can be typed however it comes to hand.
|
||||
const sizes = useMemo(
|
||||
() => sizeText.split(/[,/\s]+/).map((x) => x.trim()).filter(Boolean),
|
||||
[sizeText],
|
||||
);
|
||||
const dupSize = useMemo(() => sizes.length !== new Set(sizes).size, [sizes]);
|
||||
|
||||
/* The facility's configured groups are the vocabulary; groups already in use on the register or
|
||||
* the catalogue are folded in so none that predates the configured list vanishes. */
|
||||
const groups = useMemo(() => {
|
||||
const set = new Set<string>(["All", ...s.settings.staffGroups]);
|
||||
for (const st of s.staff) if (st.group) set.add(st.group);
|
||||
for (const i of s.catalog) if (i.group) set.add(i.group);
|
||||
return [...set].sort();
|
||||
}, [s.staff, s.catalog, s.settings.staffGroups]);
|
||||
|
||||
const types = useMemo(() => [...new Set(s.catalog.map((i: Item) => i.type).filter(Boolean))].sort(), [s.catalog]);
|
||||
const suppliers = useMemo(() => s.supplierDir.map((x) => x.name).sort(), [s.supplierDir]);
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<>
|
||||
<MTop title="New garment" back />
|
||||
<MRule />
|
||||
<MBody pad><MEmpty title="Admins only" sub="An admin adds garments to the catalogue." /></MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setErr("");
|
||||
if (!item.trim()) { setErr("Give the garment a name."); return; }
|
||||
if (!sizes.length) { setErr("Add at least one size."); return; }
|
||||
if (dupSize) { setErr("The same size is listed twice."); return; }
|
||||
const c = cost.trim() ? Number(cost) : 0;
|
||||
if (!(c >= 0) || Number.isNaN(c)) { setErr("Cost has to be a number, or left blank."); return; }
|
||||
|
||||
const r = await mutate<{ id: string }>("catalog.add", {
|
||||
item: item.trim(), type: type.trim(), group, supplier: supplier.trim(),
|
||||
sku: sku.trim(), cost: c, sizes,
|
||||
});
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
// Straight to the product card: the next thing anyone does is bind a barcode or set par.
|
||||
router.replace(`/m/catalogue/${r.result.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="New garment" back />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody pad>
|
||||
<MField label="Garment">
|
||||
<input value={item} onChange={(e) => { setItem(e.target.value); setErr(""); }}
|
||||
placeholder="Scrub top" autoCapitalize="words" enterKeyHint="next" style={inputStyle} />
|
||||
</MField>
|
||||
|
||||
<MField label="Sizes">
|
||||
<input value={sizeText} onChange={(e) => { setSizeText(e.target.value); setErr(""); }}
|
||||
placeholder="S M L XL" autoCapitalize="characters" autoCorrect="off" spellCheck={false} style={inputStyle} />
|
||||
</MField>
|
||||
<div role="group" aria-label="Common size runs" style={{ paddingBottom: 12, display: "flex", flexWrap: "wrap", gap: 8 }}>
|
||||
{COMMON_RUNS.map(([run, pretty]) => (
|
||||
<button key={run} type="button" aria-pressed={sizeText === run} onClick={() => { setSizeText(run); setErr(""); }}
|
||||
style={{ minHeight: 44, border: "2px solid " + INK, background: sizeText === run ? INK : "transparent", color: sizeText === run ? "var(--color-bg)" : INK, padding: "0 12px", fontFamily: "inherit", fontSize: 13, fontWeight: 700, cursor: "pointer" }}>
|
||||
{pretty}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{sizes.length > 0 && (
|
||||
<div style={{ paddingBottom: 12, fontSize: 13, color: dupSize ? "var(--color-accent-700)" : "var(--color-neutral-700)", fontWeight: dupSize ? 700 : 400 }}>
|
||||
{dupSize ? "The same size is listed twice." : `${sizes.length} size${sizes.length === 1 ? "" : "s"}: ${sizes.join(" · ")}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MField label="Type">
|
||||
<input list="tc-types" value={type} onChange={(e) => setType(e.target.value)} placeholder="Scrub top" style={inputStyle} />
|
||||
<datalist id="tc-types">{types.map((t) => <option key={t} value={t} />)}</datalist>
|
||||
</MField>
|
||||
|
||||
<MField label="Who wears it">
|
||||
<select value={group} onChange={(e) => setGroup(e.target.value)} style={{ ...inputStyle, appearance: "none" }}>
|
||||
{groups.map((g) => <option key={g} value={g}>{g === "All" ? "Anyone" : g}</option>)}
|
||||
</select>
|
||||
</MField>
|
||||
|
||||
<MField label="Supplier">
|
||||
<input list="tc-suppliers" value={supplier} onChange={(e) => setSupplier(e.target.value)} placeholder="Optional" style={inputStyle} />
|
||||
<datalist id="tc-suppliers">{suppliers.map((x) => <option key={x} value={x} />)}</datalist>
|
||||
</MField>
|
||||
|
||||
<MField label="Supplier code">
|
||||
<input value={sku} onChange={(e) => setSku(e.target.value)} placeholder="Optional" autoCapitalize="characters" autoCorrect="off" spellCheck={false} style={inputStyle} />
|
||||
</MField>
|
||||
|
||||
<MField label="Unit cost">
|
||||
<input value={cost} onChange={(e) => { setCost(e.target.value); setErr(""); }}
|
||||
inputMode="decimal" placeholder="0.00" style={inputStyle} />
|
||||
</MField>
|
||||
</MBody>
|
||||
<MBar label={busy ? "Saving…" : "Create garment"} onClick={save} disabled={busy} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
/* The whole catalogue on the phone, not just what's on the shelf: Stock lists only sizes with
|
||||
* history, so a garment created five minutes ago lives here. `?bind=<code>` arrives from a scan that
|
||||
* found nothing; picking a garment carries the code to its product card (admins only). */
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { label, money, type Item } from "@/lib/compute";
|
||||
import { MBody, MButton, MEmpty, MPill, MRow, MRule, MSearch, MSection, MTop, MTopCount } from "@/components/m";
|
||||
|
||||
const PAGE = 300;
|
||||
|
||||
export default function MCatalogue() {
|
||||
const { s, isAdmin } = useSnap();
|
||||
const { byId } = useDerived();
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
const bindRaw = (sp.get("bind") || "").trim().slice(0, 80);
|
||||
const bind = isAdmin ? bindRaw : "";
|
||||
const [q, setQ] = useState("");
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [shown, setShown] = useState(PAGE);
|
||||
|
||||
// Binding is an admin job; an Issuer arriving with a code just gets the catalogue.
|
||||
useEffect(() => { if (bindRaw && !isAdmin) router.replace("/m/catalogue"); }, [bindRaw, isAdmin, router]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
return s.catalog
|
||||
.filter((i: Item) => (showArchived ? i.archived : !i.archived))
|
||||
.map((i: Item) => ({
|
||||
...i,
|
||||
name: label(byId[i.id] ?? i),
|
||||
sub: [i.sizes.length ? `${i.sizes.length} size${i.sizes.length === 1 ? "" : "s"}` : "No sizes", i.supplier, i.sku].filter(Boolean).join(" · "),
|
||||
}))
|
||||
.filter((i) => !needle || `${i.name} ${i.sku} ${i.supplier} ${i.type} ${i.group}`.toLowerCase().includes(needle))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [s.catalog, byId, q, showArchived]);
|
||||
|
||||
const archivedCount = s.catalog.filter((i: Item) => i.archived).length;
|
||||
const cardHref = (id: string) => `/m/catalogue/${encodeURIComponent(id)}${bind ? `?bind=${encodeURIComponent(bind)}` : ""}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Catalogue" back right={<MTopCount>{rows.length}</MTopCount>} />
|
||||
<MRule />
|
||||
<MBody pad>
|
||||
<MSearch value={q} onChange={(v) => { setQ(v); setShown(PAGE); }} placeholder="Garment, code, supplier" label="Filter the catalogue" />
|
||||
{bind && <div style={{ marginTop: 10 }}><MPill tone="accent" mono>Binding {bind}</MPill></div>}
|
||||
|
||||
{isAdmin && !bind && <MButton icon="plus" label="New garment" href="/m/catalogue/new" />}
|
||||
|
||||
<MSection label={showArchived ? "Archived" : "Garments"} right={rows.length} />
|
||||
{rows.length === 0
|
||||
? <MEmpty title={q ? "Nothing matches" : showArchived ? "Nothing archived" : "No garments yet"}
|
||||
sub={q ? "Try a shorter search." : undefined} />
|
||||
: rows.slice(0, shown).map((i) => (
|
||||
<MRow key={i.id} href={isAdmin ? cardHref(i.id) : undefined} chev={isAdmin}
|
||||
mark={i.archived ? "mute" : "ink"} title={i.name} sub={i.sub}
|
||||
right={i.cost ? money(i.cost) : undefined} />
|
||||
))}
|
||||
{rows.length > shown && (
|
||||
<>
|
||||
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 10 }}>Showing {shown} of {rows.length}</div>
|
||||
<MButton small label="Show more" onClick={() => setShown((n) => n + PAGE)} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{archivedCount > 0 && (
|
||||
<MButton small label={showArchived ? "Current catalogue" : `Show ${archivedCount} archived`}
|
||||
onClick={() => { setShowArchived(!showArchived); setShown(PAGE); }} />
|
||||
)}
|
||||
</MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
/* Counting: scan a garment and its own line goes up by one and becomes the line being counted.
|
||||
Expected figures stay on screen (a sighted count, as the phone has always been). The tally lives
|
||||
in localStorage (lib/opencount.ts), so backgrounding the app mid-shelf loses nothing.
|
||||
|
||||
Hands-free keeps the camera reading continuously. Inside the Android shell MLKit runs with its
|
||||
preview behind this opaque screen, so the panel and the list stay in view and each garment is a
|
||||
beep, a buzz and the figures moving (handsfree.png). In a browser the camera needs a visible,
|
||||
playing <video>, so hands-free opens the live camera overlay with the same panel and switch. */
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { UNPLACED } from "@/lib/compute";
|
||||
import MScan from "@/components/MScan";
|
||||
import { isNative, startLive } from "@/lib/nativescan";
|
||||
import { scanReject, scanTick } from "@/lib/feedback";
|
||||
import { track } from "@/lib/analytics";
|
||||
import { useKeepAwake } from "@/lib/wakelock";
|
||||
import { SHELF_LABEL_PREFIX } from "@/lib/scanroute";
|
||||
import { readCount, writeCount } from "@/lib/opencount";
|
||||
import { GROUND, INK, MAction, MBody, MButton, MEmpty, MError, MRow, MRule, MSection, MSplit, MTop, MTopAction } from "@/components/m";
|
||||
import { CountFigures, CountPanel, HandsFree, TypeCount } from "@/components/m/count/CountPanel";
|
||||
import { lineTitle, useCountLines } from "@/components/m/count/lines";
|
||||
|
||||
const DEBOUNCE_MS = 900;
|
||||
|
||||
export default function MCounting() {
|
||||
const { s } = useSnap();
|
||||
const router = useRouter();
|
||||
const locationId = String(useParams().id || "");
|
||||
const { lines, locName, locs } = useCountLines(locationId);
|
||||
|
||||
const [counted, setCounted] = useState<Record<string, number>>({});
|
||||
// Held by variant key, never by position: the list is rebuilt on every live refresh.
|
||||
const [activeKey, setActiveKey] = useState("");
|
||||
const [single, setSingle] = useState(false);
|
||||
const [hands, setHands] = useState(false);
|
||||
// "inline": MLKit behind this screen. "overlay": the live camera overlay (browser, or a shell without MLKit).
|
||||
const [handsMode, setHandsMode] = useState<"inline" | "overlay">("inline");
|
||||
const [typing, setTyping] = useState(false);
|
||||
const [log, setLog] = useState<string[]>([]);
|
||||
const [err, setErr] = useState("");
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
// Restore this person's open count of this shelf, once per shelf. The key includes the user: the
|
||||
// phone is shared. It deliberately does not re-run on `lines`, so a size moved off the shelf from
|
||||
// the desktop mid-count keeps what was already counted against it.
|
||||
const me = s.session.userId;
|
||||
useEffect(() => {
|
||||
setCounted(readCount(me, locationId)?.n ?? {});
|
||||
// Recount from Check the gaps lands here with ?line=<key>.
|
||||
try {
|
||||
const line = new URLSearchParams(window.location.search).get("line");
|
||||
if (line) setActiveKey(line);
|
||||
} catch { /* no query to read */ }
|
||||
setLoaded(true);
|
||||
}, [me, locationId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded) return;
|
||||
writeCount(me, locationId, counted);
|
||||
}, [counted, me, locationId, loaded]);
|
||||
|
||||
useKeepAwake(true);
|
||||
|
||||
const total = lines.reduce((t, l) => t + (counted[l.key] ?? 0), 0);
|
||||
const expectedAll = lines.reduce((t, l) => t + l.expected, 0);
|
||||
const cur = lines.find((l) => l.key === activeKey) || lines[0];
|
||||
const curName = cur ? `${lineTitle(cur)} · ${cur.size}` : "";
|
||||
const curN = cur ? counted[cur.key] ?? 0 : 0;
|
||||
|
||||
const bump = useCallback((k: string, by: number) => {
|
||||
setCounted((c) => ({ ...c, [k]: Math.max(0, (c[k] ?? 0) + by) }));
|
||||
}, []);
|
||||
|
||||
/** A scanned code lands on its own line, whichever line was active: the barcode is the truth. */
|
||||
const onCode = useCallback((raw: string) => {
|
||||
const code = raw.trim();
|
||||
if (!code) return;
|
||||
const hit = s.barcodes[code];
|
||||
const line = hit ? lines.find((l) => l.key === hit) : lines.find((l) => l.code === code);
|
||||
if (!line) {
|
||||
scanReject();
|
||||
const known = Object.prototype.hasOwnProperty.call(s.barcodes, code);
|
||||
track("scan_miss", { kind: known ? "wrong_shelf" : "unknown" });
|
||||
const shelf = code.startsWith(SHELF_LABEL_PREFIX) ? locs[code.slice(SHELF_LABEL_PREFIX.length)] : undefined;
|
||||
const placedAt = hit ? locs[s.placed[hit] || ""]?.name : "";
|
||||
setErr(shelf ? `That’s the label for ${shelf.name}`
|
||||
: !known ? `${code} isn’t a garment ThreadCount knows`
|
||||
: placedAt ? `${code} is on ${placedAt}, not this shelf`
|
||||
: locationId === UNPLACED ? `${code} isn’t in this count` : `${code} isn’t on a shelf yet`);
|
||||
setLog((g) => [`${code}: not on this shelf`, ...g].slice(0, 8));
|
||||
return;
|
||||
}
|
||||
setActiveKey(line.key);
|
||||
setTyping(false);
|
||||
bump(line.key, 1);
|
||||
setErr("");
|
||||
setLog((g) => [`${lineTitle(line)} ${line.size}`, ...g].slice(0, 8));
|
||||
}, [s.barcodes, s.placed, lines, locs, locationId, bump]);
|
||||
|
||||
const onCodeRef = useRef(onCode); onCodeRef.current = onCode;
|
||||
|
||||
// Hands-free inside the Android shell: MLKit reads continuously behind this screen. This screen's
|
||||
// root carries `tcx-scanui` while it runs, so globals.css leaves it visible and opaque.
|
||||
const [inlineLive, setInlineLive] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!hands || handsMode !== "inline") return;
|
||||
if (!isNative()) { setHandsMode("overlay"); return; }
|
||||
let session: { stop: () => Promise<void> } | null = null;
|
||||
let cancelled = false;
|
||||
let lastRaw = "", lastT = 0;
|
||||
setInlineLive(true);
|
||||
(async () => {
|
||||
const r = await startLive((raw) => {
|
||||
if (raw === lastRaw && Date.now() - lastT < DEBOUNCE_MS) return;
|
||||
lastRaw = raw; lastT = Date.now();
|
||||
scanTick();
|
||||
onCodeRef.current(raw);
|
||||
});
|
||||
if (cancelled) { await r.stop(); return; }
|
||||
if (r.error === "native-unavailable") { setInlineLive(false); setHandsMode("overlay"); return; }
|
||||
if (r.error) { setInlineLive(false); setHands(false); setErr(r.error); return; }
|
||||
session = r;
|
||||
})();
|
||||
track("scan_opened", { mode: "live", engine: "mlkit-inline" });
|
||||
return () => { cancelled = true; setInlineLive(false); if (session) session.stop(); };
|
||||
}, [hands, handsMode]);
|
||||
|
||||
const toggleHands = () => {
|
||||
setTyping(false);
|
||||
setSingle(false);
|
||||
setHands((h) => !h);
|
||||
};
|
||||
|
||||
if (loaded && !lines.length) {
|
||||
return (
|
||||
<>
|
||||
<MTop title={locName} back />
|
||||
<MRule />
|
||||
<MBody pad><MEmpty title="Nothing on this shelf" sub="Place sizes on it in the portal" /></MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const panelInner = cur ? <CountFigures name={curName} counted={curN} expected={cur.expected} /> : null;
|
||||
|
||||
return (
|
||||
// display: contents keeps every piece a direct flex child of .tcx-app as before; the class is what
|
||||
// spares this screen while MLKit's preview runs behind the WebView.
|
||||
<div className={inlineLive ? "tcx-scanui" : undefined} style={{ display: "contents" }}>
|
||||
<MTop title={locName} back right={<MTopAction label="Finish" onClick={() => { setHands(false); router.push(`/m/count/${locationId}/variance`); }} />} />
|
||||
<MRule n={total} of={expectedAll} />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
|
||||
<MBody pad>
|
||||
{cur && (
|
||||
<CountPanel>
|
||||
{panelInner}
|
||||
<HandsFree on={hands} onToggle={toggleHands} />
|
||||
{typing && (
|
||||
<TypeCount key={cur.key} name={curName} value={curN}
|
||||
onSet={(n) => { setCounted((c) => ({ ...c, [cur.key]: n })); setTyping(false); }} />
|
||||
)}
|
||||
</CountPanel>
|
||||
)}
|
||||
|
||||
<MSection label="Lines" right={`${total} of ${expectedAll}`} />
|
||||
{lines.map((l) => {
|
||||
const n = counted[l.key] ?? 0;
|
||||
const on = !!cur && l.key === cur.key;
|
||||
return (
|
||||
<MRow key={l.key} active={on} onClick={() => { setActiveKey(l.key); setTyping(false); }}
|
||||
mark={n === l.expected ? "ok" : n > 0 ? "ink" : "mute"}
|
||||
title={`${lineTitle(l)} ${l.size}`}
|
||||
sub={l.where || undefined}
|
||||
right={`${n}/${l.expected}`} />
|
||||
);
|
||||
})}
|
||||
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<MButton label="Type a count" onClick={() => { setHands(false); setTyping((t) => !t); }} disabled={!cur} />
|
||||
</div>
|
||||
</MBody>
|
||||
|
||||
<MSplit>
|
||||
<MAction label="Undo" flex={1} tone="ink" onClick={() => cur && bump(cur.key, -1)} disabled={!cur || curN <= 0} />
|
||||
<MAction label="Scan" flex={2} glyph="scan" glyphAt="right" onClick={() => { setHands(false); setTyping(false); setSingle(true); }} />
|
||||
</MSplit>
|
||||
|
||||
{single && (
|
||||
<MScan title="Scan a garment" onHit={(raw) => { onCode(raw); setSingle(false); }} onClose={() => setSingle(false)} />
|
||||
)}
|
||||
|
||||
{hands && handsMode === "overlay" && (
|
||||
<MScan
|
||||
title="Hands-free"
|
||||
live
|
||||
running
|
||||
log={log}
|
||||
debounceMs={DEBOUNCE_MS}
|
||||
onHit={onCode}
|
||||
onClose={() => setHands(false)}
|
||||
figure={cur ? <div style={{ background: INK, color: GROUND, padding: "12px 16px 0" }}>{panelInner}</div> : undefined}
|
||||
control={<div style={{ background: INK, padding: "0 16px calc(12px + env(safe-area-inset-bottom, 0px))" }}><HandsFree on onToggle={() => setHands(false)} /></div>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
/* Check the gaps: the lines that don't match, each with a reason, then the commit.
|
||||
A gap at or over the facility's threshold (settings.varianceReason, enforced again by
|
||||
stocktake.apply) must carry a reason before the bar will commit. The reason chosen is stored
|
||||
on the stocktake line. */
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { UNPLACED } from "@/lib/compute";
|
||||
import { clearCount, readCount, writeCount } from "@/lib/opencount";
|
||||
import { INK, MBar, MBody, MEmpty, MError, MKick, MLine, MPill, MReasonChips, MRow, MRule, MSection, MTop } from "@/components/m";
|
||||
import { OVER_REASONS, SHORT_REASONS, lineTitle, signed, useCountLines } from "@/components/m/count/lines";
|
||||
|
||||
const chip: React.CSSProperties = {
|
||||
minHeight: 44, minWidth: 48, padding: "0 12px", border: "2px solid " + INK, background: "transparent", color: INK,
|
||||
fontFamily: "inherit", fontSize: 14, fontWeight: 700, cursor: "pointer", borderRadius: 0, flex: "none",
|
||||
};
|
||||
|
||||
export default function MVariance() {
|
||||
const { s, mutate, busy } = useSnap();
|
||||
const router = useRouter();
|
||||
const locationId = String(useParams().id || "");
|
||||
const { lines, locName } = useCountLines(locationId);
|
||||
|
||||
const [counted, setCounted] = useState<Record<string, number> | null>(null);
|
||||
const [reason, setReason] = useState<Record<string, string>>({});
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const me = s.session.userId;
|
||||
useEffect(() => {
|
||||
setCounted(readCount(me, locationId)?.n ?? {});
|
||||
}, [me, locationId]);
|
||||
|
||||
const gate = Math.max(1, s.settings.varianceReason);
|
||||
const gaps = useMemo(() => (counted ? lines.filter((l) => (counted[l.key] ?? 0) !== l.expected) : []), [counted, lines]);
|
||||
const matches = useMemo(() => (counted ? lines.filter((l) => (counted[l.key] ?? 0) === l.expected) : []), [counted, lines]);
|
||||
const missing = gaps.filter((l) => Math.abs((counted?.[l.key] ?? 0) - l.expected) >= gate && !reason[l.key]);
|
||||
const ready = missing.length === 0;
|
||||
|
||||
const recount = useCallback((key: string) => {
|
||||
if (!counted) return;
|
||||
writeCount(me, locationId, { ...counted, [key]: 0 });
|
||||
setReason((r) => { const n = { ...r }; delete n[key]; return n; });
|
||||
router.push(`/m/count/${locationId}?line=${encodeURIComponent(key)}`);
|
||||
}, [counted, me, locationId, router]);
|
||||
|
||||
const commit = useCallback(async () => {
|
||||
if (!counted || !ready || busy) return;
|
||||
const payload = lines.map((l) => {
|
||||
const n = counted[l.key] ?? 0;
|
||||
return { itemId: l.itemId, si: l.si, counted: n, reason: n !== l.expected ? reason[l.key] || "" : "" };
|
||||
});
|
||||
const r = await mutate("stocktake.apply", { lines: payload, mode: "shelf", locationId: locationId === UNPLACED ? "" : locationId });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
clearCount(me, locationId);
|
||||
router.replace(`/m?flash=counted&loc=${encodeURIComponent(locName)}&gaps=${gaps.length}`);
|
||||
}, [counted, ready, busy, lines, reason, mutate, locationId, me, router, locName, gaps.length]);
|
||||
|
||||
if (!counted) return (<><MTop title="Check the gaps" back /><MRule /><MBody pad /></>);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Check the gaps" back />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody pad>
|
||||
<MKick>{locName}</MKick>
|
||||
<h2 style={{ fontSize: 26, fontWeight: 900, margin: "2px 0 0", lineHeight: 1.15 }}>
|
||||
{gaps.length === 0 ? "Everything matches" : `${gaps.length} gap${gaps.length === 1 ? "" : "s"}`}
|
||||
</h2>
|
||||
|
||||
{lines.length === 0 && <MEmpty title="Nothing on this shelf" />}
|
||||
|
||||
{gaps.map((l) => {
|
||||
const n = counted[l.key] ?? 0;
|
||||
const d = n - l.expected;
|
||||
const name = `${lineTitle(l)} ${l.size}`;
|
||||
return (
|
||||
<div key={l.key} style={{ marginTop: 10 }}>
|
||||
<MLine title={name} flag={`${n} counted, ${l.expected} expected`}
|
||||
right={
|
||||
<>
|
||||
<MPill tone="accent" mono>{signed(d)}</MPill>
|
||||
<button type="button" style={chip} onClick={() => recount(l.key)} aria-label={`Recount ${name}`}>Recount</button>
|
||||
</>
|
||||
}>
|
||||
<MReasonChips reasons={d > 0 ? OVER_REASONS : SHORT_REASONS} value={reason[l.key] || null} label={`Reason for ${name}`}
|
||||
onPick={(r) => setReason((x) => { const o = { ...x }; if (r) o[l.key] = r; else delete o[l.key]; return o; })} />
|
||||
</MLine>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{matches.length > 0 && (
|
||||
<div style={{ marginTop: 22 }}>
|
||||
<MSection label="Match" right={matches.length} />
|
||||
{matches.map((l) => (
|
||||
<MRow key={l.key} dense mark="ok" title={`${lineTitle(l)} ${l.size}`} right={counted[l.key] ?? 0} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</MBody>
|
||||
<MBar label={busy ? "Committing…" : "Commit count"}
|
||||
small={ready ? `${lines.length} line${lines.length === 1 ? "" : "s"}` : "reason each gap"}
|
||||
onClick={commit} disabled={!ready || busy || lines.length === 0}
|
||||
offReason={!ready ? "Pick a reason for each gap" : undefined} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "@/components/m/stock/CountList";
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
/* The Done screen as its own history entry. A finished hand-over or delivery router.replace()s its
|
||||
form here (components/SignFlow showDone), so Back skips the spent form, a router refresh re-renders
|
||||
this screen rather than the form's parent, and a reload still shows what was just recorded. */
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DoneScreen, readDone, type DoneProps } from "@/components/SignFlow";
|
||||
import { MBody, MKick, MRule, MTop } from "@/components/m";
|
||||
|
||||
export default function DonePage() {
|
||||
const router = useRouter();
|
||||
const [done, setDone] = useState<DoneProps | null>(null);
|
||||
useEffect(() => {
|
||||
const d = readDone();
|
||||
if (d) setDone(d);
|
||||
else router.replace("/m");
|
||||
}, [router]);
|
||||
|
||||
if (done) return <DoneScreen {...done} />;
|
||||
return (
|
||||
<>
|
||||
<MTop title="Done" />
|
||||
<MRule />
|
||||
<MBody pad><MKick>Loading</MKick></MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/* The person's record opens on its Issue segment. */
|
||||
export default async function IssueTo({ params }: { params: Promise<{ staffId: string }> }) {
|
||||
const { staffId } = await params;
|
||||
redirect(`/m/person/${encodeURIComponent(staffId)}`);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/* Issuing starts from a person now: the People tab, or a scanned badge. */
|
||||
export default function IssuePicker() {
|
||||
redirect("/m/people");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/* Reprinting a label is Print a label on the stock line's own page. */
|
||||
export default function Label() {
|
||||
redirect("/m/stock?seg=all");
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { buildSnapshot } from "@/lib/snapshot";
|
||||
import { SnapshotProvider } from "@/lib/client";
|
||||
import { MToastProvider } from "@/components/m";
|
||||
import { MBasketProvider } from "@/components/MBasket";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Everything that needs a signed-in coordinator. Sending them to /m/login rather than /auth keeps
|
||||
them in the app's own world: /auth is the website's two-pane sign-in, which is a jarring thing
|
||||
to meet on a phone halfway through opening an app.
|
||||
|
||||
The toast and the in-progress basket sit inside the snapshot, and neither renders a wrapper
|
||||
element, so every screen stays a direct child of .tcx-app (the native scan transparency relies
|
||||
on that). */
|
||||
export default async function MobileAppLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = await currentUser();
|
||||
if (!user) redirect("/m/login");
|
||||
const snap = await buildSnapshot(user);
|
||||
return (
|
||||
<SnapshotProvider snap={snap}>
|
||||
<MToastProvider>
|
||||
<MBasketProvider>{children}</MBasketProvider>
|
||||
</MToastProvider>
|
||||
</SnapshotProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
/* One stock line: a garment in one size. On hand against par, where it lives, what is coming, and the
|
||||
three things done about it: print a label, count its shelf, put it on the draft order. */
|
||||
import { useCallback, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { bcBound, key, label, locMap, locTrail, money, onOrderText, onhand, reorderAt } from "@/lib/compute";
|
||||
import { INK, MBody, MButton, MEmpty, MKick, MONO, MPill, MRow, MRule, MTop, useToast } from "@/components/m";
|
||||
import PrintSheet from "@/components/m/stock/PrintSheet";
|
||||
import { heldByStaff, lastCountedText } from "@/components/m/stock/stockdata";
|
||||
|
||||
const dt: React.CSSProperties = { margin: 0, padding: "11px 14px 11px 0", borderBottom: "1px solid var(--color-divider)", fontSize: 12, fontWeight: 800, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--color-neutral-600)" };
|
||||
const dd: React.CSSProperties = { margin: 0, padding: "11px 0", borderBottom: "1px solid var(--color-divider)", textAlign: "right", fontWeight: 700 };
|
||||
|
||||
export default function MLinePage() {
|
||||
const params = useParams<{ itemId: string; si: string }>();
|
||||
const { s, isAdmin, mutate, busy } = useSnap();
|
||||
const { L } = useDerived();
|
||||
const toast = useToast();
|
||||
const [printing, setPrinting] = useState(false);
|
||||
// Stable, because MSheet re-runs its focus handling whenever onClose changes.
|
||||
const closePrint = useCallback(() => setPrinting(false), []);
|
||||
|
||||
const itemId = decodeURIComponent(String(params.itemId || ""));
|
||||
const si = /^\d+$/.test(String(params.si || "")) ? Number(params.si) : -1;
|
||||
const it = s.catalog.find((x) => x.id === itemId);
|
||||
|
||||
if (!it || it.archived || si < 0 || si >= it.sizes.length) {
|
||||
return (
|
||||
<>
|
||||
<MTop title="Stock line" back />
|
||||
<MRule />
|
||||
<MBody pad><MEmpty title="No such line" sub="It may have been archived." /></MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const k = key(it.id, si);
|
||||
const size = String(it.sizes[si]);
|
||||
const oh = onhand(s, L, k), par = reorderAt(s, k);
|
||||
const code = bcBound(s, it, si);
|
||||
const locs = locMap(s);
|
||||
const placedId = s.placed[k];
|
||||
const placed = placedId ? locs[placedId] : undefined;
|
||||
const shelf = placed ? (locTrail(locs, placed.id, 0) || placed.name) : "";
|
||||
const onOrder = onOrderText(s, it.id, si);
|
||||
const short = Math.max(0, par - oh);
|
||||
|
||||
const addToDraft = async () => {
|
||||
const r = await mutate<{ added: number; qty: number }>("stock.orderLine", { itemId: it.id, si });
|
||||
if (!r.ok) { toast(r.error); return; }
|
||||
toast(r.result.added > 0 ? `Added ${r.result.added} to the draft order` : "Already on the draft order");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title={`${it.item} ${size}`} back />
|
||||
<MRule />
|
||||
<MBody pad>
|
||||
<MKick mono>{code || (it.sku ? `SKU ${it.sku}` : "No barcode")}</MKick>
|
||||
<h2 style={{ fontSize: 24, fontWeight: 900, margin: "2px 0 0", lineHeight: 1.1 }}>{label(it)} · {size}</h2>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between", gap: 12, marginTop: 14, borderBottom: "2px solid " + INK, paddingBottom: 12 }}>
|
||||
<div style={{ fontSize: 64, fontWeight: 900, lineHeight: 0.95, letterSpacing: "-0.03em", fontVariantNumeric: "tabular-nums" }}>
|
||||
{oh}<small style={{ fontSize: 22, fontWeight: 700, color: "var(--color-neutral-600)", letterSpacing: 0 }}> / {par} par</small>
|
||||
</div>
|
||||
{short > 0
|
||||
? <MPill tone={oh <= 0 ? "accent" : "ink"}>{short} short</MPill>
|
||||
: <MPill tone="ok">At par</MPill>}
|
||||
</div>
|
||||
|
||||
<dl style={{ display: "grid", gridTemplateColumns: "auto 1fr", margin: "8px 0 0" }}>
|
||||
<dt style={dt}>Shelf</dt><dd style={dd}>{shelf || "Not on a shelf"}</dd>
|
||||
<dt style={dt}>On order</dt><dd style={dd}>{onOrder || "None"}</dd>
|
||||
<dt style={dt}>Last counted</dt><dd style={dd}>{lastCountedText(s, it.id, si)}</dd>
|
||||
<dt style={dt}>Held by staff</dt><dd style={{ ...dd, fontFamily: MONO }}>{heldByStaff(s, it.id, si)}</dd>
|
||||
<dt style={dt}>Unit cost</dt><dd style={{ ...dd, fontFamily: MONO }}>{money(it.cost)}</dd>
|
||||
</dl>
|
||||
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<MButton tone="ink" icon="print" label="Print a label" disabled={!code} onClick={() => setPrinting(true)} />
|
||||
{!code && <div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 6 }}>No barcode bound</div>}
|
||||
{!code && isAdmin && <MButton label="Bind a barcode" href={`/m/catalogue/${encodeURIComponent(it.id)}`} />}
|
||||
{placed && <MButton label={`Count ${shelf}`} href={`/m/count/${encodeURIComponent(placed.id)}?line=${encodeURIComponent(k)}`} />}
|
||||
<MButton label={busy ? "Adding…" : "Add to the draft order"} onClick={addToDraft} disabled={busy} />
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<MRow chev href={`/m/catalogue/${encodeURIComponent(it.id)}`} title="Product card" />
|
||||
</div>
|
||||
)}
|
||||
</MBody>
|
||||
|
||||
{code && <PrintSheet open={printing} onClose={closePrint} code={code} title={`${label(it)} ${size}`} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/* What a tap looks like before the server answers, for the counter app.
|
||||
*
|
||||
* The twin of app/my/(app)/loading.tsx, and here for the same reason: every screen under /m is
|
||||
* rendered from its own server query, App Router keeps the previous screen fully painted until that
|
||||
* query comes back, and on linen-room wifi that is seconds in which nothing acknowledges the tap.
|
||||
* People tap again — and on this app the second tap can land on a different row.
|
||||
*
|
||||
* It draws the app's own chrome (the 56px ink bar and the 4px accent rule, the shape MTop and MRule
|
||||
* make) so the change reads as "loading" rather than "gone", and deliberately not the tab bar: the
|
||||
* nav belongs to the four screens that draw it, and painting one here would flash it into existence
|
||||
* on the way to a detail screen that has none. The bar carries no screen title for the same reason
|
||||
* — this one fallback covers every route in the group, so any title would be wrong somewhere.
|
||||
*/
|
||||
const INK = "#201e1d";
|
||||
const GROUND = "#f3f2f2";
|
||||
|
||||
/** A grey block standing in for a line of text. Sized in the same 2px system as everything else. */
|
||||
function Bar({ w, h = 16 }: { w: string; h?: number }) {
|
||||
return <div style={{ width: w, height: h, background: "var(--color-neutral-200)" }} />;
|
||||
}
|
||||
|
||||
export default function CounterLoading() {
|
||||
return (
|
||||
<>
|
||||
<header className="tcx-topbar" style={{
|
||||
height: 56, flex: "0 0 56px", background: INK, color: GROUND, display: "flex", alignItems: "center",
|
||||
paddingLeft: 16, paddingRight: 16,
|
||||
backgroundImage: "linear-gradient(to bottom, rgba(243,242,242,0.16) 0 1px, transparent 1px)",
|
||||
backgroundPosition: "0 env(safe-area-inset-top, 0px)", backgroundRepeat: "no-repeat", backgroundSize: "100% 1px",
|
||||
}}>
|
||||
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, letterSpacing: "0.06em", textTransform: "uppercase" }}>
|
||||
One moment
|
||||
</span>
|
||||
</header>
|
||||
<div style={{ height: 4, flex: "0 0 4px", background: "var(--color-accent)" }} />
|
||||
<div style={{ flex: 1, overflow: "hidden", background: GROUND }} aria-busy="true">
|
||||
{/* Announced once, quietly. The blocks below are decoration and say nothing. */}
|
||||
<div role="status" style={{ padding: "20px 16px 0", fontSize: 13, color: "var(--color-neutral-600)" }}>Loading…</div>
|
||||
<div style={{ padding: "16px 16px 0", display: "grid", gap: 10 }} aria-hidden="true">
|
||||
<Bar w="60%" h={22} />
|
||||
<Bar w="40%" />
|
||||
</div>
|
||||
<div style={{ marginTop: 24, display: "grid", gap: 2 }} aria-hidden="true">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} style={{ background: "#fff", padding: "18px 16px", display: "grid", gap: 8 }}>
|
||||
<Bar w="55%" h={18} />
|
||||
<Bar w="35%" h={12} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/* More's rows moved: Settings is the gear on Today, the rest live in Stock and Work. */
|
||||
export default function More() {
|
||||
redirect("/m");
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
/* Today: what is waiting, what this counter has done today, and what just happened. */
|
||||
import { Suspense } from "react";
|
||||
import { MBody, MDay, MEmpty, MKick, MRow, MRule, MSection, MTabs, MTodo, MTop, MTopAction } from "@/components/m";
|
||||
import TodayBanner from "@/components/m/today/TodayBanner";
|
||||
import { useToday } from "@/components/m/today/useToday";
|
||||
|
||||
export default function MToday() {
|
||||
const t = useToday();
|
||||
return (
|
||||
<>
|
||||
<MTop title="Today" right={<MTopAction icon="gear" label="Settings" ariaLabel="Settings" href="/m/settings" />} />
|
||||
<MRule />
|
||||
<MBody pad>
|
||||
<Suspense fallback={null}><TodayBanner /></Suspense>
|
||||
<MKick>{t.kicker}</MKick>
|
||||
|
||||
<MSection label="To do" />
|
||||
{t.todo.length === 0
|
||||
? <MEmpty title="Nothing waiting" />
|
||||
: t.todo.map((r) => <MTodo key={r.key} n={r.n} accent={r.accent} title={r.title} sub={r.sub} href={r.href} />)}
|
||||
|
||||
<MSection label="Your day" />
|
||||
<MDay figs={[{ n: t.day.issued, label: "Issued" }, { n: t.day.back, label: "Handed back" }, { n: t.day.counted, label: "Shelves counted" }]} />
|
||||
|
||||
<MSection label="Recent" />
|
||||
{t.recent.length === 0
|
||||
? <MEmpty title="Nothing yet today" />
|
||||
: t.recent.map((r) => <MRow key={r.key} mark="mute" title={r.title} sub={r.sub} right={r.right} href={r.href} />)}
|
||||
</MBody>
|
||||
<MTabs active="today" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
/* The People tab: the staff register, served-recently first, each row showing how near they are to
|
||||
the sets one person holds (capCheck, the same check the counter uses). */
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { capCheck, staffName } from "@/lib/compute";
|
||||
import { MBody, MEmpty, MRow, MRule, MSearch, MSection, MTabs, MTop, MTopCount } from "@/components/m";
|
||||
|
||||
export default function People() {
|
||||
const { s } = useSnap();
|
||||
const sp = useSearchParams();
|
||||
const [q, setQ] = useState(() => (sp.get("q") || "").slice(0, 80));
|
||||
const active = useMemo(() => s.staff.filter((x) => !x.inactive), [s]);
|
||||
|
||||
const list = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
if (!needle) {
|
||||
const seen: Record<string, string> = {};
|
||||
for (const i of s.issues) if (i.date > (seen[i.staffId] || "")) seen[i.staffId] = i.date;
|
||||
return [...active].sort((a, b) => (seen[b.id] || "").localeCompare(seen[a.id] || "") || staffName(a).localeCompare(staffName(b))).slice(0, 12);
|
||||
}
|
||||
return s.staff
|
||||
.filter((x) => `${x.first} ${x.last} ${x.num} ${x.dept} ${x.group}`.toLowerCase().includes(needle))
|
||||
.sort((a, b) => Number(a.inactive) - Number(b.inactive) || staffName(a).localeCompare(staffName(b)))
|
||||
.slice(0, 40);
|
||||
}, [s, q, active]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="People" right={<MTopCount>{active.length}</MTopCount>} />
|
||||
<MRule />
|
||||
<MBody pad>
|
||||
<MSearch value={q} onChange={setQ} placeholder="Name or staff number" label="Search the staff register" scanHref="/m/scan" />
|
||||
{s.staff.length === 0 ? (
|
||||
<MEmpty title="No staff yet" sub="Staff are added in the portal." />
|
||||
) : (
|
||||
<>
|
||||
<MSection label={q.trim() ? "Matches" : "Served recently"} right={list.length} />
|
||||
{list.map((st) => {
|
||||
const cap = capCheck(s, st);
|
||||
const most = Math.max(cap.tops, cap.pants);
|
||||
return (
|
||||
<MRow key={st.id} href={`/m/person/${st.id}`} title={staffName(st)}
|
||||
sub={[st.group, st.num, st.dept].filter((x) => x && x.trim()).join(" · ")}
|
||||
right={`${most}/${cap.cap}`} mark={st.inactive ? "mute" : most >= cap.cap ? "accent" : "ink"} />
|
||||
);
|
||||
})}
|
||||
{q.trim() && list.length === 0 && <MEmpty title={`No one matches “${q.trim()}”`} sub="Check the spelling, or scan their badge." />}
|
||||
</>
|
||||
)}
|
||||
</MBody>
|
||||
<MTabs active="people" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/* A size exchange is Swap size on a Hand back line. */
|
||||
export default async function ExchangeFor({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
redirect(`/m/person/${encodeURIComponent(id)}?tab=back`);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
/* The person screen is the issue screen: who they are, how near the six sets they hold, and the
|
||||
* Issue | Hand back | History segments. Each segment draws its own docked bar through setBar; a
|
||||
* finished hand back swaps the whole screen for the Done screen in place. */
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { approvalRemaining, capCheck, isNursing, staffName } from "@/lib/compute";
|
||||
import { MBody, MEmpty, MError, MHead, MHeadRow, MMeterPair, MPill, MRule, MSeg, MTop } from "@/components/m";
|
||||
import { useBasket } from "@/components/MBasket";
|
||||
import { DoneScreen, type DoneProps } from "@/components/SignFlow";
|
||||
import HandBackTab from "@/components/person/HandBackTab";
|
||||
import IssueTab from "@/components/m/issue/IssueTab";
|
||||
import HistoryTab from "@/components/m/issue/HistoryTab";
|
||||
import { personMeta, plural } from "@/components/m/issue/meta";
|
||||
|
||||
type Tab = "issue" | "back" | "history";
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: "issue", label: "Issue" },
|
||||
{ key: "back", label: "Hand back" },
|
||||
{ key: "history", label: "History" },
|
||||
];
|
||||
const asTab = (v: string | null): Tab => (v === "back" || v === "history" ? v : "issue");
|
||||
|
||||
export default function MPersonPage() {
|
||||
const { s } = useSnap();
|
||||
const id = String(useParams().id || "");
|
||||
const sp = useSearchParams();
|
||||
const basket = useBasket();
|
||||
const st = s.staff.find((x) => x.id === id);
|
||||
const [tab, setTab] = useState<Tab>(() => asTab(sp.get("tab")));
|
||||
const [bar, setBarNode] = useState<React.ReactNode>(null);
|
||||
const [err, setErr] = useState("");
|
||||
const [done, setDone] = useState<DoneProps | null>(null);
|
||||
const top = useRef<HTMLSpanElement | null>(null);
|
||||
|
||||
const setBar = useCallback((b: React.ReactNode) => setBarNode(b), []);
|
||||
const onError = useCallback((m: string) => setErr(m), []);
|
||||
const onDone = useCallback((d: DoneProps) => { setErr(""); setDone(d); }, []);
|
||||
|
||||
if (done) return <DoneScreen {...done} />;
|
||||
|
||||
if (!st) {
|
||||
return (
|
||||
<>
|
||||
<MTop title="Person" back />
|
||||
<MRule />
|
||||
<MBody pad><MEmpty title="No such staff member" /></MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const pickTab = (k: Tab) => {
|
||||
if (k === tab) return;
|
||||
setErr("");
|
||||
setTab(k);
|
||||
try { window.history.replaceState(null, "", `/m/person/${encodeURIComponent(id)}${k === "issue" ? "" : `?tab=${k}`}`); } catch { /* not fatal */ }
|
||||
const body = top.current?.parentElement;
|
||||
if (body) body.scrollTop = 0;
|
||||
};
|
||||
|
||||
const lines = basket.issue(id);
|
||||
const cap = capCheck(s, st, lines);
|
||||
const left = approvalRemaining(s, st.id);
|
||||
const props = { staffId: st.id, setBar, onDone, onError };
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title={staffName(st)} back />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody pad>
|
||||
<span ref={top} aria-hidden="true" style={{ display: "block", height: 0 }} />
|
||||
<MHead name={staffName(st)} meta={personMeta(s, st)}>
|
||||
{tab === "issue" && (
|
||||
<>
|
||||
<MMeterPair items={[
|
||||
{ label: "Tops held", held: cap.tops, adding: cap.addTops, cap: cap.cap },
|
||||
{ label: "Pants held", held: cap.pants, adding: cap.addPants, cap: cap.cap },
|
||||
]} />
|
||||
{isNursing(s, st) && <MHeadRow label="Manager approval" value={`${plural(left, "set")} left`} />}
|
||||
</>
|
||||
)}
|
||||
</MHead>
|
||||
{st.inactive && <div style={{ margin: "-4px 0 10px" }}><MPill tone="accent">Inactive</MPill></div>}
|
||||
<MSeg label="Record" value={tab} options={TABS} onPick={pickTab} />
|
||||
{tab === "issue" ? <IssueTab {...props} /> : tab === "back" ? <HandBackTab {...props} /> : <HistoryTab {...props} />}
|
||||
</MBody>
|
||||
{bar}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/* Returns are the Hand back segment of the person's record. */
|
||||
export default async function ReturnFrom({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
redirect(`/m/person/${encodeURIComponent(id)}?tab=back`);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
/* Sign (issue): what is being handed over, the manager approval deduction, their signature and the
|
||||
* slip switch. issue.create records the lines, the per-line reasons, the approval deduction and the
|
||||
* signed slip in one locked write; SignFlow then shows the Done screen in place. */
|
||||
import { useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { approvalRemaining, isNursing, isPantItem, isTopItem, issueLineFlags, label, staffName } from "@/lib/compute";
|
||||
import { MBody, MButton, MEmpty, MHead, MRow, MRule, MSection, MStepper, MTop } from "@/components/m";
|
||||
import { useBasket, type IssueLine } from "@/components/MBasket";
|
||||
import SignFlow from "@/components/SignFlow";
|
||||
import { personMeta, plural } from "@/components/m/issue/meta";
|
||||
|
||||
export default function SignIssue() {
|
||||
const { s, mutate } = useSnap();
|
||||
const { byId } = useDerived();
|
||||
const id = String(useParams().id || "");
|
||||
const basket = useBasket();
|
||||
const st = s.staff.find((x) => x.id === id);
|
||||
// Once issued the basket is cleared; the lines are kept here so the screen does not fall back to
|
||||
// "nothing to issue" in the moment before the Done screen replaces it.
|
||||
const [frozen, setFrozen] = useState<IssueLine[] | null>(null);
|
||||
const lines = frozen ?? basket.issue(id);
|
||||
|
||||
if (!st || !lines.length) {
|
||||
return (
|
||||
<>
|
||||
<MTop title="Sign" back />
|
||||
<MRule />
|
||||
<MBody pad>
|
||||
<MEmpty title={st ? "Nothing to issue" : "No such staff member"} />
|
||||
{st && <MButton label="Back to their record" href={`/m/person/${encodeURIComponent(st.id)}`} />}
|
||||
</MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const n = lines.reduce((t, l) => t + l.qty, 0);
|
||||
const tops = lines.reduce((t, l) => t + (isTopItem(byId[l.itemId]) ? l.qty : 0), 0);
|
||||
const pants = lines.reduce((t, l) => t + (isPantItem(byId[l.itemId]) ? l.qty : 0), 0);
|
||||
const sets = Math.max(tops, pants);
|
||||
const left = approvalRemaining(s, st.id);
|
||||
const approval = isNursing(s, st) && left > 0;
|
||||
const most = Math.min(left, sets);
|
||||
const chosen = basket.deduct(id);
|
||||
const deduct = Math.max(0, Math.min(most, chosen ?? most));
|
||||
|
||||
const extra = approval ? (
|
||||
<>
|
||||
<MSection label="Manager approval" right={`${plural(left, "set")} left`} />
|
||||
<MRow title="Take off the approval" sub={`This issue is ${plural(sets, "set")}`}
|
||||
right={<MStepper label="sets off the approval" n={deduct} min={0} max={most} onChange={(v) => basket.setDeduct(id, v)} />} />
|
||||
</>
|
||||
) : undefined;
|
||||
|
||||
const commit = async ({ sigId, slip }: { sigId: string; slip: boolean }) => {
|
||||
const flags = issueLineFlags(s, st, lines);
|
||||
const r = await mutate<{ slipId: string | null }>("issue.create", {
|
||||
staffId: st.id,
|
||||
lines: lines.map((l, i) => ({ itemId: l.itemId, si: l.si, qty: l.qty, src: "stock", reason: flags[i] ? l.reason || "" : "" })),
|
||||
override: flags.some(Boolean),
|
||||
lineReasons: true,
|
||||
apDeduct: approval ? deduct : 0,
|
||||
sigId,
|
||||
slip,
|
||||
});
|
||||
if (!r.ok) return { ok: false as const, error: r.error };
|
||||
const sent = lines;
|
||||
setFrozen(sent);
|
||||
basket.clear("issue", st.id);
|
||||
return {
|
||||
ok: true as const,
|
||||
done: {
|
||||
head: `${plural(n, "item")} issued`,
|
||||
sub: `${staffName(st)} · ${slip ? "slip sent to their staff app" : "signed"}`,
|
||||
shelfKeys: sent.map((l) => l.key),
|
||||
next: "scan" as const,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<SignFlow
|
||||
kind="issue"
|
||||
head={<MHead name={staffName(st)} meta={personMeta(s, st)} />}
|
||||
lines={lines.map((l) => ({ key: l.key, name: `${label(byId[l.itemId])} ${String(byId[l.itemId]?.sizes[l.si] ?? l.si)}`, qty: l.qty }))}
|
||||
signerName={staffName(st)}
|
||||
extra={extra}
|
||||
slip={{ available: !!st.selfEmail }}
|
||||
barLabel={`Issue ${plural(n, "item")}`}
|
||||
commit={commit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/* The pickup call list is Work › Pickups. */
|
||||
export default function Pickups() {
|
||||
redirect("/m/work?seg=pickups");
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
/* Receive a delivery: tick each line as it is unpacked, step a line down when less came. Receiving
|
||||
closes the order; anything short goes onto a back order raised by order.receive. */
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { key, staffName } from "@/lib/compute";
|
||||
import { plural } from "@/lib/today";
|
||||
import { DoneScreen, useShowDone, type DoneProps } from "@/components/SignFlow";
|
||||
import { MBar, MBody, MButton, MEmpty, MError, MField, MKick, MPickRow, MRule, MSection, MStepper, MTop, inputStyle } from "@/components/m";
|
||||
import { RECEIVABLE, garmentSize, locMap, orderWhen, outstandingLines, segment, shelfOf, type OutLine } from "@/components/m/work/util";
|
||||
|
||||
export default function ReceiveOrder() {
|
||||
const id = segment(useParams<{ id: string }>().id);
|
||||
const { s, mutate } = useSnap();
|
||||
const { byId, staffById } = useDerived();
|
||||
const order = s.orders.find((o) => o.id === id);
|
||||
const lines = useMemo(() => (order && RECEIVABLE.includes(order.status) ? outstandingLines(order, byId) : []), [order, byId]);
|
||||
const locs = useMemo(() => locMap(s), [s]);
|
||||
const [tick, setTick] = useState<Record<string, boolean>>({});
|
||||
const [qty, setQty] = useState<Record<string, number>>({});
|
||||
const [invoice, setInvoice] = useState(() => order?.invoice || "");
|
||||
const [err, setErr] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [done, setDone] = useState<DoneProps | null>(null);
|
||||
const showDone = useShowDone();
|
||||
|
||||
if (done) return <DoneScreen {...done} />;
|
||||
|
||||
const q = (l: OutLine) => qty[l.id] ?? l.outstanding;
|
||||
const ticked = lines.filter((l) => tick[l.id]);
|
||||
const n = ticked.reduce((t, l) => t + q(l), 0);
|
||||
const all = lines.length > 0 && ticked.length === lines.length;
|
||||
|
||||
const receive = async () => {
|
||||
if (!order || saving) return;
|
||||
setSaving(true);
|
||||
setErr("");
|
||||
const r = await mutate("order.receive", {
|
||||
id: order.id, invoice: invoice.trim(),
|
||||
lines: lines.map((l) => ({ lineId: l.id, itemId: l.itemId, size: l.size, arrived: q(l), dest: order.staffId ? "pickup" : "shelf" })),
|
||||
});
|
||||
setSaving(false);
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
const short = lines.some((l) => q(l) < l.outstanding);
|
||||
const d: DoneProps = {
|
||||
head: `${plural(n, "item")} received`,
|
||||
sub: `${order.code} · ${order.supplier || "Supplier"} · order closed${short ? " · back order raised" : ""}`,
|
||||
shelfKeys: order.staffId ? [] : lines.filter((l) => q(l) > 0 && l.si >= 0).map((l) => key(l.itemId, l.si)),
|
||||
next: "work",
|
||||
};
|
||||
setDone(d);
|
||||
showDone(d);
|
||||
};
|
||||
|
||||
const forName = order?.staffId ? staffName(staffById[order.staffId]) : "";
|
||||
return (
|
||||
<>
|
||||
<MTop title={order?.code || "Receive"} back />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody pad>
|
||||
{!order || !lines.length ? (
|
||||
<>
|
||||
<MEmpty title="Nothing outstanding" />
|
||||
<MButton label="Back to Work" href="/m/work?seg=in" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MKick>{`${order.supplier || "Supplier"} · ${orderWhen(s, order)}`}</MKick>
|
||||
<MSection label="Tick what came in" right={`${ticked.length} of ${lines.length}`} />
|
||||
{lines.map((l) => {
|
||||
const name = garmentSize(byId[l.itemId], "Garment", l.size);
|
||||
const sub = forName ? `for ${forName}` : l.si >= 0 ? shelfOf(s, locs, l.itemId, l.si) : "";
|
||||
return (
|
||||
<MPickRow key={l.id} done={!!tick[l.id]} onToggle={() => setTick((x) => ({ ...x, [l.id]: !x[l.id] }))} title={name} sub={sub || undefined} tickLabel={`Came in ${name}`}>
|
||||
<MStepper n={q(l)} min={0} max={l.outstanding} label={name} onChange={(v) => setQty((x) => ({ ...x, [l.id]: v }))} />
|
||||
</MPickRow>
|
||||
);
|
||||
})}
|
||||
<MButton label="Tick all" onClick={() => setTick(Object.fromEntries(lines.map((l) => [l.id, true])))} />
|
||||
<MField label="Invoice number">
|
||||
<input value={invoice} onChange={(e) => setInvoice(e.target.value)} autoComplete="off" maxLength={80} style={inputStyle} />
|
||||
</MField>
|
||||
</>
|
||||
)}
|
||||
</MBody>
|
||||
{order && lines.length > 0 && (
|
||||
<MBar label={saving ? "Recording…" : `Receive ${plural(n, "item")}`} disabled={saving || !all || n === 0}
|
||||
offReason={saving ? undefined : !all ? "Tick each line" : "Nothing arrived"} onClick={receive} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/* Deliveries to receive are Work › In; each opens /m/receive/[id]. */
|
||||
export default function Receive() {
|
||||
redirect("/m/work?seg=in");
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
/* Draft order: what fell below par, at quantities that bring each line back up. Raising it makes a
|
||||
draft on Ordering; nothing reaches a supplier until someone approves it there. */
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { flaggedNeeds, label, onhand, reorderAt, touched } from "@/lib/compute";
|
||||
import { MBar, MBody, MButton, MDone, MEmpty, MError, MRow, MRule, MSection, MStepper, MTop, MTopCount } from "@/components/m";
|
||||
|
||||
export default function MReorder() {
|
||||
const { s, mutate, busy } = useSnap();
|
||||
const { L, byId, variants } = useDerived();
|
||||
const [qty, setQty] = useState<Record<string, number>>({});
|
||||
const [err, setErr] = useState("");
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
|
||||
const needs = useMemo(() => flaggedNeeds(s, L, byId).map((n) => {
|
||||
const k = `${n.itemId}:${n.si}`;
|
||||
return { ...n, key: k, name: `${label(byId[n.itemId])} ${n.size}`, oh: onhand(s, L, k), par: reorderAt(s, k) };
|
||||
}), [s, L, byId]);
|
||||
|
||||
// Stock's "short" counts every line at or under par; flaggedNeeds drops the ones open orders cover.
|
||||
const belowPar = useMemo(
|
||||
() => variants.filter((v) => touched(s, L, v.key) && onhand(s, L, v.key) <= reorderAt(s, v.key)).length,
|
||||
[s, L, variants],
|
||||
);
|
||||
const covered = Math.max(0, belowPar - needs.length);
|
||||
|
||||
const q = (k: string, fallback: number) => qty[k] ?? fallback;
|
||||
const total = needs.reduce((t, n) => t + q(n.key, n.qty), 0);
|
||||
const suppliers = [...new Set(needs.map((n) => n.supplier))];
|
||||
|
||||
const raise = async () => {
|
||||
const bySup: Record<string, typeof needs> = {};
|
||||
for (const n of needs) if (q(n.key, n.qty) > 0) (bySup[n.supplier] ||= []).push(n);
|
||||
const codes: string[] = [];
|
||||
for (const sup of Object.keys(bySup)) {
|
||||
const r = await mutate<{ code: string }>("order.create", {
|
||||
orderFor: "Stock", supplier: sup, replenish: false, notes: "Raised from a stocktake on the app",
|
||||
lines: bySup[sup].map((n) => ({ itemId: n.itemId, size: n.size, qty: q(n.key, n.qty) })),
|
||||
});
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
codes.push(r.result.code);
|
||||
}
|
||||
setDone(codes.join(" · "));
|
||||
};
|
||||
|
||||
if (done) return (
|
||||
<>
|
||||
<MTop title="Done" />
|
||||
<MRule />
|
||||
<MBody pad>
|
||||
<MDone head={`Draft ${done} raised`} sub="Waiting on Ordering" />
|
||||
<MButton label="Back to Stock" href="/m/stock" />
|
||||
</MBody>
|
||||
</>
|
||||
);
|
||||
|
||||
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Draft order" back right={<MTopCount>{plural(needs.length, "line")}</MTopCount>} />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody pad>
|
||||
{needs.length === 0 ? (
|
||||
<MEmpty title={belowPar ? "Already on order" : "Nothing to reorder"}
|
||||
sub={belowPar ? `${plural(belowPar, "line")} short, all on open orders` : "Every line is above par"} />
|
||||
) : (
|
||||
<>
|
||||
<MSection label="To order" right={suppliers.length === 1 ? suppliers[0] : plural(suppliers.length, "supplier")} />
|
||||
{needs.map((n) => (
|
||||
<MRow key={n.key} mark={n.oh <= 0 ? "accent" : "ink"} title={n.name}
|
||||
sub={`${n.oh}/${n.par} · ${n.supplier}`}
|
||||
right={<MStepper n={q(n.key, n.qty)} onChange={(v) => setQty((x) => ({ ...x, [n.key]: v }))} label={n.name} />} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{covered > 0 && needs.length > 0 && (
|
||||
<>
|
||||
<MSection label="Already on order" right={covered} />
|
||||
<MRow mark="mute" chev href="/m/stock?seg=order" title="On order" right={covered} />
|
||||
</>
|
||||
)}
|
||||
</MBody>
|
||||
{needs.length > 0 && (
|
||||
<MBar label={busy ? "Raising…" : "Raise the draft"} small={plural(total, "item")} onClick={raise}
|
||||
disabled={busy || total === 0} offReason={total === 0 ? "Set a quantity on at least one line" : undefined} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
/* Pick an approved staff request: tick or scan each garment in the bag, then hand it over. The first
|
||||
pick moves the request to "picking" through request.pick, so the desktop queue shows it as being
|
||||
picked. Ticks live in the basket (this phone only) until the hand-over is signed. */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { wasSpent } from "@/components/SignFlow";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { bcParse, key, onhand } from "@/lib/compute";
|
||||
import { scanReject } from "@/lib/feedback";
|
||||
import { relativeDay } from "@/lib/today";
|
||||
import { PICK_STATUSES } from "@/lib/workcount";
|
||||
import { useRequests } from "@/components/requests/RequestList";
|
||||
import { useBasket } from "@/components/MBasket";
|
||||
import MScan from "@/components/MScan";
|
||||
import {
|
||||
MBar, MBody, MButton, MEmpty, MError, MHead, MKick, MPickRow, MRow, MRule, MSection, MTop, useScanFlash, useToast,
|
||||
} from "@/components/m";
|
||||
import { garmentSize, locMap, personMeta, segment, shelfOf } from "@/components/m/work/util";
|
||||
|
||||
export default function PickRequest() {
|
||||
const id = segment(useParams<{ id: string }>().id);
|
||||
const { s, mutate } = useSnap();
|
||||
const { L, byId, staffById } = useDerived();
|
||||
const { data, error, reload } = useRequests();
|
||||
const basket = useBasket();
|
||||
const toast = useToast();
|
||||
const flash = useScanFlash();
|
||||
const [err, setErr] = useState("");
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const pickFired = useRef(false);
|
||||
const locs = useMemo(() => locMap(s), [s]);
|
||||
|
||||
const r = data?.requests.find((x) => x.id === id);
|
||||
const lines = useMemo(() => r?.bag ?? [], [r]);
|
||||
const stored = basket.picked(id);
|
||||
const router = useRouter();
|
||||
const gone = !!data && (!r || !PICK_STATUSES.has(r.status) || !lines.length);
|
||||
const spent = gone && wasSpent(`/m/request/${id}`);
|
||||
// Back from this request's own Done screen: the list is spent, so carry on to the queue.
|
||||
useEffect(() => { if (spent) router.replace("/m/work?seg=picks"); }, [spent, router]);
|
||||
// A ready bag was packed at the counter already: every line starts ticked.
|
||||
const got = useCallback((lineId: string, q: number) => {
|
||||
const v = stored[lineId];
|
||||
if (v === undefined) return r?.status === "ready" ? q : 0;
|
||||
return Math.max(0, Math.min(q, v));
|
||||
}, [stored, r]);
|
||||
const total = lines.reduce((t, l) => t + l.qty, 0);
|
||||
const picked = lines.reduce((t, l) => t + got(l.id, l.qty), 0);
|
||||
|
||||
const setLine = (lineId: string, n: number) => {
|
||||
const prev = stored;
|
||||
basket.setPicked(id, { ...prev, [lineId]: n });
|
||||
return () => basket.setPicked(id, prev);
|
||||
};
|
||||
|
||||
/** The first garment picked off an approved request tells the queue it is being picked. */
|
||||
const firstPick = async (undo: () => void) => {
|
||||
if (!r || r.status !== "accepted" || pickFired.current) return;
|
||||
pickFired.current = true;
|
||||
const res = await mutate("request.pick", { id: r.id });
|
||||
if (!res.ok) { pickFired.current = false; undo(); setErr(res.error); void reload(); return; }
|
||||
void reload();
|
||||
};
|
||||
|
||||
const toggle = (lineId: string, q: number) => {
|
||||
const cur = got(lineId, q);
|
||||
const undo = setLine(lineId, cur >= q ? 0 : q);
|
||||
if (cur < q) void firstPick(undo);
|
||||
};
|
||||
|
||||
const onScan = (raw: string) => {
|
||||
setScanning(false);
|
||||
const hit = bcParse(s, raw);
|
||||
if (!hit) { scanReject(); toast("That code isn’t a garment"); return; }
|
||||
const it = byId[hit.itemId];
|
||||
const name = garmentSize(it, "Garment", it?.sizes[hit.si] ?? hit.si);
|
||||
const match = lines.filter((l) => l.itemId === hit.itemId && l.si === hit.si);
|
||||
if (!match.length) { scanReject(); toast(`${name} isn’t on this request`); return; }
|
||||
const next = match.find((l) => got(l.id, l.qty) < l.qty);
|
||||
if (!next) { toast("Everything is picked"); return; }
|
||||
const n = got(next.id, next.qty) + 1;
|
||||
flash("Garment", name, () => { const undo = setLine(next.id, n); void firstPick(undo); });
|
||||
};
|
||||
|
||||
const shell = (body: React.ReactNode, bar?: React.ReactNode) => (
|
||||
<>
|
||||
<MTop title="Pick request" back />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody pad>{body}</MBody>
|
||||
{bar}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!data) {
|
||||
return shell(error
|
||||
? <><MError msg="Requests couldn’t be loaded." /><MButton small label="Try again" onClick={() => void reload()} /></>
|
||||
: <MKick>Loading</MKick>);
|
||||
}
|
||||
if (!r || !PICK_STATUSES.has(r.status) || !lines.length) {
|
||||
if (spent) return shell(<MKick>Loading</MKick>);
|
||||
return shell(<><MEmpty title="That request has moved on" /><MButton label="Back to Work" href="/m/work?seg=picks" /></>);
|
||||
}
|
||||
|
||||
const st = staffById[r.staffId];
|
||||
const when = relativeDay(r.decidedAt, s);
|
||||
return (
|
||||
<>
|
||||
{shell(
|
||||
<>
|
||||
<MHead name={r.staffName} meta={personMeta(s, st, r.ward)} />
|
||||
<MButton tone="ink" icon="scan" label="Scan to pick"
|
||||
onClick={() => (picked >= total ? toast("Everything is picked") : setScanning(true))} />
|
||||
<MSection label="Pick list" right={`${picked} of ${total}`} />
|
||||
{lines.map((l, i) => {
|
||||
const n = got(l.id, l.qty);
|
||||
const onShelf = Math.max(0, onhand(s, L, key(l.itemId, l.si)));
|
||||
const sub = [shelfOf(s, locs, l.itemId, l.si), `${onShelf} on shelf`, r.status === "ready" && i === 0 && r.collectCode ? `code ${r.collectCode}` : ""]
|
||||
.filter(Boolean).join(" · ");
|
||||
return (
|
||||
<MPickRow key={l.id} done={n >= l.qty} onToggle={() => toggle(l.id, l.qty)}
|
||||
title={garmentSize(byId[l.itemId], l.item, l.size)} sub={sub} right={`${n}/${l.qty}`} />
|
||||
);
|
||||
})}
|
||||
<MSection label="Approved" />
|
||||
<MRow mark="ok" title={r.managerName || "Manager"} sub={when ? `Approved ${when}` : "Approved"} />
|
||||
</>,
|
||||
<MBar label="Hand over" small={`${picked} of ${total} picked`} disabled={picked < total}
|
||||
offReason="Pick every line first" href={`/m/request/${r.id}/sign`} />,
|
||||
)}
|
||||
{scanning && <MScan title="Scan to pick" onHit={onScan} onClose={() => setScanning(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
/* Sign for a picked request, then Done. A request still "accepted" is moved to "picking" first
|
||||
(the queue only lets a picked request be collected); request.collected writes the issue rows, the
|
||||
signature and, when asked, the slip to the wearer's staff app. */
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { key } from "@/lib/compute";
|
||||
import { plural } from "@/lib/today";
|
||||
import { PICK_STATUSES } from "@/lib/workcount";
|
||||
import { useRequests, type RequestRow } from "@/components/requests/RequestList";
|
||||
import { useBasket } from "@/components/MBasket";
|
||||
import SignFlow from "@/components/SignFlow";
|
||||
import { MBody, MButton, MEmpty, MError, MHead, MKick, MRule, MTop } from "@/components/m";
|
||||
import { garmentSize, personMeta, segment } from "@/components/m/work/util";
|
||||
|
||||
export default function RequestSign() {
|
||||
const id = segment(useParams<{ id: string }>().id);
|
||||
const { s, mutate } = useSnap();
|
||||
const { byId, staffById } = useDerived();
|
||||
const { data, error, reload } = useRequests();
|
||||
const basket = useBasket();
|
||||
// Held once loaded: the hand-over refreshes the queue, and the Done screen must not turn into
|
||||
// "moved on" when this request leaves it.
|
||||
const [held, setHeld] = useState<{ r: RequestRow; ready: boolean } | null>(null);
|
||||
const [seen, setSeen] = useState(false);
|
||||
|
||||
const stored = basket.picked(id);
|
||||
useEffect(() => {
|
||||
if (held || !data) return;
|
||||
const r = data.requests.find((x) => x.id === id);
|
||||
if (r && PICK_STATUSES.has(r.status) && r.bag.length) {
|
||||
const ready = r.status === "ready" || r.bag.every((l) => (stored[l.id] ?? 0) >= l.qty);
|
||||
setHeld({ r, ready });
|
||||
}
|
||||
setSeen(true);
|
||||
}, [data, id, held, stored]);
|
||||
|
||||
const shell = (body: React.ReactNode) => (
|
||||
<>
|
||||
<MTop title="Sign" back />
|
||||
<MRule />
|
||||
<MBody pad>{body}</MBody>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!held) {
|
||||
if (!data && error) return shell(<><MError msg="Requests couldn’t be loaded." /><MButton small label="Try again" onClick={() => void reload()} /></>);
|
||||
if (!data || !seen) return shell(<MKick>Loading</MKick>);
|
||||
return shell(<><MEmpty title="That request has moved on" /><MButton label="Back to Work" href="/m/work?seg=picks" /></>);
|
||||
}
|
||||
const { r } = held;
|
||||
if (!held.ready) {
|
||||
return shell(<><MEmpty title="Pick every line first" /><MButton label="Back to the pick list" href={`/m/request/${r.id}`} /></>);
|
||||
}
|
||||
|
||||
const st = staffById[r.staffId];
|
||||
const total = r.bag.reduce((t, l) => t + l.qty, 0);
|
||||
return (
|
||||
<SignFlow
|
||||
kind="request"
|
||||
head={<MHead name={r.staffName} meta={personMeta(s, st, r.ward)} />}
|
||||
lines={r.bag.map((l) => ({ key: l.id, name: garmentSize(byId[l.itemId], l.item, l.size), qty: l.qty }))}
|
||||
signerName={r.staffName}
|
||||
slip={{ available: !!st?.selfEmail }}
|
||||
barLabel={`Hand over ${plural(total, "item")}`}
|
||||
spentHref={`/m/request/${r.id}`}
|
||||
commit={async ({ sigId, slip }) => {
|
||||
// Accepted cannot go straight to collected. If the pick screen already moved it, this
|
||||
// refusal is expected and collected below answers for the real state.
|
||||
if (r.status === "accepted") await mutate("request.pick", { id: r.id });
|
||||
const c = await mutate("request.collected", { id: r.id, sigId, slip });
|
||||
if (!c.ok) { void reload(); return { ok: false, error: c.error }; }
|
||||
basket.clear("picked", r.id);
|
||||
void reload();
|
||||
return {
|
||||
ok: true,
|
||||
done: {
|
||||
head: "Request handed over",
|
||||
sub: `${r.staffName} · ${plural(total, "item")}${slip ? " · slip sent" : ""}`,
|
||||
shelfKeys: r.bag.map((l) => key(l.itemId, l.si)),
|
||||
next: "work",
|
||||
},
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
/* Sign a ward round: every bag waiting for that ward (lib/today roundSheet, the same set the Rounds
|
||||
segment counted), one signature for the lot through pickup.deliverWard, then Done. */
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { plural, roundSheet } from "@/lib/today";
|
||||
import { takePhoto, uploadPhoto } from "@/lib/photo";
|
||||
import SignFlow from "@/components/SignFlow";
|
||||
import { MBody, MButton, MEmpty, MField, MHead, MRule, MTop, inputStyle, useToast } from "@/components/m";
|
||||
import { roundLines, segment } from "@/components/m/work/util";
|
||||
|
||||
const BATCH = 40; // pickup.deliverWard takes at most 40 bags a call
|
||||
|
||||
export default function RoundSign() {
|
||||
const ward = segment(useParams<{ ward: string }>().ward);
|
||||
const { s, mutate } = useSnap();
|
||||
const { byId, staffById } = useDerived();
|
||||
const toast = useToast();
|
||||
// Held from the first render: delivering removes the ward from the sheet, and the Done screen
|
||||
// must not turn into an empty round.
|
||||
const [rows] = useState(() => roundSheet(s, byId, staffById).find((w) => w.ward === ward)?.rows ?? []);
|
||||
const lines = useMemo(() => roundLines(rows, byId), [rows, byId]);
|
||||
const [name, setName] = useState("");
|
||||
const [proofId, setProofId] = useState<string | null>(null);
|
||||
const [photoBusy, setPhotoBusy] = useState(false);
|
||||
const delivered = useRef(new Set<string>());
|
||||
|
||||
if (!rows.length) {
|
||||
return (
|
||||
<>
|
||||
<MTop title="Sign" back />
|
||||
<MRule />
|
||||
<MBody pad>
|
||||
<MEmpty title="Nothing to deliver" />
|
||||
<MButton label="Back to Work" href="/m/work?seg=rounds" />
|
||||
</MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const items = lines.reduce((t, l) => t + l.qty, 0);
|
||||
const people = new Set(rows.map((r) => r.p.staffId)).size;
|
||||
const who = name.trim();
|
||||
|
||||
const takeProof = async () => {
|
||||
if (photoBusy) return;
|
||||
const data = await takePhoto();
|
||||
if (!data) return;
|
||||
setPhotoBusy(true);
|
||||
const up = await uploadPhoto(mutate, "proof", data);
|
||||
setPhotoBusy(false);
|
||||
if ("error" in up) { toast(up.error); return; }
|
||||
setProofId(up.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<SignFlow
|
||||
kind="round"
|
||||
head={<MHead name={`${ward} round`} meta={`${plural(people, "person", "people")} · ${plural(items, "item")}`} />}
|
||||
lines={lines.map((l) => ({ key: l.key, name: l.name, qty: l.qty }))}
|
||||
signerName={who || `${ward}, nurse in charge`}
|
||||
extra={
|
||||
<>
|
||||
<MField label="Received by">
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} autoComplete="off" maxLength={120} style={inputStyle} />
|
||||
</MField>
|
||||
<MButton small label={photoBusy ? "Saving photo…" : proofId ? "Photo attached · retake" : "Add handover photo"} onClick={() => void takeProof()} disabled={photoBusy} />
|
||||
</>
|
||||
}
|
||||
barLabel="Delivered"
|
||||
commit={async ({ sigId }) => {
|
||||
// A ward with more bags than one call takes goes in batches; a retry skips what already went.
|
||||
const ids = rows.map((r) => r.p.id).filter((x) => !delivered.current.has(x));
|
||||
for (let i = 0; i < ids.length; i += BATCH) {
|
||||
const part = ids.slice(i, i + BATCH);
|
||||
const r = await mutate("pickup.deliverWard", { ids: part, deliveredTo: who, sigId, proofId });
|
||||
if (!r.ok) return { ok: false, error: delivered.current.size ? `${delivered.current.size} of ${rows.length} bags recorded. ${r.error}` : r.error };
|
||||
part.forEach((x) => delivered.current.add(x));
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
done: { head: `${ward} delivered`, sub: `${plural(items, "item")} · signed by ${who || "the nurse in charge"}`, shelfKeys: [], next: "work" },
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/* Delivery rounds are Work › Rounds; each ward signs at /m/round/[ward]. */
|
||||
export default function Rounds() {
|
||||
redirect("/m/work?seg=rounds");
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
/* The Scan tab: one camera for a staff badge, a garment or a shelf label, routed by lib/scanroute.
|
||||
Under the Android shell MLKit's preview shows through the transparent viewfinder; in a browser a
|
||||
<video> fills it through lib/webscan. */
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { isNative, startLive } from "@/lib/nativescan";
|
||||
import { startWebLive } from "@/lib/webscan";
|
||||
import { scanReject, scanTick } from "@/lib/feedback";
|
||||
import { track } from "@/lib/analytics";
|
||||
import { useKeepAwake } from "@/lib/wakelock";
|
||||
import { SCAN_KIND_LABEL, resolveScan, type ScanHit } from "@/lib/scanroute";
|
||||
import { AC3, GROUND, INK, MBody, MButton, MONO, MPill, MRule, MSection, MTabs, MTop, useScanFlash } from "@/components/m";
|
||||
|
||||
type Recent = { kind: string; label: string; code: string; href: string };
|
||||
/* What this session resolved, newest first. Module memory: it survives moving between tabs and is
|
||||
gone when the app is closed. */
|
||||
let RECENT: Recent[] = [];
|
||||
|
||||
const OFF_GREY = "#b5b1af";
|
||||
const EDGE = "#57534f";
|
||||
const DEBOUNCE = 1500;
|
||||
|
||||
export default function ScanTab() {
|
||||
const { s, isAdmin } = useSnap();
|
||||
const router = useRouter();
|
||||
const flash = useScanFlash();
|
||||
const vid = useRef<HTMLVideoElement | null>(null);
|
||||
const [armed, setArmed] = useState(true);
|
||||
const [miss, setMiss] = useState<Extract<ScanHit, { kind: "unknown" | "inactive" }> | null>(null);
|
||||
const [caption, setCaption] = useState<{ msg: string; denied: boolean } | null>(null);
|
||||
const [recent, setRecent] = useState<Recent[]>([]);
|
||||
const [typed, setTyped] = useState("");
|
||||
const native = isNative();
|
||||
const [nativeOk, setNativeOk] = useState(native);
|
||||
const [nativeLive, setNativeLive] = useState(false);
|
||||
useKeepAwake(true);
|
||||
useEffect(() => { setRecent(RECENT); }, []);
|
||||
|
||||
const handle = useCallback((raw: string) => {
|
||||
const hit = resolveScan(s, raw);
|
||||
if (hit.kind === "unknown" || hit.kind === "inactive") {
|
||||
scanReject();
|
||||
if (hit.kind === "unknown") track("scan_miss", { kind: "unknown" });
|
||||
setArmed(false);
|
||||
setMiss(hit);
|
||||
return;
|
||||
}
|
||||
setArmed(false);
|
||||
const row: Recent = { kind: SCAN_KIND_LABEL[hit.kind], label: hit.label, code: raw.trim(), href: hit.href };
|
||||
RECENT = [row, ...RECENT.filter((r) => r.href !== row.href)].slice(0, 4);
|
||||
setRecent(RECENT);
|
||||
flash(row.kind, row.label, () => router.push(hit.href));
|
||||
}, [s, flash, router]);
|
||||
const handleRef = useRef(handle); handleRef.current = handle;
|
||||
|
||||
// Native: MLKit live session behind the WebView while armed.
|
||||
useEffect(() => {
|
||||
if (!native || !nativeOk || !armed) return;
|
||||
let session: { stop: () => Promise<void> } | null = null;
|
||||
let cancelled = false;
|
||||
let lastRaw = "", lastT = 0;
|
||||
(async () => {
|
||||
const r = await startLive((raw) => {
|
||||
if (raw === lastRaw && Date.now() - lastT < DEBOUNCE) return;
|
||||
lastRaw = raw; lastT = Date.now();
|
||||
scanTick();
|
||||
handleRef.current(raw);
|
||||
});
|
||||
if (cancelled) { await r.stop(); return; }
|
||||
if (r.error === "native-unavailable") { setNativeOk(false); return; }
|
||||
if (r.error) { setCaption({ msg: r.error, denied: true }); return; }
|
||||
session = r;
|
||||
setNativeLive(true);
|
||||
})();
|
||||
return () => { cancelled = true; setNativeLive(false); if (session) void session.stop(); };
|
||||
}, [native, nativeOk, armed]);
|
||||
|
||||
// Browser: the shared BarcodeDetector loop into the viewfinder's video.
|
||||
useEffect(() => {
|
||||
if (nativeOk || !armed || !vid.current) return;
|
||||
const session = startWebLive(vid.current, (raw) => handleRef.current(raw), DEBOUNCE, {
|
||||
onStatus: (st) => setCaption(st.state === "ready" ? null : { msg: st.msg, denied: st.state === "denied" }),
|
||||
});
|
||||
return () => session.stop();
|
||||
}, [nativeOk, armed]);
|
||||
|
||||
useEffect(() => {
|
||||
track("scan_opened", { mode: "tab", engine: nativeOk ? "mlkit" : native ? "mlkit-missing" : "browser" });
|
||||
}, [native, nativeOk]);
|
||||
|
||||
const again = () => { setMiss(null); setCaption(null); setArmed(true); };
|
||||
const find = () => { const c = typed.trim(); if (!c) return; setTyped(""); handle(c); };
|
||||
|
||||
return (
|
||||
<div className="tcx-scanui" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
<MTop title="Scan" />
|
||||
<MRule />
|
||||
<MBody dark pad className="tcx-scanbody">
|
||||
<div style={{ position: "relative", height: 300, margin: "-16px -16px 0", overflow: "hidden", background: nativeLive ? "transparent" : "radial-gradient(ellipse at 50% 45%, #3a3735 0, #151413 70%)" }} className="tcx-camwin">
|
||||
{!nativeOk && <video ref={vid} autoPlay playsInline muted aria-hidden="true" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover" }} />}
|
||||
<div aria-hidden="true" style={{ position: "absolute", left: 16, top: 14, display: "flex", gap: 8, alignItems: "center", color: "#fff", fontSize: 12, fontWeight: 800, letterSpacing: "0.1em" }}>
|
||||
<i style={{ width: 10, height: 10, background: armed ? "var(--color-accent)" : EDGE }} />LIVE
|
||||
</div>
|
||||
<div aria-hidden="true" style={{ position: "absolute", left: "14%", right: "14%", top: "22%", bottom: "26%" }}>
|
||||
{([["left", "top"], ["right", "top"], ["left", "bottom"], ["right", "bottom"]] as const).map(([x, y]) => (
|
||||
<i key={x + y} style={{ position: "absolute", width: 34, height: 34, [x]: 0, [y]: 0, borderStyle: "solid", borderColor: "#fff", borderWidth: 0, [`border${y === "top" ? "Top" : "Bottom"}Width`]: 4, [`border${x === "left" ? "Left" : "Right"}Width`]: 4 }} />
|
||||
))}
|
||||
</div>
|
||||
{armed && !caption?.denied && <div aria-hidden="true" className="tcx-vf-laser" style={{ position: "absolute", left: "8%", right: "8%", height: 2, background: "var(--color-accent)", boxShadow: "0 0 12px var(--color-accent)" }} />}
|
||||
<div role={caption?.denied ? "alert" : undefined} style={{ position: "absolute", left: 12, right: 12, bottom: 16, textAlign: "center", color: caption ? AC3 : "#fff", fontWeight: 800, fontSize: 14, letterSpacing: caption ? 0 : "0.06em", textTransform: caption ? "none" : "uppercase" }}>
|
||||
{caption ? caption.msg : "Badge, garment or shelf label"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: INK, margin: "0 -16px -16px", padding: "0 16px 16px", minHeight: "calc(100% - 268px)" }}>
|
||||
<div style={{ height: 1 }} />
|
||||
{miss ? (
|
||||
<div style={{ paddingTop: 18 }}>
|
||||
<MPill tone="accent">{miss.kind === "unknown" ? "Not found" : "Inactive"}</MPill>
|
||||
<div style={{ fontFamily: miss.kind === "unknown" ? MONO : undefined, fontSize: 22, fontWeight: miss.kind === "unknown" ? 500 : 800, marginTop: 10, wordBreak: "break-all" }}>
|
||||
{miss.kind === "unknown" ? miss.code || "—" : miss.label}
|
||||
</div>
|
||||
<div style={{ display: "grid", marginTop: 4 }}>
|
||||
{miss.kind === "unknown" && (isAdmin
|
||||
? <DarkButton label="Bind to a garment" href={`/m/catalogue?bind=${encodeURIComponent(miss.code)}`} />
|
||||
: <div style={{ fontSize: 13, color: OFF_GREY, marginTop: 10 }}>Only an admin can bind a code</div>)}
|
||||
{miss.kind === "inactive" && <DarkButton label="Open their record" href={`/m/person/${miss.staffId}`} />}
|
||||
<DarkButton label="Scan again" onClick={again} />
|
||||
</div>
|
||||
</div>
|
||||
) : recent.length > 0 && (
|
||||
<>
|
||||
<MSection label="Recent scans" right={recent.length} />
|
||||
<div style={{ display: "grid", gap: 8, marginTop: 10 }}>
|
||||
{recent.map((r) => (
|
||||
<button key={r.href} type="button" onClick={() => router.push(r.href)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 12, minHeight: 56, border: "2px solid " + EDGE, background: "transparent", color: GROUND, padding: "0 12px", textAlign: "left", fontFamily: "inherit", fontSize: 15, fontWeight: 700, cursor: "pointer" }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: OFF_GREY, width: 62, flex: "none" }}>{r.kind}</span>
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.label}</span>
|
||||
<span style={{ fontFamily: MONO, fontSize: 12, color: OFF_GREY, fontWeight: 500 }}>{r.code}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); find(); }} style={{ marginTop: 22 }}>
|
||||
<label htmlFor="scan-typed" style={{ display: "block", fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: OFF_GREY, marginBottom: 6 }}>Type a code</label>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<input id="scan-typed" value={typed} onChange={(e) => setTyped(e.target.value)} autoComplete="off" autoCapitalize="none" autoCorrect="off" spellCheck={false}
|
||||
style={{ flex: 1, minWidth: 0, height: 52, border: "2px solid " + EDGE, background: "transparent", color: GROUND, fontFamily: MONO, fontSize: 16, padding: "0 14px", borderRadius: 0 }} />
|
||||
<button type="submit" style={{ minWidth: 72, minHeight: 52, border: "2px solid " + GROUND, background: GROUND, color: INK, fontFamily: "inherit", fontSize: 14, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer" }}>Find</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</MBody>
|
||||
<MTabs active="scan" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** MButton's outline, drawn in ground on the ink body. */
|
||||
function DarkButton({ label, href, onClick }: { label: string; href?: string; onClick?: () => void }) {
|
||||
return (
|
||||
<div style={{ "--color-text": "var(--color-bg)" } as React.CSSProperties}>
|
||||
<MButton label={label} href={href} onClick={onClick} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/* Search became the People tab (stock search is on the Stock tab). The query comes along. */
|
||||
export default async function Search({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
const q = await searchParams;
|
||||
const term = typeof q.q === "string" ? q.q.trim().slice(0, 80) : "";
|
||||
redirect(term ? `/m/people?q=${encodeURIComponent(term)}` : "/m/people");
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
/* Settings, behind the gear on Today: this phone, the account, help and signing out. Everything else
|
||||
about the facility lives on the desktop, so there is one place a setting can be wrong. */
|
||||
import { DELETE_ACCOUNT_URL, PRIVACY_URL, TERMS_URL } from "@/lib/links";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { clearAllCounts } from "@/lib/opencount";
|
||||
import { printState, type PrintState } from "@/lib/nativeprint";
|
||||
import { clearBasket } from "@/components/MBasket";
|
||||
import { MBody, MButton, MError, MONO, MPill, MRow, MRule, MSection, MStepper, MSwitchRow, MTop } from "@/components/m";
|
||||
|
||||
const BEEP_KEY = "tc.beep";
|
||||
|
||||
export default function MSettings() {
|
||||
const { s, isAdmin, mutate, busy } = useSnap();
|
||||
const [beep, setBeep] = useState(true);
|
||||
const [gate, setGate] = useState(s.settings.varianceReason);
|
||||
const [err, setErr] = useState("");
|
||||
const [printer, setPrinter] = useState<PrintState | null>(null);
|
||||
const [host, setHost] = useState("");
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try { setBeep(localStorage.getItem(BEEP_KEY) !== "0"); } catch { /* blocked store */ }
|
||||
setHost(window.location.host);
|
||||
let live = true;
|
||||
printState().then((p) => { if (live) setPrinter(p); }).catch(() => { /* stays blank */ });
|
||||
return () => { live = false; };
|
||||
}, []);
|
||||
|
||||
const toggleBeep = () => {
|
||||
const next = !beep;
|
||||
setBeep(next);
|
||||
try { localStorage.setItem(BEEP_KEY, next ? "1" : "0"); } catch { /* blocked store */ }
|
||||
};
|
||||
|
||||
const saveGate = async (n: number) => {
|
||||
setGate(n);
|
||||
const r = await mutate("settings.update", { varianceReason: n });
|
||||
if (!r.ok) { setErr(r.error); setGate(s.settings.varianceReason); }
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
// Part-counted shelves and the half-built basket are this person's, on a phone that is passed
|
||||
// around a linen room. Cleared before the logout POST so a failed request still leaves the
|
||||
// device tidy. A full navigation afterwards: the session cookie is gone and every page behind
|
||||
// it is server-rendered. /m/login, not /auth, keeps them inside the app.
|
||||
setLeaving(true);
|
||||
clearAllCounts(s.session.userId);
|
||||
clearBasket(s.session.userId);
|
||||
try { await fetch("/api/auth/logout", { method: "POST" }); } catch { /* the cookie check on /m/login decides */ }
|
||||
window.location.replace("/m/login");
|
||||
};
|
||||
|
||||
const roleWord = s.session.role === "Admin" ? "Admin" : "Issuer";
|
||||
const tone = printer?.state === "ready" ? "ok" : printer?.state === "unavailable" ? "accent" : "mute";
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Settings" back />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody pad>
|
||||
<MSection label="This phone" />
|
||||
<MSwitchRow title="Beep and buzz on scan" on={beep} onToggle={toggleBeep} />
|
||||
<MRow title="Shelf printer" sub={printer?.sub ?? ""} right={printer ? <MPill tone={tone}>{printer.label}</MPill> : null} />
|
||||
<MRow title="Server" sub={host} />
|
||||
|
||||
<MSection label="Account" />
|
||||
<MRow title={s.settings.facility} sub={s.session.title || roleWord} />
|
||||
<MRow title="Count gap needing a reason"
|
||||
right={isAdmin
|
||||
? <MStepper n={gate} onChange={saveGate} min={1} max={99} label="gap" />
|
||||
: <span style={{ fontFamily: MONO, fontWeight: 600, fontSize: 14 }}>{gate}</span>} />
|
||||
<MRow title="Help" chev href="/app/help/apps/counter-app" />
|
||||
|
||||
{/* Deleting an account has to be reachable from inside the app (a Play requirement), and this
|
||||
is the only place the signed-in counter app names the policies. They go to the site's own
|
||||
pages: there is one account-deletion flow, and it is the one on the website. */}
|
||||
{(DELETE_ACCOUNT_URL || PRIVACY_URL || TERMS_URL) && <MSection label="Your account and your data" />}
|
||||
{DELETE_ACCOUNT_URL && <MRow href={DELETE_ACCOUNT_URL} external title="Delete your account" />}
|
||||
{PRIVACY_URL && <MRow href={PRIVACY_URL} external title="Privacy policy" />}
|
||||
{TERMS_URL && <MRow href={TERMS_URL} external title="Terms of use" />}
|
||||
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<MButton label="Sign out" onClick={signOut} disabled={busy || leaving} />
|
||||
</div>
|
||||
</MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/* The old signed-in welcome. Today is the first screen now; a brand-new facility gets its banner. */
|
||||
export default async function SignedIn({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
const q = await searchParams;
|
||||
redirect(q.new === "1" ? "/m?flash=created" : "/m");
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
/* The Stock tab: on hand against par, worst first, in three segments, and the stock jobs underneath. */
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { atReorderVariants } from "@/lib/portalcounts";
|
||||
import { bcBound, flaggedNeeds, label, locMap, locTrail, onhand, reorderAt, touched } from "@/lib/compute";
|
||||
import { MBody, MButton, MEmpty, MRow, MRule, MSearch, MSection, MSeg, MTabs, MTop, MTopCount } from "@/components/m";
|
||||
import { PAGE, byShortfall, countRows, placedOnOrder } from "@/components/m/stock/stockdata";
|
||||
|
||||
type Seg = "below" | "order" | "all";
|
||||
const SEGS: Seg[] = ["below", "order", "all"];
|
||||
|
||||
export default function MStock() {
|
||||
const { s } = useSnap();
|
||||
const { L, byId, variants } = useDerived();
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
const [seg, setSeg] = useState<Seg>(() => {
|
||||
const v = sp.get("seg") as Seg | null;
|
||||
return v && SEGS.includes(v) ? v : "below";
|
||||
});
|
||||
const [q, setQ] = useState("");
|
||||
const [shown, setShown] = useState(PAGE);
|
||||
|
||||
const data = useMemo(() => {
|
||||
const locs = locMap(s);
|
||||
const onOrd = placedOnOrder(s, byId);
|
||||
const below = new Set(atReorderVariants(s, L).map((v) => v.key));
|
||||
const all = variants.filter((v) => touched(s, L, v.key)).map((v) => ({
|
||||
key: v.key, itemId: v.itemId, si: v.si, size: v.size,
|
||||
name: label(v.item), oh: onhand(s, L, v.key), par: reorderAt(s, v.key),
|
||||
code: bcBound(s, v.item, v.si), shelf: locTrail(locs, s.placed[v.key], 0),
|
||||
below: below.has(v.key), onOrder: (onOrd[v.key] || 0) > 0,
|
||||
}));
|
||||
const order = variants.filter((v) => (onOrd[v.key] || 0) > 0).length;
|
||||
return { all, below: below.size, order, drafts: flaggedNeeds(s, L, byId).length };
|
||||
}, [s, L, byId, variants]);
|
||||
|
||||
const shelves = useMemo(() => countRows(s, L, variants).length, [s, L, variants]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
return data.all
|
||||
.filter((r) => seg === "all" || (seg === "below" ? r.below : r.onOrder))
|
||||
.filter((r) => !needle || `${r.name} ${r.size} ${r.code} ${r.shelf}`.toLowerCase().includes(needle))
|
||||
.sort(byShortfall);
|
||||
}, [data, seg, q]);
|
||||
|
||||
const pick = (k: Seg) => {
|
||||
setSeg(k); setShown(PAGE);
|
||||
router.replace(`/m/stock?seg=${k}`, { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Stock" right={<MTopCount>{data.below} short</MTopCount>} />
|
||||
<MRule />
|
||||
<MBody pad>
|
||||
<MSearch value={q} onChange={(v) => { setQ(v); setShown(PAGE); }} placeholder="Garment or size" label="Filter stock" scanHref="/m/scan" scanLabel="Scan a garment" />
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<MSeg label="Stock lines" value={seg} onPick={pick} options={[
|
||||
{ key: "below", label: "Below par", n: data.below },
|
||||
{ key: "order", label: "On order", n: data.order },
|
||||
{ key: "all", label: "All", n: data.all.length },
|
||||
]} />
|
||||
</div>
|
||||
|
||||
{rows.length === 0
|
||||
? <MEmpty title="Nothing here" sub="Try All, or clear the search." />
|
||||
: rows.slice(0, shown).map((r) => (
|
||||
<MRow key={r.key} href={`/m/line/${encodeURIComponent(r.itemId)}/${r.si}`}
|
||||
mark={r.oh <= 0 ? "accent" : r.oh <= r.par ? "ink" : "mute"}
|
||||
title={`${r.name} ${r.size}`} right={`${r.oh}/${r.par}`} />
|
||||
))}
|
||||
{rows.length > shown && (
|
||||
<>
|
||||
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 10 }}>Showing {shown} of {rows.length}</div>
|
||||
<MButton small label="Show more" onClick={() => setShown((n) => n + PAGE)} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<MSection label="Also in Stock" />
|
||||
<MRow mark="mute" chev href="/m/reorder" title={`Draft order · ${data.drafts} line${data.drafts === 1 ? "" : "s"}`} />
|
||||
<MRow mark="mute" chev href="/m/variance" title="Variance over time" />
|
||||
<MRow mark="mute" chev href="/m/catalogue" title="Catalogue" />
|
||||
<MRow mark="mute" chev href="/m/count" title={`Count a shelf · ${shelves} shel${shelves === 1 ? "f" : "ves"}`} />
|
||||
</MBody>
|
||||
<MTabs active="stock" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
/* Variance over time: the pattern, not the number. A line short at every count is a different
|
||||
problem from one short once, so the chart is the point and the latest gap is the figure. */
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { itemMap, label, monthLabel } from "@/lib/compute";
|
||||
import { INK, MBody, MButton, MEmpty, MKick, MONO, MRule, MSection, MTop, MTopCount } from "@/components/m";
|
||||
|
||||
const MAX_BAR = 56;
|
||||
const PAGE = 40;
|
||||
|
||||
export default function MVarianceOverTime() {
|
||||
const { s } = useSnap();
|
||||
const [shown, setShown] = useState(PAGE);
|
||||
|
||||
const { rows, counts } = useMemo(() => {
|
||||
const byId = itemMap(s);
|
||||
// Oldest first, shelf counts only, the last six.
|
||||
const takes = s.stocktakes.filter((t) => t.mode !== "preloved").slice(0, 6).reverse();
|
||||
const seen: Record<string, { name: string; gaps: (number | null)[] }> = {};
|
||||
takes.forEach((t, col) => {
|
||||
for (const l of t.lines) {
|
||||
const k = `${l.itemId}:${l.si}`;
|
||||
const it = byId[l.itemId];
|
||||
if (!it) continue;
|
||||
(seen[k] ||= { name: `${label(it)} ${it.sizes[l.si] ?? l.si}`, gaps: takes.map(() => null) });
|
||||
seen[k].gaps[col] = l.counted - l.sys;
|
||||
}
|
||||
});
|
||||
const out = Object.entries(seen).map(([k, v]) => {
|
||||
const known = v.gaps.filter((g): g is number => g !== null);
|
||||
const latest = [...v.gaps].reverse().find((g) => g !== null) ?? 0;
|
||||
const shortEvery = known.length >= 2 && known.every((g) => g < 0);
|
||||
const worsening = known.length >= 3 && known[known.length - 1] < known[0] && known[known.length - 1] < 0;
|
||||
const verdict = known.every((g) => g === 0) ? "Steady"
|
||||
: shortEvery ? `Short at every count since ${monthLabel(takes[v.gaps.findIndex((g) => g !== null)]?.date.slice(0, 7) || "", { month: "long" })}`
|
||||
: worsening ? "Drifting short"
|
||||
: latest === 0 ? "Back in line" : "Occasional gap";
|
||||
return { key: k, name: v.name, gaps: v.gaps, latest, verdict, persistent: shortEvery || worsening };
|
||||
});
|
||||
// Worst pattern first: persistent problems, then the biggest gap.
|
||||
out.sort((a, b) => Number(b.persistent) - Number(a.persistent) || a.latest - b.latest || a.name.localeCompare(b.name));
|
||||
return { rows: out, counts: takes };
|
||||
}, [s]);
|
||||
|
||||
const peak = Math.max(1, ...rows.flatMap((r) => r.gaps.map((g) => Math.abs(g ?? 0))));
|
||||
const month = (d: string, m: "short" | "long") => monthLabel(d.slice(0, 7), { month: m });
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Variance over time" back right={<MTopCount>{counts.length} count{counts.length === 1 ? "" : "s"}</MTopCount>} />
|
||||
<MRule />
|
||||
<MBody pad>
|
||||
{counts.length > 0 && <MKick>Gap at each count since {month(counts[0].date, "long")}</MKick>}
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<MEmpty title="No counts to compare yet" sub="Commit two shelf counts to see a pattern." />
|
||||
) : (
|
||||
<>
|
||||
<MSection label="Lines" right={rows.length} />
|
||||
{rows.slice(0, shown).map((r) => (
|
||||
<div key={r.key} style={{ padding: "12px 0", borderBottom: "1px solid var(--color-divider)", boxShadow: r.persistent ? "inset 4px 0 0 var(--color-accent)" : undefined, paddingLeft: r.persistent ? 12 : 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 700 }}>{r.name}</div>
|
||||
<div style={{ fontSize: 13, color: r.persistent ? "var(--color-accent-700)" : "var(--color-neutral-600)", fontWeight: r.persistent ? 800 : 400, marginTop: 1 }}>{r.verdict}</div>
|
||||
</div>
|
||||
<div style={{ fontFamily: MONO, fontWeight: 600, fontSize: 14, color: r.latest === 0 ? INK : "var(--color-accent-700)", whiteSpace: "nowrap" }}>
|
||||
{r.latest === 0 ? "Match" : r.latest > 0 ? `+${r.latest}` : `−${-r.latest}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="tcx-chart" style={{ marginTop: 12 }} role="img"
|
||||
aria-label={`Gap at each count: ${r.gaps.map((g, i) => `${counts[i] ? month(counts[i].date, "short") : ""} ${g === null ? "not counted" : g}`).join(", ")}`}>
|
||||
{r.gaps.map((g, i) => {
|
||||
const mag = Math.abs(g ?? 0);
|
||||
const h = g === null ? 4 : Math.max(4, Math.round((mag / peak) * MAX_BAR));
|
||||
const col = g === null ? "var(--color-neutral-300)" : mag === 0 ? "var(--color-divider)" : mag >= 3 ? "var(--color-accent)" : INK;
|
||||
return <i key={i} style={{ height: h, background: col }} />;
|
||||
})}
|
||||
</div>
|
||||
<div aria-hidden="true" style={{ display: "flex", gap: 5, marginTop: 6 }}>
|
||||
{counts.map((t, i) => (
|
||||
<span key={i} style={{ flex: 1, textAlign: "center", fontSize: 12, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--color-neutral-600)" }}>
|
||||
{month(t.date, "short")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{rows.length > shown && (
|
||||
<>
|
||||
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 10 }}>Showing {shown} of {rows.length}</div>
|
||||
<MButton small label="Show more" onClick={() => setShown((n) => n + PAGE)} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
/* The Work tab: the queue at the window. Approved requests to pick, deliveries to receive, pickups to
|
||||
call and ward rounds, counted by the same useWorkCount() as the tab badge and Today. */
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { collectRows, plural, receiveRows, relativeDay, roundSheet } from "@/lib/today";
|
||||
import { PICK_STATUSES, useWorkCount } from "@/lib/workcount";
|
||||
import { useRequests } from "@/components/requests/RequestList";
|
||||
import {
|
||||
MBody, MButton, MCard, MCardChip, MEmpty, MError, MKick, MPill, MRow, MRule, MSection, MSeg, MTabs, MTop, MTopCount, useToast,
|
||||
} from "@/components/m";
|
||||
import { openReceivable, orderWhen, outstandingTotal } from "@/components/m/work/util";
|
||||
|
||||
type Seg = "picks" | "in" | "pickups" | "rounds";
|
||||
const SEGS: readonly Seg[] = ["picks", "in", "pickups", "rounds"];
|
||||
const asSeg = (v: string | null): Seg => (SEGS.includes(v as Seg) ? (v as Seg) : "picks");
|
||||
|
||||
export default function Work() {
|
||||
const sp = useSearchParams();
|
||||
const [seg, setSeg] = useState<Seg>(() => asSeg(sp.get("seg")));
|
||||
const w = useWorkCount();
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
// A link from Today (?seg=in) lands on its segment even when Work is already mounted.
|
||||
useEffect(() => { setSeg(asSeg(sp.get("seg"))); }, [sp]);
|
||||
|
||||
const pick = (k: Seg) => {
|
||||
setSeg(k);
|
||||
setErr("");
|
||||
// Shallow: the segment is only a view of the snapshot already on the phone. null state, so Next's
|
||||
// router takes the new URL as canonical and a later refresh doesn't put the old segment back.
|
||||
try { window.history.replaceState(null, "", `/m/work?seg=${k}`); } catch { /* not fatal */ }
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Work" right={<MTopCount>{w.total} open</MTopCount>} />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody pad>
|
||||
<MSeg label="Work" value={seg} onPick={pick} options={[
|
||||
{ key: "picks", label: "Picks", n: w.picks },
|
||||
{ key: "in", label: "In", n: w.in },
|
||||
{ key: "pickups", label: "Pickups", n: w.pickups },
|
||||
{ key: "rounds", label: "Rounds", n: w.rounds },
|
||||
]} />
|
||||
{seg === "picks" && <Picks />}
|
||||
{seg === "in" && <Inbound />}
|
||||
{seg === "pickups" && <Pickups onError={setErr} />}
|
||||
{seg === "rounds" && <Rounds />}
|
||||
</MBody>
|
||||
<MTabs active="work" workBadge={w.total} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const line = (text: string) => <span style={{ display: "block" }}>{text}</span>;
|
||||
|
||||
function Picks() {
|
||||
const { s } = useSnap();
|
||||
const { staffById } = useDerived();
|
||||
const { data, error, reload } = useRequests();
|
||||
|
||||
const rows = useMemo(() => (data?.requests ?? [])
|
||||
.filter((r) => PICK_STATUSES.has(r.status))
|
||||
.sort((a, b) => (a.decidedAt || a.createdAt).localeCompare(b.decidedAt || b.createdAt)), [data]);
|
||||
|
||||
if (!data) {
|
||||
if (error) return (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<MError msg="Requests couldn’t be loaded." />
|
||||
<MButton small label="Try again" onClick={() => void reload()} />
|
||||
</div>
|
||||
);
|
||||
return <div style={{ marginTop: 14 }}><MKick>Loading</MKick></div>;
|
||||
}
|
||||
if (!rows.length) return <MEmpty title="Nothing to pick" sub="Approved requests land here." />;
|
||||
|
||||
return (
|
||||
<>
|
||||
{rows.map((r) => {
|
||||
const st = staffById[r.staffId];
|
||||
const what = r.bag.map((l) => `${l.item.toLowerCase()} ${l.size}`).join(", ");
|
||||
const when = relativeDay(r.decidedAt, s);
|
||||
const approved = [when ? `Approved ${when}` : "Approved", r.managerName].filter(Boolean).join(" · ")
|
||||
+ (r.status === "ready" && r.collectCode ? ` · ready, code ${r.collectCode}` : "");
|
||||
return (
|
||||
<MRow key={r.id} mark="ink" chev href={`/m/request/${r.id}`} title={r.staffName}
|
||||
sub={<>{line([st?.group || r.ward, what].filter(Boolean).join(" · "))}{line(approved)}</>}
|
||||
right={r.garments} />
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Inbound() {
|
||||
const { s } = useSnap();
|
||||
const { byId, staffById } = useDerived();
|
||||
const soon = useMemo(() => receiveRows(s, staffById), [s, staffById]);
|
||||
const later = useMemo(() => {
|
||||
const ids = new Set(soon.map((r) => r.o.id));
|
||||
return openReceivable(s, byId).filter((o) => !ids.has(o.id));
|
||||
}, [s, byId, soon]);
|
||||
|
||||
const row = (o: (typeof later)[number], forName: string) => (
|
||||
<MRow key={o.id} mark="ink" chev href={`/m/receive/${o.id}`} title={`${o.code} · ${o.supplier || "Supplier"}`}
|
||||
sub={[orderWhen(s, o), plural(o.lines.length, "line"), forName ? `for ${forName}` : ""].filter(Boolean).join(" · ")}
|
||||
right={outstandingTotal(o, byId)} />
|
||||
);
|
||||
|
||||
if (!soon.length && !later.length) return <MEmpty title="Nothing to receive" sub="Open orders show here when they arrive." />;
|
||||
return (
|
||||
<>
|
||||
{soon.map((r) => row(r.o, r.forName))}
|
||||
{later.length > 0 && (
|
||||
<>
|
||||
<MSection label="Later" right={later.length} />
|
||||
{later.map((o) => row(o, o.staffId ? (staffById[o.staffId] ? `${staffById[o.staffId].first} ${staffById[o.staffId].last}`.trim() : "") : ""))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Pickups({ onError }: { onError: (msg: string) => void }) {
|
||||
const { s, mutate } = useSnap();
|
||||
const { byId, staffById } = useDerived();
|
||||
const toast = useToast();
|
||||
const rows = useMemo(() => collectRows(s, byId, staffById, { includeRound: true }), [s, byId, staffById]);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const run = async (id: string, op: string, payload: Record<string, unknown>, then?: () => void) => {
|
||||
if (busy) return;
|
||||
setBusy(id);
|
||||
onError("");
|
||||
const r = await mutate(op, payload);
|
||||
setBusy(null);
|
||||
if (!r.ok) { onError(r.error); return; }
|
||||
then?.();
|
||||
};
|
||||
|
||||
if (!rows.length) return <MEmpty title="No one waiting" sub="Orders in for a person show here when they arrive." />;
|
||||
return (
|
||||
<>
|
||||
{rows.map((r) => {
|
||||
const items = r.lines.map((l) => `${l.garment} ${l.size} ×${l.qty}`).join(", ");
|
||||
const id = r.p.id;
|
||||
return (
|
||||
<MCard key={id} title={r.name} sub={[r.st?.dept, items].filter(Boolean).join(" · ")}
|
||||
pill={<MPill tone={r.late ? "accent" : "mute"}>{r.days}d</MPill>}
|
||||
actions={
|
||||
<>
|
||||
<MCardChip label="Call" href={r.tel || undefined} disabled={!r.tel} />
|
||||
<MCardChip label={r.p.contacted ? "Contacted" : "Contacted?"} on={r.p.contacted} disabled={busy === id}
|
||||
onClick={() => void run(id, "pickup.contacted", { id, contacted: !r.p.contacted })} />
|
||||
<MCardChip label="Collected" disabled={busy === id}
|
||||
onClick={() => void run(id, "pickup.pickedUp", { id }, () => toast(`${r.name} collected ${items}`))} />
|
||||
</>
|
||||
} />
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Rounds() {
|
||||
const { s } = useSnap();
|
||||
const { byId, staffById } = useDerived();
|
||||
const wards = useMemo(() => roundSheet(s, byId, staffById), [s, byId, staffById]);
|
||||
if (!wards.length) return <MEmpty title="No rounds today" sub="Delivered rounds are in History on the desktop." />;
|
||||
return (
|
||||
<>
|
||||
{wards.map((w) => {
|
||||
const people = new Set(w.rows.map((r) => r.p.staffId)).size;
|
||||
return (
|
||||
<MRow key={w.ward} mark="ink" chev href={`/m/round/${encodeURIComponent(w.ward)}`} title={w.ward}
|
||||
sub={`${plural(people, "person", "people")} · ${plural(w.rows.length, "bag")}`} right={w.garments} />
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from "next";
|
||||
import Analytics from "@/components/Analytics";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* `title` has to be absolute here. As a plain string it went through the root layout's
|
||||
"%s — ThreadCount" template and every screen of the phone app, sign-in included, was titled
|
||||
"ThreadCount — ThreadCount" in the tab, in history and in a bookmark. The template is re-declared
|
||||
for the screens below that name themselves, and the canonical points at the app rather than
|
||||
inheriting the marketing homepage's. */
|
||||
export const metadata: Metadata = {
|
||||
title: { absolute: "ThreadCount — the linen room counter", template: "%s — ThreadCount" },
|
||||
alternates: { canonical: "/m" },
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
/* The app is a fixed-height column: bars don't scroll, only the body does.
|
||||
*
|
||||
* No maximumScale. Pinning the zoom kept the layout tidy and took pinch-to-zoom away from everyone
|
||||
* on every screen — WCAG 1.4.4, and it matters more than tidiness on a ward phone held at arm's
|
||||
* length in bad light. The bundled shell's own index.html had the same line removed. */
|
||||
export const viewport = { width: "device-width", initialScale: 1, viewportFit: "cover" as const, themeColor: "#201e1d" };
|
||||
|
||||
/* Only the shell. Sign in and create account live under /m but must be reachable without a
|
||||
session — they are how you get one — so the session check sits in (app) with everything else. */
|
||||
export default function MobileLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="tcx-app" role="main">
|
||||
{/* The shell itself is the main landmark. No skip link on the phone surfaces: navigation is
|
||||
the bar at the bottom, after the content, so there is no repeated block in front to bypass.
|
||||
role on the existing box rather than a <main> wrapper — the shell is a fixed-height flex
|
||||
column, and the display:contents that an extra element would need has a long history of
|
||||
dropping the landmark out of the accessibility tree. */}
|
||||
{children}
|
||||
{/* Mounted on /m rather than inside (app): sign in and create account are app screens too,
|
||||
and they are where the Android shell lands first. */}
|
||||
<Analytics site="app" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
/* Sign in is a client component and can't export metadata of its own, so it gets this. Without it
|
||||
the page inherited the app shell's title and a browser tab, a history entry and a bookmark for
|
||||
the sign-in screen all read as if they were the site's front page. */
|
||||
export const metadata: Metadata = {
|
||||
title: "Sign in",
|
||||
alternates: { canonical: "/m/login" },
|
||||
};
|
||||
|
||||
export default function MLoginLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
/* Sign in — onboarding screen 03. Public: this is the one /m route someone reaches without a
|
||||
session, because it is how they get one. */
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import Turnstile, { awaitTurnstile, resetTurnstile, turnstileOn } from "@/components/Turnstile";
|
||||
import { track } from "@/lib/analytics";
|
||||
import { MAuthError, MAuthFooter, MAuthHeader, MField, MShowHide, authInput, authLink } from "@/components/MAuth";
|
||||
|
||||
function LoginInner() {
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
const [email, setEmail] = useState("");
|
||||
const [pw, setPw] = useState("");
|
||||
const [show, setShow] = useState(false);
|
||||
const [cfToken, setCfToken] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [reveal, setReveal] = useState(false);
|
||||
const [forgot, setForgot] = useState(false);
|
||||
const [sentReset, setSentReset] = useState(false);
|
||||
const [ticket, setTicket] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
|
||||
const next = (() => {
|
||||
const n = sp.get("next") || "";
|
||||
return n.startsWith("/m") && !n.startsWith("//") ? n : "/m";
|
||||
})();
|
||||
|
||||
async function sendReset() {
|
||||
setErr("");
|
||||
if (!email.trim()) { setErr("Put your work email in the box above first."); return; }
|
||||
setBusy(true);
|
||||
const token = cfToken || (turnstileOn() ? await awaitTurnstile() : "");
|
||||
await fetch("/api/auth/forgot", {
|
||||
method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email, cfToken: token }),
|
||||
}).catch(() => {});
|
||||
setBusy(false);
|
||||
setCfToken(""); resetTurnstile();
|
||||
// Shown whatever the server said: the answer must not reveal whether the address has an account.
|
||||
setSentReset(true);
|
||||
}
|
||||
|
||||
async function submitCode() {
|
||||
setErr("");
|
||||
if (!code.trim()) { setErr("Enter the six-digit code from your authenticator app."); return; }
|
||||
setBusy(true);
|
||||
const r = await fetch("/api/auth/2fa", {
|
||||
method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ticket, code }),
|
||||
}).catch(() => null);
|
||||
const j = r ? await r.json().catch(() => ({})) : {};
|
||||
setBusy(false);
|
||||
// A dropped connection is not a wrong code, and it used to leave the button spinning for ever
|
||||
// with nothing said. Nothing is signed in either way, so the advice is simply to try again.
|
||||
if (!r) {
|
||||
track("signin_failed", { reason: "network" });
|
||||
setErr("Couldn’t reach the server. Check the connection and try again.");
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
track("signin_failed", { reason: "second_factor" });
|
||||
setErr(j.error || "That code isn’t right.");
|
||||
if (r.status === 400) setTicket(""); // the ticket expired — start again
|
||||
return;
|
||||
}
|
||||
track("signin");
|
||||
window.location.replace(next);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!email.trim() || !pw) { setErr("Enter your email and password."); return; }
|
||||
setBusy(true);
|
||||
// The widget draws nothing in quiet mode, so nobody can see that it hasn't finished. Wait for
|
||||
// the token rather than posting an empty one and blaming the person for it.
|
||||
const token = cfToken || (turnstileOn() ? await awaitTurnstile() : "");
|
||||
if (turnstileOn() && !token) {
|
||||
// Eight seconds and no token. Either the check needs an interaction we've asked Turnstile
|
||||
// not to draw, or it couldn't reach Cloudflare at all. Show the widget rather than send an
|
||||
// empty token and let the server answer with a check the person was never shown.
|
||||
setBusy(false);
|
||||
setReveal(true);
|
||||
// How often the invisible check has to show itself. If this climbs, the quiet widget is
|
||||
// costing people sign-ins and should come back out.
|
||||
track("security_check_shown", { screen: "signin" });
|
||||
setErr("Finish the security check below, then try again.");
|
||||
return;
|
||||
}
|
||||
const r = await fetch("/api/auth/login", {
|
||||
method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email, password: pw, cfToken: token }),
|
||||
}).catch(() => null);
|
||||
const j = r ? await r.json().catch(() => ({})) : {};
|
||||
setBusy(false);
|
||||
/* The request never arrived. Ward wifi drops, and without this the promise rejected, `busy`
|
||||
never cleared and the sign-in button sat disabled on its spinner until the app was killed —
|
||||
which reads as ThreadCount refusing to let you in. Nothing was signed in, so the token is
|
||||
spent and the widget reset like any other failed attempt. */
|
||||
if (!r) {
|
||||
track("signin_failed", { reason: "network" });
|
||||
setErr("Couldn’t reach the server. Check the connection and try again.");
|
||||
setCfToken(""); resetTurnstile();
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
// Whether it was the password or the security check — no email, no message.
|
||||
track("signin_failed", { reason: r.status === 429 ? "throttled" : r.status === 400 ? "security_check" : "credentials" });
|
||||
setErr(j.error || "That email and password don’t match.");
|
||||
setCfToken(""); resetTurnstile();
|
||||
return;
|
||||
}
|
||||
// The password was right but the account has a second factor; nothing is signed in yet.
|
||||
if (j.need2fa) { setTicket(j.ticket); track("signin_2fa_required"); return; }
|
||||
track("signin");
|
||||
// A full navigation, not a router push: the session cookie has just changed and every /m page
|
||||
// is server-rendered from it. replace(), not assign(), so the hardware back button doesn't
|
||||
// land a signed-in person back on the sign-in screen.
|
||||
window.location.replace(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MAuthHeader kicker="Linen room access" title="Sign in" />
|
||||
<div style={{ height: 4, flex: "0 0 4px", background: "var(--color-accent)" }} />
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "26px 24px 30px", display: "grid", gap: 26, alignContent: "start" }}>
|
||||
<p style={{ fontSize: 14, lineHeight: 1.5, color: "var(--color-neutral-800)", maxWidth: 290 }}>
|
||||
{ticket ? "Your password was right. Now the code from your authenticator app." : "Use the account your linen services manager set up."}
|
||||
</p>
|
||||
<MAuthError msg={err} />
|
||||
{ticket ? (
|
||||
<>
|
||||
<MField n="01" label="Six-digit code">
|
||||
{(c) => (
|
||||
<input {...c} style={authInput} inputMode="numeric" autoComplete="one-time-code" autoFocus
|
||||
autoCapitalize="none" autoCorrect="off" spellCheck={false} enterKeyHint="go"
|
||||
placeholder="000000" value={code}
|
||||
onChange={(e) => { setCode(e.target.value); setErr(""); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") submitCode(); }} />
|
||||
)}
|
||||
</MField>
|
||||
<p style={{ fontSize: 12.5, lineHeight: 1.6, color: "var(--color-neutral-700)" }}>
|
||||
Lost your phone? A recovery code works here instead.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MField n="01" label="Work email">
|
||||
{(c) => (
|
||||
<input {...c} style={authInput} type="email" inputMode="email" autoCapitalize="none" autoCorrect="off"
|
||||
spellCheck={false} autoComplete="email" enterKeyHint="next" placeholder="you@yourfacility.org"
|
||||
value={email} onChange={(e) => { setEmail(e.target.value); setErr(""); }} />
|
||||
)}
|
||||
</MField>
|
||||
<MField n="02" label="Password" right={<MShowHide on={show} onToggle={() => setShow(!show)} />}>
|
||||
{(c) => (
|
||||
<input {...c} style={authInput} type={show ? "text" : "password"} autoComplete="current-password"
|
||||
enterKeyHint="go" value={pw}
|
||||
onChange={(e) => { setPw(e.target.value); setErr(""); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") submit(); }} />
|
||||
)}
|
||||
</MField>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* This used to explain that there was no reset and to go and find an admin — which was a
|
||||
dead end for the admin themselves, and deleting the last admin deletes the facility.
|
||||
Hidden during the code step, where it would be answering a question nobody asked. */}
|
||||
{ticket ? null : sentReset ? (
|
||||
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: "var(--color-neutral-800)", background: "#fff", borderLeft: "6px solid var(--color-accent)", padding: 14 }}>
|
||||
If that address has an account, a reset link is on its way. It works once and expires in
|
||||
an hour.
|
||||
</p>
|
||||
) : forgot ? (
|
||||
<div style={{ background: "#fff", borderLeft: "6px solid var(--color-text)", padding: 14 }}>
|
||||
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: "var(--color-neutral-800)", margin: 0 }}>
|
||||
Put your work email in the box above and we’ll send a link to set a new password.
|
||||
</p>
|
||||
<button onClick={sendReset} disabled={busy}
|
||||
style={{ marginTop: 12, background: "none", border: 0, padding: 0, fontSize: 13.5, fontWeight: 800, color: "var(--color-accent-700)", cursor: busy ? "wait" : "pointer" }}>
|
||||
{busy ? "Sending…" : "Send me a reset link"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setForgot(true)}
|
||||
style={{ justifySelf: "start", background: "none", border: 0, padding: 0, fontSize: 13, fontWeight: 600, color: "var(--color-accent-700)", cursor: "pointer" }}>
|
||||
Forgot password
|
||||
</button>
|
||||
)}
|
||||
{turnstileOn() && <Turnstile onToken={setCfToken} action="login" quiet={!reveal} />}
|
||||
</div>
|
||||
<MAuthFooter
|
||||
secondary={ticket
|
||||
? <button onClick={() => { setTicket(""); setCode(""); setErr(""); }} style={{ background: "none", border: 0, padding: 0, font: "inherit", fontSize: 13, fontWeight: 700, color: "var(--color-accent-700)", cursor: "pointer" }}>Start again</button>
|
||||
: <>No account yet? <Link href="/m/signup" style={authLink}>Sign up</Link></>}
|
||||
label={ticket ? "Verify" : "Sign in"} onSubmit={ticket ? submitCode : submit} busy={busy} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MLogin() {
|
||||
return <Suspense fallback={null}><LoginInner /></Suspense>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
/* Same reason as the sign-in screen next door: a client page can't name itself, and "create an
|
||||
account" is not the site's front page. */
|
||||
export const metadata: Metadata = {
|
||||
title: "Create an account",
|
||||
alternates: { canonical: "/m/signup" },
|
||||
};
|
||||
|
||||
export default function MSignupLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { switches } from "@/lib/switches";
|
||||
import MSignup from "@/components/MSignup";
|
||||
|
||||
/* Create account — onboarding screen 04. The screen itself is components/MSignup.tsx; this wrapper
|
||||
exists to tell it whether plans are live, which only the server knows. */
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MSignupPage() {
|
||||
const sw = await switches();
|
||||
return <MSignup plansLive={sw.plansLive} />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import AccountScreen from "@/components/screens/Account";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyAccount() {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
return <AccountScreen email={sess.email} />;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { reviewData } from "@/lib/managerdata";
|
||||
import ReviewScreen from "@/components/screens/Review";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyReview({ params }: { params: Promise<{ id: string }> }) {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
const { id } = await params;
|
||||
// reviewData starts from `managerId: sess.staffId`, so a request addressed to another manager
|
||||
// is not filtered out afterwards — it is never selected.
|
||||
const data = await reviewData(sess, id);
|
||||
if (!data) notFound();
|
||||
return <ReviewScreen data={data} />;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { approvalQueue } from "@/lib/managerdata";
|
||||
import ApprovalsScreen from "@/components/screens/Approvals";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyApprovals() {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
// Whoever a request names decides it, team or no team. A request reaches somebody who manages
|
||||
// nobody more than one way: the linen room re-addresses one that arrived without an approver
|
||||
// (request.reassign only checks the person is on the register, not that anybody reports to
|
||||
// them), or a manager's last report is moved to somebody else while their request is still
|
||||
// awaiting. This is the same query Home counts for its "waiting on you" banner, and the banner
|
||||
// is the only door — the staff nav has no approvals tab — so turning these people away left a
|
||||
// colleague blocked behind a 404 nobody could clear.
|
||||
const rows = await approvalQueue(sess);
|
||||
if (!rows.length) {
|
||||
// Nothing waiting and nobody reporting to them gets nothing, not an empty queue: an empty
|
||||
// approvals screen implies they might one day have a team, which is a question for the linen
|
||||
// room and not something this app should imply an answer to.
|
||||
const reports = await prisma.staff.count({ where: { managerId: sess.staffId } });
|
||||
if (!reports) notFound();
|
||||
}
|
||||
// Which of the waiting requests are for the manager themselves. Some of these now are — the
|
||||
// rule against approving your own uniform has been relaxed for the case the owner named — and
|
||||
// the screen sets those apart so nobody approves their own by accident and works out later that
|
||||
// they did. Whether a self-approval is allowed at all is the server's call and is not re-tested
|
||||
// here; this only asks the database which of the rows it already let through are the reader's
|
||||
// own, by the request's subject, which is the same fact the record is written from. The rows are
|
||||
// scoped to this manager and to `awaiting` already, so the lookup is over a handful of ids.
|
||||
const ownIds = rows.length
|
||||
? (
|
||||
await prisma.request.findMany({
|
||||
where: { id: { in: rows.map((r) => r.id) }, subjectId: sess.staffId },
|
||||
select: { id: true },
|
||||
})
|
||||
).map((r) => r.id)
|
||||
: [];
|
||||
return <ApprovalsScreen rows={rows} ownIds={ownIds} />;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { catalogueData, damageData } from "@/lib/staffdata";
|
||||
import DamageScreen from "@/components/screens/Damage";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyDamage() {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
const [{ holdings }, { managerName }] = await Promise.all([damageData(sess), catalogueData(sess)]);
|
||||
return <DamageScreen holdings={holdings} managerName={managerName} />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { kitData } from "@/lib/staffdata";
|
||||
import KitScreen from "@/components/screens/Kit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyKit() {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
return <KitScreen data={await kitData(sess)} />;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { kitCheckData } from "@/lib/cycledata";
|
||||
import KitCheckScreen from "@/components/screens/KitCheck";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyKitCheck() {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
const data = await kitCheckData(sess);
|
||||
// Between rounds there is no screen. A kit check that was always reachable would be answered at
|
||||
// random times and the cycle's numbers would mean nothing.
|
||||
if (!data) notFound();
|
||||
return <KitCheckScreen dueBy={data.dueBy} lastConfirmed={data.lastConfirmed} rows={data.rows} />;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { StaffProvider, type StaffMe } from "@/lib/staffclient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Everything that needs a signed-in staff member.
|
||||
*
|
||||
* The role flags are resolved here, once, from the database rather than trusted from the client:
|
||||
* "am I a manager" is the answer to "does anybody name me as theirs", and a screen that asked the
|
||||
* browser that question would be asking the wrong party.
|
||||
*/
|
||||
export default async function StaffAppLayout({ children }: { children: React.ReactNode }) {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
|
||||
const [staff, reports] = await Promise.all([
|
||||
prisma.staff.findUniqueOrThrow({
|
||||
where: { id: sess.staffId },
|
||||
select: { first: true, last: true, num: true, dept: true, wardDesk: true, managerId: true, facility: { select: { name: true, timezone: true } } },
|
||||
}),
|
||||
prisma.staff.count({ where: { managerId: sess.staffId, inactive: false } }),
|
||||
]);
|
||||
|
||||
const me: StaffMe = {
|
||||
staffId: sess.staffId,
|
||||
name: `${staff.first} ${staff.last}`.trim(),
|
||||
first: staff.first,
|
||||
num: staff.num,
|
||||
ward: staff.dept,
|
||||
facility: staff.facility.name,
|
||||
// Resolved here for the same reason the role flags are: it is the facility's answer, not the
|
||||
// phone's, and a device set to the wrong zone must not change what a ward round is told.
|
||||
tz: staff.facility.timezone,
|
||||
isManager: reports > 0,
|
||||
wardDesk: staff.wardDesk,
|
||||
hasManager: !!staff.managerId,
|
||||
};
|
||||
|
||||
return <StaffProvider me={me}>{children}</StaffProvider>;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/* What a tap looks like before the server answers.
|
||||
*
|
||||
* Every screen under /my is `force-dynamic` and rendered from its own database query, and App
|
||||
* Router keeps the previous screen fully painted until that query comes back. On ward wifi that is
|
||||
* two or three seconds in which nothing at all acknowledges the tap — so people tap again, and the
|
||||
* app reads as frozen. This is the route-level fallback the framework wants for exactly that: it
|
||||
* replaces the body the moment a navigation starts, keeping the app's own chrome so the change
|
||||
* reads as "loading" rather than "gone".
|
||||
*
|
||||
* Deliberately not the tab bar: the nav belongs to the four screens that draw it, and painting one
|
||||
* here would make it flash into existence on the way to a detail screen that has none. The top bar
|
||||
* has no title for the same reason — this fallback covers every route in the group, and inventing a
|
||||
* title would mean printing the wrong one somewhere.
|
||||
*/
|
||||
const INK = "#201e1d";
|
||||
const GROUND = "#f3f2f2";
|
||||
|
||||
/** A grey block standing in for a line of text. Sized in the same 2px system as everything else. */
|
||||
function Bar({ w, h = 16 }: { w: string; h?: number }) {
|
||||
return <div style={{ width: w, height: h, background: "var(--color-neutral-200)" }} />;
|
||||
}
|
||||
|
||||
export default function StaffLoading() {
|
||||
return (
|
||||
<>
|
||||
<header className="tcx-topbar" style={{
|
||||
height: 56, flex: "0 0 56px", background: INK, color: GROUND, display: "flex", alignItems: "center",
|
||||
paddingLeft: 16, paddingRight: 16,
|
||||
backgroundImage: "linear-gradient(to bottom, rgba(243,242,242,0.16) 0 1px, transparent 1px)",
|
||||
backgroundPosition: "0 env(safe-area-inset-top, 0px)", backgroundRepeat: "no-repeat", backgroundSize: "100% 1px",
|
||||
}}>
|
||||
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, letterSpacing: "0.06em", textTransform: "uppercase" }}>
|
||||
One moment
|
||||
</span>
|
||||
</header>
|
||||
<div style={{ height: 4, flex: "0 0 4px", background: "var(--color-accent)" }} />
|
||||
<div style={{ flex: 1, overflow: "hidden", background: GROUND }} aria-busy="true">
|
||||
{/* Announced once, quietly. The blocks below are decoration and say nothing. */}
|
||||
<div role="status" style={{ padding: "20px 16px 0", fontSize: 13, color: "var(--color-neutral-600)" }}>Loading…</div>
|
||||
<div style={{ padding: "16px 16px 0", display: "grid", gap: 10 }} aria-hidden="true">
|
||||
<Bar w="60%" h={22} />
|
||||
<Bar w="40%" />
|
||||
</div>
|
||||
<div style={{ marginTop: 24, display: "grid", gap: 2 }} aria-hidden="true">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} style={{ background: "#fff", padding: "18px 16px", display: "grid", gap: 8 }}>
|
||||
<Bar w="55%" h={18} />
|
||||
<Bar w="35%" h={12} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MBar, MBody, MEmpty, MRule, MTop } from "@/components/m";
|
||||
|
||||
/* What a staff screen shows when there is nothing behind it.
|
||||
*
|
||||
* Nine routes under /my call notFound(): a kit check between rounds, the waitlist with nothing
|
||||
* offered, the ward and desk screens for somebody without that role, an order that isn't theirs.
|
||||
* Without this file every one of them rendered the WEBSITE's 404 — marketing nav, "Open the demo",
|
||||
* a footer — inside the app, over the top of the tab bar, with the hardware back button as the
|
||||
* only way home. Seen on a Pixel 8 Pro on 2026-09-12 by opening Kit check with no check open.
|
||||
*
|
||||
* Next renders the nearest not-found.tsx, so this one stays inside the signed-in layout: same
|
||||
* chrome, same provider, and a bar that goes home.
|
||||
*/
|
||||
export default function StaffNotFound() {
|
||||
return (
|
||||
<>
|
||||
<MTop title="Nothing here" back />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<MEmpty
|
||||
title="Nothing to show right now"
|
||||
sub="There’s no screen behind that link at the moment — a kit check that isn’t open, a list with nothing on it, or something that isn’t yours to see. Nothing on your record has changed."
|
||||
/>
|
||||
</MBody>
|
||||
<MBar label="Back to home" href="/my" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { requestData } from "@/lib/staffdata";
|
||||
import ThreadScreen from "@/components/screens/Thread";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyOrderThread({ params }: { params: Promise<{ id: string }> }) {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
const { id } = await params;
|
||||
const data = await requestData(sess, id);
|
||||
if (!data) notFound();
|
||||
return <ThreadScreen data={data} />;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user