refactor(frontend): statistics/ai-ops/parent-inference PageContainer 적용

statistics:
- Statistics: icon=BarChart3, secondary 보고서 생성 Button
- ReportManagement: destructive '새 보고서' + Button 그룹 + demo
- ExternalService: demo

ai-operations:
- AIAssistant: PageContainer + h-full flex 조합 (chat 레이아웃)
- AIModelManagement: 운영 모델 상태 뱃지는 actions 슬롯 유지
- MLOpsPage: demo

parent-inference:
- ParentReview/LabelSession/ParentExclusion: size=lg + Select + primary Button

Phase B-4 완료. 총 9개 파일.
This commit is contained in:
htlee 2026-04-08 12:04:05 +09:00
부모 2976796652
커밋 64e24cea71
9개의 변경된 파일179개의 추가작업 그리고 172개의 파일을 삭제

파일 보기

@ -2,6 +2,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { MessageSquare, Send, Bot, User, BookOpen, Shield, AlertTriangle, FileText, ExternalLink } from 'lucide-react';
import { sendChatMessage } from '@/services/chatApi';
@ -75,11 +76,13 @@ export function AIAssistant() {
};
return (
<div className="p-5 h-full flex flex-col">
<div className="mb-4">
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><MessageSquare className="w-5 h-5 text-green-400" />{t('assistant.title')}</h2>
<p className="text-[10px] text-hint mt-0.5">{t('assistant.desc')}</p>
</div>
<PageContainer className="h-full flex flex-col">
<PageHeader
icon={MessageSquare}
iconColor="text-green-400"
title={t('assistant.title')}
description={t('assistant.desc')}
/>
<div className="flex-1 flex gap-3 min-h-0">
{/* 대화 이력 사이드바 */}
<Card className="w-56 shrink-0 bg-surface-raised border-border">
@ -150,6 +153,6 @@ export function AIAssistant() {
</div>
</div>
</div>
</div>
</PageContainer>
);
}

파일 보기

@ -2,6 +2,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import {
Brain, Settings, Zap, Activity, TrendingUp, BarChart3,
@ -248,29 +249,21 @@ export function AIModelManagement() {
const currentModel = MODELS.find((m) => m.status === '운영중')!;
return (
<div className="p-5 space-y-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Brain className="w-5 h-5 text-purple-400" />
{t('modelManagement.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span><span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5">
{t('modelManagement.desc')}
</p>
</div>
<div className="flex items-center gap-2">
<PageContainer>
<PageHeader
icon={Brain}
iconColor="text-purple-400"
title={t('modelManagement.title')}
description={t('modelManagement.desc')}
demo
actions={
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-green-500/10 border border-green-500/20 rounded-lg">
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
<span className="text-[10px] text-green-400 font-bold"> : {currentModel.version}</span>
<span className="text-[10px] text-hint">Accuracy {currentModel.accuracy}%</span>
</div>
</div>
</div>
}
/>
{/* KPI */}
<div className="flex gap-2">
@ -994,6 +987,6 @@ export function AIModelManagement() {
</Card>
</div>
)}
</div>
</PageContainer>
);
}

파일 보기

@ -2,6 +2,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { getModelStatusIntent, getQualityGateIntent, getExperimentIntent, MODEL_STATUSES, QUALITY_GATE_STATUSES, EXPERIMENT_STATUSES } from '@shared/constants/modelDeploymentStatuses';
import {
Cpu, Brain, Database, GitBranch, Activity, RefreshCw, Server, Shield,
@ -111,18 +112,14 @@ export function MLOpsPage() {
const [selectedLLM, setSelectedLLM] = useState(0);
return (
<div className="p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Cpu className="w-5 h-5 text-purple-400" />{t('mlops.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span><span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('mlops.desc')}</p>
</div>
</div>
<PageContainer>
<PageHeader
icon={Cpu}
iconColor="text-purple-400"
title={t('mlops.title')}
description={t('mlops.desc')}
demo
/>
{/* 탭 */}
<div className="flex gap-0 border-b border-border">
@ -562,6 +559,6 @@ export function MLOpsPage() {
</CardContent></Card>
</div>
)}
</div>
</PageContainer>
);
}

파일 보기

@ -2,6 +2,9 @@ import { useEffect, useState, useCallback } from 'react';
import { Tag, X, Loader2 } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Select } from '@shared/components/ui/select';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { useAuth } from '@/app/auth/AuthContext';
import {
fetchLabelSessions,
@ -85,23 +88,32 @@ export function LabelSession() {
};
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-heading"> </h1>
<p className="text-xs text-hint mt-1"> prediction </p>
</div>
<div className="flex items-center gap-2">
<select value={filter} onChange={(e) => setFilter(e.target.value)}
className="bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs text-heading">
<option value=""> </option>
<option value="ACTIVE">ACTIVE</option>
<option value="CANCELLED">CANCELLED</option>
<option value="COMPLETED">COMPLETED</option>
</select>
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded"></button>
</div>
</div>
<PageContainer size="lg">
<PageHeader
icon={Tag}
iconColor="text-cyan-400"
title="학습 세션"
description="정답 라벨링 → prediction 모델 학습 데이터로 활용"
actions={
<>
<Select
size="sm"
aria-label="상태 필터"
title="상태 필터"
value={filter}
onChange={(e) => setFilter(e.target.value)}
>
<option value=""> </option>
<option value="ACTIVE">ACTIVE</option>
<option value="CANCELLED">CANCELLED</option>
<option value="COMPLETED">COMPLETED</option>
</Select>
<Button variant="primary" size="sm" onClick={load}>
</Button>
</>
}
/>
<Card>
<CardContent className="p-4">
@ -180,6 +192,6 @@ export function LabelSession() {
</CardContent>
</Card>
)}
</div>
</PageContainer>
);
}

