From 3f479a93cbc35c96e83a4faeaa9e79e088f7dd23 Mon Sep 17 00:00:00 2001 From: zio Date: Tue, 14 Jul 2026 22:06:23 +0900 Subject: [PATCH] fix(ui): resolve reviewer backlog R13-01, R15-02, R17-01, R17-02 + stale comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - R13-01: SCR-13 revenue trend now renders AI forecast as dashed area — client-side least-squares linear regression, 2-month horizon, bridge point, AiLabel + note; skipped gracefully under 3 data points - R15-02: SCR-15 category default is now visible — categoryDefaulted flag renders a '기본값' badge with tooltip instead of silent 'exhibition' - R17-01: SCR-17 adds §8 system icon library — auto-enumerates icons.tsx line-SVG set (namespace import, self-syncing) - R17-02: CHART.warning renamed CHART.violationWarn (value unchanged), ReportPage usage updated - hygiene: MeetingPage/MultilingualCmsPage header comments updated to reflect shipped STT + AI-translation wiring (stale '미배선' removed) Co-Authored-By: Claude Fable 5 --- .../analytics/AnalyticsDashboardPage.tsx | 111 +++++++++++++++++- .../src/screens/analytics/analytics.css | 10 ++ src/frontend/src/screens/chartColors.ts | 3 +- .../src/screens/cms/MultilingualCmsPage.tsx | 3 +- .../schedule/ExhibitionSchedulePage.tsx | 18 ++- .../src/screens/schedule/sampleSchedule.ts | 2 + .../src/screens/schedule/schedule.css | 12 ++ .../screens/styleguide/ComponentGuidePage.tsx | 28 +++++ .../src/screens/styleguide/styleguide.css | 30 +++++ src/frontend/src/screens/work/MeetingPage.tsx | 3 +- src/frontend/src/screens/work/ReportPage.tsx | 2 +- 11 files changed, 208 insertions(+), 14 deletions(-) diff --git a/src/frontend/src/screens/analytics/AnalyticsDashboardPage.tsx b/src/frontend/src/screens/analytics/AnalyticsDashboardPage.tsx index b2d1e83..2ff2a18 100644 --- a/src/frontend/src/screens/analytics/AnalyticsDashboardPage.tsx +++ b/src/frontend/src/screens/analytics/AnalyticsDashboardPage.tsx @@ -139,14 +139,29 @@ export function AnalyticsDashboardPage() {
{data.trend.length === 0 ? ( - ) : ( + ) : (() => { + const { rows, hasForecast } = buildTrendForecast(data.trend); + return ( + <> + {hasForecast && ( +
+ {t('analytics.forecastLabel', { defaultValue: 'AI 수요예측' })} + + {t('analytics.forecastNote', { defaultValue: '선형회귀 기반 향후 2개월 예측(점선)' })} + +
+ )} - + + + + + @@ -155,14 +170,34 @@ export function AnalyticsDashboardPage() { - item?.dataKey === 'revenue' ? formatWon(value as number) : t('analytics.boothsUnit', { n: (value as number).toLocaleString() }) + item?.dataKey === 'booths' + ? t('analytics.boothsUnit', { n: (value as number).toLocaleString() }) + : item?.dataKey === 'revenueForecast' + ? `${formatWon(value as number)} · ${t('analytics.forecastLabel', { defaultValue: 'AI 수요예측' })}` + : formatWon(value as number) } /> - - + + {hasForecast && ( + + )} + - )} + + ); + })()}
@@ -318,6 +353,70 @@ function shortWon(v: number): string { return String(v); } +/** + * 추세(매출) 시리즈에 선형회귀 예측 구간을 부착한다(R13-01 · design.md SCR-13 "AI 예측 점선"). + * 백엔드 AnalyticsTrendPoint 에 예측 필드가 없으므로 실측 revenue 에 최소제곱 선형회귀를 적용해 + * 향후 2개월을 외삽한다(overview 엔드포인트의 선형회귀 예측과 동일 기법을 클라이언트에서 수행). + * - 실측점: revenue 값 유지, revenueForecast=null (마지막 실측점만 브릿지로 복제). + * - 예측점: revenue=null, revenueForecast=외삽값(음수는 0으로 클램프). + * 점(3개) 미만이면 회귀 신뢰 불가 → 예측 없이 실측만 반환(hasForecast=false). + */ +interface TrendRow { + period: string; + revenue: number | null; + revenueForecast: number | null; + booths: number | null; +} +function buildTrendForecast( + trend: AnalyticsData['trend'], + horizon = 2, +): { rows: TrendRow[]; hasForecast: boolean } { + const base: TrendRow[] = trend.map((p) => ({ + period: p.period, + revenue: p.revenue, + revenueForecast: null, + booths: p.booths, + })); + if (trend.length < 3) return { rows: base, hasForecast: false }; + + // 최소제곱 회귀: x=인덱스(0..n-1), y=revenue. + const n = trend.length; + let sx = 0, sy = 0, sxx = 0, sxy = 0; + trend.forEach((p, i) => { + sx += i; sy += p.revenue; sxx += i * i; sxy += i * p.revenue; + }); + const denom = n * sxx - sx * sx; + if (denom === 0) return { rows: base, hasForecast: false }; + const slope = (n * sxy - sx * sy) / denom; + const intercept = (sy - slope * sx) / n; + + // 브릿지: 마지막 실측점을 예측선 시작점으로 복제(두 시리즈 연결). + base[base.length - 1].revenueForecast = base[base.length - 1].revenue; + + const rows = [...base]; + for (let k = 1; k <= horizon; k++) { + const idx = n - 1 + k; + const yhat = Math.max(0, Math.round(intercept + slope * idx)); + rows.push({ + period: addMonths(trend[n - 1].period, k), + revenue: null, + revenueForecast: yhat, + booths: null, + }); + } + return { rows, hasForecast: true }; +} +/** "YYYY-MM" 에 개월 가산. 파싱 실패 시 원본에 접미사(비회귀 폴백). */ +function addMonths(period: string, add: number): string { + const m = /^(\d{4})-(\d{2})$/.exec(period); + if (!m) return `${period}+${add}`; + let y = Number(m[1]); + let mo = Number(m[2]) + add; + y += Math.floor((mo - 1) / 12); + mo = ((mo - 1) % 12) + 1; + return `${y}-${String(mo).padStart(2, '0')}`; +} + function isDegradable(error: unknown): boolean { return ( error instanceof ApiRequestError && diff --git a/src/frontend/src/screens/analytics/analytics.css b/src/frontend/src/screens/analytics/analytics.css index 1337eb8..4d729b6 100644 --- a/src/frontend/src/screens/analytics/analytics.css +++ b/src/frontend/src/screens/analytics/analytics.css @@ -61,6 +61,16 @@ .kx-bi__chart { width: 100%; } +.kx-bi__forecast-legend { + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-2); +} +.kx-bi__forecast-note { + font-size: var(--fs-micro); + color: var(--color-neutral-500); +} .kx-bi__sectors { align-self: stretch; } diff --git a/src/frontend/src/screens/chartColors.ts b/src/frontend/src/screens/chartColors.ts index 28a8159..3a4fd4c 100644 --- a/src/frontend/src/screens/chartColors.ts +++ b/src/frontend/src/screens/chartColors.ts @@ -11,7 +11,8 @@ export const CHART = { aiAccent: '#6D4AFF', // AI 전용(수요예측 등) aiSurface: '#F5F3FF', success: '#0E8A5F', - warning: '#F79009', // 오버레이/차트용 앰버(§1 violation-warn) + violationWarn: '#F79009', // 오버레이/차트용 앰버(§1 violation-warn) — 키명은 실제 값(violation-warn) 기준 + error: '#D92D20', slate: '#667085', // 보조 계열 neutral200: '#E4E7EC', diff --git a/src/frontend/src/screens/cms/MultilingualCmsPage.tsx b/src/frontend/src/screens/cms/MultilingualCmsPage.tsx index d61f999..5e1b744 100644 --- a/src/frontend/src/screens/cms/MultilingualCmsPage.tsx +++ b/src/frontend/src/screens/cms/MultilingualCmsPage.tsx @@ -2,7 +2,8 @@ * SCR-37 다국어 콘텐츠 관리 [M17 / F069]. 참조: design.md §3 SCR-37. * 상단: 언어 커버리지(한/영/중/일) · 좌: 번역 매트릭스(콘텐츠 × 대상언어 상태) · 우: 병렬 편집(원문 KO ↔ 번역) + 검수완료. * ★실 API 전환: GET /api/cms/contents + GET/PUT /api/cms/contents/{id}/translations. 언어별 upsert·검수완료 실동작. - * AI 자동 번역/재생성은 미배선(AiTextRouter 연동 예정) → disabled + 툴팁. + * AI 자동 번역/재생성: POST /api/cms/contents/{id}/translations/ai?lang= (AiTextRouter Claude→Ollama 폴백) 실배선 — + * 성공 시 trans_status='ai' 초벌 채움, degraded(AI 미가용) 시 저장 없이 안내만(환각 방지). */ import { useEffect, useMemo, useState } from 'react'; import { Button } from '../../components/ui/Button'; diff --git a/src/frontend/src/screens/schedule/ExhibitionSchedulePage.tsx b/src/frontend/src/screens/schedule/ExhibitionSchedulePage.tsx index fdb1ea6..cc5b540 100644 --- a/src/frontend/src/screens/schedule/ExhibitionSchedulePage.tsx +++ b/src/frontend/src/screens/schedule/ExhibitionSchedulePage.tsx @@ -81,13 +81,13 @@ function periodLabel(start: string, end: string): string { /** 실 API ExhibitionDto → 화면 ScheduleEvent 매핑. DB 미포함 필드(카테고리·홀·인원)는 기본값. */ function toScheduleEvent(e: ExhibitionDto, i: number): ScheduleEvent { - const category = (KNOWN_CATEGORIES.has(e.category as EventCategory) - ? (e.category as EventCategory) - : 'exhibition') as EventCategory; + const known = KNOWN_CATEGORIES.has(e.category as EventCategory); + const category = (known ? (e.category as EventCategory) : 'exhibition') as EventCategory; return { id: e.id, title: e.name, category, + categoryDefaulted: !known, status: statusFromDates(e.startDate, e.endDate), period: periodLabel(e.startDate, e.endDate), start: e.startDate, @@ -278,7 +278,17 @@ export function ExhibitionSchedulePage() {

{e.title}

{t(`schedule.status.${e.status}`)} -

{t('schedule.catExhibit', { cat: t(`schedule.cat.${e.category}`) })}

+

+ {t('schedule.catExhibit', { cat: t(`schedule.cat.${e.category}`) })} + {e.categoryDefaulted && ( + + {t('schedule.catDefaulted', { defaultValue: '기본값' })} + + )} +