diff --git a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx index d321dd5a4..fe16774ba 100644 --- a/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx +++ b/apps/sn/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx @@ -60,7 +60,7 @@ const ANALYSIS_POPUP_ITEMS: Array<{ }> = [ { key: "infoSearch", label: "정보검색", icon: Search }, { key: "pipeAnalysisPopup", label: "관로분석", icon: ChartColumnBig }, - { key: "floodPredictionMapPopup", label: "침수예측지도", icon: Waves }, + { key: "floodPredictionMapPopup", label: "침수예측현황", icon: Waves }, { key: "agedPipeStatusByCtpvPopup", label: "시도별 노후관로현황", diff --git a/apps/sn/app/(with-base-layout)/oper/ctyFldngPrvtnBiz/page.tsx b/apps/sn/app/(with-base-layout)/oper/ctyFldngPrvtnBiz/page.tsx new file mode 100644 index 000000000..d6791599c --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/ctyFldngPrvtnBiz/page.tsx @@ -0,0 +1,4 @@ +import { CtyFldngPrvtnBizListPage } from "@/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage"; + +/** 도시침수예방사업 목록 경로다. */ +export default function Page(){return ;} diff --git a/apps/sn/app/(with-base-layout)/oper/ctyFldngPrvtnBiz/register/[id]/page.tsx b/apps/sn/app/(with-base-layout)/oper/ctyFldngPrvtnBiz/register/[id]/page.tsx new file mode 100644 index 000000000..a212b25c6 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/ctyFldngPrvtnBiz/register/[id]/page.tsx @@ -0,0 +1,4 @@ +import { CtyFldngPrvtnBizFormPage } from "@/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage"; + +/** 도시침수예방사업 수정·삭제 경로다. */ +export default async function Page({params}:{params:Promise<{id:string}>}){const {id}=await params;return ;} diff --git a/apps/sn/app/(with-base-layout)/oper/ctyFldngPrvtnBiz/register/page.tsx b/apps/sn/app/(with-base-layout)/oper/ctyFldngPrvtnBiz/register/page.tsx new file mode 100644 index 000000000..c31ff139d --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/ctyFldngPrvtnBiz/register/page.tsx @@ -0,0 +1,4 @@ +import { CtyFldngPrvtnBizFormPage } from "@/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage"; + +/** 도시침수예방사업 신규 등록 경로다. */ +export default function Page(){return ;} diff --git a/apps/sn/docs/작업검증/SN-도시침수예방사업/001-qa-SN-도시침수예방사업-FE검증.md b/apps/sn/docs/작업검증/SN-도시침수예방사업/001-qa-SN-도시침수예방사업-FE검증.md new file mode 100644 index 000000000..11f1df2d4 --- /dev/null +++ b/apps/sn/docs/작업검증/SN-도시침수예방사업/001-qa-SN-도시침수예방사업-FE검증.md @@ -0,0 +1,11 @@ +# SN-도시침수예방사업 FE 검증 + +- 대상 ESLint: 오류 0건. +- TypeScript 전체 검사: 신규 `ctyFldngPrvtnBiz` 오류 0건. 저장소 기존 타 기능 오류로 전체 명령은 실패했다. +- 전체 Next build: 사용자 개발 서버가 실행 중이므로 수행하지 않았다. +- 실행 서버 route 확인: + - `/oper/ctyFldngPrvtnBiz`: HTTP 200 + - `/oper/ctyFldngPrvtnBiz/register`: HTTP 200 + - `/oper/ctyFldngPrvtnBiz/register/1`: HTTP 200 +- 0811 원본 구조 확인: `사업 추진 현황` 시트, 상세 시작 `AJ/BK/CS/DX`, 결산 2013~2026년, 데이터 257행을 확인했다. +- 실제 API·DB 저장과 브라우저 조작, 원본 파일 업로드 후 DB 대조는 테이블 및 인증 환경에서 추가 확인이 필요하다. diff --git a/apps/sn/docs/작업계획/SN-도시침수예방사업/001-fe-SN-도시침수예방사업-작업카드.md b/apps/sn/docs/작업계획/SN-도시침수예방사업/001-fe-SN-도시침수예방사업-작업카드.md new file mode 100644 index 000000000..bd2b08a4a --- /dev/null +++ b/apps/sn/docs/작업계획/SN-도시침수예방사업/001-fe-SN-도시침수예방사업-작업카드.md @@ -0,0 +1,9 @@ +# SN-도시침수예방사업 FE 작업카드 + +- 경로: `/oper/ctyFldngPrvtnBiz`, `/register`, `/register/{id}` +- API: `/api/v1/ctyFldngPrvtnBiz` +- 목록: 공통 지역 store 검색, URL 조건 유지, 전체 Excel 항목 가로 스크롤 +- 등록·수정: 기본정보, 연도별 결산, EM/MP/DD/CW, 시설 가변행 +- Excel: 브라우저 SheetJS 처리 후 JSON 업로드, 현재연도까지 다운로드·양식 생성 +- 삭제: 수정 화면에서만 논리삭제 API 호출 +- 공통 컴포넌트와 다른 앱은 수정하지 않는다. diff --git a/apps/sn/docs/작업내용/SN-도시침수예방사업/001-fe-SN-도시침수예방사업-구현내용.md b/apps/sn/docs/작업내용/SN-도시침수예방사업/001-fe-SN-도시침수예방사업-구현내용.md new file mode 100644 index 000000000..ce42efdcb --- /dev/null +++ b/apps/sn/docs/작업내용/SN-도시침수예방사업/001-fe-SN-도시침수예방사업-구현내용.md @@ -0,0 +1,16 @@ +# SN-도시침수예방사업 FE 구현내용 + +## 구현 + +- 신규 App Router 3개와 `src/features/ctyFldngPrvtnBiz`의 API·타입·Excel·화면 모듈을 추가했다. +- 목록 조회조건과 페이지를 URL query로 유지하고 행 클릭 시 수정 화면으로 전달한다. +- Excel 저장 대상 전체를 평탄화하여 넓은 표와 가로 스크롤로 표시한다. +- 원본 `사업 추진 현황` 13행부터 기본·결산·네 상세를 읽어 중첩 JSON으로 업로드한다. +- 상세 시작 열은 상위 헤더로 찾고, 결산연도 증가에 따라 이동한 위치를 지원한다. +- 다운로드와 양식받기는 2013년부터 실행 시점 현재연도까지 같은 데이터 위치로 생성한다. +- 등록·수정 화면은 6개 구역과 빗물펌프장·펌프저류·저류시설 행 추가·삭제를 제공한다. + +## 영향 + +- 신규 FE feature와 route만 추가했다. +- 기존 공통 store와 UI 컴포넌트는 소비만 하며 수정하지 않았다. diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/api/cty-fldng-prvtn-biz-api.ts b/apps/sn/src/features/ctyFldngPrvtnBiz/api/cty-fldng-prvtn-biz-api.ts new file mode 100644 index 000000000..c05cca963 --- /dev/null +++ b/apps/sn/src/features/ctyFldngPrvtnBiz/api/cty-fldng-prvtn-biz-api.ts @@ -0,0 +1,77 @@ +import type { + CtyFldngImportResult, + CtyFldngPrvtnBiz, + CtyFldngPrvtnBizPage, + CtyFldngPrvtnBizSaveRequest, +} from "../model/cty-fldng-prvtn-biz-types"; + +import { + deleteData, + getData, + postRawJson, + putData, +} from "@/components/lib/api"; + +const API_PATH = "v1/ctyFldngPrvtnBiz"; + +/** 공통 응답 래퍼가 있으면 실제 data를 꺼낸다. */ +const unwrapData = (body: unknown): T => + body && typeof body === "object" && "data" in body + ? (body as { data: T }).data + : (body as T); + +/** 검색조건으로 도시침수예방사업 페이지를 조회한다. */ +export const getCtyFldngPrvtnBusinesses = (params: URLSearchParams) => + getData(`${API_PATH}?${params}`) as Promise; + +/** 모든 페이지를 순회하여 Excel 대상 전체를 모은다. */ +export const getAllCtyFldngPrvtnBusinesses = async ( + search: URLSearchParams, +) => { + const rows: CtyFldngPrvtnBiz[] = []; + let page = 0; + let totalPages = 1; + while (page < totalPages) { + const params = new URLSearchParams(search); + params.set("page", String(page)); + params.set("size", "500"); + params.set("sort", "ctyFldngPrvtnBizSn,asc"); + const result = await getCtyFldngPrvtnBusinesses(params); + rows.push(...result.content); + totalPages = result.totalPages; + page += 1; + } + return rows; +}; + +/** 수정 화면의 사업 한 건을 조회한다. */ +export const getCtyFldngPrvtnBusiness = (id: string) => + getData(`${API_PATH}/${encodeURIComponent(id)}`) as Promise; + +/** 도시침수예방사업을 신규 등록한다. */ +export const createCtyFldngPrvtnBusiness = async ( + request: CtyFldngPrvtnBizSaveRequest, +) => unwrapData(await postRawJson(API_PATH, request)); + +/** 도시침수예방사업을 수정한다. */ +export const updateCtyFldngPrvtnBusiness = ( + id: string, + request: CtyFldngPrvtnBizSaveRequest, +) => + putData( + `${API_PATH}/${encodeURIComponent(id)}`, + request, + ) as Promise; + +/** 도시침수예방사업을 논리삭제한다. */ +export const deleteCtyFldngPrvtnBusiness = async (id: string) => { + await deleteData(`${API_PATH}/${encodeURIComponent(id)}`); +}; + +/** 브라우저에서 파싱한 Excel JSON 행을 일괄 저장한다. */ +export const uploadCtyFldngPrvtnExcel = async ( + rows: CtyFldngPrvtnBizSaveRequest[], +) => + unwrapData( + await postRawJson(`${API_PATH}/excel/upload`, rows), + ); diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/lib/cty-fldng-prvtn-biz-excel.ts b/apps/sn/src/features/ctyFldngPrvtnBiz/lib/cty-fldng-prvtn-biz-excel.ts new file mode 100644 index 000000000..5a0b726af --- /dev/null +++ b/apps/sn/src/features/ctyFldngPrvtnBiz/lib/cty-fldng-prvtn-biz-excel.ts @@ -0,0 +1,733 @@ +import * as XLSX from "xlsx"; +import XlsxPopulate from "xlsx-populate/browser/xlsx-populate"; + +import type { + CtyFldngDetail, + CtyFldngFacility, + CtyFldngPrvtnBiz, + CtyFldngPrvtnBizSaveRequest, +} from "../model/cty-fldng-prvtn-biz-types"; + +const SHEET_NAME = "사업 추진 현황"; +const DATA_START_ROW = 12; +const FIRST_YEAR = 2013; +const DETAIL_CODES = ["EM", "MP", "DD", "CW"] as const; +const DETAIL_TITLES = { + EM: "중점관리지역 지정", + MP: "하수도정비대책 수립", + DD: "실시설계", + CW: "시설공사", +} as const; +const DETAIL_WIDTHS = { EM: 27, MP: 34, DD: 31, CW: 32 } as const; +const PUMP_COUNTS = { EM: 6, MP: 5, DD: 3, CW: 5 } as const; +const IMPD_COUNTS = { EM: 3, MP: 2, DD: 2, CW: 2 } as const; +const DOWNLOAD_FILENAME = "하수도정비중점관리지역 도시침수대응사업 현황.xlsx"; + +/** 공백과 줄바꿈을 제거해 병합 헤더를 비교한다. */ +const normalize = (value: unknown) => + String(value ?? "") + .replace(/\s+/g, "") + .trim(); + +/** SheetJS 셀의 표시 문자열을 반환한다. */ +const cellText = (sheet: XLSX.WorkSheet, row: number, col: number) => { + const cell = sheet[XLSX.utils.encode_cell({ r: row, c: col })]; + return cell ? XLSX.utils.format_cell(cell).trim() : ""; +}; + +/** 빈 값과 대시 표기를 null로 정규화한다. */ +const textValue = (value: unknown): string | null => { + const valueText = String(value ?? "").trim(); + return !valueText || valueText === "-" ? null : valueText; +}; + +/** 콤마와 단위를 제거한 유한 숫자를 반환한다. */ +const numberValue = ( + value: unknown, + row: number, + label: string, +): number | null => { + const normalized = textValue(value); + if (!normalized) return null; + const numericText = normalized.replace(/,/g, "").replace(/[^0-9.+-]/g, ""); + if (!numericText) throw new Error(`${row}행 ${label} 값이 숫자가 아닙니다.`); + const parsed = Number(numericText); + if (!Number.isFinite(parsed)) + throw new Error(`${row}행 ${label} 값이 숫자가 아닙니다.`); + return parsed; +}; + +/** Excel 날짜형과 문자열 날짜를 yyyy-MM-dd로 변환한다. */ +const dateValue = ( + sheet: XLSX.WorkSheet, + row: number, + col: number, +): string | null => { + const cell = sheet[XLSX.utils.encode_cell({ r: row, c: col })]; + if (!cell) return null; + if (typeof cell.v === "number") { + const d = XLSX.SSF.parse_date_code(cell.v); + return d + ? `${d.y}-${String(d.m).padStart(2, "0")}-${String(d.d).padStart(2, "0")}` + : null; + } + const digits = String(cell.v ?? "") + .replace(/\D/g, "") + .slice(0, 8); + return digits.length === 8 + ? `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6)}` + : null; +}; + +/** 헤더 8~12행에서 지정 문구가 포함된 시작 열을 찾는다. */ +const findSectionColumn = (sheet: XLSX.WorkSheet, title: string) => { + const range = XLSX.utils.decode_range(sheet["!ref"] ?? "A1:A1"); + for (let row = 7; row <= 11; row += 1) + for (let col = 0; col <= range.e.c; col += 1) + if (normalize(cellText(sheet, row, col)).includes(normalize(title))) + return col; + throw new Error(`'${title}' 구역 헤더를 찾을 수 없습니다.`); +}; + +/** 상세 시설 열을 가변 JSON 배열로 묶는다. */ +const facilityValues = ( + sheet: XLSX.WorkSheet, + row: number, + start: number, + count: number, + label: string, +): CtyFldngFacility[] => + Array.from({ length: count }, (_, index) => ({ + no: index + 1, + cpct: numberValue(cellText(sheet, row, start + index), row + 1, label), + })).filter((item) => item.cpct !== null); + +/** 상세 구역 한 블록을 공통 상세 DTO로 파싱한다. */ +const parseDetail = ( + sheet: XLSX.WorkSheet, + row: number, + start: number, + code: (typeof DETAIL_CODES)[number], +): CtyFldngDetail => { + const pumpCount = PUMP_COUNTS[code]; + const impdCount = IMPD_COUNTS[code]; + let offset = 0; + const detail: CtyFldngDetail = { + bizPrgrsSe: code, + area: numberValue(cellText(sheet, row, start + offset++), row + 1, "면적"), + designFreqCn: textValue(cellText(sheet, row, start + offset++)), + rainStrCn: textValue(cellText(sheet, row, start + offset++)), + pvodPrfrncGoalRainStr: numberValue( + cellText(sheet, row, start + offset++), + row + 1, + "방재성능목표", + ), + exlnPplSumLen: numberValue( + cellText(sheet, row, start + offset++), + row + 1, + "우수관로 합계", + ), + exlnPplNbldpLen: numberValue( + cellText(sheet, row, start + offset++), + row + 1, + "우수관로 신설", + ), + exlnPplImprvmLen: numberValue( + cellText(sheet, row, start + offset++), + row + 1, + "우수관로 개량", + ), + rnwtrPumpstSumCn: textValue(cellText(sheet, row, start + offset++)), + rnwtrPumpst: [], + pumpImpd: [], + impdFclt: [], + }; + detail.rnwtrPumpst = facilityValues( + sheet, + row, + start + offset, + pumpCount, + "빗물펌프장", + ); + offset += pumpCount; + detail.pumpImpdSumCn = textValue(cellText(sheet, row, start + offset++)); + detail.pumpImpd = facilityValues(sheet, row, start + offset, 3, "펌프저류"); + offset += 3; + detail.impdFcltSumCn = textValue(cellText(sheet, row, start + offset++)); + detail.impdFclt = facilityValues( + sheet, + row, + start + offset, + impdCount, + "저류시설", + ); + offset += impdCount; + detail.tnlDmtr = numberValue( + cellText(sheet, row, start + offset++), + row + 1, + "터널 직경", + ); + detail.tnlLen = numberValue( + cellText(sheet, row, start + offset++), + row + 1, + "터널 길이", + ); + detail.tnlRscpt = numberValue( + cellText(sheet, row, start + offset++), + row + 1, + "터널 저수용량", + ); + detail.tnlExcvDpth = numberValue( + cellText(sheet, row, start + offset++), + row + 1, + "터널 굴착깊이", + ); + if (code !== "EM") { + detail.rnwtrInflwFcltCnt = numberValue( + cellText(sheet, row, start + offset++), + row + 1, + "빗물유입시설 수", + ); + detail.mnhlSftyFcltCnt = numberValue( + cellText(sheet, row, start + offset++), + row + 1, + "맨홀안전시설 수", + ); + } + detail.tpco = numberValue( + cellText(sheet, row, start + offset++), + row + 1, + "상세 총사업비", + ); + if (code !== "EM") { + detail.gvexp = numberValue( + cellText(sheet, row, start + offset++), + row + 1, + "상세 국비", + ); + detail.lcexp = numberValue( + cellText(sheet, row, start + offset++), + row + 1, + "상세 지방비", + ); + detail.bgngYmd = dateValue(sheet, row, start + offset++); + detail.cmcnYmd = dateValue(sheet, row, start + offset++); + detail.frstAprvCn = textValue(cellText(sheet, row, start + offset++)); + detail.frstChgAprvCn = textValue(cellText(sheet, row, start + offset++)); + if (code === "MP") + detail.scndChgAprvCn = textValue(cellText(sheet, row, start + offset++)); + if (code === "CW") + detail.rmrkSrcCn = textValue(cellText(sheet, row, start + offset++)); + } + return detail; +}; + +/** 2013년부터 발견한 마지막 연도까지 결산 헤더를 읽는다. */ +const settlementColumns = (sheet: XLSX.WorkSheet, detailStart: number) => { + const columns: Array<{ year: string; col: number }> = []; + for (let col = 20; col < detailStart; col += 1) + for (let row = 7; row <= 11; row += 1) { + const match = cellText(sheet, row, col).match(/(20\d{2})|(\d{2})년도/); + const year = match?.[1] ?? (match?.[2] ? `20${match[2]}` : undefined); + if (year && !columns.some((item) => item.col === col)) { + columns.push({ year, col }); + break; + } + } + return columns.filter((item) => Number(item.year) >= FIRST_YEAR); +}; + +/** 업로드 양식의 한 데이터 행을 저장 요청으로 변환한다. */ +const parseRow = ( + sheet: XLSX.WorkSheet, + row: number, + starts: Record<(typeof DETAIL_CODES)[number], number>, + years: Array<{ year: string; col: number }>, +): CtyFldngPrvtnBizSaveRequest => { + return { + seqNo: numberValue(cellText(sheet, row, 1), row + 1, "순번"), + stp: textValue(cellText(sheet, row, 2)), + ctpvNm: textValue(cellText(sheet, row, 3)), + sggNm: textValue(cellText(sheet, row, 4)), + envoNm: textValue(cellText(sheet, row, 5)), + dsgnYrCycl: textValue(cellText(sheet, row, 6)), + dsgnYmd: dateValue(sheet, row, 7), + dsgnArea: numberValue(cellText(sheet, row, 8), row + 1, "지정면적"), + dsgnType: textValue(cellText(sheet, row, 9)), + drareaNm: textValue(cellText(sheet, row, 10)), + drndstNm: textValue(cellText(sheet, row, 11)), + pstn: textValue(cellText(sheet, row, 12)), + bizNm: textValue(cellText(sheet, row, 13)) ?? "", + bizCd: textValue(cellText(sheet, row, 14)), + prplPrst: textValue(cellText(sheet, row, 15)), + bizPrplPrst: textValue(cellText(sheet, row, 16)), + tpco: numberValue(cellText(sheet, row, 17), row + 1, "총사업비"), + gvexp: numberValue(cellText(sheet, row, 18), row + 1, "국비"), + lcexp: numberValue(cellText(sheet, row, 19), row + 1, "지방비"), + picNm: textValue(cellText(sheet, row, starts.CW + DETAIL_WIDTHS.CW)), + picCnpl: textValue(cellText(sheet, row, starts.CW + DETAIL_WIDTHS.CW + 1)), + excptnMttrCn: textValue( + cellText(sheet, row, starts.CW + DETAIL_WIDTHS.CW + 2), + ), + settlements: years + .map(({ year, col }) => ({ + soaYr: year, + soaAmt: numberValue( + cellText(sheet, row, col), + row + 1, + `${year}년 결산`, + ), + })) + .filter((item) => item.soaAmt !== null), + details: DETAIL_CODES.map((code) => + parseDetail(sheet, row, starts[code], code), + ), + }; +}; + +/** 0811 단일 시트 형식을 검증하고 모든 데이터행을 JSON으로 파싱한다. */ +export const parseCtyFldngPrvtnExcel = async ( + file: File, +): Promise => { + const workbook = XLSX.read(await file.arrayBuffer(), { + type: "array", + cellDates: false, + }); + const sheet = workbook.Sheets[SHEET_NAME]; + if (!sheet) throw new Error(`'${SHEET_NAME}' 시트가 필요합니다.`); + const starts = Object.fromEntries( + DETAIL_CODES.map((code) => [ + code, + findSectionColumn(sheet, DETAIL_TITLES[code]), + ]), + ) as Record<(typeof DETAIL_CODES)[number], number>; + const years = settlementColumns(sheet, starts.EM!); + const range = XLSX.utils.decode_range(sheet["!ref"] ?? "A1:A1"); + const rows: CtyFldngPrvtnBizSaveRequest[] = []; + for (let row = DATA_START_ROW; row <= range.e.r; row += 1) { + const hasData = Array.from({ length: 19 }, (_, i) => + cellText(sheet, row, i + 1), + ).some(Boolean); + if (hasData) rows.push(parseRow(sheet, row, starts, years)); + } + if (!rows.length) throw new Error("업로드할 데이터 행이 없습니다."); + return rows; +}; + +/** 상세 DTO 값을 섹션 상대 위치에 기록한다. */ +const writeDetail = ( + row: unknown[], + start: number, + detail: CtyFldngDetail | undefined, + code: (typeof DETAIL_CODES)[number], +) => { + if (!detail) return; + let o = 0; + [ + detail.area, + detail.designFreqCn, + detail.rainStrCn, + detail.pvodPrfrncGoalRainStr, + detail.exlnPplSumLen, + detail.exlnPplNbldpLen, + detail.exlnPplImprvmLen, + detail.rnwtrPumpstSumCn, + ].forEach((v) => { + row[start + o++] = v ?? ""; + }); + for (let i = 0; i < PUMP_COUNTS[code]; i += 1) + row[start + o++] = + detail.rnwtrPumpst?.find((v) => v.no === i + 1)?.cpct ?? ""; + row[start + o++] = detail.pumpImpdSumCn ?? ""; + for (let i = 0; i < 3; i += 1) + row[start + o++] = detail.pumpImpd?.find((v) => v.no === i + 1)?.cpct ?? ""; + row[start + o++] = detail.impdFcltSumCn ?? ""; + for (let i = 0; i < IMPD_COUNTS[code]; i += 1) + row[start + o++] = detail.impdFclt?.find((v) => v.no === i + 1)?.cpct ?? ""; + [detail.tnlDmtr, detail.tnlLen, detail.tnlRscpt, detail.tnlExcvDpth].forEach( + (v) => { + row[start + o++] = v ?? ""; + }, + ); + if (code !== "EM") { + row[start + o++] = detail.rnwtrInflwFcltCnt ?? ""; + row[start + o++] = detail.mnhlSftyFcltCnt ?? ""; + } + row[start + o++] = detail.tpco ?? ""; + if (code !== "EM") { + [ + detail.gvexp, + detail.lcexp, + detail.bgngYmd, + detail.cmcnYmd, + detail.frstAprvCn, + detail.frstChgAprvCn, + ].forEach((v) => { + row[start + o++] = v ?? ""; + }); + if (code === "MP") row[start + o++] = detail.scndChgAprvCn ?? ""; + if (code === "CW") row[start + o++] = detail.rmrkSrcCn ?? ""; + } +}; + +/** 헤더 배열의 지정 위치에 값을 기록한다. */ +const putHeader = ( + aoa: unknown[][], + row: number, + col: number, + value: string, +) => { + aoa[row]![col] = value; +}; + +/** 0기준 좌표를 SheetJS 병합 범위로 추가한다. */ +const addMerge = ( + merges: XLSX.Range[], + startRow: number, + startCol: number, + endRow: number, + endCol: number, +) => { + merges.push({ s: { r: startRow, c: startCol }, e: { r: endRow, c: endCol } }); +}; + +/** 공통 3열 우수관로 헤더와 세로 병합을 구성한다. */ +const writePipeHeader = ( + aoa: unknown[][], + merges: XLSX.Range[], + start: number, +) => { + putHeader(aoa, 9, start, "우수관로(km)"); + addMerge(merges, 9, start, 9, start + 2); + ["합계", "신설", "개량"].forEach((label, index) => { + putHeader(aoa, 10, start + index, label); + addMerge(merges, 10, start + index, 11, start + index); + }); +}; + +/** 펌프장·유수지 세부번호 헤더를 병합 구조로 구성한다. */ +const writePumpHeader = ( + aoa: unknown[][], + merges: XLSX.Range[], + start: number, + pumpCount: number, +) => { + const pumpWidth = pumpCount + 1; + putHeader(aoa, 9, start, "빗물펌프장(㎥/분, ㎥)"); + addMerge(merges, 9, start, 9, start + pumpWidth + 3); + putHeader(aoa, 10, start, "펌프장"); + addMerge(merges, 10, start, 10, start + pumpWidth - 1); + putHeader(aoa, 10, start + pumpWidth, "유수지"); + addMerge(merges, 10, start + pumpWidth, 10, start + pumpWidth + 3); + [ + "합계", + ...Array.from({ length: pumpCount }, (_, index) => `#${index + 1}`), + ].forEach((label, index) => putHeader(aoa, 11, start + index, label)); + ["합계", "#1", "#2", "#3"].forEach((label, index) => + putHeader(aoa, 11, start + pumpWidth + index, label), + ); +}; + +/** 저류시설 합계·번호 헤더를 2개 행 병합 구조로 구성한다. */ +const writeStorageHeader = ( + aoa: unknown[][], + merges: XLSX.Range[], + start: number, + itemCount: number, +) => { + putHeader(aoa, 9, start, "저류시설(㎥)"); + addMerge(merges, 9, start, 10, start + itemCount); + [ + "합계", + ...Array.from({ length: itemCount }, (_, index) => `#${index + 1}`), + ].forEach((label, index) => putHeader(aoa, 11, start + index, label)); +}; + +/** 대심도터널 4개 항목 헤더를 구성한다. */ +const writeTunnelHeader = ( + aoa: unknown[][], + merges: XLSX.Range[], + start: number, +) => { + putHeader(aoa, 9, start, "대심도터널"); + addMerge(merges, 9, start, 9, start + 3); + ["직경(m)", "길이(km)", "저수용량(㎥)", "굴착심도(m)"].forEach( + (label, index) => { + putHeader(aoa, 10, start + index, label); + addMerge(merges, 10, start + index, 11, start + index); + }, + ); +}; + +/** EM/MP/DD/CW 구역별 5단 병합 헤더를 원본 위치 계약에 맞게 구성한다. */ +const writeDetailSectionHeader = ( + aoa: unknown[][], + merges: XLSX.Range[], + start: number, + code: (typeof DETAIL_CODES)[number], +) => { + const end = start + DETAIL_WIDTHS[code] - 1; + putHeader(aoa, 7, start, DETAIL_TITLES[code]); + addMerge(merges, 7, start, 7, end); + putHeader(aoa, 8, start, "면적\n(ha)"); + addMerge(merges, 8, start, 11, start); + putHeader(aoa, 8, start + 1, "설계기준"); + addMerge(merges, 8, start + 1, 8, start + 3); + ["설계빈도\n(년)", "강우강도\n(mm/hr)", "방재성능목표\n(mm/hr)"].forEach( + (label, index) => { + putHeader(aoa, 9, start + 1 + index, label); + addMerge(merges, 9, start + 1 + index, 11, start + 1 + index); + }, + ); + + const contentEnd = + code === "EM" + ? start + 25 + : code === "MP" + ? start + 25 + : code === "DD" + ? start + 23 + : start + 25; + putHeader(aoa, 8, start + 4, "사업내용 ※ 개소별 용량 기재"); + addMerge(merges, 8, start + 4, 8, contentEnd); + writePipeHeader(aoa, merges, start + 4); + + const pumpCount = PUMP_COUNTS[code]; + writePumpHeader(aoa, merges, start + 7, pumpCount); + const storageStart = start + 7 + pumpCount + 5; + writeStorageHeader(aoa, merges, storageStart, IMPD_COUNTS[code]); + const tunnelStart = storageStart + IMPD_COUNTS[code] + 1; + writeTunnelHeader(aoa, merges, tunnelStart); + + let next = tunnelStart + 4; + if (code !== "EM") { + ["빗물받이\n(개소)", "맨홀추락방지\n(개소)"].forEach((label) => { + putHeader(aoa, 9, next, label); + addMerge(merges, 9, next, 11, next); + next += 1; + }); + } + + if (code === "EM") { + putHeader(aoa, 8, next, "사업비\n(백만원)"); + addMerge(merges, 8, next, 11, next); + return; + } + + putHeader(aoa, 8, next, "사업비(백만원)"); + addMerge(merges, 8, next, 8, next + 2); + ["총사업비", "국고", "지방비"].forEach((label, index) => { + putHeader(aoa, 9, next + index, label); + addMerge(merges, 9, next + index, 11, next + index); + }); + next += 3; + + if (code === "CW") { + [ + "착공(예정)일", + "준공(예정)일", + "비고(출처)\nex. 사업추진현황, 운영관리현황, 승인 등", + ].forEach((label, index) => { + putHeader(aoa, 8, next + index, label); + addMerge(merges, 8, next + index, 11, next + index); + }); + return; + } + + putHeader(aoa, 8, next, code === "MP" ? "수립기간" : "설계기간"); + addMerge(merges, 8, next, 8, next + 1); + ["착수", "준공"].forEach((label, index) => { + putHeader(aoa, 9, next + index, label); + addMerge(merges, 9, next + index, 11, next + index); + }); + next += 2; + putHeader(aoa, 8, next, code === "MP" ? "정비대책 승인" : "승인"); + addMerge(merges, 8, next, 8, end); + const approvals = + code === "MP" ? ["최초", "1차 변경", "2차 변경"] : ["최초", "변경"]; + approvals.forEach((label, index) => { + putHeader(aoa, 9, next + index, label); + addMerge(merges, 9, next + index, 11, next + index); + }); +}; + +/** XlsxPopulate로 병합 헤더 색·테두리를 적용하고 브라우저 다운로드를 시작한다. */ +const saveStyledWorkbook = async ( + workbook: XLSX.WorkBook, + filename: string, + starts: Record<(typeof DETAIL_CODES)[number], number>, + last: number, +) => { + const data = XLSX.write(workbook, { type: "array", bookType: "xlsx" }); + const styledWorkbook = await XlsxPopulate.fromDataAsync(data); + const styledSheet = styledWorkbook.sheet(SHEET_NAME); + const lastColumn = XLSX.utils.encode_col(last); + const commonHeaderStyle = { + bold: true, + fontFamily: "맑은 고딕", + fontSize: 11, + horizontalAlignment: "center", + verticalAlignment: "center", + wrapText: true, + border: true, + }; + styledSheet.range(`B8:${lastColumn}12`).style(commonHeaderStyle); + styledSheet + .range(`B8:${XLSX.utils.encode_col(starts.EM - 1)}12`) + .style("fill", "B4C6E7"); + DETAIL_CODES.forEach((code) => { + const end = starts[code] + DETAIL_WIDTHS[code] - 1; + styledSheet + .range( + `${XLSX.utils.encode_col(starts[code])}8:${XLSX.utils.encode_col(end)}12`, + ) + .style("fill", code === "EM" || code === "DD" ? "EDFEE7" : "E7F4FE"); + }); + styledSheet + .range( + `${XLSX.utils.encode_col(starts.CW + DETAIL_WIDTHS.CW)}8:${lastColumn}12`, + ) + .style("fill", "B4C6E7"); + [8, 9, 10, 11, 12].forEach((row) => + styledSheet.row(row).height(row === 8 ? 30 : 24), + ); + const blob = await styledWorkbook.outputAsync(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); +}; + +/** 현재 연도까지 원본과 같은 병합 헤더 위치·구분색을 가진 한 시트 xlsx를 생성한다. */ +export const downloadCtyFldngPrvtnExcel = async ( + businesses: CtyFldngPrvtnBiz[], + templateOnly = false, +) => { + const years = Array.from( + { length: new Date().getFullYear() - FIRST_YEAR + 1 }, + (_, i) => String(FIRST_YEAR + i), + ); + const starts = { + EM: 21 + years.length, + MP: 21 + years.length + DETAIL_WIDTHS.EM, + DD: 21 + years.length + DETAIL_WIDTHS.EM + DETAIL_WIDTHS.MP, + CW: + 21 + + years.length + + DETAIL_WIDTHS.EM + + DETAIL_WIDTHS.MP + + DETAIL_WIDTHS.DD, + }; + const last = starts.CW + DETAIL_WIDTHS.CW + 2; + const aoa: unknown[][] = Array.from({ length: DATA_START_ROW }, () => + Array(last + 1).fill(""), + ); + const merges: XLSX.Range[] = []; + aoa[0]![1] = "하수도정비중점관리지역 도시침수대응사업 현황"; + const verticalBasicHeaders: Array<[number, string]> = [ + [1, "순번"], + [2, "단계"], + [3, "시도"], + [4, "시군구"], + [5, "환경청"], + [6, "지정년도"], + [7, "지정일"], + [8, "지정면적\n(ha)"], + [9, "유형"], + [13, "사업명"], + [14, "사업코드"], + [15, "추진현황\n(수립중/수립완료/설계중/설계완료/\n공사중/완료)"], + [16, "사업추진\n현황"], + ]; + verticalBasicHeaders.forEach(([col, label]) => { + putHeader(aoa, 7, col, label); + addMerge(merges, 7, col, 11, col); + }); + putHeader(aoa, 7, 10, "범위"); + addMerge(merges, 7, 10, 7, 12); + ["배수구역", "배수분구", "위치"].forEach((label, index) => { + putHeader(aoa, 8, 10 + index, label); + addMerge(merges, 8, 10 + index, 11, 10 + index); + }); + putHeader(aoa, 7, 17, "총사업비(백만원)"); + addMerge(merges, 7, 17, 8, 19); + ["총사업비", "국고", "지방비"].forEach((label, index) => { + putHeader(aoa, 9, 17 + index, label); + addMerge(merges, 9, 17 + index, 11, 17 + index); + }); + aoa[7]![20] = "결산 현황(백만원)"; + addMerge(merges, 7, 20, 8, starts.EM - 1); + putHeader(aoa, 9, 20, "총계"); + addMerge(merges, 9, 20, 11, 20); + years.forEach((year, i) => { + putHeader(aoa, 9, 21 + i, `${year.slice(2)}년도`); + addMerge(merges, 9, 21 + i, 11, 21 + i); + }); + DETAIL_CODES.forEach((code) => { + writeDetailSectionHeader(aoa, merges, starts[code], code); + }); + putHeader(aoa, 7, last - 2, "담당자"); + addMerge(merges, 7, last - 2, 8, last - 1); + ["이름", "연락처"].forEach((label, index) => { + putHeader(aoa, 9, last - 2 + index, label); + addMerge(merges, 9, last - 2 + index, 11, last - 2 + index); + }); + putHeader(aoa, 7, last, "비고\n(특이사항, 사업 지연 사유 등)"); + addMerge(merges, 7, last, 11, last); + if (!templateOnly) + businesses.forEach((b, index) => { + const row = Array(last + 1).fill(""); + [ + b.seqNo, + b.stp, + b.ctpvNm, + b.sggNm, + b.envoNm, + b.dsgnYrCycl, + b.dsgnYmd, + b.dsgnArea, + b.dsgnType, + b.drareaNm, + b.drndstNm, + b.pstn, + b.bizNm, + b.bizCd, + b.prplPrst, + b.bizPrplPrst, + b.tpco, + b.gvexp, + b.lcexp, + ].forEach((v, i) => { + row[i + 1] = v ?? ""; + }); + years.forEach((year, i) => { + row[21 + i] = + b.settlements?.find((s) => s.soaYr === year)?.soaAmt ?? ""; + }); + DETAIL_CODES.forEach((code) => + writeDetail( + row, + starts[code], + b.details?.find((d) => d.bizPrgrsSe === code), + code, + ), + ); + row[last - 2] = b.picNm ?? ""; + row[last - 1] = b.picCnpl ?? ""; + row[last] = b.excptnMttrCn ?? ""; + aoa.push(row); + }); + const sheet = XLSX.utils.aoa_to_sheet(aoa); + sheet["!merges"] = merges; + sheet["!cols"] = Array.from({ length: last + 1 }, () => ({ wch: 14 })); + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, sheet, SHEET_NAME); + await saveStyledWorkbook( + workbook, + templateOnly ? "도시침수예방사업_업로드양식.xlsx" : DOWNLOAD_FILENAME, + starts, + last, + ); +}; diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/model/cty-fldng-prvtn-biz-types.ts b/apps/sn/src/features/ctyFldngPrvtnBiz/model/cty-fldng-prvtn-biz-types.ts new file mode 100644 index 000000000..92259878c --- /dev/null +++ b/apps/sn/src/features/ctyFldngPrvtnBiz/model/cty-fldng-prvtn-biz-types.ts @@ -0,0 +1,97 @@ +/** 시설 추가에도 컬럼을 늘리지 않는 JSONB 배열 항목이다. */ +export interface CtyFldngFacility { + no: number; + cpct: number | null; +} + +/** 연도별 결산 한 건이다. */ +export interface CtyFldngSettlement { + soaYr: string; + soaAmt: number | null; +} + +/** 네 사업진행구분에 공통으로 저장하는 상세 계약이다. */ +export interface CtyFldngDetail { + bizPrgrsSe: "EM" | "MP" | "DD" | "CW"; + area?: number | null; + designFreqCn?: string | null; + rainStrCn?: string | null; + pvodPrfrncGoalRainStr?: number | null; + exlnPplSumLen?: number | null; + exlnPplNbldpLen?: number | null; + exlnPplImprvmLen?: number | null; + rnwtrPumpstSumCn?: string | null; + rnwtrPumpst: CtyFldngFacility[]; + pumpImpdSumCn?: string | null; + pumpImpd: CtyFldngFacility[]; + impdFcltSumCn?: string | null; + impdFclt: CtyFldngFacility[]; + tnlDmtr?: number | null; + tnlLen?: number | null; + tnlRscpt?: number | null; + tnlExcvDpth?: number | null; + rnwtrInflwFcltCnt?: number | null; + mnhlSftyFcltCnt?: number | null; + tpco?: number | null; + gvexp?: number | null; + lcexp?: number | null; + bgngYmd?: string | null; + cmcnYmd?: string | null; + frstAprvCn?: string | null; + frstChgAprvCn?: string | null; + scndChgAprvCn?: string | null; + rmrkSrcCn?: string | null; +} + +/** 도시침수예방사업 기본·결산·상세 통합 API 계약이다. */ +export interface CtyFldngPrvtnBiz { + ctyFldngPrvtnBizSn: number; + seqNo?: number | null; + stp?: string | null; + ctpvNm?: string | null; + sggNm?: string | null; + envoNm?: string | null; + dsgnYrCycl?: string | null; + dsgnYmd?: string | null; + dsgnArea?: number | null; + dsgnType?: string | null; + drareaNm?: string | null; + drndstNm?: string | null; + pstn?: string | null; + bizNm: string; + bizCd?: string | null; + prplPrst?: string | null; + bizPrplPrst?: string | null; + tpco?: number | null; + gvexp?: number | null; + lcexp?: number | null; + picNm?: string | null; + picCnpl?: string | null; + excptnMttrCn?: string | null; + regDt?: string | null; + mdfcnDt?: string | null; + settlements: CtyFldngSettlement[]; + details: CtyFldngDetail[]; +} + +/** 등록·수정·업로드에서는 서버 생성 필드를 제외한다. */ +export type CtyFldngPrvtnBizSaveRequest = Omit< + CtyFldngPrvtnBiz, + "ctyFldngPrvtnBizSn" | "regDt" | "mdfcnDt" +>; + +/** Spring Data 페이지 응답의 화면 사용 필드다. */ +export interface CtyFldngPrvtnBizPage { + content: CtyFldngPrvtnBiz[]; + totalElements: number; + totalPages: number; + number: number; + size: number; +} + +/** Excel UPDATE·INSERT 결과 건수다. */ +export interface CtyFldngImportResult { + totalCount: number; + updatedCount: number; + insertedCount: number; +} diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/model/xlsx-populate-browser.d.ts b/apps/sn/src/features/ctyFldngPrvtnBiz/model/xlsx-populate-browser.d.ts new file mode 100644 index 000000000..2a600af27 --- /dev/null +++ b/apps/sn/src/features/ctyFldngPrvtnBiz/model/xlsx-populate-browser.d.ts @@ -0,0 +1,26 @@ +declare module "xlsx-populate/browser/xlsx-populate" { + type Range = { + style(styles: Record): Range; + style(name: string, value: unknown): Range; + }; + + type Row = { + height(value: number): Row; + }; + + type Sheet = { + range(address: string): Range; + row(index: number): Row; + }; + + type Workbook = { + sheet(name: string): Sheet; + outputAsync(): Promise; + }; + + const XlsxPopulate: { + fromDataAsync(data: ArrayBuffer | Uint8Array): Promise; + }; + + export default XlsxPopulate; +} diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage.tsx b/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage.tsx new file mode 100644 index 000000000..b36076bad --- /dev/null +++ b/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage.tsx @@ -0,0 +1,509 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; + +import { SelectBox } from "@common/business_components/ui"; +import { Button, Input } from "@common/components/ui"; + +import { + createCtyFldngPrvtnBusiness, + deleteCtyFldngPrvtnBusiness, + getCtyFldngPrvtnBusiness, + updateCtyFldngPrvtnBusiness, +} from "../api/cty-fldng-prvtn-biz-api"; +import type { + CtyFldngDetail, + CtyFldngFacility, + CtyFldngPrvtnBizSaveRequest, +} from "../model/cty-fldng-prvtn-biz-types"; + +import LoadingOverlay from "@/components/ui/LoadingBar"; +import useStore from "@/store/useStore"; + +const DETAIL_META = [ + { code: "EM", title: "중점관리지역 지정" }, + { code: "MP", title: "하수도정비대책 수립" }, + { code: "DD", title: "실시설계" }, + { code: "CW", title: "시설공사" }, +] as const; +const BASIC_FIELDS = [ + ["seqNo", "순번", "number"], + ["stp", "단계", "text"], + ["envoNm", "환경청", "text"], + ["dsgnYrCycl", "지정년도/차수", "text"], + ["dsgnYmd", "지정일", "date"], + ["dsgnArea", "지정면적(ha)", "number"], + ["dsgnType", "지정유형", "text"], + ["drareaNm", "배수구역", "text"], + ["drndstNm", "배수분구", "text"], + ["pstn", "위치", "text"], + ["bizNm", "사업명", "text"], + ["bizCd", "사업코드", "text"], + ["prplPrst", "추진현황", "text"], + ["bizPrplPrst", "사업추진현황", "text"], + ["tpco", "총사업비(백만원)", "number"], + ["gvexp", "국비(백만원)", "number"], + ["lcexp", "지방비(백만원)", "number"], + ["picNm", "담당자명", "text"], + ["picCnpl", "담당자 연락처", "text"], + ["excptnMttrCn", "특이사항", "text"], +] as const; +const DETAIL_FIELDS = [ + ["area", "면적(ha)", "number"], + ["designFreqCn", "설계빈도", "text"], + ["rainStrCn", "강우강도", "text"], + ["pvodPrfrncGoalRainStr", "방재성능목표(mm/hr)", "number"], + ["exlnPplSumLen", "우수관로 합계(km)", "number"], + ["exlnPplNbldpLen", "우수관로 신설(km)", "number"], + ["exlnPplImprvmLen", "우수관로 개량(km)", "number"], + ["rnwtrPumpstSumCn", "빗물펌프장 합계", "text"], + ["pumpImpdSumCn", "펌프저류 합계", "text"], + ["impdFcltSumCn", "저류시설 합계", "text"], + ["tnlDmtr", "터널 직경(m)", "number"], + ["tnlLen", "터널 길이(km)", "number"], + ["tnlRscpt", "터널 저수용량(㎥)", "number"], + ["tnlExcvDpth", "터널 굴착깊이(m)", "number"], + ["rnwtrInflwFcltCnt", "빗물유입시설 수", "number"], + ["mnhlSftyFcltCnt", "맨홀안전시설 수", "number"], + ["tpco", "총사업비(백만원)", "number"], + ["gvexp", "국비(백만원)", "number"], + ["lcexp", "지방비(백만원)", "number"], + ["bgngYmd", "시작일자", "date"], + ["cmcnYmd", "준공일자", "date"], + ["frstAprvCn", "최초승인", "text"], + ["frstChgAprvCn", "1차변경승인", "text"], + ["scndChgAprvCn", "2차변경승인", "text"], + ["rmrkSrcCn", "비고출처", "text"], +] as const; +type BasicKey = (typeof BASIC_FIELDS)[number][0]; +type DetailKey = (typeof DETAIL_FIELDS)[number][0]; + +/** 등록 화면의 빈 기본·결산·4단계 상세 값을 생성한다. */ +const emptyForm = (): CtyFldngPrvtnBizSaveRequest => ({ + bizNm: "", + settlements: Array.from( + { length: new Date().getFullYear() - 2013 + 1 }, + (_, i) => ({ soaYr: String(2013 + i), soaAmt: null }), + ), + details: DETAIL_META.map(({ code }) => ({ + bizPrgrsSe: code, + rnwtrPumpst: [], + pumpImpd: [], + impdFclt: [], + })), +}); + +/** 입력 문자열을 API의 nullable 숫자 또는 문자열로 바꾼다. */ +const inputValue = (value: string, type: string) => + type === "number" + ? value === "" + ? null + : Number(value) + : value === "" + ? null + : value; + +/** 날짜 직접 입력에 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 validDate = (value: unknown) => + !value || + (/^\d{4}-\d{2}-\d{2}$/.test(String(value)) && + !Number.isNaN(new Date(`${value}T00:00:00`).getTime())); + +/** 가변 시설 배열의 추가·수정·삭제 UI를 제공한다. */ +function FacilityEditor({ + label, + unit, + value, + onChange, +}: { + label: string; + unit: string; + value: CtyFldngFacility[]; + onChange: (next: CtyFldngFacility[]) => void; +}) { + return ( +
+
+ + {label} ({unit}) + + +
+ {value.length === 0 ? ( +

등록된 시설이 없습니다.

+ ) : ( + value.map((item, index) => ( +
+ + onChange( + value.map((v, i) => + i === index ? { ...v, no: Number(e.target.value) } : v, + ), + ) + } + /> + + onChange( + value.map((v, i) => + i === index + ? { + ...v, + cpct: + e.target.value === "" + ? null + : Number(e.target.value), + } + : v, + ), + ) + } + /> + +
+ )) + )} +
+ ); +} + +/** 단계별 공통 필드와 세 종류 시설 배열을 편집한다. */ +function DetailEditor({ + detail, + onChange, +}: { + detail: CtyFldngDetail; + onChange: (next: CtyFldngDetail) => void; +}) { + return ( +
+ {DETAIL_FIELDS.map(([key, label, type]) => ( + + ))} + onChange({ ...detail, rnwtrPumpst: value })} + /> + onChange({ ...detail, pumpImpd: value })} + /> + onChange({ ...detail, impdFclt: value })} + /> +
+ ); +} + +/** 도시침수예방사업 별도 등록·수정·삭제 화면이다. */ +export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) { + const router = useRouter(); + const searchParams = useSearchParams(); + const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); + const [form, setForm] = useState(emptyForm); + const [loading, setLoading] = useState(Boolean(id)); + const [notice, setNotice] = useState(""); + const [ctpvCd, setCtpvCd] = useState("0"); + const [sggCd, setSggCd] = useState("0"); + const listHref = `/oper/ctyFldngPrvtnBiz${searchParams.toString() ? `?${searchParams}` : ""}`; + const availableSgg = useMemo( + () => + sgg.filter( + (option) => + String(option.val).startsWith(ctpvCd) && + String(option.val).length > 2, + ), + [ctpvCd, sgg], + ); + useEffect(() => { + if (!ctpv.length) void fetchCtpv(); + if (!sgg.length) void fetchSgg(); + }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); + useEffect(() => { + if (!id) return; + setLoading(true); + getCtyFldngPrvtnBusiness(id) + .then((data) => { + setForm({ + ...data, + settlements: data.settlements ?? [], + details: data.details ?? [], + }); + }) + .catch((e) => + setNotice( + e instanceof Error ? e.message : "상세를 조회하지 못했습니다.", + ), + ) + .finally(() => setLoading(false)); + }, [id]); + useEffect(() => { + const city = ctpv.find((option) => option.text === form.ctpvNm); + if (city) setCtpvCd(String(city.val)); + }, [ctpv, form.ctpvNm]); + useEffect(() => { + const district = availableSgg.find((option) => option.text === form.sggNm); + if (district) setSggCd(String(district.val)); + }, [availableSgg, form.sggNm]); + + /** 필수값·날짜·숫자를 검사한 뒤 등록 또는 수정한다. */ + const handleSave = async () => { + const dates = [ + form.dsgnYmd, + ...form.details.flatMap((d) => [d.bgngYmd, d.cmcnYmd]), + ]; + if (dates.some((value) => !validDate(value))) { + setNotice("일자는 YYYY-MM-DD 형식의 유효한 날짜여야 합니다."); + return; + } + setLoading(true); + try { + if (id) await updateCtyFldngPrvtnBusiness(id, form); + else await createCtyFldngPrvtnBusiness(form); + window.alert(id ? "수정되었습니다." : "등록되었습니다."); + router.push(listHref); + } catch (e) { + setNotice(e instanceof Error ? e.message : "저장하지 못했습니다."); + } finally { + setLoading(false); + } + }; + /** 사용자 확인 후 현재 사업을 논리삭제하고 목록으로 이동한다. */ + const handleDelete = async () => { + if (!id || !window.confirm("삭제하시겠습니까?")) return; + setLoading(true); + try { + await deleteCtyFldngPrvtnBusiness(id); + window.alert("삭제되었습니다."); + router.push(listHref); + } catch (e) { + setNotice(e instanceof Error ? e.message : "삭제하지 못했습니다."); + } finally { + setLoading(false); + } + }; + return ( + <> + +
+

+ 도시침수예방사업 {id ? "수정" : "등록"} +

+ {notice && ( +
+ {notice} +
+ )} +
+

기본정보

+
+ { + const code = String(value); + setCtpvCd(code); + setSggCd("0"); + setForm((f) => ({ + ...f, + ctpvNm: + ctpv.find((o) => String(o.val) === code)?.text ?? null, + sggNm: null, + })); + }} + /> + { + const code = String(value); + setSggCd(code); + setForm((f) => ({ + ...f, + sggNm: + availableSgg.find((o) => String(o.val) === code)?.text ?? + null, + })); + }} + /> +
+
+ {BASIC_FIELDS.map(([key, label, type]) => ( + + ))} +
+
+
+

