@@ -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 (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/apps/st/src/components/module/VerificationResultDialog.tsx b/apps/st/src/components/module/VerificationResultDialog.tsx
index b72132bbc..acd460f87 100644
--- a/apps/st/src/components/module/VerificationResultDialog.tsx
+++ b/apps/st/src/components/module/VerificationResultDialog.tsx
@@ -109,6 +109,8 @@ export interface VerificationResultDialogProps {
queryContext?: VerificationResultDialogQueryContext | null;
title?: string;
emptyMessage?: string;
+ /** 예외 제출 API 성공 후 부모(입력 화면)에서 메인 데이터를 다시 조회할 때 사용 */
+ onExceptionSubmitted?: () => void | Promise;
}
export function VerificationResultDialog({
@@ -117,6 +119,7 @@ export function VerificationResultDialog({
queryContext,
title = "검증 결과",
emptyMessage = "표시할 검증 결과가 없습니다.",
+ onExceptionSubmitted,
}: VerificationResultDialogProps) {
/** 부모가 매 렌더마다 새 객체를 넘기면 queryContext 참조만 바뀌어 loadInitial이 재실행되므로 필드 값 기준으로 안정화 */
const stableQueryContext = useMemo((): VerificationResultDialogQueryContext | null => {
@@ -385,6 +388,11 @@ export function VerificationResultDialog({
});
if (response.status === "success") {
alert("예외 제출 처리되었습니다.");
+ try {
+ await onExceptionSubmitted?.();
+ } catch {
+ alert("예외 제출은 완료되었으나 화면 데이터를 다시 불러오지 못했습니다. 조회로 새로고침 해주세요.");
+ }
onOpenChange(false);
} else {
alert(response.message ?? "예외 제출 처리 중 오류가 발생했습니다.");
@@ -396,9 +404,10 @@ export function VerificationResultDialog({
alert,
imrCheckedIndices,
imrRows,
- loadInitial,
normalCheckedIndices,
normalRows,
+ onExceptionSubmitted,
+ onOpenChange,
stableQueryContext,
]);
diff --git a/apps/st/src/components/module/VerificationResultHistoryDialog.tsx b/apps/st/src/components/module/VerificationResultHistoryDialog.tsx
index 1026fd82d..342a8c414 100644
--- a/apps/st/src/components/module/VerificationResultHistoryDialog.tsx
+++ b/apps/st/src/components/module/VerificationResultHistoryDialog.tsx
@@ -13,6 +13,10 @@ import {
DialogHeader,
DialogTitle,
LoadingWithAnimation,
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
} from "@common/components";
import DataGrid from "@/components/module/DataGrid";
@@ -20,6 +24,13 @@ import { VerificationResultHistoryPagination } from "@/components/module/Verific
import type { CellStyleCallbackParams, ColumnInfo } from "@/components/type/DataGrid.type";
type VerificationResultGridRow = Record;
+type VerificationHistoryTabKey = "NV" | "IMR" | "HB";
+
+const verificationHistoryTabs: { value: VerificationHistoryTabKey; label: string }[] = [
+ { value: "NV", label: "일반검증 내역" },
+ { value: "IMR", label: "I-MR 검증내역" },
+ { value: "HB", label: "HB 검증내역" },
+];
function prcsYnCodeToNumber(raw: string | number | undefined): number | undefined {
if (raw === undefined || raw === null) return undefined;
@@ -34,7 +45,7 @@ function prcsYnCodeToNumber(raw: string | number | undefined): number | undefine
function verificationPrcsYnNmCellStyle({ 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";
+ if (code === 0 || code === 3) return "rg-cell-vrf-prcs-bad";
return "rg-cell-vrf-prcs-ok";
}
@@ -76,30 +87,40 @@ export function VerificationResultHistoryDialog({
title = "검증 결과 내역",
emptyMessage = "표시할 검증 결과가 없습니다.",
}: VerificationResultHistoryDialogProps) {
+ 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);
}, []);
const fetchVerificationResultPage = useCallback(
- async (vrfcMthdCd: "NV" | "IMR", pageIndex: number) => {
+ async (vrfcMthdCd: VerificationHistoryTabKey, pageIndex: number) => {
if (!queryContext) return null;
const param: VerificationResultListRequest = {
sewerBzmnCd: queryContext.sewerBzmnCd,
@@ -126,26 +147,34 @@ export function VerificationResultHistoryDialog({
setErrorMessage(null);
setNormalLoading(true);
setImrLoading(true);
+ setHbLoading(true);
setNormalPageIndex(0);
setImrPageIndex(0);
+ setHbPageIndex(0);
try {
- const [nvRes, imrRes] = await Promise.all([
+ const [nvRes, imrRes, hbRes] = await Promise.all([
fetchVerificationResultPage("NV", 0),
fetchVerificationResultPage("IMR", 0),
+ fetchVerificationResultPage("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);
}
}, [fetchVerificationResultPage, queryContext]);
@@ -187,6 +216,25 @@ export function VerificationResultHistoryDialog({
[fetchVerificationResultPage, imrPageIndex, queryContext],
);
+ const handleHbPageChange = useCallback(
+ async (pageIndex: number) => {
+ if (pageIndex === hbPageIndex || !queryContext) return;
+ setErrorMessage(null);
+ setHbLoading(true);
+ try {
+ const response = await fetchVerificationResultPage("HB", pageIndex);
+ setHbEntries(response?.data?.verifyResultList ?? []);
+ setHbPageCount(response?.data?.pageInfo?.totalPages ?? 1);
+ setHbPageIndex(pageIndex);
+ } catch {
+ setErrorMessage("HB 검증내역 조회 중 오류가 발생했습니다.");
+ } finally {
+ setHbLoading(false);
+ }
+ },
+ [fetchVerificationResultPage, hbPageIndex, queryContext],
+ );
+
useEffect(() => {
if (!open) {
resetState();
@@ -212,6 +260,7 @@ export function VerificationResultHistoryDialog({
const normalRows = useMemo(() => mapRows(normalEntries), [normalEntries]);
const imrRows = useMemo(() => mapRows(imrEntries), [imrEntries]);
+ const hbRows = useMemo(() => mapRows(hbEntries), [hbEntries]);
const noopSetRows = ((_: SetStateAction[]>) => {}) as Dispatch<
SetStateAction[]>
@@ -269,14 +318,24 @@ export function VerificationResultHistoryDialog({
{errorMessage}
)}
-
- 일반검증 내역
- {renderGrid(normalRows, normalPageIndex, normalPageCount, handleNormalPageChange, normalLoading)}
-
-
- I-MR 검증내역
- {renderGrid(imrRows, imrPageIndex, imrPageCount, handleImrPageChange, imrLoading)}
-
+ setActiveTab(value as VerificationHistoryTabKey)} className="w-full" variant="default">
+
+ {verificationHistoryTabs.map((tab) => (
+
+ {tab.label}
+
+ ))}
+
+
+ {renderGrid(normalRows, normalPageIndex, normalPageCount, handleNormalPageChange, normalLoading)}
+
+
+ {renderGrid(imrRows, imrPageIndex, imrPageCount, handleImrPageChange, imrLoading)}
+
+
+ {renderGrid(hbRows, hbPageIndex, hbPageCount, handleHbPageChange, hbLoading)}
+
+
diff --git a/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx b/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx
index f0334fee8..471547ec5 100644
--- a/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx
+++ b/apps/st/src/components/statistics/input/PublicSewageFacilityInp.tsx
@@ -1,4 +1,4 @@
-import { useRef, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { useConfirm } from '@frontend-opensource/use-react-hooks';
@@ -18,6 +18,7 @@ import {
type CheckInputPeriodItem,
getVerificationPeriodActionBlockedMessage,
isInputPeriodCurrent,
+ type SewerageSupplyRateVerificationResult,
} from '@/entities/statistics/model/input/statistics-sewage-supply-rate-input-interface';
import type { StatisticsInputSubmitInfoFormData } from '@/entities/statistics/model/statistics-input-submit-info-schema';
import type { cancelSubmitRequest, ImrStatusResponse, SewerageStatusInfo } from '@/entities/statistics/model/statistics-interface';
@@ -46,6 +47,10 @@ import { PublicSewageFactInpColumnList } from '@/components/util/column/input/Pu
import { toGridRows } from '@/components/util/grid/gridFunction';
import { useAdminStore } from '@/store/admin-store';
+type GridRowWithVerification = Record & {
+ verificationResult?: SewerageSupplyRateVerificationResult;
+};
+
type TabToolbarSnapshot = {
showSaveInToolbar: boolean;
showEnterableToolbarButtons: boolean;
@@ -131,6 +136,10 @@ type MainFacilityMappingSaveDraft = {
initialPrxyCtrtCn: string;
};
+const PUBLIC_SEWAGE_FACILITY_COLUMN_GUIDE_TAB_KEYS = Array.from(
+ new Set(PublicSewageFactInpColumnList.map((tab) => tab.tabKey).filter(Boolean)),
+);
+
// 하수도 통계 관리 > 06. 공공하수처리시설
const PublicSewageFacilityInp = () => {
const { setMessage: alert } = useAlertStore((state) => state);
@@ -149,6 +158,8 @@ const PublicSewageFacilityInp = () => {
const [lastSearchedValues, setLastSearchedValues] = useState(null);
const [tabStatusInfoMap, setTabStatusInfoMap] = useState