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

503 lines
16 KiB
TypeScript

import React, { useState, useEffect } from "react";
import axios from "axios";
import { Button, Input, Switch, Textarea } from "@heroui/react";
import { useContestRefreshStore } from "@/stores/contestRefreshStore";
import { useTranslation } from "react-i18next";
type TranslationPayload = {
cs?: string;
en?: string;
};
type ContestFromApi = {
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;
url?: string | null;
evaluator?: string | null;
email?: string | null;
email2?: string | null;
is_mcr: boolean;
is_sixhr: boolean;
is_active: boolean;
is_test: boolean;
start_time: string | null;
duration: number;
logs_deadline_days: number;
};
type ContestFormMode = "create" | "edit";
type ContestCreateFormProps = {
mode?: ContestFormMode; // default "create"
contest?: ContestFromApi | null; // pro edit
onCreated?: (contest: ContestFromApi) => void;
onUpdated?: (contest: ContestFromApi) => void;
};
type Option = { id: number; name: string; code?: string | null };
const buildTranslationPayload = (cs: string, en: string): TranslationPayload => {
const trimmedCs = cs.trim();
const trimmedEn = en.trim();
// nic nevyplněno → prázdný objekt
if (!trimmedCs && !trimmedEn) {
return {};
}
// oba jazyky vyplněné → použij obě hodnoty
if (trimmedCs && trimmedEn) {
return {
cs: trimmedCs,
en: trimmedEn,
};
}
// vyplněný jen jeden jazyk → použij ho pro oba
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 normalizeStartTime = (time: string): string | undefined => {
if (!time.trim()) return undefined;
return time.length === 5 ? `${time}:00` : time;
};
export default function ContestCreateForm({
mode = "create",
contest,
onCreated,
onUpdated,
}: ContestCreateFormProps) {
const { t } = useTranslation("common");
const isEdit = mode === "edit" && contest != null;
const [nameCs, setNameCs] = useState("");
const [nameEn, setNameEn] = useState("");
const [descriptionCs, setDescriptionCs] = useState("");
const [descriptionEn, setDescriptionEn] = useState("");
const [url, setUrl] = useState("");
const [evaluator, setEvaluator] = useState("");
const [email, setEmail] = useState("");
const [email2, setEmail2] = useState("");
const [startTime, setStartTime] = useState("");
const [durationHours, setDurationHours] = useState("24");
const [deadlineDays, setDeadlineDays] = useState("3");
const [isMcr, setIsMcr] = useState(false);
const [isSixHr, setIsSixHr] = useState(false);
const [isActive, setIsActive] = useState(true);
const [availableBands, setAvailableBands] = useState<Option[]>([]);
const [availableCategories, setAvailableCategories] = useState<Option[]>([]);
const [availablePowerCategories, setAvailablePowerCategories] = useState<Option[]>([]);
const [availableRuleSets, setAvailableRuleSets] = useState<Option[]>([]);
const [selectedRuleSetId, setSelectedRuleSetId] = useState<number | null>(null);
const [selectedBandIds, setSelectedBandIds] = useState<number[]>([]);
const [selectedCategoryIds, setSelectedCategoryIds] = useState<number[]>([]);
const [selectedPowerCategoryIds, setSelectedPowerCategoryIds] = useState<number[]>([]);
const [loadingOptions, setLoadingOptions] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
// načti volby pro checkboxy
useEffect(() => {
let active = true;
(async () => {
try {
setLoadingOptions(true);
const [bandsRes, categoriesRes, powerCatsRes, ruleSetsRes] = await Promise.all([
axios.get<Option[] | { data: Option[] }>("/api/bands", { headers: { Accept: "application/json" } }),
axios.get<Option[] | { data: Option[] }>("/api/categories", { headers: { Accept: "application/json" } }),
axios.get<Option[] | { data: Option[] }>("/api/power-categories", { headers: { Accept: "application/json" } }),
axios.get<Option[] | { data: Option[] }>("/api/evaluation-rule-sets", { headers: { Accept: "application/json" } }),
]);
if (!active) return;
const normalize = (res: any): Option[] =>
Array.isArray(res.data ?? res) ? (res.data ?? res) : [];
setAvailableBands(normalize(bandsRes.data ?? bandsRes));
setAvailableCategories(normalize(categoriesRes.data ?? categoriesRes));
setAvailablePowerCategories(normalize(powerCatsRes.data ?? powerCatsRes));
const normalizedRuleSets = normalize(ruleSetsRes.data ?? ruleSetsRes);
setAvailableRuleSets(normalizedRuleSets);
if (!isEdit && !selectedRuleSetId) {
const defaultRuleSet = normalizedRuleSets.find((item) => item.code === "default_vhf_compat");
if (defaultRuleSet) {
setSelectedRuleSetId(defaultRuleSet.id);
}
}
} catch {
if (!active) return;
setError("Nepodařilo se načíst seznam pásem/kategorií.");
} finally {
if (active) setLoadingOptions(false);
}
})();
return () => {
active = false;
};
}, []);
React.useEffect(() => {
if (!isEdit || !contest) return;
const name = extractTranslations(contest.name);
const desc = extractTranslations(contest.description ?? null);
setNameCs(name.cs ?? "");
setNameEn(name.en ?? "");
setDescriptionCs(desc.cs ?? "");
setDescriptionEn(desc.en ?? "");
setUrl(contest.url ?? "");
setEvaluator(contest.evaluator ?? "");
setEmail(contest.email ?? "");
setEmail2(contest.email2 ?? "");
setStartTime(contest.start_time ?? "");
setDurationHours(String(contest.duration ?? 24));
setDeadlineDays(String(contest.logs_deadline_days ?? 3));
setIsMcr(!!contest.is_mcr);
setIsSixHr(!!contest.is_sixhr);
setIsActive(!!contest.is_active);
setSelectedBandIds(contest.bands?.map((b) => b.id) ?? []);
setSelectedCategoryIds(contest.categories?.map((c) => c.id) ?? []);
setSelectedPowerCategoryIds(contest.power_categories?.map((p) => p.id) ?? []);
setSelectedRuleSetId(contest.rule_set_id ?? null);
setError(null);
setSuccess(null);
}, [isEdit, contest]);
const triggerRefresh = useContestRefreshStore((s) => s.triggerRefresh);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
setSuccess(null);
const namePayload = buildTranslationPayload(nameCs, nameEn);
if (Object.keys(namePayload).length === 0) {
setError("Vyplň alespoň jeden překlad názvu závodu.");
return;
}
const descriptionPayload = buildTranslationPayload(descriptionCs, descriptionEn);
const payload: Record<string, unknown> = {
name: namePayload,
is_mcr: isMcr,
is_sixhr: isSixHr,
is_active: isActive,
};
if (Object.keys(descriptionPayload).length > 0) {
payload.description = descriptionPayload;
}
if (url.trim()) payload.url = url.trim();
if (evaluator.trim()) payload.evaluator = evaluator.trim();
if (email.trim()) payload.email = email.trim();
if (email2.trim()) payload.email2 = email2.trim();
const normalizedStartTime = normalizeStartTime(startTime);
if (normalizedStartTime) payload.start_time = normalizedStartTime;
const durationNumber = durationHours.trim() ? Number(durationHours) : undefined;
const deadlineNumber = deadlineDays.trim() ? Number(deadlineDays) : undefined;
if (durationNumber !== undefined && Number.isNaN(durationNumber)) {
setError(t("Délka závodu musí být číslo."));
return;
}
if (deadlineNumber !== undefined && Number.isNaN(deadlineNumber)) {
setError(t("Uzávěrka logů musí být číslo."));
return;
}
if (durationNumber !== undefined) payload.duration = durationNumber;
if (deadlineNumber !== undefined) payload.logs_deadline_days = deadlineNumber;
payload.band_ids = selectedBandIds;
payload.category_ids = selectedCategoryIds;
payload.power_category_ids = selectedPowerCategoryIds;
if (selectedRuleSetId) payload.rule_set_id = selectedRuleSetId;
try {
setSubmitting(true);
await axios.get("/sanctum/csrf-cookie", { withCredentials: true });
let response;
if (isEdit && contest) {
response = await axios.put(`/api/contests/${contest.id}`, payload, {
headers: {
Accept: "application/json",
},
withCredentials: true,
withXSRFToken: true,
});
setSuccess("Závod byl upraven.");
triggerRefresh();
onUpdated?.(response.data);
} else {
response = await axios.post("/api/contests", payload, {
headers: {
Accept: "application/json",
},
withCredentials: true,
withXSRFToken: true,
});
setSuccess("Závod byl vytvořen.");
triggerRefresh();
onCreated?.(response.data);
}
} catch (err) {
if (axios.isAxiosError(err)) {
const apiErrors =
err.response?.data?.errors ??
err.response?.data?.message ??
"Nepodařilo se uložit závod.";
setError(
typeof apiErrors === "string"
? apiErrors
: "Nepodařilo se uložit závod."
);
} else {
setError("Nepodařilo se uložit závod.");
}
} finally {
setSubmitting(false);
}
};
const toggleId = (id: number, list: number[], setter: (v: number[]) => void) => {
setter(list.includes(id) ? list.filter((x) => x !== id) : [...list, id]);
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<Input
label="Název (cs)"
placeholder="VKV závod"
value={nameCs}
onValueChange={setNameCs}
isRequired
/>
<Input
label="Name (en)"
placeholder="VHF contest"
value={nameEn}
onValueChange={setNameEn}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Textarea
label="Popis (cs)"
placeholder="Stručný popis závodu…"
minRows={2}
value={descriptionCs}
onValueChange={setDescriptionCs}
/>
<Textarea
label="Description (en)"
placeholder="Short contest description…"
minRows={2}
value={descriptionEn}
onValueChange={setDescriptionEn}
/>
</div>
<div className="grid gap-4 md:grid-cols-4">
<Input
label="URL závodu"
type="url"
placeholder="https://example.com"
value={url}
onValueChange={setUrl}
/>
<Input
label="Vyhodnocovatel"
placeholder="ČRK"
value={evaluator}
onValueChange={setEvaluator}
/>
<Input
label="Email"
type="email"
placeholder="kontakt@domena.cz"
value={email}
onValueChange={setEmail}
/>
<Input
label="Email 2"
type="email"
placeholder="druhy_kontakt@domena.cz"
value={email2}
onValueChange={setEmail2}
/>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Input
label="Start závodu (HH:MM)"
type="time"
value={startTime}
onValueChange={setStartTime}
/>
<Input
label="Délka závodu (hodiny)"
type="number"
min={1}
value={durationHours}
onValueChange={setDurationHours}
/>
<Input
label="Uzávěrka logů (dny)"
type="number"
min={0}
value={deadlineDays}
onValueChange={setDeadlineDays}
/>
</div>
<div className="flex flex-wrap gap-6">
<Switch isSelected={isActive} onValueChange={setIsActive}>
Závod je aktivní
</Switch>
<Switch isSelected={isMcr} onValueChange={setIsMcr}>
MČR
</Switch>
<Switch isSelected={isSixHr} onValueChange={setIsSixHr}>
6H závod
</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={() => toggleId(band.id, selectedBandIds, setSelectedBandIds)}
disabled={loadingOptions || 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={() => toggleId(cat.id, selectedCategoryIds, setSelectedCategoryIds)}
disabled={loadingOptions || 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={() => toggleId(p.id, selectedPowerCategoryIds, setSelectedPowerCategoryIds)}
disabled={loadingOptions || 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={loadingOptions || 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 && <p className="text-sm text-red-600">{error}</p>}
{success && <p className="text-sm text-green-600">{success}</p>}
<div className="flex gap-3">
<Button
type="submit"
color="primary"
isLoading={submitting}
isDisabled={submitting}
>
{isEdit ? "Uložit změny" : "Vytvořit závod"}
</Button>
</div>
</form>
);
}