lct-35: преподаватель подтверждает, снимает и добавляет отметки D1–D6 с пересчётом балла

This commit is contained in:
Ivan Gerasimov 2026-09-27 16:13:21 +03:00
commit f98c464f4b
21 changed files with 923 additions and 21 deletions

View file

@ -37,6 +37,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
<article className="debrief-stat">
<span>Автоматическая оценка</span><strong>{report.score_auto.toFixed(0)}<small> из 100</small></strong>
<small>{report.overridden_by ? "до корректировки преподавателя" : "расчёт по критериям занятия"}</small>
{report.score_reviewed != null && <small>после разбора отметок: {report.score_reviewed.toFixed(0)}</small>}
</article>
<article className="debrief-stat">
<span>Упражнение</span><strong>{report.exercise === "dds" ? "ДДС" : report.exercise === "card" ? "КИО" : report.exercise === "call" ? "Вызов 112" : "112 → ДДС"}</strong>
@ -144,6 +145,10 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
<td>
{finding.summary}
<div className="ref">{finding.fact}{finding.norm && ` · ${finding.norm}`}</div>
{finding.source === "instructor" && <div className="ref">отметка преподавателя {finding.author ?? ""}</div>}
{finding.review && <div className="ref">
{finding.review.decision === "dismissed" ? "снята" : "подтверждена"} преподавателем: {finding.review.reason}
</div>}
</td>
</tr>
))}

View file

