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

1016 lines
44 KiB
TypeScript

import React, { useEffect, useMemo, useState } from "react";
import axios from "axios";
import { Button, Card, CardBody, CardHeader } from "@heroui/react";
import FileDropZone from "@/components/FileDropZone";
import HeaderFormFields from "@/components/RoundFileUpload/HeaderFormFields";
import UploadMessages from "@/components/RoundFileUpload/UploadMessages";
import { useUserStore } from "@/stores/userStore";
import { useContestStore } from "@/stores/contestStore";
import { useTranslation } from "react-i18next";
import {
EMAIL_REGEX,
PWWLO_REGEX,
computeQsoCountWarning,
extractHeaderMap,
hasEdiHeader,
hasPowerKeyword,
normalizeSpowe,
parsePSect,
updateContentWithHeaders,
validateCallsign,
validateCallsignList,
isMultiOp,
isSingleOp,
buildIaruPsect,
} from "@/utils/ediValidation";
import { validateEdi } from "@/utils/ediFileValidation";
import { type EdiHeaderForm } from "@/types/edi";
import { useRoundMeta } from "@/hooks/useRoundMeta";
type RoundFileUploadProps = {
roundId: number | null;
startTime?: string | null;
logsDeadline?: string | null;
onUploaded?: () => void;
};
// Lokátor, email a callsign regexy pro konzistentní validaci všech vstupů
const isWithinWindow = (_start?: string | null, deadline?: string | null) => {
if (!deadline) return false;
const endDate = new Date(deadline);
if (Number.isNaN(endDate.getTime())) return false;
const now = new Date();
return now <= endDate;
};
const FILE_NAME_RULES = [
{ code: "01", operator: "SO", time: null, band: "145mhz" },
{ code: "02", operator: "MO", time: null, band: "145mhz" },
{ code: "03", operator: "SO", time: null, band: "435mhz" },
{ code: "04", operator: "MO", time: null, band: "435mhz" },
{ code: "05", operator: "SO", time: null, band: "1.3ghz" },
{ code: "06", operator: "MO", time: null, band: "1.3ghz" },
{ code: "07", operator: "SO", time: null, band: "2.3ghz" },
{ code: "08", operator: "MO", time: null, band: "2.3ghz" },
{ code: "09", operator: "SO", time: null, band: "3.4ghz" },
{ code: "10", operator: "MO", time: null, band: "3.4ghz" },
{ code: "11", operator: "SO", time: null, band: "5.7ghz" },
{ code: "12", operator: "MO", time: null, band: "5.7ghz" },
{ code: "13", operator: "SO", time: null, band: "10ghz" },
{ code: "14", operator: "MO", time: null, band: "10ghz" },
{ code: "15", operator: "SO", time: null, band: "24ghz" },
{ code: "16", operator: "MO", time: null, band: "24ghz" },
{ code: "17", operator: "SO", time: null, band: "47ghz" },
{ code: "18", operator: "MO", time: null, band: "47ghz" },
{ code: "19", operator: "SO", time: null, band: "76ghz" },
{ code: "20", operator: "MO", time: null, band: "76ghz" },
{ code: "21", operator: "SO", time: null, band: "120ghz" },
{ code: "22", operator: "MO", time: null, band: "120ghz" },
{ code: "23", operator: "SO", time: null, band: "134ghz" },
{ code: "24", operator: "MO", time: null, band: "134ghz" },
{ code: "25", operator: "SO", time: null, band: "248ghz" },
{ code: "26", operator: "MO", time: null, band: "248ghz" },
{ code: "50", operator: "SO", time: null, band: "50mhz" },
{ code: "51", operator: "MO", time: null, band: "50mhz" },
{ code: "61", operator: "SO", time: "6H", band: "145mhz" },
{ code: "62", operator: "MO", time: "6H", band: "145mhz" },
{ code: "63", operator: "SO", time: "6H", band: "435mhz" },
{ code: "64", operator: "MO", time: "6H", band: "435mhz" },
] as const;
const FILE_NAME_CODE_MAP = new Map(
FILE_NAME_RULES.map((rule) => [
`${rule.time ?? ""}|${rule.operator}|${rule.band}`,
rule.code,
])
);
const normalizeBandKey = (value: string) =>
value.trim().toLowerCase().replace(",", ".").replace(/\s+/g, "");
export default function RoundFileUpload({ roundId, startTime, logsDeadline, onUploaded }: RoundFileUploadProps) {
const user = useUserStore((s) => s.user);
const selectedRound = useContestStore((s) => s.selectedRound);
const { t } = useTranslation("common");
// Stav hlaviček, validací a vedlejších efektů formuláře
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null);
const [headerForm, setHeaderForm] = useState<EdiHeaderForm>({
TName: "",
TDate: "",
PCall: "",
PWWLo: "",
PSect: "",
PBand: "",
RHBBS: "",
SPowe: "",
SAnte: "",
RCall: "",
MOpe1: "",
MOpe2: "",
});
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [uploadedOnce, setUploadedOnce] = useState(false);
const [downloadEdited, setDownloadEdited] = useState(false);
const [normalizedFileName, setNormalizedFileName] = useState<string | null>(null);
const [qsoCountWarning, setQsoCountWarning] = useState<string | null>(null);
const [qsoCallsignInfo, setQsoCallsignInfo] = useState<string | null>(null);
const [pbandInfo, setPbandInfo] = useState<string | null>(null);
const [spoweInfo, setSpoweInfo] = useState<string | null>(null);
const [isEdiFile, setIsEdiFile] = useState(true);
const [unsupportedInfo, setUnsupportedInfo] = useState<string | null>(null);
const { bands, bandsError, bandsLoading, ediBands } = useRoundMeta(roundId, selectedRound);
const bandNames = useMemo(() => new Set(bands.map((b) => b.name)), [bands]);
const bandValue = headerForm.PBand.trim();
const bandUnknown = bands.length > 0 && bandValue !== "" && !bandNames.has(bandValue);
const bandMissing = bandValue === "";
const normalizedPwwlo = headerForm.PWWLo.trim().toUpperCase();
const pwwloEmpty = normalizedPwwlo === "";
const pwwloFormatInvalid = !pwwloEmpty && !PWWLO_REGEX.test(normalizedPwwlo);
const pwwloInvalid = pwwloEmpty || pwwloFormatInvalid;
const rhbbsValue = headerForm.RHBBS.trim();
const rhbbsEmpty = rhbbsValue === "";
const rhbbsFormatInvalid = !rhbbsEmpty && !EMAIL_REGEX.test(rhbbsValue);
const rhbbsInvalid = rhbbsEmpty || rhbbsFormatInvalid;
const rhbbsWarning =
selectedFiles && selectedFiles.length === 1 && rhbbsInvalid
? rhbbsEmpty
? (t("upload_warning_rhbbs_required") as string) || "RHBBS chybí."
: (t("upload_warning_rhbbs_format") as string) || "RHBBS není ve formátu e-mailu."
: null;
const pCallValidation = useMemo(() => validateCallsign(headerForm.PCall), [headerForm.PCall]);
const pCallEmpty = headerForm.PCall.trim() === "";
const pCallInvalid = pCallEmpty || !pCallValidation.isValid;
const rCallValidation = useMemo(() => validateCallsign(headerForm.RCall), [headerForm.RCall]);
const tNameInvalid = headerForm.TName.trim() === "";
const tDateError = (() => {
const value = headerForm.TDate.trim();
if (!value) return "TDate je povinné.";
const parts = value.split(";");
const invalidCount = parts.length !== 2;
const invalidFormat = parts.some((p) => !/^\d{8}$/.test(p));
const inverted =
!invalidCount && !invalidFormat && parseInt(parts[0], 10) > parseInt(parts[1], 10);
if (invalidCount || invalidFormat || inverted) {
return "TDate musí být ve formátu YYYYMMDD;YYYYMMDD a první datum nesmí být větší.";
}
return null;
})();
const spoweParsed = useMemo(() => normalizeSpowe(headerForm.SPowe), [headerForm.SPowe]);
const psectValue = headerForm.PSect.trim();
const allowSixHour = !!selectedRound?.is_sixhr;
const psectValidation = useMemo(
// Formulář validujeme stejnými pravidly jako načtený soubor (STRICT režim)
() => parsePSect(psectValue, { strict: true, allow6h: allowSixHour }),
[psectValue, allowSixHour]
);
const psectMissing = psectValue === "";
const psectHasErrors = !psectMissing && psectValidation.errors.length > 0;
const spoweNumber = useMemo(() => {
if (!spoweParsed.valid) return null;
const parsed = parseFloat(spoweParsed.normalized);
return Number.isFinite(parsed) ? parsed : null;
}, [spoweParsed]);
const bandAllowsPowerCategory = useMemo(() => {
const band = bands.find((b) => b.name === bandValue);
return !!band?.has_power_category;
}, [bands, bandValue]);
const shouldApplyLowPowerCategory =
!psectValidation.isCheckLog && bandAllowsPowerCategory && spoweNumber !== null && spoweNumber <= 100;
const psectTarget = useMemo(
() => ({
...psectValidation,
powerClass: shouldApplyLowPowerCategory ? "LP" : psectValidation.powerClass,
}),
[psectValidation, shouldApplyLowPowerCategory]
);
const psectCanonical = useMemo(() => buildIaruPsect(psectTarget), [psectTarget]);
const psectNeedsFormat =
!psectMissing &&
!!psectCanonical &&
psectValue.trim().toUpperCase().replace(/\s+/g, " ") !== psectCanonical.toUpperCase().replace(/\s+/g, " ");
const shouldShowIaruAdjustButton =
!!psectNeedsFormat && !!selectedFiles && selectedFiles.length === 1 && isEdiFile;
const fileNameStatus = useMemo(() => {
if (!selectedFiles || selectedFiles.length !== 1 || !isEdiFile) return null;
if (psectValidation.isCheckLog) return null;
const pcallRaw = headerForm.PCall.trim();
const baseCall = pcallRaw.split("/")[0]?.replace(/[^A-Za-z0-9]/g, "").toUpperCase() ?? "";
const bandKey = normalizeBandKey(headerForm.PBand);
const operatorKey = psectValidation.operatorClass ?? "";
const timeKey = psectValidation.timeClass ?? "";
if (!baseCall || !bandKey || !operatorKey) {
return {
state: "pending",
message:
(t("upload_filename_missing_fields") as string) ||
"Doplň PCall, PSect a PBand, aby šlo ověřit název souboru.",
};
}
const code = FILE_NAME_CODE_MAP.get(`${timeKey}|${operatorKey}|${bandKey}`);
if (!code) {
return {
state: "invalid",
message:
(t("upload_filename_unknown_code") as string) ||
"Nelze určit prefix názvu souboru pro zvolenou kategorii a pásmo.",
};
}
const expectedName = `${code}${baseCall}.edi`;
const pattern = new RegExp(`^${code}${baseCall}[A-Za-z0-9._-]*\\.edi$`, "i");
const isValid = pattern.test(selectedFiles[0].name);
return {
state: isValid ? "valid" : "invalid",
expectedName,
message: isValid
? (t("upload_filename_valid") as string) || "Název souboru odpovídá pravidlům."
: (t("upload_filename_invalid", { expected: expectedName }) as string) ||
`Název souboru neodpovídá pravidlům. Doporučený tvar: ${expectedName}`,
};
}, [
selectedFiles,
isEdiFile,
headerForm.PCall,
headerForm.PBand,
psectValidation.operatorClass,
psectValidation.timeClass,
psectValidation.isCheckLog,
t,
]);
const isAuth = Boolean(user);
const windowOpen = useMemo(() => isWithinWindow(startTime, logsDeadline), [startTime, logsDeadline]);
const canUpload = isAuth || windowOpen;
const anonymousBlocked = !isAuth && uploadedOnce;
const readFileAsText = (file: File) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : "");
reader.onerror = () => reject(reader.error);
reader.readAsText(file);
});
// Mapuje alternativní zápis PBand (EDI pásma) na název pásma v DB
const resolvePBand = (value: string): { resolved: string; source: string } | null => {
const lower = value.trim().toLowerCase();
if (!lower) return null;
const match = ediBands.find((b) => b.value.toLowerCase() === lower);
if (!match) return null;
const bandName = match.bands?.[0]?.name;
if (!bandName) return null;
if (bandNames.size && !bandNames.has(bandName)) return null;
return { resolved: bandName, source: match.value };
};
const handleFiles = (files: FileList | null) => {
if (!files || files.length === 0) {
setSelectedFiles(null);
} else if (!isAuth) {
const firstFile = new DataTransfer();
firstFile.items.add(files[0]);
setSelectedFiles(firstFile.files);
if (files.length > 1) {
setError((t("upload_hint_anonymous_once") as string) || "Anonymně lze nahrát jen jeden soubor.");
}
} else {
setSelectedFiles(files);
}
setSuccess(null);
if (files && files.length <= 1) {
setError(null);
}
setNormalizedFileName(null);
setQsoCountWarning(null);
setDownloadEdited(false);
setSpoweInfo(null);
setUnsupportedInfo(null);
setIsEdiFile(true);
setQsoCallsignInfo(null);
if (files && files.length === 1) {
const file = files[0];
readFileAsText(file)
.then((text) => {
if (!hasEdiHeader(text)) {
setIsEdiFile(false);
setUnsupportedInfo((t("upload_file_not_supported") as string) || "");
setSelectedFiles(null);
return;
}
const earlyValidationErrors = validateEdi(text, file.name);
const earlyQsoErrors = earlyValidationErrors.filter(
(msg) => msg.includes("QSO #") && msg.toLowerCase().includes("volací znak")
);
if (earlyQsoErrors.length > 0) {
const recommendation =
"Doporučení: neplatné volací znaky v QSO oprav v souboru a zkus ho nahrát znovu.";
setQsoCallsignInfo([...earlyQsoErrors, recommendation].join("\n"));
}
const headers = extractHeaderMap(text);
setQsoCountWarning(computeQsoCountWarning(text, file.name));
const spoweParsedFromFile = normalizeSpowe(headers["SPowe"] ?? "");
const pbandMapping = resolvePBand(headers["PBand"] ?? "");
const mappedPBand = pbandMapping?.resolved ?? headers["PBand"] ?? "";
if (pbandMapping) {
setPbandInfo(
(t("upload_pband_mapped", { original: headers["PBand"], mapped: mappedPBand }) as string) ||
`PBand ${headers["PBand"]} -> ${mappedPBand}`
);
} else if (!mappedPBand) {
setPbandInfo((t("upload_pband_missing") as string) || "PBand není vyplněné v souboru, vyber pásmo ze seznamu.");
} else {
setPbandInfo(null);
}
if (spoweParsedFromFile.changed) {
setSpoweInfo(
(t("upload_spowe_normalized_from", {
from: headers["SPowe"] ?? "",
to: spoweParsedFromFile.normalized,
}) as string) || ""
);
} else {
setSpoweInfo(null);
}
setHeaderForm((prev) => ({
...prev,
TName: headers["TName"] ?? "",
TDate: headers["TDate"] ?? "",
PCall: headers["PCall"] ?? "",
PWWLo: headers["PWWLo"] ?? "",
PSect: headers["PSect"] ?? "",
PBand: mappedPBand,
RHBBS: headers["RHBBS"] ?? "",
SPowe: spoweParsedFromFile.normalized,
SAnte: headers["SAnte"] ?? "",
RCall: headers["RCall"] ?? "",
MOpe1: headers["MOpe1"] ?? headers["Mope1"] ?? "",
MOpe2: headers["MOpe2"] ?? headers["Mope2"] ?? "",
}));
})
.catch(() => {
// leave editing disabled if reading fails
setHeaderForm((prev) => ({
...prev,
TName: "",
TDate: "",
PCall: "",
PWWLo: "",
PSect: "",
PBand: "",
RHBBS: "",
SPowe: "",
SAnte: "",
RCall: "",
MOpe1: "",
MOpe2: "",
}));
});
} else {
setQsoCountWarning(null);
setQsoCallsignInfo(null);
setHeaderForm((prev) => ({
...prev,
TName: "",
TDate: "",
PCall: "",
PWWLo: "",
PSect: "",
PBand: "",
RHBBS: "",
SPowe: "",
SAnte: "",
RCall: "",
MOpe1: "",
MOpe2: "",
}));
setPbandInfo(null);
}
};
const markEdited = () => {
setDownloadEdited(true);
};
const handlePbandChange = (value: string) => {
setHeaderForm((prev) => ({ ...prev, PBand: value }));
markEdited();
setPbandInfo(null);
};
const handleSpoweChange = (value: string) => {
const parsed = normalizeSpowe(value);
setHeaderForm((prev) => ({ ...prev, SPowe: parsed.normalized }));
if (parsed.changed) {
setSpoweInfo(
(t("upload_spowe_normalized_to", { value: parsed.normalized }) as string) ||
`SPowe bylo normalizováno na "${parsed.normalized}".`
);
} else {
setSpoweInfo(null);
}
markEdited();
};
const handleApplyPsectCanonical = () => {
if (!psectCanonical) {
setError(
(t("upload_error_psect_normalize") as string) || "PSect nelze normalizovat, oprav chyby a zkus to znovu."
);
return;
}
setHeaderForm((prev) => ({ ...prev, PSect: psectCanonical }));
setError(null);
};
// Reset formuláře při změně kola (např. v RoundDetail)
useEffect(() => {
setSelectedFiles(null);
setSuccess(null);
setError(null);
setNormalizedFileName(null);
setQsoCountWarning(null);
setPbandInfo(null);
setSpoweInfo(null);
setUnsupportedInfo(null);
setIsEdiFile(true);
setQsoCallsignInfo(null);
setHeaderForm({
TName: "",
TDate: "",
PCall: "",
PWWLo: "",
PSect: "",
PBand: "",
RHBBS: "",
SPowe: "",
SAnte: "",
RCall: "",
MOpe1: "",
MOpe2: "",
});
}, [roundId]);
useEffect(() => {
if (normalizedFileName) {
setNormalizedFileName(null);
}
}, [headerForm.PCall, headerForm.PBand, headerForm.PSect, selectedFiles, normalizedFileName]);
const uploadFiles = async () => {
if (!roundId) {
setError((t("upload_error_missing_round") as string) || "Chybí ID kola.");
return;
}
if (!selectedFiles || selectedFiles.length === 0) {
setError((t("upload_error_pick_file") as string) || "Vyber soubor.");
return;
}
const isSingleUpload = selectedFiles.length === 1;
if (bands.length > 0 && bandMissing) {
if (isSingleUpload) {
setError((t("upload_error_pick_band") as string) || "Vyber pásmo (PBand) ze seznamu.");
return;
}
}
if (bands.length > 0 && bandUnknown) {
if (isSingleUpload) {
setError(
(t("upload_error_band_unknown", { band: bandValue }) as string) ||
`PBand "${bandValue}" neodpovídá známým pásmům, vyber správnou hodnotu ze seznamu.`
);
return;
}
}
if (pwwloInvalid) {
if (isSingleUpload) {
setError(
pwwloEmpty
? (t("upload_error_pwwlo_required") as string) || "PWWLo je povinné."
: (t("upload_error_pwwlo_format") as string) || "PWWLo musí mít 6 znaků ve formátu lokátoru (AA00AA)."
);
return;
}
}
if (rhbbsInvalid && isSingleUpload) {
setError(null);
}
if (tNameInvalid) {
if (isSingleUpload) {
setError((t("upload_error_tname_required") as string) || "TName je povinné.");
return;
}
}
if (pCallInvalid) {
if (isSingleUpload) {
setError(
pCallEmpty
? (t("upload_error_pcall_required") as string) || "PCall je povinné."
: (t("upload_error_pcall_format") as string) || "PCall musí být validní volací znak."
);
return;
}
}
if (tDateError) {
if (isSingleUpload) {
setError(tDateError);
return;
}
}
if (psectMissing || psectHasErrors) {
if (isSingleUpload) {
setError(
psectMissing
? (t("upload_error_psect_required") as string) || "PSect je povinné."
: psectHasErrors
? psectValidation.errors.join(" ") || (t("upload_error_psect_invalid") as string) || "PSect není validní."
: undefined
);
return;
}
}
if (spoweInvalid) {
if (isSingleUpload) {
setError(
spoweEmpty
? (t("upload_error_spowe_required") as string) || "SPowe je povinné."
: spoweTooLong
? (t("upload_error_spowe_length") as string) || "SPowe může mít maximálně 12 znaků."
: (t("upload_error_spowe_format") as string) || "SPowe musí být celé číslo (bez jednotek)."
);
return;
}
}
if (!spoweInvalid && spoweOverLimit) {
if (isSingleUpload) {
setError(
(t("upload_error_spowe_limit", {
value: spoweParsed.normalized,
category: psectValidation.powerClass ?? "-",
limit: powerLimit ?? "-",
}) as string) || ""
);
return;
}
}
if (santeInvalid) {
if (isSingleUpload) {
setError((t("upload_error_sante_required") as string) || "SAnte je povinné.");
return;
}
}
if (rcallInvalid) {
if (isSingleUpload) {
setError(
headerForm.RCall.trim() === ""
? (t("upload_error_rcall_required") as string) || "RCall je povinné pro zvolenou kategorii."
: (t("upload_error_rcall_format") as string) || "RCall musí být validní volací znak."
);
return;
}
}
if (mopeInvalid) {
if (isSingleUpload) {
const invalidTokens =
mopeValidation.invalidTokens.length > 0
? ` Neplatné značky: ${mopeValidation.invalidTokens.join(", ")}.`
: "";
const mopeMsg =
(t("upload_error_mope_missing") as string) ||
"Vyplň alespoň MOpe1 nebo MOpe2 (můžeš zadat více značek oddělených mezerou/středníkem/čárkou).";
setError(`${mopeMsg}${invalidTokens}`);
return;
}
}
if (!canUpload || anonymousBlocked) {
setError((t("upload_error_not_allowed") as string) || "Nahrávání není povoleno.");
return;
}
setUploading(true);
setError(null);
setSuccess(null);
try {
await axios.get("/sanctum/csrf-cookie", { withCredentials: true });
const filesArray = Array.from(selectedFiles);
const results: string[] = [];
const errors: string[] = [];
const qsoCallsignWarnings: string[] = [];
for (const file of filesArray) {
const outputName =
filesArray.length === 1 && normalizedFileName ? normalizedFileName : file.name;
let preparedFile = file;
let editedContentForDownload: string | null = null;
// klientská validace EDI + případná úprava hlavičky u jednoho souboru
try {
let text = await readFileAsText(file);
const initialHeaders = extractHeaderMap(text);
const spoweParsedInitial = normalizeSpowe(initialHeaders["SPowe"] ?? "");
const pbandMapping = resolvePBand(initialHeaders["PBand"] ?? "");
if (pbandMapping) {
text = updateContentWithHeaders(text, { PBand: pbandMapping.resolved });
if (filesArray.length === 1) {
setPbandInfo(
(t("upload_pband_mapped", {
original: initialHeaders["PBand"],
mapped: pbandMapping.resolved,
}) as string) || ""
);
}
}
if (spoweParsedInitial.changed) {
text = updateContentWithHeaders(text, { SPowe: spoweParsedInitial.normalized });
if (filesArray.length === 1) {
setSpoweInfo(
`SPowe bylo normalizováno z "${initialHeaders["SPowe"] ?? ""}" na "${spoweParsedInitial.normalized}".`
);
}
}
if (filesArray.length === 1) {
const updatedContent = updateContentWithHeaders(text, headerForm);
text = updatedContent;
preparedFile = new File([updatedContent], outputName, { type: file.type || "text/plain" });
setQsoCountWarning(computeQsoCountWarning(updatedContent, file.name));
editedContentForDownload = updatedContent;
} else if (outputName !== file.name) {
preparedFile = new File([file], outputName, { type: file.type || "text/plain" });
}
const headers = extractHeaderMap(text);
const psect = (headers["PSect"] ?? "").trim();
const pband = (headers["PBand"] ?? "").trim();
const pcallValidation = validateCallsign(headers["PCall"] ?? "");
if (!pcallValidation.isValid) {
errors.push(
`${file.name}: ${(t("upload_error_pcall_format") as string) || "PCall musí být validní volací znak."}`
);
continue;
}
if (psect && (sectionNeedsSingle || sectionNeedsMulti)) {
const rcallValidation = validateCallsign(headers["RCall"] ?? "");
if (!rcallValidation.isValid) {
errors.push(
`${file.name}: ${(t("upload_error_rcall_format") as string) || "RCall musí být validní volací znak."}`
);
continue;
}
}
if (sectionNeedsMulti) {
const mope1Val = validateCallsignList(headers["MOpe1"] ?? "");
const mope2Val = validateCallsignList(headers["MOpe2"] ?? "");
const invalidTokens = [...mope1Val.invalidTokens, ...mope2Val.invalidTokens];
if (!mope1Val.hasValid && !mope2Val.hasValid) {
errors.push(
`${file.name}: ${(t("upload_error_mope_missing") as string) ||
"Vyplň alespoň MOpe1 nebo MOpe2 (můžeš zadat více značek oddělených mezerou/středníkem/čárkou)."
}`
);
continue;
}
if (invalidTokens.length > 0) {
errors.push(
`${file.name}: ${(t("upload_error_mope_invalid", { invalid: invalidTokens.join(", ") }) as string) ||
`Neplatné značky v MOpe1/MOpe2 (${invalidTokens.join(", ")}).`}`
);
continue;
}
}
const psectResult = parsePSect(psect, { strict: true, allow6h: allowSixHour });
if (psectResult.errors.length > 0) {
errors.push(`${file.name}: ${psectResult.errors.join(" ")}`);
continue;
}
if (psect && hasPowerKeyword(psect)) {
const bandMatch = bands.find((b) => b.name === pband);
const allowsPower = !!bandMatch?.has_power_category;
if (!allowsPower) {
errors.push(
`${file.name}: ${(t("upload_error_power_band_mismatch", {
psect,
band: pband || "?",
}) as string) || ""}`
);
continue;
}
}
const validationErrors = validateEdi(text, file.name);
const qsoCallsignErrors = validationErrors.filter(
(msg) => msg.includes("QSO #") && msg.toLowerCase().includes("volací znak")
);
const otherErrors = validationErrors.filter(
(msg) =>
!qsoCallsignErrors.includes(msg) &&
!msg.includes("Záznamy QSO nelze upravit")
);
if (qsoCallsignErrors.length > 0) {
qsoCallsignWarnings.push(
...qsoCallsignErrors,
"Doporučení: neplatné volací znaky v QSO oprav v souboru a zkus ho nahrát znovu."
);
}
if (otherErrors.length > 0) {
errors.push(...otherErrors);
continue;
}
} catch (readErr) {
errors.push(
`${file.name}: ${(t("upload_error_file_read") as string) || "Soubor se nepodařilo přečíst."}`
);
continue;
}
try {
const formData = new FormData();
formData.append("file", preparedFile);
formData.append("round_id", String(roundId));
const res = await axios.post("/api/files", formData, {
headers: { "Content-Type": "multipart/form-data", Accept: "application/json" },
withCredentials: true,
withXSRFToken: true,
});
const stored = res.data;
results.push(stored?.filename ?? outputName);
if (filesArray.length === 1 && downloadEdited && editedContentForDownload) {
const blob = new Blob([editedContentForDownload], { type: preparedFile.type || "text/plain" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = outputName;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
} catch (apiErr: any) {
const msg =
apiErr?.response?.data?.message ||
apiErr?.response?.data?.errors?.file?.[0] ||
`${file.name}: ${(t("upload_failed") as string) || "Nahrávání se nezdařilo."}`;
errors.push(msg);
}
}
if (errors.length > 0) {
setError(errors.join("\n"));
}
if (qsoCallsignWarnings.length > 0) {
setQsoCallsignInfo(qsoCallsignWarnings.join("\n"));
}
if (results.length > 0) {
setSuccess(
(t("files_uploaded", { files: results.join(", ") }) as string) ||
`Nahráno: ${results.join(", ")}`
);
setSelectedFiles(null);
if (!isAuth) {
setUploadedOnce(true);
}
onUploaded?.();
}
} catch (e: any) {
const message =
e?.response?.data?.message ||
e?.response?.data?.errors?.file?.[0] ||
(t("upload_failed") as string) ||
"Nahrávání se nezdařilo.";
setError(message);
} finally {
setUploading(false);
}
};
const helperText = (() => {
if (isAuth) {
return "";
}
if (!windowOpen) {
return (
(t("upload_hint_closed") as string) ||
"Nahrávání je dostupné během závodu do uzávěrky logů, nebo se přihlas."
);
}
if (uploadedOnce) {
return (t("upload_hint_anonymous_once") as string) || "Anonymně lze nahrát jen jeden soubor.";
}
return (
(t("upload_hint_anonymous") as string) ||
"Anonymně lze nahrát jeden soubor během závodu do uzávěrky logů."
);
})();
// Odvozené validace podle vstupů a zvolené kategorie
const sectionNeedsSingle = isSingleOp(headerForm.PSect || "");
const sectionNeedsMulti = isMultiOp(headerForm.PSect || "");
const spoweRaw = headerForm.SPowe;
const spoweEmpty = spoweParsed.normalized === "";
const spoweTooLong = spoweRaw.trim().length > 12;
const spoweInvalid = !spoweParsed.valid || spoweTooLong;
const santeValue = headerForm.SAnte.trim();
const santeTooLong = santeValue.length > 12;
const santeInvalid = santeValue === "";
const rcallInvalid =
(sectionNeedsSingle || sectionNeedsMulti) &&
(headerForm.RCall.trim() === "" || !rCallValidation.isValid);
const mopeValidation = useMemo(() => {
const first = validateCallsignList(headerForm.MOpe1);
const second = validateCallsignList(headerForm.MOpe2);
const invalidTokens = [...first.invalidTokens, ...second.invalidTokens];
const hasAnyValid = first.hasValid || second.hasValid;
const hasAny = first.tokens.length > 0 || second.tokens.length > 0;
return { invalidTokens, hasAnyValid, hasAny };
}, [headerForm.MOpe1, headerForm.MOpe2]);
const mopeMissing = sectionNeedsMulti && !mopeValidation.hasAny;
const mopeInvalidTokens =
sectionNeedsMulti && mopeValidation.invalidTokens.length > 0;
const mopeInvalid =
sectionNeedsMulti &&
(!mopeValidation.hasAnyValid || mopeValidation.invalidTokens.length > 0);
const powerLimit = useMemo(() => {
switch (psectValidation.powerClass) {
case "QRP":
return 5;
case "N":
return 10;
case "LP":
return 100;
default:
return null; // A/HP nebo chybějící
}
}, [psectValidation.powerClass]);
const spoweOverLimit =
powerLimit !== null &&
spoweParsed.valid &&
spoweParsed.normalized !== "" &&
parseFloat(spoweParsed.normalized) > powerLimit;
const spoweLimitError =
powerLimit !== null && spoweOverLimit
? ((t("upload_error_spowe_limit", {
value: spoweParsed.normalized,
category: psectValidation.powerClass ?? "-",
limit: powerLimit ?? "-",
}) as string) || "")
: null;
if (!canUpload) return null;
return (
<Card>
<CardHeader>
<div className="flex flex-col">
<span className="text-lg font-semibold">
{t("upload_log_title") ?? "Nahrát log"}
</span>
<span className="text-sm text-foreground-500">{helperText}</span>
</div>
</CardHeader>
<CardBody className="space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<FileDropZone
multiple={isAuth}
selectedFiles={selectedFiles}
label={(t("select_file") as string) ?? "Vyber soubor"}
hint={
isAuth
? (t("upload_hint_bulk_auth") as string) ??
"Hromadný upload je dostupný pouze pro přihlášené."
: (t("upload_file_drop_hint") as string) ??
"Přetáhni EDI soubor sem nebo klikni pro výběr."
}
onFiles={handleFiles}
/>
</div>
{selectedFiles && selectedFiles.length > 1 && (
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-foreground-500">
<span>Hromadný upload: hlavičku nelze upravovat, soubory se odešlou tak jak jsou.</span>
<Button
color="primary"
onPress={uploadFiles}
isDisabled={!canUpload || anonymousBlocked || uploading}
isLoading={uploading}
className="shrink-0"
>
{t("upload") ?? "Nahrát"}
</Button>
</div>
)}
{selectedFiles && selectedFiles.length === 1 && isEdiFile && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{fileNameStatus && (
<div className="md:col-span-2 rounded-md border border-foreground-200 bg-foreground-50/40 p-3 text-sm">
<div className="flex items-start justify-between gap-3">
<p
className={
fileNameStatus.state === "invalid"
? "text-danger-500"
: "text-foreground-700"
}
>
{fileNameStatus.message}
</p>
{fileNameStatus.state === "invalid" && fileNameStatus.expectedName && (
<Button
size="sm"
variant="flat"
onPress={() => setNormalizedFileName(fileNameStatus.expectedName ?? null)}
>
{t("upload_filename_normalize") ?? "Normalizovat název"}
</Button>
)}
</div>
{normalizedFileName && (
<p className="mt-2 text-xs text-foreground-500">
{(t("upload_filename_used", { name: normalizedFileName }) as string) ||
`Při uploadu se použije: ${normalizedFileName}`}
</p>
)}
</div>
)}
<HeaderFormFields
headerForm={headerForm}
setHeaderForm={setHeaderForm}
markEdited={markEdited}
onPbandChange={handlePbandChange}
onSpoweChange={handleSpoweChange}
onApplyPsectCanonical={handleApplyPsectCanonical}
t={t}
tNameInvalid={tNameInvalid}
tDateError={tDateError}
pCallEmpty={pCallEmpty}
pCallInvalid={pCallInvalid}
pwwloEmpty={pwwloEmpty}
pwwloFormatInvalid={pwwloFormatInvalid}
pwwloInvalid={pwwloInvalid}
psectMissing={psectMissing}
psectHasErrors={psectHasErrors}
psectValidation={psectValidation}
psectNeedsFormat={psectNeedsFormat}
psectCanonical={psectCanonical}
shouldShowIaruAdjustButton={shouldShowIaruAdjustButton}
bands={bands}
bandsLoading={bandsLoading}
bandsError={bandsError}
bandUnknown={bandUnknown}
bandMissing={bandMissing}
bandValue={bandValue}
pbandInfo={pbandInfo}
rhbbsWarning={rhbbsWarning}
spoweInvalid={spoweInvalid}
spoweEmpty={spoweEmpty}
spoweTooLong={spoweTooLong}
spoweOverLimit={spoweOverLimit}
spoweLimitError={spoweLimitError}
spoweInfo={spoweInfo}
santeInvalid={santeInvalid}
santeValue={santeValue}
santeTooLong={santeTooLong}
sectionNeedsSingle={sectionNeedsSingle}
sectionNeedsMulti={sectionNeedsMulti}
rcallInvalid={rcallInvalid}
mopeInvalid={mopeInvalid}
/>
<div className="md:col-span-2 flex items-center justify-end gap-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={downloadEdited}
onChange={(e) => setDownloadEdited(e.target.checked)}
disabled={uploading}
/>
<span>Chci stáhnout upravený soubor</span>
</label>
<Button
color="primary"
onPress={uploadFiles}
isDisabled={!canUpload || anonymousBlocked || uploading}
isLoading={uploading}
className="shrink-0"
>
{t("upload") ?? "Nahrát"}
</Button>
</div>
</div>
)}
<UploadMessages
isEdiFile={isEdiFile}
unsupportedInfo={unsupportedInfo}
qsoCountWarning={qsoCountWarning}
qsoCallsignInfo={qsoCallsignInfo}
rhbbsWarning={rhbbsWarning}
error={error}
success={success}
/>
</CardBody>
</Card>
);
}