From b42810d29a9a8f27a8e0b116532c9d9f54fbe22a Mon Sep 17 00:00:00 2001 From: ljpark Date: Tue, 4 Aug 2026 20:30:26 +0900 Subject: [PATCH] =?UTF-8?q?sn:=20=EB=85=B8=ED=9B=84=ED=95=98=EC=88=98?= =?UTF-8?q?=EA=B4=80=EB=A1=9C=EC=A0=95=EB=B9=84=EC=82=AC=EC=97=851?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../001-qa-SN-노후하수관로정비사업-통합검증계획.md | 2 +- .../002-qa-SN-노후하수관로정비사업-통합검증결과.md | 7 + .../001-fe-SN-노후하수관로정비사업-작업카드.md | 3 +- .../001-fe-SN-노후하수관로정비사업-통합구현기록.md | 9 +- .../features/obsltPpl/api/obslt-ppl-api.ts | 12 +- .../features/obsltPpl/lib/obslt-ppl-excel.ts | 187 +++++++++++++++++ .../features/obsltPpl/lib/obslt-ppl-region.ts | 80 +++++++ .../features/obsltPpl/ui/ObsltPplFormPage.tsx | 97 ++++++++- .../features/obsltPpl/ui/ObsltPplListPage.tsx | 197 ++++++++++++------ 9 files changed, 516 insertions(+), 78 deletions(-) create mode 100644 apps/sn/src/features/obsltPpl/lib/obslt-ppl-excel.ts create mode 100644 apps/sn/src/features/obsltPpl/lib/obslt-ppl-region.ts diff --git a/apps/sn/docs/작업검증/SN-노후하수관로정비사업/001-qa-SN-노후하수관로정비사업-통합검증계획.md b/apps/sn/docs/작업검증/SN-노후하수관로정비사업/001-qa-SN-노후하수관로정비사업-통합검증계획.md index da4077ec9..80ea08299 100644 --- a/apps/sn/docs/작업검증/SN-노후하수관로정비사업/001-qa-SN-노후하수관로정비사업-통합검증계획.md +++ b/apps/sn/docs/작업검증/SN-노후하수관로정비사업/001-qa-SN-노후하수관로정비사업-통합검증계획.md @@ -10,7 +10,7 @@ 1. 목록 전체 23개 열과 가로 스크롤 확인 2. 검색·페이지 이동 확인 -3. 제공 원본 xlsx 업로드 후 517건 표시 확인 +3. 새 `_upload.xlsx`의 헤더 오류 없이 FE에서 517건 파싱되고 네트워크 요청이 JSON 배열인지 확인 4. 엑셀다운로드 후 두 시트 및 재업로드 확인 5. 등록 저장, 행 클릭 수정, 수정 화면 삭제 확인 6. 목록에 삭제 버튼이 없고 수정 하단 버튼 순서가 `목록`, `저장`, `삭제`인지 확인 diff --git a/apps/sn/docs/작업검증/SN-노후하수관로정비사업/002-qa-SN-노후하수관로정비사업-통합검증결과.md b/apps/sn/docs/작업검증/SN-노후하수관로정비사업/002-qa-SN-노후하수관로정비사업-통합검증결과.md index f98702837..f97a4bd99 100644 --- a/apps/sn/docs/작업검증/SN-노후하수관로정비사업/002-qa-SN-노후하수관로정비사업-통합검증결과.md +++ b/apps/sn/docs/작업검증/SN-노후하수관로정비사업/002-qa-SN-노후하수관로정비사업-통합검증결과.md @@ -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`는 실행하지 않았다. diff --git a/apps/sn/docs/작업계획/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-작업카드.md b/apps/sn/docs/작업계획/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-작업카드.md index 1a3749e33..00dee4cb5 100644 --- a/apps/sn/docs/작업계획/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-작업카드.md +++ b/apps/sn/docs/작업계획/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-작업카드.md @@ -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}` diff --git a/apps/sn/docs/작업내용/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-통합구현기록.md b/apps/sn/docs/작업내용/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-통합구현기록.md index 15e6eb33e..88c913db3 100644 --- a/apps/sn/docs/작업내용/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-통합구현기록.md +++ b/apps/sn/docs/작업내용/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-통합구현기록.md @@ -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` 의존을 제거했다. diff --git a/apps/sn/src/features/obsltPpl/api/obslt-ppl-api.ts b/apps/sn/src/features/obsltPpl/api/obslt-ppl-api.ts index 6733c82e6..31d4bf208 100644 --- a/apps/sn/src/features/obsltPpl/api/obslt-ppl-api.ts +++ b/apps/sn/src/features/obsltPpl/api/obslt-ppl-api.ts @@ -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 => { await deleteData(`${API_PATH}/${encodeURIComponent(id)}`); }; -/** 원본 형식 xlsx 한 건을 두 시트 스냅샷으로 교체 저장한다. */ -export const uploadObsltPplExcel = async (file: File): Promise => { - const formData = new FormData(); - formData.append("file", file); - return postMutiPart(`${API_PATH}/excel/upload`, formData) as Promise; -}; +/** 브라우저에서 파싱한 두 시트 JSON을 활성 스냅샷으로 교체 저장한다. */ +export const uploadObsltPplExcel = async ( + rows: ObsltPplBusinessSaveRequest[], +): Promise => + unwrapData(await postRawJson(`${API_PATH}/excel/upload`, rows)); /** 현재 활성 자료를 원본과 호환되는 xlsx로 내려받는다. */ export const downloadObsltPplExcel = async (): Promise => diff --git a/apps/sn/src/features/obsltPpl/lib/obslt-ppl-excel.ts b/apps/sn/src/features/obsltPpl/lib/obslt-ppl-excel.ts new file mode 100644 index 000000000..f54ac38b9 --- /dev/null +++ b/apps/sn/src/features/obsltPpl/lib/obslt-ppl-excel.ts @@ -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 => { + if (!file.name.toLowerCase().endsWith(".xlsx")) throw new Error("xlsx 파일만 업로드할 수 있습니다."); + return parseObsltPplWorkbook(await file.arrayBuffer()); +}; diff --git a/apps/sn/src/features/obsltPpl/lib/obslt-ppl-region.ts b/apps/sn/src/features/obsltPpl/lib/obslt-ppl-region.ts new file mode 100644 index 000000000..a841ca4be --- /dev/null +++ b/apps/sn/src/features/obsltPpl/lib/obslt-ppl-region.ts @@ -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(); + + 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", + ); +}; diff --git a/apps/sn/src/features/obsltPpl/ui/ObsltPplFormPage.tsx b/apps/sn/src/features/obsltPpl/ui/ObsltPplFormPage.tsx index 0dbe7be47..ed776d2e5 100644 --- a/apps/sn/src/features/obsltPpl/ui/ObsltPplFormPage.tsx +++ b/apps/sn/src/features/obsltPpl/ui/ObsltPplFormPage.tsx @@ -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; +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(["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(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 }) { + + 시군구 선택 + +
+ { + const nextCtpvCd = String(value); + setCtpvCd(nextCtpvCd); + setSggCd("0"); + setForm((current) => ({ + ...current, + ctpvNm: ctpv.find((option) => String(option.val) === nextCtpvCd)?.text ?? "", + sigunNm: "", + sggCd: "", + })); + }} + /> + { + 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, + })); + }} + /> +
+
+
{FIELDS.map((field) => ( {field.label} diff --git a/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx b/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx index 5dcef5777..e8f3dad1d 100644 --- a/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx +++ b/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx @@ -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(["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() { )} - -
-

- 노후하수관로 정밀조사 및 정비사업 현황{" "} - ({data.totalElements.toLocaleString("ko-KR")}건) -

+ +
+
+

+ 노후하수관로 정밀조사 및 정비사업 현황{" "} + ({data.totalElements.toLocaleString("ko-KR")}건) +

+
+ + 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); + }} + />
- - 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}`); - }} - />
);