diff --git a/apps/sn/app/store/useOnsemiroAddrStore.ts b/apps/sn/app/store/useOnsemiroAddrStore.ts index 30f3647f2..f48f02af1 100644 --- a/apps/sn/app/store/useOnsemiroAddrStore.ts +++ b/apps/sn/app/store/useOnsemiroAddrStore.ts @@ -54,13 +54,13 @@ const toSelectOption = (item: Record) => { item.label, item.govAreaNm, item.govAreaName, - item.lclgvrnmntNm, - item.sido, item.gugun, - item.cityNm, - item.cityName, item.sggNm, item.sggName, + item.lclgvrnmntNm, + item.sido, + item.cityNm, + item.cityName, item.korNm, ].find((value) => typeof value === "string" && value.trim()); @@ -70,12 +70,12 @@ const toSelectOption = (item: Record) => { item.id, item.govAreaCd, item.govAreaCode, - item.lclgvrnmntCd, item.adstDstrCd, - item.cityCd, - item.cityCode, item.sggCd, item.sggCode, + item.lclgvrnmntCd, + item.cityCd, + item.cityCode, ].find( (value) => value !== null && value !== undefined && String(value).trim(), ); diff --git a/apps/sn/docs/작업검증/SN-노후하수관로정비사업/001-qa-SN-노후하수관로정비사업-통합검증계획.md b/apps/sn/docs/작업검증/SN-노후하수관로정비사업/001-qa-SN-노후하수관로정비사업-통합검증계획.md index 80ea08299..1ad1e0c51 100644 --- a/apps/sn/docs/작업검증/SN-노후하수관로정비사업/001-qa-SN-노후하수관로정비사업-통합검증계획.md +++ b/apps/sn/docs/작업검증/SN-노후하수관로정비사업/001-qa-SN-노후하수관로정비사업-통합검증계획.md @@ -14,6 +14,8 @@ 4. 엑셀다운로드 후 두 시트 및 재업로드 확인 5. 등록 저장, 행 클릭 수정, 수정 화면 삭제 확인 6. 목록에 삭제 버튼이 없고 수정 하단 버튼 순서가 `목록`, `저장`, `삭제`인지 확인 +7. 등록·수정 화면에서 `시도,시군구 선택`에 필수 표시가 노출되고, 시도 미선택 저장 시 `시도는 필수입니다.`가 표시되는지 확인 +8. 시도만 선택하고 시군구를 선택하지 않아도 프론트 검증을 통과해 저장 요청이 전송되는지 확인 ## 선행조건 diff --git a/apps/sn/docs/작업내용/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-통합구현기록.md b/apps/sn/docs/작업내용/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-통합구현기록.md index 3e01e4a68..150b1d9ae 100644 --- a/apps/sn/docs/작업내용/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-통합구현기록.md +++ b/apps/sn/docs/작업내용/SN-노후하수관로정비사업/001-fe-SN-노후하수관로정비사업-통합구현기록.md @@ -42,3 +42,9 @@ - `bizSe`에 따라 `정밀조사`, `정비사업` 두 시트로 분리하고 업로드와 같은 B6:V7 헤더, B8:V 데이터 위치로 다운로드한다. - B열 순번은 DB 값이 아니라 각 시트의 출력 순서로 생성하며 스타일·색상 복제는 적용하지 않았다. - 기존 BE Excel 다운로드 API는 삭제하지 않았다. + +## 2026-08-07 등록·수정 화면 지역 필수 표시 + +- 지역 입력 행 제목을 `시군구 선택`에서 `시도,시군구 선택`으로 변경하고 필수 표시를 추가했다. +- 저장 전 시도 선택 여부를 검증하며, 미선택 시 `시도는 필수입니다.`를 표시한다. +- 시군구는 선택사항으로 유지하여 시도만 선택한 자료도 저장 요청할 수 있다. diff --git a/apps/sn/src/features/cfmtnBgt/lib/cfmtn-bgt-excel.ts b/apps/sn/src/features/cfmtnBgt/lib/cfmtn-bgt-excel.ts index 750e6c941..fd79debd7 100644 --- a/apps/sn/src/features/cfmtnBgt/lib/cfmtn-bgt-excel.ts +++ b/apps/sn/src/features/cfmtnBgt/lib/cfmtn-bgt-excel.ts @@ -68,6 +68,7 @@ const SUB_HEADERS = [ "환특융자", "기타", ]; +const MONEY_COLUMN_INDEXES = [17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29]; /** 조회 행을 Excel 열 순서에 맞는 셀 배열로 변환한다. */ const values = (r: CfmtnBgtRow) => [ r.rowNumber, @@ -119,6 +120,16 @@ export const downloadCfmtnBgtExcel = (rows: CfmtnBgtRow[], year: string) => { { s: { r: 0, c: 17 }, e: { r: 0, c: 22 } }, { s: { r: 0, c: 24 }, e: { r: 0, c: 29 } }, ]; + rows.forEach((_, rowIndex) => { + MONEY_COLUMN_INDEXES.forEach((columnIndex) => { + const cellAddress = XLSX.utils.encode_cell({ + r: rowIndex + 2, + c: columnIndex, + }); + const cell = ws[cellAddress]; + if (cell && typeof cell.v === "number") cell.z = "#,##0"; + }); + }); XLSX.utils.book_append_sheet(wb, ws, "최종확정예산관리"); XLSX.writeFileXLSX(wb, `최종확정예산관리_${year}.xlsx`); }; diff --git a/apps/sn/src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx b/apps/sn/src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx index bef714e40..d60ee49c6 100644 --- a/apps/sn/src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx +++ b/apps/sn/src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx @@ -35,6 +35,10 @@ import LoadingOverlay from "@/components/ui/LoadingBar"; /** 검색 SelectBox에서 공통으로 사용하는 전체 옵션이다. */ const ALL = { text: "전체", val: "0" }; +const withAllOption = (items: { text: string; val: string }[]) => [ + ALL, + ...items.filter((item) => String(item.val) !== ALL.val), +]; /** 화면 목록과 Excel 반복 조회에 적용할 페이지 크기다. */ const PAGE_SIZE = 10; const EXPORT_SIZE = 1000; @@ -124,13 +128,13 @@ export default function CfmtnBgtPopupContent({ /** 부모 사업구분에 맞는 팝업 초기 검색조건을 만든다. */ const initial = () => ({ year: "", - govArea: "", - sido: "", - gugun: "", - untBzCd: "", - mevBzNm: "", //bizSe === "정비사업" ? "하수관로정비" : "노후하수관로정밀조사지원", - dtlBzCd: "", - bizNm: "", + govArea: "", // 환경청 + sido: "", // 시도 + gugun: "", // 시군구 + untBzCd: "", // 세부사업 + mevBzNm: "", //내역사업 bizSe === "정비사업" ? "하수관로정비" : "노후하수관로정밀조사지원", + dtlBzCd: "", // 세부내역 + bizNm: "", // 사업명 }); // 입력 중 검색조건과 조회 코드·페이지·선택행·메시지 상태를 관리한다. const [search, setSearch] = useState(initial); @@ -146,11 +150,12 @@ export default function CfmtnBgtPopupContent({ const [notice, setNotice] = useState(""); // 중복 요청을 막고 처리 중 버튼 상태를 제어한다. const [loading, setLoading] = useState(false); - /** 기준연도부터 최근 6개 연도의 선택 옵션을 만든다. */ + /** 현재년도부터 최근 6개 연도의 선택 옵션을 만든다. */ const yearOptions = useMemo(() => { if (!yearBase) return []; + const thisYear = new Date().getFullYear(); return Array.from({ length: 6 }, (_, index) => { - const year = String(Number(yearBase) - index); + const year = String(thisYear - index); return { text: year, val: year }; }); }, [yearBase]); @@ -261,7 +266,7 @@ export default function CfmtnBgtPopupContent({ search.govArea, ); if (cityName) p.set("sido", cityName); - if (search.gugun) + if (search.gugun && search.gugun !== "0") p.set( "gugun", sggOptions.find((x) => String(x.val) === search.gugun)?.text ?? "", @@ -362,7 +367,7 @@ export default function CfmtnBgtPopupContent({ className="" labelClass="w-20 whitespace-nowrap text-xl text-gray-600" selectClass="w-[140px] rounded-lg bg-white" - selectArray={[ALL, ...govArea]} + selectArray={withAllOption(govArea)} value={search.govArea} onSelectValue={(v) => setSearch((s) => ({ ...s, govArea: String(v) })) @@ -388,7 +393,7 @@ export default function CfmtnBgtPopupContent({ className="" labelClass="w-20 whitespace-nowrap text-xl text-gray-600" selectClass="w-[140px] rounded-lg bg-white" - selectArray={[ALL, ...sggOptions]} + selectArray={withAllOption(sggOptions)} value={search.gugun} onSelectValue={(v) => setSearch((s) => ({ ...s, gugun: String(v) })) @@ -403,7 +408,7 @@ export default function CfmtnBgtPopupContent({ className="" labelClass="w-20 whitespace-nowrap text-xl text-gray-600" selectClass="w-[180px] rounded-lg bg-white" - selectArray={[ALL, ...untOptions]} + selectArray={withAllOption(untOptions)} value={search.untBzCd} onSelectValue={(v) => setSearch((s) => ({ @@ -420,7 +425,7 @@ export default function CfmtnBgtPopupContent({ className="" labelClass="w-20 whitespace-nowrap text-xl text-gray-600" selectClass="w-[180px] rounded-lg bg-white" - selectArray={[ALL, ...mevOptions]} + selectArray={withAllOption(mevOptions)} value={search.mevBzNm} onSelectValue={(v) => setSearch((s) => ({ @@ -437,7 +442,7 @@ export default function CfmtnBgtPopupContent({ className="" labelClass="w-20 whitespace-nowrap text-xl text-gray-600" selectClass="w-[200px] rounded-lg bg-white" - selectArray={[ALL, ...dtlOptions]} + selectArray={withAllOption(dtlOptions)} value={search.dtlBzCd} onSelectValue={(v) => setSearch((s) => ({ ...s, dtlBzCd: String(v) })) diff --git a/apps/sn/src/features/obsltPpl/ui/ObsltPplFormPage.tsx b/apps/sn/src/features/obsltPpl/ui/ObsltPplFormPage.tsx index 32d3314a0..253ae2831 100644 --- a/apps/sn/src/features/obsltPpl/ui/ObsltPplFormPage.tsx +++ b/apps/sn/src/features/obsltPpl/ui/ObsltPplFormPage.tsx @@ -6,7 +6,15 @@ import { useRouter, useSearchParams } from "next/navigation"; import moment from "moment"; import { SelectBox } from "@common/business_components/ui"; -import { Button, Input, Table, TableBody, TableCell, TableHead, TableRow } from "@common/components/ui"; +import { + Button, + Input, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "@common/components/ui"; import { formatDate } from "../../common/util"; import { @@ -23,35 +31,62 @@ import { normalizeObsltPplSigunName, OBSLT_PPL_ENV_OFFICES, } from "../lib/obslt-ppl-region"; -import type { ObsltPplBusiness, ObsltPplBusinessSaveRequest } from "../model/obslt-ppl-types"; +import type { + ObsltPplBusiness, + ObsltPplBusinessSaveRequest, +} from "../model/obslt-ppl-types"; import LoadingOverlay from "@/components/ui/LoadingBar"; import useStore from "@/store/useStore"; type FormKey = keyof ObsltPplBusinessSaveRequest; type FormState = Record; +type FieldErrorKey = FormKey | "region"; const REGION_FORM_KEYS: FormKey[] = ["sggCd", "ctpvNm", "sigunNm"]; /** 등록·수정 표에서 필드 순서, 라벨, 입력 종류와 필수 여부를 한 곳에서 관리한다. */ -const FIELDS: Array<{ key: FormKey; label: string; type?: "number" | "textarea"; required?: boolean; placeholder?: string }> = [ +const FIELDS: Array<{ + key: FormKey; + label: string; + type?: "number" | "textarea"; + required?: boolean; + placeholder?: string; +}> = [ { 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" }, - { key: "cmcnYr", label: "준공(예정)년도", placeholder: "YYYY" }, { key: "planPrlg", label: "계획연장(km)", type: "number" }, - { key: "frhfyrExpcPrlg", label: "상반기(예상)연장(km)", type: "number" }, { key: "rmndPrlg", label: "잔여연장(km)", type: "number" }, - { key: "evdncDcmntSbmsnStts", label: "증빙서류 제출여부" }, { key: "bgngYmd", label: "착수(착공) 예정일자", placeholder: "YYYY-MM-DD" }, - { key: "cmcnYmd", label: "준공(예정)일자", placeholder: "YYYY-MM-DD" }, { key: "bizPrplDlyRsnCn", label: "사업추진 부진사유", type: "textarea" }, - { key: "bizPicNm", label: "담당자 이름" }, { key: "bizPicCnpl", label: "담당자 연락처" }, - { key: "rmrkCn", label: "비고", type: "textarea" }, { key: "pbcpOpnnCn", label: "공단의견", type: "textarea" }, + { key: "prcisExmnCycl", label: "정밀조사차수" }, + { key: "bizCd", label: "사업코드", required: true }, + { 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: "상반기(예상)연장(km)", type: "number" }, + { key: "rmndPrlg", label: "잔여연장(km)", type: "number" }, + { key: "evdncDcmntSbmsnStts", label: "증빙서류 제출여부" }, + { key: "bgngYmd", label: "착수(착공) 예정일자", placeholder: "YYYY-MM-DD" }, + { key: "cmcnYmd", label: "준공(예정)일자", placeholder: "YYYY-MM-DD" }, + { 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(["planPrlg", "frhfyrExpcPrlg", "rmndPrlg"]); +const NUMBER_KEYS = new Set([ + "planPrlg", + "frhfyrExpcPrlg", + "rmndPrlg", +]); const DATE_KEYS = new Set(["bgngYmd", "cmcnYmd"]); -/** 숫자만 받아 입력 길이에 맞춰 YYYY-MM-DD 형식의 하이픈을 자동으로 표시한다. */ +/** 날짜 입력을 YYYY-MM-DD 형식으로 보정하고, 올바른 위치의 직접 입력 하이픈은 유지한다. */ const formatDateInput = (value: string) => { + const normalized = value.replace(/[^\d-]/g, "").slice(0, 10); const digits = value.replace(/\D/g, "").slice(0, 8); + if (normalized.endsWith("-") && digits.length === 4) return `${digits}-`; + if (normalized.endsWith("-") && digits.length === 6) + return `${digits.slice(0, 4)}-${digits.slice(4)}-`; if (digits.length <= 4) return digits; if (digits.length <= 6) return `${digits.slice(0, 4)}-${digits.slice(4)}`; return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6)}`; @@ -59,16 +94,21 @@ const formatDateInput = (value: string) => { /** 선택 입력인 일자가 비어 있거나 실제 달력에 존재하는 YYYY-MM-DD인지 확인한다. */ const isValidDateInput = (value: string) => - !value.trim() || (/^\d{4}-\d{2}-\d{2}$/.test(value) && moment(value, "YYYY-MM-DD", true).isValid()); + !value.trim() || + (/^\d{4}-\d{2}-\d{2}$/.test(value) && + moment(value, "YYYY-MM-DD", true).isValid()); /** API의 기존 날짜가 날짜형이면 화면 형식으로 바꾸고, 비정상 원문은 숨기지 않고 검증 대상으로 남긴다. */ -const formatStoredDate = (value: string) => - formatDate(value) || value; +const formatStoredDate = (value: string) => formatDate(value) || value; /** 모든 입력을 제어 컴포넌트로 유지하기 위한 빈 폼 값을 생성한다. */ -const createEmptyForm = (): FormState => Object.fromEntries( - [...FIELDS.map(({ key }) => key), ...REGION_FORM_KEYS].map((key) => [key, key === "bizSe" ? "정밀조사" : ""]), -) as FormState; +const createEmptyForm = (): FormState => + Object.fromEntries( + [...FIELDS.map(({ key }) => key), ...REGION_FORM_KEYS].map((key) => [ + key, + key === "bizSe" ? "정밀조사" : "", + ]), + ) as FormState; /** API 상세값을 빈 문자열 기반 화면 폼 값으로 변환한다. */ const toForm = (business: ObsltPplBusiness): FormState => { @@ -84,14 +124,17 @@ const toForm = (business: ObsltPplBusiness): FormState => { }; /** 숫자 입력의 빈 문자열은 null로, 나머지는 trim 문자열로 저장 요청을 만든다. */ -const toRequest = (form: FormState): ObsltPplBusinessSaveRequest => Object.fromEntries( - [...FIELDS.map(({ key }) => key), ...REGION_FORM_KEYS].map((key) => { - const value = form[key].trim(); - if (NUMBER_KEYS.has(key)) return [key, value === "" ? null : Number(value)]; - if (DATE_KEYS.has(key)) return [key, value === "" ? null : value.replace(/\D/g, "")]; - return [key, value === "" ? null : value]; - }), -) as unknown as ObsltPplBusinessSaveRequest; +const toRequest = (form: FormState): ObsltPplBusinessSaveRequest => + Object.fromEntries( + [...FIELDS.map(({ key }) => key), ...REGION_FORM_KEYS].map((key) => { + const value = form[key].trim(); + if (NUMBER_KEYS.has(key)) + return [key, value === "" ? null : Number(value)]; + if (DATE_KEYS.has(key)) + return [key, value === "" ? null : value.replace(/\D/g, "")]; + return [key, value === "" ? null : value]; + }), + ) as unknown as ObsltPplBusinessSaveRequest; /** 배너 등록 화면의 표형 폼 패턴을 적용한 별도 등록·수정 화면이다. */ export function ObsltPplFormPage({ id }: { id?: string }) { @@ -102,14 +145,23 @@ export function ObsltPplFormPage({ id }: { id?: string }) { const [form, setForm] = useState(createEmptyForm); const [loading, setLoading] = useState(isModify); const [notice, setNotice] = useState(""); + const [fieldErrors, setFieldErrors] = useState< + Partial> + >({}); const [ctpvCd, setCtpvCd] = useState("0"); const [sggCd, setSggCd] = useState("0"); const listQueryString = searchParams.toString(); const listHref = `/oper/obsltPplBiz${listQueryString ? `?${listQueryString}` : ""}`; - const ctpvOptions = useMemo(() => [{ text: "전체", val: "0", hidden: true }, ...ctpv], [ctpv]); + const ctpvOptions = useMemo( + () => [{ text: "전체", val: "0", hidden: true }, ...ctpv], + [ctpv], + ); const representativeSggOptions = useMemo( - () => [{ text: "전체", val: "0", hidden: true }, ...buildObsltPplRepresentativeSggOptions(sgg, ctpvCd)], + () => [ + { text: "전체", val: "0", hidden: true }, + ...buildObsltPplRepresentativeSggOptions(sgg, ctpvCd), + ], [ctpvCd, sgg], ); @@ -123,7 +175,12 @@ export function ObsltPplFormPage({ id }: { id?: string }) { const resolvedCtpvCd = findObsltPplCtpvCodeByName(ctpv, form.ctpvNm); const nextCtpvCd = resolvedCtpvCd !== "0" ? resolvedCtpvCd : ctpvCd; - const nextSggCd = findObsltPplRepresentativeSggCode(sgg, nextCtpvCd, form.sigunNm, form.sggCd); + const nextSggCd = findObsltPplRepresentativeSggCode( + sgg, + nextCtpvCd, + form.sigunNm, + form.sggCd, + ); if (nextCtpvCd !== ctpvCd) setCtpvCd(nextCtpvCd); if (nextSggCd !== sggCd) setSggCd(nextSggCd); @@ -132,53 +189,103 @@ export function ObsltPplFormPage({ id }: { id?: string }) { useEffect(() => { if (!id) return; setLoading(true); - getObsltPplBusiness(id).then((data) => setForm(toForm(data))) - .catch((error) => setNotice(error instanceof Error ? error.message : "상세정보를 조회하지 못했습니다.")) + getObsltPplBusiness(id) + .then((data) => setForm(toForm(data))) + .catch((error) => + setNotice( + error instanceof Error + ? error.message + : "상세정보를 조회하지 못했습니다.", + ), + ) .finally(() => setLoading(false)); }, [id]); + const clearFieldError = (key: FieldErrorKey) => { + setFieldErrors((current) => { + if (!current[key]) return current; + const next = { ...current }; + delete next[key]; + return next; + }); + }; + /** 필수값과 숫자 형식을 화면에서 먼저 확인한 뒤 등록 또는 수정 API를 호출한다. */ 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 ([...DATE_KEYS].some((key) => !isValidDateInput(form[key]))) { - setNotice("일자는 YYYY-MM-DD 형식의 유효한 날짜로 입력해주세요."); - return; - } + const nextErrors: Partial> = {}; + if (!form.bizSe) nextErrors.bizSe = "사업구분은 필수입니다."; + if (!form.bizCd.trim()) nextErrors.bizCd = "사업코드는 필수입니다."; + if (!form.bizNm.trim()) nextErrors.bizNm = "사업명은 필수입니다."; + if (!form.ctpvNm.trim() || ctpvCd === "0") + nextErrors.region = "시도는 필수입니다."; + [...NUMBER_KEYS].forEach((key) => { + if (form[key] && !Number.isFinite(Number(form[key]))) { + nextErrors[key] = "숫자로 입력해주세요."; + } + }); + [...DATE_KEYS].forEach((key) => { + if (!isValidDateInput(form[key])) { + nextErrors[key] = "YYYY-MM-DD 형식의 유효한 날짜로 입력해주세요."; + } + }); if (!isValidObsltPplEnvOfficeName(form.envoNm)) { - setNotice(`환경청은 ${OBSLT_PPL_ENV_OFFICES.map((office) => office.name).join(", ")} 중 하나여야 합니다.`); + nextErrors.envoNm = `${OBSLT_PPL_ENV_OFFICES.map((office) => office.name).join(", ")} 중 하나여야 합니다.`; + } + if (Object.keys(nextErrors).length > 0) { + setFieldErrors(nextErrors); + setNotice(""); return; } + setFieldErrors({}); setLoading(true); try { if (id) await updateObsltPplBusiness(id, toRequest(form)); else await createObsltPplBusiness(toRequest(form)); window.alert(isModify ? "수정되었습니다." : "등록되었습니다."); router.push(listHref); - } catch (error) { setNotice(error instanceof Error ? error.message : "저장하지 못했습니다."); } - finally { setLoading(false); } + } 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(listHref); } - catch (error) { setNotice(error instanceof Error ? error.message : "삭제하지 못했습니다."); } - finally { setLoading(false); } + try { + await deleteObsltPplBusiness(id); + window.alert("삭제되었습니다."); + router.push(listHref); + } catch (error) { + setNotice( + error instanceof Error ? error.message : "삭제하지 못했습니다.", + ); + } finally { + setLoading(false); + } }; return (
-

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

+
+

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

+
- + + + + - 시군구 선택 + 시도,시군구 선택
{ const nextCtpvCd = String(value); + clearFieldError("region"); setCtpvCd(nextCtpvCd); setSggCd("0"); setForm((current) => ({ ...current, - ctpvNm: ctpv.find((option) => String(option.val) === nextCtpvCd)?.text ?? "", + ctpvNm: + ctpv.find( + (option) => String(option.val) === nextCtpvCd, + )?.text ?? "", sigunNm: "", sggCd: "", })); @@ -209,7 +320,10 @@ export function ObsltPplFormPage({ id }: { id?: string }) { value={sggCd} onSelectValue={(value) => { const nextSggCd = String(value); - const selectedOption = representativeSggOptions.find((option) => String(option.val) === nextSggCd); + const selectedOption = representativeSggOptions.find( + (option) => String(option.val) === nextSggCd, + ); + clearFieldError("region"); setSggCd(nextSggCd); setForm((current) => ({ ...current, @@ -219,16 +333,41 @@ export function ObsltPplFormPage({ id }: { id?: string }) { }} />
+ {fieldErrors.region && ( +

+ {fieldErrors.region} +

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