Files
threadcount-community/app/m/(app)/settings/page.tsx
T
ThreadCount 1bc2de655a ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
2026-09-13 08:45:19 +10:00

111 lines
6.4 KiB
TypeScript

"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 { 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 threadcount.tech.
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. */}
<MSection label="Your account and your data" />
<MRow href="https://threadcount.tech/delete-account" external mark="ink" title="Delete your account" sub="How to do it, and exactly what goes with it" />
<MRow href="https://threadcount.tech/privacy" external mark="ink" title="Privacy policy" sub="What ThreadCount stores, and what it never does" />
<MRow href="https://threadcount.tech/terms" 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 at threadcount.tech.
</p>
</MBody>
</>
);
}