연도별 결산(백만원)

+
+ {Array.from( + { length: new Date().getFullYear() - 2013 + 1 }, + (_, i) => String(2013 + i), + ).map((year) => { + const item = form.settlements.find((s) => s.soaYr === year); + return ( + + ); + })} +
+
+ {DETAIL_META.map((meta) => { + const detail = form.details.find( + (item) => item.bizPrgrsSe === meta.code, + ) ?? { + bizPrgrsSe: meta.code, + rnwtrPumpst: [], + pumpImpd: [], + impdFclt: [], + }; + return ( +
+

+ {meta.code} {meta.title} +

+
+ + setForm((current) => ({ + ...current, + details: [ + ...current.details.filter( + (item) => item.bizPrgrsSe !== meta.code, + ), + next, + ], + })) + } + /> +
+
+ ); + })} +
+ + + {id && ( + + )} +
+
+ + ); +} diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx b/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx new file mode 100644 index 000000000..a91daaa51 --- /dev/null +++ b/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx @@ -0,0 +1,599 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; + +import { + SearchDiv, + SearchFormLeft, + SearchFormRight, + SelectBox, + UiTable, +} from "@common/business_components/ui"; +import ListDiv from "@common/business_components/ui/list_div"; +import { + Button, + Input, + Label, + TableHead, + TableRow, +} from "@common/components/ui"; + +import { formatDate } from "../../common/util"; +import { + getAllCtyFldngPrvtnBusinesses, + getCtyFldngPrvtnBusinesses, + uploadCtyFldngPrvtnExcel, +} from "../api/cty-fldng-prvtn-biz-api"; +import { + downloadCtyFldngPrvtnExcel, + parseCtyFldngPrvtnExcel, +} from "../lib/cty-fldng-prvtn-biz-excel"; +import type { + CtyFldngPrvtnBiz, + CtyFldngPrvtnBizPage, +} from "../model/cty-fldng-prvtn-biz-types"; + +import LoadingOverlay from "@/components/ui/LoadingBar"; +import useStore from "@/store/useStore"; + +const PAGE_SIZE = 10; +const ACTION = + "rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700"; +const ALL = { text: "전체", val: "0" }; +const EMPTY_PAGE: CtyFldngPrvtnBizPage = { + content: [], + totalElements: 0, + totalPages: 0, + number: 0, + size: PAGE_SIZE, +}; +const DETAIL_FIELDS = [ + "area", + "designFreqCn", + "rainStrCn", + "pvodPrfrncGoalRainStr", + "exlnPplSumLen", + "exlnPplNbldpLen", + "exlnPplImprvmLen", + "rnwtrPumpstSumCn", + "rnwtrPumpst", + "pumpImpdSumCn", + "pumpImpd", + "impdFcltSumCn", + "impdFclt", + "tnlDmtr", + "tnlLen", + "tnlRscpt", + "tnlExcvDpth", + "rnwtrInflwFcltCnt", + "mnhlSftyFcltCnt", + "tpco", + "gvexp", + "lcexp", + "bgngYmd", + "cmcnYmd", + "frstAprvCn", + "frstChgAprvCn", + "scndChgAprvCn", + "rmrkSrcCn", +] as const; +const DETAIL_LABELS = [ + "면적", + "설계빈도", + "강우강도", + "방재성능목표", + "우수관로합계", + "우수관로신설", + "우수관로개량", + "빗물펌프장합계", + "빗물펌프장", + "펌프저류합계", + "펌프저류", + "저류시설합계", + "저류시설", + "터널직경", + "터널길이", + "터널저수용량", + "터널굴착깊이", + "빗물유입시설수", + "맨홀안전시설수", + "총사업비", + "국비", + "지방비", + "시작일", + "준공일", + "최초승인", + "1차변경승인", + "2차변경승인", + "비고출처", +]; +const BASIC_HEADLIST = [ + { id: "rowNumber", title: "NO", widthPercent: 4 }, + { id: "seqNo", title: "순번", widthPercent: 5 }, + { id: "stp", title: "단계", widthPercent: 6 }, + { id: "ctpvNm", title: "시도", widthPercent: 7 }, + { id: "sggNm", title: "시군구", widthPercent: 7 }, + { id: "envoNm", title: "환경청", widthPercent: 7 }, + { id: "dsgnYrCycl", title: "지정년도", widthPercent: 7 }, + { id: "dsgnYmd", title: "지정일", widthPercent: 7 }, + { id: "dsgnArea", title: "지정면적", widthPercent: 6 }, + { id: "dsgnType", title: "지정유형", widthPercent: 7 }, + { id: "drareaNm", title: "배수구역", widthPercent: 9 }, + { id: "drndstNm", title: "배수분구", widthPercent: 9 }, + { id: "pstn", title: "위치", widthPercent: 12 }, + { id: "bizNm", title: "사업명", widthPercent: 14 }, + { id: "bizCd", title: "사업코드", widthPercent: 8 }, + { id: "prplPrst", title: "추진현황", widthPercent: 8 }, + { id: "bizPrplPrst", title: "사업추진현황", widthPercent: 10 }, + { id: "tpco", title: "총사업비", widthPercent: 7 }, + { id: "gvexp", title: "국비", widthPercent: 7 }, + { id: "lcexp", title: "지방비", widthPercent: 7 }, +]; +const TAIL_HEADLIST = [ + { id: "picNm", title: "담당자", widthPercent: 7 }, + { id: "picCnpl", title: "연락처", widthPercent: 8 }, + { id: "excptnMttrCn", title: "특이사항", widthPercent: 14 }, +]; +const DETAIL_GROUPS = [ + { code: "EM", title: "중점관리지역 지정", color: "#EDFEE7" }, + { code: "MP", title: "하수도정비대책 수립", color: "#E7F4FE" }, + { code: "DD", title: "실시설계", color: "#EDFEE7" }, + { code: "CW", title: "시설공사", color: "#E7F4FE" }, +] as const; +type Search = { + ctpvNm: string; + sggNm: string; + dsgnYmdFrom: string; + dsgnYmdTo: string; + prplPrst: string; + bizNm: string; +}; +type TableRow = Record & { + ctyFldngPrvtnBizSn: number; + rowNumber: number; +}; + +/** 검색조건과 페이지를 수정화면 왕복용 query로 만든다. */ +const listQuery = ( + page: number, + search: Search, + ctpvCd: string, + sggCd: string, +) => { + const p = new URLSearchParams(); + if (page > 1) p.set("page", String(page)); + Object.entries(search).forEach(([k, v]) => { + if (v.trim()) p.set(k, v.trim()); + }); + if (ctpvCd !== "0") p.set("ctpvCd", ctpvCd); + if (sggCd !== "0") p.set("sggCd", sggCd); + return p.toString(); +}; + +/** 시설 배열을 번호:용량 형식으로 간결하게 표시한다. */ +const facilities = (value: unknown) => + Array.isArray(value) + ? value.map((item) => `${item.no}:${item.cpct ?? ""}`).join(", ") + : ""; + +/** 중첩 결산·상세를 가로 테이블의 단일 행으로 평탄화한다. */ +const flattenRow = (row: CtyFldngPrvtnBiz, rowNumber: number): TableRow => { + const { settlements, details, ...basic } = row; + const flat = { + ...basic, + dsgnYmd: formatDate(basic.dsgnYmd) || basic.dsgnYmd, + rowNumber, + } as TableRow; + settlements?.forEach((item) => { + flat[`soa_${item.soaYr}`] = item.soaAmt; + }); + details?.forEach((detail) => + DETAIL_FIELDS.forEach((field) => { + const value = detail[field]; + flat[`${detail.bizPrgrsSe}_${field}`] = Array.isArray(value) + ? facilities(value) + : field === "bgngYmd" || field === "cmcnYmd" + ? formatDate(value as string | null | undefined) || + (value as string | null | undefined) + : (value as string | number | null | undefined); + }), + ); + return flat; +}; + +/** 도시침수예방사업 목록과 FE Excel 입출력 진입점을 제공한다. */ +export function CtyFldngPrvtnBizListPage() { + const router = useRouter(); + const params = useSearchParams(); + const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); + const fileRef = useRef(null); + const initial = useMemo( + () => ({ + ctpvNm: params.get("ctpvNm") ?? "", + sggNm: params.get("sggNm") ?? "", + dsgnYmdFrom: params.get("dsgnYmdFrom") ?? "", + dsgnYmdTo: params.get("dsgnYmdTo") ?? "", + prplPrst: params.get("prplPrst") ?? "", + bizNm: params.get("bizNm") ?? "", + }), + [params], + ); + const [search, setSearch] = useState(initial); + const [applied, setApplied] = useState(initial); + const [page, setPage] = useState( + Math.max(Number(params.get("page")) || 1, 1), + ); + const [data, setData] = useState(EMPTY_PAGE); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [notice, setNotice] = useState(""); + const [ctpvCd, setCtpvCd] = useState(params.get("ctpvCd") ?? "0"); + const [sggCd, setSggCd] = useState(params.get("sggCd") ?? "0"); + const [appliedCtpvCd, setAppliedCtpvCd] = useState(ctpvCd); + const [appliedSggCd, setAppliedSggCd] = useState(sggCd); + const availableSgg = useMemo( + () => + sgg.filter( + (option) => + String(option.val).startsWith(ctpvCd) && + String(option.val).length > 2, + ), + [ctpvCd, sgg], + ); + const years = useMemo( + () => + Array.from({ length: new Date().getFullYear() - 2013 + 1 }, (_, i) => + String(2013 + i), + ), + [], + ); + const headlist = useMemo( + () => [ + ...BASIC_HEADLIST, + ...years.map((year) => ({ + id: `soa_${year}`, + title: `${year} 결산`, + widthPercent: 7, + })), + ...DETAIL_GROUPS.flatMap(({ code }) => + DETAIL_FIELDS.map((field, i) => ({ + id: `${code}_${field}`, + title: DETAIL_LABELS[i] ?? field, + widthPercent: 8, + })), + ), + ...TAIL_HEADLIST, + ], + [years], + ); + + useEffect(() => { + if (!ctpv.length) void fetchCtpv(); + if (!sgg.length) void fetchSgg(); + }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); + /** 확정된 조건으로 서버 목록을 갱신한다. */ + const load = useCallback( + async (target = page, condition = applied) => { + const query = new URLSearchParams({ + page: String(target - 1), + size: String(PAGE_SIZE), + sort: "ctyFldngPrvtnBizSn,desc", + }); + Object.entries(condition).forEach(([k, v]) => { + if (v.trim()) query.set(k, v.trim()); + }); + setLoading(true); + try { + const result = await getCtyFldngPrvtnBusinesses(query); + setData(result); + setRows( + result.content.map((row, i) => + flattenRow( + row, + result.totalElements - ((target - 1) * PAGE_SIZE + i), + ), + ), + ); + } catch (e) { + setNotice( + e instanceof Error ? e.message : "목록을 조회하지 못했습니다.", + ); + setData(EMPTY_PAGE); + setRows([]); + } finally { + setLoading(false); + } + }, + [applied, page], + ); + useEffect(() => { + void load(); + }, [load]); + /** 입력 조건을 확정하고 첫 페이지를 조회한다. */ + const handleSearch = () => { + setApplied({ ...search }); + setAppliedCtpvCd(ctpvCd); + setAppliedSggCd(sggCd); + setPage(1); + const q = listQuery(1, search, ctpvCd, sggCd); + router.replace(`/oper/ctyFldngPrvtnBiz${q ? `?${q}` : ""}`, { + scroll: false, + }); + void load(1, search); + }; + /** 선택한 xlsx를 FE에서 파싱해 JSON으로 업로드한다. */ + const handleUpload = async (file?: File) => { + if (!file) return; + if (!file.name.toLowerCase().endsWith(".xlsx")) { + setNotice("xlsx 파일만 업로드할 수 있습니다."); + return; + } + setLoading(true); + try { + const parsed = await parseCtyFldngPrvtnExcel(file); + const result = await uploadCtyFldngPrvtnExcel(parsed); + setNotice( + `총 ${result.totalCount}건 저장(수정 ${result.updatedCount}, 신규 ${result.insertedCount})`, + ); + setPage(1); + await load(1, applied); + } catch (e) { + setNotice(e instanceof Error ? e.message : "업로드하지 못했습니다."); + } finally { + setLoading(false); + if (fileRef.current) fileRef.current.value = ""; + } + }; + /** 적용 조건의 전체 자료를 업로드 호환 위치로 내려받는다. */ + const handleDownload = async () => { + setLoading(true); + try { + const query = new URLSearchParams(); + Object.entries(applied).forEach(([k, v]) => { + if (v.trim()) query.set(k, v.trim()); + }); + await downloadCtyFldngPrvtnExcel( + await getAllCtyFldngPrvtnBusinesses(query), + ); + } catch (e) { + setNotice(e instanceof Error ? e.message : "엑셀을 내려받지 못했습니다."); + } finally { + setLoading(false); + } + }; + return ( + <> + + +
+ +
+ { + const code = String(value); + setCtpvCd(code); + setSggCd("0"); + setSearch((s) => ({ + ...s, + ctpvNm: + ctpv.find((o) => String(o.val) === code)?.text ?? "", + sggNm: "", + })); + }} + /> + { + const code = String(value); + setSggCd(code); + setSearch((s) => ({ + ...s, + sggNm: + availableSgg.find((o) => String(o.val) === code)?.text ?? + "", + })); + }} + /> + + + setSearch((s) => ({ ...s, dsgnYmdFrom: e.target.value })) + } + /> + ~ + + setSearch((s) => ({ ...s, dsgnYmdTo: e.target.value })) + } + /> + + + setSearch((s) => ({ ...s, prplPrst: e.target.value })) + } + /> + + + setSearch((s) => ({ ...s, bizNm: e.target.value })) + } + /> +
+
+ +
+ + + void handleUpload(e.target.files?.[0])} + /> + + + +
+
+
+
+ {notice && ( +
+ {notice} +
+ )} + +
+
+

