From d17909df410e9febf6dd02d775e1c68f73427222 Mon Sep 17 00:00:00 2001 From: kiminchan Date: Mon, 20 Jul 2026 14:56:23 +0900 Subject: [PATCH 01/55] =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=20=EC=A0=95=EB=A0=AC=20=EA=B8=B0=EB=8A=A5=20=ED=99=95?= =?UTF-8?q?=EB=8C=80=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/agedPipe/AgedPipePopupPage.tsx | 65 +++++- .../map/components/agedPipe/agedPipeUtils.ts | 16 +- .../inftwtResult/InftwtResultPopupPage.tsx | 47 +++- .../PipeAnalysisDetailPopupPage.tsx | 97 ++++++--- .../pipeAnalysis/PipeAnalysisPopupPage.tsx | 188 ++++++++++++++-- .../rdiiResult/RdiiResultPopupPage.tsx | 47 +++- .../components/sinkhole/SinkholePopupPage.tsx | 58 +++-- .../map/components/sinkhole/sinkholeTypes.ts | 5 + .../map/searchModal/reportTab.tsx | 201 ++++++++++++++++-- .../map/searchModal/searchModal.tsx | 139 ++++++++++-- .../map/searchModal/useBasicTab.ts | 30 ++- .../map/searchModal/useOperationTab.ts | 45 +++- .../map/searchModal/useSearchModal.ts | 20 +- .../(with-base-layout)/oper/floodPre/page.tsx | 42 ++-- .../oper/floodPre/tableModal.tsx | 27 ++- apps/sn/app/(with-base-layout)/oper/index.ts | 22 +- .../(with-base-layout)/oper/oldPp/page.tsx | 43 ++-- .../oper/oldPp/tableModal.tsx | 78 +++++-- .../app/(with-base-layout)/oper/tableSort.ts | 71 +++++++ apps/sn/app/store/useSearchModalStore.ts | 15 +- .../001-qa-SFR-011-002-관로분석-통합검증결과.md | 7 + .../001-qa-SFR-011-노후관로관리-통합검증결과.md | 10 + .../001-qa-공통-UiTable-컬럼정렬-통합검증계획.md | 65 ++++++ .../002-qa-공통-UiTable-컬럼정렬-통합검증결과.md | 93 ++++++++ .../001-fe-SFR-011-002-관로분석-통합구현기록.md | 11 + .../001-fe-SFR-011-노후관로관리-통합구현기록.md | 9 + .../001-fe-공통-UiTable-컬럼정렬-통합구현기록.md | 131 ++++++++++++ .../src/ui/table/HeadMakeColSpan.tsx | 169 +++++++++------ .../src/ui/table/index.tsx | 45 ++-- .../business_components/src/ui/table/types.ts | 19 ++ 30 files changed, 1551 insertions(+), 264 deletions(-) create mode 100644 apps/sn/app/(with-base-layout)/oper/tableSort.ts create mode 100644 apps/sn/docs/작업검증/공통-UiTable-컬럼정렬/001-qa-공통-UiTable-컬럼정렬-통합검증계획.md create mode 100644 apps/sn/docs/작업검증/공통-UiTable-컬럼정렬/002-qa-공통-UiTable-컬럼정렬-통합검증결과.md create mode 100644 apps/sn/docs/작업내용/공통-UiTable-컬럼정렬/001-fe-공통-UiTable-컬럼정렬-통합구현기록.md create mode 100644 packages/common/business_components/src/ui/table/types.ts diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx index c900fe2c2..28a1e443d 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; import { Button, Input } from "@common/components"; import { UiTable } from "@common/business_components/ui"; +import type { UiTableSort } from "@common/business_components/ui"; import { getData } from "@/components/lib/api"; import useStore from "@/store/useStore"; import LoadingOverlay from "@/components/ui/LoadingBar"; @@ -25,6 +26,27 @@ const POPUP_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT"; const POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE"; const POPUP_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC"; +const AGED_PIPE_API_SORT_KEYS: Record = { + rowNumber: "rowNumber", + fcltMngNo: "fclt_mng_no", + instlYmd: "instl_ymd", + pdmtVl: "pdmt_vl", + prlgVl: "prlg_vl", + usgCd: "usg_cd", + mqltCd: "mqlt_cd", +}; + +const resolveAgedPipeApiSort = (sort?: UiTableSort) => { + if (!sort) return undefined; + if (sort.key === "elapsedYears") { + const installDateDirection = sort.direction === "asc" ? "desc" : "asc"; + return `instl_ymd,${installDateDirection}`; + } + + const apiKey = AGED_PIPE_API_SORT_KEYS[sort.key]; + return apiKey ? `${apiKey},${sort.direction}` : undefined; +}; + export default function AgedPipePopupPage() { const searchParams = useSearchParams(); const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); @@ -41,6 +63,7 @@ export default function AgedPipePopupPage() { const [selectedIndex, setSelectedIndex] = useState(-1); const [isLoading, setIsLoading] = useState(false); const [downloadProgressText, setDownloadProgressText] = useState(""); + const [sort, setSort] = useState(); const sggOptions = useMemo( () => @@ -56,14 +79,20 @@ export default function AgedPipePopupPage() { }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); const fetchRows = useCallback( - async (page: number, nextFilters: AgedPipeFilters, size: number) => { + async ( + page: number, + nextFilters: AgedPipeFilters, + size: number, + activeSort?: UiTableSort, + ) => { const normalized = normalizeAgedPipeFilters(nextFilters); const queryParams = new URLSearchParams({ lyrTypeNm: AGED_PIPE_LAYER_TYPE, page: String(page - 1), size: String(size), - sort: "string", }); + const apiSort = resolveAgedPipeApiSort(activeSort); + if (apiSort) queryParams.set("sort", apiSort); const cqlFilters = buildAgedPipeCqlFilters(normalized); if (cqlFilters.length > 0) { queryParams.append("cqlFiter", cqlFilters.join(" and ")); @@ -72,21 +101,27 @@ export default function AgedPipePopupPage() { const response = await getData(`v1/fclts?${queryParams.toString()}`); const responseRows = Array.isArray(response?.data) ? response.data : []; - return { - rows: responseRows.map((item: Record, index: number) => - toDisplayPipeRow(item, index, page, ctpv, sgg), - ), - total: Number(response?.count ?? 0), - }; + const total = Number(response?.count ?? 0); + const descendingSequence = + activeSort?.key === "rowNumber" && activeSort.direction === "desc"; + const rows = responseRows.map((item: Record, index: number) => { + const row = toDisplayPipeRow(item, index, page, ctpv, sgg); + if (descendingSequence) { + row.rowNumber = total - ((page - 1) * size + index); + } + return row; + }); + + return { rows, total }; }, [ctpv, sgg], ); const loadRows = useCallback( - async (page = 1, nextFilters = filters) => { + async (page = 1, nextFilters = filters, activeSort = sort) => { setIsLoading(true); try { - const response = await fetchRows(page, nextFilters, AGED_PIPE_PAGE_SIZE); + const response = await fetchRows(page, nextFilters, AGED_PIPE_PAGE_SIZE, activeSort); setRows(response.rows); setTotal(response.total); setCurrentPage(page); @@ -98,7 +133,7 @@ export default function AgedPipePopupPage() { setIsLoading(false); } }, - [fetchRows, filters], + [fetchRows, filters, sort], ); useEffect(() => { @@ -149,6 +184,12 @@ export default function AgedPipePopupPage() { ); }; + const handleSortChange = (nextSort?: UiTableSort) => { + setSort(nextSort); + setSelectedIndex(-1); + void loadRows(1, filters, nextSort); + }; + const handleDownload = async () => { if (total === 0) { alert("다운로드할 데이터가 없습니다."); @@ -327,6 +368,8 @@ export default function AgedPipePopupPage() { widthFixed={true} isHideCount={true} className="!m-0 overflow-x-hidden" + sort={sort} + onSortChange={handleSortChange} /> diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts index 8419e929c..0c5763239 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts @@ -54,14 +54,14 @@ const normalizeRegionFilterCode = (value: unknown, prefixLength: number) => { }; export const AGED_PIPE_LIST_HEADLIST = [ - { id: "rowNumber", title: "NO", widthPercent: 6 }, - { id: "fcltMngNo", title: "시설관리번호", widthPercent: 18 }, - { id: "instlYmd", title: "설치일자", widthPercent: 12 }, - { id: "pdmtVl", title: "관경(mm)", widthPercent: 10 }, - { id: "prlgVl", title: "연장(m)", widthPercent: 10 }, - { id: "usgCd", title: "용도", widthPercent: 12 }, - { id: "mqltCd", title: "재질", widthPercent: 18 }, - { id: "elapsedYears", title: "경과년도", widthPercent: 8, align: "right" }, + { id: "rowNumber", title: "NO", widthPercent: 6, sortable: true }, + { id: "fcltMngNo", title: "시설관리번호", widthPercent: 18, sortable: true }, + { id: "instlYmd", title: "설치일자", widthPercent: 12, sortable: true }, + { id: "pdmtVl", title: "관경(mm)", widthPercent: 10, sortable: true }, + { id: "prlgVl", title: "연장(m)", widthPercent: 10, sortable: true }, + { id: "usgCd", title: "용도", widthPercent: 12, sortable: true }, + { id: "mqltCd", title: "재질", widthPercent: 18, sortable: true }, + { id: "elapsedYears", title: "경과년도", widthPercent: 8, align: "right", sortable: true }, ]; export const AGED_PIPE_EXCEL_FIELDS = [ diff --git a/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx index b49fad160..54592fd8e 100644 --- a/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { DateMonthPicker, UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; +import type { UiHeadProps, UiTableSort } from "@common/business_components/ui"; import { Button } from "@common/components"; import { getData } from "@/components/lib/api"; @@ -30,6 +31,17 @@ const INFTWT_RESULT_HEADLIST = [ { id: "rgtrNm", title: "분석자" }, { id: "scsYn", title: "분석성공여부" }, ] as const; +// 화면 표시명과 분석 Entity 필드가 다른 열은 서버 정렬 키를 명시한다. +const INFTWT_RESULT_SORT_KEYS: Record = { + ctpvNm: "ctpvCd", + sggNm: "sggCd", +}; +const INFTWT_RESULT_SORTABLE_HEADLIST: UiHeadProps[] = INFTWT_RESULT_HEADLIST.map((head) => ({ + ...head, + // 분석자명은 목록 조회 후 외부 사용자 서비스에서 보강되므로 전체 결과 정렬에서 제외한다. + sortable: head.id !== "rgtrNm", + sortKey: INFTWT_RESULT_SORT_KEYS[head.id] ?? head.id, +})); type InftwtResultFilters = { ctpvCd: string; @@ -91,6 +103,7 @@ export default function InftwtResultPopupPage() { const [currentPage, setCurrentPage] = useState(1); const [selectedIndex, setSelectedIndex] = useState(-1); const [isLoading, setIsLoading] = useState(false); + const [sort, setSort] = useState(); useEffect(() => { if (ctpv.length === 0) void fetchCtpv(); @@ -98,12 +111,17 @@ export default function InftwtResultPopupPage() { }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); const fetchRows = useCallback( - async (page: number, nextFilters: InftwtResultFilters) => { + async (page: number, nextFilters: InftwtResultFilters, nextSort?: UiTableSort) => { const queryParams = new URLSearchParams({ page: String(page - 1), size: String(INFTWT_RESULT_PAGE_SIZE), - sort: "inftwtAnlsSn,desc", + scsYn: "Y", }); + const requestSort = nextSort?.key === "rowNumber" + ? { key: "inftwtAnlsSn", direction: nextSort.direction === "asc" ? "desc" : "asc" } + : nextSort; + if (requestSort) queryParams.append("sort", `${requestSort.key},${requestSort.direction}`); + if (requestSort?.key !== "inftwtAnlsSn") queryParams.append("sort", "inftwtAnlsSn,desc"); if (nextFilters.aplcnYmFrom) queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom); if (nextFilters.aplcnYmTo) queryParams.set("aplcnYmTo", nextFilters.aplcnYmTo); @@ -113,6 +131,7 @@ export default function InftwtResultPopupPage() { const response = await getData(`v1/inftwtAnls?${queryParams.toString()}`); const content = Array.isArray(response?.content) ? response.content : []; + const totalElements = Number(response?.totalElements ?? content.length); const nextRows = content .map((item: Record, index: number) => { @@ -122,7 +141,9 @@ export default function InftwtResultPopupPage() { return { ...(formattedItem as Record), - rowNumber: (page - 1) * INFTWT_RESULT_PAGE_SIZE + index + 1, + rowNumber: nextSort?.key === "rowNumber" && nextSort.direction === "desc" + ? totalElements - ((page - 1) * INFTWT_RESULT_PAGE_SIZE + index) + : (page - 1) * INFTWT_RESULT_PAGE_SIZE + index + 1, ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"), sggNm: sggOption ? sggOption.text : String(item?.sggCd ?? "-"), scsYn: String(item?.scsYn ?? "N"), @@ -131,17 +152,17 @@ export default function InftwtResultPopupPage() { .filter((item: InftwtResultRow) => item.scsYn === "Y"); setRows(nextRows); - setTotal(Number(response?.totalElements ?? nextRows.length)); + setTotal(totalElements); setCurrentPage(page); }, [ctpv, sgg], ); const loadRows = useCallback( - async (page = 1, nextFilters = filters) => { + async (page = 1, nextFilters = filters, nextSort = sort) => { setIsLoading(true); try { - await fetchRows(page, nextFilters); + await fetchRows(page, nextFilters, nextSort); } catch (error) { console.error("침입수 분석결과 목록 조회 중 오류:", error); alert("침입수 분석결과 목록을 불러오지 못했습니다."); @@ -151,7 +172,7 @@ export default function InftwtResultPopupPage() { setIsLoading(false); } }, - [fetchRows, filters], + [fetchRows, filters, sort], ); useEffect(() => { @@ -165,6 +186,14 @@ export default function InftwtResultPopupPage() { await loadRows(page, filters); }; + /** Applies server sorting while keeping the current search filters. */ + const handleSortChange = async (nextSort?: UiTableSort) => { + setSort(nextSort); + setSelectedIndex(-1); + setSelectedRow(null); + await loadRows(1, filters, nextSort); + }; + const handleRowClick = (index?: number) => { if (typeof index !== "number") return; const row = rows[index]; @@ -317,7 +346,7 @@ export default function InftwtResultPopupPage() { headName={<>{`분석이력 총 ${total.toLocaleString()}건`}} tableData={rows} - headlist={INFTWT_RESULT_HEADLIST as unknown as { id: string; title: string }[]} + headlist={INFTWT_RESULT_SORTABLE_HEADLIST} pageSize={INFTWT_RESULT_PAGE_SIZE} total={total} cellClick={(index) => handleRowClick(index)} @@ -330,6 +359,8 @@ export default function InftwtResultPopupPage() { curPage={currentPage} index={selectedIndex} widthFixed={true} + sort={sort} + onSortChange={(nextSort) => void handleSortChange(nextSort)} /> diff --git a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx index eb436b1fb..34f058e8a 100644 --- a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "next/navigation"; import { Button, Input } from "@common/components"; import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; +import type { UiTableSort } from "@common/business_components/ui"; import { getData } from "@/components/lib/api"; import LoadingOverlay from "@/components/ui/LoadingBar"; import useStore from "@/store/useStore"; @@ -28,6 +29,7 @@ import { } from "./pipeAnalysisUtils"; type DetailViewRow = ReturnType[number]; +const PIPE_ANALYSIS_DETAIL_PAGE_SIZE = 10; const PIPE_ANALYSIS_DETAIL_EXPORT_KEYS = [ "rowNo", "ctpvNm", @@ -41,6 +43,16 @@ const PIPE_ANALYSIS_DETAIL_EXPORT_KEYS = [ "prlgVl", "materialName", ] as const; +// 표시명 열은 원천 코드로, NO 열은 서버의 전체 순번 계약으로 정렬한다. +const PIPE_ANALYSIS_DETAIL_SORT_KEYS: Record = { + useNm: "useCd", + materialName: "materialCode", +}; +const PIPE_ANALYSIS_DETAIL_SORTABLE_HEADLIST = PIPE_ANALYSIS_DETAIL_HEADLIST.map((head) => ({ + ...head, + sortable: true, + sortKey: PIPE_ANALYSIS_DETAIL_SORT_KEYS[head.id] ?? head.id, +})); const resolveErrorMessage = (error: unknown, fallbackMessage: string) => error instanceof Error && error.message.trim() ? error.message : fallbackMessage; @@ -66,10 +78,24 @@ export default function PipeAnalysisDetailPopupPage() { scope, ), ); + const [appliedFilters, setAppliedFilters] = useState(() => + applyScopeToDetailFilters( + { + ...PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS, + ctpvCd: searchParams.get("ctpvCd") ?? PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.ctpvCd, + sggCd: searchParams.get("sggCd") ?? PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.sggCd, + swtrmtZoneNm: searchParams.get("swtrmtZoneNm") ?? PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.swtrmtZoneNm, + materialCode: baseMaterialCode, + }, + scope, + ), + ); const [rows, setRows] = useState([]); const [total, setTotal] = useState(0); const [currentPage, setCurrentPage] = useState(1); const [selectedIndex, setSelectedIndex] = useState(-1); + const [sort, setSort] = useState(); + const [appliedSort, setAppliedSort] = useState(); const [isLoading, setIsLoading] = useState(false); const initializedRef = useRef(false); @@ -80,10 +106,11 @@ export default function PipeAnalysisDetailPopupPage() { useEffect(() => { setFilters((current) => applyScopeToDetailFilters(current, scope)); + setAppliedFilters((current) => applyScopeToDetailFilters(current, scope)); }, [scope]); const fetchRows = useCallback( - async (page: number, nextFilters: PipeAnalysisDetailFilters, size: number) => { + async (page: number, nextFilters: PipeAnalysisDetailFilters, size: number, nextSort?: UiTableSort) => { const params = new URLSearchParams({ analysisType: baseAnalysisType, page: String(page - 1), @@ -98,6 +125,7 @@ export default function PipeAnalysisDetailPopupPage() { if (nextFilters.useCd !== "0") params.set("useCd", nextFilters.useCd); if (nextFilters.diameterFrom) params.set("diameterFrom", nextFilters.diameterFrom); if (nextFilters.diameterTo) params.set("diameterTo", nextFilters.diameterTo); + if (nextSort) params.set("sort", `${nextSort.key},${nextSort.direction}`); if (scope.regionCode) params.set("scopeRegionCode", scope.regionCode); const response = await getData(`v1/fclts/ppl-analysis/details?${params.toString()}`); @@ -112,13 +140,15 @@ export default function PipeAnalysisDetailPopupPage() { ); const loadRows = useCallback( - async (page = 1, nextFilters = filters) => { + async (page = 1, nextFilters = filters, nextSort = sort) => { setIsLoading(true); try { - const response = await fetchRows(page, nextFilters, 20); + const response = await fetchRows(page, nextFilters, PIPE_ANALYSIS_DETAIL_PAGE_SIZE, nextSort); setRows(response.rows); setTotal(response.total); setCurrentPage(page); + setAppliedFilters(nextFilters); + setAppliedSort(nextSort); } catch (error) { console.error("관로현황 조회 중 오류:", error); alert(resolveErrorMessage(error, "관로현황 조회 중 오류가 발생했습니다.")); @@ -128,21 +158,35 @@ export default function PipeAnalysisDetailPopupPage() { setIsLoading(false); } }, - [fetchRows, filters], + [fetchRows, filters, sort], ); useEffect(() => { if (initializedRef.current) return; if (ctpv.length === 0 || sgg.length === 0) return; initializedRef.current = true; - void loadRows(1, applyScopeToDetailFilters(filters, scope)); + const initialFilters = applyScopeToDetailFilters(filters, scope); + void loadRows(1, initialFilters); }, [ctpv.length, filters, loadRows, scope, sgg.length]); - const handleSearch = (page = 1) => { + const handleSearch = () => { setSelectedIndex(-1); - void loadRows(page, applyScopeToDetailFilters(filters, scope)); + const nextFilters = applyScopeToDetailFilters(filters, scope); + void loadRows(1, nextFilters, sort); }; + /** Keeps the last searched filters and sort when the user moves pages. */ + const handlePageChange = (page: number) => { + setSelectedIndex(-1); + void loadRows(page, appliedFilters, appliedSort); + }; + + /** Reloads the first server page with the selected grid sort. */ + const handleSortChange = (nextSort?: UiTableSort) => { + setSort(nextSort); + setSelectedIndex(-1); + void loadRows(1, appliedFilters, nextSort); + }; const handleRowClick = (index?: number) => { if (typeof index !== "number") return; const row = rows[index]; @@ -167,7 +211,7 @@ export default function PipeAnalysisDetailPopupPage() { try { setIsLoading(true); - const response = await fetchRows(1, filters, Math.max(total, 1000)); + const response = await fetchRows(1, appliedFilters, Math.max(total, 1000), appliedSort); const exportRows = response.rows.map((row) => ({ rowNo: row.rowNo, ctpvNm: row.ctpvNm, @@ -253,7 +297,7 @@ export default function PipeAnalysisDetailPopupPage() {

관로현황{titleSuffix}

-
+
@@ -324,7 +368,7 @@ export default function PipeAnalysisDetailPopupPage() { })) } onKeyDown={(event) => { - if (event.key === "Enter") handleSearch(1); + if (event.key === "Enter") handleSearch(); }} /> @@ -335,7 +379,7 @@ export default function PipeAnalysisDetailPopupPage() { size="sm" color="primary" className="h-10 px-5 !text-[1.1rem]" - onClick={() => handleSearch(1)} + onClick={handleSearch} > 조회 @@ -351,18 +395,23 @@ export default function PipeAnalysisDetailPopupPage() {
- - headName={<>관로현황 조회 결과 총 {total.toLocaleString()}건} - tableData={rows} - headlist={PIPE_ANALYSIS_DETAIL_HEADLIST} - pageSize={20} - total={total} - cellClick={(index) => handleRowClick(index)} - onPageChange={(page) => handleSearch(page)} - curPage={currentPage} - index={selectedIndex} - widthFixed={true} - /> +
+ + headName={<>관로현황 조회 결과 총 {total.toLocaleString()}건} + tableData={rows} + headlist={PIPE_ANALYSIS_DETAIL_SORTABLE_HEADLIST} + pageSize={PIPE_ANALYSIS_DETAIL_PAGE_SIZE} + total={total} + cellClick={(index) => handleRowClick(index)} + onPageChange={handlePageChange} + curPage={currentPage} + index={selectedIndex} + widthFixed={true} + className="!m-0 overflow-x-hidden" + sort={sort} + onSortChange={handleSortChange} + /> +
); diff --git a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx index 921dcf04c..a518ae6f4 100644 --- a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button, Table, TableBody, TableCell, TableHeader, TableHead, TableRow } from "@common/components"; import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; +import type { UiTableSort } from "@common/business_components/ui"; import UiPageNations from "@common/business_components/ui/table/uiPageNations"; import { getData } from "@/components/lib/api"; import LoadingOverlay from "@/components/ui/LoadingBar"; @@ -34,6 +35,13 @@ const SUMMARY_FILE_PREFIX: Record = { YEAR_MATERIAL: "관로분석_경과년도별_관재질별", }; const PIPE_ANALYSIS_SUMMARY_PAGE_SIZE = 10; +const YEAR_MATERIAL_SORTABLE_KEYS = new Set([ + "rowNo", + "ctpvNm", + "sggNm", + "swtrmtZoneNm", + "totalLengthKm", +]); const toExcelColumnName = (index: number) => { let current = index; @@ -61,6 +69,87 @@ const formatGridNumber = (value: unknown) => { const resolveErrorMessage = (error: unknown, fallbackMessage: string) => error instanceof Error && error.message.trim() ? error.message : fallbackMessage; +const KOREAN_GRID_COLLATOR = new Intl.Collator("ko", { + numeric: true, + sensitivity: "base", +}); + +/** Sorts the complete summary result before client-side pagination. */ +const sortSummaryRows = ( + sourceRows: PipeAnalysisSummaryViewRow[], + sort?: UiTableSort, +) => { + if (!sort) return sourceRows; + + const sortableRows = sourceRows.filter((row) => !row.__isNoZone && !row.__isSummaryTotal); + const pinnedRows = sourceRows.filter((row) => row.__isNoZone || row.__isSummaryTotal); + + sortableRows.sort((left, right) => { + const leftValue = left[sort.key]; + const rightValue = right[sort.key]; + const leftEmpty = leftValue === null || leftValue === undefined || leftValue === ""; + const rightEmpty = rightValue === null || rightValue === undefined || rightValue === ""; + + if (leftEmpty || rightEmpty) { + if (leftEmpty && rightEmpty) return 0; + return leftEmpty ? 1 : -1; + } + + const leftNumber = typeof leftValue === "number" ? leftValue : Number.NaN; + const rightNumber = typeof rightValue === "number" ? rightValue : Number.NaN; + const compared = Number.isFinite(leftNumber) && Number.isFinite(rightNumber) + ? leftNumber - rightNumber + : KOREAN_GRID_COLLATOR.compare(String(leftValue), String(rightValue)); + + return sort.direction === "asc" ? compared : -compared; + }); + + return [...sortableRows, ...pinnedRows]; +}; + +const getYearMaterialGroupSortValue = ( + group: PipeAnalysisYearMaterialGroup, + key: string, +) => { + switch (key) { + case "rowNo": + return group.rowNo; + case "ctpvNm": + return group.ctpvNm; + case "sggNm": + return group.sggNm; + case "swtrmtZoneNm": + return group.swtrmtZoneNm; + case "totalLengthKm": + return group.totalLengthKm; + default: + return group.rows.reduce((sum, row) => sum + Number(row.metrics[key] ?? 0), 0); + } +}; + +const sortYearMaterialGroups = ( + groups: PipeAnalysisYearMaterialGroup[], + sort?: UiTableSort, +) => { + if (!sort || !YEAR_MATERIAL_SORTABLE_KEYS.has(sort.key)) return groups; + + const sortedGroups = [...groups]; + sortedGroups.sort((left, right) => { + const leftValue = getYearMaterialGroupSortValue(left, sort.key); + const rightValue = getYearMaterialGroupSortValue(right, sort.key); + const compared = typeof leftValue === "number" && typeof rightValue === "number" + ? leftValue - rightValue + : KOREAN_GRID_COLLATOR.compare(String(leftValue), String(rightValue)); + + if (compared !== 0) { + return sort.direction === "asc" ? compared : -compared; + } + + return KOREAN_GRID_COLLATOR.compare(left.swtrmtZoneNm, right.swtrmtZoneNm); + }); + + return sortedGroups; +}; export default function PipeAnalysisPopupPage() { const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); @@ -76,6 +165,7 @@ export default function PipeAnalysisPopupPage() { const [rows, setRows] = useState([]); const [zoneOptions, setZoneOptions] = useState>([]); const [summaryCurrentPage, setSummaryCurrentPage] = useState(1); + const [sort, setSort] = useState(); const [isLoading, setIsLoading] = useState(false); const initializedRef = useRef(false); @@ -183,7 +273,7 @@ export default function PipeAnalysisPopupPage() { const ranges: string[] = []; let currentRow = 2; - yearMaterialGroups.forEach((group) => { + orderedYearMaterialGroups.forEach((group) => { const rowSpan = group.rows.length; if (rowSpan > 1) { ["A", "B", "C", "D", "E"].forEach((column) => { @@ -197,7 +287,7 @@ export default function PipeAnalysisPopupPage() { })() : []; const exportRows = appliedFilters.analysisType === "YEAR_MATERIAL" - ? yearMaterialGroups.flatMap((group) => + ? orderedYearMaterialGroups.flatMap((group) => group.rows.map((yearRow, rowIndex) => { const exportRow: Record = { rowNo: rowIndex === 0 ? group.rowNo : "", @@ -215,7 +305,7 @@ export default function PipeAnalysisPopupPage() { return exportRow; }), ) - : rows.map((row) => { + : sortedRows.map((row) => { const exportRow: Record = {}; exportHeadList.forEach((head) => { exportRow[head.id] = row[head.id]; @@ -238,7 +328,7 @@ export default function PipeAnalysisPopupPage() { const handleCellClick = (index?: number, columnId?: string) => { if (typeof index !== "number" || !columnId) return; - const row = rows[index]; + const row = pagedRows[index]; if (!row) return; if (row.__isNoZone || row.__isSummaryTotal) return; const cellFilter = row.__cellFilters[columnId]; @@ -275,16 +365,34 @@ export default function PipeAnalysisPopupPage() { }; const headList = useMemo( - () => buildPipeAnalysisSummaryHeadList(appliedFilters.analysisType, materialColumns), + () => buildPipeAnalysisSummaryHeadList(appliedFilters.analysisType, materialColumns).map((head) => ({ ...head, sortable: true })), [appliedFilters.analysisType, materialColumns], ); + const sortedRows = useMemo( + () => sortSummaryRows(rows, sort), + [rows, sort], + ); + const yearMaterialGroups = useMemo( () => appliedFilters.analysisType === "YEAR_MATERIAL" ? buildPipeAnalysisYearMaterialGroups(rows, materialColumns) : [], [appliedFilters.analysisType, materialColumns, rows], ); + const sortedYearMaterialGroups = useMemo( + () => sortYearMaterialGroups(yearMaterialGroups, sort), + [sort, yearMaterialGroups], + ); + const orderedYearMaterialGroups = useMemo( + () => sortedYearMaterialGroups.map((group, index) => ({ + ...group, + rowNo: sort?.key === "rowNo" && sort.direction === "desc" + ? sortedYearMaterialGroups.length - index + : index + 1, + })), + [sort, sortedYearMaterialGroups], + ); const useHorizontalScrollTable = appliedFilters.analysisType !== "YEAR"; const summaryPageOffset = (summaryCurrentPage - 1) * PIPE_ANALYSIS_SUMMARY_PAGE_SIZE; @@ -292,19 +400,19 @@ export default function PipeAnalysisPopupPage() { () => appliedFilters.analysisType === "YEAR_MATERIAL" ? rows - : rows.slice(summaryPageOffset, summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE), - [appliedFilters.analysisType, rows, summaryPageOffset], + : sortedRows.slice(summaryPageOffset, summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE), + [appliedFilters.analysisType, rows, sortedRows, summaryPageOffset], ); const visibleYearMaterialGroups = useMemo( () => - yearMaterialGroups.slice( + orderedYearMaterialGroups.slice( summaryPageOffset, summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE, ), - [summaryPageOffset, yearMaterialGroups], + [orderedYearMaterialGroups, summaryPageOffset], ); const summaryTotalCount = appliedFilters.analysisType === "YEAR_MATERIAL" - ? yearMaterialGroups.length + ? orderedYearMaterialGroups.length : rows.length; const sggOptions = useMemo( @@ -450,8 +558,57 @@ export default function PipeAnalysisPopupPage() { className={`border border-border bg-secondary px-4 py-[1.2rem] text-[1.3rem] font-medium text-label ${ head.align === "right" ? "text-right" : "text-center" }`} + aria-sort={ + head.id === "elapsedYearLabel" + ? undefined + : sort?.key === head.id + ? sort.direction === "asc" + ? "ascending" + : "descending" + : "none" + } > - {head.title} + {!YEAR_MATERIAL_SORTABLE_KEYS.has(head.id) ? ( + head.title + ) : ( + + )} ))} @@ -515,10 +672,10 @@ export default function PipeAnalysisPopupPage() { - {yearMaterialGroups.length > PIPE_ANALYSIS_SUMMARY_PAGE_SIZE ? ( + {orderedYearMaterialGroups.length > PIPE_ANALYSIS_SUMMARY_PAGE_SIZE ? (
{ + setSort(nextSort); + setSummaryCurrentPage(1); + }} cellClassName={(row, columnId) => !row.__isNoZone && row.__cellFilters[columnId] ? "cursor-pointer font-semibold text-blue-700 underline underline-offset-2" diff --git a/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx index 4f0ff5e15..e7add3575 100644 --- a/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { DateMonthPicker, UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; +import type { UiHeadProps, UiTableSort } from "@common/business_components/ui"; import { Button } from "@common/components"; import { getData } from "@/components/lib/api"; @@ -32,6 +33,17 @@ const RDII_RESULT_HEADLIST = [ { id: "rgtrNm", title: "분석자" }, { id: "scsYn", title: "분석성공여부" }, ] as const; +// 화면 표시명과 분석 Entity 필드가 다른 열은 서버 정렬 키를 명시한다. +const RDII_RESULT_SORT_KEYS: Record = { + ctpvNm: "ctpvCd", + sggNm: "sggCd", +}; +const RDII_RESULT_SORTABLE_HEADLIST: UiHeadProps[] = RDII_RESULT_HEADLIST.map((head) => ({ + ...head, + // 분석자명은 목록 조회 후 외부 사용자 서비스에서 보강되므로 전체 결과 정렬에서 제외한다. + sortable: head.id !== "rgtrNm", + sortKey: RDII_RESULT_SORT_KEYS[head.id] ?? head.id, +})); type RdiiResultFilters = { ctpvCd: string; @@ -93,6 +105,7 @@ export default function RdiiResultPopupPage() { const [currentPage, setCurrentPage] = useState(1); const [selectedIndex, setSelectedIndex] = useState(-1); const [isLoading, setIsLoading] = useState(false); + const [sort, setSort] = useState(); useEffect(() => { if (ctpv.length === 0) void fetchCtpv(); @@ -100,12 +113,17 @@ export default function RdiiResultPopupPage() { }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); const fetchRows = useCallback( - async (page: number, nextFilters: RdiiResultFilters) => { + async (page: number, nextFilters: RdiiResultFilters, nextSort?: UiTableSort) => { const queryParams = new URLSearchParams({ page: String(page - 1), size: String(RDII_RESULT_PAGE_SIZE), - sort: "rainInfwtAnlsSn,desc", + scsYn: "Y", }); + const requestSort = nextSort?.key === "rowNumber" + ? { key: "rainInfwtAnlsSn", direction: nextSort.direction === "asc" ? "desc" : "asc" } + : nextSort; + if (requestSort) queryParams.append("sort", `${requestSort.key},${requestSort.direction}`); + if (requestSort?.key !== "rainInfwtAnlsSn") queryParams.append("sort", "rainInfwtAnlsSn,desc"); if (nextFilters.aplcnYmFrom) queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom); if (nextFilters.aplcnYmTo) queryParams.set("aplcnYmTo", nextFilters.aplcnYmTo); @@ -115,6 +133,7 @@ export default function RdiiResultPopupPage() { const response = await getData(`v1/rdiiAnls?${queryParams.toString()}`); const content = Array.isArray(response?.content) ? response.content : []; + const totalElements = Number(response?.totalElements ?? content.length); const nextRows = content .map((item: Record, index: number) => { @@ -124,7 +143,9 @@ export default function RdiiResultPopupPage() { return { ...(formattedItem as Record), - rowNumber: (page - 1) * RDII_RESULT_PAGE_SIZE + index + 1, + rowNumber: nextSort?.key === "rowNumber" && nextSort.direction === "desc" + ? totalElements - ((page - 1) * RDII_RESULT_PAGE_SIZE + index) + : (page - 1) * RDII_RESULT_PAGE_SIZE + index + 1, ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"), sggNm: sggOption ? sggOption.text : String(item?.sggCd ?? "-"), scsYn: String(item?.scsYn ?? "N"), @@ -133,17 +154,17 @@ export default function RdiiResultPopupPage() { .filter((item: RdiiResultRow) => item.scsYn === "Y"); setRows(nextRows); - setTotal(Number(response?.totalElements ?? nextRows.length)); + setTotal(totalElements); setCurrentPage(page); }, [ctpv, sgg], ); const loadRows = useCallback( - async (page = 1, nextFilters = filters) => { + async (page = 1, nextFilters = filters, nextSort = sort) => { setIsLoading(true); try { - await fetchRows(page, nextFilters); + await fetchRows(page, nextFilters, nextSort); } catch (error) { console.error("RDII 분석결과 목록 조회 중 오류:", error); alert("RDII 분석결과 목록을 불러오지 못했습니다."); @@ -153,7 +174,7 @@ export default function RdiiResultPopupPage() { setIsLoading(false); } }, - [fetchRows, filters], + [fetchRows, filters, sort], ); useEffect(() => { @@ -167,6 +188,14 @@ export default function RdiiResultPopupPage() { await loadRows(page, filters); }; + /** Applies server sorting while keeping the current search filters. */ + const handleSortChange = async (nextSort?: UiTableSort) => { + setSort(nextSort); + setSelectedIndex(-1); + setSelectedRow(null); + await loadRows(1, filters, nextSort); + }; + const handleRowClick = (index?: number) => { if (typeof index !== "number") return; const row = rows[index]; @@ -319,7 +348,7 @@ export default function RdiiResultPopupPage() { headName={<>{`분석이력 총 ${total.toLocaleString()}건`}} tableData={rows} - headlist={RDII_RESULT_HEADLIST as unknown as { id: string; title: string }[]} + headlist={RDII_RESULT_SORTABLE_HEADLIST} pageSize={RDII_RESULT_PAGE_SIZE} total={total} cellClick={(index) => handleRowClick(index)} @@ -332,6 +361,8 @@ export default function RdiiResultPopupPage() { curPage={currentPage} index={selectedIndex} widthFixed={true} + sort={sort} + onSortChange={(nextSort) => void handleSortChange(nextSort)} />
diff --git a/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx b/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx index e1f41b952..06387e1d3 100644 --- a/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; import { Button, Input } from "@common/components"; import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; +import type { UiHeadProps, UiTableSort } from "@common/business_components/ui"; import { getRawJson } from "@/components/lib/api"; import useStore from "@/store/useStore"; import LoadingOverlay from "@/components/ui/LoadingBar"; @@ -37,18 +38,18 @@ type SinkholeTableRow = SinkholeListItem & { coordStatusDisplay: string; }; -const HEADLIST = [ - { id: "acdntNo", title: "사고번호", widthPercent: 17 }, - { id: "ocrnYmd", title: "발생일자", widthPercent: 10 }, - { id: "fullAddress", title: "주소", widthPercent: 20 }, - { id: "sizeWidthDisplay", title: "발생규모폭", widthPercent: 8 }, - { id: "sizeLengthDisplay", title: "발생규모연장", widthPercent: 8 }, - { id: "sizeDepthDisplay", title: "발생규모깊이", widthPercent: 8 }, - { id: "detailCauseDisplay", title: "상세발생원인", widthPercent: 11 }, - { id: "restoreStatus", title: "복구상태", widthPercent: 8 }, - { id: "coordStatusDisplay", title: "좌표보정상태", widthPercent: 9 }, - { id: "restoreCompletedYmdDisplay", title: "복구완료일자", widthPercent: 11 }, -] as const; +const HEADLIST: UiHeadProps[] = [ + { id: "acdntNo", title: "사고번호", widthPercent: 15, sortable: true }, + { id: "ocrnYmd", title: "발생일자", widthPercent: 10, sortable: true }, + { id: "fullAddress", title: "주소", widthPercent: 20, sortable: true }, + { id: "sizeWidthDisplay", title: "발생규모폭", widthPercent: 8, sortable: true, sortKey: "sizeWidth" }, + { id: "sizeLengthDisplay", title: "발생규모연장", widthPercent: 9, sortable: true, sortKey: "sizeLength" }, + { id: "sizeDepthDisplay", title: "발생규모깊이", widthPercent: 9, sortable: true, sortKey: "sizeDepth" }, + { id: "detailCauseDisplay", title: "상세발생원인", widthPercent: 11, sortable: true, sortKey: "detailCause" }, + { id: "restoreStatus", title: "복구상태", widthPercent: 8, sortable: true }, + { id: "coordStatusDisplay", title: "좌표보정상태", widthPercent: 9, sortable: true, sortKey: "coordStatus" }, + { id: "restoreCompletedYmdDisplay", title: "복구완료일자", widthPercent: 11, sortable: true, sortKey: "restoreCompletedYmd" }, +]; const toDisplay = (value: unknown) => { const normalized = String(value ?? "").trim(); @@ -142,6 +143,8 @@ export default function SinkholePopupPage() { const [currentPage, setCurrentPage] = useState(1); const [selectedIndex, setSelectedIndex] = useState(-1); const [isLoading, setIsLoading] = useState(false); + const [sort, setSort] = useState(); + const [appliedSort, setAppliedSort] = useState(); const sggOptions = useMemo( () => filters.ctpvCd === "0" @@ -162,6 +165,7 @@ export default function SinkholePopupPage() { pageSize = PAGE_SIZE, options?: { includeCoordStatus?: boolean; + sort?: UiTableSort; }, ) => { const queryString = buildSinkholeListQueryString( @@ -185,15 +189,18 @@ export default function SinkholePopupPage() { ); const loadRows = useCallback( - async (page = 1, nextFilters = filters) => { + async (page: number, nextFilters: SinkholeFilters, nextSort?: UiTableSort) => { setIsLoading(true); try { const normalized = normalizeSinkholeFilters(nextFilters); - const response = await fetchRows(page, normalized); + const response = await fetchRows(page, normalized, PAGE_SIZE, { sort: nextSort }); setRows(response.rows); setTotal(response.total); setCurrentPage(page); setAppliedFilters(normalized); + setSort(nextSort); + setAppliedSort(nextSort); + setSelectedIndex(-1); window.opener?.postMessage( { type: SINKHOLE_FILTER_SYNC_MESSAGE, @@ -210,11 +217,11 @@ export default function SinkholePopupPage() { setIsLoading(false); } }, - [fetchRows, filters], + [fetchRows], ); useEffect(() => { - void loadRows(1, filters); + void loadRows(1, filters, undefined); // 최초 로딩은 한 번만 수행한다. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -222,7 +229,17 @@ export default function SinkholePopupPage() { const handleSearch = async (page = 1) => { const normalized = normalizeSinkholeFilters(filters); setFilters(normalized); - await loadRows(page, normalized); + await loadRows(page, normalized, sort); + }; + + /** 현재 적용된 검색 조건을 유지한 채 서버 정렬 후 첫 페이지를 조회한다. */ + const handleSortChange = async (nextSort?: UiTableSort) => { + await loadRows(1, appliedFilters, nextSort); + }; + + /** 페이지 이동은 입력 중인 조건이 아니라 마지막 적용 조건과 정렬을 사용한다. */ + const handlePageChange = async (page: number) => { + await loadRows(page, appliedFilters, appliedSort); }; const handleRowClick = (index?: number) => { @@ -255,6 +272,7 @@ export default function SinkholePopupPage() { for (let page = 1; page <= totalPages; page += 1) { const response = await fetchRows(page, normalized, DOWNLOAD_BATCH_SIZE, { includeCoordStatus: false, + sort: appliedSort, }); downloadItems.push(...response.items); } @@ -405,15 +423,17 @@ export default function SinkholePopupPage() { headName={<>지반침하 조회 결과 총 {total.toLocaleString()}건} tableData={rows} - headlist={HEADLIST as any} + headlist={HEADLIST} pageSize={PAGE_SIZE} total={total} cellClick={(index) => handleRowClick(index)} - onPageChange={(page) => void handleSearch(page)} + onPageChange={(page) => void handlePageChange(page)} curPage={currentPage} index={selectedIndex} widthFixed={true} isHideCount={true} + sort={sort} + onSortChange={(nextSort) => void handleSortChange(nextSort)} />
diff --git a/apps/sn/app/(map-layout)/map/components/sinkhole/sinkholeTypes.ts b/apps/sn/app/(map-layout)/map/components/sinkhole/sinkholeTypes.ts index eecd5606c..f70c87593 100644 --- a/apps/sn/app/(map-layout)/map/components/sinkhole/sinkholeTypes.ts +++ b/apps/sn/app/(map-layout)/map/components/sinkhole/sinkholeTypes.ts @@ -1,6 +1,7 @@ "use client"; import type { SelectDataType } from "@common/business_components"; +import type { UiTableSort } from "@common/business_components/ui"; import type { IUserInfo } from "@/shared/store"; export const SINKHOLE_CONTROL_NODE_ID = "SN_SINKHOLE"; @@ -251,6 +252,7 @@ export const buildSinkholeListQueryString = ( size = 20, options?: { includeCoordStatus?: boolean; + sort?: UiTableSort; }, ) => { const params = new URLSearchParams({ @@ -274,6 +276,9 @@ export const buildSinkholeListQueryString = ( if (options?.includeCoordStatus === false) { params.set("includeCoordStatus", "false"); } + if (options?.sort) { + params.set("sort", `${options.sort.key},${options.sort.direction}`); + } return params.toString(); }; diff --git a/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx b/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx index 8ce577d8f..04f69f885 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx +++ b/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx @@ -23,15 +23,17 @@ import useStore from '@/store/useStore'; import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox'; import useSearchModalStore from '@/store/useSearchModalStore'; import { useReportTabStore } from '@/store/useReportTabStore'; +import type { UiTableSort } from '@common/business_components/ui'; +import type { FlattenedFolder, ReportDataFile } from './useReportTab'; // 하수도정비 기본계획 보고서 export const reportTableHeadlist = [ - { id: 'no', title: 'NO' }, - { id: 'sido', title: '시도' }, - { id: 'sigungu', title: '시군구' }, - { id: 'approvalYear', title: '승인년도' }, - { id: 'plantZoneGubun', title: '구분' }, - { id: 'reportGubun', title: '보고서구분' }, + { id: 'no', title: 'NO', sortable: true }, + { id: 'sido', title: '시도', sortable: true }, + { id: 'sigungu', title: '시군구', sortable: true }, + { id: 'approvalYear', title: '승인년도', sortable: true }, + { id: 'plantZoneGubun', title: '구분', sortable: true }, + { id: 'reportGubun', title: '보고서구분', sortable: true }, { id: 'fileName', title: '파일명' }, { id: 'regDate', title: '등록일자' }, ]; @@ -53,6 +55,51 @@ const getApprovalYearNumber = (value: string | number | undefined) => { return Number.isFinite(parsed) ? parsed : null; }; +const REPORT_COLLATOR = new Intl.Collator('ko', { numeric: true, sensitivity: 'base' }); +const REPORT_PAGE_SIZE = 10; +const REPORT_METADATA_CONCURRENCY = 8; +const REPORT_METADATA_TIMEOUT_MS = 8000; +const DISABLED_REPORT_SORT_KEYS = new Set(['fileName', 'regDate']); + +type ReportFileMetadata = Pick; + +const EMPTY_REPORT_METADATA: ReportFileMetadata = { + fileName: '-', + regDate: '', + fileSn: '', + filePath: '', + fileCount: 0, + downloadFileTitle: '', +}; + +// 파일 정보 추가 조회 전에도 존재하는 분류 트리 필드만 전체 데이터 기준으로 정렬한다. +const compareReportRows = (left: FlattenedFolder, right: FlattenedFolder, sort: UiTableSort) => { + const leftValue = left[sort.key]; + const rightValue = right[sort.key]; + const leftEmpty = leftValue === null || leftValue === undefined || String(leftValue).trim() === ''; + const rightEmpty = rightValue === null || rightValue === undefined || String(rightValue).trim() === ''; + + if (leftEmpty || rightEmpty) { + if (leftEmpty && rightEmpty) return Number(left.no ?? 0) - Number(right.no ?? 0); + return leftEmpty ? 1 : -1; + } + + const comparison = sort.key === 'approvalYear' + ? Number(getApprovalYearNumber(leftValue) ?? 0) - Number(getApprovalYearNumber(rightValue) ?? 0) + : REPORT_COLLATOR.compare(String(leftValue), String(rightValue)); + if (comparison === 0) return Number(left.no ?? 0) - Number(right.no ?? 0); + return sort.direction === 'asc' ? comparison : -comparison; +}; + +const resolveReportSort = (sort?: UiTableSort) => { + if (!sort) return undefined; + if (DISABLED_REPORT_SORT_KEYS.has(sort.key)) { + return undefined; + } + return sort; +}; + const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sggNm }) => { const [isResetPage, setIsResetPage] = useState(false); const [isResetSelection, setIsResetSelection] = useState(false); @@ -62,6 +109,8 @@ const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sg const [plantZoneGubun, setPlantZoneGubun] = useState('0'); const [reportGubun, setReportGubun] = useState('0'); const hasAutoFetchedRef = useRef(false); + const fileMetadataCacheRef = useRef>(new Map()); + const fileMetadataPromiseCacheRef = useRef>>(new Map()); const { tableData, @@ -73,16 +122,23 @@ const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sg setTableCount, setIsLoading, setYearFilter, - setCurPage + setCurPage, + sort, + setSort, } = useSearchModalStore(); const { ctpv, sgg } = useStore(); const { dcTree: reportOriginData, fetchDcTree } = useReportTabStore(); - const { REPORT_TYPES, flattenFolders, addFileName, downloadReportFile } = useReportTab(); + const { REPORT_TYPES, flattenFolders, getDcFiles, downloadReportFile } = useReportTab(); const flattenedReportData = useMemo(() => flattenFolders(reportOriginData), [reportOriginData]); + + useEffect(() => { + fileMetadataCacheRef.current.clear(); + fileMetadataPromiseCacheRef.current.clear(); + }, [reportOriginData]); const plantZoneOptions = useMemo(() => { const uniqueOptions = Array.from( new Set( @@ -113,9 +169,10 @@ const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sg setTableData([]); setTableCount(0); setCurPage(1); + setSort(undefined); setIsResetPage(true); setIsResetSelection(true); - }, [open, setCurPage, setTableCount, setTableData, setYearFilter]); + }, [open, setCurPage, setSort, setTableCount, setTableData, setYearFilter]); useEffect(() => { const newCtpvCd = ctpv.find((n) => n.text === ctpvNm)?.val || '0'; @@ -171,11 +228,94 @@ const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sg })(); }; - const fetchFilteredData = async (page = curPage) => { + const buildReportMetadata = (fileArr: ReportDataFile[]): ReportFileMetadata => { + if (fileArr.length === 0) { + return EMPTY_REPORT_METADATA; + } + + const primaryFile = fileArr[0]; + return { + fileName: fileArr.length === 1 + ? primaryFile?.fileTitle ?? '-' + : `${primaryFile?.fileTitle ?? '대표 파일'} 외 ${fileArr.length - 1}건`, + regDate: primaryFile?.regDt ?? '', + fileSn: primaryFile?.fileSn ?? '', + filePath: primaryFile?.filePath ?? '', + fileCount: fileArr.length, + downloadFileTitle: primaryFile?.fileTitle ?? '', + }; + }; + + const resolveReportMetadata = (item: FlattenedFolder) => { + const key = String(item.clsfSystmSn ?? ''); + if (!key) { + return Promise.resolve(EMPTY_REPORT_METADATA); + } + + const cached = fileMetadataCacheRef.current.get(key); + if (cached) { + return Promise.resolve(cached); + } + + const pending = fileMetadataPromiseCacheRef.current.get(key); + if (pending) { + return pending; + } + + const promise = Promise.race([ + getDcFiles(key), + new Promise((_, reject) => { + window.setTimeout(() => reject(new Error(`Report metadata timeout: ${key}`)), REPORT_METADATA_TIMEOUT_MS); + }), + ]) + .then((fileArr) => buildReportMetadata(fileArr)) + .catch((error) => { + console.warn('보고서 파일 메타데이터 조회 실패:', key, error); + return EMPTY_REPORT_METADATA; + }) + .then((metadata) => { + fileMetadataCacheRef.current.set(key, metadata); + fileMetadataPromiseCacheRef.current.delete(key); + return metadata; + }); + + fileMetadataPromiseCacheRef.current.set(key, promise); + return promise; + }; + + const enrichReportRows = async (data: FlattenedFolder[], concurrency = REPORT_METADATA_CONCURRENCY) => { + const missingRows = data.filter((item) => { + const key = String(item.clsfSystmSn ?? ''); + return key && !fileMetadataCacheRef.current.has(key); + }); + + if (missingRows.length > 0) { + let cursor = 0; + const workerCount = Math.min(concurrency, missingRows.length); + await Promise.all(Array.from({ length: workerCount }, async () => { + while (cursor < missingRows.length) { + const currentIndex = cursor; + cursor += 1; + const currentRow = missingRows[currentIndex]; + if (currentRow) { + await resolveReportMetadata(currentRow); + } + } + })); + } + + return data.map((item) => ({ + ...item, + ...(fileMetadataCacheRef.current.get(String(item.clsfSystmSn ?? '')) ?? EMPTY_REPORT_METADATA), + })); + }; + + const fetchFilteredData = async (page = curPage, requestedSort = sort) => { setIsLoading(true); setIsResetSelection(true); try { + const activeSort = resolveReportSort(requestedSort); let filterReportData = flattenedReportData; if (ctpvCd !== '0') { @@ -206,13 +346,29 @@ const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sg }); } - setTableCount(filterReportData.length || 0); + const totalCount = filterReportData.length || 0; + setTableCount(totalCount); - const pagedReportData = filterReportData - .slice((page - 1) * 10, page * 10) - .map((item, index) => ({ ...item, no: (page - 1) * 10 + index + 1 })); + const pageOffset = (page - 1) * REPORT_PAGE_SIZE; + const pageStart = (page - 1) * REPORT_PAGE_SIZE; + const pageEnd = page * REPORT_PAGE_SIZE; + + let reportRows: FlattenedFolder[]; + const sortedReportData = activeSort + ? [...filterReportData].sort((left, right) => compareReportRows(left, right, activeSort)) + : filterReportData; + + const pagedReportData = sortedReportData + .slice(pageStart, pageEnd) + .map((item, index) => ({ + ...item, + no: activeSort?.key === 'no' && activeSort.direction === 'desc' + ? sortedReportData.length - (pageOffset + index) + : pageOffset + index + 1, + })); + + reportRows = await enrichReportRows(pagedReportData); - const reportRows = await addFileName(pagedReportData); setTableData(reportRows || []); setShowTable(true); setIsResetSelection(false); @@ -225,6 +381,18 @@ const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sg } }; + // 보고서 분류 원본 전체를 다시 정렬한 뒤 첫 페이지를 표시한다. + const handleSortChange = (nextSort?: UiTableSort) => { + setSort(nextSort); + setIsResetPage(true); + setIsResetSelection(true); + if (curPage === 1) { + void fetchFilteredData(1, nextSort); + return; + } + setCurPage(1); + }; + return ( <>
@@ -298,6 +466,9 @@ const ReportTab: React.FC = ({ open, isSearch = true, ctpvNm, sg onPageChange={setCurPage} resetPageNo={isResetPage} resetSelection={isResetSelection} + curPage={curPage} + sort={sort} + onSortChange={handleSortChange} />
diff --git a/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx b/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx index 2fda54625..1f3f2686d 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx +++ b/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx @@ -26,12 +26,76 @@ import useSearchModal from './useSearchModal'; import useBasicTab from './useBasicTab'; import useOperationTab from './useOperationTab'; import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; +import type { UiHeadProps, UiTableSort } from '@common/business_components/ui'; type SearchModalProps = { renderMode?: 'launcher' | 'popup'; autoSearchReady?: boolean; }; +// 기본정보 표시 필드 중 GeoServer 원천 속성으로 정확히 정렬할 수 있는 필드만 매핑한다. +const BASIC_SORT_KEYS: Record> = { + tb_ms_swage_ppl: { + rowNumber: 'rowNumber', + fcltMngNo: 'fclt_mng_no', instMngNo: 'inst_mng_no', instlYmd: 'instl_ymd', + ctpvNm: 'pbadms_emd_cd', + sggNm: 'pbadms_emd_cd', swtrmt_zone_nm: 'swtrmt_zone_nm', usgCd: 'usg_cd', + pdmtVl: 'pdmt_vl', prlgVl: 'prlg_vl', tmptDpth: 'tmpt_dpth', + tmptMhlMngNo: 'tmpt_mhl_mng_no', scendDpth: 'scend_dpth', + scendMhlMngNo: 'scend_mhl_mng_no', vrtcLen: 'vrtc_len', wdthLen: 'wdth_len', + fcltyShpCd: 'fclty_shp_cd', mqltCd: 'mqlt_cd', + }, + tb_il_swage_prcs_fclt: { + rowNumber: 'rowNumber', + swtrmtFcltNm: 'swtrmt_fclt_nm', fcltCpct: 'fclt_cpct', rgnExpln: 'rgn_expln', + oprtngStrtYmd: 'oprtng_strt_ymd', laeNm: 'lae_nm', sareaNm: 'sarea_nm', + rvrNm: 'rvr_nm', dschgWtstNm: 'dschg_wtst_nm', swtrmtPpltnCnt: 'swtrmt_ppltn_cnt', + rprsMsrcCn: 'rprs_msrc_cn', + }, + tb_ms_swage_prcs_zone: { + rowNumber: 'rowNumber', + swtrmtZoneNm: 'swtrmt_zone_nm', ctpvNm: 'ctpv_nm', sggNm: 'sgg_nm', + swtrmtFcltNm: 'swtrmt_fclt_nm', + }, + tb_ms_swage_mhl: { + rowNumber: 'rowNumber', + fcltMngNo: 'fclt_mng_no', instlYmd: 'instl_ymd', swageMhlDiamVl: 'swage_mhl_diam_vl', + ctpvNm: 'pbadms_emd_cd', sggNm: 'pbadms_emd_cd', usgCd: 'usg_cd', + wdthLen: 'wdth_len', vrtcLen: 'vrtc_len', altdVl: 'altd_vl', dpthlVl: 'dpthl_vl', + }, + tb_ms_swage_pmstn: { + rowNumber: 'rowNumber', + fcltMngNo: 'fclt_mng_no', swagePmstnNm: 'swage_pmstn_nm', instlYmd: 'instl_ymd', + ctpvNm: 'pbadms_emd_cd', sggNm: 'pbadms_emd_cd', + dailyPrcsCpct: 'daily_prcs_cpct', maxImpndCpct: 'max_impnd_cpct', altdVl: 'altd_vl', + wtlvVl: 'wtlv_vl', exclnWpmpAbltVl: 'excln_wpmp_ablt_vl', + }, + tb_ms_stofcbr: { + rowNumber: 'rowNumber', + fcltMngNo: 'fclt_mng_no', mapshtNo: 'mapsht_no', instlYmd: 'instl_ymd', + ctpvNm: 'pbadms_emd_cd', sggNm: 'pbadms_emd_cd', + worflwWallHgt: 'worflw_wall_hgt', swageMhlMngNo: 'swage_mhl_mng_no', drtnAngl: 'drtn_angl', + }, +}; + +const OPERATION_LIST_SORT_KEYS: Record = { + rowNumber: 'rowNumber', bizInfoNm: 'bizInfoNm', flfmtBgngYmd: 'flfmtBgngYmd', + flfmtEndYmd: 'flfmtEndYmd', flfmtYr: 'flfmtYr', bizTypeCd: 'bizTypeCd', bizAmt: 'bizAmt', +}; + +const OPERATION_DETAIL_SORT_KEYS: Record = { + rowNumber: 'rowNumber', + fcltMngNo: 'fclt_mng_no', + swtrmt_zone_nm: 'swtrmt_zone_nm', + usgCd: 'usg_cd', + mqltCd: 'mqlt_cd', + pdmtVl: 'pdmt_vl', + prlgVl: 'prlg_vl', + instlYmd: 'instl_ymd', + strc_stts_grd: 'strc_stts_grd', + oper_stts_grd: 'oper_stts_grd', +}; + const SearchModal: React.FC = ({ renderMode = 'launcher', autoSearchReady = false }) => { const { showTable, @@ -47,8 +111,11 @@ const SearchModal: React.FC = ({ renderMode = 'launcher', auto setActiveTab, setCurPage, setDetailCurPage, setIsResetPage, setIsResetDetailPage, + setIsResetSelection, setDetailBizId, setIsResetDetailSelection, + sort, detailSort, + setSort, setDetailSort, resetFilters } = useSearchModalStore(); const { TABS, viewMultipleFeature, fetchFilteredData } = useSearchModal(); @@ -69,31 +136,71 @@ const SearchModal: React.FC = ({ renderMode = 'launcher', auto const hasAutoSearchedRef = useRef(false); const hasInitializedTabAutoSearchRef = useRef(false); - const dynamicHeadlist: { id: string, title: string }[] = useMemo(() => { + const dynamicHeadlist: UiHeadProps[] = 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; - return []; + const headlist = basicType === 'tb_ms_swage_ppl' ? MsSwagePplCol2 + : basicType === 'tb_il_swage_prcs_fclt' ? SwtrmtFcltCol2 + : basicType === 'tb_ms_swage_prcs_zone' ? SwtrmtPrcsZoneCol2 + : basicType === 'tb_ms_swage_mhl' ? MsSwageMhlCol2 + : basicType === 'tb_ms_swage_pmstn' ? ExclnSewagePumpCol2 + : basicType === 'tb_ms_stofcbr' ? stofcbrCol2 : []; + const sortKeys = BASIC_SORT_KEYS[basicType] ?? {}; + return headlist.map((head) => sortKeys[head.id] + ? { ...head, sortable: true, sortKey: sortKeys[head.id] } + : head); + } + if (activeTab === 'operation') { + return OPERATION_TABLE_HEADLIST.map((head) => OPERATION_LIST_SORT_KEYS[head.id] + ? { ...head, sortable: true, sortKey: OPERATION_LIST_SORT_KEYS[head.id] } + : head); } - if (activeTab === 'operation') return OPERATION_TABLE_HEADLIST; return []; }, [activeTab, basicType, workType, OPERATION_TABLE_HEADLIST]); - const dynamicHeadlist2: { id: string, title: string }[] = useMemo(() => { + const dynamicHeadlist2: UiHeadProps[] = 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; + const headlist = workType === WORK_TYPES[0]?.val ? MAINTENANACE_TABLE_HEADLIST + : workType === WORK_TYPES[1]?.val ? CLEANING_TABLE_HEADLIST + : workType === WORK_TYPES[2]?.val ? INSPECTION_TABLE_HEADLIST : []; + return headlist.map((head) => OPERATION_DETAIL_SORT_KEYS[head.id] + ? { ...head, sortable: true, sortKey: OPERATION_DETAIL_SORT_KEYS[head.id] } + : head); } return []; }, [activeTab, workType, WORK_TYPES, MAINTENANACE_TABLE_HEADLIST, CLEANING_TABLE_HEADLIST, INSPECTION_TABLE_HEADLIST]); + // 목록 정렬 변경 시 첫 페이지에서 같은 검색조건으로 다시 조회한다. + const handleSortChange = (nextSort?: UiTableSort) => { + setSort(nextSort); + setIsResetPage(true); + setIsResetSelection(true); + setIsResetDetailPage(true); + setDetailSort(undefined); + setDetailBizId(0); + if (curPage === 1) { + void fetchFilteredData(nextSort); + return; + } + setCurPage(1); + }; + + // 유지관리 상세 정렬은 상단 사업 목록과 분리하고 선택된 사업 범위만 재조회한다. + const handleDetailSortChange = (nextSort?: UiTableSort) => { + setDetailSort(nextSort); + setIsResetDetailPage(true); + setIsResetDetailSelection(true); + if (!detailBizId) { + return; + } + if (detailCurPage === 1) { + void handleOperationPageNation(Number(detailBizId), nextSort); + return; + } + setDetailCurPage(1); + }; + useEffect(() => { if (renderMode !== 'launcher' || !isSearchModalOpen) return; @@ -179,6 +286,8 @@ const SearchModal: React.FC = ({ renderMode = 'launcher', auto resetPageNo={isResetPage} resetSelection={isResetSelection} curPage={curPage} + sort={sort} + onSortChange={handleSortChange} headButton={
@@ -207,6 +316,8 @@ const SearchModal: React.FC = ({ renderMode = 'launcher', auto resetPageNo={isResetPage} resetSelection={isResetSelection} curPage={curPage} + sort={sort} + onSortChange={handleSortChange} headButton={ } @@ -229,6 +340,8 @@ const SearchModal: React.FC = ({ renderMode = 'launcher', auto resetPageNo={isResetDetailPage} resetSelection={isResetDetailSelection} curPage={detailCurPage} + sort={detailSort} + onSortChange={handleDetailSortChange} headButton={
diff --git a/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts b/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts index d914d2dbb..856b68661 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts @@ -33,12 +33,23 @@ import { swagePmstnCqlFilters } from './swagePmstn'; import { stofcbrCqlFilters } from './stofcbr'; import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage'; import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; +import type { UiTableSort } from '@common/business_components/ui'; const isPopupWindow = () => typeof window !== "undefined" && !!window.opener && window.location.pathname.startsWith("/popup/"); +const DISABLED_BASIC_SORT_KEYS = new Set(['flow_direction_basis']); + +const resolveBasicSort = (basicType: string, sort?: UiTableSort) => { + if (!sort) return undefined; + if (basicType === 'tb_ms_swage_ppl' && DISABLED_BASIC_SORT_KEYS.has(sort.key)) { + return undefined; + } + return sort; +}; + export default function useBasicTab() { const { @@ -54,14 +65,16 @@ export default function useBasicTab() { const { ctpv, sgg } = useStore(); - const fetchBasicFilteredData = async () => { + const fetchBasicFilteredData = async (requestedSort?: UiTableSort) => { + const activeSort = resolveBasicSort(basicType, requestedSort ?? useSearchModalStore.getState().sort); let cqlFilters: string[] = []; const queryParams = new URLSearchParams({ lyrTypeNm: basicType, page: `${curPage - 1}`, size: '10', - sort: 'string', }); + if (basicType === 'tb_ms_swage_ppl') queryParams.set('includeFlowDirectionBasis', 'true'); + if (activeSort) queryParams.set('sort', `${activeSort.key},${activeSort.direction}`); if (basicType === 'tb_ms_swage_ppl') cqlFilters = swagePplCqlFilters(); // 하수관로 else if (basicType === 'tb_il_swage_prcs_fclt') cqlFilters = swagePrcsFcltCqlFilters(); // 하수처리시설 @@ -72,6 +85,7 @@ export default function useBasicTab() { if (cqlFilters.length > 0) queryParams.append('cqlFiter', cqlFilters.join(' and ')); const res = await getData(`v1/fclts?${queryParams.toString()}`); + const totalCount = Number(res.count ?? 0); // 🎯 각 아이템을 개별적으로 포맷팅하면서 일련번호 추가 const dataWithRowNumber = (res.data || []).map((item: any, index: number) => { @@ -103,7 +117,9 @@ export default function useBasicTab() { // 추가 처리 및 일련번호 추가 return { ...formattedItem, - rowNumber: (curPage - 1) * 10 + index + 1, // 1부터 시작하는 일련번호 추가 + rowNumber: activeSort?.key === 'rowNumber' && activeSort.direction === 'desc' + ? totalCount - ((curPage - 1) * 10 + index) + : (curPage - 1) * 10 + index + 1, scsYn: item.scsYn === null || item.scsYn === undefined ? 'N' : item?.scsYn, // scsYn이 null이면 'N'으로 변경 mqltCd: matchedMqltCode ? matchedMqltCode.text : item?.mqltCd, usgCd: matchedMhlUsgCode ? matchedMhlUsgCode.text : (usgCdObj ? usgCdObj.text : item?.usgCd), @@ -115,8 +131,7 @@ export default function useBasicTab() { }); setTableData(dataWithRowNumber); - // console.log(tableData); - setTableCount(res.count || []); + setTableCount(totalCount); }; // 기본정보 테이블 cellClick 핸들러 @@ -169,6 +184,9 @@ export default function useBasicTab() { lyrTypeNm: basicType, size: '10000', }); + if (basicType === 'tb_ms_swage_ppl') queryParams.set('includeFlowDirectionBasis', 'true'); + const activeSort = resolveBasicSort(basicType, useSearchModalStore.getState().sort); + if (activeSort) queryParams.set('sort', `${activeSort.key},${activeSort.direction}`); let cqlFilters: string[] = []; let headerMapping: { [key: string]: string } = {}; @@ -230,7 +248,7 @@ export default function useBasicTab() { const ctpvMap = new Map(ctpv.map(code => [code.val?.trim(), code.text])); const sggMap = new Map(sgg.map(code => [code.val?.trim(), code.text])); - const mappedContent = (res.data || []).map((item: any) => { + let mappedContent = (res.data || []).map((item: any) => { const formattedItem = formatDisplayData(item); let ctpvCd = item?.ctpvCd ?? formattedItem?.ctpvCd ?? ''; diff --git a/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts b/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts index 3d80b54d9..e6f3e9974 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts @@ -25,12 +25,29 @@ import useSearchModalStore from '@/store/useSearchModalStore'; import useStore from '@/store/useStore'; import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage'; import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; +import type { UiTableSort } from '@common/business_components/ui'; const isPopupWindow = () => typeof window !== "undefined" && !!window.opener && window.location.pathname.startsWith("/popup/"); +// Spring Pageable 단일 정렬 계약을 정보검색 요청 파라미터에 추가한다. +const appendSortParameter = ( + request: Record, + sort: UiTableSort | undefined, + scope: 'list' | 'facility', +) => { + if (!sort) return; + const requestSort = sort.key === 'rowNumber' + ? { + key: scope === 'list' ? 'bizId' : 'ast_id', + direction: sort.direction === 'asc' ? 'desc' : 'asc', + } + : sort; + request.sort = `${requestSort.key},${requestSort.direction}`; +}; + export default function useOperationTab() { const OPERATION_TABLE_SIZE = 5; @@ -47,6 +64,8 @@ export default function useOperationTab() { setTableData, setDetailTableData, setTableCount, setDetailTableCount, setDetailCurPage, + sort, detailSort, + setDetailSort, setIsLoading, setIsDetailLoading, setIsResetDetailPage, setIsResetDetailSelection, @@ -136,23 +155,27 @@ export default function useOperationTab() { // JSON.parse 사용으로 문자열 -> 배열 변환 RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"')); + appendSortParameter(RequestDTO, sort, 'list'); const params = new URLSearchParams(RequestDTO).toString(); const res = await getData(`v1/bizInfo?${params}`); + const totalElements = Number(res.totalElements ?? 0); const mappedContent = (res.content || []).map((item: any, index: number) => { const formattedItem = formatDisplayData(item); const matched = BIZ_TY_CD.find(code => code.lwpoCd === item.bizTypeCd); return { ...formattedItem, - rowNumber: (curPage - 1) * OPERATION_TABLE_SIZE + index + 1, // 1부터 시작하는 일련번호 추가 + rowNumber: sort?.key === 'rowNumber' && sort.direction === 'desc' + ? totalElements - ((curPage - 1) * OPERATION_TABLE_SIZE + index) + : (curPage - 1) * OPERATION_TABLE_SIZE + index + 1, bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd, }; }); setTableData(mappedContent || []); - setTableCount(res.totalElements || 0); + setTableCount(totalElements); setDetailTableData([]); setDetailTableCount(0); }; @@ -162,6 +185,7 @@ export default function useOperationTab() { setIsResetDetailPage(true); // 페이지를 1로 초기화 setIsResetDetailSelection(true); // 선택 초기화 setIsResetDetailPage(true); // 디테일 페이지를 초기화 + setDetailSort(undefined); // 새 사업을 선택하면 이전 상세 정렬을 해제한다. if (typeof index === "number") { setIsDetailLoading(true); @@ -169,7 +193,7 @@ export default function useOperationTab() { setDetailCurPage(1); // 상세 페이지 초기화 try { const RequestDTO: Record = { - page: `${detailCurPage - 1}`, + page: '0', size: `${OPERATION_TABLE_SIZE}`, bizId: tableData[index % OPERATION_TABLE_SIZE].bizId, }; @@ -185,7 +209,7 @@ export default function useOperationTab() { const mqltCdObj = MS_SWAGE_PPL_CODES.find(code => code.val === item?.mqltCd?.trim()); return { ...formattedItem, - rowNumber: (detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex + 1, + rowNumber: detailIndex + 1, usgCd: usgCdObj ? usgCdObj.text : item.usgCd, mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd, swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : '-', @@ -232,7 +256,7 @@ export default function useOperationTab() { }; // 유지관리 정보 디테일 테이블 페이지네이션 핸들러 - const handleOperationPageNation = async (index?: number | undefined) => { + const handleOperationPageNation = async (index?: number | undefined, requestedSort?: UiTableSort) => { if (typeof index === "number") { setIsDetailLoading(true); try { @@ -241,7 +265,10 @@ export default function useOperationTab() { size: `${OPERATION_TABLE_SIZE}`, bizId: String(detailBizId), }; + const activeDetailSort = requestedSort ?? useSearchModalStore.getState().detailSort; + appendSortParameter(RequestDTO, activeDetailSort, 'facility'); const res = await getData(`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`); + const totalElements = Number(res.count ?? 0); const mappedData = (res.data || []).map((item: any, detailIndex: number) => { const formattedItem = formatDisplayData(item); @@ -250,7 +277,9 @@ export default function useOperationTab() { const mqltCdObj = MS_SWAGE_PPL_CODES.find(code => code.val === item?.mqltCd?.trim()); return { ...formattedItem, - rowNumber: (detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex + 1, + rowNumber: activeDetailSort?.key === 'rowNumber' && activeDetailSort.direction === 'desc' + ? totalElements - ((detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex) + : (detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex + 1, usgCd: usgCdObj ? usgCdObj.text : item.usgCd, mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd, swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : '-', @@ -258,7 +287,7 @@ export default function useOperationTab() { }); setDetailTableData(mappedData); - setDetailTableCount(res.count || 0); + setDetailTableCount(totalElements); } catch (error) { console.error('유지관리 정보 페이지네이션 에러:', error); } finally { @@ -309,6 +338,7 @@ export default function useOperationTab() { // JSON.parse 사용으로 문자열 -> 배열 변환 RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"')); + appendSortParameter(RequestDTO, sort, 'list'); const params = new URLSearchParams(RequestDTO).toString(); const res = await getData(`v1/bizInfo?${params}`); @@ -385,6 +415,7 @@ export default function useOperationTab() { size: '100', bizId: String(detailBizId), }; + appendSortParameter(RequestDTO, detailSort, 'facility'); const res = await getData(`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`); diff --git a/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts b/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts index 578c3ac2a..796064bd5 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts @@ -21,6 +21,7 @@ import useBasicTab from './useBasicTab'; import useOperationTab from './useOperationTab'; import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage'; import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; +import type { UiTableSort } from '@common/business_components/ui'; const isPopupWindow = () => typeof window !== 'undefined' && @@ -54,7 +55,7 @@ export default function useSearchModal() { ]; // 검색 API 호출 함수 - const fetchFilteredData = async () => { + const fetchFilteredData = async (requestedSort?: UiTableSort) => { const { activeTab: currentTab, curPage, @@ -73,7 +74,9 @@ export default function useSearchModal() { setIsLoading, setIsResetSelection, setIsResetDetailSelection, + sort, } = useSearchModalStore.getState(); + const activeSort = requestedSort ?? sort; setIsLoading(true); setIsResetSelection(true); @@ -81,7 +84,7 @@ export default function useSearchModal() { try { if (currentTab === 'basic') { - await fetchBasicFilteredData(); + await fetchBasicFilteredData(activeSort); } else if (currentTab === 'operation') { const requestDto: Record = { page: `${curPage - 1}`, @@ -105,22 +108,31 @@ export default function useSearchModal() { requestDto.fromflfmtEndYmd = startDate; requestDto.toflfmtEndYmd = endDate; requestDto.workTypes = JSON.parse(workType.replace(/'/g, '"')); + if (activeSort) { + const requestSort = activeSort.key === 'rowNumber' + ? { key: 'bizId', direction: activeSort.direction === 'asc' ? 'desc' : 'asc' } + : activeSort; + requestDto.sort = `${requestSort.key},${requestSort.direction}`; + } const params = new URLSearchParams(requestDto).toString(); const res = await getData(`v1/bizInfo?${params}`); + const totalElements = Number(res.totalElements ?? 0); const mappedContent = (res.content || []).map((item: any, index: number) => { const formattedItem = formatDisplayData(item); const matched = BIZ_TY_CD.find((code) => code.lwpoCd === item.bizTypeCd); return { ...formattedItem, - rowNumber: (curPage - 1) * OPERATION_TABLE_SIZE + index + 1, + rowNumber: activeSort?.key === 'rowNumber' && activeSort.direction === 'desc' + ? totalElements - ((curPage - 1) * OPERATION_TABLE_SIZE + index) + : (curPage - 1) * OPERATION_TABLE_SIZE + index + 1, bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd, }; }); setTableData(mappedContent || []); - setTableCount(res.totalElements || 0); + setTableCount(totalElements); setDetailTableData([]); setDetailTableCount(0); } diff --git a/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx b/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx index 464e372d5..36ea376b4 100644 --- a/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx @@ -32,6 +32,7 @@ import { getUserInfo } from "@common/components/api"; import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; import { SearchDatePicker } from '../_components/SearchDatePicker'; +import type { UiTableSort } from '@common/business_components/ui'; const Page = () => { @@ -52,6 +53,7 @@ const Page = () => { const [isLoading, setIsLoading] = useState(true) //로딩 상태 const [resetSelection, setResetSelection] = useState(false); // 선택 초기화 상태 const [isResetPage, setIsResetPage] = useState(false); + const [sort, setSort] = useState(); const { data: userInfo } = useQuery({ queryKey: [ENDPOINT.USER_SERVICE.USERS], @@ -59,11 +61,6 @@ const Page = () => { enabled: true, }); - useEffect(() => { - setCurPage(1) - fetchData() - }, []) - useEffect(() => { const handleMessage = (event: MessageEvent) => { if (event.origin !== window.location.origin) return; @@ -73,7 +70,7 @@ const Page = () => { window.addEventListener("message", handleMessage); return () => window.removeEventListener("message", handleMessage); - }, [fromDate, toDate, pjNm, curPage]); + }, [fromDate, toDate, pjNm, curPage, sort]); // 페이지 변경 시마다 데이터 fetch useEffect(() => { @@ -83,13 +80,16 @@ const Page = () => { // 조회 버튼 클릭 시 1페이지로 이동 및 데이터 초기화 const handleSearch: () => Promise = async () => { - setCurPage(1); // 페이지를 1로 초기화 setIsResetPage(true); // 페이지를 초기화 - await fetchData(); + if (curPage === 1) { + await fetchData(1, sort); + return; + } + setCurPage(1); // 페이지 변경 effect에서 한 번만 조회 }; // 조회 - const fetchData = async () => { + const fetchData = async (page = curPage, activeSort = sort) => { setIsLoading(true) setResetSelection(true); // searchCmmnCd('SITEKNDCD') @@ -99,13 +99,18 @@ const Page = () => { toRegDt: formatDate(toDate), sbmrgPrevntBizNm: pjNm, auth: AUTH, - page: curPage - 1, - size: '10' + page: page - 1, + size: '10', + sort: activeSort ? `${activeSort.key},${activeSort.direction}` : undefined, }) // 일련번호를 추가한 데이터로 변환 (전체 데이터 기준으로 일련번호 계산) + const descendingSequence = + activeSort?.key === 'sbmrgPrevntBizSn' && activeSort.direction === 'desc'; const dataWithRowNumber = (data || []).map((item: any, index: number) => ({ ...item, - rowNumber: (curPage - 1) * 10 + index + 1, // 페이지 기준 일련번호 계산 + rowNumber: descendingSequence + ? total - ((page - 1) * 10 + index) + : (page - 1) * 10 + index + 1, })); setTableData(dataWithRowNumber) setTotalData(total) @@ -120,6 +125,17 @@ const Page = () => { } } + const handleSortChange = (nextSort?: UiTableSort) => { + setSort(nextSort); + setIsResetPage(true); + setResetSelection(true); + if (curPage === 1) { + void fetchData(1, nextSort); + return; + } + setCurPage(1); + }; + // 파일 다운로드 const isChckFile = () => { if (fileNm === '' || fileNm === undefined) { @@ -253,6 +269,8 @@ const Page = () => { onPageChange={setCurPage} resetSelection={resetSelection} resetPageNo={isResetPage} + sort={sort} + onSortChange={handleSortChange} /> diff --git a/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx b/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx index 4b35069af..6edb4c1be 100644 --- a/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx +++ b/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx @@ -13,11 +13,13 @@ * - 첨부파일 다운로드(파일ID): GET /attach-service/api/v1/download/{atflId} (attach-service) */ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo } from 'react' import LoadingOverlay from '@/components/ui/LoadingBar' import { UITab, UiTable } from '@common/business_components/ui' import * as XLSX from 'xlsx'; import { getBlobData } from '@/components/lib/api'; +import type { UiTableSort } from '@common/business_components/ui'; +import { makeSortableHeaders, sortLocalTableRows } from '../tableSort'; // import { Table, TableBody, TableHeader } from "@common/components/ui"; // import { HeadMakeFloodPre } from './headMakeFloodPre'; // import { InputTableBody } from './inputTableBody'; @@ -40,6 +42,17 @@ const TableModal: React.FC = ({ fileNm, atflId }) => { const [resultTableHeader, setResultTableHeader] = useState<{ id: string; title: string, mergeCell?: boolean }[]>([]) //테이블 헤더 const [resultTableData, setResultTableData] = useState([{}]) //테이블 데이터 const [isLoading, setIsLoading] = useState(false) //로딩 상태 + const [managementSort, setManagementSort] = useState(); + const [resultSort, setResultSort] = useState(); + + const sortedManagementTableData = useMemo( + () => sortLocalTableRows(managementTableData, managementSort), + [managementSort, managementTableData], + ); + const sortedResultTableData = useMemo( + () => sortLocalTableRows(resultTableData, resultSort), + [resultSort, resultTableData], + ); useEffect(() => { isChckFile(); @@ -86,7 +99,7 @@ const TableModal: React.FC = ({ fileNm, atflId }) => { title: String(value).replace(/\r\n/g, '\n') })); - setManagementTableHeader(filteredManagementHeader); + setManagementTableHeader(makeSortableHeaders(filteredManagementHeader)); const managementData = Array.isArray(jsonDataBySheet['관리SHEET']) ? jsonDataBySheet['관리SHEET'].slice(8) @@ -108,7 +121,7 @@ const TableModal: React.FC = ({ fileNm, atflId }) => { title: '', mergeCell: false }); - setResultTableHeader(dynamicResultHeader); + setResultTableHeader(makeSortableHeaders(dynamicResultHeader)); const resultData = Array.isArray(jsonDataBySheet['운영관리현황 점검결과']) ? jsonDataBySheet['운영관리현황 점검결과'].slice(3) @@ -229,13 +242,15 @@ const TableModal: React.FC = ({ fileNm, atflId }) => {
{ }} className="whitespace-nowrap" + sort={managementSort} + onSortChange={setManagementSort} />
@@ -248,13 +263,15 @@ const TableModal: React.FC = ({ fileNm, atflId }) => { { }} + sort={resultSort} + onSortChange={setResultSort} /> {/* diff --git a/apps/sn/app/(with-base-layout)/oper/index.ts b/apps/sn/app/(with-base-layout)/oper/index.ts index b04f724bc..4ec8fcdae 100644 --- a/apps/sn/app/(with-base-layout)/oper/index.ts +++ b/apps/sn/app/(with-base-layout)/oper/index.ts @@ -10,19 +10,19 @@ * - 동적 테이블 렌더링을 위한 ID와 타이틀 매핑 */ export const tableHeadlist = [ - { id: 'rowNumber', title: '일련번호' }, - { id: 'sbmrgPrevntBizNm', title: '파일명' }, - { id: 'mngYr', title: '년도' }, - { id: 'mngQu', title: '분기' }, - { id: 'regDt', title: '등록 일자' }, + { id: 'rowNumber', title: '일련번호', sortable: true, sortKey: 'sbmrgPrevntBizSn' }, + { id: 'sbmrgPrevntBizNm', title: '파일명', sortable: true }, + { id: 'mngYr', title: '년도', sortable: true }, + { id: 'mngQu', title: '분기', sortable: true }, + { id: 'regDt', title: '등록 일자', sortable: true }, { id: 'rgtrNm', title: '등록자' } ] export const tableHeadlistOldPp = [ - { id: 'rowNumber', title: '일련번호' }, - { id: 'dcrpPplMtncBizNm', title: '파일명' }, - { id: 'mngYr', title: '년도' }, - { id: 'mngQu', title: '분기' }, - { id: 'regDt', title: '등록 일자' }, + { id: 'rowNumber', title: '일련번호', sortable: true, sortKey: 'dcrpPplMtncBizSn' }, + { id: 'dcrpPplMtncBizNm', title: '파일명', sortable: true }, + { id: 'mngYr', title: '년도', sortable: true }, + { id: 'mngQu', title: '분기', sortable: true }, + { id: 'regDt', title: '등록 일자', sortable: true }, { id: 'rgtrNm', title: '등록자' } -] \ No newline at end of file +] diff --git a/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx b/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx index 6fb88829c..a1aadf3f1 100644 --- a/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx @@ -32,6 +32,7 @@ import { getUserInfo } from "@common/components/api"; import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types'; import { SearchDatePicker } from '../_components/SearchDatePicker'; +import type { UiTableSort } from '@common/business_components/ui'; const Page = () => { @@ -50,6 +51,7 @@ const Page = () => { const [isLoading, setIsLoading] = useState(true) //로딩 상태 const [resetSelection, setResetSelection] = useState(false); // 선택 초기화 상태 const [isResetPage, setIsResetPage] = useState(false); + const [sort, setSort] = useState(); const { data: userInfo } = useQuery({ queryKey: [ENDPOINT.USER_SERVICE.USERS], @@ -57,11 +59,6 @@ const Page = () => { enabled: true, }); - useEffect(() => { - setCurPage(1) - fetchData() - }, []) - useEffect(() => { const handleMessage = (event: MessageEvent) => { if (event.origin !== window.location.origin) return; @@ -71,7 +68,7 @@ const Page = () => { window.addEventListener("message", handleMessage); return () => window.removeEventListener("message", handleMessage); - }, [fromDate, toDate, pjNm, curPage]); + }, [fromDate, toDate, pjNm, curPage, sort]); // 페이지 변경 시마다 데이터 fetch useEffect(() => { @@ -81,13 +78,16 @@ const Page = () => { // 조회 버튼 클릭 시 1페이지로 이동 및 데이터 초기화 const handleSearch: () => Promise = async () => { - setCurPage(1); // 페이지를 1로 초기화 setIsResetPage(true); // 페이지를 초기화 - await fetchData(); + if (curPage === 1) { + await fetchData(1, sort); + return; + } + setCurPage(1); // 페이지 변경 effect에서 한 번만 조회 }; // 데이터 조회 - const fetchData = async () => { + const fetchData = async (page = curPage, activeSort = sort) => { setIsLoading(true) setResetSelection(true); try { @@ -96,13 +96,18 @@ const Page = () => { toRegDt: formatDate(toDate), dcrpPplMtncBizNm: pjNm, auth: AUTH, - page: curPage - 1, - size: '10' + page: page - 1, + size: '10', + sort: activeSort ? `${activeSort.key},${activeSort.direction}` : undefined, }) // 일련번호를 추가한 데이터로 변환 (전체 데이터 기준으로 일련번호 계산) + const descendingSequence = + activeSort?.key === 'dcrpPplMtncBizSn' && activeSort.direction === 'desc'; const dataWithRowNumber = (data || []).map((item: any, index: number) => ({ ...item, - rowNumber: (curPage - 1) * 10 + index + 1, // 페이지 기준 일련번호 계산 + rowNumber: descendingSequence + ? total - ((page - 1) * 10 + index) + : (page - 1) * 10 + index + 1, })); setTableData(dataWithRowNumber) setTotalData(total) @@ -117,6 +122,17 @@ const Page = () => { } } + const handleSortChange = (nextSort?: UiTableSort) => { + setSort(nextSort); + setIsResetPage(true); + setResetSelection(true); + if (curPage === 1) { + void fetchData(1, nextSort); + return; + } + setCurPage(1); + }; + // 파일 다운로드 const isChckFile = () => { if (fileNm === '' || fileNm === undefined) { @@ -249,6 +265,9 @@ const Page = () => { }} onPageChange={setCurPage} resetSelection={resetSelection} + resetPageNo={isResetPage} + sort={sort} + onSortChange={handleSortChange} /> diff --git a/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx b/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx index f22df820c..315cd3a1d 100644 --- a/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx +++ b/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx @@ -13,13 +13,15 @@ * - 첨부파일 다운로드(파일ID): GET /attach-service/api/v1/download/{atflId} (attach-service) */ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo } from 'react' import LoadingOverlay from '@/components/ui/LoadingBar' import { UITab, UiTable } from '@common/business_components/ui' import * as XLSX from 'xlsx'; import { HeadMakeColSpan } from '@common/business_components/ui/table/HeadMakeColSpan'; import { oldPpTable2HeadList, oldPpTable2NodeList, oldPpTable6HeadList, oldPpTable6NodeList } from './headList'; import { getBlobData } from '@/components/lib/api'; +import type { UiHeadProps, UiTableSort } from '@common/business_components/ui'; +import { getNextTableSort, makeSortableHeaders, sortLocalTableRows } from '../tableSort'; const TABS = [ { val: "1", text: "raw data" }, @@ -30,6 +32,11 @@ const TABS = [ { val: "6", text: "4.정비사업최종" }, ]; +const sortableTable3HeadList = makeSortableHeaders(oldPpTable2HeadList); +const sortableTable3NodeList = makeSortableHeaders(oldPpTable2NodeList); +const sortableTable6HeadList = makeSortableHeaders(oldPpTable6HeadList); +const sortableTable6NodeList = makeSortableHeaders(oldPpTable6NodeList); + interface TableModalProps { fileNm: string; atflId: string; @@ -49,6 +56,27 @@ const TableModal: React.FC = ({ fileNm, atflId }) => { const [tableData5, setTableData5] = useState([{}]) const [tableData6, setTableData6] = useState([{}]) const [isLoading, setIsLoading] = useState(false) + const [tableSorts, setTableSorts] = useState>({}); + + const sortedTableData = useMemo( + () => ({ + '1': sortLocalTableRows(tableData1, tableSorts['1']), + '2': sortLocalTableRows(tableData2, tableSorts['2']), + '3': sortLocalTableRows(tableData3, tableSorts['3']), + '4': sortLocalTableRows(tableData4, tableSorts['4']), + '5': sortLocalTableRows(tableData5, tableSorts['5']), + '6': sortLocalTableRows(tableData6, tableSorts['6']), + }), + [tableData1, tableData2, tableData3, tableData4, tableData5, tableData6, tableSorts], + ); + + const setTableSort = (tab: string, nextSort?: UiTableSort) => { + setTableSorts((current) => ({ ...current, [tab]: nextSort })); + }; + + const handleCustomHeadSort = (tab: string, head: UiHeadProps) => { + setTableSort(tab, getNextTableSort(head, tableSorts[tab])); + }; useEffect(() => { isChckFile(); @@ -111,7 +139,7 @@ const TableModal: React.FC = ({ fileNm, atflId }) => { id: "rowNumber", title: '일련번호' }); - setTableHeader1(filteredHeader1); + setTableHeader1(makeSortableHeaders(filteredHeader1)); const data1 = Array.isArray(jsonDataBySheet['raw data']) ? jsonDataBySheet['raw data'].slice(2) @@ -150,7 +178,7 @@ const TableModal: React.FC = ({ fileNm, atflId }) => { id: "rowNumber", title: '일련번호' }); - setTableHeader2(filteredHeader2); + setTableHeader2(makeSortableHeaders(filteredHeader2)); const data2 = Array.isArray(jsonDataBySheet['1.정밀조사']) ? jsonDataBySheet['1.정밀조사'].slice(2) @@ -193,7 +221,7 @@ const TableModal: React.FC = ({ fileNm, atflId }) => { id: "rowNumber", title: '일련번호' }); - setTableHeader4(filteredHeader4); + setTableHeader4(makeSortableHeaders(filteredHeader4)); const data4 = Array.isArray(jsonDataBySheet[' 3.정비사업']) ? jsonDataBySheet[' 3.정비사업'].slice(2) @@ -225,7 +253,7 @@ const TableModal: React.FC = ({ fileNm, atflId }) => { id: "rowNumber", title: '일련번호' }); - setTableHeader5(filteredHeader5); + setTableHeader5(makeSortableHeaders(filteredHeader5)); const data5 = Array.isArray(jsonDataBySheet['4.정비사업요약']) ? jsonDataBySheet['4.정비사업요약'].slice(3) @@ -361,12 +389,14 @@ const TableModal: React.FC = ({ fileNm, atflId }) => {
{ }} + sort={tableSorts['1']} + onSortChange={(nextSort) => setTableSort('1', nextSort)} />
@@ -380,13 +410,15 @@ const TableModal: React.FC = ({ fileNm, atflId }) => {
{ }} + sort={tableSorts['2']} + onSortChange={(nextSort) => setTableSort('2', nextSort)} />
@@ -400,15 +432,21 @@ const TableModal: React.FC = ({ fileNm, atflId }) => {
{ }} + sort={tableSorts['3']} + onSortChange={(nextSort) => setTableSort('3', nextSort)} > - + handleCustomHeadSort('3', head)} + />
@@ -422,13 +460,15 @@ const TableModal: React.FC = ({ fileNm, atflId }) => {
{ }} + sort={tableSorts['4']} + onSortChange={(nextSort) => setTableSort('4', nextSort)} />
@@ -442,13 +482,15 @@ const TableModal: React.FC = ({ fileNm, atflId }) => {
{ }} + sort={tableSorts['5']} + onSortChange={(nextSort) => setTableSort('5', nextSort)} />
@@ -462,15 +504,21 @@ const TableModal: React.FC = ({ fileNm, atflId }) => {
{ }} + sort={tableSorts['6']} + onSortChange={(nextSort) => setTableSort('6', nextSort)} > - + handleCustomHeadSort('6', head)} + />
diff --git a/apps/sn/app/(with-base-layout)/oper/tableSort.ts b/apps/sn/app/(with-base-layout)/oper/tableSort.ts new file mode 100644 index 000000000..e534c3607 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/tableSort.ts @@ -0,0 +1,71 @@ +import type { UiHeadProps, UiTableSort } from "@common/business_components/ui"; + +const TABLE_SORT_COLLATOR = new Intl.Collator("ko-KR", { + numeric: true, + sensitivity: "base", +}); + +const isEmptySortValue = (value: unknown) => + value === null || + value === undefined || + String(value).trim() === "" || + String(value).trim() === "-"; + +const toComparableNumber = (value: unknown) => { + const normalized = String(value ?? "") + .trim() + .replace(/,/g, "") + .replace(/%$/, ""); + + if (!/^-?\d+(?:\.\d+)?$/.test(normalized)) return undefined; + const number = Number(normalized); + return Number.isFinite(number) ? number : undefined; +}; + +/** 업로드된 엑셀의 실제 데이터 컬럼만 공통 UiTable 정렬 대상으로 만든다. */ +export const makeSortableHeaders = (headers: T[]) => + headers.map((header) => ({ ...header, sortable: true })); + +/** 서버 페이징이 없는 엑셀 상세 그리드의 전체 행을 안정 정렬한다. */ +export const sortLocalTableRows = >( + rows: T[], + sort?: UiTableSort, +) => { + if (!sort) return rows; + + return rows + .map((row, originalIndex) => ({ row, originalIndex })) + .sort((leftEntry, rightEntry) => { + const leftValue = leftEntry.row[sort.key]; + const rightValue = rightEntry.row[sort.key]; + const leftEmpty = isEmptySortValue(leftValue); + const rightEmpty = isEmptySortValue(rightValue); + + if (leftEmpty || rightEmpty) { + if (leftEmpty && rightEmpty) return leftEntry.originalIndex - rightEntry.originalIndex; + return leftEmpty ? 1 : -1; + } + + const leftNumber = toComparableNumber(leftValue); + const rightNumber = toComparableNumber(rightValue); + const comparison = + leftNumber !== undefined && rightNumber !== undefined + ? leftNumber - rightNumber + : TABLE_SORT_COLLATOR.compare(String(leftValue), String(rightValue)); + + if (comparison === 0) return leftEntry.originalIndex - rightEntry.originalIndex; + return sort.direction === "asc" ? comparison : -comparison; + }) + .map(({ row }) => row); +}; + +/** children으로 다단 헤더를 전달하는 UiTable에서도 동일한 3상태 정렬을 사용한다. */ +export const getNextTableSort = ( + head: UiHeadProps, + currentSort?: UiTableSort, +): UiTableSort | undefined => { + const key = head.sortKey ?? head.id; + if (currentSort?.key !== key) return { key, direction: "asc" }; + if (currentSort.direction === "asc") return { key, direction: "desc" }; + return undefined; +}; diff --git a/apps/sn/app/store/useSearchModalStore.ts b/apps/sn/app/store/useSearchModalStore.ts index 1a74844bf..18c694898 100644 --- a/apps/sn/app/store/useSearchModalStore.ts +++ b/apps/sn/app/store/useSearchModalStore.ts @@ -11,6 +11,7 @@ * - 데이터 변경 감지 및 불필요한 재렌더링 방지 */ import { create } from "zustand"; +import type { UiTableSort } from "@common/business_components/ui"; // 날짜 조합 함수 @@ -50,6 +51,10 @@ export interface SearchModalState { // 페이지네이션 curPage: number; detailCurPage: number; + // 기본정보·유지관리 사업·보고서 목록이 공유하는 현재 단일 정렬 상태 + sort?: UiTableSort; + // 유지관리 상세 목록의 현재 단일 정렬 상태 + detailSort?: UiTableSort; // 로딩 상태 isLoading: boolean; isDetailLoading: boolean; @@ -93,6 +98,8 @@ export interface SearchModalState { setDetailTableCount: (count: number) => void; setCurPage: (page: number) => void; setDetailCurPage: (page: number) => void; + setSort: (sort?: UiTableSort) => void; + setDetailSort: (sort?: UiTableSort) => void; setIsLoading: (loading: boolean) => void; setIsDetailLoading: (loading: boolean) => void; setIsResetPage: (reset: boolean) => void; @@ -128,6 +135,8 @@ const useSearchModalStore = create((set) => ({ detailTableCount: 0, curPage: 1, detailCurPage: 1, + sort: undefined, + detailSort: undefined, isLoading: false, isDetailLoading: false, isResetPage: false, @@ -161,6 +170,8 @@ const useSearchModalStore = create((set) => ({ setDetailTableCount: (count) => set({ detailTableCount: count }), setCurPage: (page) => set({ curPage: page }), setDetailCurPage: (page) => set({ detailCurPage: page }), + setSort: (sort) => set({ sort }), + setDetailSort: (detailSort) => set({ detailSort }), setIsLoading: (loading) => set({ isLoading: loading }), setIsDetailLoading: (loading) => set({ isDetailLoading: loading }), setIsResetPage: (reset) => set({ isResetPage: reset }), @@ -200,6 +211,8 @@ const useSearchModalStore = create((set) => ({ detailTableCount: 0, curPage: 1, detailCurPage: 1, + sort: undefined, + detailSort: undefined, isResetPage: true, isResetDetailPage: true, isResetSelection: true, @@ -208,4 +221,4 @@ const useSearchModalStore = create((set) => ({ }), })); -export default useSearchModalStore; \ No newline at end of file +export default useSearchModalStore; diff --git a/apps/sn/docs/작업검증/SFR-011-002-관로분석/001-qa-SFR-011-002-관로분석-통합검증결과.md b/apps/sn/docs/작업검증/SFR-011-002-관로분석/001-qa-SFR-011-002-관로분석-통합검증결과.md index 220ae751c..039853b6d 100644 --- a/apps/sn/docs/작업검증/SFR-011-002-관로분석/001-qa-SFR-011-002-관로분석-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-011-002-관로분석/001-qa-SFR-011-002-관로분석-통합검증결과.md @@ -25,3 +25,10 @@ ## 6. 판단 - 프론트 수정 범위 내에서 신규 타입 오류를 만들지는 않았다. - 실제 장애 해소 여부의 최종 판정은 백엔드 반영본과 함께 브라우저 재조회 확인이 필요하다. + +## 7. 2026-07-16 관로현황 페이징 유지보수 검토 +- 코드 리뷰로 `PipeAnalysisDetailPopupPage.tsx`의 조회, 페이지 이동, 정렬 변경, 다운로드가 각각 `appliedFilters`, `appliedSort`를 공유하도록 연결된 것을 확인했다. +- 페이지 이동은 마지막 조회 조건을 유지하고, 정렬 변경은 마지막 조회 조건으로 1페이지 재조회하도록 분리되어 기존 페이징 계약을 복구했다. +- 입력 중인 필터를 바로 읽던 기존 흐름 대신 `조회` 시점에만 적용 조건을 갱신하므로, 미적용 초안 필터 때문에 상세 팝업 결과 수나 페이지 상태가 흔들리던 회귀를 방지한다. +- 상세 팝업은 페이지당 10건과 별도 테이블 스크롤 영역으로 조정되어 현재 팝업 높이에서 페이지네이션이 화면 하단에 노출되는 구조로 바뀌었다. +- 브라우저 실동작 최종 확인은 사용자 수동 검증 기준으로 진행한다. diff --git a/apps/sn/docs/작업검증/SFR-011-노후관로관리/001-qa-SFR-011-노후관로관리-통합검증결과.md b/apps/sn/docs/작업검증/SFR-011-노후관로관리/001-qa-SFR-011-노후관로관리-통합검증결과.md index 915cb2826..f82ee2931 100644 --- a/apps/sn/docs/작업검증/SFR-011-노후관로관리/001-qa-SFR-011-노후관로관리-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-011-노후관로관리/001-qa-SFR-011-노후관로관리-통합검증결과.md @@ -55,3 +55,13 @@ - 사용자 스크린샷 기준 위치 체감 확인 - 최종 크기(`850 x 600`) 상태에서 표 하단 공백 체감 확인 - 필요 시 문서만 현재 운영값 기준으로 재동기화 + +## 5. 2026-07-20 목록 정렬 검증 + +- 8개 헤더의 `sortable` 설정과 controlled `sort` 연결을 정적으로 확인했다. +- 표시 필드와 WFS 원천 필드 매핑, 경과년도와 설치일자의 반대 방향 변환을 확인했다. +- 정렬 변경 시 1페이지 재조회와 선택 행 초기화를 확인했다. +- 프론트 저장소 `git diff --check`를 통과했다. +- TypeScript 검사는 로컬 `node_modules` 읽기 권한 제한으로 미실행했다. +- 사용자 수동 확인 기준으로 실제 GeoServer 실데이터 조회와 노후관로 목록 정렬 동작이 정상 확인됐다. +- Codex 자동 브라우저 검증은 미실행이며, 사용자 수동 확인 결과를 실동작 검증 근거로 기록한다. diff --git a/apps/sn/docs/작업검증/공통-UiTable-컬럼정렬/001-qa-공통-UiTable-컬럼정렬-통합검증계획.md b/apps/sn/docs/작업검증/공통-UiTable-컬럼정렬/001-qa-공통-UiTable-컬럼정렬-통합검증계획.md new file mode 100644 index 000000000..43594a3dd --- /dev/null +++ b/apps/sn/docs/작업검증/공통-UiTable-컬럼정렬/001-qa-공통-UiTable-컬럼정렬-통합검증계획.md @@ -0,0 +1,65 @@ +# 공통 UiTable 컬럼 정렬 통합 검증 계획 + +## 1. 검증 범위 + +- 공통 `UiTable`의 optional 정렬 계약과 기존 화면 하위 호환 +- 단일·다단 헤더의 leaf 컬럼 판정, 아이콘, 키보드 버튼, `aria-sort` +- 프로토타입 화면과 서버 API 사이의 `sort=field,direction` 계약 +- 정렬 변경, 페이지 이동, 선택 행 초기화, 다운로드 순서 유지 +- 허용 필드 검증, null 배치, 동일값 보조 정렬 + +## 2. 공통 검증 기준 + +- 정렬 props가 없는 기존 화면은 렌더링과 클릭 동작이 유지되어야 한다. +- 정렬 가능한 헤더만 `asc -> desc -> undefined` 순서로 동작해야 한다. +- 공통 컴포넌트가 `tableData`를 직접 변경하지 않아야 한다. +- 화면 표시 필드가 아닌 API 원천 필드가 `sortKey`로 전송되어야 한다. +- 정렬 변경 시 첫 페이지 이동과 선택 해제가 함께 수행되어야 한다. + +## 3. 서버 연동 검증 기준 + +- 허용된 9개 필드의 asc/desc 요청이 처리되어야 한다. +- 미허용 필드, 잘못된 방향과 형식은 400 응답 대상이어야 한다. +- 날짜·문자·숫자의 null 값은 뒤에 배치되어야 한다. +- 동일한 정렬값이 많아도 발생일자·사고번호 보조 정렬로 페이지가 안정적이어야 한다. +- 일반 목록, 좌표상태 필터 조회와 다운로드 경로가 동일한 정렬 계약을 사용해야 한다. + +## 4. 회귀 검증 + +- 공통 비즈니스 컴포넌트 ESM/DTS 빌드 +- SN 변경 파일 TypeScript 검사 +- 실행 중 개발 서버와 `.next`가 충돌하지 않는 조건에서 SN 빌드 +- BE `compileJava` +- `fo`, `wr` 공통 패키지 의존 회귀 확인 + +## 5. 수동 검증 + +- 로그인 가능한 `/map`에서 오름차순, 내림차순, 기본순서 전환을 확인한다. +- 1페이지 마지막 행과 2페이지 첫 행의 연속성을 확인한다. +- 정렬 상태가 페이지 이동과 엑셀 다운로드에도 유지되는지 확인한다. +- 좌표보정상태 필터와 정렬을 함께 사용해 후처리 필터 이후 순서를 확인한다. + +## 6. 2026-07-15 추가 검증 계획 + +- 사고번호 오름차순·내림차순 요청과 페이지 연속성을 확인한다. +- 미선택 `▲/▼`, 오름차순 `▲`, 내림차순 `▼` 표시와 축소된 크기를 확인한다. +- 기본 팝업 폭에서 발생규모연장·깊이가 한 줄이고, 좁은 창에서는 자연스럽게 줄바꿈되는지 확인한다. +- 주소 오름차순 결과가 `C` collation의 UTF-8 한글 음절 순서인 `가→나→다`와 일치하는지 실 DB에서 확인한다. + +## 7. 2026-07-16 Repository 리팩터링 검증 계획 + +- 9개 허용 필드가 각각 asc/desc 단일 정렬식을 생성하는지 확인한다. +- 모든 사용자 정렬 뒤에 `ocrn_ymd desc, acdnt_no desc` 보조 정렬이 유지되는지 확인한다. +- 주소 정렬식이 기존 전체 주소 조합과 PostgreSQL `C` collation을 유지하는지 확인한다. +- 검색조건과 `limit`, `offset`이 기존 이름과 값으로 바인딩되는지 확인한다. +- 미허용 키가 SQL 생성 전에 거절되고 생성 SQL에 `sortKey`, `sortDirection`, 정렬용 `CASE`가 남지 않는지 확인한다. +- `compileJava`와 Repository 단위 테스트를 수행하고, 실 DB·브라우저 순서 검증은 기존 후속 항목으로 유지한다. + +## 8. 2026-07-16 정보검색 1단계 검증 계획 + +- 기본정보 6개 레이어에서 활성화된 원천 필드만 WFS `sortBy`로 변환되고 미허용·미지정 필드는 기존 GeoServer 순서를 유지하는지 확인한다. +- 유지관리 사업 목록은 5개 허용 필드와 `bizId desc` 기본·보조 정렬을 확인한다. +- 유지관리 상세는 관리번호, 처리구역, 구조적 상태등급, 운영적 상태등급만 정렬되고 같은 값에는 `ast_id desc`가 적용되는지 확인한다. +- 보고서는 분류 트리 전체 정렬 후 페이징되는지, 파일명·등록일자에는 정렬 버튼이 없는지 확인한다. +- 모든 서버 조회형 목록에서 정렬 변경 시 0페이지 요청, 선택 초기화, 정렬 해제 시 기존 기본순서 복귀를 확인한다. +- 기본정보·유지관리 다운로드가 화면과 같은 정렬 파라미터를 사용하는지 확인한다. diff --git a/apps/sn/docs/작업검증/공통-UiTable-컬럼정렬/002-qa-공통-UiTable-컬럼정렬-통합검증결과.md b/apps/sn/docs/작업검증/공통-UiTable-컬럼정렬/002-qa-공통-UiTable-컬럼정렬-통합검증결과.md new file mode 100644 index 000000000..22182ccee --- /dev/null +++ b/apps/sn/docs/작업검증/공통-UiTable-컬럼정렬/002-qa-공통-UiTable-컬럼정렬-통합검증결과.md @@ -0,0 +1,93 @@ +# 공통 UiTable 컬럼 정렬 통합 검증 결과 + +## 1. 수행 결과 + +- 공통 `@common/business_components` ESM/DTS 빌드는 프로토타입 구현 라운드에서 성공했다. +- SN 변경 파일 기준 신규 TypeScript 오류는 확인되지 않았다. +- SN 전체 TypeScript 검사는 기존 지도·차트·HTML 경로의 선행 오류 9건으로 종료 코드 1이었다. +- SN Next 빌드는 180초 제한 안에 종료되지 않아 타임아웃 처리했다. +- 백엔드 `compileJava`는 현재 HEAD 병합 후 다시 수행해 성공했다. +- 프론트와 백엔드 저장소 모두 미해결 충돌과 충돌 마커가 없고 `git diff --check`를 통과했다. + +## 2. 정적 계약 확인 + +- 공통 정렬 타입이 ESM/DTS 출력 대상에 포함되는 구조를 확인했다. +- 정렬 불가 컬럼에는 `sortable`이 없고 표시 필드는 `sortKey`로 API 원천 필드에 매핑된다. +- 백엔드는 허용목록 외 필드를 Repository 전달 전에 거절한다. +- 커스텀 Repository는 enum에서 선택된 고정 SQL 표현식만 사용하고 사용자 컬럼 문자열을 SQL에 직접 연결하지 않는다. +- DB 스키마와 원천 데이터 변경은 없다. + +## 3. 현재 판정 + +- 공통 컴포넌트, 현재 구현 범위 화면, 백엔드 정렬 계약과 컴파일 검증은 완료했다. +- 현재 구현 범위의 브라우저 동작은 사용자 수동 검증 기준으로 완료했다. +- 미구현 화면의 정렬 동작, `fo`, `wr` 직접 회귀 빌드, 미적용 화면의 추가 검증은 후속 범위다. + +## 4. 문서 정리 결과 + +- 지반침하정보 관련 MD는 각 저장소의 HEAD 상태로 복구했다. +- 정렬 기능의 구현·검증 이력은 이 공통 기능 문서 세트로 통합했다. + +## 5. 2026-07-15 추가 검증 결과 + +- 공통 `@common/business_components` ESM/DTS 빌드가 성공했다. +- 백엔드 `compileJava`가 성공했다. +- 2026-07-15 당시 사고번호가 프론트 정렬 key와 백엔드 허용목록·고정 CASE SQL에 동일하게 연결된 것을 확인했으며, 2026-07-16 enum 전환 후에도 같은 계약을 유지한다. +- 미선택과 선택 상태가 각각 축소된 `▲/▼`, `▲`, `▼`로 렌더링되는 것을 정적으로 확인했다. +- 기본 팝업 폭은 컬럼 폭 조정으로 한 줄 표시를 유도하고, 고정 nowrap 없이 좁은 창의 줄바꿈을 유지한다. +- 주소 전체 문자열에 PostgreSQL `C` collation이 명시된 것을 확인했다. +- Next 개발 서버와 `.next`는 건드리지 않았고 브라우저·실 DB 화면 검증은 사용자 실행 환경에서 남아 있다. + +## 6. 2026-07-16 Repository 리팩터링 검증 결과 + +- `./gradlew.bat compileJava --console=plain`: 성공. +- `./gradlew.bat test --tests iipbs.il.domain.SinkholeAccidentRepositoryImplTest --console=plain`: 성공. +- 단위 테스트에서 9개 허용 필드의 asc/desc, `NULLS LAST`, 발생일자·사고번호 보조 정렬, 주소 전체 문자열과 `C` collation을 확인했다. +- 검색조건 6개와 `limit`, `offset`의 기존 바인딩을 확인했다. +- 생성 SQL에 정렬용 `CASE`, `sortKey`, `sortDirection`이 없고 미허용 키가 SQL 생성 전에 거절되는 것을 확인했다. +- API·DTO·프론트·DB 변경은 없으며 실 DB 페이지 경계와 브라우저 상호작용 검증은 기존 후속 항목으로 남는다. + +## 7. 2026-07-16 정보검색 1단계 검증 결과 + +- 프론트·백엔드 저장소에서 `git diff --check`를 통과했다. +- 정적 검토로 네 목록의 `sortable`/`sortKey`, controlled 상태, 첫 페이지 초기화, API·다운로드 정렬 파라미터 연결을 확인했다. +- 하수관로 기본정보의 `유하방향 근거`는 현재 정렬 대상에서 제외한 것을 확인했다. +- 제외 이유는 파생값 컬럼이라 기존 목록 정렬·페이징 계약을 병목 없이 만족하려면 백엔드 정식 정렬 지원이 필요하기 때문이다. +- 기본계획 보고서는 전체 필터 결과를 정렬한 다음 10건씩 자르고, 그 뒤 현재 페이지 파일 메타데이터를 조회하는 순서를 확인했다. +- 보고서 `파일명`, `등록일자`는 현재 정렬 대상에서 제외한 것을 확인했다. +- 제외 이유는 기본 분류 트리 응답에 없는 파일 메타데이터를 분류별 추가 호출로 채워야 해, 기존 목록 정렬 계약과 성능을 동시에 만족하려면 `dc-service`의 일괄 메타데이터 지원이 필요하기 때문이다. +- 백엔드 `compileJava`와 `FcltWfsSortPolicyTest`, `BizInfoSortPolicyTest`가 성공했다. +- TypeScript 검사는 저장소 `node_modules` 읽기 권한 요청이 승인되지 않아 미실행했다. 코드 오류로 실패한 결과와 구분한다. +- 로컬 SN 서버는 3002 포트에서 확인했지만 인앱 브라우저가 사용자 프로필 경로 권한으로 시작되지 않아 화면 자동 검증은 미실행했다. +- 실 GeoServer WFS 정렬, 실 DB Native Query 페이지 경계, 브라우저의 `asc -> desc -> undefined` 상호작용은 후속 수동 검증 항목이다. + +## 8. 2026-07-16 현재 구현 범위 브라우저 확인 + +- 사용자 수동 검증 기준으로 현재 구현된 정렬 화면의 브라우저 동작은 정상 확인됐다. +- 확인 완료 범위는 지반침하정보, 정보검색 기본정보, 정보검색 유지관리 사업 목록, 정보검색 유지관리 상세, 정보검색 기본계획 보고서, 침입수 분석결과, RDII 분석결과, 관로분석 상세다. +- 관로분석 요약은 `YEAR`, `MATERIAL` 유형의 정렬 동작을 현재 구현 범위로 보고 확인 완료 범위에 포함한다. +- `YEAR_MATERIAL` 요약 분기, 노후관로 팝업, 보고서 파일 팝업, 구형 I/I 결과 페이지, 구형 운영 화면은 정렬 미구현 범위라 이번 브라우저 확인 대상에서 제외했다. +- 본 문서의 자동 브라우저 미실행 기록은 Codex 인앱 검증 부재를 뜻하며, 사용자 수동 검증 완료와는 별개로 유지한다. + +## 9. 2026-07-20 운영정보·노후관로 정적 검증 + +- 프론트 저장소 `git diff --check`를 통과했다. +- 도시침수 예방사업과 노후하수관로 정비사업 목록의 5개 서버 정렬 키, 첫 페이지 초기화, 선택 초기화, 정렬 해제 시 기본 정렬 복귀를 정적으로 확인했다. +- 두 목록의 외부 사용자명 `등록자`는 정렬 불가 헤더로 유지한 것을 확인했다. +- 도시침수 엑셀 2개 탭과 노후하수관로 엑셀 6개 탭은 원본 배열을 변경하지 않는 전체행 안정 정렬을 사용한다. +- 노후하수관로 다단 헤더는 leaf 컬럼에만 정렬 버튼이 표시되도록 공통 `HeadMakeColSpan` 계약을 연결했다. +- 노후관로 목록의 8개 표시 컬럼과 WFS 허용 원천 필드 매핑을 확인했고, 경과년도 정렬 방향을 설치일자 기준으로 반전하는 것을 확인했다. +- `pnpm --filter sn exec tsc --noEmit --pretty false`는 비대화형 환경에서 `node_modules` 재설치 확인이 중단됐다. +- 설치된 TypeScript 5.9.3을 직접 실행하고 `ignoreDeprecations`만 CLI에서 5.0으로 보정해 전체 타입 검사를 수행했다. +- 이번 변경의 `reportTab.tsx` strict 인덱스 오류 1건을 수정한 뒤 재검사했으며, 출력에는 기존 지도·침수이력·HTML·차트 경로 오류 9건만 남았다. +- 공통 `@common/business_components` ESM/DTS 빌드가 성공했다. +- 사용자 수동 확인 기준으로 운영정보의 도시침수 예방사업·노후하수관로 정비사업 그리드와 노후관로 목록의 실데이터 정렬 동작이 정상 확인됐다. +- Codex 자동 브라우저 검증은 수행하지 않았으며, 이번 실동작 판정은 사용자 수동 확인 결과를 근거로 기록한다. +- 커밋 전 정적 검토에서 기본계획 보고서가 현재 페이지 외 메타데이터를 예열하지 않는 것을 확인했다. +- 침입수·RDII `분석자`와 운영정보 `등록자`는 외부 사용자명 보강 컬럼이므로 잘못된 UUID 정렬 대신 정렬 불가 헤더로 유지한다. + +## 10. 2026-07-20 노후관로 성능 회귀 보완 + +- 노후관로 요청에는 `includeFlowDirectionBasis`가 없고, 정보검색 기본정보의 하수관로 요청에만 `true`가 전달되는 것을 정적으로 확인했다. +- 따라서 노후관로 정렬·페이지 이동마다 유하방향 근거 CTE 배치 조회가 추가 실행되지 않는다. +- 실제 응답시간 비교는 변경된 백엔드 재기동 후 사용자 통합 환경에서 확인한다. diff --git a/apps/sn/docs/작업내용/SFR-011-002-관로분석/001-fe-SFR-011-002-관로분석-통합구현기록.md b/apps/sn/docs/작업내용/SFR-011-002-관로분석/001-fe-SFR-011-002-관로분석-통합구현기록.md index 69784f54b..67235202d 100644 --- a/apps/sn/docs/작업내용/SFR-011-002-관로분석/001-fe-SFR-011-002-관로분석-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-011-002-관로분석/001-fe-SFR-011-002-관로분석-통합구현기록.md @@ -23,6 +23,9 @@ - 집계 결과는 10개 단위 클라이언트 페이징으로 잘라 렌더링한다. - `경과년도&재질`은 rowSpan 구조를 유지하기 위해 처리구역 10묶음 기준으로 페이지를 나눈다. - 모든 분석구분 헤더에 현재 집계 건수를 `N건` 형식으로 노출한다. + - `경과년도&재질` 표는 `경과년도` 열만 제외하고 `NO`, `시도`, `시군구`, `처리구역`, `합계`, 관재질 열 정렬을 지원한다. + - `경과년도&재질`의 관재질 열 정렬은 처리구역 그룹 내 연도별 값을 합산한 총계 기준으로 동작한다. + - `경과년도&재질` 엑셀 다운로드와 페이지네이션도 현재 정렬 순서를 따른다. - `PipeAnalysisDetailPopupPage` - 상세 조회/다운로드 실패 시 실제 `Error.message`를 우선 alert에 노출한다. @@ -35,3 +38,11 @@ ## 6. 리스크 및 메모 - `getData` 공통 유틸 수정이라 다른 화면에서도 이제 서버 메시지가 더 직접적으로 노출된다. - 서버 메시지 문구가 사용자 친화적이지 않은 API는 추후 별도 화면 단에서 다듬을 수 있다. + +## 7. 2026-07-16 관로현황 페이징 유지보수 +- `PipeAnalysisDetailPopupPage.tsx`에 `appliedFilters`, `appliedSort` 상태를 추가해 마지막 조회에 실제 적용된 필터·정렬 조건을 별도로 보관한다. +- `조회` 버튼은 입력 중인 필터를 scope 규칙으로 정규화한 뒤 1페이지부터 다시 조회하고, 페이지 이동은 입력 중인 값이 아니라 마지막 조회 조건을 그대로 사용한다. +- 헤더 정렬 변경도 마지막 조회 조건 기준으로 1페이지 재조회하도록 바꿔 페이징과 정렬이 서로 조건을 덮어쓰지 않게 했다. +- 엑셀 다운로드는 화면에 마지막으로 적용된 필터·정렬과 같은 조건으로 전체 상세 데이터를 다시 조회한다. +- `관로현황`은 페이지당 10건으로 조정하고, 팝업 본문은 필터 영역과 테이블 영역을 분리해 페이지네이션이 현재 창 높이 안에서 바로 보이도록 정리했다. +- 이번 수정으로 `관로현황` 팝업의 기존 페이징 기능을 유지하면서, 사용자가 필터 입력만 바꿔 둔 상태에서 페이지 이동이나 정렬을 눌러도 미적용 초안 값이 섞이지 않고 페이지네이션도 다시 노출되도록 정리했다. diff --git a/apps/sn/docs/작업내용/SFR-011-노후관로관리/001-fe-SFR-011-노후관로관리-통합구현기록.md b/apps/sn/docs/작업내용/SFR-011-노후관로관리/001-fe-SFR-011-노후관로관리-통합구현기록.md index d6f51e189..22947c3c1 100644 --- a/apps/sn/docs/작업내용/SFR-011-노후관로관리/001-fe-SFR-011-노후관로관리-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-011-노후관로관리/001-fe-SFR-011-노후관로관리-통합구현기록.md @@ -52,9 +52,18 @@ - 후속 정리로 선택 레이어 설정에는 침수심 범례 본문만 남기고, 지도 우측 하단에 따로 뜨던 기존 범례 박스는 제거했다. - 추가 구조 보정으로 토글 핸들을 패널 본체와 분리된 고정 핸들로 바꿔, 닫았을 때도 화면 왼쪽 가장자리에 다시 열기 버튼이 남도록 정리했다. +### 3.4 2026-07-20 노후관로 목록 정렬 + +- 목록의 `NO`, `시설관리번호`, `설치일자`, `관경`, `연장`, `용도`, `재질`, `경과년도`를 정렬 가능 컬럼으로 전환했다. +- 현재 페이지 배열만 정렬하지 않고 `GET /api/v1/fclts`의 `sort=field,direction`을 사용해 전체 필터 결과를 WFS에서 정렬한 뒤 페이지를 조회한다. +- 화면 필드는 GeoServer 원천 필드인 `fclt_mng_no`, `instl_ymd`, `pdmt_vl`, `prlg_vl`, `usg_cd`, `mqlt_cd`로 제한 매핑했다. +- 경과년도는 설치일자에서 계산되므로 경과년도 오름차순 요청을 설치일자 내림차순으로, 내림차순 요청을 설치일자 오름차순으로 변환한다. +- `NO` 내림차순은 전체 건수 기준 번호가 감소하도록 표시하고, 정렬 변경 시 첫 페이지와 선택 행을 초기화한다. + ## 4. 실제 수정 파일 - `FE_APP_ROOT/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx` +- `FE_APP_ROOT/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts` - `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts` - `FE_APP_ROOT/src/features/windowPopup/model/types.ts` - `FE_APP_ROOT/src/features/windowPopup/lib/openWindowPopup.ts` diff --git a/apps/sn/docs/작업내용/공통-UiTable-컬럼정렬/001-fe-공통-UiTable-컬럼정렬-통합구현기록.md b/apps/sn/docs/작업내용/공통-UiTable-컬럼정렬/001-fe-공통-UiTable-컬럼정렬-통합구현기록.md new file mode 100644 index 000000000..ecc9f8b0b --- /dev/null +++ b/apps/sn/docs/작업내용/공통-UiTable-컬럼정렬/001-fe-공통-UiTable-컬럼정렬-통합구현기록.md @@ -0,0 +1,131 @@ +# 공통 UiTable 컬럼 정렬 통합 구현 기록 + +## 1. 문서 목적 + +- 공통 `UiTable` 컬럼 정렬 기능의 구현 이력을 기능 단위로 관리한다. +- 지반침하정보는 최초 프로토타입 적용 화면이며, 정렬 기능 자체의 문서 소유자는 이 공통 기능 폴더다. + +## 2. 공통 구현 계약 + +- `UiTable`은 데이터를 직접 정렬하지 않고 optional controlled 정렬 상태와 변경 이벤트만 제공한다. +- `sortable`, `sortKey`, `sort`, `onSortChange`는 모두 선택 속성으로 기존 사용처의 동작을 유지한다. +- 정렬 상태는 `asc -> desc -> undefined` 순서로 순환한다. +- 실제 데이터 컬럼인 leaf 헤더만 정렬 버튼과 `aria-sort`를 표시한다. +- 표시 필드와 API 필드가 다르면 `sortKey`로 원천 필드를 지정한다. + +## 3. 구현 파일 + +### 3.1 공통 프론트 + +- `FE_BC_ROOT/src/ui/table/types.ts` +- `FE_BC_ROOT/src/ui/table/index.tsx` +- `FE_BC_ROOT/src/ui/table/HeadMakeColSpan.tsx` + +### 3.2 최초 프로토타입 적용 + +- `FE_APP_ROOT/app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx` +- `FE_APP_ROOT/app/(map-layout)/map/components/sinkhole/sinkholeTypes.ts` +- `BE_REPO_ROOT/src/main/java/iipbs/il/domain/SinkholeAccidentRepository.java` +- `BE_REPO_ROOT/src/main/java/iipbs/sewerage/api/sinkhole/dto/SinkholeListSearchDto.java` +- `BE_REPO_ROOT/src/main/java/iipbs/sewerage/service/sinkhole/SinkholeQueryService.java` + +## 4. 프로토타입 적용 결과 + +- 사고번호, 발생일자, 주소, 규모 3종, 상세발생원인, 복구상태, 복구완료일자에 서버 정렬을 연결했다. +- 사고번호를 정렬 대상에 추가했고, 현재 프론트 코드 기준으로 좌표보정상태 헤더도 `sortable: true`를 사용한다. +- 정렬 변경 시 1페이지로 이동하고 선택 행을 초기화한다. +- 페이지 이동과 엑셀 다운로드는 마지막 적용 검색조건과 정렬조건을 유지한다. +- 백엔드는 `sort=field,direction`을 파싱하고 9개 허용 필드를 enum으로 검증한 뒤 커스텀 Repository에서 고정 SQL 표현식 하나만 정렬에 사용한다. +- 사용자 정렬 뒤에는 발생일자·사고번호 내림차순 보조 정렬을 적용한다. +- API 응답 DTO와 DB 스키마는 변경하지 않았다. + +## 5. 문서 소유권 + +- 지반침하정보 설계·작업카드·통합구현·검증 문서는 현재 HEAD 상태를 유지한다. +- 프로토타입 적용의 상세 설계, 구현, 검증 이력은 `공통-UiTable-컬럼정렬` 문서 세트에서 관리한다. +- 향후 다른 화면에 적용해도 해당 업무 문서에는 필요한 연동 계약만 기록하고 공통 동작을 중복 설명하지 않는다. + +## 6. 남은 리스크 + +- 로그인 가능한 통합 환경에서 실제 asc/desc/default 순환과 페이지 경계 연속성을 확인해야 한다. +- 공통 패키지를 사용하는 `fo`, `wr`의 직접 회귀 빌드는 후속 검증 대상이다. + +## 7. 흡수한 임시 기록 + +- 지반침하정보 공통 설계, FE 작업카드·통합구현기록·통합검증계획·결과에 임시 추가됐던 정렬 섹션 +- 지반침하정보 BE 설계·통합구현기록·통합검증계획·결과에 임시 추가됐던 정렬 섹션 +- 위 원본 문서는 모두 각 저장소의 HEAD 상태로 복구했다. + +## 8. 2026-07-15 사고번호 정렬·헤더 표시 보완 + +- 사고번호에 `sortable: true`를 적용하고 컬럼 폭을 `17`에서 `15`로 줄였다. +- 발생규모연장과 발생규모깊이 폭을 각각 `8`에서 `9`로 늘렸다. +- 사고번호에서 줄인 폭을 발생규모연장과 발생규모깊이에 각각 배분해 기본 팝업 폭에서는 한 줄, 좁은 창에서는 기존 줄바꿈을 유지했다. +- 미선택 상태는 `▲/▼`, 오름차순은 `▲`, 내림차순은 `▼`로 표시한다. +- 2026-07-15 당시 백엔드 허용 필드와 Repository 고정 CASE 정렬에 `acdntNo`를 추가했으며, 2026-07-16 커스텀 Repository 전환 후에도 같은 키를 유지한다. +- 주소 정렬은 전체 주소 문자열에 PostgreSQL `C` collation을 적용해 한글 음절의 가나다 순서를 고정했다. + +## 9. 2026-07-16 지반침하 백엔드 정렬 구현 정리 + +- 프론트의 `UiTableSort`, 정렬 가능한 9개 헤더, `sort=field,direction` 요청과 페이지·선택 초기화 동작은 변경하지 않았다. +- 백엔드는 `SinkholeAccidentSortField` enum으로 API 키와 고정 SQL 표현식을 통합했다. +- `SinkholeAccidentRepositoryImpl` 커스텀 조회가 검증된 정렬식 하나만 동적으로 추가해 기존 정적 SQL의 asc/desc `CASE` 중복을 제거한다. +- 검색조건, 좌표상태 후처리, 일반 목록, 다운로드, 기본 정렬, null 배치와 보조 정렬은 기존 계약을 유지한다. + +## 10. 2026-07-16 정보검색 1단계 적용 + +- `searchModal.tsx`에서 기본정보 레이어별 원천 필드, 유지관리 사업 Entity 필드, 관련시설 Native Query 필드를 정렬 가능 헤더로 제한했다. +- 기본정보는 화면 필드 ID를 WFS 원천 속성명으로 매핑해 `GET /api/v1/fclts`의 Spring `sort` 파라미터로 전달한다. +- 하수관로 `유하방향 근거(flowDirectionBasis)`는 현재 정렬 대상에서 제외했다. +- 이유는 GeoServer 원천 정렬 컬럼이 아니라 관로 상세 병합 단계의 파생값이라, 기존 목록 조회·전체 결과 정렬·페이징 계약을 병목 없이 만족시키는 백엔드 정식 구현이 추가로 필요하기 때문이다. +- 유지관리 사업·상세는 각각 독립된 controlled 정렬 상태를 사용하고 `GET /api/v1/bizInfo`, `GET /api/v1/bizInfo/fclts`에 전달한다. +- 새 사업을 선택하면 상세 정렬과 상세 페이지를 초기화하고 첫 상세 요청은 항상 0페이지를 사용한다. +- 기본정보·유지관리 목록·상세 엑셀 다운로드에도 현재 정렬조건을 유지한다. +- 기본계획 보고서는 시도, 시군구, 승인년도, 구분, 보고서구분, 파일명, 등록일자를 정렬할 수 있다. +- 파일명·등록일자는 현재 정렬 대상에서 제외했다. +- 이유는 기본 분류 트리 응답에 없는 파일 메타데이터를 분류별 추가 호출로 보강해야 해서, 기존 목록 정렬 계약을 유지하면서도 병목 없이 동작하려면 `dc-service`의 대표 파일 메타데이터 일괄 제공 또는 서버 정렬 지원이 필요하기 때문이다. +- 코드 표시명 매핑 필드와 GeoServer 병합 전용 후처리 필드는 원천 필드가 안정적으로 연결된 경우에만 정렬 대상으로 노출한다. + +## 11. 2026-07-16 현재 코드 기준 적용 범위 정리 + +- 공통 `UiTable` 정렬 계약은 `packages/common/business_components/src/ui/table`에 반영되어 `sortable`, `sortKey`, controlled `sort`, `onSortChange`, `aria-sort`를 공통으로 제공한다. +- 지도형 팝업 기준 구현 완료 화면은 지반침하정보, 정보검색 기본정보, 정보검색 유지관리 사업 목록, 정보검색 유지관리 상세, 정보검색 기본계획 보고서, 침입수 분석결과, RDII 분석결과, 관로분석 상세다. +- 관로분석 요약은 `YEAR`, `MATERIAL` 유형에서 클라이언트 정렬이 동작하고, 다운로드는 현재 정렬된 요약 결과를 기준으로 유지한다. +- 침입수 분석결과와 RDII 분석결과는 `rowNumber` 요청을 각각 `inftwtAnlsSn desc`, `rainInfwtAnlsSn desc` 기반의 안정 정렬로 변환해 화면 번호와 서버 정렬을 맞춘다. +- 정보검색 기본정보·유지관리 목록·유지관리 상세는 정렬 변경 시 1페이지로 초기화하고, 엑셀 다운로드에도 마지막 정렬조건을 유지한다. +- 보고서 탭은 전체 분류 트리를 먼저 정렬한 뒤 페이지를 자르고, 현재 페이지에 필요한 파일 메타데이터만 보강한다. + +## 12. 2026-07-16 미구현·후속 적용 대상 + +- `app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx`: `UiTable`는 사용하지만 정렬 상태, 헤더 `sortable`, API 정렬 파라미터, CSV 다운로드 정렬 유지가 없다. +- `src/features/windowPopup/ui/ReportFilesPopupContent.tsx`: 파일 목록 팝업은 페이지네이션만 있고 `fileTitle`, `regDt` 기준 정렬이 없다. +- `app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx`: `YEAR_MATERIAL` 분기는 커스텀 `Table`을 직접 렌더링해 헤더 클릭 정렬이 아직 없다. +- `app/(with-base-layout)/ii/result/page.tsx`, `app/(with-base-layout)/ii/rdiiResult/page.tsx`: 구형 I/I 결과 페이지는 기본 `sort=...desc`만 사용하고 사용자 헤더 정렬은 없다. +- `app/(with-base-layout)/oper/floodPre/page.tsx`, `app/(with-base-layout)/oper/floodPre/tableModal.tsx`, `app/(with-base-layout)/oper/oldPp/page.tsx`, `app/(with-base-layout)/oper/oldPp/tableModal.tsx`, `app/(map-layout)/map/components/popupComponent/Ssc.tsx`: `UiTable` 사용처이지만 현재 정렬 연결 흔적이 없다. +- `app/html/**` 하위 프로토타입 HTML은 서비스 코드 정렬 적용 범위에서 제외했다. + +## 13. 2026-07-20 운영정보·노후관로 적용 + +- 운영정보 `도시침수 예방사업`, `노후하수관로 정비사업`의 사업정보 등록현황은 `일련번호`, `파일명`, `년도`, `분기`, `등록일자`를 기존 Spring `Pageable sort`로 전체 데이터 정렬한다. +- 두 사업 목록의 `일련번호`는 화면 생성값 대신 각 사업 PK를 `sortKey`로 사용하고, 내림차순일 때 표시 번호도 전체 건수부터 감소하도록 맞췄다. +- `등록자`는 페이지 조회 후 사용자 서비스에서 이름을 보강하는 파생값이므로 현재 정렬 대상에서 제외했다. UUID 정렬이나 현재 페이지 클라이언트 정렬로 잘못된 전체 순서를 만들지 않는다. +- 도시침수 예방사업의 엑셀 상세 2개 탭과 노후하수관로 정비사업의 엑셀 상세 6개 탭은 파일을 한 번 파싱한 전체 행을 브라우저에서 정렬한다. +- 엑셀 상세 정렬은 추가 API 호출 없이 숫자와 한글·문자열을 구분하고, 빈 값은 방향과 무관하게 뒤에 유지하며, 동일값은 원본 순서를 유지한다. +- 노후하수관로 정비사업의 다단 헤더 2개 탭도 그룹 헤더가 아닌 실제 leaf 컬럼에 공통 `▲/▼` 정렬 UI를 연결했다. +- 노후관로 목록은 `NO`, `시설관리번호`, `설치일자`, `관경`, `연장`, `용도`, `재질`, `경과년도` 8개 컬럼을 서버 WFS 정렬에 연결했다. +- 노후관로 `경과년도` 오름차순은 설치일자 내림차순, 경과년도 내림차순은 설치일자 오름차순으로 변환해 계산값 의미와 서버 정렬 방향을 맞췄다. +- 운영정보 목록의 중복 최초 조회와 검색 시 중복 호출을 제거해 정렬 추가로 인한 불필요한 네트워크 요청이 발생하지 않도록 했다. +- 구형 I/I·RDII 분석결과 화면과 SSC는 사용자 결정에 따라 이번 완료 범위에서 제외한다. + +## 14. 2026-07-20 커밋 전 최종 점검 + +- 기본계획 보고서는 정렬·페이징 후 현재 페이지 10건의 파일 메타데이터만 조회하고, 전체 분류 메타데이터 백그라운드 예열은 제거했다. +- 침입수·RDII `분석자`는 페이지 조회 후 외부 사용자 서비스에서 이름을 보강하므로 UUID 기준의 잘못된 전체 정렬을 노출하지 않는다. +- 외부 사용자명 정렬은 운영정보 `등록자`와 동일하게 서버 조인 또는 일괄 이름 정렬 지원이 준비될 때 재검토한다. + +## 15. 2026-07-20 노후관로 정렬 조회 성능 분리 + +- 노후관로와 정보검색 하수관로가 같은 `GET /api/v1/fclts`를 사용하더라도 필요한 후처리는 서로 다르므로 유하방향 근거 보강을 선택 기능으로 분리했다. +- 정보검색 기본정보의 하수관로 목록만 `includeFlowDirectionBasis=true`를 전달해 현재 페이지의 유하방향 근거를 배치 보강한다. +- 노후관로 목록은 해당 파라미터를 전달하지 않아 페이징·CQL 필터·WFS 서버 정렬과 기존 시설 정보 병합만 수행한다. +- 정렬 정확도를 위해 전체 필터 결과의 서버 정렬은 유지하며, 현재 페이지 배열만 정렬하는 임시 우회는 사용하지 않는다. diff --git a/packages/common/business_components/src/ui/table/HeadMakeColSpan.tsx b/packages/common/business_components/src/ui/table/HeadMakeColSpan.tsx index 3ad4c8e23..b13d2542e 100644 --- a/packages/common/business_components/src/ui/table/HeadMakeColSpan.tsx +++ b/packages/common/business_components/src/ui/table/HeadMakeColSpan.tsx @@ -1,71 +1,120 @@ import { TableHead, TableRow } from "@common/components/ui"; +import type { UiHeadProps, UiTableSort } from "./types"; -interface TableUpperProps { - id: string; - title: string; - upName?: string; - upSequnce?: number; - row? : number; - rowSpan?: number; - colSpan?: number; - visible?:boolean; - widthPercent?: number; -} +type HeadMakeColSpanProps = { + upHeadList: UiHeadProps[]; + sort?: UiTableSort; + onSortChange?: (head: UiHeadProps) => void; +}; -const HeadMakeColSpan = ({ upHeadList }: { upHeadList: TableUpperProps[] }) => { - const maxRow = upHeadList?.reduce((max, head) => Math.max(max, head.row || 0), 0); - //upHeadList.forEach((item) => item.visible ?item.visible:true) +const HeadMakeColSpan = ({ + upHeadList, + sort, + onSortChange, +}: HeadMakeColSpanProps) => { + const maxRow = upHeadList?.reduce( + (max, head) => Math.max(max, head.row || 0), + 0, + ); + + /** 마지막 헤더 행까지 차지하는 실제 데이터 컬럼인지 판별한다. */ + const isLeafHead = (head: UiHeadProps) => + (head.row ?? 0) + (head.rowSpan ?? 1) - 1 >= maxRow; + + /** 현재 정렬 상태에 맞는 헤더 버튼과 방향 아이콘을 만든다. */ + const renderHeadContent = (head: UiHeadProps) => { + const key = head.sortKey ?? head.id; + const direction = sort?.key === key ? sort.direction : undefined; + const sortable = Boolean(head.sortable && isLeafHead(head) && onSortChange); + + if (!sortable) return head.title; - if(maxRow == 0){ return ( - <> - - {upHeadList - .filter((item) => item.visible != false) - .map((head: TableUpperProps) => { - return ( - - {head.title} - - ); - })} - - + ); - }else{ + }; + + /** 공통 속성과 접근성 정렬 상태를 포함한 헤더 셀을 렌더링한다. */ + const renderHead = (head: UiHeadProps, key: string) => { + const sortKey = head.sortKey ?? head.id; + const direction = sort?.key === sortKey ? sort.direction : undefined; + const sortable = Boolean(head.sortable && isLeafHead(head) && onSortChange); + return ( - <> - {Array.from({ length: maxRow + 1 }, (_, rowIndex) => ( - - {upHeadList - .filter((item) => item.visible != false) - .map((head: TableUpperProps, idx) => { - if (head.row === rowIndex) { - return ( - - {head.title} - - ); - } - })} - - ))} - + + {renderHeadContent(head)} + + ); + }; + + if (maxRow === 0) { + return ( + + {upHeadList + .filter((item) => item.visible !== false) + .map((head) => renderHead(head, head.id))} + ); } - -} + + return ( + <> + {Array.from({ length: maxRow + 1 }, (_, rowIndex) => ( + + {upHeadList + .filter((item) => item.visible !== false && item.row === rowIndex) + .map((head, index) => + renderHead(head, head.id || `${rowIndex}_${index}`), + )} + + ))} + + ); +}; export { HeadMakeColSpan }; diff --git a/packages/common/business_components/src/ui/table/index.tsx b/packages/common/business_components/src/ui/table/index.tsx index e07d1e190..40704b9d4 100644 --- a/packages/common/business_components/src/ui/table/index.tsx +++ b/packages/common/business_components/src/ui/table/index.tsx @@ -12,6 +12,10 @@ import { useEffect, useState, useRef } from "react"; import { HeadMakeColSpan } from "./HeadMakeColSpan"; import UiPageNations from "./uiPageNations"; import { Check } from "lucide-react"; +import type { UiHeadProps, UiTableSort } from "./types"; + +export type { UiHeadProps, UiTableSort, UiTableSortDirection } from "./types"; + export type ChkClass = { chk: boolean; readonly: boolean; @@ -38,17 +42,8 @@ type UiTableProps = { widthFixed?: boolean; isHideCount?: boolean; // 총 건수 default false clickableHeadIds?: string[]; -}; - -type UiHeadProps = { - id: string; - title: string; - align?: string; - visible?: boolean; - row?: number; - widthPercent?: number; - rowSpan?: number; - colSpan?: number; + sort?: UiTableSort; + onSortChange?: (sort: UiTableSort | undefined) => void; }; export function UiTable>({ @@ -72,6 +67,8 @@ export function UiTable>({ widthFixed = false, isHideCount = false, clickableHeadIds, + sort, + onSortChange, }: UiTableProps) { const [cur, setCur] = useState(curPage); const [idx, setIdx] = useState(index); @@ -124,6 +121,22 @@ export function UiTable>({ setIdx(index); }; + /** 정렬 가능한 헤더를 asc, desc, 기본 순서로 순환시킨다. */ + const handleSortChange = (head: UiHeadProps) => { + if (!head.sortable || !onSortChange) return; + + const key = head.sortKey ?? head.id; + if (sort?.key !== key) { + onSortChange({ key, direction: "asc" }); + return; + } + if (sort.direction === "asc") { + onSortChange({ key, direction: "desc" }); + return; + } + onSortChange(undefined); + }; + const getLeafCols = (list: UiHeadProps[]) => { const maxRow = list?.reduce((max, head) => Math.max(max, head.row || 0), 0); return list @@ -169,7 +182,15 @@ export function UiTable>({ "" )} - {children ? children : } + {children ? ( + children + ) : ( + + )} {tableData.length > 0 ? ( diff --git a/packages/common/business_components/src/ui/table/types.ts b/packages/common/business_components/src/ui/table/types.ts new file mode 100644 index 000000000..b2d6bef00 --- /dev/null +++ b/packages/common/business_components/src/ui/table/types.ts @@ -0,0 +1,19 @@ +export type UiTableSortDirection = "asc" | "desc"; + +export type UiTableSort = { + key: string; + direction: UiTableSortDirection; +}; + +export type UiHeadProps = { + id: string; + title: string; + align?: string; + visible?: boolean; + row?: number; + widthPercent?: number; + rowSpan?: number; + colSpan?: number; + sortable?: boolean; + sortKey?: string; +}; From 1708555133f58276dc9e8ca83c980856453570dd Mon Sep 17 00:00:00 2001 From: kiminchan Date: Mon, 20 Jul 2026 15:29:23 +0900 Subject: [PATCH 02/55] =?UTF-8?q?=EC=8B=9C=EC=84=A4=EB=AC=BC=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=20=EC=B2=B4=ED=81=AC=20=EC=8B=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=20=EC=9C=84=EC=B9=98=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../001-fe-FE-MAP-레이어트리스크롤유지-구현기록.md | 32 +++++++++++++++++++ .../business_components/src/ui/map/GisTree.js | 27 +++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 apps/sn/docs/작업내용/FE-MAP-레이어트리스크롤유지/001-fe-FE-MAP-레이어트리스크롤유지-구현기록.md diff --git a/apps/sn/docs/작업내용/FE-MAP-레이어트리스크롤유지/001-fe-FE-MAP-레이어트리스크롤유지-구현기록.md b/apps/sn/docs/작업내용/FE-MAP-레이어트리스크롤유지/001-fe-FE-MAP-레이어트리스크롤유지-구현기록.md new file mode 100644 index 000000000..3183a96e7 --- /dev/null +++ b/apps/sn/docs/작업내용/FE-MAP-레이어트리스크롤유지/001-fe-FE-MAP-레이어트리스크롤유지-구현기록.md @@ -0,0 +1,32 @@ +# 시설물 레이어 트리 스크롤 유지 구현기록 + +## 작업 정보 + +- 작업일: 2026-07-20 +- 모드: `quick_vibe` +- 범위: 프론트엔드 + +## 요구사항 및 원인 + +- 시설물 레이어 트리 하단의 체크박스를 변경해도 세로 스크롤이 현재 위치를 유지해야 한다. +- 기존 `GisTree`는 체크 변경 후 부모 지도 화면과 하단 레이어 설정영역이 다시 렌더되지만, 스크롤 요소의 `scrollTop`을 별도로 보존하지 않아 위치가 초기화될 수 있었다. + +## 변경 내용 + +- `GisTree`의 실제 세로 스크롤 요소를 ref로 추적한다. +- 스크롤 이벤트에서 현재 `scrollTop`을 보관하고, 렌더 완료 직후 유효 범위 안에서 같은 위치를 복원한다. +- API, DB, 백엔드 계약은 변경하지 않았다. + +## 수정 파일 + +- `packages/common/business_components/src/ui/map/GisTree.js` + +## 검증 + +- 공통 비즈니스 컴포넌트 로컬 `tsup` ESM/DTS 빌드 성공 +- `git diff --check` 성공 +- 로컬 `/map`은 인증되지 않은 브라우저 세션에서 로그인 화면으로 전환되어 실제 레이어 체크 검증은 후속 수동 확인으로 남겼다. + +## 롤백 + +- `GisTree.js`의 스크롤 ref, 스크롤 이벤트 핸들러, `useLayoutEffect` 복원 로직을 제거한다. diff --git a/packages/common/business_components/src/ui/map/GisTree.js b/packages/common/business_components/src/ui/map/GisTree.js index de04cb91b..e58d66cbf 100644 --- a/packages/common/business_components/src/ui/map/GisTree.js +++ b/packages/common/business_components/src/ui/map/GisTree.js @@ -5,7 +5,7 @@ import { DndProvider, } from "@minoru/react-dnd-treeview"; import { ChevronLeft, ChevronRight } from "lucide-react"; -import React, { useEffect, useState, useRef, useCallback } from "react"; +import React, { useEffect, useLayoutEffect, useState, useRef, useCallback } from "react"; import { useGisContext } from "./Gis"; import GisApp from "./lib/gis.core"; @@ -66,6 +66,29 @@ export default React.memo(function GisTree({ const targetNodesRef = useRef([]); const isPausedRef = useRef(false); const timeoutIdRef = useRef(null); + // 체크 후 부모 설정영역까지 다시 렌더되어도 사용자가 보던 트리 위치를 유지한다. + const layerPanelScrollRef = useRef(null); + const layerPanelScrollTopRef = useRef(0); + + const handleLayerPanelScroll = useCallback((event) => { + layerPanelScrollTopRef.current = event.currentTarget.scrollTop; + }, []); + + useLayoutEffect(() => { + const scrollContainer = layerPanelScrollRef.current; + if (!scrollContainer) return; + + const maxScrollTop = Math.max( + 0, + scrollContainer.scrollHeight - scrollContainer.clientHeight + ); + const nextScrollTop = Math.min(layerPanelScrollTopRef.current, maxScrollTop); + + if (scrollContainer.scrollTop !== nextScrollTop) { + scrollContainer.scrollTop = nextScrollTop; + } + layerPanelScrollTopRef.current = nextScrollTop; + }); // ===================== // 유틸리티: 트리 탐색 관련 함수들 @@ -533,6 +556,8 @@ export default React.memo(function GisTree({ }`} >
From ec60a05b01635b1e03ceb55f3874b76908a5ecb9 Mon Sep 17 00:00:00 2001 From: kiminchan Date: Wed, 22 Jul 2026 13:15:06 +0900 Subject: [PATCH 03/55] =?UTF-8?q?=EC=B2=98=EB=A6=AC=EB=B6=84=EA=B5=AC?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(with-base-layout)/oper/trtdst/page.tsx | 6 + apps/sn/app/components/lib/api/index.ts | 37 ++ .../001-qa-SFR-010-004~007-조사사업-통합검증계획.md | 56 ++ .../002-qa-SFR-010-004~007-조사사업-통합검증결과.md | 61 +++ .../001-fe-SFR-010-004~007-조사사업-작업카드.md | 70 +++ .../001-fe-SFR-010-004~007-조사사업-통합구현기록.md | 106 ++++ apps/sn/src/features/exmn/api/trtdst-api.ts | 37 ++ apps/sn/src/features/exmn/index.ts | 4 + .../features/exmn/lib/trtdst-validation.ts | 42 ++ .../src/features/exmn/model/trtdst-types.ts | 68 +++ .../features/exmn/ui/TrtdstManagementPage.tsx | 516 ++++++++++++++++++ 11 files changed, 1003 insertions(+) create mode 100644 apps/sn/app/(with-base-layout)/oper/trtdst/page.tsx create mode 100644 apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/001-qa-SFR-010-004~007-조사사업-통합검증계획.md create mode 100644 apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md create mode 100644 apps/sn/docs/작업계획/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-작업카드.md create mode 100644 apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md create mode 100644 apps/sn/src/features/exmn/api/trtdst-api.ts create mode 100644 apps/sn/src/features/exmn/index.ts create mode 100644 apps/sn/src/features/exmn/lib/trtdst-validation.ts create mode 100644 apps/sn/src/features/exmn/model/trtdst-types.ts create mode 100644 apps/sn/src/features/exmn/ui/TrtdstManagementPage.tsx diff --git a/apps/sn/app/(with-base-layout)/oper/trtdst/page.tsx b/apps/sn/app/(with-base-layout)/oper/trtdst/page.tsx new file mode 100644 index 000000000..57b0fc935 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/trtdst/page.tsx @@ -0,0 +1,6 @@ +import { TrtdstManagementPage } from "@/features/exmn/ui/TrtdstManagementPage"; + +/** 처리분구관리 업무 화면 라우트다. */ +export default function Page() { + return ; +} diff --git a/apps/sn/app/components/lib/api/index.ts b/apps/sn/app/components/lib/api/index.ts index ed59ba687..250a2b4cb 100644 --- a/apps/sn/app/components/lib/api/index.ts +++ b/apps/sn/app/components/lib/api/index.ts @@ -174,6 +174,43 @@ export const postRawJson = async (url: string, data: object): Promise => { return body; }; +/** 공통 응답 래퍼를 사용하는 JSON PUT 요청을 전송하고 data 필드만 반환한다. */ +export const putData = async (url: string, data: object): Promise => { + const res = await fetch(resolveApiUrl(url), { + method: "PUT", + headers: withSiteIdHeaders({ + "Content-Type": "application/json", + }), + body: JSON.stringify(data), + }); + if (res.status === 401) { + handleUnauthorizedResponse(); + } + + const text = await res.text(); + let body: unknown = null; + if (text.trim()) { + try { + body = JSON.parse(text); + } catch { + throw buildInvalidJsonResponseError(url, text, res.headers.get("content-type")); + } + } + + if (!res.ok) { + throw buildRequestError( + `Failed to update data: ${res.status} ${res.statusText}`, + res.status, + body, + ); + } + + if (typeof body === "object" && body !== null && "data" in body) { + return (body as { data?: unknown }).data ?? null; + } + return body; +}; + const parseDownloadFileName = (contentDisposition: string | null) => { if (!contentDisposition) return undefined; diff --git a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/001-qa-SFR-010-004~007-조사사업-통합검증계획.md b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/001-qa-SFR-010-004~007-조사사업-통합검증계획.md new file mode 100644 index 000000000..53ce2d632 --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/001-qa-SFR-010-004~007-조사사업-통합검증계획.md @@ -0,0 +1,56 @@ +# SFR-010-004~SFR-010-007 조사사업 통합 검증 계획 + +## 1. 문서 목적 + +- 조사사업 프론트엔드의 단계별 완료 기준과 최종 FE/BE 통합 검증 순서를 관리한다. + +## 2. 검증 범위 + +- FE-0: 처리분구 조회·추가·수정·삭제대기·일괄저장 +- FE-1: `SFR-010-004` 사업등록·파일검토·PPL 집계/상세 +- FE-2: `SFR-010-005` 조사현황·지도 진입 +- FE-3: `SFR-010-006` MNHL 집계·상세 +- FE-4: `SFR-010-007` GIS 레이어·등급 필터·시설 상세 + +## 3. 공통 검증 기준 + +- 변경 파일 TypeScript 오류가 없어야 한다. +- 개발 서버 실행 여부 확인 후 안전한 경우에만 프로덕션 빌드를 수행한다. +- API 요청은 설계한 URL·query·body와 일치해야 한다. +- 401, 403, 404, 409, 422, 500 오류가 성공처럼 처리되지 않아야 한다. +- 다음 단계는 현재 단계 구현과 검증 결과를 기록한 뒤 시작한다. + +## 4. FE-0 검증 계획 + +| 구분 | 시나리오 | 기대 결과 | +| --- | --- | --- | +| 조회 | 전체/시도/시군구 조건 조회 | `useYn=Y`와 선택된 이름만 query에 포함 | +| 추가 | 시도·시군구 미선택/선택 후 추가 | 미선택 차단, 선택 시 행정구역 자동 입력 | +| 수정 | 미선택/삭제대기/정상 행 수정 | 잘못된 대상 차단, 처리분구명만 변경 | +| 중복 | 같은 행정구역에 동일 이름 입력 | 저장 전 오류 표시 | +| 삭제 | 신규/미참조/참조 중 기존 행 삭제 | 로컬 제거/삭제대기/참조 오류 표시 | +| 저장 | 변경 없음/변경 있음/서버 실패 | 차단/변경만 전송/상태와 오류 유지 | +| 재조회 | 미저장 변경이 있는 상태 | 폐기 확인 후 조회 | +| 정렬 | 각 헤더를 세 번 선택 | 오름차순 → 내림차순 → 기본 순서로 순환하고 선택 행 유지 | +| 검색조건 | 시도·시군구 필터 표시 | 두 셀렉트가 겹치거나 눌리지 않고 일정 간격 유지 | +| 버튼 배치 | 조회와 저장 버튼 우측 끝 비교 | 두 버튼의 우측 여백이 같은 기준선에 위치 | +| 버튼 색상 | 조회·수정·추가·삭제·저장 확인 | 모든 주요 동작 버튼 글자가 흰색으로 표시 | + +## 5. 자동·수동 검증 경계 + +- 자동: TypeScript, `git diff --check`, Next 빌드 +- 수동: `/oper/trtdst` 진입, 헤더 정렬, 선택 행 유지, 검색조건 간격, 버튼 우측 정렬·글자색, 모달 입력, 행 상태 색, 메시지 표시 +- 통합: 실제 처리분구 API와 409 참조 제약, 저장 후 사업등록 후보 즉시 갱신 +- 백엔드 미구현 동안 통합 항목은 보류로 기록하고 성공 데이터 목업으로 대체하지 않는다. + +## 6. 다음 검증 순서 + +1. FE-0 화면·UI 사용자 검증 완료 상태 유지 +2. FE-1 구현 후 사업등록 후보 연계의 FE 계약 확인 +3. 프론트 전체 완료 후 백엔드 단계 진행 +4. 백엔드 구현 후 FE-0 실 API와 각 SFR API·브라우저·네트워크 통합 검증 +5. 프로덕션 빌드는 개발 서버 종료와 실행 허용을 확인한 시점에 수행 + +## 7. 흡수한 기존 문서 + +- 없음 diff --git a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md new file mode 100644 index 000000000..18687bc4e --- /dev/null +++ b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md @@ -0,0 +1,61 @@ +# SFR-010-004~SFR-010-007 조사사업 통합 검증 결과 + +## 1. 문서 목적 + +- 구현된 단계의 실제 검증 결과와 차단 사유를 기능 완료와 구분해 누적한다. + +## 2. 수행한 검증 + +| 날짜 | 검증 | 결과 | +| --- | --- | --- | +| 2026-07-22 | `git diff --check` | 통과, 공백 오류 없음 | +| 2026-07-22 | `tsc --noEmit` 기본 실행 | 저장소 기존 `ignoreDeprecations=6.0` 값 오류 확인 | +| 2026-07-22 | `tsc --noEmit --ignoreDeprecations 5.0` | 신규 `trtdst` 파일 오류 0건, 저장소 선행 오류 존재 | +| 2026-07-22 | Next 프로덕션 빌드 | 실행 권한 승인 거절로 미수행 | +| 2026-07-22 | 브라우저/API 통합 | 백엔드·메뉴 원천 미구현으로 미수행 | +| 2026-07-22 | FE-0 import 경계 정적 확인 | 라우트가 client UI 파일을 직접 import하고 대상 파일 존재·`git diff --check` 통과 | +| 2026-07-22 | FE-0 Next 개발 서버 재컴파일 | 실행 권한이 허용되지 않아 미수행, 사용자 재진입 확인 대기 | +| 2026-07-22 | FE-0 메뉴 진입 | 사용자 화면 확인으로 Next Hook 경계 오류 해소와 화면 렌더링 통과 | +| 2026-07-22 | FE-0 UI 마무리 정적 확인 | 공통 정렬 props·정렬 표시 목록 기반 선택·12열 필터 폭·버튼 공통 클래스 확인, UTF-8·`git diff --check` 통과 | +| 2026-07-22 | FE-0 UI 마무리 브라우저 확인 | 3002 개발 서버 미실행으로 사용자 재확인 대기 | +| 2026-07-22 | FE-0 UI 1차 사용자 재확인 | 시도·시군구 간격 미적용, 조회 버튼이 저장 버튼보다 약 `0.6rem` 왼쪽임을 캡처로 확인 | +| 2026-07-22 | FE-0 UI 2차 정적 확인 | `.next` 생성 CSS에 `gap-10`·`col-span-12`가 없음을 확인하고 인라인 `2rem` 간격·고정 폭·조회 버튼 `0.6rem` 이동 적용, UTF-8·공백 검사 통과 | +| 2026-07-22 | FE-0 최종 사용자 확인 | 처리분구관리 화면과 2차 UI 보정이 정상임을 사용자가 확인, 화면 단계 완료 판정 | + +## 3. FE-0 검증 결과 + +- 신규 라우트와 feature 파일은 TypeScript 검사 출력에 오류가 없었다. +- 일괄 저장 변환은 `added → CREATE`, `modified → UPDATE`, `deletePending → DELETE`로 구현됐다. +- 활성 행 전체의 빈 이름과 행정구역별 중복 검증이 저장 전에 실행된다. +- 기존 행 삭제는 참조 API 성공과 참조 건수 0을 확인해야 삭제대기로 전환된다. +- API 실패 시 목록 또는 변경 상태를 성공으로 위장하지 않고 오류 메시지를 표시한다. +- 서버 라우트의 feature 배럴 import를 client UI 직접 import로 교체해 Hook 포함 API 유틸이 서버 import 그래프에 노출되지 않게 했다. +- 공통 `UiTable`의 오름차순·내림차순·기본 순환 상태를 사용하고 표시 목록 기준으로 선택 키를 유지하도록 구현했다. +- 생성 CSS 누락 영향을 받지 않도록 검색조건 필터의 전체 열·간격·폭을 인라인 값으로 고정하고, 조회 버튼을 캡처 차이만큼 이동했다. +- 주요 버튼의 흰색 글자 스타일을 통일했다. + +## 4. 선행 오류와 차단 사유 + +- 저장소 전체 TypeScript에는 지도 컴포넌트 타입, 좌표 튜플, `@common/auth`, `uuid`, 잘못된 alias 등 기존 오류가 남아 있다. +- 이번 변경이 있는 `app/components/lib/api/index.ts`도 기존 `@common/auth` 해석 오류 때문에 전체 검사를 완전 통과하지 못한다. +- 프로덕션 빌드는 의존성 실행에 필요한 권한 승인이 거절되어 결과를 확보하지 못했다. 기능 실패로 판정하지 않는다. +- 변경 파일 단독 TSX 변환 검사는 루트에서 `typescript` 모듈을 직접 찾을 수 없어 수행하지 못했으며, 기존 TypeScript 검사 결과와 소스 정적 확인으로 보완했다. + +## 5. 현재 검증 상태 + +- FE-0 코드 정적 검증: 부분 통과 +- FE-0 프로덕션 빌드: 미수행 +- FE-0 화면 렌더링·최종 UI: 사용자 확인 통과, 화면 단계 완료 +- FE-0 실 API: 백엔드 구현 대기 +- FE-1~FE-4: 미착수 + +## 6. 보류/리스크 + +- 백엔드 응답 필드와 `409` 오류 body를 구현 후 대조해야 한다. +- 메뉴 원천 등록과 화면 진입은 해결됐지만 최종 권한별 노출·직접 URL 접근 제어는 통합 검증에서 확인해야 한다. +- FE-0 화면·UI는 사용자 확인으로 완료됐지만 CRUD 실 API의 성공·오류·409 처리는 백엔드 구현 후 확인해야 한다. +- 프로덕션 빌드는 다음 프론트 단계 시작 전 또는 사용자가 허용한 시점에 다시 실행한다. + +## 7. 흡수한 기존 문서 + +- 없음 diff --git a/apps/sn/docs/작업계획/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-작업카드.md b/apps/sn/docs/작업계획/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-작업카드.md new file mode 100644 index 000000000..b053bd50a --- /dev/null +++ b/apps/sn/docs/작업계획/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-작업카드.md @@ -0,0 +1,70 @@ +# SFR-010-004~SFR-010-007 조사사업 FE 작업카드 + +## 1. 작업 개요 + +- 작업 ID: `SFR-010-004~007-EXMN-NEW-FE-FIRST` +- 작업명: CCTV·맨홀 조사사업 프론트엔드 선행 구현 +- 상태: 진행(`FE-0` 사용자 검증 완료, `FE-1` 시작 대기) +- 작성일: 2026-07-22 +- 담당 역할: FE +- 관련 역할: BE, QA +- 범위: `apps/sn/**` +- 근거: 공통 작업지시와 통합 설계, 조사사업 요구사항명세서 + +## 2. 공통 동기화 확인 + +- 공통 작업현황판·파일점유현황·결정로그 확인 완료 +- `FE-0` 대상 파일만 점유 후 구현했다. +- 구현 순서는 `FE-0 → FE-1(004) → FE-2(005) → FE-3(006) → FE-4(007)`이며 프론트 전체 완료 후 백엔드로 이동한다. + +## 3. 단계별 범위 + +| 단계 | 범위 | 현재 상태 | 다음 단계 진입 조건 | +| --- | --- | --- | --- | +| FE-0 | 공통 타입·API, 처리분구관리 `/oper/trtdst` | 완료(사용자 화면·UI 검증 통과) | 충족 | +| FE-1 | `SFR-010-004` 사업등록·파일검토·PPL 집계/상세 | 시작 대기 | 사용자 진행 지시 | +| FE-2 | `SFR-010-005` 조사현황·지도 진입 | 대기 | FE-1 완료 | +| FE-3 | `SFR-010-006` MNHL 집계·상세 | 대기 | FE-2 완료 | +| FE-4 | `SFR-010-007` GIS 레이어·등급 필터·상세 | 대기 | FE-3 완료 | + +## 4. FE-0 수정 대상 + +| 구분 | 경로 | 결과 | +| --- | --- | --- | +| 라우트 | `app/(with-base-layout)/oper/trtdst/page.tsx` | 신규 | +| 화면 | `src/features/exmn/ui/TrtdstManagementPage.tsx` | 신규 | +| 타입 | `src/features/exmn/model/trtdst-types.ts` | 신규 | +| API | `src/features/exmn/api/trtdst-api.ts` | 신규 | +| 검증 | `src/features/exmn/lib/trtdst-validation.ts` | 신규 | +| 공통 API 유틸 | `app/components/lib/api/index.ts` | `putData` 추가 | +| 공통 비즈니스 컴포넌트 | 변경 없음 | 기존 `SearchDiv`, `SelectBox`, `UiTable`, `UiModal` 재사용 | + +## 5. FE-0 화면 흐름 + +1. `/oper/trtdst`에서 시도·시군구 조건을 선택하고 조회한다. +2. 조회 결과에서 행을 선택해 처리분구명만 수정한다. +3. 시도·시군구를 모두 선택한 상태에서 신규 처리분구를 추가한다. +4. 신규 행은 즉시 제거할 수 있고, 기존 행은 참조 확인 후 삭제대기로 전환한다. +5. 저장 시 전체 활성 행의 필수값·행정구역별 중복을 검증한 뒤 변경 행만 일괄 전송한다. +6. 성공하면 서버 목록을 재조회하며 실패하면 미저장 행 상태와 오류 메시지를 유지한다. + +## 6. API 계약 + +| 기능 | Method | URL | 요청/응답 핵심 | 상태 | +| --- | --- | --- | --- | --- | +| 목록 조회 | GET | `/api/v1/trtdstSettings` | `ctpvNm`, `sggNm`, `useYn=Y` / 활성 목록 | FE 구현 | +| 참조 확인 | GET | `/api/v1/trtdstSettings/{id}/references` | 사업·시설·전체 참조 건수 | FE 구현 | +| 일괄 저장 | PUT | `/api/v1/trtdstSettings/batch` | `items[].changeType=CREATE|UPDATE|DELETE` / 반영 건수 | FE 구현 | + +## 7. 영향과 경계 + +- 신규 경로와 `features/exmn` 내부 모듈만 추가해 기존 업무 화면 동작은 변경하지 않는다. +- 기존 API 유틸에 공통 응답 래퍼를 해제하는 JSON PUT 함수가 추가된다. +- 메뉴 원천에 `TRTDST_MGMT`와 `/oper/trtdst`를 등록하는 배포 작업은 외부 메뉴 데이터 범위다. +- 백엔드 미구현 상태이므로 실 API 성공을 흉내 내는 목업은 두지 않았다. + +## 8. 롤백 + +- 신규 `/oper/trtdst` 라우트와 `src/features/exmn`을 제거한다. +- `app/components/lib/api/index.ts`의 `putData` 추가분만 되돌린다. +- 기존 화면·공통 비즈니스 컴포넌트에는 롤백 대상 변경이 없다. diff --git a/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md b/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md new file mode 100644 index 000000000..ec16b3117 --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md @@ -0,0 +1,106 @@ +# SFR-010-004~SFR-010-007 조사사업 통합 구현 기록 + +## 1. 문서 목적 + +- 조사사업 프론트엔드 선행 구현을 단계별로 한 문서에 누적한다. +- 프론트 전체 완료와 백엔드 통합 완료를 구분해 기록한다. + +## 2. 공통 계약 + +- 구현 순서: `FE-0 → FE-1 → FE-2 → FE-3 → FE-4 → BE-0 → BE-1 → BE-2 → BE-3 → BE-4` +- 처리분구관리 식별자/라우트: `TRTDST_MGMT` / `/oper/trtdst` +- 처리분구는 사업등록 화면에서 생성하지 않고 이 화면의 활성 후보를 선택한다. +- 런타임 목업과 하드코딩 성공 응답은 사용하지 않는다. + +## 3. 구현 라운드 요약 + +| 날짜 | 단계 | 결과 | +| --- | --- | --- | +| 2026-07-22 | FE-0 | 처리분구 조회·추가·명칭수정·삭제대기·일괄저장 UI와 타입/API 계약 구현 | +| 2026-07-22 | FE-0 후속 | 메뉴 진입 시 발생한 Next 서버/클라이언트 import 경계 오류 수정 | +| 2026-07-22 | FE-0 화면 마무리 | 공통 UiTable 정렬과 검색조건·버튼 배치/색상 보완 | +| 2026-07-22 | FE-0 완료 | 2차 UI 보정 후 사용자 화면 검증 통과, FE-1 시작 대기 | + +## 4. FE-0 처리분구관리 + +### 4.1 화면 + +- 기존 업무형 레이아웃 안에 `/oper/trtdst` 라우트를 추가했다. +- 공통 시도·시군구 코드와 `SelectBox`를 사용해 조회조건을 구성했다. +- `UiTable`에 NO, 시도, 시군구, 처리분구명, 변경상태를 표시한다. +- `UiModal`에서 신규 처리분구명 또는 선택 행의 처리분구명만 편집한다. +- 추가·수정·삭제대기 행은 색과 상태명으로 구분하며 삭제대기는 취소할 수 있다. +- API 오류와 성공 결과를 화면 안의 메시지 영역에 유지한다. + +### 4.2 상태와 검증 + +- 행 상태는 `clean | added | modified | deletePending`이다. +- 추가는 시도·시군구 선택을 필수로 하며 현재 행정구역 이름을 자동 입력한다. +- 빈 처리분구명과 동일 행정구역 내 공백/대소문자 정규화 중복을 차단한다. +- 미저장 변경이 있는 상태에서 재조회하면 폐기 여부를 확인한다. +- 신규 행 삭제는 로컬 제거, 기존 행 삭제는 참조 확인 후 삭제대기로 처리한다. + +### 4.3 API + +- `getTrtdstSettings`: 활성 처리분구 조회, 배열과 Spring `content` 응답을 모두 수용한다. +- `getTrtdstReferences`: 조사사업·시설 참조 건수를 조회한다. +- `saveTrtdstSettings`: 변경 행만 `items[]`로 일괄 저장한다. +- 공통 API 유틸에 인증·사이트 헤더와 공통 오류 추출을 재사용하는 `putData`를 추가했다. + +### 4.4 저장 요청 구조 + +```json +{ + "items": [ + { + "changeType": "CREATE", + "ctpvNm": "경기도", + "sggNm": "수원시", + "trtdstNm": "처리분구 A", + "useYn": "Y" + } + ] +} +``` + +### 4.5 메뉴 진입 import 경계 보완 + +- 최초 라우트는 `@/features/exmn` 배럴을 import했고, 이 배럴이 UI뿐 아니라 Hook을 사용하는 기존 `app/components/lib/api/index.ts`까지 서버 컴포넌트 import 그래프에 포함했다. +- Next가 `useEffect`, `useState`를 서버 컴포넌트에서 import한 것으로 판단해 `/oper/trtdst` 메뉴 진입 시 빌드 오류가 발생했다. +- 라우트가 `@/features/exmn/ui/TrtdstManagementPage` 클라이언트 컴포넌트를 직접 import하도록 변경해 서버/클라이언트 경계를 명시했다. +- 기존 API 유틸과 feature 배럴의 공개 계약은 변경하지 않았다. + +### 4.6 사용자 화면 확인 후 UI 마무리 + +- NO, 시도, 시군구, 처리분구명, 변경상태 헤더에 공통 `UiTable`의 `sort`·`onSortChange` 계약을 적용했다. +- 백엔드 정렬 파라미터가 확정되지 않은 FE-0 단계이므로 현재 조회된 활성 처리분구 전체 목록을 한글·숫자 자연순으로 클라이언트 정렬한다. +- 정렬 뒤에도 선택 행의 `localKey`를 기준으로 선택 인덱스를 다시 계산해 수정·삭제 대상이 바뀌지 않게 했다. +- 최초 보완의 `col-span-12`, `gap-10`은 신규 `src/features` 경로의 클래스가 생성 CSS에 포함되지 않아 사용자 화면에서 적용되지 않았다. +- 필터 묶음에 `gridColumn: 1 / -1`, `columnGap: 2rem`을 직접 지정하고 시도·시군구 래퍼 폭을 각각 `16rem`, `17rem`으로 고정해 Tailwind 스캔 여부와 무관하게 간격을 확보했다. +- 조회 버튼은 사용자 캡처에서 저장 버튼보다 약 `0.6rem` 왼쪽인 것을 기준으로 `translateX(0.6rem)`을 적용해 같은 우측 기준선으로 이동했다. +- 조회·수정·추가·삭제·저장 버튼의 배경/흰색 글자 규칙은 하나의 상수로 통일했다. +- 사용자가 최종 화면에서 시도·시군구 간격, 조회·저장 기준선과 처리분구관리 화면 구현 완료를 확인했다. + +## 5. 기능별 현재 상태 + +- FE-0: 화면 구현·사용자 UI 검증 완료, 실 API 연동은 백엔드 구현 후 검증 +- FE-1~FE-4: 미착수 +- 백엔드: 사용자 지시대로 프론트 전체 완료 전 미착수 + +## 6. BE 공유 사항 + +- 조회 성공 데이터는 활성 처리분구 배열 또는 Spring `content` 형태를 FE가 수용한다. +- 참조 응답 필드는 `businessCount`, `facilityCount`, 선택적 `totalCount`다. +- 일괄 저장은 전 항목 단일 트랜잭션이어야 하며 참조 중 삭제는 `409`로 거부해야 한다. +- 저장 성공 응답은 `createdCount`, `updatedCount`, `deletedCount`를 제공한다. + +## 7. 남은 리스크 + +- 실제 백엔드가 없어 성공·409·권한 오류의 네트워크 연동을 확인하지 못했다. +- 메뉴 원천·권한 및 import 경계 문제는 사용자 화면 진입 확인으로 해결됐다. +- 프로덕션 빌드와 실 API 동작은 아직 확인하지 않았으며 백엔드 구현·통합 단계에서 검증해야 한다. +- 저장 후 활성 후보가 사업등록 화면에 즉시 반영되는지는 FE-1과 백엔드 구현 후 확인한다. + +## 8. 흡수한 기존 문서 + +- 없음 diff --git a/apps/sn/src/features/exmn/api/trtdst-api.ts b/apps/sn/src/features/exmn/api/trtdst-api.ts new file mode 100644 index 000000000..3088cdefd --- /dev/null +++ b/apps/sn/src/features/exmn/api/trtdst-api.ts @@ -0,0 +1,37 @@ +import { getData, putData } from "@/components/lib/api"; +import type { + TrtdstBatchRequest, + TrtdstBatchResponse, + TrtdstReferenceSummary, + TrtdstSearchParams, + TrtdstSettingDto, +} from "../model/trtdst-types"; + +/** 조사사업 공통 선행 처리분구 API의 기준 경로다. */ +const TRTDST_API_PATH = "v1/trtdstSettings"; + +/** 선택된 행정구역 조건으로 활성 처리분구 목록을 조회한다. */ +export const getTrtdstSettings = async ( + params: TrtdstSearchParams, +): Promise => { + const query = new URLSearchParams({ useYn: params.useYn }); + if (params.ctpvNm?.trim()) query.set("ctpvNm", params.ctpvNm.trim()); + if (params.sggNm?.trim()) query.set("sggNm", params.sggNm.trim()); + + const response = await getData(`${TRTDST_API_PATH}?${query.toString()}`); + if (Array.isArray(response)) return response as TrtdstSettingDto[]; + if (Array.isArray(response?.content)) return response.content as TrtdstSettingDto[]; + return []; +}; + +/** 처리분구 추가·명칭수정·삭제대기를 한 트랜잭션으로 저장한다. */ +export const saveTrtdstSettings = async ( + request: TrtdstBatchRequest, +): Promise => + putData(`${TRTDST_API_PATH}/batch`, request) as Promise; + +/** 삭제 전 처리분구를 참조하는 조사사업과 시설 건수를 확인한다. */ +export const getTrtdstReferences = async ( + trtdstId: string, +): Promise => + getData(`${TRTDST_API_PATH}/${encodeURIComponent(trtdstId)}/references`) as Promise; diff --git a/apps/sn/src/features/exmn/index.ts b/apps/sn/src/features/exmn/index.ts new file mode 100644 index 000000000..2c9afb145 --- /dev/null +++ b/apps/sn/src/features/exmn/index.ts @@ -0,0 +1,4 @@ +export * from "./api/trtdst-api"; +export * from "./lib/trtdst-validation"; +export * from "./model/trtdst-types"; +export * from "./ui/TrtdstManagementPage"; diff --git a/apps/sn/src/features/exmn/lib/trtdst-validation.ts b/apps/sn/src/features/exmn/lib/trtdst-validation.ts new file mode 100644 index 000000000..f78d8e426 --- /dev/null +++ b/apps/sn/src/features/exmn/lib/trtdst-validation.ts @@ -0,0 +1,42 @@ +import type { + TrtdstBatchItem, + TrtdstTableRow, +} from "../model/trtdst-types"; + +/** 처리분구명 비교 시 공백과 영문 대소문자 차이를 제거한다. */ +const normalizeName = (value: string) => value.trim().toLocaleLowerCase("ko-KR"); + +/** 화면의 활성 행 전체에서 행정구역별 처리분구명 중복을 검사한다. */ +export const validateTrtdstRows = (rows: TrtdstTableRow[]): string | null => { + const activeRows = rows.filter((row) => row.rowStatus !== "deletePending"); + const seen = new Set(); + + for (const row of activeRows) { + if (!row.trtdstNm.trim()) return "처리분구명을 입력해주세요."; + const duplicateKey = [row.ctpvNm.trim(), row.sggNm.trim(), normalizeName(row.trtdstNm)].join("|"); + if (seen.has(duplicateKey)) { + return `${row.ctpvNm} ${row.sggNm}의 처리분구명 '${row.trtdstNm.trim()}'이 중복됩니다.`; + } + seen.add(duplicateKey); + } + + return null; +}; + +/** 화면 변경 상태를 백엔드 일괄 저장 명령 배열로 변환한다. */ +export const buildTrtdstBatchItems = (rows: TrtdstTableRow[]): TrtdstBatchItem[] => + rows + .filter((row) => row.rowStatus !== "clean") + .map((row) => ({ + changeType: + row.rowStatus === "added" + ? "CREATE" + : row.rowStatus === "deletePending" + ? "DELETE" + : "UPDATE", + trtdstId: row.rowStatus === "added" ? undefined : row.trtdstId, + ctpvNm: row.ctpvNm.trim(), + sggNm: row.sggNm.trim(), + trtdstNm: row.trtdstNm.trim(), + useYn: "Y", + })); diff --git a/apps/sn/src/features/exmn/model/trtdst-types.ts b/apps/sn/src/features/exmn/model/trtdst-types.ts new file mode 100644 index 000000000..434adfdd2 --- /dev/null +++ b/apps/sn/src/features/exmn/model/trtdst-types.ts @@ -0,0 +1,68 @@ +/** 처리분구 행이 화면에서 가진 변경 상태다. */ +export type TrtdstRowStatus = "clean" | "added" | "modified" | "deletePending"; + +/** 서버가 반환하는 활성 처리분구 한 건의 계약이다. */ +export interface TrtdstSettingDto { + trtdstId: string; + ctpvNm: string; + sggNm: string; + trtdstNm: string; + useYn?: "Y" | "N"; +} + +/** UiTable 선택과 변경 추적에 필요한 화면 전용 처리분구 행이다. */ +export interface TrtdstTableRow extends TrtdstSettingDto { + localKey: string; + rowNumber: number; + rowStatus: TrtdstRowStatus; + rowStatusLabel: string; + originalTrtdstNm: string; +} + +/** 처리분구 목록 조회 조건이다. 행정구역 이름은 선택된 코드에서 해석한다. */ +export interface TrtdstSearchParams { + ctpvNm?: string; + sggNm?: string; + useYn: "Y"; +} + +/** 일괄 저장에서 처리할 변경 종류다. */ +export type TrtdstChangeType = "CREATE" | "UPDATE" | "DELETE"; + +/** 처리분구 일괄 저장의 단일 변경 명령이다. */ +export interface TrtdstBatchItem { + changeType: TrtdstChangeType; + trtdstId?: string; + ctpvNm: string; + sggNm: string; + trtdstNm: string; + useYn: "Y"; +} + +/** 처리분구 일괄 저장 요청 계약이다. */ +export interface TrtdstBatchRequest { + items: TrtdstBatchItem[]; +} + +/** 처리분구 일괄 저장 결과의 반영 건수 계약이다. */ +export interface TrtdstBatchResponse { + createdCount: number; + updatedCount: number; + deletedCount: number; + items?: TrtdstSettingDto[]; +} + +/** 처리분구를 참조하는 사업·시설 건수 계약이다. */ +export interface TrtdstReferenceSummary { + businessCount: number; + facilityCount: number; + totalCount?: number; +} + +/** 화면에서 일관되게 표시하는 행 상태 이름이다. */ +export const TRTDST_ROW_STATUS_LABEL: Record = { + clean: "", + added: "추가", + modified: "수정", + deletePending: "삭제대기", +}; diff --git a/apps/sn/src/features/exmn/ui/TrtdstManagementPage.tsx b/apps/sn/src/features/exmn/ui/TrtdstManagementPage.tsx new file mode 100644 index 000000000..c50585cc3 --- /dev/null +++ b/apps/sn/src/features/exmn/ui/TrtdstManagementPage.tsx @@ -0,0 +1,516 @@ +"use client"; + +import LoadingOverlay from "@/components/ui/LoadingBar"; +import useStore from "@/store/useStore"; +import { Button, Input, Label } from "@common/components/ui"; +import { + SearchDiv, + SearchFormLeft, + SearchFormRight, + SelectBox, + UiModal, + UiTable, +} from "@common/business_components/ui"; +import type { UiTableSort } from "@common/business_components/ui"; +import ListDiv from "@common/business_components/ui/list_div"; +import { useMemo, useState } from "react"; +import { + getTrtdstReferences, + getTrtdstSettings, + saveTrtdstSettings, +} from "../api/trtdst-api"; +import { buildTrtdstBatchItems, validateTrtdstRows } from "../lib/trtdst-validation"; +import { + TRTDST_ROW_STATUS_LABEL, + type TrtdstSettingDto, + type TrtdstTableRow, +} from "../model/trtdst-types"; + +/** 처리분구 목록 표에 표시할 공통 열 정의다. */ +const TRTDST_TABLE_HEAD = [ + { id: "rowNumber", title: "NO", widthPercent: 8, sortable: true }, + { id: "ctpvNm", title: "시도", widthPercent: 20, sortable: true }, + { id: "sggNm", title: "시군구", widthPercent: 20, sortable: true }, + { id: "trtdstNm", title: "처리분구명", widthPercent: 37, sortable: true }, + { id: "rowStatusLabel", title: "변경상태", widthPercent: 15, sortable: true }, +]; + +/** 시도·시군구 선택박스에서 공통으로 사용하는 전체 옵션이다. */ +const ALL_OPTION = { val: "0", text: "전체" }; + +/** 처리분구 한글·숫자 값을 화면 목록에서 자연스럽게 비교하는 정렬 기준이다. */ +const TRTDST_COLLATOR = new Intl.Collator("ko", { numeric: true, sensitivity: "base" }); + +/** 처리분구 화면의 주요 동작 버튼에 공통으로 적용하는 시각 규칙이다. */ +const PRIMARY_ACTION_BUTTON_CLASS = + "rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white"; + +/** 추가 또는 수정 모달의 현재 동작을 구분한다. */ +type EditorMode = "add" | "edit"; + +/** 화면 상단에 노출하는 성공·오류 메시지 상태다. */ +type Notice = { type: "success" | "error"; text: string } | null; + +/** 브라우저와 SSR 양쪽에서 충돌 가능성이 낮은 로컬 행 키를 만든다. */ +const createLocalKey = () => + typeof crypto !== "undefined" && "randomUUID" in crypto + ? `client:${crypto.randomUUID()}` + : `client:${Date.now()}-${Math.random().toString(36).slice(2)}`; + +/** 서버 처리분구 목록을 UiTable과 변경 추적에 필요한 행 구조로 변환한다. */ +const toTableRows = (items: TrtdstSettingDto[]): TrtdstTableRow[] => + items.map((item, index) => ({ + ...item, + trtdstId: String(item.trtdstId), + ctpvNm: String(item.ctpvNm ?? "").trim(), + sggNm: String(item.sggNm ?? "").trim(), + trtdstNm: String(item.trtdstNm ?? "").trim(), + useYn: item.useYn ?? "Y", + localKey: `server:${item.trtdstId}`, + rowNumber: index + 1, + rowStatus: "clean", + rowStatusLabel: TRTDST_ROW_STATUS_LABEL.clean, + originalTrtdstNm: String(item.trtdstNm ?? "").trim(), + })); + +/** 알 수 없는 API 오류에서도 서버 메시지 또는 안전한 기본 문구를 반환한다. */ +const getErrorMessage = (error: unknown, fallback: string) => + error instanceof Error && error.message.trim() ? error.message : fallback; + +/** 사업등록 후보로 사용할 처리분구 기준정보를 직접 관리하는 FE-0 화면이다. */ +export function TrtdstManagementPage() { + const { ctpv, sgg } = useStore(); + + // 조회와 추가 대상 행정구역 코드 상태다. 이름은 공통 코드 목록에서 해석해 API에 전달한다. + const [ctpvCd, setCtpvCd] = useState("0"); + const [sggCd, setSggCd] = useState("0"); + // 서버 조회 결과와 사용자의 미저장 변경을 함께 보관한다. + const [rows, setRows] = useState([]); + // 공통 UiTable 헤더의 asc·desc·기본 순환 상태를 현재 조회 결과에 적용한다. + const [sort, setSort] = useState(); + // UiTable 선택 행은 저장·재조회 중에도 로컬 키 기준으로 명확히 추적한다. + const [selectedRowKey, setSelectedRowKey] = useState(null); + // 조회·삭제 참조 확인·저장 요청 중 화면의 중복 조작을 막는다. + const [isLoading, setIsLoading] = useState(false); + // API 실패와 저장 성공 결과를 화면 안에서 유지해 사용자가 원인을 확인할 수 있게 한다. + const [notice, setNotice] = useState(null); + // 처리분구명 추가·수정을 동일한 모달로 제공하기 위한 편집 상태다. + const [editorMode, setEditorMode] = useState(null); + const [editorName, setEditorName] = useState(""); + + /** 현재 시도에 속한 시군구만 선택 후보로 제공한다. */ + const availableSgg = useMemo(() => { + if (ctpvCd === "0") return sgg; + return sgg.filter((option) => String(option.val).startsWith(ctpvCd.slice(0, 2))); + }, [ctpvCd, sgg]); + + /** 현재 선택 코드에 해당하는 행정구역 이름을 API 계약용으로 해석한다. */ + const selectedRegion = useMemo(() => ({ + ctpvNm: ctpv.find((option) => String(option.val) === ctpvCd)?.text ?? "", + sggNm: sgg.find((option) => String(option.val) === sggCd)?.text ?? "", + }), [ctpv, ctpvCd, sgg, sggCd]); + + /** 선택된 로컬 키와 일치하는 화면 행을 반환한다. */ + const selectedRow = useMemo( + () => rows.find((row) => row.localKey === selectedRowKey) ?? null, + [rows, selectedRowKey], + ); + + /** 백엔드 정렬 계약 전까지 현재 조회된 전체 처리분구 목록을 공통 UiTable 상태대로 정렬한다. */ + const displayedRows = useMemo(() => { + if (!sort) return rows; + + return [...rows].sort((left, right) => { + const leftValue = left[sort.key as keyof TrtdstTableRow]; + const rightValue = right[sort.key as keyof TrtdstTableRow]; + const comparison = sort.key === "rowNumber" + ? Number(leftValue ?? 0) - Number(rightValue ?? 0) + : TRTDST_COLLATOR.compare(String(leftValue ?? ""), String(rightValue ?? "")); + + if (comparison === 0) return left.rowNumber - right.rowNumber; + return sort.direction === "asc" ? comparison : -comparison; + }); + }, [rows, sort]); + + /** UiTable의 제어 선택 인덱스를 로컬 키에서 계산한다. */ + const selectedRowIndex = useMemo( + () => displayedRows.findIndex((row) => row.localKey === selectedRowKey), + [displayedRows, selectedRowKey], + ); + + /** 미저장 추가·수정·삭제대기가 있는지 계산한다. */ + const hasChanges = useMemo( + () => rows.some((row) => row.rowStatus !== "clean"), + [rows], + ); + + /** 현재 조회조건으로 서버 목록을 읽고 화면 변경 상태를 초기화한다. */ + const fetchRows = async () => { + setIsLoading(true); + setNotice(null); + try { + const items = await getTrtdstSettings({ + ctpvNm: selectedRegion.ctpvNm || undefined, + sggNm: selectedRegion.sggNm || undefined, + useYn: "Y", + }); + setRows(toTableRows(items)); + setSelectedRowKey(null); + } catch (error) { + setRows([]); + setSelectedRowKey(null); + setNotice({ + type: "error", + text: getErrorMessage(error, "처리분구 목록 조회 중 오류가 발생했습니다."), + }); + } finally { + setIsLoading(false); + } + }; + + /** 조회 전 미저장 변경 유실 여부를 확인한 뒤 목록을 다시 읽는다. */ + const handleSearch = async () => { + if (hasChanges && !window.confirm("저장하지 않은 변경사항이 있습니다. 버리고 다시 조회하시겠습니까?")) { + return; + } + await fetchRows(); + }; + + /** 선택 행 또는 현재 행정구역을 기준으로 처리분구명 편집 모달을 연다. */ + const openEditor = (mode: EditorMode) => { + setNotice(null); + if (mode === "add") { + if (!selectedRegion.ctpvNm || !selectedRegion.sggNm) { + setNotice({ type: "error", text: "추가할 시도와 시군구를 먼저 선택해주세요." }); + return; + } + setEditorName(""); + setEditorMode("add"); + return; + } + + if (!selectedRow) { + setNotice({ type: "error", text: "수정할 처리분구를 선택해주세요." }); + return; + } + if (selectedRow.rowStatus === "deletePending") { + setNotice({ type: "error", text: "삭제대기 행은 삭제취소 후 수정할 수 있습니다." }); + return; + } + setEditorName(selectedRow.trtdstNm); + setEditorMode("edit"); + }; + + /** 모달의 처리분구명을 화면 행에 반영하고 중복 입력을 차단한다. */ + const applyEditor = () => { + const nextName = editorName.trim(); + if (!nextName) { + setNotice({ type: "error", text: "처리분구명을 입력해주세요." }); + return; + } + + const targetRegion = editorMode === "edit" && selectedRow + ? { ctpvNm: selectedRow.ctpvNm, sggNm: selectedRow.sggNm } + : selectedRegion; + const hasDuplicate = rows.some((row) => + row.rowStatus !== "deletePending" + && row.localKey !== selectedRow?.localKey + && row.ctpvNm === targetRegion.ctpvNm + && row.sggNm === targetRegion.sggNm + && row.trtdstNm.trim().toLocaleLowerCase("ko-KR") === nextName.toLocaleLowerCase("ko-KR")); + if (hasDuplicate) { + setNotice({ type: "error", text: "같은 행정구역에 동일한 처리분구명이 있습니다." }); + return; + } + + if (editorMode === "add") { + const localKey = createLocalKey(); + setRows((current) => [ + ...current, + { + trtdstId: "", + ctpvNm: targetRegion.ctpvNm, + sggNm: targetRegion.sggNm, + trtdstNm: nextName, + useYn: "Y", + localKey, + rowNumber: current.length + 1, + rowStatus: "added", + rowStatusLabel: TRTDST_ROW_STATUS_LABEL.added, + originalTrtdstNm: "", + }, + ]); + setSelectedRowKey(localKey); + } else if (selectedRow) { + setRows((current) => current.map((row) => { + if (row.localKey !== selectedRow.localKey) return row; + const rowStatus = row.rowStatus === "added" + ? "added" + : nextName === row.originalTrtdstNm + ? "clean" + : "modified"; + return { + ...row, + trtdstNm: nextName, + rowStatus, + rowStatusLabel: TRTDST_ROW_STATUS_LABEL[rowStatus], + }; + })); + } + + setEditorMode(null); + setEditorName(""); + setNotice(null); + }; + + /** 신규 행은 즉시 제거하고 기존 행은 참조 확인 후 삭제대기 또는 삭제취소로 전환한다. */ + const handleDelete = async () => { + setNotice(null); + if (!selectedRow) { + setNotice({ type: "error", text: "삭제할 처리분구를 선택해주세요." }); + return; + } + if (selectedRow.rowStatus === "added") { + setRows((current) => current + .filter((row) => row.localKey !== selectedRow.localKey) + .map((row, index) => ({ ...row, rowNumber: index + 1 }))); + setSelectedRowKey(null); + return; + } + if (selectedRow.rowStatus === "deletePending") { + const restoredStatus = selectedRow.trtdstNm === selectedRow.originalTrtdstNm ? "clean" : "modified"; + setRows((current) => current.map((row) => row.localKey === selectedRow.localKey + ? { + ...row, + rowStatus: restoredStatus, + rowStatusLabel: TRTDST_ROW_STATUS_LABEL[restoredStatus], + } + : row)); + return; + } + + setIsLoading(true); + try { + const references = await getTrtdstReferences(selectedRow.trtdstId); + const businessCount = Number(references?.businessCount ?? 0); + const facilityCount = Number(references?.facilityCount ?? 0); + const totalCount = Number(references?.totalCount ?? businessCount + facilityCount); + if (totalCount > 0) { + setNotice({ + type: "error", + text: `조사사업 ${businessCount}건, 조사시설 ${facilityCount}건이 참조 중이어서 삭제할 수 없습니다.`, + }); + return; + } + setRows((current) => current.map((row) => row.localKey === selectedRow.localKey + ? { ...row, rowStatus: "deletePending", rowStatusLabel: TRTDST_ROW_STATUS_LABEL.deletePending } + : row)); + } catch (error) { + setNotice({ + type: "error", + text: getErrorMessage(error, "처리분구 참조 여부 확인 중 오류가 발생했습니다."), + }); + } finally { + setIsLoading(false); + } + }; + + /** 화면 전체를 검증한 뒤 변경 행만 일괄 저장하고 성공 시 서버 목록으로 동기화한다. */ + const handleSave = async () => { + setNotice(null); + const validationMessage = validateTrtdstRows(rows); + if (validationMessage) { + setNotice({ type: "error", text: validationMessage }); + return; + } + const items = buildTrtdstBatchItems(rows); + if (items.length === 0) { + setNotice({ type: "error", text: "저장할 변경사항이 없습니다." }); + return; + } + + setIsLoading(true); + try { + const result = await saveTrtdstSettings({ items }); + const summary = `추가 ${Number(result?.createdCount ?? 0)}건, 수정 ${Number(result?.updatedCount ?? 0)}건, 삭제 ${Number(result?.deletedCount ?? 0)}건이 반영되었습니다.`; + try { + const refreshed = await getTrtdstSettings({ + ctpvNm: selectedRegion.ctpvNm || undefined, + sggNm: selectedRegion.sggNm || undefined, + useYn: "Y", + }); + setRows(toTableRows(refreshed)); + setSelectedRowKey(null); + setNotice({ type: "success", text: summary }); + } catch (refreshError) { + setNotice({ + type: "error", + text: `${summary} 목록 재조회는 실패했습니다: ${getErrorMessage(refreshError, "재조회 오류")}`, + }); + } + } catch (error) { + setNotice({ + type: "error", + text: getErrorMessage(error, "처리분구 일괄 저장 중 오류가 발생했습니다."), + }); + } finally { + setIsLoading(false); + } + }; + + /** 모달 하단에서 저장 또는 취소를 선택하는 공통 버튼 영역이다. */ + const editorFooter = ( +
+ + +
+ ); + + return ( + <> + + + +
+
+ { + setCtpvCd(String(value)); + setSggCd("0"); + }} + /> +
+
+ setSggCd(String(value))} + /> +
+
+
+ + + +
+ + {notice && ( +
+ {notice.text} +
+ )} + + +
+

처리분구 목록 총 {rows.length.toLocaleString("ko-KR")}건

+
+ + + + +
+
+ + headName="" + headlist={TRTDST_TABLE_HEAD} + tableData={displayedRows} + total={rows.length} + pageSize={Math.max(displayedRows.length, 1)} + curPage={1} + index={selectedRowIndex} + widthFixed + cellClick={(index) => setSelectedRowKey(index === undefined ? null : displayedRows[index]?.localKey ?? null)} + onPageChange={() => undefined} + sort={sort} + onSortChange={setSort} + rowClassName={(row) => row.rowStatus === "deletePending" + ? "!bg-red-50 text-gray-400 line-through" + : row.rowStatus === "added" + ? "!bg-blue-50" + : row.rowStatus === "modified" + ? "!bg-amber-50" + : ""} + /> +
+ + { + if (!open) setEditorMode(null); + }} + bodyClass="max-w-lg" + headerTitle={editorMode === "add" ? "처리분구 추가" : "처리분구명 수정"} + headerTitleClass="text-xl font-semibold text-gray-800" + footerContent={editorFooter} + body={( +
+
+ {editorMode === "edit" && selectedRow + ? `${selectedRow.ctpvNm} ${selectedRow.sggNm}` + : `${selectedRegion.ctpvNm} ${selectedRegion.sggNm}`} +
+
+ + setEditorName(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + applyEditor(); + } + }} + /> +
+
+ )} + /> + + ); +} From 477426c3845298bf99f8bda223e2eded4fa83038 Mon Sep 17 00:00:00 2001 From: kiminchan Date: Thu, 23 Jul 2026 17:06:01 +0900 Subject: [PATCH 04/55] =?UTF-8?q?=EC=A1=B0=EC=82=AC=EC=82=AC=EC=97=85=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=C2=B7=EA=B2=B0=EA=B3=BC=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EB=B0=8F=20=EA=B4=80=EB=A1=9C=C2=B7=EB=A7=A8?= =?UTF-8?q?=ED=99=80=20=EC=A2=85=ED=95=A9=EC=A7=91=EA=B3=84=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/app/(with-base-layout)/layout.tsx | 2 +- .../oper/exmn/register/page.tsx | 6 + .../oper/exmn/status/page.tsx | 6 + .../components/lib/utils/fileUploadUtil.tsx | 2 + .../001-qa-SFR-010-004~007-조사사업-통합검증계획.md | 84 ++- .../002-qa-SFR-010-004~007-조사사업-통합검증결과.md | 107 +++- .../001-fe-SFR-010-004~007-조사사업-작업카드.md | 109 +++- .../001-fe-SFR-010-004~007-조사사업-통합구현기록.md | 190 +++++- .../features/exmn/api/exmn-business-api.ts | 183 ++++++ apps/sn/src/features/exmn/index.ts | 6 + .../exmn/lib/exmn-business-validation.ts | 54 ++ .../exmn/model/exmn-business-types.ts | 210 +++++++ .../features/exmn/model/exmn-sample-data.ts | 163 ++++++ .../exmn/ui/ExmnBusinessRegisterPopupPage.tsx | 327 +++++++++++ .../features/exmn/ui/ExmnRegistrationPage.tsx | 554 ++++++++++++++++++ .../exmn/ui/ExmnResultRegisterPopupPage.tsx | 422 +++++++++++++ .../exmn/ui/ExmnStatusDetailPanel.tsx | 334 +++++++++++ .../src/features/exmn/ui/ExmnStatusPage.tsx | 6 + .../exmn/ui/PplFacilityDetailModal.tsx | 141 +++++ .../src/features/exmn/ui/PplSummaryModal.tsx | 373 ++++++++++++ .../windowPopup/model/popupDefinitions.ts | 13 + .../src/features/windowPopup/model/types.ts | 2 + apps/sn/src/features/windowPopup/registry.tsx | 12 +- .../ui/ExmnBusinessRegisterPopupContent.tsx | 24 + .../ui/ExmnResultRegisterPopupContent.tsx | 46 ++ apps/sn/tailwind.config.ts | 1 + .../src/ui/table/index.tsx | 10 +- 27 files changed, 3376 insertions(+), 11 deletions(-) create mode 100644 apps/sn/app/(with-base-layout)/oper/exmn/register/page.tsx create mode 100644 apps/sn/app/(with-base-layout)/oper/exmn/status/page.tsx create mode 100644 apps/sn/src/features/exmn/api/exmn-business-api.ts create mode 100644 apps/sn/src/features/exmn/lib/exmn-business-validation.ts create mode 100644 apps/sn/src/features/exmn/model/exmn-business-types.ts create mode 100644 apps/sn/src/features/exmn/model/exmn-sample-data.ts create mode 100644 apps/sn/src/features/exmn/ui/ExmnBusinessRegisterPopupPage.tsx create mode 100644 apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx create mode 100644 apps/sn/src/features/exmn/ui/ExmnResultRegisterPopupPage.tsx create mode 100644 apps/sn/src/features/exmn/ui/ExmnStatusDetailPanel.tsx create mode 100644 apps/sn/src/features/exmn/ui/ExmnStatusPage.tsx create mode 100644 apps/sn/src/features/exmn/ui/PplFacilityDetailModal.tsx create mode 100644 apps/sn/src/features/exmn/ui/PplSummaryModal.tsx create mode 100644 apps/sn/src/features/windowPopup/ui/ExmnBusinessRegisterPopupContent.tsx create mode 100644 apps/sn/src/features/windowPopup/ui/ExmnResultRegisterPopupContent.tsx diff --git a/apps/sn/app/(with-base-layout)/layout.tsx b/apps/sn/app/(with-base-layout)/layout.tsx index 9cf3614f4..35a09cf33 100644 --- a/apps/sn/app/(with-base-layout)/layout.tsx +++ b/apps/sn/app/(with-base-layout)/layout.tsx @@ -58,4 +58,4 @@ export default function WithBaseLayout({ {children} ); -} \ No newline at end of file +} diff --git a/apps/sn/app/(with-base-layout)/oper/exmn/register/page.tsx b/apps/sn/app/(with-base-layout)/oper/exmn/register/page.tsx new file mode 100644 index 000000000..bf2fa6ac3 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/exmn/register/page.tsx @@ -0,0 +1,6 @@ +import { ExmnRegistrationPage } from "@/features/exmn/ui/ExmnRegistrationPage"; + +/** SFR-010-004 사업정보·조사결과 등록 화면 라우트다. */ +export default function Page() { + return ; +} diff --git a/apps/sn/app/(with-base-layout)/oper/exmn/status/page.tsx b/apps/sn/app/(with-base-layout)/oper/exmn/status/page.tsx new file mode 100644 index 000000000..e99f6eb4e --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/exmn/status/page.tsx @@ -0,0 +1,6 @@ +import { ExmnStatusPage } from "@/features/exmn/ui/ExmnStatusPage"; + +/** SFR-010-005 조사현황과 처리분구별 조사 상세현황 라우트다. */ +export default function Page() { + return ; +} diff --git a/apps/sn/app/components/lib/utils/fileUploadUtil.tsx b/apps/sn/app/components/lib/utils/fileUploadUtil.tsx index fdbab1560..39e38c57c 100644 --- a/apps/sn/app/components/lib/utils/fileUploadUtil.tsx +++ b/apps/sn/app/components/lib/utils/fileUploadUtil.tsx @@ -21,6 +21,7 @@ type UploadOptions = { handleProgress?: (percentage: string) => void; handleSuccess?: (upload: Upload) => void; handleResponse?: (res: HttpResponse) => void; + handleError?: (error: Error) => void; }; export const handleUpload = (files: FileList, options?: UploadOptions) => { @@ -43,6 +44,7 @@ export const handleUpload = (files: FileList, options?: UploadOptions) => { metadata, onError(error) { console.error(`Failed because: ${error}`); + options?.handleError?.(error); }, onProgress(bytesUploaded, bytesTotal) { const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2); diff --git a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/001-qa-SFR-010-004~007-조사사업-통합검증계획.md b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/001-qa-SFR-010-004~007-조사사업-통합검증계획.md index 53ce2d632..0c60db9bd 100644 --- a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/001-qa-SFR-010-004~007-조사사업-통합검증계획.md +++ b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/001-qa-SFR-010-004~007-조사사업-통합검증계획.md @@ -41,7 +41,7 @@ - 자동: TypeScript, `git diff --check`, Next 빌드 - 수동: `/oper/trtdst` 진입, 헤더 정렬, 선택 행 유지, 검색조건 간격, 버튼 우측 정렬·글자색, 모달 입력, 행 상태 색, 메시지 표시 - 통합: 실제 처리분구 API와 409 참조 제약, 저장 후 사업등록 후보 즉시 갱신 -- 백엔드 미구현 동안 통합 항목은 보류로 기록하고 성공 데이터 목업으로 대체하지 않는다. +- 백엔드 미구현 동안 실 API 통합 항목은 보류로 기록한다. 사용자 승인 샘플은 `sample=Y`에만 격리하고 일반 화면 성공 데이터로 대체하지 않는다. ## 6. 다음 검증 순서 @@ -54,3 +54,85 @@ ## 7. 흡수한 기존 문서 - 없음 + +## 8. FE-1 검증 계획 + +| 구분 | 시나리오 | 기대 결과 | +| --- | --- | --- | +| 목록 | 조건 조회·페이지 이동·헤더 정렬 | 적용 조건 유지, 0-base page와 단일 sort 전달 | +| 선택 | 미선택 상태에서 결과등록/PPL 상세 | 선택 안내 후 popup 진입 차단 | +| 팝업 체계 | 사업등록·결과등록 클릭 | 공통 `/popup/[popupKey]` 윈도우가 열리고 차단 시 안내 | +| 사업등록 | 필수값·연도·음수·처리분구 미선택 | 저장 전 항목별 오류 표시 | +| 처리분구 | 행정구역 선택/변경 | 같은 지역 `useYn=Y` 후보 조회, 이전 선택 초기화 | +| 양식 | 저장 전/후 `양식 받기` | 저장 전 안내, 저장 후 생성과 다운로드를 한 번에 수행 | +| 사업등록 입력 | 사업등록 popup 확인 | 첫 줄에 사업년도·시도·시군구·정밀조사연장이 표시되고 연장 라벨은 한 줄 유지 | +| 처리분구 목록 | 처리분구 후보가 많은 사업등록 popup | 3열 7행 21개까지 표시하고 22개부터 목록 내부 세로 스크롤 | +| 정밀조사연장 | 입력칸 확인·숫자 입력 | 증감 스피너 없음, 숫자·소수점만 입력, 최대 10자 폭 | +| 결과등록 요약 | 결과등록 popup 진입 | 사업차수·사업년도·행정구역·사업명 순서로 적정 간격을 두고 한 줄 표시 | +| 결과등록 파일 목록 | 파일 5종 영역 확인 | 연한 외곽선·헤더·행 구분선만 표시되고 강한 세로 격자 없음 | +| 결과등록 스크롤 | 최초 popup 진입 | 가로·창 세로 스크롤 없음, 검토결과 초과 시 본문 내부 세로 스크롤만 표시 | +| 결과등록 행 높이 | 파일 5종 최초 표시 | 파일 선택 영역이 남는 세로 공간을 활용하되 창 스크롤은 만들지 않음 | +| 결과등록 초기 크기 | 결과등록 버튼으로 신규 popup 진입 | 콘텐츠 설정 `980×340`, Chrome 외곽 포함 약 `980×410` | +| 파일 열 정렬 | 헤더와 5개 파일 행 비교 | 찾기·데이터 검토 헤더와 버튼 중심선 일치 | +| 파일 선택 표시 | 파일 선택 전후 비교 | 선택 파일명은 파란색 굵은 글씨와 옅은 파란 배경으로 변경 | +| Hydration | 사업등록 popup 최초 진입 | Select `aria-controls` 불일치 경고 없이 client mount 후 표시 | +| 파일 1차 검사 | 빈 파일·200MB 초과·확장자 오류 | 서버 전송 전 차단 | +| 파일 검토 | PPL/MNHL 미선택·정상·업무 오류 | 최소 1개 요구, 정상 요약 또는 위치 포함 오류 표시 | +| 최종 저장 | 검토 전/후·Tus 실패·commit 실패 | 검토 전 비활성, 실패를 성공 처리하지 않고 원인 유지 | +| PPL 집계 | 결함 건수 클릭 | 처리분구·결함 분류로 시설 목록 연결 | +| PPL 상세 | 시설·단위 선택 | 정규화 unit/detail 표시, STATUS/DEFECT 구분 | +| 보고서 | 시설 상세 보고서 버튼 | 매핑 미확정 안내와 비활성 유지 | +| 스타일 | 신규 route·모달 Tailwind | `src/features` 클래스가 생성돼 레이아웃 적용 | +| 상단 구성 | 검색조건·조회·사업등록·결과등록 | 한 카드에 표시되고 사업명 입력 폭이 과도하지 않음 | +| 공통 선택 | 사업년도·시도·시군구 | 연도 선택과 I/I 지역 선택 재사용, 지역 기본값 고정 없음 | +| 목록 열 | 사용자 확정 열 순서·단위 | NO부터 보고서까지 9개 열과 km·개소 단위 일치 | +| 버튼 | 상단 업무 버튼 | `PPL 집계·상세` 직접 버튼 미표시 | +| 샘플 격리 | 일반 URL / `?sample=Y` | 일반 URL은 API만 사용, sample URL만 샘플 사업·처리분구·검토결과 표시 | + +## 9. FE-1 수동 확인 경로 + +1. 메뉴 연결 URL을 `/oper/exmn/register`로 등록하거나 주소로 직접 진입한다. +2. 조회조건과 빈 사업 목록 레이아웃을 확인한다. +3. 백엔드 데이터가 없으면 개발 환경의 `샘플 데이터 보기`를 눌러 `sample=Y` 샘플 사업목록을 확인한다. +4. `사업등록` WindowPopup을 열어 행정구역·가나다순 처리분구 체크박스·필수값·양식 버튼을 확인한다. +5. 사업 행 선택 후 `결과등록` WindowPopup의 파일 5종, 찾기, 데이터 검토, 저장 활성 조건을 확인한다. +6. 상단에 `PPL 집계·상세` 직접 버튼이 없고 목록 열 순서·단위가 확정안과 일치하는지 확인한다. +7. 일반 URL의 백엔드 미구현 오류는 화면 오류 메시지로 표시되는지 확인하고 샘플 성공으로 오해하지 않는다. + +## 10. 조사현황·집계 1차 검증 계획 + +| 구분 | 시나리오 | 기대 결과 | +| --- | --- | --- | +| 진입 | `/oper/exmn/register`, `?sample=Y` | 기존 통합 화면 유지, 일반 URL은 실 API, sample URL만 샘플 데이터 사용 | +| 목록 | 조건 조회·페이지·정렬·행 선택 | 등록 화면과 같은 조건 계약, 선택 행 강조 | +| 상세현황 | 사업 행의 어느 데이터 셀 선택 | 하단 조사 상세현황 표시, 상단과 중복되는 선택 사업 요약 띠는 표시하지 않음 | +| 선택 전 상태 | 최초 진입·재조회로 선택 해제 | 조사 상세현황 영역 자체가 표시되지 않음 | +| 명칭 | 조사유형 탭 확인 | `관로조사`, `맨홀조사`로 표시 | +| 관로 집계 | 관로조사 탭 | 대상연장·주행거리·전체·구조·운영·기타와 합계 표시 | +| 맨홀 집계 | 맨홀조사 탭 | 전체·구조·운영·기타와 합계 표시 | +| 버튼 상태 | 미선택/선택 상태 | 미선택 시 상세 패널 미표시, 선택 후 세 버튼 표시·미연결 안내 | +| 보류 경계 | 상세보기·지도보기 클릭 | 상세 화면·지도 이동 없이 후속/보류 안내 | + +- 원본 PPT의 시각 배치 확인 후 실제 브라우저에서 상·하단 카드 높이와 열 줄바꿈을 사용자 재확인한다. +- 상세보기 실제 화면, 엑셀 파일, 지도 컨텍스트 전달은 이번 1차 완료 기준에서 제외한다. +- 로그인된 사용자 화면에서 목록 전체 셀 선택, 관로·맨홀 전환, 목록·상세 배치와 집계 표시를 확인해 조사관리 상세현황 1차 수동 검증은 완료했다. + +## 11. 조사사업 목록·상세현황 최종 회귀 계획 + +| 구분 | 확인 방법 | 기대 결과 | +| --- | --- | --- | +| 검색조건 | 사업차수·사업연도·시도·시군구를 차례로 변경 후 조회 | 조건 간격이 자연스럽고 기존 연도·행정구역 연동 동작 유지 | +| 행 선택 | NO부터 보고서까지 각 열의 빈 공간·텍스트 주변 선택 | 어느 위치에서도 같은 행의 상세현황 표시, 선택 행 강조 | +| 정렬 | 각 정렬 헤더를 3회 연속 선택 | 오름차순→내림차순→기본 순환, 공통 아이콘 상태 일치 | +| 데이터 원천 | 일반 URL과 `sample=Y`에서 같은 열 정렬 | 서버 결과와 샘플 결과가 같은 값 비교 규칙 사용 | +| 목록 높이 | 샘플 사업 4건 확인 | 3행은 하단선까지 온전히 표시, 4번째부터 목록 내부 스크롤 | +| 폭·경계 | 목록과 상세 좌우 끝선 및 스크롤 상태 비교 | 같은 가로 폭·여백, 상·하단 구분선 잘림 없음 | +| 조사유형 | 관로·맨홀 버튼의 가장자리와 중앙 선택 | 보이는 button 전체가 클릭되고 해당 유형 집계 재조회 | +| 요청 순서 | 사업·조사유형을 연속으로 빠르게 변경 | 마지막 선택의 응답만 화면에 반영 | +| 합계 | 관로·맨홀 합계 행 DOM·화면 확인 | NO+처리분구 실제 병합, 중앙 정렬, 배경 `#d2deef` | +| 상세 스크롤 | 처리분구 10행을 위아래로 스크롤 | 헤더 고정, 다음 행 일부 노출 없음, 경계선 유지 | +| 페이지 높이 | 상세 표시 전후·Footer 높이 변화·화면 이탈 확인 | 문서 전체 세로 스크롤 없음, 내부 스크롤 사용, 이탈 시 overflow 복원 | +| 공통 회귀 | 다른 BaseLayout 화면 진입 | 공통 Header·Footer와 레이아웃 동작 변화 없음 | +| 팝업 회귀 | 사업등록·결과등록 WindowPopup 재진입 | 확정된 팝업 크기·배치·버튼·처리분구·파일행 디자인 유지 | + +- 상세보기·다운로드 실제 동작, 지도 이동, 실 API 성공 응답은 이번 화면 회귀의 완료 범위에 포함하지 않는다. diff --git a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md index 18687bc4e..33ade2c2c 100644 --- a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md @@ -21,6 +21,38 @@ | 2026-07-22 | FE-0 UI 1차 사용자 재확인 | 시도·시군구 간격 미적용, 조회 버튼이 저장 버튼보다 약 `0.6rem` 왼쪽임을 캡처로 확인 | | 2026-07-22 | FE-0 UI 2차 정적 확인 | `.next` 생성 CSS에 `gap-10`·`col-span-12`가 없음을 확인하고 인라인 `2rem` 간격·고정 폭·조회 버튼 `0.6rem` 이동 적용, UTF-8·공백 검사 통과 | | 2026-07-22 | FE-0 최종 사용자 확인 | 처리분구관리 화면과 2차 UI 보정이 정상임을 사용자가 확인, 화면 단계 완료 판정 | +| 2026-07-22 | FE-1 import·Hook·API 경로 정적 검사 | 상대 import 누락과 client 경계 누락 없음, 승인된 SFR-010-004 API 경로 확인 | +| 2026-07-22 | FE-1 공백·한글 검사 | 신규/수정 대상 후행 공백 없음, 한글 본문 UTF-8 재조회 정상 | +| 2026-07-22 | FE-1 `git diff --check` | 추적 파일 통과, 신규 파일은 별도 후행 공백 검사 통과 | +| 2026-07-22 | FE-1 TypeScript 실행 | pnpm 비대화형 의존성 정리 중단 및 compiler 파일 샌드박스 `EPERM`으로 결과 미확보 | +| 2026-07-22 | FE-1 Tailwind 경로 확인 | 기존 content에 `src/features`가 없음을 확인하고 `./src/**/*` 추가 | +| 2026-07-22 | FE-1 브라우저·실 API | 3002 서버 미실행·백엔드 미구현으로 사용자 화면 확인 및 통합 단계 대기 | +| 2026-07-22 | FE-1 사용자 1차 화면 확인 | 연도·지역 선택 컴포넌트, 사업명 폭, 상단 영역 분리, 불필요 PPL 버튼, 목록 열 순서 보완 요청 확인 | +| 2026-07-22 | FE-1 사용자 1차 보정 정적 확인 | 기존 연도 옵션·I/I 지역 선택 재사용, 상단 단일 카드, PPL 버튼 제거, 9개 열 순서·단위 반영 | +| 2026-07-22 | FE-1 WindowPopup 전환 정적 확인 | popup key·registry·content·부모 메시지, 처리분구 가나다순 체크 선택, 결과 파일 5종 확인 | +| 2026-07-22 | FE-1 샘플 경로 Next 개발 서버 확인 | `/oper/exmn/register?sample=Y`, 사업등록 popup, 결과등록 popup 모두 HTTP 200 | +| 2026-07-22 | FE-1 사업등록 2차 보정 | popup `920×620`, 5개 입력, 버튼 구성·green 스타일, client mount hydration 방어 정적 확인 | +| 2026-07-22 | FE-1 샘플 전환·라우트 재확인 | 일반 목록, 샘플 목록, 사업등록 샘플 popup, 결과등록 샘플 popup 모두 HTTP 200 | +| 2026-07-22 | FE-1 사업등록 3차 배치 보정 | 정밀조사연장 첫 줄 이동, 처리분구 높이 `20rem` 확장, 샘플 배지 제거 및 popup HTTP 200 확인 | +| 2026-07-22 | FE-1 사업등록 4차 배치 보정 | 정밀조사연장 한 줄·입력 `7rem`, 처리분구 높이 `21rem`, 샘플 21개 및 초과 스크롤 구조 확인 | +| 2026-07-22 | FE-1 팝업 5차 보정 | 연장 스피너 제거·최대 10자, 결과 popup `980×620`, 4개 사업요약·경계선 table, 샘플 배지 제거 및 두 popup HTTP 200 | +| 2026-07-22 | FE-1 결과 popup 6차 보정 | `980×460`, 닫기 제거, 강한 격자 제거, 창 스크롤 차단·본문 조건부 스크롤 및 HTTP 200 확인 | +| 2026-07-22 | FE-1 결과 popup 7차 보정 | html/body 문서 스크롤 잠금·복원, 업로드 행 높이 확대 및 HTTP 200 정적 확인 | +| 2026-07-22 | FE-1 결과 popup 최종 크기 보정 | 두 캡처 외곽 높이 차이를 반영해 definition `980×340`으로 조정 | +| 2026-07-22 | FE-1 결과 popup 최종 UI 보정 | 헤더·행 `gap-3` 통일, 선택 파일명 파란색·굵게 표시 및 HTTP 200 확인 | +| 2026-07-22 | FE-1 결과 사업요약 순서 보정 | 사업차수→사업년도→행정구역→사업명, 비율 열·`gap-8` 적용 | +| 2026-07-22 | FE-1 사업·결과 WindowPopup 사용자 확인 | 사업등록과 조사결과 업로드 최종 UI 완료 판정 | +| 2026-07-23 | 조사현황 목록·상세 스크롤 보정 정적 확인 | 가로 여백 `1.2rem`, 전체 셀 행 선택, 양쪽 내부 스크롤 및 목록 페이지네이션 제거 확인 | +| 2026-07-23 | 변경 파일 공백·UTF-8 검사 | `git diff --check` 통과, 대상 코드·문서 UTF-8 확인 | +| 2026-07-23 | 조사현황 브라우저 재검증 | 3002 개발 서버 미실행으로 미수행 | +| 2026-07-23 | 조사현황 사용자 재확인 | 목록·상세 실제 가로 경계 불일치, 상세 하단 여백, 일부 열만 클릭되는 문제 확인 | +| 2026-07-23 | 조사현황 2차 구조 보정 | 동일 외부 스크롤 래퍼·stable gutter, 상세 남은 높이 사용, 전체 열 기존 `cellClick` 적용 | +| 2026-07-23 | 조사현황 사용자 3차 재확인 | 목록 3행에서도 스크롤·하단선 잘림, 행·탭의 시각 영역과 실제 클릭 영역 불일치 확인 | +| 2026-07-23 | 조사현황 3차 구조 보정 | 목록 `18rem`, 공통 표 `
` 단위 `rowClick`, 80×32px 실제 button 탭, 상세 샘플 10행 적용 | +| 2026-07-23 | 조사현황 사용자 4차 재확인 | 정밀조사 연장 우측 일부부터 보고서 영역만 선택되고 앞쪽 셀은 클릭되지 않아 공통 `rowClick` 방식 실패 판정 | +| 2026-07-23 | 조사현황 4차 클릭 구조 교체 | 목록을 공통 Table 스타일의 화면 전용 표로 교체하고 모든 데이터 셀을 전체 면적 button으로 구성, 공통 `rowClick` 제거 | +| 2026-07-23 | 조사관리 상세현황 사용자 최종 확인 | 전체 행 선택, 관로·맨홀 탭, 목록·상세 배치와 상세 집계 표시 확인 완료. 조사관리 상세현황 화면 단계 완료 판정 | +| 2026-07-23 | git status·문서 정합성 점검 | 추적 코드 8개·신규 코드 15개와 FE 문서 4개를 대조하고 작업카드·구현·검증 문서의 단계 상태와 실제 파일 목록을 동기화 | ## 3. FE-0 검증 결과 @@ -47,7 +79,11 @@ - FE-0 프로덕션 빌드: 미수행 - FE-0 화면 렌더링·최종 UI: 사용자 확인 통과, 화면 단계 완료 - FE-0 실 API: 백엔드 구현 대기 -- FE-1~FE-4: 미착수 +- FE-1 코드: 사업등록·결과등록 WindowPopup과 조사관리 상세현황 UI 구현·개발 서버 컴파일·사용자 확인 완료, 실 API 검증 대기 +- 조사현황·관로·맨홀 종합집계 화면: 사용자 확인 통과, 화면 단계 완료 +- 상세보기 실제 화면·다운로드·지도보기: 미착수/보류 +- 사용자 확인 기준 화면 1~3단계: 완료 +- 공식 요구사항 기준 FE-3 상세 기능과 FE-4 GIS: 미착수. 현재 맨홀은 종합집계 UI까지만 완료 ## 6. 보류/리스크 @@ -55,6 +91,75 @@ - 메뉴 원천 등록과 화면 진입은 해결됐지만 최종 권한별 노출·직접 URL 접근 제어는 통합 검증에서 확인해야 한다. - FE-0 화면·UI는 사용자 확인으로 완료됐지만 CRUD 실 API의 성공·오류·409 처리는 백엔드 구현 후 확인해야 한다. - 프로덕션 빌드는 다음 프론트 단계 시작 전 또는 사용자가 허용한 시점에 다시 실행한다. +- FE-1 multipart 필드명과 응답 DTO는 백엔드 구현 시 최종 대조해야 한다. +- TypeScript 실행 결과를 확보하지 못했으므로 사용자 개발 서버의 Next 컴파일 결과를 FE-1 단계 종료 전에 확인해야 한다. + +## 8. FE-1 정적 검증 결과 + +- `/oper/exmn/register/page.tsx`는 client UI를 직접 import해 FE-0의 서버/클라이언트 배럴 오류를 반복하지 않는다. +- 사업 조회조건은 draft/applied로 분리되고 페이지·정렬 요청에도 적용 조건을 유지한다. +- 사업등록은 활성 처리분구 조회와 필수값·연도·음수·선택 검증을 저장 전에 수행한다. +- 파일 변경 시 기존 검토 결과를 폐기하고, 정상 검토 전 최종 저장 버튼을 비활성화한다. +- Tus 업로드 실패 콜백이 Promise reject와 화면 오류로 연결된다. +- PPL 상세는 `PPL_UNIT_V1` 정규화 계약의 단위·상태·결함 필드를 렌더링하고 보고서 미확정 기능은 활성화하지 않는다. +- 일반 URL의 런타임 성공 목업과 자동 fallback은 없다. 사용자 승인 샘플은 `sample=Y`에서만 명시적으로 격리한다. +- 사용자 1차 보정으로 직접 연도 입력과 독립 지역 셀렉트를 제거하고 기존 `getStartYearArray`·`CtpvSggPrcsFcltSelectBox`를 재사용했다. +- 상단 검색조건과 3개 액션을 하나의 `SearchDiv`에 두고 `PPL 집계·상세` 직접 버튼을 제거했다. +- 목록은 사용자 확정 9개 열로 재배치했으며 총사업비는 등록 필드로만 유지한다. +- 사업등록·결과등록은 공통 `windowPopup`의 definition→registry→content 흐름으로 열리고 완료 메시지는 동일 origin 부모창에만 반영한다. +- 사업등록 처리분구는 한글 가나다순 체크박스이며, 결과등록은 캡처 기준 파일 5종과 행별 찾기·데이터 검토 구조를 사용한다. +- 샘플 URL 세 경로는 개발 서버에서 HTTP 200을 확인했고, 사업등록·결과등록의 클릭·선택·팝업 크기·실제 시각 배치는 사용자 확인을 통과했다. +- 일반 목록의 개발 전용 `샘플 데이터 보기`는 `sample=Y`로 재진입하며, 자동 성공 fallback 없이 샘플 사업 선택과 결과등록 진입을 가능하게 한다. +- popup content의 첫 서버 출력에는 Radix Select를 렌더링하지 않고 client mount 후 화면을 구성해 보고된 hydration ID 불일치를 차단했다. + +## 9. 조사현황·집계 1차 정적 검증 결과 + +- 원본 화면설계서 슬라이드 3·4를 이미지로 렌더링해 상단 사업목록, 행 선택, 하단 조사 상세현황, 두 조사유형 탭과 세 버튼 위치를 대조했다. +- 신규 route는 client UI를 직접 import해 Hook 포함 feature 배럴이 서버 컴포넌트 import 경계에 들어가지 않게 했다. +- 일반 모드와 `sample=Y`를 분리했고 샘플은 사업별 처리분구 수에 맞춘 관로·맨홀 집계만 반환한다. +- `CCTV조사` 사용자 표출은 신규 조사현황 화면에 사용하지 않고 `관로조사`, `맨홀조사`로 구성했다. +- 세 버튼은 실제 상세·파일·지도 동작을 성공으로 가장하지 않고 미연결 또는 보류 안내만 제공한다. +- 첫 구현의 별도 조사현황 목록을 제거하고 `ExmnStatusDetailPanel`을 기존 `ExmnRegistrationPage` 목록 아래에 결합해 PPT 3·4의 동일 화면 선택 전·후 구조로 수정했다. +- 선택 전에는 패널을 렌더링하지 않고 사업 행 선택 후에만 같은 화면 하단에 패널을 추가한다. +- 사용자 캡처에서 불필요한 선택 사업 요약 띠를 제거하고 조사유형을 공통 `UITab`으로 교체했다. 후속 보정에서는 분석도구의 활성 메뉴 느낌과 맞도록 기본 파란 활성 탭을 컴팩트하게 적용했다. +- 합계 행은 앞의 두 열을 `colSpan=2`로 병합하고 연한 배경·굵은 글씨를 적용해 일반 처리분구 행과 구분했다. +- 기본 `Table`의 외부 스크롤과 sticky `thead` 조합에서 스크롤 중 행 일부가 잘려 보이는 브라우저 문제가 사용자 캡처로 확인됐다. +- 집계표를 공통 `UiTable`로 교체하고 내부 표 래퍼를 유일한 스크롤 컨테이너로 지정했다. 사용자 캡처에서 `16.2rem`은 데이터 3번째 행의 하단선을 자르는 것이 확인되어 `16.7rem`으로 보정했다. +- 실행 중인 개발 서버의 `/oper/exmn/register?sample=Y` 요청은 변경 후 HTTP 200을 반환했다. 페이지 전체 스크롤 제거와 그리드 내부 스크롤의 최종 시각 확인은 사용자 재확인 대상으로 남긴다. +- 후속 캡처에서 남은 페이지 스크롤의 원인을 `BaseLayout`의 `calc(100vh - 50px)`와 실제 `6rem` 헤더 높이 차이로 확인했다. 최초 경로 전용 CSS는 실제 SSR DOM에 존재하는 `group/sidebar-wrapper` 중간 래퍼를 건너뛰어 매칭되지 않았고, 선택자를 `.sn-exmn-base-layout > div > .flex-1 > .flex > .flex-1`로 정정했다. +- 합계 표시는 관로 열 비율 `6%+18%`, 맨홀 열 비율 `8%+28%`를 기준으로 첫 두 열 전체 중앙에 오도록 표시 영역을 확장했다. +- 보정 후 샘플 경로 HTTP 200, 초기 HTML의 `sn-exmn-base-layout` 및 `group/sidebar-wrapper` DOM 연결, 개발 CSS의 정확한 전용 선택자와 `max-height: 16.7rem` 생성을 확인했다. 실제 브라우저의 우측 스크롤 제거와 3번째 행 하단선 표시는 사용자 최종 확인 대상으로 남긴다. +- 상세현황 표시 여부와 무관하게 조사사업 본문·Footer 합계가 뷰포트 높이를 넘지 않도록 route 전용 고정 높이와 flex 축소 구조를 적용했다. Next 개발 서버 생성 CSS에서 `height: calc(100dvh - 6rem)`을 확인했다. +- 공통 `UiTable`의 `rowClick` 코드와 선택 상태가 Next 서버 번들에 생성된 것은 확인했으나 사용자 재확인에서 전체 열 클릭을 보장하지 못했다. 해당 방식은 검증 실패로 판정하고 기존 `cellClick` 전체 열 연결로 교체했다. +- 3002 포트에 개발 서버 listener가 없어 신규 route의 HTTP 렌더 검증은 확보하지 못했다. +- 저장소 로컬 TypeScript compiler는 샌드박스 `EPERM` 후 권한 요청이 허용되지 않아 실행 결과를 확보하지 못했다. 대상 소스의 필수 `UiTable` props와 import·client 경계는 정적으로 확인했다. +- 목록과 상세현황의 동일 폭 보정 과정에서 전역 `sn-exmn-grid-scroll`을 사용했으나, 팝업 디자인 회귀 보고 후 해당 전역 클래스를 제거했다. 최종안은 각 조사 그리드의 인라인 `scrollbarGutter: "stable"`만 사용한다. +- 목록은 기존 `cellClick`을 전달하고 `clickableHeadIds`를 지정하지 않으므로 공통 `UiTable` 계약상 모든 데이터 셀이 클릭 가능 대상이다. 선택 함수는 표시 행 인덱스로 원본 사업을 찾아 `selectedBusinessId`를 갱신한다. +- 조사사업 목록은 `page=0`, `size=1000`으로 조회하고 `total={0}`으로 페이지 이동 UI를 숨긴다. 목록만 `16.7rem` 상한을 두고 상세 집계 래퍼는 `flex-1`로 남은 세로 공간을 사용한다. +- `git diff --check`와 UTF-8 검사는 통과했고 개발 서버 산출물에 신규 스크롤 class와 전체 셀 callback이 포함된 것을 확인했다. 로그인 세션이 없는 검증 브라우저는 `/auth/login`으로 이동하므로 실제 선택 동작과 최종 픽셀 정렬은 사용자 세션에서 재확인이 필요하다. +- 3차 `UiTable.rowClick` 보정도 사용자 화면에서 전체 행 선택을 보장하지 못해 실패로 판정했다. 4차 보정에서는 목록만 화면 전용 공통 Table 스타일로 교체하고, 생성 번들에서 각 셀의 `min-h-[4rem] w-full` button, `` 보조 클릭, `18rem` 높이와 기존 정렬 순환을 확인했다. +- 탭은 생성 번들에서 `tablist`, `aria-selected`, 각 `w-[8rem] h-[3.2rem]` button과 직접 `setJobType` 호출을 확인했다. +- 당시 샘플 번들의 세 사업 `trtdstCnt: 10`을 확인했다. 이후 목록 내부 스크롤 검증을 위해 네 번째 사업 샘플을 추가했다. 개발 서버 라우트는 빌드 오류 없이 렌더링됐으나 검증 브라우저에 로그인 세션이 없어 실제 샘플 10행과 클릭 동작의 사용자 화면 확인은 남아 있었다. +- 이후 사용자가 로그인된 실제 화면에서 전체 행 선택, 관로·맨홀 탭, 목록·상세 배치와 집계 표시를 최종 확인해 앞선 브라우저 세션 제한을 보완했다. + +## 10. 조사사업 목록·상세현황 최종 검증 결과 + +- `ExmnRegistrationPage`에서 검색조건 간격과 사업차수·사업연도·시도·시군구 선택 연결을 확인했다. +- 모든 데이터 셀을 채우는 실제 button과 `` 보조 선택 함수, `data-state=selected`를 확인해 전체 행 클릭과 선택 행 강조 계약을 확인했다. +- 정렬 상태 전이가 `asc → desc → undefined`이며 서버 요청과 샘플 배열이 같은 열 키·비교 함수를 사용하는 것을 확인했다. +- `EXMN_SAMPLE_BUSINESSES`에 `SAMPLE-BIZ-004`가 추가됐고 목록 인라인 높이 제한과 내부 스크롤로 3행 이후 데이터를 확인하는 구조를 확인했다. +- 목록과 상세에 동일한 인라인 스크롤바 예약과 좌우 기준이 적용됐고 sticky 헤더·하단 보정선이 있는 것을 확인했다. +- `ExmnStatusDetailPanel`의 관로·맨홀 선택은 각각 전체 `80×32px`가 실제 button이며 선택 변경 시 집계를 재조회한다. +- `summaryRequestIdRef` 비교로 과거 성공·오류·loading 응답이 최신 사업·탭 선택을 덮어쓰지 않는 것을 확인했다. +- 합계 행은 실제 `colSpan=2`, 중앙 정렬, 전 셀 `#d2deef` 배경을 사용한다. +- 상세 헤더는 sticky이고 상세 영역은 인라인 `maxHeight: 23.8rem`과 내부 세로 스크롤을 사용한다. 다음 행 일부가 하단에 노출되지 않도록 목록과 같은 높이 제한 방식을 적용했다. +- 공통 `layout.tsx`는 `BaseLayout → PageLayout` 구조를 유지한다. 페이지 높이 계산, Footer `ResizeObserver`, 문서 overflow 잠금·복원은 `ExmnRegistrationPage` 내부에만 존재하며 공통 Header·Footer 컴포넌트는 수정하지 않았다. +- 공통 `UiTable`의 `noOuterMargin`과 선택 행 상태는 소스에 남아 있으나 조사사업 목록·상세현황 최종 표는 병합·클릭·스크롤 제어를 위해 직접 구성한 테이블임을 확인했다. +- `components.css`의 의미 있는 diff가 제거됐고 조사 그리드 gutter는 두 화면 컴포넌트에만 인라인으로 적용된다. +- 완료 화면 캡처와 대조해 `tailwind.config.ts`의 `./src/**/*` 누락이 결과등록 grid와 사업등록 임의 폭 클래스 미생성 원인임을 확인했다. 스캔 경로를 복구하고 핵심 grid·폭을 인라인 스타일로 이중 고정했다. +- 사업등록은 `920×620`, 처리분구 `21rem`, 사업명 최대 `480px`; 결과등록은 `980×340`, 사업요약 4열과 파일행 `180px / 가변 / 80px / 112px` 계약으로 복구했다. +- 사용자의 로그인된 실제 화면 최종 확인으로 행 전체 선택, 관로·맨홀 전환, 목록·상세 배치와 상세 집계 표시를 완료 판정했다. +- 남은 검증은 실 API/백엔드 연동과 후속 상세보기·다운로드 구현이다. 지도보기는 보류다. ## 7. 흡수한 기존 문서 diff --git a/apps/sn/docs/작업계획/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-작업카드.md b/apps/sn/docs/작업계획/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-작업카드.md index b053bd50a..d85d4ca1f 100644 --- a/apps/sn/docs/작업계획/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-작업카드.md +++ b/apps/sn/docs/작업계획/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-작업카드.md @@ -4,7 +4,7 @@ - 작업 ID: `SFR-010-004~007-EXMN-NEW-FE-FIRST` - 작업명: CCTV·맨홀 조사사업 프론트엔드 선행 구현 -- 상태: 진행(`FE-0` 사용자 검증 완료, `FE-1` 시작 대기) +- 상태: 화면 1~3단계 완료·커밋 대기(상세보기 실제 화면·다운로드·실 API 후속, 지도보기 보류) - 작성일: 2026-07-22 - 담당 역할: FE - 관련 역할: BE, QA @@ -22,11 +22,22 @@ | 단계 | 범위 | 현재 상태 | 다음 단계 진입 조건 | | --- | --- | --- | --- | | FE-0 | 공통 타입·API, 처리분구관리 `/oper/trtdst` | 완료(사용자 화면·UI 검증 통과) | 충족 | -| FE-1 | `SFR-010-004` 사업등록·파일검토·PPL 집계/상세 | 시작 대기 | 사용자 진행 지시 | -| FE-2 | `SFR-010-005` 조사현황·지도 진입 | 대기 | FE-1 완료 | -| FE-3 | `SFR-010-006` MNHL 집계·상세 | 대기 | FE-2 완료 | +| FE-1 | `SFR-010-004` 사업등록·파일검토·PPL 집계/상세 | 부분 완료 | 사업등록·결과등록·종합집계 UI 확인 완료, 실제 상세보기·단계 커밋 대기 | +| FE-2 | `SFR-010-005` 조사현황·지도 진입 | 부분 완료 | 같은 화면 조사현황 완료, 지도 진입 보류 | +| FE-3 | `SFR-010-006` MNHL 집계·상세 | 부분 구현 | 맨홀 종합집계 UI 완료, 실제 상세보기 대기 | | FE-4 | `SFR-010-007` GIS 레이어·등급 필터·상세 | 대기 | FE-3 완료 | +### 3.1 사용자 확인 기준 화면 단계 + +| 화면 단계 | 완료 내용 | 판정 | +| --- | --- | --- | +| 1단계 | 처리분구관리 조회·추가·수정·삭제, 시도·시군구 조건, 그리드 UI | 완료 | +| 2단계 | 조사사업 검색·정렬·내부 스크롤, 행 선택·강조, 사업등록·결과등록 WindowPopup, `sample=Y` 데이터 | 완료 | +| 3단계 | 같은 화면 하단의 관로·맨홀 조사 전환, 처리분구별 종합집계·합계, 상세 내부 스크롤, 요청 순서 보호 | 완료 | + +- 위 1~3단계는 화면 구현 체크포인트다. 공식 요구사항 단계는 실제 상세보기·다운로드·지도 및 백엔드 연동 범위를 포함하므로 `FE-1~FE-3`을 모두 완료로 판정하지 않는다. +- 다음 화면 작업은 `상세보기` 실제 화면이며, `지도보기`는 보류한다. + ## 4. FE-0 수정 대상 | 구분 | 경로 | 결과 | @@ -68,3 +79,93 @@ - 신규 `/oper/trtdst` 라우트와 `src/features/exmn`을 제거한다. - `app/components/lib/api/index.ts`의 `putData` 추가분만 되돌린다. - 기존 화면·공통 비즈니스 컴포넌트에는 롤백 대상 변경이 없다. + +## 9. FE-1 수정 대상 + +| 구분 | 경로 | 결과 | +| --- | --- | --- | +| 라우트 | `app/(with-base-layout)/oper/exmn/register/page.tsx` | 신규 | +| 화면 | `src/features/exmn/ui/ExmnRegistrationPage.tsx` | 사업 목록·공통 WindowPopup 진입 | +| 사업등록 | `src/features/exmn/ui/ExmnBusinessRegisterPopupPage.tsx` | 처리분구 체크 선택·가나다순·저장·양식 | +| 파일검토 | `src/features/exmn/ui/ExmnResultRegisterPopupPage.tsx` | 파일 5종·validate·Tus 첨부·commit | +| 팝업 공통 | `src/features/windowPopup/{model,ui,registry.tsx}` | popup key·크기·메시지·content 등록 | +| 샘플 검증 | `src/features/exmn/model/exmn-sample-data.ts` | `sample=Y` 전용 사업·처리분구·검토결과 | +| PPL | `src/features/exmn/ui/PplSummaryModal.tsx`, `PplFacilityDetailModal.tsx` | 집계·시설·단위구간 상세 | +| 타입/API/검증 | `src/features/exmn/{model,api,lib}/exmn-business-*` | 승인 계약 구현 | +| 첨부 오류 | `app/components/lib/utils/fileUploadUtil.tsx` | 실패 콜백 추가 | +| 스타일 스캔 | `tailwind.config.ts` | `./src/**/*` 추가 | + +## 10. FE-1 완료 게이트 + +- `/oper/exmn/register`의 검색조건과 조회·사업등록·결과등록이 하나의 상단 카드에 표시되는지 확인한다. +- 사업년도는 단일 연도 선택, 시도·시군구는 I/I 공통 지역 선택으로 동작하며 양평에 고정되지 않는지 확인한다. +- 사업명 입력 폭과 목록 열 순서·단위가 사용자 확정안과 일치하는지 확인한다. +- 사업등록 WindowPopup의 행정구역 연동 처리분구 체크 선택·가나다순과 저장 전 검증을 확인한다. +- 사업등록 WindowPopup은 콘텐츠 창 `920×620` 안에서 5개 입력만 순서대로 표시하고, 별도 닫기·양식 생성 버튼 없이 저장·양식 받기·조사보고서 양식을 제공하는지 확인한다. +- 결과등록 WindowPopup의 파일 5종, 행별 찾기·데이터 검토, 검토 정상 전 저장 비활성을 확인한다. +- `/oper/exmn/register?sample=Y`에서만 샘플 사업과 팝업 샘플 상태가 보이고 일반 URL은 실제 API 계약을 유지하는지 확인한다. +- 상단에 설계되지 않은 `PPL 집계·상세` 직접 버튼이 표시되지 않는지 확인한다. +- 사업·결과 popup 확인 후 PPT 3·4의 동일 목록 화면에 조사 상세현황을 결합하고 사용자 확인을 받는다. + +## 11. 조사현황·집계 1차 구현 범위 + +| 구분 | 경로 | 결과 | +| --- | --- | --- | +| 기존 통합 화면 | `src/features/exmn/ui/ExmnRegistrationPage.tsx` | 사업 목록 행 선택 아래 상세 패널 결합 | +| 상세 패널 | `src/features/exmn/ui/ExmnStatusDetailPanel.tsx` | 관로·맨홀 조사 상세현황 골격 | +| 호환 라우트 | `app/(with-base-layout)/oper/exmn/status/page.tsx` | 기존 통합 화면을 그대로 렌더링하는 별칭 | +| 타입 | `src/features/exmn/model/exmn-business-types.ts` | `ExmnJobType`, `ExmnSectionSummaryRow` 추가 | +| API | `src/features/exmn/api/exmn-business-api.ts` | `GET /api/v1/exmnBusinesses/{id}/sections?jobType=PPL|MNHL` 추가 | +| 샘플 | `src/features/exmn/model/exmn-sample-data.ts` | `sample=Y` 전용 관로·맨홀 처리분구 집계 | + +- 원본 화면설계서 슬라이드 3·4처럼 기존 사업등록·결과등록 버튼과 사업목록이 있는 화면에서 행 선택 시 바로 아래에 조사 상세현황을 펼친다. 별도 상세 페이지로 이동하지 않는다. +- 사용자 표출 명칭은 `CCTV조사` 대신 `관로조사`, `맨홀조사`를 사용하고 서버 구분값은 `PPL`, `MNHL`을 유지한다. +- 사업 행 선택 시 기본 `관로조사` 탭을 열고, 탭별 처리분구 합계와 상세 행을 표시한다. +- `상세보기`, `다운로드`, `지도보기`는 이번 단계에서 버튼 진입점과 미연결 안내만 제공한다. +- 상세보기 실제 화면은 사용자의 후속 집계·상세 설명을 받은 뒤 연결하고, 지도보기는 명시적 보류 상태를 유지한다. + +## 12. 조사현황·집계 1차 확인 게이트 + +- `/oper/exmn/register?sample=Y`의 기존 사업목록에서 샘플 사업을 선택하면 같은 화면 하단 상세현황이 표시되는지 확인한다. +- 관로조사 탭은 대상연장·주행거리·전체·구조·운영·기타 열, 맨홀조사 탭은 전체·구조·운영·기타 열을 표시하는지 확인한다. +- 탭 이름에 `CCTV조사`가 남지 않고 `관로조사`, `맨홀조사`가 표시되는지 확인한다. +- 세 기능 버튼은 선택 사업이 없을 때 비활성이고 선택 후 후속 연결 안내를 표시하는지 확인한다. +- 실제 상세보기·다운로드·지도 페이지가 구현 완료된 것으로 판정하지 않는다. + +- 사용자 최종 확인 결과 목록 전체 셀 선택, 관로조사·맨홀조사 전환, 목록·상세 배치와 10행 샘플 집계는 통과했다. +- 조사관리 상세현황 화면 단계는 완료하며 `상세보기`, 다운로드, 지도보기와 실 API는 후속 범위로 남긴다. + +## 13. 2026-07-23 git status 기준 실제 변경 범위 + +- 프론트 저장소의 현재 코드 변경은 추적 파일 9개, 신규 파일 15개로 총 24개다. +- 추적 변경: `app/(with-base-layout)/layout.tsx`, `app/components/lib/utils/fileUploadUtil.tsx`, `src/app/styles/components.css`, `src/features/exmn/index.ts`, `src/features/windowPopup/model/{popupDefinitions,types}.ts`, `src/features/windowPopup/registry.tsx`, `tailwind.config.ts`, `packages/common/business_components/src/ui/table/index.tsx`. +- 신규 라우트: `app/(with-base-layout)/oper/exmn/{register,status}/page.tsx`. +- 신규 조사사업 모듈: `src/features/exmn/{api,lib,model,ui}/**`의 사업 API·검증·타입·샘플·등록/결과 팝업·상세현황·PPL 상세 파일 13개. +- 신규 WindowPopup content: `src/features/windowPopup/ui/{ExmnBusinessRegisterPopupContent,ExmnResultRegisterPopupContent}.tsx`. +- 현재 `layout.tsx` diff는 파일 끝 줄바꿈 정규화뿐이며 공통 `BaseLayout → PageLayout` 구조와 Header·Footer 컴포넌트는 변경하지 않는다. 조사관리 화면 높이·문서 overflow 제어는 `ExmnRegistrationPage`가 담당한다. +- `components.css`에 추가했던 조사사업 전용 `sn-exmn-grid-scroll`은 팝업 디자인 회귀 차단을 위해 제거했다. 현재 파일의 의미 있는 CSS diff는 없으며 조사 목록·상세 gutter는 각 컴포넌트 인라인 스타일로 유지한다. +- `tailwind.config.ts`의 `./src/**/*` 스캔 경로를 복구해 `src/features` 팝업의 grid·임의 폭 클래스가 CSS에 생성되도록 했다. + +## 14. 조사사업 목록·상세현황 최종 완료 범위 + +### 14.1 조사사업 목록 + +- 검색조건 간격을 공통 조회영역에 맞추고 사업차수·사업연도·시도·시군구의 선택 동작을 확인했다. +- 목록 전체 행 영역에서 사업을 선택할 수 있으며 선택 행을 강조한다. +- 정렬 헤더는 공통 디자인을 따르고 `오름차순 → 내림차순 → 기본`으로 순환한다. 서버 응답과 `sample=Y` 데이터에 같은 정렬 규칙을 적용한다. +- 검증용 사업 샘플을 4건으로 늘렸다. 목록에는 3행이 하단선까지 온전히 보이고 4번째부터 내부 세로 스크롤로 확인한다. +- 상세현황 표와 동일한 좌우 여백·가로 폭·스크롤바 공간을 사용하고 스크롤 중 상·하단선이 잘리지 않게 보정했다. 스크롤바 공간 예약은 두 조사 그리드의 인라인 스타일로 격리해 WindowPopup 디자인에 영향을 주지 않는다. + +### 14.2 조사 상세현황 + +- 관로조사·맨홀조사는 보이는 전체 영역이 실제 클릭 가능한 독립 button이다. +- 사업이나 조사유형이 바뀌면 해당 집계를 다시 조회하며, 요청 ID로 이전 응답이 최신 선택을 덮어쓰는 것을 차단한다. +- 합계는 `NO+처리분구` 두 셀을 실제 병합하고 문구를 중앙 정렬했다. 합계 행 배경은 `#d2deef`다. +- 헤더는 상세 그리드 상단에 고정하고, 다음 행 일부가 하단에 노출되지 않는 인라인 `maxHeight`와 내부 세로 스크롤을 적용했다. +- 목록과 상세현황은 정교한 셀 전체 클릭·병합·높이 처리를 위해 공통 Table primitive로 직접 구성한 화면 전용 표를 사용한다. + +### 14.3 페이지 높이 + +- 임시 조사관리 전용 공통 레이아웃 래퍼·헤더 CSS는 최종안에서 사용하지 않는다. +- 페이지 시작 위치와 실제 Footer 높이로 사용 가능 높이를 계산하고 Footer 변화는 `ResizeObserver`로 재계산한다. +- 조사관리 화면이 열려 있는 동안 문서 전체 스크롤을 잠그고 목록·상세 그리드 내부 스크롤을 사용하며, 화면을 벗어나면 기존 문서 overflow 값을 복원한다. diff --git a/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md b/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md index ec16b3117..83e7dcba3 100644 --- a/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md @@ -20,6 +20,18 @@ | 2026-07-22 | FE-0 후속 | 메뉴 진입 시 발생한 Next 서버/클라이언트 import 경계 오류 수정 | | 2026-07-22 | FE-0 화면 마무리 | 공통 UiTable 정렬과 검색조건·버튼 배치/색상 보완 | | 2026-07-22 | FE-0 완료 | 2차 UI 보정 후 사용자 화면 검증 통과, FE-1 시작 대기 | +| 2026-07-22 | FE-1 | 사업 목록·등록, 파일 검토/확정, PPL 집계·시설·단위상세 구현 | +| 2026-07-22 | 화면 2단계 완료 | 조사사업 목록과 사업등록·조사결과 업로드 WindowPopup 사용자 확인 완료 | +| 2026-07-23 | FE-1 조사현황 보정 | 목록·상세 가로 기준 통일, 사업 행 전체 선택, 목록·상세 내부 스크롤 통일 | +| 2026-07-23 | 화면 3단계 완료 | 같은 화면의 관로·맨홀 종합집계, 합계, 내부 스크롤, 요청 순서 보호 사용자 확인 완료 | +| 2026-07-23 | 팝업 회귀 복구 | Tailwind `src` 스캔 복구와 핵심 열·폭 인라인 고정 후 완료 디자인 재확인 | + +### 3.1 현재 구현 경계 + +- 화면 1단계 `처리분구관리`, 화면 2단계 `조사사업 관리`, 화면 3단계 `조사 상세현황`까지 프론트 UI와 `sample=Y` 검증 흐름을 완료했다. +- 화면 3단계 완료 범위는 관로·맨홀의 처리분구별 종합집계까지다. +- `상세보기` 실제 화면과 다운로드 연동은 다음 프론트 작업이며 `지도보기`는 보류한다. +- 백엔드 API·서비스·Repository·DB와 실데이터 통합 검증은 아직 시작하지 않았다. ## 4. FE-0 처리분구관리 @@ -84,7 +96,8 @@ ## 5. 기능별 현재 상태 - FE-0: 화면 구현·사용자 UI 검증 완료, 실 API 연동은 백엔드 구현 후 검증 -- FE-1~FE-4: 미착수 +- FE-1: 목록과 사업등록·결과등록 WindowPopup 구현·개발 서버 컴파일·사용자 UI 확인 완료, PPL 집계·상세 확인 및 단계 커밋 대기 +- FE-2~FE-4: 미착수 - 백엔드: 사용자 지시대로 프론트 전체 완료 전 미착수 ## 6. BE 공유 사항 @@ -104,3 +117,178 @@ ## 8. 흡수한 기존 문서 - 없음 + +## 9. FE-1 사업정보·조사결과 등록 + +### 9.1 사업 목록과 등록 + +- 사업차수, 사업년도, 시도, 시군구, 사업명은 입력 상태와 적용 조회조건을 분리했다. +- 사업년도는 `YearRangeSelectBox` 모듈의 단일 연도 옵션을 사용하는 `SelectBox`로 바꿨다. +- 시도·시군구는 I/I 분석과 같은 `CtpvSggPrcsFcltSelectBox`를 사용하며 양평 고정값과 하수처리시설 선택 props를 두지 않았다. +- 검색조건과 조회·사업등록·결과등록을 한 상단 카드에 배치하고 사업명 입력은 `w-52`로 제한했다. +- 상단의 임의 `PPL 집계·상세` 버튼은 제거했다. PPL 집계·상세 구현은 조사현황의 건수 클릭 등 정식 진입점에 연결할 수 있도록 모듈로 유지한다. +- 목록 열은 NO, 사업차수, 사업년도, 시도, 시군구, 사업명, 처리구역·분구(개소), 정밀조사 연장(km), 보고서 순서다. +- 목록은 0-base page, 20건, 허용 정렬 문자열을 `GET /api/v1/exmnBusinesses`에 전달한다. +- 사업등록은 사업연도·사업명·차수·행정구역·정밀조사연장·총사업비와 활성 처리분구 ID 배열을 저장한다. +- 사업 저장 후에만 처리분구별 PPL/MNHL 시트 생성과 통합 입력양식 다운로드를 활성화한다. + +### 9.2 파일 검토와 최종 저장 + +- 파일 선택 시 첨부서비스로 즉시 전송하지 않고 파일 구분별 확장자, 빈 파일, 200MB 상한을 먼저 검사한다. +- PPL 또는 MNHL 파일 1개 이상을 `imports/validate`에 보내고 오류 위치를 그리드로 표시한다. +- 정상 검토 전에는 최종 저장을 비활성화한다. +- 최종 저장은 선택 파일 Tus 업로드, 첨부키 PUT, 동일 PPL/MNHL 파일·검토 SHA-256·첨부키 multipart commit 순서다. +- 기존 파일 업로드 유틸에 `handleError` 콜백을 추가해 실패가 최종 저장 대기 상태로 남지 않고 화면 오류로 귀결되게 했다. + +### 9.3 PPL 집계와 상세 + +- 처리분구별 대상연장, 주행거리, PPL 전체/구조/운영/기타 수를 서버 페이징·정렬 그리드로 표시한다. +- 결함 건수 클릭은 처리분구와 결함유형을 반영해 `jobType=PPL` 시설 목록을 조회한다. +- 시설 목록은 처리분구, 보고서번호, GIS 관로관리번호, 상·하류 맨홀, 관종/규격, 연장, 구조·운영등급을 표시한다. +- 시설 행을 선택하면 서버 정규화 `unitSections`와 선택 단위의 상태·결함 기록을 표시한다. +- 미확정 보고서 매핑은 비활성 버튼과 안내로 유지한다. + +### 9.4 UI 기반 보완 + +- `sn` Tailwind 설정에 빠져 있던 `./src/**/*`를 content에 추가해 `src/features` 신규 클래스가 생성되게 했다. +- 기존 `SearchDiv`, `ListDiv`, `UiTable`, Button/Input과 공통 `windowPopup` 등록 패턴을 재사용했다. +- 라우트는 FE-0에서 확인한 Next 경계 문제를 반복하지 않도록 client UI를 직접 import한다. +- 사용자 화면 확인에서 분리돼 보이던 상단 액션과 검색조건을 같은 `SearchDiv` 자식 flex 영역으로 합쳤다. + +### 9.5 영향과 롤백 + +- 신규 `/oper/exmn/register`와 `features/exmn` FE-1 파일을 제거하면 화면/API 계약을 롤백할 수 있다. +- Tus 실패 콜백과 Tailwind `src` 스캔은 `sn` 앱 전체에 적용되지만 기존 호출 계약을 깨지 않는다. +- 백엔드, DB, 타 앱, 공통 비즈니스 컴포넌트는 변경하지 않았다. + +### 9.6 사업등록·결과등록 WindowPopup 전환과 샘플 검증 + +- 상단 `사업등록`, `결과등록`은 인라인 `UiModal` 대신 `openWindowPopup()`을 사용한다. +- 공통 popup key는 `exmnBusinessRegister`, `exmnResultRegister`이며 `/popup/[popupKey]` registry에서 각각 업무 화면을 렌더링한다. +- 사업등록 팝업은 자체 브라우저 컨텍스트에서 시도·시군구 store를 다시 로드하고, 활성 처리분구를 `localeCompare(..., "ko")` 기준 가나다순으로 정렬해 체크박스 다중 선택으로 제공한다. +- 사업 저장 완료와 결과 저장 완료는 `window.opener.postMessage`로 부모 목록에 전달한다. 팝업 차단 시 부모 화면에 허용 안내를 표시한다. +- 결과등록 팝업은 조사 결과, CCTV 조사 결과(관로), CCTV 조사 결과(맨홀), 사업보고서, 조사 위치도 5개 행을 표시하며 각 행의 파일명·찾기와 조사 원본의 데이터 검토 진입을 제공한다. +- 정상 검토 전 저장 버튼은 비활성이고 실제 모드의 Tus 첨부→첨부키 연결→commit 계약은 유지한다. +- 백엔드 데이터가 없는 사용자 화면 검증을 위해 `/oper/exmn/register?sample=Y`만 샘플 모드로 사용한다. 일반 URL의 API 빈 결과를 자동 샘플로 대체하지 않는다. +- 샘플 모드는 사업목록, 가나다순 처리분구, 정상 검토 요약을 제공하되 실제 API 저장·첨부·다운로드는 수행하지 않는다. + +### 9.7 사업등록 팝업 사용자 확인 보정 + +- 사업등록 popup definition을 `1180×820`에서 `920×620`으로 축소하고 불필요한 하단 여백을 줄였다. +- 상단 `닫기`, `양식 생성`을 제거했다. `양식 받기`는 사업 저장 후 양식 생성과 다운로드를 한 번에 수행한다. +- `양식 받기`, `조사보고서 양식`은 기존 엑셀 다운로드 버튼의 전역 `green` 스타일과 흰색 글자를 사용한다. +- 화면 입력은 5개만 노출하고 첫 줄에 `사업년도·시도·시군구·정밀조사연장`, 둘째 줄에 `사업명`을 배치한다. API 필수 계약인 사업차수와 총사업비는 신규 기본값 `1`, `0`으로 전달한다. +- Radix Select의 서버·클라이언트 ID 불일치를 막기 위해 사업등록·결과등록 WindowPopup content를 client mount 이후 렌더링한다. +- 개발 환경의 목록 헤더에 `샘플 데이터 보기`를 제공한다. 이 버튼은 URL에 `sample=Y`를 명시해 기존 격리 원칙을 유지하며, 사업목록과 처리분구 샘플을 함께 활성화한다. +- 사용자 재확인에 따라 팝업의 `샘플 화면` 배지를 제거하고 처리분구 목록을 고정 높이 `21rem`으로 확장했다. 3열 7행으로 21개까지 한 번에 표시하고 초과 후보는 목록 내부 세로 스크롤로 확인한다. +- 정밀조사연장은 브라우저 증감 스피너가 없는 `text`+`inputMode=decimal` 입력으로 변경하고 숫자·소수점만 허용한다. 최대 10자, 입력 폭 `9rem`으로 첫 줄 안에서 표시한다. +- 결과등록 popup definition은 Chrome 외곽 프레임을 포함한 사용자 목표 크기 약 `980×410`에 맞춰 콘텐츠 창 설정을 `980×340`으로 확정하고 샘플 배지와 닫기 버튼을 제거했다. +- 결과등록 상단은 `사업차수 → 사업년도 → 행정구역 → 사업명` 순서로 한 줄 표시한다. 짧은 항목은 좁고 사업명은 넓은 `0.7fr/0.8fr/1.2fr/2.3fr` 비율과 `gap-8` 간격을 사용한다. +- 파일 5종 업로드 영역은 강한 셀 격자 대신 기존 화면처럼 연한 외곽선·헤더 배경·가로 행 구분선만 사용하는 4열 폼 목록으로 구성한다. +- popup root는 `h-dvh`·`overflow-hidden`으로 브라우저 가로·세로 스크롤을 차단하고, 검토결과가 추가돼 실제 높이가 넘을 때만 본문 내부를 세로 스크롤한다. +- 상위 popup layout의 `html/body`에서 남는 문서 스크롤을 제거하기 위해 결과 팝업 mount 동안 두 요소의 overflow를 잠그고 종료 시 원래 값을 복원한다. +- 파일 헤더는 `py-2.5`, 각 업로드 행은 `py-3`으로 늘려 남는 세로 공간을 활용한다. +- 파일 헤더와 데이터 행에 동일한 `gap-3`을 적용해 `찾기`·`데이터 검토` 열의 중심선을 일치시켰다. +- 파일 선택 완료 시 파일명 영역을 파란색 굵은 글씨·연한 파란 배경·파란 테두리로 바꿔 미선택 안내와 구분한다. +- 사용자가 사업등록과 조사결과 업로드 WindowPopup의 최종 크기·배치·스크롤·파일 선택 표시를 확인해 두 팝업 UI를 완료 판정했다. + +### 9.8 조사현황·처리분구 집계 1차 골격 + +- 원본 화면설계서 슬라이드 3·4를 렌더링해 사업 목록의 행 선택 후 같은 화면 하단에 조사 상세현황이 표시되는 구조를 확인했다. +- 첫 구현에서 조사 상세현황을 별도 `ExmnStatusPage`의 목록에 배치했으나, 사용자 확인으로 PPT 3·4는 같은 화면의 선택 전·후 상태임을 재확인했다. +- `ExmnStatusDetailPanel`을 분리해 현재 사용 중인 `ExmnRegistrationPage` 사업목록 바로 아래에 결합했다. `/oper/exmn/status`도 별도 화면을 만들지 않고 동일 통합 화면을 렌더링한다. +- 행 선택 시 기본 조사유형을 `PPL`로 초기화하고 `관로조사`, `맨홀조사` 탭에서 처리분구별 집계와 합계 행을 표시한다. +- 관로 집계는 대상연장·주행거리·전체 이상·구조·운영·기타를 표시하고, 맨홀 집계는 전체 이상·구조·운영·기타를 표시한다. +- 사용자 화면 명칭은 `CCTV조사`에서 `관로조사`로 정정하되 API·DB 조사작업 구분은 `PPL|MNHL`을 유지한다. +- 선택 사업의 집계 API 진입은 `/api/v1/exmnBusinesses/{exmnBizId}/sections?jobType=PPL|MNHL` 계약으로 추가했다. +- 백엔드 데이터가 없는 화면 확인은 기존 `/oper/exmn/register?sample=Y` 흐름에서 샘플 사업·처리분구 집계를 사용하며 일반 URL은 실제 API 계약을 유지한다. +- `상세보기`, `다운로드`, `지도보기`는 현재 버튼과 미연결 안내만 제공한다. 상세보기는 집계 확인 후 사용자 설명에 따라 구현하고 지도보기는 보류한다. +- 선택 사업의 사업명·차수·연도·행정구역을 반복 표시하던 요약 띠는 사용자 요청으로 제거했다. +- 관로조사·맨홀조사는 별도 버튼을 만들지 않고 기존 공통 `UITab`을 재사용한다. 사용자 후속 확인에 따라 underline 대신 분석도구의 활성 메뉴와 가까운 기본 파란 활성 탭을 컴팩트한 크기로 적용했다. +- 합계는 일반 데이터 행에 빈 NO 셀을 두지 않고 `NO+처리분구` 두 열을 병합한 전용 연한 배경 행으로 표시한다. +- 기본 `Table`에 외부 스크롤과 sticky `thead`를 결합했던 1차 보정은 이중 스크롤 컨테이너 때문에 스크롤 중 행 일부가 헤더 위로 잘려 보이는 문제가 확인됐다. +- 종합집계표를 기존 화면에서 사용하는 공통 `UiTable`로 교체하고, `UiTable` 내부 표 래퍼 하나만 스크롤 컨테이너로 사용한다. 행 패딩과 내부 높이를 `16.7rem`으로 조정해 헤더·합계·처리분구 3행의 하단선까지 온전히 표시하고, 초과 행만 그리드 내부에서 스크롤한다. +- 우측 페이지 스크롤은 집계표 높이가 아니라 공통 `BaseLayout`이 실제 `6rem` 헤더를 `50px`로 차감하던 최소 높이 불일치가 원인이었다. 1차 전용 선택자는 실제 DOM의 `SidebarProvider` 중간 래퍼를 누락해 적용되지 않았으며, SSR HTML을 기준으로 `.sn-exmn-base-layout > div > .flex-1 > .flex > .flex-1`로 정정해 `/oper/exmn/**` 경로에만 `min-height: calc(100vh - 6rem)`을 적용했다. +- `UiTable`이 본문 `colSpan`을 지원하지 않는 제약 안에서 합계 첫 두 셀의 경계선을 제거하고 첫 셀의 표시 영역을 `NO+처리분구` 너비만큼 확장해 `합계` 문구를 두 열 중앙에 배치했다. +- 상세현황 표시 시 카드가 일반 문서 흐름에서 Footer를 밀어 페이지 전체 스크롤을 만들던 구조를 보정했다. 조사사업 경로의 본문 높이를 `100dvh - 6rem`으로 고정하고, PageLayout·조사사업 페이지·상세 패널을 `min-height: 0`인 flex 구조로 연결해 남은 공간을 상세 패널에 배분한다. 상세 데이터 초과분은 종합집계 `UiTable` 내부에서만 스크롤한다. +- 조사사업 선택에 공통 `UiTable`의 신규 `rowClick` 계약을 적용했으나 사용자 재확인에서 정밀조사 연장·보고서 외 셀의 클릭이 동작하지 않았다. 신규 공통 계약은 철회하고 기존에 사용 중인 `cellClick`을 전체 열에 적용하는 방식으로 교체했다. + +### 9.9 조사사업 목록·상세현황 그리드 동작 통일 + +- 단순히 `1.2rem` 값을 각각 지정하던 구조는 목록의 `UiTable` margin과 상세의 부모 padding이 달라 실제 표 경계가 일치하지 않았다. `UiTable.noOuterMargin` 옵션을 추가하고 두 표 모두 `mx-[1.2rem]`인 동일한 외부 스크롤 래퍼만 가로 여백을 소유하게 했다. +- 두 스크롤 래퍼에 `scrollbar-gutter: stable`을 적용해 상세 표에 세로 스크롤바가 생겨도 목록과 상세의 표 가로 기준이 달라지지 않게 했다. +- 행 선택은 검증되지 않은 `rowClick` 확장을 제거하고 기존 `cellClick` 계약을 사용한다. `clickableHeadIds`를 제한하지 않아 NO부터 보고서까지 모든 데이터 셀이 같은 사업 선택 함수를 호출한다. +- 조사사업 목록의 화면 페이지네이션을 제거하고 최초 페이지의 최대 1,000건을 조회한다. 목록은 최대 높이 `16.7rem` 이후 내부 스크롤하고, 상세 집계는 고정 최대 높이를 제거해 페이지 전체 스크롤이 생기지 않는 범위의 남은 카드 높이를 모두 사용한 뒤 초과 데이터만 내부 스크롤한다. +- 목록 제목의 전체 건수는 API `totalElements`를 그대로 표시하고, 표 내부 페이지 이동 UI만 숨긴다. +- 현재 1,000건 상한은 프론트 선행 구현용이다. 실제 운영 데이터가 이를 넘을 가능성이 있으면 백엔드 통합 단계에서 무제한 조회 대신 무한 스크롤 또는 커서 기반 조회 계약을 확정한다. + +### 9.10 클릭 영역·목록 높이·샘플 행 최종 보정 + +- 목록 `16.7rem`은 공통 표의 헤더·3개 행·내부 padding 합계보다 작아 3건만 있어도 세로 스크롤과 마지막 행 하단 잘림이 발생했다. 목록 최대 높이를 `18rem`으로 조정해 3건은 스크롤 없이 하단선까지 표시하고 4건 이상부터 목록 내부 스크롤을 사용한다. +- 셀별 `cellClick` 이후 공통 `UiTable`에 선택적 `rowClick` 계약을 추가했지만, 사용자 재확인에서 정밀조사 연장 우측 일부 영역만 선택되고 앞쪽 영역은 반응하지 않는 문제가 남았다. 해당 공통 확장도 검증 실패로 판정한다. +- 관로조사·맨홀조사는 공통 `UITab`의 스타일 선택자에 의존하지 않고 보이는 크기와 실제 클릭 요소가 같은 `80×32px` native button 2개로 구성한다. `role=tablist/tab`, `aria-selected`를 유지한다. +- 당시 화면 검증용 세 조사사업의 처리분구 수를 모두 10개로 맞췄다. 이후 목록 내부 스크롤 검증을 위해 9.13절에서 네 번째 사업 샘플을 추가했으며, 샘플 상세 집계는 합계 행 외에 관로·맨홀 각각 처리분구 10행을 제공한다. + +### 9.11 조사사업 목록 클릭 영역 구조 교체 + +- 공통 `UiTable`의 `cellClick`·`rowClick`을 반복 확장하는 방식을 철회하고, 조사사업 목록만 공통 `Table` 스타일을 사용하는 화면 전용 표로 교체했다. 상세 집계의 `UiTable` 사용은 유지한다. +- 각 데이터 셀은 padding을 제거한 뒤 `min-height: 4rem`, `width: 100%`인 실제 ``에도 동일한 선택 함수를 보조로 연결해 셀 경계 외의 행 영역을 선택할 수 있게 했다. +- 정렬 헤더도 각 헤더 전체 폭의 ` + + + + + +
+ {notice && ( +
{notice.text}
+ )} + +
+
사업정보 입력
+
+
+ setBizYr(String(value))} + /> + { + if (savedBusiness) return; + setCtpvCd(nextCtpvCd); + setSggCd("0"); + setSggNm(""); + }} + onSigunguChange={(nextSggCd) => { + if (!savedBusiness) setSggCd(nextSggCd); + }} + setCtpvName={(nextCtpvNm) => { + if (!savedBusiness) setCtpvNm(nextCtpvNm === "전체" ? "" : nextCtpvNm); + }} + setSggName={(nextSggNm) => { + if (!savedBusiness) setSggNm(nextSggNm === "전체" ? "" : nextSggNm); + }} + /> +
+ + { + if (/^\d*\.?\d*$/.test(event.target.value)) setPrcisExmnPrlg(event.target.value); + }} + /> + km +
+
+
+ + setBizNm(event.target.value)} /> +
+
+
+ +
+
+

사업대상 처리분구

+ 가나다순 · {selectedDistrictIds.length}개 선택 +
+
+ {districts.length === 0 ? ( +

시도·시군구를 선택하거나 등록된 활성 처리분구를 확인해주세요.

+ ) : districts.map((district) => ( + + ))} +
+
+ +
+ + ); +} diff --git a/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx b/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx new file mode 100644 index 000000000..3c3db45df --- /dev/null +++ b/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx @@ -0,0 +1,554 @@ +"use client"; + +import LoadingOverlay from "@/components/ui/LoadingBar"; +import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox"; +import { getStartYearArray } from "@/components/ui/YearRangeSelectBox"; +import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; +import { + Button, + Input, + Label, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@common/components/ui"; +import { SearchDiv, SelectBox } from "@common/business_components/ui"; +import type { UiTableSort } from "@common/business_components/ui"; +import ListDiv from "@common/business_components/ui/list_div"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { getExmnBusinesses } from "../api/exmn-business-api"; +import type { + ExmnBusiness, + ExmnBusinessSearchParams, +} from "../model/exmn-business-types"; +import { EXMN_SAMPLE_BUSINESSES } from "../model/exmn-sample-data"; +import { ExmnStatusDetailPanel } from "./ExmnStatusDetailPanel"; + +const ACTION_BUTTON_CLASS = + "rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white"; + +// 목록 페이지네이션을 제거하고 한 번에 받아 내부 스크롤로 표시할 최대 사업 건수다. +const BUSINESS_LIST_SCROLL_FETCH_SIZE = 1000; + +const BUSINESS_TABLE_HEAD = [ + { id: "rowNumber", title: "NO", widthPercent: 5 }, + { id: "bizCyclText", title: "사업차수", widthPercent: 7, sortable: true, sortKey: "bizCycl" }, + { id: "bizYr", title: "사업년도", widthPercent: 7, sortable: true }, + { id: "ctpvNm", title: "시도", widthPercent: 9, sortable: true }, + { id: "sggNm", title: "시군구", widthPercent: 9, sortable: true }, + { id: "bizNm", title: "사업명", widthPercent: 26, sortable: true }, + { id: "trtdstCntText", title: "처리구역·분구(개소)", widthPercent: 12, sortable: true, sortKey: "trtdstCnt" }, + { id: "prcisExmnPrlgText", title: "정밀조사 연장(km)", widthPercent: 13, sortable: true, sortKey: "prcisExmnPrlg" }, + { id: "reportYnText", title: "보고서", widthPercent: 12, sortable: true, sortKey: "reportYn" }, +]; + +interface BusinessFilters { + bizCycl: string; + bizYr: string; + ctpvCd: string; + sggCd: string; + ctpvNm: string; + sggNm: string; + bizNm: string; +} + +const EMPTY_FILTERS: BusinessFilters = { + bizCycl: "", + bizYr: "0", + ctpvCd: "0", + sggCd: "0", + ctpvNm: "", + sggNm: "", + bizNm: "", +}; + +type Notice = { type: "success" | "error"; text: string } | null; + +/** 서버·샘플 목록에 같은 비교 규칙을 적용해 정렬 헤더와 실제 행 순서를 일치시킨다. */ +const sortBusinessList = ( + source: ExmnBusiness[], + nextSort: UiTableSort | undefined, +) => { + if (!nextSort) return source; + + const getSortValue = (business: ExmnBusiness) => { + switch (nextSort.key) { + case "bizCycl": return business.bizCycl ?? 0; + case "bizYr": return business.bizYr; + case "ctpvNm": return business.ctpvNm; + case "sggNm": return business.sggNm; + case "bizNm": return business.bizNm; + case "trtdstCnt": return business.trtdstCnt; + case "prcisExmnPrlg": return business.prcisExmnPrlg; + case "reportYn": return business.reportYn ?? "N"; + default: return business.bizNm; + } + }; + + return [...source].sort((left, right) => { + const leftValue = getSortValue(left); + const rightValue = getSortValue(right); + const comparison = typeof leftValue === "number" && typeof rightValue === "number" + ? leftValue - rightValue + : String(leftValue).localeCompare(String(rightValue), "ko"); + return nextSort.direction === "desc" ? comparison * -1 : comparison; + }); +}; + +/** 사업정보 조회·등록과 선택 사업의 조사결과 등록 진입을 제공하는 SFR-010-004 메인 화면이다. */ +export function ExmnRegistrationPage() { + const pageRef = useRef(null); + // 입력 중 조건과 실제 서버 조회 조건을 분리해 정렬 시 동일 조건을 재사용한다. + const [draftFilters, setDraftFilters] = useState(EMPTY_FILTERS); + const [appliedFilters, setAppliedFilters] = useState(EMPTY_FILTERS); + const [businesses, setBusinesses] = useState([]); + // 명시적 sample=Y 모드에서 신규 샘플 등록까지 유지하는 부모 창 전용 데이터 원본이다. + const [sampleBusinesses, setSampleBusinesses] = useState(EXMN_SAMPLE_BUSINESSES); + const [sampleMode, setSampleMode] = useState(false); + const [total, setTotal] = useState(0); + const [sort, setSort] = useState(); + // 선택 사업 ID는 서버 재조회 뒤에도 같은 행을 복원할 수 있도록 객체와 별도로 유지한다. + const [selectedBusinessId, setSelectedBusinessId] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [notice, setNotice] = useState(null); + + /** + * 공통 헤더와 레이아웃은 변경하지 않고, 이 화면이 시작되는 지점부터 + * 뷰포트 하단까지만 조사관리 영역으로 사용한다. + * 부모 높이가 확정되어야 상세 그리드의 flex-1 / overflow-y-auto가 동작한다. + */ + useEffect(() => { + const pageElement = pageRef.current; + if (!pageElement) return; + + const previousHtmlOverflow = document.documentElement.style.overflow; + const previousBodyOverflow = document.body.style.overflow; + const footerElement = document.querySelector("footer"); + + const updatePageHeight = () => { + const pageTop = pageElement.getBoundingClientRect().top; + const footerHeight = footerElement?.getBoundingClientRect().height ?? 0; + const bottomGap = 8; + pageElement.style.height = `${Math.max( + 0, + window.innerHeight - pageTop - footerHeight - bottomGap, + )}px`; + }; + + document.documentElement.style.overflow = "hidden"; + document.body.style.overflow = "hidden"; + updatePageHeight(); + window.addEventListener("resize", updatePageHeight); + const footerResizeObserver = footerElement + ? new ResizeObserver(updatePageHeight) + : null; + footerResizeObserver?.observe(footerElement); + + return () => { + window.removeEventListener("resize", updatePageHeight); + footerResizeObserver?.disconnect(); + document.documentElement.style.overflow = previousHtmlOverflow; + document.body.style.overflow = previousBodyOverflow; + }; + }, []); + + /** URL에 sample=Y가 명시된 경우에만 실제 API와 분리된 화면 검증용 사업목록을 노출한다. */ + useEffect(() => { + const enabled = new URLSearchParams(window.location.search).get("sample") === "Y"; + setSampleMode(enabled); + if (!enabled) return; + setBusinesses(sampleBusinesses); + setTotal(sampleBusinesses.length); + }, []); + + const selectedBusiness = useMemo( + () => businesses.find((business) => business.exmnBizId === selectedBusinessId) ?? null, + [businesses, selectedBusinessId], + ); + + /** 조사사업 목록 헤더의 정렬 상태를 오름차순·내림차순·기본 순서로 순환한다. */ + const handleBusinessSort = (head: (typeof BUSINESS_TABLE_HEAD)[number]) => { + if (!("sortable" in head) || !head.sortable) return; + + const key = "sortKey" in head && head.sortKey ? head.sortKey : head.id; + const nextSort: UiTableSort | undefined = sort?.key !== key + ? { key, direction: "asc" } + : sort.direction === "asc" + ? { key, direction: "desc" } + : undefined; + setSort(nextSort); + void fetchBusinesses(appliedFilters, nextSort); + }; + + /** 개발 환경에서만 샘플 데이터 모드를 명시적으로 켜고 끈다. */ + const switchSampleMode = (enabled: boolean) => { + const url = new URL(window.location.href); + if (enabled) url.searchParams.set("sample", "Y"); + else url.searchParams.delete("sample"); + window.location.assign(`${url.pathname}${url.search}`); + }; + + /** 사업 목록의 숫자·행정구역·보고서 여부를 표시 전용 값으로 변환한다. */ + const businessRows = useMemo(() => businesses.map((business, index) => ({ + ...business, + rowNumber: index + 1, + bizCyclText: business.bizCycl == null ? "-" : `${business.bizCycl}차`, + trtdstCntText: Number(business.trtdstCnt ?? 0).toLocaleString("ko-KR"), + prcisExmnPrlgText: Number(business.prcisExmnPrlg ?? 0).toLocaleString("ko-KR", { maximumFractionDigits: 3 }), + reportYnText: business.reportYn === "Y" || business.bizRptpAtchFileId ? "있음" : "없음", + })), [businesses]); + + /** 화면 조건을 행정구역 이름과 목록 내부 스크롤용 단일 서버 Page 계약으로 변환한다. */ + const buildSearchParams = ( + filters: BusinessFilters, + nextSort: UiTableSort | undefined, + ): ExmnBusinessSearchParams => ({ + bizCycl: filters.bizCycl || undefined, + bizYr: filters.bizYr !== "0" ? filters.bizYr : undefined, + ctpvNm: filters.ctpvNm || undefined, + sggNm: filters.sggNm || undefined, + bizNm: filters.bizNm || undefined, + page: 0, + size: BUSINESS_LIST_SCROLL_FETCH_SIZE, + sort: nextSort ? `${nextSort.key},${nextSort.direction}` : undefined, + }); + + /** 샘플 원본에 실제 조회조건과 조사사업 목록의 공통 비교 규칙을 적용한다. */ + const getSampleBusinessPage = ( + filters: BusinessFilters, + nextSort: UiTableSort | undefined, + ) => { + const filtered = sampleBusinesses.filter((business) => ( + (!filters.bizCycl || String(business.bizCycl ?? "") === filters.bizCycl) + && (filters.bizYr === "0" || business.bizYr === filters.bizYr) + && (!filters.ctpvNm || business.ctpvNm === filters.ctpvNm) + && (!filters.sggNm || business.sggNm === filters.sggNm) + && (!filters.bizNm || business.bizNm.includes(filters.bizNm.trim())) + )); + return sortBusinessList(filtered, nextSort); + }; + + /** 지정 조회조건·정렬로 스크롤 표시용 사업 목록을 읽고 선택 행을 가능한 경우 유지한다. */ + const fetchBusinesses = async ( + filters: BusinessFilters, + nextSort = sort, + ) => { + setIsLoading(true); + setNotice(null); + try { + if (sampleMode) { + const content = getSampleBusinessPage(filters, nextSort); + setBusinesses(content); + setTotal(content.length); + setSelectedBusinessId((current) => content.some((item) => item.exmnBizId === current) ? current : null); + return true; + } + const result = await getExmnBusinesses(buildSearchParams(filters, nextSort)); + const content = sortBusinessList(result.content, nextSort); + setBusinesses(content); + setTotal(result.totalElements); + setSelectedBusinessId((current) => content.some((item) => item.exmnBizId === current) ? current : null); + return true; + } catch (error) { + setBusinesses([]); + setTotal(0); + setSelectedBusinessId(null); + setNotice({ + type: "error", + text: error instanceof Error ? error.message : "조사사업 목록 조회 중 오류가 발생했습니다.", + }); + return false; + } finally { + setIsLoading(false); + } + }; + + /** 입력 중 조건을 확정하고 첫 페이지를 조회한다. */ + const handleSearch = async () => { + setAppliedFilters(draftFilters); + await fetchBusinesses(draftFilters, sort); + }; + + /** 공통 WindowPopup 체계로 사업정보 등록 화면을 연다. */ + const openBusinessRegister = () => { + setNotice(null); + const popup = openWindowPopup("exmnBusinessRegister", { + sample: sampleMode ? "Y" : undefined, + sourcePath: window.location.pathname, + }); + if (!popup) { + setNotice({ type: "error", text: "사업등록 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요." }); + } + }; + + /** 선택 사업 컨텍스트를 전달해 공통 WindowPopup 결과등록 화면을 연다. */ + const openResultRegister = () => { + if (!selectedBusiness) { + setNotice({ type: "error", text: "사업 목록에서 대상 사업을 먼저 선택해주세요." }); + return; + } + setNotice(null); + const popup = openWindowPopup("exmnResultRegister", { + exmnBizId: selectedBusiness.exmnBizId, + bizYr: selectedBusiness.bizYr, + bizCycl: selectedBusiness.bizCycl ?? 0, + bizNm: selectedBusiness.bizNm, + ctpvNm: selectedBusiness.ctpvNm, + sggNm: selectedBusiness.sggNm, + trtdstCnt: selectedBusiness.trtdstCnt, + prcisExmnPrlg: selectedBusiness.prcisExmnPrlg, + tpco: selectedBusiness.tpco, + reportYn: selectedBusiness.reportYn ?? "N", + sample: sampleMode ? "Y" : undefined, + sourcePath: window.location.pathname, + }); + if (!popup) { + setNotice({ type: "error", text: "결과등록 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요." }); + } + }; + + /** WindowPopup의 등록·저장 완료 메시지를 받아 부모 목록과 안내 상태를 동기화한다. */ + useEffect(() => { + const handlePopupMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_BUSINESS_CREATED) { + const payload = event.data?.payload as { exmnBizId?: string; sample?: string; business?: string } | undefined; + if (payload?.sample === "Y" && payload.business) { + try { + const created = JSON.parse(payload.business) as ExmnBusiness; + setSampleBusinesses((current) => [created, ...current.filter((item) => item.exmnBizId !== created.exmnBizId)]); + setBusinesses((current) => [created, ...current.filter((item) => item.exmnBizId !== created.exmnBizId)]); + setTotal((current) => current + 1); + setSelectedBusinessId(created.exmnBizId); + setNotice({ type: "success", text: "샘플 조사사업이 등록되었습니다." }); + } catch { + setNotice({ type: "error", text: "샘플 사업정보를 부모 목록에 반영하지 못했습니다." }); + } + return; + } + setSelectedBusinessId(payload?.exmnBizId ?? null); + void fetchBusinesses(appliedFilters, sort).then((refreshed) => { + if (refreshed) setNotice({ type: "success", text: "조사사업이 등록되었습니다." }); + }); + return; + } + + if (event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.EXMN_RESULT_COMMITTED) { + const payload = event.data?.payload as { sample?: string } | undefined; + if (payload?.sample === "Y") { + setNotice({ type: "success", text: "샘플 조사결과 저장 흐름을 완료했습니다." }); + return; + } + void fetchBusinesses(appliedFilters, sort).then((refreshed) => { + if (refreshed) setNotice({ type: "success", text: "조사결과 최종 저장과 PPL/MNHL 집계를 완료했습니다." }); + }); + } + }; + + window.addEventListener("message", handlePopupMessage); + return () => window.removeEventListener("message", handlePopupMessage); + }, [appliedFilters, sort]); + + return ( +
+ + +
+
+ + setDraftFilters((current) => ({ ...current, bizCycl: event.target.value }))} /> +
+ setDraftFilters((current) => ({ ...current, bizYr: String(value) }))} + /> + setDraftFilters((current) => ({ + ...current, + ctpvCd, + sggCd: "0", + sggNm: "", + }))} + onSigunguChange={(sggCd) => setDraftFilters((current) => ({ ...current, sggCd }))} + setCtpvName={(ctpvNm) => setDraftFilters((current) => ({ + ...current, + ctpvNm: ctpvNm === "전체" ? "" : ctpvNm, + }))} + setSggName={(sggNm) => setDraftFilters((current) => ({ + ...current, + sggNm: sggNm === "전체" ? "" : sggNm, + }))} + /> +
+ + setDraftFilters((current) => ({ ...current, bizNm: event.target.value }))} onKeyDown={(event) => { if (event.key === "Enter") void handleSearch(); }} /> +
+
+ + + +
+
+
+ + {notice && ( +
{notice.text}
+ )} + + +
+

조사사업 목록 총 {total.toLocaleString("ko-KR")}건

+ {process.env.NODE_ENV === "development" && ( + + )} +
+
+
+
+ + {BUSINESS_TABLE_HEAD.map((head) => ( + + ))} + + + + {BUSINESS_TABLE_HEAD.map((head) => { + const sortKey = "sortKey" in head && head.sortKey ? head.sortKey : head.id; + const direction = sort?.key === sortKey ? sort.direction : undefined; + const sortable = "sortable" in head && head.sortable; + return ( + + {sortable ? ( + + ) : head.title} + + ); + })} + + + + {businessRows.length > 0 ? businessRows.map((business) => { + const selected = business.exmnBizId === selectedBusinessId; + return ( + setSelectedBusinessId(business.exmnBizId)} + > + {BUSINESS_TABLE_HEAD.map((head) => ( + + + + ))} + + ); + }) : ( + + + 조회된 결과가 없습니다. + + + )} + +
+
+