팝업 병합 그리드 기능 추가

This commit is contained in:
2026-08-25 16:43:07 +09:00
parent 8126fb844a
commit ccfbe6ae32
6 changed files with 681 additions and 527 deletions
@@ -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() {
<StandardPopupLayout title="지반침하 목록">
<LoadingOverlay isLoading={isLoading} />
<PopupSearchPanel contentClassName="flex flex-wrap items-end gap-x-5 gap-y-3">
<label className="block md:max-w-[170px]">
<div className="mb-1 text-[1.05rem] font-medium text-label">
발생기간(시작)
</div>
<PopupSearchPanel>
<PopupSearchField label="발생기간(시작)">
<Input
className="h-[3.2rem] !text-[1.3rem]"
className="h-[3.2rem] w-[12rem] !text-[1.3rem]"
type="date"
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
value={formatYmdToDateInput(filters.startYmd)}
@@ -335,14 +335,11 @@ export default function SinkholePopupPage() {
})
}
/>
</label>
</PopupSearchField>
<label className="block md:max-w-[170px]">
<div className="mb-1 text-[1.05rem] font-medium text-label">
발생기간(종료)
</div>
<PopupSearchField label="발생기간(종료)">
<Input
className="h-[3.2rem] !text-[1.3rem]"
className="h-[3.2rem] w-[12rem] !text-[1.3rem]"
type="date"
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
value={formatYmdToDateInput(filters.endYmd)}
@@ -353,12 +350,11 @@ export default function SinkholePopupPage() {
})
}
/>
</label>
</PopupSearchField>
<label className="block md:max-w-[220px]">
<div className="mb-1 text-[1.05rem] font-medium text-label">시도</div>
<PopupSearchField label="시도">
<select
className="h-[3.2rem] w-full rounded-md border border-border px-3 text-[1.3rem]"
className="h-[3.2rem] w-[12rem] rounded-md border border-input bg-field px-3 text-[1.3rem] text-foreground"
value={filters.ctpvCd}
onChange={(event) =>
setFilters({
@@ -375,14 +371,11 @@ export default function SinkholePopupPage() {
</option>
))}
</select>
</label>
</PopupSearchField>
<label className="block md:max-w-[220px]">
<div className="mb-1 text-[1.05rem] font-medium text-label">
시군구
</div>
<PopupSearchField label="시군구">
<select
className="h-[3.2rem] w-full rounded-md border border-border px-3 text-[1.3rem]"
className="h-[3.2rem] w-[12rem] rounded-md border border-input bg-field px-3 text-[1.3rem] text-foreground"
value={filters.sggCd}
onChange={(event) =>
setFilters({ ...filters, sggCd: event.target.value })
@@ -395,14 +388,11 @@ export default function SinkholePopupPage() {
</option>
))}
</select>
</label>
</PopupSearchField>
<label className="block md:max-w-[180px]">
<div className="mb-1 text-[1.05rem] font-medium text-label">
복구상태
</div>
<PopupSearchField label="복구상태">
<select
className="h-[3.2rem] w-full rounded-md border border-border px-3 text-[1.3rem]"
className="h-[3.2rem] w-[10rem] rounded-md border border-input bg-field px-3 text-[1.3rem] text-foreground"
value={filters.restoreStatus}
onChange={(event) =>
setFilters({ ...filters, restoreStatus: event.target.value })
@@ -414,14 +404,11 @@ export default function SinkholePopupPage() {
</option>
))}
</select>
</label>
</PopupSearchField>
<label className="block md:max-w-[180px]">
<div className="mb-1 text-[1.05rem] font-medium text-label">
좌표보정상태
</div>
<PopupSearchField label="좌표보정상태">
<select
className="h-[3.2rem] w-full rounded-md border border-border px-3 text-[1.3rem]"
className="h-[3.2rem] w-[10rem] rounded-md border border-input bg-field px-3 text-[1.3rem] text-foreground"
value={filters.coordStatus}
onChange={(event) =>
setFilters({ ...filters, coordStatus: event.target.value })
@@ -433,9 +420,9 @@ export default function SinkholePopupPage() {
</option>
))}
</select>
</label>
</PopupSearchField>
<div className="ml-auto flex flex-wrap justify-end gap-2">
<PopupSearchActions>
<Button
type="button"
color="primary"
@@ -457,7 +444,7 @@ export default function SinkholePopupPage() {
>
초기화
</Button>
</div>
</PopupSearchActions>
</PopupSearchPanel>
<MapPopupDataTable
@@ -1,5 +1,7 @@
"use client";
import { Table } from "@common/components";
import {
type ExmnDetailSeverity,
type ExmnPplUnitDetailRow,
@@ -60,196 +62,195 @@ export function ExmnPplUnitDetailTable({
const tableWidth = COLUMN_WIDTHS.reduce((sum, width) => sum + width, 0);
return (
<div className="min-w-0 overflow-x-auto overflow-y-visible bg-background">
<table
className="table-ui default border-separate border-spacing-0 text-center text-foreground"
style={{ width: "100%", minWidth: `${tableWidth}px` }}
>
<colgroup>
{COLUMN_WIDTHS.map((width, index) => (
<col key={`unit-column-${index}`} style={{ width: `${width}px` }} />
))}
</colgroup>
<thead className="sticky top-0 z-30 text-center font-medium leading-[1.2]">
<tr>
<th
rowSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
관로번호
</th>
<th
rowSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
단위
<br />
구간
</th>
<th
rowSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
단위
<br />
길이
<br />
(m)
</th>
<th
rowSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
결함
<br />
위치
<br />
(m)
</th>
<th
colSpan={6}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
결함
</th>
<th
colSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
등급점수(점)
</th>
<th
rowSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
조사중단사유
</th>
<th
rowSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
비고
</th>
</tr>
<tr>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
종류
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
코드
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
등급
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
발생
<br />
위치
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
침입수
<br />
가점
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
연속
<br />
결함
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
구조적
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
운영적
</th>
</tr>
</thead>
<tbody>
{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;
<Table
variant="default"
className="border-separate border-spacing-0 text-center text-foreground"
style={{ width: "100%", minWidth: `${tableWidth}px` }}
>
<colgroup>
{COLUMN_WIDTHS.map((width, index) => (
<col key={`unit-column-${index}`} style={{ width: `${width}px` }} />
))}
</colgroup>
<thead className="sticky top-0 z-30 text-center font-medium leading-[1.2]">
<tr>
<th
rowSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
관로번호
</th>
<th
rowSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
단위
<br />
구간
</th>
<th
rowSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
단위
<br />
길이
<br />
(m)
</th>
<th
rowSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
결함
<br />
위치
<br />
(m)
</th>
<th
colSpan={6}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
결함
</th>
<th
colSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
등급점수(점)
</th>
<th
rowSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
조사중단사유
</th>
<th
rowSpan={2}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
비고
</th>
</tr>
<tr>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
종류
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
코드
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
등급
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
발생
<br />
위치
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
침입수
<br />
가점
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
연속
<br />
결함
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
구조적
</th>
<th className="border-b border-r border-border px-2 py-[1.2rem] text-center">
운영적
</th>
</tr>
</thead>
<tbody>
{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 (
<tr
key={`${row.pipeNo}-${row.unitSeq}-${row.defectPositionM}-${row.code}-${index}`}
className="transition-colors hover:bg-container"
>
{showPipeCell && (
<td
rowSpan={pipeSpan?.count}
className="border-b border-r border-border bg-background px-2 py-[1.2rem] align-middle font-medium text-primary"
>
{row.pipeNo}
</td>
)}
{showUnitCells && (
<>
<td
rowSpan={unitSpan?.count}
className="border-b border-r border-border bg-background px-2 py-[1.2rem] align-middle font-medium"
>
{row.unitSeq}
</td>
<td
rowSpan={unitSpan?.count}
className="border-b border-r border-border bg-background px-2 py-[1.2rem] align-middle"
>
{row.unitLengthM}
</td>
</>
)}
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.defectPositionM)}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem] text-left">
{row.defectName}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{row.code}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{row.severity ? SEVERITY_LABELS[row.severity] : "-"}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.occurrencePosition)}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.infiltrationBonus)}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{row.continuous == null ? "-" : row.continuous ? "Y" : "N"}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.structuralScore)}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.operationalScore)}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.surveyStopReason)}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.remark)}
</td>
</tr>
);
})
) : (
<tr>
<td
colSpan={COLUMN_WIDTHS.length}
className="h-28 border-b border-r border-border text-center text-muted-foreground"
return (
<tr
key={`${row.pipeNo}-${row.unitSeq}-${row.defectPositionM}-${row.code}-${index}`}
className="transition-colors hover:bg-container"
>
{emptyMessage}
</td>
</tr>
)}
</tbody>
</table>
</div>
{showPipeCell && (
<td
rowSpan={pipeSpan?.count}
className="border-b border-r border-border bg-background px-2 py-[1.2rem] align-middle font-medium text-primary"
>
{row.pipeNo}
</td>
)}
{showUnitCells && (
<>
<td
rowSpan={unitSpan?.count}
className="border-b border-r border-border bg-background px-2 py-[1.2rem] align-middle font-medium"
>
{row.unitSeq}
</td>
<td
rowSpan={unitSpan?.count}
className="border-b border-r border-border bg-background px-2 py-[1.2rem] align-middle"
>
{row.unitLengthM}
</td>
</>
)}
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.defectPositionM)}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem] text-left">
{row.defectName}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{row.code}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{row.severity ? SEVERITY_LABELS[row.severity] : "-"}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.occurrencePosition)}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.infiltrationBonus)}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{row.continuous == null ? "-" : row.continuous ? "Y" : "N"}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.structuralScore)}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.operationalScore)}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.surveyStopReason)}
</td>
<td className="border-b border-r border-border px-2 py-[1.2rem]">
{formatOptionalValue(row.remark)}
</td>
</tr>
);
})
) : (
<tr>
<td
colSpan={COLUMN_WIDTHS.length}
className="h-28 border-b border-r border-border text-center text-muted-foreground"
>
{emptyMessage}
</td>
</tr>
)}
</tbody>
</Table>
);
}
@@ -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<ExmnDetailSeverity, string> = {
L: "대",
@@ -705,6 +706,9 @@ export function ExmnSurveyDetailPopupPage({
() => new Set(unitRows.map((row) => `${row.pipeNo}::${row.unitSeq}`)).size,
[unitRows],
);
/** 관로 집계와 본단위구간 행을 같은 StandardDataTable 데이터 유무 계약으로 전달한다. */
const standardTableRows: Array<ExmnSurveyDetailRow | ExmnPplUnitDetailRow> =
isUnitMode ? unitRows : rows;
const popupTitle = isUnitMode
? "관로조사 본단위구간 상세현황"
: defectType === "ALL"
@@ -735,7 +739,6 @@ export function ExmnSurveyDetailPopupPage({
}
bodyClassName="overflow-x-hidden"
>
<LoadingOverlay isLoading={isLoading} />
{notice && (
<div
role="status"
@@ -862,9 +865,20 @@ export function ExmnSurveyDetailPopupPage({
</div>
)}
<div className="card card-border !m-0 min-w-0 overflow-hidden !p-0">
<div className="card-header flex flex-wrap items-center justify-between gap-4">
<h2 className="text-2xl font-medium text-label">{tableTitle}</h2>
<ExmnSurveyStandardDataTable
rows={standardTableRows}
title={tableTitle}
loading={isLoading}
emptyMessage={
isUnitMode
? appliedDetailNumberKeyword
? "입력한 관로번호에 해당하는 본단위구간이 없습니다."
: "조회된 본단위구간이 없습니다."
: "조회된 상세 결과가 없습니다."
}
minTableWidth={isUnitMode ? "142.2rem" : `${tableWidth}px`}
onScroll={isUnitMode ? undefined : handleTableScroll}
actions={
<div className="flex flex-wrap items-center gap-x-7 gap-y-2 whitespace-nowrap text-xl text-muted-foreground">
{isUnitMode ? (
<>
@@ -898,10 +912,9 @@ export function ExmnSurveyDetailPopupPage({
</>
)}
</div>
</div>
<div className="card !m-[1.2rem] min-w-0 overflow-hidden !p-0">
{isUnitMode ? (
}
renderTable={() =>
isUnitMode ? (
<ExmnPplUnitDetailTable
rows={unitRows}
emptyMessage={
@@ -911,320 +924,315 @@ export function ExmnSurveyDetailPopupPage({
}
/>
) : (
<div
className="min-w-0 overflow-x-auto overflow-y-visible bg-background"
onScroll={handleTableScroll}
<Table
variant="default"
className="border-separate border-spacing-0 text-center text-foreground"
style={{
width: `${tableWidth}px`,
minWidth: `${tableWidth}px`,
}}
>
<table
className="table-ui default border-separate border-spacing-0 text-center text-foreground"
style={{
width: `${tableWidth}px`,
minWidth: `${tableWidth}px`,
}}
>
<colgroup>
{fixedColumns.map((column) => (
<col
key={column.id}
style={{ width: `${column.width}px` }}
/>
))}
{Array.from({
length:
<colgroup>
{fixedColumns.map((column) => (
<col
key={column.id}
style={{ width: `${column.width}px` }}
/>
))}
{Array.from({
length:
(visibleDefectColumns.length + 1) *
EXMN_DETAIL_SEVERITIES.length,
}).map((_, index) => (
<col
key={`severity-col-${index}`}
style={{ width: `${SEVERITY_COLUMN_WIDTH}px` }}
/>
))}
</colgroup>
<thead className="sticky top-0 z-30 text-center font-medium leading-[1.2]">
<tr>
{fixedColumns.map((column, columnIndex) => {
const sticky = columnIndex < STICKY_COLUMN_COUNT;
const columnLabel = column.label;
return (
<th
key={column.id}
rowSpan={3}
className="border-b border-r border-border !py-[0.8rem] px-2 text-center"
style={
sticky
? {
position: "sticky",
left:
columnIndex === 0
? REPORT_COLUMN_LEFT
: PIPE_COLUMN_LEFT,
zIndex: 60,
boxShadow:
columnIndex === STICKY_COLUMN_COUNT - 1 &&
showStickyDivider
? STICKY_DIVIDER_SHADOW
: undefined,
}
: undefined
}
>
<span className="whitespace-pre-line">
{columnLabel}
</span>
</th>
);
})}
<th
colSpan={
(visibleDefectColumns.length + 1) *
EXMN_DETAIL_SEVERITIES.length,
}).map((_, index) => (
<col
key={`severity-col-${index}`}
style={{ width: `${SEVERITY_COLUMN_WIDTH}px` }}
/>
))}
</colgroup>
<thead className="sticky top-0 z-30 text-center font-medium leading-[1.2]">
<tr>
{fixedColumns.map((column, columnIndex) => {
const sticky = columnIndex < STICKY_COLUMN_COUNT;
const columnLabel = column.label;
return (
<th
key={column.id}
rowSpan={3}
className="border-b border-r border-border !py-[0.8rem] px-2 text-center"
style={
sticky
? {
position: "sticky",
left:
columnIndex === 0
? REPORT_COLUMN_LEFT
: PIPE_COLUMN_LEFT,
zIndex: 60,
boxShadow:
columnIndex === STICKY_COLUMN_COUNT - 1 &&
showStickyDivider
? STICKY_DIVIDER_SHADOW
: undefined,
}
: undefined
}
>
<span className="whitespace-pre-line">
{columnLabel}
</span>
</th>
);
})}
<th
colSpan={
(visibleDefectColumns.length + 1) *
EXMN_DETAIL_SEVERITIES.length
}
className="border-b border-r border-border !py-[0.8rem] px-2 text-center"
>
이상항목
</th>
</tr>
<tr>
EXMN_DETAIL_SEVERITIES.length
}
className="border-b border-r border-border !py-[0.8rem] px-2 text-center"
>
이상항목
</th>
</tr>
<tr>
<th
colSpan={EXMN_DETAIL_SEVERITIES.length}
className="border-b border-r border-border !py-[0.6rem] px-2 text-center"
>
합계
</th>
{visibleDefectColumns.map((column) => (
<th
key={column.code}
colSpan={EXMN_DETAIL_SEVERITIES.length}
title={`${column.label} (${column.code})`}
className="whitespace-nowrap border-b border-r border-border !py-[0.6rem] px-2 text-center"
>
{column.label} ({column.code})
</th>
))}
</tr>
<tr>
{EXMN_DETAIL_SEVERITIES.map((severity) => (
<th
key={`total-${severity}`}
className="border-b border-r border-border !py-[0.6rem] px-2 text-center"
>
합계
{SEVERITY_LABELS[severity]}
</th>
{visibleDefectColumns.map((column) => (
))}
{visibleDefectColumns.flatMap((column) =>
EXMN_DETAIL_SEVERITIES.map((severity) => (
<th
key={column.code}
colSpan={EXMN_DETAIL_SEVERITIES.length}
title={`${column.label} (${column.code})`}
className="whitespace-nowrap border-b border-r border-border !py-[0.6rem] px-2 text-center"
>
{column.label} ({column.code})
</th>
))}
</tr>
<tr>
{EXMN_DETAIL_SEVERITIES.map((severity) => (
<th
key={`total-${severity}`}
className="border-b border-r border-border !py-[0.6rem] px-2 text-center"
key={`${column.code}-${severity}`}
className="border-b border-r border-border !py-[0.6rem] px-2 text-center font-medium"
>
{SEVERITY_LABELS[severity]}
</th>
))}
{visibleDefectColumns.flatMap((column) =>
EXMN_DETAIL_SEVERITIES.map((severity) => (
<th
key={`${column.code}-${severity}`}
className="border-b border-r border-border !py-[0.6rem] px-2 text-center font-medium"
)),
)}
</tr>
</thead>
<tbody>
{rows.length > 0 ? (
<>
{rows.map((row) => {
const visibleAnomalyTotals = getVisibleAnomalyTotals(
row,
visibleDefectColumns,
);
return (
<tr
key={row.reportNo}
className="transition-colors hover:bg-container"
>
{SEVERITY_LABELS[severity]}
</th>
)),
)}
</tr>
</thead>
<tbody>
{rows.length > 0 ? (
<>
{rows.map((row) => {
const visibleAnomalyTotals = getVisibleAnomalyTotals(
row,
visibleDefectColumns,
);
return (
<tr
key={row.reportNo}
className="transition-colors hover:bg-container"
>
{fixedColumns.map((column, columnIndex) => {
const sticky =
columnIndex < STICKY_COLUMN_COUNT;
return (
<td
key={`${row.reportNo}-${column.id}`}
className="whitespace-nowrap border-b border-r border-border bg-background px-2 py-[1.2rem]"
style={
sticky
? {
position: "sticky",
left:
columnIndex === 0
? REPORT_COLUMN_LEFT
: PIPE_COLUMN_LEFT,
zIndex: 10,
boxShadow:
columnIndex ===
STICKY_COLUMN_COUNT - 1 &&
showStickyDivider
? STICKY_DIVIDER_SHADOW
: undefined,
}
: undefined
}
>
{getFixedCellValue(row, column.id)}
</td>
);
})}
{EXMN_DETAIL_SEVERITIES.map((severity) => (
{fixedColumns.map((column, columnIndex) => {
const sticky = columnIndex < STICKY_COLUMN_COUNT;
return (
<td
key={`${row.reportNo}-total-${severity}`}
className="border-b border-r border-border bg-background px-2 py-[1.2rem]"
key={`${row.reportNo}-${column.id}`}
className="whitespace-nowrap border-b border-r border-border bg-background px-2 py-[1.2rem]"
style={
sticky
? {
position: "sticky",
left:
columnIndex === 0
? REPORT_COLUMN_LEFT
: PIPE_COLUMN_LEFT,
zIndex: 10,
boxShadow:
columnIndex ===
STICKY_COLUMN_COUNT - 1 &&
showStickyDivider
? STICKY_DIVIDER_SHADOW
: undefined,
}
: undefined
}
>
{formatCount(visibleAnomalyTotals[severity])}
{getFixedCellValue(row, column.id)}
</td>
))}
{visibleDefectColumns.flatMap((column) =>
EXMN_DETAIL_SEVERITIES.map((severity) => (
<td
key={`${row.reportNo}-${column.code}-${severity}`}
className="border-b border-r border-border px-2 py-[1.2rem]"
>
{formatCount(
row.defectCounts[column.code]?.[severity],
)}
</td>
)),
)}
</tr>
);
})}
<tr className="bg-background font-medium">
<td
colSpan={STICKY_COLUMN_COUNT}
className="sticky left-0 z-20 border-b border-border bg-background px-2 py-[1.2rem] text-center align-middle"
);
})}
{EXMN_DETAIL_SEVERITIES.map((severity) => (
<td
key={`${row.reportNo}-total-${severity}`}
className="border-b border-r border-border bg-background px-2 py-[1.2rem]"
>
{formatCount(visibleAnomalyTotals[severity])}
</td>
))}
{visibleDefectColumns.flatMap((column) =>
EXMN_DETAIL_SEVERITIES.map((severity) => (
<td
key={`${row.reportNo}-${column.code}-${severity}`}
className="border-b border-r border-border px-2 py-[1.2rem]"
>
{formatCount(
row.defectCounts[column.code]?.[severity],
)}
</td>
)),
)}
</tr>
);
})}
<tr className="bg-background font-medium">
<td
colSpan={STICKY_COLUMN_COUNT}
className="sticky left-0 z-20 border-b border-border bg-background px-2 py-[1.2rem] text-center align-middle"
style={{
boxShadow: showStickyDivider
? STICKY_DIVIDER_SHADOW
: undefined,
}}
>
<span
className="inline-block whitespace-nowrap"
style={{
boxShadow: showStickyDivider
? STICKY_DIVIDER_SHADOW
: undefined,
transform: `translateX(${summaryLabelOffset}px)`,
}}
>
<span
className="inline-block whitespace-nowrap"
style={{
transform: `translateX(${summaryLabelOffset}px)`,
}}
>
소계
</span>
소계
</span>
</td>
{summaryLabelColumnCount > STICKY_COLUMN_COUNT && (
<td
aria-hidden="true"
colSpan={
summaryLabelColumnCount - STICKY_COLUMN_COUNT
}
className="border-b border-r border-border bg-background px-2 py-[1.2rem]"
/>
)}
{fixedSummaryValues.map((summary) => (
<td
key={`subtotal-fixed-${summary.key}`}
className="border-b border-r border-border bg-background px-2 py-[1.2rem] text-center"
>
{summary.value}
</td>
{summaryLabelColumnCount > STICKY_COLUMN_COUNT && (
))}
{EXMN_DETAIL_SEVERITIES.map((severity) => (
<td
key={`subtotal-anomaly-${severity}`}
className="border-b border-r border-border px-2 py-[1.2rem]"
>
{formatCount(
defectSummary.anomalySubtotals[severity],
)}
</td>
))}
{visibleDefectColumns.flatMap((column) =>
EXMN_DETAIL_SEVERITIES.map((severity) => (
<td
aria-hidden="true"
colSpan={
summaryLabelColumnCount - STICKY_COLUMN_COUNT
}
className="border-b border-r border-border bg-background px-2 py-[1.2rem]"
/>
)}
{fixedSummaryValues.map((summary) => (
<td
key={`subtotal-fixed-${summary.key}`}
className="border-b border-r border-border bg-background px-2 py-[1.2rem] text-center"
>
{summary.value}
</td>
))}
{EXMN_DETAIL_SEVERITIES.map((severity) => (
<td
key={`subtotal-anomaly-${severity}`}
key={`subtotal-${column.code}-${severity}`}
className="border-b border-r border-border px-2 py-[1.2rem]"
>
{formatCount(
defectSummary.anomalySubtotals[severity],
defectSummary.defectSubtotals[column.code]?.[
severity
],
)}
</td>
))}
{visibleDefectColumns.flatMap((column) =>
EXMN_DETAIL_SEVERITIES.map((severity) => (
<td
key={`subtotal-${column.code}-${severity}`}
className="border-b border-r border-border px-2 py-[1.2rem]"
>
{formatCount(
defectSummary.defectSubtotals[column.code]?.[
severity
],
)}
</td>
)),
)}
</tr>
<tr className="bg-secondary font-medium">
<td
colSpan={STICKY_COLUMN_COUNT}
className="sticky left-0 z-20 border-b border-border bg-secondary px-2 py-[1.2rem] text-center align-middle"
)),
)}
</tr>
<tr className="bg-secondary font-medium">
<td
colSpan={STICKY_COLUMN_COUNT}
className="sticky left-0 z-20 border-b border-border bg-secondary px-2 py-[1.2rem] text-center align-middle"
style={{
boxShadow: showStickyDivider
? STICKY_DIVIDER_SHADOW
: undefined,
}}
>
<span
className="inline-block whitespace-nowrap"
style={{
boxShadow: showStickyDivider
? STICKY_DIVIDER_SHADOW
: undefined,
transform: `translateX(${summaryLabelOffset}px)`,
}}
>
<span
className="inline-block whitespace-nowrap"
style={{
transform: `translateX(${summaryLabelOffset}px)`,
}}
>
합계
</span>
</td>
{summaryLabelColumnCount > STICKY_COLUMN_COUNT && (
<td
aria-hidden="true"
colSpan={
summaryLabelColumnCount - STICKY_COLUMN_COUNT
}
className="border-b border-r border-border bg-secondary px-2 py-[1.2rem]"
/>
)}
{fixedSummaryValues.map((summary) => (
<td
key={`total-fixed-${summary.key}`}
className="border-b border-r border-border bg-secondary px-2 py-[1.2rem] text-center"
>
{summary.value}
</td>
))}
합계
</span>
</td>
{summaryLabelColumnCount > STICKY_COLUMN_COUNT && (
<td
aria-hidden="true"
colSpan={
summaryLabelColumnCount - STICKY_COLUMN_COUNT
}
className="border-b border-r border-border bg-secondary px-2 py-[1.2rem]"
/>
)}
{fixedSummaryValues.map((summary) => (
<td
key={`total-fixed-${summary.key}`}
className="border-b border-r border-border bg-secondary px-2 py-[1.2rem] text-center"
>
{summary.value}
</td>
))}
<td
colSpan={EXMN_DETAIL_SEVERITIES.length}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
{formatCount(defectSummary.anomalyTotal)}
</td>
{visibleDefectColumns.map((column) => (
<td
key={`total-${column.code}`}
colSpan={EXMN_DETAIL_SEVERITIES.length}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
{formatCount(defectSummary.anomalyTotal)}
{formatCount(
defectSummary.defectTotals[column.code],
)}
</td>
{visibleDefectColumns.map((column) => (
<td
key={`total-${column.code}`}
colSpan={EXMN_DETAIL_SEVERITIES.length}
className="border-b border-r border-border px-2 py-[1.2rem] text-center"
>
{formatCount(
defectSummary.defectTotals[column.code],
)}
</td>
))}
</tr>
</>
) : (
<tr>
<td
colSpan={
fixedColumns.length +
(visibleDefectColumns.length + 1) *
EXMN_DETAIL_SEVERITIES.length
}
className="h-28 border-b border-r border-border text-center text-muted-foreground"
>
조회된 상세 결과가 없습니다.
</td>
))}
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
</div>
</>
) : (
<tr>
<td
colSpan={
fixedColumns.length +
(visibleDefectColumns.length + 1) *
EXMN_DETAIL_SEVERITIES.length
}
className="h-28 border-b border-r border-border text-center text-muted-foreground"
>
조회된 상세 결과가 없습니다.
</td>
</tr>
)}
</tbody>
</Table>
)
}
/>
</section>
</StandardPopupLayout>
);
@@ -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<T extends object> {
rows: T[];
title: ReactNode;
actions?: ReactNode;
loading?: boolean;
emptyMessage: string;
minTableWidth?: string;
onScroll?: UIEventHandler<HTMLDivElement>;
renderTable: () => ReactNode;
}
/**
* 조사 복합표의 병합 셀은 업무 화면에서 유지하고 카드·헤더·로딩·빈 상태·스크롤은
* StandardDataTable이 소유하도록 연결한다.
*/
export function ExmnSurveyStandardDataTable<T extends object>({
rows,
title,
actions,
loading = false,
emptyMessage,
minTableWidth = "100%",
onScroll,
renderTable,
}: ExmnSurveyStandardDataTableProps<T>) {
/** 공통 표가 데이터 유무와 로딩 상태를 판단할 수 있도록 복합 행을 최소 열로 연결한다. */
const columns = useMemo<ColumnDef<T>[]>(
() => [
{
id: "complexRow",
accessorFn: (_row, index) => index,
},
],
[],
);
const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<StandardDataTable
table={table}
title={title}
actions={actions}
loading={loading}
emptyMessage={emptyMessage}
className="!w-full !max-w-full"
scrollMode="horizontal"
minTableWidth={minTableWidth}
viewPagination={false}
flushOuterMargin
onScroll={onScroll}
renderTable={renderTable}
/>
);
}
export type { ExmnSurveyStandardDataTableProps };
@@ -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,
)}
>
<div className={cn("min-w-0", contentClassName)}>{children}</div>
<div
className={cn(
"flex min-w-0 flex-wrap items-center gap-x-5 gap-y-3",
contentClassName,
)}
>
{children}
</div>
</section>
);
}
export type { PopupSearchPanelProps };
/** 조회조건 라벨과 입력 컨트롤을 같은 행에 배치한다. */
export function PopupSearchField({
label,
children,
className,
labelClassName,
}: PopupSearchFieldProps) {
return (
<label className={cn("flex shrink-0 items-center gap-2", className)}>
<span
className={cn(
"shrink-0 whitespace-nowrap text-[1.3rem] font-medium text-label",
labelClassName,
)}
>
{label}
</span>
{children}
</label>
);
}
/** 조회·초기화 버튼을 검색조건의 우측 끝에 같은 간격으로 배치한다. */
export function PopupSearchActions({
children,
className,
}: PopupSearchActionsProps) {
return (
<div
className={cn(
"ml-auto flex min-w-fit shrink-0 items-center justify-end gap-2",
className,
)}
>
{children}
</div>
);
}
export type {
PopupSearchActionsProps,
PopupSearchFieldProps,
PopupSearchPanelProps,
};
@@ -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<T> = {
table: TableInstance<T>;
};
type StandardDataTableProps<T> = {
table: TableInstance<T>;
title?: ReactNode;
@@ -38,6 +43,10 @@ type StandardDataTableProps<T> = {
flushOuterMargin?: boolean;
/** 빈 목록일 때 부모의 남은 높이를 채워 팝업·페이지의 과도한 바깥 여백을 줄인다. */
fillAvailableHeight?: boolean;
/** 병합 셀·고정 열이 필요한 복합표가 공통 카드·로딩·스크롤 안에서 표 본문만 교체한다. */
renderTable?: (context: StandardDataTableRenderContext<T>) => ReactNode;
/** 고정 열 경계처럼 가로 스크롤 위치가 필요한 소비 화면에 공통 스크롤 이벤트를 전달한다. */
onScroll?: UIEventHandler<HTMLDivElement>;
onRowClick?: (row: T) => void;
};
@@ -185,6 +194,8 @@ function StandardDataTable<T>({
viewPagination = true,
flushOuterMargin = false,
fillAvailableHeight = false,
renderTable,
onScroll,
onRowClick,
}: StandardDataTableProps<T>) {
const rowCount = table.getRowModel().rows.length;
@@ -272,6 +283,7 @@ function StandardDataTable<T>({
>
<div
ref={scrollRef}
onScroll={onScroll}
className={cn(
scrollClassName,
shouldFillAvailableHeight && "min-h-96 flex-1",
@@ -286,19 +298,27 @@ function StandardDataTable<T>({
)}
>
{loading ? (
<LoadingWithAnimation color="primary" />
<div
className="relative size-16 shrink-0"
role="status"
aria-label="조회 중"
>
<LoadingWithAnimation color="primary" />
</div>
) : (
emptyMessage
)}
</div>
) : (
<DataTable
table={table}
variant={variant}
className={cn("w-full", className)}
viewPagination={false}
onRowClick={onRowClick}
/>
(renderTable?.({ table }) ?? (
<DataTable
table={table}
variant={variant}
className={cn("w-full", className)}
viewPagination={false}
onRowClick={onRowClick}
/>
))
)}
</div>
{viewPagination && !loading && rowCount > 0 ? (
@@ -313,4 +333,9 @@ function StandardDataTable<T>({
}
export { STANDARD_ROW_NUMBER_HEADER, StandardDataTable, getStandardRowNumber };
export type { ScrollMode, StandardDataTableProps, StandardRowNumberParams };
export type {
ScrollMode,
StandardDataTableProps,
StandardDataTableRenderContext,
StandardRowNumberParams,
};