feat(sn): 침수예측 및 통수위험 팝업 UI 보완
This commit is contained in:
@@ -281,11 +281,6 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
};
|
||||
}, [resultPage.items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !window.resizeTo) return;
|
||||
window.resizeTo(1360, 600);
|
||||
}, []);
|
||||
|
||||
/** 현재 탭의 표시·조회 기준시간을 매 정시에 새 현재시간으로 변경한다. */
|
||||
useEffect(() => {
|
||||
if (resultMode !== "current" || typeof window === "undefined") return;
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
StandardPopupLayout,
|
||||
type UiHeadProps,
|
||||
} from "@common/business_components";
|
||||
import { Button } from "@common/components";
|
||||
import { UiTable } from "@common/business_components/ui/table";
|
||||
import type { UiHeadProps, UiTableSort } from "@common/business_components/ui";
|
||||
import ListDiv from "@common/business_components/ui/list_div";
|
||||
|
||||
import { getBlobData, getRawJson } from "@/components/lib/api";
|
||||
import { fileDownload } from "@/components/lib/utils/fileDownload";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import useStore from "@/store/useStore";
|
||||
|
||||
type ApiRow = {
|
||||
@@ -18,21 +25,24 @@ type ApiRow = {
|
||||
sggNm: string;
|
||||
rainBeginningDateTime: string;
|
||||
analysisDateTime: string;
|
||||
observedAccumulatedRainfall: number;
|
||||
rainHour: number;
|
||||
rainfall: number;
|
||||
exceededFrequencyYear: number;
|
||||
observedAccumulatedRainfall: number | null;
|
||||
rainHour: number | null;
|
||||
rainfall: number | null;
|
||||
exceededFrequencyYear: number | null;
|
||||
floodRiskGrade: string;
|
||||
};
|
||||
type TableRow = ApiRow & {
|
||||
rowNo: number;
|
||||
rainBeginningDateTimeDisplay: string;
|
||||
analysisDateTimeDisplay: string;
|
||||
observedAccumulatedRainfallDisplay: string;
|
||||
rainHourDisplay: string;
|
||||
rainfallDisplay: string;
|
||||
exceededFrequencyYearDisplay: string;
|
||||
};
|
||||
|
||||
type TableRow = ApiRow &
|
||||
Record<string, unknown> & {
|
||||
rowNo: number;
|
||||
rainBeginningDateTimeDisplay: string;
|
||||
analysisDateTimeDisplay: string;
|
||||
observedAccumulatedRainfallDisplay: string;
|
||||
rainHourDisplay: string;
|
||||
rainfallDisplay: string;
|
||||
exceededFrequencyYearDisplay: string;
|
||||
};
|
||||
|
||||
type Filters = {
|
||||
ctpvCd: string;
|
||||
sggCd: string;
|
||||
@@ -43,84 +53,62 @@ type Filters = {
|
||||
frequencyYear: string;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const MAP_FREQUENCIES = new Set([30, 50, 80]);
|
||||
const PAGE_SIZE = 10;
|
||||
const MAP_FREQUENCIES = new Set([30, 50, 80, 100]);
|
||||
const FREQUENCY_OPTIONS = [30, 50, 80, 100] as const;
|
||||
|
||||
const HEADS: UiHeadProps[] = [
|
||||
{ id: "rowNo", title: "NO", widthPercent: 5, sortable: false },
|
||||
{
|
||||
id: "ctpvNm",
|
||||
title: "시도",
|
||||
widthPercent: 9,
|
||||
sortable: true,
|
||||
sortKey: "ctpvCd",
|
||||
},
|
||||
{
|
||||
id: "sggNm",
|
||||
title: "시군구",
|
||||
widthPercent: 10,
|
||||
sortable: true,
|
||||
sortKey: "sggCd",
|
||||
},
|
||||
{ id: "rowNo", title: "순번", widthPercent: 6 },
|
||||
{ id: "ctpvNm", title: "시도", widthPercent: 8 },
|
||||
{ id: "sggNm", title: "시군구", widthPercent: 9 },
|
||||
{
|
||||
id: "rainBeginningDateTimeDisplay",
|
||||
title: "강우 시작시각",
|
||||
title: "강우시각",
|
||||
widthPercent: 13,
|
||||
sortable: true,
|
||||
sortKey: "rainBeginningDateTime",
|
||||
},
|
||||
{
|
||||
id: "analysisDateTimeDisplay",
|
||||
title: "분석시각",
|
||||
widthPercent: 13,
|
||||
sortable: true,
|
||||
sortKey: "analysisDateTime",
|
||||
},
|
||||
{
|
||||
id: "observedAccumulatedRainfallDisplay",
|
||||
title: "관측 누적강수량(mm)",
|
||||
widthPercent: 13,
|
||||
sortable: true,
|
||||
sortKey: "observedAccumulatedRainfall",
|
||||
align: "right",
|
||||
},
|
||||
{
|
||||
id: "rainHourDisplay",
|
||||
title: "강우시간",
|
||||
widthPercent: 9,
|
||||
sortable: true,
|
||||
sortKey: "rainHour",
|
||||
widthPercent: 8,
|
||||
},
|
||||
{
|
||||
id: "rainfallDisplay",
|
||||
title: "강수량(mm)",
|
||||
widthPercent: 10,
|
||||
sortable: false,
|
||||
align: "right",
|
||||
},
|
||||
{
|
||||
id: "exceededFrequencyYearDisplay",
|
||||
title: "초과빈도년도",
|
||||
widthPercent: 9,
|
||||
sortable: true,
|
||||
sortKey: "exceededFrequencyYear",
|
||||
},
|
||||
{
|
||||
id: "floodRiskGrade",
|
||||
title: "위험등급",
|
||||
widthPercent: 9,
|
||||
sortable: true,
|
||||
sortKey: "floodRiskGrade",
|
||||
title: "초과 빈도(년)",
|
||||
widthPercent: 10,
|
||||
align: "right",
|
||||
},
|
||||
{ id: "floodRiskGrade", title: "위험등급", widthPercent: 10 },
|
||||
];
|
||||
|
||||
/** 브라우저 로컬시각을 datetime-local 입력값으로 변환한다. */
|
||||
const localDateTime = (date: Date) =>
|
||||
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}T${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
||||
const initialFilters = (): Filters => {
|
||||
|
||||
/** 최초·초기화 조회조건은 금일 분석결과를 기준으로 생성한다. */
|
||||
const buildInitialFilters = (): Filters => {
|
||||
const now = new Date();
|
||||
const from = new Date(now);
|
||||
from.setHours(0, 0, 0, 0);
|
||||
return {
|
||||
ctpvCd: "0",
|
||||
sggCd: "0",
|
||||
// 최초 조회에서는 강우 시작시각을 제한하지 않고 전체 강우사상을 대상으로 한다.
|
||||
rainBgngFrom: "",
|
||||
rainBgngTo: "",
|
||||
analysisFrom: localDateTime(from),
|
||||
@@ -128,47 +116,103 @@ const initialFilters = (): Filters => {
|
||||
frequencyYear: "",
|
||||
};
|
||||
};
|
||||
|
||||
const apiDate = (value: string) => value.replace(/[^0-9]/g, "").slice(0, 12);
|
||||
|
||||
const displayDate = (value: string) =>
|
||||
/^\d{12}$/.test(value)
|
||||
? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)} ${value.slice(8, 10)}:${value.slice(10, 12)}`
|
||||
: value;
|
||||
const rainHourText = (value: number) =>
|
||||
value === 0 ? "현재" : `${value > 0 ? "+" : "-"}${Math.abs(value)}시간`;
|
||||
const query = (filters: Filters, page?: number, sort?: UiTableSort) => {
|
||||
const p = new URLSearchParams();
|
||||
: value || "-";
|
||||
|
||||
const rainHourText = (value: number | null) => {
|
||||
const hour = Number(value ?? 0);
|
||||
return hour === 0 ? "현재" : `${hour > 0 ? "+" : "-"}${Math.abs(hour)}시간`;
|
||||
};
|
||||
|
||||
const formatNumber = (value: number | null, digits: number) =>
|
||||
value == null || !Number.isFinite(Number(value))
|
||||
? "-"
|
||||
: Number(value).toFixed(digits);
|
||||
|
||||
/** 화면 조회조건을 목록·Excel API 쿼리로 변환한다. */
|
||||
const buildQuery = (filters: Filters, page?: number) => {
|
||||
const params = new URLSearchParams();
|
||||
if (page !== undefined) {
|
||||
p.set("page", String(page - 1));
|
||||
p.set("size", String(PAGE_SIZE));
|
||||
params.set("page", String(page - 1));
|
||||
params.set("size", String(PAGE_SIZE));
|
||||
}
|
||||
if (filters.ctpvCd !== "0") p.set("ctpvCd", filters.ctpvCd.slice(0, 2));
|
||||
if (filters.sggCd !== "0") p.set("sggCd", filters.sggCd.slice(0, 5));
|
||||
["rainBgngFrom", "rainBgngTo", "analysisFrom", "analysisTo"].forEach(
|
||||
(key) => {
|
||||
const value = apiDate(filters[key as keyof Filters]);
|
||||
if (value) p.set(key, value);
|
||||
},
|
||||
);
|
||||
if (filters.frequencyYear) p.set("frequencyYear", filters.frequencyYear);
|
||||
if (sort?.key && sort.direction)
|
||||
p.set("sort", `${sort.key},${sort.direction}`);
|
||||
return p.toString();
|
||||
if (filters.ctpvCd !== "0") params.set("ctpvCd", filters.ctpvCd.slice(0, 2));
|
||||
if (filters.sggCd !== "0") params.set("sggCd", filters.sggCd.slice(0, 5));
|
||||
(
|
||||
["rainBgngFrom", "rainBgngTo", "analysisFrom", "analysisTo"] as const
|
||||
).forEach((key) => {
|
||||
const value = apiDate(filters[key]);
|
||||
if (value) params.set(key, value);
|
||||
});
|
||||
if (filters.frequencyYear) params.set("frequencyYear", filters.frequencyYear);
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
/** 시작·종료시각과 빈도 조건을 API 호출 전에 검증한다. */
|
||||
const validateFilters = (filters: Filters) => {
|
||||
if (
|
||||
filters.rainBgngFrom &&
|
||||
filters.rainBgngTo &&
|
||||
filters.rainBgngFrom > filters.rainBgngTo
|
||||
) {
|
||||
return "강우 시작시각의 시작은 종료보다 늦을 수 없습니다.";
|
||||
}
|
||||
if (
|
||||
filters.analysisFrom &&
|
||||
filters.analysisTo &&
|
||||
filters.analysisFrom > filters.analysisTo
|
||||
) {
|
||||
return "분석시각의 시작은 종료보다 늦을 수 없습니다.";
|
||||
}
|
||||
if (
|
||||
filters.frequencyYear &&
|
||||
!FREQUENCY_OPTIONS.includes(
|
||||
Number(filters.frequencyYear) as 30 | 50 | 80 | 100,
|
||||
)
|
||||
) {
|
||||
return "초과 빈도는 30년, 50년, 80년, 100년 중에서 선택해야 합니다.";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const buildRowId = (row: ApiRow) =>
|
||||
`${row.ctpvCd}:${row.sggCd}:${row.rainBeginningDateTime}:${row.analysisDateTime}:${row.rainHour}`;
|
||||
|
||||
const isDangerGrade = (value: string) => {
|
||||
const grade = String(value ?? "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (!grade || grade === "-" || grade.includes("정상") || grade === "NORMAL") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export default function FldngPredcPrstPopupContent() {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
const [filters, setFilters] = useState<Filters>(() => initialFilters());
|
||||
const [applied, setApplied] = useState<Filters>(() => initialFilters());
|
||||
const [filters, setFilters] = useState<Filters>(() => buildInitialFilters());
|
||||
const [appliedFilters, setAppliedFilters] = useState<Filters>(() =>
|
||||
buildInitialFilters(),
|
||||
);
|
||||
const [rows, setRows] = useState<TableRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selected, setSelected] = useState(-1);
|
||||
const [selectedRowId, setSelectedRowId] = useState("");
|
||||
// 개발 StrictMode에서도 최초 목록 API를 한 번만 호출하기 위한 ref다.
|
||||
const didInitialLoadRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctpv.length) void fetchCtpv();
|
||||
if (!sgg.length) void fetchSgg();
|
||||
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
|
||||
|
||||
const sggOptions = useMemo(
|
||||
() =>
|
||||
filters.ctpvCd === "0"
|
||||
@@ -178,229 +222,336 @@ export default function FldngPredcPrstPopupContent() {
|
||||
),
|
||||
[filters.ctpvCd, sgg],
|
||||
);
|
||||
const load = useCallback(
|
||||
async (targetPage: number, condition: Filters, nextSort?: UiTableSort) => {
|
||||
|
||||
/** 조회조건과 페이지에 맞는 침수예측현황 목록을 불러온다. */
|
||||
const loadRows = useCallback(
|
||||
async (targetPage: number, condition: Filters) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getRawJson(
|
||||
`/sewerage-service/api/v1/fldng/predc/prst?${query(condition, targetPage, nextSort)}`,
|
||||
`/sewerage-service/api/v1/fldng/predc/prst?${buildQuery(condition, targetPage)}`,
|
||||
);
|
||||
const payload = response?.data ?? response;
|
||||
const content: ApiRow[] = Array.isArray(payload?.content)
|
||||
? payload.content
|
||||
: [];
|
||||
setRows(
|
||||
content.map((item, index) => ({
|
||||
content.map((item) => ({
|
||||
...item,
|
||||
rowNo: (targetPage - 1) * PAGE_SIZE + index + 1,
|
||||
rowNo: 0,
|
||||
rainBeginningDateTimeDisplay: displayDate(
|
||||
item.rainBeginningDateTime,
|
||||
),
|
||||
analysisDateTimeDisplay: displayDate(item.analysisDateTime),
|
||||
observedAccumulatedRainfallDisplay: Number(
|
||||
item.observedAccumulatedRainfall ?? 0,
|
||||
).toFixed(4),
|
||||
rainHourDisplay: rainHourText(Number(item.rainHour ?? 0)),
|
||||
rainfallDisplay: Number(item.rainfall ?? 0).toFixed(1),
|
||||
exceededFrequencyYearDisplay: String(
|
||||
item.exceededFrequencyYear ?? "",
|
||||
observedAccumulatedRainfallDisplay: formatNumber(
|
||||
item.observedAccumulatedRainfall,
|
||||
4,
|
||||
),
|
||||
rainHourDisplay: rainHourText(item.rainHour),
|
||||
rainfallDisplay: formatNumber(item.rainfall, 1),
|
||||
exceededFrequencyYearDisplay:
|
||||
item.exceededFrequencyYear == null
|
||||
? "-"
|
||||
: String(item.exceededFrequencyYear),
|
||||
})),
|
||||
);
|
||||
setTotal(Number(payload?.totalElements ?? 0));
|
||||
setPage(targetPage);
|
||||
setApplied(condition);
|
||||
setSort(nextSort);
|
||||
setSelected(-1);
|
||||
setAppliedFilters(condition);
|
||||
setSelectedRowId("");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "침수예측현황 조회에 실패했습니다.",
|
||||
);
|
||||
console.error("침수예측현황 조회 중 오류:", error);
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
alert("침수예측현황 조회에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
[alert],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const defaults = initialFilters();
|
||||
if (didInitialLoadRef.current) return;
|
||||
didInitialLoadRef.current = true;
|
||||
const defaults = buildInitialFilters();
|
||||
setFilters(defaults);
|
||||
void load(1, defaults);
|
||||
}, [load]);
|
||||
const mapRow = async (index?: number) => {
|
||||
if (typeof index !== "number") return;
|
||||
const row = rows[index];
|
||||
if (!row) return;
|
||||
setSelected(index);
|
||||
if (!MAP_FREQUENCIES.has(row.exceededFrequencyYear)) {
|
||||
void loadRows(1, defaults);
|
||||
}, [loadRows]);
|
||||
|
||||
const handleSearch = () => {
|
||||
const message = validateFilters(filters);
|
||||
if (message) {
|
||||
alert(message);
|
||||
return;
|
||||
}
|
||||
void loadRows(1, filters);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const defaults = buildInitialFilters();
|
||||
setFilters(defaults);
|
||||
void loadRows(1, defaults);
|
||||
};
|
||||
|
||||
/** 선택 행과 동일한 시군구·빈도의 도시침수지도 범위를 부모 지도에 전달한다. */
|
||||
const handleRowClick = async (row: TableRow) => {
|
||||
const frequencyYear = Number(row.exceededFrequencyYear ?? 0);
|
||||
if (!MAP_FREQUENCIES.has(frequencyYear)) {
|
||||
alert(
|
||||
`${row.exceededFrequencyYear}년 빈도 도시침수지도는 제공되지 않습니다. (30·50·80년 빈도만 제공)`,
|
||||
`${frequencyYear || "해당"}년 빈도 도시침수지도는 제공되지 않습니다. (30·50·80·100년 빈도만 제공)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!row.sggCd) {
|
||||
alert("지도 이동에 필요한 시군구 정보가 없습니다.");
|
||||
return;
|
||||
}
|
||||
if (!window.opener || window.opener.closed) {
|
||||
alert("하수관망도 분석도구에서 침수예측현황을 다시 열어주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedRowId(buildRowId(row));
|
||||
setLoading(true);
|
||||
try {
|
||||
const sbmrgFrqExpln = `${row.exceededFrequencyYear}년`;
|
||||
const p = new URLSearchParams({ sggCd: row.sggCd, sbmrgFrqExpln });
|
||||
const sbmrgFrqExpln = `${frequencyYear}년`;
|
||||
const params = new URLSearchParams({
|
||||
sggCd: row.sggCd,
|
||||
sbmrgFrqExpln,
|
||||
});
|
||||
const extentData = await getRawJson(
|
||||
`/sewerage-service/api/v1/fldng/flood-dngr-map/group/extent?${p}`,
|
||||
`/sewerage-service/api/v1/fldng/flood-dngr-map/group/extent?${params.toString()}`,
|
||||
);
|
||||
const extent = [
|
||||
extentData.minX,
|
||||
extentData.minY,
|
||||
extentData.maxX,
|
||||
extentData.maxY,
|
||||
extentData?.minX,
|
||||
extentData?.minY,
|
||||
extentData?.maxX,
|
||||
extentData?.maxY,
|
||||
].map(Number);
|
||||
if (extent.some((v) => !Number.isFinite(v)))
|
||||
if (extent.some((value) => !Number.isFinite(value))) {
|
||||
throw new Error("도시침수지도 위치 정보가 없습니다.");
|
||||
window.opener?.postMessage(
|
||||
}
|
||||
window.opener.postMessage(
|
||||
{
|
||||
type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_FLOOD_GROUP,
|
||||
payload: { extent, sggCd: row.sggCd, sbmrgFrqExpln },
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
window.opener.focus();
|
||||
} catch (error) {
|
||||
console.error("침수예측현황 지도 이동 중 오류:", error);
|
||||
alert(
|
||||
error instanceof Error ? error.message : "지도 이동에 실패했습니다.",
|
||||
error instanceof Error && error.message.includes("위치 정보")
|
||||
? error.message
|
||||
: "도시침수지도 이동에 실패했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const download = async () => {
|
||||
if (!total) return alert("다운로드할 데이터가 없습니다.");
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!total) {
|
||||
alert("다운로드할 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const blob = await getBlobData(
|
||||
`/sewerage-service/api/v1/fldng/predc/prst/excel?${query(applied, undefined, sort)}`,
|
||||
`/sewerage-service/api/v1/fldng/predc/prst/excel?${buildQuery(appliedFilters)}`,
|
||||
);
|
||||
fileDownload(blob, "침수예측현황.xlsx");
|
||||
} catch (error) {
|
||||
alert(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Excel 다운로드에 실패했습니다.",
|
||||
);
|
||||
console.error("침수예측현황 Excel 다운로드 중 오류:", error);
|
||||
alert("침수예측현황 Excel 다운로드에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const field = (key: keyof Filters, label: string) => (
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">{label}</div>
|
||||
|
||||
const renderDateTimeRange = (
|
||||
label: string,
|
||||
fromKey: "rainBgngFrom" | "analysisFrom",
|
||||
toKey: "rainBgngTo" | "analysisTo",
|
||||
) => (
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<span className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
{label}
|
||||
</span>
|
||||
<input
|
||||
className="sn-popup-input"
|
||||
className="h-[3.2rem] w-[17rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
type="datetime-local"
|
||||
value={filters[key]}
|
||||
onChange={(e) => setFilters((v) => ({ ...v, [key]: e.target.value }))}
|
||||
value={filters[fromKey]}
|
||||
aria-label={`${label} 시작`}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
[fromKey]: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className="text-xl font-medium text-label">~</span>
|
||||
<input
|
||||
className="h-[3.2rem] w-[17rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
type="datetime-local"
|
||||
value={filters[toKey]}
|
||||
aria-label={`${label} 종료`}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
[toKey]: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="sn-popup-page w-full max-w-full overflow-hidden">
|
||||
<LoadingOverlay isLoading={loading} />
|
||||
<header className="sn-popup-header">
|
||||
<h1 className="sn-popup-title">침수예측현황</h1>
|
||||
<div />
|
||||
</header>
|
||||
<section className="flex min-h-0 flex-1 flex-col px-4 pb-3 pt-3">
|
||||
<div className="sn-popup-filter-panel-compact grid grid-cols-4 gap-3">
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">시도</div>
|
||||
<StandardPopupLayout
|
||||
title={windowPopupDefinitions.fldngPredcPrst.title}
|
||||
bodyClassName="overflow-x-hidden"
|
||||
>
|
||||
<section
|
||||
className={`relative flex min-h-0 flex-1 flex-col gap-[1.2rem] ${
|
||||
rows.length === 0
|
||||
? "[&_.standard-data-table>.card]:!flex [&_.standard-data-table>.card]:!min-h-0 [&_.standard-data-table>.card]:!flex-1 [&_.standard-data-table>.card]:!flex-col [&_.standard-data-table]:!flex [&_.standard-data-table]:!min-h-0 [&_.standard-data-table]:!flex-1 [&_.standard-data-table]:!flex-col"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<LoadingOverlay isLoading={loading} />
|
||||
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-4 gap-y-3 2xl:flex-nowrap">
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<span className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
시도
|
||||
</span>
|
||||
<select
|
||||
className="sn-popup-input"
|
||||
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={filters.ctpvCd}
|
||||
onChange={(e) =>
|
||||
setFilters((v) => ({
|
||||
...v,
|
||||
ctpvCd: e.target.value,
|
||||
aria-label="시도"
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
ctpvCd: event.target.value,
|
||||
sggCd: "0",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="0">전체</option>
|
||||
{ctpv.map((x) => (
|
||||
<option key={x.val} value={String(x.val)}>
|
||||
{x.text}
|
||||
{ctpv.map((option) => (
|
||||
<option key={option.val} value={String(option.val)}>
|
||||
{option.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">시군구</div>
|
||||
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<span className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
시군구
|
||||
</span>
|
||||
<select
|
||||
className="sn-popup-input"
|
||||
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={filters.sggCd}
|
||||
onChange={(e) =>
|
||||
setFilters((v) => ({ ...v, sggCd: e.target.value }))
|
||||
aria-label="시군구"
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
sggCd: event.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="0">전체</option>
|
||||
{sggOptions.map((x) => (
|
||||
<option key={x.val} value={String(x.val)}>
|
||||
{x.text}
|
||||
{sggOptions.map((option) => (
|
||||
<option key={option.val} value={String(option.val)}>
|
||||
{option.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{field("rainBgngFrom", "강우 시작시각 From")}
|
||||
{field("rainBgngTo", "강우 시작시각 To")}
|
||||
{field("analysisFrom", "분석시각 From")}
|
||||
{field("analysisTo", "분석시각 To")}
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">초과 빈도년도</div>
|
||||
<input
|
||||
className="sn-popup-input"
|
||||
type="number"
|
||||
min="0"
|
||||
|
||||
{renderDateTimeRange("강우시각", "rainBgngFrom", "rainBgngTo")}
|
||||
{renderDateTimeRange("분석시각", "analysisFrom", "analysisTo")}
|
||||
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<span className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
초과 빈도
|
||||
</span>
|
||||
<select
|
||||
className="h-[3.2rem] w-[12rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={filters.frequencyYear}
|
||||
onChange={(e) =>
|
||||
setFilters((v) => ({ ...v, frequencyYear: e.target.value }))
|
||||
aria-label="초과 빈도"
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
frequencyYear: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className="sn-popup-actions self-end">
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
onClick={() => void load(1, filters, sort)}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{FREQUENCY_OPTIONS.map((frequency) => (
|
||||
<option key={frequency} value={String(frequency)}>
|
||||
{frequency}년
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
|
||||
<Button type="button" color="primary" onClick={handleSearch}>
|
||||
조회
|
||||
</Button>
|
||||
<Button size="sm" color="primary" onClick={() => void download()}>
|
||||
다운로드
|
||||
<Button type="button" color="white" onClick={handleReset}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ListDiv className="mt-3 min-h-0 flex-1 overflow-x-auto">
|
||||
<div className="min-w-[1450px]">
|
||||
<UiTable<TableRow>
|
||||
headName={
|
||||
<>침수예측현황 조회 결과 총 {total.toLocaleString()}건</>
|
||||
}
|
||||
tableData={rows}
|
||||
headlist={HEADS}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={total}
|
||||
curPage={page}
|
||||
onPageChange={(p) => void load(p, applied, sort)}
|
||||
cellClick={(i) => void mapRow(i)}
|
||||
index={selected}
|
||||
widthFixed={true}
|
||||
isHideCount={true}
|
||||
sort={sort}
|
||||
onSortChange={(s) => void load(1, applied, s)}
|
||||
/>
|
||||
</div>
|
||||
</ListDiv>
|
||||
<div className="sn-popup-help-text">
|
||||
행 클릭 시 해당 시군구·빈도의 도시침수지도로 이동합니다.
|
||||
</PopupSearchPanel>
|
||||
|
||||
<MapPopupDataTable
|
||||
rows={rows}
|
||||
headlist={HEADS}
|
||||
title="침수예측현황 목록"
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
onPageChange={(nextPage) => void loadRows(nextPage, appliedFilters)}
|
||||
rowId={(row) => buildRowId(row)}
|
||||
selectedRowId={selectedRowId}
|
||||
onRowClick={(row) => void handleRowClick(row)}
|
||||
renderCell={(row, headId) =>
|
||||
headId === "floodRiskGrade" ? (
|
||||
<span
|
||||
className={`sn-popup-badge min-w-[5.6rem] ${
|
||||
isDangerGrade(row.floodRiskGrade)
|
||||
? "sn-popup-badge-danger"
|
||||
: "sn-popup-badge-normal"
|
||||
}`}
|
||||
>
|
||||
{row.floodRiskGrade || "-"}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="132rem"
|
||||
fillAvailableHeight={rows.length === 0}
|
||||
emptyMessage="조회 조건에 해당하는 침수예측현황이 없습니다."
|
||||
actions={
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
onClick={() => void handleDownload()}
|
||||
disabled={!total || loading}
|
||||
>
|
||||
엑셀다운로드
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="px-1 text-xl text-muted-foreground">
|
||||
행을 선택하면 해당 시군구·빈도의 도시침수지도로 이동합니다.
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,8 +185,8 @@ export const windowPopupDefinitions = {
|
||||
fldngPredcPrst: {
|
||||
key: "fldngPredcPrst",
|
||||
title: "침수예측현황",
|
||||
width: 1500,
|
||||
height: 860,
|
||||
width: 1700,
|
||||
height: 900,
|
||||
},
|
||||
agedPipeStatusByCtpvPopup: {
|
||||
key: "agedPipeStatusByCtpvPopup",
|
||||
@@ -223,8 +223,8 @@ export const windowPopupDefinitions = {
|
||||
flowRiskPopup: {
|
||||
key: "flowRiskPopup",
|
||||
title: "통수 위험",
|
||||
width: 1360,
|
||||
height: 600,
|
||||
width: 1700,
|
||||
height: 900,
|
||||
},
|
||||
} as const satisfies Record<string, WindowPopupDefinition>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user