wing-ops/frontend/src/tabs/reports/components/ReportsView.tsx
htlee c727afd1ba refactor(frontend): 대형 View 서브탭 단위 분할 + FEATURE_ID 체계 도입
6개 대형 View(AerialView, AssetsView, ReportsView, PreScatView, AdminView, LeftPanel)를
서브탭 단위로 분할하여 모듈 경계를 명확히 함.

- AerialView (2,526줄 → 8파일): MediaManagement, OilAreaAnalysis, RealtimeDrone 등
- AssetsView (2,047줄 → 8파일): AssetManagement, AssetMap, ShipInsurance 등
- ReportsView (1,596줄 → 5파일): TemplateFormEditor, ReportGenerator 등
- PreScatView (1,390줄 → 7파일): ScatLeftPanel, ScatMap, ScatPopup 등
- AdminView (1,306줄 → 7파일): UsersPanel, PermissionsPanel, MenusPanel 등
- LeftPanel (1,237줄 → 5파일): PredictionInputSection, InfoLayerSection, OilBoomSection 등

FEATURE_ID 레지스트리(common/constants/featureIds.ts) 및
감사로그 서브탭 추적 훅(useFeatureTracking) 추가.

.gitignore의 scat/ → /scat/ 수정 (scat 탭 파일 추적 누락 수정)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:19:22 +09:00

392 lines
25 KiB
TypeScript
Executable File

