kcg-ai-monitoring/frontend/src/features/statistics/ReportManagement.tsx
htlee c1cc36b134 refactor(design-system): 하드코딩 색상 라이트/다크 대응 + raw button/input 공통 컴포넌트 치환
30개 파일 전 영역에 동일한 패턴으로 SSOT 준수:

**StatBox 재설계 (2파일)**:
- RealGearGroups, RealVesselAnalysis 의 `color: string` prop 제거
- `intent: BadgeIntent` prop + `INTENT_TEXT_CLASS` 매핑 도입

**raw `<button>` → Button 컴포넌트 (다수)**:
- `bg-blue-600 hover:bg-blue-500 text-on-vivid ...` → `<Button variant="primary">`
- `bg-orange-600 ...` / `bg-green-600 ...` → `<Button variant="primary">`
- `bg-red-600 ...` → `<Button variant="destructive">`
- 아이콘 전용 → `<Button variant="ghost" aria-label=".." icon={...} />`
- detection/enforcement/admin/parent-inference/statistics/ai-operations/auth 전영역

**raw `<input>` → Input 컴포넌트**:
- parent-inference (ParentReview, ParentExclusion, LabelSession)
- admin (PermissionsPanel, UserRoleAssignDialog)
- ai-operations (AIAssistant)
- auth (LoginPage)

**raw `<select>` → Select 컴포넌트**:
- detection (RealGearGroups, RealVesselAnalysis, ChinaFishing)

**커스텀 탭 → TabBar/TabButton (segmented/underline)**:
- ChinaFishing: 모드 탭 + 선박 탭 + 통계 탭

**raw `<input type="checkbox">` → Checkbox**:
- GearDetection FilterCheckGroup

**하드코딩 Tailwind 색상 라이트/다크 쌍 변환 (전영역)**:
- `text-red-400` → `text-red-600 dark:text-red-400`
- `text-green-400` → `text-green-600 dark:text-green-400`
- blue/cyan/orange/yellow/purple/amber 동일 패턴
- `text-*-500` 아이콘도 `text-*-600 dark:text-*-500` 로 라이트 모드 대응
- 상태 dot (bg-red-500 animate-pulse 등)은 의도적 시각 구분이므로 유지

**에러 메시지 한글 → t('error.errorPrefix') 통일**:
- detection/parent-inference/admin 에서 `에러: {error}` 패턴 → `t('error.errorPrefix', { msg: error })`

**결과**: tsc 0 errors / eslint 0 errors (84 warnings 기존)
2026-04-16 17:09:14 +09:00

