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,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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user