@ -0,0 +1,146 @@
// Разбор по отметкам: преподаватель подтверждает, снимает и добавляет отметку.
//
// Балл пересчитывает сервер теми же метриками и весами (lct-35): здесь только
// решение и его причина. Причина обязательна — она остаётся в отчёте и в CSV/PDF.
import { useState } from "react";
import type { ErrorCode, FindingDecision, SessionReport } from "@/shared/types/generated";
const DDS_CODES: ErrorCode[] = ["D1", "D2", "D3", "D4", "D5", "D6"];
const CALL_CODES: ErrorCode[] = ["E1", "E2", "E3", "E4", "E5", "E6"];
async function send(url: string, body: unknown): Promise<SessionReport> {
const response = await fetch(url, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await response.json().catch(() => null);
if (!response.ok) {
const detail = data && typeof data === "object" && "detail" in data ? data.detail : response.statusText;
throw new Error(typeof detail === "string" ? detail : "Не удалось сохранить решение.");
}
return data as SessionReport;
}
export function FindingReview({ report, onChange }: {
report: SessionReport;
onChange: (report: SessionReport) => void;
}) {
const cards = report.card_results ?? [];
const codes = report.exercise === "dds" ? DDS_CODES
: cards.length ? [...CALL_CODES, ...DDS_CODES] : CALL_CODES;
const [reasons, setReasons] = useState<Record<number, string>>({});
const [code, setCode] = useState<ErrorCode>(codes[0]);
const [card, setCard] = useState(cards.length ? "1" : "");
const [fact, setFact] = useState("");
const [norm, setNorm] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const base = `/api/sessions/${report.session_id}/findings`;
async function run(action: () => Promise<SessionReport>) {
setBusy(true);
setError("");
try {
onChange(await action());
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Не удалось сохранить решение.");
} finally {
setBusy(false);
}
}
function decide(index: number, decision: FindingDecision) {
const reason = (reasons[index] ?? "").trim();
if (!reason) {
setError("Укажите причину решения по отметке.");
return;
}
void run(async () => {
const updated = await send(`${base}/${index}/review`, { decision, reason });
setReasons((current) => ({ ...current, [index]: "" }));
return updated;
});
}
function addFinding() {
if (!fact.trim() || !norm.trim()) {
setError("Для отметки нужны факт и норма.");
return;
}
void run(async () => {
const updated = await send(base, {
code, fact: fact.trim(), norm: norm.trim(), card: card ? Number(card) : null,
});
setFact("");
setNorm("");
return updated;
});
}
return (
<section className="debrief finding-review">
<h2>Разбор по отметкам</h2>
<p className="ref">
Снятая отметка не штрафует свою метрику, добавленная — штрафует. Балл пересчитывается
по тем же критериям и весам; автоматическая оценка ({report.score_auto.toFixed(1)}) сохраняется рядом.
{report.score_reviewed != null && ` После разбора: ${report.score_reviewed.toFixed(1)}.`}
</p>
<table className="grid">
<tbody>
{report.findings.map((finding, index) => (
<tr key={index}>
<th>{finding.code}</th>
<td>
{finding.summary}
<div className="ref">{finding.fact}{finding.norm && ` · ${finding.norm}`}</div>
{finding.review && <div className={finding.review.decision === "dismissed" ? "ref" : "state-violated"}>
{finding.review.decision === "dismissed" ? "Снята" : "Подтверждена"}: {finding.review.reason}
</div>}
<div className="finding-review-actions">
<input aria-label={`Причина решения по отметке ${index + 1}`} maxLength={1000}
placeholder="Причина решения" value={reasons[index] ?? ""}
onChange={(event) => setReasons((current) => ({ ...current, [index]: event.target.value }))} />
<button type="button" disabled={busy} onClick={() => decide(index, "confirmed")}>Подтвердить</button>
<button type="button" disabled={busy} onClick={() => decide(index, "dismissed")}>Снять</button>
</div>
</td>
</tr>
))}
{report.findings.length === 0 && <tr><td colSpan={2}>Автоматических отметок нет.</td></tr>}
</tbody>
</table>
<h3>Добавить отметку</h3>
<div className="finding-review-form">
<label>Код
<select value={code} onChange={(event) => setCode(event.target.value as ErrorCode)}>
{codes.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</label>
{cards.length > 0 && <label>Карточка
<select value={card} onChange={(event) => setCard(event.target.value)}>
{cards.map((item, index) => <option key={item.card_id} value={String(index + 1)}>
{index + 1}: {item.title || item.scenario_id}
</option>)}
</select>
</label>}
<label>Факт
<input maxLength={1000} value={fact} onChange={(event) => setFact(event.target.value)}
placeholder="Что сделал курсант" />
</label>
<label>Норма
<input maxLength={1000} value={norm} onChange={(event) => setNorm(event.target.value)}
placeholder="Как требовалось" />
</label>
<button type="button" disabled={busy || !fact.trim() || !norm.trim()} onClick={addFinding}>
{busy ? "Сохраняем…" : "Добавить отметку"}
</button>
</div>
{error && <p className="violated" role="alert">{error}</p>}
</section>
);
}

View file

@ -7,6 +7,7 @@
import { useEffect, useRef, useState } from "react";
import { Debrief } from "@/features/debrief/Debrief";
import { FindingReview } from "@/features/debrief/FindingReview";
import { Director } from "@/features/instructor/Director";
import { ScoreWeightsEditor } from "@/features/instructor/ScoreWeightsEditor";
import { ScenarioEditor } from "@/features/instructor/ScenarioEditor";
@ -801,6 +802,8 @@ export function Instructor() {
{session.report && <>
<Debrief report={overrideReport?.session_id === session.report.session_id
? overrideReport : session.report} />
<FindingReview report={overrideReport?.session_id === session.report.session_id
? overrideReport : session.report} onChange={setOverrideReport} />
<section className="debrief debrief-score-override">
<h2>Ручная корректировка оценки</h2>
<p className="ref">Автоматическая оценка: {session.report.score_auto.toFixed(1)} из 100. Меняйте её только при необходимости: причина сохранится в журнале вместе с вашим логином.</p>

View file

@ -267,6 +267,22 @@ export interface Finding {
competency?: Competency | null;
transcript_ref?: string | null;
at?: string | null;
metric_key?: string | null;
card?: number | null;
service?: string | null;
author?: string | null;
review?: FindingReview | null;
}
/** Решение преподавателя по отметке на разборе. */
export type FindingDecision = "confirmed" | "dismissed";
/** Кто, когда и почему подтвердил или снял отметку. Сама отметка остаётся. */
export interface FindingReview {
decision: FindingDecision;
reason: string;
author: string;
at: string;
}
/** Источник объяснимой отметки; `judge` оставлен для старых отчётов. */
@ -407,6 +423,10 @@ export interface Metric {
passed: boolean;
weight?: number;
credit?: number | null;
card?: number | null;
service?: string | null;
passed_auto?: boolean | null;
credit_auto?: number | null;
}
export interface ModeSet {
@ -608,6 +628,7 @@ export interface SessionReport {
self_assessment_diff?: SelfAssessmentDiff | null;
notes?: Array<InstructorNoteShown>;
score_auto: number;
score_reviewed?: number | null;
score_final: number;
overridden_by?: string | null;
override_comment?: string | null;

View file

@ -197,6 +197,9 @@ button:disabled { opacity: 0.5; cursor: default; }
.debrief-stat > strong { font-size: 24px; line-height: 1.15; overflow-wrap: anywhere; }
.debrief-stat > strong small { font-size: 14px; font-weight: 500; }
.debrief-stat > small { color: #5a6068; font-size: 12px; }
.finding-review-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.finding-review-actions input { flex: 1 1 200px; min-width: 0; }
.finding-review-form { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; align-items: end; }
.debrief-stat.is-passed { border-left-color: var(--ok); }
.debrief-stat.is-failed { border-left-color: var(--violated); }
.debrief-next-step { margin: 12px 0 18px; padding: 12px 15px; border: 1px solid var(--line); border-left: 4px solid #6b7780; border-radius: 5px; background: #fff; }