- 모든 feature 페이지의 Badge className 패턴을 intent/size prop으로 변환 - 컬러풀 액션 버튼 (bg-*-500/600/700 + text-heading) -> text-on-vivid - 검색/필터 버튼 배경 bg-blue-400 + text-on-bright (밝은 배경 위 검정) - ROLE_COLORS 4곳 중복 제거 (MainLayout/UserRoleAssignDialog/ PermissionsPanel/AccessControl) -> getRoleBadgeStyle 공통 호출 - PermissionsPanel 역할 생성/수정에 ColorPicker 통합 - MainLayout: PagePagination + scroll page state 제거 (데이터 페이지네이션 혼동) - Dashboard RiskBar 단위 버그 수정 (0~100 정수 처리) - ReportManagement, TransferDetection p-5 space-y-4 padding 복구 - EnforcementHistory 그리드 minmax 적용으로 컬럼 잘림 해소 - timeline 시간 formatDateTime 적용 (ISO T 구분자 처리) - 각 feature 페이지가 공통 카탈로그 API (getXxxIntent/Label/Classes) 사용
192 lines
9.5 KiB
TypeScript
192 lines
9.5 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 { 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';
|
|
|
|
interface Report {
|
|
id: string;
|
|
name: string;
|
|
type: string;
|
|
status: string;
|
|
statusColor: string;
|
|
date: string;
|
|
mmsiNote: string;
|
|
evidence: number;
|
|
}
|
|
|
|
const reports: Report[] = [
|
|
{ id: 'RPT-2024-0142', name: '浙江렌센號', type: 'EEZ 침범', status: 'EEZ', statusColor: 'bg-green-500', date: '2026-01-20 14:30:00', mmsiNote: 'MMSI 변조', evidence: 12 },
|
|
{ id: 'RPT-2024-0231', name: '福建海丰號', type: 'EEZ 침범', status: '확인', statusColor: 'bg-blue-500', date: '2026-01-20 14:29:00', mmsiNote: '', evidence: 8 },
|
|
{ id: 'RPT-2024-0089', name: '무명선박-A', type: '다크베셀', status: '처리중', statusColor: 'bg-yellow-500', date: '2026-01-20 14:05:00', mmsiNote: '', evidence: 6 },
|
|
];
|
|
|
|
export function ReportManagement() {
|
|
const { t } = useTranslation('statistics');
|
|
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 (
|
|
<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>
|
|
|
|
{/* 증거 파일 업로드 */}
|
|
{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 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 className={`${r.statusColor} text-heading text-[10px]`}>{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 className="bg-blue-600 text-on-vivid text-[11px] px-3 py-1 rounded hover:bg-blue-500 transition-colors">PDF</button>
|
|
<button className="bg-muted text-heading text-[11px] px-3 py-1 rounded hover:bg-muted transition-colors">한글</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 className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 text-on-vivid px-3 py-1.5 rounded-lg text-xs transition-colors">
|
|
<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>
|
|
</div>
|
|
);
|
|
}
|