st: feat: 로그 조회, 입력-하수관로 조회, 입력-하수도보급률 예외 제출

This commit is contained in:
2026-04-07 14:57:35 +09:00
parent 1489fdc664
commit 51b1daf083
28 changed files with 2489 additions and 1133 deletions
@@ -72,6 +72,8 @@ const CompactDataGrid = ({
getGridDataRef,
cellStyleCallback,
onRowClick,
compactFullWidth = false,
compactGridHeightClass = "h-[65vh]",
}: DataGridProps) => {
const { columns, columnLayout } = columnInfo;
const gridRef = useRef<RealGridReact>(null);
@@ -313,7 +315,7 @@ const CompactDataGrid = ({
return () => {
if (getGridDataRef) getGridDataRef.current = null;
};
}, [columns, columnLayout, fixedColumns, rowData, showHeaderCheckbox, getGridDataRef]);
}, [columns, columnLayout, fixedColumns, rowData, showHeaderCheckbox, getGridDataRef, compactFullWidth, compactGridHeightClass]);
// 행 클릭 이벤트
useEffect(() => {
@@ -336,8 +338,8 @@ const CompactDataGrid = ({
}, [onRowClick]);
return (
<div className="flex flex-col gap-[0.6rem] mx-[0.6rem] w-1/2">
<div className="h-[65vh]">
<div className={`flex flex-col gap-[0.6rem] ${compactFullWidth ? "w-full min-w-0" : "w-1/2"}`}>
<div className={compactGridHeightClass}>
<RealGridReact
ref={gridRef}
autoGenerateField={true}
@@ -0,0 +1,51 @@
"use client";
import { useState } from "react";
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle } from "@common/components";
import SidoSggFacilityNameSearchForm from "./SidoSggFacilityNameSearchForm";
type GridTopActionButtonProps = {
text: string;
onClick?: (helpers: { openDialog: (title?: string) => void; closeDialog: () => void }) => void;
/** 상위 화면 검색 통계연도 — 시설 조회 다이얼로그에 전달 */
statsYr?: string;
};
const GridTopActionButton = ({ text, onClick, statsYr }: GridTopActionButtonProps) => {
const [isOpen, setIsOpen] = useState(false);
const [title, setTitle] = useState(text);
const openDialog = (nextTitle?: string) => {
setTitle(nextTitle ?? text);
setIsOpen(true);
};
const closeDialog = () => setIsOpen(false);
const handleClick = () => {
if (onClick) {
onClick({ openDialog, closeDialog });
return;
}
openDialog(text);
};
return (
<>
<Button className="h-[3.2rem] px-6" color="white" type="button" onClick={handleClick}>
<span>{text}</span>
</Button>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent aria-describedby={undefined} className="max-w-[100rem]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<SidoSggFacilityNameSearchForm statsYr={statsYr} />
</DialogContent>
</Dialog>
</>
);
};
export default GridTopActionButton;
+1 -1
View File
@@ -233,7 +233,7 @@ const SearchForm = (props: SearchFormProps = {}) => {
</div>
}
<div className="flex items-center gap-2 ml-auto">
<Button color="green" className="h-[3.2rem] w-[96px]" type="button" onClick={handleReset}>
<Button color="white" className="h-[3.2rem] w-[96px]" type="button" onClick={handleReset}>
초기화
</Button>
<Button className="h-[3.2rem] w-[96px]" type="submit">
@@ -0,0 +1,321 @@
"use client";
import { useState } from "react";
import useSWR from "swr";
import { ValueType } from "realgrid";
import {
Button,
Input,
Label,
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
LoadingWithAnimation
} from "@common/components";
import CompactDataGrid from "./CompactDataGrid";
import SaveButton from "../button/SaveButton";
import { useAlertStore } from "@/shared/lib";
import { statisticsAPi } from "@/entities/statistics/api/statistics-service";
import type { ApiResponse, SearchCondition, Sgg } from "@/entities/statistics/model/statistics-interface";
import type { ColumnInfo } from "../type/DataGrid.type";
import type {
SeweragePipeFacilitiesListRequest,
SeweragePipeFacilityAdminAreaListRequest,
} from "@/entities/statistics/model/input/statistics-sewage-pipe-input-interface";
import { statisticsInputSewPipeApiService } from "@/entities/statistics/api/input/statistics-sewage-pipe-input-service";
/** 다이얼로그 좌측: 처리시설 정보 (순번 없음) */
const treatmentFacilityColumnInfo: ColumnInfo = {
columns: [
{ name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, editable: false },
{ name: "sggNm", fieldName: "sggNm", header: { text: "시군구" }, editable: false },
{ name: "fctNm", fieldName: "fctNm", header: { text: "시설명" }, editable: false },
{
name: "fctCpc",
fieldName: "fctCpc",
header: { text: "시설용량(m³/일)" },
valueType: ValueType.NUMBER,
dataFormat: "#,##0",
editable: false,
},
],
columnLayout: ["ctpvNm", "sggNm", "fctNm", "fctCpc"],
};
/** 다이얼로그 우측: 행정구역 정보 (순번 없음, 헤더 체크박스는 showHeaderCheckbox) */
const adminRegionColumnInfo: ColumnInfo = {
columns: [
{ name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, editable: false },
{ name: "sggNm", fieldName: "sggNm", header: { text: "시군구" }, editable: false },
{ name: "emdNm", fieldName: "emdNm", header: { text: "읍면동" }, editable: false },
],
columnLayout: ["ctpvNm", "sggNm", "emdNm"],
};
const getSidoListFetcher = async (): Promise<ApiResponse<SearchCondition>> => {
return statisticsAPi.getSidoList();
};
const getSggAllListFetcher = async (key: [string, string]): Promise<ApiResponse<Sgg[]>> => {
return statisticsAPi.getSggAllList(key[1]);
};
type SidoSggFacilityNameSearchFormProps = {
/** 상위(SewagePipeInp 등) 검색 조건의 통계연도 — facilitiesList 등 API 필수값 */
statsYr?: string;
};
const SidoSggFacilityNameSearchForm = ({ statsYr }: SidoSggFacilityNameSearchFormProps) => {
const { setMessage: alert } = useAlertStore((state) => state);
const { data: sidoList } = useSWR<ApiResponse<SearchCondition>>("sido-list", getSidoListFetcher, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 2000,
shouldRetryOnError: false,
onSuccess: (data) => {
if (data.status !== "success") {
alert(`시도 목록을 불러오는데 실패했습니다.\n새로고침 후 다시 시도해주세요.`);
}
},
onError: () => {
alert(`시도 목록을 불러오는데 실패했습니다.\n새로고침 후 다시 시도해주세요.`);
},
});
const [sido, setSido] = useState<string>("ALL");
const [gugun, setGugun] = useState<string>("ALL");
const [fctNm, setFctNm] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(false);
const [treatmentFacilityRows, setTreatmentFacilityRows] = useState<Record<string, string | number>[]>([]);
const [adminRegionRows, setAdminRegionRows] = useState<Record<string, string | number>[]>([]);
const sggKey: [string, string] | null = sido && sido !== "ALL" ? ["sgg-all", sido] : null;
const { data: sggList } = useSWR<ApiResponse<Sgg[]>>(sggKey, getSggAllListFetcher, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 2000,
shouldRetryOnError: false,
onSuccess: (data) => {
if (data.status !== "success") {
alert(`시군구 목록을 불러오는데 실패했습니다.\n새로고침 후 다시 시도해주세요.`);
}
},
onError: () => {
alert(`시군구 목록을 불러오는데 실패했습니다.\n새로고침 후 다시 시도해주세요.`);
},
});
const sggOptions = sggList?.data ?? [];
/** 시설 목록·행정구역 API 공통 — 하수도 사업자코드(bsnmCd) */
const resolveSewerBzmnCd = (): string => {
const sidoItem = sidoList?.data?.sidoList?.find((v) => v.sido === sido);
const raw =
gugun !== "ALL" && gugun
? sggOptions.find((s) => s.gugun === gugun)?.bsnmCd
: sidoItem?.bsnmCd;
return raw?.trim() ?? "";
};
const handleSidoChange = (value: string) => {
setSido(value);
setGugun("ALL");
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!statsYr?.trim()) {
alert("상단에서 통계연도로 조회한 뒤 이용해 주세요.");
return;
}
if (!sido || sido === "ALL") {
alert("시도를 선택해주세요.");
return;
}
const trimmedFclty = fctNm.trim();
const sewerBzmnCd = resolveSewerBzmnCd();
const params: SeweragePipeFacilitiesListRequest = {
statsYr: statsYr.trim(),
...(sewerBzmnCd ? { sewerBzmnCd } : {}),
ctpvNm: sido,
...(gugun !== "ALL" && gugun ? { sggNm: gugun } : {}),
...(trimmedFclty ? { fcltyNm: trimmedFclty } : {}),
};
try {
setIsLoading(true);
const response = await statisticsInputSewPipeApiService.getFacilitiesList(params);
if (response.status !== "success") {
alert(response.message ?? "처리시설 목록 조회에 실패했습니다.");
return;
}
const list = response.data ?? [];
setTreatmentFacilityRows(
list.map((item) => ({
ctpvNm: item.ctpvNm,
sggNm: item.sggNm,
fctNm: item.bscFctNm,
fctCpc: item.fctCpc,
bscFctCd: item.bscFctCd,
admdstCd: item.admdstCd,
statsYr: item.statsYr,
})),
);
setAdminRegionRows([]);
} catch {
alert("처리시설 목록 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
const handleTreatmentFacilityRowClick = async (row: Record<string, string | number>) => {
if (!statsYr?.trim()) {
alert("상단에서 통계연도로 조회한 뒤 이용해 주세요.");
return;
}
const swtrmtFcltCd = String(row.bscFctCd ?? "").trim();
if (!swtrmtFcltCd) {
alert("처리시설 코드가 없습니다.");
return;
}
const ctpvNm = String(row.ctpvNm ?? sido);
const sggNm = String(
row.sggNm ?? (gugun !== "ALL" && gugun ? gugun : ""),
);
const params: SeweragePipeFacilityAdminAreaListRequest = {
statsYr: statsYr.trim(),
swtrmtFcltCd,
sewerBzmnCd: String(row.admdstCd ?? "").trim(),
ctpvNm,
sggNm,
};
try {
setIsLoading(true);
const response = await statisticsInputSewPipeApiService.getFacilityAdminAreaList(params);
if (response.status !== "success") {
alert(response.message ?? "행정구역 목록 조회에 실패했습니다.");
return;
}
const list = response.data ?? [];
setAdminRegionRows(
list.map((item) => ({
ctpvNm: item.ctpvNm,
sggNm: item.sggNm,
emdNm: item.admdstNm,
admdstCd: item.admdstCd,
})),
);
} catch {
alert("행정구역 목록 조회 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
const handleSaveAdminRegions = () => {
alert("저장 API 연동 예정입니다.");
};
return (
<div className="flex flex-col gap-4 pt-2">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-wrap items-end gap-4">
<div className="flex items-center gap-2">
<Label className="text-label shrink-0">시도</Label>
<Select value={sido} onValueChange={handleSidoChange}>
<SelectTrigger className="w-[12rem]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체</SelectItem>
{sidoList?.data?.sidoList?.map((item) => (
<SelectItem key={item.adstDstrCd} value={item.sido}>
{item.sido}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label className="text-label shrink-0">시군구</Label>
<Select value={gugun} onValueChange={setGugun} disabled={!sido || sido === "ALL"}>
<SelectTrigger className="w-[12rem]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체</SelectItem>
{sggOptions?.map((sgg) => (
<SelectItem key={sgg.adstDstrCd} value={sgg.gugun}>
{sgg.gugun}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 min-w-[20rem] flex-1">
<Label className="text-label shrink-0">시설명</Label>
<Input
className="flex-1 min-w-[12rem]"
value={fctNm}
onChange={(e) => setFctNm(e.target.value)}
placeholder="시설명 입력"
/>
</div>
<Button type="submit" className="h-[3.2rem] w-[96px]" color="primary">
조회
</Button>
</div>
<div className="ml-auto">
<SaveButton saveFunc={handleSaveAdminRegions} />
</div>
</form>
{isLoading && <div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>}
<div className="flex gap-[1.2rem] items-stretch w-full min-w-0">
<div className="flex-1 flex flex-col gap-2 min-w-0">
<h3 className="text-[1.4rem] font-medium text-foreground">처리시설 정보</h3>
<CompactDataGrid
columnInfo={treatmentFacilityColumnInfo}
rowData={treatmentFacilityRows}
setRowData={setTreatmentFacilityRows}
showHeaderCheckbox={false}
compactFullWidth
onRowClick={handleTreatmentFacilityRowClick}
// compactGridHeightClass="h-[min(40vh,28rem)] min-h-[16rem]"
/>
</div>
<div className="flex-1 flex flex-col gap-2 min-w-0">
<div className="flex items-center justify-between gap-2">
<h3 className="text-[1.4rem] font-medium text-foreground">행정구역 정보</h3>
</div>
<CompactDataGrid
columnInfo={adminRegionColumnInfo}
rowData={adminRegionRows}
setRowData={setAdminRegionRows}
showHeaderCheckbox
compactFullWidth
// compactGridHeightClass="h-[min(40vh,28rem)] min-h-[16rem]"
/>
</div>
</div>
</div>
);
};
export default SidoSggFacilityNameSearchForm;
@@ -14,7 +14,7 @@ const SplitDataGrid = ({
enableSort = false,
}: SplitDataGridProps) => {
return (
<div className="flex gap-[0.6rem] mx-[0.6rem]">
<div className="flex gap-[1.2rem]">
<CompactDataGrid
columnInfo={leftColumnInfo}
fixedColumns={leftFixedColumns}
+29 -1
View File
@@ -13,6 +13,7 @@ import { useTabStore } from "@/store/tab-store"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@common/components";
import { Button } from "@common/components";
import SplitDataGrid from "./SplitDataGrid";
import GridTopActionButton from "./GridTopActionButton";
// RealGrid 타입 확장
declare module "realgrid" {
@@ -214,7 +215,20 @@ function createNumberComparer(dp: RealGridDataProviderComparer): (field: string
};
}
const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, rows = [], showHeaderCheckbox = false, rowDataMap, setRowDataMap, activeTab: activeTabProp, onActiveTabChange, getGridDataRef, enableSort = false }: TabDataGridProps) => {
const TabDataGrid = ({
columnInfo,
isExcelImport = true,
isExcelExport = true,
rows = [],
showHeaderCheckbox = false,
rowDataMap,
setRowDataMap,
activeTab: activeTabProp,
onActiveTabChange,
getGridDataRef,
enableSort = false,
facilitySearchStatsYr,
}: TabDataGridProps) => {
// 각 탭별로 그리드 ref와 데이터를 관리
const gridRefs = useRef<Map<string, RealGridReact>>(new Map());
const activeTabRef = useRef<string>("");
@@ -738,6 +752,11 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
if (info.isSplitGrid && info.left && info.right) {
const leftRowData = rowDataMap.get(info.left.tabKey || `${tabKey}_left`) || [];
const rightRowData = rowDataMap.get(info.right.tabKey || `${tabKey}_right`) || [];
const splitTopButton = info.right.topButton;
const showSplitTopButton =
Boolean(splitTopButton?.text) &&
(splitTopButton?.visible ?? true) &&
isExcelImport;
// ColumnInfoList를 ColumnInfo로 변환
const leftColumnInfo: ColumnInfo = {
@@ -751,6 +770,15 @@ const TabDataGrid = ({ columnInfo, isExcelImport = true, isExcelExport = true, r
return (
<TabsContent key={tabKey} value={tabKey}>
<div className="flex justify-end items-center gap-[0.6rem] mb-[0.6rem]">
{showSplitTopButton && (
<GridTopActionButton
text={splitTopButton?.text ?? ""}
onClick={splitTopButton?.onClick}
statsYr={facilitySearchStatsYr ?? undefined}
/>
)}
</div>
<SplitDataGrid
leftColumnInfo={leftColumnInfo}
rightColumnInfo={rightColumnInfo}