Files
vkv/resources/js/components/LogsTable.tsx
Zdeněk Burda 1e484aef47 Skrytí osobních údajů #1
Nezobrazovat detail logu anonymnímu uživateli #2
2026-01-10 12:50:45 +01:00

270 lines
10 KiB
TypeScript

import React, { useEffect, useState } from "react";
import axios from "axios";
import { useTranslation } from "react-i18next";
import { useNavigate, useLocation } from "react-router-dom";
import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, Pagination } from "@heroui/react";
import { useUserStore } from "@/stores/userStore";
import { useContestStore } from "@/stores/contestStore";
type LogItem = {
id: number;
round_id: number;
parsed?: boolean;
parsed_claimed?: boolean;
tname?: string | null;
tdate?: string | null;
pcall?: string | null;
rcall?: string | null; // pokud backend vrátí, jinak zobrazíme prázdně
psect?: string | null;
pband?: string | null;
power_watt?: number | null;
claimed_qso_count?: number | null;
claimed_score?: number | null;
file_id?: number | null;
file?: {
id: number;
filename: string;
mimetype?: string | null;
} | null;
};
type PaginatedResponse<T> = {
data: T[];
current_page: number;
last_page: number;
total: number;
};
type LogsTableProps = {
roundId: number | null;
perPage?: number;
refreshKey?: number;
contestId?: number | null;
};
export default function LogsTable({ roundId, perPage = 50, refreshKey = 0, contestId = null }: LogsTableProps) {
const { t } = useTranslation("common");
const user = useUserStore((s) => s.user);
const selectedRound = useContestStore((s) => s.selectedRound);
const navigate = useNavigate();
const location = useLocation();
const [items, setItems] = useState<LogItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [lastPage, setLastPage] = useState(1);
const [finalPublished, setFinalPublished] = useState(false);
const isAdmin = !!user?.is_admin;
useEffect(() => {
if (isAdmin) {
setFinalPublished(true);
return;
}
if (!roundId) {
setFinalPublished(false);
return;
}
const officialRunId =
selectedRound?.id === roundId ? selectedRound?.official_evaluation_run_id ?? null : null;
if (!officialRunId) {
setFinalPublished(false);
return;
}
let active = true;
(async () => {
try {
const res = await axios.get<{ status?: string | null; result_type?: string | null }>(
`/api/evaluation-runs/${officialRunId}`,
{
headers: { Accept: "application/json" },
withCredentials: true,
}
);
if (!active) return;
const isFinal =
res.data?.status === "SUCCEEDED" && res.data?.result_type === "FINAL";
setFinalPublished(isFinal);
} catch {
if (!active) return;
setFinalPublished(false);
}
})();
return () => {
active = false;
};
}, [roundId, selectedRound?.official_evaluation_run_id, isAdmin]);
useEffect(() => {
if (!roundId) return;
let active = true;
(async () => {
try {
setLoading(true);
setError(null);
const res = await axios.get<PaginatedResponse<LogItem>>("/api/logs", {
headers: { Accept: "application/json" },
params: { round_id: roundId, per_page: perPage, page },
withCredentials: true,
});
if (!active) return;
setItems(res.data.data);
setLastPage(res.data.last_page ?? 1);
} catch (e: any) {
if (!active) return;
const message = e?.response?.data?.message ?? (t("unable_to_load_logs") as string) ?? "Nepodařilo se načíst logy.";
setError(message);
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [roundId, perPage, page, t, refreshKey]);
useEffect(() => {
setPage(1);
}, [roundId, perPage, refreshKey]);
if (!roundId) return null;
if (loading) return <div>{t("logs_loading") ?? "Načítám logy…"}</div>;
if (error) return <div className="text-sm text-red-600">{error}</div>;
if (!items.length) {
return <div>{t("logs_empty") ?? "Žádné logy nejsou k dispozici."}</div>;
}
const columns = [
{ key: "parsed", label: "" },
{ key: "pcall", label: "PCall" },
{ key: "pband", label: "PBand" },
{ key: "psect", label: "PSect" },
{ key: "power_watt", label: "SPowe" },
{ key: "claimed_qso_count", label: "QSO" },
{ key: "claimed_score", label: "Deklarované body" },
...(user ? [{ key: "actions", label: "" }] : []),
];
const format = (value: string | null | undefined) => value || "—";
const formatPcall = (value: string | null | undefined, waiting: boolean) =>
waiting ? (t("logs_waiting_processing") as string) || "Čekám na zpracování" : value || "—";
const formatNumber = (value: number | null | undefined) => (value === null || value === undefined ? "—" : String(value));
const canNavigate = isAdmin || finalPublished;
const handleDelete = async (id: number, e?: React.MouseEvent) => {
e?.stopPropagation();
const confirmed = window.confirm(t("confirm_delete_log") ?? "Opravdu smazat log?");
if (!confirmed) return;
try {
await axios.delete(`/api/logs/${id}`, {
headers: { Accept: "application/json" },
withCredentials: true,
withXSRFToken: true,
});
setItems((prev) => prev.filter((i) => i.id !== id));
} catch (e: any) {
const message = e?.response?.data?.message ?? (t("unable_to_delete_log") as string) ?? "Nepodařilo se smazat log.";
setError(message);
}
};
return (
<div className="space-y-3">
<Table aria-label="Logs table" classNames={{ th: "py-2", td: "py-1 text-sm" }}>
<TableHeader columns={columns}>
{(column) => <TableColumn key={column.key}>{column.label}</TableColumn>}
</TableHeader>
<TableBody items={items}>
{(item) => (
<TableRow
key={item.id}
onClick={() => {
if (contestId && roundId && canNavigate) {
navigate(`/contests/${contestId}/rounds/${roundId}/logs/${item.id}`, {
state: { from: `${location.pathname}${location.search}` },
});
}
}}
className={
contestId && roundId && canNavigate ? "cursor-pointer hover:bg-default-100" : undefined
}
>
{(columnKey) => {
if (columnKey === "actions") {
return (
<TableCell>
<div className="flex items-center gap-2">
{item.file_id && (
<a
href={`/api/files/${item.file_id}/download`}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center p-1 rounded hover:bg-default-100 text-default-500 hover:text-default-700"
aria-label={t("download_file") ?? "Stáhnout soubor"}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4">
<path d="M3 14.25a.75.75 0 0 1 .75-.75h2v1.5h-2A.75.75 0 0 1 3 14.25Zm3.75-.75h6.5v1.5h-6.5v-1.5Zm8.5 0H17a.75.75 0 0 1 0 1.5h-1.75v-1.5ZM10.75 3a.75.75 0 0 0-1.5 0v7.19L7.53 8.47a.75.75 0 1 0-1.06 1.06l3.25 3.25c.3.3.77.3 1.06 0l3.25-3.25a.75.75 0 1 0-1.06-1.06l-1.72 1.72V3Z" />
</svg>
</a>
)}
<button
type="button"
onClick={(e) => handleDelete(item.id, e)}
className="inline-flex items-center p-1 rounded hover:bg-danger-100 text-danger-500 hover:text-danger-700"
aria-label={t("delete") ?? "Smazat"}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4">
<path d="M6 8.75A.75.75 0 0 1 6.75 8h6.5a.75.75 0 0 1 0 1.5h-6.5A.75.75 0 0 1 6 8.75Zm0 3.5a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1-.75-.75Z" />
<path d="M6 1.75A1.75 1.75 0 0 1 7.75 0h4.5A1.75 1.75 0 0 1 14 1.75V3h3.25a.75.75 0 0 1 0 1.5H16.5l-.55 11.05A2.25 2.25 0 0 1 13.7 18.75H7.3a2.25 2.25 0 0 1-2.24-2.2L4.5 4.5H2.75a.75.75 0 0 1 0-1.5H6V1.75ZM12.5 3V1.75a.25.25 0 0 0-.25-.25h-4.5a.25.25 0 0 0-.25.25V3h5Zm-6.5 1.5.5 10.25a.75.75 0 0 0 .75.7h6.4a.75.75 0 0 0 .75-.7L14 4.5H6Z" />
</svg>
</button>
</div>
</TableCell>
);
}
if (columnKey === "parsed") {
const parsedClaimed = !!item.parsed_claimed;
const symbol = parsedClaimed ? "✓" : "↻";
const color = parsedClaimed ? "text-green-600" : "text-blue-600";
return (
<TableCell>
<span className={color}>{symbol}</span>
</TableCell>
);
}
if (columnKey === "power_watt" || columnKey === "claimed_qso_count" || columnKey === "claimed_score") {
return <TableCell>{formatNumber((item as any)[columnKey as string])}</TableCell>;
}
if (columnKey === "pcall") {
const waiting = !item.parsed_claimed;
return <TableCell>{formatPcall(item.pcall, waiting)}</TableCell>;
}
return <TableCell>{format((item as any)[columnKey as string])}</TableCell>;
}}
</TableRow>
)}
</TableBody>
</Table>
{lastPage > 1 && (
<div className="flex justify-end">
<Pagination
total={lastPage}
page={page}
onChange={setPage}
showShadow
/>
</div>
)}
</div>
);
}