615 lines
41 KiB
TypeScript
Executable File
615 lines
41 KiB
TypeScript
Executable File
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 }
|
||
capturedMapImage?: string;
|
||
step3MapImage?: string;
|
||
step6MapImage?: string;
|
||
hasMapCapture?: boolean;
|
||
}
|
||
|
||
// 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)', 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', 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} className="px-3 py-1 text-[10px] font-semibold text-primary-cyan bg-[rgba(6,182,212,0.08)] border border-dashed border-primary-cyan rounded-sm cursor-pointer mb-3">
|
||
+ {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 className="absolute top-2.5 right-4 text-[9px] text-text-3 font-semibold">해양오염방제지원시스템</div>
|
||
<div className="text-primary-cyan text-[18px] font-bold mb-5 rounded px-5 py-3 text-center tracking-wide border" style={{ background: 'rgba(6,182,212,0.1)', 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 className="absolute top-2.5 right-4 text-[9px] text-text-3 font-semibold">해양오염방제지원시스템</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 className="flex gap-4 mb-4">
|
||
{data.step3MapImage
|
||
? <img src={data.step3MapImage} alt="확산예측 3시간 지도" style={{ width: '100%', maxHeight: '240px', objectFit: 'contain', borderRadius: '4px', border: '1px solid var(--bd)' }} />
|
||
: <div style={S.mapPlaceholder}>확산예측 3시간 지도</div>
|
||
}
|
||
{data.step6MapImage
|
||
? <img src={data.step6MapImage} alt="확산예측 6시간 지도" style={{ width: '100%', maxHeight: '240px', objectFit: 'contain', borderRadius: '4px', border: '1px solid var(--bd)' }} />
|
||
: <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 className="absolute top-2.5 right-4 text-[9px] text-text-3 font-semibold">해양오염방제지원시스템</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', 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 className="absolute top-2.5 right-4 text-[9px] text-text-3 font-semibold">해양오염방제지원시스템</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 className="grid grid-cols-2 gap-5">
|
||
<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 className="grid grid-cols-2 gap-5 mt-2">
|
||
<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 className="absolute top-2.5 right-4 text-[9px] text-text-3 font-semibold">해양오염방제지원시스템</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 className="absolute top-2.5 right-4 text-[9px] text-text-3 font-semibold">해양오염방제지원시스템</div>
|
||
<div style={S.sectionTitle}>5. 방제전략 수립·실행</div>
|
||
<div style={S.subHeader}>방제자원 배치 현황 (반경 30km내)</div>
|
||
<div className="overflow-x-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', 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 className="absolute top-2.5 right-4 text-[9px] text-text-3 font-semibold">해양오염방제지원시스템</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 className="w-full">
|
||
{/* Toolbar */}
|
||
<div className="flex items-center justify-between mb-5 flex-wrap gap-3">
|
||
<div className="flex items-center gap-2.5">
|
||
{onBack && <button onClick={onBack} className="px-3 py-1.5 text-[12px] font-semibold text-text-2 bg-transparent border-none cursor-pointer">← 돌아가기</button>}
|
||
<h2 className="text-[18px] font-bold">
|
||
{editing ? (
|
||
<input
|
||
value={data.title}
|
||
onChange={e => setData({ ...data, title: e.target.value })}
|
||
placeholder="보고서 제목 입력"
|
||
className="text-[18px] font-bold bg-bg-0 border border-[var(--bdL)] rounded px-2.5 py-1 outline-none w-full max-w-[600px]"
|
||
/>
|
||
) : (
|
||
data.title || '유류오염사고 대응지원 상황도'
|
||
)}
|
||
</h2>
|
||
<span className="px-2.5 py-[3px] text-[10px] font-semibold rounded border" style={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 className="flex items-center gap-2">
|
||
<button onClick={() => setViewMode('all')} className="px-3.5 py-1.5 text-[11px] font-semibold rounded cursor-pointer" style={{ 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)' }}>전체 보기</button>
|
||
<button onClick={() => setViewMode('page')} className="px-3.5 py-1.5 text-[11px] font-semibold rounded cursor-pointer" style={{ 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)' }}>페이지별</button>
|
||
{editing && (
|
||
<button onClick={handleSave} className="px-4 py-1.5 text-[11px] font-bold rounded cursor-pointer border border-[#22c55e] bg-[rgba(34,197,94,0.15)] text-status-green">저장</button>
|
||
)}
|
||
<button onClick={() => window.print()} className="px-3.5 py-1.5 text-[11px] font-semibold rounded cursor-pointer border border-[var(--red)] bg-[rgba(239,68,68,0.1)] text-status-red">인쇄 / PDF</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Page tabs */}
|
||
{viewMode === 'page' && (
|
||
<div className="flex gap-1 mb-4 flex-wrap">
|
||
{pages.map((p, i) => (
|
||
<button key={i} onClick={() => setCurrentPage(i)} className="px-3 py-1.5 text-[11px] font-semibold rounded cursor-pointer" style={{ 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)' }}>{p.label}</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Pages */}
|
||
<div id="report-print-area" className="w-full">
|
||
{viewMode === 'all'
|
||
? pages.map((p, i) => <div key={i}>{p.node}</div>)
|
||
: (
|
||
<div>
|
||
{pages[currentPage].node}
|
||
<div className="flex justify-center gap-2 mt-4">
|
||
<button onClick={() => setCurrentPage(p => Math.max(0, p - 1))} disabled={currentPage === 0} className="px-5 py-2 text-[12px] font-semibold rounded border border-border bg-bg-2 cursor-pointer" style={{ color: currentPage === 0 ? 'var(--t3)' : 'var(--t1)', opacity: currentPage === 0 ? 0.4 : 1 }}>이전</button>
|
||
<span className="px-4 py-2 text-[12px] text-text-2">{currentPage + 1} / {pages.length}</span>
|
||
<button onClick={() => setCurrentPage(p => Math.min(pages.length - 1, p + 1))} disabled={currentPage === pages.length - 1} className="px-5 py-2 text-[12px] font-semibold rounded cursor-pointer" style={{ border: '1px solid var(--cyan)', background: 'rgba(6,182,212,0.1)', color: currentPage === pages.length - 1 ? 'var(--t3)' : 'var(--cyan)', opacity: currentPage === pages.length - 1 ? 0.4 : 1 }}>다음</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|