하수관망도 업로드 구현

This commit is contained in:
kiminchan
2026-05-18 10:23:33 +09:00
parent d2a1f5e48b
commit ef7bae98d4
10 changed files with 988 additions and 11 deletions
@@ -16,10 +16,11 @@ import {
import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions";
type Props = {
onOpenPopup: (popupKey: WindowPopupKey) => void;
onOpenPopup: (popupKey: WindowPopupKey) => Window | null;
onOpenInfoSearch: () => void;
onToggleGisEdit?: () => void;
isGisEditActive?: boolean;
isInfoSearchActive?: boolean;
};
const ANALYSIS_POPUP_ITEMS: Array<{
@@ -41,6 +42,7 @@ export default function AnalysisPopupMenu({
onOpenInfoSearch,
onToggleGisEdit,
isGisEditActive = false,
isInfoSearchActive = false,
}: Props) {
const [isOpen, setIsOpen] = useState(true);
const [activeKey, setActiveKey] = useState<WindowPopupKey | "infoSearch" | null>(null);
@@ -52,6 +54,12 @@ export default function AnalysisPopupMenu({
}
}, [activeKey, isGisEditActive]);
useEffect(() => {
if (!isInfoSearchActive && activeKey === "infoSearch") {
setActiveKey(null);
}
}, [activeKey, isInfoSearchActive]);
return (
<div className="absolute right-[0.55rem] top-[20px] z-[45]">
<div className="relative">
@@ -94,7 +102,19 @@ export default function AnalysisPopupMenu({
return;
}
onOpenPopup(item.key);
const popupWindow = onOpenPopup(item.key);
if (!popupWindow) {
setActiveKey(null);
return;
}
const closeWatcher = window.setInterval(() => {
if (!popupWindow.closed) return;
window.clearInterval(closeWatcher);
setActiveKey((currentKey) =>
currentKey === item.key ? null : currentKey,
);
}, 300);
}}
>
<Icon
@@ -0,0 +1,539 @@
"use client";
import type { ChangeEvent } from "react";
import { useMemo, useRef, useState } from "react";
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
import { Button, Input, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
type UploadFacilityType = {
facilityTypeCode: string;
facilityTypeName: string;
allowedExtensions: string[];
maxFileSizeMb: number;
};
type UploadHistoryItem = {
uploadId: string;
facilityTypeCode: string;
facilityTypeName: string;
fileName: string;
registeredAt: string;
registeredBy: string;
};
const UPLOAD_FACILITY_TYPES: UploadFacilityType[] = [
{
facilityTypeCode: "PPL",
facilityTypeName: "하수관로",
allowedExtensions: [".zip", ".shp", ".geojson", ".json", ".dxf"],
maxFileSizeMb: 200,
},
{
facilityTypeCode: "MHL",
facilityTypeName: "하수맨홀",
allowedExtensions: [".zip", ".shp", ".geojson", ".json"],
maxFileSizeMb: 100,
},
{
facilityTypeCode: "SWTRMT_FCLT",
facilityTypeName: "하수처리시설",
allowedExtensions: [".zip", ".shp", ".geojson"],
maxFileSizeMb: 150,
},
];
const INITIAL_UPLOAD_HISTORY: UploadHistoryItem[] = [
{
uploadId: "GUP-MOCK-20260504-0001",
facilityTypeCode: "PPL",
facilityTypeName: "하수관로",
fileName: "asan_pipe_20260504.zip",
registeredAt: "2026-05-04",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260430-0002",
facilityTypeCode: "MHL",
facilityTypeName: "하수맨홀",
fileName: "asan_manhole_20260430.zip",
registeredAt: "2026-04-30",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260421-0003",
facilityTypeCode: "SWTRMT_FCLT",
facilityTypeName: "하수처리시설",
fileName: "00000",
registeredAt: "",
registeredBy: "",
},
{
uploadId: "GUP-MOCK-20260418-0004",
facilityTypeCode: "PPL",
facilityTypeName: "하수관로",
fileName: "asan_pipe_20260418.zip",
registeredAt: "2026-04-18",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260415-0005",
facilityTypeCode: "MHL",
facilityTypeName: "하수맨홀",
fileName: "asan_manhole_20260415.zip",
registeredAt: "2026-04-15",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260412-0006",
facilityTypeCode: "SWTRMT_FCLT",
facilityTypeName: "하수처리시설",
fileName: "asan_swtrmt_20260412.zip",
registeredAt: "2026-04-12",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260409-0007",
facilityTypeCode: "PPL",
facilityTypeName: "하수관로",
fileName: "asan_pipe_20260409.zip",
registeredAt: "2026-04-09",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260406-0008",
facilityTypeCode: "MHL",
facilityTypeName: "하수맨홀",
fileName: "asan_manhole_20260406.zip",
registeredAt: "2026-04-06",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260403-0009",
facilityTypeCode: "SWTRMT_FCLT",
facilityTypeName: "하수처리시설",
fileName: "asan_treatment_20260403.zip",
registeredAt: "2026-04-03",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260331-0010",
facilityTypeCode: "PPL",
facilityTypeName: "하수관로",
fileName: "asan_pipe_20260331.zip",
registeredAt: "2026-03-31",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260328-0011",
facilityTypeCode: "MHL",
facilityTypeName: "하수맨홀",
fileName: "asan_manhole_20260328.zip",
registeredAt: "2026-03-28",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260325-0012",
facilityTypeCode: "SWTRMT_FCLT",
facilityTypeName: "하수처리시설",
fileName: "asan_swtrmt_20260325.zip",
registeredAt: "2026-03-25",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260322-0013",
facilityTypeCode: "PPL",
facilityTypeName: "하수관로",
fileName: "asan_pipe_20260322.zip",
registeredAt: "2026-03-22",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260319-0014",
facilityTypeCode: "MHL",
facilityTypeName: "하수맨홀",
fileName: "asan_manhole_20260319.zip",
registeredAt: "2026-03-19",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260316-0015",
facilityTypeCode: "SWTRMT_FCLT",
facilityTypeName: "하수처리시설",
fileName: "asan_treatment_20260316.zip",
registeredAt: "2026-03-16",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260313-0016",
facilityTypeCode: "PPL",
facilityTypeName: "하수관로",
fileName: "asan_pipe_20260313.zip",
registeredAt: "2026-03-13",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260310-0017",
facilityTypeCode: "MHL",
facilityTypeName: "하수맨홀",
fileName: "asan_manhole_20260310.zip",
registeredAt: "2026-03-10",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260307-0018",
facilityTypeCode: "SWTRMT_FCLT",
facilityTypeName: "하수처리시설",
fileName: "asan_swtrmt_20260307.zip",
registeredAt: "2026-03-07",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260304-0019",
facilityTypeCode: "PPL",
facilityTypeName: "하수관로",
fileName: "asan_pipe_20260304.zip",
registeredAt: "2026-03-04",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260301-0020",
facilityTypeCode: "MHL",
facilityTypeName: "하수맨홀",
fileName: "asan_manhole_20260301.zip",
registeredAt: "2026-03-01",
registeredBy: "admin",
},
{
uploadId: "GUP-MOCK-20260226-0021",
facilityTypeCode: "SWTRMT_FCLT",
facilityTypeName: "하수처리시설",
fileName: "asan_treatment_20260226.zip",
registeredAt: "2026-02-26",
registeredBy: "admin",
},
];
const getFileExtension = (fileName: string) => {
const lastDotIndex = fileName.lastIndexOf(".");
if (lastDotIndex < 0) return "";
return fileName.slice(lastDotIndex).toLowerCase();
};
const buildTodayDate = () => new Date().toISOString().slice(0, 10);
const GIS_UPLOAD_PAGE_SIZE = 10;
export default function GisManagementPopupPage() {
// 브라우저 파일 picker는 제어형 input보다 ref 제어가 안전하다.
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [selectedFacilityTypeCode, setSelectedFacilityTypeCode] = useState("");
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploadHistory, setUploadHistory] = useState<UploadHistoryItem[]>(
INITIAL_UPLOAD_HISTORY,
);
const [selectedUploadId, setSelectedUploadId] = useState<string>(
INITIAL_UPLOAD_HISTORY[0]?.uploadId ?? "",
);
const [currentPage, setCurrentPage] = useState(1);
const selectedFacility = useMemo(
() =>
UPLOAD_FACILITY_TYPES.find(
(facilityType) =>
facilityType.facilityTypeCode === selectedFacilityTypeCode,
) ?? null,
[selectedFacilityTypeCode],
);
const selectedHistory = useMemo(
() =>
uploadHistory.find((history) => history.uploadId === selectedUploadId) ??
null,
[selectedUploadId, uploadHistory],
);
const pagedUploadHistory = useMemo(() => {
const startIndex = (currentPage - 1) * GIS_UPLOAD_PAGE_SIZE;
return uploadHistory.slice(startIndex, startIndex + GIS_UPLOAD_PAGE_SIZE);
}, [currentPage, uploadHistory]);
const resetSelectedFile = () => {
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleFacilityTypeChange = (
event: ChangeEvent<HTMLSelectElement>,
) => {
setSelectedFacilityTypeCode(event.target.value);
resetSelectedFile();
};
const handleOpenFilePicker = () => {
if (!selectedFacility) {
window.alert("시설구분을 먼저 선택하세요.");
return;
}
fileInputRef.current?.click();
};
const handleFileSelected = (event: ChangeEvent<HTMLInputElement>) => {
const nextFile = event.target.files?.[0] ?? null;
if (!selectedFacility) {
resetSelectedFile();
window.alert("시설구분을 먼저 선택한 뒤 파일을 선택하세요.");
return;
}
if (!nextFile) {
resetSelectedFile();
return;
}
const fileExtension = getFileExtension(nextFile.name);
const isAllowedExtension = selectedFacility.allowedExtensions.includes(
fileExtension,
);
if (!isAllowedExtension) {
resetSelectedFile();
window.alert(
`업로드 가능한 GIS 파일 형식이 아닙니다. 허용 형식: ${selectedFacility.allowedExtensions.join(", ")}`,
);
return;
}
const maxFileSizeInBytes = selectedFacility.maxFileSizeMb * 1024 * 1024;
if (nextFile.size > maxFileSizeInBytes) {
resetSelectedFile();
window.alert(
`파일 크기가 제한을 초과했습니다. 최대 ${selectedFacility.maxFileSizeMb}MB까지 업로드할 수 있습니다.`,
);
return;
}
setSelectedFile(nextFile);
};
const handleUpload = () => {
if (!selectedFacility) {
window.alert("시설구분을 선택하세요.");
return;
}
if (!selectedFile) {
window.alert("업로드할 파일을 선택하세요.");
return;
}
const isDuplicateFile = uploadHistory.some(
(history) =>
history.facilityTypeCode === selectedFacility.facilityTypeCode &&
history.fileName.toLowerCase() === selectedFile.name.toLowerCase(),
);
if (isDuplicateFile) {
window.alert("동일 시설구분에 같은 파일명이 존재합니다.");
return;
}
const nextUploadHistory: UploadHistoryItem = {
uploadId: `GUP-MOCK-${Date.now()}`,
facilityTypeCode: selectedFacility.facilityTypeCode,
facilityTypeName: selectedFacility.facilityTypeName,
fileName: selectedFile.name,
registeredAt: buildTodayDate(),
registeredBy: "admin",
};
setUploadHistory((currentHistory) => [
nextUploadHistory,
...currentHistory,
]);
setCurrentPage(1);
setSelectedUploadId(nextUploadHistory.uploadId);
window.alert("GIS 업로드가 완료되었습니다.");
resetSelectedFile();
};
const handleDownload = () => {
if (!selectedHistory) {
window.alert("다운로드할 파일을 선택하세요.");
return;
}
window.alert(`${selectedHistory.fileName} 다운로드를 요청했습니다.`);
};
return (
<main className="flex h-screen items-start justify-center bg-[linear-gradient(180deg,#f7f9fc_0%,#eef4fa_100%)] px-[12px] py-[12px] text-[#202a36]">
<section className="w-full max-w-[920px] overflow-hidden rounded-[12px] border border-[#d8e3ef] bg-[rgba(255,255,255,0.96)] shadow-[0_16px_36px_rgba(31,59,87,0.10)] backdrop-blur-[2px]">
<header className="flex h-[44px] items-center border-b border-[#e2ebf5] bg-[linear-gradient(180deg,#ffffff_0%,#f3f8fd_100%)] px-[18px]">
<h1 className="text-[1.35rem] font-semibold tracking-[-0.01em] text-[#1f3b57]">
GIS 관리
</h1>
</header>
<div className="px-[16px] pb-[16px] pt-[10px]">
<div className="mb-[10px] mt-[4px] text-[1.05rem] font-medium tracking-[-0.01em] text-slate-700">
GIS 업로드
</div>
<div className="mb-[8px] flex items-center justify-between gap-[12px] px-[2px]">
<div className="flex min-w-0 flex-1 items-center gap-[10px]">
<select
className="h-[32px] w-[110px] shrink-0 rounded-[6px] border border-[#cad7e6] bg-[linear-gradient(180deg,#ffffff_0%,#f8fbff_100%)] px-[10px] text-[1.02rem] font-medium text-slate-700 outline-none transition focus:border-[#8bbef0] focus:shadow-[0_0_0_2px_rgba(47,126,196,0.12)]"
value={selectedFacilityTypeCode}
onChange={handleFacilityTypeChange}
aria-label="시설구분"
>
<option value="">시설구분</option>
{UPLOAD_FACILITY_TYPES.map((facilityType) => (
<option
key={facilityType.facilityTypeCode}
value={facilityType.facilityTypeCode}
>
{facilityType.facilityTypeName}
</option>
))}
</select>
<Input
value={selectedFile?.name ?? ""}
placeholder="선택된 파일 없음"
readOnly
aria-label="선택 파일명"
className="h-[32px] min-w-0 flex-1 rounded-[6px] border-[#cad7e6] bg-[linear-gradient(180deg,#ffffff_0%,#f8fbff_100%)] px-[10px] py-0 !text-[1.02rem] font-medium text-slate-700 placeholder:text-[#9aa8b7] shadow-[inset_0_1px_0_rgba(255,255,255,0.7)] focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
<div className="flex shrink-0 items-center gap-[10px]">
<Button
type="button"
size="sm"
className="h-[32px] min-w-[56px] rounded-sm bg-blue-600 px-0 py-2 text-sm font-medium text-white hover:bg-blue-700"
onClick={handleOpenFilePicker}
>
찾기
</Button>
<Button
type="button"
size="sm"
className="h-[32px] min-w-[64px] rounded-sm bg-blue-600 px-0 py-2 text-sm font-medium text-white hover:bg-blue-700"
onClick={handleUpload}
>
업로드
</Button>
</div>
</div>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept={
selectedFacility?.allowedExtensions.join(",") ??
".zip,.shp,.geojson,.json,.dxf"
}
onChange={handleFileSelected}
/>
<div className="mb-[8px] flex items-center justify-between px-[2px]">
<div className="text-[1.05rem] font-medium tracking-[-0.01em] text-slate-700">
GIS 업로드 현황
</div>
<Button
type="button"
size="sm"
color="green"
className="h-[32px] min-w-[82px] rounded-sm px-[10px] py-2 text-sm font-medium text-white"
onClick={handleDownload}
>
다운로드
</Button>
</div>
<div className="overflow-hidden rounded-[10px] border border-[#d6e1ee] bg-[linear-gradient(180deg,#ffffff_0%,#f9fbfe_100%)] shadow-[0_6px_16px_rgba(31,59,87,0.06),inset_0_1px_0_rgba(255,255,255,0.78)]">
<Table variant="secondary" className="text-[1.02rem]">
<TableHeader className="!static bg-transparent">
<TableRow className="border-0">
<TableHead className="h-[32px] border border-[#d7e3ef] bg-[#eaf3fb] px-[12px] py-[5px] text-left text-[1.0rem] font-medium text-[#647784]">
no
</TableHead>
<TableHead className="h-[32px] border border-[#d7e3ef] bg-[#eaf3fb] px-[12px] py-[5px] text-left text-[1.0rem] font-medium text-[#647784]">
시설구분
</TableHead>
<TableHead className="h-[32px] border border-[#d7e3ef] bg-[#eaf3fb] px-[12px] py-[5px] text-left text-[1.0rem] font-medium text-[#647784]">
파일명
</TableHead>
<TableHead className="h-[32px] border border-[#d7e3ef] bg-[#eaf3fb] px-[12px] py-[5px] text-left text-[1.0rem] font-medium text-[#647784]">
등록일자
</TableHead>
<TableHead className="h-[32px] border border-[#d7e3ef] bg-[#eaf3fb] px-[12px] py-[5px] text-left text-[1.0rem] font-medium text-[#647784]">
등록자
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedUploadHistory.map((history, index) => {
const isSelected = history.uploadId === selectedUploadId;
const backgroundColor = isSelected
? "#eef6ff"
: index % 2 === 0
? "#ffffff"
: "#f9fbfd";
const rowNumber =
(currentPage - 1) * GIS_UPLOAD_PAGE_SIZE + index + 1;
return (
<TableRow
key={history.uploadId}
className="border-0 cursor-pointer"
onClick={() => setSelectedUploadId(history.uploadId)}
style={{ backgroundColor }}
>
<TableCell className="h-[40px] border border-[#dde7f0] px-[12px] py-[8px] text-[1.0rem] font-normal text-[#43566b]">
{rowNumber}
</TableCell>
<TableCell className="h-[40px] border border-[#dde7f0] px-[12px] py-[8px] text-[1.0rem] font-normal text-[#43566b]">
{history.facilityTypeName}
</TableCell>
<TableCell className="h-[40px] border border-[#dde7f0] px-[12px] py-[8px] text-[1.0rem] font-normal text-[#43566b]">
{history.fileName}
</TableCell>
<TableCell className="h-[40px] border border-[#dde7f0] px-[12px] py-[8px] text-[1.0rem] font-normal text-[#43566b]">
{history.registeredAt}
</TableCell>
<TableCell className="h-[40px] border border-[#dde7f0] px-[12px] py-[8px] text-[1.0rem] font-normal text-[#43566b]">
{history.registeredBy}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{uploadHistory.length > GIS_UPLOAD_PAGE_SIZE ? (
<div className="mt-[10px] flex justify-center">
<UiPageNations
total={uploadHistory.length}
cur={currentPage}
pageSize={GIS_UPLOAD_PAGE_SIZE}
onPageChange={setCurrentPage}
/>
</div>
) : null}
</div>
</section>
</main>
);
}
+4 -1
View File
@@ -2102,12 +2102,14 @@ const Map = () => {
const popup = openWindowPopup(popupKey);
if (!popup) {
alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.");
return;
return null;
}
if (popupKey === "pipeAnalysisPopup") {
pipeAnalysisPopupWindowRef.current = popup;
}
return popup;
}, []);
const openGisEditSessionPopup = useCallback(() => {
@@ -2721,6 +2723,7 @@ const Map = () => {
onOpenInfoSearch={() => setIsSearchModalOpen(true)}
onToggleGisEdit={handleToggleGisEdit}
isGisEditActive={gisEdit.isEditMode}
isInfoSearchActive={isSearchModalOpen}
/>
<GisEditToolbar
isEditMode={gisEdit.isEditMode}
@@ -0,0 +1,65 @@
# SFR-012-002 하수관망도 업로드 통합 검증 계획
## 1. 문서 목적
- `SFR-012-002 하수관망도 업로드`의 검증 계획을 기능당 1개 문서로 누적 관리한다.
- 현재는 구현 전 단계이므로 FE-only 검증 범위와 BE 연동 후 검증 범위를 먼저 분리한다.
## 2. 검증 기준 요약
- `GIS 관리` popup이 정상적으로 열린다.
- 시설구분 미선택 시 `찾기` 또는 `업로드`가 차단된다.
- 파일 선택 시 파일명이 표시된다.
- 업로드 성공 시 현황 목록이 즉시 갱신된다.
- 다운로드는 선택 행이 있을 때만 가능 상태가 된다.
- 원본 분리 저장 안내가 popup에서 유지된다.
## 3. FE-only 검증 계획
### 3.1 popup 진입
- 우측 기능 버튼 `GIS 관리` 클릭
- popup open 여부
- popup 제목, 업로드 영역, 현황 그리드 노출 여부
### 3.2 업로드 폼
- 시설구분 미선택 상태에서 `찾기` 클릭 차단
- 파일 미선택 상태에서 `업로드` 클릭 차단
- 파일 선택 후 파일명 input 반영
### 3.3 업로드 현황
- mock 업로드 성공 시 목록 상단 반영
- 행 선택 시 선택 강조 및 상태 메시지 반영
- 다운로드 버튼 선택 상태 확인
- 항목 10건 초과 시 기존 공용 페이지네이션이 노출되는지 확인
- 첫 화면 기준 5행 수준이 자연스럽게 보이는지 확인
- `찾기`, `업로드`, `다운로드` 버튼 색과 간격이 화면에 맞는지 확인
### 3.4 요청 미리보기
- popup을 닫으면 분석도구 메뉴 active가 해제되는지 확인
## 4. BE 연동 후 검증 계획
- 허용 확장자 검증
- 최대 파일 크기 검증
- 시설구분 매핑 검증
- 중복 파일 검증
- 업로드 실패 시 원본 보호
- 다운로드 권한/존재 검증
## 5. 차단 사유 분류 기준
- `기능 결함`
- `인증 차단`
- `환경 차단`
- `데이터 차단`
- `자동화 한계`
## 6. 현재 상태
- 설계 완료
- FE-only mock 구현 완료
- 브라우저 정밀 조작 검증은 일부 미실행
@@ -0,0 +1,63 @@
# SFR-012-002 하수관망도 업로드 통합 검증 결과
## 1. 문서 목적
- `SFR-012-002 하수관망도 업로드`의 검증 결과를 기능당 1개 문서로 누적 관리한다.
- 현재 문서는 구현 전 단계의 초기 상태를 기록한다.
## 2. 현재 검증 상태
### 2.1 수행 여부
- FE-only mock 구현 기준으로 정적/라우트 검증을 일부 수행했다.
### 2.2 사유
- 실제 백엔드 업로드/다운로드 연결이 아직 없으므로, 이번 결과는 popup 렌더링과 mock 흐름 중심이다.
### 2.3 수행 결과
- 정적 구현 확인
- `gisManagementPopup` placeholder가 실제 popup page wrapper 구조로 교체됨
- 시설구분 선택, 파일 선택, mock 업로드 이력, 다운로드 대상 선택 UI 확인
- 업로드 현황 10건 단위 페이지네이션 연결 확인
- popup 종료 시 분석도구 active 해제 로직 연결 확인
- `찾기`, `업로드` 버튼 `조회` 계열 색 반영
- `다운로드` 버튼 `green` 계열 색 반영
- 업로드 row 버튼 그룹 정렬/간격 조정 반영
- 로컬 라우트 응답 확인
- `http://127.0.0.1:3002/popup/gisManagementPopup` 응답 `200`
- `http://127.0.0.1:3002/map` 응답 `200`
- 응답 HTML 문자열 확인
- `GIS 업로드`
- `GIS 업로드 현황`
- `시설구분`
- `다운로드`
- 정적 타입 검사
- `pnpm --filter sn exec tsc --noEmit --pretty false --incremental false`
- 결과: 실패
- 사유: 기존 저장소 타입 오류
- `app/(map-layout)/map/components/common/index.ts`
- `app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx`
- `app/html/(map-layout)/map/components/common/index.ts`
- `app/html/(with-base-layout)/layout.tsx`
- `packages/common/business_components/src/ui/chart/*`
- 이번 `GisManagementPopupContent.tsx` 관련 신규 오류는 출력되지 않았다.
## 3. 후속 검증 예정 항목
- `GIS 관리` popup open
- 시설구분/파일 선택 유효성
- mock 업로드 이력 갱신
- 다운로드 대상 선택
- 10건 초과 페이지 전환
- popup 종료 후 active 해제
- 버튼 색/간격 최종 육안 확인
- BE 연동 후 실제 업로드/다운로드 검증
## 4. 현재 리스크
- 서버 계약이 확정되기 전까지 FE 검증 항목 일부는 mock 기준에 머문다.
- 실제 파일 선택, 업로드 버튼 클릭, 행 선택 상호작용은 브라우저 수동 확인이 한 번 더 필요하다.
- 저장소 기존 타입 오류 때문에 이번 기능만 분리한 정적 통과 여부를 보장하기 어렵다.
- 시설구분 목록은 아직 실제 시설물 레이어 조회 결과와 자동 동기화되지 않는다.
@@ -0,0 +1,174 @@
# SFR-012-002 하수관망도 업로드 작업카드
## 1. 작업 개요
- 작업 ID: SFR-012-002-FE
- 작업명: 하수관망도 업로드 프론트엔드 구현 계획
- 상태: 완료
- 작성일: 2026-05-15
- 담당 역할: FE
- 관련 역할: DE, BE, QA
- 담당 범위: `GIS 관리` popup UI, 시설구분 선택, 파일 찾기, mock 업로드/다운로드 흐름, 업로드 payload 준비 상태까지
- 구현 제외: 실제 서버 업로드/다운로드 연결, 백엔드 저장 로직, DB 반영
- 근거 문서
- `COMMON_DOCS_ROOT/작업계획/SFR-012-002-하수관망도 업로드/001-de-SFR-012-002-하수관망도 업로드-설계.md`
- `COMMON_DOCS_ROOT/작업지시/SFR-012-002-하수관망도 업로드/하수관망도업로드_요구사항명세서_v1_1.docx`
- `COMMON_DOCS_ROOT/작업지시/SFR-012-002-하수관망도 업로드/하수관망도업로드_시나리오_v1.0.md`
- `COMMON_DOCS_ROOT/작업지시/SFR-012-002-하수관망도 업로드/하수관망도업로드_화면_v1_1.html`
- `COMMON_DOCS_ROOT/작업지시/SFR-012-002-하수관망도 업로드/하수관망도업로두_화면설계서_v1.0.png`
## 1-1. 공통 동기화 확인
- 공통 작업현황판 확인 여부: 확인
- 파일점유현황 확인 여부: 확인
- 결정로그 확인 여부: 확인
- 기존 진행 중 작업과의 관계
- `SFR-012-003`에서 `GIS 편집`은 지도 로컬 모드로 구현됐다.
- 이번 `SFR-012-002`는 같은 우측 기능 버튼 그룹을 사용하지만 popup 업무 흐름으로 분리해야 한다.
## 1-2. 최근 설계 갱신
- 2026-05-15
- `SFR-012-003`의 문서 구조를 기준으로 `SFR-012-002` canonical 문서 세트를 신규 작성한다.
- 이번 기능의 FE 핵심은 `map/page.tsx` 직접 수정 확대보다 `gisManagementPopup` placeholder를 실제 popup content로 교체하는 데 있다.
- 승인 전에는 코드 수정 없이 설계, 작업카드, 구현기록, 검증 문서 초안까지만 진행한다.
- 2026-05-15 구현 반영
- `gisManagementPopup` placeholder를 실제 업로드 관리 popup content로 교체했다.
- 시설구분 선택, 파일 찾기, mock 업로드 이력 추가, 다운로드 요청 미리보기, 업로드/다운로드 payload 미리보기까지 FE-only 범위를 반영했다.
- 실제 multipart 업로드와 다운로드 바이너리 처리, 서버 이력 재조회는 후속 BE 연동 단계로 남긴다.
- 2026-05-18 구현 마감 정리
- popup 구현 구조를 `windowPopup` wrapper + 실제 popup page 컴포넌트 분리 방식으로 재구성했다.
- `GIS 관리` popup 디자인을 분석도구 계열의 타이포와 색 축에 맞춰 재정렬했다.
- 업로드 현황은 10건 단위 페이징을 붙이고, 팝업을 닫으면 분석도구 메뉴의 active 상태도 같이 해제되도록 보완했다.
- `찾기`, `업로드` 버튼은 상황판/검색영역 `조회` 버튼 계열 색으로 정리했고, `다운로드` 버튼은 운영정보 `파일 다운로드`와 같은 `green` 계열로 정리했다.
- 업로드 row는 좌측 입력 그룹과 우측 버튼 그룹을 분리해 버튼 위치와 간격을 조정했다.
## 2. 작업 목적
- 기존 지도 화면의 `GIS 관리` 기능을 실제 업로드 관리 popup으로 구체화한다.
- 사용자가 시설구분을 선택하고 GIS 파일을 찾은 뒤 업로드 이력과 다운로드 대상을 관리할 수 있는 FE 화면 흐름을 마련한다.
- 실제 백엔드 연결 전에도 UI, 상태, mock 데이터, 업로드 요청 payload 구조를 검토할 수 있도록 준비한다.
## 3. 예상 수정 대상 파일
| 구분 | 파일 경로 | 수정 여부 | 근거 |
| --- | --- | --- | --- |
| popup registry | `FE_APP_ROOT/src/features/windowPopup/registry.tsx` | 예정 | `gisManagementPopup` placeholder를 실제 content로 교체 |
| popup definition | `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts` | 가능성 있음 | popup 크기 조정 필요 시 |
| 신규 popup content | `FE_APP_ROOT/src/features/windowPopup/ui/GisManagementPopupContent.tsx` | 예정 | 업로드 관리 popup 본문 |
| 신규 보조 컴포넌트 | `FE_APP_ROOT/src/features/windowPopup/ui/gisManagement/*` | 가능성 있음 | 폼/그리드/상태영역 분리 시 |
| 지도 메인 화면 | `FE_APP_ROOT/app/(map-layout)/map/page.tsx` | 가능성 낮음 | popup open 이후 동기화가 필요할 때만 최소 수정 |
| 우측 기능 버튼 | `FE_APP_ROOT/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx` | 기본 제외 | 버튼 key와 open 흐름이 이미 존재 |
## 3-1. 이번 라운드 실제 수정 파일
- `FE_APP_ROOT/src/features/windowPopup/ui/GisManagementPopupContent.tsx`
- `FE_APP_ROOT/src/features/windowPopup/registry.tsx`
- `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts`
- `FE_APP_ROOT/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx`
- `FE_APP_ROOT/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx`
- `FE_APP_ROOT/app/(map-layout)/map/page.tsx`
## 4. 화면 흐름
- 진입 경로
- 하수관망도 지도 화면
- 우측 기능 버튼 `GIS 관리`
- 사용자 조작
- `GIS 관리` 클릭
- 시설구분 선택
- `찾기` 클릭 후 파일 선택
- `업로드` 클릭
- 업로드 현황 행 선택
- `다운로드` 클릭
- 정상 흐름
- popup 열림
- 시설구분 선택 전 `찾기`/`업로드` 안내
- 파일 선택 후 파일명 표시
- 업로드 후 현황 목록 갱신
- 다운로드 대상 선택 후 다운로드 요청 가능 상태
- 오류 흐름
- 시설구분 미선택
- 파일 미선택
- 미지원 확장자
- 업로드 실패
- 다운로드 대상 미선택
## 5. FE 단독 구현 범위
- popup UI 렌더링
- 시설구분 select 상태
- 브라우저 파일 선택 상태
- mock 업로드 이력 목록
- 업로드 시 mock 행 추가
- 다운로드 대상 선택 상태
- 상태 메시지 및 토스트
- 업로드 payload 직렬화 준비
## 6. 백엔드 필요 범위
- 업로드 가능 시설구분/허용 확장자/최대 파일 크기 메타 API
- 업로드 현황 조회 API
- GIS 파일 업로드 API
- GIS 파일 다운로드 API
- 원본 분리 저장 및 최신 참조 갱신
## 7. API 연동 초안
| 기능 | Method | URL | 요청 | 응답 | 확인 상태 |
| --- | --- | --- | --- | --- | --- |
| 시설구분 메타 조회 | `GET` | `/api/v1/map/gisUploadFacilityTypes` | `screenId` | 시설구분, 허용 확장자, 업로드 가능 여부 | BE 신규 필요 |
| 업로드 현황 조회 | `GET` | `/api/v1/map/gisUploadHistories` | `screenId`, `facilityTypeCode` | 업로드 이력 목록 | BE 신규 필요 |
| GIS 업로드 | `POST` | `/api/v1/map/gisUploads` | `multipart/form-data` | 업로드 결과, 참조 갱신 정보 | BE 신규 필요 |
| GIS 다운로드 | `GET` | `/api/v1/map/gisUploads/{uploadId}/download` | `uploadId` | 파일 다운로드 | BE 신규 필요 |
## 8. 영향 범위
- `apps/sn` 화면 영향
- 지도 화면 우측 기능 버튼의 `GIS 관리` popup 실제 동작
- popup 내부 폼/그리드/상태 메시지
- 지도 기능 영향
- 지도 이동, 확대/축소, 레이어 트리 기존 동작은 유지돼야 한다.
- 공통 비즈니스 컴포넌트 영향
- 현재 계획상 없음
- 타 앱 영향 가능성
- 없음
## 9. 검증 방법
- 브라우저
- `GIS 관리` popup 열림
- 시설구분 미선택 시 차단
- 파일 선택 후 파일명 노출
- mock 업로드 후 목록 갱신
- 다운로드 대상 선택 상태
- popup 종료 시 분석도구 메뉴 active 해제
- 10건 단위 페이징과 첫 화면 5행 수준 밀도 확인
- `찾기`, `업로드`, `다운로드` 버튼 색/간격 확인
- 정적 확인
- payload preview 또는 직렬화 결과 점검
- 이번 라운드 수행 결과
- `http://127.0.0.1:3002/popup/gisManagementPopup` 응답 `200`
- popup 응답 HTML에서 `GIS 업로드`, `GIS 업로드 현황`, `시설구분`, `다운로드` 문자열 확인
- `pnpm --filter sn exec tsc --noEmit --pretty false --incremental false` 실행 시 기존 저장소 타입 오류로 실패했으며, 이번 popup 파일 관련 신규 오류는 출력되지 않았다.
- 보류 항목
- 실제 multipart 업로드 응답
- 실제 다운로드 파일 수신
## 10. 롤백 방법
- `gisManagementPopup`을 placeholder content로 되돌린다.
- 신규 popup content와 관련 mock 상태를 제거한다.
## 11. 예상 위험
- popup 구현 위치가 `src/features/windowPopup/**`라 지도 화면 코드와 책임 경계를 명확히 잡지 않으면 유지보수가 어려워질 수 있다.
- 파일 업로드는 브라우저 객체와 서버 multipart 계약이 어긋나기 쉬워 FE-only 구현이 오래 임시 상태로 남을 수 있다.
- 업로드 후 실제 지도 레이어 refresh 시점은 서버 계약 없이는 임의로 확정하기 어렵다.
- 시설구분 목록은 아직 실제 `시설물 레이어`나 업로드 메타 API를 조회하지 않고 mock 값으로 유지된다.
## 12. 완료 기준
- FE 범위의 `GIS 관리` popup UI, mock 업로드 흐름, 페이지네이션, active 상태 해제가 반영됐을 것
- 예상 수정 파일, 화면 흐름, BE 필요 범위가 문서로 정리됐을 것
- 실제 업로드/다운로드 API 연동 전까지 FE와 BE 경계가 분리돼 있을 것
@@ -0,0 +1,108 @@
# SFR-012-002 하수관망도 업로드 통합 구현 기록
## 1. 문서 목적
- `SFR-012-002 하수관망도 업로드`의 FE 구현 이력, 결정사항, BE 연동 경계, 남은 리스크를 기능당 1개 문서로 누적 관리한다.
- 구현 전 단계에서는 설계 기준과 착수 조건을 먼저 기록한다.
- 이후 라운드부터는 새 파일을 추가하지 않고 이 문서 내부 헤더만 추가한다.
## 2. 입력 근거
- `COMMON_DOCS_ROOT/작업계획/SFR-012-002-하수관망도 업로드/001-de-SFR-012-002-하수관망도 업로드-설계.md`
- `FE_APP_DOCS_ROOT/작업계획/SFR-012-002-하수관망도 업로드/001-fe-SFR-012-002-하수관망도 업로드-작업카드.md`
## 3. 공통 계약
- `GIS 관리`는 지도 화면 내부 로컬 편집 모드가 아니라 `window popup` 업무 흐름으로 구현한다.
- 업로드 대상 GIS는 원본과 분리된 업로드본으로 저장된다는 업무 규칙을 FE 문구와 payload 구조에 모두 반영한다.
- 승인 전에는 코드 구현을 시작하지 않는다.
- FE는 승인 후에도 실제 업로드/다운로드 API 연결 전까지는 mock 이력과 payload 준비 상태까지만 구현한다.
## 4. 라운드 요약
### 4.1 2026-05-15 설계 착수
- 작업지시의 요구사항 명세서, 시나리오, HTML, PNG를 읽고 `SFR-012-003`과 비교했다.
- `SFR-012-003`이 지도 위 직접 편집 중심이었다면, `SFR-012-002`는 popup 기반 업로드 관리라는 점을 핵심 차이로 확정했다.
- canonical 문서 구조는 그대로 재사용하되, 이번 기능의 구현 후보를 `src/features/windowPopup/**` 중심으로 재설계했다.
### 4.2 2026-05-15 FE-only popup 구현
- `gisManagementPopup` placeholder를 실제 popup content로 교체했다.
- 구현 범위
- 시설구분 select
- 파일 찾기 input
- 파일 확장자/크기 1차 검증
- mock 업로드 이력 갱신
- 다운로드 대상 선택
- 업로드/다운로드 요청 미리보기
- 구현 원칙
- 실제 API 호출 없이도 백엔드 계약과 payload 구조를 검토할 수 있어야 한다.
- 업로드 성공 시 같은 시설구분의 기존 최신 참조 행은 `이력` 상태로 내리고, 신규 행을 `최신 참조`로 올리는 mock 규칙을 둔다.
- 미지원 시설구분은 select에서 비활성 처리해 예외 흐름을 먼저 노출한다.
- 실제 수정 파일
- `FE_APP_ROOT/src/features/windowPopup/ui/GisManagementPopupContent.tsx`
- `FE_APP_ROOT/src/features/windowPopup/registry.tsx`
- `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts`
### 4.3 2026-05-18 popup 구조/스타일 재정리
- `windowPopup` 내부에서 직접 화면을 모두 그리던 방식을 걷어내고, `GisManagementPopupContent`는 얇은 wrapper로 두고 실제 화면은 `GisManagementPopupPage`로 분리했다.
- 분석도구 popup과 비슷한 타이포/버튼/테이블 톤으로 재정리했다.
- 팝업 초기 폭과 높이를 조정하고, 내부 컨텐츠는 창 크기에 맞춰 유동적으로 늘어나도록 변경했다.
- 업로드 현황은 10건 단위 페이징을 적용했고, 기본적으로 5행 수준이 편하게 보이도록 높이를 조정했다.
- `GIS 관리` window popup을 닫으면 분석도구 메뉴 active가 같이 해제되도록 보완했다.
- `찾기`, `업로드` 버튼은 상황판/검색영역 `조회` 버튼 계열 색으로 통일했다.
- `다운로드` 버튼은 운영정보 `파일 다운로드`와 같은 `green` 계열 버튼으로 분리했다.
- 업로드 row는 좌측 입력 그룹과 우측 버튼 그룹을 분리 정렬하고, 버튼 간격을 별도로 조정했다.
### 4.4 현재 FE 구현 범위 확정
- 구현 완료 범위
- `GIS 관리` popup 진입
- 시설구분 선택
- 파일 찾기
- 확장자/크기 1차 검증
- mock 업로드 이력 누적
- 업로드 현황 10건 페이징
- 다운로드 대상 선택
- popup close 시 분석도구 active 해제
- 버튼 색/정렬/간격 정리
- 미완료 범위
- 실제 업로드 API 호출
- 실제 다운로드 바이너리 응답
- 업로드 성공 후 서버 이력 재조회
- 실제 시설물 레이어 기반 시설구분 동기화
## 5. BE 공유 초안
### 5.1 FE가 기대하는 메타 정보
- 업로드 가능 시설구분 목록
- 시설구분별 허용 확장자
- 시설구분별 최대 파일 크기
- 시설구분별 업로드 가능 여부
### 5.2 FE가 기대하는 업로드 응답 정보
- `uploadId`
- `facilityTypeCode`
- `facilityTypeName`
- `fileName`
- `registeredAt`
- `registeredBy`
- `activeYn`
- 최신 참조 갱신 여부
### 5.3 FE가 기대하는 다운로드 조건
- 선택 업로드 ID 기준 다운로드
- 권한 없음 / 파일 없음 / 다운로드 실패 메시지 구분
## 6. 남은 리스크
- popup UI를 어느 수준까지 기존 공용 폼 패턴으로 맞출지 구현 직전에 다시 확인이 필요하다.
- 업로드 후 지도 레이어를 즉시 새 업로드본 기준으로 refresh할지 여부는 서버 계약 없이는 확정하기 어렵다.
- 실제 upload history 스키마가 설계 초안과 달라질 수 있다.
- 현재 업로드/다운로드는 요청 미리보기와 mock 목록 갱신만 수행하므로, 실제 파일 바이너리 흐름은 백엔드 구현 후 재검증이 필요하다.
- 시설구분 목록은 현재 `UPLOAD_FACILITY_TYPES` mock 상수에 고정돼 있어 실제 좌측 `시설물 레이어`와 다를 수 있다.
@@ -112,8 +112,8 @@ export const windowPopupDefinitions = {
gisManagementPopup: {
key: "gisManagementPopup",
title: "GIS 관리",
width: 1080,
height: 760,
width: 860,
height: 700,
},
flowRiskPopup: {
key: "flowRiskPopup",
@@ -14,6 +14,7 @@ import SscDetailPopupContent from "./ui/SscDetailPopupContent";
import type { WindowPopupContentProps } from "./model/types";
import FeaturePlaceholderPopupContent from "./ui/FeaturePlaceholderPopupContent";
import GisEditSessionPopupContent from "./ui/GisEditSessionPopupContent";
import GisManagementPopupContent from "./ui/GisManagementPopupContent";
import PipeAnalysisPopupContent from "./ui/PipeAnalysisPopupContent";
import PipeAnalysisDetailPopupContent from "./ui/PipeAnalysisDetailPopupContent";
@@ -33,12 +34,6 @@ const GisEditPopupContent = (_props: WindowPopupContentProps) => (
<FeaturePlaceholderPopupContent title={windowPopupDefinitions.gisEditPopup.title} />
);
const GisManagementPopupContent = (_props: WindowPopupContentProps) => (
<FeaturePlaceholderPopupContent
title={windowPopupDefinitions.gisManagementPopup.title}
/>
);
const FlowRiskPopupContent = (_props: WindowPopupContentProps) => (
<FeaturePlaceholderPopupContent title={windowPopupDefinitions.flowRiskPopup.title} />
);
@@ -0,0 +1,10 @@
import { Suspense } from "react";
import GisManagementPopupPage from "@/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage";
export default function GisManagementPopupContent() {
return (
<Suspense fallback={null}>
<GisManagementPopupPage />
</Suspense>
);
}