270 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|