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
163 lines
6.8 KiB
TypeScript
163 lines
6.8 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type SiteConfig } from "./api.js";
|
|
|
|
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
|
|
// fold over the signed ledger); capacity and the metadata fields are admin-editable.
|
|
// The FULL gate (refuse transient entry at capacity) is enforced server-side in the
|
|
// entry flow. Metadata (name, NIUS, address, contact) feeds the ticket header.
|
|
// See wiki/concepts/capacity-occupancy.md and wiki/concepts/site-metadata.md.
|
|
|
|
// The optional text fields, in display order. `labelKey`/`phKey` are i18n keys
|
|
// (resolved at render); only `address` is multiline.
|
|
const META_FIELDS: ReadonlyArray<{ key: keyof SiteConfig; labelKey: string; phKey?: string; multiline?: boolean }> = [
|
|
{ key: "parkName", labelKey: "site.fieldParkName", phKey: "site.fieldParkNamePh" },
|
|
{ key: "operatorName", labelKey: "site.fieldOperator", phKey: "site.fieldOperatorPh" },
|
|
{ key: "nius", labelKey: "site.fieldNius", phKey: "site.fieldNiusPh" },
|
|
{ key: "address", labelKey: "site.fieldAddress", multiline: true },
|
|
{ key: "phone", labelKey: "site.fieldPhone" },
|
|
{ key: "email", labelKey: "site.fieldEmail" },
|
|
];
|
|
|
|
export function SiteSettings({ canEdit }: { canEdit: boolean }) {
|
|
const { t } = useTranslation();
|
|
const [occ, setOcc] = useState<Occupancy | null>(null);
|
|
const [capInput, setCapInput] = useState("");
|
|
const [meta, setMeta] = useState<Record<string, string>>({});
|
|
const [exitVoucherDefault, setExitVoucherDefault] = useState(false);
|
|
const [reserveSubs, setReserveSubs] = useState(false);
|
|
const [anprEntry, setAnprEntry] = useState(true);
|
|
const [msg, setMsg] = useState<string | null>(null);
|
|
|
|
function reload() {
|
|
fetchOccupancy().then(setOcc).catch(() => {});
|
|
}
|
|
useEffect(() => {
|
|
reload();
|
|
fetchSiteConfig()
|
|
.then((c) => {
|
|
setCapInput(c.capacity == null ? "" : String(c.capacity));
|
|
setExitVoucherDefault(c.exitVoucherDefault);
|
|
setReserveSubs(c.reserveSubscriberSpots);
|
|
setAnprEntry(c.anprEntryEnabled);
|
|
const m: Record<string, string> = {};
|
|
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
|
|
setMeta(m);
|
|
})
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
async function save() {
|
|
setMsg(null);
|
|
const raw = capInput.trim();
|
|
const patch: Partial<SiteConfig> = {
|
|
capacity: raw === "" ? null : Math.round(Number(raw)),
|
|
exitVoucherDefault,
|
|
reserveSubscriberSpots: reserveSubs,
|
|
anprEntryEnabled: anprEntry,
|
|
};
|
|
// Send each metadata field; "" → null is applied server-side.
|
|
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
|
|
try {
|
|
await saveSiteConfig(patch);
|
|
reload();
|
|
setMsg(t("site.saved"));
|
|
} catch (e) {
|
|
setMsg((e as Error).message);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section className="card mt-6 max-w-md p-4">
|
|
<div className="flex flex-wrap items-center gap-1.5 text-[0.8125rem]">
|
|
<strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
|
|
{occ == null ? (
|
|
<span className="text-term-muted">…</span>
|
|
) : (
|
|
<>
|
|
<span className="text-h5 font-semibold tabular-nums text-term-text">{occ.count}</span>
|
|
<span className="tabular-nums text-term-muted">
|
|
{occ.capacity != null ? `/ ${occ.capacity}` : t("site.noCapacitySet")}
|
|
</span>
|
|
{occ.capacity != null && (
|
|
<span className="tabular-nums text-term-muted">· {occ.free} {t("site.free")}</span>
|
|
)}
|
|
{occ.full && <span className="font-semibold text-term-red">{t("site.full")}</span>}
|
|
<button type="button" className="btn btn-ghost btn-sm" onClick={reload}>↻</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
{canEdit && (
|
|
<div className="mt-4 grid gap-3">
|
|
<div className="field">
|
|
<span className="label">{t("site.capacityLabel")}</span>
|
|
<input className="input w-32" value={capInput} onChange={(e) => setCapInput(e.target.value)} placeholder={t("site.capacityPlaceholder")} />
|
|
</div>
|
|
<label className="flex items-center gap-2 text-[0.75rem] text-term-text">
|
|
<input
|
|
type="checkbox"
|
|
className="accent-term-amber"
|
|
checked={exitVoucherDefault}
|
|
onChange={(e) => setExitVoucherDefault(e.target.checked)}
|
|
/>
|
|
{t("site.printExitDefault")}
|
|
<span className="hint">{t("site.printExitHint")}</span>
|
|
</label>
|
|
<label className="flex items-start gap-2 text-[0.75rem] text-term-text">
|
|
<input
|
|
type="checkbox"
|
|
className="mt-0.5 accent-term-amber"
|
|
checked={reserveSubs}
|
|
onChange={(e) => setReserveSubs(e.target.checked)}
|
|
/>
|
|
<span>
|
|
{t("site.reserveSubs")}
|
|
<span className="hint block">{t("site.reserveSubsHint")}</span>
|
|
</span>
|
|
</label>
|
|
<label className="flex items-start gap-2 text-[0.75rem] text-term-text">
|
|
<input
|
|
type="checkbox"
|
|
className="mt-0.5 accent-term-amber"
|
|
checked={anprEntry}
|
|
onChange={(e) => setAnprEntry(e.target.checked)}
|
|
/>
|
|
<span>
|
|
{t("site.anprEntry")}
|
|
<span className="hint block">{t("site.anprEntryHint")}</span>
|
|
</span>
|
|
</label>
|
|
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
|
{t("site.parkDetails")}
|
|
</div>
|
|
{META_FIELDS.map(({ key, labelKey, phKey, multiline }) => (
|
|
<div key={key} className="field">
|
|
<span className="label">{t(labelKey)}</span>
|
|
{multiline ? (
|
|
<textarea
|
|
className="textarea"
|
|
value={meta[key] ?? ""}
|
|
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
|
|
rows={2}
|
|
placeholder={phKey ? t(phKey) : undefined}
|
|
/>
|
|
) : (
|
|
<input
|
|
className="input"
|
|
value={meta[key] ?? ""}
|
|
onChange={(e) => setMeta((m) => ({ ...m, [key]: e.target.value }))}
|
|
placeholder={phKey ? t(phKey) : undefined}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
<div className="flex items-center gap-3">
|
|
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("site.save")}</button>
|
|
{msg && <span className="text-[0.75rem] text-term-muted">{msg}</span>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|