1262 lines
42 KiB
TypeScript
1262 lines
42 KiB
TypeScript
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
|
import axios from "axios";
|
|
import {
|
|
Button,
|
|
Checkbox,
|
|
Input,
|
|
Modal,
|
|
ModalBody,
|
|
ModalContent,
|
|
ModalHeader,
|
|
Select,
|
|
SelectItem,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableColumn,
|
|
TableHeader,
|
|
TableRow,
|
|
Textarea,
|
|
Tooltip,
|
|
type Selection,
|
|
useDisclosure,
|
|
} from "@heroui/react";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
type LogQso = {
|
|
id: number;
|
|
log_id: number;
|
|
time_on?: string | null;
|
|
band?: string | null;
|
|
mode?: string | null;
|
|
my_call?: string | null;
|
|
dx_call?: string | null;
|
|
my_locator?: string | null;
|
|
my_rst?: string | null;
|
|
dx_rst?: string | null;
|
|
my_serial?: string | null;
|
|
dx_serial?: string | null;
|
|
rx_wwl?: string | null;
|
|
log?: {
|
|
id: number;
|
|
round_id?: number | null;
|
|
} | null;
|
|
};
|
|
|
|
type WorkingQso = {
|
|
loc_norm?: string | null;
|
|
rloc_norm?: string | null;
|
|
errors?: string[] | null;
|
|
};
|
|
|
|
type QsoResult = {
|
|
id: number;
|
|
log_qso_id: number;
|
|
matched_qso_id?: number | null;
|
|
error_code?: string | null;
|
|
error_side?: string | null;
|
|
match_confidence?: string | null;
|
|
error_detail?: string | null;
|
|
is_nil?: boolean;
|
|
is_duplicate?: boolean;
|
|
is_busted_call?: boolean;
|
|
is_busted_exchange?: boolean;
|
|
is_time_out_of_window?: boolean;
|
|
log_qso?: LogQso | null;
|
|
matched_qso?: LogQso | null;
|
|
working_qso?: WorkingQso | null;
|
|
};
|
|
|
|
type QsoOverride = {
|
|
id: number;
|
|
evaluation_run_id: number;
|
|
log_qso_id: number;
|
|
forced_matched_log_qso_id?: number | null;
|
|
forced_status?: string | null;
|
|
reason?: string | null;
|
|
};
|
|
|
|
type LogEntry = {
|
|
id: number;
|
|
pcall?: string | null;
|
|
psect?: string | null;
|
|
pband?: string | null;
|
|
power_category?: string | null;
|
|
sixhr_category?: boolean | null;
|
|
pwwlo?: string | null;
|
|
locator?: string | null;
|
|
};
|
|
|
|
type PaginatedResponse<T> = {
|
|
data: T[];
|
|
current_page: number;
|
|
last_page: number;
|
|
total: number;
|
|
};
|
|
|
|
type Props = {
|
|
roundId: number | null;
|
|
evaluationRunId: number;
|
|
};
|
|
|
|
type OverrideForm = {
|
|
status: string;
|
|
matchedId: string;
|
|
reason: string;
|
|
saving: boolean;
|
|
error?: string | null;
|
|
success?: string | null;
|
|
};
|
|
|
|
const statusOptions = [
|
|
{ value: "AUTO", label: "AUTO" },
|
|
{ value: "VALID", label: "VALID" },
|
|
{ value: "INVALID", label: "INVALID" },
|
|
{ value: "NIL", label: "NIL" },
|
|
{ value: "DUPLICATE", label: "DUPLICATE" },
|
|
{ value: "BUSTED_CALL", label: "BUSTED_CALL" },
|
|
{ value: "BUSTED_EXCHANGE", label: "BUSTED_EXCHANGE" },
|
|
{ value: "OUT_OF_WINDOW", label: "OUT_OF_WINDOW" },
|
|
];
|
|
|
|
const issueOptions = [
|
|
{ value: "PROBLEMS", label: "Všechny problémy" },
|
|
{ value: "OK_ONLY", label: "Pouze v pořádku" },
|
|
{ value: "ALL", label: "Všechny QSO" },
|
|
{ value: "NOT_IN_COUNTERPART_LOG", label: "NOT_IN_COUNTERPART_LOG" },
|
|
{ value: "NO_COUNTERPART_LOG", label: "NO_COUNTERPART_LOG" },
|
|
{ value: "UNIQUE", label: "UNIQUE" },
|
|
{ value: "DUP", label: "DUP" },
|
|
{ value: "BUSTED_CALL", label: "BUSTED_CALL" },
|
|
{ value: "BUSTED_RST", label: "BUSTED_RST" },
|
|
{ value: "BUSTED_SERIAL", label: "BUSTED_SERIAL" },
|
|
{ value: "BUSTED_LOCATOR", label: "BUSTED_LOCATOR" },
|
|
{ value: "TIME_MISMATCH", label: "TIME_MISMATCH" },
|
|
{ value: "OUT_OF_WINDOW", label: "OUT_OF_WINDOW" },
|
|
{ value: "MISSING_LOCATOR", label: "Chybí lokátor" },
|
|
];
|
|
|
|
const getFirstSelection = (keys: Selection) => {
|
|
if (keys === "all") return "";
|
|
const [first] = Array.from(keys);
|
|
return typeof first === "string" || typeof first === "number" ? String(first) : "";
|
|
};
|
|
|
|
type OverridePayload = {
|
|
status: string;
|
|
matchedId: string;
|
|
reason: string;
|
|
};
|
|
|
|
type QsoOverrideRowProps = {
|
|
item: QsoResult;
|
|
form?: OverrideForm;
|
|
override?: QsoOverride;
|
|
selected: boolean;
|
|
onToggleSelected: (logQsoId: number, checked: boolean) => void;
|
|
onOpenMatcher: (item: QsoResult) => void;
|
|
onFieldChange: (logQsoId: number, field: keyof OverrideForm, value: string) => void;
|
|
onReasonCommit: (logQsoId: number, reason: string) => void;
|
|
onSave: (logQsoId: number, payload: OverridePayload) => void;
|
|
problemLabel: (item: QsoResult) => string;
|
|
errorDetailLabel: (item: QsoResult) => string | null;
|
|
problemTooltip: (item: QsoResult) => string[] | null;
|
|
t: (key: string) => string;
|
|
};
|
|
|
|
const QsoOverrideRow = memo(function QsoOverrideRow({
|
|
item,
|
|
form,
|
|
override,
|
|
selected,
|
|
onToggleSelected,
|
|
onOpenMatcher,
|
|
onFieldChange,
|
|
onReasonCommit,
|
|
onSave,
|
|
problemLabel,
|
|
errorDetailLabel,
|
|
problemTooltip,
|
|
t,
|
|
}: QsoOverrideRowProps) {
|
|
const logQso = item.log_qso;
|
|
const matched = item.matched_qso;
|
|
const issue = problemLabel(item);
|
|
const errorDetail = errorDetailLabel(item);
|
|
const tooltipContent = problemTooltip(item);
|
|
const highlightStatus = !!override?.forced_status && override.forced_status !== "AUTO";
|
|
const highlightMatched =
|
|
override?.forced_matched_log_qso_id !== null && override?.forced_matched_log_qso_id !== undefined;
|
|
|
|
const statusValue = form?.status ?? "AUTO";
|
|
const matchedId = form?.matchedId ?? "";
|
|
const [localReason, setLocalReason] = useState(form?.reason ?? override?.reason ?? "");
|
|
|
|
useEffect(() => {
|
|
setLocalReason(form?.reason ?? override?.reason ?? "");
|
|
}, [form?.reason, override?.reason]);
|
|
|
|
return (
|
|
<div className="rounded border border-divider px-2 py-2 text-xs">
|
|
<div className="flex flex-col gap-2 md:flex-row md:items-start md:gap-3">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex flex-wrap items-center gap-2 mb-1">
|
|
<Checkbox
|
|
size="sm"
|
|
isSelected={selected}
|
|
onValueChange={(checked) => onToggleSelected(item.log_qso_id, checked)}
|
|
>
|
|
Vybrat
|
|
</Checkbox>
|
|
<span className="font-semibold">{logQso?.my_call ?? "—"}</span>
|
|
<span>→ {logQso?.dx_call ?? "—"}</span>
|
|
<span>{logQso?.band ?? "—"}</span>
|
|
<span>{logQso?.time_on ?? "—"}</span>
|
|
{tooltipContent ? (
|
|
<Tooltip
|
|
content={
|
|
<div className="text-xs leading-snug">
|
|
{tooltipContent.map((line, index) => (
|
|
<div key={index}>{line}</div>
|
|
))}
|
|
</div>
|
|
}
|
|
>
|
|
<span className="text-foreground-500 cursor-help">
|
|
{t("qso_problem_label") ?? "Problém"}: {issue}
|
|
</span>
|
|
</Tooltip>
|
|
) : (
|
|
<span className="text-foreground-500">
|
|
{t("qso_problem_label") ?? "Problém"}: {issue}
|
|
</span>
|
|
)}
|
|
<span className="text-foreground-500">
|
|
{t("qso_problem_side") ?? "Strana"}: {item.error_side ?? "—"}
|
|
</span>
|
|
<span className="text-foreground-500">
|
|
{t("qso_problem_confidence") ?? "Match"}: {item.match_confidence ?? "—"}
|
|
</span>
|
|
</div>
|
|
{errorDetail && (
|
|
<div className="text-foreground-500 mb-2">
|
|
<span className="font-semibold">{t("qso_problem_reason") ?? "Důvod"}:</span>{" "}
|
|
{errorDetail}
|
|
</div>
|
|
)}
|
|
<div className="text-foreground-500 mb-1">
|
|
Matched: {item.matched_qso_id ?? "—"}{" "}
|
|
{matched ? `(${matched.my_call} → ${matched.dx_call})` : ""}
|
|
</div>
|
|
<div className="mb-1">
|
|
<div className="grid grid-cols-8 gap-2 text-[11px] text-foreground-500">
|
|
<span>Stanice</span>
|
|
<span>Odesl.</span>
|
|
<span>Přijat.</span>
|
|
<span>RST odesl.</span>
|
|
<span>RST přijat.</span>
|
|
<span>Loc DE</span>
|
|
<span>Loc DX</span>
|
|
<span>ID</span>
|
|
</div>
|
|
<div className="grid grid-cols-8 gap-2 text-[11px] text-foreground-600">
|
|
<span>{logQso?.my_call ?? "—"}</span>
|
|
<span>{logQso?.my_serial ?? "—"}</span>
|
|
<span>{logQso?.dx_serial ?? "—"}</span>
|
|
<span>{logQso?.my_rst ?? "—"}</span>
|
|
<span>{logQso?.dx_rst ?? "—"}</span>
|
|
<span>{item.working_qso?.loc_norm ?? "—"}</span>
|
|
<span>{item.working_qso?.rloc_norm ?? "—"}</span>
|
|
<span>{logQso?.id ?? "—"}</span>
|
|
</div>
|
|
<div className="grid grid-cols-8 gap-2 text-[11px] text-foreground-600">
|
|
<span>{matched?.my_call ?? "—"}</span>
|
|
<span>{matched?.my_serial ?? "—"}</span>
|
|
<span>{matched?.dx_serial ?? "—"}</span>
|
|
<span>{matched?.my_rst ?? "—"}</span>
|
|
<span>{matched?.dx_rst ?? "—"}</span>
|
|
<span>{matched?.my_locator ?? "—"}</span>
|
|
<span>{matched?.rx_wwl ?? "—"}</span>
|
|
<span>{matched?.id ?? "—"}</span>
|
|
</div>
|
|
{item.working_qso?.errors && item.working_qso.errors.length > 0 && (
|
|
<div className="mt-1 text-[11px] text-foreground-500">
|
|
Errors: {item.working_qso.errors.join(", ")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{override?.reason && (
|
|
<div className="mb-1 text-foreground-500">Důvod: {override.reason}</div>
|
|
)}
|
|
</div>
|
|
<div className="w-full md:w-auto">
|
|
<div className="flex flex-col gap-3 md:flex-row">
|
|
<div className="w-full md:w-56 lg:w-60">
|
|
<div className="grid gap-1">
|
|
<Select
|
|
aria-label="Forced status"
|
|
size="sm"
|
|
variant="bordered"
|
|
label="Forced status"
|
|
classNames={
|
|
highlightStatus ? { trigger: "border-warning-400 bg-warning-50" } : undefined
|
|
}
|
|
selectedKeys={new Set([statusValue])}
|
|
onSelectionChange={(keys) =>
|
|
onFieldChange(item.log_qso_id, "status", getFirstSelection(keys) || "AUTO")
|
|
}
|
|
selectionMode="single"
|
|
disallowEmptySelection={true}
|
|
>
|
|
{statusOptions.map((opt) => (
|
|
<SelectItem key={opt.value} textValue={opt.label}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</Select>
|
|
<div className="flex flex-col gap-1">
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="bordered"
|
|
className={highlightMatched ? "border-warning-400 bg-warning-50" : undefined}
|
|
onPress={() => onOpenMatcher(item)}
|
|
>
|
|
Ručně vybrat
|
|
</Button>
|
|
<span className="text-foreground-500 text-xs">
|
|
{matchedId ? `Vybráno: ${matchedId}` : "AUTO"}
|
|
</span>
|
|
{matchedId && (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="light"
|
|
onPress={() => onFieldChange(item.log_qso_id, "matchedId", "")}
|
|
>
|
|
Zrušit
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="w-full md:w-64 lg:w-72">
|
|
<Textarea
|
|
label="Důvod změny"
|
|
size="sm"
|
|
variant="bordered"
|
|
minRows={2}
|
|
value={localReason}
|
|
onChange={(e) => setLocalReason(e.target.value)}
|
|
onBlur={() => onReasonCommit(item.log_qso_id, localReason)}
|
|
placeholder="Krátce popiš, proč zasahuješ."
|
|
/>
|
|
<div className="mt-1 flex items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
color="primary"
|
|
onPress={() =>
|
|
onSave(item.log_qso_id, {
|
|
status: statusValue,
|
|
matchedId,
|
|
reason: localReason,
|
|
})
|
|
}
|
|
isDisabled={form?.saving}
|
|
>
|
|
{form?.saving ? "Ukládám…" : "Uložit"}
|
|
</Button>
|
|
{form?.error && <span className="text-red-600">{form.error}</span>}
|
|
{form?.success && <span className="text-green-600">{form.success}</span>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export default function RoundEvaluationQsoOverrides({ roundId, evaluationRunId }: Props) {
|
|
const { t } = useTranslation("common");
|
|
const { isOpen, onOpen, onOpenChange, onClose } = useDisclosure();
|
|
const [items, setItems] = useState<QsoResult[]>([]);
|
|
const [overrides, setOverrides] = useState<Record<number, QsoOverride>>({});
|
|
const [forms, setForms] = useState<Record<number, OverrideForm>>({});
|
|
const [loading, setLoading] = useState(false);
|
|
const [issueFilter, setIssueFilter] = useState("PROBLEMS");
|
|
const [searchCallInput, setSearchCallInput] = useState("");
|
|
const [searchMatchedInput, setSearchMatchedInput] = useState("");
|
|
const [searchCall, setSearchCall] = useState("");
|
|
const [searchMatchedId, setSearchMatchedId] = useState("");
|
|
const [selectedLogId, setSelectedLogId] = useState("");
|
|
const [logOptions, setLogOptions] = useState<LogEntry[]>([]);
|
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
|
const [bulkStatus, setBulkStatus] = useState("AUTO");
|
|
const [bulkMatchedId, setBulkMatchedId] = useState("");
|
|
const [bulkReason, setBulkReason] = useState("");
|
|
const [bulkLoading, setBulkLoading] = useState(false);
|
|
const [bulkMessage, setBulkMessage] = useState<string | null>(null);
|
|
const [bulkError, setBulkError] = useState<string | null>(null);
|
|
const [page, setPage] = useState(1);
|
|
const [lastPage, setLastPage] = useState(1);
|
|
const perPage = 100;
|
|
const [matcherTarget, setMatcherTarget] = useState<QsoResult | null>(null);
|
|
const [matcherQuery, setMatcherQuery] = useState("");
|
|
const [matcherItems, setMatcherItems] = useState<LogQso[]>([]);
|
|
const [matcherPage, setMatcherPage] = useState(1);
|
|
const [matcherLastPage, setMatcherLastPage] = useState(1);
|
|
const [matcherLoading, setMatcherLoading] = useState(false);
|
|
const [matcherError, setMatcherError] = useState<string | null>(null);
|
|
|
|
const hasMissingLocator = (item: QsoResult) => {
|
|
const working = item.working_qso;
|
|
if (!working) return false;
|
|
const errors = working.errors || [];
|
|
return !working.loc_norm || !working.rloc_norm || errors.includes("INVALID_LOCATOR") || errors.includes("INVALID_RLOCATOR");
|
|
};
|
|
|
|
const problemLabel = useMemo(() => {
|
|
return (item: QsoResult) => {
|
|
if (issueFilter === "MISSING_LOCATOR" && hasMissingLocator(item)) return "MISSING_LOCATOR";
|
|
if (item.error_code) return item.error_code;
|
|
if (item.is_nil) return "NIL";
|
|
if (item.is_duplicate) return "DUP";
|
|
if (item.is_busted_call) return "BUSTED_CALL";
|
|
if (item.is_busted_exchange) return "BUSTED_EXCHANGE";
|
|
if (item.is_time_out_of_window) return "OUT_OF_WINDOW";
|
|
return "—";
|
|
};
|
|
}, [issueFilter]);
|
|
|
|
const errorDetailLabel = useMemo(() => {
|
|
return (item: QsoResult) => {
|
|
if (!item.error_detail) return null;
|
|
const key = `qso_error_detail_${item.error_detail.toLowerCase()}`;
|
|
return t(key) ?? item.error_detail;
|
|
};
|
|
}, [t]);
|
|
|
|
const problemTooltip = useMemo(() => {
|
|
return (item: QsoResult) => {
|
|
const details: string[] = [];
|
|
if (item.error_detail) {
|
|
const key = `qso_error_detail_${item.error_detail.toLowerCase()}`;
|
|
details.push(
|
|
`${t("qso_problem_reason") ?? "Důvod"}: ${t(key) ?? item.error_detail}`
|
|
);
|
|
}
|
|
const errors = item.working_qso?.errors ?? [];
|
|
if (errors.length > 0) {
|
|
details.push(`${t("qso_problem_errors") ?? "Chyby"}: ${errors.join(", ")}`);
|
|
}
|
|
return details.length > 0 ? details : null;
|
|
};
|
|
}, [t]);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
(async () => {
|
|
try {
|
|
const res = await axios.get<PaginatedResponse<QsoOverride>>("/api/qso-overrides", {
|
|
headers: { Accept: "application/json" },
|
|
params: { evaluation_run_id: evaluationRunId, per_page: 500 },
|
|
withCredentials: true,
|
|
});
|
|
if (!active) return;
|
|
const map: Record<number, QsoOverride> = {};
|
|
res.data.data.forEach((item) => {
|
|
map[item.log_qso_id] = item;
|
|
});
|
|
setOverrides(map);
|
|
} catch {
|
|
if (!active) return;
|
|
setOverrides({});
|
|
}
|
|
})();
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [evaluationRunId]);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
if (!roundId) return undefined;
|
|
(async () => {
|
|
try {
|
|
const res = await axios.get<PaginatedResponse<LogEntry>>("/api/logs", {
|
|
headers: { Accept: "application/json" },
|
|
params: { round_id: roundId, per_page: 500 },
|
|
withCredentials: true,
|
|
});
|
|
if (!active) return;
|
|
setLogOptions(res.data.data ?? []);
|
|
} catch {
|
|
if (!active) return;
|
|
setLogOptions([]);
|
|
}
|
|
})();
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [roundId]);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
(async () => {
|
|
try {
|
|
setLoading(true);
|
|
const params: Record<string, unknown> = {
|
|
evaluation_run_id: evaluationRunId,
|
|
per_page: perPage,
|
|
page,
|
|
};
|
|
if (issueFilter === "PROBLEMS") params.only_problems = true;
|
|
if (issueFilter === "OK_ONLY") params.only_ok = true;
|
|
if (issueFilter === "NOT_IN_COUNTERPART_LOG") params.error_code = "NOT_IN_COUNTERPART_LOG";
|
|
if (issueFilter === "NO_COUNTERPART_LOG") params.error_code = "NO_COUNTERPART_LOG";
|
|
if (issueFilter === "UNIQUE") params.error_code = "UNIQUE";
|
|
if (issueFilter === "DUP") params.error_code = "DUP";
|
|
if (issueFilter === "BUSTED_CALL") params.error_code = "BUSTED_CALL";
|
|
if (issueFilter === "BUSTED_RST") params.error_code = "BUSTED_RST";
|
|
if (issueFilter === "BUSTED_SERIAL") params.error_code = "BUSTED_SERIAL";
|
|
if (issueFilter === "BUSTED_LOCATOR") params.error_code = "BUSTED_LOCATOR";
|
|
if (issueFilter === "TIME_MISMATCH") params.error_code = "TIME_MISMATCH";
|
|
if (issueFilter === "OUT_OF_WINDOW") params.is_time_out_of_window = true;
|
|
if (issueFilter === "MISSING_LOCATOR") params.missing_locator = true;
|
|
if (searchCall) params.call_like = searchCall;
|
|
if (searchMatchedId) params.matched_qso_id = searchMatchedId;
|
|
if (selectedLogId) params.log_id = selectedLogId;
|
|
|
|
const res = await axios.get<PaginatedResponse<QsoResult>>("/api/qso-results", {
|
|
headers: { Accept: "application/json" },
|
|
params,
|
|
withCredentials: true,
|
|
});
|
|
if (!active) return;
|
|
setItems(res.data.data);
|
|
setLastPage(res.data.last_page ?? 1);
|
|
} catch {
|
|
if (!active) return;
|
|
setItems([]);
|
|
} finally {
|
|
if (active) setLoading(false);
|
|
}
|
|
})();
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [evaluationRunId, issueFilter, page, searchCall, searchMatchedId, selectedLogId]);
|
|
|
|
useEffect(() => {
|
|
if (!items.length) return;
|
|
setForms((prev) => {
|
|
const next = { ...prev };
|
|
for (const item of items) {
|
|
const override = overrides[item.log_qso_id];
|
|
if (!next[item.log_qso_id]) {
|
|
next[item.log_qso_id] = {
|
|
status: override?.forced_status ?? "AUTO",
|
|
matchedId: override?.forced_matched_log_qso_id ? String(override.forced_matched_log_qso_id) : "",
|
|
reason: override?.reason ?? "",
|
|
saving: false,
|
|
error: null,
|
|
success: null,
|
|
};
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
}, [items, overrides]);
|
|
|
|
const applySearch = () => {
|
|
setSearchCall(searchCallInput.trim());
|
|
setSearchMatchedId(searchMatchedInput.trim());
|
|
setPage(1);
|
|
};
|
|
|
|
const clearSearch = () => {
|
|
setSearchCallInput("");
|
|
setSearchMatchedInput("");
|
|
setSearchCall("");
|
|
setSearchMatchedId("");
|
|
setPage(1);
|
|
};
|
|
|
|
const activeFilterSummary = useMemo(() => {
|
|
const parts: string[] = [];
|
|
const issueLabel = issueOptions.find((opt) => opt.value === issueFilter)?.label;
|
|
if (issueLabel) parts.push(`Filtr: ${issueLabel}`);
|
|
if (searchCall) parts.push(`Callsign: ${searchCall}`);
|
|
if (searchMatchedId) parts.push(`Matched ID: ${searchMatchedId}`);
|
|
if (selectedLogId) {
|
|
const log = logOptions.find((item) => String(item.id) === selectedLogId);
|
|
if (log) {
|
|
const locator = log.pwwlo || log.locator || "—";
|
|
const sixh = log.sixhr_category ? " 6H" : "";
|
|
parts.push(
|
|
`Log: ${log.pcall ?? "—"} | ${log.psect ?? "—"} ${log.pband ?? ""} | ${
|
|
log.power_category ?? "—"
|
|
}${sixh} | ${locator}`
|
|
);
|
|
} else {
|
|
parts.push(`Log: ${selectedLogId}`);
|
|
}
|
|
}
|
|
return parts.length ? parts.join(" | ") : null;
|
|
}, [issueFilter, searchCall, searchMatchedId, selectedLogId, logOptions]);
|
|
|
|
const filteredItems = useMemo(() => items, [items]);
|
|
|
|
const handleFieldChange = useCallback(
|
|
(logQsoId: number, field: keyof OverrideForm, value: string) => {
|
|
setForms((prev) => ({
|
|
...prev,
|
|
[logQsoId]: {
|
|
...prev[logQsoId],
|
|
[field]: value,
|
|
error: null,
|
|
success: null,
|
|
},
|
|
}));
|
|
},
|
|
[]
|
|
);
|
|
|
|
const commitReason = useCallback((logQsoId: number, reason: string) => {
|
|
setForms((prev) => ({
|
|
...prev,
|
|
[logQsoId]: {
|
|
...prev[logQsoId],
|
|
reason,
|
|
error: null,
|
|
success: null,
|
|
},
|
|
}));
|
|
}, []);
|
|
|
|
const toggleSelected = useCallback((logQsoId: number, checked: boolean) => {
|
|
setSelectedIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (checked) {
|
|
next.add(logQsoId);
|
|
} else {
|
|
next.delete(logQsoId);
|
|
}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const toggleSelectAll = useCallback(
|
|
(checked: boolean) => {
|
|
if (!checked) {
|
|
setSelectedIds(new Set());
|
|
return;
|
|
}
|
|
const next = new Set<number>();
|
|
filteredItems.forEach((item) => {
|
|
next.add(item.log_qso_id);
|
|
});
|
|
setSelectedIds(next);
|
|
},
|
|
[filteredItems]
|
|
);
|
|
|
|
const runBulkUpdate = async (ids: number[], status: string, matchedId: string, reason: string) => {
|
|
if (!ids.length) return;
|
|
if (!reason.trim()) {
|
|
setBulkError("Doplň důvod změny.");
|
|
return;
|
|
}
|
|
setBulkLoading(true);
|
|
setBulkMessage(null);
|
|
setBulkError(null);
|
|
|
|
try {
|
|
for (const logQsoId of ids) {
|
|
const override = overrides[logQsoId];
|
|
const hasStatus = status && status !== "AUTO";
|
|
const hasMatched = matchedId !== "";
|
|
const hasAny = hasStatus || hasMatched;
|
|
|
|
if (!hasAny && !override) {
|
|
continue;
|
|
}
|
|
|
|
const payload = {
|
|
evaluation_run_id: evaluationRunId,
|
|
log_qso_id: logQsoId,
|
|
forced_status: status || "AUTO",
|
|
forced_matched_log_qso_id: matchedId ? Number(matchedId) : null,
|
|
reason: reason.trim(),
|
|
};
|
|
|
|
if (override) {
|
|
const res = await axios.put<QsoOverride>(`/api/qso-overrides/${override.id}`, payload, {
|
|
headers: { Accept: "application/json" },
|
|
withCredentials: true,
|
|
withXSRFToken: true,
|
|
});
|
|
setOverrides((prev) => ({ ...prev, [logQsoId]: res.data }));
|
|
} else {
|
|
const res = await axios.post<QsoOverride>("/api/qso-overrides", payload, {
|
|
headers: { Accept: "application/json" },
|
|
withCredentials: true,
|
|
withXSRFToken: true,
|
|
});
|
|
setOverrides((prev) => ({ ...prev, [logQsoId]: res.data }));
|
|
}
|
|
}
|
|
|
|
setBulkMessage("Hromadná změna uložena.");
|
|
setSelectedIds(new Set());
|
|
setBulkReason("");
|
|
} catch (e: any) {
|
|
const msg = e?.response?.data?.message || "Nepodařilo se uložit hromadné změny.";
|
|
setBulkError(msg);
|
|
} finally {
|
|
setBulkLoading(false);
|
|
}
|
|
};
|
|
|
|
const applyBulkStatus = async () => {
|
|
await runBulkUpdate(Array.from(selectedIds), bulkStatus, bulkMatchedId, bulkReason);
|
|
};
|
|
|
|
const applyBulkPreset = async (status: string, ids: number[]) => {
|
|
if (!ids.length) return;
|
|
setSelectedIds(new Set(ids));
|
|
setBulkStatus(status);
|
|
setBulkMatchedId("");
|
|
await runBulkUpdate(ids, status, "", bulkReason);
|
|
};
|
|
|
|
const openMatcher = useCallback(
|
|
(item: QsoResult) => {
|
|
setMatcherTarget(item);
|
|
setMatcherQuery(item.log_qso?.dx_call ?? "");
|
|
setMatcherItems([]);
|
|
setMatcherPage(1);
|
|
setMatcherLastPage(1);
|
|
setMatcherError(null);
|
|
onOpen();
|
|
},
|
|
[onOpen]
|
|
);
|
|
|
|
const fetchMatcher = async (target: QsoResult, pageOverride?: number) => {
|
|
const query = matcherQuery.trim();
|
|
if (!query) {
|
|
setMatcherError("Zadej volací znak pro vyhledání.");
|
|
return;
|
|
}
|
|
const pageToUse = pageOverride ?? matcherPage;
|
|
setMatcherLoading(true);
|
|
setMatcherError(null);
|
|
try {
|
|
const res = await axios.get<PaginatedResponse<LogQso>>("/api/log-qsos", {
|
|
headers: { Accept: "application/json" },
|
|
params: {
|
|
round_id: roundId,
|
|
call_like: query,
|
|
exclude_log_qso_id: target.log_qso_id,
|
|
exclude_log_id: target.log_qso?.log_id,
|
|
per_page: 20,
|
|
page: pageToUse,
|
|
},
|
|
withCredentials: true,
|
|
});
|
|
setMatcherItems(res.data.data ?? []);
|
|
setMatcherLastPage(res.data.last_page ?? 1);
|
|
} catch (e: any) {
|
|
const msg = e?.response?.data?.message || "Nepodařilo se načíst QSO.";
|
|
setMatcherError(msg);
|
|
setMatcherItems([]);
|
|
setMatcherLastPage(1);
|
|
} finally {
|
|
setMatcherLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) {
|
|
setMatcherTarget(null);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
const saveOverride = useCallback(
|
|
async (logQsoId: number, payloadForm: OverridePayload) => {
|
|
const override = overrides[logQsoId];
|
|
const hasStatus = payloadForm.status && payloadForm.status !== "AUTO";
|
|
const hasMatched = payloadForm.matchedId !== "";
|
|
const hasAny = hasStatus || hasMatched;
|
|
const reason = payloadForm.reason.trim();
|
|
const baseline = {
|
|
status: override?.forced_status ?? "AUTO",
|
|
matchedId: override?.forced_matched_log_qso_id ? String(override.forced_matched_log_qso_id) : "",
|
|
};
|
|
const hasChanges =
|
|
payloadForm.status !== baseline.status ||
|
|
payloadForm.matchedId !== baseline.matchedId;
|
|
|
|
setForms((prev) => {
|
|
const current = prev[logQsoId] ?? {
|
|
status: payloadForm.status,
|
|
matchedId: payloadForm.matchedId,
|
|
reason: payloadForm.reason,
|
|
saving: false,
|
|
error: null,
|
|
success: null,
|
|
};
|
|
return {
|
|
...prev,
|
|
[logQsoId]: {
|
|
...current,
|
|
status: payloadForm.status,
|
|
matchedId: payloadForm.matchedId,
|
|
reason: payloadForm.reason,
|
|
saving: true,
|
|
error: null,
|
|
success: null,
|
|
},
|
|
};
|
|
});
|
|
|
|
try {
|
|
if (!hasChanges && !override) {
|
|
setForms((prev) => ({
|
|
...prev,
|
|
[logQsoId]: { ...prev[logQsoId], saving: false, success: "Bez změn." },
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (!hasChanges && override) {
|
|
setForms((prev) => ({
|
|
...prev,
|
|
[logQsoId]: { ...prev[logQsoId], saving: false, success: "Bez změn." },
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (!hasAny && override) {
|
|
await axios.delete(`/api/qso-overrides/${override.id}`, {
|
|
headers: { Accept: "application/json" },
|
|
withCredentials: true,
|
|
withXSRFToken: true,
|
|
});
|
|
setOverrides((prev) => {
|
|
const next = { ...prev };
|
|
delete next[logQsoId];
|
|
return next;
|
|
});
|
|
setForms((prev) => ({
|
|
...prev,
|
|
[logQsoId]: { ...prev[logQsoId], saving: false, success: "Uloženo." },
|
|
}));
|
|
return;
|
|
}
|
|
|
|
if (!reason) {
|
|
setForms((prev) => ({
|
|
...prev,
|
|
[logQsoId]: { ...prev[logQsoId], saving: false, error: "Doplň důvod změny." },
|
|
}));
|
|
return;
|
|
}
|
|
|
|
const payload = {
|
|
evaluation_run_id: evaluationRunId,
|
|
log_qso_id: logQsoId,
|
|
forced_status: payloadForm.status || "AUTO",
|
|
forced_matched_log_qso_id: payloadForm.matchedId ? Number(payloadForm.matchedId) : null,
|
|
reason,
|
|
};
|
|
|
|
if (override) {
|
|
const res = await axios.put<QsoOverride>(`/api/qso-overrides/${override.id}`, payload, {
|
|
headers: { Accept: "application/json" },
|
|
withCredentials: true,
|
|
withXSRFToken: true,
|
|
});
|
|
setOverrides((prev) => ({ ...prev, [logQsoId]: res.data }));
|
|
} else {
|
|
const res = await axios.post<QsoOverride>("/api/qso-overrides", payload, {
|
|
headers: { Accept: "application/json" },
|
|
withCredentials: true,
|
|
withXSRFToken: true,
|
|
});
|
|
setOverrides((prev) => ({ ...prev, [logQsoId]: res.data }));
|
|
}
|
|
|
|
setForms((prev) => ({
|
|
...prev,
|
|
[logQsoId]: { ...prev[logQsoId], saving: false, success: "Uloženo." },
|
|
}));
|
|
} catch (e: any) {
|
|
const msg = e?.response?.data?.message || "Nepodařilo se uložit override.";
|
|
setForms((prev) => ({
|
|
...prev,
|
|
[logQsoId]: { ...prev[logQsoId], saving: false, error: msg },
|
|
}));
|
|
}
|
|
},
|
|
[evaluationRunId, overrides]
|
|
);
|
|
|
|
if (!roundId) return null;
|
|
|
|
return (
|
|
<div className="pt-2 border-t border-divider">
|
|
<div className="font-semibold text-sm mb-2">Ruční zásahy po matchingu</div>
|
|
<div className="text-xs text-foreground-500 mb-2">
|
|
Změny se projeví po kliknutí na „Pokračovat“. Slouží pro úpravy NIL/DUP/busted a párování.
|
|
</div>
|
|
<div className="mb-1 flex flex-wrap items-end gap-2 text-xs">
|
|
<Select
|
|
aria-label="Filtr"
|
|
size="sm"
|
|
variant="bordered"
|
|
label="Filtr"
|
|
className="w-80"
|
|
selectedKeys={new Set([issueFilter])}
|
|
onSelectionChange={(keys) => {
|
|
const value = getFirstSelection(keys);
|
|
if (!value) return;
|
|
setIssueFilter(value);
|
|
setPage(1);
|
|
}}
|
|
selectionMode="single"
|
|
disallowEmptySelection={true}
|
|
>
|
|
{issueOptions.map((opt) => (
|
|
<SelectItem key={opt.value} textValue={opt.label}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</Select>
|
|
<Select
|
|
aria-label="Log"
|
|
size="sm"
|
|
variant="bordered"
|
|
label="Log"
|
|
className="w-80"
|
|
selectedKeys={new Set([selectedLogId || "ALL"])}
|
|
onSelectionChange={(keys) => {
|
|
const value = getFirstSelection(keys);
|
|
const next = value === "ALL" ? "" : value;
|
|
setSelectedLogId(next);
|
|
setPage(1);
|
|
}}
|
|
selectionMode="single"
|
|
disallowEmptySelection={true}
|
|
>
|
|
<SelectItem key="ALL" textValue="Všechny logy">
|
|
Všechny logy
|
|
</SelectItem>
|
|
{logOptions.map((log) => {
|
|
const locator = log.pwwlo || log.locator || "—";
|
|
const sixh = log.sixhr_category ? " 6H" : "";
|
|
const label = `${log.pcall ?? "—"} | ${log.psect ?? "—"} ${log.pband ?? ""} | ${
|
|
log.power_category ?? "—"
|
|
}${sixh} | ${locator}`;
|
|
return (
|
|
<SelectItem key={String(log.id)} textValue={label}>
|
|
{label}
|
|
</SelectItem>
|
|
);
|
|
})}
|
|
</Select>
|
|
<Input
|
|
type="text"
|
|
size="sm"
|
|
variant="bordered"
|
|
label="Callsign"
|
|
value={searchCallInput}
|
|
onChange={(e) => setSearchCallInput(e.target.value)}
|
|
placeholder="OK1* / OL?ABC"
|
|
className="w-48"
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") applySearch();
|
|
}}
|
|
/>
|
|
<Input
|
|
type="text"
|
|
size="sm"
|
|
variant="bordered"
|
|
label="Matched ID"
|
|
value={searchMatchedInput}
|
|
onChange={(e) => setSearchMatchedInput(e.target.value)}
|
|
placeholder="12345"
|
|
className="w-28"
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") applySearch();
|
|
}}
|
|
/>
|
|
<Button type="button" size="sm" variant="bordered" onPress={applySearch}>
|
|
Hledat
|
|
</Button>
|
|
<Button type="button" size="sm" variant="light" onPress={clearSearch}>
|
|
Vyčistit
|
|
</Button>
|
|
</div>
|
|
<div className="mb-1 text-[11px] text-foreground-500">
|
|
Filtr se aplikuje průběžně, hledání spustíš tlačítkem „Hledat“.
|
|
</div>
|
|
{activeFilterSummary && (
|
|
<div className="mb-1 text-[11px] text-foreground-600">{activeFilterSummary}</div>
|
|
)}
|
|
<div className="mb-1 flex flex-wrap items-end gap-2 text-xs">
|
|
<Select
|
|
aria-label="Bulk status"
|
|
size="sm"
|
|
variant="bordered"
|
|
label="Status"
|
|
className="w-36"
|
|
selectedKeys={new Set([bulkStatus])}
|
|
onSelectionChange={(keys) => {
|
|
const value = getFirstSelection(keys);
|
|
if (value) setBulkStatus(value);
|
|
}}
|
|
selectionMode="single"
|
|
disallowEmptySelection={true}
|
|
>
|
|
{statusOptions.map((opt) => (
|
|
<SelectItem key={opt.value} textValue={opt.label}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</Select>
|
|
<Input
|
|
type="number"
|
|
size="sm"
|
|
variant="bordered"
|
|
label="Matched ID"
|
|
className="w-28"
|
|
value={bulkMatchedId}
|
|
onChange={(e) => setBulkMatchedId(e.target.value)}
|
|
placeholder="matched id"
|
|
/>
|
|
<Input
|
|
type="text"
|
|
size="sm"
|
|
variant="bordered"
|
|
label="Důvod"
|
|
className="w-64"
|
|
value={bulkReason}
|
|
onChange={(e) => setBulkReason(e.target.value)}
|
|
placeholder="Krátce popiš, proč zasahuješ."
|
|
/>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
color="primary"
|
|
onPress={applyBulkStatus}
|
|
isDisabled={!selectedIds.size || bulkLoading}
|
|
>
|
|
{bulkLoading ? "Ukládám…" : "Použít na vybrané"}
|
|
</Button>
|
|
{bulkMessage && <span className="text-green-600">{bulkMessage}</span>}
|
|
{bulkError && <span className="text-red-600">{bulkError}</span>}
|
|
</div>
|
|
<div className="mb-1 text-xs">
|
|
<Checkbox
|
|
size="sm"
|
|
isSelected={selectedIds.size > 0 && selectedIds.size === filteredItems.length}
|
|
onValueChange={(checked) => toggleSelectAll(checked)}
|
|
>
|
|
Vybrat vše na stránce
|
|
</Checkbox>
|
|
</div>
|
|
{loading && <div className="text-xs text-foreground-600">Načítám QSO…</div>}
|
|
{!loading && filteredItems.length === 0 && (
|
|
<div className="text-xs text-foreground-600">Žádné položky k úpravě.</div>
|
|
)}
|
|
{filteredItems.length > 0 && (
|
|
<div className="space-y-2">
|
|
{filteredItems.map((item) => (
|
|
<QsoOverrideRow
|
|
key={item.id}
|
|
item={item}
|
|
form={forms[item.log_qso_id]}
|
|
override={overrides[item.log_qso_id]}
|
|
selected={selectedIds.has(item.log_qso_id)}
|
|
onToggleSelected={toggleSelected}
|
|
onOpenMatcher={openMatcher}
|
|
onFieldChange={handleFieldChange}
|
|
onReasonCommit={commitReason}
|
|
onSave={saveOverride}
|
|
problemLabel={problemLabel}
|
|
errorDetailLabel={errorDetailLabel}
|
|
problemTooltip={problemTooltip}
|
|
t={t}
|
|
/>
|
|
))}
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="bordered"
|
|
onPress={() => setPage((p) => Math.max(1, p - 1))}
|
|
isDisabled={page <= 1}
|
|
>
|
|
Předchozí
|
|
</Button>
|
|
<span>
|
|
Strana {page} / {lastPage}
|
|
</span>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="bordered"
|
|
onPress={() => setPage((p) => Math.min(lastPage, p + 1))}
|
|
isDisabled={page >= lastPage}
|
|
>
|
|
Další
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="5xl" scrollBehavior="inside">
|
|
<ModalContent>
|
|
<ModalHeader className="flex flex-col gap-1">Vyhledat protistanici</ModalHeader>
|
|
<ModalBody>
|
|
{matcherTarget && (
|
|
<div className="text-xs text-foreground-500 mb-2">
|
|
<div>
|
|
QSO: {matcherTarget.log_qso?.my_call ?? "—"} →{" "}
|
|
{matcherTarget.log_qso?.dx_call ?? "—"} | {matcherTarget.log_qso?.band ?? "—"}{" "}
|
|
| {matcherTarget.log_qso?.time_on ?? "—"}
|
|
</div>
|
|
<div>Můj loc: {matcherTarget.log_qso?.my_locator ?? "—"}</div>
|
|
<div>
|
|
Aktuální matched: {matcherTarget.matched_qso_id ?? "AUTO"}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="flex flex-wrap items-end gap-2 text-xs mb-2">
|
|
<Input
|
|
size="sm"
|
|
variant="bordered"
|
|
label="Volací znak"
|
|
value={matcherQuery}
|
|
onChange={(e) => setMatcherQuery(e.target.value)}
|
|
placeholder="OK1* / OL?ABC"
|
|
className="min-w-[220px]"
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && matcherTarget) {
|
|
fetchMatcher(matcherTarget, 1);
|
|
}
|
|
}}
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
color="primary"
|
|
onPress={() => matcherTarget && fetchMatcher(matcherTarget, 1)}
|
|
isDisabled={!matcherTarget || matcherLoading}
|
|
>
|
|
{matcherLoading ? "Hledám…" : "Hledat"}
|
|
</Button>
|
|
</div>
|
|
{matcherError && <div className="text-xs text-red-600 mb-2">{matcherError}</div>}
|
|
{matcherItems.length === 0 && !matcherLoading && (
|
|
<div className="text-xs text-foreground-500 mb-2">Žádné výsledky.</div>
|
|
)}
|
|
{matcherItems.length > 0 && (
|
|
<Table
|
|
aria-label="Matches"
|
|
isStriped
|
|
className="text-xs"
|
|
removeWrapper
|
|
>
|
|
<TableHeader>
|
|
<TableColumn>ID</TableColumn>
|
|
<TableColumn>Log</TableColumn>
|
|
<TableColumn>Call</TableColumn>
|
|
<TableColumn>Band</TableColumn>
|
|
<TableColumn>Loc</TableColumn>
|
|
<TableColumn>Serial</TableColumn>
|
|
<TableColumn>Time</TableColumn>
|
|
<TableColumn>Akce</TableColumn>
|
|
</TableHeader>
|
|
<TableBody items={matcherItems}>
|
|
{(row) => (
|
|
<TableRow key={row.id}>
|
|
<TableCell>{row.id}</TableCell>
|
|
<TableCell>{row.log_id}</TableCell>
|
|
<TableCell>
|
|
{row.my_call ?? "—"} → {row.dx_call ?? "—"}
|
|
</TableCell>
|
|
<TableCell>{row.band ?? "—"}</TableCell>
|
|
<TableCell>
|
|
{row.my_locator ?? "—"} / {row.rx_wwl ?? "—"}
|
|
</TableCell>
|
|
<TableCell>
|
|
{row.my_serial ?? "—"} / {row.dx_serial ?? "—"}
|
|
</TableCell>
|
|
<TableCell>{row.time_on ?? "—"}</TableCell>
|
|
<TableCell>
|
|
<Button
|
|
size="sm"
|
|
variant="bordered"
|
|
onPress={() => {
|
|
if (!matcherTarget) return;
|
|
handleFieldChange(
|
|
matcherTarget.log_qso_id,
|
|
"matchedId",
|
|
String(row.id)
|
|
);
|
|
onClose();
|
|
}}
|
|
>
|
|
Vybrat
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
{matcherItems.length > 0 && (
|
|
<div className="flex items-center gap-2 text-xs mt-2">
|
|
<Button
|
|
size="sm"
|
|
variant="bordered"
|
|
onPress={() => {
|
|
if (!matcherTarget) return;
|
|
const next = Math.max(1, matcherPage - 1);
|
|
setMatcherPage(next);
|
|
fetchMatcher(matcherTarget, next);
|
|
}}
|
|
isDisabled={matcherPage <= 1 || matcherLoading}
|
|
>
|
|
Předchozí
|
|
</Button>
|
|
<span>
|
|
Strana {matcherPage} / {matcherLastPage}
|
|
</span>
|
|
<Button
|
|
size="sm"
|
|
variant="bordered"
|
|
onPress={() => {
|
|
if (!matcherTarget) return;
|
|
const next = Math.min(matcherLastPage, matcherPage + 1);
|
|
setMatcherPage(next);
|
|
fetchMatcher(matcherTarget, next);
|
|
}}
|
|
isDisabled={matcherPage >= matcherLastPage || matcherLoading}
|
|
>
|
|
Další
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</ModalBody>
|
|
</ModalContent>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|