파일 보기

@ -2,6 +2,9 @@ import { useEffect, useState, useCallback } from 'react';
import { Ban, RotateCcw, Loader2, Globe, Layers } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Select } from '@shared/components/ui/select';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { useAuth } from '@/app/auth/AuthContext';
import {
fetchExclusions,
@ -102,25 +105,31 @@ export function ParentExclusion() {
};
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-heading"> </h1>
<p className="text-xs text-hint mt-1">GROUP/GLOBAL .</p>
</div>
<div className="flex items-center gap-2">
<select
value={filter}
onChange={(e) => setFilter(e.target.value as '' | 'GROUP' | 'GLOBAL')}
className="bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs text-heading"
>
<option value=""> </option>
<option value="GROUP">GROUP</option>
<option value="GLOBAL">GLOBAL</option>
</select>
<button type="button" onClick={load} className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded"></button>
</div>
</div>
<PageContainer size="lg">
<PageHeader
icon={Ban}
iconColor="text-red-400"
title="모선 후보 제외"
description="GROUP/GLOBAL 스코프로 잘못된 후보를 차단합니다."
actions={
<>
<Select
size="sm"
aria-label="스코프 필터"
title="스코프 필터"
value={filter}
onChange={(e) => setFilter(e.target.value as '' | 'GROUP' | 'GLOBAL')}
>
<option value=""> </option>
<option value="GROUP">GROUP</option>
<option value="GLOBAL">GLOBAL</option>
</Select>
<Button variant="primary" size="sm" onClick={load}>
</Button>
</>
}
/>
{/* 신규 등록: GROUP */}
<Card>
@ -226,6 +235,6 @@ export function ParentExclusion() {
</CardContent>
</Card>
)}
</div>
</PageContainer>
);
}

파일 보기

@ -1,7 +1,10 @@
import { useEffect, useState, useCallback } from 'react';
import { CheckCircle, XCircle, RotateCcw, Loader2 } from 'lucide-react';
import { CheckCircle, XCircle, RotateCcw, Loader2, GitMerge } from 'lucide-react';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { Select } from '@shared/components/ui/select';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { useAuth } from '@/app/auth/AuthContext';
import {
fetchReviewList,
@ -97,34 +100,26 @@ export function ParentReview() {
};
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-heading"> /</h1>
<p className="text-xs text-hint mt-1">
/. 권한: parent-inference-workflow:parent-review (UPDATE)
</p>
</div>
<div className="flex items-center gap-2">
<select
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="bg-surface-overlay border border-border rounded px-3 py-1.5 text-xs text-heading"
>
<option value=""> </option>
<option value="UNRESOLVED"></option>
<option value="MANUAL_CONFIRMED"></option>
<option value="REVIEW_REQUIRED"></option>
</select>
<button
type="button"
onClick={load}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded"
>
</button>
</div>
</div>
<PageContainer size="lg">
<PageHeader
icon={GitMerge}
iconColor="text-purple-400"
title="모선 확정/거부"
description="추론된 모선 후보를 확정/거부합니다. 권한: parent-inference-workflow:parent-review (UPDATE)"
actions={
<>
<Select size="sm" title="상태 필터" value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value=""> </option>
<option value="UNRESOLVED"></option>
<option value="MANUAL_CONFIRMED"></option>
<option value="REVIEW_REQUIRED"></option>
</Select>
<Button variant="primary" size="sm" onClick={load}>
</Button>
</>
}
/>
{/* 신규 등록 폼 (테스트용) */}
{canUpdate && (
@ -267,6 +262,6 @@ export function ParentReview() {
</CardContent>
</Card>
)}
</div>
</PageContainer>
);
}

파일 보기

