Initial commit

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

8
resources/css/app.css Normal file
View File

@@ -0,0 +1,8 @@
@source "../**/*.{js,ts,jsx,tsx}"
@import "tailwindcss/preflight";
@import 'tailwindcss';
@plugin "@tailwindcss/typography";
@plugin '../../hero.ts';
@source '../../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}';
@custom-variant dark (&:is(.dark *));

View File

@@ -0,0 +1,171 @@
# EvaluationRuleSet - pravidla vyhodnocení pro rozhodčí
Tento dokument popisuje vyhodnocovací proces a jednotlivá nastavení rulesetu
(`EvaluationRuleSet`). Slouží jako nápověda a vysvětlení dopadu na
skóre a výsledky.
## Stručný popis vyhodnocovacího procesu
1. **Prepare** - příprava běhu, kontrola locku, vyčištění staging tabulek.
2. **ParseLogs** - parsování EDI souboru, naplnění `logs` a `log_qsos`.
3. **BuildWorkingSet** - normalizace volacích znaků, lokátorů, příprava `working_qsos`.
4. **Match** - párování QSO mezi logy, detekce neshod a typy chyb.
5. **UnpairedClassification** - NIL/NO_COUNTERPART/UNIQUE pro nenapárovaná QSO.
6. **DuplicateResolution** - výběr přeživších u duplicit podle strategie.
7. **Score** - bodování, aplikace policy, penalizací a multiplikátorů.
8. **Aggregate** - součty za log (score, counts), výpočet metrik.
9. **ApplyLogOverrides** - ruční zásahy do log_results.
10. **RecalculateRanks** - přepočet pořadí.
11. **Finalize** - uzavření běhu a uvolnění locku.
## Politiky (společné chování)
Používané policy hodnoty:
- **INVALID**: `is_valid=false`, body 0.
- **ZERO_POINTS**: `is_valid=true`, body 0.
- **FLAG_ONLY**: `is_valid=true`, body beze změny (jen flag).
- **PENALTY**: `is_valid=true`, body 0 (u některých chyb body zůstávají) + penalizace.
Poznámka: `valid_qso_count` se počítá podle `is_valid`.
## Nastavení rulesetu a dopad (kde se uplatňuje)
### Skóre a body
- `scoring_mode` (DISTANCE / FIXED_POINTS): určuje typ bodování.
Uplatnění: scoring.
- `points_per_qso`: fixní body za QSO (FIXED_POINTS).
Uplatnění: scoring.
- `points_per_km`: body za km (DISTANCE).
Uplatnění: scoring.
- `distance_rounding` (FLOOR/ROUND/CEIL): zaokrouhlení vzdálenosti.
Uplatnění: scoring.
- `min_distance_km`: minimální vzdálenost pro bodované QSO.
Uplatnění: scoring.
### Multiplikátory
- `use_multipliers`: zapíná multiplikátory.
Uplatnění: scoring + agregace.
- `multiplier_type` (WWL/DXCC/SECTION/COUNTRY/NONE): typ multiplikátoru.
Uplatnění: scoring + agregace.
- `multiplier_scope` (PER_BAND/OVERALL): scope multiplikátoru.
Uplatnění: agregace.
- `multiplier_source` (VALID_ONLY/ALL_MATCHED): z čeho se počítají.
Uplatnění: agregace.
- `wwl_multiplier_level` (LOCATOR_2/4/6): délka WWL multiplikátoru.
Uplatnění: scoring.
### Error policy a penalizace
- `dup_qso_policy`, `nil_qso_policy`: policy pro DUP/NIL.
Uplatnění: scoring.
- `no_counterpart_log_policy`, `not_in_counterpart_log_policy`, `unique_qso_policy`,
`time_mismatch_policy`: policy pro NIL/UNIQUE/TIME_MISMATCH.
Uplatnění: scoring.
- `busted_call_policy`, `busted_exchange_policy`, `busted_serial_policy`,
`busted_locator_policy`, `busted_rst_policy`: policy pro BUSTED chyby.
Uplatnění: scoring.
- `penalty_*_points`: velikost penalizací pro jednotlivé chyby.
Uplatnění: scoring.
- `out_of_window_policy`, `penalty_out_of_window_points`: chování mimo časové okno.
Uplatnění: scoring.
### Matching (párování QSO)
- `time_tolerance_sec`: tolerance času pro match.
Uplatnění: matching.
- `allow_time_shift_one_hour`, `time_shift_seconds`: povolený časový posun.
Uplatnění: matching.
- `allow_time_mismatch_pairing`, `time_mismatch_max_sec`: párování mimo toleranci.
Uplatnění: matching.
- `callsign_normalization` (STRICT/IGNORE_SUFFIX),
`ignore_slash_part`, `ignore_third_part`,
`callsign_suffix_max_len`, `callsign_levenshtein_max`:
normalizace volacích znaků a fuzzy match.
Uplatnění: matching.
- `match_tiebreak_order`: pořadí tiebreak kritérií (time_diff, exchange_match, ...).
Uplatnění: matching (volba nejlepšího kandidáta).
- `match_require_locator_match`, `match_require_exchange_match`:
vyžadování shody lokátoru/exchange pro match.
Uplatnění: matching.
- `exchange_type`, `exchange_requires_*`, `exchange_pattern`:
definice a kontrola exchange.
Uplatnění: matching.
- `letters_in_rst`, `rst_ignore_third_char`: normalizace RST.
Uplatnění: matching.
- `discard_qso_*`: určuje, zda se neshoda označí jako BUSTED a s jakou stranou (RX/TX).
Uplatnění: matching.
- `checklog_matching`: zahrnout CHECK logy do matchingu.
Uplatnění: matching.
### Duplicity a unikátní QSO
- `dupe_scope` (BAND/BAND_MODE): klíč pro duplicity.
Uplatnění: working set + duplicity.
- `dup_resolution_strategy`: pořadí pravidel pro výběr přeživších DUP.
Uplatnění: duplicate resolution.
- `require_unique_qso`: zapíná detekci UNIQUE.
Uplatnění: unpaired klasifikace.
### DQ limity (log-level)
- `out_of_window_dq_threshold`: DQ při nadlimitních QSO mimo okno.
Uplatnění: agregace.
- `time_diff_dq_threshold_percent`, `time_diff_dq_threshold_sec`: DQ při časovém rozptylu.
Uplatnění: agregace.
- `bad_qso_dq_threshold_percent`: DQ při nadlimitním % chybných QSO.
Uplatnění: agregace.
### 6H operating window
- `operating_window_mode` (NONE/BEST_CONTIGUOUS): zapíná 6H operating window.
Uplatnění: agregace (vybere nejlepší 6H okno pro log).
- `operating_window_hours`: délka 6H okna (aktuálně pevně 6 h).
Uplatnění: agregace.
- `sixhr_ranking_mode` (IARU/CRK): způsob pořadí pro 6H.
Uplatnění: přepočet pořadí (IARU = jedna společná 6H tabulka bez SO/MO, CRK = odděleně SO/MO).
Poznámka: Pro IARU se 6H okno vybírá jako max. 2 segmenty s pauzou >= 2 h, součet délek <= 6 h.
### Options (JSON)
- `options`: fallback hodnoty, pokud není vyplněn sloupec.
Uplatnění: napříč matching/scoring (viz metody `getOption`/`get*` v modelu).
## Výchozí ruleset (default_vhf_compat)
Zdroj: `database/seeders/EvaluationRuleSetSeeder.php`
Nastavení (zkrácené na podstatné hodnoty):
- Profil: **Default VHF (compat)**, permisivní matching.
- Scoring: `scoring_mode=DISTANCE`, `points_per_qso=1`, `points_per_km=1.0`
- Multiplikátory: `use_multipliers=false`, `multiplier_type=WWL`
- Policy:
- `dup_qso_policy=ZERO_POINTS`
- `nil_qso_policy=ZERO_POINTS`
- `no_counterpart_log_policy=FLAG_ONLY`
- `not_in_counterpart_log_policy=ZERO_POINTS`
- `unique_qso_policy=FLAG_ONLY`
- `busted_*_policy=ZERO_POINTS`
- `time_mismatch_policy=ZERO_POINTS`
- penalizace vše 0
- Matching:
- `time_tolerance_sec=600`
- `allow_time_shift_one_hour=true`, `time_shift_seconds=3600`
- `allow_time_mismatch_pairing=false`
- `callsign_normalization=IGNORE_SUFFIX`
- `ignore_slash_part=true`, `ignore_third_part=true`
- `letters_in_rst=false`, `rst_ignore_third_char=true`
- `match_require_locator_match=false`, `match_require_exchange_match=false`
- `match_tiebreak_order=[time_diff, exchange_match, locator_match, report_match, log_qso_id]`
- `discard_qso_rec_diff_*=true`, `discard_qso_sent_diff_*=false`
- Duplicity:
- `dupe_scope=BAND`
- `dup_resolution_strategy=[paired_first, ok_first, earlier_time, lower_id]`
- Exchange:
- `exchange_type=SERIAL_WWL`
- `exchange_requires_wwl=true`, `exchange_requires_serial=true`, `exchange_requires_report=true`
- DQ limity:
- `out_of_window_dq_threshold=600`
- `time_diff_dq_threshold_percent=30`
- `time_diff_dq_threshold_sec=600`
- `bad_qso_dq_threshold_percent=30`
## Poznámky k interpretaci výsledků
- `valid_qso_count` = počet QSO s `is_valid=true`.
- `discarded_qso_count` = počet QSO s `is_valid=false`.
- Penalizace se uplatňuje v `penalty_score` a odčítá se od `base_score`.

View File

@@ -0,0 +1,149 @@
## 1) "Manažerský souhrn"
- Pipeline je vícekroková, deterministická a auditovaná přes `EvaluationRunEvent`.
- Pořadí: Prepare → Parse → Build working set → Match → Unpaired → Duplicity → Score → Aggregate → Overrides → Ranks → Finalize.
- Kritické kontroly: párování QSO (včetně chyb exchange), klasifikace nenapárovaných (NIL/NO_COUNTERPART/UNIQUE), duplicity, out-of-window.
- Body se počítají podle rulesetu: FIXED_POINTS nebo DISTANCE; politika chyb rozhoduje o validitě, bodech a penalizacích.
- Systém má tři „čekací“ body pro ruční kontrolu (input, matching, score) a podporuje ruční override logů/QSO.
## 2) Podrobný popis pipeline (technicky)
### Spuštění a příprava
1) **StartEvaluationRunJob / EvaluationCoordinator**
- Kontrola locku pro dané kolo (`evaluation:round:{round_id}`), přechod do `RUNNING`.
- Spuštění řetězce jobů (prepare → parse logs).
2) **PrepareRunJob**
- Vyčistí staging data (`qso_results`, `log_results`, `working_qsos`) pro daný run.
- Sestaví scope skupin (band/category/power) a uloží do `evaluation_runs.scope`.
- Vytvoří skeleton `log_results` pro všechny logy (včetně aplikace log overrides na band/kategorii/power).
- Zapisuje auditní eventy.
### Parsování vstupů a working set
3) **DispatchParseLogsJobsJob → ParseLogJob**
- Pro každý log načte EDI a naplní `logs` + `log_qsos`.
- Průběžný progress, chybné soubory jsou hlášeny jako eventy.
- Pro `rules_version=CLAIMED` se aktualizují deklarované výsledky.
4) **DispatchBuildWorkingSetJobsJob → BuildWorkingSetLogJob**
- Vytvoří `working_qsos` (normalizace callsignů, lokátorů, band, match_key, dupe_key).
- Kontroly:
- validace lokátoru (invalid → error v `working_qsos.errors`),
- out-of-window podle času kola,
- normalizace a klíče pro matching/duplikace.
- Logy s override `IGNORED` se vynechají.
- Po dokončení: **PauseEvaluationRunJob → WAITING_REVIEW_INPUT**.
### Matching a klasifikace nenapárovaných
5) **DispatchMatchJobsJob → MatchQsoBucketJob (PASS 1 + PASS 2)**
- Matching běží v „bucketu“ (band + call_norm), dvě fáze:
- PASS 1: pouze exact shody.
- PASS 2: u zbylých QSO povolí fuzzy shody dle rulesetu (tolerance, time shift, Levenshtein).
- Kontroly během matchingu:
- časová tolerance (`time_tolerance_sec`),
- mismatch exchange (callsign/RST/serial/locator) dle `discard_qso_*`,
- time mismatch se jen označí, validita se řeší až ve scoringu,
- ruční QSO override může vynutit match nebo status.
- Výstup: `qso_results` s `matched_log_qso_id`, `error_code`, `error_side`, `match_type`.
6) **DispatchUnpairedJobsJob → UnpairedClassificationBucketJob**
- Pro nenapárované QSO určí:
- `NOT_IN_COUNTERPART_LOG` (protistanice log má),
- `NO_COUNTERPART_LOG` (protistanice log nemá),
- `UNIQUE` (pokud je zapnuté `require_unique_qso`).
- Nastaví `error_code`, `is_nil`, `is_valid=false` (validita se následně řeší policy ve scoringu).
7) **DuplicateResolutionJob**
- Zpracuje duplicity v rámci logu podle `dupe_scope` (BAND/BAND_MODE).
- Strategie výběru „přeživšího“ QSO: `dup_resolution_strategy` (typicky paired_first → ok_first → earlier_time → lower_id).
- Nonsurvivory se označí `DUP` a připraví se na policy v bodech.
- Po dokončení: **PauseEvaluationRunJob → WAITING_REVIEW_MATCH**.
### Bodování a agregace
8) **DispatchScoreJobsJob → ScoreGroupJob**
- Pro každé QSO spočte základní body:
- `FIXED_POINTS`: `points_per_qso`
- `DISTANCE`: vzdálenost z lokátorů × `points_per_km`
- vzdálenost se zaokrouhluje (`distance_rounding`) a respektuje `min_distance_km`.
- Policy rozhodnutí (error_code → policy):
- `INVALID``is_valid=false`
- `ZERO_POINTS` → 0 bodů
- `FLAG_ONLY` → body beze změny
- `PENALTY` → 0 bodů + penalizace (u `BUSTED_RST` může body ponechat)
- Aplikuje se outofwindow policy (`out_of_window_policy`).
- Výsledky: `points`, `penalty_points`, multiplikátor (WWL/DXCC/SECTION/COUNTRY) pro pozdější agregaci.
9) **DispatchAggregateResultsJobsJob → AggregateLogResultsJob**
- Sečte `base_score` (validní QSO) a `penalty_score` (odečet penalizací).
- Pokud jsou multiplikátory aktivní: `multiplier_score = (base + penalty) × multiplier_count`.
- `official_score = max(0, multiplier_score)`.
- Počítá statistiky: valid/dupe/busted/unique/out-of-window/invalid, `score_per_qso`.
- DQ kontroly:
- `out_of_window_dq_threshold`,
- `time_diff_dq_threshold_percent` + `time_diff_dq_threshold_sec`,
- `bad_qso_dq_threshold_percent`.
- 6H: volí nejlepší operating window (pokud zapnuto).
- Navazuje **ApplyLogOverridesJob** + **RecalculateOfficialRanksJob**.
- Po dokončení: **PauseEvaluationRunJob → WAITING_REVIEW_SCORE**.
10) **FinalizeRunJob**
- Znov evidentně aplikuje overrides a přepočítá pořadí (pro jistotu).
- Uzavře běh (`SUCCEEDED`) a uvolní lock.
### Poznámky k ručním zásahům
- **Log overrides**: lze vynutit status/band/kategorii/power/6H; log lze i ignorovat.
- **QSO overrides**: lze vynutit match nebo stav QSO, případně body.
- Tři „čekací“ stavy jsou určeny pro ruční kontrolu a zásah rozhodčího.
## 3) Návrh prezentace (alfa verze a sběr feedbacku)
### Doporučená struktura prezentace (slide deck)
1) **Proč nový vyhodnocovač** cíl: determinismus, auditovatelnost, pravidla v rulesetu.
2) **Pipeline v 1 minutě** jediný graf s pořadím kroků + tři kontrolní „pause“.
3) **Matching principy** 2 passy, tolerance času, co je „busted“ vs „time mismatch“.
4) **NIL/NO_COUNTERPART/UNIQUE** kdy co vzniká a proč.
5) **Duplicity** strategie přeživších, důvod pro samostatný krok.
6) **Bodování** FIXED vs DISTANCE, validita až ve scoringu, policy + penalizace.
7) **Agregace a DQ** multiplikátory, 6H window, limity DQ.
8) **UI a ruční zásahy** ukázka overrides (log/QSO) + auditní eventy.
9) **Co umí alfa a co ještě ne** rizika, co je stabilní, co se bude dolaďovat.
10) **Co od vás potřebujeme** seznam dotazů, sběr pravidel a příkladů.
### Co získat
- Preferované policy pro NIL/DUP/BUSTED/TIME_MISMATCH (validita vs penalizace).
- Reálné hranice tolerancí (čas, matching pravidla, tiebreak priority).
- Pravidla pro 6H: reálná očekávání, jak interpretovat okno a pořadí.
- DQ prahy (out-of-window, bad QSO %, time diff).
- Seznam typických problémových situací z minulých ročníků.
### Otázky, na které se zeptat
- Co je v praxi považováno za „přijatelnou“ odchylku času?
- Kdy je chyba „busted“ vs jen „flag“ (nechat body)?
- Jak zacházet s unikátními QSO (UNIQUE) v různých soutěžích?
- Jaké ruční zásahy děláte dnes nejčastěji a proč?
- Jak má vypadat finální výstup pro rozhodčí (tabulka, export, log detail)?
## Slovníček pojmů a stavů
- **OK**: QSO bez chyb, standardni vstup do bodovani.
- **NIL**: nenaprovane QSO; protistanice se v matchingu nenasla.
- **NO_COUNTERPART_LOG**: protistanice nema zadny log; QSO je klasifikovano jako NIL.
- **NOT_IN_COUNTERPART_LOG**: protistanice log ma, ale konkretni QSO v nem chybi; QSO je klasifikovano jako NIL.
- **UNIQUE**: jediny zaznam o spojeni s danou protistanici v danem scope; pouziva se, pokud je zapnute `require_unique_qso`.
- **DUP**: duplicitni QSO v ramci logu podle `dupe_scope`; pouze "prezivsi" QSO boduje.
- **BUSTED_CALL**: neshoda volaciho znaku (callsign); urcuje se strana chyby (RX/TX).
- **BUSTED_RST**: neshoda RST reportu (pokud je RST soucasti exchange).
- **BUSTED_SERIAL**: neshoda serialu (nebo casti exchange, ktera se mapuje na serial).
- **BUSTED_LOCATOR**: neshoda lokatoru (WWL).
- **TIME_MISMATCH**: QSO sparovano, ale casovy rozdil mimo toleranci; resi se policy ve scoringu.
- **OUT_OF_WINDOW**: QSO mimo casove okno kola; bodovani urcuje `out_of_window_policy`.
- **ERROR_SIDE (RX/TX/NONE)**: kdo udelal chybu (prijem/vysilani/neurceno); ovlivnuje penalizace.

100
resources/icons/Icons.tsx Normal file
View File

@@ -0,0 +1,100 @@
import * as React from "react";
import { IconSvgProps } from "./types";
export const MoonFilledIcon = ({
size = 24,
width,
height,
...props
}: IconSvgProps) => (
<svg
aria-hidden="true"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<path
d="M21.53 15.93c-.16-.27-.61-.69-1.73-.49a8.46 8.46 0 01-1.88.13 8.409 8.409 0 01-5.91-2.82 8.068 8.068 0 01-1.44-8.66c.44-1.01.13-1.54-.09-1.76s-.77-.55-1.83-.11a10.318 10.318 0 00-6.32 10.21 10.475 10.475 0 007.04 8.99 10 10 0 002.89.55c.16.01.32.02.48.02a10.5 10.5 0 008.47-4.27c.67-.93.49-1.519.32-1.79z"
fill="currentColor"
/>
</svg>
);
export const SunFilledIcon = ({
size = 24,
width,
height,
...props
}: IconSvgProps) => (
<svg
aria-hidden="true"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<g fill="currentColor">
<path d="M19 12a7 7 0 11-7-7 7 7 0 017 7z" />
<path d="M12 22.96a.969.969 0 01-1-.96v-.08a1 1 0 012 0 1.038 1.038 0 01-1 1.04zm7.14-2.82a1.024 1.024 0 01-.71-.29l-.13-.13a1 1 0 011.41-1.41l.13.13a1 1 0 010 1.41.984.984 0 01-.7.29zm-14.28 0a1.024 1.024 0 01-.71-.29 1 1 0 010-1.41l.13-.13a1 1 0 011.41 1.41l-.13.13a1 1 0 01-.7.29zM22 13h-.08a1 1 0 010-2 1.038 1.038 0 011.04 1 .969.969 0 01-.96 1zM2.08 13H2a1 1 0 010-2 1.038 1.038 0 011.04 1 .969.969 0 01-.96 1zm16.93-7.01a1.024 1.024 0 01-.71-.29 1 1 0 010-1.41l.13-.13a1 1 0 011.41 1.41l-.13.13a.984.984 0 01-.7.29zm-14.02 0a1.024 1.024 0 01-.71-.29l-.13-.14a1 1 0 011.41-1.41l.13.13a1 1 0 010 1.41.97.97 0 01-.7.3zM12 3.04a.969.969 0 01-1-.96V2a1 1 0 012 0 1.038 1.038 0 01-1 1.04z" />
</g>
</svg>
);
export const HeartFilledIcon = ({
size = 24,
width,
height,
...props
}: IconSvgProps) => (
<svg
aria-hidden="true"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<path
d="M12.62 20.81c-.34.12-.9.12-1.24 0C8.48 19.82 2 15.69 2 8.69 2 5.6 4.49 3.1 7.56 3.1c1.82 0 3.43.88 4.44 2.24a5.53 5.53 0 0 1 4.44-2.24C19.51 3.1 22 5.6 22 8.69c0 7-6.48 11.13-9.38 12.12Z"
fill="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
/>
</svg>
);
export const SearchIcon = (props: IconSvgProps) => (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="1em"
role="presentation"
viewBox="0 0 24 24"
width="1em"
{...props}
>
<path
d="M11.5 21C16.7467 21 21 16.7467 21 11.5C21 6.25329 16.7467 2 11.5 2C6.25329 2 2 6.25329 2 11.5C2 16.7467 6.25329 21 11.5 21Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
<path
d="M22 22L20 20"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
);

5
resources/icons/types.ts Normal file
View File

@@ -0,0 +1,5 @@
import { SVGProps } from "react";
export type IconSvgProps = SVGProps<SVGSVGElement> & {
size?: number;
};

82
resources/js/Header.tsx Normal file
View File

@@ -0,0 +1,82 @@
'use client'
import {MouseEvent} from 'react'
import {Link} from 'react-router-dom'
import axios from 'axios'
import ThemeSwitch from '@/components/ThemeSwitch'
import { useTranslation } from 'react-i18next';
import LanguageSwitcher from '@/components/LanguageSwitcher';
import { useUserStore } from '@/stores/userStore'
// https://reactrouter.com/start/declarative/navigating
export default function Header() {
const { t } = useTranslation('common')
const user = useUserStore((s) => s.user);
const clearUser = useUserStore((s) => s.clearUser);
const isAuthenticated = Boolean(user);
const isAdmin = Boolean(user?.is_admin);
const handleLogout = async (event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault()
try {
await axios.post(
'/logout',
{},
{
withCredentials: true,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
},
)
clearUser()
window.location.href = '/'
} catch (error) {
console.error(t("logout_failed"), error)
}
}
return (
<header className="border-b border-gray-200 px-4 py-2 shadow-sm">
<nav className="mx-auto flex max-w-7xl items-center justify-between">
<span className="text-xl font-semibold">VKV</span>
<div className="flex gap-4 text-sm font-medium">
<Link to="/contests">
{t("contests_link")}
</Link>
{isAdmin && (
<>
<Link to="/admin/contests">
{t("admin_contests_link")}
</Link>
<Link to="/admin/news">
{t("admin_news_link")}
</Link>
<Link to="/admin/evaluation-rule-sets">
{t("admin_rulesets_link")}
</Link>
<Link to="/admin/users">
{t("admin_users_link") ?? "Uživatelé"}
</Link>
</>
)}
{isAuthenticated ? (
<Link to="/logout" onClick={handleLogout}>
{t("logout_link")}
</Link>
) : (
<Link to="/login">
{t("login_link")}
</Link>
)}
<LanguageSwitcher />
<ThemeSwitch />
</div>
</nav>
</header>
)
}

27
resources/js/Tail.tsx Normal file
View File

