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..d1ca1c51b --- /dev/null +++ b/apps/sn/src/features/ctyFldngPrvtnBiz/lib/cty-fldng-prvtn-biz-excel.ts @@ -0,0 +1,482 @@ +import * as XLSX from "xlsx"; + +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 ?? ""; + } +}; + +/** 현재 연도까지 업로드와 같은 열 위치를 가진 한 시트 xlsx를 생성한다. */ +export const downloadCtyFldngPrvtnExcel = ( + 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(""), + ); + aoa[0]![1] = "하수도정비중점관리지역 도시침수대응사업 현황"; + const basic = [ + "순번", + "단계", + "시도", + "시군구", + "환경청", + "지정년도", + "지정일", + "지정면적(ha)", + "유형", + "배수구역", + "배수분구", + "위치", + "사업명", + "사업코드", + "추진현황", + "사업추진 현황", + "총사업비", + "국고", + "지방비", + ]; + basic.forEach((v, i) => { + aoa[11]![i + 1] = v; + }); + aoa[7]![20] = "결산 현황(백만원)"; + years.forEach((year, i) => { + aoa[11]![21 + i] = year; + }); + DETAIL_CODES.forEach((code) => { + aoa[7]![starts[code]] = DETAIL_TITLES[code]; + }); + aoa[11]![last - 2] = "담당자 이름"; + aoa[11]![last - 1] = "담당자 연락처"; + aoa[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["!cols"] = Array.from({ length: last + 1 }, () => ({ wch: 14 })); + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, sheet, SHEET_NAME); + XLSX.writeFile( + workbook, + templateOnly ? "도시침수예방사업_업로드양식.xlsx" : DOWNLOAD_FILENAME, + ); +}; 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/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..c79b17bf0 --- /dev/null +++ b/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx @@ -0,0 +1,597 @@ +"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()); + }); + 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") ?? "",