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 3626054c7..1b6542816 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx @@ -3,13 +3,12 @@ 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 { UiTable } from "@common/business_components/ui"; import { getData } from "@/components/lib/api"; import useStore from "@/store/useStore"; import LoadingOverlay from "@/components/ui/LoadingBar"; import { AGED_PIPE_DEFAULT_FILTERS, - AGED_PIPE_EXCEL_HEADER, AGED_PIPE_LAYER_TYPE, AGED_PIPE_LIST_HEADLIST, AGED_PIPE_PAGE_SIZE, @@ -20,6 +19,7 @@ import { type AgedPipeFilters, type AgedPipeRow, } from "./agedPipeUtils"; +import { downloadAgedPipeCsv } from "./agedPipeExport"; const POPUP_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT"; const POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE"; @@ -40,6 +40,7 @@ export default function AgedPipePopupPage() { const [currentPage, setCurrentPage] = useState(1); const [selectedIndex, setSelectedIndex] = useState(-1); const [isLoading, setIsLoading] = useState(false); + const [downloadProgressText, setDownloadProgressText] = useState(""); const sggOptions = useMemo( () => @@ -122,7 +123,7 @@ export default function AgedPipePopupPage() { return () => window.removeEventListener("message", handleFilterSyncMessage); }, [loadRows]); - const handleSearch = (page = 1) => { + const handleSearch = async (page = 1) => { if (!validateElapsedYears(Number(filters.elapsedYears))) { alert("경과년도는 1년~100년 범위 내에서 입력해주세요."); return; @@ -130,7 +131,7 @@ export default function AgedPipePopupPage() { const normalized = normalizeAgedPipeFilters(filters); setFilters(normalized); - loadRows(page, normalized); + await loadRows(page, normalized); }; const handleRowClick = (index?: number) => { @@ -156,17 +157,28 @@ export default function AgedPipePopupPage() { try { setIsLoading(true); - const response = await fetchRows(1, filters, 10000); - await downloadExcelWithKoreanHeaders({ - data: response.rows as JSON[], - fileName: `노후관로_현황_${new Date().toISOString().slice(0, 10)}`, - sheetNameArray: ["노후관로 현황"], - headerRange: "A1:J1", - headerMapping: AGED_PIPE_EXCEL_HEADER, + setDownloadProgressText("CSV export 작업을 준비하고 있습니다."); + await downloadAgedPipeCsv(filters, (job) => { + if (job.status === "QUEUED") { + setDownloadProgressText("CSV export 작업이 대기 중입니다."); + return; + } + + if (job.status === "RUNNING") { + setDownloadProgressText( + `CSV export 생성 중입니다. (${job.progressPercent ?? 0}%)`, + ); + } }); + setDownloadProgressText(""); } catch (error) { console.error("노후관로 목록 다운로드 중 오류:", error); - alert("노후관로 목록 다운로드 중 오류가 발생했습니다."); + setDownloadProgressText(""); + alert( + error instanceof Error + ? error.message + : "노후관로 CSV 다운로드 중 오류가 발생했습니다.", + ); } finally { setIsLoading(false); } @@ -202,6 +214,12 @@ export default function AgedPipePopupPage() { + {downloadProgressText ? ( +
+ {downloadProgressText} +
+ ) : null} +
@@ -270,7 +288,7 @@ export default function AgedPipePopupPage() { size="sm" color="primary" className="h-10 px-5 !text-[1.1rem]" - onClick={() => handleSearch(1)} + onClick={() => void handleSearch(1)} > 조회 @@ -293,7 +311,7 @@ export default function AgedPipePopupPage() { pageSize={10} total={total} cellClick={(index) => handleRowClick(index)} - onPageChange={(page) => handleSearch(page)} + onPageChange={(page) => void handleSearch(page)} curPage={currentPage} index={selectedIndex} widthFixed={true} diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeExport.ts b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeExport.ts new file mode 100644 index 000000000..7230ccb3f --- /dev/null +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeExport.ts @@ -0,0 +1,42 @@ +"use client"; + +import { postBlobData } from "@/components/lib/api"; +import { fileDownload } from "@/components/lib/utils/fileDownload"; +import { + AGED_PIPE_EXCEL_FIELDS, + normalizeAgedPipeFilters, + type AgedPipeFilters, +} from "./agedPipeUtils"; + +type AgedPipeExportProgress = { + fileName?: string; + progressPercent?: number; + status: "QUEUED" | "RUNNING" | "COMPLETED"; +}; + +/** 노후관로 CSV 다운로드를 요청한다. */ +export const downloadAgedPipeCsv = async ( + filters: AgedPipeFilters, + onProgress?: (progress: AgedPipeExportProgress) => void, +) => { + const normalizedFilters = normalizeAgedPipeFilters(filters); + + onProgress?.({ status: "RUNNING", progressPercent: 0 }); + + const { blob, fileName } = await postBlobData("v1/fclts/agedPipes/exportJobs/download", { + elapsedYears: normalizedFilters.elapsedYears, + ctpvCd: normalizedFilters.ctpvCd, + sggCd: normalizedFilters.sggCd, + format: "csv", + columns: [...AGED_PIPE_EXCEL_FIELDS], + }); + + const resolvedFileName = fileName || "노후관로_현황.csv"; + fileDownload(blob, resolvedFileName); + onProgress?.({ status: "COMPLETED", fileName: resolvedFileName, progressPercent: 100 }); + + return { + status: "COMPLETED" as const, + fileName: resolvedFileName, + }; +}; 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 62bccc416..e273d9c1c 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts +++ b/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts @@ -10,8 +10,9 @@ import { export const AGED_PIPE_LAYER_TYPE = "tb_ms_swage_ppl"; export const AGED_PIPE_DEFAULT_ELAPSED_YEARS = 30; export const AGED_PIPE_PAGE_SIZE = 10; -export const AGED_PIPE_MIN_ZOOM = 14; -export const AGED_PIPE_INVALID_INSTALL_YMD_PREFIXES = ["19000101", "19990101"]; +export const AGED_PIPE_MIN_ZOOM = 13; +export const AGED_PIPE_GUIDE_MIN_ZOOM = 13.1; +export const AGED_PIPE_INVALID_INSTALL_YMD_PREFIXES = ["19000101"]; export type AgedPipeFilters = { elapsedYears: number; @@ -46,6 +47,12 @@ export const AGED_PIPE_DEFAULT_FILTERS: AgedPipeFilters = { sggCd: "0", }; +const normalizeRegionFilterCode = (value: unknown, prefixLength: number) => { + const digits = String(value ?? "").replace(/[^0-9]/g, ""); + if (digits === "" || digits === "0") return "0"; + return digits.length > prefixLength ? digits.slice(0, prefixLength) : digits; +}; + export const AGED_PIPE_LIST_HEADLIST = [ { id: "rowNumber", title: "NO", widthPercent: 6 }, { id: "fcltMngNo", title: "시설관리번호", widthPercent: 18 }, @@ -57,8 +64,61 @@ export const AGED_PIPE_LIST_HEADLIST = [ { id: "elapsedYears", title: "경과년도", widthPercent: 12, align: "right" }, ]; +export const AGED_PIPE_EXCEL_FIELDS = [ + "ftreSmbolCd", + "fcltMngNo", + "pbadmsEmdCd", + "mapshtNo", + "mngInstCd", + "instlYmd", + "usgCd", + "mqltCd", + "fcltyShpCd", + "pdmtVl", + "wdthLen", + "vrtcLen", + "prlgVl", + "tmptDpth", + "scendDpth", + "avgGrdn", + "trflnCnt", + "exclnMltvArea", + "drtwMltvArea", + "rnyssnFvlcVl", + "clarFvlcVl", + "tmptMhlMngNo", + "scendMhlMngNo", + "instMngNo", + "fid", + "lyrTypeNm", + "swtrmtFcltCd", + "elapsedYears", + "ctpvNm", + "sggNm", +] as const; + export const AGED_PIPE_EXCEL_HEADER = { - rowNumber: "NO", + ftreSmbolCd: "지형지물부호코드", + pbadmsEmdCd: "행정읍면동코드", + mapshtNo: "도엽번호", + mngInstCd: "관리기관코드", + wdthLen: "가로길이", + vrtcLen: "세로길이", + tmptDpth: "시점깊이", + scendDpth: "종점깊이", + avgGrdn: "평균구배", + trflnCnt: "차선수", + exclnMltvArea: "우수배수면적", + drtwMltvArea: "오수배수면적", + rnyssnFvlcVl: "우기유속값", + clarFvlcVl: "맑음유속값", + tmptMhlMngNo: "시점맨홀관리번호", + scendMhlMngNo: "종점맨홀관리번호", + instMngNo: "기관관리번호", + fid: "FID", + lyrTypeNm: "레이어타입명", + swtrmtFcltCd: "시설코드", + fcltyShpCd: "시설물형태코드", fcltMngNo: "시설관리번호", instlYmd: "설치일자", pdmtVl: "관경(mm)", @@ -70,6 +130,21 @@ export const AGED_PIPE_EXCEL_HEADER = { sggNm: "시군구", }; +const getExcelColumnLetter = (columnIndex: number) => { + let current = columnIndex; + let result = ""; + + while (current > 0) { + const remainder = (current - 1) % 26; + result = String.fromCharCode(65 + remainder) + result; + current = Math.floor((current - 1) / 26); + } + + return result || "A"; +}; + +export const AGED_PIPE_EXCEL_HEADER_RANGE = `A1:${getExcelColumnLetter(AGED_PIPE_EXCEL_FIELDS.length)}1`; + const pad2 = (value: number) => String(value).padStart(2, "0"); const normalizeYmd = (value: unknown) => @@ -82,6 +157,8 @@ export const normalizeAgedPipeFilters = (filters: AgedPipeFilters): AgedPipeFilt return { ...filters, + ctpvCd: normalizeRegionFilterCode(filters.ctpvCd, 2), + sggCd: normalizeRegionFilterCode(filters.sggCd, 5), elapsedYears: Number.isFinite(elapsedYears) && elapsedYears >= 1 && elapsedYears <= 100 ? elapsedYears @@ -224,6 +301,16 @@ export const toDisplayPipeRow = ( }; }; +export const toAgedPipeExcelRow = (row: AgedPipeRow) => { + const exportRow: Record = {}; + + AGED_PIPE_EXCEL_FIELDS.forEach((field) => { + exportRow[field] = row[field] ?? "-"; + }); + + return exportRow; +}; + export const isAgedPipeLayerNode = (node: any) => { const data = node?.data ?? node; const layerName = String(data?.lyrNm ?? node?.text ?? ""); diff --git a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx index f1d2590bc..53cf75946 100644 --- a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx +++ b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx @@ -1,50 +1,101 @@ "use client"; import { useState } from "react"; +import { + ChartColumnBig, + ChevronLeft, + ChevronRight, + History, + PanelsTopLeft, + PencilRuler, + Search, + Settings2, + TriangleAlert, + Waves, +} from "lucide-react"; import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions"; type Props = { onOpenPopup: (popupKey: WindowPopupKey) => void; + onOpenInfoSearch: () => void; }; -const ANALYSIS_POPUP_ITEMS: Array<{ key: WindowPopupKey; label: string }> = [ - { key: "pipeAnalysisPopup", label: "관로분석" }, - { key: "floodPredictionMapPopup", label: "침수예측지도" }, - { key: "floodHistoryPopup", label: "침수발생이력" }, - { key: "gisEditPopup", label: "GIS 편집" }, - { key: "gisManagementPopup", label: "GIS 관리" }, - { key: "flowRiskPopup", label: "통수 위험" }, +const ANALYSIS_POPUP_ITEMS: Array<{ + key: WindowPopupKey | "infoSearch"; + label: string; + icon: typeof ChartColumnBig; +}> = [ + { key: "infoSearch", label: "정보검색", icon: Search }, + { key: "pipeAnalysisPopup", label: "관로분석", icon: ChartColumnBig }, + { key: "floodPredictionMapPopup", label: "침수예측지도", icon: Waves }, + { key: "floodHistoryPopup", label: "침수발생이력", icon: History }, + { key: "gisEditPopup", label: "GIS 편집", icon: PencilRuler }, + { key: "gisManagementPopup", label: "GIS 관리", icon: Settings2 }, + { key: "flowRiskPopup", label: "통수 위험", icon: TriangleAlert }, ]; -export default function AnalysisPopupMenu({ onOpenPopup }: Props) { +export default function AnalysisPopupMenu({ onOpenPopup, onOpenInfoSearch }: Props) { const [isOpen, setIsOpen] = useState(true); + const [activeKey, setActiveKey] = useState(null); return ( -
+
{isOpen ? ( -
- {ANALYSIS_POPUP_ITEMS.map((item) => ( - - ))} +
+
+
+ + 분석도구 +
+
+ +
+ {ANALYSIS_POPUP_ITEMS.map((item) => { + const Icon = item.icon; + const isActive = activeKey === item.key; + + return ( + + ); + })} +
) : null}
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 200405c9a..c958d5caa 100644 --- a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx @@ -18,6 +18,7 @@ import { buildPipeAnalysisScope, normalizeNumberInput, resolveElapsedYearBucketLabel, + resolveMaterialKoreanName, resolveMaterialName, resolvePipeAnalysisTypeLabel, resolveRegionName, @@ -27,6 +28,19 @@ import { } from "./pipeAnalysisUtils"; type DetailViewRow = ReturnType[number]; +const PIPE_ANALYSIS_DETAIL_EXPORT_KEYS = [ + "rowNo", + "ctpvNm", + "sggNm", + "fcltMngNo", + "instMngNo", + "swtrmtZoneNm", + "instlYmd", + "useNm", + "pdmtVl", + "prlgVl", + "materialName", +] as const; export default function PipeAnalysisDetailPopupPage() { const searchParams = useSearchParams(); @@ -151,11 +165,25 @@ export default function PipeAnalysisDetailPopupPage() { try { setIsLoading(true); const response = await fetchRows(1, filters, Math.max(total, 1000)); + const exportRows = response.rows.map((row) => ({ + rowNo: row.rowNo, + ctpvNm: row.ctpvNm, + sggNm: row.sggNm, + fcltMngNo: row.fcltMngNo, + instMngNo: row.instMngNo, + swtrmtZoneNm: row.swtrmtZoneNm, + instlYmd: row.instlYmd, + useNm: row.useNm, + pdmtVl: row.pdmtVl, + prlgVl: row.prlgVl, + materialName: resolveMaterialKoreanName(row.materialCode) || row.materialName, + })); + const detailHeaderRange = `A1:${String.fromCharCode(64 + PIPE_ANALYSIS_DETAIL_EXPORT_KEYS.length)}1`; await downloadExcelWithKoreanHeaders({ - data: response.rows as unknown as JSON[], + data: exportRows as unknown as JSON[], fileName: `관로분석_관로현황_${buildPipeAnalysisFileDate()}`, sheetNameArray: ["관로현황"], - headerRange: "A1:K1", + headerRange: detailHeaderRange, headerMapping: PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS, }); } catch (error) { diff --git a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipeAnalysisUtils.ts b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipeAnalysisUtils.ts index 7eeaa934b..fddb1717c 100644 --- a/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipeAnalysisUtils.ts +++ b/apps/sn/app/(map-layout)/map/components/pipeAnalysis/pipeAnalysisUtils.ts @@ -519,17 +519,69 @@ export const resolveMaterialName = (materialCode?: string) => { return materialMap.get(code) ?? code; }; +/** + * 관재질 라벨에서 영문 약어/코드를 걷어내고 엑셀 헤더용 한글명을 우선 추출한다. + * - `염화비닐관(PVC)` -> `염화비닐관` + * - `PCF관(강판보강 PE 나선형관)` -> `강판보강 나선형관` + */ +const toKoreanMaterialLabel = (value?: string) => { + const normalized = String(value ?? "").trim(); + if (!normalized) return ""; + + const withoutParenthesis = normalized + .replace(/\s*\([^)]*\)\s*/g, " ") + .replace(/\s+/g, " ") + .trim(); + const withoutAsciiToken = withoutParenthesis + .replace(/[A-Za-z0-9-]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + + if (/[가-힣]/.test(withoutAsciiToken)) { + return withoutAsciiToken; + } + + if (/[가-힣]/.test(withoutParenthesis)) { + return withoutParenthesis; + } + + const parenthesisMatches = Array.from(normalized.matchAll(/\(([^)]*)\)/g)) + .map((match) => String(match[1] ?? "").trim()) + .filter(Boolean); + const koreanParenthesisText = parenthesisMatches.find((text) => /[가-힣]/.test(text)); + + if (koreanParenthesisText) { + const simplifiedParenthesisText = koreanParenthesisText + .replace(/[A-Za-z0-9-]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + return simplifiedParenthesisText || koreanParenthesisText; + } + + return normalized; +}; + export const resolveMaterialColumnName = (column: PipeAnalysisMaterialColumn) => { const rawName = String(column.materialName ?? "").trim(); const rawCode = String(column.materialCode ?? "").trim(); const rawKey = String(column.key ?? "").trim(); - if (!rawName) { - return resolveMaterialName(rawCode || rawKey); - } - if (rawName === rawCode || rawName === rawKey) { - return resolveMaterialName(rawCode || rawKey); - } - return rawName; + const mappedName = resolveMaterialName(rawCode || rawKey); + const candidates = [ + toKoreanMaterialLabel(mappedName), + toKoreanMaterialLabel(rawName), + mappedName, + rawName, + rawCode, + rawKey, + ].filter(Boolean); + + return candidates[0] ?? "-"; +}; + +export const resolveMaterialKoreanName = (materialCode?: string) => { + const resolved = resolveMaterialName(materialCode); + const koreanLabel = toKoreanMaterialLabel(resolved); + return koreanLabel || resolved; }; export const resolveElapsedYearBucketLabel = (bucket?: string) => { diff --git a/apps/sn/app/(map-layout)/map/page.tsx b/apps/sn/app/(map-layout)/map/page.tsx index 635f9e1b3..7618ec521 100644 --- a/apps/sn/app/(map-layout)/map/page.tsx +++ b/apps/sn/app/(map-layout)/map/page.tsx @@ -36,11 +36,9 @@ import { ProcSearchBar } from "@common/business_components/ui/map/searchBar"; import { Button } from "@common/components"; import useSearchModalStore from "@/store/useSearchModalStore"; import useStore from "@/store/useStore"; -import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui"; import AgedPipeLayerSettingPanel from "./components/agedPipe/AgedPipeLayerSettingPanel"; import type { PipeAnalysisDetailRow } from "./components/pipeAnalysis/pipeAnalysisUtils"; import { - AGED_PIPE_EXCEL_HEADER, AGED_PIPE_MIN_ZOOM, AGED_PIPE_LAYER_TYPE, AGED_PIPE_PAGE_SIZE, @@ -53,7 +51,9 @@ import { type AgedPipeFilters, type AgedPipeRow, AGED_PIPE_DEFAULT_FILTERS, + AGED_PIPE_GUIDE_MIN_ZOOM, } from "./components/agedPipe/agedPipeUtils"; +import { downloadAgedPipeCsv } from "./components/agedPipe/agedPipeExport"; import { PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE } from "./components/pipeAnalysis/pipeAnalysisUtils"; import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions"; @@ -528,6 +528,7 @@ const Map = () => { const [agedPipeTotalCount, setAgedPipeTotalCount] = useState(0); const [agedPipeCurrentPage, setAgedPipeCurrentPage] = useState(1); const [isAgedPipeListLoading, setIsAgedPipeListLoading] = useState(false); + const [agedPipeDownloadProgressText, setAgedPipeDownloadProgressText] = useState(""); const { ctpv, sgg } = useStore(); const shouldFocusAgedPipeOnNextSelectRef = useRef(false); const agedPipePopupWindowRef = useRef(null); @@ -1018,21 +1019,32 @@ const Map = () => { try { setIsAgedPipeListLoading(true); - const response = await fetchAgedPipeRows(1, appliedAgedPipeFilters, 10000); - await downloadExcelWithKoreanHeaders({ - data: response.rows as JSON[], - fileName: `노후관로_현황_${new Date().toISOString().slice(0, 10)}`, - sheetNameArray: ["노후관로 현황"], - headerRange: "A1:J1", - headerMapping: AGED_PIPE_EXCEL_HEADER, + setAgedPipeDownloadProgressText("CSV export 작업을 준비하고 있습니다."); + await downloadAgedPipeCsv(appliedAgedPipeFilters, (job) => { + if (job.status === "QUEUED") { + setAgedPipeDownloadProgressText("CSV export 작업이 대기 중입니다."); + return; + } + + if (job.status === "RUNNING") { + setAgedPipeDownloadProgressText( + `CSV export 생성 중입니다. (${job.progressPercent ?? 0}%)`, + ); + } }); + setAgedPipeDownloadProgressText(""); } catch (error) { console.error("노후관로 목록 다운로드 중 오류:", error); - alert("노후관로 목록 다운로드 중 오류가 발생했습니다."); + setAgedPipeDownloadProgressText(""); + alert( + error instanceof Error + ? error.message + : "노후관로 CSV 다운로드 중 오류가 발생했습니다.", + ); } finally { setIsAgedPipeListLoading(false); } - }, [agedPipeTotalCount, appliedAgedPipeFilters, fetchAgedPipeRows]); + }, [agedPipeTotalCount, appliedAgedPipeFilters]); useEffect(() => { const handleAgedPipePopupMessage = (event: MessageEvent) => { @@ -1224,13 +1236,21 @@ const Map = () => { return ( <> -
+
{layerLoadError ? (
레이어 설정 조회 실패: {layerLoadError}
) : null} - + {agedPipeDownloadProgressText ? ( +
+ {agedPipeDownloadProgressText} +
+ ) : null} + setIsSearchModalOpen(true)} + /> { setIsSearchModalOpen={setIsSearchModalOpen} onCheckedLayersChange={handleCheckedLayersChange} onOpenPopup={handleOpenLayerPopup} - searchBar={} + searchBar={ + + } waterDepth={showWaterDepth ? WaterDepth : undefined} inlineLayerSettingView={inlineLayerSettingView} layerIconVisible={isAgedPipeLayerNode} @@ -1257,6 +1283,17 @@ const Map = () => { checkInterval={2500} checkedLayerChangeDelay={0} showZoomLevel={true} + showZoomScaleInfo={true} + showFloatingSearchButton={false} + treeTogglePosition="top" + zoomGuide={ + isAgedPipeChecked + ? { + label: "노후관로 기준", + zoom: AGED_PIPE_GUIDE_MIN_ZOOM, + } + : undefined + } defaultZoom={7.5} defaultLonLat={[127.75, 36]} @@ -1293,6 +1330,37 @@ const Map = () => {
)} +
); diff --git a/apps/sn/app/components/lib/api/index.ts b/apps/sn/app/components/lib/api/index.ts index 08d2dfbe8..28c9f4500 100644 --- a/apps/sn/app/components/lib/api/index.ts +++ b/apps/sn/app/components/lib/api/index.ts @@ -101,6 +101,46 @@ export const postData = async (url: string, data: object): Promise => { } }; +const parseDownloadFileName = (contentDisposition: string | null) => { + if (!contentDisposition) return undefined; + + const utf8Match = contentDisposition.match(/filename\*\s*=\s*UTF-8''([^;]+)/i); + if (utf8Match?.[1]) { + return decodeURIComponent(utf8Match[1].trim()); + } + + const plainMatch = contentDisposition.match(/filename\s*=\s*"?([^\";]+)"?/i); + return plainMatch?.[1]?.trim(); +}; + +export const postBlobData = async ( + url: string, + data: object, +): Promise<{ blob: Blob; fileName?: string }> => { + const res = await fetch(resolveApiUrl(url), { + method: "POST", + headers: withSiteIdHeaders({ + "Content-Type": "application/json", + }), + body: JSON.stringify(data), + }); + if (res.status === 401) { + handleUnauthorizedResponse(); + } + + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error( + `Failed to fetch blob: ${res.status} ${res.statusText}${errorText ? ` - ${errorText.slice(0, 200)}` : ""} (${url})`, + ); + } + + return { + blob: await res.blob(), + fileName: parseDownloadFileName(res.headers.get("content-disposition")), + }; +}; + export const postMutiPart = async (url: string, data: FormData): Promise => { const res = await axios.post(`${BASE_URL}/${url}`, data, { headers: DEFAULT_HEADERS, diff --git a/apps/sn/app/store/index.ts b/apps/sn/app/store/index.ts index 91577ae1a..7d761f91a 100644 --- a/apps/sn/app/store/index.ts +++ b/apps/sn/app/store/index.ts @@ -33,8 +33,8 @@ export const fetchCtpv = async (set: (state: any) => void) => { const list = res.features .map((f: any) => ({ - text: f.properties.mctpv_korn_nm, - val: f.properties.mctpv_cd, + text: String(f.properties.mctpv_korn_nm ?? "").trim(), + val: String(f.properties.mctpv_cd ?? "").trim(), })) .sort((a:any, b:any) => { // 커스텀 순서 정의 @@ -83,8 +83,8 @@ export const fetchSgg = async (set: (state: any) => void) => { const list = res.features .map((f: any) => ({ - text: f.properties.sgg_korn_nm, - val: f.properties.sgg_cd, + text: String(f.properties.sgg_korn_nm ?? "").trim(), + val: String(f.properties.sgg_cd ?? "").trim(), })) .sort((a:any, b:any) => a.text.localeCompare(b.text, 'ko')); diff --git a/apps/st/src/components/button/ApproveButton.tsx b/apps/st/src/components/button/ApproveButton.tsx index 36455f7ff..76d3618f5 100644 --- a/apps/st/src/components/button/ApproveButton.tsx +++ b/apps/st/src/components/button/ApproveButton.tsx @@ -1,7 +1,7 @@ import { Button } from "@common/components"; interface ApproveButtonProps { - approveFunc: () => void; + approveFunc: () => void | Promise; } const ApproveButton = ({ approveFunc }: ApproveButtonProps) => { diff --git a/apps/st/src/components/css/DataGrid.css b/apps/st/src/components/css/DataGrid.css index 8a2d8d928..190d41a79 100644 --- a/apps/st/src/components/css/DataGrid.css +++ b/apps/st/src/components/css/DataGrid.css @@ -1,21 +1,6 @@ @tailwind components; @layer components { - .rg-grid .rg-header .rg-table .rg-header-group-cell { - @apply !bg-[#466995] !text-[#fff]; - } - .rg-grid .rg-header .rg-table { - @apply !bg-[#5987bf] !text-[#fff]; - } - - /* 상태바·체크바 헤더 */ - .rg-grid .rg-head .rg-table tr:first-child td:first-child, - .rg-grid .rg-head .rg-table tr:first-child td:nth-child(2), - .rg-grid .rg-header .rg-table tr:first-child td:first-child, - .rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) { - @apply !bg-[#5987bf] !text-[#fff]; - } - .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell { @apply !bg-white; } @@ -36,6 +21,91 @@ .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-prcs-ok { @apply !font-semibold !text-[#3372cf]; } + /* 개별 반려: 높이/레이아웃은 RealGrid 행에 그대로 두고, 색·테두리·정렬만 버튼 느낌 */ + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-reject-mini-btn { + text-align: center !important; + vertical-align: middle !important; + box-sizing: border-box !important; + padding-left: 8px !important; + padding-right: 8px !important; + border-radius: 0.25rem !important; + border: 1px solid rgb(248 113 113 / 0.55) !important; + background-color: rgb(254 242 242) !important; + color: rgb(185 28 28) !important; + font-size: 1.2rem !important; + font-weight: 600 !important; + white-space: nowrap !important; + cursor: pointer !important; + text-decoration: none !important; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-reject-mini-btn:hover { + background-color: rgb(254 226 226) !important; + border-color: rgb(239 68 68 / 0.85) !important; + color: rgb(153 27 27) !important; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-reject-mini-btn:active { + background-color: rgb(254 202 202) !important; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-outline-action-btn { + text-align: center !important; + vertical-align: middle !important; + box-sizing: border-box !important; + padding-left: 8px !important; + padding-right: 8px !important; + border-radius: 0.25rem !important; + border: 1px solid rgb(148 163 184 / 0.7) !important; + background-color: rgb(248 250 252) !important; + color: rgb(51 65 85) !important; + font-size: 1.2rem !important; + font-weight: 600 !important; + white-space: nowrap !important; + cursor: pointer !important; + text-decoration: none !important; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-outline-action-btn:hover { + background-color: rgb(241 245 249) !important; + border-color: rgb(100 116 139 / 0.85) !important; + color: rgb(30 41 59) !important; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-outline-action-btn:active { + background-color: rgb(226 232 240) !important; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-action-link { + @apply !font-semibold !text-[#2563eb] underline; + cursor: pointer !important; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-stats-status-pending { + @apply !text-gray-500; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-stats-status-submitted { + @apply !font-semibold; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-stats-status-approved { + @apply !font-semibold; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-stats-status-rejected { + @apply !font-semibold !text-[#ff6c64]; + white-space: nowrap !important; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-stats-status-rejected > * { + display: inline-flex !important; + align-items: center !important; + justify-content: center; + width: max-content; + margin: 0 auto; + } + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-stats-status-rejected > *::after { + content: ""; + display: inline-block; + margin-left: 0.25rem; + vertical-align: middle; + width: 0.6125rem; + height: 0.6125rem; + border-radius: 9999px; + background: #ff6c64; + box-shadow: 0 0 0 0 rgba(255, 108, 100, 0.75); + animation: rg-cell-stats-status-dot-ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; + } .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-total { @apply !bg-[#e3eaf2]; } @@ -85,59 +155,12 @@ .data-grid-status-bubble > span + span { @apply ml-4 border-l border-slate-200/70 pl-4; } +} - [data-theme="black"] { - .rg-grid .rg-header .rg-table .rg-header-group-cell { - @apply !bg-[#3C4043] !text-[#fff]; - } - .rg-grid .rg-header .rg-table .rg-header-cell { - @apply !bg-[#62686d] !text-[#fff]; - } - .rg-grid .rg-head .rg-table tr:first-child td:first-child, - .rg-grid .rg-head .rg-table tr:first-child td:nth-child(2), - .rg-grid .rg-header .rg-table tr:first-child td:first-child, - .rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) { - @apply !bg-[#62686d] !text-[#fff]; - } - .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell { - @apply !bg-[#545556] !text-[#fff]; - } - .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-prcs-bad { - @apply !text-[#f87171]; - } - .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-prcs-ok { - @apply !text-[#3372cf]; - } - } - - [data-theme="red"] { - .rg-grid .rg-header .rg-table .rg-header-group-cell { - @apply !bg-[#646469] !text-[#fff]; - } - .rg-grid .rg-header .rg-table .rg-header-cell { - @apply !bg-[#8d8d94] !text-[#fff]; - } - .rg-grid .rg-head .rg-table tr:first-child td:first-child, - .rg-grid .rg-head .rg-table tr:first-child td:nth-child(2), - .rg-grid .rg-header .rg-table tr:first-child td:first-child, - .rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) { - @apply !bg-[#8d8d94] !text-[#fff]; - } - } - - [data-theme="green"] { - .rg-grid .rg-header .rg-table .rg-header-group-cell { - @apply !bg-[#518585] !text-[#fff]; - } - .rg-grid .rg-header .rg-table .rg-header-cell { - @apply !bg-[#6bafae] !text-[#fff]; - } - .rg-grid .rg-head .rg-table tr:first-child td:first-child, - .rg-grid .rg-head .rg-table tr:first-child td:nth-child(2), - .rg-grid .rg-header .rg-table tr:first-child td:first-child, - .rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) { - @apply !bg-[#6bafae] !text-[#fff]; - } +@keyframes rg-cell-stats-status-dot-ping { + 75%, + 100% { + box-shadow: 0 0 0 0.5rem rgba(255, 108, 100, 0); } } @@ -164,4 +187,123 @@ .rg-grid .rg-head .rg-table tr td { border: none; border-bottom: 1px solid #e5e7eb; -} \ No newline at end of file +} + +/* + * ST 통계 그리드 헤더 색 — @layer 밖(일반 규칙). + * packages/rsone/components/grid/css/DataGrid.css 등 @layer 밖 !important와 동일 층에서 + * .rg-grid … 특이도로 덮어씀. + */ +.rg-grid .rg-header .rg-table .rg-header-group-cell { + background-color: #466995 !important; + color: #ffffff !important; +} +.rg-grid .rg-header .rg-table { + background-color: #5987bf !important; + color: #ffffff !important; +} +.rg-grid .rg-head .rg-table tr:first-child td:first-child, +.rg-grid .rg-head .rg-table tr:first-child td:nth-child(2), +.rg-grid .rg-header .rg-table tr:first-child td:first-child, +.rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) { + background-color: #5987bf !important; + color: #ffffff !important; +} +.rg-grid .rg-header .rg-cell, +.rg-grid .rg-head .rg-cell { + background-color: #5987bf !important; + color: #ffffff !important; +} + +[data-theme="black"] .rg-grid .rg-header .rg-table .rg-header-group-cell { + background-color: #3c4043 !important; + color: #ffffff !important; +} +[data-theme="black"] .rg-grid .rg-header .rg-table .rg-header-cell { + background-color: #62686d !important; + color: #ffffff !important; +} +[data-theme="black"] .rg-grid .rg-head .rg-table tr:first-child td:first-child, +[data-theme="black"] .rg-grid .rg-head .rg-table tr:first-child td:nth-child(2), +[data-theme="black"] .rg-grid .rg-header .rg-table tr:first-child td:first-child, +[data-theme="black"] .rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) { + background-color: #62686d !important; + color: #ffffff !important; +} +[data-theme="black"] .rg-grid .rg-header .rg-cell, +[data-theme="black"] .rg-grid .rg-head .rg-cell { + background-color: #62686d !important; + color: #ffffff !important; +} +[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell { + background-color: #545556 !important; + color: #ffffff !important; +} +[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-prcs-bad { + color: #f87171 !important; +} +[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-prcs-ok { + color: #3372cf !important; +} +[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-reject-mini-btn { + background-color: rgb(127 29 29 / 0.42) !important; + border-color: rgb(248 113 113 / 0.5) !important; + color: #fecaca !important; +} +[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-reject-mini-btn:hover { + background-color: rgb(153 27 27 / 0.55) !important; + border-color: rgb(252 165 165 / 0.75) !important; + color: #fee2e2 !important; +} +[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-outline-action-btn { + background-color: rgb(71 85 105 / 0.35) !important; + border-color: rgb(148 163 184 / 0.55) !important; + color: #e2e8f0 !important; +} +[data-theme="black"] .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-outline-action-btn:hover { + background-color: rgb(71 85 105 / 0.5) !important; + border-color: rgb(203 213 225 / 0.65) !important; + color: #f8fafc !important; +} + +[data-theme="red"] .rg-grid .rg-header .rg-table .rg-header-group-cell { + background-color: #646469 !important; + color: #ffffff !important; +} +[data-theme="red"] .rg-grid .rg-header .rg-table .rg-header-cell { + background-color: #8d8d94 !important; + color: #ffffff !important; +} +[data-theme="red"] .rg-grid .rg-head .rg-table tr:first-child td:first-child, +[data-theme="red"] .rg-grid .rg-head .rg-table tr:first-child td:nth-child(2), +[data-theme="red"] .rg-grid .rg-header .rg-table tr:first-child td:first-child, +[data-theme="red"] .rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) { + background-color: #8d8d94 !important; + color: #ffffff !important; +} +[data-theme="red"] .rg-grid .rg-header .rg-cell, +[data-theme="red"] .rg-grid .rg-head .rg-cell { + background-color: #8d8d94 !important; + color: #ffffff !important; +} + +[data-theme="green"] .rg-grid .rg-header .rg-table .rg-header-group-cell { + background-color: #518585 !important; + color: #ffffff !important; +} +[data-theme="green"] .rg-grid .rg-header .rg-table .rg-header-cell { + background-color: #6bafae !important; + color: #ffffff !important; +} +[data-theme="green"] .rg-grid .rg-head .rg-table tr:first-child td:first-child, +[data-theme="green"] .rg-grid .rg-head .rg-table tr:first-child td:nth-child(2), +[data-theme="green"] .rg-grid .rg-header .rg-table tr:first-child td:first-child, +[data-theme="green"] .rg-grid .rg-header .rg-table tr:first-child td:nth-child(2) { + background-color: #6bafae !important; + color: #ffffff !important; +} +[data-theme="green"] .rg-grid .rg-header .rg-cell, +[data-theme="green"] .rg-grid .rg-head .rg-cell { + background-color: #6bafae !important; + color: #ffffff !important; +} diff --git a/apps/st/src/components/module/CompactDataGrid.tsx b/apps/st/src/components/module/CompactDataGrid.tsx index c96f22973..6de651a1d 100644 --- a/apps/st/src/components/module/CompactDataGrid.tsx +++ b/apps/st/src/components/module/CompactDataGrid.tsx @@ -71,6 +71,38 @@ function createNumberComparer(dp: RealGridDataProviderComparer): (field: string }; } +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function realGridHeaderWithTooltip( + header: T, +): T { + const tip = header.tooltip?.trim(); + if (!tip) return header; + return { + ...header, + tooltip: tip, + showTooltip: header.showTooltip ?? true, + template: + header.template ?? + `${escapeHtml(header.text)}`, + }; +} + +function resolveColumnTooltip(column: { + tooltip?: string; + header: { text: string; tooltip?: string; showTooltip?: boolean; template?: string }; +}): string | undefined { + const tip = column.tooltip?.trim() || column.header.tooltip?.trim(); + return tip && tip.length > 0 ? tip : undefined; +} + const CompactDataGrid = ({ columnInfo, fixedColumns, @@ -88,8 +120,10 @@ const CompactDataGrid = ({ }: DataGridProps) => { const { columns, columnLayout } = columnInfo; const gridRef = useRef(null); + const columnsRef = useRef(columns); const rowDataRef = useRef[]>(rowData); const cellStyleCallbackRef = useRef(cellStyleCallback); + columnsRef.current = columns; rowDataRef.current = rowData; cellStyleCallbackRef.current = cellStyleCallback; @@ -155,6 +189,66 @@ const CompactDataGrid = ({ }); gridView.header.height = 60; + if (columns.some((c) => Boolean(resolveColumnTooltip(c)))) { + (gridView.header as { showTooltip?: boolean }).showTooltip = true; + } + columns.forEach((column) => { + ( + gridView as { + setColumnProperty?: ( + col: string, + key: string, + value: unknown, + ) => void; + } + ).setColumnProperty?.( + column.name, + "header", + realGridHeaderWithTooltip({ + ...column.header, + tooltip: resolveColumnTooltip(column), + }), + ); + }); + ( + gridView as { + onShowHeaderTooltip?: ( + grid: unknown, + column: { + name?: string; + fieldName?: string; + header?: { tooltip?: string }; + }, + value: string, + layout?: { + name?: string; + fieldName?: string; + column?: string | { name?: string; fieldName?: string }; + }, + isEllipsis?: boolean, + ) => string | undefined; + } + ).onShowHeaderTooltip = (_grid, headerColumn, _value, layout) => { + const currentColumns = columnsRef.current; + const directTip = headerColumn?.header?.tooltip?.trim(); + if (directTip) return directTip; + + const layoutColumn = + typeof layout?.column === "string" + ? layout.column + : layout?.column?.name ?? layout?.column?.fieldName; + + const col = currentColumns.find( + (c) => + c.name === headerColumn?.name || + c.fieldName === headerColumn?.fieldName || + c.name === layout?.name || + c.fieldName === layout?.fieldName || + c.name === layoutColumn || + c.fieldName === layoutColumn, + ); + return col ? resolveColumnTooltip(col) : undefined; + }; // 헤더 체크박스 표시 여부 설정 gridView.setOptions({ @@ -235,7 +329,10 @@ const CompactDataGrid = ({ const colInfo: RealGridColumnInfo = { name: col.name, fieldName: col.fieldName, - header: col.header, + header: realGridHeaderWithTooltip({ + ...col.header, + tooltip: resolveColumnTooltip(col), + }), editable: col.editable, valueType: col.valueType, dataFormat: col.dataFormat, @@ -427,7 +524,10 @@ const CompactDataGrid = ({ fieldName={column.fieldName} valueType={column.valueType ? column.valueType : ValueType.TEXT} {...(column.valueType === ValueType.NUMBER && { numberFormat: column.dataFormat ? column.dataFormat : '#,##0.000' })} - header={column.header} + header={realGridHeaderWithTooltip({ + ...column.header, + tooltip: resolveColumnTooltip(column), + })} editable={column.editable} {...(column.styleName != null ? { styleName: column.styleName } diff --git a/apps/st/src/components/module/DataGrid.tsx b/apps/st/src/components/module/DataGrid.tsx index acadc607a..0ae40300a 100644 --- a/apps/st/src/components/module/DataGrid.tsx +++ b/apps/st/src/components/module/DataGrid.tsx @@ -81,6 +81,42 @@ function createNumberComparer( }; } +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * RealGrid 헤더 툴팁: `grid.header.showTooltip`이 비어 있으면 `column.header.showTooltip === true`인 컬럼만 표시됨. + * `tooltip`이 있는 헤더는 info-circle 아이콘이 함께 보이도록 template도 자동 구성한다. + */ +function realGridHeaderWithTooltip( + header: T, +): T { + const tip = header.tooltip?.trim(); + if (!tip) return header; + return { + ...header, + tooltip: tip, + showTooltip: header.showTooltip ?? true, + template: + header.template ?? + `${escapeHtml(header.text)}`, + }; +} + +function resolveColumnTooltip(column: { + tooltip?: string; + header: { text: string; tooltip?: string; showTooltip?: boolean; template?: string }; +}): string | undefined { + const tip = column.tooltip?.trim() || column.header.tooltip?.trim(); + return tip && tip.length > 0 ? tip : undefined; +} + /** 정렬 필드 값을 숫자로 파싱 (쉼표 제거) */ function toNum(v: unknown): number { if (v == null) return NaN; @@ -342,6 +378,7 @@ const DataGrid = ({ cellTooltipCallback, onCellEdited, onRowClick, + onDataCellClick, onItemChecked, onItemAllChecked, onScrollToBottom, @@ -354,6 +391,7 @@ const DataGrid = ({ const { columns, columnLayout } = columnInfo; const gridRef = useRef(null); const fileInputRef = useRef(null); + const columnsRef = useRef(columns); const sortDirRef = useRef>({}); const originalRowsRef = useRef< Record[]> @@ -366,12 +404,14 @@ const DataGrid = ({ cellTooltipCallback, ); const onCellEditedRef = useRef(onCellEdited); + const onDataCellClickRef = useRef(onDataCellClick); const onScrollToBottomRef = useRef<(() => void) | undefined>( onScrollToBottom, ); const onItemCheckedRef = useRef(onItemChecked); const onItemAllCheckedRef = useRef(onItemAllChecked); + columnsRef.current = columns; rowDataRef.current = rowData; cellStyleCallbackRef.current = cellStyleCallback; cellTooltipCallbackRef.current = cellTooltipCallback; @@ -379,6 +419,7 @@ const DataGrid = ({ onItemCheckedRef.current = onItemChecked; onItemAllCheckedRef.current = onItemAllChecked; onCellEditedRef.current = onCellEdited; + onDataCellClickRef.current = onDataCellClick; // highlightedRowId가 변경될 때 해당 행으로 스크롤 및 선택 useEffect(() => { @@ -813,6 +854,66 @@ const DataGrid = ({ GridFitStyle.EVEN; gridView.displayOptions.rowHeight = 30; gridView.header.height = 60; + if (columns.some((c) => Boolean(resolveColumnTooltip(c)))) { + (gridView.header as { showTooltip?: boolean }).showTooltip = true; + } + columns.forEach((column) => { + ( + gridView as { + setColumnProperty?: ( + col: string, + key: string, + value: unknown, + ) => void; + } + ).setColumnProperty?.( + column.name, + "header", + realGridHeaderWithTooltip({ + ...column.header, + tooltip: resolveColumnTooltip(column), + }), + ); + }); + ( + gridView as { + onShowHeaderTooltip?: ( + grid: unknown, + column: { + name?: string; + fieldName?: string; + header?: { tooltip?: string }; + }, + value: string, + layout?: { + name?: string; + fieldName?: string; + column?: string | { name?: string; fieldName?: string }; + }, + isEllipsis?: boolean, + ) => string | undefined; + } + ).onShowHeaderTooltip = (_grid, headerColumn, _value, layout) => { + const currentColumns = columnsRef.current; + const directTip = headerColumn?.header?.tooltip?.trim(); + if (directTip) return directTip; + + const layoutColumn = + typeof layout?.column === "string" + ? layout.column + : layout?.column?.name ?? layout?.column?.fieldName; + + const col = currentColumns.find( + (c) => + c.name === headerColumn?.name || + c.fieldName === headerColumn?.fieldName || + c.name === layout?.name || + c.fieldName === layout?.fieldName || + c.name === layoutColumn || + c.fieldName === layoutColumn, + ); + return col ? resolveColumnTooltip(col) : undefined; + }; // 헤더 체크박스 표시 여부 설정 gridView.setOptions({ @@ -820,9 +921,17 @@ const DataGrid = ({ rowIndicator: { visible: false }, }); - // footer가 있는 컬럼이 있으면 footer 높이 설정 - const hasFooter = columns.some((col) => col.footer); - if (hasFooter) { + const applyFooterSettings = ( + targetGridView: typeof gridView, + ) => { + const hasFooter = columns.some((col) => col.footer); + if (!hasFooter) return; + + targetGridView.setOptions({ + summaryMode: "aggregate", + footer: { visible: true }, + }); + // footer가 배열이거나 multiRow가 true인 footer가 있으면 멀티푸터 설정 (여러 행) const hasArrayFooter = columns.some( (col) => col.footer && Array.isArray(col.footer), @@ -837,23 +946,24 @@ const DataGrid = ({ ); }); if (hasArrayFooter || hasMultiRowFooter) { - // footer 배열의 길이를 확인하여 행 수 결정 const arrayFooterLengths = columns .filter((col) => col.footer && Array.isArray(col.footer)) .map((col) => (col.footer as unknown[]).length); const maxFooterLength = arrayFooterLengths.length > 0 ? Math.max(...arrayFooterLengths) - : 2; // 기본값 2 + : 2; const footerHeights = Array.from( { length: maxFooterLength }, () => ({ height: 40 }), ); - gridView.setFooters?.(footerHeights); + targetGridView.setFooters?.(footerHeights); } else { - gridView.footer.height = 40; + targetGridView.footer.height = 40; } - } + }; + + applyFooterSettings(gridView); // 고정 컬럼 설정 if (fixedColumns !== undefined && fixedColumns > 0) { @@ -862,10 +972,6 @@ const DataGrid = ({ }); } - gridView.setOptions({ - summaryMode: "aggregate", - }); - // footerUserSpans가 있는 경우 setColumnLayout으로 설정 const checkFooterUserSpans = (layout: unknown[]): boolean => { return layout.some((item) => { @@ -888,7 +994,9 @@ const DataGrid = ({ }); }; - if (checkFooterUserSpans(columnLayout)) { + const hasFooterUserSpans = checkFooterUserSpans(columnLayout); + + if (hasFooterUserSpans) { // setColumnLayout은 그리드가 완전히 렌더링된 후 호출해야 함 setTimeout(() => { if (gridRef.current?.gridView && columnLayout) { @@ -917,7 +1025,7 @@ const DataGrid = ({ const colInfo: RealGridColumnInfo = { name: col.name, fieldName: col.fieldName, - header: col.header, + header: realGridHeaderWithTooltip(col.header), editable: col.editable, valueType: col.valueType, dataFormat: col.dataFormat, @@ -951,6 +1059,10 @@ const DataGrid = ({ (currentGridView as GridViewWithSetColumns).setColumns?.( columnsWithFooter, ); + applyFooterSettings(currentGridView); + if (hasFooterUserSpans) { + currentGridView.setColumnLayout?.(columnLayout); + } // setColumns 호출 후 footer 배열의 valueCallback을 다시 설정 setTimeout(() => { @@ -991,6 +1103,11 @@ const DataGrid = ({ ); } }); + applyFooterSettings(currentGridView); + if (hasFooterUserSpans) { + currentGridView.setColumnLayout?.(columnLayout); + } + currentGridView.refresh?.(); }, 50); } catch (e) { console.warn("Failed to set columns with footer array:", e); @@ -1066,7 +1183,11 @@ const DataGrid = ({ // 행 클릭 이벤트 + 검증 오류 셀 클릭 시 showMessage (공식 문서 방식) useEffect(() => { const gridView = gridRef.current?.gridView; - if (!gridView || (!onRowClick && !cellTooltipCallback)) return; + if ( + !gridView || + (!onRowClick && !cellTooltipCallback && !onDataCellClick) + ) + return; gridView.onCellClicked = (grid, clickData) => { if ( @@ -1085,10 +1206,10 @@ const DataGrid = ({ }; } ).getCurrent?.(); + const fieldName = + cellIndex?.fieldName ?? cellIndex?.column ?? ""; // cellTooltipCallback 있으면 해당 셀 메시지로 showMessage (공식 문서: TooltipOptions + CellIndex) if (cellTooltipCallbackRef.current && row && cellIndex) { - const fieldName = - cellIndex.fieldName ?? cellIndex.column ?? ""; const msg = cellTooltipCallbackRef.current({ row: row as Record, fieldName, @@ -1118,6 +1239,13 @@ const DataGrid = ({ ); } } + if (onDataCellClickRef.current && row !== undefined) { + onDataCellClickRef.current({ + row: (row ?? {}) as Record, + fieldName, + itemIndex: clickData.itemIndex, + }); + } if (onRowClick) onRowClick((row ?? {}) as Record); } catch (error) { @@ -1128,7 +1256,7 @@ const DataGrid = ({ return () => { if (gridView.onCellClicked) gridView.onCellClicked = undefined; }; - }, [onRowClick, cellTooltipCallback, columns]); + }, [onRowClick, onDataCellClick, cellTooltipCallback, columns]); useEffect(() => { const gridView = gridRef.current?.gridView; @@ -1334,6 +1462,48 @@ const DataGrid = ({ setTimeout(() => { const gv = gridRef.current?.gridView; if (!gv?.columnByName) return; + // 공식 문서: 헤더 툴팁 = header.showTooltip + onShowHeaderTooltip + ( + gv as { + onShowHeaderTooltip?: ( + grid: unknown, + column: { + name?: string; + fieldName?: string; + header?: { + tooltip?: string; + }; + }, + value: string, + layout?: { + name?: string; + fieldName?: string; + column?: string | { name?: string; fieldName?: string }; + }, + isEllipsis?: boolean, + ) => string | undefined; + } + ).onShowHeaderTooltip = (_grid, headerColumn, _value, layout) => { + const currentColumns = columnsRef.current; + const directTip = headerColumn?.header?.tooltip?.trim(); + if (directTip) return directTip; + + const layoutColumn = + typeof layout?.column === "string" + ? layout.column + : layout?.column?.name ?? layout?.column?.fieldName; + + const col = currentColumns.find( + (c) => + c.name === headerColumn?.name || + c.fieldName === headerColumn?.fieldName || + c.name === layout?.name || + c.fieldName === layout?.fieldName || + c.name === layoutColumn || + c.fieldName === layoutColumn, + ); + return col ? resolveColumnTooltip(col) : undefined; + }; // 공식 문서: 데이터 영역 툴팁 = renderer.showTooltip + onShowTooltip (displayOptions는 Ellipsis용이라 사용 안 함) if (cellTooltipCallbackRef.current) { ( @@ -1387,6 +1557,22 @@ const DataGrid = ({ renderer?: { showTooltip?: boolean }; } | null; if (gridCol) { + ( + gv as { + setColumnProperty?: ( + col: string, + key: string, + value: unknown, + ) => void; + } + ).setColumnProperty?.( + column.name, + "header", + realGridHeaderWithTooltip({ + ...column.header, + tooltip: resolveColumnTooltip(column), + }), + ); if (cellTooltipCallbackRef.current) { ( gv as { @@ -1456,7 +1642,10 @@ const DataGrid = ({ ? column.dataFormat : "#,##0.000", })} - header={column.header} + header={realGridHeaderWithTooltip({ + ...column.header, + tooltip: resolveColumnTooltip(column), + })} editable={column.editable} {...(column.width != null && { width: column.width, diff --git a/apps/st/src/components/module/ExceptionProcessingHistoryDialog.tsx b/apps/st/src/components/module/ExceptionProcessingHistoryDialog.tsx new file mode 100644 index 000000000..04cd06dc9 --- /dev/null +++ b/apps/st/src/components/module/ExceptionProcessingHistoryDialog.tsx @@ -0,0 +1,617 @@ +"use client"; + +import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { RealGridReact } from "realgrid-react"; + +import { getUserInfo } from "../../../../../packages/common/components/src/api"; +import { useAlertStore } from "@/shared/lib"; +import { statisticsApi } from "@/entities/statistics/api/statistics-service"; +import type { + ReviewInputSaveRequest, + VerificationResultListRequest, + VerificationResultReportItem, +} from "@/entities/statistics/model/statistics-interface"; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Label, + LoadingWithAnimation, + Tabs, + TabsContent, + TabsList, + TabsTrigger, + Textarea, +} from "@common/components"; + +import DataGrid from "@/components/module/DataGrid"; +import { VerificationResultHistoryPagination } from "@/components/module/VerificationResultHistoryPagination"; +import type { CellStyleCallbackParams, ColumnInfo } from "@/components/type/DataGrid.type"; + +/** 통계 입력 현황 > 행 클릭 시 예외처리(prcsYn=3) 내역만 조회 */ +export interface ExceptionProcessingHistoryQueryContext { + sewerBzmnCd: string; + ctpvNm: string; + sggNm: string; + statsYr: string; + statsCd: string; +} + +export interface ExceptionProcessingHistoryDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + queryContext?: ExceptionProcessingHistoryQueryContext | null; + emptyMessage?: string; + /** 반려 API 성공 후 (다이얼로그 내 목록 재조회 이후) 상위 화면 등에서 호출 */ + onRejectSuccess?: () => void | Promise; +} + +type ExceptionHistoryGridRow = Record; +type ExceptionHistoryTabKey = "NV" | "IMR" | "HB"; + +const exceptionHistoryTabs: { value: ExceptionHistoryTabKey; label: string }[] = [ + { value: "NV", label: "일반검증 내역" }, + { value: "IMR", label: "I-MR 검증내역" }, + { value: "HB", label: "HB 검증내역" }, +]; + +const TITLE = "예외처리 내역 조회"; +/** 예외처리 건만 */ +const PRCS_YN_LIST = "2"; + +export type ExceptionRejectSubmitItem = { + vrfcSn: number; + statsYr: string; + sewerBzmnCd: string; + statsCd: string; + mdfrId: string; + rjctOpnnCn?: string; +}; + +function prcsYnCodeToNumber(raw: string | number | undefined): number | undefined { + if (raw === undefined || raw === null) return undefined; + if (typeof raw === "number" && Number.isFinite(raw)) return raw; + const s = String(raw).trim(); + if (s === "") return undefined; + const n = Number.parseInt(s, 10); + return Number.isFinite(n) ? n : undefined; +} + +function exceptionPrcsYnNmCellStyle({ fieldName, row }: CellStyleCallbackParams): string | undefined { + if (fieldName !== "prcsYnNm") return undefined; + const code = prcsYnCodeToNumber(row.prcsYn); + if (code === 0 || code === 4) return "rg-cell-vrf-prcs-bad"; + return "rg-cell-vrf-prcs-ok"; +} + +const exceptionHistoryColumnInfo: ColumnInfo = { + columns: [ + { name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, editable: false }, + { name: "sggNm", fieldName: "sggNm", header: { text: "시군구" }, editable: false }, + { name: "vrfcTrgtNm", fieldName: "vrfcTrgtNm", header: { text: "검증대상" }, editable: false }, + { name: "rsnExpln", fieldName: "rsnExpln", header: { text: "사유" }, editable: false }, + { name: "nowVlCn", fieldName: "nowVlCn", header: { text: "입력값" }, editable: false }, + { name: "compVlCn", fieldName: "compVlCn", header: { text: "기준값" }, editable: false }, + { name: "prcsYnNm", fieldName: "prcsYnNm", header: { text: "처리여부" }, editable: false }, + { name: "pbcpAprvYn", fieldName: "pbcpAprvYn", header: { text: "공단승인여부" }, editable: false }, + { name: "regDt", fieldName: "regDt", header: { text: "등록일시" }, editable: false }, + { name: "mdfcnDt", fieldName: "mdfcnDt", header: { text: "갱신일시" }, editable: false }, + { + name: "individualReject", + fieldName: "individualReject", + header: { text: "개별 반려" }, + editable: false, + width: 104, + styleName: "rg-cell-reject-mini-btn", + }, + { name: "prcsYn", fieldName: "prcsYn", header: { text: "검증결과상태코드" }, editable: false, visible: false }, + { name: "vrfcSn", fieldName: "vrfcSn", header: { text: "vrfcSn" }, editable: false, visible: false }, + ], + columnLayout: [ + "ctpvNm", + "sggNm", + "vrfcTrgtNm", + "rsnExpln", + "nowVlCn", + "compVlCn", + "prcsYnNm", + "pbcpAprvYn", + "regDt", + "mdfcnDt", + "individualReject", + "prcsYn", + "vrfcSn", + ], +}; + +export function ExceptionProcessingHistoryDialog({ + open, + onOpenChange, + queryContext, + emptyMessage = "표시할 예외처리 내역이 없습니다.", + onRejectSuccess, +}: ExceptionProcessingHistoryDialogProps) { + const { setMessage: alert } = useAlertStore((state) => state); + const nvGridRef = useRef(null); + const imrGridRef = useRef(null); + const hbGridRef = useRef(null); + const [rejectReasonDialogOpen, setRejectReasonDialogOpen] = useState(false); + const [pendingRejectVrfcSns, setPendingRejectVrfcSns] = useState([]); + const [rejectReasonText, setRejectReasonText] = useState(""); + const [rejectSubmitting, setRejectSubmitting] = useState(false); + + const [activeTab, setActiveTab] = useState("NV"); + const [normalEntries, setNormalEntries] = useState([]); + const [imrEntries, setImrEntries] = useState([]); + const [hbEntries, setHbEntries] = useState([]); + const [normalPageIndex, setNormalPageIndex] = useState(0); + const [imrPageIndex, setImrPageIndex] = useState(0); + const [hbPageIndex, setHbPageIndex] = useState(0); + const [normalPageCount, setNormalPageCount] = useState(1); + const [imrPageCount, setImrPageCount] = useState(1); + const [hbPageCount, setHbPageCount] = useState(1); + const [normalLoading, setNormalLoading] = useState(false); + const [imrLoading, setImrLoading] = useState(false); + const [hbLoading, setHbLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const resetState = useCallback(() => { + setActiveTab("NV"); + setNormalEntries([]); + setImrEntries([]); + setHbEntries([]); + setNormalPageIndex(0); + setImrPageIndex(0); + setHbPageIndex(0); + setNormalPageCount(1); + setImrPageCount(1); + setHbPageCount(1); + setNormalLoading(false); + setImrLoading(false); + setHbLoading(false); + setErrorMessage(null); + setRejectReasonDialogOpen(false); + setPendingRejectVrfcSns([]); + setRejectReasonText(""); + setRejectSubmitting(false); + }, []); + + const fetchPage = useCallback( + async (vrfcMthdCd: ExceptionHistoryTabKey, pageIndex: number) => { + if (!queryContext) return null; + const param: VerificationResultListRequest = { + sewerBzmnCd: queryContext.sewerBzmnCd, + ctpvNm: queryContext.ctpvNm, + sggNm: queryContext.sggNm, + statsYr: queryContext.statsYr, + statsCd: queryContext.statsCd, + pbcpAprvYn: "N", + vrfcMthdCd, + prcsYnList: PRCS_YN_LIST, + page: pageIndex + 1, + size: 30, + }; + return statisticsApi.getVerificationResultList(param); + }, + [queryContext], + ); + + const loadInitial = useCallback(async () => { + if (!queryContext) { + setErrorMessage("조회 조건이 없어 예외처리 내역을 불러올 수 없습니다."); + return; + } + setErrorMessage(null); + setNormalLoading(true); + setImrLoading(true); + setHbLoading(true); + setNormalPageIndex(0); + setImrPageIndex(0); + setHbPageIndex(0); + try { + const [nvRes, imrRes, hbRes] = await Promise.all([ + fetchPage("NV", 0), + fetchPage("IMR", 0), + fetchPage("HB", 0), + ]); + setNormalEntries(nvRes?.data?.verifyResultList ?? []); + setImrEntries(imrRes?.data?.verifyResultList ?? []); + setHbEntries(hbRes?.data?.verifyResultList ?? []); + setNormalPageCount(nvRes?.data?.pageInfo?.totalPages ?? 1); + setImrPageCount(imrRes?.data?.pageInfo?.totalPages ?? 1); + setHbPageCount(hbRes?.data?.pageInfo?.totalPages ?? 1); + } catch { + setNormalEntries([]); + setImrEntries([]); + setHbEntries([]); + setNormalPageCount(1); + setImrPageCount(1); + setHbPageCount(1); + setErrorMessage("예외처리 내역 조회 중 오류가 발생했습니다."); + } finally { + setNormalLoading(false); + setImrLoading(false); + setHbLoading(false); + } + }, [fetchPage, queryContext]); + + const handleNormalPageChange = useCallback( + async (pageIndex: number) => { + if (pageIndex === normalPageIndex || !queryContext) return; + setErrorMessage(null); + setNormalLoading(true); + try { + const response = await fetchPage("NV", pageIndex); + setNormalEntries(response?.data?.verifyResultList ?? []); + setNormalPageCount(response?.data?.pageInfo?.totalPages ?? 1); + setNormalPageIndex(pageIndex); + } catch { + setErrorMessage("일반검증 내역 조회 중 오류가 발생했습니다."); + } finally { + setNormalLoading(false); + } + }, + [fetchPage, normalPageIndex, queryContext], + ); + + const handleImrPageChange = useCallback( + async (pageIndex: number) => { + if (pageIndex === imrPageIndex || !queryContext) return; + setErrorMessage(null); + setImrLoading(true); + try { + const response = await fetchPage("IMR", pageIndex); + setImrEntries(response?.data?.verifyResultList ?? []); + setImrPageCount(response?.data?.pageInfo?.totalPages ?? 1); + setImrPageIndex(pageIndex); + } catch { + setErrorMessage("I-MR 검증내역 조회 중 오류가 발생했습니다."); + } finally { + setImrLoading(false); + } + }, + [fetchPage, imrPageIndex, queryContext], + ); + + const handleHbPageChange = useCallback( + async (pageIndex: number) => { + if (pageIndex === hbPageIndex || !queryContext) return; + setErrorMessage(null); + setHbLoading(true); + try { + const response = await fetchPage("HB", pageIndex); + setHbEntries(response?.data?.verifyResultList ?? []); + setHbPageCount(response?.data?.pageInfo?.totalPages ?? 1); + setHbPageIndex(pageIndex); + } catch { + setErrorMessage("HB 검증내역 조회 중 오류가 발생했습니다."); + } finally { + setHbLoading(false); + } + }, + [fetchPage, hbPageIndex, queryContext], + ); + + useEffect(() => { + if (!open) { + resetState(); + return; + } + void loadInitial(); + }, [loadInitial, open, resetState]); + + const mapRows = (entries: VerificationResultReportItem[]): ExceptionHistoryGridRow[] => + entries.map((item) => ({ + ctpvNm: item.ctpvNm, + sggNm: item.sggNm, + vrfcTrgtNm: item.vrfcTrgtNm, + rsnExpln: item.rsnExpln, + nowVlCn: item.nowVlCn ?? "-", + compVlCn: item.compVlCn ?? "-", + prcsYnNm: item.prcsYnNm ?? "-", + prcsYn: item.prcsYn ?? "", + pbcpAprvYn: item.pbcpAprvYn, + regDt: (item.regDt ?? "").slice(0, 16), + mdfcnDt: (item.mdfcnDt ?? "").slice(0, 16), + individualReject: "개별 반려", + vrfcSn: item.vrfcSn, + })); + + const normalRows = useMemo(() => mapRows(normalEntries), [normalEntries]); + const imrRows = useMemo(() => mapRows(imrEntries), [imrEntries]); + const hbRows = useMemo(() => mapRows(hbEntries), [hbEntries]); + + const noopSetRows = ((_: SetStateAction[]>) => {}) as Dispatch< + SetStateAction[]> + >; + + const handleOpenRejectReasonDialog = useCallback(() => { + if (!queryContext) { + alert("조회 조건이 없습니다."); + return; + } + const rows = + activeTab === "NV" ? normalRows : activeTab === "IMR" ? imrRows : hbRows; + const gridRef = + activeTab === "NV" ? nvGridRef : activeTab === "IMR" ? imrGridRef : hbGridRef; + const gridView = gridRef.current?.gridView; + if (!gridView) { + alert("데이터가 존재하지 않습니다."); + return; + } + const checkedIndices = (gridView.getCheckedRows?.(true, false, true) ?? []) as number[]; + if (checkedIndices.length === 0) { + alert("반려할 항목을 체크해주세요."); + return; + } + const vrfcSns: number[] = []; + for (const itemIndex of checkedIndices) { + const row = rows[itemIndex]; + if (!row) continue; + const vrfcSn = Number(row.vrfcSn); + if (!Number.isFinite(vrfcSn)) continue; + vrfcSns.push(vrfcSn); + } + if (vrfcSns.length === 0) { + alert("유효한 검증번호가 있는 행을 선택해주세요."); + return; + } + setPendingRejectVrfcSns(vrfcSns); + setRejectReasonText(""); + setRejectSubmitting(false); + setRejectReasonDialogOpen(true); + }, [activeTab, alert, hbRows, imrRows, normalRows, queryContext]); + + const resetRejectReasonDialog = useCallback(() => { + setRejectReasonDialogOpen(false); + setPendingRejectVrfcSns([]); + setRejectReasonText(""); + setRejectSubmitting(false); + }, []); + + const handleRejectReasonDialogOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) resetRejectReasonDialog(); + }, + [resetRejectReasonDialog], + ); + + const handleConfirmReject = useCallback(async () => { + if (!queryContext || pendingRejectVrfcSns.length === 0) { + alert("반려할 항목이 없습니다. 다시 선택해주세요."); + resetRejectReasonDialog(); + return; + } + let userInfo; + try { + userInfo = await getUserInfo(); + } catch { + alert("사용자 정보를 불러오지 못했습니다."); + return; + } + const mdfrId = userInfo.userUnqId; + const trimmedReason = rejectReasonText.trim(); + + const items: ReviewInputSaveRequest[] = pendingRejectVrfcSns.map((vrfcSn) => ({ + type: "INPUT", + statsYr: queryContext.statsYr, + sewerBzmnCd: queryContext.sewerBzmnCd, + statsCd: queryContext.statsCd, + mdfrId, + vrfcSn: String(vrfcSn), + ...(trimmedReason ? { rjctOpnnCn: trimmedReason } : {}), + })); + + setRejectSubmitting(true); + try { + const response = await statisticsApi.saveInputReturn(items); + if (response.resultCode.toLowerCase() !== "success") { + alert(response.message ?? "반려 처리 중 오류가 발생했습니다."); + return; + } + alert("반려 처리되었습니다."); + resetRejectReasonDialog(); + await Promise.all([ + loadInitial(), + onRejectSuccess?.() ?? Promise.resolve(), + ]); + } catch { + alert("반려 처리 중 오류가 발생했습니다."); + } finally { + setRejectSubmitting(false); + } + }, [ + alert, + loadInitial, + onRejectSuccess, + pendingRejectVrfcSns, + queryContext, + rejectReasonText, + resetRejectReasonDialog, + ]); + + const handleGridRowClick = useCallback( + (rowData: Record) => { + const gridRef = + activeTab === "NV" ? nvGridRef : activeTab === "IMR" ? imrGridRef : hbGridRef; + const current = gridRef.current?.gridView?.getCurrent?.() as + | { fieldName?: string; column?: string } + | undefined; + const fieldName = current?.fieldName ?? current?.column; + if (fieldName !== "individualReject") return; + const vrfcSn = Number(rowData.vrfcSn); + if (!Number.isFinite(vrfcSn)) { + alert("유효한 검증번호가 있는 행을 선택해주세요."); + return; + } + setPendingRejectVrfcSns([vrfcSn]); + setRejectReasonText(""); + setRejectSubmitting(false); + setRejectReasonDialogOpen(true); + }, + [activeTab, alert], + ); + + const renderGrid = ( + rows: ExceptionHistoryGridRow[], + pageIndex: number, + pageCount: number, + onPageIndexChange: (page: number) => void, + loading: boolean, + gridRef: MutableRefObject, + ) => { + if (!loading && rows.length === 0) { + return

{emptyMessage}

; + } + + return ( +
+ + + {loading && ( +
+
+ +
+
+ )} +
+ ); + }; + + return ( + <> + + + + {`${TITLE} - ${queryContext?.statsYr} ${queryContext?.ctpvNm} ${queryContext?.sggNm}`} + +
+ {errorMessage && ( +

+ {errorMessage} +

+ )} + setActiveTab(value as ExceptionHistoryTabKey)} + className="w-full" + variant="default" + > + + {exceptionHistoryTabs.map((tab) => ( + + {tab.label} + + ))} + + + {renderGrid( + normalRows, + normalPageIndex, + normalPageCount, + handleNormalPageChange, + normalLoading, + nvGridRef, + )} + + + {renderGrid( + imrRows, + imrPageIndex, + imrPageCount, + handleImrPageChange, + imrLoading, + imrGridRef, + )} + + + {renderGrid( + hbRows, + hbPageIndex, + hbPageCount, + handleHbPageChange, + hbLoading, + hbGridRef, + )} + + +
+ + + + + + +
+
+ + + + + 예외처리 반려 + +
+ +