@@ -0,0 +1,27 @@
'use client'
import { useTranslation } from "react-i18next";
export default function Tail() {
const { t } = useTranslation("common");
const year = new Date().getFullYear();
return (
<footer className="border-t border-gray-200 px-6 py-4 text-sm text-gray-600">
<div className="mx-auto flex max-w-6xl flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<span>
{t("footer_rights", { year }) ??
`© ${year} VKV. Všechna práva vyhrazena.`}
</span>
<div className="flex gap-3">
<a href="https://example.com" target="_blank" rel="noreferrer">
{t("footer_docs") ?? "Dokumentace"}
</a>
<a href="https://example.com/support" target="_blank" rel="noreferrer">
{t("footer_support") ?? "Podpora"}
</a>
</div>
</div>
</footer>
)
}

26
resources/js/VkvApp.tsx Normal file
View File

@@ -0,0 +1,26 @@
// resources/js/VkvApp.tsx
import {HeroUIProvider} from '@heroui/react'
import {useHref, useNavigate, useRoutes} from 'react-router-dom'
import routes from './routes'
import Header from './Header'
import Tail from './Tail'
import AppErrorBoundary from './components/AppErrorBoundary'
export default function VkvApp() {
const navigate = useNavigate()
const routeElement = useRoutes(routes)
return (
<HeroUIProvider navigate={navigate} useHref={useHref}>
<div className="flex min-h-screen flex-col mx-auto">
<Header />
<main id="content" className="grow">
<AppErrorBoundary>
{routeElement}
</AppErrorBoundary>
</main>
<Tail />
</div>
</HeroUIProvider>
)
}

25
resources/js/app.tsx Normal file
View File

@@ -0,0 +1,25 @@
import './i18n';
import React from 'react';
import ReactDOM from 'react-dom/client';
import type {NavigateOptions} from "react-router-dom";
import {BrowserRouter} from "react-router-dom";
import VkvApp from './VkvApp'
const rootElement = document.getElementById('root')
if (!rootElement) {
throw new Error('Root element not found')
}
declare module "@react-types/shared" {
interface RouterConfig {
routerOptions: NavigateOptions;
}
}
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<BrowserRouter>
<VkvApp />
</BrowserRouter>
</React.StrictMode>,
);

4
resources/js/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,4 @@
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

11
resources/js/fonts.ts Normal file
View File

@@ -0,0 +1,11 @@
import { Fira_Code as FontMono, Inter as FontSans } from "next/font/google";
export const fontSans = FontSans({
subsets: ["latin"],
variable: "--font-sans",
});
export const fontMono = FontMono({
subsets: ["latin"],
variable: "--font-mono",
});

0
resources/js/global.css Normal file
View File

View File

