전 저장소 리팩터링과 SSOT 정비

This commit is contained in:
Yun Chan 2026-07-15 21:31:30 +09:00
parent 14ecbd4e7d
commit 3dfddcac6f
173 changed files with 19679 additions and 6952 deletions

View file

@ -0,0 +1,168 @@
import type {
SessionReviewResponse,
TeacherSessionReviewStatusResponse,
UserPrepostMeasureItem,
UserPrepostMeasuresResponse,
} from "../../lib/api";
export type PrepostMeasureName = UserPrepostMeasureItem["measure_name"];
export type PrepostTimepoint = UserPrepostMeasureItem["timepoint"];
export type WorksheetReviewStatus =
| "pending"
| "approved"
| "changes_requested"
| "rejected";
export type WorksheetDecisionStatus = Exclude<
WorksheetReviewStatus,
"pending"
>;
export const PREPOST_PILOT_ID = "phase3-pilot-draft";
export const PREPOST_INSTRUMENT_VERSION =
"pilot-prepost-scaffold-2026-06-28";
export const PREPOST_SCORE_MIN = 1;
export const PREPOST_SCORE_MAX = 5;
export const REVIEW_READY_POLL_INTERVAL_MS = 1500;
export const REVIEW_READY_POLL_LIMIT = 60;
export const PREPOST_MEASURES: Array<{
key: PrepostMeasureName;
label: string;
hint: string;
}> = [
{ key: "self_efficacy", label: "자기효능감", hint: "상담 수행 자신감" },
{ key: "skill_proficiency", label: "기술숙련도", hint: "기법 적용 숙련감" },
{
key: "training_satisfaction",
label: "수련만족도",
hint: "훈련 경험 만족도",
},
];
export const PREPOST_TIMEPOINTS: Array<{
key: PrepostTimepoint;
label: string;
}> = [
{ key: "pre", label: "사전" },
{ key: "post", label: "사후" },
];
export function teacherReviewFromResponse(
saved: TeacherSessionReviewStatusResponse,
): NonNullable<SessionReviewResponse["teacherReview"]> {
return {
status: saved.status,
note: saved.note,
reviewerId: saved.reviewer_id ?? null,
reviewedAt: saved.reviewed_at ?? null,
updatedAt: saved.updated_at ?? null,
worksheetStatus: saved.worksheet_status ?? "pending",
worksheetNote: saved.worksheet_note ?? "",
worksheetReviewedAt: saved.worksheet_reviewed_at ?? null,
};
}
export function prepostDraftKey(
measure: PrepostMeasureName,
timepoint: PrepostTimepoint,
) {
return `${measure}:${timepoint}`;
}
export function worksheetReviewStatusLabel(status: WorksheetReviewStatus) {
if (status === "approved") return "승인";
if (status === "changes_requested") return "수정요청";
if (status === "rejected") return "반려";
return "검수 대기";
}
export function splitPrepostDraftKey(
key: string,
): [PrepostMeasureName, PrepostTimepoint] | null {
const [measure, timepoint] = key.split(":");
if (
PREPOST_MEASURES.some((item) => item.key === measure) &&
PREPOST_TIMEPOINTS.some((item) => item.key === timepoint)
) {
return [measure as PrepostMeasureName, timepoint as PrepostTimepoint];
}
return null;
}
export function prepostScoreText(score: number) {
return Number.isInteger(score)
? String(score)
: score.toFixed(1).replace(/\.0$/, "");
}
export function prepostDraftFromResponse(
response: UserPrepostMeasuresResponse | null,
): Record<string, string> {
const next: Record<string, string> = {};
for (const item of response?.measures ?? []) {
next[prepostDraftKey(item.measure_name, item.timepoint)] = prepostScoreText(
item.raw_score,
);
}
return next;
}
export function parsePrepostScore(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Number(trimmed);
if (!Number.isFinite(parsed)) return null;
if (parsed < PREPOST_SCORE_MIN || parsed > PREPOST_SCORE_MAX) return null;
return parsed;
}
export function displayReviewSummary(summary: string) {
if (
/(?:사유:\s*)?(?:engine_error|session evaluation timeout|timeout|RuntimeError)/i.test(
summary,
)
) {
return "저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. AI 평가 재시도가 필요합니다.";
}
return summary;
}
export function displayEvaluationRetryError(message: string) {
if (!message.trim()) return "AI 평가를 다시 실행하지 못했습니다.";
return "AI 평가 재시도를 완료하지 못했습니다. 잠시 뒤 다시 실행해 주세요.";
}
const REVIEW_DISPLAY_PLACEHOLDERS: Record<string, string> = {
"[NAME]": "익명 내담자",
"[ORG]": "소속 기관",
"[PHONE]": "연락처",
"[EMAIL]": "이메일",
"[RRN]": "주민등록번호",
"[ADDRESS]": "주소",
};
export function displayTranscriptText(text: string) {
let next = text;
for (const [placeholder, label] of Object.entries(
REVIEW_DISPLAY_PLACEHOLDERS,
)) {
next = next.split(placeholder).join(label);
}
return next;
}
export function shouldPollReviewReady(data: SessionReviewResponse) {
return (
!data.reviewReady &&
data.degraded &&
data.supervisorState === "평가 대기" &&
(data.turns?.length ?? 0) > 0
);
}
export function noteAuthorLabel(author: string) {
const normalized = author.trim();
if (normalized === "ai") return "AI";
if (normalized === "transcript") return "기록";
return normalized || "교수자";
}

