sn: 노후 하수관로 정비사업
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user