Initial commit

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

View File

@@ -0,0 +1,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>
);
}