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 = { 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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [refreshKey, setRefreshKey] = useState(0); const [formMode, setFormMode] = useState("none"); const [editing, setEditing] = useState(null); const [form, setForm] = useState(emptyForm); const [initialForm, setInitialForm] = useState(emptyForm); const [submitting, setSubmitting] = useState(false); const [formError, setFormError] = useState(null); const [formSuccess, setFormSuccess] = useState(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 | 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 ( {labelText} {fieldKey} ); }; 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 = { 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 1–100." : 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 1–100." : 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 0–2." : 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 (

{t("admin_rulesets_title") ?? "Evaluation rules"}

{formMode !== "none" && ( )}
{loading ? (
{t("admin_rulesets_loading") ?? "Načítám rule sety…"}
) : error ? (
{error}
) : ( t(key) as string} /> )} {!loading && !error && (
{formMode !== "none" && ( )}
)} {formMode !== "none" && ( )} {label("admin_rulesets_help_title", "Dokumentace rulesetu")}
{rulesetDoc}
); }