Merge branch 'iipbs/sn/dev' into iipbs/sn/dev_kic

This commit is contained in:
kiminchan
2026-08-03 10:12:47 +09:00
22 changed files with 4144 additions and 603 deletions
@@ -21,7 +21,7 @@ type LayerComponentProps = {
};
type LayerContentMap = {
[key: string]: {
component?: React.FC<LayerComponentProps>;
component?: React.ComponentType<any>;
props?: any;
};
};
@@ -46,7 +46,7 @@ export const getLayerContentComponent = (layerNm: string): React.FC<LayerCompone
// component가 없으면 null 반환
return () => null;
}
return component.component
return component.component as React.FC<LayerComponentProps>
};
// 사용자 입력 필드 (보수 및 갱신, 청소 및 준설, 점검 및 진단)
export const handleInputChange = (section: string,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,561 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { FeatureLike } from "ol/Feature";
import GeoJSON from "ol/format/GeoJSON";
import Map from "ol/Map";
import View from "ol/View";
import { Circle as CircleStyle, Fill, Stroke, Style } from "ol/style";
import TileLayer from "ol/layer/Tile";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import TileWMTS from "ol/source/WMTS";
import WMTSTileGrid from "ol/tilegrid/WMTS";
import { getTopLeft } from "ol/extent";
import { get as getProjection } from "ol/proj";
import { register } from "ol/proj/proj4";
import proj4 from "proj4";
import { getRawJson } from "@/components/lib/api";
type GisUploadPreviewMapProps = {
uploadId: string;
mngInstCd?: string | null;
};
type LayerSetting = {
id?: string;
lyrSn?: string;
lyrNm?: string;
lyrTyNm?: string;
lyrTypeNm?: string;
lyr_type_nm?: string;
flterSql?: string | null;
minViewSize?: string | null;
minZoom?: string | null;
lnColor?: string | null;
lineColr?: string | null;
lnWdth?: string | null;
lineWdth?: string | null;
drawOrdr?: string | null;
exprsYn?: string | null;
useYn?: string | null;
actlLyrYn?: string | null;
children?: LayerSetting[];
};
const MAP_SRS = "EPSG:5179";
const INITIAL_CENTER_5179 = [965000, 1900000];
const INITIAL_ZOOM = 8;
const FALLBACK_PIPE_MIN_ZOOM = 13;
const FALLBACK_MANHOLE_MIN_ZOOM = 14;
const LAYER_GROUP = "SN_GIS_ULD_PREVIEW";
const FALLBACK_LAYER_GROUP = "SN_NETWORK";
const PIPE_LAYER_TYPE = "tb_ms_swage_ppl";
const MANHOLE_LAYER_TYPE = "tb_ms_swage_mhl";
const NGII_WMTS_URL = `/ngii-server/openapi/Gettile.do?apikey=${process.env.NEXT_PUBLIC_NGII_PARAM ?? ""}`;
const NGII_RESOLUTIONS = [1044.48, 522.24, 261.12, 130.56, 65.28, 32.64, 16.32, 8.16, 4.08, 2.04, 1.02, 0.51, 0.255];
const NGII_MATRIX_IDS = ["L06", "L07", "L08", "L09", "L10", "L11", "L12", "L13", "L14", "L15", "L16", "L17", "L18"];
let isProjectionRegistered = false;
const registerMapProjection = () => {
if (isProjectionRegistered) return;
proj4.defs(
MAP_SRS,
"+proj=tmerc +lat_0=38 +lon_0=127.5 +k=0.9996 +x_0=1000000 +y_0=2000000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs",
);
register(proj4);
const projection = getProjection(MAP_SRS);
projection?.setExtent([-200000.0, -28024123.62, 31824123.62, 4000000.0]);
isProjectionRegistered = true;
};
const createNgiiBaseLayer = () => {
registerMapProjection();
const projection = getProjection(MAP_SRS);
const tileGrid = new WMTSTileGrid({
origin: getTopLeft(projection?.getExtent() ?? [-200000.0, -28024123.62, 31824123.62, 4000000.0]),
resolutions: NGII_RESOLUTIONS,
matrixIds: NGII_MATRIX_IDS,
tileSize: [256, 256],
});
const source = new TileWMTS({
projection,
tileGrid,
crossOrigin: "anonymous",
requestEncoding: "KVP",
layer: "korean_map",
matrixSet: MAP_SRS,
format: "image/png",
style: "korean",
});
source.setTileUrlFunction((tileCoord) => {
const [z, x, y] = tileCoord;
return NGII_WMTS_URL
.replace("{z}", NGII_MATRIX_IDS[z] ?? NGII_MATRIX_IDS[0])
.replace("{x}", String(x))
.replace("{y}", String(y));
});
return new TileLayer({
source,
zIndex: 0,
});
};
const flattenLayerSettings = (items: LayerSetting[] = []): LayerSetting[] =>
items.flatMap((item) => [item, ...flattenLayerSettings(item.children ?? [])]);
const getLayerTypeName = (layer: LayerSetting) =>
String(layer.lyrTyNm ?? layer.lyrTypeNm ?? layer.lyr_type_nm ?? "").trim();
const toFiniteNumber = (value: unknown, fallback: number) => {
if (value === null || value === undefined || String(value).trim() === "") return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const normalizeColor = (value: unknown, fallback: string) => {
const color = String(value ?? "").trim();
return color || fallback;
};
const getLineColor = (layer?: LayerSetting | null) =>
normalizeColor(layer?.lnColor ?? layer?.lineColr, "#f97316");
const getLayerMinZoom = (layer: LayerSetting | null | undefined, fallback: number) =>
toFiniteNumber(layer?.minViewSize ?? layer?.minZoom, fallback);
const getPipeLineWidth = (layer?: LayerSetting | null) =>
Math.max(3, toFiniteNumber(layer?.lnWdth ?? layer?.lineWdth, 1) + 2);
const pipePreviewStyle = (layerSetting?: LayerSetting | null) => (feature: FeatureLike) => {
const status = feature.get("previewStatus");
const baseColor =
status === "CREATED" ? "#16a34a" :
status === "UPDATED" ? "#f97316" :
getLineColor(layerSetting);
const width = status === "LATEST" ? getPipeLineWidth(layerSetting) : getPipeLineWidth(layerSetting) + 3;
return [
new Style({
stroke: new Stroke({
color: "#ffffff",
width: width + 3,
}),
}),
new Style({
stroke: new Stroke({
color: baseColor,
width,
}),
}),
];
};
const manholeStyle = new Style({
image: new CircleStyle({
radius: 5,
fill: new Fill({ color: "#e11d48" }),
stroke: new Stroke({ color: "#ffffff", width: 2 }),
}),
});
const unwrapFeatureCollection = (response: any) => response?.data ?? response;
const refreshMapSize = (map: Map) => {
window.requestAnimationFrame(() => {
map.updateSize();
});
};
const getFeatureExtent = (features: ReturnType<GeoJSON["readFeatures"]>) => {
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
features.forEach((feature) => {
const geometry = feature.getGeometry();
if (!geometry) return;
const extent = geometry.getExtent();
minX = Math.min(minX, extent[0]);
minY = Math.min(minY, extent[1]);
maxX = Math.max(maxX, extent[2]);
maxY = Math.max(maxY, extent[3]);
});
if (![minX, minY, maxX, maxY].every(Number.isFinite)) {
return null;
}
return [minX, minY, maxX, maxY] as [number, number, number, number];
};
const getFeatureExtentCenter = (features: ReturnType<GeoJSON["readFeatures"]>) => {
const extent = getFeatureExtent(features);
if (!extent) return null;
const targetCenter = [(extent[0] + extent[2]) / 2, (extent[1] + extent[3]) / 2];
return features.reduce<number[] | null>((nearestCenter, feature) => {
const featureExtent = feature.getGeometry()?.getExtent();
if (!featureExtent) return nearestCenter;
const featureCenter = [
(featureExtent[0] + featureExtent[2]) / 2,
(featureExtent[1] + featureExtent[3]) / 2,
];
if (!nearestCenter) return featureCenter;
const nearestDistance = ((nearestCenter[0] - targetCenter[0]) ** 2)
+ ((nearestCenter[1] - targetCenter[1]) ** 2);
const featureDistance = ((featureCenter[0] - targetCenter[0]) ** 2)
+ ((featureCenter[1] - targetCenter[1]) ** 2);
return featureDistance < nearestDistance ? featureCenter : nearestCenter;
}, null);
};
const buildBboxFilter = (extent: number[], layer?: LayerSetting | null) => {
const bboxFilter = `BBOX(geom, ${extent.join(",")}, '${MAP_SRS}')`;
const layerFilter = String(layer?.flterSql ?? "").trim();
return layerFilter ? `(${layerFilter}) AND ${bboxFilter}` : bboxFilter;
};
const withManagementFilter = (filter: string, mngInstCd?: string | null) => {
const normalized = String(mngInstCd ?? "").trim();
if (!normalized) return filter;
return `(mng_inst_cd='${normalized.replace(/'/g, "''")}') AND (${filter})`;
};
export default function GisUploadPreviewMap({ uploadId, mngInstCd }: GisUploadPreviewMapProps) {
const mapElementRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<Map | null>(null);
const pipeSourceRef = useRef(new VectorSource());
const manholeSourceRef = useRef(new VectorSource());
const pipeLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const loadTimerRef = useRef<number | null>(null);
const didFitPreviewRef = useRef(false);
const [layerSettings, setLayerSettings] = useState<LayerSetting[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [featureCount, setFeatureCount] = useState(0);
const [manholeCount, setManholeCount] = useState(0);
const [deletedCount, setDeletedCount] = useState(0);
const [truncated, setTruncated] = useState(false);
const [message, setMessage] = useState("");
const flatLayerSettings = useMemo(() => flattenLayerSettings(layerSettings), [layerSettings]);
const pipeLayerSetting = useMemo(
() => flatLayerSettings.find((layer) => getLayerTypeName(layer) === PIPE_LAYER_TYPE) ?? null,
[flatLayerSettings],
);
const manholeLayerSetting = useMemo(
() => flatLayerSettings.find((layer) => getLayerTypeName(layer) === MANHOLE_LAYER_TYPE) ?? null,
[flatLayerSettings],
);
const pipeMinZoom = getLayerMinZoom(pipeLayerSetting, FALLBACK_PIPE_MIN_ZOOM);
const manholeMinZoom = getLayerMinZoom(manholeLayerSetting, FALLBACK_MANHOLE_MIN_ZOOM);
useEffect(() => {
let mounted = true;
const loadLayerSettings = async () => {
try {
const previewSettings = await getRawJson(`/sewerage-service/api/v1/map/lyrStngs/${LAYER_GROUP}`);
const previewFlat = flattenLayerSettings(Array.isArray(previewSettings) ? previewSettings : []);
if (mounted && previewFlat.some((layer) => [PIPE_LAYER_TYPE, MANHOLE_LAYER_TYPE].includes(getLayerTypeName(layer)))) {
setLayerSettings(previewSettings);
return;
}
const networkSettings = await getRawJson(`/sewerage-service/api/v1/map/lyrStngs/${FALLBACK_LAYER_GROUP}`);
const filtered = flattenLayerSettings(Array.isArray(networkSettings) ? networkSettings : [])
.filter((layer) => [PIPE_LAYER_TYPE, MANHOLE_LAYER_TYPE].includes(getLayerTypeName(layer)))
.filter((layer) => (layer.useYn ?? "Y") === "Y" && (layer.exprsYn ?? "Y") === "Y" && (layer.actlLyrYn ?? "Y") === "Y");
if (mounted) {
setLayerSettings(filtered);
}
} catch (error) {
console.error("GIS 업로드 미리보기 레이어 설정 조회 실패:", error);
if (mounted) {
setLayerSettings([]);
}
}
};
void loadLayerSettings();
return () => {
mounted = false;
};
}, []);
useEffect(() => {
let disposed = false;
let initializeTimer: number | null = null;
const initializeMap = () => {
const mapElement = mapElementRef.current;
if (disposed || mapRef.current) return;
if (!mapElement || mapElement.clientWidth <= 0 || mapElement.clientHeight <= 0) {
initializeTimer = window.setTimeout(initializeMap, 100);
return;
}
registerMapProjection();
const pipeLayer = new VectorLayer({
source: pipeSourceRef.current,
style: pipePreviewStyle(pipeLayerSetting),
minZoom: pipeMinZoom,
zIndex: toFiniteNumber(pipeLayerSetting?.drawOrdr, 300),
});
const manholeLayer = new VectorLayer({
source: manholeSourceRef.current,
style: manholeStyle,
minZoom: manholeMinZoom,
zIndex: toFiniteNumber(manholeLayerSetting?.drawOrdr, 510),
});
const map = new Map({
target: mapElement,
layers: [createNgiiBaseLayer(), pipeLayer, manholeLayer],
view: new View({
center: INITIAL_CENTER_5179,
zoom: INITIAL_ZOOM,
projection: MAP_SRS,
}),
controls: [],
});
pipeLayerRef.current = pipeLayer;
mapRef.current = map;
refreshMapSize(map);
};
initializeMap();
return () => {
disposed = true;
if (initializeTimer) {
window.clearTimeout(initializeTimer);
}
abortControllerRef.current?.abort();
if (loadTimerRef.current) {
window.clearTimeout(loadTimerRef.current);
}
mapRef.current?.setTarget(undefined);
mapRef.current = null;
pipeLayerRef.current = null;
pipeSourceRef.current.clear();
manholeSourceRef.current.clear();
};
}, []);
useEffect(() => {
pipeLayerRef.current?.setStyle(pipePreviewStyle(pipeLayerSetting));
pipeLayerRef.current?.setMinZoom(pipeMinZoom);
}, [pipeLayerSetting, pipeMinZoom]);
useEffect(() => {
const map = mapRef.current;
const mapElement = mapElementRef.current;
if (!map || !mapElement) return;
const resizeObserver = new ResizeObserver(() => {
refreshMapSize(map);
});
resizeObserver.observe(mapElement);
const updateTimer = window.setTimeout(() => {
map.updateSize();
}, 0);
return () => {
window.clearTimeout(updateTimer);
resizeObserver.disconnect();
};
}, []);
useEffect(() => {
didFitPreviewRef.current = false;
}, [mngInstCd, uploadId]);
const loadPreview = useCallback(async function loadPreviewRequest() {
const map = mapRef.current;
if (!map || !uploadId) return;
map.updateSize();
let size = map.getSize();
if (!size) {
map.updateSize();
size = map.getSize();
}
if (!size || size[0] <= 0 || size[1] <= 0) {
if (loadTimerRef.current) {
window.clearTimeout(loadTimerRef.current);
}
loadTimerRef.current = window.setTimeout(() => {
void loadPreviewRequest();
}, 150);
return;
}
const extent = map.getView().calculateExtent(size);
const previewParams = new URLSearchParams({
bbox: extent.join(","),
srs: MAP_SRS,
});
if (mngInstCd) {
previewParams.set("mngInstCd", mngInstCd);
}
const manholeParams = new URLSearchParams({
TYPENAME: MANHOLE_LAYER_TYPE,
CQL_FILTER: withManagementFilter(buildBboxFilter(extent, manholeLayerSetting), mngInstCd),
});
abortControllerRef.current?.abort();
const abortController = new AbortController();
abortControllerRef.current = abortController;
setIsLoading(true);
setMessage("");
pipeSourceRef.current.clear();
manholeSourceRef.current.clear();
setFeatureCount(0);
setManholeCount(0);
setDeletedCount(0);
setTruncated(false);
try {
const [previewResponse, manholeResponse] = await Promise.all([
getRawJson(
`v1/map/gisUploads/${encodeURIComponent(uploadId)}/preview?${previewParams.toString()}`,
{ signal: abortController.signal },
),
getRawJson(
`/sewerage-service/api/v1/map/geometry?${manholeParams.toString()}`,
{ signal: abortController.signal },
).catch((error) => {
if (error?.name === "AbortError") {
return { type: "FeatureCollection", features: [] };
}
console.warn("GIS 업로드 미리보기 맨홀 조회 실패:", error);
return { type: "FeatureCollection", features: [] };
}),
]);
const previewCollection = unwrapFeatureCollection(previewResponse);
const pipeFeatures = new GeoJSON({
dataProjection: MAP_SRS,
featureProjection: MAP_SRS,
}).readFeatures(previewCollection);
const manholeFeatures = new GeoJSON({
dataProjection: "EPSG:3857",
featureProjection: MAP_SRS,
}).readFeatures(unwrapFeatureCollection(manholeResponse));
pipeSourceRef.current.addFeatures(pipeFeatures);
manholeSourceRef.current.addFeatures(manholeFeatures);
if (!didFitPreviewRef.current && pipeFeatures.length > 0) {
const changedFeatures = pipeFeatures.filter((feature) => feature.get("previewStatus") !== "LATEST");
const center = getFeatureExtentCenter(changedFeatures.length > 0 ? changedFeatures : pipeFeatures);
if (center) {
map.getView().setCenter(center);
map.getView().setZoom(pipeMinZoom);
didFitPreviewRef.current = true;
}
}
setFeatureCount(pipeFeatures.length);
setManholeCount(manholeFeatures.length);
setDeletedCount(Number(previewCollection?.meta?.deletedCount ?? 0));
setTruncated(Boolean(previewCollection?.meta?.truncated));
} catch (error: any) {
if (error?.name === "AbortError") return;
console.error("GIS 업로드 미리보기 조회 실패:", error);
setMessage("미리보기 데이터를 불러오지 못했습니다.");
} finally {
if (!abortController.signal.aborted) {
setIsLoading(false);
}
}
}, [manholeLayerSetting, manholeMinZoom, mngInstCd, pipeMinZoom, uploadId]);
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const scheduleLoad = () => {
if (loadTimerRef.current) {
window.clearTimeout(loadTimerRef.current);
}
loadTimerRef.current = window.setTimeout(() => {
void loadPreview();
}, 250);
};
map.on("moveend", scheduleLoad);
scheduleLoad();
return () => {
map.un("moveend", scheduleLoad);
};
}, [loadPreview]);
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-2xl font-medium text-label">업로드 이력 미리보기</h3>
<p className="mt-1 text-[1rem] text-slate-600">
변경 관로는 업로드 이력 기준으로, 변경 없는 관로와 맨홀은 최신 기준으로 표시합니다.
</p>
</div>
</div>
<div className="mb-2 flex flex-wrap gap-3 text-[0.95rem] text-slate-700">
<span><b className="text-green-600">신규</b> / <b className="text-orange-500">수정</b> / <b className="text-blue-600">최신본</b></span>
<span>관로 표출 줌 {pipeMinZoom} 이상</span>
<span>관로 {featureCount.toLocaleString("ko-KR")}건</span>
<span>맨홀 {manholeCount.toLocaleString("ko-KR")}건</span>
<span>삭제 미표시 {deletedCount.toLocaleString("ko-KR")}건</span>
{truncated ? <span className="text-red-600">조회 상한 초과</span> : null}
{isLoading ? <span>불러오는 중...</span> : null}
</div>
{message ? (
<div className="mb-2 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-[1rem] text-red-700">
{message}
</div>
) : null}
<div
ref={mapElementRef}
className="gis-upload-preview-map shrink-0 overflow-hidden rounded-md border border-slate-300 bg-slate-100"
style={{ height: "max(420px, calc(100vh - 300px))" }}
/>
<style jsx global>{`
.gis-upload-preview-map {
position: relative;
}
.gis-upload-preview-map .ol-viewport {
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
touch-action: none;
}
.gis-upload-preview-map .ol-unselectable {
user-select: none;
}
.gis-upload-preview-map .ol-layers,
.gis-upload-preview-map .ol-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.gis-upload-preview-map canvas {
display: block;
}
`}</style>
</div>
);
}
@@ -311,7 +311,7 @@ export default function PipeAnalysisPopupPage() {
exportRow[head.id] = row[head.id];
});
return exportRow;
});
});
await downloadExcelWithKoreanHeaders({
data: exportRows as unknown as JSON[],
fileName: `${SUMMARY_FILE_PREFIX[appliedFilters.analysisType]}_${buildPipeAnalysisFileDate()}`,
File diff suppressed because it is too large Load Diff
@@ -15,7 +15,11 @@ export type WaterRiskResultRow = {
areaName: string;
sidoName: string;
sggName: string;
// 화면설계서 v1.12 결과 그리드용 표시값이며 실제 값은 후속 BE API 응답으로 치환한다.
currentAccumRainQty: number;
forecastRainQty: number;
maxRainIntensity: number;
maxFloodingAt: string;
riskStatus: WaterRiskStatus;
calculationMethod: WaterRiskCalculationMethod;
pipeSummaries: WaterRiskPipeSummary[];
@@ -88,11 +92,11 @@ const buildRectanglePolygon = (
halfHeight: number,
) => {
return [[
[centerX - halfWidth, centerY - halfHeight],
[centerX + halfWidth, centerY - halfHeight],
[centerX + halfWidth, centerY + halfHeight],
[centerX - halfWidth, centerY + halfHeight],
[centerX - halfWidth, centerY - halfHeight],
[centerX - halfWidth, centerY - halfHeight],
[centerX + halfWidth, centerY - halfHeight],
[centerX + halfWidth, centerY + halfHeight],
[centerX - halfWidth, centerY + halfHeight],
[centerX - halfWidth, centerY - halfHeight],
]];
};
@@ -112,12 +116,12 @@ const buildGridCells = (
return offsets.map(([offsetX, offsetY], index) => {
const rainQty = values[index] ?? values[values.length - 1] ?? 0;
const colorLevel = rainQty >= 40
? "LEVEL_4"
: rainQty >= 30
? "LEVEL_3"
: rainQty >= 20
? "LEVEL_2"
: "LEVEL_1";
? "LEVEL_4"
: rainQty >= 30
? "LEVEL_3"
: rainQty >= 20
? "LEVEL_2"
: "LEVEL_1";
return {
gridId: `GRID-${index + 1}`,
@@ -139,7 +143,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [
areaName: "A 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
currentAccumRainQty: 18.6,
forecastRainQty: 42.0,
maxRainIntensity: 50.2,
maxFloodingAt: "2026-05-18 14:00",
riskStatus: "DANGER",
calculationMethod: "METHOD_1",
pipeSummaries: [
@@ -152,7 +159,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [
areaName: "B 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
currentAccumRainQty: 12.4,
forecastRainQty: 31.4,
maxRainIntensity: 42.1,
maxFloodingAt: "2026-05-18 13:00",
riskStatus: "NORMAL",
calculationMethod: "METHOD_1",
pipeSummaries: [
@@ -164,7 +174,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [
areaName: "C 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
currentAccumRainQty: 15.2,
forecastRainQty: 37.8,
maxRainIntensity: 48.7,
maxFloodingAt: "2026-05-18 14:00",
riskStatus: "DANGER",
calculationMethod: "METHOD_2",
pipeSummaries: [
@@ -178,7 +191,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [
areaName: "D 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
currentAccumRainQty: 8.9,
forecastRainQty: 24.3,
maxRainIntensity: 35.4,
maxFloodingAt: "2026-05-18 12:00",
riskStatus: "NORMAL",
calculationMethod: "METHOD_2",
pipeSummaries: [
@@ -191,7 +207,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [
areaName: "E 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
currentAccumRainQty: 10.7,
forecastRainQty: 28.6,
maxRainIntensity: 39.8,
maxFloodingAt: "2026-05-18 13:00",
riskStatus: "NORMAL",
calculationMethod: "METHOD_1",
pipeSummaries: [
@@ -203,7 +222,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [
areaName: "F 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
currentAccumRainQty: 19.8,
forecastRainQty: 45.1,
maxRainIntensity: 54.2,
maxFloodingAt: "2026-05-18 15:00",
riskStatus: "DANGER",
calculationMethod: "METHOD_2",
pipeSummaries: [
@@ -216,7 +238,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [
areaName: "G 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
currentAccumRainQty: 13.5,
forecastRainQty: 33.2,
maxRainIntensity: 44.6,
maxFloodingAt: "2026-05-18 14:00",
riskStatus: "NORMAL",
calculationMethod: "METHOD_1",
pipeSummaries: [
@@ -228,7 +253,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [
areaName: "H 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
currentAccumRainQty: 16.4,
forecastRainQty: 39.4,
maxRainIntensity: 51.3,
maxFloodingAt: "2026-05-18 15:00",
riskStatus: "DANGER",
calculationMethod: "METHOD_2",
pipeSummaries: [
@@ -242,7 +270,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [
areaName: "I 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
currentAccumRainQty: 9.6,
forecastRainQty: 26.1,
maxRainIntensity: 37.2,
maxFloodingAt: "2026-05-18 12:00",
riskStatus: "NORMAL",
calculationMethod: "METHOD_1",
pipeSummaries: [
@@ -254,7 +285,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [
areaName: "J 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
currentAccumRainQty: 17.1,
forecastRainQty: 41.8,
maxRainIntensity: 52.7,
maxFloodingAt: "2026-05-18 15:00",
riskStatus: "DANGER",
calculationMethod: "METHOD_2",
pipeSummaries: [
@@ -267,7 +301,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [
areaName: "K 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
currentAccumRainQty: 11.2,
forecastRainQty: 29.7,
maxRainIntensity: 40.1,
maxFloodingAt: "2026-05-18 13:00",
riskStatus: "NORMAL",
calculationMethod: "METHOD_1",
pipeSummaries: [
@@ -280,7 +317,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [
areaName: "L 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
currentAccumRainQty: 18.9,
forecastRainQty: 43.6,
maxRainIntensity: 53.8,
maxFloodingAt: "2026-05-18 15:00",
riskStatus: "DANGER",
calculationMethod: "METHOD_2",
pipeSummaries: [
@@ -292,7 +332,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [
areaName: "M 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
currentAccumRainQty: 14.3,
forecastRainQty: 35.2,
maxRainIntensity: 46.5,
maxFloodingAt: "2026-05-18 14:00",
riskStatus: "DANGER",
calculationMethod: "METHOD_1",
pipeSummaries: [
@@ -306,7 +349,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [
areaName: "N 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
currentAccumRainQty: 7.6,
forecastRainQty: 21.9,
maxRainIntensity: 32.8,
maxFloodingAt: "2026-05-18 11:00",
riskStatus: "NORMAL",
calculationMethod: "METHOD_2",
pipeSummaries: [
@@ -318,7 +364,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [
areaName: "O 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
currentAccumRainQty: 20.4,
forecastRainQty: 47.3,
maxRainIntensity: 56.9,
maxFloodingAt: "2026-05-18 16:00",
riskStatus: "DANGER",
calculationMethod: "METHOD_2",
pipeSummaries: [
@@ -331,7 +380,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [
areaName: "P 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
currentAccumRainQty: 10.1,
forecastRainQty: 27.5,
maxRainIntensity: 38.6,
maxFloodingAt: "2026-05-18 13:00",
riskStatus: "NORMAL",
calculationMethod: "METHOD_1",
pipeSummaries: [
@@ -344,7 +396,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [
areaName: "Q 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
currentAccumRainQty: 12.8,
forecastRainQty: 32.8,
maxRainIntensity: 43.7,
maxFloodingAt: "2026-05-18 14:00",
riskStatus: "NORMAL",
calculationMethod: "METHOD_1",
pipeSummaries: [
@@ -356,7 +411,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [
areaName: "R 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
currentAccumRainQty: 18.2,
forecastRainQty: 44.9,
maxRainIntensity: 55.1,
maxFloodingAt: "2026-05-18 16:00",
riskStatus: "DANGER",
calculationMethod: "METHOD_2",
pipeSummaries: [
@@ -370,7 +428,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [
areaName: "S 중점관리지역",
sidoName: "서울특별시",
sggName: "강남구",
currentAccumRainQty: 8.4,
forecastRainQty: 23.4,
maxRainIntensity: 34.9,
maxFloodingAt: "2026-05-18 12:00",
riskStatus: "NORMAL",
calculationMethod: "METHOD_1",
pipeSummaries: [
@@ -382,7 +443,10 @@ export const WATER_RISK_RESULT_ROWS: WaterRiskResultRow[] = [
areaName: "T 중점관리지역",
sidoName: "부산광역시",
sggName: "해운대구",
currentAccumRainQty: 16.7,
forecastRainQty: 40.7,
maxRainIntensity: 51.9,
maxFloodingAt: "2026-05-18 15:00",
riskStatus: "DANGER",
calculationMethod: "METHOD_2",
pipeSummaries: [
+28
View File
@@ -3145,6 +3145,34 @@ const Map = () => {
}
await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids);
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_UPLOAD_COMPLETED) {
const rawLayerTypeNames = data.payload?.refreshLayerTypeNames;
let layerTypeNames = ["tb_ms_swage_ppl"];
if (rawLayerTypeNames) {
try {
const parsed = JSON.parse(rawLayerTypeNames);
if (Array.isArray(parsed) && parsed.length > 0) {
layerTypeNames = parsed.map((item) => String(item ?? "").trim()).filter(Boolean);
}
} catch (error) {
console.error("GIS 업로드 레이어 refresh 메시지 파싱 실패:", error);
}
}
const refreshTargetNames = new Set(layerTypeNames);
gisMaps.current?.getAllLayers?.().forEach((layer: any) => {
const layerTypeName = String(layer?.get?.("lyrTyNm") ?? "").trim();
if (!refreshTargetNames.has(layerTypeName)) return;
const source = layer.getSource?.();
source?.updateParams?.({ _t: Date.now() });
source?.refresh?.();
source?.changed?.();
layer.changed?.();
});
}
};
@@ -0,0 +1,223 @@
"use client";
import Feature from "ol/Feature.js";
import Point from "ol/geom/Point.js";
import type OlMap from "ol/Map.js";
import { unByKey } from "ol/Observable.js";
import { transform } from "ol/proj.js";
import VectorLayer from "ol/layer/Vector.js";
import VectorSource from "ol/source/Vector.js";
import CircleStyle from "ol/style/Circle.js";
import Fill from "ol/style/Fill.js";
import Stroke from "ol/style/Stroke.js";
import Style from "ol/style/Style.js";
import { useEffect, useMemo, useRef, useState } from "react";
import { Button, Input } from "@common/components/ui";
import Gis, { useGisContext } from "@common/business_components/ui/map/Gis";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import type { GeoPoint } from "../_lib/types";
type DredgingLocationMapProps = {
value: GeoPoint | null;
onChange?: (point: GeoPoint) => void;
interactive?: boolean;
};
type GisContextValue = {
gisMap: OlMap | null;
};
const markerStyle = new Style({
image: new CircleStyle({
radius: 9,
fill: new Fill({ color: "rgba(220, 38, 38, 0.9)" }),
stroke: new Stroke({ color: "#ffffff", width: 3 }),
}),
});
/**
* 공통 Gis 내부 지도 객체에 클릭 interaction과 위치 marker만 추가한다.
* 공통 지도 코드는 수정하지 않고 이 컴포넌트가 추가한 layer/event만 정리한다.
*/
const LocationInteraction = ({
value,
onChange,
interactive,
}: Required<Pick<DredgingLocationMapProps, "interactive">> &
Omit<DredgingLocationMapProps, "interactive">) => {
const { gisMap } = useGisContext() as GisContextValue;
const layerRef = useRef<VectorLayer<VectorSource> | null>(null);
const [coordinateInput, setCoordinateInput] = useState("");
useEffect(() => {
if (!value) return;
setCoordinateInput(
`${value.latitude.toFixed(7)}, ${value.longitude.toFixed(7)}`,
);
}, [value]);
useEffect(() => {
if (!gisMap) return;
const source = new VectorSource();
const layer = new VectorLayer({ source, style: markerStyle, zIndex: 999 });
layer.set("dredgingLocationLayer", true);
gisMap.addLayer(layer);
layerRef.current = layer;
const clickKey = interactive
? gisMap.on("singleclick", (event) => {
const [longitude, latitude] = transform(
event.coordinate,
GisApp.TO_PROJ,
GisApp.FROM_PROJ,
);
if (
longitude === undefined ||
latitude === undefined ||
!Number.isFinite(longitude) ||
!Number.isFinite(latitude)
) {
return;
}
onChange?.({ longitude, latitude });
})
: null;
return () => {
if (clickKey) unByKey(clickKey);
gisMap.removeLayer(layer);
layerRef.current = null;
};
}, [gisMap, interactive, onChange]);
useEffect(() => {
if (!gisMap || !layerRef.current) return;
const source = layerRef.current.getSource();
source?.clear();
if (!value) return;
const coordinate = transform(
[value.longitude, value.latitude],
GisApp.FROM_PROJ,
GisApp.TO_PROJ,
);
source?.addFeature(new Feature({ geometry: new Point(coordinate) }));
gisMap.getView().animate({ center: coordinate, zoom: 17, duration: 250 });
}, [gisMap, value]);
const applyCoordinateInput = () => {
const [latitudeText, longitudeText] = coordinateInput
.split(",")
.map((item) => item.trim());
const latitude = Number(latitudeText);
const longitude = Number(longitudeText);
if (
!Number.isFinite(latitude) ||
!Number.isFinite(longitude) ||
latitude < -90 ||
latitude > 90 ||
longitude < -180 ||
longitude > 180
) {
alert("좌표는 '위도, 경도' 형식으로 입력해주세요.");
return;
}
onChange?.({ latitude, longitude });
};
return (
<div className="flex min-w-[460px] items-center gap-2 rounded-sm border border-slate-300 bg-white/95 p-2 shadow">
{interactive ? (
<>
<Input
aria-label="준설 위치 좌표 검색"
value={coordinateInput}
onChange={(event) => setCoordinateInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
applyCoordinateInput();
}
}}
placeholder="위도, 경도 입력 또는 지도 클릭"
className="!text-base"
/>
<Button
type="button"
size="sm"
onClick={applyCoordinateInput}
className="bg-blue-600 text-white hover:bg-blue-700"
>
좌표이동
</Button>
</>
) : (
<span className="px-2 text-sm text-slate-700">
등록 위치와 자동 매핑 관로를 확인합니다.
</span>
)}
</div>
);
};
const DredgingLocationMap = ({
value,
onChange,
interactive = true,
}: DredgingLocationMapProps) => {
const gisMaps = useRef<OlMap | null>(null);
const defaultLonLat = useMemo<[number, number]>(
() => (value ? [value.longitude, value.latitude] : [127.75, 36]),
[value],
);
return (
<div className="overflow-hidden rounded-sm border border-slate-300">
<Gis
treeData={[]}
gisMaps={gisMaps}
isSearch={false}
isMiniMap={false}
isOpen={false}
setIsOpen={() => undefined}
featureData={null}
setFeatureData={() => undefined}
isSearchModalOpen={false}
setIsSearchModalOpen={() => undefined}
searchBar={
<LocationInteraction
value={value}
onChange={onChange}
interactive={interactive}
/>
}
defaultZoom={value ? 17 : 7.5}
defaultLonLat={defaultLonLat}
height="h-[420px]"
showZoomLevel={true}
showFloatingSearchButton={false}
treeTogglePosition="top"
/>
<div
className="flex items-center justify-between border-t bg-slate-50 px-4 py-2 text-sm"
data-testid="dredging-map-status"
>
<span>
{interactive
? "지도 클릭 또는 위도·경도 입력으로 준설위치를 지정하세요."
: "등록된 준설위치"}
</span>
<strong className="font-medium text-slate-800">
{value
? `${value.latitude.toFixed(7)}, ${value.longitude.toFixed(7)}`
: "위치 미선택"}
</strong>
</div>
</div>
);
};
export default DredgingLocationMap;
@@ -0,0 +1,805 @@
"use client";
import { FileUpload, UiModal } from "@common/business_components/ui";
import { Button, Input, Label } from "@common/components/ui";
import { useEffect, useRef, useState } from "react";
import {
findNearestPipeMatches,
isDredgingBackendEnabled,
saveDredgingRegistrations,
uploadDredgingPhotos,
} from "../_lib/client";
import { readPhotoMetadata } from "../_lib/exif";
import {
EMPTY_PHOTO_METADATA,
type DredgingDraftGroup,
type DredgingPhotoDraft,
type DredgingSaveGroup,
type GeoPoint,
} from "../_lib/types";
import DredgingLocationMap from "./DredgingLocationMap";
type DredgingRegisterModalProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onSaved: (groups: DredgingSaveGroup[]) => void;
};
const createGroup = (): DredgingDraftGroup => ({
id: crypto.randomUUID(),
registrationMode: "PHOTO",
photos: [],
manualLocation: null,
manualDate: "",
matchedPipe: null,
matchError: null,
isMatching: false,
lowConfidenceConfirmed: false,
});
const getValidPhotoDates = (group: DredgingDraftGroup) =>
Array.from(
new Set(
group.photos
.map((photo) => photo.metadata.dredgingDate)
.filter((date): date is string => Boolean(date)),
),
);
const needsManualDate = (group: DredgingDraftGroup) =>
group.registrationMode === "MANUAL" ||
group.photos.some((photo) => !photo.metadata.dredgingDate);
const needsManualLocation = (group: DredgingDraftGroup) =>
group.registrationMode === "MANUAL" ||
group.photos.some((photo) => !photo.metadata.location);
const getResolvedDate = (group: DredgingDraftGroup) => {
if (group.registrationMode === "MANUAL") return group.manualDate || null;
const dates = getValidPhotoDates(group);
if (needsManualDate(group)) {
if (!group.manualDate) return null;
dates.push(group.manualDate);
}
const uniqueDates = Array.from(new Set(dates));
return uniqueDates.length === 1 ? (uniqueDates[0] ?? null) : null;
};
const getResolvedLocation = (group: DredgingDraftGroup): GeoPoint | null => {
if (group.manualLocation) return group.manualLocation;
return (
group.photos.find((photo) => photo.metadata.location)?.metadata.location ??
null
);
};
const statusLabel = (status: DredgingPhotoDraft["metadata"]["status"]) => {
if (status === "COMPLETE") return "정상";
if (status === "MISSING_DATE") return "촬영일 없음";
if (status === "MISSING_GPS") return "GPS 없음";
if (status === "MISSING_BOTH") return "촬영일·GPS 없음";
return "수동 입력 필요";
};
const confidenceLabel = {
HIGH: "높음",
MEDIUM: "보통",
LOW: "낮음",
} as const;
const formatLocalDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
const DredgingRegisterModal = ({
open,
onOpenChange,
onSaved,
}: DredgingRegisterModalProps) => {
const [groups, setGroups] = useState<DredgingDraftGroup[]>([]);
const groupsRef = useRef<DredgingDraftGroup[]>([]);
const [editingLocationGroupId, setEditingLocationGroupId] = useState<
string | null
>(null);
const [isSaving, setIsSaving] = useState(false);
const [statusMessage, setStatusMessage] = useState("");
const commitGroups = (
updater: (current: DredgingDraftGroup[]) => DredgingDraftGroup[],
) => {
const next = updater(groupsRef.current);
groupsRef.current = next;
setGroups(next);
};
useEffect(() => {
if (!open) return;
const initial = [createGroup()];
groupsRef.current = initial;
setGroups(initial);
setEditingLocationGroupId(null);
setStatusMessage("");
}, [open]);
/**
* 그룹의 모든 사진 위치 또는 수동 좌표를 서버 후보 조회 형식으로 만들고,
* 결과가 하나의 관로로 수렴하는지 확인한다.
*/
const resolvePipeMatch = async (group: DredgingDraftGroup) => {
const locations =
group.manualLocation !== null
? [
{
locationKey: `${group.id}-manual`,
...group.manualLocation,
},
]
: group.photos
.filter((photo) => photo.metadata.location)
.map((photo) => ({
locationKey: photo.key,
...photo.metadata.location!,
}));
const expectedLocationCount =
group.manualLocation !== null
? 1
: group.registrationMode === "MANUAL"
? 0
: group.photos.length;
if (
locations.length === 0 ||
(group.registrationMode === "PHOTO" &&
locations.length !== expectedLocationCount)
) {
commitGroups((current) =>
current.map((item) =>
item.id === group.id
? {
...item,
matchedPipe: null,
matchError: "지도에서 준설위치를 먼저 등록해주세요.",
isMatching: false,
}
: item,
),
);
return;
}
commitGroups((current) =>
current.map((item) =>
item.id === group.id
? { ...item, isMatching: true, matchError: null }
: item,
),
);
try {
const matches = await findNearestPipeMatches(locations);
if (matches.size !== locations.length) {
throw new Error("탐색거리 안에서 관로를 찾지 못한 위치가 있습니다.");
}
const matchedPipes = Array.from(matches.values());
const pipeKeys = new Set(
matchedPipes.map(
(pipe) => `${pipe.mngInstCd.trim()}::${pipe.fcltMngNo}`,
),
);
if (pipeKeys.size !== 1) {
throw new Error(
"사진 위치가 서로 다른 관로로 매핑됩니다. 관로 그룹을 분리해주세요.",
);
}
const matchedPipe = matchedPipes[0] ?? null;
commitGroups((current) =>
current.map((item) =>
item.id === group.id
? {
...item,
matchedPipe,
matchError: matchedPipe
? null
: "최근접 관로를 찾지 못했습니다.",
isMatching: false,
lowConfidenceConfirmed: false,
}
: item,
),
);
} catch (error) {
commitGroups((current) =>
current.map((item) =>
item.id === group.id
? {
...item,
matchedPipe: null,
matchError:
error instanceof Error
? error.message
: "최근접 관로 조회에 실패했습니다.",
isMatching: false,
}
: item,
),
);
}
};
const handleFilesChange = async (
groupId: string,
selectedFiles: File[] | null,
) => {
const files = selectedFiles ?? [];
if (files.length > 5) {
alert("관로별 사진은 최대 5장까지 선택할 수 있습니다.");
return;
}
const checkingPhotos: DredgingPhotoDraft[] = files.map((file) => ({
key: crypto.randomUUID(),
file,
metadata: EMPTY_PHOTO_METADATA,
}));
commitGroups((current) =>
current.map((group) =>
group.id === groupId
? {
...group,
photos: checkingPhotos,
manualLocation: null,
manualDate: "",
matchedPipe: null,
matchError: null,
isMatching: false,
lowConfidenceConfirmed: false,
}
: group,
),
);
const parsedPhotos = await Promise.all(
checkingPhotos.map(async (photo) => ({
...photo,
metadata: await readPhotoMetadata(photo.file),
})),
);
let nextGroup: DredgingDraftGroup | null = null;
commitGroups((current) =>
current.map((group) => {
if (group.id !== groupId) return group;
nextGroup = { ...group, photos: parsedPhotos };
return nextGroup;
}),
);
if (nextGroup) {
const groupToResolve: DredgingDraftGroup = nextGroup;
if (!needsManualLocation(groupToResolve)) {
await resolvePipeMatch(groupToResolve);
} else {
commitGroups((current) =>
current.map((group) =>
group.id === groupId
? {
...group,
matchError: "GPS가 없는 사진은 지도 위치 등록이 필요합니다.",
}
: group,
),
);
}
}
};
const changeRegistrationMode = (
groupId: string,
registrationMode: DredgingDraftGroup["registrationMode"],
) => {
commitGroups((current) =>
current.map((group) =>
group.id === groupId
? {
...group,
registrationMode,
photos: registrationMode === "MANUAL" ? [] : group.photos,
matchedPipe: null,
matchError: null,
manualLocation: null,
manualDate: "",
lowConfidenceConfirmed: false,
}
: group,
),
);
if (registrationMode === "MANUAL") {
setEditingLocationGroupId(groupId);
}
};
const updateManualDate = (groupId: string, manualDate: string) => {
commitGroups((current) =>
current.map((group) =>
group.id === groupId ? { ...group, manualDate } : group,
),
);
};
const updateManualLocation = async (
groupId: string,
manualLocation: GeoPoint,
) => {
const currentGroup = groupsRef.current.find(
(group) => group.id === groupId,
);
if (!currentGroup) return;
const nextGroup = {
...currentGroup,
manualLocation,
matchedPipe: null,
matchError: null,
lowConfidenceConfirmed: false,
};
commitGroups((current) =>
current.map((group) => (group.id === groupId ? nextGroup : group)),
);
await resolvePipeMatch(nextGroup);
};
const validateAndBuildSaveGroups = () => {
const saveGroups: DredgingSaveGroup[] = [];
for (let index = 0; index < groups.length; index += 1) {
const group = groups[index]!;
const label = `관로 ${index + 1}`;
if (group.registrationMode === "PHOTO" && group.photos.length === 0) {
throw new Error(`${label}: 사진을 선택해주세요.`);
}
if (group.photos.length > 5) {
throw new Error(`${label}: 사진은 최대 5장까지 등록할 수 있습니다.`);
}
const validDates = getValidPhotoDates(group);
if (validDates.length > 1) {
throw new Error(
`${label}: 촬영일자가 서로 다릅니다. 날짜별로 관로 그룹을 분리해주세요.`,
);
}
const dredgingDate = getResolvedDate(group);
if (!dredgingDate) {
throw new Error(`${label}: 준설일자를 확인해주세요.`);
}
if (dredgingDate > formatLocalDate(new Date())) {
throw new Error(`${label}: 미래 날짜는 준설일자로 등록할 수 없습니다.`);
}
const location = getResolvedLocation(group);
if (!location) {
throw new Error(`${label}: 지도에서 준설위치를 등록해주세요.`);
}
if (!group.matchedPipe) {
throw new Error(`${label}: 최근접 관로 자동 매핑을 완료해주세요.`);
}
if (
group.matchedPipe.confidence === "LOW" &&
!group.lowConfidenceConfirmed
) {
throw new Error(`${label}: 낮은 신뢰도 관로 후보를 확인해주세요.`);
}
saveGroups.push({
localId: group.id,
registrationMode: group.registrationMode,
dredgingDate,
location,
locationSource: group.manualLocation ? "MANUAL" : "EXIF",
pipe: group.matchedPipe,
photos: group.photos,
lowConfidenceConfirmed: group.lowConfidenceConfirmed,
});
}
return saveGroups;
};
const handleSave = async () => {
try {
const saveGroups = validateAndBuildSaveGroups();
setIsSaving(true);
setStatusMessage(
isDredgingBackendEnabled
? "공통 첨부파일 업로드를 시작합니다."
: "등록 내용을 확인하고 있습니다.",
);
const attachments = await uploadDredgingPhotos(saveGroups);
setStatusMessage(
isDredgingBackendEnabled
? "준설 결과와 관로 매핑을 저장하고 있습니다."
: "준설 결과 등록을 완료했습니다.",
);
await saveDredgingRegistrations(saveGroups, attachments);
onSaved(saveGroups);
alert("준설 결과를 등록했습니다.");
onOpenChange(false);
} catch (error) {
const message =
error instanceof Error
? error.message
: "준설 결과 저장에 실패했습니다.";
setStatusMessage(message);
alert(message);
} finally {
setIsSaving(false);
}
};
const editingGroup =
groups.find((group) => group.id === editingLocationGroupId) ?? null;
const modalBody = (
<div className="max-h-[78vh] overflow-y-auto pr-2">
<div className="mb-5 rounded-sm border border-slate-200 bg-slate-50 p-4 text-sm leading-6 text-slate-700">
<strong className="text-slate-900">준설결과 등록 시 주의사항</strong>
<ol className="ml-5 mt-2 list-decimal">
<li>관로별 사진은 최대 5장까지 등록할 수 있습니다.</li>
<li>사진의 촬영일자가 준설일자가 됩니다.</li>
<li>GPS 또는 촬영일자가 없으면 지도 위치와 준설일자를 보완합니다.</li>
<li>한 그룹의 사진은 같은 관로와 같은 준설일자여야 합니다.</li>
</ol>
</div>
<div className="space-y-4">
{groups.map((group, groupIndex) => {
const resolvedDate = getResolvedDate(group);
const resolvedLocation = getResolvedLocation(group);
const manualDateRequired = needsManualDate(group);
const manualLocationRequired = needsManualLocation(group);
return (
<section
key={group.id}
className="rounded-sm border border-slate-300 bg-white p-4 shadow-sm"
data-testid={`dredging-group-${groupIndex + 1}`}
>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 border-b pb-3">
<div>
<h3 className="text-xl font-semibold text-slate-800">
관로 {groupIndex + 1}
</h3>
<p className="mt-1 text-sm text-slate-500">
사진 묶음 또는 수동 위치 한 건이 하나의 관로·준설일자로
저장됩니다.
</p>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
className={
group.registrationMode === "PHOTO"
? "bg-blue-600 text-white hover:bg-blue-700"
: "border bg-white text-slate-700"
}
onClick={() => changeRegistrationMode(group.id, "PHOTO")}
>
<span aria-hidden="true" className="mr-1">
▣
</span>
사진 메타정보
</Button>
<Button
type="button"
size="sm"
className={
group.registrationMode === "MANUAL"
? "bg-blue-600 text-white hover:bg-blue-700"
: "border bg-white text-slate-700"
}
onClick={() => changeRegistrationMode(group.id, "MANUAL")}
>
<span aria-hidden="true" className="mr-1">
◎
</span>
지도 수동 등록
</Button>
{groups.length > 1 ? (
<Button
type="button"
size="sm"
variant="outline"
aria-label={`관로 ${groupIndex + 1} 삭제`}
onClick={() => {
commitGroups((current) =>
current.filter((item) => item.id !== group.id),
);
if (editingLocationGroupId === group.id) {
setEditingLocationGroupId(null);
}
}}
>
<span aria-hidden="true">×</span>
</Button>
) : null}
</div>
</div>
{group.registrationMode === "PHOTO" ? (
<>
<FileUpload
files={group.photos.map((photo) => photo.file)}
setFiles={(files) => {
void handleFilesChange(group.id, files);
}}
fileTypes=".jpg,.jpeg,.png"
maxFiles={5}
/>
{group.photos.length > 0 ? (
<div className="mt-4 overflow-x-auto rounded-sm border">
<table className="w-full min-w-[850px] text-left text-sm">
<thead className="bg-blue-100 text-slate-800">
<tr>
<th className="px-3 py-2">사진</th>
<th className="px-3 py-2">촬영일시</th>
<th className="px-3 py-2">준설일자</th>
<th className="px-3 py-2">GPS</th>
<th className="px-3 py-2">상태</th>
</tr>
</thead>
<tbody>
{group.photos.map((photo) => (
<tr key={photo.key} className="border-t">
<td className="max-w-[240px] truncate px-3 py-2">
{photo.file.name}
</td>
<td className="px-3 py-2">
{photo.metadata.capturedAt ?? "-"}
</td>
<td className="px-3 py-2">
{photo.metadata.dredgingDate ??
"수동 입력 필요"}
</td>
<td className="px-3 py-2">
{photo.metadata.location
? `${photo.metadata.location.latitude.toFixed(6)}, ${photo.metadata.location.longitude.toFixed(6)}`
: "수동 입력 필요"}
</td>
<td className="px-3 py-2">
<span
className={`rounded-sm px-2 py-1 text-xs font-medium ${
photo.metadata.status === "COMPLETE"
? "bg-emerald-100 text-emerald-800"
: "bg-amber-100 text-amber-800"
}`}
>
{statusLabel(photo.metadata.status)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</>
) : (
<div className="rounded-sm border border-dashed border-blue-300 bg-blue-50 p-5 text-center">
<span
aria-hidden="true"
className="mx-auto mb-2 block text-2xl text-blue-600"
>
◎
</span>
<p className="text-base text-slate-700">
사진 없이 지도에서 준설위치를 지정합니다.
</p>
</div>
)}
<div className="mt-4 grid gap-4 lg:grid-cols-[1fr_1.2fr]">
<div className="space-y-3 rounded-sm border bg-slate-50 p-4">
<div className="flex items-center justify-between gap-3">
<Label className="text-base">준설일자</Label>
{manualDateRequired ? (
<Input
type="date"
value={group.manualDate}
max={formatLocalDate(new Date())}
onChange={(event) =>
updateManualDate(group.id, event.target.value)
}
className="w-[180px]"
/>
) : (
<strong>{resolvedDate ?? "-"}</strong>
)}
</div>
<div className="flex items-center justify-between gap-3">
<Label className="text-base">준설위치</Label>
<span className="text-right text-sm text-slate-700">
{resolvedLocation
? `${resolvedLocation.latitude.toFixed(6)}, ${resolvedLocation.longitude.toFixed(6)}`
: "지도 등록 필요"}
</span>
</div>
<Button
type="button"
size="sm"
className="w-full bg-blue-600 text-white hover:bg-blue-700"
onClick={() => setEditingLocationGroupId(group.id)}
>
<span aria-hidden="true" className="mr-1">
◎
</span>
{manualLocationRequired || !resolvedLocation
? "위치등록"
: "위치 확인/보정"}
</Button>
</div>
<div className="rounded-sm border bg-slate-50 p-4">
<div className="mb-2 flex items-center justify-between">
<Label className="text-base">자동 매핑 관로</Label>
{group.isMatching ? (
<span className="text-sm text-blue-700">탐색 중...</span>
) : null}
</div>
{group.matchedPipe ? (
<dl className="grid grid-cols-[110px_1fr] gap-x-3 gap-y-1 text-sm">
<dt className="text-slate-500">관로번호</dt>
<dd className="font-medium">
{group.matchedPipe.fcltMngNo}
</dd>
<dt className="text-slate-500">관리기관</dt>
<dd>{group.matchedPipe.mngInstCd}</dd>
<dt className="text-slate-500">최근접 거리</dt>
<dd>{group.matchedPipe.distanceMeters.toFixed(2)}m</dd>
<dt className="text-slate-500">신뢰도</dt>
<dd>{confidenceLabel[group.matchedPipe.confidence]}</dd>
</dl>
) : (
<p className="text-sm text-slate-500">
사진 GPS 또는 수동 위치가 입력되면 최근접 관로를 자동으로
찾습니다.
</p>
)}
{group.matchError ? (
<p className="mt-2 rounded-sm bg-rose-50 px-3 py-2 text-sm text-rose-700">
{group.matchError}
</p>
) : null}
{group.matchedPipe?.confidence === "LOW" ? (
<label className="mt-3 flex items-start gap-2 text-sm text-amber-800">
<input
type="checkbox"
checked={group.lowConfidenceConfirmed}
onChange={(event) =>
commitGroups((current) =>
current.map((item) =>
item.id === group.id
? {
...item,
lowConfidenceConfirmed:
event.target.checked,
}
: item,
),
)
}
/>
최근접 거리가 멀어 낮은 신뢰도로 판정된 관로를
확인했습니다.
</label>
) : null}
</div>
</div>
</section>
);
})}
</div>
<Button
type="button"
variant="outline"
className="mt-4 w-full border-dashed border-blue-400 text-blue-700"
onClick={() => {
commitGroups((current) => [...current, createGroup()]);
}}
>
<span aria-hidden="true" className="mr-1">
+
</span>
관로추가
</Button>
{editingGroup ? (
<section className="mt-5 rounded-sm border-2 border-blue-300 bg-blue-50/40 p-4">
<div className="mb-3 flex items-center justify-between">
<div>
<h3 className="text-xl font-semibold">준설위치 등록</h3>
<p className="mt-1 text-sm text-slate-600">
지도 클릭 후 최근접 관로가 자동으로 갱신됩니다.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setEditingLocationGroupId(null)}
>
위치등록 닫기
</Button>
</div>
<DredgingLocationMap
value={
editingGroup.manualLocation ??
editingGroup.photos.find((photo) => photo.metadata.location)
?.metadata.location ??
null
}
onChange={(point) => {
void updateManualLocation(editingGroup.id, point);
}}
/>
</section>
) : null}
<div
className="mt-5 min-h-6 text-sm text-slate-600"
data-testid="dredging-registration-status"
>
{statusMessage}
</div>
</div>
);
return (
<UiModal
open={open}
onOpenChange={(nextOpen) => {
if (!isSaving) onOpenChange(nextOpen);
}}
bodyClass="!max-w-[1200px] w-[92vw]"
headerTitle="준설결과 업로드"
headerTitleClass="text-2xl font-semibold text-slate-900"
body={modalBody}
footerContent={
<div className="flex w-full justify-end gap-2">
<Button
type="button"
variant="outline"
disabled={isSaving}
onClick={() => onOpenChange(false)}
>
취소
</Button>
<Button
type="button"
disabled={isSaving}
onClick={() => {
void handleSave();
}}
className="bg-blue-600 text-white hover:bg-blue-700"
data-testid="dredging-save-button"
>
{isSaving ? "저장 중..." : "저장"}
</Button>
</div>
}
/>
);
};
export default DredgingRegisterModal;
@@ -0,0 +1,202 @@
import { postRawJson } from "@/components/lib/api";
import { handleUpload } from "@/components/lib/utils";
import type { HttpResponse } from "tus-js-client";
import type {
DredgingSaveGroup,
GeoPoint,
MatchConfidence,
PipeMatch,
} from "./types";
type LocationRequest = GeoPoint & {
locationKey: string;
};
type AttachmentReference = {
atflCd: string;
atflId: string;
};
type NearestPipeResponseItem = {
locationKey: string;
selected?: {
fid: string;
mngInstCd: string;
fcltMngNo: string;
pipeDiameterMm?: number;
pipeLengthM?: number;
pipeMaterial?: string;
installationYear?: number | null;
distanceMeters: number;
snappedLongitude: number;
snappedLatitude: number;
confidence: MatchConfidence;
} | null;
};
export const isDredgingBackendEnabled =
process.env.NEXT_PUBLIC_DREDGING_BACKEND_ENABLED === "true";
const mockPipeForLocations = (locations: LocationRequest[]): PipeMatch => {
const first = locations[0];
const longitude = first?.longitude ?? 127.5;
const latitude = first?.latitude ?? 36.5;
const seed = Math.abs(
Math.round(longitude * 100_000) + Math.round(latitude * 100_000),
);
const distanceMeters = Number(((seed % 850) / 100 + 1.2).toFixed(2));
return {
fid: `MOCK-${String(seed % 1_000_000).padStart(6, "0")}`,
mngInstCd: "4173000",
fcltMngNo: `PPL-${String(seed % 1_000_000).padStart(6, "0")}`,
pipeDiameterMm: [450, 600, 700, 900][seed % 4] ?? 450,
pipeLengthM: Number((((seed % 8_000) + 2_000) / 100).toFixed(2)),
pipeMaterial: ["콘크리트", "PVC", "PE"][seed % 3] ?? "콘크리트",
installationYear: 1995 + (seed % 25),
distanceMeters,
snappedLongitude: longitude + 0.00001,
snappedLatitude: latitude - 0.00001,
confidence: distanceMeters <= 10 ? "HIGH" : "MEDIUM",
isMock: true,
};
};
/**
* 위치 목록을 한 번에 보내고 사진별 최근접 관로를 받는다.
* FE 선행 모드에서는 같은 그룹의 상태 전이를 검증할 수 있는 명시적 모의 관로를 반환한다.
*/
export const findNearestPipeMatches = async (
locations: LocationRequest[],
): Promise<Map<string, PipeMatch>> => {
if (locations.length === 0) return new Map();
if (!isDredgingBackendEnabled) {
const mockPipe = mockPipeForLocations(locations);
return new Map(
locations.map((location) => [location.locationKey, mockPipe]),
);
}
const body = await postRawJson("v1/dredging/nearestPipes", { locations });
const items = (body?.data ?? body) as NearestPipeResponseItem[];
const matches = new Map<string, PipeMatch>();
for (const item of items ?? []) {
if (!item.selected) continue;
matches.set(item.locationKey, {
fid: item.selected.fid,
mngInstCd: item.selected.mngInstCd,
fcltMngNo: item.selected.fcltMngNo,
pipeDiameterMm: item.selected.pipeDiameterMm ?? 0,
pipeLengthM: item.selected.pipeLengthM ?? 0,
pipeMaterial: item.selected.pipeMaterial ?? "",
installationYear: item.selected.installationYear ?? null,
distanceMeters: item.selected.distanceMeters,
snappedLongitude: item.selected.snappedLongitude,
snappedLatitude: item.selected.snappedLatitude,
confidence: item.selected.confidence,
isMock: false,
});
}
return matches;
};
const uploadOnePhoto = (file: File): Promise<AttachmentReference> =>
new Promise((resolve, reject) => {
const transfer = new DataTransfer();
transfer.items.add(file);
const timeoutId = window.setTimeout(() => {
reject(new Error(`${file.name} 첨부 업로드 응답 시간이 초과되었습니다.`));
}, 90_000);
const handleResponse = (response: HttpResponse) => {
const fileInfo = response.getHeader("X-File-Info");
if (!fileInfo) return;
try {
const parsed = JSON.parse(fileInfo) as Partial<AttachmentReference>;
if (!parsed.atflCd || !parsed.atflId) {
throw new Error("첨부 식별자가 응답에 없습니다.");
}
window.clearTimeout(timeoutId);
resolve({ atflCd: parsed.atflCd, atflId: parsed.atflId });
} catch (error) {
window.clearTimeout(timeoutId);
reject(error);
}
};
try {
handleUpload(transfer.files, { handleResponse });
} catch (error) {
window.clearTimeout(timeoutId);
reject(error);
}
});
/**
* 공통 attach-service TUS 업로드를 순차 실행한다.
* 등록 API가 아직 없는 FE 선행 모드에서는 고아 첨부 생성을 막기 위해 전송하지 않는다.
*/
export const uploadDredgingPhotos = async (groups: DredgingSaveGroup[]) => {
const references = new Map<string, AttachmentReference>();
if (!isDredgingBackendEnabled) return references;
for (const group of groups) {
for (const photo of group.photos) {
references.set(photo.key, await uploadOnePhoto(photo.file));
}
}
return references;
};
export const saveDredgingRegistrations = async (
groups: DredgingSaveGroup[],
attachments: Map<string, AttachmentReference>,
) => {
if (!isDredgingBackendEnabled) {
return {
mock: true,
registeredCount: groups.length,
clientRequestId: crypto.randomUUID(),
};
}
const clientRequestId = crypto.randomUUID();
return postRawJson("v1/dredging/registrations", {
clientRequestId,
groups: groups.map((group, groupIndex) => ({
groupSn: groupIndex + 1,
registrationMode: group.registrationMode,
dredgingDate: group.dredgingDate,
location: {
source: group.locationSource,
longitude: group.location.longitude,
latitude: group.location.latitude,
},
expectedPipe: {
fid: group.pipe.fid,
mngInstCd: group.pipe.mngInstCd,
fcltMngNo: group.pipe.fcltMngNo,
},
lowConfidenceConfirmed: group.lowConfidenceConfirmed,
photos: group.photos.map((photo) => {
const attachment = attachments.get(photo.key);
if (!attachment) {
throw new Error(`${photo.file.name}의 첨부 식별자가 없습니다.`);
}
return {
...attachment,
originalFileName: photo.file.name,
clientCapturedAt: photo.metadata.capturedAt,
clientLongitude: photo.metadata.location?.longitude ?? null,
clientLatitude: photo.metadata.location?.latitude ?? null,
};
}),
})),
});
};
@@ -0,0 +1,334 @@
import type { ExifStatus, GeoPoint, PhotoMetadata } from "./types";
type ByteOrder = "LE" | "BE";
type IfdEntry = {
type: number;
count: number;
valueOffset: number;
entryOffset: number;
};
const JPEG_START = 0xffd8;
const APP1_MARKER = 0xe1;
const EXIF_IFD_POINTER = 0x8769;
const GPS_IFD_POINTER = 0x8825;
const DATE_TIME = 0x0132;
const DATE_TIME_ORIGINAL = 0x9003;
const DATE_TIME_DIGITIZED = 0x9004;
const typeByteLength = (type: number) => {
if (type === 1 || type === 2 || type === 7) return 1;
if (type === 3) return 2;
if (type === 4 || type === 9) return 4;
if (type === 5 || type === 10) return 8;
return 0;
};
/**
* JPEG EXIF TIFF 블록을 읽는 범위 제한 reader이다.
* 손상된 offset이 파일 범위를 벗어나면 예외를 발생시켜 수동 입력 흐름으로 전환한다.
*/
class TiffReader {
private readonly view: DataView;
private readonly tiffStart: number;
private readonly order: ByteOrder;
constructor(buffer: ArrayBuffer, tiffStart: number) {
this.view = new DataView(buffer);
this.tiffStart = tiffStart;
this.ensureRange(tiffStart, 8);
const byteOrder = this.view.getUint16(tiffStart, false);
if (byteOrder === 0x4949) {
this.order = "LE";
} else if (byteOrder === 0x4d4d) {
this.order = "BE";
} else {
throw new Error("지원하지 않는 TIFF 바이트 순서입니다.");
}
if (this.uint16(2) !== 0x002a) {
throw new Error("유효한 TIFF 헤더가 아닙니다.");
}
}
private get littleEndian() {
return this.order === "LE";
}
private absolute(relativeOffset: number) {
return this.tiffStart + relativeOffset;
}
private ensureRange(absoluteOffset: number, byteLength: number) {
if (
absoluteOffset < 0 ||
byteLength < 0 ||
absoluteOffset + byteLength > this.view.byteLength
) {
throw new Error("EXIF offset이 파일 범위를 벗어났습니다.");
}
}
uint16(relativeOffset: number) {
const absoluteOffset = this.absolute(relativeOffset);
this.ensureRange(absoluteOffset, 2);
return this.view.getUint16(absoluteOffset, this.littleEndian);
}
uint32(relativeOffset: number) {
const absoluteOffset = this.absolute(relativeOffset);
this.ensureRange(absoluteOffset, 4);
return this.view.getUint32(absoluteOffset, this.littleEndian);
}
private readIfd(relativeOffset: number) {
const entryCount = this.uint16(relativeOffset);
const entries = new Map<number, IfdEntry>();
for (let index = 0; index < entryCount; index += 1) {
const entryOffset = relativeOffset + 2 + index * 12;
const tag = this.uint16(entryOffset);
const type = this.uint16(entryOffset + 2);
const count = this.uint32(entryOffset + 4);
const valueOffset = this.uint32(entryOffset + 8);
entries.set(tag, { type, count, valueOffset, entryOffset });
}
return entries;
}
private dataRelativeOffset(entry: IfdEntry) {
const byteLength = typeByteLength(entry.type) * entry.count;
if (byteLength <= 4) return entry.entryOffset + 8;
return entry.valueOffset;
}
private ascii(entry: IfdEntry) {
const relativeOffset = this.dataRelativeOffset(entry);
const absoluteOffset = this.absolute(relativeOffset);
const byteLength = typeByteLength(entry.type) * entry.count;
this.ensureRange(absoluteOffset, byteLength);
const bytes = new Uint8Array(this.view.buffer, absoluteOffset, byteLength);
return new TextDecoder("ascii").decode(bytes).replace(/\0+$/g, "").trim();
}
private rational(relativeOffset: number) {
const numerator = this.uint32(relativeOffset);
const denominator = this.uint32(relativeOffset + 4);
if (denominator === 0) return null;
return numerator / denominator;
}
private rationals(entry: IfdEntry) {
const relativeOffset = this.dataRelativeOffset(entry);
const values: number[] = [];
for (let index = 0; index < entry.count; index += 1) {
const value = this.rational(relativeOffset + index * 8);
if (value === null) return null;
values.push(value);
}
return values;
}
private pointer(entry: IfdEntry | undefined) {
if (!entry || entry.type !== 4 || entry.count !== 1) return null;
return entry.valueOffset;
}
private parseDate(value: string | null) {
if (!value) return null;
const match = value.match(
/^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/,
);
if (!match) return null;
const [, year, month, day, hour, minute, second] = match;
if (!year || !month || !day || !hour || !minute || !second) return null;
const monthNumber = Number(month);
const dayNumber = Number(day);
const hourNumber = Number(hour);
const minuteNumber = Number(minute);
const secondNumber = Number(second);
if (
monthNumber < 1 ||
monthNumber > 12 ||
dayNumber < 1 ||
dayNumber > 31 ||
hourNumber > 23 ||
minuteNumber > 59 ||
secondNumber > 59
) {
return null;
}
return {
capturedAt: `${year}-${month}-${day}T${hour}:${minute}:${second}`,
dredgingDate: `${year}-${month}-${day}`,
};
}
private parseGps(entries: Map<number, IfdEntry>): GeoPoint | null {
const latitudeRefEntry = entries.get(0x0001);
const latitudeEntry = entries.get(0x0002);
const longitudeRefEntry = entries.get(0x0003);
const longitudeEntry = entries.get(0x0004);
if (
!latitudeRefEntry ||
!latitudeEntry ||
!longitudeRefEntry ||
!longitudeEntry
) {
return null;
}
const latitudeRef = this.ascii(latitudeRefEntry).toUpperCase();
const longitudeRef = this.ascii(longitudeRefEntry).toUpperCase();
const latitudeDms = this.rationals(latitudeEntry);
const longitudeDms = this.rationals(longitudeEntry);
if (
!latitudeDms ||
latitudeDms.length < 3 ||
!longitudeDms ||
longitudeDms.length < 3
) {
return null;
}
const toDecimal = (values: number[]) =>
(values[0] ?? 0) + (values[1] ?? 0) / 60 + (values[2] ?? 0) / 3600;
let latitude = toDecimal(latitudeDms);
let longitude = toDecimal(longitudeDms);
if (latitudeRef === "S") latitude *= -1;
if (longitudeRef === "W") longitude *= -1;
if (
!Number.isFinite(latitude) ||
!Number.isFinite(longitude) ||
latitude < -90 ||
latitude > 90 ||
longitude < -180 ||
longitude > 180
) {
return null;
}
return { latitude, longitude };
}
parse() {
const ifd0Offset = this.uint32(4);
const ifd0 = this.readIfd(ifd0Offset);
const exifIfdOffset = this.pointer(ifd0.get(EXIF_IFD_POINTER));
const exifIfd = exifIfdOffset === null ? null : this.readIfd(exifIfdOffset);
const gpsIfdOffset = this.pointer(ifd0.get(GPS_IFD_POINTER));
const gpsIfd = gpsIfdOffset === null ? null : this.readIfd(gpsIfdOffset);
const dateValue =
(exifIfd?.get(DATE_TIME_ORIGINAL)
? this.ascii(exifIfd.get(DATE_TIME_ORIGINAL)!)
: null) ??
(exifIfd?.get(DATE_TIME_DIGITIZED)
? this.ascii(exifIfd.get(DATE_TIME_DIGITIZED)!)
: null) ??
(ifd0.get(DATE_TIME) ? this.ascii(ifd0.get(DATE_TIME)!) : null);
return {
date: this.parseDate(dateValue),
location: gpsIfd ? this.parseGps(gpsIfd) : null,
};
}
}
const toMetadata = (
capturedAt: string | null,
dredgingDate: string | null,
location: GeoPoint | null,
unsupported = false,
): PhotoMetadata => {
let status: ExifStatus;
let message: string;
if (unsupported) {
status = "UNSUPPORTED";
message = "JPEG EXIF가 없어 준설일자와 위치를 직접 입력해야 합니다.";
} else if (dredgingDate && location) {
status = "COMPLETE";
message = "촬영일자와 GPS를 확인했습니다.";
} else if (!dredgingDate && !location) {
status = "MISSING_BOTH";
message = "촬영일자와 GPS가 없어 수동 입력이 필요합니다.";
} else if (!dredgingDate) {
status = "MISSING_DATE";
message = "촬영일자가 없어 준설일자를 직접 입력해야 합니다.";
} else {
status = "MISSING_GPS";
message = "GPS가 없어 지도에서 준설위치를 등록해야 합니다.";
}
return { capturedAt, dredgingDate, location, status, message };
};
/**
* 브라우저에서 즉시 보여 줄 JPEG EXIF 미리보기를 만든다.
* 서버는 저장 시 원본 첨부파일을 기준으로 동일 정보를 다시 검증해야 한다.
*/
export const readPhotoMetadata = async (file: File): Promise<PhotoMetadata> => {
if (file.type !== "image/jpeg" && !/\.jpe?g$/i.test(file.name)) {
return toMetadata(null, null, null, true);
}
try {
const buffer = await file.arrayBuffer();
const view = new DataView(buffer);
if (view.byteLength < 4 || view.getUint16(0, false) !== JPEG_START) {
return toMetadata(null, null, null, true);
}
let offset = 2;
while (offset + 4 <= view.byteLength) {
if (view.getUint8(offset) !== 0xff) break;
const marker = view.getUint8(offset + 1);
if (marker === 0xda || marker === 0xd9) break;
const segmentLength = view.getUint16(offset + 2, false);
if (segmentLength < 2 || offset + 2 + segmentLength > view.byteLength) {
break;
}
if (marker === APP1_MARKER) {
const exifStart = offset + 4;
const exifSignature =
exifStart + 6 <= view.byteLength
? String.fromCharCode(
view.getUint8(exifStart),
view.getUint8(exifStart + 1),
view.getUint8(exifStart + 2),
view.getUint8(exifStart + 3),
)
: "";
if (exifSignature === "Exif") {
const parsed = new TiffReader(buffer, exifStart + 6).parse();
return toMetadata(
parsed.date?.capturedAt ?? null,
parsed.date?.dredgingDate ?? null,
parsed.location,
);
}
}
offset += 2 + segmentLength;
}
} catch (error) {
console.warn("준설 사진 EXIF 분석 실패", error);
}
return toMetadata(null, null, null, false);
};
@@ -0,0 +1,100 @@
export type DredgingViewType = "MUNICIPALITY" | "DIAMETER" | "ELAPSED_YEAR";
export type DredgingRegistrationMode = "PHOTO" | "MANUAL";
export type DredgingLocationSource = "EXIF" | "MANUAL";
export type ExifStatus =
| "COMPLETE"
| "MISSING_DATE"
| "MISSING_GPS"
| "MISSING_BOTH"
| "UNSUPPORTED";
export type MatchConfidence = "HIGH" | "MEDIUM" | "LOW";
export type GeoPoint = {
longitude: number;
latitude: number;
};
export type PhotoMetadata = {
capturedAt: string | null;
dredgingDate: string | null;
location: GeoPoint | null;
status: ExifStatus;
message: string;
};
export type DredgingPhotoDraft = {
key: string;
file: File;
metadata: PhotoMetadata;
};
export type PipeMatch = {
fid: string;
mngInstCd: string;
fcltMngNo: string;
pipeDiameterMm: number;
pipeLengthM: number;
pipeMaterial: string;
installationYear: number | null;
distanceMeters: number;
snappedLongitude: number;
snappedLatitude: number;
confidence: MatchConfidence;
isMock: boolean;
};
export type DredgingDraftGroup = {
id: string;
registrationMode: DredgingRegistrationMode;
photos: DredgingPhotoDraft[];
manualLocation: GeoPoint | null;
manualDate: string;
matchedPipe: PipeMatch | null;
matchError: string | null;
isMatching: boolean;
lowConfidenceConfirmed: boolean;
};
export type DredgingRecord = {
id: string;
ctpv: string;
sgg: string;
treatmentZone: string;
fcltMngNo: string;
pipeDiameterMm: number;
pipeLengthM: number;
pipeMaterial: string;
installationYear: number | null;
dredgingDate: string;
photoCount: number;
registeredDate: string;
longitude: number;
latitude: number;
registrationSource: DredgingLocationSource;
matchDistanceMeters: number;
matchConfidence: MatchConfidence;
isMock: boolean;
};
export type DredgingSaveGroup = {
localId: string;
registrationMode: DredgingRegistrationMode;
dredgingDate: string;
location: GeoPoint;
locationSource: DredgingLocationSource;
pipe: PipeMatch;
photos: DredgingPhotoDraft[];
lowConfidenceConfirmed: boolean;
};
export const EMPTY_PHOTO_METADATA: PhotoMetadata = {
capturedAt: null,
dredgingDate: null,
location: null,
status: "UNSUPPORTED",
message: "메타정보를 확인하고 있습니다.",
};
@@ -0,0 +1,564 @@
/**
* ➡️ 경로 : 유지관리 정보 - 준설관리
*
* - SFR-010-012 화면설계서 50~55쪽을 기준으로 한 FE 선행 구현
* - 사진 EXIF 촬영일/GPS, 지도 수동 위치, 최근접 관로 자동 매핑 흐름 제공
* - 지자체별/관경별/경과년도별 조회와 CSV 다운로드 제공
*/
"use client";
import {
SearchDiv,
SearchFormLeft,
SearchFormRight,
UiModal,
UiTable,
} from "@common/business_components/ui";
import ListDiv from "@common/business_components/ui/list_div";
import { Button, Input, Label } from "@common/components/ui";
import { useMemo, useState } from "react";
import DredgingLocationMap from "./_components/DredgingLocationMap";
import DredgingRegisterModal from "./_components/DredgingRegisterModal";
import { isDredgingBackendEnabled } from "./_lib/client";
import type {
DredgingRecord,
DredgingSaveGroup,
DredgingViewType,
} from "./_lib/types";
const PAGE_SIZE = 10;
const currentYear = new Date().getFullYear();
const formatLocalDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
const initialRecords: DredgingRecord[] = [
{
id: "mock-dredging-1",
ctpv: "경기도",
sgg: "양평군",
treatmentZone: "양평처리구역",
fcltMngNo: "PPL-DEMO-001",
pipeDiameterMm: 450,
pipeLengthM: 82.4,
pipeMaterial: "콘크리트",
installationYear: 2004,
dredgingDate: `${currentYear}-05-14`,
photoCount: 3,
registeredDate: `${currentYear}-05-15`,
longitude: 127.4875,
latitude: 37.4916,
registrationSource: "EXIF",
matchDistanceMeters: 4.28,
matchConfidence: "HIGH",
isMock: true,
},
{
id: "mock-dredging-2",
ctpv: "서울특별시",
sgg: "구로구",
treatmentZone: "서남처리구역",
fcltMngNo: "PPL-DEMO-002",
pipeDiameterMm: 900,
pipeLengthM: 116.7,
pipeMaterial: "PVC",
installationYear: 1992,
dredgingDate: `${currentYear - 1}-11-08`,
photoCount: 0,
registeredDate: `${currentYear - 1}-11-09`,
longitude: 126.8874,
latitude: 37.4954,
registrationSource: "MANUAL",
matchDistanceMeters: 8.15,
matchConfidence: "HIGH",
isMock: true,
},
];
type TableRow = Record<string, unknown> & {
recordIds: string[];
};
const municipalityHeadlist = [
{ id: "rowNumber", title: "순번" },
{ id: "ctpv", title: "시도" },
{ id: "sgg", title: "시군구" },
{ id: "treatmentZone", title: "처리구역" },
{ id: "fcltMngNo", title: "관로 번호" },
{ id: "pipeDiameterMm", title: "관경(mm)", align: "right" },
{ id: "pipeLengthM", title: "관로연장(m)", align: "right" },
{ id: "pipeMaterial", title: "관재질" },
{ id: "installationYear", title: "매설년도" },
{ id: "elapsedYears", title: "경과년도" },
{ id: "dredgingDate", title: "준설일자" },
{ id: "photoCount", title: "등록사진건수", align: "right" },
{ id: "registeredDate", title: "등록일자" },
];
const diameterHeadlist = [
{ id: "ctpv", title: "시도" },
{ id: "totalLengthKm", title: "관로연장(km)", align: "right" },
{ id: "diameter450", title: "450 이하", align: "right" },
{ id: "diameter600", title: "450~600", align: "right" },
{ id: "diameter700", title: "700", align: "right" },
{ id: "diameter900", title: "800~900", align: "right" },
{ id: "diameter1000", title: "1000", align: "right" },
{ id: "diameter1200", title: "1200", align: "right" },
{ id: "diameter1500", title: "1500 이상", align: "right" },
];
const elapsedYearHeadlist = [
{ id: "ctpv", title: "시도" },
{ id: "totalLengthKm", title: "관로연장(km)", align: "right" },
{ id: "under10", title: "10년 미만", align: "right" },
{ id: "year10To20", title: "10년~20년", align: "right" },
{ id: "year20To30", title: "20년~30년", align: "right" },
{ id: "over30", title: "30년 이상", align: "right" },
{ id: "unknown", title: "확인불가", align: "right" },
];
const roundKm = (meters: number) => Number((meters / 1000).toFixed(3));
const groupByCtpv = (records: DredgingRecord[]) => {
const groups = new Map<string, DredgingRecord[]>();
for (const record of records) {
groups.set(record.ctpv, [...(groups.get(record.ctpv) ?? []), record]);
}
return groups;
};
const buildDiameterRows = (records: DredgingRecord[]): TableRow[] =>
Array.from(groupByCtpv(records), ([ctpv, items]) => {
const sumLength = (predicate: (record: DredgingRecord) => boolean) =>
roundKm(
items
.filter(predicate)
.reduce((sum, record) => sum + record.pipeLengthM, 0),
);
return {
ctpv,
totalLengthKm: roundKm(
items.reduce((sum, record) => sum + record.pipeLengthM, 0),
),
diameter450: sumLength((record) => record.pipeDiameterMm <= 450),
diameter600: sumLength(
(record) => record.pipeDiameterMm > 450 && record.pipeDiameterMm <= 600,
),
diameter700: sumLength(
(record) => record.pipeDiameterMm > 600 && record.pipeDiameterMm <= 700,
),
diameter900: sumLength(
(record) => record.pipeDiameterMm > 700 && record.pipeDiameterMm <= 900,
),
diameter1000: sumLength(
(record) =>
record.pipeDiameterMm > 900 && record.pipeDiameterMm <= 1000,
),
diameter1200: sumLength(
(record) =>
record.pipeDiameterMm > 1000 && record.pipeDiameterMm <= 1200,
),
diameter1500: sumLength((record) => record.pipeDiameterMm >= 1500),
recordIds: items.map((record) => record.id),
};
});
const buildElapsedYearRows = (records: DredgingRecord[]): TableRow[] =>
Array.from(groupByCtpv(records), ([ctpv, items]) => {
const sumLength = (predicate: (record: DredgingRecord) => boolean) =>
roundKm(
items
.filter(predicate)
.reduce((sum, record) => sum + record.pipeLengthM, 0),
);
const elapsedYears = (record: DredgingRecord) =>
record.installationYear === null
? null
: currentYear - record.installationYear;
return {
ctpv,
totalLengthKm: roundKm(
items.reduce((sum, record) => sum + record.pipeLengthM, 0),
),
under10: sumLength((record) => {
const elapsed = elapsedYears(record);
return elapsed !== null && elapsed < 10;
}),
year10To20: sumLength((record) => {
const elapsed = elapsedYears(record);
return elapsed !== null && elapsed >= 10 && elapsed < 20;
}),
year20To30: sumLength((record) => {
const elapsed = elapsedYears(record);
return elapsed !== null && elapsed >= 20 && elapsed < 30;
}),
over30: sumLength((record) => {
const elapsed = elapsedYears(record);
return elapsed !== null && elapsed >= 30;
}),
unknown: sumLength((record) => elapsedYears(record) === null),
recordIds: items.map((record) => record.id),
};
});
const Page = () => {
const today = formatLocalDate(new Date());
const [fromDate, setFromDate] = useState(`${currentYear}-01-01`);
const [toDate, setToDate] = useState(today);
const [ctpv, setCtpv] = useState("전체");
const [sgg, setSgg] = useState("전체");
const [viewType, setViewType] = useState<DredgingViewType>("MUNICIPALITY");
const [records, setRecords] = useState<DredgingRecord[]>(initialRecords);
const [isRegisterOpen, setIsRegisterOpen] = useState(false);
const [selectedRecordId, setSelectedRecordId] = useState<string | null>(null);
const [isMapOpen, setIsMapOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [searchVersion, setSearchVersion] = useState(0);
const filteredRecords = useMemo(
() =>
records.filter(
(record) =>
record.dredgingDate >= fromDate &&
record.dredgingDate <= toDate &&
(ctpv === "전체" || record.ctpv === ctpv) &&
(sgg === "전체" || record.sgg === sgg),
),
[records, fromDate, toDate, ctpv, sgg, searchVersion],
);
const tableRows = useMemo<TableRow[]>(() => {
if (viewType === "DIAMETER") return buildDiameterRows(filteredRecords);
if (viewType === "ELAPSED_YEAR")
return buildElapsedYearRows(filteredRecords);
return filteredRecords.map((record, index) => ({
rowNumber: index + 1,
...record,
pipeLengthM: Number(record.pipeLengthM.toFixed(2)),
installationYear: record.installationYear ?? "확인불가",
elapsedYears:
record.installationYear === null
? "확인불가"
: currentYear - record.installationYear,
recordIds: [record.id],
}));
}, [filteredRecords, viewType]);
const pageRows = useMemo(
() =>
tableRows.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE),
[tableRows, currentPage],
);
const selectedRecord =
records.find((record) => record.id === selectedRecordId) ?? null;
const ctpvOptions = useMemo(
() => [
"전체",
...Array.from(new Set(records.map((record) => record.ctpv))),
],
[records],
);
const sggOptions = useMemo(
() => [
"전체",
...Array.from(
new Set(
records
.filter((record) => ctpv === "전체" || record.ctpv === ctpv)
.map((record) => record.sgg),
),
),
],
[records, ctpv],
);
const headlist =
viewType === "MUNICIPALITY"
? municipalityHeadlist
: viewType === "DIAMETER"
? diameterHeadlist
: elapsedYearHeadlist;
const title =
viewType === "MUNICIPALITY"
? "지자체별 준설현황"
: viewType === "DIAMETER"
? "관경별 준설현황"
: "경과년도별 준설현황";
const handleSearch = () => {
if (fromDate > toDate) {
alert("조회 시작일은 종료일보다 늦을 수 없습니다.");
return;
}
setCurrentPage(1);
setSelectedRecordId(null);
setSearchVersion((version) => version + 1);
};
const handleDownload = async () => {
try {
const visibleHeads = headlist.filter((head) => head.id !== "recordIds");
const excelRows = [
visibleHeads.map((head) => head.title),
...tableRows.map((row) =>
visibleHeads.map((head) => row[head.id] ?? ""),
),
];
const { utils, writeFileXLSX } = await import("xlsx");
const worksheet = utils.aoa_to_sheet(excelRows);
worksheet["!cols"] = visibleHeads.map((head) => ({
wch: Math.max(head.title.length * 2, 12),
}));
const workbook = utils.book_new();
utils.book_append_sheet(workbook, worksheet, title.slice(0, 31));
writeFileXLSX(
workbook,
`준설관리_${viewType}_${fromDate}_${toDate}.xlsx`,
{ compression: true },
);
} catch (error) {
console.error("준설현황 엑셀 다운로드 실패", error);
alert("엑셀 파일을 생성하지 못했습니다.");
}
};
const handleSaved = (savedGroups: DredgingSaveGroup[]) => {
const registeredDate = formatLocalDate(new Date());
const addedRecords = savedGroups.map<DredgingRecord>((group, index) => ({
id: `local-${crypto.randomUUID()}`,
ctpv: ctpv === "전체" ? "경기도" : ctpv,
sgg: sgg === "전체" ? "양평군" : sgg,
treatmentZone: "양평처리구역",
fcltMngNo: group.pipe.fcltMngNo,
pipeDiameterMm: group.pipe.pipeDiameterMm,
pipeLengthM: group.pipe.pipeLengthM,
pipeMaterial: group.pipe.pipeMaterial,
installationYear: group.pipe.installationYear,
dredgingDate: group.dredgingDate,
photoCount: group.photos.length,
registeredDate,
longitude: group.location.longitude,
latitude: group.location.latitude,
registrationSource: group.locationSource,
matchDistanceMeters: group.pipe.distanceMeters,
matchConfidence: group.pipe.confidence,
isMock: group.pipe.isMock || !isDredgingBackendEnabled,
}));
setRecords((current) => [...addedRecords, ...current]);
setCurrentPage(1);
};
return (
<>
<div className="mb-4">
<h1 className="text-3xl font-semibold text-slate-900">하수관로 준설</h1>
</div>
<SearchDiv>
<SearchFormLeft leftClassName="m-4 min-w-0 flex-1">
<div className="flex w-full flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap text-lg text-gray-600">
준설일자
</Label>
<Input
type="date"
value={fromDate}
max={today}
onChange={(event) => setFromDate(event.target.value)}
className="w-[155px]"
/>
<span>~</span>
<Input
type="date"
value={toDate}
max={today}
onChange={(event) => setToDate(event.target.value)}
className="w-[155px]"
/>
</div>
<label className="flex items-center gap-2 whitespace-nowrap text-lg text-gray-600">
조회구분
<select
value={viewType}
onChange={(event) => {
setViewType(event.target.value as DredgingViewType);
setCurrentPage(1);
setSelectedRecordId(null);
}}
className="h-10 min-w-[145px] rounded-sm border border-input bg-background px-3 text-base text-slate-900"
>
<option value="MUNICIPALITY">지자체별</option>
<option value="DIAMETER">관경별</option>
<option value="ELAPSED_YEAR">경과년도별</option>
</select>
</label>
<label className="flex items-center gap-2 whitespace-nowrap text-lg text-gray-600">
시도
<select
value={ctpv}
onChange={(event) => {
setCtpv(event.target.value);
setSgg("전체");
}}
className="h-10 min-w-[135px] rounded-sm border border-input bg-background px-3 text-base text-slate-900"
>
{ctpvOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2 whitespace-nowrap text-lg text-gray-600">
시군구
<select
value={sgg}
onChange={(event) => setSgg(event.target.value)}
className="h-10 min-w-[125px] rounded-sm border border-input bg-background px-3 text-base text-slate-900"
>
{sggOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
</div>
</SearchFormLeft>
<SearchFormRight className="mx-4 flex shrink-0 items-center justify-center">
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={handleSearch}
className="bg-blue-600 text-white hover:bg-blue-700"
>
<span aria-hidden="true" className="mr-1">
⌕
</span>
조회
</Button>
<Button
size="sm"
onClick={() => {
void handleDownload();
}}
className="green text-white"
>
<span aria-hidden="true" className="mr-1">
↓
</span>
다운로드
</Button>
<Button
size="sm"
onClick={() => setIsRegisterOpen(true)}
className="bg-blue-600 text-white hover:bg-blue-700"
data-testid="dredging-register-button"
>
<span aria-hidden="true" className="mr-1">
+
</span>
등록
</Button>
<Button
size="sm"
variant="outline"
disabled={!selectedRecord}
onClick={() => setIsMapOpen(true)}
>
<span aria-hidden="true" className="mr-1">
◎
</span>
지도보기
</Button>
</div>
</SearchFormRight>
</SearchDiv>
<ListDiv className="mt-4">
<UiTable
headName={title}
tableData={pageRows}
headlist={headlist}
pageSize={PAGE_SIZE}
total={tableRows.length}
curPage={currentPage}
onPageChange={setCurrentPage}
cellClick={(index) => {
const recordId =
index === undefined ? undefined : pageRows[index]?.recordIds[0];
setSelectedRecordId(recordId ?? null);
}}
resetSelection={selectedRecordId === null}
widthFixed={false}
/>
</ListDiv>
<DredgingRegisterModal
open={isRegisterOpen}
onOpenChange={setIsRegisterOpen}
onSaved={handleSaved}
/>
<UiModal
open={isMapOpen}
onOpenChange={setIsMapOpen}
bodyClass="!max-w-[1000px] w-[88vw]"
headerTitle="준설위치 및 매핑 관로"
headerTitleClass="text-2xl font-semibold text-slate-900"
noFooterYn="Y"
body={
selectedRecord ? (
<div className="space-y-4">
<div className="grid gap-3 rounded-sm border bg-slate-50 p-4 text-sm md:grid-cols-4">
<div>
<span className="block text-slate-500">관로번호</span>
<strong>{selectedRecord.fcltMngNo}</strong>
</div>
<div>
<span className="block text-slate-500">준설일자</span>
<strong>{selectedRecord.dredgingDate}</strong>
</div>
<div>
<span className="block text-slate-500">위치출처</span>
<strong>{selectedRecord.registrationSource}</strong>
</div>
<div>
<span className="block text-slate-500">최근접 거리</span>
<strong>
{selectedRecord.matchDistanceMeters.toFixed(2)}m ·{" "}
{selectedRecord.matchConfidence}
</strong>
</div>
</div>
<DredgingLocationMap
value={{
longitude: selectedRecord.longitude,
latitude: selectedRecord.latitude,
}}
interactive={false}
/>
</div>
) : null
}
/>
</>
);
};
export default Page;
+25
View File
@@ -357,6 +357,31 @@ export const getBlobData = async (url: string): Promise<Blob> => {
return res.blob();
};
export const getBlobDataWithHeaders = async (
url: string,
): Promise<{ blob: Blob; fileName?: string; headers: Headers }> => {
const res = await fetch(resolveApiUrl(url), {
method: "GET",
headers: withSiteIdHeaders(),
});
if (res.status === 401) {
handleUnauthorizedResponse();
}
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(
`Failed to fetch blob: ${res.status} ${res.statusText}${errorText ? ` - ${errorText.slice(0, 200)}` : ""} (${url})`,
);
}
return {
blob: await res.blob(),
fileName: parseDownloadFileName(res.headers.get("content-disposition")),
headers: res.headers,
};
};
// DC API 호출
export const getDcData = async (url: string, resType: string = "json"): Promise<any> => {
const res = await fetch('/dc-service' + url, {
@@ -18,7 +18,7 @@ type LayerComponentProps = {
};
type LayerContentMap = {
[key: string]: {
component?: React.FC<LayerComponentProps>;
component?: React.ComponentType<any>;
props?: any;
};
};
@@ -43,7 +43,7 @@ export const getLayerContentComponent = (layerNm: string): React.FC<LayerCompone
// component가 없으면 null 반환
return () => null;
}
return component.component
return component.component as React.FC<LayerComponentProps>
};
// 사용자 입력 필드 (보수 및 갱신, 청소 및 준설, 점검 및 진단)
export const handleInputChange = (section: string,
@@ -65,4 +65,4 @@ export const handleInputChange = (section: string,
updated[index][field as keyof typeof updated[number]] = value;
}
target.setData(updated);
};
};
@@ -20,7 +20,7 @@ import { BaseLayout } from "@common/components";
import type { MenuItem } from "@/entities/menu";
import { useMenuStore } from "@/shared/lib";
import { PageLayout } from "@/shared/ui/page-layout";
import useStore from '../store/useStore';
import useStore from '../../store/useStore';
export default function WithBaseLayout({
children,
@@ -0,0 +1,51 @@
# SFR-010-012 준설관리 FE 선행 검증결과
## 1. 검증 환경
- 일자: 2026-07-30
- URL: `http://localhost:3002/oper/dredging`
- 브라우저: Codex 인앱 브라우저
- 모드: 로컬 상태 검증, 개발단계 안내 UI 미노출
- 백엔드·DB 변경: 없음
## 2. 결과
| 항목 | 결과 | 근거 |
| --- | --- | --- |
| 직접 경로 진입 | 통과 | 로그인 후 `/oper/dredging` 제목과 목록 렌더링 |
| 조회 유형 전환 | 통과 | 지자체별 → 관경별 → 경과년도별 제목·컬럼 전환 |
| 수동 위치 등록 | 통과 | 준설일자와 `37.4916, 127.4875` 입력 |
| 최근접 관로 자동 매핑 | 통과 | 모의 관로 `PPL-497910`, 거리 `3.80m`, 신뢰도 높음 |
| JPEG EXIF 분석 | 통과 | `2026-07-29T10:20:30`, `2026-07-29`, `37.4916, 127.4875` 표시 |
| 사진 기반 모의 저장 | 통과 | 저장 알림 후 모달 닫힘, 목록 총 2건, 사진 1건 반영 |
| 등록 결과 지도보기 | 통과 | 선택 행 지도보기 활성화, 관로·거리 표시 |
| 임시 안내 문구 제거 | 통과 | 목록·등록창에서 선행 구현, 모의 데이터, 모의 매핑, 고아 첨부 안내 미노출 |
| 브라우저 콘솔 | 통과 | 최종 경고·오류 0건 |
| 신규 경로 타입 검사 | 통과 | 전용 임시 tsconfig 기준 `tsc --noEmit`, 종료코드 0 |
| 엑셀 다운로드 | 정적 통과/브라우저 재확인 필요 | 최종 XLSX 생성 코드는 타입 검사 통과. 브라우저 다운로드 이벤트는 최종 변경 전 자동화에서 포착하지 못함 |
| ESLint | 환경 보류 | 설치된 lock/node_modules 불일치로 `eslint-plugin-react-hooks` 해석 실패 |
| 전체 Next build | 미수행 | 실행 중인 `next dev -p 3002` 보호 기준에 따라 생략 |
## 3. 발견 및 보정
- 날짜 입력 직후 좌표를 갱신할 때 그룹 참조가 늦게 갱신될 수 있어 `groupsRef`를 상태 변경 시 즉시 동기화하도록 수정했다.
- 조회 영역이 공통 `SearchFormLeft`의 기본 12열 grid 한 칸에 들어가 세로로 압축되는 문제를 전용 `leftClassName`으로 보정했다.
- 다운로드는 화면설계·상세설계와 맞춰 CSV가 아니라 `.xlsx`로 최종 구현했다.
- 사용자 요청에 따라 개발단계·모의 데이터 안내 배너와 모의 매핑 표식을 제거했다.
## 4. 증적
- `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/001-준설관리-목록화면.png`
- `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/002-수동등록-자동관로매핑.png`
- `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/003-사진EXIF-자동등록.png`
- `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/004-사진등록-목록반영.png`
- `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/준설사진-EXIF-테스트.jpg`
`003`, `004` 화면은 최초 FE 흐름 검증 당시 증적이며, 이후 개발단계 안내 문구 제거는 브라우저 DOM에서 별도 재검증했다.
## 5. 후속 검증
- 백엔드 구현 후 공통 첨부 TUS 응답과 준설 등록 트랜잭션을 실제로 확인한다.
- 실제 MS 관로 geometry로 거리·스냅 좌표·동률 처리·탐색 반경을 검증한다.
- 서버 원본 EXIF 재분석 결과와 브라우저 분석 결과 불일치 케이스를 검증한다.
- 실제 XLSX 다운로드 파일명, 시트명, 행·열 값을 브라우저에서 다시 확인한다.
@@ -0,0 +1,49 @@
# SFR-010-012 준설관리 프론트엔드 작업카드
## 1. 목적
- 백엔드 구현 전에 준설관리 화면과 상태 전이를 FE 선행 구현한다.
- 공통 첨부 업로드, EXIF 분석, 지도 수동 위치, 자동 관로 매핑 payload를 검증 가능하게 만든다.
## 2. 구현 경로
- `apps/sn/app/(with-base-layout)/oper/dredging/**`
## 3. 수정 금지
- 진행 중인 `apps/sn/app/(map-layout)/map/page.tsx`
- 진행 중인 `apps/sn/app/components/lib/api/index.ts`
- `packages/common/components/**`
- 다른 앱
## 4. 화면 범위
- 지자체별/관경별/경과년도별 조회 전환
- 조회, 다운로드, 등록, 지도보기
- 관로 그룹 추가/삭제
- 공통 `FileUpload` 최대 5장
- JPEG EXIF 촬영일/GPS 파싱
- 사진 없는 수동 등록
- GPS/촬영일 누락 보완
- 공통 `Gis` 지도 클릭
- 최근접 관로 자동매핑 상태
- 실제 연동 전 로컬 데이터 기반 상태 전이
## 5. 연동 모드
- 기본: FE 선행 모의 모드
- 첨부파일 전송과 준설 DB 저장을 수행하지 않는다.
- EXIF, 수동 위치, 관로 매핑, payload 조립을 화면에서 확인한다.
- `NEXT_PUBLIC_DREDGING_BACKEND_ENABLED=true`
- 공통 TUS 업로드 수행
- 최근접 관로/등록 API 호출
## 6. 완료 조건
- `/oper/dredging` 직접 진입
- 세 조회 유형의 컬럼과 집계 전환
- 사진/수동 등록 사용자 흐름
- EXIF 상태가 사진별로 표시
- 관로, 거리, 신뢰도 표시
- 저장 전 필수 검증
- 브라우저 실제 조작과 콘솔 오류 확인
@@ -0,0 +1,59 @@
# SFR-010-012 준설관리 프론트엔드 구현기록
## 1. 구현 결과
- 신규 직접 진입 경로: `/oper/dredging`
- 기본 모드: `NEXT_PUBLIC_DREDGING_BACKEND_ENABLED`가 `true`가 아니면 FE 선행 모의 모드
- 기존 지도 메인, 공통 API 모듈, 공통 컴포넌트는 수정하지 않았다.
## 2. 구현 파일
| 파일 | 책임 |
| --- | --- |
| `page.tsx` | 지자체별·관경별·경과년도별 조회, 검색, 엑셀 다운로드, 등록/지도보기 진입 |
| `_components/DredgingRegisterModal.tsx` | 관로 그룹, 사진/수동 등록, 검증, 첨부 업로드 및 저장 순서 |
| `_components/DredgingLocationMap.tsx` | 공통 `Gis`에 로컬 위치 마커와 지도 클릭 interaction 연결 |
| `_lib/exif.ts` | JPEG APP1/TIFF EXIF 촬영일·GPS 분석 |
| `_lib/client.ts` | 최근접 관로, 공통 TUS 첨부 업로드, 준설 등록 API 어댑터와 FE 모의 응답 |
| `_lib/types.ts` | 화면 draft, 사진 메타정보, 관로 매핑, 저장 payload 타입 |
## 3. 주요 처리
### 3.1 사진 등록
1. 공통 `FileUpload`로 관로 그룹당 최대 5장을 선택한다.
2. JPEG의 `DateTimeOriginal → DateTimeDigitized → DateTime` 순서로 촬영일을 분석한다.
3. 촬영일의 날짜 부분을 준설일자로 사용한다.
4. GPS DMS를 WGS84 경위도로 변환한다.
5. 사진 위치 목록으로 최근접 관로를 조회한다.
6. 한 그룹의 촬영일 또는 관로가 둘 이상이면 저장을 차단한다.
7. 백엔드 연동 모드에서는 공통 TUS 첨부 업로드 완료 후 준설 등록 API를 호출한다.
### 3.2 수동 등록
1. `지도 수동 등록`을 선택한다.
2. 준설일자를 입력한다.
3. 공통 지도 클릭 또는 `위도, 경도` 입력으로 위치를 지정한다.
4. 좌표 변경 즉시 최근접 관로와 거리·신뢰도를 갱신한다.
5. 관로 매핑이 완료된 건만 저장 payload로 만든다.
### 3.3 FE 선행 모드
- 고아 첨부 생성을 막기 위해 실제 파일 전송과 DB 저장을 수행하지 않는다.
- 최근접 관로 응답과 저장 결과만 명시적인 `isMock` 데이터로 생성한다.
- 사용자 화면에는 개발단계, 선행 구현, 모의 매핑 안내 문구를 노출하지 않는다.
- 등록 결과는 현재 탭의 목록 상태에만 반영한다.
## 4. 연동 전환
- 환경변수: `NEXT_PUBLIC_DREDGING_BACKEND_ENABLED=true`
- 예정 API:
- `POST /api/v1/dredging/nearestPipes`
- `POST /api/v1/dredging/registrations`
- 첨부: 기존 공통 `handleUpload`의 TUS 응답 `X-File-Info`에서 `atflCd`, `atflId`를 받는다.
- 서버는 원본 파일 EXIF, 날짜, 좌표, 관로 키를 다시 검증해야 한다.
## 5. 롤백
- `apps/sn/app/(with-base-layout)/oper/dredging/**`를 제거한다.
- 신규 전용 경로이므로 기존 지도·운영정보 화면의 코드 롤백은 필요하지 않다.
@@ -45,6 +45,7 @@ export const WINDOW_POPUP_MESSAGE_TYPE = {
GIS_EDIT_SESSION_REMOVE_FOCUSED_CHANGE: "sn:popup:gisEditSessionRemoveFocusedChange",
GIS_EDIT_SESSION_REMOVE_LAST_CHANGE: "sn:popup:gisEditSessionRemoveLastChange",
GIS_EDIT_SESSION_CLEAR_CHANGES: "sn:popup:gisEditSessionClearChanges",
GIS_UPLOAD_COMPLETED: "sn:popup:gisUploadCompleted",
WATER_RISK_READY: "sn:popup:waterRiskReady",
WATER_RISK_SYNC: "sn:popup:waterRiskSync",
WATER_RISK_SELECT_RESULT: "sn:popup:waterRiskSelectResult",
@@ -225,7 +225,7 @@ export default function LineBarchart({
domain={[0, leftScale.max]}
ticks={leftScale.ticks}
orientation="left"
label={(props) => <CustomAxisText {...props} fieldText={yFieldText} position="left" />}
label={(props: any) => <CustomAxisText {...props} fieldText={yFieldText} position="left" />}
/>}
{/* 오른쪽 Y축: barVal */}
{yFieldText2 && <YAxis
@@ -237,7 +237,7 @@ export default function LineBarchart({
}
domain={[0, rightScale.max]}
ticks={rightScale.ticks}
label={(props) => <CustomAxisText {...props} fieldText={yFieldText2} position="right" />}
label={(props: any) => <CustomAxisText {...props} fieldText={yFieldText2} position="right" />}
reversed={true}
/>}
<Tooltip content={<CustomTooltip active={undefined} payload={undefined} label={undefined} />} />
@@ -254,7 +254,7 @@ export default function Linechart({
tickFormatter={(value) =>
typeof value === "number" ? value.toLocaleString() : value
}
label={(props) => <CustomAxisText {...props} fieldText={yFieldText} position="left" />}/>
label={(props: any) => <CustomAxisText {...props} fieldText={yFieldText} position="left" />}/>
<Tooltip content={<CustomTooltip />} />
{showLegend && <Legend content={CustomLegend as ContentType}/>}
{Object.keys(chartData).map((key) => {