From 2e6a75141b7508f290e5aa5cb37c5ecfce0c84b2 Mon Sep 17 00:00:00 2001 From: ljpark Date: Mon, 3 Aug 2026 18:16:42 +0900 Subject: [PATCH] =?UTF-8?q?sn:=20=EB=85=B8=ED=9B=84=20=ED=95=98=EC=88=98?= =?UTF-8?q?=EA=B4=80=EB=A1=9C=20=EC=A0=95=EB=B9=84=EC=82=AC=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oper/obsltPplBiz/page.tsx | 6 + .../oper/obsltPplBiz/register/[id]/page.tsx | 7 + .../oper/obsltPplBiz/register/page.tsx | 6 + .../001-qa-SN-노후하수관로정비사업-통합검증계획.md | 20 ++ .../002-qa-SN-노후하수관로정비사업-통합검증결과.md | 10 + .../001-fe-SN-노후하수관로정비사업-작업카드.md | 28 ++ .../001-fe-SN-노후하수관로정비사업-통합구현기록.md | 28 ++ .../features/obsltPpl/api/obslt-ppl-api.ts | 62 ++++ .../obsltPpl/model/obslt-ppl-types.ts | 51 +++ .../features/obsltPpl/ui/ObsltPplFormPage.tsx | 128 +++++++ .../features/obsltPpl/ui/ObsltPplListPage.tsx | 316 ++++++++++++++++++ 11 files changed, 662 insertions(+) create mode 100644 apps/sn/app/(with-base-layout)/oper/obsltPplBiz/page.tsx create mode 100644 apps/sn/app/(with-base-layout)/oper/obsltPplBiz/register/[id]/page.tsx create mode 100644 apps/sn/app/(with-base-layout)/oper/obsltPplBiz/register/page.tsx create mode 100644 apps/sn/docs/작업검증/SN-노후하수관로정비사업/001-qa-SN-노후하수관로정비사업-통합검증계획.md create mode 100644 apps/sn/docs/작업검증/SN-노후하수관로정비사업/002-qa-SN-노후하수관로정비사업-통합검증결과.md create mode 100644 apps/sn/docs/작업계획/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-작업카드.md create mode 100644 apps/sn/docs/작업내용/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-통합구현기록.md create mode 100644 apps/sn/src/features/obsltPpl/api/obslt-ppl-api.ts create mode 100644 apps/sn/src/features/obsltPpl/model/obslt-ppl-types.ts create mode 100644 apps/sn/src/features/obsltPpl/ui/ObsltPplFormPage.tsx create mode 100644 apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx diff --git a/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/page.tsx b/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/page.tsx new file mode 100644 index 000000000..d1721ebe7 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/page.tsx @@ -0,0 +1,6 @@ +import { ObsltPplListPage } from "@/features/obsltPpl/ui/ObsltPplListPage"; + +/** 노후하수관로 정밀조사·정비사업 목록 경로다. */ +export default function Page() { + return ; +} diff --git a/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/register/[id]/page.tsx b/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/register/[id]/page.tsx new file mode 100644 index 000000000..1f8f5e481 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/register/[id]/page.tsx @@ -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 ; +} diff --git a/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/register/page.tsx b/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/register/page.tsx new file mode 100644 index 000000000..ab92afe46 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/register/page.tsx @@ -0,0 +1,6 @@ +import { ObsltPplFormPage } from "@/features/obsltPpl/ui/ObsltPplFormPage"; + +/** 노후하수관로 사업 신규 등록 경로다. */ +export default function Page() { + return ; +} diff --git a/apps/sn/docs/작업검증/SN-노후하수관로정비사업/001-qa-SN-노후하수관로정비사업-통합검증계획.md b/apps/sn/docs/작업검증/SN-노후하수관로정비사업/001-qa-SN-노후하수관로정비사업-통합검증계획.md new file mode 100644 index 000000000..da4077ec9 --- /dev/null +++ b/apps/sn/docs/작업검증/SN-노후하수관로정비사업/001-qa-SN-노후하수관로정비사업-통합검증계획.md @@ -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`가 필요하다. diff --git a/apps/sn/docs/작업검증/SN-노후하수관로정비사업/002-qa-SN-노후하수관로정비사업-통합검증결과.md b/apps/sn/docs/작업검증/SN-노후하수관로정비사업/002-qa-SN-노후하수관로정비사업-통합검증결과.md new file mode 100644 index 000000000..f98702837 --- /dev/null +++ b/apps/sn/docs/작업검증/SN-노후하수관로정비사업/002-qa-SN-노후하수관로정비사업-통합검증결과.md @@ -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`를 실행해 오류·경고 없이 통과했다. diff --git a/apps/sn/docs/작업계획/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-작업카드.md b/apps/sn/docs/작업계획/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-작업카드.md new file mode 100644 index 000000000..1a3749e33 --- /dev/null +++ b/apps/sn/docs/작업계획/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-작업카드.md @@ -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` 또는 공통 컴포넌트 변경 +- 메뉴/권한 운영 데이터 변경 diff --git a/apps/sn/docs/작업내용/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-통합구현기록.md b/apps/sn/docs/작업내용/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-통합구현기록.md new file mode 100644 index 000000000..15e6eb33e --- /dev/null +++ b/apps/sn/docs/작업내용/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-통합구현기록.md @@ -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 계약에 맞는 한글명을 설정한다. +- 선택한 시도의 코드 앞 두 자리를 기준으로 시군구 후보를 제한하고 시도 변경 시 기존 시군구 선택을 초기화한다. diff --git a/apps/sn/src/features/obsltPpl/api/obslt-ppl-api.ts b/apps/sn/src/features/obsltPpl/api/obslt-ppl-api.ts new file mode 100644 index 000000000..6733c82e6 --- /dev/null +++ b/apps/sn/src/features/obsltPpl/api/obslt-ppl-api.ts @@ -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 = (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 => + getData(`${API_PATH}?${params.toString()}`) as Promise; + +/** 수정 화면에서 사업 상세 한 건을 조회한다. */ +export const getObsltPplBusiness = async (id: string): Promise => + getData(`${API_PATH}/${encodeURIComponent(id)}`) as Promise; + +/** 등록 화면의 입력값으로 사업 한 건을 생성한다. */ +export const createObsltPplBusiness = async ( + request: ObsltPplBusinessSaveRequest, +): Promise => + unwrapData(await postRawJson(API_PATH, request)); + +/** 수정 화면의 입력값으로 사업 한 건을 갱신한다. */ +export const updateObsltPplBusiness = async ( + id: string, + request: ObsltPplBusinessSaveRequest, +): Promise => + putData(`${API_PATH}/${encodeURIComponent(id)}`, request) as Promise; + +/** 수정 화면에서 선택 사업을 논리삭제한다. */ +export const deleteObsltPplBusiness = async (id: string): Promise => { + await deleteData(`${API_PATH}/${encodeURIComponent(id)}`); +}; + +/** 원본 형식 xlsx 한 건을 두 시트 스냅샷으로 교체 저장한다. */ +export const uploadObsltPplExcel = async (file: File): Promise => { + const formData = new FormData(); + formData.append("file", file); + return postMutiPart(`${API_PATH}/excel/upload`, formData) as Promise; +}; + +/** 현재 활성 자료를 원본과 호환되는 xlsx로 내려받는다. */ +export const downloadObsltPplExcel = async (): Promise => + getBlobData(`${API_PATH}/excel/download`); diff --git a/apps/sn/src/features/obsltPpl/model/obslt-ppl-types.ts b/apps/sn/src/features/obsltPpl/model/obslt-ppl-types.ts new file mode 100644 index 000000000..8bca2f992 --- /dev/null +++ b/apps/sn/src/features/obsltPpl/model/obslt-ppl-types.ts @@ -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; +} diff --git a/apps/sn/src/features/obsltPpl/ui/ObsltPplFormPage.tsx b/apps/sn/src/features/obsltPpl/ui/ObsltPplFormPage.tsx new file mode 100644 index 000000000..0dbe7be47 --- /dev/null +++ b/apps/sn/src/features/obsltPpl/ui/ObsltPplFormPage.tsx @@ -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; + +/** 등록·수정 표에서 필드 순서, 라벨, 입력 종류와 필수 여부를 한 곳에서 관리한다. */ +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(["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(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 ( +
+ +

노후하수관로 사업 {isModify ? "수정" : "등록"}

+
+ + + + {FIELDS.map((field) => ( + + {field.label} + + {field.key === "bizSe" ? ( + + ) : field.type === "textarea" ? ( +