개선관리 요구사항과 Google 로그인을 완료
This commit is contained in:
parent
cc0a15b7c6
commit
2a39636163
112 changed files with 10166 additions and 527 deletions
|
|
@ -471,6 +471,18 @@
|
|||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
/* 관리자처럼 세 역할을 모두 전환할 수 있는 계정도 테마·로그아웃 버튼을
|
||||
밀어내지 않게 한다. 링크 이름은 aria-label/title에 그대로 남는다. */
|
||||
.vg-topbar__switch-link {
|
||||
flex: 0 0 34px;
|
||||
padding: 0;
|
||||
}
|
||||
.vg-topbar__switch-link span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Botanical glass canvas ── */
|
||||
body[data-role] .vg-shell--botanical .vg-shell__body:not(.vg-shell__body--bare) {
|
||||
position: relative;
|
||||
|
|
|
|||
|
|
@ -128,6 +128,58 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/protocols": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List Admin Protocols */
|
||||
get: operations["list_admin_protocols_admin_protocols_get"];
|
||||
put?: never;
|
||||
/** Create Admin Protocol */
|
||||
post: operations["create_admin_protocol_admin_protocols_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/protocols/{protocol_id}/activate": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Activate Admin Protocol */
|
||||
post: operations["activate_admin_protocol_admin_protocols__protocol_id__activate_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/protocols/{protocol_id}/retire": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Retire Admin Protocol */
|
||||
post: operations["retire_admin_protocol_admin_protocols__protocol_id__retire_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/tickets": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -1218,7 +1270,8 @@ export interface paths {
|
|||
* @description 문서 인덱싱(관리자, RBAC ADMIN 강제). content_hash 증분 + 청크 임베딩 적재.
|
||||
*
|
||||
* ⚠️ 임베딩은 무거운 작업 → 본래 BackgroundTasks/배치 워커 위임 권장(202 Accepted).
|
||||
* DSM verbatim 저작권(license C/D)은 source 등록 시점 external_llm_ok 가드 책임.
|
||||
* source_id는 사전 등록된 kb.source만 허용하고, 라이선스와 프로토콜 active 상태는
|
||||
* 요청값이 아닌 DB 행으로 검증한다.
|
||||
* 모델 미가용 시 embedding NULL 폴백(BM25 만, degraded=True) — 크래시 X.
|
||||
*/
|
||||
post: operations["index_document_kb_index_post"];
|
||||
|
|
@ -2573,8 +2626,8 @@ export interface components {
|
|||
};
|
||||
/** ActualTransferExecutionResponse */
|
||||
ActualTransferExecutionResponse: {
|
||||
assessment: components["schemas"]["ActualTransferAssessment"];
|
||||
execution: components["schemas"]["ActualTransferExecution"];
|
||||
assessment?: components["schemas"]["ActualTransferAssessment"] | null;
|
||||
execution?: components["schemas"]["ActualTransferExecution"] | null;
|
||||
/** Idempotent Replay */
|
||||
idempotent_replay: boolean;
|
||||
};
|
||||
|
|
@ -2738,6 +2791,89 @@ export interface components {
|
|||
/** Smtp Configured */
|
||||
smtp_configured: boolean;
|
||||
};
|
||||
/** AdminProtocolActivationResponse */
|
||||
AdminProtocolActivationResponse: {
|
||||
/** Chunks Indexed */
|
||||
chunks_indexed: number;
|
||||
/** Degraded */
|
||||
degraded: boolean;
|
||||
/** Embedded */
|
||||
embedded: boolean;
|
||||
protocol: components["schemas"]["AdminProtocolResponse"];
|
||||
/** Skipped Unchanged */
|
||||
skipped_unchanged: boolean;
|
||||
};
|
||||
/** AdminProtocolCreate */
|
||||
AdminProtocolCreate: {
|
||||
/** Content */
|
||||
content: string;
|
||||
/**
|
||||
* External Llm Ok
|
||||
* @default false
|
||||
*/
|
||||
external_llm_ok: boolean;
|
||||
/**
|
||||
* License
|
||||
* @enum {string}
|
||||
*/
|
||||
license: "A" | "B" | "C" | "D";
|
||||
/** Source */
|
||||
source: string;
|
||||
/** Title */
|
||||
title: string;
|
||||
/**
|
||||
* Version
|
||||
* @default 1
|
||||
*/
|
||||
version: number;
|
||||
};
|
||||
/** AdminProtocolListResponse */
|
||||
AdminProtocolListResponse: {
|
||||
/** Protocols */
|
||||
protocols: components["schemas"]["AdminProtocolResponse"][];
|
||||
/** Total */
|
||||
total: number;
|
||||
};
|
||||
/** AdminProtocolResponse */
|
||||
AdminProtocolResponse: {
|
||||
/** Activated At */
|
||||
activated_at?: string | null;
|
||||
/** Content */
|
||||
content: string;
|
||||
/** Content Hash */
|
||||
content_hash: string;
|
||||
/** External Llm Ok */
|
||||
external_llm_ok: boolean;
|
||||
/**
|
||||
* License
|
||||
* @enum {string}
|
||||
*/
|
||||
license: "A" | "B" | "C" | "D";
|
||||
/** Protocol Id */
|
||||
protocol_id: string;
|
||||
/**
|
||||
* Registered At
|
||||
* Format: date-time
|
||||
*/
|
||||
registered_at: string;
|
||||
/** Registered By */
|
||||
registered_by: string;
|
||||
/** Retired At */
|
||||
retired_at?: string | null;
|
||||
/** Source */
|
||||
source: string;
|
||||
/** Source Id */
|
||||
source_id: string;
|
||||
/**
|
||||
* Status
|
||||
* @enum {string}
|
||||
*/
|
||||
status: "draft" | "active" | "retired";
|
||||
/** Title */
|
||||
title: string;
|
||||
/** Version */
|
||||
version: number;
|
||||
};
|
||||
/** AdminServiceHealth */
|
||||
AdminServiceHealth: {
|
||||
/** Detail */
|
||||
|
|
@ -3073,10 +3209,11 @@ export interface components {
|
|||
AdminUserCreate: {
|
||||
/**
|
||||
* Account Status
|
||||
* @default approved
|
||||
* @default pending
|
||||
* @constant
|
||||
* @enum {string}
|
||||
*/
|
||||
account_status: "pending" | "approved" | "suspended";
|
||||
account_status: "pending";
|
||||
/**
|
||||
* Admin Access
|
||||
* @default false
|
||||
|
|
@ -3090,6 +3227,11 @@ export interface components {
|
|||
display_name: string;
|
||||
/** Email */
|
||||
email: string;
|
||||
/**
|
||||
* Learner Feedback Enabled
|
||||
* @default true
|
||||
*/
|
||||
learner_feedback_enabled: boolean;
|
||||
/**
|
||||
* Role
|
||||
* @default learner
|
||||
|
|
@ -3116,6 +3258,8 @@ export interface components {
|
|||
cohort_ids?: string[] | null;
|
||||
/** Display Name */
|
||||
display_name?: string | null;
|
||||
/** Learner Feedback Enabled */
|
||||
learner_feedback_enabled?: boolean | null;
|
||||
/** Role */
|
||||
role?: ("learner" | "teacher" | "admin") | null;
|
||||
};
|
||||
|
|
@ -3142,6 +3286,8 @@ export interface components {
|
|||
email: string;
|
||||
/** Last Seen At */
|
||||
last_seen_at: number;
|
||||
/** Learner Feedback Enabled */
|
||||
learner_feedback_enabled: boolean;
|
||||
/**
|
||||
* Role
|
||||
* @enum {string}
|
||||
|
|
@ -5881,6 +6027,58 @@ export interface components {
|
|||
* @default runtime
|
||||
*/
|
||||
source: string;
|
||||
training_exposure?: components["schemas"]["LearnerDashboardTrainingExposure"];
|
||||
};
|
||||
/** LearnerDashboardTrainingExposure */
|
||||
LearnerDashboardTrainingExposure: {
|
||||
/**
|
||||
* Attention Threshold
|
||||
* @default 0.75
|
||||
*/
|
||||
attention_threshold: number;
|
||||
/**
|
||||
* Completed Sessions
|
||||
* @default 0
|
||||
*/
|
||||
completed_sessions: number;
|
||||
/**
|
||||
* Definition
|
||||
* @default 종료 회기의 페르소나별 최다 노출 비중을 보여 주는 투명한 훈련 노출 지표이며, 공정성 평가나 임상진단이 아닙니다.
|
||||
*/
|
||||
definition: string;
|
||||
/** Dominant Persona Code */
|
||||
dominant_persona_code?: string | null;
|
||||
/** Dominant Persona Name */
|
||||
dominant_persona_name?: string | null;
|
||||
/**
|
||||
* Dominant Sessions
|
||||
* @default 0
|
||||
*/
|
||||
dominant_sessions: number;
|
||||
/** Dominant Share */
|
||||
dominant_share?: number | null;
|
||||
/**
|
||||
* Label
|
||||
* @default 판정 근거 부족
|
||||
* @enum {string}
|
||||
*/
|
||||
label: "판정 근거 부족" | "훈련 집중 주의" | "균형";
|
||||
/**
|
||||
* Minimum Completed Sessions
|
||||
* @default 4
|
||||
*/
|
||||
minimum_completed_sessions: number;
|
||||
/**
|
||||
* Status
|
||||
* @default insufficient
|
||||
* @enum {string}
|
||||
*/
|
||||
status: "insufficient" | "attention" | "balanced";
|
||||
/**
|
||||
* Version
|
||||
* @default training-exposure-dominant-share.v1
|
||||
*/
|
||||
version: string;
|
||||
};
|
||||
/** LearnerRefMapping */
|
||||
LearnerRefMapping: {
|
||||
|
|
@ -5905,6 +6103,11 @@ export interface components {
|
|||
client_turn_count: number;
|
||||
/** Ended At */
|
||||
ended_at?: string | null;
|
||||
/**
|
||||
* Learner Feedback Enabled
|
||||
* @default true
|
||||
*/
|
||||
learner_feedback_enabled: boolean;
|
||||
/** Learner Turn Count */
|
||||
learner_turn_count: number;
|
||||
/** Persona Code */
|
||||
|
|
@ -8678,6 +8881,62 @@ export interface components {
|
|||
/** Persona */
|
||||
persona: string;
|
||||
};
|
||||
/** ReviewFirstSessionChecklist */
|
||||
ReviewFirstSessionChecklist: {
|
||||
/**
|
||||
* Applicable
|
||||
* @default true
|
||||
*/
|
||||
applicable: boolean;
|
||||
/** Criteria */
|
||||
criteria?: components["schemas"]["ReviewFirstSessionChecklistCriterion"][];
|
||||
/**
|
||||
* Note
|
||||
* @default 축어록에서 관찰 가능한 대화 행동을 규칙 기반으로 점검하며, 임상 평가나 성적 판정이 아닙니다.
|
||||
*/
|
||||
note: string;
|
||||
/**
|
||||
* Status
|
||||
* @default ready
|
||||
* @enum {string}
|
||||
*/
|
||||
status: "ready" | "not_applicable";
|
||||
/**
|
||||
* Title
|
||||
* @default 첫 회기 라포·개방질문 체크리스트
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Version
|
||||
* @default first-session-rapport-open-question.v1
|
||||
*/
|
||||
version: string;
|
||||
};
|
||||
/** ReviewFirstSessionChecklistCriterion */
|
||||
ReviewFirstSessionChecklistCriterion: {
|
||||
/** Criterionid */
|
||||
criterionId: string;
|
||||
/** Description */
|
||||
description: string;
|
||||
/** Evidenceturns */
|
||||
evidenceTurns?: components["schemas"]["ReviewFirstSessionChecklistEvidence"][];
|
||||
/** Label */
|
||||
label: string;
|
||||
/**
|
||||
* Status
|
||||
* @enum {string}
|
||||
*/
|
||||
status: "met" | "not_observed";
|
||||
};
|
||||
/** ReviewFirstSessionChecklistEvidence */
|
||||
ReviewFirstSessionChecklistEvidence: {
|
||||
/** Quote */
|
||||
quote: string;
|
||||
/** Turnid */
|
||||
turnId: string;
|
||||
/** Turnseq */
|
||||
turnSeq: number;
|
||||
};
|
||||
/** ReviewNonverbalEvent */
|
||||
ReviewNonverbalEvent: {
|
||||
/** Detail */
|
||||
|
|
@ -9097,10 +9356,19 @@ export interface components {
|
|||
ended_at?: string | null;
|
||||
/** Goal Stages */
|
||||
goal_stages?: ("라포" | "탐색" | "개입" | "정리")[];
|
||||
/**
|
||||
* Learner Feedback Enabled
|
||||
* @default true
|
||||
*/
|
||||
learner_feedback_enabled: boolean;
|
||||
/** Persona Code */
|
||||
persona_code: string;
|
||||
/** Persona Id */
|
||||
persona_id?: string | null;
|
||||
/** Persona Name */
|
||||
persona_name: string;
|
||||
/** Persona Version */
|
||||
persona_version?: number | null;
|
||||
progress?: components["schemas"]["SessionProgress"] | null;
|
||||
/**
|
||||
* Review Ready
|
||||
|
|
@ -9260,10 +9528,16 @@ export interface components {
|
|||
durationLabel: string;
|
||||
/** Durationseconds */
|
||||
durationSeconds: number;
|
||||
firstSessionChecklist?: components["schemas"]["ReviewFirstSessionChecklist"] | null;
|
||||
/** Goodmoments */
|
||||
goodMoments?: components["schemas"]["ReviewPoint"][];
|
||||
/** Growthpoints */
|
||||
growthPoints?: components["schemas"]["ReviewPoint"][];
|
||||
/**
|
||||
* Learnerfeedbackenabled
|
||||
* @default true
|
||||
*/
|
||||
learnerFeedbackEnabled: boolean;
|
||||
/** Nextline */
|
||||
nextLine?: string | null;
|
||||
/** Pdfexporturl */
|
||||
|
|
@ -9284,6 +9558,8 @@ export interface components {
|
|||
reviewReady: boolean;
|
||||
/** Rubric */
|
||||
rubric?: components["schemas"]["ReviewRubricRow"][];
|
||||
/** Sessionno */
|
||||
sessionNo: number;
|
||||
/** Sessionsignal */
|
||||
sessionSignal: string;
|
||||
/** Session Id */
|
||||
|
|
@ -9378,6 +9654,15 @@ export interface components {
|
|||
effective_openness: number;
|
||||
/** Goal Stages */
|
||||
goal_stages?: ("라포" | "탐색" | "개입" | "정리")[];
|
||||
/**
|
||||
* Learner Feedback Enabled
|
||||
* @default true
|
||||
*/
|
||||
learner_feedback_enabled: boolean;
|
||||
/** Persona Id */
|
||||
persona_id: string;
|
||||
/** Persona Version */
|
||||
persona_version: number;
|
||||
/** Recall Summary */
|
||||
recall_summary?: string | null;
|
||||
/** Session Id */
|
||||
|
|
@ -11244,6 +11529,145 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
list_admin_protocols_admin_protocols_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
status?: ("draft" | "active" | "retired") | null;
|
||||
search?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: {
|
||||
"__Host-vignette_sid"?: string | null;
|
||||
vignette_sid?: string | null;
|
||||
};
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AdminProtocolListResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
create_admin_protocol_admin_protocols_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: {
|
||||
"__Host-vignette_sid"?: string | null;
|
||||
vignette_sid?: string | null;
|
||||
};
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["AdminProtocolCreate"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AdminProtocolResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
activate_admin_protocol_admin_protocols__protocol_id__activate_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
protocol_id: string;
|
||||
};
|
||||
cookie?: {
|
||||
"__Host-vignette_sid"?: string | null;
|
||||
vignette_sid?: string | null;
|
||||
};
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AdminProtocolActivationResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
retire_admin_protocol_admin_protocols__protocol_id__retire_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
protocol_id: string;
|
||||
};
|
||||
cookie?: {
|
||||
"__Host-vignette_sid"?: string | null;
|
||||
vignette_sid?: string | null;
|
||||
};
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AdminProtocolResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_tickets_admin_tickets_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
|
@ -11841,7 +12265,9 @@ export interface operations {
|
|||
};
|
||||
get_my_calibration_transfer_calibration_learners_me_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
session_id?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: {
|
||||
|
|
@ -14361,7 +14787,9 @@ export interface operations {
|
|||
};
|
||||
get_multimodal_session_metadata_sessions__session_id__multimodal_alliance_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
include_derived?: boolean;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
session_id: string;
|
||||
|
|
|
|||
|
|
@ -252,6 +252,7 @@ export type LearnerDashboardResponse = ApiSchema<"LearnerDashboardResponse">;
|
|||
export type LearnerDashboardPersonaProgress = ApiSchema<"LearnerDashboardPersonaProgress">;
|
||||
export type LearnerDashboardAchievement = ApiSchema<"LearnerDashboardAchievement">;
|
||||
export type LearnerDashboardFeedbackItem = ApiSchema<"LearnerDashboardFeedbackItem">;
|
||||
export type LearnerDashboardTrainingExposure = ApiSchema<"LearnerDashboardTrainingExposure">;
|
||||
|
||||
export type SessionDetailTurn = ApiSchema<"SessionDetailTurn">;
|
||||
|
||||
|
|
@ -274,6 +275,9 @@ export type ReviewWorksheetItem = ApiSchema<"ReviewWorksheetItem">;
|
|||
export type ReviewWorksheetSection = ApiSchema<"ReviewWorksheetSection-Output">;
|
||||
export type ReviewCaseWorksheet = ApiSchema<"ReviewCaseWorksheet">;
|
||||
export type ReviewCaseWorksheetSaveRequest = ApiSchema<"ReviewCaseWorksheetSaveRequest">;
|
||||
export type ReviewFirstSessionChecklistEvidence = ApiSchema<"ReviewFirstSessionChecklistEvidence">;
|
||||
export type ReviewFirstSessionChecklistCriterion = ApiSchema<"ReviewFirstSessionChecklistCriterion">;
|
||||
export type ReviewFirstSessionChecklist = ApiSchema<"ReviewFirstSessionChecklist">;
|
||||
export type SessionReviewResponse = ApiSchema<"SessionReviewResponse">;
|
||||
export type SessionShareResponse = ApiSchema<"SessionShareResponse">;
|
||||
export type SessionShareDeleteResponse = ApiSchema<"SessionShareDeleteResponse">;
|
||||
|
|
@ -441,7 +445,12 @@ export async function openSessionStream(
|
|||
processBuffer(true);
|
||||
|
||||
if (streamError) throw streamError;
|
||||
return donePayload ?? { session_id: sessionId };
|
||||
if (!donePayload) {
|
||||
const detail = "client_stream_incomplete";
|
||||
handlers.onError?.({ detail });
|
||||
throw new ApiError(503, detail, { code: detail });
|
||||
}
|
||||
return donePayload;
|
||||
}
|
||||
|
||||
/* === 세션 API 헬퍼 (Features 단계 Session 페이지가 사용) === */
|
||||
|
|
@ -643,6 +652,34 @@ export const adminUsersApi = {
|
|||
}),
|
||||
};
|
||||
|
||||
export type AdminProtocol = ApiSchema<"AdminProtocolResponse">;
|
||||
export type AdminProtocolCreateRequest = ApiSchema<"AdminProtocolCreate">;
|
||||
export type AdminProtocolListResponse = ApiSchema<"AdminProtocolListResponse">;
|
||||
export type AdminProtocolActivationResponse = ApiSchema<"AdminProtocolActivationResponse">;
|
||||
export type AdminProtocolStatus = AdminProtocol["status"];
|
||||
|
||||
export const adminProtocolsApi = {
|
||||
list: (filters: { status?: AdminProtocolStatus | ""; search?: string } = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.status) params.set("status", filters.status);
|
||||
if (filters.search?.trim()) params.set("search", filters.search.trim());
|
||||
const query = params.toString();
|
||||
return api.get<AdminProtocolListResponse>(`/admin/protocols${query ? `?${query}` : ""}`);
|
||||
},
|
||||
create: (body: AdminProtocolCreateRequest) =>
|
||||
api.post<AdminProtocol>("/admin/protocols", body),
|
||||
activate: (protocolId: string) =>
|
||||
api.post<AdminProtocolActivationResponse>(
|
||||
`/admin/protocols/${encodeURIComponent(protocolId)}/activate`,
|
||||
{},
|
||||
),
|
||||
retire: (protocolId: string) =>
|
||||
api.post<AdminProtocol>(
|
||||
`/admin/protocols/${encodeURIComponent(protocolId)}/retire`,
|
||||
{},
|
||||
),
|
||||
};
|
||||
|
||||
export type TeacherSessionSummary = ApiSchema<"TeacherSessionSummary">;
|
||||
|
||||
export type TeacherSafetyAlert = ApiSchema<"TeacherSafetyAlert">;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
const DISPLAY_PLACEHOLDERS: Record<string, string> = {
|
||||
"[NAME]": "익명 내담자",
|
||||
"[COUNSELOR]": "상담자",
|
||||
"[CLIENT]": "내담자",
|
||||
"[NAME]": "익명 인물",
|
||||
"[ORG]": "소속 기관",
|
||||
"[PHONE]": "연락처",
|
||||
"[EMAIL]": "이메일",
|
||||
|
|
|
|||
|
|
@ -29,10 +29,14 @@ import {
|
|||
} from "../components/ui";
|
||||
import {
|
||||
adminApi,
|
||||
adminProtocolsApi,
|
||||
adminUsersApi,
|
||||
type AdminHealthResponse,
|
||||
type AdminHealthStatus,
|
||||
type AdminManagedUser,
|
||||
type AdminProtocol,
|
||||
type AdminProtocolCreateRequest,
|
||||
type AdminProtocolStatus,
|
||||
type AdminTicketFilters,
|
||||
type AdminSupportTicket,
|
||||
type AdminTicketsResponse,
|
||||
|
|
@ -58,6 +62,7 @@ type UserDraft = Pick<
|
|||
| "display_name"
|
||||
| "role"
|
||||
| "admin_access"
|
||||
| "learner_feedback_enabled"
|
||||
| "account_status"
|
||||
| "affiliation"
|
||||
| "cohort_ids"
|
||||
|
|
@ -67,10 +72,17 @@ type NewUserDraft = Required<
|
|||
> &
|
||||
Pick<
|
||||
UserDraft,
|
||||
"admin_access" | "account_status" | "affiliation" | "cohort_ids"
|
||||
>;
|
||||
| "admin_access"
|
||||
| "learner_feedback_enabled"
|
||||
| "affiliation"
|
||||
| "cohort_ids"
|
||||
> & {
|
||||
account_status: "pending";
|
||||
};
|
||||
type UserTab = "approval" | "manage" | "register" | "activity";
|
||||
type AccessTab = "roles" | "groups" | "matrix";
|
||||
type AccessTab = "roles" | "groups" | "matrix" | "protocols";
|
||||
type ProtocolStatusFilter = AdminProtocolStatus | "all";
|
||||
type ProtocolDraft = AdminProtocolCreateRequest;
|
||||
type TicketStatusFilter = AdminSupportTicket["status"] | "all";
|
||||
type TicketCategoryFilter = AdminSupportTicket["category"] | "all";
|
||||
type TicketPriorityFilter = AdminSupportTicket["priority"] | "all";
|
||||
|
|
@ -116,11 +128,46 @@ const EMPTY_NEW_USER: NewUserDraft = {
|
|||
display_name: "",
|
||||
role: "learner",
|
||||
admin_access: false,
|
||||
account_status: "approved",
|
||||
learner_feedback_enabled: true,
|
||||
account_status: "pending",
|
||||
affiliation: "",
|
||||
cohort_ids: [],
|
||||
};
|
||||
|
||||
const EMPTY_PROTOCOL_DRAFT: ProtocolDraft = {
|
||||
title: "",
|
||||
source: "",
|
||||
version: 1,
|
||||
license: "B",
|
||||
external_llm_ok: false,
|
||||
content: "",
|
||||
};
|
||||
|
||||
function protocolStatusLabel(status: AdminProtocolStatus): string {
|
||||
if (status === "draft") return "초안";
|
||||
if (status === "active") return "활성";
|
||||
return "퇴역";
|
||||
}
|
||||
|
||||
function protocolStatusTone(
|
||||
status: AdminProtocolStatus,
|
||||
): "warn" | "pos" | "neutral" {
|
||||
if (status === "draft") return "warn";
|
||||
if (status === "active") return "pos";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function protocolAllowsExternalLlm(license: ProtocolDraft["license"]): boolean {
|
||||
return license === "A" || license === "B";
|
||||
}
|
||||
|
||||
function protocolVersionError(version: number): string | null {
|
||||
if (!Number.isInteger(version) || version < 1 || version > 1_000_000) {
|
||||
return "버전은 1부터 1,000,000 사이의 정수여야 합니다.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const ROLE_POLICIES = [
|
||||
{
|
||||
role: "관리자",
|
||||
|
|
@ -516,6 +563,7 @@ function userDraftFrom(user: AdminManagedUser): UserDraft {
|
|||
display_name: user.display_name,
|
||||
role: user.role,
|
||||
admin_access: user.admin_access,
|
||||
learner_feedback_enabled: user.learner_feedback_enabled ?? true,
|
||||
account_status: user.account_status,
|
||||
affiliation: user.affiliation,
|
||||
cohort_ids: user.cohort_ids,
|
||||
|
|
@ -827,6 +875,18 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
const [ticketStaleOnly, setTicketStaleOnly] = useState(false);
|
||||
const [userTab, setUserTab] = useState<UserTab>("approval");
|
||||
const [accessTab, setAccessTab] = useState<AccessTab>("roles");
|
||||
const [protocols, setProtocols] = useState<AdminProtocol[]>([]);
|
||||
const [protocolsLoaded, setProtocolsLoaded] = useState(false);
|
||||
const [protocolsLoading, setProtocolsLoading] = useState(false);
|
||||
const [protocolsError, setProtocolsError] = useState<string | null>(null);
|
||||
const [protocolDraft, setProtocolDraft] = useState<ProtocolDraft>(
|
||||
EMPTY_PROTOCOL_DRAFT,
|
||||
);
|
||||
const [protocolSaving, setProtocolSaving] = useState(false);
|
||||
const [protocolActionId, setProtocolActionId] = useState<string | null>(null);
|
||||
const [protocolSearch, setProtocolSearch] = useState("");
|
||||
const [protocolStatusFilter, setProtocolStatusFilter] =
|
||||
useState<ProtocolStatusFilter>("all");
|
||||
const canGrantAdminAccess = currentUser?.superAdmin === true;
|
||||
|
||||
const loadHealth = useCallback(async () => {
|
||||
|
|
@ -940,6 +1000,24 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
ticketStatusFilter,
|
||||
]);
|
||||
|
||||
const loadProtocols = useCallback(async () => {
|
||||
setProtocolsLoading(true);
|
||||
setProtocolsError(null);
|
||||
try {
|
||||
const response = await adminProtocolsApi.list();
|
||||
setProtocols(response.protocols);
|
||||
setProtocolsLoaded(true);
|
||||
} catch (err) {
|
||||
setProtocolsError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "상담 프로토콜 목록을 불러오지 못했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setProtocolsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadHealth();
|
||||
void loadUsers();
|
||||
|
|
@ -951,6 +1029,12 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
void loadTickets();
|
||||
}, [loadTickets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (section === "access" && accessTab === "protocols" && !protocolsLoaded) {
|
||||
void loadProtocols();
|
||||
}
|
||||
}, [accessTab, loadProtocols, protocolsLoaded, section]);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
await Promise.all([
|
||||
loadHealth(),
|
||||
|
|
@ -961,6 +1045,109 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
]);
|
||||
}, [loadHealth, loadTickets, loadUptime, loadUsage, loadUsers]);
|
||||
|
||||
const visibleProtocols = useMemo(() => {
|
||||
const query = protocolSearch.trim().toLocaleLowerCase("ko-KR");
|
||||
return protocols.filter((protocol) => {
|
||||
if (
|
||||
protocolStatusFilter !== "all" &&
|
||||
protocol.status !== protocolStatusFilter
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!query) return true;
|
||||
return [protocol.title, protocol.source, protocol.source_id]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase("ko-KR")
|
||||
.includes(query);
|
||||
});
|
||||
}, [protocolSearch, protocolStatusFilter, protocols]);
|
||||
|
||||
const createProtocol = async () => {
|
||||
if (
|
||||
!protocolDraft.title.trim() ||
|
||||
!protocolDraft.source.trim() ||
|
||||
!protocolDraft.content.trim()
|
||||
) {
|
||||
setProtocolsError("제목, 출처, 원문을 모두 입력해야 합니다.");
|
||||
return;
|
||||
}
|
||||
const versionError = protocolVersionError(protocolDraft.version);
|
||||
if (versionError) {
|
||||
setProtocolsError(versionError);
|
||||
return;
|
||||
}
|
||||
setProtocolSaving(true);
|
||||
setProtocolsError(null);
|
||||
try {
|
||||
const created = await adminProtocolsApi.create({
|
||||
...protocolDraft,
|
||||
title: protocolDraft.title.trim(),
|
||||
source: protocolDraft.source.trim(),
|
||||
content: protocolDraft.content.trim(),
|
||||
external_llm_ok:
|
||||
protocolAllowsExternalLlm(protocolDraft.license)
|
||||
? protocolDraft.external_llm_ok
|
||||
: false,
|
||||
});
|
||||
setProtocols((current) => [
|
||||
created,
|
||||
...current.filter((item) => item.protocol_id !== created.protocol_id),
|
||||
]);
|
||||
setProtocolDraft(EMPTY_PROTOCOL_DRAFT);
|
||||
setProtocolsLoaded(true);
|
||||
} catch (err) {
|
||||
setProtocolsError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "상담 프로토콜 초안을 등록하지 못했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setProtocolSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const activateProtocol = async (protocol: AdminProtocol) => {
|
||||
setProtocolActionId(protocol.protocol_id);
|
||||
setProtocolsError(null);
|
||||
try {
|
||||
const response = await adminProtocolsApi.activate(protocol.protocol_id);
|
||||
setProtocols((current) =>
|
||||
current.map((item) =>
|
||||
item.protocol_id === protocol.protocol_id ? response.protocol : item,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
setProtocolsError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: `“${protocol.title}” 프로토콜을 활성화하지 못했습니다.`,
|
||||
);
|
||||
} finally {
|
||||
setProtocolActionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const retireProtocol = async (protocol: AdminProtocol) => {
|
||||
setProtocolActionId(protocol.protocol_id);
|
||||
setProtocolsError(null);
|
||||
try {
|
||||
const retired = await adminProtocolsApi.retire(protocol.protocol_id);
|
||||
setProtocols((current) =>
|
||||
current.map((item) =>
|
||||
item.protocol_id === protocol.protocol_id ? retired : item,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
setProtocolsError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: `“${protocol.title}” 프로토콜을 퇴역하지 못했습니다.`,
|
||||
);
|
||||
} finally {
|
||||
setProtocolActionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const updateDraft = (userId: string, patch: Partial<UserDraft>) => {
|
||||
setUserDrafts((current) => ({
|
||||
...current,
|
||||
|
|
@ -969,6 +1156,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
display_name: "",
|
||||
role: "learner",
|
||||
admin_access: false,
|
||||
learner_feedback_enabled: true,
|
||||
account_status: "approved",
|
||||
affiliation: "",
|
||||
cohort_ids: [],
|
||||
|
|
@ -1017,6 +1205,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
display_name: displayName,
|
||||
affiliation: newUser.affiliation.trim(),
|
||||
cohort_ids: newUser.cohort_ids,
|
||||
account_status: "pending",
|
||||
});
|
||||
setNewUser(EMPTY_NEW_USER);
|
||||
await loadUsers();
|
||||
|
|
@ -1388,6 +1577,31 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "learner_feedback_enabled",
|
||||
accessorFn: (user) => Number(user.learner_feedback_enabled),
|
||||
header: "AI 피드백",
|
||||
cell: ({ row }) => {
|
||||
const user = row.original;
|
||||
const draft = userDrafts[user.user_id] ?? user;
|
||||
return (
|
||||
<label className="vgops-user-table__check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.learner_feedback_enabled}
|
||||
onChange={(event) =>
|
||||
updateDraft(user.user_id, {
|
||||
learner_feedback_enabled: event.target.checked,
|
||||
})
|
||||
}
|
||||
disabled={!usersWritable || savingUserId === user.user_id}
|
||||
aria-label={`${user.email} 학습자 AI 피드백`}
|
||||
/>
|
||||
<span>{draft.learner_feedback_enabled ? "켜짐" : "꺼짐"}</span>
|
||||
</label>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "affiliation",
|
||||
accessorFn: (user) => user.affiliation,
|
||||
|
|
@ -1465,6 +1679,8 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
draft.display_name !== user.display_name ||
|
||||
draft.role !== user.role ||
|
||||
draft.admin_access !== user.admin_access ||
|
||||
draft.learner_feedback_enabled !==
|
||||
(user.learner_feedback_enabled ?? true) ||
|
||||
draft.account_status !== user.account_status ||
|
||||
draft.affiliation !== user.affiliation ||
|
||||
cohortInputValue(draft.cohort_ids) !==
|
||||
|
|
@ -1868,7 +2084,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
`가입 승인${accountCounts.pending ? ` ${accountCounts.pending}` : ""}`,
|
||||
],
|
||||
["manage", "사용자 목록"],
|
||||
["register", "사용자 등록"],
|
||||
["register", "외부 연구참여자 사전등록"],
|
||||
["activity", "활동 요약"],
|
||||
]}
|
||||
value={userTab}
|
||||
|
|
@ -2012,12 +2228,13 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
const renderUserCreate = () => (
|
||||
<section className={surfaceClassName("vgops-panel")}>
|
||||
<div className="vgops-section__head">
|
||||
<h2>새 사용자 등록</h2>
|
||||
<h2>외부 연구참여자 사전등록</h2>
|
||||
<span>{usersWritable ? "DB 저장 가능" : "읽기 전용"}</span>
|
||||
</div>
|
||||
<div className="vgops-users-note">
|
||||
역할과 코호트는 접근 범위를 결정합니다. 허용 도메인 밖 이메일은 정확히
|
||||
등록된 계정만 로그인 가능합니다.
|
||||
허용 도메인 밖 참여자는 관리자가 입력한 정확한 이메일 주소로만 Google·개발
|
||||
로그인을 통과합니다. 사전등록 계정은 승인 대기 상태이며, 승인 전에는 학습
|
||||
기능에 접근할 수 없습니다.
|
||||
</div>
|
||||
{!usersWritable ? (
|
||||
<div className="vgops-users-note vgops-users-note--warn" role="alert">
|
||||
|
|
@ -2034,6 +2251,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
<label>
|
||||
<span>이메일</span>
|
||||
<input
|
||||
type="email"
|
||||
value={newUser.email}
|
||||
onChange={(event) =>
|
||||
setNewUser((current) => ({
|
||||
|
|
@ -2041,7 +2259,8 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
email: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="name@example.com"
|
||||
placeholder="participant@example.com"
|
||||
autoComplete="email"
|
||||
disabled={!usersWritable || creatingUser}
|
||||
aria-label="새 사용자 이메일"
|
||||
/>
|
||||
|
|
@ -2100,24 +2319,30 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
/>
|
||||
<span>관리자 페이지 권한</span>
|
||||
</label>
|
||||
<label>
|
||||
<span>승인 상태</span>
|
||||
<select
|
||||
value={newUser.account_status}
|
||||
<label className="vgops-checkline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newUser.learner_feedback_enabled}
|
||||
onChange={(event) =>
|
||||
setNewUser((current) => ({
|
||||
...current,
|
||||
account_status: event.target
|
||||
.value as AdminManagedUser["account_status"],
|
||||
learner_feedback_enabled: event.target.checked,
|
||||
}))
|
||||
}
|
||||
disabled={!usersWritable || creatingUser}
|
||||
aria-label="새 사용자 학습자 AI 피드백"
|
||||
/>
|
||||
<span>학습자 AI 피드백</span>
|
||||
</label>
|
||||
<label>
|
||||
<span>승인 상태</span>
|
||||
<output
|
||||
className="vgops-readonly-control"
|
||||
aria-label="새 사용자 승인 상태"
|
||||
data-value="pending"
|
||||
>
|
||||
<option value="approved">승인됨</option>
|
||||
<option value="pending">승인 대기</option>
|
||||
<option value="suspended">보류</option>
|
||||
</select>
|
||||
승인 대기
|
||||
</output>
|
||||
</label>
|
||||
<label>
|
||||
<span>코호트</span>
|
||||
|
|
@ -2154,7 +2379,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
leading={<Icon name="users" size={15} />}
|
||||
disabled={!usersWritable || creatingUser}
|
||||
>
|
||||
{creatingUser ? "등록 중" : "사용자 등록"}
|
||||
{creatingUser ? "사전등록 중" : "연구참여자 사전등록"}
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
|
|
@ -2354,23 +2579,303 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
</section>
|
||||
);
|
||||
|
||||
const renderProtocols = () => (
|
||||
<section className="vgops-protocols" aria-label="상담 프로토콜 관리">
|
||||
<section className={surfaceClassName("vgops-panel vgops-protocol-create") }>
|
||||
<div className="vgops-section__head">
|
||||
<h2>상담 프로토콜 초안 등록</h2>
|
||||
<Badge tone="neutral">평가자 전용</Badge>
|
||||
</div>
|
||||
<p className="vgops-protocol-intro">
|
||||
초안은 활성화할 때만 RAG에 색인되며, 라이선스 C/D 원문은 외부 LLM에
|
||||
전달할 수 없습니다.
|
||||
</p>
|
||||
<form
|
||||
className="vgops-protocol-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void createProtocol();
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
<span>제목</span>
|
||||
<input
|
||||
required
|
||||
value={protocolDraft.title}
|
||||
onChange={(event) =>
|
||||
setProtocolDraft((current) => ({
|
||||
...current,
|
||||
title: event.target.value,
|
||||
}))
|
||||
}
|
||||
aria-label="프로토콜 제목"
|
||||
disabled={protocolSaving}
|
||||
/>
|
||||
</label>
|
||||
<label className="vgops-protocol-form__source">
|
||||
<span>출처</span>
|
||||
<input
|
||||
required
|
||||
value={protocolDraft.source}
|
||||
onChange={(event) =>
|
||||
setProtocolDraft((current) => ({
|
||||
...current,
|
||||
source: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="공식 문서 URL 또는 출처 식별자"
|
||||
aria-label="프로토콜 출처"
|
||||
disabled={protocolSaving}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>버전</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1_000_000}
|
||||
required
|
||||
value={protocolDraft.version}
|
||||
onChange={(event) =>
|
||||
setProtocolDraft((current) => ({
|
||||
...current,
|
||||
version: Number(event.target.value),
|
||||
}))
|
||||
}
|
||||
aria-label="프로토콜 버전"
|
||||
aria-invalid={protocolVersionError(protocolDraft.version) !== null}
|
||||
aria-describedby="protocol-version-error"
|
||||
disabled={protocolSaving}
|
||||
/>
|
||||
{protocolVersionError(protocolDraft.version) ? (
|
||||
<small
|
||||
id="protocol-version-error"
|
||||
className="vgops-protocol-field-error"
|
||||
role="alert"
|
||||
>
|
||||
{protocolVersionError(protocolDraft.version)}
|
||||
</small>
|
||||
) : null}
|
||||
</label>
|
||||
<label>
|
||||
<span>라이선스</span>
|
||||
<select
|
||||
value={protocolDraft.license}
|
||||
onChange={(event) => {
|
||||
const license = event.target.value as ProtocolDraft["license"];
|
||||
setProtocolDraft((current) => ({
|
||||
...current,
|
||||
license,
|
||||
external_llm_ok:
|
||||
protocolAllowsExternalLlm(license)
|
||||
? current.external_llm_ok
|
||||
: false,
|
||||
}));
|
||||
}}
|
||||
aria-label="프로토콜 라이선스"
|
||||
disabled={protocolSaving}
|
||||
>
|
||||
{(["A", "B", "C", "D"] as const).map((license) => (
|
||||
<option value={license} key={license}>
|
||||
{license}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="vgops-protocol-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={protocolDraft.external_llm_ok}
|
||||
onChange={(event) =>
|
||||
setProtocolDraft((current) => ({
|
||||
...current,
|
||||
external_llm_ok: event.target.checked,
|
||||
}))
|
||||
}
|
||||
aria-label="외부 LLM 사용 허용"
|
||||
disabled={
|
||||
protocolSaving ||
|
||||
protocolDraft.license === "C" ||
|
||||
protocolDraft.license === "D"
|
||||
}
|
||||
/>
|
||||
<span>외부 LLM 사용 허용</span>
|
||||
</label>
|
||||
<p className="vgops-protocol-policy" aria-live="polite">
|
||||
{protocolDraft.license === "C" || protocolDraft.license === "D"
|
||||
? "라이선스 C/D는 정책상 외부 LLM 사용이 차단됩니다."
|
||||
: "A/B도 명시적으로 허용한 경우에만 외부 LLM에 전달됩니다."}
|
||||
</p>
|
||||
<label className="vgops-protocol-form__content">
|
||||
<span>프로토콜 원문</span>
|
||||
<textarea
|
||||
required
|
||||
value={protocolDraft.content}
|
||||
onChange={(event) =>
|
||||
setProtocolDraft((current) => ({
|
||||
...current,
|
||||
content: event.target.value,
|
||||
}))
|
||||
}
|
||||
aria-label="프로토콜 원문"
|
||||
disabled={protocolSaving}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
className="vgops-protocol-submit"
|
||||
type="submit"
|
||||
disabled={protocolSaving}
|
||||
leading={<Icon name="plus" size={16} />}
|
||||
>
|
||||
{protocolSaving ? "초안 등록 중" : "초안 등록"}
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className={surfaceClassName("vgops-panel vgops-protocol-list") }>
|
||||
<div className="vgops-section__head">
|
||||
<h2>등록 프로토콜</h2>
|
||||
<span>{protocols.length}개</span>
|
||||
</div>
|
||||
<div className="vgops-protocol-toolbar">
|
||||
<label>
|
||||
<span>검색</span>
|
||||
<input
|
||||
type="search"
|
||||
value={protocolSearch}
|
||||
onChange={(event) => setProtocolSearch(event.target.value)}
|
||||
placeholder="제목 또는 출처"
|
||||
aria-label="프로토콜 검색"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>상태</span>
|
||||
<select
|
||||
value={protocolStatusFilter}
|
||||
onChange={(event) =>
|
||||
setProtocolStatusFilter(event.target.value as ProtocolStatusFilter)
|
||||
}
|
||||
aria-label="프로토콜 상태 필터"
|
||||
>
|
||||
<option value="all">전체</option>
|
||||
<option value="draft">초안</option>
|
||||
<option value="active">활성</option>
|
||||
<option value="retired">퇴역</option>
|
||||
</select>
|
||||
</label>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void loadProtocols()}
|
||||
disabled={protocolsLoading}
|
||||
>
|
||||
{protocolsLoading ? "불러오는 중" : "새로고침"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{protocolsError ? (
|
||||
<div className="vgops-protocol-error">
|
||||
<InlineError message={protocolsError} />
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void loadProtocols()}
|
||||
disabled={protocolsLoading}
|
||||
>
|
||||
목록 다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{protocolsLoading && !protocolsLoaded ? (
|
||||
<div className="vgops-users-note" role="status">
|
||||
상담 프로토콜 목록을 불러오는 중입니다.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!protocolsLoading && protocolsLoaded && visibleProtocols.length === 0 ? (
|
||||
<EmptyState
|
||||
title="조건에 맞는 프로토콜이 없습니다"
|
||||
body="새 초안을 등록하거나 검색·상태 필터를 바꿔 주세요."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="vgops-protocol-cards">
|
||||
{visibleProtocols.map((protocol) => (
|
||||
<article
|
||||
className={surfaceClassName("vgops-protocol-card", {
|
||||
variant: "inset",
|
||||
})}
|
||||
key={protocol.protocol_id}
|
||||
data-protocol-status={protocol.status}
|
||||
>
|
||||
<div className="vgops-protocol-card__copy">
|
||||
<div className="vgops-protocol-card__title">
|
||||
<h3>{protocol.title}</h3>
|
||||
<Badge tone={protocolStatusTone(protocol.status)}>
|
||||
{protocolStatusLabel(protocol.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p>{protocol.source}</p>
|
||||
<div className="vgops-chip-row">
|
||||
<span>v{protocol.version}</span>
|
||||
<span>라이선스 {protocol.license}</span>
|
||||
<span>
|
||||
외부 LLM {protocol.external_llm_ok ? "허용" : "차단"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="vgops-protocol-card__actions">
|
||||
{protocol.status === "draft" ? (
|
||||
<Button
|
||||
onClick={() => void activateProtocol(protocol)}
|
||||
disabled={protocolActionId === protocol.protocol_id}
|
||||
aria-label={`${protocol.title} 활성화`}
|
||||
>
|
||||
{protocolActionId === protocol.protocol_id
|
||||
? "활성화 중"
|
||||
: "활성화"}
|
||||
</Button>
|
||||
) : null}
|
||||
{protocol.status === "active" ? (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => void retireProtocol(protocol)}
|
||||
disabled={protocolActionId === protocol.protocol_id}
|
||||
aria-label={`${protocol.title} 퇴역`}
|
||||
>
|
||||
{protocolActionId === protocol.protocol_id ? "퇴역 중" : "퇴역"}
|
||||
</Button>
|
||||
) : null}
|
||||
{protocol.status === "retired" ? (
|
||||
<span className="vgops-protocol-card__terminal">변경 불가</span>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
|
||||
const renderAccess = () => (
|
||||
<>
|
||||
{/* eyebrow "접근 권한"은 아래 "역할, 그룹, 접근 범위"와 같은 말이라 제거 */}
|
||||
<PageHeader
|
||||
title="역할, 그룹, 접근 범위"
|
||||
description="운영자가 누구에게 어떤 작업 권한을 줄지 판단하는 정책 화면입니다."
|
||||
description="운영 권한 정책과 평가용 상담 프로토콜의 수명주기를 관리합니다."
|
||||
/>
|
||||
<div className="vgops-users-note">
|
||||
역할·그룹 저장 API는 아직 분리되지 않았습니다. 현재 화면은 운영 정책
|
||||
기준을 먼저 고정합니다.
|
||||
</div>
|
||||
{accessTab !== "protocols" ? (
|
||||
<div className="vgops-users-note">
|
||||
역할·그룹 저장 API는 아직 분리되지 않았습니다. 현재 화면은 운영 정책
|
||||
기준을 먼저 고정합니다.
|
||||
</div>
|
||||
) : null}
|
||||
<TabBar
|
||||
ariaLabel="접근 권한 탭"
|
||||
items={[
|
||||
["roles", "역할"],
|
||||
["groups", "그룹"],
|
||||
["matrix", "권한 매트릭스"],
|
||||
["protocols", "상담 프로토콜"],
|
||||
]}
|
||||
value={accessTab}
|
||||
onChange={setAccessTab}
|
||||
|
|
@ -2447,6 +2952,8 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{accessTab === "protocols" ? renderProtocols() : null}
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -427,6 +427,11 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
);
|
||||
const topPersona =
|
||||
personaProgressRows.find((row) => row.total > 0) ?? personaProgressRows[0];
|
||||
const trainingExposure = dashboard?.training_exposure;
|
||||
const dominantExposurePercent =
|
||||
typeof trainingExposure?.dominant_share === "number"
|
||||
? Math.round(trainingExposure.dominant_share * 100)
|
||||
: null;
|
||||
const personaCatalogUnavailable = loadState === "ready" && !selected;
|
||||
const hasSessionRecords = sessionLoadState === "ready" && sessions.length > 0;
|
||||
const rootClassName = [
|
||||
|
|
@ -1596,14 +1601,15 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
</div>
|
||||
<div className="lh-insight-list">
|
||||
<div>
|
||||
<span>훈련 편향 점검</span>
|
||||
<b>
|
||||
{topPersona?.total
|
||||
? "주호소 범위를 넓힐 차례"
|
||||
: "첫 회기 이후 표시"}
|
||||
</b>
|
||||
<span>훈련 노출 점검</span>
|
||||
<b>{trainingExposure?.label ?? "판정 근거 부족"}</b>
|
||||
<p>
|
||||
한 대상에 치우치면 다른 주호소 대응력이 늦게 올라옵니다.
|
||||
{trainingExposure?.status === "insufficient"
|
||||
? `종료 회기 ${trainingExposure.completed_sessions}/4회 · 4회부터 최다 노출 비중을 확인합니다.`
|
||||
: `${trainingExposure?.dominant_persona_name ?? "최다 연습 대상"} ${trainingExposure?.dominant_sessions ?? 0}/${trainingExposure?.completed_sessions ?? 0}회 · 최다 노출 ${dominantExposurePercent ?? 0}%`}
|
||||
</p>
|
||||
<p>
|
||||
공정성 평가나 임상진단이 아닌 투명한 훈련 노출 지표입니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const OAUTH_PROVIDER_FAILED_MESSAGE =
|
|||
const OAUTH_UNSUPPORTED_PROVIDER_MESSAGE =
|
||||
"지원하지 않는 로그인 공급자입니다. Google 로그인 버튼으로 다시 시작하세요.";
|
||||
const SAML_NOT_CONFIGURED_MESSAGE =
|
||||
"학교 SSO가 아직 연결되지 않았습니다. 현재는 승인된 Google 계정으로 로그인하세요.";
|
||||
"학교 SSO가 아직 연결되지 않았습니다. 현재는 Google 계정으로 로그인하세요.";
|
||||
const SAML_FAILED_MESSAGE =
|
||||
"학교 SSO 로그인 흐름을 완료하지 못했습니다. 관리자에게 SSO 설정 확인을 요청하세요.";
|
||||
|
||||
|
|
@ -131,21 +131,16 @@ export default function Login() {
|
|||
!isLocalRedirectUri(authConfig.redirect_uri);
|
||||
const oauthReady =
|
||||
authConfig?.google_oauth_configured === true && !devOAuthUnavailable;
|
||||
const allowedDomains = authConfig?.allowed_email_domains ?? [];
|
||||
const primaryDomainLabel = devOAuthUnavailable
|
||||
const primaryAccountLabel = devOAuthUnavailable
|
||||
? "로컬은 테스트 계정 사용"
|
||||
: allowedDomains[0]
|
||||
? `@${allowedDomains[0]}`
|
||||
: oauthChecking
|
||||
? "도메인 확인 중"
|
||||
: "승인 도메인 계정";
|
||||
const secondaryDomainLabel = devOAuthUnavailable
|
||||
: "모든 Google 계정";
|
||||
const secondaryAccountLabel = devOAuthUnavailable
|
||||
? "공개 주소에서 사용"
|
||||
: allowedDomains[1]
|
||||
? `@${allowedDomains[1]}`
|
||||
: allowedDomains[0]
|
||||
? "승인된 Google 계정"
|
||||
: "관리자 설정 필요";
|
||||
: oauthChecking
|
||||
? "계정 정책 확인 중"
|
||||
: "이메일 확인 후 바로 시작";
|
||||
const oauthStatusMessage = devOAuthUnavailable
|
||||
? LOCAL_OAUTH_UNAVAILABLE_MESSAGE
|
||||
: oauthChecking
|
||||
|
|
@ -183,12 +178,12 @@ export default function Login() {
|
|||
|
||||
return (
|
||||
<AuthShell className="lg-root" variant="room">
|
||||
<LoginBrand allowedDomains={allowedDomains} oauthChecking={oauthChecking} />
|
||||
<LoginBrand oauthChecking={oauthChecking} oauthReady={oauthReady} />
|
||||
<LoginPanel
|
||||
oauth={{
|
||||
ready: oauthReady,
|
||||
primaryDomainLabel,
|
||||
secondaryDomainLabel,
|
||||
primaryAccountLabel,
|
||||
secondaryAccountLabel,
|
||||
statusIcon: authConfigError ? "alert" : "info",
|
||||
statusMessage: oauthStatusMessage,
|
||||
onStart: startOAuth,
|
||||
|
|
|
|||
|
|
@ -1381,6 +1381,17 @@ export default function Session() {
|
|||
} catch (err) {
|
||||
if (err instanceof ApiError && err.detail === "consent_required") {
|
||||
setStartError("개인정보 및 연습 기록 처리 동의 후 회기를 시작할 수 있습니다.");
|
||||
} else if (err instanceof ApiError && err.status === 404) {
|
||||
setStartError(
|
||||
"이 페르소나는 아직 승인되지 않았거나 공개 목록에서 제외됐습니다. 승인 상태를 확인한 뒤 목록에서 다시 선택해 주세요.",
|
||||
);
|
||||
} else if (
|
||||
err instanceof ApiError &&
|
||||
err.detail === "session_persistence_unavailable"
|
||||
) {
|
||||
setStartError(
|
||||
"세션 기록 저장소에 연결할 수 없습니다. 기록 유실을 막기 위해 시작하지 않았습니다. 잠시 뒤 다시 시도해 주세요.",
|
||||
);
|
||||
} else {
|
||||
setStartError("세션을 열지 못했습니다. 잠시 뒤 다시 시도해 주세요.");
|
||||
}
|
||||
|
|
@ -1621,11 +1632,13 @@ export default function Session() {
|
|||
setAvatarState("idle");
|
||||
return;
|
||||
}
|
||||
const normalized = detail.includes("Not logged in")
|
||||
? "AI 엔진 로그인이 필요합니다. 관리자에게 엔진 상태 확인을 요청하세요."
|
||||
: detail.includes("engine unavailable")
|
||||
? "AI 엔진이 응답하지 않습니다. 잠시 뒤 다시 시도하거나 관리자에게 알려 주세요."
|
||||
: "내담자 응답을 생성하지 못했습니다. 잠시 뒤 다시 시도해 주세요.";
|
||||
const normalized = detail.includes("client_stream_incomplete")
|
||||
? "내담자 응답 연결이 중간에 끊겼습니다. 입력 내용은 복원했습니다. 같은 발화로 다시 시도해 주세요."
|
||||
: detail.includes("Not logged in")
|
||||
? "AI 엔진 로그인이 필요합니다. 입력 내용은 복원했습니다. 관리자에게 엔진 상태 확인을 요청하세요."
|
||||
: detail.includes("engine unavailable") || detail.includes("engine stream")
|
||||
? "AI 엔진이 응답하지 않습니다. 입력 내용은 복원했습니다. 잠시 뒤 다시 시도하고, 반복되면 관리자에게 내담자 응답 엔진 상태 확인을 요청하세요."
|
||||
: "내담자 응답을 생성하지 못했습니다. 입력 내용은 복원했습니다. 같은 발화로 다시 시도해 주세요.";
|
||||
setTurnError(normalized);
|
||||
pushSignal("warn", "AI 엔진 연결 실패");
|
||||
setAvatarState("listening");
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
type UserPrepostMeasuresResponse,
|
||||
} from "../lib/api";
|
||||
import { canAccessRole, useAuth } from "../lib/auth";
|
||||
import { displayPiiSafeText } from "../lib/piiDisplay";
|
||||
import {
|
||||
parsePracticeLaunchIntent,
|
||||
practiceCriterionLabel,
|
||||
|
|
@ -1019,17 +1020,16 @@ export default function SessionReview() {
|
|||
function jumpToTurn(id: string) {
|
||||
if (!data) return;
|
||||
const turns = data.turns ?? [];
|
||||
setActiveTurn(id);
|
||||
const target = turns.find((turn) => turn.id === id || turn.turn_id === id);
|
||||
const displayId = target?.id ?? id;
|
||||
setActiveTurn(displayId);
|
||||
setReviewTab("transcript");
|
||||
if (
|
||||
learnerOnly &&
|
||||
turns.find((turn) => turn.id === id)?.speaker === "client"
|
||||
) {
|
||||
if (learnerOnly && target?.speaker === "client") {
|
||||
setLearnerOnly(false);
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
turnRefs.current[id]?.scrollIntoView({
|
||||
turnRefs.current[displayId]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
|
|
@ -1211,19 +1211,26 @@ export default function SessionReview() {
|
|||
}
|
||||
|
||||
const turns = data.turns ?? [];
|
||||
const sessionNo =
|
||||
(data as SessionReviewResponse & { sessionNo?: number }).sessionNo ?? 1;
|
||||
const phases = data.phases ?? [];
|
||||
const phaseAxis = data.phaseAxis ?? [];
|
||||
const valenceAxis = data.valenceAxis ?? [];
|
||||
const clientValence = data.clientValence ?? [];
|
||||
const counselorBaseline = data.counselorBaseline ?? [];
|
||||
const rubric = data.rubric ?? [];
|
||||
const firstSessionChecklist = data.firstSessionChecklist;
|
||||
const goodMoments = data.goodMoments ?? [];
|
||||
const growthPoints = data.growthPoints ?? [];
|
||||
const hasValence = clientValence.length > 0 || counselorBaseline.length > 0;
|
||||
const canOpenAudio = Boolean(data.audioUrl);
|
||||
const canExportPdf = Boolean(data.pdfExportUrl);
|
||||
const hasTranscript = turns.length > 0;
|
||||
const canCreateShare = !isSupervisorView && data.sessionSignal === "종료됨";
|
||||
const learnerFeedbackVisible = isSupervisorView || data.learnerFeedbackEnabled;
|
||||
const canCreateShare =
|
||||
!isSupervisorView &&
|
||||
data.learnerFeedbackEnabled &&
|
||||
data.sessionSignal === "종료됨";
|
||||
const teacherReview = data.teacherReview;
|
||||
const teacherReviewStatus = teacherReview?.status ?? "pending";
|
||||
const teacherReviewStatusLabel =
|
||||
|
|
@ -1464,7 +1471,7 @@ export default function SessionReview() {
|
|||
>
|
||||
PDF 내보내기
|
||||
</Button>
|
||||
{!isSupervisorView ? (
|
||||
{!isSupervisorView && data.learnerFeedbackEnabled ? (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
|
@ -1509,6 +1516,7 @@ export default function SessionReview() {
|
|||
sessionId={data.session_id}
|
||||
turns={turns}
|
||||
isSupervisorView={isSupervisorView}
|
||||
feedbackEnabled={learnerFeedbackVisible}
|
||||
onJumpToTurn={jumpToTurn}
|
||||
/>
|
||||
) : null}
|
||||
|
|
@ -1559,9 +1567,10 @@ export default function SessionReview() {
|
|||
sessionId={data.session_id}
|
||||
isSupervisorView={false}
|
||||
practiceLaunchIntent={returnedTransferIntent}
|
||||
feedbackEnabled={data.learnerFeedbackEnabled}
|
||||
/>
|
||||
) : null}
|
||||
{data.sessionSignal === "종료됨" && returnedDeliberateIntent ? (
|
||||
{data.sessionSignal === "종료됨" && returnedDeliberateIntent && learnerFeedbackVisible ? (
|
||||
<DeliberatePracticeCard
|
||||
sessionId={data.session_id}
|
||||
turns={turns}
|
||||
|
|
@ -1574,6 +1583,7 @@ export default function SessionReview() {
|
|||
<MultimodalAllianceCard
|
||||
sessionId={data.session_id}
|
||||
isSupervisorView={isSupervisorView}
|
||||
feedbackEnabled={learnerFeedbackVisible}
|
||||
/>
|
||||
) : null}
|
||||
{data.sessionSignal === "종료됨" && !returnedTransferIntent ? (
|
||||
|
|
@ -1581,17 +1591,20 @@ export default function SessionReview() {
|
|||
sessionId={data.session_id}
|
||||
isSupervisorView={isSupervisorView}
|
||||
practiceLaunchIntent={null}
|
||||
feedbackEnabled={learnerFeedbackVisible}
|
||||
/>
|
||||
) : null}
|
||||
{data.sessionSignal === "종료됨" ? (
|
||||
<OutcomeTrajectoryCard
|
||||
sessionId={data.session_id}
|
||||
sessionNo={sessionNo}
|
||||
turns={turns}
|
||||
isSupervisorView={isSupervisorView}
|
||||
feedbackEnabled={learnerFeedbackVisible}
|
||||
onJumpToTurn={jumpToTurn}
|
||||
/>
|
||||
) : null}
|
||||
{data.sessionSignal === "종료됨" ? (
|
||||
{data.sessionSignal === "종료됨" && learnerFeedbackVisible ? (
|
||||
<RuptureRepairCard
|
||||
sessionId={data.session_id}
|
||||
turns={turns}
|
||||
|
|
@ -1599,7 +1612,7 @@ export default function SessionReview() {
|
|||
onJumpToTurn={jumpToTurn}
|
||||
/>
|
||||
) : null}
|
||||
{data.sessionSignal === "종료됨" && !returnedDeliberateIntent ? (
|
||||
{data.sessionSignal === "종료됨" && !returnedDeliberateIntent && learnerFeedbackVisible ? (
|
||||
<DeliberatePracticeCard
|
||||
sessionId={data.session_id}
|
||||
turns={turns}
|
||||
|
|
@ -1608,7 +1621,7 @@ export default function SessionReview() {
|
|||
onJumpToTurn={jumpToTurn}
|
||||
/>
|
||||
) : null}
|
||||
<Card className="sr-card sr-card--chart">
|
||||
{learnerFeedbackVisible ? <Card className="sr-card sr-card--chart">
|
||||
<Kicker dot={false}>감정 밸런스 타임라인</Kicker>
|
||||
<div style={{ marginTop: "var(--sp-4)" }}>
|
||||
{hasValence ? (
|
||||
|
|
@ -1624,9 +1637,9 @@ export default function SessionReview() {
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Card> : null}
|
||||
|
||||
<Card className="sr-card sr-card--flow">
|
||||
{learnerFeedbackVisible ? <Card className="sr-card sr-card--flow">
|
||||
<Kicker dot={false}>회기 흐름</Kicker>
|
||||
<div className="sr-phasebar" style={{ marginTop: "var(--sp-4)" }}>
|
||||
{phases.length > 0 ? (
|
||||
|
|
@ -1662,7 +1675,7 @@ export default function SessionReview() {
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Card> : null}
|
||||
</div>
|
||||
|
||||
<Card
|
||||
|
|
@ -1934,7 +1947,60 @@ export default function SessionReview() {
|
|||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card className="sr-card sr-card--side sr-card--rubric">
|
||||
{learnerFeedbackVisible && firstSessionChecklist?.applicable ? (
|
||||
<Card className="sr-card sr-card--side sr-card--first-session-checklist">
|
||||
<div className="sr-first-checklist__heading">
|
||||
<Kicker dot={false}>{firstSessionChecklist.title}</Kicker>
|
||||
<span className="sr-first-checklist__version">
|
||||
{firstSessionChecklist.version}
|
||||
</span>
|
||||
</div>
|
||||
<p className="sr-first-checklist__note">
|
||||
{firstSessionChecklist.note}
|
||||
</p>
|
||||
<div className="sr-first-checklist__items">
|
||||
{(firstSessionChecklist.criteria ?? []).map((criterion) => (
|
||||
<section
|
||||
key={criterion.criterionId}
|
||||
className="sr-first-checklist__item"
|
||||
data-criterion-id={criterion.criterionId}
|
||||
>
|
||||
<div className="sr-first-checklist__item-head">
|
||||
<b>{criterion.label}</b>
|
||||
<span
|
||||
className={`sr-first-checklist__status sr-first-checklist__status--${criterion.status}`}
|
||||
>
|
||||
{criterion.status === "met" ? "근거 확인" : "근거 미발견"}
|
||||
</span>
|
||||
</div>
|
||||
<p>{criterion.description}</p>
|
||||
{(criterion.evidenceTurns ?? []).length > 0 ? (
|
||||
<div className="sr-first-checklist__evidence-list">
|
||||
{(criterion.evidenceTurns ?? []).map((evidence) => (
|
||||
<button
|
||||
key={`${criterion.criterionId}-${evidence.turnId}`}
|
||||
type="button"
|
||||
className="sr-first-checklist__evidence"
|
||||
onClick={() => jumpToTurn(evidence.turnId)}
|
||||
aria-label={`${criterion.label} 근거 ${evidence.turnSeq}번 발화로 이동`}
|
||||
>
|
||||
<span>{evidence.turnSeq}번 발화</span>
|
||||
<q>{displayPiiSafeText(evidence.quote)}</q>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="sr-first-checklist__empty">
|
||||
이번 축어록에서 자동으로 연결할 근거 발화를 찾지 못했습니다.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{learnerFeedbackVisible ? <Card className="sr-card sr-card--side sr-card--rubric">
|
||||
<Kicker dot={false}>기법 사용 분포</Kicker>
|
||||
<div className="sr-rubric" style={{ marginTop: "var(--sp-4)" }}>
|
||||
{rubric.length > 0 ? (
|
||||
|
|
@ -1972,9 +2038,9 @@ export default function SessionReview() {
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Card> : null}
|
||||
|
||||
<Card className="sr-card sr-card--side sr-card--good">
|
||||
{learnerFeedbackVisible ? <Card className="sr-card sr-card--side sr-card--good">
|
||||
<Kicker dot={false}>좋았던 순간</Kicker>
|
||||
<div
|
||||
className="sr-points sr-points--good"
|
||||
|
|
@ -1996,9 +2062,9 @@ export default function SessionReview() {
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Card> : null}
|
||||
|
||||
<Card className="sr-card sr-card--side sr-card--growth">
|
||||
{learnerFeedbackVisible ? <Card className="sr-card sr-card--side sr-card--growth">
|
||||
<Kicker dot={false}>다음 회기 개선점</Kicker>
|
||||
<div
|
||||
className="sr-points sr-points--grow"
|
||||
|
|
@ -2035,11 +2101,11 @@ export default function SessionReview() {
|
|||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</Card> : null}
|
||||
|
||||
{!isSupervisorView ? <PrepostMeasureCard /> : null}
|
||||
|
||||
<div
|
||||
{learnerFeedbackVisible ? <div
|
||||
className={surfaceClassName(
|
||||
`sr-feedback${data.clientFeedback ? " sr-feedback--filled" : ""}`,
|
||||
{ variant: "inset" },
|
||||
|
|
@ -2068,7 +2134,7 @@ export default function SessionReview() {
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div> : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -608,7 +608,7 @@
|
|||
|
||||
.vgops-tabs button {
|
||||
flex: 0 0 auto;
|
||||
height: 34px;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--hair);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-surface);
|
||||
|
|
@ -660,12 +660,13 @@
|
|||
|
||||
.vgops-user-create input,
|
||||
.vgops-user-create select,
|
||||
.vgops-user-create output,
|
||||
.vgops-users-toolbar input,
|
||||
.vgops-user-table input,
|
||||
.vgops-user-table select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface-2);
|
||||
|
|
@ -673,6 +674,12 @@
|
|||
padding: 0 10px;
|
||||
font: var(--fs-sm) / 1.3 var(--font-sans);
|
||||
}
|
||||
.vgops-user-create output {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg-surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.vgops-user-create .vgops-checkline input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
|
@ -692,7 +699,7 @@
|
|||
|
||||
.vgops-user-create .vg-btn {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
min-height: 44px;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
padding-inline: 12px;
|
||||
|
|
@ -756,7 +763,7 @@
|
|||
|
||||
.vgops-user-table {
|
||||
width: 100%;
|
||||
min-width: 1440px;
|
||||
min-width: 1580px;
|
||||
table-layout: fixed;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
|
|
@ -804,11 +811,12 @@
|
|||
.vgops-user-table th:nth-child(2) { width: 140px; }
|
||||
.vgops-user-table th:nth-child(3) { width: 112px; }
|
||||
.vgops-user-table th:nth-child(4) { width: 145px; }
|
||||
.vgops-user-table th:nth-child(5) { width: 180px; }
|
||||
.vgops-user-table th:nth-child(6) { width: 155px; }
|
||||
.vgops-user-table th:nth-child(7) { width: 90px; }
|
||||
.vgops-user-table th:nth-child(8) { width: 160px; }
|
||||
.vgops-user-table th:nth-child(9) { width: 208px; }
|
||||
.vgops-user-table th:nth-child(5) { width: 140px; }
|
||||
.vgops-user-table th:nth-child(6) { width: 180px; }
|
||||
.vgops-user-table th:nth-child(7) { width: 155px; }
|
||||
.vgops-user-table th:nth-child(8) { width: 90px; }
|
||||
.vgops-user-table th:nth-child(9) { width: 160px; }
|
||||
.vgops-user-table th:nth-child(10) { width: 208px; }
|
||||
|
||||
.vgops-user-sort {
|
||||
width: 100%;
|
||||
|
|
@ -1267,6 +1275,196 @@
|
|||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.vgops-protocols {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 0.9fr) minmax(0, 1.25fr);
|
||||
align-items: start;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vgops-protocol-create,
|
||||
.vgops-protocol-list,
|
||||
.vgops-protocol-form,
|
||||
.vgops-protocol-toolbar,
|
||||
.vgops-protocol-cards,
|
||||
.vgops-protocol-card,
|
||||
.vgops-protocol-card__copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vgops-protocol-intro,
|
||||
.vgops-protocol-policy {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.vgops-protocol-field-error {
|
||||
color: var(--crit-text);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.vgops-protocol-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(96px, 0.38fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vgops-protocol-form label,
|
||||
.vgops-protocol-toolbar label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vgops-protocol-form label > span,
|
||||
.vgops-protocol-toolbar label > span {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.vgops-protocol-form input,
|
||||
.vgops-protocol-form select,
|
||||
.vgops-protocol-form textarea,
|
||||
.vgops-protocol-toolbar input,
|
||||
.vgops-protocol-toolbar select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface-2);
|
||||
color: var(--text-strong);
|
||||
padding: 0 11px;
|
||||
font: var(--fs-sm) / 1.4 var(--font-sans);
|
||||
}
|
||||
|
||||
.vgops-protocol-form textarea {
|
||||
min-height: 168px;
|
||||
resize: vertical;
|
||||
padding-block: 10px;
|
||||
}
|
||||
|
||||
.vgops-protocol-form input:focus,
|
||||
.vgops-protocol-form select:focus,
|
||||
.vgops-protocol-form textarea:focus,
|
||||
.vgops-protocol-toolbar input:focus,
|
||||
.vgops-protocol-toolbar select:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.vgops-protocol-form__source,
|
||||
.vgops-protocol-form__content,
|
||||
.vgops-protocol-policy,
|
||||
.vgops-protocol-submit {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.vgops-protocol-check {
|
||||
min-height: 44px;
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
align-self: end;
|
||||
gap: 9px !important;
|
||||
border: 1px solid var(--hair);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface-2);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.vgops-protocol-check input {
|
||||
width: 20px;
|
||||
min-height: 20px;
|
||||
height: 20px;
|
||||
flex: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.vgops-protocol-submit,
|
||||
.vgops-protocol-list .vg-btn {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.vgops-protocol-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(112px, 0.38fr) max-content;
|
||||
align-items: end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vgops-protocol-error {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vgops-protocol-cards {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vgops-protocol-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--hair);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-surface-2);
|
||||
}
|
||||
|
||||
.vgops-protocol-card__copy {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vgops-protocol-card__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vgops-protocol-card h3,
|
||||
.vgops-protocol-card p {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.vgops-protocol-card h3 {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-body);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.vgops-protocol-card p {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.vgops-protocol-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-width: 88px;
|
||||
}
|
||||
|
||||
.vgops-protocol-card__terminal {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.vgops-ticket-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -1546,6 +1744,10 @@
|
|||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.vgops-protocols {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.vgops-ticket {
|
||||
grid-template-columns: minmax(0, 1fr) 84px;
|
||||
}
|
||||
|
|
@ -1626,6 +1828,30 @@
|
|||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.vgops-protocol-form,
|
||||
.vgops-protocol-toolbar,
|
||||
.vgops-protocol-card,
|
||||
.vgops-protocol-error {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.vgops-protocol-form__source,
|
||||
.vgops-protocol-form__content,
|
||||
.vgops-protocol-policy,
|
||||
.vgops-protocol-submit {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.vgops-protocol-card {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.vgops-protocol-card__actions,
|
||||
.vgops-protocol-card__actions .vg-btn,
|
||||
.vgops-protocol-error .vg-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vgops-users-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -288,6 +288,10 @@ export function normalizeAdminUsersResponse(rawResponse: AdminUsersResponse): {
|
|||
warnings,
|
||||
label,
|
||||
),
|
||||
learner_feedback_enabled:
|
||||
typeof sourceRecord.learner_feedback_enabled === "boolean"
|
||||
? sourceRecord.learner_feedback_enabled
|
||||
: true,
|
||||
super_admin: booleanField(
|
||||
sourceRecord,
|
||||
"super_admin",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Icon } from "../../components/ui/Icon";
|
||||
|
||||
interface LoginBrandProps {
|
||||
allowedDomains: string[];
|
||||
oauthChecking: boolean;
|
||||
oauthReady: boolean;
|
||||
}
|
||||
|
||||
function VignetteMark() {
|
||||
|
|
@ -22,7 +22,7 @@ function VignetteMark() {
|
|||
);
|
||||
}
|
||||
|
||||
export function LoginBrand({ allowedDomains, oauthChecking }: LoginBrandProps) {
|
||||
export function LoginBrand({ oauthChecking, oauthReady }: LoginBrandProps) {
|
||||
return (
|
||||
<section className="lg-brand" aria-label="Vignette">
|
||||
<div className="lg-wordmark">
|
||||
|
|
@ -48,13 +48,9 @@ export function LoginBrand({ allowedDomains, oauthChecking }: LoginBrandProps) {
|
|||
<div className="lg-policy">
|
||||
<span>
|
||||
<Icon name="shield" size={17} />
|
||||
허용 도메인
|
||||
Google 로그인
|
||||
</span>
|
||||
{allowedDomains.length ? (
|
||||
allowedDomains.map((domain) => <b key={domain}>{domain}</b>)
|
||||
) : (
|
||||
<b>{oauthChecking ? "확인 중" : "설정 필요"}</b>
|
||||
)}
|
||||
<b>{oauthChecking ? "확인 중" : oauthReady ? "모든 Google 계정" : "설정 필요"}</b>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ export interface LoginRoleOption {
|
|||
|
||||
interface LoginOAuthView {
|
||||
ready: boolean;
|
||||
primaryDomainLabel: string;
|
||||
secondaryDomainLabel: string;
|
||||
primaryAccountLabel: string;
|
||||
secondaryAccountLabel: string;
|
||||
statusIcon: Extract<IconName, "alert" | "info">;
|
||||
statusMessage: string;
|
||||
onStart: () => void;
|
||||
|
|
@ -136,23 +136,23 @@ export function LoginPanel({ oauth, devAccess, error }: LoginPanelProps) {
|
|||
</span>
|
||||
<h2>로그인</h2>
|
||||
<p className="lg-lead">
|
||||
학교 또는 승인된 Google 계정으로 접속하면 역할과 코호트 권한을 확인합니다.
|
||||
이메일이 확인된 Google 계정이면 도메인이나 사전등록 없이 바로 시작할 수 있습니다.
|
||||
</p>
|
||||
|
||||
<div className="lg-actions">
|
||||
<LoginProviderButton
|
||||
variant="primary"
|
||||
icon="school"
|
||||
label="학교 Google 계정으로 계속"
|
||||
subLabel={oauth.primaryDomainLabel}
|
||||
label="Google 계정으로 계속"
|
||||
subLabel={oauth.primaryAccountLabel}
|
||||
disabled={!oauth.ready}
|
||||
onClick={oauth.onStart}
|
||||
/>
|
||||
<LoginProviderButton
|
||||
variant="secondary"
|
||||
icon="google"
|
||||
label="Google 계정으로 계속"
|
||||
subLabel={oauth.secondaryDomainLabel}
|
||||
label="다른 Google 계정 선택"
|
||||
subLabel={oauth.secondaryAccountLabel}
|
||||
disabled={!oauth.ready}
|
||||
onClick={oauth.onStart}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import "./alliance-pulse.css";
|
|||
|
||||
interface PulseTurn {
|
||||
id: string;
|
||||
turn_id?: string | null;
|
||||
ts: string;
|
||||
speaker: string;
|
||||
who: string;
|
||||
|
|
@ -29,6 +30,7 @@ interface AlliancePulseCardProps {
|
|||
sessionId: string;
|
||||
turns: PulseTurn[];
|
||||
isSupervisorView: boolean;
|
||||
feedbackEnabled: boolean;
|
||||
onJumpToTurn: (turnId: string) => void;
|
||||
}
|
||||
|
||||
|
|
@ -206,7 +208,16 @@ function EvidencePicker({
|
|||
onChange: (turnIds: string[]) => void;
|
||||
scope: "learner" | "supervisor";
|
||||
}) {
|
||||
const eligible = useMemo(() => turns.slice(-12), [turns]);
|
||||
const eligible = useMemo(
|
||||
() =>
|
||||
turns
|
||||
.flatMap((turn) => {
|
||||
const persistedId = turn.turn_id?.trim();
|
||||
return persistedId ? [{ turn, persistedId }] : [];
|
||||
})
|
||||
.slice(-12),
|
||||
[turns],
|
||||
);
|
||||
const summaryLabel =
|
||||
scope === "learner" ? "내 판단의 근거 장면 선택" : "교수자 판정 근거 장면 선택";
|
||||
|
||||
|
|
@ -226,12 +237,12 @@ function EvidencePicker({
|
|||
</summary>
|
||||
{eligible.length > 0 ? (
|
||||
<div className="ap-evidence-picker__list">
|
||||
{eligible.map((turn) => (
|
||||
{eligible.map(({ turn, persistedId }) => (
|
||||
<label key={turn.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(turn.id)}
|
||||
onChange={() => toggle(turn.id)}
|
||||
checked={selected.includes(persistedId)}
|
||||
onChange={() => toggle(persistedId)}
|
||||
/>
|
||||
<span className="ap-evidence-picker__time">{turn.ts}</span>
|
||||
<span>
|
||||
|
|
@ -242,7 +253,7 @@ function EvidencePicker({
|
|||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p>선택할 수 있는 저장 발화가 없습니다.</p>
|
||||
<p>영속 ID가 확인된 저장 발화가 없습니다.</p>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
|
|
@ -360,6 +371,25 @@ function LockedWaitingState({ pulse }: { pulse: AlliancePulse }) {
|
|||
);
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
@ -525,6 +555,7 @@ export function AlliancePulseCard({
|
|||
sessionId,
|
||||
turns,
|
||||
isSupervisorView,
|
||||
feedbackEnabled,
|
||||
onJumpToTurn,
|
||||
}: AlliancePulseCardProps) {
|
||||
const [loadState, setLoadState] = useState<LoadState>("loading");
|
||||
|
|
@ -559,12 +590,16 @@ export function AlliancePulseCard({
|
|||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pulse || (pulse.status !== "awaiting_agents" && pulse.status !== "processing")) {
|
||||
if (
|
||||
!feedbackEnabled ||
|
||||
!pulse ||
|
||||
(pulse.status !== "awaiting_agents" && pulse.status !== "processing")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => void load(), POLL_INTERVAL_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load, pulse]);
|
||||
}, [feedbackEnabled, load, pulse]);
|
||||
|
||||
const submitSelfAssessment = async () => {
|
||||
if (!isCompleteDraft(draft) || submitting) return;
|
||||
|
|
@ -606,8 +641,8 @@ export function AlliancePulseCard({
|
|||
const revealReady = Boolean(
|
||||
pulse?.learner_locked_at && pulse?.revealed_at,
|
||||
);
|
||||
const status = pulse ? pulseStatusCopy(pulse) : null;
|
||||
const learnerEvidenceRequired = turns.length > 0;
|
||||
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) &&
|
||||
|
|
@ -642,8 +677,16 @@ export function AlliancePulseCard({
|
|||
content = (
|
||||
<div className="ap-self-form">
|
||||
<div className="ap-self-form__intro">
|
||||
<b>AI 관점을 보기 전에 먼저 스스로 판단합니다</b>
|
||||
<p>세 축을 따로 평가하세요. 제출하면 값은 잠기며 이후 관점에 맞춰 수정할 수 없습니다.</p>
|
||||
<b>
|
||||
{feedbackEnabled
|
||||
? "AI 관점을 보기 전에 먼저 스스로 판단합니다"
|
||||
: "목표·과업·유대를 내가 직접 기록합니다"}
|
||||
</b>
|
||||
<p>
|
||||
{feedbackEnabled
|
||||
? "세 축을 따로 평가하세요. 제출하면 값은 잠기며 이후 관점에 맞춰 수정할 수 없습니다."
|
||||
: "세 축을 따로 평가해 저장합니다. AI·교수자 파생 비교는 표시하지 않습니다."}
|
||||
</p>
|
||||
</div>
|
||||
{DIMENSIONS.map((dimension) => (
|
||||
<AxisAssessmentControl
|
||||
|
|
@ -667,14 +710,22 @@ export function AlliancePulseCard({
|
|||
<div className="ap-self-form__commit">
|
||||
<span>
|
||||
<Icon name="shield" size={15} strokeWidth={1.8} />
|
||||
제출 뒤에는 AI 관점이 준비될 때까지 내 판단만 보입니다.
|
||||
{feedbackEnabled
|
||||
? "제출 뒤에는 AI 관점이 준비될 때까지 내 판단만 보입니다."
|
||||
: "제출 뒤에도 내가 직접 잠근 기록만 표시합니다."}
|
||||
</span>
|
||||
<Button size="sm" disabled={!canSubmit} onClick={() => void submitSelfAssessment()}>
|
||||
{submitting ? "판단 잠그는 중" : "내 판단 잠그고 관점 비교"}
|
||||
{submitting
|
||||
? "판단 잠그는 중"
|
||||
: feedbackEnabled
|
||||
? "내 판단 잠그고 관점 비교"
|
||||
: "내 판단 기록"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (!feedbackEnabled) {
|
||||
content = <SelfOnlyState pulse={pulse} />;
|
||||
} else if (!revealReady) {
|
||||
content = <LockedWaitingState pulse={pulse} />;
|
||||
} else {
|
||||
|
|
@ -697,8 +748,14 @@ export function AlliancePulseCard({
|
|||
<Card className="sr-card ap-card" aria-busy={loadState === "loading"}>
|
||||
<div className="ap-card__head">
|
||||
<div>
|
||||
<Kicker dot={false}>치료 동맹 펄스</Kicker>
|
||||
<h2>목표, 과업, 유대를 따로 봅니다</h2>
|
||||
<Kicker dot={false}>
|
||||
{feedbackEnabled ? "치료 동맹 펄스" : "치료 동맹 자기평가"}
|
||||
</Kicker>
|
||||
<h2>
|
||||
{feedbackEnabled
|
||||
? "목표, 과업, 유대를 따로 봅니다"
|
||||
: "목표, 과업, 유대를 직접 기록합니다"}
|
||||
</h2>
|
||||
</div>
|
||||
{status ? <Badge tone={status.tone}>{status.label}</Badge> : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ interface CalibrationTransferCardProps {
|
|||
sessionId: string;
|
||||
isSupervisorView: boolean;
|
||||
practiceLaunchIntent: TransferPracticeLaunchIntent | null;
|
||||
feedbackEnabled: boolean;
|
||||
}
|
||||
|
||||
type LoadState = "loading" | "ready" | "error";
|
||||
|
|
@ -191,7 +192,7 @@ function actualBlockerCopy(blocker: string): string {
|
|||
function actualExecutionCopy(
|
||||
response: ActualTransferExecutionResponse | null,
|
||||
): string | null {
|
||||
if (!response) return null;
|
||||
if (!response?.execution) return null;
|
||||
if (response.execution.status === "passed") return "목표 행동이 관찰됨";
|
||||
if (response.execution.status === "failed") return "목표 행동을 다시 연습해야 함";
|
||||
return "이번 회기만으로 판정하기 어려움";
|
||||
|
|
@ -202,6 +203,7 @@ function ActualTransferExecutionObservation({
|
|||
intent,
|
||||
sourceIsValid,
|
||||
data,
|
||||
showFeedback,
|
||||
ledgerRefreshState,
|
||||
onRecorded,
|
||||
}: {
|
||||
|
|
@ -209,6 +211,7 @@ function ActualTransferExecutionObservation({
|
|||
intent: TransferPracticeLaunchIntent;
|
||||
sourceIsValid: boolean;
|
||||
data: CalibrationTransferReadModelResponse;
|
||||
showFeedback: boolean;
|
||||
ledgerRefreshState: "idle" | "refreshing" | "complete";
|
||||
onRecorded: () => void;
|
||||
}) {
|
||||
|
|
@ -249,9 +252,11 @@ function ActualTransferExecutionObservation({
|
|||
setResult(response);
|
||||
setState("success");
|
||||
setMessage(
|
||||
response.idempotent_replay
|
||||
? "이미 기록된 같은 회기 근거와 일치해. 중복 기록은 만들지 않았어."
|
||||
: "이번 완료 회기를 실제 전이 근거로 기록했어.",
|
||||
showFeedback
|
||||
? response.idempotent_replay
|
||||
? "이미 기록된 같은 회기 근거와 일치해. 중복 기록은 만들지 않았어."
|
||||
: "이번 완료 회기를 실제 전이 근거로 기록했어."
|
||||
: "이번 완료 회기 연결 입력을 기록했습니다. AI 판정은 표시하지 않습니다.",
|
||||
);
|
||||
onRecorded();
|
||||
} catch (cause) {
|
||||
|
|
@ -275,11 +280,13 @@ function ActualTransferExecutionObservation({
|
|||
}
|
||||
}
|
||||
|
||||
const afterAssessment = result?.assessment ?? currentAssessment;
|
||||
const afterAssessment = showFeedback
|
||||
? result?.assessment ?? currentAssessment
|
||||
: null;
|
||||
const afterStatus = afterAssessment
|
||||
? ACTUAL_STATUS_COPY[afterAssessment.actual_transfer_status]
|
||||
: null;
|
||||
const executionCopy = actualExecutionCopy(result);
|
||||
const executionCopy = showFeedback ? actualExecutionCopy(result) : null;
|
||||
const actionLabel =
|
||||
state === "saving"
|
||||
? "전이 근거 확인 중"
|
||||
|
|
@ -835,6 +842,7 @@ export function CalibrationTransferCard({
|
|||
sessionId,
|
||||
isSupervisorView,
|
||||
practiceLaunchIntent,
|
||||
feedbackEnabled,
|
||||
}: CalibrationTransferCardProps) {
|
||||
const [data, setData] = useState<CalibrationTransferReadModelResponse | null>(null);
|
||||
const [loadState, setLoadState] = useState<LoadState>("loading");
|
||||
|
|
@ -859,7 +867,7 @@ export function CalibrationTransferCard({
|
|||
setLoadError("");
|
||||
const request = isSupervisorView
|
||||
? calibrationTransferApi.getForTeacherSession(sessionId, controller.signal)
|
||||
: calibrationTransferApi.getForLearner(controller.signal);
|
||||
: calibrationTransferApi.getForLearner(sessionId, controller.signal);
|
||||
void request
|
||||
.then((payload) => {
|
||||
setData(payload);
|
||||
|
|
@ -1057,8 +1065,9 @@ export function CalibrationTransferCard({
|
|||
<ActualTransferExecutionObservation
|
||||
sessionId={sessionId}
|
||||
intent={practiceLaunchIntent}
|
||||
sourceIsValid={returnedTransferSourceIsValid}
|
||||
sourceIsValid={feedbackEnabled ? returnedTransferSourceIsValid : true}
|
||||
data={data}
|
||||
showFeedback={feedbackEnabled}
|
||||
ledgerRefreshState={ledgerRefreshState}
|
||||
onRecorded={() => {
|
||||
setLedgerRefreshState("refreshing");
|
||||
|
|
@ -1068,10 +1077,16 @@ export function CalibrationTransferCard({
|
|||
) : null}
|
||||
<header className="ct-card__head">
|
||||
<div>
|
||||
<Kicker>Calibration mirror & transfer</Kicker>
|
||||
<h2 id="ct-card-title">먼저 예측하고, 잠근 뒤, 근거로 교정하기</h2>
|
||||
<Kicker>{feedbackEnabled ? "Calibration mirror & transfer" : "학습자 자기예측 원장"}</Kicker>
|
||||
<h2 id="ct-card-title">
|
||||
{feedbackEnabled
|
||||
? "먼저 예측하고, 잠근 뒤, 근거로 교정하기"
|
||||
: "먼저 예측하고 잠그는 내 기록만 보존합니다"}
|
||||
</h2>
|
||||
<p>
|
||||
외부평가를 보기 전에 남긴 자기예측만 비교해. 결과를 맞혔는지를 세는 화면이 아니라 내 판단 습관을 더 정확하게 만드는 원장이야.
|
||||
{feedbackEnabled
|
||||
? "외부평가를 보기 전에 남긴 자기예측만 비교해. 결과를 맞혔는지를 세는 화면이 아니라 내 판단 습관을 더 정확하게 만드는 원장이야."
|
||||
: "내 예측 revision과 명시적 잠금은 유지하며, 내부 평가와 교수자·AI 결과는 표시하지 않습니다."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ct-card__badges" aria-label="평가 범위">
|
||||
|
|
@ -1184,7 +1199,7 @@ export function CalibrationTransferCard({
|
|||
</p>
|
||||
) : null}
|
||||
|
||||
{latestAssessments.length > 0 ? (
|
||||
{feedbackEnabled && latestAssessments.length > 0 ? (
|
||||
<section className="ct-calibration" aria-labelledby="ct-calibration-title">
|
||||
<div className="ct-section-head">
|
||||
<div>
|
||||
|
|
@ -1198,26 +1213,26 @@ export function CalibrationTransferCard({
|
|||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
) : feedbackEnabled ? (
|
||||
<p className="ct-awaiting" role="status">
|
||||
잠금과 독립 관찰이 쌓이면 역량별 예측 오차·불확실성 구간·과신 또는 과소신 처방이 여기에 나타나.
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{transferSuite ? (
|
||||
{feedbackEnabled && transferSuite ? (
|
||||
<TransferCoverage
|
||||
suite={transferSuite}
|
||||
sourceSessionId={sessionId}
|
||||
calibrationAssessments={latestAssessments}
|
||||
isSupervisorView={isSupervisorView}
|
||||
/>
|
||||
) : (
|
||||
) : feedbackEnabled ? (
|
||||
<section className="ct-transfer ct-transfer--empty">
|
||||
<span className="ct-eyebrow">Unseen transfer</span>
|
||||
<h3>새 맥락의 전이 근거를 기다리는 중</h3>
|
||||
<p>상황·관계 스타일·난도·표현군이 달라진 장면에서 확인하기 전에는 숙련을 일반화하지 않아.</p>
|
||||
</section>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{isSupervisorView ? (
|
||||
<TeacherCorrectionPanel
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { randomUuid } from "../../lib/uuid";
|
|||
interface MultimodalAllianceCardProps {
|
||||
sessionId: string;
|
||||
isSupervisorView: boolean;
|
||||
feedbackEnabled: boolean;
|
||||
}
|
||||
|
||||
type LoadState = "loading" | "ready" | "empty" | "error";
|
||||
|
|
@ -498,7 +499,11 @@ function PrivacyLedger({
|
|||
);
|
||||
}
|
||||
|
||||
export function MultimodalAllianceCard({ sessionId, isSupervisorView }: MultimodalAllianceCardProps) {
|
||||
export function MultimodalAllianceCard({
|
||||
sessionId,
|
||||
isSupervisorView,
|
||||
feedbackEnabled,
|
||||
}: MultimodalAllianceCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<MultimodalAllianceReadModel | null>(null);
|
||||
const [loadState, setLoadState] = useState<LoadState>("loading");
|
||||
|
|
@ -530,7 +535,8 @@ export function MultimodalAllianceCard({ sessionId, isSupervisorView }: Multimod
|
|||
const controller = new AbortController();
|
||||
setLoadState((current) => current === "ready" ? "ready" : "loading");
|
||||
setLoadError("");
|
||||
void multimodalAllianceApi.getMetadata(sessionId, controller.signal)
|
||||
void multimodalAllianceApi
|
||||
.getMetadata(sessionId, controller.signal, feedbackEnabled)
|
||||
.then((payload) => {
|
||||
setData(payload);
|
||||
setLoadState("ready");
|
||||
|
|
@ -546,7 +552,7 @@ export function MultimodalAllianceCard({ sessionId, isSupervisorView }: Multimod
|
|||
setLoadError(compactError(error, "멀티모달 동맹 원장을 불러오지 못했습니다."));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [reloadSeq, sessionId]);
|
||||
}, [feedbackEnabled, reloadSeq, sessionId]);
|
||||
|
||||
const latestConsent = useMemo(() => data
|
||||
? [...data.consent_snapshots].sort((a, b) => a.sequence_no - b.sequence_no).at(-1) ?? null
|
||||
|
|
@ -624,13 +630,13 @@ export function MultimodalAllianceCard({ sessionId, isSupervisorView }: Multimod
|
|||
}
|
||||
|
||||
function saveConsent() {
|
||||
const signature = `${retentionDays}:${retainAudio}`;
|
||||
const signature = `${retentionDays}:${retainAudio}:${feedbackEnabled}`;
|
||||
if (!pendingConsentRef.current || pendingConsentSignatureRef.current !== signature) {
|
||||
pendingConsentRef.current = {
|
||||
submission_id: uuid(),
|
||||
consent_status: "granted",
|
||||
retain_audio: retainAudio,
|
||||
retain_derived_features: true,
|
||||
retain_derived_features: feedbackEnabled,
|
||||
transcript_retained: true,
|
||||
retention_days: retentionDays,
|
||||
policy_version: POLICY_VERSION,
|
||||
|
|
@ -735,9 +741,19 @@ export function MultimodalAllianceCard({ sessionId, isSupervisorView }: Multimod
|
|||
<Card className="mma-card">
|
||||
<header className="mma-header">
|
||||
<div>
|
||||
<Kicker dot={false}>멀티모달 동맹 근거</Kicker>
|
||||
<h2>말의 내용과 오디오 시간을 같은 시계에서 봅니다</h2>
|
||||
<p>텍스트와 음성은 먼저 독립 측정하고, 검증된 추가 이득이 있을 때만 보정 융합합니다.</p>
|
||||
<Kicker dot={false}>
|
||||
{feedbackEnabled ? "멀티모달 동맹 근거" : "멀티모달 개인정보 원장"}
|
||||
</Kicker>
|
||||
<h2>
|
||||
{feedbackEnabled
|
||||
? "말의 내용과 오디오 시간을 같은 시계에서 봅니다"
|
||||
: "동의·보존·삭제 범위를 직접 관리합니다"}
|
||||
</h2>
|
||||
<p>
|
||||
{feedbackEnabled
|
||||
? "텍스트와 음성은 먼저 독립 측정하고, 검증된 추가 이득이 있을 때만 보정 융합합니다."
|
||||
: "AI 타임라인·측정·융합 결과는 불러오지 않으며 개인정보 통제 기록만 유지합니다."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mma-header__badges">
|
||||
<Badge tone="info">교육용 관찰</Badge>
|
||||
|
|
@ -745,6 +761,7 @@ export function MultimodalAllianceCard({ sessionId, isSupervisorView }: Multimod
|
|||
</div>
|
||||
</header>
|
||||
|
||||
{feedbackEnabled ? (
|
||||
<section className="mma-clock" aria-labelledby="mma-clock-title">
|
||||
<div className="mma-section-head">
|
||||
<div>
|
||||
|
|
@ -808,7 +825,9 @@ export function MultimodalAllianceCard({ sessionId, isSupervisorView }: Multimod
|
|||
</article>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{feedbackEnabled ? (
|
||||
<section className="mma-axis-board" aria-labelledby="mma-axis-title">
|
||||
<div className="mma-section-head">
|
||||
<div>
|
||||
|
|
@ -829,10 +848,11 @@ export function MultimodalAllianceCard({ sessionId, isSupervisorView }: Multimod
|
|||
);
|
||||
})}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<PrivacyLedger sessionId={sessionId} data={data} isSupervisorView={isSupervisorView} rawAudio={rawAudio} rawAudioState={rawAudioState} rawAudioError={rawAudioError} selectedScene={selectedEvent} retentionDays={retentionDays} setRetentionDays={setRetentionDays} retainAudio={retainAudio} setRetainAudio={setRetainAudio} consentAcknowledged={consentAcknowledged} setConsentAcknowledged={setConsentAcknowledged} withdrawalAcknowledged={withdrawalAcknowledged} setWithdrawalAcknowledged={setWithdrawalAcknowledged} deletionScopes={deletionScopes} setDeletionScopes={setDeletionScopes} actionState={actionState} actionMessage={actionMessage} onConsent={saveConsent} onWithdraw={withdrawConsent} onDelete={requestDeletion} />
|
||||
|
||||
{!isSupervisorView ? (
|
||||
{!isSupervisorView && feedbackEnabled ? (
|
||||
<footer className="mma-practice-cta">
|
||||
<div><Icon name="mic" size={19} /><div><strong>다음 회기에서 음성 재연습</strong><p>특정 감정을 흉내 내지 않고, 침묵 뒤 응답과 발화 겹침을 줄이는 상호작용 행동을 연습합니다.</p></div></div>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -40,12 +40,14 @@ interface TrajectoryTurn {
|
|||
|
||||
interface OutcomeTrajectoryCardProps {
|
||||
sessionId: string;
|
||||
sessionNo: number;
|
||||
turns: TrajectoryTurn[];
|
||||
isSupervisorView: boolean;
|
||||
feedbackEnabled: boolean;
|
||||
onJumpToTurn: (turnId: string) => void;
|
||||
}
|
||||
|
||||
type LoadState = "loading" | "ready" | "empty" | "error";
|
||||
type LoadState = "loading" | "ready" | "empty" | "error" | "input_only";
|
||||
type CheckinState = "idle" | "submitting" | "success" | "error";
|
||||
type NullableAxisValues = Record<OutcomeAxis, number | null>;
|
||||
|
||||
|
|
@ -794,8 +796,10 @@ function timelineKeyDown(
|
|||
|
||||
export function OutcomeTrajectoryCard({
|
||||
sessionId,
|
||||
sessionNo,
|
||||
turns,
|
||||
isSupervisorView,
|
||||
feedbackEnabled,
|
||||
onJumpToTurn,
|
||||
}: OutcomeTrajectoryCardProps) {
|
||||
const titleId = useId();
|
||||
|
|
@ -810,6 +814,12 @@ export function OutcomeTrajectoryCard({
|
|||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let alive = true;
|
||||
if (!isSupervisorView && !feedbackEnabled) {
|
||||
setData(null);
|
||||
setError(null);
|
||||
setLoadState("input_only");
|
||||
return () => controller.abort();
|
||||
}
|
||||
setLoadState("loading");
|
||||
setError(null);
|
||||
|
||||
|
|
@ -841,7 +851,7 @@ export function OutcomeTrajectoryCard({
|
|||
alive = false;
|
||||
controller.abort();
|
||||
};
|
||||
}, [reloadSeq, sessionId]);
|
||||
}, [feedbackEnabled, isSupervisorView, reloadSeq, sessionId]);
|
||||
|
||||
const latestSessionNo = data ? latestSessionNumber(data) : 1;
|
||||
const activeSessionNo = data
|
||||
|
|
@ -862,6 +872,30 @@ export function OutcomeTrajectoryCard({
|
|||
[activeSessionNo, data],
|
||||
);
|
||||
|
||||
if (loadState === "input_only") {
|
||||
return (
|
||||
<Card className="ot-card" aria-labelledby={titleId}>
|
||||
<header className="ot-card__head">
|
||||
<div>
|
||||
<Kicker dot={false}>학습자 성과 체크인</Kicker>
|
||||
<h2 id={titleId}>내가 관찰한 현재 상태를 직접 기록합니다</h2>
|
||||
<p>
|
||||
AI 궤적과 판정은 표시하지 않으며, 학습자가 입력한 세 축과 근거만
|
||||
원장에 보존합니다.
|
||||
</p>
|
||||
</div>
|
||||
<Badge tone="neutral">학습자 입력만</Badge>
|
||||
</header>
|
||||
<LearnerOutcomeCheckin
|
||||
sessionId={sessionId}
|
||||
sessionNo={sessionNo}
|
||||
observations={[]}
|
||||
turns={turns}
|
||||
onSubmitted={() => undefined}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
if (loadState === "loading") return <LoadingState />;
|
||||
if (loadState === "empty") return <EmptyState isSupervisorView={isSupervisorView} />;
|
||||
if (loadState === "error" || !data) {
|
||||
|
|
|
|||
|
|
@ -76,10 +76,11 @@ export class CalibrationLearnerContextError extends Error {
|
|||
}
|
||||
|
||||
export const calibrationTransferApi = {
|
||||
getForLearner: (signal?: AbortSignal) =>
|
||||
supplementalApi.get<CalibrationTransferReadModelResponse>(learnerPath(), {
|
||||
signal,
|
||||
}),
|
||||
getForLearner: (sessionId: string, signal?: AbortSignal) =>
|
||||
supplementalApi.get<CalibrationTransferReadModelResponse>(
|
||||
`${learnerPath()}?session_id=${encodeURIComponent(sessionId)}`,
|
||||
{ signal },
|
||||
),
|
||||
|
||||
getForTeacherSession: async (sessionId: string, signal?: AbortSignal) => {
|
||||
const dashboard = await supplementalApi.get<TeacherDashboardLookup>(
|
||||
|
|
|
|||
|
|
@ -526,6 +526,7 @@
|
|||
.mma-privacy-actions > .vg-btn,
|
||||
.mma-delete-fieldset .vg-btn {
|
||||
justify-self: start;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.mma-raw-access strong,
|
||||
|
|
|
|||
|
|
@ -365,9 +365,16 @@ export function rawAudioPlaybackUrl(
|
|||
}
|
||||
|
||||
export const multimodalAllianceApi = {
|
||||
getMetadata: (sessionId: string, signal?: AbortSignal) =>
|
||||
getMetadata: (
|
||||
sessionId: string,
|
||||
signal?: AbortSignal,
|
||||
includeDerived = true,
|
||||
) =>
|
||||
supplementalApi
|
||||
.get<WireMetadata>(basePath(sessionId), { signal })
|
||||
.get<WireMetadata>(
|
||||
`${basePath(sessionId)}?include_derived=${includeDerived ? "true" : "false"}`,
|
||||
{ signal },
|
||||
)
|
||||
.then(normalizeMetadata),
|
||||
getRawAudio: (sessionId: string, signal?: AbortSignal) =>
|
||||
supplementalApi
|
||||
|
|
|
|||
|
|
@ -1166,6 +1166,112 @@
|
|||
font-style: italic;
|
||||
}
|
||||
|
||||
.sr-first-checklist__heading,
|
||||
.sr-first-checklist__item-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.sr-first-checklist__version {
|
||||
flex: none;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-num);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.sr-first-checklist__note {
|
||||
margin: var(--sp-3) 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.sr-first-checklist__items {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
.sr-first-checklist__item {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-top: var(--sp-4);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
.sr-first-checklist__item:first-child {
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
.sr-first-checklist__item-head b {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
.sr-first-checklist__item > p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.sr-first-checklist__status {
|
||||
flex: none;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.sr-first-checklist__status--met {
|
||||
border-color: color-mix(in srgb, var(--pos-solid) 32%, var(--border-subtle));
|
||||
color: var(--pos-text);
|
||||
background: color-mix(in srgb, var(--pos-solid) 9%, transparent);
|
||||
}
|
||||
.sr-first-checklist__status--not_observed {
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-surface-2);
|
||||
}
|
||||
.sr-first-checklist__evidence-list {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
.sr-first-checklist__evidence {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-strong);
|
||||
background: var(--bg-surface-2);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sr-first-checklist__evidence:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 42%, var(--border-subtle));
|
||||
}
|
||||
.sr-first-checklist__evidence:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.sr-first-checklist__evidence span {
|
||||
color: var(--accent-text);
|
||||
font-family: var(--font-num);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sr-first-checklist__evidence q {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sr-first-checklist__empty {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sr-rubric {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
|
|
@ -1529,6 +1635,7 @@
|
|||
background: linear-gradient(180deg, color-mix(in srgb, var(--bg-surface-2) 94%, transparent), color-mix(in srgb, var(--bg-surface) 98%, transparent));
|
||||
}
|
||||
[data-theme="dark"] .sr-card--prepost,
|
||||
[data-theme="dark"] .sr-card--first-session-checklist,
|
||||
[data-theme="dark"] .sr-card--rubric {
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--bg-surface-2) 92%, transparent), color-mix(in srgb, var(--bg-surface) 96%, transparent));
|
||||
}
|
||||
|
|
@ -1538,6 +1645,7 @@
|
|||
[data-theme="dark"] .sr-chip-toggle,
|
||||
[data-theme="dark"] .sr-eval-scope,
|
||||
[data-theme="dark"] .sr-ws-item,
|
||||
[data-theme="dark"] .sr-first-checklist__evidence,
|
||||
[data-theme="dark"] .sr-prepost__field input,
|
||||
[data-theme="dark"] .sr-feedback,
|
||||
[data-theme="dark"] .sr-nextline {
|
||||
|
|
|
|||
|
|
@ -2843,7 +2843,7 @@
|
|||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius);
|
||||
min-height: 40px;
|
||||
min-height: 44px;
|
||||
padding: 9px 13px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
|
|
@ -4835,6 +4835,26 @@
|
|||
.sx-page--active .sx-review-button {
|
||||
min-width: 116px;
|
||||
}
|
||||
|
||||
/* 390px 이하에서는 코칭/전송 문구가 입력 필드를 122px까지 밀어 placeholder가
|
||||
세로로 잘렸다. 접근 가능한 이름은 유지하고 두 액션만 44px 아이콘형으로 압축한다. */
|
||||
.sx-page--active .sx-coach-trigger-btn,
|
||||
.sx-page--active .sx-compose .vg-btn {
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
padding-inline: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
.sx-page--active .sx-coach-trigger-btn > span {
|
||||
display: none;
|
||||
}
|
||||
.sx-page--active .sx-compose .vg-btn {
|
||||
font-size: 0;
|
||||
}
|
||||
.sx-page--active .sx-compose textarea {
|
||||
height: 44px;
|
||||
padding-inline: 7px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 프리스타트 아주 좁은 폭: 1180 이하 규칙이 아바타 열을 140px 로 고정해 두는데,
|
||||
|
|
@ -5557,11 +5577,14 @@
|
|||
}
|
||||
|
||||
.sx-page--active .sx-compose {
|
||||
margin-top: 5px;
|
||||
padding-top: 5px;
|
||||
margin-top: 3px;
|
||||
padding-top: 3px;
|
||||
}
|
||||
|
||||
.sx-page--active .sx-compose textarea {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.sx-page--active .sx-compose textarea,
|
||||
.sx-page--active .sx-compose .vg-btn {
|
||||
height: 44px;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue