Initial commit

This commit is contained in:
Zdeněk Burda
2026-01-09 21:26:40 +01:00
parent e83aec6dca
commit 41e3ce6f25
404 changed files with 61250 additions and 28 deletions

View File

@@ -0,0 +1,84 @@
import React from "react";
import { Breadcrumbs, BreadcrumbItem } from "@heroui/react";
import { useContestStore } from "@/stores/contestStore";
import { useLanguageStore } from "@/stores/languageStore";
import { useNavigate } from "react-router-dom";
type AppBreadcrumbsProps = {
extra?: { label: string; href?: string }[];
};
export default function AppBreadcrumbs({ extra = [] }: AppBreadcrumbsProps) {
const selectedContest = useContestStore((s) => s.selectedContest);
const selectedRound = useContestStore((s) => s.selectedRound);
const setSelectedRound = useContestStore((s) => s.setSelectedRound);
const clearSelection = useContestStore((s) => s.clearSelection);
const setSelectedContest = useContestStore((s) => s.setSelectedContest);
const locale = useLanguageStore((s) => s.locale);
const navigate = useNavigate();
const resolveLabel = (value: any): string => {
if (!value) return "";
if (typeof value === "string") return value;
if (typeof value === "object") {
if (value[locale]) return value[locale];
if (value["en"]) return value["en"];
const first = Object.values(value)[0];
return typeof first === "string" ? first : "";
}
return String(value);
};
const crumbs = [
{ label: "Home", href: "/", level: "home" as const },
{ label: "Závody", href: "/contests", level: "contests" as const },
...(selectedContest
? [{
label: resolveLabel(selectedContest.name),
href: `/contests/${selectedContest.id}`,
level: "contest" as const,
contest: selectedContest,
}]
: []),
...(selectedRound
? [{
label: resolveLabel(selectedRound.name),
href: `/contests/${selectedContest?.id ?? ""}/rounds/${selectedRound.id}`,
level: "round" as const,
}]
: []),
...extra,
];
const handleNavigate = (href?: string, level?: "home" | "contests" | "contest" | "round", contest?: any) => {
if (!href) return;
// při kliknutí na vyšší úroveň vyčisti nižší selekce
if (level === "home") {
clearSelection();
} else if (level === "contests") {
setSelectedRound(null);
} else if (level === "contest") {
setSelectedRound(null);
if (contest) {
setSelectedContest(contest);
}
}
navigate(href);
};
return (
<Breadcrumbs aria-label="Breadcrumb" variant="solid" className="mb-2">
{crumbs.map((c, idx) => (
<BreadcrumbItem
key={`${c.label}-${idx}`}
href={c.href}
onPress={() => handleNavigate(c.href, c.level as any, (c as any).contest)}
>
{c.label}
</BreadcrumbItem>
))}
</Breadcrumbs>
);
}

View File

