조사상세 본단위구간 모드 및 결함별 엑셀 다운로드 구현
This commit is contained in:
@@ -30,6 +30,29 @@ export interface ExmnSurveyDetailRow {
|
||||
defectCounts: Record<string, ExmnSeverityCounts>;
|
||||
}
|
||||
|
||||
/** 관로 상세보기에서 전체 관로 집계와 본단위구간 등급표를 전환하는 표시 모드다. */
|
||||
export type ExmnPplDetailMode = "PIPE" | "UNIT";
|
||||
|
||||
/** `본단위등급표` 한 행에 표시하는 관로 단위구간의 상태·결함 기록이다. */
|
||||
export interface ExmnPplUnitDetailRow {
|
||||
pipeNo: string;
|
||||
unitSeq: number;
|
||||
unitLengthM: number;
|
||||
defectPositionM: number;
|
||||
defectName: string;
|
||||
code: string;
|
||||
recordType: "STATUS" | "DEFECT";
|
||||
defectType: ExmnDefectGroup | null;
|
||||
severity: ExmnDetailSeverity | null;
|
||||
occurrencePosition: string;
|
||||
infiltrationBonus: number;
|
||||
continuous: boolean | null;
|
||||
structuralScore: number | null;
|
||||
operationalScore: number | null;
|
||||
surveyStopReason: string | null;
|
||||
remark: string | null;
|
||||
}
|
||||
|
||||
/** 선택 조사유형에 따라 팝업 제목과 이상항목 설정을 함께 반환하는 화면 계약이다. */
|
||||
export interface ExmnDetailTableDefinition {
|
||||
jobType: ExmnJobType;
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import {
|
||||
EXMN_DETAIL_SEVERITIES,
|
||||
EXMN_DETAIL_TABLE_DEFINITIONS,
|
||||
type ExmnPplUnitDetailRow,
|
||||
type ExmnSeverityCounts,
|
||||
type ExmnSurveyDetailRow,
|
||||
} from "./exmn-detail-table";
|
||||
@@ -243,3 +244,217 @@ export const getExmnSampleSurveyDetails = (
|
||||
defectCounts,
|
||||
};
|
||||
});
|
||||
|
||||
/** 엑셀 `본단위등급표`의 한 관로를 단위구간별 병합 표로 확인하는 샘플 원형이다. */
|
||||
const EXMN_SAMPLE_PPL_UNIT_DETAIL_TEMPLATE: ExmnPplUnitDetailRow[] = [
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 1,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 0,
|
||||
defectName: "(상태)조사시작(순주행)",
|
||||
code: "IS",
|
||||
recordType: "STATUS",
|
||||
defectType: null,
|
||||
severity: null,
|
||||
occurrencePosition: "0/0",
|
||||
infiltrationBonus: 0,
|
||||
continuous: null,
|
||||
structuralScore: null,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 1,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 5.4,
|
||||
defectName: "(관로)변형",
|
||||
code: "DF",
|
||||
recordType: "DEFECT",
|
||||
defectType: "STRUCTURAL",
|
||||
severity: "S",
|
||||
occurrencePosition: "10/2",
|
||||
infiltrationBonus: 0,
|
||||
continuous: false,
|
||||
structuralScore: 25,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 1,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 6,
|
||||
defectName: "(관로)이음부이탈",
|
||||
code: "JS",
|
||||
recordType: "DEFECT",
|
||||
defectType: "STRUCTURAL",
|
||||
severity: "L",
|
||||
occurrencePosition: "4/6",
|
||||
infiltrationBonus: 0,
|
||||
continuous: null,
|
||||
structuralScore: 35,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 1,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 6,
|
||||
defectName: "(상태)이음부(접합부)존재",
|
||||
code: "JE",
|
||||
recordType: "STATUS",
|
||||
defectType: null,
|
||||
severity: null,
|
||||
occurrencePosition: "0/0",
|
||||
infiltrationBonus: 0,
|
||||
continuous: null,
|
||||
structuralScore: null,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 2,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 6,
|
||||
defectName: "(상태)이음부(접합부)존재",
|
||||
code: "JE",
|
||||
recordType: "STATUS",
|
||||
defectType: null,
|
||||
severity: null,
|
||||
occurrencePosition: "0/0",
|
||||
infiltrationBonus: 0,
|
||||
continuous: null,
|
||||
structuralScore: null,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 2,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 6,
|
||||
defectName: "(관로)이음부이탈",
|
||||
code: "JS",
|
||||
recordType: "DEFECT",
|
||||
defectType: "STRUCTURAL",
|
||||
severity: "L",
|
||||
occurrencePosition: "4/6",
|
||||
infiltrationBonus: 0,
|
||||
continuous: null,
|
||||
structuralScore: 35,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 2,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 7,
|
||||
defectName: "(관로)균열원주",
|
||||
code: "CC",
|
||||
recordType: "DEFECT",
|
||||
defectType: "STRUCTURAL",
|
||||
severity: "L",
|
||||
occurrencePosition: "11/12",
|
||||
infiltrationBonus: 0,
|
||||
continuous: null,
|
||||
structuralScore: 40,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 2,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 9.7,
|
||||
defectName: "(관로)균열원주",
|
||||
code: "CC",
|
||||
recordType: "DEFECT",
|
||||
defectType: "STRUCTURAL",
|
||||
severity: "L",
|
||||
occurrencePosition: "7/8",
|
||||
infiltrationBonus: 0,
|
||||
continuous: null,
|
||||
structuralScore: 40,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 2,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 11.6,
|
||||
defectName: "(관로)변형",
|
||||
code: "DF",
|
||||
recordType: "DEFECT",
|
||||
defectType: "STRUCTURAL",
|
||||
severity: "S",
|
||||
occurrencePosition: "10/12",
|
||||
infiltrationBonus: 0,
|
||||
continuous: false,
|
||||
structuralScore: 25,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 2,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 12,
|
||||
defectName: "(관로)이음부이탈",
|
||||
code: "JS",
|
||||
recordType: "DEFECT",
|
||||
defectType: "STRUCTURAL",
|
||||
severity: "L",
|
||||
occurrencePosition: "5/7",
|
||||
infiltrationBonus: 0,
|
||||
continuous: null,
|
||||
structuralScore: 35,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
{
|
||||
pipeNo: "118-0026",
|
||||
unitSeq: 2,
|
||||
unitLengthM: 6,
|
||||
defectPositionM: 12,
|
||||
defectName: "(상태)이음부(접합부)존재",
|
||||
code: "JE",
|
||||
recordType: "STATUS",
|
||||
defectType: null,
|
||||
severity: null,
|
||||
occurrencePosition: "0/0",
|
||||
infiltrationBonus: 0,
|
||||
continuous: null,
|
||||
structuralScore: null,
|
||||
operationalScore: null,
|
||||
surveyStopReason: null,
|
||||
remark: null,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 본단위구간 모드 최초 진입 시 처리분구의 관리번호 전체가 보이도록
|
||||
* 관로 상세 샘플 3건에 동일한 단위구간 원형을 적용한다.
|
||||
*/
|
||||
export const EXMN_SAMPLE_PPL_UNIT_DETAILS: ExmnPplUnitDetailRow[] =
|
||||
SAMPLE_FACILITIES.flatMap((facility) =>
|
||||
EXMN_SAMPLE_PPL_UNIT_DETAIL_TEMPLATE.map((row) => ({
|
||||
...row,
|
||||
pipeNo: facility.pipeNo,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type ExmnDetailSeverity,
|
||||
type ExmnPplUnitDetailRow,
|
||||
} from "../model/exmn-detail-table";
|
||||
|
||||
const SEVERITY_LABELS: Record<ExmnDetailSeverity, string> = {
|
||||
L: "대",
|
||||
M: "중",
|
||||
S: "소",
|
||||
};
|
||||
|
||||
const COLUMN_WIDTHS = [
|
||||
120,
|
||||
72,
|
||||
82,
|
||||
88,
|
||||
230,
|
||||
62,
|
||||
62,
|
||||
82,
|
||||
82,
|
||||
78,
|
||||
82,
|
||||
82,
|
||||
150,
|
||||
150,
|
||||
] as const;
|
||||
|
||||
interface ExmnPplUnitDetailTableProps {
|
||||
rows: ExmnPplUnitDetailRow[];
|
||||
emptyMessage: string;
|
||||
}
|
||||
|
||||
interface RowSpanInfo {
|
||||
firstIndex: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** 연속된 동일 그룹을 찾아 엑셀의 관로번호·단위구간 병합 셀에 사용할 rowSpan을 계산한다. */
|
||||
const buildRowSpanMap = (
|
||||
rows: ExmnPplUnitDetailRow[],
|
||||
getKey: (row: ExmnPplUnitDetailRow) => string,
|
||||
) => {
|
||||
const result = new Map<string, RowSpanInfo>();
|
||||
rows.forEach((row, index) => {
|
||||
const key = getKey(row);
|
||||
const current = result.get(key);
|
||||
if (current) {
|
||||
current.count += 1;
|
||||
return;
|
||||
}
|
||||
result.set(key, { firstIndex: index, count: 1 });
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
/** 숫자 0은 유지하고 미입력 점수·사유만 엑셀형 하이픈으로 표시한다. */
|
||||
const formatOptionalValue = (value: number | string | null | undefined) =>
|
||||
value == null || value === "" ? "-" : String(value);
|
||||
|
||||
/** 엑셀 `본단위등급표`의 2단 헤더와 단위별 병합 행을 상세보기 팝업에 표시한다. */
|
||||
export function ExmnPplUnitDetailTable({
|
||||
rows,
|
||||
emptyMessage,
|
||||
}: ExmnPplUnitDetailTableProps) {
|
||||
const pipeSpans = buildRowSpanMap(rows, (row) => row.pipeNo);
|
||||
const unitSpans = buildRowSpanMap(
|
||||
rows,
|
||||
(row) => `${row.pipeNo}::${row.unitSeq}`,
|
||||
);
|
||||
const tableWidth = COLUMN_WIDTHS.reduce((sum, width) => sum + width, 0);
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto border border-border bg-background">
|
||||
<table
|
||||
className="border-separate border-spacing-0 text-center text-[1.05rem] text-foreground"
|
||||
style={{ width: `${tableWidth}px`, 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 bg-[#eaf3fb] text-[1.2rem] font-medium leading-[1.2] text-[#647784]">
|
||||
<tr>
|
||||
<th rowSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">관로번호</th>
|
||||
<th rowSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">단위<br />구간</th>
|
||||
<th rowSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">단위<br />길이<br />(m)</th>
|
||||
<th rowSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">결함<br />위치<br />(m)</th>
|
||||
<th colSpan={6} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">결함</th>
|
||||
<th colSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">등급점수(점)</th>
|
||||
<th rowSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">조사중단사유</th>
|
||||
<th rowSpan={2} className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">비고</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">종류</th>
|
||||
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">코드</th>
|
||||
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">등급</th>
|
||||
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">발생<br />위치</th>
|
||||
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">침입수<br />가점</th>
|
||||
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">연속<br />결함</th>
|
||||
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">구조적</th>
|
||||
<th className="border-b border-r border-border bg-[#eaf3fb] px-2 py-2">운영적</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="sn-popup-grid-row sn-popup-grid-row-hover"
|
||||
>
|
||||
{showPipeCell && (
|
||||
<td
|
||||
rowSpan={pipeSpan?.count}
|
||||
className="border-b border-r border-border bg-background px-2 py-2.5 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-2.5 align-middle font-medium"
|
||||
>
|
||||
{row.unitSeq}
|
||||
</td>
|
||||
<td
|
||||
rowSpan={unitSpan?.count}
|
||||
className="border-b border-r border-border bg-background px-2 py-2.5 align-middle"
|
||||
>
|
||||
{row.unitLengthM}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
<td className="border-b border-r border-border px-2 py-2.5">{row.defectPositionM}</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5 text-left">{row.defectName}</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">{row.code}</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">
|
||||
{row.severity ? SEVERITY_LABELS[row.severity] : "-"}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">{formatOptionalValue(row.occurrencePosition)}</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">{row.infiltrationBonus}</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">
|
||||
{row.continuous == null ? "-" : row.continuous ? "Y" : "N"}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">{formatOptionalValue(row.structuralScore)}</td>
|
||||
<td className="border-b border-r border-border px-2 py-2.5">{formatOptionalValue(row.operationalScore)}</td>
|
||||
{showUnitCells && (
|
||||
<>
|
||||
<td
|
||||
rowSpan={unitSpan?.count}
|
||||
className="border-b border-r border-border bg-background px-2 py-2.5 align-middle"
|
||||
>
|
||||
{formatOptionalValue(row.surveyStopReason)}
|
||||
</td>
|
||||
<td
|
||||
rowSpan={unitSpan?.count}
|
||||
className="border-b border-r border-border bg-background px-2 py-2.5 align-middle"
|
||||
>
|
||||
{formatOptionalValue(row.remark)}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
}) : (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={COLUMN_WIDTHS.length}
|
||||
className="sn-popup-empty-cell h-28 border-b border-r border-border text-center"
|
||||
>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import type { UiTableSort } from "@common/business_components/ui";
|
||||
import {
|
||||
downloadExcelWithKoreanHeaders,
|
||||
type UiTableSort,
|
||||
} from "@common/business_components/ui";
|
||||
import { Button, Input } from "@common/components/ui";
|
||||
import { useEffect, useMemo, useState, type UIEvent } from "react";
|
||||
import type { ExmnBusiness, ExmnJobType } from "../model/exmn-business-types";
|
||||
@@ -10,10 +13,15 @@ import {
|
||||
type ExmnDetailDefectColumn,
|
||||
type ExmnDetailDefectType,
|
||||
type ExmnDetailSeverity,
|
||||
type ExmnPplDetailMode,
|
||||
type ExmnSeverityCounts,
|
||||
type ExmnSurveyDetailRow,
|
||||
} from "../model/exmn-detail-table";
|
||||
import { getExmnSampleSurveyDetails } from "../model/exmn-sample-data";
|
||||
import {
|
||||
EXMN_SAMPLE_PPL_UNIT_DETAILS,
|
||||
getExmnSampleSurveyDetails,
|
||||
} from "../model/exmn-sample-data";
|
||||
import { ExmnPplUnitDetailTable } from "./ExmnPplUnitDetailTable";
|
||||
|
||||
const SEVERITY_LABELS: Record<ExmnDetailSeverity, string> = {
|
||||
L: "대",
|
||||
@@ -21,10 +29,10 @@ const SEVERITY_LABELS: Record<ExmnDetailSeverity, string> = {
|
||||
S: "소",
|
||||
};
|
||||
|
||||
/** 상세보기 상단 조회 조건의 조사대상 표시 문구다. */
|
||||
const DETAIL_SCOPE_LABELS: Record<ExmnJobType, string> = {
|
||||
PPL: "관로 / 본단위구간",
|
||||
MNHL: "맨홀",
|
||||
/** 관로 상세보기에서 조회 결과 표를 전환하는 모드 표시 문구다. */
|
||||
const PPL_DETAIL_MODE_LABELS: Record<ExmnPplDetailMode, string> = {
|
||||
PIPE: "관로",
|
||||
UNIT: "본단위구간",
|
||||
};
|
||||
|
||||
const DEFECT_TYPE_LABELS: Record<ExmnDetailDefectType, string> = {
|
||||
@@ -83,6 +91,18 @@ const formatNumber = (value: number) =>
|
||||
const formatCount = (value: number | null | undefined) =>
|
||||
Number(value ?? 0) === 0 ? "-" : Number(value).toLocaleString("ko-KR");
|
||||
|
||||
/** 가변 결함 열 수에 맞춰 엑셀의 마지막 열 이름(A, Z, AA 등)을 계산한다. */
|
||||
const getExcelColumnName = (columnCount: number) => {
|
||||
let remaining = columnCount;
|
||||
let result = "";
|
||||
while (remaining > 0) {
|
||||
remaining -= 1;
|
||||
result = String.fromCharCode(65 + (remaining % 26)) + result;
|
||||
remaining = Math.floor(remaining / 26);
|
||||
}
|
||||
return result || "A";
|
||||
};
|
||||
|
||||
/** 공통 고정 열의 행 값을 화면용 문자열로 변환한다. */
|
||||
const getFixedCellValue = (
|
||||
row: ExmnSurveyDetailRow,
|
||||
@@ -131,6 +151,8 @@ export function ExmnSurveyDetailPopupPage({
|
||||
defectType,
|
||||
sampleMode,
|
||||
}: ExmnSurveyDetailPopupPageProps) {
|
||||
/** 관로 상세보기의 전체 관로 집계와 본단위구간 등급표를 전환하는 표시 모드다. */
|
||||
const [detailMode, setDetailMode] = useState<ExmnPplDetailMode>("PIPE");
|
||||
/** 관로·맨홀 번호 조회 입력값과 실제 적용값을 분리해 조회 버튼 동작을 재현한다. */
|
||||
const [detailNumberKeyword, setDetailNumberKeyword] = useState("");
|
||||
const [appliedDetailNumberKeyword, setAppliedDetailNumberKeyword] = useState("");
|
||||
@@ -142,6 +164,10 @@ export function ExmnSurveyDetailPopupPage({
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
const definition = EXMN_DETAIL_TABLE_DEFINITIONS[jobType];
|
||||
const defectTypeLabel = DEFECT_TYPE_LABELS[defectType];
|
||||
/** 맨홀은 별도 모드가 없으므로 항상 기존 상세 집계표를 사용한다. */
|
||||
const isUnitMode = jobType === "PPL" && detailMode === "UNIT";
|
||||
/** 결함 건수 링크 진입 화면은 조회 조건 없이 선택 분류 결과만 바로 표시한다. */
|
||||
const isDefectTypeView = defectType !== "ALL";
|
||||
/** 건수 링크 진입 시 선택한 구조·운영 그룹의 결함 열만 표에 남긴다. */
|
||||
const visibleDefectColumns = useMemo(
|
||||
() => defectType === "ALL"
|
||||
@@ -154,6 +180,11 @@ export function ExmnSurveyDetailPopupPage({
|
||||
() => (sampleMode ? getExmnSampleSurveyDetails(jobType) : []),
|
||||
[jobType, sampleMode],
|
||||
);
|
||||
/** 본단위구간은 PPL 전용이며 샘플 모드에서 엑셀 기준 단위 기록을 제공한다. */
|
||||
const sourceUnitRows = useMemo(
|
||||
() => (sampleMode && jobType === "PPL" ? EXMN_SAMPLE_PPL_UNIT_DETAILS : []),
|
||||
[jobType, sampleMode],
|
||||
);
|
||||
/** 분류별 상세에서는 선택 결함이 한 건 이상 존재하는 시설 행만 표시한다. */
|
||||
const scopedRows = useMemo(() => {
|
||||
if (defectType === "ALL") return sourceRows;
|
||||
@@ -173,6 +204,15 @@ export function ExmnSurveyDetailPopupPage({
|
||||
row.pipeNo.toLocaleLowerCase("ko-KR").includes(normalizedKeyword),
|
||||
);
|
||||
}, [appliedDetailNumberKeyword, scopedRows]);
|
||||
/** 본단위구간 최초 진입은 전체를 표시하고, 관리번호가 있으면 부분 일치로 좁힌다. */
|
||||
const unitRows = useMemo(() => {
|
||||
if (!isUnitMode) return [];
|
||||
const normalizedKeyword = appliedDetailNumberKeyword.trim().toLocaleLowerCase("ko-KR");
|
||||
if (!normalizedKeyword) return sourceUnitRows;
|
||||
return sourceUnitRows.filter(
|
||||
(row) => row.pipeNo.toLocaleLowerCase("ko-KR").includes(normalizedKeyword),
|
||||
);
|
||||
}, [appliedDetailNumberKeyword, isUnitMode, sourceUnitRows]);
|
||||
/** 보고서번호부터 주행거리까지의 고정 열에 선택 정렬을 적용한다. */
|
||||
const rows = useMemo(() => {
|
||||
if (!sort) return filteredRows;
|
||||
@@ -200,6 +240,7 @@ export function ExmnSurveyDetailPopupPage({
|
||||
|
||||
/** 관로·맨홀 조사유형 또는 결함 범위가 바뀌면 이전 검색 조건과 안내를 초기화한다. */
|
||||
useEffect(() => {
|
||||
setDetailMode("PIPE");
|
||||
setDetailNumberKeyword("");
|
||||
setAppliedDetailNumberKeyword("");
|
||||
setSort(undefined);
|
||||
@@ -212,6 +253,17 @@ export function ExmnSurveyDetailPopupPage({
|
||||
setNotice(null);
|
||||
};
|
||||
|
||||
/** 모드를 바꾸면 이전 표의 관리번호·정렬·안내 상태를 초기화한다. */
|
||||
const handleDetailModeChange = (nextMode: ExmnPplDetailMode) => {
|
||||
if (nextMode === detailMode) return;
|
||||
setDetailMode(nextMode);
|
||||
setDetailNumberKeyword("");
|
||||
setAppliedDetailNumberKeyword("");
|
||||
setSort(undefined);
|
||||
setTableScrollLeft(0);
|
||||
setNotice(null);
|
||||
};
|
||||
|
||||
/** 공통 UiTable 규칙대로 asc, desc, 기본 순서로 고정 열 정렬을 순환한다. */
|
||||
const handleSortChange = (columnId: ExmnDetailFixedColumnId) => {
|
||||
if (sort?.key !== columnId) {
|
||||
@@ -237,14 +289,6 @@ export function ExmnSurveyDetailPopupPage({
|
||||
SUMMARY_LABEL_INITIAL_OFFSET - tableScrollLeft,
|
||||
);
|
||||
|
||||
/** 실제 엑셀 다운로드 API 연동 전 사용자에게 후속 연결 상태를 안내한다. */
|
||||
const handleDownload = () => {
|
||||
setNotice({
|
||||
type: "info",
|
||||
text: "엑셀 다운로드는 실제 상세 API 연동 단계에서 연결할 예정입니다.",
|
||||
});
|
||||
};
|
||||
|
||||
const totals = useMemo(
|
||||
() => rows.reduce(
|
||||
(sum, row) => ({
|
||||
@@ -308,6 +352,192 @@ export function ExmnSurveyDetailPopupPage({
|
||||
};
|
||||
}, [rows, visibleDefectColumns]);
|
||||
|
||||
/** 현재 화면에 표시된 관로·맨홀 상세 결과를 한글 헤더 엑셀로 내려받는다. */
|
||||
const handleDownload = async () => {
|
||||
const downloadDate = new Date().toISOString().slice(0, 10);
|
||||
const safeFilePrefix = `${trtdstNm}_${definition.defectGroupTitle}조사_${
|
||||
isUnitMode ? "본단위등급표" : defectTypeLabel
|
||||
}`.replace(/[\\/:*?"<>|]/g, "_");
|
||||
|
||||
try {
|
||||
if (isUnitMode) {
|
||||
if (unitRows.length === 0) {
|
||||
setNotice({ type: "info", text: "다운로드할 본단위구간 데이터가 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const unitHeaderMapping: Record<string, string> = {
|
||||
pipeNo: "관로번호",
|
||||
unitSeq: "단위구간",
|
||||
unitLengthM: "단위길이(m)",
|
||||
defectPositionM: "결함위치(m)",
|
||||
defectName: "결함 종류",
|
||||
code: "결함 코드",
|
||||
severity: "결함 등급",
|
||||
occurrencePosition: "발생위치",
|
||||
infiltrationBonus: "침입수 가점",
|
||||
continuous: "연속결함",
|
||||
structuralScore: "구조적 등급점수",
|
||||
operationalScore: "운영적 등급점수",
|
||||
surveyStopReason: "조사중단사유",
|
||||
remark: "비고",
|
||||
};
|
||||
const unitDownloadRows = unitRows.map((row) => ({
|
||||
pipeNo: row.pipeNo,
|
||||
unitSeq: row.unitSeq,
|
||||
unitLengthM: row.unitLengthM,
|
||||
defectPositionM: row.defectPositionM,
|
||||
defectName: row.defectName,
|
||||
code: row.code,
|
||||
severity: row.severity ? SEVERITY_LABELS[row.severity] : "-",
|
||||
occurrencePosition: row.occurrencePosition,
|
||||
infiltrationBonus: row.infiltrationBonus,
|
||||
continuous: row.continuous == null ? "-" : row.continuous ? "Y" : "N",
|
||||
structuralScore: row.structuralScore ?? "-",
|
||||
operationalScore: row.operationalScore ?? "-",
|
||||
surveyStopReason: row.surveyStopReason ?? "-",
|
||||
remark: row.remark ?? "-",
|
||||
}));
|
||||
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: unitDownloadRows as unknown as JSON[],
|
||||
fileName: `${safeFilePrefix}_${downloadDate}.xlsx`,
|
||||
sheetNameArray: ["본단위등급표"],
|
||||
headerRange: "A1:N1",
|
||||
headerMapping: unitHeaderMapping,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
setNotice({ type: "info", text: "다운로드할 상세현황 데이터가 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const detailHeaderMapping: Record<string, string> = {
|
||||
reportNo: "보고서번호",
|
||||
pipeNo: jobType === "PPL" ? "관로번호" : "맨홀번호",
|
||||
upstreamManhole: "상류맨홀",
|
||||
downstreamManhole: "하류맨홀",
|
||||
drainageType: "배수방식",
|
||||
pipeType: "관종",
|
||||
specification: "규격",
|
||||
extensionM: "연장(m)",
|
||||
drivingDistanceM: "주행거리(m)",
|
||||
anomalyL: "이상항목 합계-대",
|
||||
anomalyM: "이상항목 합계-중",
|
||||
anomalyS: "이상항목 합계-소",
|
||||
};
|
||||
visibleDefectColumns.forEach((column) => {
|
||||
EXMN_DETAIL_SEVERITIES.forEach((severity) => {
|
||||
detailHeaderMapping[`${column.code}_${severity}`] =
|
||||
`${column.label} (${column.code})-${SEVERITY_LABELS[severity]}`;
|
||||
});
|
||||
});
|
||||
|
||||
const detailDownloadRows: Record<string, string | number>[] = rows.map((row) => {
|
||||
const anomalyTotals = getVisibleAnomalyTotals(row, visibleDefectColumns);
|
||||
const result: Record<string, string | number> = {
|
||||
reportNo: row.reportNo,
|
||||
pipeNo: row.pipeNo,
|
||||
upstreamManhole: row.upstreamManhole,
|
||||
downstreamManhole: row.downstreamManhole,
|
||||
drainageType: row.drainageType,
|
||||
pipeType: row.pipeType,
|
||||
specification: row.specification,
|
||||
extensionM: row.extensionM,
|
||||
drivingDistanceM: row.drivingDistanceM,
|
||||
anomalyL: anomalyTotals.L,
|
||||
anomalyM: anomalyTotals.M,
|
||||
anomalyS: anomalyTotals.S,
|
||||
};
|
||||
visibleDefectColumns.forEach((column) => {
|
||||
EXMN_DETAIL_SEVERITIES.forEach((severity) => {
|
||||
result[`${column.code}_${severity}`] =
|
||||
row.defectCounts[column.code]?.[severity] ?? 0;
|
||||
});
|
||||
});
|
||||
return result;
|
||||
});
|
||||
const subtotalRow: Record<string, string | number> = {
|
||||
reportNo: "소계",
|
||||
pipeNo: "",
|
||||
upstreamManhole: "",
|
||||
downstreamManhole: "",
|
||||
drainageType: "",
|
||||
pipeType: "",
|
||||
specification: "",
|
||||
extensionM: totals.extensionM,
|
||||
drivingDistanceM: totals.drivingDistanceM,
|
||||
anomalyL: defectSummary.anomalySubtotals.L,
|
||||
anomalyM: defectSummary.anomalySubtotals.M,
|
||||
anomalyS: defectSummary.anomalySubtotals.S,
|
||||
};
|
||||
const totalRow: Record<string, string | number> = {
|
||||
reportNo: "합계",
|
||||
pipeNo: "",
|
||||
upstreamManhole: "",
|
||||
downstreamManhole: "",
|
||||
drainageType: "",
|
||||
pipeType: "",
|
||||
specification: "",
|
||||
extensionM: totals.extensionM,
|
||||
drivingDistanceM: totals.drivingDistanceM,
|
||||
anomalyL: defectSummary.anomalyTotal,
|
||||
anomalyM: "",
|
||||
anomalyS: "",
|
||||
};
|
||||
visibleDefectColumns.forEach((column) => {
|
||||
EXMN_DETAIL_SEVERITIES.forEach((severity) => {
|
||||
subtotalRow[`${column.code}_${severity}`] =
|
||||
defectSummary.defectSubtotals[column.code]?.[severity] ?? 0;
|
||||
});
|
||||
totalRow[`${column.code}_L`] =
|
||||
defectSummary.defectTotals[column.code] ?? 0;
|
||||
totalRow[`${column.code}_M`] = "";
|
||||
totalRow[`${column.code}_S`] = "";
|
||||
});
|
||||
detailDownloadRows.push(subtotalRow, totalRow);
|
||||
|
||||
const subtotalExcelRow = rows.length + 2;
|
||||
const totalExcelRow = rows.length + 3;
|
||||
const mergeRanges = [
|
||||
`A${subtotalExcelRow}:G${subtotalExcelRow}`,
|
||||
`A${totalExcelRow}:G${totalExcelRow}`,
|
||||
];
|
||||
for (
|
||||
let groupStartColumn = 10;
|
||||
groupStartColumn < Object.keys(detailHeaderMapping).length;
|
||||
groupStartColumn += EXMN_DETAIL_SEVERITIES.length
|
||||
) {
|
||||
mergeRanges.push(
|
||||
`${getExcelColumnName(groupStartColumn)}${totalExcelRow}:${
|
||||
getExcelColumnName(groupStartColumn + EXMN_DETAIL_SEVERITIES.length - 1)
|
||||
}${totalExcelRow}`,
|
||||
);
|
||||
}
|
||||
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: detailDownloadRows as unknown as JSON[],
|
||||
fileName: `${safeFilePrefix}_${downloadDate}.xlsx`,
|
||||
sheetNameArray: [
|
||||
`${definition.defectGroupTitle}조사 ${defectTypeLabel}`.slice(0, 31),
|
||||
],
|
||||
headerRange: `A1:${getExcelColumnName(Object.keys(detailHeaderMapping).length)}1`,
|
||||
headerMapping: detailHeaderMapping,
|
||||
mergeRanges,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("조사 상세현황 엑셀 다운로드 오류:", error);
|
||||
setNotice({
|
||||
type: "info",
|
||||
text: error instanceof Error
|
||||
? error.message
|
||||
: "엑셀 다운로드 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const tableWidth = useMemo(
|
||||
() => FIXED_COLUMNS.reduce((sum, column) => sum + column.width, 0)
|
||||
+ (visibleDefectColumns.length + 1)
|
||||
@@ -315,12 +545,21 @@ export function ExmnSurveyDetailPopupPage({
|
||||
* SEVERITY_COLUMN_WIDTH,
|
||||
[visibleDefectColumns.length],
|
||||
);
|
||||
const popupTitle = defectType === "ALL"
|
||||
? definition.title
|
||||
: `${definition.defectGroupTitle}조사 ${defectTypeLabel} 상세현황`;
|
||||
const tableTitle = defectType === "ALL"
|
||||
? `${trtdstNm} ${definition.defectGroupTitle}조사 집계표`
|
||||
: `${trtdstNm} ${definition.defectGroupTitle}조사 ${defectTypeLabel} 집계표`;
|
||||
/** 본단위구간 요약은 조회된 한 관로의 단위 수와 상태·결함 기록 수를 사용한다. */
|
||||
const unitSectionCount = useMemo(
|
||||
() => new Set(unitRows.map((row) => `${row.pipeNo}::${row.unitSeq}`)).size,
|
||||
[unitRows],
|
||||
);
|
||||
const popupTitle = isUnitMode
|
||||
? "관로조사 본단위구간 상세현황"
|
||||
: defectType === "ALL"
|
||||
? definition.title
|
||||
: `${definition.defectGroupTitle}조사 ${defectTypeLabel} 상세현황`;
|
||||
const tableTitle = isUnitMode
|
||||
? `${trtdstNm} 관로조사 본단위등급표`
|
||||
: defectType === "ALL"
|
||||
? `${trtdstNm} ${definition.defectGroupTitle}조사 집계표`
|
||||
: `${trtdstNm} ${definition.defectGroupTitle}조사 ${defectTypeLabel} 집계표`;
|
||||
const visibleDefectGroupTitle = defectType === "ALL"
|
||||
? definition.defectGroupTitle
|
||||
: `${definition.defectGroupTitle} ${defectTypeLabel}`;
|
||||
@@ -338,73 +577,96 @@ export function ExmnSurveyDetailPopupPage({
|
||||
)}
|
||||
|
||||
<section className="sn-popup-section overflow-y-hidden">
|
||||
<div
|
||||
className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:items-end"
|
||||
style={{ gridTemplateColumns: "180px 220px 300px minmax(0, 1fr)" }}
|
||||
>
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">처리분구</div>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
defaultValue={trtdstId}
|
||||
aria-label="처리분구"
|
||||
>
|
||||
<option value={trtdstId}>{trtdstNm}</option>
|
||||
</select>
|
||||
</label>
|
||||
{!isDefectTypeView && (
|
||||
<div className="flex flex-wrap items-end justify-between gap-4 border-b border-slate-200 bg-slate-50 pb-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="sn-popup-field" style={{ width: "180px" }}>
|
||||
<div className="sn-popup-field-label">처리분구</div>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
defaultValue={trtdstId}
|
||||
aria-label="처리분구"
|
||||
>
|
||||
<option value={trtdstId}>{trtdstNm}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">조사대상</div>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
defaultValue={jobType}
|
||||
aria-label="조사대상"
|
||||
>
|
||||
<option value={jobType}>
|
||||
{DETAIL_SCOPE_LABELS[jobType]}
|
||||
{defectType === "ALL" ? "" : ` / ${defectTypeLabel}`}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">
|
||||
{jobType === "PPL" ? "관로번호" : "맨홀번호"}
|
||||
</div>
|
||||
<Input
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={detailNumberKeyword}
|
||||
placeholder={`${jobType === "PPL" ? "관로" : "맨홀"}번호 입력`}
|
||||
onChange={(event) => setDetailNumberKeyword(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") handleSearch();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="sn-popup-actions">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="sn-popup-primary-button !text-[1.1rem]"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="green"
|
||||
className="sn-popup-primary-button !text-[1.1rem]"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
다운로드
|
||||
</Button>
|
||||
<label className="sn-popup-field" style={{ width: "300px" }}>
|
||||
<div className="sn-popup-field-label">관리번호</div>
|
||||
<Input
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
value={detailNumberKeyword}
|
||||
placeholder="관리번호 입력"
|
||||
onChange={(event) => setDetailNumberKeyword(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") handleSearch();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!sampleMode && (
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
{jobType === "PPL" && (
|
||||
<fieldset className="sn-popup-field" style={{ width: "180px" }}>
|
||||
<div
|
||||
className="relative grid h-[2.8rem] grid-cols-2 rounded-full border border-[#cbd5e1] bg-white p-[2px] shadow-inner"
|
||||
role="group"
|
||||
aria-label="관로 상세보기 모드"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute bottom-[2px] left-[2px] top-[2px] w-[calc(50%-2px)] rounded-full bg-primary shadow-sm transition-transform duration-200 ease-out"
|
||||
style={{
|
||||
transform: detailMode === "UNIT"
|
||||
? "translateX(100%)"
|
||||
: "translateX(0)",
|
||||
}}
|
||||
/>
|
||||
{(Object.keys(PPL_DETAIL_MODE_LABELS) as ExmnPplDetailMode[]).map(
|
||||
(mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
className={[
|
||||
"relative z-10 rounded-full px-2 text-[0.95rem] font-semibold transition-colors duration-200",
|
||||
detailMode === mode ? "text-white" : "text-[#64748b]",
|
||||
].join(" ")}
|
||||
aria-pressed={detailMode === mode}
|
||||
onClick={() => handleDetailModeChange(mode)}
|
||||
>
|
||||
{PPL_DETAIL_MODE_LABELS[mode]}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<div className="sn-popup-actions">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="sn-popup-primary-button !text-[1.1rem]"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="green"
|
||||
className="sn-popup-primary-button !text-[1.1rem]"
|
||||
onClick={() => void handleDownload()}
|
||||
>
|
||||
다운로드
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!sampleMode && !isDefectTypeView && (
|
||||
<div className="shrink-0 border-b border-border bg-secondary px-4 py-2 text-[1.05rem] text-primary">
|
||||
실제 상세 데이터 API는 후속 백엔드 연동 단계에서 연결합니다. 화면 샘플은 `sample=Y`에서 확인할 수 있습니다.
|
||||
</div>
|
||||
@@ -416,19 +678,49 @@ export function ExmnSurveyDetailPopupPage({
|
||||
{tableTitle}
|
||||
</h2>
|
||||
<span className="truncate text-[1.05rem] text-label">
|
||||
조회 결과 총 {rows.length.toLocaleString("ko-KR")}건
|
||||
조회 결과 총 {(isUnitMode ? unitRows.length : rows.length).toLocaleString("ko-KR")}건
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-7 whitespace-nowrap text-[1.05rem] text-label">
|
||||
<span><strong className="mr-2 text-foreground">연장거리 합계</strong>{formatNumber(totals.extensionM)}m</span>
|
||||
<span><strong className="mr-2 text-foreground">주행거리 합계</strong>{formatNumber(totals.drivingDistanceM)}m</span>
|
||||
{isUnitMode ? (
|
||||
<>
|
||||
<span><strong className="mr-2 text-foreground">관리번호</strong>{appliedDetailNumberKeyword || "전체"}</span>
|
||||
<span><strong className="mr-2 text-foreground">단위구간</strong>{unitSectionCount.toLocaleString("ko-KR")}개</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span><strong className="mr-2 text-foreground">연장거리 합계</strong>{formatNumber(totals.extensionM)}m</span>
|
||||
<span><strong className="mr-2 text-foreground">주행거리 합계</strong>{formatNumber(totals.drivingDistanceM)}m</span>
|
||||
{isDefectTypeView && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="green"
|
||||
className="sn-popup-primary-button !ml-1 !text-[1.05rem]"
|
||||
onClick={() => void handleDownload()}
|
||||
>
|
||||
다운로드
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-auto border border-border bg-background"
|
||||
onScroll={handleTableScroll}
|
||||
>
|
||||
{isUnitMode ? (
|
||||
<ExmnPplUnitDetailTable
|
||||
rows={unitRows}
|
||||
emptyMessage={
|
||||
appliedDetailNumberKeyword
|
||||
? "입력한 관리번호에 해당하는 본단위구간이 없습니다."
|
||||
: "조회된 본단위구간이 없습니다."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-auto border border-border bg-background"
|
||||
onScroll={handleTableScroll}
|
||||
>
|
||||
<table
|
||||
className="border-separate border-spacing-0 text-center text-[1.05rem] text-foreground"
|
||||
style={{ width: `${tableWidth}px`, minWidth: `${tableWidth}px` }}
|
||||
@@ -732,7 +1024,8 @@ export function ExmnSurveyDetailPopupPage({
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user