sn: 노후하수관로정비사업 검색조건 추가

This commit is contained in:
2026-09-23 09:07:56 +09:00
parent be14fe2603
commit 549debf8bf
5 changed files with 86 additions and 19 deletions
@@ -148,3 +148,21 @@
- 목록은 CMS 코드값 또는 코드명과 정확히 일치하는 값만 코드명으로 표시하고 미등록 기존 값은 그대로 표시한다.
- 영문 `lwpoCd` 저장 전환과 기존 데이터 이관은 이번 범위에서 수행하지 않았다.
- 백엔드 API, Excel 입출력과 DB는 변경하지 않았다.
## 2026-09-22 목록 사업코드·추진현황 검색조건 추가
- 사용자 유지보수 요청에 따라 목록 검색조건에 사업코드와 추진현황을 추가했다.
- 검색조건은 `사업구분 → 시도 → 시군구 → 사업코드 → 추진현황 → 사업명` 순서로 배치했다.
- 사업코드는 자유 입력으로 부분일치 조회하고, 추진현황은 기존 자유문자를 직접 입력하면서 `BIZPRGRSSTTS` 공통코드 후보를 datalist로 선택할 수 있게 했다.
- 두 조건을 목록 검색 상태에 포함해 조회 URL, 페이지 이동, 정렬, 상세·등록 후 목록 복귀와 초기화에서 동일하게 유지한다.
- Excel 다운로드도 적용된 검색 상태를 재사용하므로 현재 화면과 같은 사업코드·추진현황 조건의 전체 결과를 내려받는다.
- SN 운영 빌드와 별도 TypeScript 검사를 통과했다. 최초 빌드는 Windows 파일 핸들 한도 오류로 중단됐으나 단독 재시도에서 성공했다.
## 2026-09-22 공단의견 목록·폼·Excel 제거
- 사용자 유지보수 요청에 따라 목록의 공단의견 열과 등록·상세·수정 화면의 공단 의견 입력란을 제거했다.
- FE 저장 요청 타입에서 `pbcpOpnnCn`을 제거해 등록·수정 API 요청에 공단의견을 포함하지 않는다.
- 업로드 헤더 검증과 행 파싱에서 V열 공단의견을 제거하고 B~U의 20개 업무 열만 저장 요청으로 변환한다.
- 기존 B~V 업로드 파일의 V열이 남아 있어도 이를 읽거나 저장하지 않으며, 나머지 B~U 열은 같은 위치로 처리한다.
- FE Excel 다운로드의 V열 공단의견, 열 폭, 스타일 범위를 제거하고 B~U 양식으로 생성한다.
- DB 컬럼과 기존 값은 유지하며 화면·API·Excel에서만 제외한다.
@@ -33,7 +33,7 @@ const SHEET_CONTRACTS: SheetContract[] = [
const DATA_START_ROW_INDEX = 7;
const FIRST_DATA_COLUMN_INDEX = 1;
const DATA_COLUMN_COUNT = 21;
const DATA_COLUMN_COUNT = 20;
const DOWNLOAD_FILENAME = "노후하수관로 정밀조사 및 정비사업 현황.xlsx";
/** API 문자열에 남은 Excel 개행 이스케이프를 실제 줄바꿈으로 정리한다. */
@@ -63,7 +63,7 @@ const validateHeaders = (sheet: XLSX.WorkSheet, contract: SheetContract) => {
[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, "공단의견"],
[6, 18, "이름"], [6, 19, "연락처"], [5, 20, "비고"],
];
for (const [rowIndex, columnIndex, expected] of expectedHeaders) {
@@ -116,7 +116,7 @@ const dateText = (value: string): string | null => {
return (digits || normalized).slice(0, 8);
};
/** B~V가 모두 비어 있는 행은 데이터가 아닌 양식 여백으로 판단한다. */
/** B~U가 모두 비어 있는 행은 데이터가 아닌 양식 여백으로 판단한다. */
const isEmptyDataRow = (sheet: XLSX.WorkSheet, rowIndex: number) =>
Array.from({ length: DATA_COLUMN_COUNT }, (_, offset) =>
cellText(sheet, rowIndex, FIRST_DATA_COLUMN_INDEX + offset),
@@ -159,7 +159,6 @@ const parseSheet = (sheet: XLSX.WorkSheet, contract: SheetContract): ObsltPplBus
bizPicNm: optionalText(valueAt(17)),
bizPicCnpl: optionalText(valueAt(18)),
rmrkCn: optionalText(valueAt(19)),
pbcpOpnnCn: optionalText(valueAt(20)),
});
}
return result;
@@ -183,27 +182,27 @@ export const parseObsltPplExcel = async (file: File): Promise<ObsltPplBusinessSa
return parseObsltPplWorkbook(await file.arrayBuffer());
};
/** 업로드 검증과 정확히 같은 B6:V7 위치에 시트별 헤더를 배치한다. */
/** 업로드 검증과 정확히 같은 B6:U7 위치에 시트별 헤더를 배치한다. */
const downloadHeaderRows = (contract: SheetContract) => {
const rows = Array.from({ length: DATA_START_ROW_INDEX }, () => Array<string | number>(22).fill(""));
const rows = Array.from({ length: DATA_START_ROW_INDEX }, () => Array<string | number>(21).fill(""));
const topHeaders = [
"순번", "정밀\n조사\n차수", contract.businessCodeHeader.replace("사업코드", "\n사업코드"),
"시도", "시군", "환경청", "사업명", "추진현황", "착수\n년도", "준공(예정)\n년도",
contract.extensionHeader, "", "", "", contract.progressHeader.replace("추진현황", " 추진현황"),
"", "사업추진 부진 사유", "사업 담당자(지자체)", "", "비고", "",
"", "사업추진 부진 사유", "사업 담당자(지자체)", "", "비고",
];
const bottomHeaders = [
"", "", "", "", "", "", "", "", "", "", "계획연장(km)", "상반기(예상)연장(km)",
"잔여연장(km)", "증빙서류\n제출여부", contract.startDateHeader.replace("(yyyy.mm.dd)", "\n(yyyy.mm.dd)"),
"준공(예정)일자\n(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에 업무값을 배치한다. */
/** DB에 저장하지 않는 B열 순번을 시트 출력 순서로 만들고 C~U에 업무값을 배치한다. */
const downloadDataRow = (row: ObsltPplBusiness, index: number): Array<string | number> => [
"",
index + 1,
@@ -226,7 +225,6 @@ const downloadDataRow = (row: ObsltPplBusiness, index: number): Array<string | n
excelTextValue(row.bizPicNm),
excelTextValue(row.bizPicCnpl),
excelTextValue(row.rmrkCn),
excelTextValue(row.pbcpOpnnCn),
];
/** 조회 결과를 정밀조사·정비사업 두 시트의 재업로드 가능한 위치 구조로 생성한다. */
@@ -266,7 +264,7 @@ export const buildObsltPplWorkbook = (businesses: ObsltPplBusiness[]): XLSX.Work
];
const widths = [
3, 7, 9, 16, 12, 12, 16, 34, 10, 9, 11, 13, 16, 13, 11, 16, 16,
34, 11, 16, 34, 24,
34, 11, 16, 34,
];
worksheet["!cols"] = widths.map((wch) => ({ wch }));
XLSX.utils.book_append_sheet(workbook, worksheet, contract.sheetName);
@@ -304,7 +302,7 @@ export const downloadObsltPplExcel = async (
fill: "FFF2CC",
border: true,
});
styledSheet.range("B6:V7").style({
styledSheet.range("B6:U7").style({
bold: true,
fontFamily: "맑은 고딕",
fontSize: 10,
@@ -315,7 +313,7 @@ export const downloadObsltPplExcel = async (
});
styledSheet.range("B6:H7").style("fill", "B4C6E7");
styledSheet.range("I6:Q7").style("fill", "FFF2CC");
styledSheet.range("R6:V7").style("fill", "FCE4D6");
styledSheet.range("R6:U7").style("fill", "FCE4D6");
styledSheet.row(2).height(26);
styledSheet.row(3).height(26);
styledSheet.row(5).height(22);
@@ -325,7 +323,7 @@ export const downloadObsltPplExcel = async (
if (rowCount > 0) {
const dataStartRow = DATA_START_ROW_INDEX + 1;
const dataEndRow = DATA_START_ROW_INDEX + rowCount;
styledSheet.range(`B${dataStartRow}:V${dataEndRow}`).style({
styledSheet.range(`B${dataStartRow}:U${dataEndRow}`).style({
fontFamily: "맑은 고딕",
fontSize: 10,
horizontalAlignment: "center",
@@ -336,7 +334,7 @@ export const downloadObsltPplExcel = async (
styledSheet
.range(`L${dataStartRow}:N${dataEndRow}`)
.style("numberFormat", "#,##0.###");
["H", "R", "U", "V"].forEach((column) =>
["H", "R", "U"].forEach((column) =>
styledSheet
.range(`${column}${dataStartRow}:${column}${dataEndRow}`)
.style({ horizontalAlignment: "left", wrapText: true }),
@@ -22,7 +22,6 @@ export interface ObsltPplBusiness {
bizPicNm?: string | null;
bizPicCnpl?: string | null;
rmrkCn?: string | null;
pbcpOpnnCn?: string | null;
regDt?: string | null;
mdfcnDt?: string | null;
}
@@ -79,7 +79,6 @@ const FIELDS: Array<{
{ key: "bizPicNm", label: "담당자 이름" },
{ key: "bizPicCnpl", label: "담당자 연락처" },
{ key: "rmrkCn", label: "비고", type: "textarea" },
{ key: "pbcpOpnnCn", label: "공단 의견", type: "textarea" },
];
const NUMBER_KEYS = new Set<FormKey>([
@@ -530,7 +529,6 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
{renderField("bizPicCnpl", 2)}
{renderField("bizPrplDlyRsnCn", 3)}
{renderField("rmrkCn", 3)}
{renderField("pbcpOpnnCn", 3)}
</StandardFormSection>
<div className="flex justify-center gap-4 pb-8 pt-6">
@@ -65,8 +65,10 @@ type ObsltPplTableColumn = {
};
type ObsltPplSearch = {
bizSe: string;
bizCd: string;
ctpvNm: string;
sigunNm: string;
prplPrst: string;
bizNm: string;
};
@@ -120,7 +122,6 @@ const TABLE_COLUMNS: ObsltPplTableColumn[] = [
{ id: "bizPicNm", title: "담당자 이름", width: 90 },
{ id: "bizPicCnpl", title: "담당자 연락처", width: 120 },
{ id: "rmrkCn", title: "비고", width: 160, align: "left" },
{ id: "pbcpOpnnCn", title: "공단\n의견", width: 90, align: "left" },
];
const TABLE_MIN_WIDTH = "280rem";
@@ -154,6 +155,7 @@ export function ObsltPplListPage() {
// 검색값·원본 행은 유지하고 사업구분·추진·증빙 표시명만 CMS 기준으로 변환한다.
const {
obsltBusinessTypeOptions,
businessProgressOptions,
evidenceStatusLabels,
businessProgressLabels,
} = useOperBusinessCommonCodes();
@@ -162,8 +164,10 @@ export function ObsltPplListPage() {
const initialSearch = useMemo<ObsltPplSearch>(
() => ({
bizSe: searchParams.get("bizSe") ?? "정밀조사",
bizCd: searchParams.get("bizCd") ?? "",
ctpvNm: searchParams.get("ctpvNm") ?? "",
sigunNm: searchParams.get("sigunNm") ?? "",
prplPrst: searchParams.get("prplPrst") ?? "",
bizNm: searchParams.get("bizNm") ?? "",
}),
[searchParams],
@@ -454,8 +458,10 @@ export function ObsltPplListPage() {
const handleReset = () => {
const nextSearch: ObsltPplSearch = {
bizSe: "정밀조사",
bizCd: "",
ctpvNm: "",
sigunNm: "",
prplPrst: "",
bizNm: "",
};
setSearch(nextSearch);
@@ -599,6 +605,54 @@ export function ObsltPplListPage() {
</Select>
</div>
<div className="flex items-center gap-2">
<Label
htmlFor="obslt-ppl-biz-code"
className="mr-2 whitespace-nowrap text-label"
>
사업코드
</Label>
<Input
id="obslt-ppl-biz-code"
className="w-[14rem] rounded-sm !text-[1.3rem]"
value={search.bizCd}
onChange={(event) =>
setSearch((current) => ({
...current,
bizCd: event.target.value,
}))
}
/>
</div>
<div className="flex items-center gap-2">
<Label
htmlFor="obslt-ppl-progress"
className="mr-2 whitespace-nowrap text-label"
>
추진현황
</Label>
<Input
id="obslt-ppl-progress"
className="w-[15rem] rounded-sm !text-[1.3rem]"
list="obslt-ppl-progress-suggestions"
value={search.prplPrst}
onChange={(event) =>
setSearch((current) => ({
...current,
prplPrst: event.target.value,
}))
}
/>
<datalist id="obslt-ppl-progress-suggestions">
{businessProgressOptions.map((option) => (
<option key={option.code} value={option.value}>
{option.label}
</option>
))}
</datalist>
</div>
<div className="flex items-center gap-2">
<Label
htmlFor="obslt-ppl-biz-name"