@ -1,8 +1,8 @@
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { Globe, Shield, Clock, BarChart3, ExternalLink, Lock, Unlock } from 'lucide-react';
import { Globe } from 'lucide-react';
/* SFR-14: 외부 서비스(예보·경보) 제공 결과 연계 */
@ -31,16 +31,14 @@ const cols: DataColumn<Service>[] = [
export function ExternalService() {
const { t } = useTranslation('statistics');
return (
<div className="p-5 space-y-4">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Globe className="w-5 h-5 text-green-400" />{t('externalService.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span><span> ( API )</span>
</span>
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('externalService.desc')}</p>
</div>
<PageContainer>
<PageHeader
icon={Globe}
iconColor="text-green-400"
title={t('externalService.title')}
description={t('externalService.desc')}
demo
/>
<div className="flex gap-2">
{[{ l: '운영 서비스', v: DATA.filter(d => d.status === '운영').length, c: 'text-green-400' }, { l: '테스트', v: DATA.filter(d => d.status === '테스트').length, c: 'text-blue-400' }, { l: '총 호출', v: '21,675', c: 'text-heading' }].map(k => (
<div key={k.l} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
@ -49,6 +47,6 @@ export function ExternalService() {
))}
</div>
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="서비스명, 대상기관 검색..." searchKeys={['name', 'target']} exportFilename="외부서비스연계" />
</div>
</PageContainer>
);
}

파일 보기

@ -2,12 +2,14 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { ExcelExport } from '@shared/components/common/ExcelExport';
import { PrintButton } from '@shared/components/common/PrintButton';
import { FileUpload } from '@shared/components/common/FileUpload';
import { SaveButton } from '@shared/components/common/SaveButton';
import { SearchInput } from '@shared/components/common/SearchInput';
import { Plus, Download, Clock, MapPin, Upload, X } from 'lucide-react';
import { FileText, Plus, Upload, X, Clock, MapPin, Download } from 'lucide-react';
interface Report {
id: string;
@ -37,40 +39,40 @@ export function ReportManagement() {
);
return (
<div className="p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-heading whitespace-nowrap flex items-center gap-2">
{t('reports.title')}
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/15 border border-yellow-500/30 rounded text-[10px] text-yellow-400 font-normal">
<span></span><span> ( API )</span>
</span>
</h2>
<p className="text-xs text-hint mt-0.5">{t('reports.desc')}</p>
</div>
<div className="flex items-center gap-2">
<SearchInput value={search} onChange={setSearch} placeholder="보고서 검색..." className="w-48" />
<ExcelExport
data={reports as unknown as Record<string, unknown>[]}
columns={[
{ key: 'id', label: '보고서번호' }, { key: 'name', label: '선박명' },
{ key: 'type', label: '유형' }, { key: 'status', label: '상태' },
{ key: 'date', label: '일시' }, { key: 'evidence', label: '증거수' },
]}
filename="보고서목록"
/>
<PrintButton />
<button
onClick={() => setShowUpload(!showUpload)}
className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading transition-colors"
>
<Upload className="w-3 h-3" />
</button>
<button className="flex items-center gap-2 bg-red-600 hover:bg-red-500 text-on-vivid px-4 py-2 rounded-lg text-sm transition-colors">
<Plus className="w-4 h-4" />
</button>
</div>
</div>
<PageContainer>
<PageHeader
icon={FileText}
iconColor="text-muted-foreground"
title={t('reports.title')}
description={t('reports.desc')}
demo
actions={
<>
<SearchInput value={search} onChange={setSearch} placeholder="보고서 검색..." className="w-48" />
<ExcelExport
data={reports as unknown as Record<string, unknown>[]}
columns={[
{ key: 'id', label: '보고서번호' }, { key: 'name', label: '선박명' },
{ key: 'type', label: '유형' }, { key: 'status', label: '상태' },
{ key: 'date', label: '일시' }, { key: 'evidence', label: '증거수' },
]}
filename="보고서목록"
/>
<PrintButton />
<Button
variant="secondary"
size="sm"
onClick={() => setShowUpload(!showUpload)}
icon={<Upload className="w-3 h-3" />}
>
</Button>
<Button variant="destructive" size="md" icon={<Plus className="w-4 h-4" />}>
</Button>
</>
}
/>
{/* 증거 파일 업로드 */}
{showUpload && (
@ -186,6 +188,6 @@ export function ReportManagement() {
</Card>
)}
</div>
</div>
</PageContainer>
);
}

파일 보기

@ -2,6 +2,8 @@ import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { Button } from '@shared/components/ui/button';
import { PageContainer, PageHeader } from '@shared/components/layout';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { BarChart3, Download } from 'lucide-react';
import { BarChart, AreaChart } from '@lib/charts';
@ -140,22 +142,18 @@ export function Statistics() {
});
return (
<div className="p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-purple-400" />
{t('statistics.title')}
</h2>
<p className="text-[10px] text-hint mt-0.5">
{t('statistics.desc')}
</p>
</div>
<button className="flex items-center gap-1 px-3 py-1.5 bg-surface-overlay border border-border rounded-lg text-[10px] text-muted-foreground hover:text-heading">
<Download className="w-3 h-3" />
</button>
</div>
<PageContainer>
<PageHeader
icon={BarChart3}
iconColor="text-purple-400"
title={t('statistics.title')}
description={t('statistics.desc')}
actions={
<Button variant="secondary" size="sm" icon={<Download className="w-3 h-3" />}>
</Button>
}
/>
{loading && (
<div className="text-center py-10 text-muted-foreground text-sm">
@ -242,6 +240,6 @@ export function Statistics() {
searchPlaceholder="지표명 검색..."
exportFilename="성과지표"
/>
</div>
</PageContainer>
);
}