@@ -0,0 +1,35 @@
import React from "react";
type AppErrorBoundaryProps = {
children: React.ReactNode;
};
type AppErrorBoundaryState = {
hasError: boolean;
};
export default class AppErrorBoundary extends React.Component<AppErrorBoundaryProps, AppErrorBoundaryState> {
state: AppErrorBoundaryState = {
hasError: false,
};
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: unknown, info: React.ErrorInfo) {
console.error("AppErrorBoundary caught an error", error, info);
}
render() {
if (this.state.hasError) {
return (
<div className="p-4 text-sm text-red-600">
Došlo k chybě při vykreslování stránky.
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,502 @@
import React, { useState, useEffect } from "react";
import axios from "axios";
import { Button, Input, Switch, Textarea } from "@heroui/react";
import { useContestRefreshStore } from "@/stores/contestRefreshStore";
import { useTranslation } from "react-i18next";
type TranslationPayload = {
cs?: string;
en?: string;
};
type ContestFromApi = {
id: number;
name: string | TranslationPayload;
description?: string | TranslationPayload | null;
bands?: { id: number; name: string }[];
categories?: { id: number; name: string }[];
power_categories?: { id: number; name: string }[];
rule_set_id?: number | null;
url?: string | null;
evaluator?: string | null;
email?: string | null;
email2?: string | null;
is_mcr: boolean;
is_sixhr: boolean;
is_active: boolean;
is_test: boolean;
start_time: string | null;
duration: number;
logs_deadline_days: number;
};
type ContestFormMode = "create" | "edit";
type ContestCreateFormProps = {
mode?: ContestFormMode; // default "create"
contest?: ContestFromApi | null; // pro edit
onCreated?: (contest: ContestFromApi) => void;
onUpdated?: (contest: ContestFromApi) => void;
};
type Option = { id: number; name: string; code?: string | null };
const buildTranslationPayload = (cs: string, en: string): TranslationPayload => {
const trimmedCs = cs.trim();
const trimmedEn = en.trim();
// nic nevyplněno → prázdný objekt
if (!trimmedCs && !trimmedEn) {
return {};
}
// oba jazyky vyplněné → použij obě hodnoty
if (trimmedCs && trimmedEn) {
return {
cs: trimmedCs,
en: trimmedEn,
};
}
// vyplněný jen jeden jazyk → použij ho pro oba
const value = trimmedCs || trimmedEn;
return {
cs: value,
en: value,
};
};
const extractTranslations = (
field: string | TranslationPayload | null | undefined
): TranslationPayload => {
if (!field) return {};
if (typeof field === "string") {
return { cs: field };
}
return field;
};
const normalizeStartTime = (time: string): string | undefined => {
if (!time.trim()) return undefined;
return time.length === 5 ? `${time}:00` : time;
};
export default function ContestCreateForm({
mode = "create",
contest,
onCreated,
onUpdated,
}: ContestCreateFormProps) {
const { t } = useTranslation("common");
const isEdit = mode === "edit" && contest != null;
const [nameCs, setNameCs] = useState("");
const [nameEn, setNameEn] = useState("");
const [descriptionCs, setDescriptionCs] = useState("");
const [descriptionEn, setDescriptionEn] = useState("");
const [url, setUrl] = useState("");
const [evaluator, setEvaluator] = useState("");
const [email, setEmail] = useState("");
const [email2, setEmail2] = useState("");
const [startTime, setStartTime] = useState("");
const [durationHours, setDurationHours] = useState("24");
const [deadlineDays, setDeadlineDays] = useState("3");
const [isMcr, setIsMcr] = useState(false);
const [isSixHr, setIsSixHr] = useState(false);
const [isActive, setIsActive] = useState(true);
const [availableBands, setAvailableBands] = useState<Option[]>([]);
const [availableCategories, setAvailableCategories] = useState<Option[]>([]);
const [availablePowerCategories, setAvailablePowerCategories] = useState<Option[]>([]);
const [availableRuleSets, setAvailableRuleSets] = useState<Option[]>([]);
const [selectedRuleSetId, setSelectedRuleSetId] = useState<number | null>(null);
const [selectedBandIds, setSelectedBandIds] = useState<number[]>([]);
const [selectedCategoryIds, setSelectedCategoryIds] = useState<number[]>([]);
const [selectedPowerCategoryIds, setSelectedPowerCategoryIds] = useState<number[]>([]);
const [loadingOptions, setLoadingOptions] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
// načti volby pro checkboxy
useEffect(() => {
let active = true;
(async () => {
try {
setLoadingOptions(true);
const [bandsRes, categoriesRes, powerCatsRes, ruleSetsRes] = await Promise.all([
axios.get<Option[] | { data: Option[] }>("/api/bands", { headers: { Accept: "application/json" } }),
axios.get<Option[] | { data: Option[] }>("/api/categories", { headers: { Accept: "application/json" } }),
axios.get<Option[] | { data: Option[] }>("/api/power-categories", { headers: { Accept: "application/json" } }),
axios.get<Option[] | { data: Option[] }>("/api/evaluation-rule-sets", { headers: { Accept: "application/json" } }),
]);
if (!active) return;
const normalize = (res: any): Option[] =>
Array.isArray(res.data ?? res) ? (res.data ?? res) : [];
setAvailableBands(normalize(bandsRes.data ?? bandsRes));
setAvailableCategories(normalize(categoriesRes.data ?? categoriesRes));
setAvailablePowerCategories(normalize(powerCatsRes.data ?? powerCatsRes));
const normalizedRuleSets = normalize(ruleSetsRes.data ?? ruleSetsRes);
setAvailableRuleSets(normalizedRuleSets);
if (!isEdit && !selectedRuleSetId) {
const defaultRuleSet = normalizedRuleSets.find((item) => item.code === "default_vhf_compat");
if (defaultRuleSet) {
setSelectedRuleSetId(defaultRuleSet.id);
}
}
} catch {
if (!active) return;
setError("Nepodařilo se načíst seznam pásem/kategorií.");
} finally {
if (active) setLoadingOptions(false);
}
})();
return () => {
active = false;
};
}, []);
React.useEffect(() => {
if (!isEdit || !contest) return;
const name = extractTranslations(contest.name);
const desc = extractTranslations(contest.description ?? null);
setNameCs(name.cs ?? "");
setNameEn(name.en ?? "");
setDescriptionCs(desc.cs ?? "");
setDescriptionEn(desc.en ?? "");
setUrl(contest.url ?? "");
setEvaluator(contest.evaluator ?? "");
setEmail(contest.email ?? "");
setEmail2(contest.email2 ?? "");
setStartTime(contest.start_time ?? "");
setDurationHours(String(contest.duration ?? 24));
setDeadlineDays(String(contest.logs_deadline_days ?? 3));
setIsMcr(!!contest.is_mcr);
setIsSixHr(!!contest.is_sixhr);
setIsActive(!!contest.is_active);
setSelectedBandIds(contest.bands?.map((b) => b.id) ?? []);
setSelectedCategoryIds(contest.categories?.map((c) => c.id) ?? []);
setSelectedPowerCategoryIds(contest.power_categories?.map((p) => p.id) ?? []);
setSelectedRuleSetId(contest.rule_set_id ?? null);
setError(null);
setSuccess(null);
}, [isEdit, contest]);
const triggerRefresh = useContestRefreshStore((s) => s.triggerRefresh);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
setSuccess(null);
const namePayload = buildTranslationPayload(nameCs, nameEn);
if (Object.keys(namePayload).length === 0) {
setError("Vyplň alespoň jeden překlad názvu závodu.");
return;
}
const descriptionPayload = buildTranslationPayload(descriptionCs, descriptionEn);
const payload: Record<string, unknown> = {
name: namePayload,
is_mcr: isMcr,
is_sixhr: isSixHr,
is_active: isActive,
};
if (Object.keys(descriptionPayload).length > 0) {
payload.description = descriptionPayload;
}
if (url.trim()) payload.url = url.trim();
if (evaluator.trim()) payload.evaluator = evaluator.trim();
if (email.trim()) payload.email = email.trim();
if (email2.trim()) payload.email2 = email2.trim();
const normalizedStartTime = normalizeStartTime(startTime);
if (normalizedStartTime) payload.start_time = normalizedStartTime;
const durationNumber = durationHours.trim() ? Number(durationHours) : undefined;
const deadlineNumber = deadlineDays.trim() ? Number(deadlineDays) : undefined;
if (durationNumber !== undefined && Number.isNaN(durationNumber)) {
setError(t("Délka závodu musí být číslo."));
return;
}
if (deadlineNumber !== undefined && Number.isNaN(deadlineNumber)) {
setError(t("Uzávěrka logů musí být číslo."));
return;
}
if (durationNumber !== undefined) payload.duration = durationNumber;
if (deadlineNumber !== undefined) payload.logs_deadline_days = deadlineNumber;
payload.band_ids = selectedBandIds;
payload.category_ids = selectedCategoryIds;
payload.power_category_ids = selectedPowerCategoryIds;
if (selectedRuleSetId) payload.rule_set_id = selectedRuleSetId;
try {
setSubmitting(true);
await axios.get("/sanctum/csrf-cookie", { withCredentials: true });
let response;
if (isEdit && contest) {
response = await axios.put(`/api/contests/${contest.id}`, payload, {
headers: {
Accept: "application/json",
},
withCredentials: true,
withXSRFToken: true,
});
setSuccess("Závod byl upraven.");
triggerRefresh();
onUpdated?.(response.data);
} else {
response = await axios.post("/api/contests", payload, {
headers: {
Accept: "application/json",
},
withCredentials: true,
withXSRFToken: true,
});
setSuccess("Závod byl vytvořen.");
triggerRefresh();
onCreated?.(response.data);
}
} catch (err) {
if (axios.isAxiosError(err)) {
const apiErrors =
err.response?.data?.errors ??
err.response?.data?.message ??
"Nepodařilo se uložit závod.";
setError(
typeof apiErrors === "string"
? apiErrors
: "Nepodařilo se uložit závod."
);
} else {
setError("Nepodařilo se uložit závod.");
}
} finally {
setSubmitting(false);
}
};
const toggleId = (id: number, list: number[], setter: (v: number[]) => void) => {
setter(list.includes(id) ? list.filter((x) => x !== id) : [...list, id]);
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<Input
label="Název (cs)"
placeholder="VKV závod"
value={nameCs}
onValueChange={setNameCs}
isRequired
/>
<Input
label="Name (en)"
placeholder="VHF contest"
value={nameEn}
onValueChange={setNameEn}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Textarea
label="Popis (cs)"
placeholder="Stručný popis závodu…"
minRows={2}
value={descriptionCs}
onValueChange={setDescriptionCs}
/>
<Textarea
label="Description (en)"
placeholder="Short contest description…"
minRows={2}
value={descriptionEn}
onValueChange={setDescriptionEn}
/>
</div>
<div className="grid gap-4 md:grid-cols-4">
<Input
label="URL závodu"
type="url"
placeholder="https://example.com"
value={url}
onValueChange={setUrl}
/>
<Input
label="Vyhodnocovatel"
placeholder="ČRK"
value={evaluator}
onValueChange={setEvaluator}
/>
<Input
label="Email"
type="email"
placeholder="kontakt@domena.cz"
value={email}
onValueChange={setEmail}
/>
<Input
label="Email 2"
type="email"
placeholder="druhy_kontakt@domena.cz"
value={email2}
onValueChange={setEmail2}
/>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Input
label="Start závodu (HH:MM)"
type="time"
value={startTime}
onValueChange={setStartTime}
/>
<Input
label="Délka závodu (hodiny)"
type="number"
min={1}
value={durationHours}
onValueChange={setDurationHours}
/>
<Input
label="Uzávěrka logů (dny)"
type="number"
min={0}
value={deadlineDays}
onValueChange={setDeadlineDays}
/>
</div>
<div className="flex flex-wrap gap-6">
<Switch isSelected={isActive} onValueChange={setIsActive}>
Závod je aktivní
</Switch>
<Switch isSelected={isMcr} onValueChange={setIsMcr}>
MČR
</Switch>
<Switch isSelected={isSixHr} onValueChange={setIsSixHr}>
6H závod
</Switch>
</div>
<div className="grid gap-6 md:grid-cols-3">
<div>
<p className="text-sm font-semibold mb-2">Pásma</p>
<div className="space-y-2">
{availableBands.map((band) => (
<label key={band.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={selectedBandIds.includes(band.id)}
onChange={() => toggleId(band.id, selectedBandIds, setSelectedBandIds)}
disabled={loadingOptions || submitting}
/>
<span>{band.name}</span>
</label>
))}
</div>
</div>
<div>
<p className="text-sm font-semibold mb-2">Kategorie</p>
<div className="space-y-2">
{availableCategories.map((cat) => (
<label key={cat.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={selectedCategoryIds.includes(cat.id)}
onChange={() => toggleId(cat.id, selectedCategoryIds, setSelectedCategoryIds)}
disabled={loadingOptions || submitting}
/>
<span>{cat.name}</span>
</label>
))}
</div>
</div>
<div>
<p className="text-sm font-semibold mb-2">Výkonové kategorie</p>
<div className="space-y-2">
{availablePowerCategories.map((p) => (
<label key={p.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={selectedPowerCategoryIds.includes(p.id)}
onChange={() => toggleId(p.id, selectedPowerCategoryIds, setSelectedPowerCategoryIds)}
disabled={loadingOptions || submitting}
/>
<span>{p.name}</span>
</label>
))}
</div>
</div>
<div>
<p className="text-sm font-semibold mb-2">Ruleset</p>
<select
value={selectedRuleSetId ?? ""}
onChange={(e) => setSelectedRuleSetId(e.target.value ? Number(e.target.value) : null)}
disabled={loadingOptions || submitting}
className="w-full border rounded px-3 py-2 text-sm bg-white dark:bg-gray-900"
>
<option value="">--</option>
{availableRuleSets.map((ruleSet) => (
<option key={ruleSet.id} value={ruleSet.id}>
{ruleSet.name}
</option>
))}
</select>
</div>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
{success && <p className="text-sm text-green-600">{success}</p>}
<div className="flex gap-3">
<Button
type="submit"
color="primary"
isLoading={submitting}
isDisabled={submitting}
>
{isEdit ? "Uložit změny" : "Vytvořit závod"}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,122 @@
import { useEffect, useState } from "react";
import axios from "axios";
import { Card, CardBody, CardHeader, Divider } from "@heroui/react";
import { useLanguageStore } from "@/stores/languageStore";
import { type ContestSummary } from "@/stores/contestStore";
type ContestDetailProps = {
contest?: ContestSummary | null;
};
type ContestDetailData = ContestSummary & {
description?: string | null;
evaluator?: string | null;
email?: string | null;
email2?: string | null;
url?: string | null;
rule_set_id?: number | null;
rule_set?: { id: number; name: string } | null;
bands?: { id: number; name: string }[];
categories?: { id: number; name: string }[];
power_categories?: { id: number; name: string }[];
};
export default function ContestDetail({ contest }: ContestDetailProps) {
const locale = useLanguageStore((s) => s.locale);
const [detail, setDetail] = useState<ContestDetailData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!contest) {
setDetail(null);
return;
}
// pokud už máme detailní data, použij je a nefetchuj
const hasDetailFields =
"evaluator" in contest ||
"bands" in contest ||
"categories" in contest ||
"power_categories" in contest ||
"rule_set" in contest ||
"url" in contest;
if (hasDetailFields) {
setDetail(contest as ContestDetailData);
setLoading(false);
setError(null);
return;
}
let active = true;
(async () => {
try {
setLoading(true);
setError(null);
const res = await axios.get<ContestDetailData>(`/api/contests/${contest.id}`, {
headers: { Accept: "application/json" },
params: { lang: locale },
withCredentials: true,
});
if (!active) return;
setDetail(res.data);
} catch {
if (!active) return;
setError("Nepodařilo se načíst detail závodu.");
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [contest, locale]);
return (
<Card>
<CardHeader>
<div className="flex flex-col">
<span className="text-lg font-semibold">
{detail?.name ?? contest?.name ?? "Vyber závod"}
</span>
<span className="text-sm text-foreground-500">
{detail?.description ?? ""}
</span>
</div>
</CardHeader>
<Divider />
<CardBody>
{error && <p className="text-sm text-red-600">{error}</p>}
{loading && <p className="text-sm text-foreground-500">Načítám detail</p>}
{!contest && !loading && <p className="text-sm">Vyber závod vlevo.</p>}
{detail && !loading && (
<div className="grid gap-2 text-sm">
{detail.url && (
<div className="flex gap-2">
<span className="font-semibold">URL:</span>
<a
href={detail.url}
className="text-primary underline"
target="_blank"
rel="noreferrer"
>
{detail.url}
</a>
</div>
)}
{(detail.rule_set || detail.rule_set_id) && (
<div className="flex gap-2">
<span className="font-semibold">Ruleset:</span>
<span>{detail.rule_set?.name ?? `#${detail.rule_set_id}`}</span>
</div>
)}
</div>
)}
</CardBody>
</Card>
);
}

View File

@@ -0,0 +1,130 @@
import { useEffect, useMemo, useState } from "react";
import axios from "axios";
import { Listbox, ListboxItem, type Selection } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { useLanguageStore } from "@/stores/languageStore";
import { useContestStore, type ContestSummary } from "@/stores/contestStore";
import { useContestRefreshStore } from "@/stores/contestRefreshStore";
type ContestItem = ContestSummary & {
description?: string | null;
};
type PaginatedResponse<T> = {
data: T[];
};
type ContestsListBoxProps = {
/** Zobraz pouze aktivní závody (is_active === true). Default: false */
onlyActive?: boolean;
};
export default function ContestsListBox({ onlyActive = false }: ContestsListBoxProps) {
const { t } = useTranslation("common");
const locale = useLanguageStore((s) => s.locale);
const refreshKey = useContestRefreshStore((s) => s.refreshKey);
const [items, setItems] = useState<ContestItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
const setSelectedContest = useContestStore((s) => s.setSelectedContest);
const selectedContest = useContestStore((s) => s.selectedContest);
// předvyplň výběr podle storu
useEffect(() => {
if (selectedContest) {
setSelectedKeys(new Set([String(selectedContest.id)]));
} else {
setSelectedKeys(new Set([]));
}
}, [selectedContest]);
useEffect(() => {
let active = true;
(async () => {
try {
setLoading(true);
setError(null);
const res = await axios.get<PaginatedResponse<ContestItem> | ContestItem[]>(
"/api/contests",
{
withCredentials: true,
headers: { Accept: "application/json" },
params: { lang: locale },
}
);
if (!active) return;
const data = Array.isArray(res.data)
? res.data
: (res.data as PaginatedResponse<ContestItem>).data;
setItems(data);
} catch {
if (!active) return;
setError(t("unable_to_load_contests") ?? "Nepodařilo se načíst seznam závodů.");
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [locale, t, refreshKey]);
const visibleItems = useMemo(
() => (onlyActive ? items.filter((c) => c.is_active) : items),
[items, onlyActive]
);
const handleSelectionChange = (keys: Selection) => {
setSelectedKeys(keys);
if (keys === "all") return;
const id = Array.from(keys)[0];
if (!id) {
setSelectedContest(null);
return;
}
const selected = visibleItems.find((c) => String(c.id) === String(id));
if (selected) {
setSelectedContest(selected);
} else {
setSelectedContest(null);
}
};
if (loading) return <div>{t("contests_loading") ?? "Načítám závody…"}</div>;
if (error) return <div className="text-sm text-red-600">{error}</div>;
if (visibleItems.length === 0) {
return <div>{t("contests_empty") ?? "Žádné závody nejsou k dispozici."}</div>;
}
return (
<Listbox
aria-label="Contests list"
selectionMode="single"
selectedKeys={selectedKeys}
onSelectionChange={handleSelectionChange}
variant="flat"
>
{visibleItems.map((item) => (
<ListboxItem key={item.id} textValue={item.name}>
<div className="flex flex-col">
<span className="font-semibold text-sm">{item.name}</span>
<span className="text-xs text-foreground-500 line-clamp-2">
{item.description || "—"}
</span>
</div>
</ListboxItem>
))}
</Listbox>
);
}

View File

@@ -0,0 +1,180 @@
import { useEffect, useMemo, useState, useRef } from "react";
import axios from "axios";
import { Card, CardBody, Listbox, ListboxItem, type Selection } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { useLanguageStore } from "@/stores/languageStore";
import { useContestStore, type ContestSummary } from "@/stores/contestStore";
import { useContestRefreshStore } from "@/stores/contestRefreshStore";
import { useNavigate } from "react-router-dom";
import RoundsOverview from "./RoundsOverview";
type ContestItem = ContestSummary & {
description?: string | null;
};
type PaginatedResponse<T> = {
data: T[];
};
type ContestsOverviewProps = {
/** Zobraz pouze aktivní závody. Default: false */
onlyActive?: boolean;
/** Zahrnout testovací závody. Default: false */
showTests?: boolean;
className?: string;
};
export default function ContestsOverview({ onlyActive = false, showTests = false, className }: ContestsOverviewProps) {
const { t } = useTranslation("common");
const locale = useLanguageStore((s) => s.locale);
const refreshKey = useContestRefreshStore((s) => s.refreshKey);
const navigate = useNavigate();
const [items, setItems] = useState<ContestItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set(["none"]));
const lastFetchKey = useRef<string | null>(null);
const setSelectedContest = useContestStore((s) => s.setSelectedContest);
const clearSelection = useContestStore((s) => s.clearSelection);
const selectedContest = useContestStore((s) => s.selectedContest);
// sync se store
useEffect(() => {
if (selectedContest) {
setSelectedKeys(new Set([String(selectedContest.id)]));
} else {
setSelectedKeys(new Set(["none"]));
}
}, [selectedContest]);
useEffect(() => {
let active = true;
const fetchKey = `${locale}-${refreshKey}`;
// pokud už máme data pro tento klíč a seznam není prázdný, nefetchuj
if (lastFetchKey.current === fetchKey && items.length > 0) {
setLoading(false);
return;
}
lastFetchKey.current = fetchKey;
(async () => {
try {
setLoading(true);
setError(null);
const res = await axios.get<PaginatedResponse<ContestItem> | ContestItem[]>(
"/api/contests",
{
withCredentials: true,
headers: { Accept: "application/json" },
params: {
lang: locale,
only_active: onlyActive ? 1 : 0,
include_tests: showTests ? 1 : 0,
},
}
);
if (!active) return;
const data = Array.isArray(res.data)
? res.data
: (res.data as PaginatedResponse<ContestItem>).data;
setItems(data);
} catch {
if (!active) return;
setError(t("unable_to_load_contests") ?? "Nepodařilo se načíst seznam závodů.");
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [locale, t, refreshKey, items.length, onlyActive, showTests]);
const visibleItems = useMemo(() => items, [items]);
const isSelected = (id: string | number) => {
if (selectedKeys === "all") return false;
return Array.from(selectedKeys).some((k) => String(k) === String(id));
};
const handleSelectionChange = (keys: Selection) => {
setSelectedKeys(keys);
if (keys === "all") return;
const id = Array.from(keys)[0];
if (!id || id === "none") {
clearSelection();
setSelectedKeys(new Set(["none"]));
navigate("/contests");
return;
}
if (selectedContest && String(selectedContest.id) === String(id)) {
return;
}
const selected = visibleItems.find((c) => String(c.id) === String(id));
if (selected) {
setSelectedContest(selected);
navigate(`/contests/${selected.id}`);
}
};
return (
<Card className={className}>
<CardBody className="p-0">
{loading ? (
<div className="p-4">{t("contests_loading") ?? "Načítám závody…"}</div>
) : error ? (
<div className="p-4 text-sm text-red-600">{error}</div>
) : visibleItems.length === 0 ? (
<div className="p-4">{t("contests_empty") ?? "Žádné závody nejsou k dispozici."}</div>
) : (
<Listbox
aria-label="Contests overview"
selectionMode="single"
selectedKeys={selectedKeys}
onSelectionChange={handleSelectionChange}
variant="flat"
color="primary"
shouldHighlightOnFocus={true}
>
<ListboxItem key="none" textValue="None">
<div className="flex flex-col">
<span className="font-semibold text-sm">
{t("contest_index_page") ?? "Přehled závodů"}
</span>
</div>
</ListboxItem>
{visibleItems.map((item) => (
<ListboxItem key={item.id} textValue={item.name}>
<div className="flex flex-col">
<span
className={`text-sm ${isSelected(item.id) ? "font-semibold text-primary" : "font-medium"}`}
>
{item.name}
</span>
<span
className={`text-xs line-clamp-2 ${
isSelected(item.id) ? "text-primary-500" : "text-foreground-500"
}`}
>
{item.description || "—"}
</span>
</div>
</ListboxItem>
))}
</Listbox>
)}
</CardBody>
</Card>
);
}

View File

@@ -0,0 +1,158 @@
import { useEffect, useMemo, useState } from "react";
import axios from "axios";
import { Select, SelectItem, type Selection } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { useLanguageStore } from "@/stores/languageStore";
import { useContestStore, type ContestSummary } from "@/stores/contestStore";
import { useContestRefreshStore } from "@/stores/contestRefreshStore";
import { useNavigate } from "react-router-dom";
type ContestItem = ContestSummary & {
description?: string | null;
bands?: { id: number; name: string }[];
};
type PaginatedResponse<T> = {
data: T[];
};
type ContestsSelectBoxProps = {
/** Zobraz pouze aktivní závody (is_active === true). Default: false */
onlyActive?: boolean;
/** Label pro Select, volitelné */
label?: string;
/** Placeholder text */
placeholder?: string;
};
export default function ContestsSelectBox({
onlyActive = false,
label,
placeholder,
}: ContestsSelectBoxProps) {
const { t } = useTranslation("common");
const locale = useLanguageStore((s) => s.locale);
const refreshKey = useContestRefreshStore((s) => s.refreshKey);
const navigate = useNavigate();
const [items, setItems] = useState<ContestItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set(["none"]));
const setSelectedContest = useContestStore((s) => s.setSelectedContest);
const selectedContest = useContestStore((s) => s.selectedContest);
// předvyplň výběr podle storu
useEffect(() => {
if (selectedContest) {
setSelectedKeys(new Set([String(selectedContest.id)]));
} else {
setSelectedKeys(new Set(["none"]));
}
}, [selectedContest]);
useEffect(() => {
let active = true;
(async () => {
try {
setLoading(true);
setError(null);
const res = await axios.get<PaginatedResponse<ContestItem> | ContestItem[]>(
"/api/contests",
{
withCredentials: true,
headers: { Accept: "application/json" },
params: { lang: locale },
}
);
if (!active) return;
const data = Array.isArray(res.data)
? res.data
: (res.data as PaginatedResponse<ContestItem>).data;
setItems(data);
} catch {
if (!active) return;
setError(t("unable_to_load_contests") ?? "Nepodařilo se načíst seznam závodů.");
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [locale, t, refreshKey]);
const visibleItems = useMemo(
() => (onlyActive ? items.filter((c) => c.is_active) : items),
[items, onlyActive]
);
const handleSelectionChange = (keys: Selection) => {
setSelectedKeys(keys);
if (keys === "all") return;
const id = Array.from(keys)[0];
if (!id || id === "none") {
if (selectedContest) {
setSelectedContest(null);
navigate("/contests");
}
return;
}
if (selectedContest && String(selectedContest.id) === String(id)) {
return;
}
const selected = visibleItems.find((c) => String(c.id) === String(id));
if (selected) {
setSelectedContest(selected);
navigate(`/contests/${selected.id}`);
}
};
if (loading) return <div>{t("contests_loading") ?? "Načítám závody…"}</div>;
if (error) return <div className="text-sm text-red-600">{error}</div>;
if (visibleItems.length === 0) {
return <div>{t("contests_empty") ?? "Žádné závody nejsou k dispozici."}</div>;
}
return (
<Select
aria-label="Contests select"
label={label ?? t("contest_name") ?? "Závod"}
placeholder={placeholder ?? t("select_contest") ?? "Vyber závod"}
selectedKeys={selectedKeys}
onSelectionChange={handleSelectionChange}
color="primary"
isMultiline={true}
>
<SelectItem key="none" textValue="None">
{t("select_none") ?? "Žádný závod"}
</SelectItem>
{visibleItems.map((item) => (
<SelectItem
key={item.id}
textValue={item.name}
description={
<>
<div>{item.description || "—"}</div>
<div className="text-[11px] text-foreground-500">
{(item.bands ?? []).map((b) => b.name).join(", ") || "—"}
</div>
</>
}
>
{item.name}
</SelectItem>
))}
</Select>
);
}

View File

@@ -0,0 +1,261 @@
import { useEffect, useState } from "react";
import axios from "axios";
import { useTranslation } from "react-i18next";
import { useLanguageStore } from "@/stores/languageStore";
import { useContestRefreshStore } from "@/stores/contestRefreshStore";
import {
Table,
TableHeader,
TableColumn,
TableBody,
TableRow,
TableCell,
type Selection,
} from "@heroui/react";
export type Contest = {
id: number;
name: string;
description?: string | null;
bands?: { id: number; name: string }[];
categories?: { id: number; name: string }[];
power_categories?: { id: number; name: string }[];
evaluator?: string | null;
email?: string | null;
email2?: string | null;
is_mcr: boolean;
is_sixhr: boolean;
is_active: boolean;
is_test: boolean;
start_time: string | null; // "HH:MM:SS"
duration: number; // hodiny
logs_deadline_days: number;
};
type PaginatedResponse<T> = {
data: T[];
};
type ContestsTableProps = {
/** Handler vybraného řádku (klik/označení). */
onRowSelect?: (contest: Contest) => void;
/** Handler pro klik na ikonu editace (tužka). */
onEditContest?: (contest: Contest) => void;
/** Show/hide the edit (pencil) actions column. Default: true */
enableEdit?: boolean;
/** Při kliknutí/označení řádku zavolat onSelectContest. Default: false */
selectOnRowClick?: boolean;
/** Filter the list to only active contests (is_active === true). Default: false */
onlyActive?: boolean;
/** Show/hide the is_active column. Default: true */
showActiveColumn?: boolean;
/** Zahrnout testovací závody. Default: false */
showTests?: boolean;
};
export default function ContestsTable({
onRowSelect,
onEditContest,
enableEdit = true,
onlyActive = false,
showActiveColumn = true,
selectOnRowClick = false,
showTests = false,
}: ContestsTableProps) {
const { t } = useTranslation("common");
const locale = useLanguageStore((s) => s.locale);
const refreshKey = useContestRefreshStore((s) => s.refreshKey);
const [items, setItems] = useState<Contest[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
useEffect(() => {
let active = true;
(async () => {
try {
setLoading(true);
setError(null);
const res = await axios.get<PaginatedResponse<Contest> | Contest[]>(
"/api/contests",
{
withCredentials: true,
headers: { Accept: "application/json" },
params: {
lang: locale,
include_tests: showTests ? 1 : 0,
only_active: onlyActive ? 1 : 0,
},
}
);
if (!active) return;
const data = Array.isArray(res.data)
? res.data
: (res.data as PaginatedResponse<Contest>).data;
setItems(data);
} catch {
if (!active) return;
setError(t("unable_to_load_contests") ?? "Nepodařilo se načíst seznam závodů.");
} finally {
if (active) setLoading(false);
}
})();
return () => { active = false; };
}, [locale, t, refreshKey, showTests, onlyActive]);
const handleEdit = onEditContest ?? onRowSelect;
const handleRowSelect = onRowSelect;
const canEdit = Boolean(enableEdit && handleEdit);
const visibleItems = onlyActive ? items.filter((c) => c.is_active) : items;
if (loading) return <div>{t("contests_loading") ?? "Načítám závody…"}</div>;
if (error) return <div className="text-sm text-red-600">{error}</div>;
if (visibleItems.length === 0) {
return (
<div>
{t("contests_empty") ?? "Žádné závody nejsou k dispozici."}
</div>
);
}
const columns = [
{ key: "name", label: t("contest_name") },
{ key: "description", label: t("contest_description") },
{ key: "bands", label: t("bands") ?? "Pásma" },
{ key: "categories", label: t("categories") ?? "Kategorie" },
{ key: "power_categories", label: t("power_categories") ?? "Výkonové kategorie" },
...(showActiveColumn ? [{ key: "is_active", label: t("contest_active") }] : []),
...(canEdit ? [{ key: "actions", label: "" }] : []),
];
const handleSelectionChange = (keys: Selection) => {
setSelectedKeys(keys);
if (!selectOnRowClick || !handleRowSelect) return;
if (keys === "all") return;
const id = Array.from(keys)[0];
if (!id) return;
const selected = visibleItems.find((c) => String(c.id) === String(id));
if (selected) handleRowSelect(selected);
};
return (
<Table
aria-label="Contests table"
selectionMode="single"
selectedKeys={selectedKeys}
onSelectionChange={handleSelectionChange}
>
<TableHeader columns={columns}>
{(column) => (
<TableColumn key={column.key} className="text-left">
{column.label}
</TableColumn>
)}
</TableHeader>
<TableBody items={visibleItems}>
{(item) => (
<TableRow key={item.id}>
{(columnKey) => {
switch (columnKey) {
case "name":
return (
<TableCell>
<span className="font-medium">{item.name}</span>
</TableCell>
);
case "description":
return (
<TableCell>
<span className="text-xs text-foreground-500 line-clamp-2">
{item.description || "—"}
</span>
</TableCell>
);
case "bands": {
const names = item.bands?.map((b) => b.name).join(", ");
return (
<TableCell>
<span className="text-xs text-foreground-500 line-clamp-2">
{names || "—"}
</span>
</TableCell>
);
}
case "categories": {
const names = item.categories?.map((c) => c.name).join(", ");
return (
<TableCell>
<span className="text-xs text-foreground-500 line-clamp-2">
{names || "—"}
</span>
</TableCell>
);
}
case "power_categories": {
const names = item.power_categories?.map((p) => p.name).join(", ");
return (
<TableCell>
<span className="text-xs text-foreground-500 line-clamp-2">
{names || "—"}
</span>
</TableCell>
);
}
case "is_active":
return (
<TableCell>
{item.is_active
? t("yes") ?? "Ano"
: t("no") ?? "Ne"}
</TableCell>
);
case "actions":
return (
<TableCell>
{canEdit && (
<button
type="button"
onClick={() => handleEdit?.(item)}
className="inline-flex items-center p-1 rounded hover:bg-default-100 text-default-500 hover:text-default-700"
aria-label={t("edit_contest") ?? "Edit contest"}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path d="M13.586 3.586a2 2 0 0 1 2.828 2.828l-8.25 8.25a2 2 0 0 1-.878.518l-3.122.89a.75.75 0 0 1-.926-.926l.89-3.122a2 2 0 0 1 .518-.878l8.25-8.25Z" />
<path d="M12.475 4.697 4.75 12.422l-.69 2.422 2.422-.69 7.725-7.725-1.732-1.732Z" />
</svg>
</button>
)}
</TableCell>
);
default:
return <TableCell />;
}
}}
</TableRow>
)}
</TableBody>
</Table>
);
}

View File

@@ -0,0 +1,50 @@
import { Button } from "@heroui/react";
type EvaluationActionsProps = {
canStart: boolean;
canStartIncremental: boolean;
canResume: boolean;
canCancel: boolean;
actionLoading: boolean;
message: string | null;
error: string | null;
onStart: () => void;
onStartIncremental: () => void;
onResume: () => void;
onCancel: () => void;
};
export default function EvaluationActions({
canStart,
canStartIncremental,
canResume,
canCancel,
actionLoading,
message,
error,
onStart,
onStartIncremental,
onResume,
onCancel,
}: EvaluationActionsProps) {
return (
<>
{message && <div className="text-sm text-green-600">{message}</div>}
{error && <div className="text-sm text-red-600">{error}</div>}
<div className="flex flex-wrap gap-2">
<Button size="sm" onPress={onStart} isDisabled={!canStart} isLoading={actionLoading}>
Spustit vyhodnocení
</Button>
<Button size="sm" variant="flat" onPress={onStartIncremental} isDisabled={!canStartIncremental} isLoading={actionLoading}>
Spustit znovu
</Button>
<Button size="sm" variant="flat" onPress={onResume} isDisabled={!canResume} isLoading={actionLoading}>
Pokračovat
</Button>
<Button size="sm" color="danger" variant="flat" onPress={onCancel} isDisabled={!canCancel} isLoading={actionLoading}>
Zrušit
</Button>
</div>
</>
);
}

View File

@@ -0,0 +1,32 @@
import { type EvaluationRunEvent } from "@/hooks/useRoundEvaluationRun";
type EvaluationEventsListProps = {
events: EvaluationRunEvent[];
formatEventTime: (value?: string | null) => string | null;
};
export default function EvaluationEventsList({
events,
formatEventTime,
}: EvaluationEventsListProps) {
if (events.length === 0) return null;
return (
<div className="text-sm text-foreground-700">
<div className="font-semibold mb-1">Poslední události</div>
<div className="space-y-1">
{events.map((event) => (
<div key={event.id} className="flex flex-col">
<span>
[{event.level}] {event.message}
</span>
<span className="text-xs text-foreground-500">
{formatEventTime(event.created_at) ?? "—"}
{event.context?.step ? ` • krok: ${String(event.context.step)}` : ""}
</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { type EvaluationRun } from "@/hooks/useRoundEvaluationRun";
type EvaluationHistoryPanelProps = {
runs: EvaluationRun[];
historyOpen: boolean;
onToggle: () => void;
formatEventTime: (value?: string | null) => string | null;
};
export default function EvaluationHistoryPanel({
runs,
historyOpen,
onToggle,
formatEventTime,
}: EvaluationHistoryPanelProps) {
return (
<div className="space-y-2 text-sm text-foreground-700">
<button
type="button"
className="flex w-full items-center justify-between text-left font-semibold"
onClick={onToggle}
>
<span>Historie vyhodnocování</span>
<span>{historyOpen ? "" : "+"}</span>
</button>
{historyOpen && (
<>
{runs.length === 0 && <div className="text-foreground-600">Zatím bez historie.</div>}
{runs.length > 0 && (
<div className="space-y-2">
{runs.map((item) => (
<div key={item.id} className="rounded border border-divider p-2">
<div className="font-semibold">Run #{item.id}</div>
<div>Stav: {item.status ?? "—"}</div>
{item.result_type && <div>Výsledek: {item.result_type}</div>}
<div>Start: {formatEventTime(item.started_at ?? item.created_at) ?? "—"}</div>
<div>Konec: {formatEventTime(item.finished_at) ?? "—"}</div>
</div>
))}
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,63 @@
import { type EvaluationRun } from "@/hooks/useRoundEvaluationRun";
type EvaluationStatusSummaryProps = {
loading: boolean;
hasLoaded: boolean;
run: EvaluationRun | null;
isOfficialRun: boolean;
stepProgressPercent: number | null;
};
export default function EvaluationStatusSummary({
loading,
hasLoaded,
run,
isOfficialRun,
stepProgressPercent,
}: EvaluationStatusSummaryProps) {
if (loading && !hasLoaded) {
return <div className="text-sm text-foreground-600">Načítám stav</div>;
}
if (!loading && !run && hasLoaded) {
return <div className="text-sm text-foreground-600">Vyhodnocení zatím nebylo spuštěno.</div>;
}
if (!loading && run && !isOfficialRun) {
return <div className="text-sm text-foreground-600">Žádné oficiální vyhodnocení zatím neběží.</div>;
}
if (!run || !isOfficialRun) {
return null;
}
return (
<div className="text-sm text-foreground-700 space-y-1">
<div>
<span className="font-semibold">Stav:</span> {run.status ?? "—"}
</div>
<div>
<span className="font-semibold">Krok:</span> {run.current_step ?? "—"}
</div>
<div>
<span className="font-semibold">Typ:</span> {run.rules_version ?? "—"}
</div>
{run.result_type && (
<div>
<span className="font-semibold">Výsledek:</span> {run.result_type}
</div>
)}
{run.progress_total !== null && run.progress_total !== undefined && (
<div className="space-y-1 pt-2">
<div className="text-xs text-foreground-500">
{run.progress_done ?? 0}/{run.progress_total}{" "}
{stepProgressPercent !== null ? `(${stepProgressPercent}%)` : ""}
</div>
<div className="h-2 w-full rounded bg-divider">
<div
className="h-2 rounded bg-primary"
style={{ width: `${stepProgressPercent ?? 0}%` }}
/>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { type EvaluationRun, steps } from "@/hooks/useRoundEvaluationRun";
type EvaluationStepsListProps = {
run: EvaluationRun | null;
isOfficialRun: boolean;
currentStepIndex: number;
isSucceeded: boolean;
};
export default function EvaluationStepsList({
run,
isOfficialRun,
currentStepIndex,
isSucceeded,
}: EvaluationStepsListProps) {
if (!run || !isOfficialRun) return null;
return (
<div className="pt-2">
<div className="font-semibold mb-1">Prubeh</div>
<div className="space-y-1">
{steps.map((step, index) => {
const isCurrent = !isSucceeded && index === currentStepIndex;
const isDone = isSucceeded || (currentStepIndex > -1 && index < currentStepIndex);
const tone = isCurrent
? "text-foreground"
: isDone
? "text-foreground-600"
: "text-foreground-400";
return (
<div key={step.key} className={`flex items-center gap-2 ${tone}`}>
<span>{isDone ? "●" : isCurrent ? "○" : "·"}</span>
<span>{step.label}</span>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import React from "react";
type FileDropZoneProps = {
disabled?: boolean;
multiple?: boolean;
selectedFiles: FileList | null;
label: string;
hint: string;
onFiles: (files: FileList | null) => void;
};
export default function FileDropZone({ disabled = false, multiple = false, selectedFiles, label, hint, onFiles }: FileDropZoneProps) {
const [isDragOver, setIsDragOver] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const fileCount = selectedFiles?.length ?? 0;
const fileNames = selectedFiles ? Array.from(selectedFiles).map((file) => file.name) : [];
const selectedLabel =
fileCount > 1 ? `${fileCount} souborů vybráno` : selectedFiles && fileCount === 1 ? selectedFiles[0]?.name : label;
const selectedHint =
fileCount > 1 ? `Vybráno: ${fileNames.slice(0, 3).join(", ")}${fileCount > 3 ? ", ..." : ""}` : hint;
return (
<>
<input
ref={fileInputRef}
type="file"
multiple={multiple}
onChange={(e) => onFiles(e.target.files)}
aria-label={label}
className="sr-only"
disabled={disabled}
/>
<div
onClick={() => !disabled && fileInputRef.current?.click()}
onDragOver={(e) => {
if (disabled) return;
e.preventDefault();
setIsDragOver(true);
}}
onDragLeave={(e) => {
if (disabled) return;
e.preventDefault();
setIsDragOver(false);
}}
onDrop={(e) => {
if (disabled) return;
e.preventDefault();
setIsDragOver(false);
onFiles(e.dataTransfer.files);
}}
className={`flex-1 min-w-[240px] cursor-pointer rounded border-2 border-dashed p-4 ${
isDragOver ? "border-primary bg-green-50" : "border-default-300 bg-green-50"
} ${disabled ? "opacity-60 cursor-not-allowed" : ""}`}
>
<div className="flex flex-col items-start gap-1 text-sm">
<span className="font-semibold">{selectedLabel}</span>
<span className="text-foreground-500">{selectedHint}</span>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,38 @@
import { ChangeEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useLanguageStore, type Locale } from '@/stores/languageStore';
const AVAILABLE_LOCALES: { code: Locale; label: string }[] = [
{ code: 'cs', label: 'Čeština' },
{ code: 'en', label: 'English' },
];
export default function LanguageSwitcher() {
const { i18n } = useTranslation();
const locale = useLanguageStore((s) => s.locale);
const setLocale = useLanguageStore((s) => s.setLocale);
const handleChange = async (event: ChangeEvent<HTMLSelectElement>) => {
const newLocale = event.target.value as Locale;
// 1) přepni i18next
await i18n.changeLanguage(newLocale);
// 2) aktualizuj globální store (ten nastaví <html lang> + cookie)
setLocale(newLocale);
};
return (
<select
value={locale}
onChange={handleChange}
className="border rounded px-2 py-1 text-sm bg-white dark:bg-gray-900"
>
{AVAILABLE_LOCALES.map((l) => (
<option key={l.code} value={l.code}>
{l.label}
</option>
))}
</select>
);
}

View File

@@ -0,0 +1,621 @@
import { useEffect, useState } from "react";
import axios from "axios";
import {
Card,
CardHeader,
CardBody,
Divider,
Accordion,
AccordionItem,
} from "@heroui/react";
import { useTranslation } from "react-i18next";
import { useContestStore } from "@/stores/contestStore";
import { useLanguageStore } from "@/stores/languageStore";
import LogQsoTable from "@/components/LogQsoTable";
type LogDetailData = {
id: number;
round_id: number;
pcall?: string | null;
rcall?: string | null;
tname?: string | null;
tdate?: string | null;
pwwlo?: string | null;
pexch?: string | null;
psect?: string | null;
pband?: string | null;
pclub?: string | null;
locator?: string | null;
raw_header?: string | null;
remarks?: string | null;
remarks_eval?: string | null;
claimed_qso_count?: number | null;
claimed_score?: number | null;
claimed_wwl?: string | null;
claimed_dxcc?: string | null;
round?: {
id: number;
contest_id: number;
name: string;
start_time?: string | null;
end_time?: string | null;
logs_deadline?: string | null;
} | null;
padr1?: string | null;
padr2?: string | null;
radr1?: string | null;
radr2?: string | null;
rpoco?: string | null;
rcity?: string | null;
rphon?: string | null;
rhbbs?: string | null;
rname?: string | null;
rcoun?: string | null;
mope1?: string | null;
mope2?: string | null;
stxeq?: string | null;
srxeq?: string | null;
sante?: string | null;
santh?: string | null;
power_watt?: number | null;
rx_wwl?: string | null;
rx_exchange?: string | null;
mode_code?: string | null;
new_exchange?: boolean | null;
new_wwl?: boolean | null;
new_dxcc?: boolean | null;
duplicate_qso?: boolean | null;
qsos?: {
id: number;
qso_index?: number | null;
time_on?: string | null;
dx_call?: string | null;
my_rst?: string | null;
my_serial?: string | null;
dx_rst?: string | null;
dx_serial?: string | null;
rx_wwl?: string | null;
rx_exchange?: string | null;
mode_code?: string | null;
new_exchange?: boolean | null;
new_wwl?: boolean | null;
new_dxcc?: boolean | null;
duplicate_qso?: boolean | null;
points?: number | null;
remarks?: string | null;
}[];
};
function formatDateTime(value: string | null | undefined, locale: string): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(locale, {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(date);
}
type LogDetailProps = {
logId: number | null;
};
type LogResultItem = {
id: number;
log_id: number;
official_score?: number | null;
penalty_score?: number | null;
base_score?: number | null;
multiplier_count?: number | null;
valid_qso_count?: number | null;
dupe_qso_count?: number | null;
busted_qso_count?: number | null;
other_error_qso_count?: number | null;
};
type LogResultsResponse = {
data: LogResultItem[];
};
type QsoResultItem = {
log_qso_id: number;
points?: number | null;
penalty_points?: number | null;
error_code?: string | null;
error_side?: string | null;
match_confidence?: string | null;
match_type?: string | null;
error_flags?: string[] | null;
is_valid?: boolean | null;
is_duplicate?: boolean | null;
is_nil?: boolean | null;
is_busted_call?: boolean | null;
is_busted_rst?: boolean | null;
is_busted_exchange?: boolean | null;
is_time_out_of_window?: boolean | null;
};
type LogOverrideItem = {
id: number;
log_id: number;
reason?: string | null;
};
type LogOverridesResponse = {
data: LogOverrideItem[];
};
type QsoOverrideItem = {
id: number;
log_qso_id: number;
forced_status?: string | null;
forced_matched_log_qso_id?: number | null;
forced_points?: number | null;
forced_penalty?: number | null;
reason?: string | null;
};
type LogQsoTableRow = {
id: number;
qso_index?: number | null;
time_on?: string | null;
dx_call?: string | null;
my_rst?: string | null;
my_serial?: string | null;
dx_rst?: string | null;
dx_serial?: string | null;
rx_wwl?: string | null;
rx_exchange?: string | null;
mode_code?: string | null;
new_exchange?: boolean | null;
new_wwl?: boolean | null;
new_dxcc?: boolean | null;
duplicate_qso?: boolean | null;
points?: number | null;
remarks?: string | null;
result?: QsoResultItem | null;
override?: QsoOverrideItem | null;
};
type QsoTableResponse = {
evaluation_run_id: number | null;
data: LogQsoTableRow[];
};
export default function LogDetail({ logId }: LogDetailProps) {
const { t } = useTranslation("common");
const locale = useLanguageStore((s) => s.locale) || navigator.language || "cs-CZ";
const setSelectedRound = useContestStore((s) => s.setSelectedRound);
const [detail, setDetail] = useState<LogDetailData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [officialResult, setOfficialResult] = useState<LogResultItem | null>(null);
const [officialQsoResults, setOfficialQsoResults] = useState<Record<number, QsoResultItem>>({});
const [officialLoading, setOfficialLoading] = useState(false);
const [officialError, setOfficialError] = useState<string | null>(null);
const [logOverrideReason, setLogOverrideReason] = useState<string | null>(null);
const [qsoOverrides, setQsoOverrides] = useState<Record<number, QsoOverrideItem>>({});
const [qsoTableRows, setQsoTableRows] = useState<LogQsoTableRow[]>([]);
useEffect(() => {
if (!logId) return;
let active = true;
(async () => {
try {
setLoading(true);
setError(null);
const res = await axios.get<LogDetailData>(`/api/logs/${logId}`, {
params: { include_qsos: 0 },
headers: { Accept: "application/json" },
withCredentials: true,
});
if (!active) return;
setDetail(res.data);
if (res.data.round) {
setSelectedRound({
id: res.data.round.id,
contest_id: res.data.round.contest_id,
name: res.data.round.name,
description: null,
is_active: true,
is_test: false,
is_sixhr: false,
start_time: res.data.round.start_time ?? null,
end_time: res.data.round.end_time ?? null,
logs_deadline: res.data.round.logs_deadline ?? null,
});
}
} catch {
if (!active) return;
setError(t("unable_to_load_log") ?? "Nepodařilo se načíst log.");
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [logId, t, setSelectedRound]);
useEffect(() => {
if (!detail?.id) return;
let active = true;
(async () => {
try {
setOfficialLoading(true);
setOfficialError(null);
const qsoTableRes = await axios.get<QsoTableResponse>(`/api/logs/${detail.id}/qso-table`, {
headers: { Accept: "application/json" },
withCredentials: true,
});
if (!active) return;
const rows = qsoTableRes.data.data ?? [];
setQsoTableRows(rows);
const qsoMap: Record<number, QsoResultItem> = {};
const overrideMap: Record<number, QsoOverrideItem> = {};
rows.forEach((row) => {
if (row.result) {
qsoMap[row.id] = row.result;
}
if (row.override) {
overrideMap[row.id] = row.override;
}
});
setOfficialQsoResults(qsoMap);
setQsoOverrides(overrideMap);
const effectiveRunId = qsoTableRes.data.evaluation_run_id ?? null;
if (!effectiveRunId) {
setOfficialResult(null);
setLogOverrideReason(null);
return;
}
const resultRes = await axios.get<LogResultsResponse>("/api/log-results", {
params: {
evaluation_run_id: effectiveRunId,
log_id: detail.id,
per_page: 1,
},
headers: { Accept: "application/json" },
withCredentials: true,
});
if (!active) return;
setOfficialResult(resultRes.data.data?.[0] ?? null);
const overrideRes = await axios.get<LogOverridesResponse>("/api/log-overrides", {
params: {
evaluation_run_id: effectiveRunId,
log_id: detail.id,
per_page: 1,
},
headers: { Accept: "application/json" },
withCredentials: true,
});
if (!active) return;
setLogOverrideReason(overrideRes.data.data?.[0]?.reason ?? null);
} catch (e: any) {
if (!active) return;
const msg = e?.response?.data?.message || "Nepodařilo se načíst zkontrolované výsledky.";
setOfficialError(msg);
setOfficialResult(null);
setOfficialQsoResults({});
setLogOverrideReason(null);
setQsoOverrides({});
setQsoTableRows([]);
} finally {
if (active) setOfficialLoading(false);
}
})();
return () => {
active = false;
};
}, [detail?.id]);
const title = (() => {
const pcall = detail?.pcall ?? "";
const rcall = detail?.rcall ?? "";
if (pcall && rcall && pcall !== rcall) {
return `${pcall}-${rcall}`;
}
return pcall || rcall || (t("log") ?? "Log");
})();
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 {
// fallback to raw string
}
return <div>{raw}</div>;
};
return (
<div className="space-y-4">
<Card>
<CardHeader>
<div className="flex flex-col">
<span className="text-lg font-semibold">{title}</span>
{detail?.tname && (
<span className="text-sm text-foreground-500">{detail.tname}</span>
)}
{detail?.tdate && (
<span className="text-sm text-foreground-500">{detail.tdate}</span>
)}
</div>
</CardHeader>
<Divider />
<CardBody>
{error && <div className="text-sm text-red-600">{error}</div>}
{loading && <div>{t("loading") ?? "Načítám..."}</div>}
{detail && !loading && (
<div className="space-y-4 text-sm">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_pcall_hint") ?? "Call used during contest"}>
PCall:
</span>
<span>
{detail.pcall || "—"}
{detail.pclub ? ` (${detail.pclub})` : ""}
</span>
</div>
{detail.pband && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_pband_hint") ?? "Band"}>PBand:</span>
<span>{detail.pband}</span>
</div>
)}
{detail.psect && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_psect_hint") ?? "Section / category"}>PSect:</span>
<span>{detail.psect}</span>
</div>
)}
{detail.padr1 && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_padr1_hint") ?? "Address line 1 (QTH)"}>PAdr1:</span>
<span>{detail.padr1}</span>
</div>
)}
{detail.padr2 && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_padr2_hint") ?? "Address line 2 (QTH)"}>PAdr2:</span>
<span>{detail.padr2}</span>
</div>
)}
{detail.mope1 && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_mope1_hint") ?? "Multi operator line 1"}>MOpe1:</span>
<span>{detail.mope1}</span>
</div>
)}
{detail.mope2 && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_mope2_hint") ?? "Multi operator line 2"}>MOpe2:</span>
<span>{detail.mope2}</span>
</div>
)}
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_rcall_hint") ?? "Responsible operator callsign"}>RCall:</span>
<span>{detail.rcall || "—"}</span>
</div>
{detail.radr1 && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_radr1_hint") ?? "Address line 1 of responsible operator"}>RAdr1:</span>
<span>{detail.radr1}</span>
</div>
)}
{detail.radr2 && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_radr2_hint") ?? "Address line 2 of responsible operator"}>RAdr2:</span>
<span>{detail.radr2}</span>
</div>
)}
{(detail.rpoco || detail.rcity) && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_rpoco_rcity_hint") ?? "Postal code / city of responsible operator"}>RPoCo/RCity:</span>
<span>
{detail.rpoco ?? ""} {detail.rcity ?? ""}
</span>
</div>
)}
{detail.rcoun && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_rcoun_hint") ?? "Country of responsible operator"}>RCoun:</span>
<span>{detail.rcoun}</span>
</div>
)}
{detail.rphon && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_rphon_hint") ?? "Phone of responsible operator"}>RPhon:</span>
<span>{detail.rphon}</span>
</div>
)}
{detail.rhbbs && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_rhbbs_hint") ?? "Home BBS of responsible operator"}>RHBBS:</span>
<span>{detail.rhbbs}</span>
</div>
)}
</div>
<div className="space-y-2">
{detail.stxeq && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_stxeq_hint") ?? "TX equipment"}>STXEq:</span>
<span>{detail.stxeq}</span>
</div>
)}
{detail.srxeq && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_srxeq_hint") ?? "RX equipment"}>SRXEq:</span>
<span>{detail.srxeq}</span>
</div>
)}
{detail.power_watt && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_spowe_hint") ?? "TX power [W]"}>SPowe:</span>
<span>{detail.power_watt}</span>
</div>
)}
{detail.sante && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_sante_hint") ?? "Antenna"}>SAntenna:</span>
<span>{detail.sante}</span>
</div>
)}
{detail.santh && (
<div className="flex gap-2">
<span className="font-semibold" title={t("edi_santh_hint") ?? "Antenna height [m] / ASL [m]"}>SAntH:</span>
<span>{detail.santh}</span>
</div>
)}
</div>
</div>
<Divider />
<Divider />
<div className="space-y-3">
<div className="grid gap-4 md:grid-cols-2 text-sm">
<div className="space-y-1">
<h4 className="font-semibold">Deklarované výsledky</h4>
<div className="flex gap-2">
<span className="font-semibold">Počet QSO:</span>
<span>{detail.claimed_qso_count ?? "—"}</span>
</div>
<div className="flex gap-2">
<span className="font-semibold">Body:</span>
<span>{detail.claimed_score ?? "—"}</span>
</div>
<div className="flex gap-2">
<span className="font-semibold">Unikátních WWL:</span>
<span>{detail.claimed_wwl ?? "—"}</span>
</div>
<div className="flex gap-2">
<span className="font-semibold">Počet DXCC:</span>
<span>{detail.claimed_dxcc ?? "—"}</span>
</div>
</div>
<div className="space-y-1">
<h4 className="font-semibold">Zkontrolované výsledky</h4>
<div className="flex gap-2">
<span className="font-semibold">Počet QSO:</span>
<span>
{officialLoading ? "…" : officialResult?.valid_qso_count ?? "—"}
</span>
</div>
<div className="flex gap-2">
<span className="font-semibold">Body:</span>
<span>
{officialLoading ? "…" : officialResult?.official_score ?? "—"}
</span>
</div>
<div className="flex gap-2">
<span className="font-semibold">Unikátních WWL:</span>
<span>
{officialLoading ? "…" : officialResult?.multiplier_count ?? "—"}
</span>
</div>
<div className="flex gap-2">
<span className="font-semibold">Penalizace:</span>
<span>
{officialLoading ? "…" : officialResult?.penalty_score ?? "—"}
</span>
</div>
{officialError && (
<div className="text-xs text-red-600">{officialError}</div>
)}
</div>
</div>
{detail.remarks_eval && (
<div className="text-sm text-red-600">
{renderRemarksEval(detail.remarks_eval)}
</div>
)}
{detail.raw_header && (
<Accordion>
<AccordionItem
key="raw"
aria-label="RAW header"
title={<span className="cursor-pointer text-primary-600 hover:underline">RAW header</span>}
>
<pre className="bg-default-50 p-2 rounded text-xs whitespace-pre-wrap">
{detail.raw_header}
</pre>
</AccordionItem>
</Accordion>
)}
</div>
</div>
)}
</CardBody>
</Card>
{(logOverrideReason || Object.keys(qsoOverrides).length > 0) && (
<Card>
<CardHeader>
<span className="text-md font-semibold">Zásahy rozhodčího</span>
</CardHeader>
<Divider />
<CardBody>
<div className="text-sm text-foreground-600 space-y-2">
{logOverrideReason && (
<div>Log: {logOverrideReason}</div>
)}
{Object.keys(qsoOverrides).length > 0 && (
<div className="space-y-1">
{qsoTableRows
.filter((qso) => qsoOverrides[qso.id]?.reason)
.map((qso) => (
<div key={qso.id}>
QSO #{qso.qso_index ?? qso.id}: {qso.dx_call ?? "—"} {" "}
{qsoOverrides[qso.id]?.reason}
</div>
))}
</div>
)}
</div>
</CardBody>
</Card>
)}
<Card>
<CardHeader>
<span className="text-md font-semibold">QSO</span>
</CardHeader>
<Divider />
<CardBody>
<LogQsoTable
key={`${detail?.id ?? "log"}-${qsoTableRows.length}-${Object.keys(qsoOverrides).length}-${Object.keys(officialQsoResults).length}`}
qsos={qsoTableRows}
locale={locale}
formatDateTime={formatDateTime}
officialQsoResults={officialQsoResults}
qsoOverrides={qsoOverrides}
emptyLabel={t("logs_empty") ?? "Žádné QSO záznamy."}
callsign={title}
/>
</CardBody>
</Card>
</div>
);
}

View File

@@ -0,0 +1,291 @@
import {
Table,
TableHeader,
TableColumn,
TableBody,
TableRow,
TableCell,
Tooltip,
} from "@heroui/react";
import { saveAs } from "file-saver";
type LogQsoItem = {
id: number;
qso_index?: number | null;
time_on?: string | null;
dx_call?: string | null;
my_rst?: string | null;
my_serial?: string | null;
dx_rst?: string | null;
dx_serial?: string | null;
rx_wwl?: string | null;
rx_exchange?: string | null;
mode_code?: string | null;
new_exchange?: boolean | null;
new_wwl?: boolean | null;
new_dxcc?: boolean | null;
duplicate_qso?: boolean | null;
points?: number | null;
remarks?: string | null;
};
type QsoResultItem = {
log_qso_id: number;
penalty_points?: number | null;
error_code?: string | null;
error_side?: string | null;
match_confidence?: string | null;
match_type?: string | null;
error_flags?: string[] | null;
};
type QsoOverrideInfo = {
reason?: string | null;
forced_status?: string | null;
forced_matched_log_qso_id?: number | null;
forced_points?: number | null;
forced_penalty?: number | null;
};
type LogQsoTableProps = {
qsos: LogQsoItem[];
locale: string;
formatDateTime: (value: string | null | undefined, locale: string) => string;
officialQsoResults: Record<number, QsoResultItem>;
qsoOverrides: Record<number, QsoOverrideInfo>;
emptyLabel: string;
callsign?: string | null;
};
export default function LogQsoTable({
qsos,
locale,
formatDateTime,
officialQsoResults,
qsoOverrides,
emptyLabel,
callsign,
}: LogQsoTableProps) {
if (!qsos || qsos.length === 0) {
return <div>{emptyLabel}</div>;
}
const getOverrideLabel = (override?: QsoOverrideInfo) => {
if (!override) return "—";
if (override.forced_status && override.forced_status !== "AUTO") {
return `STATUS: ${override.forced_status}`;
}
if (override.forced_matched_log_qso_id) {
return `MATCH: ${override.forced_matched_log_qso_id}`;
}
if (
override.forced_points !== null && override.forced_points !== undefined ||
override.forced_penalty !== null && override.forced_penalty !== undefined
) {
return "BODY: override";
}
return "OVERRIDE";
};
const toCsvValue = (value: string | number | null | undefined) => {
if (value === null || value === undefined || value === "—") return "";
return String(value).replace(/"/g, '""');
};
const handleCsvExport = () => {
const header = [
"#",
"Time",
"Callsign",
"Mode",
"My RST",
"My Serial",
"DX RST",
"DX Serial",
"WWL",
"Exchange",
"Points",
"Penalty",
"New WWL",
"New DXCC",
"Dupe",
"Error",
"Side",
"Match",
"Override",
"Note",
];
const lines = qsos.map((qso) => {
const override =
qsoOverrides[qso.id] ??
qsoOverrides[String(qso.id) as unknown as number];
const matchResult = officialQsoResults[qso.id];
const note = override
? override.reason ?? "—"
: qso.remarks || "—";
const row = [
qso.qso_index ?? "",
formatDateTime(qso.time_on ?? null, locale),
qso.dx_call || "—",
qso.mode_code || "—",
qso.my_rst || "—",
qso.my_serial || "—",
qso.dx_rst || "—",
qso.dx_serial || "—",
qso.rx_wwl || "—",
qso.rx_exchange || "—",
qso.points ?? "—",
typeof matchResult?.penalty_points === "number"
? matchResult?.penalty_points
: "—",
qso.new_wwl ? "N" : "—",
qso.new_dxcc ? "N" : "—",
qso.duplicate_qso ? "D" : "—",
matchResult?.error_code ?? "—",
matchResult?.error_side && matchResult?.error_side !== "NONE"
? matchResult?.error_side
: "—",
matchResult?.match_confidence ?? "—",
getOverrideLabel(override),
note,
];
return row.map((value) => `"${toCsvValue(value)}"`).join(",");
});
const csv = [header.join(","), ...lines].join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const safeCallsign = callsign?.trim() || "qso_table";
const fileName = safeCallsign.replace(/\\s+/g, "_");
saveAs(blob, `${fileName}.csv`);
};
return (
<div className="space-y-2">
<div className="flex justify-end">
<a
href="#"
className="text-xs text-foreground-500 hover:underline"
onClick={(event) => {
event.preventDefault();
handleCsvExport();
}}
>
CSV
</a>
</div>
<div className="overflow-x-auto">
<Table
aria-label="QSO table"
isCompact
radius="sm"
removeWrapper
className="min-w-max"
classNames={{
th: "py-1 px-1 text-[11px] leading-tight",
td: "py-0.5 px-1 text-[11px] leading-tight",
}}
>
<TableHeader>
<TableColumn>#</TableColumn>
<TableColumn>Čas</TableColumn>
<TableColumn>Volací znak</TableColumn>
<TableColumn>Mode</TableColumn>
<TableColumn>RST odeslané</TableColumn>
<TableColumn>Číslo odeslané</TableColumn>
<TableColumn>RST přijaté</TableColumn>
<TableColumn>Číslo přijaté</TableColumn>
<TableColumn>WWL</TableColumn>
<TableColumn>Exchange</TableColumn>
<TableColumn>Body</TableColumn>
<TableColumn>Penalizace</TableColumn>
<TableColumn>Nové WWL</TableColumn>
<TableColumn>Nové DXCC</TableColumn>
<TableColumn>Dupl.</TableColumn>
<TableColumn>Chyba</TableColumn>
<TableColumn>Strana</TableColumn>
<TableColumn>Match</TableColumn>
<TableColumn>Zásah</TableColumn>
<TableColumn>Poznámka</TableColumn>
</TableHeader>
<TableBody items={qsos} className="text-[11px] leading-tight">
{(qso) => {
const override =
qsoOverrides[qso.id] ??
qsoOverrides[String(qso.id) as unknown as number];
const matchResult = officialQsoResults[qso.id];
const matchConfidence = matchResult?.match_confidence ?? "—";
const matchTooltipLines: string[] = [];
if (matchResult?.match_type) {
matchTooltipLines.push(`Type: ${matchResult.match_type}`);
}
if (Array.isArray(matchResult?.error_flags) && matchResult.error_flags.length > 0) {
matchTooltipLines.push(`Flags: ${matchResult.error_flags.join(", ")}`);
}
const cellStyle = override ? { backgroundColor: "#FEF3C7" } : undefined;
const note = override
? override.reason ?? "—"
: qso.remarks || "—";
const overrideLabel = getOverrideLabel(override);
return (
<TableRow key={qso.id}>
<TableCell style={cellStyle}>{qso.qso_index ?? "—"}</TableCell>
<TableCell style={cellStyle}>
{formatDateTime(qso.time_on ?? null, locale)}
</TableCell>
<TableCell style={cellStyle}>{qso.dx_call || "—"}</TableCell>
<TableCell style={cellStyle}>{qso.mode_code || "—"}</TableCell>
<TableCell style={cellStyle}>{qso.my_rst || "—"}</TableCell>
<TableCell style={cellStyle}>{qso.my_serial || "—"}</TableCell>
<TableCell style={cellStyle}>{qso.dx_rst || "—"}</TableCell>
<TableCell style={cellStyle}>{qso.dx_serial || "—"}</TableCell>
<TableCell style={cellStyle}>{qso.rx_wwl || "—"}</TableCell>
<TableCell style={cellStyle}>{qso.rx_exchange || "—"}</TableCell>
<TableCell style={cellStyle}>{qso.points ?? "—"}</TableCell>
<TableCell style={cellStyle}>
{typeof officialQsoResults[qso.id]?.penalty_points === "number"
? officialQsoResults[qso.id]?.penalty_points
: "—"}
</TableCell>
<TableCell style={cellStyle}>{qso.new_wwl ? "N" : "—"}</TableCell>
<TableCell style={cellStyle}>{qso.new_dxcc ? "N" : "—"}</TableCell>
<TableCell style={cellStyle}>{qso.duplicate_qso ? "D" : "—"}</TableCell>
<TableCell style={cellStyle}>
{officialQsoResults[qso.id]?.error_code ?? "—"}
</TableCell>
<TableCell style={cellStyle}>
{officialQsoResults[qso.id]?.error_side &&
officialQsoResults[qso.id]?.error_side !== "NONE"
? officialQsoResults[qso.id]?.error_side
: "—"}
</TableCell>
<TableCell style={cellStyle}>
{matchConfidence === "PARTIAL" && matchTooltipLines.length > 0 ? (
<Tooltip
content={
<div className="text-xs leading-snug">
{matchTooltipLines.map((line, index) => (
<div key={index}>{line}</div>
))}
</div>
}
>
<span className="cursor-help">{matchConfidence}</span>
</Tooltip>
) : (
matchConfidence
)}
</TableCell>
<TableCell style={cellStyle}>{overrideLabel}</TableCell>
<TableCell style={cellStyle}>{note}</TableCell>
</TableRow>
);
}}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,206 @@
import { FC } from "react";
import React from "react";
import { SwitchProps, useSwitch } from "@heroui/switch";
import axios from "axios";
import { useTranslation } from 'react-i18next';
import { useUserStore } from "@/stores/userStore";
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
Button,
useDisclosure,
Checkbox,
Input,
Link,
} from "@heroui/react";
export const MailIcon = (props) => {
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="1em"
role="presentation"
viewBox="0 0 24 24"
width="1em"
{...props}
>
<path
d="M17 3.5H7C4 3.5 2 5 2 8.5V15.5C2 19 4 20.5 7 20.5H17C20 20.5 22 19 22 15.5V8.5C22 5 20 3.5 17 3.5ZM17.47 9.59L14.34 12.09C13.68 12.62 12.84 12.88 12 12.88C11.16 12.88 10.31 12.62 9.66 12.09L6.53 9.59C6.21 9.33 6.16 8.85 6.41 8.53C6.67 8.21 7.14 8.15 7.46 8.41L10.59 10.91C11.35 11.52 12.64 11.52 13.4 10.91L16.53 8.41C16.85 8.15 17.33 8.2 17.58 8.53C17.84 8.85 17.79 9.33 17.47 9.59Z"
fill="currentColor"
/>
</svg>
);
};
export const LockIcon = (props) => {
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="1em"
role="presentation"
viewBox="0 0 24 24"
width="1em"
{...props}
>
<path
d="M12.0011 17.3498C12.9013 17.3498 13.6311 16.6201 13.6311 15.7198C13.6311 14.8196 12.9013 14.0898 12.0011 14.0898C11.1009 14.0898 10.3711 14.8196 10.3711 15.7198C10.3711 16.6201 11.1009 17.3498 12.0011 17.3498Z"
fill="currentColor"
/>
<path
d="M18.28 9.53V8.28C18.28 5.58 17.63 2 12 2C6.37 2 5.72 5.58 5.72 8.28V9.53C2.92 9.88 2 11.3 2 14.79V16.65C2 20.75 3.25 22 7.35 22H16.65C20.75 22 22 20.75 22 16.65V14.79C22 11.3 21.08 9.88 18.28 9.53ZM12 18.74C10.33 18.74 8.98 17.38 8.98 15.72C8.98 14.05 10.34 12.7 12 12.7C13.66 12.7 15.02 14.06 15.02 15.72C15.02 17.39 13.67 18.74 12 18.74ZM7.35 9.44C7.27 9.44 7.2 9.44 7.12 9.44V8.28C7.12 5.35 7.95 3.4 12 3.4C16.05 3.4 16.88 5.35 16.88 8.28V9.45C16.8 9.45 16.73 9.45 16.65 9.45H7.35V9.44Z"
fill="currentColor"
/>
</svg>
);
};
export const LoginDialog:FC = () => {
const { t } = useTranslation('common')
const setUser = useUserStore((s) => s.setUser);
const {isOpen, onOpen, onOpenChange} = useDisclosure()
const [email, setEmail] = React.useState("")
const [password, setPassword] = React.useState("")
const [rememberMe, setRememberMe] = React.useState(false)
const [errorMessage, setErrorMessage] = React.useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = React.useState(false)
const handleSubmit = async () => {
if (isSubmitting) {
return;
}
const trimmedEmail = email.trim();
if (!trimmedEmail || !password) {
setErrorMessage(t('email_and_password_required'));
return;
}
setIsSubmitting(true);
setErrorMessage(null);
try {
// Laravel's stateful API expects the XSRF-TOKEN cookie before posting credentials
await axios.get("/sanctum/csrf-cookie", { withCredentials: true });
await axios.post(
"/api/login",
{
email: trimmedEmail,
password,
remember: rememberMe,
},
{
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
withCredentials: true,
withXSRFToken: true,
}
);
const meResponse = await axios.get("/api/user", {
headers: {
Accept: "application/json",
},
withCredentials: true,
});
setUser(meResponse.data);
//window.location.href = "/"; // pokud chceš full reload
window.location.assign("/contests");
} catch (error) {
if (axios.isAxiosError(error)) {
const responseError =
error.response?.data?.errors?.email ||
error.response?.data?.message;
setErrorMessage(responseError || t("unable_to_sign_in"));
} else {
setErrorMessage(t("unable_to_sign_in"));
}
} finally {
setIsSubmitting(false);
}
}
return (
<>
<Modal
isDismissable={false}
isKeyboardDismissDisabled={true}
isOpen={true}
hideCloseButton={true}
onOpenChange={onOpenChange}
placement="top-center"
backdrop="blur"
>
<ModalContent>
<ModalHeader className="flex flex-col gap-1">{t('login_dialog_label')}</ModalHeader>
<ModalBody>
<Input
endContent={
<MailIcon className="text-2xl text-default-400 pointer-events-none shrink-0" />
}
label={t("email")}
placeholder={t("enter_email")}
variant="bordered"
autoFocus={true}
type="email"
value={email}
onValueChange={setEmail}
autoComplete="email"
/>
<Input
endContent={
<LockIcon className="text-2xl text-default-400 pointer-events-none shrink-0" />
}
label={t("password")}
placeholder={t("enter_password")}
type="password"
variant="bordered"
value={password}
onValueChange={setPassword}
autoComplete="current-password"
/>
<div className="flex py-2 px-1 justify-between">
<Checkbox
classNames={{
label: "text-small",
}}
isSelected={rememberMe}
onValueChange={setRememberMe}
>
{t('remember_me')}
</Checkbox>
<Link color="primary" href="#" size="sm">
{t('forgot_password')}
</Link>
</div>
{errorMessage && (
<p className="text-sm text-red-500">
{errorMessage}
</p>
)}
</ModalBody>
<ModalFooter>
<Button color="danger" variant="flat" onPress={() => {history.back()}}>{t("close")}</Button>
<Button color="primary" onPress={handleSubmit} isLoading={isSubmitting} isDisabled={isSubmitting}>{t("sign_in")}</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)
}
export default LoginDialog

View 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>
);
}

View File

@@ -0,0 +1,148 @@
import { useEffect, useState } from "react";
import axios from "axios";
import Markdown from "react-markdown";
import { useLanguageStore } from "@/stores/languageStore";
import { useTranslation } from 'react-i18next';
type TranslatedField = string | Record<string, string>;
type NewsPost = {
id: number;
slug: string;
title: TranslatedField;
excerpt: TranslatedField | null;
content: TranslatedField;
published_at: string;
};
type PaginatedResponse<T> = {
data: T[];
};
function resolveTranslation(
field: TranslatedField | null | undefined,
locale: string
): string {
if (!field) return "";
if (typeof field === "string") {
return field;
}
if (field[locale]) return field[locale];
if (field["en"]) return field["en"];
const first = Object.values(field)[0];
return first ?? "";
}
function formatDate(value: string | null | undefined, locale: string): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(locale, {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(date);
}
type NewsListProps = {
initialLimit?: number;
};
export default function NewsList({ initialLimit = 3 }: NewsListProps) {
const locale = useLanguageStore((s) => s.locale);
const { t } = useTranslation("common");
const [items, setItems] = useState<NewsPost[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [limit, setLimit] = useState(initialLimit);
useEffect(() => {
let active = true;
(async () => {
try {
setLoading(true);
setError(null);
const res = await axios.get<PaginatedResponse<NewsPost> | NewsPost[]>(
"/api/news",
{
withCredentials: true,
headers: { Accept: "application/json" },
params: { lang: locale, limit }, // <<<< tady se pošle jazyk na backend
}
);
if (!active) return;
const data = Array.isArray(res.data)
? res.data
: (res.data as PaginatedResponse<NewsPost>).data;
setItems(data);
} catch {
if (!active) return;
setError("Nepodařilo se načíst novinky.");
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
// <<<< refetch při změně jazyka
}, [locale, limit]);
if (loading) return <div>Načítám novinky</div>;
if (error) return <div className="text-red-600 text-sm">{error}</div>;
if (items.length === 0) {
return;
}
return (
<div className="space-y-8">
{items.map((post) => {
const title = resolveTranslation(post.title, locale);
const content = resolveTranslation(post.content, locale);
return (
<article
key={post.id}
className="border-b border-gray-200 dark:border-gray-700 pb-6"
>
<h2 className="text-xl font-semibold mb-1">{title}</h2>
{post.published_at && (
<p className="text-xs text-gray-500 mb-3">
{formatDate(post.published_at, locale)}
</p>
)}
<div className="prose dark:prose-invert max-w-none text-sm">
<Markdown>{content}</Markdown>
</div>
</article>
);
})}
{items.length >= limit && (
<div className="flex justify-end">
<button
type="button"
className="text-primary underline text-sm"
onClick={() => setLimit((prev) => prev + 3)}
>
{t("news_show_more") ?? "Zobrazit další"}
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,640 @@
import React, { useEffect, useMemo, useState } from "react";
import axios from "axios";
import { Card, CardBody, CardHeader, Divider, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow, Spinner, Tooltip } from "@heroui/react";
import { saveAs } from "file-saver";
import { useNavigate, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
type LogResultItem = {
id: number;
log_id: number;
band_id?: number | null;
category_id?: number | null;
power_category_id?: number | null;
sixhr_category?: boolean | null;
claimed_qso_count?: number | null;
claimed_score?: number | null;
total_qso_count?: number | null;
discarded_qso_count?: number | null;
discarded_qso_percent?: number | null;
discarded_points?: number | null;
unique_qso_count?: number | null;
official_score?: number | null;
valid_qso_count?: number | null;
score_per_qso?: number | null;
rank_overall?: number | null;
rank_in_category?: number | null;
rank_overall_ok?: number | null;
rank_in_category_ok?: number | null;
status?: string | null;
status_reason?: string | null;
log?: {
id: number;
pcall?: string | null;
sixhr_category?: boolean | null;
pwwlo?: string | null;
power_watt?: number | null;
codxc?: string | null;
sante?: string | null;
santh?: string | null;
} | null;
band?: { id: number; name?: string | null; order?: number | null } | null;
category?: { id: number; name?: string | null; order?: number | null } | null;
power_category?: { id: number; name?: string | null; order?: number | null } | null;
evaluation_run?: { id: number; result_type?: string | null; rule_set?: { sixhr_ranking_mode?: string | null } | null } | null;
};
type ApiResponse<T> = {
data: T[];
};
type ResultsTablesProps = {
roundId: number | null;
contestId?: number | null;
filter?: "ALL" | "OK";
mode?: "claimed" | "final";
showResultTypeLabel?: boolean;
onResultTypeChange?: (label: string | null, className: string, resultType: string | null) => void;
refreshKey?: string | number | null;
evaluationRunId?: number | null;
};
export default function ResultsTables({
roundId,
contestId = null,
filter = "ALL",
mode = "claimed",
showResultTypeLabel = true,
onResultTypeChange,
refreshKey = null,
evaluationRunId = null,
}: ResultsTablesProps) {
const [items, setItems] = useState<LogResultItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [overrideReasons, setOverrideReasons] = useState<Record<number, string | null>>({});
const [overrideFlags, setOverrideFlags] = useState<Record<number, {
forced_log_status?: string | null;
forced_band_id?: number | null;
forced_category_id?: number | null;
forced_power_category_id?: number | null;
forced_power_w?: number | null;
forced_sixhr_category?: boolean | null;
}>>({});
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
if (!roundId) return;
let active = true;
(async () => {
try {
setLoading(true);
setError(null);
const params: Record<string, unknown> = {
per_page: 5000,
only_ok: filter === "OK",
};
if (evaluationRunId) {
params.evaluation_run_id = evaluationRunId;
}
if (mode === "claimed") {
params.round_id = roundId;
params.status = "CLAIMED";
} else if (!evaluationRunId) {
params.round_id = roundId;
params.result_type = "AUTO";
}
const res = await axios.get<ApiResponse<LogResultItem>>("/api/log-results", {
params,
headers: { Accept: "application/json" },
});
if (!active) return;
setItems(res.data.data ?? []);
} catch (e: any) {
if (!active) return;
const msg =
e?.response?.data?.message ||
(mode === "final"
? "Nepodařilo se načíst finální výsledky."
: "Nepodařilo se načíst deklarované výsledky.");
setError(msg);
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [roundId, filter, mode, refreshKey, evaluationRunId]);
useEffect(() => {
if (mode !== "final") {
setOverrideReasons({});
return;
}
const evaluationRunId = items[0]?.evaluation_run?.id ?? null;
if (!evaluationRunId) {
setOverrideReasons({});
return;
}
let active = true;
(async () => {
try {
const res = await axios.get<ApiResponse<{
log_id: number;
reason?: string | null;
forced_log_status?: string | null;
forced_band_id?: number | null;
forced_category_id?: number | null;
forced_power_category_id?: number | null;
forced_power_w?: number | null;
forced_sixhr_category?: boolean | null;
}>>("/api/log-overrides", {
params: {
evaluation_run_id: evaluationRunId,
per_page: 5000,
},
headers: { Accept: "application/json" },
withCredentials: true,
});
if (!active) return;
const map: Record<number, string | null> = {};
const flagMap: Record<number, {
forced_log_status?: string | null;
forced_band_id?: number | null;
forced_category_id?: number | null;
forced_power_category_id?: number | null;
forced_power_w?: number | null;
forced_sixhr_category?: boolean | null;
}> = {};
(res.data.data ?? []).forEach((item) => {
map[item.log_id] = item.reason ?? null;
flagMap[item.log_id] = {
forced_log_status: item.forced_log_status ?? null,
forced_band_id: item.forced_band_id ?? null,
forced_category_id: item.forced_category_id ?? null,
forced_power_category_id: item.forced_power_category_id ?? null,
forced_power_w: item.forced_power_w ?? null,
forced_sixhr_category: item.forced_sixhr_category ?? null,
};
});
setOverrideReasons(map);
setOverrideFlags(flagMap);
} catch {
if (!active) return;
setOverrideReasons({});
setOverrideFlags({});
}
})();
return () => {
active = false;
};
}, [mode, items]);
const grouped = useMemo(() => {
const isSixhr = (r: LogResultItem) => (r.sixhr_category ?? r.log?.sixhr_category) === true;
const sixhrRankingMode =
mode === "final"
? (items[0]?.evaluation_run?.rule_set?.sixhr_ranking_mode ?? "IARU")
: "CRK";
const sixh = items.filter((r) => isSixhr(r));
const standard = items.filter((r) => !isSixhr(r));
const powerEligible = standard.filter(
(r) => !isCheckCategory(r) && !isPowerA(r) && r.power_category_id && r.rank_in_category !== null
);
const groupOverall = (list: LogResultItem[]) =>
groupBy(list, (r) => `${r.band_id ?? "null"}|${r.category_id ?? "null"}`);
const groupSixhOverall = (list: LogResultItem[]) =>
groupBy(list, (r) =>
sixhrRankingMode === "IARU"
? `${r.band_id ?? "null"}|ALL`
: `${r.band_id ?? "null"}|${r.category_id ?? "null"}`
);
const groupPower = (list: LogResultItem[]) =>
groupBy(list, (r) => `${r.band_id ?? "null"}|${r.category_id ?? "null"}|${r.power_category_id ?? "null"}`);
return {
sixhOverall: groupSixhOverall(sixh),
standardOverall: groupOverall(standard),
standardPower: groupPower(powerEligible),
sixhrRankingMode,
};
}, [items, mode]);
const overrideVersion = useMemo(() => Object.keys(overrideReasons).sort().join(","), [overrideReasons]);
const showCalculatedColumns = mode === "final";
const renderGroup = (
group: GroupedResults,
title: string,
rankField: RankField,
includePowerInHeading = true,
includeCategoryInHeading = true
) => {
if (group.length === 0) return [];
return group.map(({ key, items }) => {
const sample = items[0];
const [bandName, categoryName, powerName] = resolveNames(sample);
const headingParts = [bandName, includeCategoryInHeading ? categoryName : null, title];
if (includePowerInHeading && !isCheckCategory(sample)) {
headingParts.push(powerName);
}
const heading = headingParts.filter(Boolean).join(" ");
const sorted = sortResults(items, rankField, mode);
const headerColumns = [
<TableColumn key="rank" className="whitespace-nowrap">{t("results_table_rank") ?? "Pořadí"}</TableColumn>,
<TableColumn key="callsign" className="whitespace-nowrap">{t("results_table_callsign") ?? "Značka v závodě"}</TableColumn>,
<TableColumn key="locator" className="whitespace-nowrap">{t("results_table_locator") ?? "Lokátor"}</TableColumn>,
<TableColumn key="category" className="whitespace-nowrap">{t("results_table_category") ?? "Kategorie"}</TableColumn>,
<TableColumn key="band" className="whitespace-nowrap">{t("results_table_band") ?? "Pásmo"}</TableColumn>,
<TableColumn key="power_watt" className="whitespace-nowrap">{t("results_table_power_watt") ?? "Výkon [W]"}</TableColumn>,
<TableColumn key="power_category" className="whitespace-nowrap">{t("results_table_power_category") ?? "Výkonová kat."}</TableColumn>,
<TableColumn key="score_total" className="whitespace-nowrap">{t("results_table_score_total") ?? "Body celkem"}</TableColumn>,
<TableColumn key="claimed_score" className="whitespace-nowrap">{t("results_table_claimed_score") ?? "Deklarované body"}</TableColumn>,
<TableColumn key="qso_count" className="whitespace-nowrap">{t("results_table_qso_count") ?? "Počet QSO"}</TableColumn>,
showCalculatedColumns
? (
<TableColumn key="discarded_qso" className="whitespace-nowrap">
<Tooltip content={t("results_table_discarded_qso_help") ?? "Počet QSO s is_valid=false."}>
<span className="cursor-help">{t("results_table_discarded_qso") ?? "Vyřazeno QSO"}</span>
</Tooltip>
</TableColumn>
)
: null,
showCalculatedColumns
? <TableColumn key="discarded_points" className="whitespace-nowrap">{t("results_table_discarded_points") ?? "Vyřazeno bodů"}</TableColumn>
: null,
showCalculatedColumns
? <TableColumn key="unique_qso" className="whitespace-nowrap">{t("results_table_unique_qso") ?? "Unique QSO"}</TableColumn>
: null,
<TableColumn key="score_per_qso" className="whitespace-nowrap">{t("results_table_score_per_qso") ?? "Body / QSO"}</TableColumn>,
<TableColumn key="odx" className="whitespace-nowrap">{t("results_table_odx") ?? "ODX"}</TableColumn>,
<TableColumn key="antenna" className="whitespace-nowrap">{t("results_table_antenna") ?? "Anténa"}</TableColumn>,
<TableColumn key="antenna_height" className="whitespace-nowrap">{t("results_table_antenna_height") ?? "Ant. height"}</TableColumn>,
showCalculatedColumns
? <TableColumn key="status" className="whitespace-nowrap">{t("results_table_status") ?? "Status"}</TableColumn>
: null,
showCalculatedColumns
? <TableColumn key="override_reason" className="whitespace-nowrap">{t("results_table_override_reason") ?? "Komentář rozhodčího"}</TableColumn>
: null,
].filter(Boolean);
return (
<Card key={key} className="mb-4">
<CardHeader>
<div className="flex items-center justify-between w-full gap-2">
<span className="text-md font-semibold">{heading}</span>
<button
type="button"
onClick={() => exportCsv(heading, sorted, rankField, mode)}
className="text-xs text-primary-600 hover:text-primary-700 underline"
>
CSV
</button>
</div>
</CardHeader>
<Divider />
<CardBody>
<Table
key={`${key}-${overrideVersion}`}
radius="sm"
isCompact
aria-label={heading}
removeWrapper
fullWidth
classNames={{
th: "py-1 px-1 text-[11px] leading-tight",
td: "py-0.5 px-1 text-[11px] leading-tight bg-transparent",
}}
>
<TableHeader>{headerColumns}</TableHeader>
<TableBody emptyContent="No rows to display" items={sorted} className="text-[11px] leading-tight">
{(item) => {
const logId = item.log_id ?? item.log?.id;
const hasOverride =
logId != null && Object.prototype.hasOwnProperty.call(overrideReasons, logId);
const callsignClassName = "whitespace-nowrap";
const rowCells = [
<TableCell key="rank" className="whitespace-nowrap">{item[rankField] ?? "—"}</TableCell>,
<TableCell key="callsign" className={callsignClassName}>
{item.log?.pcall ?? "—"}
</TableCell>,
<TableCell key="locator" className="whitespace-nowrap">{item.log?.pwwlo ?? "—"}</TableCell>,
<TableCell key="category" className="whitespace-nowrap">
{item.category?.name ?? "—"}
</TableCell>,
<TableCell key="band" className="whitespace-nowrap">
{item.band?.name ?? "—"}
</TableCell>,
<TableCell key="power_watt" className="whitespace-nowrap">
{formatNumber(item.log?.power_watt)}
</TableCell>,
<TableCell key="power_category" className="whitespace-nowrap">
{item.power_category?.name ?? "—"}
</TableCell>,
<TableCell key="score_total" className="whitespace-nowrap">{formatScore(item, mode)}</TableCell>,
<TableCell key="claimed_score" className="whitespace-nowrap">{formatNumber(item.claimed_score)}</TableCell>,
<TableCell key="qso_count" className="whitespace-nowrap">{formatQsoCount(item, mode)}</TableCell>,
showCalculatedColumns
? <TableCell key="discarded_qso" className="whitespace-nowrap">{formatDiscardedQso(item)}</TableCell>
: null,
showCalculatedColumns
? <TableCell key="discarded_points" className="whitespace-nowrap">{formatNumber(item.discarded_points)}</TableCell>
: null,
showCalculatedColumns
? <TableCell key="unique_qso" className="whitespace-nowrap">{formatNumber(item.unique_qso_count)}</TableCell>
: null,
<TableCell key="score_per_qso" className="whitespace-nowrap">{formatScorePerQso(item.score_per_qso)}</TableCell>,
<TableCell key="odx" className="whitespace-nowrap">{item.log?.codxc ?? "—"}</TableCell>,
<TableCell key="antenna" className="whitespace-nowrap">{item.log?.sante ?? "—"}</TableCell>,
<TableCell key="antenna_height" className="whitespace-nowrap">{item.log?.santh ?? "—"}</TableCell>,
showCalculatedColumns
? <TableCell key="status" className="whitespace-nowrap">{item.status ?? "—"}</TableCell>
: null,
showCalculatedColumns
? (
<TableCell key="override_reason" className="whitespace-nowrap">
{(() => {
const overrideReason = hasOverride && logId != null ? overrideReasons[logId] : null;
const statusReason = item.status_reason ?? null;
if (overrideReason && statusReason) {
return `${overrideReason}; ${statusReason}`;
}
return overrideReason || statusReason || "—";
})()}
</TableCell>
)
: null,
].filter(Boolean);
return (
<TableRow
key={item.id}
onClick={() => {
if (contestId && roundId) {
navigate(`/contests/${contestId}/rounds/${roundId}/logs/${item.log_id}`, {
state: { from: `${location.pathname}${location.search}` },
});
}
}}
className={contestId && roundId ? "cursor-pointer hover:bg-default-100" : undefined}
>
{rowCells}
</TableRow>
);
}}
</TableBody>
</Table>
</CardBody>
</Card>
);
});
};
const rankOverallField: RankField = filter === "OK" ? "rank_overall_ok" : "rank_overall";
const rankInCategoryField: RankField = filter === "OK" ? "rank_in_category_ok" : "rank_in_category";
const renderOverallWithPowers = (
overalls: GroupedResults,
powers: GroupedResults,
title: string,
includeCategoryInHeading = true
) => {
if (overalls.length === 0) return null;
const powerIndex = powers.reduce<Record<string, GroupedResults>>((acc, group) => {
const [bandId, categoryId] = group.key.split("|");
const key = `${bandId}|${categoryId}`;
acc[key] = acc[key] || [];
acc[key].push(group);
return acc;
}, {});
return sortGroups(overalls).flatMap(({ key, items }) => {
const [bandId, categoryId] = key.split("|");
const powerGroups = sortGroups(powerIndex[`${bandId}|${categoryId}`] ?? []);
return [
...renderGroup([{ key, items }], title, rankOverallField, false, includeCategoryInHeading),
...renderGroup(powerGroups, "", rankInCategoryField, true),
];
});
};
if (!roundId) return null;
const resultType = items[0]?.evaluation_run?.result_type ?? null;
const resultTypeLabel =
resultType === "FINAL"
? (t("results_type_final") as string) || "Finální výsledky"
: resultType === "PRELIMINARY"
? (t("results_type_preliminary") as string) || "Předběžné výsledky"
: resultType === "TEST"
? (t("results_type_test") as string) || "Testovací výsledky"
: null;
const resultTypeClass =
resultType === "FINAL"
? "bg-success-200 text-success-900"
: resultType === "PRELIMINARY"
? "bg-success-100 text-success-900"
: resultType === "TEST"
? "bg-warning-200 text-warning-900"
: "";
useEffect(() => {
if (!onResultTypeChange) return;
onResultTypeChange(resultTypeLabel, resultTypeClass, resultType);
}, [onResultTypeChange, resultTypeLabel, resultTypeClass, resultType]);
if (loading) {
const label = mode === "final" ? "Načítám finální výsledky…" : "Načítám deklarované výsledky…";
return <div className="flex items-center gap-2 text-sm text-foreground-500"><Spinner size="sm" /> {label}</div>;
}
if (error) return <div className="text-sm text-red-600">{error}</div>;
return (
<div className="space-y-4">
{mode === "final" && showResultTypeLabel && resultTypeLabel && (
<div
className={[
"inline-flex items-center rounded px-2 py-1 text-xs font-semibold",
resultTypeClass,
]
.filter(Boolean)
.join(" ")}
>
{resultTypeLabel}
</div>
)}
{renderOverallWithPowers(grouped.standardOverall, grouped.standardPower, "")}
{renderOverallWithPowers(grouped.sixhOverall, [], "6H", grouped.sixhrRankingMode !== "IARU")}
</div>
);
}
type GroupedResults = Array<{ key: string; items: LogResultItem[] }>;
function groupBy(items: LogResultItem[], getKey: (r: LogResultItem) => string): GroupedResults {
const map = new Map<string, LogResultItem[]>();
items.forEach((item) => {
const key = getKey(item);
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(item);
});
return Array.from(map.entries()).map(([key, list]) => ({ key, items: list }));
}
function sortGroups(groups: GroupedResults): GroupedResults {
return [...groups].sort((a, b) => {
const aItem = a.items[0];
const bItem = b.items[0];
const bandOrder = (aItem?.band?.order ?? Number.MAX_SAFE_INTEGER) - (bItem?.band?.order ?? Number.MAX_SAFE_INTEGER);
if (bandOrder !== 0) return bandOrder;
const categoryOrder = (aItem?.category?.order ?? Number.MAX_SAFE_INTEGER) - (bItem?.category?.order ?? Number.MAX_SAFE_INTEGER);
if (categoryOrder !== 0) return categoryOrder;
const powerOrder =
(aItem?.power_category?.order ?? Number.MAX_SAFE_INTEGER) -
(bItem?.power_category?.order ?? Number.MAX_SAFE_INTEGER);
if (powerOrder !== 0) return powerOrder;
const bandName = aItem?.band?.name ?? "";
const bandNameB = bItem?.band?.name ?? "";
if (bandName !== bandNameB) return bandName.localeCompare(bandNameB);
const categoryName = aItem?.category?.name ?? "";
const categoryNameB = bItem?.category?.name ?? "";
if (categoryName !== categoryNameB) return categoryName.localeCompare(categoryNameB);
const powerName = aItem?.power_category?.name ?? "";
const powerNameB = bItem?.power_category?.name ?? "";
return powerName.localeCompare(powerNameB);
});
}
function resolveNames(item: LogResultItem): [string | null, string | null, string | null] {
const band = item.band?.name ?? null;
const category = item.category?.name ?? null;
const power = item.power_category?.name ?? null;
return [band, category, power];
}
type RankField = "rank_overall" | "rank_in_category" | "rank_overall_ok" | "rank_in_category_ok";
function sortResults(items: LogResultItem[], rankField: RankField, mode: "claimed" | "final") {
return [...items].sort((a, b) => {
const ra = a[rankField] ?? Number.MAX_SAFE_INTEGER;
const rb = b[rankField] ?? Number.MAX_SAFE_INTEGER;
if (ra !== rb) return ra - rb;
const sa = getScore(a, mode) ?? 0;
const sb = getScore(b, mode) ?? 0;
if (sa !== sb) return sb - sa;
const qa = getQsoCount(a, mode) ?? 0;
const qb = getQsoCount(b, mode) ?? 0;
return qb - qa;
});
}
function formatNumber(value: number | null | undefined) {
if (value === null || value === undefined) return "—";
if (Number.isInteger(value)) return value.toString();
return value.toFixed(1);
}
function formatDiscardedQso(item: LogResultItem) {
const count = item.discarded_qso_count;
if (count === null || count === undefined) return "—";
const percent = item.discarded_qso_percent;
if (percent === null || percent === undefined) return `${count}`;
return `${count} (${percent.toFixed(2)}%)`;
}
function formatScorePerQso(value?: number | null) {
if (value === null || value === undefined) return "—";
return value.toFixed(2);
}
function getScore(item: LogResultItem, mode: "claimed" | "final") {
return mode === "final" ? item.official_score ?? null : item.claimed_score ?? null;
}
function getQsoCount(item: LogResultItem, mode: "claimed" | "final") {
return mode === "final" ? item.valid_qso_count ?? null : item.claimed_qso_count ?? null;
}
function formatScore(item: LogResultItem, mode: "claimed" | "final") {
return getScore(item, mode) ?? "—";
}
function formatQsoCount(item: LogResultItem, mode: "claimed" | "final") {
return getQsoCount(item, mode) ?? "—";
}
function isCheckCategory(item: LogResultItem) {
const name = item.category?.name?.toLowerCase() ?? "";
return name.includes("check");
}
function isPowerA(item: LogResultItem) {
const name = item.power_category?.name?.toLowerCase() ?? "";
return name === "a";
}
function exportCsv(title: string, rows: LogResultItem[], rankField: RankField, mode: "claimed" | "final") {
const includeCalculated = mode === "final";
const header = [
"Poradi",
"Značka v závodě",
"Lokátor",
"Kategorie",
"Pásmo",
"Výkon [W]",
"Výkonová kat.",
"Body celkem",
"Deklarované body",
"Počet QSO",
...(includeCalculated ? ["Vyřazeno QSO", "Vyřazeno bodů", "Unique QSO"] : []),
"Body / QSO",
"ODX",
"Anténa",
"Ant. height",
];
const lines = rows.map((r) => {
const score = getScore(r, mode);
const qsoCount = getQsoCount(r, mode);
const ratio = formatScorePerQso(r.score_per_qso);
const discardedQso = formatDiscardedQso(r);
const base = [
r[rankField] ?? "",
r.log?.pcall ?? "",
r.log?.pwwlo ?? "",
r.category?.name ?? "",
r.band?.name ?? "",
r.log?.power_watt ?? "",
r.power_category?.name ?? "",
score ?? "",
r.claimed_score ?? "",
qsoCount ?? "",
ratio === "—" ? "" : ratio,
r.log?.codxc ?? "",
r.log?.sante ?? "",
r.log?.santh ?? "",
];
if (includeCalculated) {
base.splice(
10,
0,
discardedQso === "—" ? "" : discardedQso,
r.discarded_points ?? "",
r.unique_qso_count ?? ""
);
}
return base.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(",");
});
const csv = [header.join(","), ...lines].join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const safeTitle = title && title.trim() !== "" ? title : "results";
saveAs(blob, `${safeTitle.replace(/\s+/g, "_")}.csv`);
}

View File

@@ -0,0 +1,624 @@
import React, { useEffect, useMemo, useState } from "react";
import axios from "axios";
import { Button, Input, Switch, Textarea } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { useLanguageStore } from "@/stores/languageStore";
type TranslationPayload = {
cs?: string;
en?: string;
};
type ContestOption = {
id: number;
name: string | TranslationPayload;
bands?: { id: number; name: string }[];
categories?: { id: number; name: string }[];
power_categories?: { id: number; name: string }[];
rule_set_id?: number | null;
duration?: number;
logs_deadline_days?: number;
};
export type RoundFromApi = {
id: number;
contest_id: number;
name: string | TranslationPayload;
description?: string | TranslationPayload | null;
bands?: { id: number; name: string }[];
categories?: { id: number; name: string }[];
power_categories?: { id: number; name: string }[];
rule_set_id?: number | null;
is_active: boolean;
is_test: boolean;
is_sixhr: boolean;
start_time: string | null;
end_time: string | null;
logs_deadline: string | null;
};
type RoundFormMode = "create" | "edit";
type RoundCreateFormProps = {
mode?: RoundFormMode; // default "create"
round?: RoundFromApi | null; // pro edit
onCreated?: (round: RoundFromApi) => void;
onUpdated?: (round: RoundFromApi) => void;
contestId?: number | null;
};
const buildTranslationPayload = (cs: string, en: string): TranslationPayload => {
const trimmedCs = cs.trim();
const trimmedEn = en.trim();
if (!trimmedCs && !trimmedEn) {
return {};
}
if (trimmedCs && trimmedEn) {
return {
cs: trimmedCs,
en: trimmedEn,
};
}
const value = trimmedCs || trimmedEn;
return {
cs: value,
en: value,
};
};
const extractTranslations = (
field: string | TranslationPayload | null | undefined
): TranslationPayload => {
if (!field) return {};
if (typeof field === "string") return { cs: field };
return field;
};
const toDatetimeLocal = (value: string | null): string => {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const pad = (n: number) => `${n}`.padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
const normalizeDatetime = (value: string): string | undefined => {
if (!value.trim()) return undefined;
return value;
};
const isSixHourBandName = (name?: string | null) => {
if (!name) return false;
const lower = name.toLowerCase();
return lower.includes("145") || lower.includes("435");
};
export default function RoundCreateForm({
mode = "create",
round,
onCreated,
onUpdated,
contestId: forcedContestId = null,
}: RoundCreateFormProps) {
const { t } = useTranslation("common");
const locale = useLanguageStore((s) => s.locale);
const isEdit = mode === "edit" && round != null;
const [contestId, setContestId] = useState<string>(forcedContestId ? String(forcedContestId) : "");
const [contests, setContests] = useState<ContestOption[]>([]);
const [loadingContests, setLoadingContests] = useState(false);
const [nameCs, setNameCs] = useState("");
const [nameEn, setNameEn] = useState("");
const [descriptionCs, setDescriptionCs] = useState("");
const [descriptionEn, setDescriptionEn] = useState("");
const [startTime, setStartTime] = useState("");
const [endTime, setEndTime] = useState("");
const [logsDeadline, setLogsDeadline] = useState("");
const [contestDurationHours, setContestDurationHours] = useState<number | null>(null);
const [contestDeadlineDays, setContestDeadlineDays] = useState<number | null>(null);
const [isActive, setIsActive] = useState(true);
const [isTest, setIsTest] = useState(false);
const [isSixHr, setIsSixHr] = useState(false);
const [availableBands, setAvailableBands] = useState<ContestOption["bands"]>([]);
const [availableCategories, setAvailableCategories] = useState<ContestOption["categories"]>([]);
const [availablePowerCategories, setAvailablePowerCategories] = useState<ContestOption["power_categories"]>([]);
const [selectedBandIds, setSelectedBandIds] = useState<number[]>([]);
const [selectedCategoryIds, setSelectedCategoryIds] = useState<number[]>([]);
const [selectedPowerCategoryIds, setSelectedPowerCategoryIds] = useState<number[]>([]);
const [availableRuleSets, setAvailableRuleSets] = useState<{ id: number; name: string; code?: string | null }[]>([]);
const [selectedRuleSetId, setSelectedRuleSetId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const hasAllowedSixHrBand = useMemo(() => {
if (!selectedBandIds.length) return false;
return availableBands.some((b) => selectedBandIds.includes(b.id) && isSixHourBandName(b.name));
}, [availableBands, selectedBandIds]);
// načti seznam závodů pro select
useEffect(() => {
let active = true;
(async () => {
try {
setLoadingContests(true);
const [contestsRes, bandsRes, categoriesRes, powerCatsRes, ruleSetsRes] = await Promise.all([
axios.get<ContestOption[] | { data: ContestOption[] }>("/api/contests", {
withCredentials: true,
headers: { Accept: "application/json" },
params: { lang: locale },
}),
axios.get("/api/bands", { headers: { Accept: "application/json" } }),
axios.get("/api/categories", { headers: { Accept: "application/json" } }),
axios.get("/api/power-categories", { headers: { Accept: "application/json" } }),
axios.get("/api/evaluation-rule-sets", { headers: { Accept: "application/json" } }),
]);
if (!active) return;
const normalize = (res: any) => (Array.isArray(res?.data) ? res.data : res?.data?.data ?? res.data ?? []);
setContests(normalize(contestsRes));
setAvailableBands(normalize(bandsRes));
setAvailableCategories(normalize(categoriesRes));
setAvailablePowerCategories(normalize(powerCatsRes));
const ruleSets = normalize(ruleSetsRes);
setAvailableRuleSets(ruleSets);
if (!isEdit && !selectedRuleSetId) {
const defaultRuleSet = ruleSets.find((item: any) => item.code === "default_vhf_compat");
if (defaultRuleSet) {
setSelectedRuleSetId(defaultRuleSet.id);
}
}
} catch {
if (!active) return;
setError(t("unable_to_load_contests") ?? "Nepodařilo se načíst seznam závodů.");
} finally {
if (active) setLoadingContests(false);
}
})();
return () => {
active = false;
};
}, [locale, t]);
// předvyplnění při editaci
useEffect(() => {
if (!isEdit || !round) return;
const name = extractTranslations(round.name);
const desc = extractTranslations(round.description ?? null);
setContestId(String(round.contest_id ?? ""));
setNameCs(name.cs ?? "");
setNameEn(name.en ?? "");
setDescriptionCs(desc.cs ?? "");
setDescriptionEn(desc.en ?? "");
setStartTime(toDatetimeLocal(round.start_time));
setEndTime(toDatetimeLocal(round.end_time));
setLogsDeadline(toDatetimeLocal(round.logs_deadline));
setIsActive(!!round.is_active);
setIsTest(!!round.is_test);
setIsSixHr(!!round.is_sixhr);
setSelectedBandIds(Array.isArray((round as any).bands) ? (round as any).bands.map((b: any) => b.id) : []);
setSelectedCategoryIds(Array.isArray((round as any).categories) ? (round as any).categories.map((c: any) => c.id) : []);
setSelectedPowerCategoryIds(Array.isArray((round as any).power_categories) ? (round as any).power_categories.map((p: any) => p.id) : []);
setSelectedRuleSetId((round as any).rule_set_id ?? null);
setError(null);
setSuccess(null);
}, [isEdit, round]);
// pokud při editaci chybí vazby, doťukej detail kola
useEffect(() => {
if (!isEdit || !round) return;
const needsDetail =
!(Array.isArray((round as any).bands) && (round as any).bands.length) ||
!(Array.isArray((round as any).categories) && (round as any).categories.length) ||
!(Array.isArray((round as any).power_categories) && (round as any).power_categories.length);
if (!needsDetail) return;
let active = true;
(async () => {
try {
const res = await axios.get(`/api/rounds/${round.id}`, {
headers: { Accept: "application/json" },
params: { lang: locale },
withCredentials: true,
});
if (!active) return;
const data = res.data;
setSelectedBandIds(Array.isArray(data.bands) ? data.bands.map((b: any) => b.id) : []);
setSelectedCategoryIds(Array.isArray(data.categories) ? data.categories.map((c: any) => c.id) : []);
setSelectedPowerCategoryIds(Array.isArray(data.power_categories) ? data.power_categories.map((p: any) => p.id) : []);
setIsActive(!!data.is_active);
setIsTest(!!data.is_test);
setIsSixHr(!!data.is_sixhr);
setStartTime(toDatetimeLocal(data.start_time));
setEndTime(toDatetimeLocal(data.end_time));
setLogsDeadline(toDatetimeLocal(data.logs_deadline));
} catch {
// ignore
}
})();
return () => { active = false; };
}, [isEdit, round, locale]);
// pokud máme zvolený contest, načti detail pro defaulty (vazby) a parametry
useEffect(() => {
if (!contestId) return;
let active = true;
(async () => {
try {
const res = await axios.get<ContestOption>(`/api/contests/${contestId}`, {
headers: { Accept: "application/json" },
params: { lang: locale },
withCredentials: true,
});
if (!active) return;
const data = res.data;
if (!isEdit) {
if (data?.bands) setSelectedBandIds(data.bands.map((b) => b.id));
if (data?.categories) setSelectedCategoryIds(data.categories.map((c) => c.id));
if (data?.power_categories) setSelectedPowerCategoryIds(data.power_categories.map((p) => p.id));
if (data?.rule_set_id) setSelectedRuleSetId(data.rule_set_id);
}
if (typeof data.duration === "number") setContestDurationHours(data.duration);
if (typeof data.logs_deadline_days === "number") setContestDeadlineDays(data.logs_deadline_days);
} catch {
// ignore
}
})();
return () => { active = false; };
}, [contestId, locale, isEdit]);
// Auto-nastavení konce kola podle startu a délky závodu
useEffect(() => {
if (!startTime || endTime || contestDurationHours == null) return;
const startDate = new Date(startTime);
if (Number.isNaN(startDate.getTime())) return;
const endDate = new Date(startDate);
endDate.setHours(endDate.getHours() + contestDurationHours);
setEndTime(toDatetimeLocal(endDate.toISOString()));
}, [startTime, endTime, contestDurationHours]);
// Auto-nastavení uzávěrky logů při vyplnění/změně konce
useEffect(() => {
if (!endTime || contestDeadlineDays == null) return;
const endDate = new Date(endTime);
if (Number.isNaN(endDate.getTime())) return;
const deadlineDate = new Date(endDate);
deadlineDate.setDate(deadlineDate.getDate() + contestDeadlineDays);
setLogsDeadline(toDatetimeLocal(deadlineDate.toISOString()));
}, [endTime, contestDeadlineDays]);
useEffect(() => {
if (isSixHr && !hasAllowedSixHrBand) {
setIsSixHr(false);
}
}, [hasAllowedSixHrBand, isSixHr]);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
setSuccess(null);
if (!contestId.trim()) {
setError(t("Vyber závod.") ?? "Vyber závod.");
return;
}
const namePayload = buildTranslationPayload(nameCs, nameEn);
if (Object.keys(namePayload).length === 0) {
setError(t("Vyplň alespoň jeden překlad názvu kola.") ?? "Vyplň alespoň jeden překlad názvu kola.");
return;
}
const descriptionPayload = buildTranslationPayload(descriptionCs, descriptionEn);
const payload: Record<string, unknown> = {
contest_id: Number(contestId),
name: namePayload,
is_active: isActive,
is_test: isTest,
is_sixhr: isSixHr,
};
if (Object.keys(descriptionPayload).length > 0) {
payload.description = descriptionPayload;
}
const normalizedStart = normalizeDatetime(startTime);
const normalizedEnd = normalizeDatetime(endTime);
const normalizedDeadline = normalizeDatetime(logsDeadline);
if (normalizedStart) payload.start_time = normalizedStart;
if (normalizedEnd) payload.end_time = normalizedEnd;
if (normalizedDeadline) payload.logs_deadline = normalizedDeadline;
payload.band_ids = selectedBandIds;
payload.category_ids = selectedCategoryIds;
payload.power_category_ids = selectedPowerCategoryIds;
if (selectedRuleSetId) payload.rule_set_id = selectedRuleSetId;
if (isSixHr && !hasAllowedSixHrBand) {
payload.is_sixhr = false;
setIsSixHr(false);
setError(t("six_hr_band_warning") ?? "6H kategorie je povolena jen pro pásma 145 MHz a 435 MHz.");
}
// 6h jen pro pásma 145 / 435 pokud nesplňuje, vypni is_sixhr
if (isSixHr) {
const bands = (round as any)?.bands ?? [];
const currentBands = (bands.length ? bands.map((b: any) => b.id) : selectedBandIds) ?? [];
const hasAllowedBand = (round as any)?.bands
? bands.some((b: any) => isSixHourBandName(b.name))
: selectedBandIds.some((id) => {
const found = (round as any)?.availableBands?.find?.((b: any) => b.id === id);
return found ? isSixHourBandName(found.name) : true;
});
if (!hasAllowedBand) {
payload.is_sixhr = false;
setIsSixHr(false);
}
}
try {
setSubmitting(true);
await axios.get("/sanctum/csrf-cookie", { withCredentials: true });
let response;
if (isEdit && round) {
response = await axios.put(`/api/rounds/${round.id}`, payload, {
headers: { Accept: "application/json" },
withCredentials: true,
withXSRFToken: true,
});
setSuccess(t("Kolo bylo upraveno.") ?? "Kolo bylo upraveno.");
onUpdated?.(response.data as RoundFromApi);
} else {
response = await axios.post("/api/rounds", payload, {
headers: { Accept: "application/json" },
withCredentials: true,
withXSRFToken: true,
});
setSuccess(t("Kolo bylo vytvořeno.") ?? "Kolo bylo vytvořeno.");
onCreated?.(response.data as RoundFromApi);
// reset formuláře po vytvoření
setNameCs("");
setNameEn("");
setDescriptionCs("");
setDescriptionEn("");
setStartTime("");
setEndTime("");
setLogsDeadline("");
setIsActive(true);
setIsTest(false);
setIsSixHr(false);
}
} catch (e: any) {
if (axios.isAxiosError(e)) {
setError(e.response?.data?.message ?? "Chyba při ukládání kola.");
} else {
setError("Chyba při ukládání kola.");
}
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">
{t("contest_name") ?? "Závod"}
</label>
<select
value={contestId}
onChange={(e) => setContestId(e.target.value)}
disabled={loadingContests || submitting || !!forcedContestId || isEdit}
className="w-full border rounded px-3 py-2 text-sm bg-white dark:bg-gray-900"
required
>
<option value="">{t("Vyber závod") ?? "Vyber závod"}</option>
{contests.map((c) => (
<option key={c.id} value={c.id}>
{typeof c.name === "string" ? c.name : c.name[locale] ?? c.name["en"] ?? c.name["cs"] ?? c.id}
</option>
))}
</select>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input
label={t("round_name") ?? "Název (cs)"}
labelPlacement="outside"
placeholder="Kolo (cs)"
value={nameCs}
onChange={(e) => setNameCs(e.target.value)}
isRequired
/>
<Input
label={t("round_name") ?? "Název (en)"}
labelPlacement="outside"
placeholder="Round (en)"
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Textarea
label={t("round_description") ?? "Popis (cs)"}
labelPlacement="outside"
placeholder="Popis kola (cs)"
value={descriptionCs}
onChange={(e) => setDescriptionCs(e.target.value)}
minRows={2}
/>
<Textarea
label={t("round_description") ?? "Popis (en)"}
labelPlacement="outside"
placeholder="Round description (en)"
value={descriptionEn}
onChange={(e) => setDescriptionEn(e.target.value)}
minRows={2}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<Input
type="datetime-local"
label={t("Začátek") ?? "Začátek"}
labelPlacement="outside-left"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
isRequired
/>
<Input
type="datetime-local"
label={t("Konec") ?? "Konec"}
labelPlacement="outside-left"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
isRequired
/>
<Input
type="datetime-local"
label={t("Uzávěrka logů") ?? "Uzávěrka logů"}
labelPlacement="outside-left"
value={logsDeadline}
onChange={(e) => setLogsDeadline(e.target.value)}
/>
</div>
<div className="flex flex-wrap gap-4">
<Switch
isSelected={isActive}
onValueChange={setIsActive}
>
{t("round_active") ?? "Aktivní"}
</Switch>
<Switch
isSelected={isTest}
onValueChange={setIsTest}
>
{t("round_test") ?? "Testovací"}
</Switch>
<Switch
isSelected={isSixHr}
onValueChange={setIsSixHr}
isDisabled={!hasAllowedSixHrBand}
>
6h
</Switch>
</div>
<div className="grid gap-6 md:grid-cols-3">
<div>
<p className="text-sm font-semibold mb-2">Pásma</p>
<div className="space-y-2">
{availableBands?.map((band) => (
<label key={band.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={selectedBandIds.includes(band.id)}
onChange={() =>
setSelectedBandIds((prev) =>
prev.includes(band.id) ? prev.filter((id) => id !== band.id) : [...prev, band.id]
)
}
disabled={loadingContests || submitting}
/>
<span>{band.name}</span>
</label>
))}
</div>
</div>
<div>
<p className="text-sm font-semibold mb-2">Kategorie</p>
<div className="space-y-2">
{availableCategories?.map((cat) => (
<label key={cat.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={selectedCategoryIds.includes(cat.id)}
onChange={() =>
setSelectedCategoryIds((prev) =>
prev.includes(cat.id) ? prev.filter((id) => id !== cat.id) : [...prev, cat.id]
)
}
disabled={loadingContests || submitting}
/>
<span>{cat.name}</span>
</label>
))}
</div>
</div>
<div>
<p className="text-sm font-semibold mb-2">Výkonové kategorie</p>
<div className="space-y-2">
{availablePowerCategories?.map((p) => (
<label key={p.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={selectedPowerCategoryIds.includes(p.id)}
onChange={() =>
setSelectedPowerCategoryIds((prev) =>
prev.includes(p.id) ? prev.filter((id) => id !== p.id) : [...prev, p.id]
)
}
disabled={loadingContests || submitting}
/>
<span>{p.name}</span>
</label>
))}
</div>
</div>
<div>
<p className="text-sm font-semibold mb-2">Ruleset</p>
<select
value={selectedRuleSetId ?? ""}
onChange={(e) => setSelectedRuleSetId(e.target.value ? Number(e.target.value) : null)}
disabled={loadingContests || submitting}
className="w-full border rounded px-3 py-2 text-sm bg-white dark:bg-gray-900"
>
<option value="">--</option>
{availableRuleSets.map((ruleSet) => (
<option key={ruleSet.id} value={ruleSet.id}>
{ruleSet.name}
</option>
))}
</select>
</div>
</div>
{error && <div className="text-sm text-red-600">{error}</div>}
{success && <div className="text-sm text-green-600">{success}</div>}
<div className="flex gap-3">
<Button type="submit" color="primary" isLoading={submitting}>
{isEdit ? t("Uložit změny") ?? "Uložit změny" : t("Vytvořit kolo") ?? "Vytvořit kolo"}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,190 @@
import { useEffect, useState } from "react";
import axios from "axios";
import { Card, CardHeader, CardBody, Divider } from "@heroui/react";
import { useLanguageStore } from "@/stores/languageStore";
import { useContestStore, type ContestSummary, type RoundSummary } from "@/stores/contestStore";
import { useTranslation } from "react-i18next";
type RoundDetailProps = {
roundId: number;
};
type RoundDetailData = RoundSummary & {
contest_id: number;
contest?: ContestSummary | null;
description?: string | Record<string, string> | null;
logs_deadline?: string | null;
start_time: string | null;
end_time: string | null;
rule_set_id?: number | null;
rule_set?: { id: number; name: string } | null;
preliminary_evaluation_run_id?: number | null;
official_evaluation_run_id?: number | null;
test_evaluation_run_id?: number | null;
};
function parseRoundDate(value: string | null): Date | null {
if (!value) return null;
const direct = new Date(value);
if (!Number.isNaN(direct.getTime())) return direct;
const normalized = value.includes("T") ? value : value.replace(" ", "T");
const fallback = new Date(normalized);
if (!Number.isNaN(fallback.getTime())) return fallback;
return null;
}
function formatDateTime(value: string | null, locale: string): string {
if (!value) return "—";
const date = parseRoundDate(value);
if (!date) return value;
return date.toLocaleString(locale);
}
function isPastDeadline(value: string | null): boolean {
const date = parseRoundDate(value);
if (!date) return false;
return (new Date() > date);
}
const resolveTranslation = (field: any, locale: string): string => {
if (!field) return "";
if (typeof field === "string") return field;
if (typeof field === "object") {
if (field[locale]) return field[locale];
if (field["en"]) return field["en"];
const first = Object.values(field)[0];
return typeof first === "string" ? first : "";
}
return String(field);
};
export default function RoundDetail({ roundId }: RoundDetailProps) {
const { t } = useTranslation("common");
const locale = useLanguageStore((s) => s.locale);
const setSelectedContest = useContestStore((s) => s.setSelectedContest);
const setSelectedRound = useContestStore((s) => s.setSelectedRound);
const [detail, setDetail] = useState<RoundDetailData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!roundId) return;
let active = true;
(async () => {
try {
setLoading(true);
setError(null);
const res = await axios.get<RoundDetailData>(`/api/rounds/${roundId}`, {
headers: { Accept: "application/json" },
params: { lang: locale },
withCredentials: true,
});
if (!active) return;
setDetail(res.data);
// set store for breadcrumbs/overview
if (res.data.contest) {
setSelectedContest({
id: res.data.contest.id,
name: resolveTranslation((res.data as any).contest.name, locale),
description: resolveTranslation((res.data as any).contest.description ?? null, locale),
is_active: res.data.contest.is_active,
is_mcr: res.data.contest.is_mcr,
is_sixhr: res.data.contest.is_sixhr,
start_time: res.data.contest.start_time ?? null,
duration: res.data.contest.duration ?? 0,
});
}
setSelectedRound({
id: res.data.id,
contest_id: res.data.contest_id,
name: resolveTranslation(res.data.name, locale),
description: resolveTranslation(res.data.description ?? null, locale),
is_active: res.data.is_active,
is_test: res.data.is_test,
is_sixhr: res.data.is_sixhr,
start_time: res.data.start_time,
end_time: res.data.end_time,
logs_deadline: res.data.logs_deadline ?? null,
preliminary_evaluation_run_id: res.data.preliminary_evaluation_run_id ?? null,
official_evaluation_run_id: res.data.official_evaluation_run_id ?? null,
test_evaluation_run_id: res.data.test_evaluation_run_id ?? null,
});
} catch {
if (!active) return;
setError("Nepodařilo se načíst detail kola.");
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [roundId, locale, setSelectedContest, setSelectedRound]);
return (
<Card>
<CardHeader>
<div className="flex flex-col">
<span className="text-lg font-semibold">
{detail ? resolveTranslation(detail.name, locale) : t("round_name") ?? "Kolo"}
</span>
{detail?.description && (
<span className="text-sm text-foreground-500">
{resolveTranslation(detail.description, locale)}
</span>
)}
</div>
</CardHeader>
<Divider />
<CardBody>
{error && <p className="text-sm text-red-600">{error}</p>}
{loading && <p className="text-sm text-foreground-500">Načítám detail</p>}
{detail && !loading && (
<div className="grid gap-2 text-sm">
<div className="flex gap-2">
<span className="font-semibold">{t("round_schedule") ?? "Termín"}:</span>
<span>{formatDateTime(detail.start_time, locale)} {formatDateTime(detail.end_time, locale)}</span>
</div>
{detail.logs_deadline && (
<div className="flex gap-2">
<span className="font-semibold">{t("round_logs_deadline") ?? "Logy do"}:</span>
<span>{formatDateTime(detail.logs_deadline, locale)}</span>
{detail.logs_deadline && isPastDeadline(detail.logs_deadline) && (
<span>
{t("round_logs_deadline_passed") ??
"Termín pro nahrání logů již vypršel."}
</span>
)}
</div>
)}
<div className="flex gap-2">
<span className="font-semibold">{t("round_active") ?? "Aktivní"}:</span>
<span>{detail.is_active ? (t("yes") ?? "Ano") : (t("no") ?? "Ne")}</span>
</div>
{detail.is_test && (
<div className="flex gap-2">
<span className="font-semibold">{t("round_test") ?? "Test"}:</span>
<span>{t("yes") ?? "Ano"}</span>
</div>
)}
<div className="flex gap-2">
<span className="font-semibold">6h:</span>
<span>{detail.is_sixhr ? (t("yes") ?? "Ano") : (t("no") ?? "Ne")}</span>
</div>
{(detail.rule_set || detail.rule_set_id) && (
<div className="flex gap-2">
<span className="font-semibold">Ruleset:</span>
<span>{detail.rule_set?.name ?? `#${detail.rule_set_id}`}</span>
</div>
)}
</div>
)}
</CardBody>
</Card>
);
}

View File

@@ -0,0 +1,341 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import axios from "axios";
import { Spinner, useDisclosure } from "@heroui/react";
import RoundEvaluationLogOverridesTable from "@/components/RoundEvaluationLogOverridesTable";
import RoundEvaluationLogOverridesModal from "@/components/RoundEvaluationLogOverridesModal";
import RoundEvaluationLogOverridesSearch from "@/components/RoundEvaluationLogOverridesSearch";
import type {
LogOverride,
LogResult,
OverrideForm,
} from "@/components/RoundEvaluationLogOverrides.types";
type RoundDetail = {
id: number;
bands?: { id: number; name: string }[];
categories?: { id: number; name: string }[];
power_categories?: { id: number; name: string }[];
powerCategories?: { id: number; name: string }[];
};
type PaginatedResponse<T> = {
data: T[];
current_page: number;
last_page: number;
total: number;
};
type Props = {
roundId: number | null;
evaluationRunId: number;
};
export default function RoundEvaluationLogOverrides({ roundId, evaluationRunId }: Props) {
const { isOpen, onOpen, onOpenChange, onClose } = useDisclosure();
const [items, setItems] = useState<LogResult[]>([]);
const [overrides, setOverrides] = useState<Record<number, LogOverride>>({});
const [forms, setForms] = useState<Record<number, OverrideForm>>({});
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState("");
const [roundDetail, setRoundDetail] = useState<RoundDetail | null>(null);
const [activeLogId, setActiveLogId] = useState<number | null>(null);
const perPage = 5000;
const bands = useMemo(() => roundDetail?.bands ?? [], [roundDetail?.bands]);
const categories = useMemo(() => roundDetail?.categories ?? [], [roundDetail?.categories]);
const powerCategories = useMemo(
() => roundDetail?.powerCategories ?? roundDetail?.power_categories ?? [],
[roundDetail?.powerCategories, roundDetail?.power_categories]
);
const resolveName = (list: { id: number; name: string }[], id?: number | null) => {
if (!id) return "AUTO";
return list.find((item) => item.id === id)?.name ?? `#${id}`;
};
useEffect(() => {
if (!roundId) return;
let active = true;
(async () => {
try {
const res = await axios.get<RoundDetail>(`/api/rounds/${roundId}`, {
headers: { Accept: "application/json" },
withCredentials: true,
});
if (!active) return;
setRoundDetail(res.data);
} catch {
if (!active) return;
setRoundDetail(null);
}
})();
return () => {
active = false;
};
}, [roundId]);
const fetchOverrides = useCallback(async (active?: { value: boolean }) => {
try {
const res = await axios.get<PaginatedResponse<LogOverride>>("/api/log-overrides", {
headers: { Accept: "application/json" },
params: { evaluation_run_id: evaluationRunId, per_page: 5000 },
withCredentials: true,
});
if (active && !active.value) return;
const map: Record<number, LogOverride> = {};
res.data.data.forEach((item) => {
map[item.log_id] = item;
});
setOverrides(map);
} catch {
if (active && !active.value) return;
setOverrides({});
}
}, [evaluationRunId]);
useEffect(() => {
const active = { value: true };
fetchOverrides(active);
return () => {
active.value = false;
};
}, [fetchOverrides]);
const fetchItems = useCallback(async (active?: { value: boolean }) => {
try {
setLoading(true);
const res = await axios.get<PaginatedResponse<LogResult>>("/api/log-results", {
headers: { Accept: "application/json" },
params: { evaluation_run_id: evaluationRunId, per_page: perPage },
withCredentials: true,
});
if (active && !active.value) return;
setItems(res.data.data);
} catch {
if (active && !active.value) return;
setItems([]);
} finally {
if (!active || active.value) setLoading(false);
}
}, [evaluationRunId, perPage]);
useEffect(() => {
const active = { value: true };
fetchItems(active);
return () => {
active.value = false;
};
}, [fetchItems]);
useEffect(() => {
if (!items.length) return;
setForms((prev) => {
const next = { ...prev };
for (const item of items) {
const override = overrides[item.log_id];
if (!next[item.log_id]) {
next[item.log_id] = {
status: override?.forced_log_status ?? "AUTO",
bandId: override?.forced_band_id ? String(override.forced_band_id) : "",
categoryId: override?.forced_category_id ? String(override.forced_category_id) : "",
powerCategoryId: override?.forced_power_category_id ? String(override.forced_power_category_id) : "",
reason: override?.reason ?? "",
saving: false,
error: null,
success: null,
};
}
}
return next;
});
}, [items, overrides]);
const filteredItems = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return items;
return items.filter((item) => {
const log = item.log;
const parts = [
String(item.log_id),
log?.pcall ?? "",
log?.pband ?? "",
log?.psect ?? "",
]
.join(" ")
.toLowerCase();
return parts.includes(query);
});
}, [items, search]);
const activeItem = useMemo(
() => (activeLogId ? items.find((item) => item.log_id === activeLogId) ?? null : null),
[items, activeLogId]
);
const openEditor = useCallback(
(logId: number) => {
setActiveLogId(logId);
onOpen();
},
[onOpen]
);
const handleFieldChange = (logId: number, field: keyof OverrideForm, value: string) => {
const emptyForm: OverrideForm = {
status: "AUTO",
bandId: "",
categoryId: "",
powerCategoryId: "",
reason: "",
saving: false,
error: null,
success: null,
};
setForms((prev) => ({
...prev,
[logId]: {
...(prev[logId] ?? emptyForm),
[field]: value,
error: null,
success: null,
},
}));
};
const saveOverride = async (logId: number) => {
const form = forms[logId];
if (!form) return;
const override = overrides[logId];
const hasStatus = form.status && form.status !== "AUTO";
const hasBand = form.bandId !== "";
const hasCategory = form.categoryId !== "";
const hasPower = form.powerCategoryId !== "";
const hasAny = hasStatus || hasBand || hasCategory || hasPower;
const reason = form.reason.trim();
const baseline = {
status: override?.forced_log_status ?? "AUTO",
bandId: override?.forced_band_id ? String(override.forced_band_id) : "",
categoryId: override?.forced_category_id ? String(override.forced_category_id) : "",
powerCategoryId: override?.forced_power_category_id ? String(override.forced_power_category_id) : "",
};
const hasChanges =
form.status !== baseline.status ||
form.bandId !== baseline.bandId ||
form.categoryId !== baseline.categoryId ||
form.powerCategoryId !== baseline.powerCategoryId;
setForms((prev) => ({
...prev,
[logId]: { ...prev[logId], saving: true, error: null, success: null },
}));
try {
if (!hasChanges && !override) {
setForms((prev) => ({
...prev,
[logId]: { ...prev[logId], saving: false, success: "Bez změn." },
}));
return;
}
if (!hasChanges && override) {
setForms((prev) => ({
...prev,
[logId]: { ...prev[logId], saving: false, success: "Bez změn." },
}));
return;
}
if (!reason) {
setForms((prev) => ({
...prev,
[logId]: { ...prev[logId], saving: false, error: "Doplň důvod změny." },
}));
return;
}
const payload = {
evaluation_run_id: evaluationRunId,
log_id: logId,
forced_log_status: form.status || "AUTO",
forced_band_id: form.bandId ? Number(form.bandId) : null,
forced_category_id: form.categoryId ? Number(form.categoryId) : null,
forced_power_category_id: form.powerCategoryId ? Number(form.powerCategoryId) : null,
reason,
};
if (override) {
const res = await axios.put<LogOverride>(`/api/log-overrides/${override.id}`, payload, {
headers: { Accept: "application/json" },
withCredentials: true,
withXSRFToken: true,
});
setOverrides((prev) => ({ ...prev, [logId]: res.data }));
} else {
const res = await axios.post<LogOverride>("/api/log-overrides", payload, {
headers: { Accept: "application/json" },
withCredentials: true,
withXSRFToken: true,
});
setOverrides((prev) => ({ ...prev, [logId]: res.data }));
}
setForms((prev) => ({
...prev,
[logId]: { ...prev[logId], saving: false, success: "Uloženo." },
}));
await Promise.all([fetchOverrides(), fetchItems()]);
onClose();
} catch (e: any) {
const msg = e?.response?.data?.message || "Nepodařilo se uložit override.";
setForms((prev) => ({
...prev,
[logId]: { ...prev[logId], saving: false, error: msg },
}));
}
};
if (!roundId) return null;
return (
<div className="pt-2 border-t border-divider">
<div className="font-semibold text-sm mb-2">Ruční zásahy po agregaci</div>
<div className="text-xs text-foreground-500 mb-2">
Slouží pro korekce klasifikace a statusu logu před publikací.
</div>
<RoundEvaluationLogOverridesSearch search={search} onChange={setSearch} />
{loading && (
<div className="flex items-center gap-2 text-xs text-foreground-600">
<Spinner size="sm" /> Načítám výsledky
</div>
)}
{!loading && filteredItems.length === 0 && (
<div className="text-xs text-foreground-600">Žádné položky k úpravě.</div>
)}
{filteredItems.length > 0 && (
<div className="space-y-4">
<RoundEvaluationLogOverridesTable
items={filteredItems}
overrides={overrides}
onEdit={openEditor}
/>
</div>
)}
<RoundEvaluationLogOverridesModal
isOpen={isOpen}
onOpenChange={onOpenChange}
onClose={onClose}
activeItem={activeItem}
override={activeItem ? overrides[activeItem.log_id] : undefined}
form={activeItem ? forms[activeItem.log_id] : undefined}
bands={bands}
categories={categories}
powerCategories={powerCategories}
onFieldChange={handleFieldChange}
onSave={saveOverride}
resolveName={resolveName}
/>
</div>
);
}

View File

@@ -0,0 +1,64 @@
export type LogItem = {
id: number;
pcall?: string | null;
pband?: string | null;
psect?: string | null;
pwwlo?: string | null;
locator?: string | null;
sixhr_category?: boolean | null;
power_watt?: number | null;
codxc?: string | null;
sante?: string | null;
santh?: string | null;
};
export type LogResult = {
id: number;
log_id: number;
status?: string | null;
rank_overall?: number | null;
rank_in_category?: number | null;
official_score?: number | null;
penalty_score?: number | null;
valid_qso_count?: number | null;
dupe_qso_count?: number | null;
busted_qso_count?: number | null;
other_error_qso_count?: number | null;
band?: { id: number; name: string; order?: number | null } | null;
category?: { id: number; name: string; order?: number | null } | null;
power_category?: { id: number; name: string; order?: number | null } | null;
powerCategory?: { id: number; name: string; order?: number | null } | null;
log?: LogItem | null;
};
export type LogOverride = {
id: number;
evaluation_run_id: number;
log_id: number;
forced_log_status?: string | null;
forced_band_id?: number | null;
forced_category_id?: number | null;
forced_power_category_id?: number | null;
forced_sixhr_category?: boolean | null;
forced_power_w?: number | null;
reason?: string | null;
context?: {
original?: {
status?: string | null;
band_id?: number | null;
category_id?: number | null;
power_category_id?: number | null;
};
} | null;
};
export type OverrideForm = {
status: string;
bandId: string;
categoryId: string;
powerCategoryId: string;
reason: string;
saving: boolean;
error?: string | null;
success?: string | null;
};

View File

@@ -0,0 +1,218 @@
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalHeader,
Select,
SelectItem,
Textarea,
type Selection,
} from "@heroui/react";
import type {
LogOverride,
LogResult,
OverrideForm,
} from "@/components/RoundEvaluationLogOverrides.types";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
onClose: () => void;
activeItem: LogResult | null;
override?: LogOverride;
form?: OverrideForm;
bands: { id: number; name: string }[];
categories: { id: number; name: string }[];
powerCategories: { id: number; name: string }[];
onFieldChange: (logId: number, field: keyof OverrideForm, value: string) => void;
onSave: (logId: number) => void;
resolveName: (list: { id: number; name: string }[], id?: number | null) => string;
};
const statusOptions = [
{ value: "AUTO", label: "AUTO" },
{ value: "OK", label: "OK" },
{ value: "CHECK", label: "CHECK" },
{ value: "DQ", label: "DQ" },
{ value: "IGNORED", label: "IGNORED" },
];
const getFirstSelection = (keys: Selection) => {
if (keys === "all") return "";
const [first] = Array.from(keys);
return typeof first === "string" || typeof first === "number" ? String(first) : "";
};
export default function RoundEvaluationLogOverridesModal({
isOpen,
onOpenChange,
onClose,
activeItem,
override,
form,
bands,
categories,
powerCategories,
onFieldChange,
onSave,
resolveName,
}: Props) {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="2xl" scrollBehavior="inside">
<ModalContent>
<ModalHeader className="flex flex-col gap-1">
Upravit log #{activeItem?.log_id ?? "—"}
</ModalHeader>
<ModalBody>
{activeItem && (
<>
<div className="text-xs text-foreground-500 mb-2">
<div>
{activeItem.log?.pcall ?? "—"} | {activeItem.band?.name ?? "—"} /{" "}
{activeItem.category?.name ?? "—"} /{" "}
{activeItem.powerCategory?.name ?? activeItem.power_category?.name ?? "—"}
</div>
<div>
Score: {activeItem.official_score ?? "—"} | Rank:{" "}
{activeItem.rank_overall ?? "—"} | Status: {activeItem.status ?? "—"}
</div>
</div>
{override && (
<div className="text-xs text-foreground-500 mb-2">
Override: {override.forced_log_status ?? "AUTO"} /{" "}
{resolveName(bands, override.forced_band_id)} /{" "}
{resolveName(categories, override.forced_category_id)} /{" "}
{resolveName(powerCategories, override.forced_power_category_id)}
</div>
)}
<div className="grid gap-2 md:grid-cols-2">
<Select
aria-label="Status"
size="sm"
variant="bordered"
label="Status"
selectedKeys={new Set([form?.status ?? "AUTO"])}
onSelectionChange={(keys) =>
onFieldChange(
activeItem.log_id,
"status",
getFirstSelection(keys) || "AUTO"
)
}
selectionMode="single"
disallowEmptySelection={true}
>
{statusOptions.map((opt) => (
<SelectItem key={opt.value} textValue={opt.label}>
{opt.label}
</SelectItem>
))}
</Select>
<Select
aria-label="Band"
size="sm"
variant="bordered"
label="Band"
selectedKeys={new Set([form?.bandId || "auto"])}
onSelectionChange={(keys) =>
onFieldChange(
activeItem.log_id,
"bandId",
getFirstSelection(keys) === "auto" ? "" : getFirstSelection(keys)
)
}
selectionMode="single"
disallowEmptySelection={true}
>
<SelectItem key="auto" textValue="AUTO">
AUTO
</SelectItem>
{bands.map((band) => (
<SelectItem key={String(band.id)} textValue={band.name}>
{band.name}
</SelectItem>
))}
</Select>
<Select
aria-label="Kategorie"
size="sm"
variant="bordered"
label="Kategorie"
selectedKeys={new Set([form?.categoryId || "auto"])}
onSelectionChange={(keys) =>
onFieldChange(
activeItem.log_id,
"categoryId",
getFirstSelection(keys) === "auto" ? "" : getFirstSelection(keys)
)
}
selectionMode="single"
disallowEmptySelection={true}
>
<SelectItem key="auto" textValue="AUTO">
AUTO
</SelectItem>
{categories.map((cat) => (
<SelectItem key={String(cat.id)} textValue={cat.name}>
{cat.name}
</SelectItem>
))}
</Select>
<Select
aria-label="Výkon"
size="sm"
variant="bordered"
label="Výkon"
selectedKeys={new Set([form?.powerCategoryId || "auto"])}
onSelectionChange={(keys) =>
onFieldChange(
activeItem.log_id,
"powerCategoryId",
getFirstSelection(keys) === "auto" ? "" : getFirstSelection(keys)
)
}
selectionMode="single"
disallowEmptySelection={true}
>
<SelectItem key="auto" textValue="AUTO">
AUTO
</SelectItem>
{powerCategories.map((cat) => (
<SelectItem key={String(cat.id)} textValue={cat.name}>
{cat.name}
</SelectItem>
))}
</Select>
</div>
<Textarea
label="Důvod změny"
size="sm"
variant="bordered"
minRows={2}
value={form?.reason ?? ""}
onChange={(e) => onFieldChange(activeItem.log_id, "reason", e.target.value)}
placeholder="Krátce popiš, proč zasahuješ."
/>
<div className="flex items-center gap-2 text-xs">
<Button
size="sm"
color="primary"
onPress={() => onSave(activeItem.log_id)}
isDisabled={form?.saving}
>
{form?.saving ? "Ukládám…" : "Uložit"}
</Button>
<Button size="sm" variant="light" onPress={onClose}>
Zavřít
</Button>
{form?.error && <span className="text-red-600">{form.error}</span>}
{form?.success && <span className="text-green-600">{form.success}</span>}
</div>
</>
)}
</ModalBody>
</ModalContent>
</Modal>
);
}

View File

@@ -0,0 +1,21 @@
type Props = {
search: string;
onChange: (value: string) => void;
};
export default function RoundEvaluationLogOverridesSearch({ search, onChange }: Props) {
return (
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
<label className="flex items-center gap-2">
<span>Hledat:</span>
<input
type="text"
className="border border-divider rounded px-2 py-1 bg-background"
value={search}
onChange={(e) => onChange(e.target.value)}
placeholder="Log ID / callsign"
/>
</label>
</div>
);
}

View File

@@ -0,0 +1,300 @@
import React, { useMemo } from "react";
import {
Button,
Card,
CardBody,
CardHeader,
Divider,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
} from "@heroui/react";
import type { LogResult, LogOverride } from "@/components/RoundEvaluationLogOverrides.types";
type GroupedResults = Array<{ key: string; items: LogResult[] }>;
type Props = {
items: LogResult[];
overrides: Record<number, LogOverride>;
onEdit: (logId: number) => void;
};
function PencilIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className={className ?? "h-4 w-4"}
>
<path d="M13.586 3.586a2 2 0 0 1 2.828 2.828l-8.25 8.25a2 2 0 0 1-.878.518l-3.122.89a.75.75 0 0 1-.926-.926l.89-3.122a2 2 0 0 1 .518-.878l8.25-8.25Z" />
<path d="M12.475 4.697 4.75 12.422l-.69 2.422 2.422-.69 7.725-7.725-1.732-1.732Z" />
</svg>
);
}
function groupBy(items: LogResult[], getKey: (r: LogResult) => string): GroupedResults {
const map = new Map<string, LogResult[]>();
items.forEach((item) => {
const key = getKey(item);
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(item);
});
return Array.from(map.entries()).map(([key, list]) => ({ key, items: list }));
}
function sortGroups(groups: GroupedResults): GroupedResults {
return [...groups].sort((a, b) => {
const aItem = a.items[0];
const bItem = b.items[0];
const bandOrder =
(aItem?.band?.order ?? Number.MAX_SAFE_INTEGER) -
(bItem?.band?.order ?? Number.MAX_SAFE_INTEGER);
if (bandOrder !== 0) return bandOrder;
const categoryOrder =
(aItem?.category?.order ?? Number.MAX_SAFE_INTEGER) -
(bItem?.category?.order ?? Number.MAX_SAFE_INTEGER);
if (categoryOrder !== 0) return categoryOrder;
const powerOrder =
(aItem?.power_category?.order ?? Number.MAX_SAFE_INTEGER) -
(bItem?.power_category?.order ?? Number.MAX_SAFE_INTEGER);
if (powerOrder !== 0) return powerOrder;
const bandName = aItem?.band?.name ?? "";
const bandNameB = bItem?.band?.name ?? "";
if (bandName !== bandNameB) return bandName.localeCompare(bandNameB);
const categoryName = aItem?.category?.name ?? "";
const categoryNameB = bItem?.category?.name ?? "";
if (categoryName !== categoryNameB) return categoryName.localeCompare(categoryNameB);
const powerName = aItem?.power_category?.name ?? "";
const powerNameB = bItem?.power_category?.name ?? "";
return powerName.localeCompare(powerNameB);
});
}
function resolveNames(item: LogResult): [string | null, string | null, string | null] {
const band = item.band?.name ?? null;
const category = item.category?.name ?? null;
const power = item.power_category?.name ?? null;
return [band, category, power];
}
type RankField = "rank_overall" | "rank_in_category";
function isCheckCategory(item: LogResult) {
const name = item.category?.name?.toLowerCase() ?? "";
return name.includes("check");
}
function isPowerA(item: LogResult) {
const name = item.power_category?.name?.toLowerCase() ?? "";
return name === "a";
}
function sortResults(items: LogResult[], rankField: RankField) {
return [...items].sort((a, b) => {
const ra = a[rankField] ?? Number.MAX_SAFE_INTEGER;
const rb = b[rankField] ?? Number.MAX_SAFE_INTEGER;
if (ra !== rb) return ra - rb;
const sa = a.official_score ?? 0;
const sb = b.official_score ?? 0;
if (sa !== sb) return sb - sa;
const qa = a.valid_qso_count ?? 0;
const qb = b.valid_qso_count ?? 0;
return qb - qa;
});
}
function formatNumber(value: number | null | undefined) {
if (value === null || value === undefined) return "—";
if (Number.isInteger(value)) return value.toString();
return value.toFixed(1);
}
function formatRatio(score?: number | null, qso?: number | null) {
if (!score || !qso || qso === 0) return "—";
return (score / qso).toFixed(2);
}
function renderGroup(
group: GroupedResults,
title: string,
rankField: RankField,
includePowerInHeading: boolean,
overrides: Record<number, LogOverride>,
onEdit: (logId: number) => void
) {
if (group.length === 0) return [];
return group.map(({ key, items }) => {
const sample = items[0];
const [bandName, categoryName, powerName] = resolveNames(sample);
const headingParts = [bandName, categoryName, title];
if (includePowerInHeading && !isCheckCategory(sample)) {
headingParts.push(powerName);
}
const heading = headingParts.filter(Boolean).join(" ");
const sorted = sortResults(items, rankField);
return (
<Card key={key} className="mb-3">
<CardHeader className="py-2">
<div className="flex items-center justify-between w-full gap-2">
<span className="text-md font-semibold">{heading}</span>
</div>
</CardHeader>
<Divider />
<CardBody className="py-2">
<Table
radius="sm"
isCompact
aria-label={heading}
removeWrapper
fullWidth
classNames={{
th: "py-1 px-1 text-[11px] leading-tight",
td: "py-0.5 px-1 text-[11px] leading-tight bg-transparent",
}}
>
<TableHeader>
<TableColumn className="whitespace-nowrap">Pořadí</TableColumn>
<TableColumn className="whitespace-nowrap">Značka</TableColumn>
<TableColumn className="whitespace-nowrap">Lokátor</TableColumn>
<TableColumn className="whitespace-nowrap">Kategorie</TableColumn>
<TableColumn className="whitespace-nowrap">Pásmo</TableColumn>
<TableColumn className="whitespace-nowrap">Výkon [W]</TableColumn>
<TableColumn className="whitespace-nowrap">Výkonová kategorie</TableColumn>
<TableColumn className="whitespace-nowrap">Body celkem</TableColumn>
<TableColumn className="whitespace-nowrap">Počet QSO</TableColumn>
<TableColumn className="whitespace-nowrap">Body/QSO</TableColumn>
<TableColumn className="whitespace-nowrap">ODX</TableColumn>
<TableColumn className="whitespace-nowrap">Anténa</TableColumn>
<TableColumn className="whitespace-nowrap">Ant. height</TableColumn>
<TableColumn className="whitespace-nowrap">Status</TableColumn>
<TableColumn className="whitespace-nowrap">Komentář rozhodčího</TableColumn>
<TableColumn className="whitespace-nowrap">Akce</TableColumn>
</TableHeader>
<TableBody emptyContent="No rows to display" items={sorted} className="text-[11px] leading-tight">
{(item) => {
const override = overrides[item.log_id];
const rowHighlight =
!!override &&
(override.forced_log_status && override.forced_log_status !== "AUTO" ||
override.forced_band_id ||
override.forced_category_id ||
override.forced_power_category_id ||
override.forced_power_w !== null && override.forced_power_w !== undefined ||
override.forced_sixhr_category !== null && override.forced_sixhr_category !== undefined);
return (
<TableRow key={item.id} className={rowHighlight ? "bg-warning-100 hover:bg-warning-300" : "hover:bg-default-200"}>
<TableCell className="whitespace-nowrap">{item[rankField] ?? "—"}</TableCell>
<TableCell className="whitespace-nowrap">{item.log?.pcall ?? "—"}</TableCell>
<TableCell className="whitespace-nowrap">
{item.log?.pwwlo ?? item.log?.locator ?? "—"}
</TableCell>
<TableCell className="whitespace-nowrap">{item.category?.name ?? "—"}</TableCell>
<TableCell className="whitespace-nowrap">{item.band?.name ?? "—"}</TableCell>
<TableCell className="whitespace-nowrap">
{formatNumber(item.log?.power_watt)}
</TableCell>
<TableCell className="whitespace-nowrap">
{item.powerCategory?.name ?? item.power_category?.name ?? "—"}
</TableCell>
<TableCell className="whitespace-nowrap">{formatNumber(item.official_score)}</TableCell>
<TableCell className="whitespace-nowrap">{item.valid_qso_count ?? "—"}</TableCell>
<TableCell className="whitespace-nowrap">
{formatRatio(item.official_score, item.valid_qso_count)}
</TableCell>
<TableCell className="whitespace-nowrap">{item.log?.codxc ?? "—"}</TableCell>
<TableCell className="whitespace-nowrap">{item.log?.sante ?? "—"}</TableCell>
<TableCell className="whitespace-nowrap">{item.log?.santh ?? "—"}</TableCell>
<TableCell className="whitespace-nowrap">{item.status ?? "—"}</TableCell>
<TableCell className="whitespace-nowrap">{override?.reason ?? "—"}</TableCell>
<TableCell className="whitespace-nowrap">
<Button
isIconOnly
size="sm"
variant="light"
className="min-w-0 w-4 h-4 p-0"
onPress={() => onEdit(item.log_id)}
aria-label="Upravit override"
>
<PencilIcon className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
}}
</TableBody>
</Table>
</CardBody>
</Card>
);
});
}
function renderOverallWithPowers(
overalls: GroupedResults,
powers: GroupedResults,
title: string,
overrides: Record<number, LogOverride>,
onEdit: (logId: number) => void
) {
if (overalls.length === 0) return null;
const powerIndex = powers.reduce<Record<string, GroupedResults>>((acc, group) => {
const [bandId, categoryId] = group.key.split("|");
const key = `${bandId}|${categoryId}`;
acc[key] = acc[key] || [];
acc[key].push(group);
return acc;
}, {});
return sortGroups(overalls).flatMap(({ key, items }) => {
const [bandId, categoryId] = key.split("|");
const powerGroups = sortGroups(powerIndex[`${bandId}|${categoryId}`] ?? []);
return [
...renderGroup([{ key, items }], title, "rank_overall", false, overrides, onEdit),
...renderGroup(powerGroups, "", "rank_in_category", true, overrides, onEdit),
];
});
}
export default React.memo(function RoundEvaluationLogOverridesTable({
items,
overrides,
onEdit,
}: Props) {
const grouped = useMemo(() => {
const isSixhr = (r: LogResult) => (r.log?.sixhr_category ?? false) === true;
const sixh = items.filter((r) => isSixhr(r));
const standard = items.filter((r) => !isSixhr(r));
const powerEligible = standard.filter(
(r) => !isCheckCategory(r) && !isPowerA(r) && r.power_category && r.rank_in_category !== null
);
const groupOverall = (list: LogResult[]) =>
groupBy(list, (r) => `${r.band?.id ?? "null"}|${r.category?.id ?? "null"}`);
const groupPower = (list: LogResult[]) =>
groupBy(
list,
(r) =>
`${r.band?.id ?? "null"}|${r.category?.id ?? "null"}|${r.power_category?.id ?? "null"}`
);
return {
sixhOverall: groupOverall(sixh),
standardOverall: groupOverall(standard),
standardPower: groupPower(powerEligible),
};
}, [items]);
return (
<>
{renderOverallWithPowers(grouped.standardOverall, grouped.standardPower, "", overrides, onEdit)}
{renderOverallWithPowers(grouped.sixhOverall, [], "6H", overrides, onEdit)}
</>
);
});

View File

@@ -0,0 +1,28 @@
import { Modal, ModalBody, ModalContent, ModalHeader } from "@heroui/react";
import { useTranslation } from "react-i18next";
import LogDetail from "@/components/LogDetail";
type RoundEvaluationOverrideDetailModalProps = {
isOpen: boolean;
logId: number | null;
onOpenChange: (isOpen: boolean) => void;
};
export default function RoundEvaluationOverrideDetailModal({
isOpen,
logId,
onOpenChange,
}: RoundEvaluationOverrideDetailModalProps) {
const { t } = useTranslation("common");
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="6xl" scrollBehavior="inside">
<ModalContent>
<ModalHeader className="flex flex-col gap-1">
{t("override_detail_title") ?? "Detail logu"}
</ModalHeader>
<ModalBody>{logId ? <LogDetail logId={logId} /> : null}</ModalBody>
</ModalContent>
</Modal>
);
}

View File

@@ -0,0 +1,249 @@
import { memo, useEffect, useState } from "react";
import { Button, Select, SelectItem, Textarea, type Selection } from "@heroui/react";
import { useTranslation } from "react-i18next";
import type { LogItem, LogOverride, OverrideForm } from "@/components/RoundEvaluationOverrides.types";
type RoundEvaluationOverrideRowProps = {
log: LogItem;
form?: OverrideForm;
override?: LogOverride;
bands: { id: number; name: string }[];
categories: { id: number; name: string }[];
powerCategories: { id: number; name: string }[];
onFieldChange: (logId: number, field: keyof OverrideForm, value: string) => void;
onReasonCommit: (logId: number, reason: string) => void;
onSave: (logId: number, reason?: string) => void;
onOpenDetail: (logId: number) => void;
};
const getFirstSelection = (keys: Selection) => {
if (keys === "all") return "";
const [first] = Array.from(keys);
return typeof first === "string" || typeof first === "number" ? String(first) : "";
};
const highlightSelectClassNames = (isHighlighted: boolean) =>
isHighlighted
? {
trigger: "border-warning-400 bg-warning-50",
}
: undefined;
const RoundEvaluationOverrideRow = memo(function RoundEvaluationOverrideRow({
log,
form,
override,
bands,
categories,
powerCategories,
onFieldChange,
onReasonCommit,
onSave,
onOpenDetail,
}: RoundEvaluationOverrideRowProps) {
const { t } = useTranslation("common");
const highlightStatus = !!override?.forced_log_status && override.forced_log_status !== "AUTO";
const highlightBand = override?.forced_band_id !== null && override?.forced_band_id !== undefined;
const highlightCategory =
override?.forced_category_id !== null && override?.forced_category_id !== undefined;
const highlightPower =
override?.forced_power_category_id !== null && override?.forced_power_category_id !== undefined;
const highlightSixhr =
override?.forced_sixhr_category !== null && override?.forced_sixhr_category !== undefined;
const [localReason, setLocalReason] = useState(form?.reason ?? override?.reason ?? "");
useEffect(() => {
setLocalReason(form?.reason ?? override?.reason ?? "");
}, [form?.reason, override?.reason]);
return (
<div className="rounded border border-divider px-2 py-1 text-xs">
<div className="flex flex-wrap items-center gap-2 mb-2 text-sm">
<span className="font-semibold">{log.pcall ?? "—"}</span>
<span>PBAND: {log.pband ?? "—"}</span>
<span>PSECT: {log.psect ?? "—"}</span>
<span>SPowe: {log.power_watt ?? "—"}</span>
<span>PWWLo: {log.pwwlo ?? "—"}</span>
<Button
type="button"
size="sm"
variant="light"
className="h-7 min-h-[28px] text-xs px-2"
onPress={() => onOpenDetail(log.id)}
>
{t("override_detail") ?? "Detail"}
</Button>
</div>
{override?.reason && (
<div className="mb-2 text-foreground-500 text-sm">
{t("override_reason_prefix") ?? "Důvod"}: {override.reason}
</div>
)}
<div className="grid gap-1 md:grid-cols-7">
<Select
label={t("override_status_label") ?? "Status"}
aria-label="Status"
size="sm"
variant="bordered"
classNames={{
...highlightSelectClassNames(highlightStatus),
trigger: "h-8 min-h-[32px]",
}}
selectedKeys={new Set([form?.status ?? "AUTO"])}
onSelectionChange={(keys) =>
onFieldChange(log.id, "status", getFirstSelection(keys) || "AUTO")
}
selectionMode="single"
disallowEmptySelection={true}
>
{[
{ value: "AUTO", label: t("override_status_auto") ?? "AUTO" },
{ value: "IGNORED", label: t("override_status_ignored") ?? "IGNORED" },
{ value: "CHECK", label: t("override_status_check") ?? "CHECK" },
{ value: "OK", label: t("override_status_ok") ?? "OK" },
{ value: "DQ", label: t("override_status_dq") ?? "DQ" },
].map((opt) => (
<SelectItem key={opt.value} textValue={opt.label}>
{opt.label}
</SelectItem>
))}
</Select>
<Select
label={t("override_band_label") ?? "Band"}
aria-label="Band"
size="sm"
variant="bordered"
classNames={{
...highlightSelectClassNames(highlightBand),
trigger: "h-8 min-h-[32px]",
}}
selectedKeys={new Set([form?.bandId ? form.bandId : "auto"])}
onSelectionChange={(keys) => {
const value = getFirstSelection(keys);
onFieldChange(log.id, "bandId", value === "auto" ? "" : value);
}}
selectionMode="single"
disallowEmptySelection={true}
>
<SelectItem key="auto" textValue={t("override_auto") ?? "AUTO"}>
{t("override_auto") ?? "AUTO"}
</SelectItem>
{bands.map((band) => (
<SelectItem key={String(band.id)} textValue={band.name}>
{band.name}
</SelectItem>
))}
</Select>
<Select
label={t("override_category_label") ?? "Kategorie"}
aria-label="Kategorie"
size="sm"
variant="bordered"
classNames={{
...highlightSelectClassNames(highlightCategory),
trigger: "h-8 min-h-[32px]",
}}
selectedKeys={new Set([form?.categoryId ? form.categoryId : "auto"])}
onSelectionChange={(keys) => {
const value = getFirstSelection(keys);
onFieldChange(log.id, "categoryId", value === "auto" ? "" : value);
}}
selectionMode="single"
disallowEmptySelection={true}
>
<SelectItem key="auto" textValue={t("override_auto") ?? "AUTO"}>
{t("override_auto") ?? "AUTO"}
</SelectItem>
{categories.map((cat) => (
<SelectItem key={String(cat.id)} textValue={cat.name}>
{cat.name}
</SelectItem>
))}
</Select>
<Select
aria-label="Výkon"
size="sm"
label={t("override_power_label") ?? "Výkon"}
variant="bordered"
classNames={{
...highlightSelectClassNames(highlightPower),
trigger: "h-8 min-h-[32px]",
}}
selectedKeys={new Set([form?.powerCategoryId ? form.powerCategoryId : "auto"])}
onSelectionChange={(keys) => {
const value = getFirstSelection(keys);
onFieldChange(log.id, "powerCategoryId", value === "auto" ? "" : value);
}}
selectionMode="single"
disallowEmptySelection={true}
>
<SelectItem key="auto" textValue={t("override_auto") ?? "AUTO"}>
{t("override_auto") ?? "AUTO"}
</SelectItem>
{powerCategories.map((cat) => (
<SelectItem key={String(cat.id)} textValue={cat.name}>
{cat.name}
</SelectItem>
))}
</Select>
<Select
aria-label="6H"
size="sm"
label={t("override_sixhr_label") ?? "6H"}
variant="bordered"
classNames={{
...highlightSelectClassNames(highlightSixhr),
trigger: "h-8 min-h-[32px]",
}}
selectedKeys={new Set([form?.sixhrCategory ? form.sixhrCategory : "auto"])}
onSelectionChange={(keys) => {
const value = getFirstSelection(keys);
onFieldChange(log.id, "sixhrCategory", value === "auto" ? "" : value);
}}
selectionMode="single"
disallowEmptySelection={true}
>
<SelectItem key="auto" textValue={t("override_auto") ?? "AUTO"}>
{t("override_auto") ?? "AUTO"}
</SelectItem>
<SelectItem key="1" textValue={t("yes") ?? "Ano"}>
{t("yes") ?? "Ano"}
</SelectItem>
<SelectItem key="0" textValue={t("no") ?? "Ne"}>
{t("no") ?? "Ne"}
</SelectItem>
</Select>
<Textarea
label={t("override_reason_label") ?? "Důvod změny"}
size="sm"
variant="bordered"
minRows={1}
value={localReason}
onChange={(e) => setLocalReason(e.target.value)}
onBlur={() => onReasonCommit(log.id, localReason)}
classNames={{
inputWrapper: "h-8 min-h-[32px] py-0",
input: "h-5 text-xs",
}}
/>
<div className="flex items-center gap-3">
<Button
type="button"
size="sm"
color="primary"
isDisabled={form?.saving}
onPress={() => onSave(log.id, localReason)}
>
{form?.saving
? t("override_saving") ?? "Ukládám…"
: t("override_save") ?? "Uložit"}
</Button>
{form?.error && <span className="text-red-600">{form.error}</span>}
{form?.success && <span className="text-green-600">{form.success}</span>}
</div>
</div>
</div>
);
});
export default RoundEvaluationOverrideRow;

View File

@@ -0,0 +1,354 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import axios from "axios";
import { useDisclosure } from "@heroui/react";
import { useTranslation } from "react-i18next";
import RoundEvaluationOverrideRow from "@/components/RoundEvaluationOverrideRow";
import RoundEvaluationOverrideDetailModal from "@/components/RoundEvaluationOverrideDetailModal";
import RoundEvaluationOverridesPagination from "@/components/RoundEvaluationOverridesPagination";
import type {
LogItem,
LogOverride,
OverrideForm,
RoundDetail,
} from "@/components/RoundEvaluationOverrides.types";
type PaginatedResponse<T> = {
data: T[];
current_page: number;
last_page: number;
total: number;
};
type Props = {
roundId: number | null;
evaluationRunId: number;
};
export default function RoundEvaluationOverrides({ roundId, evaluationRunId }: Props) {
const [logs, setLogs] = useState<LogItem[]>([]);
const [overrides, setOverrides] = useState<Record<number, LogOverride>>({});
const [forms, setForms] = useState<Record<number, OverrideForm>>({});
const [page, setPage] = useState(1);
const [lastPage, setLastPage] = useState(1);
const [roundDetail, setRoundDetail] = useState<RoundDetail | null>(null);
const [loading, setLoading] = useState(false);
const [activeLogId, setActiveLogId] = useState<number | null>(null);
const perPage = 30;
const { isOpen, onOpen, onOpenChange } = useDisclosure();
const { t } = useTranslation("common");
const bands = useMemo(() => roundDetail?.bands ?? [], [roundDetail?.bands]);
const categories = useMemo(() => roundDetail?.categories ?? [], [roundDetail?.categories]);
const powerCategories = useMemo(
() => roundDetail?.powerCategories ?? roundDetail?.power_categories ?? [],
[roundDetail?.powerCategories, roundDetail?.power_categories]
);
useEffect(() => {
if (!roundId) return;
let active = true;
(async () => {
try {
const res = await axios.get<RoundDetail>(`/api/rounds/${roundId}`, {
headers: { Accept: "application/json" },
withCredentials: true,
});
if (!active) return;
setRoundDetail(res.data);
} catch {
if (!active) return;
setRoundDetail(null);
}
})();
return () => {
active = false;
};
}, [roundId]);
useEffect(() => {
if (!roundId) return;
let active = true;
(async () => {
try {
setLoading(true);
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;
setLogs(res.data.data);
setLastPage(res.data.last_page ?? 1);
} catch {
if (!active) return;
setLogs([]);
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [roundId, page]);
useEffect(() => {
let active = true;
(async () => {
try {
const res = await axios.get<PaginatedResponse<LogOverride>>("/api/log-overrides", {
headers: { Accept: "application/json" },
params: { evaluation_run_id: evaluationRunId, per_page: 500 },
withCredentials: true,
});
if (!active) return;
const map: Record<number, LogOverride> = {};
res.data.data.forEach((item) => {
map[item.log_id] = item;
});
setOverrides(map);
} catch {
if (!active) return;
setOverrides({});
}
})();
return () => {
active = false;
};
}, [evaluationRunId]);
useEffect(() => {
if (!logs.length) return;
setForms((prev) => {
const next = { ...prev };
for (const log of logs) {
const override = overrides[log.id];
if (!next[log.id]) {
next[log.id] = {
status: override?.forced_log_status ?? "AUTO",
bandId: override?.forced_band_id ? String(override.forced_band_id) : "",
categoryId: override?.forced_category_id ? String(override.forced_category_id) : "",
powerCategoryId: override?.forced_power_category_id ? String(override.forced_power_category_id) : "",
sixhrCategory:
override?.forced_sixhr_category === null || override?.forced_sixhr_category === undefined
? ""
: override.forced_sixhr_category
? "1"
: "0",
reason: override?.reason ?? "",
saving: false,
error: null,
success: null,
};
}
}
return next;
});
}, [logs, overrides]);
useEffect(() => {
if (!isOpen) {
setActiveLogId(null);
}
}, [isOpen]);
const openDetail = useCallback(
(logId: number) => {
setActiveLogId(logId);
onOpen();
},
[onOpen]
);
const handleFieldChange = useCallback(
(logId: number, field: keyof OverrideForm, value: string) => {
setForms((prev) => ({
...prev,
[logId]: {
...prev[logId],
[field]: value,
error: null,
success: null,
},
}));
},
[]
);
const commitReason = useCallback((logId: number, reason: string) => {
setForms((prev) => ({
...prev,
[logId]: {
...prev[logId],
reason,
error: null,
success: null,
},
}));
}, []);
const saveOverride = async (logId: number, reasonOverride?: string) => {
const form = forms[logId];
if (!form) return;
const override = overrides[logId];
const hasStatus = form.status && form.status !== "AUTO";
const hasBand = form.bandId !== "";
const hasCategory = form.categoryId !== "";
const hasPower = form.powerCategoryId !== "";
const hasSixhr = form.sixhrCategory !== "";
const hasAny = hasStatus || hasBand || hasCategory || hasPower || hasSixhr;
if (reasonOverride !== undefined) {
commitReason(logId, reasonOverride);
}
const reason = (reasonOverride ?? form.reason).trim();
const baseline = {
status: override?.forced_log_status ?? "AUTO",
bandId: override?.forced_band_id ? String(override.forced_band_id) : "",
categoryId: override?.forced_category_id ? String(override.forced_category_id) : "",
powerCategoryId: override?.forced_power_category_id ? String(override.forced_power_category_id) : "",
sixhrCategory:
override?.forced_sixhr_category === null || override?.forced_sixhr_category === undefined
? ""
: override.forced_sixhr_category
? "1"
: "0",
};
const hasChanges =
form.status !== baseline.status ||
form.bandId !== baseline.bandId ||
form.categoryId !== baseline.categoryId ||
form.powerCategoryId !== baseline.powerCategoryId ||
form.sixhrCategory !== baseline.sixhrCategory;
setForms((prev) => ({
...prev,
[logId]: { ...prev[logId], saving: true, error: null, success: null },
}));
try {
if (!hasChanges && !override) {
setForms((prev) => ({
...prev,
[logId]: { ...prev[logId], saving: false, success: t("override_no_changes") ?? "Bez změn." },
}));
return;
}
if (!hasChanges && override) {
setForms((prev) => ({
...prev,
[logId]: { ...prev[logId], saving: false, success: t("override_no_changes") ?? "Bez změn." },
}));
return;
}
if (!reason) {
setForms((prev) => ({
...prev,
[logId]: {
...prev[logId],
saving: false,
error: t("override_reason_required") ?? "Doplň důvod změny.",
},
}));
return;
}
const payload = {
evaluation_run_id: evaluationRunId,
log_id: logId,
forced_log_status: form.status || "AUTO",
forced_band_id: form.bandId ? Number(form.bandId) : null,
forced_category_id: form.categoryId ? Number(form.categoryId) : null,
forced_power_category_id: form.powerCategoryId ? Number(form.powerCategoryId) : null,
forced_sixhr_category: form.sixhrCategory === "" ? null : form.sixhrCategory === "1",
reason,
};
if (override) {
const res = await axios.put<LogOverride>(`/api/log-overrides/${override.id}`, payload, {
headers: { Accept: "application/json" },
withCredentials: true,
withXSRFToken: true,
});
setOverrides((prev) => ({ ...prev, [logId]: res.data }));
} else {
const res = await axios.post<LogOverride>("/api/log-overrides", payload, {
headers: { Accept: "application/json" },
withCredentials: true,
withXSRFToken: true,
});
setOverrides((prev) => ({ ...prev, [logId]: res.data }));
}
setForms((prev) => ({
...prev,
[logId]: { ...prev[logId], saving: false, success: t("override_saved") ?? "Uloženo." },
}));
} catch (e: any) {
const msg =
e?.response?.data?.message ||
(t("override_save_failed") ?? "Nepodařilo se uložit override.");
setForms((prev) => ({
...prev,
[logId]: { ...prev[logId], saving: false, error: msg },
}));
}
};
if (!roundId) return null;
return (
<div className="pt-2 border-t border-divider">
<div className="font-semibold text-sm mb-2">
{t("override_pre_match_title") ?? "Ruční zásahy před matchingem"}
</div>
<div className="text-xs text-foreground-500 mb-2">
{t("override_pre_match_hint") ??
"Změny se projeví po kliknutí na „Pokračovat“. IGNORED vyřadí log z matchingu."}
</div>
{loading && (
<div className="text-xs text-foreground-600">
{t("override_loading_logs") ?? "Načítám logy…"}
</div>
)}
{!loading && logs.length === 0 && (
<div className="text-xs text-foreground-600">
{t("override_no_logs") ?? "Žádné logy k úpravě."}
</div>
)}
{logs.length > 0 && (
<div className="space-y-3">
<div className="grid gap-2">
{logs.map((log) => (
<RoundEvaluationOverrideRow
key={log.id}
log={log}
form={forms[log.id]}
override={overrides[log.id]}
bands={bands}
categories={categories}
powerCategories={powerCategories}
onFieldChange={handleFieldChange}
onReasonCommit={commitReason}
onSave={saveOverride}
onOpenDetail={openDetail}
/>
))}
</div>
<RoundEvaluationOverridesPagination
page={page}
lastPage={lastPage}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(lastPage, p + 1))}
/>
</div>
)}
<RoundEvaluationOverrideDetailModal
isOpen={isOpen}
logId={activeLogId}
onOpenChange={onOpenChange}
/>
</div>
);
}

View File

@@ -0,0 +1,40 @@
export type LogItem = {
id: number;
pcall?: string | null;
pband?: string | null;
psect?: string | null;
pwwlo?: string | null;
power_watt?: number | null;
};
export type RoundDetail = {
id: number;
bands?: { id: number; name: string }[];
categories?: { id: number; name: string }[];
power_categories?: { id: number; name: string }[];
powerCategories?: { id: number; name: string }[];
};
export type LogOverride = {
id: number;
evaluation_run_id: number;
log_id: number;
forced_log_status?: string | null;
forced_band_id?: number | null;
forced_category_id?: number | null;
forced_power_category_id?: number | null;
forced_sixhr_category?: boolean | null;
reason?: string | null;
};
export type OverrideForm = {
status: string;
bandId: string;
categoryId: string;
powerCategoryId: string;
sixhrCategory: string;
reason: string;
saving: boolean;
error?: string | null;
success?: string | null;
};

View File

@@ -0,0 +1,36 @@
import { Button } from "@heroui/react";
import { useTranslation } from "react-i18next";
type RoundEvaluationOverridesPaginationProps = {
page: number;
lastPage: number;
onPrev: () => void;
onNext: () => void;
};
export default function RoundEvaluationOverridesPagination({
page,
lastPage,
onPrev,
onNext,
}: RoundEvaluationOverridesPaginationProps) {
const { t } = useTranslation("common");
return (
<div className="flex items-center gap-2 text-xs">
<Button type="button" size="sm" variant="bordered" onPress={onPrev} isDisabled={page <= 1}>
{t("override_prev_page") ?? "Předchozí"}
</Button>
<span>{t("override_page_label", { page, lastPage }) ?? `Strana ${page} / ${lastPage}`}</span>
<Button
type="button"
size="sm"
variant="bordered"
onPress={onNext}
isDisabled={page >= lastPage}
>
{t("override_next_page") ?? "Další"}
</Button>
</div>
);
}

View File

@@ -0,0 +1,153 @@
import { useState } from "react";
import { Card, CardBody, CardHeader, Divider } from "@heroui/react";
import RoundEvaluationOverrides from "@/components/RoundEvaluationOverrides";
import RoundEvaluationQsoOverrides from "@/components/RoundEvaluationQsoOverrides";
import RoundEvaluationLogOverrides from "@/components/RoundEvaluationLogOverrides";
import EvaluationStatusSummary from "@/components/EvaluationStatusSummary";
import EvaluationActions from "@/components/EvaluationActions";
import EvaluationEventsList from "@/components/EvaluationEventsList";
import EvaluationHistoryPanel from "@/components/EvaluationHistoryPanel";
import EvaluationStepsList from "@/components/EvaluationStepsList";
import useRoundEvaluationRun from "@/hooks/useRoundEvaluationRun";
import { useTranslation } from "react-i18next";
type RoundEvaluationPanelProps = {
roundId: number | null;
};
export default function RoundEvaluationPanel({ roundId }: RoundEvaluationPanelProps) {
const { t } = useTranslation("common");
const {
run,
runs,
events,
loading,
actionLoading,
message,
error,
hasLoaded,
canStart,
canResume,
canCancel,
isOfficialRun,
currentStepIndex,
isSucceeded,
stepProgressPercent,
formatEventTime,
handleStart,
handleStartIncremental,
handleResume,
handleCancel,
handleSetResultType,
} = useRoundEvaluationRun(roundId);
const [historyOpen, setHistoryOpen] = useState(false);
return (
<Card className="mt-4">
<CardHeader>
<span className="text-md font-semibold">Vyhodnocování kola</span>
</CardHeader>
<Divider />
<CardBody className="grid gap-4 md:grid-cols-1">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-3">
<EvaluationStatusSummary
loading={loading}
hasLoaded={hasLoaded}
run={run}
isOfficialRun={isOfficialRun}
stepProgressPercent={stepProgressPercent}
/>
<EvaluationActions
canStart={canStart}
canStartIncremental={canStart}
canResume={canResume}
canCancel={!!canCancel}
actionLoading={actionLoading}
message={message}
error={error}
onStart={handleStart}
onStartIncremental={handleStartIncremental}
onResume={handleResume}
onCancel={handleCancel}
/>
<div className="text-xs text-foreground-500">
{t("evaluation_incremental_hint") ?? "Spustit znovu převezme overrides z posledního běhu."}
</div>
{run && isSucceeded && (
<div className="flex flex-wrap gap-2 text-sm">
<span className="font-semibold">Označit výsledky:</span>
<button
type="button"
className={[
"rounded border px-2 py-1 hover:bg-foreground-100",
run.result_type === "TEST"
? "border-foreground-400 bg-foreground-100 font-semibold"
: "border-divider",
].join(" ")}
onClick={() => handleSetResultType("TEST")}
disabled={actionLoading}
>
Testovací
</button>
<button
type="button"
className={[
"rounded border px-2 py-1 hover:bg-foreground-100",
run.result_type === "PRELIMINARY"
? "border-foreground-400 bg-foreground-100 font-semibold"
: "border-divider",
].join(" ")}
onClick={() => handleSetResultType("PRELIMINARY")}
disabled={actionLoading}
>
Předběžné
</button>
<button
type="button"
className={[
"rounded border px-2 py-1 hover:bg-foreground-100",
run.result_type === "FINAL"
? "border-foreground-400 bg-foreground-100 font-semibold"
: "border-divider",
].join(" ")}
onClick={() => handleSetResultType("FINAL")}
disabled={actionLoading}
>
Finální
</button>
</div>
)}
<EvaluationEventsList events={events} formatEventTime={formatEventTime} />
</div>
<div className="space-y-2 text-sm text-foreground-700">
<EvaluationHistoryPanel
runs={runs}
historyOpen={historyOpen}
onToggle={() => setHistoryOpen((prev) => !prev)}
formatEventTime={formatEventTime}
/>
<EvaluationStepsList
run={run}
isOfficialRun={isOfficialRun}
currentStepIndex={currentStepIndex}
isSucceeded={isSucceeded}
/>
</div>
</div>
<div>
{run?.status === "WAITING_REVIEW_INPUT" && (
<RoundEvaluationOverrides roundId={roundId} evaluationRunId={run.id} />
)}
{run?.status === "WAITING_REVIEW_MATCH" && (
<RoundEvaluationQsoOverrides roundId={roundId} evaluationRunId={run.id} />
)}
{run?.status === "WAITING_REVIEW_SCORE" && (
<RoundEvaluationLogOverrides roundId={roundId} evaluationRunId={run.id} />
)}
</div>
</CardBody>
</Card>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,325 @@
import React from "react";
import { Button, Input, Select, SelectItem } from "@heroui/react";
import { type TFunction } from "i18next";
import { type EdiHeaderForm, type PSectResult } from "@/types/edi";
import { type BandOption } from "@/hooks/useRoundMeta";
type HeaderFormFieldsProps = {
headerForm: EdiHeaderForm;
setHeaderForm: React.Dispatch<React.SetStateAction<EdiHeaderForm>>;
markEdited: () => void;
onPbandChange: (value: string) => void;
onSpoweChange: (value: string) => void;
onApplyPsectCanonical: () => void;
t: TFunction<"common">;
tNameInvalid: boolean;
tDateError: string | null;
pCallEmpty: boolean;
pCallInvalid: boolean;
pwwloEmpty: boolean;
pwwloFormatInvalid: boolean;
pwwloInvalid: boolean;
psectMissing: boolean;
psectHasErrors: boolean;
psectValidation: PSectResult;
psectNeedsFormat: boolean;
psectCanonical: string | null;
shouldShowIaruAdjustButton: boolean;
bands: BandOption[];
bandsLoading: boolean;
bandsError: string | null;
bandUnknown: boolean;
bandMissing: boolean;
bandValue: string;
pbandInfo: string | null;
rhbbsWarning: string | null;
spoweInvalid: boolean;
spoweEmpty: boolean;
spoweTooLong: boolean;
spoweOverLimit: boolean;
spoweLimitError: string | null;
spoweInfo: string | null;
santeInvalid: boolean;
santeValue: string;
santeTooLong: boolean;
sectionNeedsSingle: boolean;
sectionNeedsMulti: boolean;
rcallInvalid: boolean;
mopeInvalid: boolean;
};
export default function HeaderFormFields({
headerForm,
setHeaderForm,
markEdited,
onPbandChange,
onSpoweChange,
onApplyPsectCanonical,
t,
tNameInvalid,
tDateError,
pCallEmpty,
pCallInvalid,
pwwloEmpty,
pwwloFormatInvalid,
pwwloInvalid,
psectMissing,
psectHasErrors,
psectValidation,
psectNeedsFormat,
psectCanonical,
shouldShowIaruAdjustButton,
bands,
bandsLoading,
bandsError,
bandUnknown,
bandMissing,
bandValue,
pbandInfo,
rhbbsWarning,
spoweInvalid,
spoweEmpty,
spoweTooLong,
spoweOverLimit,
spoweLimitError,
spoweInfo,
santeInvalid,
santeValue,
santeTooLong,
sectionNeedsSingle,
sectionNeedsMulti,
rcallInvalid,
mopeInvalid,
}: HeaderFormFieldsProps) {
return (
<>
<Input
label="TName"
value={headerForm.TName}
onChange={(e) => {
setHeaderForm((prev) => ({ ...prev, TName: e.target.value }));
markEdited();
}}
isInvalid={tNameInvalid}
errorMessage={tNameInvalid ? "TName je povinné." : undefined}
/>
<Input
label="TDate (YYYYMMDD;YYYYMMDD)"
value={headerForm.TDate}
onChange={(e) => {
setHeaderForm((prev) => ({ ...prev, TDate: e.target.value }));
markEdited();
}}
isInvalid={!!tDateError}
errorMessage={tDateError || undefined}
/>
<Input
label="PCall"
value={headerForm.PCall}
onChange={(e) => {
setHeaderForm((prev) => ({ ...prev, PCall: e.target.value }));
markEdited();
}}
isInvalid={pCallInvalid}
errorMessage={
pCallEmpty ? "PCall je povinné." : pCallInvalid ? "PCall musí být validní volací znak." : undefined
}
/>
<Input
label="PWWLo"
value={headerForm.PWWLo}
onChange={(e) => {
const normalized = e.target.value.toUpperCase();
setHeaderForm((prev) => ({ ...prev, PWWLo: normalized }));
markEdited();
}}
isInvalid={pwwloInvalid}
errorMessage={
pwwloEmpty
? "PWWLo je povinné."
: pwwloFormatInvalid
? "PWWLo musí mít 6 znaků ve formátu lokátoru (AA00AA)."
: undefined
}
/>
<div className="flex flex-col gap-1">
<div className="flex items-end gap-2">
<Input
className="flex-1"
label="PSect"
value={headerForm.PSect}
onChange={(e) => {
setHeaderForm((prev) => ({ ...prev, PSect: e.target.value }));
markEdited();
}}
isInvalid={psectMissing || psectHasErrors}
errorMessage={
psectMissing
? (t("upload_error_psect_required") as string) || "PSect je povinné."
: psectHasErrors
? psectValidation.errors.join(" ")
: undefined
}
/>
{shouldShowIaruAdjustButton && (
<Button
size="lg"
variant="flat"
className="shrink-0"
onPress={onApplyPsectCanonical}
isDisabled={psectMissing}
>
{psectCanonical ? (
<span className="flex flex-col text-left leading-tight">
<span>{(t("upload_psect_format_button") as string) || "Upravit kategorie podle IARU"}</span>
<span className="text-xs text-foreground-500">{psectCanonical}</span>
</span>
) : (
(t("upload_psect_format_button") as string) || "Upravit kategorie podle IARU"
)}
</Button>
)}
</div>
{psectNeedsFormat && (
<div className="text-xs text-foreground-500">
{(t("upload_error_psect_not_iaru") as string) || "PSect není ve formátu IARU."}
</div>
)}
</div>
<div className="flex flex-col gap-1">
{bands.length > 0 ? (
<Select
label="PBand"
selectedKeys={!bandUnknown && headerForm.PBand ? [headerForm.PBand] : []}
onChange={(e) => onPbandChange(e.target.value)}
isLoading={bandsLoading}
isInvalid={bandUnknown || bandMissing}
errorMessage={
bandUnknown
? `Neznámé pásmo "${bandValue}", vyber správnou hodnotu.`
: bandMissing
? "PBand není vyplněné, vyber pásmo ze seznamu."
: bandsError ?? undefined
}
>
{bands.map((band) => (
<SelectItem key={band.name} value={band.name}>
{band.name}
</SelectItem>
))}
</Select>
) : (
<Input
label="PBand"
value={headerForm.PBand}
onChange={(e) => onPbandChange(e.target.value)}
isDisabled={bandsLoading}
errorMessage={bandsError ?? undefined}
/>
)}
{pbandInfo && <div className="text-xs text-foreground-500">{pbandInfo}</div>}
</div>
<Input
label="RHBBS (e-mail)"
value={headerForm.RHBBS}
onChange={(e) => {
setHeaderForm((prev) => ({ ...prev, RHBBS: e.target.value }));
markEdited();
}}
isInvalid={false}
/>
{rhbbsWarning && <div className="text-xs text-amber-600">{rhbbsWarning}</div>}
<div className="flex flex-col gap-1">
<Input
label="SPowe"
value={headerForm.SPowe}
onChange={(e) => onSpoweChange(e.target.value)}
isInvalid={spoweInvalid}
errorMessage={
spoweEmpty
? (t("upload_error_spowe_required") as string) || "SPowe je povinné."
: spoweTooLong
? (t("upload_error_spowe_length") as string) || "SPowe může mít maximálně 12 znaků."
: spoweInvalid || spoweOverLimit
? (t("upload_error_spowe_format") as string) || "SPowe musí být celé číslo (bez jednotek)."
: undefined
}
/>
{spoweLimitError && <div className="text-xs text-red-600">{spoweLimitError}</div>}
{spoweInfo && <div className="text-xs text-foreground-500">{spoweInfo}</div>}
</div>
<div>
<Input
label="SAnte"
value={headerForm.SAnte}
onChange={(e) => {
setHeaderForm((prev) => ({ ...prev, SAnte: e.target.value }));
markEdited();
}}
isInvalid={santeInvalid}
errorMessage={
santeValue === "" ? (t("upload_error_sante_required") as string) || "SAnte je povinné." : undefined
}
/>
{santeTooLong && (
<div className="text-xs text-warning-600">
{t("upload_warn_sante_length") ??
"Ve výsledcích bude zobrazeno pouze 12 znaků, váš popis antény je delší a bude oříznut."}
</div>
)}
</div>
{(sectionNeedsSingle || sectionNeedsMulti) && (
<Input
label="RCall"
value={headerForm.RCall}
onChange={(e) => {
setHeaderForm((prev) => ({ ...prev, RCall: e.target.value }));
markEdited();
}}
isInvalid={rcallInvalid}
errorMessage={
headerForm.RCall.trim() === ""
? "RCall je povinné pro zvolenou kategorii."
: rcallInvalid
? "RCall musí být validní volací znak."
: undefined
}
/>
)}
{sectionNeedsMulti && (
<>
<Input
label="MOpe1"
value={headerForm.MOpe1}
onChange={(e) => {
setHeaderForm((prev) => ({ ...prev, MOpe1: e.target.value }));
markEdited();
}}
isInvalid={mopeInvalid}
errorMessage={
mopeInvalid
? (t("upload_error_mope_missing") as string) ||
"Vyplň alespoň MOpe1 nebo MOpe2 (můžeš zadat více značek oddělených mezerou/středníkem/čárkou)."
: undefined
}
/>
<Input
label="MOpe2"
value={headerForm.MOpe2}
onChange={(e) => {
setHeaderForm((prev) => ({ ...prev, MOpe2: e.target.value }));
markEdited();
}}
isInvalid={mopeInvalid}
errorMessage={
mopeInvalid
? (t("upload_error_mope_missing") as string) ||
"Vyplň alespoň MOpe1 nebo MOpe2 (můžeš zadat více značek oddělených mezerou/středníkem/čárkou)."
: undefined
}
/>
</>
)}
</>
);
}

View File

@@ -0,0 +1,32 @@
import React from "react";
type UploadMessagesProps = {
isEdiFile: boolean;
unsupportedInfo: string | null;
qsoCountWarning: string | null;
qsoCallsignInfo: string | null;
rhbbsWarning: string | null;
error: string | null;
success: string | null;
};
export default function UploadMessages({
isEdiFile,
unsupportedInfo,
qsoCountWarning,
qsoCallsignInfo,
rhbbsWarning,
error,
success,
}: UploadMessagesProps) {
return (
<>
{!isEdiFile && unsupportedInfo && <div className="text-sm text-red-600">{unsupportedInfo}</div>}
{qsoCountWarning && <div className="text-sm text-amber-600">{qsoCountWarning}</div>}
{qsoCallsignInfo && <div className="text-sm text-amber-600 whitespace-pre-line">{qsoCallsignInfo}</div>}
{rhbbsWarning && <div className="text-sm text-amber-600 whitespace-pre-line">{rhbbsWarning}</div>}
{error && <div className="text-sm text-red-600 whitespace-pre-line">{error}</div>}
{success && <div className="text-sm text-green-600">{success}</div>}
</>
);
}

View File

@@ -0,0 +1,194 @@
import { useEffect, useMemo, useState } from "react";
import axios from "axios";
import { Card, CardBody, Listbox, ListboxItem, type Selection } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { useLanguageStore } from "@/stores/languageStore";
import { useContestStore, type RoundSummary } from "@/stores/contestStore";
import { useContestRefreshStore } from "@/stores/contestRefreshStore";
import { useNavigate } from "react-router-dom";
type RoundItem = RoundSummary & {
contest_id: number;
description?: string | Record<string, string> | null;
};
type PaginatedResponse<T> = {
data: T[];
};
type RoundsOverviewProps = {
contestId: number | null;
onlyActive?: boolean;
showTests?: boolean;
className?: string;
roundsFromStore?: RoundSummary[] | null;
};
const resolveTranslation = (field: any, locale: string): string => {
if (!field) return "";
if (typeof field === "string") return field;
if (typeof field === "object") {
if (field[locale]) return field[locale];
if (field["en"]) return field["en"];
const first = Object.values(field)[0];
return typeof first === "string" ? first : "";
}
return String(field);
};
export default function RoundsOverview({
contestId,
onlyActive = false,
showTests = false,
className,
roundsFromStore = null,
}: RoundsOverviewProps) {
const { t } = useTranslation("common");
const locale = useLanguageStore((s) => s.locale);
const refreshKey = useContestRefreshStore((s) => s.refreshKey);
const navigate = useNavigate();
const [items, setItems] = useState<RoundItem[]>(roundsFromStore ?? []);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
const setSelectedRound = useContestStore((s) => s.setSelectedRound);
const selectedRound = useContestStore((s) => s.selectedRound);
// sync selection with store
useEffect(() => {
if (selectedRound) {
setSelectedKeys(new Set([String(selectedRound.id)]));
} else {
setSelectedKeys(new Set([]));
}
}, [selectedRound]);
useEffect(() => {
// pokud máme roundsFromStore s daty, použij je a nefetchuj
if (roundsFromStore && roundsFromStore.length > 0) {
setItems(roundsFromStore);
setLoading(false);
setError(null);
return;
}
if (!contestId) {
setItems([]);
setLoading(false);
setError(null);
return;
}
let active = true;
(async () => {
try {
setLoading(true);
setError(null);
const res = await axios.get<PaginatedResponse<RoundItem> | RoundItem[]>(
"/api/rounds",
{
withCredentials: true,
headers: { Accept: "application/json" },
params: {
lang: locale,
contest_id: contestId,
only_active: onlyActive ? 1 : 0,
include_tests: showTests ? 1 : 0,
},
}
);
if (!active) return;
const data = Array.isArray(res.data)
? res.data
: (res.data as PaginatedResponse<RoundItem>).data;
setItems(data);
} catch {
if (!active) return;
setError(t("unable_to_load_rounds") ?? "Nepodařilo se načíst seznam kol.");
} finally {
if (active) setLoading(false);
}
})();
return () => { active = false; };
}, [locale, t, refreshKey, roundsFromStore, contestId, onlyActive, showTests]);
const visibleItems = useMemo(() => items, [items]);
const isSelected = (id: string | number) => {
if (selectedKeys === "all") return false;
return Array.from(selectedKeys).some((k) => String(k) === String(id));
};
const handleSelectionChange = (keys: Selection) => {
setSelectedKeys(keys);
if (keys === "all") return;
const id = Array.from(keys)[0];
if (!id) {
if (selectedRound) {
setSelectedRound(null);
if (contestId != null) navigate(`/contests/${contestId}`);
}
return;
}
if (selectedRound && String(selectedRound.id) === String(id)) return;
const selected = visibleItems.find((r) => String(r.id) === String(id));
if (selected) {
setSelectedRound(selected);
navigate(`/contests/${selected.contest_id}/rounds/${selected.id}`);
}
};
return (
<Card className={className}>
<CardBody className="p-0">
{loading ? (
<div className="p-4">{t("rounds_loading") ?? "Načítám kola…"}</div>
) : error ? (
<div className="p-4 text-sm text-red-600">{error}</div>
) : visibleItems.length === 0 ? (
<div className="p-4">{t("rounds_empty") ?? "Žádná kola nejsou k dispozici."}</div>
) : (
<Listbox
aria-label="Rounds overview"
selectionMode="single"
selectedKeys={selectedKeys}
onSelectionChange={handleSelectionChange}
variant="flat"
>
{visibleItems.map((item) => (
<ListboxItem key={item.id} textValue={resolveTranslation(item.name, locale)}>
<div className="flex flex-col">
<span
className={`text-sm ${
isSelected(item.id) ? "font-semibold text-primary" : "font-medium"
}`}
>
{resolveTranslation(item.name, locale)}
</span>
<span
className={`text-xs line-clamp-2 ${
isSelected(item.id) ? "text-primary-500" : "text-foreground-500"
}`}
>
{resolveTranslation(item.description ?? null, locale) || "—"}
</span>
</div>
</ListboxItem>
))}
</Listbox>
)}
</CardBody>
</Card>
);
}

View File

@@ -0,0 +1,449 @@
import { useEffect, useState } from "react";
import axios from "axios";
import { useTranslation } from "react-i18next";
import { useLanguageStore } from "@/stores/languageStore";
import { useNavigate } from "react-router-dom";
import { useContestStore, type RoundSummary } from "@/stores/contestStore";
import { useContestRefreshStore } from "@/stores/contestRefreshStore";
import {
Table,
TableHeader,
TableColumn,
TableBody,
TableRow,
TableCell,
Pagination,
type Selection,
} from "@heroui/react";
type TranslatedField = string | Record<string, string>;
export type Round = {
id: number;
contest_id: number;
name: TranslatedField;
description?: TranslatedField | null;
bands?: { id: number; name: string }[];
categories?: { id: number; name: string }[];
power_categories?: { id: number; name: string }[];
is_active: boolean;
is_test: boolean;
is_sixhr: boolean;
is_mcr?: boolean;
start_time: string | null;
end_time: string | null;
logs_deadline: string | null;
preliminary_evaluation_run_id?: number | null;
official_evaluation_run_id?: number | null;
test_evaluation_run_id?: number | null;
contest?: {
id: number;
name: TranslatedField;
is_active?: boolean;
} | null;
};
type PaginatedResponse<T> = {
data: T[];
current_page?: number;
last_page?: number;
total?: number;
per_page?: number;
};
type RoundsTableProps = {
onSelectRound?: (round: Round) => void;
/** Show/hide the edit (pencil) actions column. Default: true */
enableEdit?: boolean;
/** Filter the list to only active rounds (is_active === true). Default: false */
onlyActive?: boolean;
/** Show/hide the is_active column. Default: true */
showActiveColumn?: boolean;
/** Show/hide contest column. Default: true */
showContestColumn?: boolean;
/** Filter rounds by contest id (round.contest_id). If undefined/null, no contest filter is applied. */
contestId?: number | null;
/** Zahrnout testovací kola. Default: false */
showTests?: boolean;
/** Skrýt neaktivní kola (nebo neaktivní závody) pro nepřihlášené. */
hideInactiveForGuests?: boolean;
/** Indikuje nepřihlášeného uživatele. */
isGuest?: boolean;
/** Kliknutí na řádek přenaviguje na detail kola. Default: false */
enableRowNavigation?: boolean;
/** Volitelný builder URL pro navigaci na detail kola. */
roundLinkBuilder?: (round: Round) => string;
/** Pokud máš data kol už ve store, můžeš je sem předat místo fetchování. */
roundsFromStore?: RoundSummary[] | null;
/** Volitelný nadpis tabulky. */
title?: string;
};
function resolveTranslation(
field: TranslatedField | null | undefined,
locale: string
): string {
if (!field) return "";
if (typeof field === "string") {
return field;
}
if (field[locale]) return field[locale];
if (field["en"]) return field["en"];
const first = Object.values(field)[0];
return first ?? "";
}
function formatDateTime(value: string | null, locale: string): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString(locale);
}
type ResultBadge = {
label: string;
className: string;
};
function getResultsBadge(round: Round, t: (key: string) => string): ResultBadge | null {
if (round.preliminary_evaluation_run_id) {
return {
label: t("results_type_preliminary") ?? "Předběžné výsledky",
className: "bg-emerald-100 text-emerald-800",
};
}
if (round.official_evaluation_run_id) {
return {
label: t("results_type_final") ?? "Finální výsledky",
className: "bg-emerald-600 text-white",
};
}
const now = Date.now();
const startTime = round.start_time ? Date.parse(round.start_time) : null;
const logsDeadline = round.logs_deadline ? Date.parse(round.logs_deadline) : null;
if (startTime && !Number.isNaN(startTime) && now < startTime) {
return {
label: t("results_type_not_started") ?? "Závod ještě nezačal",
className: "bg-gray-100 text-gray-700",
};
}
if (
logsDeadline &&
!Number.isNaN(logsDeadline) &&
now <= logsDeadline
) {
return {
label: t("results_type_log_collection_open") ?? "Otevřeno pro sběr logů",
className: "bg-sky-100 text-sky-800",
};
}
return {
label: t("results_type_declared") ?? "Deklarované výsledky",
className: "bg-amber-100 text-amber-800",
};
}
export default function RoundsTable({
onSelectRound,
enableEdit = true,
onlyActive = false,
showActiveColumn = true,
showContestColumn = true,
contestId = null,
showTests = false,
hideInactiveForGuests = false,
isGuest = false,
enableRowNavigation = false,
roundLinkBuilder,
roundsFromStore = null,
title,
}: RoundsTableProps) {
const { t } = useTranslation("common");
const locale = useLanguageStore((s) => s.locale);
const navigate = useNavigate();
const storeRounds = useContestStore((s) => s.selectedContestRounds);
const refreshKey = useContestRefreshStore((s) => s.refreshKey);
const [items, setItems] = useState<Round[]>(roundsFromStore ?? []);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [lastPage, setLastPage] = useState(1);
const [useLocalPaging, setUseLocalPaging] = useState(false);
const perPage = 20;
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
const filterByTests = (list: Round[]): Round[] =>
showTests ? list : list.filter((r) => !r.is_test);
const filterForGuests = (list: Round[]): Round[] => {
if (!hideInactiveForGuests || !isGuest) return list;
return list.filter((r) => r.is_active && (r.contest?.is_active ?? true));
};
useEffect(() => {
const providedRounds = roundsFromStore ?? (storeRounds.length > 0 ? storeRounds : null);
if (providedRounds && refreshKey === 0) {
const filtered = filterForGuests(filterByTests(providedRounds as Round[]));
setUseLocalPaging(true);
setItems(filtered);
setLastPage(Math.max(1, Math.ceil(filtered.length / perPage)));
setLoading(false);
setError(null);
return;
}
let active = true;
(async () => {
try {
setLoading(true);
setError(null);
setUseLocalPaging(false);
const res = await axios.get<PaginatedResponse<Round> | Round[]>(
"/api/rounds",
{
withCredentials: true,
headers: { Accept: "application/json" },
params: {
lang: locale,
contest_id: contestId ?? undefined,
only_active: onlyActive ? 1 : 0,
include_tests: showTests ? 1 : 0,
per_page: perPage,
page,
},
}
);
if (!active) return;
const data = Array.isArray(res.data)
? res.data
: (res.data as PaginatedResponse<Round>).data;
const filtered = filterForGuests(filterByTests(data));
setItems(filtered);
if (!Array.isArray(res.data)) {
const meta = res.data as PaginatedResponse<Round>;
const nextLastPage = meta.last_page ?? 1;
setLastPage(nextLastPage > 0 ? nextLastPage : 1);
} else {
setLastPage(1);
}
} catch {
if (!active) return;
setError(t("unable_to_load_rounds") ?? "Nepodařilo se načíst seznam kol.");
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [locale, t, roundsFromStore, storeRounds, contestId, onlyActive, showTests, refreshKey, page, perPage]);
useEffect(() => {
if (page > lastPage) {
setPage(lastPage);
}
}, [page, lastPage]);
const canEdit = Boolean(enableEdit && onSelectRound);
const visibleItems = useLocalPaging
? filterForGuests(items).slice((page - 1) * perPage, page * perPage)
: filterForGuests(items);
if (loading) return <div>{t("rounds_loading") ?? "Načítám kola…"}</div>;
if (error) return <div className="text-sm text-red-600">{error}</div>;
if (visibleItems.length === 0) {
return <div>{t("rounds_empty") ?? "Žádná kola nejsou k dispozici."}</div>;
}
const columns = [
{ key: "name", label: t("round_name") ?? "Kolo" },
...(showContestColumn
? [{ key: "contest", label: t("round_contest") ?? "Závod" }]
: []),
{ key: "schedule", label: t("round_schedule") ?? "Termín" },
...(showActiveColumn
? [{ key: "is_active", label: t("round_active") ?? "Aktivní" }]
: []),
...(canEdit ? [{ key: "actions", label: "" }] : []),
];
return (
<div className="space-y-2">
{title && <h3 className="text-md font-semibold">{title}</h3>}
<Table
aria-label="Rounds table"
selectionMode="single"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
<TableHeader columns={columns}>
{(column) => (
<TableColumn key={column.key} className="text-left">
{column.label}
</TableColumn>
)}
</TableHeader>
<TableBody items={visibleItems}>
{(item) => (
<TableRow
key={item.id}
onClick={() => {
if (!enableRowNavigation) return;
const target = roundLinkBuilder
? roundLinkBuilder(item)
: `/contests/${item.contest_id}/rounds/${item.id}`;
navigate(target);
}}
className={enableRowNavigation ? "cursor-pointer hover:bg-default-100" : undefined}
>
{(columnKey) => {
switch (columnKey) {
case "name": {
const name = resolveTranslation(item.name, locale);
const description = resolveTranslation(item.description ?? null, locale);
const resultsBadge = getResultsBadge(item, t);
return (
<TableCell>
<div className="flex flex-col gap-1">
<span className="font-medium">{name}</span>
{description && (
<span className="text-xs text-foreground-500 line-clamp-2">
{description}
</span>
)}
<div className="flex flex-wrap gap-2 text-[10px] font-semibold uppercase tracking-wide">
{resultsBadge && (
<span className={`px-1.5 py-0.5 rounded ${resultsBadge.className}`}>
{resultsBadge.label}
</span>
)}
{item.is_test && (
<span className="px-1.5 py-0.5 rounded bg-amber-100 text-amber-800">
{t("round_test") ?? "Test"}
</span>
)}
{item.is_sixhr && (
<span className="px-1.5 py-0.5 rounded bg-blue-100 text-blue-800">
6h
</span>
)}
{item.is_mcr && (
<span className="px-1.5 py-0.5 rounded bg-blue-100 text-blue-800">
MČR
</span>
)}
</div>
</div>
</TableCell>
);
}
case "contest": {
const contestName = resolveTranslation(
item.contest?.name ?? null,
locale
);
return (
<TableCell>
<span className="text-sm">
{contestName || "—"}
</span>
</TableCell>
);
}
case "schedule":
return (
<TableCell>
<div className="flex flex-col text-xs text-foreground-600">
<span>{formatDateTime(item.start_time, locale)}</span>
<span>{formatDateTime(item.end_time, locale)}</span>
{item.logs_deadline && (
<span className="text-foreground-500">
{(t("round_logs_deadline") ?? "Logy do") + ": "}
{formatDateTime(item.logs_deadline, locale)}
</span>
)}
</div>
</TableCell>
);
case "is_active":
return (
<TableCell>
{item.is_active
? t("yes") ?? "Ano"
: t("no") ?? "Ne"}
</TableCell>
);
case "actions":
return (
<TableCell>
{canEdit && (
<div className="flex items-center gap-1">
{onSelectRound && (
<button
type="button"
onClick={() => onSelectRound?.(item)}
className="inline-flex items-center p-1 rounded hover:bg-default-100 text-default-500 hover:text-default-700"
aria-label={t("edit_round") ?? "Edit round"}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path d="M13.586 3.586a2 2 0 0 1 2.828 2.828l-8.25 8.25a2 2 0 0 1-.878.518l-3.122.89a.75.75 0 0 1-.926-.926l.89-3.122a2 2 0 0 1 .518-.878l8.25-8.25Z" />
<path d="M12.475 4.697 4.75 12.422l-.69 2.422 2.422-.69 7.725-7.725-1.732-1.732Z" />
</svg>
</button>
)}
</div>
)}
</TableCell>
);
default:
return <TableCell />;
}
}}
</TableRow>
)}
</TableBody>
</Table>
{lastPage > 1 && (
<div className="flex justify-end">
<Pagination total={lastPage} page={page} onChange={setPage} showShadow />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { FC } from "react";
import { VisuallyHidden } from "@react-aria/visually-hidden";
import { SwitchProps, useSwitch } from "@heroui/switch";
import { useIsSSR } from "@react-aria/ssr";
import clsx from "clsx";
import { SunFilledIcon, MoonFilledIcon } from "../../icons/Icons";
import { useTheme } from "@heroui/use-theme";
export interface ThemeSwitchProps {
className?: string;
classNames?: SwitchProps["classNames"];
}
export const ThemeSwitch: FC<ThemeSwitchProps> = ({
className,
classNames,
}) => {
const { theme, setTheme } = useTheme();
const isSSR = useIsSSR();
const onChange = () => {
theme === "light" ? setTheme("dark") : setTheme("light");
}
const {
Component,
slots,
isSelected,
getBaseProps,
getInputProps,
getWrapperProps,
} = useSwitch({
isSelected: theme === "light" || isSSR,
"aria-label": `Switch to ${theme === "light" || isSSR ? "dark" : "light"} mode`,
onChange,
})
return (
<Component
{...getBaseProps({
className: clsx(
"px-px transition-opacity hover:opacity-80 cursor-pointer",
className,
classNames?.base,
),
})}
>
<VisuallyHidden>
<input {...getInputProps()} />
</VisuallyHidden>
<div
{...getWrapperProps()}
className={slots.wrapper({
class: clsx(
[
"w-auto h-auto",
"bg-transparent",
"rounded-lg",
"flex items-center justify-center",
"group-data-[selected=true]:bg-transparent",
"!text-default-500",
"pt-px",
"px-0",
"mx-0",
],
classNames?.wrapper,
),
})}
>
{!isSelected || isSSR ? (
<SunFilledIcon size={22} />
) : (
<MoonFilledIcon className="text-foreground bg-background" size={22} />
)}
</div>
</Component>
)
}
export default ThemeSwitch

View File

@@ -0,0 +1,192 @@
import { useEffect, useMemo, useState } from "react";
import { Button, Input, Switch, Textarea } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { type NewsItem, type NewsPayload, type FormMode, type NewsTranslation } from "./adminNewsTypes";
const resolveLocale = (field: NewsTranslation | null | undefined, lang: string) => {
if (!field) return "";
if (typeof field === "string") return field;
return field[lang] ?? field["en"] ?? Object.values(field)[0] ?? "";
};
const formatForDateTimeLocal = (value: string | null | undefined) => {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const pad = (num: number) => num.toString().padStart(2, "0");
const year = date.getFullYear();
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
type AdminNewsFormProps = {
mode: Exclude<FormMode, "none">;
editing: NewsItem | null;
onSubmit: (payload: NewsPayload) => Promise<void> | void;
onCancel: () => void;
submitting: boolean;
serverError: string | null;
};
export default function AdminNewsForm({
mode,
editing,
onSubmit,
onCancel,
submitting,
serverError,
}: AdminNewsFormProps) {
const { t } = useTranslation("common");
const [titleCs, setTitleCs] = useState("");
const [titleEn, setTitleEn] = useState("");
const [contentCs, setContentCs] = useState("");
const [contentEn, setContentEn] = useState("");
const [excerptCs, setExcerptCs] = useState("");
const [excerptEn, setExcerptEn] = useState("");
const [publishedAt, setPublishedAt] = useState("");
const [isPublished, setIsPublished] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
useEffect(() => {
if (mode === "edit" && editing) {
setTitleCs(resolveLocale(editing.title, "cs"));
setTitleEn(resolveLocale(editing.title, "en"));
setExcerptCs(resolveLocale(editing.excerpt, "cs"));
setExcerptEn(resolveLocale(editing.excerpt, "en"));
setContentCs(resolveLocale(editing.content, "cs"));
setContentEn(resolveLocale(editing.content, "en"));
setPublishedAt(formatForDateTimeLocal(editing.published_at));
setIsPublished(!!editing.is_published);
setLocalError(null);
return;
}
setTitleCs("");
setTitleEn("");
setContentCs("");
setContentEn("");
setExcerptCs("");
setExcerptEn("");
setPublishedAt("");
setIsPublished(false);
setLocalError(null);
}, [mode, editing]);
const submitLabel = useMemo(() => {
if (mode === "edit") return t("admin_news_form_save") ?? "Uložit změny";
return t("admin_news_form_create") ?? "Vytvořit novinku";
}, [mode, t]);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setLocalError(null);
const titlePayload: Record<string, string> = {};
if (titleCs.trim()) titlePayload.cs = titleCs.trim();
if (titleEn.trim()) titlePayload.en = titleEn.trim();
if (Object.keys(titlePayload).length === 0) {
setLocalError(t("admin_news_title_required") ?? "Vyplň nadpis alespoň v jednom jazyce.");
return;
}
const contentPayload: Record<string, string> = {};
if (contentCs.trim()) contentPayload.cs = contentCs.trim();
if (contentEn.trim()) contentPayload.en = contentEn.trim();
if (Object.keys(contentPayload).length === 0) {
setLocalError(t("admin_news_content_required") ?? "Vyplň obsah alespoň v jednom jazyce.");
return;
}
const excerptPayload: Record<string, string> = {};
if (excerptCs.trim()) excerptPayload.cs = excerptCs.trim();
if (excerptEn.trim()) excerptPayload.en = excerptEn.trim();
const payload: NewsPayload = {
title: titlePayload,
content: contentPayload,
is_published: isPublished,
};
if (Object.keys(excerptPayload).length > 0) payload.excerpt = excerptPayload;
if (publishedAt.trim()) payload.published_at = publishedAt.trim();
await onSubmit(payload);
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<Input
label={t("admin_news_form_title_cs") ?? "Nadpis (cs)"}
value={titleCs}
onChange={(e) => setTitleCs(e.target.value)}
/>
<Input
label={t("admin_news_form_title_en") ?? "Title (en)"}
value={titleEn}
onChange={(e) => setTitleEn(e.target.value)}
/>
</div>
<div className="grid gap-3 md:grid-cols-2">
<Textarea
label={t("admin_news_form_excerpt_cs") ?? "Perex (cs)"}
value={excerptCs}
onChange={(e) => setExcerptCs(e.target.value)}
minRows={2}
/>
<Textarea
label={t("admin_news_form_excerpt_en") ?? "Excerpt (en)"}
value={excerptEn}
onChange={(e) => setExcerptEn(e.target.value)}
minRows={2}
/>
</div>
<div className="grid gap-3 md:grid-cols-2">
<Textarea
label={t("admin_news_form_content_cs") ?? "Obsah (cs)"}
value={contentCs}
onChange={(e) => setContentCs(e.target.value)}
minRows={4}
/>
<Textarea
label={t("admin_news_form_content_en") ?? "Content (en)"}
value={contentEn}
onChange={(e) => setContentEn(e.target.value)}
minRows={4}
/>
</div>
<div className="grid gap-3 md:grid-cols-2 items-center">
<Input
type="datetime-local"
label={t("admin_news_form_published_from") ?? "Publikováno od"}
value={publishedAt}
onChange={(e) => setPublishedAt(e.target.value)}
/>
<div className="flex items-center gap-2">
<Switch isSelected={isPublished} onValueChange={setIsPublished}>
{t("admin_news_form_publish") ?? "Publikovat"}
</Switch>
</div>
</div>
{(localError || serverError) && (
<div className="text-sm text-red-600">{localError ?? serverError}</div>
)}
<div className="flex gap-3">
<Button type="submit" color="primary" isLoading={submitting}>
{submitLabel}
</Button>
<Button type="button" variant="light" onPress={onCancel}>
{t("admin_form_close") ?? "Zavřít formulář"}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,112 @@
import {
Table,
TableHeader,
TableColumn,
TableBody,
TableRow,
TableCell,
} from "@heroui/react";
import { useTranslation } from "react-i18next";
import { type NewsItem, type NewsTranslation } from "./adminNewsTypes";
const resolveLocale = (field: NewsTranslation | null | undefined, lang: string) => {
if (!field) return "";
if (typeof field === "string") return field;
return field[lang] ?? field["en"] ?? Object.values(field)[0] ?? "";
};
const formatDate = (value: string | null | undefined, lang: string) => {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(lang, {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(date);
};
type AdminNewsTableProps = {
items: NewsItem[];
locale: string;
loading: boolean;
error: string | null;
onEdit: (item: NewsItem) => void;
};
export default function AdminNewsTable({
items,
locale,
loading,
error,
onEdit,
}: AdminNewsTableProps) {
const { t } = useTranslation("common");
const valuePlaceholder = t("value_na") ?? "—";
if (loading) {
return <div>{t("admin_news_loading") ?? "Načítám novinky…"}</div>;
}
if (error) {
return <div className="text-sm text-red-600">{error}</div>;
}
return (
<Table
aria-label={t("admin_news_table_aria") ?? "Admin news table"}
selectionMode="none"
>
<TableHeader>
<TableColumn>{t("admin_news_title_cs") ?? "Název (cs)"}</TableColumn>
<TableColumn>{t("admin_news_title_en") ?? "Title (en)"}</TableColumn>
<TableColumn>{t("admin_news_published_at") ?? "Publikováno"}</TableColumn>
<TableColumn>{t("admin_news_published_flag") ?? "Zveřejněno"}</TableColumn>
<TableColumn></TableColumn>
</TableHeader>
<TableBody items={items}>
{(item) => (
<TableRow key={item.id}>
<TableCell>{resolveLocale(item.title, "cs")}</TableCell>
<TableCell>{resolveLocale(item.title, "en")}</TableCell>
<TableCell>
{(() => {
if (!item.published_at) return valuePlaceholder;
const date = new Date(item.published_at);
if (Number.isNaN(date.getTime())) return valuePlaceholder;
const isFuture = date.getTime() > Date.now();
const className = isFuture ? "italic" : "font-semibold";
return (
<span className={className}>
{formatDate(item.published_at, locale)}
</span>
);
})()}
</TableCell>
<TableCell>
{item.is_published ? t("yes") ?? "Ano" : t("no") ?? "Ne"}
</TableCell>
<TableCell>
<button
type="button"
onClick={() => onEdit(item)}
className="inline-flex items-center p-1 rounded hover:bg-default-100 text-default-500 hover:text-default-700"
aria-label={t("admin_news_edit_aria") ?? "Upravit novinku"}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path d="M13.586 3.586a2 2 0 0 1 2.828 2.828l-8.25 8.25a2 2 0 0 1-.878.518l-3.122.89a.75.75 0 0 1-.926-.926l.89-3.122a2 2 0 0 1 .518-.878l8.25-8.25Z" />
<path d="M12.475 4.697 4.75 12.422l-.69 2.422 2.422-.69 7.725-7.725-1.732-1.732Z" />
</svg>
</button>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
}

View File

@@ -0,0 +1,21 @@
export type NewsTranslation = string | Record<string, string>;
export type NewsItem = {
id: number;
title: NewsTranslation;
excerpt: NewsTranslation | null;
content: NewsTranslation;
slug: string;
published_at: string | null;
is_published: boolean;
};
export type NewsPayload = {
title: Record<string, string>;
content: Record<string, string>;
excerpt?: Record<string, string>;
published_at?: string;
is_published: boolean;
};
export type FormMode = "none" | "create" | "edit";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,74 @@
import React from "react";
import {
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
} from "@heroui/react";
import type { EvaluationRuleSet } from "./adminRulesetTypes";
type AdminRulesetsTableProps = {
items: EvaluationRuleSet[];
locale: string;
valuePlaceholder: string;
formatDate: (value: string | null | undefined, lang: string) => string;
onEdit: (item: EvaluationRuleSet) => void;
t: (key: string) => string;
};
const PencilIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-4 w-4"
>
<path d="M13.586 3.586a2 2 0 0 1 2.828 2.828l-8.25 8.25a2 2 0 0 1-.878.518l-3.122.89a.75.75 0 0 1-.926-.926l.89-3.122a2 2 0 0 1 .518-.878l8.25-8.25Z" />
<path d="M12.475 4.697 4.75 12.422l-.69 2.422 2.422-.69 7.725-7.725-1.732-1.732Z" />
</svg>
);
export default function AdminRulesetsTable({
items,
locale,
valuePlaceholder,
formatDate,
onEdit,
t,
}: AdminRulesetsTableProps) {
return (
<Table aria-label={t("admin_rulesets_table_aria") ?? "Evaluation rulesets table"} selectionMode="none">
<TableHeader>
<TableColumn>{t("admin_rulesets_table_name") ?? "Název"}</TableColumn>
<TableColumn>{t("admin_rulesets_table_code") ?? "Kód"}</TableColumn>
<TableColumn>{t("admin_rulesets_table_scoring") ?? "Scoring"}</TableColumn>
<TableColumn>{t("admin_rulesets_table_multiplier") ?? "Multiplier"}</TableColumn>
<TableColumn>{t("admin_rulesets_table_updated") ?? "Aktualizace"}</TableColumn>
<TableColumn></TableColumn>
</TableHeader>
<TableBody items={items}>
{(item) => (
<TableRow key={item.id}>
<TableCell>{item.name}</TableCell>
<TableCell>{item.code}</TableCell>
<TableCell>{item.scoring_mode ?? valuePlaceholder}</TableCell>
<TableCell>{item.multiplier_type ?? valuePlaceholder}</TableCell>
<TableCell>{formatDate(item.updated_at ?? null, locale)}</TableCell>
<TableCell>
<button
type="button"
onClick={() => onEdit(item)}
className="inline-flex items-center p-1 rounded hover:bg-default-100 text-default-500 hover:text-default-700"
aria-label={t("admin_rulesets_edit_aria") ?? "Upravit rule set"}
>
<PencilIcon />
</button>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
}

View File

@@ -0,0 +1,259 @@
export type EvaluationRuleSet = {
id: number;
name: string;
code: string;
description?: string | null;
scoring_mode?: string | null;
points_per_qso?: number | null;
points_per_km?: number | null;
use_multipliers?: boolean | null;
multiplier_type?: string | null;
dup_qso_policy?: string | null;
nil_qso_policy?: string | null;
no_counterpart_log_policy?: string | null;
not_in_counterpart_log_policy?: string | null;
unique_qso_policy?: string | null;
busted_call_policy?: string | null;
busted_rst_policy?: string | null;
busted_exchange_policy?: string | null;
busted_serial_policy?: string | null;
busted_locator_policy?: string | null;
penalty_dup_points?: number | null;
penalty_nil_points?: number | null;
penalty_busted_call_points?: number | null;
penalty_busted_rst_points?: number | null;
penalty_busted_exchange_points?: number | null;
penalty_busted_serial_points?: number | null;
penalty_busted_locator_points?: number | null;
penalty_out_of_window_points?: number | null;
dupe_scope?: string | null;
callsign_normalization?: string | null;
distance_rounding?: string | null;
min_distance_km?: number | null;
require_locators?: boolean | null;
out_of_window_policy?: string | null;
exchange_type?: string | null;
exchange_requires_wwl?: boolean | null;
exchange_requires_serial?: boolean | null;
exchange_requires_report?: boolean | null;
exchange_pattern?: string | null;
match_tiebreak_order?: string[] | null;
match_require_locator_match?: boolean | null;
match_require_exchange_match?: boolean | null;
multiplier_scope?: string | null;
multiplier_source?: string | null;
wwl_multiplier_level?: string | null;
checklog_matching?: boolean | null;
out_of_window_dq_threshold?: number | null;
time_diff_dq_threshold_percent?: number | null;
time_diff_dq_threshold_sec?: number | null;
bad_qso_dq_threshold_percent?: number | null;
time_tolerance_sec?: number | null;
allow_time_shift_one_hour?: boolean | null;
time_shift_seconds?: number | null;
time_mismatch_policy?: string | null;
allow_time_mismatch_pairing?: boolean | null;
time_mismatch_max_sec?: number | null;
require_unique_qso?: boolean | null;
ignore_slash_part?: boolean | null;
ignore_third_part?: boolean | null;
rst_ignore_third_char?: boolean | null;
callsign_suffix_max_len?: number | null;
callsign_levenshtein_max?: number | null;
letters_in_rst?: boolean | null;
discard_qso_rec_diff_call?: boolean | null;
discard_qso_sent_diff_call?: boolean | null;
discard_qso_rec_diff_rst?: boolean | null;
discard_qso_sent_diff_rst?: boolean | null;
discard_qso_rec_diff_serial?: boolean | null;
discard_qso_sent_diff_serial?: boolean | null;
discard_qso_rec_diff_wwl?: boolean | null;
discard_qso_sent_diff_wwl?: boolean | null;
discard_qso_rec_diff_code?: boolean | null;
discard_qso_sent_diff_code?: boolean | null;
dup_resolution_strategy?: string[] | null;
operating_window_mode?: string | null;
operating_window_hours?: number | null;
sixhr_ranking_mode?: string | null;
options?: Record<string, unknown> | null;
updated_at?: string | null;
};
export type RuleSetForm = {
name: string;
code: string;
description: string;
scoring_mode: string;
points_per_qso: string;
points_per_km: string;
use_multipliers: boolean;
multiplier_type: string;
dup_qso_policy: string;
nil_qso_policy: string;
no_counterpart_log_policy: string;
not_in_counterpart_log_policy: string;
unique_qso_policy: string;
busted_call_policy: string;
busted_rst_policy: string;
busted_exchange_policy: string;
busted_serial_policy: string;
busted_locator_policy: string;
penalty_dup_points: string;
penalty_nil_points: string;
penalty_busted_call_points: string;
penalty_busted_rst_points: string;
penalty_busted_exchange_points: string;
penalty_busted_serial_points: string;
penalty_busted_locator_points: string;
penalty_out_of_window_points: string;
dupe_scope: string;
callsign_normalization: string;
distance_rounding: string;
min_distance_km: string;
require_locators: boolean;
out_of_window_policy: string;
exchange_type: string;
exchange_requires_wwl: boolean;
exchange_requires_serial: boolean;
exchange_requires_report: boolean;
exchange_pattern: string;
match_tiebreak_order: string;
match_require_locator_match: boolean;
match_require_exchange_match: boolean;
multiplier_scope: string;
multiplier_source: string;
wwl_multiplier_level: string;
checklog_matching: boolean;
out_of_window_dq_threshold: string;
time_diff_dq_threshold_percent: string;
time_diff_dq_threshold_sec: string;
bad_qso_dq_threshold_percent: string;
time_tolerance_sec: string;
allow_time_shift_one_hour: boolean;
time_shift_seconds: string;
time_mismatch_policy: string;
allow_time_mismatch_pairing: boolean;
time_mismatch_max_sec: string;
require_unique_qso: boolean;
ignore_slash_part: boolean;
ignore_third_part: boolean;
rst_ignore_third_char: boolean;
callsign_suffix_max_len: string;
callsign_levenshtein_max: string;
letters_in_rst: boolean;
discard_qso_rec_diff_call: boolean;
discard_qso_sent_diff_call: boolean;
discard_qso_rec_diff_rst: boolean;
discard_qso_sent_diff_rst: boolean;
discard_qso_rec_diff_serial: boolean;
discard_qso_sent_diff_serial: boolean;
discard_qso_rec_diff_wwl: boolean;
discard_qso_sent_diff_wwl: boolean;
discard_qso_rec_diff_code: boolean;
discard_qso_sent_diff_code: boolean;
dup_resolution_strategy: string;
operating_window_mode: string;
operating_window_hours: string;
sixhr_ranking_mode: string;
};
export type RuleSetFormMode = "none" | "create" | "edit";
export const emptyForm: RuleSetForm = {
name: "",
code: "",
description: "",
scoring_mode: "DISTANCE",
points_per_qso: "",
points_per_km: "",
use_multipliers: true,
multiplier_type: "WWL",
dup_qso_policy: "ZERO_POINTS",
nil_qso_policy: "PENALTY",
no_counterpart_log_policy: "PENALTY",
not_in_counterpart_log_policy: "PENALTY",
unique_qso_policy: "ZERO_POINTS",
busted_call_policy: "PENALTY",
busted_rst_policy: "ZERO_POINTS",
busted_exchange_policy: "ZERO_POINTS",
busted_serial_policy: "ZERO_POINTS",
busted_locator_policy: "ZERO_POINTS",
penalty_dup_points: "",
penalty_nil_points: "",
penalty_busted_call_points: "",
penalty_busted_rst_points: "",
penalty_busted_exchange_points: "",
penalty_busted_serial_points: "",
penalty_busted_locator_points: "",
penalty_out_of_window_points: "",
dupe_scope: "BAND",
callsign_normalization: "IGNORE_SUFFIX",
distance_rounding: "FLOOR",
min_distance_km: "",
require_locators: true,
out_of_window_policy: "INVALID",
exchange_type: "SERIAL_WWL",
exchange_requires_wwl: true,
exchange_requires_serial: true,
exchange_requires_report: false,
exchange_pattern: "",
match_tiebreak_order: "",
match_require_locator_match: false,
match_require_exchange_match: false,
multiplier_scope: "PER_BAND",
multiplier_source: "VALID_ONLY",
wwl_multiplier_level: "LOCATOR_6",
checklog_matching: true,
out_of_window_dq_threshold: "",
time_diff_dq_threshold_percent: "",
time_diff_dq_threshold_sec: "",
bad_qso_dq_threshold_percent: "",
time_tolerance_sec: "",
allow_time_shift_one_hour: true,
time_shift_seconds: "3600",
time_mismatch_policy: "FLAG_ONLY",
allow_time_mismatch_pairing: true,
time_mismatch_max_sec: "",
require_unique_qso: true,
ignore_slash_part: true,
ignore_third_part: true,
rst_ignore_third_char: true,
callsign_suffix_max_len: "4",
callsign_levenshtein_max: "2",
letters_in_rst: true,
discard_qso_rec_diff_call: true,
discard_qso_sent_diff_call: false,
discard_qso_rec_diff_rst: true,
discard_qso_sent_diff_rst: false,
discard_qso_rec_diff_serial: true,
discard_qso_sent_diff_serial: false,
discard_qso_rec_diff_wwl: true,
discard_qso_sent_diff_wwl: false,
discard_qso_rec_diff_code: true,
discard_qso_sent_diff_code: false,
dup_resolution_strategy: "paired_first, ok_first, earlier_time, lower_id",
operating_window_mode: "NONE",
operating_window_hours: "",
sixhr_ranking_mode: "IARU",
};
export const numberValue = (value?: number | null) =>
value === null || value === undefined ? "" : String(value);
export const toNumberOrNull = (value: string) => {
if (!value.trim()) return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
export const toIntOrNull = (value: string) => {
if (!value.trim()) return null;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : null;
};
export const isBlank = (value: string) => !value.trim();
export const isNumberLike = (value: string) =>
value.trim() !== "" && Number.isFinite(Number(value));
export const isIntegerLike = (value: string) =>
value.trim() !== "" && Number.isInteger(Number(value));

View File

@@ -0,0 +1,137 @@
import React, { useEffect, useState } from "react";
import { Button, Checkbox, Input } from "@heroui/react";
import { useTranslation } from "react-i18next";
type UserItem = {
id: number;
name: string;
email: string;
is_admin: boolean;
is_active: boolean;
};
type FormMode = "create" | "edit";
type Props = {
mode: FormMode;
editing: UserItem | null;
submitting: boolean;
serverError: string | null;
onSubmit: (payload: {
name: string;
email: string;
password?: string;
is_admin: boolean;
is_active: boolean;
}) => void;
onCancel: () => void;
};
export default function AdminUserForm({
mode,
editing,
submitting,
serverError,
onSubmit,
onCancel,
}: Props) {
const { t } = useTranslation("common");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isAdmin, setIsAdmin] = useState(false);
const [isActive, setIsActive] = useState(true);
useEffect(() => {
if (mode === "edit" && editing) {
setName(editing.name);
setEmail(editing.email);
setPassword("");
setIsAdmin(editing.is_admin);
setIsActive(editing.is_active);
} else {
setName("");
setEmail("");
setPassword("");
setIsAdmin(false);
setIsActive(true);
}
}, [mode, editing]);
const handleSubmit = () => {
const payload = {
name: name.trim(),
email: email.trim(),
is_admin: isAdmin,
is_active: isActive,
} as {
name: string;
email: string;
password?: string;
is_admin: boolean;
is_active: boolean;
};
if (password.trim()) {
payload.password = password.trim();
}
onSubmit(payload);
};
return (
<div className="rounded border border-divider p-4 space-y-3">
<div className="text-sm font-semibold">
{mode === "edit"
? t("admin_users_edit_title") ?? "Upravit uživatele"
: t("admin_users_create_title") ?? "Nový uživatel"}
</div>
<div className="grid gap-3">
<Input
label={t("admin_users_name") ?? "Jméno"}
value={name}
onChange={(e) => setName(e.target.value)}
size="sm"
/>
<Input
label={t("admin_users_email") ?? "Email"}
value={email}
onChange={(e) => setEmail(e.target.value)}
size="sm"
/>
<Input
label={t("admin_users_password") ?? "Heslo"}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
size="sm"
placeholder={
mode === "edit"
? t("admin_users_password_hint") ?? "Nech prázdné pro beze změny"
: undefined
}
/>
<div className="flex gap-6">
<Checkbox isSelected={isAdmin} onValueChange={setIsAdmin}>
{t("admin_users_is_admin") ?? "Admin"}
</Checkbox>
<Checkbox isSelected={isActive} onValueChange={setIsActive}>
{t("admin_users_is_active") ?? "Aktivní"}
</Checkbox>
</div>
</div>
{serverError && <div className="text-red-600 text-sm">{serverError}</div>}
<div className="flex gap-2">
<Button color="primary" isDisabled={submitting} onPress={handleSubmit}>
{submitting ? t("admin_users_saving") ?? "Ukládám…" : t("admin_users_save") ?? "Uložit"}
</Button>
<Button variant="light" onPress={onCancel}>
{t("admin_form_close") ?? "Zavřít formulář"}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
import React from "react";
import { Button, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from "@heroui/react";
import { useTranslation } from "react-i18next";
type UserItem = {
id: number;
name: string;
email: string;
is_admin: boolean;
is_active: boolean;
};
type Props = {
items: UserItem[];
loading: boolean;
onEdit: (item: UserItem) => void;
onDeactivate: (item: UserItem) => void;
};
export default function AdminUsersTable({ items, loading, onEdit, onDeactivate }: Props) {
const { t } = useTranslation("common");
return (
<Table aria-label="Users table">
<TableHeader>
<TableColumn>{t("admin_users_name") ?? "Jméno"}</TableColumn>
<TableColumn>{t("admin_users_email") ?? "Email"}</TableColumn>
<TableColumn>{t("admin_users_is_admin") ?? "Admin"}</TableColumn>
<TableColumn>{t("admin_users_is_active") ?? "Aktivní"}</TableColumn>
<TableColumn>{t("admin_users_actions") ?? "Akce"}</TableColumn>
</TableHeader>
<TableBody
items={items}
emptyContent={
loading
? t("admin_users_loading") ?? "Načítám..."
: t("admin_users_empty") ?? "Žádní uživatelé."
}
>
{(item) => (
<TableRow key={item.id}>
<TableCell>{item.name}</TableCell>
<TableCell>{item.email}</TableCell>
<TableCell>{item.is_admin ? "ANO" : "NE"}</TableCell>
<TableCell>{item.is_active ? "ANO" : "NE"}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button size="sm" variant="bordered" onPress={() => onEdit(item)}>
{t("admin_users_edit") ?? "Upravit"}
</Button>
<Button
size="sm"
color="danger"
variant="light"
onPress={() => onDeactivate(item)}
isDisabled={!item.is_active}
>
{t("admin_users_deactivate") ?? "Deaktivovat"}
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
}

View File

@@ -0,0 +1,100 @@
import React, { useEffect } from "react";
import { useParams } from "react-router-dom";
import { useContestStore } from "@/stores/contestStore";
import { useLanguageStore } from "@/stores/languageStore";
import ContestsOverview from "@/components/ContestsOverview";
import RoundsOverview from "@/components/RoundsOverview";
import axios from "axios";
export default function ContestsLeftPanel() {
const { contestId } = useParams<{ contestId?: string }>();
const { roundId } = useParams();
const cId = contestId ? Number(contestId) : null;
const rId = roundId ? Number(roundId) : null;
const selectedContest = useContestStore((s) => s.selectedContest);
const setSelectedContest = useContestStore((s) => s.setSelectedContest);
const clearSelection = useContestStore((s) => s.clearSelection);
const locale = useLanguageStore((s) => s.locale);
// pokud je contestId v URL, načti a ulož do storu
useEffect(() => {
const numericId = contestId ? Number(contestId) : null;
if (!numericId) {
clearSelection();
return;
}
if (selectedContest && selectedContest.id === numericId) {
// už máme data v store, nefetchuj
return;
}
let active = true;
(async () => {
try {
const res = await axios.get(`/api/contests/${numericId}`, {
headers: { Accept: "application/json" },
params: { lang: locale },
withCredentials: true,
});
if (!active) return;
const data = res.data;
const rounds = Array.isArray(data.rounds)
? data.rounds.map((r: any) => ({
id: r.id,
contest_id: r.contest_id,
name: r.name,
description: r.description ?? null,
is_active: r.is_active,
is_test: r.is_test,
is_sixhr: r.is_sixhr,
start_time: r.start_time ?? null,
end_time: r.end_time ?? null,
logs_deadline: r.logs_deadline ?? null,
}))
: [];
setSelectedContest({
id: data.id,
name: data.name,
description: data.description ?? null,
is_active: data.is_active,
is_mcr: data.is_mcr,
is_sixhr: data.is_sixhr,
start_time: data.start_time ?? null,
duration: data.duration ?? 0,
rounds,
});
} catch {
// při chybě jen nevybere nic
}
})();
return () => {
active = false;
};
}, [contestId, locale, clearSelection, setSelectedContest]);
return (
<div className="space-y-6">
<div>
<ContestsOverview
onlyActive={true}
showTests={false}
/>
</div>
{rId && (
<div>
<RoundsOverview
contestId={cId}
roundsFromStore={selectedContest ? selectedContest.rounds : null}
showTests={false}
/>
</div>
)}
</div>
);
}