625 lines
22 KiB
TypeScript
625 lines
22 KiB
TypeScript
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>
|
||
);
|
||
}
|