sn: 노후하수관로정비사업 시군구 필수 아님
This commit is contained in:
@@ -54,13 +54,13 @@ const toSelectOption = (item: Record<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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(),
|
||||
);
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
4. 엑셀다운로드 후 두 시트 및 재업로드 확인
|
||||
5. 등록 저장, 행 클릭 수정, 수정 화면 삭제 확인
|
||||
6. 목록에 삭제 버튼이 없고 수정 하단 버튼 순서가 `목록`, `저장`, `삭제`인지 확인
|
||||
7. 등록·수정 화면에서 `시도,시군구 선택`에 필수 표시가 노출되고, 시도 미선택 저장 시 `시도는 필수입니다.`가 표시되는지 확인
|
||||
8. 시도만 선택하고 시군구를 선택하지 않아도 프론트 검증을 통과해 저장 요청이 전송되는지 확인
|
||||
|
||||
## 선행조건
|
||||
|
||||
|
||||
@@ -42,3 +42,9 @@
|
||||
- `bizSe`에 따라 `정밀조사`, `정비사업` 두 시트로 분리하고 업로드와 같은 B6:V7 헤더, B8:V 데이터 위치로 다운로드한다.
|
||||
- B열 순번은 DB 값이 아니라 각 시트의 출력 순서로 생성하며 스타일·색상 복제는 적용하지 않았다.
|
||||
- 기존 BE Excel 다운로드 API는 삭제하지 않았다.
|
||||
|
||||
## 2026-08-07 등록·수정 화면 지역 필수 표시
|
||||
|
||||
- 지역 입력 행 제목을 `시군구 선택`에서 `시도,시군구 선택`으로 변경하고 필수 표시를 추가했다.
|
||||
- 저장 전 시도 선택 여부를 검증하며, 미선택 시 `시도는 필수입니다.`를 표시한다.
|
||||
- 시군구는 선택사항으로 유지하여 시도만 선택한 자료도 저장 요청할 수 있다.
|
||||
|
||||
@@ -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`);
|
||||
};
|
||||
|
||||
@@ -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) }))
|
||||
|
||||
@@ -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<FormKey, string>;
|
||||
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<FormKey>(["planPrlg", "frhfyrExpcPrlg", "rmndPrlg"]);
|
||||
const NUMBER_KEYS = new Set<FormKey>([
|
||||
"planPrlg",
|
||||
"frhfyrExpcPrlg",
|
||||
"rmndPrlg",
|
||||
]);
|
||||
const DATE_KEYS = new Set<FormKey>(["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<FormState>(createEmptyForm);
|
||||
const [loading, setLoading] = useState(isModify);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [fieldErrors, setFieldErrors] = useState<
|
||||
Partial<Record<FieldErrorKey, string>>
|
||||
>({});
|
||||
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<Record<FieldErrorKey, string>> = {};
|
||||
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 (
|
||||
<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-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>
|
||||
<colgroup>
|
||||
<col width="18%" />
|
||||
<col width="82%" />
|
||||
</colgroup>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableHead data-required>시군구 선택</TableHead>
|
||||
<TableHead data-required>시도,시군구 선택</TableHead>
|
||||
<TableCell className="border">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<SelectBox
|
||||
@@ -190,11 +297,15 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
|
||||
value={ctpvCd}
|
||||
onSelectValue={(value) => {
|
||||
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 }) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{fieldErrors.region && (
|
||||
<p className="mt-1 text-[1.2rem] text-red-600" role="alert">
|
||||
{fieldErrors.region}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{FIELDS.map((field) => (
|
||||
<TableRow key={field.key}>
|
||||
<TableHead data-required={field.required || undefined}>{field.label}</TableHead>
|
||||
<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>
|
||||
<select
|
||||
className="h-11 w-full rounded border bg-white px-3 text-[1.3rem]"
|
||||
value={form.bizSe}
|
||||
onChange={(e) => {
|
||||
clearFieldError("bizSe");
|
||||
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 })} />
|
||||
<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) => {
|
||||
clearFieldError(field.key);
|
||||
setForm({ ...form, [field.key]: e.target.value });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
className="!text-[1.3rem]"
|
||||
@@ -237,22 +376,52 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
|
||||
maxLength={DATE_KEYS.has(field.key) ? 10 : undefined}
|
||||
value={form[field.key]}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(e) => setForm({
|
||||
...form,
|
||||
[field.key]: DATE_KEYS.has(field.key) ? formatDateInput(e.target.value) : e.target.value,
|
||||
})}
|
||||
onChange={(e) => {
|
||||
clearFieldError(field.key);
|
||||
setForm({
|
||||
...form,
|
||||
[field.key]: DATE_KEYS.has(field.key)
|
||||
? formatDateInput(e.target.value)
|
||||
: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{fieldErrors[field.key] && (
|
||||
<p className="mt-1 text-[1.2rem] text-red-600" role="alert">
|
||||
{fieldErrors[field.key]}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{notice && <p className="mt-3 text-sm text-red-600" role="alert">{notice}</p>}
|
||||
{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(listHref)}>목록</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>}
|
||||
<Button size="sm" onClick={() => router.push(listHref)}>
|
||||
목록
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user