- 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>
608 lines
42 KiB
TypeScript
Executable File
608 lines
42 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 }
|
||
}
|
||
|
||
// 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>
|
||
)
|
||
}
|