feat(sn): CS 기준 UI 통일 및 지도 팝업 표준화

This commit is contained in:
2026-08-20 14:57:20 +09:00
parent c9e3ff0d91
commit 0cbbc3b934
167 changed files with 21191 additions and 14161 deletions
@@ -1,20 +1,25 @@
"use client";
import { useEffect, useMemo, useState } from "react";
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 { SelectBox } from "@common/business_components/ui";
import {
StandardFormField,
StandardFormSection,
} from "@common/business_components";
import {
Button,
Input,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@common/components/ui";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
} from "@common/components";
import { formatDate } from "../../common/util";
import {
@@ -37,6 +42,7 @@ import type {
} from "../model/obslt-ppl-types";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { useAlertStore } from "@/shared/lib";
import useStore from "@/store/useStore";
type FormKey = keyof ObsltPplBusinessSaveRequest;
@@ -47,6 +53,8 @@ const BIZ_SE_OPTIONS = [
{ text: "정밀조사", val: "정밀조사" },
{ text: "정비사업", val: "정비사업" },
];
const EMPTY_SELECT_VALUE = "__none__";
type FieldSpan = 1 | 2 | 3;
/** 등록·수정 표에서 필드 순서, 라벨, 입력 종류와 필수 여부를 한 곳에서 관리한다. */
const FIELDS: Array<{
@@ -57,7 +65,7 @@ const FIELDS: Array<{
placeholder?: string;
}> = [
{ key: "bizSe", label: "사업구분", required: true },
{ key: "prcisExmnCycl", label: "정밀조사차수" },
{ key: "prcisExmnCycl", label: "정밀조사 차수" },
{ key: "bizCd", label: "사업코드", required: true },
{ key: "envoNm", label: "환경청" },
{ key: "bizNm", label: "사업명", required: true },
@@ -70,11 +78,11 @@ const FIELDS: Array<{
{ key: "evdncDcmntSbmsnStts", label: "증빙서류 제출여부" },
{ key: "bgngYmd", label: "착수(착공) 예정일자", placeholder: "YYYY-MM-DD" },
{ key: "cmcnYmd", label: "준공(예정)일자", placeholder: "YYYY-MM-DD" },
{ key: "bizPrplDlyRsnCn", label: "사업추진 부진사유" },
{ key: "bizPrplDlyRsnCn", label: "사업추진 부진사유", type: "textarea" },
{ key: "bizPicNm", label: "담당자 이름" },
{ key: "bizPicCnpl", label: "담당자 연락처" },
{ key: "rmrkCn", label: "비고", },
{ key: "pbcpOpnnCn", label: "공단의견", },
{ key: "rmrkCn", label: "비고", type: "textarea" },
{ key: "pbcpOpnnCn", label: "공단 의견", type: "textarea" },
];
const NUMBER_KEYS = new Set<FormKey>([
@@ -144,6 +152,8 @@ const toRequest = (form: FormState): ObsltPplBusinessSaveRequest =>
export function ObsltPplFormPage({ id }: { id?: string }) {
const router = useRouter();
const searchParams = useSearchParams();
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
const isModify = Boolean(id);
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [form, setForm] = useState<FormState>(createEmptyForm);
@@ -157,15 +167,8 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
const listQueryString = searchParams.toString();
const listHref = `/oper/obsltPplBiz${listQueryString ? `?${listQueryString}` : ""}`;
const ctpvOptions = useMemo(
() => [{ text: "전체", val: "0", hidden: true }, ...ctpv],
[ctpv],
);
const representativeSggOptions = useMemo(
() => [
{ text: "전체", val: "0", hidden: true },
...buildObsltPplRepresentativeSggOptions(sgg, ctpvCd),
],
() => buildObsltPplRepresentativeSggOptions(sgg, ctpvCd),
[ctpvCd, sgg],
);
@@ -245,8 +248,9 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
try {
if (id) await updateObsltPplBusiness(id, toRequest(form));
else await createObsltPplBusiness(toRequest(form));
window.alert(isModify ? "수정되었습니다." : "등록되었습니다.");
router.push(listHref);
alert(isModify ? "수정되었습니다." : "등록되었습니다.", () =>
router.push(listHref),
);
} catch (error) {
setNotice(
error instanceof Error ? error.message : "저장하지 못했습니다.",
@@ -258,12 +262,11 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
/** 사용자 확인 후 현재 사업을 논리삭제하고 목록으로 이동한다. */
const handleDelete = async () => {
if (!id || !window.confirm("삭제하시겠습니까?")) return;
if (!id || !(await confirm("삭제하시겠습니까?"))) return;
setLoading(true);
try {
await deleteObsltPplBusiness(id);
window.alert("삭제되었습니다.");
router.push(listHref);
alert("삭제되었습니다.", () => router.push(listHref));
} catch (error) {
setNotice(
error instanceof Error ? error.message : "삭제하지 못했습니다.",
@@ -273,153 +276,258 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
}
};
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>
{BIZ_SE_OPTIONS.map((option) => (
<SelectItem key={option.val} value={option.val}>
{option.text}
</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 {
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}
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,
}));
}}
/>
);
}
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 ? "수정" : "등록"}
노후 하수관로 정비사업 {isModify ? "상세/수정" : "등록"}
</h3>
</div>
<div className="card">
<Table variant="secondary">
<colgroup>
<col width="18%" />
<col width="82%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead data-required>시도,시군구 선택</TableHead>
<TableCell className="border">
<div className="flex flex-wrap items-center gap-3">
<SelectBox
label="시도"
className=""
labelClass="w-16 whitespace-nowrap text-[1.3rem] text-gray-600"
selectClass="h-11 w-[180px] rounded-lg bg-white"
selectArray={ctpvOptions}
value={ctpvCd}
onSelectValue={(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: "",
}));
}}
/>
<SelectBox
label="시군구"
className=""
labelClass="w-20 whitespace-nowrap text-[1.3rem] text-gray-600"
selectClass="h-11 w-[220px] rounded-lg bg-white"
selectArray={representativeSggOptions}
value={sggCd}
onSelectValue={(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,
}));
}}
/>
</div>
{fieldErrors.region && (
<p className="mt-1 text-[1.2rem] text-red-600" role="alert">
{fieldErrors.region}
</p>
)}
</TableCell>
</TableRow>
{FIELDS.map((field) => (
<TableRow key={field.key}>
<TableHead data-required={field.required || undefined}>
{field.label}
</TableHead>
<TableCell className="border">
{field.key === "bizSe" ? (
<SelectBox
className=""
selectClass="h-11 rounded-lg bg-white"
selectArray={BIZ_SE_OPTIONS}
value={form.bizSe}
onSelectValue={(value) => {
clearFieldError("bizSe");
setForm({ ...form, bizSe: String(value) });
}}
/>
) : 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) => {
clearFieldError(field.key);
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) => {
clearFieldError(field.key);
setForm({
...form,
[field.key]: DATE_KEYS.has(field.key)
? formatDateInput(e.target.value)
: e.target.value,
});
}}
/>
)}
{fieldErrors[field.key] && (
<p className="mt-1 text-[1.2rem] text-red-600" role="alert">
{fieldErrors[field.key]}
</p>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="card space-y-4">
{notice && (
<p className="mt-3 text-sm text-red-600" role="alert">
<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>
)}
<div className="mt-5 flex justify-end gap-2">
<Button size="sm" onClick={() => router.push(listHref)}>
목록
</Button>
<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
size="sm"
className="rounded-sm bg-blue-600 px-4 py-2 !text-white hover:bg-blue-700"
type="button"
size="default"
onClick={() => void handleSave()}
>
저장
</Button>
<Button
type="button"
size="default"
color="white"
onClick={() => router.push(listHref)}
>
취소
</Button>
{isModify && (
<Button
size="sm"
className="rounded-sm bg-red-600 px-4 py-2 !text-white hover:bg-red-700"
type="button"
size="default"
color="red"
onClick={() => void handleDelete()}
>
삭제
@@ -7,14 +7,25 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
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";
import { Button, Input, Label } from "@common/components/ui";
StandardDataTable,
getStandardRowNumber,
} from "@common/business_components";
import {
Button,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import {
getCoreRowModel,
useReactTable,
type ColumnDef,
type PaginationState,
} from "@tanstack/react-table";
import {
getAllObsltPplBusinesses,
@@ -38,8 +49,6 @@ import { openWindowPopup } from "../../windowPopup/lib/openWindowPopup";
import { WINDOW_POPUP_MESSAGE_TYPE } from "../../windowPopup/model/types";
const DEFAULT_LIST_SIZE = 10;
const ACTION_BUTTON_CLASS =
"rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white";
const BIZ_SE_OPTIONS = [
{ text: "전체", val: "0" },
{ text: "정밀조사", val: "정밀조사" },
@@ -50,6 +59,12 @@ const ALL_REGION_OPTION = { text: "전체", val: "0" };
type ObsltPplTableRow = ObsltPplBusiness & {
rowNumber: number;
};
type ObsltPplTableColumn = {
id: keyof ObsltPplTableRow;
title: string;
width: number;
align?: "left" | "center" | "right";
};
type ObsltPplSearch = {
bizSe: string;
ctpvNm: string;
@@ -74,31 +89,42 @@ const createListQueryString = (
return params.toString();
};
const TABLE_HEADLIST = [
{ id: "rowNumber", title: "NO", widthPercent: 4 },
{ id: "bizSe", title: "사업구분", widthPercent: 6 },
{ id: "prcisExmnCycl", title: "정밀조사차수", widthPercent: 6 },
{ id: "bizCd", title: "사업코드", widthPercent: 7 },
//{ id: "sggCd", title: "시군구코드", widthPercent: 6 },
{ id: "ctpvNm", title: "시도", widthPercent: 6 },
{ id: "sigunNm", title: "시군", widthPercent: 6 },
{ id: "envoNm", title: "환경청", widthPercent: 8 },
{ id: "bizNm", title: "사업명", widthPercent: 16 },
{ id: "prplPrst", title: "추진현황", widthPercent: 6 },
{ id: "bgngYr", title: "착수년도", widthPercent: 5 },
{ id: "cmcnYr", title: "준공(예정)년도", widthPercent: 6 },
{ id: "planPrlg", title: "계획연장(km)", widthPercent: 6 },
{ id: "frhfyrExpcPrlg", title: "상반기(예상)연장(km)", widthPercent: 8 },
{ id: "rmndPrlg", title: "잔여연장(km)", widthPercent: 6 },
{ id: "evdncDcmntSbmsnStts", title: "증빙서류\n제출여부", widthPercent: 6 },
{ id: "bgngYmd", title: "착수일자", widthPercent: 7 },
{ id: "cmcnYmd", title: "준공(예정)일자", widthPercent: 7 },
{ id: "bizPrplDlyRsnCn", title: "사업추진 부진사유", widthPercent: 26 },
{ id: "bizPicNm", title: "담당자 이름", widthPercent: 6 },
{ id: "bizPicCnpl", title: "담당자 연락처", widthPercent: 8 },
{ id: "rmrkCn", title: "비고", widthPercent: 10 },
{ id: "pbcpOpnnCn", title: "공단\n의견", widthPercent: 2 },
/** 다열 목록은 화면별 CSS 대신 공통 스크롤 표에 전달할 실제 컬럼 폭만 정의한다. */
const TABLE_COLUMNS: ObsltPplTableColumn[] = [
{ id: "rowNumber", title: "순번", width: 60 },
{ id: "bizSe", title: "사업구분", width: 85 },
{ id: "prcisExmnCycl", title: "정밀조사\n차수", width: 85 },
{ id: "bizCd", title: "사업코드", width: 105 },
{ id: "ctpvNm", title: "시도", width: 90 },
{ id: "sigunNm", title: "시군구", width: 90 },
{ id: "envoNm", title: "환경청", width: 120 },
{ id: "bizNm", title: "사업명", width: 240, align: "left" },
{ id: "prplPrst", title: "추진현황", width: 85 },
{ id: "bgngYr", title: "착수년도", width: 75 },
{ id: "cmcnYr", title: "준공(예정)\n년도", width: 90 },
{ id: "planPrlg", title: "계획연장\n(km)", width: 95, align: "right" },
{
id: "frhfyrExpcPrlg",
title: "상반기(예상)\n연장(km)",
width: 120,
align: "right",
},
{ id: "rmndPrlg", title: "잔여연장\n(km)", width: 95, align: "right" },
{ id: "evdncDcmntSbmsnStts", title: "증빙서류\n제출여부", width: 90 },
{ id: "bgngYmd", title: "착수일자", width: 105 },
{ id: "cmcnYmd", title: "준공(예정)\n일자", width: 110 },
{
id: "bizPrplDlyRsnCn",
title: "사업추진 부진사유",
width: 300,
align: "left",
},
{ id: "bizPicNm", title: "담당자 이름", width: 90 },
{ id: "bizPicCnpl", title: "담당자 연락처", width: 120 },
{ id: "rmrkCn", title: "비고", width: 160, align: "left" },
{ id: "pbcpOpnnCn", title: "공단\n의견", width: 90, align: "left" },
];
const TABLE_MIN_WIDTH = "280rem";
const EMPTY_PAGE: ObsltPplPage = {
content: [],
@@ -108,8 +134,6 @@ const EMPTY_PAGE: ObsltPplPage = {
size: DEFAULT_LIST_SIZE,
};
const DATE_FIELD_SET = new Set<keyof ObsltPplBusiness>(["bgngYmd", "cmcnYmd"]);
/** 목록 표시값을 공통 포맷으로 정리한다. */
const formatBusinessForDisplay = (row: ObsltPplBusiness): ObsltPplBusiness => ({
...row,
@@ -118,6 +142,13 @@ const formatBusinessForDisplay = (row: ObsltPplBusiness): ObsltPplBusiness => ({
cmcnYmd: formatDate(row.cmcnYmd) || null,
});
/** 공통 DataTable 셀에 표시할 문자열을 null과 숫자까지 일관되게 변환한다. */
const formatTableValue = (value: unknown) => {
if (typeof value === "number") return value.toLocaleString("ko-KR");
if (value === null || value === undefined) return "";
return String(value);
};
/** 노후하수관로 사업 목록, 검색, xlsx 업로드·다운로드 진입점을 제공한다. */
export function ObsltPplListPage() {
const router = useRouter();
@@ -140,7 +171,6 @@ export function ObsltPplListPage() {
const [rows, setRows] = useState<ObsltPplTableRow[]>([]);
const [loading, setLoading] = useState(false);
const [notice, setNotice] = useState("");
const [resetPageNo, setResetPageNo] = useState(false);
// 선택박스의 코드와 API 검색에 사용하는 행정구역명을 함께 관리한다.
const [ctpvCd, setCtpvCd] = useState(() => searchParams.get("ctpvCd") ?? "0");
const [sggCd, setSggCd] = useState(() => searchParams.get("sggCd") ?? "0");
@@ -159,6 +189,67 @@ export function ObsltPplListPage() {
return buildObsltPplRepresentativeSggOptions(sgg, ctpvCd);
}, [ctpvCd, sgg]);
/** 공통 DataTable이 표시할 22개 업무 컬럼과 폭·정렬 규칙을 구성한다. */
const columns = useMemo<ColumnDef<ObsltPplTableRow>[]>(
() =>
TABLE_COLUMNS.map((column) => ({
id: column.id,
accessorFn: (row) => row[column.id],
header: () => (
<span className="whitespace-pre-line">{column.title}</span>
),
cell: ({ getValue }) => {
const displayValue = formatTableValue(getValue());
return (
<span
className="block whitespace-normal break-words"
title={displayValue}
>
{displayValue}
</span>
);
},
meta: {
width: column.width,
align: column.align ?? "center",
},
})),
[],
);
const pagination: PaginationState = {
pageIndex: page - 1,
pageSize: DEFAULT_LIST_SIZE,
};
/** 서버 페이지 번호와 URL 복귀 조건을 공통 DataTable 페이지 상태에 연결한다. */
const table = useReactTable({
data: rows,
columns,
state: { pagination },
rowCount: data.totalElements,
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => String(row.obsltSwagePplBizSn),
onPaginationChange: (updater) => {
const next =
typeof updater === "function" ? updater(pagination) : updater;
const nextPage = next.pageIndex + 1;
if (nextPage === page) return;
setPage(nextPage);
const queryString = createListQueryString(
nextPage,
appliedSearch,
appliedCtpvCd,
appliedSggCd,
);
router.replace(
`/oper/obsltPplBiz${queryString ? `?${queryString}` : ""}`,
{ scroll: false },
);
},
});
/** 공통 레이아웃에서 아직 지역 데이터를 불러오지 못한 경우 공통 store 조회를 보완한다. */
useEffect(() => {
if (ctpv.length === 0) void fetchCtpv();
@@ -184,9 +275,12 @@ export function ObsltPplListPage() {
setRows(
result.content.map((row, index) => ({
...formatBusinessForDisplay(row),
rowNumber:
result.totalElements -
((targetPage - 1) * DEFAULT_LIST_SIZE + index),
rowNumber: getStandardRowNumber({
total: result.totalElements,
rowIndex: index,
pageIndex: targetPage - 1,
pageSize: DEFAULT_LIST_SIZE,
}),
})),
);
} catch (error) {
@@ -199,7 +293,6 @@ export function ObsltPplListPage() {
setRows([]);
} finally {
setLoading(false);
setResetPageNo(false);
}
},
[appliedSearch, page],
@@ -221,8 +314,7 @@ export function ObsltPplListPage() {
`최종확정예산사업 ${inserted.toLocaleString()}건을 등록했습니다.${skipped ? ` 이미 등록된 ${skipped.toLocaleString()}건은 제외했습니다.` : ""}`,
);
setPage(1);
setResetPageNo(true);
void load(1, appliedSearch);
if (page === 1) void load(1, appliedSearch);
};
window.addEventListener("message", receivePopupMessage);
return () => window.removeEventListener("message", receivePopupMessage);
@@ -256,9 +348,8 @@ export function ObsltPplListPage() {
setNotice(
`총 ${result.importedCount.toLocaleString()}건을 저장했습니다. (정밀조사 ${result.precisionSurveyCount.toLocaleString()}건, 정비사업 ${result.maintenanceCount.toLocaleString()}건)`,
);
setResetPageNo(true);
setPage(1);
await load(1, appliedSearch);
if (page === 1) await load(1, appliedSearch);
} catch (error) {
setNotice(
error instanceof Error ? error.message : "업로드하지 못했습니다.",
@@ -289,236 +380,245 @@ export function ObsltPplListPage() {
};
/** 입력 중 조건을 실제 조회조건으로 확정하고 첫 페이지를 다시 조회한다. */
const handleSearch = async () => {
const handleSearch = () => {
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;
}
setPage(1);
};
/** CS 목록 화면과 같은 초기화 동작으로 기본 사업구분과 전체 지역을 다시 조회한다. */
const handleReset = () => {
const nextSearch: ObsltPplSearch = {
bizSe: "정밀조사",
ctpvNm: "",
sigunNm: "",
bizNm: "",
};
setSearch(nextSearch);
setAppliedSearch(nextSearch);
setCtpvCd("0");
setSggCd("0");
setAppliedCtpvCd("0");
setAppliedSggCd("0");
setNotice("");
setPage(1);
router.replace("/oper/obsltPplBiz?bizSe=정밀조사", { scroll: false });
};
/** 적용된 목록 조건을 보존한 채 신규 등록 화면으로 이동한다. */
const handleRegister = () => {
const queryString = createListQueryString(
page,
appliedSearch,
appliedCtpvCd,
appliedSggCd,
);
router.push(
`/oper/obsltPplBiz/register${queryString ? `?${queryString}` : ""}`,
);
};
/** 선택한 목록 행의 수정·상세 화면을 현재 목록 복귀 조건과 함께 연다. */
const handleOpenDetail = (row: ObsltPplTableRow) => {
const queryString = createListQueryString(
page,
appliedSearch,
appliedCtpvCd,
appliedSggCd,
);
router.push(
`/oper/obsltPplBiz/register/${row.obsltSwagePplBizSn}${queryString ? `?${queryString}` : ""}`,
);
};
return (
<>
<LoadingOverlay isLoading={loading} />
<SearchDiv>
<SearchFormLeft>
<div className="flex items-center gap-4">
<div className="flex items-center space-x-2">
<div className="flex items-center">
<SelectBox
label="사업구분"
className=""
labelClass="w-20 whitespace-nowrap text-xl text-gray-600"
selectClass="w-[120px] rounded-lg bg-white"
selectArray={BIZ_SE_OPTIONS}
value={search.bizSe}
onSelectValue={(value) =>
setSearch((current) => ({
...current,
bizSe: String(value),
}))
}
/>
</div>
<div className="flex items-center">
<SelectBox
label="시도"
className=""
labelClass="w-16 whitespace-nowrap text-xl text-gray-600"
selectClass="w-[140px] rounded-lg bg-white"
selectArray={[ALL_REGION_OPTION, ...ctpv]}
value={ctpvCd}
onSelectValue={(value) => {
const nextCtpvCd = String(value);
setCtpvCd(nextCtpvCd);
setSggCd("0");
setSearch((current) => ({
...current,
ctpvNm:
ctpv.find((option) => String(option.val) === nextCtpvCd)
?.text ?? "",
sigunNm: "",
}));
}}
/>
</div>
<div className="flex items-center">
<SelectBox
label="시군구"
className=""
labelClass="w-20 whitespace-nowrap text-xl text-gray-600"
selectClass="w-[140px] rounded-lg bg-white"
selectArray={[ALL_REGION_OPTION, ...availableSgg]}
value={sggCd}
onSelectValue={(value) => {
const nextSggCd = String(value);
setSggCd(nextSggCd);
const selectedOption = availableSgg.find(
(option) => String(option.val) === nextSggCd,
);
setSearch((current) => ({
...current,
sigunNm: selectedOption?.text ?? "",
}));
}}
/>
</div>
<div className="flex items-center">
<Label className="w-20 text-xl text-gray-600">사업명</Label>
<Input
className="w-[220px] !text-2xl"
value={search.bizNm}
onChange={(event) =>
setSearch((current) => ({
...current,
bizNm: event.target.value,
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void handleSearch();
}
}}
/>
</div>
</div>
</div>
</SearchFormLeft>
<SearchFormRight className="ml-auto flex shrink-0 items-center justify-end px-4">
<div className="card card-border !mt-6">
<form
className="flex flex-wrap items-center gap-2 p-2"
onSubmit={(event) => {
event.preventDefault();
handleSearch();
}}
>
<div className="flex items-center gap-2">
<Button
className={ACTION_BUTTON_CLASS}
size="sm"
onClick={() => void handleSearch()}
<Label className="mr-2 whitespace-nowrap text-label">
사업구분
</Label>
<Select
value={search.bizSe}
onValueChange={(value) =>
setSearch((current) => ({
...current,
bizSe: String(value),
}))
}
>
조회
</Button>
<Button
className={ACTION_BUTTON_CLASS}
size="sm"
onClick={() => {
const queryString = createListQueryString(
page,
appliedSearch,
appliedCtpvCd,
appliedSggCd,
);
router.push(
`/oper/obsltPplBiz/register${queryString ? `?${queryString}` : ""}`,
);
<SelectTrigger className="w-[12rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{BIZ_SE_OPTIONS.map((option) => (
<SelectItem key={option.val} value={option.val}>
{option.text}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="mr-2 whitespace-nowrap text-label">시도</Label>
<Select
value={ctpvCd}
onValueChange={(value) => {
const nextCtpvCd = String(value);
setCtpvCd(nextCtpvCd);
setSggCd("0");
setSearch((current) => ({
...current,
ctpvNm:
ctpv.find((option) => String(option.val) === nextCtpvCd)
?.text ?? "",
sigunNm: "",
}));
}}
>
등록
</Button>
<Button
className={ACTION_BUTTON_CLASS}
size="sm"
onClick={handleOpenCfmtnBgt}
>
최종확정예산사업
</Button>
<input
ref={fileInputRef}
type="file"
accept=".xlsx"
className="hidden"
onChange={(event) => void handleUpload(event.target.files?.[0])}
/>
<Button
className={ACTION_BUTTON_CLASS}
size="sm"
onClick={() => fileInputRef.current?.click()}
>
업로드
</Button>
<Button
className="rounded-sm bg-green-600 px-4 py-2 text-sm font-medium !text-white hover:bg-green-700"
size="sm"
onClick={() => void handleDownload()}
>
엑셀다운로드
</Button>
<SelectTrigger className="w-[14rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[ALL_REGION_OPTION, ...ctpv].map((option) => (
<SelectItem
key={String(option.val)}
value={String(option.val)}
>
{option.text}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</SearchFormRight>
</SearchDiv>
<div className="flex items-center gap-2">
<Label className="mr-2 whitespace-nowrap text-label">시군구</Label>
<Select
value={sggCd}
onValueChange={(value) => {
const nextSggCd = String(value);
setSggCd(nextSggCd);
const selectedOption = availableSgg.find(
(option) => String(option.val) === nextSggCd,
);
setSearch((current) => ({
...current,
sigunNm: selectedOption?.text ?? "",
}));
}}
>
<SelectTrigger className="w-[14rem]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[ALL_REGION_OPTION, ...availableSgg].map((option) => (
<SelectItem
key={String(option.val)}
value={String(option.val)}
>
{option.text}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label
htmlFor="obslt-ppl-biz-name"
className="mr-2 whitespace-nowrap text-label"
>
사업명
</Label>
<Input
id="obslt-ppl-biz-name"
className="w-[22rem] rounded-sm !text-[1.3rem]"
value={search.bizNm}
onChange={(event) =>
setSearch((current) => ({
...current,
bizNm: event.target.value,
}))
}
/>
</div>
<Button type="submit" className="w-[7.4rem]">
조회
</Button>
<Button
type="button"
color="white"
className="w-[8.8rem]"
onClick={handleReset}
>
초기화
</Button>
</form>
</div>
{notice && (
<div
role="status"
className="mx-[1.2rem] mt-4 rounded border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-700"
className="mx-[1.2rem] mt-4 rounded-sm border border-primary bg-primary-transparent px-4 py-3 text-[1.3rem] text-primary"
>
{notice}
</div>
)}
<ListDiv className="overflow-x-auto">
<div className="w-[130%]">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-medium">
노후하수관로 정밀조사 및 정비사업 현황{" "}
<span className="text-base text-gray-500">
({data.totalElements.toLocaleString("ko-KR")}건)
</span>
</h3>
</div>
<UiTable<ObsltPplTableRow>
headName=""
headlist={TABLE_HEADLIST}
tableData={rows}
pageSize={DEFAULT_LIST_SIZE}
total={data.totalElements}
curPage={page}
widthFixed
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;
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 formatDate(
row[headId as keyof ObsltPplTableRow] as
| string
| null
| undefined,
);
}}
/>
</div>
</ListDiv>
<div className="mt-4 w-full min-w-0 max-w-full overflow-hidden">
<StandardDataTable
table={table}
title="노후 하수관로 정비사업 목록"
scrollMode="horizontal"
minTableWidth={TABLE_MIN_WIDTH}
className="table-fixed"
onRowClick={handleOpenDetail}
actions={
<>
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
</Button>
<Button type="button" onClick={handleRegister}>
등록
</Button>
<Button type="button" onClick={handleOpenCfmtnBgt}>
최종확정예산사업
</Button>
<input
ref={fileInputRef}
type="file"
accept=".xlsx"
className="hidden"
onChange={(event) => void handleUpload(event.target.files?.[0])}
/>
<Button
type="button"
onClick={() => fileInputRef.current?.click()}
>
업로드
</Button>
</>
}
/>
</div>
</>
);
}