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 d947f89 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,277 @@
|
||||
"use client";
|
||||
/* Who changed what.
|
||||
*
|
||||
* Reads from its own endpoint rather than the snapshot: the trail grows without limit and putting
|
||||
* it in the snapshot would make every page in the app heavier forever, to serve a screen almost
|
||||
* nobody opens on an ordinary day.
|
||||
*
|
||||
* It shows ids rather than names on purpose — see lib/audit.ts. The id is the handle for going and
|
||||
* looking at the record; copying its contents in here would quietly build a second, unmanaged copy
|
||||
* of the staff register. */
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { Empty, LiveRegion, PageHead } from "@/components/ui";
|
||||
import { csvEsc, csvOf, facilityDate, formatInZone } from "@/lib/compute";
|
||||
import { downloadCsv } from "@/lib/print";
|
||||
|
||||
type Event = { id: string; at: string; who: string; op: string; target: string };
|
||||
|
||||
/* Operation names are written for the code. These are written for whoever is reading the log at
|
||||
the point somebody asks what happened. */
|
||||
const LABELS: Record<string, string> = {
|
||||
"issue.create": "Issued garments",
|
||||
"issue.return": "Recorded a return",
|
||||
"issue.exchange": "Exchanged a size",
|
||||
"issue.delete": "Deleted an issue",
|
||||
"issue.receipt": "Attached a signed receipt",
|
||||
"stocktake.apply": "Committed a stocktake",
|
||||
"stock.reorder": "Changed a par level",
|
||||
"stock.moves": "Adjusted stock",
|
||||
"stock.orderFlagged": "Raised an order from low stock",
|
||||
"catalog.add": "Added a garment",
|
||||
"catalog.update": "Edited a garment",
|
||||
"catalog.delete": "Deleted a garment",
|
||||
"catalog.duplicate": "Duplicated a garment",
|
||||
"catalog.bulk": "Bulk-changed the catalogue",
|
||||
"catalog.variantAdd": "Added a size",
|
||||
"catalog.removeSize": "Removed a size",
|
||||
"barcode.bind": "Bound a barcode",
|
||||
"barcode.unbind": "Unbound a barcode",
|
||||
"order.create": "Created an order",
|
||||
"order.receive": "Received an order",
|
||||
"order.status": "Changed an order’s status",
|
||||
"order.update": "Edited an order",
|
||||
"order.duplicate": "Duplicated an order",
|
||||
"order.lineAdd": "Added an order line",
|
||||
"order.lineQty": "Changed an order quantity",
|
||||
"order.lineRemove": "Removed an order line",
|
||||
"staff.save": "Added or edited a staff record",
|
||||
"staff.patch": "Edited a staff record",
|
||||
"staff.delete": "Deleted a staff record",
|
||||
"dept.save": "Edited a department",
|
||||
"dept.delete": "Deleted a department",
|
||||
"supplier.add": "Added a supplier",
|
||||
"supplier.update": "Edited a supplier",
|
||||
"supplier.remove": "Removed a supplier",
|
||||
"location.save": "Added or edited a location",
|
||||
"location.delete": "Deleted a location",
|
||||
"location.place": "Placed stock on a shelf",
|
||||
"approval.add": "Recorded a manager's approval",
|
||||
"approval.remove": "Removed a manager's approval",
|
||||
"alteration.add": "Logged an alteration",
|
||||
"alteration.advance": "Advanced an alteration",
|
||||
"alteration.remove": "Removed an alteration",
|
||||
"handin.add": "Recorded a hand-in",
|
||||
"pickup.contacted": "Marked a pickup contacted",
|
||||
"pickup.pickedUp": "Marked a pickup collected",
|
||||
"pickup.deliver": "Delivered to a ward",
|
||||
"request.raise": "Raised a request for somebody",
|
||||
"request.pick": "Started picking a request",
|
||||
"request.hold": "Held a request at the counter",
|
||||
"request.round": "Put a request on the ward round",
|
||||
"request.collected": "Handed a request over",
|
||||
"request.reply": "Wrote back about a request",
|
||||
"request.reassign": "Sent a request to a different approver",
|
||||
"request.withdraw": "Withdrew a request",
|
||||
"damage.handedIn": "Took a damaged garment back",
|
||||
"dispute.resolve": "Closed a record query",
|
||||
"notice.set": "Changed the ward notice",
|
||||
"kitcheck.open": "Started a kit check",
|
||||
"kitcheck.close": "Closed a kit check",
|
||||
"waitlist.offer": "Offered a waiting size",
|
||||
"staff.selfCode": "Made a staff-app activation code",
|
||||
"staff.selfClear": "Cancelled an activation code",
|
||||
"staff.selfUnlink": "Removed somebody’s staff-app access",
|
||||
"users.add": "Invited a user",
|
||||
"users.update": "Changed a user",
|
||||
"users.remove": "Removed a user",
|
||||
"settings.update": "Changed settings",
|
||||
"import.rows": "Imported data",
|
||||
"backup.restore": "Restored a backup",
|
||||
"data.reset": "Reset facility data",
|
||||
"data.wipeActivity": "Wiped activity history",
|
||||
"me.password": "Changed their own password",
|
||||
"me.profile": "Edited their own profile",
|
||||
"me.deleteAccount": "Deleted their own account",
|
||||
|
||||
/* Signing in and out, and the second factor.
|
||||
*
|
||||
* The page promises "every change made in this facility", and who reached the account is part of
|
||||
* that — a stock adjustment nobody disputes reads differently next to a run of failed sign-ins
|
||||
* from an address nobody recognises. Without these lines the trail rendered the raw op names. */
|
||||
"auth:signin": "Signed in",
|
||||
"auth:signin.failed": "A failed sign-in",
|
||||
"auth:signin.refused": "Sign-in refused (deactivated)",
|
||||
"auth:signout": "Signed out",
|
||||
"auth:signup": "Created the facility",
|
||||
"auth:password.reset": "Set a new password from a reset link",
|
||||
"2fa:setup": "Started two-factor setup",
|
||||
"2fa:enable": "Turned two-factor on",
|
||||
"2fa:disable": "Turned two-factor OFF",
|
||||
"2fa:regenerate": "Made new recovery codes",
|
||||
|
||||
/* The staff app. Every one of these is somebody on a ward changing something the linen room has
|
||||
* to live with, so they belong in the same trail rather than a second one nobody opens. */
|
||||
"staff:signin": "Signed in to the staff app",
|
||||
"staff:signin.failed": "A failed staff-app sign-in",
|
||||
"staff:signin.refused": "Staff-app sign-in refused (deactivated)",
|
||||
"staff:signout": "Signed out of the staff app",
|
||||
"staff:activate": "Claimed their own record",
|
||||
"staff:request.create": "Raised a uniform request",
|
||||
"staff:request.approve": "Approved a request (in the app)",
|
||||
"staff:request.decline": "Declined a request (in the app)",
|
||||
"staff:request.approve.email": "Approved a request (email link)",
|
||||
"staff:request.decline.email": "Declined a request (email link)",
|
||||
"staff:request.message": "Wrote about a request",
|
||||
"staff:round.sign": "Signed for a ward delivery",
|
||||
"staff:round.claim": "Confirmed a ward bag was collected",
|
||||
"staff:damage.report": "Reported damage",
|
||||
"staff:dispute.raise": "Said their record is wrong",
|
||||
"staff:waitlist.join": "Joined a waiting list",
|
||||
"staff:waitlist.leave": "Left a waiting list",
|
||||
"staff:waitlist.accept": "Took up a waitlist offer",
|
||||
"staff:kit.answer": "Answered a kit check",
|
||||
"staff:account.password": "Changed their own staff-app password",
|
||||
};
|
||||
|
||||
/** Operations worth noticing in a list of hundreds. */
|
||||
const NOTABLE = new Set([
|
||||
"catalog.delete", "catalog.removeSize", "staff.delete", "dept.delete", "location.delete", "supplier.remove",
|
||||
"users.add", "users.remove", "users.update", "settings.update", "backup.restore",
|
||||
"data.reset", "data.wipeActivity", "me.deleteAccount", "catalog.bulk",
|
||||
// Turning the second factor off weakens every account in the facility, and a refused sign-in is
|
||||
// somebody with a password trying to get in after their access was taken away. Both are worth
|
||||
// catching an eye in a list of hundreds.
|
||||
"2fa:disable", "auth:signin.refused", "staff:signin.refused",
|
||||
// The two ends of staff-app access: selfCode mints a credential that opens somebody's record,
|
||||
// selfUnlink takes their account away. Both are the linen room reaching into a person's access
|
||||
// rather than into stock, which is exactly what an admin is looking for when they open this.
|
||||
"staff.selfCode", "staff.selfUnlink",
|
||||
]);
|
||||
|
||||
/* Stamped in the facility's own zone, not the browser's. An audit trail read on a laptop that is
|
||||
travelling, or served by a machine set to UTC, has to agree with the clock on the linen-room wall
|
||||
or the times are worse than useless in a dispute. */
|
||||
function when(iso: string, tz: string) {
|
||||
return formatInZone(iso, tz, { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
}
|
||||
|
||||
export default function Activity() {
|
||||
const { s, isAdmin } = useSnap();
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [before, setBefore] = useState<string | null>(null);
|
||||
const [more, setMore] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const load = useCallback(async (cursor: string | null) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await fetch("/api/activity" + (cursor ? `?before=${encodeURIComponent(cursor)}` : ""));
|
||||
const j = await r.json();
|
||||
if (!r.ok) { setErr(j.error || "Couldn’t load the log."); return; }
|
||||
setEvents((prev) => (cursor ? [...prev, ...j.events] : j.events));
|
||||
setBefore(j.nextBefore);
|
||||
setMore(!!j.nextBefore);
|
||||
} catch {
|
||||
setErr("Couldn’t load the log.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { if (isAdmin) void load(null); else setLoading(false); }, [isAdmin, load]);
|
||||
|
||||
/* The file is what is on screen, and nothing more — in two senses.
|
||||
*
|
||||
* The log is paged, so what comes out is what has been loaded. If the question reaches further
|
||||
* back than the screen does, press Load older first and export again; the file states on its
|
||||
* face how far it goes, so a first page can never be handed over as though it were the whole
|
||||
* trail. And no column appears that the screen does not show: the trail also records the address
|
||||
* each change came from, which is why the endpoint never sends it to this page, and a file that
|
||||
* leaves the building by email is the last place to start handing that around. */
|
||||
function exportCsv() {
|
||||
/* Full date, and seconds — neither of which the table needs, because you read it in order.
|
||||
A spreadsheet gets re-sorted the moment it lands: "9 Sep, 14:32" sorts as text into nonsense
|
||||
and carries no year at all, and two changes inside the same minute would lose the order they
|
||||
happened in, which is the whole question when a figure is disputed. The zone is the
|
||||
facility's, the same as the screen, and it is named at the top of the file so a copy opened
|
||||
in another state is not quietly read as local time. */
|
||||
const stamp = (iso: string) =>
|
||||
`${facilityDate(iso, s.tz)} ${formatInZone(iso, s.tz, { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, hourCycle: "h23" })}`;
|
||||
/* The four headings are the table's, and mean the same four things: What is the plain-English
|
||||
label the screen shows rather than the op name behind it, and Record is the identifier
|
||||
exactly as shown — left blank rather than carrying the screen's dash, which in a spreadsheet
|
||||
cell is only noise. */
|
||||
const reach = more ? `${events.length} (older events not loaded)` : `${events.length} (the whole log)`;
|
||||
downloadCsv(`threadcount-activity-${s.today}.csv`,
|
||||
`Activity log,${csvEsc(s.today)}\nTimes shown in,${csvEsc(s.tz)}\nEvents in this file,${csvEsc(reach)}\n\n`
|
||||
+ csvOf(["When", "Who", "What", "Record"], events.map((e) => [stamp(e.at), e.who, LABELS[e.op] || e.op, e.target])));
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<section>
|
||||
<PageHead eyebrow="Admin" title="Activity" />
|
||||
<Empty>Only an admin can read the change log.</Empty>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<PageHead eyebrow="Admin" title="Activity" sub="Every change made in this facility, newest first — who made it and when.">
|
||||
<button className="btn btn-ghost" onClick={exportCsv} disabled={events.length === 0} title="Downloads the events shown.">
|
||||
{more ? `Export CSV (${events.length} shown)` : "Export CSV"}
|
||||
</button>
|
||||
</PageHead>
|
||||
|
||||
<LiveRegion tone="alert" msg={err} style={{ marginTop: 16, background: "var(--color-accent-600)", color: "#fff", padding: "10px 12px", fontWeight: 600 }} />
|
||||
|
||||
{/* The log is one block with its own head and foot rather than a table adrift on the page:
|
||||
how far back it reaches is the first thing anybody asks of it, so the count sits on the
|
||||
block itself and Load older sits under the same border as the rows it extends. */}
|
||||
<div className="tc-panel" style={{ marginTop: "var(--space-5)" }}>
|
||||
<div className="tc-panel-head">
|
||||
<span>Change log</span>
|
||||
<span className="tc-panel-aside">{events.length} shown{more ? " · older events not loaded" : ""}</span>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<table className="table" style={{ minWidth: 620 }}>
|
||||
<thead>
|
||||
<tr><th style={{ width: 150 }}>When</th><th style={{ width: 190 }}>Who</th><th>What</th><th style={{ width: 220 }}>Record</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((e) => {
|
||||
const notable = NOTABLE.has(e.op);
|
||||
return (
|
||||
<tr key={e.id}>
|
||||
<td style={{ whiteSpace: "nowrap", fontVariantNumeric: "tabular-nums" }}>{when(e.at, s.tz)}</td>
|
||||
<td>{e.who}</td>
|
||||
{/* A line worth stopping on is marked as well as coloured. The accent is the
|
||||
brand — it is the primary button and the current menu item — so a second red
|
||||
in a list of hundreds is a guess; the mark beside the words is what actually
|
||||
says "this one". Decoration, so it is hidden from a screen reader: the
|
||||
wording of the line is the message. */}
|
||||
<td style={{ fontWeight: notable ? 700 : 400, color: notable ? "var(--color-accent-700)" : undefined }}>
|
||||
{notable && <span className="tc-mark" aria-hidden="true" />}
|
||||
{LABELS[e.op] || e.op}
|
||||
</td>
|
||||
<td style={{ fontFamily: "monospace", fontSize: 11.5, color: "var(--color-neutral-700)", wordBreak: "break-all" }}>{e.target || "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{!events.length && !loading && (
|
||||
<tr><td colSpan={4} style={{ color: "var(--color-neutral-700)" }}>Nothing recorded yet.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="tc-panel-foot" style={{ display: "flex", gap: "var(--space-3)", alignItems: "center", flexWrap: "wrap" }}>
|
||||
{more && <button className="btn btn-secondary" onClick={() => load(before)} disabled={loading}>{loading ? "Loading…" : "Load older"}</button>}
|
||||
<span style={{ fontSize: 12.5, color: "var(--color-neutral-700)" }}>{events.length} shown{more ? " — Export CSV writes these, so load the older events first if the file has to reach further back." : ". Export CSV writes the whole log."}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
/* Help: the rules and routines a coordinator needs to know once, written down in one place so the
|
||||
* working screens don't have to carry them. The owner took the explanations off every screen and
|
||||
* asked for anything worth keeping to live here instead.
|
||||
*
|
||||
* Every figure is read from this facility's own settings rather than written in, so the page can't
|
||||
* quote a number the facility has changed. The import rules are the templates' own notes, so they
|
||||
* can't drift from what the importer accepts. */
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { PageHead } from "@/components/ui";
|
||||
import { CSV_TEMPLATES } from "@/lib/csv";
|
||||
import { FTE_SETS, SLIP_DAYS } from "@/lib/compute";
|
||||
import { SET_GARMENTS, setsCap, setsOnStart } from "@/lib/sets";
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="tc-panel" style={{ marginBottom: "var(--space-4)" }}>
|
||||
<div className="tc-panel-head"><span>{title}</span></div>
|
||||
<div className="tc-panel-body" style={{ fontSize: 14, lineHeight: 1.6 }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const list = { margin: 0, paddingLeft: "1.2em", display: "grid", gap: "var(--space-1)" } as const;
|
||||
|
||||
export default function Help() {
|
||||
const { s } = useSnap();
|
||||
const cap = setsCap(s.settings.capSets);
|
||||
const start = Math.min(cap, setsOnStart(s.settings.initialSets));
|
||||
// The table as the form lists it: a full-timer's figure first, down to the smallest.
|
||||
const table = Object.entries(FTE_SETS).filter(([, n]) => n !== null) as [string, number][];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead eyebrow="Help" title="How ThreadCount works" sub="The rules behind the screens, with this facility's own figures." />
|
||||
|
||||
<Section title="What anyone may hold">
|
||||
<ul style={list}>
|
||||
<li>Up to <b>{cap} sets</b> at any time — {cap} tops and {cap} pairs of trousers. The same for every staff group, nursing included.</li>
|
||||
<li>It counts everything issued and not handed in or returned, plus anything on order for them, waiting at the counter, or approved and not yet collected. Pre-loved garments count.</li>
|
||||
<li>Garments that aren't part of a set — fleeces, jackets, maternity wear — have their own ceiling of {cap}.</li>
|
||||
<li>It isn't a yearly allowance and nothing resets in July. At the ceiling, the next garment comes by handing one in first, or on a coordinator's override, which is recorded.</li>
|
||||
<li>Change the figure under Settings → General.</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section title="Ordering from suppliers">
|
||||
<ul style={list}>
|
||||
<li><b>Orders → Order list</b> gathers every size at or below its reorder level, topped up to twice the level and netted off what is already on order, one group per supplier. Adjust the quantities, add a line, then <b>Raise</b>: each group becomes one placed order with the supplier's order number as its reference.</li>
|
||||
<li>A person's order from the counter stays its own order — one per supplier per person — so an order placed under their account at the supplier is never merged into the shelf's.</li>
|
||||
<li>Each raised order prints as an A4 sheet with the supplier's product codes, downloads as a CSV, or emails the supplier from Settings → Suppliers' order address. Enter each size's code once, on the garment's page under Ordering. Invoice and tracking numbers are entered when the delivery is received.</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section title="The three routes">
|
||||
<p style={{ margin: "0 0 var(--space-2)" }}>Each staff group is on one route, chosen under Settings → Staff groups. All three stop at the same {cap} sets.</p>
|
||||
<ul style={list}>
|
||||
<li><b>FTE table</b> — the hours someone works propose their starting kit: {table.map(([fte, n]) => `${fte} FTE ${n}`).join(", ")} sets; a casual is at the manager's discretion. A manager may sign for more.</li>
|
||||
<li><b>Starting kit</b> — {start} sets on the first day ({start * SET_GARMENTS} garments), then more as needed. Nothing has to be handed back first.</li>
|
||||
<li><b>Manager approval</b> — no starting kit; the manager approves each set.</li>
|
||||
<li>A group can't be on two routes, and a group with people in it can't be removed — rename it instead.</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section title="The yearly figure">
|
||||
<p style={{ margin: 0 }}>“Items (FY)” on Reports and “drawn since July” on Issue Stock count what someone has drawn since 1 July. They feed the reports and the monthly exceptions list, and never limit what the counter issues. Groups on the FTE table aren't measured against one.</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Hand-ins">
|
||||
<ul style={list}>
|
||||
<li>Handing a garment in frees room at the counter straight away, whether or not the credit box is ticked.</li>
|
||||
<li>The credit tick adds the good garments back to the yearly figure and to the manager's approval. Pre-loved garments earn neither.</li>
|
||||
<li>Good garments join the pre-loved pool and are reissued free; rags are counted for disposal.</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section title="Garment types">
|
||||
<p style={{ margin: 0 }}>A garment's type decides how it counts. Tops and trousers are each half a set; every other type counts toward the separate ceiling. A type typed in by hand that isn't on the list counts toward no set, so pick from the list.</p>
|
||||
<p style={{ margin: "var(--space-2) 0 0" }}>Each garment is tagged for the staff groups that wear it, or for all groups. Staff can only request their own groups' garments, and the counter needs a coordinator's override, which is recorded, to issue anyone a garment outside their group.</p>
|
||||
<p style={{ margin: "var(--space-2) 0 0" }}>A garment is also men's, women's or unisex. Somebody is offered the cut set as their Uniform style plus everything unisex; blank means every style until a coordinator sets it, and the counter needs the same override, also recorded, to issue anyone another cut.</p>
|
||||
</Section>
|
||||
|
||||
<Section title="The staff app">
|
||||
<ul style={list}>
|
||||
<li>Generate a code on the staff record and hand them the slip. A code works once and expires after {SLIP_DAYS} days.</li>
|
||||
<li>Record their manager first, under Manager's approval on the staff record — nobody can raise a request without one.</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section title="Requests and approvals">
|
||||
<ul style={list}>
|
||||
<li>A request goes to the person's manager — the same person who signs their paper order form.</li>
|
||||
<li>A manager can raise requests for the people who report to them; those go to the manager above. With nobody above, the request waits under Ward Requests → Needs an approver.</li>
|
||||
<li>Nobody approves a request they raised for somebody else.</li>
|
||||
<li>Anyone can be set as their own manager; what they approve for themselves is marked Self-approved.</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section title="Stock takes">
|
||||
<p style={{ margin: 0 }}>A count in progress is saved in this browser only, under your sign-in. It survives a reload, but not a move to another computer or the phone — finish a count where you started it.</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Importing and exporting">
|
||||
<p style={{ margin: "0 0 var(--space-2)" }}>Settings → Data imports each list from a CSV file. The rules for each:</p>
|
||||
<ul style={list}>
|
||||
{Object.entries(CSV_TEMPLATES).map(([k, t]) => <li key={k}><b>{t.name}</b> — {t.note}</li>)}
|
||||
</ul>
|
||||
<p style={{ margin: "var(--space-2) 0 0" }}>The Staff Register's Export writes the same columns, so a ward's list can go to its manager, come back with Manager number filled in, and be imported again. The Approver name column is only for checking and is ignored on import.</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Month-end journal">
|
||||
<p style={{ margin: 0 }}>One debit line per cost centre, priced at each garment's cost on the day it was issued. Finance posts the balancing credit.</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Who ThreadCount emails">
|
||||
<ul style={list}>
|
||||
<li>You — password resets, and updates you've subscribed to.</li>
|
||||
<li>Staff — only about their own requests, once they've set up the staff app.</li>
|
||||
<li>Managers — the link to approve or decline a request.</li>
|
||||
</ul>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { PageHead, Empty, LiveRegion } from "@/components/ui";
|
||||
import { BindDialog, HandInDialog, ReturnDialog, openSlip, printCreditSlip } from "@/components/dialogs";
|
||||
import Camera from "@/components/Camera";
|
||||
import { SET_GARMENTS, allowance, approvalRemaining, bcParse, capCheck, capState, ccOf, entUsed, fmtDate, garmentForGroup, UNIFORM_STYLE_EITHER, garmentForStyle, genderLabel, groupBucket, groupsLabel, heldByStaff, inBucket, initialGarments, initialRemaining, isKit, isNursing, isPantItem, isTopItem, key, label, longLabel, money, onhand, openApproval, plOf, setsCap, setsHeld, staffName, type GarmentCounts, type IssueRec } from "@/lib/compute";
|
||||
|
||||
// src null = both shelf and pre-loved stock exist, the coordinator must pick one.
|
||||
type CartLine = { itemId: string; si: number; qty: number; src: "stock" | "order" | "preloved" | null };
|
||||
|
||||
const count = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
|
||||
|
||||
/** Where somebody stands against the ceiling before anything goes in the bag — for the tag beside
|
||||
* their name, and the line under it in the search results.
|
||||
*
|
||||
* Asked of the rule by putting one more of each kind in front of it, rather than by comparing their
|
||||
* sets with six here. The ceiling bites on tops and on pairs separately, so somebody holding six
|
||||
* tops and two pairs is "two sets" and is still refused the next top; a tag that read their sets
|
||||
* told the coordinator OK, and the counter then turned the person away. `full` names each kind the
|
||||
* next one of would be refused. Past the ceiling already, anything at all would be, so nothing is
|
||||
* singled out. */
|
||||
function standing(held: GarmentCounts, capSets: number) {
|
||||
const refused = (adding: { tops?: number; pants?: number; other?: number }) => capState({ held, adding, capSets }).over;
|
||||
const over = refused({});
|
||||
const full = over ? [] : ([refused({ tops: 1 }) && "tops", refused({ pants: 1 }) && "pairs", refused({ other: 1 }) && "garments outside a set"].filter(Boolean) as string[]);
|
||||
const room = full.length ? `no room for more ${full.length > 1 ? `${full.slice(0, -1).join(", ")} or ${full[full.length - 1]}` : full[0]}` : "";
|
||||
return { over, full, room, tag: over ? "OVER" : full.length ? "AT LIMIT" : "OK" };
|
||||
}
|
||||
|
||||
export default function IssuePage() {
|
||||
const { s, mutate } = useSnap();
|
||||
const { L, byId, staffById } = useDerived();
|
||||
const [staffQ, setStaffQ] = useState("");
|
||||
const [selId, setSelId] = useState<string | null>(null);
|
||||
const [scan, setScan] = useState("");
|
||||
const [qaQ, setQaQ] = useState("");
|
||||
const [cart, setCart] = useState<CartLine[]>([]);
|
||||
const [override, setOverride] = useState(false);
|
||||
const [apDeduct, setApDeduct] = useState<number | null>(null);
|
||||
const [issueMsg, setIssueMsg] = useState("");
|
||||
const [cam, setCam] = useState(false);
|
||||
useEffect(() => { const h = () => { setCamMsg(""); setCam(true); }; window.addEventListener("tc-scan", h); return () => window.removeEventListener("tc-scan", h); }, []);
|
||||
const [camMsg, setCamMsg] = useState("");
|
||||
const [bind, setBind] = useState("");
|
||||
const [ret, setRet] = useState<IssueRec | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [handin, setHandin] = useState(false);
|
||||
|
||||
const sel = selId ? staffById[selId] : undefined;
|
||||
// An override is a coordinator's decision about one person and one bag, so the tick goes the
|
||||
// moment either of them changes. Left standing, a tick given for somebody past six rode along to
|
||||
// the next name clicked, and that person's ordinary collection went on the record as a rule
|
||||
// somebody bent. Cleared as the page draws rather than afterwards, so the new bag is never on
|
||||
// screen, even for an instant, with the old tick behind it.
|
||||
const bagKey = selId ? `${selId}|${cart.map((c) => `${c.itemId}:${c.si}:${c.qty}:${c.src}`).join(",")}` : "";
|
||||
const [tickedFor, setTickedFor] = useState(bagKey);
|
||||
if (tickedFor !== bagKey) { setTickedFor(bagKey); setOverride(false); }
|
||||
function addToCart(itemId: string, si: number) {
|
||||
setCart((c) => {
|
||||
const f = c.find((x) => x.itemId === itemId && x.si === si);
|
||||
if (f) return c.map((x) => x === f ? { ...x, qty: x.qty + 1 } : x);
|
||||
const oh = onhand(s, L, key(itemId, si)), pl = plOf(s, key(itemId, si));
|
||||
return [...c, { itemId, si, qty: 1, src: pl > 0 && oh >= 1 ? null : pl > 0 ? "preloved" : oh >= 1 ? "stock" : "order" }];
|
||||
});
|
||||
setIssueMsg("");
|
||||
}
|
||||
function handleScan(raw: string) {
|
||||
const p = bcParse(s, raw);
|
||||
if (!p) { setScan(""); setBind(raw.trim()); return; }
|
||||
addToCart(p.itemId, p.si); setScan("");
|
||||
}
|
||||
function camHit(raw: string) {
|
||||
const p = bcParse(s, raw);
|
||||
if (!p) { setCam(false); setBind(raw.trim()); return; }
|
||||
addToCart(p.itemId, p.si);
|
||||
setCamMsg("Added " + label(byId[p.itemId]) + " · " + byId[p.itemId].sizes[p.si] + " — keep scanning or press Done");
|
||||
}
|
||||
|
||||
const sq = staffQ.trim().toLowerCase();
|
||||
const matches = s.staff.filter((st) => !st.inactive).filter((st) => !sq || `${st.first} ${st.last}`.toLowerCase().includes(sq) || st.num.includes(sq)).slice(0, 6);
|
||||
const cartQtyAll = cart.reduce((t, c) => t + c.qty, 0);
|
||||
const nPl = cart.filter((c) => c.src === "preloved").reduce((t, c) => t + c.qty, 0);
|
||||
// Pre-loved lines are free, so they stay out of what the ward is charged and out of what a
|
||||
// manager's approval pays for. They are not out of the ceiling: six pre-loved tops fill a locker
|
||||
// exactly as six new ones do, which is why the whole cart goes to capCheck() below.
|
||||
const cartVal = cart.filter((c) => c.src !== "preloved").reduce((t, c) => t + c.qty * (byId[c.itemId]?.cost || 0), 0);
|
||||
const nStock = cart.filter((c) => c.src === "stock").reduce((t, c) => t + c.qty, 0);
|
||||
const nOrder = cart.filter((c) => c.src === "order").reduce((t, c) => t + c.qty, 0);
|
||||
const anyUnpicked = cart.some((c) => c.src === null);
|
||||
const used = sel ? entUsed(s, sel.id) : 0;
|
||||
// The one question the counter asks, worked out by the same function the server refuses with: after
|
||||
// this pickup, is this person still inside the six sets one person holds? Six at any time, every
|
||||
// group, whichever route it takes — what somebody has on their back and in their locker, never a figure
|
||||
// that starts again in July. This screen used to keep a private copy of the sum, and the day the
|
||||
// copy and the server disagreed the coordinator was asked for a tick the record then contradicted.
|
||||
//
|
||||
// The whole cart goes in, ordered-in and pre-loved lines with the rest, because all three end up on
|
||||
// the same person. Nothing here is a set count: the ceiling bites on tops and on trousers
|
||||
// separately, or twenty tops and one pair would read as one set and pass.
|
||||
const cap = useMemo(() => (sel ? capCheck(s, sel, cart.map((c) => ({ itemId: c.itemId, qty: c.qty }))) : null), [s, sel, cart]);
|
||||
// The same question with an empty bag: is this person already past what one person holds? Only an
|
||||
// override can have put them there, and the tag beside their name should say so rather than wait
|
||||
// for somebody to put a garment in the cart. Asked of the rule rather than worked out here, because
|
||||
// a locker of seven tops and two pairs is "two sets" by any count that isn't the rule's own.
|
||||
const capHeld = useMemo(() => (sel ? capCheck(s, sel, []) : null), [s, sel]);
|
||||
const selStanding = capHeld ? standing(capHeld, s.settings.capSets) : null;
|
||||
// Sets held for every person on the register, in one walk of the issues — the search results below
|
||||
// show it, and asking person by person makes a six-hundred-name register crawl.
|
||||
const heldAll = useMemo(() => heldByStaff(s), [s]);
|
||||
const capSets = setsCap(s.settings.capSets);
|
||||
// Their allowance counted in SETS, from the one function that owns that rule, so the counter says
|
||||
// what the wearer's own app says about the same person.
|
||||
const allow = useMemo(() => {
|
||||
if (!sel || !cap) return null;
|
||||
// What they hold comes from capCheck, so this screen counts a person's uniform once. The
|
||||
// facility's own figures go in with the question: left off, a site that issues four sets on
|
||||
// starting goes on telling everybody three. Both route answers go in: without the starting-kit
|
||||
// one, somebody whose group starts on a kit is told here that they start on nothing.
|
||||
return allowance({
|
||||
group: sel.group, held: cap.sets,
|
||||
nursing: isNursing(s, sel), kit: isKit(s, sel),
|
||||
capSets: s.settings.capSets, startingSets: s.settings.initialSets,
|
||||
});
|
||||
}, [s, sel, byId, cap]);
|
||||
// allowance() words its sentence about nobody in particular, so the counter can say it about the
|
||||
// person in front of it exactly as the wearer's own app says it to them.
|
||||
const allowNote = allow ? allow.note : "";
|
||||
// Garments of the starting kit this record still owes. What they are owed on starting, and no part
|
||||
// of what the counter refuses on: a new starter holds nothing and takes three sets, three is inside
|
||||
// six, and the head-room this figure used to be added to the year's tally for existed only to stop
|
||||
// somebody's own record turning their first collection into an override.
|
||||
const kitLeft = sel ? initialRemaining(s, sel) ?? 0 : 0;
|
||||
// Past what one person holds — one of the two things on this screen that asks for a tick. Nothing
|
||||
// else blocks the button: stamping an ordinary collection as an override taught the linen room to
|
||||
// tick the box without reading it, and that devalues every real one.
|
||||
const overCap = !!sel && !!cap && cap.over;
|
||||
// The other: garments in the bag that are not for this person's staff group. garmentForGroup() is
|
||||
// the question the server refuses with, and the sentence is worded as its refusal is. The same tick
|
||||
// lets either through; the server records a garment outside the group as that, never as the ceiling.
|
||||
const offItems = useMemo(() => (sel ? [...new Set(cart.map((c) => c.itemId))].map((id) => byId[id]).filter((it): it is NonNullable<typeof it> => !!it && !garmentForGroup(it, sel.group)) : []), [sel, cart, byId]);
|
||||
const offGroup = offItems.length > 0;
|
||||
const selGroup = (sel?.group || "").trim();
|
||||
const offLine = offGroup && sel ? `${offItems.map((it) => `${it.item} is for ${groupsLabel(it.groups)}`).join("; ")} — ${staffName(sel)} ${selGroup ? `is in ${selGroup}` : "has no staff group recorded"}.` : "";
|
||||
// And the third: garments in the bag that are not the cut this person is offered. garmentForStyle()
|
||||
// is the question the server refuses with, and the sentence is worded as its refusal is. Nothing is
|
||||
// ever named here for somebody left blank or set to Either — both are offered every cut — so this
|
||||
// line can only appear about a record a coordinator has set to Men's or Women's.
|
||||
const offStyleItems = useMemo(() => (sel ? [...new Set(cart.map((c) => c.itemId))].map((id) => byId[id]).filter((it): it is NonNullable<typeof it> => !!it && !garmentForStyle(it, sel.uniformStyle)) : []), [sel, cart, byId]);
|
||||
const offStyle = offStyleItems.length > 0;
|
||||
const styleLine = offStyle && sel ? `${offStyleItems.map((it) => `${it.item} is the ${genderLabel(it.gender)} cut`).join("; ")} — ${staffName(sel)} is set to ${sel.uniformStyle}.` : "";
|
||||
const needsTick = overCap || offGroup || offStyle;
|
||||
// The lead a coordinator checks against the person standing in front of them, worded the way the
|
||||
// server words it when it refuses the same pickup: what they have out now, and then the reason,
|
||||
// which comes from the rule itself rather than being worked out again here.
|
||||
//
|
||||
// What they hold includes what is on order for them or waiting to be collected, and nobody can see
|
||||
// a garment on order in a locker — so, as the refusal does, the lead says how much of it is still
|
||||
// to come, and only when some is. Somebody holding only garments outside a set is said to be
|
||||
// holding those, not to have nothing out: "nothing out" to a person wearing the fleece they were
|
||||
// issued is a sentence the coordinator can see is wrong.
|
||||
const capLead = useMemo(() => {
|
||||
if (!sel || !cap) return "";
|
||||
const inSets = cap.breach !== "other" && cap.tops + cap.pants > 0;
|
||||
const holds = inSets ? `${count(cap.tops, "top", "tops")} and ${count(cap.pants, "pair", "pairs")}` : `${count(cap.other, "garment", "garments")} outside a set`;
|
||||
const hasSome = inSets || cap.other > 0;
|
||||
const coming = inSets ? cap.owed.tops + cap.owed.pants : cap.owed.other;
|
||||
return `${staffName(sel)} ${hasSome ? `is holding ${holds}${coming ? `, ${coming} of them still to come` : ""}` : "has nothing out"}.`;
|
||||
}, [sel, cap]);
|
||||
const anyShort = cart.some((c) => (c.src === "stock" && c.qty > onhand(s, L, key(c.itemId, c.si))) || (c.src === "preloved" && c.qty > plOf(s, key(c.itemId, c.si))));
|
||||
const cannot = !sel || cart.length === 0 || anyShort || anyUnpicked || (needsTick && !override) || busy;
|
||||
|
||||
// Manager's approval, whichever route they are on: oldest with sets remaining.
|
||||
const ap = sel ? openApproval(s, sel.id) : undefined;
|
||||
const apRem = sel ? approvalRemaining(s, sel.id) : 0; // across all open approvals (draws down oldest-first)
|
||||
const cartTops = cart.reduce((t, c) => t + (isTopItem(byId[c.itemId]) && c.src !== "preloved" ? c.qty : 0), 0);
|
||||
const cartPants = cart.reduce((t, c) => t + (isPantItem(byId[c.itemId]) && c.src !== "preloved" ? c.qty : 0), 0);
|
||||
const apDefault = ap ? Math.min(apRem, Math.max(cartTops, cartPants)) : 0;
|
||||
const apN = apDeduct === null ? apDefault : Math.min(apDeduct, apRem);
|
||||
|
||||
// Repeat last issue: the person's most recent issue date, all non-returned lines that day.
|
||||
const lastSet = useMemo(() => {
|
||||
if (!sel) return [];
|
||||
const past = s.issues.filter((i) => i.staffId === sel.id && !i.returned).sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
|
||||
if (!past.length) return [];
|
||||
return past.filter((i) => i.date === past[0].date && byId[i.itemId] && !byId[i.itemId].archived);
|
||||
}, [s.issues, sel, byId]);
|
||||
|
||||
// Quick add: garments for the person's group and cut (or everything when searching), usual size
|
||||
// outlined. Both halves come from the shared helpers, so this list and the phone counter's agree.
|
||||
const profSizes = sel ? [sel.top, sel.pants].filter(Boolean).map(String) : [];
|
||||
const qaq = qaQ.trim().toLowerCase();
|
||||
const selBucket = sel ? groupBucket(sel.group) : "";
|
||||
const qaItems = useMemo(() => {
|
||||
const out: { it: (typeof s.catalog)[number]; rel: boolean }[] = [];
|
||||
for (const it of s.catalog) {
|
||||
if (it.archived) continue;
|
||||
if (qaq && !(it.item.toLowerCase().includes(qaq) || it.sku.toLowerCase().includes(qaq))) continue;
|
||||
const rel = !sel || (inBucket(it, selBucket || "All groups") && garmentForStyle(it, sel.uniformStyle));
|
||||
if (!qaq && !rel) continue;
|
||||
out.push({ it, rel });
|
||||
}
|
||||
return out.sort((a, b) => (b.rel ? 1 : 0) - (a.rel ? 1 : 0));
|
||||
}, [s.catalog, qaq, sel, selBucket]);
|
||||
const qaCap = qaq ? 14 : 10;
|
||||
const qaNote = qaItems.length > qaCap ? `Showing ${qaCap} of ${qaItems.length} — type to narrow.` : sel && !qaq ? `Showing items for ${selBucket || "their group"}${sel && sel.uniformStyle && sel.uniformStyle !== UNIFORM_STYLE_EITHER ? `, ${sel.uniformStyle} cut` : ""} — type to search everything.` : "";
|
||||
|
||||
async function doIssue() {
|
||||
if (cannot || !sel) return;
|
||||
setBusy(true);
|
||||
// Only the ticked box, and only while the box is on the screen. An override says somebody
|
||||
// knowingly bent a rule, so nothing but a person may set it, and only about the bag they were
|
||||
// shown: a tick left over from a pickup that has since come back inside six must not travel to
|
||||
// the record as a decision nobody made about this one.
|
||||
const r = await mutate<{ stock: number; ordered: number; preloved: number; apDeducted: number; apRemaining: number; offGroup?: number; offStyle?: number }>("issue.create", { staffId: sel.id, override: needsTick && override, apDeduct: ap ? apN : 0, lines: cart });
|
||||
setBusy(false);
|
||||
if (!r.ok) { setIssueMsg(r.error); return; }
|
||||
const parts = [];
|
||||
if (r.result.stock) parts.push(`issued ${r.result.stock} from stock — replenishment draft updated on Ordering`);
|
||||
if (r.result.ordered) parts.push(`ordered ${r.result.ordered} in (arrives to the pickup list)`);
|
||||
// Free to the ward, and still uniform this person is holding — so it is never said here that a
|
||||
// pre-loved garment doesn't count. It counts towards the six sets like anything else.
|
||||
if (r.result.preloved) parts.push(`${r.result.preloved} pre-loved (free — nothing charged to the ward)`);
|
||||
if (r.result.apDeducted) parts.push(`${r.result.apDeducted} set(s) off the manager's approval — ${r.result.apRemaining} remaining`);
|
||||
if (r.result.offGroup) parts.push(`${count(r.result.offGroup, "garment", "garments")} outside their staff group, on the override`);
|
||||
if (r.result.offStyle) parts.push(`${count(r.result.offStyle, "garment", "garments")} not their uniform style, on the override`);
|
||||
setCart([]); setOverride(false); setApDeduct(null);
|
||||
setIssueMsg(`Recorded for ${staffName(sel)}: ${parts.join(" · ")} (${money(cartVal)}). Print the receipt, get a signature, then tick “signed”.`);
|
||||
}
|
||||
// The slip is signed at the counter, so it has to say what actually crosses it. That is the shelf
|
||||
// and pre-loved lines together: pre-loved is free, which is why it stays out of what is charged, but
|
||||
// a free garment is still a garment the nurse walks away with and signs for. Ordered-in lines are
|
||||
// not on this slip at all — they are not in the bag today, and they get their own collection slip
|
||||
// off the pickup list when they arrive.
|
||||
const handed = cart.filter((c) => c.src === "stock" || c.src === "preloved");
|
||||
const slipData = () => ({
|
||||
staffName: staffName(sel), dept: sel?.dept, sets: handed.reduce((t, c) => t + c.qty, 0), po: "",
|
||||
// Itemised, so whoever signs can check the bag against the paper instead of trusting a total.
|
||||
lines: handed.map((c) => `${c.qty} × ${longLabel(byId[c.itemId])} — ${byId[c.itemId]?.sizes[c.si] ?? "?"}${c.src === "preloved" ? " (pre-loved)" : ""}`).join("\n"),
|
||||
dateReceived: s.today, requestedBy: sel?.num, deliveredBy: s.settings.coordinator, dateTime: s.today });
|
||||
const recent = useMemo(() => [...s.issues].filter((i) => !sel || i.staffId === sel.id).sort((a, b) => (b.createdAt > a.createdAt ? 1 : -1)).slice(0, 8), [s.issues, sel]);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<PageHead eyebrow="Counter" title="Issue Stock" />
|
||||
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "2fr 3fr", gap: "var(--space-6)" }}>
|
||||
<div>
|
||||
<div className="tc-panel">
|
||||
<div className="tc-panel-head">
|
||||
<div>1 · Staff member</div>
|
||||
{sel && <div className="tc-panel-aside">{sel.num}</div>}
|
||||
</div>
|
||||
{!sel ? (
|
||||
<>
|
||||
<div className="tc-panel-body" style={{ paddingBottom: 0 }}>
|
||||
<input className="input" style={{ width: "100%" }} aria-label="Search the staff register by name or staff number" placeholder="Search name or staff number" value={staffQ} onChange={(e) => setStaffQ(e.target.value)} autoFocus />
|
||||
</div>
|
||||
{s.staff.length === 0 && <div className="tc-panel-body"><Empty pad={3}>No staff on the register yet — add them on the Staff Register screen.</Empty></div>}
|
||||
{/* A real button, not a clickable row: this is step one of the counter's whole job, and
|
||||
a div with an onClick puts it out of reach of the keyboard, of switch access and of
|
||||
voice control. The styling is the row's, the semantics are the button's. */}
|
||||
<div className="tc-panel-list" style={{ marginTop: "var(--space-3)" }}>
|
||||
{/* The same standing as the tag once they are picked, so a name that reads fine here
|
||||
is not refused the moment somebody puts a top in the bag. */}
|
||||
{matches.map((st) => { const h = heldAll[st.id] || { tops: 0, pants: 0, other: 0, sets: 0 }; const g = standing(h, s.settings.capSets); return (
|
||||
<button type="button" key={st.id} className="tc-row" onClick={() => { setSelId(st.id); setIssueMsg(""); setApDeduct(null); }}>
|
||||
<span className="tc-row-main">
|
||||
<span className="tc-row-name" style={{ display: "block" }}>{st.first} {st.last} <span style={{ fontWeight: 400, color: "var(--color-neutral-700)" }}>{st.num}</span></span>
|
||||
<span className="tc-row-meta" style={{ display: "block" }}>{st.group} · {st.dept} · holds {h.sets} of {capSets} sets{g.tag === "OK" ? "" : ` — ${count(h.tops, "top", "tops")} and ${count(h.pants, "pair", "pairs")}${h.other ? `, ${h.other} outside a set` : ""} · ${g.over ? "past what one person holds" : g.room}`}</span>
|
||||
</span>
|
||||
</button>
|
||||
); })}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="tc-panel-body">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: "var(--space-2)" }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 18 }}>{staffName(sel)}</div>
|
||||
<div style={{ display: "flex", gap: "var(--space-1)" }}>
|
||||
<Link href={`/app/staff/${sel.id}`} className="btn btn-ghost">Profile</Link>
|
||||
<button className="btn btn-ghost" onClick={() => setHandin(true)}>Hand-in</button>
|
||||
<button className="btn btn-ghost" onClick={() => { setSelId(null); setApDeduct(null); }}>Change</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "var(--color-neutral-800)", marginTop: "var(--space-1)", lineHeight: 1.7 }}>
|
||||
<div>{sel.group}</div>
|
||||
<div>{sel.dept} · Cost centre {ccOf(s, sel) || "—"}</div>
|
||||
<div>Sizes: top {sel.top || "—"}, pants {sel.pants || "—"}</div>
|
||||
</div>
|
||||
{/* What they are holding, in sets and in the two halves a set is made of, because the
|
||||
ceiling bites on each half: somebody with six tops and two pairs is "two sets" and
|
||||
still cannot be handed a seventh top. The tag says what the counter will do with the
|
||||
next garment, not how many sets they have — AT LIMIT for six tops and two pairs, and
|
||||
the sentence names the half that is full. Both are asked of lib/sets, so this screen,
|
||||
the counter phone, the wearer's app and the server's own refusal cannot answer the
|
||||
same question differently. */}
|
||||
{capHeld && selStanding && (
|
||||
<div style={{ marginTop: "var(--space-3)", display: "flex", alignItems: "center", gap: "var(--space-2)" }}>
|
||||
<span className={selStanding.over ? "tag tag-accent" : selStanding.full.length ? "tag tag-outline" : "tag tag-neutral"}>{selStanding.tag}</span>
|
||||
<span style={{ fontSize: 13 }}>Holds {capHeld.sets} of {capHeld.cap} sets — {count(capHeld.tops, "top", "tops")} and {count(capHeld.pants, "pair", "pairs")}{capHeld.other > 0 ? `, plus ${capHeld.other} outside a set` : ""}{capHeld.owed.tops + capHeld.owed.pants + capHeld.owed.other > 0 ? `, ${capHeld.owed.tops + capHeld.owed.pants + capHeld.owed.other} of them still to come` : ""}.{selStanding.over ? " Past what one person holds, so anything more needs a hand-in first or a coordinator override." : selStanding.room ? ` ${selStanding.room[0].toUpperCase()}${selStanding.room.slice(1)} without a hand-in first or a coordinator override.` : ""}</span>
|
||||
</div>
|
||||
)}
|
||||
{allow && (
|
||||
<div style={{ marginTop: "var(--space-2)", fontSize: 13, lineHeight: 1.6 }}>
|
||||
<div style={{ fontWeight: 700 }}>{allowNote}</div>
|
||||
{kitLeft > 0 && <div style={{ color: "var(--color-accent-700)", fontWeight: 600 }}>Starting kit: {kitLeft} of {initialGarments(s, sel) ?? kitLeft} garments still to issue.</div>}
|
||||
{/* Kept where the linen room can see it, and labelled for what it is. It is the
|
||||
figure the register and the monthly report quote; nothing on this screen and
|
||||
nothing on the server turns anybody away on it. */}
|
||||
<div style={{ color: "var(--color-neutral-700)" }}>{used} garment{used === 1 ? "" : "s"} drawn since July — a running total for the reports, not a limit.</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Sets a manager has already signed off are credit waiting to be spent, so the block
|
||||
wears the same left rule as anything else on the screen that wants acting on. */}
|
||||
{ap && (
|
||||
<div className="tc-flag" style={{ marginTop: "var(--space-3)", border: "2px solid var(--color-text)", padding: "var(--space-2) var(--space-3)", fontSize: 13 }}>
|
||||
<div style={{ fontWeight: 700 }}>Manager's approval: {ap.sets - ap.used} of {ap.sets} sets remaining{apRem > ap.sets - ap.used ? ` (+${apRem - (ap.sets - ap.used)} on later approvals)` : ""}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>Approved {fmtDate(ap.date)} by {ap.by || "the manager"}</div>
|
||||
<button className="btn btn-ghost" style={{ marginTop: "var(--space-1)", minHeight: 26, padding: "2px 8px" }} onClick={() => printCreditSlip(s, sel, ap)}>Print credit slip</button>
|
||||
</div>
|
||||
)}
|
||||
{lastSet.length > 0 && (
|
||||
<button className="btn btn-secondary" style={{ marginTop: "var(--space-3)", width: "100%" }} onClick={() => { setCart(lastSet.map((i) => ({ itemId: i.itemId, si: i.si, qty: i.qty, src: "stock" as const }))); setIssueMsg(""); }}>
|
||||
Repeat last issue — {fmtDate(lastSet[0].date)} · {lastSet.reduce((t, i) => t + i.qty, 0)} items
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="tc-panel" style={{ marginTop: "var(--space-6)" }}>
|
||||
<div className="tc-panel-head">{sel ? "Their issue history" : "Recent issues"}</div>
|
||||
{recent.length === 0 && <div className="tc-panel-body"><Empty pad={3}>{sel ? "Nothing issued yet." : "No issues recorded yet."}</Empty></div>}
|
||||
<div className="tc-panel-list">
|
||||
{recent.map((i) => {
|
||||
const it = byId[i.itemId];
|
||||
return (
|
||||
<div key={i.id} className="tc-row" style={{ fontSize: 12, gap: "var(--space-2)" }}>
|
||||
<div className="tc-row-main">
|
||||
<div className="tc-row-name" style={{ whiteSpace: "nowrap" }}>{label(it)} · {it?.sizes[i.si]} ×{i.qty}</div>
|
||||
<div className="tc-row-meta">{fmtDate(i.date)} · {staffName(staffById[i.staffId], "—")}{i.override ? " · override" : ""}{i.offGroup ? " · outside their group" : ""}{i.offStyle ? " · not their style" : ""}{i.direct ? " · pickup" : ""}</div>
|
||||
</div>
|
||||
{i.returned ? <span className="tag tag-neutral" title={i.returned.cond}>Returned</span> : i.handedIn ? <span className="tag tag-outline">Handed in</span> : <button className="btn btn-ghost" onClick={() => setRet(i)}>Return</button>}
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 11, cursor: "pointer", whiteSpace: "nowrap" }}><input type="checkbox" checked={i.receipt} onChange={async (e) => { const r = await mutate("issue.receipt", { id: i.id, receipt: e.target.checked }); if (!r.ok) setIssueMsg(r.error); }} />signed</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="tc-panel">
|
||||
<div className="tc-panel-head">
|
||||
<div>2 · Scan items</div>
|
||||
<div className="tc-panel-aside">or tap a size below</div>
|
||||
</div>
|
||||
<div className="tc-panel-body" style={{ display: "flex", gap: "var(--space-2)" }}>
|
||||
<input className="input" style={{ flex: 1 }} aria-label="Scan a barcode to add it to the pickup" placeholder="Scan barcode, then Enter" value={scan} onChange={(e) => setScan(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && scan.trim()) handleScan(scan); }} />
|
||||
<button className="btn btn-ghost" onClick={() => { setCamMsg(""); setCam(true); }}>Camera</button>
|
||||
</div>
|
||||
<div style={{ borderTop: "1px solid var(--color-divider)" }}>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", alignItems: "center", padding: "var(--space-2)", borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<span className="tc-meta" style={{ flex: "none" }}>Quick add</span>
|
||||
<input className="input" style={{ flex: 1, minHeight: 28, padding: "2px 8px" }} aria-label="Filter the quick-add list" placeholder="Filter items…" value={qaQ} onChange={(e) => setQaQ(e.target.value)} />
|
||||
</div>
|
||||
{qaItems.length === 0 && <Empty pad={2}>{s.catalog.length === 0 ? "The catalogue is empty — import it in Settings → Data." : "No items match."}</Empty>}
|
||||
{qaItems.slice(0, qaCap).map(({ it }) => (
|
||||
<div key={it.id} style={{ display: "flex", gap: "var(--space-2)", alignItems: "center", padding: "var(--space-1) var(--space-2)", borderBottom: "1px solid var(--color-neutral-200)", flexWrap: "wrap" }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, width: 165, flex: "none", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={longLabel(it)}>{longLabel(it)}</div>
|
||||
<div style={{ display: "flex", gap: 4, flexWrap: "wrap", flex: 1, minWidth: 160, padding: "2px 0" }}>
|
||||
{it.sizes.map((sz, si) => {
|
||||
const inCart = cart.find((c) => c.itemId === it.id && c.si === si);
|
||||
const usual = profSizes.includes(String(sz));
|
||||
return <button key={si} className={"btn " + (inCart ? "btn-primary" : usual ? "btn-secondary" : "btn-ghost")} style={{ minHeight: 24, padding: "1px 7px", fontSize: 12 }} title={`${onhand(s, L, key(it.id, si))} on hand`} aria-label={`Add ${label(it)} size ${sz} — ${onhand(s, L, key(it.id, si))} on hand${inCart ? `, ${inCart.qty} in the pickup` : ""}`} onClick={() => addToCart(it.id, si)}>{String(sz)}{inCart ? ` ×${inCart.qty}` : ""}</button>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="tc-panel-foot" style={{ fontSize: 11, color: "var(--color-neutral-700)" }}>Tap a size to add it — tap again for +1. Outlined = their usual size, solid = in the cart; hover shows on-hand. {qaNote}</div>
|
||||
</div>
|
||||
<div className="tc-panel" style={{ marginTop: "var(--space-6)" }}>
|
||||
<div className="tc-panel-head">
|
||||
<div>3 · The pickup</div>
|
||||
{cart.length > 0 && <div className="tc-panel-aside">{cartQtyAll} item{cartQtyAll === 1 ? "" : "s"}</div>}
|
||||
</div>
|
||||
<div className="tc-panel-list">
|
||||
{cart.map((c, i) => {
|
||||
const it = byId[c.itemId]; const oh = onhand(s, L, key(c.itemId, c.si)); const pl = plOf(s, key(c.itemId, c.si));
|
||||
const setLine = (p: Partial<CartLine>) => setCart(cart.map((x, j) => j === i ? { ...x, ...p } : x));
|
||||
const note = c.src === "preloved" ? `${pl} pre-loved on hand` : c.src === "stock" ? `${oh} on hand` : c.src === null ? `${oh} on shelf · ${pl} pre-loved` : `order from ${it?.supplier || "supplier"} — lands in the pickup list when received`;
|
||||
const mine = sel && it ? [sel.top, sel.pants].filter(Boolean).map(String).filter((x) => it.sizes.map(String).includes(x)) : [];
|
||||
const sizeHint = it && mine.length && !mine.includes(String(it.sizes[c.si])) ? `Their usual size is ${mine.join(" / ")}` : "";
|
||||
const short = (c.src === "stock" && c.qty > oh) || (c.src === "preloved" && c.qty > pl);
|
||||
return (
|
||||
<div key={c.itemId + c.si} className={"tc-row" + (short || c.src === null ? " tc-flag" : "")} style={{ flexWrap: "wrap" }}>
|
||||
<div className="tc-row-main" style={{ minWidth: 200 }}>
|
||||
<div className="tc-row-name">{label(it)}</div>
|
||||
<div className="tc-row-meta">Size {it?.sizes[c.si]} · {c.src === "preloved" ? "free" : `${money(it?.cost || 0)} each`} · {note}</div>
|
||||
{sizeHint && <div style={{ fontSize: 12, color: "var(--color-accent-700)", fontWeight: 600, marginTop: 2 }}>{sizeHint}</div>}
|
||||
{/* A short line and an unpicked source both stop the issue being recorded, so each
|
||||
one says so in words on the line it belongs to — the tag carries its own mark,
|
||||
and the row carries the rule. */}
|
||||
{c.src === "stock" && c.qty > oh && <div style={{ marginTop: 2 }}><span className="tag tag-flag">Not enough on the shelf — switch to order in</span></div>}
|
||||
{c.src === "preloved" && c.qty > pl && <div style={{ marginTop: 2 }}><span className="tag tag-flag">Not enough in the pre-loved pool</span></div>}
|
||||
{c.src === null && <div style={{ fontSize: 11, color: "var(--color-accent-700)", fontWeight: 700, marginTop: 2 }}><span className="tc-mark" aria-hidden="true" />Both available — pick a source</div>}
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4, alignItems: "flex-end" }}>
|
||||
{/* Three mutually exclusive choices, so the group is named once and each button
|
||||
says whether it is the one in force — one <label> could not name all three. */}
|
||||
<div className="seg" role="group" aria-label={`Where ${label(it)} size ${it?.sizes[c.si]} comes from`}>
|
||||
<button className={"seg-opt" + (c.src === "stock" ? " btn-primary" : "")} aria-pressed={c.src === "stock"} onClick={() => setLine({ src: "stock" })}>From stock</button>
|
||||
{(pl > 0 || c.src === "preloved") && <button className={"seg-opt" + (c.src === "preloved" ? " btn-primary" : "")} aria-pressed={c.src === "preloved"} onClick={() => setLine({ src: "preloved" })}>Pre-loved ({pl})</button>}
|
||||
<button className={"seg-opt" + (c.src === "order" ? " btn-primary" : "")} aria-pressed={c.src === "order"} onClick={() => setLine({ src: "order" })}>Order in</button>
|
||||
</div>
|
||||
{c.src === "order" && <span style={{ fontSize: 11, color: "var(--color-neutral-700)" }} title="Supplier is set by the product">{it?.supplier || "Supplier not set on product"}</span>}
|
||||
</div>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "var(--space-1)" }}>
|
||||
<button className="btn btn-ghost" style={{ padding: "0 8px", minHeight: 26 }} aria-label={`One fewer ${label(it)} size ${it?.sizes[c.si]}`} onClick={() => setCart(c.qty <= 1 ? cart.filter((_, j) => j !== i) : cart.map((x, j) => j === i ? { ...x, qty: x.qty - 1 } : x))}>−</button>
|
||||
<span style={{ width: 24, textAlign: "center", fontWeight: 700 }}>{c.qty}</span>
|
||||
<button className="btn btn-ghost" style={{ padding: "0 8px", minHeight: 26 }} aria-label={`One more ${label(it)} size ${it?.sizes[c.si]}`} onClick={() => setLine({ qty: c.qty + 1 })}>+</button>
|
||||
</span>
|
||||
<button className="btn btn-ghost" aria-label={`Remove ${label(it)} size ${it?.sizes[c.si]} from the pickup`} onClick={() => setCart(cart.filter((_, j) => j !== i))}>Remove</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{cart.length === 0 && <div className="tc-panel-body"><Empty>Scan a barcode or choose an item to start a pickup.</Empty></div>}
|
||||
{/* The pickup that goes through, said in figures a coordinator can check against the pile on
|
||||
the counter rather than left as a button that simply doesn't complain. It stands where the
|
||||
red box used to: somebody collecting more than they expected — a new starter's kit, a
|
||||
fourth set for somebody on the starting kit — is owed the reason it is allowed, and making them
|
||||
sign that off as an override taught the linen room to tick the box without reading it. */}
|
||||
{sel && cap && cart.length > 0 && !overCap && (
|
||||
<div style={{ margin: "var(--space-4)", border: "2px solid var(--color-text)", padding: "var(--space-3) var(--space-4)", fontSize: 13 }}>
|
||||
<div style={{ fontWeight: 800 }}>Inside what one person holds{needsTick ? "" : " — no override needed"}</div>
|
||||
<div>{capLead} After this pickup: {cap.note}</div>
|
||||
</div>
|
||||
)}
|
||||
{/* One box and one tick for every reason, each reason said in its own words — as the
|
||||
server's refusal names all of them at once, because one tick answers all of them at
|
||||
once. The server records the ceiling, the staff group and the cut apart, so the report
|
||||
can tell them apart too. */}
|
||||
{needsTick && sel && cap && (
|
||||
<div className="tc-flag" style={{ margin: "var(--space-4)", border: "2px solid var(--color-text)", padding: "var(--space-3) var(--space-4)", fontSize: 13 }}>
|
||||
{overCap && (
|
||||
<>
|
||||
<div style={{ fontWeight: 800, color: "var(--color-accent-700)" }}><span className="tc-mark" aria-hidden="true" />Past what one person holds</div>
|
||||
<div>{capLead} {cap.note}</div>
|
||||
</>
|
||||
)}
|
||||
{offGroup && (
|
||||
<>
|
||||
<div style={{ fontWeight: 800, color: "var(--color-accent-700)", marginTop: overCap ? "var(--space-2)" : 0 }}><span className="tc-mark" aria-hidden="true" />Outside their staff group</div>
|
||||
<div>{offLine}</div>
|
||||
</>
|
||||
)}
|
||||
{offStyle && (
|
||||
<>
|
||||
<div style={{ fontWeight: 800, color: "var(--color-accent-700)", marginTop: overCap || offGroup ? "var(--space-2)" : 0 }}><span className="tc-mark" aria-hidden="true" />Not their uniform style</div>
|
||||
<div>{styleLine}</div>
|
||||
</>
|
||||
)}
|
||||
<label style={{ display: "flex", gap: "var(--space-2)", alignItems: "center", marginTop: "var(--space-2)", cursor: "pointer" }}><input type="checkbox" checked={override} onChange={() => setOverride(!override)} /> Issue anyway (coordinator override for past the ceiling, outside their group or not their uniform style, noted on the record)</label>
|
||||
</div>
|
||||
)}
|
||||
{ap && cart.length > 0 && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-2)", padding: "var(--space-3) var(--space-4) 0", fontSize: 13, flexWrap: "wrap" }}>
|
||||
<span>Deduct from the manager's approval:</span>
|
||||
<button className="btn btn-ghost" style={{ padding: "0 8px", minHeight: 24 }} aria-label="One fewer set off the manager's approval" onClick={() => setApDeduct(Math.max(0, apN - 1))} disabled={apN <= 0}>−</button>
|
||||
<b style={{ width: 20, textAlign: "center" }}>{apN}</b>
|
||||
<button className="btn btn-ghost" style={{ padding: "0 8px", minHeight: 24 }} aria-label="One more set off the manager's approval" onClick={() => setApDeduct(Math.min(apN + 1, apRem))} disabled={apN >= apRem}>+</button>
|
||||
<span style={{ color: "var(--color-neutral-700)" }}>sets ({apRem} remaining)</span>
|
||||
</div>
|
||||
)}
|
||||
{/* What the bag is worth, at the bottom of the bag. This is the figure the coordinator
|
||||
reads back before anyone signs, so it is the screen's figure, not a line of small
|
||||
print in a toolbar. */}
|
||||
<div className="tc-panel-foot" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", gap: "var(--space-3)", flexWrap: "wrap" }}>
|
||||
<div>
|
||||
<div className="tc-meta">{cartQtyAll} item{cartQtyAll === 1 ? "" : "s"} · to charge</div>
|
||||
<div className="tc-figure">{money(cartVal)}</div>
|
||||
<div style={{ fontSize: 11, color: "var(--color-neutral-700)" }}>{cart.length ? `${nStock} from stock${nPl > 0 ? ` · ${nPl} pre-loved (free)` : ""}${nOrder > 0 ? ` · ${nOrder} ordered in` : ""}` : ""}</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", flexWrap: "wrap" }}>
|
||||
<button className="btn btn-secondary" onClick={() => openSlip("collection", slipData())} disabled={cannot}>Collection slip</button>
|
||||
<button className="btn btn-secondary" onClick={() => openSlip("delivery", slipData())} disabled={cannot}>Delivery slip</button>
|
||||
<button className="btn btn-primary" onClick={doIssue} disabled={cannot}>Record issue</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LiveRegion msg={issueMsg} style={{ marginTop: "var(--space-3)", borderTop: "2px solid var(--color-text)", paddingTop: "var(--space-2)", fontSize: 13, fontWeight: 600 }} />
|
||||
</div>
|
||||
</div>
|
||||
{cam && <Camera onHit={camHit} message={camMsg} onClose={() => setCam(false)} />}
|
||||
{bind && <BindDialog code={bind} onClose={() => setBind("")} onBound={(itemId, si) => addToCart(itemId, si)} />}
|
||||
{ret && <ReturnDialog issue={ret} onClose={() => setRet(null)} />}
|
||||
{handin && sel && <HandInDialog staff={sel} onClose={() => setHandin(false)} onDone={(msg) => setIssueMsg(msg)} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { buildSnapshot } from "@/lib/snapshot";
|
||||
import { SnapshotProvider } from "@/lib/client";
|
||||
import Shell from "@/components/Shell";
|
||||
import Analytics from "@/components/Analytics";
|
||||
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 snap = await buildSnapshot(user);
|
||||
return (
|
||||
<SnapshotProvider snap={snap}>
|
||||
<Shell>{children}</Shell>
|
||||
<Analytics site="app" />
|
||||
<Helpdesk />
|
||||
</SnapshotProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { PageHead, Empty, Field, ItemSizePicker, KpiStrip, LiveRegion } from "@/components/ui";
|
||||
import { ReceiveDialog } from "@/components/dialogs";
|
||||
import { viewPhoto } from "@/lib/photo";
|
||||
import { 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;
|
||||
});
|
||||
});
|
||||
|
||||
if (!o) return <section><PageHead eyebrow="Supply · Order" title="Order not found" /><Empty><Link href="/app/orders">← 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)]; })));
|
||||
}
|
||||
const [mailMsg, setMailMsg] = useState("");
|
||||
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>
|
||||
<header className="tc-pagehead">
|
||||
<div>
|
||||
{/* Flush with the eyebrow under it: the button's own 14px of padding would otherwise
|
||||
indent the one thing on the band that has to line up with the order number. */}
|
||||
<Link href="/app/orders" className="btn btn-ghost" style={{ marginBottom: "var(--space-2)", marginLeft: -14 }}>← All orders</Link>
|
||||
<div className="eyebrow">Supply · Order</div>
|
||||
<h1 className="h1">{o.code}</h1>
|
||||
<div style={{ fontSize: 13, color: "var(--color-neutral-700)", marginTop: "var(--space-1)" }}>{forLabel} · {onScreen.supplier} · placed {fmtDate(o.date)}{o.replenish ? " · replenishment" : ""}{parent && <> · back order of <Link href={`/app/orders/${parent.id}`}>{parent.code}</Link></>}</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", alignItems: "center", flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||
{overdue && <span className="tag tag-flag">Overdue</span>}
|
||||
<span className={statusTag(o.status)}>{o.status}</span>
|
||||
{isAdmin && <a className="btn btn-ghost" href={`/print/supplier-order?id=${o.id}`} target="_blank" rel="noreferrer" title="The A4 sheet with the supplier's product codes">Order sheet</a>}
|
||||
<button className="btn btn-ghost" onClick={printPO}>Print order</button>
|
||||
<button className="btn btn-ghost" onClick={exportCsv}>CSV</button>
|
||||
{isAdmin && o.status !== "Draft" && o.status !== "Cancelled" && <button className="btn btn-ghost" onClick={emailSupplier} title="Email this order to the supplier's address from Settings › Suppliers">{mailMsg || "Email supplier"}</button>}
|
||||
<button className="btn btn-ghost" onClick={duplicate}>Duplicate</button>
|
||||
{isAdmin && ["Draft", "Ordered", "Back Order", "Shipped"].includes(o.status) && <button className="btn btn-ghost" onClick={async () => { if (confirm(`Cancel ${o.code}?`) && await flushQty()) act("order.status", { id: o.id, status: "Cancelled" }); }}>Cancel order</button>}
|
||||
{o.status === "Draft" && <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", "Back Order"].includes(o.status) && <button className="btn btn-secondary" onClick={() => act("order.status", { id: o.id, status: "Shipped" })}>Mark shipped</button>}
|
||||
{["Ordered", "Shipped", "Back Order"].includes(o.status) && <button className="btn btn-primary" onClick={async () => { if (await flushQty()) setRcv(true); }}>Receive delivery</button>}
|
||||
</div>
|
||||
</header>
|
||||
{/* The left rule is what says "something is wrong here" from across the room. The red on its
|
||||
own would be the same red the status tag beside it wears when an order is merely open. */}
|
||||
<LiveRegion tone="alert" className="notice" msg={err} style={{ marginTop: "var(--space-3)", color: "var(--color-accent-700)" }} />
|
||||
{/* A late delivery is the one thing on this order somebody has to act on, so the date is
|
||||
marked the way the rest of the app marks trouble rather than simply turning red — the
|
||||
status tag two inches above it is already red on every order that is merely open. */}
|
||||
<KpiStrip items={[
|
||||
{ val: money(total), label: "Order value", note: `${onScreen.lines.length} line${onScreen.lines.length === 1 ? "" : "s"} · delivered units at the invoiced cost` },
|
||||
{ val: `${got} of ${units}`, label: "Units received", note: units > 0 && got >= units ? "Everything ordered has arrived" : o.status === "Draft" ? "Not sent to the supplier yet" : "Receive a delivery to book the rest in" },
|
||||
{ val: onScreen.expected ? fmtDate(onScreen.expected) : "—", label: "Expected", flag: overdue, note: overdue ? `${daysBetween(onScreen.expected, s.today)} day${daysBetween(onScreen.expected, s.today) === 1 ? "" : "s"} overdue — ring ${onScreen.supplier}` : onScreen.expected ? "The date the supplier gave" : "No delivery date recorded" },
|
||||
]} />
|
||||
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "3fr 2fr", gap: "var(--space-8)", marginTop: "var(--space-6)", alignItems: "start" }}>
|
||||
<div>
|
||||
<div className="tc-panel">
|
||||
<div className="tc-panel-head">
|
||||
<span>Order details</span>
|
||||
<span className="tc-panel-aside">Changes save as you type</span>
|
||||
</div>
|
||||
<div className="tc-panel-body">
|
||||
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-3)" }}>
|
||||
{([["ref", "Supplier order no.", "text", "e.g. WWG-48211"], ["invoice", "Invoice no.", "text", "e.g. INV-102938"], ["tracking", "Tracking no.", "text", "e.g. 34XY990812"], ["expected", "Expected delivery", "date", ""]] as const).map(([k, lbl, type, ph]) => (
|
||||
<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 WWG re back order 12/8" value={val("notes")} onChange={(e) => saveField("notes", e.target.value)} />}</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="tc-panel" style={{ marginTop: "var(--space-6)" }}>
|
||||
<div className="tc-panel-head">
|
||||
<span>Lines</span>
|
||||
<span className="tc-panel-aside">{units} unit{units === 1 ? "" : "s"} ordered</span>
|
||||
</div>
|
||||
<div className="tc-panel-list">
|
||||
{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-row" style={{ flexWrap: "wrap", fontSize: 13 }}>
|
||||
<div className="tc-row-main" style={{ minWidth: 150 }}>
|
||||
<div className="tc-row-name">{label(it)}</div>
|
||||
<div className="tc-row-meta">size {l.size}{rec ? ` · received ${rec}` : ""}{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" ? (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "var(--space-1)" }}>
|
||||
<button className="btn btn-ghost" style={{ padding: "0 8px", minHeight: 24 }} aria-label={`One fewer ${label(it)} size ${l.size}`} onClick={() => l.qty > 1 ? bumpQty(l.id, l.qty, -1) : o.lines.length > 1 && removeLine(l.id)} disabled={l.qty <= 1 && o.lines.length <= 1}>−</button>
|
||||
<span style={{ width: 24, textAlign: "center", fontWeight: 700 }}>×{l.qty}</span>
|
||||
<button className="btn btn-ghost" style={{ padding: "0 8px", minHeight: 24 }} aria-label={`One more ${label(it)} size ${l.size}`} onClick={() => bumpQty(l.id, l.qty, 1)}>+</button>
|
||||
</span>
|
||||
) : <span>×{l.qty}</span>}
|
||||
<span>@ $</span>
|
||||
<input className="input" style={{ minHeight: 28, padding: "2px 8px", width: 70, textAlign: "right" }} inputMode="decimal" aria-label={`Unit cost of ${label(it)} size ${l.size}`} 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 className="btn btn-ghost" style={{ minHeight: 26, padding: "2px 8px" }} aria-label={`Remove ${label(it)} size ${l.size} from this order`} onClick={() => removeLine(l.id)}>Remove</button>}
|
||||
</div>
|
||||
<div className="tc-row-fig" style={{ minWidth: 76, textAlign: "right" }}>{money(l.qty * unit)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{o.status === "Draft" && (
|
||||
<div className="tc-panel-foot" style={{ display: "flex", gap: "var(--space-2)", alignItems: "center", flexWrap: "wrap" }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-neutral-700)", flex: "none" }}>Add 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>
|
||||
)}
|
||||
<div className="tc-panel-foot" style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: "var(--space-3)", flexWrap: "wrap" }}>
|
||||
<div style={{ fontSize: 11, color: "var(--color-neutral-700)" }}>{isAdmin ? "Editing a price updates that item’s catalogue cost everywhere." : "Prices are set by an admin."}</div>
|
||||
<div className="tc-figure">{money(total)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{backOrders.length > 0 && <div style={{ fontSize: 12, color: "var(--color-neutral-700)", marginTop: "var(--space-2)" }}>Back order{backOrders.length > 1 ? "s" : ""}: {backOrders.map((b) => <Link key={b.id} href={`/app/orders/${b.id}`} style={{ marginRight: 8 }}>{b.code}</Link>)}</div>}
|
||||
</div>
|
||||
<div>
|
||||
<div className="tc-panel">
|
||||
<div className="tc-panel-head"><span>History</span></div>
|
||||
<div className="tc-panel-list">
|
||||
{ev.map((e, i) => (
|
||||
<div key={i} className="tc-row" style={{ alignItems: "flex-start" }}>
|
||||
<div className="tc-meta" style={{ minWidth: 82, flex: "none", paddingTop: 2 }}>{e.date ? fmtDate(e.date) : "—"}</div>
|
||||
<div className="tc-row-main">
|
||||
<div style={{ fontWeight: 600 }}>{e.what}{e.photoId && <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-row-meta">{e.sub}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{st && (
|
||||
<div className="tc-panel" style={{ marginTop: "var(--space-6)" }}>
|
||||
<div className="tc-panel-head"><span>Staff member</span></div>
|
||||
<div className="tc-panel-body" style={{ fontSize: 13, lineHeight: 1.7 }}>
|
||||
<div style={{ fontWeight: 600 }}><Link href={`/app/staff/${st.id}`} className="link-name">{staffName(st)}</Link> <span style={{ fontWeight: 400, color: "var(--color-neutral-700)" }}>{st.num}</span></div>
|
||||
<div>{st.dept} · {st.phone || "no phone"}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && o.status === "Draft" && o.replenish && (
|
||||
<div style={{ marginTop: "var(--space-4)", fontSize: 12, color: "var(--color-neutral-700)" }}>This draft grows as stock is issued — mark it ordered when you send it to {onScreen.supplier}.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{rcv && <ReceiveDialog order={onScreen} onClose={() => { setRcv(false); router.refresh(); }} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import OrderList from "@/components/OrderList";
|
||||
|
||||
export const metadata = { title: "Order list" };
|
||||
|
||||
export default function OrderListPage() {
|
||||
return <OrderList />;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { PageHead, Empty, KpiStrip, Seg } from "@/components/ui";
|
||||
import { NewOrderDialog } from "@/components/dialogs";
|
||||
import { ccOfOrder, csvOf, daysBetween, fmtDate, isOpen, isOverdue, isPlacedOpen, label, money, onhand, orderTotal, reorderAt, staffName, statusTag, touched } from "@/lib/compute";
|
||||
import { downloadCsv } from "@/lib/print";
|
||||
|
||||
const STATUSES = ["All", "Draft", "Open", "Received"] as const;
|
||||
|
||||
export default function OrdersPage() {
|
||||
const { s, mutate, isAdmin } = useSnap();
|
||||
const [flagMsg, setFlagMsg] = useState("");
|
||||
const { L, byId, staffById, variants } = useDerived();
|
||||
const [dlg, setDlg] = useState<null | "new">(null);
|
||||
const [q, setQ] = useState("");
|
||||
const [sup, setSup] = useState("All suppliers");
|
||||
const [status, setStatus] = useState<(typeof STATUSES)[number]>("All");
|
||||
const [from, setFrom] = useState("");
|
||||
const [to, setTo] = useState("");
|
||||
const [item, setItem] = useState("");
|
||||
|
||||
const suggested = useMemo(() => {
|
||||
const out: { itemId: string; size: string; lbl: string; oh: number; ro: number; sugg: number }[] = [];
|
||||
for (const v of variants) {
|
||||
const oh = onhand(s, L, v.key), ro = reorderAt(s, v.key);
|
||||
if (!touched(s, L, v.key) || oh > ro) continue;
|
||||
const onOrder = s.orders.some((o) => isOpen(o) && o.lines.some((l) => l.itemId === v.itemId && l.size === v.size));
|
||||
if (!onOrder) out.push({ itemId: v.itemId, size: v.size, lbl: label(v.item), oh, ro, sugg: Math.max(ro * 2 - oh, 1) });
|
||||
}
|
||||
return out;
|
||||
}, [s, L, variants]);
|
||||
|
||||
const kpi = useMemo(() => {
|
||||
const drafts = s.orders.filter((o) => o.status === "Draft").length;
|
||||
const open = s.orders.filter(isPlacedOpen);
|
||||
const overdue = s.orders.filter((o) => isOverdue(o, s.today)).length;
|
||||
const monthVal = s.orders.filter((o) => o.status === "Received" && o.received.slice(0, 7) === s.today.slice(0, 7)).reduce((t, o) => t + orderTotal(o, byId), 0);
|
||||
return { drafts, open: open.length, openVal: open.reduce((t, o) => t + orderTotal(o, byId), 0), overdue, monthVal };
|
||||
}, [s, byId]);
|
||||
|
||||
const supOpts = ["All suppliers", ...new Set(s.orders.map((o) => o.supplier).filter(Boolean))];
|
||||
// Garments that appear on any order, for the item filter.
|
||||
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 [...ids].map((id) => byId[id]).filter(Boolean).sort((a, b) => label(a).localeCompare(label(b))); }, [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 !== "All suppliers" && o.supplier !== sup) return false;
|
||||
if (status === "Draft" && o.status !== "Draft") return false;
|
||||
if (status === "Open" && !isPlacedOpen(o)) return false;
|
||||
if (status === "Received" && o.status !== "Received") return false;
|
||||
if (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 !== "All suppliers" || status !== "All" || from !== "" || to !== "" || item !== "";
|
||||
/* This list is the view somebody reads to answer "what is still outstanding?" or "what did we
|
||||
order this quarter?", and the only way to get any of it out of ThreadCount was to open one
|
||||
order at a time and export each. The file is the rows on screen — the search, the supplier and
|
||||
the status tab all apply — because a coordinator who has narrowed to one supplier means that
|
||||
supplier, not three years of ordering.
|
||||
|
||||
Value is orderTotal(), never quantity times catalogue price. orderTotal prices what has already
|
||||
been delivered at the cost the delivery was invoiced at and only the rest at today's catalogue
|
||||
price; multiplying it out here would mean the finance spreadsheet disagreed with the order
|
||||
screen, Reports and the dashboard the moment an admin edited a price — the exact fault that was
|
||||
just fixed everywhere else.
|
||||
|
||||
Dates go out in the stored form (2026-09-11), not as the screen prints them, so a spreadsheet
|
||||
sorts and filters them as dates. Order notes are left out: they carry remarks for the linen
|
||||
room — a supplier dispute, a substitution offered — they are not on this screen, and the screen
|
||||
is the limit of what Export hands over. */
|
||||
function exportCsv() {
|
||||
// Ordered for and Staff member are split apart because the screen's single line ("For stock",
|
||||
// "For Jane Doe") can't be filtered on in a spreadsheet. Units ordered against Units received is
|
||||
// what makes a part-delivered order visible in the file, the way the Overdue tag makes a late
|
||||
// one visible here.
|
||||
const cols = ["Order no.", "Status", "Ordered", "Supplier", "Ordered for", "Staff member", "Supplier ref", "Invoice", "Tracking", "Cost centre", "Replenishment", "Expected", "Days overdue", "Received", "Lines", "Units ordered", "Units received", "Value"];
|
||||
downloadCsv(`threadcount-orders-${s.today}.csv`, csvOf(cols, orders.map((o) => {
|
||||
const st = o.staffId ? staffById[o.staffId] : undefined;
|
||||
const units = o.lines.reduce((t, l) => t + l.qty, 0);
|
||||
const got = o.receipts.reduce((t, rc) => t + rc.lines.reduce((n, l) => n + l.qty, 0), 0);
|
||||
return [o.code, o.status, o.date, o.supplier, o.orderFor === "Stock" ? "Stock" : "Staff member", staffName(st), o.ref, o.invoice, o.tracking, ccOfOrder(s, o, staffById), o.replenish ? "Yes" : "No", o.expected, isOverdue(o, s.today) ? daysBetween(o.expected, s.today) : "", o.received, o.lines.length, units, got, +orderTotal(o, byId).toFixed(2)];
|
||||
})));
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<PageHead eyebrow="Supply" title="Ordering">
|
||||
<button className="btn btn-ghost" onClick={exportCsv} disabled={orders.length === 0} title="Downloads the orders shown, with the filters applied.">{narrowed ? `Export CSV (${orders.length} shown)` : "Export CSV"}</button>
|
||||
{isAdmin && <Link href="/app/orders/list" className="btn btn-secondary">Order list</Link>}
|
||||
<button className="btn btn-primary" onClick={() => setDlg("new")}>New order</button>
|
||||
</PageHead>
|
||||
{/* Every open order on this screen already wears the brand red on its status tag, so a red
|
||||
figure on its own would say nothing here. Overdue earns the rule and the mark instead, and
|
||||
only when something is actually late. */}
|
||||
<KpiStrip items={[
|
||||
{ val: kpi.drafts, label: "Drafts to send", note: "Nothing reaches a supplier until it is marked ordered" },
|
||||
{ val: kpi.open, label: "Awaiting delivery", note: `${money(kpi.openVal)} on order` },
|
||||
{ val: kpi.overdue, label: "Overdue", flag: kpi.overdue > 0, note: kpi.overdue > 0 ? "Past the date the supplier gave" : "Everything open is still within its expected date" },
|
||||
{ val: money(kpi.monthVal), label: "Received this month", note: "Delivered stock, priced as invoiced" },
|
||||
]} />
|
||||
<div style={{ display: "flex", gap: "var(--space-3)", alignItems: "center", marginTop: "var(--space-4)", flexWrap: "wrap" }}>
|
||||
<input className="input" style={{ width: 240 }} aria-label="Search orders by number, reference or invoice" placeholder="Search order no., ref, invoice…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<select className="input" style={{ width: 180 }} aria-label="Supplier" value={sup} onChange={(e) => setSup(e.target.value)}>{supOpts.map((o) => <option key={o}>{o}</option>)}</select>
|
||||
<select className="input" style={{ width: 200 }} aria-label="Garment" value={item} onChange={(e) => setItem(e.target.value)}><option value="">All garments</option>{itemOpts.map((it) => <option key={it.id} value={it.id}>{label(it)}</option>)}</select>
|
||||
<input className="input" style={{ width: 150 }} type="date" aria-label="Ordered from" value={from} onChange={(e) => setFrom(e.target.value)} />
|
||||
<input className="input" style={{ width: 150 }} type="date" aria-label="Ordered to" value={to} onChange={(e) => setTo(e.target.value)} />
|
||||
<span role="group" aria-label="Order status"><Seg opts={STATUSES} value={status} onChange={setStatus} /></span>
|
||||
</div>
|
||||
{suggested.length > 0 && (
|
||||
/* Stock at or below its reorder level is the one thing on this screen that has to be acted
|
||||
on today, so it is marked the way everything else that wants attention is marked — the
|
||||
rule down the edge and the mark beside the word — rather than by being the only red box
|
||||
on a screen that already has red status tags on it. */
|
||||
<div className="tc-panel tc-flag" style={{ marginTop: "var(--space-6)" }}>
|
||||
<div className="tc-panel-head">
|
||||
<span><span className="tc-mark" aria-hidden="true" />Suggested order</span>
|
||||
<span className="tc-panel-aside"><Link href="/app/orders/list">{suggested.length} line{suggested.length === 1 ? "" : "s"} at or below reorder level</Link></span>
|
||||
</div>
|
||||
<div className="tc-panel-list">
|
||||
{suggested.slice(0, 10).map((x, i) => (
|
||||
<div key={i} className="tc-row">
|
||||
<div className="tc-row-main">
|
||||
<div className="tc-row-name">{x.lbl} · {x.size}</div>
|
||||
<div className="tc-row-meta">on hand {x.oh} · reorder at {x.ro}</div>
|
||||
</div>
|
||||
<div className="tc-row-fig">+{x.sugg}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="tc-panel-foot" style={{ display: "flex", gap: "var(--space-3)", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap" }}>
|
||||
<div style={{ fontSize: 12, color: "var(--color-neutral-700)", flex: 1, minWidth: 240 }}>
|
||||
{suggested.length > 10 && <>+{suggested.length - 10} more lines. </>}
|
||||
Adds each line to its supplier's replenishment draft, topping up to 2× the reorder level and netting off stock already on order.{flagMsg && <b style={{ color: "var(--color-accent-700)" }}> {flagMsg}</b>}
|
||||
</div>
|
||||
<button className="btn btn-secondary" onClick={async () => { const r = await mutate<{ added: number }>("stock.orderFlagged", {}); setFlagMsg(!r.ok ? r.error : r.result.added ? `${r.result.added} line${r.result.added === 1 ? "" : "s"} added to draft supplier order(s) below.` : "Everything flagged already has enough on order."); }}>Add to supplier drafts</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="tc-panel" style={{ marginTop: "var(--space-6)" }}>
|
||||
<div className="tc-panel-head">
|
||||
<span>Orders</span>
|
||||
<span className="tc-panel-aside">{orders.length} of {s.orders.length} orders{orders.length > 0 ? " · Export CSV writes these" : ""}</span>
|
||||
</div>
|
||||
{orders.length === 0 && <div className="tc-panel-body"><Empty pad={2}>{s.orders.length === 0 ? "No orders yet." : "No orders match this filter."}</Empty></div>}
|
||||
<div className="tc-panel-list">
|
||||
{orders.map((o) => {
|
||||
const st = o.staffId ? staffById[o.staffId] : undefined;
|
||||
const overdue = isOverdue(o, s.today);
|
||||
/* "1 days overdue" is the kind of thing that makes a screen look unfinished, and a date
|
||||
printed for today or yesterday makes you do arithmetic to work out what it means. The
|
||||
two nearest days get named instead; anything further out is a count of days, plural
|
||||
only when it is one. */
|
||||
const late = overdue ? daysBetween(o.expected, s.today) : 0;
|
||||
const due = overdue
|
||||
? late === 1 ? "due yesterday" : `${late} days overdue`
|
||||
: isPlacedOpen(o) && o.expected
|
||||
? o.expected === s.today ? "due today" : `due ${fmtDate(o.expected)}`
|
||||
: "";
|
||||
return (
|
||||
<Link key={o.id} href={`/app/orders/${o.id}`} className={"tc-row" + (overdue ? " tc-flag" : "")} style={{ flexWrap: "wrap" }}>
|
||||
<div className="tc-row-main" style={{ minWidth: 200 }}>
|
||||
<div className="tc-row-name">{o.code}</div>
|
||||
<div className="tc-row-meta">
|
||||
{o.orderFor === "Stock" ? "For stock" : "For " + staffName(st, "staff member")} · {o.supplier} · {fmtDate(o.date)}{o.ref ? " · ref " + o.ref : ""}
|
||||
{/* No mark here: the Overdue tag across the row already carries one, and the
|
||||
same signal twice on one line reads as two different problems. */}
|
||||
{due && <> · <span style={{ color: "var(--color-accent-700)", fontWeight: 600 }}>{due}</span></>}
|
||||
</div>
|
||||
</div>
|
||||
{o.replenish && <span className="tag tag-outline">Replenishment</span>}
|
||||
{overdue && <span className="tag tag-flag">Overdue</span>}
|
||||
<span className={statusTag(o.status)}>{o.status}</span>
|
||||
<div className="tc-row-fig" style={{ minWidth: 80, textAlign: "right" }}>{money(orderTotal(o, byId))}</div>
|
||||
<span aria-hidden="true" style={{ fontSize: 12, color: "var(--color-neutral-600)" }}>→</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{dlg === "new" && <NewOrderDialog onClose={() => setDlg(null)} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { PageHead, Empty, ErrorLine, KpiStrip } from "@/components/ui";
|
||||
import { NewOrderDialog, openSlip } from "@/components/dialogs";
|
||||
import Checklist from "@/components/Checklist";
|
||||
import { capState, daysBetween, fyStart, heldByStaff, isOpen, isOverdue, label, money, onhand, orderTotal, reorderAt, setsCap, staffName, touched, telHref, type GarmentCounts } from "@/lib/compute";
|
||||
|
||||
/** Somebody with nothing out and nothing owed. heldByStaff only lists people holding something, and
|
||||
* they have to be read as holding none rather than skipped. */
|
||||
const NOTHING: GarmentCounts = { tops: 0, pants: 0, other: 0, sets: 0 };
|
||||
|
||||
export default function Dashboard() {
|
||||
const { s, mutate } = useSnap();
|
||||
const { L, byId, staffById, variants } = useDerived();
|
||||
const [newOrder, setNewOrder] = useState(false);
|
||||
const [welcome, setWelcome] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
useEffect(() => { if (new URLSearchParams(window.location.search).get("welcome") === "1") setWelcome(true); }, []);
|
||||
|
||||
// The call list is the one place two people work the same rows at once — one marks a bag
|
||||
// collected at the counter while somebody else is still on the phone about it. A refusal there
|
||||
// has to be said out loud, or the second click reads as the first one not having taken.
|
||||
async function act(op: string, payload: unknown) { setErr(""); const r = await mutate(op, payload); if (!r.ok) setErr(r.error); }
|
||||
|
||||
const d = useMemo(() => {
|
||||
const openOrders = s.orders.filter(isOpen);
|
||||
const overdue = s.orders.filter((o) => isOverdue(o, s.today));
|
||||
const pickupQ = s.pickups.filter((p) => !p.pickedUp);
|
||||
const wait14 = pickupQ.filter((p) => daysBetween(p.received, s.today) >= 14);
|
||||
// Spend = orders actually placed this month (drafts, incl. auto-replenishment, are not spend yet).
|
||||
const mtd = s.orders.filter((o) => o.date.slice(0, 7) === s.today.slice(0, 7) && o.status !== "Cancelled" && o.status !== "Draft" && !o.parentId).reduce((t, o) => t + orderTotal(o, byId), 0);
|
||||
const lowRows: { label: string; size: string; onhand: number; reorder: number }[] = [];
|
||||
for (const v of variants) { const oh = onhand(s, L, v.key), ro = reorderAt(s, v.key); if (touched(s, L, v.key) && oh <= ro) lowRows.push({ label: label(v.item), size: v.size, onhand: oh, reorder: ro }); }
|
||||
// Who holds more than one person holds: what is out with them and what is owed to them, against
|
||||
// six sets at any time. Asked exactly the way the staff register asks it, so the two screens
|
||||
// always give the same count. It is not what anybody drew this year. By that measure a new
|
||||
// starter handed three sets on Monday has had a year's worth and is nowhere near the ceiling, and
|
||||
// a tile calling her over sends a coordinator after somebody the counter would serve without a
|
||||
// second look.
|
||||
//
|
||||
// There is no "nearly there" count beside it. Holding the full six is where somebody fully kitted
|
||||
// is meant to be, not a warning, so on a settled ward it would be most of the ward.
|
||||
const held = heldByStaff(s);
|
||||
const over = s.staff.filter((st) => !st.inactive && capState({ held: held[st.id] || NOTHING, capSets: s.settings.capSets }).over).length;
|
||||
const cap = setsCap(s.settings.capSets);
|
||||
const fy = fyStart(s.today);
|
||||
return { openOrders, overdue, pickupQ, wait14, mtd, lowRows, over, cap, fy };
|
||||
}, [s, L, byId, variants]);
|
||||
|
||||
/* The tiles are the read from the doorway: four things somebody has to act on today. Each one is
|
||||
either quiet or flagged, and a flagged tile says so three ways — a rule down its edge, a mark
|
||||
against the figure, and a note in plain words — because the vermilion is already the brand
|
||||
colour on the rail and on every primary button, and a second red across the room is a guess
|
||||
rather than a signal.
|
||||
|
||||
Nothing on a tile is repeated in the registers below it. A figure printed twice on one screen is
|
||||
two chances to disagree, and the linen room reads whichever one it happened to land on. */
|
||||
const tiles = [
|
||||
{ label: "Awaiting pickup", val: d.pickupQ.length, note: d.wait14.length ? `${d.wait14.length} waiting a fortnight or more` : "bags received and not collected", flag: d.wait14.length > 0 },
|
||||
{ label: "Overdue for delivery", val: d.overdue.length, note: d.overdue.length ? "past the date the supplier gave" : "nothing past its delivery date", flag: d.overdue.length > 0 },
|
||||
{ label: "Lines at reorder", val: d.lowRows.length, note: d.lowRows.length ? "at or below their reorder level" : "every line above its reorder level", flag: d.lowRows.length > 0 },
|
||||
{ label: "Over the ceiling", val: d.over, note: d.over ? `past the ${d.cap} sets one person holds` : `nobody past the ${d.cap} sets one person holds`, flag: d.over > 0 },
|
||||
];
|
||||
|
||||
// The facts that are not a task: true of the facility, worth a glance, never the reason somebody
|
||||
// walks to the counter. Three registers, one per part of the job.
|
||||
const registers: { title: string; rows: [string, React.ReactNode][] }[] = [
|
||||
{ title: "Orders", rows: [["Open orders", d.openOrders.length], ["Month-to-date spend", money(d.mtd)]] },
|
||||
{ title: "Stock", rows: [
|
||||
["Pre-loved pool", (() => { let u = 0, sz = 0; for (const k in s.stock) if (s.stock[k].preloved > 0) { u += s.stock[k].preloved; sz++; } return u ? `${u} across ${sz} size${sz === 1 ? "" : "s"}` : "0"; })()],
|
||||
["Issues recorded (FY)", s.issues.filter((i) => i.date >= d.fy).length],
|
||||
["Stocktake adjustments", Object.values(s.stock).filter((x) => x.adj).length],
|
||||
] },
|
||||
// "Receipts not yet signed" reads handedIn as well as returned: a garment handed back at the
|
||||
// counter is stamped handedIn and never gets a returned date, so it can never be signed for.
|
||||
// Counting those made the figure a queue that only ever grew, which is how a number the linen
|
||||
// room is meant to work down stops being read at all.
|
||||
{ title: "Staff", rows: [["On the register", s.staff.filter((st) => !st.inactive).length], ["Receipts not yet signed", s.issues.filter((i) => !i.receipt && !i.returned && !i.handedIn).length]] },
|
||||
];
|
||||
|
||||
const tasks = d.pickupQ.map((p) => ({ p, st: staffById[p.staffId], days: daysBetween(p.received, s.today) })).sort((a, b) => b.days - a.days);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<PageHead eyebrow="Overview" title="Dashboard">
|
||||
<Link href="/app/issue" className="btn btn-primary">Issue stock</Link>
|
||||
<button className="btn btn-secondary" onClick={() => setNewOrder(true)}>New order</button>
|
||||
</PageHead>
|
||||
{/* The first-run checklist: six things a new room does once, ticked off from the records
|
||||
themselves. It goes on its own when every step is done or the room is two months old
|
||||
with most of them done, and an admin can put it away sooner. */}
|
||||
<Checklist welcome={welcome} onDismissWelcome={() => setWelcome(false)} />
|
||||
<KpiStrip items={tiles} />
|
||||
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "3fr 2fr", gap: "var(--space-6)", marginTop: "var(--space-6)" }}>
|
||||
<div className="tc-panel">
|
||||
<div className="tc-panel-head">
|
||||
<div>Awaiting pickup — call list</div>
|
||||
<div className="tc-panel-aside">sorted by days waiting</div>
|
||||
</div>
|
||||
{/* ErrorLine draws nothing when there is nothing to say, so this wrapper collapses with it
|
||||
rather than opening a gap above the first row. */}
|
||||
<div style={{ padding: "0 var(--space-4)" }}><ErrorLine msg={err} /></div>
|
||||
{tasks.length === 0 && <div className="tc-panel-body"><Empty pad={2}>Nothing waiting to be collected.</Empty></div>}
|
||||
<div className="tc-panel-list">
|
||||
{tasks.map(({ p, st, days }) => {
|
||||
const ord = s.orders.find((o) => o.id === p.orderId);
|
||||
const late = days >= 14;
|
||||
return (
|
||||
<div key={p.id} className={"tc-row" + (late ? " tc-flag" : "")} style={{ flexWrap: "wrap" }}>
|
||||
<div className="tc-row-fig" style={{ width: 48, flex: "none" }}>{days}d</div>
|
||||
<div className="tc-row-main">
|
||||
<div className="tc-row-name">{staffName(st, "Staff")} {telHref(st?.phone) ? <a href={telHref(st?.phone)} style={{ fontWeight: 400, color: "var(--color-neutral-700)" }}>{st?.phone}</a> : <span style={{ fontWeight: 400, color: "var(--color-neutral-700)" }}>{st?.phone}</span>}</div>
|
||||
<div className="tc-row-meta">{late && <span className="tc-mark" aria-hidden="true" />}{late ? "Waiting a fortnight or more · " : ""}{p.lines.map((l) => `${label(byId[l.itemId])} ${l.size} ×${l.qty}`).join(", ")} · {p.orderCode}</div>
|
||||
</div>
|
||||
{p.contacted ? <span className="tag tag-neutral">Contacted</span> : <button className="btn btn-ghost" onClick={() => act("pickup.contacted", { id: p.id })}>Mark contacted</button>}
|
||||
<button className="btn btn-ghost" onClick={() => openSlip("collection", { staffName: staffName(st), dept: st?.dept, sets: p.lines.reduce((t, l) => t + l.qty, 0), po: ord?.ref || ord?.code || "", dateReceived: p.received, notifiedPhone: p.contacted, dateNotified: "" })}>Collection slip</button>
|
||||
<button className="btn btn-secondary" onClick={() => act("pickup.pickedUp", { id: p.id })}>Picked up</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="tc-panel">
|
||||
{/* Every row in here is by definition at its reorder level, so a rule down all of them
|
||||
would mark nothing. The rule is kept for the sizes that are actually empty — a nurse
|
||||
at the counter can be handed a low size and cannot be handed none. */}
|
||||
<div className="tc-panel-head">
|
||||
<div>Reorder flags</div>
|
||||
{d.lowRows.length > 0 && <div className="tc-panel-aside">{d.lowRows.length} line{d.lowRows.length === 1 ? "" : "s"}</div>}
|
||||
</div>
|
||||
{d.lowRows.length === 0 && <div className="tc-panel-body"><Empty pad={2}>No stock lines at or below reorder level.</Empty></div>}
|
||||
<div className="tc-panel-list">
|
||||
{d.lowRows.slice(0, 12).map((r, i) => (
|
||||
<div key={i} className={"tc-row" + (r.onhand <= 0 ? " tc-flag" : "")}>
|
||||
<div className="tc-row-main">
|
||||
<div className="tc-row-name" style={{ whiteSpace: "nowrap" }}>{r.label} · {r.size}</div>
|
||||
<div className="tc-row-meta">{r.onhand <= 0 && <span className="tc-mark" aria-hidden="true" />}{r.onhand <= 0 ? "None on the shelf · " : ""}re-order at {r.reorder}</div>
|
||||
</div>
|
||||
<div className="tc-row-fig">{r.onhand}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{d.lowRows.length > 12 && <div className="tc-panel-foot" style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>+{d.lowRows.length - 12} more — use <Link href="/app/stock">Order flagged</Link> on Inventory.</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "var(--space-6)", marginTop: "var(--space-6)" }}>
|
||||
{registers.map((g) => (
|
||||
<div key={g.title} className="tc-panel">
|
||||
<div className="tc-panel-head">{g.title}</div>
|
||||
<div className="tc-panel-list">
|
||||
{g.rows.map(([lbl, val]) => (
|
||||
<div key={lbl} className="tc-row">
|
||||
<div className="tc-row-main"><div className="tc-row-name" style={{ fontWeight: 500 }}>{lbl}</div></div>
|
||||
<div className="tc-row-fig">{val}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{newOrder && <NewOrderDialog onClose={() => setNewOrder(false)} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
"use client";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { PageHead, Dialog, Empty, KpiStrip, th } from "@/components/ui";
|
||||
import { ccOf, countsAsIssued, csvEsc, csvOf, fmtDate, fyStart, issueCost, label, longLabel, money, monthLabel, onhand, orderTotal, prevMonth, setsCap, shiftMonth, signedInt, signedMoney, staffName } from "@/lib/compute";
|
||||
import { downloadCsv, printDoc, tbl, type Col } from "@/lib/print";
|
||||
|
||||
const TABS = ["Overview", "Journal", "Top stock", "Valuation", "Shrinkage", "Exceptions", "Suppliers", "Approvals", "Pre-loved"] as const;
|
||||
type Tab = (typeof TABS)[number];
|
||||
|
||||
/* Every table and every list on this screen sits in the same bordered block with its name across
|
||||
the top, because nine tabs that each invent their own heading is how a month-end pack ends up
|
||||
looking like nine different reports. `flag` is for the tab that is telling finance something is
|
||||
wrong — an unpostable journal line, stock gone missing, a garment handed over past the ceiling —
|
||||
and marks it the way the rest of the app marks trouble: a rule down the edge and a mark beside
|
||||
the name, so it does not rely on a red that is also the brand's. */
|
||||
function Panel({ title, aside, right, flag, children }: { title: React.ReactNode; aside?: React.ReactNode; right?: React.ReactNode; flag?: boolean; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className={"tc-panel" + (flag ? " tc-flag" : "")} style={{ marginTop: "var(--space-6)" }}>
|
||||
<div className="tc-panel-head">
|
||||
<span>{flag && <span className="tc-mark" aria-hidden="true" />}{title}</span>
|
||||
{(aside || right) && <span style={{ display: "flex", alignItems: "baseline", gap: "var(--space-3)" }}>{aside && <span className="tc-panel-aside">{aside}</span>}{right}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReportPage() {
|
||||
const { s } = useSnap();
|
||||
const { L, byId, staffById } = useDerived();
|
||||
const [month, setMonth] = useState(s.today.slice(0, 7));
|
||||
const [tab, setTab] = useState<Tab>("Overview");
|
||||
// The cost centre whose issues are open, as the keys its figure was summed from. Only the keys
|
||||
// are held: the items and the money are recounted from the snapshot on every render, so the
|
||||
// drill-down still agrees with the row underneath it if stock moves while the dialog is open.
|
||||
const [drill, setDrill] = useState<{ cc: string; dept: string; keys: string[] } | null>(null);
|
||||
|
||||
const R = useMemo(() => {
|
||||
const months = new Set([s.today.slice(0, 7)]);
|
||||
s.issues.forEach((i) => months.add(i.date.slice(0, 7))); s.orders.forEach((o) => o.date && months.add(o.date.slice(0, 7)));
|
||||
for (let i = 5; i >= 0; i--) months.add(shiftMonth(month, -i)); // trend bars are clickable, so they must be selectable
|
||||
const repMonths = [...months].sort().reverse();
|
||||
const cost = (itemId: string) => byId[itemId]?.cost || 0; // catalogue cost (stocktake lines, valuation)
|
||||
const mIssues = s.issues.filter((i) => i.date.slice(0, 7) === month && countsAsIssued(i) && !i.preloved);
|
||||
const mPl = s.issues.filter((i) => i.date.slice(0, 7) === month && countsAsIssued(i) && i.preloved);
|
||||
const pm = prevMonth(month);
|
||||
const pIssues = s.issues.filter((i) => i.date.slice(0, 7) === pm && countsAsIssued(i) && !i.preloved);
|
||||
// Pre-loved: free reissues (value saved at catalogue cost), hand-ins, and the pool at $0.
|
||||
const plIssueRows = mPl.map((i) => { const it = byId[i.itemId]; return { date: i.date, who: staffName(staffById[i.staffId], "—"), item: label(it), size: it ? String(it.sizes[i.si]) : "?", qty: i.qty, saved: i.qty * (it?.cost || 0) }; });
|
||||
const plSaved = plIssueRows.reduce((t, r) => t + r.saved, 0), plQty = mPl.reduce((t, i) => t + i.qty, 0);
|
||||
const mHi = s.handins.filter((h) => h.date.slice(0, 7) === month);
|
||||
const hiRows = mHi.map((h) => ({ date: h.date, who: staffName(staffById[h.staffId], "—"), by: h.by, good: h.lines.filter((l) => l.cond === "Good").reduce((t, l) => t + l.qty, 0), rag: h.lines.filter((l) => l.cond === "Rag").reduce((t, l) => t + l.qty, 0), credit: h.credit ? "Credited" : "—" }));
|
||||
const ragMonth = hiRows.reduce((t, r) => t + r.rag, 0);
|
||||
const plByItem: Record<string, string[]> = {};
|
||||
for (const k in s.stock) { const n = s.stock[k].preloved; if (!(n > 0)) continue; const itemId = k.slice(0, k.lastIndexOf(":")), si = +k.slice(k.lastIndexOf(":") + 1); const it = byId[itemId]; if (!it) continue; (plByItem[itemId] = plByItem[itemId] || []).push(`${it.sizes[si]} ×${n}`); }
|
||||
const plPoolRows = Object.keys(plByItem).map((itemId) => ({ item: label(byId[itemId]), sizes: plByItem[itemId].join(", "), total: plByItem[itemId].reduce((t, x) => t + parseInt(x.split("×")[1], 10), 0) }));
|
||||
const plPoolTotal = plPoolRows.reduce((t, r) => t + r.total, 0);
|
||||
type Agg = { items: number; amt: number };
|
||||
const sumBy = (arr: typeof mIssues, keyFn: (i: (typeof arr)[number]) => string) => { const m: Record<string, Agg> = {}; for (const i of arr) { const k = keyFn(i); if (!m[k]) m[k] = { items: 0, amt: 0 }; m[k].items += i.qty; m[k].amt += i.qty * issueCost(i, byId); } return m; };
|
||||
const ccKey = (i: (typeof mIssues)[number]) => { const st = staffById[i.staffId]; return st ? (ccOf(s, st) || "—") + "|" + (st.dept || "Unknown") : "—|Unknown"; };
|
||||
const byCC = sumBy(mIssues, ccKey), byCCPrev = sumBy(pIssues, ccKey);
|
||||
// Union of this month's and last month's cost centres so the Prev column reconciles to the previous-month total.
|
||||
const ccKeys = [...new Set([...Object.keys(byCC), ...Object.keys(byCCPrev)])];
|
||||
const ccRows = ccKeys.map((k) => { const v = byCC[k] || { items: 0, amt: 0 }; const [cc, dept] = k.split("|"); const prev = byCCPrev[k]?.amt || 0; return { key: k, cc, dept, items: v.items, amt: v.amt, prev, delta: v.amt - prev }; }).sort((a, b) => b.amt - a.amt || b.prev - a.prev);
|
||||
// What each cost-centre figure is actually made of, filed under the same key the total was
|
||||
// grouped on. Grouping the detail the same way as the total is what stops a drill-down from
|
||||
// disagreeing with the row that opened it — a ward manager checking their number would rather
|
||||
// have no drill-down than one that doesn't add up.
|
||||
const ccLines: Record<string, { date: string; who: string; item: string; size: string; qty: number; unit: number; amt: number }[]> = {};
|
||||
for (const i of mIssues) { const k = ccKey(i); const it = byId[i.itemId]; const unit = issueCost(i, byId); (ccLines[k] = ccLines[k] || []).push({ date: i.date, who: staffName(staffById[i.staffId], "—"), item: label(it), size: it ? String(it.sizes[i.si]) : "?", qty: i.qty, unit, amt: i.qty * unit }); }
|
||||
const totAmt = mIssues.reduce((t, i) => t + i.qty * issueCost(i, byId), 0), totPrev = pIssues.reduce((t, i) => t + i.qty * issueCost(i, byId), 0), totItems = mIssues.reduce((t, i) => t + i.qty, 0);
|
||||
// "Placed" = sent to the supplier; drafts (incl. auto-replenishment) are not spend yet.
|
||||
// Back orders carry the parent's short lines, so they're excluded from spend to avoid counting those lines twice.
|
||||
const placed = (o: (typeof s.orders)[number]) => o.status !== "Cancelled" && o.status !== "Draft" && !o.parentId;
|
||||
const mOrders = s.orders.filter((o) => o.date.slice(0, 7) === month && placed(o));
|
||||
const ordSpend = mOrders.reduce((t, o) => t + orderTotal(o, byId), 0);
|
||||
const byG = sumBy(mIssues, (i) => staffById[i.staffId]?.group || "Unknown");
|
||||
const groupRows = Object.entries(byG).sort((a, b) => b[1].amt - a[1].amt).map(([g, v]) => ({ g, ...v }));
|
||||
const supAgg: Record<string, { n: number; amt: number; inv: string[] }> = {};
|
||||
for (const o of mOrders) { const v = orderTotal(o, byId); if (!supAgg[o.supplier]) supAgg[o.supplier] = { n: 0, amt: 0, inv: [] }; supAgg[o.supplier].n++; supAgg[o.supplier].amt += v; if (o.invoice && !supAgg[o.supplier].inv.includes(o.invoice)) supAgg[o.supplier].inv.push(o.invoice); for (const rc of o.receipts) if (rc.invoice && !supAgg[o.supplier].inv.includes(rc.invoice)) supAgg[o.supplier].inv.push(rc.invoice); }
|
||||
const supRows = Object.entries(supAgg).sort((a, b) => b[1].amt - a[1].amt).map(([name, v]) => ({ name, n: v.n, amt: v.amt, invoices: v.inv.join(", ") || "—" }));
|
||||
const issueAgg = (m: string) => { const a = s.issues.filter((i) => i.date.slice(0, 7) === m && countsAsIssued(i) && !i.preloved); return { items: a.reduce((t, i) => t + i.qty, 0), amt: a.reduce((t, i) => t + i.qty * issueCost(i, byId), 0) }; };
|
||||
const orderAgg = (m: string) => s.orders.filter((o) => o.date.slice(0, 7) === m && placed(o)).reduce((t, o) => t + orderTotal(o, byId), 0);
|
||||
const fyMonths: string[] = []; { let cur = fyStart(month + "-15").slice(0, 7); let g = 0; while (cur <= month && g++ < 13) { fyMonths.push(cur); cur = shiftMonth(cur, 1); } }
|
||||
let fti = 0, fta = 0, fto = 0;
|
||||
const fyRows = fyMonths.map((m) => { const ia = issueAgg(m); const ov = orderAgg(m); fti += ia.items; fta += ia.amt; fto += ov; return { m, label: monthLabel(m, { month: "short", year: "2-digit" }), items: ia.items, issued: ia.amt, orders: ov }; });
|
||||
const trendM: string[] = []; for (let i = 5; i >= 0; i--) trendM.push(shiftMonth(month, -i));
|
||||
const tv = trendM.map((m) => issueAgg(m).amt); const tmax = Math.max(...tv, 1);
|
||||
const trend = trendM.map((m, i) => ({ m, label: monthLabel(m, { month: "short" }), amt: tv[i], h: tv[i] ? Math.max(Math.round((tv[i] / tmax) * 70), 4) : 2, sel: m === month }));
|
||||
const byS = sumBy(mIssues, (i) => i.staffId);
|
||||
const staffRows = Object.entries(byS).sort((a, b) => b[1].amt - a[1].amt).map(([sid, v]) => { const st = staffById[sid]; return { who: staffName(st, "—"), cc: ccOf(s, st), ...v }; });
|
||||
// Journal
|
||||
const glAcct = s.settings.glAccount || "—";
|
||||
const jnDesc = `${s.settings.journalDesc || "Uniform issues"} ${monthLabel(month)}`;
|
||||
// One debit per cost centre: departments that share a CC (or a ccOverride pointing at another dept's code) fold together.
|
||||
const jnAgg: Record<string, { cc: string; depts: string[]; keys: string[]; items: number; debit: number }> = {};
|
||||
for (const r of ccRows) { if (r.items <= 0) continue; const cc = r.cc === "—" ? "UNALLOCATED" : r.cc; const a = jnAgg[cc] || (jnAgg[cc] = { cc, depts: [], keys: [], items: 0, debit: 0 }); if (!a.depts.includes(r.dept)) a.depts.push(r.dept); a.keys.push(r.key); a.items += r.items; a.debit += r.amt; }
|
||||
const jnRows = Object.values(jnAgg).sort((a, b) => b.debit - a.debit).map((a) => ({ cc: a.cc, dept: a.depts.join(" / "), keys: a.keys, gl: glAcct, desc: jnDesc, items: a.items, debit: a.debit }));
|
||||
const jnUnallocated = jnRows.some((r) => r.cc === "UNALLOCATED");
|
||||
// Top stock
|
||||
const byItem: Record<string, Agg> = {}; const fyByItem: Record<string, number> = {};
|
||||
const fy = fyStart(month + "-15"); // financial year of the selected month
|
||||
// Every FY figure on this page — Top stock's, Exceptions' and Shrinkage's — stops at the end of
|
||||
// the selected month. Without the upper bound, reprinting June's pack in September counts three
|
||||
// months that hadn't happened when June closed, so the reprint no longer agrees with the pack
|
||||
// finance was already given.
|
||||
const fyCutoff = shiftMonth(month, 1) + "-01"; // exclusive: dates in `month` sort before it
|
||||
for (const i of mIssues) { if (!byItem[i.itemId]) byItem[i.itemId] = { items: 0, amt: 0 }; byItem[i.itemId].items += i.qty; byItem[i.itemId].amt += i.qty * issueCost(i, byId); }
|
||||
for (const i of s.issues) if (countsAsIssued(i) && !i.preloved && i.date >= fy && i.date < fyCutoff) fyByItem[i.itemId] = (fyByItem[i.itemId] || 0) + i.qty;
|
||||
const mTotQty = Object.values(byItem).reduce((t, v) => t + v.items, 0);
|
||||
const topRows = Object.entries(byItem).sort((a, b) => b[1].items - a[1].items).slice(0, 15).map(([id, v], n) => ({ n: n + 1, item: label(byId[id]), supplier: byId[id]?.supplier || "—", qty: v.items, val: v.amt, share: Math.round((v.items / Math.max(mTotQty, 1)) * 100) + "%", fyQty: fyByItem[id] || 0 }));
|
||||
// Valuation
|
||||
let negSizes = 0;
|
||||
const valRows = s.catalog.map((it) => { const units = it.sizes.reduce((t, _sz, si) => { const oh = onhand(s, L, `${it.id}:${si}`); if (oh < 0) negSizes++; return t + Math.max(0, oh); }, 0); return { item: longLabel(it), sku: it.sku || "—", supplier: it.supplier || "—", units, cost: it.cost, val: units * it.cost }; }).filter((x) => x.units > 0).sort((a, b) => b.val - a.val);
|
||||
const valTotUnits = valRows.reduce((t, x) => t + x.units, 0), valTot = valRows.reduce((t, x) => t + x.val, 0);
|
||||
// Shrinkage
|
||||
// Bounded at fyCutoff like the other FY figures: a count filed in July must not change the
|
||||
// shrinkage figure on June's pack after finance has it. Pool counts are at $0, not shrinkage.
|
||||
const fyTakes = s.stocktakes.filter((h) => h.date >= fy && h.date < fyCutoff && h.mode !== "preloved");
|
||||
let shU = 0, shV = 0;
|
||||
const shRows = fyTakes.map((h) => { const nu = h.lines.reduce((t, l) => t + (l.counted - l.sys), 0); const nv = h.lines.reduce((t, l) => t + (l.counted - l.sys) * cost(l.itemId), 0); shU += nu; shV += nv; return { date: h.date, by: h.by, counted: h.counted, variances: h.variances, net: nu, netVal: nv }; });
|
||||
// Exceptions
|
||||
const excThreshold = s.settings.exceptionHigh || 10;
|
||||
const mByStaff: Record<string, number> = {}; for (const i of mIssues) mByStaff[i.staffId] = (mByStaff[i.staffId] || 0) + i.qty;
|
||||
const cap = setsCap(s.settings.capSets);
|
||||
// Garments handed over this month past the six sets one person holds, on a coordinator's
|
||||
// override. That is the one exception the ceiling itself produces, and the counter stamps it on
|
||||
// the issue for this tab to find. It is read from that stamp rather than from anybody's locker
|
||||
// today, because today's locker is not June's: a June pack reprinted in September would name
|
||||
// whoever happens to be past the ceiling now, and saying who that is belongs to the staff
|
||||
// register and the dashboard. Every stamped row in the month counts, pre-loved and since-returned
|
||||
// included, because the decision was made at the counter on the day. A partial hand-in splits a
|
||||
// row without changing its date, so the halves still add up to what went over.
|
||||
const ovByStaff: Record<string, number> = {};
|
||||
for (const i of s.issues) if (i.override && i.date.slice(0, 7) === month) ovByStaff[i.staffId] = (ovByStaff[i.staffId] || 0) + i.qty;
|
||||
// Garments handed over this month outside the person's staff group, on the same tick but stamped
|
||||
// apart (offGroup), so they are counted and named apart. Same month rule as above. A garment can
|
||||
// be both, and then it is on both lines, because two rules were bent.
|
||||
const ogByStaff: Record<string, Record<string, number>> = {};
|
||||
for (const i of s.issues) if (i.offGroup && i.date.slice(0, 7) === month) { const m = (ogByStaff[i.staffId] = ogByStaff[i.staffId] || {}); const n = label(byId[i.itemId]); m[n] = (m[n] || 0) + i.qty; }
|
||||
// Garments handed over this month in a cut the person isn't offered, on the same tick and stamped
|
||||
// apart again (offStyle). Same month rule, and the same reason for counting it apart: the ceiling,
|
||||
// the staff group and the cut are three different decisions a coordinator made, and a row that
|
||||
// named them all as "an override" tells whoever reads the pack nothing about which was bent.
|
||||
const osByStaff: Record<string, Record<string, number>> = {};
|
||||
for (const i of s.issues) if (i.offStyle && i.date.slice(0, 7) === month) { const m = (osByStaff[i.staffId] = osByStaff[i.staffId] || {}); const n = label(byId[i.itemId]); m[n] = (m[n] || 0) + i.qty; }
|
||||
// What each person has drawn this financial year, to the end of the selected month. It is a
|
||||
// tally printed beside the month's figure, and nobody is flagged on it: what anybody may have is
|
||||
// six sets held at any time, with no year in it, and a report calling somebody over on a yearly
|
||||
// count sends a coordinator after a new starter the counter has kitted out quite properly.
|
||||
// Counted here rather than with entUsed(), which always measures the year containing today, so a
|
||||
// closed month reprints with the figures it was first printed with. fyCutoff is the one Top
|
||||
// stock and Shrinkage count to, so no two tabs quote a different window for the same month. Same
|
||||
// rules as entUsed(): pre-loved is free and not counted, a garment returned in good condition
|
||||
// never counted, and a credited hand-in takes the good garments back off.
|
||||
const fyByStaff: Record<string, number> = {};
|
||||
for (const i of s.issues) if (!i.preloved && countsAsIssued(i) && i.date >= fy && i.date < fyCutoff) fyByStaff[i.staffId] = (fyByStaff[i.staffId] || 0) + i.qty;
|
||||
for (const h of s.handins) if (h.credit && h.date >= fy && h.date < fyCutoff) for (const l of h.lines) fyByStaff[h.staffId] = (fyByStaff[h.staffId] || 0) - l.credited;
|
||||
const excRows: { who: string; group: string; cc: string; mQty: number; fyQty: number; ovQty: number; ogQty: number; osQty: number; flags: string[]; flag: string }[] = [];
|
||||
for (const st of s.staff) {
|
||||
const fyQ = Math.max(0, fyByStaff[st.id] || 0); const mQ = mByStaff[st.id] || 0; const ov = ovByStaff[st.id] || 0;
|
||||
const og = Object.entries(ogByStaff[st.id] || {}); const ogQ = og.reduce((t, [, n]) => t + n, 0);
|
||||
const os = Object.entries(osByStaff[st.id] || {}); const osQ = os.reduce((t, [, n]) => t + n, 0);
|
||||
const flags: string[] = [];
|
||||
if (ov) flags.push(`Past ${cap} sets on an override — ${ov} garment${ov === 1 ? "" : "s"}`);
|
||||
if (ogQ) flags.push(`Outside their staff group on an override — ${og.map(([n, q]) => `${n} ×${q}`).join(", ")}`);
|
||||
if (osQ) flags.push(`Not their uniform style on an override — ${os.map(([n, q]) => `${n} ×${q}`).join(", ")}`);
|
||||
if (mQ >= excThreshold) flags.push(`${mQ} items this month (threshold ${excThreshold})`);
|
||||
if (flags.length) excRows.push({ who: staffName(st), group: st.group, cc: ccOf(s, st), mQty: mQ, fyQty: fyQ, ovQty: ov, ogQty: ogQ, osQty: osQ, flags, flag: flags.join(" · ") });
|
||||
}
|
||||
// Overrides first, of any kind. Each one is a decision somebody made at the counter, and it is
|
||||
// the row a coordinator gets asked about. Volume on its own comes after, busiest first.
|
||||
excRows.sort((a, b) => Number(b.ovQty + b.ogQty + b.osQty > 0) - Number(a.ovQty + a.ogQty + a.osQty > 0) || b.mQty - a.mQty);
|
||||
// Approvals
|
||||
const apprRows = s.approvals.filter((a) => a.sets - a.used > 0).map((a) => { const st = staffById[a.staffId]; return { who: staffName(st, "—"), dept: st?.dept || "—", by: a.by, date: a.date, sets: a.sets, used: a.used, rem: a.sets - a.used }; });
|
||||
const apprTot = apprRows.reduce((t, a) => t + a.rem, 0);
|
||||
return { repMonths, ccRows, ccLines, totAmt, totPrev, totItems, ordSpend, groupRows, supRows, fyRows, fyTot: { items: fti, issued: fta, orders: fto }, trend, staffRows, glAcct, jnDesc, jnRows, jnUnallocated, topRows, valRows, valTotUnits, valTot, negSizes, shRows, shU, shV, cap, excThreshold, excRows, apprRows, apprTot, plIssueRows, plSaved, plQty, hiRows, ragMonth, plPoolRows, plPoolTotal };
|
||||
}, [s, L, byId, staffById, month]);
|
||||
|
||||
const mLbl = monthLabel(month);
|
||||
const meta = `${s.settings.facility} · ${s.settings.location} · prepared ${fmtDate(s.today)}${s.settings.coordinator ? " by " + s.settings.coordinator : ""}`;
|
||||
const jnTotItems = R.jnRows.reduce((t, r) => t + r.items, 0), jnTot = R.jnRows.reduce((t, r) => t + r.debit, 0);
|
||||
const drillRows = useMemo(() => (drill ? drill.keys.flatMap((k) => R.ccLines[k] || []).sort((a, b) => a.date.localeCompare(b.date) || a.who.localeCompare(b.who) || a.item.localeCompare(b.item)) : []), [drill, R]);
|
||||
const drillQty = drillRows.reduce((t, r) => t + r.qty, 0), drillAmt = drillRows.reduce((t, r) => t + r.amt, 0);
|
||||
const csvDrill = () => drill && downloadCsv(`threadcount-cost-centre-${drill.cc.replace(/[^A-Za-z0-9]+/g, "-").toLowerCase()}-${month}.csv`, `Issues behind cost centre,${csvEsc(drill.cc)},${month}\n\n` + csvOf(["Date", "Staff", "Item", "Size", "Qty", "Unit cost", "Value"], [...drillRows.map((r) => [r.date, r.who, r.item, r.size, r.qty, r.unit.toFixed(2), r.amt.toFixed(2)] as (string | number)[]), ["TOTAL", "", "", "", drillQty, "", drillAmt.toFixed(2)]]));
|
||||
|
||||
const csvOverview = () => {
|
||||
let csv = `ThreadCount monthly report,${month},${csvEsc(s.settings.facility)}\n\n` + csvOf(["Cost Centre", "Department", "Items", "Amount", "Previous Month"], [...R.ccRows.map((r) => [r.cc, r.dept, r.items, r.amt.toFixed(2), r.prev.toFixed(2)] as (string | number)[]), ["TOTAL", "", R.totItems, R.totAmt.toFixed(2), R.totPrev.toFixed(2)]]);
|
||||
csv += "\n" + csvOf(["Staff Group", "Items", "Amount"], R.groupRows.map((g) => [g.g, g.items, g.amt.toFixed(2)]));
|
||||
csv += "\n" + csvOf(["Staff", "Cost Centre", "Items", "Amount"], R.staffRows.map((r) => [r.who, r.cc, r.items, r.amt.toFixed(2)]));
|
||||
csv += "\n" + csvOf(["Supplier", "Orders", "Amount"], R.supRows.map((r) => [r.name, r.n, r.amt.toFixed(2)]));
|
||||
csv += "\n" + csvOf(["FY Month", "Items Issued", "Issued Value", "Orders Placed"], [...R.fyRows.map((m) => [m.label, m.items, m.issued.toFixed(2), m.orders.toFixed(2)] as (string | number)[]), ["FY TOTAL", R.fyTot.items, R.fyTot.issued.toFixed(2), R.fyTot.orders.toFixed(2)]]);
|
||||
downloadCsv(`threadcount-report-${month}.csv`, csv);
|
||||
};
|
||||
const csvJournal = () => downloadCsv(`threadcount-journal-${month}.csv`, csvOf(["Cost Centre", "Department", "GL Account", "Description", "Items", "Debit"], [...R.jnRows.map((r) => [r.cc, r.dept, r.gl, r.desc, r.items, r.debit.toFixed(2)] as (string | number)[]), ["TOTAL", "", "", "", jnTotItems, jnTot.toFixed(2)]]));
|
||||
const csvValuation = () => downloadCsv(`threadcount-valuation-${s.today}.csv`, `Stock valuation as at,${s.today}\n` + csvOf(["Item", "SKU", "Supplier", "Units", "Unit cost", "Value"], R.valRows.map((x) => [x.item, x.sku, x.supplier, x.units, x.cost, x.val.toFixed(2)])));
|
||||
const tabCsv: Record<Tab, () => void> = {
|
||||
Overview: csvOverview, Journal: csvJournal, Valuation: csvValuation,
|
||||
"Top stock": () => downloadCsv(`threadcount-top-stock-${month}.csv`, csvOf(["Rank", "Item", "Supplier", "Qty (month)", "Value (month)", "Share", "Qty (FY)"], R.topRows.map((r) => [r.n, r.item, r.supplier, r.qty, r.val.toFixed(2), r.share, r.fyQty]))),
|
||||
Shrinkage: () => downloadCsv(`threadcount-shrinkage-${month}.csv`, csvOf(["Date", "Counted by", "Lines counted", "Variances", "Net units", "Net value"], R.shRows.map((r) => [r.date, r.by, r.counted, r.variances, r.net, r.netVal.toFixed(2)]))),
|
||||
Exceptions: () => downloadCsv(`threadcount-exceptions-${month}.csv`, csvOf(["Staff", "Group", "Cost centre", "Items (month)", "Items (FY)", "Flag"], R.excRows.map((r) => [r.who, r.group, r.cc, r.mQty, r.fyQty, r.flag]))),
|
||||
Suppliers: () => downloadCsv(`threadcount-supplier-spend-${month}.csv`, csvOf(["Supplier", "Orders", "Value", "Invoices"], R.supRows.map((r) => [r.name, r.n, r.amt.toFixed(2), r.invoices]))),
|
||||
Approvals: () => downloadCsv(`threadcount-approvals-outstanding-${s.today}.csv`, csvOf(["Staff", "Ward", "Approved by", "Date", "Sets approved", "Collected", "Remaining"], R.apprRows.map((r) => [r.who, r.dept, r.by, r.date, r.sets, r.used, r.rem]))),
|
||||
"Pre-loved": () => downloadCsv(`threadcount-preloved-${month}.csv`, `Pre-loved issues ${month}\n` + csvOf(["Date", "Staff", "Item", "Size", "Qty", "Value saved"], R.plIssueRows.map((r) => [r.date, r.who, r.item, r.size, r.qty, r.saved.toFixed(2)])) + "\nHand-ins\n" + csvOf(["Date", "Staff", "Received by", "Good", "Rag", "Credit"], R.hiRows.map((r) => [r.date, r.who, r.by, r.good, r.rag, r.credit])) + "\nPool snapshot\n" + csvOf(["Item", "Sizes", "Total"], R.plPoolRows.map((r) => [r.item, r.sizes, r.total]))),
|
||||
};
|
||||
const C = (t: string, r = false): Col => ({ t, r });
|
||||
const tabPrint: Record<Tab, () => void> = {
|
||||
Overview: () => printDoc(`Cost centre report — ${mLbl}`, meta, [
|
||||
{ h: "Summary", html: tbl([C(""), C("", true)], [["Issued value (period)", money(R.totAmt)], ["Items issued", R.totItems], ["Supplier orders placed", money(R.ordSpend)], ["vs previous month", money(R.totPrev)]]) },
|
||||
{ h: "Issued value by cost centre", html: tbl([C("CC"), C("Department"), C("Items", true), C("This period", true), C("Prev", true), C("Δ", true)], [...R.ccRows.map((r) => [r.cc, r.dept, r.items, money(r.amt), money(r.prev), signedMoney(r.delta)] as (string | number)[]), ["TOTAL", "", R.totItems, money(R.totAmt), money(R.totPrev), ""]]) },
|
||||
{ h: "By staff group", html: tbl([C("Group"), C("Items", true), C("Value", true)], R.groupRows.map((g) => [g.g, g.items, money(g.amt)])) },
|
||||
{ h: "By staff member", html: tbl([C("Staff"), C("CC"), C("Items", true), C("Value", true)], R.staffRows.map((r) => [r.who, r.cc, r.items, money(r.amt)])) },
|
||||
{ h: "Financial year", html: tbl([C("Month"), C("Items", true), C("Issued", true), C("Orders", true)], [...R.fyRows.map((m) => [m.label, m.items, money(m.issued), money(m.orders)] as (string | number)[]), ["FY TOTAL", R.fyTot.items, money(R.fyTot.issued), money(R.fyTot.orders)]]) },
|
||||
]),
|
||||
Journal: () => printDoc(`End-of-month journal — ${mLbl}`, meta, [{ h: `One debit per cost centre — GL ${R.glAcct}`, html: tbl([C("CC"), C("Department"), C("GL"), C("Description"), C("Items", true), C("Debit", true)], [...R.jnRows.map((r) => [r.cc, r.dept, r.gl, r.desc, r.items, money(r.debit)] as (string | number)[]), ["TOTAL", "", "", "", jnTotItems, money(jnTot)]]) }]),
|
||||
"Top stock": () => printDoc(`Top stock — ${mLbl}`, meta, [{ h: "Most issued items", html: tbl([C("#"), C("Item"), C("Supplier"), C("Qty", true), C("Value", true), C("Share", true), C("Qty FY", true)], R.topRows.map((r) => [r.n, r.item, r.supplier, r.qty, money(r.val), r.share, r.fyQty])) }]),
|
||||
Valuation: () => printDoc(`Stock valuation — as at ${fmtDate(s.today)}`, meta, [{ h: "On-hand value by item", html: tbl([C("Item"), C("SKU"), C("Supplier"), C("Units", true), C("Unit cost", true), C("Value", true)], [...R.valRows.map((r) => [r.item, r.sku, r.supplier, r.units, money(r.cost), money(r.val)] as (string | number)[]), ["TOTAL", "", "", R.valTotUnits, "", money(R.valTot)]]) }]),
|
||||
Shrinkage: () => printDoc(`Stocktake variance / shrinkage — FY to end of ${mLbl}`, meta, [{ h: `${R.shRows.length} stocktakes · net ${signedInt(R.shU)} units · ${signedMoney(R.shV)}`, html: tbl([C("Date"), C("Counted by"), C("Lines", true), C("Variances", true), C("Net units", true), C("Net value", true)], R.shRows.map((r) => [fmtDate(r.date), r.by, r.counted, r.variances, signedInt(r.net), signedMoney(r.netVal)])) }]),
|
||||
Exceptions: () => printDoc(`Staff exceptions — ${mLbl}`, meta, [{ h: `Past ${R.cap} sets, outside their staff group or not their uniform style on an override, or ≥ ${R.excThreshold} items this month`, html: tbl([C("Staff"), C("Group"), C("CC"), C("Month", true), C("FY", true), C("Flag")], R.excRows.map((r) => [r.who, r.group, r.cc, r.mQty, r.fyQty, r.flag])) }]),
|
||||
Suppliers: () => printDoc(`Supplier spend — ${mLbl}`, meta, [{ h: "Orders placed this period", html: tbl([C("Supplier"), C("Orders", true), C("Value", true), C("Invoices")], R.supRows.map((r) => [r.name, r.n, money(r.amt), r.invoices])) }]),
|
||||
Approvals: () => printDoc(`Uncollected manager's approvals — as at ${fmtDate(s.today)}`, meta, [{ h: `${R.apprTot} sets outstanding`, html: tbl([C("Staff"), C("Ward"), C("Approved by"), C("Date"), C("Sets", true), C("Collected", true), C("Remaining", true)], R.apprRows.map((r) => [r.who, r.dept, r.by, fmtDate(r.date), r.sets, r.used, r.rem])) }]),
|
||||
"Pre-loved": () => printDoc(`Pre-loved uniforms — ${mLbl}`, meta, [
|
||||
{ h: `Issued free this period — saved ${money(R.plSaved)}`, html: tbl([C("Date"), C("Staff"), C("Item"), C("Size"), C("Qty", true), C("Value saved", true)], R.plIssueRows.map((r) => [fmtDate(r.date), r.who, r.item, r.size, r.qty, money(r.saved)])) },
|
||||
{ h: `Hand-ins this period · ${R.ragMonth} to rag disposal`, html: tbl([C("Date"), C("Staff"), C("Received by"), C("Good", true), C("Rag", true), C("Credit")], R.hiRows.map((r) => [fmtDate(r.date), r.who, r.by, r.good, r.rag, r.credit])) },
|
||||
{ h: `Pool snapshot — ${R.plPoolTotal} items at $0 book value`, html: tbl([C("Item"), C("Sizes"), C("Total", true)], R.plPoolRows.map((r) => [r.item, r.sizes, r.total])) },
|
||||
]),
|
||||
};
|
||||
function printEomPack() {
|
||||
const sections = [
|
||||
{ h: "Summary", html: tbl([C(""), C("", true), C(""), C("", true)], [["Issued value", money(R.totAmt), "Items issued", R.totItems], ["Supplier orders placed", money(R.ordSpend), "Stock on hand value", money(R.valTot)], ["Shrinkage (FY to end of month)", signedMoney(R.shV), "Stocktakes counted (FY)", R.shRows.length]]) },
|
||||
{ h: "Cost centre summary", html: tbl([C("CC"), C("Department"), C("Items", true), C("Value", true)], [...R.jnRows.map((r) => [r.cc, r.dept, r.items, money(r.debit)] as (string | number)[]), ["TOTAL", "", jnTotItems, money(jnTot)]]) },
|
||||
{ h: `Journal — one debit per cost centre (GL ${R.glAcct})`, html: tbl([C("CC"), C("Description"), C("Debit", true)], R.jnRows.map((r) => [r.cc, r.desc, money(r.debit)])) },
|
||||
{ h: "Top stock", html: tbl([C("Item"), C("Qty", true), C("Value", true)], R.topRows.slice(0, 10).map((r) => [r.item, r.qty, money(r.val)])) },
|
||||
];
|
||||
// Finance is promised shrinkage in this pack, and the net figure is in the summary above every
|
||||
// month. The count-by-count table only turns up when counts were actually filed, the same rule
|
||||
// the exceptions and approvals sections below follow — a heading over an empty table tells
|
||||
// finance nothing and costs them a page.
|
||||
if (R.shRows.length) sections.push({ h: `Shrinkage — stocktake variance, FY to end of ${mLbl} · net ${signedInt(R.shU)} units · ${signedMoney(R.shV)}`, html: tbl([C("Date"), C("Counted by"), C("Variances", true), C("Net units", true), C("Net value", true)], R.shRows.map((r) => [fmtDate(r.date), r.by, r.variances, signedInt(r.net), signedMoney(r.netVal)])) });
|
||||
if (R.excRows.length) sections.push({ h: "Staff exceptions", html: tbl([C("Staff"), C("Cost centre"), C("Flag")], R.excRows.map((r) => [r.who, r.cc, r.flag])) });
|
||||
if (R.apprRows.length) sections.push({ h: "Uncollected manager's approvals", html: tbl([C("Staff"), C("Approved by"), C("Remaining sets", true)], R.apprRows.map((r) => [r.who, r.by, r.rem])) });
|
||||
printDoc(`Month-end pack — ${mLbl}`, meta, sections);
|
||||
}
|
||||
const delta = (d: number) => <span style={{ color: d > 0 ? "var(--color-accent-700)" : "var(--color-neutral-700)" }}>{(d >= 0 ? "+" : "−") + money(Math.abs(d)).slice(1)}</span>;
|
||||
const R2 = (n: number) => ({ textAlign: "right" as const, fontWeight: n });
|
||||
/* A ward manager rings the linen room asking why their number doubled this month, and until now
|
||||
the coordinator had nothing on the screen to answer with. The cost centre is a button: it opens
|
||||
the issues that make the figure beside it — who, which garment, which size, when, what it cost.
|
||||
It is the cell and not the row because a row that only answers to a click is unreachable from
|
||||
the keyboard, and its name carries the figure so it is clear what the button opens. */
|
||||
const drillBtn = (cc: string, dept: string, keys: string[], items: number, amt: number) => {
|
||||
// The Overview prints an em dash for staff who have no cost centre; the journal calls those
|
||||
// UNALLOCATED, and that is the word to say out loud rather than "issues behind —".
|
||||
const name = cc === "—" ? "UNALLOCATED" : cc;
|
||||
return (
|
||||
<button type="button" aria-haspopup="dialog" aria-label={`Show the ${items} item${items === 1 ? "" : "s"} issued behind ${name} — ${money(amt)} in ${mLbl}`}
|
||||
onClick={() => setDrill({ cc: name, dept, keys })}
|
||||
style={{ background: "none", border: 0, padding: 0, font: "inherit", fontWeight: 700, color: "inherit", textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>{cc}</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section>
|
||||
<PageHead eyebrow="Finance" title="Reports">
|
||||
<select className="input" aria-label="Reporting month" value={month} onChange={(e) => setMonth(e.target.value)}>{R.repMonths.map((m) => <option key={m} value={m}>{monthLabel(m)}</option>)}</select>
|
||||
<button className="btn btn-secondary" onClick={printEomPack}>Month-end pack</button>
|
||||
<button className="btn btn-ghost" onClick={tabCsv[tab]}>Export CSV</button>
|
||||
<button className="btn btn-ghost" onClick={tabPrint[tab]}>Print</button>
|
||||
</PageHead>
|
||||
<div className="seg" style={{ display: "inline-flex", flexWrap: "wrap", marginTop: "var(--space-4)" }}>
|
||||
{TABS.map((t) => <button key={t} className={"seg-opt" + (tab === t ? " btn-primary" : "")} onClick={() => setTab(t)}>{t}</button>)}
|
||||
</div>
|
||||
|
||||
{tab === "Overview" && (
|
||||
<>
|
||||
{/* Five figures rather than the four that were here: the pre-loved pool saves the ward
|
||||
real money every month and it was a sentence under the strip, which is not where
|
||||
anybody looks for a number. */}
|
||||
<KpiStrip items={[
|
||||
{ val: money(R.totAmt), label: "Issued value (period)", note: "Each garment at what it cost the day it was issued" },
|
||||
{ val: R.totItems, label: "Items issued", note: `Across ${R.ccRows.length} cost centre${R.ccRows.length === 1 ? "" : "s"}` },
|
||||
{ val: money(R.ordSpend), label: "Supplier orders placed", note: "Drafts are not spend until they are sent" },
|
||||
{ val: money(R.totPrev), label: "vs previous month", note: R.totAmt === R.totPrev ? "Level with last month" : `${R.totAmt > R.totPrev ? "Up" : "Down"} ${money(Math.abs(R.totAmt - R.totPrev))} on last month` },
|
||||
{ val: R.plQty, label: "Pre-loved issued (free)", note: `Saved ${money(R.plSaved)} at catalogue cost` },
|
||||
]} />
|
||||
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "3fr 2fr", gap: "var(--space-8)" }}>
|
||||
<div>
|
||||
<Panel title="Issued value by cost centre" aside={`${mLbl} against the month before`}>
|
||||
<div className="tc-panel-body">
|
||||
<div className="table-wrap"><table className="table">
|
||||
<thead><tr>{th("Cost centre")}{th("Department")}{th("Items", true)}{th("This period", true)}{th("Prev", true)}{th("Δ", true)}</tr></thead>
|
||||
<tbody>
|
||||
{R.ccRows.map((r) => <tr key={r.cc + r.dept}><td style={{ fontWeight: 700 }}>{drillBtn(r.cc, r.dept, [r.key], r.items, r.amt)}</td><td>{r.dept}</td><td style={R2(400)}>{r.items}</td><td style={R2(700)}>{money(r.amt)}</td><td style={{ textAlign: "right", color: "var(--color-neutral-700)" }}>{money(r.prev)}</td><td style={{ textAlign: "right" }}>{delta(r.delta)}</td></tr>)}
|
||||
<tr><td style={{ fontWeight: 800 }}>TOTAL</td><td></td><td style={R2(800)}>{R.totItems}</td><td style={R2(800)}>{money(R.totAmt)}</td><td style={{ textAlign: "right", color: "var(--color-neutral-700)" }}>{money(R.totPrev)}</td><td></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
{R.ccRows.length === 0 && <Empty pad={4}>No issues recorded in this period yet.</Empty>}
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel title="Financial year summary" aside={`To the end of ${mLbl}`}>
|
||||
<div className="tc-panel-body">
|
||||
<div className="table-wrap"><table className="table">
|
||||
<thead><tr>{th("Month")}{th("Items issued", true)}{th("Issued value", true)}{th("Orders placed", true)}</tr></thead>
|
||||
<tbody>
|
||||
{R.fyRows.map((m) => <tr key={m.m}><td style={{ fontWeight: 600 }}>{m.label}</td><td style={R2(400)}>{m.items}</td><td style={R2(700)}>{money(m.issued)}</td><td style={{ textAlign: "right", color: "var(--color-neutral-700)" }}>{money(m.orders)}</td></tr>)}
|
||||
<tr><td style={{ fontWeight: 800 }}>FY TOTAL</td><td style={R2(800)}>{R.fyTot.items}</td><td style={R2(800)}>{money(R.fyTot.issued)}</td><td style={R2(800)}>{money(R.fyTot.orders)}</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
<div>
|
||||
<Panel title="By staff group">
|
||||
{R.groupRows.length === 0 ? <div className="tc-panel-body"><Empty pad={3}>Nothing issued in this period.</Empty></div> : (
|
||||
<div className="tc-panel-list">
|
||||
{R.groupRows.map((g) => (
|
||||
<div key={g.g} className="tc-row">
|
||||
<div className="tc-row-main"><div className="tc-row-name">{g.g}</div><div className="tc-row-meta">{g.items} item{g.items === 1 ? "" : "s"}</div></div>
|
||||
<div className="tc-row-fig">{money(g.amt)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
<Panel title="By staff member">
|
||||
{R.staffRows.length === 0 ? <div className="tc-panel-body"><Empty pad={3}>Nothing issued in this period.</Empty></div> : (
|
||||
<div className="tc-panel-list">
|
||||
{R.staffRows.map((r, i) => (
|
||||
<div key={i} className="tc-row">
|
||||
<div className="tc-row-main"><div className="tc-row-name">{r.who}</div><div className="tc-row-meta">{r.cc || "no cost centre"} · {r.items} item{r.items === 1 ? "" : "s"}</div></div>
|
||||
<div className="tc-row-fig">{money(r.amt)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
<Panel title="Supplier orders this period">
|
||||
{R.supRows.length === 0 ? <div className="tc-panel-body"><Empty pad={3}>No supplier orders placed this period.</Empty></div> : (
|
||||
<div className="tc-panel-list">
|
||||
{R.supRows.map((r) => (
|
||||
<div key={r.name} className="tc-row">
|
||||
<div className="tc-row-main"><div className="tc-row-name">{r.name}</div><div className="tc-row-meta">{r.n} order{r.n === 1 ? "" : "s"}</div></div>
|
||||
<div className="tc-row-fig">{money(r.amt)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
<Panel title="Issued value" aside="Last 6 months">
|
||||
<div className="tc-panel-body">
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: "var(--space-2)", height: 120 }}>
|
||||
{/* Each bar changes the month the whole page is reporting on, so it is a button —
|
||||
a clickable <div> put the only way of moving between months out of reach of the
|
||||
keyboard. The bar itself is decoration; the name says the month and the figure. */}
|
||||
{R.trend.map((b) => (
|
||||
<button type="button" key={b.m} aria-label={`Show ${monthLabel(b.m)} — ${money(b.amt)} issued`} aria-current={b.sel ? "true" : undefined}
|
||||
style={{ flex: 1, display: "flex", flexDirection: "column", justifyContent: "flex-end", height: "100%", gap: 4, cursor: "pointer", background: "none", border: 0, padding: 0, font: "inherit", color: "inherit" }} onClick={() => setMonth(b.m)}>
|
||||
<span style={{ display: "block", width: "100%", fontSize: 10, textAlign: "center", color: "var(--color-neutral-700)", whiteSpace: "nowrap", overflow: "hidden" }}>{b.amt ? money(b.amt) : ""}</span>
|
||||
<span aria-hidden="true" style={{ display: "block", width: "100%", height: b.h, background: b.sel ? "var(--color-accent)" : "var(--color-neutral-300)" }} />
|
||||
<span style={{ display: "block", width: "100%", fontSize: 10, textAlign: "center", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--color-neutral-700)" }}>{b.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "Journal" && (
|
||||
/* A staff member with no cost centre lands in UNALLOCATED, and finance cannot post that
|
||||
line — so the panel carries the rule and the mark rather than leaving the warning to a
|
||||
red sentence under a table nobody scrolls to. */
|
||||
<Panel title={`End-of-month journal — ${mLbl}`} aside={`GL ${R.glAcct}`} flag={R.jnUnallocated}
|
||||
right={<button className="btn btn-secondary" onClick={csvJournal}>Export journal CSV</button>}>
|
||||
<div className="tc-panel-body">
|
||||
<div className="table-wrap"><table className="table">
|
||||
<thead><tr>{th("Cost centre")}{th("Department")}{th("GL account")}{th("Description")}{th("Items", true)}{th("Debit", true)}</tr></thead>
|
||||
<tbody>
|
||||
{R.jnRows.map((r) => <tr key={r.cc + r.dept}><td style={{ fontWeight: 700 }}>{drillBtn(r.cc, r.dept, r.keys, r.items, r.debit)}</td><td>{r.dept}</td><td>{r.gl}</td><td>{r.desc}</td><td style={R2(400)}>{r.items}</td><td style={R2(700)}>{money(r.debit)}</td></tr>)}
|
||||
<tr><td style={{ fontWeight: 800 }}>TOTAL</td><td></td><td></td><td></td><td style={R2(800)}>{jnTotItems}</td><td style={R2(800)}>{money(jnTot)}</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
<div className="tc-panel-foot" style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>Set the GL account and description under Settings → General.{R.jnUnallocated && <b style={{ color: "var(--color-accent-700)" }}> UNALLOCATED = staff with no cost centre — set their department or override on the Staff Register before posting.</b>}</div>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{tab === "Top stock" && (
|
||||
<Panel title={`Top stock — ${mLbl}`} aside="Most issued items">
|
||||
<div className="tc-panel-body">
|
||||
<div className="table-wrap"><table className="table">
|
||||
<thead><tr>{th("#")}{th("Item")}{th("Supplier")}{th("Qty (month)", true)}{th("Value (month)", true)}{th("Share", true)}{th("Qty (FY)", true)}</tr></thead>
|
||||
<tbody>{R.topRows.map((r) => <tr key={r.n}><td style={{ color: "var(--color-neutral-600)" }}>{r.n}</td><td style={{ fontWeight: 600 }}>{r.item}</td><td>{r.supplier}</td><td style={R2(700)}>{r.qty}</td><td style={R2(400)}>{money(r.val)}</td><td style={{ textAlign: "right", color: "var(--color-neutral-700)" }}>{r.share}</td><td style={{ textAlign: "right", color: "var(--color-neutral-700)" }}>{r.fyQty}</td></tr>)}</tbody>
|
||||
</table></div>
|
||||
{R.topRows.length === 0 && <Empty pad={4}>Nothing issued this period.</Empty>}
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{tab === "Valuation" && (
|
||||
<Panel title="Stock valuation — as at today" flag={R.negSizes > 0}
|
||||
aside={R.negSizes > 0 ? `${R.negSizes} size${R.negSizes === 1 ? "" : "s"} negative on hand` : "Priced at catalogue cost"}
|
||||
right={<button className="btn btn-secondary" onClick={csvValuation}>Export CSV</button>}>
|
||||
<div className="tc-panel-body">
|
||||
<div className="table-wrap"><table className="table">
|
||||
<thead><tr>{th("Item")}{th("SKU")}{th("Supplier")}{th("Units on hand", true)}{th("Unit cost", true)}{th("Value", true)}</tr></thead>
|
||||
<tbody>
|
||||
{R.valRows.map((r, i) => <tr key={i}><td style={{ fontWeight: 600 }}>{r.item}</td><td style={{ fontSize: 12 }}>{r.sku}</td><td>{r.supplier}</td><td style={R2(400)}>{r.units}</td><td style={R2(400)}>{money(r.cost)}</td><td style={R2(700)}>{money(r.val)}</td></tr>)}
|
||||
<tr><td style={{ fontWeight: 800 }}>TOTAL</td><td></td><td></td><td style={R2(800)}>{R.valTotUnits}</td><td></td><td style={R2(800)}>{money(R.valTot)}</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
{R.negSizes > 0 && <div className="tc-panel-foot" style={{ fontSize: 12, color: "var(--color-accent-700)", fontWeight: 600 }}>{R.negSizes} size{R.negSizes === 1 ? "" : "s"} are negative on hand and are counted as 0 in this valuation — run a stocktake or record the missing receipt.</div>}
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{tab === "Shrinkage" && (
|
||||
<>
|
||||
{/* Stock that has gone missing is money finance has to be told about, so the two figures
|
||||
that carry it take the rule and the mark when the net is down, not just a red number. */}
|
||||
<KpiStrip items={[
|
||||
{ val: R.shRows.length, label: "Stocktakes this FY", note: `To the end of ${mLbl}` },
|
||||
{ val: signedInt(R.shU), label: "Net variance (FY)", flag: R.shV < 0, note: "Units counted against units the system expected" },
|
||||
{ val: signedMoney(R.shV), label: "Net value (FY)", flag: R.shV < 0, note: R.shV < 0 ? "Stock short at catalogue cost" : "At catalogue cost" },
|
||||
]} />
|
||||
<Panel title={`Stocktake variance — FY to end of ${mLbl}`} aside={`${R.shRows.length} count${R.shRows.length === 1 ? "" : "s"} filed`}>
|
||||
<div className="tc-panel-body">
|
||||
<div className="table-wrap"><table className="table">
|
||||
<thead><tr>{th("Date")}{th("Counted by")}{th("Lines counted", true)}{th("Variances", true)}{th("Net units", true)}{th("Net value", true)}</tr></thead>
|
||||
<tbody>{R.shRows.map((r, i) => <tr key={i}><td style={{ fontWeight: 600 }}>{fmtDate(r.date)}</td><td>{r.by}</td><td style={R2(400)}>{r.counted}</td><td style={R2(400)}>{r.variances}</td><td style={R2(400)}>{signedInt(r.net)}</td><td style={R2(700)}>{signedMoney(r.netVal)}</td></tr>)}</tbody>
|
||||
</table></div>
|
||||
{R.shRows.length === 0 && <Empty pad={4}>No stocktakes filed in this financial year up to the end of this month.</Empty>}
|
||||
</div>
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "Exceptions" && (
|
||||
<Panel title={`Staff exceptions — ${mLbl}`} flag={R.excRows.length > 0}
|
||||
aside={R.excRows.length > 0 ? `${R.excRows.length} to look at` : "No overrides, nobody at the volume threshold"}>
|
||||
<div className="tc-panel-body">
|
||||
<div className="table-wrap"><table className="table">
|
||||
<thead><tr>{th("Staff")}{th("Group")}{th("Cost centre")}{th("Items (month)", true)}{th("Items (FY)", true)}{th("Flag")}</tr></thead>
|
||||
<tbody>{R.excRows.map((r, i) => <tr key={i}><td style={{ fontWeight: 600 }}>{r.who}</td><td>{r.group}</td><td>{r.cc}</td><td style={R2(400)}>{r.mQty}</td><td style={R2(400)}>{r.fyQty}</td><td><span style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>{r.flags.map((f, j) => <span key={j} className="tag tag-flag">{f}</span>)}</span></td></tr>)}</tbody>
|
||||
</table></div>
|
||||
{R.excRows.length === 0 && <Empty pad={4}>No exceptions this period.</Empty>}
|
||||
</div>
|
||||
<div className="tc-panel-foot" style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>Items (FY) is a running tally, not an allowance.</div>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{tab === "Suppliers" && (
|
||||
<Panel title={`Supplier spend — ${mLbl}`} aside="Orders placed this period">
|
||||
<div className="tc-panel-body">
|
||||
<div className="table-wrap"><table className="table">
|
||||
<thead><tr>{th("Supplier")}{th("Orders", true)}{th("Value", true)}{th("Invoices")}</tr></thead>
|
||||
<tbody>{R.supRows.map((r) => <tr key={r.name}><td style={{ fontWeight: 600 }}>{r.name}</td><td style={R2(400)}>{r.n}</td><td style={R2(700)}>{money(r.amt)}</td><td style={{ fontSize: 12 }}>{r.invoices}</td></tr>)}</tbody>
|
||||
</table></div>
|
||||
{R.supRows.length === 0 && <Empty pad={4}>No supplier orders placed this period.</Empty>}
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{tab === "Pre-loved" && (
|
||||
<>
|
||||
<Panel title={`Pre-loved issues — ${mLbl}`} aside={`Saved ${money(R.plSaved)}`}>
|
||||
<div className="tc-panel-body">
|
||||
{R.plIssueRows.length === 0 ? <Empty pad={3}>Nothing issued from the pool this period.</Empty> : (
|
||||
<div className="table-wrap"><table className="table">
|
||||
<thead><tr>{th("Date")}{th("Staff")}{th("Item")}{th("Size")}{th("Qty", true)}{th("Value saved", true)}</tr></thead>
|
||||
<tbody>{R.plIssueRows.map((r, i) => <tr key={i}><td style={{ fontSize: 12 }}>{fmtDate(r.date)}</td><td style={{ fontWeight: 600 }}>{r.who}</td><td>{r.item}</td><td>{r.size}</td><td style={R2(400)}>{r.qty}</td><td style={{ ...R2(400), color: "var(--color-neutral-700)" }}>{money(r.saved)}</td></tr>)}</tbody>
|
||||
</table></div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel title={`Hand-ins — ${mLbl}`} aside={`${R.ragMonth} to rag disposal`}>
|
||||
<div className="tc-panel-body">
|
||||
{R.hiRows.length === 0 ? <Empty pad={3}>No hand-ins recorded this period.</Empty> : (
|
||||
<div className="table-wrap"><table className="table">
|
||||
<thead><tr>{th("Date")}{th("Staff")}{th("Received by")}{th("Good", true)}{th("Rag", true)}{th("Allowance")}</tr></thead>
|
||||
<tbody>{R.hiRows.map((r, i) => <tr key={i}><td style={{ fontSize: 12 }}>{fmtDate(r.date)}</td><td style={{ fontWeight: 600 }}>{r.who}</td><td>{r.by}</td><td style={R2(400)}>{r.good}</td><td style={R2(400)}>{r.rag}</td><td>{r.credit}</td></tr>)}</tbody>
|
||||
</table></div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel title="Pool snapshot" aside={`${R.plPoolTotal} items at $0 book value`}>
|
||||
<div className="tc-panel-body">
|
||||
{R.plPoolRows.length === 0 ? <Empty pad={3}>The pool is empty — record a hand-in from Issue Stock or a staff profile.</Empty> : (
|
||||
<div className="table-wrap"><table className="table">
|
||||
<thead><tr>{th("Item")}{th("Sizes on hand")}{th("Total", true)}</tr></thead>
|
||||
<tbody>{R.plPoolRows.map((r, i) => <tr key={i}><td style={{ fontWeight: 600 }}>{r.item}</td><td>{r.sizes}</td><td style={R2(700)}>{r.total}</td></tr>)}</tbody>
|
||||
</table></div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
{tab === "Approvals" && (
|
||||
<Panel title="Manager’s approvals — uncollected credit" flag={R.apprTot > 0}
|
||||
aside={R.apprTot > 0 ? `${R.apprTot} set${R.apprTot === 1 ? "" : "s"} outstanding` : "Everything approved has been collected"}>
|
||||
<div className="tc-panel-body">
|
||||
<div className="table-wrap"><table className="table">
|
||||
<thead><tr>{th("Staff")}{th("Ward")}{th("Approved by")}{th("Date")}{th("Sets approved", true)}{th("Collected", true)}{th("Remaining", true)}</tr></thead>
|
||||
<tbody>
|
||||
{R.apprRows.map((r, i) => <tr key={i}><td style={{ fontWeight: 600 }}>{r.who}</td><td>{r.dept}</td><td>{r.by}</td><td style={{ fontSize: 12 }}>{fmtDate(r.date)}</td><td style={R2(400)}>{r.sets}</td><td style={R2(400)}>{r.used}</td><td style={{ textAlign: "right", fontWeight: 700, color: "var(--color-accent-700)" }}>{r.rem}</td></tr>)}
|
||||
<tr><td style={{ fontWeight: 800 }}>TOTAL OUTSTANDING</td><td></td><td></td><td></td><td></td><td></td><td style={R2(800)}>{R.apprTot} sets</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
{R.apprRows.length === 0 && <Empty pad={4}>No uncollected approvals.</Empty>}
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
{drill && (
|
||||
<Dialog title={`Issues behind ${drill.cc} — ${mLbl}`} width={820} onClose={() => setDrill(null)}
|
||||
sub={`${drill.dept} · ${drillQty} item${drillQty === 1 ? "" : "s"} · ${money(drillAmt)}`}>
|
||||
{drillRows.length === 0 ? <Empty pad={3}>Nothing was issued against this cost centre in {mLbl}.</Empty> : (
|
||||
<div className="table-wrap"><table className="table" style={{ marginTop: "var(--space-3)" }}>
|
||||
<thead><tr>{th("Date")}{th("Staff")}{th("Item")}{th("Size")}{th("Qty", true)}{th("Unit cost", true)}{th("Value", true)}</tr></thead>
|
||||
<tbody>
|
||||
{drillRows.map((r, i) => <tr key={i}><td style={{ fontSize: 12 }}>{fmtDate(r.date)}</td><td style={{ fontWeight: 600 }}>{r.who}</td><td>{r.item}</td><td>{r.size}</td><td style={R2(400)}>{r.qty}</td><td style={{ ...R2(400), color: "var(--color-neutral-700)" }}>{money(r.unit)}</td><td style={R2(700)}>{money(r.amt)}</td></tr>)}
|
||||
<tr><td style={{ fontWeight: 800 }}>TOTAL</td><td></td><td></td><td></td><td style={R2(800)}>{drillQty}</td><td></td><td style={R2(800)}>{money(drillAmt)}</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
)}
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", justifyContent: "flex-end", marginTop: "var(--space-4)" }}>
|
||||
{drillRows.length > 0 && <button className="btn btn-secondary" style={{ marginRight: "auto" }} onClick={csvDrill}>Export CSV</button>}
|
||||
<button className="btn btn-ghost" onClick={() => setDrill(null)}>Close</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
)}
|
||||
<div style={{ marginTop: "var(--space-3)", fontSize: 12, color: "var(--color-neutral-700)" }}>Print and Export CSV follow the selected tab — click a cost centre for the issues behind it.</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,862 @@
|
||||
"use client";
|
||||
/* Staff requests, from the linen room's side.
|
||||
*
|
||||
* The counter's queue. A request only appears here as something to act on once a ward manager has
|
||||
* approved it — anything still `awaiting` is shown, greyed, so the linen room can see what is
|
||||
* coming without being able to do anything about it. That asymmetry is the point of the whole
|
||||
* flow: approval is the ward's, fulfilment is the linen room's, and neither can do the other's job.
|
||||
*
|
||||
* A request covers as many garments as the person asked for, one line each, and the manager can
|
||||
* knock back individual lines — the tunic and the trousers yes, the fleece no. So every screen
|
||||
* here has to keep two ideas apart: `lines` is the record of what was asked, `bag` is what is
|
||||
* actually picked. Picking off `lines` would put a garment the ward refused into somebody's hands,
|
||||
* so the pick, the count, the slip and the collection code are all built from `bag`.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { openSlip } from "@/components/dialogs";
|
||||
import { PageHead, Empty, ErrorLine, Field } from "@/components/ui";
|
||||
import { csvEsc, csvOf, facilityDate, fmtDate, formatInZone, genderLabel, slipLive, type Snapshot } from "@/lib/compute";
|
||||
import { downloadCsv } from "@/lib/print";
|
||||
import type { ReqLine } from "@/lib/staffdata";
|
||||
import { NEEDS_STAFF, OPEN_REQUEST, WAITLIST_HOLD_HOURS, holdEndsAt, holdExpired, statusText } from "@/lib/staffreq";
|
||||
|
||||
type Msg = { id: string; fromStaff: boolean; authorName: string; body: string; at: string };
|
||||
type Ev = { id: string; label: string; meta: string; actorName: string; at: string };
|
||||
type Req = {
|
||||
id: string; code: string; status: string; staffId: string; staffName: string; staffNum: string; ward: string;
|
||||
/** Everything asked for, declines included — and separately the ones that are actually a pick. */
|
||||
lines: ReqLine[]; bag: ReqLine[];
|
||||
summary: string; garments: number; lineCount: number; decision: string | null;
|
||||
reason: string; note: string;
|
||||
managerName: string;
|
||||
/** Which person on the register the approver is — not just how their name is spelled. A manager
|
||||
* may approve a request raised for herself, and the only thing that can show that happened is
|
||||
* this id beside the wearer's: two spellings of one name tell nobody anything. Null while
|
||||
* nobody has been asked. */
|
||||
managerId: string | null;
|
||||
declineReason: string | null; route: string | null;
|
||||
collectCode: string | null; holdUntil: string; signerName: string | null; signerRole: string | null;
|
||||
signedAt: string | null; claimedAt: string | null;
|
||||
/** Who raised it, and which person on the register that is. The id is what the approver list is
|
||||
* built on: a ward register carries people who share a name, and telling them apart by spelling
|
||||
* is how the wrong one gets dropped out of a dropdown. Null when the wearer raised it herself,
|
||||
* and null when the linen room raised it at the counter — that one is stamped with the
|
||||
* coordinator's own account, which is not on the ward register at all. Neither is a name this
|
||||
* screen could have offered anyway. */
|
||||
raisedById: string | null; raisedByName: string;
|
||||
createdAt: string; decidedAt: string | null; messages: Msg[]; events: Ev[];
|
||||
};
|
||||
/** One name in the re-address dropdown: who they are, whether anything can actually reach them,
|
||||
* and the words the coordinator reads before picking them. */
|
||||
type ApproverChoice = { id: string; reachable: boolean; label: string };
|
||||
type Dispute = { id: string; body: string; staffName: string; staffNum: string; ward: string; at: string };
|
||||
type Cycle = { id: string; dueBy: string; openedBy: string; openedAt: string; answers: number };
|
||||
type Waiting = { id: string; staffName: string; staffNum: string; ward: string; item: string; size: string; since: string; offeredAt: string | null };
|
||||
type Damage = { id: string; kind: string; note: string; photoId: string | null; staffId: string; staffName: string; staffNum: string; ward: string; item: string; size: string; requestCode: string; at: string };
|
||||
type Shortfall = {
|
||||
id: string; staffId: string; staffName: string; staffNum: string; ward: string;
|
||||
item: string; size: string; onRecord: number; confirmed: number; short: number; at: string;
|
||||
};
|
||||
/* `requestLimit` and `moreRequests` are the endpoint saying how much of the queue this is. It reads
|
||||
one row past its own ceiling so that "there are older ones than these" is a fact rather than a
|
||||
guess off a full page — and nothing here read it, so the list just stopped at the newest 400 with
|
||||
no word to anybody. Every tab on this screen is a view of that same set, so a request from before
|
||||
the cut-off is on none of them and in nothing exported from them. */
|
||||
type Payload = { requests: Req[]; disputes: Dispute[]; cycle: Cycle | null; shortfalls: Shortfall[]; waiting: Waiting[]; damage: Damage[]; requestLimit: number; moreRequests: boolean };
|
||||
|
||||
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
|
||||
|
||||
/* What a tab is called once it has to be named away from its own button — over the queue it heads,
|
||||
and at the top of a file exported off it. One spelling in one place, so a spreadsheet that has
|
||||
left the building can never disagree with the screen about which view it came from. */
|
||||
const TAB_TITLE = {
|
||||
todo: "To do", noapprover: "Needs an approver", open: "Open", all: "All",
|
||||
queries: "Record queries", damage: "Damage", cycles: "Kit check & waitlist",
|
||||
} as const;
|
||||
|
||||
/** A request with nothing in its `managerName` never got an approver at all.
|
||||
*
|
||||
* That happens the moment a manager raises for one of their own reports: they would otherwise be
|
||||
* approving their own raise, so the staff app sends it up a level — and when there is nobody above
|
||||
* them, or the one above is themselves, it is created with no approver and waits here. Nobody on the ward can move it, so if this
|
||||
* screen did not say so out loud it would simply sit in the queue for ever. */
|
||||
const stranded = (r: Req) => r.status === "awaiting" && !r.managerName;
|
||||
|
||||
/** Is this bag going out on the ward round rather than waiting at the counter? Decides which of
|
||||
* the two slips is the one worth printing. */
|
||||
const onRound = (r: Req) => r.status === "round" || r.status === "delivered";
|
||||
|
||||
/* Who a waiting request can be handed to, and what has to be said about each name before it is
|
||||
* picked.
|
||||
*
|
||||
* The wearer is on the list like anybody else: anyone may approve for themselves (the owner's
|
||||
* decision), and it is marked Self-approved wherever it shows. The option says out loud that this
|
||||
* is a self-approval, because otherwise the coordinator is choosing between two spellings of the
|
||||
* same person and finds out what they did from the timeline months later.
|
||||
*
|
||||
* The person who raised it is off the list altogether, and that one has no way back in. A
|
||||
* manager asking for one of her own reports' garments is the whole reason the request escalated
|
||||
* and landed on this tab with nobody to approve it — and she is the obvious pick, because she IS
|
||||
* the wearer's manager on the register, with nothing on the row to say the ask came from her.
|
||||
* Handing it back to her would have one person do both halves of a decision the ward is told two
|
||||
* people made, so it is turned down the moment the button is pressed. Leaving her in the list
|
||||
* made the Needs an approver tab offer the one name on it certain to fail, on the tab that
|
||||
* exists to fix exactly that. It is her id that keeps her off the list and nothing else, which is
|
||||
* why the queue carries it: a ward can hold two people spelled the same way, only one of them
|
||||
* raised this, and the counter refuses on the id too.
|
||||
*
|
||||
* Reachability is the other half, and nothing refuses it: a manager with no staff-app account
|
||||
* cannot be asked at all. The approval e-mail has nowhere to go and they cannot sign in to
|
||||
* decide it, so re-addressing to one of them puts the request straight back in the dead end it
|
||||
* was being rescued from — except that it does not come back to this tab, because it now has a
|
||||
* name against it. Said on the option, before it is chosen. A printed code only counts as a way in
|
||||
* while the activation would still take it, which is slipLive's call and nobody else's: "a code is
|
||||
* outstanding" is all the register holds, and reading that as live had the dropdown calling a slip
|
||||
* worth chasing that the person would be turned away with, while the staff register, looking at
|
||||
* the same person, said they had no staff app at all.
|
||||
*
|
||||
* The raiser rule is the counter's rule said a second time, in a screen, and two copies of a rule
|
||||
* agree only until one of them is edited. The queue could settle it by arriving with the answer
|
||||
* already worked out — the ids this particular request can be sent to, decided where the refusal
|
||||
* itself lives — and then a name is on this list exactly when it would be accepted, and this
|
||||
* function is left with nothing to do but the words. */
|
||||
function approverChoices(s: Snapshot, r: Req): ApproverChoice[] {
|
||||
return s.staff
|
||||
.filter((x) => !x.inactive && x.first && x.id !== r.raisedById && (x.id !== r.staffId || x.managerId === x.id))
|
||||
.map((x) => ({
|
||||
id: x.id,
|
||||
reachable: !!x.selfEmail,
|
||||
label: `${`${x.first} ${x.last}`.trim()}${x.dept ? ` · ${x.dept}` : ""}`
|
||||
+ (x.id === r.staffId ? " · this request is theirs — self-approval" : "")
|
||||
+ (x.selfEmail ? "" : x.selfCode && slipLive(x.selfCodeAt, s.today, s.tz) ? " · code printed, not used yet" : " · no staff-app account"),
|
||||
}));
|
||||
}
|
||||
|
||||
/** The whole ask, line by line, with the manager's answer against each garment.
|
||||
*
|
||||
* The declines stay on the list rather than being dropped: the wearer will ask why they got two
|
||||
* things and not three, and the person at the counter needs the answer in front of them. They are
|
||||
* struck through so nobody picks one by mistake. */
|
||||
function LineList({ r }: { r: Req }) {
|
||||
const refused = r.lines.filter((l) => l.status === "declined").length;
|
||||
const note =
|
||||
r.status === "declined" ? "Nothing to pick — every line was declined."
|
||||
: r.status === "awaiting" ? `${plural(r.garments, "garment", "garments")} asked for. Nothing is picked until the ward has decided.`
|
||||
: refused > 0 ? `In the bag: ${plural(r.garments, "garment", "garments")} across ${plural(r.bag.length, "line", "lines")}. The ${refused === 1 ? "declined line is" : `${refused} declined lines are`} not picked.`
|
||||
: `In the bag: ${plural(r.garments, "garment", "garments")}.`;
|
||||
return (
|
||||
<div style={{ marginBottom: "var(--space-3)" }}>
|
||||
{r.lines.map((l) => {
|
||||
const off = l.status === "declined";
|
||||
return (
|
||||
<div key={l.id} style={{ display: "flex", gap: "var(--space-3)", alignItems: "baseline", padding: "var(--space-2) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13.5, flexWrap: "wrap" }}>
|
||||
<span style={{ flex: 1, minWidth: 180, fontWeight: off ? 400 : 600, textDecoration: off ? "line-through" : "none", color: off ? "var(--color-neutral-700)" : undefined }}>
|
||||
{l.qty} × {l.item}{l.gender && l.gender !== "Unisex" ? ` (${genderLabel(l.gender)})` : ""} — {l.size}
|
||||
</span>
|
||||
<span className={l.status === "approved" ? "tag tag-neutral" : off ? "tag tag-outline" : "tag tag-accent"}>{l.statusLabel}</span>
|
||||
{off && l.declineReason && <span style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>{l.declineReason}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div style={{ fontSize: 12.5, color: "var(--color-neutral-700)", marginTop: "var(--space-2)" }}>{note}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RequestsPage() {
|
||||
const { s, mutate } = useSnap();
|
||||
const [data, setData] = useState<Payload | null>(null);
|
||||
const [tab, setTab] = useState<"todo" | "noapprover" | "open" | "all" | "queries" | "damage" | "cycles">("todo");
|
||||
const [dueBy, setDueBy] = useState("");
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [reply, setReply] = useState("");
|
||||
const [hold, setHold] = useState("");
|
||||
/** The replacement approver picked for a stranded `awaiting` request. */
|
||||
const [reassign, setReassign] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
/* "Still loading" and "the queue never arrived" look identical from the outside, and this screen
|
||||
is the linen room's work list — reading it as empty when the fetch failed means a ward waits on
|
||||
a request nobody knows about. So the failure is said out loud and can be retried. */
|
||||
const [loadErr, setLoadErr] = useState("");
|
||||
const load = useCallback(async () => {
|
||||
setLoadErr("");
|
||||
try {
|
||||
const r = await fetch("/api/requests");
|
||||
if (!r.ok) { const j = await r.json().catch(() => ({})); setLoadErr(j.error || "Couldn’t load the request queue."); return; }
|
||||
setData(await r.json());
|
||||
} catch {
|
||||
setLoadErr("Couldn’t reach the server — the request queue isn’t loaded.");
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
async function act(op: string, payload: unknown) {
|
||||
setErr("");
|
||||
const r = await mutate(op, payload);
|
||||
if (!r.ok) { setErr(r.error); return false; }
|
||||
await load();
|
||||
return true;
|
||||
}
|
||||
|
||||
/* What goes on the printed slip.
|
||||
*
|
||||
* The bag, never the whole ask: a slip that listed a garment the ward declined would have
|
||||
* somebody hunting the shelf for it, and the person signing would sign for three things and get
|
||||
* two. The request's own code goes in the order-number field and the collection code is printed
|
||||
* beside the name, because one code now covers several garments and it is the only thing that
|
||||
* ties this piece of paper to that bag. */
|
||||
const slipFor = (r: Req) => ({
|
||||
staffName: r.staffName, dept: r.ward, deliverTo: r.ward,
|
||||
sets: r.garments, po: r.code, code: r.collectCode || "",
|
||||
// The cut goes on the slip. Two garments can share a name and differ only by it — an
|
||||
// Ambassador Shirt comes men's and ladies, on different style codes and different shelves —
|
||||
// and a line reading "1 × Ambassador Shirt — M" gives whoever is picking no way to tell which,
|
||||
// which is a wrong garment in the bag and a return later. Omitted for unisex, where it is noise.
|
||||
lines: r.bag.map((l) => `${l.qty} × ${l.item}${l.gender && l.gender !== "Unisex" ? ` (${genderLabel(l.gender)})` : ""} — ${l.size}`).join("\n"),
|
||||
dateReceived: s.today, requestedBy: r.staffNum,
|
||||
deliveredBy: s.settings.coordinator, dateTime: s.today,
|
||||
});
|
||||
|
||||
/* The dropdown's list, worked out once and only for the row that is actually open.
|
||||
*
|
||||
* Every name on it is a walk of the whole register, and it used to be built for every waiting
|
||||
* request on screen although only the open one can show a dropdown — on a busy register, forty
|
||||
* walks to draw one list. The reply box further down shares this component's state, so that
|
||||
* whole pass ran again on every letter typed into a message to a ward. */
|
||||
const openReq = data?.requests.find((r) => r.id === openId) ?? null;
|
||||
const choices = useMemo<ApproverChoice[]>(
|
||||
() => (openReq && openReq.status === "awaiting" ? approverChoices(s, openReq) : []),
|
||||
[s, openReq],
|
||||
);
|
||||
|
||||
if (!data) return (
|
||||
<section>
|
||||
<PageHead eyebrow="Ward requests" title="Staff requests" />
|
||||
{loadErr ? (
|
||||
<>
|
||||
<ErrorLine msg={loadErr} />
|
||||
<div style={{ marginTop: "var(--space-3)" }}><button className="btn btn-secondary" onClick={() => void load()}>Try again</button></div>
|
||||
</>
|
||||
) : <Empty>Loading…</Empty>}
|
||||
</section>
|
||||
);
|
||||
|
||||
// "To do" is the linen room's actual work queue: approved and not yet handed over.
|
||||
const todo = data.requests.filter((r) => ["accepted", "picking", "ready", "round"].includes(r.status));
|
||||
const open = data.requests.filter((r) => OPEN_REQUEST.has(r.status as never));
|
||||
// Requests nobody was ever asked to approve. Their own tab because they are the only thing on
|
||||
// this screen that is stuck rather than merely waiting, and the fix — give it an approver — is
|
||||
// the linen room's to make and nobody else's.
|
||||
const noApprover = data.requests.filter(stranded);
|
||||
const rows = tab === "todo" ? todo : tab === "noapprover" ? noApprover : tab === "open" ? open : tab === "all" ? data.requests : [];
|
||||
/* The queue arrives newest first and stops at its ceiling, so what is missing is always the
|
||||
oldest — and old is exactly what a stranded request or a bag nobody collected becomes. Every
|
||||
request tab is a narrowing of that one set, so every one of them carries the mark, not just
|
||||
All: a coordinator who cleared Needs an approver to a bare 0 would take the ward's stuck
|
||||
requests to be dealt with while the longest-stuck of them sat past the cut-off, unseen. */
|
||||
const more = data.moreRequests ? "+" : "";
|
||||
const inLoaded = data.moreRequests ? ` among the most recent ${data.requestLimit} requests` : "";
|
||||
|
||||
/* Export — the tab on screen, and nothing else.
|
||||
|
||||
Every tab here is a view of the same queue narrowed a different way, so a coordinator who has
|
||||
narrowed to To do and hits Export means that work queue, not eighteen months of requests. There
|
||||
is no search box on this screen, so the tab is the only filter in force and honouring it is the
|
||||
whole job. The file is named after the tab as well: four exports all called
|
||||
threadcount-requests-2026-09-11.csv land in one Downloads folder as "(1)" and "(2)", and by
|
||||
Monday nobody can say which one the ward was sent.
|
||||
|
||||
The last three tabs are not narrowings of the request queue at all — a record query, a damage
|
||||
report and a kit-check answer share no columns with a request and no columns with each other —
|
||||
so each writes its own table rather than being forced into one shape with most cells empty.
|
||||
Kit check & waitlist is two registers on one screen, so it writes two tables into the one file,
|
||||
the way the pre-loved report does; folding them together would put a garment somebody is
|
||||
queueing for in the same column as a garment somebody has lost. */
|
||||
const shown = tab === "queries" ? data.disputes.length
|
||||
: tab === "damage" ? data.damage.length
|
||||
: tab === "cycles" ? data.shortfalls.length + data.waiting.length
|
||||
: rows.length;
|
||||
|
||||
function exportCsv() {
|
||||
// Hoisted, so the checker cannot see the early return above that already proved this is here —
|
||||
// and it is right not to: a function declaration can be called from anywhere in the body. The
|
||||
// button is only rendered once the data has loaded, so this never fires; it is here to make the
|
||||
// guarantee local to the function that relies on it.
|
||||
if (!data) return;
|
||||
/* Full date and 24-hour time, in the facility's zone, with the zone named at the top of the
|
||||
file. The screen prints "9 Sep" because you read it in order; a spreadsheet gets re-sorted the
|
||||
moment it lands, and "9 Sep, 14:32" sorts as text into nonsense and carries no year at all.
|
||||
Empty rather than an em dash where there is no instant — a dash in a spreadsheet cell is only
|
||||
noise to filter around. */
|
||||
const when = (iso: string | null) =>
|
||||
iso ? `${facilityDate(iso, s.tz)} ${formatInZone(iso, s.tz, { hour: "2-digit", minute: "2-digit", hour12: false, hourCycle: "h23" })}` : "";
|
||||
/* The header block above the column headings, so a file that has left the building still says
|
||||
which view it is, when it was taken and what zone its times are in. */
|
||||
const preamble = (facts: [string, string | number][]) =>
|
||||
facts.map(([k, v]) => `${csvEsc(k)},${typeof v === "number" ? v : csvEsc(v)}`).join("\n") + "\n\n";
|
||||
|
||||
if (tab === "queries") {
|
||||
downloadCsv(`threadcount-record-queries-${s.today}.csv`,
|
||||
preamble([["Record queries", "Raised against a staff record, not yet sorted"], ["Exported", s.today], ["Times shown in", s.tz], ["Queries in this file", data.disputes.length]])
|
||||
+ csvOf(["Raised", "Staff no.", "Staff member", "Ward", "What they say is wrong"],
|
||||
data.disputes.map((d) => [when(d.at), d.staffNum, d.staffName, d.ward, d.body])));
|
||||
return;
|
||||
}
|
||||
|
||||
if (tab === "damage") {
|
||||
downloadCsv(`threadcount-damage-${s.today}.csv`,
|
||||
preamble([["Damage reported", "Not yet handed in at the counter"], ["Exported", s.today], ["Times shown in", s.tz], ["Reports in this file", data.damage.length]])
|
||||
+ csvOf(["Reported", "Staff no.", "Staff member", "Ward", "Garment", "Size", "Damage", "What they said", "Replacement requested", "Photo"],
|
||||
// The issue a report was raised against can be deleted, and the screen says so in words
|
||||
// rather than showing a blank. A blank cell here would read as a gap in the export.
|
||||
data.damage.map((d) => [when(d.at), d.staffNum, d.staffName, d.ward, d.item || "Garment no longer on file", d.size, d.kind, d.note, d.requestCode, d.photoId ? "Yes" : "No"])));
|
||||
return;
|
||||
}
|
||||
|
||||
if (tab === "cycles") {
|
||||
const c = data.cycle;
|
||||
downloadCsv(`threadcount-kit-check-${s.today}.csv`,
|
||||
preamble([
|
||||
["Kit check and waitlist", c ? `Running — due by ${c.dueBy}` : "No kit check running"],
|
||||
["Opened by", c ? c.openedBy || "—" : ""],
|
||||
["Answers in", c ? c.answers : 0],
|
||||
["Exported", s.today],
|
||||
["Times shown in", s.tz],
|
||||
])
|
||||
// Nothing in this table has changed anybody's record, exactly as the screen says. It is the
|
||||
// working list for squaring the register one garment at a time, so it goes out with the
|
||||
// person and the size on every row rather than as a count of answers.
|
||||
+ "What people couldn't account for\n"
|
||||
+ csvOf(["Staff no.", "Staff member", "Ward", "Garment", "Size", "On record", "Confirmed", "Short", "Answered"],
|
||||
data.shortfalls.map((f) => [f.staffNum, f.staffName, f.ward, f.item, f.size, f.onRecord, f.confirmed, f.short, when(f.at)]))
|
||||
+ "\nWaiting for a size\n"
|
||||
+ csvOf(["Staff no.", "Staff member", "Ward", "Garment", "Size", "Waiting since", "Offered", "Held until", "Hold"],
|
||||
data.waiting.map((w) => {
|
||||
// The deadline is computed the one way the product computes it, so a file taken off
|
||||
// this screen can never disagree with the screen about whose garment it still is.
|
||||
const ends = holdEndsAt(w.offeredAt);
|
||||
return [w.staffNum, w.staffName, w.ward, w.item, w.size, when(w.since), when(w.offeredAt),
|
||||
ends ? when(ends.toISOString()) : "",
|
||||
!w.offeredAt ? "Not offered yet" : holdExpired(w.offeredAt) ? "Lapsed — offer to the next person" : "Held"];
|
||||
})));
|
||||
return;
|
||||
}
|
||||
|
||||
/* A row is a garment, not a request.
|
||||
|
||||
A request covers as many garments as the person asked for and the manager decides each one
|
||||
separately — the tunic and the trousers yes, the fleece no. One row per request could only
|
||||
carry the rollup, "2 of 3 approved", and the question this file is opened to answer is
|
||||
precisely the one that would then be missing: which garment was refused, and why. So the
|
||||
request's own facts repeat down its lines. That repetition is what makes the file worth
|
||||
having in a spreadsheet — every declined fleece in the hospital is one filter on Line
|
||||
decision — and the count in the preamble says how many requests those rows came from, so
|
||||
nobody reads nineteen rows as nineteen requests.
|
||||
|
||||
The two decisions keep their own words, because they are not the same decision and this file
|
||||
goes to a ward manager. A LINE is approved or declined, and Line decision is the word the
|
||||
line already carries from lineStatusLabel(). A REQUEST is accepted or declined, and Request
|
||||
status is statusText()'s label — the same words as the tag on the row. Decision summary is
|
||||
decisionSummary()'s rollup and nothing recomputed here. Request decline reason is the
|
||||
request-level one, which is as often the linen room withdrawing an unapprovable request as
|
||||
it is the ward refusing the whole ask.
|
||||
|
||||
A blank Approver is the Needs an approver tab's own definition — nobody was ever asked — so
|
||||
those requests stay identifiable after they have been filed away with the rest. Approver is
|
||||
the wearer says a manager was asked to sign for her own kit, which the product allows and
|
||||
the ward may reasonably want to see; two matching names in adjacent columns is not something
|
||||
anybody spots reading down a file, and on a ward where two people share a name it is not
|
||||
even true. It is a fact about who was asked, so it is filled in on a request still waiting
|
||||
as much as on one already decided.
|
||||
|
||||
Held until is left out: it is free text somebody typed at the counter ("Fri 6pm"), and a
|
||||
column of that sorts into nonsense beside four real dates. Ward is the wearer's ward, as the
|
||||
row on screen states it; a bag already out on a round was routed to the ward she was on when
|
||||
the trolley loaded, which after a transfer is a different one. */
|
||||
const title = TAB_TITLE[tab];
|
||||
downloadCsv(`threadcount-requests-${tab === "noapprover" ? "needs-an-approver" : tab}-${s.today}.csv`,
|
||||
preamble([
|
||||
["Ward requests", title],
|
||||
["Exported", s.today],
|
||||
["Times shown in", s.tz],
|
||||
["Requests in this file", rows.length],
|
||||
// A file that is short of the register says so in its own header, because the person who
|
||||
// opens it in three months has no screen beside it to work that out from.
|
||||
...(data.moreRequests
|
||||
? ([["Older requests not in this file", `The screen holds the most recent ${data.requestLimit} requests and there are older ones than those`]] as [string, string][])
|
||||
: []),
|
||||
["Rows", "One per line on the request — a request for a tunic and two pairs of trousers is two rows, and the pairs are a Qty of 2 on the second"],
|
||||
])
|
||||
+ csvOf(["Request", "Raised", "Staff no.", "Staff member", "Ward", "Raised by", "Reason", "Note", "Request status", "Approver", "Approver is the wearer", "Decision summary", "Decided", "Request decline reason", "Collection code", "Garment", "Cut", "Size", "Qty", "Line decision", "Line decline reason"],
|
||||
rows.flatMap((r) => {
|
||||
const req: (string | number)[] = [
|
||||
r.code, when(r.createdAt), r.staffNum, r.staffName, r.ward, r.raisedByName, r.reason, r.note,
|
||||
statusText(r).label, r.managerName, r.managerId && r.managerId === r.staffId ? "Yes" : "",
|
||||
r.decision ?? "", when(r.decidedAt), r.declineReason ?? "", r.collectCode ?? "",
|
||||
];
|
||||
/* A request with no lines on it still has to appear. It is only ever a half-written raise
|
||||
or one whose garment was deleted from the catalogue, but it is sitting in somebody's
|
||||
queue, and a file built by walking lines would drop it silently — which on the Needs an
|
||||
approver tab would hide the one kind of request nobody else can rescue. */
|
||||
const lines: (ReqLine | null)[] = r.lines.length ? r.lines : [null];
|
||||
return lines.map((l) => [...req,
|
||||
l ? l.item : "", l ? genderLabel(l.gender) : "", l ? l.size : "", l ? l.qty : "",
|
||||
l ? l.statusLabel : "", l ? l.declineReason ?? "" : ""]);
|
||||
})));
|
||||
}
|
||||
|
||||
/* The two optional names are for the actions that repeat down the queue. A button reading "Print
|
||||
order form" says nothing about which request it belongs to once you are hearing it rather than
|
||||
looking at it, and the counter shares a printer — so what a button is about to put on paper is
|
||||
worth knowing before it is pressed. */
|
||||
const Btn = ({ label, onClick, primary, ariaLabel, title }: { label: string; onClick: () => void; primary?: boolean; ariaLabel?: string; title?: string }) => (
|
||||
<button className={primary ? "btn btn-primary" : "btn btn-secondary"} style={{ minHeight: 34 }} onClick={onClick} aria-label={ariaLabel} title={title}>{label}</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<PageHead
|
||||
eyebrow="Ward requests"
|
||||
title="Staff requests"
|
||||
sub="Raised in the staff app, approved by the ward manager, fulfilled here."
|
||||
>
|
||||
{/* One button, and it writes the tab you are looking at. The count is said out loud wherever
|
||||
the tab is a narrowing, because that is the difference between a file of this morning's
|
||||
work and a file of the whole register, and the two are indistinguishable once they are
|
||||
attachments on an email. Not on Kit check & waitlist: that tab counts one of its two
|
||||
lists, and a number here that disagreed with the number on the tab would be read as a
|
||||
bug in the file rather than as two different things being counted. */}
|
||||
<button className="btn btn-ghost" onClick={exportCsv} disabled={shown === 0}
|
||||
title={tab === "cycles" ? "Downloads the kit check and the waitlist."
|
||||
: tab === "queries" ? "Downloads the record queries on screen."
|
||||
: tab === "damage" ? "Downloads the damage reports on screen."
|
||||
: "Downloads the tab on screen, one row per garment."}>
|
||||
{tab === "all" || tab === "cycles" ? "Export CSV" : `Export CSV (${shown} shown)`}
|
||||
</button>
|
||||
</PageHead>
|
||||
<ErrorLine msg={err} />
|
||||
<ErrorLine msg={loadErr} />
|
||||
|
||||
{/* A stranded request is the one thing here that nobody else can rescue. It never reaches the
|
||||
To do queue, it looks like any other greyed `awaiting` row in Open, and the ward is sitting
|
||||
waiting on an approval that was never asked for — so it is said before the tabs rather
|
||||
than found by opening one. */}
|
||||
{noApprover.length > 0 && tab !== "noapprover" && (
|
||||
/* A rule down the edge, a mark and a heavier figure rather than a red box. The primary
|
||||
button an inch away is the same red, so a red outline on its own is not a signal — and
|
||||
this is the one thing on the screen nobody but the linen room can rescue. */
|
||||
<div className="tc-flag" style={{ borderTop: "2px solid var(--color-text)", borderRight: "2px solid var(--color-text)", borderBottom: "2px solid var(--color-text)", padding: "var(--space-3)", marginTop: "var(--space-4)", display: "flex", gap: "var(--space-3)", alignItems: "baseline", flexWrap: "wrap" }}>
|
||||
<b style={{ flex: 1, minWidth: 260, fontSize: 13.5, lineHeight: 1.6 }}>
|
||||
<span className="tc-mark" aria-hidden="true" />
|
||||
<span className="tc-row-fig" style={{ color: "var(--color-accent-700)", marginRight: 6 }}>{noApprover.length}</span>
|
||||
{noApprover.length === 1 ? "request has" : "requests have"} nobody to approve {noApprover.length === 1 ? "it" : "them"} —
|
||||
the ward is waiting on a decision that was never asked for.
|
||||
</b>
|
||||
<button className="btn btn-primary" style={{ minHeight: 34 }} onClick={() => setTab("noapprover")}>Address {noApprover.length === 1 ? "it" : "them"}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-4)", flexWrap: "wrap" }}>
|
||||
{([["todo", `To do ${todo.length}${more}`], ["noapprover", `Needs an approver ${noApprover.length}${more}`], ["open", `Open ${open.length}${more}`], ["all", `All ${data.requests.length}${more}`], ["queries", `Record queries ${data.disputes.length}`], ["damage", `Damage ${data.damage.length}`], ["cycles", `Kit check & waitlist ${data.waiting.length}`]] as const).map(([k, lbl]) => (
|
||||
<button key={k} className={tab === k ? "btn btn-primary" : "btn btn-secondary"} style={{ minHeight: 34 }} onClick={() => setTab(k)}>{lbl}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "cycles" ? (
|
||||
<>
|
||||
<div className="sec" style={{ marginTop: "var(--space-5)" }}>Kit check</div>
|
||||
{data.cycle ? (
|
||||
<div style={{ padding: "var(--space-3) 0", borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<div style={{ display: "flex", gap: "var(--space-3)", alignItems: "baseline", flexWrap: "wrap" }}>
|
||||
<b style={{ flex: 1 }}>Running — due by {fmtDate(data.cycle.dueBy)}</b>
|
||||
<span style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>
|
||||
{data.cycle.answers} answer{data.cycle.answers === 1 ? "" : "s"} in · opened by {data.cycle.openedBy || "—"}
|
||||
</span>
|
||||
<Btn label="Close the round" onClick={() => act("kitcheck.close", { id: data.cycle!.id })} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: "var(--space-3) 0", borderBottom: "1px solid var(--color-divider)", display: "flex", gap: "var(--space-2)", alignItems: "flex-end", flexWrap: "wrap" }}>
|
||||
<Field label="Due by" style={{ width: 200 }}>{(c) => <input {...c} className="input" type="date" value={dueBy} onChange={(e) => setDueBy(e.target.value)} />}</Field>
|
||||
<Btn primary label="Start a kit check" onClick={async () => { if (await act("kitcheck.open", { dueBy })) setDueBy(""); }} />
|
||||
<span style={{ fontSize: 13, color: "var(--color-neutral-700)", flex: 1, minWidth: 240 }}>
|
||||
Asks everyone holding uniform to confirm what they have.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The answers themselves, which nothing in the product used to show.
|
||||
People answer a kit check garment by garment, and the shortfalls are the only reason
|
||||
to run one — a count of replies tells the linen room nothing it can act on. Written
|
||||
down here, per person and per size, they are the working list for correcting the
|
||||
register: open the record, hand in or write off the line, and the next cycle starts
|
||||
from a register that is true. */}
|
||||
<div className="sec" style={{ marginTop: "var(--space-6)" }}>What people couldn't account for</div>
|
||||
{!data.cycle ? (
|
||||
<Empty pad={3}>No kit check is running.</Empty>
|
||||
) : data.shortfalls.length === 0 ? (
|
||||
<Empty pad={3}>
|
||||
{data.cycle.answers === 0
|
||||
? "Nobody has answered yet."
|
||||
: `Every one of the ${data.cycle.answers} answer${data.cycle.answers === 1 ? "" : "s"} so far matched the record.`}
|
||||
</Empty>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-wrap"><table className="table" style={{ marginTop: "var(--space-2)" }}>
|
||||
<thead><tr>
|
||||
<th style={{ textAlign: "left" }}>Who</th>
|
||||
<th style={{ textAlign: "left" }}>Garment</th>
|
||||
<th style={{ textAlign: "left" }}>Size</th>
|
||||
<th style={{ textAlign: "right" }}>On record</th>
|
||||
<th style={{ textAlign: "right" }}>Confirmed</th>
|
||||
<th style={{ textAlign: "right" }}>Short</th>
|
||||
<th style={{ textAlign: "left" }}>Answered</th>
|
||||
<th />
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{data.shortfalls.map((f) => (
|
||||
<tr key={f.id}>
|
||||
<td>{f.staffName}<span style={{ color: "var(--color-neutral-700)" }}> · {f.staffNum}{f.ward ? ` · ${f.ward}` : ""}</span></td>
|
||||
<td>{f.item}</td>
|
||||
<td>{f.size}</td>
|
||||
<td style={{ textAlign: "right", fontVariantNumeric: "tabular-nums" }}>{f.onRecord}</td>
|
||||
<td style={{ textAlign: "right", fontVariantNumeric: "tabular-nums" }}>{f.confirmed}</td>
|
||||
{/* The number this table exists for. Marked as well as coloured: every other
|
||||
figure in the row is a plain count, and what tells them apart across the
|
||||
counter is the mark, not another shade of the brand red. */}
|
||||
<td style={{ textAlign: "right", fontVariantNumeric: "tabular-nums", fontWeight: 800, color: "var(--color-accent-700)", whiteSpace: "nowrap" }}><span className="tc-mark" aria-hidden="true" />{f.short}</td>
|
||||
<td style={{ whiteSpace: "nowrap", color: "var(--color-neutral-700)" }}>{formatInZone(f.at, s.tz)}</td>
|
||||
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}><Link href={`/app/staff/${f.staffId}`}>Open their record</Link></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table></div>
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: "var(--color-neutral-700)", marginTop: "var(--space-3)", maxWidth: "70ch" }}>
|
||||
Nothing here changes a record — open it and return the missing garments as “Written off”.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="sec" style={{ marginTop: "var(--space-6)" }}>Waiting for a size</div>
|
||||
{data.waiting.length === 0 && <Empty pad={3}>Nobody is waiting on a size.</Empty>}
|
||||
{data.waiting.map((w) => {
|
||||
// The hold is a real deadline, not wording: lib/staffops refuses an accept once it has
|
||||
// run out, so the counter has to be able to see that the garment is theirs to give to
|
||||
// the next person rather than still being held for somebody who never came back.
|
||||
const ends = holdEndsAt(w.offeredAt);
|
||||
return (
|
||||
<div key={w.id} style={{ display: "flex", gap: "var(--space-3)", alignItems: "baseline", padding: "var(--space-2) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13.5, flexWrap: "wrap" }}>
|
||||
<b style={{ flex: 1, minWidth: 200 }}>{w.item} — {w.size}<span style={{ fontWeight: 400, color: "var(--color-neutral-700)" }}> · {w.staffName}{w.ward ? ` (${w.ward})` : ""}</span></b>
|
||||
<span style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>since {formatInZone(w.since, s.tz)}</span>
|
||||
{!w.offeredAt
|
||||
? <Btn label="It’s in — offer it" onClick={() => act("waitlist.offer", { id: w.id })} />
|
||||
: holdExpired(w.offeredAt)
|
||||
? <span className="tag tag-neutral">Hold lapsed — offer to the next person</span>
|
||||
: <span className="tag tag-accent">Held until {ends ? formatInZone(ends, s.tz, { day: "numeric", month: "short", hour: "numeric", minute: "2-digit" }) : "—"}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: "var(--color-neutral-700)", marginTop: "var(--space-4)", maxWidth: "70ch" }}>
|
||||
Offering tells them and holds the garment for {WAITLIST_HOLD_HOURS} hours.
|
||||
</p>
|
||||
</>
|
||||
) : tab === "damage" ? (
|
||||
data.damage.length === 0 ? (
|
||||
<Empty pad={4}>Nothing reported damaged that hasn't come back yet.</Empty>
|
||||
) : (
|
||||
<>
|
||||
<div className="tc-panel tc-panel-list" style={{ marginTop: "var(--space-4)" }}>
|
||||
<div className="tc-panel-head"><span>{TAB_TITLE.damage}</span><span className="tc-panel-aside">{plural(data.damage.length, "report", "reports")} still to come back</span></div>
|
||||
{data.damage.map((d, i) => (
|
||||
<div key={d.id} style={{ borderBottom: i === data.damage.length - 1 ? "none" : "1px solid var(--color-divider)", padding: "var(--space-3) var(--space-4)" }}>
|
||||
<div style={{ display: "flex", gap: "var(--space-3)", alignItems: "baseline", flexWrap: "wrap" }}>
|
||||
<b style={{ flex: 1, minWidth: 200 }}>
|
||||
{d.item ? `${d.item}${d.size ? ` — ${d.size}` : ""}` : "Garment no longer on file"}
|
||||
<span style={{ fontWeight: 400, color: "var(--color-neutral-700)" }}> · {d.staffName} ({d.staffNum}{d.ward ? ` · ${d.ward}` : ""})</span>
|
||||
</b>
|
||||
<span className="tag tag-accent">{d.kind}</span>
|
||||
<span style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>{formatInZone(d.at, s.tz)}</span>
|
||||
<Btn label="Handed in at the counter" onClick={() => act("damage.handedIn", { id: d.id })} />
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: "var(--color-neutral-700)", marginTop: 4 }}>
|
||||
{d.requestCode ? `Replacement requested — ${d.requestCode}` : "No replacement asked for"}
|
||||
{d.photoId ? " · photo attached" : ""}
|
||||
{" · "}<Link href={`/app/staff/${d.staffId}`}>Open their record</Link>
|
||||
</div>
|
||||
{d.note && <p style={{ fontSize: 14, lineHeight: 1.6, margin: "var(--space-2) 0 0", maxWidth: "70ch" }}>“{d.note}”</p>}
|
||||
{d.photoId && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={`/api/photo/${d.photoId}`} alt="The damage as reported" style={{ maxWidth: 220, marginTop: "var(--space-2)", border: "2px solid var(--color-divider)" }} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: "var(--color-neutral-700)", marginTop: "var(--space-4)", maxWidth: "70ch" }}>
|
||||
Handed in only clears the report — return the garment on their staff record.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
) : tab === "queries" ? (
|
||||
data.disputes.length === 0 ? (
|
||||
<Empty pad={4}>Nobody has queried their record.</Empty>
|
||||
) : (
|
||||
<div className="tc-panel tc-panel-list" style={{ marginTop: "var(--space-4)" }}>
|
||||
<div className="tc-panel-head"><span>{TAB_TITLE.queries}</span><span className="tc-panel-aside">{plural(data.disputes.length, "record", "records")} somebody says is wrong</span></div>
|
||||
{data.disputes.map((d, i) => (
|
||||
<div key={d.id} style={{ borderBottom: i === data.disputes.length - 1 ? "none" : "1px solid var(--color-divider)", padding: "var(--space-3) var(--space-4)" }}>
|
||||
<div style={{ display: "flex", gap: "var(--space-3)", alignItems: "baseline", flexWrap: "wrap" }}>
|
||||
<b style={{ flex: 1 }}>{d.staffName} <span style={{ fontWeight: 400, color: "var(--color-neutral-700)" }}>({d.staffNum}{d.ward ? ` · ${d.ward}` : ""})</span></b>
|
||||
<span style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>{formatInZone(d.at, s.tz)}</span>
|
||||
<Btn label="Mark sorted" onClick={() => act("dispute.resolve", { id: d.id })} />
|
||||
</div>
|
||||
<p style={{ fontSize: 14, lineHeight: 1.6, margin: "var(--space-2) 0 0", maxWidth: "70ch" }}>{d.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{rows.length === 0 ? (
|
||||
<Empty pad={4}>
|
||||
{tab === "todo" ? `Nothing approved and waiting${inLoaded}.`
|
||||
: tab === "noapprover" ? `Every request waiting${inLoaded} has somebody to approve it.`
|
||||
: data.moreRequests ? `Nothing here${inLoaded}.` : "Nothing here yet."}
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="tc-panel tc-panel-list" style={{ marginTop: "var(--space-4)" }}>
|
||||
<div className="tc-panel-head">
|
||||
<span>{TAB_TITLE[tab]}</span>
|
||||
<span className="tc-panel-aside">{plural(rows.length, "request", "requests")}</span>
|
||||
</div>
|
||||
{rows.map((r, i) => {
|
||||
const st = statusText(r);
|
||||
const isOpen = openId === r.id;
|
||||
const awaiting = r.status === "awaiting";
|
||||
const orphan = stranded(r);
|
||||
/* The dropdown belongs to the open row alone, and so does `choices`, which is drawn up
|
||||
above for that row. Two requests waiting at the same moment can have different answers
|
||||
— each leaves out whoever raised it — so it is the open row's question that gets asked.
|
||||
`picked` is whoever is chosen in that dropdown; opening any row clears the choice. */
|
||||
const reachable = isOpen ? choices.filter((c) => c.reachable) : [];
|
||||
const unreachable = isOpen ? choices.filter((c) => !c.reachable) : [];
|
||||
const picked = isOpen && reassign ? s.staff.find((x) => x.id === reassign) ?? null : null;
|
||||
/* Who answered, and what they answered. A request that got as far as the linen room was
|
||||
approved, so the decision line carries the manager's name; a decline is left standing
|
||||
on its own, because a withdrawal at the counter also lands here and attributing that
|
||||
to the manager who was asked would be a lie on the face of the queue.
|
||||
The approver being the person the request is for is allowed — a manager signs for her
|
||||
own uniform the same as anybody's — and her record says so wherever it shows. This
|
||||
queue said only a name, and a name that happens to match the one three words to its
|
||||
left is not something anybody notices reading down a queue. Matched on the id, because
|
||||
a ward can carry two people spelled the same way. */
|
||||
const wearerApproves = !!r.managerId && r.managerId === r.staffId;
|
||||
const approval = awaiting
|
||||
? (r.managerName ? `with ${r.managerName}${wearerApproves ? " — their own request, theirs to approve" : ""}` : "nobody has been asked yet")
|
||||
: r.status === "declined"
|
||||
? (r.decision || "declined")
|
||||
: `${r.decision || "Approved"} by ${r.managerName}${wearerApproves ? " — their own request, self-approved" : ""}`;
|
||||
/* The ward's order form, with this request's garments already on it.
|
||||
*
|
||||
* Not the same piece of paper as the collection slip. The slip travels with the bag and is
|
||||
* what somebody signs at the handover; the order form is the record of the ask — the sheet
|
||||
* the ward used to fill in by hand and send down, and the one a signature goes on. Printing
|
||||
* it from the request is the only way the paper and the app can agree about the sizes,
|
||||
* because the alternative is somebody copying them out again. The office-use block comes
|
||||
* out blank: it is filled in at the counter and the app does not know any of it yet.
|
||||
*
|
||||
* It prints on an undecided request on purpose, and it is safe to: the form leaves off
|
||||
* every declined line, so once the ward has answered it is the bag, and while the ward is
|
||||
* still deciding there is nothing to leave off and it is the whole ask. What keeps the two
|
||||
* apart on paper is the manager's block — it prints blank, with an unsigned rule where the
|
||||
* delegate approves the sets, so an undecided request comes off the printer plainly
|
||||
* unapproved. That is the one state the form is actually for: the sheet is what the
|
||||
* request is short of, and it can be walked up to the ward and signed there.
|
||||
*
|
||||
* The code goes on the end of the spoken name, not in place of the visible words: somebody
|
||||
* driving the counter by voice — hands full of garments, which is most of the shift — says
|
||||
* what is written on the button, and a name that did not start with those words leaves them
|
||||
* pressing nothing and wondering why. */
|
||||
const orderForm = (
|
||||
<Btn label="Print order form"
|
||||
ariaLabel={`Print order form for ${r.code}`}
|
||||
title={awaiting
|
||||
? "Everything asked for, with the manager's block blank to sign."
|
||||
: "The approved lines only."}
|
||||
onClick={() => window.open(`/print/order-form?request=${encodeURIComponent(r.id)}`, "_blank", "noopener")} />
|
||||
);
|
||||
return (
|
||||
// A stranded request is not dimmed with the rest of the `awaiting` ones: it is the one
|
||||
// kind of waiting the linen room is meant to act on, so it also takes the rule down its
|
||||
// left edge that every other flagged thing in the app wears.
|
||||
<div key={r.id} className={orphan ? "tc-flag" : undefined} style={{ borderBottom: i === rows.length - 1 ? "none" : "1px solid var(--color-divider)", padding: "var(--space-3) var(--space-4)", paddingLeft: orphan ? "calc(var(--space-4) - 4px)" : "var(--space-4)", opacity: awaiting && !orphan ? 0.6 : 1 }}>
|
||||
<div style={{ display: "flex", gap: "var(--space-3)", alignItems: "baseline", flexWrap: "wrap" }}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-neutral-700)", width: 74, flex: "none" }}>{r.code}</span>
|
||||
<b style={{ flex: 1, minWidth: 180 }}>
|
||||
{r.summary}
|
||||
<span style={{ fontWeight: 400, color: "var(--color-neutral-700)" }}> · {r.staffName}{r.ward ? ` (${r.ward})` : ""}</span>
|
||||
</b>
|
||||
{orphan && <span className="tag tag-flag">No approver</span>}
|
||||
<span className={NEEDS_STAFF.has(r.status as never) ? "tag tag-accent" : "tag tag-neutral"}>{st.label}</span>
|
||||
<button className="btn btn-ghost" style={{ minHeight: 30, padding: "2px 10px" }} onClick={() => { setOpenId(isOpen ? null : r.id); setReply(""); setHold(""); setReassign(""); }}>
|
||||
{isOpen ? "Close" : `Open${r.lineCount > 1 ? ` · ${r.lineCount} lines` : ""}`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12.5, color: "var(--color-neutral-700)", marginTop: 4 }}>
|
||||
{[r.reason, approval, r.raisedByName ? `raised by ${r.raisedByName}` : "", formatInZone(r.createdAt, s.tz)].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div style={{ marginTop: "var(--space-3)", paddingLeft: "var(--space-4)", borderLeft: "2px solid var(--color-text)" }}>
|
||||
<LineList r={r} />
|
||||
{r.note && <p style={{ fontSize: 13.5, lineHeight: 1.6, margin: "0 0 var(--space-3)", maxWidth: "70ch" }}>“{r.note}”</p>}
|
||||
|
||||
{awaiting ? (
|
||||
<>
|
||||
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: "var(--color-neutral-700)", margin: 0, maxWidth: "70ch" }}>
|
||||
{orphan
|
||||
? <>Nobody has been asked to approve this one — choose somebody who can.</>
|
||||
: <>Waiting on {r.managerName}. If that answer is never coming, send it to somebody else or withdraw it.</>}
|
||||
</p>
|
||||
{/* Without these two, a request addressed to a manager who never claimed an
|
||||
account waits for ever: the wearer has no op that touches it and the manager
|
||||
cannot sign in to decide it. */}
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", flexWrap: "wrap", alignItems: "center", marginTop: "var(--space-3)" }}>
|
||||
<select className="input" style={{ width: 260 }} aria-label={orphan ? `Choose who approves ${r.code}` : `Send ${r.code} to a different approver`} value={reassign} onChange={(e) => setReassign(e.target.value)}>
|
||||
<option value="">{orphan ? "Choose an approver…" : "Send it to somebody else…"}</option>
|
||||
{/* Split only when there is something to split off — two headings over one
|
||||
undivided list of people who can all decide it today is furniture. */}
|
||||
{unreachable.length === 0
|
||||
? reachable.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)
|
||||
: (
|
||||
<>
|
||||
{reachable.length > 0 && (
|
||||
<optgroup label="Can decide it today">
|
||||
{reachable.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
|
||||
</optgroup>
|
||||
)}
|
||||
<optgroup label="Can’t be asked — no staff-app account">
|
||||
{unreachable.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
|
||||
</optgroup>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
<Btn primary label={orphan ? "Ask them" : "Re-address"} onClick={async () => { if (reassign && await act("request.reassign", { id: r.id, managerId: reassign })) setReassign(""); }} />
|
||||
<Btn label="Withdraw it" onClick={() => { if (confirm(`Withdraw ${r.code}? ${r.staffName} is told it was declined by the linen room.`)) act("request.withdraw", { id: r.id, reason: "Withdrawn — no approver available" }); }} />
|
||||
{orderForm}
|
||||
</div>
|
||||
{/* A facility that has only just loaded its register has nobody on it who can
|
||||
approve anything, and an empty dropdown beside a primary button reads as
|
||||
a screen that is broken rather than as a register that is short. */}
|
||||
{choices.length === 0 && (
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: "var(--color-neutral-700)", margin: "var(--space-2) 0 0", maxWidth: "70ch" }}>
|
||||
Nobody on the register can approve this one — add the ward's managers on{" "}
|
||||
<Link href="/app/staff">the register</Link>, or withdraw it.
|
||||
</p>
|
||||
)}
|
||||
{/* Between choosing a name and pressing the button, which is the only moment
|
||||
either of these can still be acted on. Afterwards the self-approval is on
|
||||
the record, and the unreachable one is a request in Open under the name of
|
||||
somebody who cannot answer it, with nothing anywhere saying so. */}
|
||||
{picked && (picked.id === r.staffId || !picked.selfEmail) && (
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: "var(--color-neutral-700)", margin: "var(--space-2) 0 0", maxWidth: "70ch" }}>
|
||||
{picked.id === r.staffId && (
|
||||
<>This is {picked.first}'s own request — sending it to them is a self-approval.{" "}</>
|
||||
)}
|
||||
{!picked.selfEmail && (
|
||||
<>
|
||||
{picked.first} has no staff-app account, so can't be asked.{" "}
|
||||
{picked.selfCode && slipLive(picked.selfCodeAt, s.today, s.tz)
|
||||
? <>A code on <Link href={`/app/staff/${picked.id}`}>their record</Link> hasn't been used yet.</>
|
||||
: picked.selfCode
|
||||
? <>The code on <Link href={`/app/staff/${picked.id}`}>their record</Link> has expired — make a new one first.</>
|
||||
: <>Give {picked.first} a code on <Link href={`/app/staff/${picked.id}`}>their record</Link> first.</>}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", flexWrap: "wrap", alignItems: "center" }}>
|
||||
{r.status === "accepted" && <Btn primary label="Start picking" onClick={() => act("request.pick", { id: r.id })} />}
|
||||
{r.status === "picking" && (
|
||||
<>
|
||||
<input className="input" style={{ width: 200 }} aria-label={`How long ${r.code} is held at the counter`} placeholder="Held until — e.g. Fri 6pm" value={hold} onChange={(e) => setHold(e.target.value)} />
|
||||
<Btn primary label="Hold at the counter" onClick={async () => { if (await act("request.hold", { id: r.id, holdUntil: hold })) setHold(""); }} />
|
||||
<Btn label="Send on the ward round" onClick={() => act("request.round", { id: r.id })} />
|
||||
</>
|
||||
)}
|
||||
{r.status === "ready" && (
|
||||
<>
|
||||
{/* One code for the whole bag, so the number of garments it covers is said
|
||||
beside it — otherwise the person at the counter reads out a code, hands
|
||||
over one garment and both of them think that was the lot. */}
|
||||
<span style={{ fontSize: 13 }}>Code <b style={{ fontFamily: "monospace", fontSize: 15 }}>{r.collectCode}</b> · {plural(r.garments, "garment", "garments")}{r.holdUntil ? ` · until ${r.holdUntil}` : ""}</span>
|
||||
<Btn primary label="Collected" onClick={() => act("request.collected", { id: r.id })} />
|
||||
</>
|
||||
)}
|
||||
{r.status === "round" && <span style={{ fontSize: 13, color: "var(--color-neutral-700)" }}>On the round to {r.ward || "the ward"} — {plural(r.garments, "garment", "garments")}. The ward desk signs for it.</span>}
|
||||
{r.status === "delivered" && <span style={{ fontSize: 13, color: "var(--color-neutral-700)" }}>Signed by {r.signerName}{r.signerRole ? `, ${r.signerRole}` : ""}{r.claimedAt ? " · collected by the requester" : " · not yet collected from the ward"}</span>}
|
||||
{r.status === "collected" && <span style={{ fontSize: 13, color: "var(--color-neutral-700)" }}>Handed over at the counter.</span>}
|
||||
{r.status === "declined" && <span style={{ fontSize: 13, color: "var(--color-neutral-700)" }}>Declined — {r.declineReason || "no reason recorded"}.</span>}
|
||||
{/* The paper that travels with the bag. It lists the approved lines and the
|
||||
collection code, so what is signed for is what was picked. */}
|
||||
{r.status !== "declined" && (
|
||||
<>
|
||||
<Btn label={onRound(r) ? "Delivery slip" : "Collection slip"} onClick={() => openSlip(onRound(r) ? "delivery" : "collection", slipFor(r))} />
|
||||
{/* Nothing was ordered on a request every line of which was refused, so a
|
||||
declined one gets no form — the same rule as the slip beside it. */}
|
||||
{orderForm}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="sec" style={{ marginTop: "var(--space-4)" }}>Messages</div>
|
||||
{r.messages.length === 0 && <div style={{ fontSize: 13, color: "var(--color-neutral-700)", padding: "var(--space-2) 0" }}>Nothing asked about this one.</div>}
|
||||
{r.messages.map((m) => (
|
||||
<div key={m.id} style={{ padding: "var(--space-2) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13.5 }}>
|
||||
<b>{m.fromStaff ? m.authorName : `${m.authorName} (linen room)`}</b>
|
||||
<span style={{ color: "var(--color-neutral-700)", marginLeft: 8, fontSize: 12 }}>{formatInZone(m.at, s.tz)}</span>
|
||||
<div style={{ marginTop: 3, lineHeight: 1.6 }}>{m.body}</div>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-3)", flexWrap: "wrap" }}>
|
||||
<input className="input" style={{ flex: 1, minWidth: 220 }} aria-label={`Reply about ${r.code}`} placeholder="Reply to this order" value={reply} onChange={(e) => setReply(e.target.value)} />
|
||||
<Btn label="Send" onClick={async () => { if (reply.trim() && await act("request.reply", { id: r.id, body: reply })) setReply(""); }} />
|
||||
</div>
|
||||
|
||||
<div className="sec" style={{ marginTop: "var(--space-4)" }}>History</div>
|
||||
{r.events.map((e) => (
|
||||
<div key={e.id} style={{ display: "flex", gap: "var(--space-3)", padding: "var(--space-2) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13 }}>
|
||||
<span style={{ flex: 1 }}>{e.label}{e.meta ? ` — ${e.meta}` : ""}</span>
|
||||
<span style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>{e.actorName} · {formatInZone(e.at, s.tz)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* The queue is the newest few hundred requests and no more. Unsaid, a tab that has reached
|
||||
the ceiling looks exactly like a tab that holds everything — the counts on the buttons
|
||||
included — and somebody hunting a request from last winter concludes it was never
|
||||
raised. The wearer's own record keeps its history separately, which is where a request
|
||||
older than this cut-off is actually found. */}
|
||||
{data.moreRequests && (
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: "var(--color-neutral-700)", marginTop: "var(--space-4)", maxWidth: "70ch" }}>
|
||||
Only the most recent {data.requestLimit} requests are loaded — older ones are on{" "}
|
||||
<Link href="/app/staff">the wearer's staff record</Link>.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { PageHead, Empty, KpiStrip, LiveRegion } from "@/components/ui";
|
||||
import { DeliverDialog } from "@/components/dialogs";
|
||||
import { ccOf, daysBetween, label, staffName, telHref, type PickupRec } from "@/lib/compute";
|
||||
|
||||
// Delivery rounds: everything awaiting pickup grouped by ward, ticked off on the floor with an on-screen signature.
|
||||
|
||||
/* The Dashboard already counts pickups that have sat for a fortnight and puts the figure on the
|
||||
front page, so the round sheet marks the same ones. Two screens disagreeing about which handover
|
||||
is late is how a coordinator stops trusting either. */
|
||||
const STALE = 14;
|
||||
|
||||
export default function RoundsPage() {
|
||||
const { s } = useSnap();
|
||||
const { byId, staffById } = useDerived();
|
||||
const [deliver, setDeliver] = useState<PickupRec | null>(null);
|
||||
const [msg, setMsg] = useState("");
|
||||
const pending = s.pickups.filter((p) => !p.pickedUp);
|
||||
const wards: Record<string, PickupRec[]> = {};
|
||||
for (const p of pending) { const st = staffById[p.staffId]; const w = st?.dept || "Unknown"; (wards[w] = wards[w] || []).push(p); }
|
||||
const wardNames = Object.keys(wards).sort();
|
||||
const garments = pending.reduce((t, p) => t + p.lines.reduce((n, l) => n + l.qty, 0), 0);
|
||||
const stale = pending.filter((p) => daysBetween(p.received, s.today) >= STALE).length;
|
||||
return (
|
||||
<section>
|
||||
<PageHead eyebrow="On the floor" title="Delivery Rounds" sub="Everything awaiting pickup, grouped by ward. Tick each order off as you hand it over — the receiver signs on screen." />
|
||||
<LiveRegion msg={msg} style={{ marginTop: "var(--space-3)", fontSize: 13, fontWeight: 600 }} />
|
||||
{pending.length === 0 && <Empty>Nothing waiting for delivery.</Empty>}
|
||||
{/* Zero old pickups is not news, so the third tile only takes the flag when there is something
|
||||
to answer for. A rule and a mark that are always on the screen stop meaning anything. */}
|
||||
{pending.length > 0 && <KpiStrip items={[
|
||||
{ val: pending.length, label: "To deliver", note: `${wardNames.length} ward${wardNames.length === 1 ? "" : "s"} on the round` },
|
||||
{ val: garments, label: "Garments on the trolley", note: "Everything these orders add up to" },
|
||||
{ val: stale, label: `Waiting ${STALE}+ days`, flag: stale > 0, note: stale > 0 ? "Ring the ward if nobody is on shift to sign" : "Nothing has been sitting a fortnight" },
|
||||
]} />}
|
||||
{wardNames.map((w) => {
|
||||
const rows = wards[w]; const st0 = staffById[rows[0].staffId];
|
||||
return (
|
||||
<div key={w} className="tc-panel" style={{ marginTop: "var(--space-4)" }}>
|
||||
<div className="tc-panel-head">
|
||||
<span>{w}</span>
|
||||
<span className="tc-panel-aside">CC {st0 ? ccOf(s, st0) || "—" : "—"} · {rows.length} to deliver</span>
|
||||
</div>
|
||||
<div className="tc-panel-list">
|
||||
{rows.map((p) => {
|
||||
const st = staffById[p.staffId]; const tel = telHref(st?.phone);
|
||||
const days = daysBetween(p.received, s.today);
|
||||
const late = days >= STALE;
|
||||
return (
|
||||
/* The days figure is the same number the line underneath says in words, so it is
|
||||
read out once and not twice: the glance number is decoration, the sentence is
|
||||
the message. */
|
||||
<div key={p.id} className={"tc-row" + (late ? " tc-flag" : "")} style={{ flexWrap: "wrap" }}>
|
||||
<div className="tc-row-main" style={{ minWidth: 170 }}>
|
||||
<div className="tc-row-name">{staffName(st, "Staff")} {tel ? <a href={tel} style={{ fontWeight: 400, color: "var(--color-neutral-700)" }}>{st?.phone}</a> : <span style={{ fontWeight: 400, color: "var(--color-neutral-700)" }}>{st?.phone}</span>}</div>
|
||||
<div className="tc-row-meta">{p.lines.map((l) => `${label(byId[l.itemId])} ${l.size}${l.qty > 1 ? ` ×${l.qty}` : ""}`).join(", ")} · {p.orderCode}</div>
|
||||
<div className="tc-row-meta">{late && <span className="tc-mark" aria-hidden="true" />}Waiting {days} day{days === 1 ? "" : "s"}</div>
|
||||
</div>
|
||||
<div className="tc-row-fig" aria-hidden="true">{days}d</div>
|
||||
<button className="btn btn-primary" aria-label={`Sign for the delivery to ${staffName(st, "this staff member")}`} onClick={() => setDeliver(p)}>Delivered — sign</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{deliver && <DeliverDialog pickup={deliver} onClose={() => setDeliver(null)} onDone={(m) => setMsg(m)} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,997 @@
|
||||
"use client";
|
||||
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||
import TwoFactor from "@/components/TwoFactor";
|
||||
import SsoSettings from "@/components/SsoSettings";
|
||||
import PlanTab from "@/components/PlanTab";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { PageHead, Dialog, ErrorLine, Field, LiveRegion, Seg } from "@/components/ui";
|
||||
import { parseCsv, CSV_TEMPLATES } from "@/lib/csv";
|
||||
import { LOCATION_KINDS, SET_GARMENTS, allowance, allowanceRoute, csvOf, daysBetween, fmtDate, groupKey, isKitGroup, isNursingGroup, kitGroupsOf, locMap, locPath, locTree, nursingGroupsOf, setsOnStart, type AllowanceRoute, type DeptRec, type SupplierRec, type UserRec } from "@/lib/compute";
|
||||
import { downloadCsv } from "@/lib/print";
|
||||
|
||||
// Plan is last and appears only once plans are live, for admins — see PlanTab.
|
||||
const TABS = ["General", "Locations", "Departments", "Suppliers", "Account", "Sign-in", "Data", "Plan"] as const;
|
||||
type Tab = (typeof TABS)[number];
|
||||
// The supplier details edited on the card below, which is also the shape of the keys their
|
||||
// half-typed edits are filed under in `draft`.
|
||||
type SupKey = "contact" | "phone" | "account" | "email" | "lead";
|
||||
|
||||
// Only used by a browser too old to have Intl.supportedValuesOf: the zones an Australian facility
|
||||
// is actually in, so the picker is never empty on the one machine in the room that still runs it.
|
||||
const FALLBACK_ZONES = ["Australia/Brisbane", "Australia/Sydney", "Australia/Melbourne", "Australia/Hobart", "Australia/Adelaide", "Australia/Darwin", "Australia/Perth", "Australia/Broken_Hill", "Australia/Lord_Howe"];
|
||||
|
||||
/* The three ways a staff group gets up to the ceiling, in the order a coordinator reads them. The
|
||||
names are the ones the rest of the product uses for the routes, so a coordinator who reads
|
||||
"Starting kit" here reads the same words on the order form and at the counter. */
|
||||
const ROUTES: { id: AllowanceRoute; label: string; now: string }[] = [
|
||||
{ id: "fte", label: "FTE table", now: "on the FTE table" },
|
||||
{ id: "kit", label: "Starting kit", now: "on the starting kit" },
|
||||
{ id: "approval", label: "Manager approval", now: "on manager approval" },
|
||||
];
|
||||
|
||||
// Module-scope so React keeps the same element type across renders (defining it inside the page remounts the input on every keystroke).
|
||||
function TextField({ label, hint, ph, value, onChange, disabled }: { label: string; hint?: string; ph?: string; value: string; onChange: (v: string) => void; disabled: boolean }) {
|
||||
return <Field label={label} hint={hint}>{(c) => <input {...c} className="input" placeholder={ph} value={value} onChange={(e) => onChange(e.target.value)} disabled={disabled} />}</Field>;
|
||||
}
|
||||
|
||||
/* Also module-scope, and for a sharper reason than tidiness: this is a live region now, and a live
|
||||
region that is torn down and rebuilt announces its contents again. Defined inside the page it
|
||||
would be a fresh component type on every keystroke, so "Saved." would be read out over and over
|
||||
while somebody typed in an unrelated box. */
|
||||
function Msg({ text }: { text?: string }) {
|
||||
return <LiveRegion msg={text} style={{ fontSize: 12, color: "var(--color-accent-700)", fontWeight: 600, marginTop: "var(--space-2)", whiteSpace: "pre-wrap" }} />;
|
||||
}
|
||||
|
||||
// useSearchParams needs a Suspense boundary for static rendering.
|
||||
export default function SettingsPage() {
|
||||
return <Suspense fallback={null}><SettingsInner /></Suspense>;
|
||||
}
|
||||
|
||||
function SettingsInner() {
|
||||
const { s, isAdmin, busy, mutate } = useSnap();
|
||||
const router = useRouter();
|
||||
const [tab, setTab] = useState<Tab>("General");
|
||||
const sp = useSearchParams();
|
||||
const planShown = isAdmin && !!s.plan?.live && !s.demo;
|
||||
const tabs = planShown ? TABS : TABS.filter((t) => t !== "Plan");
|
||||
// Deep links: ?tab=account (sidebar name), ?tab=data (dashboard setup card), ?tab=plan (the
|
||||
// plan banner); #hash forms kept for old links.
|
||||
useEffect(() => {
|
||||
const want = (sp.get("tab") || window.location.hash.replace("#", "")).toLowerCase();
|
||||
const t = TABS.find((x) => x.toLowerCase() === want);
|
||||
if (t && (t !== "Plan" || planShown)) setTab(t);
|
||||
}, [sp, planShown]);
|
||||
const [nl, setNl] = useState({ name: "", kind: "Shelf", parentId: "" });
|
||||
const [msg, setMsg] = useState<Record<string, string>>({});
|
||||
const say = (k: string, v: string) => setMsg((m) => ({ ...m, [k]: v }));
|
||||
const [draft, setDraft] = useState<Record<string, string>>({});
|
||||
const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||
useEffect(() => { const t = timers.current; return () => Object.values(t).forEach(clearTimeout); }, []);
|
||||
/* `draft` as it stands now, rather than as it stood in the render that set a timer. A save that
|
||||
fires after a pause carries the other boxes on the row along with it, and by the time it fires
|
||||
the coordinator may have typed in one of them: a ward renamed and a cost centre typed straight
|
||||
after used to save the new cost centre, then put the old one back a moment later when the
|
||||
rename landed — and the ward's orders went on being costed to a number nobody meant any more. */
|
||||
const draftNow = useRef(draft);
|
||||
useEffect(() => { draftNow.current = draft; });
|
||||
/* Take a half-typed edit back out of `draft`, so the box goes back to showing what the register
|
||||
holds. Used where an edit is refused: a value nobody accepted must not be left on screen, where
|
||||
it reads as saved and can be picked up by whatever else on the row saves the row.
|
||||
`only` is the text that was refused, and the box is left alone if it no longer says that. A
|
||||
refusal from the server arrives a moment after the name went to it, and by then the coordinator
|
||||
may already be typing the correction — clearing the box then takes away letters nobody has so
|
||||
much as looked at, mid-word, which reads as a field that eats what you type. What is left
|
||||
behind is on its way to be checked in its own right, so nothing unchecked is left standing. */
|
||||
const forgetDraft = (k: string, only?: string) => setDraft((d) => { if (only !== undefined && d[k] !== only) return d; const next = { ...d }; delete next[k]; return next; });
|
||||
function debounced(k: string, v: string, op: string, payload: Record<string, unknown>, msgKey = "fields") {
|
||||
setDraft((d) => ({ ...d, [k]: v }));
|
||||
clearTimeout(timers.current[k]);
|
||||
timers.current[k] = setTimeout(async () => { const r = await mutate(op, payload); say(msgKey, r.ok ? "Saved." : r.error); }, 500);
|
||||
}
|
||||
const NUMERIC = ["defaultEntitlement", "initialSets", "defaultReorder", "exceptionHigh", "capSets", "varianceReason"];
|
||||
const setField = (k: string, v: string) => { if (NUMERIC.includes(k) && v === "") { setDraft((d) => ({ ...d, [k]: v })); return; } debounced(k, v, "settings.update", { [k]: v }); };
|
||||
const val = (k: keyof typeof s.settings) => (draft[k] !== undefined ? draft[k] : String(s.settings[k] ?? ""));
|
||||
|
||||
const [newGroup, setNewGroup] = useState("");
|
||||
const [nd, setNd] = useState({ name: "", cc: "" });
|
||||
const [ns, setNs] = useState("");
|
||||
const [userDlg, setUserDlg] = useState<UserRec | null | false>(false);
|
||||
const [pw, setPw] = useState({ current: "", next: "", again: "" });
|
||||
const [del, setDel] = useState({ open: false, password: "", confirm: "", busy: false, err: "" });
|
||||
const [me, setMe] = useState({ first: s.session.first, last: s.session.last, title: s.session.title });
|
||||
const meDirty = me.first !== s.session.first || me.last !== s.session.last || me.title !== s.session.title;
|
||||
const [impKind, setImpKind] = useState("catalog");
|
||||
const [impBusy, setImpBusy] = useState(false);
|
||||
const [wipe, setWipe] = useState("");
|
||||
const [reset, setReset] = useState("");
|
||||
/* The ward notice is written from here and read nowhere on this side of the product: the snapshot
|
||||
carries no notice, so this box starts empty even while one is up on every wearer's home screen.
|
||||
Said out loud under the field rather than left to be worked out, because an empty box meaning
|
||||
"this screen can't see the board" and an empty box meaning "the board is empty" are not the
|
||||
same thing to a coordinator deciding whether to post. */
|
||||
const [notice, setNotice] = useState({ body: "", endsAt: "" });
|
||||
const [noticeBusy, setNoticeBusy] = useState(false);
|
||||
// Optimistic: the snapshot refresh lags the click, and a checkbox that snaps back reads as a failure.
|
||||
const [lookupOn, setLookupOn] = useState<boolean | null>(null);
|
||||
const [tzPick, setTzPick] = useState<string | null>(null); // same reason as lookupOn
|
||||
/* And each group's route, for the same reason again. A route saves by sending both lists whole,
|
||||
and until the refreshed snapshot lands the row still reads the old ones, so the route pressed
|
||||
would spring back to the one before — read as a save that didn't take, and pressed again. Held
|
||||
only while the snapshot still carries the lists it was worked out from: once those change,
|
||||
whether from this save landing or from somebody else's, the snapshot is the truth again. Held
|
||||
any longer, a group renamed since would still be here under its old name, and the next route
|
||||
pressed would send that name back and take the renamed group off its route. */
|
||||
const [routePick, setRoutePick] = useState<{ base: string; nursing: string[]; kit: string[] } | null>(null);
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const [tzErr, setTzErr] = useState("");
|
||||
const [bkBusy, setBkBusy] = useState(false);
|
||||
const [resetBusy, setResetBusy] = useState(false);
|
||||
const [logoV, setLogoV] = useState(0);
|
||||
/* The zone every date-only column in the product is written against — see facilityToday. A room
|
||||
left on the Brisbane default gets its evenings filed against tomorrow: a Perth issue at 22:30 on
|
||||
30 June counts against the next financial year's entitlement and drops out of June's exceptions
|
||||
report and cost-centre journal. The names come from this browser's zone table; settings.update
|
||||
checks a submitted name against the server's, and the two are not guaranteed to be the same list
|
||||
— an older Node, or a browser new enough to offer a zone the server's ICU data predates, and the
|
||||
server refuses something this select happily offered. Rare, and not something the client can
|
||||
check for, so the refusal is put under the select instead of being left to a message further
|
||||
down the page. The current zone is prepended if this browser has never heard of it, so a
|
||||
facility can always see what it is on. */
|
||||
const zones = useMemo(() => {
|
||||
// Optional call on purpose: TypeScript's lib says this exists, the browser in the linen room
|
||||
// may disagree.
|
||||
const all = Intl.supportedValuesOf?.("timeZone") || FALLBACK_ZONES;
|
||||
return all.includes(s.settings.timezone) ? all : [s.settings.timezone, ...all];
|
||||
}, [s.settings.timezone]);
|
||||
|
||||
/* The facility's own two lists and nothing else. An empty one means no group is on that route:
|
||||
there is no list of ours standing in for it, so nothing on this screen may behave as if there
|
||||
were. */
|
||||
const storedLists = { nursing: nursingGroupsOf(s), kit: kitGroupsOf(s) };
|
||||
const listSig = JSON.stringify([storedLists.nursing, storedLists.kit]);
|
||||
const lists = routePick && routePick.base === listSig ? routePick : storedLists;
|
||||
// The same answer the counter, the order form and the wearer's own app reach, including the FTE
|
||||
// table winning for a group somehow on both lists.
|
||||
const routeOf = (g: string) => allowanceRoute({ nursing: isNursingGroup(lists.nursing, g), kit: isKitGroup(lists.kit, g) });
|
||||
const routeNow = (r: AllowanceRoute) => ROUTES.find((x) => x.id === r)?.now ?? "";
|
||||
/* Everybody still working, counted by the group they are filed under and compared the way the app
|
||||
compares group names. It is what the remove button has to warn about, and what the list of
|
||||
groups nobody has added yet is built from. */
|
||||
const filedUnder: Record<string, number> = {};
|
||||
const spelt: Record<string, string> = {};
|
||||
for (const st of s.staff) {
|
||||
const k = groupKey(st.group);
|
||||
if (st.inactive || !k) continue;
|
||||
filedUnder[k] = (filedUnder[k] || 0) + 1;
|
||||
if (!spelt[k]) spelt[k] = st.group.trim();
|
||||
}
|
||||
const staffCount = (g: string) => filedUnder[groupKey(g)] || 0;
|
||||
/* The groups on the list, then any name still on a route that is no longer on the list — left
|
||||
there by a backup restored from an older file, or by the move to three routes. The people filed
|
||||
under it are still on that route, so it stays in sight to be kept or let go, rather than
|
||||
deciding somebody's kit from a list nobody can see. */
|
||||
const listedKeys = new Set(s.settings.staffGroups.map(groupKey));
|
||||
const offList: string[] = [];
|
||||
for (const g of [...lists.nursing, ...lists.kit]) {
|
||||
const k = groupKey(g);
|
||||
if (!listedKeys.has(k) && !offList.some((x) => groupKey(x) === k)) offList.push(g);
|
||||
}
|
||||
const groupRows = [...s.settings.staffGroups.map((g) => ({ g, listed: true })), ...offList.map((g) => ({ g, listed: false }))];
|
||||
/* Groups people are filed under that nobody has put on this list. A staff import files people
|
||||
under whatever the roster calls them and adds nothing here, so on a facility that has just
|
||||
loaded its register this is every group it has — and everybody in them is on manager approval
|
||||
until the group is added and given a route. Named with a button each, biggest first, rather
|
||||
than left for somebody to notice. */
|
||||
const rowKeys = new Set(groupRows.map((r) => groupKey(r.g)));
|
||||
const unlisted = Object.keys(filedUnder).filter((k) => !rowKeys.has(k)).map((k) => ({ g: spelt[k], n: filedUnder[k] })).sort((a, b) => b.n - a.n);
|
||||
|
||||
/* The figures the routes are described with, read off the boxes rather than the stored values, so
|
||||
the words describe what walking away from this screen now would leave in force. A box left empty
|
||||
saves nothing, so the stored figure stands for it. Put through allowance() — the sum the counter
|
||||
and the wearer's app do — so a starting kit typed above the ceiling is quoted at the ceiling,
|
||||
which is what the counter actually hands over. */
|
||||
const typedSets = (k: "initialSets" | "capSets") => { const t = val(k).trim(); return t === "" ? s.settings[k] : Number(t); };
|
||||
const shape = allowance({ held: 0, kit: true, startingSets: typedSets("initialSets"), capSets: typedSets("capSets") });
|
||||
const ceiling = shape.max, kitStart = shape.start ?? 0;
|
||||
const kitOverCeiling = setsOnStart(typedSets("initialSets")) > ceiling;
|
||||
/* Sets and garments together, because they are one kit counted two ways and the argument at the
|
||||
counter is always about garments. SET_GARMENTS rather than a bare 2: a set is a top and a bottom
|
||||
everywhere in the product, and this is not the place to re-decide it. */
|
||||
const sets = (n: number) => `${n} set${n === 1 ? "" : "s"} (${n * SET_GARMENTS} garments)`;
|
||||
const routeSays: Record<AllowanceRoute, string> = {
|
||||
fte: "First kit proposed from each person's hours; a manager can sign for more.",
|
||||
kit: `${sets(kitStart)} on the first day, then more as needed.`,
|
||||
approval: "Nothing on the first day; a manager approves each set.",
|
||||
};
|
||||
|
||||
async function signOut() { await fetch("/api/auth/logout", { method: "POST" }); router.push("/auth"); router.refresh(); }
|
||||
/* One change to the groups at a time. Each of these sends whole lists worked out from what is on
|
||||
screen, so a second one sent before the first is back in the snapshot is worked out from the
|
||||
lists as they were: an add straight after a rename would send the old name back, and the renamed
|
||||
group would come off its route with it. `busy` covers the save and the refresh behind it, which
|
||||
is well under a second. Refused out loud rather than by greying the buttons, because a button
|
||||
switched off under the finger drops keyboard focus on the floor. */
|
||||
const settled = () => { if (!busy) return true; say("groups", "Still saving the last change — try again in a moment."); return false; };
|
||||
// Editing the group list, a ward or a supplier used to fire and forget: a refusal (the last group,
|
||||
// a name already taken, a lost connection) left the chip sitting where it was with nothing said,
|
||||
// and the admin clicked again. `name` is for the buttons that add a group somebody is already
|
||||
// filed under; the box below the list sends nothing and is cleared once its group is in.
|
||||
async function addGroup(name?: string) {
|
||||
const g = (name ?? newGroup).trim();
|
||||
if (!g || !settled()) return;
|
||||
const route = routeOf(g);
|
||||
const r = await mutate("settings.update", { staffGroups: [...s.settings.staffGroups, g] });
|
||||
say("groups", r.ok ? `${g} added, ${routeNow(route)}${route === "approval" ? " until you choose another route" : ""}.` : r.error);
|
||||
if (r.ok && name === undefined) setNewGroup("");
|
||||
}
|
||||
async function removeGroup(g: string) {
|
||||
if (!settled()) return;
|
||||
const n = staffCount(g), route = routeOf(g);
|
||||
const who = `${n} staff member${n === 1 ? " is" : "s are"} filed under ${g}`;
|
||||
/* Asked before, not reported after. Taking a group off the list takes it off its route too, and
|
||||
everybody still filed under it goes onto manager approval — a coordinator tidying up a list is
|
||||
owed that before a team's first kit goes, not in a message once it has. */
|
||||
if (n && !confirm(route === "approval"
|
||||
? `${who}. They stay filed under it, still on manager approval, but nobody new can be put in ${g}. Take it off the list?`
|
||||
: `${who}, which is ${routeNow(route)}. Taking it off the list puts them on manager approval — move them to another group first to keep their route.\n\nTake ${g} off the list?`)) return;
|
||||
const r = await mutate("settings.update", { staffGroups: s.settings.staffGroups.filter((x) => x !== g) });
|
||||
say("groups", !r.ok ? r.error
|
||||
: n ? `${g} removed. The ${n} staff member${n === 1 ? " filed under it is" : "s filed under it are"} on manager approval, and ${g} is listed below to add back.`
|
||||
: `${g} removed.`);
|
||||
}
|
||||
// Both lists in one save, because moving a group is taking it off one route and putting it on
|
||||
// another, and the server refuses any save that would leave it on two.
|
||||
async function setRoute(g: string, to: AllowanceRoute) {
|
||||
const from = routeOf(g);
|
||||
if (from === to || !settled()) return;
|
||||
const k = groupKey(g);
|
||||
const nursing = lists.nursing.filter((x) => groupKey(x) !== k);
|
||||
/* A group caught on both lists is on the FTE table already — allowanceRoute() says so — so
|
||||
taking it off the kit list here changes nobody's route. What it does is let the save through:
|
||||
the server refuses any save that leaves a group on both, whichever group the click was about. */
|
||||
const kit = lists.kit.filter((x) => groupKey(x) !== k && !isNursingGroup(nursing, x));
|
||||
if (to === "fte") nursing.push(g);
|
||||
if (to === "kit") kit.push(g);
|
||||
setRoutePick({ base: listSig, nursing, kit });
|
||||
const r = await mutate("settings.update", { nursingGroups: nursing, kitGroups: kit });
|
||||
// Put the row back where the server still has it, or the screen would go on claiming a change
|
||||
// that was refused.
|
||||
if (!r.ok) { setRoutePick(null); say("groups", r.error); return; }
|
||||
say("groups", `${g} is ${routeNow(to)}. ${routeSays[to]}`);
|
||||
}
|
||||
/* The one message the linen room can put in front of everybody at once. It is not mail and it is
|
||||
not a request: it is the board on the wall, and the ward reads it on the home screen of their
|
||||
own app. An empty message takes the board down — the linen room's way of saying that's over. */
|
||||
async function postNotice() {
|
||||
const body = notice.body.trim(), endsAt = notice.endsAt.trim();
|
||||
/* A day already gone is a notice nobody will ever see: the staff app only shows one whose end
|
||||
date is today or later. Caught here, because the server takes the date happily and the first
|
||||
anyone would know of it is that the ward never mentioned the thing they were told. */
|
||||
if (body && endsAt && endsAt < s.today) { say("notice", `${fmtDate(endsAt)} has already gone, so nobody would see this. Pick today or later, or leave the date blank.`); return; }
|
||||
setNoticeBusy(true);
|
||||
const r = await mutate<{ cleared: boolean }>("notice.set", { body, endsAt });
|
||||
setNoticeBusy(false);
|
||||
if (!r.ok) { say("notice", r.error); return; }
|
||||
say("notice", r.result.cleared
|
||||
? "The board is clear. Nothing shows on anybody's home screen now."
|
||||
: `Posted. Every staff member who has set up the app sees this on their home screen${endsAt ? `, up to and including ${fmtDate(endsAt)}` : ", until it is taken down"}.`);
|
||||
}
|
||||
/* Renaming a ward is safe to offer because dept.save carries the old name forward in the same
|
||||
transaction: every staff record filed under it and every order costed to it moves with it, so
|
||||
nothing is left pointing at a name that has gone.
|
||||
|
||||
A rename that is not going to happen has to leave the row showing the ward the register still
|
||||
has. It used to leave the rejected text sitting in the box, which is worse than not checking at
|
||||
all: the coordinator walks away reading a ward name that exists nowhere, and the cost centre box
|
||||
beside it saves the whole row — so the next cost centre typed on that row was the thing that
|
||||
finally saved the name nobody accepted. Every ending here either saves or puts the name back,
|
||||
and says which — and either way it says so, because a name that was refused was refused whether
|
||||
or not a better one is already being typed over it. */
|
||||
function renameDept(d: DeptRec, v: string) {
|
||||
const k = "deptname:" + d.id;
|
||||
setDraft((x) => ({ ...x, [k]: v }));
|
||||
clearTimeout(timers.current[k]);
|
||||
/* Checked when the typing stops rather than on every keystroke, because half a ward's name on
|
||||
the way to a whole one is not a refusal — clearing the box under somebody mid-word would make
|
||||
the field unusable. The pause is the same one that commits the save. */
|
||||
timers.current[k] = setTimeout(async () => {
|
||||
const name = v.trim();
|
||||
const no = deptNameRefusal(d, name);
|
||||
if (no) { forgetDraft(k, v); say("depts", no); return; }
|
||||
const r = await mutate("dept.save", { id: d.id, name, cc: deptCc(d, draftNow.current).trim() });
|
||||
if (!r.ok) { forgetDraft(k, v); say("depts", r.error); return; }
|
||||
say("depts", `Renamed to ${name}. Everyone filed under ${d.name}, and every order costed to it, moved with it.`);
|
||||
}, 600);
|
||||
}
|
||||
/* The catch is the whole point of parseCsv refusing a malformed file. It throws to stop a
|
||||
half-import, and without somewhere to land that refusal was an unhandled rejection: the admin
|
||||
saw "Importing…" sit there forever and went looking for the staff it never loaded. Whatever
|
||||
parseCsv says — which line, which quote — is what the admin needs on screen to fix the file. */
|
||||
async function importFile(file: File) {
|
||||
setImpBusy(true); say("import", "Importing…");
|
||||
try {
|
||||
const rows = parseCsv(await file.text());
|
||||
if (!rows.length) { say("import", "No rows found — check the header row."); return; }
|
||||
const r = await mutate<{ created: number; updated: number; skipped: number; styles?: number; errors: string[] }>("import.rows", { kind: impKind, rows });
|
||||
if (!r.ok) { say("import", r.error); return; }
|
||||
const x = r.result;
|
||||
say("import", `${CSV_TEMPLATES[impKind].name}: ${x.created} created, ${x.updated} updated, ${x.skipped} skipped.${x.styles ? ` ${x.styles} uniform ${x.styles === 1 ? "style" : "styles"} set.` : ""}` + (x.errors.length ? "\n" + x.errors.join("\n") : ""));
|
||||
}
|
||||
catch (e) { say("import", (e as Error)?.message || "That file couldn’t be read as a CSV. Nothing was imported."); }
|
||||
finally { setImpBusy(false); }
|
||||
}
|
||||
async function restore(file: File) {
|
||||
if (!confirm("Restore this backup? It replaces ALL data in this facility (catalogue, staff, orders, issues, stock, approvals). Users are kept.")) return;
|
||||
say("backup", "Restoring…");
|
||||
try {
|
||||
const data = JSON.parse(await file.text());
|
||||
const r = await mutate<{ photosSkipped: number }>("backup.restore", data);
|
||||
if (!r.ok) { say("backup", r.error); return; }
|
||||
// A restore takes back a capped number of photos and drops the rest rather than refusing the
|
||||
// whole file. Said out loud, because the alternative is a room believing every signature and
|
||||
// damage photo is back on the record when some of them only exist in the file.
|
||||
const skipped = r.result?.photosSkipped || 0;
|
||||
say("backup", skipped ? `Backup restored — every record came back, but ${skipped} photo${skipped === 1 ? "" : "s"} in the file did not. Keep the backup file: those images are only in it now.` : "Backup restored.");
|
||||
}
|
||||
catch (e) { say("backup", "Import failed — " + (e as Error).message); }
|
||||
}
|
||||
/* Fetched rather than a plain <a href="/api/backup">, because a browser downloading a file never
|
||||
shows the page its contents: the export trims the oldest photos to keep the file inside what a
|
||||
restore will take back, counts them in `photosOmitted`, and until this ran through fetch nobody
|
||||
was ever told. A room finds out otherwise only on the day it restores. */
|
||||
async function exportBackup() {
|
||||
setBkBusy(true); say("backup", "Preparing the backup…");
|
||||
try {
|
||||
const res = await fetch("/api/backup");
|
||||
/* Every other write on this page goes through mutate, which sends a dead session back to the
|
||||
sign-in door; this one fetch was outside that and would have handed the admin a signed-out
|
||||
error page saved as threadcount-backup.json — a file that looks like a backup and restores
|
||||
nothing. Same destination as mutate's, carrying where they were so they land back here. */
|
||||
if (res.status === 401) { window.location.assign(`/auth?next=${encodeURIComponent(location.pathname + location.search)}`); return; }
|
||||
if (!res.ok) { say("backup", ((await res.json().catch(() => ({}))) as { error?: string }).error || "Export failed — nothing was downloaded."); return; }
|
||||
const text = await res.text();
|
||||
// Read out of the text rather than JSON.parse: the file carries every photo that travelled and
|
||||
// can run to tens of megabytes, and parsing it a second time on a linen-room PC to learn one
|
||||
// number is not worth the memory.
|
||||
const omitted = Number(/"photosOmitted":\s*(\d+)/.exec(text)?.[1] || 0);
|
||||
const name = /filename="([^"]+)"/.exec(res.headers.get("content-disposition") || "")?.[1] || "threadcount-backup.json";
|
||||
const url = URL.createObjectURL(new Blob([text], { type: "application/json" }));
|
||||
const a = document.createElement("a"); a.href = url; a.download = name; a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
say("backup", omitted
|
||||
? `${name} downloaded. ${omitted} older photo${omitted === 1 ? " was" : "s were"} left out so the file stays small enough to restore — every record is in it, and the images stay on the server.`
|
||||
: `${name} downloaded.`);
|
||||
router.refresh();
|
||||
} catch (e) { say("backup", "Export failed — " + (e as Error).message); }
|
||||
finally { setBkBusy(false); }
|
||||
}
|
||||
function template(kind: string) {
|
||||
const t = CSV_TEMPLATES[kind];
|
||||
const a = document.createElement("a"); a.href = "data:text/csv;charset=utf-8," + encodeURIComponent(t.headers + "\n" + t.example + "\n"); a.download = `threadcount-${kind}-template.csv`; a.click();
|
||||
}
|
||||
function uploadLogo(file: File) {
|
||||
if (file.size > 400 * 1024) { say("logo", "Logo must be under 400 KB."); return; }
|
||||
const r = new FileReader();
|
||||
r.onload = async () => { const res = await mutate("settings.update", { logoData: String(r.result) }); setLogoV((v) => v + 1); say("logo", res.ok ? "Logo saved — it prints top-right on slips." : res.error); };
|
||||
r.readAsDataURL(file);
|
||||
}
|
||||
// How many sizes sit on each location, so an empty shelf is obvious before it is deleted.
|
||||
const locCounts: Record<string, number> = {};
|
||||
for (const k in s.placed) locCounts[s.placed[k]] = (locCounts[s.placed[k]] || 0) + 1;
|
||||
const H = ({ children, top = 6 }: { children: React.ReactNode; top?: number }) => <div className="sec" style={{ marginTop: `var(--space-${top})` }}>{children}</div>;
|
||||
const Note = ({ children }: { children: React.ReactNode }) => <div style={{ fontSize: 12, color: "var(--color-neutral-700)", marginTop: "var(--space-2)", lineHeight: 1.6 }}>{children}</div>;
|
||||
/* Not a component defined in here. React compares element types by identity, so a helper declared
|
||||
inside the render is a brand-new type on every keystroke: the whole field is torn down and
|
||||
rebuilt, and the caret goes with it. TextField sits at module scope and is handed everything it
|
||||
needs, which is why F is a plain function returning an element rather than <F />. */
|
||||
const F = (k: keyof typeof s.settings, label: string, opts: { ph?: string; hint?: string; numeric?: boolean; demoFixed?: boolean } = {}) =>
|
||||
<TextField label={label} hint={opts.hint} ph={opts.ph} value={val(k)} disabled={!isAdmin || (!!opts.demoFixed && !!s.demo)} onChange={(v) => setField(k, opts.numeric ? v.replace(/[^0-9]/g, "") : v)} />;
|
||||
const grid: React.CSSProperties = { display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-4)", marginTop: "var(--space-3)" };
|
||||
const deptStaff: Record<string, number> = {}; for (const st of s.staff) deptStaff[st.dept] = (deptStaff[st.dept] || 0) + 1;
|
||||
|
||||
/* One reading of a field, used both by the input that edits it and by the export that writes it,
|
||||
so the two cannot drift apart. debounced() holds a keystroke in `draft` for half a second before
|
||||
it reaches the server, and `draft` is what the coordinator can see in the box — so `draft` is
|
||||
what the file has to say. Overlaying it here rather than flushing the pending saves first is
|
||||
deliberate: pressing Export must not write to the register (a half-typed cost centre would be
|
||||
committed early), it must not wait on the network to hand over a file, and a save the server
|
||||
refuses leaves the typed value on screen anyway — only the overlay still matches it. The ward's
|
||||
own name is the exception, and exportDepts says why. A save that goes out after a pause passes
|
||||
draftNow instead, for the reason given where that is kept. */
|
||||
const deptCc = (d: DeptRec, from = draft) => from["dept:" + d.id] ?? d.cc;
|
||||
/* The same overlay for the ward's own name, and for a second reason on top of the export's: the
|
||||
cost centre box beside it saves the whole row, name included, so without this a cost centre
|
||||
typed while a rename was still settling would quietly put the old name back. */
|
||||
const deptName = (d: DeptRec) => draft["deptname:" + d.id] ?? d.name;
|
||||
/* What is wrong with a ward name, in the words the coordinator needs, or nothing if it is fine.
|
||||
One reading of it, because two boxes on the row both save the row — the name and the cost centre
|
||||
— and if they disagreed about what counts as a name, the cost centre box would be the way a
|
||||
rejected name got saved anyway. The register itself refuses both of these; asked here as well so
|
||||
the answer arrives while the coordinator is still looking at the row they typed it on. */
|
||||
function deptNameRefusal(d: DeptRec, name: string) {
|
||||
if (!name) return `A ward needs a name — ${d.name} hasn’t been changed.`;
|
||||
const clash = s.depts.find((o) => o.id !== d.id && o.name.trim().toLowerCase() === name.toLowerCase());
|
||||
return clash ? `${clash.name} is already on the list, and two wards with one name can’t be told apart on a staff record or a journal line.` : "";
|
||||
}
|
||||
/* The name this row would be saved under: what has been typed, unless it is a name the register
|
||||
would refuse, in which case the ward keeps the one it has. Typing a cost centre must never be
|
||||
the thing that commits a rename. It is for the save alone — what the file hands to finance is
|
||||
the name the register actually holds, see exportDepts. */
|
||||
const deptSaveName = (d: DeptRec) => { const n = deptName(d).trim(); return deptNameRefusal(d, n) ? d.name : n; };
|
||||
const supField = (sup: SupplierRec, k: SupKey) => draft[`sup:${sup.id}:${k}`] ?? (sup[k] === null ? "" : String(sup[k]));
|
||||
|
||||
/* The three registers on this page are the ones a coordinator is most often asked to hand over —
|
||||
the shelf map before a stocktake, the ward list for finance, the supplier list for procurement —
|
||||
and until now the only way out of any of them was to retype what was on the screen. Each tab
|
||||
exports its own register and nothing else: somebody on Suppliers pressing Export means suppliers. */
|
||||
function exportLocations() {
|
||||
/* Nothing to overlay here, unlike the two below: the only editable thing on this tab is the
|
||||
Inside select, and that is saved the moment it changes rather than held in `draft`. */
|
||||
const byId = locMap(s);
|
||||
/* A tree flattened into rows loses the thing that made it a tree, and "Bay B3" on its own is no
|
||||
use to anybody walking the room — there is a B3 on every shelf. So each row carries its full
|
||||
path as well as its own name, built with the helper the rest of the app renders a location
|
||||
with, and a spreadsheet sorted any which way still reads Linen Room · Shelf B · Bay B3. */
|
||||
const rows = locTree(s, true).map(({ loc }) => [loc.name, loc.kind, loc.parentId ? byId[loc.parentId]?.name ?? "" : "", locPath(byId, loc.id).map((l) => l.name).join(" · "), locCounts[loc.id] || 0]);
|
||||
downloadCsv(`threadcount-locations-${s.today}.csv`, csvOf(["Location", "Kind", "Inside", "Full path", "Sizes"], rows));
|
||||
}
|
||||
/* dept and cc are the import template's own headers, not prettier ones that happen to normalise
|
||||
onto them, so what comes out of here is exactly what the importer expects back: a coordinator
|
||||
can export the wards, fix twenty cost centres in a spreadsheet and import the same file under
|
||||
Data without touching the header row. The staff count is ours to be useful — the importer has
|
||||
no alias for it, so it is ignored on the way back in and cannot create a ward of its own. */
|
||||
function exportDepts() {
|
||||
/* The ward's name as the register holds it, not as the box reads it. A rename is half a second
|
||||
behind the typing and the server can still turn it down after that, so a name in the box is
|
||||
not yet a ward. This file goes to finance and comes back in through Data, where a name that
|
||||
never landed arrives as a ward of its own: the staff stay on the old one, the new cost centre
|
||||
goes on the new one, and the ward is in two halves. The cost centre beside it is the typed
|
||||
one on purpose — a code is typed into a ward that already exists, so the worst an unsaved one
|
||||
does is carry a correction to finance a moment early.
|
||||
Trimmed the way dept.save trims, so a code typed with a stray trailing space — invisible in
|
||||
the box — reaches finance in the form the register will actually hold. */
|
||||
downloadCsv(`threadcount-departments-${s.today}.csv`, csvOf(["dept", "cc", "staff"], s.depts.map((d) => [d.name, deptCc(d).trim(), deptStaff[d.name] || 0])));
|
||||
}
|
||||
// Everything procurement rings a supplier about: who to ask for, on which account, and how long
|
||||
// they take — lead time being what dates the delivery on a new order. The product and order counts
|
||||
// are the screen's own, and they say which of these names anybody is actually buying from.
|
||||
function exportSuppliers() {
|
||||
const rows = s.supplierDir.map((sup) => [sup.name, supField(sup, "contact"), supField(sup, "phone"), supField(sup, "account"), supField(sup, "lead"), s.catalog.filter((it) => it.supplier === sup.name).length, s.orders.filter((o) => o.supplier === sup.name).length]);
|
||||
downloadCsv(`threadcount-suppliers-${s.today}.csv`, csvOf(["Supplier", "Contact", "Phone", "Account no.", "Lead time (days)", "Products", "Orders"], rows));
|
||||
}
|
||||
const lastBk = s.settings.lastBackup; const bkDays = lastBk ? daysBetween(lastBk, s.today) : null;
|
||||
/* A week is the line. Past it the facility is one failed disk away from retyping its register by
|
||||
hand, which is the only thing on this page worth interrupting somebody over. */
|
||||
const bkStale = !lastBk || (bkDays ?? 0) > 7;
|
||||
|
||||
return (
|
||||
/* The ink band runs the full width of the content column, so the reading measure is set on the
|
||||
form underneath it rather than on the section. Set here, the head would bleed out to the left
|
||||
gutter and stop dead at 760px on the right. */
|
||||
<section>
|
||||
<PageHead eyebrow="Admin" title="Settings" />
|
||||
<div style={{ maxWidth: 760 }}>
|
||||
{/* Seg rather than a hand-rolled strip, for what Seg carries: aria-pressed. Which tab you are
|
||||
on used to be a fill colour and nothing else, so a coordinator on a screen reader heard six
|
||||
identical buttons, and tapping one announced no change at all. */}
|
||||
<span role="group" aria-label="Settings sections"><Seg opts={tabs} value={tab} onChange={setTab} style={{ flexWrap: "wrap", marginTop: "var(--space-4)" }} /></span>
|
||||
|
||||
{tab === "General" && (
|
||||
<>
|
||||
<H>Facility</H>
|
||||
<div className="tc-grid" style={grid}>
|
||||
{F("facility", "Facility")}{F("location", "Stock location")}{F("coordinator", "Coordinator name")}
|
||||
{/* Fixed in the demo for the same reason the coordinator's name is, with more riding on
|
||||
it: everyone shares that one facility, so an address or number typed here prints in
|
||||
the foot of the order form in front of every other visitor — and it would be a real
|
||||
person's address and a real phone. */}
|
||||
{F("coordinatorEmail", "Coordinator e-mail", { ph: "e.g. uniforms@yourhospital.org.au", demoFixed: true })}
|
||||
{F("coordinatorPhone", "Coordinator phone", { ph: "e.g. 07 3xxx xxxx", demoFixed: true })}
|
||||
{/* Off in the demo for the same reason settings.update refuses the facility name and the
|
||||
slip footers there: everyone shares that one facility, so a visitor setting it to
|
||||
Honolulu re-dates the dashboard, the exceptions report and the journal for every
|
||||
other visitor — the director of nursing and the Play reviewer included. */}
|
||||
<Field label="Time zone" hint="Where the linen room actually is." error={tzErr || undefined}>{(c) => (
|
||||
<select {...c} className="input" value={tzPick ?? s.settings.timezone} disabled={!isAdmin || !!s.demo}
|
||||
onChange={async (e) => {
|
||||
const z = e.target.value; setTzPick(z); setTzErr("");
|
||||
const r = await mutate("settings.update", { timezone: z });
|
||||
// The refusal belongs here, next to the select that snaps back, not in the shared
|
||||
// message line under Staff groups two screens further down where it reads as
|
||||
// nothing having happened at all.
|
||||
if (!r.ok) { setTzPick(null); setTzErr(r.error); return; }
|
||||
say("fields", `Dates now follow ${z} time.`);
|
||||
}}>
|
||||
{zones.map((z) => <option key={z} value={z}>{z}</option>)}
|
||||
</select>
|
||||
)}</Field>
|
||||
</div>
|
||||
<Note>These print on slips, purchase orders, reports and the uniform order form.</Note>
|
||||
<H>Issuing & stock</H>
|
||||
<div className="tc-grid" style={grid}>{F("capSets", "Ceiling, every group (sets)", { numeric: true, ph: "e.g. 6", hint: "Held at any time — not a yearly allowance." })}{F("initialSets", "Starting kit (sets)", { numeric: true, ph: "e.g. 3", hint: "First day, Starting kit route only." })}{F("defaultEntitlement", "Yearly figure for reports (garments)", { numeric: true })}{F("defaultReorder", "Default reorder level", { numeric: true, hint: "For sizes without their own." })}{F("exceptionHigh", "Exception threshold (items/month)", { numeric: true })}</div>
|
||||
{kitOverCeiling && <Note>Nobody is handed more than the ceiling, so the starting kit stops at {sets(kitStart)}.</Note>}
|
||||
{/* Set on the counter phone and nowhere else until now, which meant the one number the
|
||||
desktop stock take enforces could only be changed by somebody holding the phone —
|
||||
and on a facility whose phones are all issued out, not at all. */}
|
||||
<H>Stock takes</H>
|
||||
<div className="tc-grid" style={grid}>{F("varianceReason", "Reason required at (garments)", { numeric: true, ph: "e.g. 5", hint: "Over or short, here and on the counter phone." })}</div>
|
||||
<H>Finance & journal</H>
|
||||
<div className="tc-grid" style={grid}>{F("glAccount", "GL account", { ph: "e.g. 631020" })}{F("journalDesc", "Journal description prefix", { ph: "e.g. Uniform issues" })}</div>
|
||||
<Note>Used by the Reports journal export and month-end pack.</Note>
|
||||
<H>Slips & logo</H>
|
||||
<div className="tc-grid" style={grid}>
|
||||
{F("slipOrg", "Organisation name on slips", { ph: "Printed when there is no logo" })}
|
||||
{/* Not a Field: this cell holds a preview, a file picker and a remove button, and a single
|
||||
<label> cannot name three controls. A named group is the honest markup. */}
|
||||
<div className="field" role="group" aria-label="Logo (top-right on slips)">
|
||||
<span aria-hidden="true" style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--color-neutral-700)" }}>Logo (top-right on slips)</span>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", alignItems: "center", flexWrap: "wrap" }}>
|
||||
{s.settings.hasLogo && <img src={`/api/logo?v=${logoV}`} alt="The logo currently printed on slips" style={{ height: 34, maxWidth: 140, objectFit: "contain", border: "1px solid var(--color-divider)", background: "#fff", padding: 2 }} />}
|
||||
{isAdmin && <label className="btn btn-secondary" style={{ cursor: "pointer" }}>{s.settings.hasLogo ? "Replace" : "Upload"}<input type="file" accept="image/png,image/jpeg,image/webp,image/gif" aria-label={s.settings.hasLogo ? "Replace the slip logo" : "Upload a slip logo"} style={{ display: "none" }} onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadLogo(f); e.target.value = ""; }} /></label>}
|
||||
{isAdmin && s.settings.hasLogo && <button className="btn btn-ghost" aria-label="Remove the slip logo" onClick={async () => { const r = await mutate("settings.update", { logoData: "" }); say("logo", r.ok ? "Logo removed." : r.error); }}>Remove</button>}
|
||||
</div>
|
||||
</div>
|
||||
<Field label="Collection slip footer" style={{ gridColumn: "1 / -1" }}>{(c) => <input {...c} className="input" value={val("slipCollectionFooter")} onChange={(e) => setField("slipCollectionFooter", e.target.value)} disabled={!isAdmin} />}</Field>
|
||||
<Field label="Delivery slip footer" style={{ gridColumn: "1 / -1" }}>{(c) => <input {...c} className="input" value={val("slipDeliveryFooter")} onChange={(e) => setField("slipDeliveryFooter", e.target.value)} disabled={!isAdmin} />}</Field>
|
||||
</div>
|
||||
<Msg text={msg.logo} />
|
||||
<H>Staff groups</H>
|
||||
<Note>Each group takes one route; every route stops at the ceiling of {sets(ceiling)} held.</Note>
|
||||
{/* One sentence per route, read once here rather than repeated down every row, and written
|
||||
with this facility's own figures — the ones the counter and the wearer's app quote. */}
|
||||
<div style={{ fontSize: 12, color: "var(--color-neutral-700)", marginTop: "var(--space-2)", lineHeight: 1.6 }}>
|
||||
{ROUTES.map((r) => <div key={r.id}><b style={{ color: "var(--color-text)" }}>{r.label}.</b> {routeSays[r.id]}</div>)}
|
||||
</div>
|
||||
{/* Where every new facility starts: it names its own groups, and there is no list of ours to
|
||||
stand in for them. Until it does, the only route anybody is on is manager approval, and
|
||||
that is said here rather than left as an empty space to be puzzled over. */}
|
||||
{!groupRows.length && (
|
||||
<div className="tc-flag" style={{ fontSize: 13, marginTop: "var(--space-4)", paddingLeft: "var(--space-3)", lineHeight: 1.6 }}>
|
||||
<span className="tc-mark" aria-hidden="true" />
|
||||
<b>No staff groups yet</b>, so everybody is on manager approval{isAdmin ? " — add your groups below." : "."}
|
||||
</div>
|
||||
)}
|
||||
{!!groupRows.length && (
|
||||
<div style={{ marginTop: "var(--space-3)", borderTop: "1px solid var(--color-divider)" }}>
|
||||
{groupRows.map(({ g, listed }) => {
|
||||
const route = routeOf(g), n = staffCount(g);
|
||||
return (
|
||||
<div key={g} style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "var(--space-2) var(--space-3)", padding: "var(--space-2) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13 }}>
|
||||
<div style={{ flex: "1 1 160px", minWidth: 0 }}>
|
||||
<b>{g}</b>
|
||||
<div style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>{n ? `${n} staff member${n === 1 ? "" : "s"}` : "Nobody filed under it"}{listed ? "" : " · not on the list"}</div>
|
||||
</div>
|
||||
{/* Seg's markup rather than Seg itself, because Seg's buttons can't be switched off
|
||||
for an issuer, who may read the routes but not change them. One pressed button
|
||||
out of three is also what makes a group on two routes impossible to ask for. */}
|
||||
<div className="seg" role="group" aria-label={`Route for ${g}`}>
|
||||
{ROUTES.map((r) => <button key={r.id} className={"seg-opt" + (route === r.id ? " btn-primary" : "")} aria-pressed={route === r.id} disabled={!isAdmin} onClick={() => setRoute(g, r.id)}>{r.label}</button>)}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div style={{ display: "flex", gap: "var(--space-1)", alignItems: "center" }}>
|
||||
{listed
|
||||
? <button className="btn btn-ghost" aria-label={`Rename the ${g} staff group`} onClick={() => { if (settled()) setRenaming(g); }}>Rename</button>
|
||||
: <button className="btn btn-ghost" aria-label={`Add to list — ${g}`} onClick={() => addGroup(g)}>Add to list</button>}
|
||||
{/* A bare "×" announces as "times, button" and names nothing, so a screen-reader
|
||||
user had no way to tell which group they were about to delete. */}
|
||||
{listed && <button className="btn btn-ghost btn-icon" style={{ fontSize: 13 }} aria-label={`Remove the ${g} staff group`} onClick={() => removeGroup(g)}>×</button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && <div style={{ display: "flex", gap: "var(--space-2)", flexWrap: "wrap", marginTop: "var(--space-3)" }}><input className="input" style={{ minHeight: 30, padding: "2px 8px", width: 200 }} aria-label="New staff group" placeholder="New staff group" value={newGroup} onChange={(e) => setNewGroup(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && newGroup.trim()) addGroup(); }} /><button className="btn btn-secondary" style={{ minHeight: 30 }} disabled={!newGroup.trim()} onClick={() => addGroup()}>Add</button></div>}
|
||||
{!!offList.length && <Note>Groups not on the list keep their route but take nobody new — add one back to keep it.</Note>}
|
||||
{!!unlisted.length && (
|
||||
<div style={{ marginTop: "var(--space-4)" }}>
|
||||
<Note>{unlisted.length === 1 ? "This group is" : "These groups are"} on the staff register but not on this list, so {unlisted.length === 1 ? "its" : "their"} staff are on manager approval until added{isAdmin ? "" : " by an admin"}.</Note>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", flexWrap: "wrap", marginTop: "var(--space-2)" }}>
|
||||
{unlisted.map(({ g, n }) => isAdmin
|
||||
? <button key={g} className="btn btn-secondary" style={{ minHeight: 30 }} aria-label={`Add ${g} — ${n} staff member${n === 1 ? " is" : "s are"} filed under it`} onClick={() => addGroup(g)}>Add {g} · {n}</button>
|
||||
: <span key={g} className="tag tag-outline" style={{ fontSize: 12, textTransform: "none", letterSpacing: 0 }}>{g} · {n}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Msg text={msg.groups} />
|
||||
<Msg text={msg.fields} />
|
||||
{isAdmin && (
|
||||
<>
|
||||
<H>Ward notice</H>
|
||||
<Note>Shown on the home screen of the staff app.</Note>
|
||||
<Field label="Message" hint="Replaces the current notice, which this box doesn’t show." style={{ marginTop: "var(--space-3)" }}>{(c) => <textarea {...c} className="input" rows={3} maxLength={400} style={{ width: "100%" }} placeholder="e.g. The linen room is closed this Friday — collections move to Thursday." value={notice.body} onChange={(e) => setNotice({ ...notice, body: e.target.value })} />}</Field>
|
||||
<div className="tc-grid" style={grid}>
|
||||
<Field label="Last day shown" hint="Optional — left blank, it stays up until taken down.">{(c) => <input {...c} className="input" type="date" min={s.today} value={notice.endsAt} onChange={(e) => setNotice({ ...notice, endsAt: e.target.value })} />}</Field>
|
||||
</div>
|
||||
{/* The label is what pressing it does, rather than one word that means two opposite
|
||||
things depending on whether the box above happens to be empty. */}
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-3)", flexWrap: "wrap" }}>
|
||||
<button className="btn btn-secondary" disabled={noticeBusy} onClick={postNotice}>{noticeBusy ? "Saving…" : notice.body.trim() ? "Post this notice" : "Take the notice down"}</button>
|
||||
</div>
|
||||
<Msg text={msg.notice} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "Locations" && (
|
||||
<>
|
||||
<H>Where garments live</H>
|
||||
<Note>Rooms hold shelves, shelves hold bays. Put a size on a shelf from Inventory.</Note>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: "var(--space-3)" }}>
|
||||
<button className="btn btn-ghost" onClick={exportLocations} disabled={s.locations.length === 0}>Export CSV</button>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 130px 1fr 90px 32px", gap: "var(--space-2)", padding: "var(--space-3) 0 var(--space-1)", fontSize: 11, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--color-neutral-600)", fontWeight: 600 }}><div>Location</div><div>Kind</div><div>Inside</div><div style={{ textAlign: "right" }}>Sizes</div><div></div></div>
|
||||
{locTree(s, true).map(({ loc, depth }) => (
|
||||
<div key={loc.id} style={{ display: "grid", gridTemplateColumns: "1fr 130px 1fr 90px 32px", gap: "var(--space-2)", alignItems: "center", padding: "var(--space-1) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13 }}>
|
||||
<div style={{ fontWeight: 600, paddingLeft: depth * 16 }}>{loc.name}</div>
|
||||
<div style={{ color: "var(--color-neutral-700)" }}>{loc.kind}</div>
|
||||
<select className="input" style={{ minHeight: 28, padding: "2px 6px" }} aria-label={`What ${loc.name} sits inside`} value={loc.parentId || ""} disabled={!isAdmin}
|
||||
onChange={async (e) => { const r = await mutate("location.save", { id: loc.id, name: loc.name, kind: loc.kind, parentId: e.target.value }); say("locs", r.ok ? "Moved." : r.error); }}>
|
||||
<option value="">— top level —</option>
|
||||
{locTree(s, true).filter(({ loc: o }) => o.id !== loc.id).map(({ loc: o, depth: d }) => <option key={o.id} value={o.id}>{"\u00a0".repeat(d * 2)}{o.name}</option>)}
|
||||
</select>
|
||||
<div style={{ textAlign: "right", color: "var(--color-neutral-700)" }}>{locCounts[loc.id] || 0}</div>
|
||||
{isAdmin ? <button className="btn btn-ghost btn-icon" title="Remove — anything on it becomes unplaced" aria-label={`Remove ${loc.name} — anything on it becomes unplaced`} onClick={async () => { const r = await mutate("location.delete", { id: loc.id }); say("locs", r.ok ? "Removed." : r.error); }}>×</button> : <span />}
|
||||
</div>
|
||||
))}
|
||||
{s.locations.length === 0 && <Note>No locations yet. Add the first shelf below.</Note>}
|
||||
{isAdmin && (
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-3)", flexWrap: "wrap", alignItems: "flex-end" }}>
|
||||
<Field label="New location" style={{ flex: 1, minWidth: 160 }}>{(c) => <input {...c} className="input" value={nl.name} onChange={(e) => setNl({ ...nl, name: e.target.value })} placeholder="e.g. Shelf B" />}</Field>
|
||||
<Field label="Kind" style={{ width: 130 }}>{(c) => <select {...c} className="input" value={nl.kind} onChange={(e) => setNl({ ...nl, kind: e.target.value })}>{LOCATION_KINDS.map((k) => <option key={k}>{k}</option>)}</select>}</Field>
|
||||
<Field label="Inside" style={{ width: 200 }}>{(c) => <select {...c} className="input" value={nl.parentId} onChange={(e) => setNl({ ...nl, parentId: e.target.value })}><option value="">— top level —</option>{locTree(s, true).map(({ loc: o, depth: d }) => <option key={o.id} value={o.id}>{"\u00a0".repeat(d * 2)}{o.name}</option>)}</select>}</Field>
|
||||
<button className="btn btn-secondary" disabled={!nl.name.trim()} onClick={async () => { const r = await mutate("location.save", nl); say("locs", r.ok ? "Added." : r.error); if (r.ok) setNl({ name: "", kind: nl.kind, parentId: nl.parentId }); }}>Add</button>
|
||||
</div>
|
||||
)}
|
||||
<Msg text={msg.locs} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "Departments" && (
|
||||
<>
|
||||
<H>Departments & cost centres</H>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: "var(--space-3)" }}>
|
||||
<button className="btn btn-ghost" onClick={exportDepts} disabled={s.depts.length === 0}>Export CSV</button>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 130px 90px 32px", gap: "var(--space-2)", padding: "var(--space-2) 0 var(--space-1)", fontSize: 11, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--color-neutral-600)", fontWeight: 600 }}><div>Department / ward</div><div>Cost centre</div><div style={{ textAlign: "right" }}>Staff</div><div></div></div>
|
||||
{s.depts.map((d) => (
|
||||
<div key={d.id} style={{ display: "grid", gridTemplateColumns: "1fr 130px 90px 32px", gap: "var(--space-2)", alignItems: "center", padding: "var(--space-1) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13 }}>
|
||||
<input className="input" style={{ minHeight: 28, padding: "2px 8px", fontWeight: 600 }} aria-label={`Name of ${d.name}`} value={deptName(d)} onChange={(e) => renameDept(d, e.target.value)} disabled={!isAdmin} />
|
||||
{/* Sends the name that is on screen, not the one the server still holds: this save
|
||||
writes the whole row, so during the second a rename is settling it would otherwise
|
||||
undo it. */}
|
||||
<input className="input" style={{ minHeight: 28, padding: "2px 8px" }} aria-label={`Cost centre for ${d.name}`} value={deptCc(d)} onChange={(e) => debounced("dept:" + d.id, e.target.value, "dept.save", { id: d.id, name: deptSaveName(d), cc: e.target.value.trim() }, "depts")} disabled={!isAdmin} />
|
||||
<div style={{ textAlign: "right", color: "var(--color-neutral-700)" }}>{deptStaff[d.name] || 0}</div>
|
||||
{isAdmin && !(deptStaff[d.name] || 0) ? <button className="btn btn-ghost btn-icon" title="Remove — no staff assigned" aria-label={`Remove ${d.name} — no staff assigned`} onClick={async () => { const r = await mutate("dept.delete", { id: d.id }); say("depts", r.ok ? `${d.name} removed.` : r.error); }}>×</button> : <span />}
|
||||
</div>
|
||||
))}
|
||||
{s.depts.length === 0 && <Note>No departments yet. Add wards below or import them in Data.</Note>}
|
||||
{isAdmin && (
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-3)", flexWrap: "wrap", alignItems: "flex-end" }}>
|
||||
<Field label="New department / ward" style={{ flex: 1, minWidth: 160 }}>{(c) => <input {...c} className="input" value={nd.name} onChange={(e) => setNd({ ...nd, name: e.target.value })} placeholder="e.g. Ward 5C" />}</Field>
|
||||
<Field label="Cost centre" style={{ width: 130 }}>{(c) => <input {...c} className="input" value={nd.cc} onChange={(e) => setNd({ ...nd, cc: e.target.value })} placeholder="e.g. RGH-5090" />}</Field>
|
||||
<button className="btn btn-secondary" disabled={!nd.name.trim() || !nd.cc.trim()} onClick={async () => { const r = await mutate("dept.save", nd); say("depts", r.ok ? "Added." : r.error); if (r.ok) setNd({ name: "", cc: "" }); }}>Add</button>
|
||||
</div>
|
||||
)}
|
||||
<Note>Wards with staff on them can't be removed.</Note>
|
||||
<Msg text={msg.depts} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "Suppliers" && (
|
||||
<>
|
||||
<H>Suppliers</H>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: "var(--space-3)" }}>
|
||||
<button className="btn btn-ghost" onClick={exportSuppliers} disabled={s.supplierDir.length === 0}>Export CSV</button>
|
||||
</div>
|
||||
{s.supplierDir.map((sp) => {
|
||||
const nItems = s.catalog.filter((it) => it.supplier === sp.name).length, nOrds = s.orders.filter((o) => o.supplier === sp.name).length;
|
||||
return (
|
||||
/* One supplier, one panel — the same bordered block with a named head that the rest
|
||||
of the app puts a list in, rather than this screen's own thinner version of it. The
|
||||
name keeps its own case: it is somebody's trading name, and the head's small caps
|
||||
would shout it back at them. */
|
||||
<div key={sp.id} className="tc-panel" style={{ marginTop: "var(--space-3)" }}>
|
||||
<div className="tc-panel-head">
|
||||
<span style={{ textTransform: "none", letterSpacing: 0, fontFamily: "var(--font-heading)", fontSize: 15, fontWeight: 800 }}>{sp.name}</span>
|
||||
<span className="tc-panel-aside" style={{ display: "flex", alignItems: "center", gap: "var(--space-2)", flex: "none" }}>
|
||||
{nItems} product{nItems === 1 ? "" : "s"} · {nOrds} order{nOrds === 1 ? "" : "s"}
|
||||
{isAdmin && nItems + nOrds === 0 && <button className="btn btn-ghost btn-icon" title="Remove — no products or orders use this supplier" aria-label={`Remove ${sp.name} — no products or orders use this supplier`} onClick={async () => { const r = await mutate("supplier.remove", { id: sp.id }); say("sup", r.ok ? `${sp.name} removed.` : r.error); }}>×</button>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tc-grid tc-panel-body" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-3)" }}>
|
||||
{([["contact", "Contact person", "e.g. Dana R."], ["phone", "Phone", "e.g. 07 3xxx xxxx"], ["account", "Account no.", "e.g. ACC-2201"], ["email", "Order email", "e.g. orders@example.com"], ["lead", "Lead time (days)", "e.g. 14"]] as const).map(([k, lbl, ph]) => (
|
||||
<Field key={k} label={`${sp.name} — ${lbl}`}>{(c) => <input {...c} className="input" placeholder={ph} value={supField(sp, k)} onChange={(e) => { const v = k === "lead" ? e.target.value.replace(/[^0-9]/g, "") : e.target.value; debounced(`sup:${sp.id}:${k}`, v, "supplier.update", { id: sp.id, [k]: v }, "sup"); }} disabled={!isAdmin} />}</Field>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{isAdmin && (
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-3)", alignItems: "flex-end", flexWrap: "wrap" }}>
|
||||
<Field label="New supplier" style={{ flex: 1, minWidth: 200 }}>{(c) => <input {...c} className="input" value={ns} onChange={(e) => setNs(e.target.value)} placeholder="e.g. Scrubs Direct" />}</Field>
|
||||
<button className="btn btn-secondary" disabled={!ns.trim()} onClick={async () => { const r = await mutate("supplier.add", { name: ns }); say("sup", r.ok ? "Added." : r.error); if (r.ok) setNs(""); }}>Add supplier</button>
|
||||
</div>
|
||||
)}
|
||||
<Note>Lead time auto-fills the expected delivery date on new orders; contact and account number print on purchase orders; the order email is where the order list sends a purchase order. Suppliers with products or orders can't be removed.</Note>
|
||||
<Msg text={msg.sup} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "Sign-in" && (
|
||||
<>
|
||||
<H>Single sign-on</H>
|
||||
<SsoSettings isAdmin={isAdmin} demo={!!s.demo} sso={s.settings.sso} users={s.users} onChanged={() => router.refresh()} mutate={mutate} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "Plan" && planShown && <PlanTab />}
|
||||
|
||||
{tab === "Account" && (
|
||||
<>
|
||||
<H>Account</H>
|
||||
<div style={{ fontSize: 13, marginTop: "var(--space-3)", lineHeight: 1.7 }}>Signed in as <b>{s.session.name}</b> ({s.session.role}, {s.session.email}).</div>
|
||||
|
||||
{/* Sits at the top of Account because it is the one setting on this page that protects
|
||||
every other one. */}
|
||||
<TwoFactor isAdmin={isAdmin} />
|
||||
<Note>Your name and title stamp every issue, stocktake and slip you record.</Note>
|
||||
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "var(--space-3)", marginTop: "var(--space-3)", maxWidth: 640 }}>
|
||||
<Field label="First name">{(c) => <input {...c} className="input" value={me.first} onChange={(e) => setMe({ ...me, first: e.target.value })} disabled={!!s.demo} />}</Field>
|
||||
<Field label="Last name">{(c) => <input {...c} className="input" value={me.last} onChange={(e) => setMe({ ...me, last: e.target.value })} disabled={!!s.demo} />}</Field>
|
||||
<Field label="Title">{(c) => <input {...c} className="input" value={me.title} onChange={(e) => setMe({ ...me, title: e.target.value })} placeholder="e.g. Uniform Coordinator" disabled={!!s.demo} />}</Field>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-3)", alignItems: "center", flexWrap: "wrap" }}>
|
||||
<button className="btn btn-secondary" disabled={!meDirty || !me.first.trim() || !me.last.trim()} onClick={async () => { const r = await mutate("me.profile", me); say("me", r.ok ? "Saved." : r.error); }}>Save my details</button>
|
||||
</div>
|
||||
<Msg text={msg.me} />
|
||||
<H>Password</H>
|
||||
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "var(--space-3)", marginTop: "var(--space-3)", maxWidth: 640 }}>
|
||||
<Field label="Current password">{(c) => <input {...c} className="input" type="password" autoComplete="current-password" value={pw.current} onChange={(e) => setPw({ ...pw, current: e.target.value })} />}</Field>
|
||||
<Field label="New password" hint="At least 8 characters.">{(c) => <input {...c} className="input" type="password" autoComplete="new-password" value={pw.next} onChange={(e) => setPw({ ...pw, next: e.target.value })} />}</Field>
|
||||
<Field label="Confirm" error={pw.again && pw.next !== pw.again ? "The two new passwords don\u2019t match." : undefined}>{(c) => <input {...c} className="input" type="password" autoComplete="new-password" value={pw.again} onChange={(e) => setPw({ ...pw, again: e.target.value })} />}</Field>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-3)", alignItems: "center", flexWrap: "wrap" }}>
|
||||
<button className="btn btn-secondary" disabled={!pw.current || pw.next.length < 8 || pw.next !== pw.again} onClick={async () => { const r = await mutate("me.password", { current: pw.current, next: pw.next }); say("pw", r.ok ? "Password changed." : r.error); if (r.ok) setPw({ current: "", next: "", again: "" }); }}>Change password</button>
|
||||
<button className="btn btn-ghost" onClick={signOut}>Sign out</button>
|
||||
</div>
|
||||
<Msg text={msg.pw} />
|
||||
{isAdmin && (
|
||||
<>
|
||||
<H>Users</H>
|
||||
<Note>People who can sign in to {s.settings.facility}. Passwords set here aren't emailed — hand them over yourself.</Note>
|
||||
{s.users.filter((u) => !u.inactive).map((u) => (
|
||||
<div key={u.id} style={{ display: "flex", alignItems: "center", gap: "var(--space-3)", padding: "var(--space-2) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13, flexWrap: "wrap" }}>
|
||||
<div style={{ flex: 1, minWidth: 200 }}><b>{u.first} {u.last}</b> <span style={{ color: "var(--color-neutral-700)" }}>{u.title}</span><div style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>{u.email}</div></div>
|
||||
<span className={u.role === "ADMIN" ? "tag tag-accent" : "tag tag-neutral"}>{u.role === "ADMIN" ? "Admin" : "Issuer"}</span>
|
||||
<button className="btn btn-ghost" aria-label={`Edit ${u.first} ${u.last}`} onClick={() => setUserDlg(u)}>Edit</button>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-secondary" style={{ marginTop: "var(--space-3)" }} onClick={() => setUserDlg(null)}>Add user</button>
|
||||
{s.users.some((u) => u.inactive) && (
|
||||
<>
|
||||
<H>Deactivated users</H>
|
||||
<Note>Reactivate to let them sign in again.</Note>
|
||||
{s.users.filter((u) => u.inactive).map((u) => (
|
||||
<div key={u.id} style={{ display: "flex", alignItems: "center", gap: "var(--space-3)", padding: "var(--space-2) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13, flexWrap: "wrap", color: "var(--color-neutral-700)" }}>
|
||||
<div style={{ flex: 1, minWidth: 200 }}><b>{u.first} {u.last}</b> {u.title}<div style={{ fontSize: 12 }}>{u.email}</div></div>
|
||||
<span className="tag tag-outline">{u.role === "ADMIN" ? "Admin" : "Issuer"}</span>
|
||||
<button className="btn btn-ghost" aria-label={`Let ${u.first} ${u.last} sign in again`} onClick={async () => { const r = await mutate("users.update", { id: u.id, inactive: false }); say("users", r.ok ? `${u.first} reactivated.` : r.error); }}>Reactivate</button>
|
||||
</div>
|
||||
))}
|
||||
<Msg text={msg.users} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<H>Delete my account</H>
|
||||
{(() => {
|
||||
// Whether this account leaving takes the facility with it decides what the warning has to say.
|
||||
// The user list is only in the snapshot for admins, and there is always at least one active
|
||||
// admin, so a non-admin is never the last person standing — say so rather than guess from an
|
||||
// empty list.
|
||||
const othersLeft = s.users.filter((u) => !u.inactive && u.id !== s.session.userId).length;
|
||||
const last = isAdmin && othersLeft === 0;
|
||||
return (
|
||||
<>
|
||||
<Note>
|
||||
{last
|
||||
? <>You are the only person who can sign in to <b>{s.settings.facility}</b>, so this deletes the facility and everything in it. It can't be undone.</>
|
||||
: <>This removes your login from <b>{s.settings.facility}</b>. The facility and its records stay.</>}
|
||||
</Note>
|
||||
{/* The same fetch as the Data tab's export rather than a plain link, for the same
|
||||
reason and with more riding on it: this is the last copy this facility will ever
|
||||
have, and whether some photos were left out of it is not something to find out
|
||||
after the delete. */}
|
||||
{last && <><Note><button disabled={bkBusy} onClick={exportBackup} style={{ background: "none", border: 0, padding: 0, font: "inherit", fontWeight: 700, color: "var(--color-accent-700)", textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>Download a backup first</button> — it can be restored into a new facility later.</Note><Msg text={msg.backup} /></>}
|
||||
{!del.open ? (
|
||||
<button className="btn btn-secondary" style={{ marginTop: "var(--space-3)", borderColor: "var(--color-accent)", color: "var(--color-accent-700)" }} onClick={() => setDel({ open: true, password: "", confirm: "", busy: false, err: "" })} disabled={!!s.demo}>
|
||||
Delete my account{last ? " and this facility" : ""}
|
||||
</button>
|
||||
) : (
|
||||
<div className="tc-flag" style={{ borderTop: "2px solid var(--color-text)", borderRight: "2px solid var(--color-text)", borderBottom: "2px solid var(--color-text)", padding: "var(--space-4)", marginTop: "var(--space-3)", maxWidth: 520 }}>
|
||||
<div style={{ fontWeight: 800, fontSize: 14, color: "var(--color-accent-700)" }}><span className="tc-mark" aria-hidden="true" />{last ? `Delete ${s.settings.facility} and everything in it?` : "Delete your login?"}</div>
|
||||
<Field label="Your password" style={{ marginTop: "var(--space-3)" }} error={del.err || undefined}>{(c) => <input {...c} className="input" type="password" autoComplete="current-password" value={del.password} onChange={(e) => setDel({ ...del, password: e.target.value, err: "" })} />}</Field>
|
||||
{last && <Field label="Type the facility name to confirm" style={{ marginTop: "var(--space-2)" }}>{(c) => <input {...c} className="input" value={del.confirm} placeholder={s.settings.facility} onChange={(e) => setDel({ ...del, confirm: e.target.value, err: "" })} />}</Field>}
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-3)" }}>
|
||||
<button className="btn btn-ghost" onClick={() => setDel({ open: false, password: "", confirm: "", busy: false, err: "" })}>Cancel</button>
|
||||
<button className="btn btn-primary" disabled={del.busy || !del.password || (last && del.confirm.trim() !== s.settings.facility)}
|
||||
onClick={async () => {
|
||||
setDel((d) => ({ ...d, busy: true, err: "" }));
|
||||
const r = await mutate("me.deleteAccount", { password: del.password, confirm: del.confirm });
|
||||
if (!r.ok) { setDel((d) => ({ ...d, busy: false, err: r.error })); return; }
|
||||
// The session now points at a row that is gone; drop the cookie rather than leave it.
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
window.location.assign("/?deleted=1");
|
||||
}}>
|
||||
{del.busy ? "Deleting…" : last ? "Delete everything" : "Delete my login"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "Data" && (
|
||||
<>
|
||||
<H>Data & backup</H>
|
||||
{/* What a backup would actually be carrying, in the same tiles the rest of the app counts
|
||||
things in — rather than a line of numbers run together, which is what this was. */}
|
||||
<div className="tc-tiles" style={{ marginTop: "var(--space-3)" }}>
|
||||
{[[s.staff.filter((x) => !x.inactive).length, "active staff"], [s.catalog.filter((x) => !x.archived).length, "catalogue items"], [s.issues.length, "issues"], [s.orders.length, "orders"], [s.stocktakes.length, "recent stocktakes"], [s.approvals.length, "manager's approvals"]].map(([n, l]) => (
|
||||
<div key={String(l)} className="tc-tile">
|
||||
<span className="tc-figure">{n}</span>
|
||||
<span className="tc-tile-label">{l}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Overdue is marked, not merely reddened: the Export backup button a few lines down is
|
||||
the same red, and the whole point of this line is to be noticed before somebody scrolls
|
||||
past it. */}
|
||||
<div className={bkStale ? "tc-flag" : undefined} style={{ fontSize: 13, marginTop: "var(--space-4)", fontWeight: bkStale ? 700 : 600, paddingLeft: bkStale ? "var(--space-3)" : 0, color: bkStale ? "var(--color-accent-700)" : "var(--color-text)" }}>
|
||||
{bkStale && <span className="tc-mark" aria-hidden="true" />}
|
||||
{lastBk ? `Last backup: ${fmtDate(lastBk)}${bkDays ? ` (${bkDays} day${bkDays === 1 ? "" : "s"} ago)` : " (today)"}` : "No backup taken yet."}
|
||||
</div>
|
||||
<Note>Export saves everything in this facility to one file; importing a backup replaces this facility's data.</Note>
|
||||
{isAdmin && (
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", alignItems: "center", marginTop: "var(--space-3)", flexWrap: "wrap" }}>
|
||||
<button className="btn btn-secondary" disabled={bkBusy} onClick={exportBackup}>{bkBusy ? "Preparing…" : "Export backup"}</button>
|
||||
<label className="btn btn-ghost" style={{ cursor: "pointer" }}>Import backup<input type="file" accept="application/json,.json" aria-label="Choose a ThreadCount backup file to restore" style={{ display: "none" }} onChange={(e) => { const f = e.target.files?.[0]; if (f) restore(f); e.target.value = ""; }} /></label>
|
||||
</div>
|
||||
)}
|
||||
<Msg text={msg.backup} />
|
||||
{isAdmin && (
|
||||
<>
|
||||
<H>Barcode product lookup</H>
|
||||
<Note>Only the barcode number is sent, and most uniform barcodes aren't publicly listed.</Note>
|
||||
<label style={{ display: "flex", gap: "var(--space-2)", alignItems: "center", marginTop: "var(--space-3)", fontSize: 13, cursor: "pointer" }}>
|
||||
<input type="checkbox" style={{ width: 16, height: 16, accentColor: "var(--color-accent)" }} checked={lookupOn ?? s.settings.barcodeLookup}
|
||||
onChange={async (e) => { const v = e.target.checked; setLookupOn(v); const r = await mutate("settings.update", { barcodeLookup: v }); if (!r.ok) setLookupOn(!v); say("lookup", r.ok ? (v ? "Lookup on — unknown barcodes are checked against the public databases." : "Lookup off — nothing leaves the server.") : r.error); }} />
|
||||
Look up unknown barcodes in public databases
|
||||
</label>
|
||||
<Msg text={msg.lookup} />
|
||||
<H>Import from CSV</H>
|
||||
<Note>Download a template, fill it in, save it as CSV and import it; re-importing updates matching rows.</Note>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", alignItems: "center", marginTop: "var(--space-3)", flexWrap: "wrap" }}>
|
||||
<select className="input" aria-label="What kind of CSV to import" value={impKind} onChange={(e) => setImpKind(e.target.value)}>{Object.entries(CSV_TEMPLATES).map(([k, t]) => <option key={k} value={k}>{t.name}</option>)}</select>
|
||||
<button className="btn btn-ghost" onClick={() => template(impKind)}>Download template</button>
|
||||
<label className="btn btn-primary" style={{ cursor: impBusy ? "wait" : "pointer" }}>Import CSV<input type="file" accept=".csv,text/csv" aria-label="Choose a CSV file to import" style={{ display: "none" }} disabled={impBusy} onChange={(e) => { const f = e.target.files?.[0]; if (f) void importFile(f); e.target.value = ""; }} /></label>
|
||||
</div>
|
||||
<Msg text={msg.import} />
|
||||
<div style={{ marginTop: "var(--space-8)", border: "2px solid var(--color-divider)", padding: "var(--space-4)" }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 13 }}>Wipe recorded activity</div>
|
||||
<div style={{ fontSize: 12, color: "var(--color-neutral-700)", margin: "var(--space-2) 0" }}>Removes all recorded activity, staff-app requests and the ward notice included; keeps the catalogue, staff, departments, suppliers, barcodes and opening balances. Type WIPE to confirm.</div>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", alignItems: "center" }}>
|
||||
<input className="input" style={{ width: 120 }} aria-label="Type WIPE to confirm wiping recorded activity" value={wipe} onChange={(e) => setWipe(e.target.value)} placeholder="WIPE" />
|
||||
<button className="btn btn-secondary" disabled={wipe !== "WIPE"} onClick={async () => { const r = await mutate("data.wipeActivity", { confirm: wipe }); say("wipe", r.ok ? "Activity wiped." : r.error); setWipe(""); }}>Wipe activity</button>
|
||||
</div>
|
||||
<Msg text={msg.wipe} />
|
||||
</div>
|
||||
{/* The one irreversible thing on this page. A red outline is not enough on its own —
|
||||
the Import CSV button above it is the same red — so it takes the left rule and the
|
||||
mark as well. */}
|
||||
<div className="tc-flag" style={{ marginTop: "var(--space-4)", borderTop: "2px solid var(--color-text)", borderRight: "2px solid var(--color-text)", borderBottom: "2px solid var(--color-text)", padding: "var(--space-4)" }}>
|
||||
<div style={{ fontWeight: 800, fontSize: 13, color: "var(--color-accent-700)" }}><span className="tc-mark" aria-hidden="true" />Start fresh</div>
|
||||
<div style={{ fontSize: 12, color: "var(--color-neutral-700)", margin: "var(--space-2) 0" }}>Empties this facility completely; your logins, facility name and settings stay. <b>Export a backup first</b> — this can't be undone. Type RESET to confirm.</div>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", alignItems: "center", flexWrap: "wrap" }}>
|
||||
<input className="input" style={{ width: 120 }} aria-label="Type RESET to confirm emptying this facility" value={reset} onChange={(e) => setReset(e.target.value)} placeholder="RESET" />
|
||||
<button className="btn btn-primary" disabled={reset !== "RESET" || resetBusy} onClick={async () => {
|
||||
if (!confirm("Delete everything in this facility and start fresh? Logins stay; all data goes.")) return;
|
||||
setResetBusy(true);
|
||||
const r = await mutate("data.reset", { confirm: reset });
|
||||
setResetBusy(false);
|
||||
say("reset", r.ok ? "Facility emptied — you’re starting fresh." : r.error);
|
||||
setReset("");
|
||||
}}>{resetBusy ? "Emptying…" : "Empty this facility"}</button>
|
||||
</div>
|
||||
<Msg text={msg.reset} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{userDlg !== false && <UserDialog user={userDlg} onClose={() => setUserDlg(false)} />}
|
||||
{renaming !== null && <RenameGroupDialog from={renaming} onClose={() => setRenaming(null)} onDone={(m) => { setRenaming(null); say("groups", m); }} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* A staff group renamed everywhere its name is held — the list, its route, and every staff record
|
||||
filed under it — in one go, by settings.renameGroup. Its own step rather than typing over the name
|
||||
in the row, because the routes are lists of names: a name edited and saved as one group removed and
|
||||
another added would take the group off its route, and a team's first kit would change because a
|
||||
label was tidied up. Being a dialog also means nothing else on the page can be pressed while the
|
||||
rename is on its way, and be worked out from lists that still carry the old name. */
|
||||
function RenameGroupDialog({ from, onClose, onDone }: { from: string; onClose: () => void; onDone: (msg: string) => void }) {
|
||||
const { mutate } = useSnap();
|
||||
const [to, setTo] = useState(from);
|
||||
const [err, setErr] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const name = to.trim();
|
||||
const unchanged = name === from;
|
||||
async function save() {
|
||||
if (!name || unchanged || saving) return;
|
||||
setSaving(true);
|
||||
const r = await mutate<{ staff: number }>("settings.renameGroup", { from, to: name });
|
||||
setSaving(false);
|
||||
// A name already in use is the server's to refuse — it also knows the names only staff records
|
||||
// carry — so its words go under the box rather than being second-guessed here.
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
const n = r.result.staff;
|
||||
onDone(`${from} is now ${name}, on the same route as before.${n ? ` ${n} staff record${n === 1 ? "" : "s"} moved with it.` : ""}`);
|
||||
}
|
||||
return (
|
||||
<Dialog title={`Rename ${from}`} width={460} onClose={onClose}>
|
||||
<div style={{ fontSize: 13, lineHeight: 1.6, marginTop: "var(--space-3)" }}>Everybody filed under {from} moves to the new name and keeps the same route.</div>
|
||||
<Field label="New name" style={{ marginTop: "var(--space-3)" }}>{(c) => <input {...c} className="input" autoFocus maxLength={80} value={to} onChange={(e) => { setTo(e.target.value); setErr(""); }} onKeyDown={(e) => { if (e.key === "Enter") void save(); }} />}</Field>
|
||||
<ErrorLine msg={err} />
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: "var(--space-2)", marginTop: "var(--space-4)" }}>
|
||||
<button className="btn btn-ghost" onClick={onClose}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={() => void save()} disabled={!name || unchanged || saving}>{saving ? "Renaming…" : "Rename"}</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function UserDialog({ user, onClose }: { user: UserRec | null; onClose: () => void }) {
|
||||
const { s, mutate } = useSnap();
|
||||
const [f, setF] = useState({ first: user?.first || "", last: user?.last || "", title: user?.title || "", email: user?.email || "", role: user?.role || "ISSUER", password: "" });
|
||||
const [err, setErr] = useState("");
|
||||
const invalid = !f.first.trim() || !f.last.trim() || (!user && (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(f.email) || f.password.length < 8)) || (!!user && f.password !== "" && f.password.length < 8);
|
||||
async function save() {
|
||||
if (invalid) return;
|
||||
const r = user ? await mutate("users.update", { id: user.id, first: f.first, last: f.last, title: f.title, role: f.role, password: f.password }) : await mutate("users.add", f);
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
onClose();
|
||||
}
|
||||
async function remove() {
|
||||
if (!user || !confirm(`Deactivate ${user.first} ${user.last}'s login? They can be reactivated later.`)) return;
|
||||
const r = await mutate("users.remove", { id: user.id });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
onClose();
|
||||
}
|
||||
return (
|
||||
<Dialog title={user ? "Edit user" : "Add user"} width={520} onClose={onClose}>
|
||||
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-3)", marginTop: "var(--space-4)" }}>
|
||||
<Field label="First name">{(c) => <input {...c} className="input" value={f.first} onChange={(e) => setF({ ...f, first: e.target.value })} />}</Field>
|
||||
<Field label="Last name">{(c) => <input {...c} className="input" value={f.last} onChange={(e) => setF({ ...f, last: e.target.value })} />}</Field>
|
||||
<Field label="Title">{(c) => <input {...c} className="input" value={f.title} onChange={(e) => setF({ ...f, title: e.target.value })} placeholder="e.g. Linen Room Assistant" />}</Field>
|
||||
<Field label="Role">{(c) => <select {...c} className="input" value={f.role} onChange={(e) => setF({ ...f, role: e.target.value as "ADMIN" | "ISSUER" })}><option value="ISSUER">Issuer</option><option value="ADMIN">Admin</option></select>}</Field>
|
||||
<Field label="Work email" style={{ gridColumn: "1 / -1" }} hint={user ? "Can\u2019t be changed." : undefined}>{(c) => <input {...c} className="input" type="email" value={f.email} onChange={(e) => setF({ ...f, email: e.target.value })} disabled={!!user} />}</Field>
|
||||
<Field label={user ? "New password (leave blank to keep)" : "Password"} style={{ gridColumn: "1 / -1" }} hint="At least 8 characters.">{(c) => <input {...c} className="input" type="password" autoComplete="new-password" value={f.password} onChange={(e) => setF({ ...f, password: e.target.value })} />}</Field>
|
||||
</div>
|
||||
<ErrorLine msg={err} />
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--space-2)", marginTop: "var(--space-4)" }}>
|
||||
<div>{user && user.id !== s.session.userId && <button className="btn btn-ghost" onClick={remove}>Remove</button>}</div>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)" }}><button className="btn btn-ghost" onClick={onClose}>Cancel</button><button className="btn btn-primary" onClick={save} disabled={invalid}>Save</button></div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,417 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { PageHead, Empty, th } from "@/components/ui";
|
||||
import { StaffDialog } from "@/components/dialogs";
|
||||
import { capState, ccFor, ccOf, csvOf, heldByStaff, isNursing, setsCap, slipLive, staffName, type CapState, type GarmentCounts, type Snapshot, type StaffRec } from "@/lib/compute";
|
||||
import { downloadCsv } from "@/lib/print";
|
||||
|
||||
/* The register as this screen reads it: the snapshot, and the same staff indexed by id.
|
||||
*
|
||||
* The index is the point. Every gap is asked of every row, and the approver gap used to search the
|
||||
* register from the top for each one — every row against every staff member, redone on each
|
||||
* keystroke in the search box. Registers arrive here twenty thousand rows deep off the importer,
|
||||
* and at that size the search box stops taking typing. */
|
||||
type Reg = { s: Snapshot; byId: Record<string, StaffRec> };
|
||||
|
||||
/** Who the register currently holds as their approver. Undefined when the field was never set, and
|
||||
* undefined again when it points at somebody who has since been taken off the register altogether. */
|
||||
const approverOf = (r: Reg, st: StaffRec) => (st.managerId ? r.byId[st.managerId] : undefined);
|
||||
|
||||
/** Somebody with nothing out. heldByStaff only lists people holding something, and a blank row and a
|
||||
* row of noughts have to read the same way. */
|
||||
const NOTHING: GarmentCounts = { tops: 0, pants: 0, other: 0, sets: 0 };
|
||||
|
||||
/** What somebody is holding, in the halves the ceiling is counted in. Six tops and two pairs is two
|
||||
* sets by any count and still no room for a seventh top, so the halves are what a coordinator has
|
||||
* to be able to see. Anything that is no part of a set — a fleece, maternity wear — is left to the
|
||||
* caller to name as what it is, because it has a ceiling of its own, and a row past that one has
|
||||
* to be able to say so without the tops and pairs taking the blame. */
|
||||
const halves = (c: CapState) =>
|
||||
`${c.tops} ${c.tops === 1 ? "top" : "tops"} · ${c.pants} ${c.pants === 1 ? "pair" : "pairs"}`;
|
||||
|
||||
/** Where this person stands against the ceiling, in the word the tag shows.
|
||||
*
|
||||
* AT LIMIT is a full half rather than a full six sets: somebody holding six tops and two pairs
|
||||
* takes no more tops, and reading their four spare pairs as room would have a coordinator promise
|
||||
* a top the counter then turns down. OVER is the counter's own answer — they hold more than one
|
||||
* person holds, which happens on an override or on a record that predates the ceiling. */
|
||||
const holdState = (c: CapState): "OVER" | "AT LIMIT" | "OK" =>
|
||||
c.over ? "OVER" : c.tops >= c.cap || c.pants >= c.cap || c.other >= c.otherCap ? "AT LIMIT" : "OK";
|
||||
|
||||
/** The sentence behind the tag. Inside the ceiling it is capState's own — the same words the counter
|
||||
* uses, so the two screens cannot drift into quoting different figures at the same person. Past it,
|
||||
* capState writes for a hand-over that is about to happen, and nothing is about to happen on a
|
||||
* register row, so the row says what is true of the locker instead.
|
||||
*
|
||||
* It names the ceiling they are actually past. There are two — six sets, and six garments that are
|
||||
* no part of a set — and somebody holding seven fleeces and no uniform at all was told they were
|
||||
* past the six sets, which a coordinator checking the locker would find plainly untrue. Both are
|
||||
* named when both are breached, because handing in a top fixes only one of them. */
|
||||
const holdWhy = (c: CapState) => {
|
||||
if (!c.over) return c.note;
|
||||
const past = [
|
||||
...(c.overTops || c.overPants ? [`${c.tops} ${c.tops === 1 ? "top" : "tops"} and ${c.pants} ${c.pants === 1 ? "pair" : "pairs"}, past the ${c.cap}-set ceiling`] : []),
|
||||
...(c.overOther ? [`${c.other} ${c.other === 1 ? "garment" : "garments"} outside a set, past the ${c.otherCap} allowed`] : []),
|
||||
];
|
||||
return `Holding ${past.join("; and ")}.`;
|
||||
};
|
||||
|
||||
/** A code is outstanding and would still be accepted at activation. The age test is slipLive(), the
|
||||
* one the activation route itself asks, so a slip this register counts as done is never one the
|
||||
* nurse is then turned away with. */
|
||||
const liveSlip = (s: Snapshot, st: StaffRec) => !!st.selfCode && slipLive(st.selfCodeAt, s.today, s.tz);
|
||||
|
||||
/* What a record is still missing before the product can do its job for the person on it.
|
||||
*
|
||||
* Each of these is something that is broken until somebody sets it, not a field that merely happens
|
||||
* to be blank — and none of them showed anywhere on a list before, so the only way to find the
|
||||
* forty people with no approver on a register of a couple of hundred was to open all of them. That
|
||||
* is why they are counted, filtered and exported rather than just marked: a coordinator works
|
||||
* through one of these lists until it is empty, and a badge on a row nobody can filter to is no
|
||||
* help at all.
|
||||
*
|
||||
* `required` says whether somebody has to set it at all. An approver, a nursing FTE and a pair of
|
||||
* sizes do: without them nobody can raise a request, the table proposes no kit, and nothing can be
|
||||
* picked before the person is standing at the counter. A staff-app account does not — it is
|
||||
* something a coordinator offers somebody, and plenty of the register will never want one. Counting
|
||||
* the offer alongside the three made a single queue that could never reach nought, and a queue that
|
||||
* never empties is a queue nobody works.
|
||||
*
|
||||
* Inactive staff have no gaps. They are off the register, nobody is going to issue to them or hand
|
||||
* them a code, and putting them in the queue would mean a list that never empties.
|
||||
*
|
||||
* `done` is what an empty list of one of the offered things says. The required three share one
|
||||
* sentence — the job is finished — but an offer is finished in its own words, and "everyone has it
|
||||
* or has been offered it" is a sentence about a staff-app account that says nothing true about a
|
||||
* cut of uniform. */
|
||||
type GapKey = "manager" | "fte" | "sizes" | "app" | "style";
|
||||
const GAPS: { key: GapKey; short: string; filter: string; noun: string; why: string; required: boolean; done?: string; missing: (r: Reg, st: StaffRec) => boolean }[] = [
|
||||
// A manager who has left the register is the same thing as no manager at all: a request addressed
|
||||
// to one is refused outright, because somebody off the register cannot sign in to approve
|
||||
// anything. Reading the field alone let a ward whose nurse unit manager had gone read as finished
|
||||
// while not one of them could raise a thing.
|
||||
{ key: "manager", short: "Approver", filter: "No approver", noun: "no approver", required: true, why: "No approver on the register, so they can't raise a request.", missing: (r, st) => { const mgr = approverOf(r, st); return !mgr || mgr.inactive; } },
|
||||
// Nursing only: the FTE table decides a nurse's initial kit and nothing else in the product reads
|
||||
// the figure, so flagging two hundred operational staff for a blank one would bury the nurses who
|
||||
// genuinely cannot be issued anything.
|
||||
{ key: "fte", short: "FTE", filter: "No FTE", noun: "no FTE", required: true, why: "No FTE, so no initial kit is proposed.", missing: (r, st) => isNursing(r.s, st) && !st.fte.trim() },
|
||||
{ key: "sizes", short: "Sizes", filter: "No sizes", noun: "no sizes", required: true, why: "No top or pants size recorded.", missing: (r, st) => !st.top.trim() || !st.pants.trim() },
|
||||
// A code that still works is work already done — the slip is printed and waiting to be used —
|
||||
// so it is not a gap. One printed two months ago is: it will be turned away, leaving the person
|
||||
// exactly where they started, and reading it as finished drops them off the only list that would
|
||||
// have found them.
|
||||
{ key: "app", short: "Staff app", filter: "No staff app", noun: "no staff app", required: false, why: "Optional — no account, and no code that still works.", done: "Nobody is waiting on that — everyone on the register has an account or has been offered one.", missing: (r, st) => !st.selfEmail && !liveSlip(r.s, st) },
|
||||
// Blank is not a broken record: it is what every record on the register reads as today, and it
|
||||
// offers every cut exactly as Either does. So it is listed among the things that are offered
|
||||
// rather than owed — nothing is stopped while it stays blank, and a coordinator who never sets
|
||||
// one has finished their work. It is here at all because blank and Either are kept apart in
|
||||
// storage for exactly this: to be able to ask who nobody has said anything about yet.
|
||||
{ key: "style", short: "Uniform style", filter: "No uniform style", noun: "no uniform style", required: false, why: "Optional — nobody has set a cut, so they are offered every style.", done: "Nobody is waiting on that — every record has a uniform style set.", missing: (r, st) => !st.uniformStyle.trim() },
|
||||
];
|
||||
const REQUIRED = GAPS.filter((g) => g.required);
|
||||
const OPTIONAL = GAPS.filter((g) => !g.required);
|
||||
|
||||
export default function StaffPage() {
|
||||
const { s, isAdmin } = useSnap();
|
||||
const [add, setAdd] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const [group, setGroup] = useState("All");
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
const [gap, setGap] = useState<"All" | "any" | GapKey>("All");
|
||||
/* Built once a snapshot, and everything that asks a gap asks it through this — the counts, the
|
||||
filter, the rows and the file. See Reg. */
|
||||
const reg = useMemo<Reg>(() => { const byId: Record<string, StaffRec> = {}; for (const st of s.staff) byId[st.id] = st; return { s, byId }; }, [s]);
|
||||
/** What this person is still missing, in the order the register lists it. Empty for anybody
|
||||
* inactive — see GAPS. */
|
||||
const gapsOf = useCallback((st: StaffRec) => (st.inactive ? [] : GAPS.filter((g) => g.missing(reg, st))), [reg]);
|
||||
const rows = useMemo(() => {
|
||||
const ql = q.trim().toLowerCase();
|
||||
const wanted = (st: StaffRec) => gap === "All" || (gap === "any" ? !st.inactive && REQUIRED.some((g) => g.missing(reg, st)) : !st.inactive && GAPS.find((g) => g.key === gap)!.missing(reg, st));
|
||||
return reg.s.staff.filter((st) => (showInactive || !st.inactive) && (group === "All" || st.group === group) && wanted(st) && (!ql || `${st.first} ${st.last}`.toLowerCase().includes(ql) || st.num.toLowerCase().includes(ql) || st.dept.toLowerCase().includes(ql)));
|
||||
}, [reg, q, group, showInactive, gap]);
|
||||
const groups = ["All", ...new Set(s.staff.map((st) => st.group).filter(Boolean))];
|
||||
const nInactive = s.staff.filter((st) => st.inactive).length;
|
||||
const nActive = s.staff.length - nInactive;
|
||||
const nDesk = s.staff.filter((st) => !st.inactive && st.wardDesk).length;
|
||||
const narrowed = q.trim() !== "" || group !== "All" || gap !== "All";
|
||||
const shownInactive = rows.filter((st) => st.inactive).length;
|
||||
/* The one gap the filter is pointed at, or nothing when it is pointed at everybody or at the whole
|
||||
required queue. What is said to somebody who has worked a list down to nothing depends on
|
||||
whether the list was work or an offer. */
|
||||
const gapSel = gap === "All" || gap === "any" ? null : GAPS.find((g) => g.key === gap)!;
|
||||
/* Counted over the whole active register, like the ceiling figure below and for the same reason:
|
||||
these are queues of work for the coordinator, and narrowing to one ward must not make the
|
||||
hospital's forty missing approvers read as three. The panel head says how many rows are on
|
||||
screen, so the two numbers never claim to be the same thing. */
|
||||
const gapCount = useMemo(() => {
|
||||
// `any` counts only what somebody has to set. A person who has simply never been offered the
|
||||
// staff app is not a record anybody has to finish, and counting them kept the queue full.
|
||||
const counts = { any: 0 } as Record<GapKey | "any", number>;
|
||||
for (const g of GAPS) counts[g.key] = 0;
|
||||
for (const st of reg.s.staff) {
|
||||
if (st.inactive) continue;
|
||||
let some = false;
|
||||
for (const g of GAPS) if (g.missing(reg, st)) { counts[g.key]++; if (g.required) some = true; }
|
||||
if (some) counts.any++;
|
||||
}
|
||||
return counts;
|
||||
}, [reg]);
|
||||
const gapBreakdown = REQUIRED.filter((g) => gapCount[g.key] > 0).map((g) => `${gapCount[g.key]} with ${g.noun}`).join(" · ");
|
||||
const optionalBreakdown = OPTIONAL.filter((g) => gapCount[g.key] > 0).map((g) => `${gapCount[g.key]} with ${g.noun}`).join(" · ");
|
||||
/* What everybody holds, worked out once for the whole register — what they have out, and what is
|
||||
on order for them or waiting at the counter, which the counter counts as theirs too. This is what
|
||||
the screen is now about: the ceiling is on what a person is holding — six sets, at any time, every
|
||||
group — so a row is read against their locker rather than against anything they drew in
|
||||
July. One walk of the issues instead of one per person, because asked a row at a time it is the
|
||||
facility's whole issue history re-read for every name on the register. */
|
||||
const held = useMemo(() => heldByStaff(s), [s]);
|
||||
const capSets = setsCap(s.settings.capSets);
|
||||
/* Where somebody stands against that ceiling, asked of the same function the counter asks and with
|
||||
nothing in their hands: no hand-over is happening on a register row, so the answer is about what
|
||||
is in the locker. Anybody past the ceiling got there on a coordinator's override, and they are
|
||||
the rows this screen exists to surface. */
|
||||
const holdingOf = useCallback((st: StaffRec) => capState({ held: held[st.id] || NOTHING, capSets: s.settings.capSets }), [held, s.settings.capSets]);
|
||||
/* Counted over the whole active register, not over the rows on screen. It is the figure somebody
|
||||
opens this page to check, and narrowing the search to one ward must not make the hospital's
|
||||
count read as nought. */
|
||||
const nOver = useMemo(() => s.staff.filter((st) => !st.inactive && holdingOf(st).over).length, [s, holdingOf]);
|
||||
/* The file is the rows on screen, search and filter and all — somebody who has narrowed to one
|
||||
ward and hits Export means that ward, not the whole hospital. It is also written to go back in:
|
||||
the headers are the staff import template's, so a register can be sent to a ward manager, come
|
||||
back with the sizes corrected, and be imported in Settings → Data without anyone retyping it.
|
||||
That is why there is no title line above the headers the way the stocktake and order exports
|
||||
have one — the importer reads row one as the header row, and a preamble would make every file
|
||||
this button produces unreadable to it.
|
||||
|
||||
Cost centre goes out as two columns where the screen shows one. The screen shows the code an
|
||||
issue is charged to: the person's override if they have one, else their ward's. The template's
|
||||
cc column means something narrower — it is the code a ward is created with — so folding one
|
||||
person's override into it would stamp their whole ward with it on the next import, and every
|
||||
issue on that ward would report against the wrong cost centre from then on.
|
||||
|
||||
Notes and start dates are left out. Notes carry remarks about a person — light duties, a
|
||||
grievance — and this file is the one thing on the register that gets emailed to a ward; neither
|
||||
is on this screen, and the screen is the limit of what Export hands over. Dropping a column is
|
||||
safe for the round trip: the importer only writes the columns a row actually provides, so an
|
||||
edited register coming back leaves notes and start dates exactly as they were.
|
||||
|
||||
The approver does go out now, as the manager's staff number under the header the importer
|
||||
reads. Nobody is going to set two hundred of them one dropdown at a time — the way it gets done
|
||||
is to send a ward its own people, have the manager fill that column in a spreadsheet and import
|
||||
it back, and the Missing column beside it says who still needs one. Their name goes out too,
|
||||
under a header the importer has no alias for, because a payroll number on its own tells a ward
|
||||
manager nothing about whether it is the right person. A blank manager cell coming back changes
|
||||
nothing, so a file edited by somebody who ignored the column is harmless.
|
||||
|
||||
Those two columns say in their own headers which is which, because filling a ward's approvers
|
||||
in is now the whole reason the file gets sent anywhere, and a name typed into the column that
|
||||
wants a staff number is the mistake to expect rather than an unlikely slip. "Manager number"
|
||||
asks for the number and is the one the importer reads; "Approver name (reference only)" is
|
||||
there to check the number against and is ignored coming back. Neither header is free text —
|
||||
the importer matches on the letters and digits alone, so "Manager number" has to stay one of
|
||||
the spellings it knows and the other has to stay clear of all of them. */
|
||||
function exportCsv() {
|
||||
// Two of these carry a different fact from the column of the same name on the screen, so they
|
||||
// are named apart rather than left to collide. "Department cost centre" is the ward's own code,
|
||||
// which is what the import template means by cc; "Cost centre in use" is what the screen shows
|
||||
// and what an issue is actually charged to. "Register status" is on or off the register;
|
||||
// "Holding status" is the ceiling tag the screen calls Status. The export-only columns,
|
||||
// "Approver name (reference only)" among them, normalise to keys the importer has no alias for,
|
||||
// so they are ignored coming back.
|
||||
// FTE is in the file because the register is loaded and corrected through it: the importer has
|
||||
// always read the column, and a round trip that dropped it was quietly answering "no initial
|
||||
// kit" for every nurse whose figure a ward manager had just written in.
|
||||
// "Entitlement" is that person's own yearly figure and nothing more — the reports measure a
|
||||
// year's drawing against it, and a blank one means the facility's. It keeps its bare header
|
||||
// because that is the one the importer reads, so a figure corrected in a spreadsheet comes
|
||||
// back. What the counter will and won't hand over is the five columns after it: sets, tops,
|
||||
// pairs and garments outside a set held now, against the ceiling one person holds. Outside a set
|
||||
// is there because it has a ceiling of its own: without it, somebody past that one went out as
|
||||
// nought sets, nought tops, nought pairs and OVER, which no ward manager can make sense of. The year's running tally is a reports
|
||||
// question and is printed there — this file's job is who holds what and whose record needs
|
||||
// work.
|
||||
// The cut of uniform goes out beside the FTE, under a header the importer reads — it strips
|
||||
// everything but the letters and digits, so "Uniform style" reaches it as one of the column
|
||||
// names it knows. A whole register's worth of these is set the same way the approvers are:
|
||||
// send the ward its own people, have the column filled in a spreadsheet and import it back.
|
||||
// Blank goes out blank, which is the one answer that stays safe on the round trip: a blank
|
||||
// cell keeps whatever is on the record, so a file edited by somebody who ignored the column
|
||||
// sets nobody's style to anything.
|
||||
const cols = ["Staff no.", "First name", "Last name", "Phone", "Group", "Department", "Department cost centre", "Cost centre override", "Cost centre in use", "Top", "Pants", "FTE", "Uniform style", "Manager number", "Approver name (reference only)", "Ward desk", "Entitlement", "Sets held", "Tops held", "Pairs held", "Outside a set held", "Ceiling (sets)", "Holding status", "Register status", "Missing"];
|
||||
downloadCsv(`threadcount-staff-${s.today}.csv`, csvOf(cols, rows.map((st) => {
|
||||
const c = holdingOf(st);
|
||||
// A recorded approver who has left says so beside their name. The number still goes out, so
|
||||
// the round trip keeps it, but the Missing column flags the row — and without a word here a
|
||||
// ward manager reads a name in the file, sees nothing wrong, and sends it back untouched.
|
||||
const mgr = approverOf(reg, st);
|
||||
// Only what has to be set goes in the Missing column. This file is sent to a ward manager to
|
||||
// fill the approver column in, and a staff-app account is linen-room business — there is no
|
||||
// column here for it, nothing a manager can do about it, and the importer has never heard of
|
||||
// the idea. Listed beside the sizes it had wards ringing about work that was never theirs. It
|
||||
// is still on the screen, and still has its own filter.
|
||||
return [st.num, st.first, st.last, st.phone, st.group, st.dept, ccFor(s, st.dept), st.ccOverride, ccOf(s, st), st.top, st.pants, st.fte, st.uniformStyle, mgr?.num ?? "", mgr?.inactive ? `${staffName(mgr)} — no longer on the register` : staffName(mgr), st.wardDesk ? "Yes" : "No", st.ent ?? "", c.sets, c.tops, c.pants, c.other, c.cap, st.inactive ? "Inactive" : holdState(c), gapsOf(st).filter((g) => g.required).map((g) => g.short).join(", ")];
|
||||
})));
|
||||
}
|
||||
return (
|
||||
<section>
|
||||
<PageHead eyebrow="People" title="Staff Register">
|
||||
<input className="input" style={{ width: 220 }} aria-label="Search the register by name, number or ward" placeholder="Search name, number, ward" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<select className="input" aria-label="Staff group" value={group} onChange={(e) => setGroup(e.target.value)}>{groups.map((g) => <option key={g}>{g}</option>)}</select>
|
||||
{/* The queue, picked by what is missing rather than by who. The counts stay in the list
|
||||
when they reach nought instead of the option disappearing, because a coordinator working
|
||||
through one of these needs to see it empty — that is the moment the job is finished, and
|
||||
an option that vanishes at the end looks like the filter broke.
|
||||
|
||||
Grouped, because the two halves are different jobs: the top of the list is work that has
|
||||
to be done before those people can use the product at all, and the bottom is an offer
|
||||
that is nobody's fault for being outstanding. Loose in one list, the staff-app figure
|
||||
read as a backlog. */}
|
||||
<select className="input" aria-label="Show only records that are missing something" value={gap} onChange={(e) => setGap(e.target.value as typeof gap)}>
|
||||
<option value="All">Everyone</option>
|
||||
<optgroup label="Has to be set">
|
||||
<option value="any">Missing something ({gapCount.any})</option>
|
||||
{REQUIRED.map((g) => <option key={g.key} value={g.key}>{g.filter} ({gapCount[g.key]})</option>)}
|
||||
</optgroup>
|
||||
{OPTIONAL.length > 0 && (
|
||||
<optgroup label="Optional">
|
||||
{OPTIONAL.map((g) => <option key={g.key} value={g.key}>{g.filter} ({gapCount[g.key]})</option>)}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
{nInactive > 0 && <label style={{ fontSize: 12, display: "flex", gap: 4, alignItems: "center", cursor: "pointer" }}><input type="checkbox" checked={showInactive} onChange={(e) => setShowInactive(e.target.checked)} />Show inactive ({nInactive})</label>}
|
||||
<button className="btn btn-ghost" onClick={exportCsv} disabled={rows.length === 0} title="Downloads the rows shown, search and filter applied.">{narrowed ? `Export CSV (${rows.length} shown)` : "Export CSV"}</button>
|
||||
{isAdmin && <button className="btn btn-primary" onClick={() => setAdd(true)}>Add staff member</button>}
|
||||
</PageHead>
|
||||
{/* The four facts the register is opened for, before anybody reads a row: how many people are
|
||||
on it, who can sign for a ward bag at the other end of the round, who is holding more than
|
||||
one person holds, and how much of the register is still half-filled-in. Only the ceiling
|
||||
figure is marked as well as coloured — the accent is already the brand, and a
|
||||
second red a metre away across the room is a guess rather than a signal. The incomplete
|
||||
figure is work too, but it is work with a queue behind it, so it points at the filter
|
||||
rather than shouting. */}
|
||||
<div className="tc-tiles">
|
||||
<div className="tc-tile">
|
||||
<span className="tc-figure">{nActive}</span>
|
||||
<span className="tc-tile-label">On the register</span>
|
||||
<span className="tc-tile-note">{nInactive ? `${nInactive} inactive, kept for their history` : "Nobody inactive"}</span>
|
||||
</div>
|
||||
<div className="tc-tile">
|
||||
<span className="tc-figure">{nDesk}</span>
|
||||
<span className="tc-tile-label">On a ward desk</span>
|
||||
<span className="tc-tile-note">{nDesk ? "They sign for the bags the round drops" : "Nobody signs for a ward bag"}</span>
|
||||
</div>
|
||||
<div className={"tc-tile" + (nOver > 0 ? " tc-flag" : "")}>
|
||||
<span className="tc-figure">{nOver}</span>
|
||||
<span className="tc-tile-label">Over the ceiling</span>
|
||||
<span className="tc-tile-note">
|
||||
{nOver > 0 ? <><span className="tc-mark" aria-hidden="true" />Nothing more goes out to them until something comes back in</> : `Everybody is inside the ${capSets} sets one person holds`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tc-tile">
|
||||
<span className="tc-figure">{gapCount.any}</span>
|
||||
<span className="tc-tile-label">Records to finish</span>
|
||||
{/* Only the work that has to be done is counted here. The staff app is named underneath
|
||||
once the required list is clear, so the offer is still visible without a coordinator
|
||||
reading it as a job they have not finished. */}
|
||||
<span className="tc-tile-note">{gapCount.any
|
||||
? `${gapBreakdown} — pick one above and work it down`
|
||||
: optionalBreakdown
|
||||
? `Every record is finished — ${optionalBreakdown}, which is optional`
|
||||
: "Every record is finished"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="tc-panel" style={{ marginTop: "var(--space-4)" }}>
|
||||
<div className="tc-panel-head">
|
||||
<span>{narrowed ? "Matching staff" : "The register"}</span>
|
||||
{/* Counted off the rows themselves rather than off the tick. Every one of the missing
|
||||
filters leaves inactive staff out — nobody is going to issue to them or hand them a
|
||||
code — so with one of those on, a ticked Show inactive puts not one of them on screen
|
||||
while the head went on saying they were in the list. */}
|
||||
<span className="tc-panel-aside">{rows.length} {rows.length === 1 ? "row" : "rows"}{shownInactive ? `, ${shownInactive} inactive` : ""}</span>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
{/* Ward desk is set one record at a time and nothing else in the product ever lists who
|
||||
holds it — so the only way to answer "who signs for the round on 4B?" was to open
|
||||
every record in turn. It is a column here for the same reason the department is: it
|
||||
is a fact about the person you scan the register for. */}
|
||||
<thead><tr>{th("Staff no.")}{th("Name")}{th("Group")}{th("Department")}{th("Ward desk")}{th("Cost centre")}{th("Sizes")}{th("Sets held", true)}{th("Status")}{th("Missing")}<th></th></tr></thead>
|
||||
<tbody>
|
||||
{rows.map((st) => {
|
||||
const c = holdingOf(st), state = holdState(c);
|
||||
const over = !st.inactive && c.over;
|
||||
const overSets = over && (c.overTops > 0 || c.overPants > 0), overOther = over && c.overOther > 0;
|
||||
const missing = gapsOf(st);
|
||||
return (
|
||||
<tr key={st.id} style={{ opacity: st.inactive ? 0.45 : 1 }}>
|
||||
<td style={{ fontSize: 12 }}>{st.num}</td>
|
||||
<td style={{ fontWeight: 600 }}><Link href={`/app/staff/${st.id}`} className="link-name">{st.first} {st.last}</Link>{st.phone && <div style={{ fontSize: 11, fontWeight: 400, color: "var(--color-neutral-700)" }}>{st.phone}</div>}</td>
|
||||
<td>{st.group}</td>
|
||||
<td>{st.dept}</td>
|
||||
<td>{st.wardDesk ? <span className="tag tag-outline">On the desk</span> : <span style={{ color: "var(--color-neutral-600)" }}>—</span>}</td>
|
||||
<td>{ccOf(s, st) || "—"}</td>
|
||||
<td style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>top {st.top || "—"} · pants {st.pants || "—"}</td>
|
||||
{/* Past the ceiling is the one status on this screen somebody has to do something
|
||||
about, so the figure carries the weight and the tag carries the mark. Every
|
||||
other tag on the row is red too — the accent is the brand — and a row scanned
|
||||
from a metre away needs more than another red rectangle.
|
||||
|
||||
Sets on the top line, the halves under it: the ceiling bites on each half, so
|
||||
the set count on its own shows somebody holding six tops and one pair as
|
||||
"1 / 6" — five sets of room where there is room for no more tops at all.
|
||||
|
||||
The weight goes on whichever figure is past its ceiling. Somebody holding
|
||||
seven fleeces and no uniform is "0 / 6", and a red nought reads as the sets
|
||||
being the trouble; the garments outside a set carry the mark instead. */}
|
||||
<td style={{ textAlign: "right", fontWeight: overSets ? 800 : 400, color: overSets ? "var(--color-accent-700)" : undefined }}>{c.sets} / {c.cap}
|
||||
{c.tops || c.pants || c.other ? <div style={{ fontSize: 11, fontWeight: 400, color: "var(--color-neutral-700)" }}>{halves(c)}{c.other ? <> · <span style={overOther ? { fontWeight: 800, color: "var(--color-accent-700)" } : undefined}>{c.other} outside a set{overOther ? `, past the ${c.otherCap}` : ""}</span></> : null}</div> : null}
|
||||
</td>
|
||||
<td><span className={st.inactive ? "tag tag-outline" : over ? "tag tag-flag" : state === "AT LIMIT" ? "tag tag-outline" : "tag tag-neutral"} title={st.inactive ? undefined : holdWhy(c)}>{st.inactive ? "Inactive" : state}</span></td>
|
||||
{/* Outline, not the flag: four of these on a row of red rectangles would drown
|
||||
the one status on this screen that is somebody's immediate problem. Each tag
|
||||
carries the consequence in its tooltip, because "FTE" on its own says which
|
||||
box is empty and not what is broken while it stays that way.
|
||||
|
||||
The offer is not a tag at all. Boxed like the rest, "Sizes, Staff app" put two
|
||||
jobs of equal weight on a row where only one of them stops the person being
|
||||
issued anything — and this column is the part of the row that actually gets
|
||||
scanned. Named in quiet grey beside them, it still says the offer is
|
||||
outstanding without joining the queue. */}
|
||||
<td>{missing.length
|
||||
? <span style={{ display: "flex", flexWrap: "wrap", gap: 4, alignItems: "center" }}>{missing.map((g) => g.required
|
||||
? <span key={g.key} className="tag tag-outline" title={g.why}>{g.short}</span>
|
||||
: <span key={g.key} style={{ fontSize: 11, color: "var(--color-neutral-600)" }} title={g.why}>{g.short}</span>)}</span>
|
||||
: <span style={{ color: "var(--color-neutral-600)" }}>—</span>}</td>
|
||||
<td style={{ textAlign: "right" }}><Link href={`/app/staff/${st.id}`} className="btn btn-ghost" aria-label={`View ${st.first} ${st.last}`}>View</Link></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* An empty queue is the answer to the question, not a dead end: somebody who has filtered
|
||||
to "No approver" and got nothing back has finished that job, and saying "no staff match"
|
||||
reads as though the filter is broken.
|
||||
|
||||
The staff app is the exception, and it is the one this used to get wrong. It was taken
|
||||
out of the queue on purpose — an account is offered, not owed — so "that list is done"
|
||||
and "every record is finished" put it straight back in, and a coordinator reads a job
|
||||
they were told they did not have. An offer that nobody is waiting on is worth saying in
|
||||
those words instead. */}
|
||||
{rows.length === 0 && <div style={{ padding: "0 var(--space-4)" }}><Empty>{s.staff.length === 0
|
||||
? "No staff on the register yet. Add a staff member, or import a CSV in Settings → Data."
|
||||
: gap === "All" ? "No staff match."
|
||||
: gapCount[gap] === 0
|
||||
? gap === "any" ? "Nothing that has to be set is missing anywhere on the register — every record is finished."
|
||||
: gapSel && !gapSel.required ? gapSel.done ?? "Nobody is waiting on that."
|
||||
: "Nobody on the register is missing that. That list is done."
|
||||
: gapSel && !gapSel.required ? "Nobody in this search is waiting on that — clear the search or the group to see the rest of the list."
|
||||
: "Nobody in this search is missing that — clear the search or the group to see the rest of the list."}</Empty></div>}
|
||||
</div>
|
||||
<div style={{ marginTop: "var(--space-3)", fontSize: 12, color: "var(--color-neutral-700)" }}>{s.staff.length - nInactive} active on register{nDesk > 0 ? `, ${nDesk} on a ward desk` : ", nobody on a ward desk"}. Click a name for the full profile.</div>
|
||||
{add && <StaffDialog staff={null} onClose={() => setAdd(false)} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
"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 { bcBound, countsAsIssued, fmtDate, forecastFor, forecastLabel, fyStart, garmentGroups, genderLabel, issueCost, itemOrderHistory, key, money, onOrderMap, onhand, reorderAt, staffName, statusTag, supplierCodeOf, touched } from "@/lib/compute";
|
||||
|
||||
export default function ProductPage() {
|
||||
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 code printed on a garment label
|
||||
// is as often read out and typed as it is scanned, and a code bound to the wrong garment can only
|
||||
// be corrected from the garment it belongs to — so every size takes one by hand.
|
||||
const [codes, setCodes] = useState<Record<number, string>>({});
|
||||
const [rowErr, setRowErr] = useState<{ si: number; msg: string } | null>(null);
|
||||
// Arriving from "Create and scan sizes" — open the scanner straight away.
|
||||
useEffect(() => { if (new URLSearchParams(window.location.search).get("scan") === "1") { setScanSizes(true); window.history.replaceState(null, "", window.location.pathname); } }, []);
|
||||
|
||||
const d = useMemo(() => {
|
||||
if (!it) return null;
|
||||
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) }; });
|
||||
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 oo = onOrderMap(s, byId).byKey; let onOrder = 0; it.sizes.forEach((_sz, si) => { onOrder += oo[key(it.id, si)] || 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, in either direction.
|
||||
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><PageHead eyebrow="Inventory" title="Item not found" /><Empty><Link href="/app/stock">← Stock on Hand</Link></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);
|
||||
}
|
||||
// Discontinuing a product and nudging a par level are both refusable, and both used to be fired
|
||||
// and forgotten — the row simply didn't change and nothing said why.
|
||||
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, and the note under this field must not say it does: a
|
||||
// 6XL taken as handled is a rack of garments no scanner can see and no stocktake can count. The
|
||||
// code stays a decision, because most sizes arrive carrying the supplier's own number and
|
||||
// stamping ours over it would cut the tie to their delivery notes.
|
||||
async function addSize() {
|
||||
if (sizeInvalid) return;
|
||||
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: catalog.removeSize names exactly what is recorded
|
||||
// against that size, which is no use read six rows away at the top of the page.
|
||||
function rowFail(si: number, msg: string) { setErr(""); setRowErr({ si, msg }); }
|
||||
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, so an empty field unbinds rather than complains.
|
||||
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 can clear, and moving the code across
|
||||
// is usually the whole point of typing it here. The generated-code refusal stands however hard
|
||||
// you push, so it must never be offered as something to push through.
|
||||
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;
|
||||
}
|
||||
// Hold what was saved rather than dropping back to the snapshot, which refreshes a beat later.
|
||||
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]: "" }));
|
||||
}
|
||||
/* A barcode for garments that arrived without one.
|
||||
*
|
||||
* Whole ranges turn up unlabelled — the cafe shirts came with nothing on any of fifteen sizes —
|
||||
* and a garment nobody can scan is invisible to a count and cannot be issued by scanning. This
|
||||
* mints ThreadCount's own number for the sizes that have none. It is not destructive and it only
|
||||
* ever fills gaps, but it does put a number on every garment on that rack, so the count goes into
|
||||
* the question first.
|
||||
*
|
||||
* The button stays live even when this page can see no gaps left: the codes it is reading came
|
||||
* from a snapshot and the server is the thing that actually knows, so its refusal is the honest
|
||||
* answer to show. */
|
||||
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; }
|
||||
// Drafts go for the same reason they go when the scanner closes: a box still holding what was
|
||||
// typed earlier would sit over the code just minted for that size.
|
||||
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(", ")}. Print labels to get them onto the garments.`);
|
||||
}
|
||||
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(", ")}. Print labels to get it onto the garments.`);
|
||||
}
|
||||
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, so drafts kept against the old positions
|
||||
// would now sit on the wrong sizes.
|
||||
setCodes({});
|
||||
}
|
||||
/* How much paper Print labels is about to produce. The sheet prints one label per garment ON HAND
|
||||
across the sizes that carry a code — six size-14s in the cupboard means six size-14 labels,
|
||||
because each of those six shirts is getting one stuck on it — and it opens with the print
|
||||
dialog already up. On a shared printer that is the wrong moment to learn the number, so it goes
|
||||
on the button and into the question. */
|
||||
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;
|
||||
// A new tab: this screen is the rack somebody is working down. With nothing to print the sheet
|
||||
// says which of the two reasons it is, which is more use than a refusal from here.
|
||||
window.open(`/print/labels?item=${encodeURIComponent(it!.id)}`, "_blank", "noopener");
|
||||
}
|
||||
|
||||
// An editable code needs room the read-only text didn't; an issuer still sees the plain list.
|
||||
const sizeCols = isAdmin ? "64px minmax(190px, 1fr) 96px 74px 112px 168px" : "70px 110px 1fr 90px 130px 110px";
|
||||
const sizeMin = isAdmin ? 760 : 560;
|
||||
|
||||
return (
|
||||
<section>
|
||||
{/* The band is the screen's own head, so the way back out lives inside it — a link stranded
|
||||
above the ink would leave a white shelf across the top of the page. */}
|
||||
<header className="tc-pagehead">
|
||||
<div>
|
||||
<Link href="/app/stock" className="btn btn-ghost" style={{ padding: "0 0 var(--space-1)", minHeight: 0 }}>← Stock on Hand</Link>
|
||||
<div className="eyebrow">Inventory · {it.sku || "No SKU"}</div>
|
||||
<h1 className="h1">{it.item}</h1>
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-2)", flexWrap: "wrap" }}>
|
||||
{tagged.length ? tagged.map((g) => <span key={g} className="tag tag-outline">{g}</span>) : <span className="tag tag-outline">All groups</span>}
|
||||
{it.gender !== "Unisex" && <span className="tag tag-outline">{genderLabel(it.gender)}</span>}
|
||||
<span className="tag tag-neutral">{it.supplier || "No supplier"}</span>
|
||||
{it.archived && <span className="tag tag-accent">Discontinued</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "var(--space-4)", alignItems: "flex-end", flexWrap: "wrap" }}>
|
||||
<div style={{ fontSize: 13, color: "var(--color-neutral-700)", textAlign: "right" }}>
|
||||
<div className="tc-figure">{d.tot}</div>
|
||||
<div className="tc-meta">on hand · {money(d.value)} at {money(it.cost)} each</div>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", flexWrap: "wrap" }}>
|
||||
{!edit ? (
|
||||
<>
|
||||
{!it.archived ? <button className="btn btn-ghost" onClick={() => act("catalog.update", { id: it.id, archived: true })}>Discontinue</button> : <button className="btn btn-secondary" onClick={() => act("catalog.update", { id: it.id, archived: false })}>Reinstate</button>}
|
||||
<button className="btn btn-ghost" onClick={() => setDup(true)}>Duplicate</button>
|
||||
<button className="btn btn-ghost" title="Give each size with no barcode one of our own" onClick={generateAll}>Generate barcodes</button>
|
||||
<button className="btn btn-ghost" title="One label per garment on hand, across the sizes that carry a barcode." onClick={printLabels}>Print labels{labels ? ` (${labels})` : ""}</button>
|
||||
<button className="btn btn-secondary" onClick={() => setScanSizes(true)}>Scan sizes</button>
|
||||
<button className="btn btn-primary" onClick={startEdit}>Edit product</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn btn-ghost" onClick={() => setEdit(false)}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={save} disabled={invalid}>Save changes</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<ErrorLine msg={err} />
|
||||
<Notice msg={msg} />
|
||||
<div className="tc-grid" style={{ display: "grid", gridTemplateColumns: "7fr 5fr", gap: "var(--space-8)", marginTop: "var(--space-6)" }}>
|
||||
<div>
|
||||
{edit && (
|
||||
<div className="tc-panel" style={{ marginBottom: "var(--space-6)" }}>
|
||||
<div className="tc-panel-head">Edit details</div>
|
||||
<div className="tc-panel-body tc-grid" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-3)" }}>
|
||||
<Field label="Item name" style={{ gridColumn: "1 / -1" }} error={!f.item.trim() ? "Needed — the product 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>
|
||||
<div className="tc-panel-body" style={{ paddingTop: 0, display: "flex", gap: "var(--space-2)", alignItems: "flex-end", flexWrap: "wrap" }}>
|
||||
<Field label="Add a size" style={{ flex: 1, minWidth: 160 }} error={newSize.trim() && it.sizes.map(String).includes(newSize.trim()) ? "That size is already on this product." : 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 className="btn btn-secondary" onClick={addSize} disabled={sizeInvalid}>Add size</button>
|
||||
</div>
|
||||
<div className="tc-panel-foot" style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>A new size starts with no barcode — scan, type or generate one on its row below.</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="tc-panel">
|
||||
<div className="tc-panel-head">
|
||||
<div>Sizes</div>
|
||||
<div className="tc-panel-aside">{labelled} of {it.sizes.length} carry a barcode</div>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<div style={{ display: "grid", gridTemplateColumns: sizeCols, gap: "var(--space-2)", fontSize: 11, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--color-neutral-600)", borderBottom: "2px solid var(--color-text)", padding: "var(--space-3) var(--space-4) var(--space-1)", minWidth: sizeMin }}>
|
||||
<div>Size</div><div>Barcode</div><div></div><div style={{ textAlign: "right" }}>On hand</div><div style={{ textAlign: "right" }}>Reorder at</div><div></div>
|
||||
</div>
|
||||
{d.sizes.map((v) => {
|
||||
const status = v.oh <= 0 ? (v.touched ? "OUT" : "—") : v.oh <= v.ro ? "REORDER" : "OK";
|
||||
// Whatever is in the box, falling back to the bound code. Nothing reformats what was
|
||||
// typed: Code 128 labels carry letters as well as digits.
|
||||
const draft = codes[v.si] ?? v.barcode;
|
||||
const dirty = draft.trim() !== v.barcode;
|
||||
return (
|
||||
<Fragment key={v.si}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: sizeCols, gap: "var(--space-2)", alignItems: "center", padding: "var(--space-1) var(--space-4)", borderBottom: "1px solid var(--color-divider)", fontSize: 13, minWidth: sizeMin }}>
|
||||
<div style={{ fontWeight: 600 }}>{v.size}</div>
|
||||
{isAdmin ? (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<input className="input" style={{ flex: 1, minWidth: 0, minHeight: 28, padding: "2px 6px", fontSize: 12 }} value={draft} maxLength={64} inputMode="numeric" placeholder="Not bound"
|
||||
aria-label={`Barcode for size ${v.size}`} title="The code printed on the label — type it or scan it. 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 className="btn btn-secondary" style={{ minHeight: 26, padding: "2px 8px", fontSize: 12 }} aria-label={`Save the barcode for size ${v.size}`} onClick={() => saveCode(v.si, v.size, v.barcode)}>Save</button>
|
||||
: v.barcode
|
||||
? <button className="btn btn-ghost btn-icon" style={{ fontSize: 12 }} title="Unbind this barcode (falls back to the generated code)" 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 className="btn btn-ghost" style={{ minHeight: 26, padding: "2px 8px", fontSize: 12 }} title="Give this size a barcode of our own" aria-label={`Generate a barcode for size ${v.size}`} onClick={() => generateOne(v.si, v.size)}>Generate</button>}
|
||||
</div>
|
||||
) : (
|
||||
/* 600, not 400: 400 is the muted colour for the dark rail and reads at under
|
||||
2:1 on paper, which left "Not bound" all but invisible on the one screen
|
||||
where an unbound size is the thing to notice. */
|
||||
<div style={{ fontSize: 12, color: "var(--color-neutral-600)" }} title={v.barcode ? "Supplier barcode scanned in against this size" : "No supplier barcode bound yet"}>{v.barcode || "Not bound"}</div>
|
||||
)}
|
||||
<div><span className={status === "OK" ? "tag tag-neutral" : status === "—" ? "tag tag-outline" : "tag tag-flag"}>{status}</span></div>
|
||||
<div style={{ textAlign: "right", fontWeight: 700, color: status === "OK" || status === "—" ? "var(--color-text)" : "var(--color-accent-700)" }}>{v.oh}</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "var(--space-1)" }}>
|
||||
{isAdmin && <button className="btn btn-ghost" style={{ padding: "0 6px", minHeight: 22 }} aria-label={`Lower the reorder level for size ${v.size}`} onClick={() => act("stock.reorder", { itemId: it.id, si: v.si, reorder: Math.max(0, v.ro - 1) })}>−</button>}
|
||||
<span style={{ width: 20, textAlign: "center" }}>{v.ro}</span>
|
||||
{isAdmin && <button className="btn btn-ghost" style={{ padding: "0 6px", minHeight: 22 }} aria-label={`Raise the reorder level for size ${v.size}`} onClick={() => act("stock.reorder", { itemId: it.id, si: v.si, reorder: v.ro + 1 })}>+</button>}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "var(--space-1)", justifyContent: "flex-end" }}>
|
||||
<button className="btn btn-ghost" style={{ minHeight: 26, padding: "2px 8px" }} aria-label={`Adjust the quantity of size ${v.size}`} onClick={() => setAdjust({ itemId: it.id, si: v.si })}>Adjust</button>
|
||||
{isAdmin && <button className="btn btn-ghost" style={{ minHeight: 26, padding: "2px 8px" }} aria-label={`Remove size ${v.size} from this product`} onClick={() => removeSize(v.si, v.size)}>Remove</button>}
|
||||
</div>
|
||||
</div>
|
||||
{rowErr?.si === v.si && <div style={{ padding: "0 var(--space-4) var(--space-2)", borderBottom: "1px solid var(--color-divider)", minWidth: sizeMin }}><ErrorLine msg={rowErr.msg} /></div>}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Ordering: the supplier's own code for each size — what the order list prints and what gets
|
||||
keyed into the supplier's site — beside what the last 13 weeks of issues say the reorder
|
||||
level should be. "Use" writes the suggestion as the level. Usage is the whole history the
|
||||
facility holds; the window is 13 weeks, or 26 when nothing moved in 13. */}
|
||||
<div className="tc-panel" style={{ marginTop: "var(--space-6)" }}>
|
||||
<div className="tc-panel-head"><span>Ordering</span><span className="tc-panel-aside">{it.supplier || "no supplier"}{(() => { const sp = s.supplierDir.find((x) => x.name === it.supplier); return sp?.lead ? ` · ${sp.lead}-day lead` : ""; })()}</span></div>
|
||||
<div className="table-wrap">
|
||||
<div style={{ display: "grid", gridTemplateColumns: "64px 200px 1fr 120px", gap: "var(--space-2)", fontSize: 11, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--color-neutral-600)", borderBottom: "2px solid var(--color-text)", padding: "var(--space-3) var(--space-4) var(--space-1)", minWidth: 620 }}>
|
||||
<div>Size</div><div>Supplier code</div><div>Usage · suggested reorder</div><div style={{ textAlign: "right" }}>Reorder at</div>
|
||||
</div>
|
||||
{d.sizes.map((v) => {
|
||||
const f = forecastFor(s, L, byId, v.key);
|
||||
const code = supplierCodeOf(s, v.key);
|
||||
return (
|
||||
<div key={"ord" + v.si} style={{ display: "grid", gridTemplateColumns: "64px 200px 1fr 120px", gap: "var(--space-2)", alignItems: "center", padding: "var(--space-1) var(--space-4)", borderBottom: "1px solid var(--color-divider)", fontSize: 13, minWidth: 620 }}>
|
||||
<div style={{ fontWeight: 600 }}>{v.size}</div>
|
||||
<div>{isAdmin
|
||||
? <input className="input" style={{ minHeight: 28, padding: "2px 6px", fontSize: 12, fontFamily: "var(--font-mono, ui-monospace, monospace)" }} 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 style={{ fontFamily: "var(--font-mono, ui-monospace, monospace)" }}>{code || "—"}</span>}</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-2)", flexWrap: "wrap" }}>
|
||||
<span>{f.suggestedReorder !== null ? <><b>{`Suggested ${f.suggestedReorder}`}</b> · {forecastLabel(f)}</> : forecastLabel(f)}</span>
|
||||
{f.runsOutBeforeDelivery && <span className="tag tag-flag" title="At the current rate the shelf runs out before a delivery placed today would arrive">runs out before delivery</span>}
|
||||
{isAdmin && f.suggestedReorder !== null && f.suggestedReorder !== v.ro && <button className="btn btn-ghost" style={{ minHeight: 24, padding: "0 8px" }} aria-label={`Set the reorder level for size ${v.size} to ${f.suggestedReorder}`} onClick={() => act("stock.reorder", { itemId: it.id, si: v.si, reorder: f.suggestedReorder })}>Use</button>}
|
||||
</div>
|
||||
<div style={{ textAlign: "right", fontWeight: 700 }}>{v.ro}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="tc-panel-foot" style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>Suggested = weekly issues over the last 13 weeks (26 if none) × (lead time + 2 weeks). Usage counts every issue on record.</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const hist = itemOrderHistory(s, it.id);
|
||||
const prices = s.costs.filter((c) => c.itemId === it.id).sort((a, b) => b.at.localeCompare(a.at));
|
||||
return (
|
||||
<>
|
||||
<div className="tc-panel" style={{ marginTop: "var(--space-6)" }}>
|
||||
<div className="tc-panel-head"><span>Orders</span><span className="tc-panel-aside">{hist.length} line{hist.length === 1 ? "" : "s"}</span></div>
|
||||
{hist.length === 0 ? <Empty pad={4}>Never ordered.</Empty> : (
|
||||
<div className="table-wrap">
|
||||
<div style={{ display: "grid", gridTemplateColumns: "92px 130px 1fr 56px 56px 90px 110px 100px 90px", gap: "var(--space-2)", fontSize: 11, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--color-neutral-600)", borderBottom: "2px solid var(--color-text)", padding: "var(--space-3) var(--space-4) var(--space-1)", minWidth: 820 }}>
|
||||
<div>Date</div><div>Order</div><div>Supplier</div><div>Size</div><div style={{ textAlign: "right" }}>Qty</div><div style={{ textAlign: "right" }}>Unit then</div><div>Supplier ref</div><div>Invoice</div><div>Status</div>
|
||||
</div>
|
||||
{hist.map((h, i) => (
|
||||
<div key={i} style={{ display: "grid", gridTemplateColumns: "92px 130px 1fr 56px 56px 90px 110px 100px 90px", gap: "var(--space-2)", alignItems: "center", padding: "var(--space-1) var(--space-4)", borderBottom: "1px solid var(--color-divider)", fontSize: 13, minWidth: 820 }}>
|
||||
<div>{fmtDate(h.date)}</div><div><Link href={`/app/orders/${h.orderId}`}>{h.code}</Link></div><div>{h.supplier || "—"}</div><div>{h.size}</div><div style={{ textAlign: "right", fontWeight: 700 }}>{h.qty}</div><div style={{ textAlign: "right" }}>{h.unit ? money(h.unit) : "—"}</div><div>{h.ref || "—"}</div><div>{h.invoice || "—"}</div><div><span className={statusTag(h.status)}>{h.status}</span></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="tc-panel" style={{ marginTop: "var(--space-6)" }}>
|
||||
<div className="tc-panel-head"><span>Price history</span><span className="tc-panel-aside">now {money(it.cost)}</span></div>
|
||||
{prices.length === 0 ? <Empty pad={4}>No price changes recorded.</Empty> : (
|
||||
<div className="tc-panel-list">
|
||||
{prices.map((c) => (
|
||||
<div key={c.id} className="tc-row"><div className="tc-row-main"><div className="tc-row-name">{fmtDate(c.at.slice(0, 10))}{c.byName ? ` · ${c.byName}` : ""}</div></div><div className="tc-row-fig">{c.previous !== null ? `${money(c.previous)} → ` : ""}{money(c.cost)}</div></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{it.notes && !edit && (
|
||||
<div className="tc-panel" style={{ marginTop: "var(--space-6)" }}>
|
||||
<div className="tc-panel-head">Notes</div>
|
||||
<div className="tc-panel-body" style={{ fontSize: 13, lineHeight: 1.6, color: "var(--color-neutral-800)", whiteSpace: "pre-wrap" }}>{it.notes}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="tc-panel">
|
||||
<div className="tc-panel-head">This financial year</div>
|
||||
<div className="tc-panel-list">
|
||||
{[["Issued this FY", `${d.fyIssued} items`], ["FY spend (at issue price)", money(d.fySpend)], ["On open orders", `${d.onOrder} items`], ["Sizes carried", String(it.sizes.length)]].map(([k, v]) => (
|
||||
<div key={k} className="tc-row">
|
||||
<div className="tc-row-main"><div className="tc-row-name" style={{ fontWeight: 500 }}>{k}</div></div>
|
||||
<div className="tc-row-fig">{v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="tc-panel" style={{ marginTop: "var(--space-6)" }}>
|
||||
<div className="tc-panel-head">
|
||||
<div>Recent movement</div>
|
||||
{d.hist.length > 0 && <div className="tc-panel-aside">newest first</div>}
|
||||
</div>
|
||||
{d.hist.length === 0 && <div className="tc-panel-body"><Empty pad={3}>No movement recorded yet.</Empty></div>}
|
||||
<div className="tc-panel-list">
|
||||
{d.hist.map((h, i) => (
|
||||
<div key={i} className="tc-row" style={{ fontSize: 13 }}>
|
||||
<span className="tc-row-meta" style={{ flex: "none", width: 84 }}>{fmtDate(h.date)}</span>
|
||||
<span className={h.cls} style={{ flex: "none" }}>{h.kind}</span>
|
||||
<span className="tc-row-main" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{h.desc}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{adjust && <AdjustDialog init={adjust} onClose={() => setAdjust(null)} />}
|
||||
{/* Drafts go when the scanner closes: it binds codes to these same sizes, and a box still
|
||||
holding what was typed earlier would sit over the code that was just scanned in. */}
|
||||
{scanSizes && <ScanVariantsDialog item={it} onClose={() => { setScanSizes(false); setCodes({}); }} />}
|
||||
{dup && <DuplicateItemDialog item={it} onClose={() => setDup(false)} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { PageHead, Empty, InvTabs, KpiStrip, Seg, Notice } from "@/components/ui";
|
||||
import { AdjustDialog, ItemDialog, BindDialog, ScanAddDialog } from "@/components/dialogs";
|
||||
import Camera from "@/components/Camera";
|
||||
import { ALL_GROUPS, bcBound, bcFor, bcParse, csvOf, fmtDate, genderLabel, groupKey, inBucket, key, lastCountMap, locTree, money, onOrderMap, onhand, reorderAt, touched, type Item, plOf } from "@/lib/compute";
|
||||
import { downloadCsv } from "@/lib/print";
|
||||
|
||||
type SortKey = "" | "name" | "value" | "onorder" | "onhand";
|
||||
const FILTERS = ["All", "In stock", "Flagged", "Out", "No barcode"] as const;
|
||||
|
||||
export default function StockPage() {
|
||||
const { s, isAdmin, mutate } = useSnap();
|
||||
const { L, byId, variants } = useDerived();
|
||||
const [q, setQ] = useState("");
|
||||
const [group, setGroup] = useState("All groups");
|
||||
const [supplier, setSupplier] = useState("All suppliers");
|
||||
const [filter, setFilter] = useState<(typeof FILTERS)[number]>("All");
|
||||
const [sortKey, setSortKey] = useState<SortKey>("");
|
||||
const [sortDir, setSortDir] = useState(1);
|
||||
const [expand, setExpand] = useState<string | null>(null);
|
||||
const [adjust, setAdjust] = useState<{ itemId: string; si: number } | null | false>(false);
|
||||
const [newItem, setNewItem] = useState(false);
|
||||
const [scanAdd, setScanAdd] = useState(false);
|
||||
const [cam, setCam] = useState(false);
|
||||
// SCAN button from the mobile bar lands here as ?scan=1 (garment lookup).
|
||||
useEffect(() => { if (new URLSearchParams(window.location.search).get("scan") === "1") { setCam(true); window.history.replaceState(null, "", "/app/stock"); } const h = () => setCam(true); window.addEventListener("tc-scan", h); return () => window.removeEventListener("tc-scan", h); }, []);
|
||||
const [bind, setBind] = useState("");
|
||||
const [limit, setLimit] = useState(40);
|
||||
const [msg, setMsg] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [sel, setSel] = useState<Record<string, boolean>>({});
|
||||
const [bulkRo, setBulkRo] = useState("");
|
||||
const [bulkPrice, setBulkPrice] = useState("");
|
||||
|
||||
const onOrder = useMemo(() => onOrderMap(s, byId), [s, byId]);
|
||||
const lastCount = useMemo(() => lastCountMap(s), [s]);
|
||||
const supplierOpts = ["All suppliers", ...new Set(s.catalog.map((it) => it.supplier).filter(Boolean))];
|
||||
|
||||
const kpi = useMemo(() => {
|
||||
// Out and below-reorder stay on the live catalogue: nobody reorders a garment that has been
|
||||
// retired, and flagging one would push it into Order flagged.
|
||||
let out = 0, below = 0;
|
||||
for (const v of variants) { if (!touched(s, L, v.key)) continue; const oh = onhand(s, L, v.key); if (oh <= 0) out++; if (oh <= reorderAt(s, v.key)) below++; }
|
||||
// Value walks the whole catalogue, discontinued lines included, because they are still garments
|
||||
// on a shelf. Deleting a product with stock on hand discontinues it instead (records stay
|
||||
// intact), so counting only live items made 40 retired tunics — $1,200 — vanish from this figure
|
||||
// the moment somebody pressed Delete, while the CSV below and Reports → Valuation both kept
|
||||
// counting them. Three surfaces, one shelf: they have to agree.
|
||||
let value = 0;
|
||||
for (const it of s.catalog) it.sizes.forEach((_sz, si) => { const oh = onhand(s, L, key(it.id, si)); if (oh > 0) value += oh * it.cost; });
|
||||
return { out, below, value };
|
||||
}, [s, L, variants]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const ql = q.trim().toLowerCase();
|
||||
const out: { it: Item; sizes: { si: number; size: string; key: string; oh: number; ro: number; touched: boolean; barcode: string; bound: string; onOrd: number; pl: number }[]; tot: number; flagged: number; val: number; onOrd: number }[] = [];
|
||||
for (const it of s.catalog) {
|
||||
if (it.archived && filter !== "All") continue;
|
||||
if (!inBucket(it, group)) continue;
|
||||
if (supplier !== "All suppliers" && it.supplier !== supplier) continue;
|
||||
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: bcFor(s, it, si), bound: bcBound(s, it, si), onOrd: onOrder.byKey[k] || 0, pl: plOf(s, k) }; });
|
||||
if (ql && !(it.item.toLowerCase().includes(ql) || it.sku.toLowerCase().includes(ql) || sizes.some((v) => v.barcode.includes(ql) || v.size.toLowerCase() === ql))) continue;
|
||||
const tot = sizes.reduce((t, v) => t + v.oh, 0);
|
||||
const flagged = sizes.filter((v) => v.touched && v.oh <= v.ro).length;
|
||||
if (filter === "In stock" && tot <= 0) continue;
|
||||
if (filter === "Flagged" && flagged === 0) continue;
|
||||
if (filter === "Out" && !sizes.some((v) => v.touched && v.oh <= 0)) continue;
|
||||
// Sizes still waiting on a supplier barcode — the work list for Scan sizes.
|
||||
if (filter === "No barcode" && !sizes.some((v) => !v.bound)) continue;
|
||||
out.push({ it, sizes, tot, flagged, val: sizes.reduce((t, v) => t + Math.max(0, v.oh) * it.cost, 0), onOrd: sizes.reduce((t, v) => t + v.onOrd, 0) });
|
||||
}
|
||||
if (sortKey) out.sort((a, b) => (sortKey === "name" ? a.it.item.localeCompare(b.it.item) : sortKey === "onhand" ? a.tot - b.tot : sortKey === "value" ? a.val - b.val : a.onOrd - b.onOrd) * sortDir);
|
||||
return out;
|
||||
}, [s, L, q, group, supplier, filter, sortKey, sortDir, onOrder]);
|
||||
|
||||
/* A real button, so the list can be sorted from the keyboard. The arrow glyph is decorative — the
|
||||
direction is said in the accessible name instead, because "▲" reads as nothing useful. This is a
|
||||
CSS grid rather than a <table>, so there is no columnheader for aria-sort to sit on. */
|
||||
const head = (k: SortKey, t: string, right = false) => {
|
||||
const on = sortKey === k;
|
||||
return (
|
||||
<button type="button"
|
||||
aria-label={on ? `${t} — sorted ${sortDir > 0 ? "ascending" : "descending"}, sort the other way` : `Sort by ${t}`}
|
||||
onClick={() => { if (on) setSortDir(-sortDir); else { setSortKey(k); setSortDir(1); } }}
|
||||
style={{ font: "inherit", color: "inherit", background: "none", border: 0, padding: 0, cursor: "pointer", textAlign: right ? "right" : "left", letterSpacing: "inherit", textTransform: "inherit", fontWeight: "inherit" }}>
|
||||
{t} <span aria-hidden="true">{on ? (sortDir > 0 ? "▲" : "▼") : ""}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
function camHit(raw: string) {
|
||||
const p = bcParse(s, raw);
|
||||
if (!p) { setCam(false); setBind(raw); return; }
|
||||
setCam(false); setQ(""); setGroup("All groups"); setSupplier("All suppliers"); setFilter("All"); setExpand(p.itemId);
|
||||
setTimeout(() => document.getElementById("item-" + p.itemId)?.scrollIntoView({ block: "center" }), 50);
|
||||
}
|
||||
/* Placing a size on a shelf and nudging a par level used to fire and forget. A refusal left the
|
||||
<select> showing the shelf the coordinator picked until the next snapshot quietly snapped it
|
||||
back, with nothing said — the worst of both, because the screen agreed with them for a while. */
|
||||
async function act(op: string, payload: unknown) { const r = await mutate(op, payload); setMsg(r.ok ? "" : r.error); }
|
||||
async function orderFlagged() {
|
||||
setBusy(true);
|
||||
const r = await mutate<{ added: number }>("stock.orderFlagged", {});
|
||||
setBusy(false);
|
||||
setMsg(!r.ok ? r.error : r.result.added ? `${r.result.added} line${r.result.added === 1 ? "" : "s"} added to draft supplier order(s) — review them under Ordering.` : "Everything flagged already has enough on order — nothing to add.");
|
||||
}
|
||||
function exportCsv() {
|
||||
const rows: (string | number)[][] = [];
|
||||
for (const it of s.catalog) it.sizes.forEach((sz, si) => { const k = key(it.id, si); const oh = onhand(s, L, k); rows.push([it.item, genderLabel(it.gender), it.sku, it.supplier, String(sz), bcBound(s, it, si), oh, reorderAt(s, k), onOrder.byKey[k] || 0, lastCount[k] || "", it.cost, (Math.max(0, oh) * it.cost).toFixed(2)]); });
|
||||
downloadCsv(`threadcount-stock-${s.today}.csv`, csvOf(["Item", "Gender", "SKU", "Supplier", "Size", "Barcode", "On hand", "Reorder at", "On order", "Last counted", "Unit cost", "Value"], rows));
|
||||
}
|
||||
const cols = (isAdmin ? "18px " : "") + "16px minmax(0,1fr) 110px 90px 140px 80px";
|
||||
const locOpts = locTree(s).map(({ loc, depth }) => ({ id: loc.id, name: "\u00a0".repeat(depth * 2) + loc.name }));
|
||||
const sizeCols = "70px 110px 1fr 80px 76px 80px 110px 130px 120px 100px";
|
||||
|
||||
const selIds = Object.keys(sel).filter((id) => sel[id] && byId[id]);
|
||||
const shownIds = items.slice(0, limit).map((x) => x.it.id);
|
||||
const allSel = shownIds.length > 0 && shownIds.every((id) => sel[id]);
|
||||
const selectAll = () => setSel((m) => { const n = { ...m }; for (const id of shownIds) n[id] = !allSel; return n; });
|
||||
// The facility's own staff groups, not whatever garments happen to be tagged with: a group nothing
|
||||
// is tagged for yet is exactly the one somebody is about to move garments into.
|
||||
const groupOpts = [ALL_GROUPS, ...s.settings.staffGroups.filter((g) => groupKey(g) !== "all" && groupKey(g) !== groupKey(ALL_GROUPS))];
|
||||
const bp = bulkPrice.trim();
|
||||
const priceOk = /^[+-]\d+(\.\d+)?%$/.test(bp) || /^\$?\d+(\.\d+)?$/.test(bp);
|
||||
const sm: React.CSSProperties = { minHeight: 28, padding: "2px 10px" };
|
||||
const vr: React.CSSProperties = { width: 1, height: 22, background: "var(--color-divider)" };
|
||||
const cb: React.CSSProperties = { width: 14, height: 14, accentColor: "var(--color-accent)", cursor: "pointer", margin: 0 };
|
||||
async function bulk(action: string, value?: string, extra?: Record<string, unknown>) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await mutate<{ message: string }>("catalog.bulk", { ids: selIds, action, value, ...extra });
|
||||
setMsg(r.ok ? r.result.message : r.error);
|
||||
if (r.ok) { setSel({}); setBulkRo(""); setBulkPrice(""); }
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<PageHead eyebrow="Inventory" title="Stock on Hand" below={<InvTabs active="stock" />}>
|
||||
<div style={{ fontSize: 13, color: "var(--color-neutral-700)", textAlign: "right" }}>
|
||||
<div>{variants.length} variants · {s.catalog.filter((i) => !i.archived).length} items</div>
|
||||
</div>
|
||||
<button className="btn btn-secondary" onClick={() => setAdjust(null)}>Adjust quantity</button>
|
||||
{isAdmin && <button className="btn btn-secondary" onClick={() => setScanAdd(true)}>Scan to add</button>}
|
||||
{isAdmin && <button className="btn btn-primary" onClick={() => setNewItem(true)}>Add item</button>}
|
||||
</PageHead>
|
||||
{/* An empty size and a size at its reorder level are the two figures that send somebody to the
|
||||
Ordering screen, so they are the two that can be flagged. The value and the units on order
|
||||
are facts, and a fact never wears the rule. */}
|
||||
<KpiStrip items={[
|
||||
{ val: money(kpi.value), label: "On-hand value", note: "every garment on the shelf, at cost" },
|
||||
{ val: kpi.out, label: "Sizes out of stock", flag: kpi.out > 0, note: kpi.out > 0 ? "nothing to hand over the counter" : "every size has something on the shelf" },
|
||||
{ val: kpi.below, label: "At or below reorder", flag: kpi.below > 0, note: kpi.below > 0 ? "Order flagged drafts the order" : "nothing to reorder" },
|
||||
{ val: onOrder.total, label: "Units on open orders", note: "placed and not yet received" },
|
||||
]} />
|
||||
<div style={{ display: "flex", gap: "var(--space-3)", alignItems: "center", margin: "var(--space-4) 0", flexWrap: "wrap" }}>
|
||||
<input className="input" style={{ width: 240 }} aria-label="Search the catalogue by item, SKU or barcode" placeholder="Search item, SKU or barcode" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<select className="input" style={{ width: 160 }} aria-label="Staff group" value={group} onChange={(e) => setGroup(e.target.value)}>{[ALL_GROUPS, ...s.settings.staffGroups].map((g) => <option key={g}>{g}</option>)}</select>
|
||||
<select className="input" style={{ width: 180 }} aria-label="Supplier" value={supplier} onChange={(e) => setSupplier(e.target.value)}>{supplierOpts.map((g) => <option key={g}>{g}</option>)}</select>
|
||||
<span role="group" aria-label="Which lines to show"><Seg opts={FILTERS} value={filter} onChange={(f) => { setFilter(f); setMsg(""); }} /></span>
|
||||
<button className="btn btn-ghost" onClick={() => setCam(true)}>Camera lookup</button>
|
||||
<div style={{ marginLeft: "auto", display: "flex", gap: "var(--space-2)", alignItems: "center" }}>
|
||||
{kpi.below > 0 && <button className="btn btn-secondary" onClick={orderFlagged} disabled={busy}>Order flagged ({kpi.below})</button>}
|
||||
<button className="btn btn-ghost" onClick={exportCsv}>Export CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && selIds.length > 0 && (
|
||||
<div style={{ display: "flex", gap: "var(--space-2)", alignItems: "center", border: "2px solid var(--color-text)", background: "var(--color-surface)", padding: "var(--space-2) var(--space-3)", marginBottom: "var(--space-3)", flexWrap: "wrap" }}>
|
||||
<b style={{ fontSize: 13, flex: "none" }}>{selIds.length} selected</b>
|
||||
<button className="btn btn-ghost" style={sm} onClick={() => setSel({})}>Clear</button>
|
||||
<span style={vr} />
|
||||
<button className="btn btn-ghost" style={sm} disabled={busy} onClick={() => bulk("discontinue")}>Discontinue</button>
|
||||
<button className="btn btn-ghost" style={sm} disabled={busy} onClick={() => bulk("reinstate")}>Reinstate</button>
|
||||
<button className="btn btn-ghost" style={sm} disabled={busy} onClick={() => { if (confirm(`Delete ${selIds.length} product${selIds.length === 1 ? "" : "s"}? Anything with history or stock on hand is discontinued instead.`)) bulk("delete"); }}>Delete</button>
|
||||
<span style={vr} />
|
||||
<select className="input" style={{ ...sm, width: 160, fontSize: 12 }} aria-label="Change the supplier on the selected products" value="" disabled={busy} onChange={(e) => { if (e.target.value) bulk("supplier", e.target.value); }}>
|
||||
<option value="">Change supplier…</option>{s.settings.suppliers.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
<select className="input" style={{ ...sm, width: 160, fontSize: 12 }} aria-label="Change the staff group on the selected products" value="" disabled={busy} onChange={(e) => { const v = e.target.value; if (v) bulk("group", undefined, { groups: v === ALL_GROUPS ? [] : [v] }); }}>
|
||||
<option value="">Change group…</option>{groupOpts.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
<span style={{ display: "flex", gap: 4, alignItems: "center" }}>
|
||||
<input className="input" style={{ ...sm, width: 64, fontSize: 12 }} aria-label="Reorder level to set on the selected products" placeholder="Level" inputMode="numeric" value={bulkRo} onChange={(e) => setBulkRo(e.target.value.replace(/[^0-9]/g, ""))} />
|
||||
<button className="btn btn-ghost" style={sm} disabled={busy || bulkRo === ""} onClick={() => bulk("reorder", bulkRo)}>Set reorder</button>
|
||||
</span>
|
||||
<span style={{ display: "flex", gap: 4, alignItems: "center" }}>
|
||||
<input className="input" style={{ ...sm, width: 84, fontSize: 12 }} aria-label="New price, or a percentage change, for the selected products" placeholder="$ or +5%" value={bulkPrice} onChange={(e) => setBulkPrice(e.target.value)} />
|
||||
<button className="btn btn-ghost" style={sm} disabled={busy || !priceOk} onClick={() => bulk("price", bulkPrice.trim())}>Apply price</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Notice msg={msg} />
|
||||
<div className="table-wrap">
|
||||
<div style={{ display: "grid", gridTemplateColumns: cols, gap: "var(--space-3)", padding: "0 var(--space-2) var(--space-1)", fontSize: 11, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--color-neutral-600)", fontWeight: 600, minWidth: 640 }}>
|
||||
{isAdmin && <input type="checkbox" checked={allSel} onChange={selectAll} title="Select all shown" aria-label="Select every item shown" style={cb} />}
|
||||
<div></div>{head("name", "Item")}{head("value", "Value", true)}{head("onorder", "On order", true)}<div style={{ textAlign: "right" }}>Status</div>{head("onhand", "On hand", true)}
|
||||
</div>
|
||||
<div style={{ borderTop: "2px solid var(--color-text)", minWidth: 640 }}>
|
||||
{items.length === 0 && <Empty>{s.catalog.length === 0 ? "The catalogue is empty — add an item, or import it in Settings → Data." : "No items match."}</Empty>}
|
||||
{items.slice(0, limit).map((x) => {
|
||||
const open = expand === x.it.id;
|
||||
const inStock = x.sizes.filter((v) => v.oh > 0).length;
|
||||
return (
|
||||
<div key={x.it.id} id={"item-" + x.it.id} style={{ borderBottom: "1px solid var(--color-divider)", opacity: x.it.archived ? 0.55 : 1 }}>
|
||||
{/* The row can't become one button: it already carries a select-all checkbox and a
|
||||
link to the product page, and nesting those inside a button makes both unreachable.
|
||||
The caret is promoted to a real disclosure control instead, so the sizes can be
|
||||
opened from the keyboard; clicking the row stays a mouse convenience. */}
|
||||
<div className="row-hover" onClick={() => setExpand(open ? null : x.it.id)} style={{ display: "grid", gridTemplateColumns: cols, gap: "var(--space-3)", alignItems: "center", padding: "var(--space-3) var(--space-2)", cursor: "pointer" }}>
|
||||
{isAdmin && <input type="checkbox" checked={!!sel[x.it.id]} aria-label={`Select ${x.it.item}`} onClick={(e) => e.stopPropagation()} onChange={() => setSel((m) => ({ ...m, [x.it.id]: !m[x.it.id] }))} style={cb} />}
|
||||
<button type="button" aria-expanded={open} aria-label={`${open ? "Hide" : "Show"} the sizes of ${x.it.item}`} onClick={(e) => { e.stopPropagation(); setExpand(open ? null : x.it.id); }} style={{ font: "inherit", fontSize: 11, color: "var(--color-neutral-600)", background: "none", border: 0, padding: 0, cursor: "pointer", lineHeight: 1 }}>{open ? "▾" : "▸"}</button>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
<Link href={`/app/stock/${x.it.id}`} className="link-name" onClick={(e) => e.stopPropagation()}>{x.it.item}</Link>
|
||||
{x.it.archived && <span className="tag tag-outline" style={{ marginLeft: 8 }}>Discontinued</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>{genderLabel(x.it.gender)} · {x.it.sku || "—"} · {x.it.supplier || "—"} · {money(x.it.cost)} each · {inStock} of {x.sizes.length} sizes in stock{x.sizes.reduce((t, v) => t + v.pl, 0) > 0 ? ` · ${x.sizes.reduce((t, v) => t + v.pl, 0)} pre-loved` : ""}</div>
|
||||
</div>
|
||||
<div style={{ textAlign: "right", fontSize: 13, fontWeight: 600 }}>{money(x.val)}</div>
|
||||
<div style={{ textAlign: "right", fontSize: 13, color: x.onOrd > 0 ? "var(--color-text)" : "var(--color-neutral-600)" }}>{x.onOrd > 0 ? "+" + x.onOrd : "—"}</div>
|
||||
{/* tag-flag carries the mark the plain accent tag does not: on a list this long the
|
||||
colour alone is a row you have to go looking for. */}
|
||||
<div style={{ textAlign: "right" }}>{x.flagged > 0 && <span className="tag tag-flag">{x.flagged} to reorder</span>}</div>
|
||||
<div className="tc-row-fig" style={{ textAlign: "right", color: x.tot > 0 ? "var(--color-text)" : "var(--color-neutral-600)" }}>{x.tot}</div>
|
||||
</div>
|
||||
{open && (
|
||||
<div style={{ padding: "0 var(--space-2) var(--space-3) calc(16px + var(--space-3) + var(--space-2))" }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: sizeCols, gap: "var(--space-2)", fontSize: 11, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--color-neutral-600)", borderBottom: "2px solid var(--color-text)", paddingBottom: "var(--space-1)" }}>
|
||||
<div>Size</div><div>Barcode</div><div></div><div style={{ textAlign: "right" }}>On hand</div><div style={{ textAlign: "right" }}>Pre-loved</div><div style={{ textAlign: "right" }}>On order</div><div style={{ textAlign: "right" }}>Last counted</div><div>Location</div><div style={{ textAlign: "right" }}>Reorder at</div><div></div>
|
||||
</div>
|
||||
{x.sizes.map((v) => {
|
||||
const status = v.oh <= 0 ? (v.touched ? "OUT" : "—") : v.oh <= v.ro ? "REORDER" : "OK";
|
||||
return (
|
||||
<div key={v.si} style={{ display: "grid", gridTemplateColumns: sizeCols, gap: "var(--space-2)", alignItems: "center", padding: "var(--space-1) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13 }}>
|
||||
<div style={{ fontWeight: 600 }}>{v.size}</div>
|
||||
{/* Everything muted on this screen is 600, not 400: 400 is the meta colour
|
||||
for the dark rail and reads at under 2:1 on paper, which turned a
|
||||
"Not bound" and every dash for nothing-on-order into a ghost. */}
|
||||
<div style={{ fontSize: 12, color: "var(--color-neutral-600)" }}>{v.bound || "Not bound"}</div>
|
||||
<div><span className={status === "OK" ? "tag tag-neutral" : status === "—" ? "tag tag-outline" : "tag tag-flag"}>{status}</span></div>
|
||||
<div style={{ textAlign: "right", fontWeight: 700, color: status === "OK" || status === "—" ? "var(--color-text)" : "var(--color-accent-700)" }}>{v.oh}</div>
|
||||
<div style={{ textAlign: "right", color: v.pl > 0 ? "var(--color-text)" : "var(--color-neutral-600)" }}>{v.pl > 0 ? v.pl : "—"}</div>
|
||||
<div style={{ textAlign: "right", color: v.onOrd > 0 ? "var(--color-text)" : "var(--color-neutral-600)" }}>{v.onOrd > 0 ? "+" + v.onOrd : "—"}</div>
|
||||
<div style={{ textAlign: "right", fontSize: 12, color: "var(--color-neutral-600)" }}>{lastCount[v.key] ? fmtDate(lastCount[v.key]) : "never"}</div>
|
||||
<div>
|
||||
{/* Where this size lives. Counting a location on the phone walks its bays too. */}
|
||||
<select className="input" style={{ minHeight: 26, padding: "1px 4px", fontSize: 12 }} value={s.placed[v.key] || ""}
|
||||
aria-label={`Where ${x.it.item} size ${v.size} lives`}
|
||||
onChange={(e) => act("location.place", { itemId: x.it.id, si: v.si, locationId: e.target.value })}
|
||||
disabled={s.locations.length === 0} title={s.locations.length === 0 ? "Add locations in Settings first" : "Where this size lives"}>
|
||||
<option value="">{s.locations.length === 0 ? "—" : "Unplaced"}</option>
|
||||
{locOpts.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "var(--space-1)" }}>
|
||||
{isAdmin && <button className="btn btn-ghost" style={{ padding: "0 6px", minHeight: 22 }} aria-label={`Lower the reorder level for ${x.it.item} size ${v.size}`} onClick={() => act("stock.reorder", { itemId: x.it.id, si: v.si, reorder: Math.max(0, v.ro - 1) })}>−</button>}
|
||||
<span style={{ width: 20, textAlign: "center" }}>{v.ro}</span>
|
||||
{isAdmin && <button className="btn btn-ghost" style={{ padding: "0 6px", minHeight: 22 }} aria-label={`Raise the reorder level for ${x.it.item} size ${v.size}`} onClick={() => act("stock.reorder", { itemId: x.it.id, si: v.si, reorder: v.ro + 1 })}>+</button>}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}><button className="btn btn-ghost" style={{ minHeight: 26, padding: "2px 8px" }} aria-label={`Adjust the quantity of ${x.it.item} size ${v.size}`} onClick={() => setAdjust({ itemId: x.it.id, si: v.si })}>Adjust</button></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{items.length > limit && <button className="btn btn-secondary" style={{ marginTop: "var(--space-3)" }} onClick={() => setLimit(100000)}>Show all {items.length} items</button>}
|
||||
<div style={{ marginTop: "var(--space-3)", fontSize: 12, color: "var(--color-neutral-700)" }}>Showing {Math.min(limit, items.length)} of {items.length} matching items — click a row for its sizes, click the name for the product page. Order flagged drafts a supplier order for everything at or below its reorder level, netting off what's already on order.</div>
|
||||
{adjust !== false && <AdjustDialog init={adjust} onClose={() => setAdjust(false)} />}
|
||||
{newItem && <ItemDialog onClose={() => setNewItem(false)} onSaved={(id) => { setQ(""); setGroup("All groups"); setFilter("All"); setExpand(id); }} />}
|
||||
{scanAdd && <ScanAddDialog onClose={() => setScanAdd(false)} />}
|
||||
{cam && <Camera onHit={camHit} message="" onClose={() => setCam(false)} />}
|
||||
{bind && <BindDialog code={bind} onClose={() => setBind("")} onBound={(itemId) => setExpand(itemId)} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
"use client";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { PageHead, Empty, InvTabs, KpiStrip, Notice, Seg } from "@/components/ui";
|
||||
import { BindDialog } from "@/components/dialogs";
|
||||
import Camera from "@/components/Camera";
|
||||
import { ALL_GROUPS, bcBound, bcFor, bcParse, csvOf, fmtDate, formatInZone, inBucket, key, label, money, onhand, signedInt, signedMoney, plOf, csvEsc } from "@/lib/compute";
|
||||
import { downloadCsv, esc, openPrintWindow } from "@/lib/print";
|
||||
|
||||
/* An in-progress count belongs to the person doing it, not to the browser. On a shared linen-room
|
||||
desktop the old fixed key handed whoever signed in next a half-finished tally with nothing to say
|
||||
whose it was, and they committed it under their own name. Scoping the key to the user id keeps
|
||||
two counters on one machine apart; the saved-at stamp lets the screen say how old a restored
|
||||
tally is, because a count from last Tuesday is not one to carry on with. */
|
||||
const countsKey = (userId: string) => `threadcount-counts:${userId}`;
|
||||
type Saved = { counts: Record<string, string>; savedAt: string };
|
||||
const VIEWS = ["All", "Uncounted"] as const;
|
||||
const MODES = ["Normal", "Blind"] as const;
|
||||
const POOLS = ["Shelf", "Pre-loved"] as const;
|
||||
/* The same four the phone offers on the variance screen (app/m/(app)/count/[id]/variance). They are
|
||||
deliberately the same words: a variance filed at the counter and one filed on the floor end up in
|
||||
the same shrinkage report, and a fifth wording here would fragment it. */
|
||||
const REASONS = ["At laundry", "Condemned", "Missing", "Other"];
|
||||
|
||||
export default function StocktakePage() {
|
||||
const { s, mutate } = useSnap();
|
||||
const { L, byId, variants } = useDerived();
|
||||
const [counts, setCountsRaw] = useState<Record<string, string>>({});
|
||||
const [reason, setReason] = useState<Record<string, string>>({});
|
||||
const [savedAt, setSavedAt] = useState("");
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [scan, setScan] = useState("");
|
||||
const [msg, setMsg] = useState("");
|
||||
const [q, setQ] = useState("");
|
||||
const [group, setGroup] = useState("All groups");
|
||||
const [view, setView] = useState<(typeof VIEWS)[number]>("All");
|
||||
const [mode, setMode] = useState<(typeof MODES)[number]>("Normal");
|
||||
const [cam, setCam] = useState(false);
|
||||
useEffect(() => { const h = () => { setCamMsg(""); setCam(true); }; window.addEventListener("tc-scan", h); return () => window.removeEventListener("tc-scan", h); }, []);
|
||||
const [camMsg, setCamMsg] = useState("");
|
||||
const [bind, setBind] = useState("");
|
||||
const [expand, setExpand] = useState<string | null>(null);
|
||||
const [limit, setLimit] = useState(60);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [pool, setPool] = useState<(typeof POOLS)[number]>("Shelf");
|
||||
const blind = mode === "Blind";
|
||||
const plMode = pool === "Pre-loved";
|
||||
// Pre-loved counts live under a "pl:" prefix so a shelf take and a pool take can be in progress together.
|
||||
const kOf = (k: string) => (plMode ? "pl:" + k : k);
|
||||
const sysOf = (k: string) => (plMode ? plOf(s, k) : onhand(s, L, k));
|
||||
|
||||
// Counts persist across reloads until applied or cleared.
|
||||
const KEY = countsKey(s.session.userId);
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(KEY) || "{}") as Partial<Saved>;
|
||||
if (raw && typeof raw.counts === "object" && raw.counts) { setCountsRaw(raw.counts); setSavedAt(typeof raw.savedAt === "string" ? raw.savedAt : ""); }
|
||||
} catch { /* ignore */ }
|
||||
setLoaded(true);
|
||||
}, [KEY]);
|
||||
const setCounts = (c: Record<string, string>) => {
|
||||
setCountsRaw(c);
|
||||
const at = new Date().toISOString();
|
||||
setSavedAt(at);
|
||||
try { localStorage.setItem(KEY, JSON.stringify({ counts: c, savedAt: at } satisfies Saved)); } catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const has = (k: string) => counts[k] !== undefined && counts[k] !== "";
|
||||
function countPlus(itemId: string, si: number) {
|
||||
const k = kOf(key(itemId, si));
|
||||
const n = (parseInt(counts[k] || "0", 10) || 0) + 1;
|
||||
setCounts({ ...counts, [k]: String(n) });
|
||||
const it = byId[itemId];
|
||||
return `${label(it)} ${it?.sizes[si]} → ${n}`;
|
||||
}
|
||||
function handleScan(raw: string) {
|
||||
const p = bcParse(s, raw);
|
||||
if (!p) { setScan(""); setBind(raw.trim()); return; }
|
||||
setMsg(countPlus(p.itemId, p.si)); setScan("");
|
||||
}
|
||||
function camHit(raw: string) {
|
||||
const p = bcParse(s, raw);
|
||||
if (!p) { setCam(false); setBind(raw.trim()); return; }
|
||||
setCamMsg("Counted " + countPlus(p.itemId, p.si));
|
||||
}
|
||||
|
||||
const tq = q.trim().toLowerCase();
|
||||
const scopeAll = useMemo(() => variants.filter((v) => inBucket(v.item, group)), [variants, group]);
|
||||
const match = useMemo(() => scopeAll.filter((v) => (view !== "Uncounted" || !has(kOf(v.key))) && (!tq || v.item.item.toLowerCase().includes(tq) || v.item.sku.toLowerCase().includes(tq) || v.size.toLowerCase() === tq || bcFor(s, v.item, v.si).includes(tq))),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[scopeAll, view, tq, counts, s, plMode]);
|
||||
// A gap this big or bigger has to say why. The server refuses the whole count otherwise
|
||||
// (lib/ops.ts, stocktake.apply), so without a chooser on this screen a desktop count with a real
|
||||
// discrepancy could never be filed at all — the only ways out were to abandon it or type a
|
||||
// figure nobody had counted.
|
||||
const gate = Math.max(1, s.settings.varianceReason);
|
||||
let counted = 0, variances = 0, netVal = 0;
|
||||
const bigGaps: string[] = [];
|
||||
for (const v of variants) if (has(kOf(v.key))) { counted++; const diff = (parseInt(counts[kOf(v.key)], 10) || 0) - sysOf(v.key); if (diff !== 0) { variances++; netVal += diff * (plMode ? 0 : v.item.cost); if (Math.abs(diff) >= gate) bigGaps.push(kOf(v.key)); } }
|
||||
const needsReason = bigGaps.filter((k) => !reason[k]);
|
||||
const scopeCounted = scopeAll.filter((v) => has(kOf(v.key))).length;
|
||||
const pct = Math.round((scopeCounted / Math.max(scopeAll.length, 1)) * 100);
|
||||
// A line that still owes a reason has to be reachable whatever the filter says. "Uncounted" hides
|
||||
// every counted line, and the row limit hides the tail, so without this Apply could be blocked by
|
||||
// a gap the screen was refusing to show. Those rows are forced to the top instead.
|
||||
const needSet = new Set(needsReason);
|
||||
const forced = needSet.size ? variants.filter((v) => needSet.has(kOf(v.key)) && !match.includes(v)) : [];
|
||||
const rows = [...forced, ...[...match].sort((a, b) => (has(kOf(b.key)) ? 1 : 0) - (has(kOf(a.key)) ? 1 : 0))];
|
||||
|
||||
async function apply() {
|
||||
if (counted === 0 || busy) return;
|
||||
if (needsReason.length) { setMsg(`A gap of ${gate} or more needs a reason — ${needsReason.length} line${needsReason.length === 1 ? "" : "s"} still to go.`); return; }
|
||||
setBusy(true);
|
||||
const lines = variants.filter((v) => has(kOf(v.key))).map((v) => ({ itemId: v.itemId, si: v.si, counted: parseInt(counts[kOf(v.key)], 10) || 0, reason: reason[kOf(v.key)] || "" }));
|
||||
const r = await mutate("stocktake.apply", { lines, mode: plMode ? "preloved" : "shelf" });
|
||||
setBusy(false);
|
||||
if (!r.ok) { setMsg(r.error); return; }
|
||||
const kept: Record<string, string> = {}; for (const k in counts) if (plMode ? !k.startsWith("pl:") : k.startsWith("pl:")) kept[k] = counts[k];
|
||||
const keptReasons: Record<string, string> = {}; for (const k in reason) if (kept[k]) keptReasons[k] = reason[k];
|
||||
setCounts(kept); setReason(keptReasons);
|
||||
setMsg(variances ? (plMode ? "Pre-loved pool updated and filed in stocktake history." : "Adjustments applied and filed in stocktake history.") : "Count filed in stocktake history — everything matched.");
|
||||
}
|
||||
function zeroFill() {
|
||||
const c = { ...counts }; let n = 0;
|
||||
for (const v of match) if (!has(kOf(v.key))) { c[kOf(v.key)] = "0"; n++; }
|
||||
setCounts(c); setMsg(n ? `${n} uncounted line${n === 1 ? "" : "s"} in scope set to zero — review before applying.` : "Everything in scope is already counted.");
|
||||
}
|
||||
function printCountSheet() {
|
||||
const byItem: Record<string, typeof match> = {};
|
||||
for (const v of match) (byItem[v.itemId] = byItem[v.itemId] || []).push(v);
|
||||
let rowsHtml = "";
|
||||
for (const itemId in byItem) {
|
||||
const it = byId[itemId];
|
||||
rowsHtml += `<tr class="ih"><td colspan="4">${esc(label(it))}${it?.sku ? " · " + esc(it.sku) : ""}</td></tr>`;
|
||||
// Only the real supplier code goes on paper — a generated id isn't on the garment, so printing
|
||||
// it would put an unscannable number in front of whoever is counting.
|
||||
for (const v of byItem[itemId]) rowsHtml += `<tr><td>${esc(v.size)}</td><td>${esc(bcBound(s, v.item, v.si))}</td><td class="r">${blind ? "" : sysOf(v.key)}</td><td class="box"></td></tr>`;
|
||||
}
|
||||
openPrintWindow("Count sheet", `<h1>ThreadCount — ${plMode ? "Pre-loved pool" : "Stocktake"} count sheet</h1><div class="meta">${esc(s.settings.facility)} · Scope: ${esc(group)}${tq ? " · filter “" + esc(q) + "”" : ""} · ${match.length} lines · Printed ${esc(fmtDate(s.today))} · Counted by ____________ ${blind ? "· BLIND COUNT" : ""}</div><table><tr><th>Size</th><th>Barcode</th><th class="r">${blind ? "" : "System"}</th><th>Counted</th></tr>${rowsHtml}</table>`, { width: 780, height: 920 });
|
||||
}
|
||||
function historyCsv(h: (typeof s.stocktakes)[number]) {
|
||||
downloadCsv(`threadcount-stocktake-${h.date}.csv`, `Stocktake ${h.date} by ${csvEsc(h.by)}${h.mode === "preloved" ? " · pre-loved pool" : ""}\n` + csvOf(["Item", "Size", "System", "Counted", "Variance", "Unit cost", "Variance value"], h.lines.filter((l) => l.counted !== l.sys).map((l) => { const it = byId[l.itemId]; const diff = l.counted - l.sys; return [label(it), it ? String(it.sizes[l.si]) : "?", l.sys, l.counted, diff, it ? it.cost : "", it ? (diff * it.cost).toFixed(2) : ""]; })));
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<PageHead eyebrow="Inventory" title="Stock Take" below={<InvTabs active="take" />}>
|
||||
<button className="btn btn-ghost" onClick={printCountSheet}>Print count sheet</button>
|
||||
<button className="btn btn-ghost" onClick={() => { setCounts({}); setReason({}); setMsg(""); }}>Clear counts</button>
|
||||
<button className="btn btn-primary" onClick={apply} disabled={counted === 0 || busy || !loaded || needsReason.length > 0} title={needsReason.length ? `${needsReason.length} large gap${needsReason.length === 1 ? "" : "s"} still need a reason` : undefined}>{variances === 0 ? "File count" : "Apply adjustments"}</button>
|
||||
</PageHead>
|
||||
{/* Where the count is up to, at a size that reads from the shelf you are standing at rather
|
||||
than from a line of small print beside the buttons. A blind count drops two of these on
|
||||
purpose: showing a variance would tell the counter the answer. */}
|
||||
<KpiStrip items={[
|
||||
{ val: counted, label: "Lines counted", note: `${scopeCounted} of ${scopeAll.length} in the scope you are filtered to` },
|
||||
...(blind ? [] : [
|
||||
{ val: variances, label: "Variances", flag: variances > 0, note: variances > 0 ? "the shelf disagrees with the ledger" : "every counted line matched" },
|
||||
{ val: signedMoney(netVal), label: "Net value", flag: netVal !== 0, note: plMode ? "the pre-loved pool is carried at nil" : "what applying this count would move" },
|
||||
]),
|
||||
{ val: needsReason.length, label: "Gaps needing a reason", flag: needsReason.length > 0, note: needsReason.length > 0 ? "the count cannot be filed until each has one" : `a gap of ${gate} or more has to say why` },
|
||||
]} />
|
||||
<div style={{ display: "flex", gap: "var(--space-3)", alignItems: "center", margin: "var(--space-4) 0 var(--space-2)", flexWrap: "wrap" }}>
|
||||
<input className="input" style={{ width: 280 }} aria-label="Scan a barcode to add one to its count" placeholder="Scan barcode to count +1, then Enter" value={scan} onChange={(e) => setScan(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && scan.trim()) handleScan(scan); }} autoFocus />
|
||||
<button className="btn btn-ghost" onClick={() => { setCamMsg(""); setCam(true); }}>Camera</button>
|
||||
<input className="input" style={{ width: 180 }} aria-label="Filter the lines to count" placeholder="Filter items…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<select className="input" style={{ width: 160 }} aria-label="Staff group to count" value={group} onChange={(e) => setGroup(e.target.value)}>{[ALL_GROUPS, ...s.settings.staffGroups].map((g) => <option key={g}>{g}</option>)}</select>
|
||||
{/* Three segmented controls in a row: without a name on each group a screen reader reads six
|
||||
bare words ("Shelf, Pre-loved, All, Uncounted…") with nothing to say what they switch. */}
|
||||
<span role="group" aria-label="Which pool to count"><Seg opts={POOLS} value={pool} onChange={setPool} /></span>
|
||||
<span role="group" aria-label="Which lines to show"><Seg opts={VIEWS} value={view} onChange={setView} /></span>
|
||||
<span role="group" aria-label="Counting mode"><Seg opts={MODES} value={mode} onChange={setMode} /></span>
|
||||
<button className="btn btn-ghost" onClick={zeroFill}>Zero uncounted in scope</button>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "var(--space-3)", marginBottom: "var(--space-3)" }}>
|
||||
<div className="bar-track" style={{ flex: 1 }}><div className="bar-fill" style={{ width: pct + "%" }} /></div>
|
||||
{/* The tile above already gives the two numbers; the bar is here to be glanced at, so it
|
||||
says the one thing the numbers do not — how far along this is. */}
|
||||
<span style={{ fontSize: 12, color: "var(--color-neutral-700)", flex: "none" }}>{pct}% of this scope counted</span>
|
||||
</div>
|
||||
{/* Whose tally this is is settled by the key it was saved under; when it was entered is not,
|
||||
and a count picked up three days later is a different thing from one left ten minutes ago. */}
|
||||
{loaded && counted > 0 && savedAt && <div style={{ fontSize: 12, color: "var(--color-neutral-700)", marginBottom: "var(--space-2)" }}>Carrying on your saved tally — last entry {formatInZone(savedAt, s.tz, { day: "numeric", month: "short", hour: "numeric", minute: "2-digit" })}. Clear counts to start again.</div>}
|
||||
<Notice msg={msg} />
|
||||
<div className="tc-panel">
|
||||
<div className="tc-panel-head">
|
||||
<div>{plMode ? "Pre-loved pool" : "Shelf"} — lines to count</div>
|
||||
<div className="tc-panel-aside">{Math.min(limit, rows.length)} of {match.length} in scope{blind ? " · blind" : ""}</div>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr><th style={{ textAlign: "left" }}>Item</th><th style={{ textAlign: "left" }}>Size</th><th style={{ textAlign: "right" }}>{blind ? "" : "System"}</th><th style={{ textAlign: "right", width: 110 }}>Counted</th><th style={{ textAlign: "right" }}>{blind ? "" : "Variance"}</th><th style={{ textAlign: "left", width: 170 }}>Reason</th></tr></thead>
|
||||
<tbody>
|
||||
{rows.slice(0, limit).map((v) => {
|
||||
const ck = kOf(v.key); const sys = sysOf(v.key); const h = has(ck); const varr = h ? (parseInt(counts[ck], 10) || 0) - sys : 0;
|
||||
const big = h && Math.abs(varr) >= gate;
|
||||
const name = `${label(v.item)} size ${v.size}`;
|
||||
return (
|
||||
<tr key={v.key}>
|
||||
<td style={{ fontWeight: 600 }}>{label(v.item)}</td>
|
||||
<td>{v.size}</td>
|
||||
<td style={{ textAlign: "right" }}>{blind ? "" : sys}</td>
|
||||
<td style={{ textAlign: "right" }}><input className="input" style={{ width: 70, textAlign: "right", minHeight: 28, padding: "2px 8px" }} inputMode="numeric" aria-label={`Counted — ${name}`} value={h ? counts[ck] : ""} onChange={(e) => setCounts({ ...counts, [ck]: e.target.value.replace(/[^0-9]/g, "") })} /></td>
|
||||
<td style={{ textAlign: "right", fontWeight: 700, color: !blind && h && varr !== 0 ? "var(--color-accent-700)" : "var(--color-neutral-700)" }}>{blind ? (h ? "✓" : "") : h ? signedInt(varr) : "—"}</td>
|
||||
{/* Only the lines that need one. A blind count still shows the chooser — the system
|
||||
figure stays hidden, but a line that cannot be filed without an explanation has
|
||||
to say so while the counter is still standing at the shelf. */}
|
||||
<td>{big && (<>
|
||||
{/* The red border alone would be one more red on a screen that already has the
|
||||
accent everywhere, so an unanswered row also carries the mark and says
|
||||
"Needs a reason" in the box itself. */}
|
||||
{!reason[ck] && <span className="tc-mark" aria-hidden="true" />}
|
||||
<select className="input" style={{ minHeight: 28, padding: "2px 6px", fontSize: 12, borderColor: reason[ck] ? undefined : "var(--color-accent-600)" }} aria-label={`Reason for the gap on ${name}`} value={reason[ck] || ""} onChange={(e) => setReason({ ...reason, [ck]: e.target.value })}>
|
||||
<option value="">Needs a reason…</option>
|
||||
{REASONS.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</>)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{variants.length === 0 && <div className="tc-panel-body"><Empty>No catalogue items to count yet.</Empty></div>}
|
||||
{rows.length > limit && <div className="tc-panel-foot"><button className="btn btn-secondary" onClick={() => setLimit(100000)}>Show all {match.length} variants</button></div>}
|
||||
<div className="tc-panel-foot" style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>Blind hides the system figure while you count.</div>
|
||||
</div>
|
||||
<div className="tc-panel" style={{ marginTop: "var(--space-8)" }}>
|
||||
<div className="tc-panel-head">
|
||||
<div>Stocktake history</div>
|
||||
{s.stocktakes.length > 0 && <div className="tc-panel-aside">{s.stocktakes.length} filed</div>}
|
||||
</div>
|
||||
{s.stocktakes.length === 0 && <div className="tc-panel-body"><Empty pad={4}>No stocktakes filed yet.</Empty></div>}
|
||||
{s.stocktakes.map((h) => {
|
||||
const net = h.lines.reduce((t, l) => t + (l.counted - l.sys), 0);
|
||||
const nv = h.mode === "preloved" ? 0 : h.lines.reduce((t, l) => t + (l.counted - l.sys) * (byId[l.itemId]?.cost || 0), 0);
|
||||
const open = expand === h.id;
|
||||
return (
|
||||
<div key={h.id} style={{ borderBottom: "1px solid var(--color-divider)" }}>
|
||||
{/* The row can't be one big button — it carries a CSV button of its own, and a button
|
||||
inside a button is neither valid nor operable. The disclosure is its own control, so
|
||||
the keyboard can open a count without reaching for a mouse. */}
|
||||
<div className="tc-row row-hover" onClick={() => setExpand(open ? null : h.id)} style={{ cursor: "pointer", flexWrap: "wrap", borderBottom: "none" }}>
|
||||
<div className="tc-row-name" style={{ minWidth: 100 }}>{fmtDate(h.date)}</div>
|
||||
<div className="tc-row-main" style={{ fontSize: 13, color: "var(--color-neutral-800)" }}>Counted by {h.by}{h.mode === "preloved" ? " · pre-loved pool" : ""} · {h.counted} lines counted · <b>{h.variances}</b> variance(s) · net {signedInt(net)} ({signedMoney(nv)})</div>
|
||||
<button className="btn btn-ghost" style={{ minHeight: 26, padding: "2px 8px" }} aria-label={`Download the ${fmtDate(h.date)} count as CSV`} onClick={(e) => { e.stopPropagation(); historyCsv(h); }}>CSV</button>
|
||||
<button type="button" className="btn btn-ghost btn-icon" aria-expanded={open} aria-label={`${open ? "Hide" : "Show"} the variances from the ${fmtDate(h.date)} count`} style={{ fontSize: 12, color: "var(--color-neutral-600)" }} onClick={(e) => { e.stopPropagation(); setExpand(open ? null : h.id); }}>{open ? "▾" : "▸"}</button>
|
||||
</div>
|
||||
{open && (
|
||||
<div style={{ padding: "0 var(--space-4) var(--space-3)" }}>
|
||||
{h.variances === 0 && <Empty pad={2}>No variances — every counted line matched.</Empty>}
|
||||
{h.lines.filter((l) => l.counted !== l.sys).map((l, i) => { const diff = l.counted - l.sys; return (
|
||||
<div key={i} style={{ display: "flex", justifyContent: "space-between", gap: "var(--space-3)", padding: "var(--space-1) 0", fontSize: 13, borderBottom: "1px solid var(--color-neutral-200)" }}>
|
||||
<div>{label(byId[l.itemId])} · size {byId[l.itemId]?.sizes[l.si] ?? "?"}</div>
|
||||
<div>system {l.sys} → counted {l.counted} · <b style={{ color: "var(--color-accent-700)" }}>{signedInt(diff)}</b> ({money(Math.abs(diff) * (byId[l.itemId]?.cost || 0))})</div>
|
||||
</div>
|
||||
); })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{cam && <Camera onHit={camHit} message={camMsg} onClose={() => setCam(false)} />}
|
||||
{bind && <BindDialog code={bind} onClose={() => setBind("")} onBound={(itemId, si) => setMsg(countPlus(itemId, si))} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+677
@@ -0,0 +1,677 @@
|
||||
/* ThreadCount — Modernist design system tokens (authored from the handoff spec) */
|
||||
:root {
|
||||
--color-bg: #f3f2f2;
|
||||
--color-surface: #eae9e9;
|
||||
--color-text: #201e1d;
|
||||
--color-accent: #ec3013;
|
||||
--color-accent-300: #ffc4b8;
|
||||
/* The brand red carries white type on every primary button, accent tag and error bar, and at
|
||||
#ec3013 that pairing is 4.20:1 — under the 4.5:1 a label at body size needs. 600 is the same
|
||||
red one step down: white on it is 5.07:1, and it is 4.54:1 as text on the paper ground, so it
|
||||
is the shade to fill anything that has words on it. #ec3013 stays the brand mark and belongs on
|
||||
the rules, the progress bar and the 4px edge marks, which carry no text and need only 3:1. */
|
||||
--color-accent-600: #d42a12;
|
||||
--color-accent-700: #b8240e;
|
||||
--color-divider: #cfcccb;
|
||||
--color-neutral-200: #e4e2e1;
|
||||
--color-neutral-300: #d6d3d2;
|
||||
/* 400 is only legible on ink — it is the muted meta colour inside the dark panels and top bars
|
||||
(7.8:1 there). On the paper ground it is 1.9:1, so anything that has to be read on the light
|
||||
side wants 600 or 700 instead. */
|
||||
--color-neutral-400: #b5b1af;
|
||||
--color-neutral-500: #928d8a;
|
||||
--color-neutral-900: #2d2b2b;
|
||||
/* Was #7a7573, which is 4.07:1 on the paper ground and 3.75:1 on the surface tone — under the
|
||||
minimum for the sub-lines, table headers and tab labels this colour carries. */
|
||||
--color-neutral-600: #6c6764;
|
||||
--color-neutral-700: #57534f;
|
||||
--color-neutral-800: #3a3735;
|
||||
--color-slip-teal: #9acbd8;
|
||||
/* The desktop app's chrome, and only the desktop app's. #201e1d, #f3f2f2, #ec3013 and #b8240e
|
||||
are the four the Android shell already ships, so nothing here is a new colour in the product;
|
||||
#2b2827 is one step up from the ink so the rail reads as lifted off the page header rather
|
||||
than welded to it, and #37332f is the lightest hairline that still shows on ink. These are
|
||||
literal values on purpose: the desktop scopes below remap some --color-* tokens on the ink
|
||||
bands, and a --tc-* token defined as var(--color-…) would be remapped along with them. */
|
||||
--tc-ink: #201e1d;
|
||||
--tc-rail: #2b2827;
|
||||
--tc-hair: #37332f;
|
||||
--tc-on-ink: #f3f2f2;
|
||||
--tc-ink-idle: #d6d3d2;
|
||||
--tc-ink-muted: #b5b1af;
|
||||
--tc-on-ink-accent: #ffc4b8;
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--font-heading: var(--font-archivo), "Archivo", system-ui, sans-serif;
|
||||
--font-body: var(--font-archivo), "Archivo", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; background: var(--color-bg); color: var(--color-text); font-family: var(--font-body); font-size: 14px; line-height: 1.4; -webkit-font-smoothing: antialiased; }
|
||||
/* Chrome's text autosizing "boosts" font sizes in the Android WebView — a 15px top-bar title came
|
||||
out at 24px on a real phone, which is why the app's chrome looked oversized against the design.
|
||||
The sizes here are deliberate, so opt out of the boosting; the OS accessibility font scale still
|
||||
applies on top of them. */
|
||||
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
|
||||
a { color: var(--color-accent-700); }
|
||||
a:hover { color: var(--color-accent-600); }
|
||||
h1, h2, h3 { font-family: var(--font-heading); margin: 0; }
|
||||
b, strong { font-weight: 700; }
|
||||
button, input, select, textarea { font-family: var(--font-body); font-size: 14px; color: var(--color-text); border-radius: 0; }
|
||||
input[type="checkbox"] { accent-color: var(--color-text); }
|
||||
|
||||
/* Buttons — flush-left labels, zero radius */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: flex-start; gap: 6px;
|
||||
min-height: 36px; padding: 6px 14px; font-weight: 600; font-size: 13px; line-height: 1.2;
|
||||
border: 2px solid transparent; background: transparent; color: var(--color-text); cursor: pointer;
|
||||
text-align: left; white-space: nowrap; text-decoration: none;
|
||||
}
|
||||
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.btn-primary { background: var(--color-accent-600); border-color: var(--color-accent-600); color: #fff; }
|
||||
.btn-primary:not(:disabled):hover { background: var(--color-accent-700); border-color: var(--color-accent-700); }
|
||||
.btn-secondary { background: transparent; border-color: var(--color-text); color: var(--color-text); }
|
||||
.btn-secondary:not(:disabled):hover { background: var(--color-neutral-200); }
|
||||
.btn-ghost { background: transparent; border-color: transparent; color: var(--color-text); text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1.5px; }
|
||||
.btn-ghost:not(:disabled):hover { background: var(--color-neutral-200); }
|
||||
.btn-block { width: 100%; }
|
||||
|
||||
/* Tags */
|
||||
.tag {
|
||||
display: inline-flex; align-items: center; padding: 2px 7px; font-size: 10px; font-weight: 700;
|
||||
letter-spacing: 0.08em; text-transform: uppercase; line-height: 1.5; border: 1.5px solid transparent; white-space: nowrap;
|
||||
}
|
||||
.tag-accent { background: var(--color-accent-600); border-color: var(--color-accent-600); color: #fff; }
|
||||
.tag-neutral { background: var(--color-neutral-200); border-color: var(--color-neutral-200); color: var(--color-text); }
|
||||
.tag-outline { background: transparent; border-color: var(--color-text); color: var(--color-text); }
|
||||
|
||||
/* Segmented control */
|
||||
.seg { display: inline-flex; border: 2px solid var(--color-text); background: var(--color-bg); }
|
||||
.seg-opt {
|
||||
padding: 5px 12px; min-height: 30px; font-size: 12px; font-weight: 600; background: transparent;
|
||||
border: none; border-right: 2px solid var(--color-text); color: var(--color-text); cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.seg-opt:last-child { border-right: none; }
|
||||
.seg-opt.btn-primary { background: var(--color-accent-600); color: #fff; }
|
||||
|
||||
/* Inputs */
|
||||
.input {
|
||||
min-height: 36px; padding: 6px 10px; font-size: 14px; border: 2px solid var(--color-text); background: #fff; color: var(--color-text);
|
||||
outline: none; width: auto; max-width: 100%;
|
||||
}
|
||||
.input:focus { border-color: var(--color-accent); box-shadow: inset 0 0 0 1px var(--color-accent); }
|
||||
.input:disabled { background: var(--color-surface); color: var(--color-neutral-700); }
|
||||
select.input { appearance: none; -webkit-appearance: none; padding-right: 28px; background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'><path d='M1 1l5 5 5-5' fill='none' stroke='%23201e1d' stroke-width='2'/></svg>"); background-repeat: no-repeat; background-position: right 10px center; }
|
||||
.field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.field label { font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-neutral-700); }
|
||||
.field .input { width: 100%; }
|
||||
|
||||
/* Tables */
|
||||
.table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||
.table th { font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--color-neutral-600); border-bottom: 2px solid var(--color-text); padding: 8px 8px 6px; }
|
||||
.table td { padding: 8px; border-bottom: 1px solid var(--color-divider); vertical-align: middle; }
|
||||
.table tr:last-child td { border-bottom: 1px solid var(--color-divider); }
|
||||
|
||||
/* Section heads */
|
||||
.sec { border-bottom: 2px solid var(--color-text); padding-bottom: var(--space-2); font-size: 12px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; }
|
||||
.eyebrow { font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-accent-700); }
|
||||
.h1 { font-family: var(--font-heading); font-weight: 800; font-size: 30px; margin: var(--space-1) 0 0; letter-spacing: -0.02em; }
|
||||
.page-head { padding: var(--space-6) 0 var(--space-4); border-bottom: 2px solid var(--color-text); display: flex; align-items: flex-end; justify-content: space-between; gap: var(--space-4); flex-wrap: wrap; }
|
||||
.row-hover:hover { background: var(--color-neutral-200); }
|
||||
.muted { color: var(--color-neutral-700); }
|
||||
.num { font-family: var(--font-heading); font-weight: 800; }
|
||||
|
||||
/* Dialogs */
|
||||
.overlay { position: fixed; inset: 0; background: color-mix(in srgb, #201e1d 45%, transparent); display: flex; align-items: center; justify-content: center; z-index: 50; padding: 16px; }
|
||||
.dialog { background: var(--color-bg); border: 2px solid var(--color-text); padding: var(--space-6); max-height: 88vh; overflow: auto; width: 100%; }
|
||||
.dialog-title { font-family: var(--font-heading); font-weight: 800; font-size: 20px; }
|
||||
|
||||
/* App shell */
|
||||
/* Skip link: out of the way until it takes focus, then a solid ink chip in the top-left corner.
|
||||
Fixed rather than in flow so it cannot disturb the shell's flex row while it is hidden. */
|
||||
.skip-link { position: fixed; top: 8px; left: -9999px; z-index: 100; background: var(--color-text); color: var(--color-bg); border: 2px solid var(--color-text); padding: 10px 14px; font-size: 13px; font-weight: 700; text-decoration: none; }
|
||||
.skip-link:focus { left: 8px; }
|
||||
/* The content landmark only holds focus so the skip link can hand it over; a ring around the whole
|
||||
page would be noise, and nothing else can focus it. */
|
||||
main.tc-main:focus { outline: none; }
|
||||
/* --tc-gutter is the page's side margin. The page head bleeds out to it with a negative margin,
|
||||
so the two have to be one number or the ink band stops short of the edge. */
|
||||
.tc-shell { display: flex; min-height: 100vh; align-items: stretch; --tc-gutter: var(--space-8); }
|
||||
#tc-side {
|
||||
--tc-rail-w: 232px;
|
||||
width: var(--tc-rail-w); flex: 0 0 var(--tc-rail-w);
|
||||
background: var(--tc-rail); color: var(--tc-ink-idle);
|
||||
border-right: 2px solid var(--tc-ink);
|
||||
display: flex; flex-direction: column;
|
||||
position: sticky; top: 0; align-self: flex-start; height: 100vh;
|
||||
/* Eleven items, a footer and a short window: the rail scrolls rather than losing Sign out off
|
||||
the bottom. Collapsed it must not, because the hover labels are drawn outside its width and
|
||||
overflow-y clips the other axis with it. */
|
||||
overflow-y: auto; overflow-x: hidden;
|
||||
transition: width 140ms ease, flex-basis 140ms ease;
|
||||
}
|
||||
#tc-side.tc-rail-narrow { --tc-rail-w: 62px; overflow: visible; }
|
||||
@media (prefers-reduced-motion: reduce) { #tc-side { transition: none; } }
|
||||
#tc-mobilebar { display: none; }
|
||||
main.tc-main { flex: 1; min-width: 0; padding: 0 var(--tc-gutter) var(--space-8); }
|
||||
|
||||
#tc-scanfab { display: none; }
|
||||
@media screen and (max-width: 780px) {
|
||||
/* main's padding drops to space-3 below; the page head bleeds by the same number or it hangs
|
||||
over the edge of a narrow window. */
|
||||
.tc-shell { --tc-gutter: var(--space-3); }
|
||||
#tc-side { display: none !important; }
|
||||
#tc-mobilebar { display: grid !important; }
|
||||
#tc-scanfab { display: flex !important; }
|
||||
/* The fab sits above the dialog overlay; hide it while a dialog is open so it can't be tapped
|
||||
through (the scan dialogs have their own Camera button). */
|
||||
body:has(.overlay) #tc-scanfab { display: none !important; }
|
||||
main.tc-main { padding: 0 var(--space-3) 110px !important; }
|
||||
.tc-grid { grid-template-columns: 1fr !important; }
|
||||
/* A grid track defaults to a min-content floor, so a wide table (the size list on a product
|
||||
page) stretches its column instead of scrolling inside .table-wrap and drags the whole page
|
||||
sideways. Let the tracks shrink and the wrapper does its job. */
|
||||
.tc-grid > * { min-width: 0; }
|
||||
.dialog { max-width: 94vw !important; }
|
||||
/* Touch targets: 44px controls, 16px inputs (stops iOS zoom), 40px segmented options, scrollable tables. */
|
||||
.btn { min-height: 44px; }
|
||||
.input, select.input { min-height: 44px; font-size: 16px; }
|
||||
.seg .seg-opt { min-height: 40px; }
|
||||
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
table.table { display: block; overflow-x: auto; }
|
||||
/* Scoped to the desktop app's content area. As a bare `h1 !important` this reached every
|
||||
heading on every phone-width screen — the counter app's 15px top-bar titles came out at 24px,
|
||||
which is why the app's chrome looked oversized on a real device, and the marketing hero and
|
||||
the onboarding headings were flattened to 24px too. Every other rule in this block is scoped
|
||||
to the desktop shell; this one wasn't. */
|
||||
main.tc-main h1, main.tc-main .h1 { font-size: 24px !important; }
|
||||
.page-head .btn { min-height: 44px; padding: 6px 12px; }
|
||||
}
|
||||
.table-wrap { overflow-x: auto; }
|
||||
@media screen and (max-width: 820px) {
|
||||
.tc-auth { grid-template-columns: 1fr !important; min-height: 100dvh !important; }
|
||||
.tc-brandpane { display: none !important; }
|
||||
/* Phone: brand strip on top, form starts high (keyboard-friendly), links home/demo underneath. */
|
||||
.tc-authpane { align-items: flex-start !important; padding: 20px 20px calc(28px + env(safe-area-inset-bottom)) !important; }
|
||||
.tc-brandmobile, .tc-authfoot { display: flex !important; }
|
||||
.tc-auth .seg .seg-opt { min-height: 44px; }
|
||||
.tc-auth .btn-primary { min-height: 48px; font-size: 15px; }
|
||||
.mk-grid2, .mk-grid3, .mk-grid4, .mk-hero, .mk-rep { grid-template-columns: 1fr !important; }
|
||||
.mk-h1 { font-size: 38px !important; }
|
||||
.tcl-grid { grid-template-columns: 1fr !important; }
|
||||
.tcl-side { position: static !important; border-right: none !important; border-bottom: 2px solid var(--color-text); display: flex; flex-wrap: wrap; gap: 4px; padding: 12px 16px !important; }
|
||||
.tcl-side .btn { width: auto !important; }
|
||||
}
|
||||
/* The marketing nav is sticky, so an anchor jump lands the heading underneath it. */
|
||||
#loop, #product, #features, #reporting, #price { scroll-margin-top: 96px; }
|
||||
@media screen and (max-width: 900px) {
|
||||
.tcm-hero, .tcm-loop, .tcm-rep, .tcm-price { grid-template-columns: 1fr !important; }
|
||||
.tcm-feat { grid-template-columns: 1fr 1fr !important; }
|
||||
.tcm-stats { grid-template-columns: 1fr 1fr !important; }
|
||||
.tcm-foot { grid-template-columns: 1fr 1fr !important; }
|
||||
.tcm-rail { display: none !important; }
|
||||
.tcm-navlinks { gap: 16px !important; font-size: 12.5px !important; }
|
||||
.tcm-hero > div:first-child { border-right: none !important; border-bottom: 2px solid var(--color-text); }
|
||||
#reporting > div:first-child { border-right: none !important; border-bottom: 2px solid var(--color-text); }
|
||||
.tcm-price > div:first-child { border-right: none !important; border-bottom: 1px solid var(--color-divider); }
|
||||
}
|
||||
@media screen and (max-width: 820px) {
|
||||
.tcm-foot { grid-template-columns: 1fr 1fr !important; }
|
||||
}
|
||||
@media screen and (max-width: 780px) {
|
||||
/* Handoff hides the desktop link row here. It leaves a second scrollable row in its place so the
|
||||
pages stay reachable — the handoff flags "no mobile menu was designed" as an open question. */
|
||||
.tcm-navlinks, .tcm-navlogin { display: none !important; }
|
||||
.tcm-navmobile { display: block !important; }
|
||||
}
|
||||
@media screen and (max-width: 900px) {
|
||||
.tcm-headsplit, .tcm-split, .tcm-3col, .tcm-2col { grid-template-columns: 1fr !important; }
|
||||
/* The left cell of a .tcm-split carries no left padding of its own — on a wide screen it sits on
|
||||
the page gutter, which the wrap has already paid for. Collapsed to one column that zero becomes
|
||||
copy printed against the edge of the phone, so the gutter goes back here. */
|
||||
.tcm-splitpad { padding-left: 40px; }
|
||||
/* A grid track floors at min-content, so a fixed-width mock inside a collapsed split stretches
|
||||
its column and drags the whole page sideways instead of scrolling inside its own .table-wrap. */
|
||||
.tcm-split > * { min-width: 0; }
|
||||
.tcm-rowgrid { grid-template-columns: 1fr !important; gap: 6px !important; }
|
||||
.tcm-pullup { margin-top: 0 !important; }
|
||||
.tcm-stagger > * { padding-top: 0 !important; }
|
||||
}
|
||||
@media screen and (max-width: 560px) {
|
||||
.tcm-pillars { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
@media screen and (max-width: 640px) {
|
||||
/* Phones: drop the in-page links so the sticky bar stays one row and keeps the CTAs above the fold. */
|
||||
.tcm-navlinks { display: none !important; }
|
||||
.tcm-nav { padding: 12px 16px !important; gap: 12px !important; }
|
||||
#loop, #product, #features, #reporting, #price { scroll-margin-top: 72px; }
|
||||
/* The hero's second and third mock cards are illustration. On a phone they added ~490px
|
||||
of scrolling before the first real section, so show one and get on with it. */
|
||||
.tcm-mock-extra { display: none !important; }
|
||||
.tcm-mock-first { margin-bottom: 20px !important; }
|
||||
}
|
||||
@media screen and (max-width: 560px) {
|
||||
.tcm-feat, .tcm-foot { grid-template-columns: 1fr !important; }
|
||||
.tcm-feat-lead { grid-column: auto !important; }
|
||||
}
|
||||
@media print {
|
||||
#tc-side, #tc-mobilebar, #tc-scanfab, .skip-link, .no-print { display: none !important; }
|
||||
main.tc-main { padding: 0 !important; }
|
||||
body { background: #fff; }
|
||||
}
|
||||
|
||||
/* Update wave additions */
|
||||
.btn-icon { padding: 0 8px; min-height: 26px; text-decoration: none; font-size: 16px; line-height: 1; }
|
||||
.kpi-strip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 2px; background: var(--color-divider); border: 2px solid var(--color-text); margin-top: var(--space-4); }
|
||||
.kpi-strip > div { background: var(--color-bg); padding: var(--space-3); }
|
||||
.kpi-strip .kv { font-family: var(--font-heading); font-weight: 800; font-size: 24px; }
|
||||
.kpi-strip .kl { font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-neutral-700); font-weight: 600; margin-top: 2px; }
|
||||
.link-name { cursor: pointer; border-bottom: 2px solid var(--color-accent); color: inherit; text-decoration: none; }
|
||||
.link-name:hover { color: var(--color-accent-700); }
|
||||
.notice { border-left: 4px solid var(--color-accent); padding: var(--space-2) var(--space-3); font-size: 13px; font-weight: 600; margin-bottom: var(--space-3); }
|
||||
.bar-track { height: 10px; border: 2px solid var(--color-text); }
|
||||
.bar-fill { height: 100%; background: var(--color-text); }
|
||||
textarea.input { font-family: var(--font-body); resize: vertical; }
|
||||
@media screen and (max-width: 780px) { .kpi-strip { grid-template-columns: 1fr 1fr; } }
|
||||
|
||||
/* Marketing photography: every image is greyscale, never tinted or restored on hover. */
|
||||
.grayscale { filter: grayscale(1) contrast(1.08); }
|
||||
|
||||
/* ---------- the public site (app/(site)): the ledger design, 2026-09-15
|
||||
*
|
||||
* Everything below is scoped to .tcm-site, the wrapper the (site) layout draws, so none of it can
|
||||
* reach the app or the phone apps. The --color-* tokens at the top of this file are read, never
|
||||
* redefined; the three colours the site adds (scrub navy for vignettes and shadows, a green for
|
||||
* the trust marks, plain white for the ledger cards) are its own tokens. Red stays for the one
|
||||
* action on each screen. */
|
||||
.tcm-site {
|
||||
--ms-navy: #1d3557;
|
||||
--ms-navy2: #e6ecf5;
|
||||
--ms-ok: #1e7a4f;
|
||||
--ms-white: #ffffff;
|
||||
--font-mono: var(--font-plex-mono), "IBM Plex Mono", ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 16px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.tcm-site [id] { scroll-margin-top: 84px; }
|
||||
.ms-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.ms-wrap { max-width: 1360px; margin: 0 auto; width: 100%; padding-inline: clamp(16px, 4vw, 40px); }
|
||||
/* Blocks stay inside the column (a first cut let the ledger and the photo run to the screen edge,
|
||||
which on a 1920px monitor split the page into two far-apart halves). Only the grey video band
|
||||
and the red band run the full width. */
|
||||
.ms-clip { overflow-x: clip; }
|
||||
.ms-kick { font-size: 11px; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase; color: var(--color-accent-700); }
|
||||
.ms-section { padding-block: clamp(44px, 6vw, 80px); }
|
||||
.ms-section.tight { padding-top: 0; }
|
||||
.ms-split { display: flex; justify-content: space-between; align-items: flex-end; gap: 24px; flex-wrap: wrap; margin-bottom: 26px; }
|
||||
.ms-lede { font-size: 19px; line-height: 1.5; color: var(--color-neutral-700); max-width: 52ch; margin: 0; }
|
||||
/* Buttons: the two doors (trial, demo) and nothing else competes for the click. */
|
||||
.ms-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 13px 20px; font-family: var(--font-heading); font-weight: 700; font-size: 15px; line-height: 1.2; text-decoration: none; border: 2px solid var(--color-text); color: var(--color-text); background: transparent; cursor: pointer; white-space: nowrap; }
|
||||
.ms-btn:hover { background: var(--color-neutral-200); color: var(--color-text); }
|
||||
.ms-btn.pri { background: var(--color-accent-600); border-color: var(--color-accent-600); color: #fff; }
|
||||
.ms-btn.pri:hover { background: var(--color-accent-700); border-color: var(--color-accent-700); color: #fff; }
|
||||
.ms-btn.ink { background: var(--color-text); border-color: var(--color-text); color: var(--color-bg); }
|
||||
.ms-btn.ink:hover { background: var(--color-neutral-900); color: var(--color-bg); }
|
||||
.ms-btn.white { background: #fff; border-color: #fff; color: var(--color-accent-700); }
|
||||
.ms-btn.white:hover { background: var(--color-neutral-200); border-color: var(--color-neutral-200); color: var(--color-accent-700); }
|
||||
.ms-btn.ghost-white { border-color: #fff; color: #fff; background: transparent; }
|
||||
.ms-btn.ghost-white:hover { background: rgba(255, 255, 255, 0.14); color: #fff; }
|
||||
.ms-btn.sm { padding: 9px 14px; font-size: 13.5px; }
|
||||
.ms-btn:focus-visible, .tcm-site a:focus-visible { outline: 3px solid var(--ms-navy); outline-offset: 2px; }
|
||||
/* Nav */
|
||||
.ms-nav { position: sticky; top: 0; z-index: 60; background: var(--color-bg); border-bottom: 2px solid var(--color-text); }
|
||||
.ms-nav .ms-navrow { display: flex; align-items: center; gap: 26px; height: 64px; }
|
||||
.ms-nav a.l { font-family: var(--font-heading); font-weight: 600; font-size: 14px; color: var(--color-text); text-decoration: none; padding-bottom: 3px; border-bottom: 3px solid transparent; white-space: nowrap; }
|
||||
.ms-nav a.l.cur { border-bottom-color: var(--color-accent); color: var(--color-accent-700); }
|
||||
.ms-nav .ms-navright { margin-left: auto; display: flex; gap: 10px; align-items: center; }
|
||||
.ms-navmobile { display: none; border-top: 1px solid var(--color-divider); position: relative; }
|
||||
.ms-navmobile .row { display: flex; gap: 18px; padding: 10px clamp(16px, 4vw, 40px); white-space: nowrap; overflow-x: auto; }
|
||||
.ms-navmobile a { font-family: var(--font-heading); font-weight: 700; font-size: 13px; text-decoration: none; color: var(--color-text); border-bottom: 3px solid transparent; padding-bottom: 2px; }
|
||||
.ms-navmobile a.cur { color: var(--color-accent-700); border-bottom-color: var(--color-accent); }
|
||||
.ms-navmobile a.auth { color: var(--color-accent-700); font-weight: 800; }
|
||||
/* The ledger card the hero and the product page are built from. */
|
||||
.ms-ledger { background: var(--ms-white); border: 2px solid var(--color-text); box-shadow: 8px 8px 0 var(--ms-navy2); }
|
||||
.ms-ledger .hd { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 16px; border-bottom: 2px solid var(--color-text); font-family: var(--font-heading); font-weight: 700; font-size: 12px; letter-spacing: 0.1em; text-transform: uppercase; }
|
||||
.ms-ledger .hd .ms-mono { font-weight: 600; color: var(--color-neutral-700); letter-spacing: 0; text-transform: none; }
|
||||
.ms-row { display: grid; grid-template-columns: 62px 1fr 92px 64px; gap: 12px; align-items: center; padding: 11px 16px; border-bottom: 1px solid var(--color-divider); font-size: 14px; }
|
||||
.ms-row .t { font-size: 12px; color: var(--color-neutral-600); }
|
||||
.ms-row .n { font-weight: 700; }
|
||||
.ms-row .who { font-size: 12.5px; color: var(--color-neutral-700); }
|
||||
.ms-stamp { justify-self: end; font-family: var(--font-heading); font-weight: 800; font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; padding: 4px 7px; border: 1.5px solid var(--color-accent-700); color: var(--color-accent-700); transform: rotate(-6deg); }
|
||||
.ms-stamp.ok { border-color: var(--ms-ok); color: var(--ms-ok); transform: rotate(4deg); }
|
||||
.ms-stamp.nv { border-color: var(--ms-navy); color: var(--ms-navy); transform: none; }
|
||||
.ms-sizes { display: flex; gap: 6px; flex-wrap: wrap; padding: 12px 16px; border-top: 1px solid var(--color-divider); background: var(--color-surface); }
|
||||
.ms-sz { font-family: var(--font-mono); font-weight: 600; font-size: 12px; padding: 4px 8px; border: 1px solid var(--color-text); background: var(--ms-white); position: relative; }
|
||||
.ms-sz b { color: var(--color-neutral-600); font-weight: 400; margin-left: 6px; }
|
||||
.ms-sz.low { border-color: var(--color-accent-700); color: var(--color-accent-700); }
|
||||
.ms-sz.low::after { content: "reorder"; position: absolute; top: -9px; right: -6px; font-family: var(--font-heading); font-weight: 600; font-size: 8px; letter-spacing: 0.1em; text-transform: uppercase; background: var(--color-accent-600); color: #fff; padding: 1px 4px; }
|
||||
/* The three questions, and the vignette each one carries. */
|
||||
.ms-q3 { display: grid; grid-template-columns: repeat(3, 1fr); border: 2px solid var(--color-text); background: var(--ms-white); }
|
||||
.ms-q3 > div { padding: 26px 26px 30px; border-right: 1px solid var(--color-divider); min-width: 0; }
|
||||
.ms-q3 > div:last-child { border-right: 0; }
|
||||
.ms-q3.four { grid-template-columns: repeat(4, 1fr); }
|
||||
.ms-q3 p { margin: 10px 0 0; color: var(--color-neutral-700); font-size: 15px; }
|
||||
.ms-vig { margin-top: 18px; border: 1px solid var(--color-text); background: var(--color-bg); }
|
||||
.ms-vig .l, .ms-ledger .l { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 8px 10px; border-bottom: 1px solid var(--color-divider); font-size: 13px; }
|
||||
.ms-vig .l:last-child, .ms-ledger .l:last-child { border-bottom: 0; }
|
||||
.ms-ledger .l { padding: 9px 16px; font-size: 13.5px; }
|
||||
.ms-vig .l b, .ms-ledger .l b { font-weight: 700; }
|
||||
.ms-vig .l .red, .ms-ledger .l .red { color: var(--color-accent-700); }
|
||||
.ms-vig .l .dim, .ms-ledger .l .dim { color: var(--color-neutral-700); }
|
||||
.ms-bar { flex: 1; height: 8px; background: var(--ms-navy2); border: 1px solid var(--ms-navy); position: relative; min-width: 40px; }
|
||||
.ms-bar i { position: absolute; inset: 0; background: var(--ms-navy); width: var(--w); }
|
||||
/* Five steps, one record. */
|
||||
.ms-flow { display: grid; grid-template-columns: repeat(5, 1fr); border-top: 2px solid var(--color-text); border-bottom: 2px solid var(--color-text); }
|
||||
.ms-flow > div { padding: 22px 18px 26px; border-right: 1px solid var(--color-divider); min-width: 0; }
|
||||
.ms-flow > div:last-child { border-right: 0; }
|
||||
.ms-flow .n { font-family: var(--font-mono); font-weight: 600; font-size: 12px; color: var(--color-accent-700); }
|
||||
.ms-flow p { font-size: 14px; color: var(--color-neutral-700); margin: 8px 0 0; }
|
||||
/* What finance gets. */
|
||||
.ms-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--color-divider); border: 2px solid var(--color-text); }
|
||||
.ms-stats > div { background: var(--ms-white); padding: 22px 20px; min-width: 0; }
|
||||
.ms-stats b { display: block; font-family: var(--font-heading); font-weight: 800; font-size: 40px; line-height: 1; letter-spacing: -0.03em; }
|
||||
.ms-stats span { display: block; margin-top: 8px; font-size: 14px; color: var(--color-neutral-700); }
|
||||
/* The walkthrough. */
|
||||
.ms-video { border: 2px solid var(--color-text); background: var(--ms-navy); aspect-ratio: 16 / 10; max-width: 100%; overflow: hidden; position: relative; }
|
||||
.ms-video video { width: 100%; height: 100%; display: block; object-fit: cover; }
|
||||
/* Pricing strip. */
|
||||
.ms-price { display: grid; grid-template-columns: repeat(3, 1fr); border: 2px solid var(--color-text); background: var(--ms-white); }
|
||||
.ms-price > div { padding: 24px 24px 28px; border-right: 1px solid var(--color-divider); display: flex; flex-direction: column; min-width: 0; }
|
||||
.ms-price > div:last-child { border-right: 0; }
|
||||
.ms-price .amt { font-family: var(--font-heading); font-weight: 800; font-size: 38px; line-height: 1; letter-spacing: -0.03em; margin-top: 10px; }
|
||||
.ms-price .amt small { font-family: var(--font-heading); font-weight: 600; font-size: 14px; color: var(--color-neutral-700); letter-spacing: 0; }
|
||||
.ms-price ul { margin: 14px 0 0; padding-left: 18px; font-size: 14px; color: var(--color-neutral-700); flex: 1; }
|
||||
.ms-price li { margin: 5px 0; }
|
||||
.ms-price .who { margin: 10px 0 0; font-size: 14px; color: var(--color-neutral-700); }
|
||||
.ms-price .lead { background: var(--color-text); color: #f4f2ee; }
|
||||
.ms-price .lead .ms-kick { color: var(--color-accent-300); }
|
||||
.ms-price .lead .amt small, .ms-price .lead ul, .ms-price .lead .who { color: #e0dbd2; }
|
||||
.ms-price .ms-btn { margin-top: 18px; align-self: flex-start; }
|
||||
.ms-trust { display: flex; gap: 24px; flex-wrap: wrap; padding: 22px 0; border-top: 1px solid var(--color-divider); border-bottom: 1px solid var(--color-divider); }
|
||||
.ms-trust > div { font-family: var(--font-heading); font-weight: 600; font-size: 13px; color: var(--color-neutral-700); display: flex; gap: 8px; align-items: center; }
|
||||
.ms-trust i { width: 8px; height: 8px; background: var(--ms-ok); display: inline-block; flex: none; }
|
||||
/* Where it came from. */
|
||||
.ms-story { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; align-items: center; }
|
||||
.ms-story .photo { aspect-ratio: 4 / 3; border: 2px solid var(--color-text); overflow: hidden; max-width: 100%; }
|
||||
.ms-story .photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
/* Questions. */
|
||||
.ms-faq { border-top: 2px solid var(--color-text); }
|
||||
.ms-faq details { border-bottom: 1px solid var(--color-divider); padding: 16px 0; }
|
||||
.ms-faq summary { font-family: var(--font-heading); font-weight: 700; font-size: 17px; cursor: pointer; list-style: none; }
|
||||
.ms-faq summary::-webkit-details-marker { display: none; }
|
||||
.ms-faq summary::before { content: "+ "; color: var(--color-accent-700); }
|
||||
.ms-faq details[open] summary::before { content: "− "; }
|
||||
.ms-faq p { margin: 8px 0 0; color: var(--color-neutral-700); max-width: 70ch; }
|
||||
/* The red band. */
|
||||
.ms-band { background: var(--color-accent-600); color: #fff; padding: 56px 0; border-top: 2px solid var(--color-text); }
|
||||
.ms-band .ms-kick { color: #fff; opacity: 0.85; }
|
||||
.ms-band .ms-wrap { display: flex; justify-content: space-between; align-items: center; gap: 24px; flex-wrap: wrap; }
|
||||
/* Footer. */
|
||||
.ms-foot { padding: 36px 0 0; border-top: 2px solid var(--color-text); }
|
||||
.ms-fcols { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 28px; }
|
||||
.ms-foot h4 { font-family: var(--font-heading); font-weight: 700; font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase; margin: 0 0 10px; }
|
||||
.ms-foot a.f { display: block; font-size: 14px; color: var(--color-text); text-decoration: none; margin: 4px 0; }
|
||||
.ms-foot a.f:hover { color: var(--color-accent-700); }
|
||||
.ms-foot .fine { margin-top: 28px; padding-top: 14px; padding-bottom: 28px; border-top: 1px solid var(--color-divider); font-size: 12.5px; color: var(--color-neutral-700); display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
||||
/* Generic rows the product and docs pages use: a ruled list with a monospace index. */
|
||||
.ms-list > div { display: grid; grid-template-columns: 56px 1fr; gap: 16px; padding: 18px 0; border-bottom: 1px solid var(--color-divider); }
|
||||
.ms-list .i { font-family: var(--font-mono); font-weight: 600; font-size: 12px; color: var(--color-accent-700); padding-top: 4px; }
|
||||
.ms-list .t { font-family: var(--font-heading); font-weight: 800; font-size: 18px; letter-spacing: -0.01em; }
|
||||
.ms-list p { margin: 6px 0 0; font-size: 15px; color: var(--color-neutral-700); max-width: 62ch; }
|
||||
.ms-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 0 48px; }
|
||||
.ms-hero { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: clamp(32px, 4vw, 56px); align-items: center; padding: clamp(48px, 7vw, 96px) 0 clamp(48px, 6vw, 72px); }
|
||||
.ms-hero .ms-ledger { box-shadow: 8px 8px 0 var(--ms-navy2); }
|
||||
.ms-pricing { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2.4fr); gap: clamp(28px, 4vw, 56px); align-items: start; }
|
||||
.ms-videoband { background: var(--color-surface); border-top: 2px solid var(--color-text); border-bottom: 2px solid var(--color-text); }
|
||||
.ms-hero .ctas { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 28px; }
|
||||
.ms-proof { display: flex; gap: 26px; flex-wrap: wrap; margin-top: 34px; padding-top: 18px; border-top: 1px solid var(--color-divider); }
|
||||
.ms-proof > div { font-family: var(--font-heading); font-weight: 600; font-size: 13px; line-height: 1.3; color: var(--color-neutral-700); }
|
||||
.ms-proof b { display: block; font-weight: 800; font-size: 22px; line-height: 1; color: var(--color-text); letter-spacing: -0.02em; margin-bottom: 4px; }
|
||||
@media screen and (max-width: 900px) {
|
||||
.ms-hero, .ms-story, .ms-2col, .ms-pricing { grid-template-columns: 1fr !important; }
|
||||
.ms-q3, .ms-q3.four { grid-template-columns: 1fr; }
|
||||
.ms-q3 > div { border-right: 0; border-bottom: 1px solid var(--color-divider); }
|
||||
.ms-q3 > div:last-child { border-bottom: 0; }
|
||||
.ms-flow { grid-template-columns: 1fr 1fr; }
|
||||
.ms-flow > div:nth-child(2n) { border-right: 0; }
|
||||
.ms-flow > div { border-bottom: 1px solid var(--color-divider); }
|
||||
}
|
||||
@media screen and (max-width: 820px) {
|
||||
.ms-nav a.l { display: none; }
|
||||
.ms-navmobile { display: block; }
|
||||
.ms-stats, .ms-fcols { grid-template-columns: 1fr 1fr; }
|
||||
.ms-price { grid-template-columns: 1fr; }
|
||||
.ms-price > div { border-right: 0; border-bottom: 1px solid var(--color-divider); }
|
||||
.ms-price > div:last-child { border-bottom: 0; }
|
||||
}
|
||||
@media screen and (max-width: 520px) {
|
||||
.ms-flow, .ms-stats, .ms-fcols { grid-template-columns: 1fr; }
|
||||
.ms-flow > div { border-right: 0; }
|
||||
.ms-row { grid-template-columns: 52px 1fr 64px; }
|
||||
.ms-row .code { display: none; }
|
||||
.ms-nav .ms-navright .ms-demo { display: none; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { .ms-stamp { transform: none !important; } }
|
||||
|
||||
/* ---------- the phone app (/m): a fixed-height column, only the body scrolls */
|
||||
/* The page returns a fragment, so its top bar, rule, body and nav are this column's own children. */
|
||||
.tcx-app { position: fixed; inset: 0; display: flex; flex-direction: column; background: var(--color-bg); overflow: hidden; }
|
||||
/* Android 15 draws apps edge to edge whether they ask or not, so the ink top bar would otherwise
|
||||
sit under the status bar with the screen title behind the clock. The inset is padding on the bar
|
||||
itself, so the ink still runs to the top of the screen — the colour bleeds, the words don't. */
|
||||
.tcx-topbar { padding-top: env(safe-area-inset-top, 0px); height: calc(56px + env(safe-area-inset-top, 0px)) !important; flex-basis: calc(56px + env(safe-area-inset-top, 0px)) !important; }
|
||||
.tcx-bar:not(:disabled):active { filter: brightness(0.86); }
|
||||
@keyframes tcx-sweep { 0%, 100% { top: 6px; } 50% { top: calc(100% - 9px); } }
|
||||
.tcx-laser { animation: tcx-sweep 1.6s ease-in-out infinite; top: 6px; }
|
||||
@media (prefers-reduced-motion: reduce) { .tcx-laser { animation: none; top: 50%; } }
|
||||
/* Bars on the counting screen carry the chart in "variance over time" */
|
||||
.tcx-chart { display: flex; align-items: flex-end; gap: 5px; height: 56px; border-bottom: 1px solid var(--color-divider); }
|
||||
.tcx-chart > i { flex: 1; min-width: 6px; display: block; }
|
||||
|
||||
/* Native scanning: MLKit draws the camera preview behind the WebView, so the page has to get
|
||||
out of the way while it runs. Only ever set inside the Android shell. */
|
||||
html.tcx-native-scan, html.tcx-native-scan body, html.tcx-native-scan .tcx-app { background: transparent !important; }
|
||||
/* Everything the shelf screen drew goes; the scan overlay stays. `.tcx-scanui` is the class the
|
||||
camera overlay's own root carries (components/MScan.tsx) — if nothing on the page carries it,
|
||||
this hides the scan UI as well, and a coordinator mid-count is left with a bare camera picture:
|
||||
no LIVE/PAUSED header, no running counted/expected figure and no "Stop scanning" bar to press,
|
||||
only the hardware back button, which walks out of the count altogether. */
|
||||
html.tcx-native-scan .tcx-app > *:not(.tcx-scanui) { visibility: hidden; }
|
||||
/* The overlay is drawn for the browser scanner, where the picture comes from its own <video>.
|
||||
Under MLKit the picture is behind the WebView, so the overlay's ground and its camera window —
|
||||
the last <div> in it, the one wrapping the video — have to let it through, or the spared UI
|
||||
covers the preview with flat ink and the garment can't be aimed at. The header, the last-scans
|
||||
panel and the bottom bar keep their own backgrounds so they stay readable over the picture. */
|
||||
html.tcx-native-scan .tcx-scanui,
|
||||
html.tcx-native-scan .tcx-scanui .tcx-camwin { background: transparent !important; }
|
||||
/* The browser scanner's <video> is still in the camera window under MLKit, with no stream to
|
||||
show. Android's WebView paints a source-less video as an opaque grey box with a play glyph in
|
||||
the middle — which is exactly what a coordinator in hands-free mode saw on a Pixel 8 Pro on
|
||||
2026-09-12 instead of the shelf: the native preview was behind it the whole time. */
|
||||
html.tcx-native-scan .tcx-scanui video { display: none !important; }
|
||||
|
||||
/* On a tablet the app keeps a phone-shaped column rather than stretching a list row to 1200px —
|
||||
a row that wide puts the garment name and its count at opposite ends of the screen. */
|
||||
@media (min-width: 620px) {
|
||||
.tcx-app { align-items: center; }
|
||||
.tcx-app > * { width: 100%; max-width: 560px; }
|
||||
.tcx-app { background: var(--color-surface); }
|
||||
}
|
||||
|
||||
/* ---------- the desktop app (/app): dark chrome, paper content
|
||||
*
|
||||
* Everything that changes how an existing thing looks, from here down, is scoped to .tc-shell —
|
||||
* the wrapper components/Shell.tsx draws and nothing else in the product draws. The counter app
|
||||
* (/m) and the staff app (/my) build their screens out of the --color-* tokens at the top of this
|
||||
* file and the .tcx-* classes above, and neither ever renders a .tc-shell, so none of those
|
||||
* selectors can reach a phone. The unscoped rules below add new class names only — nothing the
|
||||
* marketing site or the two phone apps already wears.
|
||||
*
|
||||
* The chrome went dark and the content did not. A shelf count is read across a linen room under
|
||||
* ward lighting, and paper holds its contrast there in a way an ink panel does not. */
|
||||
.tc-rail-brand {
|
||||
display: flex; align-items: center; gap: var(--space-2);
|
||||
padding: var(--space-5) var(--space-4) var(--space-3);
|
||||
}
|
||||
.tc-rail-mark { width: 14px; height: 14px; flex: 0 0 14px; background: var(--color-accent); }
|
||||
.tc-rail-word { font-family: var(--font-heading); font-weight: 800; font-size: 18px; letter-spacing: -0.02em; color: var(--tc-on-ink); flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; }
|
||||
.tc-rail-facility {
|
||||
padding: 0 var(--space-4) var(--space-3);
|
||||
font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--tc-ink-muted);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
border-bottom: 1px solid var(--tc-hair);
|
||||
}
|
||||
.tc-rail-toggle {
|
||||
flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 30px; height: 30px; padding: 0; border: 1px solid var(--tc-hair);
|
||||
background: transparent; color: var(--tc-ink-idle); cursor: pointer;
|
||||
}
|
||||
.tc-rail-toggle:hover { background: var(--tc-hair); color: var(--tc-on-ink); }
|
||||
.tc-rail-nav { flex: 1; display: flex; flex-direction: column; }
|
||||
/* An item is a fixed-height icon row, so collapsed the rail is a column of even squares. */
|
||||
.tc-rail-item {
|
||||
position: relative; display: flex; align-items: center; gap: var(--space-3);
|
||||
padding: 0 var(--space-4); min-height: 42px;
|
||||
border: none; border-bottom: 1px solid var(--tc-hair); border-left: 4px solid transparent;
|
||||
background: transparent; color: var(--tc-ink-idle);
|
||||
font-family: var(--font-body); font-size: 14px; font-weight: 500; text-align: left;
|
||||
text-decoration: none; cursor: pointer; width: 100%;
|
||||
}
|
||||
.tc-rail-item:hover { background: var(--tc-hair); color: var(--tc-on-ink); }
|
||||
.tc-rail-item:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
/* The accent marks the screen you are on as a rule down its edge, not as the label's colour: the
|
||||
vermilion is 3.5:1 against the rail, which is enough for a 4px bar and not enough for a 14px
|
||||
word. The word turns paper-white and heavy instead, so the item is marked twice over. */
|
||||
.tc-rail-item.active { border-left-color: var(--color-accent); background: var(--tc-ink); color: var(--tc-on-ink); font-weight: 800; }
|
||||
.tc-rail-icon { flex: 0 0 18px; display: inline-flex; align-items: center; justify-content: center; color: var(--tc-ink-muted); }
|
||||
.tc-rail-item:hover .tc-rail-icon, .tc-rail-item.active .tc-rail-icon { color: var(--color-accent-300); }
|
||||
.tc-rail-label { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.tc-rail-meta { padding: var(--space-3) var(--space-4); font-size: 11px; line-height: 1.7; color: var(--tc-ink-muted); letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
.tc-rail-foot { border-top: 1px solid var(--tc-hair); }
|
||||
.tc-rail-foot .tc-rail-item:last-child { border-bottom: none; }
|
||||
.tc-rail-role { display: inline-flex; margin-left: 6px; padding: 1px 6px; font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; background: var(--tc-hair); color: var(--tc-ink-idle); }
|
||||
.tc-rail-live { padding: 0 var(--space-4) var(--space-3); font-size: 11px; font-weight: 600; color: var(--color-accent-300); }
|
||||
.tc-rail-item:focus-visible, .tc-rail-toggle:focus-visible { outline: 2px solid var(--color-accent-300); outline-offset: -3px; }
|
||||
|
||||
/* Collapsed: an icon rail. The label stays in the DOM and stays in the accessibility tree —
|
||||
opacity, not display or visibility — so a screen reader still reads "Delivery Rounds" off an
|
||||
item a sighted coordinator has to hover to name. */
|
||||
/* The facility name carries the hairline under the brand when the rail is open; collapsed it is
|
||||
hidden, so the brand block takes the rule over or the mark runs straight into the first icon. */
|
||||
.tc-rail-narrow .tc-rail-brand { justify-content: center; flex-wrap: wrap; gap: var(--space-2); padding: var(--space-4) 0 var(--space-3); border-bottom: 1px solid var(--tc-hair); }
|
||||
.tc-rail-narrow .tc-rail-word, .tc-rail-narrow .tc-rail-facility, .tc-rail-narrow .tc-rail-meta, .tc-rail-narrow .tc-rail-role { display: none; }
|
||||
.tc-rail-narrow .tc-rail-item { justify-content: center; padding: 0; min-height: 44px; border-left-width: 3px; }
|
||||
.tc-rail-narrow .tc-rail-label {
|
||||
position: absolute; left: 100%; top: 50%; transform: translateY(-50%); margin-left: 2px;
|
||||
padding: 7px 10px; background: var(--tc-ink); color: var(--tc-on-ink);
|
||||
border: 2px solid var(--tc-hair); font-weight: 600; font-size: 13px;
|
||||
opacity: 0; pointer-events: none; z-index: 40; transition: opacity 90ms linear;
|
||||
}
|
||||
.tc-rail-narrow .tc-rail-item:hover .tc-rail-label,
|
||||
.tc-rail-narrow .tc-rail-item:focus-visible .tc-rail-label { opacity: 1; }
|
||||
@media (prefers-reduced-motion: reduce) { .tc-rail-narrow .tc-rail-label { transition: none; } }
|
||||
|
||||
/* The page head carries the ink the full width of the content column, so it bleeds back out
|
||||
* through main's gutter.
|
||||
*
|
||||
* Screens put whatever they like in this band — a counted total, a supplier filter, a "net −$412"
|
||||
* in accent-700 — and most of it names its colour inline as var(--color-text) or
|
||||
* var(--color-neutral-700), which on ink is either invisible or close to it. Rather than make
|
||||
* fourteen screens special-case the band, the band remaps those tokens for everything inside it:
|
||||
* an inline var(--color-text) resolves to paper in here and to ink everywhere else. This is the
|
||||
* only place in the product where a --color-* token takes a different value, it happens on a
|
||||
* descendant of .tc-shell, and no phone screen has one of those. */
|
||||
.tc-shell .page-head, .tc-shell .tc-pagehead {
|
||||
--color-text: #f3f2f2;
|
||||
--color-bg: #201e1d;
|
||||
--color-surface: #37332f;
|
||||
--color-divider: #37332f;
|
||||
--color-neutral-200: #37332f;
|
||||
--color-neutral-300: #57534f;
|
||||
--color-neutral-600: #b5b1af;
|
||||
--color-neutral-700: #b5b1af;
|
||||
--color-neutral-800: #d6d3d2;
|
||||
--color-accent-700: #ffc4b8;
|
||||
background: var(--tc-ink); color: var(--tc-on-ink);
|
||||
margin: 0 calc(var(--tc-gutter) * -1) var(--space-6);
|
||||
padding: var(--space-6) var(--tc-gutter) var(--space-5);
|
||||
border-bottom: 4px solid var(--color-accent);
|
||||
display: flex; align-items: flex-end; justify-content: space-between; gap: var(--space-4); flex-wrap: wrap;
|
||||
}
|
||||
/* A field keeps its white ground, so inside one the ink has to come back or what someone types is
|
||||
white on white — the reporting month select and the register's search box both live up here. */
|
||||
.tc-shell .page-head .input, .tc-shell .tc-pagehead .input { --color-text: #201e1d; }
|
||||
.tc-shell .page-head .h1, .tc-shell .tc-pagehead .h1 { color: var(--tc-on-ink); }
|
||||
|
||||
/* The figure a screen is actually about: a count, a value, a number of gaps. */
|
||||
.tc-figure { font-family: var(--font-heading); font-weight: 800; font-size: 28px; line-height: 1.05; letter-spacing: -0.02em; }
|
||||
.tc-meta { font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-neutral-700); }
|
||||
|
||||
/* Tiles: the figures that sit under a page head. auto-fit rather than a fixed count, because the
|
||||
same strip carries three tiles on Ordering and five on Reports. */
|
||||
.tc-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); gap: 2px; background: var(--color-text); border: 2px solid var(--color-text); }
|
||||
.tc-tile { background: var(--color-bg); padding: var(--space-4); display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.tc-tile-label { font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-neutral-700); }
|
||||
.tc-tile-note { font-size: 12px; color: var(--color-neutral-700); }
|
||||
|
||||
/* Panels: a bordered block with a named head. */
|
||||
.tc-panel { border: 2px solid var(--color-text); background: var(--color-bg); min-width: 0; }
|
||||
.tc-panel-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3); padding: var(--space-3) var(--space-4); border-bottom: 2px solid var(--color-text); font-size: 12px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; }
|
||||
.tc-panel-aside { font-size: 12px; font-weight: 400; letter-spacing: 0; text-transform: none; color: var(--color-neutral-700); }
|
||||
.tc-panel-body { padding: var(--space-4); }
|
||||
.tc-panel-foot { padding: var(--space-3) var(--space-4); border-top: 1px solid var(--color-divider); }
|
||||
/* A list of rows fills its panel edge to edge — no body padding, the rows carry their own. */
|
||||
.tc-panel-list > .tc-row:last-child { border-bottom: none; }
|
||||
|
||||
/* Rows: one garment, one order, one request. */
|
||||
.tc-row { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--color-divider); min-width: 0; }
|
||||
.tc-row-main { flex: 1; min-width: 0; }
|
||||
.tc-row-name { font-weight: 700; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tc-row-meta { font-size: 12px; color: var(--color-neutral-700); }
|
||||
.tc-row-fig { font-family: var(--font-heading); font-weight: 800; font-size: 18px; white-space: nowrap; }
|
||||
a.tc-row, button.tc-row { width: 100%; background: var(--color-bg); border-left: none; border-right: none; border-top: none; color: inherit; text-decoration: none; text-align: left; font-size: 14px; cursor: pointer; }
|
||||
a.tc-row:hover, button.tc-row:hover { background: var(--color-neutral-200); color: inherit; }
|
||||
|
||||
/* Anything that wants attention.
|
||||
*
|
||||
* The accent is already the brand — it is the primary button and it is the current nav item — so
|
||||
* a second red a metre away across the room is a guess, not a signal. A flagged tile or row is
|
||||
* marked three ways instead: a rule down its left edge, its figure heavier and in the darker red
|
||||
* that stays legible on paper, and .tc-mark next to a word saying what is wrong. The mark is
|
||||
* decoration and belongs behind aria-hidden; the word is the message, so screens keep the word. */
|
||||
.tc-flag { border-left: 4px solid var(--color-accent); }
|
||||
.tc-tile.tc-flag, .tc-row.tc-flag { padding-left: calc(var(--space-4) - 4px); }
|
||||
.tc-flag .tc-figure, .tc-flag .tc-row-fig { font-weight: 800; color: var(--color-accent-700); }
|
||||
.tc-flag .tc-tile-label { color: var(--color-accent-700); }
|
||||
.tc-mark { display: inline-block; width: 0; height: 0; border-left: 5px solid transparent; border-right: 5px solid transparent; border-bottom: 9px solid var(--color-accent); vertical-align: -1px; margin-right: 6px; }
|
||||
.tag-flag { background: var(--color-accent-600); border-color: var(--color-accent-600); color: #fff; gap: 5px; }
|
||||
.tag-flag::before { content: ""; width: 0; height: 0; border-left: 4px solid transparent; border-right: 4px solid transparent; border-bottom: 7px solid currentColor; }
|
||||
|
||||
/* On paper the ink band is a trap: browsers drop background colours out of a print by default, and
|
||||
what is left is paper-white type on white paper — a screen title that prints as nothing. So the
|
||||
head goes back to the light treatment for the printer, and the gutter it bleeds through goes to
|
||||
zero along with main's padding. */
|
||||
@media print {
|
||||
.tc-shell { --tc-gutter: 0px; }
|
||||
.tc-shell .page-head, .tc-shell .tc-pagehead {
|
||||
--color-text: #201e1d;
|
||||
--color-bg: #f3f2f2;
|
||||
--color-surface: #eae9e9;
|
||||
--color-divider: #cfcccb;
|
||||
--color-neutral-200: #e4e2e1;
|
||||
--color-neutral-300: #d6d3d2;
|
||||
--color-neutral-600: #6c6764;
|
||||
--color-neutral-700: #57534f;
|
||||
--color-neutral-800: #3a3735;
|
||||
--color-accent-700: #b8240e;
|
||||
background: none; color: var(--color-text);
|
||||
margin: 0 0 var(--space-4); padding: 0 0 var(--space-2);
|
||||
border-bottom: 2px solid var(--color-text);
|
||||
}
|
||||
.tc-shell .page-head .h1, .tc-shell .tc-pagehead .h1 { color: var(--color-text); }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Archivo, IBM_Plex_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import ErrorReporting from "@/components/ErrorReporting";
|
||||
|
||||
const archivo = Archivo({
|
||||
variable: "--font-archivo",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "600", "700", "800", "900"],
|
||||
display: "swap",
|
||||
});
|
||||
// The marketing site sets product codes, size runs, times and cost centres in a monospace face,
|
||||
// the way a coordinator reads them off a slip. Only the public pages use it (globals.css scopes
|
||||
// --font-mono to .tcm-site); the app and the two phone apps never see it.
|
||||
const plexMono = IBM_Plex_Mono({
|
||||
variable: "--font-plex-mono",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "600"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const viewport = { width: "device-width", initialScale: 1, viewportFit: "cover" as const };
|
||||
|
||||
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://threadcount.tech";
|
||||
const DESC = "Uniform stock management for hospitals, aged care, clinics and community care. What's on the shelf, who took it and what it cost the ward or clinic — orders and stocktakes in one place.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(SITE),
|
||||
title: { default: "ThreadCount — Uniform management for hospitals, aged care and clinics", template: "%s — ThreadCount" },
|
||||
description: DESC,
|
||||
applicationName: "ThreadCount",
|
||||
alternates: { canonical: "/" },
|
||||
// Without these a link pasted into Slack, Teams or an email renders as a bare URL.
|
||||
openGraph: {
|
||||
type: "website", siteName: "ThreadCount", url: SITE, locale: "en_AU",
|
||||
title: "ThreadCount — Uniform management for hospitals, aged care and clinics",
|
||||
description: DESC,
|
||||
images: [{ url: "/og.png", width: 1200, height: 630, alt: "ThreadCount — every garment out the door, accounted for." }],
|
||||
},
|
||||
twitter: { card: "summary_large_image", title: "ThreadCount — Uniform management for hospitals, aged care and clinics", description: DESC, images: ["/og.png"] },
|
||||
robots: { index: true, follow: true },
|
||||
formatDetection: { telephone: false },
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className={`${archivo.variable} ${plexMono.variable}`}>
|
||||
<body>
|
||||
{children}
|
||||
{/* Global handlers for the client crashes that never reach a React boundary. */}
|
||||
<ErrorReporting />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
"use client";
|
||||
/* The product card, on the phone.
|
||||
*
|
||||
* Two halves, because they are two different jobs. The top is the garment's description, which is
|
||||
* typed once and rarely changed. The bottom is per size — par level, barcode, what's on hand —
|
||||
* which is what someone standing at a shelf actually came here to adjust.
|
||||
*
|
||||
* The size index — a position in that list — is what every issue, order line and barcode points at,
|
||||
* so the order of the list is never offered for editing: shuffling it would silently repoint years
|
||||
* of records. One size can be taken off, though, and the server does the deciding: it shifts every
|
||||
* later size down across the ten tables that store a position, in one transaction, and refuses
|
||||
* outright when the size being removed has anything recorded against it. So this screen offers the
|
||||
* removal on every size and shows whatever comes back. */
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { bcBound, formatInZone, key as vkey, label, onhand, reorderAt, splitKey, type CostRec, type Item } from "@/lib/compute";
|
||||
import { isNative } from "@/lib/nativescan";
|
||||
import MScan from "@/components/MScan";
|
||||
import {
|
||||
ACCENT, GROUND, INK, ON_DARK, MBar, MBody, MError, MField, MNote, MRule, MSection, MTop, inputStyle,
|
||||
} from "@/components/m";
|
||||
|
||||
/* The two ways to get a code onto a size, side by side. Scanning stays the primary act, ink-filled:
|
||||
it is the fastest when the camera cooperates. Typing sits beside it rather than behind it — a
|
||||
label in your hand beats a camera that won't focus, and it is the only way to reach a code the
|
||||
scanner keeps putting on the wrong garment. */
|
||||
const codeBtn: React.CSSProperties = {
|
||||
flex: 1, minHeight: 48, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800,
|
||||
fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer",
|
||||
};
|
||||
/* Undoing rather than doing: the quieter kind of action on a size row. No border, so it reads as a
|
||||
link; 44px tall, so it is still a target you can hit with gloves on. */
|
||||
const quietAction: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", width: "100%", minHeight: 44, background: "none", border: 0,
|
||||
padding: 0, font: "inherit", fontSize: 12.5, fontWeight: 700, color: "var(--color-neutral-700)",
|
||||
textAlign: "left", cursor: "pointer",
|
||||
};
|
||||
|
||||
/* What a freshly minted number is, and what it still isn't.
|
||||
*
|
||||
* The code exists in ThreadCount the moment it is made, but the garment on the rack carries nothing
|
||||
* until somebody prints it and sticks it on — so the confirmation carries the print with it rather
|
||||
* than leaving it to be found at the foot of a fifteen-size screen. */
|
||||
function MMade({ made, inApp, labels, inset, onPrint }: {
|
||||
made: { size: string; code: string }[]; inApp: boolean; labels: number; inset?: boolean; onPrint: () => void;
|
||||
}) {
|
||||
if (!made.length) return null;
|
||||
return (
|
||||
<div style={{ margin: inset ? "10px 0 0" : 16, padding: 16, background: INK, color: GROUND, fontSize: 13.5, lineHeight: 1.6 }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 16, letterSpacing: "-0.01em" }}>
|
||||
{made.length === 1 ? `Size ${made[0].size} has a barcode now` : `${made.length} sizes have a barcode now`}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, color: ON_DARK, fontSize: 12.5, fontVariantNumeric: "tabular-nums" }}>
|
||||
{made.map((m) => <div key={m.code}>{m.size} · {m.code}</div>)}
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
{inApp
|
||||
? "Nothing is on the garments yet. Printing is a desktop job — the app can’t open a label sheet — so open ThreadCount on the desktop and print this garment’s labels from there."
|
||||
: labels
|
||||
? "Nothing is on the garments yet. Print the labels and stick one on each."
|
||||
: "Nothing is on the garments yet, and nothing in a labelled size is on the shelf to stick one on. Count some in and the labels will print, one for each garment."}
|
||||
</div>
|
||||
{!inApp && labels > 0 && (
|
||||
<button onClick={onPrint}
|
||||
style={{ width: "100%", minHeight: 48, marginTop: 12, border: "2px solid " + GROUND, background: GROUND, color: INK, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer" }}>
|
||||
Print labels
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MProductCard() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { s, isAdmin, mutate, busy } = useSnap();
|
||||
const { L, byId } = useDerived();
|
||||
const router = useRouter();
|
||||
|
||||
const it = s.catalog.find((x: Item) => x.id === id);
|
||||
|
||||
const [err, setErr] = useState("");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [f, setF] = useState(() => ({
|
||||
item: it?.item ?? "", type: it?.type ?? "", group: it?.group ?? "All",
|
||||
supplier: it?.supplier ?? "", sku: it?.sku ?? "", cost: it ? String(it.cost) : "", notes: it?.notes ?? "",
|
||||
}));
|
||||
const [newSize, setNewSize] = useState("");
|
||||
const [scanFor, setScanFor] = useState<number | null>(null);
|
||||
const [typeFor, setTypeFor] = useState<number | null>(null);
|
||||
const [typed, setTyped] = useState("");
|
||||
/* Which run is in flight, so only the button that was pressed says so: `busy` is true for every
|
||||
mutation on the screen, and fifteen rows all reading "Generating…" because somebody nudged a
|
||||
par level is a lie. -1 is the whole-garment run. */
|
||||
const [genFor, setGenFor] = useState<number | null>(null);
|
||||
const [made, setMade] = useState<{ si: number; size: string; code: string }[]>([]);
|
||||
/* The Android shell cannot print: its WebView opens no second window, so the label sheet would
|
||||
replace the app, and window.print() doesn't exist there. Same reading as the reprint screen,
|
||||
taken after mount — the server render doesn't know which shell it is being sent to. */
|
||||
const [inApp, setInApp] = useState(false);
|
||||
useEffect(() => { setInApp(isNative()); }, []);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const set = new Set<string>(["All"]);
|
||||
for (const st of s.staff) if (st.group) set.add(st.group);
|
||||
for (const i of s.catalog) if (i.group) set.add(i.group);
|
||||
return [...set].sort();
|
||||
}, [s.staff, s.catalog]);
|
||||
|
||||
if (!it) {
|
||||
return (
|
||||
<>
|
||||
<MTop title="Garment" back />
|
||||
<MRule />
|
||||
<MBody><MNote tone="warn">That garment isn’t in the catalogue any more.</MNote></MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const name = label(byId[it.id] ?? it);
|
||||
// Newest first; the snapshot already caps how many it carries.
|
||||
const costs: CostRec[] = s.costs.filter((c) => c.itemId === it.id);
|
||||
const readOnly = !isAdmin;
|
||||
// How many sizes a whole-garment run would cover, and how much paper a print run would produce —
|
||||
// one label per garment on the shelf. Both read through the same bcBound and onhand the rows
|
||||
// below use, so the two numbers on this screen can never disagree with each other.
|
||||
const unlabelled = it.sizes.filter((_: string, si: number) => !bcBound(s, it, si)).length;
|
||||
const labels = it.sizes.reduce((n: number, _: string, si: number) =>
|
||||
n + (bcBound(s, it, si) ? Math.max(0, onhand(s, L, vkey(it.id, si))) : 0), 0);
|
||||
|
||||
/* Fill the boxes from the garment as it stands right now, not as it stood when the screen was
|
||||
* opened. The card refreshes underneath without remounting, so a coordinator can be looking at a
|
||||
* unit cost somebody else raised on the desktop minutes ago while this form still holds the old
|
||||
* one — and saving would quietly put the old price back and file a "Down from $24.00" cost change
|
||||
* in the wrong person's name. Every issue costed after that would use the stale figure. */
|
||||
function startEdit() {
|
||||
setErr("");
|
||||
setF({
|
||||
item: it!.item, type: it!.type, group: it!.group,
|
||||
supplier: it!.supplier, sku: it!.sku, cost: String(it!.cost), notes: it!.notes,
|
||||
});
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
async function saveDetails() {
|
||||
setErr("");
|
||||
if (!f.item.trim()) { setErr("The garment needs a name."); return; }
|
||||
const c = f.cost.trim() ? Number(f.cost) : 0;
|
||||
if (!(c >= 0) || Number.isNaN(c)) { setErr("Cost has to be a number."); return; }
|
||||
const r = await mutate("catalog.update", {
|
||||
id: it!.id, item: f.item.trim(), type: f.type.trim(), group: f.group,
|
||||
supplier: f.supplier.trim(), sku: f.sku.trim(), cost: c, notes: f.notes,
|
||||
});
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
async function addSize() {
|
||||
const sz = newSize.trim();
|
||||
if (!sz) return;
|
||||
setErr("");
|
||||
const r = await mutate("catalog.update", { id: it!.id, addSize: sz });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setNewSize("");
|
||||
}
|
||||
|
||||
async function setPar(si: number, next: number) {
|
||||
const r = await mutate("stock.reorder", { itemId: it!.id, si, reorder: Math.max(0, next) });
|
||||
if (!r.ok) setErr(r.error);
|
||||
}
|
||||
|
||||
/** Where a code already sits, named the way a person would name it, or "" if this snapshot has
|
||||
* never seen it. */
|
||||
function boundElsewhere(code: string): string {
|
||||
const at = s.barcodes[code];
|
||||
if (!at) return "";
|
||||
const { itemId, si } = splitKey(at);
|
||||
const other = byId[itemId];
|
||||
return other ? `${label(other)} · size ${other.sizes[si] ?? si}` : "";
|
||||
}
|
||||
|
||||
/* Binding, including the refusal that used to be a dead end.
|
||||
*
|
||||
* A code scanned onto the wrong garment can only be put right by moving it, and barcode.bind
|
||||
* won't move one unless it is told to — so when that is why it refused, offer the move rather
|
||||
* than printing the message and stopping there. The snapshot is asked where the code sits so the
|
||||
* question can name the garment it would come off; the server's own sentence, which names it too,
|
||||
* is the fallback for a code somebody else bound since this page loaded. The other refusal — a
|
||||
* generated 93XXXXXXX code, which stands for a garment rather than sitting on a label — is
|
||||
* refused with or without force, matches neither test, and is shown as it came. */
|
||||
async function bind(si: number, raw: string) {
|
||||
const code = raw.trim();
|
||||
if (!code) return;
|
||||
setErr(""); setMade([]);
|
||||
const r = await mutate("barcode.bind", { code, itemId: it!.id, si });
|
||||
if (r.ok) { setTypeFor(null); setTyped(""); return; }
|
||||
const at = boundElsewhere(code);
|
||||
if (!at && !/is already on/.test(r.error)) { setErr(r.error); return; }
|
||||
const ask = at
|
||||
? `${code} is on ${at}. Take it off there and put it on ${name} · size ${it!.sizes[si]}?`
|
||||
: `${r.error}\n\nMove it onto ${name} · size ${it!.sizes[si]}?`;
|
||||
if (!confirm(ask)) { setErr(r.error); return; }
|
||||
const moved = await mutate("barcode.bind", { code, itemId: it!.id, si, force: true });
|
||||
if (!moved.ok) { setErr(moved.error); return; }
|
||||
setTypeFor(null); setTyped("");
|
||||
}
|
||||
|
||||
async function unbind(si: number, code: string) {
|
||||
if (!confirm(`Unbind ${code} from ${name} · size ${it!.sizes[si]}? Scanning that label won't find this size any more.`)) return;
|
||||
setErr(""); setMade([]);
|
||||
const r = await mutate("barcode.unbind", { code });
|
||||
if (!r.ok) setErr(r.error);
|
||||
}
|
||||
|
||||
/* The server decides whether a size can go — it is the one that can count what has been recorded
|
||||
* against this exact position — so the offer is made on every size and the refusal is shown when
|
||||
* one comes back. Removing shifts the sizes after it down a place, so anything this screen is
|
||||
* holding open against a position has to let go of it. */
|
||||
async function removeSize(si: number) {
|
||||
if (!confirm(`Remove size ${it!.sizes[si]} from ${name}? Its par level and any barcode on it go with it.`)) return;
|
||||
setErr("");
|
||||
const r = await mutate("catalog.removeSize", { id: it!.id, si });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setScanFor(null); setTypeFor(null); setTyped(""); setMade([]);
|
||||
}
|
||||
|
||||
/* Printing our own barcode for stock that arrived without one — the cafe shirts came with nothing
|
||||
* printed on any of fifteen sizes, and a garment nobody can scan is invisible to a count and
|
||||
* cannot be issued by scanning. The number is a real EAN-13 from the range GS1 keeps for exactly
|
||||
* this, so every scanner in the building already reads it.
|
||||
*
|
||||
* The server decides what is missing: it fills only the gaps, leaves a size carrying a supplier's
|
||||
* code alone, and refuses outright when there is nothing to do. So the offer is made and whatever
|
||||
* comes back is shown, rather than the button being hidden on this screen's guess about a
|
||||
* snapshot that may be a few seconds old. */
|
||||
async function generate(si?: number) {
|
||||
setErr(""); setMade([]);
|
||||
setGenFor(si ?? -1);
|
||||
const r = await mutate<{ made: { si: number; size: string; code: string }[]; count: number }>(
|
||||
"barcode.generate", si === undefined ? { itemId: it!.id } : { itemId: it!.id, si },
|
||||
);
|
||||
setGenFor(null);
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setMade(r.result.made);
|
||||
}
|
||||
|
||||
/* Not destructive, but it does put numbers on garments — and on a rack of fifteen sizes it is a
|
||||
good deal more than the person pressing it can see at once. So it says how many first. */
|
||||
async function generateAll() {
|
||||
const ask = `Generate a barcode for ${unlabelled} size${unlabelled === 1 ? "" : "s"} on ${name}? Sizes that already carry a supplier's code keep theirs, and nothing is on a garment until the labels are printed.`;
|
||||
if (unlabelled > 0 && !confirm(ask)) return;
|
||||
await generate();
|
||||
}
|
||||
|
||||
/* A whole garment's labels: one per garment on hand, every size that carries a code. A second
|
||||
window rather than this one, because leaving the screen would lose the size list somebody is
|
||||
halfway through labelling — and inside the app there is no second window to open, which is why
|
||||
every path to here is closed off when `inApp`. */
|
||||
function printLabels() {
|
||||
window.open(`/print/labels?item=${encodeURIComponent(it!.id)}`, "_blank", "noopener");
|
||||
}
|
||||
|
||||
async function archive() {
|
||||
const r = await mutate("catalog.update", { id: it!.id, archived: !it!.archived });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
if (!it!.archived) router.replace("/m/catalogue");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title={it.archived ? "Archived" : "Garment"} right={`${it.sizes.length} size${it.sizes.length === 1 ? "" : "s"}`} back />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
|
||||
{it.archived && <MNote tone="warn">This garment is archived. It stays on old records but can’t be issued.</MNote>}
|
||||
|
||||
{/* ---- the description ---- */}
|
||||
{!editing ? (
|
||||
<>
|
||||
<div style={{ padding: "18px 16px", borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em", lineHeight: 1.1 }}>{name}</div>
|
||||
<div style={{ fontSize: 13.5, color: "var(--color-neutral-700)", marginTop: 8, lineHeight: 1.6 }}>
|
||||
{[it.type, it.group === "All" ? "Anyone" : it.group, it.supplier, it.sku].filter(Boolean).join(" · ") || "No details yet"}
|
||||
<br />
|
||||
{it.cost ? `$${it.cost.toFixed(2)} each` : "No unit cost set"}
|
||||
</div>
|
||||
{it.notes && <div style={{ fontSize: 13, color: "var(--color-neutral-800)", marginTop: 10, lineHeight: 1.6 }}>{it.notes}</div>}
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div style={{ padding: 16, borderBottom: "2px solid " + INK }}>
|
||||
<button onClick={startEdit}
|
||||
style={{ width: "100%", minHeight: 52, border: "2px solid " + INK, background: "transparent", color: INK, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer" }}>
|
||||
Edit details
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MField label="Garment">
|
||||
<input value={f.item} onChange={(e) => setF({ ...f, item: e.target.value })} autoCapitalize="words" style={inputStyle} />
|
||||
</MField>
|
||||
<MField label="Type">
|
||||
<input value={f.type} onChange={(e) => setF({ ...f, type: e.target.value })} style={inputStyle} />
|
||||
</MField>
|
||||
<MField label="Who wears it">
|
||||
<select value={f.group} onChange={(e) => setF({ ...f, group: e.target.value })} style={{ ...inputStyle, appearance: "none" }}>
|
||||
{groups.map((g) => <option key={g} value={g}>{g === "All" ? "Anyone" : g}</option>)}
|
||||
</select>
|
||||
</MField>
|
||||
<MField label="Supplier">
|
||||
<input value={f.supplier} onChange={(e) => setF({ ...f, supplier: e.target.value })} style={inputStyle} />
|
||||
</MField>
|
||||
<MField label="Supplier code">
|
||||
<input value={f.sku} onChange={(e) => setF({ ...f, sku: e.target.value })} autoCapitalize="characters" autoCorrect="off" spellCheck={false} style={inputStyle} />
|
||||
</MField>
|
||||
<MField label="Unit cost">
|
||||
<input value={f.cost} onChange={(e) => setF({ ...f, cost: e.target.value })} inputMode="decimal" style={inputStyle} />
|
||||
</MField>
|
||||
<MField label="Notes">
|
||||
<input value={f.notes} onChange={(e) => setF({ ...f, notes: e.target.value })} placeholder="Optional" style={inputStyle} />
|
||||
</MField>
|
||||
<div style={{ padding: 16, display: "grid", gap: 10, borderBottom: "2px solid " + INK }}>
|
||||
<button onClick={saveDetails} disabled={busy}
|
||||
style={{ width: "100%", minHeight: 52, border: "2px solid " + ACCENT, background: ACCENT, color: "#fff", font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer" }}>
|
||||
{busy ? "Saving…" : "Save details"}
|
||||
</button>
|
||||
<button onClick={() => { setEditing(false); setErr(""); setF({ item: it.item, type: it.type, group: it.group, supplier: it.supplier, sku: it.sku, cost: String(it.cost), notes: it.notes }); }}
|
||||
style={{ width: "100%", minHeight: 48, border: 0, background: "none", color: "var(--color-neutral-700)", font: "inherit", fontSize: 13.5, fontWeight: 700, cursor: "pointer" }}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ---- per size ---- */}
|
||||
<MSection label="Sizes" right="On hand · par" />
|
||||
{it.sizes.map((sz: string, si: number) => {
|
||||
const k = vkey(it.id, si);
|
||||
const oh = onhand(s, L, k);
|
||||
const par = reorderAt(s, k);
|
||||
// The bound supplier code only. bcFor()'s generated 93XXXXXXX fallback is printed on no
|
||||
// garment, so showing it made every size look labelled and hid the ones that need one.
|
||||
const code = bcBound(s, it, si);
|
||||
return (
|
||||
<div key={si} style={{ padding: "14px 16px", borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, minWidth: 54 }}>{sz}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, color: "var(--color-neutral-700)" }}>
|
||||
<b style={{ color: oh <= par ? "var(--color-accent-700)" : INK, fontSize: 15 }}>{oh}</b> on hand
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: code ? "var(--color-neutral-700)" : "var(--color-neutral-600)", marginTop: 2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{code || "No barcode bound"}
|
||||
</div>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 0 }}>
|
||||
<button onClick={() => setPar(si, par - 1)} aria-label={`Lower par for ${sz}`}
|
||||
style={{ width: 44, height: 44, border: "2px solid " + INK, background: "transparent", color: INK, fontSize: 20, fontWeight: 800, cursor: "pointer" }}>−</button>
|
||||
<div style={{ minWidth: 44, height: 44, border: "2px solid " + INK, borderLeft: 0, borderRight: 0, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 16 }}>{par}</div>
|
||||
<button onClick={() => setPar(si, par + 1)} aria-label={`Raise par for ${sz}`}
|
||||
style={{ width: 44, height: 44, border: "2px solid " + INK, background: "transparent", color: INK, fontSize: 20, fontWeight: 800, cursor: "pointer" }}>+</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
{typeFor === si ? (
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
{/* A numeric keypad, because a supplier code is thirteen digits and that is
|
||||
the keyboard you can hit accurately while holding the garment. It is only
|
||||
a hint to the keyboard: whatever arrives is taken as typed, so the
|
||||
alphanumeric codes some labels carry go through on a keyboard that offers
|
||||
letters, and pasting is unaffected either way. */}
|
||||
<input value={typed} onChange={(e) => setTyped(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") bind(si, typed); }}
|
||||
placeholder="Barcode on the label" inputMode="numeric" autoFocus
|
||||
autoCapitalize="characters" autoCorrect="off" spellCheck={false}
|
||||
aria-label={`Barcode for size ${sz}`} style={inputStyle} />
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button onClick={() => bind(si, typed)} disabled={busy || !typed.trim()}
|
||||
style={{ ...codeBtn, border: "2px solid " + ACCENT, background: ACCENT, color: "#fff", opacity: typed.trim() ? 1 : 0.4 }}>
|
||||
{busy ? "Binding…" : "Bind"}
|
||||
</button>
|
||||
<button onClick={() => { setTypeFor(null); setTyped(""); }}
|
||||
style={{ ...codeBtn, border: "2px solid var(--color-neutral-400)", background: "transparent", color: "var(--color-neutral-700)" }}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button onClick={() => setScanFor(si)} aria-label={`Scan a barcode for size ${sz}`}
|
||||
style={{ ...codeBtn, border: "2px solid " + INK, background: INK, color: GROUND }}>
|
||||
{code ? "Scan a new one" : "Scan"}
|
||||
</button>
|
||||
<button onClick={() => { setErr(""); setTyped(""); setTypeFor(si); }} aria-label={`Type a barcode for size ${sz}`}
|
||||
style={{ ...codeBtn, border: "2px solid " + INK, background: "transparent", color: INK }}>
|
||||
Type it in
|
||||
</button>
|
||||
</div>
|
||||
{/* Stock that turned up with nothing printed on it has no label to scan and no
|
||||
number to type, so the third way is to make one. Offered only where nothing
|
||||
is bound: wherever the supplier printed a code, that code is the one the
|
||||
delivery note will use next time and it stays. */}
|
||||
{!code && (
|
||||
<button onClick={() => generate(si)} disabled={busy} aria-label={`Generate a barcode for size ${sz}`}
|
||||
style={{ ...codeBtn, width: "100%", marginTop: 8, border: "2px solid " + INK, background: "transparent", color: INK, opacity: busy ? 0.5 : 1 }}>
|
||||
{genFor === si ? "Generating…" : "Generate a barcode"}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{code && <button onClick={() => unbind(si, code)} style={quietAction}>Unbind {code}</button>}
|
||||
<button onClick={() => removeSize(si)} style={quietAction}>Remove size {sz}</button>
|
||||
{made.length === 1 && made[0].si === si && <MMade made={made} inApp={inApp} labels={labels} inset onPrint={printLabels} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!readOnly && (
|
||||
<div style={{ padding: 16, borderBottom: "2px solid " + INK, display: "flex", gap: 10 }}>
|
||||
<input value={newSize} onChange={(e) => setNewSize(e.target.value)} placeholder="Add a size"
|
||||
autoCapitalize="characters" autoCorrect="off" spellCheck={false}
|
||||
style={{ ...inputStyle, flex: 1 }} />
|
||||
<button onClick={addSize} disabled={busy || !newSize.trim()}
|
||||
style={{ minWidth: 96, border: "2px solid " + INK, background: INK, color: GROUND, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer", opacity: newSize.trim() ? 1 : 0.4 }}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<>
|
||||
<MSection label="Barcodes" right={unlabelled ? `${unlabelled} without` : "All labelled"} />
|
||||
{made.length > 1 && <MMade made={made} inApp={inApp} labels={labels} onPrint={printLabels} />}
|
||||
<div style={{ padding: 16, display: "grid", gap: 10, borderBottom: "2px solid " + INK }}>
|
||||
<button onClick={generateAll} disabled={busy}
|
||||
style={{ width: "100%", minHeight: 52, border: "2px solid " + INK, background: "transparent", color: INK, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer", opacity: busy ? 0.5 : 1 }}>
|
||||
{genFor === -1 ? "Generating…" : "Generate for every unlabelled size"}
|
||||
</button>
|
||||
<button onClick={printLabels} disabled={inApp || labels === 0}
|
||||
style={{ width: "100%", minHeight: 52, border: "2px solid " + ACCENT, background: ACCENT, color: "#fff", font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase", cursor: inApp || labels === 0 ? "not-allowed" : "pointer", opacity: inApp || labels === 0 ? 0.5 : 1 }}>
|
||||
{inApp ? "Print on the desktop" : "Print labels"}
|
||||
</button>
|
||||
<div style={{ fontSize: 12.5, color: "var(--color-neutral-700)", lineHeight: 1.6 }}>
|
||||
{inApp
|
||||
? "Printing is a desktop job — the app can’t open a label sheet. Open ThreadCount on the desktop and print this garment’s labels from there."
|
||||
: labels
|
||||
? `One label for every garment on hand in a size that carries a code — ${labels} at the moment, six to an A4 sheet.`
|
||||
: "Nothing on the shelf carries a code yet, so there is nothing to print."}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<div style={{ padding: 16 }}>
|
||||
<button onClick={archive}
|
||||
style={{ background: "none", border: 0, padding: 0, font: "inherit", fontSize: 13, fontWeight: 700, color: it.archived ? "var(--color-accent-700)" : "var(--color-neutral-700)", cursor: "pointer" }}>
|
||||
{it.archived ? "Put this garment back in the catalogue" : "Archive this garment"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* What we used to pay. CatalogItem.cost is a single field, so without this a price rise
|
||||
silently erased the old figure — and "what did these cost last year" is a question
|
||||
finance asks every year. */}
|
||||
{costs.length > 0 && (
|
||||
<>
|
||||
<MSection label="What it has cost" right="Changed by" />
|
||||
{costs.map((c) => (
|
||||
<div key={c.id} style={{ display: "flex", alignItems: "baseline", gap: 12, padding: "12px 16px", borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 17, minWidth: 78, fontVariantNumeric: "tabular-nums" }}>
|
||||
${c.cost.toFixed(2)}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0, fontSize: 12.5, color: "var(--color-neutral-700)" }}>
|
||||
{c.previous === null
|
||||
? "Opening price"
|
||||
: `${c.previous > c.cost ? "Down" : "Up"} from $${c.previous.toFixed(2)}`}
|
||||
{" · "}
|
||||
{/* The facility's zone, not the device's. A price change stamped at 09:00 in
|
||||
Perth is a different calendar day on a phone left set to Sydney, and this
|
||||
page is server-rendered first: with no zone pinned the server and the browser
|
||||
formatted the same instant differently and React threw the markup away. */}
|
||||
{formatInZone(c.at, s.tz)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--color-neutral-600)", whiteSpace: "nowrap" }}>{c.byName}</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{readOnly && <MNote>Only an admin can change the catalogue.</MNote>}
|
||||
</MBody>
|
||||
|
||||
{!readOnly && !it.archived && <MBar label="Done" glyph="check" tone="ink" onClick={() => router.push("/m/catalogue")} />}
|
||||
|
||||
{scanFor !== null && (
|
||||
<MScan
|
||||
title={`Barcode for ${it.sizes[scanFor]}`}
|
||||
onClose={() => setScanFor(null)}
|
||||
onHit={(raw) => { const si = scanFor; setScanFor(null); if (si !== null) bind(si, raw); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
/* Create a garment from the counter.
|
||||
*
|
||||
* The desktop form asks for everything at once, which is right when you are importing a range.
|
||||
* Here the only genuinely required things are a name and at least one size — the server enforces
|
||||
* exactly that — so everything else can be filled in later from the product card. A coordinator
|
||||
* with a new garment in one hand and a phone in the other should be able to make it exist in about
|
||||
* twenty seconds and scan it in.
|
||||
*
|
||||
* Sizes are entered as a run rather than one at a time, because that is how they arrive: a garment
|
||||
* comes in S-M-L-XL, not as four separate decisions. */
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import type { Item } from "@/lib/compute";
|
||||
import { INK, MBar, MBody, MError, MField, MNote, MRule, MTop, inputStyle } from "@/components/m";
|
||||
|
||||
const COMMON_RUNS: [string, string][] = [
|
||||
["XS S M L XL", "XS · S · M · L · XL"],
|
||||
["S M L XL 2XL", "S · M · L · XL · 2XL"],
|
||||
["8 10 12 14 16 18", "8 – 18"],
|
||||
["77R 82R 87R 92R", "77R – 92R"],
|
||||
];
|
||||
|
||||
export default function MCatalogueNew() {
|
||||
const { s, isAdmin, mutate, busy } = useSnap();
|
||||
const router = useRouter();
|
||||
|
||||
const [item, setItem] = useState("");
|
||||
const [type, setType] = useState("");
|
||||
const [group, setGroup] = useState("All");
|
||||
const [supplier, setSupplier] = useState("");
|
||||
const [sku, setSku] = useState("");
|
||||
const [cost, setCost] = useState("");
|
||||
const [sizeText, setSizeText] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
// Split on commas, slashes or whitespace so a run can be typed however it comes to hand.
|
||||
const sizes = useMemo(
|
||||
() => sizeText.split(/[,/\s]+/).map((x) => x.trim()).filter(Boolean),
|
||||
[sizeText],
|
||||
);
|
||||
const dupSize = useMemo(() => sizes.length !== new Set(sizes).size, [sizes]);
|
||||
|
||||
/* The facility's configured groups are the vocabulary; the register and the catalogue only ever
|
||||
* add to it.
|
||||
*
|
||||
* This used to be built from the groups already in USE, which made it impossible to put a garment
|
||||
* on a role nothing had used yet — the first Kitchen shirt could never be added from the counter,
|
||||
* because "Kitchen" only appeared in the list once a Kitchen garment existed. On a facility whose
|
||||
* register has not been imported yet it collapsed to "Anyone" and whatever one or two groups the
|
||||
* first few items happened to carry. The desktop dialog has always read settings.staffGroups;
|
||||
* this is the same field and now has the same source. The in-use ones are still folded in so a
|
||||
* group that predates the configured list, or arrived on a CSV import, does not vanish. */
|
||||
const groups = useMemo(() => {
|
||||
const set = new Set<string>(["All", ...s.settings.staffGroups]);
|
||||
for (const st of s.staff) if (st.group) set.add(st.group);
|
||||
for (const i of s.catalog) if (i.group) set.add(i.group);
|
||||
return [...set].sort();
|
||||
}, [s.staff, s.catalog, s.settings.staffGroups]);
|
||||
|
||||
const types = useMemo(() => [...new Set(s.catalog.map((i: Item) => i.type).filter(Boolean))].sort(), [s.catalog]);
|
||||
const suppliers = useMemo(() => s.supplierDir.map((x) => x.name).sort(), [s.supplierDir]);
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<>
|
||||
<MTop title="New garment" back />
|
||||
<MRule />
|
||||
<MBody><MNote tone="warn">Only an admin can add to the catalogue.</MNote></MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setErr("");
|
||||
if (!item.trim()) { setErr("Give the garment a name."); return; }
|
||||
if (!sizes.length) { setErr("Add at least one size."); return; }
|
||||
if (dupSize) { setErr("The same size is listed twice."); return; }
|
||||
const c = cost.trim() ? Number(cost) : 0;
|
||||
if (!(c >= 0) || Number.isNaN(c)) { setErr("Cost has to be a number, or left blank."); return; }
|
||||
|
||||
const r = await mutate<{ id: string }>("catalog.add", {
|
||||
item: item.trim(), type: type.trim(), group, supplier: supplier.trim(),
|
||||
sku: sku.trim(), cost: c, sizes,
|
||||
});
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
// Straight to the product card: the next thing anyone does is bind a barcode or set par.
|
||||
router.replace(`/m/catalogue/${r.result.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="New garment" back />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
|
||||
<MField label="Garment">
|
||||
<input value={item} onChange={(e) => { setItem(e.target.value); setErr(""); }}
|
||||
placeholder="Scrub top" autoCapitalize="words" enterKeyHint="next" style={inputStyle} />
|
||||
</MField>
|
||||
|
||||
<MField label="Sizes">
|
||||
<input value={sizeText} onChange={(e) => { setSizeText(e.target.value); setErr(""); }}
|
||||
placeholder="S M L XL" autoCapitalize="characters" autoCorrect="off" spellCheck={false} style={inputStyle} />
|
||||
</MField>
|
||||
<div style={{ padding: "0 16px 14px", display: "flex", flexWrap: "wrap", gap: 8 }}>
|
||||
{COMMON_RUNS.map(([run, pretty]) => (
|
||||
<button key={run} onClick={() => { setSizeText(run); setErr(""); }}
|
||||
style={{ border: "2px solid " + INK, background: "transparent", color: INK, padding: "8px 12px", fontSize: 12.5, fontWeight: 700, cursor: "pointer" }}>
|
||||
{pretty}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{sizes.length > 0 && (
|
||||
<div style={{ padding: "0 16px 14px", fontSize: 13, color: dupSize ? "var(--color-accent-700)" : "var(--color-neutral-700)", fontWeight: dupSize ? 700 : 400 }}>
|
||||
{dupSize ? "The same size is listed twice." : `${sizes.length} size${sizes.length === 1 ? "" : "s"}: ${sizes.join(" · ")}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MField label="Type">
|
||||
<input list="tc-types" value={type} onChange={(e) => setType(e.target.value)} placeholder="Scrub top" style={inputStyle} />
|
||||
<datalist id="tc-types">{types.map((t) => <option key={t} value={t} />)}</datalist>
|
||||
</MField>
|
||||
|
||||
<MField label="Who wears it">
|
||||
<select value={group} onChange={(e) => setGroup(e.target.value)} style={{ ...inputStyle, appearance: "none" }}>
|
||||
{groups.map((g) => <option key={g} value={g}>{g === "All" ? "Anyone" : g}</option>)}
|
||||
</select>
|
||||
</MField>
|
||||
|
||||
<MField label="Supplier">
|
||||
<input list="tc-suppliers" value={supplier} onChange={(e) => setSupplier(e.target.value)} placeholder="Optional" style={inputStyle} />
|
||||
<datalist id="tc-suppliers">{suppliers.map((x) => <option key={x} value={x} />)}</datalist>
|
||||
</MField>
|
||||
|
||||
<MField label="Supplier code">
|
||||
<input value={sku} onChange={(e) => setSku(e.target.value)} placeholder="Optional" autoCapitalize="characters" autoCorrect="off" spellCheck={false} style={inputStyle} />
|
||||
</MField>
|
||||
|
||||
<MField label="Unit cost">
|
||||
<input value={cost} onChange={(e) => { setCost(e.target.value); setErr(""); }}
|
||||
inputMode="decimal" placeholder="0.00" style={inputStyle} />
|
||||
</MField>
|
||||
|
||||
<MNote>
|
||||
Barcodes, par levels and opening stock are set on the product card once this exists — it
|
||||
is quicker to scan a garment in than to type its code.
|
||||
</MNote>
|
||||
</MBody>
|
||||
<MBar label={busy ? "Saving…" : "Create garment"} onClick={save} disabled={busy} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
/* The catalogue on the phone.
|
||||
*
|
||||
* This used to be one of the rows under "On the desktop" — listed, greyed out, untappable, with
|
||||
* the note that adding garments is a sit-down job. It genuinely is, for a bulk import of two
|
||||
* hundred lines. It is not for the thing that actually happens in a linen room: a new garment
|
||||
* turns up at the counter and needs to exist before it can be scanned in.
|
||||
*
|
||||
* So this is the whole catalogue, not just what's on the shelf — /m/stock deliberately shows only
|
||||
* variants with history, which means a garment created five minutes ago wouldn't appear there. */
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { label, type Item } from "@/lib/compute";
|
||||
import { INK, IconPlus, MBody, MEmpty, MNav, MNote, MRow, MRule, MSection, MTop, inputStyle } from "@/components/m";
|
||||
|
||||
export default function MCatalogue() {
|
||||
const { s, isAdmin } = useSnap();
|
||||
const { byId } = useDerived();
|
||||
const [q, setQ] = useState("");
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
return s.catalog
|
||||
.filter((i: Item) => (showArchived ? i.archived : !i.archived))
|
||||
.map((i: Item) => ({
|
||||
...i,
|
||||
name: label(byId[i.id] ?? i),
|
||||
sub: [i.sizes.length ? `${i.sizes.length} size${i.sizes.length === 1 ? "" : "s"}` : "No sizes yet", i.supplier || "No supplier", i.sku].filter(Boolean).join(" · "),
|
||||
}))
|
||||
.filter((i) => !needle || `${i.name} ${i.sku} ${i.supplier} ${i.type} ${i.group}`.toLowerCase().includes(needle))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [s.catalog, byId, q, showArchived]);
|
||||
|
||||
const archivedCount = s.catalog.filter((i: Item) => i.archived).length;
|
||||
const addLink: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", gap: 12, minHeight: 64, padding: "0 20px",
|
||||
border: "2px solid " + INK, color: INK, textDecoration: "none",
|
||||
fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase",
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Catalogue" right={`${rows.length} item${rows.length === 1 ? "" : "s"}`} back />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ padding: 16, borderBottom: "2px solid " + INK }}>
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Garment, code, supplier" aria-label="Filter the catalogue" style={inputStyle} />
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div style={{ padding: 16, borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<Link href="/m/catalogue/new" style={addLink}>
|
||||
<IconPlus /><span style={{ flex: 1 }}>New garment</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MSection label={showArchived ? "Archived" : "Garments"} right={isAdmin ? "Tap to edit" : undefined} />
|
||||
{rows.length === 0
|
||||
? <MEmpty
|
||||
title={q ? "Nothing matches" : showArchived ? "Nothing archived" : "No garments yet"}
|
||||
sub={q ? "Try a shorter search." : isAdmin ? "Add the first one and it can be scanned in straight away." : "An admin sets the catalogue up."} />
|
||||
: rows.slice(0, 300).map((i) => (
|
||||
<MRow
|
||||
key={i.id}
|
||||
href={isAdmin ? `/m/catalogue/${i.id}` : undefined}
|
||||
mark={i.archived ? "mute" : "ink"}
|
||||
title={i.name}
|
||||
sub={i.sub}
|
||||
right={<span style={{ fontSize: 12, color: "var(--color-neutral-600)" }}>{i.cost ? `$${i.cost.toFixed(2)}` : ""}</span>}
|
||||
/>
|
||||
))}
|
||||
|
||||
{archivedCount > 0 && (
|
||||
<div style={{ padding: 16 }}>
|
||||
<button
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
style={{ background: "none", border: 0, padding: 0, font: "inherit", fontSize: 13, fontWeight: 700, color: "var(--color-accent-700)", cursor: "pointer" }}>
|
||||
{showArchived ? "Back to the current catalogue" : `Show ${archivedCount} archived`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAdmin && <MNote>Only an admin can change the catalogue. You can still see what exists.</MNote>}
|
||||
</MBody>
|
||||
<MNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
/* Counting — the screen the app exists for. Scan a garment, the active line goes up by one.
|
||||
Expected quantities stay visible throughout: this is a sighted count, not a blind one.
|
||||
The tally lives in localStorage, so backgrounding the app mid-shelf loses nothing. */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { bcBound, locMap, locSubtree, locUnder, onhand, touched, UNPLACED, variantName } from "@/lib/compute";
|
||||
import MScan from "@/components/MScan";
|
||||
import { scanReject } from "@/lib/feedback";
|
||||
import { track } from "@/lib/analytics";
|
||||
import { useKeepAwake } from "@/lib/wakelock";
|
||||
import { INK, MAction, MBody, MEmpty, MError, MFigures, MInkLink, MPanel, MRow, MRule, MSection, MSplit, MTop, ON_DARK, inputStyle } from "@/components/m";
|
||||
import { readCount, writeCount } from "@/lib/opencount";
|
||||
|
||||
export default function MCounting() {
|
||||
const { s } = useSnap();
|
||||
const { L, byId, variants } = useDerived();
|
||||
const router = useRouter();
|
||||
const locationId = String(useParams().id || "");
|
||||
const locs = useMemo(() => locMap(s), [s]);
|
||||
const loc = locs[locationId];
|
||||
const locName = locationId === UNPLACED ? "Not on a shelf" : loc?.name || "Location";
|
||||
|
||||
// The lines on this shelf, in catalogue order.
|
||||
//
|
||||
// Being placed on the shelf is enough to be countable: a size placed from the desktop but never
|
||||
// stocked has no history at all, and filtering it out meant the six of them you have just found
|
||||
// on the shelf could not be counted in from the count that found them. The unplaced bucket still
|
||||
// needs the history test, or it would be the whole catalogue.
|
||||
//
|
||||
// The variance screen repeats this test verbatim, and the two have to keep listing the same
|
||||
// lines: anything countable here but missing there is counted on the phone and then dropped at
|
||||
// commit, with the tally cleared behind it and nothing said.
|
||||
const lines = useMemo(() => {
|
||||
const sub = locationId === UNPLACED ? null : locSubtree(s, locationId);
|
||||
return variants
|
||||
// A bound barcode counts as much as stock history does. Somebody stood at the counter with
|
||||
// the garment in one hand and scanned its label onto that size — that is a stronger statement
|
||||
// that the size physically exists than a stock figure, which on a room being set up is
|
||||
// precisely what nobody has yet. Without this the first count after building a catalogue can
|
||||
// reach nothing at all: every size is unplaced and untouched, so the list is empty and every
|
||||
// scan is refused as belonging somewhere else.
|
||||
.filter((v) => (sub ? sub.has(s.placed[v.key] || "") : !s.placed[v.key] && (touched(s, L, v.key) || !!bcBound(s, v.item, v.si))))
|
||||
.map((v) => ({ ...v, expected: onhand(s, L, v.key), code: bcBound(s, v.item, v.si), where: locUnder(locs, s.placed[v.key], locationId) }));
|
||||
}, [s, L, variants, locationId, locs]);
|
||||
|
||||
const [counted, setCounted] = useState<Record<string, number>>({});
|
||||
// The line being counted is held by its variant key, never by its position in `lines`. The list
|
||||
// is rebuilt on every live refresh, and a size sorting earlier in the catalogue being inserted
|
||||
// ahead of it would leave an index pointing at the neighbouring garment: the next Undo would then
|
||||
// take one off a line that was counted correctly and leave the double-scan where it was.
|
||||
const [activeKey, setActiveKey] = useState("");
|
||||
const [scan, setScan] = useState<null | "single" | "live">(null);
|
||||
const [live, setLive] = useState(false);
|
||||
const [log, setLog] = useState<string[]>([]);
|
||||
const [manual, setManual] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Restore this person's open count of this shelf. The tally is keyed on the signed-in user as
|
||||
// well as the location: the phone is shared, and resuming somebody else's abandoned count under
|
||||
// your own name is worse than starting again.
|
||||
//
|
||||
// It reads once per shelf and deliberately does not re-run on `lines`. The list is rebuilt on
|
||||
// every live refresh, and rebuilding the tally from it dropped any key that had just left this
|
||||
// shelf — the coordinator placing a size from the desktop while the trolley is being counted —
|
||||
// which the write below then made permanent. The garments the counter had already found went
|
||||
// with it, silently. Counts are held by key whether or not the key is still listed here.
|
||||
const me = s.session.userId;
|
||||
useEffect(() => {
|
||||
setCounted(readCount(me, locationId)?.n ?? {});
|
||||
setLoaded(true);
|
||||
}, [me, locationId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded) return;
|
||||
writeCount(me, locationId, counted);
|
||||
}, [counted, me, locationId, loaded]);
|
||||
|
||||
useKeepAwake(true);
|
||||
|
||||
const total = lines.reduce((t, l) => t + (counted[l.key] ?? 0), 0);
|
||||
const expectedAll = lines.reduce((t, l) => t + l.expected, 0);
|
||||
const cur = lines.find((l) => l.key === activeKey) || lines[0];
|
||||
|
||||
const bump = useCallback((k: string, by: number) => {
|
||||
setCounted((c) => ({ ...c, [k]: Math.max(0, (c[k] ?? 0) + by) }));
|
||||
}, []);
|
||||
|
||||
/** A scanned code lands on its own line, whichever line was active — the barcode is the truth. */
|
||||
const onCode = useCallback((raw: string) => {
|
||||
const code = raw.trim();
|
||||
const hit = s.barcodes[code];
|
||||
const ix = hit ? lines.findIndex((l) => l.key === hit) : lines.findIndex((l) => l.code === code);
|
||||
if (ix < 0) {
|
||||
scanReject();
|
||||
const known = Object.prototype.hasOwnProperty.call(s.barcodes, code);
|
||||
// Never the barcode itself — only whether ThreadCount knew it. "unknown" in volume means
|
||||
// labels are being printed outside the catalogue.
|
||||
track("scan_miss", { kind: known ? "wrong_shelf" : "unknown" });
|
||||
/* "Somewhere else" is only true when it IS somewhere. A code bound to a size that has never
|
||||
been placed and never been stocked is on no shelf at all, and telling somebody to go and
|
||||
look for it elsewhere sends them hunting for a garment nothing has ever recorded. Say which
|
||||
of the two it is, and name the shelf when there is one to name. */
|
||||
const placedAt = hit ? locs[s.placed[hit] || ""]?.name : "";
|
||||
setErr(!known ? `${code} isn’t a garment ThreadCount knows. Bind it to a size first — you can type it in on the garment’s page.`
|
||||
: placedAt ? `${code} is on ${placedAt}, not this shelf.`
|
||||
: `${code} isn’t in this count. It hasn’t been placed on a shelf, so it sits under “Not on a shelf”.`);
|
||||
setLog((g) => [`${code} — not on this shelf`, ...g]);
|
||||
return;
|
||||
}
|
||||
setActiveKey(lines[ix].key);
|
||||
bump(lines[ix].key, 1);
|
||||
setErr("");
|
||||
setLog((g) => [`${variantName(byId[lines[ix].itemId], lines[ix].size)}`, ...g].slice(0, 8));
|
||||
}, [s.barcodes, lines, bump, byId]);
|
||||
|
||||
if (loaded && !lines.length) {
|
||||
return (
|
||||
<>
|
||||
<MTop title={locName} back />
|
||||
<MRule />
|
||||
<MBody><MEmpty title="Nothing on this shelf" sub="No garment has been placed here yet. Place sizes against a location from Inventory on the desktop, then come back." /></MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title={locName} right={`${total} / ${expectedAll}`} back />
|
||||
<MRule n={total} of={expectedAll} />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
|
||||
{cur && (
|
||||
<MPanel kicker="Now counting" kickerRight={<MInkLink label="Hands-free" onClick={() => { setScan("live"); setLive(true); }} />}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 26, letterSpacing: "-0.02em", lineHeight: 1.1 }}>
|
||||
{variantName(byId[cur.itemId], cur.size)}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: ON_DARK, marginTop: 6 }}>
|
||||
{[cur.code || (cur.item.sku ? `SKU ${cur.item.sku}` : "No barcode bound"), cur.where].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
<MFigures counted={counted[cur.key] ?? 0} expected={cur.expected} />
|
||||
</MPanel>
|
||||
)}
|
||||
|
||||
<MSplit>
|
||||
<MAction label="Scan" flex={2} glyph="scan" onClick={() => setScan("single")} />
|
||||
<MAction label="Undo" flex={1} tone="grey" onClick={() => cur && bump(cur.key, -1)} disabled={!cur || (counted[cur.key] ?? 0) <= 0} />
|
||||
</MSplit>
|
||||
|
||||
<MBody>
|
||||
<div ref={listRef}>
|
||||
<MSection label="Lines" right="Counted / expected" />
|
||||
{lines.map((l) => {
|
||||
const n = counted[l.key] ?? 0;
|
||||
const on = !!cur && l.key === cur.key;
|
||||
return (
|
||||
<MRow key={l.key} onClick={() => setActiveKey(l.key)} attention={on}
|
||||
mark={on ? "accent" : n === l.expected ? "ink" : "mute"}
|
||||
title={`${variantName(byId[l.itemId], l.size)}`}
|
||||
sub={[l.code || (l.item.sku ? `SKU ${l.item.sku}` : "No barcode bound"), l.where].filter(Boolean).join(" · ")}
|
||||
right={
|
||||
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, fontVariantNumeric: "tabular-nums" }}>
|
||||
{/* The expected figure is the whole point of the row, so it is readable ink,
|
||||
not the near-invisible neutral-400 it used to be drawn in. */}
|
||||
{n}<span style={{ color: "var(--color-neutral-700)" }}>/{l.expected}</span>
|
||||
</span>
|
||||
} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 16 }}>
|
||||
{manual && cur ? (
|
||||
<div style={{ border: "2px solid " + INK, background: "#fff", padding: 16 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-neutral-600)" }}>Counted for {variantName(byId[cur.itemId], cur.size)}</div>
|
||||
{/* Keyed on the line so the box is rebuilt when the counter taps a different one. An
|
||||
uncontrolled input keeps its first value, so it went on showing the figure typed
|
||||
for the previous line under the new line's heading — read as "counted at 7", the
|
||||
new line was then committed at 0 and the gap blamed on the shelf. */}
|
||||
<input key={cur.key} type="number" inputMode="numeric" min={0} defaultValue={counted[cur.key] ?? 0} autoFocus style={{ ...inputStyle, marginTop: 8 }}
|
||||
onChange={(e) => setCounted((c) => ({ ...c, [cur.key]: Math.max(0, parseInt(e.target.value || "0", 10) || 0) }))} />
|
||||
<button onClick={() => setManual(false)} style={{ marginTop: 12, minHeight: 44, width: "100%", border: "2px solid " + INK, background: "transparent", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer" }}>Done</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setManual(true)} style={{ background: "none", border: 0, padding: "8px 0", color: "var(--color-accent-700)", fontSize: 14, fontWeight: 600, textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>
|
||||
Type a count instead — for a label that won’t scan
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</MBody>
|
||||
|
||||
<MAction label="Finish count" glyph="none" onClick={() => router.push(`/m/count/${locationId}/variance`)} />
|
||||
|
||||
{scan && (
|
||||
<MScan
|
||||
title="Scan a garment"
|
||||
live={scan === "live"}
|
||||
running={live}
|
||||
onToggle={() => setLive((v) => !v)}
|
||||
log={log}
|
||||
onHit={(raw) => { onCode(raw); if (scan === "single") setScan(null); }}
|
||||
onClose={() => { setScan(null); setLive(false); }}
|
||||
figure={scan === "live" && cur ? (
|
||||
<MPanel pad={14}>
|
||||
<div style={{ fontSize: 13, color: ON_DARK }}>{variantName(byId[cur.itemId], cur.size)}</div>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 14, marginTop: 4 }}>
|
||||
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 40, lineHeight: 1, fontVariantNumeric: "tabular-nums" }}>{counted[cur.key] ?? 0}</span>
|
||||
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 22, color: "var(--color-neutral-300)", fontVariantNumeric: "tabular-nums" }}>{cur.expected}</span>
|
||||
<span style={{ marginLeft: "auto", fontSize: 12, color: ON_DARK }}>{total} / {expectedAll} on this shelf</span>
|
||||
</div>
|
||||
</MPanel>
|
||||
) : undefined}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
/* Variance — only the lines that don't match, what happens when the count commits, and the commit.
|
||||
A gap at or over the facility's threshold has to carry a reason before anything is filed. */
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { UNPLACED, bcBound, formatInZone, locMap, locSubtree, locUnder, onhand, reorderAt, touched, variantName } from "@/lib/compute";
|
||||
import { INK, MBar, MBody, MEmpty, MError, MRule, MTop, MPanel, MInkLink } from "@/components/m";
|
||||
import { clearCount, readCount } from "@/lib/opencount";
|
||||
|
||||
const REASONS = ["At laundry", "Condemned", "Missing", "Other"];
|
||||
|
||||
export default function MVariance() {
|
||||
const { s, mutate, busy } = useSnap();
|
||||
const { L, byId, variants } = useDerived();
|
||||
const router = useRouter();
|
||||
const locationId = String(useParams().id || "");
|
||||
const locs = useMemo(() => locMap(s), [s]);
|
||||
const locName = locationId === UNPLACED ? "Not on a shelf" : locs[locationId]?.name || "Location";
|
||||
|
||||
const lines = useMemo(() => {
|
||||
const sub = locationId === UNPLACED ? null : locSubtree(s, locationId);
|
||||
// Exactly the set the counting screen lists, and it has to stay the same test. A placed size
|
||||
// counts even with no history, and so does an unplaced size with a barcode bound to it —
|
||||
// somebody stood at the counter and scanned that label onto that size, which is why the
|
||||
// counting screen lets you count it. Leave that arm off here and a size counted on the phone
|
||||
// has no row on this screen and no line in the payload: committing files a stocktake without
|
||||
// it, the garments found on the trolley are never counted in, and clearCount() then wipes the
|
||||
// tally that was the only record they had been found.
|
||||
return variants
|
||||
.filter((v) => (sub ? sub.has(s.placed[v.key] || "") : !s.placed[v.key] && (touched(s, L, v.key) || !!bcBound(s, v.item, v.si))))
|
||||
.map((v) => ({ ...v, expected: onhand(s, L, v.key), code: bcBound(s, v.item, v.si), where: locUnder(locs, s.placed[v.key], locationId) }));
|
||||
}, [s, L, variants, locationId, locs]);
|
||||
|
||||
const [counted, setCounted] = useState<Record<string, number> | null>(null);
|
||||
const [savedAt, setSavedAt] = useState("");
|
||||
const [reason, setReason] = useState<Record<string, string>>({});
|
||||
const [accepted, setAccepted] = useState<Record<string, boolean>>({});
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
// The tally belongs to the person who took it, so it is read back under their own key — the
|
||||
// counting screen writes it under theirs. When it was taken matters as much as what it says:
|
||||
// a count resumed the next morning has had a night of issuing against it, and the screen should
|
||||
// say when it was last touched rather than present a stale tally as if it were fresh.
|
||||
const me = s.session.userId;
|
||||
useEffect(() => {
|
||||
const open = readCount(me, locationId);
|
||||
setCounted(open?.n ?? {});
|
||||
setSavedAt(open?.savedAt ?? "");
|
||||
}, [me, locationId]);
|
||||
|
||||
const gate = Math.max(1, s.settings.varianceReason);
|
||||
const off = useMemo(() => (counted ? lines.filter((l) => (counted[l.key] ?? 0) !== l.expected) : []), [counted, lines]);
|
||||
const totalCounted = counted ? lines.reduce((t, l) => t + (counted[l.key] ?? 0), 0) : 0;
|
||||
const totalExpected = lines.reduce((t, l) => t + l.expected, 0);
|
||||
const needsReason = off.filter((l) => Math.abs((counted?.[l.key] ?? 0) - l.expected) >= gate && !reason[l.key]);
|
||||
|
||||
// What the shelf will look like once this commits — not what the commit does. Committing a count
|
||||
// writes stock adjustments and the stocktake itself and nothing else; the reorder draft is a
|
||||
// separate, deliberate step on Reorder, which is where the quantities can still be changed
|
||||
// before anything goes to a supplier.
|
||||
const willReorder = useMemo(() => {
|
||||
if (!counted) return { lines: 0, units: 0 };
|
||||
let n = 0, units = 0;
|
||||
for (const l of lines) {
|
||||
const after = counted[l.key] ?? 0;
|
||||
const par = reorderAt(s, l.key);
|
||||
if (after <= par && l.expected > par) { n++; units += Math.max(0, par * 2 - after); }
|
||||
}
|
||||
return { lines: n, units };
|
||||
}, [counted, lines, s]);
|
||||
|
||||
const commit = useCallback(async () => {
|
||||
if (!counted) return;
|
||||
if (needsReason.length) { setErr(`A gap of ${gate} or more needs a reason — ${needsReason.length} line${needsReason.length === 1 ? "" : "s"} still to go.`); return; }
|
||||
const payload = lines.map((l) => ({ itemId: l.itemId, si: l.si, counted: counted[l.key] ?? 0, reason: reason[l.key] || "" }));
|
||||
const r = await mutate("stocktake.apply", { lines: payload, mode: "shelf", locationId: locationId === UNPLACED ? "" : locationId });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
clearCount(me, locationId);
|
||||
// A count that leaves lines below par hands straight over to Reorder. Nothing is drafted by
|
||||
// the commit itself, and a count that ends on the home screen is a count whose shortfall
|
||||
// nobody ever goes back for.
|
||||
router.push(willReorder.lines > 0 ? "/m/reorder" : "/m?counted=1");
|
||||
}, [counted, lines, reason, needsReason.length, gate, mutate, me, locationId, router, willReorder.lines]);
|
||||
|
||||
if (!counted) return (<><MTop title="Variance" back /><MRule /><MBody /></>);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Variance" back />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody>
|
||||
<div style={{ padding: "20px 16px 22px", borderBottom: "2px solid " + INK }}>
|
||||
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 30, letterSpacing: "-0.03em", lineHeight: 1.05 }}>
|
||||
{off.length === 0 ? "Everything matches" : `${off.length} line${off.length === 1 ? "" : "s"} don’t match`}
|
||||
</h2>
|
||||
<p style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 8 }}>{locName} · counted {totalCounted} of {totalExpected} expected</p>
|
||||
{savedAt && (
|
||||
<p style={{ fontSize: 13, color: "var(--color-neutral-700)", marginTop: 4 }}>
|
||||
Tallied {formatInZone(savedAt, s.tz, { day: "numeric", month: "short", hour: "numeric", minute: "2-digit" })}.
|
||||
{" "}Anything issued since then is already off the expected figure.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{off.length === 0 ? (
|
||||
<MEmpty title="No gaps to explain" sub="Every line came out at what the system expected. Commit the count to file it against this shelf." />
|
||||
) : off.map((l) => {
|
||||
const n = counted[l.key] ?? 0;
|
||||
const d = n - l.expected;
|
||||
const big = Math.abs(d) >= gate;
|
||||
return (
|
||||
<div key={l.key} style={{ padding: 16, background: "#fff", borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, letterSpacing: "-0.02em" }}>{variantName(byId[l.itemId], l.size)}</div>
|
||||
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 4 }}>{[l.code || (l.item.sku ? `SKU ${l.item.sku}` : "No barcode bound"), l.where].filter(Boolean).join(" · ")}</div>
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em", color: "var(--color-accent-700)", fontVariantNumeric: "tabular-nums" }}>{d > 0 ? `+${d}` : `−${-d}`}</div>
|
||||
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 2 }}>{n} of {l.expected}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 14 }}>
|
||||
<button onClick={() => router.push(`/m/count/${locationId}`)}
|
||||
style={{ flex: 1, minHeight: 44, border: "2px solid " + INK, background: "transparent", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer" }}>Recount</button>
|
||||
<button onClick={() => setAccepted((a) => ({ ...a, [l.key]: !a[l.key] }))} aria-pressed={!!accepted[l.key]}
|
||||
style={{ flex: 1, minHeight: 44, border: "2px solid " + INK, background: accepted[l.key] ? INK : "transparent", color: accepted[l.key] ? "var(--color-bg)" : INK, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer" }}>
|
||||
{accepted[l.key] ? "Accepted" : "Accept"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{big && (
|
||||
<div style={{ marginTop: 14, padding: 14, background: "var(--color-bg)" }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-accent-700)" }}>
|
||||
A gap of {gate} or more needs a reason
|
||||
</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 10 }}>
|
||||
{REASONS.map((r) => {
|
||||
const on = reason[l.key] === r;
|
||||
return (
|
||||
<button key={r} onClick={() => setReason((x) => ({ ...x, [l.key]: on ? "" : r }))} aria-pressed={on}
|
||||
style={{ minHeight: 44, padding: "0 14px", border: "2px solid " + INK, background: on ? INK : "transparent", color: on ? "var(--color-bg)" : INK, fontSize: 13, fontWeight: 700, cursor: "pointer" }}>{r}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div style={{ padding: 16 }}>
|
||||
<MPanel kicker="After this count">
|
||||
<p style={{ fontSize: 14, lineHeight: 1.6, margin: 0 }}>
|
||||
{willReorder.lines === 0
|
||||
? "Nothing falls below par when this commits, so there is nothing to reorder."
|
||||
: `${willReorder.lines} line${willReorder.lines === 1 ? "" : "s"} will be below par once this count commits — about ${willReorder.units} item${willReorder.units === 1 ? "" : "s"} to order. Committing orders nothing on its own: it takes you to Reorder, where you raise the draft.`}
|
||||
</p>
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, marginTop: 10, color: "var(--color-neutral-400)" }}>Nothing is sent to a supplier without approval.</p>
|
||||
{willReorder.lines > 0 && <div style={{ marginTop: 14 }}><MInkLink label="Reorder" href="/m/reorder" /></div>}
|
||||
</MPanel>
|
||||
</div>
|
||||
</MBody>
|
||||
<MBar label={busy ? "Committing…" : "Commit count"} glyph="check" onClick={commit} disabled={busy || needsReason.length > 0}
|
||||
sub={needsReason.length ? `${needsReason.length} gap${needsReason.length === 1 ? " still needs" : "s still need"} a reason` : undefined} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
/* Stocktake — choose what you're counting. One row per location that actually holds garments,
|
||||
plus everything not yet placed, so nothing on the shelf is uncountable. */
|
||||
import { useMemo } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { UNPLACED, bcBound, daysBetween, locSubtree, locTree, onhand, touched } from "@/lib/compute";
|
||||
import { INK, MBody, MEmpty, MNav, MNote, MRow, MRule, MSection, MTop } from "@/components/m";
|
||||
|
||||
/* "Last counted 0 days ago" and "1 days ago" are how a shelf counted this morning used to read. */
|
||||
function lastCounted(last: string | undefined, today: string): string {
|
||||
if (!last) return "Never counted";
|
||||
const n = daysBetween(last, today);
|
||||
if (n <= 0) return "Counted today";
|
||||
if (n === 1) return "Counted yesterday";
|
||||
return `Last counted ${n} days ago`;
|
||||
}
|
||||
|
||||
export default function MCountStart() {
|
||||
const { s } = useSnap();
|
||||
const { L, variants } = useDerived();
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const lastAt: Record<string, string> = {};
|
||||
for (const t of s.stocktakes) if (t.mode !== "preloved" && t.locationId && !lastAt[t.locationId]) lastAt[t.locationId] = t.date;
|
||||
const out = locTree(s).map(({ loc, depth }) => {
|
||||
const sub = locSubtree(s, loc.id);
|
||||
// Placed on the shelf is enough to make a shelf countable. A location holding only sizes
|
||||
// that have never been stocked is exactly the shelf someone needs to count in.
|
||||
const mine = variants.filter((v) => sub.has(s.placed[v.key] || ""));
|
||||
return { id: loc.id, name: loc.name, kind: loc.kind, depth, lines: mine.length, units: mine.reduce((t, v) => t + onhand(s, L, v.key), 0), last: lastAt[loc.id] as string | undefined };
|
||||
}).filter((r) => r.lines > 0);
|
||||
// Only the unplaced bucket needs a test at all — without one it would list the whole
|
||||
// catalogue. A bound barcode counts as much as stock history does: somebody stood at the
|
||||
// counter with the garment in hand and scanned its label onto that size, which says the size
|
||||
// physically exists even when no stock figure does. The counting and variance screens filter
|
||||
// the unplaced bucket with exactly this expression and all three have to agree — a room whose
|
||||
// unplaced sizes are all barcode-bound and never yet stocked otherwise gets no "Not on a shelf
|
||||
// yet" row here, and the one screen that could count them in is unreachable from the menu.
|
||||
const loose = variants.filter((v) => !s.placed[v.key] && (touched(s, L, v.key) || !!bcBound(s, v.item, v.si)));
|
||||
if (loose.length) out.push({ id: UNPLACED, name: "Not on a shelf yet", kind: "", depth: 0, lines: loose.length, units: loose.reduce((t, v) => t + onhand(s, L, v.key), 0), last: undefined });
|
||||
return out;
|
||||
}, [s, L, variants]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Stocktake" right={`${rows.length} location${rows.length === 1 ? "" : "s"}`} />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ padding: "20px 16px 22px", borderBottom: "2px solid " + INK }}>
|
||||
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 30, letterSpacing: "-0.03em", lineHeight: 1.05 }}>Where are you counting?</h2>
|
||||
<p style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 10, lineHeight: 1.6 }}>
|
||||
Scan every garment on the shelf. Each scan adds one to that line, and the expected figure stays on screen the whole way.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<MEmpty
|
||||
title="Nothing to count yet"
|
||||
sub="A location shows up here once garments are placed on it. Set your shelves up in Settings on the desktop, then place each size against one."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<MSection label="Locations" right="Lines · units" />
|
||||
{rows.map((r) => (
|
||||
<MRow key={r.id} href={`/m/count/${r.id}`} mark={r.id === UNPLACED ? "mute" : "ink"}
|
||||
title={<span style={{ paddingLeft: r.depth * 14 }}>{r.name}</span>}
|
||||
sub={<span style={{ paddingLeft: r.depth * 14 }}>{lastCounted(r.last, s.today)}</span>}
|
||||
right={<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, fontVariantNumeric: "tabular-nums" }}>{r.lines} · {r.units}</span>} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<MNote>A count stays open until you commit it, so you can put the phone down halfway along a shelf and pick it up again.</MNote>
|
||||
</MBody>
|
||||
<MNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
/* Issue — 1B, person first. Their sizes are already known, so the list is what they'd normally take;
|
||||
scanning adds anything else. What they may hold and the manager’s approval are both checked before
|
||||
the bag is handed over, not after. */
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { approvalRemaining, capCheck, garmentForGroup, garmentForStyle, genderLabel, groupBucket, groupsLabel, inBucket, initialRemaining, isNursing, isPantItem, isTopItem, label, money, onhand, sizeIndexOf, splitKey, variantName } from "@/lib/compute";
|
||||
import MScan from "@/components/MScan";
|
||||
import { INK, MBar, MBody, MEmpty, MError, MRow, MRule, MSection, MTop, MStepper } from "@/components/m";
|
||||
import { MEntitlement, MPersonHead, useHeld } from "@/components/MPerson";
|
||||
|
||||
type Line = { key: string; itemId: string; si: number; size: string; name: string; qty: number; cost: number; onHand: number };
|
||||
|
||||
export default function MIssue() {
|
||||
const { s, mutate, busy } = useSnap();
|
||||
const { L, byId } = useDerived();
|
||||
const router = useRouter();
|
||||
const id = String(useParams().staffId || "");
|
||||
const st = s.staff.find((x) => x.id === id);
|
||||
const held = useHeld(s, id);
|
||||
|
||||
const [cart, setCart] = useState<Line[]>([]);
|
||||
const [scan, setScan] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [override, setOverride] = useState(false);
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
|
||||
/* What this person would normally be handed: their group’s garments, in the cut they are offered,
|
||||
in their recorded size. Both questions are the server's own — a rule written again here would
|
||||
suggest a garment the counter then refuses. Blank and Either are offered every cut. */
|
||||
const suggested = useMemo(() => {
|
||||
if (!st) return [];
|
||||
const bucket = groupBucket(st.group);
|
||||
const out: Line[] = [];
|
||||
for (const it of s.catalog) {
|
||||
if (it.archived) continue;
|
||||
if (bucket && !inBucket(it, bucket)) continue;
|
||||
if (!garmentForStyle(it, st.uniformStyle)) continue;
|
||||
const want = isTopItem(it) ? st.top : isPantItem(it) ? st.pants : "";
|
||||
const si = want ? sizeIndexOf(it, want) : -1;
|
||||
if (si < 0) continue;
|
||||
const k = `${it.id}:${si}`;
|
||||
out.push({ key: k, itemId: it.id, si, size: String(it.sizes[si]), name: `${variantName(it, it.sizes[si])}`, qty: 1, cost: it.cost, onHand: onhand(s, L, k) });
|
||||
}
|
||||
return out;
|
||||
}, [s, st, L]);
|
||||
|
||||
const inCart = useCallback((k: string) => cart.find((c) => c.key === k), [cart]);
|
||||
const add = useCallback((l: Line) => {
|
||||
setErr("");
|
||||
setCart((c) => {
|
||||
const at = c.findIndex((x) => x.key === l.key);
|
||||
if (at < 0) return [...c, { ...l, qty: 1 }];
|
||||
const next = [...c]; next[at] = { ...next[at], qty: next[at].qty + 1 }; return next;
|
||||
});
|
||||
}, []);
|
||||
const setQty = useCallback((k: string, n: number) => setCart((c) => (n <= 0 ? c.filter((x) => x.key !== k) : c.map((x) => (x.key === k ? { ...x, qty: n } : x)))), []);
|
||||
|
||||
const onCode = useCallback((raw: string) => {
|
||||
const k = s.barcodes[raw.trim()];
|
||||
if (!k) { setErr(`${raw.trim()} isn’t a garment ThreadCount knows.`); return; }
|
||||
const { itemId, si } = splitKey(k);
|
||||
const it = byId[itemId];
|
||||
if (!it || it.archived) { setErr("That garment is discontinued."); return; }
|
||||
add({ key: k, itemId, si, size: String(it.sizes[si]), name: `${variantName(it, it.sizes[si])}`, qty: 1, cost: it.cost, onHand: onhand(s, L, k) });
|
||||
}, [s, byId, L, add]);
|
||||
|
||||
if (!st) return (<><MTop title="Issue" back /><MRule /><MBody><MEmpty title="No such staff member" /></MBody></>);
|
||||
|
||||
const cartQty = cart.reduce((t, c) => t + c.qty, 0);
|
||||
const heldQty = held.reduce((t, h) => t + h.qty, 0);
|
||||
const total = cart.reduce((t, c) => t + c.qty * c.cost, 0);
|
||||
const nursing = isNursing(s, st);
|
||||
/* The one question this screen asks: after this bag, is this person still inside the six sets one
|
||||
person holds? Six at any time, every group, nursing included — so the sum is what they have out
|
||||
now plus what is on the counter, and nothing in it starts again in July. It is the server's own
|
||||
function, so the warning here and the refusal there cannot drift apart; the last time this screen
|
||||
kept a private copy of the sum it demanded a tick the server never wanted. */
|
||||
const cap = capCheck(s, st, cart);
|
||||
const over = cap.over;
|
||||
/* Garments in the cart that are not for this person's staff group, and garments that are not the
|
||||
cut they are offered. The server refuses either without the coordinator override, and records
|
||||
them as outside the group or outside the style rather than as over the ceiling, so the same tick
|
||||
is offered for any of the three reasons. garmentForGroup() and garmentForStyle() are the
|
||||
server's own questions, asked here so the screen and the refusal cannot drift apart. */
|
||||
const cartItems = [...new Set(cart.map((c) => c.itemId))].map((iid) => byId[iid])
|
||||
.filter((it): it is NonNullable<typeof it> => !!it);
|
||||
const offGroup = cartItems.filter((it) => !garmentForGroup(it, st.group));
|
||||
const offStyle = cartItems.filter((it) => !garmentForStyle(it, st.uniformStyle));
|
||||
/* One refusal naming every reason that applies, composed as the server composes it: a clause per
|
||||
reason, the ceiling among them, and the sentence about the tick once at the end, because one
|
||||
tick answers all of them. A message that named the first and stopped would have the coordinator
|
||||
tick for that and wave the rest through without anybody having been told about them. The count
|
||||
is of distinct garments across both lists — one garment wrong on both counts is still "it". */
|
||||
const wrongCount = new Set([...offGroup, ...offStyle].map((it) => it.id)).size;
|
||||
const wrongNote = wrongCount
|
||||
? `${[
|
||||
offGroup.length ? `${offGroup.map((it) => `${it.item} is for ${groupsLabel(it.groups)}`).join("; ")} — ${(st.group || "").trim() ? `${st.first} ${st.last} is in ${st.group.trim()}` : `${st.first} ${st.last} has no staff group recorded`}` : "",
|
||||
offStyle.length ? `${offStyle.map((it) => `${it.item} is the ${genderLabel(it.gender)} cut`).join("; ")} — ${st.first} ${st.last} is set to ${st.uniformStyle}` : "",
|
||||
over ? `It would also take them past what one person holds: ${cap.note}` : "",
|
||||
].filter(Boolean).join(". ")}. Tick the coordinator override to issue ${wrongCount === 1 ? "it" : "them"} anyway.`
|
||||
: "";
|
||||
const overrideWhy = [offGroup.length ? "outside their staff group" : "", offStyle.length ? "outside their uniform style" : "", over ? "above what one person holds" : ""]
|
||||
.filter(Boolean).reduce((a, b, i, all) => (i === 0 ? b : i === all.length - 1 ? `${a} and ${b}` : `${a}, ${b}`), "");
|
||||
/* Garments of the starting kit this record still owes. What they are owed on starting, said on the
|
||||
shelf list below — never a term in whether this collection is allowed. A new starter holds
|
||||
nothing and takes three sets, and three is inside six, so the kit that used to need a coordinator
|
||||
override to hand over now goes through as the ordinary first issue it always was. */
|
||||
const kitLeft = initialRemaining(s, st) ?? 0;
|
||||
const sets = approvalRemaining(s, st.id);
|
||||
/* A manager’s approval is counted in SETS — one top and one pair of trousers — so a set is spent per top
|
||||
or per pair of trousers, whichever side of the pair is bigger, and never by anything else. A
|
||||
jacket, a vest or maternity wear is neither half of a set and costs the ward nothing off the
|
||||
approval. This must stay identical to the desktop Issue screen: counting garments instead of
|
||||
sets here quietly spent a whole approved set on a single fleece, and spent only half of what
|
||||
the manager signed for when someone took four tops. It is a separate control from the six sets
|
||||
anybody may hold: the approval is what pays for the garments, the ceiling is how much uniform one
|
||||
person walks around with, and a nurse has to satisfy both. */
|
||||
const cartTops = cart.reduce((t, c) => t + (isTopItem(byId[c.itemId]) ? c.qty : 0), 0);
|
||||
const cartPants = cart.reduce((t, c) => t + (isPantItem(byId[c.itemId]) ? c.qty : 0), 0);
|
||||
const short = cart.find((c) => c.qty > c.onHand);
|
||||
/* What they hold against the ceiling, said in the section headers that are already on the screen.
|
||||
Without it the counter can’t tell a new starter collecting the kit they’re owed from somebody
|
||||
drawing a seventh set. */
|
||||
const holdsRight = `${cap.sets}/${cap.cap} sets · ${heldQty} item${heldQty === 1 ? "" : "s"}`;
|
||||
const notYetLabel = kitLeft > 0 ? `Starting kit — ${kitLeft} still to issue`
|
||||
: "Their size, not yet issued";
|
||||
|
||||
const commit = async () => {
|
||||
if (!cart.length) return;
|
||||
if (short) { setErr(`Only ${short.onHand} of ${short.name} on the shelf.`); return; }
|
||||
// The reason comes from the same function the server refuses with, so nobody is told one thing
|
||||
// here and another when they press the button.
|
||||
if (wrongCount && !override) { setErr(wrongNote); return; }
|
||||
if (over && !override) { setErr(cap.note); return; }
|
||||
const r = await mutate<{ stock: number; apDeducted: number; apRemaining: number }>("issue.create", {
|
||||
// The tick and nothing else. An override is a record that somebody knowingly bent a rule, so
|
||||
// only somebody may set it: a new starter collecting the kit they are owed has bent nothing,
|
||||
// and it now goes through on its own merits.
|
||||
staffId: st.id, override, apDeduct: nursing ? Math.min(sets, Math.max(cartTops, cartPants)) : 0,
|
||||
lines: cart.map((c) => ({ itemId: c.itemId, si: c.si, qty: c.qty, src: "stock" })),
|
||||
});
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setDone(`${cartQty} item${cartQty === 1 ? "" : "s"} issued to ${st.first} ${st.last}.`);
|
||||
setCart([]);
|
||||
};
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<>
|
||||
<MTop title="Issued" />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<MEmpty title={done} sub="A replenishment draft has been topped up on Ordering. Nothing is sent to a supplier without approval." />
|
||||
</MBody>
|
||||
<MBar label="Back to the person" href={`/m/person/${st.id}`} glyph="arrow" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Issue" back right={cartQty ? `${cartQty} to issue` : undefined} />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody>
|
||||
<MPersonHead s={s} st={st} sub={<MEntitlement s={s} st={st} cart={cart} />} />
|
||||
|
||||
{cart.length > 0 && (
|
||||
<>
|
||||
<MSection label="Issuing now" right={money(total)} />
|
||||
{cart.map((c) => (
|
||||
<MRow key={c.key} mark="accent" attention title={c.name} sub={`${money(c.cost)} · ${c.onHand} on the shelf`}
|
||||
right={<MStepper n={c.qty} onChange={(n) => setQty(c.key, n)} max={Math.max(1, c.onHand)} />} />
|
||||
))}
|
||||
{(over || wrongCount > 0) && (
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 10, padding: "14px 16px", background: "#fff", borderBottom: "1px solid var(--color-divider)", fontSize: 14 }}>
|
||||
<input type="checkbox" checked={override} onChange={(e) => setOverride(e.target.checked)} style={{ width: 22, height: 22 }} />
|
||||
<span>Coordinator override — record this {overrideWhy}</span>
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<MSection label="Currently holds" right={holdsRight} />
|
||||
{held.length === 0 && <div style={{ padding: "18px 16px", fontSize: 14, color: "var(--color-neutral-600)" }}>Nothing out at the moment.</div>}
|
||||
{held.map((h) => {
|
||||
const it = byId[h.itemId];
|
||||
const k = h.key;
|
||||
return (
|
||||
<MRow key={k} title={h.name} sub={`${h.qty} held`}
|
||||
right={<button onClick={() => add({ key: k, itemId: h.itemId, si: h.si, size: h.size, name: h.name, qty: 1, cost: it?.cost ?? 0, onHand: onhand(s, L, k) })}
|
||||
style={{ width: 56, height: 44, border: "2px solid " + INK, background: inCart(k) ? INK : "transparent", color: inCart(k) ? "var(--color-bg)" : INK, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, cursor: "pointer" }}>+ 1</button>} />
|
||||
);
|
||||
})}
|
||||
|
||||
{suggested.filter((l) => !held.some((h) => h.key === l.key)).length > 0 && (
|
||||
<>
|
||||
<MSection label={notYetLabel} />
|
||||
{suggested.filter((l) => !held.some((h) => h.key === l.key)).map((l) => (
|
||||
<MRow key={l.key} attention mark="accent" title={l.name}
|
||||
sub={<span style={{ color: l.onHand > 0 ? "var(--color-accent-700)" : "var(--color-neutral-600)" }}>{l.onHand > 0 ? "Not yet issued" : "None on the shelf"}</span>}
|
||||
right={<button onClick={() => add(l)} disabled={l.onHand <= 0}
|
||||
style={{ width: 56, height: 44, border: "2px solid " + INK, background: inCart(l.key) ? INK : "transparent", color: inCart(l.key) ? "var(--color-bg)" : INK, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, cursor: l.onHand > 0 ? "pointer" : "not-allowed", opacity: l.onHand > 0 ? 1 : 0.4 }}>+ 1</button>} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ padding: "18px 16px 24px", fontSize: 14, color: "var(--color-neutral-700)" }}>Scan to add anything not on this list.</div>
|
||||
</MBody>
|
||||
|
||||
{cart.length === 0
|
||||
? <MBar label="Scan to add" glyph="scan" onClick={() => setScan(true)} />
|
||||
: <MBar label={busy ? "Recording…" : `Issue ${cartQty} item${cartQty === 1 ? "" : "s"}`} glyph="check" onClick={commit} disabled={busy} sub={money(total)} />}
|
||||
|
||||
{scan && <MScan title="Scan a garment" onHit={(r) => { onCode(r); setScan(false); }} onClose={() => setScan(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
/* Issue starts with the person: their sizes, allowance and approvals all hang off the record,
|
||||
so choosing them first is what lets the app check an issue before the garments leave the shelf. */
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { ccOf, staffName } from "@/lib/compute";
|
||||
import { INK, MBody, MEmpty, MRow, MRule, MSection, MTop, inputStyle } from "@/components/m";
|
||||
|
||||
export default function MIssuePick() {
|
||||
const { s } = useSnap();
|
||||
const [q, setQ] = useState("");
|
||||
|
||||
const list = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
const active = s.staff.filter((x) => !x.inactive);
|
||||
if (!needle) {
|
||||
// No query: whoever was served most recently, so the usual faces are one tap away.
|
||||
const seen: Record<string, string> = {};
|
||||
for (const i of s.issues) seen[i.staffId] = i.date > (seen[i.staffId] || "") ? i.date : seen[i.staffId];
|
||||
return [...active].sort((a, b) => (seen[b.id] || "").localeCompare(seen[a.id] || "")).slice(0, 12);
|
||||
}
|
||||
return active.filter((x) => `${x.first} ${x.last} ${x.num} ${x.dept}`.toLowerCase().includes(needle)).slice(0, 40);
|
||||
}, [s, q]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Issue" back right={`${s.staff.filter((x) => !x.inactive).length} on the register`} />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ padding: 16, borderBottom: "2px solid " + INK }}>
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Name or staff number" autoFocus
|
||||
aria-label="Search the staff register" style={inputStyle} />
|
||||
</div>
|
||||
<MSection label={q.trim() ? "Matches" : "Recently served"} />
|
||||
{list.length === 0
|
||||
? <MEmpty title="Nobody matches that" sub="Try a surname or a staff number. New starters are added on the desktop." />
|
||||
: list.map((st) => (
|
||||
<MRow key={st.id} href={`/m/issue/${st.id}`} mark="accent"
|
||||
title={staffName(st)}
|
||||
sub={[st.num, st.dept || st.group, ccOf(s, st) && `CC ${ccOf(s, st)}`].filter(Boolean).join(" · ")} />
|
||||
))}
|
||||
</MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
/* Reprint a label. Short by design: it exists because a garment nobody can scan silently vanishes
|
||||
from every count. Only sizes with a real supplier barcode can be reprinted — ThreadCount's
|
||||
internal fallback code appears nowhere on a garment, so printing it would help nobody. */
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { bcBound, label, variantName } from "@/lib/compute";
|
||||
import { isNative } from "@/lib/nativescan";
|
||||
import MScan from "@/components/MScan";
|
||||
import { INK, IconScan, MBar, MBody, MEmpty, MError, MNote, MRow, MRule, MSection, MStepper, MTop, inputStyle } from "@/components/m";
|
||||
|
||||
const REASONS = ["Worn off in the laundry", "Torn", "Never labelled", "Other"];
|
||||
|
||||
export default function MLabel() {
|
||||
const { s } = useSnap();
|
||||
const { byId, variants } = useDerived();
|
||||
const [q, setQ] = useState("");
|
||||
const [pick, setPick] = useState<string | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
const [copies, setCopies] = useState(6);
|
||||
const [scan, setScan] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
/* The Android shell cannot print. Its WebView opens no second window, so the label sheet would
|
||||
replace the app, and window.print() doesn't exist there — the button looked like it worked and
|
||||
stranded the person on a page with nothing to do. Read after mount: the server render doesn't
|
||||
know which shell it is being sent to. */
|
||||
const [inApp, setInApp] = useState(false);
|
||||
useEffect(() => { setInApp(isNative()); }, []);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
return variants
|
||||
.map((v) => ({ ...v, code: bcBound(s, v.item, v.si), name: `${variantName(byId[v.itemId], v.size)}` }))
|
||||
.filter((r) => r.code)
|
||||
.filter((r) => !needle || `${r.name} ${r.code} ${r.item.sku}`.toLowerCase().includes(needle));
|
||||
}, [s, variants, byId, q]);
|
||||
|
||||
const chosen = rows.find((r) => r.key === pick);
|
||||
const printable = chosen && chosen.code;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Reprint label" back right={chosen ? undefined : `${rows.length} labelled size${rows.length === 1 ? "" : "s"}`} />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody>
|
||||
{!chosen ? (
|
||||
<>
|
||||
<div style={{ padding: "20px 16px 22px", borderBottom: "2px solid " + INK }}>
|
||||
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 28, letterSpacing: "-0.03em", lineHeight: 1.05 }}>Barcode gone</h2>
|
||||
<p style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 10, lineHeight: 1.6 }}>
|
||||
A garment nobody can scan drops out of every count. Find it by code or description and print a fresh label.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ padding: 16, borderBottom: "2px solid " + INK, display: "flex", gap: 8 }}>
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Code or description" autoFocus
|
||||
aria-label="Find a garment" style={{ ...inputStyle, flex: 1 }} />
|
||||
<button onClick={() => setScan(true)} aria-label="Scan a working label"
|
||||
style={{ width: 56, minHeight: 48, border: "2px solid " + INK, background: "var(--color-accent)", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer" }}>
|
||||
<IconScan />
|
||||
</button>
|
||||
</div>
|
||||
<MSection label="Sizes with a supplier barcode" />
|
||||
{rows.length === 0
|
||||
? <MEmpty title="Nothing matches" sub="Only sizes with a supplier barcode bound to them can be reprinted. Bind one by scanning the size on the desktop." />
|
||||
: rows.slice(0, 40).map((r) => (
|
||||
<MRow key={r.key} onClick={() => setPick(r.key)} mark="ink" title={r.name} sub={`${r.code}${r.item.sku ? ` · ${r.item.sku}` : ""}`} />
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ padding: "20px 16px", background: "#fff", borderBottom: "2px solid " + INK }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em" }}>{chosen.name}</div>
|
||||
<div style={{ fontSize: 14, color: "var(--color-neutral-600)", marginTop: 6 }}>{chosen.code}</div>
|
||||
<button onClick={() => { setPick(null); setReason(""); }} style={{ marginTop: 12, background: "none", border: 0, padding: 0, color: "var(--color-accent-700)", fontSize: 14, fontWeight: 600, textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>Choose a different garment</button>
|
||||
</div>
|
||||
|
||||
<MSection label="Why is it being reprinted?" />
|
||||
<div style={{ padding: 16, display: "flex", flexWrap: "wrap", gap: 8 }}>
|
||||
{REASONS.map((r) => {
|
||||
const on = reason === r;
|
||||
return (
|
||||
<button key={r} onClick={() => setReason(on ? "" : r)} aria-pressed={on}
|
||||
style={{ minHeight: 48, padding: "0 14px", border: "2px solid " + INK, background: on ? INK : "transparent", color: on ? "var(--color-bg)" : INK, fontSize: 14, fontWeight: 700, cursor: "pointer" }}>{r}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<MSection label="Copies" />
|
||||
<div style={{ padding: 16, display: "flex", alignItems: "center", gap: 16 }}>
|
||||
<span style={{ flex: 1, fontSize: 14, color: "var(--color-neutral-700)" }}>Six to an A4 sheet.</span>
|
||||
<MStepper n={copies} onChange={setCopies} min={1} max={24} />
|
||||
</div>
|
||||
|
||||
<MNote>The label carries the same barcode the supplier printed, so it scans identically to the ones still on the shelf.</MNote>
|
||||
{inApp && (
|
||||
<MNote tone="warn">
|
||||
Printing is a desktop job — the app can’t open a label sheet. Open ThreadCount
|
||||
on the desktop site, find <b>{chosen.name}</b> under {chosen.code}, and print it
|
||||
from there.
|
||||
</MNote>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</MBody>
|
||||
|
||||
{chosen && (
|
||||
<MBar label={inApp ? "Print it on the desktop" : `Print ${copies} label${copies === 1 ? "" : "s"}`} glyph="printer"
|
||||
disabled={inApp}
|
||||
onClick={() => {
|
||||
if (!printable) { setErr("That size has no supplier barcode bound to it."); return; }
|
||||
const url = `/print/labels?code=${encodeURIComponent(chosen.code)}&copies=${copies}&reason=${encodeURIComponent(reason)}`;
|
||||
window.open(url, "_blank", "noopener");
|
||||
}} />
|
||||
)}
|
||||
|
||||
{scan && <MScan title="Scan a working label" onHit={(raw) => {
|
||||
const k = s.barcodes[raw.trim()];
|
||||
if (k) { setPick(k); setQ(""); } else setErr(`${raw.trim()} isn’t a garment ThreadCount knows.`);
|
||||
setScan(false);
|
||||
}} onClose={() => setScan(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { buildSnapshot } from "@/lib/snapshot";
|
||||
import { SnapshotProvider } from "@/lib/client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Everything that needs a signed-in coordinator. Sending them to /m/login rather than /auth keeps
|
||||
them in the app's own world: /auth is the website's two-pane sign-in, which is a jarring thing
|
||||
to meet on a phone halfway through opening an app. */
|
||||
export default async function MobileAppLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = await currentUser();
|
||||
if (!user) redirect("/m/login");
|
||||
const snap = await buildSnapshot(user);
|
||||
return <SnapshotProvider snap={snap}>{children}</SnapshotProvider>;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/* What a tap looks like before the server answers, for the counter app.
|
||||
*
|
||||
* The twin of app/my/(app)/loading.tsx, and here for the same reason: every screen under /m is
|
||||
* rendered from its own server query, App Router keeps the previous screen fully painted until that
|
||||
* query comes back, and on linen-room wifi that is seconds in which nothing acknowledges the tap.
|
||||
* People tap again — and on this app the second tap can land on a different row.
|
||||
*
|
||||
* It draws the app's own chrome (the 56px ink bar and the 4px accent rule, the shape MTop and MRule
|
||||
* make) so the change reads as "loading" rather than "gone", and deliberately not the tab bar: the
|
||||
* nav belongs to the four screens that draw it, and painting one here would flash it into existence
|
||||
* on the way to a detail screen that has none. The bar carries no screen title for the same reason
|
||||
* — this one fallback covers every route in the group, so any title would be wrong somewhere.
|
||||
*/
|
||||
const INK = "#201e1d";
|
||||
const GROUND = "#f3f2f2";
|
||||
|
||||
/** A grey block standing in for a line of text. Sized in the same 2px system as everything else. */
|
||||
function Bar({ w, h = 16 }: { w: string; h?: number }) {
|
||||
return <div style={{ width: w, height: h, background: "var(--color-neutral-200)" }} />;
|
||||
}
|
||||
|
||||
export default function CounterLoading() {
|
||||
return (
|
||||
<>
|
||||
<header className="tcx-topbar" style={{
|
||||
height: 56, flex: "0 0 56px", background: INK, color: GROUND, display: "flex", alignItems: "center",
|
||||
paddingLeft: 16, paddingRight: 16,
|
||||
backgroundImage: "linear-gradient(to bottom, rgba(243,242,242,0.16) 0 1px, transparent 1px)",
|
||||
backgroundPosition: "0 env(safe-area-inset-top, 0px)", backgroundRepeat: "no-repeat", backgroundSize: "100% 1px",
|
||||
}}>
|
||||
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, letterSpacing: "0.06em", textTransform: "uppercase" }}>
|
||||
One moment
|
||||
</span>
|
||||
</header>
|
||||
<div style={{ height: 4, flex: "0 0 4px", background: "var(--color-accent)" }} />
|
||||
<div style={{ flex: 1, overflow: "hidden", background: GROUND }} aria-busy="true">
|
||||
{/* Announced once, quietly. The blocks below are decoration and say nothing. */}
|
||||
<div role="status" style={{ padding: "20px 16px 0", fontSize: 13, color: "var(--color-neutral-600)" }}>Loading…</div>
|
||||
<div style={{ padding: "16px 16px 0", display: "grid", gap: 10 }} aria-hidden="true">
|
||||
<Bar w="60%" h={22} />
|
||||
<Bar w="40%" />
|
||||
</div>
|
||||
<div style={{ marginTop: 24, display: "grid", gap: 2 }} aria-hidden="true">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} style={{ background: "#fff", padding: "18px 16px", display: "grid", gap: 8 }}>
|
||||
<Bar w="55%" h={18} />
|
||||
<Bar w="35%" h={12} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
/* Everything else the app does.
|
||||
*
|
||||
* There used to be an "On the desktop" section here listing five things the phone couldn't do —
|
||||
* greyed out, untappable, and so just a list of disappointments in the middle of a menu. A menu
|
||||
* should be things you can do. The catalogue moved onto the phone rather than staying on that
|
||||
* list; the rest are simply not advertised here any more. */
|
||||
import { useMemo } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { OPEN_STATUSES } from "@/lib/compute";
|
||||
import { MBody, MNav, MRow, MRule, MSection, MTop } from "@/components/m";
|
||||
|
||||
export default function MMore() {
|
||||
const { s } = useSnap();
|
||||
|
||||
const activeItems = useMemo(() => s.catalog.filter((i) => !i.archived).length, [s.catalog]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const waiting = s.pickups.filter((p) => !p.pickedUp).length;
|
||||
const incoming = s.orders.filter((o) => OPEN_STATUSES.includes(o.status) && o.status !== "Draft").length;
|
||||
const rounds = s.pickups.filter((p) => !p.pickedUp && p.deliveredTo).length;
|
||||
return { waiting, incoming, rounds };
|
||||
}, [s]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="More" />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<MSection label="Everything else" />
|
||||
<MRow href="/m/receive" mark="ink" title="Receive a delivery" sub={counts.incoming ? `${counts.incoming} order${counts.incoming === 1 ? "" : "s"} on their way` : "Nothing on order"} />
|
||||
<MRow href="/m/pickups" mark={counts.waiting ? "accent" : "ink"} attention={counts.waiting > 0} title="Pickup call list" sub={counts.waiting ? `${counts.waiting} waiting to be collected` : "Nobody waiting"} />
|
||||
<MRow href="/m/rounds" mark="ink" title="Delivery round" sub={counts.rounds ? `${counts.rounds} to drop off` : "Nothing loaded"} />
|
||||
<MRow href="/m/label" mark="ink" title="Reprint a label" sub="For a barcode that has worn off" />
|
||||
<MRow href="/m/variance" mark="ink" title="Variance over time" sub="What keeps going missing" />
|
||||
<MRow href="/m/catalogue" mark="ink" title="Catalogue" sub={`${activeItems} garment${activeItems === 1 ? "" : "s"}, sizes and pricing`} />
|
||||
<MRow href="/m/settings" mark="ink" title="Settings" sub={s.session.name} />
|
||||
|
||||
</MBody>
|
||||
<MNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
/* Home — today. Four figures, what’s just happened, and a way into a count. */
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { countsAsIssued, daysBetween, label, longLabel, onhand, touched, reorderAt, staffName, variantName } from "@/lib/compute";
|
||||
import { INK, IconRight, MBody, MNav, MRow, MRule, MSection, MTopBrand } from "@/components/m";
|
||||
|
||||
function Stat({ n, l, hot }: { n: string; l: string; hot?: boolean }) {
|
||||
return (
|
||||
<div style={{ padding: "18px 16px 16px", borderRight: "1px solid var(--color-divider)", borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 44, lineHeight: 1, letterSpacing: "-0.03em", fontVariantNumeric: "tabular-nums", color: hot ? "var(--color-accent-700)" : INK }}>{n}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-neutral-600)", marginTop: 8 }}>{l}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MHome() {
|
||||
const { s } = useSnap();
|
||||
const { L, byId, staffById, variants } = useDerived();
|
||||
|
||||
const d = useMemo(() => {
|
||||
const today = s.today;
|
||||
let issued = 0, returned = 0;
|
||||
for (const i of s.issues) {
|
||||
if (i.date === today) issued += i.qty;
|
||||
if (i.returned?.date === today) returned += i.qty;
|
||||
}
|
||||
const low = variants.filter((v) => touched(s, L, v.key) && onhand(s, L, v.key) <= reorderAt(s, v.key));
|
||||
const lastCount = s.stocktakes.find((t) => t.mode !== "preloved");
|
||||
const since = lastCount ? daysBetween(lastCount.date, today) : null;
|
||||
// Recent activity, newest first. One line per person per day per kind — four garments handed
|
||||
// to the same nurse in one go is one thing that happened, not four.
|
||||
const grouped: Record<string, { staffId: string; kind: "Issued" | "Returned"; at: string; qty: number }> = {};
|
||||
for (const i of s.issues.slice(-120)) {
|
||||
const add = (kind: "Issued" | "Returned", at: string) => {
|
||||
const k = `${i.staffId}|${kind}|${at}`;
|
||||
(grouped[k] ||= { staffId: i.staffId, kind, at, qty: 0 }).qty += i.qty;
|
||||
};
|
||||
add("Issued", i.date);
|
||||
if (i.returned) add("Returned", i.returned.date);
|
||||
}
|
||||
const recent = Object.values(grouped)
|
||||
.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0))
|
||||
.slice(0, 3)
|
||||
.map((g) => ({
|
||||
title: `${staffName(staffById[g.staffId], "Staff")} — ${g.qty} item${g.qty === 1 ? "" : "s"}`,
|
||||
sub: `${g.kind} · ${g.at === today ? "today" : g.at}`,
|
||||
mark: "ink" as const, href: `/m/person/${g.staffId}`, at: g.at,
|
||||
})) as { title: string; sub: string; mark: "ink" | "accent"; href?: string; at: string }[];
|
||||
// A line AT its reorder level is in `low` on purpose (reorder now, not once it's short), but
|
||||
// "Below par · 3 of 3" reads as a contradiction on the phone, so name the two states apart.
|
||||
for (const v of low.slice(0, 2)) {
|
||||
const oh = onhand(s, L, v.key), par = reorderAt(s, v.key);
|
||||
recent.push({ title: `${variantName(byId[v.itemId], v.size)}`, sub: `${oh < par ? "Below par" : "At par"} · ${oh} of ${par}`, mark: "accent", href: `/m/stock`, at: "" });
|
||||
}
|
||||
return { issued, returned, low: low.length, since, recent };
|
||||
}, [s, L, byId, staffById, variants]);
|
||||
|
||||
const fac = [s.settings.facility, s.settings.location].filter(Boolean).join(" · ");
|
||||
const dateLine = new Date(+s.today.slice(0, 4), +s.today.slice(5, 7) - 1, +s.today.slice(8, 10))
|
||||
.toLocaleDateString("en-AU", { weekday: "long", day: "numeric", month: "long" });
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTopBrand facility={fac} />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ padding: "20px 16px 24px", borderBottom: "2px solid " + INK }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-neutral-600)" }}>{dateLine}</div>
|
||||
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 44, letterSpacing: "-0.03em", lineHeight: 1, marginTop: 10 }}>Today</h2>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr" }}>
|
||||
<Stat n={String(d.issued)} l="Issued" />
|
||||
<Stat n={String(d.returned)} l="Returned" />
|
||||
<Stat n={String(d.low)} l="Below par" hot={d.low > 0} />
|
||||
<Stat n={d.since === null ? "—" : `${d.since}d`} l="Since count" hot={d.since !== null && d.since > 30} />
|
||||
</div>
|
||||
|
||||
<MSection label="Recent" />
|
||||
{d.recent.length === 0
|
||||
? <div style={{ padding: "28px 16px", fontSize: 14, color: "var(--color-neutral-600)" }}>Nothing has moved yet today.</div>
|
||||
: d.recent.map((r, i) => (
|
||||
<MRow key={i} mark={r.mark} attention={r.mark === "accent"} href={r.href}
|
||||
title={r.title}
|
||||
sub={<span style={{ color: r.mark === "accent" ? "var(--color-accent-700)" : undefined }}>{r.sub}</span>} />
|
||||
))}
|
||||
|
||||
<div style={{ padding: 16, display: "grid", gap: 12 }}>
|
||||
<Link href="/m/count" style={{ display: "flex", alignItems: "center", gap: 12, minHeight: 64, padding: "0 20px", border: "2px solid " + INK, color: INK, textDecoration: "none", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase" }}>
|
||||
<span style={{ flex: 1 }}>Start a count</span><IconRight />
|
||||
</Link>
|
||||
<Link href="/m/issue" style={{ display: "flex", alignItems: "center", gap: 12, minHeight: 64, padding: "0 20px", border: "2px solid " + INK, color: INK, textDecoration: "none", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase" }}>
|
||||
<span style={{ flex: 1 }}>Issue to someone</span><IconRight />
|
||||
</Link>
|
||||
<Link href="/m/more" style={{ display: "flex", alignItems: "center", gap: 12, minHeight: 52, padding: "0 20px", color: "var(--color-neutral-700)", textDecoration: "none", fontSize: 13, fontWeight: 600, letterSpacing: "0.06em", textTransform: "uppercase" }}>
|
||||
<span style={{ flex: 1 }}>Deliveries, pickups, rounds and more</span><IconRight size={18} />
|
||||
</Link>
|
||||
</div>
|
||||
</MBody>
|
||||
<MNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
/* Size exchange — one movement, not a return followed by an issue. What comes back, what goes out,
|
||||
and the staff record updated so nobody hands them the wrong size again next month. */
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { isPantItem, isTopItem, key, label, onhand, staffName } from "@/lib/compute";
|
||||
import MScan from "@/components/MScan";
|
||||
import { GROUND, INK, MBar, MBody, MChips, MEmpty, MError, MRow, MRule, MSection, MTop } from "@/components/m";
|
||||
import { useHeld, type Held } from "@/components/MPerson";
|
||||
|
||||
export default function MExchange() {
|
||||
const { s, mutate, busy } = useSnap();
|
||||
const { L, byId } = useDerived();
|
||||
const router = useRouter();
|
||||
const id = String(useParams().id || "");
|
||||
const st = s.staff.find((x) => x.id === id);
|
||||
const held = useHeld(s, id);
|
||||
|
||||
const [pick, setPick] = useState<Held | null>(null);
|
||||
const [si, setSi] = useState(-1);
|
||||
const [scan, setScan] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const it = pick ? byId[pick.itemId] : undefined;
|
||||
const stock = useMemo(() => {
|
||||
if (!it) return [] as number[];
|
||||
return it.sizes.map((_, i) => onhand(s, L, key(it.id, i)));
|
||||
}, [it, s, L]);
|
||||
|
||||
const onCode = useCallback((raw: string) => {
|
||||
const k = s.barcodes[raw.trim()];
|
||||
const hit = held.find((h) => h.key === k);
|
||||
if (!hit) { setErr(`${raw.trim()} isn’t something ${st?.first ?? "they"} is holding.`); return; }
|
||||
setPick(hit); setSi(-1); setErr("");
|
||||
}, [s.barcodes, held, st]);
|
||||
|
||||
if (!st) return (<><MTop title="Exchange" back /><MRule /><MBody><MEmpty title="No such staff member" /></MBody></>);
|
||||
|
||||
const commit = async () => {
|
||||
if (!pick || si < 0) return;
|
||||
const r = await mutate<{ size: string }>("issue.exchange", { id: pick.issues[0].id, si, qty: 1 });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
router.push(`/m/person/${st.id}`);
|
||||
};
|
||||
|
||||
const willUpdate = it && (isTopItem(it) || isPantItem(it));
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Exchange" back right={staffName(st)} />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody>
|
||||
{!pick ? (
|
||||
<>
|
||||
<MSection label="What doesn’t fit?" right={`${held.reduce((t, h) => t + h.qty, 0)} out`} />
|
||||
{held.length === 0
|
||||
? <MEmpty title="Nothing to exchange" sub={`${staffName(st)} has no garments out at the moment.`} />
|
||||
: held.map((h) => <MRow key={h.key} onClick={() => { setPick(h); setSi(-1); }} mark="ink" title={h.name} sub={`${h.qty} held`} />)}
|
||||
{/* Same rule as the return screen: the scan bar is off when nothing is out, so the line
|
||||
offering a scan goes with it. */}
|
||||
{held.length > 0 && <div style={{ padding: "18px 16px", fontSize: 14, color: "var(--color-neutral-700)" }}>Or scan the garment they’ve brought back.</div>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<section style={{ background: INK, color: GROUND, padding: 16 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-accent-300)" }}>Taking back</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, marginTop: 8 }}>
|
||||
<span aria-hidden="true" style={{ width: 4, height: 34, background: "#fff" }} />
|
||||
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 20, letterSpacing: "-0.02em" }}>{pick.name}</span>
|
||||
</div>
|
||||
<button onClick={() => setPick(null)} style={{ marginTop: 12, background: "none", border: 0, padding: 0, color: "#fff", fontSize: 13, fontWeight: 700, textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>Choose a different garment</button>
|
||||
</section>
|
||||
|
||||
<MSection label="Giving out" right={it ? label(it) : ""} />
|
||||
<div style={{ padding: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<span aria-hidden="true" style={{ width: 4, height: 34, background: "var(--color-accent)" }} />
|
||||
<span style={{ fontSize: 14, color: "var(--color-neutral-700)" }}>Pick the size that fits. Greyed sizes are the one coming back, or have none on the shelf.</span>
|
||||
</div>
|
||||
{it && <MChips sizes={it.sizes.map(String)} value={si} onPick={(i) => setSi(i)} disabled={(i) => i === pick.si || stock[i] <= 0} />}
|
||||
{si >= 0 && it && (
|
||||
<p style={{ fontSize: 13.5, color: "var(--color-neutral-700)", marginTop: 14, lineHeight: 1.6 }}>
|
||||
{stock[si]} on the shelf in size {it.sizes[si]}. The old garment goes back to stock in the same movement
|
||||
{willUpdate ? `, and ${st.first}’s recorded size becomes ${it.sizes[si]}.` : "."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</MBody>
|
||||
|
||||
{pick
|
||||
? <MBar label={busy ? "Recording…" : si >= 0 && it ? `Exchange for size ${it.sizes[si]}` : "Pick a size"} glyph="check" onClick={commit} disabled={busy || si < 0} />
|
||||
: <MBar label="Scan the garment" glyph="scan" onClick={() => setScan(true)} disabled={held.length === 0} />}
|
||||
|
||||
{scan && <MScan title="Scan the garment" onHit={(r) => { onCode(r); setScan(false); }} onClose={() => setScan(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
/* Person record — who they are, what they're holding, what has happened, and the three things
|
||||
you can do about it. Issuing starts here: 1B, person first. */
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { fmtDate, itemMap, staffName, variantName } from "@/lib/compute";
|
||||
import { GROUND, INK, MBar, MBody, MEmpty, MError, MRow, MRule, MSection, MTop } from "@/components/m";
|
||||
import { MPersonHead, useHeld } from "@/components/MPerson";
|
||||
|
||||
export default function MPersonPage() {
|
||||
const { s, isAdmin, mutate } = useSnap();
|
||||
const id = String(useParams().id || "");
|
||||
const st = s.staff.find((x) => x.id === id);
|
||||
const held = useHeld(s, id);
|
||||
const byId = useMemo(() => itemMap(s), [s]);
|
||||
// Shown once, then gone: the code is a credential and is never in the snapshot.
|
||||
const [code, setCode] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const origin = typeof window === "undefined" ? "" : window.location.host;
|
||||
|
||||
const history = useMemo(() => {
|
||||
if (!st) return [];
|
||||
const out: { text: string; date: string }[] = [];
|
||||
for (const i of s.issues) {
|
||||
if (i.staffId !== id) continue;
|
||||
const it = byId[i.itemId];
|
||||
const size = String(it?.sizes[i.si] ?? i.si);
|
||||
out.push({ text: `Issued ${i.qty} × ${variantName(it, size)}`, date: i.date });
|
||||
if (i.returned) out.push({ text: `${i.returned.cond} — ${variantName(it, size)}`, date: i.returned.date });
|
||||
if (i.handedIn) out.push({ text: `Handed in — ${variantName(it, size)}`, date: i.handedIn });
|
||||
}
|
||||
return out.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)).slice(0, 25);
|
||||
}, [s, id, st, byId]);
|
||||
|
||||
if (!st) return (<><MTop title="Person" back /><MRule /><MBody><MEmpty title="No such staff member" sub="They may have been removed from the register." /></MBody></>);
|
||||
|
||||
const total = held.reduce((t, h) => t + h.qty, 0);
|
||||
/* Issue, Exchange and Return are docked at the foot of the window with nothing underneath them,
|
||||
so Android draws the gesture handle across their bottom edge. The inset goes inside the bar the
|
||||
way the shared MBar and MAction take it — same custom property, so an ancestor that zeroes it
|
||||
for a bar sitting mid-screen would zero this one too — and the accent still runs to the bottom
|
||||
of the glass while the words stay above the handle. Without it the lower third of "Issue" is
|
||||
untappable, and this is the row a counter hand hits all day. */
|
||||
const SAFE_BOTTOM = "var(--tcx-safe-bottom, env(safe-area-inset-bottom, 0px))";
|
||||
const foot: React.CSSProperties = { flex: 1, minHeight: `calc(64px + ${SAFE_BOTTOM})`, display: "flex", alignItems: "center", padding: `0 16px ${SAFE_BOTTOM}`, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", textDecoration: "none" };
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Person" back />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<MPersonHead s={s} st={st} />
|
||||
<MSection label="Holding now" right={`${total} item${total === 1 ? "" : "s"}`} />
|
||||
{held.length === 0
|
||||
? <div style={{ padding: "22px 16px", fontSize: 14, color: "var(--color-neutral-600)" }}>Nothing out at the moment.</div>
|
||||
: held.map((h) => <MRow key={h.key} title={h.name} right={<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, fontVariantNumeric: "tabular-nums" }}>{h.qty}</span>} />)}
|
||||
|
||||
<MSection label="Their own record" />
|
||||
{code ? (
|
||||
<>
|
||||
<div style={{ padding: "16px" }}>
|
||||
<div style={{ fontFamily: "ui-monospace, Menlo, Consolas, monospace", fontSize: 26, fontWeight: 800, letterSpacing: "0.06em" }}>{code}</div>
|
||||
<p style={{ fontSize: 13.5, lineHeight: 1.55, color: "var(--color-neutral-700)", margin: "8px 0 0" }}>
|
||||
Read this out or write it down now — it can't be shown again. They go to{" "}
|
||||
<b>{origin}/my</b>, choose “I have a code”, and set an email and password.
|
||||
</p>
|
||||
</div>
|
||||
<MBar label="Done" tone="ink" glyph="none" onClick={() => setCode(null)} />
|
||||
</>
|
||||
) : st.selfEmail ? (
|
||||
<div style={{ padding: "16px", fontSize: 14, lineHeight: 1.55, color: "var(--color-neutral-600)" }}>
|
||||
Signed up as {st.selfEmail} — they can look up their own record instead of coming to the counter.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ padding: "16px" }}>
|
||||
<p style={{ fontSize: 14, lineHeight: 1.55, color: "var(--color-neutral-600)", margin: 0 }}>
|
||||
{st.selfCode
|
||||
? "A code is out but hasn’t been used. Make a new one if they’ve lost it — the old one stops working."
|
||||
: "Give them a code and they can check what they hold on their own phone. Read-only."}
|
||||
</p>
|
||||
</div>
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
{isAdmin && (
|
||||
<MBar label={busy ? "Generating…" : st.selfCode ? "New code" : "Generate a code"} tone="ink" glyph="none" disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true); setErr("");
|
||||
const r = await mutate<{ code: string }>("staff.selfCode", { id: st.id });
|
||||
setBusy(false);
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setCode(r.result.code);
|
||||
}} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<MSection label="History" />
|
||||
{history.length === 0
|
||||
? <div style={{ padding: "22px 16px", fontSize: 14, color: "var(--color-neutral-600)" }}>Nothing recorded for {staffName(st)} yet.</div>
|
||||
: history.map((h, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: 12, padding: "14px 16px", borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<span style={{ flex: 1, fontSize: 14.5 }}>{h.text}</span>
|
||||
<span style={{ fontSize: 13, color: "var(--color-neutral-600)", whiteSpace: "nowrap" }}>{fmtDate(h.date)}</span>
|
||||
</div>
|
||||
))}
|
||||
</MBody>
|
||||
<div style={{ display: "flex", flex: `0 0 calc(64px + ${SAFE_BOTTOM})`, borderTop: "2px solid " + INK }}>
|
||||
<Link href={`/m/issue/${st.id}`} style={{ ...foot, background: "var(--color-accent)", color: "#fff" }}>Issue</Link>
|
||||
<Link href={`/m/person/${st.id}/exchange`} style={{ ...foot, background: "var(--color-neutral-200)", color: INK, borderLeft: "1px solid " + GROUND }}>Exchange</Link>
|
||||
<Link href={`/m/person/${st.id}/return`} style={{ ...foot, background: "var(--color-neutral-200)", color: INK, borderLeft: "1px solid " + GROUND }}>Return</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
/* Return — scan the garment or pick it off what they're holding, say how many and what state
|
||||
they're in, confirm. The conditions are ThreadCount's real four: only "fit for use" puts a
|
||||
garment back on the shelf. */
|
||||
import { useCallback, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { fmtDate, staffName } from "@/lib/compute";
|
||||
import MScan from "@/components/MScan";
|
||||
import { INK, MBar, MBody, MEmpty, MError, MRow, MRule, MSection, MStepper, MTop } from "@/components/m";
|
||||
import { useHeld, type Held } from "@/components/MPerson";
|
||||
|
||||
const CONDITIONS: [string, string, string][] = [
|
||||
["Returned - Good", "Fit for use — back to shelf", "Counts back into stock the moment it’s confirmed."],
|
||||
["Returned - Damaged", "Damaged — needs repair", "Stays off the shelf and stays charged to the cost centre."],
|
||||
["Written Off", "Condemn — beyond repair", "Written off. Nothing comes back to stock."],
|
||||
["Lost", "Lost", "Never came back. Stays charged."],
|
||||
];
|
||||
|
||||
export default function MReturn() {
|
||||
const { s, mutate, busy } = useSnap();
|
||||
const router = useRouter();
|
||||
const id = String(useParams().id || "");
|
||||
const st = s.staff.find((x) => x.id === id);
|
||||
const held = useHeld(s, id);
|
||||
|
||||
const [pick, setPick] = useState<Held | null>(null);
|
||||
// How many of that garment are actually on the counter. Three of a size can be out on one issue
|
||||
// line, and one pair coming back is one pair — crediting the whole line put two garments that
|
||||
// are still on a ward back onto the shelf.
|
||||
const [qty, setQty] = useState(1);
|
||||
const [cond, setCond] = useState("Returned - Good");
|
||||
const [scan, setScan] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const choose = useCallback((h: Held) => { setPick(h); setQty(h.qty); setErr(""); }, []);
|
||||
|
||||
const onCode = useCallback((raw: string) => {
|
||||
const k = s.barcodes[raw.trim()];
|
||||
const hit = held.find((h) => h.key === k);
|
||||
if (!hit) { setErr(`${raw.trim()} isn’t something ${st?.first ?? "they"} is holding.`); return; }
|
||||
choose(hit);
|
||||
}, [s.barcodes, held, st, choose]);
|
||||
|
||||
if (!st) return (<><MTop title="Return" back /><MRule /><MBody><MEmpty title="No such staff member" /></MBody></>);
|
||||
|
||||
const confirm = async () => {
|
||||
if (!pick) return;
|
||||
// What they hold in this size can be spread over several issue lines, so returning four of
|
||||
// them is several movements. Oldest line first — the garment that has been out longest is the
|
||||
// one that came back — and the last line is split when it is only partly returned.
|
||||
const rows = [...pick.issues].sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
||||
let left = Math.min(qty, pick.qty);
|
||||
const asked = left;
|
||||
for (const i of rows) {
|
||||
if (left <= 0) break;
|
||||
const take = Math.min(i.qty, left);
|
||||
const r = await mutate("issue.return", { id: i.id, cond, qty: take });
|
||||
if (!r.ok) {
|
||||
// Some of them may already be back. Say so rather than leave the counter to guess, and
|
||||
// send them back to a fresh list rather than acting on what is now a stale row.
|
||||
setErr(asked - left > 0 ? `${asked - left} of ${asked} went back before this stopped — ${r.error}` : r.error);
|
||||
setPick(null);
|
||||
return;
|
||||
}
|
||||
left -= take;
|
||||
}
|
||||
router.push(`/m/person/${st.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Return" back right={staffName(st)} />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody>
|
||||
{pick ? (
|
||||
<>
|
||||
<div style={{ padding: "20px 16px", background: "#fff", borderBottom: "2px solid " + INK }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em" }}>{pick.name}</div>
|
||||
<div style={{ fontSize: 14, color: "var(--color-neutral-600)", marginTop: 6 }}>
|
||||
Issued to {staffName(st)}, {fmtDate(pick.issues[0].date)}
|
||||
</div>
|
||||
<button onClick={() => setPick(null)} style={{ marginTop: 12, background: "none", border: 0, padding: 0, color: "var(--color-accent-700)", fontSize: 14, fontWeight: 600, textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>Choose a different garment</button>
|
||||
</div>
|
||||
|
||||
<MSection label="How many are coming back?" right={`${pick.qty} out`} />
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16, padding: 16, borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<span style={{ flex: 1, fontSize: 14, color: "var(--color-neutral-700)", lineHeight: 1.55 }}>
|
||||
{pick.qty === 1
|
||||
? "One is out, so this is it."
|
||||
: `${pick.qty} are out. Count what is on the counter — the rest stays on ${st.first}’s record.`}
|
||||
</span>
|
||||
<MStepper n={qty} onChange={setQty} min={1} max={pick.qty} />
|
||||
</div>
|
||||
|
||||
<MSection label="Condition" />
|
||||
<div style={{ padding: 16, display: "grid", gap: 8 }}>
|
||||
{CONDITIONS.map(([value, title, note]) => {
|
||||
const on = cond === value;
|
||||
return (
|
||||
<button key={value} onClick={() => setCond(value)} aria-pressed={on}
|
||||
style={{ textAlign: "left", padding: "16px 18px", minHeight: 64, border: "2px solid " + INK, background: on ? INK : "transparent", color: on ? "var(--color-bg)" : INK, cursor: "pointer" }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 17, letterSpacing: "-0.01em" }}>{title}</div>
|
||||
<div style={{ fontSize: 13, marginTop: 4, color: on ? "var(--color-neutral-400)" : "var(--color-neutral-600)" }}>{note}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MSection label="What is coming back?" right={`${held.reduce((t, h) => t + h.qty, 0)} out`} />
|
||||
{held.length === 0
|
||||
? <MEmpty title="Nothing to return" sub={`${staffName(st)} has no garments out at the moment.`} />
|
||||
: held.map((h) => <MRow key={h.key} onClick={() => choose(h)} mark="ink" title={h.name} sub={`${h.qty} held · issued ${fmtDate(h.issues[0].date)}`} />)}
|
||||
{/* A return has to match a record they hold, which is why the scan bar below is off when
|
||||
nothing is out — so don't invite a scan the bar then refuses. */}
|
||||
{held.length > 0 && <div style={{ padding: "18px 16px", fontSize: 14, color: "var(--color-neutral-700)" }}>Or scan the garment.</div>}
|
||||
</>
|
||||
)}
|
||||
</MBody>
|
||||
|
||||
{pick
|
||||
? <MBar label={busy ? "Recording…" : qty === 1 ? "Confirm return" : `Confirm return of ${qty}`} glyph="check" onClick={confirm} disabled={busy} />
|
||||
: <MBar label="Scan the garment" glyph="scan" onClick={() => setScan(true)} disabled={held.length === 0} />}
|
||||
|
||||
{scan && <MScan title="Scan the garment" onHit={(r) => { onCode(r); setScan(false); }} onClose={() => setScan(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
/* The call list — longest wait first, because that’s the one somebody is annoyed about. */
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { daysBetween, itemMap, label, staffMap, staffName } from "@/lib/compute";
|
||||
import { INK, MBody, MEmpty, MError, MNav, MRow, MRule, MSection, MTop } from "@/components/m";
|
||||
|
||||
export default function MPickups() {
|
||||
const { s, mutate, busy } = useSnap();
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const staffById = staffMap(s);
|
||||
const byId = itemMap(s);
|
||||
return s.pickups
|
||||
.filter((p) => !p.pickedUp)
|
||||
.map((p) => ({
|
||||
...p,
|
||||
who: staffById[p.staffId],
|
||||
phone: staffById[p.staffId]?.phone || "",
|
||||
days: daysBetween(p.received, s.today),
|
||||
what: p.lines.map((l) => `${label(byId[l.itemId])} · ${l.size}${l.qty > 1 ? ` ×${l.qty}` : ""}`).join(", "),
|
||||
}))
|
||||
.sort((a, b) => b.days - a.days);
|
||||
}, [s]);
|
||||
|
||||
const act = async (op: string, id: string) => {
|
||||
const r = await mutate(op, { id });
|
||||
if (!r.ok) setErr(r.error);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Pickups" back right={rows.length ? `${rows.length} waiting` : undefined} />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody>
|
||||
{rows.length === 0 ? (
|
||||
<MEmpty title="Nobody is waiting" sub="Everything that has come in has been collected." />
|
||||
) : (
|
||||
<>
|
||||
<MSection label="Waiting" right="Longest first" />
|
||||
{rows.map((p) => (
|
||||
<div key={p.id} style={{ padding: 16, background: p.days > 10 ? "#fff" : "var(--color-bg)", borderBottom: "1px solid var(--color-divider)", display: "flex", gap: 12 }}>
|
||||
<span aria-hidden="true" style={{ width: 4, flex: "0 0 4px", background: p.days > 10 ? "var(--color-accent)" : INK, alignSelf: "stretch" }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
|
||||
<span style={{ flex: 1, fontSize: 16.5, fontWeight: 700 }}>{staffName(p.who, "Staff member")}</span>
|
||||
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, color: p.days > 10 ? "var(--color-accent-700)" : "var(--color-neutral-600)", fontVariantNumeric: "tabular-nums" }}>{p.days}d</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13.5, color: "var(--color-neutral-600)", marginTop: 3 }}>{p.what} · {p.orderCode}</div>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 12, flexWrap: "wrap" }}>
|
||||
{p.phone && (
|
||||
<a href={`tel:${p.phone.replace(/\s+/g, "")}`}
|
||||
style={{ minHeight: 44, display: "inline-flex", alignItems: "center", padding: "0 14px", border: "2px solid " + INK, color: INK, textDecoration: "none", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase" }}>Call</a>
|
||||
)}
|
||||
<button onClick={() => act("pickup.contacted", p.id)} disabled={busy || p.contacted}
|
||||
style={{ minHeight: 44, padding: "0 14px", border: "2px solid " + INK, background: p.contacted ? INK : "transparent", color: p.contacted ? "var(--color-bg)" : INK, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: p.contacted ? "default" : "pointer", opacity: busy ? 0.5 : 1 }}>
|
||||
{p.contacted ? "Contacted" : "Mark contacted"}
|
||||
</button>
|
||||
<button onClick={() => act("pickup.pickedUp", p.id)} disabled={busy}
|
||||
style={{ minHeight: 44, padding: "0 14px", border: "2px solid var(--color-accent)", background: "var(--color-accent)", color: "#fff", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer", opacity: busy ? 0.5 : 1 }}>
|
||||
Collected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</MBody>
|
||||
<MNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
/* Receive a delivery — tick lines against the invoice as you unpack. Receiving closes the order:
|
||||
anything short is raised as its own back order, so the shortfall is chased on a live order
|
||||
rather than left sitting on a closed one. */
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { fmtDate, itemMap, label, OPEN_STATUSES, sizeIndexOf, staffMap, staffName, variantName } from "@/lib/compute";
|
||||
import { INK, MBar, MBody, MEmpty, MError, MRow, MRule, MSection, MStepper, MTop } from "@/components/m";
|
||||
|
||||
export default function MReceive() {
|
||||
const { s, mutate, busy } = useSnap();
|
||||
const router = useRouter();
|
||||
const [pick, setPick] = useState<string | null>(null);
|
||||
const [got, setGot] = useState<Record<string, number>>({});
|
||||
const [invoice, setInvoice] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
/* The shortfall is banked here at the moment of receipt, not read off the order afterwards:
|
||||
receiving closes the order, so the refresh that follows drops it out of the open list and both
|
||||
`lines` and `short` fall empty. Reading them on this screen told a storeperson who had just
|
||||
stepped two tunics down to zero that everything on the order arrived, and the back order sat
|
||||
unchased on Ordering. */
|
||||
const [done, setDone] = useState<{ code: string; short: number } | null>(null);
|
||||
|
||||
const byId = useMemo(() => itemMap(s), [s]);
|
||||
const staffById = useMemo(() => staffMap(s), [s]);
|
||||
const open = useMemo(() => s.orders.filter((o) => OPEN_STATUSES.includes(o.status) && o.status !== "Draft"), [s]);
|
||||
const order = open.find((o) => o.id === pick);
|
||||
|
||||
// What's still outstanding on each line after any earlier partial receipt.
|
||||
const lines = useMemo(() => {
|
||||
if (!order) return [];
|
||||
return order.lines.map((l) => {
|
||||
const already = order.receipts.reduce((t, r) => t + r.lines.filter((x) => x.itemId === l.itemId && x.size === l.size).reduce((a, x) => a + x.qty, 0), 0);
|
||||
return { ...l, already, outstanding: Math.max(0, l.qty - already), name: `${variantName(byId[l.itemId], l.size)}`, ok: sizeIndexOf(byId[l.itemId], l.size) >= 0 };
|
||||
}).filter((l) => l.outstanding > 0);
|
||||
}, [order, byId]);
|
||||
|
||||
const q = (id: string, fallback: number) => got[id] ?? fallback;
|
||||
const arriving = lines.reduce((t, l) => t + q(l.id, l.outstanding), 0);
|
||||
const short = lines.filter((l) => q(l.id, l.outstanding) < l.outstanding);
|
||||
|
||||
const receive = async () => {
|
||||
if (!order) return;
|
||||
const r = await mutate("order.receive", {
|
||||
id: order.id, invoice: invoice.trim(),
|
||||
lines: lines.map((l) => ({ lineId: l.id, itemId: l.itemId, size: l.size, arrived: q(l.id, l.outstanding), dest: order.staffId ? "pickup" : "shelf" })),
|
||||
});
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setDone({ code: order.code, short: short.length });
|
||||
};
|
||||
|
||||
if (done) return (
|
||||
<>
|
||||
<MTop title="Received" />
|
||||
<MRule />
|
||||
<MBody><MEmpty title={`${done.code} received`} sub={done.short ? `${done.code} is closed as received, and a back order for the ${done.short} short line${done.short === 1 ? "" : "s"} has been raised automatically. It’s waiting on Ordering on the desktop.` : "Everything on the order arrived. Stock is updated."} /></MBody>
|
||||
<MBar label="Back" onClick={() => router.push("/m/more")} />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Receive" back right={order ? order.code : `${open.length} on order`} />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody>
|
||||
{!order ? (
|
||||
<>
|
||||
<MSection label="On their way" />
|
||||
{open.length === 0
|
||||
? <MEmpty title="Nothing on order" sub="Orders show up here once they’re marked as ordered on the desktop." />
|
||||
: open.map((o) => (
|
||||
<MRow key={o.id} onClick={() => { setPick(o.id); setGot({}); setInvoice(o.invoice || ""); }} mark="ink"
|
||||
title={`${o.supplier || "Supplier"} · ${o.code}`}
|
||||
sub={[o.staffId ? `For ${staffName(staffById[o.staffId])}` : "For stock", o.expected ? `expected ${fmtDate(o.expected)}` : o.status].filter(Boolean).join(" · ")}
|
||||
right={<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 17, fontVariantNumeric: "tabular-nums" }}>{o.lines.reduce((t, l) => t + l.qty, 0)}</span>} />
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ padding: "20px 16px", background: "#fff", borderBottom: "2px solid " + INK }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-accent-700)" }}>{order.supplier || "Supplier"}</div>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em", marginTop: 6 }}>{order.code}</div>
|
||||
<div style={{ fontSize: 13.5, color: "var(--color-neutral-600)", marginTop: 6 }}>
|
||||
Ordered {fmtDate(order.date)}{order.expected ? ` · expected ${fmtDate(order.expected)}` : ""}
|
||||
</div>
|
||||
<input value={invoice} onChange={(e) => setInvoice(e.target.value)} placeholder="Invoice number (optional)" aria-label="Invoice number"
|
||||
style={{ width: "100%", minHeight: 48, padding: "10px 12px", border: "2px solid " + INK, background: "var(--color-bg)", fontSize: 16, fontWeight: 600, marginTop: 14 }} />
|
||||
<button onClick={() => setPick(null)} style={{ marginTop: 12, background: "none", border: 0, padding: 0, color: "var(--color-accent-700)", fontSize: 14, fontWeight: 600, textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>Choose a different order</button>
|
||||
</div>
|
||||
|
||||
<MSection label="Tick each line as you unpack" right="Arrived / ordered" />
|
||||
{lines.length === 0
|
||||
? <MEmpty title="Nothing outstanding" sub="Every line on this order has already been receipted." />
|
||||
: lines.map((l) => (
|
||||
<div key={l.id} style={{ display: "flex", alignItems: "center", gap: 12, padding: 16, borderBottom: "1px solid var(--color-divider)", background: q(l.id, l.outstanding) < l.outstanding ? "#fff" : "var(--color-bg)" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700 }}>{l.name}</div>
|
||||
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 3 }}>
|
||||
{l.outstanding} outstanding{l.already ? ` · ${l.already} already received` : ""}
|
||||
{q(l.id, l.outstanding) < l.outstanding ? ` · ${l.outstanding - q(l.id, l.outstanding)} short` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<MStepper n={q(l.id, l.outstanding)} onChange={(n) => setGot((x) => ({ ...x, [l.id]: n }))} max={l.outstanding} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{lines.length > 0 && (
|
||||
<p style={{ padding: "18px 16px 26px", fontSize: 14, color: "var(--color-neutral-700)", lineHeight: 1.6 }}>
|
||||
{order.staffId ? "Goes onto the pickup list for the staff member it was ordered for." : "Goes onto the shelf."}
|
||||
{short.length > 0 && ` ${short.length} line${short.length === 1 ? "" : "s"} short — ${order.code} closes as received and the shortfall goes onto a new back order.`}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</MBody>
|
||||
{order && lines.length > 0 && <MBar label={busy ? "Receiving…" : `Receive ${arriving} item${arriving === 1 ? "" : "s"}`} glyph="check" onClick={receive} disabled={busy || arriving === 0} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
/* Reorder draft — what fell below par, at quantities that bring each line back up. Adjust and raise.
|
||||
This raises a draft on Ordering; nothing reaches a supplier until someone approves it there. */
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { flaggedNeeds, label, onhand, reorderAt, touched, variantName } from "@/lib/compute";
|
||||
import { INK, MBar, MBody, MEmpty, MError, MRule, MStepper, MTop } from "@/components/m";
|
||||
|
||||
export default function MReorder() {
|
||||
const { s, mutate, busy } = useSnap();
|
||||
const { L, byId, variants } = useDerived();
|
||||
const router = useRouter();
|
||||
const [qty, setQty] = useState<Record<string, number>>({});
|
||||
const [err, setErr] = useState("");
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
|
||||
const needs = useMemo(() => flaggedNeeds(s, L, byId).map((n) => {
|
||||
const k = `${n.itemId}:${n.si}`;
|
||||
return { ...n, key: k, name: `${variantName(byId[n.itemId], n.size)}`, oh: onhand(s, L, k), par: reorderAt(s, k) };
|
||||
}), [s, L, byId]);
|
||||
|
||||
// /m/stock's "N below par" counts every line at or under its reorder point. flaggedNeeds nets off
|
||||
// what is already on an open order and drops a line once that covers it, so the two figures
|
||||
// legitimately differ — and a counter who taps "Reorder 8 lines" and is told nothing needs
|
||||
// ordering stops believing the screen. Report both, and never call a short shelf healthy:
|
||||
// stock on order is not stock on the shelf until somebody receipts it.
|
||||
const belowPar = useMemo(
|
||||
() => variants.filter((v) => touched(s, L, v.key) && onhand(s, L, v.key) <= reorderAt(s, v.key)).length,
|
||||
[s, L, variants],
|
||||
);
|
||||
const covered = belowPar - needs.length;
|
||||
|
||||
const q = (k: string, fallback: number) => qty[k] ?? fallback;
|
||||
const total = needs.reduce((t, n) => t + q(n.key, n.qty), 0);
|
||||
const suppliers = [...new Set(needs.map((n) => n.supplier))];
|
||||
|
||||
const raise = async () => {
|
||||
const bySup: Record<string, typeof needs> = {};
|
||||
for (const n of needs) if (q(n.key, n.qty) > 0) (bySup[n.supplier] ||= []).push(n);
|
||||
const codes: string[] = [];
|
||||
for (const sup of Object.keys(bySup)) {
|
||||
const r = await mutate<{ code: string }>("order.create", {
|
||||
orderFor: "Stock", supplier: sup, replenish: false, notes: "Raised from a stocktake on the app",
|
||||
lines: bySup[sup].map((n) => ({ itemId: n.itemId, size: n.size, qty: q(n.key, n.qty) })),
|
||||
});
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
codes.push(r.result.code);
|
||||
}
|
||||
setDone(codes.join(" · "));
|
||||
};
|
||||
|
||||
if (done) return (
|
||||
<>
|
||||
<MTop title="Reorder" />
|
||||
<MRule />
|
||||
<MBody><MEmpty title={`Draft ${done} raised`} sub="It’s waiting on Ordering. Check the quantities and the supplier reference there, then send it." /></MBody>
|
||||
<MBar label="Back to stock" href="/m/stock" />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Reorder" back right={needs.length ? `${needs.length} line${needs.length === 1 ? "" : "s"}` : undefined} />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody>
|
||||
<div style={{ padding: "20px 16px 22px", borderBottom: "2px solid " + INK }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-accent-700)" }}>Below par</div>
|
||||
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 30, letterSpacing: "-0.03em", lineHeight: 1.05, marginTop: 8 }}>Draft order</h2>
|
||||
<p style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 10, lineHeight: 1.6 }}>
|
||||
{needs.length === 0
|
||||
? belowPar === 0
|
||||
? "Every line is at or above its par level. Nothing needs ordering."
|
||||
: `${belowPar} line${belowPar === 1 ? "" : "s"} ${belowPar === 1 ? "is" : "are"} at or below par, and open orders already cover ${belowPar === 1 ? "it" : "them"}. There’s nothing more to raise — but the shelf stays short until the delivery is receipted.`
|
||||
: covered > 0
|
||||
? `${belowPar} lines are at or below par. Open orders cover ${covered}; the other ${needs.length} still need${needs.length === 1 ? "s" : ""} ordering, at quantities that bring ${needs.length === 1 ? "it" : "each one"} back up.`
|
||||
: `${needs.length} line${needs.length === 1 ? "" : "s"} ${needs.length === 1 ? "is" : "are"} at or below par. Quantities bring each one back up.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{needs.length === 0 ? (
|
||||
<MEmpty
|
||||
title={belowPar ? "Already on order" : "Nothing to reorder"}
|
||||
sub={belowPar
|
||||
? "Every short line is on an open order. Receipt it on Ordering when it lands — until then those shelves are still short."
|
||||
: "Come back after a count, or lower a par level on the desktop if a line should be carrying more."} />
|
||||
) : needs.map((n) => (
|
||||
<div key={n.key} style={{ display: "flex", alignItems: "center", gap: 12, padding: 16, background: "#fff", borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 16.5, fontWeight: 700, letterSpacing: "-0.01em" }}>{n.name}</div>
|
||||
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 3 }}>{n.oh} on hand · par {n.par} · {n.supplier}</div>
|
||||
</div>
|
||||
<MStepper n={q(n.key, n.qty)} onChange={(v) => setQty((x) => ({ ...x, [n.key]: v }))} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{needs.length > 0 && (
|
||||
<p style={{ padding: "18px 16px 26px", fontSize: 14, color: "var(--color-neutral-700)", lineHeight: 1.6 }}>
|
||||
Goes to {suppliers.length === 1 ? suppliers[0] : `${suppliers.length} suppliers`} as a draft. Nothing is sent until you approve it on Ordering.
|
||||
</p>
|
||||
)}
|
||||
</MBody>
|
||||
{needs.length > 0 && <MBar label={busy ? "Raising…" : `Raise the draft — ${total} item${total === 1 ? "" : "s"}`} onClick={raise} disabled={busy || total === 0} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
/* Delivery round — everything waiting, grouped by ward, handed over on the floor with a signature.
|
||||
Reuses the same signature pad and photo upload as the desktop, so a handover looks identical
|
||||
in the record whichever screen recorded it. */
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { daysBetween, itemMap, label, staffMap, staffName, type PickupRec } from "@/lib/compute";
|
||||
import { uploadPhoto } from "@/lib/photo";
|
||||
import { SignaturePad } from "@/components/dialogs";
|
||||
import { INK, MBar, MBody, MEmpty, MError, MNav, MRow, MRule, MSection, MTop, inputStyle } from "@/components/m";
|
||||
|
||||
export default function MRounds() {
|
||||
const { s, mutate, busy } = useSnap();
|
||||
const [pick, setPick] = useState<PickupRec | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const pad = useRef<{ clear: () => void; dataUrl: () => string | null } | null>(null);
|
||||
|
||||
const byId = useMemo(() => itemMap(s), [s]);
|
||||
const staffById = useMemo(() => staffMap(s), [s]);
|
||||
|
||||
/* Grouped by ward — a round is walked ward by ward, not order by order. */
|
||||
const wards = useMemo(() => {
|
||||
const m: Record<string, PickupRec[]> = {};
|
||||
for (const p of s.pickups) {
|
||||
if (p.pickedUp) continue;
|
||||
const w = staffById[p.staffId]?.dept || "No ward recorded";
|
||||
(m[w] ||= []).push(p);
|
||||
}
|
||||
return Object.entries(m).sort((a, b) => a[0].localeCompare(b[0]));
|
||||
}, [s, staffById]);
|
||||
|
||||
const items = (p: PickupRec) => p.lines.map((l) => `${label(byId[l.itemId])} ${l.size}${l.qty > 1 ? ` ×${l.qty}` : ""}`).join(", ");
|
||||
|
||||
const deliver = async () => {
|
||||
if (!pick || saving) return;
|
||||
setSaving(true); setErr("");
|
||||
let sigId: string | null = null;
|
||||
const png = pad.current?.dataUrl() || null;
|
||||
if (png) {
|
||||
const up = await uploadPhoto(mutate, "sig", png);
|
||||
if ("error" in up) { setSaving(false); setErr(up.error); return; }
|
||||
sigId = up.id;
|
||||
}
|
||||
const r = await mutate("pickup.deliver", { id: pick.id, deliveredTo: name.trim(), sigId });
|
||||
setSaving(false);
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setPick(null); setName("");
|
||||
};
|
||||
|
||||
const total = wards.reduce((t, [, ps]) => t + ps.length, 0);
|
||||
|
||||
if (pick) {
|
||||
const st = staffById[pick.staffId];
|
||||
return (
|
||||
<>
|
||||
<MTop title="Hand over" back onBack={() => setPick(null)} right={st?.dept || undefined} />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody>
|
||||
<section style={{ background: INK, color: "var(--color-bg)", padding: "18px 16px" }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-accent-300)" }}>{pick.orderCode}</div>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 26, letterSpacing: "-0.02em", marginTop: 8 }}>{staffName(st, "Staff member")}</div>
|
||||
<div style={{ fontSize: 13.5, color: "var(--color-neutral-400)", marginTop: 8 }}>{items(pick)}</div>
|
||||
</section>
|
||||
|
||||
<MSection label="Received by" />
|
||||
<div style={{ padding: 16 }}>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Name of whoever signs, e.g. the manager" aria-label="Received by" style={inputStyle} />
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-neutral-600)", marginTop: 18, marginBottom: 8 }}>Signature</div>
|
||||
<SignaturePad onReady={(api) => { pad.current = api; }} />
|
||||
<button onClick={() => pad.current?.clear()} style={{ marginTop: 10, background: "none", border: 0, padding: 0, color: "var(--color-accent-700)", fontSize: 14, fontWeight: 600, textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>Clear the signature</button>
|
||||
<p style={{ fontSize: 13.5, color: "var(--color-neutral-700)", marginTop: 18, lineHeight: 1.6 }}>
|
||||
Handing over records the garments as collected — the same as a pickup at the counter — and keeps the name and signature with the record.
|
||||
</p>
|
||||
</div>
|
||||
</MBody>
|
||||
<MBar label={saving ? "Recording…" : "Delivered"} glyph="check" onClick={deliver} disabled={saving || busy} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Round" back right={total ? `${total} to drop off` : undefined} />
|
||||
<MRule />
|
||||
<MBody>
|
||||
{total === 0 ? (
|
||||
<MEmpty title="Nothing to deliver" sub="Everything that has come in has been collected or handed over." />
|
||||
) : wards.map(([ward, ps]) => (
|
||||
<div key={ward}>
|
||||
<MSection label={ward} right={`${ps.length} order${ps.length === 1 ? "" : "s"}`} />
|
||||
{ps.map((p) => {
|
||||
const st = staffById[p.staffId];
|
||||
const days = daysBetween(p.received, s.today);
|
||||
return (
|
||||
<MRow key={p.id} onClick={() => { setPick(p); setName(""); }} mark={days > 10 ? "accent" : "ink"} attention={days > 10}
|
||||
title={staffName(st, "Staff member")}
|
||||
sub={`${items(p)} · waiting ${days}d`}
|
||||
right={<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase" }}>Sign</span>} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</MBody>
|
||||
<MNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
/* One field over people and stock together — at the counter you don't know in advance which one
|
||||
you're after. People take an accent marker, stock lines an ink one. */
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { bcBound, ccOf, label, locMap, locTrail, onhand, touched, reorderAt, staffName, variantName } from "@/lib/compute";
|
||||
import MScan from "@/components/MScan";
|
||||
import { INK, IconScan, MBody, MEmpty, MNav, MRow, MRule, MSection, MTop, inputStyle } from "@/components/m";
|
||||
|
||||
export default function MSearch() {
|
||||
const { s } = useSnap();
|
||||
const { L, byId, variants } = useDerived();
|
||||
const [q, setQ] = useState("");
|
||||
const [scan, setScan] = useState(false);
|
||||
const locs = useMemo(() => locMap(s), [s]);
|
||||
|
||||
const { people, lines } = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
const stockRows = variants.filter((v) => touched(s, L, v.key)).map((v) => ({
|
||||
// Only the bound supplier code: it is what someone reads off a label and types in here.
|
||||
...v, oh: onhand(s, L, v.key), par: reorderAt(s, v.key), code: bcBound(s, v.item, v.si),
|
||||
where: locTrail(locs, s.placed[v.key], 0), name: `${variantName(byId[v.itemId], v.size)}`,
|
||||
}));
|
||||
if (!needle) {
|
||||
const seen: Record<string, string> = {};
|
||||
for (const i of s.issues) seen[i.staffId] = i.date > (seen[i.staffId] || "") ? i.date : seen[i.staffId];
|
||||
return {
|
||||
people: s.staff.filter((x) => !x.inactive).sort((a, b) => (seen[b.id] || "").localeCompare(seen[a.id] || "")).slice(0, 5),
|
||||
lines: stockRows.filter((r) => r.oh <= r.par).slice(0, 5),
|
||||
};
|
||||
}
|
||||
return {
|
||||
people: s.staff.filter((x) => !x.inactive && `${x.first} ${x.last} ${x.num} ${x.dept}`.toLowerCase().includes(needle)).slice(0, 12),
|
||||
lines: stockRows.filter((r) => `${r.name} ${r.code} ${r.where}`.toLowerCase().includes(needle)).slice(0, 20),
|
||||
};
|
||||
}, [s, L, variants, byId, q, locs]);
|
||||
|
||||
const empty = q.trim() && people.length === 0 && lines.length === 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Search" right={q.trim() ? `${people.length + lines.length} result${people.length + lines.length === 1 ? "" : "s"}` : undefined} />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ padding: 16, borderBottom: "2px solid " + INK, display: "flex", gap: 8 }}>
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Name, staff number, garment or code" autoFocus
|
||||
aria-label="Search people and stock" style={{ ...inputStyle, flex: 1 }} />
|
||||
<button onClick={() => setScan(true)} aria-label="Scan a barcode"
|
||||
style={{ width: 56, minHeight: 48, border: "2px solid " + INK, background: "var(--color-accent)", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer" }}>
|
||||
<IconScan />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{empty ? (
|
||||
<MEmpty title="Nothing matches that" sub="Try a surname, a staff number, or part of a garment name. Scanning a label finds it straight away." />
|
||||
) : (
|
||||
<>
|
||||
{people.length > 0 && (
|
||||
<>
|
||||
<MSection label={q.trim() ? "People" : "Recently served"} />
|
||||
{people.map((st) => (
|
||||
<MRow key={st.id} href={`/m/person/${st.id}`} mark="accent" title={staffName(st)}
|
||||
sub={[st.num, st.dept || st.group, ccOf(s, st) && `CC ${ccOf(s, st)}`].filter(Boolean).join(" · ")} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{lines.length > 0 && (
|
||||
<>
|
||||
<MSection label={q.trim() ? "Stock" : "At or below par"} right="On hand / par" />
|
||||
{lines.map((r) => (
|
||||
<MRow key={r.key} href="/m/stock" mark="ink" attention={r.oh <= r.par} title={r.name}
|
||||
sub={[r.code || "No barcode bound", r.where].filter(Boolean).join(" · ")}
|
||||
right={<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, fontVariantNumeric: "tabular-nums", color: r.oh <= r.par ? "var(--color-accent-700)" : INK }}>{r.oh}<span style={{ color: "var(--color-neutral-700)" }}>/{r.par}</span></span>} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</MBody>
|
||||
<MNav />
|
||||
{scan && <MScan title="Scan to find" onHit={(raw) => {
|
||||
const k = s.barcodes[raw.trim()];
|
||||
const v = k ? variants.find((x) => x.key === k) : undefined;
|
||||
setQ(v ? `${variantName(byId[v.itemId], v.size)}` : raw.trim());
|
||||
setScan(false);
|
||||
}} onClose={() => setScan(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
/* Settings — only what the app itself controls. Everything else about the facility lives on the
|
||||
desktop, so there is one place a setting can be wrong rather than two. */
|
||||
import { DELETE_ACCOUNT_URL, PRIVACY_URL, TERMS_URL } from "@/lib/links";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { locTree } from "@/lib/compute";
|
||||
import { clearAllCounts } from "@/lib/opencount";
|
||||
import { INK, MBody, MError, MRow, MRule, MSection, MStepper, MTop } from "@/components/m";
|
||||
|
||||
const BEEP_KEY = "tc.beep";
|
||||
|
||||
export default function MSettings() {
|
||||
const { s, isAdmin, mutate, busy } = useSnap();
|
||||
const router = useRouter();
|
||||
const [beep, setBeep] = useState(true);
|
||||
const [gate, setGate] = useState(s.settings.varianceReason);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
useEffect(() => { try { setBeep(localStorage.getItem(BEEP_KEY) !== "0"); } catch { /* blocked store */ } }, []);
|
||||
const toggleBeep = () => {
|
||||
const next = !beep;
|
||||
setBeep(next);
|
||||
try { localStorage.setItem(BEEP_KEY, next ? "1" : "0"); } catch { /* blocked store */ }
|
||||
};
|
||||
|
||||
const saveGate = async (n: number) => {
|
||||
setGate(n);
|
||||
const r = await mutate("settings.update", { varianceReason: n });
|
||||
if (!r.ok) { setErr(r.error); setGate(s.settings.varianceReason); }
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
// Their part-counted shelves go with them. The tally is keyed per person, so what is left
|
||||
// behind can never be read by the next signed-in user — but it is theirs, it is on a phone
|
||||
// that is passed around a linen room, and nothing would ever clear it again once they have
|
||||
// gone. Done before the logout POST so a failed request still leaves the device tidy.
|
||||
clearAllCounts(s.session.userId);
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
// /m/login, not /auth: /auth is the website's two-pane desktop sign-in, and landing on it
|
||||
// inside a phone app is how you make someone think the app is broken. A full navigation
|
||||
// rather than router.push, because the session cookie has just been cleared and every page
|
||||
// behind it is server-rendered.
|
||||
window.location.replace("/m/login");
|
||||
};
|
||||
|
||||
const locs = locTree(s).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Settings" back />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody>
|
||||
<div style={{ padding: "20px 16px 22px", borderBottom: "2px solid " + INK }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 26, letterSpacing: "-0.03em" }}>{s.session.name}</div>
|
||||
<div style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 6 }}>
|
||||
{[s.session.title || s.session.role, s.session.email].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MSection label="Site" />
|
||||
<MRow title={s.settings.facility} sub={s.settings.location} right={<span style={{ fontSize: 13, color: "var(--color-neutral-600)" }}>Desktop</span>} />
|
||||
<MRow title="Locations" sub={locs ? `${locs} ${locs === 1 ? "shelf" : "shelves"} and bays set up` : "None set up yet"} right={<span style={{ fontSize: 13, color: "var(--color-neutral-600)" }}>Desktop</span>} />
|
||||
|
||||
<MSection label="Counting" />
|
||||
<MRow title="Beep and buzz on a scan" sub="This device only"
|
||||
right={
|
||||
<button onClick={toggleBeep} role="switch" aria-checked={beep} aria-label="Beep and buzz on a scan"
|
||||
style={{ minWidth: 72, minHeight: 44, border: "2px solid " + INK, background: beep ? INK : "transparent", color: beep ? "var(--color-bg)" : INK, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer" }}>
|
||||
{beep ? "On" : "Off"}
|
||||
</button>
|
||||
} />
|
||||
<MRow title="Reason required at" sub={isAdmin ? "A count gap this big has to say why" : "Set by an administrator"}
|
||||
right={isAdmin
|
||||
? <MStepper n={gate} onChange={saveGate} min={1} max={99} />
|
||||
: <span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19 }}>{gate}</span>} />
|
||||
|
||||
{/* Deleting an account has to be reachable from inside the app, not only from a web page
|
||||
somebody has to know exists — this is the app that created the facility in the first
|
||||
place, and it is a Play requirement besides. These rows go to the site's own pages
|
||||
rather than a second deletion screen: there is one account-deletion flow, and it is
|
||||
the one on the website.
|
||||
|
||||
Absolute, and on the web they open in a new tab. On a phone they cannot: this shell
|
||||
registers no browser plugin, so nothing here is able to hand a URL to Chrome, and the
|
||||
page loads over the top of the counter with the site's own nav and no tab bar. The row
|
||||
says as much before the tap, and the hardware back button comes straight back. The other
|
||||
two ways out were worse: a row that does nothing when tapped, or no deletion route in
|
||||
the app at all. Somebody who signs in rather than signing up never passes the links on
|
||||
the create-account screen, so this is the only place the signed-in counter app names
|
||||
them at all. */}
|
||||
{(DELETE_ACCOUNT_URL || PRIVACY_URL || TERMS_URL) && <MSection label="Your account and your data" />}
|
||||
{DELETE_ACCOUNT_URL && <MRow href={DELETE_ACCOUNT_URL} external mark="ink" title="Delete your account" sub="How to do it, and exactly what goes with it" />}
|
||||
{PRIVACY_URL && <MRow href={PRIVACY_URL} external mark="ink" title="Privacy policy" sub="What ThreadCount stores, and what it never does" />}
|
||||
{TERMS_URL && <MRow href={TERMS_URL} external mark="ink" title="Terms of use" sub="What you and ThreadCount each agree to" />}
|
||||
|
||||
<div style={{ padding: 16 }}>
|
||||
<button onClick={signOut} disabled={busy}
|
||||
style={{ width: "100%", minHeight: 64, border: "2px solid var(--color-accent)", background: "transparent", color: "var(--color-accent-700)", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase", textAlign: "left", padding: "0 20px", cursor: "pointer" }}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ padding: "0 16px 26px", fontSize: 13.5, color: "var(--color-neutral-600)", lineHeight: 1.6 }}>
|
||||
Ordering, reports, the catalogue and the staff register are all on the desktop site.
|
||||
</p>
|
||||
</MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
/* Signed in — onboarding screen 05. A beat of confirmation before the app: which linen room you
|
||||
are now in, and the two figures that decide what the morning looks like. */
|
||||
import { Suspense, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { daysBetween, locTree, onhand, reorderAt, touched } from "@/lib/compute";
|
||||
|
||||
function SignedInInner() {
|
||||
const { s } = useSnap();
|
||||
const { L, variants } = useDerived();
|
||||
const isNew = useSearchParams().get("new") === "1";
|
||||
|
||||
const d = useMemo(() => {
|
||||
const counted = variants.filter((v) => touched(s, L, v.key));
|
||||
const low = counted.filter((v) => onhand(s, L, v.key) <= reorderAt(s, v.key)).length;
|
||||
const lastCount = s.stocktakes.find((t) => t.mode !== "preloved");
|
||||
return {
|
||||
lines: counted.length,
|
||||
low,
|
||||
since: lastCount ? daysBetween(lastCount.date, s.today) : null,
|
||||
locations: locTree(s).length,
|
||||
};
|
||||
}, [s, L, variants]);
|
||||
|
||||
const row: React.CSSProperties = { display: "flex", alignItems: "baseline", gap: 12, padding: "14px 0", borderBottom: "1px solid var(--color-divider)" };
|
||||
const lab: React.CSSProperties = { flex: 1, fontSize: 14.5, color: "var(--color-neutral-800)" };
|
||||
// Figures get the big numeral; "Never counted" is a sentence and was being set at the same size,
|
||||
// where it ran nearly the width of the row and read as the loudest thing on the screen.
|
||||
const val = (hot?: boolean, text?: boolean): React.CSSProperties => ({ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: text ? 14.5 : 19, fontVariantNumeric: "tabular-nums", color: hot ? "var(--color-accent-700)" : "var(--color-text)" });
|
||||
const overdue = d.since !== null && d.since > 30;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section style={{ background: "var(--color-accent)", color: "#fff", padding: "calc(34px + env(safe-area-inset-top, 0px)) 24px 34px" }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.14em", textTransform: "uppercase", color: "rgba(255,255,255,0.88)" }}>
|
||||
{isNew ? "Facility created" : "Signed in"}
|
||||
</div>
|
||||
<h1 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 36, lineHeight: 1.02, letterSpacing: "-0.03em", marginTop: 10 }}>
|
||||
{s.settings.facility}
|
||||
</h1>
|
||||
</section>
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "24px 24px 30px", background: "var(--color-bg)" }}>
|
||||
{isNew ? (
|
||||
<p style={{ fontSize: 15, lineHeight: 1.6, color: "var(--color-neutral-800)" }}>
|
||||
Your linen room is set up and you are its first administrator. There is nothing in it
|
||||
yet — the catalogue, staff register and cost centres come in from CSV on the desktop
|
||||
site, and take about an afternoon.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-neutral-600)" }}>
|
||||
{s.settings.location || "Linen Room"}
|
||||
</div>
|
||||
<div style={{ marginTop: 14, borderTop: "2px solid var(--color-text)" }}>
|
||||
<div style={row}><span style={lab}>Lines on the shelf</span><span style={val()}>{d.lines}</span></div>
|
||||
<div style={row}><span style={lab}>Below par</span><span style={val(d.low > 0)}>{d.low}</span></div>
|
||||
<div style={row}>
|
||||
<span style={lab}>Since the last count</span>
|
||||
<span style={val(overdue, d.since === null)}>{d.since === null ? "Never counted" : `${d.since} day${d.since === 1 ? "" : "s"}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
{d.locations === 0 && (
|
||||
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: "var(--color-neutral-800)", background: "#fff", borderLeft: "6px solid var(--color-accent)", padding: 14, marginTop: 20 }}>
|
||||
No shelves set up yet, so there is nothing to count against. Add them in Settings on
|
||||
the desktop, then place each size on one from Inventory.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link href="/m" replace
|
||||
style={{ flex: "0 0 auto", height: 66, background: "var(--color-text)", color: "var(--color-bg)", display: "flex", alignItems: "center", gap: 12, padding: "0 24px calc(0px + env(safe-area-inset-bottom, 0px))", textDecoration: "none", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase" }}>
|
||||
<span style={{ flex: 1 }}>Start the day</span>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="square" aria-hidden="true"><path d="M5 12h14" /><path d="m13 6 6 6-6 6" /></svg>
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MSignedIn() {
|
||||
return <Suspense fallback={null}><SignedInInner /></Suspense>;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
/* Stock — on hand against par, worst first, with the three things you do about it underneath. */
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { bcBound, label, locMap, locTrail, onhand, touched, reorderAt, variantName } from "@/lib/compute";
|
||||
import { INK, IconRight, MBody, MEmpty, MNav, MRow, MRule, MSection, MTop, inputStyle } from "@/components/m";
|
||||
|
||||
export default function MStock() {
|
||||
const { s } = useSnap();
|
||||
const { L, byId, variants } = useDerived();
|
||||
const [q, setQ] = useState("");
|
||||
const locs = useMemo(() => locMap(s), [s]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
return variants
|
||||
.filter((v) => touched(s, L, v.key))
|
||||
.map((v) => {
|
||||
const oh = onhand(s, L, v.key), par = reorderAt(s, v.key);
|
||||
// The bound supplier code, not bcFor()'s generated stand-in: this line is read against a
|
||||
// label on a garment, and a number printed on nothing is worse than saying there isn't one.
|
||||
return { ...v, oh, par, low: oh <= par, code: bcBound(s, v.item, v.si), where: locTrail(locs, s.placed[v.key], 0), name: `${variantName(byId[v.itemId], v.size)}` };
|
||||
})
|
||||
.filter((r) => !needle || `${r.name} ${r.code} ${r.where}`.toLowerCase().includes(needle))
|
||||
// Short lines first, then furthest below par — the shelf you have to do something about.
|
||||
.sort((a, b) => Number(b.low) - Number(a.low) || (a.oh - a.par) - (b.oh - b.par) || a.name.localeCompare(b.name));
|
||||
}, [s, L, variants, byId, q, locs]);
|
||||
|
||||
const low = rows.filter((r) => r.low).length;
|
||||
const link: React.CSSProperties = { display: "flex", alignItems: "center", gap: 12, minHeight: 64, padding: "0 20px", border: "2px solid " + INK, color: INK, textDecoration: "none", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase" };
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Stock" right={low ? `${low} below par` : `${rows.length} line${rows.length === 1 ? "" : "s"}`} />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ padding: 16, borderBottom: "2px solid " + INK }}>
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Garment, code or shelf" aria-label="Filter stock" style={inputStyle} />
|
||||
</div>
|
||||
<MSection label="Line" right="On hand / par" />
|
||||
{rows.length === 0
|
||||
? <MEmpty title="Nothing in stock yet" sub="This list is what has moved. Add a garment to the catalogue and scan some in, and it appears here." />
|
||||
: rows.slice(0, 200).map((r) => (
|
||||
<MRow key={r.key} mark={r.low ? "accent" : "ink"} attention={r.low}
|
||||
title={r.name}
|
||||
sub={[r.code || "No barcode bound", `par ${r.par}`, r.where].filter(Boolean).join(" · ")}
|
||||
right={
|
||||
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 21, fontVariantNumeric: "tabular-nums", color: r.low ? "var(--color-accent-700)" : INK }}>
|
||||
{r.oh}<span style={{ color: "var(--color-neutral-700)", fontSize: 17 }}>/{r.par}</span>
|
||||
</span>
|
||||
} />
|
||||
))}
|
||||
|
||||
<div style={{ padding: 16, display: "grid", gap: 12 }}>
|
||||
<Link href="/m/reorder" style={link}><span style={{ flex: 1 }}>{low ? `Reorder ${low} line${low === 1 ? "" : "s"}` : "Reorder draft"}</span><IconRight /></Link>
|
||||
<Link href="/m/variance" style={link}><span style={{ flex: 1 }}>Variance over time</span><IconRight /></Link>
|
||||
<Link href="/m/label" style={link}><span style={{ flex: 1 }}>Reprint a label</span><IconRight /></Link>
|
||||
{/* Stock only lists variants with history, so a garment added five minutes ago isn’t here
|
||||
yet. The catalogue is where it actually lives. */}
|
||||
<Link href="/m/catalogue" style={link}><span style={{ flex: 1 }}>Catalogue</span><IconRight /></Link>
|
||||
</div>
|
||||
</MBody>
|
||||
<MNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
/* Variance over time — the pattern, not the number. A line short at every count is a different
|
||||
problem from one short once, so the chart is the point and the latest gap is the caption. */
|
||||
import { useMemo } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { itemMap, monthLabel, variantName } from "@/lib/compute";
|
||||
import { INK, MBody, MEmpty, MNav, MRule, MTop } from "@/components/m";
|
||||
|
||||
const MAX_BAR = 56;
|
||||
|
||||
export default function MVarianceOverTime() {
|
||||
const { s } = useSnap();
|
||||
|
||||
const { rows, counts } = useMemo(() => {
|
||||
const byId = itemMap(s);
|
||||
// Oldest-first, shelf counts only, last six.
|
||||
const takes = s.stocktakes.filter((t) => t.mode !== "preloved").slice(0, 6).reverse();
|
||||
const seen: Record<string, { name: string; gaps: (number | null)[] }> = {};
|
||||
takes.forEach((t, col) => {
|
||||
for (const l of t.lines) {
|
||||
const k = `${l.itemId}:${l.si}`;
|
||||
const it = byId[l.itemId];
|
||||
if (!it) continue;
|
||||
(seen[k] ||= { name: variantName(it, it.sizes[l.si] ?? l.si), gaps: takes.map(() => null) });
|
||||
seen[k].gaps[col] = l.counted - l.sys;
|
||||
}
|
||||
});
|
||||
const out = Object.entries(seen).map(([k, v]) => {
|
||||
const known = v.gaps.filter((g): g is number => g !== null);
|
||||
const latest = [...v.gaps].reverse().find((g) => g !== null) ?? 0;
|
||||
const shortEvery = known.length >= 2 && known.every((g) => g < 0);
|
||||
const worsening = known.length >= 3 && known[known.length - 1] < known[0] && known[known.length - 1] < 0;
|
||||
const verdict = known.every((g) => g === 0) ? "Steady"
|
||||
: shortEvery ? `Short at every count since ${monthLabel(takes[v.gaps.findIndex((g) => g !== null)]?.date.slice(0, 7) || "", { month: "long" })}`
|
||||
: worsening ? "Drifting short"
|
||||
: latest === 0 ? "Back in line" : "Occasional gap";
|
||||
const persistent = shortEvery || worsening;
|
||||
return { key: k, name: v.name, gaps: v.gaps, latest, verdict, persistent };
|
||||
});
|
||||
// Worst pattern first: persistent problems, then biggest gap.
|
||||
out.sort((a, b) => Number(b.persistent) - Number(a.persistent) || a.latest - b.latest || a.name.localeCompare(b.name));
|
||||
return { rows: out, counts: takes };
|
||||
}, [s]);
|
||||
|
||||
const peak = Math.max(1, ...rows.flatMap((r) => r.gaps.map((g) => Math.abs(g ?? 0))));
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Variance" back right={`${counts.length} count${counts.length === 1 ? "" : "s"}`} />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ padding: "20px 16px 22px", borderBottom: "2px solid " + INK }}>
|
||||
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 30, letterSpacing: "-0.03em", lineHeight: 1.05 }}>What keeps going missing</h2>
|
||||
<p style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 8 }}>
|
||||
{counts.length ? `Gap against expected at each count since ${monthLabel(counts[0].date.slice(0, 7), { month: "long" })}.` : "Nothing counted yet."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<MEmpty title="No counts to compare yet" sub="File two stocktakes and the pattern starts showing here." />
|
||||
) : rows.slice(0, 40).map((r) => (
|
||||
<div key={r.key} style={{ padding: "18px 16px 14px", background: r.persistent ? "#fff" : "var(--color-bg)", borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 17, fontWeight: 700, letterSpacing: "-0.01em" }}>{r.name}</div>
|
||||
<div style={{ fontSize: 13.5, color: "var(--color-neutral-600)", marginTop: 3 }}>{r.verdict}</div>
|
||||
</div>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: r.latest === 0 ? 21 : 24, letterSpacing: "-0.02em", color: r.latest === 0 ? INK : "var(--color-accent-700)", fontVariantNumeric: "tabular-nums" }}>
|
||||
{r.latest === 0 ? "Match" : r.latest > 0 ? `+${r.latest}` : `−${-r.latest}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tcx-chart" style={{ marginTop: 18 }} role="img"
|
||||
aria-label={`Gap at each count: ${r.gaps.map((g, i) => `${counts[i] ? monthLabel(counts[i].date.slice(0, 7), { month: "short" }) : ""} ${g === null ? "not counted" : g}`).join(", ")}`}>
|
||||
{r.gaps.map((g, i) => {
|
||||
const mag = Math.abs(g ?? 0);
|
||||
const h = g === null ? 4 : Math.max(4, Math.round((mag / peak) * MAX_BAR));
|
||||
const col = g === null ? "var(--color-neutral-300)" : mag === 0 ? "var(--color-divider)" : mag >= 3 ? "var(--color-accent)" : INK;
|
||||
return <i key={i} style={{ height: h, background: col }} />;
|
||||
})}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 5, marginTop: 6 }}>
|
||||
{counts.map((t, i) => (
|
||||
<span key={i} style={{ flex: 1, textAlign: "center", fontSize: 10.5, fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--color-neutral-600)" }}>
|
||||
{monthLabel(t.date.slice(0, 7), { month: "short" })}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</MBody>
|
||||
<MNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from "next";
|
||||
import Analytics from "@/components/Analytics";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* `title` has to be absolute here. As a plain string it went through the root layout's
|
||||
"%s — ThreadCount" template and every screen of the phone app, sign-in included, was titled
|
||||
"ThreadCount — ThreadCount" in the tab, in history and in a bookmark. The template is re-declared
|
||||
for the screens below that name themselves, and the canonical points at the app rather than
|
||||
inheriting the marketing homepage's. */
|
||||
export const metadata: Metadata = {
|
||||
title: { absolute: "ThreadCount — the linen room counter", template: "%s — ThreadCount" },
|
||||
alternates: { canonical: "/m" },
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
/* The app is a fixed-height column: bars don't scroll, only the body does.
|
||||
*
|
||||
* No maximumScale. Pinning the zoom kept the layout tidy and took pinch-to-zoom away from everyone
|
||||
* on every screen — WCAG 1.4.4, and it matters more than tidiness on a ward phone held at arm's
|
||||
* length in bad light. The bundled shell's own index.html had the same line removed. */
|
||||
export const viewport = { width: "device-width", initialScale: 1, viewportFit: "cover" as const, themeColor: "#201e1d" };
|
||||
|
||||
/* Only the shell. Sign in and create account live under /m but must be reachable without a
|
||||
session — they are how you get one — so the session check sits in (app) with everything else. */
|
||||
export default function MobileLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="tcx-app" role="main">
|
||||
{/* The shell itself is the main landmark. No skip link on the phone surfaces: navigation is
|
||||
the bar at the bottom, after the content, so there is no repeated block in front to bypass.
|
||||
role on the existing box rather than a <main> wrapper — the shell is a fixed-height flex
|
||||
column, and the display:contents that an extra element would need has a long history of
|
||||
dropping the landmark out of the accessibility tree. */}
|
||||
{children}
|
||||
{/* Mounted on /m rather than inside (app): sign in and create account are app screens too,
|
||||
and they are where the Android shell lands first. */}
|
||||
<Analytics site="app" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
/* Sign in is a client component and can't export metadata of its own, so it gets this. Without it
|
||||
the page inherited the app shell's title and a browser tab, a history entry and a bookmark for
|
||||
the sign-in screen all read as if they were the site's front page. */
|
||||
export const metadata: Metadata = {
|
||||
title: "Sign in",
|
||||
alternates: { canonical: "/m/login" },
|
||||
};
|
||||
|
||||
export default function MLoginLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
/* Sign in — onboarding screen 03. Public: this is the one /m route someone reaches without a
|
||||
session, because it is how they get one. */
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import Turnstile, { awaitTurnstile, resetTurnstile, turnstileOn } from "@/components/Turnstile";
|
||||
import { track } from "@/lib/analytics";
|
||||
import { MAuthError, MAuthFooter, MAuthHeader, MField, MShowHide, authInput, authLink } from "@/components/MAuth";
|
||||
|
||||
function LoginInner() {
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
const [email, setEmail] = useState("");
|
||||
const [pw, setPw] = useState("");
|
||||
const [show, setShow] = useState(false);
|
||||
const [cfToken, setCfToken] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [reveal, setReveal] = useState(false);
|
||||
const [forgot, setForgot] = useState(false);
|
||||
const [sentReset, setSentReset] = useState(false);
|
||||
const [ticket, setTicket] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
|
||||
const next = (() => {
|
||||
const n = sp.get("next") || "";
|
||||
return n.startsWith("/m") && !n.startsWith("//") ? n : "/m/signed-in";
|
||||
})();
|
||||
|
||||
async function sendReset() {
|
||||
setErr("");
|
||||
if (!email.trim()) { setErr("Put your work email in the box above first."); return; }
|
||||
setBusy(true);
|
||||
const token = cfToken || (turnstileOn() ? await awaitTurnstile() : "");
|
||||
await fetch("/api/auth/forgot", {
|
||||
method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email, cfToken: token }),
|
||||
}).catch(() => {});
|
||||
setBusy(false);
|
||||
setCfToken(""); resetTurnstile();
|
||||
// Shown whatever the server said: the answer must not reveal whether the address has an account.
|
||||
setSentReset(true);
|
||||
}
|
||||
|
||||
async function submitCode() {
|
||||
setErr("");
|
||||
if (!code.trim()) { setErr("Enter the six-digit code from your authenticator app."); return; }
|
||||
setBusy(true);
|
||||
const r = await fetch("/api/auth/2fa", {
|
||||
method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ticket, code }),
|
||||
}).catch(() => null);
|
||||
const j = r ? await r.json().catch(() => ({})) : {};
|
||||
setBusy(false);
|
||||
// A dropped connection is not a wrong code, and it used to leave the button spinning for ever
|
||||
// with nothing said. Nothing is signed in either way, so the advice is simply to try again.
|
||||
if (!r) {
|
||||
track("signin_failed", { reason: "network" });
|
||||
setErr("Couldn’t reach the server. Check the connection and try again.");
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
track("signin_failed", { reason: "second_factor" });
|
||||
setErr(j.error || "That code isn’t right.");
|
||||
if (r.status === 400) setTicket(""); // the ticket expired — start again
|
||||
return;
|
||||
}
|
||||
track("signin");
|
||||
window.location.replace(next);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!email.trim() || !pw) { setErr("Enter your email and password."); return; }
|
||||
setBusy(true);
|
||||
// The widget draws nothing in quiet mode, so nobody can see that it hasn't finished. Wait for
|
||||
// the token rather than posting an empty one and blaming the person for it.
|
||||
const token = cfToken || (turnstileOn() ? await awaitTurnstile() : "");
|
||||
if (turnstileOn() && !token) {
|
||||
// Eight seconds and no token. Either the check needs an interaction we've asked Turnstile
|
||||
// not to draw, or it couldn't reach Cloudflare at all. Show the widget rather than send an
|
||||
// empty token and let the server answer with a check the person was never shown.
|
||||
setBusy(false);
|
||||
setReveal(true);
|
||||
// How often the invisible check has to show itself. If this climbs, the quiet widget is
|
||||
// costing people sign-ins and should come back out.
|
||||
track("security_check_shown", { screen: "signin" });
|
||||
setErr("Finish the security check below, then try again.");
|
||||
return;
|
||||
}
|
||||
const r = await fetch("/api/auth/login", {
|
||||
method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email, password: pw, cfToken: token }),
|
||||
}).catch(() => null);
|
||||
const j = r ? await r.json().catch(() => ({})) : {};
|
||||
setBusy(false);
|
||||
/* The request never arrived. Ward wifi drops, and without this the promise rejected, `busy`
|
||||
never cleared and the sign-in button sat disabled on its spinner until the app was killed —
|
||||
which reads as ThreadCount refusing to let you in. Nothing was signed in, so the token is
|
||||
spent and the widget reset like any other failed attempt. */
|
||||
if (!r) {
|
||||
track("signin_failed", { reason: "network" });
|
||||
setErr("Couldn’t reach the server. Check the connection and try again.");
|
||||
setCfToken(""); resetTurnstile();
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
// Whether it was the password or the security check — no email, no message.
|
||||
track("signin_failed", { reason: r.status === 429 ? "throttled" : r.status === 400 ? "security_check" : "credentials" });
|
||||
setErr(j.error || "That email and password don’t match.");
|
||||
setCfToken(""); resetTurnstile();
|
||||
return;
|
||||
}
|
||||
// The password was right but the account has a second factor; nothing is signed in yet.
|
||||
if (j.need2fa) { setTicket(j.ticket); track("signin_2fa_required"); return; }
|
||||
track("signin");
|
||||
// A full navigation, not a router push: the session cookie has just changed and every /m page
|
||||
// is server-rendered from it. replace(), not assign(), so the hardware back button doesn't
|
||||
// land a signed-in person back on the sign-in screen.
|
||||
window.location.replace(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MAuthHeader kicker="Linen room access" title="Sign in" />
|
||||
<div style={{ height: 4, flex: "0 0 4px", background: "var(--color-accent)" }} />
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "26px 24px 30px", display: "grid", gap: 26, alignContent: "start" }}>
|
||||
<p style={{ fontSize: 14, lineHeight: 1.5, color: "var(--color-neutral-800)", maxWidth: 290 }}>
|
||||
{ticket ? "Your password was right. Now the code from your authenticator app." : "Use the account your linen services manager set up."}
|
||||
</p>
|
||||
<MAuthError msg={err} />
|
||||
{ticket ? (
|
||||
<>
|
||||
<MField n="01" label="Six-digit code">
|
||||
{(c) => (
|
||||
<input {...c} style={authInput} inputMode="numeric" autoComplete="one-time-code" autoFocus
|
||||
autoCapitalize="none" autoCorrect="off" spellCheck={false} enterKeyHint="go"
|
||||
placeholder="000000" value={code}
|
||||
onChange={(e) => { setCode(e.target.value); setErr(""); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") submitCode(); }} />
|
||||
)}
|
||||
</MField>
|
||||
<p style={{ fontSize: 12.5, lineHeight: 1.6, color: "var(--color-neutral-700)" }}>
|
||||
Lost your phone? A recovery code works here instead.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MField n="01" label="Work email">
|
||||
{(c) => (
|
||||
<input {...c} style={authInput} type="email" inputMode="email" autoCapitalize="none" autoCorrect="off"
|
||||
spellCheck={false} autoComplete="email" enterKeyHint="next" placeholder="you@yourfacility.org"
|
||||
value={email} onChange={(e) => { setEmail(e.target.value); setErr(""); }} />
|
||||
)}
|
||||
</MField>
|
||||
<MField n="02" label="Password" right={<MShowHide on={show} onToggle={() => setShow(!show)} />}>
|
||||
{(c) => (
|
||||
<input {...c} style={authInput} type={show ? "text" : "password"} autoComplete="current-password"
|
||||
enterKeyHint="go" value={pw}
|
||||
onChange={(e) => { setPw(e.target.value); setErr(""); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") submit(); }} />
|
||||
)}
|
||||
</MField>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* This used to explain that there was no reset and to go and find an admin — which was a
|
||||
dead end for the admin themselves, and deleting the last admin deletes the facility.
|
||||
Hidden during the code step, where it would be answering a question nobody asked. */}
|
||||
{ticket ? null : sentReset ? (
|
||||
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: "var(--color-neutral-800)", background: "#fff", borderLeft: "6px solid var(--color-accent)", padding: 14 }}>
|
||||
If that address has an account, a reset link is on its way. It works once and expires in
|
||||
an hour.
|
||||
</p>
|
||||
) : forgot ? (
|
||||
<div style={{ background: "#fff", borderLeft: "6px solid var(--color-text)", padding: 14 }}>
|
||||
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: "var(--color-neutral-800)", margin: 0 }}>
|
||||
Put your work email in the box above and we’ll send a link to set a new password.
|
||||
</p>
|
||||
<button onClick={sendReset} disabled={busy}
|
||||
style={{ marginTop: 12, background: "none", border: 0, padding: 0, fontSize: 13.5, fontWeight: 800, color: "var(--color-accent-700)", cursor: busy ? "wait" : "pointer" }}>
|
||||
{busy ? "Sending…" : "Send me a reset link"}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setForgot(true)}
|
||||
style={{ justifySelf: "start", background: "none", border: 0, padding: 0, fontSize: 13, fontWeight: 600, color: "var(--color-accent-700)", cursor: "pointer" }}>
|
||||
Forgot password
|
||||
</button>
|
||||
)}
|
||||
{turnstileOn() && <Turnstile onToken={setCfToken} action="login" quiet={!reveal} />}
|
||||
</div>
|
||||
<MAuthFooter
|
||||
secondary={ticket
|
||||
? <button onClick={() => { setTicket(""); setCode(""); setErr(""); }} style={{ background: "none", border: 0, padding: 0, font: "inherit", fontSize: 13, fontWeight: 700, color: "var(--color-accent-700)", cursor: "pointer" }}>Start again</button>
|
||||
: <>No account yet? <Link href="/m/signup" style={authLink}>Sign up</Link></>}
|
||||
label={ticket ? "Verify" : "Sign in"} onSubmit={ticket ? submitCode : submit} busy={busy} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MLogin() {
|
||||
return <Suspense fallback={null}><LoginInner /></Suspense>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
/* Same reason as the sign-in screen next door: a client page can't name itself, and "create an
|
||||
account" is not the site's front page. */
|
||||
export const metadata: Metadata = {
|
||||
title: "Create an account",
|
||||
alternates: { canonical: "/m/signup" },
|
||||
};
|
||||
|
||||
export default function MSignupLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { switches } from "@/lib/switches";
|
||||
import MSignup from "@/components/MSignup";
|
||||
|
||||
/* Create account — onboarding screen 04. The screen itself is components/MSignup.tsx; this wrapper
|
||||
exists to tell it whether plans are live, which only the server knows. */
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MSignupPage() {
|
||||
const sw = await switches();
|
||||
return <MSignup plansLive={sw.plansLive} />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import AccountScreen from "@/components/screens/Account";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyAccount() {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
return <AccountScreen email={sess.email} />;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { reviewData } from "@/lib/managerdata";
|
||||
import ReviewScreen from "@/components/screens/Review";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyReview({ params }: { params: Promise<{ id: string }> }) {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
const { id } = await params;
|
||||
// reviewData starts from `managerId: sess.staffId`, so a request addressed to another manager
|
||||
// is not filtered out afterwards — it is never selected.
|
||||
const data = await reviewData(sess, id);
|
||||
if (!data) notFound();
|
||||
return <ReviewScreen data={data} />;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { approvalQueue } from "@/lib/managerdata";
|
||||
import ApprovalsScreen from "@/components/screens/Approvals";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyApprovals() {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
// Whoever a request names decides it, team or no team. A request reaches somebody who manages
|
||||
// nobody more than one way: the linen room re-addresses one that arrived without an approver
|
||||
// (request.reassign only checks the person is on the register, not that anybody reports to
|
||||
// them), or a manager's last report is moved to somebody else while their request is still
|
||||
// awaiting. This is the same query Home counts for its "waiting on you" banner, and the banner
|
||||
// is the only door — the staff nav has no approvals tab — so turning these people away left a
|
||||
// colleague blocked behind a 404 nobody could clear.
|
||||
const rows = await approvalQueue(sess);
|
||||
if (!rows.length) {
|
||||
// Nothing waiting and nobody reporting to them gets nothing, not an empty queue: an empty
|
||||
// approvals screen implies they might one day have a team, which is a question for the linen
|
||||
// room and not something this app should imply an answer to.
|
||||
const reports = await prisma.staff.count({ where: { managerId: sess.staffId } });
|
||||
if (!reports) notFound();
|
||||
}
|
||||
// Which of the waiting requests are for the manager themselves. Some of these now are — the
|
||||
// rule against approving your own uniform has been relaxed for the case the owner named — and
|
||||
// the screen sets those apart so nobody approves their own by accident and works out later that
|
||||
// they did. Whether a self-approval is allowed at all is the server's call and is not re-tested
|
||||
// here; this only asks the database which of the rows it already let through are the reader's
|
||||
// own, by the request's subject, which is the same fact the record is written from. The rows are
|
||||
// scoped to this manager and to `awaiting` already, so the lookup is over a handful of ids.
|
||||
const ownIds = rows.length
|
||||
? (
|
||||
await prisma.request.findMany({
|
||||
where: { id: { in: rows.map((r) => r.id) }, subjectId: sess.staffId },
|
||||
select: { id: true },
|
||||
})
|
||||
).map((r) => r.id)
|
||||
: [];
|
||||
return <ApprovalsScreen rows={rows} ownIds={ownIds} />;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { catalogueData, damageData } from "@/lib/staffdata";
|
||||
import DamageScreen from "@/components/screens/Damage";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyDamage() {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
const [{ holdings }, { managerName }] = await Promise.all([damageData(sess), catalogueData(sess)]);
|
||||
return <DamageScreen holdings={holdings} managerName={managerName} />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { kitData } from "@/lib/staffdata";
|
||||
import KitScreen from "@/components/screens/Kit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyKit() {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
return <KitScreen data={await kitData(sess)} />;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { kitCheckData } from "@/lib/cycledata";
|
||||
import KitCheckScreen from "@/components/screens/KitCheck";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyKitCheck() {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
const data = await kitCheckData(sess);
|
||||
// Between rounds there is no screen. A kit check that was always reachable would be answered at
|
||||
// random times and the cycle's numbers would mean nothing.
|
||||
if (!data) notFound();
|
||||
return <KitCheckScreen dueBy={data.dueBy} lastConfirmed={data.lastConfirmed} rows={data.rows} />;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { StaffProvider, type StaffMe } from "@/lib/staffclient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Everything that needs a signed-in staff member.
|
||||
*
|
||||
* The role flags are resolved here, once, from the database rather than trusted from the client:
|
||||
* "am I a manager" is the answer to "does anybody name me as theirs", and a screen that asked the
|
||||
* browser that question would be asking the wrong party.
|
||||
*/
|
||||
export default async function StaffAppLayout({ children }: { children: React.ReactNode }) {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
|
||||
const [staff, reports] = await Promise.all([
|
||||
prisma.staff.findUniqueOrThrow({
|
||||
where: { id: sess.staffId },
|
||||
select: { first: true, last: true, num: true, dept: true, wardDesk: true, managerId: true, facility: { select: { name: true, timezone: true } } },
|
||||
}),
|
||||
prisma.staff.count({ where: { managerId: sess.staffId, inactive: false } }),
|
||||
]);
|
||||
|
||||
const me: StaffMe = {
|
||||
staffId: sess.staffId,
|
||||
name: `${staff.first} ${staff.last}`.trim(),
|
||||
first: staff.first,
|
||||
num: staff.num,
|
||||
ward: staff.dept,
|
||||
facility: staff.facility.name,
|
||||
// Resolved here for the same reason the role flags are: it is the facility's answer, not the
|
||||
// phone's, and a device set to the wrong zone must not change what a ward round is told.
|
||||
tz: staff.facility.timezone,
|
||||
isManager: reports > 0,
|
||||
wardDesk: staff.wardDesk,
|
||||
hasManager: !!staff.managerId,
|
||||
};
|
||||
|
||||
return <StaffProvider me={me}>{children}</StaffProvider>;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/* What a tap looks like before the server answers.
|
||||
*
|
||||
* Every screen under /my is `force-dynamic` and rendered from its own database query, and App
|
||||
* Router keeps the previous screen fully painted until that query comes back. On ward wifi that is
|
||||
* two or three seconds in which nothing at all acknowledges the tap — so people tap again, and the
|
||||
* app reads as frozen. This is the route-level fallback the framework wants for exactly that: it
|
||||
* replaces the body the moment a navigation starts, keeping the app's own chrome so the change
|
||||
* reads as "loading" rather than "gone".
|
||||
*
|
||||
* Deliberately not the tab bar: the nav belongs to the four screens that draw it, and painting one
|
||||
* here would make it flash into existence on the way to a detail screen that has none. The top bar
|
||||
* has no title for the same reason — this fallback covers every route in the group, and inventing a
|
||||
* title would mean printing the wrong one somewhere.
|
||||
*/
|
||||
const INK = "#201e1d";
|
||||
const GROUND = "#f3f2f2";
|
||||
|
||||
/** A grey block standing in for a line of text. Sized in the same 2px system as everything else. */
|
||||
function Bar({ w, h = 16 }: { w: string; h?: number }) {
|
||||
return <div style={{ width: w, height: h, background: "var(--color-neutral-200)" }} />;
|
||||
}
|
||||
|
||||
export default function StaffLoading() {
|
||||
return (
|
||||
<>
|
||||
<header className="tcx-topbar" style={{
|
||||
height: 56, flex: "0 0 56px", background: INK, color: GROUND, display: "flex", alignItems: "center",
|
||||
paddingLeft: 16, paddingRight: 16,
|
||||
backgroundImage: "linear-gradient(to bottom, rgba(243,242,242,0.16) 0 1px, transparent 1px)",
|
||||
backgroundPosition: "0 env(safe-area-inset-top, 0px)", backgroundRepeat: "no-repeat", backgroundSize: "100% 1px",
|
||||
}}>
|
||||
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, letterSpacing: "0.06em", textTransform: "uppercase" }}>
|
||||
One moment
|
||||
</span>
|
||||
</header>
|
||||
<div style={{ height: 4, flex: "0 0 4px", background: "var(--color-accent)" }} />
|
||||
<div style={{ flex: 1, overflow: "hidden", background: GROUND }} aria-busy="true">
|
||||
{/* Announced once, quietly. The blocks below are decoration and say nothing. */}
|
||||
<div role="status" style={{ padding: "20px 16px 0", fontSize: 13, color: "var(--color-neutral-600)" }}>Loading…</div>
|
||||
<div style={{ padding: "16px 16px 0", display: "grid", gap: 10 }} aria-hidden="true">
|
||||
<Bar w="60%" h={22} />
|
||||
<Bar w="40%" />
|
||||
</div>
|
||||
<div style={{ marginTop: 24, display: "grid", gap: 2 }} aria-hidden="true">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} style={{ background: "#fff", padding: "18px 16px", display: "grid", gap: 8 }}>
|
||||
<Bar w="55%" h={18} />
|
||||
<Bar w="35%" h={12} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MBar, MBody, MEmpty, MRule, MTop } from "@/components/m";
|
||||
|
||||
/* What a staff screen shows when there is nothing behind it.
|
||||
*
|
||||
* Nine routes under /my call notFound(): a kit check between rounds, the waitlist with nothing
|
||||
* offered, the ward and desk screens for somebody without that role, an order that isn't theirs.
|
||||
* Without this file every one of them rendered the WEBSITE's 404 — marketing nav, "Open the demo",
|
||||
* a footer — inside the app, over the top of the tab bar, with the hardware back button as the
|
||||
* only way home. Seen on a Pixel 8 Pro on 2026-09-12 by opening Kit check with no check open.
|
||||
*
|
||||
* Next renders the nearest not-found.tsx, so this one stays inside the signed-in layout: same
|
||||
* chrome, same provider, and a bar that goes home.
|
||||
*/
|
||||
export default function StaffNotFound() {
|
||||
return (
|
||||
<>
|
||||
<MTop title="Nothing here" back />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<MEmpty
|
||||
title="Nothing to show right now"
|
||||
sub="There’s no screen behind that link at the moment — a kit check that isn’t open, a list with nothing on it, or something that isn’t yours to see. Nothing on your record has changed."
|
||||
/>
|
||||
</MBody>
|
||||
<MBar label="Back to home" href="/my" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { requestData } from "@/lib/staffdata";
|
||||
import ThreadScreen from "@/components/screens/Thread";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyOrderThread({ params }: { params: Promise<{ id: string }> }) {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
const { id } = await params;
|
||||
const data = await requestData(sess, id);
|
||||
if (!data) notFound();
|
||||
return <ThreadScreen data={data} />;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { requestData } from "@/lib/staffdata";
|
||||
import OrderScreen from "@/components/screens/Order";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyOrder({ params }: { params: Promise<{ id: string }> }) {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
const { id } = await params;
|
||||
const data = await requestData(sess, id);
|
||||
// A request that isn't theirs, their team's, or one they raised is a 404 rather than a 403 —
|
||||
// "you may not see this" confirms it exists.
|
||||
if (!data) notFound();
|
||||
return <OrderScreen data={data} />;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { ordersData } from "@/lib/staffdata";
|
||||
import OrdersScreen from "@/components/screens/Orders";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyOrders({ searchParams }: { searchParams: Promise<{ tab?: string }> }) {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
const { tab } = await searchParams;
|
||||
const { open, done, raised } = await ordersData(sess);
|
||||
// Landing from the Messages tab with nothing open should still show the Open tab and its empty
|
||||
// state, rather than a list of finished orders nobody asked for. `raised` is what this person
|
||||
// typed in for somebody else — the desk's whole day, and now a manager's too.
|
||||
return (
|
||||
<OrdersScreen
|
||||
open={open}
|
||||
done={done}
|
||||
raised={raised}
|
||||
initialTab={tab === "done" ? "done" : tab === "raised" ? "raised" : "open"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { homeData } from "@/lib/staffdata";
|
||||
import { fmtDate } from "@/lib/compute";
|
||||
import { ROUTED_TO_ROUND, dueOnWard } from "@/lib/staffreq";
|
||||
import HomeScreen from "@/components/screens/Home";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyHome() {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
|
||||
const data = await homeData(sess);
|
||||
// A manager's queue and a clerk's trolley are counts here, not lists: Home answers "is anything
|
||||
// waiting for me?" and then gets out of the way. Both are skipped entirely for the people the
|
||||
// flags don't apply to, which is nearly everyone.
|
||||
const [approvals, reports, roundBags, cycle] = await Promise.all([
|
||||
prisma.request.count({ where: { managerId: sess.staffId, status: "awaiting" } }),
|
||||
/* Whether anybody reports to them, which is the same question /my/raise answers with a 404.
|
||||
* Not the same as having approvals waiting: a manager whose team has asked for nothing this
|
||||
* month still needs the door, and this is the only way in to it. */
|
||||
prisma.staff.count({ where: { managerId: sess.staffId, inactive: false } }),
|
||||
data.wardDesk && data.ward
|
||||
// The ward the trolley left the bag on, off the timeline — the same fence /my/round and
|
||||
// round.sign use (see roundWard() in lib/staffreq.ts). Counting on the wearer's current ward
|
||||
// made this badge disagree with the screen it opens the moment anybody transferred wards
|
||||
// mid-round: a bag counted here and missing from the round, or the other way about.
|
||||
? prisma.request.count({
|
||||
where: {
|
||||
facilityId: sess.facilityId, status: "round",
|
||||
events: { some: { label: ROUTED_TO_ROUND, meta: dueOnWard(data.ward) } },
|
||||
},
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
prisma.kitCheck.findFirst({
|
||||
where: { facilityId: sess.facilityId, closedAt: null },
|
||||
orderBy: { openedAt: "desc" },
|
||||
select: { id: true, dueBy: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Only prompt for a cycle they still owe answers to — someone who finished last week should not
|
||||
// be nagged for the rest of the month.
|
||||
let kitCheckDue: string | null = null;
|
||||
if (cycle) {
|
||||
const [held, answered] = await Promise.all([
|
||||
// handedIn as well as returnedDate: a garment handed back at the counter never gets marked
|
||||
// returned, so counting on returnedDate alone nags somebody for a kit check about uniform
|
||||
// they gave back months ago. Every equivalent query in lib/ reads both.
|
||||
prisma.issue.count({ where: { staffId: sess.staffId, returnedDate: null, handedIn: null } }),
|
||||
prisma.kitCheckAnswer.count({ where: { kitCheckId: cycle.id, staffId: sess.staffId } }),
|
||||
]);
|
||||
if (held > 0 && answered === 0) kitCheckDue = fmtDate(cycle.dueBy);
|
||||
}
|
||||
|
||||
return (
|
||||
<HomeScreen
|
||||
data={data}
|
||||
approvals={approvals}
|
||||
roundBags={roundBags}
|
||||
kitCheckDue={kitCheckDue}
|
||||
canRaiseForTeam={reports > 0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { deskCatalogue, teamPeople } from "@/lib/deskdata";
|
||||
import { ordersData } from "@/lib/staffdata";
|
||||
import { REQUEST_MAX_LINES, REQUEST_MAX_QTY } from "@/lib/ops";
|
||||
import DeskScreen from "@/components/screens/Desk";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* A manager raising for one of their own reports — the only way one person types a request in
|
||||
* somebody else's name in this app. A ward clerk on the desk used to have a screen of its own for
|
||||
* anyone on their ward; that is gone, and the person who would have asked the clerk asks the
|
||||
* manager who approves it anyway.
|
||||
*
|
||||
* The scope here is the same relationship the server enforces on `request.create`: the people who
|
||||
* name this person as their manager. Nothing on this page decides who may be raised for — it asks
|
||||
* teamPeople() for exactly the set the op would accept, and a request for anybody else is refused
|
||||
* there.
|
||||
*/
|
||||
export default async function MyRaise() {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
|
||||
const [people, items, orders] = await Promise.all([
|
||||
teamPeople(sess),
|
||||
deskCatalogue(sess),
|
||||
ordersData(sess),
|
||||
]);
|
||||
// Nobody reporting to them means no screen, the same way the approvals queue works: an empty one
|
||||
// implies they might one day have a team, which is a question for the linen room.
|
||||
if (!people.length) notFound();
|
||||
|
||||
return (
|
||||
<DeskScreen
|
||||
// Every one of them names this manager, which is exactly why the request cannot stay with
|
||||
// them: the screen says whose name is on it, and the server sends it up a level.
|
||||
people={people}
|
||||
items={items}
|
||||
raised={orders.raised.open}
|
||||
maxLines={REQUEST_MAX_LINES}
|
||||
maxQty={REQUEST_MAX_QTY}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { catalogueData } from "@/lib/staffdata";
|
||||
import { REQUEST_MAX_LINES, REQUEST_MAX_QTY } from "@/lib/ops";
|
||||
import RequestScreen from "@/components/screens/Request";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyRequest({ searchParams }: { searchParams: Promise<{ swap?: string; item?: string; si?: string }> }) {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
const { swap, item, si } = await searchParams;
|
||||
|
||||
const [{ items, managerName, holding, allowance }, held] = await Promise.all([
|
||||
catalogueData(sess),
|
||||
// handedIn as well as returnedDate, for the same reason the kit check reads both: a garment
|
||||
// handed back at the counter is off the person without ever being marked returned, and the
|
||||
// swap flow would otherwise offer to exchange something they no longer hold.
|
||||
prisma.issue.findMany({
|
||||
where: { staffId: sess.staffId, returnedDate: null, handedIn: null },
|
||||
select: { itemId: true }, distinct: ["itemId"],
|
||||
}),
|
||||
]);
|
||||
|
||||
// Only honour a pre-selection that actually exists in this facility's catalogue — the ids come
|
||||
// off a query string.
|
||||
const pre = items.find((i) => i.id === item) || null;
|
||||
const preSi = pre ? pre.sizes.find((s) => s.si === parseInt(String(si ?? ""), 10))?.si ?? null : null;
|
||||
|
||||
return (
|
||||
<RequestScreen
|
||||
items={items}
|
||||
managerName={managerName}
|
||||
swap={swap === "1"}
|
||||
heldItemIds={held.map((h) => h.itemId)}
|
||||
preItemId={pre?.id ?? null}
|
||||
preSi={preSi}
|
||||
holding={holding}
|
||||
allowance={allowance}
|
||||
// The ceilings are the server's, handed down rather than restated in the browser: a screen
|
||||
// that let somebody build an eleventh line would only be showing them a refusal.
|
||||
maxLines={REQUEST_MAX_LINES}
|
||||
maxQty={REQUEST_MAX_QTY}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { roundData } from "@/lib/deskdata";
|
||||
import { ordersData } from "@/lib/staffdata";
|
||||
import RoundScreen from "@/components/screens/Round";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyRound() {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
const data = await roundData(sess);
|
||||
if (!data) notFound();
|
||||
/* What this person raised for other people and hasn't seen the end of — as a manager for one
|
||||
* of their team, or, on an older request, from the desk route that no longer exists.
|
||||
*
|
||||
* The three lists above it are only ever about bags arriving today, so a request typed in on
|
||||
* Tuesday and approved on Thursday appears on no screen the raiser can reach until it turns up
|
||||
* on a trolley — which is why they rang the linen room to ask. It is deliberately the open ones
|
||||
* only: the round screen is a day's work, not an archive, and everything that has finished is
|
||||
* under Raised in Orders. */
|
||||
const { raised } = await ordersData(sess);
|
||||
return (
|
||||
<RoundScreen
|
||||
ward={data.ward}
|
||||
toSign={data.toSign}
|
||||
unclaimed={data.unclaimed}
|
||||
signedToday={data.signedToday}
|
||||
raised={raised.open}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { catalogueData } from "@/lib/staffdata";
|
||||
import ShelfScreen from "@/components/screens/Shelf";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyShelf() {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
const { items } = await catalogueData(sess);
|
||||
return <ShelfScreen items={items} />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { waitlistData } from "@/lib/cycledata";
|
||||
import WaitlistScreen from "@/components/screens/Waitlist";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyWaitlist({ searchParams }: { searchParams: Promise<{ item?: string; si?: string }> }) {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
const { item, si } = await searchParams;
|
||||
const data = await waitlistData(sess, String(item || ""), parseInt(String(si ?? "-1"), 10));
|
||||
if (!data) notFound();
|
||||
return <WaitlistScreen data={data} />;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentStaff } from "@/lib/staffsession";
|
||||
import { wardData } from "@/lib/managerdata";
|
||||
import WardScreen from "@/components/screens/Ward";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MyWard() {
|
||||
const sess = await currentStaff();
|
||||
if (!sess) redirect("/my/signin");
|
||||
const reports = await prisma.staff.count({ where: { managerId: sess.staffId } });
|
||||
if (!reports) notFound();
|
||||
const { ward, rows, anyCapped } = await wardData(sess);
|
||||
return <WardScreen ward={ward} rows={rows} anyCapped={anyCapped} />;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { readApprovalToken } from "@/lib/approvallink";
|
||||
import { linesSummary, reqLines } from "@/lib/staffdata";
|
||||
import ApproveByLink from "@/components/screens/ApproveByLink";
|
||||
import { MyShell, h1, kicker, lead } from "@/components/my";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata = { title: "Approve a uniform request", robots: { index: false, follow: false } };
|
||||
|
||||
/* The page an emailed approve/decline link opens.
|
||||
*
|
||||
* Deliberately outside (app): a manager standing in a corridor with an email open should not have
|
||||
* to sign in to unblock somebody. The token is the authorisation, and rendering the request is all
|
||||
* this page does — the decision is a POST from here, never from the link itself.
|
||||
*/
|
||||
export default async function ApprovePage({ searchParams }: { searchParams: Promise<{ t?: string }> }) {
|
||||
const { t } = await searchParams;
|
||||
const claim = readApprovalToken(t);
|
||||
|
||||
if (!claim) {
|
||||
return (
|
||||
<MyShell>
|
||||
<div style={kicker}>ThreadCount</div>
|
||||
<h1 style={h1}>That link has expired.</h1>
|
||||
<p style={lead}>
|
||||
Approval links last a fortnight and stop working once a request has been decided. Open the
|
||||
app and use your approvals queue instead.
|
||||
</p>
|
||||
</MyShell>
|
||||
);
|
||||
}
|
||||
|
||||
const r = await prisma.request.findFirst({
|
||||
where: { id: claim.rid, managerId: claim.mid },
|
||||
include: {
|
||||
lines: { include: { item: { select: { item: true, gender: true, sizes: true } } }, orderBy: { sort: "asc" } },
|
||||
subject: { select: { first: true, last: true, group: true, num: true, dept: true, inactive: true } },
|
||||
facility: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!r) {
|
||||
return (
|
||||
<MyShell>
|
||||
<div style={kicker}>ThreadCount</div>
|
||||
<h1 style={h1}>That request is gone.</h1>
|
||||
<p style={lead}>It may have been withdrawn, or the staff member removed from the register.</p>
|
||||
</MyShell>
|
||||
);
|
||||
}
|
||||
|
||||
/* The same re-check the POST does, and for the same reason — but this page has its own thing to
|
||||
* protect. It renders somebody's name, staff number, ward and role, and a token lives for a
|
||||
* fortnight in a mailbox that may since have been closed or handed on. Refusing the decision but
|
||||
* still showing the personal details behind it would be a refusal in name only. */
|
||||
const mgr = await prisma.staff.findFirst({
|
||||
where: { id: claim.mid, facilityId: r.facilityId, inactive: false },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!mgr || r.subject.inactive) {
|
||||
return (
|
||||
<MyShell>
|
||||
<div style={kicker}>ThreadCount</div>
|
||||
<h1 style={h1}>That link has expired.</h1>
|
||||
<p style={lead}>
|
||||
You’re no longer on the register at this facility, or the person who asked isn’t.
|
||||
Ask the linen room.
|
||||
</p>
|
||||
</MyShell>
|
||||
);
|
||||
}
|
||||
|
||||
/* Whose uniform this is.
|
||||
*
|
||||
* A manager may now decide a request they are the subject of. The approvals queue sets those
|
||||
* apart under their own heading, and this page is the one door that never goes through it: a
|
||||
* link from a mailbox is the only way a manager can approve their own uniform with nothing on
|
||||
* the screen telling them that is what they are doing. Whether a self-approval is allowed at
|
||||
* all is the server's call and is not re-tested here — this only asks who the request is for,
|
||||
* by its own subject, which is the fact the timeline row is written from.
|
||||
*
|
||||
* It rides on the line under the heading because that line is the only copy on this screen the
|
||||
* page itself writes, and the heading above it — a person's own name, needing their approval —
|
||||
* is precisely what needs answering. The queue says it twice, the second time in the record's
|
||||
* words; saying that here as well needs the screen, not this file. */
|
||||
const own = r.subjectId === claim.mid;
|
||||
|
||||
/* The whole ask, in the order it was entered, shaped exactly as every other screen shapes it.
|
||||
*
|
||||
* A request covers as many garments as the person needed, and this page is now the only place a
|
||||
* manager might meet one without the app in front of them. It shows all of them, declines
|
||||
* included when they come back to a settled one — the link itself can only settle the request in
|
||||
* one direction, which is a limit the screen has to state rather than hide. */
|
||||
const lines = reqLines(r.lines);
|
||||
|
||||
return (
|
||||
<ApproveByLink
|
||||
token={t!}
|
||||
decided={r.status !== "awaiting"}
|
||||
status={r.status}
|
||||
declineReason={r.declineReason}
|
||||
data={{
|
||||
code: r.code,
|
||||
subjectName: `${r.subject.first} ${r.subject.last}`.trim(),
|
||||
subjectMeta: [own ? "Your own uniform" : "", r.subject.dept, r.subject.num, r.subject.group]
|
||||
.filter(Boolean).join(" · "),
|
||||
lines,
|
||||
summary: linesSummary(lines),
|
||||
reason: r.reason,
|
||||
note: r.note,
|
||||
raisedByName: r.raisedByName,
|
||||
facility: r.facility.name,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Metadata } from "next";
|
||||
import Analytics from "@/components/Analytics";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Nothing under /my is public. The sign-in page is reached from a printed slip handed over at the
|
||||
* counter, so there is no reason for it to be in an index either. */
|
||||
export const metadata: Metadata = {
|
||||
title: "Your uniform record",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
/* A fixed-height column, like the counter app: the bars don't scroll, only the body does.
|
||||
*
|
||||
* No maximumScale, for the same reason it is gone there: pinning the zoom takes pinch-to-zoom away
|
||||
* from everyone on every screen of the staff app, which is WCAG 1.4.4. */
|
||||
export const viewport = {
|
||||
width: "device-width", initialScale: 1,
|
||||
viewportFit: "cover" as const, themeColor: "#201e1d",
|
||||
};
|
||||
|
||||
/* Only the shell. Sign in and the emailed approval link live under /my but must be reachable
|
||||
* without a session — one is how you get a session, and the other is deliberately for a manager
|
||||
* who is standing in a corridor with an email open and no intention of signing in. */
|
||||
export default function MyLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="tcx-app" role="main">
|
||||
{/* The shell is the main landmark, as on the counter app, and for the same reason it is a
|
||||
role rather than a wrapping element. */}
|
||||
{children}
|
||||
<Analytics site="app" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
/* Signing in, in the app's own chrome.
|
||||
*
|
||||
* This screen used to be a centred web page dropped between two app screens: light ground where
|
||||
* the welcome and the app are ink, no app bar, a different type scale, and — worst — it asked the
|
||||
* same question the bundled welcome had just asked. Tapping "Sign in" there appeared to do
|
||||
* nothing, because you arrived at two buttons saying "Sign in" and "I have a code" again.
|
||||
*
|
||||
* So: one decision, made once. The welcome sends you here already in a mode, and the other way in
|
||||
* is a quiet line of text rather than a second pair of buttons. Everything else is the app's own
|
||||
* vocabulary — ink bar, accent rule, 64px flush-left action — so the seam disappears.
|
||||
*/
|
||||
import { PRIVACY_URL, TERMS_URL } from "@/lib/links";
|
||||
import { Suspense, useId, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { MBar, MBody, MError, MExternalLink, MRule, MTop, inputStyle } from "@/components/m";
|
||||
import { ACCENT_700, INK, N600, N700 } from "@/components/staffui";
|
||||
import Turnstile, { awaitTurnstile, resetTurnstile, turnstileOn } from "@/components/Turnstile";
|
||||
import { track } from "@/lib/analytics";
|
||||
|
||||
type Mode = "signin" | "activate";
|
||||
|
||||
const label: React.CSSProperties = {
|
||||
display: "block", fontSize: 11, fontWeight: 800, letterSpacing: "0.12em",
|
||||
textTransform: "uppercase", color: N600, marginBottom: 8,
|
||||
};
|
||||
|
||||
function SignInForm() {
|
||||
// ?code=1 arrives from the app's "I have a code" button, and from the printed slip's link.
|
||||
const sp = useSearchParams();
|
||||
/* Real htmlFor/id pairs, the way components/MAuth.tsx does it for the counter app.
|
||||
*
|
||||
* These three boxes were wrapped in their <label>, which associates — but the password one wraps
|
||||
* the Show/Hide button too, and a label may only name one control: the toggle's words were being
|
||||
* read out as part of the password field's name, and a tap anywhere in the label pulled focus off
|
||||
* the button. Naming each control explicitly puts the toggle outside the label where it belongs.
|
||||
* This is the sign-in screen of a Play-shipped app, so it is the first thing a screen reader
|
||||
* meets. */
|
||||
const codeId = useId();
|
||||
const emailId = useId();
|
||||
const pwId = useId();
|
||||
const [mode, setMode] = useState<Mode>(sp.get("code") === "1" ? "activate" : "signin");
|
||||
const [code, setCode] = useState("");
|
||||
// A Community instance whose operator has not set NEXT_PUBLIC_TERMS_URL / PRIVACY_URL has
|
||||
// nothing to agree to, so the line is not shown and consent is not asked for.
|
||||
const legal = !!(TERMS_URL || PRIVACY_URL);
|
||||
const [email, setEmail] = useState("");
|
||||
const [pw, setPw] = useState("");
|
||||
const [show, setShow] = useState(false);
|
||||
/* The terms tick. Kyle's ask (2026-09-12): sign-in carries an explicit agreement to the terms and
|
||||
* the privacy policy, the way the coordinator's sign-up does. It gates both doors — setting up is
|
||||
* where the account is created, and signing in is what the app does every other day — and the
|
||||
* activation route refuses without it, so the box can't be talked past by a client that skips it. */
|
||||
const [agree, setAgree] = useState(!legal);
|
||||
const agreeId = useId();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
/* Both staff doors sit behind Turnstile on the server, and in production the check is required
|
||||
* rather than advisory — so a screen that never obtains a token gets a flat 400 "Please complete
|
||||
* the security check" and no way past it. The widget runs the way the counter app's sign-in runs
|
||||
* it, quiet: nothing is drawn unless Cloudflare actually wants an interaction, which matters here
|
||||
* because this screen is inside a WebView with no browser chrome to explain a card that appeared
|
||||
* from nowhere. */
|
||||
const [cfToken, setCfToken] = useState("");
|
||||
|
||||
const activating = mode === "activate";
|
||||
|
||||
async function submit(e?: React.FormEvent) {
|
||||
e?.preventDefault();
|
||||
setErr("");
|
||||
setBusy(true);
|
||||
const url = activating ? "/api/staff/activate" : "/api/staff/login";
|
||||
// Waited for rather than read: the token usually lands long before anyone has finished typing a
|
||||
// password, but occasionally a second or two later, and posting the empty string blames the
|
||||
// person for a check they were never shown.
|
||||
const token = cfToken || (turnstileOn() ? await awaitTurnstile() : "");
|
||||
const body = activating
|
||||
? { code, email, password: pw, cfToken: token, agreed: agree }
|
||||
: { email, password: pw, cfToken: token, agreed: agree };
|
||||
const r = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) })
|
||||
.catch(() => null);
|
||||
const j = await r?.json().catch(() => ({}));
|
||||
setBusy(false);
|
||||
if (!r || !r.ok) {
|
||||
// Which door and which kind of refusal — never the server's words. Together with
|
||||
// staff_code_issued on the counter side this says how many printed codes become accounts.
|
||||
track(activating ? "staff_activation_failed" : "staff_signin_failed", {
|
||||
reason: !r ? "network" : r.status === 429 ? "throttled" : r.status === 400 && /security check/i.test(String(j?.error || "")) ? "security_check" : activating ? "code_or_details" : "credentials",
|
||||
});
|
||||
setErr(j?.error || "That didn’t work. Try again.");
|
||||
setCfToken(""); resetTurnstile();
|
||||
return;
|
||||
}
|
||||
track(activating ? "staff_activated" : "staff_signin");
|
||||
// A full navigation: the cookie was just set and /my is server-rendered.
|
||||
window.location.replace("/my");
|
||||
}
|
||||
|
||||
const ready = (activating ? !!code.trim() && !!email.trim() && !!pw : !!email.trim() && !!pw) && agree;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MBody>
|
||||
<div style={{ padding: "24px 16px 20px", borderBottom: "2px solid " + INK, background: "var(--color-bg)" }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 28, letterSpacing: "-0.02em", lineHeight: 1.1 }}>
|
||||
{activating ? "Set up your sign-in." : "Your uniform record."}
|
||||
</div>
|
||||
<p style={{ fontSize: 14.5, lineHeight: 1.55, color: N700, margin: "12px 0 0", maxWidth: "42ch" }}>
|
||||
{activating
|
||||
? "The linen room gives you a twelve-character code. Use it once, and pick how you’ll sign in from now on."
|
||||
: "What you have out, what you’re still owed and what’s on order — the same record the linen room sees."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} style={{ padding: 16, display: "grid", gap: 18 }}>
|
||||
{activating && (
|
||||
<div>
|
||||
<label htmlFor={codeId} style={label}>Your code</label>
|
||||
<input
|
||||
id={codeId}
|
||||
value={code} autoFocus autoCapitalize="characters" autoComplete="off" spellCheck={false}
|
||||
placeholder="XXXX-XXXX-XXXX"
|
||||
onChange={(e) => { setCode(e.target.value); setErr(""); }}
|
||||
style={{ ...inputStyle, fontFamily: "ui-monospace, Menlo, Consolas, monospace", letterSpacing: "0.08em" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor={emailId} style={label}>Email</label>
|
||||
<input
|
||||
id={emailId}
|
||||
type="email" value={email} autoComplete="email" inputMode="email" autoFocus={!activating}
|
||||
onChange={(e) => { setEmail(e.target.value); setErr(""); }} style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={pwId} style={label}>{activating ? "Choose a password" : "Password"}</label>
|
||||
<input
|
||||
id={pwId}
|
||||
type={show ? "text" : "password"} value={pw}
|
||||
autoComplete={activating ? "new-password" : "current-password"}
|
||||
onChange={(e) => { setPw(e.target.value); setErr(""); }} style={inputStyle}
|
||||
/>
|
||||
{/* aria-pressed rather than a changing label alone: read on its own, "Show" says
|
||||
nothing about what it shows. */}
|
||||
<button type="button" onClick={() => setShow(!show)} aria-pressed={show} style={{
|
||||
marginTop: 10, background: "none", border: 0, padding: 0, font: "inherit",
|
||||
fontSize: 13, fontWeight: 800, color: ACCENT_700, cursor: "pointer",
|
||||
}}>{show ? "Hide" : "Show"} password</button>
|
||||
</div>
|
||||
{/* The links open in the phone's browser inside the shell, and in a new tab on the web,
|
||||
so ticking never means leaving a half-typed password behind. */}
|
||||
{legal && <label htmlFor={agreeId} style={{ display: "flex", gap: 12, alignItems: "flex-start", fontSize: 13.5, lineHeight: 1.55, color: N700, cursor: "pointer" }}>
|
||||
<input
|
||||
id={agreeId} type="checkbox" checked={agree}
|
||||
onChange={(e) => { setAgree(e.target.checked); setErr(""); }}
|
||||
style={{ width: 22, height: 22, flex: "0 0 22px", marginTop: 1, accentColor: ACCENT_700 }}
|
||||
/>
|
||||
<span>
|
||||
I agree to the {TERMS_URL ? <MExternalLink href={TERMS_URL}>Terms of use</MExternalLink> : "Terms of use"}{TERMS_URL && PRIVACY_URL ? " and the " : ""}{PRIVACY_URL ? <MExternalLink href={PRIVACY_URL}>Privacy policy</MExternalLink> : null}{!PRIVACY_URL ? "" : ""}.
|
||||
</span>
|
||||
</label>}
|
||||
{turnstileOn() && <Turnstile onToken={setCfToken} action={activating ? "staff-activate" : "staff-login"} quiet />}
|
||||
{/* Submitting with the keyboard’s Go key, without a visible second button. */}
|
||||
<button type="submit" disabled={!ready || busy} style={{ display: "none" }} aria-hidden />
|
||||
</form>
|
||||
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
|
||||
{/* The other way in — a line of text, not a second pair of buttons. The welcome screen
|
||||
has already asked once, and asking again is what made the old screen feel broken. */}
|
||||
<div style={{ padding: "4px 16px 0" }}>
|
||||
<button
|
||||
onClick={() => { setMode(activating ? "signin" : "activate"); setErr(""); }}
|
||||
style={{ background: "none", border: 0, padding: 0, font: "inherit", fontSize: 14, fontWeight: 800, color: ACCENT_700, cursor: "pointer", textAlign: "left" }}
|
||||
>
|
||||
{activating ? "Already set up? Sign in instead" : "First time? I have a code"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: N600, padding: "20px 16px 0", margin: 0 }}>
|
||||
{activating
|
||||
? "Your code works once. If it has already been used, ask the linen room for a new one."
|
||||
: "Forgotten your password? The linen room can clear your access and hand you a fresh code."}
|
||||
</p>
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, color: N600, padding: "12px 16px 0", margin: 0 }}>
|
||||
This is for people who wear the uniform.
|
||||
</p>
|
||||
|
||||
{/* The policy line, and it is on the activation branch for a reason: that branch is where an
|
||||
email address and a password are collected, so it is the point of collection, and Play's
|
||||
review looks for a policy reachable from inside the app rather than only from the store
|
||||
listing. */}
|
||||
{activating && (
|
||||
<p style={{ fontSize: 12.5, lineHeight: 1.6, color: N600, padding: "12px 16px 0", margin: 0 }}>
|
||||
Setting this up stores your email address and a password so you can sign in. Your name,
|
||||
ward and uniform record belong to the linen room.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* There used to be an "On the website" list here — the coordinator's sign-in, the privacy
|
||||
policy, the terms, account deletion. Removed at Kyle's ask (2026-09-12): none of it is
|
||||
something a wearer can act on from this screen, and the coordinator's door is a desktop
|
||||
screen this app cannot open. The terms and the policy are now the two links in the
|
||||
agreement tick above, and all three site pages remain on Account once signed in — which
|
||||
is where Play's review looks for a policy reachable from inside the app. */}
|
||||
<div style={{ height: 20 }} />
|
||||
</MBody>
|
||||
|
||||
<MBar
|
||||
label={busy ? "One moment…" : activating ? "Set up my sign-in" : "Sign in"}
|
||||
disabled={!ready || busy}
|
||||
onClick={() => submit()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StaffSignIn() {
|
||||
// The bar and rule sit outside the Suspense boundary: useSearchParams suspends, and an app that
|
||||
// opens on a bare white rectangle before hydrating looks broken on a ward phone.
|
||||
return (
|
||||
<>
|
||||
<MTop title="ThreadCount" />
|
||||
<MRule />
|
||||
<Suspense fallback={<MBody><div style={{ padding: 24, fontSize: 14, color: N600 }}>One moment…</div></MBody>}>
|
||||
<SignInForm />
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main style={{ maxWidth: 520, margin: "80px auto", padding: "0 20px", fontFamily: "var(--font-body)" }}>
|
||||
<div style={{ fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", fontWeight: 700, color: "var(--color-neutral-600)" }}>ThreadCount</div>
|
||||
<h1 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 34, letterSpacing: "-0.02em", margin: "10px 0 0" }}>There is nothing at this address.</h1>
|
||||
<p style={{ fontSize: 15, lineHeight: 1.6, color: "var(--color-neutral-800)", marginTop: 14 }}>The linen room signs in at <Link href="/auth" style={{ fontWeight: 700 }}>/auth</Link>, the phone counter at <Link href="/m" style={{ fontWeight: 700 }}>/m</Link>, and staff at <Link href="/my" style={{ fontWeight: 700 }}>/my</Link>.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/* A Community instance is the product, not a website: the front door is the sign-in. */
|
||||
export default function Home() {
|
||||
redirect("/auth");
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user