lct-35: преподаватель подтверждает, снимает и добавляет отметки D1–D6 с пересчётом балла
This commit is contained in:
parent
b4420cea16
commit
f98c464f4b
21 changed files with 923 additions and 21 deletions
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
146
frontend/src/features/debrief/FindingReview.tsx
Normal file
146
frontend/src/features/debrief/FindingReview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue