wing-ops/frontend/src/tabs/reports/components/ReportsView.tsx
Nan Kyung Lee 374a487878 feat(reports): HWP 저장을 실제 HWPX 포맷으로 변경
기존 HTML Blob → .doc 저장 방식을 OWPML 표준 HWPX(ZIP+XML) 포맷으로 교체.
JSZip으로 HWPX 파일을 순수 브라우저에서 생성하여 한글에서 직접 열 수 있도록 구현.

- hwpxExport.ts 신규: HWPX ZIP 패키징 (mimetype, header.xml, section0.xml 등)
- reportUtils.ts: exportAsHWP → dynamic import로 HWPX 위임
- ReportsView.tsx, TemplateFormEditor.tsx: 구조화 데이터 직접 전달

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:07:06 +09:00

403 lines
24 KiB
TypeScript
Executable File

import { useState, useEffect, useCallback } from 'react'
import {
OilSpillReportTemplate,
type OilSpillReportData,
type Jurisdiction,
} from './OilSpillReportTemplate'
import { loadReportsFromApi, loadReportDetail, deleteReportApi } from '../services/reportsApi'
import { useSubMenu } from '@common/hooks/useSubMenu'
import { templateTypes } from './reportTypes'
import {
generateReportHTML,
exportAsPDF,
exportAsHWP,
typeColors,
statusColors,
analysisCatColors,
inferAnalysisCategory,
type ViewState,
} from './reportUtils';
import type { TemplateType } from './reportTypes';
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 = useCallback(async () => {
try {
const list = await loadReportsFromApi()
setReports(list)
} catch (err) {
console.error('[reports] 목록 조회 오류:', err)
}
}, [])
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refreshList() }, [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, refreshList])
const handleSave = () => { refreshList(); setView({ screen: 'list' }); setActiveSubTab('report-list') }
const handleDelete = async (id: string) => {
if (!confirm('이 보고서를 삭제하시겠습니까?')) return
try {
await deleteReportApi(parseInt(id, 10))
refreshList()
setSelectedIds(prev => { const n = new Set(prev); n.delete(id); return n })
} catch (err) {
console.error('[reports] 삭제 오류:', err)
alert('보고서 삭제 중 오류가 발생했습니다.')
}
}
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 border-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={async () => { try { const detail = await loadReportDetail(parseInt(report.id, 10)); setPreviewReport(detail) } catch { 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={async () => { try { const detail = await loadReportDetail(parseInt(report.id, 10)); setView({ screen: 'edit', data: detail }) } catch { 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 bg-bg-2 border border-border" style={{ 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 w-60 border-r border-border bg-bg-1">
{/* 상단 아이콘·제목 */}
<div className="px-[18px] pt-5 pb-4 border-b border-border">
<div className="text-center mb-2.5 text-[28px]">
{({ '초기보고서': '📋', '지휘부 보고': '📊', '예측보고서': '🔬', '종합보고서': '📑', '유출유 보고': '🛢️' } as Record<string, string>)[previewReport.reportType] || '📄'}
</div>
<div className="text-center font-korean text-[13px] font-bold leading-snug" style={{ 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 text-[11px] px-[18px] py-3.5 border-b border-border">
<div className="flex flex-col gap-0.5">
<span className="text-text-3 text-[9px] uppercase tracking-wide"></span>
<span className="font-semibold">{previewReport.author || '—'}</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-text-3 text-[9px] uppercase tracking-wide"></span>
<span className="font-semibold">{previewReport.jurisdiction}</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-text-3 text-[9px] uppercase tracking-wide"></span>
<span className="font-mono font-semibold">{formatDate(previewReport.createdAt)}</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-text-3 text-[9px] uppercase tracking-wide"></span>
<b style={{ color: statusColors[previewReport.status]?.text || 'var(--t1)' }}>{previewReport.status}</b>
</div>
</div>
{/* 수정 버튼 */}
<div className="px-[18px] py-3 border-b border-border">
<button
onClick={() => { setPreviewReport(null); setView({ screen: 'edit', data: { ...previewReport } }) }}
className="w-full font-korean text-[11px] font-semibold cursor-pointer rounded-md border border-[rgba(6,182,212,0.3)] bg-[rgba(6,182,212,0.08)] text-primary-cyan py-2"
>
</button>
</div>
{/* spacer */}
<div className="flex-1" />
{/* 하단 다운로드 버튼 */}
<div className="flex flex-col gap-2 px-4 py-3.5 border-t border-border">
<div className="text-center font-korean mb-0.5 text-[9px] text-text-3"> </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 text-[12px] font-bold cursor-pointer rounded-md py-[11px]"
style={{ border: '1px solid rgba(239,68,68,0.4)', background: 'rgba(239,68,68,0.1)', color: 'var(--red)' }}
>
<span>📄</span> PDF
</button>
<button
onClick={() => {
const tpl = templateTypes.find(t => t.id === previewReport.reportType) as TemplateType | undefined
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 meta = { writeTime: previewReport.incident.writeTime, author: previewReport.author, jurisdiction: previewReport.jurisdiction }
const filename = previewReport.title || tpl.label
exportAsHWP(tpl.label, meta, tpl.sections, getVal, filename)
}
}}
className="w-full flex items-center justify-center gap-1.5 font-korean text-[12px] font-bold cursor-pointer rounded-md py-[11px]"
style={{ border: '1px solid rgba(59,130,246,0.4)', background: 'rgba(59,130,246,0.1)', color: 'var(--blue)' }}
>
<span>📝</span> HWPX
</button>
</div>
</div>
{/* ── 오른쪽: 본문 뷰어 ── */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* 헤더 */}
<div className="flex items-center justify-between shrink-0 px-5 py-3.5 border-b border-border">
<div className="flex items-center gap-2">
<span className="font-korean text-[9px] px-2 py-[3px] rounded bg-[rgba(6,182,212,0.1)] text-primary-cyan font-semibold"></span>
<span className="font-korean text-[12px] text-text-3"> </span>
</div>
<span
onClick={() => setPreviewReport(null)}
className="text-[18px] cursor-pointer text-text-3 leading-none hover:text-text-1 transition-colors"
>
</span>
</div>
{/* 본문 스크롤 영역 */}
<div className="flex-1 overflow-y-auto p-6" style={{ scrollbarWidth: 'thin' }}>
<div className="flex flex-col gap-4">
{/* 1. 사고개요 */}
<div>
<div className="font-korean text-[12px] font-bold text-primary-cyan border-b pb-1 mb-2" style={{ borderColor: 'rgba(6,182,212,0.15)' }}>
1.
</div>
<div className="font-korean text-[12px] leading-[1.7] whitespace-pre-wrap mt-2">
{[
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 text-[12px] font-bold text-primary-cyan border-b pb-1 mb-2" style={{ borderColor: 'rgba(6,182,212,0.15)' }}>
2.
</div>
<div className="font-korean text-[12px] leading-[1.7] whitespace-pre-wrap mt-2">
{[
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 text-[12px] font-bold text-primary-cyan border-b pb-1 mb-2" style={{ borderColor: 'rgba(6,182,212,0.15)' }}>
3. /
</div>
<div className="font-korean text-[12px] leading-[1.7] whitespace-pre-wrap mt-2">
{previewReport.analysis || '—'}
</div>
</div>
{/* 4. 향후 계획 */}
<div>
<div className="font-korean text-[12px] font-bold text-primary-cyan border-b pb-1 mb-2" style={{ borderColor: 'rgba(6,182,212,0.15)' }}>
4.
</div>
<div className="font-korean text-[12px] leading-[1.7] whitespace-pre-wrap mt-2">
{previewReport.etcEquipment || '—'}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
)
}