fix(ui): 모니터링/디자인시스템 런타임 에러 해소

### SystemStatusPanel TypeError
- 증상: /monitoring 에서 Uncaught TypeError: Cannot read properties of undefined (reading 'toLocaleString')
- 원인: stats 객체는 존재하나 total 필드가 undefined 인 경우 (백엔드 응답이 기대 shape 와 다를 때) 크래시
- 수정: stats?.total != null ? ... / stats.critical ?? 0 식 null-safe 전환 (total/clusterCount/gearGroups/critical/high/medium/low 전부)

### CatalogBadges 렌더링 오류
- 증상: /design-system.html 에서
  (1) Each child in a list should have a unique "key" prop
  (2) Objects are not valid as a React child (found: object with keys {ko, en})
- 원인: PERFORMANCE_STATUS_META 의 meta 는 {intent, hex, label: {ko, en}} 형식. code 필드 없고 label 이 객체.
  - Object.values() + <Trk key={meta.code}> 로 undefined key 중복
  - getKoLabel 이 meta.label (객체) 그대로 반환해 Badge children 에 객체 주입
  다른 카탈로그는 fallback: {ko, en} 패턴이라 문제 없음 (performanceStatus 만 label 객체)
- 수정:
  - Object.entries() 로 순회해 Record key 를 안정적 식별자로 사용
  - AnyMeta.label 타입을 string | {ko,en} 확장
  - getKoLabel/getEnLabel 우선순위: fallback.ko → label.ko → label(문자열) → code → key
  - PERFORMANCE_STATUS_META 자체는 변경 안 함 (admin 페이지들이 label.ko/label.en 직접 참조 중)

### 검증
- npx tsc --noEmit 통과
- pre-commit tsc+ESLint 통과
This commit is contained in:
htlee 2026-04-20 06:31:11 +09:00
부모 2395ef1613
커밋 eee9e79818
2개의 변경된 파일31개의 추가작업 그리고 18개의 파일을 삭제

파일 보기

@ -11,19 +11,30 @@ import { CATALOG_REGISTRY, type CatalogEntry } from '@shared/constants/catalogRe
*/
interface AnyMeta {
code: string;
/** 일부 카탈로그는 code 없이 Record key 만 사용 (예: PERFORMANCE_STATUS_META) */
code?: string;
intent?: BadgeIntent;
fallback?: { ko: string; en: string };
classes?: string | { bg?: string; text?: string; border?: string };
label?: string;
/** 문자열 라벨 또는 { ko, en } 객체 라벨 양쪽 지원 */
label?: string | { ko: string; en: string };
}
function getKoLabel(meta: AnyMeta): string {
return meta.fallback?.ko ?? meta.label ?? meta.code;
function getKoLabel(meta: AnyMeta, fallbackKey: string): string {
if (meta.fallback?.ko) return meta.fallback.ko;
if (meta.label && typeof meta.label === 'object' && 'ko' in meta.label) {
return meta.label.ko;
}
if (typeof meta.label === 'string') return meta.label;
return meta.code ?? fallbackKey;
}
function getEnLabel(meta: AnyMeta): string | undefined {
return meta.fallback?.en;
if (meta.fallback?.en) return meta.fallback.en;
if (meta.label && typeof meta.label === 'object' && 'en' in meta.label) {
return meta.label.en;
}
return undefined;
}
function getFallbackClasses(meta: AnyMeta): string | undefined {
@ -55,17 +66,19 @@ function renderBadge(meta: AnyMeta, label: string): ReactNode {
}
function CatalogBadges({ entry }: { entry: CatalogEntry }) {
const items = Object.values(entry.items) as AnyMeta[];
// Record key 를 안정적 식별자로 사용 (일부 카탈로그는 meta.code 없음)
const items = Object.entries(entry.items) as [string, AnyMeta][];
return (
<div className="space-y-1.5">
{items.map((meta) => {
const koLabel = getKoLabel(meta);
{items.map(([key, meta]) => {
const displayCode = meta.code ?? key;
const koLabel = getKoLabel(meta, key);
const enLabel = getEnLabel(meta);
const trkId = `${entry.showcaseId}-${meta.code}`;
const trkId = `${entry.showcaseId}-${displayCode}`;
return (
<Trk key={meta.code} id={trkId} className="flex items-center gap-3 rounded-sm">
<Trk key={key} id={trkId} className="flex items-center gap-3 rounded-sm">
<code className="text-[10px] text-hint font-mono whitespace-nowrap w-32 shrink-0 truncate">
{meta.code}
{displayCode}
</code>
<div className="flex-1">{renderBadge(meta, koLabel)}</div>
<div className="flex-1">

파일 보기

@ -101,9 +101,9 @@ export function SystemStatusPanel() {
status={stats ? 'CONNECTED' : 'DISCONNECTED'}
statusIntent={stats ? 'success' : 'critical'}
details={[
['선박 분석', stats ? `${stats.total.toLocaleString()}` : '-'],
['클러스터', stats ? `${stats.clusterCount}` : '-'],
['어구 그룹', stats ? `${stats.gearGroups}` : '-'],
['선박 분석', stats?.total != null ? `${stats.total.toLocaleString()}` : '-'],
['클러스터', stats?.clusterCount != null ? `${stats.clusterCount}` : '-'],
['어구 그룹', stats?.gearGroups != null ? `${stats.gearGroups}` : '-'],
]}
/>
@ -124,10 +124,10 @@ export function SystemStatusPanel() {
{/* 위험도 분포 */}
{stats && (
<div className="grid grid-cols-4 gap-2">
<RiskBox label="CRITICAL" value={stats.critical} color="text-red-400" />
<RiskBox label="HIGH" value={stats.high} color="text-orange-400" />
<RiskBox label="MEDIUM" value={stats.medium} color="text-yellow-400" />
<RiskBox label="LOW" value={stats.low} color="text-blue-400" />
<RiskBox label="CRITICAL" value={stats.critical ?? 0} color="text-red-400" />
<RiskBox label="HIGH" value={stats.high ?? 0} color="text-orange-400" />
<RiskBox label="MEDIUM" value={stats.medium ?? 0} color="text-yellow-400" />
<RiskBox label="LOW" value={stats.low ?? 0} color="text-blue-400" />
</div>
)}
</CardContent>