Merge branch 'iipbs/sn/dev' into iipbs/sn/dev_kic
This commit is contained in:
@@ -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,
|
||||
|
||||
+609
-294
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: [
|
||||
|
||||
@@ -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;
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user