From 3a9f70a97bfcab8761904d1f6c9e8eb28c506f33 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sun, 28 Jun 2026 20:13:21 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9A=B4=EC=98=81=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EA=B3=BC=20=ED=9A=8C=EA=B8=B0=20=EB=A6=AC=EB=B7=B0=20UI=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/e2e/admin.spec.ts | 126 +++++++ apps/web/e2e/layout-visual-gate.spec.ts | 98 +++++- apps/web/e2e/session-mvp.spec.ts | 32 +- apps/web/e2e/session-review-fixture.ts | 185 +++++++++- apps/web/e2e/session-review.spec.ts | 67 +++- apps/web/e2e/settings.spec.ts | 79 ++++- .../src/components/avatar/ClientAvatar.tsx | 6 +- apps/web/src/lib/api.gen.ts | 323 +++++++++++++++++- apps/web/src/lib/api.ts | 12 + apps/web/src/pages/Admin.tsx | 210 ++++++++++-- apps/web/src/pages/Session.tsx | 256 ++++++++++---- apps/web/src/pages/SessionReview.tsx | 238 ++++++++++++- apps/web/src/pages/Settings.tsx | 124 ++++++- apps/web/src/pages/admin/admin-console.css | 78 +++++ apps/web/src/pages/avatar-preview.css | 5 +- .../pages/session-review/session-review.css | 264 ++++++++++++++ apps/web/src/pages/session/session.css | 180 +++++++++- apps/web/src/pages/settings/settings.css | 64 +++- 18 files changed, 2210 insertions(+), 137 deletions(-) diff --git a/apps/web/e2e/admin.spec.ts b/apps/web/e2e/admin.spec.ts index 01bf59d..01f9089 100644 --- a/apps/web/e2e/admin.spec.ts +++ b/apps/web/e2e/admin.spec.ts @@ -53,6 +53,14 @@ interface AdminUsageBreakdown { cost_usd: number; } +interface AdminUsageDailyCost { + day: string; + turns: number; + tokens_in: number; + tokens_out: number; + cost_usd: number; +} + interface AdminUsageBudget { limit_usd: number; used_ratio: number; @@ -60,6 +68,17 @@ interface AdminUsageBudget { status: "disabled" | "ok" | "warn" | "exceeded"; } +interface AdminUsageEvaluatorCache { + enabled: boolean; + entries: number; + hits: number; + misses: number; + stores: number; + evictions: number; + requests: number; + hit_rate: number; +} + interface AdminUsageResponse { source: "database" | "server_session_registry"; durable: boolean; @@ -70,7 +89,9 @@ interface AdminUsageResponse { tokens_out: number; cost_usd: number; budget: AdminUsageBudget; + evaluator_cache?: AdminUsageEvaluatorCache; by_provider: AdminUsageBreakdown[]; + daily_cost?: AdminUsageDailyCost[]; } interface AdminUptimeResponse { @@ -99,6 +120,10 @@ interface AdminSupportTicket { subject: string; body: string; source_path: string; + parent_ticket_id?: string | null; + duplicate_count?: number; + duplicate_parent_candidate_id?: string | null; + child_ticket_count?: number; created_at: number; updated_at: number; } @@ -262,6 +287,30 @@ function costLabel(value: number) { return `$${value.toFixed(value < 0.01 ? 6 : 4)}`; } +function rateLabel(value: number) { + if (!Number.isFinite(value) || value <= 0) return "0%"; + return `${Math.round(value * 1000) / 10}%`; +} + +function evaluatorCache(response: AdminUsageResponse): AdminUsageEvaluatorCache { + return ( + response.evaluator_cache ?? { + enabled: false, + entries: 0, + hits: 0, + misses: 0, + stores: 0, + evictions: 0, + requests: 0, + hit_rate: 0, + } + ); +} + +function usageDailyCost(response: AdminUsageResponse): AdminUsageDailyCost[] { + return response.daily_cost ?? []; +} + function isOnline(seconds: number) { return Number.isFinite(seconds) && seconds > 0 && Date.now() / 1000 - seconds < 15 * 60; } @@ -446,6 +495,20 @@ test.describe("admin route", () => { ? "예산 주의" : "예산 정상", ); + await expect(page.locator(".ad-panel").filter({ hasText: "AI 비용" })).toContainText( + "평가 캐시 hit-rate", + ); + const cache = evaluatorCache(usage); + await expect(page.locator(".ad-panel").filter({ hasText: "AI 비용" })).toContainText( + cache.enabled + ? `${rateLabel(cache.hit_rate)} hit` + : "캐시 비활성", + ); + if (usageDailyCost(usage).length > 0) { + await expect(page.locator(".ad-panel").filter({ hasText: "AI 비용" })).toContainText( + "일별 비용 추이", + ); + } await expect(page.getByRole("heading", { name: "가용성" })).toBeVisible(); await expect(page.getByText("샘플 정상률")).toBeVisible(); expect(uptime.ok_ratio).toBeGreaterThanOrEqual(0); @@ -657,6 +720,69 @@ test.describe("admin route", () => { } }); + test("manually links duplicate operation tickets", async ({ page }, testInfo) => { + await signInAsAdmin(page); + + const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`; + const subject = `[E2E] 중복 운영 티켓 ${slug}`; + const createdTicketIds: string[] = []; + + try { + for (let i = 0; i < 2; i += 1) { + const create = await page.request.post("/api/users/support-tickets", { + data: { + category: "voice_browser", + priority: "normal", + subject, + body: "E2E 중복 티켓 수동 연결 검증입니다. 실제 음성 장애가 아닙니다.", + source_path: "/__e2e__/learn/session/duplicate", + }, + }); + await expectResponseOk(create); + const created = (await create.json()) as { ticket_id: string }; + createdTicketIds.push(created.ticket_id); + } + + const tickets = await openAdminTickets(page); + const createdTickets = tickets.tickets.filter((ticket) => createdTicketIds.includes(ticket.ticket_id)); + expect(createdTickets).toHaveLength(2); + expect(createdTickets.some((ticket) => (ticket.duplicate_count ?? 0) > 0)).toBeTruthy(); + + const linkableCard = page + .locator(".ad-ticket") + .filter({ hasText: subject }) + .filter({ has: page.getByRole("button", { name: "연결" }) }); + await expect(linkableCard).toBeVisible(); + + const patchPromise = page.waitForResponse( + (response) => + response.request().method() === "PATCH" && + createdTicketIds.some((ticketId) => response.url().endsWith(`/api/admin/tickets/${ticketId}`)), + ); + await linkableCard.getByRole("button", { name: "연결" }).click(); + const patchResponse = await patchPromise; + await expectResponseOk(patchResponse); + const updated = (await patchResponse.json()) as AdminSupportTicket; + expect(updated.parent_ticket_id).toBeTruthy(); + + await expect(page.locator(".ad-ticket").filter({ hasText: subject }).filter({ hasText: "중복 연결" })).toBeVisible(); + await expectNoHorizontalOverflow(page); + } finally { + for (const ticketId of createdTicketIds) { + const cleanup = await page.request.patch(`/api/admin/tickets/${ticketId}`, { + data: { + status: "resolved", + parent_ticket_id: "", + resolution_note: "E2E cleanup", + }, + }); + if (!cleanup.ok()) { + console.warn(`E2E duplicate ticket cleanup failed: ${cleanup.status()} ${await cleanup.text()}`); + } + } + } + }); + test("denies learner access to the admin API and UI", async ({ page }) => { await signInAsLearner(page); diff --git a/apps/web/e2e/layout-visual-gate.spec.ts b/apps/web/e2e/layout-visual-gate.spec.ts index ad93de3..03fd602 100644 --- a/apps/web/e2e/layout-visual-gate.spec.ts +++ b/apps/web/e2e/layout-visual-gate.spec.ts @@ -1,7 +1,13 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { expect, test, type Page } from "@playwright/test"; -import { FILLED_REVIEW_SESSION_ID, routeFilledSessionReview } from "./session-review-fixture"; +import { + EMPTY_REVIEW_SESSION_ID, + FILLED_REVIEW_SESSION_ID, + routeEmptySessionReview, + routeFilledSessionReview, + routePrepostMeasures, +} from "./session-review-fixture"; import { completeOnboarding, expectNoHorizontalOverflow, @@ -190,6 +196,81 @@ async function gateScreen( } } +async function expectEmptyReviewNoDeadThirdColumn(page: Page) { + const report = await page.evaluate(() => { + const root = document.querySelector(".sr-root--empty"); + const cols = document.querySelector(".sr-root--empty .sr-cols"); + const transcript = document.querySelector(".sr-card--transcript"); + if (!root || !cols || !transcript) { + return { + present: false, + columnCount: 0, + }; + } + + const gridTemplate = getComputedStyle(cols).gridTemplateColumns; + const columnCount = gridTemplate.split(" ").filter(Boolean).length; + const transcriptRect = transcript.getBoundingClientRect(); + + return { + present: true, + columnCount, + transcriptWidth: Math.round(transcriptRect.width), + mainColumnWidth: Math.round(cols.getBoundingClientRect().width), + }; + }); + + expect(report.present, "empty review layout should be mounted").toBe(true); + expect( + report.columnCount, + "empty review should collapse the desktop masonry layout instead of leaving a sparse third column", + ).toBeLessThanOrEqual(2); +} + +async function expectFilledReviewLearnerTwoColumn(page: Page) { + const report = await page.evaluate(() => { + const cols = document.querySelector(".sr-cols--learner"); + const transcript = document.querySelector(".sr-card--transcript"); + const overview = document.querySelector(".sr-overview"); + const rubric = document.querySelector(".sr-card--rubric"); + const worksheet = document.querySelector(".sr-card--worksheet"); + if (!cols || !transcript || !overview || !rubric || !worksheet) { + return { + present: false, + viewportWidth: window.innerWidth, + columnCount: 0, + transcriptRight: 0, + overviewLeft: 0, + rubricLeft: 0, + worksheetLeft: 0, + }; + } + + const columnCount = getComputedStyle(cols).gridTemplateColumns.split(" ").filter(Boolean).length; + const transcriptRect = transcript.getBoundingClientRect(); + const overviewRect = overview.getBoundingClientRect(); + const rubricRect = rubric.getBoundingClientRect(); + const worksheetRect = worksheet.getBoundingClientRect(); + return { + present: true, + viewportWidth: window.innerWidth, + columnCount, + transcriptRight: Math.ceil(transcriptRect.right), + overviewLeft: Math.floor(overviewRect.left), + rubricLeft: Math.floor(rubricRect.left), + worksheetLeft: Math.floor(worksheetRect.left), + }; + }); + + expect(report.present, "filled learner review layout should be mounted").toBe(true); + if (report.viewportWidth > 1180) { + expect(report.columnCount, "desktop learner review should use transcript + work column").toBe(2); + expect(report.transcriptRight).toBeLessThanOrEqual(report.overviewLeft); + expect(report.rubricLeft).toBeGreaterThanOrEqual(report.overviewLeft); + expect(report.worksheetLeft).toBeGreaterThanOrEqual(report.overviewLeft); + } +} + test.describe("layout visual gate @single-run", () => { test.describe.configure({ mode: "serial" }); @@ -257,10 +338,25 @@ test.describe("layout visual gate @single-run", () => { test("session review stays contained across all widths", async ({ page }) => { await signInAsLearner(page); await routeFilledSessionReview(page); + await routePrepostMeasures(page); await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`); await gateScreen(page, "session-review", async () => { await expect(page.locator(".sr-overview")).toBeVisible({ timeout: 15_000 }); await expect(page.getByText("사례개념화 워크시트")).toBeVisible(); + await expectFilledReviewLearnerTwoColumn(page); + }); + }); + + test("empty session review avoids sparse column gaps across all widths", async ({ page }) => { + await signInAsLearner(page); + await routeEmptySessionReview(page); + await routePrepostMeasures(page); + await page.goto(`/learn/session/${EMPTY_REVIEW_SESSION_ID}/review`); + await gateScreen(page, "session-review-empty", async () => { + await expect(page.locator(".sr-root--empty")).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText("축어록 저장 후 생성")).toBeVisible(); + await expect(page.getByText("감정 타임라인 대기")).toBeVisible(); + await expectEmptyReviewNoDeadThirdColumn(page); }); }); diff --git a/apps/web/e2e/session-mvp.spec.ts b/apps/web/e2e/session-mvp.spec.ts index 9bfb567..62a8f0d 100644 --- a/apps/web/e2e/session-mvp.spec.ts +++ b/apps/web/e2e/session-mvp.spec.ts @@ -4,7 +4,19 @@ const sessionId = "33333333-3333-4333-8333-333333333333"; const learnerText = "요즘 많이 힘들었겠어요. 어떤 마음이 가장 크게 남아 있나요?"; const clientReply = "괜찮아요. 천천히 말해볼게요."; +function deferred() { + let resolve!: () => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + async function routeMvpApi(page: Page) { + const sessionStartRequests: unknown[] = []; + const streamSeen = deferred(); + const streamGate = deferred(); + await page.route("**/api/auth/me", async (route) => { await route.fulfill({ status: 200, @@ -49,6 +61,7 @@ async function routeMvpApi(page: Page) { await route.fallback(); return; } + sessionStartRequests.push(route.request().postDataJSON()); await route.fulfill({ status: 201, contentType: "application/json", @@ -76,7 +89,7 @@ async function routeMvpApi(page: Page) { session_no: 1, status: "active", stage: "라포", - theory_mode: "humanistic", + theory_mode: "cbt", effective_openness: 0.21, started_at: new Date().toISOString(), ended_at: null, @@ -87,6 +100,8 @@ async function routeMvpApi(page: Page) { }); await page.route(`**/api/sessions/${sessionId}/stream`, async (route) => { + streamSeen.resolve(); + await streamGate.promise; await route.fulfill({ status: 200, contentType: "text/event-stream", @@ -176,6 +191,8 @@ async function routeMvpApi(page: Page) { }), }); }); + + return { sessionStartRequests, streamGate, streamSeen }; } test.describe("P1 MVP core loop", () => { @@ -187,21 +204,32 @@ test.describe("P1 MVP core loop", () => { page.on("console", (message) => { if (message.type() === "error") diagnostics.push(`console: ${message.text()}`); }); - await routeMvpApi(page); + const api = await routeMvpApi(page); await page.goto("/learn/session/P1"); await expect( page.getByRole("button", { name: "회기 시작" }), diagnostics.join("\n") || (await page.locator("#root").innerText().catch(() => "")), ).toBeVisible(); + await page.getByRole("button", { name: /CBT/ }).click(); await page.getByRole("button", { name: "회기 시작" }).click(); await expect(page.locator(".sx-page.sx-page--active")).toBeVisible(); + expect(api.sessionStartRequests[0]).toMatchObject({ + persona_code: "P1", + theory_mode: "cbt", + }); await page.getByLabel("학습자 발화 입력").fill(learnerText); await page.getByRole("button", { name: "보내기" }).click(); + await api.streamSeen.promise; await expect(page.locator(".sx-utt").filter({ hasText: learnerText })).toBeVisible(); + await expect(page.locator(".sx-utt.is-thinking").filter({ hasText: "답변을 준비 중입니다." })).toBeVisible(); + await expect(page.getByLabel("학습자 발화 입력")).toBeDisabled(); + + api.streamGate.resolve(); await expect(page.locator(".sx-utt").filter({ hasText: clientReply })).toBeVisible(); + await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled(); await page.getByRole("button", { name: "회기 종료" }).click(); await page.getByRole("button", { name: "종료하고 리뷰 보기" }).click(); diff --git a/apps/web/e2e/session-review-fixture.ts b/apps/web/e2e/session-review-fixture.ts index bdd5d63..f9eda2b 100644 --- a/apps/web/e2e/session-review-fixture.ts +++ b/apps/web/e2e/session-review-fixture.ts @@ -1,7 +1,12 @@ import type { Page } from "@playwright/test"; -import type { SessionReviewResponse } from "../src/lib/api"; +import type { + SessionReviewResponse, + UserPrepostMeasureItem, + UserPrepostMeasuresResponse, +} from "../src/lib/api"; export const FILLED_REVIEW_SESSION_ID = "filled-review-visual"; +export const EMPTY_REVIEW_SESSION_ID = "empty-review-visual"; export function filledReviewResponse( sessionId: string = FILLED_REVIEW_SESSION_ID, @@ -67,7 +72,10 @@ export function filledReviewResponse( { kind: "reflect", label: "감정 반영" }, { kind: "explore", label: "개방 질문" }, ], - nonverbal: [{ kind: "pace", label: "말 속도", detail: "안정" }], + nonverbal: [ + { kind: "pace", label: "말 속도", detail: "안정" }, + { kind: "paralinguistic", label: "음성 단서", detail: "한숨 감지 · 신뢰도 82%" }, + ], note: { author: "AI 슈퍼바이저", tone: "ai", @@ -239,3 +247,176 @@ export async function routeFilledSessionReview( }); }); } + +export function prepostMeasuresResponse( + measures: UserPrepostMeasureItem[] = [ + { + measure_id: "prepost-self-efficacy-pre", + pilot_id: "phase3-pilot-draft", + measure_name: "self_efficacy", + timepoint: "pre", + raw_score: 3, + min_score: 1, + max_score: 5, + normalized_score: 0.5, + instrument_version: "pilot-prepost-scaffold-2026-06-28", + item_count: 1, + collected_at: 1782662400, + updated_at: 1782662400, + }, + { + measure_id: "prepost-self-efficacy-post", + pilot_id: "phase3-pilot-draft", + measure_name: "self_efficacy", + timepoint: "post", + raw_score: 4, + min_score: 1, + max_score: 5, + normalized_score: 0.75, + instrument_version: "pilot-prepost-scaffold-2026-06-28", + item_count: 1, + collected_at: 1782662400, + updated_at: 1782662400, + }, + ], +): UserPrepostMeasuresResponse { + const completePairs = new Set(); + for (const measureName of ["self_efficacy", "skill_proficiency", "training_satisfaction"]) { + const hasPre = measures.some( + (item) => item.measure_name === measureName && item.timepoint === "pre", + ); + const hasPost = measures.some( + (item) => item.measure_name === measureName && item.timepoint === "post", + ); + if (hasPre && hasPost) completePairs.add(measureName); + } + return { + source: "database", + durable: true, + pilot_id: "phase3-pilot-draft", + required_measure_names: [ + "self_efficacy", + "skill_proficiency", + "training_satisfaction", + ], + required_timepoints: ["pre", "post"], + complete_measure_pairs: completePairs.size, + measures, + generated_at: 1782662400, + }; +} + +export async function routePrepostMeasures(page: Page) { + let measures = prepostMeasuresResponse().measures; + + await page.route("**/api/users/me/prepost-measures**", async (route) => { + const request = route.request(); + if (request.method() === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(prepostMeasuresResponse(measures)), + }); + return; + } + + if (request.method() === "PUT") { + const body = request.postDataJSON() as { + measure_name: UserPrepostMeasureItem["measure_name"]; + timepoint: UserPrepostMeasureItem["timepoint"]; + raw_score: number; + min_score?: number; + max_score?: number; + instrument_version?: string; + item_count?: number; + pilot_id?: string; + }; + const now = 1782666000; + const next: UserPrepostMeasureItem = { + measure_id: `prepost-${body.measure_name}-${body.timepoint}`, + pilot_id: body.pilot_id ?? "phase3-pilot-draft", + measure_name: body.measure_name, + timepoint: body.timepoint, + raw_score: body.raw_score, + min_score: body.min_score ?? 1, + max_score: body.max_score ?? 5, + normalized_score: + (body.raw_score - (body.min_score ?? 1)) / ((body.max_score ?? 5) - (body.min_score ?? 1)), + instrument_version: body.instrument_version ?? "pilot-prepost-scaffold-2026-06-28", + item_count: body.item_count ?? 1, + collected_at: now, + updated_at: now, + }; + measures = measures + .filter( + (item) => + !( + item.measure_name === next.measure_name && + item.timepoint === next.timepoint && + item.instrument_version === next.instrument_version + ), + ) + .concat(next); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(next), + }); + return; + } + + await route.fallback(); + }); +} + +export function emptyReviewResponse( + sessionId: string = EMPTY_REVIEW_SESSION_ID, +): SessionReviewResponse { + const response = filledReviewResponse(sessionId); + return { + ...response, + date: "2026-06-28", + durationLabel: "0분", + durationSeconds: 0, + reachedPhase: "라포", + sessionSignal: "종료됨", + supervisorState: "평가 대기", + summary: + "축어록이 아직 저장되지 않아 평가 근거를 생성하지 않았습니다. 빈 상태에서는 임의의 강점이나 개선점을 표시하지 않습니다.", + phases: [], + phaseAxis: [], + valenceAxis: [], + clientValence: [], + counselorBaseline: [], + turns: [], + rubric: [], + goodMoments: [], + growthPoints: [], + caseWorksheet: { + status: "empty", + generatedBy: "rule-based transcript extractor", + savedAt: null, + sections: [], + limitations: ["축어록이 없어 사례개념화 워크시트를 생성하지 않았습니다."], + }, + nextLine: null, + clientFeedback: null, + audioUrl: null, + pdfExportUrl: null, + degraded: false, + reviewReady: false, + }; +} + +export async function routeEmptySessionReview( + page: Page, + sessionId: string = EMPTY_REVIEW_SESSION_ID, +) { + await page.route(`**/api/sessions/${sessionId}/review`, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(emptyReviewResponse(sessionId)), + }); + }); +} diff --git a/apps/web/e2e/session-review.spec.ts b/apps/web/e2e/session-review.spec.ts index fcb4c06..5beb2de 100644 --- a/apps/web/e2e/session-review.spec.ts +++ b/apps/web/e2e/session-review.spec.ts @@ -1,5 +1,9 @@ import { expect, test, type Page, type Response } from "@playwright/test"; -import { FILLED_REVIEW_SESSION_ID, routeFilledSessionReview } from "./session-review-fixture"; +import { + FILLED_REVIEW_SESSION_ID, + routeFilledSessionReview, + routePrepostMeasures, +} from "./session-review-fixture"; import { expectNoHorizontalOverflow, fetchAvailablePersona, signInAsLearner } from "./support"; interface SessionStartResponse { @@ -45,6 +49,7 @@ async function expectReviewRegionsReachable(page: Page) { await expect(page.locator(".sr-card--flow")).toBeVisible(); await expect(page.locator(".sr-card--rubric")).toBeVisible(); await expect(page.locator(".sr-card--transcript")).toBeVisible(); + await expect(page.locator(".sr-card--prepost")).toBeVisible(); const grid = await page.locator(".sr-cols").evaluate((el) => { const style = window.getComputedStyle(el); @@ -64,19 +69,34 @@ async function expectReviewRegionsReachable(page: Page) { await expect(page.locator(".sr-card--transcript")).toBeInViewport(); const railGeometry = await page.locator(".sr-cols").evaluate((gridEl) => { + const root = gridEl.closest(".sr-root"); const overview = gridEl.querySelector(".sr-overview")?.getBoundingClientRect(); const transcript = gridEl.querySelector(".sr-card--transcript")?.getBoundingClientRect(); + const rubric = gridEl.querySelector(".sr-card--rubric")?.getBoundingClientRect(); + const prepost = gridEl.querySelector(".sr-card--prepost")?.getBoundingClientRect(); + const worksheet = gridEl.querySelector(".sr-card--worksheet")?.getBoundingClientRect(); const columns = window .getComputedStyle(gridEl) .gridTemplateColumns.split(" ") .filter(Boolean).length; return { + isEmpty: root?.classList.contains("sr-root--empty") ?? false, columns, + overviewLeft: overview ? Math.floor(overview.left) : 0, overviewRight: overview ? Math.ceil(overview.right) : 0, transcriptLeft: transcript ? Math.floor(transcript.left) : 0, + transcriptRight: transcript ? Math.ceil(transcript.right) : 0, + rubricLeft: rubric ? Math.floor(rubric.left) : 0, + prepostLeft: prepost ? Math.floor(prepost.left) : 0, + worksheetLeft: worksheet ? Math.floor(worksheet.left) : 0, }; }); - if (railGeometry.columns >= 3) { + if (!railGeometry.isEmpty && railGeometry.columns === 2) { + expect(railGeometry.transcriptRight).toBeLessThanOrEqual(railGeometry.overviewLeft); + expect(railGeometry.rubricLeft).toBeGreaterThanOrEqual(railGeometry.overviewLeft); + expect(railGeometry.prepostLeft).toBeGreaterThanOrEqual(railGeometry.overviewLeft); + expect(railGeometry.worksheetLeft).toBeGreaterThanOrEqual(railGeometry.overviewLeft); + } else if (railGeometry.columns >= 3) { expect(railGeometry.overviewRight).toBeLessThanOrEqual(railGeometry.transcriptLeft); } } @@ -84,6 +104,7 @@ async function expectReviewRegionsReachable(page: Page) { test.describe("session review", () => { test("renders server review data without legacy transcript fixtures", async ({ page }) => { await signInAsLearner(page); + await routePrepostMeasures(page); const sessionId = await createEndedSession(page); const reviewResponsePromise = page.waitForResponse(isReviewResponse(sessionId)); @@ -124,6 +145,7 @@ test.describe("session review", () => { test("renders filled review evidence state without clipping", async ({ page }) => { await signInAsLearner(page); await routeFilledSessionReview(page); + await routePrepostMeasures(page); const reviewResponsePromise = page.waitForResponse(isReviewResponse(FILLED_REVIEW_SESSION_ID)); await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`); @@ -141,6 +163,24 @@ test.describe("session review", () => { await expect(page.getByText("회기 흐름")).toBeVisible(); await expect(page.getByText("세션 트랜스크립트")).toBeVisible(); await expect(page.getByText("기법 사용 분포")).toBeVisible(); + await expect(page.getByText("파일럿 증거 원장")).toBeVisible(); + await expect(page.getByText("1/3 쌍")).toBeVisible(); + const prepostInputs = page.locator(".sr-card--prepost input"); + await expect(prepostInputs).toHaveCount(6); + await expect(prepostInputs.nth(0)).toHaveValue("3"); + await expect(prepostInputs.nth(1)).toHaveValue("4"); + const prepostLabels = await prepostInputs.evaluateAll((inputs) => + inputs.map((input) => input.getAttribute("aria-label")), + ); + expect(prepostLabels).toEqual([ + "자기효능감 사전 점수", + "자기효능감 사후 점수", + "기술숙련도 사전 점수", + "기술숙련도 사후 점수", + "수련만족도 사전 점수", + "수련만족도 사후 점수", + ]); + await expect(page.getByText("효과 판정과 통계 검정은 평가설계 확정 후 별도 산출합니다.")).toBeVisible(); await expect(page.getByText("사례개념화 워크시트")).toBeVisible(); await expect(page.getByText("감정 반영").first()).toBeVisible(); await expect(page.getByText("AI 슈퍼바이저").first()).toBeVisible(); @@ -154,4 +194,27 @@ test.describe("session review", () => { await expectReviewRegionsReachable(page); await expectNoHorizontalOverflow(page); }); + + test("stores learner pre/post pilot evidence without effect claims", async ({ page }) => { + await signInAsLearner(page); + await routeFilledSessionReview(page); + await routePrepostMeasures(page); + + await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`); + await expect(page.locator(".sr-card--prepost")).toBeVisible({ timeout: 15_000 }); + + const prepostInputs = page.locator(".sr-card--prepost input"); + await expect(prepostInputs).toHaveCount(6); + await expect(page.getByText("1/3 쌍")).toBeVisible(); + await expect(page.getByText("효과 판정과 통계 검정은 평가설계 확정 후 별도 산출합니다.")).toBeVisible(); + + await prepostInputs.nth(2).fill("2.5"); + await prepostInputs.nth(3).fill("3.5"); + await expect(prepostInputs.nth(2)).toHaveValue("2.5"); + await expect(prepostInputs.nth(3)).toHaveValue("3.5"); + await page.getByRole("button", { name: "점수 저장" }).click(); + + await expect(page.getByText("2/3 쌍")).toBeVisible(); + await expect(page.locator(".sr-prepost__actions")).toContainText("저장된 값 기준"); + }); }); diff --git a/apps/web/e2e/settings.spec.ts b/apps/web/e2e/settings.spec.ts index 6b3e97e..2811e67 100644 --- a/apps/web/e2e/settings.spec.ts +++ b/apps/web/e2e/settings.spec.ts @@ -29,6 +29,17 @@ interface UserPreferencesResponse { }; } +interface UserSupportTicketsResponse { + source: "database"; + durable: boolean; + tickets: Array<{ + ticket_id: string; + status: string; + subject: string; + resolution_note: string; + }>; +} + interface AdminEngineConfigResponse { engine_mode: string; engine_url: string; @@ -214,6 +225,9 @@ async function expectNoSettingsControlClipping(page: Page) { ".vg-set__field", ".vg-set__control-block", ".vg-set__opt", + ".vg-set__tickets", + ".vg-set__ticket", + ".vg-set__ticket-note", ".vg-set__voice", ".vg-set__range-row", ".vg-set__seg", @@ -405,27 +419,39 @@ async function openSettings(page: Page, options: { admin?: boolean } = {}) { const voicePresetsPromise = page.waitForResponse( isApiResponse("GET", "/users/me/voice-presets"), ); + const supportTicketsPromise = page.waitForResponse( + isApiResponse("GET", "/users/support-tickets"), + ); const engineConfigPromise = options.admin ? page.waitForResponse(isApiResponse("GET", "/admin/engine-config")) : null; await page.goto("/settings"); - const [profileResponse, preferencesResponse, voicePresetsResponse, engineConfigResponse] = + const [ + profileResponse, + preferencesResponse, + voicePresetsResponse, + supportTicketsResponse, + engineConfigResponse, + ] = await Promise.all([ profilePromise, preferencesPromise, voicePresetsPromise, + supportTicketsPromise, engineConfigPromise, ]); await expectResponseOk(profileResponse); await expectResponseOk(preferencesResponse); await expectResponseOk(voicePresetsResponse); + await expectResponseOk(supportTicketsResponse); if (engineConfigResponse) await expectResponseOk(engineConfigResponse); const profile = (await profileResponse.json()) as UserProfileResponse; const preferences = (await preferencesResponse.json()) as UserPreferencesResponse; + const supportTickets = (await supportTicketsResponse.json()) as UserSupportTicketsResponse; const engineConfig = engineConfigResponse ? ((await engineConfigResponse.json()) as AdminEngineConfigResponse) : null; @@ -433,7 +459,7 @@ async function openSettings(page: Page, options: { admin?: boolean } = {}) { await expect(page).toHaveURL(/\/settings$/); await expect(page.locator(".vg-set")).toBeVisible(); - return { profile, preferences, engineConfig }; + return { profile, preferences, supportTickets, engineConfig }; } test.describe("settings page", () => { @@ -580,6 +606,55 @@ test.describe("settings page", () => { expect(preferences.notifications.product_news).toBe(nextProductNews); }); + test("learner sees only own support ticket status and resolution note", async ({ + page, + }, testInfo) => { + const learnerName = `Settings Ticket Learner ${testInfo.project.name}`; + const learnerEmail = await signInAs(page, "learner", testInfo, learnerName); + const subject = `설정 티켓 상태 ${slugFor(testInfo)}`; + const hiddenBody = `설정 화면에는 보이면 안 되는 문의 본문 ${slugFor(testInfo)}`; + const create = await page.request.post("/api/users/support-tickets", { + data: { + category: "session_review", + priority: "high", + subject, + body: hiddenBody, + source_path: "/settings", + }, + }); + await expectResponseOk(create); + const created = (await create.json()) as { ticket_id: string }; + + await signInAs( + page, + "admin", + testInfo, + `Settings Ticket Admin ${testInfo.project.name}`, + `settings.ticket.admin.${slugFor(testInfo)}@twentyoz.kr`, + ); + const resolutionNote = `재처리 완료 ${slugFor(testInfo)}`; + const patch = await page.request.patch(`/api/admin/tickets/${created.ticket_id}`, { + data: { + status: "resolved", + assigned_group: "서비스 운영자", + resolution_note: resolutionNote, + }, + }); + await expectResponseOk(patch); + + await signInAs(page, "learner", testInfo, learnerName, learnerEmail); + const { supportTickets } = await openSettings(page); + expect(supportTickets.tickets.some((ticket) => ticket.ticket_id === created.ticket_id)).toBe( + true, + ); + + const support = page.locator("#set-support"); + await expect(support).toContainText(subject); + await expect(support).toContainText("해결"); + await expect(support).toContainText(resolutionNote); + await expect(support).not.toContainText(hiddenBody); + }); + test("admin sees and updates the AI engine settings panel @single-run", async ({ page }, testInfo) => { const displayName = `Settings Admin ${testInfo.project.name}`; const email = await signInAs(page, "admin", testInfo, displayName); diff --git a/apps/web/src/components/avatar/ClientAvatar.tsx b/apps/web/src/components/avatar/ClientAvatar.tsx index d90f4f9..992808f 100644 --- a/apps/web/src/components/avatar/ClientAvatar.tsx +++ b/apps/web/src/components/avatar/ClientAvatar.tsx @@ -411,7 +411,11 @@ const AVATAR_CSS = ` .vg-avatar__stage{ position:relative;width:100%;border-radius:50%; display:flex;align-items:center;justify-content:center;overflow:hidden; - background:var(--bg-stage); + background: + radial-gradient(circle at 50% 38%, rgba(251,250,248,.98) 0%, rgba(246,240,236,.9) 42%, rgba(238,244,242,.76) 64%, rgba(145,200,189,.22) 84%, rgba(30,39,36,.18) 100%); + box-shadow: + inset 0 0 0 1px rgba(251,250,248,.28), + inset 0 -24px 40px rgba(28,42,42,.13); } .vg-avatar__aura{ position:absolute;inset:-12%;border-radius:50%;pointer-events:none; diff --git a/apps/web/src/lib/api.gen.ts b/apps/web/src/lib/api.gen.ts index 2a66d0b..1efbd1a 100644 --- a/apps/web/src/lib/api.gen.ts +++ b/apps/web/src/lib/api.gen.ts @@ -1188,6 +1188,24 @@ export interface paths { patch: operations["patch_preferences_users_me_preferences_patch"]; trace?: never; }; + "/users/me/prepost-measures": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List My Prepost Measures */ + get: operations["list_my_prepost_measures_users_me_prepost_measures_get"]; + /** Upsert My Prepost Measure */ + put: operations["upsert_my_prepost_measure_users_me_prepost_measures_put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/users/me/voice-presets": { parameters: { query?: never; @@ -1212,7 +1230,8 @@ export interface paths { path?: never; cookie?: never; }; - get?: never; + /** List My Support Tickets */ + get: operations["list_my_support_tickets_users_support_tickets_get"]; put?: never; /** Create Support Ticket */ post: operations["create_support_ticket_users_support_tickets_post"]; @@ -1349,15 +1368,31 @@ export interface components { * @enum {string} */ category: "account_access" | "session_review" | "voice_browser" | "content_scenario" | "safety" | "other"; + /** + * Child Ticket Count + * @default 0 + */ + child_ticket_count: number; /** Created At */ created_at: number; + /** + * Duplicate Count + * @default 0 + */ + duplicate_count: number; + /** Duplicate Parent Candidate Id */ + duplicate_parent_candidate_id?: string | null; /** * Event Count * @default 0 */ event_count: number; + /** Fingerprint */ + fingerprint: string; /** Last Event At */ last_event_at?: number | null; + /** Parent Ticket Id */ + parent_ticket_id?: string | null; /** * Priority * @enum {string} @@ -1386,6 +1421,8 @@ export interface components { AdminTicketPatch: { /** Assigned Group */ assigned_group?: string | null; + /** Parent Ticket Id */ + parent_ticket_id?: string | null; /** Priority */ priority?: ("low" | "normal" | "high" | "urgent") | null; /** Resolution Note */ @@ -1521,6 +1558,38 @@ export interface components { /** Used Ratio */ used_ratio: number; }; + /** AdminUsageDailyCost */ + AdminUsageDailyCost: { + /** Cost Usd */ + cost_usd: number; + /** Day */ + day: string; + /** Tokens In */ + tokens_in: number; + /** Tokens Out */ + tokens_out: number; + /** Turns */ + turns: number; + }; + /** AdminUsageEvaluatorCache */ + AdminUsageEvaluatorCache: { + /** Enabled */ + enabled: boolean; + /** Entries */ + entries: number; + /** Evictions */ + evictions: number; + /** Hit Rate */ + hit_rate: number; + /** Hits */ + hits: number; + /** Misses */ + misses: number; + /** Requests */ + requests: number; + /** Stores */ + stores: number; + }; /** AdminUsageResponse */ AdminUsageResponse: { budget: components["schemas"]["AdminUsageBudget"]; @@ -1528,8 +1597,11 @@ export interface components { by_provider: components["schemas"]["AdminUsageBreakdown"][]; /** Cost Usd */ cost_usd: number; + /** Daily Cost */ + daily_cost?: components["schemas"]["AdminUsageDailyCost"][]; /** Durable */ durable: boolean; + evaluator_cache: components["schemas"]["AdminUsageEvaluatorCache"]; /** Generated At */ generated_at: number; /** Metered Turns */ @@ -2904,7 +2976,7 @@ export interface components { * Kind * @enum {string} */ - kind: "audio" | "silence" | "pace" | "barge_in"; + kind: "audio" | "silence" | "pace" | "barge_in" | "paralinguistic" | "prosody" | "audio_quality"; /** Label */ label: string; }; @@ -3649,6 +3721,102 @@ export interface components { */ voice_rate: number; }; + /** UserPrepostMeasureItem */ + UserPrepostMeasureItem: { + /** Collected At */ + collected_at: number; + /** Instrument Version */ + instrument_version: string; + /** Item Count */ + item_count: number; + /** Max Score */ + max_score: number; + /** Measure Id */ + measure_id: string; + /** + * Measure Name + * @enum {string} + */ + measure_name: "self_efficacy" | "skill_proficiency" | "training_satisfaction"; + /** Min Score */ + min_score: number; + /** Normalized Score */ + normalized_score: number; + /** Pilot Id */ + pilot_id: string; + /** Raw Score */ + raw_score: number; + /** + * Timepoint + * @enum {string} + */ + timepoint: "pre" | "post"; + /** Updated At */ + updated_at: number; + }; + /** UserPrepostMeasureRequest */ + UserPrepostMeasureRequest: { + /** + * Instrument Version + * @default pilot-prepost-scaffold-2026-06-28 + */ + instrument_version: string; + /** + * Item Count + * @default 1 + */ + item_count: number; + /** + * Max Score + * @default 5 + */ + max_score: number; + /** + * Measure Name + * @enum {string} + */ + measure_name: "self_efficacy" | "skill_proficiency" | "training_satisfaction"; + /** + * Min Score + * @default 1 + */ + min_score: number; + /** + * Pilot Id + * @default phase3-pilot-draft + */ + pilot_id: string; + /** Raw Score */ + raw_score: number; + /** + * Timepoint + * @enum {string} + */ + timepoint: "pre" | "post"; + }; + /** UserPrepostMeasuresResponse */ + UserPrepostMeasuresResponse: { + /** Complete Measure Pairs */ + complete_measure_pairs: number; + /** Durable */ + durable: boolean; + /** Generated At */ + generated_at: number; + /** Measures */ + measures: components["schemas"]["UserPrepostMeasureItem"][]; + /** Pilot Id */ + pilot_id: string; + /** Required Measure Names */ + required_measure_names: ("self_efficacy" | "skill_proficiency" | "training_satisfaction")[]; + /** Required Timepoints */ + required_timepoints: ("pre" | "post")[]; + /** + * Source + * @constant + * @enum {string} + */ + source: "database"; + }; /** UserProfilePatch */ UserProfilePatch: { /** Affiliation */ @@ -3751,6 +3919,40 @@ export interface components { /** User Id */ user_id: string; }; + /** UserSupportTicketListItem */ + UserSupportTicketListItem: { + /** Assigned Group */ + assigned_group: string; + /** + * Category + * @enum {string} + */ + category: "account_access" | "session_review" | "voice_browser" | "content_scenario" | "safety" | "other"; + /** Created At */ + created_at: number; + /** + * Priority + * @enum {string} + */ + priority: "low" | "normal" | "high" | "urgent"; + /** Resolution Note */ + resolution_note: string; + /** Resolved At */ + resolved_at?: number | null; + /** Source Path */ + source_path: string; + /** + * Status + * @enum {string} + */ + status: "open" | "triaged" | "in_progress" | "resolved" | "closed"; + /** Subject */ + subject: string; + /** Ticket Id */ + ticket_id: string; + /** Updated At */ + updated_at: number; + }; /** UserSupportTicketRequest */ UserSupportTicketRequest: { /** Body */ @@ -3800,6 +4002,21 @@ export interface components { /** Ticket Id */ ticket_id: string; }; + /** UserSupportTicketsResponse */ + UserSupportTicketsResponse: { + /** Durable */ + durable: boolean; + /** Generated At */ + generated_at: number; + /** + * Source + * @constant + * @enum {string} + */ + source: "database"; + /** Tickets */ + tickets: components["schemas"]["UserSupportTicketListItem"][]; + }; /** ValidationError */ ValidationError: { /** Location */ @@ -6083,6 +6300,76 @@ export interface operations { }; }; }; + list_my_prepost_measures_users_me_prepost_measures_get: { + parameters: { + query?: { + pilot_id?: string; + }; + 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"]["UserPrepostMeasuresResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + upsert_my_prepost_measure_users_me_prepost_measures_put: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserPrepostMeasureRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserPrepostMeasureItem"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_voice_presets_users_me_voice_presets_get: { parameters: { query?: never; @@ -6115,6 +6402,38 @@ export interface operations { }; }; }; + list_my_support_tickets_users_support_tickets_get: { + parameters: { + query?: never; + 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"]["UserSupportTicketsResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; create_support_ticket_users_support_tickets_post: { parameters: { query?: never; diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index bfca7dd..081049c 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -562,6 +562,11 @@ export type UserProfilePatchRequest = ApiSchema<"UserProfilePatch">; export type OnboardingRequest = ApiSchema<"OnboardingRequest">; export type AvatarUploadResponse = ApiSchema<"AvatarUploadResponse">; export type VoicePresetResponse = ApiSchema<"VoicePresetResponse">; +export type UserSupportTicketListItem = ApiSchema<"UserSupportTicketListItem">; +export type UserSupportTicketsResponse = ApiSchema<"UserSupportTicketsResponse">; +export type UserPrepostMeasureRequest = ApiSchema<"UserPrepostMeasureRequest">; +export type UserPrepostMeasureItem = ApiSchema<"UserPrepostMeasureItem">; +export type UserPrepostMeasuresResponse = ApiSchema<"UserPrepostMeasuresResponse">; export type RoleString = "learner" | "teacher" | "admin" | string; @@ -588,6 +593,13 @@ export const userApi = { updatePreferences: (body: UserPreferencesPatchRequest) => apiFetch("/users/me/preferences", { method: "PATCH", body }), voicePresets: () => api.get("/users/me/voice-presets"), + supportTickets: () => api.get("/users/support-tickets"), + prepostMeasures: (pilotId = "phase3-pilot-draft") => + api.get( + `/users/me/prepost-measures?pilot_id=${encodeURIComponent(pilotId)}`, + ), + upsertPrepostMeasure: (body: UserPrepostMeasureRequest) => + apiFetch("/users/me/prepost-measures", { method: "PUT", body }), }; export type AdminEngineConfigResponse = ApiSchema<"AdminEngineConfigResponse">; diff --git a/apps/web/src/pages/Admin.tsx b/apps/web/src/pages/Admin.tsx index d46d0ec..17f98dd 100644 --- a/apps/web/src/pages/Admin.tsx +++ b/apps/web/src/pages/Admin.tsx @@ -221,6 +221,48 @@ function usageBudgetDetail(data: AdminUsageResponse): string { return `${costLabel(data.cost_usd)} / ${costLabel(budget.limit_usd)} · ${pct}% 사용${remaining}`; } +function rateLabel(value: number): string { + if (!Number.isFinite(value) || value <= 0) return "0%"; + return `${Math.round(value * 1000) / 10}%`; +} + +function evaluatorCache(data: AdminUsageResponse): AdminUsageResponse["evaluator_cache"] { + return ( + data.evaluator_cache ?? { + enabled: false, + entries: 0, + hits: 0, + misses: 0, + stores: 0, + evictions: 0, + requests: 0, + hit_rate: 0, + } + ); +} + +function evaluatorCacheLabel(data: AdminUsageResponse): string { + const cache = evaluatorCache(data); + if (!cache.enabled) return "캐시 비활성"; + return `${rateLabel(cache.hit_rate)} hit`; +} + +function evaluatorCacheDetail(data: AdminUsageResponse): string { + const cache = evaluatorCache(data); + if (!cache.enabled) return "EVALUATOR_SEMANTIC_CACHE 설정이 꺼져 있습니다."; + return `${countLabel(cache.hits)} / ${countLabel(cache.requests)} hit · entry ${countLabel(cache.entries)}개 · 저장 ${countLabel(cache.stores)}회`; +} + +function usageDailyCost(data: AdminUsageResponse): NonNullable { + return data.daily_cost ?? []; +} + +function usageDayLabel(day: string): string { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day); + if (!match) return day; + return `${match[2]}.${match[3]}`; +} + function uptimeLabel(data: AdminUptimeResponse | null): string { if (!data) return "-"; if (!data.durable || data.sample_count === 0) return "샘플 없음"; @@ -268,6 +310,10 @@ function ticketTone(ticket: AdminSupportTicket): "accent" | "neutral" | "warn" | return "accent"; } +function shortTicketId(ticketId: string): string { + return ticketId.replace(/-/g, "").slice(0, 8).toUpperCase(); +} + function initialOf(user: AdminManagedUser): string { const label = user.display_name.trim() || user.email; return Array.from(label)[0]?.toUpperCase() ?? "?"; @@ -622,6 +668,34 @@ export default function Admin({ section = "overview" }: AdminProps) { } }; + const updateTicketParent = async ( + ticket: AdminSupportTicket, + parentTicketId: string | null, + ) => { + setUpdatingTicketId(ticket.ticket_id); + setTicketsError(null); + try { + const updated = await adminApi.updateTicket(ticket.ticket_id, { + parent_ticket_id: parentTicketId ?? "", + }); + setTickets((current) => + current + ? { + ...current, + tickets: current.tickets.map((item) => + item.ticket_id === updated.ticket_id ? updated : item, + ), + } + : current, + ); + await loadTickets(); + } catch (err) { + setTicketsError(err instanceof Error ? err.message : "중복 연결을 저장하지 못했습니다."); + } finally { + setUpdatingTicketId(null); + } + }; + const services = health?.services ?? []; const users = usersData?.users ?? []; const counts = useMemo( @@ -848,6 +922,28 @@ export default function Admin({ section = "overview" }: AdminProps) { {usage ? {usageBudgetDetail(usage)} : null} {usage ? : null} + {usage ? ( +
+ 평가 캐시 hit-rate + {evaluatorCacheLabel(usage)} + {evaluatorCacheDetail(usage)} +
+ ) : null} + {usage && usageDailyCost(usage).length > 0 ? ( +
+ 일별 비용 추이 + {usageDailyCost(usage) + .slice(-7) + .map((day) => ( +
+ {usageDayLabel(day.day)} + + {costLabel(day.cost_usd)} · {countLabel(day.turns)}턴 + +
+ ))} +
+ ) : null}
@@ -1658,44 +1754,84 @@ export default function Admin({ section = "overview" }: AdminProps) { {activeTickets.length > 0 ? (
- {activeTickets.map((ticket) => ( -
-
-
- {ticketCategoryLabel(ticket.category)} - {ticket.reporter.email} - 갱신 {dateTimeLabel(ticket.updated_at)} + {activeTickets.map((ticket) => { + const duplicateParentId = ticket.duplicate_parent_candidate_id; + const canLinkDuplicate = + ticket.duplicate_count > 0 && + Boolean(duplicateParentId) && + duplicateParentId !== ticket.ticket_id && + duplicateParentId !== ticket.parent_ticket_id; + return ( +
+
+
+ {ticketCategoryLabel(ticket.category)} + {ticket.reporter.email} + 갱신 {dateTimeLabel(ticket.updated_at)} +
+

{ticket.subject}

+

{ticket.body}

+ {(ticket.duplicate_count > 0 || ticket.parent_ticket_id || ticket.child_ticket_count > 0) ? ( +
+ {ticket.parent_ticket_id ? ( + 중복 연결 #{shortTicketId(ticket.parent_ticket_id)} + ) : ticket.duplicate_count > 0 ? ( + 중복 후보 {countLabel(ticket.duplicate_count)}건 + ) : null} + {ticket.child_ticket_count > 0 ? ( + 하위 티켓 {countLabel(ticket.child_ticket_count)}건 + ) : null} +
+ ) : null} + + {ticket.source_path || "출처 없음"} · 접수 {dateTimeLabel(ticket.created_at)} + {ticket.assigned_group ? ` · 담당 ${ticket.assigned_group}` : ""} + {ticket.event_count > 0 ? ` · 처리 이력 ${countLabel(ticket.event_count)}건` : ""} +
-

{ticket.subject}

-

{ticket.body}

- - {ticket.source_path || "출처 없음"} · 접수 {dateTimeLabel(ticket.created_at)} - {ticket.assigned_group ? ` · 담당 ${ticket.assigned_group}` : ""} - {ticket.event_count > 0 ? ` · 처리 이력 ${countLabel(ticket.event_count)}건` : ""} - -
- {ticketPriorityLabel(ticket.priority)} - {ticketStatusLabel(ticket.status)} -
- - -
-
- ))} + {ticketPriorityLabel(ticket.priority)} + {ticketStatusLabel(ticket.status)} +
+ {canLinkDuplicate ? ( + + ) : null} + {ticket.parent_ticket_id ? ( + + ) : null} + + +
+ + ); + })}
) : null} diff --git a/apps/web/src/pages/Session.tsx b/apps/web/src/pages/Session.tsx index 2fafef4..50a7343 100644 --- a/apps/web/src/pages/Session.tsx +++ b/apps/web/src/pages/Session.tsx @@ -53,6 +53,7 @@ import "./session/session.css"; /* ── 도메인 상수/타입 ───────────────────────────────────────────────── */ type FeedbackMode = "immersive" | "ambient" | "coached"; +type TheoryMode = "humanistic" | "cbt" | "integrative"; type Speaker = "client" | "learner"; type SignalTone = "pos" | "warn" | "neutral"; type VoiceStatus = @@ -115,6 +116,20 @@ const SESSION_PHASES: PhaseInfo[] = [ { key: "정리", desc: "오늘의 대화 정리와 다음 약속" }, ]; +const THEORY_MODE_OPTIONS: { value: TheoryMode; label: string; detail: string }[] = [ + { value: "humanistic", label: "인간중심", detail: "공감·반영" }, + { value: "cbt", label: "CBT", detail: "생각·행동" }, + { value: "integrative", label: "통합", detail: "혼합 접근" }, +]; + +function preferredTheoryMode(summary: PersonaSummary | null): TheoryMode { + const firstSupported = summary?.theory_target.find( + (target): target is TheoryMode => + target === "humanistic" || target === "cbt" || target === "integrative", + ); + return firstSupported ?? "humanistic"; +} + const PERSONA_AVATAR_LOOKS = { P1: { skinTone: "#F0DDC4", @@ -378,6 +393,7 @@ export default function Session() { const [startError, setStartError] = useState(null); const [consentChecked, setConsentChecked] = useState(false); const [consentBusy, setConsentBusy] = useState(false); + const [selectedTheoryMode, setSelectedTheoryMode] = useState("humanistic"); // ── 회기/대화 상태 ── const [stage, setStage] = useState("라포"); @@ -394,6 +410,7 @@ export default function Session() { const [feedbackMode, setFeedbackMode] = useState("ambient"); const [composeText, setComposeText] = useState(""); const [sending, setSending] = useState(false); + const [clientReplyPending, setClientReplyPending] = useState(false); const [voiceStatus, setVoiceStatus] = useState("idle"); const [voiceDetail, setVoiceDetail] = useState( "마이크를 켜면 권한 요청 후 음성으로 회기를 진행합니다.", @@ -483,6 +500,7 @@ export default function Session() { setSafety(null); setCrisisResource(null); setResumedSessionLoaded(false); + setClientReplyPending(false); setStartError(null); setCoachSuggestion(null); setCoachError(null); @@ -524,6 +542,11 @@ export default function Session() { }; }, [personaCode]); + useEffect(() => { + if (started) return; + setSelectedTheoryMode(preferredTheoryMode(personaSummary)); + }, [personaSummary, started]); + // 현재 단계 인덱스 const stageIdx = useMemo(() => SESSION_PHASES.findIndex((p) => p.key === stage), [stage]); const currentPhase = SESSION_PHASES[Math.max(0, stageIdx)] ?? SESSION_PHASES[0]; @@ -540,7 +563,7 @@ export default function Session() { if (!autoScroll) return; const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; - }, [utterances, autoScroll]); + }, [utterances, clientReplyPending, autoScroll]); // ── 라이브 신호 6초 페이드(§5.5) ── useEffect(() => { @@ -584,6 +607,7 @@ export default function Session() { setSafety(next.message); setPaused(true); setMicOn(false); + setClientReplyPending(false); setVoiceStatus("idle"); setVoiceDetail("위기 신호가 감지되어 연습을 중단했습니다."); pushSignal("warn", "위기 안전게이트 작동"); @@ -698,6 +722,7 @@ export default function Session() { setCoachEvidenceOpen(false); setCoachHistoryOpen(false); setCoachHistoryTurnSeq(null); + setClientReplyPending(false); pushSignal("neutral", ended ? "종료된 회기 기록" : "기존 회기 이어하기"); } catch { if (!alive) return; @@ -725,15 +750,9 @@ export default function Session() { setStarting(true); setStartError(null); setResumedSessionLoaded(false); + setClientReplyPending(false); try { - // 회기 이론모드 = 페르소나 설계 이론(theory_target) 기준. CBT 페르소나는 cbt로 시작. - // (이전엔 'humanistic' 하드코딩 — 평가 이론부합이 항상 인간중심으로 고정됐다.) - const theoryMode = - personaSummary.theory_target.find( - (t): t is "humanistic" | "cbt" | "integrative" => - t === "humanistic" || t === "cbt" || t === "integrative", - ) ?? "humanistic"; - const res = await sessionApi.start(personaCode, theoryMode); + const res = await sessionApi.start(personaCode, selectedTheoryMode); setLiveSessionId(res.session_id); // ★ 진짜 세션 id 저장 — turn 이 이걸 써야 라이브 setSessionEnded(false); setReviewReady(false); @@ -778,7 +797,7 @@ export default function Session() { } finally { setStarting(false); } - }, [navigate, personaCode, personaSummary, pushSignal]); + }, [navigate, personaCode, personaSummary, pushSignal, selectedTheoryMode]); const handleAcceptConsent = useCallback(async () => { if (!consentChecked) { @@ -799,6 +818,7 @@ export default function Session() { const appendServerClientReply = useCallback( (replyText: string | null, at = elapsed) => { + setClientReplyPending(false); if (!replyText) { setAvatarState("listening"); pushSignal("warn", "내담자 응답 없음"); @@ -817,7 +837,7 @@ export default function Session() { /* ── 학습자 발화 전송(텍스트 입력 폴백 = 음성 1턴 왕복 대역) ──────── */ const handleSend = useCallback(async () => { const text = composeText.trim(); - if (!text || sending || paused) return; + if (!text || sending || paused || voiceStatus === "thinking") return; if (sessionEnded) { setTurnError("종료된 회기에서는 새 발화를 보낼 수 없습니다. 리뷰에서 기록을 확인해 주세요."); return; @@ -829,8 +849,14 @@ export default function Session() { } const at = elapsed; + const learnerId = nextId(); setSending(true); setComposeText(""); + setUtterances((prev) => [ + ...prev, + { id: learnerId, speaker: "learner", text, at }, + ]); + setClientReplyPending(true); setAvatarState("thinking"); setTurnError(null); @@ -841,6 +867,7 @@ export default function Session() { onToken: (chunk) => { if (!chunk) return; clientReply += chunk; + setClientReplyPending(false); setAvatarState("speaking"); if (clientId == null) { clientId = nextId(); @@ -878,25 +905,30 @@ export default function Session() { }, }); - const learnerUtterance: Utterance = { - id: nextId(), - speaker: "learner", - text, - turnSeq: done.turn_seq, - at, - }; if (clientId != null) { const id = clientId; setUtterances((prev) => { const existingIndex = prev.findIndex((u) => u.id === id); - if (existingIndex === -1) return [...prev, learnerUtterance]; - const next = [...prev]; - next.splice(existingIndex, 0, learnerUtterance); - return next.map((u) => (u.id === id ? { ...u, partial: false } : u)); + if (existingIndex === -1) { + return [ + ...prev.map((u) => (u.id === learnerId ? { ...u, turnSeq: done.turn_seq } : u)), + { id, speaker: "client", text: clientReply, at }, + ]; + } + return prev.map((u) => + u.id === learnerId + ? { ...u, turnSeq: done.turn_seq } + : u.id === id + ? { ...u, partial: false } + : u, + ); }); pushSignal("neutral", "내담자 응답 수신"); } else { - setUtterances((prev) => [...prev, learnerUtterance]); + setClientReplyPending(false); + setUtterances((prev) => + prev.map((u) => (u.id === learnerId ? { ...u, turnSeq: done.turn_seq } : u)), + ); pushSignal("warn", "내담자 응답 없음"); } setAvatarState("listening"); @@ -907,9 +939,12 @@ export default function Session() { }); } catch (err) { setComposeText(text); + setClientReplyPending(false); if (clientId != null) { const id = clientId; - setUtterances((prev) => prev.filter((u) => u.id !== id)); + setUtterances((prev) => prev.filter((u) => u.id !== id && u.id !== learnerId)); + } else { + setUtterances((prev) => prev.filter((u) => u.id !== learnerId)); } const detail = err instanceof ApiError @@ -928,7 +963,17 @@ export default function Session() { } finally { setSending(false); } - }, [composeText, sending, paused, sessionEnded, liveSessionId, elapsed, pushSignal, requestLiveCoach]); + }, [ + composeText, + sending, + paused, + voiceStatus, + sessionEnded, + liveSessionId, + elapsed, + pushSignal, + requestLiveCoach, + ]); const onComposeKey = (e: ReactKeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { @@ -1156,6 +1201,7 @@ export default function Session() { return; } recorderRef.current = recorder; + setClientReplyPending(false); recorder.ondataavailable = (event) => { if (!event.data.size || ws.readyState !== WebSocket.OPEN) return; @@ -1218,6 +1264,7 @@ export default function Session() { const id = nextId(); pendingVoiceLearnerIdRef.current = id; pendingVoiceLearnerTextRef.current = payload.text; + setClientReplyPending(true); setUtterances((prev) => [ ...prev, { id, speaker: "learner", text: payload.text ?? "", at: elapsed, partial: true }, @@ -1226,6 +1273,7 @@ export default function Session() { } if (payload.type === "reply") { + setClientReplyPending(false); if (payload.stage) setStage(payload.stage); if (typeof payload.effective_openness === "number") { setOpenness(payload.effective_openness); @@ -1264,6 +1312,7 @@ export default function Session() { } if (payload.type === "degraded") { + setClientReplyPending(false); const fallbackReason = "음성 설정이 완료되지 않아 지금은 텍스트 입력으로 진행합니다."; const reason = payload.reason && !payload.reason.includes("OPENAI") ? payload.reason : fallbackReason; @@ -1273,6 +1322,7 @@ export default function Session() { } if (payload.type === "error") { + setClientReplyPending(false); if (pendingVoiceLearnerIdRef.current != null && payload.detail?.includes("engine unavailable")) { const pendingId = pendingVoiceLearnerIdRef.current; setUtterances((prev) => prev.filter((u) => u.id !== pendingId)); @@ -1285,6 +1335,7 @@ export default function Session() { }; ws.onerror = () => { + setClientReplyPending(false); shutdownVoice("error", "음성 연결을 확인하지 못했습니다."); pushSignal("warn", "음성 연결 실패"); }; @@ -1322,6 +1373,17 @@ export default function Session() { stopMicStream, ]); + const skipVoicePlayback = useCallback(() => { + ttsChunksRef.current = []; + stopTtsPlayback(); + closeVoiceSocket(); + setVoiceAnalyser(null); + setAvatarState("listening"); + setVoiceStatus("idle"); + setVoiceDetail("음성을 건너뛰었습니다. 다음 발화를 입력하거나 마이크를 켜세요."); + pushSignal("neutral", "음성 건너뜀"); + }, [closeVoiceSocket, pushSignal, stopTtsPlayback]); + const toggleMic = useCallback(() => { if (paused || sending || sessionEnded) return; if (voiceAvailable === false) { @@ -1348,6 +1410,7 @@ export default function Session() { setPaused((p) => { const next = !p; if (next) { + if (voiceStatus !== "idle") setClientReplyPending(false); shutdownVoice("idle", "일시정지 중입니다."); setAvatarState("idle"); } else { @@ -1356,7 +1419,7 @@ export default function Session() { } return next; }); - }, [sessionEnded, shutdownVoice]); + }, [sessionEnded, shutdownVoice, voiceStatus]); // ── 키보드 단축키 (§5.6: Space=마이크, P=일시정지). 입력 중엔 무시 ── useEffect(() => { @@ -1546,6 +1609,7 @@ export default function Session() { voiceStatus === "thinking" || voiceStatus === "speaking" || sending; + const textTurnBlocked = sending || voiceStatus === "thinking"; const elapsedLabel = formatElapsed(elapsed); const personaIsUsable = personaSummary ? isUsablePersona(personaSummary) : false; const consentRequired = user?.role === "learner" && user.consentAt == null; @@ -1748,6 +1812,23 @@ export default function Session() { ))}
+
+ 이론모드 +
+ {THEORY_MODE_OPTIONS.map((option) => ( + + ))} +
+
{personaStatusMessage ? (

{personaStatusMessage}

) : null} @@ -1986,57 +2067,77 @@ export default function Session() {
- {utterances.length > 0 ? ( - utterances.map((u) => { - const turnCoachEvents = - u.speaker === "learner" && u.turnSeq - ? (coachHistoryByTurn.get(u.turnSeq) ?? []) - : []; - const latestCoach = turnCoachEvents[turnCoachEvents.length - 1]; - return ( -
+ {utterances.length > 0 || clientReplyPending ? ( + <> + {utterances.map((u) => { + const turnCoachEvents = + u.speaker === "learner" && u.turnSeq + ? (coachHistoryByTurn.get(u.turnSeq) ?? []) + : []; + const latestCoach = turnCoachEvents[turnCoachEvents.length - 1]; + return ( +
+
+ + {u.speaker === "client" ? clientName : "나 (학습자)"} + + {formatTimecode(u.at)} +
+
+
+ {u.text} + {u.partial ? : null} +
+ {turnCoachEvents.length && u.turnSeq ? ( + + ) : null} +
+
+ ); + })} + {clientReplyPending ? ( +
- - {u.speaker === "client" ? clientName : "나 (학습자)"} - - {formatTimecode(u.at)} + {clientName} + {formatTimecode(elapsed)}
- {u.text} - {u.partial ? : null} + 답변을 준비 중입니다. +
- {turnCoachEvents.length && u.turnSeq ? ( - - ) : null}
- ); - }) + ) : null} + ) : (
+ ) : null}
diff --git a/apps/web/src/pages/SessionReview.tsx b/apps/web/src/pages/SessionReview.tsx index 311dce1..7ebbb22 100644 --- a/apps/web/src/pages/SessionReview.tsx +++ b/apps/web/src/pages/SessionReview.tsx @@ -13,6 +13,7 @@ import { import { sessionApi, teacherApi, + userApi, type ReviewCaseWorksheet, type ReviewNonverbalEvent, type ReviewNote, @@ -21,11 +22,69 @@ import { type ReviewTurn, type ReviewWorksheetSection, type SessionReviewResponse, + type UserPrepostMeasureItem, + type UserPrepostMeasuresResponse, } from "../lib/api"; import { ValenceChart } from "./session-review/ValenceChart"; import "./session-review/session-review.css"; type LoadState = "loading" | "ready" | "error"; +type PrepostMeasureName = UserPrepostMeasureItem["measure_name"]; +type PrepostTimepoint = UserPrepostMeasureItem["timepoint"]; + +const PREPOST_PILOT_ID = "phase3-pilot-draft"; +const PREPOST_INSTRUMENT_VERSION = "pilot-prepost-scaffold-2026-06-28"; +const PREPOST_SCORE_MIN = 1; +const PREPOST_SCORE_MAX = 5; + +const PREPOST_MEASURES: Array<{ key: PrepostMeasureName; label: string; hint: string }> = [ + { key: "self_efficacy", label: "자기효능감", hint: "상담 수행 자신감" }, + { key: "skill_proficiency", label: "기술숙련도", hint: "기법 적용 숙련감" }, + { key: "training_satisfaction", label: "수련만족도", hint: "훈련 경험 만족도" }, +]; + +const PREPOST_TIMEPOINTS: Array<{ key: PrepostTimepoint; label: string }> = [ + { key: "pre", label: "사전" }, + { key: "post", label: "사후" }, +]; + +function prepostDraftKey(measure: PrepostMeasureName, timepoint: PrepostTimepoint) { + return `${measure}:${timepoint}`; +} + +function splitPrepostDraftKey(key: string): [PrepostMeasureName, PrepostTimepoint] | null { + const [measure, timepoint] = key.split(":"); + if ( + PREPOST_MEASURES.some((item) => item.key === measure) && + PREPOST_TIMEPOINTS.some((item) => item.key === timepoint) + ) { + return [measure as PrepostMeasureName, timepoint as PrepostTimepoint]; + } + return null; +} + +function prepostScoreText(score: number) { + return Number.isInteger(score) ? String(score) : score.toFixed(1).replace(/\.0$/, ""); +} + +function prepostDraftFromResponse( + response: UserPrepostMeasuresResponse | null, +): Record { + const next: Record = {}; + for (const item of response?.measures ?? []) { + next[prepostDraftKey(item.measure_name, item.timepoint)] = prepostScoreText(item.raw_score); + } + return next; +} + +function parsePrepostScore(value: string): number | null { + const trimmed = value.trim(); + if (!trimmed) return null; + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) return null; + if (parsed < PREPOST_SCORE_MIN || parsed > PREPOST_SCORE_MAX) return null; + return parsed; +} function renderSummary(summary: string) { const parts = summary.split(/(.*?<\/hl>)/g); @@ -420,6 +479,181 @@ function CaseWorksheetCard({ ); } +function PrepostMeasureCard() { + const [data, setData] = useState(null); + const [draft, setDraft] = useState>({}); + const [baseline, setBaseline] = useState>({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let alive = true; + (async () => { + setLoading(true); + setError(null); + try { + const next = await userApi.prepostMeasures(PREPOST_PILOT_ID); + if (!alive) return; + const nextDraft = prepostDraftFromResponse(next); + setData(next); + setDraft(nextDraft); + setBaseline(nextDraft); + } catch { + if (!alive) return; + setError("사전/사후 점수 원장을 불러오지 못했습니다."); + } finally { + if (alive) setLoading(false); + } + })(); + + return () => { + alive = false; + }; + }, []); + + const dirtyKeys = useMemo( + () => + Object.keys(draft).filter((key) => { + const split = splitPrepostDraftKey(key); + if (!split) return false; + const value = draft[key]?.trim() ?? ""; + return value !== (baseline[key] ?? "") && parsePrepostScore(value) !== null; + }), + [baseline, draft], + ); + + const invalidKeys = useMemo( + () => + Object.keys(draft).filter((key) => { + const split = splitPrepostDraftKey(key); + if (!split) return false; + const value = draft[key]?.trim() ?? ""; + return value.length > 0 && parsePrepostScore(value) === null; + }), + [draft], + ); + + const saveDraft = async () => { + if (dirtyKeys.length === 0) return; + setSaving(true); + setError(null); + try { + for (const key of dirtyKeys) { + const split = splitPrepostDraftKey(key); + const score = parsePrepostScore(draft[key] ?? ""); + if (!split || score === null) continue; + const [measureName, timepoint] = split; + await userApi.upsertPrepostMeasure({ + pilot_id: PREPOST_PILOT_ID, + instrument_version: PREPOST_INSTRUMENT_VERSION, + item_count: 1, + min_score: PREPOST_SCORE_MIN, + max_score: PREPOST_SCORE_MAX, + measure_name: measureName, + timepoint, + raw_score: score, + }); + } + const next = await userApi.prepostMeasures(PREPOST_PILOT_ID); + const nextDraft = prepostDraftFromResponse(next); + setData(next); + setDraft(nextDraft); + setBaseline(nextDraft); + } catch { + setError("사전/사후 점수를 저장하지 못했습니다."); + } finally { + setSaving(false); + } + }; + + const completePairs = data?.complete_measure_pairs ?? 0; + + return ( + +
+
+ Phase 3 사전/사후 + 파일럿 증거 원장 +
+ {completePairs}/3 쌍 +
+

+ 1~5 평균값만 저장합니다. 효과 판정과 통계 검정은 평가설계 확정 후 별도 산출합니다. +

+
+ + {PREPOST_MEASURES.map((measure) => ( +
+
+ {measure.label} + {measure.hint} +
+ {PREPOST_TIMEPOINTS.map((timepoint) => { + const key = prepostDraftKey(measure.key, timepoint.key); + const invalid = invalidKeys.includes(key); + return ( + + ); + })} +
+ ))} +
+ {invalidKeys.length > 0 ? ( +

+ 점수는 1 이상 5 이하로 입력해야 합니다. +

+ ) : error ? ( +

+ {error} +

+ ) : null} +
+ + {loading + ? "불러오는 중" + : dirtyKeys.length > 0 + ? `${dirtyKeys.length}개 변경` + : "저장된 값 기준"} + + +
+
+ ); +} + export default function SessionReview() { const { sessionId } = useParams<{ sessionId: string }>(); const navigate = useNavigate(); @@ -653,7 +887,7 @@ export default function SessionReview() {
-
+
리뷰 요약 @@ -1018,6 +1252,8 @@ export default function SessionReview() { ) : null} + {!isSupervisorView ? : null} + = { }; const ENGINE_MODES = ["claude_cli", "claude_api", "openai", "solar"] as const; +const TICKET_STATUS_LABEL: Record = { + open: "접수", + triaged: "분류됨", + in_progress: "처리 중", + resolved: "해결", + closed: "종료", +}; + +const TICKET_CATEGORY_LABEL: Record = { + account_access: "계정", + session_review: "회기 리뷰", + voice_browser: "음성·브라우저", + content_scenario: "콘텐츠", + safety: "안전", + other: "기타", +}; + +const TICKET_PRIORITY_LABEL: Record = { + low: "낮음", + normal: "보통", + high: "높음", + urgent: "긴급", +}; + function SpeakerIcon({ size = 15 }: { size?: number }) { return ( (null); + const [supportTickets, setSupportTickets] = useState(null); const [voicePresets, setVoicePresets] = useState([]); const [engineConfig, setEngineConfig] = useState(null); const [adminHealth, setAdminHealth] = useState(null); const [profileReady, setProfileReady] = useState(false); const [preferencesReady, setPreferencesReady] = useState(false); + const [supportTicketsReady, setSupportTicketsReady] = useState(false); + const [supportTicketsError, setSupportTicketsError] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [savedKey, setSavedKey] = useState(null); @@ -192,14 +237,21 @@ export default function Settings() { setError(null); setProfileReady(false); setPreferencesReady(false); + setSupportTicketsReady(false); setPreferences(null); + setSupportTickets(null); + setSupportTicketsError(null); setVoicePresets([]); setEngineConfig(null); engineConfigRef.current = null; try { - const [nextProfile, nextPrefs, presets, nextEngine] = await Promise.all([ + const [nextProfile, nextPrefs, nextTickets, presets, nextEngine] = await Promise.all([ userApi.me(), userApi.preferences(), + userApi.supportTickets().catch((err) => { + setSupportTicketsError(err instanceof Error ? err.message : "지원 요청을 불러오지 못했습니다."); + return null; + }), userApi.voicePresets(), isAdmin ? adminEngineApi.get() : Promise.resolve(null), ]); @@ -215,6 +267,8 @@ export default function Settings() { } setPreferences(nextPrefs); setPreferencesReady(true); + setSupportTickets(nextTickets); + setSupportTicketsReady(true); setDark(nextPrefs.theme === "dark" || (nextPrefs.theme !== "light" && readInitialTheme() === "dark")); setVoicePresets(presets); if (nextEngine) { @@ -240,6 +294,7 @@ export default function Settings() { const navItems: NavItem[] = useMemo(() => { const base: NavItem[] = [ { id: "account", label: "계정", icon: "users" }, + { id: "support", label: "지원 요청", icon: "review" }, { id: "appearance", label: "테마", icon: "settings" }, { id: "notify", label: "알림", icon: "info" }, { id: "voice", label: "음성", icon: "mic" }, @@ -550,6 +605,73 @@ export default function Settings() {
+ +
+
+
지원 요청
+
+ 내가 접수한 운영 티켓의 처리 상태와 해결 메모입니다. +
+
+ {supportTickets ? ( + + {supportTickets.tickets.length}건 + + ) : null} +
+ + {supportTicketsError ? ( +
+
+ ) : supportTickets ? ( + supportTickets.tickets.length > 0 ? ( +
+ {supportTickets.tickets.map((ticket) => ( +
+
+ + {TICKET_STATUS_LABEL[ticket.status]} + + {TICKET_CATEGORY_LABEL[ticket.category]} + {TICKET_PRIORITY_LABEL[ticket.priority]} +
+
{ticket.subject}
+
+ 접수 {formatTicketDate(ticket.created_at)} + 갱신 {formatTicketDate(ticket.updated_at)} + {ticket.assigned_group ? {ticket.assigned_group} : null} +
+ {ticket.resolution_note ? ( +
+ 해결 메모 + {ticket.resolution_note} +
+ ) : ( +
+ 처리 메모가 아직 없습니다. +
+ )} +
+ ))} +
+ ) : ( + + 접수한 지원 요청이 없습니다. + + ) + ) : supportTicketsReady ? ( + + 지원 요청 저장소에 표시할 항목이 없습니다. + + ) : ( + + 내 지원 요청을 불러오는 중입니다. + + )} +
+ {isAdmin ? ( span { + color: var(--text-muted); + font-size: var(--fs-xs); + line-height: 1.35; +} + +.ad-cost-trend > div { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + min-width: 0; +} + +.ad-cost-trend b { + flex: 0 0 auto; + color: var(--text-strong); + font-family: var(--font-num); + font-size: var(--fs-sm); + line-height: 1.25; +} + +.ad-cost-trend div span { + min-width: 0; + color: var(--text-muted); + font-size: var(--fs-xs); + line-height: 1.35; + text-align: right; +} + .ad-ticket-mini { display: grid; gap: 10px; @@ -1113,6 +1171,24 @@ gap: 4px 10px; } +.ad-ticket__dupes { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin: 5px 0; +} + +.ad-ticket .ad-ticket__dupes span { + border: 1px solid var(--hair); + border-radius: var(--radius-sm); + padding: 3px 6px; + background: var(--bg-app); + color: var(--accent-deep); + font-family: var(--font-num); + font-size: var(--fs-xs); + font-weight: 800; +} + .ad-ticket span, .ad-ticket time { color: var(--text-muted); @@ -1143,8 +1219,10 @@ .ad-ticket__actions { display: flex; + flex-wrap: wrap; justify-content: flex-end; gap: 6px; + max-width: 190px; } .ad-ticket__actions .vg-btn { diff --git a/apps/web/src/pages/avatar-preview.css b/apps/web/src/pages/avatar-preview.css index 71959de..bab87d0 100644 --- a/apps/web/src/pages/avatar-preview.css +++ b/apps/web/src/pages/avatar-preview.css @@ -1,7 +1,10 @@ .ap{padding:32px;max-width:1100px;margin:0 auto;color:var(--text-body);} .ap__head h1{font-size:var(--fs-xl);margin:0 0 6px;} .ap__head p{color:var(--text-muted);font-size:var(--fs-sm);margin:0 0 28px;} -.ap__hero{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;padding:24px;border-radius:20px;background:var(--bg-stage);margin-bottom:32px;} +.ap__hero{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;padding:24px;border:1px solid var(--border-subtle);border-radius:20px;background:linear-gradient(135deg,rgba(238,244,242,.96),rgba(246,240,236,.92));margin-bottom:32px;} +.ap__hero .vg-avatar__persona{color:#1c2a2a;} +.ap__hero .vg-avatar__state{color:#5a6663;} +.ap__hero .vg-avatar__state b{color:#1c2a2a;} .ap__grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:18px;} .ap__cell{margin:0;display:flex;flex-direction:column;align-items:center;gap:8px;} .ap__cell figcaption{font-size:var(--fs-xs);color:var(--text-muted);} diff --git a/apps/web/src/pages/session-review/session-review.css b/apps/web/src/pages/session-review/session-review.css index b3a8a70..ae5076c 100644 --- a/apps/web/src/pages/session-review/session-review.css +++ b/apps/web/src/pages/session-review/session-review.css @@ -120,6 +120,7 @@ "overview flow rubric" "overview transcript good" "overview transcript growth" + "overview transcript prepost" "overview transcript worksheet" "overview transcript feedback" "session session session"; @@ -133,10 +134,59 @@ "overview flow rubric" "overview transcript good" "overview transcript growth" + "overview transcript prepost" "overview transcript worksheet" "overview transcript feedback" "session session session"; } +.sr-cols--learner { + grid-template-columns: minmax(0, 1.08fr) minmax(320px, 0.92fr); + grid-template-areas: + "transcript overview" + "transcript chart" + "transcript flow" + "transcript rubric" + "transcript good" + "transcript growth" + "transcript prepost" + "transcript worksheet" + "transcript feedback" + "session session"; +} +.sr-cols--learner .sr-overview { + position: static; + max-height: none; + overflow: visible; + scrollbar-gutter: auto; +} +.sr-root--empty .sr-cols { + grid-template-columns: minmax(236px, 300px) minmax(0, 1fr); + grid-template-areas: + "overview chart" + "overview flow" + "overview transcript" + "overview rubric" + "overview good" + "overview growth" + "overview prepost" + "overview worksheet" + "overview feedback" + "session session"; +} +.sr-root--empty .sr-cols--supervisor { + grid-template-areas: + "overview chart" + "overview flow" + "overview teacher" + "overview transcript" + "overview rubric" + "overview good" + "overview growth" + "overview prepost" + "overview worksheet" + "overview feedback" + "session session"; +} .sr-left, .sr-right { display: contents; @@ -175,6 +225,9 @@ .sr-card--growth { grid-area: growth; } +.sr-card--prepost { + grid-area: prepost; +} .sr-card--worksheet { grid-area: worksheet; } @@ -353,6 +406,146 @@ line-height: 1.55; } +.sr-prepost__head { + min-width: 0; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--sp-3); +} +.sr-prepost__head > div { + min-width: 0; + display: grid; + gap: 4px; +} +.sr-prepost__head b { + color: var(--text-strong); + font-size: var(--fs-sm); + line-height: 1.35; +} +.sr-prepost__badge { + flex: none; + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 9px; + border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--border-subtle)); + border-radius: var(--radius-sm); + color: var(--accent-deep); + background: var(--accent-tint); + font-family: var(--font-num); + font-size: 11px; + font-weight: 750; + white-space: nowrap; +} +.sr-prepost__note { + margin: var(--sp-3) 0 0; + color: var(--text-muted); + font-size: var(--fs-xs); + line-height: 1.5; +} +.sr-prepost__table { + min-width: 0; + display: grid; + gap: 7px; + margin-top: var(--sp-4); +} +.sr-prepost__row { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) repeat(2, minmax(58px, 68px)); + gap: 8px; + align-items: center; +} +.sr-prepost__row--head { + color: var(--text-muted); + font-family: var(--font-num); + font-size: 10.5px; + font-weight: 750; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.sr-prepost__row--head span:not(:first-child) { + text-align: center; +} +.sr-prepost__measure { + min-width: 0; + display: grid; + gap: 2px; +} +.sr-prepost__measure b { + min-width: 0; + color: var(--text-strong); + font-size: var(--fs-xs); + line-height: 1.35; + overflow-wrap: anywhere; +} +.sr-prepost__measure small { + min-width: 0; + color: var(--text-muted); + font-size: 10.5px; + line-height: 1.35; + overflow-wrap: anywhere; +} +.sr-prepost__field { + min-width: 0; + display: grid; +} +.sr-prepost__field span { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; +} +.sr-prepost__field input { + width: 100%; + min-width: 0; + height: 36px; + padding: 0 7px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + background: var(--bg-surface); + color: var(--text-strong); + font-family: var(--font-num); + font-size: var(--fs-sm); + font-weight: 650; + text-align: center; +} +.sr-prepost__field input:focus { + outline: 2px solid var(--accent-tint); + border-color: var(--accent); +} +.sr-prepost__field input[aria-invalid="true"] { + border-color: color-mix(in srgb, var(--crit-text) 44%, var(--border-subtle)); + background: var(--crit-tint); +} +.sr-prepost__error { + margin: var(--sp-3) 0 0; + color: var(--crit-text); + font-size: 12px; + line-height: 1.45; +} +.sr-prepost__actions { + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + margin-top: var(--sp-4); + padding-top: var(--sp-3); + border-top: 1px solid var(--hair); +} +.sr-prepost__actions span { + min-width: 0; + color: var(--text-muted); + font-size: var(--fs-xs); + line-height: 1.35; + overflow-wrap: anywhere; +} + .sr-chart { position: relative; width: 100%; @@ -692,6 +885,15 @@ .sr-nonverbal--audio .sr-nonverbal__dot { background: var(--accent); } +.sr-nonverbal--paralinguistic .sr-nonverbal__dot { + background: var(--good-solid); +} +.sr-nonverbal--prosody .sr-nonverbal__dot { + background: var(--ink-soft); +} +.sr-nonverbal--audio_quality .sr-nonverbal__dot { + background: var(--text-muted); +} .sr-note { max-width: 68ch; @@ -1187,6 +1389,7 @@ [data-theme="dark"] .sr-empty, [data-theme="dark"] .sr-chip-toggle, [data-theme="dark"] .sr-ws-item, +[data-theme="dark"] .sr-prepost__field input, [data-theme="dark"] .sr-feedback, [data-theme="dark"] .sr-nextline { background: rgba(255, 255, 255, 0.055); @@ -1196,6 +1399,7 @@ [data-theme="dark"] .sr-turn + .sr-turn, [data-theme="dark"] .sr-readiness, [data-theme="dark"] .sr-ws-limitations, +[data-theme="dark"] .sr-prepost__actions, [data-theme="dark"] .sr-feedback__src { border-color: rgba(203, 227, 220, 0.1); } @@ -1225,6 +1429,20 @@ "rubric rubric" "good growth" "transcript transcript" + "prepost prepost" + "feedback feedback" + "worksheet worksheet" + "session session"; + } + .sr-root--empty .sr-cols { + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-areas: + "overview overview" + "chart flow" + "transcript transcript" + "rubric rubric" + "good growth" + "prepost prepost" "feedback feedback" "worksheet worksheet" "session session"; @@ -1237,6 +1455,20 @@ "rubric rubric" "good growth" "transcript transcript" + "prepost prepost" + "feedback feedback" + "worksheet worksheet" + "session session"; + } + .sr-root--empty .sr-cols--supervisor { + grid-template-areas: + "overview overview" + "chart flow" + "teacher teacher" + "transcript transcript" + "rubric rubric" + "good growth" + "prepost prepost" "feedback feedback" "worksheet worksheet" "session session"; @@ -1283,12 +1515,28 @@ "rubric" "good" "growth" + "prepost" "transcript" "feedback" "worksheet" "session"; gap: var(--sp-4); } + .sr-root--empty .sr-cols { + grid-template-columns: minmax(0, 1fr); + grid-template-areas: + "overview" + "chart" + "flow" + "transcript" + "rubric" + "good" + "growth" + "prepost" + "feedback" + "worksheet" + "session"; + } .sr-cols--supervisor { grid-template-areas: "overview" @@ -1298,11 +1546,27 @@ "rubric" "good" "growth" + "prepost" "transcript" "feedback" "worksheet" "session"; } + .sr-root--empty .sr-cols--supervisor { + grid-template-areas: + "overview" + "chart" + "flow" + "teacher" + "transcript" + "rubric" + "good" + "growth" + "prepost" + "feedback" + "worksheet" + "session"; + } .sr-summary { font-size: 20px; line-height: 1.45; diff --git a/apps/web/src/pages/session/session.css b/apps/web/src/pages/session/session.css index 40a3566..d0563bd 100644 --- a/apps/web/src/pages/session/session.css +++ b/apps/web/src/pages/session/session.css @@ -544,8 +544,8 @@ } .sx-page--active .sx-stage { background: - radial-gradient(circle at 28% 50%, rgba(145, 200, 189, 0.18), transparent 42%), - linear-gradient(135deg, rgba(11, 22, 20, 0.96), rgba(31, 43, 39, 0.93)), + radial-gradient(circle at 28% 50%, rgba(197, 222, 214, 0.26), transparent 42%), + linear-gradient(135deg, rgba(24, 42, 38, 0.94), rgba(48, 65, 59, 0.9)), var(--asset-warm-elements) center / cover no-repeat; border-color: rgba(255, 255, 255, 0.11); box-shadow: 0 18px 44px rgba(7, 16, 14, 0.28); @@ -949,6 +949,33 @@ .sx-utt.is-partial .sx-utt__line { color: var(--text-muted); } +.sx-utt.is-thinking .sx-utt__line { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--text-muted); + border: 1px dashed color-mix(in srgb, var(--clay-deep) 24%, transparent); +} +.sx-utt__dots { + display: inline-flex; + align-items: center; + gap: 3px; + flex: none; +} +.sx-utt__dots i { + width: 4px; + height: 4px; + border-radius: 50%; + background: currentColor; + opacity: 0.35; + animation: sxThinkingDot 900ms var(--ease-in-out) infinite; +} +.sx-utt__dots i:nth-child(2) { + animation-delay: 120ms; +} +.sx-utt__dots i:nth-child(3) { + animation-delay: 240ms; +} .sx-utt__caret { display: inline-block; width: 2px; @@ -961,6 +988,10 @@ @keyframes sxCaret { 50% { opacity: 0; } } +@keyframes sxThinkingDot { + 0%, 100% { opacity: 0.25; transform: translateY(0); } + 50% { opacity: 0.9; transform: translateY(-2px); } +} /* "최신으로" 칩 (위로 스크롤 시) */ .sx-transcript__jump { @@ -1642,6 +1673,10 @@ background: rgba(204, 143, 119, 0.12); color: #edf5f2; } +.sx-page--active .sx-utt.is-thinking .sx-utt__line { + color: rgba(234, 240, 241, 0.62); + border-color: rgba(229, 173, 149, 0.22); +} .sx-page--active .sx-utt.is-learner .sx-utt__line { background: rgba(111, 179, 164, 0.13); color: #edf5f2; @@ -1731,6 +1766,36 @@ overflow: hidden; text-overflow: ellipsis; } +.sx-voice-skip { + flex: none; + height: 34px; + padding: 0 11px; + border-radius: 999px; + border: 1px solid var(--border-strong); + background: var(--bg-surface-2); + color: var(--text-body); + display: inline-flex; + align-items: center; + gap: 5px; + font-family: var(--font-sans); + font-size: 12px; + font-weight: 700; + cursor: pointer; + white-space: nowrap; +} +.sx-voice-skip:hover { + border-color: var(--border-focus); + color: var(--text-strong); +} +.sx-page--active .sx-voice-skip { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.14); + color: rgba(238, 244, 242, 0.86); +} +.sx-page--active .sx-voice-skip:hover { + background: rgba(255, 255, 255, 0.12); + color: #ffffff; +} .sx-cb-sep { width: 1px; @@ -2336,6 +2401,72 @@ background: rgba(204, 143, 119, 0.17); color: #f0bda9; } +.sx-theory { + width: min(100%, 520px); + margin: var(--sp-4) 0 0; + padding: 0; + border: 0; + display: grid; + gap: 8px; +} +.sx-theory legend { + padding: 0; + color: rgba(238, 244, 242, 0.54); + font-size: 11.5px; + font-weight: 760; +} +.sx-theory__seg { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + padding: 4px; + border-radius: var(--radius-sm); + background: rgba(6, 15, 14, 0.26); + border: 1px solid rgba(255, 255, 255, 0.1); +} +.sx-theory__seg button { + min-width: 0; + min-height: 56px; + display: grid; + align-content: center; + gap: 3px; + padding: 8px 7px; + border: 1px solid transparent; + border-radius: calc(var(--radius-sm) - 2px); + background: transparent; + color: rgba(238, 244, 242, 0.72); + text-align: center; + cursor: pointer; +} +.sx-theory__seg button:hover { + border-color: rgba(145, 200, 189, 0.3); + color: #f4f8f7; +} +.sx-theory__seg button:focus-visible { + outline: 2px solid rgba(145, 200, 189, 0.7); + outline-offset: 2px; +} +.sx-theory__seg button.is-selected { + background: rgba(145, 200, 189, 0.18); + border-color: rgba(145, 200, 189, 0.46); + color: #f4f8f7; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06); +} +.sx-theory__seg span, +.sx-theory__seg small { + min-width: 0; + overflow-wrap: anywhere; + line-height: 1.2; +} +.sx-theory__seg span { + font-size: 13px; + font-weight: 760; +} +.sx-theory__seg small { + color: rgba(238, 244, 242, 0.56); + font-size: 11px; + font-weight: 650; +} .sx-prestart__note { max-width: 460px; font-size: 12.5px; @@ -2431,16 +2562,21 @@ .sx-stage.is-paused::after { content: "일시정지"; position: absolute; - inset: 0; + inset: 12px; display: flex; - align-items: center; - justify-content: center; - font-size: 15px; + align-items: flex-start; + justify-content: flex-end; + padding: 12px; + font-size: 13px; font-weight: 600; letter-spacing: 0.04em; - color: var(--text-on-accent); - background: color-mix(in srgb, var(--bg-stage) 62%, transparent); - backdrop-filter: blur(1px); + color: #f4faf7; + border: 1px solid rgba(203, 227, 220, 0.14); + border-radius: calc(var(--radius-lg) - 2px); + background: + linear-gradient(135deg, rgba(238, 244, 242, 0.08), rgba(145, 200, 189, 0.06)); + backdrop-filter: blur(0.5px); + text-shadow: 0 1px 2px rgba(7, 16, 14, 0.5); } /* ── 반응형 ── */ @@ -2918,6 +3054,23 @@ .sx-page--prestart .sx-prestart__chips { display: none; } + .sx-page--prestart .sx-theory { + margin-top: 8px; + } + .sx-page--prestart .sx-theory__seg { + gap: 4px; + padding: 3px; + } + .sx-page--prestart .sx-theory__seg button { + min-height: 50px; + padding: 6px 4px; + } + .sx-page--prestart .sx-theory__seg span { + font-size: 12px; + } + .sx-page--prestart .sx-theory__seg small { + font-size: 10.5px; + } .sx-page--prestart .sx-prestart__plan { display: none; } @@ -2971,6 +3124,14 @@ font-size: 13px; line-height: 1.4; } + .sx-voice-skip span { + display: none; + } + .sx-voice-skip { + width: 34px; + padding: 0; + justify-content: center; + } } @media (max-width: 420px) { @@ -3246,6 +3407,7 @@ .sx-orb, .sx-mic.is-on::after, .sx-utt__caret, + .sx-utt__dots i, .sx-transcript__live-dot.is-live { animation: none !important; } diff --git a/apps/web/src/pages/settings/settings.css b/apps/web/src/pages/settings/settings.css index 12342b9..7e63e06 100644 --- a/apps/web/src/pages/settings/settings.css +++ b/apps/web/src/pages/settings/settings.css @@ -1334,18 +1334,21 @@ body[data-page="settings"] .vg-set__forms { body[data-page="settings"] #set-account { order: 1; } -body[data-page="settings"] #set-engine { +body[data-page="settings"] #set-support { order: 2; } -body[data-page="settings"] #set-appearance { +body[data-page="settings"] #set-engine { order: 3; } -body[data-page="settings"] #set-voice { +body[data-page="settings"] #set-appearance { order: 4; } -body[data-page="settings"] #set-notify { +body[data-page="settings"] #set-voice { order: 5; } +body[data-page="settings"] #set-notify { + order: 6; +} body[data-page="settings"] .vg-set__group { padding: 18px; border: 1px solid rgba(37, 45, 42, 0.12); @@ -1409,6 +1412,57 @@ body[data-page="settings"] .vg-set__voicelist { body[data-page="settings"] .vg-set__voice { min-height: 72px; } +body[data-page="settings"] .vg-set__tickets { + display: grid; + gap: 10px; +} +body[data-page="settings"] .vg-set__ticket { + min-width: 0; + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid rgba(37, 45, 42, 0.12); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.86); +} +body[data-page="settings"] .vg-set__ticket-top, +body[data-page="settings"] .vg-set__ticket-meta { + min-width: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 7px 10px; + color: var(--text-muted); + font-size: var(--fs-xs); +} +body[data-page="settings"] .vg-set__ticket-title { + min-width: 0; + color: var(--text-strong); + font-size: var(--fs-sm); + font-weight: 700; + line-height: 1.4; + overflow-wrap: anywhere; +} +body[data-page="settings"] .vg-set__ticket-note { + min-width: 0; + display: grid; + gap: 4px; + padding: 9px 10px; + border-radius: var(--radius-sm); + background: rgba(244, 241, 236, 0.78); + color: var(--text-body); + font-size: var(--fs-xs); + line-height: 1.55; + overflow-wrap: anywhere; +} +body[data-page="settings"] .vg-set__ticket-note b { + color: var(--text-strong); + font-size: var(--fs-kicker); + letter-spacing: 0.04em; +} +body[data-page="settings"] .vg-set__ticket-note--empty { + color: var(--text-muted); +} body[data-page="settings"] .vg-set__foot { margin-top: 12px; padding-top: 12px; @@ -1594,6 +1648,8 @@ html[data-theme="dark"] body[data-page="settings"] .vg-set__range-row, html[data-theme="dark"] body[data-page="settings"] .vg-set__seg, html[data-theme="dark"] body[data-page="settings"] .vg-set__pill, html[data-theme="dark"] body[data-page="settings"] .vg-set__callout, +html[data-theme="dark"] body[data-page="settings"] .vg-set__ticket, +html[data-theme="dark"] body[data-page="settings"] .vg-set__ticket-note, html[data-theme="dark"] body[data-page="settings"] .vg-badge { border-color: rgba(203, 227, 220, 0.11); background: rgba(255, 255, 255, 0.055);