From c4f11423aa8a0df09dfde29dae86c638fc7deaa0 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Mon, 16 Mar 2026 18:23:42 +0900 Subject: [PATCH] =?UTF-8?q?feat(reports):=20=EB=B3=B4=EA=B3=A0=EC=84=9C=20?= =?UTF-8?q?=ED=99=95=EC=82=B0=EC=98=88=EC=B8=A1=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=EC=BA=A1=EC=B2=98=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- backend/src/middleware/security.ts | 4 +- backend/src/reports/reportsRouter.ts | 7 +- backend/src/reports/reportsService.ts | 22 +++- database/migration/007_reports.sql | 1 + .../components/map/HydrParticleOverlay.tsx | 8 +- .../src/common/components/map/MapView.tsx | 58 +++++++--- frontend/src/common/hooks/useSubMenu.ts | 16 +++ .../prediction/components/OilSpillView.tsx | 9 ++ .../components/OilSpillReportTemplate.tsx | 2 + .../reports/components/OilSpreadMapPanel.tsx | 106 ++++++++++++++++++ .../reports/components/ReportGenerator.tsx | 33 +++++- .../tabs/reports/components/ReportsView.tsx | 16 +++ .../src/tabs/reports/services/reportsApi.ts | 11 ++ 13 files changed, 260 insertions(+), 33 deletions(-) create mode 100644 frontend/src/tabs/reports/components/OilSpreadMapPanel.tsx diff --git a/backend/src/middleware/security.ts b/backend/src/middleware/security.ts index 4f5d53e..3945c1b 100755 --- a/backend/src/middleware/security.ts +++ b/backend/src/middleware/security.ts @@ -153,9 +153,9 @@ export function sanitizeQuery(req: Request, res: Response, next: NextFunction): } /** - * JSON 본문 크기 제한 (기본 100kb) + * JSON 본문 크기 제한 (보고서 지도 캡처 이미지 포함 대응: 5mb) */ -export const BODY_SIZE_LIMIT = '100kb' +export const BODY_SIZE_LIMIT = '5mb' /** * 응답 헤더에서 서버 정보 제거 diff --git a/backend/src/reports/reportsRouter.ts b/backend/src/reports/reportsRouter.ts index 5455dd1..1644497 100644 --- a/backend/src/reports/reportsRouter.ts +++ b/backend/src/reports/reportsRouter.ts @@ -92,7 +92,7 @@ router.get('/:sn', requireAuth, requirePermission('reports', 'READ'), async (req // ============================================================ router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req, res) => { try { - const { tmplSn, ctgrSn, acdntSn, title, jrsdCd, sttsCd, sections } = req.body; + const { tmplSn, ctgrSn, acdntSn, title, jrsdCd, sttsCd, sections, mapCaptureImg } = req.body; const result = await createReport({ tmplSn, ctgrSn, @@ -101,6 +101,7 @@ router.post('/', requireAuth, requirePermission('reports', 'CREATE'), async (req jrsdCd, sttsCd, authorId: req.user!.sub, + mapCaptureImg, sections, }); res.status(201).json(result); @@ -124,8 +125,8 @@ router.post('/:sn/update', requireAuth, requirePermission('reports', 'UPDATE'), res.status(400).json({ error: '유효하지 않은 보고서 번호입니다.' }); return; } - const { title, jrsdCd, sttsCd, acdntSn, sections } = req.body; - await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections }, req.user!.sub); + const { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg } = req.body; + await updateReport(sn, { title, jrsdCd, sttsCd, acdntSn, sections, mapCaptureImg }, req.user!.sub); res.json({ success: true }); } catch (err) { if (err instanceof AuthError) { diff --git a/backend/src/reports/reportsService.ts b/backend/src/reports/reportsService.ts index 63437e6..db655d9 100644 --- a/backend/src/reports/reportsService.ts +++ b/backend/src/reports/reportsService.ts @@ -62,6 +62,7 @@ interface ReportListItem { authorName: string; regDtm: string; mdfcnDtm: string | null; + hasMapCapture: boolean; } interface SectionData { @@ -74,6 +75,7 @@ interface SectionData { interface ReportDetail extends ReportListItem { acdntSn: number | null; sections: SectionData[]; + mapCaptureImg: string | null; } interface ListReportsInput { @@ -100,6 +102,7 @@ interface CreateReportInput { jrsdCd?: string; sttsCd?: string; authorId: string; + mapCaptureImg?: string; sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[]; } @@ -108,6 +111,7 @@ interface UpdateReportInput { jrsdCd?: string; sttsCd?: string; acdntSn?: number | null; + mapCaptureImg?: string | null; sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[]; } @@ -256,7 +260,8 @@ export async function listReports(input: ListReportsInput): Promise '' THEN true ELSE false END AS HAS_MAP_CAPTURE FROM REPORT r LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN @@ -281,6 +286,7 @@ export async function listReports(input: ListReportsInput): Promise { c.CTGR_CD, c.CTGR_NM, r.TITLE, r.JRSD_CD, r.STTS_CD, r.ACDNT_SN, r.AUTHOR_ID, u.USER_NM AS AUTHOR_NAME, - r.REG_DTM, r.MDFCN_DTM + r.REG_DTM, r.MDFCN_DTM, r.MAP_CAPTURE_IMG, + CASE WHEN r.MAP_CAPTURE_IMG IS NOT NULL AND r.MAP_CAPTURE_IMG <> '' THEN true ELSE false END AS HAS_MAP_CAPTURE FROM REPORT r LEFT JOIN REPORT_TMPL t ON t.TMPL_SN = r.TMPL_SN LEFT JOIN REPORT_ANALYSIS_CTGR c ON c.CTGR_SN = r.CTGR_SN @@ -331,6 +338,8 @@ export async function getReport(reportSn: number): Promise { authorName: r.author_name || '', regDtm: r.reg_dtm, mdfcnDtm: r.mdfcn_dtm, + mapCaptureImg: r.map_capture_img, + hasMapCapture: r.has_map_capture, sections: sectRes.rows.map((s) => ({ sectCd: s.sect_cd, includeYn: s.include_yn, @@ -350,8 +359,8 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb await client.query('BEGIN'); const res = await client.query( - `INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID) - VALUES ($1, $2, $3, $4, $5, $6, $7) + `INSERT INTO REPORT (TMPL_SN, CTGR_SN, ACDNT_SN, TITLE, JRSD_CD, STTS_CD, AUTHOR_ID, MAP_CAPTURE_IMG) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING REPORT_SN`, [ input.tmplSn || null, @@ -361,6 +370,7 @@ export async function createReport(input: CreateReportInput): Promise<{ sn: numb input.jrsdCd || null, input.sttsCd || 'DRAFT', input.authorId, + input.mapCaptureImg || null, ] ); const reportSn = res.rows[0].report_sn; @@ -432,6 +442,10 @@ export async function updateReport( sets.push(`ACDNT_SN = $${idx++}`); params.push(input.acdntSn); } + if (input.mapCaptureImg !== undefined) { + sets.push(`MAP_CAPTURE_IMG = $${idx++}`); + params.push(input.mapCaptureImg); + } params.push(reportSn); await client.query( diff --git a/database/migration/007_reports.sql b/database/migration/007_reports.sql index 76cfb5a..fe0a09d 100644 --- a/database/migration/007_reports.sql +++ b/database/migration/007_reports.sql @@ -77,6 +77,7 @@ CREATE TABLE IF NOT EXISTS REPORT ( USE_YN CHAR(1) DEFAULT 'Y', REG_DTM TIMESTAMPTZ DEFAULT NOW(), MDFCN_DTM TIMESTAMPTZ, + MAP_CAPTURE_IMG TEXT CONSTRAINT CK_REPORT_STATUS CHECK (STTS_CD IN ('DRAFT','IN_PROGRESS','COMPLETED')) ); diff --git a/frontend/src/common/components/map/HydrParticleOverlay.tsx b/frontend/src/common/components/map/HydrParticleOverlay.tsx index 2ff9154..c6454f6 100644 --- a/frontend/src/common/components/map/HydrParticleOverlay.tsx +++ b/frontend/src/common/components/map/HydrParticleOverlay.tsx @@ -4,6 +4,7 @@ import type { HydrDataStep } from '@tabs/prediction/services/predictionApi'; interface HydrParticleOverlayProps { hydrStep: HydrDataStep | null; + lightMode?: boolean; } const PARTICLE_COUNT = 3000; @@ -21,7 +22,7 @@ interface Particle { age: number; } -export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayProps) { +export default function HydrParticleOverlay({ hydrStep, lightMode = false }: HydrParticleOverlayProps) { const { current: map } = useMap(); const animRef = useRef(); @@ -125,7 +126,8 @@ export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayPro // alpha band별 일괄 렌더링 ctx.lineWidth = 0.8; for (let b = 0; b < NUM_ALPHA_BANDS; b++) { - ctx.strokeStyle = `rgba(180, 210, 255, ${((b + 1) / NUM_ALPHA_BANDS) * 0.75})`; + const [pr, pg, pb] = lightMode ? [30, 90, 180] : [180, 210, 255]; + ctx.strokeStyle = `rgba(${pr}, ${pg}, ${pb}, ${((b + 1) / NUM_ALPHA_BANDS) * 0.75})`; ctx.beginPath(); for (const [x1, y1, x2, y2] of bands[b]) { ctx.moveTo(x1, y1); @@ -151,7 +153,7 @@ export default function HydrParticleOverlay({ hydrStep }: HydrParticleOverlayPro map.off('move', onMove); canvas.remove(); }; - }, [map, hydrStep]); + }, [map, hydrStep, lightMode]); return null; } diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 7c269c3..fa7ab07 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -346,7 +346,7 @@ interface MapViewProps { hydrData?: (HydrDataStep | null)[] // 외부 플레이어 제어 (prediction 하단 바에서 제어할 때 사용) externalCurrentTime?: number - mapCaptureRef?: React.MutableRefObject<(() => string | null) | null> + mapCaptureRef?: React.MutableRefObject<(() => Promise) | null> onIncidentFlyEnd?: () => void flyToIncident?: { lon: number; lat: number } showCurrent?: boolean @@ -360,6 +360,8 @@ interface MapViewProps { analysisCircleRadiusM?: number /** 밝은 톤 지도 스타일 사용 (CartoDB Positron) */ lightMode?: boolean + /** false로 설정 시 WeatherInfoPanel, MapLegend, CoordinateDisplay 숨김 (기본: true) */ + showOverlays?: boolean } // deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved) @@ -435,15 +437,34 @@ function MapFlyToIncident({ lon, lat, onFlyEnd }: { lon?: number; lat?: number; return null } -// 지도 캡처 지원 (preserveDrawingBuffer 필요) -function MapCaptureSetup({ captureRef }: { captureRef: React.MutableRefObject<(() => string | null) | null> }) { +// 지도 캡처 지원 (map.once('render') 후 캡처로 빈 캔버스 문제 방지) +function MapCaptureSetup({ captureRef }: { captureRef: React.MutableRefObject<(() => Promise) | null> }) { const { current: map } = useMap(); useEffect(() => { if (!map) return; - captureRef.current = () => { - try { return map.getCanvas().toDataURL('image/png'); } - catch { return null; } - }; + captureRef.current = () => + new Promise((resolve) => { + map.once('render', () => { + try { + // WebGL 캔버스는 alpha=0 투명 배경이므로 불투명 배경과 합성 후 추출 + // 최대 1200px로 리사이즈 + JPEG 압축으로 전송 크기 절감 + const src = map.getCanvas(); + const maxW = 1200; + const scale = src.width > maxW ? maxW / src.width : 1; + const composite = document.createElement('canvas'); + composite.width = Math.round(src.width * scale); + composite.height = Math.round(src.height * scale); + const ctx = composite.getContext('2d')!; + ctx.fillStyle = '#0f1117'; + ctx.fillRect(0, 0, composite.width, composite.height); + ctx.drawImage(src, 0, 0, composite.width, composite.height); + resolve(composite.toDataURL('image/jpeg', 0.82)); + } catch { + resolve(null); + } + }); + map.triggerRepaint(); + }); }, [map, captureRef]); return null; } @@ -492,6 +513,7 @@ export function MapView({ analysisCircleCenter, analysisCircleRadiusM = 0, lightMode = false, + showOverlays = true, }: MapViewProps) { const { mapToggles } = useMapStore() const isControlled = externalCurrentTime !== undefined @@ -1007,7 +1029,7 @@ export function MapView({ getPosition: (d: SensitiveResource) => [d.lon, d.lat], getText: (d: SensitiveResource) => `${SENSITIVE_ICONS[d.type]} ${d.name} (${d.arrivalTimeH}h)`, getSize: 12, - getColor: [255, 255, 255, 200], + getColor: (lightMode ? [20, 40, 100, 240] : [255, 255, 255, 200]) as [number, number, number, number], getPixelOffset: [0, -20], fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif', fontWeight: 'bold', @@ -1028,7 +1050,7 @@ export function MapView({ id: 'center-path', data: [{ path: visibleCenters.map(p => [p.lon, p.lat] as [number, number]) }], getPath: (d: { path: [number, number][] }) => d.path, - getColor: [255, 220, 50, 200], + getColor: (lightMode ? [0, 60, 150, 230] : [255, 220, 50, 200]) as [number, number, number, number], getWidth: 2, widthMinPixels: 2, widthMaxPixels: 4, @@ -1042,7 +1064,7 @@ export function MapView({ data: visibleCenters, getPosition: (d: (typeof visibleCenters)[0]) => [d.lon, d.lat], getRadius: 5, - getFillColor: [255, 220, 50, 230], + getFillColor: (lightMode ? [0, 60, 150, 230] : [255, 220, 50, 230]) as [number, number, number, number], radiusMinPixels: 4, radiusMaxPixels: 8, pickable: false, @@ -1067,11 +1089,11 @@ export function MapView({ return `+${d.time}h`; }, getSize: 12, - getColor: [255, 220, 50, 220] as [number, number, number, number], + getColor: (lightMode ? [20, 40, 100, 240] : [255, 220, 50, 220]) as [number, number, number, number], getPixelOffset: [0, 16] as [number, number], fontWeight: 'bold', outlineWidth: 2, - outlineColor: [15, 21, 36, 200] as [number, number, number, number], + outlineColor: (lightMode ? [255, 255, 255, 180] : [15, 21, 36, 200]) as [number, number, number, number], billboard: true, sizeUnits: 'pixels' as const, updateTriggers: { @@ -1129,7 +1151,7 @@ export function MapView({ dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay, sensitiveResources, centerPoints, windData, showWind, showBeached, showTimeLabel, simulationStartTime, - analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, + analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, lightMode, ]) // 3D 모드 / 밝은 톤에 따른 지도 스타일 전환 @@ -1189,7 +1211,7 @@ export function MapView({ {/* 해류 파티클 오버레이 */} {hydrData.length > 0 && showCurrent && ( - + )} {/* 사고 위치 마커 (MapLibre Marker) */} @@ -1241,15 +1263,15 @@ export function MapView({ )} {/* 기상청 연계 정보 */} - + {showOverlays && } {/* 범례 */} - + {showOverlays && } {/* 좌표 표시 */} - + />} {/* 타임라인 컨트롤 (외부 제어 모드에서는 숨김 — 하단 플레이어가 대신 담당) */} {!isControlled && oilTrajectory.length > 0 && ( diff --git a/frontend/src/common/hooks/useSubMenu.ts b/frontend/src/common/hooks/useSubMenu.ts index fb11da7..593d846 100755 --- a/frontend/src/common/hooks/useSubMenu.ts +++ b/frontend/src/common/hooks/useSubMenu.ts @@ -169,6 +169,14 @@ export function consumeHnsReportPayload(): HnsReportPayload | null { } // ─── 유출유 예측 보고서 실 데이터 전달 ────────────────────────── +export interface OilReportMapParticle { + lat: number; + lon: number; + time: number; + particle?: number; + stranded?: 0 | 1; +} + export interface OilReportPayload { incident: { name: string; @@ -204,6 +212,14 @@ export interface OilReportPayload { firstTime: string | null; }; hasSimulation: boolean; + mapData: { + center: [number, number]; + zoom: number; + trajectory: OilReportMapParticle[]; + currentStep: number; + centerPoints: { lat: number; lon: number; time: number }[]; + simulationStartTime: string; + } | null; } let _oilReportPayload: OilReportPayload | null = null; diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index 996b7d1..bacb50c 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -749,6 +749,14 @@ export function OilSpillView() { })(), }, hasSimulation: simulationSummary !== null, + mapData: incidentCoord ? { + center: [incidentCoord.lat, incidentCoord.lon], + zoom: 10, + trajectory: oilTrajectory, + currentStep, + centerPoints, + simulationStartTime: accidentTime, + } : null, }; setOilReportPayload(payload); @@ -829,6 +837,7 @@ export function OilSpillView() { layerOpacity={layerOpacity} layerBrightness={layerBrightness} sensitiveResources={sensitiveResources} + lightMode centerPoints={centerPoints} windData={windData} hydrData={hydrData} diff --git a/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx b/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx index b9de350..c469a46 100755 --- a/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx +++ b/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx @@ -38,6 +38,8 @@ export interface OilSpillReportData { etcEquipment: string recovery: { shipName: string; period: string }[] result: { spillTotal: string; weatheredTotal: string; recoveredTotal: string; seaRemainTotal: string; coastAttachTotal: string } + capturedMapImage?: string; + hasMapCapture?: boolean; } // eslint-disable-next-line react-refresh/only-export-components diff --git a/frontend/src/tabs/reports/components/OilSpreadMapPanel.tsx b/frontend/src/tabs/reports/components/OilSpreadMapPanel.tsx new file mode 100644 index 0000000..9278a81 --- /dev/null +++ b/frontend/src/tabs/reports/components/OilSpreadMapPanel.tsx @@ -0,0 +1,106 @@ +import { useRef, useState } from 'react'; +import { MapView } from '@common/components/map/MapView'; +import type { OilReportPayload } from '@common/hooks/useSubMenu'; + +interface OilSpreadMapPanelProps { + mapData: OilReportPayload['mapData']; + capturedImage: string | null; + onCapture: (dataUrl: string) => void; + onReset: () => void; +} + +const OilSpreadMapPanel = ({ mapData, capturedImage, onCapture, onReset }: OilSpreadMapPanelProps) => { + const captureRef = useRef<(() => Promise) | null>(null); + const [isCapturing, setIsCapturing] = useState(false); + + const handleCapture = async () => { + if (!captureRef.current) return; + setIsCapturing(true); + const dataUrl = await captureRef.current(); + setIsCapturing(false); + if (dataUrl) { + onCapture(dataUrl); + } + }; + + if (!mapData) { + return ( +
+ 확산 예측 데이터가 없습니다. 예측 탭에서 시뮬레이션을 실행 후 보고서를 생성하세요. +
+ ); + } + + return ( +
+ {/* 지도 + 오버레이 컨테이너 — MapView 항상 마운트 유지 (deck.gl rAF race condition 방지) */} +
+ + + {/* 캡처 이미지 오버레이 — 우측 상단 */} + {capturedImage && ( +
+
+ 확산예측 지도 캡처 +
+ + 📷 캡처 완료 + + +
+
+
+ )} +
+ + {/* 하단 안내 + 캡처 버튼 */} +
+

+ {capturedImage + ? 'PDF 다운로드 시 캡처된 이미지가 포함됩니다.' + : '지도를 이동/확대하여 원하는 범위를 선택한 후 캡처하세요.'} +

+ +
+
+ ); +}; + +export default OilSpreadMapPanel; diff --git a/frontend/src/tabs/reports/components/ReportGenerator.tsx b/frontend/src/tabs/reports/components/ReportGenerator.tsx index c478d59..5aaac64 100644 --- a/frontend/src/tabs/reports/components/ReportGenerator.tsx +++ b/frontend/src/tabs/reports/components/ReportGenerator.tsx @@ -3,6 +3,7 @@ import { createEmptyReport, } from './OilSpillReportTemplate'; import { consumeReportGenCategory, consumeHnsReportPayload, type HnsReportPayload, consumeOilReportPayload, type OilReportPayload } from '@common/hooks/useSubMenu'; +import OilSpreadMapPanel from './OilSpreadMapPanel'; import { saveReport } from '../services/reportsApi'; import { CATEGORIES, @@ -34,6 +35,8 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { const [hnsPayload, setHnsPayload] = useState(null) // OIL 실 데이터 (없으면 sampleOilData fallback) const [oilPayload, setOilPayload] = useState(null) + // 확산예측 지도 캡처 이미지 + const [oilMapCaptured, setOilMapCaptured] = useState(null) // 외부에서 카테고리 힌트가 변경되면 반영 useEffect(() => { @@ -84,6 +87,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { report.incident.spillAmount = sampleOilData.pollution.spillAmount; } } + if (activeCat === 0 && oilMapCaptured) { + report.capturedMapImage = oilMapCaptured; + } try { await saveReport(report) onSave() @@ -99,6 +105,24 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { let content = `

${sec.desc}

`; // OIL 섹션에 실 데이터 삽입 + if (activeCat === 0) { + if (sec.id === 'oil-spread') { + const mapImg = oilMapCaptured + ? `` + : '
[확산예측 지도 미캡처]
'; + const spreadRows = oilPayload + ? [ + ['KOSPS', oilPayload.spread.kosps], + ['OpenDrift', oilPayload.spread.openDrift], + ['POSEIDON', oilPayload.spread.poseidon], + ] + : [['KOSPS', '—'], ['OpenDrift', '—'], ['POSEIDON', '—']]; + const tds = spreadRows.map(r => + `${r[0]}
${r[1]}` + ).join(''); + content = `${mapImg}${tds}
`; + } + } if (activeCat === 0 && oilPayload) { if (sec.id === 'oil-pollution') { const rows = [ @@ -290,9 +314,12 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { {/* ── 유출유 확산예측 섹션들 ── */} {sec.id === 'oil-spread' && ( <> -
- [확산예측 지도 - 범위 조절 작업] -
+ setOilMapCaptured(dataUrl)} + onReset={() => setOilMapCaptured(null)} + />
{[ { label: 'KOSPS', value: oilPayload?.spread.kosps || sampleOilData.spread.kosps, color: '#06b6d4' }, diff --git a/frontend/src/tabs/reports/components/ReportsView.tsx b/frontend/src/tabs/reports/components/ReportsView.tsx index 014c1a6..ced51db 100755 --- a/frontend/src/tabs/reports/components/ReportsView.tsx +++ b/frontend/src/tabs/reports/components/ReportsView.tsx @@ -130,6 +130,7 @@ export function ReportsView() { + @@ -145,6 +146,7 @@ export function ReportsView() { 관할 상태 수정 + 지도 다운로드 삭제 @@ -177,6 +179,12 @@ export function ReportsView() { {report.jurisdiction} {report.status} + + {(report.hasMapCapture || report.capturedMapImage) + ? 📷 + : + } + @@ -369,6 +377,14 @@ export function ReportsView() { previewReport.spread?.length > 0 && `확산예측: ${previewReport.spread.length}개 시점 데이터`, ].filter(Boolean).join('\n') || '—'}
+ {previewReport.capturedMapImage && ( + 확산예측 지도 캡처 + )} {/* 3. 초동조치 / 대응현황 */} diff --git a/frontend/src/tabs/reports/services/reportsApi.ts b/frontend/src/tabs/reports/services/reportsApi.ts index 33ee933..0bee564 100644 --- a/frontend/src/tabs/reports/services/reportsApi.ts +++ b/frontend/src/tabs/reports/services/reportsApi.ts @@ -62,6 +62,7 @@ export interface ApiReportListItem { authorName: string; regDtm: string; mdfcnDtm: string | null; + hasMapCapture?: boolean; } export interface ApiReportSectionData { @@ -74,6 +75,7 @@ export interface ApiReportSectionData { export interface ApiReportDetail extends ApiReportListItem { acdntSn: number | null; sections: ApiReportSectionData[]; + mapCaptureImg?: string | null; } export interface ApiReportListResponse { @@ -176,6 +178,7 @@ export async function createReportApi(input: { title: string; jrsdCd?: string; sttsCd?: string; + mapCaptureImg?: string; sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[]; }): Promise<{ sn: number }> { const res = await api.post<{ sn: number }>('/reports', input); @@ -187,6 +190,7 @@ export async function updateReportApi(sn: number, input: { jrsdCd?: string; sttsCd?: string; acdntSn?: number | null; + mapCaptureImg?: string | null; sections?: { sectCd: string; includeYn?: string; sectData: unknown; sortOrd?: number }[]; }): Promise { await api.post(`/reports/${sn}/update`, input); @@ -239,6 +243,7 @@ export async function saveReport(data: OilSpillReportData): Promise { title: data.title || data.incident.name || '보고서', jrsdCd: data.jurisdiction, sttsCd, + mapCaptureImg: data.capturedMapImage !== undefined ? (data.capturedMapImage || null) : undefined, sections, }); return existingSn; @@ -250,6 +255,7 @@ export async function saveReport(data: OilSpillReportData): Promise { title: data.title || data.incident.name || '보고서', jrsdCd: data.jurisdiction, sttsCd, + mapCaptureImg: data.capturedMapImage || undefined, sections, }); return result.sn; @@ -266,6 +272,7 @@ export function apiListItemToReportData(item: ApiReportListItem): OilSpillReport analysisCategory: (item.ctgrCd ? CTGR_CODE_TO_CAT[item.ctgrCd] : '') || '', jurisdiction: (item.jrsdCd as Jurisdiction) || '남해청', status: CODE_TO_STATUS[item.sttsCd] || '테스트', + hasMapCapture: item.hasMapCapture, // 목록에서는 섹션 데이터 없음 — 빈 기본값 incident: { name: '', writeTime: '', shipName: '', agent: '', location: '', lat: '', lon: '', occurTime: '', accidentType: '', pollutant: '', spillAmount: '', depth: '', seabed: '' }, tide: [], weather: [], spread: [], @@ -337,6 +344,10 @@ export function apiDetailToReportData(detail: ApiReportDetail): OilSpillReportDa } } + if (detail.mapCaptureImg) { + reportData.capturedMapImage = detail.mapCaptureImg; + } + return reportData; }