Initial commit
This commit is contained in:
246
resources/js/components/LogsTable.tsx
Normal file
246
resources/js/components/LogsTable.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
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";
|
||||
|
||||
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ě
|
||||
pwwlo?: string | null;
|
||||
psect?: string | null;
|
||||
pband?: string | null;
|
||||
power_watt?: number | null;
|
||||
claimed_qso_count?: number | null;
|
||||
claimed_score?: number | null;
|
||||
remarks_eval?: string | 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 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);
|
||||
|
||||
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: "pwwlo", label: "PWWLo" },
|
||||
{ key: "pband", label: "PBand" },
|
||||
{ key: "psect", label: "PSect" },
|
||||
{ key: "power_watt", label: "SPowe" },
|
||||
{ key: "claimed_qso_count", label: "QSO" },
|
||||
{ key: "claimed_score", label: "Body" },
|
||||
{ key: "remarks_eval", label: "remarks_eval" },
|
||||
...(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 renderRemarksEval = (raw: string | null | undefined) => {
|
||||
if (!raw) return "—";
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
const lines = parsed
|
||||
.filter((item) => typeof item === "string" && item.trim() !== "")
|
||||
.map((item, idx) => <div key={idx}>{item}</div>);
|
||||
if (lines.length > 0) return lines;
|
||||
}
|
||||
} catch {
|
||||
// fall through to show raw string
|
||||
}
|
||||
|
||||
return <div>{raw}</div>;
|
||||
};
|
||||
|
||||
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) {
|
||||
navigate(`/contests/${contestId}/rounds/${roundId}/logs/${item.id}`, {
|
||||
state: { from: `${location.pathname}${location.search}` },
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={contestId && roundId ? "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 parsedAny = !!item.parsed;
|
||||
const symbol = parsedClaimed ? "✓" : "↻";
|
||||
const color = parsedClaimed ? "text-green-600" : "text-blue-600";
|
||||
return (
|
||||
<TableCell>
|
||||
<span className={color}>{symbol}</span>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
if (columnKey === "remarks_eval") {
|
||||
return <TableCell>{renderRemarksEval(item.remarks_eval)}</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user