Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev

This commit is contained in:
2026-08-12 11:14:05 +09:00
15 changed files with 2095 additions and 3 deletions
@@ -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: "시도별 노후관로현황",
@@ -0,0 +1,4 @@
import { CtyFldngPrvtnBizListPage } from "@/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage";
/** 도시침수예방사업 목록 경로다. */
export default function Page(){return <CtyFldngPrvtnBizListPage/>;}
@@ -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 <CtyFldngPrvtnBizFormPage id={id}/>;}
@@ -0,0 +1,4 @@
import { CtyFldngPrvtnBizFormPage } from "@/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage";
/** 도시침수예방사업 신규 등록 경로다. */
export default function Page(){return <CtyFldngPrvtnBizFormPage/>;}
@@ -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 대조는 테이블 및 인증 환경에서 추가 확인이 필요하다.
@@ -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 호출
- 공통 컴포넌트와 다른 앱은 수정하지 않는다.
@@ -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 컴포넌트는 소비만 하며 수정하지 않았다.
@@ -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 = <T>(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<CtyFldngPrvtnBizPage>;
/** 모든 페이지를 순회하여 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<CtyFldngPrvtnBiz>;
/** 도시침수예방사업을 신규 등록한다. */
export const createCtyFldngPrvtnBusiness = async (
request: CtyFldngPrvtnBizSaveRequest,
) => unwrapData<CtyFldngPrvtnBiz>(await postRawJson(API_PATH, request));
/** 도시침수예방사업을 수정한다. */
export const updateCtyFldngPrvtnBusiness = (
id: string,
request: CtyFldngPrvtnBizSaveRequest,
) =>
putData(
`${API_PATH}/${encodeURIComponent(id)}`,
request,
) as Promise<CtyFldngPrvtnBiz>;
/** 도시침수예방사업을 논리삭제한다. */
export const deleteCtyFldngPrvtnBusiness = async (id: string) => {
await deleteData(`${API_PATH}/${encodeURIComponent(id)}`);
};
/** 브라우저에서 파싱한 Excel JSON 행을 일괄 저장한다. */
export const uploadCtyFldngPrvtnExcel = async (
rows: CtyFldngPrvtnBizSaveRequest[],
) =>
unwrapData<CtyFldngImportResult>(
await postRawJson(`${API_PATH}/excel/upload`, rows),
);
@@ -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<CtyFldngPrvtnBizSaveRequest[]> => {
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,
);
};
@@ -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;
}
@@ -0,0 +1,26 @@
declare module "xlsx-populate/browser/xlsx-populate" {
type Range = {
style(styles: Record<string, unknown>): 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<Blob>;
};
const XlsxPopulate: {
fromDataAsync(data: ArrayBuffer | Uint8Array): Promise<Workbook>;
};
export default XlsxPopulate;
}
@@ -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 (
<div className="rounded border p-3">
<div className="mb-2 flex items-center justify-between">
<strong>
{label} ({unit})
</strong>
<Button
size="sm"
onClick={() =>
onChange([
...value,
{ no: (value.at(-1)?.no ?? 0) + 1, cpct: null },
])
}
>
추가
</Button>
</div>
{value.length === 0 ? (
<p className="text-sm text-gray-500">등록된 시설이 없습니다.</p>
) : (
value.map((item, index) => (
<div
key={`${item.no}-${index}`}
className="mb-2 flex items-center gap-2"
>
<Input
className="w-20"
type="number"
value={item.no}
onChange={(e) =>
onChange(
value.map((v, i) =>
i === index ? { ...v, no: Number(e.target.value) } : v,
),
)
}
/>
<Input
className="w-40"
type="number"
value={item.cpct ?? ""}
onChange={(e) =>
onChange(
value.map((v, i) =>
i === index
? {
...v,
cpct:
e.target.value === ""
? null
: Number(e.target.value),
}
: v,
),
)
}
/>
<Button
size="sm"
variant="outline"
onClick={() => onChange(value.filter((_, i) => i !== index))}
>
삭제
</Button>
</div>
))
)}
</div>
);
}
/** 단계별 공통 필드와 세 종류 시설 배열을 편집한다. */
function DetailEditor({
detail,
onChange,
}: {
detail: CtyFldngDetail;
onChange: (next: CtyFldngDetail) => void;
}) {
return (
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
{DETAIL_FIELDS.map(([key, label, type]) => (
<label key={key} className="flex flex-col gap-1 text-sm font-medium">
{label}
<Input
type={type === "date" ? "text" : type}
placeholder={type === "date" ? "YYYY-MM-DD" : undefined}
value={String(detail[key] ?? "")}
onChange={(e) =>
onChange({
...detail,
[key]:
type === "date"
? formatDateInput(e.target.value)
: inputValue(e.target.value, type),
})
}
/>
</label>
))}
<FacilityEditor
label="빗물펌프장"
unit="㎥/분"
value={detail.rnwtrPumpst ?? []}
onChange={(value) => onChange({ ...detail, rnwtrPumpst: value })}
/>
<FacilityEditor
label="펌프저류"
unit="㎥"
value={detail.pumpImpd ?? []}
onChange={(value) => onChange({ ...detail, pumpImpd: value })}
/>
<FacilityEditor
label="저류시설"
unit="㎥"
value={detail.impdFclt ?? []}
onChange={(value) => onChange({ ...detail, impdFclt: value })}
/>
</div>
);
}
/** 도시침수예방사업 별도 등록·수정·삭제 화면이다. */
export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
const router = useRouter();
const searchParams = useSearchParams();
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [form, setForm] = useState<CtyFldngPrvtnBizSaveRequest>(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 (
<>
<LoadingOverlay isLoading={loading} />
<div className="mx-5 space-y-6">
<h2 className="text-2xl font-semibold">
도시침수예방사업 {id ? "수정" : "등록"}
</h2>
{notice && (
<div
role="alert"
className="rounded border border-red-200 bg-red-50 p-3 text-red-700"
>
{notice}
</div>
)}
<section className="rounded border bg-white p-5">
<h3 className="mb-4 text-xl font-semibold">기본정보</h3>
<div className="mb-4 grid grid-cols-1 gap-3 md:grid-cols-2">
<SelectBox
className=""
label="시도"
labelClass="w-24 text-base"
selectClass="w-full"
selectArray={[{ text: "선택", val: "0" }, ...ctpv]}
value={ctpvCd}
onSelectValue={(value) => {
const code = String(value);
setCtpvCd(code);
setSggCd("0");
setForm((f) => ({
...f,
ctpvNm:
ctpv.find((o) => String(o.val) === code)?.text ?? null,
sggNm: null,
}));
}}
/>
<SelectBox
className=""
label="시군구"
labelClass="w-24 text-base"
selectClass="w-full"
selectArray={[{ text: "선택", val: "0" }, ...availableSgg]}
value={sggCd}
onSelectValue={(value) => {
const code = String(value);
setSggCd(code);
setForm((f) => ({
...f,
sggNm:
availableSgg.find((o) => String(o.val) === code)?.text ??
null,
}));
}}
/>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
{BASIC_FIELDS.map(([key, label, type]) => (
<label
key={key}
className="flex flex-col gap-1 text-sm font-medium"
>
{label}
<Input
type={type === "date" ? "text" : type}
placeholder={type === "date" ? "YYYY-MM-DD" : undefined}
value={String(form[key] ?? "")}
onChange={(e) =>
setForm((f) => ({
...f,
[key]:
type === "date"
? formatDateInput(e.target.value)
: inputValue(e.target.value, type),
}))
}
/>
</label>
))}
</div>
</section>
<section className="rounded border bg-white p-5">
<h3 className="mb-4 text-xl font-semibold">연도별 결산(백만원)</h3>
<div className="grid grid-cols-2 gap-3 md:grid-cols-7">
{Array.from(
{ length: new Date().getFullYear() - 2013 + 1 },
(_, i) => String(2013 + i),
).map((year) => {
const item = form.settlements.find((s) => s.soaYr === year);
return (
<label key={year} className="text-sm font-medium">
{year}
<Input
type="number"
value={item?.soaAmt ?? ""}
onChange={(e) =>
setForm((f) => ({
...f,
settlements: [
...f.settlements.filter((s) => s.soaYr !== year),
{
soaYr: year,
soaAmt:
e.target.value === ""
? null
: Number(e.target.value),
},
],
}))
}
/>
</label>
);
})}
</div>
</section>
{DETAIL_META.map((meta) => {
const detail = form.details.find(
(item) => item.bizPrgrsSe === meta.code,
) ?? {
bizPrgrsSe: meta.code,
rnwtrPumpst: [],
pumpImpd: [],
impdFclt: [],
};
return (
<section
key={meta.code}
className="overflow-x-auto rounded border bg-white p-5"
>
<h3 className="mb-4 text-xl font-semibold">
{meta.code} {meta.title}
</h3>
<div className="min-w-[1100px]">
<DetailEditor
detail={detail}
onChange={(next) =>
setForm((current) => ({
...current,
details: [
...current.details.filter(
(item) => item.bizPrgrsSe !== meta.code,
),
next,
],
}))
}
/>
</div>
</section>
);
})}
<div className="flex justify-center gap-3 pb-8">
<Button variant="outline" onClick={() => router.push(listHref)}>
목록
</Button>
<Button onClick={() => void handleSave()}>저장</Button>
{id && (
<Button variant="destructive" onClick={() => void handleDelete()}>
삭제
</Button>
)}
</div>
</div>
</>
);
}
@@ -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<string, string | number | null | undefined> & {
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<HTMLInputElement>(null);
const initial = useMemo<Search>(
() => ({
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<TableRow[]>([]);
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 (
<>
<LoadingOverlay isLoading={loading} />
<SearchDiv>
<div className="flex w-full flex-col gap-4">
<SearchFormLeft>
<div className="col-span-12 flex flex-wrap items-center gap-3">
<SelectBox
className=""
label="시도"
labelClass="w-14 text-xl"
selectClass="w-[140px]"
selectArray={[ALL, ...ctpv]}
value={ctpvCd}
onSelectValue={(value) => {
const code = String(value);
setCtpvCd(code);
setSggCd("0");
setSearch((s) => ({
...s,
ctpvNm:
ctpv.find((o) => String(o.val) === code)?.text ?? "",
sggNm: "",
}));
}}
/>
<SelectBox
className=""
label="시군구"
labelClass="w-20 text-xl"
selectClass="w-[150px]"
selectArray={[ALL, ...availableSgg]}
value={sggCd}
onSelectValue={(value) => {
const code = String(value);
setSggCd(code);
setSearch((s) => ({
...s,
sggNm:
availableSgg.find((o) => String(o.val) === code)?.text ??
"",
}));
}}
/>
<Label>지정일</Label>
<Input
type="date"
className="w-[150px]"
value={search.dsgnYmdFrom}
onChange={(e) =>
setSearch((s) => ({ ...s, dsgnYmdFrom: e.target.value }))
}
/>
<span>~</span>
<Input
type="date"
className="w-[150px]"
value={search.dsgnYmdTo}
onChange={(e) =>
setSearch((s) => ({ ...s, dsgnYmdTo: e.target.value }))
}
/>
<Label>추진현황</Label>
<Input
className="w-[150px]"
value={search.prplPrst}
onChange={(e) =>
setSearch((s) => ({ ...s, prplPrst: e.target.value }))
}
/>
<Label>사업명</Label>
<Input
className="w-[180px]"
value={search.bizNm}
onChange={(e) =>
setSearch((s) => ({ ...s, bizNm: e.target.value }))
}
/>
</div>
</SearchFormLeft>
<SearchFormRight className="flex justify-end px-4 pb-4">
<div className="flex flex-wrap justify-end gap-2">
<Button className={ACTION} size="sm" onClick={handleSearch}>
조회
</Button>
<Button
className={ACTION}
size="sm"
onClick={() => {
const q = listQuery(
page,
applied,
appliedCtpvCd,
appliedSggCd,
);
router.push(
`/oper/ctyFldngPrvtnBiz/register${q ? `?${q}` : ""}`,
);
}}
>
등록
</Button>
<input
ref={fileRef}
type="file"
accept=".xlsx"
className="hidden"
onChange={(e) => void handleUpload(e.target.files?.[0])}
/>
<Button
className={ACTION}
size="sm"
onClick={() => fileRef.current?.click()}
>
업로드
</Button>
<Button
className="rounded-sm bg-green-600 px-4 py-2 text-sm !text-white"
size="sm"
onClick={() => void handleDownload()}
>
엑셀다운로드
</Button>
<Button
className={ACTION}
size="sm"
onClick={() => void downloadCtyFldngPrvtnExcel([], true)}
>
양식받기
</Button>
</div>
</SearchFormRight>
</div>
</SearchDiv>
{notice && (
<div
role="status"
className="mx-5 mt-4 rounded border border-blue-200 bg-blue-50 p-3 text-sm text-blue-700"
>
{notice}
</div>
)}
<ListDiv className="overflow-x-auto">
<div className="w-[560%]">
<div className="card-header">
<h3 className="text-2xl font-medium">
도시침수예방사업{" "}
<span className="text-base text-gray-500">
({data.totalElements.toLocaleString()}건)
</span>
</h3>
</div>
<UiTable<TableRow>
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}` : ""}`,
);
}}
>
<TableRow>
{BASIC_HEADLIST.map((head) => (
<TableHead
key={head.id}
rowSpan={2}
className="whitespace-pre-line bg-gray-50"
>
{head.title}
</TableHead>
))}
<TableHead
colSpan={years.length}
className="whitespace-nowrap bg-gray-50"
>
결산 현황(백만원)
</TableHead>
{DETAIL_GROUPS.map((group) => (
<TableHead
key={group.code}
colSpan={DETAIL_FIELDS.length}
className="whitespace-nowrap"
style={{ backgroundColor: group.color }}
>
{group.title}
</TableHead>
))}
{TAIL_HEADLIST.map((head) => (
<TableHead
key={head.id}
rowSpan={2}
className="whitespace-pre-line bg-gray-50"
>
{head.title}
</TableHead>
))}
</TableRow>
<TableRow>
{years.map((year) => (
<TableHead key={year} className="whitespace-nowrap bg-gray-50">
{year}년
</TableHead>
))}
{DETAIL_GROUPS.flatMap((group) =>
DETAIL_FIELDS.map((field, index) => (
<TableHead
key={`${group.code}_${field}`}
className="whitespace-pre-line"
style={{ backgroundColor: group.color }}
>
{DETAIL_LABELS[index] ?? field}
</TableHead>
)),
)}
</TableRow>
</UiTable>
</div>
</ListDiv>
</>
);
}
@@ -1,3 +1,6 @@
/**
* 노후 하수관로 정비사업
*/
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -123,7 +126,7 @@ export function ObsltPplListPage() {
const fileInputRef = useRef<HTMLInputElement>(null);
const initialSearch = useMemo<ObsltPplSearch>(
() => ({
bizSe: searchParams.get("bizSe") ?? "",
bizSe: searchParams.get("bizSe") ?? "정밀조사",
ctpvNm: searchParams.get("ctpvNm") ?? "",
sigunNm: searchParams.get("sigunNm") ?? "",
bizNm: searchParams.get("bizNm") ?? "",
@@ -190,7 +190,7 @@ export const windowPopupDefinitions = {
},
floodPredictionMapPopup: {
key: "floodPredictionMapPopup",
title: "침수예측지도",
title: "침수예측현황",
width: 1080,
height: 760,
},