:3
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CheckCircle2, Clock, Inbox, Search, SendHorizontal, Undo2, XCircle } from "lucide-react";
|
||||
import { api, type AccessMessage, type AccessRequest, type AccessStatus } from "../lib/api.ts";
|
||||
import { Button, CopyButton, Field, Input, Spinner } from "./ui.tsx";
|
||||
@@ -23,8 +23,11 @@ export function AccessRequestsPanel() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const { t } = useT();
|
||||
const listBusy = useRef(false);
|
||||
|
||||
async function refresh(select?: string) {
|
||||
if (listBusy.current) return;
|
||||
listBusy.current = true;
|
||||
try {
|
||||
const res = await api.adminAccessList();
|
||||
setRequests(res.requests);
|
||||
@@ -32,12 +35,19 @@ export function AccessRequestsPanel() {
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("admin.loadFailed"));
|
||||
} finally {
|
||||
listBusy.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
// Live list: new requests / replies pop without manual refresh.
|
||||
const id = setInterval(() => {
|
||||
if (!document.hidden) refresh();
|
||||
}, 15000);
|
||||
return () => clearInterval(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const visible = requests.filter((r) => filter === "all" || r.status === filter);
|
||||
@@ -122,12 +132,47 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
|
||||
const [error, setError] = useState("");
|
||||
const { t } = useT();
|
||||
const [freshKey, setFreshKey] = useState<string | null>(null);
|
||||
// Latest callbacks / server state without restarting the poll loop.
|
||||
const onChangedRef = useRef(onChanged);
|
||||
onChangedRef.current = onChanged;
|
||||
const known = useRef({ status: request.status, reason: request.reason });
|
||||
|
||||
useEffect(() => {
|
||||
setReason(request.reason);
|
||||
setFreshKey(null);
|
||||
api.adminAccessGet(request.id).then((res) => setMessages(res.messages)).catch(() => {});
|
||||
}, [request.id, request.reason]);
|
||||
known.current = { status: request.status, reason: request.reason };
|
||||
let stopped = false;
|
||||
let busy = false;
|
||||
// Live thread: requester replies appear without manual refresh, and a
|
||||
// status change made elsewhere syncs the list (badges, filters).
|
||||
async function fetchOnce() {
|
||||
if (busy || document.hidden) return;
|
||||
busy = true;
|
||||
try {
|
||||
const res = await api.adminAccessGet(request.id);
|
||||
if (stopped) return;
|
||||
setMessages(res.messages);
|
||||
if (
|
||||
res.request.status !== known.current.status ||
|
||||
res.request.reason !== known.current.reason
|
||||
) {
|
||||
known.current = { status: res.request.status, reason: res.request.reason };
|
||||
onChangedRef.current();
|
||||
}
|
||||
} catch {
|
||||
// Transient failure: keep last state, retry next tick.
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
fetchOnce();
|
||||
const id = setInterval(fetchOnce, 5000);
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [request.id]);
|
||||
|
||||
const closed = request.status === "accepted" || request.status === "refused";
|
||||
|
||||
@@ -137,6 +182,7 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
|
||||
try {
|
||||
const res = await api.adminAccessPatch(request.id, { status, reason: reason.trim() });
|
||||
if (res.key) setFreshKey(res.key);
|
||||
known.current = { status: res.request.status, reason: res.request.reason };
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("access.updateFailed"));
|
||||
|
||||
@@ -23,6 +23,7 @@ export function AdminCenter() {
|
||||
const [settings, setSettings] = useState<{ registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean } | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [openAccessCount, setOpenAccessCount] = useState(0);
|
||||
|
||||
const TABS: { id: Tab; label: string; icon: typeof Users }[] = [
|
||||
{ id: "accounts", label: t("center.tabAccounts"), icon: Users },
|
||||
@@ -45,6 +46,32 @@ export function AdminCenter() {
|
||||
.catch(() => setAllowed(false));
|
||||
}, []);
|
||||
|
||||
// Live badge on the Access tab: poll the open (pending + reviewing)
|
||||
// request count so new demands pop without opening the tab.
|
||||
useEffect(() => {
|
||||
if (!allowed) return;
|
||||
let stopped = false;
|
||||
async function fetchCount() {
|
||||
try {
|
||||
const res = await api.adminAccessList();
|
||||
if (stopped) return;
|
||||
setOpenAccessCount(
|
||||
res.requests.filter((r) => r.status === "pending" || r.status === "reviewing").length,
|
||||
);
|
||||
} catch {
|
||||
// Transient failure: keep last count, retry next tick.
|
||||
}
|
||||
}
|
||||
fetchCount();
|
||||
const id = setInterval(() => {
|
||||
if (!document.hidden) fetchCount();
|
||||
}, 15000);
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [allowed]);
|
||||
|
||||
async function toggle(patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean }) {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
@@ -95,7 +122,7 @@ export function AdminCenter() {
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-center gap-2 whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition",
|
||||
"relative flex flex-1 items-center justify-center gap-2 whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition",
|
||||
tab === t.id
|
||||
? "bg-white text-stone-900 shadow dark:bg-zinc-800 dark:text-zinc-100"
|
||||
: "opacity-60 hover:opacity-100",
|
||||
@@ -103,6 +130,15 @@ export function AdminCenter() {
|
||||
>
|
||||
<t.icon size={15} />
|
||||
{t.label}
|
||||
{t.id === "access" && openAccessCount > 0 && (
|
||||
<span
|
||||
role="status"
|
||||
aria-label={`${t.label}: ${openAccessCount}`}
|
||||
className="absolute -right-0.5 -top-1.5 grid min-h-5 min-w-5 place-items-center rounded-full bg-red-500 px-1 text-[11px] font-bold leading-5 text-white shadow"
|
||||
>
|
||||
{openAccessCount > 99 ? "99+" : openAccessCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -90,11 +90,12 @@ export function Chat({
|
||||
const modelGroups: PickerGroup[] = (() => {
|
||||
const byDriver = new Map<string, { value: string; label: string; hint: string }[]>();
|
||||
for (const m of models) {
|
||||
const list = byDriver.get(m.driver) ?? [];
|
||||
const group = m.group ?? m.driver;
|
||||
const list = byDriver.get(group) ?? [];
|
||||
list.push({ value: m.id, label: m.label, hint: m.id });
|
||||
byDriver.set(m.driver, list);
|
||||
byDriver.set(group, list);
|
||||
}
|
||||
return [...byDriver.entries()].map(([driver, options]) => ({ group: driver, options }));
|
||||
return [...byDriver.entries()].map(([group, options]) => ({ group, options }));
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -83,7 +83,31 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let stopped = false;
|
||||
load();
|
||||
// Live updates: short-poll the ticket so admin replies and the
|
||||
// decision show up without a manual refresh. No WS infra on the
|
||||
// backend — traffic here is tiny, polling is the clean fit.
|
||||
let busy = false;
|
||||
async function poll() {
|
||||
if (stopped || busy || document.hidden) return;
|
||||
busy = true;
|
||||
try {
|
||||
const res = await api.accessTicket(ticketId);
|
||||
if (stopped) return;
|
||||
setRequest(res.request);
|
||||
setMessages(res.messages);
|
||||
} catch {
|
||||
// Transient failure: keep last known state, retry next tick.
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
const id = setInterval(poll, 5000);
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ticketId]);
|
||||
|
||||
@@ -147,9 +171,18 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
||||
</p>
|
||||
)}
|
||||
{request.status === "accepted" && (
|
||||
<p className="rounded-2xl bg-accent-500/10 px-4 py-2.5 text-center text-sm">
|
||||
{t("review.acceptedHint")}
|
||||
</p>
|
||||
<>
|
||||
<p className="rounded-2xl bg-accent-500/10 px-4 py-2.5 text-center text-sm">
|
||||
{t("review.acceptedHint")}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => (window.location.href = "/")}
|
||||
>
|
||||
{t("common.backToLogin")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="max-h-80 space-y-2.5 overflow-y-auto rounded-2xl border border-stone-200/70 p-3 dark:border-zinc-800">
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface DriverModel {
|
||||
name: string;
|
||||
driver: string;
|
||||
label: string;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
|
||||
Reference in New Issue
Block a user