준설관리 개발완료

This commit is contained in:
2026-08-07 15:28:33 +09:00
parent 29f2a9169e
commit 3f4e3581dd
25 changed files with 2588 additions and 999 deletions
@@ -1,314 +0,0 @@
"use client";
import Feature from "ol/Feature.js";
import type { FeatureLike } from "ol/Feature.js";
import GeoJSON from "ol/format/GeoJSON.js";
import Point from "ol/geom/Point.js";
import type OlMap from "ol/Map.js";
import { unByKey } from "ol/Observable.js";
import { bbox as bboxStrategy } from "ol/loadingstrategy.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 Gis, { useGisContext } from "@common/business_components/ui/map/Gis";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import type { GeoPoint, PipeMatch } from "../_lib/types";
type DredgingLocationMapProps = {
value: GeoPoint | null;
onChange?: (point: GeoPoint) => void;
interactive?: boolean;
statusText?: string;
matchedPipe?: PipeMatch | null;
};
type GisContextValue = {
gisMap: OlMap | null;
};
const confirmedMarkerStyle = new Style({
image: new CircleStyle({
radius: 9,
fill: new Fill({ color: "rgba(220, 38, 38, 0.9)" }),
stroke: new Stroke({ color: "#ffffff", width: 3 }),
}),
});
const draftMarkerStyle = new Style({
image: new CircleStyle({
radius: 9,
fill: new Fill({ color: "rgba(37, 99, 235, 0.9)" }),
stroke: new Stroke({ color: "#ffffff", width: 3 }),
}),
});
const nearbyPipeStyle = [
new Style({
stroke: new Stroke({ color: "rgba(255, 255, 255, 0.95)", width: 5 }),
zIndex: 10,
}),
new Style({
stroke: new Stroke({ color: "#0f6cbd", width: 3 }),
zIndex: 11,
}),
];
const matchedPipeStyle = [
new Style({
stroke: new Stroke({ color: "rgba(255, 255, 255, 0.98)", width: 8 }),
zIndex: 20,
}),
new Style({
stroke: new Stroke({ color: "#f97316", width: 5 }),
zIndex: 21,
}),
];
const PIPE_LAYER_MIN_ZOOM = 13;
const normalizePipeFid = (fid: unknown) => {
const value = String(fid ?? "").trim();
const separatorIndex = value.lastIndexOf(".");
return separatorIndex >= 0 ? value.slice(separatorIndex + 1) : value;
};
const getPipeFeatureStyle = (matchedPipeFid?: string) => (feature: FeatureLike) =>
normalizePipeFid(feature.getId()) === normalizePipeFid(matchedPipeFid)
? matchedPipeStyle
: nearbyPipeStyle;
/**
* 공통 Gis 내부 지도 객체에 관로 layer, 클릭 interaction과 위치 marker를 추가한다.
* 공통 지도 코드는 수정하지 않고 이 컴포넌트가 추가한 layer/event만 정리한다.
*/
const LocationInteraction = ({
value,
onChange,
interactive,
matchedPipe,
}: Required<Pick<DredgingLocationMapProps, "interactive">> &
Omit<DredgingLocationMapProps, "interactive">) => {
const { gisMap } = useGisContext() as GisContextValue;
const markerLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const pipeLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const [pipeLoadState, setPipeLoadState] = useState<
"idle" | "loading" | "loaded" | "error"
>("idle");
const [visiblePipeCount, setVisiblePipeCount] = useState(0);
useEffect(() => {
if (!gisMap) return;
const pipeSource = new VectorSource({
format: new GeoJSON(),
strategy: bboxStrategy,
url: (extent) => {
const [minX, minY, maxX, maxY] = extent;
const layerModule = GisApp.Module.layer as unknown as {
storageNm?: string;
} | null;
const storageName = String(layerModule?.storageNm ?? "hasudo:");
const params = new URLSearchParams({
SERVICE: "WFS",
VERSION: "1.1.0",
REQUEST: "GetFeature",
TYPENAME: `${storageName}tb_ms_swage_ppl`,
SRSNAME: GisApp.TO_PROJ,
OUTPUTFORMAT: "application/json",
MAXFEATURES: "2000",
CQL_FILTER: `BBOX(geom, ${minX}, ${minY}, ${maxX}, ${maxY}, '${GisApp.TO_PROJ}')`,
});
return `${GisApp.GEOSERVER_URL}?${params.toString()}`;
},
});
const pipeLayer = new VectorLayer({
source: pipeSource,
minZoom: PIPE_LAYER_MIN_ZOOM,
style: getPipeFeatureStyle(matchedPipe?.fid),
updateWhileAnimating: false,
updateWhileInteracting: false,
zIndex: 700,
});
pipeLayer.set("dredgingPipeLayer", true);
pipeLayer.set("lyrTyNm", "tb_ms_swage_ppl");
pipeLayer.set("clickPsblYn", "N");
gisMap.addLayer(pipeLayer);
pipeLayerRef.current = pipeLayer;
const sourceEventKeys = [
pipeSource.on("featuresloadstart", () => setPipeLoadState("loading")),
pipeSource.on("featuresloadend", () => {
setVisiblePipeCount(pipeSource.getFeatures().length);
setPipeLoadState("loaded");
}),
pipeSource.on("featuresloaderror", () => {
setVisiblePipeCount(0);
setPipeLoadState("error");
}),
];
const markerSource = new VectorSource();
const markerLayer = new VectorLayer({
source: markerSource,
style: interactive ? draftMarkerStyle : confirmedMarkerStyle,
zIndex: 999,
});
markerLayer.set("dredgingLocationLayer", true);
gisMap.addLayer(markerLayer);
markerLayerRef.current = markerLayer;
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);
unByKey(sourceEventKeys);
gisMap.removeLayer(markerLayer);
gisMap.removeLayer(pipeLayer);
markerLayerRef.current = null;
pipeLayerRef.current = null;
};
}, [gisMap, interactive, onChange]);
useEffect(() => {
if (!gisMap || !markerLayerRef.current) return;
const source = markerLayerRef.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, interactive, value]);
useEffect(() => {
if (!pipeLayerRef.current) return;
pipeLayerRef.current.setStyle(getPipeFeatureStyle(matchedPipe?.fid));
pipeLayerRef.current.changed();
}, [matchedPipe?.fid]);
const pipeStatusText = (() => {
if (!value) return "위치를 등록하면 주변 관로가 표시됩니다.";
if (pipeLoadState === "loading") return "주변 관로를 불러오는 중입니다.";
if (pipeLoadState === "error") return "주변 관로를 불러오지 못했습니다.";
if (pipeLoadState === "loaded") {
return visiblePipeCount > 0
? `주변 관로 ${visiblePipeCount.toLocaleString()}건 표시`
: "현재 지도 범위에 표시할 관로가 없습니다.";
}
return "지도를 확대하면 주변 관로가 표시됩니다.";
})();
return (
<div className="rounded-sm border border-slate-300 bg-white/95 px-3 py-2 text-sm text-slate-700 shadow">
<p className="font-medium">
{interactive
? "지도를 클릭해 준설위치를 선택하세요."
: "등록 위치와 주변 관로를 함께 확인합니다."}
</p>
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
<span className="inline-flex items-center gap-1.5">
<span className="inline-block h-1 w-6 rounded bg-[#0f6cbd]" />
주변 관로
</span>
<span className="inline-flex items-center gap-1.5">
<span className="inline-block h-1.5 w-6 rounded bg-[#f97316]" />
자동 연결 관로
</span>
<span
className={
pipeLoadState === "error" ? "text-rose-600" : "text-slate-500"
}
data-testid="dredging-pipe-map-status"
>
{pipeStatusText}
</span>
</div>
</div>
);
};
const DredgingLocationMap = ({
value,
onChange,
interactive = true,
statusText,
matchedPipe,
}: 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}
matchedPipe={matchedPipe}
/>
}
defaultZoom={value ? 17 : 7.5}
defaultLonLat={defaultLonLat}
height="h-[540px]"
showZoomLevel={true}
showFloatingSearchButton={false}
treeTogglePosition="top"
/>
<div
className="border-t bg-slate-50 px-4 py-2 text-sm text-slate-700"
data-testid="dredging-map-status"
>
{statusText ??
(interactive
? "지도에서 준설위치를 선택해주세요."
: value
? "등록된 준설위치가 표시되었습니다."
: "위치가 등록되지 않았습니다.")}
</div>
</div>
);
};
export default DredgingLocationMap;
@@ -0,0 +1,722 @@
"use client";
import dynamic from "next/dynamic";
import { FileUpload } from "@common/business_components/ui/file-upload";
import { Button } from "@common/components/ui/button";
import { Input } from "@common/components/ui/input";
import { Label } from "@common/components/ui/label";
import { MS_SWAGE_PPL_CODES } from "@/app/(map-layout)/map/components/common/table";
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import {
fetchDredgingPhotoBlob,
fetchDredgingPhotos,
findNearestPipeMatches,
updateDredgingRecord,
uploadDredgingPhotoDrafts,
type AttachmentReference,
} from "../lib/client";
import { readPhotoMetadata } from "../lib/exif";
import {
EMPTY_PHOTO_METADATA,
type DredgingPhoto,
type DredgingPhotoDraft,
type DredgingRecord,
type GeoPoint,
type PipeMatch,
} from "../lib/types";
const DredgingLocationMap = dynamic(() => import("./DredgingLocationMap"), {
ssr: false,
});
type Props = {
record: DredgingRecord;
mode?: "detail" | "edit";
onUpdated?: () => void;
};
type DredgingPhotoPreview = DredgingPhoto & {
previewUrl: string | null;
loadError: string | null;
};
type NewPhotoPreview = DredgingPhotoDraft & {
previewUrl: string;
};
const PRIMARY_BUTTON_CLASS =
"min-w-[6.4rem] border !border-blue-600 !bg-blue-600 px-4 font-medium !text-white hover:!bg-blue-700";
const SECONDARY_BUTTON_CLASS =
"min-w-[6.4rem] border !border-blue-300 !bg-white px-4 font-medium !text-blue-700 hover:!bg-blue-50";
const pipeMaterialNameByCode = new Map(
MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text]),
);
const resolvePipeMaterialName = (value: string) => {
const normalizedValue = value.trim();
if (!normalizedValue) return "확인불가";
const normalizedCode = normalizedValue.toUpperCase();
if (!/^MOP\d+$/.test(normalizedCode)) return normalizedValue;
return pipeMaterialNameByCode.get(normalizedCode) ?? "확인불가";
};
const locationSourceLabel = {
EXIF: "사진 GPS",
MANUAL: "지도 지정",
} 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}`;
};
/** 상세 UI를 공통 기반으로 사용해 조회와 수정 상태의 시각 구조를 동일하게 유지한다. */
const DredgingDetailPopupPage = ({
record,
mode = "detail",
onUpdated,
}: Props) => {
const editable = mode === "edit";
const [photos, setPhotos] = useState<DredgingPhotoPreview[]>([]);
const [newPhotos, setNewPhotos] = useState<NewPhotoPreview[]>([]);
const newPhotoUrlsRef = useRef<string[]>([]);
const [selectedPhotoKey, setSelectedPhotoKey] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dredgingDate, setDredgingDate] = useState(record.dredgingDate);
const [location, setLocation] = useState<GeoPoint>({
longitude: record.longitude,
latitude: record.latitude,
});
const [locationDraft, setLocationDraft] = useState<GeoPoint | null>(null);
const [isEditingLocation, setIsEditingLocation] = useState(false);
const [locationSource, setLocationSource] = useState(
record.registrationSource,
);
const [matchError, setMatchError] = useState<string | null>(null);
const [isMatching, setIsMatching] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [statusMessage, setStatusMessage] = useState("");
const uploadedAttachmentsRef = useRef(new Map<string, AttachmentReference>());
const initialMatchedPipe = useMemo<PipeMatch>(
() => ({
fid: record.pipeFid,
mngInstCd: record.mngInstCd,
fcltMngNo: record.fcltMngNo,
pipeDiameterMm: record.pipeDiameterMm,
pipeLengthM: record.pipeLengthM,
pipeMaterial: record.pipeMaterial,
installationYear: record.installationYear,
swtrmtZoneFid: null,
swtrmtZoneNm: record.treatmentZone,
distanceMeters: record.matchDistanceMeters,
snappedLongitude: record.snappedLongitude || record.longitude,
snappedLatitude: record.snappedLatitude || record.latitude,
isMock: record.isMock,
}),
[record],
);
const [matchedPipe, setMatchedPipe] = useState<PipeMatch>(initialMatchedPipe);
useEffect(() => {
let cancelled = false;
const createdUrls: string[] = [];
const load = async () => {
setIsLoading(true);
setError(null);
setPhotos([]);
setSelectedPhotoKey(null);
try {
const items = await fetchDredgingPhotos(
record.dredgingJobSn,
record.pipeFid,
);
const previews = await Promise.all(
items.map(async (photo): Promise<DredgingPhotoPreview> => {
try {
const blob = await fetchDredgingPhotoBlob(photo.atflId);
const previewUrl = URL.createObjectURL(blob);
createdUrls.push(previewUrl);
return { ...photo, previewUrl, loadError: null };
} catch (loadError) {
return {
...photo,
previewUrl: null,
loadError:
loadError instanceof Error
? loadError.message
: "사진 원본을 불러오지 못했습니다.",
};
}
}),
);
if (!cancelled) {
setPhotos(previews);
setSelectedPhotoKey(
previews[0] ? `existing-${previews[0].photoSn}` : null,
);
}
} catch (loadError) {
if (!cancelled) {
setError(
loadError instanceof Error
? loadError.message
: "등록된 준설사진을 불러오지 못했습니다.",
);
}
} finally {
if (!cancelled) setIsLoading(false);
}
};
void load();
return () => {
cancelled = true;
createdUrls.forEach((url) => URL.revokeObjectURL(url));
};
}, [record.dredgingJobSn, record.pipeFid]);
useEffect(
() => () => {
newPhotoUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
},
[],
);
const selectedExistingPhoto = selectedPhotoKey?.startsWith("existing-")
? (photos.find(
(photo) => `existing-${photo.photoSn}` === selectedPhotoKey,
) ?? null)
: null;
const selectedNewPhoto = selectedPhotoKey?.startsWith("new-")
? (newPhotos.find((photo) => `new-${photo.key}` === selectedPhotoKey) ??
null)
: null;
const pipeInfo = [
["시도", record.ctpv || "확인불가"],
["시군구", record.sgg || "확인불가"],
[
"처리구역",
matchedPipe.swtrmtZoneNm || record.treatmentZone || "확인불가",
],
["관경", `${matchedPipe.pipeDiameterMm.toLocaleString()} mm`],
["관로연장", `${matchedPipe.pipeLengthM.toLocaleString()} m`],
["관재질", resolvePipeMaterialName(matchedPipe.pipeMaterial)],
[
"매설년도",
matchedPipe.installationYear
? `${matchedPipe.installationYear}년`
: "확인불가",
],
["위치등록", locationSourceLabel[locationSource]],
];
const automaticMappingValue = `${matchedPipe.distanceMeters.toFixed(2)} m`;
const handleNewFiles = async (files: File[] | null) => {
const selectedFiles = files ?? [];
if (photos.length + selectedFiles.length > 5) {
alert(
"관로별 사진은 기존 사진을 포함해 최대 5장까지 등록할 수 있습니다.",
);
return;
}
newPhotoUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
newPhotoUrlsRef.current = [];
const checking = selectedFiles.map<NewPhotoPreview>((file) => {
const previewUrl = URL.createObjectURL(file);
newPhotoUrlsRef.current.push(previewUrl);
return {
key: crypto.randomUUID(),
file,
metadata: EMPTY_PHOTO_METADATA,
previewUrl,
};
});
setNewPhotos(checking);
if (checking[0]) setSelectedPhotoKey(`new-${checking[0].key}`);
const parsed = await Promise.all(
checking.map(async (photo) => ({
...photo,
metadata: await readPhotoMetadata(photo.file),
})),
);
setNewPhotos(parsed);
};
const removeExistingPhoto = (photoSn: number) => {
setPhotos((current) =>
current.filter((photo) => photo.photoSn !== photoSn),
);
if (selectedPhotoKey === `existing-${photoSn}`) setSelectedPhotoKey(null);
};
const removeNewPhoto = (photoKey: string) => {
const removed = newPhotos.find((photo) => photo.key === photoKey);
if (removed) {
URL.revokeObjectURL(removed.previewUrl);
newPhotoUrlsRef.current = newPhotoUrlsRef.current.filter(
(url) => url !== removed.previewUrl,
);
}
setNewPhotos((current) =>
current.filter((photo) => photo.key !== photoKey),
);
if (selectedPhotoKey === `new-${photoKey}`) setSelectedPhotoKey(null);
};
const startLocationEditing = () => {
setLocationDraft(location);
setIsEditingLocation(true);
setMatchError(null);
};
const cancelLocationEditing = () => {
setLocationDraft(null);
setIsEditingLocation(false);
setMatchError(null);
};
const applyLocation = async () => {
if (!locationDraft) {
alert("지도에서 변경할 준설위치를 선택해주세요.");
return;
}
setIsMatching(true);
setMatchError(null);
try {
const locationKey = `edit-${record.dredgingJobSn}-${record.pipeFid}`;
const matches = await findNearestPipeMatches([
{ locationKey, ...locationDraft },
]);
const nextPipe = matches.get(locationKey);
if (!nextPipe) {
throw new Error(
"50m 이내에 관로가 없습니다. 지도에서 준설위치를 다시 확인해주세요.",
);
}
setLocation(locationDraft);
setLocationSource("MANUAL");
setMatchedPipe(nextPipe);
setLocationDraft(null);
setIsEditingLocation(false);
setStatusMessage("변경한 위치와 최근접 관로를 확인했습니다.");
} catch (matchFailure) {
setMatchError(
matchFailure instanceof Error
? matchFailure.message
: "최근접 관로를 찾지 못했습니다.",
);
} finally {
setIsMatching(false);
}
};
const handleSave = async () => {
if (!dredgingDate) {
alert("준설일자를 입력해주세요.");
return;
}
if (dredgingDate > formatLocalDate(new Date())) {
alert("미래 날짜는 준설일자로 수정할 수 없습니다.");
return;
}
if (isEditingLocation) {
alert("선택한 지도 위치를 먼저 적용해주세요.");
return;
}
setIsSaving(true);
try {
setStatusMessage(
newPhotos.length > 0
? "추가한 사진을 공통 첨부파일로 업로드하고 있습니다."
: "수정 내용을 저장하고 있습니다.",
);
const attachments = await uploadDredgingPhotoDrafts(
newPhotos,
uploadedAttachmentsRef.current,
);
uploadedAttachmentsRef.current = attachments;
await updateDredgingRecord(
record.dredgingJobSn,
{
dredgingDate,
originalPipeFid: record.pipeFid,
location,
locationSource,
pipe: matchedPipe,
retainedPhotoSns: photos.map((photo) => photo.photoSn),
newPhotos,
},
attachments,
);
uploadedAttachmentsRef.current.clear();
setStatusMessage("준설정보 수정을 완료했습니다.");
onUpdated?.();
alert("준설정보를 수정했습니다.");
window.close();
} catch (saveError) {
const message =
saveError instanceof Error
? saveError.message
: "준설정보를 수정하지 못했습니다.";
setStatusMessage(message);
alert(message);
} finally {
setIsSaving(false);
}
};
const totalPhotoCount = photos.length + newPhotos.length;
return (
<main className="sn-popup-page sn-dredging-register bg-[#eef3f7]">
<header className="sn-popup-header shrink-0">
<div className="flex min-w-0 items-center gap-4">
<h1 className="sn-popup-title shrink-0">
준설결과 {editable ? "수정" : "상세"}
</h1>
<span className="truncate text-[1.15rem] text-white/90">
관로번호 {matchedPipe.fcltMngNo || "확인불가"}
</span>
</div>
{editable ? (
<div className="flex items-center gap-1.5">
<Button
type="button"
size="sm"
className="sn-popup-header-button min-w-[6.4rem] !border-white !bg-white/10 !text-white hover:!bg-white/20"
disabled={isSaving}
onClick={() => window.close()}
>
취소
</Button>
<Button
type="button"
size="sm"
className="sn-popup-header-button min-w-[6.4rem] !border-white !bg-white !text-blue-700 hover:!bg-blue-50"
disabled={isSaving || isMatching}
onClick={() => void handleSave()}
data-testid="dredging-edit-save-button"
>
{isSaving ? "저장 중..." : "저장"}
</Button>
</div>
) : null}
</header>
<section className="grid min-h-0 flex-1 grid-cols-[minmax(440px,31%)_minmax(0,1fr)] gap-2 p-2">
<aside className="flex min-h-0 flex-col gap-2 overflow-y-auto pr-1">
<section className="shrink-0 rounded-sm border border-slate-300 bg-white">
<div className="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-3 py-2">
<h2 className="text-base font-semibold text-slate-900">
관로정보
</h2>
{editable ? (
<div className="flex items-center gap-2">
<Label
htmlFor="dredging-edit-date"
className="text-sm font-medium text-slate-700"
>
준설일자 <span className="text-rose-600">*</span>
</Label>
<Input
id="dredging-edit-date"
type="date"
value={dredgingDate}
max={formatLocalDate(new Date())}
onChange={(event) => setDredgingDate(event.target.value)}
className="sn-dredging-date-input h-[3.2rem] w-[155px] text-base"
disabled={isSaving}
/>
</div>
) : (
<strong className="text-sm text-slate-700">
{dredgingDate}
</strong>
)}
</div>
<dl className="grid grid-cols-[82px_minmax(0,1fr)_82px_minmax(0,1fr)] gap-px bg-slate-200">
{pipeInfo.map(([label, value]) => (
<Fragment key={label}>
<dt className="flex min-h-8 items-center bg-blue-100 px-2 text-xs font-semibold text-slate-700">
{label}
</dt>
<dd
className="flex min-h-8 min-w-0 items-center truncate bg-white px-2 text-sm font-medium text-slate-900"
title={value}
>
{value}
</dd>
</Fragment>
))}
<dt className="flex min-h-8 items-center bg-blue-100 px-2 text-xs font-semibold text-slate-700">
자동매핑
</dt>
<dd
className="col-span-3 flex min-h-8 min-w-0 items-center truncate bg-white px-2 text-sm font-medium text-slate-900"
title={automaticMappingValue}
>
{automaticMappingValue}
</dd>
</dl>
</section>
<section className="flex min-h-[360px] flex-1 flex-col overflow-hidden rounded-sm border border-slate-300 bg-white">
<div className="flex min-h-[30px] shrink-0 items-center justify-between gap-2 overflow-visible border-b border-slate-200 bg-slate-50 px-3 py-1">
<div className="flex min-h-[20px] min-w-0 items-center">
<p className="truncate text-xs leading-[18px] text-slate-500">
{editable
? "기존 사진을 삭제하거나 새 사진을 추가할 수 있습니다."
: "사진을 선택하면 아래에서 원본을 확인합니다."}
</p>
</div>
<span className="shrink-0 text-xs font-medium leading-[18px] text-slate-600">
{totalPhotoCount}/5장
</span>
</div>
{editable ? (
<div className="sn-dredging-photo-upload shrink-0 border-b border-slate-200 p-2">
<FileUpload
files={newPhotos.map((photo) => photo.file)}
setFiles={(files) => void handleNewFiles(files)}
fileTypes=".jpg,.jpeg,.png"
maxFiles={Math.max(1, 5 - photos.length)}
/>
</div>
) : null}
<div className="max-h-[190px] shrink-0 overflow-y-auto border-b border-slate-200 p-2">
{isLoading ? (
<p className="flex h-[88px] items-center justify-center text-sm text-slate-500">
등록된 사진을 불러오는 중입니다.
</p>
) : error ? (
<p
role="alert"
className="rounded-sm bg-rose-50 px-3 py-3 text-sm text-rose-700"
>
{error}
</p>
) : totalPhotoCount > 0 ? (
<div
className="space-y-1"
data-testid="dredging-detail-photo-list"
>
{photos.map((photo, index) => {
const key = `existing-${photo.photoSn}`;
const selected = key === selectedPhotoKey;
return (
<div
key={key}
className={`flex items-stretch rounded-sm border ${selected ? "border-blue-500 bg-blue-50" : "border-slate-200 bg-white"}`}
>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2 text-left"
onClick={() => setSelectedPhotoKey(key)}
aria-pressed={selected}
data-testid={`dredging-detail-photo-${photo.photoSn}`}
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-600">
{index + 1}
</span>
<span className="flex min-w-0 flex-1 items-center gap-2">
<strong
className="min-w-0 flex-1 truncate text-[11px]"
title={photo.originalFileName}
>
{photo.originalFileName ||
`준설사진 ${index + 1}`}
</strong>
<span className="shrink-0 whitespace-nowrap text-[10px] text-slate-500">
{photo.capturedAt ?? "촬영일시 없음"}
</span>
</span>
</button>
{editable ? (
<button
type="button"
aria-label={`${photo.originalFileName} 삭제`}
className="w-9 border-l border-slate-200 text-base font-bold text-slate-400 hover:bg-rose-50 hover:text-rose-700"
onClick={() => removeExistingPhoto(photo.photoSn)}
>
×
</button>
) : null}
</div>
);
})}
{newPhotos.map((photo, index) => {
const key = `new-${photo.key}`;
const selected = key === selectedPhotoKey;
return (
<div
key={key}
className={`flex items-stretch rounded-sm border ${selected ? "border-blue-500 bg-blue-50" : "border-emerald-200 bg-emerald-50/40"}`}
>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2 text-left"
onClick={() => setSelectedPhotoKey(key)}
aria-pressed={selected}
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-xs font-semibold text-emerald-700">
{photos.length + index + 1}
</span>
<span className="flex min-w-0 flex-1 items-center gap-2">
<strong
className="min-w-0 flex-1 truncate text-[11px]"
title={photo.file.name}
>
신규 · {photo.file.name}
</strong>
<span className="shrink-0 whitespace-nowrap text-[10px] text-slate-500">
{photo.metadata.capturedAt ?? "촬영일시 없음"}
</span>
</span>
</button>
<button
type="button"
aria-label={`${photo.file.name} 삭제`}
className="w-9 border-l border-emerald-200 text-base font-bold text-slate-400 hover:bg-rose-50 hover:text-rose-700"
onClick={() => removeNewPhoto(photo.key)}
>
×
</button>
</div>
);
})}
</div>
) : (
<p className="flex h-[88px] items-center justify-center text-sm text-slate-500">
등록된 사진이 없습니다.
</p>
)}
</div>
<div
className="flex min-h-[220px] flex-1 items-center justify-center overflow-hidden bg-slate-900 p-2"
data-testid="dredging-detail-photo-preview"
>
{selectedExistingPhoto?.previewUrl ? (
<img
src={selectedExistingPhoto.previewUrl}
alt={selectedExistingPhoto.originalFileName || "준설사진"}
className="h-full w-full object-contain"
/>
) : selectedNewPhoto ? (
<img
src={selectedNewPhoto.previewUrl}
alt={selectedNewPhoto.file.name}
className="h-full w-full object-contain"
/>
) : selectedExistingPhoto?.loadError ? (
<p className="px-4 text-center text-sm text-rose-200">
{selectedExistingPhoto.loadError}
</p>
) : (
<p className="px-4 text-center text-sm text-slate-300">
사진목록에서 확인할 사진을 선택해주세요.
</p>
)}
</div>
</section>
{editable ? (
<div className="shrink-0 rounded-sm border border-slate-300 bg-white px-3 py-2">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<strong className="block truncate text-sm text-slate-800">
자동 연결 관로 · {matchedPipe.fcltMngNo}
</strong>
<span
className={`block truncate text-xs ${matchError ? "text-rose-700" : "text-slate-500"}`}
>
{matchError ??
`${matchedPipe.distanceMeters.toFixed(2)}m · 지도에서 위치를 수정할 수 있습니다.`}
</span>
</div>
<div className="flex shrink-0 gap-1">
{isEditingLocation ? (
<>
<Button
type="button"
size="sm"
className={SECONDARY_BUTTON_CLASS}
onClick={cancelLocationEditing}
>
취소
</Button>
<Button
type="button"
size="sm"
className={PRIMARY_BUTTON_CLASS}
disabled={!locationDraft || isMatching}
onClick={() => void applyLocation()}
>
{isMatching ? "관로 찾는 중..." : "선택 위치 적용"}
</Button>
</>
) : (
<Button
type="button"
size="sm"
className={PRIMARY_BUTTON_CLASS}
onClick={startLocationEditing}
>
위치 수정
</Button>
)}
</div>
</div>
<div
className="mt-1 min-h-5 rounded-sm bg-slate-50 px-2 py-1 text-xs text-slate-600"
data-testid="dredging-edit-status"
>
{statusMessage ||
"준설일자·사진·위치와 자동 연결 관로를 확인해주세요."}
</div>
</div>
) : null}
</aside>
<section
className="min-h-0 overflow-hidden rounded-sm bg-white"
data-testid="dredging-detail-map"
>
<DredgingLocationMap
value={isEditingLocation ? locationDraft : location}
matchedPipe={
isEditingLocation && locationDraft ? null : matchedPipe
}
interactive={editable && isEditingLocation}
onChange={setLocationDraft}
statusText={
isEditingLocation
? "지도에서 변경할 준설위치를 클릭한 뒤 선택 위치 적용을 눌러주세요."
: editable
? "현재 준설위치와 자동 연결된 관로를 표시하고 있습니다."
: "준설위치와 자동 연결된 관로를 표시하고 있습니다."
}
height="h-full min-h-[560px]"
/>
</section>
</section>
</main>
);
};
export default DredgingDetailPopupPage;
@@ -0,0 +1,396 @@
"use client";
import Feature from "ol/Feature.js";
import type { FeatureLike } from "ol/Feature.js";
import Point from "ol/geom/Point.js";
import type OlMap from "ol/Map.js";
import type { EventsKey } from "ol/events.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 type { StyleFunction, StyleLike } from "ol/style/Style.js";
import { useEffect, useMemo, useRef, useState } from "react";
import { getRawJson } from "@/components/lib/api";
import Gis, { useGisContext } from "@common/business_components/ui/map/Gis";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import type { GeoPoint, PipeMatch } from "../lib/types";
type DredgingLocationMapProps = {
value: GeoPoint | null;
onChange?: (point: GeoPoint) => void;
interactive?: boolean;
statusText?: string;
matchedPipe?: PipeMatch | null;
height?: string;
};
type LayerSetting = Record<string, unknown> & {
id?: string;
lyrSn?: string;
lyrTyNm?: string;
useYn?: string;
actlLyrYn?: string;
exprsYn?: string;
children?: LayerSetting[];
};
type GisCore = {
addWfsLayer: (setting: LayerSetting) => VectorLayer<VectorSource>;
};
type GisContextValue = {
gisMap: OlMap | null;
gisCore: GisCore | null;
};
const confirmedMarkerStyle = new Style({
image: new CircleStyle({
radius: 9,
fill: new Fill({ color: "rgba(220, 38, 38, 0.9)" }),
stroke: new Stroke({ color: "#ffffff", width: 3 }),
}),
});
const draftMarkerStyle = new Style({
image: new CircleStyle({
radius: 9,
fill: new Fill({ color: "rgba(37, 99, 235, 0.9)" }),
stroke: new Stroke({ color: "#ffffff", width: 3 }),
}),
});
const matchedPipeStyle = [
new Style({
stroke: new Stroke({ color: "rgba(255, 255, 255, 0.98)", width: 7 }),
zIndex: 20,
}),
new Style({
stroke: new Stroke({ color: "#f97316", width: 4 }),
zIndex: 21,
}),
];
const normalizePipeFid = (fid: unknown) => {
const value = String(fid ?? "").trim();
const separatorIndex = value.lastIndexOf(".");
return separatorIndex >= 0 ? value.slice(separatorIndex + 1) : value;
};
const flattenLayerSettings = (items: LayerSetting[]): LayerSetting[] =>
items.flatMap((item) => [
item,
...flattenLayerSettings(Array.isArray(item.children) ? item.children : []),
]);
const isPipeLayerSetting = (setting: LayerSetting) =>
setting.lyrTyNm === "tb_ms_swage_ppl" &&
setting.useYn === "Y" &&
setting.actlLyrYn === "Y" &&
setting.exprsYn !== "N";
const resolveStyle = (
style: StyleLike | null | undefined,
feature: FeatureLike,
resolution: number,
) => (typeof style === "function" ? style(feature, resolution) : style);
/**
* 공통 관망도와 같은 DB 레이어 설정으로 관로를 그리고, 이 화면이 소유한 마커와
* 클릭 이벤트만 추가·정리한다.
*/
const LocationInteraction = ({
value,
onChange,
interactive,
matchedPipe,
pipeLayerSettings,
}: Required<Pick<DredgingLocationMapProps, "interactive">> &
Omit<DredgingLocationMapProps, "interactive" | "height"> & {
pipeLayerSettings: LayerSetting[];
}) => {
const { gisMap, gisCore } = useGisContext() as GisContextValue;
const markerLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const pipeLayersRef = useRef<
Array<{ layer: VectorLayer<VectorSource>; baseStyle: StyleLike | null }>
>([]);
const onChangeRef = useRef(onChange);
const previousValueRef = useRef<GeoPoint | null>(null);
const [pipeLoadState, setPipeLoadState] = useState<
"idle" | "loading" | "loaded" | "error"
>("idle");
const [visiblePipeCount, setVisiblePipeCount] = useState(0);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
if (!gisMap) return;
const markerSource = new VectorSource();
const markerLayer = new VectorLayer({
source: markerSource,
style: interactive ? draftMarkerStyle : confirmedMarkerStyle,
zIndex: 999,
});
markerLayer.set("dredgingLocationLayer", true);
gisMap.addLayer(markerLayer);
markerLayerRef.current = markerLayer;
return () => {
gisMap.removeLayer(markerLayer);
markerLayerRef.current = null;
};
}, [gisMap]);
useEffect(() => {
markerLayerRef.current?.setStyle(
interactive ? draftMarkerStyle : confirmedMarkerStyle,
);
}, [interactive]);
useEffect(() => {
if (!gisMap || !interactive) return;
const clickKey = 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;
}
onChangeRef.current?.({ longitude, latitude });
});
return () => unByKey(clickKey);
}, [gisMap, interactive]);
useEffect(() => {
if (!gisMap || !gisCore || pipeLayerSettings.length === 0) return;
setPipeLoadState("loading");
let loadErrors = 0;
const sourceEventKeys: EventsKey[] = [];
const layers = pipeLayerSettings.map((setting, index) => {
const layer = gisCore.addWfsLayer({
...setting,
id: `dredging-pipe-${setting.lyrSn ?? setting.id ?? index}`,
clickPsblYn: "N",
});
layer.set("dredgingPipeLayer", true);
const source = layer.getSource();
const refreshCount = () => {
setVisiblePipeCount(
pipeLayersRef.current.reduce(
(count, item) =>
count + (item.layer.getSource()?.getFeatures().length ?? 0),
0,
),
);
setPipeLoadState(loadErrors > 0 ? "error" : "loaded");
};
if (source) {
sourceEventKeys.push(
source.on("featuresloadstart", () =>
setPipeLoadState("loading"),
) as EventsKey,
source.on("featuresloadend", refreshCount) as EventsKey,
source.on("featuresloaderror", () => {
loadErrors += 1;
refreshCount();
}) as EventsKey,
);
}
return { layer, baseStyle: layer.getStyle() ?? null };
});
pipeLayersRef.current = layers;
return () => {
unByKey(sourceEventKeys);
layers.forEach(({ layer }) => gisMap.removeLayer(layer));
pipeLayersRef.current = [];
setVisiblePipeCount(0);
setPipeLoadState("idle");
};
}, [gisCore, gisMap, pipeLayerSettings]);
useEffect(() => {
const matchedFid = normalizePipeFid(matchedPipe?.fid);
pipeLayersRef.current.forEach(({ layer, baseStyle }) => {
const styleFunction: StyleFunction = (feature, resolution) =>
matchedFid && normalizePipeFid(feature.getId()) === matchedFid
? matchedPipeStyle
: (resolveStyle(baseStyle, feature, resolution) ?? undefined);
layer.setStyle(styleFunction);
layer.changed();
});
}, [matchedPipe?.fid, pipeLayerSettings]);
useEffect(() => {
if (!gisMap || !markerLayerRef.current) return;
const source = markerLayerRef.current.getSource();
source?.clear();
if (!value) {
previousValueRef.current = null;
return;
}
const coordinate = transform(
[value.longitude, value.latitude],
GisApp.FROM_PROJ,
GisApp.TO_PROJ,
);
source?.addFeature(new Feature({ geometry: new Point(coordinate) }));
const previous = previousValueRef.current;
const locationChanged =
!previous ||
previous.longitude !== value.longitude ||
previous.latitude !== value.latitude;
// 사진 GPS가 처음 들어오거나 등록 건을 바꾼 때만 이동한다. 위치수정 진입처럼
// interactive 상태만 바뀐 경우에는 사용자가 보고 있던 지도 범위를 유지한다.
if (locationChanged && (!interactive || previous === null)) {
gisMap.getView().animate({ center: coordinate, zoom: 17, duration: 250 });
}
previousValueRef.current = value;
}, [gisMap, interactive, value?.latitude, value?.longitude]);
const pipeStatusText = (() => {
if (pipeLayerSettings.length === 0)
return "관망도 레이어 설정을 불러오는 중입니다.";
if (pipeLoadState === "loading") return "관망도 관로를 불러오는 중입니다.";
if (pipeLoadState === "error")
return visiblePipeCount > 0
? `일부 관로만 표시 중 · ${visiblePipeCount.toLocaleString()}건`
: "관망도 관로를 불러오지 못했습니다.";
if (pipeLoadState === "loaded")
return visiblePipeCount > 0
? `관망도 관로 ${visiblePipeCount.toLocaleString()}건 표시`
: "현재 지도 범위에 표시할 관로가 없습니다.";
return "지도를 확대하면 관망도 관로가 표시됩니다.";
})();
return (
<div className="rounded-sm border border-slate-300 bg-white/95 px-2 py-1.5 text-xs text-slate-700 shadow">
<p className="font-medium">
{interactive
? "지도를 클릭해 준설위치를 선택하세요."
: "등록 위치와 관망도 관로를 함께 확인합니다."}
</p>
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1">
<span>관로 색상·굵기: 관망도 레이어 설정</span>
<span className="inline-flex items-center gap-1.5">
<span className="inline-block h-1.5 w-6 rounded bg-[#f97316]" />
자동 연결 관로
</span>
<span
className={
pipeLoadState === "error" ? "text-rose-600" : "text-slate-500"
}
data-testid="dredging-pipe-map-status"
>
{pipeStatusText}
</span>
</div>
</div>
);
};
const DredgingLocationMap = ({
value,
onChange,
interactive = true,
statusText,
matchedPipe,
height = "h-[540px]",
}: DredgingLocationMapProps) => {
const gisMaps = useRef<OlMap | null>(null);
const [pipeLayerSettings, setPipeLayerSettings] = useState<LayerSetting[]>(
[],
);
const defaultLonLat = useMemo<[number, number]>(
() => (value ? [value.longitude, value.latitude] : [127.75, 36]),
// 지도 최초 생성 위치만 사용하며 이후 위치 변경은 marker effect가 담당한다.
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const body = await getRawJson(
"/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK",
);
const rows = (body?.data ?? body ?? []) as LayerSetting[];
if (!cancelled) {
setPipeLayerSettings(
flattenLayerSettings(rows).filter(isPipeLayerSetting),
);
}
} catch (error) {
console.error("준설 등록 관망도 레이어 설정 조회 실패", error);
if (!cancelled) setPipeLayerSettings([]);
}
};
void load();
return () => {
cancelled = true;
};
}, []);
return (
<div className="flex h-full min-h-0 flex-col 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}
matchedPipe={matchedPipe}
pipeLayerSettings={pipeLayerSettings}
/>
}
defaultZoom={value ? 17 : 7.5}
defaultLonLat={defaultLonLat}
height={height}
showZoomLevel={true}
showFloatingSearchButton={false}
treeTogglePosition="top"
/>
<div
className="shrink-0 border-t bg-slate-50 px-3 py-1.5 text-xs text-slate-700"
data-testid="dredging-map-status"
>
{statusText ??
(interactive
? "지도에서 준설위치를 선택해주세요."
: value
? "등록된 준설위치가 표시되었습니다."
: "위치가 등록되지 않았습니다.")}
</div>
</div>
);
};
export default DredgingLocationMap;
@@ -1,12 +1,20 @@
import { getRawJson, postRawJson } from "@/components/lib/api";
import {
deleteData,
getBlobData,
getRawJson,
postRawJson,
putData,
} from "@/components/lib/api";
import { handleUpload } from "@/components/lib/utils";
import type { HttpResponse } from "tus-js-client";
import type {
DredgingSaveGroup,
DredgingRecordUpdate,
DredgingPhotoDraft,
DredgingPhoto,
DredgingRecord,
GeoPoint,
MatchConfidence,
PipeMatch,
} from "./types";
@@ -21,12 +29,7 @@ export type AttachmentReference = {
type NearestPipeResponseItem = {
locationKey: string;
failureCode?:
| "PIPE_NOT_FOUND"
| "PIPE_ASSET_NOT_MAPPED"
| "PIPE_ASSET_AMBIGUOUS"
| string
| null;
failureCode?: "PIPE_NOT_FOUND" | string | null;
failureMessage?: string | null;
selected?: {
fid: string;
@@ -36,10 +39,11 @@ type NearestPipeResponseItem = {
pipeLengthM?: number;
pipeMaterial?: string;
installationYear?: number | null;
swtrmtZoneFid?: string | null;
swtrmtZoneNm?: string | null;
distanceMeters: number;
snappedLongitude: number;
snappedLatitude: number;
confidence: MatchConfidence;
} | null;
};
@@ -47,10 +51,6 @@ const nearestPipeFailureMessage = (item: NearestPipeResponseItem) => {
switch (item.failureCode) {
case "PIPE_NOT_FOUND":
return "50m 이내에 관로가 없습니다. 지도에서 준설위치를 다시 확인해주세요.";
case "PIPE_ASSET_NOT_MAPPED":
return "가장 가까운 관로는 찾았지만 자산 매핑 정보가 없습니다. 관로 자산 매핑 데이터를 확인해주세요.";
case "PIPE_ASSET_AMBIGUOUS":
return "가장 가까운 관로의 자산 매핑이 둘 이상입니다. 관로 자산 매핑 데이터를 확인해주세요.";
default:
return (
item.failureMessage?.trim() ??
@@ -62,21 +62,24 @@ const nearestPipeFailureMessage = (item: NearestPipeResponseItem) => {
export const isDredgingBackendEnabled =
process.env.NEXT_PUBLIC_DREDGING_BACKEND_ENABLED !== "false";
const toDredgingApiError = (error: unknown, fallbackMessage: string) => {
if (!(error instanceof Error)) return new Error(fallbackMessage);
// 프록시/서버 오류가 HTML 또는 plain text로 반환되면 공통 JSON 파서의
// SyntaxError를 그대로 노출하지 않고 사용자가 이해할 수 있는 문구로 바꾼다.
if (
error instanceof SyntaxError ||
/Unexpected token|Internal Server Error|Failed to (fetch|post) data/i.test(
error.message,
)
) {
return new Error(fallbackMessage);
}
return error;
/**
* 백엔드·프록시의 상태코드, HTML 오류 본문과 파싱 문구를 화면에 전달하지 않는다.
* 상세 응답은 브라우저 Network에서 확인하고 화면에는 업무별 재시도 안내만 노출한다.
*/
const toDredgingApiError = (
error: unknown,
fallbackMessage: string,
operation: string,
) => {
const status =
typeof error === "object" && error !== null && "status" in error
? (error as { status?: unknown }).status
: undefined;
console.warn(`[준설관리] ${operation} 실패`, {
status: typeof status === "number" ? status : undefined,
errorType: error instanceof Error ? error.name : typeof error,
});
return new Error(fallbackMessage);
};
type DredgingRecordListResponse = {
@@ -90,6 +93,8 @@ type DredgingRecordListResponse = {
export const fetchDredgingRecords = async (params: {
fromDate: string;
toDate: string;
ctpv?: string;
sgg?: string;
page?: number;
size?: number;
}): Promise<DredgingRecordListResponse> => {
@@ -102,8 +107,119 @@ export const fetchDredgingRecords = async (params: {
page: String(params.page ?? 0),
size: String(params.size ?? 2000),
});
const body = await getRawJson(`v1/dredging/records?${search.toString()}`);
return (body?.data ?? body) as DredgingRecordListResponse;
if (params.ctpv?.trim()) search.set("ctpv", params.ctpv.trim());
if (params.sgg?.trim()) search.set("sgg", params.sgg.trim());
try {
const body = await getRawJson(`v1/dredging/records?${search.toString()}`);
return (body?.data ?? body) as DredgingRecordListResponse;
} catch (error) {
throw toDredgingApiError(
error,
"준설현황을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.",
"준설현황 조회",
);
}
};
/** 등록된 준설사진의 공통 첨부파일 참조를 조회한다. */
export const fetchDredgingPhotos = async (
dredgingJobSn: string | number,
pipeFid: string,
): Promise<DredgingPhoto[]> => {
if (!isDredgingBackendEnabled) return [];
try {
const body = await getRawJson(
`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}/photos?fid=${encodeURIComponent(pipeFid)}`,
);
return (body?.data ?? body ?? []) as DredgingPhoto[];
} catch (error) {
throw toDredgingApiError(
error,
"등록된 준설사진을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.",
"준설사진 목록 조회",
);
}
};
/** 상세 기반 수정 팝업에서 선택 관로의 일자·위치·사진 구성을 함께 수정한다. */
export const updateDredgingRecord = async (
dredgingJobSn: string | number,
update: DredgingRecordUpdate,
attachments: Map<string, AttachmentReference>,
) => {
if (!isDredgingBackendEnabled) return true;
try {
await putData(`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}`, {
dredgingDate: update.dredgingDate,
pipeFid: update.originalPipeFid,
location: {
source: update.locationSource,
longitude: update.location.longitude,
latitude: update.location.latitude,
},
expectedPipe: {
fid: update.pipe.fid,
mngInstCd: update.pipe.mngInstCd,
fcltMngNo: update.pipe.fcltMngNo,
},
retainedPhotoSns: update.retainedPhotoSns,
newPhotos: update.newPhotos.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,
};
}),
});
return true;
} catch (error) {
throw toDredgingApiError(
error,
"준설정보를 수정하지 못했습니다. 잠시 후 다시 시도해주세요.",
"준설작업 수정",
);
}
};
/** 목록에서 선택한 준설작업과 관로·사진 참조를 모두 물리 삭제한다. */
export const deleteDredgingRecord = async (dredgingJobSn: string | number) => {
if (!isDredgingBackendEnabled) return true;
try {
const response = await deleteData(
`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}`,
);
if (response instanceof Response && !response.ok) {
throw new Error(`준설작업 삭제 응답 오류: ${response.status}`);
}
return true;
} catch (error) {
throw toDredgingApiError(
error,
"준설정보를 삭제하지 못했습니다. 잠시 후 다시 시도해주세요.",
"준설작업 삭제",
);
}
};
/** 공통 첨부서비스에서 준설사진 원본을 Blob으로 내려받는다. */
export const fetchDredgingPhotoBlob = async (atflId: string) => {
try {
return await getBlobData(
`/attach-service/api/v1/download/${encodeURIComponent(atflId)}`,
);
} catch (error) {
throw toDredgingApiError(
error,
"사진 원본을 불러오지 못했습니다.",
"준설사진 원본 조회",
);
}
};
const mockPipeForLocations = (locations: LocationRequest[]): PipeMatch => {
@@ -123,10 +239,11 @@ const mockPipeForLocations = (locations: LocationRequest[]): PipeMatch => {
pipeLengthM: Number((((seed % 8_000) + 2_000) / 100).toFixed(2)),
pipeMaterial: ["콘크리트", "PVC", "PE"][seed % 3] ?? "콘크리트",
installationYear: 1995 + (seed % 25),
swtrmtZoneFid: "MOCK-ZONE",
swtrmtZoneNm: "모의처리구역",
distanceMeters,
snappedLongitude: longitude + 0.00001,
snappedLatitude: latitude - 0.00001,
confidence: distanceMeters <= 10 ? "HIGH" : "MEDIUM",
isMock: true,
};
};
@@ -154,6 +271,7 @@ export const findNearestPipeMatches = async (
throw toDredgingApiError(
error,
"최근접 관로를 조회하지 못했습니다. 잠시 후 다시 시도해주세요.",
"최근접 관로 조회",
);
}
const items = (body?.data ?? body) as NearestPipeResponseItem[];
@@ -173,10 +291,11 @@ export const findNearestPipeMatches = async (
pipeLengthM: item.selected.pipeLengthM ?? 0,
pipeMaterial: item.selected.pipeMaterial ?? "",
installationYear: item.selected.installationYear ?? null,
swtrmtZoneFid: item.selected.swtrmtZoneFid ?? null,
swtrmtZoneNm: item.selected.swtrmtZoneNm ?? "",
distanceMeters: item.selected.distanceMeters,
snappedLongitude: item.selected.snappedLongitude,
snappedLatitude: item.selected.snappedLatitude,
confidence: item.selected.confidence,
isMock: false,
});
}
@@ -229,16 +348,33 @@ const uploadOnePhoto = (file: File): Promise<AttachmentReference> =>
export const uploadDredgingPhotos = async (
groups: DredgingSaveGroup[],
existingReferences: Map<string, AttachmentReference> = new Map(),
) => {
return uploadDredgingPhotoDrafts(
groups.flatMap((group) => group.photos),
existingReferences,
);
};
/** 등록·수정 화면이 공통으로 사용하는 신규 준설사진 첨부 업로드이다. */
export const uploadDredgingPhotoDrafts = async (
photos: DredgingPhotoDraft[],
existingReferences: Map<string, AttachmentReference> = new Map(),
) => {
const references = new Map(existingReferences);
if (!isDredgingBackendEnabled) return references;
for (const group of groups) {
for (const photo of group.photos) {
try {
for (const photo of photos) {
// DB 저장만 실패한 재시도에서는 이미 성공한 공통 첨부 업로드를 재사용한다.
if (references.has(photo.key)) continue;
references.set(photo.key, await uploadOnePhoto(photo.file));
}
} catch (error) {
throw toDredgingApiError(
error,
"준설사진을 업로드하지 못했습니다. 잠시 후 다시 시도해주세요.",
"준설사진 업로드",
);
}
return references;
};
@@ -250,42 +386,49 @@ export const saveDredgingRegistrations = async (
if (!isDredgingBackendEnabled) {
return {
mock: true,
registeredCount: groups.length,
registeredCount: 1,
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,
};
}),
})),
});
try {
return await postRawJson("v1/dredging/registrations", {
clientRequestId,
dredgingDate: groups[0]?.dredgingDate,
groups: groups.map((group, groupIndex) => ({
groupSn: groupIndex + 1,
registrationMode: group.registrationMode,
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,
},
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,
};
}),
})),
});
} catch (error) {
throw toDredgingApiError(
error,
"준설결과를 저장하지 못했습니다. 잠시 후 다시 시도해주세요.",
"준설결과 저장",
);
}
};
@@ -11,8 +11,6 @@ export type ExifStatus =
| "MISSING_BOTH"
| "UNSUPPORTED";
export type MatchConfidence = "HIGH" | "MEDIUM" | "LOW";
export type GeoPoint = {
longitude: number;
latitude: number;
@@ -40,10 +38,11 @@ export type PipeMatch = {
pipeLengthM: number;
pipeMaterial: string;
installationYear: number | null;
swtrmtZoneFid: string | null;
swtrmtZoneNm: string;
distanceMeters: number;
snappedLongitude: number;
snappedLatitude: number;
confidence: MatchConfidence;
isMock: boolean;
};
@@ -52,15 +51,16 @@ export type DredgingDraftGroup = {
registrationMode: DredgingRegistrationMode;
photos: DredgingPhotoDraft[];
manualLocation: GeoPoint | null;
manualDate: string;
matchedPipe: PipeMatch | null;
matchError: string | null;
isMatching: boolean;
lowConfidenceConfirmed: boolean;
};
export type DredgingRecord = {
id: string;
dredgingJobSn: number | string;
pipeFid: string;
mngInstCd: string;
ctpv: string;
sgg: string;
treatmentZone: string;
@@ -74,12 +74,22 @@ export type DredgingRecord = {
registeredDate: string;
longitude: number;
latitude: number;
snappedLongitude: number;
snappedLatitude: number;
registrationSource: DredgingLocationSource;
matchDistanceMeters: number;
matchConfidence: MatchConfidence;
isMock: boolean;
};
export type DredgingPhoto = {
photoSn: number;
pipeFid: string;
atflCd: string;
atflId: string;
originalFileName: string;
capturedAt: string | null;
};
export type DredgingSaveGroup = {
localId: string;
registrationMode: DredgingRegistrationMode;
@@ -88,7 +98,17 @@ export type DredgingSaveGroup = {
locationSource: DredgingLocationSource;
pipe: PipeMatch;
photos: DredgingPhotoDraft[];
lowConfidenceConfirmed: boolean;
};
/** 상세 기반 수정 팝업에서 선택한 한 관로의 위치·사진을 함께 갱신한다. */
export type DredgingRecordUpdate = {
dredgingDate: string;
originalPipeFid: string;
location: GeoPoint;
locationSource: DredgingLocationSource;
pipe: PipeMatch;
retainedPhotoSns: number[];
newPhotos: DredgingPhotoDraft[];
};
export const EMPTY_PHOTO_METADATA: PhotoMetadata = {
@@ -7,35 +7,47 @@
*/
"use client";
import dynamic from "next/dynamic";
import { SelectBox } from "@common/business_components/ui";
import { SearchDiv } from "@common/business_components/ui/search_form";
import { UiModal } from "@common/business_components/ui/dialog_ui";
import { UiTable } from "@common/business_components/ui/table";
import ListDiv from "@common/business_components/ui/list_div";
import { Button } from "@common/components/ui/button";
import { Input } from "@common/components/ui/input";
import { Label } from "@common/components/ui/label";
import { MS_SWAGE_PPL_CODES } from "@/app/(map-layout)/map/components/common/table";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import useStore from "@/store/useStore";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
deleteDredgingRecord,
fetchDredgingRecords,
isDredgingBackendEnabled,
} from "./_lib/client";
} from "./lib/client";
import type {
DredgingRecord,
DredgingSaveGroup,
DredgingViewType,
} from "./_lib/types";
// OpenLayers 지도는 사용자가 지도 팝업을 열 때만 컴파일해 목록의 최초 로딩을 가볍게 유지한다.
const DredgingLocationMap = dynamic(
() => import("./_components/DredgingLocationMap"),
{ ssr: false },
);
} from "./lib/types";
const PAGE_SIZE = 10;
const ALL_REGION_OPTION = { text: "전체", val: "0" };
const pipeMaterialNameByCode = new Map(
MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text]),
);
const resolvePipeMaterialName = (value: string) => {
const normalizedValue = value.trim();
if (!normalizedValue) return "확인불가";
const normalizedCode = normalizedValue.toUpperCase();
if (!/^MOP\d+$/.test(normalizedCode)) return normalizedValue;
return pipeMaterialNameByCode.get(normalizedCode) ?? "확인불가";
};
// 백엔드·프록시 상세 오류 대신 사용자가 다음 행동을 알 수 있는 문구만 표시한다.
const DREDGING_LOAD_ERROR_MESSAGE =
"준설현황을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.";
const currentYear = new Date().getFullYear();
const ACTION_BUTTON_CLASS =
"rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white";
@@ -50,6 +62,9 @@ const formatLocalDate = (date: Date) => {
const initialRecords: DredgingRecord[] = [
{
id: "mock-dredging-1",
dredgingJobSn: "mock-dredging-job-1",
pipeFid: "MOCK-PIPE-1",
mngInstCd: "4173000",
ctpv: "경기도",
sgg: "양평군",
treatmentZone: "양평처리구역",
@@ -63,13 +78,17 @@ const initialRecords: DredgingRecord[] = [
registeredDate: `${currentYear}-05-15`,
longitude: 127.4875,
latitude: 37.4916,
snappedLongitude: 127.48751,
snappedLatitude: 37.49159,
registrationSource: "EXIF",
matchDistanceMeters: 4.28,
matchConfidence: "HIGH",
isMock: true,
},
{
id: "mock-dredging-2",
dredgingJobSn: "mock-dredging-job-2",
pipeFid: "MOCK-PIPE-2",
mngInstCd: "1153000",
ctpv: "서울특별시",
sgg: "구로구",
treatmentZone: "서남처리구역",
@@ -83,13 +102,41 @@ const initialRecords: DredgingRecord[] = [
registeredDate: `${currentYear - 1}-11-09`,
longitude: 126.8874,
latitude: 37.4954,
snappedLongitude: 126.88741,
snappedLatitude: 37.49539,
registrationSource: "MANUAL",
matchDistanceMeters: 8.15,
matchConfidence: "HIGH",
isMock: true,
},
];
/** 상세·수정 팝업이 동일한 관로·위치 정보를 받도록 쿼리 파라미터를 한곳에서 구성한다. */
const buildDredgingPopupParams = (record: DredgingRecord) => ({
sourcePath: window.location.pathname,
id: record.id,
dredgingJobSn: record.dredgingJobSn,
pipeFid: record.pipeFid,
mngInstCd: record.mngInstCd,
ctpv: record.ctpv,
sgg: record.sgg,
treatmentZone: record.treatmentZone,
fcltMngNo: record.fcltMngNo,
pipeDiameterMm: record.pipeDiameterMm,
pipeLengthM: record.pipeLengthM,
pipeMaterial: record.pipeMaterial,
installationYear: record.installationYear,
dredgingDate: record.dredgingDate,
photoCount: record.photoCount,
registeredDate: record.registeredDate,
longitude: record.longitude,
latitude: record.latitude,
snappedLongitude: record.snappedLongitude,
snappedLatitude: record.snappedLatitude,
registrationSource: record.registrationSource,
matchDistanceMeters: record.matchDistanceMeters,
isMock: record.isMock,
});
type TableRow = Record<string, unknown> & {
recordIds: string[];
};
@@ -220,10 +267,21 @@ const buildElapsedYearRows = (records: DredgingRecord[]): TableRow[] =>
const Page = () => {
const today = formatLocalDate(new Date());
const {
ctpv: ctpvRegionOptions,
sgg: sggRegionOptions,
fetchCtpv,
fetchSgg,
} = useStore();
const [fromDate, setFromDate] = useState(`${currentYear}-01-01`);
const [toDate, setToDate] = useState(today);
const [ctpv, setCtpv] = useState("전체");
const [sgg, setSgg] = useState("전체");
// 선택 UI는 행정구역 코드를, 준설 조회 API는 행정구역 명칭을 사용한다.
const [ctpvCd, setCtpvCd] = useState("0");
const [sggCd, setSggCd] = useState("0");
const [ctpvName, setCtpvName] = useState("");
const [sggName, setSggName] = useState("");
const [appliedCtpvName, setAppliedCtpvName] = useState("");
const [appliedSggName, setAppliedSggName] = useState("");
const [viewType, setViewType] = useState<DredgingViewType>("MUNICIPALITY");
const [records, setRecords] = useState<DredgingRecord[]>(
isDredgingBackendEnabled ? [] : initialRecords,
@@ -231,10 +289,26 @@ const Page = () => {
const [isLoading, setIsLoading] = useState(isDredgingBackendEnabled);
const [loadError, setLoadError] = useState<string | null>(null);
const [selectedRecordId, setSelectedRecordId] = useState<string | null>(null);
const [isMapOpen, setIsMapOpen] = useState(false);
// 삭제 중 중복 요청과 선택 변경을 막는다.
const [isMutating, setIsMutating] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [searchVersion, setSearchVersion] = useState(0);
/** 공통 레이아웃에서 아직 지역 기준정보를 받지 못한 경우 조회를 보완한다. */
useEffect(() => {
if (ctpvRegionOptions.length === 0) void fetchCtpv();
if (sggRegionOptions.length === 0) void fetchSgg();
}, [ctpvRegionOptions.length, fetchCtpv, fetchSgg, sggRegionOptions.length]);
/** 선택한 시도 코드와 앞 두 자리가 같은 시군구만 후보로 제공한다. */
const availableSggOptions = useMemo(() => {
if (ctpvCd === "0") return sggRegionOptions;
const ctpvPrefix = ctpvCd.slice(0, 2);
return sggRegionOptions.filter((option) =>
String(option.val).startsWith(ctpvPrefix),
);
}, [ctpvCd, sggRegionOptions]);
// 조회 버튼을 누른 시점의 기간으로 실제 준설관리 API를 호출한다.
useEffect(() => {
if (!isDredgingBackendEnabled) return;
@@ -246,17 +320,15 @@ const Page = () => {
const response = await fetchDredgingRecords({
fromDate,
toDate,
ctpv: appliedCtpvName,
sgg: appliedSggName,
size: 2000,
});
if (!cancelled) setRecords(response.items ?? []);
} catch (error) {
} catch {
if (!cancelled) {
setRecords([]);
setLoadError(
error instanceof Error
? error.message
: "준설현황을 조회하지 못했습니다.",
);
setLoadError(DREDGING_LOAD_ERROR_MESSAGE);
}
} finally {
if (!cancelled) setIsLoading(false);
@@ -276,10 +348,10 @@ const Page = () => {
(record) =>
record.dredgingDate >= fromDate &&
record.dredgingDate <= toDate &&
(ctpv === "전체" || record.ctpv === ctpv) &&
(sgg === "전체" || record.sgg === sgg),
(!appliedCtpvName || record.ctpv === appliedCtpvName) &&
(!appliedSggName || record.sgg === appliedSggName),
),
[records, fromDate, toDate, ctpv, sgg, searchVersion],
[records, fromDate, toDate, appliedCtpvName, appliedSggName, searchVersion],
);
const tableRows = useMemo<TableRow[]>(() => {
@@ -291,6 +363,7 @@ const Page = () => {
rowNumber: index + 1,
...record,
pipeLengthM: Number(record.pipeLengthM.toFixed(2)),
pipeMaterial: resolvePipeMaterialName(record.pipeMaterial),
installationYear: record.installationYear ?? "확인불가",
elapsedYears:
record.installationYear === null
@@ -308,27 +381,16 @@ const Page = () => {
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 selectedJobRecords = selectedRecord
? records.filter(
(record) => record.dredgingJobSn === selectedRecord.dredgingJobSn,
)
: [];
const canManageSelectedRecord =
viewType === "MUNICIPALITY" && selectedRecord !== null && !isMutating;
// 관경별·경과년도별 행은 여러 관로를 합산한 집계 행이므로 단일 관로 상세를 열지 않는다.
const canViewSelectedRecord =
viewType === "MUNICIPALITY" && selectedRecord !== null;
const headlist =
viewType === "MUNICIPALITY"
@@ -351,6 +413,8 @@ const Page = () => {
}
setCurrentPage(1);
setSelectedRecordId(null);
setAppliedCtpvName(ctpvName);
setAppliedSggName(sggName);
setSearchVersion((version) => version + 1);
};
@@ -390,11 +454,15 @@ const Page = () => {
return;
}
const registeredDate = formatLocalDate(new Date());
const addedRecords = savedGroups.map<DredgingRecord>((group) => ({
id: `local-${crypto.randomUUID()}`,
ctpv: ctpv === "전체" ? "경기도" : ctpv,
sgg: sgg === "전체" ? "양평군" : sgg,
treatmentZone: "양평처리구역",
const localJobSn = `local-job-${crypto.randomUUID()}`;
const addedRecords = savedGroups.map<DredgingRecord>((group, index) => ({
id: `${localJobSn}-${index + 1}`,
dredgingJobSn: localJobSn,
pipeFid: group.pipe.fid,
mngInstCd: group.pipe.mngInstCd,
ctpv: ctpvName || "경기도",
sgg: sggName || "양평군",
treatmentZone: group.pipe.swtrmtZoneNm || "확인불가",
fcltMngNo: group.pipe.fcltMngNo,
pipeDiameterMm: group.pipe.pipeDiameterMm,
pipeLengthM: group.pipe.pipeLengthM,
@@ -405,26 +473,73 @@ const Page = () => {
registeredDate,
longitude: group.location.longitude,
latitude: group.location.latitude,
snappedLongitude: group.pipe.snappedLongitude,
snappedLatitude: group.pipe.snappedLatitude,
registrationSource: group.locationSource,
matchDistanceMeters: group.pipe.distanceMeters,
matchConfidence: group.pipe.confidence,
isMock: group.pipe.isMock || !isDredgingBackendEnabled,
}));
setRecords((current) => [...addedRecords, ...current]);
setCurrentPage(1);
},
[ctpv, sgg],
[ctpvName, sggName],
);
/** 상세 화면과 같은 최대화 팝업에서 준설일자·사진·위치를 함께 수정한다. */
const openEditRecord = () => {
if (!canManageSelectedRecord || !selectedRecord) return;
const popup = openWindowPopup(
"dredgingEdit",
buildDredgingPopupParams(selectedRecord),
{ maximize: true },
);
if (!popup) {
alert(
"준설정보 수정 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.",
);
}
};
/** 선택 행이 속한 준설작업과 연결 관로·사진 참조를 물리 삭제하고 목록을 재조회한다. */
const handleDeleteRecord = async () => {
if (!canManageSelectedRecord || !selectedRecord) return;
const confirmed = window.confirm(
`선택한 준설작업 전체를 삭제하시겠습니까?\n준설일자: ${selectedRecord.dredgingDate}\n연결 관로: ${selectedJobRecords.length}건 · 등록사진: ${selectedRecord.photoCount}건`,
);
if (!confirmed) return;
setIsMutating(true);
try {
await deleteDredgingRecord(selectedRecord.dredgingJobSn);
if (isDredgingBackendEnabled) {
setSearchVersion((version) => version + 1);
} else {
setRecords((current) =>
current.filter(
(record) => record.dredgingJobSn !== selectedRecord.dredgingJobSn,
),
);
}
setSelectedRecordId(null);
alert("준설정보를 삭제했습니다.");
} catch (error) {
alert(
error instanceof Error
? error.message
: "준설정보를 삭제하지 못했습니다.",
);
} finally {
setIsMutating(false);
}
};
/** 공통 WindowPopup 체계로 준설결과 등록 화면을 연다. */
const openDredgingRegister = () => {
const popup = openWindowPopup(
"dredgingRegister",
{ sourcePath: window.location.pathname },
{
width: window.screen.availWidth,
height: window.screen.availHeight,
},
{ maximize: true },
);
if (!popup) {
alert(
@@ -433,10 +548,33 @@ const Page = () => {
}
};
/** 등록 팝업의 저장 완료 메시지를 받아 목록에 신규 준설결과를 반영한다. */
/** 선택 관로의 정보·사진·위치를 하나의 최대화 WindowPopup에서 확인한다. */
const openDredgingDetail = () => {
if (!canViewSelectedRecord || !selectedRecord) return;
const popup = openWindowPopup(
"dredgingDetail",
buildDredgingPopupParams(selectedRecord),
{ maximize: true },
);
if (!popup) {
alert(
"준설결과 상세 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.",
);
}
};
/** 등록·수정 팝업의 저장 완료 메시지를 받아 목록을 최신 상태로 갱신한다. */
useEffect(() => {
const handlePopupMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.DREDGING_UPDATED) {
setSelectedRecordId(null);
setCurrentPage(1);
setSearchVersion((version) => version + 1);
return;
}
if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.DREDGING_REGISTERED) {
return;
}
@@ -499,38 +637,47 @@ const Page = () => {
</select>
</label>
<label className="mr-8 flex items-center gap-2 whitespace-nowrap text-xl font-medium text-gray-600">
시도
<select
value={ctpv}
onChange={(event) => {
setCtpv(event.target.value);
setSgg("전체");
<div className="mr-8 flex items-center">
<SelectBox
label="시도"
className=""
labelClass="w-16 whitespace-nowrap text-xl font-medium text-gray-600"
selectClass="w-[140px] rounded-lg bg-white"
selectArray={[ALL_REGION_OPTION, ...ctpvRegionOptions]}
value={ctpvCd}
onSelectValue={(value) => {
const nextCtpvCd = String(value);
setCtpvCd(nextCtpvCd);
setSggCd("0");
setCtpvName(
ctpvRegionOptions.find(
(option) => String(option.val) === nextCtpvCd,
)?.text ?? "",
);
setSggName("");
}}
className="h-[3.2rem] min-w-[135px] rounded-lg border border-input bg-white px-3 text-base font-normal text-slate-900"
>
{ctpvOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
/>
</div>
<label className="mr-8 flex items-center gap-2 whitespace-nowrap text-xl font-medium text-gray-600">
시군구
<select
value={sgg}
onChange={(event) => setSgg(event.target.value)}
className="h-[3.2rem] min-w-[125px] rounded-lg border border-input bg-white px-3 text-base font-normal text-slate-900"
>
{sggOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<div className="mr-8 flex items-center">
<SelectBox
label="시군구"
className=""
labelClass="w-20 whitespace-nowrap text-xl font-medium text-gray-600"
selectClass="w-[140px] rounded-lg bg-white"
selectArray={[ALL_REGION_OPTION, ...availableSggOptions]}
value={sggCd}
onSelectValue={(value) => {
const nextSggCd = String(value);
setSggCd(nextSggCd);
setSggName(
availableSggOptions.find(
(option) => String(option.val) === nextSggCd,
)?.text ?? "",
);
}}
/>
</div>
<div className="ml-auto flex shrink-0 items-center gap-2">
<Button
size="sm"
@@ -558,11 +705,32 @@ const Page = () => {
</Button>
<Button
size="sm"
onClick={openEditRecord}
className={ACTION_BUTTON_CLASS}
disabled={!selectedRecord}
onClick={() => setIsMapOpen(true)}
disabled={!canManageSelectedRecord}
data-testid="dredging-edit-button"
>
지도보기
수정
</Button>
<Button
size="sm"
onClick={() => {
void handleDeleteRecord();
}}
className={ACTION_BUTTON_CLASS}
disabled={!canManageSelectedRecord}
data-testid="dredging-delete-button"
>
삭제
</Button>
<Button
size="sm"
className={ACTION_BUTTON_CLASS}
disabled={!canViewSelectedRecord}
onClick={openDredgingDetail}
data-testid="dredging-detail-button"
>
상세보기
</Button>
</div>
</div>
@@ -571,9 +739,17 @@ const Page = () => {
{loadError ? (
<div
role="alert"
className="mt-3 rounded-sm border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
className="mt-3 flex items-center justify-between gap-3 rounded-sm border border-amber-200 bg-amber-50 px-4 py-2.5 text-sm text-amber-800"
>
준설현황 조회 실패: {loadError}
<span>{loadError}</span>
<Button
type="button"
size="sm"
className="h-8 shrink-0 border border-amber-300 bg-white px-3 text-amber-800 hover:bg-amber-100"
onClick={handleSearch}
>
다시 시도
</Button>
</div>
) : null}
@@ -595,49 +771,6 @@ const Page = () => {
widthFixed={false}
/>
</ListDiv>
<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
}
/>
</>
);
};
@@ -31,7 +31,7 @@ import { ENDPOINT } from "@common/components/config";
import { getUserInfo } from "@common/components/api";
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
import { SearchDatePicker } from '../_components/SearchDatePicker';
import { SearchDatePicker } from '../components/SearchDatePicker';
import type { UiTableSort } from '@common/business_components/ui';
@@ -31,7 +31,7 @@ import { ENDPOINT } from "@common/components/config";
import { getUserInfo } from "@common/components/api";
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
import { SearchDatePicker } from '../_components/SearchDatePicker';
import { SearchDatePicker } from '../components/SearchDatePicker';
import type { UiTableSort } from '@common/business_components/ui';
+8 -6
View File
@@ -19,7 +19,8 @@ export const dynamic = "force-dynamic";
export const maxDuration = 600;
/**
* 대용량 multipart 본문을 middleware rewrite의 복제 단계 없이 백엔드로 그대로 전달한다.
* multipart 본문을 한 번 버퍼링해 백엔드에 정확한 Content-Length로 전달한다.
* 로컬 Node Web Stream의 chunked 재전송 지연을 피하며, 허용 크기는 백엔드의 50MB 제한을 따른다.
*/
export async function POST(request: NextRequest) {
const sewerageApiUrl = process.env.SEWERAGE_API_URL?.trim();
@@ -48,7 +49,7 @@ export async function POST(request: NextRequest) {
const requestHeaders = new Headers(request.headers);
HOP_BY_HOP_HEADERS.forEach((headerName) => requestHeaders.delete(headerName));
requestHeaders.delete("host");
// Next가 라우트 본문을 Web Stream으로 다시 노출하므로 원 요청 길이를 전달하면 undici가 불일치로 중단할 수 있다.
// 원 요청 길이는 Next가 다시 구성한 multipart 본문 길이와 다를 수 있어 아래에서 다시 설정한다.
requestHeaders.delete("content-length");
// undici fetch는 curl 등이 대용량 요청에 붙이는 Expect: 100-continue 전달을 지원하지 않는다.
requestHeaders.delete("expect");
@@ -62,15 +63,16 @@ export async function POST(request: NextRequest) {
const targetUrl = `${sewerageApiUrl.replace(/\/$/, "")}${GIS_UPLOAD_PATH}`;
try {
const requestBody = await request.arrayBuffer();
requestHeaders.set("content-length", String(requestBody.byteLength));
const upstreamResponse = await fetch(targetUrl, {
method: "POST",
headers: requestHeaders,
body: request.body,
body: requestBody,
cache: "no-store",
signal: request.signal,
// Node fetch는 ReadableStream 요청 본문을 전송할 때 duplex 지정이 필요하다.
duplex: "half",
} as RequestInit & { duplex: "half" });
});
const responseHeaders = new Headers(upstreamResponse.headers);
HOP_BY_HOP_HEADERS.forEach((headerName) =>
@@ -1,5 +1,37 @@
# SFR-010-012 준설관리 FE 누적 검증결과
## 0.8 2026-08-06 등록 UI·처리구역·사진조회 회귀검증
| 항목 | 결과 | 확인 내용 |
| --- | --- | --- |
| 등록 건 삭제 | 통과 | 건 2 추가 시 `등록 건 2 삭제` X 노출, 삭제 후 건 1 유지, 건 1에는 삭제 버튼 없음 |
| 위치 마커 | 통과 | 수동 위치 클릭 즉시 파란 임시 마커 표시, 적용 후 빨간 확정 마커 표시 |
| 위치수정 포커스 | 통과 | 확정 후 지도를 이동하고 `위치 수정` 진입해도 이동한 범위와 마커 화면 위치 유지 |
| 상단 밀도·지도 높이 | 통과 | 상단 영역 축소, 사진목록 최대 112px 내부 스크롤, 지도 최소 300px·잔여 높이 확장 |
| 관망도 관로 계약 | 통과 | DB 레이어 설정 적용 문구와 줌 17에서 관망도 관로 12,663건 표시, 콘솔 error/warn 0건 |
| 처리구역 공간매핑 | 통과 | MS 관로 `4119279` → FID `181`, `전북혁신도시처리구역`; 등록·단건·목록 셀 표출 후 검증 데이터 삭제 |
| 등록사진 조회 | 통과 | 작업 121의 사진 1건 API 조회, `사진보기` 팝업에서 실제 JPG 원본 렌더링 |
| 백엔드 대상 테스트 | 통과 | `DredgingServiceTest`, `DredgingNearestPipeRepositoryTest` 성공 |
| TypeScript | 변경 파일 통과/전체 기존 오류 | `oper/dredging` 신규 오류 0건, 준설 외 기존 오류 존재 |
| Next build | 미수행 | 사용자 소유 3002 개발 서버와 `.next` 보호 기준 적용 |
## 0.7 2026-08-05 독립 준설 테이블 전환 회귀검증
| 항목 | 결과 | 확인 내용 |
| --- | --- | --- |
| API 최근접 관로 | 통과 | `fid=3538280`, 관로 `2400429`, 거리 `0.000m`, HIGH 반환; 자산 매핑 요구 없음 |
| API 등록 | 통과 | 작업 101번과 신규 `tb_sn_drdgn_ppl` 관로 저장 성공 |
| 멱등 등록 | 통과 | 동일 요청 재전송 시 등록 0건·기등록 1건 |
| 목록·상세 | 통과 | 관경 200, 연장 42.80, 재질 `MOP919`, 매설년도 2024 확인 |
| 개발 DB | 통과 | 기존 작업 1건의 관로 무손실 이관, 구 사업/자산 컬럼 제거, 코멘트 깨짐/설명 접미사 없음 |
| 실제 목록 화면 | 통과 | 경상남도/양산시, 관로 `2400429`, 관재질 `MOP919` 1건 표시 |
| 수동 위치 흐름 | 통과 | 날짜 입력, 지도 위치 선택, 관로 위 재선택 후 `2024100064`, 19.58m, 보통 연결 |
| 화면 저장 | API로 보완 | 자동화 브라우저 alert 제어 응답 없음; 실제 API 등록·멱등·목록·상세로 저장 계약 검증 |
| TypeScript | 변경 파일 통과/전체 기존 오류 | 준설 client 신규 오류 없음; 전체 검사는 준설 외 기존 오류로 실패 |
| Next build | 미수행 | 사용자 소유 3002 개발 서버와 `.next` 보호 기준 적용 |
0.5~0.6의 `PIPE_ASSET_*` 판정은 2026-08-05 사용자 결정으로 폐기된 과거 회귀 이력이다.
## 0.6 2026-08-05 사진 위치 보정 이전 오류 제거 회귀검증
| 항목 | 결과 | 확인 내용 |
@@ -43,7 +75,7 @@
| 관로 WFS 식별자 연계 | 통과 | 같은 FID의 WFS feature ID가 `tb_ms_swage_ppl.3538280`, 시설관리번호가 `2400429`임을 실 GeoServer로 확인하여 자동 연결 관로 강조 조건과 일치 |
| 프록시/서버 오류 표시 | 통과 | JSON이 아닌 500 응답의 `Unexpected token` 원문 대신 `최근접 관로를 조회하지 못했습니다...` 사용자 문구로 변환 |
| 주변 관로 표시 | 통과 | 수동 위치 선택 후 지도 줌 17, `주변 관로 128건` 및 `154건` 표시를 실제 화면에서 확인 |
| 개발 DB 등록 | 통과 | 수동 등록 1건 생성(jobSn `81`), 상세·목록 조회 후 논리 삭제 및 잔여 0건 확인 |
| 개발 DB 등록 | 통과 | 수동 등록 1건 생성(jobSn `81`), 상세·목록 조회 후 삭제 및 잔여 0건 확인 |
| 멱등 등록 | 통과 | 같은 `clientRequestId` 재호출 시 등록 0건·기등록 1건 반환 |
| 통계 API 회귀 | 통과 | `MUNICIPALITY`, `DIAMETER`, `ELAPSED_YEAR` 모두 응답 1행 확인 |
| 목록 화면 회귀 | 통과 | 지자체별·관경별·경과년도별 제목/컬럼 전환, 최종 목록 0건, 콘솔 error/warn 0건 |
@@ -181,3 +213,128 @@ Chrome 확장의 파일 URL 접근 권한이 꺼져 있어 이번 회귀에서 E
- 실제 MS 관로 geometry로 거리·스냅 좌표·동률 처리·탐색 반경을 검증한다.
- 서버 원본 EXIF 재분석 결과와 브라우저 분석 결과 불일치 케이스를 검증한다.
- 실제 XLSX 다운로드 파일명, 시트명, 행·열 값을 브라우저에서 다시 확인한다.
## 6. 2026-08-06 회귀검증
| 검증 항목 | 결과 | 근거 |
| --- | --- | --- |
| 대표 준설일자 단일 입력 | 통과 | 등록창 상단에 날짜 입력 1개만 표시 |
| 등록 탭 간 날짜 유지 | 통과 | `2026-08-04` 입력 후 등록 건 2로 전환해 동일 값 확인 |
| 단일 작업·다중 관로 저장 | 통과 | 개발 API에서 작업 1건, 관로 2건, 고유 목록 행 2건 확인 |
| 서로 다른 사진 촬영일 허용 | 통과 | 촬영일이 다른 사진 2장이 동일 대표 준설일자 작업에 저장됨 |
| 관로별 사진 조회 | 통과 | 같은 작업의 두 관로가 `jobSn + fid` 기준으로 서로 다른 사진 1건씩 반환 |
| 백엔드 전체 테스트 | 통과 | 128건, 실패 0건, `gradlew test` BUILD SUCCESSFUL |
| 프론트 준설 변경 파일 타입 검사 | 통과 | 준설 경로 신규 진단 0건 |
| 프론트 전체 타입 검사 | 기존 오류 | `ignoreDeprecations` 및 준설 외 기존 진단이 남아 전체 종료코드는 0이 아님 |
| Next 빌드 | 미수행 | 사용자 실행 중인 3002 개발 서버 보호 기준에 따라 생략 |
사진 조회는 관로별 분리 방식으로 보완됐으며, 3002 프록시를 통한 목록과 사진 바이너리 표시를 실제 브라우저에서 확인했다.
## 7. 2026-08-06 통신 오류 화면 회귀검증
| 검증 항목 | 결과 | 근거 |
| --- | --- | --- |
| 실제 백엔드 실패 조건 | 확인 | 사용자 실행 8883 목록 API가 HTTP 500을 반환하는 상태 사용 |
| 사용자 안내 문구 | 통과 | `준설현황을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.` 표시 |
| 기술정보 비노출 | 통과 | DOM에 `500`, `Internal Server Error`, `Failed to fetch data`, `doctype/html` 없음 |
| 재시도 흐름 | 통과 | `다시 시도` 클릭 후 재조회되고 동일한 사용자 안내로 복귀 |
| 개발 오류 오버레이 | 통과 | 기술 오류를 화면 오류로 다시 기록하지 않아 Next 오류 배지 없음 |
| 준설 TypeScript 진단 | 통과 | 준설 경로 신규 진단 0건 |
| 코드 형식 | 통과 | Prettier 및 변경 파일 `git diff --check` 통과 |
백엔드가 정상 응답하도록 재기동된 뒤에는 조회 성공 시 안내 영역이 사라지고 목록 데이터가 표시되는 정상 전환도 재확인한다.
## 8. 2026-08-06 최대화·좌우 분할 UI 회귀검증
| 검증 항목 | 결과 | 근거 |
| --- | --- | --- |
| 최대화 팝업 | 통과 | Chrome 실제 팝업 viewport `1920 × 855` 확인 |
| 좌우 분할 | 통과 | 최대화 화면에서 왼쪽 입력영역 520px, 오른쪽 지도 1380px 확인 |
| 지도 가용 영역 | 통과 | 지도는 오른쪽 열의 남은 폭과 높이를 모두 사용하고 입력영역 스크롤과 분리됨 |
| 부모 화면 달력 스타일 | 통과 | 부모·팝업 모두 높이 32px, 폭 155px, 글자 12.5px, 색상 `rgb(102, 102, 102)` 확인 |
| 등록 건 추가·삭제 | 통과 | 등록 건 2 추가 시 탭과 삭제 버튼 생성, 삭제 후 등록 건 1개 유지 |
| 등록 방식 전환 | 통과 | 사진 기반/사진 없이 직접 등록 전환 후 `aria-pressed` 상태 확인 |
| 위치 지정 흐름 | 통과 | 위치 지정 시 `취소`, `선택 위치 적용` 노출 후 취소 복귀 확인 |
| 파일명 중복 영역 | 통과 | 공통 업로드 목록 `display: none`, 메타정보 목록만 사용 |
| 개발 화면 오류 | 통과 | `Failed to compile`, `Build Error`, `Internal Server Error` 미표출 |
| 변경 파일 공백 검사 | 통과 | `git diff --check` 오류 없음 |
| ESLint | 환경 보류 | 기존 의존성/설정 불일치로 `eslint-plugin-react-hooks` 및 flat config 로딩 실패 |
| 전체 Next build | 미수행 | 사용자가 실행한 3002 개발 서버 보호 기준에 따라 생략 |
검증 화면은 `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/20260806-준설등록-최대화-팝업.png`에 저장했다.
## 9. 2026-08-06 목록 관재질 코드명 회귀검증
| 검증 항목 | 결과 | 근거 |
| --- | --- | --- |
| 목록 코드 비노출 | 통과 | 화면 본문에서 `MOP` 코드 패턴 미검출 |
| 관재질 코드명 | 통과 | `MOP012 → 흉관(HP)`, `MOP873 → 파형강관(CSP)`, `MOP005 → 폴리에틸렌관(PE)` 확인 |
| 목록 건수·다른 컬럼 | 통과 | 기존 6개 행과 준설일자·사진건수 등 다른 값 유지 |
| 개발 화면 오류 | 통과 | Build/Runtime 오류 문자열 없음 |
## 10. 2026-08-06 부모 목록 수정·삭제 회귀검증
| 검증 항목 | 결과 | 근거 |
| --- | --- | --- |
| 초기 버튼 상태 | 통과 | 행 선택 전 수정·삭제 비활성 |
| 선택 후 활성화 | 통과 | 지자체별 첫 행 선택 후 수정·삭제 활성 |
| 작업 단위 식별 | 통과 | 같은 작업의 관로 2건을 수정 안내에 함께 표시 |
| 수정 레이어 | 통과 | 기존 준설일자 `2026-06-20`, 최대일 `2026-08-06`, 취소·저장 표시 |
| 수정 취소 | 통과 | 레이어 닫힘 후 선택과 관리 버튼 상태 유지 |
| 삭제 확인 | 통과 | 삭제 버튼에서 JavaScript confirm 진입, 실제 데이터 삭제 없이 취소 |
| 집계행 보호 | 통과 | 관경별 조회에서 행을 눌러도 수정·삭제 비활성 |
| 브라우저 콘솔 | 통과 | 오류 로그 0건 |
| 백엔드 단위 테스트 | 통과 | `DredgingServiceTest` BUILD SUCCESSFUL |
| 백엔드 전체 회귀 | 통과 | 35 suites, 131 tests, 실패·오류 0건 |
| 프론트 전체 타입 검사 | 기존 오류 | 이번 준설 변경 파일 신규 오류는 없고 조사·침수·GIS 등 기존 오류만 남음 |
| ESLint | 환경 보류 | 기존 `eslint.config.mjs` flat config 로딩 오류 |
| 전체 Next build | 미수행 | 사용자가 실행한 3002 개발 서버 보호 기준에 따라 생략 |
사용자 실행 백엔드 8883은 수정 전 Spring 컨텍스트이므로 브라우저에서 실제 PUT을 보내지 않았다. 대신 변경 코드로 에이전트 전용 18884 서버를 기동해 작업 261번의 기존 준설일자 `2026-06-20`을 같은 값으로 PUT했고 204 응답과 상세 GET의 동일 날짜를 확인했다. 검증 서버는 종료했으며 3002·8883 사용자 프로세스는 유지했다.
## 11. 2026-08-06 시도·시군구 검색조건 회귀검증
| 검증 항목 | 결과 | 근거 |
| --- | --- | --- |
| 시도 기준정보 | 통과 | 실제 브라우저 시도 선택 목록에서 전국 17개 시도와 `전체` 확인 |
| 기존 조회값 독립성 | 통과 | 준설 목록은 2개 지역만 조회된 상태에서도 시도 선택 목록은 전국 기준정보 유지 |
| 시군구 연동 로직 | 정적 통과 | 시도 코드 앞 두 자리 필터, 시도 변경 시 시군구 코드·명칭 초기화 확인 |
| API 값 계약 | 통과 | 선택 코드는 화면 상태에 유지하고 API에는 공식 한글 명칭을 전송하도록 확인 |
| 서버 지역 조회 | 통과 | `ctpv=충청남도&sgg=서산시` 요청이 HTTP 200, 총 1건·서산시 행 반환 |
| 초기 목록 회귀 | 통과 | `/oper/dredging` 기존 2건과 관재질 코드명, 수정·삭제·사진·지도 버튼 정상 렌더링 |
| 브라우저 콘솔 | 통과 | 오류 로그 0건 |
| 프론트 전체 타입 검사 | 기존 오류 | 이번 준설 변경 파일 신규 오류는 없고 조사·침수·GIS·노후관로 엑셀 등 기존 오류만 남음 |
| ESLint | 환경 보류 | 기존 `eslint.config.mjs` flat config 로딩 오류 |
| 전체 Next build | 미수행 | 사용자가 실행한 3002 개발 서버 보호 기준에 따라 생략 |
브라우저 자동화 도구에서 공통 Radix 선택 항목의 포인터 선택 이벤트가 상태 변경까지 재현되지 않아 시도 선택 후 시군구 후보 축소는 소스와 기준정보 값으로 확인했다. 실제 브라우저에는 선택 목록과 화면 렌더링 오류가 없었고, 동일 명칭을 사용한 백엔드 필터 응답까지 별도로 검증했다.
## 12. 2026-08-06 통합 상세 WindowPopup 회귀검증
| 검증 항목 | 결과 | 근거 |
| --- | --- | --- |
| 부모 버튼 통합 | 통과 | 부모 화면에 사진보기·지도보기 대신 상세보기 한 개만 표시 |
| 선택 상태 연동 | 통과 | 초기 상세보기 비활성, 지자체별 첫 행 선택 후 활성 |
| 상세 좌우 배치 | 통과 | 1280px 검증 화면에서 왼쪽 420px, 오른쪽 지도 845px |
| 관로정보 | 통과 | 관로번호·준설일자·행정구역·관경·연장·관재질·매설년도·매핑 거리 표시 |
| 관재질 코드명 | 통과 | `MOP012 → 흉관(HP)`, `MOP033 → 철근콘크리트박스(RCB)` 표시 |
| 관로별 사진 분리 | 통과 | 작업 301의 FID 2398511과 3749103이 서로 다른 사진 1장씩 표시 |
| 사진 선택 미리보기 | 통과 | 사진목록 버튼 `aria-pressed=true`, 아래 미리보기 이미지 1건 표시 |
| 관망도 레이어 | 통과 | 상세 2건에서 각각 관망도 관로 13,365건·13,488건 표시 |
| 자동 연결 관로 | 통과 | 선택 관로 FID를 지도에 전달하고 관망도 위 강조 스타일 적용 |
| 브라우저 콘솔 | 통과 | 부모·상세 팝업 경고·오류 로그 0건 |
| 준설 TypeScript 진단 | 통과 | 이번 상세 팝업과 준설 경로 신규 진단 0건 |
| 프론트 전체 타입 검사 | 기존 오류 | 조사·침수·GIS·노후관로 엑셀 등 기존 오류로 종료코드 2 |
| 변경 파일 공백 검사 | 통과 | `git diff --check` 오류 없음 |
| Next build | 미수행 | 사용자가 실행한 3002 개발 서버 보호 기준에 따라 생략 |
## 13. 2026-08-06 준설관리 LNB 선택 표시 회귀검증
| 검증 항목 | 결과 | 근거 |
| --- | --- | --- |
| 준설관리 직접 진입 | 통과 | `/oper/dredging`에서 준설관리 `data-matched=true` |
| 잘못된 메뉴 선택 방지 | 통과 | 같은 화면에서 조사관리 `data-matched=false` |
| 조사관리 회귀 | 통과 | `/oper/exmn/register` 이동 후 조사관리만 `true`, 준설관리는 `false` |
| 공통 선택 스타일 | 통과 | 별도 준설 CSS 없이 기존 `bg-accent !text-primary` 규칙 적용 |
| 브라우저 콘솔 | 통과 | 새 검증 탭 경고·오류 0건 |
| Next build | 미수행 | 사용자가 실행한 3002 개발 서버 보호 기준에 따라 생략 |
@@ -180,3 +180,116 @@
- 복원 응답: `28-regression-rollback-completed.json`
- 복원 후 현재본·이력: `29-current-after-rollback.gpkg`, `30-history-after-rollback.json`
- 복원 데이터 대조: `32-regression-rollback-semantic-verification.txt`
## 7. 2026-08-07 SPAR 재검증·보완
### 7.1 범위와 추적 근거
- 요구사항정의서: `3. (기능)요구사항정의 및 추적표`, `SFR-012-002 하수관로자료 업로드/다운로드`
- 화면설계서: 슬라이드 33, 화면 ID `SN_MN010302`; 공유 데이터 회귀는 `SFR-012-003`, 화면 ID `SN_MN010301`
- 관리기관·데이터: 가평군 `4160000`, `tb_ms_swage_ppl`, `tb_ms_edit_swage_ppl`, EPSG:5179
- 사용자 소유 서버: FE `localhost:3002`, BE `localhost:8883`; 종료·재시작하거나 Next build를 병행하지 않음
### 7.2 다운로드와 GeoPackage
| 항목 | 결과 |
| --- | --- |
| 최신 다운로드 | HTTP 200, 11,169,792 byte, 12.629초 |
| 최신본 구조 | 16,670건, 26컬럼, MultiLineString, EPSG:5179 |
| FID·인덱스 | `fid INTEGER PRIMARY KEY`, `fid_1` 없음, RTree 존재 |
| 무결성 | `PRAGMA integrity_check=ok` |
| 선택 이력 | `GUP-20260729120404-B389A9CD`, HTTP 200, 11,149,312 byte, 16,671건 |
선택 이력의 16,671건은 과거 변경 행과 현재 미변경 행을 조합하는 현행 계약에서, 삭제 후 다른 FID로 복원된 현재 행이 함께 포함된 결과다. 전체 과거 시점 snapshot을 보장하지 않는 기존 구조적 한계로 분류했으며 이번 범위에서 제품 결함으로 수정하지 않았다.
### 7.3 업로드·오류·C/U/D
| 시나리오 | 업로드 ID / 결과 | 브라우저 처리시간 |
| --- | --- | ---: |
| 무변경 | `GUP-20260807104159-E41ED143`, C/U/D/오류 `0/0/0/0` | 19.123초 |
| 관리기관 불일치 | `GIS_UPLOAD_MNG_INST_MISMATCH`, 행 1, FID `1891744`, 이력·최종본 변경 없음 | 16.763초 |
| 1차 생성·수정 | `GUP-20260807104544-502263E3`, C/U/D/오류 `1/1/0/0` | 20.301초 |
| 2차 삭제·복원 | `GUP-20260807104858-88542F52`, C/U/D/오류 `0/1/1/0` | 20.852초 |
- 수정 대상 FID `1891744`는 `SPAR_QA_UPDATE_20260807`로 변경된 뒤 원래 `4121674063`으로 복원됐다.
- 임시 신규 FID `4132724`와 `SPAR_QA_NEW_20260807`은 2차 업로드에서 삭제됐다.
- 복원 후 최신본은 16,670건, 최대 FID `4132723`이며 기준본과 피처·속성·도형 의미 해시 `1290dc800d4be4166f7669a771fe8901a3314a6ceb5db424fb14e820cfb9f4f4`가 정확히 일치했다.
- 모든 쓰기 요청은 31.2초 회귀 조사 기준과 60초 실패 상한을 만족했다.
### 7.4 브라우저·자동화·정적 검증
- 이력은 최종 총 18건으로 1페이지 10건, 2페이지 8건을 확인했다.
- 생성·수정 이력과 복원 이력의 미리보기, 오류 0건 화면, 선택 이력 다운로드를 확인했다.
- 복원 이력 미리보기는 관로 3,557건, 맨홀 3,219건, 삭제 미표시 1건이었다.
- 조회·미리보기·오류 화면을 반복 전환했으나 기존 OpenLayers 0 크기 경고는 재현되지 않았고 기능 대상 console error·warning은 0건이었다.
- `gradlew.bat compileJava test --tests iipbs.sewerage.service.map.SwagePplEditServiceTest`: `BUILD SUCCESSFUL`
- fixture·검사 Python 스크립트: 문법 검사 통과
- FE 업로드 route: Prettier 통과. 기존 미수정 팝업·미리보기 2개 파일은 현재 저장소 형식 기준 Prettier 경고가 남아 자동 포맷하지 않았다.
- 실행 중인 사용자 Next 개발 서버 보호를 위해 `next build`는 수행하지 않았다.
### 7.5 보완 판정·롤백·잔여 위험
- 재현 가능한 제품 결함이 없어 FE·BE 제품 코드는 변경하지 않았다. 검증 재현용 SPAR 문서·GPKG 검사/fixture 스크립트와 증빙만 추가했다.
- 테스트 데이터는 최신본 기준으로 완전 복원되어 별도 데이터 롤백이 필요 없다.
- 검증 산출물 롤백은 `specs/active/sfr-012-002-gis-management-validation-hardening/`과 `검증/20260807/` 추가분 및 이 문서의 7절을 제거하면 된다.
- 선택 이력 전체 시점 snapshot 비지원, 미수정 FE 파일의 Prettier 경고, MS·SN 다중 DB 정합성 보상 정책은 기존 후속 위험으로 유지한다.
### 7.6 증빙
- 공통 증빙 폴더: `COMMON_DOCS_ROOT/작업내용/SFR-012-002-하수관망도 업로드/검증/20260807/`
- 기준본·검사: `05-gapyeong-current-baseline.gpkg`, `06-current-gpkg-inspection.json`, `35-final-semantic-comparison.json`
- 오류 차단: `16-invalid-management-error.png`, `18-current-after-invalid.gpkg`
- 생성·수정: `20-current-after-create-update.gpkg`, `21-create-update-gpkg-inspection.json`, `24-create-update-preview.png`
- 복원: `28-current-after-restore.gpkg`, `29-restore-gpkg-inspection.json`, `30-restore-preview-deleted-one.png`
- 복원 이력: `32-restore-history-download.gpkg`, `33-restore-history-inspection.json`
- 최종 이력 페이지: `34-final-history-page2.jpg`
## 8. 2026-08-07 GIS 업로드 10초 목표 검증·보완
### 8.1 판정 기준과 변경
- 원본 요구사항 `SFR-012-002`, 화면 `SN_MN010302`에는 처리시간 SLA가 없다. 10초는 이번 사용자 추가 목표다.
- 대상은 가평군 `4160000`, 11,169,792 byte, 16,670건 기준 GPKG다.
- 기존 `/api/gis-upload`는 Next Web Stream을 chunked 방식으로 백엔드에 재전송했다.
- 보완 후에는 multipart body를 한 번 버퍼링하고 실제 byte 수를 `Content-Length`로 설정해 재전송한다. API·DB·응답 계약과 백엔드 비교 로직은 변경하지 않았다.
### 8.2 성능 결과
| 구분 | 결과 | 판정 |
| --- | ---: | --- |
| 변경 전 FE route warm 3회 | 8.449 / 7.778 / 8.781초 | 10초 이내이나 여유 부족 |
| 변경 후 FE route warm 3회 | 4.686 / 5.593 / 4.568초 | 통과 |
| 변경 후 FE route 중앙값·최댓값 | 4.686 / 5.593초 | 통과 |
| 최종 브라우저 3회 | 7.576 / 7.615 / 8.937초 | 전건 통과 |
| 브라우저 중앙값·최댓값 | 7.615 / 8.937초 | 통과 |
| 관리기관 오류 route·브라우저 | 4.532 / 7.992초 | 통과 |
| 생성 1·수정 1 | 4.911초 | 통과 |
| 삭제 1·복원 1 | 6.351초 | 통과 |
- 에이전트 소유 BE 8894 warm HTTP 3회는 2.540 / 2.565 / 3.109초였다.
- warm 서버 stage 중앙값은 GPKG 읽기·signature 828ms, DB 현재본·signature 1,027ms, 분류 341ms, total 2,370ms였다.
- 변경 전 대비 FE route warm 중앙값은 44.53% 단축됐다.
### 8.3 기능·데이터 회귀
- 무변경은 16,670건, C/U/D/오류 `0/0/0/0`이다.
- 관리기관 불일치는 `GIS_UPLOAD_MNG_INST_MISMATCH`, 행 1, FID `1891744` 한 건으로 차단됐다.
- 생성·수정은 C/U/D `1/1/0`, 삭제·복원은 `0/1/1`로 응답·이력이 일치했다.
- 최종 현재본은 16,670건, FID `1891744`~`4132723`, 전체 26개 컬럼·geometry BLOB 해시가 기준본과 정확히 일치했다.
- 이력 총 44건·7개 페이지 링크, 미리보기 관로 3,557건·맨홀 3,219건, 오류 확인 화면을 실제 브라우저에서 확인했다.
### 8.4 자동화·정적 검사와 제한
- `SwagePplEditServiceTest`: `BUILD SUCCESSFUL`
- 변경 route Prettier: 통과
- TypeScript: 기존 `tsconfig.json`의 `ignoreDeprecations` 값과 실행 가능한 프로젝트 `tsc` 부재로 시작되지 않음
- 실행 중인 사용자 Next 개발 서버 보호를 위해 `next build`는 수행하지 않았다. 3002 개발 서버의 실제 route 컴파일과 브라우저 3회 검증으로 보완했다.
- 브라우저 로그의 초기화 500 한 건은 사용자 BE가 일시 응답하지 않던 11:23 기록이다. 복구 후 최종 시나리오에서 새 오류·경고는 발생하지 않았다.
### 8.5 롤백·잔여 위험·증빙
- 롤백은 `app/api/gis-upload/route.ts`에서 `request.arrayBuffer()`와 재설정한 `content-length`를 제거하고 기존 `request.body` + `duplex: "half"` 전달로 되돌린다.
- 버퍼링은 요청당 최대 백엔드 허용치 50MB 수준의 Node 메모리를 사용한다. 운영 동시 업로드 부하와 더 큰 파일은 이번 로컬 10초 판정 범위 밖이다.
- 10초는 로컬 warm 11.2MB·16,670건 조건의 통과 결과이며 운영 SLA 보증이 아니다.
- 증빙: `COMMON_DOCS_ROOT/작업내용/SFR-012-002-하수관망도 업로드/검증/20260807/10s/23-performance-summary.json`
- 최종 데이터 대조: 같은 폴더의 `22-final-feature-comparison.json`
@@ -19,7 +19,9 @@
## 4. 화면 범위
- 지자체별/관경별/경과년도별 조회 전환
- 조회, 다운로드, 등록, 지도보기
- 조회, 다운로드, 등록, 수정, 삭제, 상세보기
- 상세 WindowPopup 좌측 관로정보·관로별 사진목록·선택 사진 미리보기
- 상세 WindowPopup 우측 관망도·선택 관로 강조
- 관로 그룹 추가/삭제
- 공통 `FileUpload` 최대 5장
- JPEG EXIF 촬영일/GPS 파싱
@@ -44,6 +46,7 @@
- 세 조회 유형의 컬럼과 집계 전환
- 사진/수동 등록 사용자 흐름
- EXIF 상태가 사진별로 표시
- 관로, 거리, 신뢰도 표시
- 관로와 최근접 거리 표시
- 저장 전 필수 검증
- 상세보기에서 선택 관로의 정보·사진·지도 통합 확인
- 브라우저 실제 조작과 콘솔 오류 확인
@@ -1,5 +1,25 @@
# SFR-010-012 준설관리 프론트엔드 구현기록
## 0.8 2026-08-06 등록 탭·지도·사진조회·처리구역 보완
- 등록 건 탭의 두 번째 건부터 `×` 삭제 버튼을 표시하고, 기준이 되는 첫 번째 등록 건은 삭제할 수 없게 했다.
- 마커 레이어 생성과 위치 편집 이벤트를 분리해 첫 위치가 들어올 때 마커가 누락되지 않게 했다.
- 위치수정 진입은 좌표값을 변경하지 않으므로 사용자가 이동한 현재 지도 범위를 유지하며 사진 GPS로 다시 포커스하지 않는다.
- 상단 안내·사진·일자·위치 카드를 축소하고 상단 상세영역과 사진목록에 내부 스크롤을 적용해 다중 사진에서도 지도 높이를 유지한다.
- 등록 지도 관로는 `/api/v1/map/lyrStngs/SN_NETWORK`의 `tb_ms_swage_ppl` 활성 레이어를 모두 사용한다. 용도별 CQL 필터, 선 색·굵기, 최소 줌은 관망도 DB 설정과 동일하고 자동 연결 관로만 주황색으로 강조한다.
- 최근접 관로 응답의 처리구역명을 등록 화면에 표시하고 저장 후 목록의 처리구역 셀에 반영한다.
- 목록에 `사진보기` 버튼과 원본 미리보기 팝업을 추가해 공통 첨부서비스에서 등록사진을 조회한다.
- 다중 사진은 모든 사진의 GPS가 같은 관로로 수렴해야 하며, 대표 등록 위치는 첫 번째 GPS 사진이다. 지도에서 위치를 보정하면 수동 위치가 그룹 전체의 대표 위치로 우선한다.
- 브라우저에서 등록 건 2 추가·X 삭제·첫 건 보호, 지도 수동 마커, 위치수정 무재포커스, 관망도 관로 12,663건, 저장사진 원본 미리보기, 처리구역 목록 표출을 확인했다.
## 0.7 2026-08-05 사업·자산 매핑 의존 제거
- 백엔드 저장 구조가 `준설작업–준설관로–사진`으로 변경되어 `tb_sn_biz_info`, `tb_sn_cstrn_rslt`, `ast_id`를 더 이상 요구하지 않는다.
- 최근접 관로 성공 조건은 MS 관로의 `fid`, 관리기관코드, 시설관리번호 확인이며 SN 관로자산 매핑은 조회하지 않는다.
- 더 이상 발생하지 않는 `PIPE_ASSET_NOT_MAPPED`, `PIPE_ASSET_AMBIGUOUS` 전용 안내를 프론트 API 계약에서 제거했다.
- 3002 실제 화면에서 목록의 관로 `2400429`, 관재질 `MOP919` 표시와 수동 위치 재선택 후 관로 `2024100064`, 거리 `19.58m` 자동 연결을 확인했다.
- 아래 0.5~0.6의 자산 매핑 관련 내용은 당시 구현 이력이며 현재 계약에는 적용하지 않는다.
## 0.6 2026-08-05 사진 위치 보정 시 이전 관로 오류 제거
- 사진 EXIF 원위치에서 발생한 `PIPE_NOT_FOUND` 결과가 지도에서 새 위치를 선택한 뒤에도 남아 새 좌표의 조회 결과처럼 보이던 상태 표시 오류를 수정하였다.
@@ -78,12 +98,13 @@
| 파일 | 책임 |
| --- | --- |
| `page.tsx` | 지자체별·관경별·경과년도별 조회, 검색, 엑셀 다운로드, 등록/지도보기 진입 |
| `_components/DredgingRegisterModal.tsx` | 관로 그룹, 사진/수동 등록, 검증, 첨부 업로드 및 저장 순서와 팝업 표시 모드 |
| `_components/DredgingRegisterPopupPage.tsx` | 등록 완료 메시지 전송과 팝업 창 닫기 |
| `_components/DredgingLocationMap.tsx` | 공통 `Gis`에 주변 관로 WFS, 자동 연결 관로 강조, 위치 마커와 지도 클릭 interaction 연결 |
| `_lib/exif.ts` | JPEG APP1/TIFF EXIF 촬영일·GPS 분석 |
| `_lib/client.ts` | 최근접 관로, 공통 TUS 첨부 업로드, 준설 등록 API 어댑터, API 오류 정규화와 FE 모의 응답 |
| `_lib/types.ts` | 화면 draft, 사진 메타정보, 관로 매핑, 저장 payload 타입 |
| `components/DredgingRegisterModal.tsx` | 관로 그룹, 사진/수동 등록, 검증, 첨부 업로드 및 저장 순서와 팝업 표시 모드 |
| `components/DredgingRegisterPopupPage.tsx` | 등록 완료 메시지 전송과 팝업 창 닫기 |
| `components/DredgingLocationMap.tsx` | 공통 `Gis`에 관망도 DB 레이어 설정 관로, 자동 연결 관로 강조, 위치 마커와 지도 클릭 interaction 연결 |
| `components/DredgingPhotoViewerModal.tsx` | 등록사진 목록 조회와 공통 첨부 원본 미리보기 |
| `lib/exif.ts` | JPEG APP1/TIFF EXIF 촬영일·GPS 분석 |
| `lib/client.ts` | 최근접 관로, 공통 TUS 첨부 업로드, 준설 등록 API 어댑터, API 오류 정규화와 FE 모의 응답 |
| `lib/types.ts` | 화면 draft, 사진 메타정보, 관로 매핑, 저장 payload 타입 |
| `src/features/windowPopup/**` | `dredgingRegister` 정의·레지스트리·완료 메시지 계약 |
## 3. 주요 처리
@@ -104,7 +125,7 @@
2. 준설일자를 입력한다.
3. 등록창 하단의 `위치등록`을 눌러 같은 화면의 지도를 편집 상태로 전환한다.
4. 사용자는 좌표값을 직접 입력하지 않고 지도에서 위치를 클릭한다.
5. `선택 위치 적용`을 눌렀을 때만 선택 위치를 확정하고 최근접 관로와 거리·신뢰도를 갱신한다.
5. `선택 위치 적용`을 눌렀을 때만 선택 위치를 확정하고 최근접 관로와 거리를 갱신한다.
6. 편집을 취소하면 적용하지 않은 위치는 폐기하고 기존 위치를 유지한다.
7. 관로 매핑이 완료된 건만 저장 payload로 만든다.
@@ -119,7 +140,7 @@
1. 검색조건을 조사관리와 같은 단일 가로 flex 행으로 배치한다.
2. 라벨은 `text-xl`, 입력 높이는 `3.2rem`, 선택상자는 흰색 `rounded-lg` 규칙을 사용한다.
3. 조회·다운로드·등록·지도보기 버튼을 조사관리와 같은 파란색 액션 버튼으로 통일하고 장식 아이콘을 제거한다.
3. 조회·다운로드·등록·수정·삭제·상세보기 버튼을 조사관리와 같은 파란색 액션 버튼으로 통일하고 장식 아이콘을 제거한다.
4. 등록 버튼은 `/popup/dredgingRegister` 창에 화면 가용 크기를 호출 옵션으로 전달해 풀사이즈에 가깝게 연다.
5. 저장 성공 시 팝업이 같은 출처의 `sn:popup:dredgingRegistered` 메시지를 부모 화면에 전달한다.
6. 부모 화면은 메시지의 등록 그룹을 기존 목록 상태에 반영하고 첫 페이지로 이동한다.
@@ -130,7 +151,7 @@
2. 공통 `Gis` 지도는 위치·관로 요약 바로 아래에 상시 표시한다.
3. 확정 위치가 있으면 `위치 수정`, 위치가 없으면 `위치등록`을 제공한다.
4. 위치 편집 중 지도 클릭 위치는 임시 상태로 유지하고 `선택 위치 적용` 이후에만 등록 데이터와 최근접 관로 조회에 반영한다.
5. 목록 화면의 `지도보기`는 기존 `UiModal` 레이어 팝업 방식을 유지한다.
5. 목록 화면의 사진·지도 조회는 `상세보기` WindowPopup으로 통합해 관로정보, 관로별 사진, 관망도를 한 화면에서 확인한다.
### 3.6 관로 선형 표시
@@ -156,3 +177,103 @@
- `DredgingRegisterModal.tsx`의 `presentation="popup"` 분기와 `DredgingRegisterPopupPage.tsx`를 제거한다.
- `src/features/windowPopup`의 `dredgingRegister` 정의·레지스트리·메시지 타입을 제거한다.
- 신규 전용 경로이므로 기존 지도·운영정보 화면의 코드 롤백은 필요하지 않다.
## 6. 2026-08-06 대표 준설일자와 전체 사진보기
- 등록 탭별 준설일자 입력을 제거하고 등록창 상단에 `대표 준설일자` 1개를 배치했다.
- 첫 번째로 등록한 사진에 촬영일이 있으면 대표 준설일자를 자동 입력하며, 이후 사용자가 날짜 입력에서 직접 수정할 수 있다.
- 사진 기반·사진 없이 직접 등록 탭을 여러 개 추가해도 모든 저장 그룹은 같은 대표 준설일자를 사용한다.
- 사진 촬영일시는 사진 메타정보 표에 별도로 표시하되 대표 준설일자와 일치하도록 강제하지 않는다.
- 등록 payload는 최상위 `dredgingDate` 한 개와 다중 관로 그룹을 전송한다.
- 같은 준설작업의 사진은 저장 그룹별 관로 FID를 함께 보관하고, 조회 시 `dredgingJobSn + pipeFid`로 선택 관로 사진만 구분한다.
- 상세 팝업은 선택 관로 사진 목록을 제공하고 목록에서 선택한 한 장을 아래 미리보기 영역에 표시한다.
- 로컬 모의 등록도 여러 관로 행이 같은 `dredgingJobSn`을 공유하되 사진수는 관로별로 계산한다.
브라우저에서 대표 준설일자 `2026-08-04`를 입력하고 두 번째 등록 탭으로 전환해도 날짜가 유지되는 것을 확인했다.
## 7. 2026-08-06 백엔드 통신 오류 노출 보완
- 준설 목록, 최근접 관로, 등록, 사진 업로드, 사진 목록과 사진 원본 조회의 통신 실패를 업무별 사용자 안내 문구로 정규화했다.
- HTTP 상태코드, 영문 fetch 오류, HTML 오류 페이지와 JSON 파싱 문구는 화면 상태·알림·팝업 본문에 전달하지 않는다.
- 목록 조회 실패는 붉은 기술 오류 문자열 대신 노란 안내 영역과 `다시 시도` 버튼으로 표시한다.
- 상세 오류 본문은 화면과 콘솔 메시지에 복사하지 않고 브라우저 Network 응답에서만 확인하도록 했다.
- 추적 근거는 요구사항 `SFR-010-012/013`, 요구사항정의서 84~85행, 화면설계서 51~56번 슬라이드와 화면 ID `SN_MN040201~03`이다.
## 8. 2026-08-06 등록 팝업 좌우 분할·최대화 보완
- 최대화 등록 팝업을 왼쪽 입력·상태 영역과 오른쪽 지도 영역으로 분할했다.
- 왼쪽 영역은 최대 520px로 제한하고 준설일자, 등록 건, 사진, 위치·관로 상태만 콘텐츠 높이로 배치했다.
- 오른쪽 지도는 팝업의 남은 폭과 높이를 모두 사용하며 1920px 화면에서 왼쪽 520px, 지도 1380px로 표시된다.
- 사진이 여러 장이어도 왼쪽 사진 목록에만 스크롤이 생겨 지도 크기가 줄지 않는다.
- 공통 첨부 컴포넌트의 파일명 목록은 숨기고 사진 메타정보 표에서 파일명을 한 번만 표시한다.
- 준설일자 입력은 부모 목록 화면과 동일한 높이 32px, 폭 155px, 글자 12.5px, 색상과 테두리 규격을 적용했다.
- 등록 호출에는 `maximize` 옵션을 사용해 가용 화면 전체 크기로 열고, 브라우저가 이동·크기 변경 API를 제한하면 `window.open`의 최대화 feature 값을 유지한다.
- 추적 근거는 요구사항 `SFR-010-012`, 요구사항정의서 84행, 화면설계서 51~52번 슬라이드와 화면 ID `SN_MN040201`이다. 원본의 업무 흐름을 유지하면서 좌우 배치는 사용성 개선 설계로 적용했다.
## 9. 2026-08-06 목록 관재질 코드명 표출
- 준설 목록의 관재질은 API와 저장 데이터의 `MOP` 코드를 변경하지 않고 화면 행을 만들 때 코드명으로 변환한다.
- 관망도에서 공통으로 사용하는 `MS_SWAGE_PPL_CODES` 코드표를 재사용해 코드표 중복을 만들지 않았다.
- 매핑되지 않은 `MOP` 코드는 코드값을 그대로 노출하지 않고 `확인불가`로 표시하며, 이미 코드명이 전달된 값은 그대로 유지한다.
- 같은 화면 행을 사용하는 엑셀 다운로드에도 관재질 코드명이 적용된다.
## 10. 2026-08-06 부모 목록 수정·삭제
- 지자체별 준설현황에서 행을 선택하면 `수정`, `삭제` 버튼이 활성화된다.
- 관경별·경과년도별 집계 행은 단일 준설작업을 특정할 수 없으므로 두 버튼을 비활성화한다.
- 수정 레이어에서는 자동 매핑 관로와 등록사진은 유지하고, 하나의 준설작업이 공유하는 준설일자만 변경한다.
- 같은 `dredgingJobSn`의 관로가 여러 행이면 수정 안내와 삭제 확인창에 연결 관로 수와 사진 수를 함께 표시한다.
- 삭제는 선택 행 하나가 아니라 해당 `dredgingJobSn`의 사진 참조·연결 관로·작업 마스터를 물리 삭제한 뒤 목록을 재조회한다.
- 통신 실패는 HTTP 상태나 응답 본문을 노출하지 않고 수정·삭제별 재시도 안내로 정규화한다.
- 원본 화면설계서에는 수정·삭제 흐름이 정의되지 않아 이번 기능은 사용자 지시에 따른 `설계 보완`으로 기록한다. 추적 기준은 `SFR-010-012`, 요구사항정의서 84행, 화면설계서 51~52번 슬라이드와 화면 ID `SN_MN040201`이다.
## 11. 2026-08-06 시도·시군구 검색조건 공통화
- 준설 조회 결과에 포함된 지역명으로 선택 후보를 만들던 방식을 제거하고 공통 행정구역 store의 `tb_ms_ctpv`, `tb_ms_sgg` 기준정보를 사용한다.
- 시도·시군구 선택값은 공통 기준정보의 행정구역 코드로 관리하고, 기존 준설 목록 API에는 선택 코드에 해당하는 공식 한글 명칭을 전송한다.
- 시도를 변경하면 기존 시군구 선택을 `전체`로 초기화하고, 시도 코드 앞 두 자리와 일치하는 시군구만 후보로 표시한다.
- 조회 버튼을 누른 시점의 지역명을 확정 조건으로 보관해 입력 중 선택 변경만으로 기존 목록이 먼저 필터링되지 않게 했다.
- `GET /api/v1/dredging/records`의 기존 `ctpv`, `sgg` 선택 파라미터를 사용하므로 백엔드와 DB 구조 변경은 없다.
- 원본 화면설계서에는 지역 선택의 기준정보 출처와 코드·명칭 변환 규칙이 정의되지 않아, 노후 하수관로 정비사업 화면의 기존 공통 패턴을 준설관리에 적용한 `설계 보완`으로 기록한다. 추적 기준은 `SFR-010-012`, 요구사항정의서 84행, 화면설계서 51~52번 슬라이드와 화면 ID `SN_MN040201`이다.
## 12. 2026-08-06 사진·지도 통합 상세 WindowPopup
- 부모 목록의 `사진보기`, `지도보기` 버튼을 `상세보기` 한 개로 통합했다.
- 지자체별 조회 행을 선택한 경우만 상세보기가 활성화되며 선택 행의 작업번호, 관로 FID, 관로 속성과 위치를 최대화 WindowPopup에 전달한다.
- 상세 팝업은 왼쪽에 관로정보와 해당 관로의 사진목록·선택 사진 미리보기를, 오른쪽에 등록 화면과 같은 공통 `Gis` 관망도를 배치한다.
- 사진은 `GET /api/v1/dredging/records/{jobSn}/photos?fid={pipeFid}`로 조회해 같은 작업의 다른 관로 사진이 섞이지 않게 한다.
- 사진목록 행을 누르면 선택 상태가 바뀌고 같은 왼쪽 영역 아래에서 공통 첨부 원본을 미리보기한다.
- 지도는 DB 레이어 설정의 `tb_ms_swage_ppl` 관로 색상·굵기를 사용하고 선택 관로 FID를 주황색으로 강조한다.
- 백엔드 API와 DB 구조는 변경하지 않았다.
- 원본 화면설계서 슬라이드 53의 상세정보와 슬라이드 56의 지도보기를 하나의 화면으로 합친 사용자 지시 기반 `설계 보완`이다. 추적 기준은 요구사항 `SFR-010-012`, 요구사항정의서 84행, 화면 ID `SN_MN040202`, `SN_MN040203`이다.
## 13. 2026-08-06 준설관리 LNB 선택 표시 보완
- `/oper/dredging` 진입 시 좌측 `준설관리` 메뉴에 선택 배경과 기본색이 적용되지 않던 문제를 수정했다.
- 메뉴 데이터에서 상위 메뉴와 준설 하위 메뉴가 같은 URL을 공유하며, 공통 LNB가 먼저 발견한 상위 메뉴 코드를 현재 메뉴로 확정한 것이 원인이었다.
- 동일 URL이 여러 계층에 존재하면 실제 클릭 가능한 가장 깊은 하위 메뉴를 우선 선택하도록 공통 LNB의 탐색 순서를 자식 우선으로 변경했다.
- 준설관리 전용 색상이나 경로 예외를 추가하지 않아 기존 조사관리와 동일한 공통 선택 스타일을 사용한다.
- 원본 요구사항의 업무 기능 변경은 없으며 화면 사용성 보완으로 적용했다. 추적 기준은 요구사항 `SFR-010-012`, 화면설계서 51~56번 슬라이드와 화면 ID `SN_MN040201~03`이다.
## 14. 2026-08-06 상세 화면 기반 수정 팝업
- 부모 목록의 `수정` 버튼은 기존 일자 전용 레이어 대신 최대화 `dredgingEdit` WindowPopup을 연다.
- 수정 화면은 상세보기와 같은 좌우 분할 컴포넌트를 공유한다. 왼쪽에는 관로정보, 준설일자, 해당 관로 사진목록과 선택 사진 미리보기, 위치·매핑 상태를 두고 오른쪽에는 DB 레이어 설정을 사용하는 관망도를 배치한다.
- 기존 사진 행의 `×`로 유지 대상에서 제외할 수 있고, 공통 `FileUpload`로 신규 사진을 최대 5장 범위에서 추가한다. 저장할 때만 신규 파일을 공통 첨부서비스에 업로드한다.
- `위치 수정`을 누르면 현재 지도 화면을 유지한 채 위치 선택 모드로 전환한다. 지도에서 위치를 고르고 `선택 위치 적용`을 누르면 최근접 관로를 다시 조회하며 사진 GPS 위치로 화면을 되돌리지 않는다.
- 저장 요청에는 원래 관로 FID, 수정 위치·출처, 재확인 관로, 유지할 사진 일련번호와 신규 사진 메타정보를 전달한다.
- 수정 완료 메시지를 부모 창에 전달해 선택을 해제하고 목록을 다시 조회한다.
### 검증 결과
- 전체 TypeScript 검사에서 이번 준설 수정 파일 오류는 0건이다. 전체 검사는 기존 조사·침수·GIS·노후관로 파일 오류로 종료되며 사용자 실행 중인 Next 서버 보호를 위해 `next build`는 수행하지 않았다.
- 로컬 브라우저에서 목록 행 선택 후 수정 버튼 활성화, 상세와 동일한 좌우 UI, 관로정보·기존 사진 1장·사진 미리보기·관망도 표시를 확인했다.
- 지도 위치 선택 후 최근접 관로 재매핑 상태가 `변경한 위치와 최근접 관로를 확인했습니다.`로 변경되는 것을 확인했다.
- 기존 사진 `×` 클릭 시 0/5장과 빈 목록으로 바뀌고, 저장하지 않고 다시 열면 서버의 사진 1장이 복원되는 것을 확인했다.
- 실제 저장 후 개발 API에서 수정 위치와 최근접 거리 변경, 관로별 사진 1장 유지가 확인됐다. 브라우저 콘솔의 수정 팝업 오류는 0건이다.
- 화면 증빙: `D:/workspace/IIPBS/docs/작업내용/SFR-010-013-준설수정/검증/001-상세기반-수정팝업.png`
### 추적 근거
- 요구사항정의서 `SFR-010-012`, `SFR-010-013`, 화면설계서 51~52번 슬라이드와 화면 ID `SN_MN040201`을 확인했다.
- 상세 UI를 수정 기반으로 재사용하고 사진·위치 편집을 추가한 부분은 사용자 지시에 따른 설계 보완이다.
+28
View File
@@ -88,6 +88,34 @@
font-size: 1.8rem;
}
/* 부모 조회 화면의 날짜 입력 규격과 등록 팝업의 준설일자 입력을 동일하게 유지한다. */
.sn-dredging-register .sn-dredging-date-input {
color: #666 !important;
font-size: 12.5px !important;
}
/* 준설사진 파일명은 메타정보 표에서만 표시하고 공통 업로드 목록의 중복 표출을 숨긴다. */
.sn-dredging-photo-upload [aria-description="content file holder"] {
display: none;
}
/* 등록 팝업의 첨부영역을 한 줄 높이로 압축해 하단 지도 가용 높이를 확보한다. */
.sn-dredging-photo-upload .fileup_root {
@apply rounded-none bg-transparent p-0;
}
.sn-dredging-photo-upload .fileup_panel {
@apply min-h-10 flex-row gap-1 py-1;
}
.sn-dredging-photo-upload .fileup_hint {
@apply mb-0;
}
.sn-dredging-photo-upload .fileup_meta {
@apply ml-1;
}
.sn-popup-filter-panel {
@apply grid grid-cols-1 gap-3 border-b border-border bg-secondary pb-4;
}
@@ -29,6 +29,7 @@ const clamp = (value: number, min: number, max: number) => {
const buildPopupFeatures = ({
width,
height,
maximize = false,
placement = "center",
alignTop = false,
offsetLeft = 0,
@@ -36,7 +37,9 @@ const buildPopupFeatures = ({
}: Pick<
WindowPopupDefinition,
"width" | "height" | "placement" | "alignTop" | "offsetLeft" | "offsetTop"
>) => {
> & {
maximize?: boolean;
}) => {
const screenLeft = window.screenX ?? window.screenLeft ?? 0;
const screenTop = window.screenY ?? window.screenTop ?? 0;
const outerWidth = window.outerWidth || window.screen.width;
@@ -49,27 +52,34 @@ const buildPopupFeatures = ({
const availableTop = screen.availTop ?? 0;
const availableWidth = window.screen.availWidth || outerWidth;
const availableHeight = window.screen.availHeight || outerHeight;
const safeWidth = Math.min(width, Math.max(360, availableWidth - 48));
const safeHeight = Math.min(height, Math.max(420, availableHeight - 72));
const safeWidth = maximize
? availableWidth
: Math.min(width, Math.max(360, availableWidth - 48));
const safeHeight = maximize
? availableHeight
: Math.min(height, Math.max(420, availableHeight - 72));
const minLeft = availableLeft;
const minTop = availableTop + 24;
const maxLeft = availableLeft + availableWidth - safeWidth - 16;
const maxTop = availableTop + availableHeight - safeHeight - 24;
const left =
placement === "browser-left"
const left = maximize
? availableLeft
: placement === "browser-left"
? clamp(screenLeft + offsetLeft, minLeft, maxLeft)
: clamp(
screenLeft + Math.round((outerWidth - safeWidth) / 2) + offsetLeft,
availableLeft + 16,
maxLeft,
);
const top = clamp(
alignTop
? screenTop + 24 + offsetTop
: screenTop + Math.round((outerHeight - safeHeight) / 2) + offsetTop,
minTop,
maxTop,
);
const top = maximize
? availableTop
: clamp(
alignTop
? screenTop + 24 + offsetTop
: screenTop + Math.round((outerHeight - safeHeight) / 2) + offsetTop,
minTop,
maxTop,
);
return [
`width=${safeWidth}`,
@@ -99,6 +109,7 @@ export const openWindowPopup = (
...definition,
width: options?.width ?? definition.width,
height: options?.height ?? definition.height,
maximize: options?.maximize,
});
const popupWindow = window.open(
popupUrl,
@@ -106,6 +117,20 @@ export const openWindowPopup = (
popupFeatures,
);
if (popupWindow && options?.maximize) {
const screen = window.screen as Screen & {
availLeft?: number;
availTop?: number;
};
try {
// 브라우저가 window.open의 최대 크기를 제한하더라도 가용 화면 전체로 한 번 더 맞춘다.
popupWindow.moveTo(screen.availLeft ?? 0, screen.availTop ?? 0);
popupWindow.resizeTo(screen.availWidth, screen.availHeight);
} catch {
// 브라우저 정책이 이동·크기 변경을 막으면 window.open feature의 최대 크기를 유지한다.
}
}
popupWindow?.focus();
return popupWindow;
@@ -27,6 +27,20 @@ export const windowPopupDefinitions = {
width: 1280,
height: 900,
},
dredgingDetail: {
key: "dredgingDetail",
title: "준설결과 상세",
width: 1280,
height: 900,
requiredParams: ["dredgingJobSn", "pipeFid", "longitude", "latitude"],
},
dredgingEdit: {
key: "dredgingEdit",
title: "준설결과 수정",
width: 1280,
height: 900,
requiredParams: ["dredgingJobSn", "pipeFid", "longitude", "latitude"],
},
exmnPipeReport: {
key: "exmnPipeReport",
title: "관로 조사보고서",
@@ -30,16 +30,19 @@ export type OpenWindowPopupParams = Record<
string | number | boolean | null | undefined
>;
/** 팝업별 기본 정의를 유지하면서 호출 화면에서 필요한 크기만 재정의한다. */
/** 팝업별 기본 정의를 유지하면서 호출 화면에서 필요한 크기와 최대화 여부를 재정의한다. */
export type OpenWindowPopupOptions = Partial<
Pick<WindowPopupDefinition, "width" | "height">
>;
> & {
maximize?: boolean;
};
export const WINDOW_POPUP_MESSAGE_TYPE = {
EXMN_BUSINESS_CREATED: "sn:popup:exmnBusinessCreated",
EXMN_BUSINESS_UPDATED: "sn:popup:exmnBusinessUpdated",
EXMN_RESULT_COMMITTED: "sn:popup:exmnResultCommitted",
DREDGING_REGISTERED: "sn:popup:dredgingRegistered",
DREDGING_UPDATED: "sn:popup:dredgingUpdated",
OLD_PP_UPLOAD_COMPLETED: "sn:popup:oldPpUploadCompleted",
FLOOD_PRE_UPLOAD_COMPLETED: "sn:popup:floodPreUploadCompleted",
MAP_SEARCH_SELECT_FEATURE: "sn:popup:mapSearchSelectFeature",
@@ -50,8 +53,10 @@ export const WINDOW_POPUP_MESSAGE_TYPE = {
GIS_EDIT_SESSION_SYNC: "sn:popup:gisEditSessionSync",
GIS_EDIT_SESSION_SHOW_INLINE: "sn:popup:gisEditSessionShowInline",
GIS_EDIT_SESSION_FOCUS_CHANGE: "sn:popup:gisEditSessionFocusChange",
GIS_EDIT_SESSION_REMOVE_FOCUSED_CHANGE: "sn:popup:gisEditSessionRemoveFocusedChange",
GIS_EDIT_SESSION_REMOVE_LAST_CHANGE: "sn:popup:gisEditSessionRemoveLastChange",
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",
+20 -3
View File
@@ -1,8 +1,13 @@
import { windowPopupDefinitions, type WindowPopupKey } from "./model/popupDefinitions";
import {
windowPopupDefinitions,
type WindowPopupKey,
} from "./model/popupDefinitions";
import type { WindowPopupRegistryItem } from "./model/types";
import AgedPipePopupContent from "./ui/AgedPipePopupContent";
import AgedPipeStatusByCtpvPopupContent from "./ui/AgedPipeStatusByCtpvPopupContent";
import DredgingRegisterPopupContent from "./ui/DredgingRegisterPopupContent";
import DredgingDetailPopupContent from "./ui/DredgingDetailPopupContent";
import DredgingEditPopupContent from "./ui/DredgingEditPopupContent";
import ExmnBusinessRegisterPopupContent from "./ui/ExmnBusinessRegisterPopupContent";
import ExmnPipeReportPopupContent from "./ui/ExmnPipeReportPopupContent";
import ExmnResultRegisterPopupContent from "./ui/ExmnResultRegisterPopupContent";
@@ -31,14 +36,18 @@ import PipeAnalysisPopupContent from "./ui/PipeAnalysisPopupContent";
import PipeAnalysisDetailPopupContent from "./ui/PipeAnalysisDetailPopupContent";
import WaterRiskPopupContent from "./ui/WaterRiskPopupContent";
const FloodPredictionPlaceholderPopupContent = (_props: WindowPopupContentProps) => (
const FloodPredictionPlaceholderPopupContent = (
_props: WindowPopupContentProps,
) => (
<FeaturePlaceholderPopupContent
title={windowPopupDefinitions.floodPredictionMapPopup.title}
/>
);
const GisEditPopupContent = (_props: WindowPopupContentProps) => (
<FeaturePlaceholderPopupContent title={windowPopupDefinitions.gisEditPopup.title} />
<FeaturePlaceholderPopupContent
title={windowPopupDefinitions.gisEditPopup.title}
/>
);
export const windowPopupRegistry = {
@@ -58,6 +67,14 @@ export const windowPopupRegistry = {
...windowPopupDefinitions.dredgingRegister,
component: DredgingRegisterPopupContent,
},
dredgingDetail: {
...windowPopupDefinitions.dredgingDetail,
component: DredgingDetailPopupContent,
},
dredgingEdit: {
...windowPopupDefinitions.dredgingEdit,
component: DredgingEditPopupContent,
},
exmnPipeReport: {
...windowPopupDefinitions.exmnPipeReport,
component: ExmnPipeReportPopupContent,
@@ -0,0 +1,58 @@
"use client";
import DredgingDetailPopupPage from "@/app/(with-base-layout)/oper/dredging/components/DredgingDetailPopupPage";
import type { DredgingRecord } from "@/app/(with-base-layout)/oper/dredging/lib/types";
import type { WindowPopupContentProps } from "../model/types";
const getParam = (value: string | string[] | undefined) =>
Array.isArray(value) ? (value[0] ?? "") : (value ?? "");
const getNumber = (value: string | string[] | undefined) => {
const parsed = Number(getParam(value));
return Number.isFinite(parsed) ? parsed : 0;
};
const getNullableNumber = (value: string | string[] | undefined) => {
const raw = getParam(value);
if (!raw) return null;
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : null;
};
/** 목록 행의 직렬화된 값을 준설 상세 화면 모델로 복원한다. */
export const buildDredgingPopupRecord = (
searchParams: WindowPopupContentProps["searchParams"],
): DredgingRecord => ({
id: getParam(searchParams.id),
dredgingJobSn: getParam(searchParams.dredgingJobSn),
pipeFid: getParam(searchParams.pipeFid),
mngInstCd: getParam(searchParams.mngInstCd),
ctpv: getParam(searchParams.ctpv),
sgg: getParam(searchParams.sgg),
treatmentZone: getParam(searchParams.treatmentZone),
fcltMngNo: getParam(searchParams.fcltMngNo),
pipeDiameterMm: getNumber(searchParams.pipeDiameterMm),
pipeLengthM: getNumber(searchParams.pipeLengthM),
pipeMaterial: getParam(searchParams.pipeMaterial),
installationYear: getNullableNumber(searchParams.installationYear),
dredgingDate: getParam(searchParams.dredgingDate),
photoCount: getNumber(searchParams.photoCount),
registeredDate: getParam(searchParams.registeredDate),
longitude: getNumber(searchParams.longitude),
latitude: getNumber(searchParams.latitude),
snappedLongitude: getNumber(searchParams.snappedLongitude),
snappedLatitude: getNumber(searchParams.snappedLatitude),
registrationSource:
getParam(searchParams.registrationSource) === "MANUAL" ? "MANUAL" : "EXIF",
matchDistanceMeters: getNumber(searchParams.matchDistanceMeters),
isMock: getParam(searchParams.isMock) === "true",
});
export default function DredgingDetailPopupContent({
searchParams,
}: WindowPopupContentProps) {
return (
<DredgingDetailPopupPage record={buildDredgingPopupRecord(searchParams)} />
);
}
@@ -0,0 +1,27 @@
"use client";
import DredgingDetailPopupPage from "@/app/(with-base-layout)/oper/dredging/components/DredgingDetailPopupPage";
import { postPopupMessage } from "../lib/popupMessage";
import { WINDOW_POPUP_MESSAGE_TYPE } from "../model/types";
import type { WindowPopupContentProps } from "../model/types";
import { buildDredgingPopupRecord } from "./DredgingDetailPopupContent";
/** 상세 화면과 같은 구조에서 선택 관로의 사진·위치·준설일자를 수정한다. */
export default function DredgingEditPopupContent({
searchParams,
}: WindowPopupContentProps) {
return (
<DredgingDetailPopupPage
mode="edit"
record={buildDredgingPopupRecord(searchParams)}
onUpdated={() => {
postPopupMessage(WINDOW_POPUP_MESSAGE_TYPE.DREDGING_UPDATED, {
dredgingJobSn: Array.isArray(searchParams.dredgingJobSn)
? (searchParams.dredgingJobSn[0] ?? "")
: (searchParams.dredgingJobSn ?? ""),
});
}}
/>
);
}
@@ -1,6 +1,6 @@
"use client";
import DredgingRegisterPopupPage from "@/app/(with-base-layout)/oper/dredging/_components/DredgingRegisterPopupPage";
import DredgingRegisterPopupPage from "@/app/(with-base-layout)/oper/dredging/components/DredgingRegisterPopupPage";
import { useEffect, useState } from "react";
/** 준설결과 등록 화면을 공통 WindowPopup 레이아웃에 연결한다. */