sn: 노후하수관로정비사업1
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
|
||||
1. 목록 전체 23개 열과 가로 스크롤 확인
|
||||
2. 검색·페이지 이동 확인
|
||||
3. 제공 원본 xlsx 업로드 후 517건 표시 확인
|
||||
3. 새 `_upload.xlsx`의 헤더 오류 없이 FE에서 517건 파싱되고 네트워크 요청이 JSON 배열인지 확인
|
||||
4. 엑셀다운로드 후 두 시트 및 재업로드 확인
|
||||
5. 등록 저장, 행 클릭 수정, 수정 화면 삭제 확인
|
||||
6. 목록에 삭제 버튼이 없고 수정 하단 버튼 순서가 `목록`, `저장`, `삭제`인지 확인
|
||||
|
||||
@@ -8,3 +8,10 @@
|
||||
- 최종 전체 빌드는 신규 화면이 아닌 공통 `file-uploader`, `slider`, `switch`, `table`, `tabs` CSS 처리 중 Windows `EMFILE: too many open files`로 실패했다.
|
||||
- 로그인·실 API 브라우저 검증은 실행환경 미기동으로 보류한다.
|
||||
- 지역 검색 유지보수 후 `pnpm exec eslint apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx`를 실행해 오류·경고 없이 통과했다.
|
||||
|
||||
## 2026-08-04
|
||||
|
||||
- 새 업로드 양식의 두 시트 B6:V9를 값 검사와 렌더링으로 확인했으며 수정 헤더가 정상 표시됨을 확인했다.
|
||||
- 실제 FE 파서를 실행해 총 517건, 정밀조사 128건, 정비사업 389건을 변환하고 대표 사업코드·연장 값을 대조했다.
|
||||
- 변경한 API·Excel 파서·목록 파일 대상 ESLint를 오류·경고 없이 통과했다.
|
||||
- SN `next dev` 프로세스가 실행 중이어서 앱 규칙에 따라 전체 `next build`는 실행하지 않았다.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
- 목록 행 클릭은 `/oper/obsltPplBiz/register/{id}`로 이동한다.
|
||||
- 목록에는 삭제 버튼을 두지 않는다.
|
||||
- 등록 화면 하단은 `목록`, `저장`; 수정 화면은 `목록`, `저장`, `삭제` 순서다.
|
||||
- 조사관리의 multipart 흐름만 재사용하고 조사 결과 전용 validate/commit·첨부서비스 코드는 공유하지 않는다.
|
||||
- `apps/wr /matMng/coMat/ruseCaseMng`의 브라우저 Excel 파싱·JSON 저장 흐름을 참고하되 SN 전용 두 시트·병합 헤더 계약으로 구현한다.
|
||||
- `apps/cs/banner/register`의 표형 폼과 별도 등록/수정 경로를 시각 참고한다.
|
||||
- 목록 검색의 시도·시군구는 SN 앱 공통 `useStore`의 지역 목록을 사용하는 연계 `SelectBox`로 제공하고, 시도 변경 시 시군구를 초기화한다.
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
- 목록 로딩·오류·업로드 결과 건수는 화면에서 문구로 관측 가능해야 한다.
|
||||
- 업로드 input은 `.xlsx`만 받는다.
|
||||
- FE는 `노후하수관로 정밀조사 및 정비사업 현황_upload.xlsx`의 두 시트와 B6:V7 핵심 헤더를 검증하고 B8:V 마지막 행을 JSON 저장 요청으로 변환한다.
|
||||
- 다운로드 버튼명은 `엑셀다운로드`로 고정한다.
|
||||
- 브라우저 검증 URL: `/oper/obsltPplBiz`, `/oper/obsltPplBiz/register`, `/oper/obsltPplBiz/register/{id}`
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
## BE 공유 계약
|
||||
|
||||
기준 API는 `/api/v1/obsltPplBiz`이며 목록은 Spring Page, 업로드는 multipart `file`, 다운로드는 XLSX Blob이다.
|
||||
기준 API는 `/api/v1/obsltPplBiz`이며 목록은 Spring Page, 업로드는 FE 파싱 결과 JSON 배열, 다운로드는 XLSX Blob이다.
|
||||
|
||||
## 남은 리스크
|
||||
|
||||
@@ -26,3 +26,10 @@
|
||||
- 목록의 시도·시군 입력을 공통 `SelectBox`로 교체했다.
|
||||
- SN 앱 공통 Zustand store의 `ctpv`, `sgg`, `fetchCtpv`, `fetchSgg`를 사용하고 선택 시 기존 검색 API 계약에 맞는 한글명을 설정한다.
|
||||
- 선택한 시도의 코드 앞 두 자리를 기준으로 시군구 후보를 제한하고 시도 변경 시 기존 시군구 선택을 초기화한다.
|
||||
|
||||
## 2026-08-04 Excel 업로드 FE 파싱 전환
|
||||
|
||||
- `apps/wr /matMng/coMat/ruseCaseMng` 흐름을 참고하여 `.xlsx` 읽기와 값 파싱 책임을 FE로 이동했다.
|
||||
- `lib/obslt-ppl-excel.ts`에서 두 필수 시트, B6:V7 핵심 헤더, 필수 사업명, 순번·연장 숫자를 검증한다.
|
||||
- `노후하수관로 정밀조사 및 정비사업 현황_upload.xlsx`의 517개 행을 저장 요청 JSON으로 변환한 뒤 `/excel/upload`에 `application/json`으로 전송한다.
|
||||
- 기존 multipart `FormData` 전송과 `postMutiPart` 의존을 제거했다.
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
deleteData,
|
||||
getBlobData,
|
||||
getData,
|
||||
postMutiPart,
|
||||
postRawJson,
|
||||
putData,
|
||||
} from "@/components/lib/api";
|
||||
@@ -50,12 +49,11 @@ export const deleteObsltPplBusiness = async (id: string): Promise<void> => {
|
||||
await deleteData(`${API_PATH}/${encodeURIComponent(id)}`);
|
||||
};
|
||||
|
||||
/** 원본 형식 xlsx 한 건을 두 시트 스냅샷으로 교체 저장한다. */
|
||||
export const uploadObsltPplExcel = async (file: File): Promise<ObsltPplImportResult> => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return postMutiPart(`${API_PATH}/excel/upload`, formData) as Promise<ObsltPplImportResult>;
|
||||
};
|
||||
/** 브라우저에서 파싱한 두 시트 JSON을 활성 스냅샷으로 교체 저장한다. */
|
||||
export const uploadObsltPplExcel = async (
|
||||
rows: ObsltPplBusinessSaveRequest[],
|
||||
): Promise<ObsltPplImportResult> =>
|
||||
unwrapData<ObsltPplImportResult>(await postRawJson(`${API_PATH}/excel/upload`, rows));
|
||||
|
||||
/** 현재 활성 자료를 원본과 호환되는 xlsx로 내려받는다. */
|
||||
export const downloadObsltPplExcel = async (): Promise<Blob> =>
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
import type { ObsltPplBusinessSaveRequest } from "../model/obslt-ppl-types";
|
||||
|
||||
type BusinessType = ObsltPplBusinessSaveRequest["bizSe"];
|
||||
|
||||
type SheetContract = {
|
||||
sheetName: BusinessType;
|
||||
businessCodeHeader: string;
|
||||
extensionHeader: string;
|
||||
progressHeader: string;
|
||||
startDateHeader: string;
|
||||
};
|
||||
|
||||
const SHEET_CONTRACTS: SheetContract[] = [
|
||||
{
|
||||
sheetName: "정밀조사",
|
||||
businessCodeHeader: "정밀조사사업코드",
|
||||
extensionHeader: "조사연장(km)",
|
||||
progressHeader: "정밀조사추진현황",
|
||||
startDateHeader: "착수(예정)일자(yyyy.mm.dd)",
|
||||
},
|
||||
{
|
||||
sheetName: "정비사업",
|
||||
businessCodeHeader: "정비사업사업코드",
|
||||
extensionHeader: "정비연장(km)",
|
||||
progressHeader: "정비사업추진현황",
|
||||
startDateHeader: "착공(예정)일자(yyyy.mm.dd)",
|
||||
},
|
||||
];
|
||||
|
||||
const DATA_START_ROW_INDEX = 7;
|
||||
const FIRST_DATA_COLUMN_INDEX = 1;
|
||||
const DATA_COLUMN_COUNT = 21;
|
||||
|
||||
/** 줄바꿈과 공백이 포함된 병합 헤더를 비교 가능한 문자열로 정규화한다. */
|
||||
const normalizeHeader = (value: string) => value.replace(/\s+/g, "").trim();
|
||||
|
||||
/** SheetJS 셀의 화면 표시값을 원본 양식과 같은 문자열로 읽는다. */
|
||||
const cellText = (sheet: XLSX.WorkSheet, rowIndex: number, columnIndex: number) => {
|
||||
const cell = sheet[XLSX.utils.encode_cell({ r: rowIndex, c: columnIndex })];
|
||||
return cell ? XLSX.utils.format_cell(cell).trim() : "";
|
||||
};
|
||||
|
||||
/** 새 업로드 양식의 공통·업무별 핵심 헤더가 정확한 위치에 있는지 확인한다. */
|
||||
const validateHeaders = (sheet: XLSX.WorkSheet, contract: SheetContract) => {
|
||||
const expectedHeaders: Array<[number, number, string]> = [
|
||||
[5, 1, "순번"], [5, 2, "정밀조사차수"], [5, 3, contract.businessCodeHeader],
|
||||
[5, 4, "시도"], [5, 5, "시군"], [5, 6, "환경청"], [5, 7, "사업명"],
|
||||
[5, 8, "추진현황"], [5, 9, "착수년도"], [5, 10, "준공(예정)년도"],
|
||||
[5, 11, contract.extensionHeader], [6, 11, "계획연장(km)"],
|
||||
[6, 12, "상반기(예상)연장(km)"], [6, 13, "잔여연장(km)"],
|
||||
[6, 14, "증빙서류제출여부"], [5, 15, contract.progressHeader],
|
||||
[6, 15, contract.startDateHeader], [6, 16, "준공(예정)일자(yyyy.mm.dd)"],
|
||||
[5, 17, "사업추진부진사유"], [5, 18, "사업담당자(지자체)"],
|
||||
[6, 18, "이름"], [6, 19, "연락처"], [5, 20, "비고"], [6, 21, "공단의견"],
|
||||
];
|
||||
|
||||
for (const [rowIndex, columnIndex, expected] of expectedHeaders) {
|
||||
const actual = normalizeHeader(cellText(sheet, rowIndex, columnIndex));
|
||||
if (actual !== normalizeHeader(expected)) {
|
||||
const address = XLSX.utils.encode_cell({ r: rowIndex, c: columnIndex });
|
||||
throw new Error(
|
||||
`${contract.sheetName} 시트 ${address} 헤더가 올바르지 않습니다. 기대값: '${expected}', 실제값: '${actual || "(빈 값)"}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** 빈 셀과 대시 표기를 API의 null 값으로 정규화한다. */
|
||||
const optionalText = (value: string): string | null => {
|
||||
const normalized = value.trim();
|
||||
return !normalized || normalized === "-" ? null : normalized;
|
||||
};
|
||||
|
||||
/** 필수 문자열을 검사하고 시트·행이 포함된 오류를 제공한다. */
|
||||
const requiredText = (value: string, sheetName: string, rowNumber: number, fieldName: string) => {
|
||||
const normalized = optionalText(value);
|
||||
if (!normalized) throw new Error(`${sheetName} 시트 ${rowNumber}행의 ${fieldName} 값은 필수입니다.`);
|
||||
return normalized;
|
||||
};
|
||||
|
||||
/** 순번 셀을 안전한 정수로 변환한다. */
|
||||
const optionalInteger = (value: string, sheetName: string, rowNumber: number): number | null => {
|
||||
const normalized = optionalText(value)?.replace(/,/g, "");
|
||||
if (!normalized) return null;
|
||||
const parsed = Number(normalized);
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
throw new Error(`${sheetName} 시트 ${rowNumber}행의 순번 값이 정수가 아닙니다.`);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
/** 연장 셀의 콤마와 km 단위를 제거하고 유한한 숫자로 변환한다. */
|
||||
const optionalDecimal = (value: string, sheetName: string, rowNumber: number, fieldName: string): number | null => {
|
||||
const normalized = optionalText(value)?.replace(/,/g, "").replace(/km/gi, "").trim();
|
||||
if (!normalized) return null;
|
||||
const parsed = Number(normalized);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new Error(`${sheetName} 시트 ${rowNumber}행의 ${fieldName} 값이 숫자가 아닙니다.`);
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
/** 연도 셀은 DB 계약에 맞게 최초 4자리 연도를 보존한다. */
|
||||
const yearText = (value: string): string | null => {
|
||||
const normalized = optionalText(value);
|
||||
if (!normalized) return null;
|
||||
return normalized.match(/(19|20)\d{2}/)?.[0] ?? normalized.slice(0, 4);
|
||||
};
|
||||
|
||||
/** 혼합 날짜 표기에서 숫자를 추출하고 DB 최대 길이 8자로 제한한다. */
|
||||
const dateText = (value: string): string | null => {
|
||||
const normalized = optionalText(value);
|
||||
if (!normalized) return null;
|
||||
const digits = normalized.replace(/[^0-9]/g, "");
|
||||
return (digits || normalized).slice(0, 8);
|
||||
};
|
||||
|
||||
/** B~V가 모두 비어 있는 행은 데이터가 아닌 양식 여백으로 판단한다. */
|
||||
const isEmptyDataRow = (sheet: XLSX.WorkSheet, rowIndex: number) =>
|
||||
Array.from({ length: DATA_COLUMN_COUNT }, (_, offset) =>
|
||||
cellText(sheet, rowIndex, FIRST_DATA_COLUMN_INDEX + offset),
|
||||
).every((value) => !value.trim());
|
||||
|
||||
/** 한 업무 시트의 데이터행을 JSON 저장 요청으로 변환한다. */
|
||||
const parseSheet = (sheet: XLSX.WorkSheet, contract: SheetContract): ObsltPplBusinessSaveRequest[] => {
|
||||
validateHeaders(sheet, contract);
|
||||
const range = XLSX.utils.decode_range(sheet["!ref"] ?? "A1:A1");
|
||||
const result: ObsltPplBusinessSaveRequest[] = [];
|
||||
|
||||
for (let rowIndex = DATA_START_ROW_INDEX; rowIndex <= range.e.r; rowIndex += 1) {
|
||||
if (isEmptyDataRow(sheet, rowIndex)) continue;
|
||||
const values = Array.from({ length: DATA_COLUMN_COUNT }, (_, offset) =>
|
||||
cellText(sheet, rowIndex, FIRST_DATA_COLUMN_INDEX + offset),
|
||||
);
|
||||
const rowNumber = rowIndex + 1;
|
||||
const bizSeq = optionalInteger(values[0], contract.sheetName, rowNumber);
|
||||
const originalBizCd = optionalText(values[2]);
|
||||
const fallbackType = contract.sheetName === "정밀조사" ? "S" : "M";
|
||||
|
||||
result.push({
|
||||
bizSe: contract.sheetName,
|
||||
bizSeq,
|
||||
prcisExmnCycl: optionalText(values[1]),
|
||||
bizCd: originalBizCd ?? `UNASSIGNED-${fallbackType}-${bizSeq ?? rowNumber}`,
|
||||
sggCd: null,
|
||||
ctpvNm: optionalText(values[3]),
|
||||
sigunNm: optionalText(values[4]),
|
||||
envoNm: optionalText(values[5]),
|
||||
bizNm: requiredText(values[6], contract.sheetName, rowNumber, "사업명"),
|
||||
prplPrst: optionalText(values[7]),
|
||||
bgngYr: yearText(values[8]),
|
||||
cmcnYr: yearText(values[9]),
|
||||
planPrlg: optionalDecimal(values[10], contract.sheetName, rowNumber, "계획연장(km)"),
|
||||
frhfyrExpcPrlg: optionalDecimal(values[11], contract.sheetName, rowNumber, "상반기(예상)연장(km)"),
|
||||
rmndPrlg: optionalDecimal(values[12], contract.sheetName, rowNumber, "잔여연장(km)"),
|
||||
evdncDcmntSbmsnStts: optionalText(values[13]),
|
||||
bgngYmd: dateText(values[14]),
|
||||
cmcnYmd: dateText(values[15]),
|
||||
bizPrplDlyRsnCn: optionalText(values[16]),
|
||||
bizPicNm: optionalText(values[17]),
|
||||
bizPicCnpl: optionalText(values[18]),
|
||||
rmrkCn: optionalText(values[19]),
|
||||
pbcpOpnnCn: optionalText(values[20]),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/** ArrayBuffer workbook을 검증·파싱하여 두 시트의 JSON 저장 요청을 반환한다. */
|
||||
export const parseObsltPplWorkbook = (workbookData: ArrayBuffer | Uint8Array): ObsltPplBusinessSaveRequest[] => {
|
||||
const workbook = XLSX.read(workbookData, { type: "array", cellDates: false });
|
||||
const rows = SHEET_CONTRACTS.flatMap((contract) => {
|
||||
const sheet = workbook.Sheets[contract.sheetName];
|
||||
if (!sheet) throw new Error(`필수 시트가 없습니다: ${contract.sheetName}`);
|
||||
return parseSheet(sheet, contract);
|
||||
});
|
||||
if (rows.length === 0) throw new Error("업로드할 사업 자료가 없습니다.");
|
||||
return rows;
|
||||
};
|
||||
|
||||
/** 사용자가 선택한 xlsx 파일을 브라우저에서 읽고 JSON 요청 목록으로 변환한다. */
|
||||
export const parseObsltPplExcel = async (file: File): Promise<ObsltPplBusinessSaveRequest[]> => {
|
||||
if (!file.name.toLowerCase().endsWith(".xlsx")) throw new Error("xlsx 파일만 업로드할 수 있습니다.");
|
||||
return parseObsltPplWorkbook(await file.arrayBuffer());
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
type RegionOption = {
|
||||
text: string;
|
||||
val: string | number;
|
||||
};
|
||||
|
||||
const normalizeRegionText = (value?: string | null) => String(value ?? "").replace(/\s+/g, " ").trim();
|
||||
|
||||
/** 시군구명에서 상위 시만 대표명으로 추출한다. */
|
||||
export const extractObsltPplRepresentativeSigunName = (value?: string | null) => {
|
||||
const normalized = normalizeRegionText(value);
|
||||
if (!normalized) return "";
|
||||
const cityMatch = normalized.match(/^(.+?시)\s*(.+)$/);
|
||||
return cityMatch?.[1] ?? normalized;
|
||||
};
|
||||
|
||||
/** 목록과 상세 저장에 사용할 대표 시군구명을 정규화한다. */
|
||||
export const normalizeObsltPplSigunName = (value?: string | null) => {
|
||||
const representative = extractObsltPplRepresentativeSigunName(value);
|
||||
return representative || normalizeRegionText(value);
|
||||
};
|
||||
|
||||
/** 선택된 시도 기준으로 대표 시군구 한 건씩만 노출할 옵션 목록을 만든다. */
|
||||
export const buildObsltPplRepresentativeSggOptions = (
|
||||
sggOptions: RegionOption[],
|
||||
ctpvCd: string,
|
||||
) => {
|
||||
const representativeMap = new Map<string, RegionOption>();
|
||||
|
||||
sggOptions
|
||||
.filter((option) => {
|
||||
const optionCode = String(option.val ?? "").trim();
|
||||
return ctpvCd === "0" || optionCode.startsWith(ctpvCd.slice(0, 2));
|
||||
})
|
||||
.forEach((option) => {
|
||||
const representativeName = normalizeObsltPplSigunName(option.text);
|
||||
if (!representativeName) return;
|
||||
|
||||
const existing = representativeMap.get(representativeName);
|
||||
if (!existing || String(option.val).localeCompare(String(existing.val), "ko") < 0) {
|
||||
representativeMap.set(representativeName, {
|
||||
text: representativeName,
|
||||
val: String(option.val),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return [...representativeMap.values()].sort((left, right) => left.text.localeCompare(right.text, "ko"));
|
||||
};
|
||||
|
||||
/** 저장된 시도명으로 공통 시도 코드값을 다시 찾는다. */
|
||||
export const findObsltPplCtpvCodeByName = (ctpvOptions: RegionOption[], ctpvNm?: string | null) =>
|
||||
String(
|
||||
ctpvOptions.find((option) => normalizeRegionText(option.text) === normalizeRegionText(ctpvNm))?.val ?? "0",
|
||||
);
|
||||
|
||||
/** 저장된 시군구명 또는 코드에 해당하는 대표 시군구 옵션을 찾는다. */
|
||||
export const findObsltPplRepresentativeSggCode = (
|
||||
sggOptions: RegionOption[],
|
||||
ctpvCd: string,
|
||||
sigunNm?: string | null,
|
||||
sggCd?: string | null,
|
||||
) => {
|
||||
const normalizedSigunName = normalizeObsltPplSigunName(sigunNm);
|
||||
const representativeOptions = buildObsltPplRepresentativeSggOptions(sggOptions, ctpvCd);
|
||||
|
||||
if (normalizedSigunName) {
|
||||
const matchedByName = representativeOptions.find((option) => option.text === normalizedSigunName);
|
||||
if (matchedByName) return String(matchedByName.val);
|
||||
}
|
||||
|
||||
const normalizedSggCd = String(sggCd ?? "").trim();
|
||||
if (!normalizedSggCd) return "0";
|
||||
|
||||
const matchedRawOption = sggOptions.find((option) => String(option.val).trim() === normalizedSggCd);
|
||||
if (!matchedRawOption) return "0";
|
||||
|
||||
return String(
|
||||
representativeOptions.find((option) => option.text === normalizeObsltPplSigunName(matchedRawOption.text))?.val ?? "0",
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { SelectBox } from "@common/business_components/ui";
|
||||
import { Button, Input, Table, TableBody, TableCell, TableHead, TableRow } from "@common/components/ui";
|
||||
|
||||
import {
|
||||
@@ -11,24 +12,31 @@ import {
|
||||
getObsltPplBusiness,
|
||||
updateObsltPplBusiness,
|
||||
} from "../api/obslt-ppl-api";
|
||||
import {
|
||||
buildObsltPplRepresentativeSggOptions,
|
||||
findObsltPplCtpvCodeByName,
|
||||
findObsltPplRepresentativeSggCode,
|
||||
normalizeObsltPplSigunName,
|
||||
} from "../lib/obslt-ppl-region";
|
||||
import type { ObsltPplBusiness, ObsltPplBusinessSaveRequest } from "../model/obslt-ppl-types";
|
||||
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import useStore from "@/store/useStore";
|
||||
|
||||
type FormKey = keyof ObsltPplBusinessSaveRequest;
|
||||
type FormState = Record<FormKey, string>;
|
||||
const REGION_FORM_KEYS: FormKey[] = ["sggCd", "ctpvNm", "sigunNm"];
|
||||
|
||||
/** 등록·수정 표에서 필드 순서, 라벨, 입력 종류와 필수 여부를 한 곳에서 관리한다. */
|
||||
const FIELDS: Array<{ key: FormKey; label: string; type?: "number" | "textarea"; required?: boolean; placeholder?: string }> = [
|
||||
{ key: "bizSe", label: "사업구분", required: true }, { key: "bizSeq", label: "순번", type: "number" },
|
||||
{ key: "prcisExmnCycl", label: "정밀조사차수" }, { key: "bizCd", label: "사업코드", required: true },
|
||||
{ key: "sggCd", label: "시군구코드" }, { key: "ctpvNm", label: "시도" }, { key: "sigunNm", label: "시군" },
|
||||
{ key: "envoNm", label: "환경청" }, { key: "bizNm", label: "사업명", required: true },
|
||||
{ key: "prplPrst", label: "추진현황" }, { key: "bgngYr", label: "착수년도", placeholder: "YYYY" },
|
||||
{ key: "cmcnYr", label: "준공(예정)년도", placeholder: "YYYY" }, { key: "planPrlg", label: "계획 연장(km)", type: "number" },
|
||||
{ key: "frhfyrExpcPrlg", label: "26.6월말 예상물량", type: "number" }, { key: "rmndPrlg", label: "잔여물량", type: "number" },
|
||||
{ key: "cmcnYr", label: "준공(예정)년도", placeholder: "YYYY" }, { key: "planPrlg", label: "계획연장(km)", type: "number" },
|
||||
{ key: "frhfyrExpcPrlg", label: "상반기(예상)연장(km)", type: "number" }, { key: "rmndPrlg", label: "잔여연장(km)", type: "number" },
|
||||
{ key: "evdncDcmntSbmsnStts", label: "증빙서류 제출여부" }, { key: "bgngYmd", label: "착수(착공) 예정일자", placeholder: "YYYYMMDD" },
|
||||
{ key: "cmcnYmd", label: "준공(예정)일자", placeholder: "YYYYMMDD" }, { key: "bizPrplDlyRsnCn", label: "추진 지연사업 대상", type: "textarea" },
|
||||
{ key: "cmcnYmd", label: "준공(예정)일자", placeholder: "YYYYMMDD" }, { key: "bizPrplDlyRsnCn", label: "사업추진 부진사유", type: "textarea" },
|
||||
{ key: "bizPicNm", label: "담당자 이름" }, { key: "bizPicCnpl", label: "담당자 연락처" },
|
||||
{ key: "rmrkCn", label: "비고", type: "textarea" }, { key: "pbcpOpnnCn", label: "공단의견", type: "textarea" },
|
||||
];
|
||||
@@ -36,18 +44,23 @@ const FIELDS: Array<{ key: FormKey; label: string; type?: "number" | "textarea";
|
||||
const NUMBER_KEYS = new Set<FormKey>(["bizSeq", "planPrlg", "frhfyrExpcPrlg", "rmndPrlg"]);
|
||||
|
||||
/** 모든 입력을 제어 컴포넌트로 유지하기 위한 빈 폼 값을 생성한다. */
|
||||
const createEmptyForm = (): FormState => Object.fromEntries(FIELDS.map(({ key }) => [key, key === "bizSe" ? "정밀조사" : ""])) as FormState;
|
||||
const createEmptyForm = (): FormState => Object.fromEntries(
|
||||
[...FIELDS.map(({ key }) => key), ...REGION_FORM_KEYS].map((key) => [key, key === "bizSe" ? "정밀조사" : ""]),
|
||||
) as FormState;
|
||||
|
||||
/** API 상세값을 빈 문자열 기반 화면 폼 값으로 변환한다. */
|
||||
const toForm = (business: ObsltPplBusiness): FormState => {
|
||||
const form = createEmptyForm();
|
||||
FIELDS.forEach(({ key }) => { form[key] = String(business[key] ?? ""); });
|
||||
form.sggCd = String(business.sggCd ?? "");
|
||||
form.ctpvNm = String(business.ctpvNm ?? "");
|
||||
form.sigunNm = normalizeObsltPplSigunName(business.sigunNm);
|
||||
return form;
|
||||
};
|
||||
|
||||
/** 숫자 입력의 빈 문자열은 null로, 나머지는 trim 문자열로 저장 요청을 만든다. */
|
||||
const toRequest = (form: FormState): ObsltPplBusinessSaveRequest => Object.fromEntries(
|
||||
FIELDS.map(({ key }) => {
|
||||
[...FIELDS.map(({ key }) => key), ...REGION_FORM_KEYS].map((key) => {
|
||||
const value = form[key].trim();
|
||||
return [key, NUMBER_KEYS.has(key) ? (value === "" ? null : Number(value)) : (value === "" ? null : value)];
|
||||
}),
|
||||
@@ -57,9 +70,34 @@ const toRequest = (form: FormState): ObsltPplBusinessSaveRequest => Object.fromE
|
||||
export function ObsltPplFormPage({ id }: { id?: string }) {
|
||||
const router = useRouter();
|
||||
const isModify = Boolean(id);
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
const [form, setForm] = useState<FormState>(createEmptyForm);
|
||||
const [loading, setLoading] = useState(isModify);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [ctpvCd, setCtpvCd] = useState("0");
|
||||
const [sggCd, setSggCd] = useState("0");
|
||||
|
||||
const ctpvOptions = useMemo(() => [{ text: "전체", val: "0", hidden: true }, ...ctpv], [ctpv]);
|
||||
const representativeSggOptions = useMemo(
|
||||
() => [{ text: "전체", val: "0", hidden: true }, ...buildObsltPplRepresentativeSggOptions(sgg, ctpvCd)],
|
||||
[ctpvCd, sgg],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (ctpv.length === 0) void fetchCtpv();
|
||||
if (sgg.length === 0) void fetchSgg();
|
||||
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctpv.length || !sgg.length) return;
|
||||
|
||||
const resolvedCtpvCd = findObsltPplCtpvCodeByName(ctpv, form.ctpvNm);
|
||||
const nextCtpvCd = resolvedCtpvCd !== "0" ? resolvedCtpvCd : ctpvCd;
|
||||
const nextSggCd = findObsltPplRepresentativeSggCode(sgg, nextCtpvCd, form.sigunNm, form.sggCd);
|
||||
|
||||
if (nextCtpvCd !== ctpvCd) setCtpvCd(nextCtpvCd);
|
||||
if (nextSggCd !== sggCd) setSggCd(nextSggCd);
|
||||
}, [ctpv, ctpvCd, form.ctpvNm, form.sggCd, form.sigunNm, sgg, sggCd]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -72,6 +110,7 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
|
||||
/** 필수값과 숫자 형식을 화면에서 먼저 확인한 뒤 등록 또는 수정 API를 호출한다. */
|
||||
const handleSave = async () => {
|
||||
if (!form.bizSe || !form.bizCd.trim() || !form.bizNm.trim()) { setNotice("사업구분, 사업코드, 사업명은 필수입니다."); return; }
|
||||
if (ctpvCd === "0" || sggCd === "0" || !form.ctpvNm.trim() || !form.sigunNm.trim()) { setNotice("시도와 시군구를 선택해주세요."); return; }
|
||||
if ([...NUMBER_KEYS].some((key) => form[key] && !Number.isFinite(Number(form[key])))) { setNotice("순번과 물량은 숫자로 입력해주세요."); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -100,6 +139,50 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
|
||||
<Table variant="secondary">
|
||||
<colgroup><col width="18%" /><col width="82%" /></colgroup>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableHead data-required>시군구 선택</TableHead>
|
||||
<TableCell className="border">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<SelectBox
|
||||
label="시도"
|
||||
className=""
|
||||
labelClass="w-16 whitespace-nowrap text-[1.3rem] text-gray-600"
|
||||
selectClass="w-[180px] rounded-lg bg-white"
|
||||
selectArray={ctpvOptions}
|
||||
value={ctpvCd}
|
||||
onSelectValue={(value) => {
|
||||
const nextCtpvCd = String(value);
|
||||
setCtpvCd(nextCtpvCd);
|
||||
setSggCd("0");
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
ctpvNm: ctpv.find((option) => String(option.val) === nextCtpvCd)?.text ?? "",
|
||||
sigunNm: "",
|
||||
sggCd: "",
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<SelectBox
|
||||
label="시군구"
|
||||
className=""
|
||||
labelClass="w-20 whitespace-nowrap text-[1.3rem] text-gray-600"
|
||||
selectClass="w-[220px] rounded-lg bg-white"
|
||||
selectArray={representativeSggOptions}
|
||||
value={sggCd}
|
||||
onSelectValue={(value) => {
|
||||
const nextSggCd = String(value);
|
||||
const selectedOption = representativeSggOptions.find((option) => String(option.val) === nextSggCd);
|
||||
setSggCd(nextSggCd);
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
sigunNm: selectedOption?.text ?? "",
|
||||
sggCd: nextSggCd === "0" ? "" : nextSggCd,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{FIELDS.map((field) => (
|
||||
<TableRow key={field.key}>
|
||||
<TableHead data-required={field.required || undefined}>{field.label}</TableHead>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import moment from "moment";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
@@ -8,21 +9,21 @@ import ListDiv from "@common/business_components/ui/list_div";
|
||||
import { Button, Input, Label } from "@common/components/ui";
|
||||
|
||||
import {
|
||||
downloadObsltPplExcel,
|
||||
getObsltPplBusinesses,
|
||||
uploadObsltPplExcel,
|
||||
} from "../api/obslt-ppl-api";
|
||||
import { parseObsltPplExcel } from "../lib/obslt-ppl-excel";
|
||||
import { buildObsltPplRepresentativeSggOptions, normalizeObsltPplSigunName } from "../lib/obslt-ppl-region";
|
||||
import type { ObsltPplBusiness, ObsltPplPage } from "../model/obslt-ppl-types";
|
||||
|
||||
import { fileDownload } from "@/components/lib/utils";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import useStore from "@/store/useStore";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const DEFAULT_LIST_SIZE = 10;
|
||||
const ACTION_BUTTON_CLASS =
|
||||
"rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white";
|
||||
const BIZ_SE_OPTIONS = [
|
||||
{ text: "전체", val: "0", hidden: true },
|
||||
{ text: "전체", val: "0" },
|
||||
{ text: "정밀조사", val: "정밀조사" },
|
||||
{ text: "정비사업", val: "정비사업" },
|
||||
];
|
||||
@@ -33,30 +34,30 @@ type ObsltPplTableRow = ObsltPplBusiness & {
|
||||
};
|
||||
|
||||
const TABLE_HEADLIST = [
|
||||
{ id: "rowNumber", title: "NO" },
|
||||
{ id: "bizSe", title: "사업구분" },
|
||||
{ id: "bizSeq", title: "순번" },
|
||||
{ id: "prcisExmnCycl", title: "정밀조사차수" },
|
||||
{ id: "bizCd", title: "사업코드" },
|
||||
{ id: "sggCd", title: "시군구코드" },
|
||||
{ id: "ctpvNm", title: "시도" },
|
||||
{ id: "sigunNm", title: "시군" },
|
||||
{ id: "envoNm", title: "환경청" },
|
||||
{ id: "bizNm", title: "사업명" },
|
||||
{ id: "prplPrst", title: "추진현황" },
|
||||
{ id: "bgngYr", title: "착수년도" },
|
||||
{ id: "cmcnYr", title: "준공(예정)년도" },
|
||||
{ id: "planPrlg", title: "계획 연장(km)" },
|
||||
{ id: "frhfyrExpcPrlg", title: "26.6월말 예상물량" },
|
||||
{ id: "rmndPrlg", title: "잔여물량" },
|
||||
{ id: "evdncDcmntSbmsnStts", title: "증빙서류 제출여부" },
|
||||
{ id: "bgngYmd", title: "착수(착공) 예정일자" },
|
||||
{ id: "cmcnYmd", title: "준공(예정)일자" },
|
||||
{ id: "bizPrplDlyRsnCn", title: "추진 지연사업 대상" },
|
||||
{ id: "bizPicNm", title: "담당자 이름" },
|
||||
{ id: "bizPicCnpl", title: "담당자 연락처" },
|
||||
{ id: "rmrkCn", title: "비고" },
|
||||
{ id: "pbcpOpnnCn", title: "공단의견" },
|
||||
{ id: "rowNumber", title: "NO", widthPercent: 4 },
|
||||
{ id: "bizSe", title: "사업구분", widthPercent: 6 },
|
||||
{ id: "bizSeq", title: "순번", widthPercent: 5 },
|
||||
{ id: "prcisExmnCycl", title: "정밀조사차수", widthPercent: 6 },
|
||||
{ id: "bizCd", title: "사업코드", widthPercent: 7 },
|
||||
//{ id: "sggCd", title: "시군구코드", widthPercent: 6 },
|
||||
{ id: "ctpvNm", title: "시도", widthPercent: 6 },
|
||||
{ id: "sigunNm", title: "시군", widthPercent: 6 },
|
||||
{ id: "envoNm", title: "환경청", widthPercent: 8 },
|
||||
{ id: "bizNm", title: "사업명", widthPercent: 16 },
|
||||
{ id: "prplPrst", title: "추진현황", widthPercent: 6 },
|
||||
{ id: "bgngYr", title: "착수년도", widthPercent: 5 },
|
||||
{ id: "cmcnYr", title: "준공(예정)년도", widthPercent: 6 },
|
||||
{ id: "planPrlg", title: "계획연장(km)", widthPercent: 6 },
|
||||
{ id: "frhfyrExpcPrlg", title: "상반기(예상)연장(km)", widthPercent: 8 },
|
||||
{ id: "rmndPrlg", title: "잔여연장(km)", widthPercent: 6 },
|
||||
{ id: "evdncDcmntSbmsnStts", title: "증빙서류\n제출여부", widthPercent: 6 },
|
||||
{ id: "bgngYmd", title: "착수일자", widthPercent: 7 },
|
||||
{ id: "cmcnYmd", title: "준공(예정)일자", widthPercent: 7 },
|
||||
{ id: "bizPrplDlyRsnCn", title: "사업추진 부진사유", widthPercent: 26 },
|
||||
{ id: "bizPicNm", title: "담당자 이름", widthPercent: 6 },
|
||||
{ id: "bizPicCnpl", title: "담당자 연락처", widthPercent: 8 },
|
||||
{ id: "rmrkCn", title: "비고", widthPercent: 10 },
|
||||
{ id: "pbcpOpnnCn", title: "공단\n의견", widthPercent: 2 },
|
||||
];
|
||||
|
||||
const EMPTY_PAGE: ObsltPplPage = {
|
||||
@@ -64,9 +65,60 @@ const EMPTY_PAGE: ObsltPplPage = {
|
||||
totalElements: 0,
|
||||
totalPages: 0,
|
||||
number: 0,
|
||||
size: PAGE_SIZE,
|
||||
size: DEFAULT_LIST_SIZE,
|
||||
};
|
||||
|
||||
const DATE_FIELD_SET = new Set<keyof ObsltPplBusiness>(["bgngYmd", "cmcnYmd"]);
|
||||
|
||||
/** 8자리 또는 구분자가 섞인 날짜 문자열을 화면·다운로드 공통 형식으로 정규화한다. */
|
||||
const formatObsltPplDate = (value?: string | null) => {
|
||||
if (!value) return "";
|
||||
const normalized = String(value).trim();
|
||||
if (!normalized) return "";
|
||||
const digits = normalized.replace(/[^0-9]/g, "");
|
||||
if (digits.length === 8) {
|
||||
const parsedDate = moment(digits, "YYYYMMDD", true);
|
||||
return parsedDate.isValid() ? parsedDate.format("YYYY-MM-DD") : normalized;
|
||||
}
|
||||
const parsedDate = moment(normalized);
|
||||
return parsedDate.isValid() ? parsedDate.format("YYYY-MM-DD") : normalized;
|
||||
};
|
||||
|
||||
/** 목록 표시값을 공통 포맷으로 정리한다. */
|
||||
const formatBusinessForDisplay = (row: ObsltPplBusiness): ObsltPplBusiness => ({
|
||||
...row,
|
||||
sigunNm: normalizeObsltPplSigunName(row.sigunNm) || null,
|
||||
bgngYmd: formatObsltPplDate(row.bgngYmd) || null,
|
||||
cmcnYmd: formatObsltPplDate(row.cmcnYmd) || null,
|
||||
});
|
||||
|
||||
/** 현재 조회조건 기준 전체 목록을 내려받기용 행으로 변환한다. */
|
||||
const toExcelRow = (row: ObsltPplTableRow) => ({
|
||||
NO: row.rowNumber,
|
||||
사업구분: row.bizSe ?? "",
|
||||
순번: row.bizSeq ?? "",
|
||||
정밀조사차수: row.prcisExmnCycl ?? "",
|
||||
사업코드: row.bizCd ?? "",
|
||||
시도: row.ctpvNm ?? "",
|
||||
시군: row.sigunNm ?? "",
|
||||
환경청: row.envoNm ?? "",
|
||||
사업명: row.bizNm ?? "",
|
||||
추진현황: row.prplPrst ?? "",
|
||||
착수년도: row.bgngYr ?? "",
|
||||
"준공(예정)년도": row.cmcnYr ?? "",
|
||||
"계획연장(km)": row.planPrlg ?? "",
|
||||
"상반기(예상)연장(km)": row.frhfyrExpcPrlg ?? "",
|
||||
"잔여연장(km)": row.rmndPrlg ?? "",
|
||||
증빙서류제출여부: row.evdncDcmntSbmsnStts ?? "",
|
||||
착수일자: formatObsltPplDate(row.bgngYmd),
|
||||
"준공(예정)일자": formatObsltPplDate(row.cmcnYmd),
|
||||
사업추진부진사유: row.bizPrplDlyRsnCn ?? "",
|
||||
"담당자 이름": row.bizPicNm ?? "",
|
||||
"담당자 연락처": row.bizPicCnpl ?? "",
|
||||
비고: row.rmrkCn ?? "",
|
||||
공단의견: row.pbcpOpnnCn ?? "",
|
||||
});
|
||||
|
||||
/** 노후하수관로 사업 목록, 검색, xlsx 업로드·다운로드 진입점을 제공한다. */
|
||||
export function ObsltPplListPage() {
|
||||
const router = useRouter();
|
||||
@@ -86,8 +138,7 @@ export function ObsltPplListPage() {
|
||||
|
||||
/** 현재 선택한 시도에 속한 시군구만 선택 후보로 제공한다. */
|
||||
const availableSgg = useMemo(() => {
|
||||
if (ctpvCd === "0") return sgg;
|
||||
return sgg.filter((option) => String(option.val).startsWith(ctpvCd.slice(0, 2)));
|
||||
return buildObsltPplRepresentativeSggOptions(sgg, ctpvCd);
|
||||
}, [ctpvCd, sgg]);
|
||||
|
||||
/** 공통 레이아웃에서 아직 지역 데이터를 불러오지 못한 경우 공통 store 조회를 보완한다. */
|
||||
@@ -100,7 +151,7 @@ export function ObsltPplListPage() {
|
||||
const load = useCallback(async (targetPage = page, targetSearch = appliedSearch) => {
|
||||
const params = new URLSearchParams({
|
||||
page: String(Math.max(targetPage - 1, 0)),
|
||||
size: String(PAGE_SIZE),
|
||||
size: String(DEFAULT_LIST_SIZE),
|
||||
sort: "obsltSwagePplBizSn,desc",
|
||||
});
|
||||
Object.entries(targetSearch).forEach(([key, value]) => {
|
||||
@@ -112,8 +163,8 @@ export function ObsltPplListPage() {
|
||||
const result = await getObsltPplBusinesses(params);
|
||||
setData(result);
|
||||
setRows(result.content.map((row, index) => ({
|
||||
...row,
|
||||
rowNumber: result.totalElements - ((targetPage - 1) * PAGE_SIZE + index),
|
||||
...formatBusinessForDisplay(row),
|
||||
rowNumber: result.totalElements - ((targetPage - 1) * DEFAULT_LIST_SIZE + index),
|
||||
})));
|
||||
} catch (error) {
|
||||
setNotice(error instanceof Error ? error.message : "목록을 조회하지 못했습니다.");
|
||||
@@ -129,7 +180,7 @@ export function ObsltPplListPage() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
/** 선택한 xlsx를 서버 교체 업로드하고 첫 페이지를 다시 조회한다. */
|
||||
/** 선택한 xlsx를 브라우저에서 검증·파싱한 뒤 JSON으로 교체 업로드한다. */
|
||||
const handleUpload = async (file?: File) => {
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith(".xlsx")) {
|
||||
@@ -139,7 +190,8 @@ export function ObsltPplListPage() {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await uploadObsltPplExcel(file);
|
||||
const parsedRows = await parseObsltPplExcel(file);
|
||||
const result = await uploadObsltPplExcel(parsedRows);
|
||||
setNotice(
|
||||
`총 ${result.importedCount.toLocaleString()}건을 저장했습니다. (정밀조사 ${result.precisionSurveyCount.toLocaleString()}건, 정비사업 ${result.maintenanceCount.toLocaleString()}건)`,
|
||||
);
|
||||
@@ -154,11 +206,28 @@ export function ObsltPplListPage() {
|
||||
}
|
||||
};
|
||||
|
||||
/** 서버가 생성한 xlsx Blob을 사용자 PC에 고정 파일명으로 저장한다. */
|
||||
/** 현재 조회조건 기준 전체 목록을 화면 포맷 그대로 xlsx로 내려받는다. */
|
||||
const handleDownload = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
fileDownload(await downloadObsltPplExcel(), "노후하수관로 정밀조사 및 정비사업 현황.xlsx");
|
||||
const params = new URLSearchParams({
|
||||
page: "0",
|
||||
size: String(Math.max(data.totalElements, 1)),
|
||||
sort: "obsltSwagePplBizSn,desc",
|
||||
});
|
||||
Object.entries(appliedSearch).forEach(([key, value]) => {
|
||||
if (value.trim()) params.set(key, value.trim());
|
||||
});
|
||||
const result = await getObsltPplBusinesses(params);
|
||||
const exportRows = result.content.map((row, index) => ({
|
||||
...formatBusinessForDisplay(row),
|
||||
rowNumber: result.totalElements - index,
|
||||
}));
|
||||
const { utils, writeFileXLSX } = await import("xlsx");
|
||||
const worksheet = utils.json_to_sheet(exportRows.map(toExcelRow));
|
||||
const workbook = utils.book_new();
|
||||
utils.book_append_sheet(workbook, worksheet, "노후하수관로");
|
||||
writeFileXLSX(workbook, "노후하수관로 정밀조사 및 정비사업 현황.xlsx");
|
||||
} catch (error) {
|
||||
setNotice(error instanceof Error ? error.message : "엑셀을 내려받지 못했습니다.");
|
||||
} finally {
|
||||
@@ -228,9 +297,10 @@ export function ObsltPplListPage() {
|
||||
onSelectValue={(value) => {
|
||||
const nextSggCd = String(value);
|
||||
setSggCd(nextSggCd);
|
||||
const selectedOption = availableSgg.find((option) => String(option.val) === nextSggCd);
|
||||
setSearch((current) => ({
|
||||
...current,
|
||||
sigunNm: sgg.find((option) => String(option.val) === nextSggCd)?.text ?? "",
|
||||
sigunNm: selectedOption?.text ?? "",
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
@@ -287,29 +357,34 @@ export function ObsltPplListPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ListDiv className="mt-4">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="text-2xl font-medium">
|
||||
노후하수관로 정밀조사 및 정비사업 현황{" "}
|
||||
<span className="text-base text-gray-500">({data.totalElements.toLocaleString("ko-KR")}건)</span>
|
||||
</h3>
|
||||
<ListDiv className="overflow-x-auto">
|
||||
<div className="w-[100%]">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="text-2xl font-medium">
|
||||
노후하수관로 정밀조사 및 정비사업 현황{" "}
|
||||
<span className="text-base text-gray-500">({data.totalElements.toLocaleString("ko-KR")}건)</span>
|
||||
</h3>
|
||||
</div>
|
||||
<UiTable<ObsltPplTableRow>
|
||||
headName=""
|
||||
headlist={TABLE_HEADLIST}
|
||||
tableData={rows}
|
||||
pageSize={DEFAULT_LIST_SIZE}
|
||||
total={data.totalElements}
|
||||
curPage={page}
|
||||
widthFixed
|
||||
onPageChange={setPage}
|
||||
resetPageNo={resetPageNo}
|
||||
cellClick={(index) => {
|
||||
if (typeof index !== "number" || !rows[index]) return;
|
||||
router.push(`/oper/obsltPplBiz/register/${rows[index].obsltSwagePplBizSn}`);
|
||||
}}
|
||||
renderCell={(row, headId) => {
|
||||
if (!DATE_FIELD_SET.has(headId as keyof ObsltPplBusiness)) return undefined;
|
||||
return formatObsltPplDate(row[headId as keyof ObsltPplTableRow] as string | null | undefined);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<UiTable<ObsltPplTableRow>
|
||||
headName=""
|
||||
headlist={TABLE_HEADLIST}
|
||||
tableData={rows}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={data.totalElements}
|
||||
curPage={page}
|
||||
widthFixed
|
||||
className="overflow-x-auto overflow-y-auto"
|
||||
onPageChange={setPage}
|
||||
resetPageNo={resetPageNo}
|
||||
cellClick={(index) => {
|
||||
if (typeof index !== "number" || !rows[index]) return;
|
||||
router.push(`/oper/obsltPplBiz/register/${rows[index].obsltSwagePplBizSn}`);
|
||||
}}
|
||||
/>
|
||||
</ListDiv>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user