sn: 노후하수관로정비사업 시군구 필수 아님

This commit is contained in:
2026-08-07 18:22:08 +09:00
parent 4109691eea
commit d07bb6074d
6 changed files with 277 additions and 84 deletions
+7 -7
View File
@@ -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>