View file

@ -111,125 +111,84 @@
color: var(--accent-deep);
}
/* 레이아웃 코어 (2026-07-15 재설계 같은 전환)
리뷰 요약은 상단 전폭 밴드(.sr-overview--band) 읽고 지나간다
(sticky 도킹/스크롤 추종 제거). 본문은 모든 폭에서 상단 가로 탭이
결정한 영역만 전폭 단일 컬럼으로 보여준다 탭별 areas·표시 규칙은
파일 끝의 '전 폭 탭' 블록이 단일 진실이다. */
.sr-cols {
min-width: 0;
display: grid;
grid-template-columns: minmax(236px, 280px) minmax(0, 1fr) minmax(286px, 340px);
grid-template-areas:
"overview chart rubric"
"overview flow rubric"
"overview transcript good"
"overview transcript growth"
"overview transcript prepost"
"overview transcript worksheet"
"overview transcript feedback"
"session session session";
grid-template-columns: minmax(0, 1fr);
gap: var(--sp-4);
align-items: start;
margin-top: 0;
}
.sr-cols--supervisor {
grid-template-areas:
"overview chart teacher"
"overview flow rubric"
"overview transcript good"
"overview transcript growth"
"overview transcript prepost"
"overview transcript worksheet"
"overview transcript feedback"
"session session session";
}
.sr-cols--learner {
grid-template-columns: minmax(232px, 300px) minmax(380px, 1fr) minmax(264px, 328px);
grid-template-areas:
"overview transcript rubric"
"chart transcript good"
"flow transcript growth"
"worksheet worksheet prepost"
"worksheet worksheet feedback"
"session session session";
}
.sr-cols--learner .sr-overview {
position: sticky;
top: calc(var(--topbar-h) + var(--sp-4));
max-height: calc(100vh - var(--topbar-h) - var(--sp-5) - var(--sp-5));
overflow: auto;
scrollbar-gutter: stable;
}
.sr-root--empty .sr-cols {
grid-template-columns: minmax(236px, 300px) minmax(0, 1fr);
grid-template-areas:
"overview chart"
"overview flow"
"overview transcript"
"overview rubric"
"overview good"
"overview growth"
"overview prepost"
"overview worksheet"
"overview feedback"
"session session";
}
.sr-root--empty .sr-cols--supervisor {
grid-template-areas:
"overview chart"
"overview flow"
"overview teacher"
"overview transcript"
"overview rubric"
"overview good"
"overview growth"
"overview prepost"
"overview worksheet"
"overview feedback"
"session session";
}
.sr-left,
.sr-right {
display: contents;
}
.sr-overview {
grid-area: overview;
min-width: 0;
display: grid;
gap: var(--sp-3);
align-self: start;
position: sticky;
top: calc(var(--topbar-h) + var(--sp-4));
z-index: 1;
max-height: calc(100vh - var(--topbar-h) - var(--sp-5) - var(--sp-5));
overflow: auto;
scrollbar-gutter: stable;
gap: var(--sp-4);
align-content: start;
align-items: start;
}
.sr-feedback {
.sr-left {
grid-area: insight;
}
.sr-right {
grid-area: feedback;
}
.sr-card--chart {
grid-area: chart;
.sr-left > *,
.sr-right > * {
min-width: 0;
}
.sr-card--flow {
grid-area: flow;
/* ── 리뷰 요약 밴드 ── */
.sr-overview {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--sp-3) var(--sp-5);
align-items: start;
}
.sr-card--rubric {
grid-area: rubric;
.sr-overview > :first-child {
grid-column: 1 / -1;
}
.sr-card--teacher-review {
grid-area: teacher;
.sr-overview .sr-summary {
grid-column: 1;
margin: 0;
}
.sr-card--good {
grid-area: good;
.sr-overview .sr-readiness {
grid-column: 2;
grid-row: 2;
border-top: 0;
padding-top: 0;
grid-template-columns: repeat(3, minmax(112px, 148px));
align-self: center;
}
.sr-card--growth {
grid-area: growth;
.sr-overview .sr-actions {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2) var(--sp-3);
padding-top: var(--sp-3);
border-top: 1px solid var(--hair);
}
.sr-card--prepost {
grid-area: prepost;
.sr-overview .sr-actions .vg-btn {
width: auto;
}
.sr-overview .sr-share-note {
flex: 1 1 260px;
margin: 0;
}
.sr-card--worksheet {
grid-area: worksheet;
}
.sr-card--transcript {
grid-area: transcript;
align-self: start;
}
.sr-session-id {
grid-area: session;
@ -243,56 +202,52 @@
align-self: start;
}
@media (min-width: 1181px) {
.sr-cols--supervisor {
grid-template-columns: minmax(0, 1fr) minmax(286px, 340px);
grid-template-areas: "main side";
align-items: start;
}
.sr-cols--supervisor .sr-left {
grid-area: main;
min-width: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-areas:
"overview overview"
"chart flow"
"transcript transcript";
gap: var(--sp-4);
align-items: start;
}
.sr-cols--supervisor .sr-right {
grid-area: side;
min-width: 0;
display: grid;
gap: var(--sp-4);
align-items: start;
}
.sr-cols--supervisor .sr-left > *,
.sr-cols--supervisor .sr-right > * {
grid-area: auto;
}
.sr-cols--supervisor .sr-overview {
grid-area: overview;
position: static;
max-height: none;
overflow: visible;
scrollbar-gutter: auto;
}
.sr-cols--supervisor .sr-card--chart {
grid-area: chart;
}
.sr-cols--supervisor .sr-card--flow {
grid-area: flow;
}
.sr-cols--supervisor .sr-card--transcript {
grid-area: transcript;
align-self: start;
}
/* ── 리뷰 영역 가로 탭 ── */
.sr-tabs {
display: flex;
gap: 6px;
padding: 4px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
background: var(--bg-surface-2);
}
.sr-root--empty .sr-card--side {
padding-top: var(--sp-5);
padding-bottom: var(--sp-5);
.sr-tabs button {
flex: 1 1 0;
min-width: 0;
padding: 9px 12px;
border: 1px solid transparent;
border-radius: calc(var(--radius) - 2px);
background: transparent;
color: var(--text-body);
font-size: var(--fs-sm);
font-weight: 700;
cursor: pointer;
}
.sr-tabs button:hover {
color: var(--text-strong);
}
.sr-tabs button:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 1px;
}
.sr-tabs button.is-active {
background: var(--bg-surface);
border-color: var(--border-strong);
color: var(--text-strong);
box-shadow: var(--shadow-sm);
}
/* 대기 자리표시자는 사이드 레일에서 컴팩트하게 — 큰 빈 박스 금지 */
.sr-right .sr-empty {
gap: 3px;
padding: 10px 12px;
}
.sr-right .sr-empty .vg-empty__title {
font-size: var(--fs-sm);
}
.sr-right .sr-empty .vg-empty__desc {
font-size: var(--fs-xs);
line-height: 1.5;
}
.sr-teacher-review__head {
display: flex;
@ -1493,7 +1448,7 @@
background: linear-gradient(180deg, rgba(13, 24, 22, 0.98), rgba(11, 19, 17, 0.98));
}
[data-theme="dark"] .sr-overview {
border-color: rgba(111, 179, 164, 0.2);
border-color: rgba(125, 162, 148, 0.2);
background: linear-gradient(180deg, rgba(32, 50, 45, 0.94), rgba(18, 31, 28, 0.98));
}
[data-theme="dark"] .sr-card--worksheet {
@ -1533,7 +1488,7 @@
background: rgba(212, 162, 74, 0.12);
}
[data-theme="dark"] .sr-turn--active .sr-turn__said {
background: rgba(111, 179, 164, 0.14);
background: rgba(125, 162, 148, 0.14);
}
[data-theme="dark"] .sr-feedback--filled {
border-color: rgba(203, 227, 220, 0.12);
@ -1541,76 +1496,16 @@
}
@media (max-width: 1180px) {
.sr-cols {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-areas:
"overview overview"
"chart flow"
"rubric rubric"
"good growth"
"transcript transcript"
"prepost prepost"
"feedback feedback"
"worksheet worksheet"
"session session";
}
.sr-root--empty .sr-cols {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-areas:
"overview overview"
"chart flow"
"transcript transcript"
"rubric rubric"
"good growth"
"prepost prepost"
"feedback feedback"
"worksheet worksheet"
"session session";
}
.sr-cols--supervisor {
grid-template-areas:
"overview overview"
"chart flow"
"teacher teacher"
"rubric rubric"
"good growth"
"transcript transcript"
"prepost prepost"
"feedback feedback"
"worksheet worksheet"
"session session";
}
.sr-root--empty .sr-cols--supervisor {
grid-template-areas:
"overview overview"
"chart flow"
"teacher teacher"
"transcript transcript"
"rubric rubric"
"good growth"
"prepost prepost"
"feedback feedback"
"worksheet worksheet"
"session session";
}
.sr-overview {
position: static;
max-height: none;
overflow: visible;
scrollbar-gutter: auto;
grid-template-columns: minmax(0, 1fr);
}
.sr-readiness {
.sr-overview .sr-readiness {
grid-column: 1;
grid-row: auto;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.sr-actions {
display: flex;
flex-wrap: wrap;
}
.sr-actions .vg-btn {
width: auto;
}
.sr-worksheet {
max-height: 520px;
max-height: none;
}
}
@ -1626,67 +1521,19 @@
.sr-head__stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sr-cols {
.sr-cols,
.sr-cols--supervisor,
.sr-root--empty .sr-cols,
.sr-root--empty .sr-cols--supervisor {
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
"overview"
"chart"
"flow"
"rubric"
"good"
"growth"
"prepost"
"transcript"
"insight"
"feedback"
"worksheet"
"session";
gap: var(--sp-4);
}
.sr-root--empty .sr-cols {
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
"overview"
"chart"
"flow"
"transcript"
"rubric"
"good"
"growth"
"prepost"
"feedback"
"worksheet"
"session";
}
.sr-cols--supervisor {
grid-template-areas:
"overview"
"chart"
"flow"
"teacher"
"rubric"
"good"
"growth"
"prepost"
"transcript"
"feedback"
"worksheet"
"session";
}
.sr-root--empty .sr-cols--supervisor {
grid-template-areas:
"overview"
"chart"
"flow"
"teacher"
"transcript"
"rubric"
"good"
"growth"
"prepost"
"feedback"
"worksheet"
"session";
}
.sr-summary {
font-size: 20px;
line-height: 1.45;
@ -1707,7 +1554,7 @@
flex: 1 1 150px;
}
.sr-worksheet {
max-height: 460px;
max-height: none; /* 워크시트 탭이 전용 화면이므로 내부 스크롤 불필요 */
}
.sr-ws-toolbar {
align-items: stretch;
@ -1824,3 +1671,42 @@
transition-duration: 0.01ms;
}
}
/* 2026-07-15 소유자 피드백: 리뷰는 폭에서 축어록/피드백/워크시트 탭을 유지한다.
기존 데스크톱 3컬럼은 정보 밀도보다 시선 이동과 공간이 커서 사용하지 않는다. */
.sr-cols,
.sr-cols--supervisor,
.sr-root--empty .sr-cols,
.sr-root--empty .sr-cols--supervisor {
grid-template-columns: minmax(0, 1fr);
}
.sr-cols--tab-transcript {
grid-template-areas:
"transcript"
"session";
}
.sr-cols--tab-insights {
grid-template-areas:
"insight"
"feedback"
"session";
}
.sr-cols--tab-worksheet {
grid-template-areas:
"worksheet"
"session";
}
.sr-cols:not(.sr-cols--tab-transcript) .sr-card--transcript,
.sr-cols:not(.sr-cols--tab-insights) .sr-left,
.sr-cols:not(.sr-cols--tab-insights) .sr-right,
.sr-cols:not(.sr-cols--tab-worksheet) .sr-card--worksheet {
display: none;
}
.sr-cols--supervisor .sr-left {
grid-template-columns: minmax(0, 1fr);
}