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

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>
);
}