Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev
This commit is contained in:
@@ -399,43 +399,67 @@ export default function GisManagementPopupPage() {
|
||||
|
||||
const initialize = async () => {
|
||||
setIsLoading(true);
|
||||
// 시설유형과 관리기관은 서로 독립 계약이므로 한쪽 실패가 다른 콤보박스까지 비우지 않게 분리한다.
|
||||
const loadFacilityType = async () => {
|
||||
try {
|
||||
const facilityTypes = await getData(
|
||||
`v1/map/gisUploadFacilityTypes?screenId=${SCREEN_ID}`,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
const pplFacility = Array.isArray(facilityTypes)
|
||||
? facilityTypes.find(
|
||||
(item) => item?.facilityTypeCode === FACILITY_TYPE_CODE,
|
||||
)
|
||||
: null;
|
||||
if (pplFacility) {
|
||||
setFacility({
|
||||
...DEFAULT_FACILITY,
|
||||
...pplFacility,
|
||||
allowedExtensions: Array.isArray(pplFacility.allowedExtensions)
|
||||
? pplFacility.allowedExtensions
|
||||
: DEFAULT_FACILITY.allowedExtensions,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("GIS 관리 시설유형 조회 실패:", error);
|
||||
if (mounted) {
|
||||
setNotice(
|
||||
"GIS 시설정보를 불러오지 못했습니다. 기본 하수관로 설정으로 표시합니다.",
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 관리기관 목록은 시설유형 조회 결과와 무관하게 즉시 화면 상태에 반영한다.
|
||||
const loadManagementOptions = async () => {
|
||||
try {
|
||||
const managementOptions = await getData("v1/fcltppl/mngInsts");
|
||||
if (!mounted) return;
|
||||
|
||||
const sortedOptions = Array.isArray(managementOptions)
|
||||
? [...managementOptions].sort((a, b) =>
|
||||
String(a?.lwrInstNm ?? a?.mngInstCd ?? "").localeCompare(
|
||||
String(b?.lwrInstNm ?? b?.mngInstCd ?? ""),
|
||||
"ko",
|
||||
),
|
||||
)
|
||||
: [];
|
||||
setMngInstOptions(sortedOptions);
|
||||
if (sortedOptions.length === 0) {
|
||||
setNotice("조회 가능한 관리기관이 없습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("GIS 관리 관리기관 조회 실패:", error);
|
||||
if (mounted) {
|
||||
setMngInstOptions([]);
|
||||
setNotice("관리기관 목록을 불러오지 못했습니다.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const [facilityTypes, managementOptions] = await Promise.all([
|
||||
getData(`v1/map/gisUploadFacilityTypes?screenId=${SCREEN_ID}`),
|
||||
getData("v1/fcltppl/mngInsts"),
|
||||
]);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
const pplFacility = Array.isArray(facilityTypes)
|
||||
? facilityTypes.find(
|
||||
(item) => item?.facilityTypeCode === FACILITY_TYPE_CODE,
|
||||
)
|
||||
: null;
|
||||
if (pplFacility) {
|
||||
setFacility({
|
||||
...DEFAULT_FACILITY,
|
||||
...pplFacility,
|
||||
allowedExtensions: Array.isArray(pplFacility.allowedExtensions)
|
||||
? pplFacility.allowedExtensions
|
||||
: DEFAULT_FACILITY.allowedExtensions,
|
||||
});
|
||||
}
|
||||
|
||||
const sortedOptions = Array.isArray(managementOptions)
|
||||
? [...managementOptions].sort((a, b) =>
|
||||
String(a?.lwrInstNm ?? a?.mngInstCd ?? "").localeCompare(
|
||||
String(b?.lwrInstNm ?? b?.mngInstCd ?? ""),
|
||||
"ko",
|
||||
),
|
||||
)
|
||||
: [];
|
||||
setMngInstOptions(sortedOptions);
|
||||
} catch (error) {
|
||||
console.error("GIS 관리 초기 데이터 조회 실패:", error);
|
||||
if (mounted) {
|
||||
setNotice("GIS 관리 정보를 불러오지 못했습니다.");
|
||||
}
|
||||
await Promise.all([loadFacilityType(), loadManagementOptions()]);
|
||||
} finally {
|
||||
if (mounted) setIsLoading(false);
|
||||
}
|
||||
@@ -797,7 +821,7 @@ export default function GisManagementPopupPage() {
|
||||
disabled={!isManagementSelected || isLoading}
|
||||
onClick={() => void handleRefreshHistories()}
|
||||
>
|
||||
조회
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -904,7 +928,7 @@ export default function GisManagementPopupPage() {
|
||||
disabled={!isManagementSelected || isLoading}
|
||||
onClick={() => void handleRefreshHistories()}
|
||||
>
|
||||
조회
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
+101
-10
@@ -179,9 +179,16 @@ const FeatureClickEventPopup = ({
|
||||
const [chmbrYdnts, setChmbrYdnts] = useState<string>(""); // 로딩 상태
|
||||
const linkedFacilityFeatureRef = useRef<any>(null);
|
||||
const linkedFacilityOriginalStyleRef = useRef<any>(null);
|
||||
const flowDirectionBasisRequestCacheRef = useRef(
|
||||
new Map<string, Promise<any | null>>(),
|
||||
);
|
||||
const [mngInstOptions, setMngInstOptions] = useState<MngInstOption[]>([]);
|
||||
const [floodDepthRows, setFloodDepthRows] = useState<Record<string, unknown>[]>([]);
|
||||
const [selectedFloodDepthYear, setSelectedFloodDepthYear] = useState<string | null>(null);
|
||||
const [floodDepthRows, setFloodDepthRows] = useState<
|
||||
Record<string, unknown>[]
|
||||
>([]);
|
||||
const [selectedFloodDepthYear, setSelectedFloodDepthYear] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const floodDepthYearListRef = useRef<HTMLDivElement>(null);
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
|
||||
@@ -483,7 +490,14 @@ const FeatureClickEventPopup = ({
|
||||
case "tb_ms_fldng_dpth_map": {
|
||||
result = "침수흔적깊이";
|
||||
setHeadlist(FldngDpthMapCol);
|
||||
setLayerSpecificNm(String(featureData.values_?.data_sn ?? featureData.data_sn ?? featureId ?? ""));
|
||||
setLayerSpecificNm(
|
||||
String(
|
||||
featureData.values_?.data_sn ??
|
||||
featureData.data_sn ??
|
||||
featureId ??
|
||||
"",
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "tb_ms_stofcbr": {
|
||||
@@ -558,7 +572,14 @@ const FeatureClickEventPopup = ({
|
||||
case "tb_ms_fldng_dpth_map": {
|
||||
result = "침수흔적깊이";
|
||||
setHeadlist(FldngDpthMapCol);
|
||||
setLayerSpecificNm(String(featureData.dataSn ?? featureData.data_sn ?? featureData.fid ?? ""));
|
||||
setLayerSpecificNm(
|
||||
String(
|
||||
featureData.dataSn ??
|
||||
featureData.data_sn ??
|
||||
featureData.fid ??
|
||||
"",
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "tb_ms_stofcbr": {
|
||||
@@ -613,8 +634,8 @@ const FeatureClickEventPopup = ({
|
||||
|
||||
// 🎯 기본 데이터 포맷팅
|
||||
const normalizedRegionData =
|
||||
(selectedLayerName === "침수흔적수위" ||
|
||||
selectedLayerName === "침수흔적깊이")
|
||||
selectedLayerName === "침수흔적수위" ||
|
||||
selectedLayerName === "침수흔적깊이"
|
||||
? withResolvedRegionNames(data)
|
||||
: data;
|
||||
const formattedData = withMngInstName(
|
||||
@@ -655,6 +676,7 @@ const FeatureClickEventPopup = ({
|
||||
formattedData.instlYmd = "확인불가";
|
||||
}
|
||||
}
|
||||
let mergedDetailData = formattedData;
|
||||
|
||||
setFetchData(formattedData);
|
||||
setTableData(formattedData);
|
||||
@@ -760,11 +782,11 @@ const FeatureClickEventPopup = ({
|
||||
const result = await getData(apiEndpoint);
|
||||
|
||||
if (result) {
|
||||
const mergedData = mergeDetailData(formattedData, result);
|
||||
mergedDetailData = mergeDetailData(mergedDetailData, result);
|
||||
const processedApiData = withMngInstName(
|
||||
codeMapper({
|
||||
layerName: selectedLayerName,
|
||||
data: mergedData,
|
||||
data: mergedDetailData,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -777,6 +799,61 @@ const FeatureClickEventPopup = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedLayerName === "하수관로" && canRequestDetail) {
|
||||
try {
|
||||
const escapeCqlLiteral = (value: string) =>
|
||||
value.replace(/'/g, "''");
|
||||
const requestCacheKey = `${fcltMngNo}::${mngInstCd}`;
|
||||
const params = new URLSearchParams({
|
||||
lyrTypeNm: "tb_ms_swage_ppl",
|
||||
includeFlowDirectionBasis: "true",
|
||||
cqlFiter:
|
||||
`fclt_mng_no='${escapeCqlLiteral(fcltMngNo)}' and ` +
|
||||
`mng_inst_cd='${escapeCqlLiteral(mngInstCd)}'`,
|
||||
page: "0",
|
||||
size: "1",
|
||||
});
|
||||
let flowDirectionRequest =
|
||||
flowDirectionBasisRequestCacheRef.current.get(requestCacheKey);
|
||||
if (!flowDirectionRequest) {
|
||||
flowDirectionRequest = getData(`v1/fclts?${params.toString()}`)
|
||||
.then((response) =>
|
||||
Array.isArray(response?.data) ? response.data[0] : null,
|
||||
)
|
||||
.catch((error) => {
|
||||
flowDirectionBasisRequestCacheRef.current.delete(
|
||||
requestCacheKey,
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
flowDirectionBasisRequestCacheRef.current.set(
|
||||
requestCacheKey,
|
||||
flowDirectionRequest,
|
||||
);
|
||||
}
|
||||
const enrichedPipe = await flowDirectionRequest;
|
||||
if (enrichedPipe) {
|
||||
mergedDetailData = mergeDetailData(
|
||||
mergedDetailData,
|
||||
enrichedPipe,
|
||||
);
|
||||
const processedPipeData = withMngInstName(
|
||||
codeMapper({
|
||||
layerName: selectedLayerName,
|
||||
data: mergedDetailData,
|
||||
}),
|
||||
);
|
||||
setFetchData(processedPipeData);
|
||||
setTableData(processedPipeData);
|
||||
}
|
||||
} catch (flowDirectionError) {
|
||||
console.warn(
|
||||
"관로 유하방향 근거 조회 실패, 기존 상세정보를 유지합니다.",
|
||||
flowDirectionError,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedLayerName === "침수흔적수위" && featureId) {
|
||||
const depthLookupKey = normalizeFldngWtlvMapApiFid(featureId);
|
||||
setFloodDepthRows([]);
|
||||
@@ -812,7 +889,9 @@ const FeatureClickEventPopup = ({
|
||||
: [];
|
||||
setFloodDepthRows(normalizedDepthRows);
|
||||
const years = normalizedDepthRows
|
||||
.map((row) => String(row.fldng_yr ?? row.fldngYr ?? row.yr ?? "").trim())
|
||||
.map((row) =>
|
||||
String(row.fldng_yr ?? row.fldngYr ?? row.yr ?? "").trim(),
|
||||
)
|
||||
.filter(Boolean);
|
||||
// 최초에는 연도를 선택하지 않고 목록만 표시한다.
|
||||
setSelectedFloodDepthYear(null);
|
||||
@@ -1097,7 +1176,19 @@ const FeatureClickEventPopup = ({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" aria-label="다음 연도" className="shrink-0 px-1 text-blue-600" onClick={() => floodDepthYearListRef.current?.scrollBy({ left: 120, behavior: "smooth" })}>›</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="다음 연도"
|
||||
className="shrink-0 px-1 text-blue-600"
|
||||
onClick={() =>
|
||||
floodDepthYearListRef.current?.scrollBy({
|
||||
left: 120,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
{selectedFloodDepthYear && (
|
||||
<>
|
||||
|
||||
@@ -3523,7 +3523,10 @@ const Map = () => {
|
||||
const payload = response?.data ?? response;
|
||||
const row = payload && !Array.isArray(payload) ? payload : null;
|
||||
if (!row || extent.length !== 4 || extent.some((value) => !Number.isFinite(value))) return;
|
||||
const center = [(extent[0] + extent[2]) / 2, (extent[1] + extent[3]) / 2];
|
||||
const center = [
|
||||
(extent[0]! + extent[2]!) / 2,
|
||||
(extent[1]! + extent[3]!) / 2,
|
||||
];
|
||||
const observed = Number(row.observedAccumulatedRainfall ?? 0).toFixed(1);
|
||||
const predicted = Number(row.predictedAccumulatedRainfall ?? 0).toFixed(1);
|
||||
const container = document.createElement("div");
|
||||
@@ -3725,7 +3728,7 @@ const Map = () => {
|
||||
);
|
||||
});
|
||||
if (!floodLayerVisible) {
|
||||
event.source?.postMessage?.(
|
||||
(event.source as Window | null)?.postMessage?.(
|
||||
{ type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_FLOOD_LAYER_REQUIRED },
|
||||
event.origin,
|
||||
);
|
||||
@@ -6063,7 +6066,7 @@ const Map = () => {
|
||||
);
|
||||
|
||||
const handlePipeAnalysisRowSelect = useCallback(
|
||||
async (row: Pick<PipeAnalysisDetailRow, "fid">) => {
|
||||
async (row: PipeAnalysisDetailRow) => {
|
||||
// 관로현황 팝업에서 선택한 경우 메시지를 받은 최상위 지도창을 먼저 앞으로 가져온다.
|
||||
window.focus();
|
||||
try {
|
||||
|
||||
@@ -25,7 +25,9 @@ import {
|
||||
uploadDredgingPhotoDrafts,
|
||||
type AttachmentReference,
|
||||
} from "../lib/client";
|
||||
import { formatDredgingCapturedDate } from "../lib/date";
|
||||
import { readPhotoMetadata } from "../lib/exif";
|
||||
import { createDredgingClientUuid } from "../lib/uuid";
|
||||
import {
|
||||
EMPTY_PHOTO_METADATA,
|
||||
type DredgingPhoto,
|
||||
@@ -259,7 +261,7 @@ const DredgingDetailPopupPage = ({
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
newPhotoUrlsRef.current.push(previewUrl);
|
||||
return {
|
||||
key: crypto.randomUUID(),
|
||||
key: createDredgingClientUuid(),
|
||||
file,
|
||||
metadata: EMPTY_PHOTO_METADATA,
|
||||
previewUrl,
|
||||
@@ -657,7 +659,8 @@ const DredgingDetailPopupPage = ({
|
||||
`준설사진 ${index + 1}`}
|
||||
</strong>
|
||||
<span className="shrink-0 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{photo.capturedAt ?? "촬영일시 없음"}
|
||||
{formatDredgingCapturedDate(photo.capturedAt) ??
|
||||
"촬영일시 없음"}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
@@ -704,7 +707,9 @@ const DredgingDetailPopupPage = ({
|
||||
신규 · {photo.file.name}
|
||||
</strong>
|
||||
<span className="shrink-0 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{photo.metadata.capturedAt ?? "촬영일시 없음"}
|
||||
{formatDredgingCapturedDate(
|
||||
photo.metadata.capturedAt,
|
||||
) ?? "촬영일시 없음"}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
@@ -29,7 +29,9 @@ import {
|
||||
type AttachmentReference,
|
||||
uploadDredgingPhotos,
|
||||
} from "../lib/client";
|
||||
import { formatDredgingCapturedDate } from "../lib/date";
|
||||
import { readPhotoMetadata } from "../lib/exif";
|
||||
import { createDredgingClientUuid } from "../lib/uuid";
|
||||
import {
|
||||
EMPTY_PHOTO_METADATA,
|
||||
type DredgingDraftGroup,
|
||||
@@ -51,7 +53,7 @@ type DredgingRegisterModalProps = {
|
||||
};
|
||||
|
||||
const createGroup = (): DredgingDraftGroup => ({
|
||||
id: crypto.randomUUID(),
|
||||
id: createDredgingClientUuid(),
|
||||
registrationMode: "PHOTO",
|
||||
photos: [],
|
||||
manualLocation: null,
|
||||
@@ -253,7 +255,7 @@ const DredgingRegisterModal = ({
|
||||
}
|
||||
|
||||
const checkingPhotos: DredgingPhotoDraft[] = files.map((file) => ({
|
||||
key: crypto.randomUUID(),
|
||||
key: createDredgingClientUuid(),
|
||||
file,
|
||||
metadata: EMPTY_PHOTO_METADATA,
|
||||
}));
|
||||
@@ -762,9 +764,15 @@ const DredgingRegisterModal = ({
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="truncate px-3"
|
||||
title={photo.metadata.capturedAt ?? "-"}
|
||||
title={
|
||||
formatDredgingCapturedDate(
|
||||
photo.metadata.capturedAt,
|
||||
) ?? "-"
|
||||
}
|
||||
>
|
||||
{photo.metadata.capturedAt ?? "-"}
|
||||
{formatDredgingCapturedDate(
|
||||
photo.metadata.capturedAt,
|
||||
) ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell className="px-3">
|
||||
{photo.metadata.location
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
GeoPoint,
|
||||
PipeMatch,
|
||||
} from "./types";
|
||||
import { createDredgingClientUuid } from "./uuid";
|
||||
|
||||
type LocationRequest = GeoPoint & {
|
||||
locationKey: string;
|
||||
@@ -130,8 +131,7 @@ export const fetchAllDredgingRecords = async (params: {
|
||||
sgg?: string;
|
||||
}) =>
|
||||
fetchAllPages({
|
||||
fetchPage: (page, size) =>
|
||||
fetchDredgingRecords({ ...params, page, size }),
|
||||
fetchPage: (page, size) => fetchDredgingRecords({ ...params, page, size }),
|
||||
getItems: (response) => response.items ?? [],
|
||||
getTotal: (response) => Number(response.total ?? 0),
|
||||
});
|
||||
@@ -403,11 +403,11 @@ export const saveDredgingRegistrations = async (
|
||||
return {
|
||||
mock: true,
|
||||
registeredCount: 1,
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
clientRequestId: createDredgingClientUuid(),
|
||||
};
|
||||
}
|
||||
|
||||
const clientRequestId = crypto.randomUUID();
|
||||
const clientRequestId = createDredgingClientUuid();
|
||||
try {
|
||||
return await postRawJson("v1/dredging/registrations", {
|
||||
clientRequestId,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/** 촬영일시 원본은 유지하고 등록·수정 화면 표출값만 YYYY-MM-DD로 정규화한다. */
|
||||
export const formatDredgingCapturedDate = (
|
||||
value: string | null | undefined,
|
||||
) => {
|
||||
const normalized = String(value ?? "").trim();
|
||||
const matched = normalized.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (!matched) return null;
|
||||
|
||||
const year = Number(matched[1]);
|
||||
const month = Number(matched[2]);
|
||||
const day = Number(matched[3]);
|
||||
const date = new Date(year, month - 1, day);
|
||||
if (
|
||||
date.getFullYear() !== year ||
|
||||
date.getMonth() !== month - 1 ||
|
||||
date.getDate() !== day
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return matched[0];
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 일반 HTTP에서는 `crypto.randomUUID`가 제공되지 않을 수 있어 Web Crypto 난수로 UUID v4를 생성한다.
|
||||
* 백엔드 `clientRequestId`가 UUID 타입이므로 fallback도 같은 36자 형식을 유지한다.
|
||||
*/
|
||||
export const createDredgingClientUuid = () => {
|
||||
if (
|
||||
typeof crypto !== "undefined" &&
|
||||
typeof crypto.randomUUID === "function"
|
||||
) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(16);
|
||||
if (
|
||||
typeof crypto !== "undefined" &&
|
||||
typeof crypto.getRandomValues === "function"
|
||||
) {
|
||||
crypto.getRandomValues(bytes);
|
||||
} else {
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
bytes[index] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
}
|
||||
|
||||
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, (value) =>
|
||||
value.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
};
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
fetchAllDredgingRecords,
|
||||
isDredgingBackendEnabled,
|
||||
} from "./lib/client";
|
||||
import { createDredgingClientUuid } from "./lib/uuid";
|
||||
import type {
|
||||
DredgingRecord,
|
||||
DredgingSaveGroup,
|
||||
@@ -592,7 +593,7 @@ const Page = () => {
|
||||
return;
|
||||
}
|
||||
const registeredDate = formatLocalDate(new Date());
|
||||
const localJobSn = `local-job-${crypto.randomUUID()}`;
|
||||
const localJobSn = `local-job-${createDredgingClientUuid()}`;
|
||||
const addedRecords = savedGroups.map<DredgingRecord>((group, index) => ({
|
||||
id: `${localJobSn}-${index + 1}`,
|
||||
dredgingJobSn: localJobSn,
|
||||
|
||||
@@ -600,3 +600,18 @@
|
||||
| 선형 시설 회귀 | 정적 통과 | 기존 스타일 뒤에 선형 선택 강조를 추가하는 순서 유지 |
|
||||
| 변경 공백·구문 검사 | 통과 | `git diff --check`, `node --check` 수행 |
|
||||
| 브라우저 수동 검증 | 대기 | 맨홀 클릭 후 보라색 맨홀 심볼과 선택 원이 함께 유지되는지 확인 필요 |
|
||||
|
||||
## 38. 2026-08-28 조사 입력양식 다운로드 검증
|
||||
|
||||
| 검증 항목 | 결과 | 근거 |
|
||||
| --- | --- | --- |
|
||||
| 저장 전 클릭 안내 | 통과 | `사업정보를 먼저 저장해주세요.` 안내 표출 확인 |
|
||||
| 등록 화면 사업별 양식 버튼 | 통과 | `양식다운로드` 버튼 0개 |
|
||||
| 등록 화면 공통 보고서 양식 | 통과 | `조사보고서 양식다운로드` 버튼 1개 유지 |
|
||||
| 수정 화면 사업별 양식 버튼 | 통과 | 저장 사업 `신사업`에서 `양식다운로드` 버튼 1개 |
|
||||
| 수정 화면 공통 보고서 양식 | 통과 | `조사보고서 양식다운로드` 버튼 1개 유지 |
|
||||
| 실제 파일 생성 | 통과 | `신사업_조사입력양식 (2).xlsx`, 188,261바이트 생성 |
|
||||
| 정적 검사 | 통과 | 전체 TypeScript, 대상 Prettier, `git diff --check` 통과 |
|
||||
| Next build | 미수행 | 사용자 실행 중인 3002 개발서버 보호 기준에 따라 생략 |
|
||||
|
||||
- API·DB 변경 없음. 판정: PASS.
|
||||
|
||||
@@ -338,3 +338,36 @@ Chrome 확장의 파일 URL 접근 권한이 꺼져 있어 이번 회귀에서 E
|
||||
| 공통 선택 스타일 | 통과 | 별도 준설 CSS 없이 기존 `bg-accent !text-primary` 규칙 적용 |
|
||||
| 브라우저 콘솔 | 통과 | 새 검증 탭 경고·오류 0건 |
|
||||
| Next build | 미수행 | 사용자가 실행한 3002 개발 서버 보호 기준에 따라 생략 |
|
||||
|
||||
## 14. 2026-08-28 일반 HTTP 준설 등록 팝업 회귀검증
|
||||
|
||||
| 검증 항목 | 결과 | 근거 |
|
||||
| --- | --- | --- |
|
||||
| 원격 오류 재현 | 통과 | `con.mind-one.co.kr:11302`에서 client-side exception과 `crypto.randomUUID is not a function` 확인 |
|
||||
| 비보안 컨텍스트 조건 | 통과 | LAN HTTP 개발주소에서 `isSecureContext=false`, `crypto` 미제공 확인 |
|
||||
| UUID fallback | 통과 | Web Crypto `getRandomValues`만 있는 경우와 `crypto`가 없는 경우 모두 UUID v4 정규식 통과 |
|
||||
| 로컬 HMR 팝업 | 통과 | 등록 건 1, 사진 업로드, 위치 지정, 지도 영역이 정상 렌더링되고 오류·경고 0건 |
|
||||
| 준설 직접 호출 제거 | 통과 | 기능 파일의 직접 호출을 제거하고 `lib/uuid.ts` 공통 함수 한 곳으로 집중 |
|
||||
| TypeScript | 통과 | SN 전체 `tsc --noEmit --ignoreDeprecations 5.0` 종료코드 0 |
|
||||
| Prettier·공백 검사 | 통과 | 변경 5개 파일 Prettier 및 `git diff --check` 통과 |
|
||||
| Next build | 미수행 | 사용자 실행 중인 3002 개발서버 보호 기준에 따라 생략 |
|
||||
|
||||
- 수정 전 원격 증빙: `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/20260828/01-원격HTTP-randomUUID-오류.png`.
|
||||
- 수정 후 로컬 증빙: `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/20260828/02-로컬-수정후-준설등록-정상.png`.
|
||||
- 원격 11302 서버는 아직 수정 코드가 배포되지 않았으므로 배포 후 같은 URL의 최종 회귀검증이 필요하다.
|
||||
|
||||
## 15. 2026-08-28 등록·수정 촬영일시 날짜 표출 검증
|
||||
|
||||
| 검증 항목 | 결과 | 근거 |
|
||||
| --- | --- | --- |
|
||||
| ISO 일시 | 통과 | `2026-06-20T15:47:45 → 2026-06-20` |
|
||||
| 공백 구분 일시 | 통과 | `2026-07-18 16:12:57 → 2026-07-18` |
|
||||
| 날짜 단독 | 통과 | `2026-02-14 → 2026-02-14` |
|
||||
| 잘못된 날짜·빈 값 | 통과 | `2026-02-30`, null, 빈 문자열은 fallback 처리 |
|
||||
| 등록 화면 | 통과 | 사진 표의 촬영일시 cell과 title에 공통 날짜 포맷 함수 적용 |
|
||||
| 수정 화면 | 통과 | 기존 사진 3건이 `2026-06-20`, `2026-07-18`로 표출되고 시간 미표시 확인 |
|
||||
| 저장 계약 | 통과 | `clientCapturedAt`에는 기존 원본 일시를 그대로 전달 |
|
||||
| 정적 검사 | 통과 | 전체 TypeScript, 대상 Prettier, `git diff --check` 통과 |
|
||||
|
||||
- 검증 화면: `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/20260828/03-수정화면-촬영일자-날짜만.png`.
|
||||
- 사용자 실행 중인 3002 개발서버를 보호하기 위해 Next build는 수행하지 않았다.
|
||||
|
||||
@@ -63,3 +63,23 @@
|
||||
- 최종 구현은 상세창·중간창 `blur()`와 이름 기반 지도창 `focus()`, 지도창 메시지 처리 전후 self-focus를 함께 적용했다.
|
||||
- 자동화 제어는 창 전환 순간 기존 상세창 제어가 종료되어 OS 전경창 최종 상태의 재확인이 제한됐다. 실제 사용자 클릭 기준 수동 포커스 확인은 남은 검증 항목이다.
|
||||
- SN 전체 TypeScript와 `git diff --check` 통과. `map/page.tsx` 전체 Prettier는 기존 파일의 선행 포맷 차이로 통과하지 않았고 이번 추가 구문은 기존 스타일에 맞췄다.
|
||||
|
||||
## 11. 2026-08-26 관로 상세 유하방향 근거 검증
|
||||
|
||||
- 수정 전 Chrome 지도 상세에서 관로 `118529`의 유하방향 근거가 `-`로 표출되는 것을 확인했다.
|
||||
- 같은 관로의 유하방향 포함 API 응답은 `flowDirectionBasis=GIS 작도순서`, `flowDirectionBasisCode=GEOMETRY_FALLBACK`, `flowDirectionRuleLabel=geometry 시점->종점 fallback`을 반환했다.
|
||||
- 관로현황 행과 보강 API 응답의 병합 결과에 위 세 필드가 유지되는 것을 확인했다.
|
||||
- `map/page.tsx` 변경 구문 TypeScript 파서와 `git diff --check`는 통과했다.
|
||||
- SN 전체 TypeScript는 동시 변경된 기존 `event.source.postMessage` 타입 오류 1건으로 차단됐으며 이번 유하방향 보강 코드의 추가 오류는 없었다.
|
||||
- Chrome 입력 제어가 관로행 클릭 단계에서 시간 초과되어 수정 후 최종 화면 표출은 사용자 수동 확인 항목으로 남긴다.
|
||||
|
||||
## 12. 2026-08-28 하수관로 상세 공통 보강 재검증
|
||||
|
||||
- 재현 관로: 시설관리번호 `2015303665`, 관리기관코드 `5690000`, FID `tb_ms_swage_ppl.1135978`.
|
||||
- 실 API 응답: `flowDirectionBasis=시종점 관저고`, `flowDirectionBasisCode=DEPTH`, `flowDirectionRuleLabel=맨홀 표고-관로 깊이 관저고 높은 쪽->낮은 쪽`, 조회건수 1건.
|
||||
- 로컬 브라우저에서 같은 관로를 지도 상세 흐름으로 열어 우측 기본정보의 `유하방향 근거`가 `시종점 관저고`로 표시되는 것을 확인했다.
|
||||
- 검증 화면: `COMMON_DOCS_ROOT/작업내용/SFR-012-005-하수관로유하방향표출/검증/20260828-관로-2015303665-유하방향근거.png`.
|
||||
- 변경 컴포넌트 Prettier와 TypeScript 구문검사, `git diff --check` 통과.
|
||||
- SN 전체 TypeScript는 기존 `map/page.tsx:3659`의 `postMessage` 타입 오류 1건만 남았고 이번 공통 보강 코드의 추가 오류는 없다.
|
||||
- ESLint는 설치 상태에서 `eslint-plugin-react-hooks`를 찾지 못해 실행 환경 오류로 완료하지 못했다.
|
||||
- 판정: PASS.
|
||||
|
||||
@@ -293,3 +293,23 @@
|
||||
- 10초는 로컬 warm 11.2MB·16,670건 조건의 통과 결과이며 운영 SLA 보증이 아니다.
|
||||
- 증빙: `COMMON_DOCS_ROOT/작업내용/SFR-012-002-하수관망도 업로드/검증/20260807/10s/23-performance-summary.json`
|
||||
- 최종 데이터 대조: 같은 폴더의 `22-final-feature-comparison.json`
|
||||
|
||||
## 9. 2026-08-28 관리기관 콤보박스 목록 검증
|
||||
|
||||
- 개발서버 API 확인:
|
||||
- `GET /api/v1/map/gisUploadFacilityTypes?screenId=SN_MN010302`: 하수관로 시설유형 1건, HTTP 200.
|
||||
- `GET /api/v1/fcltppl/mngInsts`: 관리기관 160건, 빈 관리기관코드 0건, HTTP 200.
|
||||
- 수정 후 실제 Chrome 개발서버 팝업에서 관리기관 콤보박스 161개 옵션(`선택` 1개 + 관리기관 160개)을 확인했다.
|
||||
- `가평군(4160000)`을 선택한 뒤 GIS 업로드 이력 목록과 다운로드·조회 버튼 활성화를 확인했다.
|
||||
- Prettier, SN 전체 TypeScript, `git diff --check`를 통과했다.
|
||||
- 사용자 소유 개발서버 3002·8883은 종료·재시작하지 않았고 `next build`는 수행하지 않았다.
|
||||
- 증빙: `COMMON_DOCS_ROOT/작업내용/SFR-012-002-하수관망도 업로드/검증/20260828/01-GIS관리-관리기관-가평군-선택.png`.
|
||||
- 판정: PASS.
|
||||
|
||||
## 10. 2026-08-28 GIS 관리 목록 버튼 문구 검증
|
||||
|
||||
- 기본 GIS 업로드 목록 액션에서 `목록` 버튼 표출을 확인했다.
|
||||
- 오류 확인 화면에서도 같은 버튼이 `목록`으로 표시됐다.
|
||||
- 오류 화면의 `목록` 클릭 시 `GIS 업로드 목록`으로 정상 복귀했다.
|
||||
- 전체 TypeScript, 대상 Prettier와 `git diff --check`를 통과했다.
|
||||
- API·DB 변경 없음. 판정: PASS.
|
||||
|
||||
@@ -934,3 +934,10 @@
|
||||
- Excel 데이터 내보내기는 `엑셀다운로드`, 입력 템플릿은 `양식다운로드`, 일반 첨부파일은 `다운로드`로 구분했다.
|
||||
- `StandardPopupLayout` 본문을 margin 기반 `space-y` 대신 `flex`와 `gap: 1.2rem`으로 변경했다. 자식 카드의 `!m-0` 때문에 간격이 0으로 덮이던 문제를 공통 레이아웃에서 해결했다.
|
||||
- 브라우저에서 조사사업 등록·수정의 `사업 정보`와 `사업대상 처리분구` 카드 사이 실제 간격이 모두 12px인 것을 확인했다.
|
||||
|
||||
## 45. 2026-08-28 조사 입력양식 다운로드 노출·Blob 처리 보완
|
||||
|
||||
- 사업별 조사 입력양식은 저장된 `exmnBizId`와 확정 처리분구가 있어야 생성할 수 있으므로 신규 등록 화면에서는 `양식다운로드` 버튼을 숨겼다.
|
||||
- 수정 화면에는 `양식다운로드`를 유지하고, 저장과 무관한 공통 `조사보고서 양식다운로드`는 등록·수정 모두 유지한다.
|
||||
- Blob 다운로드 링크를 DOM에 연결한 뒤 클릭하고 1초 후 object URL을 해제하도록 변경해 Chrome가 파일을 수락하기 전에 URL이 사라지는 문제를 방지했다.
|
||||
- 사업·처리분구 저장, 양식 생성 API와 DB 계약은 변경하지 않았다.
|
||||
|
||||
@@ -333,3 +333,23 @@
|
||||
- 수정 화면은 기존과 같이 관로정보·준설일자·사진·위치·자동매핑 정보를 모두 제공하므로 조회에 필요한 정보 확인과 편집 기능은 유지된다.
|
||||
- 수정 화면에서도 사용하는 검색 파라미터 복원 로직은 중립 유틸 파일로 분리했으며 API, 저장 payload, 부모창 갱신 메시지는 변경하지 않았다.
|
||||
- 요구사항정의서 sheet3의 `SFR-010-013`은 준설 등록·수정·삭제·조회를 요구하지만 별도 상세 버튼을 강제하지 않는다. 화면설계서 슬라이드 51의 `SN_MN040201`과 슬라이드 53의 `SN_MN040202`도 등록·조회·다운로드 중심이며, 슬라이드 57의 별도 세부현황은 `화면 삭제`로 표시되어 있다.
|
||||
|
||||
## 20. 2026-08-28 일반 HTTP 준설 팝업 UUID 호환성 보완
|
||||
|
||||
- 원격 `http://con.mind-one.co.kr:11302/popup/dredgingRegister`에서 `crypto.randomUUID is not a function` 예외로 등록 팝업이 렌더링 전에 중단됐다.
|
||||
- `localhost`는 브라우저가 보안 컨텍스트 예외로 취급하지만 일반 HTTP 주소에서는 `crypto.randomUUID()` 또는 `crypto` 자체가 제공되지 않을 수 있다.
|
||||
- `lib/uuid.ts`에 준설 전용 UUID v4 생성 함수를 추가했다.
|
||||
- `crypto.randomUUID`가 있으면 기존 동작을 유지한다.
|
||||
- 없으면 `crypto.getRandomValues`를 사용하고 UUID version·variant 비트를 적용한다.
|
||||
- Web Crypto 자체가 없으면 36자 UUID v4 형식을 유지하는 최종 fallback을 사용한다.
|
||||
- 등록 건 ID, 신규 사진 key, 수정 사진 key, 백엔드 `clientRequestId`, mock 작업번호의 직접 `randomUUID` 호출을 모두 공통 함수로 교체했다.
|
||||
- 백엔드 UUID 계약, 등록 payload, DB 구조는 변경하지 않았다.
|
||||
- 추적 근거: 요구사항정의서 `SFR-010-012` G84, 화면설계서 슬라이드 53~59와 화면 ID `SN_MN040201~040203`.
|
||||
|
||||
## 21. 2026-08-28 촬영일시 날짜 표출 통일
|
||||
|
||||
- 등록 화면 사진 표와 수정 화면의 기존·신규 사진 목록에서 촬영일시를 `YYYY-MM-DD`까지만 표시한다.
|
||||
- `lib/date.ts`에 촬영일시 표출 전용 함수를 추가해 ISO `T` 구분, 공백 구분과 날짜 단독 문자열을 같은 형식으로 처리한다.
|
||||
- 존재하지 않는 날짜나 빈 값은 기존 화면별 fallback(`-`, `촬영일시 없음`)을 유지한다.
|
||||
- EXIF 원본 `capturedAt`, 첨부정보와 백엔드 저장 payload는 변경하지 않고 화면 렌더링만 변경했다.
|
||||
- 추적 근거는 요구사항 `SFR-010-012`, 화면설계서 슬라이드 53의 `SN_MN040201`이며 세부 시간 숨김은 사용자 지시에 따른 표출 보완이다.
|
||||
|
||||
@@ -67,3 +67,21 @@
|
||||
- 상세창과 중간 관로분석창을 `blur()`한 뒤 지도창을 활성화해 중간 팝업에 포커스가 머무는 경우를 방지한다.
|
||||
- 최상위 지도창도 메시지 처리 시작과 지도 이동·상세 패널 렌더링 완료 시점에 각각 `window.focus()`를 요청한다.
|
||||
- 기존 관로 지도 이동, 강조와 우측 상세정보 표출은 그대로 유지한다.
|
||||
|
||||
## 11. 2026-08-26 관로현황 선택 관로 유하방향 근거 보강
|
||||
|
||||
- 관로현황 상세 API 행에는 `flowDirectionBasis`가 없어 지도 상세 패널을 행 데이터로 갱신하면 유하방향 근거가 `-`로 표출됐다.
|
||||
- 관로행 클릭 시 시설관리번호와 관리기관코드로 `v1/fclts` 한 건을 `includeFlowDirectionBasis=true` 조건으로 조회한다.
|
||||
- 조회 결과를 기존 관로현황 행에 병합한 뒤 지도 상세 패널에 전달한다.
|
||||
- 보강 API가 실패하면 지도 이동과 기존 행 상세는 유지하고 경고 로그만 남긴다.
|
||||
- 하수관로 유하방향 레이어 계산과 DB 구조는 변경하지 않는다.
|
||||
|
||||
## 12. 2026-08-28 하수관로 상세 공통 유하방향 근거 보강
|
||||
|
||||
- 사용자 재확인에서 관로분석 행 선택 외의 지도 피처 클릭 경로는 여전히 `유하방향 근거`가 `-`로 표시되는 것을 확인했다.
|
||||
- 관로분석 메시지 처리 함수에 두었던 한정 보강을 제거하고 `FeatureClickEventPopup`의 하수관로 상세 공통 로딩 흐름으로 이동했다.
|
||||
- 지도 피처 클릭, 정보검색/관로현황 연계 등 진입 경로와 관계없이 시설관리번호·관리기관코드가 유효하면 `v1/fclts?includeFlowDirectionBasis=true` 한 건을 조회한다.
|
||||
- 기존 피처 정보와 자체입력정보를 먼저 병합한 뒤 유하방향 응답을 최종 병합해 기존 상세 필드를 잃지 않도록 했다.
|
||||
- 동일 관로의 상세 효과가 개발 모드에서 반복 실행돼도 시설관리번호·관리기관코드별 요청 Promise를 재사용해 중복 API 호출을 막는다. 실패한 요청은 캐시에서 제거해 재시도를 허용한다.
|
||||
- 대상 관로 `2015303665`의 화면 표출값은 `시종점 관저고`이며, 계산코드는 `DEPTH`이다.
|
||||
- 수정 파일은 `app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx`이며, `map/page.tsx`는 관로 선택 행을 그대로 상세 컴포넌트에 전달한다.
|
||||
|
||||
@@ -145,3 +145,28 @@
|
||||
- 두 파일 모두 16,670건, EPSG:5179, SQLite 무결성 `ok`
|
||||
- `fid INTEGER PRIMARY KEY` 단일 컬럼과 공간 인덱스 확인
|
||||
- 최종 브라우저 콘솔 오류 및 경고 없음
|
||||
|
||||
## 8. 2026-08-28 GIS 관리 관리기관 목록 초기화 보완
|
||||
|
||||
### 8.1 추적 근거
|
||||
|
||||
- 화면설계서 슬라이드 35 `SN_MN010302`, 요구사항 `SFR-012-002`의 GIS 관리 팝업·시설정보 선택·업로드 흐름을 기준으로 확인했다.
|
||||
- 요구사항정의서 `3. (기능)요구사항정의 및 추적표`의 `SFR-012-002`를 대조했다.
|
||||
|
||||
### 8.2 원인
|
||||
|
||||
- 팝업 초기화가 시설유형 API와 관리기관 API를 하나의 `Promise.all` 및 단일 `try/catch`로 처리했다.
|
||||
- 어느 한 API라도 일시 실패하면 관리기관 API가 정상이어도 `setMngInstOptions`가 실행되지 않아 콤보박스에는 `선택`만 남았다.
|
||||
|
||||
### 8.3 변경 내용
|
||||
|
||||
- 시설유형과 관리기관 조회를 독립 비동기 함수로 분리했다.
|
||||
- 시설유형 조회 실패 시 기본 하수관로 설정을 유지하고, 관리기관 조회 결과는 별도로 정렬·반영한다.
|
||||
- 관리기관 조회 실패와 정상 빈 결과를 구분한 사용자 안내를 추가했다.
|
||||
- BE API·DB·업로드/다운로드 계약은 변경하지 않았다.
|
||||
|
||||
### 8.4 GIS 업로드 목록 이동 버튼 문구
|
||||
|
||||
- 업로드 이력을 다시 조회하고 목록 화면으로 전환하는 버튼명을 `조회`에서 `목록`으로 변경했다.
|
||||
- 기본 업로드 이력 화면과 오류·미리보기 화면의 동일 액션 문구를 함께 통일했다.
|
||||
- 버튼 이벤트와 API 요청은 변경하지 않았다.
|
||||
|
||||
@@ -52,14 +52,16 @@ const sortDistricts = (items: TrtdstSettingDto[]) =>
|
||||
left.trtdstNm.trim().localeCompare(right.trtdstNm.trim(), "ko"),
|
||||
);
|
||||
|
||||
/** Blob을 브라우저 다운로드로 전달하고 임시 URL을 즉시 정리한다. */
|
||||
/** Blob 다운로드 링크를 DOM에 연결하고 브라우저가 수락한 뒤 임시 URL을 정리한다. */
|
||||
const saveBlob = (blob: Blob, fileName: string) => {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = fileName;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1_000);
|
||||
};
|
||||
|
||||
/** 공통 WindowPopup 안에서 사업정보·대상 처리분구·조사양식을 등록·수정하는 화면이다. */
|
||||
@@ -416,14 +418,16 @@ export function ExmnBusinessRegisterPopupPage({
|
||||
showDefaultCancelAction={false}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
disabled={isLoading}
|
||||
onClick={() => void handleDownloadTemplates()}
|
||||
>
|
||||
양식다운로드
|
||||
</Button>
|
||||
{isEditMode ? (
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
disabled={isLoading}
|
||||
onClick={() => void handleDownloadTemplates()}
|
||||
>
|
||||
양식다운로드
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
|
||||
Reference in New Issue
Block a user