fix(ui): resolve reviewer backlog R13-01, R15-02, R17-01, R17-02 + stale comments

- 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 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-14 22:06:23 +09:00
parent d548044f34
commit 3f479a93cb
11 changed files with 208 additions and 14 deletions

View File

@ -139,14 +139,29 @@ export function AnalyticsDashboardPage() {
<div className="kx-bi__chart">
{data.trend.length === 0 ? (
<EmptyState title={t('analytics.trendEmptyTitle')} description={t('analytics.trendEmptyDesc')} />
) : (
) : (() => {
const { rows, hasForecast } = buildTrendForecast(data.trend);
return (
<>
{hasForecast && (
<div className="kx-bi__forecast-legend">
<AiLabel>{t('analytics.forecastLabel', { defaultValue: 'AI 수요예측' })}</AiLabel>
<span className="kx-bi__forecast-note">
{t('analytics.forecastNote', { defaultValue: '선형회귀 기반 향후 2개월 예측(점선)' })}
</span>
</div>
)}
<ResponsiveContainer width="100%" height={320}>
<AreaChart data={data.trend} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
<AreaChart data={rows} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
<defs>
<linearGradient id="gRevenue" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={CHART.primary600} stopOpacity={0.35} />
<stop offset="100%" stopColor={CHART.primary600} stopOpacity={0.02} />
</linearGradient>
<linearGradient id="gRevenueForecast" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={CHART.aiAccent} stopOpacity={0.22} />
<stop offset="100%" stopColor={CHART.aiAccent} stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid stroke={CHART.neutral200} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="period" tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={{ stroke: CHART.neutral200 }} />
@ -155,14 +170,34 @@ export function AnalyticsDashboardPage() {
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(value, _name, item) =>
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)
}
/>
<Area yAxisId="rev" type="monotone" dataKey="revenue" name={t('analytics.revenue')} stroke={CHART.primary600} fill="url(#gRevenue)" strokeWidth={2} />
<Area yAxisId="booth" type="monotone" dataKey="booths" name={t('analytics.booth')} stroke={CHART.aiAccent} fill={CHART.aiAccent} fillOpacity={0.08} strokeWidth={2} />
<Area yAxisId="rev" type="monotone" dataKey="revenue" name={t('analytics.revenue')} stroke={CHART.primary600} fill="url(#gRevenue)" strokeWidth={2} connectNulls={false} />
{hasForecast && (
<Area
yAxisId="rev"
type="monotone"
dataKey="revenueForecast"
name={t('analytics.forecastLabel', { defaultValue: 'AI 수요예측' })}
stroke={CHART.aiAccent}
fill="url(#gRevenueForecast)"
strokeWidth={2}
strokeDasharray="5 4"
dot={{ r: 2 }}
connectNulls
/>
)}
<Area yAxisId="booth" type="monotone" dataKey="booths" name={t('analytics.booth')} stroke={CHART.aiAccent} fill={CHART.aiAccent} fillOpacity={0.08} strokeWidth={2} connectNulls={false} />
</AreaChart>
</ResponsiveContainer>
)}
</>
);
})()}
</div>
</section>
@ -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 &&

View File

@ -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;
}

View File

@ -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',

View File

@ -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 ClaudeOllama )
* trans_status='ai' , degraded(AI ) ( ).
*/
import { useEffect, useMemo, useState } from 'react';
import { Button } from '../../components/ui/Button';

View File

@ -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() {
<h2 className="kx-evcard__title">{e.title}</h2>
<span className={`kx-evstatus is-${e.status}`}>{t(`schedule.status.${e.status}`)}</span>
</div>
<p className="kx-evcard__cat">{t('schedule.catExhibit', { cat: t(`schedule.cat.${e.category}`) })}</p>
<p className="kx-evcard__cat">
{t('schedule.catExhibit', { cat: t(`schedule.cat.${e.category}`) })}
{e.categoryDefaulted && (
<span
className="kx-evcard__cat-default"
title={t('schedule.catDefaultedTip', { defaultValue: '행사 분류 미지정 — 기본값(전시)으로 표시' })}
>
{t('schedule.catDefaulted', { defaultValue: '기본값' })}
</span>
)}
</p>
<ul className="kx-evcard__meta">
<li aria-label={t('schedule.period')}><IconCalendar size={14} /> <span className="tnum">{e.period}</span></li>
<li aria-label={t('schedule.assignedHall')}><IconGrid size={14} /> {e.halls}</li>

View File

@ -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.0509.08"
start: string; // "2026-09-05"

View File

@ -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;

View File

@ -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<IconProps>] as const);
/*
* SCR-17 (§1 / ).
* (/_styleguide) . · design.md §1(tokens.css).
@ -192,6 +204,22 @@ export function ComponentGuidePage() {
</div>
</div>
</section>
{/* 8. 시스템 아이콘 라이브러리 (R17-01 · SCR-17 스펙 9항목) */}
<section className="kx-sg-card kx-sg-card--wide" data-accent="primary">
<h2 className="kx-sg-card__title"> </h2>
<p className="kx-sg-card__note">
components/ui/icons.tsx SVG · fill:none / stroke:currentColor · size . {ICON_ENTRIES.length}.
</p>
<ul className="kx-sg__icons">
{ICON_ENTRIES.map(([name, Comp]) => (
<li key={name} className="kx-sg__icon-cell">
<Comp size={22} aria-hidden="true" />
<span className="kx-sg__icon-name">{name}</span>
</li>
))}
</ul>
</section>
</div>
</div>
);

View File

@ -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,

View File

@ -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';

View File

@ -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')}`;