sn: 노후하수관로정비사업1

This commit is contained in:
2026-08-04 20:30:26 +09:00
parent 2e6a75141b
commit b42810d29a
9 changed files with 516 additions and 78 deletions
@@ -10,7 +10,7 @@
1. 목록 전체 23개 열과 가로 스크롤 확인
2. 검색·페이지 이동 확인
3. 제공 원본 xlsx 업로드 후 517건 표시 확인
3. 새 `_upload.xlsx`의 헤더 오류 없이 FE에서 517건 파싱되고 네트워크 요청이 JSON 배열인지 확인
4. 엑셀다운로드 후 두 시트 및 재업로드 확인
5. 등록 저장, 행 클릭 수정, 수정 화면 삭제 확인
6. 목록에 삭제 버튼이 없고 수정 하단 버튼 순서가 `목록`, `저장`, `삭제`인지 확인
@@ -8,3 +8,10 @@
- 최종 전체 빌드는 신규 화면이 아닌 공통 `file-uploader`, `slider`, `switch`, `table`, `tabs` CSS 처리 중 Windows `EMFILE: too many open files`로 실패했다.
- 로그인·실 API 브라우저 검증은 실행환경 미기동으로 보류한다.
- 지역 검색 유지보수 후 `pnpm exec eslint apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx`를 실행해 오류·경고 없이 통과했다.
## 2026-08-04
- 새 업로드 양식의 두 시트 B6:V9를 값 검사와 렌더링으로 확인했으며 수정 헤더가 정상 표시됨을 확인했다.
- 실제 FE 파서를 실행해 총 517건, 정밀조사 128건, 정비사업 389건을 변환하고 대표 사업코드·연장 값을 대조했다.
- 변경한 API·Excel 파서·목록 파일 대상 ESLint를 오류·경고 없이 통과했다.
- SN `next dev` 프로세스가 실행 중이어서 앱 규칙에 따라 전체 `next build`는 실행하지 않았다.
@@ -10,7 +10,7 @@
- 목록 행 클릭은 `/oper/obsltPplBiz/register/{id}`로 이동한다.
- 목록에는 삭제 버튼을 두지 않는다.
- 등록 화면 하단은 `목록`, `저장`; 수정 화면은 `목록`, `저장`, `삭제` 순서다.
- 조사관리의 multipart 흐름만 재사용하고 조사 결과 전용 validate/commit·첨부서비스 코드는 공유하지 않는다.
- `apps/wr /matMng/coMat/ruseCaseMng`의 브라우저 Excel 파싱·JSON 저장 흐름을 참고하되 SN 전용 두 시트·병합 헤더 계약으로 구현한다.
- `apps/cs/banner/register`의 표형 폼과 별도 등록/수정 경로를 시각 참고한다.
- 목록 검색의 시도·시군구는 SN 앱 공통 `useStore`의 지역 목록을 사용하는 연계 `SelectBox`로 제공하고, 시도 변경 시 시군구를 초기화한다.
@@ -18,6 +18,7 @@
- 목록 로딩·오류·업로드 결과 건수는 화면에서 문구로 관측 가능해야 한다.
- 업로드 input은 `.xlsx`만 받는다.
- FE는 `노후하수관로 정밀조사 및 정비사업 현황_upload.xlsx`의 두 시트와 B6:V7 핵심 헤더를 검증하고 B8:V 마지막 행을 JSON 저장 요청으로 변환한다.
- 다운로드 버튼명은 `엑셀다운로드`로 고정한다.
- 브라우저 검증 URL: `/oper/obsltPplBiz`, `/oper/obsltPplBiz/register`, `/oper/obsltPplBiz/register/{id}`
@@ -15,7 +15,7 @@
## BE 공유 계약
기준 API는 `/api/v1/obsltPplBiz`이며 목록은 Spring Page, 업로드는 multipart `file`, 다운로드는 XLSX Blob이다.
기준 API는 `/api/v1/obsltPplBiz`이며 목록은 Spring Page, 업로드는 FE 파싱 결과 JSON 배열, 다운로드는 XLSX Blob이다.
## 남은 리스크
@@ -26,3 +26,10 @@
- 목록의 시도·시군 입력을 공통 `SelectBox`로 교체했다.
- SN 앱 공통 Zustand store의 `ctpv`, `sgg`, `fetchCtpv`, `fetchSgg`를 사용하고 선택 시 기존 검색 API 계약에 맞는 한글명을 설정한다.
- 선택한 시도의 코드 앞 두 자리를 기준으로 시군구 후보를 제한하고 시도 변경 시 기존 시군구 선택을 초기화한다.
## 2026-08-04 Excel 업로드 FE 파싱 전환
- `apps/wr /matMng/coMat/ruseCaseMng` 흐름을 참고하여 `.xlsx` 읽기와 값 파싱 책임을 FE로 이동했다.
- `lib/obslt-ppl-excel.ts`에서 두 필수 시트, B6:V7 핵심 헤더, 필수 사업명, 순번·연장 숫자를 검증한다.
- `노후하수관로 정밀조사 및 정비사업 현황_upload.xlsx`의 517개 행을 저장 요청 JSON으로 변환한 뒤 `/excel/upload`에 `application/json`으로 전송한다.
- 기존 multipart `FormData` 전송과 `postMutiPart` 의존을 제거했다.
@@ -9,7 +9,6 @@ import {
deleteData,
getBlobData,
getData,
postMutiPart,
postRawJson,
putData,
} from "@/components/lib/api";
@@ -50,12 +49,11 @@ export const deleteObsltPplBusiness = async (id: string): Promise<void> => {
await deleteData(`${API_PATH}/${encodeURIComponent(id)}`);
};
/** 원본 형식 xlsx 한 건을 두 시트 스냅샷으로 교체 저장한다. */
export const uploadObsltPplExcel = async (file: File): Promise<ObsltPplImportResult> => {
const formData = new FormData();
formData.append("file", file);
return postMutiPart(`${API_PATH}/excel/upload`, formData) as Promise<ObsltPplImportResult>;
};
/** 브라우저에서 파싱한 두 시트 JSON을 활성 스냅샷으로 교체 저장한다. */
export const uploadObsltPplExcel = async (
rows: ObsltPplBusinessSaveRequest[],
): Promise<ObsltPplImportResult> =>
unwrapData<ObsltPplImportResult>(await postRawJson(`${API_PATH}/excel/upload`, rows));
/** 현재 활성 자료를 원본과 호환되는 xlsx로 내려받는다. */
export const downloadObsltPplExcel = async (): Promise<Blob> =>
@@ -0,0 +1,187 @@
import * as XLSX from "xlsx";
import type { ObsltPplBusinessSaveRequest } from "../model/obslt-ppl-types";
type BusinessType = ObsltPplBusinessSaveRequest["bizSe"];
type SheetContract = {
sheetName: BusinessType;
businessCodeHeader: string;
extensionHeader: string;
progressHeader: string;
startDateHeader: string;
};
const SHEET_CONTRACTS: SheetContract[] = [
{
sheetName: "정밀조사",
businessCodeHeader: "정밀조사사업코드",
extensionHeader: "조사연장(km)",
progressHeader: "정밀조사추진현황",
startDateHeader: "착수(예정)일자(yyyy.mm.dd)",
},
{
sheetName: "정비사업",
businessCodeHeader: "정비사업사업코드",
extensionHeader: "정비연장(km)",
progressHeader: "정비사업추진현황",
startDateHeader: "착공(예정)일자(yyyy.mm.dd)",
},
];
const DATA_START_ROW_INDEX = 7;
const FIRST_DATA_COLUMN_INDEX = 1;
const DATA_COLUMN_COUNT = 21;
/** 줄바꿈과 공백이 포함된 병합 헤더를 비교 가능한 문자열로 정규화한다. */
const normalizeHeader = (value: string) => value.replace(/\s+/g, "").trim();
/** SheetJS 셀의 화면 표시값을 원본 양식과 같은 문자열로 읽는다. */
const cellText = (sheet: XLSX.WorkSheet, rowIndex: number, columnIndex: number) => {
const cell = sheet[XLSX.utils.encode_cell({ r: rowIndex, c: columnIndex })];
return cell ? XLSX.utils.format_cell(cell).trim() : "";
};
/** 새 업로드 양식의 공통·업무별 핵심 헤더가 정확한 위치에 있는지 확인한다. */
const validateHeaders = (sheet: XLSX.WorkSheet, contract: SheetContract) => {
const expectedHeaders: Array<[number, number, string]> = [
[5, 1, "순번"], [5, 2, "정밀조사차수"], [5, 3, contract.businessCodeHeader],
[5, 4, "시도"], [5, 5, "시군"], [5, 6, "환경청"], [5, 7, "사업명"],
[5, 8, "추진현황"], [5, 9, "착수년도"], [5, 10, "준공(예정)년도"],
[5, 11, contract.extensionHeader], [6, 11, "계획연장(km)"],
[6, 12, "상반기(예상)연장(km)"], [6, 13, "잔여연장(km)"],
[6, 14, "증빙서류제출여부"], [5, 15, contract.progressHeader],
[6, 15, contract.startDateHeader], [6, 16, "준공(예정)일자(yyyy.mm.dd)"],
[5, 17, "사업추진부진사유"], [5, 18, "사업담당자(지자체)"],
[6, 18, "이름"], [6, 19, "연락처"], [5, 20, "비고"], [6, 21, "공단의견"],
];
for (const [rowIndex, columnIndex, expected] of expectedHeaders) {
const actual = normalizeHeader(cellText(sheet, rowIndex, columnIndex));
if (actual !== normalizeHeader(expected)) {
const address = XLSX.utils.encode_cell({ r: rowIndex, c: columnIndex });
throw new Error(
`${contract.sheetName} 시트 ${address} 헤더가 올바르지 않습니다. 기대값: '${expected}', 실제값: '${actual || "(빈 값)"}'`,
);
}
}
};
/** 빈 셀과 대시 표기를 API의 null 값으로 정규화한다. */
const optionalText = (value: string): string | null => {
const normalized = value.trim();
return !normalized || normalized === "-" ? null : normalized;
};
/** 필수 문자열을 검사하고 시트·행이 포함된 오류를 제공한다. */
const requiredText = (value: string, sheetName: string, rowNumber: number, fieldName: string) => {
const normalized = optionalText(value);
if (!normalized) throw new Error(`${sheetName} 시트 ${rowNumber}행의 ${fieldName} 값은 필수입니다.`);
return normalized;
};
/** 순번 셀을 안전한 정수로 변환한다. */
const optionalInteger = (value: string, sheetName: string, rowNumber: number): number | null => {
const normalized = optionalText(value)?.replace(/,/g, "");
if (!normalized) return null;
const parsed = Number(normalized);
if (!Number.isSafeInteger(parsed)) {
throw new Error(`${sheetName} 시트 ${rowNumber}행의 순번 값이 정수가 아닙니다.`);
}
return parsed;
};
/** 연장 셀의 콤마와 km 단위를 제거하고 유한한 숫자로 변환한다. */
const optionalDecimal = (value: string, sheetName: string, rowNumber: number, fieldName: string): number | null => {
const normalized = optionalText(value)?.replace(/,/g, "").replace(/km/gi, "").trim();
if (!normalized) return null;
const parsed = Number(normalized);
if (!Number.isFinite(parsed)) {
throw new Error(`${sheetName} 시트 ${rowNumber}행의 ${fieldName} 값이 숫자가 아닙니다.`);
}
return parsed;
};
/** 연도 셀은 DB 계약에 맞게 최초 4자리 연도를 보존한다. */
const yearText = (value: string): string | null => {
const normalized = optionalText(value);
if (!normalized) return null;
return normalized.match(/(19|20)\d{2}/)?.[0] ?? normalized.slice(0, 4);
};
/** 혼합 날짜 표기에서 숫자를 추출하고 DB 최대 길이 8자로 제한한다. */
const dateText = (value: string): string | null => {
const normalized = optionalText(value);
if (!normalized) return null;
const digits = normalized.replace(/[^0-9]/g, "");
return (digits || normalized).slice(0, 8);
};
/** B~V가 모두 비어 있는 행은 데이터가 아닌 양식 여백으로 판단한다. */
const isEmptyDataRow = (sheet: XLSX.WorkSheet, rowIndex: number) =>
Array.from({ length: DATA_COLUMN_COUNT }, (_, offset) =>
cellText(sheet, rowIndex, FIRST_DATA_COLUMN_INDEX + offset),
).every((value) => !value.trim());
/** 한 업무 시트의 데이터행을 JSON 저장 요청으로 변환한다. */
const parseSheet = (sheet: XLSX.WorkSheet, contract: SheetContract): ObsltPplBusinessSaveRequest[] => {
validateHeaders(sheet, contract);
const range = XLSX.utils.decode_range(sheet["!ref"] ?? "A1:A1");
const result: ObsltPplBusinessSaveRequest[] = [];
for (let rowIndex = DATA_START_ROW_INDEX; rowIndex <= range.e.r; rowIndex += 1) {
if (isEmptyDataRow(sheet, rowIndex)) continue;
const values = Array.from({ length: DATA_COLUMN_COUNT }, (_, offset) =>
cellText(sheet, rowIndex, FIRST_DATA_COLUMN_INDEX + offset),
);
const rowNumber = rowIndex + 1;
const bizSeq = optionalInteger(values[0], contract.sheetName, rowNumber);
const originalBizCd = optionalText(values[2]);
const fallbackType = contract.sheetName === "정밀조사" ? "S" : "M";
result.push({
bizSe: contract.sheetName,
bizSeq,
prcisExmnCycl: optionalText(values[1]),
bizCd: originalBizCd ?? `UNASSIGNED-${fallbackType}-${bizSeq ?? rowNumber}`,
sggCd: null,
ctpvNm: optionalText(values[3]),
sigunNm: optionalText(values[4]),
envoNm: optionalText(values[5]),
bizNm: requiredText(values[6], contract.sheetName, rowNumber, "사업명"),
prplPrst: optionalText(values[7]),
bgngYr: yearText(values[8]),
cmcnYr: yearText(values[9]),
planPrlg: optionalDecimal(values[10], contract.sheetName, rowNumber, "계획연장(km)"),
frhfyrExpcPrlg: optionalDecimal(values[11], contract.sheetName, rowNumber, "상반기(예상)연장(km)"),
rmndPrlg: optionalDecimal(values[12], contract.sheetName, rowNumber, "잔여연장(km)"),
evdncDcmntSbmsnStts: optionalText(values[13]),
bgngYmd: dateText(values[14]),
cmcnYmd: dateText(values[15]),
bizPrplDlyRsnCn: optionalText(values[16]),
bizPicNm: optionalText(values[17]),
bizPicCnpl: optionalText(values[18]),
rmrkCn: optionalText(values[19]),
pbcpOpnnCn: optionalText(values[20]),
});
}
return result;
};
/** ArrayBuffer workbook을 검증·파싱하여 두 시트의 JSON 저장 요청을 반환한다. */
export const parseObsltPplWorkbook = (workbookData: ArrayBuffer | Uint8Array): ObsltPplBusinessSaveRequest[] => {
const workbook = XLSX.read(workbookData, { type: "array", cellDates: false });
const rows = SHEET_CONTRACTS.flatMap((contract) => {
const sheet = workbook.Sheets[contract.sheetName];
if (!sheet) throw new Error(`필수 시트가 없습니다: ${contract.sheetName}`);
return parseSheet(sheet, contract);
});
if (rows.length === 0) throw new Error("업로드할 사업 자료가 없습니다.");
return rows;
};
/** 사용자가 선택한 xlsx 파일을 브라우저에서 읽고 JSON 요청 목록으로 변환한다. */
export const parseObsltPplExcel = async (file: File): Promise<ObsltPplBusinessSaveRequest[]> => {
if (!file.name.toLowerCase().endsWith(".xlsx")) throw new Error("xlsx 파일만 업로드할 수 있습니다.");
return parseObsltPplWorkbook(await file.arrayBuffer());
};
@@ -0,0 +1,80 @@
type RegionOption = {
text: string;
val: string | number;
};
const normalizeRegionText = (value?: string | null) => String(value ?? "").replace(/\s+/g, " ").trim();
/** 시군구명에서 상위 시만 대표명으로 추출한다. */
export const extractObsltPplRepresentativeSigunName = (value?: string | null) => {
const normalized = normalizeRegionText(value);
if (!normalized) return "";
const cityMatch = normalized.match(/^(.+?시)\s*(.+)$/);
return cityMatch?.[1] ?? normalized;
};
/** 목록과 상세 저장에 사용할 대표 시군구명을 정규화한다. */
export const normalizeObsltPplSigunName = (value?: string | null) => {
const representative = extractObsltPplRepresentativeSigunName(value);
return representative || normalizeRegionText(value);
};
/** 선택된 시도 기준으로 대표 시군구 한 건씩만 노출할 옵션 목록을 만든다. */
export const buildObsltPplRepresentativeSggOptions = (
sggOptions: RegionOption[],
ctpvCd: string,
) => {
const representativeMap = new Map<string, RegionOption>();
sggOptions
.filter((option) => {
const optionCode = String(option.val ?? "").trim();
return ctpvCd === "0" || optionCode.startsWith(ctpvCd.slice(0, 2));
})
.forEach((option) => {
const representativeName = normalizeObsltPplSigunName(option.text);
if (!representativeName) return;
const existing = representativeMap.get(representativeName);
if (!existing || String(option.val).localeCompare(String(existing.val), "ko") < 0) {
representativeMap.set(representativeName, {
text: representativeName,
val: String(option.val),
});
}
});
return [...representativeMap.values()].sort((left, right) => left.text.localeCompare(right.text, "ko"));
};
/** 저장된 시도명으로 공통 시도 코드값을 다시 찾는다. */
export const findObsltPplCtpvCodeByName = (ctpvOptions: RegionOption[], ctpvNm?: string | null) =>
String(
ctpvOptions.find((option) => normalizeRegionText(option.text) === normalizeRegionText(ctpvNm))?.val ?? "0",
);
/** 저장된 시군구명 또는 코드에 해당하는 대표 시군구 옵션을 찾는다. */
export const findObsltPplRepresentativeSggCode = (
sggOptions: RegionOption[],
ctpvCd: string,
sigunNm?: string | null,
sggCd?: string | null,
) => {
const normalizedSigunName = normalizeObsltPplSigunName(sigunNm);
const representativeOptions = buildObsltPplRepresentativeSggOptions(sggOptions, ctpvCd);
if (normalizedSigunName) {
const matchedByName = representativeOptions.find((option) => option.text === normalizedSigunName);
if (matchedByName) return String(matchedByName.val);
}
const normalizedSggCd = String(sggCd ?? "").trim();
if (!normalizedSggCd) return "0";
const matchedRawOption = sggOptions.find((option) => String(option.val).trim() === normalizedSggCd);
if (!matchedRawOption) return "0";
return String(
representativeOptions.find((option) => option.text === normalizeObsltPplSigunName(matchedRawOption.text))?.val ?? "0",
);
};
@@ -1,8 +1,9 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { SelectBox } from "@common/business_components/ui";
import { Button, Input, Table, TableBody, TableCell, TableHead, TableRow } from "@common/components/ui";
import {
@@ -11,24 +12,31 @@ import {
getObsltPplBusiness,
updateObsltPplBusiness,
} from "../api/obslt-ppl-api";
import {
buildObsltPplRepresentativeSggOptions,
findObsltPplCtpvCodeByName,
findObsltPplRepresentativeSggCode,
normalizeObsltPplSigunName,
} from "../lib/obslt-ppl-region";
import type { ObsltPplBusiness, ObsltPplBusinessSaveRequest } from "../model/obslt-ppl-types";
import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore";
type FormKey = keyof ObsltPplBusinessSaveRequest;
type FormState = Record<FormKey, string>;
const REGION_FORM_KEYS: FormKey[] = ["sggCd", "ctpvNm", "sigunNm"];
/** 등록·수정 표에서 필드 순서, 라벨, 입력 종류와 필수 여부를 한 곳에서 관리한다. */
const FIELDS: Array<{ key: FormKey; label: string; type?: "number" | "textarea"; required?: boolean; placeholder?: string }> = [
{ key: "bizSe", label: "사업구분", required: true }, { key: "bizSeq", label: "순번", type: "number" },
{ key: "prcisExmnCycl", label: "정밀조사차수" }, { key: "bizCd", label: "사업코드", required: true },
{ key: "sggCd", label: "시군구코드" }, { key: "ctpvNm", label: "시도" }, { key: "sigunNm", label: "시군" },
{ key: "envoNm", label: "환경청" }, { key: "bizNm", label: "사업명", required: true },
{ key: "prplPrst", label: "추진현황" }, { key: "bgngYr", label: "착수년도", placeholder: "YYYY" },
{ key: "cmcnYr", label: "준공(예정)년도", placeholder: "YYYY" }, { key: "planPrlg", label: "계획 연장(km)", type: "number" },
{ key: "frhfyrExpcPrlg", label: "26.6월말 예상물량", type: "number" }, { key: "rmndPrlg", label: "잔여물량", type: "number" },
{ key: "cmcnYr", label: "준공(예정)년도", placeholder: "YYYY" }, { key: "planPrlg", label: "계획연장(km)", type: "number" },
{ key: "frhfyrExpcPrlg", label: "상반기(예상)연장(km)", type: "number" }, { key: "rmndPrlg", label: "잔여연장(km)", type: "number" },
{ key: "evdncDcmntSbmsnStts", label: "증빙서류 제출여부" }, { key: "bgngYmd", label: "착수(착공) 예정일자", placeholder: "YYYYMMDD" },
{ key: "cmcnYmd", label: "준공(예정)일자", placeholder: "YYYYMMDD" }, { key: "bizPrplDlyRsnCn", label: "추진 지연사업 대상", type: "textarea" },
{ key: "cmcnYmd", label: "준공(예정)일자", placeholder: "YYYYMMDD" }, { key: "bizPrplDlyRsnCn", label: "사업추진 부진사유", type: "textarea" },
{ key: "bizPicNm", label: "담당자 이름" }, { key: "bizPicCnpl", label: "담당자 연락처" },
{ key: "rmrkCn", label: "비고", type: "textarea" }, { key: "pbcpOpnnCn", label: "공단의견", type: "textarea" },
];
@@ -36,18 +44,23 @@ const FIELDS: Array<{ key: FormKey; label: string; type?: "number" | "textarea";
const NUMBER_KEYS = new Set<FormKey>(["bizSeq", "planPrlg", "frhfyrExpcPrlg", "rmndPrlg"]);
/** 모든 입력을 제어 컴포넌트로 유지하기 위한 빈 폼 값을 생성한다. */
const createEmptyForm = (): FormState => Object.fromEntries(FIELDS.map(({ key }) => [key, key === "bizSe" ? "정밀조사" : ""])) as FormState;
const createEmptyForm = (): FormState => Object.fromEntries(
[...FIELDS.map(({ key }) => key), ...REGION_FORM_KEYS].map((key) => [key, key === "bizSe" ? "정밀조사" : ""]),
) as FormState;
/** API 상세값을 빈 문자열 기반 화면 폼 값으로 변환한다. */
const toForm = (business: ObsltPplBusiness): FormState => {
const form = createEmptyForm();
FIELDS.forEach(({ key }) => { form[key] = String(business[key] ?? ""); });
form.sggCd = String(business.sggCd ?? "");
form.ctpvNm = String(business.ctpvNm ?? "");
form.sigunNm = normalizeObsltPplSigunName(business.sigunNm);
return form;
};
/** 숫자 입력의 빈 문자열은 null로, 나머지는 trim 문자열로 저장 요청을 만든다. */
const toRequest = (form: FormState): ObsltPplBusinessSaveRequest => Object.fromEntries(
FIELDS.map(({ key }) => {
[...FIELDS.map(({ key }) => key), ...REGION_FORM_KEYS].map((key) => {
const value = form[key].trim();
return [key, NUMBER_KEYS.has(key) ? (value === "" ? null : Number(value)) : (value === "" ? null : value)];
}),
@@ -57,9 +70,34 @@ const toRequest = (form: FormState): ObsltPplBusinessSaveRequest => Object.fromE
export function ObsltPplFormPage({ id }: { id?: string }) {
const router = useRouter();
const isModify = Boolean(id);
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [form, setForm] = useState<FormState>(createEmptyForm);
const [loading, setLoading] = useState(isModify);
const [notice, setNotice] = useState("");
const [ctpvCd, setCtpvCd] = useState("0");
const [sggCd, setSggCd] = useState("0");
const ctpvOptions = useMemo(() => [{ text: "전체", val: "0", hidden: true }, ...ctpv], [ctpv]);
const representativeSggOptions = useMemo(
() => [{ text: "전체", val: "0", hidden: true }, ...buildObsltPplRepresentativeSggOptions(sgg, ctpvCd)],
[ctpvCd, sgg],
);
useEffect(() => {
if (ctpv.length === 0) void fetchCtpv();
if (sgg.length === 0) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
useEffect(() => {
if (!ctpv.length || !sgg.length) return;
const resolvedCtpvCd = findObsltPplCtpvCodeByName(ctpv, form.ctpvNm);
const nextCtpvCd = resolvedCtpvCd !== "0" ? resolvedCtpvCd : ctpvCd;
const nextSggCd = findObsltPplRepresentativeSggCode(sgg, nextCtpvCd, form.sigunNm, form.sggCd);
if (nextCtpvCd !== ctpvCd) setCtpvCd(nextCtpvCd);
if (nextSggCd !== sggCd) setSggCd(nextSggCd);
}, [ctpv, ctpvCd, form.ctpvNm, form.sggCd, form.sigunNm, sgg, sggCd]);
useEffect(() => {
if (!id) return;
@@ -72,6 +110,7 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
/** 필수값과 숫자 형식을 화면에서 먼저 확인한 뒤 등록 또는 수정 API를 호출한다. */
const handleSave = async () => {
if (!form.bizSe || !form.bizCd.trim() || !form.bizNm.trim()) { setNotice("사업구분, 사업코드, 사업명은 필수입니다."); return; }
if (ctpvCd === "0" || sggCd === "0" || !form.ctpvNm.trim() || !form.sigunNm.trim()) { setNotice("시도와 시군구를 선택해주세요."); return; }
if ([...NUMBER_KEYS].some((key) => form[key] && !Number.isFinite(Number(form[key])))) { setNotice("순번과 물량은 숫자로 입력해주세요."); return; }
setLoading(true);
try {
@@ -100,6 +139,50 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
<Table variant="secondary">
<colgroup><col width="18%" /><col width="82%" /></colgroup>
<TableBody>
<TableRow>
<TableHead data-required>시군구 선택</TableHead>
<TableCell className="border">
<div className="flex flex-wrap items-center gap-3">
<SelectBox
label="시도"
className=""
labelClass="w-16 whitespace-nowrap text-[1.3rem] text-gray-600"
selectClass="w-[180px] rounded-lg bg-white"
selectArray={ctpvOptions}
value={ctpvCd}
onSelectValue={(value) => {
const nextCtpvCd = String(value);
setCtpvCd(nextCtpvCd);
setSggCd("0");
setForm((current) => ({
...current,
ctpvNm: ctpv.find((option) => String(option.val) === nextCtpvCd)?.text ?? "",
sigunNm: "",
sggCd: "",
}));
}}
/>
<SelectBox
label="시군구"
className=""
labelClass="w-20 whitespace-nowrap text-[1.3rem] text-gray-600"
selectClass="w-[220px] rounded-lg bg-white"
selectArray={representativeSggOptions}
value={sggCd}
onSelectValue={(value) => {
const nextSggCd = String(value);
const selectedOption = representativeSggOptions.find((option) => String(option.val) === nextSggCd);
setSggCd(nextSggCd);
setForm((current) => ({
...current,
sigunNm: selectedOption?.text ?? "",
sggCd: nextSggCd === "0" ? "" : nextSggCd,
}));
}}
/>
</div>
</TableCell>
</TableRow>
{FIELDS.map((field) => (
<TableRow key={field.key}>
<TableHead data-required={field.required || undefined}>{field.label}</TableHead>
@@ -1,5 +1,6 @@
"use client";
import moment from "moment";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
@@ -8,21 +9,21 @@ import ListDiv from "@common/business_components/ui/list_div";
import { Button, Input, Label } from "@common/components/ui";
import {
downloadObsltPplExcel,
getObsltPplBusinesses,
uploadObsltPplExcel,
} from "../api/obslt-ppl-api";
import { parseObsltPplExcel } from "../lib/obslt-ppl-excel";
import { buildObsltPplRepresentativeSggOptions, normalizeObsltPplSigunName } from "../lib/obslt-ppl-region";
import type { ObsltPplBusiness, ObsltPplPage } from "../model/obslt-ppl-types";
import { fileDownload } from "@/components/lib/utils";
import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore";
const PAGE_SIZE = 20;
const DEFAULT_LIST_SIZE = 10;
const ACTION_BUTTON_CLASS =
"rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white";
const BIZ_SE_OPTIONS = [
{ text: "전체", val: "0", hidden: true },
{ text: "전체", val: "0" },
{ text: "정밀조사", val: "정밀조사" },
{ text: "정비사업", val: "정비사업" },
];
@@ -33,30 +34,30 @@ type ObsltPplTableRow = ObsltPplBusiness & {
};
const TABLE_HEADLIST = [
{ id: "rowNumber", title: "NO" },
{ id: "bizSe", title: "사업구분" },
{ id: "bizSeq", title: "순번" },
{ id: "prcisExmnCycl", title: "정밀조사차수" },
{ id: "bizCd", title: "사업코드" },
{ id: "sggCd", title: "시군구코드" },
{ id: "ctpvNm", title: "시도" },
{ id: "sigunNm", title: "시군" },
{ id: "envoNm", title: "환경청" },
{ id: "bizNm", title: "사업명" },
{ id: "prplPrst", title: "추진현황" },
{ id: "bgngYr", title: "착수년도" },
{ id: "cmcnYr", title: "준공(예정)년도" },
{ id: "planPrlg", title: "계획 연장(km)" },
{ id: "frhfyrExpcPrlg", title: "26.6월말 예상물량" },
{ id: "rmndPrlg", title: "잔여물량" },
{ id: "evdncDcmntSbmsnStts", title: "증빙서류 제출여부" },
{ id: "bgngYmd", title: "착수(착공) 예정일자" },
{ id: "cmcnYmd", title: "준공(예정)일자" },
{ id: "bizPrplDlyRsnCn", title: "추진 지연사업 대상" },
{ id: "bizPicNm", title: "담당자 이름" },
{ id: "bizPicCnpl", title: "담당자 연락처" },
{ id: "rmrkCn", title: "비고" },
{ id: "pbcpOpnnCn", title: "공단의견" },
{ id: "rowNumber", title: "NO", widthPercent: 4 },
{ id: "bizSe", title: "사업구분", widthPercent: 6 },
{ id: "bizSeq", title: "순번", widthPercent: 5 },
{ id: "prcisExmnCycl", title: "정밀조사차수", widthPercent: 6 },
{ id: "bizCd", title: "사업코드", widthPercent: 7 },
//{ id: "sggCd", title: "시군구코드", widthPercent: 6 },
{ id: "ctpvNm", title: "시도", widthPercent: 6 },
{ id: "sigunNm", title: "시군", widthPercent: 6 },
{ id: "envoNm", title: "환경청", widthPercent: 8 },
{ id: "bizNm", title: "사업명", widthPercent: 16 },
{ id: "prplPrst", title: "추진현황", widthPercent: 6 },
{ id: "bgngYr", title: "착수년도", widthPercent: 5 },
{ id: "cmcnYr", title: "준공(예정)년도", widthPercent: 6 },
{ id: "planPrlg", title: "계획연장(km)", widthPercent: 6 },
{ id: "frhfyrExpcPrlg", title: "상반기(예상)연장(km)", widthPercent: 8 },
{ id: "rmndPrlg", title: "잔여연장(km)", widthPercent: 6 },
{ id: "evdncDcmntSbmsnStts", title: "증빙서류\n제출여부", widthPercent: 6 },
{ id: "bgngYmd", title: "착수일자", widthPercent: 7 },
{ id: "cmcnYmd", title: "준공(예정)일자", widthPercent: 7 },
{ id: "bizPrplDlyRsnCn", title: "사업추진 부진사유", widthPercent: 26 },
{ id: "bizPicNm", title: "담당자 이름", widthPercent: 6 },
{ id: "bizPicCnpl", title: "담당자 연락처", widthPercent: 8 },
{ id: "rmrkCn", title: "비고", widthPercent: 10 },
{ id: "pbcpOpnnCn", title: "공단\n의견", widthPercent: 2 },
];
const EMPTY_PAGE: ObsltPplPage = {
@@ -64,9 +65,60 @@ const EMPTY_PAGE: ObsltPplPage = {
totalElements: 0,
totalPages: 0,
number: 0,
size: PAGE_SIZE,
size: DEFAULT_LIST_SIZE,
};
const DATE_FIELD_SET = new Set<keyof ObsltPplBusiness>(["bgngYmd", "cmcnYmd"]);
/** 8자리 또는 구분자가 섞인 날짜 문자열을 화면·다운로드 공통 형식으로 정규화한다. */
const formatObsltPplDate = (value?: string | null) => {
if (!value) return "";
const normalized = String(value).trim();
if (!normalized) return "";
const digits = normalized.replace(/[^0-9]/g, "");
if (digits.length === 8) {
const parsedDate = moment(digits, "YYYYMMDD", true);
return parsedDate.isValid() ? parsedDate.format("YYYY-MM-DD") : normalized;
}
const parsedDate = moment(normalized);
return parsedDate.isValid() ? parsedDate.format("YYYY-MM-DD") : normalized;
};
/** 목록 표시값을 공통 포맷으로 정리한다. */
const formatBusinessForDisplay = (row: ObsltPplBusiness): ObsltPplBusiness => ({
...row,
sigunNm: normalizeObsltPplSigunName(row.sigunNm) || null,
bgngYmd: formatObsltPplDate(row.bgngYmd) || null,
cmcnYmd: formatObsltPplDate(row.cmcnYmd) || null,
});
/** 현재 조회조건 기준 전체 목록을 내려받기용 행으로 변환한다. */
const toExcelRow = (row: ObsltPplTableRow) => ({
NO: row.rowNumber,
사업구분: row.bizSe ?? "",
순번: row.bizSeq ?? "",
정밀조사차수: row.prcisExmnCycl ?? "",
사업코드: row.bizCd ?? "",
시도: row.ctpvNm ?? "",
시군: row.sigunNm ?? "",
환경청: row.envoNm ?? "",
사업명: row.bizNm ?? "",
추진현황: row.prplPrst ?? "",
착수년도: row.bgngYr ?? "",
"준공(예정)년도": row.cmcnYr ?? "",
"계획연장(km)": row.planPrlg ?? "",
"상반기(예상)연장(km)": row.frhfyrExpcPrlg ?? "",
"잔여연장(km)": row.rmndPrlg ?? "",
증빙서류제출여부: row.evdncDcmntSbmsnStts ?? "",
착수일자: formatObsltPplDate(row.bgngYmd),
"준공(예정)일자": formatObsltPplDate(row.cmcnYmd),
사업추진부진사유: row.bizPrplDlyRsnCn ?? "",
"담당자 이름": row.bizPicNm ?? "",
"담당자 연락처": row.bizPicCnpl ?? "",
비고: row.rmrkCn ?? "",
공단의견: row.pbcpOpnnCn ?? "",
});
/** 노후하수관로 사업 목록, 검색, xlsx 업로드·다운로드 진입점을 제공한다. */
export function ObsltPplListPage() {
const router = useRouter();
@@ -86,8 +138,7 @@ export function ObsltPplListPage() {
/** 현재 선택한 시도에 속한 시군구만 선택 후보로 제공한다. */
const availableSgg = useMemo(() => {
if (ctpvCd === "0") return sgg;
return sgg.filter((option) => String(option.val).startsWith(ctpvCd.slice(0, 2)));
return buildObsltPplRepresentativeSggOptions(sgg, ctpvCd);
}, [ctpvCd, sgg]);
/** 공통 레이아웃에서 아직 지역 데이터를 불러오지 못한 경우 공통 store 조회를 보완한다. */
@@ -100,7 +151,7 @@ export function ObsltPplListPage() {
const load = useCallback(async (targetPage = page, targetSearch = appliedSearch) => {
const params = new URLSearchParams({
page: String(Math.max(targetPage - 1, 0)),
size: String(PAGE_SIZE),
size: String(DEFAULT_LIST_SIZE),
sort: "obsltSwagePplBizSn,desc",
});
Object.entries(targetSearch).forEach(([key, value]) => {
@@ -112,8 +163,8 @@ export function ObsltPplListPage() {
const result = await getObsltPplBusinesses(params);
setData(result);
setRows(result.content.map((row, index) => ({
...row,
rowNumber: result.totalElements - ((targetPage - 1) * PAGE_SIZE + index),
...formatBusinessForDisplay(row),
rowNumber: result.totalElements - ((targetPage - 1) * DEFAULT_LIST_SIZE + index),
})));
} catch (error) {
setNotice(error instanceof Error ? error.message : "목록을 조회하지 못했습니다.");
@@ -129,7 +180,7 @@ export function ObsltPplListPage() {
void load();
}, [load]);
/** 선택한 xlsx를 서버 교체 업로드하고 첫 페이지를 다시 조회한다. */
/** 선택한 xlsx를 브라우저에서 검증·파싱한 뒤 JSON으로 교체 업로드한다. */
const handleUpload = async (file?: File) => {
if (!file) return;
if (!file.name.toLowerCase().endsWith(".xlsx")) {
@@ -139,7 +190,8 @@ export function ObsltPplListPage() {
setLoading(true);
try {
const result = await uploadObsltPplExcel(file);
const parsedRows = await parseObsltPplExcel(file);
const result = await uploadObsltPplExcel(parsedRows);
setNotice(
`총 ${result.importedCount.toLocaleString()}건을 저장했습니다. (정밀조사 ${result.precisionSurveyCount.toLocaleString()}건, 정비사업 ${result.maintenanceCount.toLocaleString()}건)`,
);
@@ -154,11 +206,28 @@ export function ObsltPplListPage() {
}
};
/** 서버가 생성한 xlsx Blob을 사용자 PC에 고정 파일명으로 저장한다. */
/** 현재 조회조건 기준 전체 목록을 화면 포맷 그대로 xlsx로 내려받는다. */
const handleDownload = async () => {
setLoading(true);
try {
fileDownload(await downloadObsltPplExcel(), "노후하수관로 정밀조사 및 정비사업 현황.xlsx");
const params = new URLSearchParams({
page: "0",
size: String(Math.max(data.totalElements, 1)),
sort: "obsltSwagePplBizSn,desc",
});
Object.entries(appliedSearch).forEach(([key, value]) => {
if (value.trim()) params.set(key, value.trim());
});
const result = await getObsltPplBusinesses(params);
const exportRows = result.content.map((row, index) => ({
...formatBusinessForDisplay(row),
rowNumber: result.totalElements - index,
}));
const { utils, writeFileXLSX } = await import("xlsx");
const worksheet = utils.json_to_sheet(exportRows.map(toExcelRow));
const workbook = utils.book_new();
utils.book_append_sheet(workbook, worksheet, "노후하수관로");
writeFileXLSX(workbook, "노후하수관로 정밀조사 및 정비사업 현황.xlsx");
} catch (error) {
setNotice(error instanceof Error ? error.message : "엑셀을 내려받지 못했습니다.");
} finally {
@@ -228,9 +297,10 @@ export function ObsltPplListPage() {
onSelectValue={(value) => {
const nextSggCd = String(value);
setSggCd(nextSggCd);
const selectedOption = availableSgg.find((option) => String(option.val) === nextSggCd);
setSearch((current) => ({
...current,
sigunNm: sgg.find((option) => String(option.val) === nextSggCd)?.text ?? "",
sigunNm: selectedOption?.text ?? "",
}));
}}
/>
@@ -287,29 +357,34 @@ export function ObsltPplListPage() {
</div>
)}
<ListDiv className="mt-4">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-medium">
노후하수관로 정밀조사 및 정비사업 현황{" "}
<span className="text-base text-gray-500">({data.totalElements.toLocaleString("ko-KR")}건)</span>
</h3>
<ListDiv className="overflow-x-auto">
<div className="w-[100%]">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-medium">
노후하수관로 정밀조사 및 정비사업 현황{" "}
<span className="text-base text-gray-500">({data.totalElements.toLocaleString("ko-KR")}건)</span>
</h3>
</div>
<UiTable<ObsltPplTableRow>
headName=""
headlist={TABLE_HEADLIST}
tableData={rows}
pageSize={DEFAULT_LIST_SIZE}
total={data.totalElements}
curPage={page}
widthFixed
onPageChange={setPage}
resetPageNo={resetPageNo}
cellClick={(index) => {
if (typeof index !== "number" || !rows[index]) return;
router.push(`/oper/obsltPplBiz/register/${rows[index].obsltSwagePplBizSn}`);
}}
renderCell={(row, headId) => {
if (!DATE_FIELD_SET.has(headId as keyof ObsltPplBusiness)) return undefined;
return formatObsltPplDate(row[headId as keyof ObsltPplTableRow] as string | null | undefined);
}}
/>
</div>
<UiTable<ObsltPplTableRow>
headName=""
headlist={TABLE_HEADLIST}
tableData={rows}
pageSize={PAGE_SIZE}
total={data.totalElements}
curPage={page}
widthFixed
className="overflow-x-auto overflow-y-auto"
onPageChange={setPage}
resetPageNo={resetPageNo}
cellClick={(index) => {
if (typeof index !== "number" || !rows[index]) return;
router.push(`/oper/obsltPplBiz/register/${rows[index].obsltSwagePplBizSn}`);
}}
/>
</ListDiv>
</>
);