From 1858739bb9bff1c263974e8768f7bbd663bb1fa1 Mon Sep 17 00:00:00 2001 From: ljpark Date: Thu, 6 Aug 2026 10:46:47 +0900 Subject: [PATCH] =?UTF-8?q?sn:=20=EB=85=B8=ED=9B=84=ED=95=98=EC=88=98?= =?UTF-8?q?=EA=B4=80=EB=A1=9C=EC=A0=95=EB=B9=84=EC=82=AC=EC=97=85=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=A1=B0=EA=B1=B4=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sn/src/features/common/util.ts | 17 ++++ .../features/obsltPpl/lib/obslt-ppl-excel.ts | 5 +- .../features/obsltPpl/ui/ObsltPplFormPage.tsx | 61 +++++++++++-- .../features/obsltPpl/ui/ObsltPplListPage.tsx | 86 +++++++++++++------ 4 files changed, 130 insertions(+), 39 deletions(-) create mode 100644 apps/sn/src/features/common/util.ts diff --git a/apps/sn/src/features/common/util.ts b/apps/sn/src/features/common/util.ts new file mode 100644 index 000000000..7686cafc1 --- /dev/null +++ b/apps/sn/src/features/common/util.ts @@ -0,0 +1,17 @@ +import moment from "moment"; + +/** 8자리 또는 구분자가 섞인 날짜 문자열을 YYYY-MM-DD 형식으로 정규화한다. */ +export const formatDate = (value?: string | null) => { + if (!value) return ""; + const normalized = String(value).trim(); + if (!normalized) return ""; + + const digits = normalized.replace(/\D/g, ""); + if (digits.length === 8) { + const parsedDate = moment(digits, "YYYYMMDD", true); + return parsedDate.isValid() ? parsedDate.format("YYYY-MM-DD") : normalized; + } + + const parsedDate = moment(normalized); + return parsedDate.isValid() ? parsedDate.format("YYYY-MM-DD") : normalized; +}; diff --git a/apps/sn/src/features/obsltPpl/lib/obslt-ppl-excel.ts b/apps/sn/src/features/obsltPpl/lib/obslt-ppl-excel.ts index 049a94017..c4e7da426 100644 --- a/apps/sn/src/features/obsltPpl/lib/obslt-ppl-excel.ts +++ b/apps/sn/src/features/obsltPpl/lib/obslt-ppl-excel.ts @@ -1,5 +1,6 @@ import * as XLSX from "xlsx"; +import { formatDate } from "../../common/util"; import type { ObsltPplBusiness, ObsltPplBusinessSaveRequest } from "../model/obslt-ppl-types"; type BusinessType = ObsltPplBusinessSaveRequest["bizSe"]; @@ -209,8 +210,8 @@ const downloadDataRow = (row: ObsltPplBusiness, index: number): Array(["planPrlg", "frhfyrExpcPrlg", "rmndPrlg"]); +const DATE_KEYS = new Set(["bgngYmd", "cmcnYmd"]); + +/** 숫자만 받아 입력 길이에 맞춰 YYYY-MM-DD 형식의 하이픈을 자동으로 표시한다. */ +const formatDateInput = (value: string) => { + const digits = value.replace(/\D/g, "").slice(0, 8); + 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( @@ -53,7 +73,10 @@ const createEmptyForm = (): FormState => Object.fromEntries( /** API 상세값을 빈 문자열 기반 화면 폼 값으로 변환한다. */ const toForm = (business: ObsltPplBusiness): FormState => { const form = createEmptyForm(); - FIELDS.forEach(({ key }) => { form[key] = String(business[key] ?? ""); }); + 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); @@ -64,13 +87,16 @@ const toForm = (business: ObsltPplBusiness): FormState => { const toRequest = (form: FormState): ObsltPplBusinessSaveRequest => Object.fromEntries( [...FIELDS.map(({ key }) => key), ...REGION_FORM_KEYS].map((key) => { const value = form[key].trim(); - return [key, NUMBER_KEYS.has(key) ? (value === "" ? null : Number(value)) : (value === "" ? null : value)]; + 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 isModify = Boolean(id); const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); const [form, setForm] = useState(createEmptyForm); @@ -78,6 +104,8 @@ export function ObsltPplFormPage({ id }: { id?: string }) { const [notice, setNotice] = useState(""); 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 representativeSggOptions = useMemo( @@ -114,6 +142,10 @@ export function ObsltPplFormPage({ id }: { id?: string }) { 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; + } if (!isValidObsltPplEnvOfficeName(form.envoNm)) { setNotice(`환경청은 ${OBSLT_PPL_ENV_OFFICES.map((office) => office.name).join(", ")} 중 하나여야 합니다.`); return; @@ -123,7 +155,7 @@ export function ObsltPplFormPage({ id }: { id?: string }) { if (id) await updateObsltPplBusiness(id, toRequest(form)); else await createObsltPplBusiness(toRequest(form)); window.alert(isModify ? "수정되었습니다." : "등록되었습니다."); - router.push("/oper/obsltPplBiz"); + router.push(listHref); } catch (error) { setNotice(error instanceof Error ? error.message : "저장하지 못했습니다."); } finally { setLoading(false); } }; @@ -132,7 +164,7 @@ export function ObsltPplFormPage({ id }: { id?: string }) { const handleDelete = async () => { if (!id || !window.confirm("삭제하시겠습니까?")) return; setLoading(true); - try { await deleteObsltPplBusiness(id); window.alert("삭제되었습니다."); router.push("/oper/obsltPplBiz"); } + try { await deleteObsltPplBusiness(id); window.alert("삭제되었습니다."); router.push(listHref); } catch (error) { setNotice(error instanceof Error ? error.message : "삭제하지 못했습니다."); } finally { setLoading(false); } }; @@ -198,7 +230,18 @@ export function ObsltPplFormPage({ id }: { id?: string }) { ) : field.type === "textarea" ? (