Initial commit
This commit is contained in:
84
resources/js/components/AppBreadcrumbs.tsx
Normal file
84
resources/js/components/AppBreadcrumbs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
resources/js/components/AppErrorBoundary.tsx
Normal file
35
resources/js/components/AppErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
502
resources/js/components/ContestCreateForm.tsx
Normal file
502
resources/js/components/ContestCreateForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
resources/js/components/ContestDetail.tsx
Normal file
122
resources/js/components/ContestDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
resources/js/components/ContestsListBox.tsx
Normal file
130
resources/js/components/ContestsListBox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
resources/js/components/ContestsOverview.tsx
Normal file
180
resources/js/components/ContestsOverview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
resources/js/components/ContestsSelectBox.tsx
Normal file
158
resources/js/components/ContestsSelectBox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
261
resources/js/components/ContestsTable.tsx
Normal file
261
resources/js/components/ContestsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
resources/js/components/EvaluationActions.tsx
Normal file
50
resources/js/components/EvaluationActions.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
32
resources/js/components/EvaluationEventsList.tsx
Normal file
32
resources/js/components/EvaluationEventsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
resources/js/components/EvaluationHistoryPanel.tsx
Normal file
46
resources/js/components/EvaluationHistoryPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
resources/js/components/EvaluationStatusSummary.tsx
Normal file
63
resources/js/components/EvaluationStatusSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
resources/js/components/EvaluationStepsList.tsx
Normal file
40
resources/js/components/EvaluationStepsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
resources/js/components/FileDropZone.tsx
Normal file
62
resources/js/components/FileDropZone.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
resources/js/components/LanguageSwitcher.tsx
Normal file
38
resources/js/components/LanguageSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
621
resources/js/components/LogDetail.tsx
Normal file
621
resources/js/components/LogDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
291
resources/js/components/LogQsoTable.tsx
Normal file
291
resources/js/components/LogQsoTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
206
resources/js/components/LoginDialog.tsx
Normal file
206
resources/js/components/LoginDialog.tsx
Normal 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
|
||||
246
resources/js/components/LogsTable.tsx
Normal file
246
resources/js/components/LogsTable.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, Pagination } from "@heroui/react";
|
||||
import { useUserStore } from "@/stores/userStore";
|
||||
|
||||
type LogItem = {
|
||||
id: number;
|
||||
round_id: number;
|
||||
parsed?: boolean;
|
||||
parsed_claimed?: boolean;
|
||||
tname?: string | null;
|
||||
tdate?: string | null;
|
||||
pcall?: string | null;
|
||||
rcall?: string | null; // pokud backend vrátí, jinak zobrazíme prázdně
|
||||
pwwlo?: string | null;
|
||||
psect?: string | null;
|
||||
pband?: string | null;
|
||||
power_watt?: number | null;
|
||||
claimed_qso_count?: number | null;
|
||||
claimed_score?: number | null;
|
||||
remarks_eval?: string | null;
|
||||
file_id?: number | null;
|
||||
file?: {
|
||||
id: number;
|
||||
filename: string;
|
||||
mimetype?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type PaginatedResponse<T> = {
|
||||
data: T[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
type LogsTableProps = {
|
||||
roundId: number | null;
|
||||
perPage?: number;
|
||||
refreshKey?: number;
|
||||
contestId?: number | null;
|
||||
};
|
||||
|
||||
export default function LogsTable({ roundId, perPage = 50, refreshKey = 0, contestId = null }: LogsTableProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const user = useUserStore((s) => s.user);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [items, setItems] = useState<LogItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [lastPage, setLastPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!roundId) return;
|
||||
let active = true;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const res = await axios.get<PaginatedResponse<LogItem>>("/api/logs", {
|
||||
headers: { Accept: "application/json" },
|
||||
params: { round_id: roundId, per_page: perPage, page },
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
if (!active) return;
|
||||
|
||||
setItems(res.data.data);
|
||||
setLastPage(res.data.last_page ?? 1);
|
||||
} catch (e: any) {
|
||||
if (!active) return;
|
||||
const message = e?.response?.data?.message ?? (t("unable_to_load_logs") as string) ?? "Nepodařilo se načíst logy.";
|
||||
setError(message);
|
||||
} finally {
|
||||
if (active) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [roundId, perPage, page, t, refreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [roundId, perPage, refreshKey]);
|
||||
|
||||
if (!roundId) return null;
|
||||
if (loading) return <div>{t("logs_loading") ?? "Načítám logy…"}</div>;
|
||||
if (error) return <div className="text-sm text-red-600">{error}</div>;
|
||||
if (!items.length) {
|
||||
return <div>{t("logs_empty") ?? "Žádné logy nejsou k dispozici."}</div>;
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ key: "parsed", label: "" },
|
||||
{ key: "pcall", label: "PCall" },
|
||||
{ key: "pwwlo", label: "PWWLo" },
|
||||
{ key: "pband", label: "PBand" },
|
||||
{ key: "psect", label: "PSect" },
|
||||
{ key: "power_watt", label: "SPowe" },
|
||||
{ key: "claimed_qso_count", label: "QSO" },
|
||||
{ key: "claimed_score", label: "Body" },
|
||||
{ key: "remarks_eval", label: "remarks_eval" },
|
||||
...(user ? [{ key: "actions", label: "" }] : []),
|
||||
];
|
||||
|
||||
const format = (value: string | null | undefined) => value || "—";
|
||||
const formatPcall = (value: string | null | undefined, waiting: boolean) =>
|
||||
waiting ? (t("logs_waiting_processing") as string) || "Čekám na zpracování" : value || "—";
|
||||
const formatNumber = (value: number | null | undefined) => (value === null || value === undefined ? "—" : String(value));
|
||||
|
||||
const renderRemarksEval = (raw: string | null | undefined) => {
|
||||
if (!raw) return "—";
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
const lines = parsed
|
||||
.filter((item) => typeof item === "string" && item.trim() !== "")
|
||||
.map((item, idx) => <div key={idx}>{item}</div>);
|
||||
if (lines.length > 0) return lines;
|
||||
}
|
||||
} catch {
|
||||
// fall through to show raw string
|
||||
}
|
||||
|
||||
return <div>{raw}</div>;
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
const confirmed = window.confirm(t("confirm_delete_log") ?? "Opravdu smazat log?");
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await axios.delete(`/api/logs/${id}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
withCredentials: true,
|
||||
withXSRFToken: true,
|
||||
});
|
||||
setItems((prev) => prev.filter((i) => i.id !== id));
|
||||
} catch (e: any) {
|
||||
const message = e?.response?.data?.message ?? (t("unable_to_delete_log") as string) ?? "Nepodařilo se smazat log.";
|
||||
setError(message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Table aria-label="Logs table" classNames={{ th: "py-2", td: "py-1 text-sm" }}>
|
||||
<TableHeader columns={columns}>
|
||||
{(column) => <TableColumn key={column.key}>{column.label}</TableColumn>}
|
||||
</TableHeader>
|
||||
<TableBody items={items}>
|
||||
{(item) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
if (contestId && roundId) {
|
||||
navigate(`/contests/${contestId}/rounds/${roundId}/logs/${item.id}`, {
|
||||
state: { from: `${location.pathname}${location.search}` },
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={contestId && roundId ? "cursor-pointer hover:bg-default-100" : undefined}
|
||||
>
|
||||
{(columnKey) => {
|
||||
if (columnKey === "actions") {
|
||||
return (
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.file_id && (
|
||||
<a
|
||||
href={`/api/files/${item.file_id}/download`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center p-1 rounded hover:bg-default-100 text-default-500 hover:text-default-700"
|
||||
aria-label={t("download_file") ?? "Stáhnout soubor"}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4">
|
||||
<path d="M3 14.25a.75.75 0 0 1 .75-.75h2v1.5h-2A.75.75 0 0 1 3 14.25Zm3.75-.75h6.5v1.5h-6.5v-1.5Zm8.5 0H17a.75.75 0 0 1 0 1.5h-1.75v-1.5ZM10.75 3a.75.75 0 0 0-1.5 0v7.19L7.53 8.47a.75.75 0 1 0-1.06 1.06l3.25 3.25c.3.3.77.3 1.06 0l3.25-3.25a.75.75 0 1 0-1.06-1.06l-1.72 1.72V3Z" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleDelete(item.id, e)}
|
||||
className="inline-flex items-center p-1 rounded hover:bg-danger-100 text-danger-500 hover:text-danger-700"
|
||||
aria-label={t("delete") ?? "Smazat"}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4">
|
||||
<path d="M6 8.75A.75.75 0 0 1 6.75 8h6.5a.75.75 0 0 1 0 1.5h-6.5A.75.75 0 0 1 6 8.75Zm0 3.5a.75.75 0 0 1 .75-.75h6.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1-.75-.75Z" />
|
||||
<path d="M6 1.75A1.75 1.75 0 0 1 7.75 0h4.5A1.75 1.75 0 0 1 14 1.75V3h3.25a.75.75 0 0 1 0 1.5H16.5l-.55 11.05A2.25 2.25 0 0 1 13.7 18.75H7.3a2.25 2.25 0 0 1-2.24-2.2L4.5 4.5H2.75a.75.75 0 0 1 0-1.5H6V1.75ZM12.5 3V1.75a.25.25 0 0 0-.25-.25h-4.5a.25.25 0 0 0-.25.25V3h5Zm-6.5 1.5.5 10.25a.75.75 0 0 0 .75.7h6.4a.75.75 0 0 0 .75-.7L14 4.5H6Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
if (columnKey === "parsed") {
|
||||
const parsedClaimed = !!item.parsed_claimed;
|
||||
const parsedAny = !!item.parsed;
|
||||
const symbol = parsedClaimed ? "✓" : "↻";
|
||||
const color = parsedClaimed ? "text-green-600" : "text-blue-600";
|
||||
return (
|
||||
<TableCell>
|
||||
<span className={color}>{symbol}</span>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
if (columnKey === "remarks_eval") {
|
||||
return <TableCell>{renderRemarksEval(item.remarks_eval)}</TableCell>;
|
||||
}
|
||||
if (columnKey === "power_watt" || columnKey === "claimed_qso_count" || columnKey === "claimed_score") {
|
||||
return <TableCell>{formatNumber((item as any)[columnKey as string])}</TableCell>;
|
||||
}
|
||||
if (columnKey === "pcall") {
|
||||
const waiting = !item.parsed_claimed;
|
||||
return <TableCell>{formatPcall(item.pcall, waiting)}</TableCell>;
|
||||
}
|
||||
return <TableCell>{format((item as any)[columnKey as string])}</TableCell>;
|
||||
}}
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{lastPage > 1 && (
|
||||
<div className="flex justify-end">
|
||||
<Pagination
|
||||
total={lastPage}
|
||||
page={page}
|
||||
onChange={setPage}
|
||||
showShadow
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
resources/js/components/NewsList.tsx
Normal file
148
resources/js/components/NewsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
640
resources/js/components/ResultsTables.tsx
Normal file
640
resources/js/components/ResultsTables.tsx
Normal 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`);
|
||||
}
|
||||
624
resources/js/components/RoundCreateForm.tsx
Normal file
624
resources/js/components/RoundCreateForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
190
resources/js/components/RoundDetail.tsx
Normal file
190
resources/js/components/RoundDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
341
resources/js/components/RoundEvaluationLogOverrides.tsx
Normal file
341
resources/js/components/RoundEvaluationLogOverrides.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
resources/js/components/RoundEvaluationLogOverrides.types.ts
Normal file
64
resources/js/components/RoundEvaluationLogOverrides.types.ts
Normal 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;
|
||||
};
|
||||
218
resources/js/components/RoundEvaluationLogOverridesModal.tsx
Normal file
218
resources/js/components/RoundEvaluationLogOverridesModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
300
resources/js/components/RoundEvaluationLogOverridesTable.tsx
Normal file
300
resources/js/components/RoundEvaluationLogOverridesTable.tsx
Normal 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)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
249
resources/js/components/RoundEvaluationOverrideRow.tsx
Normal file
249
resources/js/components/RoundEvaluationOverrideRow.tsx
Normal 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;
|
||||
354
resources/js/components/RoundEvaluationOverrides.tsx
Normal file
354
resources/js/components/RoundEvaluationOverrides.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
resources/js/components/RoundEvaluationOverrides.types.ts
Normal file
40
resources/js/components/RoundEvaluationOverrides.types.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
153
resources/js/components/RoundEvaluationPanel.tsx
Normal file
153
resources/js/components/RoundEvaluationPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1261
resources/js/components/RoundEvaluationQsoOverrides.tsx
Normal file
1261
resources/js/components/RoundEvaluationQsoOverrides.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1015
resources/js/components/RoundFileUpload.tsx
Normal file
1015
resources/js/components/RoundFileUpload.tsx
Normal file
File diff suppressed because it is too large
Load Diff
325
resources/js/components/RoundFileUpload/HeaderFormFields.tsx
Normal file
325
resources/js/components/RoundFileUpload/HeaderFormFields.tsx
Normal 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
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
32
resources/js/components/RoundFileUpload/UploadMessages.tsx
Normal file
32
resources/js/components/RoundFileUpload/UploadMessages.tsx
Normal 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>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
194
resources/js/components/RoundsOverview.tsx
Normal file
194
resources/js/components/RoundsOverview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
449
resources/js/components/RoundsTable.tsx
Normal file
449
resources/js/components/RoundsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
resources/js/components/ThemeSwitch.tsx
Normal file
81
resources/js/components/ThemeSwitch.tsx
Normal 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
|
||||
192
resources/js/components/admin/news/AdminNewsForm.tsx
Normal file
192
resources/js/components/admin/news/AdminNewsForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
resources/js/components/admin/news/AdminNewsTable.tsx
Normal file
112
resources/js/components/admin/news/AdminNewsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
resources/js/components/admin/news/adminNewsTypes.ts
Normal file
21
resources/js/components/admin/news/adminNewsTypes.ts
Normal 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";
|
||||
1325
resources/js/components/admin/rulesets/AdminRulesetForm.tsx
Normal file
1325
resources/js/components/admin/rulesets/AdminRulesetForm.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
259
resources/js/components/admin/rulesets/adminRulesetTypes.ts
Normal file
259
resources/js/components/admin/rulesets/adminRulesetTypes.ts
Normal 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));
|
||||
137
resources/js/components/admin/users/AdminUserForm.tsx
Normal file
137
resources/js/components/admin/users/AdminUserForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
resources/js/components/admin/users/AdminUsersTable.tsx
Normal file
67
resources/js/components/admin/users/AdminUsersTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
resources/js/components/layout/ContestsLeftPanel.tsx
Normal file
100
resources/js/components/layout/ContestsLeftPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user