fix: форма разбора с историей решений, выбором карточки только для D-кодов и ключом повтора

This commit is contained in:
GGlamer 2026-09-27 22:48:50 +03:00
commit 0d54fcc893
2 changed files with 30 additions and 12 deletions

View file

@ -3,7 +3,7 @@
// Балл пересчитывает сервер теми же метриками и весами (lct-35): здесь только // Балл пересчитывает сервер теми же метриками и весами (lct-35): здесь только
// решение и его причина. Причина обязательна — она остаётся в отчёте и в CSV/PDF. // решение и его причина. Причина обязательна — она остаётся в отчёте и в CSV/PDF.
import { useState } from "react"; import { useRef, useState } from "react";
import type { ErrorCode, FindingDecision, SessionReport } from "@/shared/types/generated"; import type { ErrorCode, FindingDecision, SessionReport } from "@/shared/types/generated";
@ -34,7 +34,13 @@ export function FindingReview({ report, onChange }: {
: cards.length ? [...CALL_CODES, ...DDS_CODES] : CALL_CODES; : cards.length ? [...CALL_CODES, ...DDS_CODES] : CALL_CODES;
const [reasons, setReasons] = useState<Record<number, string>>({}); const [reasons, setReasons] = useState<Record<number, string>>({});
const [code, setCode] = useState<ErrorCode>(codes[0]); const [code, setCode] = useState<ErrorCode>(codes[0]);
const [card, setCard] = useState(cards.length ? "1" : ""); // В ДДС все метрики принадлежат карточкам очереди. В связке 112 → ДДС
// E-коды относятся к занятию, а D-код без карточки — к живой карточке звонка.
const [card, setCard] = useState(report.exercise === "dds" && cards.length ? "1" : "");
const cardChoice = cards.length > 0 && (report.exercise === "dds" || code.startsWith("D"));
// Один ключ на отметку, пока она не сохранена: повтор после сбоя сети
// не добавит её второй раз.
const pendingId = useRef<string | null>(null);
const [fact, setFact] = useState(""); const [fact, setFact] = useState("");
const [norm, setNorm] = useState(""); const [norm, setNorm] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@ -71,10 +77,14 @@ export function FindingReview({ report, onChange }: {
setError("Для отметки нужны факт и норма."); setError("Для отметки нужны факт и норма.");
return; return;
} }
pendingId.current ??= crypto.randomUUID();
const clientId = pendingId.current;
void run(async () => { void run(async () => {
const updated = await send(base, { const updated = await send(base, {
code, fact: fact.trim(), norm: norm.trim(), card: card ? Number(card) : null, code, fact: fact.trim(), norm: norm.trim(), card: cardChoice && card ? Number(card) : null,
client_id: clientId,
}); });
pendingId.current = null;
setFact(""); setFact("");
setNorm(""); setNorm("");
return updated; return updated;
@ -97,9 +107,11 @@ export function FindingReview({ report, onChange }: {
<td> <td>
{finding.summary} {finding.summary}
<div className="ref">{finding.fact}{finding.norm && ` · ${finding.norm}`}</div> <div className="ref">{finding.fact}{finding.norm && ` · ${finding.norm}`}</div>
{finding.review && <div className={finding.review.decision === "dismissed" ? "ref" : "state-violated"}> {(finding.reviews ?? []).map((review, reviewIndex, all) => <div key={reviewIndex}
{finding.review.decision === "dismissed" ? "Снята" : "Подтверждена"}: {finding.review.reason} className={reviewIndex < all.length - 1 || review.decision === "dismissed" ? "ref" : "state-violated"}>
</div>} {review.decision === "dismissed" ? "Снята" : "Подтверждена"} ({review.author},{" "}
{new Date(review.at).toLocaleString("ru-RU")}): {review.reason}
</div>)}
<div className="finding-review-actions"> <div className="finding-review-actions">
<input aria-label={`Причина решения по отметке ${index + 1}`} maxLength={1000} <input aria-label={`Причина решения по отметке ${index + 1}`} maxLength={1000}
placeholder="Причина решения" value={reasons[index] ?? ""} placeholder="Причина решения" value={reasons[index] ?? ""}
@ -117,23 +129,27 @@ export function FindingReview({ report, onChange }: {
<h3>Добавить отметку</h3> <h3>Добавить отметку</h3>
<div className="finding-review-form"> <div className="finding-review-form">
<label>Код <label>Код
<select value={code} onChange={(event) => setCode(event.target.value as ErrorCode)}> <select value={code} onChange={(event) => {
pendingId.current = null;
setCode(event.target.value as ErrorCode);
}}>
{codes.map((item) => <option key={item} value={item}>{item}</option>)} {codes.map((item) => <option key={item} value={item}>{item}</option>)}
</select> </select>
</label> </label>
{cards.length > 0 && <label>Карточка {cardChoice && <label>Карточка
<select value={card} onChange={(event) => setCard(event.target.value)}> <select value={card} onChange={(event) => { pendingId.current = null; setCard(event.target.value); }}>
{report.exercise !== "dds" && <option value="">занятие (без карточки)</option>}
{cards.map((item, index) => <option key={item.card_id} value={String(index + 1)}> {cards.map((item, index) => <option key={item.card_id} value={String(index + 1)}>
{index + 1}: {item.title || item.scenario_id} {index + 1}: {item.title || item.scenario_id}
</option>)} </option>)}
</select> </select>
</label>} </label>}
<label>Факт <label>Факт
<input maxLength={1000} value={fact} onChange={(event) => setFact(event.target.value)} <input maxLength={1000} value={fact} onChange={(event) => { pendingId.current = null; setFact(event.target.value); }}
placeholder="Что сделал курсант" /> placeholder="Что сделал курсант" />
</label> </label>
<label>Норма <label>Норма
<input maxLength={1000} value={norm} onChange={(event) => setNorm(event.target.value)} <input maxLength={1000} value={norm} onChange={(event) => { pendingId.current = null; setNorm(event.target.value); }}
placeholder="Как требовалось" /> placeholder="Как требовалось" />
</label> </label>
<button type="button" disabled={busy || !fact.trim() || !norm.trim()} onClick={addFinding}> <button type="button" disabled={busy || !fact.trim() || !norm.trim()} onClick={addFinding}>

View file

@ -177,6 +177,7 @@ export interface DdsCardReport {
card_id: string; card_id: string;
scenario_id: string; scenario_id: string;
score_auto: number; score_auto: number;
score_reviewed?: number | null;
reply_text: string; reply_text: string;
metrics: Array<Metric>; metrics: Array<Metric>;
findings: Array<Finding>; findings: Array<Finding>;
@ -271,7 +272,8 @@ export interface Finding {
card?: number | null; card?: number | null;
service?: string | null; service?: string | null;
author?: string | null; author?: string | null;
review?: FindingReview | null; client_id?: string | null;
reviews?: Array<FindingReview>;
} }
/** Решение преподавателя по отметке на разборе. */ /** Решение преподавателя по отметке на разборе. */