sn: 노후하수관로정비사업 조회조건 유지
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user