wing-ops/frontend/src/tabs/reports/components/reportUtils.ts
2026-03-24 16:56:03 +09:00

236 lines
14 KiB
TypeScript

import { sanitizeHtml } from '@common/utils/sanitize';
import type { OilSpillReportData } from './OilSpillReportTemplate';
// ─── Report Export Helpers ──────────────────────────────
export function generateReportHTML(
templateLabel: string,
meta: { writeTime: string; author: string; jurisdiction: string },
sections: { title: string; fields: { key: string; label: string }[] }[],
getVal: (key: string) => string
) {
const rows = sections.map(section => {
const fieldRows = section.fields.map(f => {
if (f.label) {
return `<tr><td style="background:#f0f4f8;padding:8px 12px;border:1px solid #d1d5db;font-weight:600;width:200px;font-size:12px;">${f.label}</td><td style="padding:8px 12px;border:1px solid #d1d5db;font-size:12px;">${getVal(f.key) || '-'}</td></tr>`
}
return `<tr><td colspan="2" style="padding:8px 12px;border:1px solid #d1d5db;font-size:12px;white-space:pre-wrap;">${getVal(f.key) || '-'}</td></tr>`
}).join('')
return `<h3 style="color:#0891b2;font-size:14px;margin:20px 0 8px;">${section.title}</h3><table style="width:100%;border-collapse:collapse;">${fieldRows}</table>`
}).join('')
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${templateLabel}</title>
<style>@page{size:A4;margin:20mm}body{font-family:'맑은 고딕','Malgun Gothic',sans-serif;color:#1a1a1a;max-width:800px;margin:0 auto;padding:40px}</style>
</head><body>
<div style="text-align:center;margin-bottom:30px">
<h1 style="font-size:20px;margin:0">해양오염방제지원시스템</h1>
<h2 style="font-size:16px;color:#0891b2;margin:8px 0">${templateLabel}</h2>
<p style="font-size:11px;color:#666">작성일시: ${meta.writeTime} | 작성자: ${meta.author || '-'} | 관할: ${meta.jurisdiction}</p>
</div>${rows}</body></html>`
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function exportAsPDF(html: string, _filename: string) {
const sanitizedHtml = sanitizeHtml(html)
const blob = new Blob([sanitizedHtml], { type: 'text/html; charset=utf-8' })
const url = URL.createObjectURL(blob)
const win = window.open(url, '_blank')
if (win) {
win.addEventListener('afterprint', () => URL.revokeObjectURL(url))
setTimeout(() => win.print(), 500)
}
setTimeout(() => URL.revokeObjectURL(url), 30000)
}
export async function exportAsHWP(
templateLabel: string,
meta: { writeTime: string; author: string; jurisdiction: string },
sections: { title: string; fields: { key: string; label: string }[] }[],
getVal: (key: string) => string,
filename: string,
images?: { step3?: string; step6?: string; sensitiveMap?: string; sensitivityMap?: string },
) {
const { exportAsHWPX } = await import('./hwpxExport');
await exportAsHWPX(templateLabel, meta, sections, getVal, filename, images);
}
export type ViewState =
| { screen: 'list' }
| { screen: 'templates' }
| { screen: 'generate' }
| { screen: 'view'; data: OilSpillReportData }
| { screen: 'edit'; data: OilSpillReportData }
export const typeColors: Record<string, { bg: string; text: string }> = {
'초기보고서': { bg: 'rgba(6,182,212,0.15)', text: '#06b6d4' },
'지휘부 보고': { bg: 'rgba(168,85,247,0.15)', text: '#a855f7' },
'예측보고서': { bg: 'rgba(59,130,246,0.15)', text: '#3b82f6' },
'종합보고서': { bg: 'rgba(249,115,22,0.15)', text: '#f97316' },
'유출유 보고': { bg: 'rgba(234,179,8,0.15)', text: '#eab308' },
}
export const statusColors: Record<string, { bg: string; text: string }> = {
'완료': { bg: 'rgba(34,197,94,0.15)', text: '#22c55e' },
'수행중': { bg: 'rgba(249,115,22,0.15)', text: '#f97316' },
'테스트': { bg: 'rgba(138,150,168,0.15)', text: '#8a96a8' },
}
export const analysisCatColors: Record<string, { bg: string; text: string; icon: string }> = {
'유출유 확산예측': { bg: 'rgba(6,182,212,0.12)', text: '#06b6d4', icon: '🛢' },
'HNS 대기확산': { bg: 'rgba(249,115,22,0.12)', text: '#f97316', icon: '🧪' },
'긴급구난': { bg: 'rgba(239,68,68,0.12)', text: '#ef4444', icon: '🚨' },
}
export function inferAnalysisCategory(report: OilSpillReportData): string {
if (report.analysisCategory) return report.analysisCategory
const t = (report.title || '').toLowerCase()
const rt = report.reportType || ''
if (t.includes('hns') || t.includes('대기확산') || t.includes('화학') || t.includes('aloha')) return 'HNS 대기확산'
if (t.includes('구난') || t.includes('구조') || t.includes('긴급') || t.includes('salvage') || t.includes('rescue')) return '긴급구난'
if (t.includes('유출유') || t.includes('확산예측') || t.includes('민감자원') || t.includes('유출사고') || t.includes('오염') || t.includes('방제') || rt === '유출유 보고' || rt === '예측보고서') return '유출유 확산예측'
return ''
}
// ─── PDF/HWP 섹션 포맷 헬퍼 ─────────────────────────────────
const TH = 'padding:6px 8px;border:1px solid #d1d5db;background:#f0f4f8;font-weight:600;font-size:11px;'
const TD = 'padding:6px 8px;border:1px solid #d1d5db;font-size:11px;'
const TABLE = 'width:100%;border-collapse:collapse;'
function formatTideTable(tide: OilSpillReportData['tide']): string {
if (!tide?.length) return ''
const header = `<tr><th style="${TH}">날짜</th><th style="${TH}">조형</th><th style="${TH}">간조1</th><th style="${TH}">만조1</th><th style="${TH}">간조2</th><th style="${TH}">만조2</th></tr>`
const rows = tide.map(t =>
`<tr><td style="${TD}">${t.date}</td><td style="${TD}">${t.tideType}</td><td style="${TD}">${t.lowTide1}</td><td style="${TD}">${t.highTide1}</td><td style="${TD}">${t.lowTide2}</td><td style="${TD}">${t.highTide2}</td></tr>`
).join('')
return `<table style="${TABLE}">${header}${rows}</table>`
}
function formatWeatherTable(weather: OilSpillReportData['weather']): string {
if (!weather?.length) return ''
const header = `<tr><th style="${TH}">시각</th><th style="${TH}">풍향</th><th style="${TH}">풍속</th><th style="${TH}">유향</th><th style="${TH}">유속</th><th style="${TH}">파고</th></tr>`
const rows = weather.map(w =>
`<tr><td style="${TD}">${w.time}</td><td style="${TD}">${w.windDir}</td><td style="${TD}">${w.windSpeed}</td><td style="${TD}">${w.currentDir}</td><td style="${TD}">${w.currentSpeed}</td><td style="${TD}">${w.waveHeight}</td></tr>`
).join('')
return `<table style="${TABLE}">${header}${rows}</table>`
}
function formatSpreadTable(spread: OilSpillReportData['spread']): string {
if (!spread?.length) return ''
const header = `<tr><th style="${TH}">경과시간</th><th style="${TH}">풍화량</th><th style="${TH}">해상잔유량</th><th style="${TH}">연안부착량</th><th style="${TH}">면적</th></tr>`
const rows = spread.map(s =>
`<tr><td style="${TD}">${s.elapsed}</td><td style="${TD}">${s.weathered}</td><td style="${TD}">${s.seaRemain}</td><td style="${TD}">${s.coastAttach}</td><td style="${TD}">${s.area}</td></tr>`
).join('')
return `<table style="${TABLE}">${header}${rows}</table>`
}
function formatSensitiveTable(r: OilSpillReportData): string {
const parts: string[] = []
if (r.sensitiveMapImage) {
parts.push(
'<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">민감자원 분포 지도</p>' +
`<img src="${r.sensitiveMapImage}" style="width:100%;max-height:300px;object-fit:contain;border:1px solid #ddd;border-radius:6px;display:block;margin-bottom:8px;" />`
)
}
if (r.aquaculture?.length) {
const h = `<tr><th style="${TH}">종류</th><th style="${TH}">면적</th><th style="${TH}">거리</th></tr>`
const rows = r.aquaculture.map(a => `<tr><td style="${TD}">${a.type}</td><td style="${TD}">${a.area}</td><td style="${TD}">${a.distance}</td></tr>`).join('')
parts.push(`<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">양식업</p><table style="${TABLE}">${h}${rows}</table>`)
}
if (r.beaches?.length) {
const h = `<tr><th style="${TH}">해수욕장명</th><th style="${TH}">거리</th></tr>`
const rows = r.beaches.map(b => `<tr><td style="${TD}">${b.name}</td><td style="${TD}">${b.distance}</td></tr>`).join('')
parts.push(`<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">해수욕장</p><table style="${TABLE}">${h}${rows}</table>`)
}
if (r.markets?.length) {
const h = `<tr><th style="${TH}">수산시장명</th><th style="${TH}">거리</th></tr>`
const rows = r.markets.map(m => `<tr><td style="${TD}">${m.name}</td><td style="${TD}">${m.distance}</td></tr>`).join('')
parts.push(`<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">수산시장</p><table style="${TABLE}">${h}${rows}</table>`)
}
if (r.esi?.length) {
const h = `<tr><th style="${TH}">코드</th><th style="${TH}">유형</th><th style="${TH}">길이</th></tr>`
const rows = r.esi.map(e => `<tr><td style="${TD}">${e.code}</td><td style="${TD}">${e.type}</td><td style="${TD}">${e.length}</td></tr>`).join('')
parts.push(`<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">ESI 해안선</p><table style="${TABLE}">${h}${rows}</table>`)
}
if (r.species?.length) {
const h = `<tr><th style="${TH}">분류</th><th style="${TH}">종명</th></tr>`
const rows = r.species.map(s => `<tr><td style="${TD}">${s.category}</td><td style="${TD}">${s.species}</td></tr>`).join('')
parts.push(`<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">보호생물</p><table style="${TABLE}">${h}${rows}</table>`)
}
if (r.habitat?.length) {
const h = `<tr><th style="${TH}">유형</th><th style="${TH}">면적</th></tr>`
const rows = r.habitat.map(h2 => `<tr><td style="${TD}">${h2.type}</td><td style="${TD}">${h2.area}</td></tr>`).join('')
parts.push(`<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">서식지</p><table style="${TABLE}">${h}${rows}</table>`)
}
if (r.sensitivityMapImage) {
parts.push(
'<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">통합민감도 평가 지도</p>' +
`<img src="${r.sensitivityMapImage}" style="width:100%;max-height:300px;object-fit:contain;border:1px solid #ddd;border-radius:6px;display:block;margin-bottom:8px;" />`
)
}
if (r.sensitivity?.length) {
const h = `<tr><th style="${TH}">민감도</th><th style="${TH}">면적</th></tr>`
const rows = r.sensitivity.map(s => `<tr><td style="${TD}">${s.level}</td><td style="${TD}">${s.area}</td></tr>`).join('')
parts.push(`<p style="font-size:11px;font-weight:600;margin:8px 0 4px;">민감도 등급</p><table style="${TABLE}">${h}${rows}</table>`)
}
return parts.join('')
}
function formatVesselsTable(vessels: OilSpillReportData['vessels']): string {
if (!vessels?.length) return ''
const header = `<tr><th style="${TH}">선명</th><th style="${TH}">기관</th><th style="${TH}">거리</th><th style="${TH}">속력</th><th style="${TH}">톤수</th><th style="${TH}">회수장비</th><th style="${TH}">오일붐</th></tr>`
const rows = vessels.map(v =>
`<tr><td style="${TD}">${v.name}</td><td style="${TD}">${v.org}</td><td style="${TD}">${v.dist}</td><td style="${TD}">${v.speed}</td><td style="${TD}">${v.ton}</td><td style="${TD}">${v.collectorType} ${v.collectorCap}</td><td style="${TD}">${v.boomType} ${v.boomLength}</td></tr>`
).join('')
return `<table style="${TABLE}">${header}${rows}</table>`
}
function formatRecoveryTable(recovery: OilSpillReportData['recovery']): string {
if (!recovery?.length) return ''
const header = `<tr><th style="${TH}">선박명</th><th style="${TH}">회수 기간</th></tr>`
const rows = recovery.map(r =>
`<tr><td style="${TD}">${r.shipName}</td><td style="${TD}">${r.period}</td></tr>`
).join('')
return `<table style="${TABLE}">${header}${rows}</table>`
}
function formatResultTable(result: OilSpillReportData['result']): string {
if (!result) return ''
return `<table style="${TABLE}">
<tr><td style="${TH}">유출총량</td><td style="${TD}">${result.spillTotal}</td><td style="${TH}">풍화총량</td><td style="${TD}">${result.weatheredTotal}</td></tr>
<tr><td style="${TH}">회수총량</td><td style="${TD}">${result.recoveredTotal}</td><td style="${TH}">해상잔유량</td><td style="${TD}">${result.seaRemainTotal}</td></tr>
<tr><td style="${TH}">연안부착량</td><td style="${TD}" colspan="3">${result.coastAttachTotal}</td></tr>
</table>`
}
export function buildReportGetVal(report: OilSpillReportData) {
return (key: string): string => {
if (key === 'author') return report.author ?? ''
if (key.startsWith('incident.')) {
const f = key.split('.')[1]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (report.incident as any)[f] || ''
}
if (key === '__tide') return formatTideTable(report.tide)
if (key === '__weather') return formatWeatherTable(report.weather)
if (key === '__spreadMaps') {
const img3 = report.step3MapImage
const img6 = report.step6MapImage
if (!img3 && !img6) return ''
const cell = (label: string, src: string) =>
`<div style="flex:1;min-width:0"><p style="font-size:11px;font-weight:bold;color:#0891b2;margin:0 0 4px;">${label}</p>` +
`<img src="${src}" style="width:100%;max-height:260px;object-fit:contain;border:1px solid #ddd;border-radius:6px;" /></div>`
return `<div style="display:flex;gap:12px;margin-bottom:8px;">` +
(img3 ? cell('3시간 후', img3) : '') +
(img6 ? cell('6시간 후', img6) : '') +
`</div>`
}
if (key === '__spread') return formatSpreadTable(report.spread)
if (key === '__sensitive') return formatSensitiveTable(report)
if (key === '__vessels') return formatVesselsTable(report.vessels)
if (key === '__recovery') return formatRecoveryTable(report.recovery)
if (key === '__result') return formatResultTable(report.result)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (report as any)[key] || ''
}
}