197 lines
9.0 KiB
TypeScript

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 { FileText, Plus, Upload, X, Clock, MapPin, Download } from 'lucide-react';
import type { BadgeIntent } from '@lib/theme/variants';
interface Report {
id: string;
name: string;
type: string;
status: string;
statusIntent: BadgeIntent;
date: string;
mmsiNote: string;
evidence: number;
}
const reports: Report[] = [
{ id: 'RPT-2024-0142', name: '浙江렌센號', type: 'EEZ 침범', status: 'EEZ', statusIntent: 'success', date: '2026-01-20 14:30:00', mmsiNote: 'MMSI 변조', evidence: 12 },
{ id: 'RPT-2024-0231', name: '福建海丰號', type: 'EEZ 침범', status: '확인', statusIntent: 'info', date: '2026-01-20 14:29:00', mmsiNote: '', evidence: 8 },
{ id: 'RPT-2024-0089', name: '무명선박-A', type: '다크베셀', status: '처리중', statusIntent: 'warning', date: '2026-01-20 14:05:00', mmsiNote: '', evidence: 6 },
];
export function ReportManagement() {
const { t } = useTranslation('statistics');
const { t: tc } = useTranslation('common');
const [selected, setSelected] = useState<Report>(reports[0]);
const [search, setSearch] = useState('');
const [showUpload, setShowUpload] = useState(false);
const filtered = reports.filter((r) =>
!search || r.name.includes(search) || r.id.includes(search) || r.type.includes(search)
);
return (
<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 && (
<div className="rounded-xl border border-border bg-card p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] text-label font-bold"> (··)</span>
<button type="button" aria-label={tc('aria.uploadPanelClose')} onClick={() => setShowUpload(false)} className="text-hint hover:text-muted-foreground"><X className="w-4 h-4" /></button>
</div>
<FileUpload accept=".jpg,.jpeg,.png,.mp4,.pdf,.hwp,.docx" multiple maxSizeMB={100} />
<div className="flex justify-end mt-3">
<SaveButton onClick={() => setShowUpload(false)} label="증거 저장" />
</div>
</div>
)}
<div className="flex gap-5">
{/* 보고서 목록 */}
<Card className="flex-1 bg-surface-overlay border-slate-700/40">
<CardContent className="p-5">
<div className="text-sm text-label mb-3"> </div>
<div className="space-y-3">
{filtered.map((r) => (
<div
key={r.id}
onClick={() => setSelected(r)}
className={`p-4 rounded-lg border cursor-pointer transition-all ${
selected?.id === r.id
? 'bg-switch-background/40 border-blue-500/40'
: 'bg-surface-raised border-slate-700/30 hover:bg-surface-overlay'
}`}
>
<div className="flex items-center justify-between mb-1">
<span className="text-heading font-medium text-sm">{r.name}</span>
<Badge intent={r.statusIntent} size="xs">{r.status}</Badge>
</div>
<div className="text-[11px] text-hint">{r.id}</div>
<div className="flex items-center gap-2 text-[11px] text-hint mt-0.5">
<span>{r.type}</span>
{r.mmsiNote && <><span>·</span><span>{r.mmsiNote}</span></>}
<span>·</span>
<Clock className="w-3 h-3 inline" />
<span>{r.date}</span>
</div>
<div className="text-[11px] text-hint mt-1"> {r.evidence}</div>
<div className="flex gap-2 mt-2">
<Button variant="primary" size="sm">PDF</Button>
<Button variant="secondary" size="sm"></Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* 보고서 미리보기 */}
{selected && (
<Card className="w-[460px] shrink-0 bg-surface-overlay border-slate-700/40">
<CardContent className="p-5">
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-label"> </div>
<Button variant="primary" size="sm" icon={<Download className="w-3.5 h-3.5" />}>
</Button>
</div>
<div className="bg-surface-raised border border-slate-700/40 rounded-xl p-6 space-y-5">
{/* 제목 */}
<div>
<h3 className="text-lg font-bold text-heading mb-0.5"> </h3>
<div className="text-[11px] text-hint"> : {selected.id}</div>
</div>
{/* 사건 개요 */}
<div>
<div className="text-sm text-label mb-2"> </div>
<div className="grid grid-cols-2 gap-y-2 text-[11px]">
<div className="text-hint">:</div><div className="text-heading text-right">{selected.name}</div>
<div className="text-hint"> :</div><div className="text-heading text-right">{selected.mmsiNote || selected.type}</div>
<div className="text-hint"> :</div><div className="text-heading text-right">2026-01-20 14:23:15</div>
<div className="text-hint">:</div><div className="text-heading text-right">EEZ (37.56°N, 129.12°E)</div>
</div>
</div>
{/* 지도 스냅샷 */}
<div>
<div className="text-sm text-label mb-2"> </div>
<div className="bg-green-950/10 border border-green-900/20 rounded-lg h-24 flex items-center justify-center">
<MapPin className="w-8 h-8 text-hint" />
</div>
</div>
{/* AI 판단 설명 */}
<div>
<div className="text-sm text-label mb-2">AI </div>
<div className="space-y-1.5">
{['MMSI 변조 패턴 감지', '3kn 이하 저속 42분간 지속', 'EEZ 1.2km 침범'].map((t, i) => (
<div key={i} className="flex items-center gap-2 text-[11px] text-muted-foreground">
<div className="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" />{t}
</div>
))}
</div>
</div>
{/* 조치 이력 */}
<div>
<div className="text-sm text-label mb-2"> </div>
{['2026-01-20 14:30: 사건 등록', '2026-01-20 14:35: 증거 수집 완료', '2026-01-20 14:45: 보고서 생성'].map((t, i) => (
<div key={i} className="text-[11px] text-hint">- {t}</div>
))}
</div>
</div>
</CardContent>
</Card>
)}
</div>
</PageContainer>
);
}