import { useState, useEffect } from 'react'
import {
OilSpillReportTemplate,
loadReports,
deleteReportFromStorage,
type OilSpillReportData,
type Jurisdiction,
} from './OilSpillReportTemplate'
import { useSubMenu } from '@common/hooks/useSubMenu'
import { templateTypes } from './reportTypes'
import {
generateReportHTML,
exportAsPDF,
exportAsHWP,
typeColors,
statusColors,
analysisCatColors,
inferAnalysisCategory,
type ViewState,
} from './reportUtils'
import TemplateFormEditor from './TemplateFormEditor'
import ReportGenerator from './ReportGenerator'
// ─── Main ReportsView ────────────────────────────────────
export function ReportsView() {
const { activeSubTab, setActiveSubTab } = useSubMenu('reports')
const [view, setView] = useState<ViewState>({ screen: 'list' })
const [reports, setReports] = useState<OilSpillReportData[]>([])
const [searchTerm, setSearchTerm] = useState('')
const [filterJurisdiction, setFilterJurisdiction] = useState<string>('전체')
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [previewReport, setPreviewReport] = useState<OilSpillReportData | null>(null)
const refreshList = () => setReports(loadReports())
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refreshList() }, [])
// SubMenuBar 탭과 내부 view 동기화
useEffect(() => {
if (activeSubTab === 'report-list') {
// eslint-disable-next-line react-hooks/set-state-in-effect
setView({ screen: 'list' })
refreshList()
} else if (activeSubTab === 'template') {
setView({ screen: 'templates' })
} else if (activeSubTab === 'generate') {
setView({ screen: 'generate' })
}
}, [activeSubTab])
const handleSave = () => { refreshList(); setView({ screen: 'list' }); setActiveSubTab('report-list') }
const handleDelete = (id: string) => {
if (!confirm('이 보고서를 삭제하시겠습니까?')) return
deleteReportFromStorage(id)
refreshList()
setSelectedIds(prev => { const n = new Set(prev); n.delete(id); return n })
}
const formatDate = (iso: string) => {
try {
const d = new Date(iso)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
} catch { return iso }
}
const filteredReports = reports.filter(r => {
if (filterJurisdiction !== '전체' && r.jurisdiction !== filterJurisdiction) return false
if (searchTerm && !r.title.toLowerCase().includes(searchTerm.toLowerCase())) return false
return true
})
const toggleSelect = (id: string) => setSelectedIds(prev => { const n = new Set(prev); if (n.has(id)) { n.delete(id) } else { n.add(id) } return n })
const toggleAll = () => selectedIds.size === filteredReports.length ? setSelectedIds(new Set()) : setSelectedIds(new Set(filteredReports.map(r => r.id)))
const jurisdictions: Jurisdiction[] = ['남해청', '서해청', '중부청', '동해청', '제주청']
return (
<div className="flex flex-col h-full w-full bg-bg-0">
{/* Content */}
<div className="flex-1 overflow-hidden flex flex-col">
{/* ──── 보고서 목록 ──── */}
{view.screen === 'list' && (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-5 py-3 border-b border-border bg-bg-1">
<div className="flex items-center gap-3">
<span className="text-[11px] text-text-3 font-korean"></span>
<select value={filterJurisdiction} onChange={e => setFilterJurisdiction(e.target.value)} className="px-3 py-1.5 text-[11px] bg-bg-2 border border-border rounded text-text-1 font-korean outline-none focus:border-primary-cyan">
<option value="전체"></option>
{jurisdictions.map(j => <option key={j} value={j}>{j}</option>)}
</select>
</div>
<div className="flex items-center gap-2">
<input type="text" placeholder="보고서명 검색..." value={searchTerm} onChange={e => setSearchTerm(e.target.value)} className="w-52 px-3 py-1.5 text-[11px] bg-bg-2 border border-border rounded text-text-1 placeholder-text-3 font-korean outline-none focus:border-primary-cyan" />
<button className="px-3 py-1.5 text-[11px] font-semibold rounded bg-bg-3 border border-border text-text-2 hover:bg-bg-hover hover:text-text-1 transition-all font-korean"></button>
<button onClick={() => { setView({ screen: 'templates' }); setActiveSubTab('template') }} className="px-3 py-1.5 text-[11px] font-semibold rounded bg-primary-cyan text-bg-0 hover:shadow-[0_0_8px_rgba(6,182,212,0.3)] transition-all font-korean">+ </button>
</div>
</div>
{filteredReports.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-text-3">
<div className="text-4xl mb-4">📄</div>
<p className="text-sm font-korean mb-2"> </p>
<button onClick={() => { setView({ screen: 'templates' }); setActiveSubTab('template') }} className="px-4 py-2 text-xs font-semibold rounded-md border border-primary-cyan text-primary-cyan hover:bg-[rgba(6,182,212,0.1)] transition-all font-korean mt-4">릿 </button>
</div>
) : (
<div className="flex-1 overflow-auto">
<table className="w-full table-fixed" style={{ borderCollapse: 'collapse' }}>
<colgroup>
<col style={{ width: '3%' }} />
<col style={{ width: '4%' }} />
<col />
<col style={{ width: '9%' }} />
<col style={{ width: '11%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '8%' }} />
<col style={{ width: '7%' }} />
<col style={{ width: '6%' }} />
<col style={{ width: '5%' }} />
<col style={{ width: '6%' }} />
<col style={{ width: '4%' }} />
</colgroup>
<thead>
<tr className="border-b border-border bg-bg-1 sticky top-0 z-10">
<th className="px-3 py-3 text-center"><input type="checkbox" checked={selectedIds.size === filteredReports.length && filteredReports.length > 0} onChange={toggleAll} className="accent-[#06b6d4] w-3.5 h-3.5" /></th>
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean"></th>
<th className="px-4 py-3 text-[11px] font-semibold text-text-3 text-left font-korean"></th>
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean"> </th>
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean"> </th>
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean"></th>
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean"></th>
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean"></th>
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean"></th>
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean"></th>
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean"></th>
<th className="px-3 py-3 text-[11px] font-semibold text-text-3 text-center font-korean"></th>
</tr>
</thead>
<tbody>
{filteredReports.map((report, idx) => (
<tr key={report.id} className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors">
<td className="px-3 py-3 text-center"><input type="checkbox" checked={selectedIds.has(report.id)} onChange={() => toggleSelect(report.id)} className="accent-[#06b6d4] w-3.5 h-3.5" /></td>
<td className="px-3 py-3 text-[12px] text-text-3 text-center font-mono">{idx + 1}</td>
<td className="px-4 py-3 truncate"><button onClick={() => setPreviewReport(report)} className="text-[12px] font-semibold text-primary-cyan hover:underline text-left font-korean truncate max-w-full block">{report.title || '제목 없음'}</button></td>
<td className="px-3 py-3 text-center"><span className="inline-block px-2.5 py-1 text-[10px] font-semibold rounded font-korean" style={{ background: typeColors[report.reportType]?.bg || 'rgba(138,150,168,0.15)', color: typeColors[report.reportType]?.text || '#8a96a8' }}>{report.reportType}</span></td>
{(() => {
const cat = inferAnalysisCategory(report)
const style = cat ? analysisCatColors[cat] : null
return (
<td className="px-3 py-3 text-center">
{style ? (
<div className="inline-flex items-center gap-1.5">
<div className="flex items-center justify-center w-5 h-5 rounded-full text-[11px]" style={{ background: style.bg, boxShadow: `0 0 6px ${style.text}25` }}>{style.icon}</div>
<span className="text-[9px] font-semibold font-korean" style={{ color: style.text }}>{cat}</span>
</div>
) : (
<span className="text-[10px] text-text-3 font-korean">-</span>
)}
</td>
)
})()}
<td className="px-3 py-3 text-[11px] text-text-2 text-center font-mono">{formatDate(report.createdAt)}</td>
<td className="px-3 py-3 text-[11px] text-text-2 text-center font-korean">{report.author || '-'}</td>
<td className="px-3 py-3 text-[11px] text-text-2 text-center font-korean">{report.jurisdiction}</td>
<td className="px-3 py-3 text-center"><span className="inline-block px-2.5 py-1 text-[10px] font-semibold rounded font-korean" style={{ background: statusColors[report.status]?.bg, color: statusColors[report.status]?.text }}>{report.status}</span></td>
<td className="px-3 py-3 text-center"><button onClick={() => setView({ screen: 'edit', data: { ...report } })} className="text-[11px] text-primary-cyan hover:underline font-korean"></button></td>
<td className="px-3 py-3 text-center"><button className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-semibold rounded bg-[rgba(239,68,68,0.12)] text-[#ef4444] border border-[rgba(239,68,68,0.25)] hover:bg-[rgba(239,68,68,0.2)] transition-all">PDF</button></td>
<td className="px-3 py-3 text-center"><button onClick={() => handleDelete(report.id)} className="w-7 h-7 rounded flex items-center justify-center text-status-red hover:bg-[rgba(239,68,68,0.1)] transition-all text-sm">🗑</button></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* ──── 템플릿 (좌: 선택 / 우: 폼) ──── */}
{view.screen === 'templates' && (
<TemplateFormEditor onSave={handleSave} onBack={() => { setView({ screen: 'list' }); setActiveSubTab('report-list'); refreshList() }} />
)}
{/* ──── 보고서 생성 ──── */}
{view.screen === 'generate' && (
<ReportGenerator onSave={handleSave} />
)}
{/* ──── 보기 ──── */}
{view.screen === 'view' && (
<div className="flex-1 overflow-auto px-4 py-4">
<OilSpillReportTemplate mode="view" initialData={view.data} onBack={() => { setView({ screen: 'list' }); setActiveSubTab('report-list'); refreshList() }} />
</div>
)}
{/* ──── 수정 ──── */}
{view.screen === 'edit' && (
<div className="flex-1 overflow-auto px-4 py-4">
<OilSpillReportTemplate mode="edit" initialData={view.data} onSave={handleSave} onBack={() => { setView({ screen: 'list' }); setActiveSubTab('report-list'); refreshList() }} />
</div>
)}
</div>
{/* ──── 보고서 미리보기 모달 ──── */}
{previewReport && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.65)', backdropFilter: 'blur(6px)' }}>
<div className="flex overflow-hidden" style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', borderRadius: '14px', width: 'min(96vw, 1320px)', height: 'min(94vh, 860px)', boxShadow: '0 28px 72px rgba(0,0,0,0.6)' }}>
{/* ── 왼쪽: 메타 + 다운로드 ── */}
<div className="flex flex-col shrink-0" style={{ width: '240px', borderRight: '1px solid var(--bd)', background: 'var(--bg1)' }}>
{/* 상단 아이콘·제목 */}
<div style={{ padding: '20px 18px 16px', borderBottom: '1px solid var(--bd)' }}>
<div className="text-center mb-2.5" style={{ fontSize: '28px' }}>
{({ '초기보고서': '📋', '지휘부 보고': '📊', '예측보고서': '🔬', '종합보고서': '📑', '유출유 보고': '🛢️' } as Record<string, string>)[previewReport.reportType] || '📄'}
</div>
<div className="text-center font-korean" style={{ fontSize: '13px', fontWeight: 700, color: 'var(--t1)', lineHeight: 1.4, wordBreak: 'keep-all' }}>
{previewReport.title || '제목 없음'}
</div>
<div className="text-center mt-2">
<span className="inline-block px-2.5 py-1 text-[10px] font-semibold rounded font-korean" style={{ background: typeColors[previewReport.reportType]?.bg || 'rgba(138,150,168,0.15)', color: typeColors[previewReport.reportType]?.text || '#8a96a8' }}>
{previewReport.reportType}
</span>
</div>
</div>
{/* 메타 정보 */}
<div className="flex flex-col gap-2.5 font-korean" style={{ padding: '14px 18px', fontSize: '11px', borderBottom: '1px solid var(--bd)' }}>
<div className="flex flex-col gap-0.5">
<span style={{ color: 'var(--t3)', fontSize: '9px', textTransform: 'uppercase', letterSpacing: '0.5px' }}></span>
<span style={{ color: 'var(--t1)', fontWeight: 600 }}>{previewReport.author || '—'}</span>
</div>
<div className="flex flex-col gap-0.5">
<span style={{ color: 'var(--t3)', fontSize: '9px', textTransform: 'uppercase', letterSpacing: '0.5px' }}></span>
<span style={{ color: 'var(--t1)', fontWeight: 600 }}>{previewReport.jurisdiction}</span>
</div>
<div className="flex flex-col gap-0.5">
<span style={{ color: 'var(--t3)', fontSize: '9px', textTransform: 'uppercase', letterSpacing: '0.5px' }}></span>
<span className="font-mono" style={{ color: 'var(--t1)', fontWeight: 600 }}>{formatDate(previewReport.createdAt)}</span>
</div>
<div className="flex flex-col gap-0.5">
<span style={{ color: 'var(--t3)', fontSize: '9px', textTransform: 'uppercase', letterSpacing: '0.5px' }}></span>
<b style={{ color: statusColors[previewReport.status]?.text || 'var(--t1)' }}>{previewReport.status}</b>
</div>
</div>
{/* 수정 버튼 */}
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--bd)' }}>
<button
onClick={() => { setPreviewReport(null); setView({ screen: 'edit', data: { ...previewReport } }) }}
className="w-full font-korean"
style={{ padding: '8px 0', borderRadius: '6px', border: '1px solid rgba(6,182,212,0.3)', background: 'rgba(6,182,212,0.08)', color: 'var(--cyan)', fontSize: '11px', fontWeight: 600, cursor: 'pointer' }}
>
</button>
</div>
{/* spacer */}
<div className="flex-1" />
{/* 하단 다운로드 버튼 */}
<div className="flex flex-col gap-2" style={{ padding: '14px 16px', borderTop: '1px solid var(--bd)' }}>
<div className="text-center font-korean mb-0.5" style={{ fontSize: '9px', color: 'var(--t3)' }}> </div>
<button
onClick={() => {
const tpl = templateTypes.find(t => t.id === previewReport.reportType)
if (tpl) {
const getVal = (key: string) => {
if (key === 'author') return previewReport.author
if (key.startsWith('incident.')) {
const f = key.split('.')[1]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (previewReport.incident as any)[f] || ''
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (previewReport as any)[key] || ''
}
const html = generateReportHTML(tpl.label, { writeTime: previewReport.incident.writeTime, author: previewReport.author, jurisdiction: previewReport.jurisdiction }, tpl.sections, getVal)
exportAsPDF(html, previewReport.title || tpl.label)
}
}}
className="w-full flex items-center justify-center gap-1.5 font-korean"
style={{ padding: '11px 0', borderRadius: '6px', border: '1px solid rgba(239,68,68,0.4)', background: 'rgba(239,68,68,0.1)', color: 'var(--red)', fontSize: '12px', fontWeight: 700, cursor: 'pointer' }}
>
<span>📄</span> PDF
</button>
<button
onClick={() => {
const tpl = templateTypes.find(t => t.id === previewReport.reportType)
if (tpl) {
const getVal = (key: string) => {
if (key === 'author') return previewReport.author
if (key.startsWith('incident.')) {
const f = key.split('.')[1]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (previewReport.incident as any)[f] || ''
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (previewReport as any)[key] || ''
}
const html = generateReportHTML(tpl.label, { writeTime: previewReport.incident.writeTime, author: previewReport.author, jurisdiction: previewReport.jurisdiction }, tpl.sections, getVal)
exportAsHWP(html, previewReport.title || tpl.label)
}
}}
className="w-full flex items-center justify-center gap-1.5 font-korean"
style={{ padding: '11px 0', borderRadius: '6px', border: '1px solid rgba(59,130,246,0.4)', background: 'rgba(59,130,246,0.1)', color: 'var(--blue)', fontSize: '12px', fontWeight: 700, cursor: 'pointer' }}
>
<span>📝</span> HWP
</button>
</div>
</div>
{/* ── 오른쪽: 본문 뷰어 ── */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between shrink-0" style={{ padding: '14px 20px', borderBottom: '1px solid var(--bd)' }}>
<div className="flex items-center gap-2">
<span className="font-korean" style={{ fontSize: '9px', padding: '3px 8px', borderRadius: '4px', background: 'rgba(6,182,212,0.1)', color: 'var(--cyan)', fontWeight: 600 }}></span>
<span className="font-korean" style={{ fontSize: '12px', color: 'var(--t3)' }}> </span>
</div>
<span
onClick={() => setPreviewReport(null)}
style={{ fontSize: '18px', cursor: 'pointer', color: 'var(--t3)', lineHeight: 1 }}
className="hover:text-text-1 transition-colors"
>
</span>
</div>
{/* 본문 스크롤 영역 */}
<div className="flex-1 overflow-y-auto" style={{ padding: '24px', scrollbarWidth: 'thin' }}>
<div className="flex flex-col gap-4">
{/* 1. 사고개요 */}
<div>
<div className="font-korean" style={{ fontSize: '12px', fontWeight: 700, color: 'var(--cyan)', borderBottom: '1px solid rgba(6,182,212,0.15)', paddingBottom: '4px' }}>
1.
</div>
<div className="font-korean" style={{ fontSize: '12px', color: 'var(--t1)', lineHeight: 1.7, whiteSpace: 'pre-wrap', marginTop: '8px' }}>
{[
previewReport.incident.name && `사고명: ${previewReport.incident.name}`,
previewReport.incident.occurTime && `발생일시: ${previewReport.incident.occurTime}`,
previewReport.incident.location && `발생위치: ${previewReport.incident.location}`,
previewReport.incident.shipName && `사고선박: ${previewReport.incident.shipName}`,
previewReport.incident.accidentType && `사고유형: ${previewReport.incident.accidentType}`,
].filter(Boolean).join('\n') || '—'}
</div>
</div>
{/* 2. 유출현황 */}
<div>
<div className="font-korean" style={{ fontSize: '12px', fontWeight: 700, color: 'var(--cyan)', borderBottom: '1px solid rgba(6,182,212,0.15)', paddingBottom: '4px' }}>
2.
</div>
<div className="font-korean" style={{ fontSize: '12px', color: 'var(--t1)', lineHeight: 1.7, whiteSpace: 'pre-wrap', marginTop: '8px' }}>
{[
previewReport.incident.pollutant && `유출유종: ${previewReport.incident.pollutant}`,
previewReport.incident.spillAmount && `유출량: ${previewReport.incident.spillAmount}`,
previewReport.spread?.length > 0 && `확산예측: ${previewReport.spread.length}개 시점 데이터`,
].filter(Boolean).join('\n') || '—'}
</div>
</div>
{/* 3. 초동조치 / 대응현황 */}
<div>
<div className="font-korean" style={{ fontSize: '12px', fontWeight: 700, color: 'var(--cyan)', borderBottom: '1px solid rgba(6,182,212,0.15)', paddingBottom: '4px' }}>
3. /
</div>
<div className="font-korean" style={{ fontSize: '12px', color: 'var(--t1)', lineHeight: 1.7, whiteSpace: 'pre-wrap', marginTop: '8px' }}>
{previewReport.analysis || '—'}
</div>
</div>
{/* 4. 향후 계획 */}
<div>
<div className="font-korean" style={{ fontSize: '12px', fontWeight: 700, color: 'var(--cyan)', borderBottom: '1px solid rgba(6,182,212,0.15)', paddingBottom: '4px' }}>
4.
</div>
<div className="font-korean" style={{ fontSize: '12px', color: 'var(--t1)', lineHeight: 1.7, whiteSpace: 'pre-wrap', marginTop: '8px' }}>
{previewReport.etcEquipment || '—'}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
)
}