Files
frontend/apps/sn/app/(map-layout)/map/components/gis-management/gis-management-popup-page.tsx
T

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>
);
}