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: '기본값' })}
+
+ )}
+
- {e.period}
- {e.halls}
diff --git a/src/frontend/src/screens/schedule/sampleSchedule.ts b/src/frontend/src/screens/schedule/sampleSchedule.ts
index 098f35d..052b952 100644
--- a/src/frontend/src/screens/schedule/sampleSchedule.ts
+++ b/src/frontend/src/screens/schedule/sampleSchedule.ts
@@ -16,6 +16,8 @@ export interface ScheduleEvent {
id: string;
title: string;
category: EventCategory;
+ /** category 가 DB 실값이 아니라 기본값('exhibition')으로 대체됐는지(R15-02 시각 표기용). */
+ categoryDefaulted?: boolean;
status: EventStatus;
period: string; // "2026.09.05–09.08"
start: string; // "2026-09-05"
diff --git a/src/frontend/src/screens/schedule/schedule.css b/src/frontend/src/screens/schedule/schedule.css
index c5f23b1..b800b5f 100644
--- a/src/frontend/src/screens/schedule/schedule.css
+++ b/src/frontend/src/screens/schedule/schedule.css
@@ -101,6 +101,18 @@
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
+.kx-evcard__cat-default {
+ display: inline-block;
+ margin-left: var(--space-1);
+ padding: 0 6px;
+ border-radius: 999px;
+ font-size: var(--fs-micro);
+ line-height: 1.6;
+ color: var(--color-neutral-500);
+ background: var(--color-neutral-100);
+ border: 1px solid var(--color-neutral-200);
+ vertical-align: middle;
+}
.kx-evcard__meta {
list-style: none;
margin: var(--space-1) 0;
diff --git a/src/frontend/src/screens/styleguide/ComponentGuidePage.tsx b/src/frontend/src/screens/styleguide/ComponentGuidePage.tsx
index 3ca5248..b2dc0fe 100644
--- a/src/frontend/src/screens/styleguide/ComponentGuidePage.tsx
+++ b/src/frontend/src/screens/styleguide/ComponentGuidePage.tsx
@@ -1,9 +1,21 @@
+import type { ComponentType } from 'react';
import { AiImage } from '../../components/ui/AiImage';
import { AiLabel, DdayChip, RoleBadge, StatusBadge, type FlowStatus } from '../../components/ui/Badge';
import { Button } from '../../components/ui/Button';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
+import * as Icons from '../../components/ui/icons';
+import type { IconProps } from '../../components/ui/icons';
import './styleguide.css';
+/*
+ * SCR-17 §9 "시스템 아이콘 라이브러리" — components/ui/icons.tsx 의 선 SVG 세트를 자동 열거한다.
+ * 네임스페이스 import 로 아이콘 추가/제거 시 자동 동기화(신규 아이콘 제작 아님 — 기존 세트 나열).
+ */
+const ICON_ENTRIES = (Object.entries(Icons) as [string, unknown][])
+ .filter(([name, val]) => name.startsWith('Icon') && typeof val === 'function')
+ .sort((a, b) => a[0].localeCompare(b[0]))
+ .map(([name, comp]) => [name.replace(/^Icon/, ''), comp as ComponentType] as const);
+
/*
* SCR-17 컴포넌트 가이드 (§1 실증 스타일가이드 / 스토리북 성격).
* 내부 전용(/_styleguide) — 제품 내비 비노출. 색·치수 권위는 design.md §1(tokens.css).
@@ -192,6 +204,22 @@ export function ComponentGuidePage() {
+
+ {/* 8. 시스템 아이콘 라이브러리 (R17-01 · SCR-17 스펙 9항목) */}
+
+ 시스템 아이콘 라이브러리
+
+ components/ui/icons.tsx 선 SVG 세트 · fill:none / stroke:currentColor 상속 · size 조절. 총 {ICON_ENTRIES.length}종.
+
+
+ {ICON_ENTRIES.map(([name, Comp]) => (
+ -
+
+ {name}
+
+ ))}
+
+
);
diff --git a/src/frontend/src/screens/styleguide/styleguide.css b/src/frontend/src/screens/styleguide/styleguide.css
index 9200be2..695549c 100644
--- a/src/frontend/src/screens/styleguide/styleguide.css
+++ b/src/frontend/src/screens/styleguide/styleguide.css
@@ -276,6 +276,36 @@
justify-content: center;
}
+/* 8. 시스템 아이콘 라이브러리 */
+.kx-sg-card--wide {
+ grid-column: 1 / -1;
+}
+.kx-sg__icons {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
+ gap: var(--space-3);
+}
+.kx-sg__icon-cell {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-3) var(--space-2);
+ border: var(--border-card);
+ border-radius: var(--radius-md);
+ color: var(--color-neutral-700, var(--color-neutral-900));
+ background: var(--color-neutral-50, #fff);
+}
+.kx-sg__icon-name {
+ font-size: var(--fs-micro);
+ color: var(--color-neutral-500);
+ text-align: center;
+ word-break: break-word;
+}
+
@media (max-width: 1000px) {
.kx-sg__grid,
.kx-sg__form,
diff --git a/src/frontend/src/screens/work/MeetingPage.tsx b/src/frontend/src/screens/work/MeetingPage.tsx
index c39e29d..efeee1b 100644
--- a/src/frontend/src/screens/work/MeetingPage.tsx
+++ b/src/frontend/src/screens/work/MeetingPage.tsx
@@ -2,7 +2,8 @@
* SCR-45 회의록 (meeting). 참조: UIWS meeting/MeetingDetailPage.
* 좌: 회의 목록 / 우: 상세(본문·회의록 편집·액션아이템).
* 백엔드: /api/work/meetings (get·create·minutes·actions·action status).
- * ★ 갭: 녹음 재생·STT 전사·AI 요약·Jasper PDF 엔드포인트 부재 → 해당 UI는 disabled+툴팁. 07_work_api_gaps.md 기록.
+ * STT 전사·AI 회의록 자동작성은 실배선(G-06 해소, 2026-07-14) — MeetingAiPanel(업로드→STT→회의록)
+ * ↔ MeetingController/MeetingSttClient/MeetingMinutesGenerator + V52 meeting_transcript. Jasper PDF는 후속.
*/
import { useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
diff --git a/src/frontend/src/screens/work/ReportPage.tsx b/src/frontend/src/screens/work/ReportPage.tsx
index 218d2fb..05ffe6e 100644
--- a/src/frontend/src/screens/work/ReportPage.tsx
+++ b/src/frontend/src/screens/work/ReportPage.tsx
@@ -32,7 +32,7 @@ const PERIODS: { v: Period; l: string }[] = [
{ v: 'QUARTERLY', l: '분기' },
{ v: 'YEARLY', l: '연간' },
];
-const BAR_COLORS = [CHART.primary600, CHART.primary700, CHART.aiAccent, CHART.success, CHART.warning, CHART.slate];
+const BAR_COLORS = [CHART.primary600, CHART.primary700, CHART.aiAccent, CHART.success, CHART.violationWarn, CHART.slate];
function iso(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;