sn: 노후하수관로정비사업 조회조건 유지

This commit is contained in:
2026-08-06 10:46:47 +09:00
parent d3bdc58994
commit 1858739bb9
4 changed files with 130 additions and 39 deletions
+17
View File
@@ -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;
};
@@ -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<string | n
row.frhfyrExpcPrlg ?? "",
row.rmndPrlg ?? "",
row.evdncDcmntSbmsnStts ?? "",
row.bgngYmd ?? "",
row.cmcnYmd ?? "",
formatDate(row.bgngYmd),
formatDate(row.cmcnYmd),
row.bizPrplDlyRsnCn ?? "",
row.bizPicNm ?? "",
row.bizPicCnpl ?? "",
@@ -1,11 +1,14 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
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 { formatDate } from "../../common/util";
import {
createObsltPplBusiness,
deleteObsltPplBusiness,
@@ -37,13 +40,30 @@ const FIELDS: Array<{ key: FormKey; label: string; type?: "number" | "textarea";
{ 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: "YYYYMMDD" },
{ key: "cmcnYmd", label: "준공(예정)일자", placeholder: "YYYYMMDD" }, { key: "bizPrplDlyRsnCn", label: "사업추진 부진사유", type: "textarea" },
{ 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 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<FormState>(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" ? (
<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 })} />
) : (
<Input className="!text-[1.3rem]" type={field.type === "number" ? "number" : "text"} step={field.type === "number" ? "any" : undefined} value={form[field.key]} placeholder={field.placeholder} onChange={(e) => setForm({ ...form, [field.key]: e.target.value })} />
<Input
className="!text-[1.3rem]"
type={field.type === "number" ? "number" : "text"}
step={field.type === "number" ? "any" : undefined}
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,
})}
/>
)}
</TableCell>
</TableRow>
@@ -207,7 +250,7 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
</Table>
{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("/oper/obsltPplBiz")}>목록</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>
@@ -1,9 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import moment from "moment";
import { useRouter, useSearchParams } from "next/navigation";
import { SearchDiv, SearchFormLeft, SearchFormRight, SelectBox, UiTable } from "@common/business_components/ui";
import ListDiv from "@common/business_components/ui/list_div";
@@ -14,6 +12,7 @@ import {
getObsltPplBusinesses,
uploadObsltPplExcel,
} from "../api/obslt-ppl-api";
import { formatDate } from "../../common/util";
import { downloadObsltPplExcel, parseObsltPplExcel } from "../lib/obslt-ppl-excel";
import { buildObsltPplRepresentativeSggOptions, normalizeObsltPplSigunName } from "../lib/obslt-ppl-region";
import type { ObsltPplBusiness, ObsltPplPage } from "../model/obslt-ppl-types";
@@ -34,6 +33,24 @@ const ALL_REGION_OPTION = { text: "전체", val: "0" };
type ObsltPplTableRow = ObsltPplBusiness & {
rowNumber: number;
};
type ObsltPplSearch = { bizSe: string; ctpvNm: string; sigunNm: string; bizNm: string };
/** 조회조건·지역 선택 코드·페이지를 목록 복귀에 사용할 URL query로 만든다. */
const createListQueryString = (
page: number,
search: ObsltPplSearch,
ctpvCd: string,
sggCd: string,
) => {
const params = new URLSearchParams();
if (page > 1) params.set("page", String(page));
Object.entries(search).forEach(([key, value]) => {
if (value.trim()) params.set(key, value.trim());
});
if (ctpvCd !== "0") params.set("ctpvCd", ctpvCd);
if (sggCd !== "0") params.set("sggCd", sggCd);
return params.toString();
};
const TABLE_HEADLIST = [
{ id: "rowNumber", title: "NO", widthPercent: 4 },
@@ -71,44 +88,39 @@ const EMPTY_PAGE: ObsltPplPage = {
const DATE_FIELD_SET = new Set<keyof ObsltPplBusiness>(["bgngYmd", "cmcnYmd"]);
/** 8자리 또는 구분자가 섞인 날짜 문자열을 화면·다운로드 공통 형식으로 정규화한다. */
const formatObsltPplDate = (value?: string | null) => {
if (!value) return "";
const normalized = String(value).trim();
if (!normalized) return "";
const digits = normalized.replace(/[^0-9]/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;
};
/** 목록 표시값을 공통 포맷으로 정리한다. */
const formatBusinessForDisplay = (row: ObsltPplBusiness): ObsltPplBusiness => ({
...row,
sigunNm: normalizeObsltPplSigunName(row.sigunNm) || null,
bgngYmd: formatObsltPplDate(row.bgngYmd) || null,
cmcnYmd: formatObsltPplDate(row.cmcnYmd) || null,
bgngYmd: formatDate(row.bgngYmd) || null,
cmcnYmd: formatDate(row.cmcnYmd) || null,
});
/** 노후하수관로 사업 목록, 검색, xlsx 업로드·다운로드 진입점을 제공한다. */
export function ObsltPplListPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [page, setPage] = useState(1);
const initialSearch = useMemo<ObsltPplSearch>(() => ({
bizSe: searchParams.get("bizSe") ?? "",
ctpvNm: searchParams.get("ctpvNm") ?? "",
sigunNm: searchParams.get("sigunNm") ?? "",
bizNm: searchParams.get("bizNm") ?? "",
}), [searchParams]);
const [page, setPage] = useState(() => Math.max(Number(searchParams.get("page")) || 1, 1));
const [data, setData] = useState<ObsltPplPage>(EMPTY_PAGE);
const [rows, setRows] = useState<ObsltPplTableRow[]>([]);
const [loading, setLoading] = useState(false);
const [notice, setNotice] = useState("");
const [resetPageNo, setResetPageNo] = useState(false);
// 선택박스의 코드와 API 검색에 사용하는 행정구역명을 함께 관리한다.
const [ctpvCd, setCtpvCd] = useState("0");
const [sggCd, setSggCd] = useState("0");
const [search, setSearch] = useState({ bizSe: "", ctpvNm: "", sigunNm: "", bizNm: "" });
const [appliedSearch, setAppliedSearch] = useState(search);
const [ctpvCd, setCtpvCd] = useState(() => searchParams.get("ctpvCd") ?? "0");
const [sggCd, setSggCd] = useState(() => searchParams.get("sggCd") ?? "0");
const [appliedCtpvCd, setAppliedCtpvCd] = useState(() => searchParams.get("ctpvCd") ?? "0");
const [appliedSggCd, setAppliedSggCd] = useState(() => searchParams.get("sggCd") ?? "0");
const [search, setSearch] = useState<ObsltPplSearch>(initialSearch);
const [appliedSearch, setAppliedSearch] = useState<ObsltPplSearch>(initialSearch);
/** 현재 선택한 시도에 속한 시군구만 선택 후보로 제공한다. */
const availableSgg = useMemo(() => {
@@ -202,7 +214,11 @@ export function ObsltPplListPage() {
const nextSearch = { ...search };
setNotice("");
setAppliedSearch(nextSearch);
setAppliedCtpvCd(ctpvCd);
setAppliedSggCd(sggCd);
setResetPageNo(true);
const queryString = createListQueryString(1, nextSearch, ctpvCd, sggCd);
router.replace(`/oper/obsltPplBiz${queryString ? `?${queryString}` : ""}`, { scroll: false });
if (page === 1) {
await load(1, nextSearch);
return;
@@ -289,7 +305,14 @@ export function ObsltPplListPage() {
<Button className={ACTION_BUTTON_CLASS} size="sm" onClick={() => void handleSearch()}>
조회
</Button>
<Button className={ACTION_BUTTON_CLASS} size="sm" onClick={() => router.push("/oper/obsltPplBiz/register")}>
<Button
className={ACTION_BUTTON_CLASS}
size="sm"
onClick={() => {
const queryString = createListQueryString(page, appliedSearch, appliedCtpvCd, appliedSggCd);
router.push(`/oper/obsltPplBiz/register${queryString ? `?${queryString}` : ""}`);
}}
>
등록
</Button>
<input
@@ -335,15 +358,22 @@ export function ObsltPplListPage() {
total={data.totalElements}
curPage={page}
widthFixed
onPageChange={setPage}
onPageChange={(nextPage) => {
setPage(nextPage);
const queryString = createListQueryString(nextPage, appliedSearch, appliedCtpvCd, appliedSggCd);
router.replace(`/oper/obsltPplBiz${queryString ? `?${queryString}` : ""}`, { scroll: false });
}}
resetPageNo={resetPageNo}
cellClick={(index) => {
if (typeof index !== "number" || !rows[index]) return;
router.push(`/oper/obsltPplBiz/register/${rows[index].obsltSwagePplBizSn}`);
const queryString = createListQueryString(page, appliedSearch, appliedCtpvCd, appliedSggCd);
router.push(
`/oper/obsltPplBiz/register/${rows[index].obsltSwagePplBizSn}${queryString ? `?${queryString}` : ""}`,
);
}}
renderCell={(row, headId) => {
if (!DATE_FIELD_SET.has(headId as keyof ObsltPplBusiness)) return undefined;
return formatObsltPplDate(row[headId as keyof ObsltPplTableRow] as string | null | undefined);
return formatDate(row[headId as keyof ObsltPplTableRow] as string | null | undefined);
}}
/>
</div>