Files
vkv/resources/js/pages/AdminEvaluationPage.tsx
Zdeněk Burda 41e3ce6f25 Initial commit
2026-01-09 21:26:40 +01:00

515 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}