Files
frontend/apps/sn/src/features/obsltPpl/ui/ObsltPplFormPage.tsx
T
2026-09-02 18:10:50 +09:00

564 lines
19 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import moment from "moment";
import {
StandardFormField,
StandardFormSection,
} from "@common/business_components";
import {
Button,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
} from "@common/components";
import { formatDate } from "@/shared/util/util";
import {
createObsltPplBusiness,
deleteObsltPplBusiness,
getObsltPplBusiness,
updateObsltPplBusiness,
} from "../api/obslt-ppl-api";
import {
buildObsltPplRepresentativeSggOptions,
findObsltPplCtpvCodeByName,
findObsltPplRepresentativeSggCode,
isValidObsltPplEnvOfficeName,
normalizeObsltPplSigunName,
OBSLT_PPL_ENV_OFFICES,
} from "../lib/obslt-ppl-region";
import type {
ObsltPplBusiness,
ObsltPplBusinessSaveRequest,
} from "../model/obslt-ppl-types";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { useAlertStore } from "@/shared/lib";
import { useOperBusinessCommonCodes } from "@/shared/lib/use-oper-business-common-codes";
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 EMPTY_SELECT_VALUE = "__none__";
type FieldSpan = 1 | 2 | 3;
/** 등록·수정 표에서 필드 순서, 라벨, 입력 종류와 필수 여부를 한 곳에서 관리한다. */
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" },
];
const NUMBER_KEYS = new Set<FormKey>([
"planPrlg",
"frhfyrExpcPrlg",
"rmndPrlg",
]);
const DATE_KEYS = new Set<FormKey>(["bgngYmd", "cmcnYmd"]);
/** 날짜 입력을 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)}`;
};
/** 선택 입력인 일자가 비어 있거나 실제 달력에 존재하는 YYYY-MM-DD인지 확인한다. */
const isValidDateInput = (value: string) =>
!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 createEmptyForm = (): FormState =>
Object.fromEntries(
[...FIELDS.map(({ key }) => key), ...REGION_FORM_KEYS].map((key) => [
key,
key === "bizSe" ? "정밀조사" : "",
]),
) as FormState;
/** API 상세값을 빈 문자열 기반 화면 폼 값으로 변환한다. */
const toForm = (business: ObsltPplBusiness): FormState => {
const form = createEmptyForm();
FIELDS.forEach(({ key }) => {
const value = String(business[key] ?? "");
form[key] = DATE_KEYS.has(key) ? formatStoredDate(value) : value;
});
form.sggCd = String(business.sggCd ?? "");
form.ctpvNm = String(business.ctpvNm ?? "");
form.sigunNm = normalizeObsltPplSigunName(business.sigunNm);
return form;
};
/** 숫자 입력의 빈 문자열은 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;
/** 배너 등록 화면의 표형 폼 패턴을 적용한 별도 등록·수정 화면이다. */
export function ObsltPplFormPage({ id }: { id?: string }) {
const router = useRouter();
const searchParams = useSearchParams();
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
// 기존 한글 저장값은 유지하면서 사업구분·추진·증빙 선택 후보를 CMS에서 구성한다.
const {
obsltBusinessTypeOptions,
evidenceStatusOptions,
businessProgressOptions,
} = useOperBusinessCommonCodes();
const isModify = Boolean(id);
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
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 representativeSggOptions = useMemo(
() => buildObsltPplRepresentativeSggOptions(sgg, ctpvCd),
[ctpvCd, sgg],
);
useEffect(() => {
if (ctpv.length === 0) void fetchCtpv();
if (sgg.length === 0) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
useEffect(() => {
if (!ctpv.length || !sgg.length) return;
const resolvedCtpvCd = findObsltPplCtpvCodeByName(ctpv, form.ctpvNm);
const nextCtpvCd = resolvedCtpvCd !== "0" ? resolvedCtpvCd : ctpvCd;
const nextSggCd = findObsltPplRepresentativeSggCode(
sgg,
nextCtpvCd,
form.sigunNm,
form.sggCd,
);
if (nextCtpvCd !== ctpvCd) setCtpvCd(nextCtpvCd);
if (nextSggCd !== sggCd) setSggCd(nextSggCd);
}, [ctpv, ctpvCd, form.ctpvNm, form.sggCd, form.sigunNm, sgg, sggCd]);
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]);
const clearFieldError = (key: FieldErrorKey) => {
setFieldErrors((current) => {
if (!current[key]) return current;
const next = { ...current };
delete next[key];
return next;
});
};
/** 필수값과 숫자 형식을 화면에서 먼저 확인한 뒤 등록 또는 수정 API를 호출한다. */
const handleSave = async () => {
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)) {
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));
alert(isModify ? "수정되었습니다." : "등록되었습니다.", () =>
router.push(listHref),
);
} catch (error) {
setNotice(
error instanceof Error ? error.message : "저장하지 못했습니다.",
);
} finally {
setLoading(false);
}
};
/** 사용자 확인 후 현재 사업을 논리삭제하고 목록으로 이동한다. */
const handleDelete = async () => {
if (!id || !(await confirm("삭제하시겠습니까?"))) return;
setLoading(true);
try {
await deleteObsltPplBusiness(id);
alert("삭제되었습니다.", () => router.push(listHref));
} catch (error) {
setNotice(
error instanceof Error ? error.message : "삭제하지 못했습니다.",
);
} finally {
setLoading(false);
}
};
const renderField = (key: FormKey, span: FieldSpan = 1) => {
const field = FIELDS.find((candidate) => candidate.key === key);
if (!field) return null;
const fieldId = `obslt-ppl-${field.key}`;
let control: ReactNode;
if (field.key === "bizSe") {
control = (
<Select
value={form.bizSe}
onValueChange={(value) => {
clearFieldError("bizSe");
setForm((current) => ({ ...current, bizSe: String(value) }));
}}
>
<SelectTrigger id={fieldId} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{obsltBusinessTypeOptions.map((option) => (
<SelectItem key={option.code} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
} else if (field.key === "envoNm") {
control = (
<Select
value={form.envoNm || EMPTY_SELECT_VALUE}
onValueChange={(value) => {
clearFieldError("envoNm");
setForm((current) => ({
...current,
envoNm: value === EMPTY_SELECT_VALUE ? "" : String(value),
}));
}}
>
<SelectTrigger id={fieldId} className="w-full">
<SelectValue placeholder="환경청 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value={EMPTY_SELECT_VALUE}>선택</SelectItem>
{OBSLT_PPL_ENV_OFFICES.map((office) => (
<SelectItem key={office.name} value={office.name}>
{office.name}
</SelectItem>
))}
</SelectContent>
</Select>
);
} else if (field.type === "textarea") {
control = (
<Textarea
id={fieldId}
className="min-h-[7.2rem] resize-y !text-[1.3rem]"
value={form[field.key]}
placeholder={field.placeholder}
onChange={(event) => {
clearFieldError(field.key);
setForm((current) => ({
...current,
[field.key]: event.target.value,
}));
}}
/>
);
} else {
const suggestionOptions =
field.key === "prplPrst"
? businessProgressOptions
: field.key === "evdncDcmntSbmsnStts"
? evidenceStatusOptions
: [];
const suggestionListId =
suggestionOptions.length > 0 ? `${fieldId}-suggestions` : undefined;
control = (
<>
<Input
id={fieldId}
className="w-full !text-[1.3rem]"
type={field.type === "number" ? "number" : "text"}
step={field.type === "number" ? "any" : undefined}
maxLength={DATE_KEYS.has(field.key) ? 10 : undefined}
list={suggestionListId}
value={form[field.key]}
placeholder={field.placeholder}
onChange={(event) => {
clearFieldError(field.key);
setForm((current) => ({
...current,
[field.key]: DATE_KEYS.has(field.key)
? formatDateInput(event.target.value)
: event.target.value,
}));
}}
/>
{suggestionListId ? (
<datalist id={suggestionListId}>
{suggestionOptions.map((option) => (
<option key={option.code} value={option.value}>
{option.label}
</option>
))}
</datalist>
) : null}
</>
);
}
return (
<StandardFormField
key={field.key}
id={fieldId}
label={field.label}
required={field.required}
error={fieldErrors[field.key]}
span={span}
>
{control}
</StandardFormField>
);
};
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 space-y-4">
{notice && (
<p
className="rounded-sm border border-red-200 bg-red-50 px-4 py-3 text-[1.3rem] text-red-700"
role="alert"
>
{notice}
</p>
)}
<StandardFormSection title="기본 정보">
{renderField("bizSe")}
{renderField("prcisExmnCycl")}
{renderField("bizCd")}
{renderField("bizNm", 2)}
{renderField("prplPrst")}
</StandardFormSection>
<StandardFormSection title="지역 및 일정">
<StandardFormField
id="obslt-ppl-ctpv"
label="시도"
required
error={fieldErrors.region}
>
<Select
value={ctpvCd}
onValueChange={(value) => {
const nextCtpvCd = String(value);
clearFieldError("region");
setCtpvCd(nextCtpvCd);
setSggCd("0");
setForm((current) => ({
...current,
ctpvNm:
ctpv.find((option) => String(option.val) === nextCtpvCd)
?.text ?? "",
sigunNm: "",
sggCd: "",
}));
}}
>
<SelectTrigger id="obslt-ppl-ctpv" className="w-full">
<SelectValue placeholder="시도 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0" disabled>
선택
</SelectItem>
{ctpv.map((option) => (
<SelectItem
key={String(option.val)}
value={String(option.val)}
>
{option.text}
</SelectItem>
))}
</SelectContent>
</Select>
</StandardFormField>
<StandardFormField id="obslt-ppl-sgg" label="시군구">
<Select
value={sggCd}
onValueChange={(value) => {
const nextSggCd = String(value);
const selectedOption = representativeSggOptions.find(
(option) => String(option.val) === nextSggCd,
);
clearFieldError("region");
setSggCd(nextSggCd);
setForm((current) => ({
...current,
sigunNm: selectedOption?.text ?? "",
sggCd: nextSggCd === "0" ? "" : nextSggCd,
}));
}}
>
<SelectTrigger id="obslt-ppl-sgg" className="w-full">
<SelectValue placeholder="시군구 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">선택</SelectItem>
{representativeSggOptions.map((option) => (
<SelectItem
key={String(option.val)}
value={String(option.val)}
>
{option.text}
</SelectItem>
))}
</SelectContent>
</Select>
</StandardFormField>
{renderField("envoNm")}
{renderField("bgngYr")}
{renderField("cmcnYr")}
{renderField("evdncDcmntSbmsnStts")}
{renderField("bgngYmd")}
{renderField("cmcnYmd", 2)}
</StandardFormSection>
<StandardFormSection title="사업량">
{renderField("planPrlg")}
{renderField("frhfyrExpcPrlg")}
{renderField("rmndPrlg")}
</StandardFormSection>
<StandardFormSection title="담당자 및 의견">
{renderField("bizPicNm")}
{renderField("bizPicCnpl", 2)}
{renderField("bizPrplDlyRsnCn", 3)}
{renderField("rmrkCn", 3)}
{renderField("pbcpOpnnCn", 3)}
</StandardFormSection>
<div className="flex justify-center gap-4 pb-8 pt-6">
<Button
type="button"
size="default"
onClick={() => void handleSave()}
>
저장
</Button>
<Button
type="button"
size="default"
color="white"
onClick={() => router.push(listHref)}
>
취소
</Button>
{isModify && (
<Button
type="button"
size="default"
color="red"
onClick={() => void handleDelete()}
>
삭제
</Button>
)}
</div>
</div>
</div>
);
}