cce99aadfd
A round of operator-facing fixes on the booth screen, shift views, and the
font-scale control. (Follows the font-scale feature in f706726, which used CSS
`zoom` — reverted here for the rem approach below.)
Font scaling (the A−/A+ control now actually works without breaking layout):
- The control scaled via CSS `zoom`, which also scaled viewport-locked containers
(h-screen frame, max-h-[90vh] modals) so at 130% modal headers/footers were
pushed off-screen. Reworked to scale TEXT only: converted every `text-[Npx]`
font utility to rem across the web app (~230 sites in 25 files + the
.label/.hint/.btn component classes + body in index.css; 16px root, so 100% is
visually identical), and applyFontScale now sets the ROOT font-size. vh/h-screen
layout stays put, so chrome never clips; tall content scrolls its own container.
Verified at 130%: text 12px→15.6px while the frame stayed viewport-height.
Live feed (event rows):
- Plate, badges and reason now flow inline after the identity and wrap only when
the row runs out of width — no more forced second line when there's empty space.
- Dropped the redundant TARGË via-badge (the plate chip already conveys it).
- Removed the Direction filter group (Hyrje/Dalje) — it duplicated the entry/exit
options already in the Type filter.
Active sessions:
- Rebuilt as a real table (Ticket/subscriber · Plate · Entry · Elapsed) so columns
align and long values (subscriber names, ticket ids) no longer truncate.
- Dropped the status column (an unpaid transient is normal; a subscriber shows ★ +
name; overstay keeps a row tint). Removed the now-redundant status filter; only
the Transient/Subscriber filter remains. Plate is now searchable (uses s.plate).
Shift report (close-shift modal + Shift History + printed Z-report slip):
- Removed the confusing `shitje` (subscription-sales) sub-line — Abonime is the
total; only the out-of-window part is broken out. subscriptionSalesMinor stays in
the signed payload (audit data), just not displayed/printed.
- Show the inherited opening cash ("Arka fillestare") above the expected drawer, so
opening + cash-taken = expected reads clearly. Money values no longer line-wrap.
Subscription edit modal:
- Fixed the 2-col grid alignment: a lone "only one version" cell was shifting every
following row by one column — it now emits a full label+value pair.
Removed orphaned i18n keys (fStatus*, fDir*, srcSubSales) from sq+en (parity kept).
Full workspace build/lint/test green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
172 lines
5.9 KiB
TypeScript
172 lines
5.9 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { changeMyPassword, updateMyProfile, type SessionUser } from "./api.js";
|
|
|
|
// Self-service profile: the signed-in user edits their OWN display name + email and
|
|
// changes their OWN password (proving the current one). This is NOT the admin
|
|
// user-manager (UsersManager.tsx) — it never touches another account, username, or
|
|
// role, and needs no `user:*` permission. See routes/auth.ts (/api/auth/profile,
|
|
// /api/auth/password) and wiki/entities/local-jwt-auth.md.
|
|
|
|
const MIN_PASSWORD = 8;
|
|
|
|
export function Profile({
|
|
user,
|
|
setUser,
|
|
}: {
|
|
user: SessionUser;
|
|
setUser: (u: SessionUser | null) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
|
|
// --- Account (name / email) ---
|
|
const [fullName, setFullName] = useState(user.fullName ?? "");
|
|
const [email, setEmail] = useState(user.email ?? "");
|
|
const [accountMsg, setAccountMsg] = useState<string | null>(null);
|
|
const [savingAccount, setSavingAccount] = useState(false);
|
|
|
|
async function saveAccount() {
|
|
setAccountMsg(null);
|
|
setSavingAccount(true);
|
|
try {
|
|
const next = await updateMyProfile({ fullName, email });
|
|
// Keep the router-context user in sync so the header reflects the change.
|
|
setUser(next);
|
|
setFullName(next.fullName ?? "");
|
|
setEmail(next.email ?? "");
|
|
setAccountMsg(t("profile.profileSaved"));
|
|
} catch (e) {
|
|
setAccountMsg((e as Error).message);
|
|
} finally {
|
|
setSavingAccount(false);
|
|
}
|
|
}
|
|
|
|
// --- Password ---
|
|
const [current, setCurrent] = useState("");
|
|
const [next, setNext] = useState("");
|
|
const [confirm, setConfirm] = useState("");
|
|
const [pwMsg, setPwMsg] = useState<string | null>(null);
|
|
const [savingPw, setSavingPw] = useState(false);
|
|
|
|
async function changePassword() {
|
|
setPwMsg(null);
|
|
if (next.length < MIN_PASSWORD) {
|
|
setPwMsg(t("profile.passwordTooShort", { min: MIN_PASSWORD }));
|
|
return;
|
|
}
|
|
if (next !== confirm) {
|
|
setPwMsg(t("profile.passwordsDontMatch"));
|
|
return;
|
|
}
|
|
setSavingPw(true);
|
|
try {
|
|
await changeMyPassword(current, next);
|
|
setCurrent("");
|
|
setNext("");
|
|
setConfirm("");
|
|
setPwMsg(t("profile.passwordChanged"));
|
|
} catch (e) {
|
|
setPwMsg((e as Error).message);
|
|
} finally {
|
|
setSavingPw(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto flex max-w-xl flex-col gap-6">
|
|
<h1 className="text-lg text-term-text">{t("profile.title")}</h1>
|
|
|
|
{/* Account: display name + email (username + role are read-only — admin-managed). */}
|
|
<section className="card flex flex-col gap-3 p-4">
|
|
<h2 className="text-sm uppercase tracking-wider text-term-muted">
|
|
{t("profile.accountSection")}
|
|
</h2>
|
|
<div className="grid grid-cols-2 gap-3 text-[0.6875rem] text-term-muted">
|
|
<div>
|
|
<span className="block">{t("profile.username")}</span>
|
|
<span className="text-sm text-term-text">{user.username}</span>
|
|
</div>
|
|
<div>
|
|
<span className="block">{t("profile.role")}</span>
|
|
<span className="text-sm text-term-text">{user.roleName}</span>
|
|
</div>
|
|
</div>
|
|
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
|
|
{t("profile.fullName")}
|
|
<input
|
|
className="input"
|
|
value={fullName}
|
|
placeholder={t("profile.fullNamePh")}
|
|
onChange={(e) => setFullName(e.target.value)}
|
|
/>
|
|
</label>
|
|
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
|
|
{t("profile.email")}
|
|
<input
|
|
className="input"
|
|
type="email"
|
|
value={email}
|
|
placeholder={t("profile.emailPh")}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
/>
|
|
</label>
|
|
<div className="flex items-center gap-3">
|
|
<button type="button" className="btn btn-primary btn-sm" onClick={saveAccount} disabled={savingAccount}>
|
|
{t("profile.saveProfile")}
|
|
</button>
|
|
{accountMsg && <span className="text-[0.6875rem] text-term-muted">{accountMsg}</span>}
|
|
</div>
|
|
</section>
|
|
|
|
{/* Password: requires the current one (server enforces). */}
|
|
<section className="card flex flex-col gap-3 p-4">
|
|
<h2 className="text-sm uppercase tracking-wider text-term-muted">
|
|
{t("profile.passwordSection")}
|
|
</h2>
|
|
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
|
|
{t("profile.currentPassword")}
|
|
<input
|
|
className="input"
|
|
type="password"
|
|
autoComplete="current-password"
|
|
value={current}
|
|
onChange={(e) => setCurrent(e.target.value)}
|
|
/>
|
|
</label>
|
|
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
|
|
{t("profile.newPassword")}
|
|
<input
|
|
className="input"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
value={next}
|
|
onChange={(e) => setNext(e.target.value)}
|
|
/>
|
|
</label>
|
|
<label className="flex flex-col gap-1 text-[0.6875rem] text-term-muted">
|
|
{t("profile.confirmPassword")}
|
|
<input
|
|
className="input"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
value={confirm}
|
|
onChange={(e) => setConfirm(e.target.value)}
|
|
/>
|
|
</label>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary btn-sm"
|
|
onClick={changePassword}
|
|
disabled={savingPw || !current || !next || !confirm}
|
|
>
|
|
{t("profile.changePassword")}
|
|
</button>
|
|
{pwMsg && <span className="text-[0.6875rem] text-term-muted">{pwMsg}</span>}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|