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:
parent
d548044f34
commit
3f479a93cb
@ -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 &&
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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')}`;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user