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