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

625 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useState } from "react";
import axios from "axios";
import { Button, 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>
);
}