sn: 순번 삭제 외

This commit is contained in:
2026-08-05 15:49:55 +09:00
parent b42810d29a
commit d3bdc58994
9 changed files with 239 additions and 93 deletions
@@ -15,3 +15,12 @@
- 실제 FE 파서를 실행해 총 517건, 정밀조사 128건, 정비사업 389건을 변환하고 대표 사업코드·연장 값을 대조했다.
- 변경한 API·Excel 파서·목록 파일 대상 ESLint를 오류·경고 없이 통과했다.
- SN `next dev` 프로세스가 실행 중이어서 앱 규칙에 따라 전체 `next build`는 실행하지 않았다.
## 2026-08-05
- 변경한 API·Excel·타입·목록·폼 5개 파일의 ESLint를 오류·경고 없이 통과했다.
- SN 전체 TypeScript 검사는 저장소 설정의 `ignoreDeprecations` 값을 명령행에서 보정해 실행했다. 다른 기능의 기존 진단 43건은 남았지만 `features/obsltPpl` 진단은 0건이었다.
- 실제 작업지시 517건을 파싱해 두 시트 workbook으로 생성하고 다시 현행 파서로 읽는 왕복 검증을 통과했다: 정밀조사 128건, 정비사업 389건.
- 시트명과 B6 `순번`, D6 업무별 사업코드 헤더, B8 데이터 시작 위치를 자동 확인했다.
- SN 개발 서버가 3002 포트에서 실행 중이어서 앱 규칙에 따라 `next build`는 실행하지 않았다.
- 로그인·실 API 환경에서 조회조건 다운로드와 다운로드→수정→업로드 브라우저 검증은 남은 통합검증이다.
@@ -0,0 +1,78 @@
# SN-노후하수관로정비사업 FE 엑셀 다운로드 유지보수 계획
## 목표
`/oper/obsltPplBiz`의 `엑셀다운로드`가 업로드와 동일한 시트명 및 셀 위치를 사용하도록 수정한다. 다운로드 파일을 편집한 후 현행 업로드 기능으로 다시 저장할 수 있어야 한다.
- 모드: `maintenance`
- 상태: **구현 및 자동 검증 완료, 브라우저 통합검증 대기**
- 다운로드 생성 주체: FE
공통 `005-de-공통-노후하수관로정비사업-미부여사업코드-유지보수계획.md`에 따라 사업코드 공란을 `{사업구분명}-{PK}`로 저장하는 FE·BE 선행 작업을 먼저 완료한다.
선행 작업에서 `bizSeq` 영속·API 필드를 제거하므로 다운로드 B열 `순번`은 DB 값이 아니라 각 시트의 출력 순서로 생성한다. 업로드 파서는 B열 헤더 위치를 검증하되 순번 값을 저장 요청에 포함하지 않는다.
## 현재 문제
- 현재 다운로드는 `노후하수관로` 한 시트의 1행 헤더로 생성된다.
- 현행 업로드 파서는 `정밀조사`, `정비사업` 두 시트와 B6:V7 헤더, B8 이후 데이터를 요구한다.
- 다운로드 파일과 업로드 계약이 달라 직접 재업로드할 수 없다.
## FE 구현 계약
- SheetJS(`xlsx`)를 계속 사용한다.
- `정밀조사`, `정비사업` 두 시트를 순서대로 생성한다.
- `bizSe`가 `정밀조사`인 행은 `정밀조사` 시트, `정비사업`인 행은 `정비사업` 시트에 기록한다.
- 업로드가 검증하는 정확한 헤더 문자열을 B6:V7에 배치한다.
- 실제 데이터는 B8부터 V열까지 기록한다.
- 업로드와 같은 선, 색, 글꼴 등 시각적 스타일은 복제하지 않는다.
- 화면에 적용된 조회조건에 해당하는 활성 자료만 내려받는다.
- 조건에 해당하는 전체 건은 기존 목록 API에 같은 조회조건과 고정 정렬을 전달하고 페이지 단위로 반복 호출하여 수집한다.
- 선행 작업에서 업로드가 활성행 UPDATE/신규 INSERT 방식으로 바뀌므로, 필터 파일 재업로드 시 파일에 없는 활성행은 유지된다.
- 다운로드 파일명은 `노후하수관로 정밀조사 및 정비사업 현황.xlsx`를 유지한다.
## 수정 파일
| 파일 | 변경 내용 |
| --- | --- |
| `src/features/obsltPpl/api/obslt-ppl-api.ts` | 적용 조회조건 전체 목록을 안정적으로 모으는 페이지 순회 함수 추가 |
| `src/features/obsltPpl/lib/obslt-ppl-excel.ts` | 업로드 계약과 공유되는 두 시트 workbook 생성 로직 추가 |
| `src/features/obsltPpl/ui/ObsltPplListPage.tsx` | 기존 단일 시트 생성 코드 제거 및 새 다운로드 흐름 연결 |
기존 사용자 수정이 있는 `obslt-ppl-region.ts`, `ObsltPplFormPage.tsx`는 이 작업에서 수정하지 않는다.
## 데이터 위치 계약
| Excel 위치 | 값 |
| --- | --- |
| B6:V7 | 업로드 검증 대상 헤더 |
| B8:V마지막행 | 21개 사업 데이터 필드 |
| 시트 1 | `정밀조사` |
| 시트 2 | `정비사업` |
데이터 필드 순서는 현행 `parseSheet`의 B~V 매핑을 역방향으로 사용한다. DB PK인 `obslt_swage_ppl_biz_sn`은 다운로드 열에 포함하지 않고, 업로드 시에도 엑셀 순번을 PK로 사용하지 않는다.
## 검증 계획
1. 변경 파일 ESLint와 대상 TypeScript 검증
2. 두 시트명 및 B6:V7/B8 위치 자동 확인
3. workbook 생성 후 `parseObsltPplWorkbook`으로 재파싱하는 왕복 검증
4. 전체·정밀조사·정비사업 건수와 대표 필드 값 대조
5. 브라우저에서 다운로드 → 일부 셀 수정 → 업로드 동작 확인
6. 검색조건이 적용된 화면에서는 조건에 해당하는 전체 건만 포함되고, 재업로드 후 조건 밖 기존 활성행이 유지되는지 확인
## 제외 범위
- 업로드 파일과 동일한 선, 색상, 글꼴, 행 높이, 열 너비 등 스타일 복제
- BE Controller·Service·Repository 변경
- API 경로 또는 응답 계약 변경
- DB DDL 및 데이터 변경
- 기존 BE 다운로드 API 삭제
## 롤백
예정된 FE 3개 파일의 다운로드 관련 변경만 되돌린다. DB와 API 계약 변경이 없으므로 별도 데이터 롤백은 없다.
## 수행 결과
사용자 승인 후 미부여 사업코드 선행 작업을 먼저 완료하고 다운로드를 구현했다. 원본 517건을 두 시트로 생성한 뒤 현행 업로드 파서로 다시 읽는 자동 왕복 검증을 통과했다.
@@ -33,3 +33,12 @@
- `lib/obslt-ppl-excel.ts`에서 두 필수 시트, B6:V7 핵심 헤더, 필수 사업명, 순번·연장 숫자를 검증한다.
- `노후하수관로 정밀조사 및 정비사업 현황_upload.xlsx`의 517개 행을 저장 요청 JSON으로 변환한 뒤 `/excel/upload`에 `application/json`으로 전송한다.
- 기존 multipart `FormData` 전송과 `postMutiPart` 의존을 제거했다.
## 2026-08-05 사업순서 제거 및 다운로드 위치 호환
- Excel B열 순번은 헤더 위치 검증만 유지하고 저장 JSON의 `bizSeq`에는 포함하지 않도록 변경했으며, 사업코드 공란은 빈 문자열로 BE에 전달한다.
- FE 모델·목록·등록/수정 폼에서 `bizSeq`를 제거했다.
- 화면의 확정 조회조건으로 목록 API를 500건씩 반복 조회하여 조건에 해당하는 전체 활성 자료를 수집한다.
- `bizSe`에 따라 `정밀조사`, `정비사업` 두 시트로 분리하고 업로드와 같은 B6:V7 헤더, B8:V 데이터 위치로 다운로드한다.
- B열 순번은 DB 값이 아니라 각 시트의 출력 순서로 생성하며 스타일·색상 복제는 적용하지 않았다.
- 기존 BE Excel 다운로드 API는 삭제하지 않았다.
@@ -7,13 +7,13 @@ import type {
import {
deleteData,
getBlobData,
getData,
postRawJson,
putData,
} from "@/components/lib/api";
const API_PATH = "v1/obsltPplBiz";
const EXCEL_DOWNLOAD_PAGE_SIZE = 500;
/** 공통 응답 래퍼가 있는 JSON 응답에서 data를 안전하게 꺼낸다. */
const unwrapData = <T>(body: unknown): T => {
@@ -27,6 +27,25 @@ const unwrapData = <T>(body: unknown): T => {
export const getObsltPplBusinesses = async (params: URLSearchParams): Promise<ObsltPplPage> =>
getData(`${API_PATH}?${params.toString()}`) as Promise<ObsltPplPage>;
/** 적용된 조회조건을 유지하며 모든 페이지를 순회해 Excel 다운로드 대상 전체를 모은다. */
export const getAllObsltPplBusinesses = async (searchParams: URLSearchParams): Promise<ObsltPplBusiness[]> => {
const rows: ObsltPplBusiness[] = [];
let page = 0;
let totalPages = 1;
while (page < totalPages) {
const params = new URLSearchParams(searchParams);
params.set("page", String(page));
params.set("size", String(EXCEL_DOWNLOAD_PAGE_SIZE));
params.set("sort", "obsltSwagePplBizSn,asc");
const result = await getObsltPplBusinesses(params);
rows.push(...result.content);
totalPages = result.totalPages;
page += 1;
}
return rows;
};
/** 수정 화면에서 사업 상세 한 건을 조회한다. */
export const getObsltPplBusiness = async (id: string): Promise<ObsltPplBusiness> =>
getData(`${API_PATH}/${encodeURIComponent(id)}`) as Promise<ObsltPplBusiness>;
@@ -49,12 +68,8 @@ export const deleteObsltPplBusiness = async (id: string): Promise<void> => {
await deleteData(`${API_PATH}/${encodeURIComponent(id)}`);
};
/** 브라우저에서 파싱한 두 시트 JSON을 활성 스냅샷으로 교체 저장한다. */
/** 브라우저에서 파싱한 두 시트 JSON을 활성행 UPDATE·신규 INSERT 방식으로 저장한다. */
export const uploadObsltPplExcel = async (
rows: ObsltPplBusinessSaveRequest[],
): Promise<ObsltPplImportResult> =>
unwrapData<ObsltPplImportResult>(await postRawJson(`${API_PATH}/excel/upload`, rows));
/** 현재 활성 자료를 원본과 호환되는 xlsx로 내려받는다. */
export const downloadObsltPplExcel = async (): Promise<Blob> =>
getBlobData(`${API_PATH}/excel/download`);
@@ -1,6 +1,6 @@
import * as XLSX from "xlsx";
import type { ObsltPplBusinessSaveRequest } from "../model/obslt-ppl-types";
import type { ObsltPplBusiness, ObsltPplBusinessSaveRequest } from "../model/obslt-ppl-types";
type BusinessType = ObsltPplBusinessSaveRequest["bizSe"];
@@ -32,6 +32,7 @@ const SHEET_CONTRACTS: SheetContract[] = [
const DATA_START_ROW_INDEX = 7;
const FIRST_DATA_COLUMN_INDEX = 1;
const DATA_COLUMN_COUNT = 21;
const DOWNLOAD_FILENAME = "노후하수관로 정밀조사 및 정비사업 현황.xlsx";
/** 줄바꿈과 공백이 포함된 병합 헤더를 비교 가능한 문자열로 정규화한다. */
const normalizeHeader = (value: string) => value.replace(/\s+/g, "").trim();
@@ -80,17 +81,6 @@ const requiredText = (value: string, sheetName: string, rowNumber: number, field
return normalized;
};
/** 순번 셀을 안전한 정수로 변환한다. */
const optionalInteger = (value: string, sheetName: string, rowNumber: number): number | null => {
const normalized = optionalText(value)?.replace(/,/g, "");
if (!normalized) return null;
const parsed = Number(normalized);
if (!Number.isSafeInteger(parsed)) {
throw new Error(`${sheetName} 시트 ${rowNumber}행의 순번 값이 정수가 아닙니다.`);
}
return parsed;
};
/** 연장 셀의 콤마와 km 단위를 제거하고 유한한 숫자로 변환한다. */
const optionalDecimal = (value: string, sheetName: string, rowNumber: number, fieldName: string): number | null => {
const normalized = optionalText(value)?.replace(/,/g, "").replace(/km/gi, "").trim();
@@ -134,35 +124,33 @@ const parseSheet = (sheet: XLSX.WorkSheet, contract: SheetContract): ObsltPplBus
const values = Array.from({ length: DATA_COLUMN_COUNT }, (_, offset) =>
cellText(sheet, rowIndex, FIRST_DATA_COLUMN_INDEX + offset),
);
const valueAt = (index: number) => values[index] ?? "";
const rowNumber = rowIndex + 1;
const bizSeq = optionalInteger(values[0], contract.sheetName, rowNumber);
const originalBizCd = optionalText(values[2]);
const fallbackType = contract.sheetName === "정밀조사" ? "S" : "M";
const originalBizCd = optionalText(valueAt(2));
result.push({
bizSe: contract.sheetName,
bizSeq,
prcisExmnCycl: optionalText(values[1]),
bizCd: originalBizCd ?? `UNASSIGNED-${fallbackType}-${bizSeq ?? rowNumber}`,
prcisExmnCycl: optionalText(valueAt(1)),
bizCd: originalBizCd ?? "",
sggCd: null,
ctpvNm: optionalText(values[3]),
sigunNm: optionalText(values[4]),
envoNm: optionalText(values[5]),
bizNm: requiredText(values[6], contract.sheetName, rowNumber, "사업명"),
prplPrst: optionalText(values[7]),
bgngYr: yearText(values[8]),
cmcnYr: yearText(values[9]),
planPrlg: optionalDecimal(values[10], contract.sheetName, rowNumber, "계획연장(km)"),
frhfyrExpcPrlg: optionalDecimal(values[11], contract.sheetName, rowNumber, "상반기(예상)연장(km)"),
rmndPrlg: optionalDecimal(values[12], contract.sheetName, rowNumber, "잔여연장(km)"),
evdncDcmntSbmsnStts: optionalText(values[13]),
bgngYmd: dateText(values[14]),
cmcnYmd: dateText(values[15]),
bizPrplDlyRsnCn: optionalText(values[16]),
bizPicNm: optionalText(values[17]),
bizPicCnpl: optionalText(values[18]),
rmrkCn: optionalText(values[19]),
pbcpOpnnCn: optionalText(values[20]),
ctpvNm: optionalText(valueAt(3)),
sigunNm: optionalText(valueAt(4)),
envoNm: optionalText(valueAt(5)),
bizNm: requiredText(valueAt(6), contract.sheetName, rowNumber, "사업명"),
prplPrst: optionalText(valueAt(7)),
bgngYr: yearText(valueAt(8)),
cmcnYr: yearText(valueAt(9)),
planPrlg: optionalDecimal(valueAt(10), contract.sheetName, rowNumber, "계획연장(km)"),
frhfyrExpcPrlg: optionalDecimal(valueAt(11), contract.sheetName, rowNumber, "상반기(예상)연장(km)"),
rmndPrlg: optionalDecimal(valueAt(12), contract.sheetName, rowNumber, "잔여연장(km)"),
evdncDcmntSbmsnStts: optionalText(valueAt(13)),
bgngYmd: dateText(valueAt(14)),
cmcnYmd: dateText(valueAt(15)),
bizPrplDlyRsnCn: optionalText(valueAt(16)),
bizPicNm: optionalText(valueAt(17)),
bizPicCnpl: optionalText(valueAt(18)),
rmrkCn: optionalText(valueAt(19)),
pbcpOpnnCn: optionalText(valueAt(20)),
});
}
return result;
@@ -185,3 +173,67 @@ export const parseObsltPplExcel = async (file: File): Promise<ObsltPplBusinessSa
if (!file.name.toLowerCase().endsWith(".xlsx")) throw new Error("xlsx 파일만 업로드할 수 있습니다.");
return parseObsltPplWorkbook(await file.arrayBuffer());
};
/** 업로드 검증과 정확히 같은 B6:V7 위치에 시트별 헤더를 배치한다. */
const downloadHeaderRows = (contract: SheetContract) => {
const rows = Array.from({ length: DATA_START_ROW_INDEX }, () => Array<string | number>(22).fill(""));
const topHeaders = [
"순번", "정밀조사차수", contract.businessCodeHeader, "시도", "시군", "환경청", "사업명",
"추진현황", "착수년도", "준공(예정)년도", contract.extensionHeader, "", "", "",
contract.progressHeader, "", "사업추진부진사유", "사업담당자(지자체)", "", "비고", "",
];
const bottomHeaders = [
"", "", "", "", "", "", "", "", "", "", "계획연장(km)", "상반기(예상)연장(km)",
"잔여연장(km)", "증빙서류제출여부", contract.startDateHeader, "준공(예정)일자(yyyy.mm.dd)",
"", "이름", "연락처", "", "공단의견",
];
topHeaders.forEach((value, index) => { rows[5]![index + FIRST_DATA_COLUMN_INDEX] = value; });
bottomHeaders.forEach((value, index) => { rows[6]![index + FIRST_DATA_COLUMN_INDEX] = value; });
return rows;
};
/** DB에 저장하지 않는 B열 순번을 시트 출력 순서로 만들고 C~V에 업무값을 배치한다. */
const downloadDataRow = (row: ObsltPplBusiness, index: number): Array<string | number> => [
"",
index + 1,
row.prcisExmnCycl ?? "",
row.bizCd ?? "",
row.ctpvNm ?? "",
row.sigunNm ?? "",
row.envoNm ?? "",
row.bizNm ?? "",
row.prplPrst ?? "",
row.bgngYr ?? "",
row.cmcnYr ?? "",
row.planPrlg ?? "",
row.frhfyrExpcPrlg ?? "",
row.rmndPrlg ?? "",
row.evdncDcmntSbmsnStts ?? "",
row.bgngYmd ?? "",
row.cmcnYmd ?? "",
row.bizPrplDlyRsnCn ?? "",
row.bizPicNm ?? "",
row.bizPicCnpl ?? "",
row.rmrkCn ?? "",
row.pbcpOpnnCn ?? "",
];
/** 조회 결과를 정밀조사·정비사업 두 시트의 재업로드 가능한 위치 구조로 생성한다. */
export const buildObsltPplWorkbook = (businesses: ObsltPplBusiness[]): XLSX.WorkBook => {
const workbook = XLSX.utils.book_new();
SHEET_CONTRACTS.forEach((contract) => {
const data = businesses.filter((row) => row.bizSe === contract.sheetName);
const worksheet = XLSX.utils.aoa_to_sheet([
...downloadHeaderRows(contract),
...data.map(downloadDataRow),
]);
worksheet["!cols"] = Array.from({ length: 22 }, (_, index) => ({ wch: index === 7 ? 30 : 16 }));
XLSX.utils.book_append_sheet(workbook, worksheet, contract.sheetName);
});
return workbook;
};
/** 생성한 두 시트 workbook을 브라우저 다운로드로 저장한다. */
export const downloadObsltPplExcel = (businesses: ObsltPplBusiness[]) => {
XLSX.writeFileXLSX(buildObsltPplWorkbook(businesses), DOWNLOAD_FILENAME);
};
@@ -4,6 +4,15 @@ type RegionOption = {
};
const normalizeRegionText = (value?: string | null) => String(value ?? "").replace(/\s+/g, " ").trim();
export const OBSLT_PPL_ENV_OFFICES = [
{ code: "G2101", name: "한강유역환경청" },
{ code: "G2102", name: "원주지방환경청" },
{ code: "G2201", name: "낙동강유역환경청" },
{ code: "G2202", name: "대구지방환경청" },
{ code: "G2301", name: "금강유역환경청" },
{ code: "G2302", name: "전북지방환경청" },
{ code: "G2501", name: "영산강유역환경청" },
] as const;
/** 시군구명에서 상위 시만 대표명으로 추출한다. */
export const extractObsltPplRepresentativeSigunName = (value?: string | null) => {
@@ -19,6 +28,13 @@ export const normalizeObsltPplSigunName = (value?: string | null) => {
return representative || normalizeRegionText(value);
};
/** 저장 가능한 환경청 명칭인지 검사한다. */
export const isValidObsltPplEnvOfficeName = (value?: string | null) => {
const normalizedValue = normalizeRegionText(value);
if (!normalizedValue) return true;
return OBSLT_PPL_ENV_OFFICES.some((office) => office.name === normalizedValue);
};
/** 선택된 시도 기준으로 대표 시군구 한 건씩만 노출할 옵션 목록을 만든다. */
export const buildObsltPplRepresentativeSggOptions = (
sggOptions: RegionOption[],
@@ -2,7 +2,6 @@
export interface ObsltPplBusiness {
obsltSwagePplBizSn: number;
bizSe: "정밀조사" | "정비사업";
bizSeq?: number | null;
prcisExmnCycl?: string | null;
bizCd: string;
sggCd?: string | null;
@@ -43,7 +42,7 @@ export interface ObsltPplPage {
size: number;
}
/** Excel 교체 업로드 결과 건수를 표시한다. */
/** Excel UPDATE·INSERT 업로드 결과 건수를 표시한다. */
export interface ObsltPplImportResult {
importedCount: number;
precisionSurveyCount: number;
@@ -16,7 +16,9 @@ import {
buildObsltPplRepresentativeSggOptions,
findObsltPplCtpvCodeByName,
findObsltPplRepresentativeSggCode,
isValidObsltPplEnvOfficeName,
normalizeObsltPplSigunName,
OBSLT_PPL_ENV_OFFICES,
} from "../lib/obslt-ppl-region";
import type { ObsltPplBusiness, ObsltPplBusinessSaveRequest } from "../model/obslt-ppl-types";
@@ -29,7 +31,7 @@ const REGION_FORM_KEYS: FormKey[] = ["sggCd", "ctpvNm", "sigunNm"];
/** 등록·수정 표에서 필드 순서, 라벨, 입력 종류와 필수 여부를 한 곳에서 관리한다. */
const FIELDS: Array<{ key: FormKey; label: string; type?: "number" | "textarea"; required?: boolean; placeholder?: string }> = [
{ key: "bizSe", label: "사업구분", required: true }, { key: "bizSeq", label: "순번", type: "number" },
{ key: "bizSe", label: "사업구분", required: true },
{ key: "prcisExmnCycl", label: "정밀조사차수" }, { key: "bizCd", label: "사업코드", required: true },
{ key: "envoNm", label: "환경청" }, { key: "bizNm", label: "사업명", required: true },
{ key: "prplPrst", label: "추진현황" }, { key: "bgngYr", label: "착수년도", placeholder: "YYYY" },
@@ -41,7 +43,7 @@ const FIELDS: Array<{ key: FormKey; label: string; type?: "number" | "textarea";
{ key: "rmrkCn", label: "비고", type: "textarea" }, { key: "pbcpOpnnCn", label: "공단의견", type: "textarea" },
];
const NUMBER_KEYS = new Set<FormKey>(["bizSeq", "planPrlg", "frhfyrExpcPrlg", "rmndPrlg"]);
const NUMBER_KEYS = new Set<FormKey>(["planPrlg", "frhfyrExpcPrlg", "rmndPrlg"]);
/** 모든 입력을 제어 컴포넌트로 유지하기 위한 빈 폼 값을 생성한다. */
const createEmptyForm = (): FormState => Object.fromEntries(
@@ -111,7 +113,11 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
const handleSave = async () => {
if (!form.bizSe || !form.bizCd.trim() || !form.bizNm.trim()) { setNotice("사업구분, 사업코드, 사업명은 필수입니다."); return; }
if (ctpvCd === "0" || sggCd === "0" || !form.ctpvNm.trim() || !form.sigunNm.trim()) { setNotice("시도와 시군구를 선택해주세요."); return; }
if ([...NUMBER_KEYS].some((key) => form[key] && !Number.isFinite(Number(form[key])))) { setNotice("순번과 물량은 숫자로 입력해주세요."); return; }
if ([...NUMBER_KEYS].some((key) => form[key] && !Number.isFinite(Number(form[key])))) { setNotice("연장 값은 숫자로 입력해주세요."); return; }
if (!isValidObsltPplEnvOfficeName(form.envoNm)) {
setNotice(`환경청은 ${OBSLT_PPL_ENV_OFFICES.map((office) => office.name).join(", ")} 중 하나여야 합니다.`);
return;
}
setLoading(true);
try {
if (id) await updateObsltPplBusiness(id, toRequest(form));
@@ -1,18 +1,20 @@
"use client";
import moment from "moment";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import moment from "moment";
import { SearchDiv, SearchFormLeft, SearchFormRight, SelectBox, UiTable } from "@common/business_components/ui";
import ListDiv from "@common/business_components/ui/list_div";
import { Button, Input, Label } from "@common/components/ui";
import {
getAllObsltPplBusinesses,
getObsltPplBusinesses,
uploadObsltPplExcel,
} from "../api/obslt-ppl-api";
import { parseObsltPplExcel } from "../lib/obslt-ppl-excel";
import { downloadObsltPplExcel, parseObsltPplExcel } from "../lib/obslt-ppl-excel";
import { buildObsltPplRepresentativeSggOptions, normalizeObsltPplSigunName } from "../lib/obslt-ppl-region";
import type { ObsltPplBusiness, ObsltPplPage } from "../model/obslt-ppl-types";
@@ -36,7 +38,6 @@ type ObsltPplTableRow = ObsltPplBusiness & {
const TABLE_HEADLIST = [
{ id: "rowNumber", title: "NO", widthPercent: 4 },
{ id: "bizSe", title: "사업구분", widthPercent: 6 },
{ id: "bizSeq", title: "순번", widthPercent: 5 },
{ id: "prcisExmnCycl", title: "정밀조사차수", widthPercent: 6 },
{ id: "bizCd", title: "사업코드", widthPercent: 7 },
//{ id: "sggCd", title: "시군구코드", widthPercent: 6 },
@@ -92,33 +93,6 @@ const formatBusinessForDisplay = (row: ObsltPplBusiness): ObsltPplBusiness => ({
cmcnYmd: formatObsltPplDate(row.cmcnYmd) || null,
});
/** 현재 조회조건 기준 전체 목록을 내려받기용 행으로 변환한다. */
const toExcelRow = (row: ObsltPplTableRow) => ({
NO: row.rowNumber,
사업구분: row.bizSe ?? "",
순번: row.bizSeq ?? "",
정밀조사차수: row.prcisExmnCycl ?? "",
사업코드: row.bizCd ?? "",
시도: row.ctpvNm ?? "",
시군: row.sigunNm ?? "",
환경청: row.envoNm ?? "",
사업명: row.bizNm ?? "",
추진현황: row.prplPrst ?? "",
착수년도: row.bgngYr ?? "",
"준공(예정)년도": row.cmcnYr ?? "",
"계획연장(km)": row.planPrlg ?? "",
"상반기(예상)연장(km)": row.frhfyrExpcPrlg ?? "",
"잔여연장(km)": row.rmndPrlg ?? "",
증빙서류제출여부: row.evdncDcmntSbmsnStts ?? "",
착수일자: formatObsltPplDate(row.bgngYmd),
"준공(예정)일자": formatObsltPplDate(row.cmcnYmd),
사업추진부진사유: row.bizPrplDlyRsnCn ?? "",
"담당자 이름": row.bizPicNm ?? "",
"담당자 연락처": row.bizPicCnpl ?? "",
비고: row.rmrkCn ?? "",
공단의견: row.pbcpOpnnCn ?? "",
});
/** 노후하수관로 사업 목록, 검색, xlsx 업로드·다운로드 진입점을 제공한다. */
export function ObsltPplListPage() {
const router = useRouter();
@@ -206,28 +180,16 @@ export function ObsltPplListPage() {
}
};
/** 현재 조회조건 기준 전체 목록을 화면 포맷 그대로 xlsx로 내려받는다. */
/** 적용된 조회조건 전체를 두 업무 시트의 업로드 호환 위치로 내려받는다. */
const handleDownload = async () => {
setLoading(true);
try {
const params = new URLSearchParams({
page: "0",
size: String(Math.max(data.totalElements, 1)),
sort: "obsltSwagePplBizSn,desc",
});
const params = new URLSearchParams();
Object.entries(appliedSearch).forEach(([key, value]) => {
if (value.trim()) params.set(key, value.trim());
});
const result = await getObsltPplBusinesses(params);
const exportRows = result.content.map((row, index) => ({
...formatBusinessForDisplay(row),
rowNumber: result.totalElements - index,
}));
const { utils, writeFileXLSX } = await import("xlsx");
const worksheet = utils.json_to_sheet(exportRows.map(toExcelRow));
const workbook = utils.book_new();
utils.book_append_sheet(workbook, worksheet, "노후하수관로");
writeFileXLSX(workbook, "노후하수관로 정밀조사 및 정비사업 현황.xlsx");
const exportRows = await getAllObsltPplBusinesses(params);
downloadObsltPplExcel(exportRows);
} catch (error) {
setNotice(error instanceof Error ? error.message : "엑셀을 내려받지 못했습니다.");
} finally {