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

342 lines
11 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
import axios from "axios";
import { Spinner, useDisclosure } from "@heroui/react";
import RoundEvaluationLogOverridesTable from "@/components/RoundEvaluationLogOverridesTable";
import RoundEvaluationLogOverridesModal from "@/components/RoundEvaluationLogOverridesModal";
import RoundEvaluationLogOverridesSearch from "@/components/RoundEvaluationLogOverridesSearch";
import type {
LogOverride,
LogResult,
OverrideForm,
} from "@/components/RoundEvaluationLogOverrides.types";
type RoundDetail = {
id: number;
bands?: { id: number; name: string }[];
categories?: { id: number; name: string }[];
power_categories?: { id: number; name: string }[];
powerCategories?: { id: number; name: string }[];
};
type PaginatedResponse<T> = {
data: T[];
current_page: number;
last_page: number;
total: number;
};
type Props = {
roundId: number | null;
evaluationRunId: number;
};
export default function RoundEvaluationLogOverrides({ roundId, evaluationRunId }: Props) {
const { isOpen, onOpen, onOpenChange, onClose } = useDisclosure();
const [items, setItems] = useState<LogResult[]>([]);
const [overrides, setOverrides] = useState<Record<number, LogOverride>>({});
const [forms, setForms] = useState<Record<number, OverrideForm>>({});
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState("");
const [roundDetail, setRoundDetail] = useState<RoundDetail | null>(null);
const [activeLogId, setActiveLogId] = useState<number | null>(null);
const perPage = 5000;
const bands = useMemo(() => roundDetail?.bands ?? [], [roundDetail?.bands]);
const categories = useMemo(() => roundDetail?.categories ?? [], [roundDetail?.categories]);
const powerCategories = useMemo(
() => roundDetail?.powerCategories ?? roundDetail?.power_categories ?? [],
[roundDetail?.powerCategories, roundDetail?.power_categories]
);
const resolveName = (list: { id: number; name: string }[], id?: number | null) => {
if (!id) return "AUTO";
return list.find((item) => item.id === id)?.name ?? `#${id}`;
};
useEffect(() => {
if (!roundId) return;
let active = true;
(async () => {
try {
const res = await axios.get<RoundDetail>(`/api/rounds/${roundId}`, {
headers: { Accept: "application/json" },
withCredentials: true,
});
if (!active) return;
setRoundDetail(res.data);
} catch {
if (!active) return;
setRoundDetail(null);
}
})();
return () => {
active = false;
};
}, [roundId]);
const fetchOverrides = useCallback(async (active?: { value: boolean }) => {
try {
const res = await axios.get<PaginatedResponse<LogOverride>>("/api/log-overrides", {
headers: { Accept: "application/json" },
params: { evaluation_run_id: evaluationRunId, per_page: 5000 },
withCredentials: true,
});
if (active && !active.value) return;
const map: Record<number, LogOverride> = {};
res.data.data.forEach((item) => {
map[item.log_id] = item;
});
setOverrides(map);
} catch {
if (active && !active.value) return;
setOverrides({});
}
}, [evaluationRunId]);
useEffect(() => {
const active = { value: true };
fetchOverrides(active);
return () => {
active.value = false;
};
}, [fetchOverrides]);
const fetchItems = useCallback(async (active?: { value: boolean }) => {
try {
setLoading(true);
const res = await axios.get<PaginatedResponse<LogResult>>("/api/log-results", {
headers: { Accept: "application/json" },
params: { evaluation_run_id: evaluationRunId, per_page: perPage },
withCredentials: true,
});
if (active && !active.value) return;
setItems(res.data.data);
} catch {
if (active && !active.value) return;
setItems([]);
} finally {
if (!active || active.value) setLoading(false);
}
}, [evaluationRunId, perPage]);
useEffect(() => {
const active = { value: true };
fetchItems(active);
return () => {
active.value = false;
};
}, [fetchItems]);
useEffect(() => {
if (!items.length) return;
setForms((prev) => {
const next = { ...prev };
for (const item of items) {
const override = overrides[item.log_id];
if (!next[item.log_id]) {
next[item.log_id] = {
status: override?.forced_log_status ?? "AUTO",
bandId: override?.forced_band_id ? String(override.forced_band_id) : "",
categoryId: override?.forced_category_id ? String(override.forced_category_id) : "",
powerCategoryId: override?.forced_power_category_id ? String(override.forced_power_category_id) : "",
reason: override?.reason ?? "",
saving: false,
error: null,
success: null,
};
}
}
return next;
});
}, [items, overrides]);
const filteredItems = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return items;
return items.filter((item) => {
const log = item.log;
const parts = [
String(item.log_id),
log?.pcall ?? "",
log?.pband ?? "",
log?.psect ?? "",
]
.join(" ")
.toLowerCase();
return parts.includes(query);
});
}, [items, search]);
const activeItem = useMemo(
() => (activeLogId ? items.find((item) => item.log_id === activeLogId) ?? null : null),
[items, activeLogId]
);
const openEditor = useCallback(
(logId: number) => {
setActiveLogId(logId);
onOpen();
},
[onOpen]
);
const handleFieldChange = (logId: number, field: keyof OverrideForm, value: string) => {
const emptyForm: OverrideForm = {
status: "AUTO",
bandId: "",
categoryId: "",
powerCategoryId: "",
reason: "",
saving: false,
error: null,
success: null,
};
setForms((prev) => ({
...prev,
[logId]: {
...(prev[logId] ?? emptyForm),
[field]: value,
error: null,
success: null,
},
}));
};
const saveOverride = async (logId: number) => {
const form = forms[logId];
if (!form) return;
const override = overrides[logId];
const hasStatus = form.status && form.status !== "AUTO";
const hasBand = form.bandId !== "";
const hasCategory = form.categoryId !== "";
const hasPower = form.powerCategoryId !== "";
const hasAny = hasStatus || hasBand || hasCategory || hasPower;
const reason = form.reason.trim();
const baseline = {
status: override?.forced_log_status ?? "AUTO",
bandId: override?.forced_band_id ? String(override.forced_band_id) : "",
categoryId: override?.forced_category_id ? String(override.forced_category_id) : "",
powerCategoryId: override?.forced_power_category_id ? String(override.forced_power_category_id) : "",
};
const hasChanges =
form.status !== baseline.status ||
form.bandId !== baseline.bandId ||
form.categoryId !== baseline.categoryId ||
form.powerCategoryId !== baseline.powerCategoryId;
setForms((prev) => ({
...prev,
[logId]: { ...prev[logId], saving: true, error: null, success: null },
}));
try {
if (!hasChanges && !override) {
setForms((prev) => ({
...prev,
[logId]: { ...prev[logId], saving: false, success: "Bez změn." },
}));
return;
}
if (!hasChanges && override) {
setForms((prev) => ({
...prev,
[logId]: { ...prev[logId], saving: false, success: "Bez změn." },
}));
return;
}
if (!reason) {
setForms((prev) => ({
...prev,
[logId]: { ...prev[logId], saving: false, error: "Doplň důvod změny." },
}));
return;
}
const payload = {
evaluation_run_id: evaluationRunId,
log_id: logId,
forced_log_status: form.status || "AUTO",
forced_band_id: form.bandId ? Number(form.bandId) : null,
forced_category_id: form.categoryId ? Number(form.categoryId) : null,
forced_power_category_id: form.powerCategoryId ? Number(form.powerCategoryId) : null,
reason,
};
if (override) {
const res = await axios.put<LogOverride>(`/api/log-overrides/${override.id}`, payload, {
headers: { Accept: "application/json" },
withCredentials: true,
withXSRFToken: true,
});
setOverrides((prev) => ({ ...prev, [logId]: res.data }));
} else {
const res = await axios.post<LogOverride>("/api/log-overrides", payload, {
headers: { Accept: "application/json" },
withCredentials: true,
withXSRFToken: true,
});
setOverrides((prev) => ({ ...prev, [logId]: res.data }));
}
setForms((prev) => ({
...prev,
[logId]: { ...prev[logId], saving: false, success: "Uloženo." },
}));
await Promise.all([fetchOverrides(), fetchItems()]);
onClose();
} catch (e: any) {
const msg = e?.response?.data?.message || "Nepodařilo se uložit override.";
setForms((prev) => ({
...prev,
[logId]: { ...prev[logId], saving: false, error: msg },
}));
}
};
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 agregaci</div>
<div className="text-xs text-foreground-500 mb-2">
Slouží pro korekce klasifikace a statusu logu před publikací.
</div>
<RoundEvaluationLogOverridesSearch search={search} onChange={setSearch} />
{loading && (
<div className="flex items-center gap-2 text-xs text-foreground-600">
<Spinner size="sm" /> Načítám výsledky
</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-4">
<RoundEvaluationLogOverridesTable
items={filteredItems}
overrides={overrides}
onEdit={openEditor}
/>
</div>
)}
<RoundEvaluationLogOverridesModal
isOpen={isOpen}
onOpenChange={onOpenChange}
onClose={onClose}
activeItem={activeItem}
override={activeItem ? overrides[activeItem.log_id] : undefined}
form={activeItem ? forms[activeItem.log_id] : undefined}
bands={bands}
categories={categories}
powerCategories={powerCategories}
onFieldChange={handleFieldChange}
onSave={saveOverride}
resolveName={resolveName}
/>
</div>
);
}