wing-ops/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx
htlee 844eebb7cc feat(reports): 보고서 탭 localStorage → DB/API 전환
- DB 마이그레이션 007_reports.sql: 7개 테이블 (REPORT_TMPL, REPORT_TMPL_SECT,
  REPORT_ANALYSIS_CTGR, REPORT_CTGR_SECT, REPORT, REPORT_SECT_DATA 등)
  + 초기 데이터 (5개 템플릿, 3개 카테고리, 섹션 정의)
- 백엔드 reportsService.ts: 템플릿/카테고리 조회, 보고서 CRUD, 섹션 UPSERT
- 백엔드 reportsRouter.ts: GET/POST only 패턴 (보안취약점 가이드 준수)
  - GET /api/reports, GET /api/reports/:sn (조회)
  - POST /api/reports (생성), POST /:sn/update (수정), POST /:sn/delete (삭제)
  - POST /:sn/sections/:sectCd (개별 섹션 수정)
- 프론트 reportsApi.ts: API 호출 + OilSpillReportData ↔ API 변환 + 캐싱
- 프론트 4개 컴포넌트 localStorage → API 전환:
  ReportsView, OilSpillReportTemplate, TemplateFormEditor, ReportGenerator

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:59:11 +09:00

608 lines
42 KiB
TypeScript
Executable File
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useCallback } from 'react'
import { saveReport } from '../services/reportsApi'
// ─── Data Types ─────────────────────────────────────────────
export type ReportType = '초기보고서' | '지휘부 보고' | '예측보고서' | '종합보고서' | '유출유 보고'
export type ReportStatus = '완료' | '수행중' | '테스트'
export type Jurisdiction = '남해청' | '서해청' | '중부청' | '동해청' | '제주청'
export type AnalysisCategory = '유출유 확산예측' | 'HNS 대기확산' | '긴급구난' | ''
export interface OilSpillReportData {
id: string
title: string
createdAt: string
updatedAt: string
author: string
reportType: ReportType
analysisCategory: AnalysisCategory
jurisdiction: Jurisdiction
status: ReportStatus
incident: {
name: string; writeTime: string; shipName: string; agent: string
location: string; lat: string; lon: string; occurTime: string
accidentType: string; pollutant: string; spillAmount: string
depth: string; seabed: string
}
tide: { date: string; tideType: string; lowTide1: string; highTide1: string; lowTide2: string; highTide2: string }[]
weather: { time: string; sunrise: string; sunset: string; windDir: string; windSpeed: string; currentDir: string; currentSpeed: string; waveHeight: string }[]
spread: { elapsed: string; weathered: string; seaRemain: string; coastAttach: string; area: string }[]
analysis: string
aquaculture: { type: string; area: string; distance: string }[]
beaches: { name: string; distance: string }[]
markets: { name: string; distance: string }[]
esi: { code: string; type: string; length: string }[]
species: { category: string; species: string }[]
habitat: { type: string; area: string }[]
sensitivity: { level: string; area: string; color: string }[]
vessels: { name: string; org: string; dist: string; speed: string; ton: string; collectorType: string; collectorCap: string; boomType: string; boomLength: string }[]
etcEquipment: string
recovery: { shipName: string; period: string }[]
result: { spillTotal: string; weatheredTotal: string; recoveredTotal: string; seaRemainTotal: string; coastAttachTotal: string }
}
// eslint-disable-next-line react-refresh/only-export-components
export function createEmptyReport(): OilSpillReportData {
const now = new Date()
const ts = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
return {
id: `RPT-${Date.now()}`,
title: '',
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
author: '',
reportType: '예측보고서',
analysisCategory: '',
jurisdiction: '남해청',
status: '수행중',
incident: { name: '', writeTime: ts, shipName: '', agent: '', location: '', lat: '', lon: '', occurTime: '', accidentType: '', pollutant: '', spillAmount: '', depth: '', seabed: '' },
tide: [{ date: '', tideType: '', lowTide1: '', highTide1: '', lowTide2: '', highTide2: '' }],
weather: [{ time: '', sunrise: '', sunset: '', windDir: '', windSpeed: '', currentDir: '', currentSpeed: '', waveHeight: '' }],
spread: [{ elapsed: '3시간', weathered: '', seaRemain: '', coastAttach: '', area: '' }, { elapsed: '6시간', weathered: '', seaRemain: '', coastAttach: '', area: '' }],
analysis: '',
aquaculture: [{ type: '', area: '', distance: '' }],
beaches: [{ name: '', distance: '' }],
markets: [{ name: '', distance: '' }],
esi: [
{ code: 'ESI 1', type: '수직암반', length: '' }, { code: 'ESI 2', type: '수평암반', length: '' },
{ code: 'ESI 3', type: '세립질 모래', length: '' }, { code: 'ESI 4', type: '조립질 모래', length: '' },
{ code: 'ESI 5', type: '모래·자갈', length: '' }, { code: 'ESI 6A', type: '자갈', length: '' },
{ code: 'ESI 6B', type: '투과성 사석', length: '' }, { code: 'ESI 7', type: '반폐쇄성 해안', length: '' },
{ code: 'ESI 8A', type: '갯벌', length: '' }, { code: 'ESI 8B', type: '염습지', length: '' },
],
species: [{ category: '양서파충류', species: '' }, { category: '조류', species: '' }, { category: '포유류', species: '' }],
habitat: [{ type: '갯벌', area: '' }],
sensitivity: [
{ level: '매우 높음', area: '', color: '#ef4444' }, { level: '높음', area: '', color: '#f97316' },
{ level: '보통', area: '', color: '#eab308' }, { level: '낮음', area: '', color: '#22c55e' },
],
vessels: [{ name: '', org: '', dist: '', speed: '', ton: '', collectorType: '', collectorCap: '', boomType: '', boomLength: '' }],
etcEquipment: '',
recovery: [{ shipName: '', period: '' }],
result: { spillTotal: '', weatheredTotal: '', recoveredTotal: '', seaRemainTotal: '', coastAttachTotal: '' },
}
}
// eslint-disable-next-line react-refresh/only-export-components
export function createSampleReport(): OilSpillReportData {
return {
id: `RPT-${Date.now()}`,
title: '여수 수변공원 Diesel 유출사고 대응지원',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
author: '남해청_방제과',
reportType: '초기보고서',
analysisCategory: '유출유 확산예측',
jurisdiction: '남해청',
status: '완료',
incident: { name: '여수 수변공원 유출사고', writeTime: '2025.07.16 13:48', shipName: '수변공원', agent: '', location: '여수 돌산 남방', lat: '34° 43 37.6″', lon: '127° 43 32.6″', occurTime: '2025. 07. 08. 17:55', accidentType: '시설 파손', pollutant: 'Diesel', spillAmount: '1㎘', depth: '12m', seabed: '모래' },
tide: [
{ date: '2025-07-08 20:55', tideType: '배꼽사리', lowTide1: '01:53 (146)', highTide1: '13:19 (104)', lowTide2: '07:17 (253)', highTide2: '20:24 (310)' },
{ date: '2025-07-08 23:55', tideType: '배꼽사리', lowTide1: '01:53 (146)', highTide1: '13:19 (104)', lowTide2: '07:17 (253)', highTide2: '20:24 (310)' },
],
weather: [
{ time: '2025-07-08 20:55', sunrise: '05:22', sunset: '19:46', windDir: '북동', windSpeed: '0.0', currentDir: '북', currentSpeed: '0.0 / 0.0', waveHeight: '0.0' },
{ time: '2025-07-08 23:55', sunrise: '05:22', sunset: '19:46', windDir: '북동', windSpeed: '0.0', currentDir: '북', currentSpeed: '0.0 / 0.0', waveHeight: '0.0' },
],
spread: [
{ elapsed: '3시간', weathered: '0.48', seaRemain: '0.52', coastAttach: '0.12', area: '1.75' },
{ elapsed: '6시간', weathered: '0.64', seaRemain: '0.36', coastAttach: '0.24', area: '3.49' },
],
analysis: '',
aquaculture: [
{ type: '', area: '3.19', distance: '0.56' }, { type: '', area: '20.15', distance: '0.83' },
{ type: '어류', area: '3.84', distance: '1.14' }, { type: '', area: '2.89', distance: '1.40' }, { type: '어류', area: '0.90', distance: '1.42' },
],
beaches: [{ name: '검은모래해수욕장', distance: '5.86' }, { name: '무슬목해수욕장', distance: '6.69' }, { name: '모사금해수욕장', distance: '8.18' }],
markets: [{ name: '돌산대교 회타운', distance: '1.09' }],
esi: [
{ code: 'ESI 1', type: '수직암반', length: '29.05 km' }, { code: 'ESI 2', type: '수평암반', length: '13.76 km' },
{ code: 'ESI 3', type: '세립질 모래', length: '1.03 km' }, { code: 'ESI 4', type: '조립질 모래', length: '0.47 km' },
{ code: 'ESI 5', type: '모래·자갈', length: '21.10 km' }, { code: 'ESI 6A', type: '자갈', length: '15.29 km' },
{ code: 'ESI 6B', type: '투과성 사석', length: '26.60 km' }, { code: 'ESI 7', type: '반폐쇄성 해안', length: '9.39 km' },
{ code: 'ESI 8A', type: '갯벌', length: '0.00 km' }, { code: 'ESI 8B', type: '염습지', length: '3.43 km' },
],
species: [{ category: '양서파충류', species: '' }, { category: '조류', species: '뿔논병아리(겨울철새), 꾀꼬리(여름철새)' }, { category: '포유류', species: '수달' }],
habitat: [{ type: '갯벌', area: '4.32 km²' }],
sensitivity: [
{ level: '매우 높음', area: '18.54', color: '#ef4444' }, { level: '높음', area: '31.32', color: '#f97316' },
{ level: '보통', area: '59.22', color: '#eab308' }, { level: '낮음', area: '32.13', color: '#22c55e' },
],
vessels: [
{ name: '방제15호', org: '서해청', dist: '0.41', speed: '7.0', ton: '450.0', collectorType: '흡착', collectorCap: '140.0', boomType: 'B', boomLength: '60' },
{ name: '방제26호', org: '서해청', dist: '0.41', speed: '13.0', ton: '360.0', collectorType: '흡착', collectorCap: '100.0', boomType: 'B', boomLength: '300' },
{ name: '방제1001호', org: '서해청', dist: '0.74', speed: '10.0', ton: '564.0', collectorType: '흡착', collectorCap: '30.0', boomType: 'B', boomLength: '20' },
{ name: '전남939호', org: '서해청', dist: '0.74', speed: '10.0', ton: '149.0', collectorType: '흡착', collectorCap: '30.0', boomType: 'B', boomLength: '300' },
{ name: '동양방제호', org: '서해청', dist: '1.78', speed: '10.0', ton: '179.0', collectorType: '위어,흡착', collectorCap: '66.0', boomType: 'C', boomLength: '300' },
{ name: '화학방제2함', org: '서해청', dist: '3.00', speed: '13.0', ton: '501.0', collectorType: '위어,브러쉬', collectorCap: '150.0', boomType: '특수', boomLength: '60' },
{ name: '우진방제호', org: '서해청', dist: '13.22', speed: '9.0', ton: '79.0', collectorType: '위어', collectorCap: '20.0', boomType: 'C', boomLength: '300' },
],
etcEquipment: '',
recovery: [{ shipName: '방제15호', period: '0' }],
result: { spillTotal: '1', weatheredTotal: '0.64', recoveredTotal: '0', seaRemainTotal: '0.36', coastAttachTotal: '0.24' },
}
}
// ─── localStorage helpers 제거됨 — reportsApi.ts 사용 ────────
// ─── Styles ─────────────────────────────────────────────────
const S = {
page: { background: 'var(--bg1)', color: 'var(--t1)', padding: '32px 40px', marginBottom: '24px', borderRadius: '6px', border: '1px solid var(--bd)', fontFamily: "'Pretendard', 'Noto Sans KR', sans-serif", fontSize: '12px', lineHeight: '1.6', position: 'relative' as const, width: '100%', boxSizing: 'border-box' as const },
sectionTitle: { background: 'rgba(6,182,212,0.12)', color: 'var(--cyan)', padding: '8px 16px', fontSize: '13px', fontWeight: 700, marginBottom: '12px', borderRadius: '4px', border: '1px solid rgba(6,182,212,0.2)' },
subHeader: { fontSize: '14px', fontWeight: 700, color: 'var(--cyan)', marginBottom: '12px', borderBottom: '2px solid var(--bd)', paddingBottom: '6px' },
table: { width: '100%', tableLayout: 'fixed' as const, borderCollapse: 'collapse' as const, fontSize: '11px', marginBottom: '16px' },
th: { background: 'var(--bg3)', border: '1px solid var(--bd)', padding: '6px 10px', fontWeight: 600, color: 'var(--t2)', textAlign: 'center' as const, fontSize: '10px' },
td: { border: '1px solid var(--bd)', padding: '5px 10px', textAlign: 'center' as const, fontSize: '11px', color: 'var(--t2)' },
tdLeft: { border: '1px solid var(--bd)', padding: '5px 10px', textAlign: 'left' as const, fontSize: '11px', color: 'var(--t2)' },
thLabel: { background: 'var(--bg3)', border: '1px solid var(--bd)', padding: '6px 10px', fontWeight: 600, color: 'var(--t2)', textAlign: 'left' as const, fontSize: '11px', width: '120px' },
mapPlaceholder: { width: '100%', height: '240px', background: 'var(--bg0)', border: '2px dashed var(--bd)', borderRadius: '4px', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--t3)', fontSize: '13px', fontWeight: 600, marginBottom: '16px' },
}
// ─── Editable cell ──────────────────────────────────────────
const inputStyle: React.CSSProperties = {
width: '100%', background: 'var(--bg0)', border: '1px solid var(--bdL)', borderRadius: '3px',
padding: '4px 8px', fontSize: '11px', color: 'var(--t1)', outline: 'none', textAlign: 'center',
}
function ECell({ value, editing, onChange, align, placeholder }: {
value: string; editing: boolean; onChange?: (v: string) => void; align?: 'left' | 'center'; placeholder?: string
}) {
const style = align === 'left' ? S.tdLeft : S.td
if (!editing) {
return <td style={{ ...style, color: value ? 'var(--t1)' : 'var(--t3)', fontStyle: value ? 'normal' : 'italic' }}>{value || placeholder || '-'}</td>
}
return (
<td style={style}>
<input
style={{ ...inputStyle, textAlign: align || 'center' }}
value={value}
onChange={e => onChange?.(e.target.value)}
placeholder={placeholder}
/>
</td>
)
}
function AddRowBtn({ onClick, label }: { onClick: () => void; label?: string }) {
return (
<button onClick={onClick} style={{
padding: '4px 12px', fontSize: '10px', fontWeight: 600, color: '#06b6d4', background: 'rgba(6,182,212,0.08)',
border: '1px dashed #06b6d4', borderRadius: '3px', cursor: 'pointer', marginBottom: '12px',
}}>
+ {label || '행 추가'}
</button>
)
}
// ─── Page components ────────────────────────────────────────
function Page1({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) {
const inc = data.incident
const set = (k: keyof typeof inc, v: string) => onChange({ ...data, incident: { ...inc, [k]: v } })
return (
<div style={S.page}>
<div style={{ position: 'absolute', top: 10, right: 16, fontSize: '9px', color: 'var(--t3)', fontWeight: 600 }}></div>
<div style={{ background: 'rgba(6,182,212,0.1)', color: 'var(--cyan)', padding: '12px 20px', fontSize: '18px', fontWeight: 700, marginBottom: '20px', borderRadius: '4px', letterSpacing: '1px', textAlign: 'center', border: '1px solid rgba(6,182,212,0.2)' }}>
</div>
<div style={S.sectionTitle}>1. </div>
<table style={S.table}>
<colgroup><col style={{ width: '14%' }} /><col style={{ width: '36%' }} /><col style={{ width: '14%' }} /><col style={{ width: '36%' }} /></colgroup>
<tbody>
<tr><td style={S.thLabel}></td><ECell value={inc.name} editing={editing} onChange={v => set('name', v)} placeholder="사고명 입력" /><td style={S.thLabel}></td><ECell value={inc.writeTime} editing={editing} onChange={v => set('writeTime', v)} /></tr>
<tr><td style={S.thLabel}>()</td><ECell value={inc.shipName} editing={editing} onChange={v => set('shipName', v)} placeholder="선명" /><td style={S.thLabel}></td><ECell value={inc.agent} editing={editing} onChange={v => set('agent', v)} placeholder="제원" /></tr>
<tr><td style={S.thLabel}></td><ECell value={inc.location} editing={editing} onChange={v => set('location', v)} placeholder="위치" /><td style={S.thLabel}></td><ECell value={`위도: ${inc.lat} 경도: ${inc.lon}`} editing={false} /></tr>
{editing && <tr><td style={S.thLabel}></td><ECell value={inc.lat} editing onChange={v => set('lat', v)} placeholder="34° 43 37.6″" /><td style={S.thLabel}></td><ECell value={inc.lon} editing onChange={v => set('lon', v)} placeholder="127° 43 32.6″" /></tr>}
<tr><td style={S.thLabel}></td><ECell value={inc.occurTime} editing={editing} onChange={v => set('occurTime', v)} /><td style={S.thLabel}></td><ECell value={inc.accidentType} editing={editing} onChange={v => set('accidentType', v)} placeholder="사고유형" /></tr>
<tr><td style={S.thLabel}></td><ECell value={inc.pollutant} editing={editing} onChange={v => set('pollutant', v)} /><td style={S.thLabel}> </td><ECell value={inc.spillAmount} editing={editing} onChange={v => set('spillAmount', v)} /></tr>
<tr><td style={S.thLabel}></td><ECell value={inc.depth} editing={editing} onChange={v => set('depth', v)} placeholder="수심" /><td style={S.thLabel}></td><ECell value={inc.seabed} editing={editing} onChange={v => set('seabed', v)} placeholder="저질" /></tr>
</tbody></table>
</div>
)
}
function Page2({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) {
const setTide = (i: number, k: string, v: string) => { const t = [...data.tide]; t[i] = { ...t[i], [k]: v }; onChange({ ...data, tide: t }) }
const setWeather = (i: number, k: string, v: string) => { const w = [...data.weather]; w[i] = { ...w[i], [k]: v }; onChange({ ...data, weather: w }) }
const setSpread = (i: number, k: string, v: string) => { const s = [...data.spread]; s[i] = { ...s[i], [k]: v }; onChange({ ...data, spread: s }) }
return (
<div style={S.page}>
<div style={{ position: 'absolute', top: 10, right: 16, fontSize: '9px', color: 'var(--t3)', fontWeight: 600 }}></div>
<div style={S.sectionTitle}>2. </div>
<div style={S.subHeader}> </div>
<table style={S.table}>
<thead><tr><th style={S.th}></th><th style={S.th}></th><th style={S.th} colSpan={2}></th><th style={S.th} colSpan={2}></th></tr></thead>
<tbody>
{data.tide.map((t, i) => (
<tr key={i}>
<ECell value={t.date} editing={editing} onChange={v => setTide(i, 'date', v)} />
<ECell value={t.tideType} editing={editing} onChange={v => setTide(i, 'tideType', v)} />
<ECell value={t.lowTide1} editing={editing} onChange={v => setTide(i, 'lowTide1', v)} />
<ECell value={t.lowTide2} editing={editing} onChange={v => setTide(i, 'lowTide2', v)} />
<ECell value={t.highTide1} editing={editing} onChange={v => setTide(i, 'highTide1', v)} />
<ECell value={t.highTide2} editing={editing} onChange={v => setTide(i, 'highTide2', v)} />
</tr>
))}
</tbody>
</table>
{editing && <AddRowBtn onClick={() => onChange({ ...data, tide: [...data.tide, { date: '', tideType: '', lowTide1: '', highTide1: '', lowTide2: '', highTide2: '' }] })} />}
<div style={S.subHeader}> </div>
<table style={S.table}>
<thead><tr><th style={S.th}> </th><th style={S.th}></th><th style={S.th}></th><th style={S.th}></th><th style={S.th}>(m/s)</th><th style={S.th}></th><th style={S.th}>(knot/m/s)</th><th style={S.th}>(m)</th></tr></thead>
<tbody>
{data.weather.map((w, i) => (
<tr key={i}>
<ECell value={w.time} editing={editing} onChange={v => setWeather(i, 'time', v)} />
<ECell value={w.sunrise} editing={editing} onChange={v => setWeather(i, 'sunrise', v)} />
<ECell value={w.sunset} editing={editing} onChange={v => setWeather(i, 'sunset', v)} />
<ECell value={w.windDir} editing={editing} onChange={v => setWeather(i, 'windDir', v)} />
<ECell value={w.windSpeed} editing={editing} onChange={v => setWeather(i, 'windSpeed', v)} />
<ECell value={w.currentDir} editing={editing} onChange={v => setWeather(i, 'currentDir', v)} />
<ECell value={w.currentSpeed} editing={editing} onChange={v => setWeather(i, 'currentSpeed', v)} />
<ECell value={w.waveHeight} editing={editing} onChange={v => setWeather(i, 'waveHeight', v)} />
</tr>
))}
</tbody>
</table>
{editing && <AddRowBtn onClick={() => onChange({ ...data, weather: [...data.weather, { time: '', sunrise: '', sunset: '', windDir: '', windSpeed: '', currentDir: '', currentSpeed: '', waveHeight: '' }] })} />}
<div style={S.sectionTitle}>3. </div>
<div style={{ display: 'flex', gap: '16px', marginBottom: '16px' }}>
<div style={S.mapPlaceholder}> 3 </div>
<div style={S.mapPlaceholder}> 6 </div>
</div>
<div style={S.subHeader}> </div>
<table style={S.table}>
<thead><tr><th style={S.th}></th><th style={S.th}>(kl)</th><th style={S.th}>(kl)</th><th style={S.th}>(kl)</th><th style={S.th}>(km²)</th></tr></thead>
<tbody>
{data.spread.map((s, i) => (
<tr key={i}>
<ECell value={s.elapsed} editing={editing} onChange={v => setSpread(i, 'elapsed', v)} />
<ECell value={s.weathered} editing={editing} onChange={v => setSpread(i, 'weathered', v)} />
<ECell value={s.seaRemain} editing={editing} onChange={v => setSpread(i, 'seaRemain', v)} />
<ECell value={s.coastAttach} editing={editing} onChange={v => setSpread(i, 'coastAttach', v)} />
<ECell value={s.area} editing={editing} onChange={v => setSpread(i, 'area', v)} />
</tr>
))}
</tbody>
</table>
{editing && <AddRowBtn onClick={() => onChange({ ...data, spread: [...data.spread, { elapsed: '', weathered: '', seaRemain: '', coastAttach: '', area: '' }] })} />}
</div>
)
}
function Page3({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) {
return (
<div style={S.page}>
<div style={{ position: 'absolute', top: 10, right: 16, fontSize: '9px', color: 'var(--t3)', fontWeight: 600 }}></div>
<div style={S.sectionTitle}></div>
{editing ? (
<textarea
value={data.analysis}
onChange={e => onChange({ ...data, analysis: e.target.value })}
placeholder="분석 내용을 작성하세요..."
style={{ width: '100%', minHeight: '300px', background: 'var(--bg0)', border: '1px solid var(--bdL)', borderRadius: '4px', padding: '16px', fontSize: '13px', color: 'var(--t1)', outline: 'none', resize: 'vertical', lineHeight: '1.8' }}
/>
) : (
<div style={{ minHeight: '300px', border: data.analysis ? '1px solid var(--bd)' : '2px dashed var(--bd)', borderRadius: '4px', padding: '16px', color: data.analysis ? 'var(--t1)' : 'var(--t3)', fontStyle: data.analysis ? 'normal' : 'italic', fontSize: '13px', whiteSpace: 'pre-wrap', lineHeight: '1.8' }}>
{data.analysis || '분석 내용이 없습니다'}
</div>
)}
</div>
)
}
function Page4({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) {
const setArr = <T extends Record<string, string>>(key: keyof OilSpillReportData, arr: T[], i: number, k: string, v: string) => {
const copy = [...arr]; copy[i] = { ...copy[i], [k]: v }; onChange({ ...data, [key]: copy })
}
return (
<div style={S.page}>
<div style={{ position: 'absolute', top: 10, right: 16, fontSize: '9px', color: 'var(--t3)', fontWeight: 600 }}></div>
<div style={S.sectionTitle}>4. </div>
<div style={S.mapPlaceholder}> (10km ) </div>
<div style={S.subHeader}> </div>
<table style={S.table}>
<thead><tr><th style={S.th}></th><th style={S.th}>(ha)</th><th style={S.th}> (km)</th></tr></thead>
<tbody>{data.aquaculture.map((a, i) => (
<tr key={i}>
<ECell value={a.type} editing={editing} onChange={v => setArr('aquaculture', data.aquaculture, i, 'type', v)} placeholder="-" />
<ECell value={a.area} editing={editing} onChange={v => setArr('aquaculture', data.aquaculture, i, 'area', v)} />
<ECell value={a.distance} editing={editing} onChange={v => setArr('aquaculture', data.aquaculture, i, 'distance', v)} />
</tr>
))}</tbody>
</table>
{editing && <AddRowBtn onClick={() => onChange({ ...data, aquaculture: [...data.aquaculture, { type: '', area: '', distance: '' }] })} />}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px' }}>
<div>
<div style={S.subHeader}> </div>
<table style={S.table}>
<thead><tr><th style={S.th}></th><th style={S.th}>(km)</th></tr></thead>
<tbody>{data.beaches.map((b, i) => (
<tr key={i}>
<ECell value={b.name} editing={editing} onChange={v => setArr('beaches', data.beaches, i, 'name', v)} align="left" />
<ECell value={b.distance} editing={editing} onChange={v => setArr('beaches', data.beaches, i, 'distance', v)} />
</tr>
))}</tbody>
</table>
{editing && <AddRowBtn onClick={() => onChange({ ...data, beaches: [...data.beaches, { name: '', distance: '' }] })} />}
</div>
<div>
<div style={S.subHeader}> </div>
<table style={S.table}>
<thead><tr><th style={S.th}></th><th style={S.th}>(km)</th></tr></thead>
<tbody>{data.markets.map((m, i) => (
<tr key={i}>
<ECell value={m.name} editing={editing} onChange={v => setArr('markets', data.markets, i, 'name', v)} align="left" />
<ECell value={m.distance} editing={editing} onChange={v => setArr('markets', data.markets, i, 'distance', v)} />
</tr>
))}</tbody>
</table>
{editing && <AddRowBtn onClick={() => onChange({ ...data, markets: [...data.markets, { name: '', distance: '' }] })} />}
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px', marginTop: '8px' }}>
<div>
<div style={S.subHeader}>(ESI) </div>
<table style={S.table}>
<thead><tr><th style={S.th}></th><th style={S.th}></th><th style={S.th}> </th></tr></thead>
<tbody>{data.esi.map((e, i) => (
<tr key={i}>
<td style={{ ...S.td, fontWeight: 600, fontSize: '10px' }}>{e.code}</td>
<td style={S.tdLeft}>{e.type}</td>
<ECell value={e.length} editing={editing} onChange={v => setArr('esi', data.esi, i, 'length', v)} />
</tr>
))}</tbody>
</table>
</div>
<div>
<div style={S.subHeader}>() </div>
<table style={S.table}>
<thead><tr><th style={S.th}></th><th style={S.th}></th></tr></thead>
<tbody>{data.species.map((s, i) => (
<tr key={i}>
<td style={{ ...S.td, fontWeight: 600 }}>{s.category}</td>
<ECell value={s.species} editing={editing} onChange={v => setArr('species', data.species, i, 'species', v)} align="left" placeholder="-" />
</tr>
))}</tbody>
</table>
<div style={{ ...S.subHeader, marginTop: '16px' }}> </div>
<table style={S.table}>
<thead><tr><th style={S.th}></th><th style={S.th}></th></tr></thead>
<tbody>{data.habitat.map((h, i) => (
<tr key={i}>
<ECell value={h.type} editing={editing} onChange={v => setArr('habitat', data.habitat, i, 'type', v)} />
<ECell value={h.area} editing={editing} onChange={v => setArr('habitat', data.habitat, i, 'area', v)} />
</tr>
))}</tbody>
</table>
</div>
</div>
</div>
)
}
function Page5({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) {
const setSens = (i: number, v: string) => { const s = [...data.sensitivity]; s[i] = { ...s[i], area: v }; onChange({ ...data, sensitivity: s }) }
return (
<div style={S.page}>
<div style={{ position: 'absolute', top: 10, right: 16, fontSize: '9px', color: 'var(--t3)', fontWeight: 600 }}></div>
<div style={S.sectionTitle}> ( )</div>
<div style={S.mapPlaceholder}> </div>
<table style={S.table}>
<thead><tr><th style={S.th}></th><th style={S.th}> (km²)</th></tr></thead>
<tbody>{data.sensitivity.map((s, i) => (
<tr key={i}>
<td style={{ ...S.td, fontWeight: 700 }}><span style={{ display: 'inline-block', width: 10, height: 10, borderRadius: 2, background: s.color, marginRight: 8, verticalAlign: 'middle' }} />{s.level}</td>
<ECell value={s.area} editing={editing} onChange={v => setSens(i, v)} />
</tr>
))}</tbody>
</table>
</div>
)
}
function Page6({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) {
const setVessel = (i: number, k: string, v: string) => { const vs = [...data.vessels]; vs[i] = { ...vs[i], [k]: v }; onChange({ ...data, vessels: vs }) }
return (
<div style={S.page}>
<div style={{ position: 'absolute', top: 10, right: 16, fontSize: '9px', color: 'var(--t3)', fontWeight: 600 }}></div>
<div style={S.sectionTitle}>5. ·</div>
<div style={S.subHeader}> ( 30km내)</div>
<div style={{ overflowX: 'auto' }}>
<table style={S.table}>
<thead>
<tr><th style={S.th} rowSpan={2}>#</th><th style={S.th} rowSpan={2}></th><th style={S.th} rowSpan={2}></th><th style={S.th} rowSpan={2}>(km)</th><th style={S.th} rowSpan={2}>(knots)</th><th style={S.th} rowSpan={2}>()</th><th style={S.th} colSpan={2}></th><th style={S.th} colSpan={2}></th></tr>
<tr><th style={S.th}></th><th style={S.th}>(kl)</th><th style={S.th}></th><th style={S.th}>(m)</th></tr>
</thead>
<tbody>{data.vessels.map((v, i) => (
<tr key={i}>
<td style={{ ...S.td, fontWeight: 600 }}>{i + 1}</td>
<ECell value={v.name} editing={editing} onChange={val => setVessel(i, 'name', val)} />
<ECell value={v.org} editing={editing} onChange={val => setVessel(i, 'org', val)} />
<ECell value={v.dist} editing={editing} onChange={val => setVessel(i, 'dist', val)} />
<ECell value={v.speed} editing={editing} onChange={val => setVessel(i, 'speed', val)} />
<ECell value={v.ton} editing={editing} onChange={val => setVessel(i, 'ton', val)} />
<ECell value={v.collectorType} editing={editing} onChange={val => setVessel(i, 'collectorType', val)} />
<ECell value={v.collectorCap} editing={editing} onChange={val => setVessel(i, 'collectorCap', val)} />
<ECell value={v.boomType} editing={editing} onChange={val => setVessel(i, 'boomType', val)} />
<ECell value={v.boomLength} editing={editing} onChange={val => setVessel(i, 'boomLength', val)} />
</tr>
))}</tbody>
</table>
</div>
{editing && <AddRowBtn onClick={() => onChange({ ...data, vessels: [...data.vessels, { name: '', org: '', dist: '', speed: '', ton: '', collectorType: '', collectorCap: '', boomType: '', boomLength: '' }] })} />}
<div style={S.subHeader}> </div>
{editing ? (
<textarea value={data.etcEquipment} onChange={e => onChange({ ...data, etcEquipment: e.target.value })} placeholder="기타 장비 입력" style={{ width: '100%', minHeight: '60px', background: 'var(--bg0)', border: '1px solid var(--bdL)', borderRadius: '4px', padding: '12px', fontSize: '12px', color: 'var(--t1)', outline: 'none', resize: 'vertical' }} />
) : (
<div style={{ minHeight: '60px', border: data.etcEquipment ? '1px solid var(--bd)' : '2px dashed var(--bd)', borderRadius: '4px', padding: '12px', color: data.etcEquipment ? 'var(--t1)' : 'var(--t3)', fontStyle: data.etcEquipment ? 'normal' : 'italic', fontSize: '12px' }}>{data.etcEquipment || '-'}</div>
)}
</div>
)
}
function Page7({ data, editing, onChange }: { data: OilSpillReportData; editing: boolean; onChange: (d: OilSpillReportData) => void }) {
const setRec = (i: number, k: string, v: string) => { const r = [...data.recovery]; r[i] = { ...r[i], [k]: v }; onChange({ ...data, recovery: r }) }
const setRes = (k: string, v: string) => onChange({ ...data, result: { ...data.result, [k]: v } })
return (
<div style={S.page}>
<div style={{ position: 'absolute', top: 10, right: 16, fontSize: '9px', color: 'var(--t3)', fontWeight: 600 }}></div>
<div style={S.sectionTitle}>/ </div>
<div style={S.mapPlaceholder}>/ </div>
<div style={S.subHeader}></div>
<table style={S.table}>
<thead><tr><th style={S.th}></th><th style={S.th}>( +)</th></tr></thead>
<tbody>{data.recovery.map((r, i) => (
<tr key={i}>
<ECell value={r.shipName} editing={editing} onChange={v => setRec(i, 'shipName', v)} />
<ECell value={r.period} editing={editing} onChange={v => setRec(i, 'period', v)} />
</tr>
))}</tbody>
</table>
{editing && <AddRowBtn onClick={() => onChange({ ...data, recovery: [...data.recovery, { shipName: '', period: '' }] })} />}
<div style={S.subHeader}> </div>
<table style={S.table}>
<thead><tr><th style={S.th}>(kl)</th><th style={S.th}>(kl)</th><th style={S.th}>(kl)</th><th style={S.th}>(kl)</th><th style={S.th}>(kl)</th></tr></thead>
<tbody><tr>
<ECell value={data.result.spillTotal} editing={editing} onChange={v => setRes('spillTotal', v)} />
<ECell value={data.result.weatheredTotal} editing={editing} onChange={v => setRes('weatheredTotal', v)} />
<ECell value={data.result.recoveredTotal} editing={editing} onChange={v => setRes('recoveredTotal', v)} />
<ECell value={data.result.seaRemainTotal} editing={editing} onChange={v => setRes('seaRemainTotal', v)} />
<ECell value={data.result.coastAttachTotal} editing={editing} onChange={v => setRes('coastAttachTotal', v)} />
</tr></tbody>
</table>
</div>
)
}
// ─── Main Template Component ────────────────────────────────
interface Props {
mode: 'preview' | 'edit' | 'view'
initialData?: OilSpillReportData
onSave?: (data: OilSpillReportData) => void
onBack?: () => void
}
export function OilSpillReportTemplate({ mode, initialData, onSave, onBack }: Props) {
const [data, setData] = useState<OilSpillReportData>(() => initialData || createSampleReport())
const [currentPage, setCurrentPage] = useState(0)
const [viewMode, setViewMode] = useState<'page' | 'all'>('all')
const editing = mode === 'edit'
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
if (initialData) setData(initialData)
}, [initialData])
const handleSave = useCallback(async () => {
let reportData = data
if (!data.title) {
const title = data.incident.name || `보고서 ${new Date().toLocaleDateString('ko-KR')}`
reportData = { ...data, title }
}
try {
await saveReport(reportData)
onSave?.(reportData)
} catch (err) {
console.error('[reports] 저장 오류:', err)
alert('보고서 저장 중 오류가 발생했습니다.')
}
}, [data, onSave])
const pages = [
{ label: '1. 사고 정보', node: <Page1 data={data} editing={editing} onChange={setData} /> },
{ label: '2. 해양기상 + 확산예측', node: <Page2 data={data} editing={editing} onChange={setData} /> },
{ label: '3. 분석', node: <Page3 data={data} editing={editing} onChange={setData} /> },
{ label: '4. 민감자원', node: <Page4 data={data} editing={editing} onChange={setData} /> },
{ label: '5. 통합민감도', node: <Page5 data={data} editing={editing} onChange={setData} /> },
{ label: '6. 방제전략', node: <Page6 data={data} editing={editing} onChange={setData} /> },
{ label: '7. 동원 결과', node: <Page7 data={data} editing={editing} onChange={setData} /> },
]
return (
<div style={{ width: '100%' }}>
{/* Toolbar */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '20px', flexWrap: 'wrap', gap: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
{onBack && <button onClick={onBack} style={{ padding: '6px 12px', fontSize: '12px', fontWeight: 600, color: 'var(--t2)', background: 'none', border: 'none', cursor: 'pointer' }}> </button>}
<h2 style={{ fontSize: '18px', fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)' }}>
{editing ? (
<input
value={data.title}
onChange={e => setData({ ...data, title: e.target.value })}
placeholder="보고서 제목 입력"
style={{ fontSize: '18px', fontWeight: 700, color: 'var(--t1)', background: 'var(--bg0)', border: '1px solid var(--bdL)', borderRadius: '4px', padding: '4px 10px', outline: 'none', width: '100%', maxWidth: '600px' }}
/>
) : (
data.title || '유류오염사고 대응지원 상황도'
)}
</h2>
<span style={{ padding: '3px 10px', fontSize: '10px', fontWeight: 600, borderRadius: '4px', border: '1px solid', ...( editing ? { background: 'rgba(251,191,36,0.15)', color: '#f59e0b', borderColor: 'rgba(251,191,36,0.3)' } : { background: 'rgba(6,182,212,0.15)', color: 'var(--cyan)', borderColor: 'rgba(6,182,212,0.3)' }) }}>
{editing ? '편집 중' : mode === 'preview' ? '샘플' : '보기'}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button onClick={() => setViewMode('all')} style={{ padding: '6px 14px', fontSize: '11px', fontWeight: 600, fontFamily: 'var(--fK)', borderRadius: '4px', border: viewMode === 'all' ? '1px solid var(--cyan)' : '1px solid var(--bd)', background: viewMode === 'all' ? 'rgba(6,182,212,0.1)' : 'var(--bg2)', color: viewMode === 'all' ? 'var(--cyan)' : 'var(--t3)', cursor: 'pointer' }}> </button>
<button onClick={() => setViewMode('page')} style={{ padding: '6px 14px', fontSize: '11px', fontWeight: 600, fontFamily: 'var(--fK)', borderRadius: '4px', border: viewMode === 'page' ? '1px solid var(--cyan)' : '1px solid var(--bd)', background: viewMode === 'page' ? 'rgba(6,182,212,0.1)' : 'var(--bg2)', color: viewMode === 'page' ? 'var(--cyan)' : 'var(--t3)', cursor: 'pointer' }}></button>
{editing && (
<button onClick={handleSave} style={{ padding: '6px 16px', fontSize: '11px', fontWeight: 700, fontFamily: 'var(--fK)', borderRadius: '4px', border: '1px solid #22c55e', background: 'rgba(34,197,94,0.15)', color: '#22c55e', cursor: 'pointer' }}></button>
)}
<button onClick={() => window.print()} style={{ padding: '6px 14px', fontSize: '11px', fontWeight: 600, fontFamily: 'var(--fK)', borderRadius: '4px', border: '1px solid var(--red)', background: 'rgba(239,68,68,0.1)', color: '#ef4444', cursor: 'pointer' }}> / PDF</button>
</div>
</div>
{/* Page tabs */}
{viewMode === 'page' && (
<div style={{ display: 'flex', gap: '4px', marginBottom: '16px', flexWrap: 'wrap' }}>
{pages.map((p, i) => (
<button key={i} onClick={() => setCurrentPage(i)} style={{ padding: '6px 12px', fontSize: '11px', fontWeight: 600, fontFamily: 'var(--fK)', borderRadius: '4px', border: currentPage === i ? '1px solid var(--cyan)' : '1px solid var(--bd)', background: currentPage === i ? 'rgba(6,182,212,0.15)' : 'transparent', color: currentPage === i ? 'var(--cyan)' : 'var(--t3)', cursor: 'pointer' }}>{p.label}</button>
))}
</div>
)}
{/* Pages */}
<div id="report-print-area" style={{ width: '100%' }}>
{viewMode === 'all'
? pages.map((p, i) => <div key={i}>{p.node}</div>)
: (
<div>
{pages[currentPage].node}
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '16px' }}>
<button onClick={() => setCurrentPage(p => Math.max(0, p - 1))} disabled={currentPage === 0} style={{ padding: '8px 20px', fontSize: '12px', fontWeight: 600, borderRadius: '4px', border: '1px solid var(--bd)', background: 'var(--bg2)', color: currentPage === 0 ? 'var(--t3)' : 'var(--t1)', cursor: currentPage === 0 ? 'default' : 'pointer', opacity: currentPage === 0 ? 0.4 : 1 }}></button>
<span style={{ padding: '8px 16px', fontSize: '12px', color: 'var(--t2)' }}>{currentPage + 1} / {pages.length}</span>
<button onClick={() => setCurrentPage(p => Math.min(pages.length - 1, p + 1))} disabled={currentPage === pages.length - 1} style={{ padding: '8px 20px', fontSize: '12px', fontWeight: 600, borderRadius: '4px', border: '1px solid var(--cyan)', background: 'rgba(6,182,212,0.1)', color: currentPage === pages.length - 1 ? 'var(--t3)' : 'var(--cyan)', cursor: currentPage === pages.length - 1 ? 'default' : 'pointer', opacity: currentPage === pages.length - 1 ? 0.4 : 1 }}></button>
</div>
</div>
)
}
</div>
</div>
)
}