diff --git a/backend/src/incidents/incidentsRouter.ts b/backend/src/incidents/incidentsRouter.ts index abb09d6..ea65044 100644 --- a/backend/src/incidents/incidentsRouter.ts +++ b/backend/src/incidents/incidentsRouter.ts @@ -7,6 +7,7 @@ import { getIncidentWeather, saveIncidentWeather, getIncidentMedia, + getIncidentImageAnalysis, } from './incidentsService.js'; const router = Router(); @@ -133,4 +134,26 @@ router.get('/:sn/media', requireAuth, async (req, res) => { } }); +// ============================================================ +// GET /api/incidents/:sn/image-analysis — 이미지 분석 데이터 +// ============================================================ +router.get('/:sn/image-analysis', requireAuth, async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10); + if (isNaN(sn)) { + res.status(400).json({ error: '유효하지 않은 사고 번호입니다.' }); + return; + } + const data = await getIncidentImageAnalysis(sn); + if (!data) { + res.status(404).json({ error: '이미지 분석 데이터가 없습니다.' }); + return; + } + res.json(data); + } catch (err) { + console.error('[incidents] 이미지 분석 데이터 조회 오류:', err); + res.status(500).json({ error: '이미지 분석 데이터 조회 중 오류가 발생했습니다.' }); + } +}); + export default router; diff --git a/backend/src/incidents/incidentsService.ts b/backend/src/incidents/incidentsService.ts index 104fee0..a02534b 100644 --- a/backend/src/incidents/incidentsService.ts +++ b/backend/src/incidents/incidentsService.ts @@ -24,7 +24,9 @@ interface IncidentListItem { spilQty: number | null; spilUnitCd: string | null; fcstHr: number | null; + hasPredCompleted: boolean; mediaCnt: number; + hasImgAnalysis: boolean; } interface PredExecItem { @@ -111,11 +113,17 @@ export async function listIncidents(filters: { a.LAT, a.LNG, a.LOC_DC, a.OCCRN_DTM, a.REGION_NM, a.OFFICE_NM, a.SVRT_CD, a.VESSEL_TP, a.PHASE_CD, a.ANALYST_NM, s.OIL_TP_CD, s.SPIL_QTY, s.SPIL_UNIT_CD, s.FCST_HR, + COALESCE(s.HAS_IMG_ANALYSIS, FALSE) AS has_img_analysis, + EXISTS ( + SELECT 1 FROM wing.PRED_EXEC pe + WHERE pe.ACDNT_SN = a.ACDNT_SN AND pe.EXEC_STTS_CD = 'COMPLETED' + ) AS has_pred_completed, COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0) + COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt FROM wing.ACDNT a LEFT JOIN LATERAL ( - SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR + SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, + IMG_RSLT_DATA IS NOT NULL AS HAS_IMG_ANALYSIS FROM wing.SPIL_DATA WHERE ACDNT_SN = a.ACDNT_SN ORDER BY SPIL_DATA_SN @@ -148,7 +156,9 @@ export async function listIncidents(filters: { spilQty: r.spil_qty != null ? parseFloat(r.spil_qty as string) : null, spilUnitCd: (r.spil_unit_cd as string) ?? null, fcstHr: (r.fcst_hr as number) ?? null, + hasPredCompleted: r.has_pred_completed as boolean, mediaCnt: Number(r.media_cnt), + hasImgAnalysis: (r.has_img_analysis as boolean) ?? false, })); } @@ -162,11 +172,17 @@ export async function getIncident(acdntSn: number): Promise) ?? null, }; } + +// ============================================================ +// 이미지 분석 데이터 조회 +// ============================================================ +export async function getIncidentImageAnalysis(acdntSn: number): Promise | null> { + const sql = ` + SELECT IMG_RSLT_DATA + FROM wing.SPIL_DATA + WHERE ACDNT_SN = $1 AND IMG_RSLT_DATA IS NOT NULL + ORDER BY SPIL_DATA_SN + LIMIT 1 + `; + + const { rows } = await wingPool.query(sql, [acdntSn]); + if (rows.length === 0) return null; + + return (rows[0] as Record).img_rslt_data as Record; +} diff --git a/backend/src/prediction/imageAnalyzeService.ts b/backend/src/prediction/imageAnalyzeService.ts index 41452e7..7d68896 100644 --- a/backend/src/prediction/imageAnalyzeService.ts +++ b/backend/src/prediction/imageAnalyzeService.ts @@ -74,7 +74,7 @@ function parseMeta(metaStr: string): { lat: number; lon: number; occurredAt: str return { lat, lon, occurredAt }; } -export async function analyzeImageFile(imageBuffer: Buffer, originalName: string): Promise { +export async function analyzeImageFile(imageBuffer: Buffer, originalName: string, acdntNmOverride?: string): Promise { const fileId = crypto.randomUUID(); // camTy는 현재 "mx15hdi"로 하드코딩한다. @@ -122,7 +122,7 @@ export async function analyzeImageFile(imageBuffer: Buffer, originalName: string const volume = firstOil?.volume ?? 0; // ACDNT INSERT - const acdntNm = `이미지분석_${new Date().toISOString().slice(0, 16).replace('T', ' ')}`; + const acdntNm = acdntNmOverride?.trim() || `이미지분석_${new Date().toISOString().slice(0, 16).replace('T', ' ')}`; const acdntRes = await wingPool.query( `INSERT INTO wing.ACDNT (ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, OCCRN_DTM, LAT, LNG, ACDNT_STTS_CD, USE_YN, REG_DTM) @@ -145,7 +145,7 @@ export async function analyzeImageFile(imageBuffer: Buffer, originalName: string await wingPool.query( `INSERT INTO wing.SPIL_DATA (ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, IMG_RSLT_DATA, REG_DTM) - VALUES ($1, $2, $3, 'KL', 'CONTINUOUS', 48, $4, NOW())`, + VALUES ($1, $2, $3, 'KL', 'CONTINUOUS', 6, $4, NOW())`, [ acdntSn, OIL_DB_CODE_MAP[oilType] ?? 'BUNKER_C', diff --git a/backend/src/prediction/predictionRouter.ts b/backend/src/prediction/predictionRouter.ts index 6d6c357..497ec86 100644 --- a/backend/src/prediction/predictionRouter.ts +++ b/backend/src/prediction/predictionRouter.ts @@ -230,7 +230,8 @@ router.post( res.status(400).json({ error: '이미지 파일이 필요합니다' }); return; } - const result = await analyzeImageFile(req.file.buffer, req.file.originalname); + const acdntNm = typeof req.body?.acdntNm === 'string' ? req.body.acdntNm : undefined; + const result = await analyzeImageFile(req.file.buffer, req.file.originalname, acdntNm); res.json(result); } catch (err: unknown) { if (err instanceof Error) { diff --git a/backend/src/routes/simulation.ts b/backend/src/routes/simulation.ts index abba4e4..a8b69dd 100755 --- a/backend/src/routes/simulation.ts +++ b/backend/src/routes/simulation.ts @@ -20,9 +20,9 @@ const POLL_TIMEOUT_MS = 30 * 60 * 1000 // 30분 const OIL_TYPE_MAP: Record = { '벙커C유': 'GENERIC BUNKER C', '경유': 'GENERIC DIESEL', - '원유': 'WEST TEXAS INTERMEDIATE (WTI)', + '원유': 'WEST TEXAS INTERMEDIATE', '중유': 'GENERIC HEAVY FUEL OIL', - '등유': 'FUEL OIL NO.1 (KEROSENE)', + '등유': 'FUEL OIL NO.1 (KEROSENE) ', '휘발유': 'GENERIC GASOLINE', } diff --git a/database/init.sql b/database/init.sql index a23122d..b814167 100755 --- a/database/init.sql +++ b/database/init.sql @@ -293,7 +293,7 @@ CREATE TABLE SPIL_DATA ( SPIL_DATA_SN SERIAL NOT NULL, -- 유출정보순번 ACDNT_SN INTEGER NOT NULL, -- 사고순번 OIL_TP_CD VARCHAR(50) NOT NULL, -- 유종코드 - SPIL_QTY NUMERIC(12,2), -- 유출량 + SPIL_QTY NUMERIC(14,10), -- 유출량 SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL', -- 유출단위코드 SPIL_TP_CD VARCHAR(20), -- 유출유형코드 SPIL_LOC_GEOM GEOMETRY(Point, 4326), -- 유출위치지오메트리 diff --git a/database/migration/009_incidents.sql b/database/migration/009_incidents.sql index 166c595..ae88141 100644 --- a/database/migration/009_incidents.sql +++ b/database/migration/009_incidents.sql @@ -40,7 +40,7 @@ CREATE TABLE IF NOT EXISTS SPIL_DATA ( SPIL_DATA_SN SERIAL NOT NULL, ACDNT_SN INTEGER NOT NULL, OIL_TP_CD VARCHAR(50) NOT NULL, - SPIL_QTY NUMERIC(12,2), + SPIL_QTY NUMERIC(14,10), SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL', SPIL_TP_CD VARCHAR(20), FCST_HR INTEGER, diff --git a/database/migration/013_hns_analysis.sql b/database/migration/013_hns_analysis.sql index 0a05240..18858f0 100644 --- a/database/migration/013_hns_analysis.sql +++ b/database/migration/013_hns_analysis.sql @@ -21,7 +21,7 @@ CREATE TABLE IF NOT EXISTS HNS_ANALYSIS ( SBST_NM VARCHAR(100), UN_NO VARCHAR(10), CAS_NO VARCHAR(20), - SPIL_QTY NUMERIC(10,2), + SPIL_QTY NUMERIC(14,10), SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL', SPIL_TP_CD VARCHAR(20), FCST_HR INTEGER, diff --git a/database/migration/031_spil_qty_precision.sql b/database/migration/031_spil_qty_precision.sql new file mode 100644 index 0000000..2fb18a5 --- /dev/null +++ b/database/migration/031_spil_qty_precision.sql @@ -0,0 +1,7 @@ +-- 031: 유출량(SPIL_QTY) 소수점 정밀도 확대 +-- 이미지 분석 결과로 1e-7 수준의 매우 작은 유출량을 저장할 수 있도록 +-- NUMERIC(12,2) / NUMERIC(10,2) → NUMERIC(14,10) 으로 변경 +-- 정수부 최대 4자리, 소수부 10자리 + +ALTER TABLE wing.SPIL_DATA ALTER COLUMN SPIL_QTY TYPE NUMERIC(14,10); +ALTER TABLE wing.HNS_ANALY ALTER COLUMN SPIL_QTY TYPE NUMERIC(14,10); diff --git a/frontend/src/common/styles/components.css b/frontend/src/common/styles/components.css index 0bd81c1..f0be483 100644 --- a/frontend/src/common/styles/components.css +++ b/frontend/src/common/styles/components.css @@ -4,6 +4,21 @@ z-index: 500; } +/* 사고 팝업 — @layer 밖에 위치해야 MapLibre 기본 스타일을 덮어씀 */ +.incident-popup .maplibregl-popup-content { + background: transparent; + border-radius: 0; + padding: 0; + box-shadow: none; + border: none; +} +.incident-popup .maplibregl-popup-tip { + border-top-color: var(--bg-elevated); + border-bottom-color: var(--bg-elevated); + border-left-color: transparent; + border-right-color: transparent; +} + @layer components { /* ═══ CCTV 지도 팝업 (어두운 톤) ═══ */ .cctv-dark-popup .maplibregl-popup-content { diff --git a/frontend/src/tabs/incidents/components/ImageAnalysisModal.tsx b/frontend/src/tabs/incidents/components/ImageAnalysisModal.tsx new file mode 100644 index 0000000..94b2dfd --- /dev/null +++ b/frontend/src/tabs/incidents/components/ImageAnalysisModal.tsx @@ -0,0 +1,2 @@ +// 이 파일은 사용되지 않습니다. 이미지 보기 기능은 MediaModal에 통합되었습니다. +export {}; diff --git a/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx b/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx index ef73bcc..1627be8 100755 --- a/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx +++ b/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx @@ -15,12 +15,14 @@ export interface Incident { prediction?: string; vesselName?: string; mediaCount?: number; + hasImgAnalysis?: boolean; } interface IncidentsLeftPanelProps { incidents: Incident[]; selectedIncidentId: string | null; onIncidentSelect: (id: string | null) => void; + onFilteredChange?: (filtered: Incident[]) => void; } const PERIOD_PRESETS = ['오늘', '1주일', '1개월', '3개월', '6개월', '1년'] as const; @@ -75,6 +77,7 @@ export function IncidentsLeftPanel({ incidents, selectedIncidentId, onIncidentSelect, + onFilteredChange, }: IncidentsLeftPanelProps) { const today = formatDate(new Date()); const todayLabel = today.replace(/-/g, '-'); @@ -157,6 +160,10 @@ export function IncidentsLeftPanel({ }); }, [incidents, searchTerm, selectedRegion, selectedStatus, dateFrom, dateTo]); + useEffect(() => { + onFilteredChange?.(filteredIncidents); + }, [filteredIncidents, onFilteredChange]); + const regionCounts = useMemo(() => { const dateFiltered = incidents.filter((i) => { const matchesSearch = @@ -551,6 +558,27 @@ export function IncidentsLeftPanel({ 📹 {inc.mediaCount} )} + {inc.hasImgAnalysis && ( + + )} diff --git a/frontend/src/tabs/incidents/components/IncidentsView.tsx b/frontend/src/tabs/incidents/components/IncidentsView.tsx index 9d6837b..a1d17e6 100755 --- a/frontend/src/tabs/incidents/components/IncidentsView.tsx +++ b/frontend/src/tabs/incidents/components/IncidentsView.tsx @@ -129,6 +129,7 @@ interface HoverInfo { ════════════════════════════════════════════════════ */ export function IncidentsView() { const [incidents, setIncidents] = useState([]); + const [filteredIncidents, setFilteredIncidents] = useState([]); const [selectedIncidentId, setSelectedIncidentId] = useState(null); const [selectedVessel, setSelectedVessel] = useState(null); const [detailVessel, setDetailVessel] = useState(null); @@ -249,7 +250,7 @@ export function IncidentsView() { () => new ScatterplotLayer({ id: 'incidents', - data: incidents, + data: filteredIncidents, getPosition: (d: IncidentCompat) => [d.location.lon, d.location.lat], getRadius: (d: IncidentCompat) => (selectedIncidentId === d.id ? 16 : 12), getFillColor: (d: IncidentCompat) => getMarkerColor(d.status), @@ -290,7 +291,7 @@ export function IncidentsView() { getLineWidth: [selectedIncidentId], }, }), - [incidents, selectedIncidentId], + [filteredIncidents, selectedIncidentId], ); // ── 선박 방향 지시 아이콘 (삼각형 SVG → IconLayer) ────── @@ -577,6 +578,7 @@ export function IncidentsView() { incidents={incidents} selectedIncidentId={selectedIncidentId} onIncidentSelect={setSelectedIncidentId} + onFilteredChange={setFilteredIncidents} /> {/* Center - Map + Analysis Views */} @@ -689,29 +691,15 @@ export function IncidentsView() { latitude={incidentPopup.latitude} anchor="bottom" onClose={() => setIncidentPopup(null)} - closeButton={true} + closeButton={false} closeOnClick={false} + className="incident-popup" + maxWidth="none" > -
-
- {incidentPopup.incident.name} -
-
-
상태: {getStatusLabel(incidentPopup.incident.status)}
-
- 일시: {incidentPopup.incident.date} {incidentPopup.incident.time} -
-
관할: {incidentPopup.incident.office}
- {incidentPopup.incident.causeType && ( -
원인: {incidentPopup.incident.causeType}
- )} - {incidentPopup.incident.prediction && ( -
- {incidentPopup.incident.prediction} -
- )} -
-
+ setIncidentPopup(null)} + /> )} @@ -1443,6 +1431,165 @@ function PopupRow({ ); } +/* ════════════════════════════════════════════════════ + IncidentPopupContent – 사고 마커 클릭 팝업 + ════════════════════════════════════════════════════ */ +function IncidentPopupContent({ + incident: inc, + onClose, +}: { + incident: IncidentCompat; + onClose: () => void; +}) { + const dotColor: Record = { + active: 'var(--color-danger)', + investigating: 'var(--color-warning)', + closed: 'var(--fg-disabled)', + }; + const stBg: Record = { + active: 'rgba(239,68,68,0.15)', + investigating: 'rgba(249,115,22,0.15)', + closed: 'rgba(100,116,139,0.15)', + }; + const stColor: Record = { + active: 'var(--color-danger)', + investigating: 'var(--color-warning)', + closed: 'var(--fg-disabled)', + }; + + return ( +
+ {/* Header */} +
+ +
+ {inc.name} +
+ + ✕ + +
+ + {/* Tags */} +
+ + {getStatusLabel(inc.status)} + + {inc.causeType && ( + + {inc.causeType} + + )} + {inc.oilType && ( + + {inc.oilType} + + )} +
+ + {/* Info rows */} +
+
+ 일시 + + {inc.date} {inc.time} + +
+
+ 관할 + {inc.office} +
+
+ 지역 + {inc.region} +
+
+ + {/* Prediction badge */} + {inc.prediction && ( +
+ + {inc.prediction} + +
+ )} +
+ ); +} + /* ════════════════════════════════════════════════════ VesselDetailModal ════════════════════════════════════════════════════ */ diff --git a/frontend/src/tabs/incidents/components/MediaModal.tsx b/frontend/src/tabs/incidents/components/MediaModal.tsx index 0f89875..626b2a1 100755 --- a/frontend/src/tabs/incidents/components/MediaModal.tsx +++ b/frontend/src/tabs/incidents/components/MediaModal.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import type { Incident } from './IncidentsLeftPanel'; -import { fetchIncidentMedia } from '../services/incidentsApi'; -import type { MediaInfo } from '../services/incidentsApi'; +import { fetchIncidentMedia, fetchIncidentAerialMedia, getMediaImageUrl } from '../services/incidentsApi'; +import type { MediaInfo, AerialMediaItem } from '../services/incidentsApi'; type MediaTab = 'all' | 'photo' | 'video' | 'satellite' | 'cctv'; @@ -35,9 +35,12 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose: const [activeTab, setActiveTab] = useState('all'); const [selectedCam, setSelectedCam] = useState(0); const [media, setMedia] = useState(null); + const [aerialImages, setAerialImages] = useState([]); + const [selectedImageIdx, setSelectedImageIdx] = useState(0); useEffect(() => { fetchIncidentMedia(parseInt(incident.id)).then(setMedia); + fetchIncidentAerialMedia(parseInt(incident.id)).then(setAerialImages); }, [incident.id]); // Timeline dots (UI constant) @@ -75,7 +78,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose: ); } - const total = media.photoCnt + media.videoCnt + media.satCnt + media.cctvCnt; + const total = (media.photoCnt ?? 0) + (media.videoCnt ?? 0) + (media.satCnt ?? 0) + (media.cctvCnt ?? 0) + aerialImages.length; const showPhoto = activeTab === 'all' || activeTab === 'photo'; const showVideo = activeTab === 'all' || activeTab === 'video'; @@ -233,61 +236,171 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
📷 - 현장사진 — {str(media.photoMeta, 'title', '현장 사진')} + 현장사진 — {aerialImages.length > 0 ? `${aerialImages.length}장` : str(media.photoMeta, 'title', '현장 사진')}
- + {aerialImages.length > 1 && ( + <> + setSelectedImageIdx((p) => Math.max(0, p - 1))} /> + setSelectedImageIdx((p) => Math.min(aerialImages.length - 1, p + 1))} /> + + )} +
{/* Photo content */} -
-
- 📷 -
-
- {incident.name.replace('유류유출', '유출 현장').replace('오염', '현장')} 해상 사진 -
-
- {str(media.photoMeta, 'date')} · {str(media.photoMeta, 'by')} -
+
+ {aerialImages.length > 0 ? ( + <> + {aerialImages[selectedImageIdx].orgnlNm { + (e.target as HTMLImageElement).style.display = 'none'; + (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden'); + }} + /> +
+
📷
+
이미지를 불러올 수 없습니다
+
+ {aerialImages.length > 1 && ( + <> + + + + )} +
+ {selectedImageIdx + 1} / {aerialImages.length} +
+ + ) : ( +
+
📷
+
+ {incident.name.replace('유류유출', '유출 현장').replace('오염', '현장')} 해상 사진 +
+
+ {str(media.photoMeta, 'date')} · {str(media.photoMeta, 'by')} +
+
+ )}
{/* Thumbnails */}
-
- {Array.from({ length: Math.min(num(media.photoMeta, 'thumbCount'), 7) }).map( - (_, i) => ( -
0 ? ( + <> +
+ {aerialImages.map((img, i) => ( +
- 📷 -
- ), - )} -
-
- - 📷 사진 {num(media.photoMeta, 'thumbCount')}장 · {str(media.photoMeta, 'stage')} - - - 🔗 R&D 연계 - -
+ }} + onClick={() => setSelectedImageIdx(i)} + > + {img.orgnlNm { + const el = e.target as HTMLImageElement; + el.style.display = 'none'; + }} + /> +
+ ))} +
+
+ + 📷 사진 {aerialImages.length}장 + {aerialImages[selectedImageIdx]?.takngDtm + ? ` · ${new Date(aerialImages[selectedImageIdx].takngDtm!).toLocaleDateString('ko-KR')}` + : ''} + + + {aerialImages[selectedImageIdx]?.orgnlNm ?? ''} + +
+ + ) : ( + <> +
+ {Array.from({ length: Math.min(num(media.photoMeta, 'thumbCount'), 7) }).map( + (_, i) => ( +
+ 📷 +
+ ), + )} +
+
+ + 📷 사진 {num(media.photoMeta, 'thumbCount')}장 · {str(media.photoMeta, 'stage')} + + + 🔗 R&D 연계 + +
+ + )}
)} @@ -560,16 +673,16 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose: >
- 📷 사진 {media.photoCnt} + 📷 사진 {aerialImages.length > 0 ? aerialImages.length : (media.photoCnt ?? 0)} - 🎬 영상 {media.videoCnt} + 🎬 영상 {media.videoCnt ?? 0} - 🛰 위성 {media.satCnt} + 🛰 위성 {media.satCnt ?? 0} - 📹 CCTV {media.cctvCnt} + 📹 CCTV {media.cctvCnt ?? 0} 📎 총 {total}건 @@ -604,9 +717,10 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose: ); } -function NavBtn({ label }: { label: string }) { +function NavBtn({ label, onClick }: { label: string; onClick?: () => void }) { return (
- {/* Direct Input Mode */} - {inputMode === 'direct' && ( - <> - onIncidentNameChange(e.target.value)} - style={ - validationErrors?.has('incidentName') - ? { borderColor: 'var(--color-danger)' } - : undefined - } - /> - - - )} + {/* 사고명 입력 (직접입력 / 이미지업로드 공통) */} + onIncidentNameChange(e.target.value)} + style={ + validationErrors?.has('incidentName') + ? { borderColor: 'var(--color-danger)' } + : undefined + } + /> + {/* Image Upload Mode */} {inputMode === 'upload' && ( @@ -353,10 +349,10 @@ const PredictionInputSection = ({ className="prd-i" placeholder="유출량" type="number" - min="1" - step="1" + min="0" + step="any" value={spillAmount} - onChange={(e) => onSpillAmountChange(parseInt(e.target.value) || 0)} + onChange={(e) => onSpillAmountChange(parseFloat(e.target.value) || 0)} /> => { +export const analyzeImage = async (file: File, acdntNm?: string): Promise => { const formData = new FormData(); formData.append('image', file); + if (acdntNm?.trim()) formData.append('acdntNm', acdntNm.trim()); const response = await api.post('/prediction/image-analyze', formData, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 330_000,