Initial commit
This commit is contained in:
3
resources/js/pages/AboutPage.tsx
Normal file
3
resources/js/pages/AboutPage.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function AboutPage () {
|
||||
return "About page"
|
||||
}
|
||||
181
resources/js/pages/AdminContestsPage.tsx
Normal file
181
resources/js/pages/AdminContestsPage.tsx
Normal 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}":`)
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
514
resources/js/pages/AdminEvaluationPage.tsx
Normal file
514
resources/js/pages/AdminEvaluationPage.tsx
Normal 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 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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
143
resources/js/pages/AdminNewsPage.tsx
Normal file
143
resources/js/pages/AdminNewsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
resources/js/pages/AdminPage.tsx
Normal file
13
resources/js/pages/AdminPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
184
resources/js/pages/AdminUsersPage.tsx
Normal file
184
resources/js/pages/AdminUsersPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
resources/js/pages/ContestDetailPage.tsx
Normal file
42
resources/js/pages/ContestDetailPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
26
resources/js/pages/ContestsIndexPage.tsx
Normal file
26
resources/js/pages/ContestsIndexPage.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
36
resources/js/pages/LogDetailPage.tsx
Normal file
36
resources/js/pages/LogDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
resources/js/pages/LoginPage.tsx
Normal file
12
resources/js/pages/LoginPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
223
resources/js/pages/RoundDetailPage.tsx
Normal file
223
resources/js/pages/RoundDetailPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user