From ccfbe6ae3297874d31d07512f570d67444bd33d4 Mon Sep 17 00:00:00 2001 From: mind-one001 Date: Tue, 25 Aug 2026 16:43:07 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8C=9D=EC=97=85=20=EB=B3=91=ED=95=A9=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/sinkhole/SinkholePopupPage.tsx | 63 +- .../exmn/ui/ExmnPplUnitDetailTable.tsx | 379 +++++------ .../exmn/ui/ExmnSurveyDetailPopupPage.tsx | 586 +++++++++--------- .../exmn/ui/ExmnSurveyStandardDataTable.tsx | 71 +++ .../windowPopup/ui/PopupSearchPanel.tsx | 66 +- .../src/ui/standard_data_table/index.tsx | 43 +- 6 files changed, 681 insertions(+), 527 deletions(-) create mode 100644 apps/sn/src/features/exmn/ui/ExmnSurveyStandardDataTable.tsx diff --git a/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx b/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx index 3738f1e69..f98c15f9f 100644 --- a/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx @@ -13,7 +13,10 @@ import { useAlertStore } from "@/shared/lib"; import useStore from "@/store/useStore"; import LoadingOverlay from "@/components/ui/LoadingBar"; import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable"; -import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel"; +import PopupSearchPanel, { + PopupSearchActions, + PopupSearchField, +} from "@/features/windowPopup/ui/PopupSearchPanel"; import type { SelectDataType } from "@common/business_components"; import { buildSinkholeDefaultFilters, @@ -318,13 +321,10 @@ export default function SinkholePopupPage() { - - + - + - + - + - + - + -
+ -
+
sum + width, 0); return ( -
- - - {COLUMN_WIDTHS.map((width, index) => ( - - ))} - - - - - - - - - - - - - - - - - - - - - - - - - {rows.length > 0 ? ( - rows.map((row, index) => { - const pipeSpan = pipeSpans.get(row.pipeNo); - const unitKey = `${row.pipeNo}::${row.unitSeq}`; - const unitSpan = unitSpans.get(unitKey); - const showPipeCell = pipeSpan?.firstIndex === index; - const showUnitCells = unitSpan?.firstIndex === index; +
- 관로번호 - - 단위 -
- 구간 -
- 단위 -
- 길이 -
- (m) -
- 결함 -
- 위치 -
- (m) -
- 결함 - - 등급점수(점) - - 조사중단사유 - - 비고 -
- 종류 - - 코드 - - 등급 - - 발생 -
- 위치 -
- 침입수 -
- 가점 -
- 연속 -
- 결함 -
- 구조적 - - 운영적 -
+ + {COLUMN_WIDTHS.map((width, index) => ( + + ))} + + + + + + + + + + + + + + + + + + + + + + + + + {rows.length > 0 ? ( + rows.map((row, index) => { + const pipeSpan = pipeSpans.get(row.pipeNo); + const unitKey = `${row.pipeNo}::${row.unitSeq}`; + const unitSpan = unitSpans.get(unitKey); + const showPipeCell = pipeSpan?.firstIndex === index; + const showUnitCells = unitSpan?.firstIndex === index; - return ( - - {showPipeCell && ( - - )} - {showUnitCells && ( - <> - - - - )} - - - - - - - - - - - - - ); - }) - ) : ( - - - - )} - -
+ 관로번호 + + 단위 +
+ 구간 +
+ 단위 +
+ 길이 +
+ (m) +
+ 결함 +
+ 위치 +
+ (m) +
+ 결함 + + 등급점수(점) + + 조사중단사유 + + 비고 +
+ 종류 + + 코드 + + 등급 + + 발생 +
+ 위치 +
+ 침입수 +
+ 가점 +
+ 연속 +
+ 결함 +
+ 구조적 + + 운영적 +
- {row.pipeNo} - - {row.unitSeq} - - {row.unitLengthM} - - {formatOptionalValue(row.defectPositionM)} - - {row.defectName} - - {row.code} - - {row.severity ? SEVERITY_LABELS[row.severity] : "-"} - - {formatOptionalValue(row.occurrencePosition)} - - {formatOptionalValue(row.infiltrationBonus)} - - {row.continuous == null ? "-" : row.continuous ? "Y" : "N"} - - {formatOptionalValue(row.structuralScore)} - - {formatOptionalValue(row.operationalScore)} - - {formatOptionalValue(row.surveyStopReason)} - - {formatOptionalValue(row.remark)} -
- {emptyMessage} -
-
+ {showPipeCell && ( + + {row.pipeNo} + + )} + {showUnitCells && ( + <> + + {row.unitSeq} + + + {row.unitLengthM} + + + )} + + {formatOptionalValue(row.defectPositionM)} + + + {row.defectName} + + + {row.code} + + + {row.severity ? SEVERITY_LABELS[row.severity] : "-"} + + + {formatOptionalValue(row.occurrencePosition)} + + + {formatOptionalValue(row.infiltrationBonus)} + + + {row.continuous == null ? "-" : row.continuous ? "Y" : "N"} + + + {formatOptionalValue(row.structuralScore)} + + + {formatOptionalValue(row.operationalScore)} + + + {formatOptionalValue(row.surveyStopReason)} + + + {formatOptionalValue(row.remark)} + + + ); + }) + ) : ( + + + {emptyMessage} + + + )} + + ); } diff --git a/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx b/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx index a0851ceb4..cf13ff90e 100644 --- a/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx +++ b/apps/sn/src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx @@ -13,9 +13,9 @@ import { SelectItem, SelectTrigger, SelectValue, + Table, } from "@common/components"; import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; -import LoadingOverlay from "@/components/ui/LoadingBar"; import { getExmnFacilityDetails, @@ -40,6 +40,7 @@ import { } from "../model/exmn-detail-table"; import { ExmnPplUnitDetailTable } from "./ExmnPplUnitDetailTable"; +import { ExmnSurveyStandardDataTable } from "./ExmnSurveyStandardDataTable"; const SEVERITY_LABELS: Record = { L: "대", @@ -705,6 +706,9 @@ export function ExmnSurveyDetailPopupPage({ () => new Set(unitRows.map((row) => `${row.pipeNo}::${row.unitSeq}`)).size, [unitRows], ); + /** 관로 집계와 본단위구간 행을 같은 StandardDataTable 데이터 유무 계약으로 전달한다. */ + const standardTableRows: Array = + isUnitMode ? unitRows : rows; const popupTitle = isUnitMode ? "관로조사 본단위구간 상세현황" : defectType === "ALL" @@ -735,7 +739,6 @@ export function ExmnSurveyDetailPopupPage({ } bodyClassName="overflow-x-hidden" > - {notice && (
)} -
-
-

{tableTitle}

+ {isUnitMode ? ( <> @@ -898,10 +912,9 @@ export function ExmnSurveyDetailPopupPage({ )}
-
- -
- {isUnitMode ? ( + } + renderTable={() => + isUnitMode ? ( ) : ( -
- - - {fixedColumns.map((column) => ( - - ))} - {Array.from({ - length: + + {fixedColumns.map((column) => ( + + ))} + {Array.from({ + length: + (visibleDefectColumns.length + 1) * + EXMN_DETAIL_SEVERITIES.length, + }).map((_, index) => ( + + ))} + + + + {fixedColumns.map((column, columnIndex) => { + const sticky = columnIndex < STICKY_COLUMN_COUNT; + const columnLabel = column.label; + return ( + + ); + })} + - ))} - - - - {fixedColumns.map((column, columnIndex) => { - const sticky = columnIndex < STICKY_COLUMN_COUNT; - const columnLabel = column.label; - return ( - - ); - })} - - - + EXMN_DETAIL_SEVERITIES.length + } + className="border-b border-r border-border !py-[0.8rem] px-2 text-center" + > + 이상항목 + + + + + {visibleDefectColumns.map((column) => ( + ))} + + + {EXMN_DETAIL_SEVERITIES.map((severity) => ( + - {visibleDefectColumns.map((column) => ( + ))} + {visibleDefectColumns.flatMap((column) => + EXMN_DETAIL_SEVERITIES.map((severity) => ( - ))} - - - {EXMN_DETAIL_SEVERITIES.map((severity) => ( - - ))} - {visibleDefectColumns.flatMap((column) => - EXMN_DETAIL_SEVERITIES.map((severity) => ( - + + {rows.length > 0 ? ( + <> + {rows.map((row) => { + const visibleAnomalyTotals = getVisibleAnomalyTotals( + row, + visibleDefectColumns, + ); + return ( + - {SEVERITY_LABELS[severity]} - - )), - )} - - - - {rows.length > 0 ? ( - <> - {rows.map((row) => { - const visibleAnomalyTotals = getVisibleAnomalyTotals( - row, - visibleDefectColumns, - ); - return ( - - {fixedColumns.map((column, columnIndex) => { - const sticky = - columnIndex < STICKY_COLUMN_COUNT; - return ( - - ); - })} - {EXMN_DETAIL_SEVERITIES.map((severity) => ( + {fixedColumns.map((column, columnIndex) => { + const sticky = columnIndex < STICKY_COLUMN_COUNT; + return ( - ))} - {visibleDefectColumns.flatMap((column) => - EXMN_DETAIL_SEVERITIES.map((severity) => ( - - )), - )} - - ); - })} - - + ))} + {visibleDefectColumns.flatMap((column) => + EXMN_DETAIL_SEVERITIES.map((severity) => ( + + )), + )} + + ); + })} + + + {summaryLabelColumnCount > STICKY_COLUMN_COUNT && ( + - {summaryLabelColumnCount > STICKY_COLUMN_COUNT && ( + ))} + {EXMN_DETAIL_SEVERITIES.map((severity) => ( + + ))} + {visibleDefectColumns.flatMap((column) => + EXMN_DETAIL_SEVERITIES.map((severity) => ( - ))} - {EXMN_DETAIL_SEVERITIES.map((severity) => ( - - ))} - {visibleDefectColumns.flatMap((column) => - EXMN_DETAIL_SEVERITIES.map((severity) => ( - - )), - )} - - - + - {summaryLabelColumnCount > STICKY_COLUMN_COUNT && ( - - ))} + 합계 + + + {summaryLabelColumnCount > STICKY_COLUMN_COUNT && ( + ))} + + {visibleDefectColumns.map((column) => ( + - {visibleDefectColumns.map((column) => ( - - ))} - - - ) : ( - - + ))} - )} - -
+ + {columnLabel} + + ( -
- - {columnLabel} - - - 이상항목 -
+ 합계 + + {column.label} ({column.code}) +
- 합계 + {SEVERITY_LABELS[severity]} - {column.label} ({column.code}) -
{SEVERITY_LABELS[severity]} +
- {getFixedCellValue(row, column.id)} - - {formatCount(visibleAnomalyTotals[severity])} + {getFixedCellValue(row, column.id)} - {formatCount( - row.defectCounts[column.code]?.[severity], - )} -
( + + {formatCount(visibleAnomalyTotals[severity])} + + {formatCount( + row.defectCounts[column.code]?.[severity], + )} +
+ - - 소계 - + 소계 + + + {summary.value} + {formatCount( + defectSummary.anomalySubtotals[severity], + )} + - {summary.value} - {formatCount( - defectSummary.anomalySubtotals[severity], + defectSummary.defectSubtotals[column.code]?.[ + severity + ], )} - {formatCount( - defectSummary.defectSubtotals[column.code]?.[ - severity - ], - )} -
+
+ - - 합계 - - - {summary.value} - + {summary.value} + + {formatCount(defectSummary.anomalyTotal)} + - {formatCount(defectSummary.anomalyTotal)} + {formatCount( + defectSummary.defectTotals[column.code], + )} - {formatCount( - defectSummary.defectTotals[column.code], - )} -
- 조회된 상세 결과가 없습니다. -
-
- )} -
-
+ + ) : ( + + + 조회된 상세 결과가 없습니다. + + + )} + + + ) + } + />
); diff --git a/apps/sn/src/features/exmn/ui/ExmnSurveyStandardDataTable.tsx b/apps/sn/src/features/exmn/ui/ExmnSurveyStandardDataTable.tsx new file mode 100644 index 000000000..fd498371e --- /dev/null +++ b/apps/sn/src/features/exmn/ui/ExmnSurveyStandardDataTable.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useMemo, type ReactNode, type UIEventHandler } from "react"; +import { + getCoreRowModel, + useReactTable, + type ColumnDef, +} from "@tanstack/react-table"; + +import { StandardDataTable } from "@common/business_components"; + +interface ExmnSurveyStandardDataTableProps { + rows: T[]; + title: ReactNode; + actions?: ReactNode; + loading?: boolean; + emptyMessage: string; + minTableWidth?: string; + onScroll?: UIEventHandler; + renderTable: () => ReactNode; +} + +/** + * 조사 복합표의 병합 셀은 업무 화면에서 유지하고 카드·헤더·로딩·빈 상태·스크롤은 + * StandardDataTable이 소유하도록 연결한다. + */ +export function ExmnSurveyStandardDataTable({ + rows, + title, + actions, + loading = false, + emptyMessage, + minTableWidth = "100%", + onScroll, + renderTable, +}: ExmnSurveyStandardDataTableProps) { + /** 공통 표가 데이터 유무와 로딩 상태를 판단할 수 있도록 복합 행을 최소 열로 연결한다. */ + const columns = useMemo[]>( + () => [ + { + id: "complexRow", + accessorFn: (_row, index) => index, + }, + ], + [], + ); + const table = useReactTable({ + data: rows, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( + + ); +} + +export type { ExmnSurveyStandardDataTableProps }; diff --git a/apps/sn/src/features/windowPopup/ui/PopupSearchPanel.tsx b/apps/sn/src/features/windowPopup/ui/PopupSearchPanel.tsx index e7d4f871a..8ccabac19 100644 --- a/apps/sn/src/features/windowPopup/ui/PopupSearchPanel.tsx +++ b/apps/sn/src/features/windowPopup/ui/PopupSearchPanel.tsx @@ -10,6 +10,18 @@ type PopupSearchPanelProps = { contentClassName?: string; }; +type PopupSearchFieldProps = { + label: ReactNode; + children: ReactNode; + className?: string; + labelClassName?: string; +}; + +type PopupSearchActionsProps = { + children: ReactNode; + className?: string; +}; + /** WindowPopup 조회조건의 외곽 카드, 패딩과 가로 스크롤 규칙을 한 곳에서 관리한다. */ export default function PopupSearchPanel({ children, @@ -24,9 +36,59 @@ export default function PopupSearchPanel({ className, )} > -
{children}
+
+ {children} +
); } -export type { PopupSearchPanelProps }; +/** 조회조건 라벨과 입력 컨트롤을 같은 행에 배치한다. */ +export function PopupSearchField({ + label, + children, + className, + labelClassName, +}: PopupSearchFieldProps) { + return ( + + ); +} + +/** 조회·초기화 버튼을 검색조건의 우측 끝에 같은 간격으로 배치한다. */ +export function PopupSearchActions({ + children, + className, +}: PopupSearchActionsProps) { + return ( +
+ {children} +
+ ); +} + +export type { + PopupSearchActionsProps, + PopupSearchFieldProps, + PopupSearchPanelProps, +}; diff --git a/packages/common/business_components/src/ui/standard_data_table/index.tsx b/packages/common/business_components/src/ui/standard_data_table/index.tsx index a6c1e0ab2..0a83e5ce6 100644 --- a/packages/common/business_components/src/ui/standard_data_table/index.tsx +++ b/packages/common/business_components/src/ui/standard_data_table/index.tsx @@ -8,6 +8,7 @@ import { useState, type CSSProperties, type ReactNode, + type UIEventHandler, } from "react"; import type { Table as TableInstance } from "@tanstack/react-table"; import { ChevronLeft, ChevronRight } from "lucide-react"; @@ -21,6 +22,10 @@ import { type ScrollMode = "none" | "horizontal" | "vertical" | "both"; +type StandardDataTableRenderContext = { + table: TableInstance; +}; + type StandardDataTableProps = { table: TableInstance; title?: ReactNode; @@ -38,6 +43,10 @@ type StandardDataTableProps = { flushOuterMargin?: boolean; /** 빈 목록일 때 부모의 남은 높이를 채워 팝업·페이지의 과도한 바깥 여백을 줄인다. */ fillAvailableHeight?: boolean; + /** 병합 셀·고정 열이 필요한 복합표가 공통 카드·로딩·스크롤 안에서 표 본문만 교체한다. */ + renderTable?: (context: StandardDataTableRenderContext) => ReactNode; + /** 고정 열 경계처럼 가로 스크롤 위치가 필요한 소비 화면에 공통 스크롤 이벤트를 전달한다. */ + onScroll?: UIEventHandler; onRowClick?: (row: T) => void; }; @@ -185,6 +194,8 @@ function StandardDataTable({ viewPagination = true, flushOuterMargin = false, fillAvailableHeight = false, + renderTable, + onScroll, onRowClick, }: StandardDataTableProps) { const rowCount = table.getRowModel().rows.length; @@ -272,6 +283,7 @@ function StandardDataTable({ >
({ )} > {loading ? ( - +
+ +
) : ( emptyMessage )}
) : ( - + (renderTable?.({ table }) ?? ( + + )) )} {viewPagination && !loading && rowCount > 0 ? ( @@ -313,4 +333,9 @@ function StandardDataTable({ } export { STANDARD_ROW_NUMBER_HEADER, StandardDataTable, getStandardRowNumber }; -export type { ScrollMode, StandardDataTableProps, StandardRowNumberParams }; +export type { + ScrollMode, + StandardDataTableProps, + StandardDataTableRenderContext, + StandardRowNumberParams, +};