1016 lines
44 KiB
TypeScript
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>
|
|
);
|
|
}
|