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

This commit is contained in:
2026-08-03 18:16:42 +09:00
parent ce60e5f5fb
commit 2e6a75141b
11 changed files with 662 additions and 0 deletions
@@ -0,0 +1,6 @@
import { ObsltPplListPage } from "@/features/obsltPpl/ui/ObsltPplListPage";
/** 노후하수관로 정밀조사·정비사업 목록 경로다. */
export default function Page() {
return <ObsltPplListPage />;
}
@@ -0,0 +1,7 @@
import { ObsltPplFormPage } from "@/features/obsltPpl/ui/ObsltPplFormPage";
/** 노후하수관로 사업 수정·삭제 경로다. */
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return <ObsltPplFormPage id={id} />;
}
@@ -0,0 +1,6 @@
import { ObsltPplFormPage } from "@/features/obsltPpl/ui/ObsltPplFormPage";
/** 노후하수관로 사업 신규 등록 경로다. */
export default function Page() {
return <ObsltPplFormPage />;
}
@@ -0,0 +1,20 @@
# SN-노후하수관로정비사업 FE 통합검증계획
## 자동 검증
- `pnpm --filter sn run build`
- 신규 세 경로의 Next 산출물 생성 확인
- 변경 파일 `git diff --check`
## 브라우저 검증
1. 목록 전체 23개 열과 가로 스크롤 확인
2. 검색·페이지 이동 확인
3. 제공 원본 xlsx 업로드 후 517건 표시 확인
4. 엑셀다운로드 후 두 시트 및 재업로드 확인
5. 등록 저장, 행 클릭 수정, 수정 화면 삭제 확인
6. 목록에 삭제 버튼이 없고 수정 하단 버튼 순서가 `목록`, `저장`, `삭제`인지 확인
## 선행조건
로그인 세션, 메뉴 권한, sewerage-service, 생성된 `tb_sn_obslt_swage_ppl_biz`가 필요하다.
@@ -0,0 +1,10 @@
# SN-노후하수관로정비사업 FE 통합검증결과
## 2026-08-03
- 신규 TS/TSX 파일 대상 ESLint를 실행했고 오류·경고 없이 통과했다.
- 선행 빌드에서 목록·등록·수정 세 경로의 `page.js` 산출을 확인했다.
- 독립 `tsc`는 저장소에 설치된 TypeScript가 `ignoreDeprecations: 6.0`을 지원하지 않아 환경 오류로 종료했다.
- 최종 전체 빌드는 신규 화면이 아닌 공통 `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`를 실행해 오류·경고 없이 통과했다.
@@ -0,0 +1,28 @@
# SN-노후하수관로정비사업 FE 작업카드
## 목표
`/oper/obsltPplBiz`에 원본 Excel 전체 항목 목록과 검색·등록·업로드·엑셀다운로드 기능을 제공하고 등록·수정 화면을 분리한다.
## 구현 계약
- 목록은 23개 컬럼을 모두 노출하고 `overflow-x-auto`로 가로 스크롤한다.
- 목록 행 클릭은 `/oper/obsltPplBiz/register/{id}`로 이동한다.
- 목록에는 삭제 버튼을 두지 않는다.
- 등록 화면 하단은 `목록`, `저장`; 수정 화면은 `목록`, `저장`, `삭제` 순서다.
- 조사관리의 multipart 흐름만 재사용하고 조사 결과 전용 validate/commit·첨부서비스 코드는 공유하지 않는다.
- `apps/cs/banner/register`의 표형 폼과 별도 등록/수정 경로를 시각 참고한다.
- 목록 검색의 시도·시군구는 SN 앱 공통 `useStore`의 지역 목록을 사용하는 연계 `SelectBox`로 제공하고, 시도 변경 시 시군구를 초기화한다.
## API 및 관측 포인트
- 목록 로딩·오류·업로드 결과 건수는 화면에서 문구로 관측 가능해야 한다.
- 업로드 input은 `.xlsx`만 받는다.
- 다운로드 버튼명은 `엑셀다운로드`로 고정한다.
- 브라우저 검증 URL: `/oper/obsltPplBiz`, `/oper/obsltPplBiz/register`, `/oper/obsltPplBiz/register/{id}`
## 제외
- 기존 `/oper/oldPp` 변경
- `apps/cs` 또는 공통 컴포넌트 변경
- 메뉴/권한 운영 데이터 변경
@@ -0,0 +1,28 @@
# SN-노후하수관로정비사업 FE 통합구현기록
## 입력 근거
사용자 작업지시 Excel, 공통 통합설계, `/oper/oldPp`, 조사관리 업로드, `apps/cs/banner/register`를 확인했다.
## 2026-08-03 구현
- `src/features/obsltPpl`에 API, 타입, 목록, 표형 등록·수정 화면을 추가했다.
- `app/(with-base-layout)/oper/obsltPplBiz`에 목록·등록·동적 수정 경로를 추가했다.
- 목록에서 Excel 전체 항목과 사업구분·시군구코드를 표시하고 직접 구성 Table을 가로 스크롤 영역에 배치했다.
- 목록 액션을 `조회`, `등록`, `업로드`, `엑셀다운로드`로 구성하고 삭제는 수정 화면으로 이동했다.
- 업로드 성공 시 전체·정밀조사·정비사업 저장 건수를 표시하고 목록을 재조회한다.
- 기존 공통 또는 타 앱 소스는 변경하지 않았다.
## BE 공유 계약
기준 API는 `/api/v1/obsltPplBiz`이며 목록은 Spring Page, 업로드는 multipart `file`, 다운로드는 XLSX Blob이다.
## 남은 리스크
로그인·메뉴 권한이 구성된 통합환경 브라우저 검증이 필요하다.
## 2026-08-03 지역 검색 유지보수
- 목록의 시도·시군 입력을 공통 `SelectBox`로 교체했다.
- SN 앱 공통 Zustand store의 `ctpv`, `sgg`, `fetchCtpv`, `fetchSgg`를 사용하고 선택 시 기존 검색 API 계약에 맞는 한글명을 설정한다.
- 선택한 시도의 코드 앞 두 자리를 기준으로 시군구 후보를 제한하고 시도 변경 시 기존 시군구 선택을 초기화한다.
@@ -0,0 +1,62 @@
import type {
ObsltPplBusiness,
ObsltPplBusinessSaveRequest,
ObsltPplImportResult,
ObsltPplPage,
} from "../model/obslt-ppl-types";
import {
deleteData,
getBlobData,
getData,
postMutiPart,
postRawJson,
putData,
} from "@/components/lib/api";
const API_PATH = "v1/obsltPplBiz";
/** 공통 응답 래퍼가 있는 JSON 응답에서 data를 안전하게 꺼낸다. */
const unwrapData = <T>(body: unknown): T => {
if (body && typeof body === "object" && "data" in body) {
return (body as { data: T }).data;
}
return body as T;
};
/** 목록 검색조건과 페이지 번호로 활성 사업 목록을 조회한다. */
export const getObsltPplBusinesses = async (params: URLSearchParams): Promise<ObsltPplPage> =>
getData(`${API_PATH}?${params.toString()}`) as Promise<ObsltPplPage>;
/** 수정 화면에서 사업 상세 한 건을 조회한다. */
export const getObsltPplBusiness = async (id: string): Promise<ObsltPplBusiness> =>
getData(`${API_PATH}/${encodeURIComponent(id)}`) as Promise<ObsltPplBusiness>;
/** 등록 화면의 입력값으로 사업 한 건을 생성한다. */
export const createObsltPplBusiness = async (
request: ObsltPplBusinessSaveRequest,
): Promise<ObsltPplBusiness> =>
unwrapData<ObsltPplBusiness>(await postRawJson(API_PATH, request));
/** 수정 화면의 입력값으로 사업 한 건을 갱신한다. */
export const updateObsltPplBusiness = async (
id: string,
request: ObsltPplBusinessSaveRequest,
): Promise<ObsltPplBusiness> =>
putData(`${API_PATH}/${encodeURIComponent(id)}`, request) as Promise<ObsltPplBusiness>;
/** 수정 화면에서 선택 사업을 논리삭제한다. */
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>;
};
/** 현재 활성 자료를 원본과 호환되는 xlsx로 내려받는다. */
export const downloadObsltPplExcel = async (): Promise<Blob> =>
getBlobData(`${API_PATH}/excel/download`);
@@ -0,0 +1,51 @@
/** 노후하수관로 정밀조사·정비사업 한 건의 API 계약이다. */
export interface ObsltPplBusiness {
obsltSwagePplBizSn: number;
bizSe: "정밀조사" | "정비사업";
bizSeq?: number | null;
prcisExmnCycl?: string | null;
bizCd: string;
sggCd?: string | null;
ctpvNm?: string | null;
sigunNm?: string | null;
envoNm?: string | null;
bizNm: string;
prplPrst?: string | null;
bgngYr?: string | null;
cmcnYr?: string | null;
planPrlg?: number | null;
frhfyrExpcPrlg?: number | null;
rmndPrlg?: number | null;
evdncDcmntSbmsnStts?: string | null;
bgngYmd?: string | null;
cmcnYmd?: string | null;
bizPrplDlyRsnCn?: string | null;
bizPicNm?: string | null;
bizPicCnpl?: string | null;
rmrkCn?: string | null;
pbcpOpnnCn?: string | null;
regDt?: string | null;
mdfcnDt?: string | null;
}
/** 등록·수정 요청에서는 서버 생성 일련번호와 감사 컬럼을 제외한다. */
export type ObsltPplBusinessSaveRequest = Omit<
ObsltPplBusiness,
"obsltSwagePplBizSn" | "regDt" | "mdfcnDt"
>;
/** Spring Data 페이지 응답의 화면 사용 필드다. */
export interface ObsltPplPage {
content: ObsltPplBusiness[];
totalElements: number;
totalPages: number;
number: number;
size: number;
}
/** Excel 교체 업로드 결과 건수를 표시한다. */
export interface ObsltPplImportResult {
importedCount: number;
precisionSurveyCount: number;
maintenanceCount: number;
}
@@ -0,0 +1,128 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Button, Input, Table, TableBody, TableCell, TableHead, TableRow } from "@common/components/ui";
import {
createObsltPplBusiness,
deleteObsltPplBusiness,
getObsltPplBusiness,
updateObsltPplBusiness,
} from "../api/obslt-ppl-api";
import type { ObsltPplBusiness, ObsltPplBusinessSaveRequest } from "../model/obslt-ppl-types";
import LoadingOverlay from "@/components/ui/LoadingBar";
type FormKey = keyof ObsltPplBusinessSaveRequest;
type FormState = Record<FormKey, string>;
/** 등록·수정 표에서 필드 순서, 라벨, 입력 종류와 필수 여부를 한 곳에서 관리한다. */
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: "evdncDcmntSbmsnStts", label: "증빙서류 제출여부" }, { key: "bgngYmd", label: "착수(착공) 예정일자", placeholder: "YYYYMMDD" },
{ 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" },
];
const NUMBER_KEYS = new Set<FormKey>(["bizSeq", "planPrlg", "frhfyrExpcPrlg", "rmndPrlg"]);
/** 모든 입력을 제어 컴포넌트로 유지하기 위한 빈 폼 값을 생성한다. */
const createEmptyForm = (): FormState => Object.fromEntries(FIELDS.map(({ key }) => [key, key === "bizSe" ? "정밀조사" : ""])) as FormState;
/** API 상세값을 빈 문자열 기반 화면 폼 값으로 변환한다. */
const toForm = (business: ObsltPplBusiness): FormState => {
const form = createEmptyForm();
FIELDS.forEach(({ key }) => { form[key] = String(business[key] ?? ""); });
return form;
};
/** 숫자 입력의 빈 문자열은 null로, 나머지는 trim 문자열로 저장 요청을 만든다. */
const toRequest = (form: FormState): ObsltPplBusinessSaveRequest => Object.fromEntries(
FIELDS.map(({ key }) => {
const value = form[key].trim();
return [key, NUMBER_KEYS.has(key) ? (value === "" ? null : Number(value)) : (value === "" ? null : value)];
}),
) as unknown as ObsltPplBusinessSaveRequest;
/** 배너 등록 화면의 표형 폼 패턴을 적용한 별도 등록·수정 화면이다. */
export function ObsltPplFormPage({ id }: { id?: string }) {
const router = useRouter();
const isModify = Boolean(id);
const [form, setForm] = useState<FormState>(createEmptyForm);
const [loading, setLoading] = useState(isModify);
const [notice, setNotice] = useState("");
useEffect(() => {
if (!id) return;
setLoading(true);
getObsltPplBusiness(id).then((data) => setForm(toForm(data)))
.catch((error) => setNotice(error instanceof Error ? error.message : "상세정보를 조회하지 못했습니다."))
.finally(() => setLoading(false));
}, [id]);
/** 필수값과 숫자 형식을 화면에서 먼저 확인한 뒤 등록 또는 수정 API를 호출한다. */
const handleSave = async () => {
if (!form.bizSe || !form.bizCd.trim() || !form.bizNm.trim()) { setNotice("사업구분, 사업코드, 사업명은 필수입니다."); return; }
if ([...NUMBER_KEYS].some((key) => form[key] && !Number.isFinite(Number(form[key])))) { setNotice("순번과 물량은 숫자로 입력해주세요."); return; }
setLoading(true);
try {
if (id) await updateObsltPplBusiness(id, toRequest(form));
else await createObsltPplBusiness(toRequest(form));
window.alert(isModify ? "수정되었습니다." : "등록되었습니다.");
router.push("/oper/obsltPplBiz");
} catch (error) { setNotice(error instanceof Error ? error.message : "저장하지 못했습니다."); }
finally { setLoading(false); }
};
/** 사용자 확인 후 현재 사업을 논리삭제하고 목록으로 이동한다. */
const handleDelete = async () => {
if (!id || !window.confirm("삭제하시겠습니까?")) return;
setLoading(true);
try { await deleteObsltPplBusiness(id); window.alert("삭제되었습니다."); router.push("/oper/obsltPplBiz"); }
catch (error) { setNotice(error instanceof Error ? error.message : "삭제하지 못했습니다."); }
finally { setLoading(false); }
};
return (
<div className="card card-border !mt-6">
<LoadingOverlay isLoading={loading} />
<div className="card-header"><h3 className="text-2xl font-medium text-label">노후하수관로 사업 {isModify ? "수정" : "등록"}</h3></div>
<div className="card">
<Table variant="secondary">
<colgroup><col width="18%" /><col width="82%" /></colgroup>
<TableBody>
{FIELDS.map((field) => (
<TableRow key={field.key}>
<TableHead data-required={field.required || undefined}>{field.label}</TableHead>
<TableCell className="border">
{field.key === "bizSe" ? (
<select className="h-11 w-full rounded border bg-white px-3 text-[1.3rem]" value={form.bizSe} onChange={(e) => setForm({ ...form, bizSe: e.target.value })}><option value="정밀조사">정밀조사</option><option value="정비사업">정비사업</option></select>
) : field.type === "textarea" ? (
<textarea className="min-h-24 w-full rounded border bg-white p-3 text-[1.3rem]" value={form[field.key]} placeholder={field.placeholder} onChange={(e) => setForm({ ...form, [field.key]: e.target.value })} />
) : (
<Input className="!text-[1.3rem]" type={field.type === "number" ? "number" : "text"} step={field.type === "number" ? "any" : undefined} value={form[field.key]} placeholder={field.placeholder} onChange={(e) => setForm({ ...form, [field.key]: e.target.value })} />
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{notice && <p className="mt-3 text-sm text-red-600" role="alert">{notice}</p>}
<div className="mt-5 flex justify-end gap-2">
<Button size="sm" onClick={() => router.push("/oper/obsltPplBiz")}>목록</Button>
<Button size="sm" className="rounded-sm bg-blue-600 px-4 py-2 !text-white hover:bg-blue-700" onClick={() => void handleSave()}>저장</Button>
{isModify && <Button size="sm" className="rounded-sm bg-red-600 px-4 py-2 !text-white hover:bg-red-700" onClick={() => void handleDelete()}>삭제</Button>}
</div>
</div>
</div>
);
}
@@ -0,0 +1,316 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } 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 } from "@common/components/ui";
import {
downloadObsltPplExcel,
getObsltPplBusinesses,
uploadObsltPplExcel,
} from "../api/obslt-ppl-api";
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 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: "정밀조사" },
{ text: "정비사업", val: "정비사업" },
];
const ALL_REGION_OPTION = { text: "전체", val: "0" };
type ObsltPplTableRow = ObsltPplBusiness & {
rowNumber: number;
};
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: "공단의견" },
];
const EMPTY_PAGE: ObsltPplPage = {
content: [],
totalElements: 0,
totalPages: 0,
number: 0,
size: PAGE_SIZE,
};
/** 노후하수관로 사업 목록, 검색, xlsx 업로드·다운로드 진입점을 제공한다. */
export function ObsltPplListPage() {
const router = useRouter();
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [page, setPage] = useState(1);
const [data, setData] = useState<ObsltPplPage>(EMPTY_PAGE);
const [rows, setRows] = useState<ObsltPplTableRow[]>([]);
const [loading, setLoading] = useState(false);
const [notice, setNotice] = useState("");
const [resetPageNo, setResetPageNo] = useState(false);
// 선택박스의 코드와 API 검색에 사용하는 행정구역명을 함께 관리한다.
const [ctpvCd, setCtpvCd] = useState("0");
const [sggCd, setSggCd] = useState("0");
const [search, setSearch] = useState({ bizSe: "", ctpvNm: "", sigunNm: "", bizNm: "" });
const [appliedSearch, setAppliedSearch] = useState(search);
/** 현재 선택한 시도에 속한 시군구만 선택 후보로 제공한다. */
const availableSgg = useMemo(() => {
if (ctpvCd === "0") return sgg;
return sgg.filter((option) => String(option.val).startsWith(ctpvCd.slice(0, 2)));
}, [ctpvCd, sgg]);
/** 공통 레이아웃에서 아직 지역 데이터를 불러오지 못한 경우 공통 store 조회를 보완한다. */
useEffect(() => {
if (ctpv.length === 0) void fetchCtpv();
if (sgg.length === 0) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
/** 확정 검색조건과 페이지를 서버 요청 파라미터로 변환해 목록을 갱신한다. */
const load = useCallback(async (targetPage = page, targetSearch = appliedSearch) => {
const params = new URLSearchParams({
page: String(Math.max(targetPage - 1, 0)),
size: String(PAGE_SIZE),
sort: "obsltSwagePplBizSn,desc",
});
Object.entries(targetSearch).forEach(([key, value]) => {
if (value.trim()) params.set(key, value.trim());
});
setLoading(true);
try {
const result = await getObsltPplBusinesses(params);
setData(result);
setRows(result.content.map((row, index) => ({
...row,
rowNumber: result.totalElements - ((targetPage - 1) * PAGE_SIZE + index),
})));
} catch (error) {
setNotice(error instanceof Error ? error.message : "목록을 조회하지 못했습니다.");
setData(EMPTY_PAGE);
setRows([]);
} finally {
setLoading(false);
setResetPageNo(false);
}
}, [appliedSearch, page]);
useEffect(() => {
void load();
}, [load]);
/** 선택한 xlsx를 서버 교체 업로드하고 첫 페이지를 다시 조회한다. */
const handleUpload = async (file?: File) => {
if (!file) return;
if (!file.name.toLowerCase().endsWith(".xlsx")) {
setNotice("xlsx 파일만 업로드할 수 있습니다.");
return;
}
setLoading(true);
try {
const result = await uploadObsltPplExcel(file);
setNotice(
`총 ${result.importedCount.toLocaleString()}건을 저장했습니다. (정밀조사 ${result.precisionSurveyCount.toLocaleString()}건, 정비사업 ${result.maintenanceCount.toLocaleString()}건)`,
);
setResetPageNo(true);
setPage(1);
await load(1, appliedSearch);
} catch (error) {
setNotice(error instanceof Error ? error.message : "업로드하지 못했습니다.");
} finally {
setLoading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
/** 서버가 생성한 xlsx Blob을 사용자 PC에 고정 파일명으로 저장한다. */
const handleDownload = async () => {
setLoading(true);
try {
fileDownload(await downloadObsltPplExcel(), "노후하수관로 정밀조사 및 정비사업 현황.xlsx");
} catch (error) {
setNotice(error instanceof Error ? error.message : "엑셀을 내려받지 못했습니다.");
} finally {
setLoading(false);
}
};
/** 입력 중 조건을 실제 조회조건으로 확정하고 첫 페이지를 다시 조회한다. */
const handleSearch = async () => {
const nextSearch = { ...search };
setNotice("");
setAppliedSearch(nextSearch);
setResetPageNo(true);
if (page === 1) {
await load(1, nextSearch);
return;
}
setPage(1);
};
return (
<>
<LoadingOverlay isLoading={loading} />
<SearchDiv>
<SearchFormLeft>
<div className="flex items-center gap-4">
<div className="flex items-center space-x-2">
<div className="flex items-center">
<SelectBox
label="사업구분"
className=""
labelClass="w-20 whitespace-nowrap text-xl text-gray-600"
selectClass="w-[120px] rounded-lg bg-white"
selectArray={BIZ_SE_OPTIONS}
value={search.bizSe}
onSelectValue={(value) => setSearch((current) => ({ ...current, bizSe: String(value) }))}
/>
</div>
<div className="flex items-center">
<SelectBox
label="시도"
className=""
labelClass="w-16 whitespace-nowrap text-xl text-gray-600"
selectClass="w-[140px] rounded-lg bg-white"
selectArray={[ALL_REGION_OPTION, ...ctpv]}
value={ctpvCd}
onSelectValue={(value) => {
const nextCtpvCd = String(value);
setCtpvCd(nextCtpvCd);
setSggCd("0");
setSearch((current) => ({
...current,
ctpvNm: ctpv.find((option) => String(option.val) === nextCtpvCd)?.text ?? "",
sigunNm: "",
}));
}}
/>
</div>
<div className="flex items-center">
<SelectBox
label="시군구"
className=""
labelClass="w-20 whitespace-nowrap text-xl text-gray-600"
selectClass="w-[140px] rounded-lg bg-white"
selectArray={[ALL_REGION_OPTION, ...availableSgg]}
value={sggCd}
onSelectValue={(value) => {
const nextSggCd = String(value);
setSggCd(nextSggCd);
setSearch((current) => ({
...current,
sigunNm: sgg.find((option) => String(option.val) === nextSggCd)?.text ?? "",
}));
}}
/>
</div>
<div className="flex items-center">
<Label className="w-20 text-xl text-gray-600">사업명</Label>
<Input
className="w-[220px] !text-2xl"
value={search.bizNm}
onChange={(event) => setSearch((current) => ({ ...current, bizNm: event.target.value }))}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void handleSearch();
}
}}
/>
</div>
</div>
</div>
</SearchFormLeft>
<SearchFormRight className="ml-auto flex shrink-0 items-center justify-end px-4">
<div className="flex items-center gap-2">
<Button className={ACTION_BUTTON_CLASS} size="sm" onClick={() => void handleSearch()}>
조회
</Button>
<Button className={ACTION_BUTTON_CLASS} size="sm" onClick={() => router.push("/oper/obsltPplBiz/register")}>
등록
</Button>
<input
ref={fileInputRef}
type="file"
accept=".xlsx"
className="hidden"
onChange={(event) => void handleUpload(event.target.files?.[0])}
/>
<Button className={ACTION_BUTTON_CLASS} size="sm" onClick={() => fileInputRef.current?.click()}>
업로드
</Button>
<Button
className="rounded-sm bg-green-600 px-4 py-2 text-sm font-medium !text-white hover:bg-green-700"
size="sm"
onClick={() => void handleDownload()}
>
엑셀다운로드
</Button>
</div>
</SearchFormRight>
</SearchDiv>
{notice && (
<div role="status" className="mx-[1.2rem] mt-4 rounded border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-700">
{notice}
</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>
</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>
</>
);
}