준설관리 개발완료
This commit is contained in:
@@ -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;
|
||||
+355
-436
File diff suppressed because it is too large
Load Diff
+210
-67
@@ -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,
|
||||
"준설결과를 저장하지 못했습니다. 잠시 후 다시 시도해주세요.",
|
||||
"준설결과 저장",
|
||||
);
|
||||
}
|
||||
};
|
||||
+27
-7
@@ -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';
|
||||
|
||||
|
||||
|
||||
@@ -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를 수정 기반으로 재사용하고 사진·위치 편집을 추가한 부분은 사용자 지시에 따른 설계 보완이다.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 레이아웃에 연결한다. */
|
||||
|
||||
Reference in New Issue
Block a user