@@ -0,0 +1,326 @@
import { useEffect, useMemo, useState } from "react";
import axios from "axios";
import { useTranslation } from "react-i18next";
export type EvaluationRun = {
id: number;
round_id: number;
name?: string | null;
rules_version?: string | null;
result_type?: string | null;
status?: string | null;
current_step?: string | null;
created_at?: string | null;
started_at?: string | null;
finished_at?: string | null;
progress_total?: number | null;
progress_done?: number | null;
};
export type EvaluationRunEvent = {
id: number;
level: string;
message: string;
context?: Record<string, unknown> | null;
created_at?: string | null;
};
type PaginatedResponse<T> = {
data: T[];
current_page: number;
last_page: number;
total: number;
};
const isActiveStatus = (status?: string | null) =>
status === "PENDING" ||
status === "RUNNING" ||
status === "WAITING_REVIEW_INPUT" ||
status === "WAITING_REVIEW_MATCH" ||
status === "WAITING_REVIEW_SCORE";
const isOfficialRun = (run: EvaluationRun | null) =>
!!run && run.rules_version !== "CLAIMED";
export const steps = [
{ key: "prepare", label: "Prepare" },
{ key: "parse_logs", label: "Parse" },
{ key: "build_working_set", label: "Working set" },
{ key: "waiting_review_input", label: "Review input" },
{ key: "match", label: "Match" },
{ key: "waiting_review_match", label: "Review match" },
{ key: "score", label: "Score" },
{ key: "aggregate", label: "Aggregate" },
{ key: "waiting_review_score", label: "Review score" },
{ key: "finalize", label: "Finalize" },
];
export default function useRoundEvaluationRun(roundId: number | null) {
const { i18n } = useTranslation();
const [run, setRun] = useState<EvaluationRun | null>(null);
const [runs, setRuns] = useState<EvaluationRun[]>([]);
const [events, setEvents] = useState<EvaluationRunEvent[]>([]);
const [loading, setLoading] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [pendingMessageClear, setPendingMessageClear] = useState(false);
const [lastKnownStep, setLastKnownStep] = useState<string | null>(null);
const [hasLoaded, setHasLoaded] = useState(false);
const load = async (opts?: { silent?: boolean }) => {
if (!roundId) return;
try {
if (!opts?.silent) {
setLoading(true);
}
if (!opts?.silent) {
setError(null);
}
const res = await axios.get<PaginatedResponse<EvaluationRun>>("/api/evaluation-runs", {
headers: { Accept: "application/json" },
params: { round_id: roundId, per_page: 20 },
withCredentials: true,
});
const officialRuns = res.data.data.filter((item) => item.rules_version !== "CLAIMED");
const limitedRuns = officialRuns.slice(0, 3);
const latest = limitedRuns[0] ?? null;
setRuns(limitedRuns);
setRun(latest);
setHasLoaded(true);
if (pendingMessageClear) {
setMessage(null);
setPendingMessageClear(false);
}
} catch (e: any) {
if (!opts?.silent) {
const msg = e?.response?.data?.message || "Nepodařilo se načíst stav vyhodnocení.";
setError(msg);
}
} finally {
if (!opts?.silent) {
setLoading(false);
}
}
};
const fetchEvents = async (runId: number) => {
try {
const res = await axios.get<EvaluationRunEvent[]>(
`/api/evaluation-runs/${runId}/events`,
{
headers: { Accept: "application/json" },
params: { limit: 8, min_level: "warning" },
withCredentials: true,
}
);
setEvents(res.data);
} catch {
setEvents([]);
}
};
useEffect(() => {
load();
}, [roundId]);
useEffect(() => {
if (!run || !isOfficialRun(run)) return;
fetchEvents(run.id);
}, [run?.id]);
const canStart = useMemo(() => {
if (!roundId) return false;
if (!run) return true;
return !isActiveStatus(run.status);
}, [roundId, run]);
const canResume =
run?.status === "WAITING_REVIEW_INPUT" ||
run?.status === "WAITING_REVIEW_MATCH" ||
run?.status === "WAITING_REVIEW_SCORE";
const canCancel = run && isActiveStatus(run.status);
const shouldPollRun =
run && isOfficialRun(run) && (run.status === "RUNNING" || run.status === "PENDING");
const shouldPollEvents = shouldPollRun;
const currentStepIndex = run
? steps.findIndex((step) => step.key === (run.current_step ?? lastKnownStep))
: -1;
const isSucceeded = run?.status === "SUCCEEDED";
const stepProgressPercent =
run && run.progress_total && run.progress_total > 0
? Math.round((run.progress_done / run.progress_total) * 100)
: null;
useEffect(() => {
if (!run || !shouldPollEvents) return;
const interval = window.setInterval(() => {
fetchEvents(run.id);
}, 1000);
return () => window.clearInterval(interval);
}, [run?.id, shouldPollEvents]);
useEffect(() => {
if (!run || !shouldPollRun) return;
const interval = window.setInterval(() => {
load({ silent: true });
}, 1000);
return () => window.clearInterval(interval);
}, [run?.id, shouldPollRun]);
useEffect(() => {
if (run?.current_step) {
setLastKnownStep(run.current_step);
}
}, [run?.current_step]);
const formatEventTime = (value?: string | null) => {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const locale = i18n.language?.startsWith("cs") ? "cs-CZ" : "en-US";
return new Intl.DateTimeFormat(locale, {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
};
const handleStart = async () => {
if (!roundId) return;
try {
setActionLoading(true);
setMessage(null);
setError(null);
const res = await axios.post(`/api/rounds/${roundId}/evaluation-runs/start`, null, {
headers: { Accept: "application/json" },
withCredentials: true,
withXSRFToken: true,
});
setRun(res.data);
setMessage("Vyhodnocení bylo spuštěno.");
setPendingMessageClear(true);
} catch (e: any) {
const msg = e?.response?.data?.message || "Nepodařilo se spustit vyhodnocení.";
setError(msg);
} finally {
setActionLoading(false);
}
};
const handleStartIncremental = async () => {
if (!roundId) return;
try {
setActionLoading(true);
setMessage(null);
setError(null);
const res = await axios.post(`/api/rounds/${roundId}/evaluation-runs/start-incremental`, null, {
headers: { Accept: "application/json" },
withCredentials: true,
withXSRFToken: true,
});
setRun(res.data);
setMessage("Vyhodnocení bylo spuštěno znovu.");
setPendingMessageClear(true);
} catch (e: any) {
const msg = e?.response?.data?.message || "Nepodařilo se spustit vyhodnocení znovu.";
setError(msg);
} finally {
setActionLoading(false);
}
};
const handleResume = async () => {
if (!run) return;
try {
setActionLoading(true);
setMessage(null);
setError(null);
await axios.post(`/api/evaluation-runs/${run.id}/resume`, null, {
headers: { Accept: "application/json" },
withCredentials: true,
withXSRFToken: true,
});
setMessage("Pokračování bylo spuštěno.");
setPendingMessageClear(true);
await load();
} catch (e: any) {
const msg = e?.response?.data?.message || "Nepodařilo se pokračovat.";
setError(msg);
} finally {
setActionLoading(false);
}
};
const handleCancel = async () => {
if (!run) return;
try {
setActionLoading(true);
setMessage(null);
setError(null);
await axios.post(`/api/evaluation-runs/${run.id}/cancel`, null, {
headers: { Accept: "application/json" },
withCredentials: true,
withXSRFToken: true,
});
setMessage("Vyhodnocení bylo zrušeno.");
await load();
} catch (e: any) {
const msg = e?.response?.data?.message || "Nepodařilo se zrušit běh.";
setError(msg);
} finally {
setActionLoading(false);
}
};
const handleSetResultType = async (resultType: "PRELIMINARY" | "FINAL" | "TEST") => {
if (!run) return;
try {
setActionLoading(true);
setMessage(null);
setError(null);
const res = await axios.post(
`/api/evaluation-runs/${run.id}/result-type`,
{ result_type: resultType },
{
headers: { Accept: "application/json" },
withCredentials: true,
withXSRFToken: true,
}
);
setRun(res.data);
setMessage("Typ výsledků byl uložen.");
setPendingMessageClear(true);
} catch (e: any) {
const msg = e?.response?.data?.message || "Nepodařilo se uložit typ výsledků.";
setError(msg);
} finally {
setActionLoading(false);
}
};
return {
run,
runs,
events,
loading,
actionLoading,
message,
error,
hasLoaded,
canStart,
canResume,
canCancel,
shouldPollRun,
isOfficialRun: isOfficialRun(run),
currentStepIndex,
isSucceeded,
stepProgressPercent,
formatEventTime,
handleStart,
handleStartIncremental,
handleResume,
handleCancel,
handleSetResultType,
};
}

View File

@@ -0,0 +1,151 @@
import { useEffect, useState } from "react";
import axios from "axios";
export type BandOption = {
id: number;
name: string;
order?: number | null;
has_power_category?: boolean | null;
};
export type EdiCategoryOption = {
id: number;
value: string;
};
export type EdiBandOption = {
id: number;
value: string;
bands?: { id: number; name: string }[];
};
type SelectedRoundLike = {
id?: number | null;
bands?: BandOption[];
} | null;
export const useRoundMeta = (roundId: number | null, selectedRound: SelectedRoundLike) => {
const [bands, setBands] = useState<BandOption[]>([]);
const [bandsError, setBandsError] = useState<string | null>(null);
const [bandsLoading, setBandsLoading] = useState(false);
const [ediCategories, setEdiCategories] = useState<EdiCategoryOption[]>([]);
const [ediCategoriesError, setEdiCategoriesError] = useState<string | null>(null);
const [ediCategoriesLoading, setEdiCategoriesLoading] = useState(false);
const [ediBands, setEdiBands] = useState<EdiBandOption[]>([]);
// načti dostupné bandy pro select PBand
useEffect(() => {
let active = true;
(async () => {
try {
setBandsLoading(true);
setBandsError(null);
let bandList: BandOption[] = [];
if (roundId && selectedRound?.id === roundId && Array.isArray(selectedRound.bands)) {
bandList = selectedRound.bands as BandOption[];
}
if (roundId) {
try {
const roundRes = await axios.get<any>(`/api/rounds/${roundId}`, {
headers: { Accept: "application/json" },
withCredentials: true,
});
if (Array.isArray(roundRes.data?.bands) && roundRes.data.bands.length > 0) {
bandList = roundRes.data.bands;
}
} catch {
// fallback handled below
}
}
if (!bandList.length) {
const res = await axios.get<BandOption[] | { data: BandOption[] }>("/api/bands", {
headers: { Accept: "application/json" },
withCredentials: true,
});
bandList = Array.isArray(res.data) ? res.data : (res.data as any)?.data ?? [];
}
if (!active) return;
const sorted = [...bandList].sort((a, b) => {
const aOrder = a.order ?? 0;
const bOrder = b.order ?? 0;
if (aOrder !== bOrder) return aOrder - bOrder;
return a.name.localeCompare(b.name);
});
setBands(sorted);
} catch {
if (!active) return;
setBandsError("Nepodařilo se načíst seznam pásem.");
} finally {
if (active) setBandsLoading(false);
}
})();
return () => {
active = false;
};
}, [roundId, selectedRound]);
// načti dostupné EDI kategorie pro validaci PSect
useEffect(() => {
let active = true;
(async () => {
try {
setEdiCategoriesLoading(true);
setEdiCategoriesError(null);
const res = await axios.get<EdiCategoryOption[] | { data: EdiCategoryOption[] }>("/api/edi-categories", {
headers: { Accept: "application/json" },
withCredentials: true,
});
if (!active) return;
const raw = Array.isArray(res.data) ? res.data : (res.data as any)?.data ?? [];
const sorted = [...raw].sort((a, b) => a.value.localeCompare(b.value));
setEdiCategories(sorted);
} catch {
if (!active) return;
setEdiCategoriesError("Nepodařilo se načíst seznam kategorií.");
} finally {
if (active) setEdiCategoriesLoading(false);
}
})();
return () => {
active = false;
};
}, []);
// načti EDI bandy pro mapování PBand -> Band
useEffect(() => {
let active = true;
(async () => {
try {
const res = await axios.get<EdiBandOption[] | { data: EdiBandOption[] }>("/api/edi-bands", {
headers: { Accept: "application/json" },
withCredentials: true,
params: { per_page: 500 },
});
if (!active) return;
const raw = Array.isArray(res.data) ? res.data : (res.data as any)?.data ?? [];
setEdiBands(raw);
} catch {
if (!active) return;
// nechávám tiché selhání, mapování je nice-to-have
}
})();
return () => {
active = false;
};
}, []);
return {
bands,
bandsError,
bandsLoading,
ediCategories,
ediCategoriesError,
ediCategoriesLoading,
ediBands,
};
};

31
resources/js/i18n.ts Normal file
View File

@@ -0,0 +1,31 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
const htmlLang = document.documentElement.lang || 'en';
const initialLang = htmlLang.split('-')[0];
i18n
.use(initReactI18next)
.init({
lng: initialLang,
fallbackLng: 'en',
interpolation: {
escapeValue: false,
},
resources: {
en: {
common: await import('./locales/en/common.json'),
},
cs: {
common: await import('./locales/cs/common.json'),
},
},
ns: ['common', 'ruleset'],
defaultNS: 'common',
});
export default i18n;
// mnozne cislo
//t('qso_count', { count: 1 });
//t('qso_count', { count: 5 });

View File

@@ -0,0 +1,139 @@
import {
createContext,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { Outlet, matchPath, useLocation } from "react-router-dom";
type TwoPaneLayoutConfig = {
left?: ReactNode;
rightTop?: ReactNode;
leftWidthClassName?: string;
leftCollapsedWidthClassName?: string;
};
const TwoPaneLayoutContext = createContext<TwoPaneLayoutConfig | null>(null);
const DEFAULT_LEFT_WIDTH_PX = 300;
const LEFT_RAIL_WIDTH_PX = 32;
const AUTO_HIDE_MS = 5000;
export function useTwoPaneLayout() {
const ctx = useContext(TwoPaneLayoutContext);
if (!ctx) throw new Error("TwoPaneLayoutContext missing");
return ctx;
}
export default function TwoPaneLayout({ value }: { value: TwoPaneLayoutConfig }) {
const {
left,
rightTop,
leftWidthClassName = "md:grid-cols-[300px_minmax(0,1fr)]",
leftCollapsedWidthClassName = "md:grid-cols-[10px_minmax(0,1fr)]",
} = value;
const location = useLocation();
const isContestsIndex = !!matchPath({ path: "/contests", end: true }, location.pathname);
const isContestDetail = !!matchPath({ path: "/contests/:contestId", end: true }, location.pathname);
const isRoundDetail = !!matchPath(
{ path: "/contests/:contestId/rounds/:roundId", end: true },
location.pathname,
);
const [leftCollapsed, setLeftCollapsed] = useState(!isContestsIndex);
const [leftPaneWidth, setLeftPaneWidth] = useState(DEFAULT_LEFT_WIDTH_PX);
const leftPaneRef = useRef<HTMLDivElement | null>(null);
const autoHideTimerRef = useRef<number | null>(null);
useEffect(() => {
setLeftCollapsed(!(isContestsIndex || isContestDetail));
}, [isContestsIndex, isContestDetail]);
useEffect(() => {
if (!leftPaneRef.current || leftCollapsed) return;
const width = leftPaneRef.current.getBoundingClientRect().width;
if (width > 0 && width !== leftPaneWidth) {
setLeftPaneWidth(width);
}
}, [leftCollapsed, leftPaneWidth, leftWidthClassName]);
const clearAutoHide = () => {
if (autoHideTimerRef.current) {
window.clearTimeout(autoHideTimerRef.current);
autoHideTimerRef.current = null;
}
};
const scheduleAutoHide = () => {
if (!isRoundDetail || leftCollapsed) return;
clearAutoHide();
autoHideTimerRef.current = window.setTimeout(() => {
setLeftCollapsed(true);
}, AUTO_HIDE_MS);
};
useEffect(() => {
if (!isRoundDetail || leftCollapsed) {
clearAutoHide();
return;
}
scheduleAutoHide();
return clearAutoHide;
}, [isRoundDetail, leftCollapsed]);
const handlePaneMouseEnter = () => {
clearAutoHide();
};
const handlePaneMouseLeave = () => {
scheduleAutoHide();
};
const translateX = leftCollapsed
? -(Math.max(0, leftPaneWidth - LEFT_RAIL_WIDTH_PX))
: 0;
const gridClassName = leftCollapsed ? leftCollapsedWidthClassName : leftWidthClassName;
return (
<TwoPaneLayoutContext.Provider value={value}>
<div className={`w-full grid grid-cols-1 ${gridClassName} gap-6`}>
<aside
className="min-w-0 relative"
onMouseEnter={handlePaneMouseEnter}
onMouseLeave={handlePaneMouseLeave}
>
<button
type="button"
onClick={() => setLeftCollapsed((prev) => !prev)}
className="absolute left-0 top-0 h-10 w-8 rounded-r bg-slate-200 text-slate-700 shadow hover:bg-slate-300 z-10"
aria-label="Toggle left panel"
onFocus={handlePaneMouseEnter}
onBlur={handlePaneMouseLeave}
>
{leftCollapsed ? ">" : "<"}
</button>
<div className="overflow-hidden pt-10">
<div
ref={leftPaneRef}
className={`min-w-0 transition-transform duration-300 ease-in-out${leftCollapsed ? " pointer-events-none" : ""}`}
style={{
width: leftCollapsed ? leftPaneWidth : undefined,
transform: `translateX(${translateX}px)`,
}}
aria-hidden={leftCollapsed || undefined}
onFocus={handlePaneMouseEnter}
onBlur={handlePaneMouseLeave}
>
{left}
</div>
</div>
</aside>
<section className="min-w-0 space-y-4">
{rightTop}
<Outlet />
</section>
</div>
</TwoPaneLayoutContext.Provider>
);
}

View File

@@ -0,0 +1,448 @@
{
"active": "Aktivní",
"close": "Zavřít",
"email": "Email",
"email_and_password_required": "Email and heslo jsou povinné.",
"enter_email": "Zadej svůj email",
"enter_password": "Zadej heslo",
"forgot_password": "Zapomenuté heslo?",
"inactive": "Neaktivní",
"login_dialog_label": "Přihlášení do VCM administrace",
"logout_failed": "Odhlášení se nepodařilo",
"password": "Heslo",
"qso_count_one": "{{count}} QSO",
"qso_count_few": "{{count}} QSO",
"qso_count_other": "{{count}} QSO",
"remember_me": "Zapamatovat přihlášení",
"sign_in": "Přihlásit",
"title": "VKV závody",
"unable_to_sign_in": "Přihlášení se nezdařilo.",
"yes": "Ano",
"no": "Ne",
"contest_name": "Název závodu",
"contest_description": "Popis závodu",
"rounds_loading": "Načítám kola…",
"unable_to_load_rounds": "Nepodařilo se načíst seznam kol.",
"rounds_empty": "Žádná kola nejsou k dispozici.",
"round_name": "Kolo",
"round_contest": "Závod",
"round_schedule": "Termín",
"round_active": "Aktivní",
"round_test": "Test",
"round_logs_deadline": "Logy do",
"round_logs_deadline_passed": "Termín pro nahrání logů již vypršel.",
"edit_round": "Upravit kolo",
"upload_log_title": "Nahrát log",
"select_file": "Vyber soubor",
"upload": "Nahrát soubor na server",
"upload_pband_mapped": "Hodnota PBand \"{{original}}\" z EDI souboru odpovídá \"{{mapped}}\" pásmu.",
"upload_pband_missing": "PBand není vyplněné v souboru, vyber pásmo ze seznamu.",
"upload_file_not_supported": "Soubor není podporován: chybí hlavička [REG1TEST;1].",
"upload_spowe_normalized_from": "SPowe bylo normalizováno z \"{{from}}\" na \"{{to}}\".",
"upload_spowe_normalized_to": "SPowe bylo normalizováno na \"{{value}}\".",
"upload_filename_missing_fields": "Doplň PCall, PSect a PBand, aby šlo ověřit název souboru.",
"upload_filename_unknown_code": "Nelze určit prefix názvu souboru pro zvolenou kategorii a pásmo.",
"upload_filename_valid": "Název souboru odpovídá pravidlům.",
"upload_filename_invalid": "Název souboru neodpovídá pravidlům. Doporučený tvar: {{expected}}",
"upload_filename_normalize": "Normalizovat název",
"upload_filename_used": "Při uploadu se použije: {{name}}",
"upload_error_pick_file": "Vyber soubor.",
"upload_error_pick_band": "Vyber pásmo (PBand) ze seznamu.",
"upload_error_band_unknown": "PBand \"{{band}}\" neodpovídá známým pásmům, vyber správnou hodnotu.",
"upload_error_pwwlo_required": "PWWLo je povinné.",
"upload_error_pwwlo_format": "PWWLo musí mít 6 znaků ve formátu lokátoru (AA00AA).",
"upload_error_rhbbs_required": "RHBBS je povinné.",
"upload_error_rhbbs_format": "RHBBS musí být ve formátu e-mailu.",
"upload_warning_rhbbs_required": "RHBBS chybí. Log lze nahrát, ale doporučujeme hodnotu doplnit.",
"upload_warning_rhbbs_format": "RHBBS není ve formátu e-mailu. Log lze nahrát, ale doporučujeme hodnotu opravit.",
"upload_error_tname_required": "TName je povinné.",
"upload_error_pcall_required": "PCall je povinné.",
"upload_error_pcall_format": "PCall musí být validní volací znak.",
"upload_error_psect_required": "PSect je povinné.",
"upload_error_psect_invalid": "PSect není validní.",
"upload_error_psect_not_iaru": "PSect není ve formátu IARU.",
"upload_error_spowe_required": "SPowe je povinné.",
"upload_error_spowe_format": "SPowe musí být číslo (může být desetinné, bez jednotek).",
"upload_error_spowe_length": "SPowe může mít maximálně 12 znaků.",
"upload_error_spowe_limit": "SPowe ({{value}} W) přesahuje limit pro kategorii {{category}} ({{limit}} W).",
"upload_error_sante_required": "SAnte je povinné.",
"upload_error_sante_length": "SAnte může mít maximálně 12 znaků.",
"upload_warn_sante_length": "Ve výsledcích bude zobrazeno pouze 12 znaků, váš popis antény je delší a bude oříznut.",
"upload_error_rcall_required": "RCall je povinné pro zvolenou kategorii.",
"upload_error_rcall_format": "RCall musí být validní volací znak.",
"upload_error_mope_missing": "Vyplň alespoň MOpe1 nebo MOpe2 (můžeš zadat více značek oddělených mezerou/středníkem/čárkou).",
"upload_error_mope_invalid": "Neplatné značky v MOpe1/MOpe2 ({{invalid}}).",
"upload_error_not_allowed": "Nahrávání není povoleno.",
"upload_file_drop_hint": "Přetáhni EDI soubor sem nebo klikni pro výběr.",
"upload_psect_format_button": "Upravit kategorie podle IARU",
"upload_error_psect_normalize": "PSect nelze normalizovat, oprav chyby a zkus to znovu.",
"upload_error_missing_round": "Chybí ID kola.",
"upload_error_power_band_mismatch": "PSect \"{{psect}}\" obsahuje výkonovou kategorii, ale pásmo \"{{band}}\" ji nepodporuje.",
"upload_error_file_read": "Soubor se nepodařilo přečíst.",
"upload_failed": "Nahrávání se nezdařilo.",
"files_uploaded": "Nahráno: {{files}}",
"upload_hint_authenticated": "Můžeš nahrát více souborů najednou.",
"upload_hint_closed": "Nahrávání je dostupné během závodu do uzávěrky logů, nebo se přihlas.",
"upload_hint_anonymous_once": "Anonymně lze nahrát jen jeden soubor.",
"upload_hint_anonymous": "Anonymně lze nahrát jeden soubor během závodu do uzávěrky logů.",
"edi_error_file_empty": "{{name}}: soubor je prázdný.",
"edi_error_missing_header": "{{name}}: chybí hlavička [REG1TEST;1].",
"edi_error_missing_field": "{{name}}: chybí {{field}}.",
"edi_error_tdate_format": "{{name}}: TDate musí být ve formátu YYYYMMDD;YYYYMMDD a první datum nesmí být větší než druhé.",
"edi_error_pwwlo_format": "{{name}}: PWWLo musí mít 6 znaků ve formátu lokátoru (AA00AA).",
"edi_error_pcall_format": "{{name}}: PCall musí být validní volací znak.",
"edi_error_rcall_for_psect": "{{name}}: chybí nebo je neplatné RCall pro PSect {{psect}}.",
"edi_error_mope_missing": "{{name}}: chybí MOpe1/MOpe2 pro multi operátory.",
"edi_error_mope_invalid": "{{name}}: neplatné značky v MOpe1/MOpe2 ({{invalid}}).",
"edi_error_missing_qso_records": "{{name}}: chybí sekce [QSORecords;N].",
"edi_error_qso_fields_min": "{{name}}: QSO #{{index}} má málo polí (minimálně {{min}} očekáváno).",
"edi_error_qso_date_format": "{{name}}: QSO #{{index}} má neplatné datum (YYMMDD).",
"edi_error_qso_year_out_of_range": "{{name}}: QSO #{{index}} má rok mimo rozsah TDate.",
"edi_error_qso_date_out_of_range": "{{name}}: QSO #{{index}} má datum mimo rozsah TDate.",
"edi_error_qso_time_format": "{{name}}: QSO #{{index}} má neplatný čas (HHMM UTC).",
"edi_error_qso_callsign": "{{name}}: QSO #{{index}} má neplatný volací znak.",
"edi_error_qso_edit_in_file": "{{name}}: Záznamy QSO nelze upravit ve formuláři, oprav je v souboru a zkus ho nahrát znovu.",
"edi_warning_qso_count_mismatch": "{{name}}: Deklarovaný počet QSO ({{expected}}) nesedí se skutečným ({{actual}}).",
"edi_error_psect_multiple_power": "PSect obsahuje více výkonových kategorií.",
"edi_error_psect_time_not_allowed": "PSect obsahuje časovou kategorii 6H, která není pro toto kolo povolena.",
"edi_warning_psect_time_not_allowed": "PSect obsahuje časovou kategorii 6H, která není pro toto kolo povolena. Log lze nahrát, ale doporučujeme hodnotu opravit.",
"edi_error_psect_multiple_time": "PSect obsahuje více časových kategorií.",
"edi_error_psect_missing_operator": "PSect musí obsahovat SO nebo MO.",
"edi_error_psect_check_extra": "PSect CHECK nesmí obsahovat žádné další tokeny.",
"edi_error_psect_unknown_tokens": "PSect obsahuje neznámé tokeny: {{tokens}}.",
"edi_warning_psect_unknown_tokens": "Neznámé tokeny v PSect: {{tokens}}.",
"edi_field_tname": "název soutěže (TName)",
"edi_field_tdate": "datum závodu (TDate=YYYYMMDD;YYYYMMDD)",
"edi_field_pcall": "volací znak (PCall)",
"edi_field_pwwlo": "lokátor (PWWLo)",
"edi_field_psect": "sekce/kategorie (PSect)",
"edi_field_pband": "použité pásmo (PBand)",
"edi_field_spowe": "výkon (SPowe)",
"edi_field_sante": "anténa (SAnte)",
"edi_field_cqsop": "QSO body (CQSOP)",
"edi_field_ctosc": "celkové body (CToSc)",
"declared_note_line1": "Deklarované výsledky jsou předběžné výsledky OK a OL stanic.",
"declared_note_line2": "Zobrazené mezinárodní výsledky nejsou oficiální výsledky a slouží pouze pro porovnání.",
"declared_note_line3": "Deklarované výsledky jsou uspořádány na základě údajů v hlavičce EDI souborů v řádce CQSOP=. Další sloupce rovněž zobrazují data z deníku, které jsou kontrolovány pouze na správnost formátu zápisu.",
"logs_loading": "Načítám logy…",
"logs_empty": "Žádné logy nejsou k dispozici.",
"unable_to_load_logs": "Nepodařilo se načíst logy.",
"logs_waiting_processing": "Čekám na zpracování",
"confirm_delete_log": "Opravdu smazat log?",
"edi_pcall_hint": "Volací znak použitý v závodě (PCall).",
"edi_padr1_hint": "Adresa QTH, řádek 1 (PAdr1).",
"edi_padr2_hint": "Adresa QTH, řádek 2 (PAdr2).",
"edi_mope1_hint": "Multi-operator řádek 1 (MOpe1).",
"edi_mope2_hint": "Multi-operator řádek 2 (MOpe2).",
"edi_rcall_hint": "Volací znak zodpovědného operátora (RCall).",
"edi_radr1_hint": "Adresa zodpovědného operátora, řádek 1 (RAdr1).",
"edi_radr2_hint": "Adresa zodpovědného operátora, řádek 2 (RAdr2).",
"edi_rpoco_rcity_hint": "PSČ a město zodpovědného operátora (RPoCo / RCity).",
"edi_rcoun_hint": "Země zodpovědného operátora (RCoun).",
"edi_rphon_hint": "Telefonní číslo zodpovědného operátora (RPhon).",
"edi_rhbbs_hint": "Domácí BBS zodpovědného operátora (RHBBS).",
"edi_stxeq_hint": "Použitá TX sestava (STXEq).",
"edi_srxeq_hint": "Použitá RX sestava (SRXEq).",
"edi_spowe_hint": "Výkon TX ve wattech (SPowe).",
"edi_sante_hint": "Použitá anténa (SAnte).",
"edi_santh_hint": "Výška antény nad zemí / nad mořem (SAntH).",
"six_hr_band_warning": "6H kategorie je povolena jen pro pásma 145 MHz a 435 MHz.",
"override_pre_match_title": "Ruční zásahy před matchingem",
"override_pre_match_hint": "Změny se projeví po kliknutí na „Pokračovat“. IGNORED vyřadí log z matchingu.",
"override_loading_logs": "Načítám logy…",
"override_no_logs": "Žádné logy k úpravě.",
"override_detail": "Detail",
"override_detail_title": "Detail logu",
"override_reason_prefix": "Důvod",
"override_status_label": "Status",
"override_status_auto": "AUTO",
"override_status_ignored": "IGNORED",
"override_status_check": "CHECK",
"override_status_ok": "OK",
"override_status_dq": "DQ",
"override_band_label": "Band",
"override_category_label": "Kategorie",
"override_power_label": "Výkon",
"override_sixhr_label": "6H",
"override_reason_label": "Důvod změny",
"override_auto": "AUTO",
"override_save": "Uložit",
"override_saving": "Ukládám…",
"override_saved": "Uloženo.",
"override_no_changes": "Bez změn.",
"override_reason_required": "Doplň důvod změny.",
"override_save_failed": "Nepodařilo se uložit override.",
"override_prev_page": "Předchozí",
"override_next_page": "Další",
"override_page_label": "Strana {{page}} / {{lastPage}}",
"qso_problem_label": "Problém",
"qso_problem_reason": "Důvod",
"qso_problem_errors": "Chyby",
"qso_problem_side": "Strana",
"qso_problem_confidence": "Match",
"qso_error_detail_exchange_serial_missing": "Chybí serial v exchange.",
"qso_error_detail_exchange_wwl_missing": "Chybí lokátor (WWL) v exchange.",
"qso_error_detail_exchange_custom_mismatch": "CUSTOM exchange neodpovídá očekávanému formátu.",
"qso_error_detail_exchange_serial_mismatch": "Neshoda serialu mezi stanicemi.",
"qso_error_detail_exchange_wwl_mismatch": "Neshoda lokátorů (WWL) mezi stanicemi.",
"qso_error_detail_exchange_serial_wwl_mismatch": "Neshoda serialu i lokátoru (WWL).",
"qso_error_detail_exchange_mismatch": "Neshoda exchange mezi stanicemi.",
"upload_hint_bulk_auth": "Hromadný upload je dostupný pouze pro přihlášené.",
"results_table_rank": "Pořadí",
"results_table_callsign": "Značka v závodě",
"results_table_locator": "Lokátor",
"results_table_category": "Kategorie",
"results_table_band": "Pásmo",
"results_table_power_watt": "Výkon [W]",
"results_table_power_category": "Výkonová kat.",
"results_table_score_total": "Body celkem",
"results_table_claimed_score": "Deklarované body",
"results_table_qso_count": "Počet QSO",
"results_table_discarded_qso": "Vyřazeno QSO",
"results_table_discarded_qso_help": "Počet QSO s is_valid=false.",
"results_table_discarded_points": "Vyřazeno bodů",
"results_table_unique_qso": "Unique QSO",
"results_table_score_per_qso": "Body / QSO",
"results_table_odx": "ODX",
"results_table_antenna": "Anténa",
"results_table_antenna_height": "Ant. height",
"results_table_status": "Status",
"results_table_override_reason": "Komentář rozhodčího",
"results_evaluating": "Vyhodnocuje se",
"results_tab": "Výsledky",
"declared_results_tab": "Deklarované výsledky",
"uploaded_logs_tab": "Nahrané logy",
"results_filter_all": "Všechny výsledky",
"results_filter_ok_ol": "OK/OL závodníci",
"declared_recalculate": "Přepočítat",
"declared_recalculate_started": "Přepočet byl spuštěn.",
"declared_recalculate_failed": "Nepodařilo se spustit přepočet.",
"round_detail_title": "Detail kola",
"round_detail_tabs_aria": "Záložky detailu kola",
"footer_rights": "© {{year}} VKV. Všechna práva vyhrazena.",
"footer_docs": "Dokumentace",
"footer_support": "Podpora",
"results_type_final": "Finální výsledky",
"results_type_declared": "Deklarované výsledky",
"results_type_log_collection_open": "Otevřeno pro sběr logů",
"results_type_not_started": "Závod ještě nezačal",
"results_type_preliminary": "Předběžné výsledky",
"results_type_test": "Testovací výsledky",
"evaluation_incremental_hint": "Spustit znovu převezme overrides z posledního běhu.",
"contests_title": "Závody",
"contest_rounds_title": "Kola závodu",
"open_rounds_title": "Závody:",
"contest_index_page": "Novinky a přehled závodů",
"select_none": "Žádný vybraný závod",
"select_contest": "Vyber závod",
"admin_form_close": "Zavřít formulář",
"admin_form_confirm_close": "Formulář obsahuje neuložené změny. Opravdu ho chcete zavřít?",
"value_na": "—",
"validation_number": "Musí být číslo.",
"validation_integer": "Musí být celé číslo.",
"validation_json": "Neplatný JSON.",
"validation_name_required": "Název je povinný.",
"validation_code_required": "Kód je povinný.",
"validation_range_1_100": "Rozsah 1100.",
"validation_min_one": "Musí být alespoň 1.",
"validation_min_zero": "Musí být alespoň 0.",
"validation_range_0_2": "Rozsah 02.",
"contest_new": "Nový závod",
"contest_edit": "Upravit závod",
"contest_add_new": "Přidat nový závod",
"contest_rounds_title_named": "Kola závodu \"{{name}}\":",
"round_new": "Nové kolo",
"round_edit": "Upravit kolo",
"round_add_new": "Přidat nové kolo",
"admin_news_title": "Novinky",
"admin_news_create": "Nová novinka",
"admin_news_loading": "Načítám novinky…",
"admin_news_load_failed": "Nepodařilo se načíst novinky.",
"admin_news_title_required": "Vyplň nadpis alespoň v jednom jazyce.",
"admin_news_content_required": "Vyplň obsah alespoň v jednom jazyce.",
"admin_news_created": "Novinka byla vytvořena.",
"admin_news_updated": "Novinka byla upravena.",
"admin_news_save_failed": "Chyba při ukládání novinky.",
"admin_news_table_aria": "Tabulka novinek",
"admin_news_title_cs": "Název (cs)",
"admin_news_title_en": "Název (en)",
"admin_news_published_at": "Publikováno",
"admin_news_published_flag": "Zveřejněno",
"admin_news_edit_aria": "Upravit novinku",
"admin_news_form_title_cs": "Nadpis (cs)",
"admin_news_form_title_en": "Nadpis (en)",
"admin_news_form_excerpt_cs": "Perex (cs)",
"admin_news_form_excerpt_en": "Perex (en)",
"admin_news_form_content_cs": "Obsah (cs)",
"admin_news_form_content_en": "Obsah (en)",
"admin_news_form_published_from": "Publikováno od",
"admin_news_form_publish": "Publikovat",
"admin_news_form_save": "Uložit změny",
"admin_news_form_create": "Vytvořit novinku",
"admin_rulesets_title": "Rulesety",
"admin_rulesets_loading": "Načítám rulesety…",
"admin_rulesets_load_failed": "Nepodařilo se načíst rulesety.",
"admin_rulesets_fix_errors": "Opravte chyby ve formuláři.",
"admin_rulesets_options_invalid_json": "Options nejsou validní JSON.",
"admin_rulesets_updated": "Ruleset byl upraven.",
"admin_rulesets_created": "Ruleset byl vytvořen.",
"admin_rulesets_save_failed": "Chyba při ukládání rulesetu.",
"admin_rulesets_help": "Nápověda",
"admin_rulesets_help_title": "Dokumentace rulesetu",
"admin_rulesets_table_aria": "Tabulka rulesetů",
"admin_rulesets_table_name": "Název",
"admin_rulesets_table_code": "Kód",
"admin_rulesets_table_scoring": "Bodování",
"admin_rulesets_table_multiplier": "Multiplikátor",
"admin_rulesets_table_updated": "Aktualizace",
"admin_rulesets_edit_aria": "Upravit ruleset",
"admin_rulesets_section_base_title": "Základ",
"admin_rulesets_section_base_desc": "Identita rulesetu a krátký popis pro rozhodčí.",
"admin_rulesets_label_name": "Název",
"admin_rulesets_label_code": "Kód",
"admin_rulesets_label_description": "Popis",
"admin_rulesets_section_scoring_title": "Bodování",
"admin_rulesets_section_scoring_desc": "Jak se počítají body a jak se zaokrouhluje vzdálenost.",
"admin_rulesets_label_scoring_mode": "Režim bodování",
"admin_rulesets_label_points_per_qso": "Body / QSO",
"admin_rulesets_label_points_per_km": "Body / km",
"admin_rulesets_label_distance_rounding": "Zaokrouhlení vzdálenosti",
"admin_rulesets_label_min_distance_km": "Min. vzdálenost (km)",
"admin_rulesets_section_operating_window_title": "Operating window",
"admin_rulesets_section_operating_window_desc": "Nastavení výběru nejlepšího 6H okna (max. 2 segmenty s pauzou >= 2 h).",
"admin_rulesets_label_operating_window_enabled": "6H operating window",
"admin_rulesets_label_operating_window_hours": "Operating window (hodiny)",
"admin_rulesets_label_sixhr_ranking_mode": "Režim pořadí 6H",
"admin_rulesets_section_matching_title": "Matching",
"admin_rulesets_section_matching_desc": "Pravidla pro párování a normalizaci volacích znaků.",
"admin_rulesets_label_time_tolerance_sec": "Tolerace času (s)",
"admin_rulesets_label_allow_time_shift": "Povolit posun času",
"admin_rulesets_label_time_shift_seconds": "Posun času (s)",
"admin_rulesets_label_time_mismatch_policy": "Politika časové neshody",
"admin_rulesets_label_allow_time_mismatch_pairing": "Párovat mimo toleranci",
"admin_rulesets_label_time_mismatch_max_sec": "Max. odchylka času (s)",
"admin_rulesets_label_dup_resolution_strategy": "Pořadí řešení duplicit",
"admin_rulesets_label_dup_resolution_placeholder": "paired_first, ok_first, earlier_time, lower_id",
"admin_rulesets_label_callsign_suffix_len": "Max délka suffixu",
"admin_rulesets_label_callsign_levenshtein": "Levenshtein max",
"admin_rulesets_label_callsign_normalization": "Normalizace volacího znaku",
"admin_rulesets_label_checklog_matching": "CHECK logy v matchingu",
"admin_rulesets_label_time_diff_dq_percent": "Časový rozdíl DQ %",
"admin_rulesets_label_time_diff_dq_sec": "Časový rozdíl DQ (s)",
"admin_rulesets_label_bad_qso_dq_percent": "Bad QSO DQ %",
"admin_rulesets_label_match_require_locator": "Matching vyžaduje lokátor",
"admin_rulesets_label_match_require_exchange": "Matching vyžaduje exchange",
"admin_rulesets_label_tiebreak_order": "Pořadí tie-breaku",
"admin_rulesets_label_tiebreak_placeholder": "time_diff, exchange_match, locator_match",
"admin_rulesets_label_ignore_suffix": "Ignorovat suffix v call",
"admin_rulesets_label_ignore_third_part": "Ignorovat 3. část call",
"admin_rulesets_label_rst_ignore_third_char": "Ignorovat 3. znak RST",
"admin_rulesets_label_letters_in_rst": "RST s písmeny",
"admin_rulesets_section_exchange_title": "Výměna",
"admin_rulesets_section_exchange_desc": "Nastavení typu exchange a povinných částí výměny.",
"admin_rulesets_label_exchange_type": "Typ exchange",
"admin_rulesets_label_exchange_requires_wwl": "Vyžadovat WWL",
"admin_rulesets_label_exchange_requires_serial": "Vyžadovat serial",
"admin_rulesets_label_exchange_requires_report": "Report součástí výměny",
"admin_rulesets_label_exchange_pattern": "Regex pro exchange",
"admin_rulesets_warning_report_required": "Busted RST se vyhodnocuje jen pokud je zapnuté „Report součástí výměny“.",
"admin_rulesets_section_errors_title": "Duplicity a busted",
"admin_rulesets_section_errors_desc": "Jak se označují a bodují DUP/NIL/BUSTED situace.",
"admin_rulesets_label_dupe_scope": "Rozsah DUP",
"admin_rulesets_label_unique_qso": "Unikátní QSO",
"admin_rulesets_label_busted_call_rx": "Busted call (RX)",
"admin_rulesets_label_busted_call_tx": "Busted call (TX)",
"admin_rulesets_label_busted_rst_rx": "Busted RST (RX)",
"admin_rulesets_label_busted_rst_tx": "Busted RST (TX)",
"admin_rulesets_label_busted_serial_rx": "Busted serial (RX)",
"admin_rulesets_label_busted_serial_tx": "Busted serial (TX)",
"admin_rulesets_label_busted_wwl_rx": "Busted WWL (RX)",
"admin_rulesets_label_busted_wwl_tx": "Busted WWL (TX)",
"admin_rulesets_label_busted_exchange_rx": "Busted exchange (RX)",
"admin_rulesets_label_busted_exchange_tx": "Busted exchange (TX)",
"admin_rulesets_label_dup_policy": "DUP policy",
"admin_rulesets_label_nil_policy": "NIL policy",
"admin_rulesets_label_no_counterpart_policy": "No counterpart policy",
"admin_rulesets_label_not_in_counterpart_policy": "Not in counterpart policy",
"admin_rulesets_label_unique_policy": "Unique policy",
"admin_rulesets_label_busted_call_policy": "Busted call policy",
"admin_rulesets_label_busted_rst_policy": "Busted RST policy",
"admin_rulesets_label_busted_exchange_policy": "Busted exchange policy",
"admin_rulesets_label_busted_serial_policy": "Busted serial policy",
"admin_rulesets_label_busted_locator_policy": "Busted locator policy",
"admin_rulesets_section_out_of_window_title": "Out-of-window",
"admin_rulesets_section_out_of_window_desc": "Chování pro QSO mimo časové okno kola.",
"admin_rulesets_label_out_of_window_policy": "Out-of-window policy",
"admin_rulesets_label_out_of_window_dq_threshold": "DQ práh (out-of-window)",
"admin_rulesets_label_require_locators": "Požadovat lokátory",
"admin_rulesets_section_multipliers_title": "Multiplikátory",
"admin_rulesets_section_multipliers_desc": "Nastavení typu a rozsahu multiplikátorů.",
"admin_rulesets_label_use_multipliers": "Používat multiplikátory",
"admin_rulesets_label_multiplier_type": "Typ multiplikátoru",
"admin_rulesets_label_multiplier_scope": "Rozsah multiplikátoru",
"admin_rulesets_label_multiplier_source": "Zdroj multiplikátoru",
"admin_rulesets_label_wwl_level": "Úroveň WWL",
"admin_rulesets_section_penalties_title": "Penalizace",
"admin_rulesets_section_penalties_desc": "Výše penalizací pro jednotlivé typy chyb.",
"admin_rulesets_label_penalty_dup": "Penalty DUP",
"admin_rulesets_label_penalty_nil": "Penalty NIL",
"admin_rulesets_label_penalty_busted_call": "Penalty busted call",
"admin_rulesets_label_penalty_busted_rst": "Penalty busted RST",
"admin_rulesets_label_penalty_busted_exchange": "Penalty busted exchange",
"admin_rulesets_label_penalty_busted_serial": "Penalty busted serial",
"admin_rulesets_label_penalty_busted_locator": "Penalty busted locator",
"admin_rulesets_label_penalty_out_of_window": "Penalty out-of-window",
"admin_rulesets_section_advanced_title": "Pokročilé",
"admin_rulesets_section_advanced_desc": "Volitelné rozšíření pravidel v JSON.",
"admin_rulesets_label_options_json": "Options (JSON)",
"admin_rulesets_create": "Nová sada pravidel",
"admin_rulesets_save": "Uložit změny",
"admin_rulesets_form_create_title": "Nová sada pravidel",
"admin_rulesets_form_edit_title": "Upravit sadu pravidel",
"about_link": "O aplikaci",
"admin_contests_link": "Správa závodů",
"admin_news_link": "Novinky",
"admin_rulesets_link": "Rulesety",
"admin_users_link": "Uživatelé",
"admin_users_title": "Uživatelé",
"admin_users_create": "Nový uživatel",
"admin_users_load_failed": "Nepodařilo se načíst uživatele.",
"admin_users_save_failed": "Chyba při ukládání uživatele.",
"admin_users_deactivate_failed": "Nepodařilo se deaktivovat uživatele.",
"admin_users_search": "Hledat jméno nebo email",
"admin_users_name": "Jméno",
"admin_users_email": "Email",
"admin_users_password": "Heslo",
"admin_users_password_hint": "Nech prázdné pro beze změny",
"admin_users_is_admin": "Admin",
"admin_users_is_active": "Aktivní",
"admin_users_actions": "Akce",
"admin_users_loading": "Načítám...",
"admin_users_empty": "Žádní uživatelé.",
"admin_users_edit": "Upravit",
"admin_users_deactivate": "Deaktivovat",
"admin_users_edit_title": "Upravit uživatele",
"admin_users_create_title": "Nový uživatel",
"admin_users_saving": "Ukládám…",
"admin_users_save": "Uložit",
"home_link": "Domů",
"contests_link": "Závody",
"contests_left_title": "Závody",
"login_link": "Přihlášení",
"logout_link": "Odhlášení",
"back": "Zpět",
"log": "Log",
"news_show_more": "Zobrazit další"
}

View File

@@ -0,0 +1,78 @@
{
"ruleset_help_name": "Název rulesetu pro administraci; nemá vliv na výpočet.",
"ruleset_help_code": "Stálý identifikátor rulesetu pro vazby, exporty a API; změna může rozbít reference.",
"ruleset_help_description": "Popis pravidel pro rozhodčí; výpočet neovlivňuje.",
"ruleset_help_scoring_mode": "Způsob bodování: DISTANCE = body z km; FIXED_POINTS = fixní body za valid QSO.",
"ruleset_help_points_per_qso": "Použije se jen ve FIXED_POINTS; valid QSO dostane tuto hodnotu bodů.",
"ruleset_help_points_per_km": "Použije se jen v DISTANCE; body = distance_km * points_per_km (po zaokrouhlení).",
"ruleset_help_use_multipliers": "Zapne multiplikátory v agregaci; při vypnutí se počítá multiplier_count=1.",
"ruleset_help_multiplier_type": "Typ multiplikátoru (WWL/DXCC/SECTION/COUNTRY), který se sbírá z QSO.",
"ruleset_help_multiplier_scope": "PER_BAND = multiplikátory se počítají zvlášť pro pásma; OVERALL = společně.",
"ruleset_help_multiplier_source": "VALID_ONLY bere jen valid QSO; ALL_MATCHED bere všechny matched (i nevalidní).",
"ruleset_help_wwl_multiplier_level": "Úroveň WWL (2/4/6) použitá pro počítání unikátů.",
"ruleset_help_checklog_matching": "Pokud je vypnuto, CHECK logy se z matchingu vyřadí a netvoří páry.",
"ruleset_help_dupe_scope": "Rozsah duplicity: BAND jen v rámci pásma; BAND_MODE i podle módu.",
"ruleset_help_callsign_normalization": "Jak se normalizují volací znaky pro matching a busted kontroly.",
"ruleset_help_callsign_suffix_max_len": "Maximální délka suffixu za lomítkem, která se má ignorovat (delší suffix se ponechá).",
"ruleset_help_callsign_levenshtein_max": "Maximální Levenshtein vzdálenost pro tolerantní matching callsignů (02).",
"ruleset_help_distance_rounding": "Jak se zaokrouhluje distance_km před bodováním (FLOOR/ROUND/CEIL).",
"ruleset_help_min_distance_km": "QSO pod touto vzdáleností nedostanou body nebo se označí jako nevalidní.",
"ruleset_help_operating_window_mode": "Zapne 6H operating window: započítají se max. 2 segmenty s pauzou >= 2 h a celkovou délkou <= 6 h.",
"ruleset_help_operating_window_hours": "Celková doba provozu je pevná (6 hodin).",
"ruleset_help_sixhr_ranking_mode": "IARU = jedna společná 6H tabulka bez SO/MO; CRK = samostatné pořadí pro SO a MO.",
"ruleset_help_time_tolerance_sec": "Maximální časový rozdíl pro matching; mimo toleranci se QSO spáruje hůř/není matched.",
"ruleset_help_allow_time_shift_one_hour": "Umožní tolerovat posun času (typicky +1h) při matchingu a označí QSO jako TIME_SHIFT.",
"ruleset_help_time_shift_seconds": "Posun v sekundách, který se zkouší jako alternativní časové okno (např. 3600).",
"ruleset_help_time_mismatch_policy": "Jak naložit s nesouladem času: INVALID označí QSO jako nevalidní; ZERO_POINTS = validní s 0 body; FLAG_ONLY ponechá body; PENALTY nastaví 0 bodů + penalizaci.",
"ruleset_help_allow_time_mismatch_pairing": "Povolí párování mimo časovou toleranci; výsledné QSO se označí TIME_MISMATCH.",
"ruleset_help_time_mismatch_max_sec": "Maximální odchylka času (v sekundách) pro párování mimo toleranci; NULL = bez limitu.",
"ruleset_help_time_diff_dq_threshold_percent": "Pokud je více než tento podíl matched QSO nad časovým prahem, log se označí jako DQ.",
"ruleset_help_time_diff_dq_threshold_sec": "Časový práh v sekundách pro výpočet podílu QSO s velkým rozdílem času.",
"ruleset_help_bad_qso_dq_threshold_percent": "Pokud podíl špatných QSO (NIL/DUP/BUSTED/OUT) dosáhne této hodnoty, log se vyřadí (DQ).",
"ruleset_help_out_of_window_dq_threshold": "Počet QSO mimo časové okno, po kterém se log přepne na DQ.",
"ruleset_help_require_locators": "Bez lokátorů se QSO označí jako nevalidní nebo nedostane body.",
"ruleset_help_require_unique_qso": "Duplicitní QSO se označí jako DUP a další už nebudou bodovat.",
"ruleset_help_out_of_window_policy": "Jak naložit s QSO mimo čas: IGNORE/0 bodů/PENALTY/INVALID.",
"ruleset_help_exchange_type": "Určuje, co je součást exchange a co se porovnává pro busted_exchange.",
"ruleset_help_exchange_requires_wwl": "Pokud chybí WWL, QSO se označí jako nevalidní/busted podle pravidel.",
"ruleset_help_exchange_requires_serial": "Pokud chybí serial, QSO se označí jako nevalidní/busted podle pravidel.",
"ruleset_help_exchange_requires_report": "RST je součást výměny; neshoda může vést na BUSTED_RST.",
"ruleset_help_exchange_pattern": "Regex pro CUSTOM exchange; nevyhovující hodnota => busted_exchange.",
"ruleset_help_match_tiebreak_order": "Pořadí kritérií při výběru kandidáta (čas, exchange, locator...).",
"ruleset_help_match_require_locator_match": "Matching povolí jen kandidáty se shodným lokátorem; jinak NIL.",
"ruleset_help_match_require_exchange_match": "Matching povolí jen kandidáty se shodnou výměnou; jinak NIL.",
"ruleset_help_letters_in_rst": "Povolí alfanumerický RST; jinak se validují jen čísla.",
"ruleset_help_ignore_slash_part": "Při normalizaci ignoruje suffix za / (např. /P).",
"ruleset_help_ignore_third_part": "Ignoruje třetí část volací značky (např. OK1ABC/0/P).",
"ruleset_help_rst_ignore_third_char": "Při porovnání RST ignoruje třetí znak (např. 59X).",
"ruleset_help_discard_qso_rec_diff_call": "Pokud se liší přijatý callsign (DX call) od odeslaného callsignu protistanice, QSO dostane error_code=BUSTED_CALL a error_side=RX. Body se neudělí; penalizace se aplikuje podle busted_call_policy.",
"ruleset_help_discard_qso_sent_diff_call": "Pokud se liší odeslaný callsign od přijatého callsignu protistanice, QSO dostane error_code=BUSTED_CALL a error_side=TX. Pro aktuální stanici se body nekrátí; penalizace se týká protistanice.",
"ruleset_help_discard_qso_rec_diff_rst": "Neshoda přijatého RST vůči protistanici označí QSO jako BUSTED_RST s error_side=RX. Body 0; penalizace dle busted_rst_policy.",
"ruleset_help_discard_qso_sent_diff_rst": "Neshoda odeslaného RST vůči protistanici označí QSO jako BUSTED_RST s error_side=TX. Pro aktuální stanici se body nekrátí; penalizace patří protistanici.",
"ruleset_help_discard_qso_rec_diff_serial": "Neshoda přijatého serialu vůči protistanici označí QSO jako BUSTED_SERIAL s error_side=RX. Politika podle busted_serial_policy.",
"ruleset_help_discard_qso_sent_diff_serial": "Neshoda odeslaného serialu vůči protistanici označí QSO jako BUSTED_SERIAL s error_side=TX. Pro aktuální stanici se body nekrátí; penalizace patří protistanici.",
"ruleset_help_discard_qso_rec_diff_wwl": "Neshoda přijatého WWL vůči protistanici označí QSO jako BUSTED_LOCATOR s error_side=RX. Politika podle busted_locator_policy.",
"ruleset_help_discard_qso_sent_diff_wwl": "Neshoda odeslaného WWL vůči protistanici označí QSO jako BUSTED_LOCATOR s error_side=TX. Pro aktuální stanici se body nekrátí; penalizace patří protistanici.",
"ruleset_help_discard_qso_rec_diff_code": "Neshoda přijatého exchange vůči protistanici označí QSO jako BUSTED_SERIAL nebo BUSTED_LOCATOR (dle typu exchange) s error_side=RX. Politiky podle busted_serial_policy/busted_locator_policy.",
"ruleset_help_discard_qso_sent_diff_code": "Neshoda odeslaného exchange vůči protistanici označí QSO jako BUSTED_SERIAL nebo BUSTED_LOCATOR (dle typu exchange) s error_side=TX. Pro aktuální stanici se body nekrátí; penalizace patří protistanici.",
"ruleset_help_dup_resolution_strategy": "Pořadí pravidel pro výběr survivor u duplicit (paired_first, ok_first, earlier_time, lower_id).",
"ruleset_help_dup_qso_policy": "DUP: ZERO_POINTS = 0; PENALTY = 0 + penalizace.",
"ruleset_help_nil_qso_policy": "NIL: ZERO_POINTS = 0; PENALTY = 0 + penalizace.",
"ruleset_help_no_counterpart_log_policy": "Bez protistanice: INVALID = nevalidní; ZERO_POINTS = validní s 0 body; FLAG_ONLY ponechá body; PENALTY = 0 + penalizace.",
"ruleset_help_not_in_counterpart_log_policy": "Není v logu protistanice: INVALID = nevalidní; ZERO_POINTS = validní s 0 body; FLAG_ONLY ponechá body; PENALTY = 0 + penalizace.",
"ruleset_help_unique_qso_policy": "Unikátní QSO: INVALID = nevalidní; ZERO_POINTS = validní s 0 body; FLAG_ONLY ponechá body.",
"ruleset_help_busted_call_policy": "BUSTED_CALL: ZERO_POINTS = 0; PENALTY = 0 + penalizace.",
"ruleset_help_busted_rst_policy": "BUSTED_RST: ZERO_POINTS = 0; PENALTY = ponechá body + penalizace.",
"ruleset_help_busted_exchange_policy": "BUSTED exchange fallback: ZERO_POINTS = 0; PENALTY = 0 + penalizace.",
"ruleset_help_busted_serial_policy": "BUSTED_SERIAL: ZERO_POINTS = 0; PENALTY = 0 + penalizace.",
"ruleset_help_busted_locator_policy": "BUSTED_LOCATOR: ZERO_POINTS = 0; PENALTY = 0 + penalizace.",
"ruleset_help_penalty_dup_points": "Výše penalizace (záporné body) za DUP, pokud je policy= PENALTY.",
"ruleset_help_penalty_nil_points": "Výše penalizace (záporné body) za NIL, pokud je policy= PENALTY.",
"ruleset_help_penalty_busted_call_points": "Výše penalizace (záporné body) za BUSTED_CALL, pokud je policy= PENALTY.",
"ruleset_help_penalty_busted_rst_points": "Výše penalizace (záporné body) za BUSTED_RST, pokud je policy= PENALTY.",
"ruleset_help_penalty_busted_exchange_points": "Výše penalizace (záporné body) za BUSTED exchange fallback, pokud je policy= PENALTY.",
"ruleset_help_penalty_busted_serial_points": "Výše penalizace (záporné body) za BUSTED_SERIAL, pokud je policy= PENALTY.",
"ruleset_help_penalty_busted_locator_points": "Výše penalizace (záporné body) za BUSTED_LOCATOR, pokud je policy= PENALTY.",
"ruleset_help_penalty_out_of_window_points": "Výše penalizace za QSO mimo časové okno, když out_of_window_policy= PENALTY.",
"ruleset_help_options_json": "Volitelná rozšíření pravidel v JSON; ovlivní výpočet jen pokud kód čte daný klíč."
}

View File

@@ -0,0 +1,444 @@
{
"active": "Active",
"close": "Close",
"email": "Email",
"email_and_password_required": "Email and password are required.",
"enter_email": "Enter your email",
"enter_password": "Enter your password",
"forgot_password": "Forgot password?",
"inactive": "Inactive",
"login_dialog_label": "Login to VCM admin",
"logout_failed": "Logout failed",
"password": "Password",
"qso_count_one": "{{count}} QSO",
"qso_count_other": "{{count}} QSOs",
"remember_me": "Remember me",
"sign_in": "Sign in",
"title": "VKV Contests",
"unable_to_sign_in": "Unable to sign in.",
"yes": "Yes",
"no": "No",
"rounds_loading": "Loading rounds…",
"unable_to_load_rounds": "Unable to load rounds.",
"rounds_empty": "No rounds available.",
"round_name": "Round",
"round_contest": "Contest",
"round_schedule": "Schedule",
"round_active": "Active",
"round_test": "Test",
"round_logs_deadline": "Logs deadline",
"round_logs_deadline_passed": "The deadline for uploading logs has passed.",
"edit_round": "Edit round",
"upload_log_title": "Upload log",
"select_file": "Select file",
"upload": "Upload file to server",
"upload_pband_mapped": "PBand \"{{original}}\" from EDI matches band \"{{mapped}}\".",
"upload_pband_missing": "PBand is missing in the file, choose a band.",
"upload_file_not_supported": "Unsupported file: missing [REG1TEST;1] header.",
"upload_spowe_normalized_from": "SPowe normalized from \"{{from}}\" to \"{{to}}\".",
"upload_spowe_normalized_to": "SPowe normalized to \"{{value}}\".",
"upload_filename_missing_fields": "Fill in PCall, PSect, and PBand so the filename can be checked.",
"upload_filename_unknown_code": "Unable to determine the filename prefix for the selected category and band.",
"upload_filename_valid": "The filename matches the required format.",
"upload_filename_invalid": "The filename does not match the required format. Suggested: {{expected}}",
"upload_filename_normalize": "Normalize name",
"upload_filename_used": "Upload will use: {{name}}",
"upload_error_pick_file": "Select a file.",
"upload_error_pick_band": "Select a band (PBand).",
"upload_error_band_unknown": "PBand \"{{band}}\" does not match known bands, choose a valid one.",
"upload_error_pwwlo_required": "PWWLo is required.",
"upload_error_pwwlo_format": "PWWLo must have 6 characters in locator format (AA00AA).",
"upload_error_rhbbs_required": "RHBBS is required.",
"upload_error_rhbbs_format": "RHBBS must be a valid email.",
"upload_warning_rhbbs_required": "RHBBS is missing. The log can be uploaded, but we recommend filling it in.",
"upload_warning_rhbbs_format": "RHBBS is not a valid email address. The log can be uploaded, but we recommend fixing it.",
"upload_error_tname_required": "TName is required.",
"upload_error_pcall_required": "PCall is required.",
"upload_error_pcall_format": "PCall must be a valid callsign.",
"upload_error_psect_required": "PSect is required.",
"upload_error_psect_invalid": "PSect is not valid.",
"upload_error_psect_not_iaru": "PSect is not in IARU format.",
"upload_error_spowe_required": "SPowe is required.",
"upload_error_spowe_format": "SPowe must be a number (decimals allowed, without units).",
"upload_error_spowe_length": "SPowe can be at most 12 characters.",
"upload_error_spowe_limit": "SPowe ({{value}} W) exceeds limit for category {{category}} ({{limit}} W).",
"upload_error_sante_required": "SAnte is required.",
"upload_error_sante_length": "SAnte can be at most 12 characters.",
"upload_warn_sante_length": "Only the first 12 characters will be shown in results; your antenna description is longer and will be truncated.",
"upload_error_rcall_required": "RCall is required for this category.",
"upload_error_rcall_format": "RCall must be a valid callsign.",
"upload_error_mope_missing": "Fill at least MOpe1 or MOpe2 (you can enter multiple calls separated by space/semicolon/comma).",
"upload_error_mope_invalid": "Invalid calls in MOpe1/MOpe2 ({{invalid}}).",
"upload_error_not_allowed": "Uploading is not allowed.",
"upload_file_drop_hint": "Drag & drop an EDI file here or click to choose.",
"upload_psect_format_button": "Adjust categories by IARU",
"upload_error_psect_normalize": "PSect cannot be normalized, fix errors and try again.",
"upload_error_missing_round": "Missing round ID.",
"upload_error_power_band_mismatch": "PSect \"{{psect}}\" contains a power category, but band \"{{band}}\" does not support it.",
"upload_error_file_read": "The file could not be read.",
"upload_failed": "Upload failed.",
"files_uploaded": "Uploaded: {{files}}",
"upload_hint_authenticated": "You can upload multiple files at once.",
"upload_hint_closed": "Uploads are allowed during contest until logs deadline, or sign in.",
"upload_hint_anonymous_once": "Anonymous upload allows only one file.",
"upload_hint_anonymous": "Anonymous upload allows one file during contest until logs deadline.",
"declared_note_line1": "Declared results are preliminary results for OK and OL stations.",
"declared_note_line2": "Displayed international results are not official and are for comparison only.",
"declared_note_line3": "Declared results are ordered based on the EDI header field CQSOP=. Other columns also show log data that are only checked for proper format.",
"logs_loading": "Loading logs…",
"logs_empty": "No logs available.",
"unable_to_load_logs": "Unable to load logs.",
"logs_waiting_processing": "Waiting for processing",
"confirm_delete_log": "Really delete the log?",
"edi_pcall_hint": "Callsign used during contest (PCall).",
"edi_padr1_hint": "QTH address line 1 (PAdr1).",
"edi_padr2_hint": "QTH address line 2 (PAdr2).",
"edi_mope1_hint": "Multi-operator line 1 (MOpe1).",
"edi_mope2_hint": "Multi-operator line 2 (MOpe2).",
"edi_rcall_hint": "Responsible operator callsign (RCall).",
"edi_radr1_hint": "Responsible operator address line 1 (RAdr1).",
"edi_radr2_hint": "Responsible operator address line 2 (RAdr2).",
"edi_rpoco_rcity_hint": "Postal code / city of responsible operator (RPoCo / RCity).",
"edi_rcoun_hint": "Country of responsible operator (RCoun).",
"edi_rphon_hint": "Phone of responsible operator (RPhon).",
"edi_rhbbs_hint": "Home BBS of responsible operator (RHBBS).",
"edi_stxeq_hint": "TX equipment (STXEq).",
"edi_srxeq_hint": "RX equipment (SRXEq).",
"edi_spowe_hint": "TX power in watts (SPowe).",
"edi_sante_hint": "Antenna used (SAnte).",
"edi_santh_hint": "Antenna height above ground/sea (SAntH).",
"six_hr_band_warning": "6H category is allowed only for bands 145 MHz and 435 MHz.",
"override_pre_match_title": "Manual overrides before matching",
"override_pre_match_hint": "Changes take effect after clicking “Continue”. IGNORED excludes the log from matching.",
"override_loading_logs": "Loading logs…",
"override_no_logs": "No logs to edit.",
"override_detail": "Detail",
"override_detail_title": "Log detail",
"override_reason_prefix": "Reason",
"override_status_label": "Status",
"override_status_auto": "AUTO",
"override_status_ignored": "IGNORED",
"override_status_check": "CHECK",
"override_status_ok": "OK",
"override_status_dq": "DQ",
"override_band_label": "Band",
"override_category_label": "Category",
"override_power_label": "Power",
"override_sixhr_label": "6H",
"override_reason_label": "Reason for change",
"override_auto": "AUTO",
"override_save": "Save",
"override_saving": "Saving…",
"override_saved": "Saved.",
"override_no_changes": "No changes.",
"override_reason_required": "Please provide a reason for the change.",
"override_save_failed": "Failed to save override.",
"override_prev_page": "Previous",
"override_next_page": "Next",
"override_page_label": "Page {{page}} / {{lastPage}}",
"qso_problem_label": "Problem",
"qso_problem_reason": "Reason",
"qso_problem_errors": "Errors",
"qso_problem_side": "Side",
"qso_problem_confidence": "Match",
"qso_error_detail_exchange_serial_missing": "Serial is missing in the exchange.",
"qso_error_detail_exchange_wwl_missing": "Locator (WWL) is missing in the exchange.",
"qso_error_detail_exchange_custom_mismatch": "CUSTOM exchange does not match the expected format.",
"qso_error_detail_exchange_serial_mismatch": "Serial mismatch between stations.",
"qso_error_detail_exchange_wwl_mismatch": "Locator (WWL) mismatch between stations.",
"qso_error_detail_exchange_serial_wwl_mismatch": "Serial and locator (WWL) mismatch.",
"qso_error_detail_exchange_mismatch": "Exchange mismatch between stations.",
"upload_hint_bulk_auth": "Bulk upload is available only for signed-in users.",
"results_table_rank": "Rank",
"results_table_callsign": "Callsign",
"results_table_locator": "Locator",
"results_table_category": "Category",
"results_table_band": "Band",
"results_table_power_watt": "Power [W]",
"results_table_power_category": "Power category",
"results_table_score_total": "Total score",
"results_table_claimed_score": "Declared score",
"results_table_qso_count": "QSO count",
"results_table_discarded_qso": "Discarded QSOs",
"results_table_discarded_qso_help": "Count of QSOs with is_valid=false.",
"results_table_discarded_points": "Discarded points",
"results_table_unique_qso": "Unique QSOs",
"results_table_score_per_qso": "Score / QSO",
"results_table_odx": "ODX",
"results_table_antenna": "Antenna",
"results_table_antenna_height": "Antenna height",
"results_table_status": "Status",
"results_table_override_reason": "Referee note",
"results_evaluating": "Evaluation in progress",
"results_tab": "Results",
"declared_results_tab": "Declared results",
"uploaded_logs_tab": "Uploaded logs",
"results_filter_all": "All results",
"results_filter_ok_ol": "OK/OL competitors",
"declared_recalculate": "Recalculate",
"declared_recalculate_started": "Recalculation started.",
"declared_recalculate_failed": "Failed to start recalculation.",
"round_detail_title": "Round detail",
"round_detail_tabs_aria": "Round detail tabs",
"footer_rights": "© {{year}} VKV. All rights reserved.",
"footer_docs": "Documentation",
"footer_support": "Support",
"results_type_final": "Final results",
"results_type_declared": "Declared results",
"results_type_log_collection_open": "Open for log collection",
"results_type_not_started": "Contest not started yet",
"results_type_preliminary": "Preliminary results",
"results_type_test": "Test results",
"evaluation_incremental_hint": "Restarting will reuse overrides from the last run.",
"contests_title": "Contests",
"contest_rounds_title": "Contest rounds",
"open_rounds_title": "Contests:",
"contest_index_page": "Contests overview",
"select_none": "No contest selected",
"select_contest": "Select contest",
"admin_form_close": "Close form",
"admin_form_confirm_close": "The form has unsaved changes. Do you really want to close it?",
"value_na": "—",
"validation_number": "Must be a number.",
"validation_integer": "Must be an integer.",
"validation_json": "Invalid JSON.",
"validation_name_required": "Name is required.",
"validation_code_required": "Code is required.",
"validation_range_1_100": "Range 1100.",
"validation_min_one": "Must be at least 1.",
"validation_min_zero": "Must be at least 0.",
"validation_range_0_2": "Range 02.",
"contest_new": "New contest",
"contest_edit": "Edit contest",
"contest_add_new": "Add new contest",
"contest_rounds_title_named": "Contest rounds \"{{name}}\":",
"round_new": "New round",
"round_edit": "Edit round",
"round_add_new": "Add new round",
"admin_news_title": "News",
"admin_news_create": "New news",
"admin_news_loading": "Loading news…",
"admin_news_load_failed": "Failed to load news.",
"admin_news_title_required": "Fill in the title in at least one language.",
"admin_news_content_required": "Fill in the content in at least one language.",
"admin_news_created": "News item created.",
"admin_news_updated": "News item updated.",
"admin_news_save_failed": "Failed to save news item.",
"admin_news_table_aria": "News table",
"admin_news_title_cs": "Title (cs)",
"admin_news_title_en": "Title (en)",
"admin_news_published_at": "Published",
"admin_news_published_flag": "Visible",
"admin_news_edit_aria": "Edit news item",
"admin_news_form_title_cs": "Title (cs)",
"admin_news_form_title_en": "Title (en)",
"admin_news_form_excerpt_cs": "Excerpt (cs)",
"admin_news_form_excerpt_en": "Excerpt (en)",
"admin_news_form_content_cs": "Content (cs)",
"admin_news_form_content_en": "Content (en)",
"admin_news_form_published_from": "Published from",
"admin_news_form_publish": "Publish",
"admin_news_form_save": "Save changes",
"admin_news_form_create": "Create news",
"admin_rulesets_title": "Rule sets",
"admin_rulesets_loading": "Loading rule sets…",
"admin_rulesets_load_failed": "Failed to load rule sets.",
"admin_rulesets_fix_errors": "Fix the errors in the form.",
"admin_rulesets_options_invalid_json": "Options are not valid JSON.",
"admin_rulesets_updated": "Rule set updated.",
"admin_rulesets_created": "Rule set created.",
"admin_rulesets_save_failed": "Failed to save rule set.",
"admin_rulesets_help": "Help",
"admin_rulesets_help_title": "Ruleset documentation",
"admin_rulesets_table_aria": "Rule sets table",
"admin_rulesets_table_name": "Name",
"admin_rulesets_table_code": "Code",
"admin_rulesets_table_scoring": "Scoring",
"admin_rulesets_table_multiplier": "Multiplier",
"admin_rulesets_table_updated": "Updated",
"admin_rulesets_edit_aria": "Edit rule set",
"admin_rulesets_section_base_title": "Basics",
"admin_rulesets_section_base_desc": "Rule set identity and a short description for referees.",
"admin_rulesets_label_name": "Name",
"admin_rulesets_label_code": "Code",
"admin_rulesets_label_description": "Description",
"admin_rulesets_section_scoring_title": "Scoring",
"admin_rulesets_section_scoring_desc": "How points are calculated and distance rounding.",
"admin_rulesets_label_scoring_mode": "Scoring mode",
"admin_rulesets_label_points_per_qso": "Points / QSO",
"admin_rulesets_label_points_per_km": "Points / km",
"admin_rulesets_label_distance_rounding": "Distance rounding",
"admin_rulesets_label_min_distance_km": "Min. distance (km)",
"admin_rulesets_section_operating_window_title": "Operating window",
"admin_rulesets_section_operating_window_desc": "Best 6H operating window selection (up to two segments separated by >=2h).",
"admin_rulesets_label_operating_window_enabled": "6H operating window",
"admin_rulesets_label_operating_window_hours": "Operating window (hours)",
"admin_rulesets_label_sixhr_ranking_mode": "6H ranking mode",
"admin_rulesets_section_matching_title": "Matching",
"admin_rulesets_section_matching_desc": "Rules for pairing and callsign normalization.",
"admin_rulesets_label_time_tolerance_sec": "Time tolerance (s)",
"admin_rulesets_label_allow_time_shift": "Allow time shift",
"admin_rulesets_label_time_shift_seconds": "Time shift (s)",
"admin_rulesets_label_time_mismatch_policy": "Time mismatch policy",
"admin_rulesets_label_allow_time_mismatch_pairing": "Pair outside tolerance",
"admin_rulesets_label_time_mismatch_max_sec": "Max time mismatch (s)",
"admin_rulesets_label_dup_resolution_strategy": "Dup resolution order",
"admin_rulesets_label_dup_resolution_placeholder": "paired_first, ok_first, earlier_time, lower_id",
"admin_rulesets_label_callsign_suffix_len": "Max suffix length",
"admin_rulesets_label_callsign_levenshtein": "Levenshtein max",
"admin_rulesets_label_callsign_normalization": "Callsign normalization",
"admin_rulesets_label_checklog_matching": "Include CHECK logs in matching",
"admin_rulesets_label_time_diff_dq_percent": "Time diff DQ %",
"admin_rulesets_label_time_diff_dq_sec": "Time diff DQ (s)",
"admin_rulesets_label_bad_qso_dq_percent": "Bad QSO DQ %",
"admin_rulesets_label_match_require_locator": "Matching requires locator",
"admin_rulesets_label_match_require_exchange": "Matching requires exchange",
"admin_rulesets_label_tiebreak_order": "Tiebreak order",
"admin_rulesets_label_tiebreak_placeholder": "time_diff, exchange_match, locator_match",
"admin_rulesets_label_ignore_suffix": "Ignore suffix in call",
"admin_rulesets_label_ignore_third_part": "Ignore 3rd part of call",
"admin_rulesets_label_rst_ignore_third_char": "Ignore 3rd RST character",
"admin_rulesets_label_letters_in_rst": "RST with letters",
"admin_rulesets_section_exchange_title": "Exchange",
"admin_rulesets_section_exchange_desc": "Exchange type and required parts.",
"admin_rulesets_label_exchange_type": "Exchange type",
"admin_rulesets_label_exchange_requires_wwl": "Require WWL",
"admin_rulesets_label_exchange_requires_serial": "Require serial",
"admin_rulesets_label_exchange_requires_report": "Report is part of exchange",
"admin_rulesets_label_exchange_pattern": "Exchange regex",
"admin_rulesets_warning_report_required": "Busted RST is evaluated only when “Report is part of exchange” is enabled.",
"admin_rulesets_section_errors_title": "Duplicates and busted",
"admin_rulesets_section_errors_desc": "How DUP/NIL/BUSTED situations are marked and scored.",
"admin_rulesets_label_dupe_scope": "Dupe scope",
"admin_rulesets_label_unique_qso": "Unique QSO",
"admin_rulesets_label_busted_call_rx": "Busted call (RX)",
"admin_rulesets_label_busted_call_tx": "Busted call (TX)",
"admin_rulesets_label_busted_rst_rx": "Busted RST (RX)",
"admin_rulesets_label_busted_rst_tx": "Busted RST (TX)",
"admin_rulesets_label_busted_serial_rx": "Busted serial (RX)",
"admin_rulesets_label_busted_serial_tx": "Busted serial (TX)",
"admin_rulesets_label_busted_wwl_rx": "Busted WWL (RX)",
"admin_rulesets_label_busted_wwl_tx": "Busted WWL (TX)",
"admin_rulesets_label_busted_exchange_rx": "Busted exchange (RX)",
"admin_rulesets_label_busted_exchange_tx": "Busted exchange (TX)",
"admin_rulesets_label_dup_policy": "DUP policy",
"admin_rulesets_label_nil_policy": "NIL policy",
"admin_rulesets_label_no_counterpart_policy": "No counterpart policy",
"admin_rulesets_label_not_in_counterpart_policy": "Not in counterpart policy",
"admin_rulesets_label_unique_policy": "Unique policy",
"admin_rulesets_label_busted_call_policy": "Busted call policy",
"admin_rulesets_label_busted_rst_policy": "Busted RST policy",
"admin_rulesets_label_busted_exchange_policy": "Busted exchange policy",
"admin_rulesets_label_busted_serial_policy": "Busted serial policy",
"admin_rulesets_label_busted_locator_policy": "Busted locator policy",
"admin_rulesets_section_out_of_window_title": "Out-of-window",
"admin_rulesets_section_out_of_window_desc": "Behavior for QSOs outside the contest time window.",
"admin_rulesets_label_out_of_window_policy": "Out-of-window policy",
"admin_rulesets_label_out_of_window_dq_threshold": "DQ threshold (out-of-window)",
"admin_rulesets_label_require_locators": "Require locators",
"admin_rulesets_section_multipliers_title": "Multipliers",
"admin_rulesets_section_multipliers_desc": "Multiplier type and scope settings.",
"admin_rulesets_label_use_multipliers": "Use multipliers",
"admin_rulesets_label_multiplier_type": "Multiplier type",
"admin_rulesets_label_multiplier_scope": "Multiplier scope",
"admin_rulesets_label_multiplier_source": "Multiplier source",
"admin_rulesets_label_wwl_level": "WWL level",
"admin_rulesets_section_penalties_title": "Penalties",
"admin_rulesets_section_penalties_desc": "Penalty values per error type.",
"admin_rulesets_label_penalty_dup": "Penalty DUP",
"admin_rulesets_label_penalty_nil": "Penalty NIL",
"admin_rulesets_label_penalty_busted_call": "Penalty busted call",
"admin_rulesets_label_penalty_busted_rst": "Penalty busted RST",
"admin_rulesets_label_penalty_busted_exchange": "Penalty busted exchange",
"admin_rulesets_label_penalty_busted_serial": "Penalty busted serial",
"admin_rulesets_label_penalty_busted_locator": "Penalty busted locator",
"admin_rulesets_label_penalty_out_of_window": "Penalty out-of-window",
"admin_rulesets_section_advanced_title": "Advanced",
"admin_rulesets_section_advanced_desc": "Optional JSON rules extensions.",
"admin_rulesets_label_options_json": "Options (JSON)",
"admin_rulesets_create": "New rule set",
"admin_rulesets_save": "Save changes",
"admin_rulesets_form_create_title": "New rule set",
"admin_rulesets_form_edit_title": "Edit rule set",
"edi_error_file_empty": "{{name}}: file is empty.",
"edi_error_missing_header": "{{name}}: missing header [REG1TEST;1].",
"edi_error_missing_field": "{{name}}: missing {{field}}.",
"edi_error_tdate_format": "{{name}}: TDate must be in YYYYMMDD;YYYYMMDD format and the first date must not be after the second.",
"edi_error_pwwlo_format": "{{name}}: PWWLo must be a 6-character locator (AA00AA).",
"edi_error_pcall_format": "{{name}}: PCall must be a valid callsign.",
"edi_error_rcall_for_psect": "{{name}}: missing or invalid RCall for PSect {{psect}}.",
"edi_error_mope_missing": "{{name}}: MOpe1/MOpe2 is required for multi operators.",
"edi_error_mope_invalid": "{{name}}: invalid callsigns in MOpe1/MOpe2 ({{invalid}}).",
"edi_error_missing_qso_records": "{{name}}: missing section [QSORecords;N].",
"edi_error_qso_fields_min": "{{name}}: QSO #{{index}} has too few fields (at least {{min}} expected).",
"edi_error_qso_date_format": "{{name}}: QSO #{{index}} has invalid date (YYMMDD).",
"edi_error_qso_year_out_of_range": "{{name}}: QSO #{{index}} year is outside TDate range.",
"edi_error_qso_date_out_of_range": "{{name}}: QSO #{{index}} date is outside TDate range.",
"edi_error_qso_time_format": "{{name}}: QSO #{{index}} has invalid time (HHMM UTC).",
"edi_error_qso_callsign": "{{name}}: QSO #{{index}} has invalid callsign.",
"edi_error_qso_edit_in_file": "{{name}}: QSO records cannot be edited in the form, fix them in the file and upload again.",
"edi_warning_qso_count_mismatch": "{{name}}: Declared QSO count ({{expected}}) does not match actual ({{actual}}).",
"edi_error_psect_multiple_power": "PSect contains multiple power categories.",
"edi_error_psect_time_not_allowed": "PSect contains 6H time category which is not allowed for this round.",
"edi_warning_psect_time_not_allowed": "PSect contains 6H time category which is not allowed for this round. Upload is allowed, but we recommend fixing the value.",
"edi_error_psect_multiple_time": "PSect contains multiple time categories.",
"edi_error_psect_missing_operator": "PSect must contain SO or MO.",
"edi_error_psect_check_extra": "PSect CHECK must not contain any other tokens.",
"edi_error_psect_unknown_tokens": "PSect contains unknown tokens: {{tokens}}.",
"edi_warning_psect_unknown_tokens": "Unknown tokens in PSect: {{tokens}}.",
"edi_field_tname": "contest name (TName)",
"edi_field_tdate": "contest date (TDate=YYYYMMDD;YYYYMMDD)",
"edi_field_pcall": "callsign (PCall)",
"edi_field_pwwlo": "locator (PWWLo)",
"edi_field_psect": "section/category (PSect)",
"edi_field_pband": "band used (PBand)",
"edi_field_spowe": "power (SPowe)",
"edi_field_sante": "antenna (SAnte)",
"edi_field_cqsop": "QSO points (CQSOP)",
"edi_field_ctosc": "total score (CToSc)",
"about_link": "About",
"admin_contests_link": "Contests",
"admin_news_link": "News",
"admin_rulesets_link": "Rule sets",
"admin_users_link": "Users",
"admin_users_title": "Users",
"admin_users_create": "New user",
"admin_users_load_failed": "Failed to load users.",
"admin_users_save_failed": "Failed to save user.",
"admin_users_deactivate_failed": "Failed to deactivate user.",
"admin_users_search": "Search name or email",
"admin_users_name": "Name",
"admin_users_email": "Email",
"admin_users_password": "Password",
"admin_users_password_hint": "Leave empty to keep unchanged",
"admin_users_is_admin": "Admin",
"admin_users_is_active": "Active",
"admin_users_actions": "Actions",
"admin_users_loading": "Loading...",
"admin_users_empty": "No users.",
"admin_users_edit": "Edit",
"admin_users_deactivate": "Deactivate",
"admin_users_edit_title": "Edit user",
"admin_users_create_title": "New user",
"admin_users_saving": "Saving…",
"admin_users_save": "Save",
"admin_link": "Administration",
"contests_link": "Contests",
"contests_left_title": "Contests",
"home_link": "Home",
"login_link": "Login",
"logout_link": "Logout",
"back": "Back",
"log": "Log",
"news_show_more": "Show more"
}

View File

@@ -0,0 +1,78 @@
{
"ruleset_help_name": "Ruleset name for administration; it does not affect scoring.",
"ruleset_help_code": "Stable ruleset identifier for links, exports, and API; changing it can break references.",
"ruleset_help_description": "Description for referees; it does not change the calculation.",
"ruleset_help_scoring_mode": "Scoring method: DISTANCE = points from km; FIXED_POINTS = fixed points per valid QSO.",
"ruleset_help_points_per_qso": "Used only in FIXED_POINTS; valid QSO gets this many points.",
"ruleset_help_points_per_km": "Used only in DISTANCE; points = distance_km * points_per_km (after rounding).",
"ruleset_help_use_multipliers": "Enables multipliers in aggregation; when off, multiplier_count=1.",
"ruleset_help_multiplier_type": "Multiplier type to collect from QSOs (WWL/DXCC/SECTION/COUNTRY).",
"ruleset_help_multiplier_scope": "PER_BAND counts per band; OVERALL counts all together.",
"ruleset_help_multiplier_source": "VALID_ONLY uses valid QSOs; ALL_MATCHED uses all matched (even invalid).",
"ruleset_help_wwl_multiplier_level": "WWL level (2/4/6) used to count uniques.",
"ruleset_help_checklog_matching": "When off, CHECK logs are excluded from matching and cannot form pairs.",
"ruleset_help_dupe_scope": "Duplicate scope: BAND within band only; BAND_MODE also by mode.",
"ruleset_help_callsign_normalization": "How callsigns are normalized for matching and busted checks.",
"ruleset_help_callsign_suffix_max_len": "Max suffix length after slash to ignore (longer suffix is kept).",
"ruleset_help_callsign_levenshtein_max": "Maximum Levenshtein distance for tolerant callsign matching (02).",
"ruleset_help_distance_rounding": "How distance_km is rounded before scoring (FLOOR/ROUND/CEIL).",
"ruleset_help_min_distance_km": "QSOs below this distance get 0 points or can be marked invalid.",
"ruleset_help_operating_window_mode": "Enables the 6H operating window: up to two segments separated by >=2h are counted (total duration <= 6 hours).",
"ruleset_help_operating_window_hours": "Total operating time is fixed (6 hours).",
"ruleset_help_sixhr_ranking_mode": "IARU = one combined 6H table without SO/MO; CRK = separate SO and MO rankings.",
"ruleset_help_time_tolerance_sec": "Max time delta for matching; outside tolerance reduces matching or yields NIL.",
"ruleset_help_allow_time_shift_one_hour": "Allows time shift (typically +1h) during matching and flags QSOs as TIME_SHIFT.",
"ruleset_help_time_shift_seconds": "Time shift in seconds used as an alternative matching window (e.g. 3600).",
"ruleset_help_time_mismatch_policy": "How to handle time mismatch: INVALID marks QSO invalid; ZERO_POINTS keeps valid but 0 points; FLAG_ONLY keeps points; PENALTY sets 0 points and adds penalty.",
"ruleset_help_allow_time_mismatch_pairing": "Allow pairing outside time tolerance; resulting QSO is flagged TIME_MISMATCH.",
"ruleset_help_time_mismatch_max_sec": "Maximum time delta (seconds) for pairing outside tolerance; NULL = unlimited.",
"ruleset_help_time_diff_dq_threshold_percent": "If more than this share of matched QSOs exceeds the time threshold, the log is marked DQ.",
"ruleset_help_time_diff_dq_threshold_sec": "Time threshold in seconds used to compute the share of QSOs with large time difference.",
"ruleset_help_bad_qso_dq_threshold_percent": "If the share of bad QSOs (NIL/DUP/BUSTED/OUT) reaches this value, the log is disqualified (DQ).",
"ruleset_help_out_of_window_dq_threshold": "Out-of-window QSO count that flips the log to DQ.",
"ruleset_help_require_locators": "Without locators, QSOs are marked invalid or score 0.",
"ruleset_help_require_unique_qso": "Duplicate QSOs are marked DUP and do not score.",
"ruleset_help_out_of_window_policy": "How to handle out-of-window QSOs: IGNORE/0 points/PENALTY/INVALID.",
"ruleset_help_exchange_type": "Defines what is part of the exchange and checked for busted_exchange.",
"ruleset_help_exchange_requires_wwl": "If WWL is missing, the QSO is marked invalid/busted per rules.",
"ruleset_help_exchange_requires_serial": "If serial is missing, the QSO is marked invalid/busted per rules.",
"ruleset_help_exchange_requires_report": "RST is part of the exchange; mismatch can lead to BUSTED_RST.",
"ruleset_help_exchange_pattern": "Regex for CUSTOM exchange; non-matching value => busted_exchange.",
"ruleset_help_match_tiebreak_order": "Tie-break order when choosing a candidate (time, exchange, locator...).",
"ruleset_help_match_require_locator_match": "Matching allows only candidates with matching locator; otherwise NIL.",
"ruleset_help_match_require_exchange_match": "Matching allows only candidates with matching exchange; otherwise NIL.",
"ruleset_help_letters_in_rst": "Allow alphanumeric RST; otherwise only digits are valid.",
"ruleset_help_ignore_slash_part": "Ignore suffix after slash during normalization (e.g. /P).",
"ruleset_help_ignore_third_part": "Ignore the third part of a callsign (e.g. OK1ABC/0/P).",
"ruleset_help_rst_ignore_third_char": "Ignores the 3rd character of RST when comparing (e.g. 59X).",
"ruleset_help_discard_qso_rec_diff_call": "If the received callsign (DX call) differs from the other stations sent callsign, the QSO is flagged BUSTED_CALL with error_side=RX. Points are zero; penalties apply per busted_call_policy.",
"ruleset_help_discard_qso_sent_diff_call": "If the sent callsign differs from the other stations received callsign, the QSO is flagged BUSTED_CALL with error_side=TX. Points for the current station stay; penalties belong to the counterpart.",
"ruleset_help_discard_qso_rec_diff_rst": "Received RST mismatch flags BUSTED_RST with error_side=RX. Points 0; penalty per busted_rst_policy.",
"ruleset_help_discard_qso_sent_diff_rst": "Sent RST mismatch flags BUSTED_RST with error_side=TX. Points stay for current station; penalties belong to the counterpart.",
"ruleset_help_discard_qso_rec_diff_serial": "Received serial mismatch flags BUSTED_SERIAL with error_side=RX. Policy uses busted_serial_policy.",
"ruleset_help_discard_qso_sent_diff_serial": "Sent serial mismatch flags BUSTED_SERIAL with error_side=TX. Points stay for current station; penalties belong to the counterpart.",
"ruleset_help_discard_qso_rec_diff_wwl": "Received locator mismatch flags BUSTED_LOCATOR with error_side=RX. Policy uses busted_locator_policy.",
"ruleset_help_discard_qso_sent_diff_wwl": "Sent locator mismatch flags BUSTED_LOCATOR with error_side=TX. Points stay for current station; penalties belong to the counterpart.",
"ruleset_help_discard_qso_rec_diff_code": "Received exchange mismatch flags BUSTED_SERIAL or BUSTED_LOCATOR (by exchange type) with error_side=RX. Policies use busted_serial_policy/busted_locator_policy.",
"ruleset_help_discard_qso_sent_diff_code": "Sent exchange mismatch flags BUSTED_SERIAL or BUSTED_LOCATOR (by exchange type) with error_side=TX. Points stay for current station; penalties belong to the counterpart.",
"ruleset_help_dup_resolution_strategy": "Order of rules for selecting the survivor among duplicates (paired_first, ok_first, earlier_time, lower_id).",
"ruleset_help_dup_qso_policy": "DUP: ZERO_POINTS = 0; PENALTY = 0 + penalty.",
"ruleset_help_nil_qso_policy": "NIL: ZERO_POINTS = 0; PENALTY = 0 + penalty.",
"ruleset_help_no_counterpart_log_policy": "No counterpart log: INVALID = invalid; ZERO_POINTS = valid with 0; FLAG_ONLY = keep points; PENALTY = 0 + penalty.",
"ruleset_help_not_in_counterpart_log_policy": "Not in counterpart log: INVALID = invalid; ZERO_POINTS = valid with 0; FLAG_ONLY = keep points; PENALTY = 0 + penalty.",
"ruleset_help_unique_qso_policy": "Unique QSO: INVALID = invalid; ZERO_POINTS = valid with 0; FLAG_ONLY = keep points.",
"ruleset_help_busted_call_policy": "BUSTED_CALL: ZERO_POINTS = 0; PENALTY = 0 + penalty.",
"ruleset_help_busted_rst_policy": "BUSTED_RST: ZERO_POINTS = 0; PENALTY = keep points + penalty.",
"ruleset_help_busted_exchange_policy": "BUSTED exchange fallback: ZERO_POINTS = 0; PENALTY = 0 + penalty.",
"ruleset_help_busted_serial_policy": "BUSTED_SERIAL: ZERO_POINTS = 0; PENALTY = 0 + penalty.",
"ruleset_help_busted_locator_policy": "BUSTED_LOCATOR: ZERO_POINTS = 0; PENALTY = 0 + penalty.",
"ruleset_help_penalty_dup_points": "Penalty points (negative) for DUP when policy= PENALTY.",
"ruleset_help_penalty_nil_points": "Penalty points (negative) for NIL when policy= PENALTY.",
"ruleset_help_penalty_busted_call_points": "Penalty points (negative) for BUSTED_CALL when policy= PENALTY.",
"ruleset_help_penalty_busted_rst_points": "Penalty points (negative) for BUSTED_RST when policy= PENALTY.",
"ruleset_help_penalty_busted_exchange_points": "Penalty points (negative) for BUSTED exchange fallback when policy= PENALTY.",
"ruleset_help_penalty_busted_serial_points": "Penalty points (negative) for BUSTED_SERIAL when policy= PENALTY.",
"ruleset_help_penalty_busted_locator_points": "Penalty points (negative) for BUSTED_LOCATOR when policy= PENALTY.",
"ruleset_help_penalty_out_of_window_points": "Penalty points for out-of-window QSOs when out_of_window_policy= PENALTY.",
"ruleset_help_options_json": "Optional rule extensions in JSON; they affect scoring only if code reads the key."
}

View File

@@ -0,0 +1,3 @@
export default function AboutPage () {
return "About page"
}

View File

@@ -0,0 +1,181 @@
import React, { useMemo, useRef, useState, useEffect } from "react";
import { Button } from "@heroui/react";
import { useTranslation } from "react-i18next";
import ContestsTable from "../components/ContestsTable";
import ContestCreateForm from "../components/ContestCreateForm";
import RoundsTable from "@/components/RoundsTable";
import RoundCreateForm from "@/components/RoundCreateForm";
import { useContestStore } from "@/stores/contestStore";
import { useContestRefreshStore } from "@/stores/contestRefreshStore";
type FormMode = "none" | "create" | "edit";
type RoundFormMode = "none" | "create" | "edit";
export default function AdminContestsPage() {
const { t } = useTranslation("common");
const [formMode, setFormMode] = useState<FormMode>("none");
const [roundFormMode, setRoundFormMode] = useState<RoundFormMode>("none");
const contestFormRef = useRef<HTMLDivElement | null>(null);
const roundFormRef = useRef<HTMLDivElement | null>(null);
const selectedContest = useContestStore((s) => s.selectedContest);
const setSelectedContest = useContestStore((s) => s.setSelectedContest);
const selectedRound = useContestStore((s) => s.selectedRound);
const setSelectedRound = useContestStore((s) => s.setSelectedRound);
const triggerRefresh = useContestRefreshStore((s) => s.triggerRefresh);
const isFormVisible = formMode !== "none";
const isRoundFormVisible = roundFormMode !== "none";
const formTitle = useMemo(() => {
if (formMode === "create") return t("contest_new");
if (formMode === "edit") return t("contest_edit");
return "";
}, [formMode, t]);
const roundFormTitle = useMemo(() => {
if (roundFormMode === "create") return t("round_new");
if (roundFormMode === "edit") return t("round_edit");
return "";
}, [roundFormMode, t]);
useEffect(() => {
if (formMode !== "none" && contestFormRef.current) {
contestFormRef.current.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, [formMode]);
useEffect(() => {
if (roundFormMode !== "none" && roundFormRef.current) {
roundFormRef.current.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, [roundFormMode]);
return (
<>
<ContestsTable
selectOnRowClick
showTests
onRowSelect={(contest) => {
setSelectedContest(contest);
setSelectedRound(null);
setFormMode("none");
setRoundFormMode("none");
}}
onEditContest={(contest) => {
setSelectedContest(contest);
setSelectedRound(null);
setRoundFormMode("none");
setFormMode("edit");
}}
/>
<div style={{ display: "flex", gap: 12, marginTop: 16 }}>
<Button
onPress={() => {
setSelectedContest(null);
setSelectedRound(null);
setFormMode("create");
setRoundFormMode("none");
}}
>
{t("contest_add_new") ?? "Přidat nový závod"}
</Button>
<Button
onPress={() => {
if (!selectedContest) return;
setFormMode("none");
setRoundFormMode("create");
setSelectedRound(null);
}}
isDisabled={!selectedContest}
>
{t("round_add_new") ?? "Přidat nové kolo"}
</Button>
{(isFormVisible || isRoundFormVisible) && (
<Button
variant="light"
onPress={() => {
setFormMode("none");
setRoundFormMode("none");
setSelectedRound(null);
}}
>
{t("admin_form_close") ?? "Zavřít formulář"}
</Button>
)}
</div>
{isFormVisible && (
<div style={{ marginTop: 16 }} ref={contestFormRef}>
{formTitle && <h2 style={{ fontSize: 18, fontWeight: 600 }}>{formTitle}</h2>}
{formMode === "edit" && selectedContest && (
<ContestCreateForm
mode="edit"
contest={selectedContest}
onUpdated={() => {
setFormMode("none");
triggerRefresh();
}}
/>
)}
{formMode === "create" && (
<ContestCreateForm
mode="create"
onCreated={(contest) => {
setSelectedContest(contest);
setFormMode("none");
triggerRefresh();
}}
/>
)}
</div>
)}
{isRoundFormVisible && selectedContest && (
<div style={{ marginTop: 16 }} ref={roundFormRef}>
{roundFormTitle && <h2 style={{ fontSize: 18, fontWeight: 600 }}>{roundFormTitle}</h2>}
<RoundCreateForm
mode={roundFormMode === "edit" ? "edit" : "create"}
round={roundFormMode === "edit" ? (selectedRound as any) : undefined}
contestId={selectedContest.id}
onCreated={() => {
setRoundFormMode("none");
triggerRefresh();
}}
onUpdated={() => {
setRoundFormMode("none");
triggerRefresh();
}}
/>
</div>
)}
{ selectedContest && (
<RoundsTable
contestId={selectedContest ? selectedContest.id : null}
enableEdit
showContestColumn={false}
showTests
onSelectRound={(round) => {
setSelectedRound(round);
setFormMode("none");
setRoundFormMode("edit");
}}
title={
selectedContest
? (t("contest_rounds_title_named", { name: selectedContest.name }) ??
`Kola závodu "${selectedContest.name}":`)
: ""
}
/>
)}
</>
);
}

View File

@@ -0,0 +1,514 @@
import React, { useEffect, useMemo, useState } from "react";
import axios from "axios";
import { Button, Modal, ModalBody, ModalContent, ModalHeader, useDisclosure } from "@heroui/react";
import Markdown from "react-markdown";
import { useLanguageStore } from "@/stores/languageStore";
import { useTranslation } from "react-i18next";
import AdminRulesetForm from "@/components/admin/rulesets/AdminRulesetForm";
import AdminRulesetsTable from "@/components/admin/rulesets/AdminRulesetsTable";
import {
emptyForm,
isBlank,
isIntegerLike,
isNumberLike,
numberValue,
toIntOrNull,
toNumberOrNull,
type EvaluationRuleSet,
type RuleSetForm,
type RuleSetFormMode,
} from "@/components/admin/rulesets/adminRulesetTypes";
import rulesetDoc from "../../docs/EvaluationRuleSet.md?raw";
const RULESET_LOADERS = {
en: () => import("../locales/en/ruleset.json"),
cs: () => import("../locales/cs/ruleset.json"),
} as const;
type PaginatedResponse<T> = { data: T[] };
export default function AdminEvaluationPage() {
const locale = useLanguageStore((s) => s.locale);
const { t } = useTranslation("common");
const { t: tRules, i18n } = useTranslation("ruleset");
const valuePlaceholder = t("value_na") ?? "—";
const label = (key: string, fallback: string) => (t(key) as string) ?? fallback;
const [items, setItems] = useState<EvaluationRuleSet[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const [formMode, setFormMode] = useState<RuleSetFormMode>("none");
const [editing, setEditing] = useState<EvaluationRuleSet | null>(null);
const [form, setForm] = useState<RuleSetForm>(emptyForm);
const [initialForm, setInitialForm] = useState<RuleSetForm>(emptyForm);
const [submitting, setSubmitting] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [formSuccess, setFormSuccess] = useState<string | null>(null);
const { isOpen: helpOpen, onOpen: openHelp, onOpenChange: onHelpChange } = useDisclosure();
useEffect(() => {
let active = true;
const loadNamespace = async (lang: "cs" | "en") => {
if (i18n.hasResourceBundle(lang, "ruleset")) return;
const loader = RULESET_LOADERS[lang];
if (!loader) return;
const module = await loader();
if (!active) return;
const data = module.default ?? module;
i18n.addResourceBundle(lang, "ruleset", data, true, true);
};
const lang = i18n.language?.startsWith("cs") ? "cs" : "en";
loadNamespace(lang);
if (lang !== "en") {
loadNamespace("en");
}
return () => {
active = false;
};
}, [i18n, i18n.language]);
useEffect(() => {
let active = true;
(async () => {
try {
setLoading(true);
setError(null);
const res = await axios.get<PaginatedResponse<EvaluationRuleSet> | EvaluationRuleSet[]>(
"/api/evaluation-rule-sets",
{
headers: { Accept: "application/json" },
params: { per_page: 200 },
withCredentials: true,
}
);
if (!active) return;
const data = Array.isArray(res.data) ? res.data : res.data.data;
setItems(data);
} catch {
if (!active) return;
setError(t("admin_rulesets_load_failed") ?? "Nepodařilo se načíst rule sety.");
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [locale, refreshKey]);
const resetForm = () => {
setForm(emptyForm);
setInitialForm(emptyForm);
setEditing(null);
setFormError(null);
setFormSuccess(null);
};
const helpLabel = (labelText: string, key: string) => {
const fieldKey = key.startsWith("ruleset_help_") ? key.slice("ruleset_help_".length) : key;
return (
<span className="inline-flex flex-col leading-tight" title={tRules(key)}>
<span>{labelText}</span>
<span className="text-[0.8em] text-foreground-400 font-mono">{fieldKey}</span>
</span>
);
};
const formatDate = (value: string | null | undefined, lang: string) => {
if (!value) return valuePlaceholder;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return valuePlaceholder;
return new Intl.DateTimeFormat(lang, {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(date);
};
const openEdit = (item: EvaluationRuleSet) => {
resetForm();
setEditing(item);
setFormMode("edit");
const nextForm: RuleSetForm = {
name: item.name ?? "",
code: item.code ?? "",
description: item.description ?? "",
scoring_mode: item.scoring_mode ?? "DISTANCE",
points_per_qso: numberValue(item.points_per_qso),
points_per_km: numberValue(item.points_per_km),
use_multipliers: !!item.use_multipliers,
multiplier_type: item.multiplier_type ?? "WWL",
dup_qso_policy: item.dup_qso_policy === "COUNT_ONCE" ? "ZERO_POINTS" : item.dup_qso_policy ?? "ZERO_POINTS",
nil_qso_policy: item.nil_qso_policy ?? "PENALTY",
no_counterpart_log_policy: item.no_counterpart_log_policy ?? item.nil_qso_policy ?? "PENALTY",
not_in_counterpart_log_policy:
item.not_in_counterpart_log_policy ?? item.nil_qso_policy ?? "PENALTY",
unique_qso_policy: item.unique_qso_policy ?? "ZERO_POINTS",
busted_call_policy: item.busted_call_policy ?? "PENALTY",
busted_rst_policy: item.busted_rst_policy ?? "ZERO_POINTS",
busted_exchange_policy: item.busted_exchange_policy ?? "ZERO_POINTS",
busted_serial_policy: item.busted_serial_policy ?? item.busted_exchange_policy ?? "ZERO_POINTS",
busted_locator_policy: item.busted_locator_policy ?? item.busted_exchange_policy ?? "ZERO_POINTS",
penalty_dup_points: numberValue(item.penalty_dup_points),
penalty_nil_points: numberValue(item.penalty_nil_points),
penalty_busted_call_points: numberValue(item.penalty_busted_call_points),
penalty_busted_rst_points: numberValue(item.penalty_busted_rst_points),
penalty_busted_exchange_points: numberValue(item.penalty_busted_exchange_points),
penalty_busted_serial_points: numberValue(item.penalty_busted_serial_points),
penalty_busted_locator_points: numberValue(item.penalty_busted_locator_points),
penalty_out_of_window_points: numberValue(item.penalty_out_of_window_points),
dupe_scope: item.dupe_scope ?? "BAND",
callsign_normalization: item.callsign_normalization ?? "IGNORE_SUFFIX",
distance_rounding: item.distance_rounding ?? "FLOOR",
min_distance_km: numberValue(item.min_distance_km),
require_locators: item.require_locators ?? true,
out_of_window_policy: item.out_of_window_policy ?? "INVALID",
exchange_type: item.exchange_type ?? "SERIAL_WWL",
exchange_requires_wwl: item.exchange_requires_wwl ?? true,
exchange_requires_serial: item.exchange_requires_serial ?? true,
exchange_requires_report: item.exchange_requires_report ?? false,
exchange_pattern: item.exchange_pattern ?? "",
match_tiebreak_order: Array.isArray(item.match_tiebreak_order)
? item.match_tiebreak_order.join(", ")
: "",
match_require_locator_match: item.match_require_locator_match ?? false,
match_require_exchange_match: item.match_require_exchange_match ?? false,
multiplier_scope: item.multiplier_scope ?? "PER_BAND",
multiplier_source: item.multiplier_source ?? "VALID_ONLY",
wwl_multiplier_level: item.wwl_multiplier_level ?? "LOCATOR_6",
checklog_matching: item.checklog_matching ?? true,
out_of_window_dq_threshold: numberValue(item.out_of_window_dq_threshold),
time_diff_dq_threshold_percent: numberValue(item.time_diff_dq_threshold_percent),
time_diff_dq_threshold_sec: numberValue(item.time_diff_dq_threshold_sec),
bad_qso_dq_threshold_percent: numberValue(item.bad_qso_dq_threshold_percent),
time_tolerance_sec: numberValue(item.time_tolerance_sec),
allow_time_shift_one_hour: item.allow_time_shift_one_hour ?? true,
time_shift_seconds: numberValue(item.time_shift_seconds),
time_mismatch_policy: item.time_mismatch_policy ?? "FLAG_ONLY",
allow_time_mismatch_pairing: item.allow_time_mismatch_pairing ?? true,
time_mismatch_max_sec: numberValue(item.time_mismatch_max_sec),
require_unique_qso: item.require_unique_qso ?? true,
ignore_slash_part: item.ignore_slash_part ?? true,
ignore_third_part: item.ignore_third_part ?? true,
rst_ignore_third_char: item.rst_ignore_third_char ?? true,
callsign_suffix_max_len: numberValue(item.callsign_suffix_max_len),
callsign_levenshtein_max: numberValue(item.callsign_levenshtein_max),
letters_in_rst: item.letters_in_rst ?? true,
discard_qso_rec_diff_call: item.discard_qso_rec_diff_call ?? true,
discard_qso_sent_diff_call: item.discard_qso_sent_diff_call ?? false,
discard_qso_rec_diff_rst: item.discard_qso_rec_diff_rst ?? true,
discard_qso_sent_diff_rst: item.discard_qso_sent_diff_rst ?? false,
discard_qso_rec_diff_serial: item.discard_qso_rec_diff_serial ?? true,
discard_qso_sent_diff_serial: item.discard_qso_sent_diff_serial ?? false,
discard_qso_rec_diff_wwl: item.discard_qso_rec_diff_wwl ?? true,
discard_qso_sent_diff_wwl: item.discard_qso_sent_diff_wwl ?? false,
discard_qso_rec_diff_code: item.discard_qso_rec_diff_code ?? true,
discard_qso_sent_diff_code: item.discard_qso_sent_diff_code ?? false,
dup_resolution_strategy: Array.isArray(item.dup_resolution_strategy)
? item.dup_resolution_strategy.join(", ")
: "",
operating_window_mode: item.operating_window_mode ?? "NONE",
operating_window_hours: numberValue(item.operating_window_hours),
sixhr_ranking_mode: item.sixhr_ranking_mode ?? "IARU",
};
setForm(nextForm);
setInitialForm(nextForm);
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setFormError(null);
setFormSuccess(null);
if (hasValidationErrors) {
setFormError(t("admin_rulesets_fix_errors") ?? "Opravte chyby ve formuláři.");
return;
}
const payload: Record<string, unknown> = {
name: form.name.trim(),
code: form.code.trim(),
description: form.description.trim() || null,
scoring_mode: form.scoring_mode,
points_per_qso: toNumberOrNull(form.points_per_qso),
points_per_km: toNumberOrNull(form.points_per_km),
use_multipliers: form.use_multipliers,
multiplier_type: form.multiplier_type,
dup_qso_policy: form.dup_qso_policy,
nil_qso_policy: form.nil_qso_policy,
no_counterpart_log_policy: form.no_counterpart_log_policy,
not_in_counterpart_log_policy: form.not_in_counterpart_log_policy,
unique_qso_policy: form.unique_qso_policy,
busted_call_policy: form.busted_call_policy,
busted_rst_policy: form.busted_rst_policy,
busted_exchange_policy: form.busted_exchange_policy,
busted_serial_policy: form.busted_serial_policy,
busted_locator_policy: form.busted_locator_policy,
penalty_dup_points: toIntOrNull(form.penalty_dup_points),
penalty_nil_points: toIntOrNull(form.penalty_nil_points),
penalty_busted_call_points: toIntOrNull(form.penalty_busted_call_points),
penalty_busted_rst_points: toIntOrNull(form.penalty_busted_rst_points),
penalty_busted_exchange_points: toIntOrNull(form.penalty_busted_exchange_points),
penalty_busted_serial_points: toIntOrNull(form.penalty_busted_serial_points),
penalty_busted_locator_points: toIntOrNull(form.penalty_busted_locator_points),
penalty_out_of_window_points: toIntOrNull(form.penalty_out_of_window_points),
dupe_scope: form.dupe_scope,
callsign_normalization: form.callsign_normalization,
distance_rounding: form.distance_rounding,
min_distance_km: toNumberOrNull(form.min_distance_km),
require_locators: form.require_locators,
out_of_window_policy: form.out_of_window_policy,
exchange_type: form.exchange_type,
exchange_requires_wwl: form.exchange_requires_wwl,
exchange_requires_serial: form.exchange_requires_serial,
exchange_requires_report: form.exchange_requires_report,
exchange_pattern: form.exchange_pattern.trim() || null,
match_tiebreak_order: form.match_tiebreak_order.trim()
? form.match_tiebreak_order.split(",").map((v) => v.trim()).filter(Boolean)
: null,
match_require_locator_match: form.match_require_locator_match,
match_require_exchange_match: form.match_require_exchange_match,
multiplier_scope: form.multiplier_scope,
multiplier_source: form.multiplier_source,
wwl_multiplier_level: form.wwl_multiplier_level,
checklog_matching: form.checklog_matching,
out_of_window_dq_threshold: toNumberOrNull(form.out_of_window_dq_threshold),
time_diff_dq_threshold_percent: toIntOrNull(form.time_diff_dq_threshold_percent),
time_diff_dq_threshold_sec: toIntOrNull(form.time_diff_dq_threshold_sec),
bad_qso_dq_threshold_percent: toIntOrNull(form.bad_qso_dq_threshold_percent),
time_tolerance_sec: toNumberOrNull(form.time_tolerance_sec),
allow_time_shift_one_hour: form.allow_time_shift_one_hour,
time_shift_seconds: toIntOrNull(form.time_shift_seconds),
time_mismatch_policy: form.time_mismatch_policy,
allow_time_mismatch_pairing: form.allow_time_mismatch_pairing,
time_mismatch_max_sec: toIntOrNull(form.time_mismatch_max_sec),
require_unique_qso: form.require_unique_qso,
ignore_slash_part: form.ignore_slash_part,
ignore_third_part: form.ignore_third_part,
rst_ignore_third_char: form.rst_ignore_third_char,
callsign_suffix_max_len: toIntOrNull(form.callsign_suffix_max_len),
callsign_levenshtein_max: toIntOrNull(form.callsign_levenshtein_max),
letters_in_rst: form.letters_in_rst,
discard_qso_rec_diff_call: form.discard_qso_rec_diff_call,
discard_qso_sent_diff_call: form.discard_qso_sent_diff_call,
discard_qso_rec_diff_rst: form.discard_qso_rec_diff_rst,
discard_qso_sent_diff_rst: form.discard_qso_sent_diff_rst,
discard_qso_rec_diff_serial: form.discard_qso_rec_diff_serial,
discard_qso_sent_diff_serial: form.discard_qso_sent_diff_serial,
discard_qso_rec_diff_wwl: form.discard_qso_rec_diff_wwl,
discard_qso_sent_diff_wwl: form.discard_qso_sent_diff_wwl,
discard_qso_rec_diff_code: form.discard_qso_rec_diff_code,
discard_qso_sent_diff_code: form.discard_qso_sent_diff_code,
dup_resolution_strategy: form.dup_resolution_strategy.trim()
? form.dup_resolution_strategy.split(",").map((v) => v.trim()).filter(Boolean)
: null,
operating_window_mode: form.operating_window_mode,
operating_window_hours: toIntOrNull(form.operating_window_hours),
sixhr_ranking_mode: form.sixhr_ranking_mode,
options: null,
};
try {
setSubmitting(true);
await axios.get("/sanctum/csrf-cookie", { withCredentials: true });
if (formMode === "edit" && editing) {
await axios.put(`/api/evaluation-rule-sets/${editing.id}`, payload, {
headers: { Accept: "application/json" },
withCredentials: true,
withXSRFToken: true,
});
setFormSuccess(t("admin_rulesets_updated") ?? "Rule set byl upraven.");
} else {
await axios.post(`/api/evaluation-rule-sets`, payload, {
headers: { Accept: "application/json" },
withCredentials: true,
withXSRFToken: true,
});
setFormSuccess(t("admin_rulesets_created") ?? "Rule set byl vytvořen.");
}
setFormMode("none");
resetForm();
setRefreshKey((k) => k + 1);
} catch (e: any) {
setFormError(
e?.response?.data?.message ?? (t("admin_rulesets_save_failed") ?? "Chyba při ukládání rule setu.")
);
} finally {
setSubmitting(false);
}
};
const visibleItems = useMemo(() => items, [items]);
const validation = useMemo(() => {
const numberError = (value: string) =>
value.trim() && !isNumberLike(value)
? t("validation_number") ?? "Musí být číslo."
: null;
const intError = (value: string) =>
value.trim() && !isIntegerLike(value)
? t("validation_integer") ?? "Musí být celé číslo."
: null;
return {
name: isBlank(form.name)
? t("validation_name_required") ?? "Název je povinný."
: null,
code: isBlank(form.code)
? t("validation_code_required") ?? "Kód je povinný."
: null,
points_per_qso: numberError(form.points_per_qso),
points_per_km: numberError(form.points_per_km),
min_distance_km: numberError(form.min_distance_km),
time_tolerance_sec: intError(form.time_tolerance_sec),
out_of_window_dq_threshold: intError(form.out_of_window_dq_threshold),
time_diff_dq_threshold_percent: intError(form.time_diff_dq_threshold_percent) ||
(form.time_diff_dq_threshold_percent.trim() &&
(Number(form.time_diff_dq_threshold_percent) < 1 || Number(form.time_diff_dq_threshold_percent) > 100)
? t("validation_range_1_100") ?? "Rozsah 1100."
: null),
time_diff_dq_threshold_sec: intError(form.time_diff_dq_threshold_sec) ||
(form.time_diff_dq_threshold_sec.trim() && Number(form.time_diff_dq_threshold_sec) < 1
? t("validation_min_one") ?? "Musí být alespoň 1."
: null),
bad_qso_dq_threshold_percent: intError(form.bad_qso_dq_threshold_percent) ||
(form.bad_qso_dq_threshold_percent.trim() &&
(Number(form.bad_qso_dq_threshold_percent) < 1 || Number(form.bad_qso_dq_threshold_percent) > 100)
? t("validation_range_1_100") ?? "Rozsah 1100."
: null),
callsign_suffix_max_len: intError(form.callsign_suffix_max_len) ||
(form.callsign_suffix_max_len.trim() && Number(form.callsign_suffix_max_len) < 0
? t("validation_min_zero") ?? "Musí být alespoň 0."
: null),
callsign_levenshtein_max: intError(form.callsign_levenshtein_max) ||
(form.callsign_levenshtein_max.trim() &&
(Number(form.callsign_levenshtein_max) < 0 || Number(form.callsign_levenshtein_max) > 2)
? t("validation_range_0_2") ?? "Rozsah 02."
: null),
time_shift_seconds: intError(form.time_shift_seconds) ||
(form.time_shift_seconds.trim() && Number(form.time_shift_seconds) < 0
? t("validation_min_zero") ?? "Musí být alespoň 0."
: null),
time_mismatch_max_sec: intError(form.time_mismatch_max_sec) ||
(form.time_mismatch_max_sec.trim() && Number(form.time_mismatch_max_sec) < 0
? t("validation_min_zero") ?? "Musí být alespoň 0."
: null),
penalty_dup_points: intError(form.penalty_dup_points),
penalty_nil_points: intError(form.penalty_nil_points),
penalty_busted_call_points: intError(form.penalty_busted_call_points),
penalty_busted_rst_points: intError(form.penalty_busted_rst_points),
penalty_busted_exchange_points: intError(form.penalty_busted_exchange_points),
penalty_busted_serial_points: intError(form.penalty_busted_serial_points),
penalty_busted_locator_points: intError(form.penalty_busted_locator_points),
penalty_out_of_window_points: intError(form.penalty_out_of_window_points),
};
}, [form]);
const hasValidationErrors = useMemo(
() => Object.values(validation).some(Boolean),
[validation]
);
const isFormDirty = useMemo(
() => JSON.stringify(form) !== JSON.stringify(initialForm),
[form, initialForm]
);
const reportRequirementWarning =
!form.exchange_requires_report &&
(form.discard_qso_rec_diff_rst || form.discard_qso_sent_diff_rst);
const closeForm = () => {
if (isFormDirty) {
const confirmed = window.confirm(
t("admin_form_confirm_close") ??
"Formulář obsahuje neuložené změny. Opravdu ho chcete zavřít?"
);
if (!confirmed) return;
}
setFormMode("none");
resetForm();
};
return (
<div className="space-y-6">
<div className="flex gap-3 items-center">
<h1 className="text-xl font-semibold">{t("admin_rulesets_title") ?? "Evaluation rules"}</h1>
{formMode !== "none" && (
<Button variant="light" onPress={closeForm}>
{t("admin_form_close") ?? "Zavřít formulář"}
</Button>
)}
</div>
{loading ? (
<div>{t("admin_rulesets_loading") ?? "Načítám rule sety…"}</div>
) : error ? (
<div className="text-sm text-red-600">{error}</div>
) : (
<AdminRulesetsTable
items={visibleItems}
locale={locale}
valuePlaceholder={valuePlaceholder}
formatDate={formatDate}
onEdit={openEdit}
t={(key) => t(key) as string}
/>
)}
{!loading && !error && (
<div className="flex flex-wrap gap-3">
<Button
type="button"
color="primary"
onPress={() => {
resetForm();
setFormMode("create");
}}
>
{label("admin_rulesets_create", "Nová sada pravidel")}
</Button>
<Button type="button" variant="bordered" onPress={openHelp}>
{label("admin_rulesets_help", "Nápověda")}
</Button>
{formMode !== "none" && (
<Button type="button" variant="bordered" onPress={closeForm}>
{label("admin_form_close", "Zavřít formulář")}
</Button>
)}
</div>
)}
{formMode !== "none" && (
<AdminRulesetForm
formMode={formMode === "create" ? "create" : "edit"}
form={form}
setForm={setForm}
validation={validation}
formError={formError}
formSuccess={formSuccess}
reportRequirementWarning={reportRequirementWarning}
submitting={submitting}
hasValidationErrors={hasValidationErrors}
label={label}
helpLabel={helpLabel}
tRules={tRules}
onSubmit={handleSubmit}
onClose={closeForm}
/>
)}
<Modal isOpen={helpOpen} onOpenChange={onHelpChange} size="5xl" scrollBehavior="inside">
<ModalContent>
<ModalHeader className="flex flex-col gap-1">
{label("admin_rulesets_help_title", "Dokumentace rulesetu")}
</ModalHeader>
<ModalBody>
<div className="prose max-w-none text-sm">
<Markdown>{rulesetDoc}</Markdown>
</div>
</ModalBody>
</ModalContent>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,143 @@
import React, { useEffect, useMemo, useState } from "react";
import axios from "axios";
import { Button } from "@heroui/react";
import { useLanguageStore } from "@/stores/languageStore";
import { useTranslation } from "react-i18next";
import AdminNewsTable from "@/components/admin/news/AdminNewsTable";
import AdminNewsForm from "@/components/admin/news/AdminNewsForm";
import { type NewsItem, type NewsPayload, type FormMode } from "@/components/admin/news/adminNewsTypes";
type PaginatedResponse<T> = { data: T[] };
export default function AdminNewsPage() {
const locale = useLanguageStore((s) => s.locale);
const { t } = useTranslation("common");
const [items, setItems] = useState<NewsItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const [formMode, setFormMode] = useState<FormMode>("none");
const [editing, setEditing] = useState<NewsItem | null>(null);
const [submitting, setSubmitting] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
useEffect(() => {
let active = true;
(async () => {
try {
setLoading(true);
setError(null);
const res = await axios.get<PaginatedResponse<NewsItem> | NewsItem[]>("/api/news", {
headers: { Accept: "application/json" },
params: { per_page: 200, include_unpublished: 1 },
withCredentials: true,
});
if (!active) return;
const data = Array.isArray(res.data) ? res.data : res.data.data;
setItems(data);
} catch {
if (!active) return;
setError(t("admin_news_load_failed") ?? "Nepodařilo se načíst novinky.");
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [locale, refreshKey, t]);
const visibleItems = useMemo(() => items, [items]);
const resetForm = () => {
setEditing(null);
setFormError(null);
};
const openCreate = () => {
resetForm();
setFormMode("create");
};
const openEdit = (item: NewsItem) => {
resetForm();
setEditing(item);
setFormMode("edit");
};
const handleSubmit = async (payload: NewsPayload) => {
setFormError(null);
try {
setSubmitting(true);
await axios.get("/sanctum/csrf-cookie", { withCredentials: true });
if (formMode === "edit" && editing) {
const key = editing.slug ?? editing.id;
await axios.put(`/api/news/${key}`, payload, {
headers: { Accept: "application/json" },
withCredentials: true,
withXSRFToken: true,
});
} else {
await axios.post("/api/news", payload, {
headers: { Accept: "application/json" },
withCredentials: true,
withXSRFToken: true,
});
}
setFormMode("none");
resetForm();
setRefreshKey((k) => k + 1);
} catch (e: any) {
setFormError(
e?.response?.data?.message ??
(t("admin_news_save_failed") ?? "Chyba při ukládání novinky.")
);
} finally {
setSubmitting(false);
}
};
return (
<div className="space-y-6">
<div className="flex gap-3 items-center">
<h1 className="text-xl font-semibold">{t("admin_news_title") ?? "Novinky"}</h1>
</div>
<AdminNewsTable
items={visibleItems}
locale={locale}
loading={loading}
error={error}
onEdit={openEdit}
/>
<Button onPress={openCreate}>{t("admin_news_create") ?? "Nová novinka"}</Button>
{formMode !== "none" && (
<Button
variant="light"
onPress={() => {
setFormMode("none");
resetForm();
}}
>
{t("admin_form_close") ?? "Zavřít formulář"}
</Button>
)}
{formMode !== "none" && (
<AdminNewsForm
mode={formMode}
editing={editing}
submitting={submitting}
serverError={formError}
onSubmit={handleSubmit}
onCancel={() => {
setFormMode("none");
resetForm();
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { useTranslation } from "react-i18next";
import { useLanguageStore } from "@/stores/languageStore";
export default function AdminPage() {
const { t } = useTranslation("common");
const locale = useLanguageStore((s) => s.locale);
return (
<>
<h1>Správa závodů</h1>
</>
);
}

View File

@@ -0,0 +1,184 @@
import React, { useEffect, useMemo, useState } from "react";
import axios from "axios";
import { Button } from "@heroui/react";
import { useTranslation } from "react-i18next";
import AdminUsersTable from "@/components/admin/users/AdminUsersTable";
import AdminUserForm from "@/components/admin/users/AdminUserForm";
type UserItem = {
id: number;
name: string;
email: string;
is_admin: boolean;
is_active: boolean;
};
type FormMode = "none" | "create" | "edit";
type PaginatedResponse<T> = {
data: T[];
};
export default function AdminUsersPage() {
const { t } = useTranslation("common");
const [items, setItems] = useState<UserItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const [query, setQuery] = useState("");
const [formMode, setFormMode] = useState<FormMode>("none");
const [editing, setEditing] = useState<UserItem | null>(null);
const [submitting, setSubmitting] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
useEffect(() => {
let active = true;
(async () => {
try {
setLoading(true);
setError(null);
const res = await axios.get<PaginatedResponse<UserItem>>("/api/users", {
headers: { Accept: "application/json" },
params: { per_page: 200, query },
withCredentials: true,
});
if (!active) return;
setItems(res.data.data ?? []);
} catch (e: any) {
if (!active) return;
setError(
e?.response?.data?.message ??
(t("admin_users_load_failed") ?? "Nepodařilo se načíst uživatele.")
);
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, [refreshKey, query, t]);
const visibleItems = useMemo(() => items, [items]);
const resetForm = () => {
setEditing(null);
setFormError(null);
};
const openCreate = () => {
resetForm();
setFormMode("create");
};
const openEdit = (item: UserItem) => {
resetForm();
setEditing(item);
setFormMode("edit");
};
const deactivateUser = async (item: UserItem) => {
try {
await axios.delete(`/api/users/${item.id}`, {
headers: { Accept: "application/json" },
withCredentials: true,
withXSRFToken: true,
});
setRefreshKey((k) => k + 1);
} catch (e: any) {
setError(
e?.response?.data?.message ??
(t("admin_users_deactivate_failed") ?? "Nepodařilo se deaktivovat uživatele.")
);
}
};
const handleSubmit = async (payload: {
name: string;
email: string;
password?: string;
is_admin: boolean;
is_active: boolean;
}) => {
setFormError(null);
try {
setSubmitting(true);
await axios.get("/sanctum/csrf-cookie", { withCredentials: true });
if (formMode === "edit" && editing) {
await axios.put(`/api/users/${editing.id}`, payload, {
headers: { Accept: "application/json" },
withCredentials: true,
withXSRFToken: true,
});
} else {
await axios.post("/api/users", payload, {
headers: { Accept: "application/json" },
withCredentials: true,
withXSRFToken: true,
});
}
setFormMode("none");
resetForm();
setRefreshKey((k) => k + 1);
} catch (e: any) {
setFormError(
e?.response?.data?.message ??
(t("admin_users_save_failed") ?? "Chyba při ukládání uživatele.")
);
} finally {
setSubmitting(false);
}
};
return (
<div className="space-y-6">
<div className="flex flex-wrap gap-3 items-center">
<h1 className="text-xl font-semibold">{t("admin_users_title") ?? "Uživatelé"}</h1>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("admin_users_search") ?? "Hledat jméno nebo email"}
className="border rounded px-2 py-1 text-sm"
/>
</div>
{error && <div className="text-red-600 text-sm">{error}</div>}
<AdminUsersTable
items={visibleItems}
loading={loading}
onEdit={openEdit}
onDeactivate={deactivateUser}
/>
<Button onPress={openCreate}>{t("admin_users_create") ?? "Nový uživatel"}</Button>
{formMode !== "none" && (
<Button
variant="light"
onPress={() => {
setFormMode("none");
resetForm();
}}
>
{t("admin_form_close") ?? "Zavřít formulář"}
</Button>
)}
{formMode !== "none" && (
<AdminUserForm
mode={formMode}
editing={editing}
submitting={submitting}
serverError={formError}
onSubmit={handleSubmit}
onCancel={() => {
setFormMode("none");
resetForm();
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { useParams } from "react-router-dom";
import { useContestStore } from "@/stores/contestStore";
import { useUserStore } from "@/stores/userStore";
import ContestDetail from "@/components/ContestDetail";
import RoundsTable from "@/components/RoundsTable";
import { Card, CardHeader, CardBody, Divider } from "@heroui/react";
import { useTranslation } from "react-i18next";
export default function ContestDetailPage() {
const { contestId } = useParams();
const id = contestId ? Number(contestId) : null;
const selectedContest = useContestStore((s) => s.selectedContest);
const user = useUserStore((s) => s.user);
const { t } = useTranslation("common");
const isAuthenticated = !!user;
const showTests = isAuthenticated;
return (
<>
<ContestDetail contest={selectedContest} />
<Card>
<CardHeader>
<span className="text-md font-semibold">
{t("contest_rounds_title") ?? "Kola závodu"}
</span>
</CardHeader>
<Divider />
<CardBody>
<RoundsTable
contestId={id}
showTests={showTests}
enableEdit={false}
showContestColumn={false}
enableRowNavigation
roundsFromStore={selectedContest ? selectedContest.rounds : null}
/>
</CardBody>
</Card>
</>
);
}

View File

@@ -0,0 +1,26 @@
import NewsList from "@/components/NewsList";
import RoundsTable from "@/components/RoundsTable";
import { useUserStore } from "@/stores/userStore";
import { useTranslation } from "react-i18next";
export default function ContestsIndexPage() {
const user = useUserStore((s) => s.user);
const { t } = useTranslation("common");
const showTests = !!user;
return (
<>
<NewsList initialLimit={2}/>
<RoundsTable
title={t("open_rounds_title") ?? "Otevřené a zatím nezpracované závody"}
onlyActive={true}
showActiveColumn={false}
enableRowNavigation
showTests={showTests}
hideInactiveForGuests={!user}
isGuest={!user}
/>
</>
)
}

View File

@@ -0,0 +1,36 @@
import { useParams, useNavigate, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import LogDetail from "@/components/LogDetail";
export default function LogDetailPage() {
const { contestId, roundId, logId } = useParams();
const { t } = useTranslation("common");
const navigate = useNavigate();
const location = useLocation();
const from = (location.state as { from?: string } | null)?.from;
return (
<div className="space-y-4">
<div className="text-sm text-foreground-500 mb-2 flex gap-2">
<button
className="underline"
onClick={() => {
if (from) {
navigate(from);
} else if (contestId && roundId) {
navigate(`/contests/${contestId}/rounds/${roundId}?tab=logs`);
} else {
navigate(-1);
}
}}
>
{t("back") ?? "Zpět"}
</button>
<span>/</span>
{t("log") ?? "Log"}
</div>
<LogDetail logId={logId ? Number(logId) : null} />
</div>
);
}

View File

@@ -0,0 +1,12 @@
import LoginDialog from '../components/LoginDialog';
export default function LoginPage () {
return (
<div className='grow py-2 px-2 md:px-4'>
<div className='flex text-3xl font-bold underline mx-auto'>
<LoginDialog />
</div>
</div>
)
}

View File

@@ -0,0 +1,223 @@
import { useParams, useSearchParams } from "react-router-dom";
import RoundDetail from "@/components/RoundDetail";
import RoundFileUpload from "@/components/RoundFileUpload";
import LogsTable from "@/components/LogsTable";
import ResultsTables from "@/components/ResultsTables";
import RoundEvaluationPanel from "@/components/RoundEvaluationPanel";
import { Button, Card, CardHeader, CardBody, Divider, Tabs, Tab } from "@heroui/react";
import { useContestStore } from "@/stores/contestStore";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useUserStore } from "@/stores/userStore";
import axios from "axios";
import useRoundEvaluationRun from "@/hooks/useRoundEvaluationRun";
export default function RoundDetailPage() {
const { contestId, roundId } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const cId = contestId ? Number(contestId) : null;
const rId = roundId ? Number(roundId) : null;
const selectedRound = useContestStore((s) => s.selectedRound);
const user = useUserStore((s) => s.user);
const [logsRefreshKey, setLogsRefreshKey] = useState(0);
const selectedTab = searchParams.get("tab") ?? "verified";
const [declaredFilter, setDeclaredFilter] = useState<"ALL" | "OK">("ALL");
const [finalFilter, setFinalFilter] = useState<"ALL" | "OK">("ALL");
const [finalResultLabel, setFinalResultLabel] = useState<string | null>(null);
const [finalResultClass, setFinalResultClass] = useState<string>("");
const [finalResultType, setFinalResultType] = useState<string | null>(null);
const [recalcLoading, setRecalcLoading] = useState(false);
const [recalcMessage, setRecalcMessage] = useState<string | null>(null);
const [recalcError, setRecalcError] = useState<string | null>(null);
const { run } = useRoundEvaluationRun(rId);
const { t } = useTranslation("common");
const roundDeadline =
selectedRound?.id === rId ? selectedRound?.logs_deadline ?? null : null;
const anonymousUploadClosed = () => {
if (user) return false;
if (!roundDeadline) return false;
const deadline = new Date(roundDeadline);
if (Number.isNaN(deadline.getTime())) return false;
return new Date() > deadline;
};
const publicResultType = finalResultType ?? run?.result_type ?? null;
const shouldShowEvaluatingMessage =
!user &&
((run && run.status !== "SUCCEEDED") || publicResultType === "TEST");
const evaluatingLabel = t("results_evaluating") ?? "Vyhodnocuje se";
const filterAllLabel = t("results_filter_all") ?? "Všechny výsledky";
const filterOkLabel = t("results_filter_ok_ol") ?? "OK/OL závodníci";
const handleTabChange = (key: any) => {
const params = new URLSearchParams(searchParams);
params.set("tab", String(key));
setSearchParams(params, { replace: true });
};
const handleRecalculate = async () => {
if (!rId) return;
try {
setRecalcLoading(true);
setRecalcMessage(null);
setRecalcError(null);
await axios.post(`/api/rounds/${rId}/recalculate-claimed`, null, {
headers: { Accept: "application/json" },
withCredentials: true,
});
setRecalcMessage(
(t("declared_recalculate_started") as string) || "Přepočet byl spuštěn."
);
} catch (e: any) {
const fallback =
(t("declared_recalculate_failed") as string) ||
"Nepodařilo se spustit přepočet.";
const msg = e?.response?.data?.message || fallback;
setRecalcError(msg);
} finally {
setRecalcLoading(false);
}
};
return (
<>
<Card>
<CardHeader>
<span className="text-md font-semibold">
{t("round_detail_title") ?? "Detail kola"}
</span>
</CardHeader>
<Divider />
<CardBody>
<RoundDetail roundId={rId} />
</CardBody>
</Card>
{user && <RoundEvaluationPanel roundId={rId} />}
{anonymousUploadClosed && (
<RoundFileUpload
roundId={rId}
startTime={selectedRound?.start_time ?? null}
logsDeadline={roundDeadline}
onUploaded={() => setLogsRefreshKey((k) => k + 1)}
/>
)}
<Card className="mt-4">
<Tabs
aria-label={t("round_detail_tabs_aria") ?? "Round detail tabs"}
selectedKey={selectedTab}
onSelectionChange={handleTabChange}
>
<Tab key="verified" title={t("results_tab") ?? "Výsledky"}>
<CardBody>
{shouldShowEvaluatingMessage ? (
<div className="text-sm text-foreground-600">{evaluatingLabel}</div>
) : (
<>
{finalResultLabel && (
<div
className={[
"mb-2 inline-flex items-center rounded px-2 py-1 text-xs font-semibold",
finalResultClass,
]
.filter(Boolean)
.join(" ")}
>
{finalResultLabel}
</div>
)}
<div className="mb-3 flex items-center gap-3 text-sm">
<label className="flex items-center gap-1 cursor-pointer">
<input
type="radio"
name="finalFilter"
value="ALL"
checked={finalFilter === "ALL"}
onChange={() => setFinalFilter("ALL")}
/>
<span>{filterAllLabel}</span>
</label>
<label className="flex items-center gap-1 cursor-pointer">
<input
type="radio"
name="finalFilter"
value="OK"
checked={finalFilter === "OK"}
onChange={() => setFinalFilter("OK")}
/>
<span>{filterOkLabel}</span>
</label>
</div>
<ResultsTables
roundId={rId}
contestId={cId}
filter={finalFilter}
mode="final"
showResultTypeLabel={false}
onResultTypeChange={(label, className, resultType) => {
setFinalResultLabel(label);
setFinalResultClass(className);
setFinalResultType(resultType);
}}
refreshKey={run?.result_type ?? ""}
evaluationRunId={run?.id ?? null}
/>
</>
)}
</CardBody>
</Tab>
<Tab key="declared" title={t("declared_results_tab") ?? "Deklarované výsledky"}>
<CardBody>
<div className="mb-3 text-sm text-foreground-600 space-y-1">
<p>{t("declared_note_line1") ?? "Deklarované výsledky jsou předběžné výsledky OK a OL stanic."}</p>
<p>{t("declared_note_line2") ?? "Zobrazené mezinárodní výsledky nejsou oficiální výsledky a slouží pouze pro porovnání."}</p>
<p>{t("declared_note_line3") ?? "Deklarované výsledky jsou uspořádány na základě údajů v hlavičce EDI souborů v řádce CQSOP=. Další sloupce rovněž zobrazují data z deníku, které jsou kontrolovány pouze na správnost formátu zápisu."}</p>
</div>
<div className="mb-3 flex items-center gap-3 text-sm">
<label className="flex items-center gap-1 cursor-pointer">
<input
type="radio"
name="declaredFilter"
value="ALL"
checked={declaredFilter === "ALL"}
onChange={() => setDeclaredFilter("ALL")}
/>
<span>{filterAllLabel}</span>
</label>
<label className="flex items-center gap-1 cursor-pointer">
<input
type="radio"
name="declaredFilter"
value="OK"
checked={declaredFilter === "OK"}
onChange={() => setDeclaredFilter("OK")}
/>
<span>{filterOkLabel}</span>
</label>
{user && (
<Button
size="sm"
variant="flat"
onPress={handleRecalculate}
isLoading={recalcLoading}
isDisabled={!rId}
>
{t("declared_recalculate") ?? "Přepočítat"}
</Button>
)}
</div>
{recalcMessage && <div className="mb-2 text-sm text-green-600">{recalcMessage}</div>}
{recalcError && <div className="mb-2 text-sm text-red-600">{recalcError}</div>}
<ResultsTables roundId={rId} contestId={cId} filter={declaredFilter} />
</CardBody>
</Tab>
<Tab key="logs" title={t("uploaded_logs_tab") ?? "Nahrané logy"}>
<CardBody>
<LogsTable roundId={rId} contestId={cId} refreshKey={logsRefreshKey} />
</CardBody>
</Tab>
</Tabs>
</Card>
</>
);
}

71
resources/js/routes.tsx Normal file
View File

@@ -0,0 +1,71 @@
import type {RouteObject} from 'react-router-dom'
import { Navigate } from 'react-router-dom'
import AboutPage from './pages/AboutPage'
import AdminPage from './pages/AdminPage'
import AdminContestsPage from './pages/AdminContestsPage'
import AdminNewsPage from './pages/AdminNewsPage'
import AdminEvaluationPage from './pages/AdminEvaluationPage'
import AdminUsersPage from './pages/AdminUsersPage'
import LoginPage from './pages/LoginPage'
import ContestsIndexPage from "./pages/ContestsIndexPage";
import ContestDetailPage from "./pages/ContestDetailPage";
import RoundDetailPage from "./pages/RoundDetailPage";
import LogDetailPage from "./pages/LogDetailPage";
import TwoPaneLayout from "@/layouts/TwoPaneLayout";
import ContestsLeftPanel from '@/components/layout/ContestsLeftPanel'
import AppBreadcrumbs from "@/components/AppBreadcrumbs";
// https://reactrouter.com/start/declarative/routing
const routes: RouteObject[] = [
{
path: '/',
element: <Navigate to="/contests" replace />,
},
{
path: '/about',
element: <AboutPage />,
},
{
path: '/admin',
element: <AdminPage />,
},
{
path: '/admin/contests',
element: <AdminContestsPage />,
},
{
path: '/admin/news',
element: <AdminNewsPage />,
},
{
path: '/admin/evaluation-rule-sets',
element: <AdminEvaluationPage />,
},
{
path: '/admin/users',
element: <AdminUsersPage />,
},
{
path: '/login',
element: <LoginPage />,
},
{
path: "/contests",
element: (
<TwoPaneLayout
value={{
left: <ContestsLeftPanel />,
rightTop: <AppBreadcrumbs />,
}}
/>
),
children: [
{ index: true, element: <ContestsIndexPage /> }, // /contests
{ path: ":contestId", element: <ContestDetailPage /> }, // /contests/:contestId
{ path: ":contestId/rounds/:roundId", element: <RoundDetailPage /> }, // /contests/:contestId/rounds/:roundId
{ path: ":contestId/rounds/:roundId/logs/:logId", element: <LogDetailPage /> }, // detail logu
],
},
]
export default routes

View File

@@ -0,0 +1,12 @@
import { create } from "zustand";
type ContestRefreshStore = {
refreshKey: number;
triggerRefresh: () => void;
};
export const useContestRefreshStore = create<ContestRefreshStore>((set) => ({
refreshKey: 0,
triggerRefresh: () =>
set((s) => ({ refreshKey: s.refreshKey + 1 })),
}));

View File

@@ -0,0 +1,87 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type ContestSummary = {
id: number;
name: string; // už přeložené jméno podle locale
description?: string | null;
is_active: boolean;
is_test: boolean;
is_mcr: boolean;
is_sixhr: boolean;
start_time: string | null; // ISO string z API
duration: number; // hodiny
logs_deadline_days: number;
rounds?: RoundSummary[];
};
export type RoundSummary = {
id: number;
contest_id: number;
name: string;
description?: string | 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;
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;
};
type ContestState = {
selectedContest: ContestSummary | null;
selectedRound: RoundSummary | null;
selectedContestRounds: RoundSummary[];
setSelectedContest: (contest: ContestSummary | null) => void;
setSelectedRound: (round: RoundSummary | null) => void;
setSelectedContestRounds: (rounds: RoundSummary[]) => void;
clearSelection: () => void;
};
export const useContestStore = create<ContestState>()(
persist(
(set) => ({
selectedContest: null,
selectedRound: null,
selectedContestRounds: [],
setSelectedContest: (contest) =>
set({
selectedContest: contest,
// při přepnutí závodu implicitně zruš vybrané kolo
selectedRound: null,
selectedContestRounds: contest?.rounds ?? [],
}),
setSelectedRound: (round) => set({ selectedRound: round }),
setSelectedContestRounds: (rounds) => set({ selectedContestRounds: rounds }),
clearSelection: () =>
set({
selectedContest: null,
selectedRound: null,
selectedContestRounds: [],
}),
}),
{
name: 'contest-store',
partialize: (state) => ({
selectedContest: state.selectedContest,
selectedRound: state.selectedRound,
selectedContestRounds: state.selectedContestRounds,
}),
}
)
);
// Použití:
// import { useContestStore } from '@/stores/contestStore';
// const selectedContest = useContestStore((s) => s.selectedContest);
// const selectedRound = useContestStore((s) => s.selectedRound);
// const setSelectedContest = useContestStore((s) => s.setSelectedContest);
// const setSelectedRound = useContestStore((s) => s.setSelectedRound);
// po kliknutí v seznamu závodů:
//setSelectedContest(contestFromApi);

View File

@@ -0,0 +1,69 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type Locale = 'cs' | 'en';
type LanguageState = {
locale: Locale;
setLocale: (locale: Locale) => void;
};
const LOCALE_COOKIE = 'locale';
function detectInitialLocale(): Locale {
if (typeof document !== 'undefined') {
// 1) cookie
const cookieMatch = document.cookie
.split('; ')
.find((row) => row.startsWith(`${LOCALE_COOKIE}=`));
if (cookieMatch) {
const value = cookieMatch.split('=')[1];
if (value === 'cs' || value === 'en') return value;
}
// 2) <html lang="...">
const htmlLang = document.documentElement.lang;
if (htmlLang.startsWith('cs')) return 'cs';
if (htmlLang.startsWith('en')) return 'en';
}
return 'cs';
}
function setLocaleCookie(locale: Locale) {
const expires = new Date();
expires.setFullYear(expires.getFullYear() + 1);
document.cookie = [
`${LOCALE_COOKIE}=${encodeURIComponent(locale)}`,
`expires=${expires.toUTCString()}`,
'path=/',
'SameSite=Lax',
].join('; ');
}
export const useLanguageStore = create<LanguageState>()(
persist(
(set) => ({
locale: detectInitialLocale(),
setLocale: (locale) => {
set({ locale });
// sync s DOM a Laravel
if (typeof document !== 'undefined') {
document.documentElement.lang = locale;
setLocaleCookie(locale);
}
},
}),
{
name: 'language-store',
partialize: (state) => ({ locale: state.locale }),
}
)
);
// Použití:
// import { useLanguageStore } from '@/stores/languageStore';
// const locale = useLanguageStore((s) => s.locale);
// const setLocale = useLanguageStore((s) => s.setLocale);

View File

@@ -0,0 +1,37 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type User = {
id: number;
name: string;
email: string;
is_admin?: boolean;
is_active?: boolean;
// případně další pole: roles, callsign, atd.
};
type UserState = {
user: User | null;
setUser: (user: User | null) => void;
clearUser: () => void;
};
export const useUserStore = create<UserState>()(
persist(
(set) => ({
user: null,
setUser: (user) => set({ user }),
clearUser: () => set({ user: null }),
}),
{ name: 'user-store' }
)
);
// použití:
// import { useUserStore } from '@/stores/userStore';
// const user = useUserStore((s) => s.user); // získání přihlášeného uživatele
// const isAuthenticated = !!user;
// const setUser = useUserStore((s) => s.setUser);
// const clearUser = useUserStore((s) => s.clearUser);

25
resources/js/types/edi.ts Normal file
View File

@@ -0,0 +1,25 @@
export type EdiHeaderForm = {
TName: string;
TDate: string;
PCall: string;
PWWLo: string;
PSect: string;
PBand: string;
RHBBS: string;
SPowe: string;
SAnte: string;
RCall: string;
MOpe1: string;
MOpe2: string;
};
export type PSectResult = {
operatorClass: "SO" | "MO" | null;
powerClass: "QRP" | "N" | "LP" | "A" | null;
timeClass: "6H" | null;
isCheckLog: boolean;
unknownTokens: string[];
normalized: string;
errors: string[];
warnings: string[];
};

View File

@@ -0,0 +1,297 @@
import {
PWWLO_REGEX,
normalizeContent,
validateCallsign,
validateCallsignList,
isMultiOp,
isSingleOp,
} from "@/utils/ediValidation";
import i18n from "@/i18n";
import { type EdiHeaderForm } from "@/types/edi";
const translate = (key: string, options?: Record<string, unknown>, fallback?: string) => {
const value = i18n.t(key, options);
if (fallback && (value === key || value === "")) return fallback;
return value as string;
};
export const validateEdi = (content: string, name: string): string[] => {
const errors: string[] = [];
const lines = normalizeContent(content).split("\n");
const nonEmpty = lines.map((l) => l.trim()).filter((l) => l.length > 0);
let tDateStart: number | null = null;
let tDateEnd: number | null = null;
let tDateStartYear: number | null = null;
let tDateEndYear: number | null = null;
let tDateValid = false;
if (nonEmpty.length === 0) {
errors.push(
translate("edi_error_file_empty", { name }, `${name}: soubor je prázdný.`)
);
return errors;
}
if (!nonEmpty[0].startsWith("[REG1TEST")) {
errors.push(
translate(
"edi_error_missing_header",
{ name },
`${name}: chybí hlavička [REG1TEST;1].`
)
);
}
const headerMap: Record<string, string> = {};
let remarksStart = -1;
let qsoStart = -1;
nonEmpty.forEach((line, idx) => {
if (line.startsWith("[Remarks")) remarksStart = idx;
if (line.startsWith("[QSORecords")) qsoStart = idx;
const m = line.match(/^([A-Za-z0-9]+)=(.*)$/);
if (m) {
headerMap[m[1]] = m[2].trim();
}
});
// Required header fields
const requiredFields: Array<[keyof EdiHeaderForm, string]> = [
["TName", translate("edi_field_tname", undefined, "název soutěže (TName)")],
["TDate", translate("edi_field_tdate", undefined, "datum závodu (TDate=YYYYMMDD;YYYYMMDD)")],
["PCall", translate("edi_field_pcall", undefined, "volací znak (PCall)")],
["PWWLo", translate("edi_field_pwwlo", undefined, "lokátor (PWWLo)")],
["PSect", translate("edi_field_psect", undefined, "sekce/kategorie (PSect)")],
["PBand", translate("edi_field_pband", undefined, "použité pásmo (PBand)")],
["SPowe", translate("edi_field_spowe", undefined, "výkon (SPowe)")],
["SAnte", translate("edi_field_sante", undefined, "anténa (SAnte)")],
["CQSOP", translate("edi_field_cqsop", undefined, "QSO body (CQSOP)")],
["CToSc", translate("edi_field_ctosc", undefined, "celkové body (CToSc)")],
];
requiredFields.forEach(([key, label]) => {
if (!headerMap[key] || headerMap[key].trim() === "") {
errors.push(
translate(
"edi_error_missing_field",
{ name, field: label },
`${name}: chybí ${label}.`
)
);
}
});
if (headerMap["TDate"]) {
const parts = headerMap["TDate"].split(";");
const invalidCount = parts.length !== 2;
const invalidFormat = parts.some((p) => !/^\d{8}$/.test(p));
const invertedRange = !invalidCount && !invalidFormat && parseInt(parts[0], 10) > parseInt(parts[1], 10);
if (invalidCount || invalidFormat || invertedRange) {
errors.push(
translate(
"edi_error_tdate_format",
{ name },
`${name}: TDate musí být ve formátu YYYYMMDD;YYYYMMDD a první datum nesmí být větší než druhé.`
)
);
} else {
tDateStart = parseInt(parts[0], 10);
tDateEnd = parseInt(parts[1], 10);
tDateStartYear = parseInt(parts[0].slice(0, 4), 10);
tDateEndYear = parseInt(parts[1].slice(0, 4), 10);
tDateValid = true;
}
}
if (headerMap["PWWLo"]) {
const normalizedPwwlo = headerMap["PWWLo"].trim().toUpperCase();
if (!PWWLO_REGEX.test(normalizedPwwlo)) {
errors.push(
translate(
"edi_error_pwwlo_format",
{ name },
`${name}: PWWLo musí mít 6 znaků ve formátu lokátoru (AA00AA).`
)
);
}
headerMap["PWWLo"] = normalizedPwwlo;
}
if (headerMap["RHBBS"] && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(headerMap["RHBBS"])) {
// měkká validace, řeší se upozorněním ve formuláři
}
const pcallValidation = validateCallsign(headerMap["PCall"] ?? "");
if (!pcallValidation.isValid) {
errors.push(
translate(
"edi_error_pcall_format",
{ name },
`${name}: PCall musí být validní volací znak.`
)
);
} else {
headerMap["PCall"] = pcallValidation.normalized;
}
const section = headerMap["PSect"] ?? "";
const requiresSingle = isSingleOp(section);
const requiresMulti = isMultiOp(section);
if (requiresSingle || requiresMulti) {
const rcallValidation = validateCallsign(headerMap["RCall"] ?? "");
if (!rcallValidation.isValid) {
errors.push(
translate(
"edi_error_rcall_for_psect",
{ name, psect: section },
`${name}: chybí nebo je neplatné RCall pro PSect ${section}.`
)
);
}
if (requiresMulti) {
const mope1Val = validateCallsignList(headerMap["MOpe1"] ?? "");
const mope2Val = validateCallsignList(headerMap["MOpe2"] ?? "");
const hasAnyValid = mope1Val.hasValid || mope2Val.hasValid;
const invalidTokens = [...mope1Val.invalidTokens, ...mope2Val.invalidTokens];
if (!hasAnyValid) {
errors.push(
translate(
"edi_error_mope_missing",
{ name },
`${name}: chybí MOpe1/MOpe2 pro multi operátory.`
)
);
}
if (invalidTokens.length > 0) {
errors.push(
translate(
"edi_error_mope_invalid",
{ name, invalid: invalidTokens.join(", ") },
`${name}: neplatné značky v MOpe1/MOpe2 (${invalidTokens.join(", ")}).`
)
);
}
}
}
if (qsoStart === -1) {
errors.push(
translate(
"edi_error_missing_qso_records",
{ name },
`${name}: chybí sekce [QSORecords;N].`
)
);
return errors;
}
const qsoHeader = nonEmpty[qsoStart];
const m = qsoHeader.match(/^\[QSORecords;(\d+)\]/);
const expectedCount = m ? parseInt(m[1], 10) : null;
const qsoLines: string[] = [];
for (let i = qsoStart + 1; i < nonEmpty.length; i++) {
const line = nonEmpty[i];
if (line.startsWith("[END")) break;
if (line.startsWith("[") && !line.startsWith("[QSORecords")) break;
qsoLines.push(line);
}
if (expectedCount !== null && qsoLines.length !== expectedCount) {
// upozornění řeší computeQsoCountWarning, tady neblokujeme upload
}
let hasQsoValidationError = false;
qsoLines.forEach((line, idx) => {
const parts = line.split(";");
if (parts.length < 10) {
errors.push(
translate(
"edi_error_qso_fields_min",
{ name, index: idx + 1, min: 10 },
`${name}: QSO #${idx + 1} má málo polí (minimálně 10 očekáváno).`
)
);
hasQsoValidationError = true;
return;
}
const date = parts[0] ?? "";
const time = parts[1] ?? "";
const call = parts[2] ?? "";
if (!/^\d{6}$/.test(date)) {
errors.push(
translate(
"edi_error_qso_date_format",
{ name, index: idx + 1 },
`${name}: QSO #${idx + 1} má neplatné datum (YYMMDD).`
)
);
hasQsoValidationError = true;
} else if (tDateValid && tDateStart !== null && tDateEnd !== null) {
const yy = parseInt(date.slice(0, 2), 10);
const mmdd = date.slice(2);
let fullYear: number | null = null;
if (tDateStartYear !== null && tDateEndYear !== null) {
if (tDateStartYear === tDateEndYear) {
fullYear = tDateStartYear;
} else if (yy === tDateStartYear % 100) {
fullYear = tDateStartYear;
} else if (yy === tDateEndYear % 100) {
fullYear = tDateEndYear;
}
}
if (fullYear === null) {
errors.push(
translate(
"edi_error_qso_year_out_of_range",
{ name, index: idx + 1 },
`${name}: QSO #${idx + 1} má rok mimo rozsah TDate.`
)
);
hasQsoValidationError = true;
} else {
const fullDate = parseInt(`${fullYear}${mmdd}`, 10);
if (fullDate < tDateStart || fullDate > tDateEnd) {
errors.push(
translate(
"edi_error_qso_date_out_of_range",
{ name, index: idx + 1 },
`${name}: QSO #${idx + 1} má datum mimo rozsah TDate.`
)
);
hasQsoValidationError = true;
}
}
}
if (!/^\d{4}$/.test(time)) {
errors.push(
translate(
"edi_error_qso_time_format",
{ name, index: idx + 1 },
`${name}: QSO #${idx + 1} má neplatný čas (HHMM UTC).`
)
);
hasQsoValidationError = true;
}
const qsoCallValidation = validateCallsign(call);
if (!qsoCallValidation.isValid) {
errors.push(
translate(
"edi_error_qso_callsign",
{ name, index: idx + 1 },
`${name}: QSO #${idx + 1} má neplatný volací znak.`
)
);
hasQsoValidationError = true;
}
});
if (hasQsoValidationError) {
errors.push(
translate(
"edi_error_qso_edit_in_file",
{ name },
`${name}: Záznamy QSO nelze upravit ve formuláři, oprav je v souboru a zkus ho nahrát znovu.`
)
);
}
return errors;
};

View File

@@ -0,0 +1,355 @@
import { EdiHeaderForm, PSectResult } from "@/types/edi";
import i18n from "@/i18n";
const translate = (key: string, options?: Record<string, unknown>, fallback?: string) => {
const value = i18n.t(key, options);
if (fallback && (value === key || value === "")) return fallback;
return value as string;
};
export const PWWLO_REGEX = /^[A-R]{2}[0-9]{2}[A-X]{2}$/;
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const CALLSIGN_REGEX =
/^(?:[A-Z0-9]{1,3}\/[A-Z0-9]{1,3}[0-9][A-Z0-9]{1,4}|[A-Z0-9]{1,3}[0-9][A-Z0-9]{1,4}(?:\/[A-Z0-9]{1,4})?)$/;
export const normalizeContent = (content: string) => content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
export const hasEdiHeader = (content: string) => {
const lines = normalizeContent(content)
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0);
return lines.length > 0 && lines[0].toUpperCase().startsWith("[REG1TEST");
};
export const extractHeaderMap = (content: string) => {
const lines = normalizeContent(content).split("\n");
const headerMap: Record<string, string> = {};
lines.forEach((line) => {
const trimmed = line.trim();
const m = trimmed.match(/^([A-Za-z0-9]+)=(.*)$/);
if (m) {
headerMap[m[1]] = m[2].trim();
}
});
return headerMap;
};
export const normalizeCallsign = (raw: string) => {
let value = raw.trim().toUpperCase().replace(/\s+/g, "");
if (!value) return "";
const firstSlash = value.indexOf("/");
if (firstSlash !== -1) {
const before = value.slice(0, firstSlash + 1);
const after = value.slice(firstSlash + 1).replace(/\//g, "");
value = before + after;
} else {
value = value.replace(/\//g, "");
}
return value;
};
export const validateCallsign = (raw: string) => {
const normalized = normalizeCallsign(raw);
const isValid = normalized !== "" && CALLSIGN_REGEX.test(normalized);
return { normalized, isValid };
};
export const validateCallsignList = (raw: string) => {
const tokens = raw.split(/[\s;,]+/).map((t) => t.trim()).filter((t) => t.length > 0);
const results = tokens.map((t) => validateCallsign(t));
const invalidTokens = tokens.filter((_, idx) => !results[idx].isValid);
const normalizedTokens = results.map((r) => r.normalized).filter((n) => n.length > 0);
const hasValid = results.some((r) => r.isValid);
return { tokens, invalidTokens, normalizedTokens, hasValid };
};
export type SpoweParse = { normalized: string; changed: boolean; valid: boolean };
export const normalizeSpowe = (raw: string): SpoweParse => {
const trimmed = raw.trim();
if (!trimmed) return { normalized: "", changed: false, valid: false };
// remove unit suffixes and prepare numeric string
const lower = trimmed.toLowerCase();
const compact = lower.replace(/\s+/g, "");
let cleaned = lower;
let factor = 1;
if (compact.endsWith("kw")) {
cleaned = compact.slice(0, -2);
factor = 1000;
} else if (compact.endsWith("mw")) {
cleaned = compact.slice(0, -2);
factor = 0.001;
} else if (compact.endsWith("w")) {
cleaned = compact.slice(0, -1);
}
cleaned = cleaned.trim();
// allow comma or dot as decimal separator
cleaned = cleaned.replace(/,/g, ".");
// if nothing but digits/dot remains, parse float
if (!/^[0-9]*\.?[0-9]+$/.test(cleaned)) {
return { normalized: cleaned || trimmed, changed: cleaned !== trimmed, valid: false };
}
const numeric = parseFloat(cleaned) * factor;
if (Number.isNaN(numeric)) {
return { normalized: cleaned || trimmed, changed: cleaned !== trimmed, valid: false };
}
const normalized = factor === 1000 ? String(Math.round(numeric)) : String(numeric);
const changed = normalized !== trimmed;
return { normalized, changed, valid: true };
};
export const isSingleOp = (psect: string) => {
const lower = psect.toLowerCase();
const patterns = ["single", "single-op", "single operator", "so", "so-lp", "sop", "sohp"];
return patterns.some((p) => lower.includes(p));
};
export const isMultiOp = (psect: string) => {
const lower = psect.toLowerCase();
const patterns = ["multi", "multi-op", "multi operator", "mo", "mo-lp", "mohp"];
return patterns.some((p) => lower.includes(p));
};
export const hasPowerKeyword = (psect: string) => {
const lower = psect.toLowerCase();
return /\b(qrp|lp)\b/.test(lower) || lower.includes("low power");
};
export const computeQsoCountWarning = (content: string, name: string): string | null => {
const lines = normalizeContent(content).split("\n");
const nonEmpty = lines.map((l) => l.trim()).filter((l) => l.length > 0);
let qsoStart = -1;
nonEmpty.forEach((line, idx) => {
if (line.startsWith("[QSORecords")) qsoStart = idx;
});
if (qsoStart === -1) return null;
const qsoHeader = nonEmpty[qsoStart];
const m = qsoHeader.match(/^\[QSORecords;(\d+)\]/);
const expectedCount = m ? parseInt(m[1], 10) : null;
const qsoLines: string[] = [];
for (let i = qsoStart + 1; i < nonEmpty.length; i++) {
const line = nonEmpty[i];
if (line.startsWith("[END")) break;
if (line.startsWith("[") && !line.startsWith("[QSORecords")) break;
qsoLines.push(line);
}
if (expectedCount !== null && qsoLines.length !== expectedCount) {
return translate(
"edi_warning_qso_count_mismatch",
{ name, expected: expectedCount, actual: qsoLines.length },
`${name}: Deklarovaný počet QSO (${expectedCount}) nesedí se skutečným (${qsoLines.length}).`
);
}
return null;
};
export const updateContentWithHeaders = (content: string, updates: Partial<EdiHeaderForm>): string => {
const normalized = normalizeContent(content);
const lines = normalized.split("\n");
const updatedLines = [...lines];
const regIndex = lines.findIndex((line) => line.trim().toUpperCase().startsWith("[REG1TEST"));
const insertionIndex = regIndex >= 0 ? regIndex + 1 : lines.length;
const handledKeys = new Set<string>();
updatedLines.forEach((line, idx) => {
const m = line.trim().match(/^([A-Za-z0-9]+)=(.*)$/);
if (!m) return;
const key = m[1];
if (key in updates) {
updatedLines[idx] = `${key}=${updates[key as keyof EdiHeaderForm] ?? ""}`;
handledKeys.add(key);
}
});
Object.entries(updates).forEach(([key, value]) => {
if (handledKeys.has(key) || value === undefined) return;
updatedLines.splice(insertionIndex, 0, `${key}=${value}`);
});
return updatedLines.join("\n");
};
export const parsePSect = (raw: string, opts: { strict?: boolean; allow6h?: boolean } = {}): PSectResult => {
const strict = opts.strict ?? false;
const allow6h = opts.allow6h ?? false;
const normalizedBase = raw
.trim()
.toUpperCase()
.replace(/\b6\s*HOURS?\b/g, "6H")
.replace(/\s+/g, " ")
.replace(/[\/,;._]+/g, " ");
const tokensRaw = normalizedBase
.split(" ")
.flatMap((t) => t.split("-"))
.map((t) => t.trim())
.filter((t) => t.length > 0)
.map((t) =>
t
.replace(/^SINGLE$/, "SO")
.replace(/^MULTI$/, "MO")
.replace(/^LOWPOWER$/, "LP")
.replace(/^HIGHPOWER$/, "HP")
.replace(/^UNLIMITED$/, "A")
.replace(/^CHECKLOG$/, "CHECK")
);
let operatorClass: "SO" | "MO" | null = null;
let powerClass: "QRP" | "N" | "LP" | "A" | null = null;
let timeClass: "6H" | null = null;
let isCheckLog = false;
const unknownTokens: string[] = [];
const errors: string[] = [];
const warnings: string[] = [];
const powerTokens = new Set(["QRP", "N", "LP", "A", "HP"]);
const timeTokens = new Set(["6H"]);
const operatorTokens = new Set(["SO", "MO", "SOLO", "MULTI"]);
const optionalTokens = new Set(["CHECK", "SWL", "MGM", "CW", "SSB", "FM", "ALL", "FULL", "UNLIMITEDTIME", "12H", "24H", "OP"]);
tokensRaw.forEach((token) => {
if (operatorTokens.has(token)) {
if (token === "SOLO") {
operatorClass = operatorClass ?? "SO";
} else if (token === "MULTI") {
operatorClass = operatorClass ?? "MO";
} else {
operatorClass = operatorClass ?? (token as "SO" | "MO");
}
return;
}
if (powerTokens.has(token)) {
const mapped = token === "HP" ? "A" : token;
if (powerClass && powerClass !== mapped) {
errors.push(
translate(
"edi_error_psect_multiple_power",
undefined,
"PSect obsahuje více výkonových kategorií."
)
);
} else {
powerClass = mapped as typeof powerClass;
}
return;
}
if (timeTokens.has(token)) {
if (!allow6h) {
warnings.push(
translate(
"edi_warning_psect_time_not_allowed",
undefined,
"PSect obsahuje časovou kategorii 6H, která není pro toto kolo povolena."
)
);
return;
}
if (timeClass && timeClass !== token) {
errors.push(
translate(
"edi_error_psect_multiple_time",
undefined,
"PSect obsahuje více časových kategorií."
)
);
} else {
timeClass = token as "6H";
}
return;
}
if (token === "CHECK") {
isCheckLog = true;
return;
}
if (optionalTokens.has(token)) {
return;
}
unknownTokens.push(token);
});
if (!operatorClass && !isCheckLog) {
errors.push(
translate(
"edi_error_psect_missing_operator",
undefined,
"PSect musí obsahovat SO nebo MO."
)
);
}
if (isCheckLog) {
const hasAnythingElse =
operatorClass !== null ||
powerClass !== null ||
timeClass !== null ||
unknownTokens.length > 0;
if (hasAnythingElse) {
errors.push(
translate(
"edi_error_psect_check_extra",
undefined,
"PSect CHECK nesmí obsahovat žádné další tokeny."
)
);
}
}
if (strict && unknownTokens.length > 0) {
errors.push(
translate(
"edi_error_psect_unknown_tokens",
{ tokens: unknownTokens.join(", ") },
`PSect obsahuje neznámé tokeny: ${unknownTokens.join(", ")}.`
)
);
} else if (unknownTokens.length > 0) {
warnings.push(
translate(
"edi_warning_psect_unknown_tokens",
{ tokens: unknownTokens.join(", ") },
`Neznámé tokeny v PSect: ${unknownTokens.join(", ")}.`
)
);
}
const normalizedParts = [
operatorClass ?? "",
powerClass ?? "",
timeClass ?? "",
isCheckLog ? "CHECK" : "",
...unknownTokens,
].filter((t) => t);
return {
operatorClass,
powerClass,
timeClass,
isCheckLog,
unknownTokens,
normalized: normalizedParts.join(" "),
errors,
warnings,
};
};
export const buildIaruPsect = (res: PSectResult): string | null => {
if (res.isCheckLog) return "CHECK";
if (!res.operatorClass) return null;
const parts: string[] = [res.operatorClass];
if (res.powerClass && res.powerClass !== "A") {
parts[0] = `${parts[0]}-${res.powerClass}`;
}
let base = parts.join(" ");
if (res.timeClass === "6H") {
base = `${base} 6H`;
}
return base.trim();
};

View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}"
@class(['dark' => ($appearance ?? 'system') === 'dark'])>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
(function() {
const appearance = '{{ $appearance ?? "system" }}';
if (appearance === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (prefersDark) document.documentElement.classList.add('dark');
}
})();
</script>
<style>
html { background-color: oklch(1 0 0); }
html.dark { background-color: oklch(0.145 0 0); }
</style>
<title>{{ config('app.name', 'Laravel') }}</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
@viteReactRefresh
@vite('resources/css/app.css')
@vite('resources/js/app.tsx')
</head>
<body class="font-sans antialiased">
<div id="root"></div>
</body>
</html>