+ 도시침수예방사업{" "} + + ({data.totalElements.toLocaleString()}건) + +

+
+ + headName="" + headlist={headlist} + tableData={rows} + pageSize={PAGE_SIZE} + total={data.totalElements} + curPage={page} + widthFixed + onPageChange={(next) => { + setPage(next); + const q = listQuery(next, applied, appliedCtpvCd, appliedSggCd); + router.replace(`/oper/ctyFldngPrvtnBiz${q ? `?${q}` : ""}`, { + scroll: false, + }); + }} + cellClick={(index) => { + if (typeof index !== "number" || !rows[index]) return; + const q = listQuery(page, applied, appliedCtpvCd, appliedSggCd); + router.push( + `/oper/ctyFldngPrvtnBiz/register/${rows[index].ctyFldngPrvtnBizSn}${q ? `?${q}` : ""}`, + ); + }} + > + + {BASIC_HEADLIST.map((head) => ( + + {head.title} + + ))} + + 결산 현황(백만원) + + {DETAIL_GROUPS.map((group) => ( + + {group.title} + + ))} + {TAIL_HEADLIST.map((head) => ( + + {head.title} + + ))} + + + {years.map((year) => ( + + {year}년 + + ))} + {DETAIL_GROUPS.flatMap((group) => + DETAIL_FIELDS.map((field, index) => ( + + {DETAIL_LABELS[index] ?? field} + + )), + )} + + +
+
+ + ); +} diff --git a/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx b/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx index a7e228ddf..d53746f53 100644 --- a/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx +++ b/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx @@ -1,3 +1,6 @@ +/** + * 노후 하수관로 정비사업 + */ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -123,7 +126,7 @@ export function ObsltPplListPage() { const fileInputRef = useRef(null); const initialSearch = useMemo( () => ({ - bizSe: searchParams.get("bizSe") ?? "", + bizSe: searchParams.get("bizSe") ?? "정밀조사", ctpvNm: searchParams.get("ctpvNm") ?? "", sigunNm: searchParams.get("sigunNm") ?? "", bizNm: searchParams.get("bizNm") ?? "", diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts index cb67ddf71..8fcacc05d 100644 --- a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts +++ b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts @@ -190,7 +190,7 @@ export const windowPopupDefinitions = { }, floodPredictionMapPopup: { key: "floodPredictionMapPopup", - title: "침수예측지도", + title: "침수예측현황", width: 1080, height: 760, },