503 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|