564 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|