856 lines
30 KiB
TypeScript
856 lines
30 KiB
TypeScript
/**
|
|
* 화면명: GIS 관리
|
|
* GIS 자료 업로드 이력을 조회하고 등록·검증·반영 기능을 제공한다.
|
|
*/
|
|
"use client";
|
|
|
|
import type { ChangeEvent } from "react";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
StandardPopupLayout,
|
|
type UiHeadProps,
|
|
} from "@common/business_components";
|
|
import { Button, Input } from "@common/components";
|
|
|
|
import { getBlobDataWithHeaders, postMutiPart } from "@/shared/api/file-api";
|
|
import { getData, postRawJson } from "@/shared/api/sewerage-api";
|
|
import LoadingOverlay from "@/components/ui/loading-bar";
|
|
import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions";
|
|
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
|
|
import { useAlertStore } from "@/shared/lib";
|
|
import { useUserStore } from "@/shared/lib/use-user-store";
|
|
import GisUploadPreviewMap from "./gis-upload-preview-map";
|
|
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
|
|
import { useGisManagementCommonCodes } from "./use-gis-management-common-codes";
|
|
import type { UploadFacilityType, MngInstOption, UploadHistoryItem, GisUploadResponse, ValidationErrorItem, ViewMode } from "@/features/gis-management/model/upload-types";
|
|
import { getLocalizedValidationErrorMessage, getFileExtension, formatDateTime, toScopeKey, buildManagementLabel, getErrorMessage } from "@/features/gis-management/lib/upload-display";
|
|
import { createGisDownloadClient, saveBlob } from "@/features/gis-management/api/gis-download";
|
|
import { postRefreshMessageToOpener } from "@/features/gis-management/lib/popup-refresh";
|
|
|
|
import { createGisUploadRetryGuard, getGisPartialSuccessNotice } from "@/features/gis-management";
|
|
|
|
// 기존 전송 경로를 유지하며 다운로드 작업의 비동기 처리는 기능 모듈에 위임한다.
|
|
const { downloadGeoPackage, startDownloadJob, waitForDownloadJob } =
|
|
createGisDownloadClient({ getData, postRawJson, getBlobDataWithHeaders });
|
|
|
|
|
|
type UploadHistoryRow = UploadHistoryItem &
|
|
Record<string, unknown> & {
|
|
rowNo: number;
|
|
managementLabel: string;
|
|
changeSummary: string;
|
|
registeredAtLabel: string;
|
|
registeredByLabel: string;
|
|
};
|
|
|
|
const GIS_UPLOAD_HISTORY_HEADLIST: UiHeadProps[] = [
|
|
{ id: "rowNo", title: "순번", widthPercent: 7 },
|
|
{ id: "managementLabel", title: "관리기관", widthPercent: 13 },
|
|
{ id: "fileName", title: "파일명", align: "left", widthPercent: 34 },
|
|
{ id: "changeSummary", title: "변경/오류", widthPercent: 13 },
|
|
{ id: "registeredAtLabel", title: "등록일자", widthPercent: 22 },
|
|
{ id: "registeredByLabel", title: "등록자", widthPercent: 11 },
|
|
];
|
|
|
|
|
|
/** 오류 원본 행번호와 공통 표의 오름차순 표시 순번을 별도 열로 표시한다. */
|
|
const GIS_VALIDATION_ERROR_HEADLIST: UiHeadProps[] = [
|
|
{ id: "no", title: "순번", widthPercent: 8 },
|
|
{ id: "sourceRowNo", title: "행번호", widthPercent: 10 },
|
|
{ id: "fid", title: "FID", widthPercent: 22 },
|
|
{ id: "errorMessage", title: "오류내용", align: "left", widthPercent: 60 },
|
|
];
|
|
|
|
|
|
const SCREEN_ID = "SN_MN010302";
|
|
const FACILITY_TYPE_CODE = "PPL";
|
|
const LAYER_ID = "tb_ms_swage_ppl";
|
|
const GIS_UPLOAD_PAGE_SIZE = 10;
|
|
const GIS_ERROR_PAGE_SIZE = 10;
|
|
const SELECT_MNG_INST_VALUE = "";
|
|
// A current/history download is an idempotent GET, so one retry is safe after a transient connection drop.
|
|
const DEFAULT_FACILITY: UploadFacilityType = {
|
|
facilityTypeCode: FACILITY_TYPE_CODE,
|
|
facilityTypeName: "하수관로",
|
|
layerId: LAYER_ID,
|
|
allowedExtensions: [".gpkg"],
|
|
maxFileSizeMb: 2048,
|
|
requiredDownloadFormat: "GPKG",
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export default function GisManagementPopupPage() {
|
|
const alert = useAlertStore((state) => state.setMessage);
|
|
const userInfo = useUserStore((state) => state.userInfo);
|
|
// GIS 관리 코드값은 기존 분기에 사용하고 CMS 공통코드명은 화면 안내에만 사용한다.
|
|
const { uploadStatusLabels, downloadStatusLabels, facilityTypeLabels } =
|
|
useGisManagementCommonCodes();
|
|
// 파일 input은 브라우저 보안 정책상 value 직접 제어가 제한돼 ref로 선택/초기화한다.
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
// 파일명·기관 선택을 바꿔도 부분 성공 업로드 차단을 같은 팝업 세션에 유지한다.
|
|
const uploadRetryGuardRef = useRef<ReturnType<typeof createGisUploadRetryGuard> | null>(null);
|
|
if (!uploadRetryGuardRef.current) uploadRetryGuardRef.current = createGisUploadRetryGuard();
|
|
const uploadRetryGuard = uploadRetryGuardRef.current;
|
|
|
|
const [facility, setFacility] =
|
|
useState<UploadFacilityType>(DEFAULT_FACILITY);
|
|
const [mngInstOptions, setMngInstOptions] = useState<MngInstOption[]>([]);
|
|
const [selectedMngInstCd, setSelectedMngInstCd] = useState(
|
|
SELECT_MNG_INST_VALUE,
|
|
);
|
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
|
const [downloadIdsByScope, setDownloadIdsByScope] = useState<
|
|
Record<string, string>
|
|
>({});
|
|
const [uploadHistory, setUploadHistory] = useState<UploadHistoryItem[]>([]);
|
|
const [validationErrors, setValidationErrors] = useState<
|
|
ValidationErrorItem[]
|
|
>([]);
|
|
// 오류 페이지는 이력 목록의 선택·페이지와 독립적으로 업로드 ID에 고정한다.
|
|
const [errorUploadId, setErrorUploadId] = useState("");
|
|
const [errorPage, setErrorPage] = useState(1);
|
|
const [errorTotal, setErrorTotal] = useState(0);
|
|
const [viewMode, setViewMode] = useState<ViewMode>("history");
|
|
const [selectedUploadId, setSelectedUploadId] = useState("");
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [total, setTotal] = useState(0);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [notice, setNotice] = useState("");
|
|
|
|
// 오류 목록도 업로드 이력과 같은 표준 그리드를 사용하며 기존 오류 설명을 그대로 표시한다.
|
|
const validationErrorRows = useMemo(
|
|
() =>
|
|
validationErrors.map((error) => ({
|
|
sourceRowNo: error.rowNo === 0 ? "파일 전체" : (error.rowNo ?? "-"),
|
|
fid: error.fid || "-",
|
|
errorMessage: getLocalizedValidationErrorMessage(error),
|
|
})),
|
|
[validationErrors],
|
|
);
|
|
|
|
const selectedHistory = useMemo(
|
|
() =>
|
|
uploadHistory.find((history) => history.uploadId === selectedUploadId) ??
|
|
null,
|
|
[selectedUploadId, uploadHistory],
|
|
);
|
|
const uploadHistoryRows = useMemo<UploadHistoryRow[]>(
|
|
() =>
|
|
uploadHistory.map((history) => ({
|
|
...history,
|
|
rowNo: 0,
|
|
managementLabel: buildManagementLabel(
|
|
history.mngInstCd,
|
|
history.mngInstNm,
|
|
),
|
|
changeSummary: `${(history.changedCount ?? 0).toLocaleString("ko-KR")} / ${(history.invalidCount ?? 0).toLocaleString("ko-KR")}`,
|
|
registeredAtLabel: formatDateTime(history.registeredAt),
|
|
registeredByLabel:
|
|
history.registeredByName ||
|
|
(history.registeredBy === userInfo?.userUnqId
|
|
? userInfo.userName
|
|
: history.registeredBy) ||
|
|
"-",
|
|
})),
|
|
[uploadHistory, userInfo?.userName, userInfo?.userUnqId],
|
|
);
|
|
|
|
const selectedScopeKey = toScopeKey(selectedMngInstCd);
|
|
const selectedScopeLabel = useMemo(() => {
|
|
if (!selectedMngInstCd) return "선택";
|
|
const matched = mngInstOptions.find(
|
|
(option) => option.mngInstCd === selectedMngInstCd,
|
|
);
|
|
return matched?.lwrInstNm || selectedMngInstCd;
|
|
}, [mngInstOptions, selectedMngInstCd]);
|
|
|
|
const allowedExtensionText = facility.allowedExtensions.join(", ");
|
|
const facilityTypeDisplayName =
|
|
facilityTypeLabels[FACILITY_TYPE_CODE] || facility.facilityTypeName;
|
|
const latestDownloadId = downloadIdsByScope[selectedScopeKey] ?? "";
|
|
const isManagementSelected = Boolean(selectedMngInstCd);
|
|
// 기존 notice가 조회·다운로드 안내로 바뀌어도 미해결 부분 성공 경고는 유지한다.
|
|
const partialSuccessBlock = uploadRetryGuard.getBlock(selectedScopeKey);
|
|
const displayedNotice = partialSuccessBlock ? getGisPartialSuccessNotice(partialSuccessBlock) : notice;
|
|
|
|
const resetSelectedFile = () => {
|
|
setSelectedFile(null);
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = "";
|
|
}
|
|
};
|
|
|
|
const fetchHistories = useCallback(
|
|
async (page = currentPage, mngInstCd = selectedMngInstCd) => {
|
|
if (!mngInstCd) {
|
|
setUploadHistory([]);
|
|
setSelectedUploadId("");
|
|
setTotal(0);
|
|
setCurrentPage(1);
|
|
return;
|
|
}
|
|
|
|
const params = new URLSearchParams({
|
|
screenId: SCREEN_ID,
|
|
facilityTypeCode: FACILITY_TYPE_CODE,
|
|
page: String(page - 1),
|
|
size: String(GIS_UPLOAD_PAGE_SIZE),
|
|
});
|
|
params.set("mngInstCd", mngInstCd);
|
|
|
|
try {
|
|
const response = await getData(
|
|
`v1/map/gisUploadHistories?${params.toString()}`,
|
|
);
|
|
const items = Array.isArray(response?.items) ? response.items : [];
|
|
setUploadHistory(items);
|
|
setTotal(Number(response?.total ?? 0));
|
|
setCurrentPage(page);
|
|
setSelectedUploadId(items[0]?.uploadId ?? "");
|
|
setNotice("");
|
|
} catch (error) {
|
|
console.error("GIS 업로드 이력 조회 실패:", error);
|
|
setUploadHistory([]);
|
|
setSelectedUploadId("");
|
|
setTotal(0);
|
|
setCurrentPage(1);
|
|
setNotice("GIS 업로드 이력을 불러오지 못했습니다.");
|
|
}
|
|
},
|
|
[currentPage, selectedMngInstCd],
|
|
);
|
|
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
|
|
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 {
|
|
await Promise.all([loadFacilityType(), loadManagementOptions()]);
|
|
} finally {
|
|
if (mounted) setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
void initialize();
|
|
return () => {
|
|
mounted = false;
|
|
};
|
|
// 최초 로딩은 고정 화면 ID와 전체 관리기관 기준으로 한 번만 수행한다.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const handleMngInstChange = (event: ChangeEvent<HTMLSelectElement>) => {
|
|
if (isLoading) return;
|
|
const nextMngInstCd = event.target.value;
|
|
setSelectedMngInstCd(nextMngInstCd);
|
|
setUploadHistory([]);
|
|
setSelectedUploadId("");
|
|
setTotal(0);
|
|
setCurrentPage(1);
|
|
resetSelectedFile();
|
|
setValidationErrors([]);
|
|
setViewMode("history");
|
|
setNotice("");
|
|
void fetchHistories(1, nextMngInstCd);
|
|
};
|
|
|
|
const handleOpenFilePicker = () => {
|
|
if (!isManagementSelected) {
|
|
alert("관리기관을 선택하세요.");
|
|
return;
|
|
}
|
|
fileInputRef.current?.click();
|
|
};
|
|
|
|
const handleFileSelected = (event: ChangeEvent<HTMLInputElement>) => {
|
|
const nextFile = event.target.files?.[0] ?? null;
|
|
if (!nextFile) {
|
|
resetSelectedFile();
|
|
return;
|
|
}
|
|
|
|
const fileExtension = getFileExtension(nextFile.name);
|
|
if (!facility.allowedExtensions.includes(fileExtension)) {
|
|
resetSelectedFile();
|
|
alert(
|
|
`업로드 가능한 파일 형식이 아닙니다. 허용 형식: ${allowedExtensionText}`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const maxFileSizeInBytes = facility.maxFileSizeMb * 1024 * 1024;
|
|
if (nextFile.size > maxFileSizeInBytes) {
|
|
resetSelectedFile();
|
|
alert(
|
|
`파일 크기가 제한을 초과했습니다. 최대 ${facility.maxFileSizeMb}MB까지 업로드할 수 있습니다.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
setSelectedFile(nextFile);
|
|
};
|
|
|
|
const handleDownloadCurrent = async () => {
|
|
if (!isManagementSelected) {
|
|
alert("관리기관을 선택하세요.");
|
|
return;
|
|
}
|
|
|
|
const params = new URLSearchParams({
|
|
screenId: SCREEN_ID,
|
|
facilityTypeCode: FACILITY_TYPE_CODE,
|
|
format: facility.requiredDownloadFormat || "GPKG",
|
|
});
|
|
params.set("mngInstCd", selectedMngInstCd);
|
|
|
|
setIsLoading(true);
|
|
setNotice("");
|
|
try {
|
|
const job = await startDownloadJob(
|
|
`v1/map/gisUploads/swagePpl/downloadJobs?${params.toString()}`,
|
|
);
|
|
setNotice(
|
|
`${selectedScopeLabel} ${downloadStatusLabels[job.status] || "GeoPackage 파일을 생성하고 있습니다."}`,
|
|
);
|
|
const readyJob = await waitForDownloadJob(job.jobId, (currentJob) => {
|
|
const statusLabel = downloadStatusLabels[currentJob.status];
|
|
if (statusLabel) setNotice(`${selectedScopeLabel} ${statusLabel}`);
|
|
});
|
|
const response = await downloadGeoPackage(
|
|
`v1/map/gisDownloadJobs/${encodeURIComponent(job.jobId)}/file`,
|
|
);
|
|
const downloadId =
|
|
response.headers.get("X-Gis-Download-Id") ?? readyJob.downloadId ?? "";
|
|
if (!downloadId) {
|
|
setNotice(
|
|
"다운로드 기준 정보를 확인하지 못했습니다. 다시 다운로드한 뒤 업로드하세요.",
|
|
);
|
|
} else {
|
|
setDownloadIdsByScope((current) => ({
|
|
...current,
|
|
[selectedScopeKey]: downloadId,
|
|
}));
|
|
}
|
|
saveBlob(
|
|
response.blob,
|
|
response.fileName ??
|
|
readyJob.fileName ??
|
|
`swage_ppl_${selectedScopeKey}.gpkg`,
|
|
);
|
|
// 새 현재본을 실제 수신한 경우에만 다음 업로드를 허용하고 이전 선택파일을 버린다.
|
|
const previousPartialSuccess = uploadRetryGuard.acceptFreshDownload(selectedScopeKey, downloadId);
|
|
if (previousPartialSuccess) resetSelectedFile();
|
|
if (downloadId) {
|
|
setNotice(`${selectedScopeLabel} 기준 GeoPackage를 다운로드했습니다.${previousPartialSuccess ? ` 이전 업로드 ${previousPartialSuccess.uploadId || "식별번호 확인 필요"}의 이력 복구는 관리자 확인이 필요합니다.` : ""}`);
|
|
}
|
|
} catch (error) {
|
|
console.error("GIS 다운로드 실패:", error);
|
|
alert(
|
|
getErrorMessage(error, "GeoPackage 다운로드 중 오류가 발생했습니다."),
|
|
);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleUpload = async () => {
|
|
// 버튼 disabled 외에 호출 경로에서도 동일 세션의 재업로드를 차단한다.
|
|
const blocked = uploadRetryGuard.getBlock(selectedScopeKey);
|
|
if (blocked) {
|
|
alert(getGisPartialSuccessNotice(blocked));
|
|
return;
|
|
}
|
|
if (!isManagementSelected) {
|
|
alert("관리기관을 선택하세요.");
|
|
return;
|
|
}
|
|
|
|
if (!selectedFile) {
|
|
alert("업로드할 파일을 선택하세요.");
|
|
return;
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append("screenId", SCREEN_ID);
|
|
formData.append("facilityTypeCode", FACILITY_TYPE_CODE);
|
|
formData.append("layerId", facility.layerId || LAYER_ID);
|
|
if (latestDownloadId) {
|
|
formData.append("downloadId", latestDownloadId);
|
|
}
|
|
formData.append("mngInstCd", selectedMngInstCd);
|
|
formData.append("file", selectedFile);
|
|
|
|
setIsLoading(true);
|
|
setNotice("");
|
|
try {
|
|
const response = (await postMutiPart(
|
|
"/api/gisUpload",
|
|
formData,
|
|
)) as GisUploadResponse;
|
|
if (response.status === "VALIDATION_FAILED") {
|
|
await fetchHistories(1, selectedMngInstCd);
|
|
setSelectedUploadId(response.uploadId);
|
|
setErrorUploadId(response.uploadId);
|
|
setErrorPage(1);
|
|
setErrorTotal(
|
|
response.invalidCount ?? response.validationErrors?.length ?? 0,
|
|
);
|
|
setValidationErrors(
|
|
Array.isArray(response.validationErrors)
|
|
? response.validationErrors.slice(0, GIS_ERROR_PAGE_SIZE)
|
|
: [],
|
|
);
|
|
setViewMode("validationErrors");
|
|
setNotice(
|
|
response.message ??
|
|
uploadStatusLabels[response.status] ??
|
|
"GeoPackage 검증 오류가 있습니다.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
postRefreshMessageToOpener(response);
|
|
await fetchHistories(1, selectedMngInstCd);
|
|
resetSelectedFile();
|
|
setValidationErrors([]);
|
|
setViewMode("history");
|
|
setNotice(
|
|
response.message ??
|
|
uploadStatusLabels[response.status] ??
|
|
"GIS 업로드가 완료되었습니다.",
|
|
);
|
|
} catch (error) {
|
|
console.error("GIS 업로드 실패:", error);
|
|
const partial = uploadRetryGuard.recordFailure(selectedScopeKey, latestDownloadId, error);
|
|
if (partial) {
|
|
const message = getGisPartialSuccessNotice(partial);
|
|
setNotice(message);
|
|
alert(message);
|
|
return;
|
|
}
|
|
alert(
|
|
getErrorMessage(error, "GeoPackage 업로드 중 오류가 발생했습니다."),
|
|
);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleDownloadSelectedHistory = async () => {
|
|
if (viewMode !== "history") return;
|
|
if (!selectedHistory) {
|
|
alert("다운로드할 업로드 현황 행을 선택하세요.");
|
|
return;
|
|
}
|
|
if (selectedHistory.status === "VALIDATION_FAILED") {
|
|
alert(
|
|
"검증에 실패한 파일은 반영되지 않았습니다. 오류 확인을 이용해주세요.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
setNotice("");
|
|
try {
|
|
const job = await startDownloadJob(
|
|
`v1/map/gisUploads/${encodeURIComponent(selectedHistory.uploadId)}/downloadJobs`,
|
|
);
|
|
setNotice(
|
|
downloadStatusLabels[job.status] ||
|
|
"선택한 현황의 GeoPackage 파일을 생성하고 있습니다.",
|
|
);
|
|
const readyJob = await waitForDownloadJob(job.jobId, (currentJob) => {
|
|
const statusLabel = downloadStatusLabels[currentJob.status];
|
|
if (statusLabel) setNotice(statusLabel);
|
|
});
|
|
const response = await downloadGeoPackage(
|
|
`v1/map/gisDownloadJobs/${encodeURIComponent(job.jobId)}/file`,
|
|
);
|
|
saveBlob(
|
|
response.blob,
|
|
response.fileName ??
|
|
readyJob.fileName ??
|
|
`${selectedHistory.uploadId}.gpkg`,
|
|
);
|
|
setNotice(
|
|
`${buildManagementLabel(selectedHistory.mngInstCd, selectedHistory.mngInstNm)} 기준 GeoPackage를 다운로드했습니다.`,
|
|
);
|
|
} catch (error) {
|
|
console.error("GIS 이력 다운로드 실패:", error);
|
|
alert(
|
|
getErrorMessage(
|
|
error,
|
|
"선택한 현황 기준 GeoPackage 다운로드 중 오류가 발생했습니다.",
|
|
),
|
|
);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchValidationErrors = async (
|
|
uploadId = selectedUploadId,
|
|
page = 1,
|
|
) => {
|
|
if (!uploadId) {
|
|
alert("오류를 확인할 업로드 현황 행을 선택하세요.");
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
setNotice("");
|
|
try {
|
|
const response = await getData(
|
|
`v1/map/gisUploads/${encodeURIComponent(uploadId)}/validationErrors?page=${page - 1}&size=${GIS_ERROR_PAGE_SIZE}`,
|
|
);
|
|
setValidationErrors(
|
|
Array.isArray(response?.errors) ? response.errors : [],
|
|
);
|
|
setErrorUploadId(uploadId);
|
|
setErrorPage(page);
|
|
setErrorTotal(Number(response?.total ?? 0));
|
|
setViewMode("validationErrors");
|
|
} catch (error) {
|
|
console.error("GIS 업로드 오류 조회 실패:", error);
|
|
alert("업로드 오류 목록을 불러오지 못했습니다.");
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleRefreshHistories = async () => {
|
|
setValidationErrors([]);
|
|
setViewMode("history");
|
|
await fetchHistories(1, selectedMngInstCd);
|
|
};
|
|
|
|
const handleOpenPreview = () => {
|
|
if (!selectedHistory) {
|
|
alert("미리보기할 업로드 현황 행을 선택하세요.");
|
|
return;
|
|
}
|
|
if (selectedHistory.status === "VALIDATION_FAILED") {
|
|
alert(
|
|
"검증에 실패한 파일은 반영되지 않았습니다. 오류 확인을 이용해주세요.",
|
|
);
|
|
return;
|
|
}
|
|
setViewMode("preview");
|
|
};
|
|
|
|
// 미리보기와 오류 목록의 기존 보조 액션을 같은 버튼 구성으로 유지한다.
|
|
const auxiliaryActions = (
|
|
<div className="flex flex-wrap justify-end gap-2">
|
|
<Button
|
|
type="button"
|
|
color="primary"
|
|
disabled={!isManagementSelected || isLoading}
|
|
onClick={() => void handleRefreshHistories()}
|
|
>
|
|
목록
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
color="primary"
|
|
disabled
|
|
onClick={() => void handleDownloadSelectedHistory()}
|
|
>
|
|
GeoPackage 다운로드
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
color="white"
|
|
disabled={
|
|
!selectedHistory ||
|
|
selectedHistory.status === "VALIDATION_FAILED" ||
|
|
isLoading
|
|
}
|
|
onClick={handleOpenPreview}
|
|
>
|
|
미리보기
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
color="white"
|
|
disabled={!selectedHistory || isLoading}
|
|
onClick={() => void fetchValidationErrors()}
|
|
>
|
|
오류 확인
|
|
</Button>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<StandardPopupLayout
|
|
title={windowPopupDefinitions.gisManagementPopup.title}
|
|
>
|
|
<LoadingOverlay isLoading={isLoading} />
|
|
|
|
{displayedNotice ? (
|
|
<div className="border-b border-border bg-secondary px-5 py-2 text-[1rem] text-label">
|
|
{displayedNotice}
|
|
</div>
|
|
) : null}
|
|
|
|
<section
|
|
className={
|
|
viewMode === "preview"
|
|
? "flex min-h-0 flex-1 flex-col gap-[1.2rem] overflow-hidden p-4 pb-2"
|
|
: "flex min-h-0 flex-1 flex-col gap-[1.2rem] p-4"
|
|
}
|
|
>
|
|
<PopupSearchPanel
|
|
className="shrink-0"
|
|
contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3"
|
|
>
|
|
<label className="flex shrink-0 items-center gap-2">
|
|
<div className="whitespace-nowrap text-xl font-medium text-label">
|
|
관리기관
|
|
</div>
|
|
<select
|
|
className="h-[3.2rem] w-[20rem] rounded-md border border-input bg-field px-3 text-xl text-foreground"
|
|
value={selectedMngInstCd}
|
|
onChange={handleMngInstChange}
|
|
disabled={isLoading}
|
|
>
|
|
<option value={SELECT_MNG_INST_VALUE}>선택</option>
|
|
{mngInstOptions.map((option) => {
|
|
const value = String(option.mngInstCd ?? "").trim();
|
|
if (!value) return null;
|
|
return (
|
|
<option key={value} value={value}>
|
|
{option.lwrInstNm || value}
|
|
</option>
|
|
);
|
|
})}
|
|
</select>
|
|
</label>
|
|
|
|
<label className="flex min-w-[30rem] flex-1 items-center gap-2">
|
|
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
|
업로드 파일 ({facilityTypeDisplayName}, {allowedExtensionText})
|
|
</div>
|
|
<Input
|
|
value={selectedFile?.name ?? ""}
|
|
placeholder="선택된 파일 없음"
|
|
readOnly
|
|
aria-label="선택 파일명"
|
|
className="!h-[3.2rem] min-w-0 flex-1 !text-xl"
|
|
/>
|
|
</label>
|
|
|
|
<div className="flex min-w-fit flex-wrap justify-end gap-2">
|
|
<Button
|
|
type="button"
|
|
color="primary"
|
|
disabled={!isManagementSelected || isLoading}
|
|
onClick={() => void handleDownloadCurrent()}
|
|
>
|
|
GeoPackage 다운로드
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
color="white"
|
|
disabled={!isManagementSelected || isLoading}
|
|
onClick={handleOpenFilePicker}
|
|
>
|
|
파일찾기
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
color="primary"
|
|
disabled={!isManagementSelected || !selectedFile || isLoading || Boolean(partialSuccessBlock)}
|
|
onClick={() => void handleUpload()}
|
|
>
|
|
업로드
|
|
</Button>
|
|
</div>
|
|
</PopupSearchPanel>
|
|
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
className="hidden"
|
|
accept={facility.allowedExtensions.join(",")}
|
|
onChange={handleFileSelected}
|
|
/>
|
|
|
|
{viewMode === "preview" ? (
|
|
<div className="card-header flex shrink-0 items-center justify-between">
|
|
<h3 className="text-2xl font-medium text-label">
|
|
GIS 업로드 미리보기
|
|
</h3>
|
|
{auxiliaryActions}
|
|
</div>
|
|
) : null}
|
|
|
|
{viewMode === "preview" && selectedHistory ? (
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
<GisUploadPreviewMap
|
|
uploadId={selectedHistory.uploadId}
|
|
mngInstCd={selectedMngInstCd}
|
|
/>
|
|
</div>
|
|
) : viewMode === "validationErrors" ? (
|
|
<MapPopupDataTable
|
|
rows={validationErrorRows}
|
|
headlist={GIS_VALIDATION_ERROR_HEADLIST}
|
|
title="GIS 업로드 오류 목록"
|
|
actions={auxiliaryActions}
|
|
total={errorTotal}
|
|
page={errorPage}
|
|
pageSize={GIS_ERROR_PAGE_SIZE}
|
|
onPageChange={(page) =>
|
|
void fetchValidationErrors(errorUploadId, page)
|
|
}
|
|
scrollMode="horizontal"
|
|
minTableWidth="64rem"
|
|
emptyMessage="표시할 오류가 없습니다."
|
|
fillAvailableHeight
|
|
rowId={(_row, index) => String(index)}
|
|
renderCell={(row, headId) =>
|
|
headId === "errorMessage" ? (
|
|
<span className="block whitespace-normal break-words text-left">
|
|
{row.errorMessage}
|
|
</span>
|
|
) : headId === "fid" ? (
|
|
<span className="block whitespace-normal break-all">
|
|
{row.fid}
|
|
</span>
|
|
) : undefined
|
|
}
|
|
/>
|
|
) : (
|
|
<MapPopupDataTable
|
|
rows={uploadHistoryRows}
|
|
headlist={GIS_UPLOAD_HISTORY_HEADLIST}
|
|
title="GIS 업로드 목록"
|
|
total={total}
|
|
page={currentPage}
|
|
pageSize={GIS_UPLOAD_PAGE_SIZE}
|
|
onPageChange={(page) =>
|
|
void fetchHistories(page, selectedMngInstCd)
|
|
}
|
|
actions={
|
|
<>
|
|
<Button
|
|
type="button"
|
|
color="primary"
|
|
disabled={!isManagementSelected || isLoading}
|
|
onClick={() => void handleRefreshHistories()}
|
|
>
|
|
목록
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
color="primary"
|
|
disabled={
|
|
!selectedHistory ||
|
|
selectedHistory.status === "VALIDATION_FAILED" ||
|
|
isLoading
|
|
}
|
|
onClick={() => void handleDownloadSelectedHistory()}
|
|
>
|
|
GeoPackage 다운로드
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
color="white"
|
|
disabled={
|
|
!selectedHistory ||
|
|
selectedHistory.status === "VALIDATION_FAILED" ||
|
|
isLoading
|
|
}
|
|
onClick={handleOpenPreview}
|
|
>
|
|
미리보기
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
color="white"
|
|
disabled={!selectedHistory || isLoading}
|
|
onClick={() => void fetchValidationErrors()}
|
|
>
|
|
오류 확인
|
|
</Button>
|
|
</>
|
|
}
|
|
scrollMode="horizontal"
|
|
minTableWidth="72rem"
|
|
emptyMessage="조회 결과가 없습니다."
|
|
fillAvailableHeight
|
|
rowId={(row) => row.uploadId}
|
|
selectedRowId={selectedUploadId}
|
|
onRowClick={(row) => setSelectedUploadId(row.uploadId)}
|
|
/>
|
|
)}
|
|
</section>
|
|
</StandardPopupLayout>
|
|
);
|
|
}
|