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
171 lines
6.4 KiB
TypeScript
171 lines
6.4 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
ApiError,
|
|
can,
|
|
fetchRecycleBin,
|
|
purgeRecycleItem,
|
|
restoreRecycleItem,
|
|
type RecycleBinItem,
|
|
type RecycleKind,
|
|
type SessionUser,
|
|
} from "./api.js";
|
|
import { qk } from "./lib/query.js";
|
|
import { formatRelativeDateTime } from "./lib/format.js";
|
|
import { Modal } from "./ui/Modal.js";
|
|
|
|
// Recycle bin — the way back from an accidental delete. Lists everything soft-deleted
|
|
// across users/roles/subscriptions/plans/tariffs; an admin can Restore (back to its
|
|
// catalog) or Purge (permanent). Items auto-purge after the retention window. Gated by
|
|
// recyclebin:* (read to view, update to restore, delete to purge). See
|
|
// apps/server/src/recycle-bin.ts, wiki/concepts/soft-delete.md.
|
|
|
|
const KIND_KEY: Record<RecycleKind, string> = {
|
|
user: "recycleBin.kind.user",
|
|
role: "recycleBin.kind.role",
|
|
subscription: "recycleBin.kind.subscription",
|
|
plan: "recycleBin.kind.plan",
|
|
tariff: "recycleBin.kind.tariff",
|
|
};
|
|
|
|
export function RecycleBin({ user }: { user: SessionUser | null }) {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const binQ = useQuery({ queryKey: qk.recycleBin, queryFn: fetchRecycleBin });
|
|
|
|
const canRestore = can(user, "recyclebin:update");
|
|
const canPurge = can(user, "recyclebin:delete");
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [purging, setPurging] = useState<RecycleBinItem | null>(null);
|
|
|
|
const onError = (e: unknown) => setError(e instanceof ApiError ? e.message : (e as Error).message);
|
|
const invalidate = () => {
|
|
void qc.invalidateQueries({ queryKey: qk.recycleBin });
|
|
// A restore/purge can change any catalog — refresh the ones a restore touches.
|
|
for (const key of [["users"], ["roles"], ["subscriptions"], ["subscription-plans"], ["tariff"]]) {
|
|
void qc.invalidateQueries({ queryKey: key });
|
|
}
|
|
};
|
|
|
|
const restoreM = useMutation({
|
|
mutationFn: ({ kind, id }: { kind: RecycleKind; id: string }) => restoreRecycleItem(kind, id),
|
|
onSuccess: invalidate,
|
|
onError,
|
|
});
|
|
const purgeM = useMutation({
|
|
mutationFn: ({ kind, id }: { kind: RecycleKind; id: string }) => purgeRecycleItem(kind, id),
|
|
onSuccess: () => {
|
|
setPurging(null);
|
|
invalidate();
|
|
},
|
|
onError: (e) => {
|
|
setPurging(null);
|
|
onError(e);
|
|
},
|
|
});
|
|
|
|
const items = binQ.data?.items ?? [];
|
|
const retentionDays = binQ.data?.retentionDays ?? 0;
|
|
|
|
return (
|
|
<div className="mx-auto max-w-4xl">
|
|
<div className="mb-3 flex items-center gap-3">
|
|
<h1 className="text-base font-bold uppercase tracking-widest text-term-amber">
|
|
{t("recycleBin.title")}
|
|
</h1>
|
|
{retentionDays > 0 && (
|
|
<span className="text-[0.75rem] text-term-muted">
|
|
{t("recycleBin.retentionNote", { days: retentionDays })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{error && <p className="mb-2 text-[0.75rem] text-term-red">{error}</p>}
|
|
{binQ.isLoading && <p className="text-term-muted">{t("common.loading")}</p>}
|
|
|
|
{!binQ.isLoading && items.length === 0 ? (
|
|
<p className="rounded-term border border-term-border bg-term-panel p-6 text-center text-term-muted">
|
|
{t("recycleBin.empty")}
|
|
</p>
|
|
) : (
|
|
<table className="w-full text-[0.8125rem]">
|
|
<thead>
|
|
<tr className="border-b border-term-border text-left text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
|
<th className="py-1.5 pr-3">{t("recycleBin.col.type")}</th>
|
|
<th className="py-1.5 pr-3">{t("recycleBin.col.item")}</th>
|
|
<th className="py-1.5 pr-3">{t("recycleBin.col.deleted")}</th>
|
|
<th className="py-1.5 text-right">{t("recycleBin.col.actions")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.map((it) => (
|
|
<tr key={`${it.kind}:${it.id}`} className="border-b border-term-border/50">
|
|
<td className="py-1.5 pr-3">
|
|
<span className="rounded-term border border-term-border px-1.5 py-0.5 text-[0.6875rem] text-term-muted">
|
|
{t(KIND_KEY[it.kind])}
|
|
</span>
|
|
</td>
|
|
<td className="py-1.5 pr-3 text-term-text">{it.label}</td>
|
|
<td className="py-1.5 pr-3 text-term-muted">
|
|
{formatRelativeDateTime(it.deletedAt, t)}
|
|
</td>
|
|
<td className="py-1.5 text-right">
|
|
{canRestore && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm"
|
|
disabled={restoreM.isPending}
|
|
onClick={() => {
|
|
setError(null);
|
|
restoreM.mutate({ kind: it.kind, id: it.id });
|
|
}}
|
|
>
|
|
{t("recycleBin.restore")}
|
|
</button>
|
|
)}
|
|
{canPurge && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-ghost ml-1 text-term-red"
|
|
onClick={() => {
|
|
setError(null);
|
|
setPurging(it);
|
|
}}
|
|
>
|
|
{t("recycleBin.purge")}
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
|
|
{purging && (
|
|
<Modal open onClose={() => setPurging(null)} title={t("recycleBin.purgeConfirmTitle")}>
|
|
<p className="text-[0.8125rem] text-term-text">
|
|
{t("recycleBin.purgeConfirmBody", { label: purging.label })}
|
|
</p>
|
|
<p className="mt-1 text-[0.75rem] text-term-red">{t("recycleBin.purgeIrreversible")}</p>
|
|
<div className="mt-3 flex justify-end gap-2">
|
|
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setPurging(null)}>
|
|
{t("common.cancel")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-sm btn-danger"
|
|
disabled={purgeM.isPending}
|
|
onClick={() => purgeM.mutate({ kind: purging.kind, id: purging.id })}
|
|
>
|
|
{t("recycleBin.purge")}
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|