From d3bdc589941fef300b4a52d34ab14d108b6d56ca Mon Sep 17 00:00:00 2001 From: ljpark Date: Wed, 5 Aug 2026 15:49:55 +0900 Subject: [PATCH 1/7] =?UTF-8?q?sn:=20=EC=88=9C=EB=B2=88=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../002-qa-SN-노후하수관로정비사업-통합검증결과.md | 9 ++ ...-fe-SN-노후하수관로정비사업-엑셀다운로드-유지보수계획.md | 78 +++++++++++ .../001-fe-SN-노후하수관로정비사업-통합구현기록.md | 9 ++ .../features/obsltPpl/api/obslt-ppl-api.ts | 27 +++- .../features/obsltPpl/lib/obslt-ppl-excel.ts | 124 +++++++++++++----- .../features/obsltPpl/lib/obslt-ppl-region.ts | 16 +++ .../obsltPpl/model/obslt-ppl-types.ts | 3 +- .../features/obsltPpl/ui/ObsltPplFormPage.tsx | 12 +- .../features/obsltPpl/ui/ObsltPplListPage.tsx | 54 ++------ 9 files changed, 239 insertions(+), 93 deletions(-) create mode 100644 apps/sn/docs/작업계획/SN-노후하수관로정비사업/002-fe-SN-노후하수관로정비사업-엑셀다운로드-유지보수계획.md diff --git a/apps/sn/docs/작업검증/SN-노후하수관로정비사업/002-qa-SN-노후하수관로정비사업-통합검증결과.md b/apps/sn/docs/작업검증/SN-노후하수관로정비사업/002-qa-SN-노후하수관로정비사업-통합검증결과.md index f97a4bd99..76b4a4478 100644 --- a/apps/sn/docs/작업검증/SN-노후하수관로정비사업/002-qa-SN-노후하수관로정비사업-통합검증결과.md +++ b/apps/sn/docs/작업검증/SN-노후하수관로정비사업/002-qa-SN-노후하수관로정비사업-통합검증결과.md @@ -15,3 +15,12 @@ - 실제 FE 파서를 실행해 총 517건, 정밀조사 128건, 정비사업 389건을 변환하고 대표 사업코드·연장 값을 대조했다. - 변경한 API·Excel 파서·목록 파일 대상 ESLint를 오류·경고 없이 통과했다. - SN `next dev` 프로세스가 실행 중이어서 앱 규칙에 따라 전체 `next build`는 실행하지 않았다. + +## 2026-08-05 + +- 변경한 API·Excel·타입·목록·폼 5개 파일의 ESLint를 오류·경고 없이 통과했다. +- SN 전체 TypeScript 검사는 저장소 설정의 `ignoreDeprecations` 값을 명령행에서 보정해 실행했다. 다른 기능의 기존 진단 43건은 남았지만 `features/obsltPpl` 진단은 0건이었다. +- 실제 작업지시 517건을 파싱해 두 시트 workbook으로 생성하고 다시 현행 파서로 읽는 왕복 검증을 통과했다: 정밀조사 128건, 정비사업 389건. +- 시트명과 B6 `순번`, D6 업무별 사업코드 헤더, B8 데이터 시작 위치를 자동 확인했다. +- SN 개발 서버가 3002 포트에서 실행 중이어서 앱 규칙에 따라 `next build`는 실행하지 않았다. +- 로그인·실 API 환경에서 조회조건 다운로드와 다운로드→수정→업로드 브라우저 검증은 남은 통합검증이다. diff --git a/apps/sn/docs/작업계획/SN-노후하수관로정비사업/002-fe-SN-노후하수관로정비사업-엑셀다운로드-유지보수계획.md b/apps/sn/docs/작업계획/SN-노후하수관로정비사업/002-fe-SN-노후하수관로정비사업-엑셀다운로드-유지보수계획.md new file mode 100644 index 000000000..4293b6261 --- /dev/null +++ b/apps/sn/docs/작업계획/SN-노후하수관로정비사업/002-fe-SN-노후하수관로정비사업-엑셀다운로드-유지보수계획.md @@ -0,0 +1,78 @@ +# SN-노후하수관로정비사업 FE 엑셀 다운로드 유지보수 계획 + +## 목표 + +`/oper/obsltPplBiz`의 `엑셀다운로드`가 업로드와 동일한 시트명 및 셀 위치를 사용하도록 수정한다. 다운로드 파일을 편집한 후 현행 업로드 기능으로 다시 저장할 수 있어야 한다. + +- 모드: `maintenance` +- 상태: **구현 및 자동 검증 완료, 브라우저 통합검증 대기** +- 다운로드 생성 주체: FE + +공통 `005-de-공통-노후하수관로정비사업-미부여사업코드-유지보수계획.md`에 따라 사업코드 공란을 `{사업구분명}-{PK}`로 저장하는 FE·BE 선행 작업을 먼저 완료한다. + +선행 작업에서 `bizSeq` 영속·API 필드를 제거하므로 다운로드 B열 `순번`은 DB 값이 아니라 각 시트의 출력 순서로 생성한다. 업로드 파서는 B열 헤더 위치를 검증하되 순번 값을 저장 요청에 포함하지 않는다. + +## 현재 문제 + +- 현재 다운로드는 `노후하수관로` 한 시트의 1행 헤더로 생성된다. +- 현행 업로드 파서는 `정밀조사`, `정비사업` 두 시트와 B6:V7 헤더, B8 이후 데이터를 요구한다. +- 다운로드 파일과 업로드 계약이 달라 직접 재업로드할 수 없다. + +## FE 구현 계약 + +- SheetJS(`xlsx`)를 계속 사용한다. +- `정밀조사`, `정비사업` 두 시트를 순서대로 생성한다. +- `bizSe`가 `정밀조사`인 행은 `정밀조사` 시트, `정비사업`인 행은 `정비사업` 시트에 기록한다. +- 업로드가 검증하는 정확한 헤더 문자열을 B6:V7에 배치한다. +- 실제 데이터는 B8부터 V열까지 기록한다. +- 업로드와 같은 선, 색, 글꼴 등 시각적 스타일은 복제하지 않는다. +- 화면에 적용된 조회조건에 해당하는 활성 자료만 내려받는다. +- 조건에 해당하는 전체 건은 기존 목록 API에 같은 조회조건과 고정 정렬을 전달하고 페이지 단위로 반복 호출하여 수집한다. +- 선행 작업에서 업로드가 활성행 UPDATE/신규 INSERT 방식으로 바뀌므로, 필터 파일 재업로드 시 파일에 없는 활성행은 유지된다. +- 다운로드 파일명은 `노후하수관로 정밀조사 및 정비사업 현황.xlsx`를 유지한다. + +## 수정 파일 + +| 파일 | 변경 내용 | +| --- | --- | +| `src/features/obsltPpl/api/obslt-ppl-api.ts` | 적용 조회조건 전체 목록을 안정적으로 모으는 페이지 순회 함수 추가 | +| `src/features/obsltPpl/lib/obslt-ppl-excel.ts` | 업로드 계약과 공유되는 두 시트 workbook 생성 로직 추가 | +| `src/features/obsltPpl/ui/ObsltPplListPage.tsx` | 기존 단일 시트 생성 코드 제거 및 새 다운로드 흐름 연결 | + +기존 사용자 수정이 있는 `obslt-ppl-region.ts`, `ObsltPplFormPage.tsx`는 이 작업에서 수정하지 않는다. + +## 데이터 위치 계약 + +| Excel 위치 | 값 | +| --- | --- | +| B6:V7 | 업로드 검증 대상 헤더 | +| B8:V마지막행 | 21개 사업 데이터 필드 | +| 시트 1 | `정밀조사` | +| 시트 2 | `정비사업` | + +데이터 필드 순서는 현행 `parseSheet`의 B~V 매핑을 역방향으로 사용한다. DB PK인 `obslt_swage_ppl_biz_sn`은 다운로드 열에 포함하지 않고, 업로드 시에도 엑셀 순번을 PK로 사용하지 않는다. + +## 검증 계획 + +1. 변경 파일 ESLint와 대상 TypeScript 검증 +2. 두 시트명 및 B6:V7/B8 위치 자동 확인 +3. workbook 생성 후 `parseObsltPplWorkbook`으로 재파싱하는 왕복 검증 +4. 전체·정밀조사·정비사업 건수와 대표 필드 값 대조 +5. 브라우저에서 다운로드 → 일부 셀 수정 → 업로드 동작 확인 +6. 검색조건이 적용된 화면에서는 조건에 해당하는 전체 건만 포함되고, 재업로드 후 조건 밖 기존 활성행이 유지되는지 확인 + +## 제외 범위 + +- 업로드 파일과 동일한 선, 색상, 글꼴, 행 높이, 열 너비 등 스타일 복제 +- BE Controller·Service·Repository 변경 +- API 경로 또는 응답 계약 변경 +- DB DDL 및 데이터 변경 +- 기존 BE 다운로드 API 삭제 + +## 롤백 + +예정된 FE 3개 파일의 다운로드 관련 변경만 되돌린다. DB와 API 계약 변경이 없으므로 별도 데이터 롤백은 없다. + +## 수행 결과 + +사용자 승인 후 미부여 사업코드 선행 작업을 먼저 완료하고 다운로드를 구현했다. 원본 517건을 두 시트로 생성한 뒤 현행 업로드 파서로 다시 읽는 자동 왕복 검증을 통과했다. diff --git a/apps/sn/docs/작업내용/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-통합구현기록.md b/apps/sn/docs/작업내용/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-통합구현기록.md index 88c913db3..3e01e4a68 100644 --- a/apps/sn/docs/작업내용/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-통합구현기록.md +++ b/apps/sn/docs/작업내용/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-통합구현기록.md @@ -33,3 +33,12 @@ - `lib/obslt-ppl-excel.ts`에서 두 필수 시트, B6:V7 핵심 헤더, 필수 사업명, 순번·연장 숫자를 검증한다. - `노후하수관로 정밀조사 및 정비사업 현황_upload.xlsx`의 517개 행을 저장 요청 JSON으로 변환한 뒤 `/excel/upload`에 `application/json`으로 전송한다. - 기존 multipart `FormData` 전송과 `postMutiPart` 의존을 제거했다. + +## 2026-08-05 사업순서 제거 및 다운로드 위치 호환 + +- Excel B열 순번은 헤더 위치 검증만 유지하고 저장 JSON의 `bizSeq`에는 포함하지 않도록 변경했으며, 사업코드 공란은 빈 문자열로 BE에 전달한다. +- FE 모델·목록·등록/수정 폼에서 `bizSeq`를 제거했다. +- 화면의 확정 조회조건으로 목록 API를 500건씩 반복 조회하여 조건에 해당하는 전체 활성 자료를 수집한다. +- `bizSe`에 따라 `정밀조사`, `정비사업` 두 시트로 분리하고 업로드와 같은 B6:V7 헤더, B8:V 데이터 위치로 다운로드한다. +- B열 순번은 DB 값이 아니라 각 시트의 출력 순서로 생성하며 스타일·색상 복제는 적용하지 않았다. +- 기존 BE Excel 다운로드 API는 삭제하지 않았다. 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 31d4bf208..c073867ae 100644 --- a/apps/sn/src/features/obsltPpl/api/obslt-ppl-api.ts +++ b/apps/sn/src/features/obsltPpl/api/obslt-ppl-api.ts @@ -7,13 +7,13 @@ import type { import { deleteData, - getBlobData, getData, postRawJson, putData, } from "@/components/lib/api"; const API_PATH = "v1/obsltPplBiz"; +const EXCEL_DOWNLOAD_PAGE_SIZE = 500; /** 공통 응답 래퍼가 있는 JSON 응답에서 data를 안전하게 꺼낸다. */ const unwrapData = (body: unknown): T => { @@ -27,6 +27,25 @@ const unwrapData = (body: unknown): T => { export const getObsltPplBusinesses = async (params: URLSearchParams): Promise => getData(`${API_PATH}?${params.toString()}`) as Promise; +/** 적용된 조회조건을 유지하며 모든 페이지를 순회해 Excel 다운로드 대상 전체를 모은다. */ +export const getAllObsltPplBusinesses = async (searchParams: URLSearchParams): Promise => { + const rows: ObsltPplBusiness[] = []; + let page = 0; + let totalPages = 1; + + while (page < totalPages) { + const params = new URLSearchParams(searchParams); + params.set("page", String(page)); + params.set("size", String(EXCEL_DOWNLOAD_PAGE_SIZE)); + params.set("sort", "obsltSwagePplBizSn,asc"); + const result = await getObsltPplBusinesses(params); + rows.push(...result.content); + totalPages = result.totalPages; + page += 1; + } + return rows; +}; + /** 수정 화면에서 사업 상세 한 건을 조회한다. */ export const getObsltPplBusiness = async (id: string): Promise => getData(`${API_PATH}/${encodeURIComponent(id)}`) as Promise; @@ -49,12 +68,8 @@ export const deleteObsltPplBusiness = async (id: string): Promise => { await deleteData(`${API_PATH}/${encodeURIComponent(id)}`); }; -/** 브라우저에서 파싱한 두 시트 JSON을 활성 스냅샷으로 교체 저장한다. */ +/** 브라우저에서 파싱한 두 시트 JSON을 활성행 UPDATE·신규 INSERT 방식으로 저장한다. */ export const uploadObsltPplExcel = async ( rows: ObsltPplBusinessSaveRequest[], ): Promise => unwrapData(await postRawJson(`${API_PATH}/excel/upload`, rows)); - -/** 현재 활성 자료를 원본과 호환되는 xlsx로 내려받는다. */ -export const downloadObsltPplExcel = async (): Promise => - getBlobData(`${API_PATH}/excel/download`); diff --git a/apps/sn/src/features/obsltPpl/lib/obslt-ppl-excel.ts b/apps/sn/src/features/obsltPpl/lib/obslt-ppl-excel.ts index f54ac38b9..049a94017 100644 --- a/apps/sn/src/features/obsltPpl/lib/obslt-ppl-excel.ts +++ b/apps/sn/src/features/obsltPpl/lib/obslt-ppl-excel.ts @@ -1,6 +1,6 @@ import * as XLSX from "xlsx"; -import type { ObsltPplBusinessSaveRequest } from "../model/obslt-ppl-types"; +import type { ObsltPplBusiness, ObsltPplBusinessSaveRequest } from "../model/obslt-ppl-types"; type BusinessType = ObsltPplBusinessSaveRequest["bizSe"]; @@ -32,6 +32,7 @@ const SHEET_CONTRACTS: SheetContract[] = [ const DATA_START_ROW_INDEX = 7; const FIRST_DATA_COLUMN_INDEX = 1; const DATA_COLUMN_COUNT = 21; +const DOWNLOAD_FILENAME = "노후하수관로 정밀조사 및 정비사업 현황.xlsx"; /** 줄바꿈과 공백이 포함된 병합 헤더를 비교 가능한 문자열로 정규화한다. */ const normalizeHeader = (value: string) => value.replace(/\s+/g, "").trim(); @@ -80,17 +81,6 @@ const requiredText = (value: string, sheetName: string, rowNumber: number, field 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(); @@ -134,35 +124,33 @@ const parseSheet = (sheet: XLSX.WorkSheet, contract: SheetContract): ObsltPplBus const values = Array.from({ length: DATA_COLUMN_COUNT }, (_, offset) => cellText(sheet, rowIndex, FIRST_DATA_COLUMN_INDEX + offset), ); + const valueAt = (index: number) => values[index] ?? ""; const rowNumber = rowIndex + 1; - const bizSeq = optionalInteger(values[0], contract.sheetName, rowNumber); - const originalBizCd = optionalText(values[2]); - const fallbackType = contract.sheetName === "정밀조사" ? "S" : "M"; + const originalBizCd = optionalText(valueAt(2)); result.push({ bizSe: contract.sheetName, - bizSeq, - prcisExmnCycl: optionalText(values[1]), - bizCd: originalBizCd ?? `UNASSIGNED-${fallbackType}-${bizSeq ?? rowNumber}`, + prcisExmnCycl: optionalText(valueAt(1)), + bizCd: originalBizCd ?? "", 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]), + ctpvNm: optionalText(valueAt(3)), + sigunNm: optionalText(valueAt(4)), + envoNm: optionalText(valueAt(5)), + bizNm: requiredText(valueAt(6), contract.sheetName, rowNumber, "사업명"), + prplPrst: optionalText(valueAt(7)), + bgngYr: yearText(valueAt(8)), + cmcnYr: yearText(valueAt(9)), + planPrlg: optionalDecimal(valueAt(10), contract.sheetName, rowNumber, "계획연장(km)"), + frhfyrExpcPrlg: optionalDecimal(valueAt(11), contract.sheetName, rowNumber, "상반기(예상)연장(km)"), + rmndPrlg: optionalDecimal(valueAt(12), contract.sheetName, rowNumber, "잔여연장(km)"), + evdncDcmntSbmsnStts: optionalText(valueAt(13)), + bgngYmd: dateText(valueAt(14)), + cmcnYmd: dateText(valueAt(15)), + bizPrplDlyRsnCn: optionalText(valueAt(16)), + bizPicNm: optionalText(valueAt(17)), + bizPicCnpl: optionalText(valueAt(18)), + rmrkCn: optionalText(valueAt(19)), + pbcpOpnnCn: optionalText(valueAt(20)), }); } return result; @@ -185,3 +173,67 @@ export const parseObsltPplExcel = async (file: File): Promise { + const rows = Array.from({ length: DATA_START_ROW_INDEX }, () => Array(22).fill("")); + const topHeaders = [ + "순번", "정밀조사차수", contract.businessCodeHeader, "시도", "시군", "환경청", "사업명", + "추진현황", "착수년도", "준공(예정)년도", contract.extensionHeader, "", "", "", + contract.progressHeader, "", "사업추진부진사유", "사업담당자(지자체)", "", "비고", "", + ]; + const bottomHeaders = [ + "", "", "", "", "", "", "", "", "", "", "계획연장(km)", "상반기(예상)연장(km)", + "잔여연장(km)", "증빙서류제출여부", contract.startDateHeader, "준공(예정)일자(yyyy.mm.dd)", + "", "이름", "연락처", "", "공단의견", + ]; + topHeaders.forEach((value, index) => { rows[5]![index + FIRST_DATA_COLUMN_INDEX] = value; }); + bottomHeaders.forEach((value, index) => { rows[6]![index + FIRST_DATA_COLUMN_INDEX] = value; }); + return rows; +}; + +/** DB에 저장하지 않는 B열 순번을 시트 출력 순서로 만들고 C~V에 업무값을 배치한다. */ +const downloadDataRow = (row: ObsltPplBusiness, index: number): Array => [ + "", + index + 1, + row.prcisExmnCycl ?? "", + row.bizCd ?? "", + row.ctpvNm ?? "", + row.sigunNm ?? "", + row.envoNm ?? "", + row.bizNm ?? "", + row.prplPrst ?? "", + row.bgngYr ?? "", + row.cmcnYr ?? "", + row.planPrlg ?? "", + row.frhfyrExpcPrlg ?? "", + row.rmndPrlg ?? "", + row.evdncDcmntSbmsnStts ?? "", + row.bgngYmd ?? "", + row.cmcnYmd ?? "", + row.bizPrplDlyRsnCn ?? "", + row.bizPicNm ?? "", + row.bizPicCnpl ?? "", + row.rmrkCn ?? "", + row.pbcpOpnnCn ?? "", +]; + +/** 조회 결과를 정밀조사·정비사업 두 시트의 재업로드 가능한 위치 구조로 생성한다. */ +export const buildObsltPplWorkbook = (businesses: ObsltPplBusiness[]): XLSX.WorkBook => { + const workbook = XLSX.utils.book_new(); + SHEET_CONTRACTS.forEach((contract) => { + const data = businesses.filter((row) => row.bizSe === contract.sheetName); + const worksheet = XLSX.utils.aoa_to_sheet([ + ...downloadHeaderRows(contract), + ...data.map(downloadDataRow), + ]); + worksheet["!cols"] = Array.from({ length: 22 }, (_, index) => ({ wch: index === 7 ? 30 : 16 })); + XLSX.utils.book_append_sheet(workbook, worksheet, contract.sheetName); + }); + return workbook; +}; + +/** 생성한 두 시트 workbook을 브라우저 다운로드로 저장한다. */ +export const downloadObsltPplExcel = (businesses: ObsltPplBusiness[]) => { + XLSX.writeFileXLSX(buildObsltPplWorkbook(businesses), DOWNLOAD_FILENAME); +}; diff --git a/apps/sn/src/features/obsltPpl/lib/obslt-ppl-region.ts b/apps/sn/src/features/obsltPpl/lib/obslt-ppl-region.ts index a841ca4be..2fc38854e 100644 --- a/apps/sn/src/features/obsltPpl/lib/obslt-ppl-region.ts +++ b/apps/sn/src/features/obsltPpl/lib/obslt-ppl-region.ts @@ -4,6 +4,15 @@ type RegionOption = { }; const normalizeRegionText = (value?: string | null) => String(value ?? "").replace(/\s+/g, " ").trim(); +export const OBSLT_PPL_ENV_OFFICES = [ + { code: "G2101", name: "한강유역환경청" }, + { code: "G2102", name: "원주지방환경청" }, + { code: "G2201", name: "낙동강유역환경청" }, + { code: "G2202", name: "대구지방환경청" }, + { code: "G2301", name: "금강유역환경청" }, + { code: "G2302", name: "전북지방환경청" }, + { code: "G2501", name: "영산강유역환경청" }, +] as const; /** 시군구명에서 상위 시만 대표명으로 추출한다. */ export const extractObsltPplRepresentativeSigunName = (value?: string | null) => { @@ -19,6 +28,13 @@ export const normalizeObsltPplSigunName = (value?: string | null) => { return representative || normalizeRegionText(value); }; +/** 저장 가능한 환경청 명칭인지 검사한다. */ +export const isValidObsltPplEnvOfficeName = (value?: string | null) => { + const normalizedValue = normalizeRegionText(value); + if (!normalizedValue) return true; + return OBSLT_PPL_ENV_OFFICES.some((office) => office.name === normalizedValue); +}; + /** 선택된 시도 기준으로 대표 시군구 한 건씩만 노출할 옵션 목록을 만든다. */ export const buildObsltPplRepresentativeSggOptions = ( sggOptions: RegionOption[], diff --git a/apps/sn/src/features/obsltPpl/model/obslt-ppl-types.ts b/apps/sn/src/features/obsltPpl/model/obslt-ppl-types.ts index 8bca2f992..dd991d1bf 100644 --- a/apps/sn/src/features/obsltPpl/model/obslt-ppl-types.ts +++ b/apps/sn/src/features/obsltPpl/model/obslt-ppl-types.ts @@ -2,7 +2,6 @@ export interface ObsltPplBusiness { obsltSwagePplBizSn: number; bizSe: "정밀조사" | "정비사업"; - bizSeq?: number | null; prcisExmnCycl?: string | null; bizCd: string; sggCd?: string | null; @@ -43,7 +42,7 @@ export interface ObsltPplPage { size: number; } -/** Excel 교체 업로드 결과 건수를 표시한다. */ +/** Excel UPDATE·INSERT 업로드 결과 건수를 표시한다. */ export interface ObsltPplImportResult { importedCount: number; precisionSurveyCount: number; diff --git a/apps/sn/src/features/obsltPpl/ui/ObsltPplFormPage.tsx b/apps/sn/src/features/obsltPpl/ui/ObsltPplFormPage.tsx index ed776d2e5..3bcbe99f9 100644 --- a/apps/sn/src/features/obsltPpl/ui/ObsltPplFormPage.tsx +++ b/apps/sn/src/features/obsltPpl/ui/ObsltPplFormPage.tsx @@ -16,7 +16,9 @@ import { buildObsltPplRepresentativeSggOptions, findObsltPplCtpvCodeByName, findObsltPplRepresentativeSggCode, + isValidObsltPplEnvOfficeName, normalizeObsltPplSigunName, + OBSLT_PPL_ENV_OFFICES, } from "../lib/obslt-ppl-region"; import type { ObsltPplBusiness, ObsltPplBusinessSaveRequest } from "../model/obslt-ppl-types"; @@ -29,7 +31,7 @@ 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: "bizSe", label: "사업구분", required: true }, { key: "prcisExmnCycl", label: "정밀조사차수" }, { key: "bizCd", label: "사업코드", required: true }, { key: "envoNm", label: "환경청" }, { key: "bizNm", label: "사업명", required: true }, { key: "prplPrst", label: "추진현황" }, { key: "bgngYr", label: "착수년도", placeholder: "YYYY" }, @@ -41,7 +43,7 @@ const FIELDS: Array<{ key: FormKey; label: string; type?: "number" | "textarea"; { key: "rmrkCn", label: "비고", type: "textarea" }, { key: "pbcpOpnnCn", label: "공단의견", type: "textarea" }, ]; -const NUMBER_KEYS = new Set(["bizSeq", "planPrlg", "frhfyrExpcPrlg", "rmndPrlg"]); +const NUMBER_KEYS = new Set(["planPrlg", "frhfyrExpcPrlg", "rmndPrlg"]); /** 모든 입력을 제어 컴포넌트로 유지하기 위한 빈 폼 값을 생성한다. */ const createEmptyForm = (): FormState => Object.fromEntries( @@ -111,7 +113,11 @@ export function ObsltPplFormPage({ id }: { id?: string }) { 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; } + if ([...NUMBER_KEYS].some((key) => form[key] && !Number.isFinite(Number(form[key])))) { setNotice("연장 값은 숫자로 입력해주세요."); return; } + if (!isValidObsltPplEnvOfficeName(form.envoNm)) { + setNotice(`환경청은 ${OBSLT_PPL_ENV_OFFICES.map((office) => office.name).join(", ")} 중 하나여야 합니다.`); + return; + } setLoading(true); try { if (id) await updateObsltPplBusiness(id, toRequest(form)); diff --git a/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx b/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx index e8f3dad1d..3d9b0624d 100644 --- a/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx +++ b/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx @@ -1,18 +1,20 @@ "use client"; -import moment from "moment"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; +import moment from "moment"; + import { SearchDiv, SearchFormLeft, SearchFormRight, SelectBox, UiTable } from "@common/business_components/ui"; import ListDiv from "@common/business_components/ui/list_div"; import { Button, Input, Label } from "@common/components/ui"; import { + getAllObsltPplBusinesses, getObsltPplBusinesses, uploadObsltPplExcel, } from "../api/obslt-ppl-api"; -import { parseObsltPplExcel } from "../lib/obslt-ppl-excel"; +import { downloadObsltPplExcel, parseObsltPplExcel } from "../lib/obslt-ppl-excel"; import { buildObsltPplRepresentativeSggOptions, normalizeObsltPplSigunName } from "../lib/obslt-ppl-region"; import type { ObsltPplBusiness, ObsltPplPage } from "../model/obslt-ppl-types"; @@ -36,7 +38,6 @@ type ObsltPplTableRow = ObsltPplBusiness & { const TABLE_HEADLIST = [ { 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 }, @@ -92,33 +93,6 @@ const formatBusinessForDisplay = (row: ObsltPplBusiness): ObsltPplBusiness => ({ 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(); @@ -206,28 +180,16 @@ export function ObsltPplListPage() { } }; - /** 현재 조회조건 기준 전체 목록을 화면 포맷 그대로 xlsx로 내려받는다. */ + /** 적용된 조회조건 전체를 두 업무 시트의 업로드 호환 위치로 내려받는다. */ const handleDownload = async () => { setLoading(true); try { - const params = new URLSearchParams({ - page: "0", - size: String(Math.max(data.totalElements, 1)), - sort: "obsltSwagePplBizSn,desc", - }); + const params = new URLSearchParams(); 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"); + const exportRows = await getAllObsltPplBusinesses(params); + downloadObsltPplExcel(exportRows); } catch (error) { setNotice(error instanceof Error ? error.message : "엑셀을 내려받지 못했습니다."); } finally { From 1858739bb9bff1c263974e8768f7bbd663bb1fa1 Mon Sep 17 00:00:00 2001 From: ljpark Date: Thu, 6 Aug 2026 10:46:47 +0900 Subject: [PATCH 2/7] =?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=85=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=A1=B0=EA=B1=B4=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/src/features/common/util.ts | 17 ++++ .../features/obsltPpl/lib/obslt-ppl-excel.ts | 5 +- .../features/obsltPpl/ui/ObsltPplFormPage.tsx | 61 +++++++++++-- .../features/obsltPpl/ui/ObsltPplListPage.tsx | 86 +++++++++++++------ 4 files changed, 130 insertions(+), 39 deletions(-) create mode 100644 apps/sn/src/features/common/util.ts diff --git a/apps/sn/src/features/common/util.ts b/apps/sn/src/features/common/util.ts new file mode 100644 index 000000000..7686cafc1 --- /dev/null +++ b/apps/sn/src/features/common/util.ts @@ -0,0 +1,17 @@ +import moment from "moment"; + +/** 8자리 또는 구분자가 섞인 날짜 문자열을 YYYY-MM-DD 형식으로 정규화한다. */ +export const formatDate = (value?: string | null) => { + if (!value) return ""; + const normalized = String(value).trim(); + if (!normalized) return ""; + + const digits = normalized.replace(/\D/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; +}; diff --git a/apps/sn/src/features/obsltPpl/lib/obslt-ppl-excel.ts b/apps/sn/src/features/obsltPpl/lib/obslt-ppl-excel.ts index 049a94017..c4e7da426 100644 --- a/apps/sn/src/features/obsltPpl/lib/obslt-ppl-excel.ts +++ b/apps/sn/src/features/obsltPpl/lib/obslt-ppl-excel.ts @@ -1,5 +1,6 @@ import * as XLSX from "xlsx"; +import { formatDate } from "../../common/util"; import type { ObsltPplBusiness, ObsltPplBusinessSaveRequest } from "../model/obslt-ppl-types"; type BusinessType = ObsltPplBusinessSaveRequest["bizSe"]; @@ -209,8 +210,8 @@ const downloadDataRow = (row: ObsltPplBusiness, index: number): Array(["planPrlg", "frhfyrExpcPrlg", "rmndPrlg"]); +const DATE_KEYS = new Set(["bgngYmd", "cmcnYmd"]); + +/** 숫자만 받아 입력 길이에 맞춰 YYYY-MM-DD 형식의 하이픈을 자동으로 표시한다. */ +const formatDateInput = (value: string) => { + const digits = value.replace(/\D/g, "").slice(0, 8); + if (digits.length <= 4) return digits; + if (digits.length <= 6) return `${digits.slice(0, 4)}-${digits.slice(4)}`; + return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6)}`; +}; + +/** 선택 입력인 일자가 비어 있거나 실제 달력에 존재하는 YYYY-MM-DD인지 확인한다. */ +const isValidDateInput = (value: string) => + !value.trim() || (/^\d{4}-\d{2}-\d{2}$/.test(value) && moment(value, "YYYY-MM-DD", true).isValid()); + +/** API의 기존 날짜가 날짜형이면 화면 형식으로 바꾸고, 비정상 원문은 숨기지 않고 검증 대상으로 남긴다. */ +const formatStoredDate = (value: string) => + formatDate(value) || value; /** 모든 입력을 제어 컴포넌트로 유지하기 위한 빈 폼 값을 생성한다. */ const createEmptyForm = (): FormState => Object.fromEntries( @@ -53,7 +73,10 @@ const createEmptyForm = (): FormState => Object.fromEntries( /** API 상세값을 빈 문자열 기반 화면 폼 값으로 변환한다. */ const toForm = (business: ObsltPplBusiness): FormState => { const form = createEmptyForm(); - FIELDS.forEach(({ key }) => { form[key] = String(business[key] ?? ""); }); + FIELDS.forEach(({ key }) => { + const value = String(business[key] ?? ""); + form[key] = DATE_KEYS.has(key) ? formatStoredDate(value) : value; + }); form.sggCd = String(business.sggCd ?? ""); form.ctpvNm = String(business.ctpvNm ?? ""); form.sigunNm = normalizeObsltPplSigunName(business.sigunNm); @@ -64,13 +87,16 @@ const toForm = (business: ObsltPplBusiness): FormState => { const toRequest = (form: FormState): ObsltPplBusinessSaveRequest => Object.fromEntries( [...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)]; + if (NUMBER_KEYS.has(key)) return [key, value === "" ? null : Number(value)]; + if (DATE_KEYS.has(key)) return [key, value === "" ? null : value.replace(/\D/g, "")]; + return [key, value === "" ? null : value]; }), ) as unknown as ObsltPplBusinessSaveRequest; /** 배너 등록 화면의 표형 폼 패턴을 적용한 별도 등록·수정 화면이다. */ export function ObsltPplFormPage({ id }: { id?: string }) { const router = useRouter(); + const searchParams = useSearchParams(); const isModify = Boolean(id); const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); const [form, setForm] = useState(createEmptyForm); @@ -78,6 +104,8 @@ export function ObsltPplFormPage({ id }: { id?: string }) { const [notice, setNotice] = useState(""); const [ctpvCd, setCtpvCd] = useState("0"); const [sggCd, setSggCd] = useState("0"); + const listQueryString = searchParams.toString(); + const listHref = `/oper/obsltPplBiz${listQueryString ? `?${listQueryString}` : ""}`; const ctpvOptions = useMemo(() => [{ text: "전체", val: "0", hidden: true }, ...ctpv], [ctpv]); const representativeSggOptions = useMemo( @@ -114,6 +142,10 @@ export function ObsltPplFormPage({ id }: { id?: string }) { 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; } + if ([...DATE_KEYS].some((key) => !isValidDateInput(form[key]))) { + setNotice("일자는 YYYY-MM-DD 형식의 유효한 날짜로 입력해주세요."); + return; + } if (!isValidObsltPplEnvOfficeName(form.envoNm)) { setNotice(`환경청은 ${OBSLT_PPL_ENV_OFFICES.map((office) => office.name).join(", ")} 중 하나여야 합니다.`); return; @@ -123,7 +155,7 @@ export function ObsltPplFormPage({ id }: { id?: string }) { if (id) await updateObsltPplBusiness(id, toRequest(form)); else await createObsltPplBusiness(toRequest(form)); window.alert(isModify ? "수정되었습니다." : "등록되었습니다."); - router.push("/oper/obsltPplBiz"); + router.push(listHref); } catch (error) { setNotice(error instanceof Error ? error.message : "저장하지 못했습니다."); } finally { setLoading(false); } }; @@ -132,7 +164,7 @@ export function ObsltPplFormPage({ id }: { id?: string }) { const handleDelete = async () => { if (!id || !window.confirm("삭제하시겠습니까?")) return; setLoading(true); - try { await deleteObsltPplBusiness(id); window.alert("삭제되었습니다."); router.push("/oper/obsltPplBiz"); } + try { await deleteObsltPplBusiness(id); window.alert("삭제되었습니다."); router.push(listHref); } catch (error) { setNotice(error instanceof Error ? error.message : "삭제하지 못했습니다."); } finally { setLoading(false); } }; @@ -198,7 +230,18 @@ export function ObsltPplFormPage({ id }: { id?: string }) { ) : field.type === "textarea" ? (