sn: 순번 삭제 외
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user