766 lines
24 KiB
TypeScript
766 lines
24 KiB
TypeScript
import {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { Badge, Button, Card, Icon, Kicker } from "../../components/ui";
|
|
import { ApiError } from "../../lib/api";
|
|
import {
|
|
alliancePulseApi,
|
|
type AllianceDimension,
|
|
type AllianceMeasurement,
|
|
type AlliancePerspective,
|
|
type AlliancePulse,
|
|
type AllianceScores,
|
|
} from "./alliancePulseApi";
|
|
import "./alliance-pulse.css";
|
|
|
|
interface PulseTurn {
|
|
id: string;
|
|
turn_id?: string | null;
|
|
ts: string;
|
|
speaker: string;
|
|
who: string;
|
|
text: string;
|
|
}
|
|
|
|
interface AlliancePulseCardProps {
|
|
sessionId: string;
|
|
turns: PulseTurn[];
|
|
isSupervisorView: boolean;
|
|
feedbackEnabled: boolean;
|
|
onJumpToTurn: (turnId: string) => void;
|
|
}
|
|
|
|
type ScoreDraft = Record<AllianceDimension, number | null>;
|
|
type LoadState = "loading" | "ready" | "error";
|
|
|
|
const DIMENSIONS: AllianceDimension[] = ["goal", "task", "bond"];
|
|
|
|
const DIMENSION_COPY: Record<
|
|
AllianceDimension,
|
|
{ label: string; short: string; prompt: string }
|
|
> = {
|
|
goal: {
|
|
label: "목표 합의",
|
|
short: "목표",
|
|
prompt: "이번 회기에서 무엇을 다루려는지 서로 이해했나요?",
|
|
},
|
|
task: {
|
|
label: "과업 합의",
|
|
short: "과업",
|
|
prompt: "목표를 위해 사용한 질문과 활동이 도움이 됐나요?",
|
|
},
|
|
bond: {
|
|
label: "정서적 유대",
|
|
short: "유대",
|
|
prompt: "내담자가 존중받고 안전하다고 느꼈을까요?",
|
|
},
|
|
};
|
|
|
|
const CORE_PERSPECTIVES: AlliancePerspective[] = [
|
|
"learner_self_report",
|
|
"client_agent_report",
|
|
"independent_observer",
|
|
];
|
|
|
|
const PERSPECTIVE_COPY: Record<
|
|
AlliancePerspective,
|
|
{ label: string; provenance: string }
|
|
> = {
|
|
learner_self_report: { label: "내 판단", provenance: "잠긴 자기평가" },
|
|
client_agent_report: { label: "내담자 관점", provenance: "AI 역할 추론" },
|
|
independent_observer: { label: "관찰자 관점", provenance: "AI 축어록 추론" },
|
|
supervisor_human: { label: "교수자 판정", provenance: "교수자 근거 판정" },
|
|
};
|
|
|
|
const SCALE = [
|
|
{ value: 0, number: "1", label: "전혀 아니다" },
|
|
{ value: 0.25, number: "2", label: "조금 아니다" },
|
|
{ value: 0.5, number: "3", label: "보통이다" },
|
|
{ value: 0.75, number: "4", label: "대체로 그렇다" },
|
|
{ value: 1, number: "5", label: "매우 그렇다" },
|
|
] as const;
|
|
|
|
const EMPTY_DRAFT: ScoreDraft = { goal: null, task: null, bond: null };
|
|
const POLL_INTERVAL_MS = 2_000;
|
|
|
|
function scoreText(value: number | null): string {
|
|
if (value == null || !Number.isFinite(value)) return "근거 없음";
|
|
return `${Math.round(Math.max(0, Math.min(1, value)) * 100)}점`;
|
|
}
|
|
|
|
function scoreDescriptor(value: number | null): string {
|
|
if (value == null || !Number.isFinite(value)) return "판단 불가";
|
|
if (value >= 0.8) return "강하게 형성됨";
|
|
if (value >= 0.6) return "대체로 형성됨";
|
|
if (value >= 0.4) return "혼재함";
|
|
if (value >= 0.2) return "약한 편";
|
|
return "거의 형성되지 않음";
|
|
}
|
|
|
|
function pulseStatusCopy(pulse: AlliancePulse): {
|
|
label: string;
|
|
tone: "neutral" | "accent" | "warn" | "crit" | "info";
|
|
body: string;
|
|
} {
|
|
if (pulse.status === "awaiting_agents" || pulse.status === "processing") {
|
|
return {
|
|
label: "관점 분석 중",
|
|
tone: "info",
|
|
body: "자기평가는 잠겼습니다. 내담자와 관찰자 관점이 모두 준비된 뒤 함께 공개합니다.",
|
|
};
|
|
}
|
|
if (pulse.status === "degraded") {
|
|
return {
|
|
label: "일부 근거 부족",
|
|
tone: "warn",
|
|
body: "확인 가능한 관점만 표시합니다. 생성되지 않은 점수를 임의로 채우지 않습니다.",
|
|
};
|
|
}
|
|
if (pulse.status === "error" || pulse.error_code) {
|
|
return {
|
|
label: "분석 보류",
|
|
tone: "crit",
|
|
body: "자기평가는 보존됐지만 AI 관점은 준비되지 않았습니다. 점수를 추정해 표시하지 않습니다.",
|
|
};
|
|
}
|
|
return {
|
|
label: "관점 비교 준비됨",
|
|
tone: "accent",
|
|
body: "세 축을 평균내지 않고 관점별 차이와 발화 근거를 확인합니다.",
|
|
};
|
|
}
|
|
|
|
function isCompleteDraft(draft: ScoreDraft): draft is AllianceScores {
|
|
return DIMENSIONS.every((dimension) => draft[dimension] != null);
|
|
}
|
|
|
|
function latestPostPulse(items: AlliancePulse[]): AlliancePulse | null {
|
|
const postItems = items.filter((item) => item.checkpoint === "post");
|
|
return postItems.length > 0 ? postItems[postItems.length - 1] : null;
|
|
}
|
|
|
|
function selectedMeasurement(
|
|
pulse: AlliancePulse,
|
|
dimension: AllianceDimension,
|
|
perspective: AlliancePerspective,
|
|
): AllianceMeasurement | null {
|
|
const candidates = pulse.measurements.filter(
|
|
(item) => item.dimension === dimension && item.perspective === perspective,
|
|
);
|
|
return candidates.length > 0 ? candidates[candidates.length - 1] : null;
|
|
}
|
|
|
|
function AxisAssessmentControl({
|
|
dimension,
|
|
value,
|
|
onChange,
|
|
disabled = false,
|
|
prefix,
|
|
}: {
|
|
dimension: AllianceDimension;
|
|
value: number | null;
|
|
onChange: (value: number) => void;
|
|
disabled?: boolean;
|
|
prefix: string;
|
|
}) {
|
|
const copy = DIMENSION_COPY[dimension];
|
|
return (
|
|
<fieldset className="ap-axis" disabled={disabled}>
|
|
<legend>
|
|
<span>{copy.label}</span>
|
|
<small>{copy.prompt}</small>
|
|
</legend>
|
|
<div className="ap-scale" role="radiogroup" aria-label={`${copy.label} 평가`}>
|
|
{SCALE.map((option) => (
|
|
<label
|
|
key={option.value}
|
|
className={`ap-scale__option ${value === option.value ? "is-selected" : ""}`}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name={`${prefix}-${dimension}`}
|
|
value={option.value}
|
|
aria-label={`${option.number} ${option.label}`}
|
|
checked={value === option.value}
|
|
onChange={() => onChange(option.value)}
|
|
/>
|
|
<span aria-hidden="true">{option.number}</span>
|
|
<small>{option.label}</small>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</fieldset>
|
|
);
|
|
}
|
|
|
|
function EvidencePicker({
|
|
turns,
|
|
selected,
|
|
onChange,
|
|
scope,
|
|
}: {
|
|
turns: PulseTurn[];
|
|
selected: string[];
|
|
onChange: (turnIds: string[]) => void;
|
|
scope: "learner" | "supervisor";
|
|
}) {
|
|
const eligible = useMemo(
|
|
() =>
|
|
turns
|
|
.flatMap((turn) => {
|
|
const persistedId = turn.turn_id?.trim();
|
|
return persistedId ? [{ turn, persistedId }] : [];
|
|
})
|
|
.slice(-12),
|
|
[turns],
|
|
);
|
|
const summaryLabel =
|
|
scope === "learner" ? "내 판단의 근거 장면 선택" : "교수자 판정 근거 장면 선택";
|
|
|
|
const toggle = (turnId: string) => {
|
|
onChange(
|
|
selected.includes(turnId)
|
|
? selected.filter((item) => item !== turnId)
|
|
: [...selected, turnId],
|
|
);
|
|
};
|
|
|
|
return (
|
|
<details className="ap-evidence-picker">
|
|
<summary>
|
|
{summaryLabel}
|
|
<span>{selected.length}개 선택</span>
|
|
</summary>
|
|
{eligible.length > 0 ? (
|
|
<div className="ap-evidence-picker__list">
|
|
{eligible.map(({ turn, persistedId }) => (
|
|
<label key={turn.id}>
|
|
<input
|
|
type="checkbox"
|
|
checked={selected.includes(persistedId)}
|
|
onChange={() => toggle(persistedId)}
|
|
/>
|
|
<span className="ap-evidence-picker__time">{turn.ts}</span>
|
|
<span>
|
|
<b>{turn.who}</b>
|
|
{turn.text}
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p>영속 ID가 확인된 저장 발화가 없습니다.</p>
|
|
)}
|
|
</details>
|
|
);
|
|
}
|
|
|
|
function MeasurementEvidence({
|
|
measurement,
|
|
onJumpToTurn,
|
|
}: {
|
|
measurement: AllianceMeasurement;
|
|
onJumpToTurn: (turnId: string) => void;
|
|
}) {
|
|
const evidence = measurement.evidence ?? [];
|
|
if (!measurement.rationale && evidence.length === 0) return null;
|
|
return (
|
|
<details className="ap-measurement-evidence">
|
|
<summary>근거 {evidence.length}개</summary>
|
|
{measurement.rationale ? <p>{measurement.rationale}</p> : null}
|
|
{evidence.length > 0 ? (
|
|
<div className="ap-measurement-evidence__turns">
|
|
{evidence.map((item) => (
|
|
<button
|
|
type="button"
|
|
key={`${measurement.measurement_id}-${item.turn_id}-${item.seq}`}
|
|
onClick={() => onJumpToTurn(item.turn_id)}
|
|
>
|
|
<span>{item.seq}번째 발화</span>
|
|
{item.text}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</details>
|
|
);
|
|
}
|
|
|
|
function PerspectiveCell({
|
|
pulse,
|
|
dimension,
|
|
perspective,
|
|
onJumpToTurn,
|
|
}: {
|
|
pulse: AlliancePulse;
|
|
dimension: AllianceDimension;
|
|
perspective: AlliancePerspective;
|
|
onJumpToTurn: (turnId: string) => void;
|
|
}) {
|
|
const measurement =
|
|
perspective === "learner_self_report"
|
|
? null
|
|
: selectedMeasurement(pulse, dimension, perspective);
|
|
const value =
|
|
perspective === "learner_self_report"
|
|
? (pulse.self_scores?.[dimension] ?? null)
|
|
: (measurement?.value ?? null);
|
|
const failed = measurement != null && measurement.status !== "ready";
|
|
|
|
return (
|
|
<div
|
|
className={`ap-perspective-cell ${failed || value == null ? "is-unavailable" : ""}`}
|
|
>
|
|
<span className="ap-perspective-cell__label">
|
|
<b>{PERSPECTIVE_COPY[perspective].label}</b>
|
|
<small>{PERSPECTIVE_COPY[perspective].provenance}</small>
|
|
</span>
|
|
<strong>{scoreText(value)}</strong>
|
|
<span>{scoreDescriptor(value)}</span>
|
|
{measurement ? (
|
|
<MeasurementEvidence measurement={measurement} onJumpToTurn={onJumpToTurn} />
|
|
) : (
|
|
<small>먼저 기록한 자기평가</small>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LockedWaitingState({ pulse }: { pulse: AlliancePulse }) {
|
|
const terminalWithoutReveal =
|
|
pulse.status === "error" ||
|
|
pulse.status === "degraded" ||
|
|
Boolean(pulse.error_code);
|
|
return (
|
|
<div className="ap-waiting" role="status" aria-live="polite">
|
|
<div className="ap-waiting__lock">
|
|
<Icon name="shield" size={18} strokeWidth={1.8} />
|
|
</div>
|
|
<div>
|
|
<b>
|
|
{terminalWithoutReveal
|
|
? "내 판단은 보존됐습니다"
|
|
: "내 판단이 잠겼습니다"}
|
|
</b>
|
|
<p>
|
|
{terminalWithoutReveal
|
|
? "공개 가능한 AI 근거를 확정하지 못했습니다. 준비되지 않은 점수는 추정해 표시하지 않습니다."
|
|
: "AI 관점은 아직 화면에 표시하지 않습니다. 두 관점이 준비되면 한 번에 공개합니다."}
|
|
</p>
|
|
</div>
|
|
{terminalWithoutReveal ? null : (
|
|
<div className="ap-waiting__skeleton" aria-hidden="true">
|
|
<span />
|
|
<span />
|
|
<span />
|
|
</div>
|
|
)}
|
|
<dl className="ap-locked-scores">
|
|
{DIMENSIONS.map((dimension) => (
|
|
<div key={dimension}>
|
|
<dt>{DIMENSION_COPY[dimension].short}</dt>
|
|
<dd>{scoreText(pulse.self_scores?.[dimension] ?? null)}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SelfOnlyState({ pulse }: { pulse: AlliancePulse }) {
|
|
return (
|
|
<div className="ap-waiting ap-self-only" aria-label="저장된 치료 동맹 자기평가">
|
|
<div className="ap-waiting__copy">
|
|
<b>저장한 내 치료 동맹 자기평가</b>
|
|
<p>내가 직접 잠근 세 축만 보존합니다. AI·교수자 파생 비교는 표시하지 않습니다.</p>
|
|
</div>
|
|
<dl className="ap-locked-scores">
|
|
{DIMENSIONS.map((dimension) => (
|
|
<div key={dimension}>
|
|
<dt>{DIMENSION_COPY[dimension].short}</dt>
|
|
<dd>{scoreText(pulse.self_scores?.[dimension] ?? null)}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ComparisonView({
|
|
pulse,
|
|
onJumpToTurn,
|
|
}: {
|
|
pulse: AlliancePulse;
|
|
onJumpToTurn: (turnId: string) => void;
|
|
}) {
|
|
const supervisorExists = pulse.measurements.some(
|
|
(measurement) => measurement.perspective === "supervisor_human",
|
|
);
|
|
const perspectives = supervisorExists
|
|
? [...CORE_PERSPECTIVES, "supervisor_human" as const]
|
|
: CORE_PERSPECTIVES;
|
|
|
|
return (
|
|
<div className="ap-comparison" aria-label="치료 동맹 관점 비교">
|
|
<div
|
|
className={`ap-comparison__head ${supervisorExists ? "has-supervisor" : ""}`}
|
|
aria-hidden="true"
|
|
>
|
|
<span>독립 축</span>
|
|
{perspectives.map((perspective) => (
|
|
<span key={perspective}>
|
|
<b>{PERSPECTIVE_COPY[perspective].label}</b>
|
|
<small>{PERSPECTIVE_COPY[perspective].provenance}</small>
|
|
</span>
|
|
))}
|
|
</div>
|
|
{DIMENSIONS.map((dimension) => (
|
|
<section
|
|
className={`ap-comparison__row ${supervisorExists ? "has-supervisor" : ""}`}
|
|
key={dimension}
|
|
aria-labelledby={`ap-dimension-${dimension}`}
|
|
>
|
|
<div className="ap-comparison__axis">
|
|
<b id={`ap-dimension-${dimension}`}>{DIMENSION_COPY[dimension].label}</b>
|
|
<span>{DIMENSION_COPY[dimension].prompt}</span>
|
|
</div>
|
|
{perspectives.map((perspective) => (
|
|
<PerspectiveCell
|
|
key={perspective}
|
|
pulse={pulse}
|
|
dimension={dimension}
|
|
perspective={perspective}
|
|
onJumpToTurn={onJumpToTurn}
|
|
/>
|
|
))}
|
|
</section>
|
|
))}
|
|
<p className="ap-comparison__note">
|
|
총점과 평균은 만들지 않습니다. 축별 차이는 다음 회기에서 확인할 학습 질문으로 사용합니다.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SupervisorRatingForm({
|
|
sessionId,
|
|
pulse,
|
|
turns,
|
|
onRecorded,
|
|
}: {
|
|
sessionId: string;
|
|
pulse: AlliancePulse;
|
|
turns: PulseTurn[];
|
|
onRecorded: () => Promise<void>;
|
|
}) {
|
|
const [draft, setDraft] = useState<ScoreDraft>(EMPTY_DRAFT);
|
|
const [evidenceTurnIds, setEvidenceTurnIds] = useState<string[]>([]);
|
|
const [note, setNote] = useState("");
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [recorded, setRecorded] = useState(false);
|
|
const hasExisting = pulse.measurements.some(
|
|
(measurement) => measurement.perspective === "supervisor_human",
|
|
);
|
|
|
|
const submit = async () => {
|
|
if (
|
|
!isCompleteDraft(draft) ||
|
|
evidenceTurnIds.length === 0 ||
|
|
!note.trim()
|
|
) return;
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
await alliancePulseApi.addSupervisorRating(sessionId, pulse.pulse_id, {
|
|
scores: draft,
|
|
evidence_turn_ids: evidenceTurnIds,
|
|
note: note.trim(),
|
|
});
|
|
setRecorded(true);
|
|
setDraft(EMPTY_DRAFT);
|
|
setEvidenceTurnIds([]);
|
|
setNote("");
|
|
await onRecorded();
|
|
} catch (err) {
|
|
setError(
|
|
err instanceof Error ? err.message : "교수자 동맹 판정을 저장하지 못했습니다.",
|
|
);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<details className="ap-supervisor" open={!hasExisting}>
|
|
<summary>{hasExisting ? "교수자 판정 추가" : "교수자 판정 기록"}</summary>
|
|
<div className="ap-supervisor__body">
|
|
<p>
|
|
세 축을 각각 판정하고 근거 발화를 연결합니다. 새 판정은 기존 기록을 덮어쓰지 않습니다.
|
|
</p>
|
|
{DIMENSIONS.map((dimension) => (
|
|
<AxisAssessmentControl
|
|
key={dimension}
|
|
dimension={dimension}
|
|
value={draft[dimension]}
|
|
prefix="supervisor-alliance"
|
|
disabled={saving}
|
|
onChange={(value) =>
|
|
setDraft((current) => ({ ...current, [dimension]: value }))
|
|
}
|
|
/>
|
|
))}
|
|
<EvidencePicker
|
|
turns={turns}
|
|
selected={evidenceTurnIds}
|
|
onChange={setEvidenceTurnIds}
|
|
scope="supervisor"
|
|
/>
|
|
<label className="ap-note-field">
|
|
<span>판정 메모</span>
|
|
<textarea
|
|
rows={3}
|
|
value={note}
|
|
maxLength={2000}
|
|
placeholder="관점 차이를 해석하고 다음 지도에서 확인할 점을 남깁니다."
|
|
onChange={(event) => setNote(event.target.value)}
|
|
/>
|
|
</label>
|
|
{error ? <p className="ap-inline-error" role="alert">{error}</p> : null}
|
|
{recorded ? (
|
|
<p className="ap-inline-success" role="status">교수자 판정을 원장에 추가했습니다.</p>
|
|
) : null}
|
|
<Button
|
|
size="sm"
|
|
disabled={
|
|
saving ||
|
|
!isCompleteDraft(draft) ||
|
|
evidenceTurnIds.length === 0 ||
|
|
!note.trim()
|
|
}
|
|
onClick={() => void submit()}
|
|
>
|
|
{saving ? "판정 저장 중" : "근거와 함께 판정 추가"}
|
|
</Button>
|
|
</div>
|
|
</details>
|
|
);
|
|
}
|
|
|
|
export function AlliancePulseCard({
|
|
sessionId,
|
|
turns,
|
|
isSupervisorView,
|
|
feedbackEnabled,
|
|
onJumpToTurn,
|
|
}: AlliancePulseCardProps) {
|
|
const [loadState, setLoadState] = useState<LoadState>("loading");
|
|
const [pulse, setPulse] = useState<AlliancePulse | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [draft, setDraft] = useState<ScoreDraft>(EMPTY_DRAFT);
|
|
const [evidenceTurnIds, setEvidenceTurnIds] = useState<string[]>([]);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
const load = useCallback(
|
|
async (signal?: AbortSignal) => {
|
|
try {
|
|
const response = await alliancePulseApi.list(sessionId, signal);
|
|
setPulse(latestPostPulse(response.items ?? []));
|
|
setLoadState("ready");
|
|
setError(null);
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
setLoadState("error");
|
|
setError(
|
|
err instanceof Error ? err.message : "치료 동맹 펄스를 불러오지 못했습니다.",
|
|
);
|
|
}
|
|
},
|
|
[sessionId],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController();
|
|
void load(controller.signal);
|
|
return () => controller.abort();
|
|
}, [load]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!feedbackEnabled ||
|
|
!pulse ||
|
|
(pulse.status !== "awaiting_agents" && pulse.status !== "processing")
|
|
) {
|
|
return;
|
|
}
|
|
const timer = window.setTimeout(() => void load(), POLL_INTERVAL_MS);
|
|
return () => window.clearTimeout(timer);
|
|
}, [feedbackEnabled, load, pulse]);
|
|
|
|
const submitSelfAssessment = async () => {
|
|
if (!isCompleteDraft(draft) || submitting) return;
|
|
setSubmitting(true);
|
|
setError(null);
|
|
try {
|
|
const scores: AllianceScores = draft;
|
|
const created = await alliancePulseApi.create(sessionId, {
|
|
checkpoint: "post",
|
|
scores,
|
|
evidence_turn_ids: evidenceTurnIds,
|
|
});
|
|
setPulse({
|
|
pulse_id: created.pulse_id,
|
|
checkpoint: "post",
|
|
status: created.status,
|
|
learner_locked_at: new Date().toISOString(),
|
|
revealed_at: null,
|
|
error_code: null,
|
|
self_scores: scores,
|
|
measurements: [],
|
|
});
|
|
setLoadState("ready");
|
|
await load();
|
|
} catch (err) {
|
|
if (err instanceof ApiError && err.status === 409) {
|
|
await load();
|
|
setError("이미 잠긴 자기평가가 있어 저장된 기록을 불러왔습니다.");
|
|
} else {
|
|
setError(
|
|
err instanceof Error ? err.message : "자기평가를 잠그지 못했습니다.",
|
|
);
|
|
}
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const revealReady = Boolean(
|
|
pulse?.learner_locked_at && pulse?.revealed_at,
|
|
);
|
|
const status = feedbackEnabled && pulse ? pulseStatusCopy(pulse) : null;
|
|
const learnerEvidenceRequired = turns.some((turn) => Boolean(turn.turn_id?.trim()));
|
|
const canSubmit =
|
|
isCompleteDraft(draft) &&
|
|
(!learnerEvidenceRequired || evidenceTurnIds.length > 0) &&
|
|
!submitting;
|
|
|
|
let content: ReactNode;
|
|
if (loadState === "loading") {
|
|
content = (
|
|
<div className="ap-loading" role="status" aria-label="치료 동맹 펄스 불러오는 중">
|
|
<span />
|
|
<span />
|
|
<span />
|
|
</div>
|
|
);
|
|
} else if (loadState === "error" && !pulse) {
|
|
content = (
|
|
<div className="ap-load-error" role="alert">
|
|
<p>{error ?? "치료 동맹 펄스를 불러오지 못했습니다."}</p>
|
|
<Button variant="secondary" size="sm" onClick={() => void load()}>
|
|
다시 불러오기
|
|
</Button>
|
|
</div>
|
|
);
|
|
} else if (!pulse && isSupervisorView) {
|
|
content = (
|
|
<div className="ap-empty">
|
|
<b>학습자 자기평가 대기</b>
|
|
<p>학습자가 세 축을 먼저 잠근 뒤에만 AI 관점과 교수자 판정을 연결할 수 있습니다.</p>
|
|
</div>
|
|
);
|
|
} else if (!pulse) {
|
|
content = (
|
|
<div className="ap-self-form">
|
|
<div className="ap-self-form__intro">
|
|
<b>
|
|
{feedbackEnabled
|
|
? "AI 관점을 보기 전에 먼저 스스로 판단합니다"
|
|
: "목표·과업·유대를 내가 직접 기록합니다"}
|
|
</b>
|
|
<p>
|
|
{feedbackEnabled
|
|
? "세 축을 따로 평가하세요. 제출하면 값은 잠기며 이후 관점에 맞춰 수정할 수 없습니다."
|
|
: "세 축을 따로 평가해 저장합니다. AI·교수자 파생 비교는 표시하지 않습니다."}
|
|
</p>
|
|
</div>
|
|
{DIMENSIONS.map((dimension) => (
|
|
<AxisAssessmentControl
|
|
key={dimension}
|
|
dimension={dimension}
|
|
value={draft[dimension]}
|
|
prefix="learner-alliance"
|
|
disabled={submitting}
|
|
onChange={(value) =>
|
|
setDraft((current) => ({ ...current, [dimension]: value }))
|
|
}
|
|
/>
|
|
))}
|
|
<EvidencePicker
|
|
turns={turns}
|
|
selected={evidenceTurnIds}
|
|
onChange={setEvidenceTurnIds}
|
|
scope="learner"
|
|
/>
|
|
{error ? <p className="ap-inline-error" role="alert">{error}</p> : null}
|
|
<div className="ap-self-form__commit">
|
|
<span>
|
|
<Icon name="shield" size={15} strokeWidth={1.8} />
|
|
{feedbackEnabled
|
|
? "제출 뒤에는 AI 관점이 준비될 때까지 내 판단만 보입니다."
|
|
: "제출 뒤에도 내가 직접 잠근 기록만 표시합니다."}
|
|
</span>
|
|
<Button size="sm" disabled={!canSubmit} onClick={() => void submitSelfAssessment()}>
|
|
{submitting
|
|
? "판단 잠그는 중"
|
|
: feedbackEnabled
|
|
? "내 판단 잠그고 관점 비교"
|
|
: "내 판단 기록"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
} else if (!feedbackEnabled) {
|
|
content = <SelfOnlyState pulse={pulse} />;
|
|
} else if (!revealReady) {
|
|
content = <LockedWaitingState pulse={pulse} />;
|
|
} else {
|
|
content = (
|
|
<>
|
|
<ComparisonView pulse={pulse} onJumpToTurn={onJumpToTurn} />
|
|
{isSupervisorView ? (
|
|
<SupervisorRatingForm
|
|
sessionId={sessionId}
|
|
pulse={pulse}
|
|
turns={turns}
|
|
onRecorded={() => load()}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card className="sr-card ap-card" aria-busy={loadState === "loading"}>
|
|
<div className="ap-card__head">
|
|
<div>
|
|
<Kicker dot={false}>
|
|
{feedbackEnabled ? "치료 동맹 펄스" : "치료 동맹 자기평가"}
|
|
</Kicker>
|
|
<h2>
|
|
{feedbackEnabled
|
|
? "목표, 과업, 유대를 따로 봅니다"
|
|
: "목표, 과업, 유대를 직접 기록합니다"}
|
|
</h2>
|
|
</div>
|
|
{status ? <Badge tone={status.tone}>{status.label}</Badge> : null}
|
|
</div>
|
|
{status ? <p className="ap-card__status-copy">{status.body}</p> : null}
|
|
{content}
|
|
</Card>
|
|
);
|
|
}
|