Initial commit
This commit is contained in:
502
resources/js/components/ContestCreateForm.tsx
Normal file
502
resources/js/components/ContestCreateForm.tsx
Normal file
@@ -0,0 +1,502 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user