From c14d53613375fa5c7b0baf95cc05bda80e023ee4 Mon Sep 17 00:00:00 2001 From: kiminchan Date: Tue, 30 Jun 2026 13:09:07 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=95=EB=B3=B4=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=EA=B3=84=ED=9A=8D=EB=B3=B4=EA=B3=A0=EC=84=9C?= =?UTF-8?q?=20=ED=83=AD=20=EC=A1=B0=ED=9A=8C=20UX=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../map/searchModal/reportTab.tsx | 318 ++++++++++-------- .../map/searchModal/searchModal.tsx | 198 +++++------ .../map/searchModal/useReportTab.ts | 89 +++-- .../map/searchModal/useSearchModal.ts | 113 ++++--- .../001-qa-정보검색-하수도정비기본계획보고서-통합검증계획.md | 56 +++ .../002-qa-정보검색-하수도정비기본계획보고서-통합검증결과.md | 37 ++ .../001-fe-정보검색-하수도정비기본계획보고서-통합구현기록.md | 82 +++++ .../windowPopup/ui/ReportTabPopupContent.tsx | 12 + .../ui/SearchModalPopupContent.tsx | 21 +- .../src/ui/select/lib/selectBox.tsx | 6 +- .../src/ui/select/select.css | 4 +- .../src/ui/table/index.tsx | 115 +++---- 12 files changed, 641 insertions(+), 410 deletions(-) create mode 100644 apps/sn/docs/작업검증/SN-정보검색-하수도정비기본계획보고서-유지보수/001-qa-정보검색-하수도정비기본계획보고서-통합검증계획.md create mode 100644 apps/sn/docs/작업검증/SN-정보검색-하수도정비기본계획보고서-유지보수/002-qa-정보검색-하수도정비기본계획보고서-통합검증결과.md create mode 100644 apps/sn/docs/작업내용/SN-정보검색-하수도정비기본계획보고서-유지보수/001-fe-정보검색-하수도정비기본계획보고서-통합구현기록.md diff --git a/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx b/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx index f054a9ed1..b7659604e 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx +++ b/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx @@ -1,18 +1,19 @@ -/** +/** * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 하수도정비기본계획보고서 탭 * * 📄 Report Tab * Next.js App Router (app/) 기반 검색 모달 보고서 탭 컴포넌트 * * - 하수도정비 기본계획 보고서 목록 표시 및 필터링 - * - 시도/시군구/보고서 구분/승인년도 기반 검색 기능 + * - 시도/시군구/구분/보고서 구분/승인년도 기반 검색 기능 * - 테이블 데이터 표시 및 페이지네이션 지원 - * - 보고서 클릭 시 파일 목록 모달 열기 + * - 파일명 클릭 시 대표 파일 바로 다운로드 + * - 탭 진입 시 기본 조건 자동 조회 지원 * - Zustand를 통한 모달 상태 및 데이터 관리 * - 로딩 오버레이 및 선택 초기화 기능 */ 'use client'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Button, Label } from '@common/components'; import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox'; import { SelectBox, UiTable } from '@common/business_components/ui'; @@ -22,19 +23,17 @@ import useStore from '@/store/useStore'; import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox'; import useSearchModalStore from '@/store/useSearchModalStore'; import { useReportTabStore } from '@/store/useReportTabStore'; -import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; - // 하수도정비 기본계획 보고서 export const reportTableHeadlist = [ - { id: "no", title: "NO" }, - { id: "sido", title: "시도" }, - { id: "sigungu", title: "시군구" }, - { id: "approvalYear", title: "승인년도" }, - { id: "plantZoneGubun", title: "구분" }, - { id: "reportGubun", title: "보고서구분" }, - { id: "fileName", title: "파일명" }, - { id: "regDate", title: "등록일자" }, + { id: 'no', title: 'NO' }, + { id: 'sido', title: '시도' }, + { id: 'sigungu', title: '시군구' }, + { id: 'approvalYear', title: '승인년도' }, + { id: 'plantZoneGubun', title: '구분' }, + { id: 'reportGubun', title: '보고서구분' }, + { id: 'fileName', title: '파일명' }, + { id: 'regDate', title: '등록일자' }, ]; interface ReportTabProps { open?: boolean; @@ -43,19 +42,26 @@ interface ReportTabProps { sggNm?: string; } -const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sggNm }) => { +const normalizeYear = (value: string) => { + if (!value || value === '0' || value === '9999') return ''; + return value; +}; - // const [isLoading, setIsLoading] = useState(false); +const getApprovalYearNumber = (value: string | number | undefined) => { + const normalized = String(value ?? '').replace('년', '').trim(); + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : null; +}; + +const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sggNm }) => { const [isResetPage, setIsResetPage] = useState(false); const [isResetSelection, setIsResetSelection] = useState(false); const [showTable, setShowTable] = useState(false); - // const [tableData, setTableData] = useState([]); - // const [tableCount, setTableCount] = useState(0); - // const [curPage, setCurPage] = useState(1); - // const [yearFilter, setYearFilter] = useState({ start: "", end: "" }); const [ctpvCd, setCtpvCd] = useState('0'); const [sggCd, setSggCd] = useState('0'); - const [reportGubun, setReportGubun] = useState("0"); + const [plantZoneGubun, setPlantZoneGubun] = useState('0'); + const [reportGubun, setReportGubun] = useState('0'); + const hasAutoFetchedRef = useRef(false); const { tableData, @@ -73,26 +79,43 @@ const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sg const { dcTree: reportOriginData, fetchDcTree - } = useReportTabStore(); // 모달 상태 관리 스토어 - const { REPORT_TYPES, flattenFolders, addFileName } = useReportTab(); + } = useReportTabStore(); + const { REPORT_TYPES, flattenFolders, addFileName, downloadReportFile } = useReportTab(); + + const flattenedReportData = useMemo(() => flattenFolders(reportOriginData), [reportOriginData]); + const plantZoneOptions = useMemo(() => { + const uniqueOptions = Array.from( + new Set( + flattenedReportData + .map((item) => String(item.plantZoneGubun ?? '').trim()) + .filter((item) => item.length > 0) + ) + ).sort((a, b) => a.localeCompare(b, 'ko')); + + return [ + { val: '0', text: '전체' }, + ...uniqueOptions.map((item) => ({ val: item, text: item })), + ]; + }, [flattenedReportData]); useEffect(() => { - fetchDcTree("기본계획"); // 하수정비기본계획 report - }, []); + void fetchDcTree('기본계획'); + }, [fetchDcTree]); useEffect(() => { - // filterParams 초기화 + hasAutoFetchedRef.current = false; setCtpvCd('0'); setSggCd('0'); + setPlantZoneGubun('0'); setReportGubun('0'); - setYearFilter({ start: "", end: "" }); + setYearFilter({ start: '', end: '' }); setShowTable(false); setTableData([]); setTableCount(0); - setCurPage(1); // 페이지를 1로 초기화 - setIsResetPage(true); // 페이지를 초기화 - setIsResetSelection(true); // 선택 초기화 - }, [open]); + setCurPage(1); + setIsResetPage(true); + setIsResetSelection(true); + }, [open, setCurPage, setTableCount, setTableData, setYearFilter]); useEffect(() => { const newCtpvCd = ctpv.find((n) => n.text === ctpvNm)?.val || '0'; @@ -100,174 +123,189 @@ const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sg setCtpvCd(newCtpvCd); setSggCd(newSggCd); - }, [ctpvNm, sggNm]); + }, [ctpv, ctpvNm, sgg, sggNm]); - // ctpvCd, sggCd가 업데이트된 후에 fetchFilteredData 실행 useEffect(() => { - if (!isSearch && ctpvCd && sggCd) { - fetchFilteredData(); - } - }, [reportOriginData, ctpvCd, sggCd, isSearch]); + if (!open || flattenedReportData.length === 0 || hasAutoFetchedRef.current) return; + + hasAutoFetchedRef.current = true; + void fetchFilteredData(1); + }, [flattenedReportData.length, open]); - // 페이지 변경 시마다 데이터 fetch useEffect(() => { - // showTable이 true일 때만 fetch (최초 렌더 방지) setIsResetPage(false); - if (showTable) fetchFilteredData(); + if (showTable) { + void fetchFilteredData(); + } }, [curPage]); const handleCtpvCdChange = (sidoId: string) => { setCtpvCd(sidoId); setSggCd('0'); - } + }; - // 조회 버튼 클릭 시 1페이지로 이동 및 데이터 초기화 const handleSearch = () => { - setCurPage(1); // 페이지를 1로 초기화 - fetchFilteredData(); - setIsResetPage(true); // 페이지를 초기화 - setIsResetSelection(false); // 선택 초기화 + hasAutoFetchedRef.current = true; + setCurPage(1); + void fetchFilteredData(1); + setIsResetPage(true); + setIsResetSelection(false); setShowTable(true); }; - // 하수도정비기본계획 테이블 cellClick 핸들러 - const handleReportCellClick = (index: number | undefined) => { - if (typeof index === "number" && tableData[index]) { - const reportData = tableData[index]; - if (reportData.fileSn !== "") { - const popupWindow = openWindowPopup("reportFiles", { - clsfSystmSn: reportData.clsfSystmSn, - sourcePath: window.location.pathname, - }); + const handleReportCellClick = (index: number | undefined, headId: string) => { + if (headId !== 'fileName') return; + if (typeof index !== 'number' || !tableData[index]) return; - if (!popupWindow) { - alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.'); - } + const reportData = tableData[index]; + if (!reportData.fileSn) return; + + void (async () => { + setIsLoading(true); + try { + await downloadReportFile(reportData); + } finally { + setIsLoading(false); } - } + })(); }; - // 검색 API 호출 함수 - const fetchFilteredData = async () => { - setIsLoading(true); // 로딩 시작 - setIsResetSelection(true); // 선택 초기화 + const fetchFilteredData = async (page = curPage) => { + setIsLoading(true); + setIsResetSelection(true); try { - const reportData = flattenFolders(reportOriginData); - let filterReportData = reportData; + let filterReportData = flattenedReportData; - if (ctpvCd !== '0') { // 시도검색 - const sidoOption = ctpv.find((n) => n.val.trim() === ctpvCd.trim()); - filterReportData = filterReportData.filter(item => item.sido === sidoOption?.text); + if (ctpvCd !== '0') { + const sidoOption = ctpv.find((n) => String(n.val).trim() === ctpvCd.trim()); + filterReportData = filterReportData.filter((item) => item.sido === sidoOption?.text); } - if (sggCd !== '0') { // 시군구검색 + if (sggCd !== '0') { const sigunguOption = sgg.find((n) => String(n.val) === String(sggCd)); - filterReportData = filterReportData.filter(item => item.sigungu === sigunguOption?.text); + filterReportData = filterReportData.filter((item) => item.sigungu === sigunguOption?.text); } - if (reportGubun !== "0") { // 보고서구분검색 + if (plantZoneGubun !== '0') { + filterReportData = filterReportData.filter((item) => String(item.plantZoneGubun ?? '') === plantZoneGubun); + } + if (reportGubun !== '0') { const reportGubunOption = REPORT_TYPES.find((n) => n.val === reportGubun); - filterReportData = filterReportData.filter(item => item.reportGubun === reportGubunOption?.text); + filterReportData = filterReportData.filter((item) => item.reportGubun === reportGubunOption?.text); } - const { start, end } = yearFilter; - if (start && !end) //승인년도검색 - filterReportData = filterReportData.filter(item => (item.approvalYear) ? item.approvalYear >= yearFilter.start + "년" : null); - else if (!start && end) - filterReportData = filterReportData.filter(item => (item.approvalYear) ? item.approvalYear <= yearFilter.end + "년" : null); - else if (start && end) { - filterReportData = filterReportData.filter(item => { - return (item.approvalYear) ? item.approvalYear >= yearFilter.start + "년" && item.approvalYear <= yearFilter.end + "년" : null; + const normalizedStartYear = normalizeYear(yearFilter.start); + const normalizedEndYear = normalizeYear(yearFilter.end); + if (normalizedStartYear || normalizedEndYear) { + filterReportData = filterReportData.filter((item) => { + const approvalYear = getApprovalYearNumber(item.approvalYear); + if (approvalYear === null) return false; + if (normalizedStartYear && approvalYear < Number(normalizedStartYear)) return false; + if (normalizedEndYear && approvalYear > Number(normalizedEndYear)) return false; + return true; }); } - // if (gubun !== "") { // 구분검색 (추후 사용할 수도 있을 것 같아 남겨둠) - // filterReportData = filterReportData.filter(item => (item.plantZoneGubun) ? String(item.plantZoneGubun).includes(String(gubun)) : null); - // } setTableCount(filterReportData.length || 0); - filterReportData = filterReportData.slice((curPage - 1) * 10, curPage * 10); // paging - filterReportData = filterReportData?.map((item, index) => ({ ...item, no: (curPage - 1) * 10 + index + 1 })); + const pagedReportData = filterReportData + .slice((page - 1) * 10, page * 10) + .map((item, index) => ({ ...item, no: (page - 1) * 10 + index + 1 })); - addFileName(filterReportData).then(data => { - filterReportData = data; - setTableData(filterReportData || []); - setIsResetSelection(false); // 데이터 설정 후 선택 초기화 해제 - }); - - setTableData(filterReportData || []); + const reportRows = await addFileName(pagedReportData); + setTableData(reportRows || []); setShowTable(true); + setIsResetSelection(false); } catch (err) { console.error('API ERROR:', err); setTableData([]); - setIsResetSelection(false); // 에러 시에도 선택 초기화 해제 + setIsResetSelection(false); } finally { - setIsLoading(false); // 로딩 종료 + setIsLoading(false); } }; - return ( <> -
- {isSearch && ( - <> -
+
+
+
+ {isSearch && ( { handleCtpvCdChange(sidoId) }} + onSidoChange={(sidoId: string) => { handleCtpvCdChange(sidoId); }} onSigunguChange={setSggCd} setIsLoading={setIsLoading} /> + )} + setYearFilter({ + start: normalizeYear(range.start), + end: normalizeYear(range.end), + })} + startLabel="승인년도" + endLabel="" + /> + + setPlantZoneGubun(String(val))} + selectContentClass="z-[101]" + /> + + setReportGubun(String(val))} + selectContentClass="z-[101]" + /> +
+
- - )} -
- - setReportGubun(String(val))} - selectContentClass="z-[101]" labelClass='whitespace-nowrap text-xl text-gray-600 pr-2' - /> - {/* 승인년도 필터 */} - setYearFilter({ ...yearFilter, start: range.start, end: range.end })} - startLabel="승인년도" endLabel="" - /> - {/* 조회 버튼 */} -
-
-
- {/* 테이블2 영역 (가로/세로 스크롤 가능) */} -
- - { - return (<>조회 결과); - })() - } - tableData={tableData} - headlist={reportTableHeadlist} - pageSize={10} - total={tableCount} - cellClick={handleReportCellClick} - onPageChange={setCurPage} - resetPageNo={isResetPage} - resetSelection={isResetSelection} - /> -
+
+ + 조회 결과} + tableData={tableData} + headlist={reportTableHeadlist} + pageSize={10} + total={tableCount} + cellClick={handleReportCellClick} + cellClassName={(row, headId) => { + if (headId !== 'fileName') return ''; + if (!row.fileSn) return '!text-gray-400'; + return '!text-left !text-blue-600 underline underline-offset-4 hover:!text-blue-800'; + }} + onPageChange={setCurPage} + resetPageNo={isResetPage} + resetSelection={isResetSelection} + />
); }; export default React.memo(ReportTab); + + + + diff --git a/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx b/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx index 797969427..a4cd3caca 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx +++ b/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx @@ -1,4 +1,4 @@ -/** +/** * ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 * * 📄 Search Modal @@ -16,22 +16,23 @@ import LoadingOverlay from '@/components/ui/LoadingBar'; import { UITab, UiTable } from '@common/business_components/ui'; import { Button } from '@common/components/ui'; import React, { useEffect, useMemo, useRef } from 'react'; -import { Toaster } from "react-hot-toast"; +import { Toaster } from 'react-hot-toast'; import useSearchModalStore from '@/store/useSearchModalStore'; import { ExclnSewagePumpCol2, MsSwageMhlCol2, MsSwagePplCol2, stofcbrCol2, SwtrmtFcltCol2, SwtrmtPrcsZoneCol2 } from '../components/common/table'; import BasicTab, { BASIC_TYPES } from './basicTab'; import OperationTab from './operationTab'; +import ReportTab from './reportTab'; import useSearchModal from './useSearchModal'; import useBasicTab from './useBasicTab'; import useOperationTab from './useOperationTab'; import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; type SearchModalProps = { - renderMode?: "launcher" | "popup"; + renderMode?: 'launcher' | 'popup'; autoSearchReady?: boolean; }; -const SearchModal: React.FC = ({ renderMode = "launcher", autoSearchReady = false }) => { +const SearchModal: React.FC = ({ renderMode = 'launcher', autoSearchReady = false }) => { const { showTable, isSearchModalOpen, setIsSearchModalOpen, @@ -65,40 +66,38 @@ const SearchModal: React.FC = ({ renderMode = "launcher", auto operationResultExcelDownload, handleOperationPageNation } = useOperationTab(); - const hasAutoOpenedReportRef = useRef(false); const hasAutoSearchedRef = useRef(false); - + const hasInitializedTabAutoSearchRef = useRef(false); const dynamicHeadlist: { id: string, title: string }[] = useMemo(() => { - if (activeTab === 'basic') { // 기본정보 - if (basicType === 'tb_ms_swage_ppl') return MsSwagePplCol2;// 하수관로 - if (basicType === 'tb_il_swage_prcs_fclt') return SwtrmtFcltCol2;// 하수처리시설 - if (basicType === 'tb_ms_swage_prcs_zone') return SwtrmtPrcsZoneCol2; // 하수처리구역 - if (basicType === 'tb_ms_swage_mhl') return MsSwageMhlCol2; // 하수맨홀 - if (basicType === 'tb_ms_swage_pmstn') return ExclnSewagePumpCol2; // 하수펌프장 - if (basicType === 'tb_ms_stofcbr') return stofcbrCol2; // 우수토실 - + if (activeTab === 'basic') { + if (basicType === 'tb_ms_swage_ppl') return MsSwagePplCol2; + if (basicType === 'tb_il_swage_prcs_fclt') return SwtrmtFcltCol2; + if (basicType === 'tb_ms_swage_prcs_zone') return SwtrmtPrcsZoneCol2; + if (basicType === 'tb_ms_swage_mhl') return MsSwageMhlCol2; + if (basicType === 'tb_ms_swage_pmstn') return ExclnSewagePumpCol2; + if (basicType === 'tb_ms_stofcbr') return stofcbrCol2; return []; } - if (activeTab === 'operation') return OPERATION_TABLE_HEADLIST; // 유지관리 정보 + if (activeTab === 'operation') return OPERATION_TABLE_HEADLIST; return []; - }, [activeTab, basicType, workType]); + }, [activeTab, basicType, workType, OPERATION_TABLE_HEADLIST]); const dynamicHeadlist2: { id: string, title: string }[] = useMemo(() => { if (activeTab === 'operation') { - if (workType === WORK_TYPES[0]?.val) return MAINTENANACE_TABLE_HEADLIST; // 보수 및 갱신 - if (workType === WORK_TYPES[1]?.val) return CLEANING_TABLE_HEADLIST; // 점검 및 진단 - if (workType === WORK_TYPES[2]?.val) return INSPECTION_TABLE_HEADLIST; // 청소 및 준설 + if (workType === WORK_TYPES[0]?.val) return MAINTENANACE_TABLE_HEADLIST; + if (workType === WORK_TYPES[1]?.val) return CLEANING_TABLE_HEADLIST; + if (workType === WORK_TYPES[2]?.val) return INSPECTION_TABLE_HEADLIST; } return []; - }, [activeTab, workType]); + }, [activeTab, workType, WORK_TYPES, MAINTENANACE_TABLE_HEADLIST, CLEANING_TABLE_HEADLIST, INSPECTION_TABLE_HEADLIST]); useEffect(() => { - if (renderMode !== "launcher" || !isSearchModalOpen) return; + if (renderMode !== 'launcher' || !isSearchModalOpen) return; - const popupWindow = openWindowPopup("mapSearch", { + const popupWindow = openWindowPopup('mapSearch', { sourcePath: window.location.pathname, }); @@ -107,59 +106,49 @@ const SearchModal: React.FC = ({ renderMode = "launcher", auto } setIsSearchModalOpen(false); - }, [renderMode, isSearchModalOpen]); - - const openReportTabPopup = () => { - const popupWindow = openWindowPopup("reportTab", { - sourcePath: window.location.pathname, - }); - - if (!popupWindow) { - alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요."); - } - }; - - useEffect(() => { - if (activeTab !== 'report') { - hasAutoOpenedReportRef.current = false; - return; - } - - if (hasAutoOpenedReportRef.current) return; - - openReportTabPopup(); - hasAutoOpenedReportRef.current = true; - }, [activeTab]); + }, [isSearchModalOpen, renderMode, setIsSearchModalOpen]); useEffect(() => { resetFilters(); - }, [activeTab, basicType, workType]); + }, [activeTab, basicType, workType, resetFilters]); useEffect(() => { - if (renderMode !== "popup" || !autoSearchReady || hasAutoSearchedRef.current) return; + if (renderMode !== 'popup' || !autoSearchReady || hasAutoSearchedRef.current) return; hasAutoSearchedRef.current = true; + hasInitializedTabAutoSearchRef.current = true; void fetchFilteredData(); }, [autoSearchReady, fetchFilteredData, renderMode]); - // 페이지 변경 시마다 데이터 fetch + useEffect(() => { + if (renderMode !== 'popup' || !autoSearchReady) return; + if (!hasInitializedTabAutoSearchRef.current) return; + if (activeTab === 'report') return; + + setCurPage(1); + setIsResetPage(true); + setIsResetDetailPage(true); + void fetchFilteredData(); + }, [activeTab, basicType, workType]); + useEffect(() => { setIsResetPage(false); setIsResetDetailPage(true); setDetailBizId(0); - if (showTable) fetchFilteredData(); + if (showTable) { + void fetchFilteredData(); + } }, [curPage]); - // 페이지 변경 시마다 데이터 fetch useEffect(() => { setIsResetDetailSelection(false); - if (showTable) handleOperationPageNation(Number(detailBizId)); + if (showTable) { + void handleOperationPageNation(Number(detailBizId)); + } }, [detailCurPage]); - const renderBody = () => ( <> - {/* 탭 영역 */} ({ @@ -168,69 +157,47 @@ const SearchModal: React.FC = ({ renderMode = "launcher", auto }))} onValueChange={(value: string) => setActiveTab(value)} /> - {activeTab === 'basic' && ()} {/* 기본정보 탭 */} - {activeTab === 'operation' && ()} {/* 유지관리 정보 탭 */} - {activeTab === 'report' && ( -
-
-

- 하수도정비 기본계획보고서 -

-

- 보고서 상세는 모달 내부 탭 대신 -
- 전용 윈도우 팝업으로 열리도록 전환되었습니다. -

- -
-
- )} {/* 하수도정비기본계획보고서 탭 */} + {activeTab === 'basic' && ()} + {activeTab === 'operation' && ()} + {activeTab === 'report' && ()} - - {/* 📋 결과 리스트 */} - {/* 기본정보 탭 */} {activeTab === 'basic' && (
- { - const label = BASIC_TYPES.find((type) => type.val === basicType)?.text - return (<>{label} 조회 결과); - })() - } - tableData={tableData} - headlist={dynamicHeadlist} - pageSize={10} - total={tableCount} - cellClick={handleBasicCellClick} - onPageChange={setCurPage} - resetPageNo={isResetPage} - resetSelection={isResetSelection} - curPage={curPage} - headButton={ -
- - -
- } - /> + { + const label = BASIC_TYPES.find((type) => type.val === basicType)?.text; + return (<>{label} 조회 결과); + })()} + tableData={tableData} + headlist={dynamicHeadlist} + pageSize={10} + total={tableCount} + cellClick={handleBasicCellClick} + onPageChange={setCurPage} + resetPageNo={isResetPage} + resetSelection={isResetSelection} + curPage={curPage} + headButton={ +
+ + +
+ } + />
)} - {/* 유지관리 정보 탭 */} + {activeTab === 'operation' && ( <> -
- {/*
*/} +
{ - const label = WORK_TYPES.find((type) => type.val === workType)?.text - return (<>{label} 사업 현황); - })() - } + headName={(() => { + const label = WORK_TYPES.find((type) => type.val === workType)?.text; + return (<>{label} 사업 현황); + })()} tableData={tableData} headlist={dynamicHeadlist} pageSize={OPERATION_TABLE_SIZE} @@ -244,20 +211,15 @@ const SearchModal: React.FC = ({ renderMode = "launcher", auto } /> - {/*
*/}
- {/* 테이블2 영역 */} - {/*
*/} { - const label = WORK_TYPES.find((type) => type.val === workType)?.text - return (<>{label} 결과표); - })() - } + headName={(() => { + const label = WORK_TYPES.find((type) => type.val === workType)?.text; + return (<>{label} 결과표); + })()} tableData={detailTableData} headlist={dynamicHeadlist2} pageSize={OPERATION_TABLE_SIZE} @@ -274,14 +236,13 @@ const SearchModal: React.FC = ({ renderMode = "launcher", auto
} /> - {/*
*/}
)} ); - if (renderMode === "launcher") { + if (renderMode === 'launcher') { return null; } @@ -291,9 +252,10 @@ const SearchModal: React.FC = ({ renderMode = "launcher", auto
{renderBody()}
- +
); }; export default React.memo(SearchModal); + diff --git a/apps/sn/app/(map-layout)/map/searchModal/useReportTab.ts b/apps/sn/app/(map-layout)/map/searchModal/useReportTab.ts index 8ffe06de8..4a2bd5a5b 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useReportTab.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useReportTab.ts @@ -1,4 +1,4 @@ -/** +/** * 📄 useReportTab Hook * 검색 모달 보고서 탭 훅 * @@ -40,6 +40,15 @@ export type ReportDataFile = { filePath: string; } +const getStringValue = (value: string | number | undefined) => String(value ?? ''); + +const buildDisplayFileName = (files: ReportDataFile[]) => { + if (files.length === 0) return '-'; + if (files.length === 1) return files[0]?.fileTitle ?? '-'; + + return `${files[0]?.fileTitle ?? '대표 파일'} 외 ${files.length - 1}건`; +}; + export default function useReportTab() { const REPORT_TYPES = [ { val: '0', text: '전체' }, @@ -59,11 +68,11 @@ export default function useReportTab() { */ const flattenFolders = (data: Folder[]): FlattenedFolder[] => { const result: FlattenedFolder[] = []; - const exceptNodeNm = ["부분", "통합", "전체"]; - const addNodeNm = ["보고서", "요약보고서", "부록", "기타"]; // level5 + const exceptNodeNm = ['부분', '통합', '전체']; + const addNodeNm = ['보고서', '요약보고서', '부록', '기타']; // level5 const columnNms: { [key: number]: string } = { - 0: "root", 1: "sido", 2: "sigungu", 3: "approvalYear" - , 4: "plantZoneGubun", 5: "reportGubun", 7: "fileName", 8: "regDate" + 0: 'root', 1: 'sido', 2: 'sigungu', 3: 'approvalYear', + 4: 'plantZoneGubun', 5: 'reportGubun', 7: 'fileName', 8: 'regDate' }; const traverse = ( @@ -92,28 +101,26 @@ export default function useReportTab() { newPath.clsfSystmSn = node.clsfSystmSn; // 파일 조회를 위한 고유번호 result.push(newPath); } else { - if (level === 4) { - if (exceptNodeNm.includes(node.clsfSystmNm)) { - level--; // 제외할 노드 이름인 경우, 현재의 레벨에 하위값이 써지도록 함. - } + if (level === 4 && exceptNodeNm.includes(node.clsfSystmNm)) { + level--; // 제외할 노드 이름인 경우, 현재의 레벨에 하위값이 써지도록 함. } node.children.forEach((child) => { traverse(child, newPath, level + 1); }); } - } + }; - if (data && data.length > 0 && data[0]?.clsfSystmNm == "기본계획") { // data가 비어있지 않고 children이 있는 경우에만 처리 + if (data && data.length > 0 && data[0]?.clsfSystmNm == '기본계획') { data.forEach((root) => traverse(root, {}, 0)); } else { data.forEach((root) => traverse(root, {}, 1)); } return result; - } + }; const getDcFiles = async (clsfSystmSn: string): Promise => { if (!clsfSystmSn) { - console.error("clsfSystmSn is required to get files."); + console.error('clsfSystmSn is required to get files.'); return []; } const queryString = 'clsfSystmSn=' + clsfSystmSn; @@ -126,29 +133,54 @@ export default function useReportTab() { const fileReportData = await Promise.all( data.map(async (item) => { const fileArr: ReportDataFile[] = await getDcFiles(String(item.clsfSystmSn)); - let result: FlattenedFolder; - if (fileArr.length > 0) { - const f = fileArr[0]; - const fCount = fileArr.length; - result = { ...item, fileName: f?.fileTitle + " 외 " + fCount + "건", regDate: f?.regDt, fileSn: f?.fileSn, filePath: f?.filePath }; - } else { - result = { ...item, fileName: "", regDate: "", fileSn: "", filePath: "" }; + if (fileArr.length === 0) { + return { + ...item, + fileName: '-', + regDate: '', + fileSn: '', + filePath: '', + fileCount: 0, + downloadFileTitle: '', + }; } - return result; + + const primaryFile = fileArr[0]; + return { + ...item, + fileName: buildDisplayFileName(fileArr), + regDate: primaryFile?.regDt ?? '', + fileSn: primaryFile?.fileSn ?? '', + filePath: primaryFile?.filePath ?? '', + fileCount: fileArr.length, + downloadFileTitle: primaryFile?.fileTitle ?? '', + }; }) ); return fileReportData; }; const getDcFileDownload = async (fileSn: string, fileName: string, filePath: string) => { - if (filePath === "") { + if (filePath === '') { alert(`filePath 정보가 없으므로 다운로드가 불가합니다.\nfilesn: ${fileSn}`); return; } const queryString = 'filePath=' + encodeURIComponent(filePath); - const responseBlob = await getDcData(`/api/v1/fileDownload${queryString ? `?${queryString}` : ''}`, "blob"); + const responseBlob = await getDcData(`/api/v1/fileDownload${queryString ? `?${queryString}` : ''}`, 'blob'); fileDownload(responseBlob, fileName); - } + }; + + const downloadReportFile = async (item: FlattenedFolder) => { + const fileSn = getStringValue(item.fileSn); + const filePath = getStringValue(item.filePath); + const downloadFileTitle = getStringValue(item.downloadFileTitle || item.fileName); + + if (!fileSn || !filePath || !downloadFileTitle || downloadFileTitle === '-') { + return; + } + + await getDcFileDownload(fileSn, downloadFileTitle, filePath); + }; // 파일 목록 조회 const getFiles = async () => { @@ -159,7 +191,7 @@ export default function useReportTab() { data = data?.map((item, index) => ({ ...item, no: (curPage - 1) * 10 + index + 1 })); setFiles(data); } catch (error) { - console.error("파일 목록 조회 에러:", error); + console.error('파일 목록 조회 에러:', error); } finally { setIsLoading(false); // 로딩 종료 } @@ -167,10 +199,10 @@ export default function useReportTab() { // cellClick 핸들러 const handleCellClick = (index: number | undefined) => { - if (typeof index === "number" && files[index]) { + if (typeof index === 'number' && files[index]) { const fileName = files[index].fileTitle; const filePath = files[index].filePath; - if (files[index].fileSn !== "") { + if (files[index].fileSn !== '') { getDcFileDownload(files[index].fileSn, fileName, filePath); } } @@ -182,7 +214,8 @@ export default function useReportTab() { addFileName, getDcFiles, getDcFileDownload, + downloadReportFile, getFiles, handleCellClick }; -} +} diff --git a/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts b/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts index 585885521..578c3ac2a 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts @@ -1,4 +1,4 @@ -/** +/** * 📄 useSearchModal Hook * 검색 모달 훅 * @@ -12,10 +12,10 @@ * 🔗 사용 API * - 유지관리 정보 목록 조회: GET /api/v1/bizInfo (sewerage-service) */ -import { getData } from "@/components/lib/api"; +import { getData } from '@/components/lib/api'; import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue'; import useStore from '../../../store/useStore'; -import GisApp from "@common/business_components/ui/map/lib/gis.core"; +import GisApp from '@common/business_components/ui/map/lib/gis.core'; import useSearchModalStore from '../../../store/useSearchModalStore'; import useBasicTab from './useBasicTab'; import useOperationTab from './useOperationTab'; @@ -23,61 +23,78 @@ import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessag import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; const isPopupWindow = () => - typeof window !== "undefined" && + typeof window !== 'undefined' && !!window.opener && - window.location.pathname.startsWith("/popup/"); + window.location.pathname.startsWith('/popup/'); export default function useSearchModal() { const { - activeTab, workType, - tableData, detailTableData, - curPage, - var1, var2, - yearFilter, - todayYear, todayMonth, todayDate, + activeTab, + tableData, + detailTableData, setIsSearchModalOpen, setShowTable, - setTableData, setDetailTableData, - setTableCount, setDetailTableCount, + setTableData, setCurPage, setIsLoading, - setIsResetPage, setIsResetDetailPage, - setIsResetSelection, setIsResetDetailSelection, - getDateString + setIsResetPage, + setIsResetDetailPage, + setIsResetSelection, + setIsResetDetailSelection, + getDateString, } = useSearchModalStore(); const { fetchBasicFilteredData } = useBasicTab(); const { OPERATION_TABLE_SIZE } = useOperationTab(); const { BIZ_TY_CD } = useStore(); const TABS = [ - { val: "basic", text: "기본 정보" }, - { val: "operation", text: "유지관리 정보" }, - { val: "report", text: "하수도 정비 기본계획 보고서" }, + { val: 'basic', text: '기본 정보' }, + { val: 'operation', text: '유지관리 정보' }, + { val: 'report', text: '하수도 정비 기본계획 보고서' }, ]; // 검색 API 호출 함수 const fetchFilteredData = async () => { - setIsLoading(true); // 로딩 시작 - setIsResetSelection(true); // 선택 초기화 - setIsResetDetailSelection(true); // 선택 초기화 + const { + activeTab: currentTab, + curPage, + var1, + var2, + yearFilter, + workType, + todayYear, + todayMonth, + todayDate, + setTableData, + setDetailTableData, + setTableCount, + setDetailTableCount, + setShowTable, + setIsLoading, + setIsResetSelection, + setIsResetDetailSelection, + } = useSearchModalStore.getState(); + + setIsLoading(true); + setIsResetSelection(true); + setIsResetDetailSelection(true); try { - if (activeTab === 'basic') { - fetchBasicFilteredData(); - - } else if (activeTab === 'operation') { - const RequestDTO: Record = { + if (currentTab === 'basic') { + await fetchBasicFilteredData(); + } else if (currentTab === 'operation') { + const requestDto: Record = { page: `${curPage - 1}`, size: `${OPERATION_TABLE_SIZE}`, }; - if (var1) RequestDTO.bizInfoNm = var1; - if (var2 != '0' && var2) RequestDTO.bizTypeCd = var2; - // 수행기간 범위 + if (var1) requestDto.bizInfoNm = var1; + if (var2 !== '0' && var2) requestDto.bizTypeCd = var2; + const startDate = getDateString( yearFilter.start, '', '', - "19000101", + '19000101', ); const endDate = getDateString( yearFilter.end, @@ -85,22 +102,19 @@ export default function useSearchModal() { '', `${todayYear}${todayMonth}${todayDate}`, ); - RequestDTO.fromflfmtEndYmd = startDate; - RequestDTO.toflfmtEndYmd = endDate; - - // JSON.parse 사용으로 문자열 -> 배열 변환 - RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"')); - - const params = new URLSearchParams(RequestDTO).toString(); + requestDto.fromflfmtEndYmd = startDate; + requestDto.toflfmtEndYmd = endDate; + requestDto.workTypes = JSON.parse(workType.replace(/'/g, '"')); + const params = new URLSearchParams(requestDto).toString(); const res = await getData(`v1/bizInfo?${params}`); const mappedContent = (res.content || []).map((item: any, index: number) => { const formattedItem = formatDisplayData(item); - const matched = BIZ_TY_CD.find(code => code.lwpoCd === item.bizTypeCd); + const matched = BIZ_TY_CD.find((code) => code.lwpoCd === item.bizTypeCd); return { ...formattedItem, - rowNumber: (curPage - 1) * OPERATION_TABLE_SIZE + index + 1, // 1부터 시작하는 일련번호 추가 + rowNumber: (curPage - 1) * OPERATION_TABLE_SIZE + index + 1, bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd, }; }); @@ -118,20 +132,19 @@ export default function useSearchModal() { setTableData([]); setIsResetSelection(false); } finally { - setIsLoading(false); // 로딩 종료 + setIsLoading(false); setIsResetSelection(false); - // setIsResetDetailSelection(false); } }; // 조회 버튼 클릭 시 1페이지로 이동 및 데이터 초기화 const handleSearch = () => { - setCurPage(1); // 페이지를 1로 초기화 - fetchFilteredData(); - setIsResetPage(true); // 페이지를 초기화 - setIsResetDetailPage(true); // 디테일 페이지를 초기화 - setIsResetSelection(false); // 선택 초기화 - setIsResetDetailSelection(false); // 선택 초기화 + setCurPage(1); + void fetchFilteredData(); + setIsResetPage(true); + setIsResetDetailPage(true); + setIsResetSelection(false); + setIsResetDetailSelection(false); setShowTable(true); }; @@ -186,7 +199,7 @@ export default function useSearchModal() { setIsSearchModalOpen(false); } } - } + }; return { TABS, getDateString, handleSearch, viewMultipleFeature, fetchFilteredData }; -}; +} diff --git a/apps/sn/docs/작업검증/SN-정보검색-하수도정비기본계획보고서-유지보수/001-qa-정보검색-하수도정비기본계획보고서-통합검증계획.md b/apps/sn/docs/작업검증/SN-정보검색-하수도정비기본계획보고서-유지보수/001-qa-정보검색-하수도정비기본계획보고서-통합검증계획.md new file mode 100644 index 000000000..a66ce8e0e --- /dev/null +++ b/apps/sn/docs/작업검증/SN-정보검색-하수도정비기본계획보고서-유지보수/001-qa-정보검색-하수도정비기본계획보고서-통합검증계획.md @@ -0,0 +1,56 @@ +# 정보검색-하수도정비기본계획보고서 통합검증계획 + +## 1. 문서 목적 + +- 분석도구 `정보검색` 내 `하수도 정비 기본계획 보고서` 유지보수 검증 계획을 기록한다. + +## 2. 검증 범위 + +- report 탭 추가 팝업 제거 여부 +- 조회조건 순서 및 필터 동작 +- 탭 전환 시 자동조회 동작 +- 파일명 셀 직접 다운로드 동작 +- 보고서 행 클릭 제한 동작 +- 공통 select 포커스 표시 잘림 여부 +- 기존 `dc-service` 호출 유지 여부 + +## 3. 검증 기준 + +1. `정보검색` 팝업에서 `report` 탭 선택 시 추가 윈도우 팝업이 열리지 않아야 한다. +2. 조회조건이 `시도 > 시군구 > 승인년도 > 구분 > 보고서 구분` 순서로 보여야 한다. +3. 조회조건이 한 줄 우선 정렬로 보이고 불필요한 하단 공백이 없어야 한다. +4. `기본 정보`, `유지관리 정보`, `하수도 정비 기본계획 보고서` 탭 전환 시 각 탭의 기본조회가 자동 실행되어야 한다. +5. 파일이 있는 행의 `파일명`만 링크 스타일을 가져야 한다. +6. 파일명 클릭 시 별도 파일목록 팝업 없이 다운로드가 시작되어야 한다. +7. 파일명 외 다른 셀 클릭 시 추가 동작이 없어야 한다. +8. `시도`, `시군구`, `승인년도`, `구분`, `보고서 구분` select 포커스 표시가 잘리지 않아야 한다. +9. Network에서 `dc-service`의 `clsf`, `file`, `fileDownload` 호출이 유지되어야 한다. + +## 4. 라운드별 검증 계획 + +### 4.1 정적 검증 + +- 수정 파일 import/상태 흐름 재검토 +- 기존 popup 호출 제거 여부 검색 +- `UiTable` 옵션 추가가 다른 화면 기본동작을 깨지 않는지 코드 기준 확인 + +### 4.2 명령 검증 + +- `pnpm --filter sn lint` + - 목적: 타입/구문/기본 lint 문제 확인 + +### 4.3 수동 브라우저 검증 + +- 분석도구 `정보검색` 클릭 +- `기본 정보`, `유지관리 정보`, `하수도 정비 기본계획 보고서` 순서로 탭 전환 +- 각 탭 진입 시 버튼 없이 기본조회 자동 실행 확인 +- 보고서 탭 조건 변경 후 조회 +- 파일명 클릭 다운로드 확인 +- 파일명 외 셀 클릭 무반응 확인 +- 기본정보/유지관리/보고서 탭의 select 포커스 잘림 여부 확인 +- 하수처리시설 상세의 `조회` 버튼으로 여는 `reportTab` 팝업 회귀 확인 + +## 5. 차단 사유 분류 기준 + +- 코드 문제: 타입 오류, 런타임 오류, 잘못된 자동조회/필터/다운로드 동작 +- 환경 문제: pnpm 설치 정책, lockfile 설정, TTY 제약, 개발 서버 충돌 diff --git a/apps/sn/docs/작업검증/SN-정보검색-하수도정비기본계획보고서-유지보수/002-qa-정보검색-하수도정비기본계획보고서-통합검증결과.md b/apps/sn/docs/작업검증/SN-정보검색-하수도정비기본계획보고서-유지보수/002-qa-정보검색-하수도정비기본계획보고서-통합검증결과.md new file mode 100644 index 000000000..4debf96f4 --- /dev/null +++ b/apps/sn/docs/작업검증/SN-정보검색-하수도정비기본계획보고서-유지보수/002-qa-정보검색-하수도정비기본계획보고서-통합검증결과.md @@ -0,0 +1,37 @@ +# 정보검색-하수도정비기본계획보고서 통합검증결과 + +## 1. 문서 목적 + +- 분석도구 `정보검색` 내 `하수도 정비 기본계획 보고서` 유지보수 검증 수행 결과를 기록한다. + +## 2. 수행한 검증 + +### 2.1 정적 확인 + +- `report` 탭 자동 팝업 오픈 제거 반영 여부 확인 +- `reportFiles` 팝업 호출 제거 반영 여부 확인 +- 파일명 직접 다운로드 로직 연결 여부 확인 +- 탭 전환 시 자동조회 흐름 추가 여부 확인 +- `UiTable`의 `clickableHeadIds` 옵션이 미지정 화면에는 영향이 없는 구조인지 확인 +- 공통 `SelectBox` 포커스 보정이 style 변경 수준인지 확인 + +### 2.2 명령 실행 + +- 2026-06-30: `pnpm --filter sn lint` + - 1차 결과: 샌드박스 임시 파일 권한 오류로 실패 + - 2차 결과: `ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY`로 실패 + - 3차 결과: `CI=true` 재시도 후 `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`로 실패 + +## 3. 현재 검증 상태 + +- 코드 반영: 완료 +- 정적 구조 확인: 완료 +- 자동 lint 검증: 환경 문제로 미완료 +- 브라우저 수동 검증: 미실행 + +## 4. 보류/리스크 + +1. 현재 워크스페이스 pnpm 환경이 lockfile 설정 불일치로 자동 lint를 진행하지 못한다. +2. 브라우저 수동 검증이 남아 있어 탭별 자동조회 체감, select 포커스 표시, 여백 보정 체감은 아직 확인하지 못했다. +3. 다건 파일 대표 다운로드 기준은 현재 첫 번째 파일 기준이다. +4. `reportFiles` 팝업 관련 미사용 코드 정리는 후속 정리 대상으로 남긴다. diff --git a/apps/sn/docs/작업내용/SN-정보검색-하수도정비기본계획보고서-유지보수/001-fe-정보검색-하수도정비기본계획보고서-통합구현기록.md b/apps/sn/docs/작업내용/SN-정보검색-하수도정비기본계획보고서-유지보수/001-fe-정보검색-하수도정비기본계획보고서-통합구현기록.md new file mode 100644 index 000000000..a0a6ea34b --- /dev/null +++ b/apps/sn/docs/작업내용/SN-정보검색-하수도정비기본계획보고서-유지보수/001-fe-정보검색-하수도정비기본계획보고서-통합구현기록.md @@ -0,0 +1,82 @@ +# 정보검색-하수도정비기본계획보고서 통합구현기록 + +## 1. 문서 목적 + +- 분석도구 `정보검색` 내 `하수도 정비 기본계획 보고서` 유지보수 구현 내용을 기록한다. + +## 2. 입력 근거 + +- 공통 설계: `IIPBS/docs/작업계획/SN-정보검색-하수도정비기본계획보고서-유지보수/001-de-정보검색-하수도정비기본계획보고서-설계.md` +- 사용자 요구사항 + - report 탭 조회결과를 기존 영역에 표시 + - 파일명 클릭 즉시 다운로드 + - 조회조건 순서 변경 + - 조회조건 한 줄 정렬 및 여백 보정 + - 탭 전환 시 기본조회 + - select 포커스 잘림 보정 + +## 3. 공통 계약 + +- 기존 `dc-service` 프록시 계약 `clsf`, `file`, `fileDownload`를 그대로 사용한다. +- `sewerage-service` 신규 API와 DB 변경은 이번 범위에서 수행하지 않는다. +- 공통 컴포넌트 수정은 `sn` 요구사항을 위한 옵션 추가 또는 스타일 보정으로만 한정한다. + +## 4. 구현 라운드 요약 + +### 4.1 report 탭 인라인 복구 + +- `SearchModal`에서 `report` 탭 선택 시 전용 팝업을 자동으로 열던 흐름을 제거했다. +- 안내 카드 대신 `ReportTab`을 같은 본문 영역에 직접 렌더링하도록 바꿨다. + +### 4.2 파일명 직접 다운로드 전환 + +- `ReportTab`의 셀 클릭을 `파일명` 컬럼으로 한정했다. +- 파일이 있는 행의 `파일명` 셀에 파란 글씨, 밑줄, 포인터 스타일을 적용했다. +- 파일 클릭 시 `dc-service` 파일 다운로드를 바로 호출하도록 연결했다. +- 다건 파일은 현재 구조상 대표 파일 정의가 필요해서 첫 번째 파일을 대표 다운로드 대상으로 사용했다. +- 표시 문자열은 `1건이면 실제 파일명`, `2건 이상이면 첫 파일명 외 N건`으로 보정했다. + +### 4.3 조회조건 재배치 및 필터 보강 + +- 조회조건 표시 순서를 `시도 > 시군구 > 승인년도 > 구분 > 보고서 구분`으로 맞췄다. +- 기존에 조회 UI에 없던 `구분(plantZoneGubun)` 필터를 추가했다. +- 승인년도는 `0`, `9999` 같은 placeholder 값을 실제 필터에서 제외하도록 정리했다. + +### 4.4 탭 기본조회 보강 + +- 팝업 최초 진입 시 1회 자동조회만 하던 흐름을 탭 전환 기준으로 보강했다. +- `기본 정보`, `유지관리 정보`는 탭 또는 대표 조건이 바뀔 때 기본조건 조회가 다시 돌도록 `SearchModal/useSearchModal` 공통 흐름을 정리했다. +- `하수도 정비 기본계획 보고서`는 `ReportTab` 내부에서 탭 진입 시 1회 자동조회되도록 유지했다. + +### 4.5 조회조건 레이아웃 및 결과 영역 정리 + +- 보고서 탭 조회조건은 한 줄 우선 정렬로 재배치하고, 화면이 좁을 때는 줄바꿈 대신 가로 스크롤이 나도록 바꿨다. +- 조회조건 박스의 과한 최소 높이와 하단 여백을 줄여 빈 줄처럼 보이던 공간을 제거했다. +- 결과 영역은 고정 높이 wrapper를 제거하고 `UiTable` 자체 레이아웃을 사용하게 정리해 상하 공백을 줄였다. +- 최종 보정으로 조회조건 각 요소 간 간격을 조금 더 벌려 보이도록 조정했다. + +### 4.6 행 클릭 제한 및 공통 select 포커스 보정 + +- 보고서 표는 이제 행 전체가 아니라 `파일명` 컬럼만 클릭되도록 `UiTable`에 `clickableHeadIds` 옵션을 추가했다. +- 이 옵션은 기본 미지정 시 기존 동작을 유지하므로 다른 화면에 영향이 없다. +- 공통 `SelectBox`는 포커스 outline이 위에서 잘리지 않도록 root에 `overflow-visible`과 세로 여유를 주고, trigger 높이를 조금 늘렸다. +- 추가로 포커스 ring을 바깥이 아닌 안쪽(`ring-inset`)으로 바꿔 기본정보/유지관리/보고서 탭 모두에서 잘림 없이 보이도록 보정했다. + +## 5. 수정 파일 + +- `frontend/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx` +- `frontend/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx` +- `frontend/apps/sn/app/(map-layout)/map/searchModal/useReportTab.ts` +- `frontend/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts` +- `frontend/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx` +- `frontend/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx` +- `frontend/packages/common/business_components/src/ui/table/index.tsx` +- `frontend/packages/common/business_components/src/ui/select/select.css` +- `frontend/packages/common/business_components/src/ui/select/lib/selectBox.tsx` + +## 6. 남은 리스크 + +- 다건 파일 대표 다운로드 기준은 현재 첫 번째 파일 기준이다. +- `reportFiles` 윈도우 팝업 코드는 현재 미호출 상태로 남아 있다. +- lint/build 자동 검증은 워크스페이스 pnpm 환경 문제로 완료하지 못했다. +- 브라우저에서 최신 select 포커스 스타일과 자동조회 체감은 추가 수동 확인이 필요하다. diff --git a/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx index d2913df8e..14af20da4 100644 --- a/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx +++ b/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx @@ -1,4 +1,7 @@ +"use client"; + import ReportTab from "@/app/(map-layout)/map/searchModal/reportTab"; +import { useEffect, useState } from "react"; import { windowPopupDefinitions } from "../model/popupDefinitions"; import type { WindowPopupContentProps } from "../model/types"; import { getPopupParamValue } from "./popupContentUtils"; @@ -6,9 +9,18 @@ import { getPopupParamValue } from "./popupContentUtils"; export default function ReportTabPopupContent({ searchParams, }: WindowPopupContentProps) { + const [isMounted, setIsMounted] = useState(false); const ctpvNm = getPopupParamValue(searchParams.ctpvNm); const sggNm = getPopupParamValue(searchParams.sggNm); + useEffect(() => { + setIsMounted(true); + }, []); + + if (!isMounted) { + return
; + } + return (

diff --git a/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx index 34343b6d0..bb71a6da3 100644 --- a/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx +++ b/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx @@ -1,10 +1,11 @@ -"use client"; +"use client"; import SearchModal from "@/app/(map-layout)/map/searchModal/searchModal"; import useStore from "@/store/useStore"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; export default function SearchModalPopupContent() { + const [isMounted, setIsMounted] = useState(false); const { ctpv, sgg, @@ -17,15 +18,27 @@ export default function SearchModalPopupContent() { } = useStore(); useEffect(() => { + setIsMounted(true); + }, []); + + useEffect(() => { + if (!isMounted) return; + // Popup windows do not run map-layout initialization, so load location codes here. if (ctpv.length === 0) void fetchCtpv(); if (sgg.length === 0) void fetchSgg(); - }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); + }, [ctpv.length, fetchCtpv, fetchSgg, isMounted, sgg.length]); useEffect(() => { + if (!isMounted) return; + if (BIZ_SE_CD.length === 0) void fetchBIZ_SE_CD(); if (BIZ_TY_CD.length === 0) void fetchBIZ_TY_CD(); - }, [BIZ_SE_CD.length, BIZ_TY_CD.length, fetchBIZ_SE_CD, fetchBIZ_TY_CD]); + }, [BIZ_SE_CD.length, BIZ_TY_CD.length, fetchBIZ_SE_CD, fetchBIZ_TY_CD, isMounted]); + + if (!isMounted) { + return
; + } return 0 && sgg.length > 0} />; } diff --git a/packages/common/business_components/src/ui/select/lib/selectBox.tsx b/packages/common/business_components/src/ui/select/lib/selectBox.tsx index cc4bac4d1..da0761733 100644 --- a/packages/common/business_components/src/ui/select/lib/selectBox.tsx +++ b/packages/common/business_components/src/ui/select/lib/selectBox.tsx @@ -1,4 +1,4 @@ -import * as SelectPrimitive from "@radix-ui/react-select" +import * as SelectPrimitive from "@radix-ui/react-select" import { Check, ChevronDown, ChevronUp } from "lucide-react" import * as React from "react" @@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef< span]:line-clamp-1", + "flex h-14 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-xl ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-inset focus:ring-ring focus:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", className )} {...props} @@ -148,3 +148,5 @@ export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue } + + diff --git a/packages/common/business_components/src/ui/select/select.css b/packages/common/business_components/src/ui/select/select.css index 8aa16f386..721ab81b3 100644 --- a/packages/common/business_components/src/ui/select/select.css +++ b/packages/common/business_components/src/ui/select/select.css @@ -1,8 +1,8 @@ -@tailwind components; +@tailwind components; @layer components { .selectbox_root { - @apply flex items-center; + @apply flex items-center overflow-visible py-1; } .selectbox_label { diff --git a/packages/common/business_components/src/ui/table/index.tsx b/packages/common/business_components/src/ui/table/index.tsx index ed05a7f19..17a2c953e 100644 --- a/packages/common/business_components/src/ui/table/index.tsx +++ b/packages/common/business_components/src/ui/table/index.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import { Checkbox, @@ -37,6 +37,7 @@ type UiTableProps = { headButton?: React.ReactNode | undefined; widthFixed?: boolean; isHideCount?: boolean; // 총 건수 default false + clickableHeadIds?: string[]; }; type UiHeadProps = { @@ -69,7 +70,8 @@ export function UiTable>({ curPage = 1, headButton = undefined, widthFixed = false, - isHideCount = false, // 총 건수 + isHideCount = false, + clickableHeadIds, }: UiTableProps) { const [cur, setCur] = useState(curPage); const [idx, setIdx] = useState(index); @@ -97,32 +99,36 @@ export function UiTable>({ useEffect(() => { setIdx(index); - }, [index]) + }, [index]); useEffect(() => { setCur(curPage); - }, [curPage]) + }, [curPage]); const handlePageChange = (page: number) => { setCur(page); onPageChange(page); - if (index == 0) - setIdx(0) + if (index == 0) setIdx(0); + }; + + const isHeadClickable = (headId: string) => { + if (!cellClick) return false; + if (!clickableHeadIds || clickableHeadIds.length === 0) return true; + return clickableHeadIds.includes(headId); }; const handleCellClick = (index: number, headId: string) => { - if (cellClick) { - cellClick(index, headId); - setIdx(index); - } + if (!isHeadClickable(headId) || !cellClick) return; + + cellClick(index, headId); + setIdx(index); }; const getLeafCols = (list: UiHeadProps[]) => { const maxRow = list?.reduce((max, head) => Math.max(max, head.row || 0), 0); return list - .filter(h => h.visible !== false) - .filter(h => ((h.row ?? 0) + (h.rowSpan ?? 1) - 1) >= maxRow) - ; + .filter((h) => h.visible !== false) + .filter((h) => ((h.row ?? 0) + (h.rowSpan ?? 1) - 1) >= maxRow); }; const leafCols = getLeafCols(headlist); @@ -153,12 +159,15 @@ export function UiTable>({ )}
- {widthFixed ? + {widthFixed ? ( - {leafCols.map(col => ( + {leafCols.map((col) => ( ))} - : ""} + + ) : ( + "" + )} {children ? children : } @@ -174,9 +183,9 @@ export function UiTable>({ head.id.length > 1 && (boldArray.includes(index) ? ( >({ ? String(cellValue ?? "") : String(cellValue).includes(".") ? String(cellValue) - .split(".") - .map((part, index) => - index === 0 - ? part.replace( - /\B(?=(\d{3})+(?!\d))/g, - ",", - ) - : part, - ) - .join(".") - : String(cellValue ?? "").replace( - /\B(?=(\d{3})+(?!\d))/g, - ",", - ); + .split(".") + .map((part, index) => + index === 0 + ? part.replace(/\B(?=(\d{3})+(?!\d))/g, ",") + : part, + ) + .join(".") + : String(cellValue ?? "").replace(/\B(?=(\d{3})+(?!\d))/g, ","); })()} ) : ( handleCellClick(index, head.id)} key={`cell-${head.id}-${index}`} > - { - //체크박스 유형애서 readOnly가 필요한 경우를 위해서 ChkClass 를 사용해서 readOnly 처리함. - (() => { - const cellValue = (el as Record)[head.id]; - return typeof cellValue === "object" && - cellValue !== null && - "chk" in (cellValue as object) && - "readonly" in (cellValue as object) ? ( + {(() => { + const cellValue = (el as Record)[head.id]; + return typeof cellValue === "object" && + cellValue !== null && + "chk" in (cellValue as object) && + "readonly" in (cellValue as object) ? ( { - { - console.log(cellValue); - } - if ( - (cellValue as ChkClass).readonly == true - ) - return; - (cellValue as ChkClass).chk = - Boolean(checked); + if ((cellValue as ChkClass).readonly == true) return; + (cellValue as ChkClass).chk = Boolean(checked); }} > @@ -258,22 +250,13 @@ export function UiTable>({ String(cellValue) .split(".") .map((part, index) => - index === 0 - ? part.replace( - /\B(?=(\d{3})+(?!\d))/g, - ",", - ) - : part, + index === 0 ? part.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : part, ) .join(".") ) : ( - String(cellValue ?? "").replace( - /\B(?=(\d{3})+(?!\d))/g, - ",", - ) + String(cellValue ?? "").replace(/\B(?=(\d{3})+(?!\d))/g, ",") ); - })() - } + })()} )), )}