feat(sn): CS 기준 UI 통일 및 지도 팝업 표준화
This commit is contained in:
@@ -1,49 +1,78 @@
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@common/components/ui";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableCell,
|
||||
} from "@common/components/ui";
|
||||
import React from "react";
|
||||
|
||||
interface InputSectionProps {
|
||||
title: string;
|
||||
data: { year: string; type: string, bizInfoNm: string, bizType: string }[];
|
||||
data: { year: string; type: string; bizInfoNm: string; bizType: string }[];
|
||||
sectionKey: string;
|
||||
onInputChange: (section: string, index: number, field: string, value: string) => void;
|
||||
onInputChange: (
|
||||
section: string,
|
||||
index: number,
|
||||
field: string,
|
||||
value: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
const InputSection: React.FC<InputSectionProps> = ({ title, data, sectionKey, onInputChange }) => {
|
||||
const InputSection: React.FC<InputSectionProps> = ({
|
||||
title,
|
||||
data,
|
||||
sectionKey,
|
||||
onInputChange,
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
{/* 섹션 제목 */}
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl mx-3 font-medium text-label whitespace-nowrap">{title}</h3>
|
||||
<h3 className="mx-3 whitespace-nowrap text-2xl font-medium text-label">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
{/* 테이블 */}
|
||||
<div className="card !m-[1.2rem] !p-0r">
|
||||
<div className="card !p-0r !m-[1.2rem]">
|
||||
<Table variant="secondary" className="w-full">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="border border-gray-300 px-2 py-2 text-sm w-auto whitespace-nowrap">구분</TableHead>
|
||||
<TableHead className="border border-gray-300 px-2 py-2 text-sm w-[80px] whitespace-nowrap">진단연도</TableHead>
|
||||
<TableHead className="border border-gray-300 px-2 py-2 text-sm w-auto whitespace-nowrap">사업명</TableHead>
|
||||
<TableHead className="border border-gray-300 px-2 py-2 text-sm w-auto whitespace-nowrap">유형</TableHead>
|
||||
<TableHead className="w-auto whitespace-nowrap border border-border px-2 py-2 text-sm">
|
||||
구분
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px] whitespace-nowrap border border-border px-2 py-2 text-sm">
|
||||
진단연도
|
||||
</TableHead>
|
||||
<TableHead className="w-auto whitespace-nowrap border border-border px-2 py-2 text-sm">
|
||||
사업명
|
||||
</TableHead>
|
||||
<TableHead className="w-auto whitespace-nowrap border border-border px-2 py-2 text-sm">
|
||||
유형
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((row, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell className="border border-gray-300 px-2 py-2 text-sm text-center leading-normal w-auto whitespace-nowrap">
|
||||
<TableCell className="w-auto whitespace-nowrap border border-border px-2 py-2 text-center text-sm leading-normal">
|
||||
{row.type}
|
||||
</TableCell>
|
||||
<TableCell className="border border-gray-300 px-2 py-2 text-sm w-[80px] whitespace-nowrap">
|
||||
<TableCell className="w-[80px] whitespace-nowrap border border-border px-2 py-2 text-sm">
|
||||
<input
|
||||
type="text"
|
||||
value={row.year}
|
||||
onChange={(e) => onInputChange(sectionKey, index, "year", e.target.value)}
|
||||
className="w-full text-xl border-radius-0 px-3 py-2 text-base text-center leading-normal"
|
||||
onChange={(e) =>
|
||||
onInputChange(sectionKey, index, "year", e.target.value)
|
||||
}
|
||||
className="border-radius-0 w-full px-3 py-2 text-center text-base text-xl leading-normal"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="border border-gray-300 px-2 py-2 text-sm text-center leading-normal w-auto">
|
||||
<TableCell className="w-auto border border-border px-2 py-2 text-center text-sm leading-normal">
|
||||
{row.bizInfoNm}
|
||||
</TableCell>
|
||||
<TableCell className="border border-gray-300 px-2 py-2 text-sm text-center leading-normal w-auto whitespace-nowrap">
|
||||
<TableCell className="w-auto whitespace-nowrap border border-border px-2 py-2 text-center text-sm leading-normal">
|
||||
{row.bizType}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { validateElapsedYears, type AgedPipeFilters } from "./agedPipeUtils";
|
||||
|
||||
type MaybePromise<T = void> = T | Promise<T>;
|
||||
@@ -18,15 +19,19 @@ export default function AgedPipeLayerSettingPanel({
|
||||
onApply,
|
||||
compact = false,
|
||||
}: Props) {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [elapsedYearsText, setElapsedYearsText] = useState(String(filters.elapsedYears ?? ""));
|
||||
const [elapsedYearsText, setElapsedYearsText] = useState(
|
||||
String(filters.elapsedYears ?? ""),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setElapsedYearsText(String(filters.elapsedYears ?? ""));
|
||||
}, [filters.elapsedYears]);
|
||||
|
||||
const commitElapsedYears = async () => {
|
||||
const elapsedYears = elapsedYearsText === "" ? NaN : Number(elapsedYearsText);
|
||||
const elapsedYears =
|
||||
elapsedYearsText === "" ? NaN : Number(elapsedYearsText);
|
||||
if (!validateElapsedYears(elapsedYears)) {
|
||||
alert("경과년도는 1년~100년 범위 내에서 입력해주세요.");
|
||||
return;
|
||||
@@ -41,16 +46,22 @@ export default function AgedPipeLayerSettingPanel({
|
||||
alert(`노후관로 경과년도 ${elapsedYears}년으로 반영되었습니다.`);
|
||||
};
|
||||
|
||||
const inputClass = "h-[22px] border border-[#ccd6df] px-[2px] text-center text-[1rem] leading-none text-[#222]";
|
||||
const inputClass =
|
||||
"h-[22px] border border-input bg-background px-[2px] text-center text-[1rem] leading-none text-foreground";
|
||||
const numberInputClass = inputClass + " w-[40px]";
|
||||
const applyButtonClass = "grid h-[20px] w-[20px] shrink-0 place-items-center rounded-full bg-[#31a83b] text-[0.95rem] font-bold leading-none text-white";
|
||||
const indicator = <span className="h-2 w-2 shrink-0 rounded-full bg-[#243b93]" />;
|
||||
const applyButtonClass =
|
||||
"grid h-[20px] w-[20px] shrink-0 place-items-center rounded-full bg-primary text-[0.95rem] font-bold leading-none text-primary-foreground";
|
||||
const indicator = (
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||
);
|
||||
|
||||
const settingRow = (
|
||||
<div className="flex min-h-[25px] items-center gap-1 whitespace-nowrap text-[1.05rem] font-medium text-slate-700">
|
||||
<div className="flex min-h-[25px] items-center gap-1 whitespace-nowrap text-[1.05rem] font-medium text-label">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{indicator}
|
||||
<label htmlFor="aged-pipe-elapsed-years" className="shrink-0">노후관로 경과년도(년)</label>
|
||||
<label htmlFor="aged-pipe-elapsed-years" className="shrink-0">
|
||||
노후관로 경과년도(년)
|
||||
</label>
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<input
|
||||
@@ -85,7 +96,7 @@ export default function AgedPipeLayerSettingPanel({
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="w-full rounded-[6px] border border-[#dbe7f5] bg-white px-[6px] py-2 shadow-[0_2px_8px_rgba(15,23,42,0.05)]">
|
||||
<div className="w-full rounded-[6px] border border-border bg-background px-[6px] py-2 shadow-blur">
|
||||
{settingRow}
|
||||
</div>
|
||||
);
|
||||
@@ -93,23 +104,21 @@ export default function AgedPipeLayerSettingPanel({
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-hidden rounded-sm border border-red-100 bg-red-50/35">
|
||||
<div className="relative flex min-h-[30px] items-center justify-center border-b border-red-100 bg-white px-8">
|
||||
<div className="relative flex min-h-[30px] items-center justify-center border-b border-red-100 bg-background px-8">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-2 top-1/2 h-[18px] w-[18px] -translate-y-1/2 bg-transparent text-[1.2rem] leading-none text-[#555]"
|
||||
className="absolute left-2 top-1/2 h-[18px] w-[18px] -translate-y-1/2 bg-transparent text-[1.2rem] leading-none text-muted-foreground"
|
||||
onClick={() => setIsCollapsed((value) => !value)}
|
||||
title={isCollapsed ? "펼치기" : "접기"}
|
||||
>
|
||||
{isCollapsed ? "+" : "-"}
|
||||
</button>
|
||||
<span className="text-[1.2rem] font-semibold text-red-700">노후관로 설정</span>
|
||||
<span className="text-[1.2rem] font-semibold text-red-700">
|
||||
노후관로 설정
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="px-2 py-2">
|
||||
{settingRow}
|
||||
</div>
|
||||
)}
|
||||
{!isCollapsed && <div className="px-2 py-2">{settingRow}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Button, Input } from "@common/components";
|
||||
import { UiTable } from "@common/business_components/ui";
|
||||
import type { UiTableSort } from "@common/business_components/ui";
|
||||
import { StandardPopupLayout } from "@common/business_components";
|
||||
import { getData } from "@/components/lib/api";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import useStore from "@/store/useStore";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import {
|
||||
@@ -21,38 +21,23 @@ import {
|
||||
type AgedPipeRow,
|
||||
} from "./agedPipeUtils";
|
||||
import { downloadAgedPipeCsv } from "./agedPipeExport";
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
|
||||
const POPUP_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT";
|
||||
const POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE";
|
||||
const POPUP_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC";
|
||||
|
||||
const AGED_PIPE_API_SORT_KEYS: Record<string, string> = {
|
||||
rowNumber: "rowNumber",
|
||||
fcltMngNo: "fclt_mng_no",
|
||||
instlYmd: "instl_ymd",
|
||||
pdmtVl: "pdmt_vl",
|
||||
prlgVl: "prlg_vl",
|
||||
usgCd: "usg_cd",
|
||||
mqltCd: "mqlt_cd",
|
||||
};
|
||||
|
||||
const resolveAgedPipeApiSort = (sort?: UiTableSort) => {
|
||||
if (!sort) return undefined;
|
||||
if (sort.key === "elapsedYears") {
|
||||
const installDateDirection = sort.direction === "asc" ? "desc" : "asc";
|
||||
return `instl_ymd,${installDateDirection}`;
|
||||
}
|
||||
|
||||
const apiKey = AGED_PIPE_API_SORT_KEYS[sort.key];
|
||||
return apiKey ? `${apiKey},${sort.direction}` : undefined;
|
||||
};
|
||||
|
||||
export default function AgedPipePopupPage() {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const searchParams = useSearchParams();
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
const [filters, setFilters] = useState<AgedPipeFilters>(() =>
|
||||
normalizeAgedPipeFilters({
|
||||
elapsedYears: Number(searchParams.get("elapsedYears") ?? AGED_PIPE_DEFAULT_FILTERS.elapsedYears),
|
||||
elapsedYears: Number(
|
||||
searchParams.get("elapsedYears") ??
|
||||
AGED_PIPE_DEFAULT_FILTERS.elapsedYears,
|
||||
),
|
||||
ctpvCd: searchParams.get("ctpvCd") ?? AGED_PIPE_DEFAULT_FILTERS.ctpvCd,
|
||||
sggCd: searchParams.get("sggCd") ?? AGED_PIPE_DEFAULT_FILTERS.sggCd,
|
||||
}),
|
||||
@@ -60,16 +45,16 @@ export default function AgedPipePopupPage() {
|
||||
const [rows, setRows] = useState<AgedPipeRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [downloadProgressText, setDownloadProgressText] = useState("");
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
|
||||
const sggOptions = useMemo(
|
||||
() =>
|
||||
filters.ctpvCd === "0"
|
||||
? sgg
|
||||
: sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))),
|
||||
: sgg.filter((item) =>
|
||||
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
|
||||
),
|
||||
[filters.ctpvCd, sgg],
|
||||
);
|
||||
|
||||
@@ -79,20 +64,13 @@ export default function AgedPipePopupPage() {
|
||||
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
|
||||
|
||||
const fetchRows = useCallback(
|
||||
async (
|
||||
page: number,
|
||||
nextFilters: AgedPipeFilters,
|
||||
size: number,
|
||||
activeSort?: UiTableSort,
|
||||
) => {
|
||||
async (page: number, nextFilters: AgedPipeFilters, size: number) => {
|
||||
const normalized = normalizeAgedPipeFilters(nextFilters);
|
||||
const queryParams = new URLSearchParams({
|
||||
lyrTypeNm: AGED_PIPE_LAYER_TYPE,
|
||||
page: String(page - 1),
|
||||
size: String(size),
|
||||
});
|
||||
const apiSort = resolveAgedPipeApiSort(activeSort);
|
||||
if (apiSort) queryParams.set("sort", apiSort);
|
||||
const cqlFilters = buildAgedPipeCqlFilters(normalized);
|
||||
if (cqlFilters.length > 0) {
|
||||
queryParams.append("cqlFiter", cqlFilters.join(" and "));
|
||||
@@ -102,15 +80,10 @@ export default function AgedPipePopupPage() {
|
||||
const responseRows = Array.isArray(response?.data) ? response.data : [];
|
||||
|
||||
const total = Number(response?.count ?? 0);
|
||||
const descendingSequence =
|
||||
activeSort?.key === "rowNumber" && activeSort.direction === "desc";
|
||||
const rows = responseRows.map((item: Record<string, unknown>, index: number) => {
|
||||
const row = toDisplayPipeRow(item, index, page, ctpv, sgg);
|
||||
if (descendingSequence) {
|
||||
row.rowNumber = total - ((page - 1) * size + index);
|
||||
}
|
||||
return row;
|
||||
});
|
||||
const rows = responseRows.map(
|
||||
(item: Record<string, unknown>, index: number) =>
|
||||
toDisplayPipeRow(item, index, page, ctpv, sgg),
|
||||
);
|
||||
|
||||
return { rows, total };
|
||||
},
|
||||
@@ -118,10 +91,14 @@ export default function AgedPipePopupPage() {
|
||||
);
|
||||
|
||||
const loadRows = useCallback(
|
||||
async (page = 1, nextFilters = filters, activeSort = sort) => {
|
||||
async (page = 1, nextFilters = filters) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetchRows(page, nextFilters, AGED_PIPE_PAGE_SIZE, activeSort);
|
||||
const response = await fetchRows(
|
||||
page,
|
||||
nextFilters,
|
||||
AGED_PIPE_PAGE_SIZE,
|
||||
);
|
||||
setRows(response.rows);
|
||||
setTotal(response.total);
|
||||
setCurrentPage(page);
|
||||
@@ -133,7 +110,7 @@ export default function AgedPipePopupPage() {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[fetchRows, filters, sort],
|
||||
[fetchRows, filters],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -148,9 +125,10 @@ export default function AgedPipePopupPage() {
|
||||
if (event.data?.type !== POPUP_FILTER_SYNC_MESSAGE) return;
|
||||
if (!event.data?.filters) return;
|
||||
|
||||
const normalized = normalizeAgedPipeFilters(event.data.filters as AgedPipeFilters);
|
||||
const normalized = normalizeAgedPipeFilters(
|
||||
event.data.filters as AgedPipeFilters,
|
||||
);
|
||||
setFilters(normalized);
|
||||
setSelectedIndex(-1);
|
||||
loadRows(1, normalized);
|
||||
};
|
||||
|
||||
@@ -169,12 +147,7 @@ export default function AgedPipePopupPage() {
|
||||
await loadRows(page, normalized);
|
||||
};
|
||||
|
||||
const handleRowClick = (index?: number) => {
|
||||
if (typeof index !== "number") return;
|
||||
const row = rows[index];
|
||||
if (!row) return;
|
||||
|
||||
setSelectedIndex(index);
|
||||
const handleRowClick = (row: AgedPipeRow) => {
|
||||
window.opener?.postMessage(
|
||||
{
|
||||
type: POPUP_ROW_SELECT_MESSAGE,
|
||||
@@ -184,12 +157,6 @@ export default function AgedPipePopupPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleSortChange = (nextSort?: UiTableSort) => {
|
||||
setSort(nextSort);
|
||||
setSelectedIndex(-1);
|
||||
void loadRows(1, filters, nextSort);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (total === 0) {
|
||||
alert("다운로드할 데이터가 없습니다.");
|
||||
@@ -237,44 +204,30 @@ export default function AgedPipePopupPage() {
|
||||
window.close();
|
||||
};
|
||||
|
||||
const getCellClassName = (_row: AgedPipeRow, headId: string) => {
|
||||
if (headId === "mqltCd") {
|
||||
return "break-all whitespace-normal";
|
||||
}
|
||||
|
||||
return "whitespace-normal";
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="sn-popup-page w-full max-w-full overflow-x-hidden overflow-y-hidden">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<header className="sn-popup-header">
|
||||
<h1 className="sn-popup-title">노후관로 현황</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
size="xs"
|
||||
className="sn-popup-header-button"
|
||||
onClick={handleMinimize}
|
||||
>
|
||||
<StandardPopupLayout
|
||||
title="노후관로 목록"
|
||||
actions={
|
||||
<Button type="button" color="white" onClick={handleMinimize}>
|
||||
최소화
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
>
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
|
||||
{downloadProgressText ? (
|
||||
<div className="sn-popup-success-bar">
|
||||
<div className="rounded-sm border border-primary bg-primary-transparent px-4 py-3 text-[1.3rem] text-primary">
|
||||
{downloadProgressText}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="flex min-h-0 flex-1 flex-col overflow-x-hidden px-4 pb-3 pt-3">
|
||||
<div className="sn-popup-filter-panel-compact grid grid-cols-1 gap-3 md:grid-cols-[140px_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-end">
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">경과년도</div>
|
||||
<PopupSearchPanel contentClassName="flex flex-wrap items-end gap-x-5 gap-y-3">
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.3rem] text-label">
|
||||
경과년도
|
||||
</span>
|
||||
<Input
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
className="w-[12rem]"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
@@ -284,7 +237,8 @@ export default function AgedPipePopupPage() {
|
||||
const digits = event.target.value.replace(/[^0-9]/g, "");
|
||||
setFilters({
|
||||
...filters,
|
||||
elapsedYears: digits === "" ? ("" as unknown as number) : Number(digits),
|
||||
elapsedYears:
|
||||
digits === "" ? ("" as unknown as number) : Number(digits),
|
||||
});
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
@@ -293,10 +247,12 @@ export default function AgedPipePopupPage() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">시도</div>
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.3rem] text-label">
|
||||
시도
|
||||
</span>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
className="h-[3.2rem] w-[18rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
|
||||
value={filters.ctpvCd}
|
||||
onChange={(event) =>
|
||||
setFilters({
|
||||
@@ -315,12 +271,16 @@ export default function AgedPipePopupPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">시군구</div>
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.3rem] text-label">
|
||||
시군구
|
||||
</span>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
className="h-[3.2rem] w-[18rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
|
||||
value={filters.sggCd}
|
||||
onChange={(event) => setFilters({ ...filters, sggCd: event.target.value })}
|
||||
onChange={(event) =>
|
||||
setFilters({ ...filters, sggCd: event.target.value })
|
||||
}
|
||||
>
|
||||
<option value="0">전체</option>
|
||||
{sggOptions.map((item) => (
|
||||
@@ -331,89 +291,46 @@ export default function AgedPipePopupPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="sn-popup-actions">
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="sn-popup-primary-button !text-[1.1rem]"
|
||||
onClick={() => void handleSearch(1)}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="sn-popup-primary-button !text-[1.1rem]"
|
||||
onClick={handleDownload}
|
||||
color="white"
|
||||
onClick={() => {
|
||||
const resetFilters = normalizeAgedPipeFilters(
|
||||
AGED_PIPE_DEFAULT_FILTERS,
|
||||
);
|
||||
setFilters(resetFilters);
|
||||
void loadRows(1, resetFilters);
|
||||
}}
|
||||
>
|
||||
다운로드
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopupSearchPanel>
|
||||
|
||||
<div className="aged-pipe-popup-table min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto pt-2">
|
||||
<UiTable<AgedPipeRow>
|
||||
headName={<>노후관로 조회 결과 총 {total.toLocaleString()}건</>}
|
||||
tableData={rows}
|
||||
<MapPopupDataTable
|
||||
rows={rows as Array<AgedPipeRow & Record<string, unknown>>}
|
||||
headlist={AGED_PIPE_LIST_HEADLIST}
|
||||
pageSize={10}
|
||||
title="노후관로 목록"
|
||||
total={total}
|
||||
cellClick={(index) => handleRowClick(index)}
|
||||
page={currentPage}
|
||||
pageSize={AGED_PIPE_PAGE_SIZE}
|
||||
onPageChange={(page) => void handleSearch(page)}
|
||||
cellClassName={getCellClassName}
|
||||
curPage={currentPage}
|
||||
index={selectedIndex}
|
||||
widthFixed={true}
|
||||
isHideCount={true}
|
||||
className="!m-0 overflow-x-hidden"
|
||||
sort={sort}
|
||||
onSortChange={handleSortChange}
|
||||
onRowClick={handleRowClick}
|
||||
rowId={(row, index) => String(row.fid ?? row.fcltMngNo ?? index)}
|
||||
actions={
|
||||
<Button type="button" onClick={handleDownload}>
|
||||
엑셀다운로드
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<style jsx global>{`
|
||||
.aged-pipe-popup-table .card-header {
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.aged-pipe-popup-table .card-header h3 {
|
||||
font-size: 1.7rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.aged-pipe-popup-table .card {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.aged-pipe-popup-table .table-ui {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.aged-pipe-popup-table .table-ui th {
|
||||
padding: 0.8rem 0.45rem;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.2;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.aged-pipe-popup-table .table-ui td {
|
||||
padding: 0.85rem 0.4rem;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.aged-pipe-popup-table .table-ui td:nth-child(2),
|
||||
.aged-pipe-popup-table .table-ui td:nth-child(3),
|
||||
.aged-pipe-popup-table .table-ui td:nth-child(8) {
|
||||
word-break: keep-all;
|
||||
}
|
||||
`}</style>
|
||||
</main>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,14 +54,14 @@ const normalizeRegionFilterCode = (value: unknown, prefixLength: number) => {
|
||||
};
|
||||
|
||||
export const AGED_PIPE_LIST_HEADLIST = [
|
||||
{ id: "rowNumber", title: "NO", widthPercent: 6, sortable: true },
|
||||
{ id: "fcltMngNo", title: "시설관리번호", widthPercent: 18, sortable: true },
|
||||
{ id: "instlYmd", title: "설치일자", widthPercent: 12, sortable: true },
|
||||
{ id: "pdmtVl", title: "관경(mm)", widthPercent: 10, sortable: true },
|
||||
{ id: "prlgVl", title: "연장(m)", widthPercent: 10, sortable: true },
|
||||
{ id: "usgCd", title: "용도", widthPercent: 12, sortable: true },
|
||||
{ id: "mqltCd", title: "재질", widthPercent: 18, sortable: true },
|
||||
{ id: "elapsedYears", title: "경과년도", widthPercent: 8, align: "right", sortable: true },
|
||||
{ id: "rowNumber", title: "순번", widthPercent: 6 },
|
||||
{ id: "fcltMngNo", title: "시설관리번호", widthPercent: 18 },
|
||||
{ id: "instlYmd", title: "설치일자", widthPercent: 12 },
|
||||
{ id: "pdmtVl", title: "관경(mm)", widthPercent: 10 },
|
||||
{ id: "prlgVl", title: "연장(m)", widthPercent: 10 },
|
||||
{ id: "usgCd", title: "용도", widthPercent: 12 },
|
||||
{ id: "mqltCd", title: "재질", widthPercent: 18 },
|
||||
{ id: "elapsedYears", title: "경과년도", widthPercent: 8, align: "right" },
|
||||
];
|
||||
|
||||
export const AGED_PIPE_EXCEL_FIELDS = [
|
||||
|
||||
+92
-53
@@ -2,12 +2,13 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Input } from "@common/components";
|
||||
import { UiTable } from "@common/business_components/ui";
|
||||
import { StandardPopupLayout } from "@common/business_components";
|
||||
import { getRawJson } from "@/components/lib/api";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
|
||||
import {
|
||||
AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
|
||||
AGED_PIPE_STATUS_BY_CTPV_PAGE_SIZE,
|
||||
buildAgedPipeStatusByCtpvHeadList,
|
||||
buildAgedPipeStatusYearOptions,
|
||||
normalizeAgedPipeStatusByCtpvResponse,
|
||||
@@ -19,8 +20,14 @@ import {
|
||||
type AgedPipeStatusByCtpvRow,
|
||||
} from "./agedPipeStatusByCtpvUtils";
|
||||
import { downloadAgedPipeStatusByCtpvCsv } from "./agedPipeStatusByCtpvExport";
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
|
||||
// CS 목록과 동일하게 한 페이지에는 최대 10개 시도만 표시한다.
|
||||
const AGED_PIPE_STATUS_PAGE_SIZE = 10;
|
||||
|
||||
export default function AgedPipeStatusByCtpvPopupPage() {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
// 조회조건 입력 상태다. 기본값 30년, 최근 연도 기준 단년 조회로 시작한다.
|
||||
const [filters, setFilters] = useState<AgedPipeStatusByCtpvFilters>(() =>
|
||||
normalizeAgedPipeStatusByCtpvFilters({
|
||||
@@ -35,11 +42,27 @@ export default function AgedPipeStatusByCtpvPopupPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// 사용자에게 현재 다운로드 상태를 보여주는 진행 문구다.
|
||||
const [downloadProgressText, setDownloadProgressText] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const yearOptions = useMemo(() => buildAgedPipeStatusYearOptions(), []);
|
||||
const headList = useMemo(() => buildAgedPipeStatusByCtpvHeadList(years), [years]);
|
||||
const headList = useMemo(
|
||||
() => [
|
||||
{ id: "rowNumber", title: "순번", widthPercent: 7 },
|
||||
...buildAgedPipeStatusByCtpvHeadList(years),
|
||||
],
|
||||
[years],
|
||||
);
|
||||
const pageRows = useMemo(
|
||||
() =>
|
||||
rows.slice(
|
||||
(currentPage - 1) * AGED_PIPE_STATUS_PAGE_SIZE,
|
||||
currentPage * AGED_PIPE_STATUS_PAGE_SIZE,
|
||||
),
|
||||
[currentPage, rows],
|
||||
);
|
||||
|
||||
const loadRows = useCallback(async (nextFilters: AgedPipeStatusByCtpvFilters) => {
|
||||
const loadRows = useCallback(
|
||||
async (nextFilters: AgedPipeStatusByCtpvFilters) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const normalized = normalizeAgedPipeStatusByCtpvFilters(nextFilters);
|
||||
@@ -51,9 +74,12 @@ export default function AgedPipeStatusByCtpvPopupPage() {
|
||||
const rawResponse = await getRawJson(
|
||||
`v1/swagePpl/aged-status-by-ctpv?${queryParams.toString()}`,
|
||||
);
|
||||
const response = normalizeAgedPipeStatusByCtpvResponse(rawResponse) as AgedPipeStatusByCtpvResponse;
|
||||
const response = normalizeAgedPipeStatusByCtpvResponse(
|
||||
rawResponse,
|
||||
) as AgedPipeStatusByCtpvResponse;
|
||||
setYears(Array.isArray(response?.years) ? response.years : []);
|
||||
setRows(toAgedPipeStatusByCtpvRows(response));
|
||||
setCurrentPage(1);
|
||||
} catch (error) {
|
||||
console.error("시도별 노후관로현황 조회 중 오류:", error);
|
||||
setYears([]);
|
||||
@@ -61,7 +87,9 @@ export default function AgedPipeStatusByCtpvPopupPage() {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRows(filters);
|
||||
@@ -70,7 +98,9 @@ export default function AgedPipeStatusByCtpvPopupPage() {
|
||||
const handleSearch = async () => {
|
||||
const normalized = normalizeAgedPipeStatusByCtpvFilters(filters);
|
||||
if (!validateAgedPipeStatusByCtpvFilters(normalized)) {
|
||||
alert("기준년도는 최대 3년 범위 내에서 선택하고, 경과년도는 1~100 사이로 입력해주세요.");
|
||||
alert(
|
||||
"기준년도는 최대 3년 범위 내에서 선택하고, 경과년도는 1~100 사이로 입력해주세요.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setFilters(normalized);
|
||||
@@ -97,25 +127,25 @@ export default function AgedPipeStatusByCtpvPopupPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="sn-popup-page w-full max-w-full overflow-x-hidden overflow-y-hidden">
|
||||
<StandardPopupLayout
|
||||
title={windowPopupDefinitions.agedPipeStatusByCtpvPopup.title}
|
||||
bodyClassName="overflow-x-hidden"
|
||||
>
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<header className="sn-popup-header">
|
||||
<h1 className="sn-popup-title">시도별 노후관로현황</h1>
|
||||
<div />
|
||||
</header>
|
||||
|
||||
{downloadProgressText ? (
|
||||
<div className="sn-popup-success-bar">
|
||||
<div className="rounded-sm border border-primary bg-primary-transparent px-4 py-3 text-[1.3rem] text-primary">
|
||||
{downloadProgressText}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="flex min-h-0 flex-1 flex-col overflow-x-hidden px-4 pb-3 pt-3">
|
||||
<div className="sn-popup-filter-panel-compact grid grid-cols-1 gap-3 md:grid-cols-[160px_160px_160px_auto] md:items-end">
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">기준년도 시작</div>
|
||||
<PopupSearchPanel contentClassName="flex flex-wrap items-end gap-x-5 gap-y-3">
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<span className="whitespace-nowrap text-xl font-medium text-label">
|
||||
기준년도 시작
|
||||
</span>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl"
|
||||
value={filters.startYear}
|
||||
onChange={(event) =>
|
||||
setFilters((current) =>
|
||||
@@ -127,15 +157,19 @@ export default function AgedPipeStatusByCtpvPopupPage() {
|
||||
}
|
||||
>
|
||||
{yearOptions.map((year) => (
|
||||
<option key={year} value={year}>{year}</option>
|
||||
<option key={year} value={year}>
|
||||
{year}
|
||||
</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="whitespace-nowrap text-xl font-medium text-label">
|
||||
기준년도 종료
|
||||
</span>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl"
|
||||
value={filters.endYear}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
@@ -145,13 +179,17 @@ export default function AgedPipeStatusByCtpvPopupPage() {
|
||||
}
|
||||
>
|
||||
{yearOptions.map((year) => (
|
||||
<option key={year} value={year}>{year}</option>
|
||||
<option key={year} value={year}>
|
||||
{year}
|
||||
</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="whitespace-nowrap text-xl font-medium text-label">
|
||||
노후관로 기준(년)
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
@@ -162,49 +200,50 @@ export default function AgedPipeStatusByCtpvPopupPage() {
|
||||
elapsedYears: Number(event.target.value.replace(/[^0-9]/g, "")),
|
||||
}))
|
||||
}
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
className="w-[12rem]"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void handleSearch();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="sn-popup-actions md:pr-3">
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
color="primary"
|
||||
className="sn-popup-primary-button !text-[1.1rem]"
|
||||
>
|
||||
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
|
||||
<Button onClick={handleSearch} color="primary">
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
color="primary"
|
||||
className="sn-popup-primary-button !text-[1.1rem]"
|
||||
color="white"
|
||||
onClick={() => {
|
||||
const resetFilters = normalizeAgedPipeStatusByCtpvFilters({
|
||||
elapsedYears: AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
|
||||
});
|
||||
setFilters(resetFilters);
|
||||
void loadRows(resetFilters);
|
||||
}}
|
||||
>
|
||||
엑셀 다운로드
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopupSearchPanel>
|
||||
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-x-auto overflow-y-auto pt-2">
|
||||
<UiTable<AgedPipeStatusByCtpvRow>
|
||||
// 조회 결과 총 {rows.length.toLocaleString()}건
|
||||
headName={<>시도별 노후관로현황 </>}
|
||||
tableData={rows}
|
||||
<MapPopupDataTable
|
||||
rows={
|
||||
pageRows as Array<AgedPipeStatusByCtpvRow & Record<string, unknown>>
|
||||
}
|
||||
headlist={headList}
|
||||
pageSize={AGED_PIPE_STATUS_BY_CTPV_PAGE_SIZE}
|
||||
title="시도별 노후관로 목록"
|
||||
total={rows.length}
|
||||
cellClick={() => undefined}
|
||||
onPageChange={() => undefined}
|
||||
curPage={1}
|
||||
index={-1}
|
||||
widthFixed={true}
|
||||
isHideCount={true}
|
||||
cellClassName={() => "text-[1.2rem]"}
|
||||
page={currentPage}
|
||||
pageSize={AGED_PIPE_STATUS_PAGE_SIZE}
|
||||
onPageChange={setCurrentPage}
|
||||
scrollMode={years.length > 2 ? "horizontal" : "none"}
|
||||
minTableWidth={years.length > 2 ? "120rem" : "100%"}
|
||||
actions={
|
||||
<Button type="button" onClick={handleDownload}>
|
||||
엑셀다운로드
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
+39
-16
@@ -44,14 +44,21 @@ export const normalizeAgedPipeStatusByCtpvResponse = (
|
||||
): AgedPipeStatusByCtpvResponse => ({
|
||||
startYear: Number(response?.startYear ?? 0),
|
||||
endYear: Number(response?.endYear ?? 0),
|
||||
elapsedYears: Number(response?.elapsedYears ?? AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS),
|
||||
years: Array.isArray(response?.years) ? response.years.map((year) => Number(year)) : [],
|
||||
elapsedYears: Number(
|
||||
response?.elapsedYears ?? AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
|
||||
),
|
||||
years: Array.isArray(response?.years)
|
||||
? response.years.map((year) => Number(year))
|
||||
: [],
|
||||
rows: Array.isArray(response?.rows) ? response.rows : [],
|
||||
});
|
||||
|
||||
export const buildAgedPipeStatusYearOptions = () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
return Array.from({ length: currentYear - 1900 + 1 }, (_, index) => currentYear - index);
|
||||
return Array.from(
|
||||
{ length: currentYear - 1900 + 1 },
|
||||
(_, index) => currentYear - index,
|
||||
);
|
||||
};
|
||||
|
||||
export const normalizeAgedPipeStatusByCtpvFilters = (
|
||||
@@ -61,12 +68,18 @@ export const normalizeAgedPipeStatusByCtpvFilters = (
|
||||
let startYear = Math.trunc(Number(filters.startYear ?? currentYear));
|
||||
let endYear = Math.trunc(Number(filters.endYear ?? startYear));
|
||||
let elapsedYears = Math.trunc(
|
||||
Number(filters.elapsedYears ?? AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS),
|
||||
Number(
|
||||
filters.elapsedYears ?? AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
|
||||
),
|
||||
);
|
||||
|
||||
if (!Number.isFinite(startYear)) startYear = currentYear;
|
||||
if (!Number.isFinite(endYear)) endYear = startYear;
|
||||
if (!Number.isFinite(elapsedYears) || elapsedYears < 1 || elapsedYears > 100) {
|
||||
if (
|
||||
!Number.isFinite(elapsedYears) ||
|
||||
elapsedYears < 1 ||
|
||||
elapsedYears > 100
|
||||
) {
|
||||
elapsedYears = AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS;
|
||||
}
|
||||
if (startYear > endYear) {
|
||||
@@ -85,9 +98,15 @@ export const normalizeAgedPipeStatusByCtpvFilters = (
|
||||
};
|
||||
};
|
||||
|
||||
export const validateAgedPipeStatusByCtpvFilters = (filters: AgedPipeStatusByCtpvFilters) => {
|
||||
export const validateAgedPipeStatusByCtpvFilters = (
|
||||
filters: AgedPipeStatusByCtpvFilters,
|
||||
) => {
|
||||
if (filters.startYear > filters.endYear) return false;
|
||||
if (filters.endYear - filters.startYear + 1 > AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN) return false;
|
||||
if (
|
||||
filters.endYear - filters.startYear + 1 >
|
||||
AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN
|
||||
)
|
||||
return false;
|
||||
return filters.elapsedYears >= 1 && filters.elapsedYears <= 100;
|
||||
};
|
||||
|
||||
@@ -109,18 +128,25 @@ export const toAgedPipeStatusByCtpvRows = (
|
||||
};
|
||||
|
||||
(row.yearStats ?? []).forEach((yearStat) => {
|
||||
displayRow[`year${yearStat.year}TotalLengthKm`] = formatMetric(Number(yearStat.totalLengthKm ?? 0));
|
||||
displayRow[`year${yearStat.year}AgedLengthKm`] = formatMetric(Number(yearStat.agedLengthKm ?? 0));
|
||||
displayRow[`year${yearStat.year}AgedRate`] = formatMetric(Number(yearStat.agedRate ?? 0));
|
||||
displayRow[`year${yearStat.year}TotalLengthKm`] = formatMetric(
|
||||
Number(yearStat.totalLengthKm ?? 0),
|
||||
);
|
||||
displayRow[`year${yearStat.year}AgedLengthKm`] = formatMetric(
|
||||
Number(yearStat.agedLengthKm ?? 0),
|
||||
);
|
||||
displayRow[`year${yearStat.year}AgedRate`] = formatMetric(
|
||||
Number(yearStat.agedRate ?? 0),
|
||||
);
|
||||
});
|
||||
|
||||
return displayRow;
|
||||
});
|
||||
|
||||
export const buildAgedPipeStatusByCtpvHeadList = (years: number[]): UiHeadProps[] => {
|
||||
export const buildAgedPipeStatusByCtpvHeadList = (
|
||||
years: number[],
|
||||
): UiHeadProps[] => {
|
||||
const heads: UiHeadProps[] = [
|
||||
// { id: "rowNumber", title: "NO", widthPercent: 6, sortable: false },
|
||||
{ id: "ctpvNm", title: "시도", widthPercent: 14, sortable: false },
|
||||
{ id: "ctpvNm", title: "시도", widthPercent: 14 },
|
||||
];
|
||||
|
||||
years.forEach((year) => {
|
||||
@@ -129,21 +155,18 @@ export const buildAgedPipeStatusByCtpvHeadList = (years: number[]): UiHeadProps[
|
||||
id: `year${year}TotalLengthKm`,
|
||||
title: `${year} 전체관로연장(km)`,
|
||||
widthPercent: 13,
|
||||
sortable: false,
|
||||
align: "right",
|
||||
},
|
||||
{
|
||||
id: `year${year}AgedLengthKm`,
|
||||
title: `${year} 노후관로연장(km)`,
|
||||
widthPercent: 13,
|
||||
sortable: false,
|
||||
align: "right",
|
||||
},
|
||||
{
|
||||
id: `year${year}AgedRate`,
|
||||
title: `${year} 노후화율(%)`,
|
||||
widthPercent: 11,
|
||||
sortable: false,
|
||||
align: "right",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
TriangleAlert,
|
||||
Waves,
|
||||
} from "lucide-react";
|
||||
import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions";
|
||||
import {
|
||||
windowPopupDefinitions,
|
||||
type WindowPopupKey,
|
||||
} from "@/features/windowPopup/model/popupDefinitions";
|
||||
|
||||
type Props = {
|
||||
onOpenPopup: (popupKey: WindowPopupKey) => Window | null;
|
||||
@@ -58,23 +61,51 @@ const ANALYSIS_POPUP_ITEMS: Array<{
|
||||
icon?: ComponentType<{ className?: string }>;
|
||||
renderIcon?: (props: MenuIconProps) => ReactNode;
|
||||
}> = [
|
||||
{ key: "infoSearch", label: "정보검색", icon: Search },
|
||||
{ key: "pipeAnalysisPopup", label: "관로분석", icon: ChartColumnBig },
|
||||
{ key: "floodPredictionMapPopup", label: "침수예측현황", icon: Waves },
|
||||
{
|
||||
key: "agedPipeStatusByCtpvPopup",
|
||||
label: "시도별 노후관로현황",
|
||||
key: "infoSearch",
|
||||
label: windowPopupDefinitions.mapSearch.title,
|
||||
icon: Search,
|
||||
},
|
||||
{
|
||||
key: "pipeAnalysisPopup",
|
||||
label: windowPopupDefinitions.pipeAnalysisPopup.title,
|
||||
icon: ChartColumnBig,
|
||||
},
|
||||
{ key: "gisEditPopup", label: "GIS 편집", icon: PencilRuler },
|
||||
{ key: "gisManagementPopup", label: "GIS 관리", icon: Settings2 },
|
||||
{ key: "flowRiskPopup", label: "통수 위험", icon: TriangleAlert },
|
||||
{
|
||||
key: "floodPredictionMapPopup",
|
||||
label: windowPopupDefinitions.floodPredictionMapPopup.title,
|
||||
icon: Waves,
|
||||
},
|
||||
{
|
||||
key: "agedPipeStatusByCtpvPopup",
|
||||
label: windowPopupDefinitions.agedPipeStatusByCtpvPopup.title,
|
||||
icon: ChartColumnBig,
|
||||
},
|
||||
{
|
||||
key: "gisEditPopup",
|
||||
label: windowPopupDefinitions.gisEditPopup.title,
|
||||
icon: PencilRuler,
|
||||
},
|
||||
{
|
||||
key: "gisManagementPopup",
|
||||
label: windowPopupDefinitions.gisManagementPopup.title,
|
||||
icon: Settings2,
|
||||
},
|
||||
{
|
||||
key: "flowRiskPopup",
|
||||
label: windowPopupDefinitions.flowRiskPopup.title,
|
||||
icon: TriangleAlert,
|
||||
},
|
||||
{
|
||||
key: "inftwtResultPopup",
|
||||
label: "침입수 분석결과",
|
||||
label: windowPopupDefinitions.inftwtResultPopup.title,
|
||||
renderIcon: InftwtMenuIcon,
|
||||
},
|
||||
{ key: "rdiiResultPopup", label: "RDII 분석결과", icon: ChartColumnBig },
|
||||
{
|
||||
key: "rdiiResultPopup",
|
||||
label: windowPopupDefinitions.rdiiResultPopup.title,
|
||||
icon: ChartColumnBig,
|
||||
},
|
||||
];
|
||||
|
||||
export default function AnalysisPopupMenu({
|
||||
|
||||
@@ -32,7 +32,7 @@ export const MsSwagePplCol = [
|
||||
]
|
||||
// 하수관로 searchModal 기본정보tab
|
||||
export const MsSwagePplCol2 = [
|
||||
{ id: 'rowNumber', title: '일련\n번호' },
|
||||
{ id: 'rowNumber', title: '순번' },
|
||||
{ id: 'fcltMngNo', title: '시설\n관리번호' },
|
||||
{ id: 'instMngNo', title: '기관\n관리번호' },
|
||||
{ id: 'flowDirectionBasis', title: '유하방향\n근거' },
|
||||
@@ -398,7 +398,7 @@ export const SwtrmtFcltCol = [
|
||||
// { id: 'obsrvnSptCd', title: '관측지점코드' },
|
||||
];
|
||||
export const SwtrmtFcltCol2 = [
|
||||
{ id: 'rowNumber', title: '일련\n번호' },
|
||||
{ id: 'rowNumber', title: '순번' },
|
||||
{ id: 'swtrmtFcltNm', title: '하수처리\n시설명' },
|
||||
{ id: 'fcltCpct', title: '시설\n용량\n(㎥/d)' },
|
||||
{ id: 'rgnExpln', title: '지역설명' },
|
||||
@@ -461,7 +461,7 @@ export const SwtrmtPrcsZoneCol = [
|
||||
{ id: 'swtrmt_fclt_nm', title: '하수처리시설명' },
|
||||
];
|
||||
export const SwtrmtPrcsZoneCol2 = [
|
||||
{ id: 'rowNumber', title: '일련번호' },
|
||||
{ id: 'rowNumber', title: '순번' },
|
||||
// { id: 'swtrmtZoneSn', title: '하수처리구역 일련번호' },
|
||||
{ id: 'swtrmtZoneNm', title: '하수처리구역명' },
|
||||
// { id: 'swtrmt_fclt_cd', title: '하수처리시설코드' },
|
||||
@@ -500,7 +500,7 @@ export const FldngWtlvMapCol = [
|
||||
{ id: 'rain_str', title: '강우강도(mm/hr)' },
|
||||
];
|
||||
export const FldngWtlvMapCol2 = [
|
||||
{ id: 'rowNumber', title: '일련번호' },
|
||||
{ id: 'rowNumber', title: '순번' },
|
||||
{ id: 'fldngYr', title: '침수년도' },
|
||||
{ id: 'fldngDstrNm', title: '침수재해명' },
|
||||
{ id: 'ctpvNm', title: '시도' },
|
||||
@@ -561,7 +561,7 @@ export const MsSwageMhlCol = [
|
||||
// { id: 'inst_mng_no', title: '기관관리번호' }
|
||||
];
|
||||
export const MsSwageMhlCol2 = [
|
||||
{ id: 'rowNumber', title: '일련번호' },
|
||||
{ id: 'rowNumber', title: '순번' },
|
||||
// { id: 'fid', title: 'FID' },
|
||||
// { id: 'geom', title: 'geometry' },
|
||||
// { id: 'ftreSmbolCd', title: '지형지물부호코드' },
|
||||
@@ -683,7 +683,7 @@ export const ExclnSewagePumpCol = [
|
||||
// { id: 'instMngNo', title: '기관관리번호' },
|
||||
]
|
||||
export const ExclnSewagePumpCol2 = [
|
||||
{ id: 'rowNumber', title: '일련번호' },
|
||||
{ id: 'rowNumber', title: '순번' },
|
||||
// { id: 'fid', title: 'FID' },
|
||||
// { id: 'geom', title: 'geometry' },
|
||||
// { id: 'ftreSmbolCd', title: '지형지물부호코드' },
|
||||
@@ -739,7 +739,7 @@ export const DrtwSewagePumpCol = [
|
||||
// { id: 'instMngNo', title: '기관관리번호' },
|
||||
]
|
||||
export const DrtwSewagePumpCol2 = [
|
||||
{ id: 'rowNumber', title: '일련번호' },
|
||||
{ id: 'rowNumber', title: '순번' },
|
||||
// { id: 'fid', title: 'FID' },
|
||||
// { id: 'geom', title: 'geometry' },
|
||||
// { id: 'ftreSmbolCd', title: '지형지물부호코드' },
|
||||
@@ -788,7 +788,7 @@ export const stofcbrCol = [
|
||||
{ id: 'drtn_angl', title: '방향각도(°)' },
|
||||
]
|
||||
export const stofcbrCol2 = [
|
||||
{ id: 'rowNumber', title: '일련번호' },
|
||||
{ id: 'rowNumber', title: '순번' },
|
||||
// { id: 'ftreSmbolCd', title: '지형지물부호코드' },
|
||||
{ id: 'fcltMngNo', title: '시설관리번호' },
|
||||
// { id: 'pbadmsEmdCd', title: '행정읍면동코드' },
|
||||
|
||||
@@ -23,14 +23,17 @@ export default function ExmnLayerSettingPanel({
|
||||
});
|
||||
};
|
||||
const selectClass =
|
||||
"h-[24px] w-[54px] rounded-[4px] border border-[#ccd6df] bg-white px-2 text-[1rem] text-[#222] outline-none focus:border-primary";
|
||||
const labelClass = "shrink-0 text-[1.05rem] font-medium text-slate-700";
|
||||
"h-[24px] w-[54px] rounded-[4px] border border-input bg-background px-2 text-[1rem] text-foreground outline-none focus:border-primary";
|
||||
const labelClass = "shrink-0 text-[1.05rem] font-medium text-label";
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-[6px] border border-[#dbe7f5] bg-white px-2 py-[9px] shadow-[0_2px_8px_rgba(15,23,42,0.05)]">
|
||||
<div className="w-full rounded-[6px] border border-border bg-background px-2 py-[9px] shadow-blur">
|
||||
<div className="mb-2 flex items-center gap-1">
|
||||
<span className={`h-2 w-2 rounded-full ${jobType === "PPL" ? "bg-[#facc15]" : "bg-[#8b5cf6]"}`} />
|
||||
<span className="text-[1.1rem] font-semibold text-slate-700">
|
||||
{/* 관로/맨홀 조사유형을 구분하는 지도 범례 의미색이므로 고정색을 유지한다. */}
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${jobType === "PPL" ? "bg-[#facc15]" : "bg-[#8b5cf6]"}`}
|
||||
/>
|
||||
<span className="text-[1.1rem] font-semibold text-label">
|
||||
{jobType === "PPL" ? "관로조사" : "맨홀조사"}
|
||||
</span>
|
||||
</div>
|
||||
@@ -52,7 +55,9 @@ export default function ExmnLayerSettingPanel({
|
||||
<select
|
||||
className={selectClass}
|
||||
value={filters.structuralGrade}
|
||||
onChange={(event) => updateFilter("structuralGrade", event.target.value)}
|
||||
onChange={(event) =>
|
||||
updateFilter("structuralGrade", event.target.value)
|
||||
}
|
||||
>
|
||||
{GRADE_OPTIONS.map((grade) => (
|
||||
<option key={grade || "all"} value={grade}>
|
||||
@@ -66,7 +71,9 @@ export default function ExmnLayerSettingPanel({
|
||||
<select
|
||||
className={selectClass}
|
||||
value={filters.operationalGrade}
|
||||
onChange={(event) => updateFilter("operationalGrade", event.target.value)}
|
||||
onChange={(event) =>
|
||||
updateFilter("operationalGrade", event.target.value)
|
||||
}
|
||||
>
|
||||
{GRADE_OPTIONS.map((grade) => (
|
||||
<option key={grade || "all"} value={grade}>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
|
||||
import { getExmnPipeReportAvailability } from "@/features/exmn";
|
||||
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
|
||||
type ExmnSurveyResultSectionProps = {
|
||||
featureData: any;
|
||||
@@ -31,7 +32,12 @@ const readFeatureProperty = (featureData: any, keys: string[]) =>
|
||||
|
||||
const readBooleanFeatureProperty = (featureData: any, keys: string[]) => {
|
||||
const value = readRawFeatureProperty(featureData, keys);
|
||||
return value === true || String(value ?? "").trim().toLowerCase() === "true";
|
||||
return (
|
||||
value === true ||
|
||||
String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase() === "true"
|
||||
);
|
||||
};
|
||||
|
||||
const displayValue = (value: string) => value || "-";
|
||||
@@ -39,6 +45,7 @@ const displayValue = (value: string) => value || "-";
|
||||
export default function ExmnSurveyResultSection({
|
||||
featureData,
|
||||
}: ExmnSurveyResultSectionProps) {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const exmnBizId = readFeatureProperty(featureData, ["exmnBizId"]);
|
||||
const jobType = readFeatureProperty(featureData, ["jobType"]);
|
||||
const pipeNumber = readFeatureProperty(featureData, [
|
||||
@@ -49,7 +56,8 @@ export default function ExmnSurveyResultSection({
|
||||
const reportAvailable = readBooleanFeatureProperty(featureData, [
|
||||
"reportAvailable",
|
||||
]);
|
||||
const canOpenPipeReport = jobType === "PPL" && Boolean(exmnBizId && pipeNumber);
|
||||
const canOpenPipeReport =
|
||||
jobType === "PPL" && Boolean(exmnBizId && pipeNumber);
|
||||
|
||||
const openPipeReport = async () => {
|
||||
if (!canOpenPipeReport) return;
|
||||
@@ -65,8 +73,8 @@ export default function ExmnSurveyResultSection({
|
||||
);
|
||||
if (!availability.available) {
|
||||
alert(
|
||||
availability.message?.trim()
|
||||
|| `선택한 관로번호의 조사보고서가 등록된 PDF에 없습니다: ${pipeNumber}`,
|
||||
availability.message?.trim() ||
|
||||
`선택한 관로번호의 조사보고서가 등록된 PDF에 없습니다: ${pipeNumber}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -77,7 +85,9 @@ export default function ExmnSurveyResultSection({
|
||||
reportNo: readFeatureProperty(featureData, ["reportNo"]),
|
||||
});
|
||||
if (!reportWindow) {
|
||||
alert("팝업이 차단되었습니다. 브라우저의 팝업 허용 설정을 확인해주세요.");
|
||||
alert(
|
||||
"팝업이 차단되었습니다. 브라우저의 팝업 허용 설정을 확인해주세요.",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
alert(
|
||||
@@ -99,14 +109,14 @@ export default function ExmnSurveyResultSection({
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="border-b border-[#d9e2ec] bg-white">
|
||||
<section className="border-b border-border bg-background">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="text-2xl mx-3 font-medium text-label">조사 결과</h3>
|
||||
<h3 className="mx-3 text-2xl font-medium text-label">조사 결과</h3>
|
||||
{jobType === "PPL" && (
|
||||
<Button
|
||||
onClick={openPipeReport}
|
||||
disabled={!canOpenPipeReport}
|
||||
className="px-4 mr-3 py-2 bg-[#16c784] text-white rounded-sm text-sm font-medium hover:bg-[#12ad72] disabled:cursor-not-allowed disabled:bg-[#a7d9c6]"
|
||||
className="hover:bg-primary/90 mr-3 rounded-sm bg-primary px-4 py-2 text-sm font-medium text-primary-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
조사보고서 보기
|
||||
</Button>
|
||||
@@ -118,11 +128,11 @@ export default function ExmnSurveyResultSection({
|
||||
<TableBody className="text-center">
|
||||
{rows.map(([label, value]) => (
|
||||
<TableRow key={label}>
|
||||
<TableCell className="w-[120px] whitespace-nowrap bg-blue-100 text-xs font-bold">
|
||||
<TableCell className="w-[120px] whitespace-nowrap bg-secondary text-xs font-bold text-label">
|
||||
{label}
|
||||
</TableCell>
|
||||
<TableCell className="break-all text-left text-xs">
|
||||
{displayValue(value)}
|
||||
{displayValue(value ?? "")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { formatFloodArea, formatFloodDepth, type FldngHstryItem } from "./fldngHstryTypes";
|
||||
import {
|
||||
formatFloodArea,
|
||||
formatFloodDepth,
|
||||
type FldngHstryItem,
|
||||
} from "./fldngHstryTypes";
|
||||
|
||||
type Props = {
|
||||
detail: FldngHstryItem;
|
||||
@@ -23,10 +27,10 @@ const renderRows = (rows: Array<[string, string]>) => (
|
||||
<tbody>
|
||||
{rows.map(([label, value], index) => (
|
||||
<tr key={`${label}-${index}`}>
|
||||
<th className="border border-[#d8e3ee] bg-[#f8fbfe] px-3 py-[7px] text-left text-[1.02rem] font-semibold text-[#36506b]">
|
||||
<th className="border border-border bg-secondary px-3 py-[7px] text-left text-[1.02rem] font-semibold text-label">
|
||||
{label}
|
||||
</th>
|
||||
<td className="border border-[#d8e3ee] bg-white px-3 py-[7px] text-[1.02rem] text-[#1f2937]">
|
||||
<td className="border border-border bg-background px-3 py-[7px] text-[1.02rem] text-foreground">
|
||||
{value}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -35,12 +39,17 @@ const renderRows = (rows: Array<[string, string]>) => (
|
||||
</table>
|
||||
);
|
||||
|
||||
export default function FldngHstryDetailPanel({ detail, onClose, position }: Props) {
|
||||
export default function FldngHstryDetailPanel({
|
||||
detail,
|
||||
onClose,
|
||||
position,
|
||||
}: Props) {
|
||||
// 지도에서 침수흔적도 polygon을 클릭했을 때, 상세정보는 업무 의미 기준으로 3개 탭에 나눠 보여준다.
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>("year");
|
||||
|
||||
const yearRows = useMemo(
|
||||
() => [
|
||||
() =>
|
||||
[
|
||||
["침수심", `${detail.floodDepthM.toFixed(2)} m`],
|
||||
["침수면적", formatFloodArea(detail.floodAreaHa)],
|
||||
["침수년도", `${detail.floodYear}년`],
|
||||
@@ -51,7 +60,8 @@ export default function FldngHstryDetailPanel({ detail, onClose, position }: Pro
|
||||
);
|
||||
|
||||
const disasterRows = useMemo(
|
||||
() => [
|
||||
() =>
|
||||
[
|
||||
["침수재해명", detail.disasterName],
|
||||
["침수면적", formatFloodArea(detail.floodAreaHa)],
|
||||
["침수원인상세명", detail.causeDetailName],
|
||||
@@ -63,7 +73,8 @@ export default function FldngHstryDetailPanel({ detail, onClose, position }: Pro
|
||||
);
|
||||
|
||||
const rainfallRows = useMemo(
|
||||
() => [
|
||||
() =>
|
||||
[
|
||||
["침수시작일자", detail.startDate],
|
||||
["침수종료일자", detail.endDate],
|
||||
["침수시작시간", detail.startTime],
|
||||
@@ -80,7 +91,8 @@ export default function FldngHstryDetailPanel({ detail, onClose, position }: Pro
|
||||
[detail],
|
||||
);
|
||||
|
||||
const currentRows = activeTab === "year"
|
||||
const currentRows =
|
||||
activeTab === "year"
|
||||
? yearRows
|
||||
: activeTab === "disaster"
|
||||
? disasterRows
|
||||
@@ -88,24 +100,26 @@ export default function FldngHstryDetailPanel({ detail, onClose, position }: Pro
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-[70] w-[248px] overflow-hidden rounded-[8px] border border-[#cfdbe8] bg-[rgba(255,255,255,0.98)] shadow-[0_14px_28px_rgba(15,23,42,0.18)]"
|
||||
className="absolute z-[70] w-[248px] overflow-hidden rounded-[8px] border border-border bg-background shadow-blur"
|
||||
style={{
|
||||
left: `${position.left}px`,
|
||||
top: `${position.top}px`,
|
||||
}}
|
||||
>
|
||||
<div className="flex h-[28px] items-center justify-between border-b border-[#d8e3ee] bg-[linear-gradient(180deg,#ffffff_0%,#f6faff_100%)] px-[10px]">
|
||||
<div className="text-[10px] font-bold tracking-[-0.01em] text-[#243b53]">상세정보</div>
|
||||
<div className="flex h-[28px] items-center justify-between border-b border-border bg-secondary px-[10px]">
|
||||
<div className="text-[10px] font-bold tracking-[-0.01em] text-label">
|
||||
상세정보
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-[4px] px-[4px] text-[13px] leading-none text-[#7b8ba1] transition hover:bg-[#eef5fb] hover:text-[#35597d]"
|
||||
className="rounded-[4px] px-[4px] text-[13px] leading-none text-muted-foreground transition hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-[#d8e3ee] bg-[#f8fbfe] px-[6px] pt-[6px]">
|
||||
<div className="border-b border-border bg-secondary px-[6px] pt-[6px]">
|
||||
<div className="flex gap-[4px]">
|
||||
{[
|
||||
["year", "년도탭"],
|
||||
@@ -120,8 +134,8 @@ export default function FldngHstryDetailPanel({ detail, onClose, position }: Pro
|
||||
type="button"
|
||||
className={`h-[24px] flex-1 rounded-t-[6px] border px-[6px] text-[9px] font-semibold leading-none transition ${
|
||||
isActive
|
||||
? "border-[#b8d4f0] border-b-white bg-white text-[#1f5d99]"
|
||||
: "border-[#d8e3ee] bg-[#f3f7fb] text-[#5f7388] hover:bg-[#eef5fb]"
|
||||
? "border-primary border-b-background bg-background text-primary"
|
||||
: "border-border bg-secondary text-muted-foreground hover:bg-accent"
|
||||
}`}
|
||||
onClick={() => setActiveTab(tabId as DetailTab)}
|
||||
>
|
||||
@@ -132,8 +146,8 @@ export default function FldngHstryDetailPanel({ detail, onClose, position }: Pro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-[6px]">
|
||||
<div className="overflow-hidden rounded-[6px] border border-[#d8e3ee] bg-white">
|
||||
<div className="bg-background p-[6px]">
|
||||
<div className="overflow-hidden rounded-[6px] border border-border bg-background">
|
||||
{renderRows(currentRows)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,13 +5,15 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Input } from "@common/components";
|
||||
import { UiTable } from "@common/business_components/ui/table";
|
||||
import type { UiTableSort } from "@common/business_components/ui/table";
|
||||
import { StandardPopupLayout } from "@common/business_components";
|
||||
import { getRawJson } from "@/components/lib/api";
|
||||
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import useStore from "@/store/useStore";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
import {
|
||||
FLOOD_HISTORY_LAYER_TYPE,
|
||||
FLDNG_HSTRY_DEFAULT_FILTERS,
|
||||
@@ -44,11 +46,6 @@ const resolveOptionText = (
|
||||
return options.find((item) => String(item.val).trim() === lookup)?.text ?? "";
|
||||
};
|
||||
|
||||
const resolveFldngHstryApiSort = (sort?: UiTableSort) => {
|
||||
if (!sort?.key || !sort.direction) return undefined;
|
||||
return { key: sort.key, direction: sort.direction } as UiTableSort;
|
||||
};
|
||||
|
||||
const resolveFldngHstryMapFeatureFid = (row: FldngHstryTableRow) => {
|
||||
const fid = String(row.fid ?? "").trim();
|
||||
if (fid.startsWith(`${FLOOD_HISTORY_LAYER_TYPE}.`)) return fid;
|
||||
@@ -90,16 +87,18 @@ const resolveRegionNames = (
|
||||
});
|
||||
|
||||
export default function FldngHstryPopupPage() {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
const [filters, setFilters] = useState<FldngHstryFilters>(FLDNG_HSTRY_DEFAULT_FILTERS);
|
||||
const [appliedFilters, setAppliedFilters] = useState<FldngHstryFilters>(FLDNG_HSTRY_DEFAULT_FILTERS);
|
||||
const [filters, setFilters] = useState<FldngHstryFilters>(
|
||||
FLDNG_HSTRY_DEFAULT_FILTERS,
|
||||
);
|
||||
const [appliedFilters, setAppliedFilters] = useState<FldngHstryFilters>(
|
||||
FLDNG_HSTRY_DEFAULT_FILTERS,
|
||||
);
|
||||
const [rows, setRows] = useState<FldngHstryTableRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
const [appliedSort, setAppliedSort] = useState<UiTableSort>();
|
||||
|
||||
useEffect(() => {
|
||||
if (ctpv.length === 0) void fetchCtpv();
|
||||
@@ -121,21 +120,27 @@ export default function FldngHstryPopupPage() {
|
||||
() =>
|
||||
filters.ctpvCd === "0"
|
||||
? sgg
|
||||
: sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))),
|
||||
: sgg.filter((item) =>
|
||||
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
|
||||
),
|
||||
[filters.ctpvCd, sgg],
|
||||
);
|
||||
|
||||
const fetchRows = useCallback(
|
||||
async (page: number, nextFilters: FldngHstryFilters, size: number, nextSort?: UiTableSort) => {
|
||||
async (page: number, nextFilters: FldngHstryFilters, size: number) => {
|
||||
const normalized = normalizeFldngHstryFilters(nextFilters);
|
||||
const queryString = buildFldngHstryListQueryString(
|
||||
normalized,
|
||||
page,
|
||||
size,
|
||||
resolveFldngHstryApiSort(nextSort),
|
||||
);
|
||||
const response = await getRawJson(`/sewerage-service/api/v1/fldng/hstry?${queryString}`);
|
||||
const payload = unwrapPayload<{ items?: FldngHstryApiItem[]; total?: number }>(response);
|
||||
const response = await getRawJson(
|
||||
`/sewerage-service/api/v1/fldng/hstry?${queryString}`,
|
||||
);
|
||||
const payload = unwrapPayload<{
|
||||
items?: FldngHstryApiItem[];
|
||||
total?: number;
|
||||
}>(response);
|
||||
const items = Array.isArray(payload?.items) ? payload.items : [];
|
||||
|
||||
const displayItems = items.map((item) => {
|
||||
@@ -156,32 +161,37 @@ export default function FldngHstryPopupPage() {
|
||||
);
|
||||
|
||||
const loadRows = useCallback(
|
||||
async (page: number, nextFilters: FldngHstryFilters, nextSort?: UiTableSort) => {
|
||||
async (page: number, nextFilters: FldngHstryFilters) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const normalized = normalizeFldngHstryFilters(nextFilters);
|
||||
const response = await fetchRows(page, normalized, FLDNG_HSTRY_PAGE_SIZE, nextSort);
|
||||
const response = await fetchRows(
|
||||
page,
|
||||
normalized,
|
||||
FLDNG_HSTRY_PAGE_SIZE,
|
||||
);
|
||||
setRows(response.rows);
|
||||
setTotal(response.total);
|
||||
setCurrentPage(page);
|
||||
setAppliedFilters(normalized);
|
||||
setSort(nextSort);
|
||||
setAppliedSort(nextSort);
|
||||
setSelectedIndex(-1);
|
||||
} catch (error) {
|
||||
console.error("침수발생이력 목록 조회 중 오류:", error);
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
alert(error instanceof Error ? error.message : "침수발생이력 목록 조회 중 오류가 발생했습니다.");
|
||||
alert(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "침수발생이력 목록 조회 중 오류가 발생했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[fetchRows],
|
||||
[alert, fetchRows],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRows(1, FLDNG_HSTRY_DEFAULT_FILTERS, undefined);
|
||||
void loadRows(1, FLDNG_HSTRY_DEFAULT_FILTERS);
|
||||
// 최초 로딩은 한 번만 수행한다.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
@@ -195,24 +205,14 @@ export default function FldngHstryPopupPage() {
|
||||
}
|
||||
|
||||
setFilters(normalized);
|
||||
await loadRows(page, normalized, sort);
|
||||
await loadRows(page, normalized);
|
||||
};
|
||||
|
||||
const handlePageChange = async (page: number) => {
|
||||
await loadRows(page, appliedFilters, appliedSort);
|
||||
await loadRows(page, appliedFilters);
|
||||
};
|
||||
|
||||
const handleSortChange = async (nextSort?: UiTableSort) => {
|
||||
await loadRows(1, appliedFilters, nextSort);
|
||||
};
|
||||
|
||||
const handleRowClick = (index?: number) => {
|
||||
if (typeof index !== "number") return;
|
||||
const row = rows[index];
|
||||
if (!row) return;
|
||||
|
||||
setSelectedIndex(index);
|
||||
|
||||
const handleRowClick = (row: FldngHstryTableRow) => {
|
||||
const mapFeatureFid = resolveFldngHstryMapFeatureFid(row);
|
||||
if (mapFeatureFid) {
|
||||
window.opener?.postMessage(
|
||||
@@ -250,7 +250,6 @@ export default function FldngHstryPopupPage() {
|
||||
1,
|
||||
appliedFilters,
|
||||
Math.max(total, FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE),
|
||||
appliedSort,
|
||||
);
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: buildDownloadRows(response.rows) as unknown as JSON[],
|
||||
@@ -261,26 +260,27 @@ export default function FldngHstryPopupPage() {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("침수발생이력 다운로드 중 오류:", error);
|
||||
alert(error instanceof Error ? error.message : "침수발생이력 다운로드 중 오류가 발생했습니다.");
|
||||
alert(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "침수발생이력 다운로드 중 오류가 발생했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="sn-popup-page w-full max-w-full overflow-x-hidden overflow-y-hidden">
|
||||
<StandardPopupLayout title="침수발생이력 목록">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<header className="sn-popup-header">
|
||||
<h1 className="sn-popup-title">침수발생이력</h1>
|
||||
<div />
|
||||
</header>
|
||||
|
||||
<section className="flex min-h-0 flex-1 flex-col overflow-x-hidden px-4 pb-3 pt-3">
|
||||
<div className="sn-popup-filter-panel-compact grid grid-cols-1 gap-3 md:grid-cols-[180px_180px_180px_minmax(0,1fr)_auto] md:items-end">
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">시도</div>
|
||||
<PopupSearchPanel contentClassName="flex flex-wrap items-end gap-x-5 gap-y-3">
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.3rem] text-label">
|
||||
시도
|
||||
</span>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
className="h-[3.2rem] w-[16rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
|
||||
value={filters.ctpvCd}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
@@ -299,10 +299,12 @@ export default function FldngHstryPopupPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">시군구</div>
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.3rem] text-label">
|
||||
시군구
|
||||
</span>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
className="h-[3.2rem] w-[16rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
|
||||
value={filters.sggCd}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
@@ -320,10 +322,12 @@ export default function FldngHstryPopupPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">침수발생년도</div>
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.3rem] text-label">
|
||||
침수발생년도
|
||||
</span>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
|
||||
value={filters.floodYear}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
@@ -341,10 +345,12 @@ export default function FldngHstryPopupPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="sn-popup-field">
|
||||
<div className="sn-popup-field-label">침수재해명</div>
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.3rem] text-label">
|
||||
침수재해명
|
||||
</span>
|
||||
<Input
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
className="w-[20rem]"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={filters.disasterName}
|
||||
@@ -360,50 +366,49 @@ export default function FldngHstryPopupPage() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="sn-popup-actions md:pr-3">
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="sn-popup-primary-button !text-[1.1rem]"
|
||||
onClick={() => void handleSearch(1)}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="sn-popup-primary-button !text-[1.1rem]"
|
||||
onClick={() => void handleDownload()}
|
||||
color="white"
|
||||
onClick={() => {
|
||||
setFilters(FLDNG_HSTRY_DEFAULT_FILTERS);
|
||||
void loadRows(1, FLDNG_HSTRY_DEFAULT_FILTERS);
|
||||
}}
|
||||
>
|
||||
다운로드
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopupSearchPanel>
|
||||
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto pt-2">
|
||||
<UiTable<FldngHstryTableRow>
|
||||
headName={<>침수발생이력 조회 결과 총 {total.toLocaleString()}건</>}
|
||||
tableData={rows}
|
||||
<MapPopupDataTable
|
||||
rows={rows as Array<FldngHstryTableRow & Record<string, unknown>>}
|
||||
headlist={FLDNG_HSTRY_LIST_HEADLIST}
|
||||
pageSize={FLDNG_HSTRY_PAGE_SIZE}
|
||||
title="침수발생이력 목록"
|
||||
total={total}
|
||||
cellClick={(index) => handleRowClick(index)}
|
||||
page={currentPage}
|
||||
pageSize={FLDNG_HSTRY_PAGE_SIZE}
|
||||
onPageChange={(page) => void handlePageChange(page)}
|
||||
curPage={currentPage}
|
||||
index={selectedIndex}
|
||||
widthFixed={true}
|
||||
isHideCount={true}
|
||||
sort={sort}
|
||||
onSortChange={(nextSort) => void handleSortChange(nextSort)}
|
||||
onRowClick={handleRowClick}
|
||||
rowId={(row, index) => String(row.fid ?? index)}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="220rem"
|
||||
actions={
|
||||
<Button type="button" onClick={() => void handleDownload()}>
|
||||
엑셀다운로드
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sn-popup-help-text">
|
||||
행 클릭 시 메인 지도에 미리 표시되고, 더블클릭하면 팝업을 닫습니다. 상세정보는 지도 위 침수흔적 영역을 클릭해 확인합니다.
|
||||
<div className="rounded-sm bg-secondary px-4 py-3 text-[1.2rem] text-muted-foreground">
|
||||
행을 클릭하면 메인 지도에서 해당 침수흔적을 표시합니다.
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { UiHeadProps, UiTableSort } from "@common/business_components/ui";
|
||||
import type { UiHeadProps } from "@common/business_components/ui";
|
||||
|
||||
export const FLOOD_HISTORY_POPUP_KEY = "fldngHstryPopup";
|
||||
export const FLDNG_HSTRY_ROW_SELECT_MESSAGE = "SN_FLOOD_HISTORY_ROW_SELECT";
|
||||
@@ -119,27 +119,25 @@ export const FLDNG_HSTRY_DEPTH_LEGEND_ITEMS = [
|
||||
] as const;
|
||||
|
||||
export const FLDNG_HSTRY_LIST_HEADLIST: UiHeadProps[] = [
|
||||
{ id: "rowNo", title: "NO", widthPercent: 5, sortable: false },
|
||||
{ id: "floodYearDisplay", title: "침수\n년도", widthPercent: 6, sortable: true, sortKey: "fldngYr" },
|
||||
{ id: "ctpvNm", title: "시도", widthPercent: 7, sortable: true, sortKey: "ctpvCd" },
|
||||
{ id: "sggNm", title: "시군구", widthPercent: 8, sortable: true, sortKey: "sggCd" },
|
||||
{ id: "disasterName", title: "침수재해명", widthPercent: 14, sortable: true, sortKey: "fldngDstrNm" },
|
||||
{ id: "rainBgngTmDisplay", title: "강우\n시작일시", widthPercent: 10, sortable: true, sortKey: "rainBgngTm" },
|
||||
{ id: "rainEndTmDisplay", title: "강우\n종료일시", widthPercent: 10, sortable: true, sortKey: "rainEndTm" },
|
||||
{ id: "rainTotDisplay", title: "총강우량(mm)", widthPercent: 8, sortable: true, sortKey: "rainTot" },
|
||||
{ id: "rainStrDisplay", title: "강우강도(mm/hr)", widthPercent: 8, sortable: true, sortKey: "rainStr" },
|
||||
{ id: "floodHourDisplay", title: "침수시간", widthPercent: 7, sortable: true, sortKey: "fldngHr" },
|
||||
{ id: "districtName", title: "침수지구명", widthPercent: 10, sortable: true, sortKey: "fldngDstrctNm" },
|
||||
//{ id: "causeDetailCode", title: "침수원인상세코드", widthPercent: 8, sortable: true, sortKey: "fldngCsDtlCd" },
|
||||
//{ id: "causeDetailName", title: "침수원인상세", widthPercent: 12, sortable: false },
|
||||
{ id: "avgFloodDepthDisplay", title: "평균\n침수위", widthPercent: 7, sortable: true, sortKey: "avgFldngWtlv" },
|
||||
{ id: "floodAreaDisplay", title: "침수\n면적\(ha)", widthPercent: 7, sortable: true, sortKey: "fldngArea" },
|
||||
{ id: "exmnYearDisplay", title: "조사년도", widthPercent: 6, sortable: true, sortKey: "exmnYr" },
|
||||
{ id: "damageAmountDisplay", title: "피해금액", widthPercent: 9, sortable: true, sortKey: "damAmt" },
|
||||
{ id: "rowNo", title: "순번", widthPercent: 5 },
|
||||
{ id: "floodYearDisplay", title: "침수\n년도", widthPercent: 6 },
|
||||
{ id: "ctpvNm", title: "시도", widthPercent: 7 },
|
||||
{ id: "sggNm", title: "시군구", widthPercent: 8 },
|
||||
{ id: "disasterName", title: "침수재해명", widthPercent: 14 },
|
||||
{ id: "rainBgngTmDisplay", title: "강우\n시작일시", widthPercent: 10 },
|
||||
{ id: "rainEndTmDisplay", title: "강우\n종료일시", widthPercent: 10 },
|
||||
{ id: "rainTotDisplay", title: "총강우량(mm)", widthPercent: 8 },
|
||||
{ id: "rainStrDisplay", title: "강우강도(mm/hr)", widthPercent: 8 },
|
||||
{ id: "floodHourDisplay", title: "침수시간", widthPercent: 7 },
|
||||
{ id: "districtName", title: "침수지구명", widthPercent: 10 },
|
||||
{ id: "avgFloodDepthDisplay", title: "평균\n침수위", widthPercent: 7 },
|
||||
{ id: "floodAreaDisplay", title: "침수\n면적\(ha)", widthPercent: 7 },
|
||||
{ id: "exmnYearDisplay", title: "조사년도", widthPercent: 6 },
|
||||
{ id: "damageAmountDisplay", title: "피해금액", widthPercent: 9 },
|
||||
];
|
||||
|
||||
export const FLDNG_HSTRY_DOWNLOAD_HEADERS: Record<string, string> = {
|
||||
rowNo: "NO",
|
||||
rowNo: "순번",
|
||||
floodYearDisplay: "침수년도",
|
||||
ctpvNm: "시도",
|
||||
sggNm: "시군구",
|
||||
@@ -183,11 +181,14 @@ const createIrregularRing = ({
|
||||
return [centerX + jitterX, centerY + jitterY] as [number, number];
|
||||
});
|
||||
|
||||
return [...points, points[0]];
|
||||
return points.length > 0 ? [...points, points[0]!] : [];
|
||||
};
|
||||
|
||||
const toNumericSeed = (value: string) =>
|
||||
Array.from(value).reduce((sum, char, index) => sum + char.charCodeAt(0) * (index + 1), 0);
|
||||
Array.from(value).reduce(
|
||||
(sum, char, index) => sum + char.charCodeAt(0) * (index + 1),
|
||||
0,
|
||||
);
|
||||
|
||||
const buildGeometrySeed = (fid: string) => {
|
||||
const seed = toNumericSeed(fid);
|
||||
@@ -284,7 +285,8 @@ const normalizeHm = (value?: string | null) => {
|
||||
if (!digits) return "";
|
||||
if (digits.length === 1) return `0${digits}:00`;
|
||||
if (digits.length === 2) return `${digits}:00`;
|
||||
if (digits.length === 3) return `0${digits.slice(0, 1)}:${digits.slice(1, 3)}`;
|
||||
if (digits.length === 3)
|
||||
return `0${digits.slice(0, 1)}:${digits.slice(1, 3)}`;
|
||||
return `${digits.slice(0, 2)}:${digits.slice(2, 4)}`;
|
||||
};
|
||||
|
||||
@@ -293,7 +295,8 @@ const normalizeDateTime = (value?: string | null) => {
|
||||
if (!source) return "";
|
||||
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(source)) return source;
|
||||
const date = new Date(source);
|
||||
if (Number.isNaN(date.getTime())) return source.slice(0, 16).replace("T", " ");
|
||||
if (Number.isNaN(date.getTime()))
|
||||
return source.slice(0, 16).replace("T", " ");
|
||||
const yyyy = date.getFullYear();
|
||||
const mm = `${date.getMonth() + 1}`.padStart(2, "0");
|
||||
const dd = `${date.getDate()}`.padStart(2, "0");
|
||||
@@ -314,8 +317,12 @@ const formatInteger = (value?: number | null, suffix = "") => {
|
||||
return `${numeric.toLocaleString("ko-KR")}${suffix}`;
|
||||
};
|
||||
|
||||
const formatOptionalNumber = (value?: number | null, digits = 1, suffix = "") => {
|
||||
if (value === null || value === undefined || value === "") return "-";
|
||||
const formatOptionalNumber = (
|
||||
value?: number | null,
|
||||
digits = 1,
|
||||
suffix = "",
|
||||
) => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return "-";
|
||||
return `${numeric.toFixed(digits)}${suffix}`;
|
||||
@@ -339,7 +346,9 @@ export const normalizeFldngHstryFilters = (
|
||||
): FldngHstryFilters => ({
|
||||
ctpvCd: String(filters.ctpvCd ?? "0") || "0",
|
||||
sggCd: String(filters.sggCd ?? "0") || "0",
|
||||
floodYear: String(filters.floodYear ?? "").replace(/[^0-9]/g, "").slice(0, 4),
|
||||
floodYear: String(filters.floodYear ?? "")
|
||||
.replace(/[^0-9]/g, "")
|
||||
.slice(0, 4),
|
||||
disasterName: String(filters.disasterName ?? "").trim(),
|
||||
});
|
||||
|
||||
@@ -352,7 +361,6 @@ export const buildFldngHstryListQueryString = (
|
||||
filters: FldngHstryFilters,
|
||||
page: number,
|
||||
size: number,
|
||||
sort?: UiTableSort,
|
||||
) => {
|
||||
const normalized = normalizeFldngHstryFilters(filters);
|
||||
const params = new URLSearchParams({
|
||||
@@ -360,23 +368,27 @@ export const buildFldngHstryListQueryString = (
|
||||
size: String(size),
|
||||
});
|
||||
|
||||
if (normalized.ctpvCd !== "0") params.set("ctpvCd", normalized.ctpvCd.slice(0, 2));
|
||||
if (normalized.sggCd !== "0") params.set("sggCd", normalized.sggCd.slice(0, 5));
|
||||
if (normalized.ctpvCd !== "0")
|
||||
params.set("ctpvCd", normalized.ctpvCd.slice(0, 2));
|
||||
if (normalized.sggCd !== "0")
|
||||
params.set("sggCd", normalized.sggCd.slice(0, 5));
|
||||
if (normalized.floodYear) params.set("fldngYr", normalized.floodYear);
|
||||
if (normalized.disasterName) params.set("fldngDstrNm", normalized.disasterName);
|
||||
if (sort?.key && sort.direction) params.set("sort", `${sort.key},${sort.direction}`);
|
||||
|
||||
if (normalized.disasterName)
|
||||
params.set("fldngDstrNm", normalized.disasterName);
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
export const formatFloodArea = (value: number) => Number(value ?? 0).toLocaleString("ko-KR", {
|
||||
export const formatFloodArea = (value: number) =>
|
||||
Number(value ?? 0).toLocaleString("ko-KR", {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
export const formatFloodDepth = (value: number) => value.toFixed(2);
|
||||
|
||||
export const attachFldngHstryGeometry = (item: FldngHstryApiItem): FldngHstryItem => {
|
||||
export const attachFldngHstryGeometry = (
|
||||
item: FldngHstryApiItem,
|
||||
): FldngHstryItem => {
|
||||
const historyId = String(item.fid ?? "");
|
||||
const geometrySeed = buildGeometrySeed(historyId);
|
||||
const startDate = normalizeDate(item.bgngYmd);
|
||||
@@ -421,7 +433,11 @@ export const attachFldngHstryGeometry = (item: FldngHstryApiItem): FldngHstryIte
|
||||
rotationDeg: 9,
|
||||
scales: [1.08, 0.9, 1.04, 0.82, 0.98, 1.12, 0.88, 0.96, 1.03, 0.86],
|
||||
}),
|
||||
depthZones: createFldngHstryDepthZones(historyId, geometrySeed.centerX, geometrySeed.centerY),
|
||||
depthZones: createFldngHstryDepthZones(
|
||||
historyId,
|
||||
geometrySeed.centerX,
|
||||
geometrySeed.centerY,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -446,20 +462,36 @@ export const toFldngHstryTableRows = (
|
||||
}));
|
||||
|
||||
const resolveFldngHstryNodeName = (node: any) =>
|
||||
String(node?.text ?? node?.data?.lyrNm ?? node?.data?.name ?? node?.lyrNm ?? node?.name ?? "").trim();
|
||||
String(
|
||||
node?.text ??
|
||||
node?.data?.lyrNm ??
|
||||
node?.data?.name ??
|
||||
node?.lyrNm ??
|
||||
node?.name ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
const resolveFldngHstryLayerType = (node: any) =>
|
||||
String(node?.data?.lyrTyNm ?? node?.data?.lyrTypeNm ?? node?.lyrTyNm ?? node?.lyrTypeNm ?? "").trim();
|
||||
String(
|
||||
node?.data?.lyrTyNm ??
|
||||
node?.data?.lyrTypeNm ??
|
||||
node?.lyrTyNm ??
|
||||
node?.lyrTypeNm ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
export const isFldngHstryNode = (node: any) => {
|
||||
const name = resolveFldngHstryNodeName(node);
|
||||
const layerType = resolveFldngHstryLayerType(node);
|
||||
return name === FLOOD_HISTORY_LAYER_NAME
|
||||
|| layerType === FLOOD_HISTORY_LAYER_TYPE
|
||||
|| layerType === FLOOD_HISTORY_PREVIEW_LAYER_TYPE;
|
||||
return (
|
||||
name === FLOOD_HISTORY_LAYER_NAME ||
|
||||
layerType === FLOOD_HISTORY_LAYER_TYPE ||
|
||||
layerType === FLOOD_HISTORY_PREVIEW_LAYER_TYPE
|
||||
);
|
||||
};
|
||||
|
||||
export const isFloodHistoryNode = isFldngHstryNode;
|
||||
export const FLOOD_HISTORY_ROW_SELECT_MESSAGE = FLDNG_HSTRY_ROW_SELECT_MESSAGE;
|
||||
export const FLOOD_HISTORY_PAGE_SIZE = FLDNG_HSTRY_PAGE_SIZE;
|
||||
export const FLOOD_HISTORY_DOWNLOAD_BATCH_SIZE = FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE;
|
||||
export const FLOOD_HISTORY_DOWNLOAD_BATCH_SIZE =
|
||||
FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE;
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@common/components";
|
||||
import { UiTable } from "@common/business_components/ui";
|
||||
import type { UiTableSort } from "@common/business_components/ui";
|
||||
import { StandardPopupLayout } from "@common/business_components";
|
||||
import { getData } from "@/components/lib/api";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
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 useStore from "@/store/useStore";
|
||||
import {
|
||||
FLOOD_DNGR_MAP_DEFAULT_FILTERS,
|
||||
@@ -30,7 +31,11 @@ const getFloodDngrMapJson = async (url: string) => {
|
||||
let timeoutId: number | undefined;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = window.setTimeout(() => {
|
||||
reject(new Error("도시침수지도 조회 시간이 초과되었습니다. 검색조건을 좁혀 다시 조회해주세요."));
|
||||
reject(
|
||||
new Error(
|
||||
"도시침수지도 조회 시간이 초과되었습니다. 검색조건을 좁혀 다시 조회해주세요.",
|
||||
),
|
||||
);
|
||||
}, FLOOD_DNGR_MAP_REQUEST_TIMEOUT_MS);
|
||||
});
|
||||
try {
|
||||
@@ -43,25 +48,38 @@ const getFloodDngrMapJson = async (url: string) => {
|
||||
/** 시설물 레이어의 정보제공포털 도시침수지도 피처를 조건별 목록으로 제공한다. */
|
||||
export default function FloodDngrMapPopupPage() {
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
const [filters, setFilters] = useState<FloodDngrMapFilters>(FLOOD_DNGR_MAP_DEFAULT_FILTERS);
|
||||
const [appliedFilters, setAppliedFilters] = useState<FloodDngrMapFilters>(FLOOD_DNGR_MAP_DEFAULT_FILTERS);
|
||||
const [filters, setFilters] = useState<FloodDngrMapFilters>(
|
||||
FLOOD_DNGR_MAP_DEFAULT_FILTERS,
|
||||
);
|
||||
const [appliedFilters, setAppliedFilters] = useState<FloodDngrMapFilters>(
|
||||
FLOOD_DNGR_MAP_DEFAULT_FILTERS,
|
||||
);
|
||||
const [items, setItems] = useState<FloodDngrMapApiItem[]>([]);
|
||||
const [administrativeScopeCodes, setAdministrativeScopeCodes] = useState<string[] | null>(null);
|
||||
const [administrativeScopeCodes, setAdministrativeScopeCodes] = useState<
|
||||
string[] | null
|
||||
>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
const [appliedSort, setAppliedSort] = useState<UiTableSort>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const availableCtpvCodes = useMemo(
|
||||
() => new Set((administrativeScopeCodes ?? []).map((code) => code.slice(0, 2)).filter(Boolean)),
|
||||
() =>
|
||||
new Set(
|
||||
(administrativeScopeCodes ?? [])
|
||||
.map((code) => code.slice(0, 2))
|
||||
.filter(Boolean),
|
||||
),
|
||||
[administrativeScopeCodes],
|
||||
);
|
||||
|
||||
const availableSggCodes = useMemo(
|
||||
() => new Set((administrativeScopeCodes ?? []).map((code) => code.slice(0, 5)).filter(Boolean)),
|
||||
() =>
|
||||
new Set(
|
||||
(administrativeScopeCodes ?? [])
|
||||
.map((code) => code.slice(0, 5))
|
||||
.filter(Boolean),
|
||||
),
|
||||
[administrativeScopeCodes],
|
||||
);
|
||||
|
||||
@@ -69,7 +87,9 @@ export default function FloodDngrMapPopupPage() {
|
||||
() =>
|
||||
administrativeScopeCodes === null
|
||||
? ctpv
|
||||
: ctpv.filter((item) => availableCtpvCodes.has(String(item.val).trim().slice(0, 2))),
|
||||
: ctpv.filter((item) =>
|
||||
availableCtpvCodes.has(String(item.val).trim().slice(0, 2)),
|
||||
),
|
||||
[administrativeScopeCodes, availableCtpvCodes, ctpv],
|
||||
);
|
||||
|
||||
@@ -77,10 +97,14 @@ export default function FloodDngrMapPopupPage() {
|
||||
const scopedOptions =
|
||||
administrativeScopeCodes === null
|
||||
? sgg
|
||||
: sgg.filter((item) => availableSggCodes.has(String(item.val).trim().slice(0, 5)));
|
||||
: sgg.filter((item) =>
|
||||
availableSggCodes.has(String(item.val).trim().slice(0, 5)),
|
||||
);
|
||||
return filters.ctpvCd === "0"
|
||||
? scopedOptions
|
||||
: scopedOptions.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2)));
|
||||
: scopedOptions.filter((item) =>
|
||||
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
|
||||
);
|
||||
}, [administrativeScopeCodes, availableSggCodes, filters.ctpvCd, sgg]);
|
||||
|
||||
const rows = useMemo(
|
||||
@@ -92,9 +116,8 @@ export default function FloodDngrMapPopupPage() {
|
||||
ctpv,
|
||||
sgg,
|
||||
total,
|
||||
sort,
|
||||
),
|
||||
[ctpv, currentPage, items, sgg, sort, total],
|
||||
[ctpv, currentPage, items, sgg, total],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -107,16 +130,18 @@ export default function FloodDngrMapPopupPage() {
|
||||
if (!isLoading) return;
|
||||
const watchdogId = window.setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
setErrorMessage((current) =>
|
||||
current || "도시침수지도 조회가 지연되어 로딩을 종료했습니다. 검색조건을 좁혀 다시 조회해주세요.",
|
||||
setErrorMessage(
|
||||
(current) =>
|
||||
current ||
|
||||
"도시침수지도 조회가 지연되어 로딩을 종료했습니다. 검색조건을 좁혀 다시 조회해주세요.",
|
||||
);
|
||||
}, FLOOD_DNGR_MAP_LOADING_WATCHDOG_MS);
|
||||
return () => window.clearTimeout(watchdogId);
|
||||
}, [isLoading]);
|
||||
|
||||
/** 마지막 적용 조건과 정렬로 서버 페이지를 조회한다. */
|
||||
/** 마지막 적용 조건으로 서버 페이지를 조회한다. */
|
||||
const loadRows = useCallback(
|
||||
async (page: number, nextFilters: FloodDngrMapFilters, nextSort?: UiTableSort) => {
|
||||
async (page: number, nextFilters: FloodDngrMapFilters) => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage("");
|
||||
try {
|
||||
@@ -124,7 +149,6 @@ export default function FloodDngrMapPopupPage() {
|
||||
nextFilters,
|
||||
page - 1,
|
||||
FLOOD_DNGR_MAP_PAGE_SIZE,
|
||||
nextSort,
|
||||
);
|
||||
const response = await getFloodDngrMapJson(
|
||||
`/sewerage-service/api/v1/fldng/flood-dngr-map?${queryString}`,
|
||||
@@ -134,21 +158,22 @@ export default function FloodDngrMapPopupPage() {
|
||||
total?: number;
|
||||
} | null;
|
||||
if (!payload || !Array.isArray(payload.items)) {
|
||||
throw new Error("도시침수지도 목록 API 응답 형식이 올바르지 않습니다.");
|
||||
throw new Error(
|
||||
"도시침수지도 목록 API 응답 형식이 올바르지 않습니다.",
|
||||
);
|
||||
}
|
||||
setItems(payload.items);
|
||||
setTotal(Number(payload?.total ?? 0));
|
||||
setCurrentPage(page);
|
||||
setAppliedFilters(nextFilters);
|
||||
setSort(nextSort);
|
||||
setAppliedSort(nextSort);
|
||||
setSelectedIndex(-1);
|
||||
} catch (error) {
|
||||
console.error("도시침수지도 목록 조회 중 오류:", error);
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : "도시침수지도 목록 조회 중 오류가 발생했습니다.",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "도시침수지도 목록 조회 중 오류가 발생했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -164,7 +189,9 @@ export default function FloodDngrMapPopupPage() {
|
||||
"/sewerage-service/api/v1/fldng/flood-dngr-map/scope",
|
||||
);
|
||||
if (!Array.isArray(response)) {
|
||||
throw new Error("도시침수지도 행정구역 API 응답 형식이 올바르지 않습니다.");
|
||||
throw new Error(
|
||||
"도시침수지도 행정구역 API 응답 형식이 올바르지 않습니다.",
|
||||
);
|
||||
}
|
||||
setAdministrativeScopeCodes(response);
|
||||
} catch (error) {
|
||||
@@ -179,15 +206,12 @@ export default function FloodDngrMapPopupPage() {
|
||||
}, [loadAdministrativeScope, loadRows]);
|
||||
|
||||
const handleSearch = () => {
|
||||
void loadRows(1, filters, sort);
|
||||
void loadRows(1, filters);
|
||||
};
|
||||
|
||||
/** 시군구·침수빈도 그룹의 범위와 선택 조건을 부모 지도에 전달한다. */
|
||||
const handleRowClick = async (index?: number) => {
|
||||
if (typeof index !== "number") return;
|
||||
const row = rows[index];
|
||||
const handleRowClick = async (row: FloodDngrMapTableRow) => {
|
||||
if (!row?.sggCd || !row?.sbmrgFrqExpln) return;
|
||||
setSelectedIndex(index);
|
||||
setIsLoading(true);
|
||||
setErrorMessage("");
|
||||
try {
|
||||
@@ -199,8 +223,16 @@ export default function FloodDngrMapPopupPage() {
|
||||
`/sewerage-service/api/v1/fldng/flood-dngr-map/group/extent?${params.toString()}`,
|
||||
);
|
||||
const payload = response as FloodDngrMapExtent;
|
||||
const extent = [payload?.minX, payload?.minY, payload?.maxX, payload?.maxY].map(Number);
|
||||
if (extent.length !== 4 || extent.some((value) => !Number.isFinite(value))) {
|
||||
const extent = [
|
||||
payload?.minX,
|
||||
payload?.minY,
|
||||
payload?.maxX,
|
||||
payload?.maxY,
|
||||
].map(Number);
|
||||
if (
|
||||
extent.length !== 4 ||
|
||||
extent.some((value) => !Number.isFinite(value))
|
||||
) {
|
||||
throw new Error("해당 도시침수지도 피처의 위치 정보가 없습니다.");
|
||||
}
|
||||
window.opener?.postMessage(
|
||||
@@ -229,105 +261,138 @@ export default function FloodDngrMapPopupPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex h-screen flex-col overflow-hidden bg-white">
|
||||
<StandardPopupLayout title="도시침수지도 목록">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<header className="flex min-h-[44px] items-center border-b border-[#dbe2e6] bg-primary px-5 py-3">
|
||||
<h1 className="text-[1.45rem] font-semibold text-white">도시침수지도 목록</h1>
|
||||
</header>
|
||||
|
||||
<section className="flex min-h-0 flex-1 flex-col p-4">
|
||||
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[200px_200px_220px_200px_auto] md:items-end">
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div>
|
||||
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
시도
|
||||
</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground"
|
||||
value={filters.ctpvCd}
|
||||
onChange={(event) => setFilters({ ...filters, ctpvCd: event.target.value, sggCd: "0" })}
|
||||
onChange={(event) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
ctpvCd: event.target.value,
|
||||
sggCd: "0",
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="0">전체</option>
|
||||
{ctpvOptions.map((item) => (
|
||||
<option key={item.val} value={String(item.val)}>{item.text}</option>
|
||||
<option key={item.val} value={String(item.val)}>
|
||||
{item.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시군구</div>
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
시군구
|
||||
</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground"
|
||||
value={filters.sggCd}
|
||||
onChange={(event) => setFilters({ ...filters, sggCd: event.target.value })}
|
||||
onChange={(event) =>
|
||||
setFilters({ ...filters, sggCd: event.target.value })
|
||||
}
|
||||
>
|
||||
<option value="0">전체</option>
|
||||
{sggOptions.map((item) => (
|
||||
<option key={item.val} value={String(item.val)}>{item.text}</option>
|
||||
<option key={item.val} value={String(item.val)}>
|
||||
{item.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">침수심도</div>
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
침수심도
|
||||
</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="h-[3.2rem] w-[18rem] rounded-md border border-input bg-field px-3 text-xl text-foreground"
|
||||
value={filters.sbmrgDpthlCd}
|
||||
onChange={(event) => setFilters({ ...filters, sbmrgDpthlCd: event.target.value })}
|
||||
onChange={(event) =>
|
||||
setFilters({ ...filters, sbmrgDpthlCd: event.target.value })
|
||||
}
|
||||
>
|
||||
<option value="0">전체</option>
|
||||
{FLOOD_DNGR_MAP_DEPTH_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">침수빈도</div>
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
침수빈도
|
||||
</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground"
|
||||
value={filters.sbmrgFrqExpln}
|
||||
onChange={(event) => setFilters({ ...filters, sbmrgFrqExpln: event.target.value })}
|
||||
onChange={(event) =>
|
||||
setFilters({ ...filters, sbmrgFrqExpln: event.target.value })
|
||||
}
|
||||
>
|
||||
<option value="0">전체</option>
|
||||
{FLOOD_DNGR_MAP_FREQUENCY_OPTIONS.map((value) => (
|
||||
<option key={value} value={value}>{Number(value)}년</option>
|
||||
<option key={value} value={value}>
|
||||
{Number(value)}년
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="flex justify-end md:pr-3">
|
||||
<Button type="button" size="sm" color="primary" className="h-10 px-5 !text-[1.1rem]" onClick={handleSearch}>
|
||||
<div className="flex min-w-fit flex-1 justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="default"
|
||||
color="primary"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="default"
|
||||
color="white"
|
||||
onClick={() => {
|
||||
setFilters(FLOOD_DNGR_MAP_DEFAULT_FILTERS);
|
||||
void loadRows(1, FLOOD_DNGR_MAP_DEFAULT_FILTERS);
|
||||
}}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopupSearchPanel>
|
||||
|
||||
{errorMessage ? (
|
||||
<div role="alert" className="mt-2 rounded border border-red-200 bg-red-50 px-3 py-2 text-[1rem] text-red-700">
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-2 rounded border border-red-200 bg-red-50 px-3 py-2 text-[1rem] text-red-700"
|
||||
>
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto pt-2">
|
||||
<UiTable<FloodDngrMapTableRow>
|
||||
headName={
|
||||
<>
|
||||
도시침수지도 조회 결과 총 {total.toLocaleString()}건
|
||||
</>
|
||||
}
|
||||
tableData={rows}
|
||||
<MapPopupDataTable
|
||||
rows={rows as Array<FloodDngrMapTableRow & Record<string, unknown>>}
|
||||
headlist={FLOOD_DNGR_MAP_HEADLIST}
|
||||
pageSize={FLOOD_DNGR_MAP_PAGE_SIZE}
|
||||
title="도시침수지도 목록"
|
||||
total={total}
|
||||
cellClick={(index) => void handleRowClick(index)}
|
||||
onPageChange={(page) => void loadRows(page, appliedFilters, appliedSort)}
|
||||
curPage={currentPage}
|
||||
index={selectedIndex}
|
||||
widthFixed={true}
|
||||
isHideCount={true}
|
||||
sort={sort}
|
||||
onSortChange={(nextSort) => void loadRows(1, appliedFilters, nextSort)}
|
||||
page={currentPage}
|
||||
pageSize={FLOOD_DNGR_MAP_PAGE_SIZE}
|
||||
onPageChange={(page) => void loadRows(page, appliedFilters)}
|
||||
onRowClick={(row) => void handleRowClick(row)}
|
||||
rowId={(row, index) => `${row.sggCd}-${row.sbmrgFrqExpln}-${index}`}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="120rem"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { UiHeadProps, UiTableSort } from "@common/business_components/ui";
|
||||
import type { UiHeadProps } from "@common/business_components/ui";
|
||||
|
||||
export const FLOOD_DNGR_MAP_POPUP_KEY = "floodDngrMapPopup";
|
||||
export const FLOOD_DNGR_MAP_LAYER_TYPE = "tb_ms_flood_dngr_map";
|
||||
@@ -59,47 +59,46 @@ export const FLOOD_DNGR_MAP_DEPTH_LABELS: Record<string, string> = {
|
||||
N334: "5.0m 이상",
|
||||
};
|
||||
|
||||
export const FLOOD_DNGR_MAP_DEPTH_OPTIONS = Object.entries(FLOOD_DNGR_MAP_DEPTH_LABELS).map(
|
||||
([value, label]) => ({ value, label }),
|
||||
);
|
||||
export const FLOOD_DNGR_MAP_DEPTH_OPTIONS = Object.entries(
|
||||
FLOOD_DNGR_MAP_DEPTH_LABELS,
|
||||
).map(([value, label]) => ({ value, label }));
|
||||
|
||||
export const FLOOD_DNGR_MAP_HEADLIST: UiHeadProps[] = [
|
||||
{ id: "rowNo", title: "NO", widthPercent: 8, sortable: true, sortKey: "rowNo" },
|
||||
{ id: "ctpvNm", title: "시도", widthPercent: 18, sortable: true, sortKey: "ctpvCd" },
|
||||
{ id: "sggNm", title: "시군구", widthPercent: 20, sortable: true, sortKey: "sggCd" },
|
||||
{ id: "rowNo", title: "순번", widthPercent: 8 },
|
||||
{ id: "ctpvNm", title: "시도", widthPercent: 18 },
|
||||
{ id: "sggNm", title: "시군구", widthPercent: 20 },
|
||||
{
|
||||
id: "sbmrgDpthlDisplay",
|
||||
title: "침수심도(최소~최대)",
|
||||
widthPercent: 31,
|
||||
sortable: true,
|
||||
sortKey: "sbmrgDpthlCd",
|
||||
},
|
||||
{
|
||||
id: "sbmrgFrqDisplay",
|
||||
title: "침수빈도",
|
||||
widthPercent: 23,
|
||||
sortable: true,
|
||||
sortKey: "sbmrgFrqExpln",
|
||||
},
|
||||
];
|
||||
|
||||
/** API가 허용하는 검색 조건과 페이징·정렬 파라미터만 쿼리 문자열로 만든다. */
|
||||
/** API가 허용하는 검색 조건과 페이징 파라미터만 쿼리 문자열로 만든다. */
|
||||
export const buildFloodDngrMapListQueryString = (
|
||||
filters: FloodDngrMapFilters,
|
||||
page: number,
|
||||
size: number,
|
||||
sort?: UiTableSort,
|
||||
) => {
|
||||
const params = new URLSearchParams({ page: String(page), size: String(size) });
|
||||
if (filters.ctpvCd && filters.ctpvCd !== "0") params.set("ctpvCd", filters.ctpvCd.slice(0, 2));
|
||||
if (filters.sggCd && filters.sggCd !== "0") params.set("sggCd", filters.sggCd.slice(0, 5));
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
size: String(size),
|
||||
});
|
||||
if (filters.ctpvCd && filters.ctpvCd !== "0")
|
||||
params.set("ctpvCd", filters.ctpvCd.slice(0, 2));
|
||||
if (filters.sggCd && filters.sggCd !== "0")
|
||||
params.set("sggCd", filters.sggCd.slice(0, 5));
|
||||
if (filters.sbmrgDpthlCd && filters.sbmrgDpthlCd !== "0") {
|
||||
params.set("sbmrgDpthlCd", filters.sbmrgDpthlCd);
|
||||
}
|
||||
if (filters.sbmrgFrqExpln && filters.sbmrgFrqExpln !== "0") {
|
||||
params.set("sbmrgFrqExpln", filters.sbmrgFrqExpln);
|
||||
}
|
||||
if (sort?.key && sort.direction) params.set("sort", `${sort.key},${sort.direction}`);
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
@@ -108,9 +107,14 @@ const resolveAdministrativeName = (
|
||||
options: AdministrativeOption[],
|
||||
codeLength: number,
|
||||
) => {
|
||||
const code = String(value ?? "").trim().slice(0, codeLength);
|
||||
const code = String(value ?? "")
|
||||
.trim()
|
||||
.slice(0, codeLength);
|
||||
const matched = options.find(
|
||||
(option) => String(option.val ?? "").trim().slice(0, codeLength) === code,
|
||||
(option) =>
|
||||
String(option.val ?? "")
|
||||
.trim()
|
||||
.slice(0, codeLength) === code,
|
||||
);
|
||||
return String(matched?.text ?? "").trim() || "-";
|
||||
};
|
||||
@@ -122,7 +126,10 @@ const toFrequencyDisplay = (value: unknown) => {
|
||||
return Number.isFinite(numericValue) ? `${numericValue}년` : normalized;
|
||||
};
|
||||
|
||||
const FLOOD_DNGR_MAP_DEPTH_BOUNDS: Record<string, { min: string; max: string; openEnded?: boolean }> = {
|
||||
const FLOOD_DNGR_MAP_DEPTH_BOUNDS: Record<
|
||||
string,
|
||||
{ min: string; max: string; openEnded?: boolean }
|
||||
> = {
|
||||
N330: { min: "0.0", max: "0.5" },
|
||||
N331: { min: "0.5", max: "1.0" },
|
||||
N332: { min: "1.0", max: "2.0" },
|
||||
@@ -133,7 +140,8 @@ const FLOOD_DNGR_MAP_DEPTH_BOUNDS: Record<string, { min: string; max: string; op
|
||||
/** 그룹의 최소·최대 침수심도 코드를 하나의 범위 문구로 표시한다. */
|
||||
const toDepthRangeDisplay = (minCode: string, maxCode: string) => {
|
||||
if (!minCode && !maxCode) return "-";
|
||||
if (minCode === maxCode) return FLOOD_DNGR_MAP_DEPTH_LABELS[minCode] || minCode || "-";
|
||||
if (minCode === maxCode)
|
||||
return FLOOD_DNGR_MAP_DEPTH_LABELS[minCode] || minCode || "-";
|
||||
const minDepth = FLOOD_DNGR_MAP_DEPTH_BOUNDS[minCode];
|
||||
const maxDepth = FLOOD_DNGR_MAP_DEPTH_BOUNDS[maxCode];
|
||||
if (!minDepth || !maxDepth) return `${minCode || "-"} ~ ${maxCode || "-"}`;
|
||||
@@ -150,23 +158,30 @@ export const toFloodDngrMapTableRows = (
|
||||
ctpvOptions: AdministrativeOption[] = [],
|
||||
sggOptions: AdministrativeOption[] = [],
|
||||
total = items.length,
|
||||
sort?: UiTableSort,
|
||||
): FloodDngrMapTableRow[] =>
|
||||
items.map((item, index) => ({
|
||||
...item,
|
||||
rowNo:
|
||||
sort?.key === "rowNo" && sort.direction === "desc"
|
||||
? total - page * pageSize - index
|
||||
: page * pageSize + index + 1,
|
||||
rowNo: total - page * pageSize - index,
|
||||
ctpvNm: resolveAdministrativeName(item.ctpvCd, ctpvOptions, 2),
|
||||
sggNm: resolveAdministrativeName(item.sggCd, sggOptions, 5),
|
||||
sbmrgDpthlDisplay: toDepthRangeDisplay(item.minSbmrgDpthlCd, item.maxSbmrgDpthlCd),
|
||||
sbmrgDpthlDisplay: toDepthRangeDisplay(
|
||||
item.minSbmrgDpthlCd,
|
||||
item.maxSbmrgDpthlCd,
|
||||
),
|
||||
sbmrgFrqDisplay: toFrequencyDisplay(item.sbmrgFrqExpln),
|
||||
}));
|
||||
|
||||
const resolveNodeName = (node: any) =>
|
||||
String(node?.text ?? node?.data?.lyrNm ?? node?.data?.name ?? node?.lyrNm ?? node?.name ?? "").trim();
|
||||
String(
|
||||
node?.text ??
|
||||
node?.data?.lyrNm ??
|
||||
node?.data?.name ??
|
||||
node?.lyrNm ??
|
||||
node?.name ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
/** 빈도·침수심 자식 노드가 아니라 도시침수지도 루트만 목록보기 대상으로 판별한다. */
|
||||
export const isFloodDngrMapRootNode = (node: any) =>
|
||||
resolveNodeName(node).replace(/\s/g, "") === FLOOD_DNGR_MAP_LAYER_NAME.replace(/\s/g, "");
|
||||
resolveNodeName(node).replace(/\s/g, "") ===
|
||||
FLOOD_DNGR_MAP_LAYER_NAME.replace(/\s/g, "");
|
||||
|
||||
@@ -25,9 +25,27 @@ type MngInstOption = {
|
||||
const EMPTY_SELECT_OPTION = { value: "", label: "선택" };
|
||||
|
||||
const PIPE_BASIC_INFO_FIELDS = [
|
||||
{ id: "mngInstCd", sourceId: "mng_inst_cd", title: "관리기관", type: "select", required: true },
|
||||
{ id: "fcltMngNo", sourceId: "fclt_mng_no", title: "시설관리번호", type: "input", required: true },
|
||||
{ id: "instMngNo", sourceId: "inst_mng_no", title: "기관관리번호", type: "input", required: true },
|
||||
{
|
||||
id: "mngInstCd",
|
||||
sourceId: "mng_inst_cd",
|
||||
title: "관리기관",
|
||||
type: "select",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "fcltMngNo",
|
||||
sourceId: "fclt_mng_no",
|
||||
title: "시설관리번호",
|
||||
type: "input",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "instMngNo",
|
||||
sourceId: "inst_mng_no",
|
||||
title: "기관관리번호",
|
||||
type: "input",
|
||||
required: true,
|
||||
},
|
||||
{ id: "instlYmd", sourceId: "instl_ymd", title: "설치일자", type: "date" },
|
||||
{ id: "mapshtNo", sourceId: "mapsht_no", title: "도엽번호", type: "input" },
|
||||
{ id: "pdmtVl", sourceId: "pdmt_vl", title: "관경(mm)", type: "input" },
|
||||
@@ -40,7 +58,10 @@ const PIPE_BASIC_INFO_FIELDS = [
|
||||
required: true,
|
||||
options: [
|
||||
EMPTY_SELECT_OPTION,
|
||||
...MS_SWAGE_PPL_USE_CODES.map((item) => ({ value: item.val.trim(), label: item.text })),
|
||||
...MS_SWAGE_PPL_USE_CODES.map((item) => ({
|
||||
value: item.val.trim(),
|
||||
label: item.text,
|
||||
})),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -50,33 +71,95 @@ const PIPE_BASIC_INFO_FIELDS = [
|
||||
type: "select",
|
||||
options: [
|
||||
EMPTY_SELECT_OPTION,
|
||||
...MS_SWAGE_PPL_CODES.map((item) => ({ value: item.val.trim(), label: item.text })),
|
||||
...MS_SWAGE_PPL_CODES.map((item) => ({
|
||||
value: item.val.trim(),
|
||||
label: item.text,
|
||||
})),
|
||||
],
|
||||
},
|
||||
{ id: "avgGrdn", sourceId: "avg_grdn", title: "평균구배(m/m)", type: "input" },
|
||||
{ id: "drtwMltvArea", sourceId: "drtw_mltv_area", title: "오수배수면적(m²)", type: "input" },
|
||||
{ id: "exclnMltvArea", sourceId: "excln_mltv_area", title: "우수배수면적(m²)", type: "input" },
|
||||
{ id: "tmptDpth", sourceId: "tmpt_dpth", title: "시점깊이(m)", type: "input" },
|
||||
{ id: "tmptMhlMngNo", sourceId: "tmpt_mhl_mng_no", title: "시점맨홀관리번호", type: "input" },
|
||||
{ id: "scendDpth", sourceId: "scend_dpth", title: "종점깊이(m)", type: "input" },
|
||||
{ id: "scendMhlMngNo", sourceId: "scend_mhl_mng_no", title: "종점맨홀관리번호", type: "input" },
|
||||
{
|
||||
id: "avgGrdn",
|
||||
sourceId: "avg_grdn",
|
||||
title: "평균구배(m/m)",
|
||||
type: "input",
|
||||
},
|
||||
{
|
||||
id: "drtwMltvArea",
|
||||
sourceId: "drtw_mltv_area",
|
||||
title: "오수배수면적(m²)",
|
||||
type: "input",
|
||||
},
|
||||
{
|
||||
id: "exclnMltvArea",
|
||||
sourceId: "excln_mltv_area",
|
||||
title: "우수배수면적(m²)",
|
||||
type: "input",
|
||||
},
|
||||
{
|
||||
id: "tmptDpth",
|
||||
sourceId: "tmpt_dpth",
|
||||
title: "시점깊이(m)",
|
||||
type: "input",
|
||||
},
|
||||
{
|
||||
id: "tmptMhlMngNo",
|
||||
sourceId: "tmpt_mhl_mng_no",
|
||||
title: "시점맨홀관리번호",
|
||||
type: "input",
|
||||
},
|
||||
{
|
||||
id: "scendDpth",
|
||||
sourceId: "scend_dpth",
|
||||
title: "종점깊이(m)",
|
||||
type: "input",
|
||||
},
|
||||
{
|
||||
id: "scendMhlMngNo",
|
||||
sourceId: "scend_mhl_mng_no",
|
||||
title: "종점맨홀관리번호",
|
||||
type: "input",
|
||||
},
|
||||
{ id: "trflnCnt", sourceId: "trfln_cnt", title: "차선수", type: "input" },
|
||||
{ id: "vrtcLen", sourceId: "vrtc_len", title: "세로길이(m)", type: "input" },
|
||||
{ id: "wdthLen", sourceId: "wdth_len", title: "가로길이(m)", type: "input" },
|
||||
];
|
||||
|
||||
const PIPE_INPUT_INFO_FIELDS = [
|
||||
{ id: "instlMbdyCd", title: "설치주체", type: "select", options: [EMPTY_SELECT_OPTION, "BTL", "재정사업"] },
|
||||
{
|
||||
id: "instlMbdyCd",
|
||||
title: "설치주체",
|
||||
type: "select",
|
||||
options: [EMPTY_SELECT_OPTION, "BTL", "재정사업"],
|
||||
},
|
||||
{ id: "swtrmtZoneNm", title: "처리구역", type: "input" },
|
||||
{ id: "mltvZoneNm", title: "배수구역", type: "input" },
|
||||
{ id: "mltvDtdvNm", title: "배수분구", type: "input" },
|
||||
{ id: "lineCd", title: "지선/간선", type: "select", options: [EMPTY_SELECT_OPTION, "지선", "간선"] },
|
||||
{ id: "floCd", title: "흐름방식", type: "select", options: [EMPTY_SELECT_OPTION, "자연유하", "압송"] },
|
||||
{ id: "layCd", title: "매설위치", type: "select", options: [EMPTY_SELECT_OPTION, "도로", "하상"] },
|
||||
{
|
||||
id: "lineCd",
|
||||
title: "지선/간선",
|
||||
type: "select",
|
||||
options: [EMPTY_SELECT_OPTION, "지선", "간선"],
|
||||
},
|
||||
{
|
||||
id: "floCd",
|
||||
title: "흐름방식",
|
||||
type: "select",
|
||||
options: [EMPTY_SELECT_OPTION, "자연유하", "압송"],
|
||||
},
|
||||
{
|
||||
id: "layCd",
|
||||
title: "매설위치",
|
||||
type: "select",
|
||||
options: [EMPTY_SELECT_OPTION, "도로", "하상"],
|
||||
},
|
||||
];
|
||||
|
||||
const PIPE_VISIBLE_BASIC_FIELD_IDS = PIPE_BASIC_INFO_FIELDS.map((field) => field.id);
|
||||
const PIPE_VISIBLE_INPUT_FIELD_IDS = PIPE_INPUT_INFO_FIELDS.map((field) => field.id);
|
||||
const PIPE_VISIBLE_BASIC_FIELD_IDS = PIPE_BASIC_INFO_FIELDS.map(
|
||||
(field) => field.id,
|
||||
);
|
||||
const PIPE_VISIBLE_INPUT_FIELD_IDS = PIPE_INPUT_INFO_FIELDS.map(
|
||||
(field) => field.id,
|
||||
);
|
||||
|
||||
const toEditableText = (value: unknown) => {
|
||||
const text = String(value ?? "").trim();
|
||||
@@ -84,7 +167,10 @@ const toEditableText = (value: unknown) => {
|
||||
};
|
||||
|
||||
const isInvalidPipeLookupKey = (value: string) =>
|
||||
!value || value === "-" || /^NEW-/i.test(value) || /^(null|undefined)$/i.test(value);
|
||||
!value ||
|
||||
value === "-" ||
|
||||
/^NEW-/i.test(value) ||
|
||||
/^(null|undefined)$/i.test(value);
|
||||
|
||||
const toDateInputValue = (value: unknown) => {
|
||||
const text = toEditableText(value).replaceAll(".", "").replaceAll("-", "");
|
||||
@@ -92,10 +178,13 @@ const toDateInputValue = (value: unknown) => {
|
||||
return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
|
||||
};
|
||||
|
||||
const toDbYmdValue = (value: unknown) => toEditableText(value).replaceAll("-", "");
|
||||
const toDbYmdValue = (value: unknown) =>
|
||||
toEditableText(value).replaceAll("-", "");
|
||||
|
||||
const getObjectValue = (values: Record<string, any>, field: { id: string; sourceId?: string }) =>
|
||||
values[field.id] ?? (field.sourceId ? values[field.sourceId] : undefined);
|
||||
const getObjectValue = (
|
||||
values: Record<string, any>,
|
||||
field: { id: string; sourceId?: string },
|
||||
) => values[field.id] ?? (field.sourceId ? values[field.sourceId] : undefined);
|
||||
|
||||
const pickFields = (values: Record<string, any>, fieldIds: string[]) =>
|
||||
fieldIds.reduce<Record<string, any>>((acc, fieldId) => {
|
||||
@@ -116,13 +205,17 @@ const normalizeBasicFormValues = (values: Record<string, any>) => {
|
||||
const buildBaseFeatureValues = (featureData: any) => {
|
||||
const resolveUsageCode = (value: unknown) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
const matched = MS_SWAGE_PPL_USE_CODES.find((item) => item.val === raw || item.text === raw);
|
||||
const matched = MS_SWAGE_PPL_USE_CODES.find(
|
||||
(item) => item.val === raw || item.text === raw,
|
||||
);
|
||||
return matched?.val.trim() ?? raw;
|
||||
};
|
||||
|
||||
const resolveMaterialCode = (value: unknown) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
const matched = MS_SWAGE_PPL_CODES.find((item) => item.val === raw || item.text === raw);
|
||||
const matched = MS_SWAGE_PPL_CODES.find(
|
||||
(item) => item.val === raw || item.text === raw,
|
||||
);
|
||||
return matched?.val.trim() ?? raw;
|
||||
};
|
||||
|
||||
@@ -149,7 +242,11 @@ const buildBaseFeatureValues = (featureData: any) => {
|
||||
const getPipeUsageLabel = (value: unknown) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "";
|
||||
return MS_SWAGE_PPL_USE_CODES.find((item) => item.val.trim() === raw || item.text === raw)?.text ?? raw;
|
||||
return (
|
||||
MS_SWAGE_PPL_USE_CODES.find(
|
||||
(item) => item.val.trim() === raw || item.text === raw,
|
||||
)?.text ?? raw
|
||||
);
|
||||
};
|
||||
|
||||
const buildInitialInputDraft = (change: GisEditChange) => ({
|
||||
@@ -169,11 +266,16 @@ export default function GisEditPipeSessionPopup({
|
||||
onSave,
|
||||
}: Props) {
|
||||
const isNewPipe = change.editType === "ADD";
|
||||
const baseFeatureValues = useMemo(() => buildBaseFeatureValues(featureData), [featureData]);
|
||||
const [basicInfoTableData, setBasicInfoTableData] = useState<Record<string, any>>({});
|
||||
const [inputInfoTableData, setInputInfoTableData] = useState<Record<string, any>>(
|
||||
buildInitialInputDraft(change),
|
||||
const baseFeatureValues = useMemo(
|
||||
() => buildBaseFeatureValues(featureData),
|
||||
[featureData],
|
||||
);
|
||||
const [basicInfoTableData, setBasicInfoTableData] = useState<
|
||||
Record<string, any>
|
||||
>({});
|
||||
const [inputInfoTableData, setInputInfoTableData] = useState<
|
||||
Record<string, any>
|
||||
>(buildInitialInputDraft(change));
|
||||
const [mngInstOptions, setMngInstOptions] = useState<MngInstOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -195,8 +297,7 @@ export default function GisEditPipeSessionPopup({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const mngInstSelectOptions = useMemo(
|
||||
() => {
|
||||
const mngInstSelectOptions = useMemo(() => {
|
||||
const sortedOptions = [...mngInstOptions].sort((a, b) =>
|
||||
String(a.lwrInstNm ?? a.mngInstCd ?? "").localeCompare(
|
||||
String(b.lwrInstNm ?? b.mngInstCd ?? ""),
|
||||
@@ -210,9 +311,7 @@ export default function GisEditPipeSessionPopup({
|
||||
label: option.lwrInstNm || String(option.mngInstCd ?? "").trim(),
|
||||
})),
|
||||
];
|
||||
},
|
||||
[mngInstOptions],
|
||||
);
|
||||
}, [mngInstOptions]);
|
||||
|
||||
const basicInfoFields = useMemo(
|
||||
() =>
|
||||
@@ -239,14 +338,17 @@ export default function GisEditPipeSessionPopup({
|
||||
|
||||
const fcltMngNo = String(change.featureRef?.fcltMngNo ?? "").trim();
|
||||
const mngInstCd = String(change.featureRef?.mngInstCd ?? "").trim();
|
||||
if (isInvalidPipeLookupKey(fcltMngNo) || isInvalidPipeLookupKey(mngInstCd)) return;
|
||||
if (isInvalidPipeLookupKey(fcltMngNo) || isInvalidPipeLookupKey(mngInstCd))
|
||||
return;
|
||||
|
||||
let isMounted = true;
|
||||
const fetchPipeInputInfo = async () => {
|
||||
try {
|
||||
const safeFcltMngNo = encodeURIComponent(fcltMngNo);
|
||||
const safeMngInstCd = encodeURIComponent(mngInstCd);
|
||||
const result = await getData(`v1/fcltppl/${safeFcltMngNo}/${safeMngInstCd}`);
|
||||
const result = await getData(
|
||||
`v1/fcltppl/${safeFcltMngNo}/${safeMngInstCd}`,
|
||||
);
|
||||
if (!isMounted || !result) return;
|
||||
|
||||
setInputInfoTableData((previous) => ({
|
||||
@@ -263,14 +365,25 @@ export default function GisEditPipeSessionPopup({
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [change.changeId, change.featureRef?.fcltMngNo, change.featureRef?.mngInstCd, isNewPipe]);
|
||||
}, [
|
||||
change.changeId,
|
||||
change.featureRef?.fcltMngNo,
|
||||
change.featureRef?.mngInstCd,
|
||||
isNewPipe,
|
||||
]);
|
||||
|
||||
const buildMergedDraft = (
|
||||
nextBasicInfoTableData: Record<string, any>,
|
||||
nextInputInfoTableData: Record<string, any>,
|
||||
): GisEditPipeBasicInfo => {
|
||||
const basicDraft = pickFields(nextBasicInfoTableData, PIPE_VISIBLE_BASIC_FIELD_IDS);
|
||||
const inputDraft = pickFields(nextInputInfoTableData, PIPE_VISIBLE_INPUT_FIELD_IDS);
|
||||
const basicDraft = pickFields(
|
||||
nextBasicInfoTableData,
|
||||
PIPE_VISIBLE_BASIC_FIELD_IDS,
|
||||
);
|
||||
const inputDraft = pickFields(
|
||||
nextInputInfoTableData,
|
||||
PIPE_VISIBLE_INPUT_FIELD_IDS,
|
||||
);
|
||||
if (basicDraft.instlYmd) {
|
||||
basicDraft.instlYmd = toDbYmdValue(basicDraft.instlYmd);
|
||||
}
|
||||
@@ -290,29 +403,43 @@ export default function GisEditPipeSessionPopup({
|
||||
onSave(change.changeId, buildMergedDraft(basicInfoTableData, nextFormData));
|
||||
};
|
||||
|
||||
const basicInfoTableRows = useMemo(() => [basicInfoTableData], [basicInfoTableData]);
|
||||
const inputInfoTableRows = useMemo(() => [inputInfoTableData], [inputInfoTableData]);
|
||||
const displayManagementNo = toEditableText(change.featureRef?.fcltMngNo ?? change.objectId ?? "");
|
||||
const basicInfoTableRows = useMemo(
|
||||
() => [basicInfoTableData],
|
||||
[basicInfoTableData],
|
||||
);
|
||||
const inputInfoTableRows = useMemo(
|
||||
() => [inputInfoTableData],
|
||||
[inputInfoTableData],
|
||||
);
|
||||
const displayManagementNo = toEditableText(
|
||||
change.featureRef?.fcltMngNo ?? change.objectId ?? "",
|
||||
);
|
||||
|
||||
return (
|
||||
<div id="popup" className="absolute top-0 right-0 w-[350px] h-[calc(100vh-5rem)] bg-white border-r border-gray-300 flex flex-col overflow-hidden transition-all duration-300 z-[50]">
|
||||
<div className="card-header bg-primary flex items-center justify-between relative p-4">
|
||||
<div className="flex items-center space-x-4 flex-1">
|
||||
<h3 className="text-2xl font-medium text-white text-label whitespace-nowrap">
|
||||
<div
|
||||
id="popup"
|
||||
className="absolute right-0 top-0 z-[50] flex h-[calc(100vh-5rem)] w-[350px] flex-col overflow-hidden border-r border-border bg-background transition-all duration-300"
|
||||
>
|
||||
<div className="card-header relative flex items-center justify-between bg-primary p-4">
|
||||
<div className="flex flex-1 items-center space-x-4">
|
||||
<h3 className="whitespace-nowrap text-2xl font-medium text-primary-foreground">
|
||||
하수관로
|
||||
{basicInfoTableData?.usgCd ? `(${getPipeUsageLabel(basicInfoTableData.usgCd)})` : ""}
|
||||
{" "}정보 <br />관리번호 : {displayManagementNo || "신규"}
|
||||
{basicInfoTableData?.usgCd
|
||||
? `(${getPipeUsageLabel(basicInfoTableData.usgCd)})`
|
||||
: ""}{" "}
|
||||
정보 <br />
|
||||
관리번호 : {displayManagementNo || "신규"}
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
className="flex items-center space-x-4 text-white hover:text-gray-300 focus:outline-none"
|
||||
className="text-primary-foreground/80 flex items-center space-x-4 hover:text-primary-foreground focus:outline-none"
|
||||
size="xs"
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-y-auto overflow-x-hidden flex-1">
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<VerticalTable
|
||||
headName="기본정보"
|
||||
tableData={basicInfoTableRows}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import type { GisEditSessionSnapshot, GisEditTool } from "./gisEditTypes";
|
||||
|
||||
type Props = GisEditSessionSnapshot & {
|
||||
@@ -20,20 +22,23 @@ const TOOL_LABELS: Record<GisEditTool, string> = {
|
||||
};
|
||||
|
||||
const buildChangeItemClassName = (isFocused: boolean) =>
|
||||
`block w-full rounded border px-2 py-2 text-left text-[12px] transition ${
|
||||
`block w-full rounded-md border px-3 py-2.5 text-left text-[1.1rem] transition ${
|
||||
isFocused
|
||||
? "border-[#5a9bd5] bg-[#eef7ff] shadow-[inset_0_0_0_1px_rgba(90,155,213,0.2)]"
|
||||
: "border-[#d8dee8] bg-[#fbfcfe] hover:border-[#bfd2e5] hover:bg-white"
|
||||
? "border-primary bg-primary-transparent shadow-[inset_0_0_0_1px_var(--primary-transparent)]"
|
||||
: "border-border bg-background hover:border-primary hover:bg-background"
|
||||
}`;
|
||||
|
||||
const getChangeLabel = (change: GisEditSessionSnapshot["changes"][number]) =>
|
||||
change.attributeOnly ? "상세정보수정" : TOOL_LABELS[change.editType];
|
||||
|
||||
const getChangeSummary = (change: GisEditSessionSnapshot["changes"][number]) => {
|
||||
const getChangeSummary = (
|
||||
change: GisEditSessionSnapshot["changes"][number],
|
||||
) => {
|
||||
if (change.attributeOnly) return "변경한 상세정보를 확인하세요.";
|
||||
if (change.editType === "ADD") return "추가한 객체 위치를 확인하세요.";
|
||||
if (change.editType === "MOVE") return "객체를 드래그해 위치를 조정하세요.";
|
||||
if (change.editType === "VERTEX_EDIT") return "점을 옮기거나 선분을 끌어 추가하세요.";
|
||||
if (change.editType === "VERTEX_EDIT")
|
||||
return "점을 옮기거나 선분을 끌어 추가하세요.";
|
||||
return "삭제할 객체가 맞는지 확인하세요.";
|
||||
};
|
||||
|
||||
@@ -43,33 +48,41 @@ type ChangeListProps = {
|
||||
onFocusChange: (changeId: string) => void;
|
||||
};
|
||||
|
||||
const ChangeList = ({ changes, focusedChangeId, onFocusChange }: ChangeListProps) => (
|
||||
const ChangeList = ({
|
||||
changes,
|
||||
focusedChangeId,
|
||||
onFocusChange,
|
||||
}: ChangeListProps) => (
|
||||
<>
|
||||
{changes.map((change, index) => (
|
||||
<button
|
||||
key={change.changeId}
|
||||
type="button"
|
||||
className={buildChangeItemClassName(focusedChangeId === change.changeId)}
|
||||
className={buildChangeItemClassName(
|
||||
focusedChangeId === change.changeId,
|
||||
)}
|
||||
onClick={() => onFocusChange(change.changeId)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="shrink-0 whitespace-nowrap text-[11px] font-semibold leading-4 text-[#173f67]">
|
||||
<div className="shrink-0 whitespace-nowrap text-[1.1rem] font-semibold leading-4 text-label">
|
||||
{index + 1}. {getChangeLabel(change)} / {change.objectLabel}
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{change.basicInfoSavedAt && focusedChangeId !== change.changeId ? (
|
||||
<span className="whitespace-nowrap rounded-full border border-[#a9d9bd] bg-white px-1.5 py-0.5 text-[9px] font-semibold text-[#16834f]">
|
||||
<span className="whitespace-nowrap rounded-full border border-emerald-200 bg-background px-1.5 py-0.5 text-[0.9rem] font-semibold text-emerald-700">
|
||||
정보저장
|
||||
</span>
|
||||
) : null}
|
||||
{focusedChangeId === change.changeId ? (
|
||||
<span className="whitespace-nowrap rounded-full border border-[#b8d3ef] bg-white px-1.5 py-0.5 text-[9px] font-semibold text-[#176db8]">
|
||||
지도 미리보기 선택
|
||||
<span className="whitespace-nowrap rounded-full border border-primary bg-background px-1.5 py-0.5 text-[0.9rem] font-semibold text-primary">
|
||||
선택됨
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[11px] text-[#526173]">{getChangeSummary(change)}</div>
|
||||
<div className="mt-1 truncate text-[1rem] text-muted-foreground">
|
||||
{getChangeSummary(change)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
@@ -94,63 +107,81 @@ export default function GisEditSessionPanel({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex min-h-0 flex-col overflow-hidden rounded-lg border border-[#d8dee8] bg-[rgba(255,255,255,0.98)] text-sm text-[#304255] shadow-[0_10px_26px_rgba(23,42,70,0.14)] ${isCompactInline ? "h-[58px] p-2" : "p-4"} ${className}`.trim()}
|
||||
className={`flex min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-[rgba(255,255,255,0.98)] text-label shadow-[0_10px_26px_rgba(23,42,70,0.16)] ${isCompactInline ? "min-h-[68px]" : "p-3"} ${className}`.trim()}
|
||||
>
|
||||
<div
|
||||
className={`flex shrink-0 justify-between gap-2 ${isCompactInline ? "items-center bg-container px-3 py-2.5" : "mb-3 items-start rounded-md bg-container px-3 py-2.5"}`}
|
||||
>
|
||||
<div className={`flex shrink-0 justify-between gap-3 ${isCompactInline ? "mb-0 items-center" : "mb-3 items-start"}`}>
|
||||
<div className="min-w-0">
|
||||
{variant === "inline" ? (
|
||||
<div className="mb-1 text-[14px] font-bold text-[#173f67]">편집 세션</div>
|
||||
<div className="mb-1 flex items-center gap-2 text-[1.3rem] font-semibold text-label">
|
||||
<span>편집 세션</span>
|
||||
<span className="rounded-full bg-primary px-2 py-0.5 text-[0.9rem] font-semibold text-primary-foreground">
|
||||
{changes.length}건
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="truncate text-[12px] text-[#667485]">
|
||||
레이어: {selectedLayerName || "미선택"} / 모드: {isEditMode ? (activeTool ? TOOL_LABELS[activeTool] : "대기") : "편집 모드 종료"}
|
||||
<div className="truncate text-[1.05rem] text-muted-foreground">
|
||||
{selectedLayerName || "레이어 미선택"} ·{" "}
|
||||
{isEditMode
|
||||
? activeTool
|
||||
? TOOL_LABELS[activeTool]
|
||||
: "대기"
|
||||
: "편집 모드 종료"}
|
||||
</div>
|
||||
</div>
|
||||
{onOpenPopup ? (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="flex shrink-0 items-center gap-1 rounded border border-[#b8d3ef] bg-white px-2.5 py-1.5 text-[11px] font-semibold text-[#176db8] transition hover:bg-[#eef7ff]"
|
||||
color="white"
|
||||
onClick={onOpenPopup}
|
||||
title="편집 세션을 기존 윈도우 팝업으로 열기"
|
||||
>
|
||||
<span aria-hidden="true" className="text-[12px] leading-none">↗</span>
|
||||
<span aria-hidden="true" className="text-[1.1rem] leading-none">
|
||||
↗
|
||||
</span>
|
||||
<span>팝업</span>
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={`${isCompactInline ? "hidden" : "mb-3 flex"} shrink-0 flex-wrap items-center gap-2`}>
|
||||
<button
|
||||
<div
|
||||
className={`${isCompactInline ? "hidden" : "mb-3 flex"} shrink-0 flex-wrap items-center gap-2 rounded-md border border-border bg-background p-2`}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
className="rounded border border-[#d8dee8] bg-white px-3 py-1.5 text-[12px] font-semibold text-[#304255] transition hover:bg-[#f8fafc] disabled:cursor-not-allowed disabled:text-[#9aa8b7]"
|
||||
color="white"
|
||||
disabled={!focusedChangeId}
|
||||
onClick={onRemoveFocusedChange}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
선택 취소
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="rounded border border-[#d8dee8] bg-white px-3 py-1.5 text-[12px] font-semibold text-[#304255] transition hover:bg-[#f8fafc] disabled:cursor-not-allowed disabled:text-[#9aa8b7]"
|
||||
color="white"
|
||||
disabled={!canUndo}
|
||||
onClick={onRemoveLastChange}
|
||||
>
|
||||
뒤로가기
|
||||
</button>
|
||||
<button
|
||||
최근 취소
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="rounded border border-[#f3d0d0] bg-white px-3 py-1.5 text-[12px] font-semibold text-[#b42318] transition hover:bg-[#fff5f5] disabled:cursor-not-allowed disabled:text-[#d0a5a5]"
|
||||
color="white"
|
||||
disabled={!canUndo}
|
||||
onClick={onClearChanges}
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
<div className="ml-auto rounded-full border border-[#d8dee8] bg-white px-2.5 py-1 text-[11px] font-semibold text-[#526173]">
|
||||
전체 초기화
|
||||
</Button>
|
||||
<div className="ml-auto rounded-full border border-border bg-background px-2.5 py-1 text-[1rem] font-semibold text-muted-foreground">
|
||||
총 {changes.length}건
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${isCompactInline ? "hidden" : "min-h-0 flex-1"} overflow-hidden`}>
|
||||
<div
|
||||
className={`${isCompactInline ? "hidden" : "min-h-0 flex-1"} overflow-hidden`}
|
||||
>
|
||||
{changes.length === 0 ? (
|
||||
<div className="rounded border border-dashed border-[#d8dee8] bg-[#fbfcfe] px-3 py-4 text-[12px] text-[#667485]">
|
||||
<div className="rounded-md border border-dashed border-border bg-background px-3 py-5 text-center text-[1.1rem] text-muted-foreground">
|
||||
저장할 편집 변경분이 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Check,
|
||||
Move,
|
||||
PenTool,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Check, Move, PenTool, Plus, Trash2, X } from "lucide-react";
|
||||
import { Button } from "@common/components/ui";
|
||||
|
||||
import type { GisEditTool } from "./gisEditTypes";
|
||||
@@ -45,7 +38,7 @@ export default function GisEditToolbar({
|
||||
canUseTools,
|
||||
canSave,
|
||||
isSaving = false,
|
||||
statusMessage: _statusMessage,
|
||||
statusMessage,
|
||||
allowedTools = [],
|
||||
onClose,
|
||||
onToggleSnap,
|
||||
@@ -55,26 +48,41 @@ export default function GisEditToolbar({
|
||||
if (!isEditMode) return null;
|
||||
|
||||
return (
|
||||
<div data-testid="gis-edit-toolbar" className="absolute bottom-12 left-1/2 z-[60] flex w-[min(960px,calc(100%-2rem))] -translate-x-1/2 flex-col">
|
||||
<div className="card card-border flex !bg-white py-[1px]">
|
||||
<div className="flex w-full items-center gap-2 overflow-hidden">
|
||||
<div className="flex w-[120px] shrink-0 items-center justify-start">
|
||||
<span className="whitespace-nowrap text-[1.2rem] font-bold leading-5 text-[#123b62]">하수관로 편집</span>
|
||||
<div
|
||||
data-testid="gis-edit-toolbar"
|
||||
className="absolute bottom-4 left-1/2 z-[60] flex w-[min(980px,calc(100%-2rem))] -translate-x-1/2 flex-col"
|
||||
>
|
||||
<div className="card card-border !m-0 flex !bg-background !p-[0.8rem] shadow-[0_8px_24px_rgba(23,42,70,0.18)]">
|
||||
<div className="flex w-full items-center gap-3 overflow-hidden">
|
||||
<div className="flex w-[168px] shrink-0 flex-col justify-center border-r border-border pr-3">
|
||||
<span className="whitespace-nowrap text-[1.3rem] font-semibold leading-5 text-label">
|
||||
하수관로 편집
|
||||
</span>
|
||||
<span
|
||||
className="mt-0.5 truncate text-[1.05rem] text-muted-foreground"
|
||||
title={statusMessage}
|
||||
>
|
||||
{statusMessage || "편집 도구를 선택하세요."}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 justify-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-3">
|
||||
<div className="flex items-center gap-1 rounded-md bg-container p-1">
|
||||
{TOOL_BUTTONS.map(({ tool, label, icon: Icon }) => {
|
||||
const isActive = activeTool === tool;
|
||||
const isToolEnabled =
|
||||
canUseTools && allowedTools.includes(tool);
|
||||
if (tool === "VERTEX_EDIT") {
|
||||
return (
|
||||
<div key="vertex-edit-with-snap" className="flex shrink-0 items-center">
|
||||
<div
|
||||
key="vertex-edit-with-snap"
|
||||
className="flex shrink-0 items-center"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
color={activeTool === "VERTEX_EDIT" ? "primary" : "white"}
|
||||
size="sm"
|
||||
className="mx-0.5 mt-1 min-w-[98px] px-2"
|
||||
color={
|
||||
activeTool === "VERTEX_EDIT" ? "primary" : "white"
|
||||
}
|
||||
className="min-w-[98px] px-2"
|
||||
disabled={!isToolEnabled}
|
||||
onClick={() => onSelectTool("VERTEX_EDIT")}
|
||||
>
|
||||
@@ -83,20 +91,6 @@ export default function GisEditToolbar({
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color={isSnapEnabled ? "primary" : "white"}
|
||||
size="sm"
|
||||
className="mx-0.5 mt-1 min-w-[82px] px-2"
|
||||
disabled={!canUseTools}
|
||||
onClick={onToggleSnap}
|
||||
title="이동과 꼭지점편집에 스냅핑 적용"
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
|
||||
<Check className="h-4 w-4" />
|
||||
<span>스냅핑</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -105,8 +99,7 @@ export default function GisEditToolbar({
|
||||
key={tool}
|
||||
type="button"
|
||||
color={isActive ? "primary" : "white"}
|
||||
size="sm"
|
||||
className="mx-0.5 mt-1 min-w-[74px] shrink-0 px-2"
|
||||
className="min-w-[74px] shrink-0 px-2"
|
||||
disabled={!isToolEnabled}
|
||||
onClick={() => onSelectTool(tool)}
|
||||
>
|
||||
@@ -118,14 +111,27 @@ export default function GisEditToolbar({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="h-8 w-px shrink-0 bg-border" />
|
||||
<Button
|
||||
type="button"
|
||||
color={isSnapEnabled ? "primary" : "white"}
|
||||
className="min-w-[82px] shrink-0 px-2"
|
||||
disabled={!canUseTools}
|
||||
onClick={onToggleSnap}
|
||||
title="이동과 꼭지점편집에 스냅핑 적용"
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
|
||||
<Check className="h-4 w-4" />
|
||||
<span>스냅핑</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex w-[132px] shrink-0 items-center justify-end gap-1">
|
||||
<div className="flex w-[140px] shrink-0 items-center justify-end gap-2 border-l border-border pl-3">
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
size="sm"
|
||||
className="mt-1 min-w-[58px] shrink-0 px-2"
|
||||
className="min-w-[64px] shrink-0 px-2"
|
||||
disabled={!canSave || isSaving}
|
||||
onClick={onSave}
|
||||
>
|
||||
@@ -135,14 +141,13 @@ export default function GisEditToolbar({
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
size="sm"
|
||||
className="mt-1 min-w-[58px] shrink-0 px-2"
|
||||
className="min-w-[64px] shrink-0 px-2"
|
||||
onClick={onClose}
|
||||
title="하수관로 편집 닫기"
|
||||
title="하수관로 편집 취소"
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
|
||||
<X className="h-4 w-4" />
|
||||
<span>닫기</span>
|
||||
<span>취소</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
+296
-173
@@ -2,7 +2,10 @@
|
||||
|
||||
import type { ChangeEvent } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
|
||||
import {
|
||||
StandardPopupLayout,
|
||||
type UiHeadProps,
|
||||
} from "@common/business_components";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
@@ -21,9 +24,13 @@ import {
|
||||
postRawJson,
|
||||
} from "@/components/lib/api";
|
||||
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 { useAlertStore } from "@/shared/lib";
|
||||
import { useUserStore } from "@/shared/lib/use-user-store";
|
||||
import GisUploadPreviewMap from "./GisUploadPreviewMap";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
|
||||
type UploadFacilityType = {
|
||||
facilityTypeCode: string;
|
||||
@@ -55,6 +62,24 @@ type UploadHistoryItem = {
|
||||
message?: string | null;
|
||||
};
|
||||
|
||||
type UploadHistoryRow = UploadHistoryItem &
|
||||
Record<string, unknown> & {
|
||||
rowNo: number;
|
||||
managementLabel: string;
|
||||
changeSummary: string;
|
||||
registeredAtLabel: string;
|
||||
registeredByLabel: string;
|
||||
};
|
||||
|
||||
const GIS_UPLOAD_HISTORY_HEADLIST: UiHeadProps[] = [
|
||||
{ id: "rowNo", title: "순번", widthPercent: 7 },
|
||||
{ id: "managementLabel", title: "관리기관", widthPercent: 13 },
|
||||
{ id: "fileName", title: "파일명", align: "left", widthPercent: 34 },
|
||||
{ id: "changeSummary", title: "변경/오류", widthPercent: 13 },
|
||||
{ id: "registeredAtLabel", title: "등록일자", widthPercent: 22 },
|
||||
{ id: "registeredByLabel", title: "등록자", widthPercent: 11 },
|
||||
];
|
||||
|
||||
type GisUploadResponse = {
|
||||
uploadId: string;
|
||||
status: "COMPLETED" | "VALIDATION_FAILED" | string;
|
||||
@@ -101,6 +126,38 @@ const DEFAULT_FACILITY: UploadFacilityType = {
|
||||
requiredDownloadFormat: "GPKG",
|
||||
};
|
||||
|
||||
/** 백엔드 검증 코드를 사용자가 이해할 수 있는 한글 오류내용으로 변환한다. */
|
||||
const GIS_VALIDATION_ERROR_MESSAGES: Record<string, string> = {
|
||||
GIS_UPLOAD_MNG_INST_MISMATCH:
|
||||
"선택한 관리기관과 다른 관로가 포함되어 있습니다.",
|
||||
GIS_UPLOAD_FID_DUPLICATED: "업로드 파일에 중복된 시설물 식별번호가 있습니다.",
|
||||
GIS_UPLOAD_DELETE_LIMIT_EXCEEDED:
|
||||
"삭제 대상 건수가 허용 범위를 초과했습니다.",
|
||||
};
|
||||
|
||||
/** 형상 오류는 같은 오류코드 안에서도 누락과 형식 오류를 구분해 안내한다. */
|
||||
const getLocalizedValidationErrorMessage = (error: ValidationErrorItem) => {
|
||||
const errorCode = String(error.errorCode ?? "").trim();
|
||||
const errorMessage = String(error.errorMessage ?? "").trim();
|
||||
|
||||
if (errorCode === "GIS_UPLOAD_INVALID_GEOMETRY") {
|
||||
return errorMessage.toLowerCase().includes("없는 feature")
|
||||
? "공간정보가 없는 시설물입니다."
|
||||
: "하수관로는 선 또는 다중선 형식의 공간정보만 업로드할 수 있습니다.";
|
||||
}
|
||||
|
||||
const localizedMessage = GIS_VALIDATION_ERROR_MESSAGES[errorCode];
|
||||
if (localizedMessage) {
|
||||
const count = errorMessage.match(/count\s*=\s*(\d+)/i)?.[1];
|
||||
return count ? `${localizedMessage} (대상: ${count}건)` : localizedMessage;
|
||||
}
|
||||
|
||||
// 서버가 완전한 한글 문구를 제공한 신규 오류만 보존하고 영문이 섞인 문구는 일반 안내로 대체한다.
|
||||
return /[가-힣]/.test(errorMessage) && !/[A-Za-z]/.test(errorMessage)
|
||||
? errorMessage
|
||||
: "업로드 파일 검증 중 오류가 발생했습니다.";
|
||||
};
|
||||
|
||||
const getFileExtension = (fileName: string) => {
|
||||
const lastDotIndex = fileName.lastIndexOf(".");
|
||||
if (lastDotIndex < 0) return "";
|
||||
@@ -114,7 +171,10 @@ const formatDateTime = (value?: string | null) => {
|
||||
|
||||
const toScopeKey = (mngInstCd: string) => mngInstCd || "__UNSELECTED__";
|
||||
|
||||
const buildManagementLabel = (mngInstCd?: string | null, mngInstNm?: string | null) => {
|
||||
const buildManagementLabel = (
|
||||
mngInstCd?: string | null,
|
||||
mngInstNm?: string | null,
|
||||
) => {
|
||||
if (!mngInstCd) return "-";
|
||||
return mngInstNm || mngInstCd;
|
||||
};
|
||||
@@ -123,7 +183,9 @@ const buildManagementLabel = (mngInstCd?: string | null, mngInstNm?: string | nu
|
||||
const isDownloadNetworkError = (error: unknown) => {
|
||||
if (!(error instanceof TypeError)) return false;
|
||||
const message = error.message.toLowerCase();
|
||||
return message.includes("failed to fetch") || message.includes("networkerror");
|
||||
return (
|
||||
message.includes("failed to fetch") || message.includes("networkerror")
|
||||
);
|
||||
};
|
||||
|
||||
// Retry only transport failures; HTTP validation and server errors must be shown immediately.
|
||||
@@ -133,7 +195,9 @@ const downloadGeoPackage = async (url: string) => {
|
||||
} catch (error) {
|
||||
if (!isDownloadNetworkError(error)) throw error;
|
||||
console.warn("GIS 다운로드 연결이 중단되어 1회 재시도합니다.", error);
|
||||
await new Promise((resolve) => window.setTimeout(resolve, DOWNLOAD_NETWORK_RETRY_DELAY_MS));
|
||||
await new Promise((resolve) =>
|
||||
window.setTimeout(resolve, DOWNLOAD_NETWORK_RETRY_DELAY_MS),
|
||||
);
|
||||
return getBlobDataWithHeaders(url);
|
||||
}
|
||||
};
|
||||
@@ -156,7 +220,9 @@ const waitForDownloadJob = async (jobId: string): Promise<DownloadJob> => {
|
||||
)) as DownloadJob;
|
||||
if (job?.status === "READY") return job;
|
||||
if (job?.status === "FAILED") {
|
||||
throw new Error(job.message || "GeoPackage 다운로드 파일 생성에 실패했습니다.");
|
||||
throw new Error(
|
||||
job.message || "GeoPackage 다운로드 파일 생성에 실패했습니다.",
|
||||
);
|
||||
}
|
||||
const retryAfter = Math.max(
|
||||
Number(job?.retryAfterMillis) || DOWNLOAD_JOB_POLL_INTERVAL_MS,
|
||||
@@ -164,7 +230,9 @@ const waitForDownloadJob = async (jobId: string): Promise<DownloadJob> => {
|
||||
);
|
||||
await new Promise((resolve) => window.setTimeout(resolve, retryAfter));
|
||||
}
|
||||
throw new Error("GeoPackage 파일 생성 대기 시간이 초과되었습니다. 다시 시도하세요.");
|
||||
throw new Error(
|
||||
"GeoPackage 파일 생성 대기 시간이 초과되었습니다. 다시 시도하세요.",
|
||||
);
|
||||
};
|
||||
|
||||
// Keep the object URL alive until Chrome has accepted the synthetic download click.
|
||||
@@ -181,15 +249,21 @@ const saveBlob = (blob: Blob, fileName: string) => {
|
||||
|
||||
const getErrorMessage = (error: unknown, fallback: string) => {
|
||||
const apiError = error as any;
|
||||
return apiError?.response?.data?.message
|
||||
?? apiError?.response?.data?.data?.message
|
||||
?? apiError?.body?.message
|
||||
?? apiError?.message
|
||||
?? fallback;
|
||||
return (
|
||||
apiError?.response?.data?.message ??
|
||||
apiError?.response?.data?.data?.message ??
|
||||
apiError?.body?.message ??
|
||||
apiError?.message ??
|
||||
fallback
|
||||
);
|
||||
};
|
||||
|
||||
const postRefreshMessageToOpener = (response: GisUploadResponse) => {
|
||||
if (!response.mapRefreshRequired || typeof window === "undefined" || !window.opener) {
|
||||
if (
|
||||
!response.mapRefreshRequired ||
|
||||
typeof window === "undefined" ||
|
||||
!window.opener
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -197,7 +271,9 @@ const postRefreshMessageToOpener = (response: GisUploadResponse) => {
|
||||
{
|
||||
type: WINDOW_POPUP_MESSAGE_TYPE.GIS_UPLOAD_COMPLETED,
|
||||
payload: {
|
||||
refreshLayerTypeNames: JSON.stringify(response.refreshLayerTypeNames ?? [LAYER_ID]),
|
||||
refreshLayerTypeNames: JSON.stringify(
|
||||
response.refreshLayerTypeNames ?? [LAYER_ID],
|
||||
),
|
||||
},
|
||||
},
|
||||
window.location.origin,
|
||||
@@ -205,17 +281,25 @@ const postRefreshMessageToOpener = (response: GisUploadResponse) => {
|
||||
};
|
||||
|
||||
export default function GisManagementPopupPage() {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
// 파일 input은 브라우저 보안 정책상 value 직접 제어가 제한돼 ref로 선택/초기화한다.
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [facility, setFacility] = useState<UploadFacilityType>(DEFAULT_FACILITY);
|
||||
const [facility, setFacility] =
|
||||
useState<UploadFacilityType>(DEFAULT_FACILITY);
|
||||
const [mngInstOptions, setMngInstOptions] = useState<MngInstOption[]>([]);
|
||||
const [selectedMngInstCd, setSelectedMngInstCd] = useState(SELECT_MNG_INST_VALUE);
|
||||
const [selectedMngInstCd, setSelectedMngInstCd] = useState(
|
||||
SELECT_MNG_INST_VALUE,
|
||||
);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [downloadIdsByScope, setDownloadIdsByScope] = useState<Record<string, string>>({});
|
||||
const [downloadIdsByScope, setDownloadIdsByScope] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [uploadHistory, setUploadHistory] = useState<UploadHistoryItem[]>([]);
|
||||
const [validationErrors, setValidationErrors] = useState<ValidationErrorItem[]>([]);
|
||||
const [validationErrors, setValidationErrors] = useState<
|
||||
ValidationErrorItem[]
|
||||
>([]);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("history");
|
||||
const [selectedUploadId, setSelectedUploadId] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
@@ -224,14 +308,38 @@ export default function GisManagementPopupPage() {
|
||||
const [notice, setNotice] = useState("");
|
||||
|
||||
const selectedHistory = useMemo(
|
||||
() => uploadHistory.find((history) => history.uploadId === selectedUploadId) ?? null,
|
||||
() =>
|
||||
uploadHistory.find((history) => history.uploadId === selectedUploadId) ??
|
||||
null,
|
||||
[selectedUploadId, uploadHistory],
|
||||
);
|
||||
const uploadHistoryRows = useMemo<UploadHistoryRow[]>(
|
||||
() =>
|
||||
uploadHistory.map((history) => ({
|
||||
...history,
|
||||
rowNo: 0,
|
||||
managementLabel: buildManagementLabel(
|
||||
history.mngInstCd,
|
||||
history.mngInstNm,
|
||||
),
|
||||
changeSummary: `${(history.changedCount ?? 0).toLocaleString("ko-KR")} / ${(history.invalidCount ?? 0).toLocaleString("ko-KR")}`,
|
||||
registeredAtLabel: formatDateTime(history.registeredAt),
|
||||
registeredByLabel:
|
||||
history.registeredByName ||
|
||||
(history.registeredBy === userInfo?.userUnqId
|
||||
? userInfo.userName
|
||||
: history.registeredBy) ||
|
||||
"-",
|
||||
})),
|
||||
[uploadHistory, userInfo?.userName, userInfo?.userUnqId],
|
||||
);
|
||||
|
||||
const selectedScopeKey = toScopeKey(selectedMngInstCd);
|
||||
const selectedScopeLabel = useMemo(() => {
|
||||
if (!selectedMngInstCd) return "선택";
|
||||
const matched = mngInstOptions.find((option) => option.mngInstCd === selectedMngInstCd);
|
||||
const matched = mngInstOptions.find(
|
||||
(option) => option.mngInstCd === selectedMngInstCd,
|
||||
);
|
||||
return matched?.lwrInstNm || selectedMngInstCd;
|
||||
}, [mngInstOptions, selectedMngInstCd]);
|
||||
|
||||
@@ -265,7 +373,9 @@ export default function GisManagementPopupPage() {
|
||||
params.set("mngInstCd", mngInstCd);
|
||||
|
||||
try {
|
||||
const response = await getData(`v1/map/gisUploads?${params.toString()}`);
|
||||
const response = await getData(
|
||||
`v1/map/gisUploads?${params.toString()}`,
|
||||
);
|
||||
const items = Array.isArray(response?.items) ? response.items : [];
|
||||
setUploadHistory(items);
|
||||
setTotal(Number(response?.total ?? 0));
|
||||
@@ -298,7 +408,9 @@ export default function GisManagementPopupPage() {
|
||||
if (!mounted) return;
|
||||
|
||||
const pplFacility = Array.isArray(facilityTypes)
|
||||
? facilityTypes.find((item) => item?.facilityTypeCode === FACILITY_TYPE_CODE)
|
||||
? facilityTypes.find(
|
||||
(item) => item?.facilityTypeCode === FACILITY_TYPE_CODE,
|
||||
)
|
||||
: null;
|
||||
if (pplFacility) {
|
||||
setFacility({
|
||||
@@ -354,7 +466,7 @@ export default function GisManagementPopupPage() {
|
||||
|
||||
const handleOpenFilePicker = () => {
|
||||
if (!isManagementSelected) {
|
||||
window.alert("관리기관을 선택하세요.");
|
||||
alert("관리기관을 선택하세요.");
|
||||
return;
|
||||
}
|
||||
fileInputRef.current?.click();
|
||||
@@ -370,14 +482,18 @@ export default function GisManagementPopupPage() {
|
||||
const fileExtension = getFileExtension(nextFile.name);
|
||||
if (!facility.allowedExtensions.includes(fileExtension)) {
|
||||
resetSelectedFile();
|
||||
window.alert(`업로드 가능한 파일 형식이 아닙니다. 허용 형식: ${allowedExtensionText}`);
|
||||
alert(
|
||||
`업로드 가능한 파일 형식이 아닙니다. 허용 형식: ${allowedExtensionText}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxFileSizeInBytes = facility.maxFileSizeMb * 1024 * 1024;
|
||||
if (nextFile.size > maxFileSizeInBytes) {
|
||||
resetSelectedFile();
|
||||
window.alert(`파일 크기가 제한을 초과했습니다. 최대 ${facility.maxFileSizeMb}MB까지 업로드할 수 있습니다.`);
|
||||
alert(
|
||||
`파일 크기가 제한을 초과했습니다. 최대 ${facility.maxFileSizeMb}MB까지 업로드할 수 있습니다.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -386,7 +502,7 @@ export default function GisManagementPopupPage() {
|
||||
|
||||
const handleDownloadCurrent = async () => {
|
||||
if (!isManagementSelected) {
|
||||
window.alert("관리기관을 선택하세요.");
|
||||
alert("관리기관을 선택하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -408,22 +524,32 @@ export default function GisManagementPopupPage() {
|
||||
const response = await downloadGeoPackage(
|
||||
`v1/map/gisDownloadJobs/${encodeURIComponent(job.jobId)}/file`,
|
||||
);
|
||||
const downloadId = response.headers.get("X-Gis-Download-Id") ?? readyJob.downloadId ?? "";
|
||||
const downloadId =
|
||||
response.headers.get("X-Gis-Download-Id") ?? readyJob.downloadId ?? "";
|
||||
if (!downloadId) {
|
||||
setNotice("다운로드 기준 정보를 확인하지 못했습니다. 다시 다운로드한 뒤 업로드하세요.");
|
||||
setNotice(
|
||||
"다운로드 기준 정보를 확인하지 못했습니다. 다시 다운로드한 뒤 업로드하세요.",
|
||||
);
|
||||
} else {
|
||||
setDownloadIdsByScope((current) => ({ ...current, [selectedScopeKey]: downloadId }));
|
||||
setDownloadIdsByScope((current) => ({
|
||||
...current,
|
||||
[selectedScopeKey]: downloadId,
|
||||
}));
|
||||
}
|
||||
saveBlob(
|
||||
response.blob,
|
||||
response.fileName ?? readyJob.fileName ?? `swage_ppl_${selectedScopeKey}.gpkg`,
|
||||
response.fileName ??
|
||||
readyJob.fileName ??
|
||||
`swage_ppl_${selectedScopeKey}.gpkg`,
|
||||
);
|
||||
if (downloadId) {
|
||||
setNotice(`${selectedScopeLabel} 기준 GeoPackage를 다운로드했습니다.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("GIS 다운로드 실패:", error);
|
||||
window.alert(getErrorMessage(error, "GeoPackage 다운로드 중 오류가 발생했습니다."));
|
||||
alert(
|
||||
getErrorMessage(error, "GeoPackage 다운로드 중 오류가 발생했습니다."),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -431,12 +557,12 @@ export default function GisManagementPopupPage() {
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!isManagementSelected) {
|
||||
window.alert("관리기관을 선택하세요.");
|
||||
alert("관리기관을 선택하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedFile) {
|
||||
window.alert("업로드할 파일을 선택하세요.");
|
||||
alert("업로드할 파일을 선택하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -453,9 +579,16 @@ export default function GisManagementPopupPage() {
|
||||
setIsLoading(true);
|
||||
setNotice("");
|
||||
try {
|
||||
const response = (await postMutiPart("/api/gis-upload", formData)) as GisUploadResponse;
|
||||
const response = (await postMutiPart(
|
||||
"/api/gis-upload",
|
||||
formData,
|
||||
)) as GisUploadResponse;
|
||||
if (response.status === "VALIDATION_FAILED") {
|
||||
setValidationErrors(Array.isArray(response.validationErrors) ? response.validationErrors : []);
|
||||
setValidationErrors(
|
||||
Array.isArray(response.validationErrors)
|
||||
? response.validationErrors
|
||||
: [],
|
||||
);
|
||||
setViewMode("validationErrors");
|
||||
setNotice(response.message ?? "GeoPackage 검증 오류가 있습니다.");
|
||||
return;
|
||||
@@ -469,7 +602,9 @@ export default function GisManagementPopupPage() {
|
||||
setNotice(response.message ?? "GIS 업로드가 완료되었습니다.");
|
||||
} catch (error) {
|
||||
console.error("GIS 업로드 실패:", error);
|
||||
window.alert(getErrorMessage(error, "GeoPackage 업로드 중 오류가 발생했습니다."));
|
||||
alert(
|
||||
getErrorMessage(error, "GeoPackage 업로드 중 오류가 발생했습니다."),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -478,7 +613,7 @@ export default function GisManagementPopupPage() {
|
||||
const handleDownloadSelectedHistory = async () => {
|
||||
if (viewMode !== "history") return;
|
||||
if (!selectedHistory) {
|
||||
window.alert("다운로드할 업로드 현황 행을 선택하세요.");
|
||||
alert("다운로드할 업로드 현황 행을 선택하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -495,12 +630,21 @@ export default function GisManagementPopupPage() {
|
||||
);
|
||||
saveBlob(
|
||||
response.blob,
|
||||
response.fileName ?? readyJob.fileName ?? `${selectedHistory.uploadId}.gpkg`,
|
||||
response.fileName ??
|
||||
readyJob.fileName ??
|
||||
`${selectedHistory.uploadId}.gpkg`,
|
||||
);
|
||||
setNotice(
|
||||
`${buildManagementLabel(selectedHistory.mngInstCd, selectedHistory.mngInstNm)} 기준 GeoPackage를 다운로드했습니다.`,
|
||||
);
|
||||
setNotice(`${buildManagementLabel(selectedHistory.mngInstCd, selectedHistory.mngInstNm)} 기준 GeoPackage를 다운로드했습니다.`);
|
||||
} catch (error) {
|
||||
console.error("GIS 이력 다운로드 실패:", error);
|
||||
window.alert(getErrorMessage(error, "선택한 현황 기준 GeoPackage 다운로드 중 오류가 발생했습니다."));
|
||||
alert(
|
||||
getErrorMessage(
|
||||
error,
|
||||
"선택한 현황 기준 GeoPackage 다운로드 중 오류가 발생했습니다.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -508,7 +652,7 @@ export default function GisManagementPopupPage() {
|
||||
|
||||
const fetchValidationErrors = async (uploadId = selectedUploadId) => {
|
||||
if (!uploadId) {
|
||||
window.alert("오류를 확인할 업로드 현황 행을 선택하세요.");
|
||||
alert("오류를 확인할 업로드 현황 행을 선택하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -518,11 +662,13 @@ export default function GisManagementPopupPage() {
|
||||
const response = await getData(
|
||||
`v1/map/gisUploads/${encodeURIComponent(uploadId)}/validationErrors?page=0&size=500`,
|
||||
);
|
||||
setValidationErrors(Array.isArray(response?.errors) ? response.errors : []);
|
||||
setValidationErrors(
|
||||
Array.isArray(response?.errors) ? response.errors : [],
|
||||
);
|
||||
setViewMode("validationErrors");
|
||||
} catch (error) {
|
||||
console.error("GIS 업로드 오류 조회 실패:", error);
|
||||
window.alert("업로드 오류 목록을 불러오지 못했습니다.");
|
||||
alert("업로드 오류 목록을 불러오지 못했습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -536,21 +682,20 @@ export default function GisManagementPopupPage() {
|
||||
|
||||
const handleOpenPreview = () => {
|
||||
if (!selectedHistory) {
|
||||
window.alert("미리보기할 업로드 현황 행을 선택하세요.");
|
||||
alert("미리보기할 업로드 현황 행을 선택하세요.");
|
||||
return;
|
||||
}
|
||||
setViewMode("preview");
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex h-screen flex-col overflow-hidden bg-white">
|
||||
<StandardPopupLayout
|
||||
title={windowPopupDefinitions.gisManagementPopup.title}
|
||||
>
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<header className="flex min-h-[44px] items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3">
|
||||
<h1 className="text-[1.45rem] font-semibold text-white">GIS 관리</h1>
|
||||
</header>
|
||||
|
||||
{notice ? (
|
||||
<div className="border-b border-slate-200 bg-emerald-50 px-5 py-2 text-[1rem] text-emerald-700">
|
||||
<div className="border-b border-border bg-secondary px-5 py-2 text-[1rem] text-label">
|
||||
{notice}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -558,15 +703,20 @@ export default function GisManagementPopupPage() {
|
||||
<section
|
||||
className={
|
||||
viewMode === "preview"
|
||||
? "flex min-h-0 flex-1 flex-col overflow-hidden p-4 pb-2"
|
||||
: "min-h-0 flex-1 overflow-y-auto p-4"
|
||||
? "flex min-h-0 flex-1 flex-col gap-[1.2rem] overflow-hidden p-4 pb-2"
|
||||
: "flex min-h-0 flex-1 flex-col gap-[1.2rem] p-4"
|
||||
}
|
||||
>
|
||||
<div className="grid shrink-0 grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[240px_minmax(0,1fr)_auto] md:items-end">
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">관리기관</div>
|
||||
<PopupSearchPanel
|
||||
className="shrink-0"
|
||||
contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3"
|
||||
>
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<div className="whitespace-nowrap text-xl font-medium text-label">
|
||||
관리기관
|
||||
</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="h-[3.2rem] w-[20rem] rounded-md border border-input bg-field px-3 text-xl text-foreground"
|
||||
value={selectedMngInstCd}
|
||||
onChange={handleMngInstChange}
|
||||
disabled={isLoading}
|
||||
@@ -584,8 +734,8 @@ export default function GisManagementPopupPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block min-w-0">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">
|
||||
<label className="flex min-w-[30rem] flex-1 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
업로드 파일 ({facility.facilityTypeName}, {allowedExtensionText})
|
||||
</div>
|
||||
<Input
|
||||
@@ -593,43 +743,37 @@ export default function GisManagementPopupPage() {
|
||||
placeholder="선택된 파일 없음"
|
||||
readOnly
|
||||
aria-label="선택 파일명"
|
||||
className="h-10 !text-[1.1rem]"
|
||||
className="!h-[3.2rem] min-w-0 flex-1 !text-xl"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2 md:pr-3">
|
||||
<div className="flex min-w-fit flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="green"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
color="primary"
|
||||
disabled={!isManagementSelected || isLoading}
|
||||
onClick={() => void handleDownloadCurrent()}
|
||||
>
|
||||
다운로드
|
||||
GeoPackage 다운로드
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
color="white"
|
||||
disabled={!isManagementSelected || isLoading}
|
||||
onClick={handleOpenFilePicker}
|
||||
>
|
||||
찾기
|
||||
파일찾기
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
disabled={!isManagementSelected || !selectedFile || isLoading}
|
||||
onClick={() => void handleUpload()}
|
||||
>
|
||||
업로드
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopupSearchPanel>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -639,16 +783,17 @@ export default function GisManagementPopupPage() {
|
||||
onChange={handleFileSelected}
|
||||
/>
|
||||
|
||||
<div className="card-header mt-4 flex shrink-0 items-center justify-between">
|
||||
{viewMode !== "history" ? (
|
||||
<div className="card-header flex shrink-0 items-center justify-between">
|
||||
<h3 className="text-2xl font-medium text-label">
|
||||
{viewMode === "preview" ? "GIS 업로드 미리보기" : viewMode === "validationErrors" ? "GIS 업로드 오류 확인" : `GIS 업로드 현황 총 ${total.toLocaleString("ko-KR")}건`}
|
||||
{viewMode === "preview"
|
||||
? "GIS 업로드 미리보기"
|
||||
: "GIS 업로드 오류 목록"}
|
||||
</h3>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="h-9 px-4 !text-[1rem]"
|
||||
disabled={!isManagementSelected || isLoading}
|
||||
onClick={() => void handleRefreshHistories()}
|
||||
>
|
||||
@@ -656,9 +801,15 @@ export default function GisManagementPopupPage() {
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="h-9 px-4 !text-[1rem]"
|
||||
disabled
|
||||
onClick={() => void handleDownloadSelectedHistory()}
|
||||
>
|
||||
GeoPackage 다운로드
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
disabled={!selectedHistory || isLoading}
|
||||
onClick={handleOpenPreview}
|
||||
>
|
||||
@@ -666,26 +817,15 @@ export default function GisManagementPopupPage() {
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="h-9 px-4 !text-[1rem]"
|
||||
color="white"
|
||||
disabled={!selectedHistory || isLoading}
|
||||
onClick={() => void fetchValidationErrors()}
|
||||
>
|
||||
오류 확인
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="green"
|
||||
className="h-9 px-4 !text-[1rem]"
|
||||
disabled={viewMode !== "history" || !selectedHistory || isLoading}
|
||||
onClick={() => void handleDownloadSelectedHistory()}
|
||||
>
|
||||
선택 다운로드
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{viewMode === "preview" && selectedHistory ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
@@ -695,14 +835,17 @@ export default function GisManagementPopupPage() {
|
||||
/>
|
||||
</div>
|
||||
) : viewMode === "validationErrors" ? (
|
||||
<div className="card !m-0 !p-0 overflow-x-auto">
|
||||
<Table variant="secondary" className="w-full min-w-[760px] caption-bottom text-sm">
|
||||
<div className="card !m-0 overflow-x-auto !p-0">
|
||||
<Table
|
||||
variant="secondary"
|
||||
className="w-full min-w-[640px] caption-bottom text-sm"
|
||||
>
|
||||
<TableHeader className="font-bold">
|
||||
<TableRow>
|
||||
{["no", "행번호", "FID", "오류코드", "오류내용"].map((head) => (
|
||||
{["순번", "행번호", "FID", "오류내용"].map((head) => (
|
||||
<TableHead
|
||||
key={head}
|
||||
className="border border-border bg-[#eaf3fb] px-4 py-[1.1rem] text-center text-[1.2rem] font-medium text-[#647784]"
|
||||
className="border border-border !bg-primary px-4 py-[1.1rem] text-center text-[1.2rem] font-medium !text-primary-foreground"
|
||||
>
|
||||
{head}
|
||||
</TableHead>
|
||||
@@ -712,9 +855,11 @@ export default function GisManagementPopupPage() {
|
||||
<TableBody className="text-center">
|
||||
{validationErrors.length > 0 ? (
|
||||
validationErrors.map((error, index) => (
|
||||
<TableRow key={`${error.rowNo ?? index}-${error.fid ?? ""}-${error.errorCode ?? ""}`}>
|
||||
<TableRow
|
||||
key={`${error.rowNo ?? index}-${error.fid ?? ""}-${error.errorCode ?? ""}`}
|
||||
>
|
||||
<TableCell className="border border-border px-3 py-[1rem] text-[1.15rem]">
|
||||
{index + 1}
|
||||
{validationErrors.length - index}
|
||||
</TableCell>
|
||||
<TableCell className="border border-border px-3 py-[1rem] text-[1.15rem]">
|
||||
{error.rowNo ?? "-"}
|
||||
@@ -722,17 +867,17 @@ export default function GisManagementPopupPage() {
|
||||
<TableCell className="border border-border px-3 py-[1rem] text-[1.15rem]">
|
||||
{error.fid || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="border border-border px-3 py-[1rem] text-[1.15rem]">
|
||||
{error.errorCode || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="border border-border px-3 py-[1rem] text-left text-[1.15rem]">
|
||||
{error.errorMessage || "-"}
|
||||
{getLocalizedValidationErrorMessage(error)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-24 border border-border text-center text-[1.2rem] text-gray-500">
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="h-[calc(100dvh-28rem)] min-h-96 border border-border text-center text-[1.2rem] text-muted-foreground"
|
||||
>
|
||||
표시할 오류가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -741,84 +886,62 @@ export default function GisManagementPopupPage() {
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="card !m-0 !p-0 overflow-x-auto">
|
||||
<Table variant="secondary" className="w-full min-w-[820px] caption-bottom text-sm">
|
||||
<TableHeader className="font-bold">
|
||||
<TableRow>
|
||||
{["no", "관리기관", "파일명", "상태", "변경/오류", "등록일자", "등록자"].map((head) => (
|
||||
<TableHead
|
||||
key={head}
|
||||
className="border border-border bg-[#eaf3fb] px-4 py-[1.1rem] text-center text-[1.2rem] font-medium text-[#647784]"
|
||||
>
|
||||
{head}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="text-center">
|
||||
{uploadHistory.length > 0 ? (
|
||||
uploadHistory.map((history, index) => {
|
||||
const rowNumber = (currentPage - 1) * GIS_UPLOAD_PAGE_SIZE + index + 1;
|
||||
const isSelected = history.uploadId === selectedUploadId;
|
||||
return (
|
||||
<TableRow
|
||||
key={history.uploadId}
|
||||
className={`cursor-pointer ${isSelected ? "bg-blue-50" : ""}`}
|
||||
onClick={() => setSelectedUploadId(history.uploadId)}
|
||||
>
|
||||
<TableCell className="border border-border px-3 py-[1rem] text-[1.15rem]">
|
||||
{rowNumber}
|
||||
</TableCell>
|
||||
<TableCell className="border border-border px-3 py-[1rem] text-[1.15rem]">
|
||||
{buildManagementLabel(history.mngInstCd, history.mngInstNm)}
|
||||
</TableCell>
|
||||
<TableCell className="border border-border px-3 py-[1rem] text-left text-[1.15rem]">
|
||||
{history.fileName}
|
||||
</TableCell>
|
||||
<TableCell className="border border-border px-3 py-[1rem] text-[1.15rem]">
|
||||
{history.status ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell className="border border-border px-3 py-[1rem] text-[1.15rem]">
|
||||
{(history.changedCount ?? 0).toLocaleString("ko-KR")} / {(history.invalidCount ?? 0).toLocaleString("ko-KR")}
|
||||
</TableCell>
|
||||
<TableCell className="border border-border px-3 py-[1rem] text-[1.15rem]">
|
||||
{formatDateTime(history.registeredAt)}
|
||||
</TableCell>
|
||||
<TableCell className="border border-border px-3 py-[1rem] text-[1.15rem]">
|
||||
{history.registeredByName ||
|
||||
(history.registeredBy === userInfo?.userUnqId
|
||||
? userInfo.userName
|
||||
: history.registeredBy) ||
|
||||
"-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-24 border border-border text-center text-[1.2rem] text-gray-500">
|
||||
조회 결과가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{total > GIS_UPLOAD_PAGE_SIZE ? (
|
||||
<div className="mt-3 flex justify-center">
|
||||
<UiPageNations
|
||||
<MapPopupDataTable
|
||||
rows={uploadHistoryRows}
|
||||
headlist={GIS_UPLOAD_HISTORY_HEADLIST}
|
||||
title="GIS 업로드 목록"
|
||||
total={total}
|
||||
cur={currentPage}
|
||||
page={currentPage}
|
||||
pageSize={GIS_UPLOAD_PAGE_SIZE}
|
||||
onPageChange={(page) => void fetchHistories(page, selectedMngInstCd)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
onPageChange={(page) =>
|
||||
void fetchHistories(page, selectedMngInstCd)
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
disabled={!isManagementSelected || isLoading}
|
||||
onClick={() => void handleRefreshHistories()}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
disabled={!selectedHistory || isLoading}
|
||||
onClick={() => void handleDownloadSelectedHistory()}
|
||||
>
|
||||
GeoPackage 다운로드
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
disabled={!selectedHistory || isLoading}
|
||||
onClick={handleOpenPreview}
|
||||
>
|
||||
미리보기
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
disabled={!selectedHistory || isLoading}
|
||||
onClick={() => void fetchValidationErrors()}
|
||||
>
|
||||
오류 확인
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="72rem"
|
||||
emptyMessage="조회 결과가 없습니다."
|
||||
fillAvailableHeight
|
||||
rowId={(row) => row.uploadId}
|
||||
selectedRowId={selectedUploadId}
|
||||
onRowClick={(row) => setSelectedUploadId(row.uploadId)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,8 +53,25 @@ const MIN_ZOOM_VISIBILITY_OFFSET = 0.01;
|
||||
const NETWORK_LAYER_GROUP = "SN_NETWORK";
|
||||
const PIPE_LAYER_TYPE = "tb_ms_swage_ppl";
|
||||
const NGII_WMTS_URL = `/ngii-server/openapi/Gettile.do?apikey=${process.env.NEXT_PUBLIC_NGII_PARAM ?? ""}`;
|
||||
const NGII_RESOLUTIONS = [1044.48, 522.24, 261.12, 130.56, 65.28, 32.64, 16.32, 8.16, 4.08, 2.04, 1.02, 0.51, 0.255];
|
||||
const NGII_MATRIX_IDS = ["L06", "L07", "L08", "L09", "L10", "L11", "L12", "L13", "L14", "L15", "L16", "L17", "L18"];
|
||||
const NGII_RESOLUTIONS = [
|
||||
1044.48, 522.24, 261.12, 130.56, 65.28, 32.64, 16.32, 8.16, 4.08, 2.04, 1.02,
|
||||
0.51, 0.255,
|
||||
];
|
||||
const NGII_MATRIX_IDS = [
|
||||
"L06",
|
||||
"L07",
|
||||
"L08",
|
||||
"L09",
|
||||
"L10",
|
||||
"L11",
|
||||
"L12",
|
||||
"L13",
|
||||
"L14",
|
||||
"L15",
|
||||
"L16",
|
||||
"L17",
|
||||
"L18",
|
||||
];
|
||||
|
||||
let isProjectionRegistered = false;
|
||||
|
||||
@@ -74,7 +91,11 @@ const createNgiiBaseLayer = () => {
|
||||
registerMapProjection();
|
||||
const projection = getProjection(MAP_SRS);
|
||||
const tileGrid = new WMTSTileGrid({
|
||||
origin: getTopLeft(projection?.getExtent() ?? [-200000.0, -28024123.62, 31824123.62, 4000000.0]),
|
||||
origin: getTopLeft(
|
||||
projection?.getExtent() ?? [
|
||||
-200000.0, -28024123.62, 31824123.62, 4000000.0,
|
||||
],
|
||||
),
|
||||
resolutions: NGII_RESOLUTIONS,
|
||||
matrixIds: NGII_MATRIX_IDS,
|
||||
tileSize: [256, 256],
|
||||
@@ -91,8 +112,10 @@ const createNgiiBaseLayer = () => {
|
||||
});
|
||||
source.setTileUrlFunction((tileCoord) => {
|
||||
const [z, x, y] = tileCoord;
|
||||
return NGII_WMTS_URL
|
||||
.replace("{z}", NGII_MATRIX_IDS[z ?? 0] ?? NGII_MATRIX_IDS[0] ?? "L06")
|
||||
return NGII_WMTS_URL.replace(
|
||||
"{z}",
|
||||
NGII_MATRIX_IDS[z ?? 0] ?? NGII_MATRIX_IDS[0] ?? "L06",
|
||||
)
|
||||
.replace("{x}", String(x))
|
||||
.replace("{y}", String(y));
|
||||
});
|
||||
@@ -109,7 +132,8 @@ const getLayerTypeName = (layer: LayerSetting) =>
|
||||
String(layer.lyrTyNm ?? layer.lyrTypeNm ?? layer.lyr_type_nm ?? "").trim();
|
||||
|
||||
const toFiniteNumber = (value: unknown, fallback: number) => {
|
||||
if (value === null || value === undefined || String(value).trim() === "") return fallback;
|
||||
if (value === null || value === undefined || String(value).trim() === "")
|
||||
return fallback;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
};
|
||||
@@ -122,8 +146,10 @@ const normalizeColor = (value: unknown, fallback: string) => {
|
||||
const getLineColor = (layer?: LayerSetting | null) =>
|
||||
normalizeColor(layer?.lnColor ?? layer?.lineColr, "#f97316");
|
||||
|
||||
const getLayerMinZoom = (layer: LayerSetting | null | undefined, fallback: number) =>
|
||||
toFiniteNumber(layer?.minViewSize ?? layer?.minZoom, fallback);
|
||||
const getLayerMinZoom = (
|
||||
layer: LayerSetting | null | undefined,
|
||||
fallback: number,
|
||||
) => toFiniteNumber(layer?.minViewSize ?? layer?.minZoom, fallback);
|
||||
|
||||
const getNetworkPipeLineWidth = (layer?: LayerSetting | null) =>
|
||||
Math.max(1, toFiniteNumber(layer?.lnWdth ?? layer?.lineWdth, 1));
|
||||
@@ -131,7 +157,8 @@ const getNetworkPipeLineWidth = (layer?: LayerSetting | null) =>
|
||||
const getHighlightedPipeLineWidth = (layer?: LayerSetting | null) =>
|
||||
Math.max(3, getNetworkPipeLineWidth(layer) + 2) + 3;
|
||||
|
||||
const pipePreviewStyle = (layerSetting?: LayerSetting | null) => (feature: FeatureLike) => {
|
||||
const pipePreviewStyle =
|
||||
(layerSetting?: LayerSetting | null) => (feature: FeatureLike) => {
|
||||
const status = feature.get("previewStatus");
|
||||
if (status !== "CREATED" && status !== "UPDATED") {
|
||||
return new Style({
|
||||
@@ -158,7 +185,7 @@ const pipePreviewStyle = (layerSetting?: LayerSetting | null) => (feature: Featu
|
||||
}),
|
||||
}),
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
const unwrapFeatureCollection = (response: any) => response?.data ?? response;
|
||||
|
||||
@@ -190,10 +217,15 @@ const getFeatureExtent = (features: ReturnType<GeoJSON["readFeatures"]>) => {
|
||||
return [minX, minY, maxX, maxY] as [number, number, number, number];
|
||||
};
|
||||
|
||||
const getFeatureExtentCenter = (features: ReturnType<GeoJSON["readFeatures"]>) => {
|
||||
const getFeatureExtentCenter = (
|
||||
features: ReturnType<GeoJSON["readFeatures"]>,
|
||||
) => {
|
||||
const extent = getFeatureExtent(features);
|
||||
if (!extent) return null;
|
||||
const targetCenter: [number, number] = [(extent[0] + extent[2]) / 2, (extent[1] + extent[3]) / 2];
|
||||
const targetCenter: [number, number] = [
|
||||
(extent[0] + extent[2]) / 2,
|
||||
(extent[1] + extent[3]) / 2,
|
||||
];
|
||||
return features.reduce<[number, number] | null>((nearestCenter, feature) => {
|
||||
const featureExtent = feature.getGeometry()?.getExtent();
|
||||
if (!featureExtent) return nearestCenter;
|
||||
@@ -202,15 +234,20 @@ const getFeatureExtentCenter = (features: ReturnType<GeoJSON["readFeatures"]>) =
|
||||
(featureExtent[1]! + featureExtent[3]!) / 2,
|
||||
];
|
||||
if (!nearestCenter) return featureCenter;
|
||||
const nearestDistance = ((nearestCenter[0] - targetCenter[0]) ** 2)
|
||||
+ ((nearestCenter[1] - targetCenter[1]) ** 2);
|
||||
const featureDistance = ((featureCenter[0] - targetCenter[0]) ** 2)
|
||||
+ ((featureCenter[1] - targetCenter[1]) ** 2);
|
||||
const nearestDistance =
|
||||
(nearestCenter[0] - targetCenter[0]) ** 2 +
|
||||
(nearestCenter[1] - targetCenter[1]) ** 2;
|
||||
const featureDistance =
|
||||
(featureCenter[0] - targetCenter[0]) ** 2 +
|
||||
(featureCenter[1] - targetCenter[1]) ** 2;
|
||||
return featureDistance < nearestDistance ? featureCenter : nearestCenter;
|
||||
}, null);
|
||||
};
|
||||
|
||||
export default function GisUploadPreviewMap({ uploadId, mngInstCd }: GisUploadPreviewMapProps) {
|
||||
export default function GisUploadPreviewMap({
|
||||
uploadId,
|
||||
mngInstCd,
|
||||
}: GisUploadPreviewMapProps) {
|
||||
const mapElementRef = useRef<HTMLDivElement | null>(null);
|
||||
const mapRef = useRef<Map | null>(null);
|
||||
const pipeSourceRef = useRef(new VectorSource());
|
||||
@@ -226,9 +263,15 @@ export default function GisUploadPreviewMap({ uploadId, mngInstCd }: GisUploadPr
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const flatLayerSettings = useMemo(() => flattenLayerSettings(layerSettings), [layerSettings]);
|
||||
const flatLayerSettings = useMemo(
|
||||
() => flattenLayerSettings(layerSettings),
|
||||
[layerSettings],
|
||||
);
|
||||
const pipeLayerSetting = useMemo(
|
||||
() => flatLayerSettings.find((layer) => getLayerTypeName(layer) === PIPE_LAYER_TYPE) ?? null,
|
||||
() =>
|
||||
flatLayerSettings.find(
|
||||
(layer) => getLayerTypeName(layer) === PIPE_LAYER_TYPE,
|
||||
) ?? null,
|
||||
[flatLayerSettings],
|
||||
);
|
||||
const pipeMinZoom = getLayerMinZoom(pipeLayerSetting, FALLBACK_PIPE_MIN_ZOOM);
|
||||
@@ -237,10 +280,19 @@ export default function GisUploadPreviewMap({ uploadId, mngInstCd }: GisUploadPr
|
||||
let mounted = true;
|
||||
const loadLayerSettings = async () => {
|
||||
try {
|
||||
const networkSettings = await getRawJson(`/sewerage-service/api/v1/map/lyrStngs/${NETWORK_LAYER_GROUP}`);
|
||||
const filtered = flattenLayerSettings(Array.isArray(networkSettings) ? networkSettings : [])
|
||||
const networkSettings = await getRawJson(
|
||||
`/sewerage-service/api/v1/map/lyrStngs/${NETWORK_LAYER_GROUP}`,
|
||||
);
|
||||
const filtered = flattenLayerSettings(
|
||||
Array.isArray(networkSettings) ? networkSettings : [],
|
||||
)
|
||||
.filter((layer) => getLayerTypeName(layer) === PIPE_LAYER_TYPE)
|
||||
.filter((layer) => (layer.useYn ?? "Y") === "Y" && (layer.exprsYn ?? "Y") === "Y" && (layer.actlLyrYn ?? "Y") === "Y");
|
||||
.filter(
|
||||
(layer) =>
|
||||
(layer.useYn ?? "Y") === "Y" &&
|
||||
(layer.exprsYn ?? "Y") === "Y" &&
|
||||
(layer.actlLyrYn ?? "Y") === "Y",
|
||||
);
|
||||
if (mounted) {
|
||||
setLayerSettings(filtered);
|
||||
}
|
||||
@@ -264,7 +316,11 @@ export default function GisUploadPreviewMap({ uploadId, mngInstCd }: GisUploadPr
|
||||
const initializeMap = () => {
|
||||
const mapElement = mapElementRef.current;
|
||||
if (disposed || mapRef.current) return;
|
||||
if (!mapElement || mapElement.clientWidth <= 0 || mapElement.clientHeight <= 0) {
|
||||
if (
|
||||
!mapElement ||
|
||||
mapElement.clientWidth <= 0 ||
|
||||
mapElement.clientHeight <= 0
|
||||
) {
|
||||
initializeTimer = window.setTimeout(initializeMap, 100);
|
||||
return;
|
||||
}
|
||||
@@ -338,7 +394,8 @@ export default function GisUploadPreviewMap({ uploadId, mngInstCd }: GisUploadPr
|
||||
didFitPreviewRef.current = false;
|
||||
}, [mngInstCd, uploadId]);
|
||||
|
||||
const loadPreview = useCallback(async function loadPreviewRequest() {
|
||||
const loadPreview = useCallback(
|
||||
async function loadPreviewRequest() {
|
||||
const map = mapRef.current;
|
||||
if (!map || !uploadId) return;
|
||||
|
||||
@@ -392,8 +449,12 @@ export default function GisUploadPreviewMap({ uploadId, mngInstCd }: GisUploadPr
|
||||
pipeSourceRef.current.addFeatures(pipeFeatures);
|
||||
|
||||
if (!didFitPreviewRef.current && pipeFeatures.length > 0) {
|
||||
const changedFeatures = pipeFeatures.filter((feature) => feature.get("previewStatus") !== "LATEST");
|
||||
const center = getFeatureExtentCenter(changedFeatures.length > 0 ? changedFeatures : pipeFeatures);
|
||||
const changedFeatures = pipeFeatures.filter(
|
||||
(feature) => feature.get("previewStatus") !== "LATEST",
|
||||
);
|
||||
const center = getFeatureExtentCenter(
|
||||
changedFeatures.length > 0 ? changedFeatures : pipeFeatures,
|
||||
);
|
||||
if (center) {
|
||||
map.getView().setCenter(center);
|
||||
map.getView().setZoom(pipeMinZoom + MIN_ZOOM_VISIBILITY_OFFSET);
|
||||
@@ -413,7 +474,9 @@ export default function GisUploadPreviewMap({ uploadId, mngInstCd }: GisUploadPr
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [mngInstCd, pipeMinZoom, uploadId]);
|
||||
},
|
||||
[mngInstCd, pipeMinZoom, uploadId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
@@ -439,22 +502,28 @@ export default function GisUploadPreviewMap({ uploadId, mngInstCd }: GisUploadPr
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-2xl font-medium text-label">업로드 이력 미리보기</h3>
|
||||
<p className="mt-1 text-[1rem] text-slate-600">
|
||||
변경 관로는 업로드 이력 기준으로 강조하고, 변경 없는 관로는 관망도와 동일한 스타일로 표시합니다.
|
||||
<h3 className="text-2xl font-medium text-label">
|
||||
업로드 이력 미리보기
|
||||
</h3>
|
||||
<p className="mt-1 text-[1rem] text-label">
|
||||
변경 관로는 업로드 이력 기준으로 강조하고, 변경 없는 관로는 관망도와
|
||||
동일한 스타일로 표시합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex flex-wrap gap-3 text-[0.95rem] text-slate-700">
|
||||
<div className="mb-2 flex flex-wrap gap-3 text-[0.95rem] text-label">
|
||||
<span>
|
||||
<b className="text-green-600">신규</b> / <b className="text-orange-500">수정</b> /{" "}
|
||||
<b className="text-green-600">신규</b> /{" "}
|
||||
<b className="text-orange-500">수정</b> /{" "}
|
||||
<b style={{ color: getLineColor(pipeLayerSetting) }}>관망도 기본</b>
|
||||
</span>
|
||||
<span>관로 표출 줌 {pipeMinZoom} 이상</span>
|
||||
<span>관로 {featureCount.toLocaleString("ko-KR")}건</span>
|
||||
<span>삭제 미표시 {deletedCount.toLocaleString("ko-KR")}건</span>
|
||||
{truncated ? <span className="text-red-600">조회 상한 초과</span> : null}
|
||||
{truncated ? (
|
||||
<span className="text-red-600">조회 상한 초과</span>
|
||||
) : null}
|
||||
{isLoading ? <span>불러오는 중...</span> : null}
|
||||
</div>
|
||||
|
||||
@@ -466,7 +535,7 @@ export default function GisUploadPreviewMap({ uploadId, mngInstCd }: GisUploadPr
|
||||
|
||||
<div
|
||||
ref={mapElementRef}
|
||||
className="gis-upload-preview-map min-h-0 flex-1 overflow-hidden rounded-md border border-slate-300 bg-slate-100"
|
||||
className="gis-upload-preview-map min-h-0 flex-1 overflow-hidden rounded-md border border-border bg-secondary"
|
||||
/>
|
||||
|
||||
<style jsx global>{`
|
||||
|
||||
@@ -2,19 +2,22 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { DateMonthPicker } from "@common/business_components/ui/date_month_picker";
|
||||
import { UiTable } from "@common/business_components/ui/table";
|
||||
import type {
|
||||
UiHeadProps,
|
||||
UiTableSort,
|
||||
} from "@common/business_components/ui/table";
|
||||
import {
|
||||
StandardPopupLayout,
|
||||
type UiHeadProps,
|
||||
} from "@common/business_components";
|
||||
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import { getData } from "@/components/lib/api";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
|
||||
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
|
||||
import useStore from "@/store/useStore";
|
||||
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
|
||||
import {
|
||||
INFTWT_RESULT_ROW_SELECT_MESSAGE,
|
||||
@@ -24,8 +27,8 @@ import {
|
||||
|
||||
const INFTWT_RESULT_PAGE_SIZE = 10;
|
||||
|
||||
const INFTWT_RESULT_HEADLIST = [
|
||||
{ id: "rowNumber", title: "일련번호" },
|
||||
const INFTWT_RESULT_HEADLIST: UiHeadProps[] = [
|
||||
{ id: "rowNumber", title: "순번" },
|
||||
{ id: "ctpvNm", title: "시도" },
|
||||
{ id: "sggNm", title: "시군구" },
|
||||
{ id: "swtrmtFcltNm", title: "하수처리시설" },
|
||||
@@ -35,18 +38,7 @@ const INFTWT_RESULT_HEADLIST = [
|
||||
{ id: "regDt", title: "분석일자" },
|
||||
{ id: "rgtrNm", title: "분석자" },
|
||||
{ id: "scsYn", title: "분석성공여부" },
|
||||
] as const;
|
||||
// 화면 표시명과 분석 Entity 필드가 다른 열은 서버 정렬 키를 명시한다.
|
||||
const INFTWT_RESULT_SORT_KEYS: Record<string, string> = {
|
||||
ctpvNm: "ctpvCd",
|
||||
sggNm: "sggCd",
|
||||
};
|
||||
const INFTWT_RESULT_SORTABLE_HEADLIST: UiHeadProps[] = INFTWT_RESULT_HEADLIST.map((head) => ({
|
||||
...head,
|
||||
// 분석자명은 목록 조회 후 외부 사용자 서비스에서 보강되므로 전체 결과 정렬에서 제외한다.
|
||||
sortable: head.id !== "rgtrNm",
|
||||
sortKey: INFTWT_RESULT_SORT_KEYS[head.id] ?? head.id,
|
||||
}));
|
||||
];
|
||||
|
||||
type InftwtResultFilters = {
|
||||
ctpvCd: string;
|
||||
@@ -99,6 +91,7 @@ const postRowSelectMessage = (row: InftwtResultRow) => {
|
||||
};
|
||||
|
||||
export default function InftwtResultPopupPage() {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
const [filters, setFilters] = useState<InftwtResultFilters>(DEFAULT_FILTERS);
|
||||
const [selectedSwagePrcsFclt, setSelectedSwagePrcsFclt] = useState("0");
|
||||
@@ -106,9 +99,7 @@ export default function InftwtResultPopupPage() {
|
||||
const [rows, setRows] = useState<InftwtResultRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
|
||||
useEffect(() => {
|
||||
if (ctpv.length === 0) void fetchCtpv();
|
||||
@@ -116,23 +107,24 @@ export default function InftwtResultPopupPage() {
|
||||
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
|
||||
|
||||
const fetchRows = useCallback(
|
||||
async (page: number, nextFilters: InftwtResultFilters, nextSort?: UiTableSort) => {
|
||||
async (page: number, nextFilters: InftwtResultFilters) => {
|
||||
const queryParams = new URLSearchParams({
|
||||
page: String(page - 1),
|
||||
size: String(INFTWT_RESULT_PAGE_SIZE),
|
||||
scsYn: "Y",
|
||||
});
|
||||
const requestSort = nextSort?.key === "rowNumber"
|
||||
? { key: "inftwtAnlsSn", direction: nextSort.direction === "asc" ? "desc" : "asc" }
|
||||
: nextSort;
|
||||
if (requestSort) queryParams.append("sort", `${requestSort.key},${requestSort.direction}`);
|
||||
if (requestSort?.key !== "inftwtAnlsSn") queryParams.append("sort", "inftwtAnlsSn,desc");
|
||||
queryParams.append("sort", "inftwtAnlsSn,desc");
|
||||
|
||||
if (nextFilters.aplcnYmFrom) queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom);
|
||||
if (nextFilters.aplcnYmTo) queryParams.set("aplcnYmTo", nextFilters.aplcnYmTo);
|
||||
if (nextFilters.ctpvCd !== "0") queryParams.set("ctpvCd", nextFilters.ctpvCd);
|
||||
if (nextFilters.sggCd !== "0") queryParams.set("sggCd", nextFilters.sggCd);
|
||||
if (nextFilters.swtrmtFcltNm.trim()) queryParams.set("swtrmtFcltNm", nextFilters.swtrmtFcltNm.trim());
|
||||
if (nextFilters.aplcnYmFrom)
|
||||
queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom);
|
||||
if (nextFilters.aplcnYmTo)
|
||||
queryParams.set("aplcnYmTo", nextFilters.aplcnYmTo);
|
||||
if (nextFilters.ctpvCd !== "0")
|
||||
queryParams.set("ctpvCd", nextFilters.ctpvCd);
|
||||
if (nextFilters.sggCd !== "0")
|
||||
queryParams.set("sggCd", nextFilters.sggCd);
|
||||
if (nextFilters.swtrmtFcltNm.trim())
|
||||
queryParams.set("swtrmtFcltNm", nextFilters.swtrmtFcltNm.trim());
|
||||
|
||||
const response = await getData(`v1/inftwtAnls?${queryParams.toString()}`);
|
||||
const content = Array.isArray(response?.content) ? response.content : [];
|
||||
@@ -141,14 +133,17 @@ export default function InftwtResultPopupPage() {
|
||||
const nextRows = content
|
||||
.map((item: Record<string, unknown>, index: number) => {
|
||||
const formattedItem = formatDisplayData(item);
|
||||
const ctpvOption = ctpv.find((code) => code.val.trim() === String(item?.ctpvCd ?? "").trim());
|
||||
const ctpvOption = ctpv.find(
|
||||
(code) => code.val.trim() === String(item?.ctpvCd ?? "").trim(),
|
||||
);
|
||||
const sggOption = sgg.find((code) => code.val === item?.sggCd);
|
||||
|
||||
return {
|
||||
...(formattedItem as Record<string, unknown>),
|
||||
rowNumber: nextSort?.key === "rowNumber" && nextSort.direction === "desc"
|
||||
? totalElements - ((page - 1) * INFTWT_RESULT_PAGE_SIZE + index)
|
||||
: (page - 1) * INFTWT_RESULT_PAGE_SIZE + index + 1,
|
||||
rowNumber: Math.max(
|
||||
totalElements - ((page - 1) * INFTWT_RESULT_PAGE_SIZE + index),
|
||||
0,
|
||||
),
|
||||
ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"),
|
||||
sggNm: sggOption ? sggOption.text : String(item?.sggCd ?? "-"),
|
||||
scsYn: String(item?.scsYn ?? "N"),
|
||||
@@ -164,10 +159,10 @@ export default function InftwtResultPopupPage() {
|
||||
);
|
||||
|
||||
const loadRows = useCallback(
|
||||
async (page = 1, nextFilters = filters, nextSort = sort) => {
|
||||
async (page = 1, nextFilters = filters) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await fetchRows(page, nextFilters, nextSort);
|
||||
await fetchRows(page, nextFilters);
|
||||
} catch (error) {
|
||||
console.error("침입수 분석결과 목록 조회 중 오류:", error);
|
||||
alert("침입수 분석결과 목록을 불러오지 못했습니다.");
|
||||
@@ -177,7 +172,7 @@ export default function InftwtResultPopupPage() {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[fetchRows, filters, sort],
|
||||
[alert, fetchRows, filters],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -186,17 +181,16 @@ export default function InftwtResultPopupPage() {
|
||||
}, []);
|
||||
|
||||
const handleSearch = async (page = 1) => {
|
||||
setSelectedIndex(-1);
|
||||
setSelectedRow(null);
|
||||
await loadRows(page, filters);
|
||||
};
|
||||
|
||||
/** Applies server sorting while keeping the current search filters. */
|
||||
const handleSortChange = async (nextSort?: UiTableSort) => {
|
||||
setSort(nextSort);
|
||||
setSelectedIndex(-1);
|
||||
/** CS 검색 카드와 동일하게 조건과 선택 결과를 기본값으로 되돌린다. */
|
||||
const handleReset = async () => {
|
||||
setFilters(DEFAULT_FILTERS);
|
||||
setSelectedSwagePrcsFclt("0");
|
||||
setSelectedRow(null);
|
||||
await loadRows(1, filters, nextSort);
|
||||
await loadRows(1, DEFAULT_FILTERS);
|
||||
};
|
||||
|
||||
const handleRowClick = (index?: number) => {
|
||||
@@ -204,7 +198,6 @@ export default function InftwtResultPopupPage() {
|
||||
const row = rows[index];
|
||||
if (!row) return;
|
||||
|
||||
setSelectedIndex(index);
|
||||
setSelectedRow(row);
|
||||
postRowSelectMessage(row);
|
||||
};
|
||||
@@ -217,8 +210,12 @@ export default function InftwtResultPopupPage() {
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await getData(`v1/inftwtAnlsRslt/ppl/${selectedRow.inftwtAnlsSn}`);
|
||||
const excelRows = Array.isArray(response?.content) ? response.content : [];
|
||||
const response = await getData(
|
||||
`v1/inftwtAnlsRslt/ppl/${selectedRow.inftwtAnlsSn}`,
|
||||
);
|
||||
const excelRows = Array.isArray(response?.content)
|
||||
? response.content
|
||||
: [];
|
||||
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: excelRows as JSON[],
|
||||
@@ -226,7 +223,7 @@ export default function InftwtResultPopupPage() {
|
||||
sheetNameArray: ["침입수 분석결과"],
|
||||
headerRange: "A1:D1",
|
||||
headerMapping: {
|
||||
inftwtAnlsSn: "침입수분석\n일련번호",
|
||||
inftwtAnlsSn: "침입수분석\n분석번호",
|
||||
pplMngNo: "관로\n관리번호",
|
||||
anlsTm: "분석시각",
|
||||
predcOilq: "예측유량\n(m³/h)",
|
||||
@@ -241,17 +238,16 @@ export default function InftwtResultPopupPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="sn-popup-page">
|
||||
<StandardPopupLayout
|
||||
title={windowPopupDefinitions.inftwtResultPopup.title}
|
||||
bodyClassName="overflow-x-hidden"
|
||||
>
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<header className="sn-popup-header">
|
||||
<h1 className="sn-popup-title">침입수 분석결과</h1>
|
||||
</header>
|
||||
|
||||
<section className="sn-popup-section overflow-y-auto">
|
||||
<div className="rounded-md border border-border bg-secondary p-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.05rem] font-medium text-label">상수사용기간</span>
|
||||
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.3rem] font-medium text-label">
|
||||
상수사용기간
|
||||
</span>
|
||||
<DateMonthPicker
|
||||
field={{
|
||||
value: filters.aplcnYmFrom,
|
||||
@@ -262,10 +258,12 @@ export default function InftwtResultPopupPage() {
|
||||
}));
|
||||
},
|
||||
}}
|
||||
selectClass="w-[145px]"
|
||||
selectClass="w-[145px] !text-[1.3rem]"
|
||||
startYear={1900}
|
||||
/>
|
||||
<span className="text-[1.1rem] font-semibold text-muted-foreground">~</span>
|
||||
<span className="text-[1.1rem] font-semibold text-muted-foreground">
|
||||
~
|
||||
</span>
|
||||
<DateMonthPicker
|
||||
field={{
|
||||
value: filters.aplcnYmTo,
|
||||
@@ -276,13 +274,16 @@ export default function InftwtResultPopupPage() {
|
||||
}));
|
||||
},
|
||||
}}
|
||||
selectClass="w-[145px]"
|
||||
selectClass="w-[145px] !text-[1.3rem]"
|
||||
startYear={1900}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 overflow-x-auto">
|
||||
<div className="min-w-0 shrink-0">
|
||||
<CtpvSggPrcsFcltSelectBox
|
||||
compact
|
||||
className="!justify-start"
|
||||
selectValueClassName="!text-[1.3rem]"
|
||||
sido={filters.ctpvCd}
|
||||
sigungu={filters.sggCd}
|
||||
sggName={filters.sggName}
|
||||
@@ -324,58 +325,51 @@ export default function InftwtResultPopupPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div className="ml-auto flex shrink-0 items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
onClick={() => void handleSearch(1)}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="green"
|
||||
size="sm"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
onClick={() => void handleDownload()}
|
||||
color="white"
|
||||
onClick={() => void handleReset()}
|
||||
>
|
||||
다운로드
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopupSearchPanel>
|
||||
|
||||
<div className="mt-4">
|
||||
<UiTable<InftwtResultRow>
|
||||
headName={<>{`분석이력 총 ${total.toLocaleString()}건`}</>}
|
||||
tableData={rows}
|
||||
headlist={INFTWT_RESULT_SORTABLE_HEADLIST}
|
||||
pageSize={INFTWT_RESULT_PAGE_SIZE}
|
||||
<MapPopupDataTable
|
||||
rows={rows as Array<InftwtResultRow & Record<string, unknown>>}
|
||||
headlist={INFTWT_RESULT_HEADLIST}
|
||||
title="침입수 분석결과 목록"
|
||||
total={total}
|
||||
cellClick={(index) => handleRowClick(index)}
|
||||
cellClassName={(_, headId) =>
|
||||
headId === "anlsInfo"
|
||||
? "whitespace-normal break-all leading-[1.35]"
|
||||
: ""
|
||||
}
|
||||
page={currentPage}
|
||||
pageSize={INFTWT_RESULT_PAGE_SIZE}
|
||||
onPageChange={(page) => void handleSearch(page)}
|
||||
curPage={currentPage}
|
||||
index={selectedIndex}
|
||||
widthFixed={true}
|
||||
sort={sort}
|
||||
onSortChange={(nextSort) => void handleSortChange(nextSort)}
|
||||
onRowClick={(_, index) => handleRowClick(index)}
|
||||
rowId={(row) => String(row.inftwtAnlsSn)}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="110rem"
|
||||
actions={
|
||||
<Button type="button" onClick={() => void handleDownload()}>
|
||||
엑셀다운로드
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedRow ? (
|
||||
<div className="mt-3 rounded-md border border-border bg-secondary px-4 py-3 text-[1rem] text-label">
|
||||
<div className="rounded-sm border border-border bg-secondary px-4 py-3 text-[1rem] text-label">
|
||||
{"선택 분석: "}
|
||||
<span className="font-semibold text-foreground">{selectedRow.anlsInfo}</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
{selectedRow.anlsInfo}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { intersects as intersectsExtent } from "ol/extent";
|
||||
|
||||
import { getRawJson } from "@/components/lib/api";
|
||||
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
|
||||
import GisApp from "@common/business_components/ui/map/lib/gis.core";
|
||||
import { getSigunguCenter, moveMapToCenter, sidoCenter } from "@common/business_components/ui/map/searchBar/utils";
|
||||
@@ -194,6 +195,7 @@ export const useInftwtResultMap = ({
|
||||
setIsOpen,
|
||||
setIsSearchModalOpen,
|
||||
}: UseInftwtResultMapParams) => {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const activeResultRowRef = useRef<InftwtResultRow | null>(null);
|
||||
const zoneLayerRef = useRef<VectorLayer<any> | null>(null);
|
||||
const zoneSourceRef = useRef<VectorSource | null>(null);
|
||||
@@ -433,10 +435,10 @@ export const useInftwtResultMap = ({
|
||||
void renderMapView(mapView);
|
||||
} catch (error) {
|
||||
console.error("移⑥엯??遺꾩꽍寃곌낵 吏???곌퀎 以??ㅻ쪟:", error);
|
||||
alert("移⑥엯??遺꾩꽍寃곌낵瑜?吏?꾩뿉 ?곌퀎?섏? 紐삵뻽?듬땲??");
|
||||
alert("침입수 분석결과를 지도에 연계하지 못했습니다.");
|
||||
}
|
||||
},
|
||||
[renderMapView, setFeatureData, setIsOpen, setIsSearchModalOpen],
|
||||
[alert, renderMapView, setFeatureData, setIsOpen, setIsSearchModalOpen],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -494,11 +496,11 @@ export const useInftwtResultMap = ({
|
||||
});
|
||||
|
||||
if (!popupWindow) {
|
||||
alert("?앹뾽??李⑤떒?섏뿀?듬땲?? 釉뚮씪?곗? ?앹뾽 ?덉슜 ?ㅼ젙???뺤씤?댁<?몄슂.");
|
||||
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [activeMapView]);
|
||||
}, [activeMapView, alert]);
|
||||
|
||||
return {
|
||||
activeMapView,
|
||||
|
||||
+174
-115
@@ -3,13 +3,15 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Button, Input } from "@common/components";
|
||||
import { UiTable } from "@common/business_components/ui/table";
|
||||
import type { UiTableSort } from "@common/business_components/ui/table";
|
||||
import { StandardPopupLayout } from "@common/business_components";
|
||||
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
|
||||
import { getData } from "@/components/lib/api";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import useStore from "@/store/useStore";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { useUserStore } from "@/shared/lib/use-user-store";
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
import {
|
||||
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS,
|
||||
PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS,
|
||||
@@ -44,21 +46,15 @@ const PIPE_ANALYSIS_DETAIL_EXPORT_KEYS = [
|
||||
"prlgVl",
|
||||
"materialName",
|
||||
] as const;
|
||||
// 표시명 열은 원천 코드로, NO 열은 서버의 전체 순번 계약으로 정렬한다.
|
||||
const PIPE_ANALYSIS_DETAIL_SORT_KEYS: Record<string, string> = {
|
||||
useNm: "useCd",
|
||||
materialName: "materialCode",
|
||||
};
|
||||
const PIPE_ANALYSIS_DETAIL_SORTABLE_HEADLIST = PIPE_ANALYSIS_DETAIL_HEADLIST.map((head) => ({
|
||||
...head,
|
||||
sortable: true,
|
||||
sortKey: PIPE_ANALYSIS_DETAIL_SORT_KEYS[head.id] ?? head.id,
|
||||
}));
|
||||
const PIPE_ANALYSIS_DETAIL_STANDARD_HEADLIST = PIPE_ANALYSIS_DETAIL_HEADLIST;
|
||||
|
||||
const resolveErrorMessage = (error: unknown, fallbackMessage: string) =>
|
||||
error instanceof Error && error.message.trim() ? error.message : fallbackMessage;
|
||||
error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: fallbackMessage;
|
||||
|
||||
export default function PipeAnalysisDetailPopupPage() {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const searchParams = useSearchParams();
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
@@ -71,21 +67,34 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
applyScopeToDetailFilters(
|
||||
{
|
||||
...PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS,
|
||||
ctpvCd: searchParams.get("ctpvCd") ?? PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.ctpvCd,
|
||||
sggCd: searchParams.get("sggCd") ?? PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.sggCd,
|
||||
swtrmtZoneNm: searchParams.get("swtrmtZoneNm") ?? PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.swtrmtZoneNm,
|
||||
ctpvCd:
|
||||
searchParams.get("ctpvCd") ??
|
||||
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.ctpvCd,
|
||||
sggCd:
|
||||
searchParams.get("sggCd") ??
|
||||
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.sggCd,
|
||||
swtrmtZoneNm:
|
||||
searchParams.get("swtrmtZoneNm") ??
|
||||
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.swtrmtZoneNm,
|
||||
materialCode: baseMaterialCode,
|
||||
},
|
||||
scope,
|
||||
),
|
||||
);
|
||||
const [appliedFilters, setAppliedFilters] = useState<PipeAnalysisDetailFilters>(() =>
|
||||
const [appliedFilters, setAppliedFilters] =
|
||||
useState<PipeAnalysisDetailFilters>(() =>
|
||||
applyScopeToDetailFilters(
|
||||
{
|
||||
...PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS,
|
||||
ctpvCd: searchParams.get("ctpvCd") ?? PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.ctpvCd,
|
||||
sggCd: searchParams.get("sggCd") ?? PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.sggCd,
|
||||
swtrmtZoneNm: searchParams.get("swtrmtZoneNm") ?? PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.swtrmtZoneNm,
|
||||
ctpvCd:
|
||||
searchParams.get("ctpvCd") ??
|
||||
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.ctpvCd,
|
||||
sggCd:
|
||||
searchParams.get("sggCd") ??
|
||||
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.sggCd,
|
||||
swtrmtZoneNm:
|
||||
searchParams.get("swtrmtZoneNm") ??
|
||||
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.swtrmtZoneNm,
|
||||
materialCode: baseMaterialCode,
|
||||
},
|
||||
scope,
|
||||
@@ -94,9 +103,6 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
const [rows, setRows] = useState<DetailViewRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
const [appliedSort, setAppliedSort] = useState<UiTableSort>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
@@ -111,7 +117,11 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
}, [scope]);
|
||||
|
||||
const fetchRows = useCallback(
|
||||
async (page: number, nextFilters: PipeAnalysisDetailFilters, size: number, nextSort?: UiTableSort) => {
|
||||
async (
|
||||
page: number,
|
||||
nextFilters: PipeAnalysisDetailFilters,
|
||||
size: number,
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
analysisType: baseAnalysisType,
|
||||
page: String(page - 1),
|
||||
@@ -120,20 +130,30 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
|
||||
if (nextFilters.ctpvCd !== "0") params.set("ctpvCd", nextFilters.ctpvCd);
|
||||
if (nextFilters.sggCd !== "0") params.set("sggCd", nextFilters.sggCd);
|
||||
if (nextFilters.swtrmtZoneNm !== "0") params.set("swtrmtZoneNm", nextFilters.swtrmtZoneNm);
|
||||
if (baseElapsedYearBucket) params.set("elapsedYearBucket", baseElapsedYearBucket);
|
||||
if (nextFilters.materialCode !== "0") params.set("materialCode", nextFilters.materialCode);
|
||||
if (nextFilters.swtrmtZoneNm !== "0")
|
||||
params.set("swtrmtZoneNm", nextFilters.swtrmtZoneNm);
|
||||
if (baseElapsedYearBucket)
|
||||
params.set("elapsedYearBucket", baseElapsedYearBucket);
|
||||
if (nextFilters.materialCode !== "0")
|
||||
params.set("materialCode", nextFilters.materialCode);
|
||||
if (nextFilters.useCd !== "0") params.set("useCd", nextFilters.useCd);
|
||||
if (nextFilters.diameterFrom) params.set("diameterFrom", nextFilters.diameterFrom);
|
||||
if (nextFilters.diameterTo) params.set("diameterTo", nextFilters.diameterTo);
|
||||
if (nextSort) params.set("sort", `${nextSort.key},${nextSort.direction}`);
|
||||
if (nextFilters.diameterFrom)
|
||||
params.set("diameterFrom", nextFilters.diameterFrom);
|
||||
if (nextFilters.diameterTo)
|
||||
params.set("diameterTo", nextFilters.diameterTo);
|
||||
if (scope.regionCode) params.set("scopeRegionCode", scope.regionCode);
|
||||
|
||||
const response = await getData(`v1/fclts/ppl-analysis/details?${params.toString()}`);
|
||||
const response = await getData(
|
||||
`v1/fclts/ppl-analysis/details?${params.toString()}`,
|
||||
);
|
||||
const responseRows = Array.isArray(response?.data) ? response.data : [];
|
||||
|
||||
return {
|
||||
rows: toPipeAnalysisDetailViewRows(responseRows as PipeAnalysisDetailRow[], ctpv, sgg),
|
||||
rows: toPipeAnalysisDetailViewRows(
|
||||
responseRows as PipeAnalysisDetailRow[],
|
||||
ctpv,
|
||||
sgg,
|
||||
),
|
||||
total: Number(response?.count ?? 0),
|
||||
};
|
||||
},
|
||||
@@ -141,25 +161,30 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
);
|
||||
|
||||
const loadRows = useCallback(
|
||||
async (page = 1, nextFilters = filters, nextSort = sort) => {
|
||||
async (page = 1, nextFilters = filters) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetchRows(page, nextFilters, PIPE_ANALYSIS_DETAIL_PAGE_SIZE, nextSort);
|
||||
const response = await fetchRows(
|
||||
page,
|
||||
nextFilters,
|
||||
PIPE_ANALYSIS_DETAIL_PAGE_SIZE,
|
||||
);
|
||||
setRows(response.rows);
|
||||
setTotal(response.total);
|
||||
setCurrentPage(page);
|
||||
setAppliedFilters(nextFilters);
|
||||
setAppliedSort(nextSort);
|
||||
} catch (error) {
|
||||
console.error("관로현황 조회 중 오류:", error);
|
||||
alert(resolveErrorMessage(error, "관로현황 조회 중 오류가 발생했습니다."));
|
||||
alert(
|
||||
resolveErrorMessage(error, "관로현황 조회 중 오류가 발생했습니다."),
|
||||
);
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[fetchRows, filters, sort],
|
||||
[alert, fetchRows, filters],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -171,29 +196,16 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
}, [ctpv.length, filters, loadRows, scope, sgg.length]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setSelectedIndex(-1);
|
||||
const nextFilters = applyScopeToDetailFilters(filters, scope);
|
||||
void loadRows(1, nextFilters, sort);
|
||||
void loadRows(1, nextFilters);
|
||||
};
|
||||
|
||||
/** Keeps the last searched filters and sort when the user moves pages. */
|
||||
/** Keeps the last searched filters when the user moves pages. */
|
||||
const handlePageChange = (page: number) => {
|
||||
setSelectedIndex(-1);
|
||||
void loadRows(page, appliedFilters, appliedSort);
|
||||
void loadRows(page, appliedFilters);
|
||||
};
|
||||
|
||||
/** Reloads the first server page with the selected grid sort. */
|
||||
const handleSortChange = (nextSort?: UiTableSort) => {
|
||||
setSort(nextSort);
|
||||
setSelectedIndex(-1);
|
||||
void loadRows(1, appliedFilters, nextSort);
|
||||
};
|
||||
const handleRowClick = (index?: number) => {
|
||||
if (typeof index !== "number") return;
|
||||
const row = rows[index];
|
||||
if (!row) return;
|
||||
|
||||
setSelectedIndex(index);
|
||||
const handleRowClick = (row: DetailViewRow) => {
|
||||
const targetWindow = window.opener?.opener ?? window.opener;
|
||||
targetWindow?.postMessage(
|
||||
{
|
||||
@@ -212,7 +224,11 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetchRows(1, appliedFilters, Math.max(total, 1000), appliedSort);
|
||||
const response = await fetchRows(
|
||||
1,
|
||||
appliedFilters,
|
||||
Math.max(total, 1000),
|
||||
);
|
||||
const exportRows = response.rows.map((row) => ({
|
||||
rowNo: row.rowNo,
|
||||
ctpvNm: row.ctpvNm,
|
||||
@@ -224,7 +240,8 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
useNm: row.useNm,
|
||||
pdmtVl: row.pdmtVl,
|
||||
prlgVl: row.prlgVl,
|
||||
materialName: resolveMaterialKoreanName(row.materialCode) || row.materialName,
|
||||
materialName:
|
||||
resolveMaterialKoreanName(row.materialCode) || row.materialName,
|
||||
}));
|
||||
const detailHeaderRange = `A1:${String.fromCharCode(64 + PIPE_ANALYSIS_DETAIL_EXPORT_KEYS.length)}1`;
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
@@ -236,7 +253,9 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("관로현황 다운로드 중 오류:", error);
|
||||
alert(resolveErrorMessage(error, "관로현황 다운로드 중 오류가 발생했습니다."));
|
||||
alert(
|
||||
resolveErrorMessage(error, "관로현황 다운로드 중 오류가 발생했습니다."),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -273,37 +292,60 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
|
||||
const titleSuffix = useMemo(() => {
|
||||
const locationParts: string[] = [];
|
||||
const resolvedCtpvName = rows[0]?.ctpvNm || resolveRegionName(filters.ctpvCd, ctpv, 2);
|
||||
const resolvedSggName = rows[0]?.sggNm || resolveRegionName(filters.sggCd, sgg, 5);
|
||||
const resolvedCtpvName =
|
||||
rows[0]?.ctpvNm || resolveRegionName(filters.ctpvCd, ctpv, 2);
|
||||
const resolvedSggName =
|
||||
rows[0]?.sggNm || resolveRegionName(filters.sggCd, sgg, 5);
|
||||
const resolvedZoneName =
|
||||
(filters.swtrmtZoneNm && filters.swtrmtZoneNm !== "0" ? filters.swtrmtZoneNm : rows[0]?.swtrmtZoneNm) || "";
|
||||
(filters.swtrmtZoneNm && filters.swtrmtZoneNm !== "0"
|
||||
? filters.swtrmtZoneNm
|
||||
: rows[0]?.swtrmtZoneNm) || "";
|
||||
|
||||
if (resolvedCtpvName && resolvedCtpvName !== "-") locationParts.push(resolvedCtpvName);
|
||||
if (resolvedSggName && resolvedSggName !== "-") locationParts.push(resolvedSggName);
|
||||
if (resolvedZoneName && resolvedZoneName !== "-" && resolvedZoneName !== "0") locationParts.push(resolvedZoneName);
|
||||
if (resolvedCtpvName && resolvedCtpvName !== "-")
|
||||
locationParts.push(resolvedCtpvName);
|
||||
if (resolvedSggName && resolvedSggName !== "-")
|
||||
locationParts.push(resolvedSggName);
|
||||
if (
|
||||
resolvedZoneName &&
|
||||
resolvedZoneName !== "-" &&
|
||||
resolvedZoneName !== "0"
|
||||
)
|
||||
locationParts.push(resolvedZoneName);
|
||||
|
||||
const parts = [];
|
||||
if (locationParts.length > 0) parts.push(locationParts.join(" "));
|
||||
const analysisTypeLabel = resolvePipeAnalysisTypeLabel(baseAnalysisType);
|
||||
if (analysisTypeLabel) parts.push(analysisTypeLabel);
|
||||
if (baseElapsedYearBucket) parts.push(resolveElapsedYearBucketLabel(baseElapsedYearBucket));
|
||||
if (baseMaterialCode && baseMaterialCode !== "0") parts.push(resolveMaterialName(baseMaterialCode));
|
||||
if (baseElapsedYearBucket)
|
||||
parts.push(resolveElapsedYearBucketLabel(baseElapsedYearBucket));
|
||||
if (baseMaterialCode && baseMaterialCode !== "0")
|
||||
parts.push(resolveMaterialName(baseMaterialCode));
|
||||
return parts.length > 0 ? ` (${parts.join(" / ")})` : "";
|
||||
}, [baseAnalysisType, baseElapsedYearBucket, baseMaterialCode, ctpv, filters.ctpvCd, filters.sggCd, filters.swtrmtZoneNm, rows, sgg]);
|
||||
}, [
|
||||
baseAnalysisType,
|
||||
baseElapsedYearBucket,
|
||||
baseMaterialCode,
|
||||
ctpv,
|
||||
filters.ctpvCd,
|
||||
filters.sggCd,
|
||||
filters.swtrmtZoneNm,
|
||||
rows,
|
||||
sgg,
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="sn-popup-page">
|
||||
<StandardPopupLayout
|
||||
title={`관로현황 목록${titleSuffix}`}
|
||||
bodyClassName="overflow-x-hidden"
|
||||
>
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<header className="sn-popup-header">
|
||||
<h1 className="sn-popup-title">관로현황{titleSuffix}</h1>
|
||||
</header>
|
||||
|
||||
<section className="sn-popup-section overflow-y-hidden">
|
||||
<div className="grid grid-cols-1 gap-3 border-b border-border bg-secondary pb-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_110px_110px_auto] md:items-end">
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">용도</div>
|
||||
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
용도
|
||||
</div>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
className="sn-popup-input w-[14rem] !h-[3.2rem] !text-xl"
|
||||
value={filters.useCd}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
@@ -320,10 +362,12 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">관재질</div>
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
관재질
|
||||
</div>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
className="sn-popup-input w-[14rem] !h-[3.2rem] !text-xl"
|
||||
value={filters.materialCode}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
@@ -340,10 +384,12 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">관경 시작</div>
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
관경 시작
|
||||
</div>
|
||||
<Input
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
className="sn-popup-input w-[12rem] !h-[3.2rem] !text-xl"
|
||||
value={filters.diameterFrom}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
@@ -357,10 +403,12 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">관경 종료</div>
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
관경 종료
|
||||
</div>
|
||||
<Input
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
className="sn-popup-input w-[12rem] !h-[3.2rem] !text-xl"
|
||||
value={filters.diameterTo}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
@@ -374,46 +422,57 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2 md:pr-3">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
|
||||
<Button type="button" color="primary" onClick={handleSearch}>
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="green"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
onClick={() => void handleDownload()}
|
||||
color="white"
|
||||
onClick={() => {
|
||||
const resetFilters = applyScopeToDetailFilters(
|
||||
{
|
||||
...PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS,
|
||||
ctpvCd:
|
||||
searchParams.get("ctpvCd") ??
|
||||
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.ctpvCd,
|
||||
sggCd:
|
||||
searchParams.get("sggCd") ??
|
||||
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.sggCd,
|
||||
swtrmtZoneNm:
|
||||
searchParams.get("swtrmtZoneNm") ??
|
||||
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.swtrmtZoneNm,
|
||||
materialCode: baseMaterialCode,
|
||||
},
|
||||
scope,
|
||||
);
|
||||
setFilters(resetFilters);
|
||||
void loadRows(1, resetFilters);
|
||||
}}
|
||||
>
|
||||
다운로드
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopupSearchPanel>
|
||||
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto pt-2">
|
||||
<UiTable<DetailViewRow>
|
||||
headName={<>관로현황 조회 결과 총 {total.toLocaleString()}건</>}
|
||||
tableData={rows}
|
||||
headlist={PIPE_ANALYSIS_DETAIL_SORTABLE_HEADLIST}
|
||||
pageSize={PIPE_ANALYSIS_DETAIL_PAGE_SIZE}
|
||||
<MapPopupDataTable
|
||||
rows={rows as Array<DetailViewRow & Record<string, unknown>>}
|
||||
headlist={PIPE_ANALYSIS_DETAIL_STANDARD_HEADLIST}
|
||||
title="관로현황 목록"
|
||||
total={total}
|
||||
cellClick={(index) => handleRowClick(index)}
|
||||
page={currentPage}
|
||||
pageSize={PIPE_ANALYSIS_DETAIL_PAGE_SIZE}
|
||||
onPageChange={handlePageChange}
|
||||
curPage={currentPage}
|
||||
index={selectedIndex}
|
||||
widthFixed={true}
|
||||
className="!m-0 overflow-x-hidden"
|
||||
sort={sort}
|
||||
onSortChange={handleSortChange}
|
||||
onRowClick={handleRowClick}
|
||||
rowId={(row, index) => String(row.fid ?? row.fcltMngNo ?? index)}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="150rem"
|
||||
actions={
|
||||
<Button type="button" onClick={() => void handleDownload()}>
|
||||
엑셀다운로드
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, Table, TableBody, TableCell, TableHeader, TableHead, TableRow } from "@common/components";
|
||||
import { UiTable } from "@common/business_components/ui/table";
|
||||
import type { UiTableSort } from "@common/business_components/ui/table";
|
||||
import {
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHead,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
import { StandardPopupLayout } from "@common/business_components";
|
||||
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
|
||||
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
|
||||
import { getData } from "@/components/lib/api";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import useStore from "@/store/useStore";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { useUserStore } from "@/shared/lib/use-user-store";
|
||||
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
|
||||
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
import {
|
||||
PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS,
|
||||
PIPE_ANALYSIS_TYPE_OPTIONS,
|
||||
@@ -36,13 +47,6 @@ const SUMMARY_FILE_PREFIX: Record<string, string> = {
|
||||
YEAR_MATERIAL: "관로분석_경과년도별_관재질별",
|
||||
};
|
||||
const PIPE_ANALYSIS_SUMMARY_PAGE_SIZE = 10;
|
||||
const YEAR_MATERIAL_SORTABLE_KEYS = new Set([
|
||||
"rowNo",
|
||||
"ctpvNm",
|
||||
"sggNm",
|
||||
"swtrmtZoneNm",
|
||||
"totalLengthKm",
|
||||
]);
|
||||
|
||||
const toExcelColumnName = (index: number) => {
|
||||
let current = index;
|
||||
@@ -69,104 +73,31 @@ const formatGridNumber = (value: unknown) => {
|
||||
};
|
||||
|
||||
const resolveErrorMessage = (error: unknown, fallbackMessage: string) =>
|
||||
error instanceof Error && error.message.trim() ? error.message : fallbackMessage;
|
||||
const KOREAN_GRID_COLLATOR = new Intl.Collator("ko", {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
|
||||
/** Sorts the complete summary result before client-side pagination. */
|
||||
const sortSummaryRows = (
|
||||
sourceRows: PipeAnalysisSummaryViewRow[],
|
||||
sort?: UiTableSort,
|
||||
) => {
|
||||
if (!sort) return sourceRows;
|
||||
|
||||
const sortableRows = sourceRows.filter((row) => !row.__isNoZone && !row.__isSummaryTotal);
|
||||
const pinnedRows = sourceRows.filter((row) => row.__isNoZone || row.__isSummaryTotal);
|
||||
|
||||
sortableRows.sort((left, right) => {
|
||||
const leftValue = left[sort.key];
|
||||
const rightValue = right[sort.key];
|
||||
const leftEmpty = leftValue === null || leftValue === undefined || leftValue === "";
|
||||
const rightEmpty = rightValue === null || rightValue === undefined || rightValue === "";
|
||||
|
||||
if (leftEmpty || rightEmpty) {
|
||||
if (leftEmpty && rightEmpty) return 0;
|
||||
return leftEmpty ? 1 : -1;
|
||||
}
|
||||
|
||||
const leftNumber = typeof leftValue === "number" ? leftValue : Number.NaN;
|
||||
const rightNumber = typeof rightValue === "number" ? rightValue : Number.NaN;
|
||||
const compared = Number.isFinite(leftNumber) && Number.isFinite(rightNumber)
|
||||
? leftNumber - rightNumber
|
||||
: KOREAN_GRID_COLLATOR.compare(String(leftValue), String(rightValue));
|
||||
|
||||
return sort.direction === "asc" ? compared : -compared;
|
||||
});
|
||||
|
||||
return [...sortableRows, ...pinnedRows];
|
||||
};
|
||||
|
||||
const getYearMaterialGroupSortValue = (
|
||||
group: PipeAnalysisYearMaterialGroup,
|
||||
key: string,
|
||||
) => {
|
||||
switch (key) {
|
||||
case "rowNo":
|
||||
return group.rowNo;
|
||||
case "ctpvNm":
|
||||
return group.ctpvNm;
|
||||
case "sggNm":
|
||||
return group.sggNm;
|
||||
case "swtrmtZoneNm":
|
||||
return group.swtrmtZoneNm;
|
||||
case "totalLengthKm":
|
||||
return group.totalLengthKm;
|
||||
default:
|
||||
return group.rows.reduce((sum, row) => sum + Number(row.metrics[key] ?? 0), 0);
|
||||
}
|
||||
};
|
||||
|
||||
const sortYearMaterialGroups = (
|
||||
groups: PipeAnalysisYearMaterialGroup[],
|
||||
sort?: UiTableSort,
|
||||
) => {
|
||||
if (!sort || !YEAR_MATERIAL_SORTABLE_KEYS.has(sort.key)) return groups;
|
||||
|
||||
const sortedGroups = [...groups];
|
||||
sortedGroups.sort((left, right) => {
|
||||
const leftValue = getYearMaterialGroupSortValue(left, sort.key);
|
||||
const rightValue = getYearMaterialGroupSortValue(right, sort.key);
|
||||
const compared = typeof leftValue === "number" && typeof rightValue === "number"
|
||||
? leftValue - rightValue
|
||||
: KOREAN_GRID_COLLATOR.compare(String(leftValue), String(rightValue));
|
||||
|
||||
if (compared !== 0) {
|
||||
return sort.direction === "asc" ? compared : -compared;
|
||||
}
|
||||
|
||||
return KOREAN_GRID_COLLATOR.compare(left.swtrmtZoneNm, right.swtrmtZoneNm);
|
||||
});
|
||||
|
||||
return sortedGroups;
|
||||
};
|
||||
error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: fallbackMessage;
|
||||
|
||||
export default function PipeAnalysisPopupPage() {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
const scope = useMemo(() => buildPipeAnalysisScope(userInfo), [userInfo]);
|
||||
const [draftFilters, setDraftFilters] = useState<PipeAnalysisSummaryFilters>(() =>
|
||||
const [draftFilters, setDraftFilters] = useState<PipeAnalysisSummaryFilters>(
|
||||
() =>
|
||||
applyScopeToSummaryFilters(PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS, scope),
|
||||
);
|
||||
const [appliedFilters, setAppliedFilters] = useState<PipeAnalysisSummaryFilters>(() =>
|
||||
const [appliedFilters, setAppliedFilters] =
|
||||
useState<PipeAnalysisSummaryFilters>(() =>
|
||||
applyScopeToSummaryFilters(PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS, scope),
|
||||
);
|
||||
const [materialColumns, setMaterialColumns] = useState<PipeAnalysisMaterialColumn[]>([]);
|
||||
const [materialColumns, setMaterialColumns] = useState<
|
||||
PipeAnalysisMaterialColumn[]
|
||||
>([]);
|
||||
const [rows, setRows] = useState<PipeAnalysisSummaryViewRow[]>([]);
|
||||
const [zoneOptions, setZoneOptions] = useState<Array<{ value: string; label: string }>>([]);
|
||||
const [zoneOptions, setZoneOptions] = useState<
|
||||
Array<{ value: string; label: string }>
|
||||
>([]);
|
||||
const [summaryCurrentPage, setSummaryCurrentPage] = useState(1);
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
@@ -212,8 +143,10 @@ export default function PipeAnalysisPopupPage() {
|
||||
|
||||
if (nextFilters.ctpvCd !== "0") params.set("ctpvCd", nextFilters.ctpvCd);
|
||||
if (nextFilters.sggCd !== "0") params.set("sggCd", nextFilters.sggCd);
|
||||
if (nextFilters.swtrmtZoneNm !== "0") params.set("swtrmtZoneNm", nextFilters.swtrmtZoneNm);
|
||||
if (nextFilters.scopeRegionCode) params.set("scopeRegionCode", nextFilters.scopeRegionCode);
|
||||
if (nextFilters.swtrmtZoneNm !== "0")
|
||||
params.set("swtrmtZoneNm", nextFilters.swtrmtZoneNm);
|
||||
if (nextFilters.scopeRegionCode)
|
||||
params.set("scopeRegionCode", nextFilters.scopeRegionCode);
|
||||
|
||||
const response = (await getData(
|
||||
`v1/fclts/ppl-analysis/summaries?${params.toString()}`,
|
||||
@@ -239,14 +172,19 @@ export default function PipeAnalysisPopupPage() {
|
||||
await fetchSummary(scopedFilters);
|
||||
} catch (error) {
|
||||
console.error("관로분석 집계 조회 중 오류:", error);
|
||||
alert(resolveErrorMessage(error, "관로분석 집계 조회 중 오류가 발생했습니다."));
|
||||
alert(
|
||||
resolveErrorMessage(
|
||||
error,
|
||||
"관로분석 집계 조회 중 오류가 발생했습니다.",
|
||||
),
|
||||
);
|
||||
setRows([]);
|
||||
setMaterialColumns([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[draftFilters, fetchSummary, scope],
|
||||
[alert, draftFilters, fetchSummary, scope],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -269,7 +207,8 @@ export default function PipeAnalysisPopupPage() {
|
||||
materialColumns,
|
||||
);
|
||||
const headerRange = `A1:${toExcelColumnName(exportHeadList.length)}1`;
|
||||
const mergeRanges = appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
const mergeRanges =
|
||||
appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
? (() => {
|
||||
const ranges: string[] = [];
|
||||
let currentRow = 2;
|
||||
@@ -278,7 +217,9 @@ export default function PipeAnalysisPopupPage() {
|
||||
const rowSpan = group.rows.length;
|
||||
if (rowSpan > 1) {
|
||||
["A", "B", "C", "D", "E"].forEach((column) => {
|
||||
ranges.push(`${column}${currentRow}:${column}${currentRow + rowSpan - 1}`);
|
||||
ranges.push(
|
||||
`${column}${currentRow}:${column}${currentRow + rowSpan - 1}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
currentRow += rowSpan;
|
||||
@@ -287,7 +228,8 @@ export default function PipeAnalysisPopupPage() {
|
||||
return ranges;
|
||||
})()
|
||||
: [];
|
||||
const exportRows = appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
const exportRows =
|
||||
appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
? orderedYearMaterialGroups.flatMap((group) =>
|
||||
group.rows.map((yearRow, rowIndex) => {
|
||||
const exportRow: Record<string, unknown> = {
|
||||
@@ -306,7 +248,7 @@ export default function PipeAnalysisPopupPage() {
|
||||
return exportRow;
|
||||
}),
|
||||
)
|
||||
: sortedRows.map((row) => {
|
||||
: rows.map((row) => {
|
||||
const exportRow: Record<string, unknown> = {};
|
||||
exportHeadList.forEach((head) => {
|
||||
exportRow[head.id] = row[head.id];
|
||||
@@ -318,32 +260,45 @@ export default function PipeAnalysisPopupPage() {
|
||||
fileName: `${SUMMARY_FILE_PREFIX[appliedFilters.analysisType]}_${buildPipeAnalysisFileDate()}`,
|
||||
sheetNameArray: ["관로분석 현황"],
|
||||
headerRange,
|
||||
headerMapping: buildPipeAnalysisSummaryDownloadHeaders(appliedFilters.analysisType, materialColumns),
|
||||
headerMapping: buildPipeAnalysisSummaryDownloadHeaders(
|
||||
appliedFilters.analysisType,
|
||||
materialColumns,
|
||||
),
|
||||
mergeRanges,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("관로분석 집계 다운로드 중 오류:", error);
|
||||
alert(resolveErrorMessage(error, "관로분석 집계 다운로드 중 오류가 발생했습니다."));
|
||||
alert(
|
||||
resolveErrorMessage(
|
||||
error,
|
||||
"관로분석 집계 다운로드 중 오류가 발생했습니다.",
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCellClick = (index?: number, columnId?: string) => {
|
||||
if (typeof index !== "number" || !columnId) return;
|
||||
const row = pagedRows[index];
|
||||
if (!row) return;
|
||||
const handleCellClick = (
|
||||
row: PipeAnalysisSummaryViewRow,
|
||||
columnId: string,
|
||||
) => {
|
||||
if (row.__isNoZone || row.__isSummaryTotal) return;
|
||||
const cellFilter = row.__cellFilters[columnId];
|
||||
if (!cellFilter) return;
|
||||
|
||||
openWindowPopup("pipeAnalysisDetailPopup", {
|
||||
const popup = openWindowPopup("pipeAnalysisDetailPopup", {
|
||||
analysisType: appliedFilters.analysisType,
|
||||
ctpvCd: String(row.__base.ctpvCd || appliedFilters.ctpvCd),
|
||||
sggCd: String(row.__base.sggCd || appliedFilters.sggCd || "0"),
|
||||
swtrmtZoneNm: String(row.__base.swtrmtZoneNm || appliedFilters.swtrmtZoneNm),
|
||||
swtrmtZoneNm: String(
|
||||
row.__base.swtrmtZoneNm || appliedFilters.swtrmtZoneNm,
|
||||
),
|
||||
elapsedYearBucket: cellFilter.elapsedYearBucket ?? "",
|
||||
materialCode: cellFilter.materialCode ?? "",
|
||||
scopeRegionCode: scope.regionCode,
|
||||
});
|
||||
if (!popup) {
|
||||
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleYearMaterialCellClick = (
|
||||
@@ -354,55 +309,68 @@ export default function PipeAnalysisPopupPage() {
|
||||
const cellFilter = row.cellFilters[columnId];
|
||||
if (!cellFilter) return;
|
||||
|
||||
openWindowPopup("pipeAnalysisDetailPopup", {
|
||||
const popup = openWindowPopup("pipeAnalysisDetailPopup", {
|
||||
analysisType: appliedFilters.analysisType,
|
||||
ctpvCd: String(group.base.ctpvCd || appliedFilters.ctpvCd),
|
||||
sggCd: String(group.base.sggCd || appliedFilters.sggCd || "0"),
|
||||
swtrmtZoneNm: String(group.base.swtrmtZoneNm || appliedFilters.swtrmtZoneNm),
|
||||
swtrmtZoneNm: String(
|
||||
group.base.swtrmtZoneNm || appliedFilters.swtrmtZoneNm,
|
||||
),
|
||||
elapsedYearBucket: cellFilter.elapsedYearBucket ?? "",
|
||||
materialCode: cellFilter.materialCode ?? "",
|
||||
scopeRegionCode: scope.regionCode,
|
||||
});
|
||||
if (!popup) {
|
||||
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
|
||||
}
|
||||
};
|
||||
|
||||
const headList = useMemo(
|
||||
() => buildPipeAnalysisSummaryHeadList(appliedFilters.analysisType, materialColumns).map((head) => ({ ...head, sortable: true })),
|
||||
() =>
|
||||
buildPipeAnalysisSummaryHeadList(
|
||||
appliedFilters.analysisType,
|
||||
materialColumns,
|
||||
),
|
||||
[appliedFilters.analysisType, materialColumns],
|
||||
);
|
||||
|
||||
const sortedRows = useMemo(
|
||||
() => sortSummaryRows(rows, sort),
|
||||
[rows, sort],
|
||||
);
|
||||
|
||||
const yearMaterialGroups = useMemo(
|
||||
() => appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
() =>
|
||||
appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
? buildPipeAnalysisYearMaterialGroups(rows, materialColumns)
|
||||
: [],
|
||||
[appliedFilters.analysisType, materialColumns, rows],
|
||||
);
|
||||
const sortedYearMaterialGroups = useMemo(
|
||||
() => sortYearMaterialGroups(yearMaterialGroups, sort),
|
||||
[sort, yearMaterialGroups],
|
||||
);
|
||||
const orderedYearMaterialGroups = useMemo(
|
||||
() => sortedYearMaterialGroups.map((group, index) => ({
|
||||
() =>
|
||||
yearMaterialGroups.map((group, index) => ({
|
||||
...group,
|
||||
rowNo: sort?.key === "rowNo" && sort.direction === "desc"
|
||||
? sortedYearMaterialGroups.length - index
|
||||
: index + 1,
|
||||
rowNo: yearMaterialGroups.length - index,
|
||||
})),
|
||||
[sort, sortedYearMaterialGroups],
|
||||
[yearMaterialGroups],
|
||||
);
|
||||
|
||||
const useHorizontalScrollTable = appliedFilters.analysisType !== "YEAR";
|
||||
const summaryPageOffset = (summaryCurrentPage - 1) * PIPE_ANALYSIS_SUMMARY_PAGE_SIZE;
|
||||
const summaryPageOffset =
|
||||
(summaryCurrentPage - 1) * PIPE_ANALYSIS_SUMMARY_PAGE_SIZE;
|
||||
const pagedRows = useMemo(
|
||||
() =>
|
||||
appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
? rows
|
||||
: sortedRows.slice(summaryPageOffset, summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE),
|
||||
[appliedFilters.analysisType, rows, sortedRows, summaryPageOffset],
|
||||
: rows.slice(
|
||||
summaryPageOffset,
|
||||
summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE,
|
||||
),
|
||||
[appliedFilters.analysisType, rows, summaryPageOffset],
|
||||
);
|
||||
const clickableHeadIds = useMemo(
|
||||
() =>
|
||||
headList
|
||||
.filter((head) =>
|
||||
rows.some((row) => Boolean(row.__cellFilters[head.id])),
|
||||
)
|
||||
.map((head) => head.id),
|
||||
[headList, rows],
|
||||
);
|
||||
const visibleYearMaterialGroups = useMemo(
|
||||
() =>
|
||||
@@ -412,31 +380,29 @@ export default function PipeAnalysisPopupPage() {
|
||||
),
|
||||
[orderedYearMaterialGroups, summaryPageOffset],
|
||||
);
|
||||
const summaryTotalCount = appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
? orderedYearMaterialGroups.length
|
||||
: rows.length;
|
||||
|
||||
const sggOptions = useMemo(
|
||||
() =>
|
||||
draftFilters.ctpvCd === "0"
|
||||
? sgg
|
||||
: sgg.filter((item) => String(item.val).startsWith(draftFilters.ctpvCd.slice(0, 2))),
|
||||
: sgg.filter((item) =>
|
||||
String(item.val).startsWith(draftFilters.ctpvCd.slice(0, 2)),
|
||||
),
|
||||
[draftFilters.ctpvCd, sgg],
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="sn-popup-page">
|
||||
<StandardPopupLayout
|
||||
title={windowPopupDefinitions.pipeAnalysisPopup.title}
|
||||
bodyClassName="overflow-x-hidden"
|
||||
>
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<header className="sn-popup-header">
|
||||
<h1 className="sn-popup-title">관로분석</h1>
|
||||
</header>
|
||||
|
||||
<section className="sn-popup-section overflow-y-auto">
|
||||
<div className="grid grid-cols-1 gap-3 border-b border-border bg-secondary pb-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-end">
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">시도</div>
|
||||
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
시도
|
||||
</div>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
className="sn-popup-input !h-[3.2rem] w-[14rem] !text-xl"
|
||||
value={draftFilters.ctpvCd}
|
||||
disabled={scope.ctpvLocked}
|
||||
onChange={(event) =>
|
||||
@@ -457,10 +423,12 @@ export default function PipeAnalysisPopupPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">시군구</div>
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
시군구
|
||||
</div>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
className="sn-popup-input !h-[3.2rem] w-[14rem] !text-xl"
|
||||
value={draftFilters.sggCd}
|
||||
disabled={scope.sggLocked}
|
||||
onChange={(event) =>
|
||||
@@ -480,10 +448,12 @@ export default function PipeAnalysisPopupPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">처리구역</div>
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
처리구역
|
||||
</div>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
className="sn-popup-input !h-[3.2rem] w-[14rem] !text-xl"
|
||||
value={draftFilters.swtrmtZoneNm}
|
||||
onChange={(event) =>
|
||||
setDraftFilters((current) => ({
|
||||
@@ -500,15 +470,18 @@ export default function PipeAnalysisPopupPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">분석구분</div>
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
분석구분
|
||||
</div>
|
||||
<select
|
||||
className="sn-popup-input !text-[1.1rem]"
|
||||
className="sn-popup-input !h-[3.2rem] w-[18rem] !text-xl"
|
||||
value={draftFilters.analysisType}
|
||||
onChange={(event) =>
|
||||
setDraftFilters((current) => ({
|
||||
...current,
|
||||
analysisType: event.target.value as PipeAnalysisSummaryFilters["analysisType"],
|
||||
analysisType: event.target
|
||||
.value as PipeAnalysisSummaryFilters["analysisType"],
|
||||
}))
|
||||
}
|
||||
>
|
||||
@@ -520,96 +493,54 @@ export default function PipeAnalysisPopupPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2 md:pr-3">
|
||||
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
onClick={() => void handleSearch()}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="green"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
onClick={() => void handleDownload()}
|
||||
color="white"
|
||||
onClick={() => {
|
||||
const resetFilters = applyScopeToSummaryFilters(
|
||||
PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS,
|
||||
scope,
|
||||
);
|
||||
setDraftFilters(resetFilters);
|
||||
void handleSearch(resetFilters);
|
||||
}}
|
||||
>
|
||||
다운로드
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopupSearchPanel>
|
||||
|
||||
{appliedFilters.analysisType === "YEAR_MATERIAL" ? (
|
||||
<>
|
||||
<div className="card card-border !m-0 min-w-0 overflow-hidden !p-0">
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl font-medium text-label">
|
||||
{resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 관로현황 {summaryTotalCount.toLocaleString("ko-KR")}건
|
||||
{resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 목록
|
||||
</h3>
|
||||
<Button type="button" onClick={() => void handleDownload()}>
|
||||
엑셀다운로드
|
||||
</Button>
|
||||
</div>
|
||||
<div className="card !p-0r !m-[1.2rem] overflow-x-auto">
|
||||
<Table variant="secondary" className="w-max min-w-full caption-bottom text-sm">
|
||||
<div className="card !m-[1.2rem] overflow-x-auto !p-0">
|
||||
<Table
|
||||
variant="secondary"
|
||||
className="w-max min-w-full caption-bottom text-sm"
|
||||
>
|
||||
<TableHeader className="font-bold">
|
||||
<TableRow>
|
||||
{headList.map((head) => (
|
||||
<TableHead
|
||||
key={head.id}
|
||||
className={`border border-border bg-secondary px-4 py-[1.2rem] text-[1.3rem] font-medium text-label ${
|
||||
head.align === "right" ? "text-right" : "text-center"
|
||||
}`}
|
||||
aria-sort={
|
||||
head.id === "elapsedYearLabel"
|
||||
? undefined
|
||||
: sort?.key === head.id
|
||||
? sort.direction === "asc"
|
||||
? "ascending"
|
||||
: "descending"
|
||||
: "none"
|
||||
}
|
||||
className="border border-border bg-primary px-4 py-[1.2rem] text-center text-[1.3rem] font-medium text-primary-foreground"
|
||||
>
|
||||
{!YEAR_MATERIAL_SORTABLE_KEYS.has(head.id) ? (
|
||||
head.title
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex w-full items-center justify-center gap-1.5"
|
||||
aria-label={`${head.title} 정렬`}
|
||||
onClick={() => {
|
||||
if (sort?.key !== head.id) {
|
||||
setSort({ key: head.id, direction: "asc" });
|
||||
setSummaryCurrentPage(1);
|
||||
return;
|
||||
}
|
||||
if (sort.direction === "asc") {
|
||||
setSort({ key: head.id, direction: "desc" });
|
||||
setSummaryCurrentPage(1);
|
||||
return;
|
||||
}
|
||||
setSort(undefined);
|
||||
setSummaryCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
<span>{head.title}</span>
|
||||
{sort?.key === head.id ? (
|
||||
<span
|
||||
className="inline-flex min-w-2 justify-center text-[8px] leading-none text-[#1d4ed8]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{sort.direction === "asc" ? "▲" : "▼"}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="inline-flex flex-col items-center gap-px text-[6px] leading-none text-[#475569]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span>▲</span>
|
||||
<span>▼</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{head.id === "rowNo" ? "순번" : head.title}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
@@ -618,22 +549,39 @@ export default function PipeAnalysisPopupPage() {
|
||||
{visibleYearMaterialGroups.length > 0 ? (
|
||||
visibleYearMaterialGroups.flatMap((group) =>
|
||||
group.rows.map((yearRow, rowIndex) => (
|
||||
<TableRow key={`${group.base.ctpvCd}-${group.base.sggCd}-${group.base.swtrmtZoneNm}-${yearRow.elapsedYearBucket}`}>
|
||||
<TableRow
|
||||
key={`${group.base.ctpvCd}-${group.base.sggCd}-${group.base.swtrmtZoneNm}-${yearRow.elapsedYearBucket}`}
|
||||
>
|
||||
{rowIndex === 0 ? (
|
||||
<>
|
||||
<TableCell rowSpan={group.rows.length} className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]">
|
||||
<TableCell
|
||||
rowSpan={group.rows.length}
|
||||
className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]"
|
||||
>
|
||||
{group.rowNo}
|
||||
</TableCell>
|
||||
<TableCell rowSpan={group.rows.length} className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]">
|
||||
<TableCell
|
||||
rowSpan={group.rows.length}
|
||||
className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]"
|
||||
>
|
||||
{group.ctpvNm}
|
||||
</TableCell>
|
||||
<TableCell rowSpan={group.rows.length} className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]">
|
||||
<TableCell
|
||||
rowSpan={group.rows.length}
|
||||
className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]"
|
||||
>
|
||||
{group.sggNm}
|
||||
</TableCell>
|
||||
<TableCell rowSpan={group.rows.length} className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]">
|
||||
<TableCell
|
||||
rowSpan={group.rows.length}
|
||||
className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]"
|
||||
>
|
||||
{group.swtrmtZoneNm}
|
||||
</TableCell>
|
||||
<TableCell rowSpan={group.rows.length} className="border border-border px-2 py-[1.2rem] text-right text-[1.3rem]">
|
||||
<TableCell
|
||||
rowSpan={group.rows.length}
|
||||
className="border border-border px-2 py-[1.2rem] text-right text-[1.3rem]"
|
||||
>
|
||||
{formatGridNumber(group.totalLengthKm)}
|
||||
</TableCell>
|
||||
</>
|
||||
@@ -642,17 +590,27 @@ export default function PipeAnalysisPopupPage() {
|
||||
{yearRow.elapsedYearLabel}
|
||||
</TableCell>
|
||||
{materialColumns.map((column) => {
|
||||
const value = Number(yearRow.metrics[column.key] ?? 0);
|
||||
const isClickable = value > 0 && Boolean(yearRow.cellFilters[column.key]);
|
||||
const value = Number(
|
||||
yearRow.metrics[column.key] ?? 0,
|
||||
);
|
||||
const isClickable =
|
||||
value > 0 &&
|
||||
Boolean(yearRow.cellFilters[column.key]);
|
||||
return (
|
||||
<TableCell
|
||||
key={`${group.base.ctpvCd}-${group.base.sggCd}-${group.base.swtrmtZoneNm}-${yearRow.elapsedYearBucket}-${column.key}`}
|
||||
className={`border border-border px-2 py-[1.2rem] text-right text-[1.3rem] ${
|
||||
isClickable ? "cursor-pointer font-semibold text-primary underline underline-offset-2" : ""
|
||||
isClickable
|
||||
? "cursor-pointer font-semibold text-primary underline underline-offset-2"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (isClickable) {
|
||||
handleYearMaterialCellClick(group, yearRow, column.key);
|
||||
handleYearMaterialCellClick(
|
||||
group,
|
||||
yearRow,
|
||||
column.key,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -661,11 +619,14 @@ export default function PipeAnalysisPopupPage() {
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
)),
|
||||
)
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={headList.length} className="h-24 border border-border text-center text-[1.3rem] text-label">
|
||||
<TableCell
|
||||
colSpan={headList.length}
|
||||
className="h-24 border border-border text-center text-[1.3rem] text-label"
|
||||
>
|
||||
조회 결과가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -673,8 +634,9 @@ export default function PipeAnalysisPopupPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{orderedYearMaterialGroups.length > PIPE_ANALYSIS_SUMMARY_PAGE_SIZE ? (
|
||||
<div className="flex justify-center">
|
||||
{orderedYearMaterialGroups.length >
|
||||
PIPE_ANALYSIS_SUMMARY_PAGE_SIZE ? (
|
||||
<div className="mb-[1.2rem] flex justify-center">
|
||||
<UiPageNations
|
||||
total={orderedYearMaterialGroups.length}
|
||||
cur={summaryCurrentPage}
|
||||
@@ -683,45 +645,38 @@ export default function PipeAnalysisPopupPage() {
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<UiTable<PipeAnalysisSummaryViewRow>
|
||||
className={appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
? "hidden"
|
||||
: useHorizontalScrollTable
|
||||
? "overflow-x-auto overflow-y-visible [&_table]:min-w-full [&_table]:w-max"
|
||||
: ""}
|
||||
headName={
|
||||
appliedFilters.analysisType === "YEAR_MATERIAL"
|
||||
? `경과년도&재질 관로현황 ${summaryTotalCount.toLocaleString("ko-KR")}건`
|
||||
: `관로분석현황 ${summaryTotalCount.toLocaleString("ko-KR")}건`
|
||||
{appliedFilters.analysisType !== "YEAR_MATERIAL" ? (
|
||||
<MapPopupDataTable
|
||||
rows={
|
||||
pagedRows as Array<
|
||||
PipeAnalysisSummaryViewRow & Record<string, unknown>
|
||||
>
|
||||
}
|
||||
tableData={pagedRows}
|
||||
headlist={headList}
|
||||
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
|
||||
title={`${resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 목록`}
|
||||
total={rows.length}
|
||||
cellClick={(index, columnId) =>
|
||||
handleCellClick(
|
||||
typeof index === "number" ? index + summaryPageOffset : index,
|
||||
columnId,
|
||||
)}
|
||||
page={summaryCurrentPage}
|
||||
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
|
||||
onPageChange={setSummaryCurrentPage}
|
||||
curPage={summaryCurrentPage}
|
||||
widthFixed={!useHorizontalScrollTable}
|
||||
isHideCount={true}
|
||||
sort={sort}
|
||||
onSortChange={(nextSort) => {
|
||||
setSort(nextSort);
|
||||
setSummaryCurrentPage(1);
|
||||
}}
|
||||
cellClassName={(row, columnId) =>
|
||||
!row.__isNoZone && row.__cellFilters[columnId]
|
||||
? "cursor-pointer font-semibold text-blue-700 underline underline-offset-2"
|
||||
: ""
|
||||
scrollMode={useHorizontalScrollTable ? "horizontal" : "none"}
|
||||
minTableWidth={useHorizontalScrollTable ? "160rem" : "100%"}
|
||||
clickableHeadIds={clickableHeadIds}
|
||||
onCellClick={(row, _rowIndex, columnId) =>
|
||||
handleCellClick(row, columnId)
|
||||
}
|
||||
rowId={(row, index) =>
|
||||
`${String(row.__base.ctpvCd)}-${String(row.__base.sggCd)}-${String(row.__base.swtrmtZoneNm)}-${index}`
|
||||
}
|
||||
actions={
|
||||
<Button type="button" onClick={() => void handleDownload()}>
|
||||
엑셀다운로드
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
) : null}
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
import type { SelectDataType } from "@common/business_components";
|
||||
import type { IUserInfo } from "@/shared/store";
|
||||
import {
|
||||
MS_SWAGE_PPL_CODES,
|
||||
MS_SWAGE_PPL_USE_CODES,
|
||||
} from "../common/table";
|
||||
import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } from "../common/table";
|
||||
|
||||
export const PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE = "SN_PIPE_ANALYSIS_DETAIL_ROW_SELECT";
|
||||
export const PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE =
|
||||
"SN_PIPE_ANALYSIS_DETAIL_ROW_SELECT";
|
||||
|
||||
export type PipeAnalysisType = "YEAR" | "MATERIAL" | "YEAR_MATERIAL";
|
||||
|
||||
@@ -126,22 +124,30 @@ export type PipeAnalysisYearMaterialGroup = {
|
||||
rows: PipeAnalysisYearMaterialGroupRow[];
|
||||
};
|
||||
|
||||
const materialMap = new Map(MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text]));
|
||||
const useMap = new Map(MS_SWAGE_PPL_USE_CODES.map((item) => [item.val.trim(), item.text]));
|
||||
const materialMap = new Map(
|
||||
MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text]),
|
||||
);
|
||||
const useMap = new Map(
|
||||
MS_SWAGE_PPL_USE_CODES.map((item) => [item.val.trim(), item.text]),
|
||||
);
|
||||
|
||||
export const PIPE_ANALYSIS_TYPE_OPTIONS: Array<{ value: PipeAnalysisType; label: string }> = [
|
||||
export const PIPE_ANALYSIS_TYPE_OPTIONS: Array<{
|
||||
value: PipeAnalysisType;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "YEAR", label: "경과년도별" },
|
||||
{ value: "MATERIAL", label: "재질별" },
|
||||
{ value: "YEAR_MATERIAL", label: "경과년도&재질" },
|
||||
];
|
||||
|
||||
export const PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS: PipeAnalysisSummaryFilters = {
|
||||
export const PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS: PipeAnalysisSummaryFilters =
|
||||
{
|
||||
analysisType: "YEAR",
|
||||
ctpvCd: "0",
|
||||
sggCd: "0",
|
||||
swtrmtZoneNm: "0",
|
||||
scopeRegionCode: "",
|
||||
};
|
||||
};
|
||||
|
||||
export const PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS: PipeAnalysisDetailFilters = {
|
||||
ctpvCd: "0",
|
||||
@@ -172,7 +178,7 @@ const elapsedYearBucketLabelMap: Record<string, string> = {
|
||||
const YEAR_MATERIAL_BASE_BUCKETS = ["LT10", "Y10_20", "Y20_30", "GE30"];
|
||||
|
||||
export const PIPE_ANALYSIS_DETAIL_HEADLIST = [
|
||||
{ id: "rowNo", title: "NO", widthPercent: 6 },
|
||||
{ id: "rowNo", title: "순번", widthPercent: 6 },
|
||||
{ id: "ctpvNm", title: "시도", widthPercent: 9 },
|
||||
{ id: "sggNm", title: "시군구", widthPercent: 10 },
|
||||
{ id: "fcltMngNo", title: "시설관리번호", widthPercent: 15 },
|
||||
@@ -185,7 +191,9 @@ export const PIPE_ANALYSIS_DETAIL_HEADLIST = [
|
||||
{ id: "materialName", title: "관재질", widthPercent: 12 },
|
||||
];
|
||||
|
||||
export const buildPipeAnalysisScope = (userInfo: IUserInfo | null): PipeAnalysisScope => {
|
||||
export const buildPipeAnalysisScope = (
|
||||
userInfo: IUserInfo | null,
|
||||
): PipeAnalysisScope => {
|
||||
const admin = userInfo?.adminRoleYn === "Y";
|
||||
if (admin) {
|
||||
return {
|
||||
@@ -263,11 +271,16 @@ export const buildPipeAnalysisSummaryHeadList = (
|
||||
materialColumns: PipeAnalysisMaterialColumn[],
|
||||
) => {
|
||||
const common = [
|
||||
{ id: "rowNo", title: "NO", widthPercent: 6 },
|
||||
{ id: "rowNo", title: "순번", widthPercent: 6 },
|
||||
{ id: "ctpvNm", title: "시도", widthPercent: 10 },
|
||||
{ id: "sggNm", title: "시군구", widthPercent: 10 },
|
||||
{ id: "swtrmtZoneNm", title: "처리구역", widthPercent: 14 },
|
||||
{ id: "totalLengthKm", title: "합계(km)", widthPercent: 10, align: "right" },
|
||||
{
|
||||
id: "totalLengthKm",
|
||||
title: "합계(km)",
|
||||
widthPercent: 10,
|
||||
align: "right",
|
||||
},
|
||||
];
|
||||
|
||||
if (analysisType === "YEAR") {
|
||||
@@ -309,12 +322,17 @@ export const buildPipeAnalysisSummaryViewRows = (
|
||||
|
||||
return response.data.map((row) => {
|
||||
const isNoZone = String(row.swtrmtZoneNm ?? "").trim() === "NO_ZONE";
|
||||
const isSummaryTotal = !String(row.ctpvCd ?? "").trim()
|
||||
&& String(row.swtrmtZoneNm ?? "").trim() === "-";
|
||||
const isSummaryTotal =
|
||||
!String(row.ctpvCd ?? "").trim() &&
|
||||
String(row.swtrmtZoneNm ?? "").trim() === "-";
|
||||
const viewRow: PipeAnalysisSummaryViewRow = {
|
||||
rowNo: row.rowNo,
|
||||
ctpvNm: isNoZone ? "전체" : row.ctpvNm || resolveRegionName(row.ctpvCd, ctpvOptions, 2),
|
||||
sggNm: isNoZone ? "-" : row.sggNm || resolveRegionName(row.sggCd, sggOptions, 5),
|
||||
ctpvNm: isNoZone
|
||||
? "전체"
|
||||
: row.ctpvNm || resolveRegionName(row.ctpvCd, ctpvOptions, 2),
|
||||
sggNm: isNoZone
|
||||
? "-"
|
||||
: row.sggNm || resolveRegionName(row.sggCd, sggOptions, 5),
|
||||
swtrmtZoneNm: isNoZone ? "-" : row.swtrmtZoneNm || "-",
|
||||
totalLengthKm: row.totalLengthKm ?? 0,
|
||||
elapsedYearBucket: row.elapsedYearBucket ?? "",
|
||||
@@ -357,16 +375,18 @@ export const buildPipeAnalysisSummaryViewRows = (
|
||||
return viewRow;
|
||||
}
|
||||
|
||||
const columns = response.analysisType === "YEAR_MATERIAL"
|
||||
? materialCodes
|
||||
: materialCodes;
|
||||
const columns =
|
||||
response.analysisType === "YEAR_MATERIAL" ? materialCodes : materialCodes;
|
||||
|
||||
columns.forEach((columnKey) => {
|
||||
const value = Number(row.metrics?.[columnKey] ?? 0);
|
||||
viewRow[columnKey] = value;
|
||||
if (!isNoZone && !isSummaryTotal && value > 0) {
|
||||
viewRow.__cellFilters[columnKey] = {
|
||||
elapsedYearBucket: response.analysisType === "YEAR_MATERIAL" ? row.elapsedYearBucket : undefined,
|
||||
elapsedYearBucket:
|
||||
response.analysisType === "YEAR_MATERIAL"
|
||||
? row.elapsedYearBucket
|
||||
: undefined,
|
||||
materialCode: columnKey === "OTHER" ? "" : columnKey,
|
||||
};
|
||||
}
|
||||
@@ -416,14 +436,19 @@ export const buildPipeAnalysisYearMaterialGroups = (
|
||||
|
||||
group.rows.push({
|
||||
elapsedYearBucket,
|
||||
elapsedYearLabel: String(row.elapsedYearLabel ?? resolveElapsedYearBucketLabel(elapsedYearBucket)),
|
||||
elapsedYearLabel: String(
|
||||
row.elapsedYearLabel ??
|
||||
resolveElapsedYearBucketLabel(elapsedYearBucket),
|
||||
),
|
||||
metrics,
|
||||
cellFilters: row.__cellFilters,
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(groupMap.values()).map((group) => {
|
||||
const rowByBucket = new Map(group.rows.map((row) => [row.elapsedYearBucket, row]));
|
||||
const rowByBucket = new Map(
|
||||
group.rows.map((row) => [row.elapsedYearBucket, row]),
|
||||
);
|
||||
const orderedBuckets = [...YEAR_MATERIAL_BASE_BUCKETS];
|
||||
|
||||
if (rowByBucket.has("UNKNOWN")) {
|
||||
@@ -474,7 +499,7 @@ export const buildPipeAnalysisSummaryDownloadHeaders = (
|
||||
materialColumns: PipeAnalysisMaterialColumn[],
|
||||
) => {
|
||||
const headers: Record<string, string> = {
|
||||
rowNo: "NO",
|
||||
rowNo: "순번",
|
||||
ctpvNm: "시도",
|
||||
sggNm: "시군구",
|
||||
swtrmtZoneNm: "처리구역",
|
||||
@@ -499,7 +524,7 @@ export const buildPipeAnalysisSummaryDownloadHeaders = (
|
||||
};
|
||||
|
||||
export const PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS = {
|
||||
rowNo: "NO",
|
||||
rowNo: "순번",
|
||||
ctpvNm: "시도",
|
||||
sggNm: "시군구",
|
||||
fcltMngNo: "시설관리번호",
|
||||
@@ -548,7 +573,9 @@ const toKoreanMaterialLabel = (value?: string) => {
|
||||
const parenthesisMatches = Array.from(normalized.matchAll(/\(([^)]*)\)/g))
|
||||
.map((match) => String(match[1] ?? "").trim())
|
||||
.filter(Boolean);
|
||||
const koreanParenthesisText = parenthesisMatches.find((text) => /[가-힣]/.test(text));
|
||||
const koreanParenthesisText = parenthesisMatches.find((text) =>
|
||||
/[가-힣]/.test(text),
|
||||
);
|
||||
|
||||
if (koreanParenthesisText) {
|
||||
const simplifiedParenthesisText = koreanParenthesisText
|
||||
@@ -561,7 +588,9 @@ const toKoreanMaterialLabel = (value?: string) => {
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const resolveMaterialColumnName = (column: PipeAnalysisMaterialColumn) => {
|
||||
export const resolveMaterialColumnName = (
|
||||
column: PipeAnalysisMaterialColumn,
|
||||
) => {
|
||||
const rawName = String(column.materialName ?? "").trim();
|
||||
const rawCode = String(column.materialCode ?? "").trim();
|
||||
const rawKey = String(column.key ?? "").trim();
|
||||
@@ -592,8 +621,8 @@ export const resolveElapsedYearBucketLabel = (bucket?: string) => {
|
||||
export const resolvePipeAnalysisTypeLabel = (analysisType?: string) => {
|
||||
const normalized = String(analysisType ?? "").trim();
|
||||
return (
|
||||
PIPE_ANALYSIS_TYPE_OPTIONS.find((option) => option.value === normalized)?.label
|
||||
?? normalized
|
||||
PIPE_ANALYSIS_TYPE_OPTIONS.find((option) => option.value === normalized)
|
||||
?.label ?? normalized
|
||||
);
|
||||
};
|
||||
|
||||
@@ -611,12 +640,16 @@ export const resolveRegionName = (
|
||||
const normalized = String(code ?? "").trim();
|
||||
if (!normalized) return "-";
|
||||
const prefix = normalized.slice(0, prefixLength);
|
||||
return options.find((item) => String(item.val).trim() === prefix)?.text ?? prefix;
|
||||
return (
|
||||
options.find((item) => String(item.val).trim() === prefix)?.text ?? prefix
|
||||
);
|
||||
};
|
||||
|
||||
export const normalizeNumberInput = (value: string) => value.replace(/[^0-9]/g, "");
|
||||
export const normalizeNumberInput = (value: string) =>
|
||||
value.replace(/[^0-9]/g, "");
|
||||
|
||||
export const buildPipeAnalysisFileDate = () => new Date().toISOString().slice(0, 10);
|
||||
export const buildPipeAnalysisFileDate = () =>
|
||||
new Date().toISOString().slice(0, 10);
|
||||
|
||||
export const fetchPipeAnalysisZoneOptions = async ({
|
||||
ctpvCd,
|
||||
|
||||
@@ -13,48 +13,73 @@
|
||||
* - 위경도로 강우시 미처리 하수 목록 조회: GET /api/v1/onsemiro/getSwgRainsByGeomety (sewerage-service)
|
||||
* - 강우시 월류 하수 관리 모니터링 조회: GET /api/v1/onsemiro/swgRain/{adstDstrCd}/{manageNo}/mntrngs (sewerage-service)
|
||||
*/
|
||||
'use client';
|
||||
import { UiTable } from '@common/business_components/ui';
|
||||
import { Button, TableHead, TableRow } from '@common/components/ui';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { untrtSwgRainCol3, untrtSwgRainCol4, untrtSwgRainCol5 } from '../common/table';
|
||||
import { VerticalTable } from '@/components/ui/verticalTable';
|
||||
"use client";
|
||||
import { StandardPopupLayout } from "@common/business_components/ui";
|
||||
import { Button } from "@common/components/ui";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
untrtSwgRainCol3,
|
||||
untrtSwgRainCol4,
|
||||
untrtSwgRainCol5,
|
||||
} from "../common/table";
|
||||
import { VerticalTable } from "@/components/ui/verticalTable";
|
||||
import { getRawJson } from "@/components/lib/api";
|
||||
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
|
||||
import { Label } from '@radix-ui/react-label';
|
||||
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
|
||||
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
|
||||
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
|
||||
const inputColumn = [
|
||||
{ id: "prrtymngYn", title: "중점여부", type: "select", options: ["우수토실", "중점우수토실"] },
|
||||
{
|
||||
id: "prrtymngYn",
|
||||
title: "중점여부",
|
||||
type: "select",
|
||||
options: ["우수토실", "중점우수토실"],
|
||||
},
|
||||
{ id: "swtrmtZoneNm", title: "처리구역", type: "input" },
|
||||
];
|
||||
|
||||
const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, renderMode = 'inline' }: {
|
||||
featureData: any,
|
||||
isMainSsc?: boolean,
|
||||
chmbrXcnts?: string,
|
||||
chmbrYdnts?: string,
|
||||
onSaveClick?: () => void,
|
||||
renderMode?: 'inline' | 'popup'
|
||||
const toStandardHeadList = (headlist: typeof untrtSwgRainCol3) =>
|
||||
headlist.map((head) => ({
|
||||
...head,
|
||||
title: ["no", "rowNo", "rowNumber"].includes(head.id) ? "순번" : head.title,
|
||||
}));
|
||||
|
||||
const Ssc = ({
|
||||
featureData,
|
||||
isMainSsc,
|
||||
chmbrXcnts,
|
||||
chmbrYdnts,
|
||||
onSaveClick,
|
||||
renderMode = "inline",
|
||||
}: {
|
||||
featureData: any;
|
||||
isMainSsc?: boolean;
|
||||
chmbrXcnts?: string;
|
||||
chmbrYdnts?: string;
|
||||
onSaveClick?: () => void;
|
||||
renderMode?: "inline" | "popup";
|
||||
}) => {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [formData, setFormData] = useState<any>({});
|
||||
const [rainMeasureData, setRainMeasureData] = useState<any>({}); // 강우, 측정 정보
|
||||
const [rainMeasureData, setRainMeasureData] = useState<any[]>([]); // 강우, 측정 정보
|
||||
const [mntrngsData, setMntrngsData] = useState<any[]>([]); // 수량/수질측정 정보
|
||||
const [totalCount, setTotalCount] = useState<number>(0);
|
||||
const [totalDetailCount, setTotalDetailCount] = useState<number>(0);
|
||||
const [selectedRowIndex, setSelectedRowIndex] = useState<number>(-1);
|
||||
const [curPage, setCurPage] = useState<number>(1);
|
||||
const [curDetailPage, setCurDetailPage] = useState<number>(1);
|
||||
const [isResetPage, setIsResetPage] = useState<boolean>(true);
|
||||
const [isResetDetailPage, setIsResetDetailPage] = useState<boolean>(true);
|
||||
const [isResetSelection, setIsResetSelection] = useState<boolean>(false);
|
||||
|
||||
// 강우, 측정 정보 API 호출 함수
|
||||
const fetchRainMeasureData = async (chmbrXcnts: string, chmbrYdnts: string) => {
|
||||
setIsResetDetailPage(true);
|
||||
setIsResetSelection(true);
|
||||
const fetchRainMeasureData = async (
|
||||
chmbrXcnts: string,
|
||||
chmbrYdnts: string,
|
||||
) => {
|
||||
if (!chmbrXcnts || !chmbrYdnts) {
|
||||
console.warn('추가 데이터 호출 실패: 필수 매개변수 누락', { chmbrXcnts, chmbrYdnts });
|
||||
console.warn("추가 데이터 호출 실패: 필수 매개변수 누락", {
|
||||
chmbrXcnts,
|
||||
chmbrYdnts,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -63,17 +88,21 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
|
||||
chmbrXcnts: chmbrXcnts,
|
||||
chmbrYdnts: chmbrYdnts,
|
||||
page: `${curPage - 1}`,
|
||||
size: '3',
|
||||
size: "3",
|
||||
});
|
||||
|
||||
const result = await getRawJson(`/sewerage-service/api/v1/onsemiro/getSwgRainsByGeomety?${queryParams.toString()}`);
|
||||
const result = await getRawJson(
|
||||
`/sewerage-service/api/v1/onsemiro/getSwgRainsByGeomety?${queryParams.toString()}`,
|
||||
);
|
||||
|
||||
// helper: "YYYY-MM-DD HH:MM:SS" -> "MM-DD HH:MM"
|
||||
const formatNoYearNoSec = (val: any) => {
|
||||
if (!val && val !== 0) return "-";
|
||||
const s = String(val);
|
||||
// 정규식으로 안전하게 매칭 후 변환
|
||||
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::\d{2})?$/);
|
||||
const m = s.match(
|
||||
/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::\d{2})?$/,
|
||||
);
|
||||
if (m) {
|
||||
const [, , mm, dd, hh, min] = m;
|
||||
return `${mm}-${dd} ${hh}:${min}`;
|
||||
@@ -86,45 +115,70 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
|
||||
const processedDataArray: any[] = result.content.map((data: any) => {
|
||||
// 🎯 formatDisplayData로 기본 포맷팅 적용
|
||||
const formattedItem = formatDisplayData(data);
|
||||
const flwmtrInstTp = data.flwmtrInstTp === 'R' ? '이동식' : data.flwmtrInstTp === 'S' ? '고정식' : '-';
|
||||
const flwmtrMesureTp = data.flwmtrMesureTp === 'R' ? '레이더' : data.flwmtrMesureTp === 'P' ? '플륨'
|
||||
: data.flwmtrMesureTp === 'U' ? '초음파' : data.flwmtrMesureTp === 'E' ? '기타' : '-';
|
||||
const flwmtrInstTp =
|
||||
data.flwmtrInstTp === "R"
|
||||
? "이동식"
|
||||
: data.flwmtrInstTp === "S"
|
||||
? "고정식"
|
||||
: "-";
|
||||
const flwmtrMesureTp =
|
||||
data.flwmtrMesureTp === "R"
|
||||
? "레이더"
|
||||
: data.flwmtrMesureTp === "P"
|
||||
? "플륨"
|
||||
: data.flwmtrMesureTp === "U"
|
||||
? "초음파"
|
||||
: data.flwmtrMesureTp === "E"
|
||||
? "기타"
|
||||
: "-";
|
||||
const flwmtrCombined = `${flwmtrInstTp}, ${flwmtrMesureTp}`;
|
||||
// 🎯 추가 데이터 조합
|
||||
return {
|
||||
...formattedItem,
|
||||
rainInfo: `${data.rainInfo === '0' ? '강우량계' : data.observatory || '-'}`, // 강우정보 0: 강우량계,1: 관측기관 이면 OBSERVATORY 이값사용
|
||||
mesureIntrvl: `${data.mesureIntrvlGbn === '1' ? '1분' : (data.mesureIntrvlGbn === '0' ? data.mesureIntrvl + '분' : '-')}`, // 측정간격
|
||||
flwmtrCombined: `${flwmtrCombined} / ${data.flwmtrKnd || '-'}`, // 유량계종류 및 모델명
|
||||
mesureBeginDt: data.mesureBeginDt ? formatNoYearNoSec(data.mesureBeginDt) : '-', // 수량 측정시작일
|
||||
mesureEndDt: data.mesureEndDt ? formatNoYearNoSec(data.mesureEndDt) : '-', // 수량 측정종료일
|
||||
qltwtrBeginDt: data.qltwtrBeginDt ? formatNoYearNoSec(data.qltwtrBeginDt) : '-', // 수질 측정시작일(채수시작일)
|
||||
qltwtrEndDt: data.qltwtrEndDt ? formatNoYearNoSec(data.qltwtrEndDt) : '-', // 수질 측정종료일(채수종료일)
|
||||
rainInfo: `${data.rainInfo === "0" ? "강우량계" : data.observatory || "-"}`, // 강우정보 0: 강우량계,1: 관측기관 이면 OBSERVATORY 이값사용
|
||||
mesureIntrvl: `${data.mesureIntrvlGbn === "1" ? "1분" : data.mesureIntrvlGbn === "0" ? data.mesureIntrvl + "분" : "-"}`, // 측정간격
|
||||
flwmtrCombined: `${flwmtrCombined} / ${data.flwmtrKnd || "-"}`, // 유량계종류 및 모델명
|
||||
mesureBeginDt: data.mesureBeginDt
|
||||
? formatNoYearNoSec(data.mesureBeginDt)
|
||||
: "-", // 수량 측정시작일
|
||||
mesureEndDt: data.mesureEndDt
|
||||
? formatNoYearNoSec(data.mesureEndDt)
|
||||
: "-", // 수량 측정종료일
|
||||
qltwtrBeginDt: data.qltwtrBeginDt
|
||||
? formatNoYearNoSec(data.qltwtrBeginDt)
|
||||
: "-", // 수질 측정시작일(채수시작일)
|
||||
qltwtrEndDt: data.qltwtrEndDt
|
||||
? formatNoYearNoSec(data.qltwtrEndDt)
|
||||
: "-", // 수질 측정종료일(채수종료일)
|
||||
};
|
||||
});
|
||||
setRainMeasureData(processedDataArray);
|
||||
setTotalCount(result?.totalElements || 0);
|
||||
} catch (error) {
|
||||
console.error('추가 데이터 로딩 실패:', error);
|
||||
console.error("추가 데이터 로딩 실패:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// 수량/수질측정 정보 API 호출 함수
|
||||
const fetchMntrngsData = async (adstDstrCd: string, manageNo: string) => {
|
||||
setIsResetDetailPage(true);
|
||||
if (!adstDstrCd || !manageNo) {
|
||||
console.warn('추가 데이터 호출 실패: 필수 매개변수 누락', { adstDstrCd, manageNo });
|
||||
console.warn("추가 데이터 호출 실패: 필수 매개변수 누락", {
|
||||
adstDstrCd,
|
||||
manageNo,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const queryParams = new URLSearchParams({
|
||||
page: `${curDetailPage - 1}`,
|
||||
size: '3',
|
||||
size: "3",
|
||||
});
|
||||
|
||||
const result = await getRawJson(`/sewerage-service/api/v1/onsemiro/swgRain/${adstDstrCd}/${manageNo}/mntrngs?${queryParams.toString()}`);
|
||||
const result = await getRawJson(
|
||||
`/sewerage-service/api/v1/onsemiro/swgRain/${adstDstrCd}/${manageNo}/mntrngs?${queryParams.toString()}`,
|
||||
);
|
||||
|
||||
// 각 아이템 전처리
|
||||
const processedDataArray = result.content.map((data: any) => {
|
||||
@@ -138,17 +192,16 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
|
||||
setMntrngsData(processedDataArray);
|
||||
setTotalDetailCount(result?.totalElements || 0);
|
||||
} catch (error) {
|
||||
console.error('추가 데이터 로딩 실패:', error);
|
||||
console.error("추가 데이터 로딩 실패:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 🎯 추가 데이터는 별도 useEffect로 비동기 로딩
|
||||
useEffect(() => {
|
||||
// 강우/측정 정보 데이터 호출
|
||||
if (isMainSsc && chmbrXcnts && chmbrYdnts) {
|
||||
fetchRainMeasureData(chmbrXcnts, chmbrYdnts)
|
||||
fetchRainMeasureData(chmbrXcnts, chmbrYdnts);
|
||||
}
|
||||
}, [chmbrXcnts, chmbrYdnts]);
|
||||
|
||||
@@ -160,7 +213,7 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
|
||||
});
|
||||
|
||||
if (!popupWindow) {
|
||||
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
|
||||
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -175,14 +228,17 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
|
||||
// featureData가 변경될 때 formData 초기화
|
||||
useEffect(() => {
|
||||
// showTable이 true일 때만 fetch (최초 렌더 방지)
|
||||
setIsResetDetailPage(false);
|
||||
setSelectedRowIndex(-1);
|
||||
if (chmbrXcnts && chmbrYdnts) fetchRainMeasureData(chmbrXcnts, chmbrYdnts);
|
||||
}, [curPage]);
|
||||
|
||||
// 페이지 변경 시마다 데이터 fetch
|
||||
useEffect(() => {
|
||||
if (chmbrXcnts && chmbrYdnts && selectedRowIndex !== -1) fetchMntrngsData(rainMeasureData[selectedRowIndex].adstDstrCd, rainMeasureData[selectedRowIndex].manageNo);
|
||||
if (chmbrXcnts && chmbrYdnts && selectedRowIndex !== -1)
|
||||
fetchMntrngsData(
|
||||
rainMeasureData[selectedRowIndex].adstDstrCd,
|
||||
rainMeasureData[selectedRowIndex].manageNo,
|
||||
);
|
||||
}, [curDetailPage]);
|
||||
|
||||
// 폼 데이터가 변경될 때 호출될 핸들러
|
||||
@@ -199,86 +255,72 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
|
||||
};
|
||||
|
||||
// 테이블 셀 클릭 핸들러 추가
|
||||
const handleCellClick = async (index: number | undefined, headId: string) => {
|
||||
if (index !== undefined && rainMeasureData) {
|
||||
await fetchMntrngsData(rainMeasureData[index].adstDstrCd, rainMeasureData[index].manageNo);
|
||||
const handleCellClick = async (
|
||||
_row: Record<string, unknown>,
|
||||
index: number,
|
||||
) => {
|
||||
if (rainMeasureData[index]) {
|
||||
await fetchMntrngsData(
|
||||
rainMeasureData[index].adstDstrCd,
|
||||
rainMeasureData[index].manageNo,
|
||||
);
|
||||
setSelectedRowIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
const renderModalBody = () => (
|
||||
<div className='grid grid-cols-1 gap-4 p-4 mr-4 mt-4'>
|
||||
<div className='border rounded-lg pt-4 bg-white'>
|
||||
<Label className='text-2xl font-bold text-label ml-4'>강우/측정 정보</Label>
|
||||
<hr className="my-4 ml-4 mr-4 border-t border-gray-300" />
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-4">
|
||||
{/* 강우정보 테이블 */}
|
||||
<UiTable
|
||||
headName=''
|
||||
tableData={rainMeasureData}
|
||||
headlist={untrtSwgRainCol3}
|
||||
<MapPopupDataTable
|
||||
title="강우정보 목록"
|
||||
rows={rainMeasureData}
|
||||
headlist={toStandardHeadList(untrtSwgRainCol3)}
|
||||
pageSize={3}
|
||||
total={0}
|
||||
cellClick={handleCellClick}
|
||||
total={totalCount}
|
||||
page={curPage}
|
||||
onCellClick={handleCellClick}
|
||||
onPageChange={setCurPage}
|
||||
resetPageNo={isResetPage}
|
||||
resetSelection={isResetSelection}
|
||||
index={selectedRowIndex}
|
||||
viewPagination={false}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="72rem"
|
||||
/>
|
||||
|
||||
{/* 측정정보 테이블 */}
|
||||
<UiTable
|
||||
headName=''
|
||||
tableData={rainMeasureData}
|
||||
headlist={untrtSwgRainCol4}
|
||||
<MapPopupDataTable
|
||||
title="측정정보 목록"
|
||||
rows={rainMeasureData}
|
||||
headlist={toStandardHeadList(untrtSwgRainCol4)}
|
||||
pageSize={3}
|
||||
total={totalCount}
|
||||
cellClick={handleCellClick}
|
||||
page={curPage}
|
||||
onCellClick={handleCellClick}
|
||||
onPageChange={setCurPage}
|
||||
resetPageNo={isResetPage}
|
||||
resetSelection={isResetSelection}
|
||||
index={selectedRowIndex}
|
||||
>
|
||||
{/* 커스텀 헤더 */}
|
||||
<TableRow>
|
||||
<TableHead colSpan={5} className="text-center bg-blue-100 font-bold">수량</TableHead>
|
||||
<TableHead colSpan={3} className="text-center bg-blue-100 font-bold">수질</TableHead>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead className="text-center">측정시작일시</TableHead>
|
||||
<TableHead className="text-center">측정종료일시</TableHead>
|
||||
<TableHead className="text-center">측정간격(분)</TableHead>
|
||||
<TableHead className="text-center">측정기기설치위치</TableHead>
|
||||
<TableHead className="text-center">유량계 종류 및 모델명</TableHead>
|
||||
<TableHead className="text-center">채수시작일시</TableHead>
|
||||
<TableHead className="text-center">채수종료일시</TableHead>
|
||||
<TableHead className="text-center">채수횟수</TableHead>
|
||||
</TableRow>
|
||||
</UiTable>
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="88rem"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='border rounded-lg pt-4 bg-white'>
|
||||
<div>
|
||||
{/* 수량/수질측정 정보 테이블 */}
|
||||
<Label className='text-2xl font-bold text-label ml-4'>수량/수질측정 정보</Label>
|
||||
<hr className="my-4 ml-4 mr-4 border-t border-gray-300" />
|
||||
<UiTable
|
||||
headName=''
|
||||
tableData={mntrngsData}
|
||||
headlist={untrtSwgRainCol5}
|
||||
<MapPopupDataTable
|
||||
title="수량·수질측정 목록"
|
||||
rows={mntrngsData}
|
||||
headlist={toStandardHeadList(untrtSwgRainCol5)}
|
||||
pageSize={3}
|
||||
total={totalDetailCount}
|
||||
cellClick={undefined}
|
||||
page={curDetailPage}
|
||||
onPageChange={setCurDetailPage}
|
||||
resetPageNo={isResetDetailPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!isMainSsc && (
|
||||
<VerticalTable
|
||||
headName='입력정보'
|
||||
headName="입력정보"
|
||||
tableData={featureData ? [formData] : []}
|
||||
headlist={inputColumn}
|
||||
pageSize={0}
|
||||
@@ -286,7 +328,7 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
|
||||
cellClick={undefined}
|
||||
isEditable={true}
|
||||
onPageChange={function (): void {
|
||||
throw new Error('Function not implemented.');
|
||||
throw new Error("Function not implemented.");
|
||||
}}
|
||||
showSaveButton={true}
|
||||
onSaveClick={handleSaveClick}
|
||||
@@ -294,13 +336,16 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
|
||||
/>
|
||||
)}
|
||||
|
||||
{isMainSsc && renderMode !== 'popup' && (
|
||||
{isMainSsc && renderMode !== "popup" && (
|
||||
<div>
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl mx-3 font-medium text-label">중점우수토실 운영정보</h3>
|
||||
<h3 className="text-2xl mx-3 font-medium text-label">
|
||||
중점우수토실 운영정보
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
className='mx-auto block w-64 mt-3 py-2 text-white rounded-sm text-sm font-medium hover:bg-blue-700 bg-blue-600'
|
||||
className="mx-auto mt-3 block w-64"
|
||||
color="white"
|
||||
onClick={openDetailPopup}
|
||||
>
|
||||
상세보기
|
||||
@@ -308,14 +353,13 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isMainSsc && renderMode === 'popup' && (
|
||||
<div className="min-h-dvh bg-white p-4">
|
||||
<h1 className="mb-3 text-2xl font-semibold text-gray-900">중점우수토실 운영정보</h1>
|
||||
{isMainSsc && renderMode === "popup" && (
|
||||
<StandardPopupLayout title="중점우수토실 운영정보">
|
||||
{renderModalBody()}
|
||||
</div>
|
||||
</StandardPopupLayout>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Ssc
|
||||
export default Ssc;
|
||||
|
||||
@@ -13,6 +13,7 @@ import React from 'react'
|
||||
import { VerticalTable } from '@/components/ui/verticalTable';
|
||||
import { Button } from '@common/components/ui';
|
||||
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
|
||||
import { useAlertStore } from '@/shared/lib';
|
||||
|
||||
// 하수처리시설
|
||||
const headList = [
|
||||
@@ -20,6 +21,7 @@ const headList = [
|
||||
];
|
||||
|
||||
const SwtrmtFclt = ({ gisObject }: { gisObject: any }) => {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const ctpvNm = gisObject?.ctpv_nm;
|
||||
const sggNm = gisObject?.sgg_nm;
|
||||
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, Table, TableBody, TableCell, TableRow } from "@common/components";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
import { useGisContext } from "@common/business_components/ui/map/Gis";
|
||||
import Feature from "ol/Feature.js";
|
||||
import GeoJSON from "ol/format/GeoJSON.js";
|
||||
@@ -12,6 +24,7 @@ import Style from "ol/style/Style.js";
|
||||
|
||||
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import {
|
||||
fetchSwtrmtPrcsZoneAnalysis,
|
||||
getSwtrmtPrcsZoneId,
|
||||
@@ -22,7 +35,10 @@ import type {
|
||||
SwtrmtPrcsZoneAnalysisResponse,
|
||||
} from "./swtrmtPrcsZoneAnalysisApi";
|
||||
|
||||
const analysisRows: { key: keyof NonNullable<SwtrmtPrcsZoneAnalysisResponse["analysis"]>; title: string }[] = [
|
||||
const analysisRows: {
|
||||
key: keyof NonNullable<SwtrmtPrcsZoneAnalysisResponse["analysis"]>;
|
||||
title: string;
|
||||
}[] = [
|
||||
{ key: "total", title: "전체" },
|
||||
{ key: "combined", title: "합류관" },
|
||||
{ key: "rain", title: "우수관" },
|
||||
@@ -40,11 +56,17 @@ const selectedRepairHistoryStyle = new Style({
|
||||
});
|
||||
|
||||
const formatValue = (value: unknown, unit = "") => {
|
||||
if (value === null || value === undefined || value === "") return "데이터 없음";
|
||||
if (value === null || value === undefined || value === "")
|
||||
return "데이터 없음";
|
||||
|
||||
const numberValue = typeof value === "number" ? value : Number(String(value).replaceAll(",", ""));
|
||||
const numberValue =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: Number(String(value).replaceAll(",", ""));
|
||||
if (!Number.isNaN(numberValue) && Number.isFinite(numberValue)) {
|
||||
const formatted = numberValue.toLocaleString("ko-KR", { maximumFractionDigits: 2 });
|
||||
const formatted = numberValue.toLocaleString("ko-KR", {
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
return unit ? `${formatted}${unit}` : formatted;
|
||||
}
|
||||
|
||||
@@ -69,26 +91,24 @@ const MetricVerticalGrid = ({
|
||||
title,
|
||||
field,
|
||||
analysis,
|
||||
isLoading,
|
||||
}: {
|
||||
title: string;
|
||||
field: keyof SwtrmtPrcsZoneAnalysisMetric;
|
||||
analysis: SwtrmtPrcsZoneAnalysisResponse["analysis"] | undefined;
|
||||
isLoading?: boolean;
|
||||
}) => (
|
||||
<div className="mb-4 last:mb-0">
|
||||
<div className="text-xl mx-3 mb-2 mt-1 font-medium text-label">{title}</div>
|
||||
<div className="mx-3 mb-2 mt-1 text-xl font-medium text-label">{title}</div>
|
||||
<Table variant="secondary">
|
||||
<TableBody className="text-center">
|
||||
{analysisRows.map((row) => {
|
||||
const metric = getMetric(analysis, row.key);
|
||||
return (
|
||||
<TableRow key={`${title}-${row.key}`}>
|
||||
<TableCell className="font-bold bg-blue-100 text-[13px] w-[120px] whitespace-nowrap">
|
||||
<TableCell className="w-[120px] whitespace-nowrap bg-secondary text-[13px] font-bold text-label">
|
||||
{row.title}
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px]">
|
||||
{isLoading ? "조회 중" : formatValue(metric[field])}
|
||||
{formatValue(metric[field])}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -106,9 +126,11 @@ const SwtrmtPrcsZoneAnalysis = ({
|
||||
featureData: any;
|
||||
rawFeatureData?: any;
|
||||
}) => {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { gisMap } = useGisContext() as unknown as { gisMap: any };
|
||||
// 기존 처리구역 관로분석 정보의 조회 상태다.
|
||||
const [analysisData, setAnalysisData] = useState<SwtrmtPrcsZoneAnalysisResponse | null>(null);
|
||||
const [analysisData, setAnalysisData] =
|
||||
useState<SwtrmtPrcsZoneAnalysisResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
// 보수이력 지도 레이어는 React 렌더링 대상이 아니므로 ref로 보관해 팝업 종료·구역 전환 때 제거한다.
|
||||
@@ -127,7 +149,9 @@ const SwtrmtPrcsZoneAnalysis = ({
|
||||
rawValues?.swtrmtFcltCd,
|
||||
featureData?.swtrmt_fclt_cd,
|
||||
featureData?.swtrmtFcltCd,
|
||||
].filter(Boolean).join("|");
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("|");
|
||||
}, [featureData, rawFeatureData]);
|
||||
|
||||
const zoneId = useMemo(
|
||||
@@ -144,7 +168,8 @@ const SwtrmtPrcsZoneAnalysis = ({
|
||||
}, [gisMap]);
|
||||
|
||||
/** 윈도우 팝업 조회 결과 전체를 청록색 관로 레이어로 교체한다. */
|
||||
const replaceRepairLayer = useCallback((rows: RepairHistoryRow[]) => {
|
||||
const replaceRepairLayer = useCallback(
|
||||
(rows: RepairHistoryRow[]) => {
|
||||
if (!gisMap) return;
|
||||
clearRepairLayer();
|
||||
|
||||
@@ -169,21 +194,31 @@ const SwtrmtPrcsZoneAnalysis = ({
|
||||
if (features.length === 0) return;
|
||||
const layer = new VectorLayer({
|
||||
source: new VectorSource({ features }),
|
||||
style: (feature) => feature.get("repairSelected") ? selectedRepairHistoryStyle : repairHistoryStyle,
|
||||
style: (feature) =>
|
||||
feature.get("repairSelected")
|
||||
? selectedRepairHistoryStyle
|
||||
: repairHistoryStyle,
|
||||
});
|
||||
layer.set("repairHistoryLayer", true);
|
||||
layer.setZIndex(10020);
|
||||
gisMap.addLayer(layer);
|
||||
repairLayerRef.current = layer;
|
||||
}, [clearRepairLayer, gisMap]);
|
||||
},
|
||||
[clearRepairLayer, gisMap],
|
||||
);
|
||||
|
||||
/** 팝업에서 클릭한 관로를 붉게 선택하고 부모 지도를 해당 범위로 이동한다. */
|
||||
const focusRepairFeature = useCallback((fid: string) => {
|
||||
const focusRepairFeature = useCallback(
|
||||
(fid: string) => {
|
||||
const features = repairLayerRef.current?.getSource()?.getFeatures() ?? [];
|
||||
const selectedFeature = features.find((feature) => String(feature.get("repairFid")) === String(fid));
|
||||
const selectedFeature = features.find(
|
||||
(feature) => String(feature.get("repairFid")) === String(fid),
|
||||
);
|
||||
if (!selectedFeature || !gisMap) return;
|
||||
|
||||
features.forEach((feature) => feature.set("repairSelected", feature === selectedFeature));
|
||||
features.forEach((feature) =>
|
||||
feature.set("repairSelected", feature === selectedFeature),
|
||||
);
|
||||
repairLayerRef.current?.changed();
|
||||
const extent = selectedFeature.getGeometry()?.getExtent();
|
||||
if (extent) {
|
||||
@@ -193,7 +228,9 @@ const SwtrmtPrcsZoneAnalysis = ({
|
||||
maxZoom: 18,
|
||||
});
|
||||
}
|
||||
}, [gisMap]);
|
||||
},
|
||||
[gisMap],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
@@ -202,18 +239,27 @@ const SwtrmtPrcsZoneAnalysis = ({
|
||||
|
||||
const loadAnalysis = async () => {
|
||||
try {
|
||||
const data = await fetchSwtrmtPrcsZoneAnalysis(rawFeatureData, featureData);
|
||||
const data = await fetchSwtrmtPrcsZoneAnalysis(
|
||||
rawFeatureData,
|
||||
featureData,
|
||||
);
|
||||
if (!isMounted) return;
|
||||
if (!data) {
|
||||
setAnalysisData(null);
|
||||
setErrorMessage("처리구역 식별값이 없어 관로분석정보를 조회하지 못했습니다.");
|
||||
setErrorMessage(
|
||||
"처리구역 식별값이 없어 관로분석정보를 조회하지 못했습니다.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setAnalysisData(data);
|
||||
} catch (error) {
|
||||
if (!isMounted) return;
|
||||
setAnalysisData(null);
|
||||
setErrorMessage(error instanceof Error ? error.message : "관로분석정보를 불러오지 못했습니다.");
|
||||
setErrorMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "관로분석정보를 불러오지 못했습니다.",
|
||||
);
|
||||
} finally {
|
||||
if (isMounted) setIsLoading(false);
|
||||
}
|
||||
@@ -232,8 +278,12 @@ const SwtrmtPrcsZoneAnalysis = ({
|
||||
const messageZoneId = String(data?.payload?.zoneId ?? "").trim();
|
||||
if (!messageZoneId || messageZoneId !== zoneId) return;
|
||||
|
||||
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS) {
|
||||
replaceRepairLayer(Array.isArray(data.payload?.rows) ? data.payload.rows : []);
|
||||
if (
|
||||
data?.type === WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS
|
||||
) {
|
||||
replaceRepairLayer(
|
||||
Array.isArray(data.payload?.rows) ? data.payload.rows : [],
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SELECT_PIPE) {
|
||||
@@ -246,7 +296,8 @@ const SwtrmtPrcsZoneAnalysis = ({
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleRepairPopupMessage);
|
||||
return () => window.removeEventListener("message", handleRepairPopupMessage);
|
||||
return () =>
|
||||
window.removeEventListener("message", handleRepairPopupMessage);
|
||||
}, [clearRepairLayer, focusRepairFeature, replaceRepairLayer, zoneId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -276,7 +327,10 @@ const SwtrmtPrcsZoneAnalysis = ({
|
||||
clearRepairLayer();
|
||||
const popup = openWindowPopup("repairHistoryPopup", {
|
||||
zoneId,
|
||||
zoneName: analysisData?.zone?.swtrmtZoneNm ?? featureData?.swtrmt_zone_nm ?? featureData?.swtrmtZoneNm,
|
||||
zoneName:
|
||||
analysisData?.zone?.swtrmtZoneNm ??
|
||||
featureData?.swtrmt_zone_nm ??
|
||||
featureData?.swtrmtZoneNm,
|
||||
});
|
||||
if (!popup) {
|
||||
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
|
||||
@@ -288,11 +342,12 @@ const SwtrmtPrcsZoneAnalysis = ({
|
||||
return (
|
||||
<>
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="text-2xl mx-3 font-medium text-label">처리구역내 관로분석정보</h3>
|
||||
<h3 className="mx-3 text-2xl font-medium text-label">
|
||||
처리구역내 관로분석정보
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="mr-3 bg-blue-600 text-white hover:bg-blue-700"
|
||||
className="hover:bg-primary/90 mr-3 bg-primary text-primary-foreground"
|
||||
onClick={handleOpenRepairHistory}
|
||||
data-testid="repair-history-open"
|
||||
>
|
||||
@@ -306,15 +361,23 @@ const SwtrmtPrcsZoneAnalysis = ({
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
{metricGroups.map((group) => (
|
||||
{isLoading ? (
|
||||
<div
|
||||
role="status"
|
||||
className="flex min-h-[12rem] items-center justify-center rounded-md border border-border bg-background text-[1.2rem] text-muted-foreground"
|
||||
>
|
||||
관로분석정보를 조회하고 있습니다.
|
||||
</div>
|
||||
) : (
|
||||
metricGroups.map((group) => (
|
||||
<MetricVerticalGrid
|
||||
key={group.title}
|
||||
title={group.title}
|
||||
field={group.field}
|
||||
analysis={analysisData?.analysis}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import moment from 'moment';
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import moment from "moment";
|
||||
|
||||
import {
|
||||
CHART_FORM,
|
||||
type ChartConfig,
|
||||
MainChart,
|
||||
} from '@common/business_components';
|
||||
import { SelectBox } from '@common/business_components/ui';
|
||||
import { Button, Input, Label } from '@common/components';
|
||||
StandardFormField,
|
||||
StandardFormSection,
|
||||
StandardPopupLayout,
|
||||
} from "@common/business_components";
|
||||
import { SelectBox } from "@common/business_components/ui";
|
||||
import { Button, Input } from "@common/components";
|
||||
|
||||
import type { TInftwtAnls, TInftwtAnlsRslt, TOilqData } from './aplcnYms';
|
||||
import * as fn from './aplcnYms';
|
||||
import type { TInftwtAnls, TInftwtAnlsRslt, TOilqData } from "./aplcnYms";
|
||||
import * as fn from "./aplcnYms";
|
||||
|
||||
type ModalOpenProp = {
|
||||
pplMngNo?: string;
|
||||
@@ -27,43 +30,46 @@ type DataType = {
|
||||
val: number;
|
||||
};
|
||||
|
||||
const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: '선택' }];
|
||||
const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: "선택" }];
|
||||
|
||||
const InfilChart = ({
|
||||
pplMngNo = '',
|
||||
pplMngNo = "",
|
||||
inftwtAnlsSn = 0,
|
||||
aplcnYm = '',
|
||||
analysisInfo = '',
|
||||
aplcnYm = "",
|
||||
analysisInfo = "",
|
||||
resultOnly = false,
|
||||
}: ModalOpenProp) => {
|
||||
const [selectedAplcnYm, setSelectedAplcnYm] = useState('');
|
||||
const [selectedAplcnYm, setSelectedAplcnYm] = useState("");
|
||||
const [inftwtAnlss, setInftwtAnlss] = useState<TInftwtAnls[]>([]);
|
||||
const [inftwtAnlsInfos, setInftwtAnlsInfos] = useState<{ text: string; val: number; hidden?: boolean }[]>(
|
||||
INITIAL_ANALYSIS_OPTIONS,
|
||||
);
|
||||
const [inftwtAnlsInfos, setInftwtAnlsInfos] = useState<
|
||||
{ text: string; val: number; hidden?: boolean }[]
|
||||
>(INITIAL_ANALYSIS_OPTIONS);
|
||||
const [inftwtAnls, setInftwtAnls] = useState<TInftwtAnls | null>(null);
|
||||
const [selectedInftwtAnls, setSelectedInftwtAnls] = useState<number>(0);
|
||||
const [chartData, setChartData] = useState<{ chartData1: DataType[]; chartData2: DataType[] }>({
|
||||
const [chartData, setChartData] = useState<{
|
||||
chartData1: DataType[];
|
||||
chartData2: DataType[];
|
||||
}>({
|
||||
chartData1: [],
|
||||
chartData2: [],
|
||||
});
|
||||
const [inftwtQty, setInftwtQty] = useState<string>('0');
|
||||
const [inftwtQty, setInftwtQty] = useState<string>("0");
|
||||
|
||||
const aplcnYms = fn.useAplcnYms('inftwtAnlsRslt');
|
||||
const normalizedPplMngNo = pplMngNo ? `L${pplMngNo}` : '';
|
||||
const isResultOnlyMode = resultOnly && inftwtAnlsSn > 0 && Boolean(normalizedPplMngNo);
|
||||
const aplcnYms = fn.useAplcnYms("inftwtAnlsRslt");
|
||||
const normalizedPplMngNo = pplMngNo ? `L${pplMngNo}` : "";
|
||||
const isResultOnlyMode =
|
||||
resultOnly && inftwtAnlsSn > 0 && Boolean(normalizedPplMngNo);
|
||||
|
||||
const chartForm = CHART_FORM.LINE;
|
||||
const chartConfig = {
|
||||
chartData1: { label: '순오수량', color: 'red' },
|
||||
chartData2: { label: '실측하수량', color: 'orange' },
|
||||
chartData1: { label: "순오수량", color: "red" },
|
||||
chartData2: { label: "실측하수량", color: "orange" },
|
||||
} as ChartConfig;
|
||||
|
||||
const hasChartData = useMemo(() => {
|
||||
return chartData.chartData1.length > 0 || chartData.chartData2.length > 0;
|
||||
}, [chartData]);
|
||||
|
||||
|
||||
const calculateFallbackInftwtQty = (
|
||||
inftwtAnlsRslt: TInftwtAnlsRslt[],
|
||||
oilqRows: TOilqData[],
|
||||
@@ -79,26 +85,28 @@ const InfilChart = ({
|
||||
return 0;
|
||||
}
|
||||
|
||||
const avgPredc = predcValues.reduce((sum, value) => sum + value, 0) / predcValues.length;
|
||||
const avgOilq = oilqValues.reduce((sum, value) => sum + value, 0) / oilqValues.length;
|
||||
const avgPredc =
|
||||
predcValues.reduce((sum, value) => sum + value, 0) / predcValues.length;
|
||||
const avgOilq =
|
||||
oilqValues.reduce((sum, value) => sum + value, 0) / oilqValues.length;
|
||||
const dailyQty = (avgOilq - avgPredc) * 24;
|
||||
return Math.round(dailyQty / 10) * 10;
|
||||
};
|
||||
|
||||
const resetState = () => {
|
||||
setSelectedAplcnYm('');
|
||||
setSelectedAplcnYm("");
|
||||
setInftwtAnlss([]);
|
||||
setInftwtAnlsInfos(INITIAL_ANALYSIS_OPTIONS);
|
||||
setInftwtAnls(null);
|
||||
setSelectedInftwtAnls(0);
|
||||
setChartData({ chartData1: [], chartData2: [] });
|
||||
setInftwtQty('0');
|
||||
setInftwtQty("0");
|
||||
};
|
||||
|
||||
const loadResultChart = async (targetInftwtAnlsSn: number) => {
|
||||
if (!targetInftwtAnlsSn || !normalizedPplMngNo) {
|
||||
setChartData({ chartData1: [], chartData2: [] });
|
||||
setInftwtQty('0');
|
||||
setInftwtQty("0");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -106,11 +114,13 @@ const InfilChart = ({
|
||||
const [inftwtAnlsRslt, oilq, qty] = await Promise.all([
|
||||
fn.selectInftwtAnlsRslt(targetInftwtAnlsSn, normalizedPplMngNo),
|
||||
fn.selectOilq(targetInftwtAnlsSn, normalizedPplMngNo).catch((err) => {
|
||||
console.error('selectOilq 에러:', err);
|
||||
console.error("selectOilq 에러:", err);
|
||||
return [];
|
||||
}),
|
||||
fn.selectPplInftwtQty(targetInftwtAnlsSn, normalizedPplMngNo).catch((err) => {
|
||||
console.error('selectPplInftwtQty 에러:', err);
|
||||
fn
|
||||
.selectPplInftwtQty(targetInftwtAnlsSn, normalizedPplMngNo)
|
||||
.catch((err) => {
|
||||
console.error("selectPplInftwtQty 에러:", err);
|
||||
return 0;
|
||||
}),
|
||||
]);
|
||||
@@ -120,26 +130,37 @@ const InfilChart = ({
|
||||
chartData2: fn.getChartDataOilq(oilq ?? []),
|
||||
});
|
||||
|
||||
const fallbackQty = calculateFallbackInftwtQty(inftwtAnlsRslt ?? [], oilq ?? []);
|
||||
const fallbackQty = calculateFallbackInftwtQty(
|
||||
inftwtAnlsRslt ?? [],
|
||||
oilq ?? [],
|
||||
);
|
||||
const normalizedQty = Number(qty ?? Number.NaN);
|
||||
const resolvedQty = Number.isFinite(normalizedQty) && !(normalizedQty === 0 && fallbackQty !== 0)
|
||||
const resolvedQty =
|
||||
Number.isFinite(normalizedQty) &&
|
||||
!(normalizedQty === 0 && fallbackQty !== 0)
|
||||
? normalizedQty
|
||||
: fallbackQty;
|
||||
|
||||
if (!Number.isFinite(normalizedQty) || (normalizedQty === 0 && fallbackQty !== 0)) {
|
||||
console.warn('Inftwt qty API returned empty or zero; using chart-data fallback qty.', {
|
||||
if (
|
||||
!Number.isFinite(normalizedQty) ||
|
||||
(normalizedQty === 0 && fallbackQty !== 0)
|
||||
) {
|
||||
console.warn(
|
||||
"Inftwt qty API returned empty or zero; using chart-data fallback qty.",
|
||||
{
|
||||
apiQty: qty,
|
||||
fallbackQty,
|
||||
resultCount: inftwtAnlsRslt?.length ?? 0,
|
||||
oilqCount: oilq?.length ?? 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
setInftwtQty(resolvedQty.toLocaleString('ko-KR'));
|
||||
setInftwtQty(resolvedQty.toLocaleString("ko-KR"));
|
||||
} catch (error) {
|
||||
console.error('침입수 분석결과 차트 조회 중 오류:', error);
|
||||
console.error("침입수 분석결과 차트 조회 중 오류:", error);
|
||||
setChartData({ chartData1: [], chartData2: [] });
|
||||
setInftwtQty('0');
|
||||
setInftwtQty("0");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -159,10 +180,10 @@ const InfilChart = ({
|
||||
setInftwtAnlss([]);
|
||||
setInftwtAnls(null);
|
||||
setSelectedInftwtAnls(0);
|
||||
setInftwtQty('0');
|
||||
setInftwtQty("0");
|
||||
setChartData({ chartData1: [], chartData2: [] });
|
||||
|
||||
if (!selectedValue || selectedValue === '0') {
|
||||
if (!selectedValue || selectedValue === "0") {
|
||||
setInftwtAnlsInfos(INITIAL_ANALYSIS_OPTIONS);
|
||||
return;
|
||||
}
|
||||
@@ -173,7 +194,7 @@ const InfilChart = ({
|
||||
setInftwtAnlss(list);
|
||||
setInftwtAnlsInfos(fn.getOptionsInftwtAnlsInfo(list));
|
||||
} catch (error) {
|
||||
console.error('selectInftwtAnls 에러:', error);
|
||||
console.error("selectInftwtAnls 에러:", error);
|
||||
setInftwtAnlss([]);
|
||||
setInftwtAnlsInfos(INITIAL_ANALYSIS_OPTIONS);
|
||||
}
|
||||
@@ -184,37 +205,37 @@ const InfilChart = ({
|
||||
|
||||
setSelectedInftwtAnls(selectedValue);
|
||||
setChartData({ chartData1: [], chartData2: [] });
|
||||
setInftwtQty('0');
|
||||
setInftwtQty("0");
|
||||
|
||||
if (selectedValue === 0) {
|
||||
setInftwtAnls(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedAnls = inftwtAnlss.find((anls) => anls.inftwtAnlsSn === selectedValue) ?? null;
|
||||
const selectedAnls =
|
||||
inftwtAnlss.find((anls) => anls.inftwtAnlsSn === selectedValue) ?? null;
|
||||
setInftwtAnls(selectedAnls);
|
||||
};
|
||||
|
||||
const infilRsltChart = async () => {
|
||||
if (!selectedAplcnYm || selectedInftwtAnls === 0 || !normalizedPplMngNo) {
|
||||
setChartData({ chartData1: [], chartData2: [] });
|
||||
setInftwtQty('0');
|
||||
setInftwtQty("0");
|
||||
return;
|
||||
}
|
||||
|
||||
await loadResultChart(selectedInftwtAnls);
|
||||
};
|
||||
|
||||
const renderInfoInput = (label: string, value?: string | number | null) => (
|
||||
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3">
|
||||
<Label className="w-auto text-[14px] font-semibold text-[#5c6470] whitespace-nowrap">{label}</Label>
|
||||
<Input
|
||||
className="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white px-3 text-xl md:text-xl text-[#2b3440]"
|
||||
placeholder=""
|
||||
value={value ?? ''}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
/** 분석 메타데이터를 공통 표형 폼의 읽기 전용 셀로 표시한다. */
|
||||
const renderInfoField = (
|
||||
label: string,
|
||||
value?: string | number | null,
|
||||
span: 1 | 2 = 1,
|
||||
) => (
|
||||
<StandardFormField label={label} columns={2} span={span}>
|
||||
<Input className="w-full" value={value ?? ""} readOnly />
|
||||
</StandardFormField>
|
||||
);
|
||||
|
||||
const handleReset = () => {
|
||||
@@ -222,116 +243,122 @@ const InfilChart = ({
|
||||
};
|
||||
|
||||
const renderResultPanel = () => (
|
||||
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-4">
|
||||
<h3 className="mb-4 text-[20px] font-extrabold tracking-[-0.02em] text-black">침입수 분석 결과</h3>
|
||||
<div className="mb-4 grid grid-cols-2 gap-4">
|
||||
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
|
||||
<div className="flex items-center gap-2 text-[16px] font-extrabold text-[#ef4444]">
|
||||
<span>상수사용량 적용연월</span>
|
||||
<section className="card card-border !m-0 min-w-0 overflow-hidden !p-[1.2rem]">
|
||||
<StandardFormSection title="침입수 분석결과" columns={2}>
|
||||
<StandardFormField label="상수사용량 적용연월" columns={2}>
|
||||
<div className="flex min-h-[3.2rem] items-center justify-end text-[1.35rem] font-semibold tabular-nums text-foreground">
|
||||
{selectedAplcnYm
|
||||
? moment(selectedAplcnYm, "YYYYMM").format("YYYY-MM")
|
||||
: "-"}
|
||||
</div>
|
||||
<div className="text-[20px] font-extrabold text-[#222]">
|
||||
{selectedAplcnYm ? moment(selectedAplcnYm, 'YYYYMM').format('YYYY-MM') : '-'}
|
||||
</StandardFormField>
|
||||
<StandardFormField label="침입수량 산정결과" columns={2}>
|
||||
<div className="flex min-h-[3.2rem] items-center justify-end gap-3 whitespace-nowrap text-foreground">
|
||||
<span className="text-[1.15rem] text-muted-foreground">
|
||||
침입수량
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<strong className="text-[1.5rem] font-semibold tabular-nums">
|
||||
{inftwtQty}
|
||||
</strong>
|
||||
<span className="text-[1.1rem]">m³/d</span>
|
||||
</div>
|
||||
</div>
|
||||
</StandardFormField>
|
||||
</StandardFormSection>
|
||||
|
||||
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
|
||||
<div className="flex items-center gap-2 text-[16px] font-extrabold text-[#ef4444]">
|
||||
<span>침입수량 산정결과</span>
|
||||
</div>
|
||||
<div className="flex items-end gap-3 whitespace-nowrap text-[#222]">
|
||||
<span className="text-[16px] text-[#666]">침입수량</span>
|
||||
<div className="flex items-end justify-start gap-2 whitespace-nowrap">
|
||||
<span className="text-[20px] font-extrabold">{inftwtQty}</span>
|
||||
<span className="pb-[2px] text-[16px]">m³/d</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[10px] bg-white px-2 pb-2 pt-3">
|
||||
<section className="mt-[1.2rem] overflow-hidden rounded-sm border border-border bg-background">
|
||||
<h4 className="border-b border-border bg-container px-4 py-3 text-[1.4rem] font-semibold text-label">
|
||||
침입수량 그래프
|
||||
</h4>
|
||||
<div className="min-h-[30rem] p-3">
|
||||
{hasChartData ? (
|
||||
<MainChart
|
||||
chartConfig={chartConfig}
|
||||
chartForm={chartForm}
|
||||
chartData={chartData}
|
||||
chartTitle={'침입수량 그래프'}
|
||||
chartTitle=""
|
||||
yFieldText="유량(m³/h)"
|
||||
yFieldText2=""
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-[464px] items-center justify-center text-[18px] text-[#2c2f33]">
|
||||
데이터가 없습니다.
|
||||
<div
|
||||
className="flex min-h-[30rem] items-center justify-center text-[1.3rem] text-muted-foreground"
|
||||
role="status"
|
||||
>
|
||||
조회된 데이터가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
|
||||
const renderDefaultBody = () => (
|
||||
<div className="text-black">
|
||||
<div className="grid min-h-[68vh] grid-cols-[minmax(0,38%)_minmax(0,62%)] gap-5">
|
||||
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-5">
|
||||
<h3 className="mb-6 text-[20px] font-extrabold tracking-[-0.02em] text-black">
|
||||
침입수 분석 선택 조건
|
||||
</h3>
|
||||
|
||||
<div className="rounded-[6px] border border-[#d9dde3] bg-[#eff1f4] px-4 py-6">
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3">
|
||||
<Label className="w-auto text-[14px] font-semibold text-[#5c6470] whitespace-nowrap">
|
||||
상수사용월:
|
||||
</Label>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-[1.2rem]">
|
||||
<StandardFormSection title="침입수 분석 조건" columns={2}>
|
||||
<StandardFormField label="상수사용월" columns={2}>
|
||||
<SelectBox
|
||||
className="w-full"
|
||||
selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl"
|
||||
selectClass="w-full"
|
||||
label=""
|
||||
selectArray={aplcnYms}
|
||||
value={selectedAplcnYm}
|
||||
onSelectValue={handleAplcnYmChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3">
|
||||
<Label className="w-auto text-[14px] font-semibold text-[#5c6470] whitespace-nowrap">
|
||||
분석정보:
|
||||
</Label>
|
||||
</StandardFormField>
|
||||
<StandardFormField label="분석정보" columns={2}>
|
||||
<SelectBox
|
||||
className="w-full"
|
||||
selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl"
|
||||
selectClass="w-full"
|
||||
label=""
|
||||
selectArray={inftwtAnlsInfos}
|
||||
value={selectedInftwtAnls}
|
||||
onSelectValue={handleInftwtAnlsSnChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{renderInfoInput('오수전환율:', inftwtAnls?.drtwSwtcRt ?? '')}
|
||||
{renderInfoInput('시간패턴 파일명:', inftwtAnls?.atflHrPttnNm ?? '')}
|
||||
{renderInfoInput('수용가-맨홀 매칭:', inftwtAnls?.atflMpngNm ?? '')}
|
||||
{renderInfoInput('관망도 파일명:', inftwtAnls?.atflInpNm ?? '')}
|
||||
{renderInfoInput('유량 파일명:', inftwtAnls?.atflOilqNm ?? '')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 mb-[40px] flex justify-center">
|
||||
<Button size="lg" onClick={infilRsltChart} >
|
||||
조회
|
||||
</Button>
|
||||
<Button size="lg" color="gray" onClick={handleReset} >
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</StandardFormField>
|
||||
{renderInfoField("오수전환율", inftwtAnls?.drtwSwtcRt ?? "")}
|
||||
{renderInfoField(
|
||||
"시간패턴 파일명",
|
||||
inftwtAnls?.atflHrPttnNm ?? "",
|
||||
)}
|
||||
{renderInfoField("수용가-맨홀 매칭", inftwtAnls?.atflMpngNm ?? "")}
|
||||
{renderInfoField("관망도 파일명", inftwtAnls?.atflInpNm ?? "")}
|
||||
{renderInfoField("유량 파일명", inftwtAnls?.atflOilqNm ?? "", 2)}
|
||||
</StandardFormSection>
|
||||
|
||||
{renderResultPanel()}
|
||||
</div>
|
||||
);
|
||||
|
||||
/** 조회·초기화 뒤에 창 닫기 성격의 취소 액션을 항상 마지막에 배치한다. */
|
||||
const renderFooterActions = () => (
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
{!isResultOnlyMode ? (
|
||||
<>
|
||||
<Button type="button" onClick={infilRsltChart}>
|
||||
조회
|
||||
</Button>
|
||||
<Button type="button" color="white" onClick={handleReset}>
|
||||
초기화
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button type="button" color="white" onClick={() => window.close()}>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-white p-3">
|
||||
<StandardPopupLayout
|
||||
title={isResultOnlyMode ? "침입수 분석결과" : "침입수 분석"}
|
||||
bodyClassName="overflow-x-hidden"
|
||||
footer={renderFooterActions()}
|
||||
showDefaultCancelAction={false}
|
||||
>
|
||||
{isResultOnlyMode ? renderResultPanel() : renderDefaultBody()}
|
||||
</div>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,9 +7,12 @@ import {
|
||||
CHART_FORM,
|
||||
type ChartConfig,
|
||||
MainChart,
|
||||
StandardFormField,
|
||||
StandardFormSection,
|
||||
StandardPopupLayout,
|
||||
} from "@common/business_components";
|
||||
import { SelectBox } from "@common/business_components/ui";
|
||||
import { Button, Input, Label } from "@common/components";
|
||||
import { Button, Input } from "@common/components";
|
||||
|
||||
import type { TRdiiAnls } from "./aplcnYms";
|
||||
import * as fn from "./aplcnYms";
|
||||
@@ -45,18 +48,20 @@ const RdiiChart = ({
|
||||
}: ModalOpenProp) => {
|
||||
const [selectedAplcnYm, setSelectedAplcnYm] = useState("");
|
||||
const [rdiiAnlss, setRdiiAnlss] = useState<TRdiiAnls[]>([]);
|
||||
const [rdiiAnlsInfos, setRdiiAnlsInfos] = useState<{ text: string; val: number; hidden?: boolean }[]>(
|
||||
INITIAL_ANALYSIS_OPTIONS,
|
||||
);
|
||||
const [rdiiAnlsInfos, setRdiiAnlsInfos] = useState<
|
||||
{ text: string; val: number; hidden?: boolean }[]
|
||||
>(INITIAL_ANALYSIS_OPTIONS);
|
||||
const [rdiiAnls, setRdiiAnls] = useState<TRdiiAnls | null>(null);
|
||||
const [selectedRainInfwtAnlsSn, setSelectedRainInfwtAnlsSn] = useState<number>(0);
|
||||
const [selectedRainInfwtAnlsSn, setSelectedRainInfwtAnlsSn] =
|
||||
useState<number>(0);
|
||||
const [chartData, setChartData] = useState<LineBarChartData[]>([]);
|
||||
const [rdiiQty, setRdiiQty] = useState<string>("0");
|
||||
const [rainTotal, setRainTotal] = useState<string>("0");
|
||||
const [totalVolume, setTotalVolume] = useState<string>("0");
|
||||
|
||||
const normalizedPplMngNo = normalizePplMngNo(pplMngNo);
|
||||
const isResultOnlyMode = resultOnly && rainInfwtAnlsSn > 0 && Boolean(normalizedPplMngNo);
|
||||
const isResultOnlyMode =
|
||||
resultOnly && rainInfwtAnlsSn > 0 && Boolean(normalizedPplMngNo);
|
||||
const aplcnYms = fn.useAplcnYms(isResultOnlyMode ? "" : "rdiiAnlsRslt");
|
||||
|
||||
const chartForm = CHART_FORM.LINEBAR2;
|
||||
@@ -103,7 +108,9 @@ const RdiiChart = ({
|
||||
|
||||
const rsltDataFormatted = fn.getChartDataRdiiAnlsRslt(rsltData ?? []);
|
||||
const rainDataFormatted = fn.getChartDataRainData(rainData ?? []);
|
||||
const rainMap = new Map(rainDataFormatted.map((item) => [item.label, item.barVal0]));
|
||||
const rainMap = new Map(
|
||||
rainDataFormatted.map((item) => [item.label, item.barVal0]),
|
||||
);
|
||||
|
||||
rsltDataFormatted.forEach((item) => {
|
||||
const matchedVal = rainMap.get(item.label);
|
||||
@@ -118,7 +125,9 @@ const RdiiChart = ({
|
||||
const appliedRainfall = Number(qtyData?.raintotal ?? 0);
|
||||
const rdiiPerRainfall =
|
||||
qtyData?.raintotal && qtyData?.raintotal !== 0
|
||||
? Math.round(avgRdii * Number(qtyData?.sustaintime ?? 0) / qtyData.raintotal)
|
||||
? Math.round(
|
||||
(avgRdii * Number(qtyData?.sustaintime ?? 0)) / qtyData.raintotal,
|
||||
)
|
||||
: 0;
|
||||
|
||||
setRdiiQty(Math.round(avgRdii * 24).toLocaleString("ko-KR"));
|
||||
@@ -179,7 +188,8 @@ const RdiiChart = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedAnls = rdiiAnlss.find((anls) => anls.rainInfwtAnlsSn === selectedValue) ?? null;
|
||||
const selectedAnls =
|
||||
rdiiAnlss.find((anls) => anls.rainInfwtAnlsSn === selectedValue) ?? null;
|
||||
setRdiiAnls(selectedAnls);
|
||||
};
|
||||
|
||||
@@ -195,135 +205,139 @@ const RdiiChart = ({
|
||||
await loadResultChart(selectedRainInfwtAnlsSn);
|
||||
};
|
||||
|
||||
const renderInfoInput = (label: string, value?: string | number | null) => (
|
||||
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3">
|
||||
<Label className="w-auto whitespace-nowrap text-[14px] font-semibold text-[#5c6470]">
|
||||
{label}
|
||||
</Label>
|
||||
<Input
|
||||
className="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white px-3 text-xl text-[#2b3440]"
|
||||
placeholder=""
|
||||
value={value ?? ""}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
/** 분석 메타데이터를 공통 표형 폼의 읽기 전용 셀로 표시한다. */
|
||||
const renderInfoField = (
|
||||
label: string,
|
||||
value?: string | number | null,
|
||||
span: 1 | 2 = 1,
|
||||
) => (
|
||||
<StandardFormField label={label} columns={2} span={span}>
|
||||
<Input className="w-full" value={value ?? ""} readOnly />
|
||||
</StandardFormField>
|
||||
);
|
||||
|
||||
const renderResultPanel = () => (
|
||||
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-4">
|
||||
<h3 className="mb-4 text-[20px] font-extrabold tracking-[-0.02em] text-black">RDII 분석 결과</h3>
|
||||
<div className="mb-4 grid grid-cols-2 gap-4">
|
||||
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
|
||||
<div className="flex items-center gap-2 text-[16px] font-extrabold text-[#ef4444]">
|
||||
<span>상수사용 적용연월</span>
|
||||
</div>
|
||||
<div className="text-[20px] font-extrabold text-[#222]">
|
||||
{selectedAplcnYm ? moment(selectedAplcnYm, "YYYYMM").format("YYYY-MM") : "-"}
|
||||
<section className="card card-border !m-0 min-w-0 overflow-hidden !p-[1.2rem]">
|
||||
<StandardFormSection title="RDII 분석결과" columns={2}>
|
||||
<StandardFormField label="상수사용 적용연월" columns={2}>
|
||||
<div className="flex min-h-[3.2rem] items-center justify-end text-[1.35rem] font-semibold tabular-nums text-foreground">
|
||||
{selectedAplcnYm
|
||||
? moment(selectedAplcnYm, "YYYYMM").format("YYYY-MM")
|
||||
: "-"}
|
||||
</div>
|
||||
</StandardFormField>
|
||||
<StandardFormField label="강우유입수량 분석결과" columns={2}>
|
||||
<div className="grid min-h-[3.2rem] grid-cols-[minmax(0,1fr)_7rem_5rem] items-center gap-x-3 gap-y-1 text-[1.1rem] text-foreground">
|
||||
<span className="text-right text-muted-foreground">평균 RDII</span>
|
||||
<strong className="text-right text-[1.35rem] font-semibold tabular-nums">
|
||||
{rdiiQty}
|
||||
</strong>
|
||||
<span>m³/d</span>
|
||||
<span className="text-right text-muted-foreground">적용 강우량</span>
|
||||
<strong className="text-right text-[1.35rem] font-semibold tabular-nums">
|
||||
{rainTotal}
|
||||
</strong>
|
||||
<span>mm</span>
|
||||
<span className="text-right text-muted-foreground">
|
||||
강우량 대비 RDII
|
||||
</span>
|
||||
<strong className="text-right text-[1.35rem] font-semibold tabular-nums">
|
||||
{totalVolume}
|
||||
</strong>
|
||||
<span>m³/mm</span>
|
||||
</div>
|
||||
</StandardFormField>
|
||||
</StandardFormSection>
|
||||
|
||||
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
|
||||
<div className="shrink-0 whitespace-nowrap text-[14px] font-extrabold text-[#ef4444]">
|
||||
<span>강우유입수량 분석결과</span>
|
||||
</div>
|
||||
<div className="ml-4 flex min-w-0 basis-[60%] flex-col gap-[2px]">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="min-w-0 basis-[48%] text-right text-[15px] text-[#555]">평균RDII</div>
|
||||
<div className="min-w-0 basis-[32%] text-right text-[18px] font-extrabold text-[#111] tabular-nums">{rdiiQty}</div>
|
||||
<div className="min-w-0 basis-[20%] text-left text-[14px] text-[#111]">m³/d</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="min-w-0 basis-[48%] text-right text-[15px] text-[#555]">적용 강우량</div>
|
||||
<div className="min-w-0 basis-[32%] text-right text-[18px] font-extrabold text-[#111] tabular-nums">{rainTotal}</div>
|
||||
<div className="min-w-0 basis-[20%] text-left text-[14px] text-[#111]">mm</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="min-w-0 basis-[48%] text-right text-[15px] text-[#555]">강우량 대비 RDII</div>
|
||||
<div className="min-w-0 basis-[32%] text-right text-[18px] font-extrabold text-[#111] tabular-nums">{totalVolume}</div>
|
||||
<div className="min-w-0 basis-[20%] text-left text-[14px] text-[#111]">m³/mm</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[10px] bg-white px-2 pb-2 pt-3">
|
||||
<section className="mt-[1.2rem] overflow-hidden rounded-sm border border-border bg-background">
|
||||
<h4 className="border-b border-border bg-container px-4 py-3 text-[1.4rem] font-semibold text-label">
|
||||
RDII 그래프
|
||||
</h4>
|
||||
<div className="min-h-[30rem] p-3">
|
||||
{hasChartData ? (
|
||||
<MainChart
|
||||
chartConfig={chartConfig}
|
||||
chartForm={chartForm}
|
||||
chartData={chartData}
|
||||
chartTitle={"RDII 그래프"}
|
||||
chartTitle=""
|
||||
yFieldText2="강수량(mm)"
|
||||
yFieldText="유량(m³/h)"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-[464px] items-center justify-center text-[18px] text-[#2c2f33]">데이터가 없습니다.</div>
|
||||
<div
|
||||
className="flex min-h-[30rem] items-center justify-center text-[1.3rem] text-muted-foreground"
|
||||
role="status"
|
||||
>
|
||||
조회된 데이터가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
|
||||
const renderDefaultBody = () => (
|
||||
<div className="text-black">
|
||||
<div className="grid min-h-[68vh] grid-cols-[minmax(0,36%)_minmax(0,64%)] gap-5">
|
||||
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-5">
|
||||
<h3 className="mb-6 text-[20px] font-extrabold tracking-[-0.02em] text-black">강우유입수량 선택 조건</h3>
|
||||
|
||||
<div className="rounded-[6px] border border-[#d9dde3] bg-[#eff1f4] px-4 py-6">
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3">
|
||||
<Label className="w-auto whitespace-nowrap text-[14px] font-semibold text-[#5c6470]">상수사용월</Label>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-[1.2rem]">
|
||||
<StandardFormSection title="RDII 분석 조건" columns={2}>
|
||||
<StandardFormField label="상수사용월" columns={2}>
|
||||
<SelectBox
|
||||
className="w-full"
|
||||
selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl"
|
||||
selectClass="w-full"
|
||||
label=""
|
||||
selectArray={aplcnYms}
|
||||
value={selectedAplcnYm}
|
||||
onSelectValue={handleAplcnYmChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3">
|
||||
<Label className="w-auto whitespace-nowrap text-[14px] font-semibold text-[#5c6470]">분석정보:</Label>
|
||||
</StandardFormField>
|
||||
<StandardFormField label="분석정보" columns={2}>
|
||||
<SelectBox
|
||||
className="w-full"
|
||||
selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl"
|
||||
selectClass="w-full"
|
||||
label=""
|
||||
selectArray={rdiiAnlsInfos}
|
||||
value={selectedRainInfwtAnlsSn}
|
||||
onSelectValue={handleRdiiAnlsSnChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{renderInfoInput("오수전환율", rdiiAnls?.drtwSwtcRt ?? "")}
|
||||
{renderInfoInput("수용가-맨홀 매칭:", rdiiAnls?.atflMpngNm ?? "")}
|
||||
{renderInfoInput("관망도 파일명", rdiiAnls?.atflInpNm ?? "")}
|
||||
{renderInfoInput("강우 파일명", rdiiAnls?.atflRainNm ?? "")}
|
||||
{renderInfoInput("RTK계수 출처:", rdiiAnls?.srcNm ?? "")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 mb-[40px] flex justify-center">
|
||||
<Button size="lg" onClick={fetchFilteredData}>조회</Button>
|
||||
|
||||
<Button size="lg" color="gray" onClick={resetState}>초기화</Button>
|
||||
</div>
|
||||
</section>
|
||||
</StandardFormField>
|
||||
{renderInfoField("오수전환율", rdiiAnls?.drtwSwtcRt ?? "")}
|
||||
{renderInfoField("수용가-맨홀 매칭", rdiiAnls?.atflMpngNm ?? "")}
|
||||
{renderInfoField("관망도 파일명", rdiiAnls?.atflInpNm ?? "")}
|
||||
{renderInfoField("강우 파일명", rdiiAnls?.atflRainNm ?? "")}
|
||||
{renderInfoField("RTK계수 출처", rdiiAnls?.srcNm ?? "", 2)}
|
||||
</StandardFormSection>
|
||||
|
||||
{renderResultPanel()}
|
||||
</div>
|
||||
);
|
||||
|
||||
/** 조회·초기화 뒤에 창 닫기 성격의 취소 액션을 항상 마지막에 배치한다. */
|
||||
const renderFooterActions = () => (
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
{!isResultOnlyMode ? (
|
||||
<>
|
||||
<Button type="button" onClick={fetchFilteredData}>
|
||||
조회
|
||||
</Button>
|
||||
<Button type="button" color="white" onClick={resetState}>
|
||||
초기화
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button type="button" color="white" onClick={() => window.close()}>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isResultOnlyMode) {
|
||||
return <div className="min-h-dvh bg-white p-3">{renderResultPanel()}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-white p-4">
|
||||
<h1 className="mb-3 text-2xl font-semibold text-gray-900">RDII 분석</h1>
|
||||
{renderDefaultBody()}
|
||||
</div>
|
||||
<StandardPopupLayout
|
||||
title={isResultOnlyMode ? "RDII 분석결과" : "RDII 분석"}
|
||||
bodyClassName="overflow-x-hidden"
|
||||
footer={renderFooterActions()}
|
||||
showDefaultCancelAction={false}
|
||||
>
|
||||
{isResultOnlyMode ? renderResultPanel() : renderDefaultBody()}
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+354
-227
@@ -21,7 +21,7 @@
|
||||
* - 하수펌프장 자체입력정보 저장: POST /api/v1/fcltpump (sewerage-service)
|
||||
* - 하수맨홀 자체입력정보 저장: POST /api/v1/fcltmhl (sewerage-service)
|
||||
*/
|
||||
'use client'
|
||||
"use client";
|
||||
import { getData, postData } from "@/components/lib/api";
|
||||
import { VerticalTable } from "@/components/ui/verticalTable";
|
||||
import { useGisContext } from "@common/business_components/ui/map/Gis";
|
||||
@@ -32,22 +32,39 @@ import Fill from "ol/style/Fill.js";
|
||||
import Stroke from "ol/style/Stroke.js";
|
||||
import CircleStyle from "ol/style/Circle.js";
|
||||
import { getLayerContentComponent } from "../common";
|
||||
import { FldngWtlvMapCol, FldngWtlvMapCol2
|
||||
, SwtrmtFcltCol, SwtrmtFcltCol2
|
||||
, SwtrmtPrcsZoneCol, SwtrmtPrcsZoneCol2
|
||||
, untrtSwgRainCol1, untrtSwgRainCol2
|
||||
, MsSwagePplCol, MsSwagePplCol2
|
||||
, ExclnSewagePumpCol, ExclnSewagePumpCol2
|
||||
, DrtwSewagePumpCol, DrtwSewagePumpCol2
|
||||
, MsSwageMhlCol, MsSwageMhlCol2
|
||||
, stofcbrCol, stofcbrCol2
|
||||
, MS_SWAGE_MHL_CODES, DRTW_SEWAGE_PUMP_USE_CODES } from "../common/table";
|
||||
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
|
||||
import {
|
||||
FldngWtlvMapCol,
|
||||
FldngWtlvMapCol2,
|
||||
SwtrmtFcltCol,
|
||||
SwtrmtFcltCol2,
|
||||
SwtrmtPrcsZoneCol,
|
||||
SwtrmtPrcsZoneCol2,
|
||||
untrtSwgRainCol1,
|
||||
untrtSwgRainCol2,
|
||||
MsSwagePplCol,
|
||||
MsSwagePplCol2,
|
||||
ExclnSewagePumpCol,
|
||||
ExclnSewagePumpCol2,
|
||||
DrtwSewagePumpCol,
|
||||
DrtwSewagePumpCol2,
|
||||
MsSwageMhlCol,
|
||||
MsSwageMhlCol2,
|
||||
stofcbrCol,
|
||||
stofcbrCol2,
|
||||
MS_SWAGE_MHL_CODES,
|
||||
DRTW_SEWAGE_PUMP_USE_CODES,
|
||||
} from "../common/table";
|
||||
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
|
||||
import { codeMapper } from "../common/codeMapper";
|
||||
import LoadingOverlay from '@/components/ui/LoadingBar';
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { searchUpperPpls } from "./smh";
|
||||
import { getElapsedYearsDisplayText, isInvalidInstallYmd } from "../agedPipe/agedPipeUtils";
|
||||
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
|
||||
import {
|
||||
getElapsedYearsDisplayText,
|
||||
isInvalidInstallYmd,
|
||||
} from "../agedPipe/agedPipeUtils";
|
||||
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import useStore from "@/store/useStore";
|
||||
import ExmnSurveyResultSection from "../exmn/ExmnSurveyResultSection";
|
||||
|
||||
@@ -55,18 +72,18 @@ const linkedFacilityHighlightStyle = new Style({
|
||||
image: new CircleStyle({
|
||||
radius: 18,
|
||||
fill: new Fill({
|
||||
color: 'rgba(255, 235, 59, 0.35)',
|
||||
color: "rgba(255, 235, 59, 0.35)",
|
||||
}),
|
||||
stroke: new Stroke({
|
||||
color: 'rgba(255, 193, 7, 0.45)',
|
||||
color: "rgba(255, 193, 7, 0.45)",
|
||||
width: 2,
|
||||
}),
|
||||
}),
|
||||
fill: new Fill({
|
||||
color: 'rgba(255, 235, 59, 0.2)',
|
||||
color: "rgba(255, 235, 59, 0.2)",
|
||||
}),
|
||||
stroke: new Stroke({
|
||||
color: 'rgba(255, 193, 7, 0.35)',
|
||||
color: "rgba(255, 193, 7, 0.35)",
|
||||
width: 2.5,
|
||||
}),
|
||||
zIndex: 9999,
|
||||
@@ -75,7 +92,9 @@ const linkedFacilityHighlightStyle = new Style({
|
||||
const normalizeFldngWtlvMapDisplayId = (value: unknown) => {
|
||||
const normalized = String(value ?? "").trim();
|
||||
const prefix = "tb_ms_fldng_wtlv_map.";
|
||||
return normalized.startsWith(prefix) ? normalized.slice(prefix.length) : normalized;
|
||||
return normalized.startsWith(prefix)
|
||||
? normalized.slice(prefix.length)
|
||||
: normalized;
|
||||
};
|
||||
|
||||
const normalizeFldngWtlvMapApiFid = (value: unknown) => {
|
||||
@@ -131,13 +150,22 @@ const FeatureClickEventPopup = ({
|
||||
featureData: any;
|
||||
surveyMode?: boolean;
|
||||
}) => {
|
||||
const { gisMap } = useGisContext() as unknown as { checkedLayerIds: React.MutableRefObject<number[]>; treeData: { lyrSn: number }[] | null; gisCore: any; gisMap: any; };
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { confirm } = useConfirm();
|
||||
const { gisMap } = useGisContext() as unknown as {
|
||||
checkedLayerIds: React.MutableRefObject<number[]>;
|
||||
treeData: { lyrSn: number }[] | null;
|
||||
gisCore: any;
|
||||
gisMap: any;
|
||||
};
|
||||
const [layerNm, setLayerNm] = useState<string>(""); // 레이어 이름 상태
|
||||
const [layerSpecificNm, setLayerSpecificNm] = useState<string>(""); // 레이어 이름 상태
|
||||
const [tableData, setTableData] = useState<any>(null); // 피처 데이터
|
||||
const [headlist, setHeadlist] = useState<{ id: string; title: string; }[]>([]); // 컬럼리스트 변경상태
|
||||
const [headlist2, setHeadlist2] = useState<{ id: string; title: string; }[]>([]); // 컬럼리스트 변경상태
|
||||
const [buttonChk, setButtonChk] = useState(true)
|
||||
const [headlist, setHeadlist] = useState<{ id: string; title: string }[]>([]); // 컬럼리스트 변경상태
|
||||
const [headlist2, setHeadlist2] = useState<{ id: string; title: string }[]>(
|
||||
[],
|
||||
); // 컬럼리스트 변경상태
|
||||
const [buttonChk, setButtonChk] = useState(true);
|
||||
const [fetchData, setFetchData] = useState<any>(); // api 호출여부
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [isMainSsc, setIsMainSsc] = useState(false); // 중점우수토실 여부
|
||||
@@ -145,23 +173,25 @@ const FeatureClickEventPopup = ({
|
||||
// 🎯 InfilChart 및 RdiiChart 관련 상태 추가
|
||||
const [isClicked, setIsClicked] = useState(true); // 클릭 여부 상태
|
||||
const [isLoading, setIsLoading] = useState(false); // 로딩 상태
|
||||
const [chmbrXcnts, setChmbrXcnts] = useState<string>(''); // 로딩 상태
|
||||
const [chmbrYdnts, setChmbrYdnts] = useState<string>(''); // 로딩 상태
|
||||
const [chmbrXcnts, setChmbrXcnts] = useState<string>(""); // 로딩 상태
|
||||
const [chmbrYdnts, setChmbrYdnts] = useState<string>(""); // 로딩 상태
|
||||
const linkedFacilityFeatureRef = useRef<any>(null);
|
||||
const linkedFacilityOriginalStyleRef = useRef<any>(null);
|
||||
const [mngInstOptions, setMngInstOptions] = useState<MngInstOption[]>([]);
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
|
||||
// 🎯 관로관리번호 추출
|
||||
const pplMngNo = featureData?.values_?.fclt_mng_no || featureData?.fcltMngNo || "";
|
||||
const pplMngNo =
|
||||
featureData?.values_?.fclt_mng_no || featureData?.fcltMngNo || "";
|
||||
|
||||
const featureId = typeof featureData.getId === 'function'
|
||||
const featureId =
|
||||
typeof featureData.getId === "function"
|
||||
? featureData.getId()
|
||||
: featureData.fid;
|
||||
const selectedLayerTypeName = featureData?.values_
|
||||
? featureData?.id_?.split('.')?.[0]
|
||||
? featureData?.id_?.split(".")?.[0]
|
||||
: featureData?.lyrTypeNm;
|
||||
const isPipeFeature = selectedLayerTypeName === 'tb_ms_swage_ppl';
|
||||
const isPipeFeature = selectedLayerTypeName === "tb_ms_swage_ppl";
|
||||
|
||||
// 🎯 데이터 전처리 메모이제이션
|
||||
const processedData = useMemo(() => {
|
||||
@@ -169,7 +199,7 @@ const FeatureClickEventPopup = ({
|
||||
|
||||
return {
|
||||
...featureData.values_,
|
||||
discharge_pipe_spec: `직경: ${featureData.values_.pipe_diameter || '-'} / 높이: ${featureData.values_.pipe_hg || '-'} / 길이: ${featureData.values_.pipe_lt || '-'}`
|
||||
discharge_pipe_spec: `직경: ${featureData.values_.pipe_diameter || "-"} / 높이: ${featureData.values_.pipe_hg || "-"} / 길이: ${featureData.values_.pipe_lt || "-"}`,
|
||||
};
|
||||
}, [featureData?.values_]);
|
||||
|
||||
@@ -229,7 +259,11 @@ const FeatureClickEventPopup = ({
|
||||
if (!mngInstCd) return data;
|
||||
return {
|
||||
...data,
|
||||
mngInstNm: mngInstNameMap.get(mngInstCd) ?? data.mngInstNm ?? data.mng_inst_nm ?? mngInstCd,
|
||||
mngInstNm:
|
||||
mngInstNameMap.get(mngInstCd) ??
|
||||
data.mngInstNm ??
|
||||
data.mng_inst_nm ??
|
||||
mngInstCd,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -237,10 +271,16 @@ const FeatureClickEventPopup = ({
|
||||
if (!data || typeof data !== "object") return data;
|
||||
|
||||
const stdgCd = String(data.stdg_cd ?? data.stdgCd ?? "").trim();
|
||||
const ctpvCd = String(data.ctpv_cd ?? data.ctpvCd ?? stdgCd.slice(0, 2)).trim();
|
||||
const sggCd = String(data.sgg_cd ?? data.sggCd ?? stdgCd.slice(0, 5)).trim();
|
||||
const resolvedCtpvNm = ctpv.find((item) => String(item.val).trim() === ctpvCd)?.text ?? "";
|
||||
const resolvedSggNm = sgg.find((item) => String(item.val).trim() === sggCd)?.text ?? "";
|
||||
const ctpvCd = String(
|
||||
data.ctpv_cd ?? data.ctpvCd ?? stdgCd.slice(0, 2),
|
||||
).trim();
|
||||
const sggCd = String(
|
||||
data.sgg_cd ?? data.sggCd ?? stdgCd.slice(0, 5),
|
||||
).trim();
|
||||
const resolvedCtpvNm =
|
||||
ctpv.find((item) => String(item.val).trim() === ctpvCd)?.text ?? "";
|
||||
const resolvedSggNm =
|
||||
sgg.find((item) => String(item.val).trim() === sggCd)?.text ?? "";
|
||||
|
||||
return {
|
||||
...data,
|
||||
@@ -269,7 +309,9 @@ const FeatureClickEventPopup = ({
|
||||
const mergeDetailData = (baseData: any, detailData: any) => {
|
||||
if (!detailData || typeof detailData !== "object") return baseData;
|
||||
|
||||
const meaningfulDetailData = Object.entries(detailData).reduce<Record<string, unknown>>((acc, [key, value]) => {
|
||||
const meaningfulDetailData = Object.entries(detailData).reduce<
|
||||
Record<string, unknown>
|
||||
>((acc, [key, value]) => {
|
||||
if (hasDetailValue(value)) acc[key] = value;
|
||||
return acc;
|
||||
}, {});
|
||||
@@ -282,7 +324,9 @@ const FeatureClickEventPopup = ({
|
||||
|
||||
const clearLinkedFacilityHighlight = () => {
|
||||
if (linkedFacilityFeatureRef.current) {
|
||||
linkedFacilityFeatureRef.current.setStyle(linkedFacilityOriginalStyleRef.current ?? undefined);
|
||||
linkedFacilityFeatureRef.current.setStyle(
|
||||
linkedFacilityOriginalStyleRef.current ?? undefined,
|
||||
);
|
||||
linkedFacilityFeatureRef.current.changed?.();
|
||||
}
|
||||
|
||||
@@ -306,10 +350,15 @@ const FeatureClickEventPopup = ({
|
||||
const features = source.getFeatures();
|
||||
const targetFeature = features.find((feature: any) => {
|
||||
const featureId = feature?.getId?.();
|
||||
const isFacilityLayer = typeof featureId === 'string' && featureId.startsWith('tb_il_swage_prcs_fclt.');
|
||||
const featureFacilityCode = feature?.get?.('swtrmt_fclt_cd');
|
||||
const isFacilityLayer =
|
||||
typeof featureId === "string" &&
|
||||
featureId.startsWith("tb_il_swage_prcs_fclt.");
|
||||
const featureFacilityCode = feature?.get?.("swtrmt_fclt_cd");
|
||||
|
||||
return isFacilityLayer && String(featureFacilityCode ?? '').trim() === normalizedFacilityCode;
|
||||
return (
|
||||
isFacilityLayer &&
|
||||
String(featureFacilityCode ?? "").trim() === normalizedFacilityCode
|
||||
);
|
||||
});
|
||||
|
||||
if (targetFeature) {
|
||||
@@ -322,12 +371,13 @@ const FeatureClickEventPopup = ({
|
||||
targetFeature.setStyle((feature: any, resolution: number) => {
|
||||
let styles: any[] = [];
|
||||
|
||||
if (typeof originalFeatureStyle === 'function') {
|
||||
if (typeof originalFeatureStyle === "function") {
|
||||
const originalStyles = originalFeatureStyle(feature, resolution);
|
||||
if (Array.isArray(originalStyles)) styles.push(...originalStyles);
|
||||
else if (originalStyles) styles.push(originalStyles);
|
||||
} else if (originalFeatureStyle) {
|
||||
if (Array.isArray(originalFeatureStyle)) styles.push(...originalFeatureStyle);
|
||||
if (Array.isArray(originalFeatureStyle))
|
||||
styles.push(...originalFeatureStyle);
|
||||
else styles.push(originalFeatureStyle);
|
||||
} else if (layerStyleFn) {
|
||||
const layerStyles = layerStyleFn(feature, resolution);
|
||||
@@ -354,136 +404,149 @@ const FeatureClickEventPopup = ({
|
||||
// if (source && featureData && featureData.fid) {
|
||||
// featureLayer = layer; // 피처가 속한 레이어 찾기
|
||||
// }
|
||||
if (source && source.hasFeature && featureData
|
||||
&& typeof featureData.getId === 'function' && source.hasFeature(featureData)) {
|
||||
if (
|
||||
source &&
|
||||
source.hasFeature &&
|
||||
featureData &&
|
||||
typeof featureData.getId === "function" &&
|
||||
source.hasFeature(featureData)
|
||||
) {
|
||||
featureLayer = layer; // 피처가 속한 레이어 찾기
|
||||
return true; // 반복 종료
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (featureData['ol_uid']) {
|
||||
if (featureData["ol_uid"]) {
|
||||
// Gis 레이어 클릭 이벤트
|
||||
setIsClicked(true);
|
||||
const lyrTyNm = featureData.id_.split('.')[0];
|
||||
const lyrTyNm = featureData.id_.split(".")[0];
|
||||
|
||||
switch (lyrTyNm) {
|
||||
case 'tb_ms_swage_ppl': {
|
||||
result = '하수관로';
|
||||
case "tb_ms_swage_ppl": {
|
||||
result = "하수관로";
|
||||
setHeadlist(MsSwagePplCol);
|
||||
setLayerSpecificNm(featureData.values_.fclt_mng_no);
|
||||
break;
|
||||
}
|
||||
case 'tb_ms_swage_mhl': {
|
||||
result = '하수맨홀';
|
||||
case "tb_ms_swage_mhl": {
|
||||
result = "하수맨홀";
|
||||
setHeadlist(MsSwageMhlCol);
|
||||
setLayerSpecificNm(featureData.values_.fclt_mng_no);
|
||||
break;
|
||||
}
|
||||
case 'tb_ms_swage_pmstn': {
|
||||
const drtwPumpCode = DRTW_SEWAGE_PUMP_USE_CODES.find(code => code.text === '오수펌프장');
|
||||
if (drtwPumpCode && featureData.values_.usg_cd.trim() === drtwPumpCode.val) {
|
||||
case "tb_ms_swage_pmstn": {
|
||||
const drtwPumpCode = DRTW_SEWAGE_PUMP_USE_CODES.find(
|
||||
(code) => code.text === "오수펌프장",
|
||||
);
|
||||
if (
|
||||
drtwPumpCode &&
|
||||
featureData.values_.usg_cd.trim() === drtwPumpCode.val
|
||||
) {
|
||||
setIsDrtwSewagePump(true);
|
||||
setHeadlist(DrtwSewagePumpCol);
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
setIsDrtwSewagePump(false);
|
||||
setHeadlist(ExclnSewagePumpCol);
|
||||
}
|
||||
setLayerSpecificNm(featureData.values_.fclt_mng_no);
|
||||
result = '하수펌프장';
|
||||
result = "하수펌프장";
|
||||
break;
|
||||
}
|
||||
case 'tb_il_swage_prcs_fclt': {
|
||||
result = '하수처리시설';
|
||||
case "tb_il_swage_prcs_fclt": {
|
||||
result = "하수처리시설";
|
||||
setHeadlist(SwtrmtFcltCol);
|
||||
setLayerSpecificNm(featureData.values_.swtrmt_fclt_cd);
|
||||
break;
|
||||
}
|
||||
case 'tb_ms_swage_prcs_zone': {
|
||||
result = '하수처리구역';
|
||||
case "tb_ms_swage_prcs_zone": {
|
||||
result = "하수처리구역";
|
||||
setHeadlist(SwtrmtPrcsZoneCol);
|
||||
setLayerSpecificNm(featureData.values_.swtrmt_fclt_cd);
|
||||
break;
|
||||
}
|
||||
case 'tb_ms_fldng_wtlv_map': {
|
||||
result = '침수흔적도';
|
||||
case "tb_ms_fldng_wtlv_map": {
|
||||
result = "침수흔적도";
|
||||
setHeadlist(FldngWtlvMapCol);
|
||||
setLayerSpecificNm(normalizeFldngWtlvMapDisplayId(featureData.values_.fid ?? featureId));
|
||||
setLayerSpecificNm(
|
||||
normalizeFldngWtlvMapDisplayId(
|
||||
featureData.values_.fid ?? featureId,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'tb_ms_stofcbr': {
|
||||
result = '우수토실';
|
||||
case "tb_ms_stofcbr": {
|
||||
result = "우수토실";
|
||||
setHeadlist(stofcbrCol);
|
||||
setLayerSpecificNm(featureData.values_.fclt_mng_no);
|
||||
break;
|
||||
}
|
||||
case 'om_dt_untrt_swg_rain': {
|
||||
result = '중점우수토실';
|
||||
case "om_dt_untrt_swg_rain": {
|
||||
result = "중점우수토실";
|
||||
setHeadlist(untrtSwgRainCol1);
|
||||
setHeadlist2(untrtSwgRainCol2);
|
||||
setLayerSpecificNm(featureData.values_.manage_no);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
result = featureLayer ? featureLayer.get('lyrNm') : null;
|
||||
result = featureLayer ? featureLayer.get("lyrNm") : null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
// SearchModal 클릭 이벤트
|
||||
setIsClicked(false);
|
||||
const lyrTyNm = featureData.lyrTypeNm;
|
||||
|
||||
switch (lyrTyNm) {
|
||||
case 'tb_ms_swage_ppl': {
|
||||
result = '하수관로';
|
||||
setHeadlist(MsSwagePplCol2)
|
||||
case "tb_ms_swage_ppl": {
|
||||
result = "하수관로";
|
||||
setHeadlist(MsSwagePplCol2);
|
||||
setLayerSpecificNm(featureData.fcltMngNo ?? featureData.swtrmtFcltCd);
|
||||
break;
|
||||
}
|
||||
case 'tb_ms_swage_mhl': {
|
||||
result = '하수맨홀';
|
||||
setHeadlist(MsSwageMhlCol2)
|
||||
case "tb_ms_swage_mhl": {
|
||||
result = "하수맨홀";
|
||||
setHeadlist(MsSwageMhlCol2);
|
||||
setLayerSpecificNm(featureData.fcltMngNo);
|
||||
break;
|
||||
}
|
||||
case 'tb_ms_swage_pmstn': {
|
||||
const drtwPumpCode = DRTW_SEWAGE_PUMP_USE_CODES.find(code => code.text === '오수펌프장');
|
||||
case "tb_ms_swage_pmstn": {
|
||||
const drtwPumpCode = DRTW_SEWAGE_PUMP_USE_CODES.find(
|
||||
(code) => code.text === "오수펌프장",
|
||||
);
|
||||
if (drtwPumpCode && featureData.usgCd.trim() === drtwPumpCode.val) {
|
||||
setIsDrtwSewagePump(false);
|
||||
setHeadlist(ExclnSewagePumpCol2)
|
||||
}
|
||||
else {
|
||||
setHeadlist(ExclnSewagePumpCol2);
|
||||
} else {
|
||||
setIsDrtwSewagePump(true);
|
||||
setHeadlist(DrtwSewagePumpCol2)
|
||||
setHeadlist(DrtwSewagePumpCol2);
|
||||
}
|
||||
setLayerSpecificNm(featureData.fcltMngNo);
|
||||
result = '하수펌프장';
|
||||
result = "하수펌프장";
|
||||
break;
|
||||
}
|
||||
case 'tb_il_swage_prcs_fclt': {
|
||||
result = '하수처리시설';
|
||||
setHeadlist(SwtrmtFcltCol2)
|
||||
case "tb_il_swage_prcs_fclt": {
|
||||
result = "하수처리시설";
|
||||
setHeadlist(SwtrmtFcltCol2);
|
||||
setLayerSpecificNm(featureData.swtrmtFcltCd);
|
||||
break;
|
||||
}
|
||||
case 'tb_ms_swage_prcs_zone': {
|
||||
result = '하수처리구역';
|
||||
case "tb_ms_swage_prcs_zone": {
|
||||
result = "하수처리구역";
|
||||
setHeadlist(SwtrmtPrcsZoneCol2);
|
||||
setLayerSpecificNm(featureData.swtrmtFcltCd);
|
||||
break;
|
||||
}
|
||||
case 'tb_ms_fldng_wtlv_map': {
|
||||
result = '침수흔적도';
|
||||
case "tb_ms_fldng_wtlv_map": {
|
||||
result = "침수흔적도";
|
||||
setHeadlist(FldngWtlvMapCol2);
|
||||
setLayerSpecificNm(normalizeFldngWtlvMapDisplayId(featureData.fid));
|
||||
break;
|
||||
}
|
||||
case 'tb_ms_stofcbr': {
|
||||
result = '우수토실';
|
||||
setHeadlist(stofcbrCol2)
|
||||
case "tb_ms_stofcbr": {
|
||||
result = "우수토실";
|
||||
setHeadlist(stofcbrCol2);
|
||||
setLayerSpecificNm(featureData.fcltMngNo);
|
||||
break;
|
||||
}
|
||||
@@ -502,25 +565,29 @@ const FeatureClickEventPopup = ({
|
||||
setLayerNm(selectedLayerName); // 상태 업데이트
|
||||
|
||||
let data;
|
||||
if (featureData['ol_uid']) data = featureData.values_; // Gis 레이어 클릭 이벤트일 때
|
||||
if (featureData["ol_uid"])
|
||||
data = featureData.values_; // Gis 레이어 클릭 이벤트일 때
|
||||
else data = featureData; // SearchModal 클릭 이벤트일 때
|
||||
|
||||
// 하수맨홀인 경우 용도코드를 텍스트로 변환
|
||||
if (selectedLayerName === '하수맨홀' && data) {
|
||||
if (selectedLayerName === "하수맨홀" && data) {
|
||||
// mqlt_cd 값 변환 (재질코드)
|
||||
if (data.mqlt_cd) {
|
||||
const materialCodeItem = MS_SWAGE_MHL_CODES.find(
|
||||
code => code.val === data.mqlt_cd.trim()
|
||||
(code) => code.val === data.mqlt_cd.trim(),
|
||||
);
|
||||
if (materialCodeItem) data.mqlt_cd = `${materialCodeItem.text}`;
|
||||
}
|
||||
} else {
|
||||
// 중점우수토실인 경우 구분 값
|
||||
if (selectedLayerName === '중점우수토실' && data) {
|
||||
if (selectedLayerName === "중점우수토실" && data) {
|
||||
if (data.mesure_spot_se) {
|
||||
const mesureSpotSe = [{id:'0', text: '합류식'}, {id:'1', text: '분류식'}]
|
||||
const mesureSpotSe = [
|
||||
{ id: "0", text: "합류식" },
|
||||
{ id: "1", text: "분류식" },
|
||||
];
|
||||
const mesureSpotSeItem = mesureSpotSe.find(
|
||||
code => code.id === data.mesure_spot_se.trim()
|
||||
(code) => code.id === data.mesure_spot_se.trim(),
|
||||
);
|
||||
if (mesureSpotSeItem) data.mesure_spot_se = mesureSpotSeItem.text;
|
||||
}
|
||||
@@ -528,18 +595,33 @@ const FeatureClickEventPopup = ({
|
||||
}
|
||||
|
||||
// 🎯 기본 데이터 포맷팅
|
||||
const normalizedRegionData = selectedLayerName === '침수흔적도'
|
||||
const normalizedRegionData =
|
||||
selectedLayerName === "침수흔적도"
|
||||
? withResolvedRegionNames(data)
|
||||
: data;
|
||||
const formattedData = withMngInstName(formatDisplayData(normalizedRegionData));
|
||||
if (selectedLayerName === '침수흔적도') {
|
||||
const formattedData = withMngInstName(
|
||||
formatDisplayData(normalizedRegionData),
|
||||
);
|
||||
if (selectedLayerName === "침수흔적도") {
|
||||
const fldngBgngDtm = formatFldngWtlvDateTime(
|
||||
formattedData.bgngYmd ?? formattedData.bgng_ymd ?? data.bgngYmd ?? data.bgng_ymd,
|
||||
formattedData.fldngBgngHm ?? formattedData.fldng_bgng_hm ?? data.fldngBgngHm ?? data.fldng_bgng_hm,
|
||||
formattedData.bgngYmd ??
|
||||
formattedData.bgng_ymd ??
|
||||
data.bgngYmd ??
|
||||
data.bgng_ymd,
|
||||
formattedData.fldngBgngHm ??
|
||||
formattedData.fldng_bgng_hm ??
|
||||
data.fldngBgngHm ??
|
||||
data.fldng_bgng_hm,
|
||||
);
|
||||
const fldngEndDtm = formatFldngWtlvDateTime(
|
||||
formattedData.endYmd ?? formattedData.end_ymd ?? data.endYmd ?? data.end_ymd,
|
||||
formattedData.fldngEndHm ?? formattedData.fldng_end_hm ?? data.fldngEndHm ?? data.fldng_end_hm,
|
||||
formattedData.endYmd ??
|
||||
formattedData.end_ymd ??
|
||||
data.endYmd ??
|
||||
data.end_ymd,
|
||||
formattedData.fldngEndHm ??
|
||||
formattedData.fldng_end_hm ??
|
||||
data.fldngEndHm ??
|
||||
data.fldng_end_hm,
|
||||
);
|
||||
|
||||
formattedData.fldng_bgng_dtm = fldngBgngDtm;
|
||||
@@ -547,7 +629,7 @@ const FeatureClickEventPopup = ({
|
||||
formattedData.fldng_end_dtm = fldngEndDtm;
|
||||
formattedData.fldngEndDtm = fldngEndDtm;
|
||||
}
|
||||
if (selectedLayerName === '하수관로') {
|
||||
if (selectedLayerName === "하수관로") {
|
||||
const installYmd = data.instl_ymd ?? data.instlYmd;
|
||||
formattedData.elapsedYears = getElapsedYearsDisplayText(installYmd);
|
||||
if (isInvalidInstallYmd(installYmd)) {
|
||||
@@ -560,63 +642,71 @@ const FeatureClickEventPopup = ({
|
||||
setTableData(formattedData);
|
||||
|
||||
switch (selectedLayerName) {
|
||||
case '하수관로':
|
||||
setButtonChk(true)
|
||||
case "하수관로":
|
||||
setButtonChk(true);
|
||||
|
||||
// ✅ codeMapper 사용하여 API 결과도 변환
|
||||
{
|
||||
const initialPipeData = withMngInstName(codeMapper({
|
||||
const initialPipeData = withMngInstName(
|
||||
codeMapper({
|
||||
layerName: selectedLayerName,
|
||||
data: { ...formattedData }
|
||||
}));
|
||||
data: { ...formattedData },
|
||||
}),
|
||||
);
|
||||
setFetchData(initialPipeData);
|
||||
setTableData(initialPipeData);
|
||||
}
|
||||
break;
|
||||
case '하수맨홀':
|
||||
setButtonChk(true)
|
||||
case "하수맨홀":
|
||||
setButtonChk(true);
|
||||
|
||||
// ✅ codeMapper 사용하여 API 결과도 변환
|
||||
setTableData(codeMapper({
|
||||
setTableData(
|
||||
codeMapper({
|
||||
layerName: selectedLayerName,
|
||||
data: { ...formattedData }
|
||||
}));
|
||||
data: { ...formattedData },
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case '하수펌프장':
|
||||
setButtonChk(true)
|
||||
case "하수펌프장":
|
||||
setButtonChk(true);
|
||||
|
||||
setTableData(codeMapper({
|
||||
setTableData(
|
||||
codeMapper({
|
||||
layerName: selectedLayerName,
|
||||
data: { ...formattedData }
|
||||
}));
|
||||
data: { ...formattedData },
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case '하수처리시설':
|
||||
setButtonChk(false)
|
||||
case "하수처리시설":
|
||||
setButtonChk(false);
|
||||
break;
|
||||
case '하수처리구역':
|
||||
setButtonChk(false)
|
||||
case "하수처리구역":
|
||||
setButtonChk(false);
|
||||
break;
|
||||
case '침수흔적도':
|
||||
setButtonChk(false)
|
||||
case "침수흔적도":
|
||||
setButtonChk(false);
|
||||
break;
|
||||
case '우수토실':
|
||||
setButtonChk(true)
|
||||
setIsMainSsc(false)
|
||||
case "우수토실":
|
||||
setButtonChk(true);
|
||||
setIsMainSsc(false);
|
||||
break;
|
||||
case '중점우수토실':
|
||||
setButtonChk(false)
|
||||
setIsMainSsc(true)
|
||||
case "중점우수토실":
|
||||
setButtonChk(false);
|
||||
setIsMainSsc(true);
|
||||
// 🎯 processedData도 포맷팅하여 설정
|
||||
const processedFormattedData = formatDisplayData(processedData);
|
||||
|
||||
setFetchData([processedFormattedData])
|
||||
setFetchData([processedFormattedData]);
|
||||
setTableData(processedFormattedData);
|
||||
// setIsLoading(false); // 기본 렌더링 완료
|
||||
|
||||
setTableData(codeMapper({
|
||||
setTableData(
|
||||
codeMapper({
|
||||
layerName: selectedLayerName,
|
||||
data: { ...processedFormattedData }
|
||||
}));
|
||||
data: { ...processedFormattedData },
|
||||
}),
|
||||
);
|
||||
|
||||
setChmbrXcnts(data.chmbr_xcnts);
|
||||
setChmbrYdnts(data.chmbr_ydnts);
|
||||
@@ -627,14 +717,24 @@ const FeatureClickEventPopup = ({
|
||||
}
|
||||
|
||||
// 자체입력정보 API 엔드포인트 선택
|
||||
let apiEndpoint = '';
|
||||
const fcltMngNo = String(featureData?.values_?.fclt_mng_no || featureData?.fcltMngNo || "").trim();
|
||||
const mngInstCd = String(featureData?.values_?.mng_inst_cd || featureData?.mngInstCd || "").trim();
|
||||
const canRequestDetail = isValidDetailIdentifier(fcltMngNo) && isValidDetailIdentifier(mngInstCd);
|
||||
if (selectedLayerName === '하수관로') apiEndpoint = `v1/fcltppl/${fcltMngNo}/${mngInstCd}`;
|
||||
else if (selectedLayerName === '우수토실') apiEndpoint = `v1/fcltstofcbr/${fcltMngNo}/${mngInstCd}`;
|
||||
else if (selectedLayerName === '하수펌프장') apiEndpoint = `v1/fcltpump/${fcltMngNo}/${mngInstCd}`;
|
||||
else if (selectedLayerName === '하수맨홀') apiEndpoint = `v1/fcltmhl/${fcltMngNo}/${mngInstCd}`;
|
||||
let apiEndpoint = "";
|
||||
const fcltMngNo = String(
|
||||
featureData?.values_?.fclt_mng_no || featureData?.fcltMngNo || "",
|
||||
).trim();
|
||||
const mngInstCd = String(
|
||||
featureData?.values_?.mng_inst_cd || featureData?.mngInstCd || "",
|
||||
).trim();
|
||||
const canRequestDetail =
|
||||
isValidDetailIdentifier(fcltMngNo) &&
|
||||
isValidDetailIdentifier(mngInstCd);
|
||||
if (selectedLayerName === "하수관로")
|
||||
apiEndpoint = `v1/fcltppl/${fcltMngNo}/${mngInstCd}`;
|
||||
else if (selectedLayerName === "우수토실")
|
||||
apiEndpoint = `v1/fcltstofcbr/${fcltMngNo}/${mngInstCd}`;
|
||||
else if (selectedLayerName === "하수펌프장")
|
||||
apiEndpoint = `v1/fcltpump/${fcltMngNo}/${mngInstCd}`;
|
||||
else if (selectedLayerName === "하수맨홀")
|
||||
apiEndpoint = `v1/fcltmhl/${fcltMngNo}/${mngInstCd}`;
|
||||
|
||||
// API에서 상세 데이터 가져오기
|
||||
if (apiEndpoint && canRequestDetail) {
|
||||
@@ -643,10 +743,12 @@ const FeatureClickEventPopup = ({
|
||||
|
||||
if (result) {
|
||||
const mergedData = mergeDetailData(formattedData, result);
|
||||
const processedApiData = withMngInstName(codeMapper({
|
||||
const processedApiData = withMngInstName(
|
||||
codeMapper({
|
||||
layerName: selectedLayerName,
|
||||
data: mergedData
|
||||
}));
|
||||
data: mergedData,
|
||||
}),
|
||||
);
|
||||
|
||||
setFetchData(processedApiData);
|
||||
setTableData(processedApiData);
|
||||
@@ -657,13 +759,16 @@ const FeatureClickEventPopup = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedLayerName === '침수흔적도' && featureId) {
|
||||
if (selectedLayerName === "침수흔적도" && featureId) {
|
||||
try {
|
||||
const normalizedFloodMapFid = normalizeFldngWtlvMapApiFid(featureId);
|
||||
const normalizedFloodMapFid =
|
||||
normalizeFldngWtlvMapApiFid(featureId);
|
||||
if (!normalizedFloodMapFid) {
|
||||
throw new Error("침수흔적도 fid가 비어 있습니다.");
|
||||
}
|
||||
const rainAnalysis = await getData(`v1/fldng/hstry/rain-anls/${encodeURIComponent(normalizedFloodMapFid)}`);
|
||||
const rainAnalysis = await getData(
|
||||
`v1/fldng/hstry/rain-anls/${encodeURIComponent(normalizedFloodMapFid)}`,
|
||||
);
|
||||
if (rainAnalysis) {
|
||||
const mergedData = {
|
||||
...formattedData,
|
||||
@@ -677,7 +782,7 @@ const FeatureClickEventPopup = ({
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('데이터 로딩 중 오류:', error);
|
||||
console.error("데이터 로딩 중 오류:", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -686,11 +791,12 @@ const FeatureClickEventPopup = ({
|
||||
|
||||
useEffect(() => {
|
||||
const layerTypeNm = featureData?.values_
|
||||
? featureData?.id_?.split('.')?.[0]
|
||||
? featureData?.id_?.split(".")?.[0]
|
||||
: featureData?.lyrTypeNm;
|
||||
|
||||
if (layerTypeNm === 'tb_ms_swage_prcs_zone') {
|
||||
const facilityCode = featureData?.values_?.swtrmt_fclt_cd ?? featureData?.swtrmtFcltCd;
|
||||
if (layerTypeNm === "tb_ms_swage_prcs_zone") {
|
||||
const facilityCode =
|
||||
featureData?.values_?.swtrmt_fclt_cd ?? featureData?.swtrmtFcltCd;
|
||||
highlightLinkedFacility(facilityCode);
|
||||
return () => {
|
||||
clearLinkedFacilityHighlight();
|
||||
@@ -705,19 +811,19 @@ const FeatureClickEventPopup = ({
|
||||
}, [featureData, gisMap]);
|
||||
|
||||
// 저장 시 모달 띄우기
|
||||
const handleSaveClick = () => {
|
||||
const shouldSave = window.confirm('정보를 저장하시겠습니까?');
|
||||
const handleSaveClick = async () => {
|
||||
const shouldSave = await confirm("정보를 저장하시겠습니까?");
|
||||
if (!shouldSave) return;
|
||||
btnClickEvent();
|
||||
};
|
||||
|
||||
// 저장 버튼 이벤트
|
||||
const btnClickEvent = async () => {
|
||||
const bodyData: Record<string, any> = {}
|
||||
const bodyData: Record<string, any> = {};
|
||||
|
||||
if (buttonChk && contentRef.current) {
|
||||
const inputElements = contentRef.current.querySelectorAll('input');
|
||||
const selectElements = contentRef.current.querySelectorAll('select');
|
||||
const inputElements = contentRef.current.querySelectorAll("input");
|
||||
const selectElements = contentRef.current.querySelectorAll("select");
|
||||
|
||||
// 카멜케이스 변환 함수
|
||||
const toCamelCase = (str: string) =>
|
||||
@@ -736,25 +842,27 @@ const FeatureClickEventPopup = ({
|
||||
});
|
||||
|
||||
// 레이어명에 따라 API 엔드포인트 분기처리
|
||||
let apiEndpoint = '';
|
||||
let apiEndpoint = "";
|
||||
let result = null;
|
||||
|
||||
if (layerNm === '하수관로') apiEndpoint = 'v1/fcltppl';
|
||||
else if (layerNm === '우수토실') apiEndpoint = 'v1/fcltstofcbr';
|
||||
else if (layerNm === '하수펌프장') apiEndpoint = 'v1/fcltpump';
|
||||
else if (layerNm === '하수맨홀') apiEndpoint = 'v1/fcltmhl';
|
||||
if (layerNm === "하수관로") apiEndpoint = "v1/fcltppl";
|
||||
else if (layerNm === "우수토실") apiEndpoint = "v1/fcltstofcbr";
|
||||
else if (layerNm === "하수펌프장") apiEndpoint = "v1/fcltpump";
|
||||
else if (layerNm === "하수맨홀") apiEndpoint = "v1/fcltmhl";
|
||||
|
||||
bodyData['fcltMngNo'] = featureData.values_.fclt_mng_no.toString() || null;
|
||||
bodyData['mngInstCd'] = featureData.values_.mng_inst_cd.toString() || null;
|
||||
bodyData["fcltMngNo"] =
|
||||
featureData.values_.fclt_mng_no.toString() || null;
|
||||
bodyData["mngInstCd"] =
|
||||
featureData.values_.mng_inst_cd.toString() || null;
|
||||
// 빈 키 제거
|
||||
if (bodyData[""] !== undefined) delete bodyData[""];
|
||||
|
||||
// 🎯 우수토실일 경우 prrtymngYn 값 변환
|
||||
if (layerNm === '우수토실' && bodyData.prrtymngYn) {
|
||||
if (bodyData.prrtymngYn === '중점우수토실') {
|
||||
bodyData.prrtymngYn = 'Y';
|
||||
} else if (bodyData.prrtymngYn === '우수토실') {
|
||||
bodyData.prrtymngYn = 'N';
|
||||
if (layerNm === "우수토실" && bodyData.prrtymngYn) {
|
||||
if (bodyData.prrtymngYn === "중점우수토실") {
|
||||
bodyData.prrtymngYn = "Y";
|
||||
} else if (bodyData.prrtymngYn === "우수토실") {
|
||||
bodyData.prrtymngYn = "N";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -762,86 +870,93 @@ const FeatureClickEventPopup = ({
|
||||
result = await postData(apiEndpoint, bodyData);
|
||||
|
||||
if (result) {
|
||||
alert('저장되었습니다');
|
||||
alert("저장되었습니다");
|
||||
setFetchData(result.data || result);
|
||||
} else {
|
||||
alert('저장에 실패했습니다');
|
||||
console.error('API 응답 오류:', result);
|
||||
alert("저장에 실패했습니다");
|
||||
console.error("API 응답 오류:", result);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('저장 중 오류가 발생했습니다');
|
||||
console.error('API 요청 오류:', error);
|
||||
}
|
||||
alert("저장 중 오류가 발생했습니다");
|
||||
console.error("API 요청 오류:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 레이어명에 따라 상세페이지 렌더링
|
||||
const ContentComponent = layerNm ? getLayerContentComponent(layerNm) : null;
|
||||
|
||||
return (
|
||||
<div id="popup" className="absolute top-0 right-0 w-[350px] h-[calc(100vh-5rem)] bg-white border-r border-gray-300 flex flex-col overflow-hidden transition-all duration-300 z-[50]">
|
||||
<div
|
||||
id="popup"
|
||||
className="absolute right-0 top-0 z-[50] flex h-[calc(100vh-5rem)] w-[350px] flex-col overflow-hidden border-r border-border bg-background transition-all duration-300"
|
||||
>
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<div className="card-header bg-primary flex items-center justify-between relative p-4">
|
||||
<div className="flex items-center space-x-4 flex-1">
|
||||
<div className="card-header relative flex items-center justify-between bg-primary p-4">
|
||||
<div className="flex flex-1 items-center space-x-4">
|
||||
{surveyMode && (
|
||||
<h3 className="text-2xl font-medium text-white text-label whitespace-nowrap">
|
||||
<h3 className="whitespace-nowrap text-2xl font-medium text-primary-foreground">
|
||||
조사관리 <br />
|
||||
관로번호 : {
|
||||
featureData?.get?.("gisManagementNo")
|
||||
?? featureData?.values_?.gisManagementNo
|
||||
?? featureData?.gisManagementNo
|
||||
?? layerSpecificNm
|
||||
}
|
||||
관로번호 :{" "}
|
||||
{featureData?.get?.("gisManagementNo") ??
|
||||
featureData?.values_?.gisManagementNo ??
|
||||
featureData?.gisManagementNo ??
|
||||
layerSpecificNm}
|
||||
</h3>
|
||||
)}
|
||||
<h3 className={`${surveyMode ? "hidden" : ""} text-2xl font-medium text-white text-label whitespace-nowrap`}>{layerNm}
|
||||
{tableData?.usg_cd && (
|
||||
<>
|
||||
({tableData.usg_cd})
|
||||
</>
|
||||
)} 정보 <br />관리번호 : {layerSpecificNm}
|
||||
<h3
|
||||
className={`${surveyMode ? "hidden" : ""} whitespace-nowrap text-2xl font-medium text-primary-foreground`}
|
||||
>
|
||||
{layerNm}
|
||||
{tableData?.usg_cd && <>({tableData.usg_cd})</>} 정보 <br />
|
||||
관리번호 : {layerSpecificNm}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* 하수관로일 때만 Infil/RDII 버튼 표시 */}
|
||||
{layerNm === '하수관로' && (
|
||||
{layerNm === "하수관로" && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const popupWindow = openWindowPopup("infilChart", { pplMngNo });
|
||||
const popupWindow = openWindowPopup("infilChart", {
|
||||
pplMngNo,
|
||||
});
|
||||
if (!popupWindow) {
|
||||
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
|
||||
alert(
|
||||
"팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.",
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="py-1 text-xl text-blue-600 border border-blue-600 rounded hover:bg-gray-700"
|
||||
color="white"
|
||||
size="sm"
|
||||
color="primary"
|
||||
>
|
||||
침입수분석
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const popupWindow = openWindowPopup("rdiiChart", { pplMngNo });
|
||||
const popupWindow = openWindowPopup("rdiiChart", {
|
||||
pplMngNo,
|
||||
});
|
||||
if (!popupWindow) {
|
||||
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
|
||||
alert(
|
||||
"팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.",
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="py-1 text-xl text-gray-600 border border-gray-600 rounded hover:bg-gray-700 mr-4"
|
||||
color="white"
|
||||
size="sm"
|
||||
color="primary"
|
||||
>
|
||||
RDII분석
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{layerNm === '하수맨홀' && (
|
||||
{layerNm === "하수맨홀" && (
|
||||
<>
|
||||
<Button
|
||||
color="white"
|
||||
className="px-2 py-1 text-xl text-gray-600 border border-gray-600 rounded hover:bg-gray-700 mr-4"
|
||||
onClick={() => {
|
||||
setIsLoading(true);
|
||||
searchUpperPpls(gisMap, layerSpecificNm).finally(() => setIsLoading(false));
|
||||
searchUpperPpls(gisMap, layerSpecificNm, alert).finally(() =>
|
||||
setIsLoading(false),
|
||||
);
|
||||
}}
|
||||
>
|
||||
상류관로 보기
|
||||
@@ -851,20 +966,30 @@ const FeatureClickEventPopup = ({
|
||||
</div>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
className="flex items-center space-x-4 text-white hover:text-gray-300 focus:outline-none"
|
||||
className="text-primary-foreground/80 flex items-center space-x-4 hover:text-primary-foreground focus:outline-none"
|
||||
size="xs"
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-y-auto overflow-x-hidden flex-1" ref={contentRef}>
|
||||
<div
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden"
|
||||
ref={contentRef}
|
||||
>
|
||||
<VerticalTable
|
||||
headName={
|
||||
layerNm === '하수관로' && tableData?.elapsedYears
|
||||
? <>기본정보 <span className="ml-2 rounded-sm bg-red-50 px-2 py-0.5 text-[1.1rem] font-semibold text-red-700">경과년도 {tableData.elapsedYears}</span></>
|
||||
: '기본정보'
|
||||
layerNm === "하수관로" && tableData?.elapsedYears ? (
|
||||
<>
|
||||
기본정보{" "}
|
||||
<span className="ml-2 rounded-sm bg-red-50 px-2 py-0.5 text-[1.1rem] font-semibold text-red-700">
|
||||
경과년도 {tableData.elapsedYears}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
"기본정보"
|
||||
)
|
||||
}
|
||||
tableName1={layerNm === '중점우수토실' ? "측정지점" : undefined}
|
||||
tableName1={layerNm === "중점우수토실" ? "측정지점" : undefined}
|
||||
tableData={[tableData]}
|
||||
headlist={headlist}
|
||||
pageSize={20}
|
||||
@@ -874,15 +999,17 @@ const FeatureClickEventPopup = ({
|
||||
throw new Error("Function not implemented.");
|
||||
}}
|
||||
// 두 번째 테이블 조건부 props
|
||||
showSecondTable={layerNm === '중점우수토실' ? true : false}
|
||||
tableName2={layerNm === '중점우수토실' ? "시설현황" : undefined}
|
||||
tableData2={layerNm === '중점우수토실' && tableData ? [tableData] : []}
|
||||
headlist2={layerNm === '중점우수토실' ? headlist2 : undefined}
|
||||
showSecondTable={layerNm === "중점우수토실" ? true : false}
|
||||
tableName2={layerNm === "중점우수토실" ? "시설현황" : undefined}
|
||||
tableData2={
|
||||
layerNm === "중점우수토실" && tableData ? [tableData] : []
|
||||
}
|
||||
headlist2={layerNm === "중점우수토실" ? headlist2 : undefined}
|
||||
pageSize2={20}
|
||||
total2={0}
|
||||
isEditable2={buttonChk}
|
||||
/>
|
||||
{ContentComponent &&
|
||||
{ContentComponent && (
|
||||
<ContentComponent
|
||||
featureData={fetchData}
|
||||
isMainSsc={isMainSsc}
|
||||
@@ -893,14 +1020,14 @@ const FeatureClickEventPopup = ({
|
||||
gisObject={featureData?.values_ ?? featureData}
|
||||
rawFeatureData={featureData}
|
||||
surveyResultContent={
|
||||
surveyMode
|
||||
? <ExmnSurveyResultSection featureData={featureData} />
|
||||
: undefined
|
||||
surveyMode ? (
|
||||
<ExmnSurveyResultSection featureData={featureData} />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
export default FeatureClickEventPopup;
|
||||
|
||||
@@ -18,18 +18,22 @@ import GisApp from "@common/business_components/ui/map/lib/gis.core";
|
||||
import { getData } from '@/components/lib/api';
|
||||
|
||||
// 상류관로 보기 함수 (FeatureClickEventPopup에서 사용)
|
||||
export async function searchUpperPpls(gisMap: any, fcltMngNo: any) {
|
||||
export async function searchUpperPpls(
|
||||
gisMap: any,
|
||||
fcltMngNo: any,
|
||||
notify: (message: string) => void,
|
||||
) {
|
||||
let data: any = null;
|
||||
try {
|
||||
data = await getData(`v1/fcltmhl/upperPpls/${fcltMngNo}`);
|
||||
} catch (error) {
|
||||
console.error("JSON parsing error:", error);
|
||||
alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
|
||||
notify(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
|
||||
notify(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -59,7 +63,7 @@ export async function searchUpperPpls(gisMap: any, fcltMngNo: any) {
|
||||
if (foundFeature) select?.selectMultiFeature?.(foundFeature, true);
|
||||
});
|
||||
|
||||
if (!foundFeature) alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
|
||||
if (!foundFeature) notify(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
|
||||
|
||||
const geometry = foundFeature?.getGeometry();
|
||||
if (geometry) {
|
||||
|
||||
@@ -2,19 +2,22 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { DateMonthPicker } from "@common/business_components/ui/date_month_picker";
|
||||
import { UiTable } from "@common/business_components/ui/table";
|
||||
import type {
|
||||
UiHeadProps,
|
||||
UiTableSort,
|
||||
} from "@common/business_components/ui/table";
|
||||
import {
|
||||
StandardPopupLayout,
|
||||
type UiHeadProps,
|
||||
} from "@common/business_components";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import { getData } from "@/components/lib/api";
|
||||
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
|
||||
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
|
||||
import useStore from "@/store/useStore";
|
||||
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
|
||||
import {
|
||||
RDII_RESULT_ROW_SELECT_MESSAGE,
|
||||
@@ -24,8 +27,8 @@ import {
|
||||
|
||||
const RDII_RESULT_PAGE_SIZE = 10;
|
||||
|
||||
const RDII_RESULT_HEADLIST = [
|
||||
{ id: "rowNumber", title: "일련번호" },
|
||||
const RDII_RESULT_HEADLIST: UiHeadProps[] = [
|
||||
{ id: "rowNumber", title: "순번" },
|
||||
{ id: "ctpvNm", title: "시도" },
|
||||
{ id: "sggNm", title: "시군구" },
|
||||
{ id: "swtrmtFcltNm", title: "하수처리시설" },
|
||||
@@ -37,18 +40,7 @@ const RDII_RESULT_HEADLIST = [
|
||||
{ id: "regDt", title: "분석일자" },
|
||||
{ id: "rgtrNm", title: "분석자" },
|
||||
{ id: "scsYn", title: "분석성공여부" },
|
||||
] as const;
|
||||
// 화면 표시명과 분석 Entity 필드가 다른 열은 서버 정렬 키를 명시한다.
|
||||
const RDII_RESULT_SORT_KEYS: Record<string, string> = {
|
||||
ctpvNm: "ctpvCd",
|
||||
sggNm: "sggCd",
|
||||
};
|
||||
const RDII_RESULT_SORTABLE_HEADLIST: UiHeadProps[] = RDII_RESULT_HEADLIST.map((head) => ({
|
||||
...head,
|
||||
// 분석자명은 목록 조회 후 외부 사용자 서비스에서 보강되므로 전체 결과 정렬에서 제외한다.
|
||||
sortable: head.id !== "rgtrNm",
|
||||
sortKey: RDII_RESULT_SORT_KEYS[head.id] ?? head.id,
|
||||
}));
|
||||
];
|
||||
|
||||
type RdiiResultFilters = {
|
||||
ctpvCd: string;
|
||||
@@ -101,6 +93,7 @@ const postRowSelectMessage = (row: RdiiResultRow) => {
|
||||
};
|
||||
|
||||
export default function RdiiResultPopupPage() {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
const [filters, setFilters] = useState<RdiiResultFilters>(DEFAULT_FILTERS);
|
||||
const [selectedSwagePrcsFclt, setSelectedSwagePrcsFclt] = useState("0");
|
||||
@@ -108,9 +101,7 @@ export default function RdiiResultPopupPage() {
|
||||
const [rows, setRows] = useState<RdiiResultRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
|
||||
useEffect(() => {
|
||||
if (ctpv.length === 0) void fetchCtpv();
|
||||
@@ -118,23 +109,24 @@ export default function RdiiResultPopupPage() {
|
||||
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
|
||||
|
||||
const fetchRows = useCallback(
|
||||
async (page: number, nextFilters: RdiiResultFilters, nextSort?: UiTableSort) => {
|
||||
async (page: number, nextFilters: RdiiResultFilters) => {
|
||||
const queryParams = new URLSearchParams({
|
||||
page: String(page - 1),
|
||||
size: String(RDII_RESULT_PAGE_SIZE),
|
||||
scsYn: "Y",
|
||||
});
|
||||
const requestSort = nextSort?.key === "rowNumber"
|
||||
? { key: "rainInfwtAnlsSn", direction: nextSort.direction === "asc" ? "desc" : "asc" }
|
||||
: nextSort;
|
||||
if (requestSort) queryParams.append("sort", `${requestSort.key},${requestSort.direction}`);
|
||||
if (requestSort?.key !== "rainInfwtAnlsSn") queryParams.append("sort", "rainInfwtAnlsSn,desc");
|
||||
queryParams.append("sort", "rainInfwtAnlsSn,desc");
|
||||
|
||||
if (nextFilters.aplcnYmFrom) queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom);
|
||||
if (nextFilters.aplcnYmTo) queryParams.set("aplcnYmTo", nextFilters.aplcnYmTo);
|
||||
if (nextFilters.ctpvCd !== "0") queryParams.set("ctpvCd", nextFilters.ctpvCd);
|
||||
if (nextFilters.sggCd !== "0") queryParams.set("sggCd", nextFilters.sggCd);
|
||||
if (nextFilters.swtrmtFcltNm.trim()) queryParams.set("swtrmtFcltNm", nextFilters.swtrmtFcltNm.trim());
|
||||
if (nextFilters.aplcnYmFrom)
|
||||
queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom);
|
||||
if (nextFilters.aplcnYmTo)
|
||||
queryParams.set("aplcnYmTo", nextFilters.aplcnYmTo);
|
||||
if (nextFilters.ctpvCd !== "0")
|
||||
queryParams.set("ctpvCd", nextFilters.ctpvCd);
|
||||
if (nextFilters.sggCd !== "0")
|
||||
queryParams.set("sggCd", nextFilters.sggCd);
|
||||
if (nextFilters.swtrmtFcltNm.trim())
|
||||
queryParams.set("swtrmtFcltNm", nextFilters.swtrmtFcltNm.trim());
|
||||
|
||||
const response = await getData(`v1/rdiiAnls?${queryParams.toString()}`);
|
||||
const content = Array.isArray(response?.content) ? response.content : [];
|
||||
@@ -143,14 +135,17 @@ export default function RdiiResultPopupPage() {
|
||||
const nextRows = content
|
||||
.map((item: Record<string, unknown>, index: number) => {
|
||||
const formattedItem = formatDisplayData(item);
|
||||
const ctpvOption = ctpv.find((code) => code.val.trim() === String(item?.ctpvCd ?? "").trim());
|
||||
const ctpvOption = ctpv.find(
|
||||
(code) => code.val.trim() === String(item?.ctpvCd ?? "").trim(),
|
||||
);
|
||||
const sggOption = sgg.find((code) => code.val === item?.sggCd);
|
||||
|
||||
return {
|
||||
...(formattedItem as Record<string, unknown>),
|
||||
rowNumber: nextSort?.key === "rowNumber" && nextSort.direction === "desc"
|
||||
? totalElements - ((page - 1) * RDII_RESULT_PAGE_SIZE + index)
|
||||
: (page - 1) * RDII_RESULT_PAGE_SIZE + index + 1,
|
||||
rowNumber: Math.max(
|
||||
totalElements - ((page - 1) * RDII_RESULT_PAGE_SIZE + index),
|
||||
0,
|
||||
),
|
||||
ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"),
|
||||
sggNm: sggOption ? sggOption.text : String(item?.sggCd ?? "-"),
|
||||
scsYn: String(item?.scsYn ?? "N"),
|
||||
@@ -166,10 +161,10 @@ export default function RdiiResultPopupPage() {
|
||||
);
|
||||
|
||||
const loadRows = useCallback(
|
||||
async (page = 1, nextFilters = filters, nextSort = sort) => {
|
||||
async (page = 1, nextFilters = filters) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await fetchRows(page, nextFilters, nextSort);
|
||||
await fetchRows(page, nextFilters);
|
||||
} catch (error) {
|
||||
console.error("RDII 분석결과 목록 조회 중 오류:", error);
|
||||
alert("RDII 분석결과 목록을 불러오지 못했습니다.");
|
||||
@@ -179,7 +174,7 @@ export default function RdiiResultPopupPage() {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[fetchRows, filters, sort],
|
||||
[alert, fetchRows, filters],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -188,17 +183,16 @@ export default function RdiiResultPopupPage() {
|
||||
}, []);
|
||||
|
||||
const handleSearch = async (page = 1) => {
|
||||
setSelectedIndex(-1);
|
||||
setSelectedRow(null);
|
||||
await loadRows(page, filters);
|
||||
};
|
||||
|
||||
/** Applies server sorting while keeping the current search filters. */
|
||||
const handleSortChange = async (nextSort?: UiTableSort) => {
|
||||
setSort(nextSort);
|
||||
setSelectedIndex(-1);
|
||||
/** CS 검색 카드와 동일하게 조건과 선택 결과를 기본값으로 되돌린다. */
|
||||
const handleReset = async () => {
|
||||
setFilters(DEFAULT_FILTERS);
|
||||
setSelectedSwagePrcsFclt("0");
|
||||
setSelectedRow(null);
|
||||
await loadRows(1, filters, nextSort);
|
||||
await loadRows(1, DEFAULT_FILTERS);
|
||||
};
|
||||
|
||||
const handleRowClick = (index?: number) => {
|
||||
@@ -206,7 +200,6 @@ export default function RdiiResultPopupPage() {
|
||||
const row = rows[index];
|
||||
if (!row) return;
|
||||
|
||||
setSelectedIndex(index);
|
||||
setSelectedRow(row);
|
||||
postRowSelectMessage(row);
|
||||
};
|
||||
@@ -219,8 +212,12 @@ export default function RdiiResultPopupPage() {
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await getData(`v1/rdiiAnlsRslt/ppl/${selectedRow.rainInfwtAnlsSn}`);
|
||||
const excelRows = Array.isArray(response?.content) ? response.content : [];
|
||||
const response = await getData(
|
||||
`v1/rdiiAnlsRslt/ppl/${selectedRow.rainInfwtAnlsSn}`,
|
||||
);
|
||||
const excelRows = Array.isArray(response?.content)
|
||||
? response.content
|
||||
: [];
|
||||
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: excelRows as JSON[],
|
||||
@@ -228,7 +225,7 @@ export default function RdiiResultPopupPage() {
|
||||
sheetNameArray: ["RDII 분석결과"],
|
||||
headerRange: "A1:D1",
|
||||
headerMapping: {
|
||||
rainInfwtAnlsSn: "RDII분석\n일련번호",
|
||||
rainInfwtAnlsSn: "RDII분석\n분석번호",
|
||||
pplMngNo: "관로\n관리번호",
|
||||
anlsTm: "분석시각",
|
||||
predcOilq: "예측유량\n(m³/h)",
|
||||
@@ -243,17 +240,16 @@ export default function RdiiResultPopupPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="sn-popup-page">
|
||||
<StandardPopupLayout
|
||||
title={windowPopupDefinitions.rdiiResultPopup.title}
|
||||
bodyClassName="overflow-x-hidden"
|
||||
>
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<header className="sn-popup-header">
|
||||
<h1 className="sn-popup-title">RDII 분석결과</h1>
|
||||
</header>
|
||||
|
||||
<section className="sn-popup-section overflow-y-auto">
|
||||
<div className="rounded-md border border-border bg-secondary p-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.05rem] font-medium text-label">상수사용기간</span>
|
||||
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.3rem] font-medium text-label">
|
||||
상수사용기간
|
||||
</span>
|
||||
<DateMonthPicker
|
||||
field={{
|
||||
value: filters.aplcnYmFrom,
|
||||
@@ -264,10 +260,12 @@ export default function RdiiResultPopupPage() {
|
||||
}));
|
||||
},
|
||||
}}
|
||||
selectClass="w-[145px]"
|
||||
selectClass="w-[145px] !text-[1.3rem]"
|
||||
startYear={1900}
|
||||
/>
|
||||
<span className="text-[1.1rem] font-semibold text-muted-foreground">~</span>
|
||||
<span className="text-[1.1rem] font-semibold text-muted-foreground">
|
||||
~
|
||||
</span>
|
||||
<DateMonthPicker
|
||||
field={{
|
||||
value: filters.aplcnYmTo,
|
||||
@@ -278,13 +276,16 @@ export default function RdiiResultPopupPage() {
|
||||
}));
|
||||
},
|
||||
}}
|
||||
selectClass="w-[145px]"
|
||||
selectClass="w-[145px] !text-[1.3rem]"
|
||||
startYear={1900}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 overflow-x-auto">
|
||||
<div className="min-w-0 shrink-0">
|
||||
<CtpvSggPrcsFcltSelectBox
|
||||
compact
|
||||
className="!justify-start"
|
||||
selectValueClassName="!text-[1.3rem]"
|
||||
sido={filters.ctpvCd}
|
||||
sigungu={filters.sggCd}
|
||||
sggName={filters.sggName}
|
||||
@@ -326,58 +327,51 @@ export default function RdiiResultPopupPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div className="ml-auto flex shrink-0 items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
onClick={() => void handleSearch(1)}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="green"
|
||||
size="sm"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
onClick={() => void handleDownload()}
|
||||
color="white"
|
||||
onClick={() => void handleReset()}
|
||||
>
|
||||
다운로드
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopupSearchPanel>
|
||||
|
||||
<div className="mt-4">
|
||||
<UiTable<RdiiResultRow>
|
||||
headName={<>{`분석이력 총 ${total.toLocaleString()}건`}</>}
|
||||
tableData={rows}
|
||||
headlist={RDII_RESULT_SORTABLE_HEADLIST}
|
||||
pageSize={RDII_RESULT_PAGE_SIZE}
|
||||
<MapPopupDataTable
|
||||
rows={rows as Array<RdiiResultRow & Record<string, unknown>>}
|
||||
headlist={RDII_RESULT_HEADLIST}
|
||||
title="RDII 분석결과 목록"
|
||||
total={total}
|
||||
cellClick={(index) => handleRowClick(index)}
|
||||
cellClassName={(_, headId) =>
|
||||
headId === "anlsInfo"
|
||||
? "whitespace-normal break-all leading-[1.35]"
|
||||
: ""
|
||||
}
|
||||
page={currentPage}
|
||||
pageSize={RDII_RESULT_PAGE_SIZE}
|
||||
onPageChange={(page) => void handleSearch(page)}
|
||||
curPage={currentPage}
|
||||
index={selectedIndex}
|
||||
widthFixed={true}
|
||||
sort={sort}
|
||||
onSortChange={(nextSort) => void handleSortChange(nextSort)}
|
||||
onRowClick={(_, index) => handleRowClick(index)}
|
||||
rowId={(row) => String(row.rainInfwtAnlsSn)}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="125rem"
|
||||
actions={
|
||||
<Button type="button" onClick={() => void handleDownload()}>
|
||||
엑셀다운로드
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedRow ? (
|
||||
<div className="mt-3 rounded-md border border-border bg-secondary px-4 py-3 text-[1rem] text-label">
|
||||
<div className="rounded-sm border border-border bg-secondary px-4 py-3 text-[1rem] text-label">
|
||||
{"선택 분석: "}
|
||||
<span className="font-semibold text-foreground">{selectedRow.anlsInfo}</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
{selectedRow.anlsInfo}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import Style from "ol/style/Style.js";
|
||||
|
||||
import { getRawJson } from "@/components/lib/api";
|
||||
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
|
||||
import GisApp from "@common/business_components/ui/map/lib/gis.core";
|
||||
import { getSigunguCenter, moveMapToCenter, sidoCenter } from "@common/business_components/ui/map/searchBar/utils";
|
||||
@@ -138,6 +139,7 @@ export const useRdiiResultMap = ({
|
||||
setIsOpen,
|
||||
setIsSearchModalOpen,
|
||||
}: UseRdiiResultMapParams) => {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const activeResultRowRef = useRef<RdiiResultRow | null>(null);
|
||||
const pipeLayerRef = useRef<VectorLayer<any> | null>(null);
|
||||
const pipeSourceRef = useRef<VectorSource | null>(null);
|
||||
@@ -294,7 +296,7 @@ export const useRdiiResultMap = ({
|
||||
alert("RDII 분석결과를 지도에 연계하지 못했습니다.");
|
||||
}
|
||||
},
|
||||
[renderMapView, setFeatureData, setIsOpen, setIsSearchModalOpen],
|
||||
[alert, renderMapView, setFeatureData, setIsOpen, setIsSearchModalOpen],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -356,7 +358,7 @@ export const useRdiiResultMap = ({
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [activeMapView]);
|
||||
}, [activeMapView, alert]);
|
||||
|
||||
return {
|
||||
activeMapView,
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@common/components";
|
||||
import { UiTable } from "@common/business_components/ui/table";
|
||||
import type { UiHeadProps } from "@common/business_components/ui/table";
|
||||
import { Button, Input } from "@common/components";
|
||||
import {
|
||||
StandardPopupLayout,
|
||||
type UiHeadProps,
|
||||
} from "@common/business_components";
|
||||
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
import { getRawJson } from "@/components/lib/api";
|
||||
import {
|
||||
WINDOW_POPUP_MESSAGE_TYPE,
|
||||
@@ -21,7 +25,8 @@ import {
|
||||
} from "../popupComponent/swtrmtPrcsZoneAnalysisApi";
|
||||
|
||||
const REPAIR_HISTORY_PAGE_SIZE = 10;
|
||||
const MAP_LAYER_SETTING_PATH = "/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK";
|
||||
const MAP_LAYER_SETTING_PATH =
|
||||
"/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK";
|
||||
|
||||
type PipeUseOption = {
|
||||
val: string;
|
||||
@@ -36,11 +41,16 @@ type MapLayerSettingRow = {
|
||||
};
|
||||
|
||||
/** 관망도 트리의 하수관로 노드에서 용도코드·명칭·표시순서를 그대로 추출한다. */
|
||||
const resolvePipeUseOptions = (rows: MapLayerSettingRow[]): PipeUseOption[] => rows
|
||||
const resolvePipeUseOptions = (rows: MapLayerSettingRow[]): PipeUseOption[] =>
|
||||
rows
|
||||
.filter((row) => row.lyrTyNm === "tb_ms_swage_ppl")
|
||||
.sort((left, right) => Number(left.sortOrdr ?? 0) - Number(right.sortOrdr ?? 0))
|
||||
.sort(
|
||||
(left, right) => Number(left.sortOrdr ?? 0) - Number(right.sortOrdr ?? 0),
|
||||
)
|
||||
.flatMap((row) => {
|
||||
const code = String(row.flterSql ?? "").match(/\busg_cd\s*=\s*['\"]([^'\"]+)['\"]/i)?.[1]?.trim();
|
||||
const code = String(row.flterSql ?? "")
|
||||
.match(/\busg_cd\s*=\s*['\"]([^'\"]+)['\"]/i)?.[1]
|
||||
?.trim();
|
||||
const text = String(row.lyrNm ?? "").trim();
|
||||
return code && text ? [{ val: code, text }] : [];
|
||||
});
|
||||
@@ -58,16 +68,37 @@ type RepairHistoryTableRow = {
|
||||
burialDepth: string;
|
||||
};
|
||||
|
||||
/** 부모 업무화면과 동일한 공통 UiTable 헤더·열 정렬 규격이다. */
|
||||
/** 부모 업무화면과 동일한 공통 표 헤더·열 규격이다. */
|
||||
const REPAIR_HISTORY_HEADLIST: UiHeadProps[] = [
|
||||
{ id: "rowNo", title: "순번", align: "center", widthPercent: 7 },
|
||||
{ id: "fcltMngNo", title: "시설관리번호", align: "center", widthPercent: 17 },
|
||||
{ id: "useName", title: "용도", align: "center", widthPercent: 10 },
|
||||
{ id: "repairCount", title: "보수이력(건)", align: "right", widthPercent: 10 },
|
||||
{ id: "latestRepairYear", title: "최근보수년도", align: "center", widthPercent: 11 },
|
||||
{ id: "elapsedYearsSinceRepair", title: "보수후 경과년도", align: "right", widthPercent: 12 },
|
||||
{
|
||||
id: "repairCount",
|
||||
title: "보수이력(건)",
|
||||
align: "right",
|
||||
widthPercent: 10,
|
||||
},
|
||||
{
|
||||
id: "latestRepairYear",
|
||||
title: "최근보수년도",
|
||||
align: "center",
|
||||
widthPercent: 11,
|
||||
},
|
||||
{
|
||||
id: "elapsedYearsSinceRepair",
|
||||
title: "보수후 경과년도",
|
||||
align: "right",
|
||||
widthPercent: 12,
|
||||
},
|
||||
{ id: "materialName", title: "재질", align: "center", widthPercent: 13 },
|
||||
{ id: "diameter", title: "관경(mm)", align: "right", widthPercent: 9 },
|
||||
{ id: "installationYear", title: "설치년도", align: "center", widthPercent: 8 },
|
||||
{
|
||||
id: "installationYear",
|
||||
title: "설치년도",
|
||||
align: "center",
|
||||
widthPercent: 8,
|
||||
},
|
||||
{ id: "burialDepth", title: "매설심도(m)", align: "right", widthPercent: 10 },
|
||||
];
|
||||
|
||||
@@ -81,7 +112,9 @@ const buildEmptyFilters = (): RepairHistoryFilters => ({
|
||||
|
||||
/** Next searchParams의 단일 값을 안전하게 읽는다. */
|
||||
const firstSearchParam = (value: string | string[] | undefined) =>
|
||||
Array.isArray(value) ? String(value[0] ?? "").trim() : String(value ?? "").trim();
|
||||
Array.isArray(value)
|
||||
? String(value[0] ?? "").trim()
|
||||
: String(value ?? "").trim();
|
||||
|
||||
/** 팝업의 조회·선택·정리 이벤트를 동일 출처의 부모 지도에 전달한다. */
|
||||
const postRepairMessage = (
|
||||
@@ -122,23 +155,33 @@ const formatYear = (value: unknown) => {
|
||||
};
|
||||
|
||||
/** SFR-011-006 관로별 보수이력 조회·다운로드와 부모 지도 연계를 제공한다. */
|
||||
export default function RepairHistoryPopupPage({ searchParams }: WindowPopupContentProps) {
|
||||
const zoneId = useMemo(() => firstSearchParam(searchParams.zoneId), [searchParams.zoneId]);
|
||||
const zoneName = useMemo(() => firstSearchParam(searchParams.zoneName), [searchParams.zoneName]);
|
||||
export default function RepairHistoryPopupPage({
|
||||
searchParams,
|
||||
}: WindowPopupContentProps) {
|
||||
const zoneId = useMemo(
|
||||
() => firstSearchParam(searchParams.zoneId),
|
||||
[searchParams.zoneId],
|
||||
);
|
||||
const zoneName = useMemo(
|
||||
() => firstSearchParam(searchParams.zoneName),
|
||||
[searchParams.zoneName],
|
||||
);
|
||||
// 입력조건과 마지막 조회조건을 분리해 다운로드가 현재 표와 동일한 범위를 사용한다.
|
||||
const [filters, setFilters] = useState<RepairHistoryFilters>(buildEmptyFilters);
|
||||
const [appliedFilters, setAppliedFilters] = useState<RepairHistoryFilters>(buildEmptyFilters);
|
||||
const [filters, setFilters] =
|
||||
useState<RepairHistoryFilters>(buildEmptyFilters);
|
||||
const [appliedFilters, setAppliedFilters] =
|
||||
useState<RepairHistoryFilters>(buildEmptyFilters);
|
||||
// 전체 조회 결과는 부모 지도 강조와 팝업 내 10건 페이지 표가 함께 사용한다.
|
||||
const [rows, setRows] = useState<RepairHistoryRow[]>([]);
|
||||
// 용도 선택지는 별도 하드코딩하지 않고 관망도 트리 설정 API의 sortOrdr를 따른다.
|
||||
const [pipeUseOptions, setPipeUseOptions] = useState<PipeUseOption[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedFid, setSelectedFid] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
/** 조회 결과 전체를 부모 지도 레이어와 팝업 표에 동시에 반영한다. */
|
||||
const searchRepairHistories = useCallback(async (nextFilters: RepairHistoryFilters) => {
|
||||
const searchRepairHistories = useCallback(
|
||||
async (nextFilters: RepairHistoryFilters) => {
|
||||
if (!zoneId) {
|
||||
setErrorMessage("처리구역 식별값이 없습니다.");
|
||||
return;
|
||||
@@ -146,28 +189,42 @@ export default function RepairHistoryPopupPage({ searchParams }: WindowPopupCont
|
||||
|
||||
setIsLoading(true);
|
||||
setErrorMessage("");
|
||||
setSelectedFid("");
|
||||
try {
|
||||
const response = await fetchAllRepairHistoriesByZoneId(zoneId, nextFilters);
|
||||
const response = await fetchAllRepairHistoriesByZoneId(
|
||||
zoneId,
|
||||
nextFilters,
|
||||
);
|
||||
const nextRows = response.data ?? [];
|
||||
setRows(nextRows);
|
||||
setAppliedFilters({ ...nextFilters });
|
||||
setCurrentPage(1);
|
||||
postRepairMessage(WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS, {
|
||||
postRepairMessage(
|
||||
WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS,
|
||||
{
|
||||
zoneId,
|
||||
rows: nextRows,
|
||||
});
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
setRows([]);
|
||||
postRepairMessage(WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS, {
|
||||
postRepairMessage(
|
||||
WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS,
|
||||
{
|
||||
zoneId,
|
||||
rows: [],
|
||||
});
|
||||
setErrorMessage(error instanceof Error ? error.message : "보수이력을 불러오지 못했습니다.");
|
||||
},
|
||||
);
|
||||
setErrorMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "보수이력을 불러오지 못했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [zoneId]);
|
||||
},
|
||||
[zoneId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const initialFilters = buildEmptyFilters();
|
||||
@@ -181,8 +238,15 @@ export default function RepairHistoryPopupPage({ searchParams }: WindowPopupCont
|
||||
|
||||
const loadPipeUseOptions = async () => {
|
||||
try {
|
||||
const layerSettings = await getRawJson(MAP_LAYER_SETTING_PATH) as MapLayerSettingRow[];
|
||||
if (isMounted) setPipeUseOptions(resolvePipeUseOptions(Array.isArray(layerSettings) ? layerSettings : []));
|
||||
const layerSettings = (await getRawJson(
|
||||
MAP_LAYER_SETTING_PATH,
|
||||
)) as MapLayerSettingRow[];
|
||||
if (isMounted)
|
||||
setPipeUseOptions(
|
||||
resolvePipeUseOptions(
|
||||
Array.isArray(layerSettings) ? layerSettings : [],
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
if (isMounted) setPipeUseOptions([]);
|
||||
}
|
||||
@@ -196,7 +260,9 @@ export default function RepairHistoryPopupPage({ searchParams }: WindowPopupCont
|
||||
|
||||
useEffect(() => {
|
||||
const clearParentLayer = () => {
|
||||
postRepairMessage(WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_CLEAR, { zoneId });
|
||||
postRepairMessage(WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_CLEAR, {
|
||||
zoneId,
|
||||
});
|
||||
};
|
||||
window.addEventListener("beforeunload", clearParentLayer);
|
||||
return () => {
|
||||
@@ -211,10 +277,18 @@ export default function RepairHistoryPopupPage({ searchParams }: WindowPopupCont
|
||||
setIsLoading(true);
|
||||
setErrorMessage("");
|
||||
try {
|
||||
const { blob } = await downloadRepairHistoriesByZoneId(zoneId, appliedFilters, zoneName);
|
||||
const { blob } = await downloadRepairHistoriesByZoneId(
|
||||
zoneId,
|
||||
appliedFilters,
|
||||
zoneName,
|
||||
);
|
||||
saveBlob(blob, buildRepairHistoryFileName(zoneName));
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : "보수이력 Excel 다운로드에 실패했습니다.");
|
||||
setErrorMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "보수이력 Excel 다운로드에 실패했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -222,7 +296,6 @@ export default function RepairHistoryPopupPage({ searchParams }: WindowPopupCont
|
||||
|
||||
/** 행을 한 번 클릭하면 부모 지도에서 해당 관로를 선택하고 범위로 이동한다. */
|
||||
const handleRowClick = (row: RepairHistoryRow) => {
|
||||
setSelectedFid(row.fid);
|
||||
postRepairMessage(WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SELECT_PIPE, {
|
||||
zoneId,
|
||||
fid: row.fid,
|
||||
@@ -230,15 +303,17 @@ export default function RepairHistoryPopupPage({ searchParams }: WindowPopupCont
|
||||
};
|
||||
|
||||
const visibleRows = useMemo(
|
||||
() => rows.slice(
|
||||
() =>
|
||||
rows.slice(
|
||||
(currentPage - 1) * REPAIR_HISTORY_PAGE_SIZE,
|
||||
currentPage * REPAIR_HISTORY_PAGE_SIZE,
|
||||
),
|
||||
[currentPage, rows],
|
||||
);
|
||||
// 공통 UiTable에 전달할 표시값은 원본 API 행과 분리해 지도 연계 식별값을 보존한다.
|
||||
// 공통 표에 전달할 표시값은 원본 API 행과 분리해 지도 연계 식별값을 보존한다.
|
||||
const tableRows = useMemo<RepairHistoryTableRow[]>(
|
||||
() => visibleRows.map((row) => ({
|
||||
() =>
|
||||
visibleRows.map((row) => ({
|
||||
fid: row.fid,
|
||||
fcltMngNo: formatCellValue(row.fcltMngNo),
|
||||
useName: formatCellValue(row.useNm),
|
||||
@@ -252,66 +327,84 @@ export default function RepairHistoryPopupPage({ searchParams }: WindowPopupCont
|
||||
})),
|
||||
[visibleRows],
|
||||
);
|
||||
const selectedRowIndex = visibleRows.findIndex((row) => row.fid === selectedFid);
|
||||
|
||||
return (
|
||||
<main className="sn-popup-page w-full max-w-full overflow-x-hidden overflow-y-hidden">
|
||||
<StandardPopupLayout title="보수이력 목록">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<header className="sn-popup-header">
|
||||
<h1 className="sn-popup-title">보수이력</h1>
|
||||
</header>
|
||||
|
||||
<section className="flex min-h-0 flex-1 flex-col overflow-x-hidden px-4 pb-3 pt-3">
|
||||
<div className="sn-popup-filter-panel-compact flex w-full flex-wrap items-center gap-x-8 gap-y-3 px-4 py-3">
|
||||
<label className="flex items-center gap-2 whitespace-nowrap text-xl font-medium text-gray-600">
|
||||
<PopupSearchPanel contentClassName="flex w-full flex-wrap items-center gap-x-5 gap-y-3">
|
||||
<label className="flex items-center gap-2 whitespace-nowrap text-[1.3rem] text-label">
|
||||
<span>용도</span>
|
||||
<select
|
||||
className="h-[3.2rem] w-[150px] rounded-lg border border-input bg-white px-3 text-base font-normal text-slate-900"
|
||||
className="h-[3.2rem] w-[15rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
|
||||
value={filters.useCd}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, useCd: event.target.value }))}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
useCd: event.target.value,
|
||||
}))
|
||||
}
|
||||
data-testid="repair-use-filter"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{pipeUseOptions.map((code) => (
|
||||
<option key={code.val} value={code.val}>{code.text}</option>
|
||||
<option key={code.val} value={code.val}>
|
||||
{code.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 whitespace-nowrap text-xl font-medium text-gray-600">
|
||||
<label className="flex items-center gap-2 whitespace-nowrap text-[1.3rem] text-label">
|
||||
<span>재질</span>
|
||||
<select
|
||||
className="h-[3.2rem] w-[180px] rounded-lg border border-input bg-white px-3 text-base font-normal text-slate-900"
|
||||
className="h-[3.2rem] w-[18rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
|
||||
value={filters.materialCode}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, materialCode: event.target.value }))}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
materialCode: event.target.value,
|
||||
}))
|
||||
}
|
||||
data-testid="repair-material-filter"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{MS_SWAGE_PPL_CODES.map((code) => (
|
||||
<option key={code.val} value={code.val}>{code.text}</option>
|
||||
<option key={code.val} value={code.val}>
|
||||
{code.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-2 whitespace-nowrap text-xl font-medium text-gray-600">
|
||||
<div className="flex items-center gap-2 whitespace-nowrap text-[1.3rem] text-label">
|
||||
<span>관경(mm)</span>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
aria-label="최소 관경(mm)"
|
||||
className="h-[3.2rem] w-[100px] rounded-lg border border-input bg-white px-3 text-base font-normal text-slate-900"
|
||||
className="w-[10rem]"
|
||||
value={filters.diameterFrom}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, diameterFrom: event.target.value }))}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
diameterFrom: event.target.value,
|
||||
}))
|
||||
}
|
||||
data-testid="repair-diameter-from"
|
||||
/>
|
||||
<span aria-hidden="true">~</span>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
aria-label="최대 관경(mm)"
|
||||
className="h-[3.2rem] w-[100px] rounded-lg border border-input bg-white px-3 text-base font-normal text-slate-900"
|
||||
className="w-[10rem]"
|
||||
value={filters.diameterTo}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, diameterTo: event.target.value }))}
|
||||
onChange={(event) =>
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
diameterTo: event.target.value,
|
||||
}))
|
||||
}
|
||||
data-testid="repair-diameter-to"
|
||||
/>
|
||||
</div>
|
||||
@@ -319,9 +412,7 @@ export default function RepairHistoryPopupPage({ searchParams }: WindowPopupCont
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white"
|
||||
onClick={() => void searchRepairHistories({ ...filters })}
|
||||
disabled={isLoading}
|
||||
data-testid="repair-history-search"
|
||||
@@ -330,48 +421,64 @@ export default function RepairHistoryPopupPage({ searchParams }: WindowPopupCont
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="rounded-sm bg-blue-600 px-4 py-2 text-sm font-medium !text-white hover:bg-blue-700 disabled:!text-white"
|
||||
onClick={() => void handleDownload()}
|
||||
disabled={isLoading || rows.length === 0}
|
||||
data-testid="repair-history-download"
|
||||
color="white"
|
||||
onClick={() => {
|
||||
const resetFilters: RepairHistoryFilters = {
|
||||
useCd: "",
|
||||
materialCode: "",
|
||||
diameterFrom: "",
|
||||
diameterTo: "",
|
||||
};
|
||||
setFilters(resetFilters);
|
||||
void searchRepairHistories(resetFilters);
|
||||
}}
|
||||
>
|
||||
다운로드
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopupSearchPanel>
|
||||
|
||||
{errorMessage ? (
|
||||
<div role="alert" className="mb-2 mt-2 border border-destructive bg-secondary px-4 py-2 text-base text-destructive">
|
||||
<div
|
||||
role="alert"
|
||||
className="mb-2 mt-2 border border-destructive bg-secondary px-4 py-2 text-base text-destructive"
|
||||
>
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto pt-2" aria-busy={isLoading}>
|
||||
<UiTable<RepairHistoryTableRow>
|
||||
headName={<>{zoneName || "처리구역"} 총 {rows.length.toLocaleString("ko-KR")}건</>}
|
||||
tableData={isLoading ? [] : tableRows}
|
||||
<MapPopupDataTable
|
||||
rows={
|
||||
(isLoading ? [] : tableRows) as Array<
|
||||
RepairHistoryTableRow & Record<string, unknown>
|
||||
>
|
||||
}
|
||||
headlist={REPAIR_HISTORY_HEADLIST}
|
||||
pageSize={REPAIR_HISTORY_PAGE_SIZE}
|
||||
title={`${zoneName || "처리구역"} 보수이력 목록`}
|
||||
total={rows.length}
|
||||
cellClick={(index) => {
|
||||
if (index === undefined) return;
|
||||
page={currentPage}
|
||||
pageSize={REPAIR_HISTORY_PAGE_SIZE}
|
||||
onPageChange={setCurrentPage}
|
||||
onRowClick={(_, index) => {
|
||||
const row = visibleRows[index];
|
||||
if (row) handleRowClick(row);
|
||||
}}
|
||||
onPageChange={setCurrentPage}
|
||||
curPage={currentPage}
|
||||
index={selectedRowIndex}
|
||||
widthFixed={true}
|
||||
isHideCount={true}
|
||||
className="!m-0 overflow-x-hidden"
|
||||
cellClassName={(_, headId) => headId === "fcltMngNo" ? "break-all whitespace-normal" : "whitespace-normal"}
|
||||
rowId={(row, index) => String(row.fid ?? index)}
|
||||
actions={
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleDownload()}
|
||||
disabled={isLoading || rows.length === 0}
|
||||
data-testid="repair-history-download"
|
||||
>
|
||||
엑셀다운로드
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sn-popup-help-text">행을 클릭하면 부모 지도의 해당 관로로 이동합니다.</div>
|
||||
</section>
|
||||
</main>
|
||||
<div className="rounded-sm bg-secondary px-4 py-3 text-[1.2rem] text-muted-foreground">
|
||||
행을 클릭하면 부모 지도의 해당 관로로 이동합니다.
|
||||
</div>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@common/components";
|
||||
import { UiTable } from "@common/business_components/ui";
|
||||
import type { UiTableSort } from "@common/business_components/ui";
|
||||
import { StandardPopupLayout } from "@common/business_components";
|
||||
import { getRawJson } from "@/components/lib/api";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import useStore from "@/store/useStore";
|
||||
import {
|
||||
SBMRG_DNGR_MAP_DEFAULT_FILTERS,
|
||||
@@ -23,21 +25,26 @@ import {
|
||||
type SbmrgDngrMapTableRow,
|
||||
} from "./sbmrgDngrMapTypes";
|
||||
|
||||
const unwrapPayload = <T,>(response: any): T => (response?.data ?? response) as T;
|
||||
const unwrapPayload = <T,>(response: any): T =>
|
||||
(response?.data ?? response) as T;
|
||||
|
||||
/** 시설물 레이어의 중점관리지역 침수위험지도 피처를 조건별 목록으로 제공한다. */
|
||||
export default function SbmrgDngrMapPopupPage() {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
// 입력 중인 조건과 실제 조회에 적용된 조건을 분리해 페이지 이동 시 조건이 흔들리지 않게 한다.
|
||||
const [filters, setFilters] = useState<SbmrgDngrMapFilters>(SBMRG_DNGR_MAP_DEFAULT_FILTERS);
|
||||
const [appliedFilters, setAppliedFilters] = useState<SbmrgDngrMapFilters>(SBMRG_DNGR_MAP_DEFAULT_FILTERS);
|
||||
const [filters, setFilters] = useState<SbmrgDngrMapFilters>(
|
||||
SBMRG_DNGR_MAP_DEFAULT_FILTERS,
|
||||
);
|
||||
const [appliedFilters, setAppliedFilters] = useState<SbmrgDngrMapFilters>(
|
||||
SBMRG_DNGR_MAP_DEFAULT_FILTERS,
|
||||
);
|
||||
const [items, setItems] = useState<SbmrgDngrMapApiItem[]>([]);
|
||||
const [administrativeScopeItems, setAdministrativeScopeItems] = useState<SbmrgDngrMapApiItem[] | null>(null);
|
||||
const [administrativeScopeItems, setAdministrativeScopeItems] = useState<
|
||||
SbmrgDngrMapApiItem[] | null
|
||||
>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
const [appliedSort, setAppliedSort] = useState<UiTableSort>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const availableCtpvCodes = useMemo(
|
||||
@@ -64,24 +71,25 @@ export default function SbmrgDngrMapPopupPage() {
|
||||
() =>
|
||||
administrativeScopeItems === null
|
||||
? ctpv
|
||||
: ctpv.filter((item) => availableCtpvCodes.has(String(item.val).trim().slice(0, 2))),
|
||||
: ctpv.filter((item) =>
|
||||
availableCtpvCodes.has(String(item.val).trim().slice(0, 2)),
|
||||
),
|
||||
[administrativeScopeItems, availableCtpvCodes, ctpv],
|
||||
);
|
||||
|
||||
const sggOptions = useMemo(
|
||||
() => {
|
||||
const sggOptions = useMemo(() => {
|
||||
const scopedOptions =
|
||||
administrativeScopeItems === null
|
||||
? sgg
|
||||
: sgg.filter((item) => availableSggCodes.has(String(item.val).trim().slice(0, 5)));
|
||||
: sgg.filter((item) =>
|
||||
availableSggCodes.has(String(item.val).trim().slice(0, 5)),
|
||||
);
|
||||
return filters.ctpvCd === "0"
|
||||
? scopedOptions
|
||||
: scopedOptions.filter((item) =>
|
||||
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
|
||||
);
|
||||
},
|
||||
[administrativeScopeItems, availableSggCodes, filters.ctpvCd, sgg],
|
||||
);
|
||||
}, [administrativeScopeItems, availableSggCodes, filters.ctpvCd, sgg]);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
@@ -92,9 +100,8 @@ export default function SbmrgDngrMapPopupPage() {
|
||||
ctpv,
|
||||
sgg,
|
||||
total,
|
||||
sort,
|
||||
),
|
||||
[ctpv, currentPage, items, sgg, sort, total],
|
||||
[ctpv, currentPage, items, sgg, total],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -102,39 +109,42 @@ export default function SbmrgDngrMapPopupPage() {
|
||||
if (sgg.length === 0) void fetchSgg();
|
||||
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
|
||||
|
||||
/** 마지막 적용 조건과 정렬로 서버 페이지를 조회한다. */
|
||||
/** 마지막 적용 조건으로 서버 페이지를 조회한다. */
|
||||
const loadRows = useCallback(
|
||||
async (page: number, nextFilters: SbmrgDngrMapFilters, nextSort?: UiTableSort) => {
|
||||
async (page: number, nextFilters: SbmrgDngrMapFilters) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const queryString = buildSbmrgDngrMapListQueryString(
|
||||
nextFilters,
|
||||
page - 1,
|
||||
SBMRG_DNGR_MAP_PAGE_SIZE,
|
||||
nextSort,
|
||||
);
|
||||
const response = await getRawJson(
|
||||
`/sewerage-service/api/v1/fldng/sbmrg-dngr-map?${queryString}`,
|
||||
);
|
||||
const payload = unwrapPayload<{ items?: SbmrgDngrMapApiItem[]; total?: number }>(response);
|
||||
const payload = unwrapPayload<{
|
||||
items?: SbmrgDngrMapApiItem[];
|
||||
total?: number;
|
||||
}>(response);
|
||||
const items = Array.isArray(payload?.items) ? payload.items : [];
|
||||
setItems(items);
|
||||
setTotal(Number(payload?.total ?? 0));
|
||||
setCurrentPage(page);
|
||||
setAppliedFilters(nextFilters);
|
||||
setSort(nextSort);
|
||||
setAppliedSort(nextSort);
|
||||
setSelectedIndex(-1);
|
||||
} catch (error) {
|
||||
console.error("침수위험지도 목록 조회 중 오류:", error);
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
alert(error instanceof Error ? error.message : "침수위험지도 목록 조회 중 오류가 발생했습니다.");
|
||||
alert(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "침수위험지도 목록 조회 중 오류가 발생했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
[alert],
|
||||
);
|
||||
|
||||
/** 전체 50개 그룹에서 실제 존재하는 행정구역 코드만 검색 선택지로 제한한다. */
|
||||
@@ -148,8 +158,12 @@ export default function SbmrgDngrMapPopupPage() {
|
||||
const response = await getRawJson(
|
||||
`/sewerage-service/api/v1/fldng/sbmrg-dngr-map?${queryString}`,
|
||||
);
|
||||
const payload = unwrapPayload<{ items?: SbmrgDngrMapApiItem[] }>(response);
|
||||
setAdministrativeScopeItems(Array.isArray(payload?.items) ? payload.items : []);
|
||||
const payload = unwrapPayload<{ items?: SbmrgDngrMapApiItem[] }>(
|
||||
response,
|
||||
);
|
||||
setAdministrativeScopeItems(
|
||||
Array.isArray(payload?.items) ? payload.items : [],
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("침수위험지도 행정구역 범위 조회 중 오류:", error);
|
||||
setAdministrativeScopeItems(null);
|
||||
@@ -163,15 +177,12 @@ export default function SbmrgDngrMapPopupPage() {
|
||||
|
||||
/** 현재 입력 조건을 적용하고 첫 페이지부터 다시 조회한다. */
|
||||
const handleSearch = () => {
|
||||
void loadRows(1, filters, sort);
|
||||
void loadRows(1, filters);
|
||||
};
|
||||
|
||||
/** 선택한 지역·빈도 그룹의 전체 fid를 부모 지도에 전달해 전체 범위 이동·강조를 실행한다. */
|
||||
const handleRowClick = (index?: number) => {
|
||||
if (typeof index !== "number") return;
|
||||
const row = rows[index];
|
||||
if (!Array.isArray(row?.fids) || row.fids.length === 0) return;
|
||||
setSelectedIndex(index);
|
||||
const handleRowClick = (row: SbmrgDngrMapTableRow) => {
|
||||
if (!Array.isArray(row.fids) || row.fids.length === 0) return;
|
||||
const featureIds = row.fids.map((fid) =>
|
||||
String(fid).startsWith(`${SBMRG_DNGR_MAP_LAYER_TYPE}.`)
|
||||
? String(fid)
|
||||
@@ -180,88 +191,112 @@ export default function SbmrgDngrMapPopupPage() {
|
||||
window.opener?.postMessage(
|
||||
{
|
||||
type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE,
|
||||
payload: { layerNm: SBMRG_DNGR_MAP_LAYER_TYPE, fids: JSON.stringify(featureIds) },
|
||||
payload: {
|
||||
layerNm: SBMRG_DNGR_MAP_LAYER_TYPE,
|
||||
fids: JSON.stringify(featureIds),
|
||||
},
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex h-screen flex-col overflow-hidden bg-white">
|
||||
<StandardPopupLayout title="침수위험지도 목록">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<header className="flex min-h-[44px] items-center border-b border-[#dbe2e6] bg-primary px-5 py-3">
|
||||
<h1 className="text-[1.45rem] font-semibold text-white">침수위험지도 목록</h1>
|
||||
</header>
|
||||
|
||||
<section className="flex min-h-0 flex-1 flex-col p-4">
|
||||
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[220px_220px_220px_auto] md:items-end">
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div>
|
||||
<PopupSearchPanel contentClassName="flex flex-wrap items-end gap-x-5 gap-y-3">
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.3rem] text-label">
|
||||
시도
|
||||
</span>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="h-[3.2rem] w-[18rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
|
||||
value={filters.ctpvCd}
|
||||
onChange={(event) => setFilters({ ...filters, ctpvCd: event.target.value, sggCd: "0" })}
|
||||
onChange={(event) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
ctpvCd: event.target.value,
|
||||
sggCd: "0",
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="0">전체</option>
|
||||
{ctpvOptions.map((item) => (
|
||||
<option key={item.val} value={String(item.val)}>{item.text}</option>
|
||||
<option key={item.val} value={String(item.val)}>
|
||||
{item.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시군구</div>
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.3rem] text-label">
|
||||
시군구
|
||||
</span>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="h-[3.2rem] w-[18rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
|
||||
value={filters.sggCd}
|
||||
onChange={(event) => setFilters({ ...filters, sggCd: event.target.value })}
|
||||
onChange={(event) =>
|
||||
setFilters({ ...filters, sggCd: event.target.value })
|
||||
}
|
||||
>
|
||||
<option value="0">전체</option>
|
||||
{sggOptions.map((item) => (
|
||||
<option key={item.val} value={String(item.val)}>{item.text}</option>
|
||||
<option key={item.val} value={String(item.val)}>
|
||||
{item.text}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">홍수수위빈도값</div>
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-[1.3rem] text-label">
|
||||
홍수수위빈도값
|
||||
</span>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="h-[3.2rem] w-[16rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
|
||||
value={filters.floodWtlvFrqVl}
|
||||
onChange={(event) => setFilters({ ...filters, floodWtlvFrqVl: event.target.value })}
|
||||
onChange={(event) =>
|
||||
setFilters({ ...filters, floodWtlvFrqVl: event.target.value })
|
||||
}
|
||||
>
|
||||
<option value="0">전체</option>
|
||||
{SBMRG_DNGR_MAP_FREQUENCY_OPTIONS.map((value) => (
|
||||
<option key={value} value={String(value)}>{value}년</option>
|
||||
<option key={value} value={String(value)}>
|
||||
{value}년
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="flex justify-end md:pr-3">
|
||||
<Button type="button" size="sm" color="primary" className="h-10 px-5 !text-[1.1rem]" onClick={handleSearch}>
|
||||
<div className="ml-auto flex justify-end gap-2">
|
||||
<Button type="button" color="primary" onClick={handleSearch}>
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
onClick={() => {
|
||||
setFilters(SBMRG_DNGR_MAP_DEFAULT_FILTERS);
|
||||
void loadRows(1, SBMRG_DNGR_MAP_DEFAULT_FILTERS);
|
||||
}}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopupSearchPanel>
|
||||
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto pt-2">
|
||||
<UiTable<SbmrgDngrMapTableRow>
|
||||
headName={<>침수위험지도 조회 결과 총 {total.toLocaleString()}건</>}
|
||||
tableData={rows}
|
||||
<MapPopupDataTable
|
||||
rows={rows as Array<SbmrgDngrMapTableRow & Record<string, unknown>>}
|
||||
headlist={SBMRG_DNGR_MAP_HEADLIST}
|
||||
pageSize={SBMRG_DNGR_MAP_PAGE_SIZE}
|
||||
title="침수위험지도 목록"
|
||||
total={total}
|
||||
cellClick={(index) => handleRowClick(index)}
|
||||
onPageChange={(page) => void loadRows(page, appliedFilters, appliedSort)}
|
||||
curPage={currentPage}
|
||||
index={selectedIndex}
|
||||
widthFixed={true}
|
||||
isHideCount={true}
|
||||
sort={sort}
|
||||
onSortChange={(nextSort) => void loadRows(1, appliedFilters, nextSort)}
|
||||
page={currentPage}
|
||||
pageSize={SBMRG_DNGR_MAP_PAGE_SIZE}
|
||||
onPageChange={(page) => void loadRows(page, appliedFilters)}
|
||||
onRowClick={handleRowClick}
|
||||
rowId={(row, index) => String(row.emphsMngRgnMngNo ?? index)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { UiHeadProps, UiTableSort } from "@common/business_components/ui";
|
||||
import type { UiHeadProps } from "@common/business_components/ui";
|
||||
|
||||
export const SBMRG_DNGR_MAP_POPUP_KEY = "sbmrgDngrMapPopup";
|
||||
export const SBMRG_DNGR_MAP_LAYER_TYPE = "tb_ms_sbmrg_dngr_map";
|
||||
@@ -47,41 +47,40 @@ export const SBMRG_DNGR_MAP_DEFAULT_FILTERS: SbmrgDngrMapFilters = {
|
||||
export const SBMRG_DNGR_MAP_FREQUENCY_OPTIONS = [30, 50, 80, 100];
|
||||
|
||||
export const SBMRG_DNGR_MAP_HEADLIST: UiHeadProps[] = [
|
||||
{ id: "rowNo", title: "NO", widthPercent: 7, sortable: true, sortKey: "rowNo" },
|
||||
{ id: "ctpvNm", title: "시도", widthPercent: 11, sortable: true, sortKey: "ctpvCd" },
|
||||
{ id: "sggNm", title: "시군구", widthPercent: 12, sortable: true, sortKey: "sggCd" },
|
||||
{ id: "emphsMngRgnNm", title: "중점관리지역명", widthPercent: 23, sortable: true },
|
||||
{ id: "emphsMngRgnMngNo", title: "중점관리지역번호", widthPercent: 15, sortable: true },
|
||||
{ id: "rowNo", title: "순번", widthPercent: 7 },
|
||||
{ id: "ctpvNm", title: "시도", widthPercent: 11 },
|
||||
{ id: "sggNm", title: "시군구", widthPercent: 12 },
|
||||
{ id: "emphsMngRgnNm", title: "중점관리지역명", widthPercent: 23 },
|
||||
{ id: "emphsMngRgnMngNo", title: "중점관리지역번호", widthPercent: 15 },
|
||||
{
|
||||
id: "floodWtlvFrqVlDisplay",
|
||||
title: "홍수수위빈도값",
|
||||
widthPercent: 17,
|
||||
sortable: true,
|
||||
sortKey: "floodWtlvFrqVl",
|
||||
},
|
||||
{
|
||||
id: "elvtVlDisplay",
|
||||
title: "해발값(최소~최대)",
|
||||
widthPercent: 15,
|
||||
sortable: true,
|
||||
sortKey: "elvtVl",
|
||||
},
|
||||
];
|
||||
|
||||
/** API가 허용하는 검색 조건과 페이징·정렬 파라미터만 쿼리 문자열로 만든다. */
|
||||
/** API가 허용하는 검색 조건과 페이징 파라미터만 쿼리 문자열로 만든다. */
|
||||
export const buildSbmrgDngrMapListQueryString = (
|
||||
filters: SbmrgDngrMapFilters,
|
||||
page: number,
|
||||
size: number,
|
||||
sort?: UiTableSort,
|
||||
) => {
|
||||
const params = new URLSearchParams({ page: String(page), size: String(size) });
|
||||
if (filters.ctpvCd && filters.ctpvCd !== "0") params.set("ctpvCd", filters.ctpvCd.slice(0, 2));
|
||||
if (filters.sggCd && filters.sggCd !== "0") params.set("sggCd", filters.sggCd.slice(0, 5));
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
size: String(size),
|
||||
});
|
||||
if (filters.ctpvCd && filters.ctpvCd !== "0")
|
||||
params.set("ctpvCd", filters.ctpvCd.slice(0, 2));
|
||||
if (filters.sggCd && filters.sggCd !== "0")
|
||||
params.set("sggCd", filters.sggCd.slice(0, 5));
|
||||
if (filters.floodWtlvFrqVl && filters.floodWtlvFrqVl !== "0") {
|
||||
params.set("floodWtlvFrqVl", filters.floodWtlvFrqVl);
|
||||
}
|
||||
if (sort?.key && sort.direction) params.set("sort", `${sort.key},${sort.direction}`);
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
@@ -99,7 +98,10 @@ const toRangeDisplay = (minValue: unknown, maxValue: unknown) => {
|
||||
};
|
||||
|
||||
/** 집계 응답에서 쉼표로 묶일 수 있는 행정구역 코드를 정규화한다. */
|
||||
export const extractAdministrativeCodes = (value: unknown, codeLength: number) =>
|
||||
export const extractAdministrativeCodes = (
|
||||
value: unknown,
|
||||
codeLength: number,
|
||||
) =>
|
||||
String(value ?? "")
|
||||
.split(",")
|
||||
.map((code) => code.trim().slice(0, codeLength))
|
||||
@@ -114,7 +116,10 @@ const resolveAdministrativeNames = (
|
||||
const names = extractAdministrativeCodes(value, codeLength)
|
||||
.map((code) => {
|
||||
const matched = options.find(
|
||||
(option) => String(option.val ?? "").trim().slice(0, codeLength) === code,
|
||||
(option) =>
|
||||
String(option.val ?? "")
|
||||
.trim()
|
||||
.slice(0, codeLength) === code,
|
||||
);
|
||||
return String(matched?.text ?? "").trim();
|
||||
})
|
||||
@@ -123,7 +128,7 @@ const resolveAdministrativeNames = (
|
||||
return names.length > 0 ? [...new Set(names)].join(", ") : "-";
|
||||
};
|
||||
|
||||
/** 서버 페이지 번호를 화면 NO에 반영하고 숫자 표시값을 보정한다. */
|
||||
/** 서버 페이지 번호를 화면 순번에 반영하고 숫자 표시값을 보정한다. */
|
||||
export const toSbmrgDngrMapTableRows = (
|
||||
items: SbmrgDngrMapApiItem[],
|
||||
page: number,
|
||||
@@ -131,14 +136,10 @@ export const toSbmrgDngrMapTableRows = (
|
||||
ctpvOptions: AdministrativeOption[] = [],
|
||||
sggOptions: AdministrativeOption[] = [],
|
||||
total = items.length,
|
||||
sort?: UiTableSort,
|
||||
): SbmrgDngrMapTableRow[] =>
|
||||
items.map((item, index) => ({
|
||||
...item,
|
||||
rowNo:
|
||||
sort?.key === "rowNo" && sort.direction === "desc"
|
||||
? total - page * pageSize - index
|
||||
: page * pageSize + index + 1,
|
||||
rowNo: total - page * pageSize - index,
|
||||
ctpvNm: resolveAdministrativeNames(item.ctpvCd, ctpvOptions, 2),
|
||||
sggNm: resolveAdministrativeNames(item.sggCd, sggOptions, 5),
|
||||
floodWtlvFrqVlDisplay: toDisplay(item.floodWtlvFrqVl),
|
||||
@@ -146,8 +147,16 @@ export const toSbmrgDngrMapTableRows = (
|
||||
}));
|
||||
|
||||
const resolveNodeName = (node: any) =>
|
||||
String(node?.text ?? node?.data?.lyrNm ?? node?.data?.name ?? node?.lyrNm ?? node?.name ?? "").trim();
|
||||
String(
|
||||
node?.text ??
|
||||
node?.data?.lyrNm ??
|
||||
node?.data?.name ??
|
||||
node?.lyrNm ??
|
||||
node?.name ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
/** 빈도·침수심 자식 노드가 아니라 중점관리지역 위험지도 루트만 목록보기 대상으로 판별한다. */
|
||||
export const isSbmrgDngrMapRootNode = (node: any) =>
|
||||
resolveNodeName(node).replace(/\s/g, "") === SBMRG_DNGR_MAP_LAYER_NAME.replace(/\s/g, "");
|
||||
resolveNodeName(node).replace(/\s/g, "") ===
|
||||
SBMRG_DNGR_MAP_LAYER_NAME.replace(/\s/g, "");
|
||||
|
||||
@@ -74,7 +74,9 @@ export default function SinkholeDetailPanel({
|
||||
onClose,
|
||||
onSelectPipe,
|
||||
}: Props) {
|
||||
const [pipeUseCodeMap, setPipeUseCodeMap] = useState<Map<string, string>>(new Map());
|
||||
const [pipeUseCodeMap, setPipeUseCodeMap] = useState<Map<string, string>>(
|
||||
new Map(),
|
||||
);
|
||||
const canShowNearby = String(detail.coordStatus ?? "").trim() === "SUCCESS";
|
||||
|
||||
useEffect(() => {
|
||||
@@ -83,7 +85,9 @@ export default function SinkholeDetailPanel({
|
||||
loadPipeUseCodes()
|
||||
.then((codes) => {
|
||||
if (cancelled) return;
|
||||
setPipeUseCodeMap(new Map(codes.map((item) => [item.lwpoCd, item.lwpoCdNm])));
|
||||
setPipeUseCodeMap(
|
||||
new Map(codes.map((item) => [item.lwpoCd, item.lwpoCdNm])),
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to load OGC011 pipe use codes:", error);
|
||||
@@ -148,11 +152,11 @@ export default function SinkholeDetailPanel({
|
||||
return (
|
||||
<div
|
||||
id="popup"
|
||||
className="absolute top-0 right-0 z-[50] flex h-[calc(100vh-5rem)] w-[350px] flex-col overflow-hidden border-r border-gray-300 bg-white transition-all duration-300"
|
||||
className="absolute right-0 top-0 z-[50] flex h-[calc(100vh-5rem)] w-[350px] flex-col overflow-hidden border-r border-border bg-background transition-all duration-300"
|
||||
>
|
||||
<div className="card-header relative flex items-center justify-between bg-primary p-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-2xl font-medium text-white">
|
||||
<h3 className="text-2xl font-medium text-primary-foreground">
|
||||
지반침하정보
|
||||
<br />
|
||||
사고번호 : {display(detail.acdntNo)}
|
||||
@@ -160,7 +164,7 @@ export default function SinkholeDetailPanel({
|
||||
</div>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
className="flex items-center space-x-4 text-white hover:text-gray-300 focus:outline-none"
|
||||
className="text-primary-foreground/80 flex items-center space-x-4 hover:text-primary-foreground focus:outline-none"
|
||||
size="xs"
|
||||
>
|
||||
✕
|
||||
@@ -186,15 +190,17 @@ export default function SinkholeDetailPanel({
|
||||
침하발생 반경내 관로정보
|
||||
</h3>
|
||||
{canShowNearby ? (
|
||||
<div className="mx-3 mt-2 whitespace-nowrap text-left text-[0.95rem] leading-5 text-slate-500">
|
||||
<div className="mx-3 mt-2 whitespace-nowrap text-left text-[0.95rem] leading-5 text-muted-foreground">
|
||||
<span>반경 {radiusMeters}m · </span>
|
||||
<span className="font-bold text-[#1d4ed8]">{nearbyPipes.length.toLocaleString()}건</span>
|
||||
<span className="font-bold text-primary">
|
||||
{nearbyPipes.length.toLocaleString()}건
|
||||
</span>
|
||||
<span>, 평균경과 </span>
|
||||
<span className="font-bold text-[#1d4ed8]">
|
||||
<span className="font-bold text-primary">
|
||||
{displayElapsedYears(nearbyPipeElapsedSummary.average)}
|
||||
</span>
|
||||
<span>, 최장경과 </span>
|
||||
<span className="font-bold text-[#1d4ed8]">
|
||||
<span className="font-bold text-primary">
|
||||
{displayElapsedYears(nearbyPipeElapsedSummary.oldest)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -210,7 +216,7 @@ export default function SinkholeDetailPanel({
|
||||
) : null}
|
||||
</div>
|
||||
) : nearbyPipes.length === 0 ? (
|
||||
<div className="card !m-[1.2rem] !mt-0 rounded-sm border border-slate-200 bg-slate-50 px-3 py-3 text-[1.05rem] text-slate-600">
|
||||
<div className="card !m-[1.2rem] !mt-0 rounded-sm border border-border bg-secondary px-3 py-3 text-[1.05rem] text-label">
|
||||
반경 내 하수관로 정보가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
@@ -225,19 +231,19 @@ export default function SinkholeDetailPanel({
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border border-gray-300 bg-blue-100 px-1 py-2 text-center text-[0.95rem] font-bold text-slate-900 whitespace-nowrap">
|
||||
<th className="whitespace-nowrap border border-border bg-secondary px-1 py-2 text-center text-[0.95rem] font-bold text-label">
|
||||
NO
|
||||
</th>
|
||||
<th className="border border-gray-300 bg-blue-100 px-1 py-2 text-center text-[0.95rem] font-bold text-slate-900 whitespace-nowrap">
|
||||
<th className="whitespace-nowrap border border-border bg-secondary px-1 py-2 text-center text-[0.95rem] font-bold text-label">
|
||||
관로 ID
|
||||
</th>
|
||||
<th className="border border-gray-300 bg-blue-100 px-1 py-2 text-center text-[0.95rem] font-bold text-slate-900 whitespace-nowrap">
|
||||
<th className="whitespace-nowrap border border-border bg-secondary px-1 py-2 text-center text-[0.95rem] font-bold text-label">
|
||||
용도
|
||||
</th>
|
||||
<th className="border border-gray-300 bg-blue-100 px-1 py-2 text-center text-[0.95rem] font-bold text-slate-900 whitespace-nowrap">
|
||||
<th className="whitespace-nowrap border border-border bg-secondary px-1 py-2 text-center text-[0.95rem] font-bold text-label">
|
||||
경과년도
|
||||
</th>
|
||||
<th className="border border-gray-300 bg-blue-100 px-1 py-2 text-center text-[0.95rem] font-bold text-slate-900 whitespace-nowrap">
|
||||
<th className="whitespace-nowrap border border-border bg-secondary px-1 py-2 text-center text-[0.95rem] font-bold text-label">
|
||||
거리(m)
|
||||
</th>
|
||||
</tr>
|
||||
@@ -246,22 +252,22 @@ export default function SinkholeDetailPanel({
|
||||
{nearbyPipes.map((pipe, index) => (
|
||||
<tr
|
||||
key={`${pipe.fid}-${index}`}
|
||||
className="cursor-pointer hover:bg-blue-50"
|
||||
className="cursor-pointer hover:bg-accent"
|
||||
onClick={() => onSelectPipe(pipe)}
|
||||
>
|
||||
<td className="border border-gray-300 px-1 py-2 text-center text-[0.95rem] text-slate-900">
|
||||
<td className="border border-border px-1 py-2 text-center text-[0.95rem] text-foreground">
|
||||
{index + 1}
|
||||
</td>
|
||||
<td className="border border-gray-300 px-1 py-2 text-left text-[0.95rem] font-semibold text-[#1d4ed8] underline underline-offset-2">
|
||||
<td className="border border-border px-1 py-2 text-left text-[0.95rem] font-semibold text-primary underline underline-offset-2">
|
||||
{display(pipe.pipeId)}
|
||||
</td>
|
||||
<td className="border border-gray-300 px-1 py-2 text-center text-[0.95rem] text-slate-900 whitespace-nowrap">
|
||||
<td className="whitespace-nowrap border border-border px-1 py-2 text-center text-[0.95rem] text-foreground">
|
||||
{getPipeUseName(pipe)}
|
||||
</td>
|
||||
<td className="border border-gray-300 px-1 py-2 text-right text-[0.95rem] text-slate-900">
|
||||
<td className="border border-border px-1 py-2 text-right text-[0.95rem] text-foreground">
|
||||
{displayElapsedYears(pipe.elapsedYears)}
|
||||
</td>
|
||||
<td className="border border-gray-300 px-1 py-2 text-right text-[0.95rem] text-slate-900">
|
||||
<td className="border border-border px-1 py-2 text-right text-[0.95rem] text-foreground">
|
||||
{displayNumber(pipe.distanceMeters)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { SelectBox } from "@common/business_components/ui";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import {
|
||||
normalizeSinkholeYearRange,
|
||||
SINKHOLE_MIN_SEARCH_YEAR,
|
||||
@@ -31,6 +32,7 @@ export default function SinkholeLayerSettingPanel({
|
||||
isAdmin = false,
|
||||
compact = false,
|
||||
}: Props) {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [radiusText, setRadiusText] = useState(String(radiusMeters));
|
||||
const [yearRangeDraft, setYearRangeDraft] = useState<SinkholeYearRange>(() =>
|
||||
@@ -70,45 +72,67 @@ export default function SinkholeLayerSettingPanel({
|
||||
|
||||
try {
|
||||
await onApplyYearRange(normalized);
|
||||
alert(`시작년도 ${normalized.startYear} ~ 종료년도 ${normalized.endYear} 반영되었습니다.`);
|
||||
alert(
|
||||
`시작년도 ${normalized.startYear} ~ 종료년도 ${normalized.endYear} 반영되었습니다.`,
|
||||
);
|
||||
} catch {
|
||||
alert("반영 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const updateYearRangeDraft = (key: keyof SinkholeYearRange, value: string) => {
|
||||
const updateYearRangeDraft = (
|
||||
key: keyof SinkholeYearRange,
|
||||
value: string,
|
||||
) => {
|
||||
setYearRangeDraft((current) => ({
|
||||
...current,
|
||||
[key]: String(value).replace(/[^0-9]/g, "").slice(0, 4),
|
||||
[key]: String(value)
|
||||
.replace(/[^0-9]/g, "")
|
||||
.slice(0, 4),
|
||||
}));
|
||||
};
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearSelectClass = "h-[22px] w-[62px] rounded-none border border-[#ccd6df] bg-white px-[4px] text-center text-[1rem] leading-none text-[#222]";
|
||||
const yearSelectClass =
|
||||
"h-[22px] w-[62px] rounded-none border border-input bg-background px-[4px] text-center text-[1rem] leading-none text-foreground";
|
||||
const startYearOptions = Array.from(
|
||||
{ length: currentYear - SINKHOLE_MIN_SEARCH_YEAR + 1 },
|
||||
(_, index) => String(currentYear - index),
|
||||
)
|
||||
.filter((year) => !yearRangeDraft.endYear || Number(year) <= Number(yearRangeDraft.endYear))
|
||||
.filter(
|
||||
(year) =>
|
||||
!yearRangeDraft.endYear ||
|
||||
Number(year) <= Number(yearRangeDraft.endYear),
|
||||
)
|
||||
.map((year) => ({ val: year, text: year }));
|
||||
const endYearOptions = Array.from(
|
||||
{ length: currentYear - SINKHOLE_MIN_SEARCH_YEAR + 1 },
|
||||
(_, index) => String(currentYear - index),
|
||||
)
|
||||
.filter((year) => !yearRangeDraft.startYear || Number(year) >= Number(yearRangeDraft.startYear))
|
||||
.filter(
|
||||
(year) =>
|
||||
!yearRangeDraft.startYear ||
|
||||
Number(year) >= Number(yearRangeDraft.startYear),
|
||||
)
|
||||
.map((year) => ({ val: year, text: year }));
|
||||
|
||||
const inputClass = "h-[22px] border border-[#ccd6df] px-[2px] text-center text-[1rem] leading-none text-[#222]";
|
||||
const inputClass =
|
||||
"h-[22px] border border-input bg-background px-[2px] text-center text-[1rem] leading-none text-foreground";
|
||||
const numberInputClass = inputClass + " w-[40px]";
|
||||
const applyButtonClass = "grid h-[20px] w-[20px] shrink-0 place-items-center rounded-full bg-[#31a83b] text-[0.95rem] font-bold leading-none text-white";
|
||||
const indicator = <span className="h-2 w-2 shrink-0 rounded-full bg-[#243b93]" />;
|
||||
const applyButtonClass =
|
||||
"grid h-[20px] w-[20px] shrink-0 place-items-center rounded-full bg-primary text-[0.95rem] font-bold leading-none text-primary-foreground";
|
||||
const indicator = (
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||
);
|
||||
|
||||
const settingRows = (
|
||||
<div className="space-y-1.5 text-[1.05rem] font-medium text-slate-700">
|
||||
<div className="space-y-1.5 text-[1.05rem] font-medium text-label">
|
||||
<div className="flex min-h-[25px] items-center gap-1.5 whitespace-nowrap">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{indicator}
|
||||
<label htmlFor="sinkhole-radius" className="shrink-0">지반침하반경(m)</label>
|
||||
<label htmlFor="sinkhole-radius" className="shrink-0">
|
||||
지반침하반경(m)
|
||||
</label>
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<input
|
||||
@@ -120,7 +144,9 @@ export default function SinkholeLayerSettingPanel({
|
||||
autoComplete="off"
|
||||
value={radiusText}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onChange={(event) => setRadiusText(event.target.value.replace(/[^0-9]/g, ""))}
|
||||
onChange={(event) =>
|
||||
setRadiusText(event.target.value.replace(/[^0-9]/g, ""))
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void commitRadius();
|
||||
}}
|
||||
@@ -139,7 +165,9 @@ export default function SinkholeLayerSettingPanel({
|
||||
<div className="flex min-h-[25px] items-center gap-1.5 whitespace-nowrap">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{indicator}
|
||||
<label htmlFor="sinkhole-start-year" className="shrink-0">년도설정</label>
|
||||
<label htmlFor="sinkhole-start-year" className="shrink-0">
|
||||
년도설정
|
||||
</label>
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
<div id="sinkhole-start-year" className="w-[62px]">
|
||||
@@ -149,12 +177,16 @@ export default function SinkholeLayerSettingPanel({
|
||||
label=""
|
||||
selectArray={startYearOptions}
|
||||
value={yearRangeDraft.startYear}
|
||||
onSelectValue={(value) => updateYearRangeDraft("startYear", String(value))}
|
||||
onSelectValue={(value) =>
|
||||
updateYearRangeDraft("startYear", String(value))
|
||||
}
|
||||
selectContentClass="z-[101]"
|
||||
labelClass="hidden"
|
||||
/>
|
||||
</div>
|
||||
<span className="px-[1px] text-[0.95rem] font-normal text-slate-500">~</span>
|
||||
<span className="px-[1px] text-[0.95rem] font-normal text-muted-foreground">
|
||||
~
|
||||
</span>
|
||||
<div id="sinkhole-end-year" className="w-[62px]">
|
||||
<SelectBox
|
||||
className="w-full"
|
||||
@@ -162,7 +194,9 @@ export default function SinkholeLayerSettingPanel({
|
||||
label=""
|
||||
selectArray={endYearOptions}
|
||||
value={yearRangeDraft.endYear}
|
||||
onSelectValue={(value) => updateYearRangeDraft("endYear", String(value))}
|
||||
onSelectValue={(value) =>
|
||||
updateYearRangeDraft("endYear", String(value))
|
||||
}
|
||||
selectContentClass="z-[101]"
|
||||
labelClass="hidden"
|
||||
/>
|
||||
@@ -183,7 +217,7 @@ export default function SinkholeLayerSettingPanel({
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="w-full rounded-[6px] border border-[#dbe7f5] bg-white px-2 py-[10px] shadow-[0_2px_8px_rgba(15,23,42,0.05)]">
|
||||
<div className="w-full rounded-[6px] border border-border bg-background px-2 py-[10px] shadow-blur">
|
||||
<div className="overflow-visible">{settingRows}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -191,16 +225,19 @@ export default function SinkholeLayerSettingPanel({
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-hidden rounded-sm border border-[#f0d7b0] bg-[#fff8ef]">
|
||||
<div className="relative flex min-h-[30px] items-center justify-center border-b border-[#f2dfc3] bg-white px-8">
|
||||
{/* 지반침하 상세는 지도 위험정보 경고영역이므로 주황 의미색을 유지한다. */}
|
||||
<div className="relative flex min-h-[30px] items-center justify-center border-b border-[#f2dfc3] bg-background px-8">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-2 top-1/2 h-[18px] w-[18px] -translate-y-1/2 bg-transparent text-[1.2rem] leading-none text-[#555]"
|
||||
className="absolute left-2 top-1/2 h-[18px] w-[18px] -translate-y-1/2 bg-transparent text-[1.2rem] leading-none text-muted-foreground"
|
||||
onClick={() => setIsCollapsed((value) => !value)}
|
||||
title={isCollapsed ? "펼치기" : "접기"}
|
||||
>
|
||||
{isCollapsed ? "+" : "-"}
|
||||
</button>
|
||||
<span className="text-[1.2rem] font-semibold text-[#d97706]">지반침하정보</span>
|
||||
<span className="text-[1.2rem] font-semibold text-[#d97706]">
|
||||
지반침하정보
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
@@ -208,11 +245,14 @@ export default function SinkholeLayerSettingPanel({
|
||||
<div className="overflow-visible">{settingRows}</div>
|
||||
|
||||
{isAdmin && lastSyncSummary ? (
|
||||
<div className="mt-2 rounded-sm border border-[#f2dfc3] bg-white px-2 py-2 text-[1rem] text-[#7c5b2f]">
|
||||
<div className="mt-2 rounded-sm border border-[#f2dfc3] bg-background px-2 py-2 text-[1rem] text-[#7c5b2f]">
|
||||
<div className="font-semibold">최근 동기화</div>
|
||||
<div className="mt-1">완료시각: {lastSyncSummary.finishedAt || "-"}</div>
|
||||
<div className="mt-1">
|
||||
성공 {lastSyncSummary.successCount}건 / 실패 {lastSyncSummary.failedCount}건
|
||||
완료시각: {lastSyncSummary.finishedAt || "-"}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
성공 {lastSyncSummary.successCount}건 / 실패{" "}
|
||||
{lastSyncSummary.failedCount}건
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -3,15 +3,17 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Button, Input } from "@common/components";
|
||||
import { UiTable } from "@common/business_components/ui/table";
|
||||
import type {
|
||||
UiHeadProps,
|
||||
UiTableSort,
|
||||
} from "@common/business_components/ui/table";
|
||||
import {
|
||||
StandardPopupLayout,
|
||||
type UiHeadProps,
|
||||
} from "@common/business_components";
|
||||
import { getRawJson } from "@/components/lib/api";
|
||||
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import useStore from "@/store/useStore";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
import type { SelectDataType } from "@common/business_components";
|
||||
import {
|
||||
buildSinkholeDefaultFilters,
|
||||
@@ -29,7 +31,7 @@ import {
|
||||
type SinkholeListItem,
|
||||
} from "./sinkholeTypes";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const PAGE_SIZE = 10;
|
||||
const DOWNLOAD_BATCH_SIZE = 2000;
|
||||
|
||||
type SinkholeTableRow = SinkholeListItem & {
|
||||
@@ -43,16 +45,17 @@ type SinkholeTableRow = SinkholeListItem & {
|
||||
};
|
||||
|
||||
const HEADLIST: UiHeadProps[] = [
|
||||
{ id: "acdntNo", title: "사고번호", widthPercent: 15, sortable: true },
|
||||
{ id: "ocrnYmd", title: "발생일자", widthPercent: 10, sortable: true },
|
||||
{ id: "fullAddress", title: "주소", widthPercent: 20, sortable: true },
|
||||
{ id: "sizeWidthDisplay", title: "발생규모폭", widthPercent: 8, sortable: true, sortKey: "sizeWidth" },
|
||||
{ id: "sizeLengthDisplay", title: "발생규모연장", widthPercent: 9, sortable: true, sortKey: "sizeLength" },
|
||||
{ id: "sizeDepthDisplay", title: "발생규모깊이", widthPercent: 9, sortable: true, sortKey: "sizeDepth" },
|
||||
{ id: "detailCauseDisplay", title: "상세발생원인", widthPercent: 11, sortable: true, sortKey: "detailCause" },
|
||||
{ id: "restoreStatus", title: "복구상태", widthPercent: 8, sortable: true },
|
||||
{ id: "coordStatusDisplay", title: "좌표보정상태", widthPercent: 9, sortable: true, sortKey: "coordStatus" },
|
||||
{ id: "restoreCompletedYmdDisplay", title: "복구완료일자", widthPercent: 11, sortable: true, sortKey: "restoreCompletedYmd" },
|
||||
{ id: "rowNo", title: "순번", widthPercent: 6 },
|
||||
{ id: "acdntNo", title: "사고번호", widthPercent: 15 },
|
||||
{ id: "ocrnYmd", title: "발생일자", widthPercent: 10 },
|
||||
{ id: "fullAddress", title: "주소", widthPercent: 20 },
|
||||
{ id: "sizeWidthDisplay", title: "발생규모폭", widthPercent: 8 },
|
||||
{ id: "sizeLengthDisplay", title: "발생규모연장", widthPercent: 9 },
|
||||
{ id: "sizeDepthDisplay", title: "발생규모깊이", widthPercent: 9 },
|
||||
{ id: "detailCauseDisplay", title: "상세발생원인", widthPercent: 11 },
|
||||
{ id: "restoreStatus", title: "복구상태", widthPercent: 8 },
|
||||
{ id: "coordStatusDisplay", title: "좌표보정상태", widthPercent: 9 },
|
||||
{ id: "restoreCompletedYmdDisplay", title: "복구완료일자", widthPercent: 11 },
|
||||
];
|
||||
|
||||
const toDisplay = (value: unknown) => {
|
||||
@@ -61,14 +64,20 @@ const toDisplay = (value: unknown) => {
|
||||
};
|
||||
|
||||
const toCoordStatusDisplay = (value: unknown) => {
|
||||
const normalized = String(value ?? "").trim().toUpperCase();
|
||||
const normalized = String(value ?? "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (normalized === "SUCCESS") return "성공";
|
||||
if (normalized === "NO_ADDRESS") return "주소없음";
|
||||
if (normalized === "FAILED") return "실패";
|
||||
return normalized || "-";
|
||||
};
|
||||
|
||||
const toTableRows = (items: SinkholeListItem[], page: number, pageSize = PAGE_SIZE) =>
|
||||
const toTableRows = (
|
||||
items: SinkholeListItem[],
|
||||
page: number,
|
||||
pageSize = PAGE_SIZE,
|
||||
) =>
|
||||
items.map((item, index) => ({
|
||||
...item,
|
||||
rowNo: page * pageSize + index + 1,
|
||||
@@ -97,6 +106,7 @@ const unwrapPayload = <T,>(response: any): T =>
|
||||
(response?.data ?? response) as T;
|
||||
|
||||
export default function SinkholePopupPage() {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const searchParams = useSearchParams();
|
||||
const hasInitialFilterParams =
|
||||
searchParams.has("startYmd") ||
|
||||
@@ -145,15 +155,14 @@ export default function SinkholePopupPage() {
|
||||
const [rows, setRows] = useState<SinkholeTableRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
const [appliedSort, setAppliedSort] = useState<UiTableSort>();
|
||||
const sggOptions = useMemo(
|
||||
() =>
|
||||
filters.ctpvCd === "0"
|
||||
? sgg
|
||||
: sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))),
|
||||
: sgg.filter((item) =>
|
||||
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
|
||||
),
|
||||
[filters.ctpvCd, sgg],
|
||||
);
|
||||
|
||||
@@ -169,7 +178,6 @@ export default function SinkholePopupPage() {
|
||||
pageSize = PAGE_SIZE,
|
||||
options?: {
|
||||
includeCoordStatus?: boolean;
|
||||
sort?: UiTableSort;
|
||||
},
|
||||
) => {
|
||||
const queryString = buildSinkholeListQueryString(
|
||||
@@ -180,8 +188,13 @@ export default function SinkholePopupPage() {
|
||||
pageSize,
|
||||
options,
|
||||
);
|
||||
const response = await getRawJson(`/sewerage-service/api/v1/sinkholes?${queryString}`);
|
||||
const payload = unwrapPayload<{ items?: SinkholeListItem[]; total?: number }>(response);
|
||||
const response = await getRawJson(
|
||||
`/sewerage-service/api/v1/sinkholes?${queryString}`,
|
||||
);
|
||||
const payload = unwrapPayload<{
|
||||
items?: SinkholeListItem[];
|
||||
total?: number;
|
||||
}>(response);
|
||||
const items = Array.isArray(payload?.items) ? payload.items : [];
|
||||
return {
|
||||
rows: toTableRows(items, page - 1, pageSize),
|
||||
@@ -193,18 +206,15 @@ export default function SinkholePopupPage() {
|
||||
);
|
||||
|
||||
const loadRows = useCallback(
|
||||
async (page: number, nextFilters: SinkholeFilters, nextSort?: UiTableSort) => {
|
||||
async (page: number, nextFilters: SinkholeFilters) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const normalized = normalizeSinkholeFilters(nextFilters);
|
||||
const response = await fetchRows(page, normalized, PAGE_SIZE, { sort: nextSort });
|
||||
const response = await fetchRows(page, normalized, PAGE_SIZE);
|
||||
setRows(response.rows);
|
||||
setTotal(response.total);
|
||||
setCurrentPage(page);
|
||||
setAppliedFilters(normalized);
|
||||
setSort(nextSort);
|
||||
setAppliedSort(nextSort);
|
||||
setSelectedIndex(-1);
|
||||
window.opener?.postMessage(
|
||||
{
|
||||
type: SINKHOLE_FILTER_SYNC_MESSAGE,
|
||||
@@ -216,16 +226,20 @@ export default function SinkholePopupPage() {
|
||||
console.error("지반침하 목록 조회 중 오류:", error);
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
alert(error instanceof Error ? error.message : "지반침하 목록 조회 중 오류가 발생했습니다.");
|
||||
alert(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "지반침하 목록 조회 중 오류가 발생했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[fetchRows],
|
||||
[alert, fetchRows],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRows(1, filters, undefined);
|
||||
void loadRows(1, filters);
|
||||
// 최초 로딩은 한 번만 수행한다.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
@@ -233,25 +247,15 @@ export default function SinkholePopupPage() {
|
||||
const handleSearch = async (page = 1) => {
|
||||
const normalized = normalizeSinkholeFilters(filters);
|
||||
setFilters(normalized);
|
||||
await loadRows(page, normalized, sort);
|
||||
await loadRows(page, normalized);
|
||||
};
|
||||
|
||||
/** 현재 적용된 검색 조건을 유지한 채 서버 정렬 후 첫 페이지를 조회한다. */
|
||||
const handleSortChange = async (nextSort?: UiTableSort) => {
|
||||
await loadRows(1, appliedFilters, nextSort);
|
||||
};
|
||||
|
||||
/** 페이지 이동은 입력 중인 조건이 아니라 마지막 적용 조건과 정렬을 사용한다. */
|
||||
/** 페이지 이동은 입력 중인 조건이 아니라 마지막 적용 조건을 사용한다. */
|
||||
const handlePageChange = async (page: number) => {
|
||||
await loadRows(page, appliedFilters, appliedSort);
|
||||
await loadRows(page, appliedFilters);
|
||||
};
|
||||
|
||||
const handleRowClick = (index?: number) => {
|
||||
if (typeof index !== "number") return;
|
||||
const row = rows[index];
|
||||
if (!row) return;
|
||||
|
||||
setSelectedIndex(index);
|
||||
const handleRowClick = (row: SinkholeTableRow) => {
|
||||
window.opener?.postMessage(
|
||||
{
|
||||
type: SINKHOLE_ROW_SELECT_MESSAGE,
|
||||
@@ -274,10 +278,14 @@ export default function SinkholePopupPage() {
|
||||
const totalPages = Math.max(1, Math.ceil(total / DOWNLOAD_BATCH_SIZE));
|
||||
|
||||
for (let page = 1; page <= totalPages; page += 1) {
|
||||
const response = await fetchRows(page, normalized, DOWNLOAD_BATCH_SIZE, {
|
||||
const response = await fetchRows(
|
||||
page,
|
||||
normalized,
|
||||
DOWNLOAD_BATCH_SIZE,
|
||||
{
|
||||
includeCoordStatus: false,
|
||||
sort: appliedSort,
|
||||
});
|
||||
},
|
||||
);
|
||||
downloadItems.push(...response.items);
|
||||
}
|
||||
|
||||
@@ -296,48 +304,61 @@ export default function SinkholePopupPage() {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("지반침하 다운로드 중 오류:", error);
|
||||
alert(error instanceof Error ? error.message : "지반침하 다운로드 중 오류가 발생했습니다.");
|
||||
alert(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "지반침하 다운로드 중 오류가 발생했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex h-screen flex-col overflow-hidden bg-white">
|
||||
<StandardPopupLayout title="지반침하 목록">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<header className="flex min-h-[44px] items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3">
|
||||
<h1 className="text-[1.45rem] font-semibold text-white">지반침하발생현황</h1>
|
||||
<div />
|
||||
</header>
|
||||
|
||||
<section className="overflow-y-auto p-4">
|
||||
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[170px_170px_220px_220px_180px_180px_auto] md:items-end md:justify-between">
|
||||
<PopupSearchPanel contentClassName="flex flex-wrap items-end gap-x-5 gap-y-3">
|
||||
<label className="block md:max-w-[170px]">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">발생기간(시작)</div>
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">
|
||||
발생기간(시작)
|
||||
</div>
|
||||
<Input
|
||||
className="h-10 !text-[1.1rem]"
|
||||
className="h-[3.2rem] !text-[1.3rem]"
|
||||
type="date"
|
||||
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
|
||||
value={formatYmdToDateInput(filters.startYmd)}
|
||||
onChange={(event) => setFilters({ ...filters, startYmd: formatDateInputToYmd(event.target.value) })}
|
||||
onChange={(event) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
startYmd: formatDateInputToYmd(event.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block md:max-w-[170px]">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">발생기간(종료)</div>
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">
|
||||
발생기간(종료)
|
||||
</div>
|
||||
<Input
|
||||
className="h-10 !text-[1.1rem]"
|
||||
className="h-[3.2rem] !text-[1.3rem]"
|
||||
type="date"
|
||||
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
|
||||
value={formatYmdToDateInput(filters.endYmd)}
|
||||
onChange={(event) => setFilters({ ...filters, endYmd: formatDateInputToYmd(event.target.value) })}
|
||||
onChange={(event) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
endYmd: formatDateInputToYmd(event.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block md:max-w-[220px]">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div>
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">시도</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="h-[3.2rem] w-full rounded-md border border-border px-3 text-[1.3rem]"
|
||||
value={filters.ctpvCd}
|
||||
onChange={(event) =>
|
||||
setFilters({
|
||||
@@ -357,11 +378,15 @@ export default function SinkholePopupPage() {
|
||||
</label>
|
||||
|
||||
<label className="block md:max-w-[220px]">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시군구</div>
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">
|
||||
시군구
|
||||
</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="h-[3.2rem] w-full rounded-md border border-border px-3 text-[1.3rem]"
|
||||
value={filters.sggCd}
|
||||
onChange={(event) => setFilters({ ...filters, sggCd: event.target.value })}
|
||||
onChange={(event) =>
|
||||
setFilters({ ...filters, sggCd: event.target.value })
|
||||
}
|
||||
>
|
||||
<option value="0">전체</option>
|
||||
{sggOptions.map((item) => (
|
||||
@@ -373,11 +398,15 @@ export default function SinkholePopupPage() {
|
||||
</label>
|
||||
|
||||
<label className="block md:max-w-[180px]">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">복구상태</div>
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">
|
||||
복구상태
|
||||
</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="h-[3.2rem] w-full rounded-md border border-border px-3 text-[1.3rem]"
|
||||
value={filters.restoreStatus}
|
||||
onChange={(event) => setFilters({ ...filters, restoreStatus: event.target.value })}
|
||||
onChange={(event) =>
|
||||
setFilters({ ...filters, restoreStatus: event.target.value })
|
||||
}
|
||||
>
|
||||
{SINKHOLE_RESTORE_STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
@@ -388,11 +417,15 @@ export default function SinkholePopupPage() {
|
||||
</label>
|
||||
|
||||
<label className="block md:max-w-[180px]">
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">좌표보정상태</div>
|
||||
<div className="mb-1 text-[1.05rem] font-medium text-label">
|
||||
좌표보정상태
|
||||
</div>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
|
||||
className="h-[3.2rem] w-full rounded-md border border-border px-3 text-[1.3rem]"
|
||||
value={filters.coordStatus}
|
||||
onChange={(event) => setFilters({ ...filters, coordStatus: event.target.value })}
|
||||
onChange={(event) =>
|
||||
setFilters({ ...filters, coordStatus: event.target.value })
|
||||
}
|
||||
>
|
||||
{SINKHOLE_COORD_STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
@@ -402,44 +435,49 @@ export default function SinkholePopupPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2 md:pr-3">
|
||||
<div className="ml-auto flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
onClick={() => void handleSearch(1)}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="green"
|
||||
className="h-10 px-5 !text-[1.1rem]"
|
||||
onClick={handleDownload}
|
||||
color="white"
|
||||
onClick={() => {
|
||||
const resetFilters = normalizeSinkholeFilters(
|
||||
buildSinkholeDefaultFilters(),
|
||||
undefined,
|
||||
);
|
||||
setFilters(resetFilters);
|
||||
void loadRows(1, resetFilters);
|
||||
}}
|
||||
>
|
||||
다운로드
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopupSearchPanel>
|
||||
|
||||
<UiTable<SinkholeTableRow>
|
||||
headName={<>지반침하 조회 결과 총 {total.toLocaleString()}건</>}
|
||||
tableData={rows}
|
||||
<MapPopupDataTable
|
||||
rows={rows as Array<SinkholeTableRow & Record<string, unknown>>}
|
||||
headlist={HEADLIST}
|
||||
pageSize={PAGE_SIZE}
|
||||
title="지반침하 목록"
|
||||
total={total}
|
||||
cellClick={(index) => handleRowClick(index)}
|
||||
page={currentPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
onPageChange={(page) => void handlePageChange(page)}
|
||||
curPage={currentPage}
|
||||
index={selectedIndex}
|
||||
widthFixed={true}
|
||||
isHideCount={true}
|
||||
sort={sort}
|
||||
onSortChange={(nextSort) => void handleSortChange(nextSort)}
|
||||
onRowClick={handleRowClick}
|
||||
rowId={(row, index) => String(row.fid ?? row.acdntNo ?? index)}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="150rem"
|
||||
actions={
|
||||
<Button type="button" onClick={handleDownload}>
|
||||
엑셀다운로드
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { SelectDataType } from "@common/business_components";
|
||||
import type { UiTableSort } from "@common/business_components/ui";
|
||||
import type { IUserInfo } from "@/shared/store";
|
||||
|
||||
export const SINKHOLE_CONTROL_NODE_ID = "SN_SINKHOLE";
|
||||
@@ -15,7 +14,9 @@ export const SINKHOLE_DEFAULT_RADIUS = 100;
|
||||
export const SINKHOLE_LAYER_ID = "SN_SINKHOLE_LAYER";
|
||||
export const SINKHOLE_SELECTED_LAYER_ID = "SN_SINKHOLE_SELECTED_LAYER";
|
||||
export const SINKHOLE_MIN_SEARCH_YMD = "20160101";
|
||||
export const SINKHOLE_MIN_SEARCH_YEAR = Number(SINKHOLE_MIN_SEARCH_YMD.slice(0, 4));
|
||||
export const SINKHOLE_MIN_SEARCH_YEAR = Number(
|
||||
SINKHOLE_MIN_SEARCH_YMD.slice(0, 4),
|
||||
);
|
||||
|
||||
export type SinkholeFilters = {
|
||||
startYmd: string;
|
||||
@@ -94,7 +95,9 @@ export type SinkholeYearRange = {
|
||||
const toYmd = (date: Date) =>
|
||||
`${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, "0")}${String(date.getDate()).padStart(2, "0")}`;
|
||||
|
||||
export const buildSinkholeDefaultYearRange = (baseDate = new Date()): SinkholeYearRange => {
|
||||
export const buildSinkholeDefaultYearRange = (
|
||||
baseDate = new Date(),
|
||||
): SinkholeYearRange => {
|
||||
const currentYear = baseDate.getFullYear();
|
||||
return {
|
||||
startYear: String(currentYear - 1),
|
||||
@@ -106,8 +109,12 @@ export const normalizeSinkholeYearRange = (
|
||||
range?: Partial<SinkholeYearRange> | null,
|
||||
fallback: SinkholeYearRange = buildSinkholeDefaultYearRange(),
|
||||
): SinkholeYearRange => ({
|
||||
startYear: String(range?.startYear ?? fallback.startYear).replace(/[^0-9]/g, "").slice(0, 4),
|
||||
endYear: String(range?.endYear ?? fallback.endYear).replace(/[^0-9]/g, "").slice(0, 4),
|
||||
startYear: String(range?.startYear ?? fallback.startYear)
|
||||
.replace(/[^0-9]/g, "")
|
||||
.slice(0, 4),
|
||||
endYear: String(range?.endYear ?? fallback.endYear)
|
||||
.replace(/[^0-9]/g, "")
|
||||
.slice(0, 4),
|
||||
});
|
||||
|
||||
export const validateSinkholeYearRange = (
|
||||
@@ -120,7 +127,10 @@ export const validateSinkholeYearRange = (
|
||||
if (!/^\d{4}$/.test(range.startYear) || !/^\d{4}$/.test(range.endYear)) {
|
||||
return "시작년도와 종료년도는 4자리 숫자로 입력해야 합니다.";
|
||||
}
|
||||
if (startYear < SINKHOLE_MIN_SEARCH_YEAR || endYear < SINKHOLE_MIN_SEARCH_YEAR) {
|
||||
if (
|
||||
startYear < SINKHOLE_MIN_SEARCH_YEAR ||
|
||||
endYear < SINKHOLE_MIN_SEARCH_YEAR
|
||||
) {
|
||||
return `${SINKHOLE_MIN_SEARCH_YEAR}년 이후 범위로 입력해야 합니다.`;
|
||||
}
|
||||
if (startYear > endYear) {
|
||||
@@ -148,7 +158,9 @@ export const buildSinkholeFiltersFromYearRange = (
|
||||
};
|
||||
};
|
||||
|
||||
export const buildSinkholeDefaultFilters = (scope?: SinkholeScope): SinkholeFilters => {
|
||||
export const buildSinkholeDefaultFilters = (
|
||||
scope?: SinkholeScope,
|
||||
): SinkholeFilters => {
|
||||
const today = new Date();
|
||||
const oneYearAgo = new Date(today);
|
||||
oneYearAgo.setFullYear(today.getFullYear() - 1);
|
||||
@@ -163,7 +175,9 @@ export const buildSinkholeDefaultFilters = (scope?: SinkholeScope): SinkholeFilt
|
||||
};
|
||||
};
|
||||
|
||||
export const buildSinkholeScope = (userInfo: IUserInfo | null): SinkholeScope => {
|
||||
export const buildSinkholeScope = (
|
||||
userInfo: IUserInfo | null,
|
||||
): SinkholeScope => {
|
||||
const admin = userInfo?.adminRoleYn === "Y";
|
||||
if (admin) {
|
||||
return {
|
||||
@@ -205,8 +219,12 @@ export const normalizeSinkholeFilters = (
|
||||
scope?: SinkholeScope,
|
||||
): SinkholeFilters => {
|
||||
const next = {
|
||||
startYmd: String(filters.startYmd ?? "").replace(/[^0-9]/g, "").slice(0, 8),
|
||||
endYmd: String(filters.endYmd ?? "").replace(/[^0-9]/g, "").slice(0, 8),
|
||||
startYmd: String(filters.startYmd ?? "")
|
||||
.replace(/[^0-9]/g, "")
|
||||
.slice(0, 8),
|
||||
endYmd: String(filters.endYmd ?? "")
|
||||
.replace(/[^0-9]/g, "")
|
||||
.slice(0, 8),
|
||||
ctpvCd: String(filters.ctpvCd ?? "0") || "0",
|
||||
sggCd: String(filters.sggCd ?? "0") || "0",
|
||||
restoreStatus: String(filters.restoreStatus ?? "0") || "0",
|
||||
@@ -218,7 +236,11 @@ export const normalizeSinkholeFilters = (
|
||||
}
|
||||
if (scope?.sggCd && scope.sggCd !== "0") {
|
||||
next.sggCd = scope.sggCd;
|
||||
} else if (next.ctpvCd !== "0" && next.sggCd !== "0" && !next.sggCd.startsWith(next.ctpvCd)) {
|
||||
} else if (
|
||||
next.ctpvCd !== "0" &&
|
||||
next.sggCd !== "0" &&
|
||||
!next.sggCd.startsWith(next.ctpvCd)
|
||||
) {
|
||||
next.sggCd = "0";
|
||||
}
|
||||
|
||||
@@ -231,7 +253,10 @@ export const formatYmdToDateInput = (value?: string) => {
|
||||
return `${normalized.slice(0, 4)}-${normalized.slice(4, 6)}-${normalized.slice(6, 8)}`;
|
||||
};
|
||||
|
||||
export const formatDateInputToYmd = (value?: string) => String(value ?? "").replace(/[^0-9]/g, "").slice(0, 8);
|
||||
export const formatDateInputToYmd = (value?: string) =>
|
||||
String(value ?? "")
|
||||
.replace(/[^0-9]/g, "")
|
||||
.slice(0, 8);
|
||||
|
||||
export const resolveRegionNameByCode = (
|
||||
code: string,
|
||||
@@ -252,7 +277,6 @@ export const buildSinkholeListQueryString = (
|
||||
size = 20,
|
||||
options?: {
|
||||
includeCoordStatus?: boolean;
|
||||
sort?: UiTableSort;
|
||||
},
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
@@ -276,10 +300,6 @@ export const buildSinkholeListQueryString = (
|
||||
if (options?.includeCoordStatus === false) {
|
||||
params.set("includeCoordStatus", "false");
|
||||
}
|
||||
if (options?.sort) {
|
||||
params.set("sort", `${options.sort.key},${options.sort.direction}`);
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
@@ -287,7 +307,9 @@ export const isSinkholeLayerNode = (node: any) => {
|
||||
const data = node?.data ?? node;
|
||||
const nodeId = String(node?.id ?? data?.lyrSn ?? "");
|
||||
const layerName = String(data?.lyrNm ?? data?.name ?? "").trim();
|
||||
const layerTypeName = String(data?.lyrTyNm ?? data?.lyrTypeNm ?? data?.lyr_type_nm ?? "").trim();
|
||||
const layerTypeName = String(
|
||||
data?.lyrTyNm ?? data?.lyrTypeNm ?? data?.lyr_type_nm ?? "",
|
||||
).trim();
|
||||
|
||||
return (
|
||||
nodeId === SINKHOLE_CONTROL_NODE_ID ||
|
||||
@@ -300,10 +322,11 @@ export const isSinkholeRootNode = (node: any) => {
|
||||
const data = node?.data ?? node;
|
||||
const nodeId = String(node?.id ?? data?.lyrSn ?? "");
|
||||
const layerName = String(data?.lyrNm ?? data?.name ?? "").trim();
|
||||
return nodeId === SINKHOLE_CONTROL_NODE_ID || layerName === SINKHOLE_LAYER_NAME;
|
||||
return (
|
||||
nodeId === SINKHOLE_CONTROL_NODE_ID || layerName === SINKHOLE_LAYER_NAME
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export const SINKHOLE_RESTORE_STATUS_OPTIONS = [
|
||||
{ value: "0", label: "전체" },
|
||||
{ value: "복구완료", label: "복구완료" },
|
||||
@@ -318,7 +341,7 @@ export const SINKHOLE_COORD_STATUS_OPTIONS = [
|
||||
] as const;
|
||||
|
||||
export const SINKHOLE_EXCEL_HEADERS: Record<string, string> = {
|
||||
rowNo: "NO",
|
||||
rowNo: "순번",
|
||||
ocrnYmd: "발생일자",
|
||||
fullAddress: "주소",
|
||||
sizeWidth: "발생규모폭",
|
||||
@@ -331,7 +354,7 @@ export const SINKHOLE_EXCEL_HEADERS: Record<string, string> = {
|
||||
};
|
||||
|
||||
export const SINKHOLE_DOWNLOAD_EXCEL_HEADERS: Record<string, string> = {
|
||||
rowNo: "NO",
|
||||
rowNo: "순번",
|
||||
ocrnYmd: "발생일자",
|
||||
fullAddress: "주소",
|
||||
sizeWidth: "발생규모폭",
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
|
||||
import {
|
||||
StandardPopupLayout,
|
||||
UITab,
|
||||
type UiHeadProps,
|
||||
} from "@common/business_components";
|
||||
|
||||
import { Button } from "@common/components";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
|
||||
import {
|
||||
WINDOW_POPUP_MESSAGE_TYPE,
|
||||
@@ -43,6 +51,86 @@ const EMPTY_RESULT_PAGE: WaterRiskResultPage = {
|
||||
totalPages: 0,
|
||||
};
|
||||
|
||||
type WaterRiskTableRow = Record<string, unknown> & {
|
||||
sourceRow: WaterRiskResultRow;
|
||||
rowNo: number;
|
||||
sidoName: string;
|
||||
sggName: string;
|
||||
areaName: string;
|
||||
currentAccumRainQty: string;
|
||||
forecastRainQty: string;
|
||||
maxRainIntensity: string;
|
||||
runoffFlowQty: string;
|
||||
conveyQty: string;
|
||||
analysisDisplay: string;
|
||||
riskStatus: WaterRiskResultRow["riskStatus"];
|
||||
};
|
||||
|
||||
const CURRENT_RESULT_HEADLIST: UiHeadProps[] = [
|
||||
{ id: "rowNo", title: "순번", widthPercent: 6 },
|
||||
{ id: "sidoName", title: "시도", widthPercent: 9 },
|
||||
{ id: "sggName", title: "시군구", widthPercent: 9 },
|
||||
{ id: "areaName", title: "중점관리지역", widthPercent: 14 },
|
||||
{
|
||||
id: "currentAccumRainQty",
|
||||
title: "현재누적강우량(mm)",
|
||||
widthPercent: 13,
|
||||
align: "right",
|
||||
},
|
||||
{
|
||||
id: "forecastRainQty",
|
||||
title: "예측강우량(mm)",
|
||||
widthPercent: 12,
|
||||
align: "right",
|
||||
},
|
||||
{
|
||||
id: "runoffFlowQty",
|
||||
title: "유출유량(㎥/s)",
|
||||
widthPercent: 11,
|
||||
align: "right",
|
||||
},
|
||||
{
|
||||
id: "conveyQty",
|
||||
title: "통수량(㎥/s)",
|
||||
widthPercent: 10,
|
||||
align: "right",
|
||||
},
|
||||
{ id: "analysisDisplay", title: "분석일", widthPercent: 10 },
|
||||
{ id: "riskStatus", title: "통수위험", widthPercent: 7 },
|
||||
];
|
||||
|
||||
const HISTORY_RESULT_HEADLIST: UiHeadProps[] = [
|
||||
{ id: "rowNo", title: "순번", widthPercent: 6 },
|
||||
{ id: "sidoName", title: "시도", widthPercent: 9 },
|
||||
{ id: "sggName", title: "시군구", widthPercent: 9 },
|
||||
{ id: "areaName", title: "중점관리지역", widthPercent: 15 },
|
||||
{
|
||||
id: "maxRainIntensity",
|
||||
title: "최대강우강도(mm/hr)",
|
||||
widthPercent: 15,
|
||||
align: "right",
|
||||
},
|
||||
{
|
||||
id: "runoffFlowQty",
|
||||
title: "유출유량(㎥/s)",
|
||||
widthPercent: 12,
|
||||
align: "right",
|
||||
},
|
||||
{
|
||||
id: "conveyQty",
|
||||
title: "통수량(㎥/s)",
|
||||
widthPercent: 11,
|
||||
align: "right",
|
||||
},
|
||||
{ id: "analysisDisplay", title: "분석일", widthPercent: 12 },
|
||||
{ id: "riskStatus", title: "통수위험", widthPercent: 11 },
|
||||
];
|
||||
|
||||
const formatOptionalNumber = (
|
||||
value: number | null | undefined,
|
||||
digits: number,
|
||||
) => (value == null ? "-" : value.toFixed(digits));
|
||||
|
||||
/** 브라우저 로컬 날짜를 API의 yyyy-MM-dd 형식으로 변환한다. */
|
||||
const formatLocalDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
@@ -149,7 +237,9 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
if (!active) return;
|
||||
setResultPage(page);
|
||||
if (page.items.length > 0) {
|
||||
setSelectedResultKey((current) => current || buildResultRowKey(page.items[0]!));
|
||||
setSelectedResultKey(
|
||||
(current) => current || buildResultRowKey(page.items[0]!),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -172,7 +262,8 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SYNC) return;
|
||||
if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SYNC)
|
||||
return;
|
||||
|
||||
const nextSnapshot = (event.data.payload ??
|
||||
EMPTY_WATER_RISK_SESSION_SNAPSHOT) as WaterRiskSessionSnapshot;
|
||||
@@ -199,7 +290,8 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
const ctpvOptions = useMemo(() => {
|
||||
const unique = new Map<string, string>();
|
||||
areaOptions.forEach((option) => {
|
||||
if (option.ctpvCd) unique.set(option.ctpvCd, option.ctpvName || option.ctpvCd);
|
||||
if (option.ctpvCd)
|
||||
unique.set(option.ctpvCd, option.ctpvName || option.ctpvCd);
|
||||
});
|
||||
return Array.from(unique, ([code, name]) => ({ code, name })).sort((a, b) =>
|
||||
a.name.localeCompare(b.name, "ko"),
|
||||
@@ -215,7 +307,8 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
option.ctpvCd === draftResultFilters.ctpvCd,
|
||||
)
|
||||
.forEach((option) => {
|
||||
if (option.sggCd) unique.set(option.sggCd, option.sggName || option.sggCd);
|
||||
if (option.sggCd)
|
||||
unique.set(option.sggCd, option.sggName || option.sggCd);
|
||||
});
|
||||
return Array.from(unique, ([code, name]) => ({ code, name })).sort((a, b) =>
|
||||
a.name.localeCompare(b.name, "ko"),
|
||||
@@ -234,7 +327,37 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
[areaOptions, draftResultFilters.ctpvCd, draftResultFilters.sggCd],
|
||||
);
|
||||
|
||||
const resultTableMinWidth = resultMode === "current" ? 1140 : 1060;
|
||||
const resultListTitle =
|
||||
resultMode === "current"
|
||||
? "통수위험 분석 결과 목록"
|
||||
: "통수위험 과거 이력 목록";
|
||||
const tableRows = useMemo<WaterRiskTableRow[]>(
|
||||
() =>
|
||||
resultPage.items.map((row) => {
|
||||
const representativePipe =
|
||||
row.pipeSummaries.find((pipe) => pipe.riskStatus === "DANGER") ??
|
||||
row.pipeSummaries[0];
|
||||
|
||||
return {
|
||||
sourceRow: row,
|
||||
rowNo: 0,
|
||||
sidoName: row.sidoName || "-",
|
||||
sggName: row.sggName || "-",
|
||||
areaName: row.areaName,
|
||||
currentAccumRainQty: formatOptionalNumber(row.currentAccumRainQty, 1),
|
||||
forecastRainQty: formatOptionalNumber(row.forecastRainQty, 1),
|
||||
maxRainIntensity: formatOptionalNumber(row.maxRainIntensity, 1),
|
||||
runoffFlowQty: formatOptionalNumber(
|
||||
representativePipe?.runoffFlowQty,
|
||||
2,
|
||||
),
|
||||
conveyQty: formatOptionalNumber(representativePipe?.conveyQty, 3),
|
||||
analysisDisplay: row.maxFloodingAt || row.analysisDate || "-",
|
||||
riskStatus: row.riskStatus,
|
||||
};
|
||||
}),
|
||||
[resultPage.items],
|
||||
);
|
||||
|
||||
/** 검증된 검색조건을 적용하고 첫 페이지를 새로 조회한다. */
|
||||
const handleApplyResultFilters = () => {
|
||||
@@ -285,45 +408,33 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="sn-popup-page">
|
||||
<header className="sn-popup-header">
|
||||
<h1 className="sn-popup-title">통수위험분석 결과조회</h1>
|
||||
</header>
|
||||
|
||||
<section className="sn-popup-section">
|
||||
<section className="flex h-full min-h-0 flex-col gap-4">
|
||||
<div className="flex items-end border-b border-border bg-background">
|
||||
<button
|
||||
type="button"
|
||||
className={`min-w-[112px] border-b-2 px-4 py-3 text-xl font-semibold ${
|
||||
resultMode === "current"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-label hover:text-foreground"
|
||||
<StandardPopupLayout title={windowPopupDefinitions.flowRiskPopup.title}>
|
||||
<section
|
||||
className={`relative flex min-h-0 flex-1 flex-col gap-[1.2rem] ${
|
||||
tableRows.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 [&_.standard-data-table__scroll>div]:!h-full [&_.standard-data-table__scroll]:!min-h-96 [&_.standard-data-table__scroll]:!flex-1"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => handleChangeResultMode("current")}
|
||||
>
|
||||
현재 위험
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`min-w-[112px] border-b-2 px-4 py-3 text-xl font-semibold ${
|
||||
resultMode === "history"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-label hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => handleChangeResultMode("history")}
|
||||
>
|
||||
과거 이력
|
||||
</button>
|
||||
</div>
|
||||
<LoadingOverlay isLoading={isLoading || isMapLoading} />
|
||||
<UITab
|
||||
variant="underline"
|
||||
defaultValue="current"
|
||||
tabList={[
|
||||
{ value: "current", label: "현재 위험" },
|
||||
{ value: "history", label: "과거 이력" },
|
||||
]}
|
||||
onValueChange={(value) =>
|
||||
handleChangeResultMode(value as WaterRiskResultMode)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="overflow-x-auto rounded-md border border-border bg-secondary p-4">
|
||||
<div className="grid min-w-[1080px] grid-cols-[300px_140px_140px_180px_140px_auto] items-end gap-3">
|
||||
<label className="block min-w-0">
|
||||
<div className="mb-1 text-xl font-medium text-label">
|
||||
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
{resultMode === "current" ? "조회기간" : "강우발생일"}
|
||||
</div>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={draftResultFilters.startDate}
|
||||
@@ -334,7 +445,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
}))
|
||||
}
|
||||
aria-label="조회 시작일"
|
||||
className="h-14 min-w-[138px] flex-1 rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
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"
|
||||
/>
|
||||
<span className="text-xl font-medium text-label">~</span>
|
||||
<input
|
||||
@@ -347,13 +458,15 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
}))
|
||||
}
|
||||
aria-label="조회 종료일"
|
||||
className="h-14 min-w-[138px] flex-1 rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
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"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="block min-w-0">
|
||||
<div className="mb-1 text-xl font-medium text-label">시도</div>
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
시도
|
||||
</div>
|
||||
<select
|
||||
value={draftResultFilters.ctpvCd}
|
||||
onChange={(event) =>
|
||||
@@ -365,7 +478,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
}))
|
||||
}
|
||||
aria-label="시도"
|
||||
className="h-14 w-full rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
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"
|
||||
>
|
||||
<option value="ALL">전체</option>
|
||||
{ctpvOptions.map((option) => (
|
||||
@@ -376,8 +489,10 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block min-w-0">
|
||||
<div className="mb-1 text-xl font-medium text-label">시군구</div>
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
시군구
|
||||
</div>
|
||||
<select
|
||||
value={draftResultFilters.sggCd}
|
||||
onChange={(event) =>
|
||||
@@ -388,7 +503,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
}))
|
||||
}
|
||||
aria-label="시군구"
|
||||
className="h-14 w-full rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
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"
|
||||
>
|
||||
<option value="ALL">전체</option>
|
||||
{sggOptions.map((option) => (
|
||||
@@ -399,8 +514,10 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block min-w-0">
|
||||
<div className="mb-1 text-xl font-medium text-label">중점관리지역</div>
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
중점관리지역
|
||||
</div>
|
||||
<select
|
||||
value={draftResultFilters.areaId}
|
||||
onChange={(event) =>
|
||||
@@ -410,7 +527,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
}))
|
||||
}
|
||||
aria-label="중점관리지역"
|
||||
className="h-14 w-full rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
className="h-[3.2rem] w-[18rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="ALL">전체</option>
|
||||
{filteredAreaOptions.map((option) => (
|
||||
@@ -424,8 +541,10 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block min-w-0">
|
||||
<div className="mb-1 text-xl font-medium text-label">위험상태</div>
|
||||
<label className="flex shrink-0 items-center gap-2">
|
||||
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
|
||||
위험상태
|
||||
</div>
|
||||
<select
|
||||
value={draftResultFilters.riskStatus}
|
||||
onChange={(event) =>
|
||||
@@ -435,7 +554,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
}))
|
||||
}
|
||||
aria-label="위험상태"
|
||||
className="h-14 w-full rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
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"
|
||||
>
|
||||
<option value="ALL">전체</option>
|
||||
<option value="DANGER">위험</option>
|
||||
@@ -443,164 +562,74 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="primary"
|
||||
className="h-14 min-w-[72px] px-6 !text-xl"
|
||||
onClick={handleApplyResultFilters}
|
||||
disabled={isLoading}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2 className="sn-popup-modal-title">
|
||||
{resultMode === "current" ? "통수위험 분석 결과" : "통수위험 과거 이력"}{" "}
|
||||
<span className="text-primary">{resultPage.total.toLocaleString()}건</span>
|
||||
</h2>
|
||||
<span className="text-xl text-label">
|
||||
합리식 유출유량과 Manning 통수량 비교 결과
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="sn-popup-card min-h-0 flex-1 overflow-auto" aria-busy={isLoading || isMapLoading}>
|
||||
<div className="overflow-auto">
|
||||
<table
|
||||
className="sn-popup-grid-table !text-xl"
|
||||
style={{ minWidth: `${resultTableMinWidth}px`, width: "100%" }}
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
onClick={() => {
|
||||
setDraftResultFilters(initialFilters);
|
||||
setAppliedResultFilters(initialFilters);
|
||||
setResultCurrentPage(1);
|
||||
setSelectedResultKey("");
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr className="sn-popup-grid-head-row">
|
||||
<th className="sn-popup-grid-head-cell whitespace-nowrap">No.</th>
|
||||
<th className="sn-popup-grid-head-cell whitespace-nowrap">시도</th>
|
||||
<th className="sn-popup-grid-head-cell whitespace-nowrap">시군구</th>
|
||||
<th className="sn-popup-grid-head-cell whitespace-nowrap">중점관리지역</th>
|
||||
{resultMode === "current" ? (
|
||||
<>
|
||||
<th className="sn-popup-grid-head-cell whitespace-nowrap">현재누적강우량(mm)</th>
|
||||
<th className="sn-popup-grid-head-cell whitespace-nowrap">예측강우량(mm)</th>
|
||||
</>
|
||||
) : (
|
||||
<th className="sn-popup-grid-head-cell whitespace-nowrap">최대강우강도(mm/hr)</th>
|
||||
)}
|
||||
<th className="sn-popup-grid-head-cell whitespace-nowrap">유출유량(㎥/s)</th>
|
||||
<th className="sn-popup-grid-head-cell whitespace-nowrap">통수량(㎥/s)</th>
|
||||
<th className="sn-popup-grid-head-cell whitespace-nowrap">분석일</th>
|
||||
<th className="sn-popup-grid-head-cell whitespace-nowrap">통수위험</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{resultPage.items.map((row, index) => {
|
||||
const rowKey = buildResultRowKey(row);
|
||||
const isSelected = rowKey === selectedResultKey;
|
||||
const representativePipe =
|
||||
row.pipeSummaries.find((pipe) => pipe.riskStatus === "DANGER") ??
|
||||
row.pipeSummaries[0];
|
||||
const rowNumber =
|
||||
(resultCurrentPage - 1) * WATER_RISK_PAGE_SIZE + index + 1;
|
||||
return (
|
||||
<tr
|
||||
key={rowKey}
|
||||
aria-selected={isSelected}
|
||||
className={`sn-popup-grid-row-hover cursor-pointer ${
|
||||
isSelected ? "sn-popup-grid-row-selected" : "sn-popup-grid-row"
|
||||
}`}
|
||||
onClick={() => void handleSelectResultRow(row)}
|
||||
>
|
||||
<td className="sn-popup-grid-cell text-center">{rowNumber}</td>
|
||||
<td className="sn-popup-grid-cell">{row.sidoName || "-"}</td>
|
||||
<td className="sn-popup-grid-cell">{row.sggName || "-"}</td>
|
||||
<td className="sn-popup-grid-cell font-semibold">{row.areaName}</td>
|
||||
{resultMode === "current" ? (
|
||||
<>
|
||||
<td className="sn-popup-grid-cell text-right">
|
||||
{row.currentAccumRainQty == null
|
||||
? "-"
|
||||
: row.currentAccumRainQty.toFixed(1)}
|
||||
</td>
|
||||
<td className="sn-popup-grid-cell text-right">
|
||||
{row.forecastRainQty == null ? "-" : row.forecastRainQty.toFixed(1)}
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<td className="sn-popup-grid-cell text-right">
|
||||
{row.maxRainIntensity == null ? "-" : row.maxRainIntensity.toFixed(1)}
|
||||
</td>
|
||||
)}
|
||||
<td className="sn-popup-grid-cell text-right">
|
||||
{representativePipe?.runoffFlowQty == null
|
||||
? "-"
|
||||
: representativePipe.runoffFlowQty.toFixed(2)}
|
||||
</td>
|
||||
<td className="sn-popup-grid-cell text-right">
|
||||
{representativePipe?.conveyQty == null
|
||||
? "-"
|
||||
: representativePipe.conveyQty.toFixed(3)}
|
||||
</td>
|
||||
<td className="sn-popup-grid-cell text-center">
|
||||
{row.maxFloodingAt || row.analysisDate || "-"}
|
||||
</td>
|
||||
<td className="sn-popup-grid-cell text-center">
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</PopupSearchPanel>
|
||||
|
||||
<MapPopupDataTable
|
||||
rows={tableRows}
|
||||
headlist={
|
||||
resultMode === "current"
|
||||
? CURRENT_RESULT_HEADLIST
|
||||
: HISTORY_RESULT_HEADLIST
|
||||
}
|
||||
title={resultListTitle}
|
||||
total={resultPage.total}
|
||||
page={resultCurrentPage}
|
||||
pageSize={WATER_RISK_PAGE_SIZE}
|
||||
onPageChange={setResultCurrentPage}
|
||||
rowId={(row) => buildResultRowKey(row.sourceRow)}
|
||||
selectedRowId={selectedResultKey}
|
||||
onRowClick={(row) => void handleSelectResultRow(row.sourceRow)}
|
||||
renderCell={(row, headId) =>
|
||||
headId === "riskStatus" ? (
|
||||
<span
|
||||
className={`sn-popup-badge min-w-[56px] ${getRiskBadgeClassName(row.riskStatus)}`}
|
||||
className={`sn-popup-badge min-w-[5.6rem] ${getRiskBadgeClassName(row.riskStatus)}`}
|
||||
>
|
||||
{buildWaterRiskStatusLabel(row.riskStatus)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={resultMode === "current" ? 10 : 9}
|
||||
className="sn-popup-empty-cell sn-popup-grid-cell px-3 py-10 text-center"
|
||||
>
|
||||
통수위험 결과를 조회하고 있습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{!isLoading && resultPage.items.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={resultMode === "current" ? 10 : 9}
|
||||
className="sn-popup-empty-cell sn-popup-grid-cell px-3 py-10 text-center"
|
||||
>
|
||||
조회 조건에 해당하는 통수위험 결과가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resultPage.total > WATER_RISK_PAGE_SIZE ? (
|
||||
<div className="flex justify-center">
|
||||
<UiPageNations
|
||||
total={resultPage.total}
|
||||
cur={resultCurrentPage}
|
||||
pageSize={WATER_RISK_PAGE_SIZE}
|
||||
onPageChange={setResultCurrentPage}
|
||||
) : undefined
|
||||
}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth={resultMode === "current" ? "114rem" : "106rem"}
|
||||
emptyMessage="조회 조건에 해당하는 통수위험 결과가 없습니다."
|
||||
actions={
|
||||
<span className="text-xl font-normal text-muted-foreground">
|
||||
합리식 유출유량과 Manning 통수량 비교 결과
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div aria-live="polite" className="min-h-[20px] px-1 text-xl text-label">
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="min-h-[20px] px-1 text-xl text-label"
|
||||
>
|
||||
{errorMessage ? (
|
||||
<span className="text-destructive">{errorMessage}</span>
|
||||
) : isMapLoading ? (
|
||||
"선택한 결과의 지도 도형을 조회하고 있습니다."
|
||||
) : (
|
||||
noticeMessage
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
+1854
-637
File diff suppressed because it is too large
Load Diff
@@ -8,18 +8,18 @@
|
||||
* - 하수관로, 하수처리시설, 하수처리구역, 하수맨홀, 하수펌프장, 우수토실 등 다양한 유형별 컴포넌트 렌더링
|
||||
* - useSearchModalStore를 통해 상태 관리 및 사용자 상호작용 처리
|
||||
*/
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
import { SelectBox } from "@common/business_components/ui/select";
|
||||
import { Label } from '@common/components';
|
||||
import SwagePpl from './swagePpl';
|
||||
import SwagePrcsFclt from './swagePrcsFclt';
|
||||
import SwagePrcsZone from './swagePrcsZone';
|
||||
import FldngWtlvMap from './fldngWtlvMap';
|
||||
import SwageMhl from './swageMhl';
|
||||
import SwagePmstn from './swagePmstn';
|
||||
import Stofcbr from './stofcbr';
|
||||
import useSearchModalStore from '@/store/useSearchModalStore';
|
||||
|
||||
import { Label } from "@common/components";
|
||||
import SwagePpl from "./swagePpl";
|
||||
import SwagePrcsFclt from "./swagePrcsFclt";
|
||||
import SwagePrcsZone from "./swagePrcsZone";
|
||||
import FldngWtlvMap from "./fldngWtlvMap";
|
||||
import SwageMhl from "./swageMhl";
|
||||
import SwagePmstn from "./swagePmstn";
|
||||
import Stofcbr from "./stofcbr";
|
||||
import useSearchModalStore from "@/store/useSearchModalStore";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
|
||||
// 기본정보 '구분' 선택 목록
|
||||
export const BASIC_TYPES = [
|
||||
@@ -36,13 +36,14 @@ const BasicTab: React.FC = () => {
|
||||
const { basicType, setBasicType } = useSearchModalStore();
|
||||
|
||||
return (
|
||||
<div className="relative w-full min-h-[10vh] rounded-lg border border-border bg-background px-5 pt-5">
|
||||
{/* 구분 */}
|
||||
<div className="mb-3 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center">
|
||||
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">구분</Label>
|
||||
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-4 gap-y-3">
|
||||
{/* 구분과 세부 조회조건은 하나의 검색 행으로 이어서 배치한다. */}
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">
|
||||
구분
|
||||
</Label>
|
||||
<SelectBox
|
||||
className="w-[150px] mr-3"
|
||||
className="mr-3 w-[150px]"
|
||||
selectClass="rounded-lg bg-field text-foreground"
|
||||
label=""
|
||||
selectArray={BASIC_TYPES}
|
||||
@@ -53,16 +54,19 @@ const BasicTab: React.FC = () => {
|
||||
selectContentClass="z-[101]"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-[1_1_60rem]">
|
||||
{basicType === "tb_ms_swage_ppl" && <SwagePpl />} {/* 하수관로 */}
|
||||
{basicType === "tb_il_swage_prcs_fclt" && <SwagePrcsFclt />}{" "}
|
||||
{/** 하수처리시설 */}
|
||||
{basicType === "tb_ms_swage_prcs_zone" && <SwagePrcsZone />}{" "}
|
||||
{/** 하수처리구역 */}
|
||||
{basicType === "tb_ms_fldng_wtlv_map" && <FldngWtlvMap />}{" "}
|
||||
{/** 침수흔적도 */}
|
||||
{basicType === "tb_ms_swage_mhl" && <SwageMhl />} {/* 하수맨홀 */}
|
||||
{basicType === "tb_ms_swage_pmstn" && <SwagePmstn />} {/* 하수펌프장 */}
|
||||
{basicType === "tb_ms_stofcbr" && <Stofcbr />} {/* 우수토실 */}
|
||||
</div>
|
||||
{/* 조회 조건 */}
|
||||
{basicType === "tb_ms_swage_ppl" && (<SwagePpl />)} {/* 하수관로 */}
|
||||
{basicType === "tb_il_swage_prcs_fclt" && (<SwagePrcsFclt />)} {/* 하수처리시설 */}
|
||||
{basicType === "tb_ms_swage_prcs_zone" && (<SwagePrcsZone />)} {/* 하수처리구역 */}
|
||||
{basicType === "tb_ms_fldng_wtlv_map" && (<FldngWtlvMap />)} {/* 침수흔적도 */}
|
||||
{basicType === "tb_ms_swage_mhl" && (<SwageMhl />)} {/* 하수맨홀 */}
|
||||
{basicType === "tb_ms_swage_pmstn" && (<SwagePmstn />)} {/* 하수펌프장 */}
|
||||
{basicType === "tb_ms_stofcbr" && (<Stofcbr />)} {/* 우수토실 */}
|
||||
</div>
|
||||
</PopupSearchPanel>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
import React from 'react';
|
||||
import { Button, Input, Label } from '@common/components';
|
||||
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
|
||||
import useSearchModalStore from '../../../store/useSearchModalStore';
|
||||
import useSearchModal from './useSearchModal';
|
||||
import React from "react";
|
||||
import { Button, Input, Label } from "@common/components";
|
||||
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
|
||||
import useSearchModalStore from "../../../store/useSearchModalStore";
|
||||
import useSearchModal from "./useSearchModal";
|
||||
|
||||
// 침수흔적도용 CQL 필터 생성
|
||||
export const fldngWtlvMapCqlFilters = () => {
|
||||
const { ctpvCd, sggCd, var1, var2 } = useSearchModalStore.getState();
|
||||
const cqlFilters: string[] = [];
|
||||
if (ctpvCd !== '' && ctpvCd !== '0') cqlFilters.push(`stdg_cd LIKE '${ctpvCd.trim()}%'`);
|
||||
if (sggCd !== '' && sggCd !== '0') cqlFilters.push(`stdg_cd LIKE '${sggCd.trim()}%'`);
|
||||
if (ctpvCd !== "" && ctpvCd !== "0")
|
||||
cqlFilters.push(`stdg_cd LIKE '${ctpvCd.trim()}%'`);
|
||||
if (sggCd !== "" && sggCd !== "0")
|
||||
cqlFilters.push(`stdg_cd LIKE '${sggCd.trim()}%'`);
|
||||
if (var1) cqlFilters.push(`fldng_yr = '${var1.trim()}'`);
|
||||
if (var2) cqlFilters.push(`fldng_dstr_nm ILIKE '%${var2.trim()}%'`);
|
||||
return cqlFilters;
|
||||
};
|
||||
|
||||
const FldngWtlvMap: React.FC = () => {
|
||||
const {
|
||||
var1, setVar1, var2, setVar2,
|
||||
ctpvCd, setCtpvCd,
|
||||
sggCd, setSggCd,
|
||||
} = useSearchModalStore();
|
||||
const { handleSearch } = useSearchModal();
|
||||
const { var1, setVar1, var2, setVar2, ctpvCd, setCtpvCd, sggCd, setSggCd } =
|
||||
useSearchModalStore();
|
||||
const { handleSearch, handleResetSearch } = useSearchModal();
|
||||
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<div className="mb-2 flex items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
|
||||
<CtpvSggPrcsFcltSelectBox
|
||||
sido={ctpvCd}
|
||||
sigungu={sggCd}
|
||||
@@ -35,34 +34,47 @@ const FldngWtlvMap: React.FC = () => {
|
||||
}}
|
||||
onSigunguChange={setSggCd}
|
||||
/>
|
||||
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-gray-600">침수년도</Label>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-xl font-medium text-label">
|
||||
침수년도
|
||||
</Label>
|
||||
<Input
|
||||
className="mr-8 w-[100px] !text-xl placeholder:text-center"
|
||||
className="w-[100px] !text-xl placeholder:text-center"
|
||||
placeholder="YYYY"
|
||||
value={var1}
|
||||
onChange={(e) => setVar1(e.target.value.replace(/[^0-9]/g, '').slice(0, 4))}
|
||||
onChange={(e) =>
|
||||
setVar1(e.target.value.replace(/[^0-9]/g, "").slice(0, 4))
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-gray-600">침수재해명</Label>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-xl font-medium text-label">
|
||||
침수재해명
|
||||
</Label>
|
||||
<Input
|
||||
className='!text-xl w-[180px] placeholder:text-center mr-8'
|
||||
className="w-[180px] !text-xl placeholder:text-center"
|
||||
placeholder="입력"
|
||||
value={var2}
|
||||
onChange={(e) => setVar2(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-1 justify-end">
|
||||
<Button className="py-2 text-base" onClick={handleSearch}>조회</Button>
|
||||
</div>
|
||||
<div className="flex min-w-fit flex-1 justify-end gap-2">
|
||||
<Button onClick={handleSearch}>조회</Button>
|
||||
<Button color="white" onClick={handleResetSearch}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,37 +11,44 @@
|
||||
* - Zustand를 통한 상태 관리 및 필터 값 저장
|
||||
* - Enter 키 입력 시 자동 조회 지원
|
||||
*/
|
||||
import React from 'react';
|
||||
import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox';
|
||||
import React from "react";
|
||||
import { YearRangeSelectBox } from "@/components/ui/YearRangeSelectBox";
|
||||
import { SelectBox } from "@common/business_components/ui/select";
|
||||
import { Button, Input, Label } from '@common/components';
|
||||
import useStore from '@/store/useStore';
|
||||
import useSearchModalStore from '@/store/useSearchModalStore';
|
||||
import useSearchModal from './useSearchModal';
|
||||
import useOperationTab from './useOperationTab';
|
||||
|
||||
import { Button, Input, Label } from "@common/components";
|
||||
import useStore from "@/store/useStore";
|
||||
import useSearchModalStore from "@/store/useSearchModalStore";
|
||||
import useSearchModal from "./useSearchModal";
|
||||
import useOperationTab from "./useOperationTab";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
|
||||
const OperationTab: React.FC = () => {
|
||||
const { BIZ_TY_CD } = useStore();
|
||||
const {
|
||||
workType, setWorkType,
|
||||
var1, setVar1, var2, setVar2,
|
||||
yearFilter, setYearFilter,
|
||||
workType,
|
||||
setWorkType,
|
||||
var1,
|
||||
setVar1,
|
||||
var2,
|
||||
setVar2,
|
||||
yearFilter,
|
||||
setYearFilter,
|
||||
} = useSearchModalStore();
|
||||
const { handleSearch } = useSearchModal();
|
||||
const { handleSearch, handleResetSearch } = useSearchModal();
|
||||
const { WORK_TYPES } = useOperationTab();
|
||||
|
||||
const BIZ_TY_CD_SELECT_ARRAY = BIZ_TY_CD.map(item => ({
|
||||
const BIZ_TY_CD_SELECT_ARRAY = BIZ_TY_CD.map((item) => ({
|
||||
val: item.lwpoCd,
|
||||
text: item.lwpoCdNm,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-[10vh] w-full flex-col rounded-lg border border-border bg-background px-5 pt-5">
|
||||
<div className="flex items-center mb-3">
|
||||
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">업무구분</Label>
|
||||
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-xl font-medium text-label">
|
||||
업무구분
|
||||
</Label>
|
||||
<SelectBox
|
||||
className="w-40 mr-8"
|
||||
className="w-40"
|
||||
selectClass="rounded-lg bg-field text-xl text-foreground"
|
||||
label=""
|
||||
selectArray={WORK_TYPES.map((w) => ({
|
||||
@@ -53,30 +60,44 @@ const OperationTab: React.FC = () => {
|
||||
selectContentClass="z-[101]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center mb-6">
|
||||
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">사업명</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-xl font-medium text-label">
|
||||
사업명
|
||||
</Label>
|
||||
<Input
|
||||
className="w-[100px] !text-xl placeholder:text-center mr-8"
|
||||
className="w-[140px] !text-xl placeholder:text-center"
|
||||
placeholder="입력"
|
||||
value={var1}
|
||||
onChange={(e) => { setVar1(e.target.value); }}
|
||||
onChange={(e) => {
|
||||
setVar1(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault(); // Enter 키 기본 동작 방지
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<YearRangeSelectBox
|
||||
start={yearFilter.start}
|
||||
end={yearFilter.end}
|
||||
onChange={range => setYearFilter({ ...yearFilter, start: range.start, end: range.end })}
|
||||
onChange={(range) =>
|
||||
setYearFilter({
|
||||
...yearFilter,
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
})
|
||||
}
|
||||
startLabel="수행기간"
|
||||
endLabel=""
|
||||
/>
|
||||
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">사업 유형</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-xl font-medium text-label">
|
||||
사업 유형
|
||||
</Label>
|
||||
<SelectBox
|
||||
className="w-60 mr-8"
|
||||
className="w-60"
|
||||
selectClass="rounded-lg bg-field text-xl text-foreground"
|
||||
label=""
|
||||
selectArray={[{ val: "0", text: "전체" }, ...BIZ_TY_CD_SELECT_ARRAY]}
|
||||
@@ -86,13 +107,16 @@ const OperationTab: React.FC = () => {
|
||||
}}
|
||||
selectContentClass="z-[101]"
|
||||
/>
|
||||
</div>
|
||||
{/* 조회 버튼 */}
|
||||
<div className="flex-1 flex justify-end">
|
||||
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
|
||||
<div className="flex justify-end gap-2 sm:ml-auto sm:pl-4">
|
||||
<Button onClick={handleSearch}>조회</Button>
|
||||
<Button color="white" onClick={handleResetSearch}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
</PopupSearchPanel>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(OperationTab);
|
||||
|
||||
@@ -12,31 +12,30 @@
|
||||
* - Zustand를 통한 모달 상태 및 데이터 관리
|
||||
* - 로딩 오버레이 및 선택 초기화 기능
|
||||
*/
|
||||
'use client';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button, Label } from '@common/components';
|
||||
import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox';
|
||||
"use client";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, Label } from "@common/components";
|
||||
import { YearRangeSelectBox } from "@/components/ui/YearRangeSelectBox";
|
||||
import { SelectBox } from "@common/business_components/ui/select";
|
||||
import { UiTable } from "@common/business_components/ui/table";
|
||||
import LoadingOverlay from '@/components/ui/LoadingBar';
|
||||
import useReportTab from './useReportTab';
|
||||
import useStore from '@/store/useStore';
|
||||
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
|
||||
import useSearchModalStore from '@/store/useSearchModalStore';
|
||||
import { useReportTabStore } from '@/store/useReportTabStore';
|
||||
import type { UiTableSort } from "@common/business_components/ui/table";
|
||||
import type { FlattenedFolder, ReportDataFile } from './useReportTab';
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
import useReportTab from "./useReportTab";
|
||||
import useStore from "@/store/useStore";
|
||||
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
|
||||
import useSearchModalStore from "@/store/useSearchModalStore";
|
||||
import { useReportTabStore } from "@/store/useReportTabStore";
|
||||
import type { FlattenedFolder, ReportDataFile } from "./useReportTab";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
|
||||
// 하수도정비 기본계획 보고서
|
||||
export const reportTableHeadlist = [
|
||||
{ id: 'no', title: 'NO', sortable: true },
|
||||
{ id: 'sido', title: '시도', sortable: true },
|
||||
{ id: 'sigungu', title: '시군구', sortable: true },
|
||||
{ id: 'approvalYear', title: '승인년도', sortable: true },
|
||||
{ id: 'plantZoneGubun', title: '구분', sortable: true },
|
||||
{ id: 'reportGubun', title: '보고서구분', sortable: true },
|
||||
{ id: 'fileName', title: '파일명' },
|
||||
{ id: 'regDate', title: '등록일자' },
|
||||
{ id: "no", title: "순번" },
|
||||
{ id: "sido", title: "시도" },
|
||||
{ id: "sigungu", title: "시군구" },
|
||||
{ id: "approvalYear", title: "승인년도" },
|
||||
{ id: "plantZoneGubun", title: "구분" },
|
||||
{ id: "reportGubun", title: "보고서구분" },
|
||||
{ id: "fileName", title: "파일명" },
|
||||
{ id: "regDate", title: "등록일자" },
|
||||
];
|
||||
interface ReportTabProps {
|
||||
open?: boolean;
|
||||
@@ -46,72 +45,75 @@ interface ReportTabProps {
|
||||
}
|
||||
|
||||
const normalizeYear = (value: string) => {
|
||||
if (!value || value === '0' || value === '9999') return '';
|
||||
if (!value || value === "0" || value === "9999") return "";
|
||||
return value;
|
||||
};
|
||||
|
||||
const getApprovalYearNumber = (value: string | number | undefined) => {
|
||||
const normalized = String(value ?? '').replace('년', '').trim();
|
||||
const normalized = String(value ?? "")
|
||||
.replace("년", "")
|
||||
.trim();
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const REPORT_COLLATOR = new Intl.Collator('ko', { numeric: true, sensitivity: 'base' });
|
||||
const REPORT_PAGE_SIZE = 10;
|
||||
const REPORT_METADATA_CONCURRENCY = 8;
|
||||
const REPORT_METADATA_TIMEOUT_MS = 8000;
|
||||
const DISABLED_REPORT_SORT_KEYS = new Set(['fileName', 'regDate']);
|
||||
|
||||
type ReportFileMetadata = Pick<FlattenedFolder,
|
||||
'fileName' | 'regDate' | 'fileSn' | 'filePath' | 'fileCount' | 'downloadFileTitle'>;
|
||||
type ReportSearchFilters = {
|
||||
ctpvCd: string;
|
||||
sggCd: string;
|
||||
plantZoneGubun: string;
|
||||
reportGubun: string;
|
||||
yearFilter: { start: string; end: string };
|
||||
};
|
||||
|
||||
const DEFAULT_REPORT_SEARCH_FILTERS: ReportSearchFilters = {
|
||||
ctpvCd: "0",
|
||||
sggCd: "0",
|
||||
plantZoneGubun: "0",
|
||||
reportGubun: "0",
|
||||
yearFilter: { start: "", end: "" },
|
||||
};
|
||||
|
||||
type ReportFileMetadata = Pick<
|
||||
FlattenedFolder,
|
||||
| "fileName"
|
||||
| "regDate"
|
||||
| "fileSn"
|
||||
| "filePath"
|
||||
| "fileCount"
|
||||
| "downloadFileTitle"
|
||||
>;
|
||||
|
||||
const EMPTY_REPORT_METADATA: ReportFileMetadata = {
|
||||
fileName: '-',
|
||||
regDate: '',
|
||||
fileSn: '',
|
||||
filePath: '',
|
||||
fileName: "-",
|
||||
regDate: "",
|
||||
fileSn: "",
|
||||
filePath: "",
|
||||
fileCount: 0,
|
||||
downloadFileTitle: '',
|
||||
downloadFileTitle: "",
|
||||
};
|
||||
|
||||
// 파일 정보 추가 조회 전에도 존재하는 분류 트리 필드만 전체 데이터 기준으로 정렬한다.
|
||||
const compareReportRows = (left: FlattenedFolder, right: FlattenedFolder, sort: UiTableSort) => {
|
||||
const leftValue = left[sort.key];
|
||||
const rightValue = right[sort.key];
|
||||
const leftEmpty = leftValue === null || leftValue === undefined || String(leftValue).trim() === '';
|
||||
const rightEmpty = rightValue === null || rightValue === undefined || String(rightValue).trim() === '';
|
||||
|
||||
if (leftEmpty || rightEmpty) {
|
||||
if (leftEmpty && rightEmpty) return Number(left.no ?? 0) - Number(right.no ?? 0);
|
||||
return leftEmpty ? 1 : -1;
|
||||
}
|
||||
|
||||
const comparison = sort.key === 'approvalYear'
|
||||
? Number(getApprovalYearNumber(leftValue) ?? 0) - Number(getApprovalYearNumber(rightValue) ?? 0)
|
||||
: REPORT_COLLATOR.compare(String(leftValue), String(rightValue));
|
||||
if (comparison === 0) return Number(left.no ?? 0) - Number(right.no ?? 0);
|
||||
return sort.direction === 'asc' ? comparison : -comparison;
|
||||
};
|
||||
|
||||
const resolveReportSort = (sort?: UiTableSort) => {
|
||||
if (!sort) return undefined;
|
||||
if (DISABLED_REPORT_SORT_KEYS.has(sort.key)) {
|
||||
return undefined;
|
||||
}
|
||||
return sort;
|
||||
};
|
||||
|
||||
const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sggNm }) => {
|
||||
const [isResetPage, setIsResetPage] = useState<boolean>(false);
|
||||
const [isResetSelection, setIsResetSelection] = useState<boolean>(false);
|
||||
const ReportTab: React.FC<ReportTabProps> = ({
|
||||
open,
|
||||
isSearch = true,
|
||||
ctpvNm,
|
||||
sggNm,
|
||||
}) => {
|
||||
const [showTable, setShowTable] = useState(false);
|
||||
const [ctpvCd, setCtpvCd] = useState('0');
|
||||
const [sggCd, setSggCd] = useState('0');
|
||||
const [plantZoneGubun, setPlantZoneGubun] = useState('0');
|
||||
const [reportGubun, setReportGubun] = useState('0');
|
||||
const [ctpvCd, setCtpvCd] = useState("0");
|
||||
const [sggCd, setSggCd] = useState("0");
|
||||
const [plantZoneGubun, setPlantZoneGubun] = useState("0");
|
||||
const [reportGubun, setReportGubun] = useState("0");
|
||||
const hasAutoFetchedRef = useRef(false);
|
||||
const fileMetadataCacheRef = useRef<Map<string, ReportFileMetadata>>(new Map());
|
||||
const fileMetadataPromiseCacheRef = useRef<Map<string, Promise<ReportFileMetadata>>>(new Map());
|
||||
const fileMetadataCacheRef = useRef<Map<string, ReportFileMetadata>>(
|
||||
new Map(),
|
||||
);
|
||||
const fileMetadataPromiseCacheRef = useRef<
|
||||
Map<string, Promise<ReportFileMetadata>>
|
||||
>(new Map());
|
||||
|
||||
const {
|
||||
tableData,
|
||||
@@ -124,17 +126,16 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
|
||||
setIsLoading,
|
||||
setYearFilter,
|
||||
setCurPage,
|
||||
sort,
|
||||
setSort,
|
||||
} = useSearchModalStore();
|
||||
const { ctpv, sgg } = useStore();
|
||||
const {
|
||||
dcTree: reportOriginData,
|
||||
fetchDcTree
|
||||
} = useReportTabStore();
|
||||
const { REPORT_TYPES, flattenFolders, getDcFiles, downloadReportFile } = useReportTab();
|
||||
const { dcTree: reportOriginData, fetchDcTree } = useReportTabStore();
|
||||
const { REPORT_TYPES, flattenFolders, getDcFiles, downloadReportFile } =
|
||||
useReportTab();
|
||||
|
||||
const flattenedReportData = useMemo(() => flattenFolders(reportOriginData), [reportOriginData]);
|
||||
const flattenedReportData = useMemo(
|
||||
() => flattenFolders(reportOriginData),
|
||||
[reportOriginData],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fileMetadataCacheRef.current.clear();
|
||||
@@ -144,55 +145,51 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
|
||||
const uniqueOptions = Array.from(
|
||||
new Set(
|
||||
flattenedReportData
|
||||
.map((item) => String(item.plantZoneGubun ?? '').trim())
|
||||
.filter((item) => item.length > 0)
|
||||
)
|
||||
).sort((a, b) => a.localeCompare(b, 'ko'));
|
||||
.map((item) => String(item.plantZoneGubun ?? "").trim())
|
||||
.filter((item) => item.length > 0),
|
||||
),
|
||||
).sort((a, b) => a.localeCompare(b, "ko"));
|
||||
|
||||
return [
|
||||
{ val: '0', text: '전체' },
|
||||
{ val: "0", text: "전체" },
|
||||
...uniqueOptions.map((item) => ({ val: item, text: item })),
|
||||
];
|
||||
}, [flattenedReportData]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchDcTree('기본계획');
|
||||
void fetchDcTree("기본계획");
|
||||
}, [fetchDcTree]);
|
||||
|
||||
useEffect(() => {
|
||||
hasAutoFetchedRef.current = false;
|
||||
setCtpvCd('0');
|
||||
setSggCd('0');
|
||||
setPlantZoneGubun('0');
|
||||
setReportGubun('0');
|
||||
setYearFilter({ start: '', end: '' });
|
||||
setCtpvCd("0");
|
||||
setSggCd("0");
|
||||
setPlantZoneGubun("0");
|
||||
setReportGubun("0");
|
||||
setYearFilter({ start: "", end: "" });
|
||||
setShowTable(false);
|
||||
setTableData([]);
|
||||
setTableCount(0);
|
||||
setCurPage(1);
|
||||
setSort(undefined);
|
||||
setIsResetPage(true);
|
||||
setIsResetSelection(true);
|
||||
}, [open, setCurPage, setSort, setTableCount, setTableData, setYearFilter]);
|
||||
}, [open, setCurPage, setTableCount, setTableData, setYearFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
const newCtpvCd = ctpv.find((n) => n.text === ctpvNm)?.val || '0';
|
||||
const newSggCd = sgg.find((n) => n.text === sggNm)?.val || '0';
|
||||
const newCtpvCd = ctpv.find((n) => n.text === ctpvNm)?.val || "0";
|
||||
const newSggCd = sgg.find((n) => n.text === sggNm)?.val || "0";
|
||||
|
||||
setCtpvCd(newCtpvCd);
|
||||
setSggCd(newSggCd);
|
||||
}, [ctpv, ctpvNm, sgg, sggNm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || flattenedReportData.length === 0 || hasAutoFetchedRef.current) return;
|
||||
if (!open || flattenedReportData.length === 0 || hasAutoFetchedRef.current)
|
||||
return;
|
||||
|
||||
hasAutoFetchedRef.current = true;
|
||||
void fetchFilteredData(1);
|
||||
}, [flattenedReportData.length, open]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsResetPage(false);
|
||||
|
||||
if (showTable) {
|
||||
void fetchFilteredData();
|
||||
}
|
||||
@@ -200,21 +197,34 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
|
||||
|
||||
const handleCtpvCdChange = (sidoId: string) => {
|
||||
setCtpvCd(sidoId);
|
||||
setSggCd('0');
|
||||
setSggCd("0");
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
hasAutoFetchedRef.current = true;
|
||||
setCurPage(1);
|
||||
void fetchFilteredData(1);
|
||||
setIsResetPage(true);
|
||||
setIsResetSelection(false);
|
||||
setShowTable(true);
|
||||
};
|
||||
|
||||
/** 보고서 조건·페이지·결과를 기본값으로 복원하고 전체 목록을 즉시 재조회한다. */
|
||||
const handleResetSearch = () => {
|
||||
setCtpvCd(DEFAULT_REPORT_SEARCH_FILTERS.ctpvCd);
|
||||
setSggCd(DEFAULT_REPORT_SEARCH_FILTERS.sggCd);
|
||||
setPlantZoneGubun(DEFAULT_REPORT_SEARCH_FILTERS.plantZoneGubun);
|
||||
setReportGubun(DEFAULT_REPORT_SEARCH_FILTERS.reportGubun);
|
||||
setYearFilter({ ...DEFAULT_REPORT_SEARCH_FILTERS.yearFilter });
|
||||
setCurPage(1);
|
||||
setTableData([]);
|
||||
setTableCount(0);
|
||||
setShowTable(false);
|
||||
hasAutoFetchedRef.current = true;
|
||||
void fetchFilteredData(1, DEFAULT_REPORT_SEARCH_FILTERS);
|
||||
};
|
||||
|
||||
const handleReportCellClick = (index: number | undefined, headId: string) => {
|
||||
if (headId !== 'fileName') return;
|
||||
if (typeof index !== 'number' || !tableData[index]) return;
|
||||
if (headId !== "fileName") return;
|
||||
if (typeof index !== "number" || !tableData[index]) return;
|
||||
|
||||
const reportData = tableData[index];
|
||||
if (!reportData.fileSn) return;
|
||||
@@ -229,26 +239,29 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
|
||||
})();
|
||||
};
|
||||
|
||||
const buildReportMetadata = (fileArr: ReportDataFile[]): ReportFileMetadata => {
|
||||
const buildReportMetadata = (
|
||||
fileArr: ReportDataFile[],
|
||||
): ReportFileMetadata => {
|
||||
if (fileArr.length === 0) {
|
||||
return EMPTY_REPORT_METADATA;
|
||||
}
|
||||
|
||||
const primaryFile = fileArr[0];
|
||||
return {
|
||||
fileName: fileArr.length === 1
|
||||
? primaryFile?.fileTitle ?? '-'
|
||||
: `${primaryFile?.fileTitle ?? '대표 파일'} 외 ${fileArr.length - 1}건`,
|
||||
regDate: primaryFile?.regDt ?? '',
|
||||
fileSn: primaryFile?.fileSn ?? '',
|
||||
filePath: primaryFile?.filePath ?? '',
|
||||
fileName:
|
||||
fileArr.length === 1
|
||||
? (primaryFile?.fileTitle ?? "-")
|
||||
: `${primaryFile?.fileTitle ?? "대표 파일"} 외 ${fileArr.length - 1}건`,
|
||||
regDate: primaryFile?.regDt ?? "",
|
||||
fileSn: primaryFile?.fileSn ?? "",
|
||||
filePath: primaryFile?.filePath ?? "",
|
||||
fileCount: fileArr.length,
|
||||
downloadFileTitle: primaryFile?.fileTitle ?? '',
|
||||
downloadFileTitle: primaryFile?.fileTitle ?? "",
|
||||
};
|
||||
};
|
||||
|
||||
const resolveReportMetadata = (item: FlattenedFolder) => {
|
||||
const key = String(item.clsfSystmSn ?? '');
|
||||
const key = String(item.clsfSystmSn ?? "");
|
||||
if (!key) {
|
||||
return Promise.resolve(EMPTY_REPORT_METADATA);
|
||||
}
|
||||
@@ -266,12 +279,15 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
|
||||
const promise = Promise.race<ReportDataFile[] | never>([
|
||||
getDcFiles(key),
|
||||
new Promise<never>((_, reject) => {
|
||||
window.setTimeout(() => reject(new Error(`Report metadata timeout: ${key}`)), REPORT_METADATA_TIMEOUT_MS);
|
||||
window.setTimeout(
|
||||
() => reject(new Error(`Report metadata timeout: ${key}`)),
|
||||
REPORT_METADATA_TIMEOUT_MS,
|
||||
);
|
||||
}),
|
||||
])
|
||||
.then((fileArr) => buildReportMetadata(fileArr))
|
||||
.catch((error) => {
|
||||
console.warn('보고서 파일 메타데이터 조회 실패:', key, error);
|
||||
console.warn("보고서 파일 메타데이터 조회 실패:", key, error);
|
||||
return EMPTY_REPORT_METADATA;
|
||||
})
|
||||
.then((metadata) => {
|
||||
@@ -284,16 +300,20 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
|
||||
return promise;
|
||||
};
|
||||
|
||||
const enrichReportRows = async (data: FlattenedFolder[], concurrency = REPORT_METADATA_CONCURRENCY) => {
|
||||
const enrichReportRows = async (
|
||||
data: FlattenedFolder[],
|
||||
concurrency = REPORT_METADATA_CONCURRENCY,
|
||||
) => {
|
||||
const missingRows = data.filter((item) => {
|
||||
const key = String(item.clsfSystmSn ?? '');
|
||||
const key = String(item.clsfSystmSn ?? "");
|
||||
return key && !fileMetadataCacheRef.current.has(key);
|
||||
});
|
||||
|
||||
if (missingRows.length > 0) {
|
||||
let cursor = 0;
|
||||
const workerCount = Math.min(concurrency, missingRows.length);
|
||||
await Promise.all(Array.from({ length: workerCount }, async () => {
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (cursor < missingRows.length) {
|
||||
const currentIndex = cursor;
|
||||
cursor += 1;
|
||||
@@ -302,47 +322,73 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
|
||||
await resolveReportMetadata(currentRow);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return data.map((item) => ({
|
||||
...item,
|
||||
...(fileMetadataCacheRef.current.get(String(item.clsfSystmSn ?? '')) ?? EMPTY_REPORT_METADATA),
|
||||
...(fileMetadataCacheRef.current.get(String(item.clsfSystmSn ?? "")) ??
|
||||
EMPTY_REPORT_METADATA),
|
||||
}));
|
||||
};
|
||||
|
||||
const fetchFilteredData = async (page = curPage, requestedSort = sort) => {
|
||||
const fetchFilteredData = async (
|
||||
page = curPage,
|
||||
filters: ReportSearchFilters = {
|
||||
ctpvCd,
|
||||
sggCd,
|
||||
plantZoneGubun,
|
||||
reportGubun,
|
||||
yearFilter,
|
||||
},
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
setIsResetSelection(true);
|
||||
|
||||
try {
|
||||
const activeSort = resolveReportSort(requestedSort);
|
||||
let filterReportData = flattenedReportData;
|
||||
|
||||
if (ctpvCd !== '0') {
|
||||
const sidoOption = ctpv.find((n) => String(n.val).trim() === ctpvCd.trim());
|
||||
filterReportData = filterReportData.filter((item) => item.sido === sidoOption?.text);
|
||||
if (filters.ctpvCd !== "0") {
|
||||
const sidoOption = ctpv.find(
|
||||
(n) => String(n.val).trim() === filters.ctpvCd.trim(),
|
||||
);
|
||||
filterReportData = filterReportData.filter(
|
||||
(item) => item.sido === sidoOption?.text,
|
||||
);
|
||||
}
|
||||
if (sggCd !== '0') {
|
||||
const sigunguOption = sgg.find((n) => String(n.val) === String(sggCd));
|
||||
filterReportData = filterReportData.filter((item) => item.sigungu === sigunguOption?.text);
|
||||
if (filters.sggCd !== "0") {
|
||||
const sigunguOption = sgg.find(
|
||||
(n) => String(n.val) === String(filters.sggCd),
|
||||
);
|
||||
filterReportData = filterReportData.filter(
|
||||
(item) => item.sigungu === sigunguOption?.text,
|
||||
);
|
||||
}
|
||||
if (plantZoneGubun !== '0') {
|
||||
filterReportData = filterReportData.filter((item) => String(item.plantZoneGubun ?? '') === plantZoneGubun);
|
||||
if (filters.plantZoneGubun !== "0") {
|
||||
filterReportData = filterReportData.filter(
|
||||
(item) =>
|
||||
String(item.plantZoneGubun ?? "") === filters.plantZoneGubun,
|
||||
);
|
||||
}
|
||||
if (reportGubun !== '0') {
|
||||
const reportGubunOption = REPORT_TYPES.find((n) => n.val === reportGubun);
|
||||
filterReportData = filterReportData.filter((item) => item.reportGubun === reportGubunOption?.text);
|
||||
if (filters.reportGubun !== "0") {
|
||||
const reportGubunOption = REPORT_TYPES.find(
|
||||
(n) => n.val === filters.reportGubun,
|
||||
);
|
||||
filterReportData = filterReportData.filter(
|
||||
(item) => item.reportGubun === reportGubunOption?.text,
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedStartYear = normalizeYear(yearFilter.start);
|
||||
const normalizedEndYear = normalizeYear(yearFilter.end);
|
||||
const normalizedStartYear = normalizeYear(filters.yearFilter.start);
|
||||
const normalizedEndYear = normalizeYear(filters.yearFilter.end);
|
||||
if (normalizedStartYear || normalizedEndYear) {
|
||||
filterReportData = filterReportData.filter((item) => {
|
||||
const approvalYear = getApprovalYearNumber(item.approvalYear);
|
||||
if (approvalYear === null) return false;
|
||||
if (normalizedStartYear && approvalYear < Number(normalizedStartYear)) return false;
|
||||
if (normalizedEndYear && approvalYear > Number(normalizedEndYear)) return false;
|
||||
if (normalizedStartYear && approvalYear < Number(normalizedStartYear))
|
||||
return false;
|
||||
if (normalizedEndYear && approvalYear > Number(normalizedEndYear))
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -355,73 +401,58 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
|
||||
const pageEnd = page * REPORT_PAGE_SIZE;
|
||||
|
||||
let reportRows: FlattenedFolder[];
|
||||
const sortedReportData = activeSort
|
||||
? [...filterReportData].sort((left, right) => compareReportRows(left, right, activeSort))
|
||||
: filterReportData;
|
||||
|
||||
const pagedReportData = sortedReportData
|
||||
const pagedReportData = filterReportData
|
||||
.slice(pageStart, pageEnd)
|
||||
.map((item, index) => ({
|
||||
...item,
|
||||
no: activeSort?.key === 'no' && activeSort.direction === 'desc'
|
||||
? sortedReportData.length - (pageOffset + index)
|
||||
: pageOffset + index + 1,
|
||||
no: totalCount - (pageOffset + index),
|
||||
}));
|
||||
|
||||
reportRows = await enrichReportRows(pagedReportData);
|
||||
|
||||
setTableData(reportRows || []);
|
||||
setShowTable(true);
|
||||
setIsResetSelection(false);
|
||||
} catch (err) {
|
||||
console.error('API ERROR:', err);
|
||||
console.error("API ERROR:", err);
|
||||
setTableData([]);
|
||||
setIsResetSelection(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 보고서 분류 원본 전체를 다시 정렬한 뒤 첫 페이지를 표시한다.
|
||||
const handleSortChange = (nextSort?: UiTableSort) => {
|
||||
setSort(nextSort);
|
||||
setIsResetPage(true);
|
||||
setIsResetSelection(true);
|
||||
if (curPage === 1) {
|
||||
void fetchFilteredData(1, nextSort);
|
||||
return;
|
||||
}
|
||||
setCurPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative mt-4 rounded-lg border px-5 pt-5 pb-3">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="flex min-w-max items-center gap-4 whitespace-nowrap">
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
|
||||
{isSearch && (
|
||||
<CtpvSggPrcsFcltSelectBox
|
||||
className="shrink-0"
|
||||
sido={ctpvCd}
|
||||
sigungu={sggCd}
|
||||
onSidoChange={(sidoId: string) => { handleCtpvCdChange(sidoId); }}
|
||||
onSidoChange={(sidoId: string) => {
|
||||
handleCtpvCdChange(sidoId);
|
||||
}}
|
||||
onSigunguChange={setSggCd}
|
||||
setIsLoading={setIsLoading}
|
||||
/>
|
||||
)}
|
||||
<YearRangeSelectBox
|
||||
className="shrink-0 !mr-1"
|
||||
className="!mr-1 shrink-0"
|
||||
selectClass="w-[78px] flex-shrink-0 rounded-lg bg-field text-foreground"
|
||||
start={yearFilter.start}
|
||||
end={yearFilter.end}
|
||||
onChange={(range) => setYearFilter({
|
||||
onChange={(range) =>
|
||||
setYearFilter({
|
||||
start: normalizeYear(range.start),
|
||||
end: normalizeYear(range.end),
|
||||
})}
|
||||
})
|
||||
}
|
||||
startLabel="승인년도"
|
||||
endLabel=""
|
||||
/>
|
||||
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">구분</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-xl font-medium text-label">
|
||||
구분
|
||||
</Label>
|
||||
<SelectBox
|
||||
className="w-36 shrink-0"
|
||||
selectClass="rounded-lg bg-field text-foreground"
|
||||
@@ -431,7 +462,11 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
|
||||
onSelectValue={(val) => setPlantZoneGubun(String(val))}
|
||||
selectContentClass="z-[101]"
|
||||
/>
|
||||
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">보고서 구분</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-xl font-medium text-label">
|
||||
보고서 구분
|
||||
</Label>
|
||||
<SelectBox
|
||||
className="w-36 shrink-0"
|
||||
selectClass="rounded-lg bg-field text-foreground"
|
||||
@@ -441,43 +476,35 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
|
||||
onSelectValue={(val) => setReportGubun(String(val))}
|
||||
selectContentClass="z-[101]"
|
||||
/>
|
||||
<div className="ml-auto shrink-0 pl-4">
|
||||
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 sm:ml-auto sm:pl-4">
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSearch}>조회</Button>
|
||||
<Button color="white" onClick={handleResetSearch}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopupSearchPanel>
|
||||
|
||||
<div className="mt-3">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<UiTable
|
||||
className="!m-0"
|
||||
<div>
|
||||
<MapPopupDataTable
|
||||
rows={tableData as Array<FlattenedFolder & Record<string, unknown>>}
|
||||
clickableHeadIds={["fileName"]}
|
||||
headName={<>조회 결과</>}
|
||||
tableData={tableData}
|
||||
title="보고서 목록"
|
||||
headlist={reportTableHeadlist}
|
||||
pageSize={10}
|
||||
total={tableCount}
|
||||
cellClick={handleReportCellClick}
|
||||
cellClassName={(row, headId) => {
|
||||
if (headId !== 'fileName') return '';
|
||||
if (!row.fileSn) return '!text-gray-400';
|
||||
return '!text-left !text-blue-600 underline underline-offset-4 hover:!text-blue-800';
|
||||
}}
|
||||
onCellClick={(_row, index, headId) =>
|
||||
handleReportCellClick(index, headId)
|
||||
}
|
||||
onPageChange={setCurPage}
|
||||
resetPageNo={isResetPage}
|
||||
resetSelection={isResetSelection}
|
||||
curPage={curPage}
|
||||
sort={sort}
|
||||
onSortChange={handleSortChange}
|
||||
page={curPage}
|
||||
rowId={(row, index) => String(row.clsfSystmSn ?? index)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ReportTab);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -11,123 +11,68 @@
|
||||
* - Zustand를 통한 검색 모달 상태 관리
|
||||
* - 로딩 오버레이 및 선택 초기화 지원
|
||||
*/
|
||||
'use client';
|
||||
import LoadingOverlay from '@/components/ui/LoadingBar';
|
||||
"use client";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { UITab } from "@common/business_components/ui/tab";
|
||||
import { UiTable } from "@common/business_components/ui/table";
|
||||
import { Button } from '@common/components/ui';
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import useSearchModalStore from '@/store/useSearchModalStore';
|
||||
import { ExclnSewagePumpCol2, FldngWtlvMapCol2, MsSwageMhlCol2, MsSwagePplCol2, stofcbrCol2, SwtrmtFcltCol2, SwtrmtPrcsZoneCol2 } from '../components/common/table';
|
||||
import BasicTab, { BASIC_TYPES } from './basicTab';
|
||||
import OperationTab from './operationTab';
|
||||
import ReportTab from './reportTab';
|
||||
import useSearchModal from './useSearchModal';
|
||||
import useBasicTab from './useBasicTab';
|
||||
import useOperationTab from './useOperationTab';
|
||||
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
|
||||
import type {
|
||||
UiHeadProps,
|
||||
UiTableSort,
|
||||
} from "@common/business_components/ui/table";
|
||||
import { StandardPopupLayout } from "@common/business_components";
|
||||
import { Button } from "@common/components/ui";
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import useSearchModalStore from "@/store/useSearchModalStore";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
|
||||
import {
|
||||
ExclnSewagePumpCol2,
|
||||
FldngWtlvMapCol2,
|
||||
MsSwageMhlCol2,
|
||||
MsSwagePplCol2,
|
||||
stofcbrCol2,
|
||||
SwtrmtFcltCol2,
|
||||
SwtrmtPrcsZoneCol2,
|
||||
} from "../components/common/table";
|
||||
import BasicTab, { BASIC_TYPES } from "./basicTab";
|
||||
import OperationTab from "./operationTab";
|
||||
import ReportTab from "./reportTab";
|
||||
import useSearchModal from "./useSearchModal";
|
||||
import useBasicTab from "./useBasicTab";
|
||||
import useOperationTab from "./useOperationTab";
|
||||
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
|
||||
import type { UiHeadProps } from "@common/business_components/ui/table";
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
|
||||
type SearchModalProps = {
|
||||
renderMode?: 'launcher' | 'popup';
|
||||
renderMode?: "launcher" | "popup";
|
||||
autoSearchReady?: boolean;
|
||||
};
|
||||
|
||||
// 기본정보 표시 필드 중 GeoServer 원천 속성으로 정확히 정렬할 수 있는 필드만 매핑한다.
|
||||
const BASIC_SORT_KEYS: Record<string, Record<string, string>> = {
|
||||
tb_ms_swage_ppl: {
|
||||
rowNumber: 'rowNumber',
|
||||
fcltMngNo: 'fclt_mng_no', instMngNo: 'inst_mng_no', instlYmd: 'instl_ymd',
|
||||
ctpvNm: 'pbadms_emd_cd',
|
||||
sggNm: 'pbadms_emd_cd', swtrmt_zone_nm: 'swtrmt_zone_nm', usgCd: 'usg_cd',
|
||||
pdmtVl: 'pdmt_vl', prlgVl: 'prlg_vl', tmptDpth: 'tmpt_dpth',
|
||||
tmptMhlMngNo: 'tmpt_mhl_mng_no', scendDpth: 'scend_dpth',
|
||||
scendMhlMngNo: 'scend_mhl_mng_no', vrtcLen: 'vrtc_len', wdthLen: 'wdth_len',
|
||||
fcltyShpCd: 'fclty_shp_cd', mqltCd: 'mqlt_cd',
|
||||
},
|
||||
tb_il_swage_prcs_fclt: {
|
||||
rowNumber: 'rowNumber',
|
||||
swtrmtFcltNm: 'swtrmt_fclt_nm', fcltCpct: 'fclt_cpct', rgnExpln: 'rgn_expln',
|
||||
oprtngStrtYmd: 'oprtng_strt_ymd', laeNm: 'lae_nm', sareaNm: 'sarea_nm',
|
||||
rvrNm: 'rvr_nm', dschgWtstNm: 'dschg_wtst_nm', swtrmtPpltnCnt: 'swtrmt_ppltn_cnt',
|
||||
rprsMsrcCn: 'rprs_msrc_cn',
|
||||
},
|
||||
tb_ms_swage_prcs_zone: {
|
||||
rowNumber: 'rowNumber',
|
||||
swtrmtZoneNm: 'swtrmt_zone_nm', ctpvNm: 'ctpv_nm', sggNm: 'sgg_nm',
|
||||
swtrmtFcltNm: 'swtrmt_fclt_nm',
|
||||
},
|
||||
tb_ms_fldng_wtlv_map: {
|
||||
rowNumber: 'rowNumber',
|
||||
fldngYr: 'fldng_yr', fldngDstrNm: 'fldng_dstr_nm', ctpvNm: 'stdg_cd', sggNm: 'stdg_cd',
|
||||
bgngYmd: 'bgng_ymd', fldngBgngHm: 'fldng_bgng_hm', endYmd: 'end_ymd', fldngEndHm: 'fldng_end_hm',
|
||||
fldngHr: 'fldng_hr', fldngDstrctNm: 'fldng_dstrct_nm', fldngCsDtlCd: 'fldng_cs_dtl_cd',
|
||||
avgFldngWtlv: 'avg_fldng_wtlv', fldngArea: 'fldng_area', exmnYr: 'exmn_yr', damAmt: 'dam_amt',
|
||||
},
|
||||
tb_ms_swage_mhl: {
|
||||
rowNumber: 'rowNumber',
|
||||
fcltMngNo: 'fclt_mng_no', instlYmd: 'instl_ymd', swageMhlDiamVl: 'swage_mhl_diam_vl',
|
||||
ctpvNm: 'pbadms_emd_cd', sggNm: 'pbadms_emd_cd', usgCd: 'usg_cd',
|
||||
wdthLen: 'wdth_len', vrtcLen: 'vrtc_len', altdVl: 'altd_vl', dpthlVl: 'dpthl_vl',
|
||||
},
|
||||
tb_ms_swage_pmstn: {
|
||||
rowNumber: 'rowNumber',
|
||||
fcltMngNo: 'fclt_mng_no', swagePmstnNm: 'swage_pmstn_nm', instlYmd: 'instl_ymd',
|
||||
ctpvNm: 'pbadms_emd_cd', sggNm: 'pbadms_emd_cd',
|
||||
dailyPrcsCpct: 'daily_prcs_cpct', maxImpndCpct: 'max_impnd_cpct', altdVl: 'altd_vl',
|
||||
wtlvVl: 'wtlv_vl', exclnWpmpAbltVl: 'excln_wpmp_ablt_vl',
|
||||
},
|
||||
tb_ms_stofcbr: {
|
||||
rowNumber: 'rowNumber',
|
||||
fcltMngNo: 'fclt_mng_no', mapshtNo: 'mapsht_no', instlYmd: 'instl_ymd',
|
||||
ctpvNm: 'pbadms_emd_cd', sggNm: 'pbadms_emd_cd',
|
||||
worflwWallHgt: 'worflw_wall_hgt', swageMhlMngNo: 'swage_mhl_mng_no', drtnAngl: 'drtn_angl',
|
||||
},
|
||||
};
|
||||
|
||||
const OPERATION_LIST_SORT_KEYS: Record<string, string> = {
|
||||
rowNumber: 'rowNumber', bizInfoNm: 'bizInfoNm', flfmtBgngYmd: 'flfmtBgngYmd',
|
||||
flfmtEndYmd: 'flfmtEndYmd', flfmtYr: 'flfmtYr', bizTypeCd: 'bizTypeCd', bizAmt: 'bizAmt',
|
||||
};
|
||||
|
||||
const OPERATION_DETAIL_SORT_KEYS: Record<string, string> = {
|
||||
rowNumber: 'rowNumber',
|
||||
fcltMngNo: 'fclt_mng_no',
|
||||
swtrmt_zone_nm: 'swtrmt_zone_nm',
|
||||
usgCd: 'usg_cd',
|
||||
mqltCd: 'mqlt_cd',
|
||||
pdmtVl: 'pdmt_vl',
|
||||
prlgVl: 'prlg_vl',
|
||||
instlYmd: 'instl_ymd',
|
||||
strc_stts_grd: 'strc_stts_grd',
|
||||
oper_stts_grd: 'oper_stts_grd',
|
||||
};
|
||||
|
||||
const SearchModal: React.FC<SearchModalProps> = ({ renderMode = 'launcher', autoSearchReady = false }) => {
|
||||
const SearchModal: React.FC<SearchModalProps> = ({
|
||||
renderMode = "launcher",
|
||||
autoSearchReady = false,
|
||||
}) => {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const {
|
||||
showTable,
|
||||
isSearchModalOpen, setIsSearchModalOpen,
|
||||
activeTab, basicType, workType,
|
||||
tableData, detailTableData,
|
||||
tableCount, detailTableCount,
|
||||
curPage, detailCurPage,
|
||||
isSearchModalOpen,
|
||||
setIsSearchModalOpen,
|
||||
activeTab,
|
||||
basicType,
|
||||
workType,
|
||||
tableData,
|
||||
detailTableData,
|
||||
tableCount,
|
||||
detailTableCount,
|
||||
curPage,
|
||||
detailCurPage,
|
||||
detailBizId,
|
||||
isLoading, isDetailLoading,
|
||||
isResetPage, isResetDetailPage,
|
||||
isResetSelection, isResetDetailSelection,
|
||||
isLoading,
|
||||
isDetailLoading,
|
||||
setActiveTab,
|
||||
setCurPage, setDetailCurPage,
|
||||
setIsResetPage, setIsResetDetailPage,
|
||||
setIsResetSelection,
|
||||
setCurPage,
|
||||
setDetailCurPage,
|
||||
setIsResetPage,
|
||||
setIsResetDetailPage,
|
||||
setDetailBizId,
|
||||
setIsResetDetailSelection,
|
||||
sort, detailSort,
|
||||
setSort, setDetailSort,
|
||||
resetFilters
|
||||
resetFilters,
|
||||
} = useSearchModalStore();
|
||||
const { TABS, viewMultipleFeature, fetchFilteredData } = useSearchModal();
|
||||
const { handleBasicCellClick, basicExcelDownload } = useBasicTab();
|
||||
@@ -142,97 +87,95 @@ const SearchModal: React.FC<SearchModalProps> = ({ renderMode = 'launcher', auto
|
||||
handleOperationResultCellClick,
|
||||
operationExcelDownload,
|
||||
operationResultExcelDownload,
|
||||
handleOperationPageNation
|
||||
handleOperationPageNation,
|
||||
} = useOperationTab();
|
||||
const hasAutoSearchedRef = useRef(false);
|
||||
const hasInitializedTabAutoSearchRef = useRef(false);
|
||||
|
||||
const dynamicHeadlist: UiHeadProps[] = useMemo(() => {
|
||||
if (activeTab === 'basic') {
|
||||
const headlist = basicType === 'tb_ms_swage_ppl' ? MsSwagePplCol2
|
||||
: basicType === 'tb_il_swage_prcs_fclt' ? SwtrmtFcltCol2
|
||||
: basicType === 'tb_ms_swage_prcs_zone' ? SwtrmtPrcsZoneCol2
|
||||
: basicType === 'tb_ms_fldng_wtlv_map' ? FldngWtlvMapCol2
|
||||
: basicType === 'tb_ms_swage_mhl' ? MsSwageMhlCol2
|
||||
: basicType === 'tb_ms_swage_pmstn' ? ExclnSewagePumpCol2
|
||||
: basicType === 'tb_ms_stofcbr' ? stofcbrCol2 : [];
|
||||
const sortKeys = BASIC_SORT_KEYS[basicType] ?? {};
|
||||
return headlist.map((head) => sortKeys[head.id]
|
||||
? { ...head, sortable: true, sortKey: sortKeys[head.id] }
|
||||
: head);
|
||||
if (activeTab === "basic") {
|
||||
const headlist =
|
||||
basicType === "tb_ms_swage_ppl"
|
||||
? MsSwagePplCol2
|
||||
: basicType === "tb_il_swage_prcs_fclt"
|
||||
? SwtrmtFcltCol2
|
||||
: basicType === "tb_ms_swage_prcs_zone"
|
||||
? SwtrmtPrcsZoneCol2
|
||||
: basicType === "tb_ms_fldng_wtlv_map"
|
||||
? FldngWtlvMapCol2
|
||||
: basicType === "tb_ms_swage_mhl"
|
||||
? MsSwageMhlCol2
|
||||
: basicType === "tb_ms_swage_pmstn"
|
||||
? ExclnSewagePumpCol2
|
||||
: basicType === "tb_ms_stofcbr"
|
||||
? stofcbrCol2
|
||||
: [];
|
||||
return headlist.map((head) => ({
|
||||
...head,
|
||||
title: head.id === "rowNumber" ? "순번" : head.title,
|
||||
}));
|
||||
}
|
||||
if (activeTab === 'operation') {
|
||||
return OPERATION_TABLE_HEADLIST.map((head) => OPERATION_LIST_SORT_KEYS[head.id]
|
||||
? { ...head, sortable: true, sortKey: OPERATION_LIST_SORT_KEYS[head.id] }
|
||||
: head);
|
||||
if (activeTab === "operation") {
|
||||
return OPERATION_TABLE_HEADLIST.map((head) => ({
|
||||
...head,
|
||||
title: head.id === "rowNumber" ? "순번" : head.title,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [activeTab, basicType, workType, OPERATION_TABLE_HEADLIST]);
|
||||
|
||||
const dynamicHeadlist2: UiHeadProps[] = useMemo(() => {
|
||||
if (activeTab === 'operation') {
|
||||
const headlist = workType === WORK_TYPES[0]?.val ? MAINTENANACE_TABLE_HEADLIST
|
||||
: workType === WORK_TYPES[1]?.val ? CLEANING_TABLE_HEADLIST
|
||||
: workType === WORK_TYPES[2]?.val ? INSPECTION_TABLE_HEADLIST : [];
|
||||
return headlist.map((head) => OPERATION_DETAIL_SORT_KEYS[head.id]
|
||||
? { ...head, sortable: true, sortKey: OPERATION_DETAIL_SORT_KEYS[head.id] }
|
||||
: head);
|
||||
if (activeTab === "operation") {
|
||||
const headlist =
|
||||
workType === WORK_TYPES[0]?.val
|
||||
? MAINTENANACE_TABLE_HEADLIST
|
||||
: workType === WORK_TYPES[1]?.val
|
||||
? CLEANING_TABLE_HEADLIST
|
||||
: workType === WORK_TYPES[2]?.val
|
||||
? INSPECTION_TABLE_HEADLIST
|
||||
: [];
|
||||
return headlist.map((head) => ({
|
||||
...head,
|
||||
title: head.id === "rowNumber" ? "순번" : head.title,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [activeTab, workType, WORK_TYPES, MAINTENANACE_TABLE_HEADLIST, CLEANING_TABLE_HEADLIST, INSPECTION_TABLE_HEADLIST]);
|
||||
|
||||
// 목록 정렬 변경 시 첫 페이지에서 같은 검색조건으로 다시 조회한다.
|
||||
const handleSortChange = (nextSort?: UiTableSort) => {
|
||||
setSort(nextSort);
|
||||
setIsResetPage(true);
|
||||
setIsResetSelection(true);
|
||||
setIsResetDetailPage(true);
|
||||
setDetailSort(undefined);
|
||||
setDetailBizId(0);
|
||||
if (curPage === 1) {
|
||||
void fetchFilteredData(nextSort);
|
||||
return;
|
||||
}
|
||||
setCurPage(1);
|
||||
};
|
||||
|
||||
// 유지관리 상세 정렬은 상단 사업 목록과 분리하고 선택된 사업 범위만 재조회한다.
|
||||
const handleDetailSortChange = (nextSort?: UiTableSort) => {
|
||||
setDetailSort(nextSort);
|
||||
setIsResetDetailPage(true);
|
||||
setIsResetDetailSelection(true);
|
||||
if (!detailBizId) {
|
||||
return;
|
||||
}
|
||||
if (detailCurPage === 1) {
|
||||
void handleOperationPageNation(Number(detailBizId), nextSort);
|
||||
return;
|
||||
}
|
||||
setDetailCurPage(1);
|
||||
};
|
||||
}, [
|
||||
activeTab,
|
||||
workType,
|
||||
WORK_TYPES,
|
||||
MAINTENANACE_TABLE_HEADLIST,
|
||||
CLEANING_TABLE_HEADLIST,
|
||||
INSPECTION_TABLE_HEADLIST,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (renderMode !== 'launcher' || !isSearchModalOpen) return;
|
||||
if (renderMode !== "launcher" || !isSearchModalOpen) return;
|
||||
|
||||
const popupWindow = openWindowPopup('mapSearch', {
|
||||
const popupWindow = openWindowPopup("mapSearch", {
|
||||
sourcePath: window.location.pathname,
|
||||
});
|
||||
|
||||
if (!popupWindow) {
|
||||
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
|
||||
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
|
||||
}
|
||||
|
||||
setIsSearchModalOpen(false);
|
||||
}, [isSearchModalOpen, renderMode, setIsSearchModalOpen]);
|
||||
}, [alert, isSearchModalOpen, renderMode, setIsSearchModalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
resetFilters();
|
||||
}, [activeTab, basicType, workType, resetFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (renderMode !== 'popup' || !autoSearchReady || hasAutoSearchedRef.current) return;
|
||||
if (
|
||||
renderMode !== "popup" ||
|
||||
!autoSearchReady ||
|
||||
hasAutoSearchedRef.current
|
||||
)
|
||||
return;
|
||||
|
||||
hasAutoSearchedRef.current = true;
|
||||
hasInitializedTabAutoSearchRef.current = true;
|
||||
@@ -240,9 +183,9 @@ const SearchModal: React.FC<SearchModalProps> = ({ renderMode = 'launcher', auto
|
||||
}, [autoSearchReady, fetchFilteredData, renderMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (renderMode !== 'popup' || !autoSearchReady) return;
|
||||
if (renderMode !== "popup" || !autoSearchReady) return;
|
||||
if (!hasInitializedTabAutoSearchRef.current) return;
|
||||
if (activeTab === 'report') return;
|
||||
if (activeTab === "report") return;
|
||||
|
||||
setCurPage(1);
|
||||
setIsResetPage(true);
|
||||
@@ -270,94 +213,101 @@ const SearchModal: React.FC<SearchModalProps> = ({ renderMode = 'launcher', auto
|
||||
<>
|
||||
<UITab
|
||||
defaultValue={activeTab}
|
||||
variant="underline"
|
||||
className="shrink-0"
|
||||
tabList={TABS.map((tab: any) => ({
|
||||
value: tab.val,
|
||||
label: tab.text
|
||||
label: tab.text,
|
||||
}))}
|
||||
onValueChange={(value: string) => setActiveTab(value)}
|
||||
/>
|
||||
{activeTab === 'basic' && (<BasicTab />)}
|
||||
{activeTab === 'operation' && (<OperationTab />)}
|
||||
{activeTab === 'report' && (<ReportTab open={activeTab === 'report'} />)}
|
||||
<LoadingOverlay isLoading={isLoading || isDetailLoading} />
|
||||
{activeTab === "basic" && <BasicTab />}
|
||||
{activeTab === "operation" && <OperationTab />}
|
||||
{activeTab === "report" && <ReportTab open={activeTab === "report"} />}
|
||||
|
||||
{activeTab === 'basic' && (
|
||||
<div className="mt-4 h-[79dvh] w-full overflow-x-hidden overflow-y-auto rounded-lg border">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<UiTable
|
||||
className='overflow-x-auto [&_table]:min-w-full [&_table]:w-max'
|
||||
headName={(() => {
|
||||
const label = BASIC_TYPES.find((type) => type.val === basicType)?.text;
|
||||
return (<>{label} 조회 결과</>);
|
||||
{activeTab === "basic" && (
|
||||
<div className="w-full">
|
||||
<MapPopupDataTable
|
||||
rows={tableData as Array<Record<string, unknown>>}
|
||||
title={(() => {
|
||||
const label = BASIC_TYPES.find(
|
||||
(type) => type.val === basicType,
|
||||
)?.text;
|
||||
return <>{label} 목록</>;
|
||||
})()}
|
||||
tableData={tableData}
|
||||
headlist={dynamicHeadlist}
|
||||
pageSize={10}
|
||||
total={tableCount}
|
||||
cellClick={handleBasicCellClick}
|
||||
onCellClick={(_row, index, headId) =>
|
||||
void handleBasicCellClick(index, headId)
|
||||
}
|
||||
onPageChange={setCurPage}
|
||||
resetPageNo={isResetPage}
|
||||
resetSelection={isResetSelection}
|
||||
curPage={curPage}
|
||||
sort={sort}
|
||||
onSortChange={handleSortChange}
|
||||
headButton={
|
||||
<div className='flex'>
|
||||
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium primary" size="sm" onClick={viewMultipleFeature}>지도에서 보기</Button>
|
||||
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium green" size="sm" onClick={basicExcelDownload}>파일 다운로드</Button>
|
||||
page={curPage}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="160rem"
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={basicExcelDownload}>엑셀다운로드</Button>
|
||||
<Button onClick={viewMultipleFeature}>지도에서 보기</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'operation' && (
|
||||
{activeTab === "operation" && (
|
||||
<>
|
||||
<div className="mt-4 h-[39dvh] w-full overflow-x-hidden overflow-y-auto rounded-lg border">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<UiTable
|
||||
headName={(() => {
|
||||
const label = WORK_TYPES.find((type) => type.val === workType)?.text;
|
||||
return (<>{label} 사업 현황</>);
|
||||
<div className="w-full">
|
||||
<MapPopupDataTable
|
||||
rows={tableData as Array<Record<string, unknown>>}
|
||||
title={(() => {
|
||||
const label = WORK_TYPES.find(
|
||||
(type) => type.val === workType,
|
||||
)?.text;
|
||||
return <>{label} 사업 목록</>;
|
||||
})()}
|
||||
tableData={tableData}
|
||||
headlist={dynamicHeadlist}
|
||||
pageSize={OPERATION_TABLE_SIZE}
|
||||
total={tableCount}
|
||||
cellClick={handleOperationCellClick}
|
||||
onCellClick={(_row, index, headId) =>
|
||||
void handleOperationCellClick(index, headId)
|
||||
}
|
||||
onPageChange={setCurPage}
|
||||
resetPageNo={isResetPage}
|
||||
resetSelection={isResetSelection}
|
||||
curPage={curPage}
|
||||
sort={sort}
|
||||
onSortChange={handleSortChange}
|
||||
headButton={
|
||||
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-sm font-medium green" size="sm" onClick={operationExcelDownload}>파일 다운로드</Button>
|
||||
page={curPage}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="140rem"
|
||||
actions={
|
||||
<Button onClick={operationExcelDownload}>엑셀다운로드</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-[39dvh] w-full overflow-x-hidden overflow-y-auto rounded-lg border">
|
||||
<LoadingOverlay isLoading={isDetailLoading} />
|
||||
<UiTable
|
||||
headName={(() => {
|
||||
const label = WORK_TYPES.find((type) => type.val === workType)?.text;
|
||||
return (<>{label} 결과표</>);
|
||||
<div className="w-full">
|
||||
<MapPopupDataTable
|
||||
rows={detailTableData as Array<Record<string, unknown>>}
|
||||
title={(() => {
|
||||
const label = WORK_TYPES.find(
|
||||
(type) => type.val === workType,
|
||||
)?.text;
|
||||
return <>{label} 결과 목록</>;
|
||||
})()}
|
||||
tableData={detailTableData}
|
||||
headlist={dynamicHeadlist2}
|
||||
pageSize={OPERATION_TABLE_SIZE}
|
||||
total={detailTableCount}
|
||||
cellClick={handleOperationResultCellClick}
|
||||
onCellClick={(_row, index, headId) =>
|
||||
void handleOperationResultCellClick(index, headId)
|
||||
}
|
||||
onPageChange={setDetailCurPage}
|
||||
resetPageNo={isResetDetailPage}
|
||||
resetSelection={isResetDetailSelection}
|
||||
curPage={detailCurPage}
|
||||
sort={detailSort}
|
||||
onSortChange={handleDetailSortChange}
|
||||
headButton={
|
||||
<div className='flex'>
|
||||
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium primary" size="sm" onClick={viewMultipleFeature}>지도에서 보기</Button>
|
||||
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium green" size="sm" onClick={operationResultExcelDownload}>파일 다운로드</Button>
|
||||
page={detailCurPage}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="160rem"
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={operationResultExcelDownload}>
|
||||
엑셀다운로드
|
||||
</Button>
|
||||
<Button onClick={viewMultipleFeature}>지도에서 보기</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -367,20 +317,18 @@ const SearchModal: React.FC<SearchModalProps> = ({ renderMode = 'launcher', auto
|
||||
</>
|
||||
);
|
||||
|
||||
if (renderMode === 'launcher') {
|
||||
if (renderMode === "launcher") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='h-dvh overflow-x-auto overflow-y-hidden bg-container p-4 text-foreground'>
|
||||
<h1 className="mb-3 text-2xl font-semibold text-foreground">관망도 검색</h1>
|
||||
<div className="h-[calc(100dvh-5.6rem)] w-full overflow-y-hidden rounded-lg border border-border bg-background p-4">
|
||||
{renderBody()}
|
||||
</div>
|
||||
<Toaster position="top-center" />
|
||||
</div>
|
||||
<StandardPopupLayout
|
||||
title={windowPopupDefinitions.mapSearch.title}
|
||||
bodyClassName="overflow-x-hidden"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-3">{renderBody()}</div>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(SearchModal);
|
||||
|
||||
|
||||
@@ -10,58 +10,51 @@
|
||||
* - Zustand를 통한 상태 관리 및 필터 값 저장
|
||||
* - Enter 키 입력 시 자동 조회 지원
|
||||
*/
|
||||
import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox';
|
||||
import { Button } from '@common/components';
|
||||
import React from 'react';
|
||||
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
|
||||
import useSearchModalStore from '../../../store/useSearchModalStore';
|
||||
import useSearchModal from './useSearchModal';
|
||||
|
||||
import { YearRangeSelectBox } from "@/components/ui/YearRangeSelectBox";
|
||||
import { Button } from "@common/components";
|
||||
import React from "react";
|
||||
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
|
||||
import useSearchModalStore from "../../../store/useSearchModalStore";
|
||||
import useSearchModal from "./useSearchModal";
|
||||
|
||||
export const stofcbrCqlFilters = () => {
|
||||
const {
|
||||
ctpvCd, sggCd,
|
||||
ctpvCd,
|
||||
sggCd,
|
||||
yearFilter,
|
||||
todayYear, todayMonth, todayDate,
|
||||
getDateString
|
||||
todayYear,
|
||||
todayMonth,
|
||||
todayDate,
|
||||
getDateString,
|
||||
} = useSearchModalStore.getState();
|
||||
|
||||
const cqlFilters: string[] = [];
|
||||
// 시도, 시군구
|
||||
if (ctpvCd !== '0' && sggCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`);
|
||||
else if (ctpvCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`);
|
||||
if (ctpvCd !== "0" && sggCd !== "0")
|
||||
cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`);
|
||||
else if (ctpvCd !== "0")
|
||||
cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`);
|
||||
// 설치년도 범위
|
||||
const startDate = getDateString(
|
||||
yearFilter.start,
|
||||
'',
|
||||
'',
|
||||
"19000101",
|
||||
);
|
||||
const startDate = getDateString(yearFilter.start, "", "", "19000101");
|
||||
const endDate = getDateString(
|
||||
yearFilter.end,
|
||||
'',
|
||||
'',
|
||||
"",
|
||||
"",
|
||||
`${todayYear}${todayMonth}${todayDate}`,
|
||||
);
|
||||
cqlFilters.push(
|
||||
`instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`,
|
||||
);
|
||||
cqlFilters.push(`instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`);
|
||||
|
||||
return cqlFilters;
|
||||
};
|
||||
|
||||
const Stofcbr: React.FC = () => {
|
||||
const {
|
||||
ctpvCd, setCtpvCd,
|
||||
sggCd, setSggCd,
|
||||
yearFilter, setYearFilter,
|
||||
} = useSearchModalStore();
|
||||
const { handleSearch } = useSearchModal();
|
||||
|
||||
const { ctpvCd, setCtpvCd, sggCd, setSggCd, yearFilter, setYearFilter } =
|
||||
useSearchModalStore();
|
||||
const { handleSearch, handleResetSearch } = useSearchModal();
|
||||
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center mb-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
|
||||
{/* 시도, 시군구 */}
|
||||
<CtpvSggPrcsFcltSelectBox
|
||||
sido={ctpvCd}
|
||||
@@ -76,13 +69,18 @@ const Stofcbr: React.FC = () => {
|
||||
<YearRangeSelectBox
|
||||
start={yearFilter.start}
|
||||
end={yearFilter.end}
|
||||
onChange={range => setYearFilter({ ...yearFilter, start: range.start, end: range.end })}
|
||||
onChange={(range) =>
|
||||
setYearFilter({ ...yearFilter, start: range.start, end: range.end })
|
||||
}
|
||||
startLabel="설치년도"
|
||||
endLabel=""
|
||||
/>
|
||||
{/* 조회 버튼 */}
|
||||
<div className="flex-1 flex justify-end">
|
||||
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
|
||||
<div className="flex min-w-fit flex-1 justify-end gap-2">
|
||||
<Button onClick={handleSearch}>조회</Button>
|
||||
<Button color="white" onClick={handleResetSearch}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,49 +11,48 @@
|
||||
* - Zustand를 통한 상태 관리 및 필터 값 저장
|
||||
* - Enter 키 입력 시 자동 조회 지원
|
||||
*/
|
||||
import React from 'react';
|
||||
import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox';
|
||||
import { Button, Input, Label } from '@common/components';
|
||||
import { MS_SWAGE_MHL_USE_CODES } from '../components/common/table';
|
||||
import React from "react";
|
||||
import { YearRangeSelectBox } from "@/components/ui/YearRangeSelectBox";
|
||||
import { Button, Input, Label } from "@common/components";
|
||||
import { MS_SWAGE_MHL_USE_CODES } from "../components/common/table";
|
||||
import { SelectBox } from "@common/business_components/ui/select";
|
||||
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
|
||||
import useSearchModalStore from '../../../store/useSearchModalStore';
|
||||
import useSearchModal from './useSearchModal';
|
||||
|
||||
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
|
||||
import useSearchModalStore from "../../../store/useSearchModalStore";
|
||||
import useSearchModal from "./useSearchModal";
|
||||
|
||||
export const swageMhlCqlFilters = () => {
|
||||
const {
|
||||
ctpvCd, sggCd,
|
||||
ctpvCd,
|
||||
sggCd,
|
||||
yearFilter,
|
||||
start1, end1,
|
||||
start1,
|
||||
end1,
|
||||
var1,
|
||||
todayYear, todayMonth, todayDate,
|
||||
getDateString
|
||||
todayYear,
|
||||
todayMonth,
|
||||
todayDate,
|
||||
getDateString,
|
||||
} = useSearchModalStore.getState();
|
||||
|
||||
const cqlFilters: string[] = [];
|
||||
// 시도, 시군구
|
||||
if (ctpvCd !== '0' && sggCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`);
|
||||
else if (ctpvCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`);
|
||||
if (ctpvCd !== "0" && sggCd !== "0")
|
||||
cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`);
|
||||
else if (ctpvCd !== "0")
|
||||
cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`);
|
||||
// // 설치년도 범위
|
||||
const startDate = getDateString(
|
||||
yearFilter.start,
|
||||
'',
|
||||
'',
|
||||
"19000101",
|
||||
);
|
||||
const startDate = getDateString(yearFilter.start, "", "", "19000101");
|
||||
const endDate = getDateString(
|
||||
yearFilter.end,
|
||||
'',
|
||||
'',
|
||||
"",
|
||||
"",
|
||||
`${todayYear}${todayMonth}${todayDate}`,
|
||||
);
|
||||
cqlFilters.push(
|
||||
`instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`,
|
||||
);
|
||||
cqlFilters.push(`instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`);
|
||||
if (var1) cqlFilters.push(`usg_cd='${var1}'`);
|
||||
// 구경(mm) 범위
|
||||
if (start1 && end1) cqlFilters.push(`swage_mhL_diam_vl BETWEEN ${start1} AND ${end1}`);
|
||||
if (start1 && end1)
|
||||
cqlFilters.push(`swage_mhL_diam_vl BETWEEN ${start1} AND ${end1}`);
|
||||
else if (start1) cqlFilters.push(`swage_mhL_diam_vl >= ${start1}`);
|
||||
else if (end1) cqlFilters.push(`swage_mhL_diam_vl <= ${end1}`);
|
||||
|
||||
@@ -62,17 +61,24 @@ export const swageMhlCqlFilters = () => {
|
||||
|
||||
const SwageMhl: React.FC = () => {
|
||||
const {
|
||||
var1, setVar1,
|
||||
start1, setStart1, end1, setEnd1,
|
||||
ctpvCd, setCtpvCd,
|
||||
sggCd, setSggCd,
|
||||
yearFilter, setYearFilter,
|
||||
var1,
|
||||
setVar1,
|
||||
start1,
|
||||
setStart1,
|
||||
end1,
|
||||
setEnd1,
|
||||
ctpvCd,
|
||||
setCtpvCd,
|
||||
sggCd,
|
||||
setSggCd,
|
||||
yearFilter,
|
||||
setYearFilter,
|
||||
} = useSearchModalStore();
|
||||
const { handleSearch } = useSearchModal();
|
||||
const { handleSearch, handleResetSearch } = useSearchModal();
|
||||
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<div className="mb-2 flex items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
|
||||
<CtpvSggPrcsFcltSelectBox
|
||||
sido={ctpvCd}
|
||||
sigungu={sggCd}
|
||||
@@ -97,20 +103,30 @@ const SwageMhl: React.FC = () => {
|
||||
endLabel=""
|
||||
/>
|
||||
{/* 용도 */}
|
||||
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">용도</Label>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-xl font-medium text-label">
|
||||
용도
|
||||
</Label>
|
||||
<SelectBox
|
||||
className="w-48 mr-8"
|
||||
className="w-48"
|
||||
selectClass="rounded-lg bg-field text-xl text-foreground"
|
||||
label=""
|
||||
selectArray={[{ val: "0", text: "전체" }, ...MS_SWAGE_MHL_USE_CODES]}
|
||||
selectArray={[
|
||||
{ val: "0", text: "전체" },
|
||||
...MS_SWAGE_MHL_USE_CODES,
|
||||
]}
|
||||
value={var1}
|
||||
onSelectValue={(val) => {
|
||||
setVar1(String(val));
|
||||
}}
|
||||
selectContentClass="z-[101]"
|
||||
/>
|
||||
</div>
|
||||
{/* 하수맨홀 구경 */}
|
||||
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">구경(mm)</Label>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-xl font-medium text-label">
|
||||
구경(mm)
|
||||
</Label>
|
||||
<Input
|
||||
className="w-[100px] !text-xl placeholder:text-center"
|
||||
placeholder="입력"
|
||||
@@ -121,15 +137,15 @@ const SwageMhl: React.FC = () => {
|
||||
setStart1(val);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault(); // Enter 키 기본 동작 방지
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="mx-2 text-xl font-semibold">~</span>
|
||||
<span className="text-xl font-semibold">~</span>
|
||||
<Input
|
||||
className="mr-8 w-[100px] !text-xl placeholder:text-center"
|
||||
className="w-[100px] !text-xl placeholder:text-center"
|
||||
placeholder="입력"
|
||||
value={end1}
|
||||
onChange={(e) => {
|
||||
@@ -138,15 +154,19 @@ const SwageMhl: React.FC = () => {
|
||||
setEnd1(val);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault(); // Enter 키 기본 동작 방지
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* 조회 버튼 */}
|
||||
<div className="flex-1 flex justify-end">
|
||||
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
|
||||
<div className="flex min-w-fit flex-1 justify-end gap-2">
|
||||
<Button onClick={handleSearch}>조회</Button>
|
||||
<Button color="white" onClick={handleResetSearch}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,43 +11,43 @@
|
||||
* - Zustand를 통한 상태 관리 및 필터 값 저장
|
||||
* - Enter 키 입력 시 자동 조회 지원
|
||||
*/
|
||||
import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox';
|
||||
import { Button, Input, Label } from '@common/components';
|
||||
import React from 'react';
|
||||
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
|
||||
import useSearchModalStore from '../../../store/useSearchModalStore';
|
||||
import useSearchModal from './useSearchModal';
|
||||
|
||||
import { YearRangeSelectBox } from "@/components/ui/YearRangeSelectBox";
|
||||
import { Button, Input, Label } from "@common/components";
|
||||
import React from "react";
|
||||
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
|
||||
import useSearchModalStore from "../../../store/useSearchModalStore";
|
||||
import useSearchModal from "./useSearchModal";
|
||||
|
||||
export const swagePmstnCqlFilters = () => {
|
||||
const {
|
||||
ctpvCd, sggCd,
|
||||
ctpvCd,
|
||||
sggCd,
|
||||
yearFilter,
|
||||
start1, start2, end1, end2,
|
||||
todayYear, todayMonth, todayDate,
|
||||
getDateString
|
||||
start1,
|
||||
start2,
|
||||
end1,
|
||||
end2,
|
||||
todayYear,
|
||||
todayMonth,
|
||||
todayDate,
|
||||
getDateString,
|
||||
} = useSearchModalStore.getState();
|
||||
|
||||
const cqlFilters: string[] = [];
|
||||
// 시도, 시군구
|
||||
if (ctpvCd !== '0' && sggCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`);
|
||||
else if (ctpvCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`);
|
||||
if (ctpvCd !== "0" && sggCd !== "0")
|
||||
cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`);
|
||||
else if (ctpvCd !== "0")
|
||||
cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`);
|
||||
// 설치년도 범위
|
||||
const startDate = getDateString(
|
||||
yearFilter.start,
|
||||
'',
|
||||
'',
|
||||
"19000101",
|
||||
);
|
||||
const startDate = getDateString(yearFilter.start, "", "", "19000101");
|
||||
const endDate = getDateString(
|
||||
yearFilter.end,
|
||||
'',
|
||||
'',
|
||||
"",
|
||||
"",
|
||||
`${todayYear}${todayMonth}${todayDate}`,
|
||||
);
|
||||
cqlFilters.push(
|
||||
`instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`,
|
||||
);
|
||||
cqlFilters.push(`instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`);
|
||||
// 일일처리용량 범위
|
||||
if (start1 && end1)
|
||||
cqlFilters.push(`daily_prcs_cpct BETWEEN ${start1} AND ${end1}`);
|
||||
@@ -64,17 +64,22 @@ export const swagePmstnCqlFilters = () => {
|
||||
|
||||
const SwagePmstn: React.FC = () => {
|
||||
const {
|
||||
start1, setStart1,
|
||||
end1, setEnd1,
|
||||
ctpvCd, setCtpvCd,
|
||||
sggCd, setSggCd,
|
||||
yearFilter, setYearFilter,
|
||||
start1,
|
||||
setStart1,
|
||||
end1,
|
||||
setEnd1,
|
||||
ctpvCd,
|
||||
setCtpvCd,
|
||||
sggCd,
|
||||
setSggCd,
|
||||
yearFilter,
|
||||
setYearFilter,
|
||||
} = useSearchModalStore();
|
||||
const { handleSearch } = useSearchModal();
|
||||
const { handleSearch, handleResetSearch } = useSearchModal();
|
||||
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center mb-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
|
||||
{/* 시도, 시군구 */}
|
||||
<CtpvSggPrcsFcltSelectBox
|
||||
sido={ctpvCd}
|
||||
@@ -89,12 +94,17 @@ const SwagePmstn: React.FC = () => {
|
||||
<YearRangeSelectBox
|
||||
start={yearFilter.start}
|
||||
end={yearFilter.end}
|
||||
onChange={range => setYearFilter({ ...yearFilter, start: range.start, end: range.end })}
|
||||
onChange={(range) =>
|
||||
setYearFilter({ ...yearFilter, start: range.start, end: range.end })
|
||||
}
|
||||
startLabel="설치년도"
|
||||
endLabel=""
|
||||
/>
|
||||
{/* 일일처리용량 */}
|
||||
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">일일처리용량(㎥)</Label>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-xl font-medium text-label">
|
||||
일일처리용량(㎥)
|
||||
</Label>
|
||||
<Input
|
||||
className="w-[100px] !text-xl placeholder:text-center"
|
||||
placeholder="입력"
|
||||
@@ -105,15 +115,15 @@ const SwagePmstn: React.FC = () => {
|
||||
setStart1(val);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault(); // Enter 키 기본 동작 방지
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="mx-2 text-xl font-semibold">~</span>
|
||||
<span className="text-xl font-semibold">~</span>
|
||||
<Input
|
||||
className="mr-8 w-[100px] !text-xl placeholder:text-center"
|
||||
className="w-[100px] !text-xl placeholder:text-center"
|
||||
placeholder="입력"
|
||||
value={end1}
|
||||
onChange={(e) => {
|
||||
@@ -122,14 +132,15 @@ const SwagePmstn: React.FC = () => {
|
||||
setEnd1(val);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault(); // Enter 키 기본 동작 방지
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* 양수능력 */}
|
||||
{/* <Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">양수능력(m²/d)</Label>
|
||||
{/* <Label className="text-xl text-label font-medium whitespace-nowrap mr-2">양수능력(m²/d)</Label>
|
||||
<Input
|
||||
className="w-[100px] !text-xl placeholder:text-center"
|
||||
placeholder="입력"
|
||||
@@ -164,8 +175,11 @@ const SwagePmstn: React.FC = () => {
|
||||
}}
|
||||
/> */}
|
||||
{/* 조회 버튼 */}
|
||||
<div className="flex-1 flex justify-end">
|
||||
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
|
||||
<div className="flex min-w-fit flex-1 justify-end gap-2">
|
||||
<Button onClick={handleSearch}>조회</Button>
|
||||
<Button color="white" onClick={handleResetSearch}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,48 +11,49 @@
|
||||
* - Zustand를 통한 상태 관리 및 필터 값 저장
|
||||
* - Enter 키 입력 시 자동 조회 지원
|
||||
*/
|
||||
import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox';
|
||||
import { YearRangeSelectBox } from "@/components/ui/YearRangeSelectBox";
|
||||
import { SelectBox } from "@common/business_components/ui/select";
|
||||
import { Button, Input, Label } from '@common/components';
|
||||
import React from 'react';
|
||||
import { MS_SWAGE_PPL_USE_CODES } from '../components/common/table';
|
||||
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
|
||||
import useSearchModalStore from '../../../store/useSearchModalStore';
|
||||
import useSearchModal from './useSearchModal';
|
||||
|
||||
import { Button, Input, Label } from "@common/components";
|
||||
import React from "react";
|
||||
import { MS_SWAGE_PPL_USE_CODES } from "../components/common/table";
|
||||
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
|
||||
import useSearchModalStore from "../../../store/useSearchModalStore";
|
||||
import useSearchModal from "./useSearchModal";
|
||||
|
||||
// 하수관로용 CQL 필터 생성
|
||||
export const swagePplCqlFilters = () => {
|
||||
const {
|
||||
var1, var2,
|
||||
ctpvCd, sggCd,
|
||||
var1,
|
||||
var2,
|
||||
ctpvCd,
|
||||
sggCd,
|
||||
yearFilter,
|
||||
start1, start2, end1, end2,
|
||||
todayYear, todayMonth, todayDate,
|
||||
getDateString
|
||||
start1,
|
||||
start2,
|
||||
end1,
|
||||
end2,
|
||||
todayYear,
|
||||
todayMonth,
|
||||
todayDate,
|
||||
getDateString,
|
||||
} = useSearchModalStore.getState();
|
||||
|
||||
const cqlFilters: string[] = [];
|
||||
if (var1) cqlFilters.push(`usg_cd='${var1}'`);
|
||||
if (var2) cqlFilters.push(`mqlt_cd='${var2}'`);
|
||||
if (ctpvCd !== '0' && sggCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`);
|
||||
else if (ctpvCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`);
|
||||
if (ctpvCd !== "0" && sggCd !== "0")
|
||||
cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`);
|
||||
else if (ctpvCd !== "0")
|
||||
cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`);
|
||||
// 설치년도 범위
|
||||
const startDate = getDateString(
|
||||
yearFilter.start,
|
||||
'',
|
||||
'',
|
||||
"19000101",
|
||||
);
|
||||
const startDate = getDateString(yearFilter.start, "", "", "19000101");
|
||||
const endDate = getDateString(
|
||||
yearFilter.end,
|
||||
'',
|
||||
'',
|
||||
"",
|
||||
"",
|
||||
`${todayYear}${todayMonth}${todayDate}`,
|
||||
);
|
||||
cqlFilters.push(
|
||||
`instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`,
|
||||
);
|
||||
cqlFilters.push(`instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`);
|
||||
// 관경(mm) 범위
|
||||
if (start1 && end1) cqlFilters.push(`pdmt_vl BETWEEN ${start1} AND ${end1}`);
|
||||
else if (start1) cqlFilters.push(`pdmt_vl >= ${start1}`);
|
||||
@@ -67,17 +68,24 @@ export const swagePplCqlFilters = () => {
|
||||
|
||||
const SwagePpl: React.FC = () => {
|
||||
const {
|
||||
var1, setVar1,
|
||||
start1, setStart1, end1, setEnd1,
|
||||
ctpvCd, setCtpvCd, sggCd, setSggCd,
|
||||
yearFilter, setYearFilter,
|
||||
var1,
|
||||
setVar1,
|
||||
start1,
|
||||
setStart1,
|
||||
end1,
|
||||
setEnd1,
|
||||
ctpvCd,
|
||||
setCtpvCd,
|
||||
sggCd,
|
||||
setSggCd,
|
||||
yearFilter,
|
||||
setYearFilter,
|
||||
} = useSearchModalStore();
|
||||
const { handleSearch } = useSearchModal();
|
||||
|
||||
const { handleSearch, handleResetSearch } = useSearchModal();
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 flex items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
|
||||
<CtpvSggPrcsFcltSelectBox
|
||||
sido={ctpvCd}
|
||||
sigungu={sggCd}
|
||||
@@ -102,23 +110,30 @@ const SwagePpl: React.FC = () => {
|
||||
endLabel=""
|
||||
/>
|
||||
{/* 용도 */}
|
||||
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">용도</Label>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-xl font-medium text-label">
|
||||
용도
|
||||
</Label>
|
||||
<SelectBox
|
||||
className="w-32 mr-8"
|
||||
className="w-32"
|
||||
selectClass="rounded-lg bg-field text-xl text-foreground"
|
||||
label=""
|
||||
selectArray={[{ val: "0", text: "전체" }, ...MS_SWAGE_PPL_USE_CODES]}
|
||||
selectArray={[
|
||||
{ val: "0", text: "전체" },
|
||||
...MS_SWAGE_PPL_USE_CODES,
|
||||
]}
|
||||
value={var1}
|
||||
onSelectValue={(val) => {
|
||||
setVar1(String(val));
|
||||
}}
|
||||
selectContentClass="z-[101]"
|
||||
/>
|
||||
</div>
|
||||
{/* 재질 */}
|
||||
{/* <Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">재질</Label>
|
||||
{/* <Label className="text-xl text-label font-medium whitespace-nowrap mr-2">재질</Label>
|
||||
<SelectBox
|
||||
className="w-60 mr-8"
|
||||
selectClass="rounded-lg bg-white text-xl"
|
||||
selectClass="rounded-lg bg-background text-xl"
|
||||
label=""
|
||||
selectArray={MS_SWAGE_PPL_CODES}
|
||||
value={var2}
|
||||
@@ -128,7 +143,10 @@ const SwagePpl: React.FC = () => {
|
||||
selectContentClass="z-[101]"
|
||||
/> */}
|
||||
{/* 관경 */}
|
||||
<Label className="mr-2 whitespace-nowrap text-xl font-medium text-label">관경(mm)</Label>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-xl font-medium text-label">
|
||||
관경(mm)
|
||||
</Label>
|
||||
<Input
|
||||
className="w-[60px] !text-xl placeholder:text-center"
|
||||
placeholder="입력"
|
||||
@@ -139,15 +157,15 @@ const SwagePpl: React.FC = () => {
|
||||
setStart1(val);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault(); // Enter 키 기본 동작 방지
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="mx-2 text-xl font-semibold">~</span>
|
||||
<span className="text-xl font-semibold">~</span>
|
||||
<Input
|
||||
className="mr-8 w-[60px] !text-xl placeholder:text-center"
|
||||
className="w-[60px] !text-xl placeholder:text-center"
|
||||
placeholder="입력"
|
||||
value={end1}
|
||||
onChange={(e) => {
|
||||
@@ -156,14 +174,15 @@ const SwagePpl: React.FC = () => {
|
||||
setEnd1(val);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault(); // Enter 키 기본 동작 방지
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* 연장 */}
|
||||
{/* <Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">연장(m)</Label>
|
||||
{/* <Label className="text-xl text-label font-medium whitespace-nowrap mr-2">연장(m)</Label>
|
||||
<Input
|
||||
className="w-[60px] !text-xl placeholder:text-center"
|
||||
placeholder="입력"
|
||||
@@ -198,8 +217,11 @@ const SwagePpl: React.FC = () => {
|
||||
}}
|
||||
/> */}
|
||||
{/* 조회 버튼 */}
|
||||
<div className="flex-1 flex justify-end">
|
||||
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
|
||||
<div className="flex min-w-fit flex-1 justify-end gap-2">
|
||||
<Button onClick={handleSearch}>조회</Button>
|
||||
<Button color="white" onClick={handleResetSearch}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,36 +11,34 @@
|
||||
* - Zustand를 통한 상태 관리 및 필터 값 저장
|
||||
* - Enter 키 입력 시 자동 조회 지원
|
||||
*/
|
||||
import React from 'react';
|
||||
import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox';
|
||||
import { Button, Input, Label } from '@common/components';
|
||||
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
|
||||
import useSearchModalStore from '../../../store/useSearchModalStore';
|
||||
import useSearchModal from './useSearchModal';
|
||||
|
||||
import React from "react";
|
||||
import { YearRangeSelectBox } from "@/components/ui/YearRangeSelectBox";
|
||||
import { Button, Input, Label } from "@common/components";
|
||||
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
|
||||
import useSearchModalStore from "../../../store/useSearchModalStore";
|
||||
import useSearchModal from "./useSearchModal";
|
||||
|
||||
// 하수처리시설용 CQL 필터 생성
|
||||
export const swagePrcsFcltCqlFilters = () => {
|
||||
const {
|
||||
ctpvName, sggName,
|
||||
ctpvName,
|
||||
sggName,
|
||||
yearFilter,
|
||||
start1, end1,
|
||||
todayYear, todayMonth, todayDate,
|
||||
getDateString
|
||||
start1,
|
||||
end1,
|
||||
todayYear,
|
||||
todayMonth,
|
||||
todayDate,
|
||||
getDateString,
|
||||
} = useSearchModalStore.getState();
|
||||
|
||||
const cqlFilters: string[] = [];
|
||||
// 가동개시일자 범위
|
||||
const startDate = getDateString(
|
||||
yearFilter.start,
|
||||
'',
|
||||
'',
|
||||
"19000101",
|
||||
);
|
||||
const startDate = getDateString(yearFilter.start, "", "", "19000101");
|
||||
const endDate = getDateString(
|
||||
yearFilter.end,
|
||||
'',
|
||||
'',
|
||||
"",
|
||||
"",
|
||||
`${todayYear}${todayMonth}${todayDate}`,
|
||||
);
|
||||
cqlFilters.push(
|
||||
@@ -67,16 +65,24 @@ export const swagePrcsFcltCqlFilters = () => {
|
||||
|
||||
const SwagePrcsFclt: React.FC = () => {
|
||||
const {
|
||||
start1, setStart1, end1, setEnd1,
|
||||
ctpvCd, setCtpvCd, setCtpvName,
|
||||
sggCd, setSggCd, setSggName,
|
||||
yearFilter, setYearFilter,
|
||||
start1,
|
||||
setStart1,
|
||||
end1,
|
||||
setEnd1,
|
||||
ctpvCd,
|
||||
setCtpvCd,
|
||||
setCtpvName,
|
||||
sggCd,
|
||||
setSggCd,
|
||||
setSggName,
|
||||
yearFilter,
|
||||
setYearFilter,
|
||||
} = useSearchModalStore();
|
||||
const { handleSearch } = useSearchModal();
|
||||
const { handleSearch, handleResetSearch } = useSearchModal();
|
||||
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<div className="mb-2 flex items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
|
||||
<CtpvSggPrcsFcltSelectBox
|
||||
sido={ctpvCd}
|
||||
sigungu={sggCd}
|
||||
@@ -89,7 +95,10 @@ const SwagePrcsFclt: React.FC = () => {
|
||||
setSggName={setSggName}
|
||||
/>
|
||||
{/* 가동개시일자 */}
|
||||
<Label className="mr-2 w-auto text-xl text-gray-600">가동개시일자</Label>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-xl font-medium text-label">
|
||||
가동개시일자
|
||||
</Label>
|
||||
<YearRangeSelectBox
|
||||
start={yearFilter.start}
|
||||
end={yearFilter.end}
|
||||
@@ -103,8 +112,12 @@ const SwagePrcsFclt: React.FC = () => {
|
||||
startLabel=""
|
||||
endLabel=""
|
||||
/>
|
||||
</div>
|
||||
{/* 시설용량 */}
|
||||
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">시설용량(㎥/d)</Label>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-xl font-medium text-label">
|
||||
시설용량(㎥/d)
|
||||
</Label>
|
||||
<Input
|
||||
className="w-[60px] !text-xl placeholder:text-center"
|
||||
placeholder="입력"
|
||||
@@ -115,15 +128,15 @@ const SwagePrcsFclt: React.FC = () => {
|
||||
setStart1(val);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault(); // Enter 키 기본 동작 방지
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="mx-2 text-xl font-semibold">~</span>
|
||||
<span className="text-xl font-semibold">~</span>
|
||||
<Input
|
||||
className="mr-8 w-[60px] !text-xl placeholder:text-center"
|
||||
className="w-[60px] !text-xl placeholder:text-center"
|
||||
placeholder="입력"
|
||||
value={end1}
|
||||
onChange={(e) => {
|
||||
@@ -132,15 +145,19 @@ const SwagePrcsFclt: React.FC = () => {
|
||||
setEnd1(val);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault(); // Enter 키 기본 동작 방지
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* 조회 버튼 */}
|
||||
<div className="flex-1 flex justify-end">
|
||||
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
|
||||
<div className="flex min-w-fit flex-1 justify-end gap-2">
|
||||
<Button onClick={handleSearch}>조회</Button>
|
||||
<Button color="white" onClick={handleResetSearch}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,38 +10,32 @@
|
||||
* - Zustand를 통한 상태 관리 및 필터 값 저장
|
||||
* - Enter 키 입력 시 자동 조회 지원
|
||||
*/
|
||||
import React from 'react';
|
||||
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
|
||||
import { Button, Input, Label } from '@common/components';
|
||||
import useSearchModalStore from '../../../store/useSearchModalStore';
|
||||
import useSearchModal from './useSearchModal';
|
||||
|
||||
import React from "react";
|
||||
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
|
||||
import { Button, Input, Label } from "@common/components";
|
||||
import useSearchModalStore from "../../../store/useSearchModalStore";
|
||||
import useSearchModal from "./useSearchModal";
|
||||
|
||||
// 하수처리구역용 CQL 필터 생성
|
||||
export const swagePrcsZoneCqlFilters = () => {
|
||||
const {
|
||||
ctpvCd, sggCd,
|
||||
var1, var2
|
||||
} = useSearchModalStore.getState();
|
||||
const { ctpvCd, sggCd, var1, var2 } = useSearchModalStore.getState();
|
||||
const cqlFilters: string[] = [];
|
||||
if (ctpvCd !== '' && ctpvCd !== '0') cqlFilters.push(`ctpv_cd='${ctpvCd?.trim()}'`);
|
||||
if (sggCd !== '' && sggCd !== '0') cqlFilters.push(`sgg_cd='${sggCd}'`);
|
||||
if (ctpvCd !== "" && ctpvCd !== "0")
|
||||
cqlFilters.push(`ctpv_cd='${ctpvCd?.trim()}'`);
|
||||
if (sggCd !== "" && sggCd !== "0") cqlFilters.push(`sgg_cd='${sggCd}'`);
|
||||
if (var1) cqlFilters.push(`swtrmt_zone_nm ILIKE '%${var1}%'`);
|
||||
if (var2) cqlFilters.push(`swtrmt_fclt_nm ILIKE '%${var2}%'`);
|
||||
return cqlFilters;
|
||||
};
|
||||
|
||||
const swagePrcsZone: React.FC = () => {
|
||||
const {
|
||||
var1, setVar1, var2, setVar2,
|
||||
ctpvCd, setCtpvCd,
|
||||
sggCd, setSggCd,
|
||||
} = useSearchModalStore();
|
||||
const { handleSearch } = useSearchModal();
|
||||
const { var1, setVar1, var2, setVar2, ctpvCd, setCtpvCd, sggCd, setSggCd } =
|
||||
useSearchModalStore();
|
||||
const { handleSearch, handleResetSearch } = useSearchModal();
|
||||
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<div className="mb-2 flex items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
|
||||
<CtpvSggPrcsFcltSelectBox
|
||||
sido={ctpvCd}
|
||||
sigungu={sggCd}
|
||||
@@ -51,39 +45,50 @@ const swagePrcsZone: React.FC = () => {
|
||||
}}
|
||||
onSigunguChange={setSggCd}
|
||||
/>
|
||||
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">하수처리시설명</Label>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-xl font-medium text-label">
|
||||
하수처리시설명
|
||||
</Label>
|
||||
<Input
|
||||
className='!text-xl w-[120px] placeholder:text-center mr-8'
|
||||
className="w-[120px] !text-xl placeholder:text-center"
|
||||
placeholder="입력"
|
||||
value={var2}
|
||||
onChange={(e) => {
|
||||
setVar2(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault(); // Enter 키 기본 동작 방지
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">하수처리구역명</Label>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-xl font-medium text-label">
|
||||
하수처리구역명
|
||||
</Label>
|
||||
<Input
|
||||
className="mr-8 w-[100px] !text-xl placeholder:text-center"
|
||||
className="w-[100px] !text-xl placeholder:text-center"
|
||||
placeholder="입력"
|
||||
value={var1}
|
||||
onChange={(e) => {
|
||||
setVar1(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault(); // Enter 키 기본 동작 방지
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* 조회 버튼 */}
|
||||
<div className="flex-1 flex justify-end">
|
||||
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
|
||||
<div className="flex min-w-fit flex-1 justify-end gap-2">
|
||||
<Button onClick={handleSearch}>조회</Button>
|
||||
<Button color="white" onClick={handleResetSearch}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,50 +14,52 @@
|
||||
*/
|
||||
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
|
||||
import {
|
||||
MS_SWAGE_PPL_USE_CODES, MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_SHAPE, MS_SWAGE_MHL_CODES, MS_SWAGE_MHL_USE_CODES,
|
||||
FldngWtlvMapExcelCol, MsSwagePplExcelCol, SwtrmtFcltExcelCol, SwtrmtPrcsZoneExcelCol, MsSwageMhlExcelCol, ExclnSewagePumpExcelCol, stofcbrExcelCol
|
||||
} from '../components/common/table';
|
||||
MS_SWAGE_PPL_USE_CODES,
|
||||
MS_SWAGE_PPL_CODES,
|
||||
MS_SWAGE_PPL_SHAPE,
|
||||
MS_SWAGE_MHL_CODES,
|
||||
MS_SWAGE_MHL_USE_CODES,
|
||||
FldngWtlvMapExcelCol,
|
||||
MsSwagePplExcelCol,
|
||||
SwtrmtFcltExcelCol,
|
||||
SwtrmtPrcsZoneExcelCol,
|
||||
MsSwageMhlExcelCol,
|
||||
ExclnSewagePumpExcelCol,
|
||||
stofcbrExcelCol,
|
||||
} from "../components/common/table";
|
||||
import { getData } from "@/components/lib/api";
|
||||
import { BASIC_TYPES } from './basicTab';
|
||||
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
|
||||
import useStore from '@/store/useStore';
|
||||
import { BASIC_TYPES } from "./basicTab";
|
||||
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
|
||||
import useStore from "@/store/useStore";
|
||||
import GisApp from "@common/business_components/ui/map/lib/gis.core";
|
||||
import { confirmToast } from '@/components/ui/toast'
|
||||
import { toast } from "react-hot-toast";
|
||||
import useSearchModalStore from '@/store/useSearchModalStore';
|
||||
import { swagePplCqlFilters } from './swagePpl';
|
||||
import { swagePrcsFcltCqlFilters } from './swagePrcsFclt';
|
||||
import { swagePrcsZoneCqlFilters } from './swagePrcsZone';
|
||||
import { fldngWtlvMapCqlFilters } from './fldngWtlvMap';
|
||||
import { swageMhlCqlFilters } from './swageMhl';
|
||||
import { swagePmstnCqlFilters } from './swagePmstn';
|
||||
import { stofcbrCqlFilters } from './stofcbr';
|
||||
import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage';
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
|
||||
import type { UiTableSort } from "@common/business_components/ui/table";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import useSearchModalStore from "@/store/useSearchModalStore";
|
||||
import { swagePplCqlFilters } from "./swagePpl";
|
||||
import { swagePrcsFcltCqlFilters } from "./swagePrcsFclt";
|
||||
import { swagePrcsZoneCqlFilters } from "./swagePrcsZone";
|
||||
import { fldngWtlvMapCqlFilters } from "./fldngWtlvMap";
|
||||
import { swageMhlCqlFilters } from "./swageMhl";
|
||||
import { swagePmstnCqlFilters } from "./swagePmstn";
|
||||
import { stofcbrCqlFilters } from "./stofcbr";
|
||||
import { postPopupMessageAndClose } from "@/features/windowPopup/lib/popupMessage";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
|
||||
|
||||
const isPopupWindow = () =>
|
||||
typeof window !== "undefined" &&
|
||||
!!window.opener &&
|
||||
window.location.pathname.startsWith("/popup/");
|
||||
|
||||
const DISABLED_BASIC_SORT_KEYS = new Set(['flow_direction_basis']);
|
||||
|
||||
const resolveBasicSort = (basicType: string, sort?: UiTableSort) => {
|
||||
if (!sort) return undefined;
|
||||
if (basicType === 'tb_ms_swage_ppl' && DISABLED_BASIC_SORT_KEYS.has(sort.key)) {
|
||||
return undefined;
|
||||
}
|
||||
return sort;
|
||||
};
|
||||
|
||||
|
||||
export default function useBasicTab() {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { confirm } = useConfirm();
|
||||
const {
|
||||
basicType,
|
||||
tableData,
|
||||
curPage,
|
||||
todayYear, todayMonth, todayDate,
|
||||
todayYear,
|
||||
todayMonth,
|
||||
todayDate,
|
||||
setIsSearchModalOpen,
|
||||
setTableData,
|
||||
setTableCount,
|
||||
@@ -65,79 +67,114 @@ export default function useBasicTab() {
|
||||
} = useSearchModalStore();
|
||||
const { ctpv, sgg } = useStore();
|
||||
|
||||
|
||||
const fetchBasicFilteredData = async (requestedSort?: UiTableSort) => {
|
||||
const activeSort = resolveBasicSort(basicType, requestedSort ?? useSearchModalStore.getState().sort);
|
||||
const fetchBasicFilteredData = async () => {
|
||||
// 초기화 직후에도 이전 렌더의 조건을 사용하지 않도록 최신 스토어 값을 읽는다.
|
||||
const { basicType, curPage } = useSearchModalStore.getState();
|
||||
let cqlFilters: string[] = [];
|
||||
const queryParams = new URLSearchParams({
|
||||
lyrTypeNm: basicType,
|
||||
page: `${curPage - 1}`,
|
||||
size: '10',
|
||||
size: "10",
|
||||
});
|
||||
if (basicType === 'tb_ms_swage_ppl') queryParams.set('includeFlowDirectionBasis', 'true');
|
||||
if (activeSort) queryParams.set('sort', `${activeSort.key},${activeSort.direction}`);
|
||||
if (basicType === "tb_ms_swage_ppl")
|
||||
queryParams.set("includeFlowDirectionBasis", "true");
|
||||
|
||||
if (basicType === 'tb_ms_swage_ppl') cqlFilters = swagePplCqlFilters(); // 하수관로
|
||||
else if (basicType === 'tb_il_swage_prcs_fclt') cqlFilters = swagePrcsFcltCqlFilters(); // 하수처리시설
|
||||
else if (basicType === 'tb_ms_swage_prcs_zone') cqlFilters = swagePrcsZoneCqlFilters(); // 하수처리구역
|
||||
else if (basicType === 'tb_ms_fldng_wtlv_map') cqlFilters = fldngWtlvMapCqlFilters(); // 침수흔적도
|
||||
else if (basicType === 'tb_ms_swage_mhl') cqlFilters = swageMhlCqlFilters(); // 하수맨홀
|
||||
else if (basicType === 'tb_ms_swage_pmstn') cqlFilters = swagePmstnCqlFilters(); // 하수펌프장
|
||||
else if (basicType === 'tb_ms_stofcbr') cqlFilters = stofcbrCqlFilters(); // 우수토실
|
||||
if (cqlFilters.length > 0) queryParams.append('cqlFiter', cqlFilters.join(' and '));
|
||||
if (basicType === "tb_ms_swage_ppl")
|
||||
cqlFilters = swagePplCqlFilters(); // 하수관로
|
||||
else if (basicType === "tb_il_swage_prcs_fclt")
|
||||
cqlFilters = swagePrcsFcltCqlFilters(); // 하수처리시설
|
||||
else if (basicType === "tb_ms_swage_prcs_zone")
|
||||
cqlFilters = swagePrcsZoneCqlFilters(); // 하수처리구역
|
||||
else if (basicType === "tb_ms_fldng_wtlv_map")
|
||||
cqlFilters = fldngWtlvMapCqlFilters(); // 침수흔적도
|
||||
else if (basicType === "tb_ms_swage_mhl")
|
||||
cqlFilters = swageMhlCqlFilters(); // 하수맨홀
|
||||
else if (basicType === "tb_ms_swage_pmstn")
|
||||
cqlFilters = swagePmstnCqlFilters(); // 하수펌프장
|
||||
else if (basicType === "tb_ms_stofcbr") cqlFilters = stofcbrCqlFilters(); // 우수토실
|
||||
if (cqlFilters.length > 0)
|
||||
queryParams.append("cqlFiter", cqlFilters.join(" and "));
|
||||
|
||||
const res = await getData(`v1/fclts?${queryParams.toString()}`);
|
||||
const totalCount = Number(res.count ?? 0);
|
||||
|
||||
// 🎯 각 아이템을 개별적으로 포맷팅하면서 일련번호 추가
|
||||
const dataWithRowNumber = (res.data || []).map((item: any, index: number) => {
|
||||
const dataWithRowNumber = (res.data || []).map(
|
||||
(item: any, index: number) => {
|
||||
// 🎯 먼저 각 아이템을 포맷팅
|
||||
const formattedItem = formatDisplayData(item);
|
||||
|
||||
let ctpvCd = item?.ctpvNm ?? formattedItem?.ctpvNm ?? '';
|
||||
let sggCd = item?.sggNm ?? formattedItem?.sggNm ?? '';
|
||||
let ctpvCd = item?.ctpvNm ?? formattedItem?.ctpvNm ?? "";
|
||||
let sggCd = item?.sggNm ?? formattedItem?.sggNm ?? "";
|
||||
|
||||
if (!ctpvCd || !sggCd) {
|
||||
const code = String(item?.pbadmsEmdCd ?? formattedItem?.pbadmsEmdCd ?? item?.stdgCd ?? formattedItem?.stdgCd ?? '')?.trim();
|
||||
const code = String(
|
||||
item?.pbadmsEmdCd ??
|
||||
formattedItem?.pbadmsEmdCd ??
|
||||
item?.stdgCd ??
|
||||
formattedItem?.stdgCd ??
|
||||
"",
|
||||
)?.trim();
|
||||
if (!ctpvCd && code) ctpvCd = code.slice(0, 2);
|
||||
if (!sggCd && code) sggCd = code.slice(0, 5);
|
||||
}
|
||||
|
||||
const matchedMqltCode = basicType === 'tb_ms_swage_ppl'
|
||||
? MS_SWAGE_PPL_CODES.find(code => code.val === item?.mqltCd?.trim()) // 하수관로 재질
|
||||
: basicType === 'tb_ms_swage_mhl'
|
||||
? MS_SWAGE_MHL_CODES.find(code => code.val === item?.mqltCd?.trim()) // 맨홀 재질
|
||||
const matchedMqltCode =
|
||||
basicType === "tb_ms_swage_ppl"
|
||||
? MS_SWAGE_PPL_CODES.find(
|
||||
(code) => code.val === item?.mqltCd?.trim(),
|
||||
) // 하수관로 재질
|
||||
: basicType === "tb_ms_swage_mhl"
|
||||
? MS_SWAGE_MHL_CODES.find(
|
||||
(code) => code.val === item?.mqltCd?.trim(),
|
||||
) // 맨홀 재질
|
||||
: undefined;
|
||||
const matchedMhlUsgCode = MS_SWAGE_MHL_USE_CODES.find(code => code.val === item?.usgCd); // 맨홀 용도
|
||||
const matchedPplShpCode = MS_SWAGE_PPL_SHAPE.find(code => code.val === item?.fcltyShpCd); // 하수관로 시설물형태코드
|
||||
const matchedMhlUsgCode = MS_SWAGE_MHL_USE_CODES.find(
|
||||
(code) => code.val === item?.usgCd,
|
||||
); // 맨홀 용도
|
||||
const matchedPplShpCode = MS_SWAGE_PPL_SHAPE.find(
|
||||
(code) => code.val === item?.fcltyShpCd,
|
||||
); // 하수관로 시설물형태코드
|
||||
|
||||
|
||||
const ctpvNm = ctpv.find(code => code.val.trim() === ctpvCd)?.text || item?.ctpvNm;
|
||||
const sggNm = sgg.find(code => code.val.trim() === sggCd)?.text || item?.sggNm;
|
||||
const usgCdObj = MS_SWAGE_PPL_USE_CODES.find(code => code.val === item?.usgCd?.trim());
|
||||
const ctpvNm =
|
||||
ctpv.find((code) => code.val.trim() === ctpvCd)?.text || item?.ctpvNm;
|
||||
const sggNm =
|
||||
sgg.find((code) => code.val.trim() === sggCd)?.text || item?.sggNm;
|
||||
const usgCdObj = MS_SWAGE_PPL_USE_CODES.find(
|
||||
(code) => code.val === item?.usgCd?.trim(),
|
||||
);
|
||||
|
||||
// 추가 처리 및 일련번호 추가
|
||||
return {
|
||||
...formattedItem,
|
||||
rowNumber: activeSort?.key === 'rowNumber' && activeSort.direction === 'desc'
|
||||
? totalCount - ((curPage - 1) * 10 + index)
|
||||
: (curPage - 1) * 10 + index + 1,
|
||||
scsYn: item.scsYn === null || item.scsYn === undefined ? 'N' : item?.scsYn, // scsYn이 null이면 'N'으로 변경
|
||||
rowNumber: totalCount - ((curPage - 1) * 10 + index),
|
||||
scsYn:
|
||||
item.scsYn === null || item.scsYn === undefined ? "N" : item?.scsYn, // scsYn이 null이면 'N'으로 변경
|
||||
mqltCd: matchedMqltCode ? matchedMqltCode.text : item?.mqltCd,
|
||||
usgCd: matchedMhlUsgCode ? matchedMhlUsgCode.text : (usgCdObj ? usgCdObj.text : item?.usgCd),
|
||||
ctpvNm: ctpvNm ?? '-',
|
||||
sggNm: sggNm ?? '-',
|
||||
swtrmt_zone_nm: item.swtrmtZoneNm || item.swtrmt_zone_nm || '-',
|
||||
fcltyShpCd: matchedPplShpCode? matchedPplShpCode.text : item?.fcltyShpCd,
|
||||
usgCd: matchedMhlUsgCode
|
||||
? matchedMhlUsgCode.text
|
||||
: usgCdObj
|
||||
? usgCdObj.text
|
||||
: item?.usgCd,
|
||||
ctpvNm: ctpvNm ?? "-",
|
||||
sggNm: sggNm ?? "-",
|
||||
swtrmt_zone_nm: item.swtrmtZoneNm || item.swtrmt_zone_nm || "-",
|
||||
fcltyShpCd: matchedPplShpCode
|
||||
? matchedPplShpCode.text
|
||||
: item?.fcltyShpCd,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
setTableData(dataWithRowNumber);
|
||||
setTableCount(totalCount);
|
||||
};
|
||||
|
||||
// 기본정보 테이블 cellClick 핸들러
|
||||
const handleBasicCellClick = async (index?: number | undefined, headId?: String) => {
|
||||
const handleBasicCellClick = async (
|
||||
index?: number | undefined,
|
||||
headId?: String,
|
||||
) => {
|
||||
if (typeof index === "number") {
|
||||
const row = tableData[index];
|
||||
|
||||
@@ -170,13 +207,14 @@ export default function useBasicTab() {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss("global-confirm-toast");
|
||||
|
||||
confirmToast(
|
||||
if (
|
||||
!(await confirm(
|
||||
"파일 다운로드에는 2분 내외의 시간이 소요될 수 있습니다.\n파일 다운로드를 진행하시겠습니까?",
|
||||
onDownload ?? (() => { }),
|
||||
"text-xl font-medium text-gray-800"
|
||||
);
|
||||
))
|
||||
)
|
||||
return;
|
||||
|
||||
await onDownload();
|
||||
|
||||
async function onDownload() {
|
||||
setIsLoading(true);
|
||||
@@ -184,60 +222,59 @@ export default function useBasicTab() {
|
||||
try {
|
||||
const queryParams = new URLSearchParams({
|
||||
lyrTypeNm: basicType,
|
||||
size: '10000',
|
||||
size: "10000",
|
||||
});
|
||||
if (basicType === 'tb_ms_swage_ppl') queryParams.set('includeFlowDirectionBasis', 'true');
|
||||
const activeSort = resolveBasicSort(basicType, useSearchModalStore.getState().sort);
|
||||
if (activeSort) queryParams.set('sort', `${activeSort.key},${activeSort.direction}`);
|
||||
if (basicType === "tb_ms_swage_ppl")
|
||||
queryParams.set("includeFlowDirectionBasis", "true");
|
||||
|
||||
let cqlFilters: string[] = [];
|
||||
let headerMapping: { [key: string]: string } = {};
|
||||
let headerRange: string = "A1:F1";
|
||||
|
||||
switch (basicType) {
|
||||
case 'tb_ms_swage_ppl': {
|
||||
case "tb_ms_swage_ppl": {
|
||||
// 하수관로
|
||||
cqlFilters = swagePplCqlFilters()
|
||||
cqlFilters = swagePplCqlFilters();
|
||||
headerMapping = MsSwagePplExcelCol;
|
||||
headerRange = "A1:Q1";
|
||||
break;
|
||||
}
|
||||
case 'tb_il_swage_prcs_fclt': {
|
||||
case "tb_il_swage_prcs_fclt": {
|
||||
// 하수처리시설
|
||||
cqlFilters = swagePrcsFcltCqlFilters();
|
||||
headerMapping = SwtrmtFcltExcelCol;
|
||||
headerRange = "A1:J1";
|
||||
break;
|
||||
}
|
||||
case 'tb_ms_swage_prcs_zone': {
|
||||
case "tb_ms_swage_prcs_zone": {
|
||||
// 하수처리구역
|
||||
cqlFilters = swagePrcsZoneCqlFilters();
|
||||
headerMapping = SwtrmtPrcsZoneExcelCol;
|
||||
headerRange = "A1:D1";
|
||||
break;
|
||||
}
|
||||
case 'tb_ms_fldng_wtlv_map': {
|
||||
case "tb_ms_fldng_wtlv_map": {
|
||||
// 침수흔적도
|
||||
cqlFilters = fldngWtlvMapCqlFilters();
|
||||
headerMapping = FldngWtlvMapExcelCol;
|
||||
headerRange = "A1:O1";
|
||||
break;
|
||||
}
|
||||
case 'tb_ms_swage_mhl': {
|
||||
case "tb_ms_swage_mhl": {
|
||||
// 하수맨홀
|
||||
cqlFilters = swageMhlCqlFilters();
|
||||
headerMapping = MsSwageMhlExcelCol;
|
||||
headerRange = "A1:J1";
|
||||
break;
|
||||
}
|
||||
case 'tb_ms_swage_pmstn': {
|
||||
case "tb_ms_swage_pmstn": {
|
||||
// 하수펌프장
|
||||
cqlFilters = swagePmstnCqlFilters();
|
||||
headerMapping = ExclnSewagePumpExcelCol;
|
||||
headerRange = "A1:J1";
|
||||
break;
|
||||
}
|
||||
case 'tb_ms_stofcbr': {
|
||||
case "tb_ms_stofcbr": {
|
||||
// 우수토실
|
||||
cqlFilters = stofcbrCqlFilters();
|
||||
headerMapping = stofcbrExcelCol;
|
||||
@@ -246,25 +283,44 @@ export default function useBasicTab() {
|
||||
}
|
||||
}
|
||||
|
||||
if (cqlFilters.length > 0) queryParams.append('cqlFiter', cqlFilters.join(' and '));
|
||||
if (cqlFilters.length > 0)
|
||||
queryParams.append("cqlFiter", cqlFilters.join(" and "));
|
||||
|
||||
const res = await getData(`v1/fclts?${queryParams.toString()}`);
|
||||
|
||||
const mhlUsgCdMap = new Map(MS_SWAGE_MHL_USE_CODES.map(code => [code.val?.trim(), code.text]));
|
||||
const pplUsgCdMap = new Map(MS_SWAGE_PPL_USE_CODES.map(code => [code.val?.trim(), code.text]));
|
||||
const pplMqltCdMap = new Map(MS_SWAGE_PPL_CODES.map(code => [code.val?.trim(), code.text]));
|
||||
const pplShpCdMap = new Map(MS_SWAGE_PPL_SHAPE.map(code => [code.val?.trim(), code.text])); // 관로 시설물형태코드
|
||||
const ctpvMap = new Map(ctpv.map(code => [code.val?.trim(), code.text]));
|
||||
const sggMap = new Map(sgg.map(code => [code.val?.trim(), code.text]));
|
||||
const mhlUsgCdMap = new Map(
|
||||
MS_SWAGE_MHL_USE_CODES.map((code) => [code.val?.trim(), code.text]),
|
||||
);
|
||||
const pplUsgCdMap = new Map(
|
||||
MS_SWAGE_PPL_USE_CODES.map((code) => [code.val?.trim(), code.text]),
|
||||
);
|
||||
const pplMqltCdMap = new Map(
|
||||
MS_SWAGE_PPL_CODES.map((code) => [code.val?.trim(), code.text]),
|
||||
);
|
||||
const pplShpCdMap = new Map(
|
||||
MS_SWAGE_PPL_SHAPE.map((code) => [code.val?.trim(), code.text]),
|
||||
); // 관로 시설물형태코드
|
||||
const ctpvMap = new Map(
|
||||
ctpv.map((code) => [code.val?.trim(), code.text]),
|
||||
);
|
||||
const sggMap = new Map(
|
||||
sgg.map((code) => [code.val?.trim(), code.text]),
|
||||
);
|
||||
|
||||
let mappedContent = (res.data || []).map((item: any) => {
|
||||
const formattedItem = formatDisplayData(item);
|
||||
|
||||
let ctpvCd = item?.ctpvCd ?? formattedItem?.ctpvCd ?? '';
|
||||
let sggCd = item?.sggCd ?? formattedItem?.sggCd ?? '';
|
||||
let ctpvCd = item?.ctpvCd ?? formattedItem?.ctpvCd ?? "";
|
||||
let sggCd = item?.sggCd ?? formattedItem?.sggCd ?? "";
|
||||
|
||||
if (!ctpvCd || !sggCd) {
|
||||
const code = String(item?.pbadmsEmdCd ?? formattedItem?.pbadmsEmdCd ?? item?.stdgCd ?? formattedItem?.stdgCd ?? '')?.trim();
|
||||
const code = String(
|
||||
item?.pbadmsEmdCd ??
|
||||
formattedItem?.pbadmsEmdCd ??
|
||||
item?.stdgCd ??
|
||||
formattedItem?.stdgCd ??
|
||||
"",
|
||||
)?.trim();
|
||||
if (!ctpvCd && code) ctpvCd = code.slice(0, 2);
|
||||
if (!sggCd && code) sggCd = code.slice(0, 5);
|
||||
}
|
||||
@@ -274,113 +330,150 @@ export default function useBasicTab() {
|
||||
// const trimmedMqltCd = item?.mqltCd?.trim();
|
||||
|
||||
switch (basicType) {
|
||||
case 'tb_ms_swage_ppl': {
|
||||
case "tb_ms_swage_ppl": {
|
||||
// 하수관로
|
||||
return {
|
||||
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? '-',
|
||||
instMngNo: formattedItem.instMngNo ?? item.instMngNo ?? '-',
|
||||
ctpvNm: ctpvMap.get(ctpvCd) ?? item.ctpvNm ?? '-',
|
||||
sggNm: sggMap.get(sggCd) ?? item.sggNm ?? '-',
|
||||
swtrmt_zone_nm: formattedItem.swtrmt_zone_nm ?? item.swtrmt_zone_nm ?? '-',
|
||||
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? '-',
|
||||
usgCd: pplUsgCdMap.get(trimmedUsgCd) || item.usgCd || formattedItem.usgCd || '-',
|
||||
pdmtVl: formattedItem.pdmtVl ?? item.pdmtVl ?? '-',
|
||||
prlgVl: formattedItem.prlgVl ?? item.prlgVl ?? '-',
|
||||
tmptDpth: formattedItem.tmptDpth ?? item.tmptDpth ?? '-',
|
||||
tmptMhlMngNo: formattedItem.tmptMhlMngNo ?? item.tmptMhlMngNo ?? '-',
|
||||
scendDpth: formattedItem.scendDpth ?? item.scendDpth ?? '-',
|
||||
scendMhlMngNo: formattedItem.scendMhlMngNo ?? item.scendMhlMngNo ?? '-',
|
||||
vrtcLen: formattedItem.vrtcLen ?? item.vrtcLen ?? '-',
|
||||
wdthLen: formattedItem.wdthLen ?? item.wdthLen ?? '-',
|
||||
fcltyShpCd: pplShpCdMap.get(item?.fcltyShpCd?.trim()) || item.fcltyShpCd || formattedItem.fcltyShpCd || '-',
|
||||
mqltCd: pplMqltCdMap.get(item?.mqltCd?.trim()) || item.mqltCd || formattedItem.mqltCd || '-',
|
||||
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? "-",
|
||||
instMngNo: formattedItem.instMngNo ?? item.instMngNo ?? "-",
|
||||
ctpvNm: ctpvMap.get(ctpvCd) ?? item.ctpvNm ?? "-",
|
||||
sggNm: sggMap.get(sggCd) ?? item.sggNm ?? "-",
|
||||
swtrmt_zone_nm:
|
||||
formattedItem.swtrmt_zone_nm ?? item.swtrmt_zone_nm ?? "-",
|
||||
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? "-",
|
||||
usgCd:
|
||||
pplUsgCdMap.get(trimmedUsgCd) ||
|
||||
item.usgCd ||
|
||||
formattedItem.usgCd ||
|
||||
"-",
|
||||
pdmtVl: formattedItem.pdmtVl ?? item.pdmtVl ?? "-",
|
||||
prlgVl: formattedItem.prlgVl ?? item.prlgVl ?? "-",
|
||||
tmptDpth: formattedItem.tmptDpth ?? item.tmptDpth ?? "-",
|
||||
tmptMhlMngNo:
|
||||
formattedItem.tmptMhlMngNo ?? item.tmptMhlMngNo ?? "-",
|
||||
scendDpth: formattedItem.scendDpth ?? item.scendDpth ?? "-",
|
||||
scendMhlMngNo:
|
||||
formattedItem.scendMhlMngNo ?? item.scendMhlMngNo ?? "-",
|
||||
vrtcLen: formattedItem.vrtcLen ?? item.vrtcLen ?? "-",
|
||||
wdthLen: formattedItem.wdthLen ?? item.wdthLen ?? "-",
|
||||
fcltyShpCd:
|
||||
pplShpCdMap.get(item?.fcltyShpCd?.trim()) ||
|
||||
item.fcltyShpCd ||
|
||||
formattedItem.fcltyShpCd ||
|
||||
"-",
|
||||
mqltCd:
|
||||
pplMqltCdMap.get(item?.mqltCd?.trim()) ||
|
||||
item.mqltCd ||
|
||||
formattedItem.mqltCd ||
|
||||
"-",
|
||||
};
|
||||
}
|
||||
case 'tb_il_swage_prcs_fclt': {
|
||||
case "tb_il_swage_prcs_fclt": {
|
||||
// 하수처리시설
|
||||
return {
|
||||
swtrmtFcltNm: formattedItem.swtrmtFcltNm ?? item.swtrmtFcltNm ?? '-',
|
||||
fcltCpct: formattedItem.fcltCpct ?? item.fcltCpct ?? '-',
|
||||
rgnExpln: formattedItem.rgnExpln ?? item.rgnExpln ?? '-',
|
||||
oprtngStrtYmd: formattedItem.oprtngStrtYmd ?? item.oprtngStrtYmd ?? '-',
|
||||
laeNm: formattedItem.laeNm ?? item.laeNm ?? '-',
|
||||
sareaNm: formattedItem.sareaNm ?? item.sareaNm ?? '-',
|
||||
rvrNm: formattedItem.rvrNm ?? item.rvrNm ?? '-',
|
||||
dschgWtstNm: formattedItem.dschgWtstNm ?? item.dschgWtstNm ?? '-',
|
||||
swtrmtPpltnCnt: formattedItem.swtrmtPpltnCnt ?? item.swtrmtPpltnCnt ?? '-',
|
||||
rprsMsrcCn: formattedItem.rprsMsrcCn ?? item.rprsMsrcCn ?? '-'
|
||||
swtrmtFcltNm:
|
||||
formattedItem.swtrmtFcltNm ?? item.swtrmtFcltNm ?? "-",
|
||||
fcltCpct: formattedItem.fcltCpct ?? item.fcltCpct ?? "-",
|
||||
rgnExpln: formattedItem.rgnExpln ?? item.rgnExpln ?? "-",
|
||||
oprtngStrtYmd:
|
||||
formattedItem.oprtngStrtYmd ?? item.oprtngStrtYmd ?? "-",
|
||||
laeNm: formattedItem.laeNm ?? item.laeNm ?? "-",
|
||||
sareaNm: formattedItem.sareaNm ?? item.sareaNm ?? "-",
|
||||
rvrNm: formattedItem.rvrNm ?? item.rvrNm ?? "-",
|
||||
dschgWtstNm:
|
||||
formattedItem.dschgWtstNm ?? item.dschgWtstNm ?? "-",
|
||||
swtrmtPpltnCnt:
|
||||
formattedItem.swtrmtPpltnCnt ?? item.swtrmtPpltnCnt ?? "-",
|
||||
rprsMsrcCn: formattedItem.rprsMsrcCn ?? item.rprsMsrcCn ?? "-",
|
||||
};
|
||||
}
|
||||
case 'tb_ms_swage_prcs_zone': {
|
||||
case "tb_ms_swage_prcs_zone": {
|
||||
// 하수처리구역
|
||||
return {
|
||||
swtrmtZoneNm: formattedItem.swtrmtZoneNm ?? item.swtrmtZoneNm ?? '-',
|
||||
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? '-',
|
||||
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? '-',
|
||||
swtrmtFcltNm: formattedItem.swtrmtFcltNm ?? item.swtrmtFcltNm ?? '-'
|
||||
swtrmtZoneNm:
|
||||
formattedItem.swtrmtZoneNm ?? item.swtrmtZoneNm ?? "-",
|
||||
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? "-",
|
||||
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? "-",
|
||||
swtrmtFcltNm:
|
||||
formattedItem.swtrmtFcltNm ?? item.swtrmtFcltNm ?? "-",
|
||||
};
|
||||
}
|
||||
case 'tb_ms_fldng_wtlv_map': {
|
||||
case "tb_ms_fldng_wtlv_map": {
|
||||
// 침수흔적도
|
||||
return {
|
||||
fldngYr: formattedItem.fldngYr ?? item.fldngYr ?? '-',
|
||||
fldngDstrNm: formattedItem.fldngDstrNm ?? item.fldngDstrNm ?? '-',
|
||||
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? '-',
|
||||
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? '-',
|
||||
bgngYmd: formattedItem.bgngYmd ?? item.bgngYmd ?? '-',
|
||||
fldngBgngHm: formattedItem.fldngBgngHm ?? item.fldngBgngHm ?? '-',
|
||||
endYmd: formattedItem.endYmd ?? item.endYmd ?? '-',
|
||||
fldngEndHm: formattedItem.fldngEndHm ?? item.fldngEndHm ?? '-',
|
||||
fldngHr: formattedItem.fldngHr ?? item.fldngHr ?? '-',
|
||||
fldngDstrctNm: formattedItem.fldngDstrctNm ?? item.fldngDstrctNm ?? '-',
|
||||
fldngCsDtlCd: formattedItem.fldngCsDtlCd ?? item.fldngCsDtlCd ?? '-',
|
||||
avgFldngWtlv: formattedItem.avgFldngWtlv ?? item.avgFldngWtlv ?? '-',
|
||||
fldngArea: formattedItem.fldngArea ?? item.fldngArea ?? '-',
|
||||
exmnYr: formattedItem.exmnYr ?? item.exmnYr ?? '-',
|
||||
damAmt: formattedItem.damAmt ?? item.damAmt ?? '-',
|
||||
fldngYr: formattedItem.fldngYr ?? item.fldngYr ?? "-",
|
||||
fldngDstrNm:
|
||||
formattedItem.fldngDstrNm ?? item.fldngDstrNm ?? "-",
|
||||
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? "-",
|
||||
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? "-",
|
||||
bgngYmd: formattedItem.bgngYmd ?? item.bgngYmd ?? "-",
|
||||
fldngBgngHm:
|
||||
formattedItem.fldngBgngHm ?? item.fldngBgngHm ?? "-",
|
||||
endYmd: formattedItem.endYmd ?? item.endYmd ?? "-",
|
||||
fldngEndHm: formattedItem.fldngEndHm ?? item.fldngEndHm ?? "-",
|
||||
fldngHr: formattedItem.fldngHr ?? item.fldngHr ?? "-",
|
||||
fldngDstrctNm:
|
||||
formattedItem.fldngDstrctNm ?? item.fldngDstrctNm ?? "-",
|
||||
fldngCsDtlCd:
|
||||
formattedItem.fldngCsDtlCd ?? item.fldngCsDtlCd ?? "-",
|
||||
avgFldngWtlv:
|
||||
formattedItem.avgFldngWtlv ?? item.avgFldngWtlv ?? "-",
|
||||
fldngArea: formattedItem.fldngArea ?? item.fldngArea ?? "-",
|
||||
exmnYr: formattedItem.exmnYr ?? item.exmnYr ?? "-",
|
||||
damAmt: formattedItem.damAmt ?? item.damAmt ?? "-",
|
||||
};
|
||||
}
|
||||
case 'tb_ms_swage_mhl': {
|
||||
case "tb_ms_swage_mhl": {
|
||||
// 하수맨홀
|
||||
return {
|
||||
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? '-',
|
||||
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? '-',
|
||||
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? '-',
|
||||
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? '-',
|
||||
usgCd: mhlUsgCdMap.get(trimmedUsgCd) || item.usgCd || formattedItem.usgCd || '-',
|
||||
swageMhlDiamVl: formattedItem.swageMhlDiamVl ?? item.swageMhlDiamVl ?? '-',
|
||||
wdthLen: formattedItem.wdthLen ?? item.wdthLen ?? '-',
|
||||
vrtcLen: formattedItem.vrtcLen ?? item.vrtcLen ?? '-',
|
||||
altdVl: formattedItem.altdVl ?? item.altdVl ?? '-',
|
||||
dpthlVl: formattedItem.dpthlVl ?? item.dpthlVl ?? '-'
|
||||
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? "-",
|
||||
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? "-",
|
||||
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? "-",
|
||||
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? "-",
|
||||
usgCd:
|
||||
mhlUsgCdMap.get(trimmedUsgCd) ||
|
||||
item.usgCd ||
|
||||
formattedItem.usgCd ||
|
||||
"-",
|
||||
swageMhlDiamVl:
|
||||
formattedItem.swageMhlDiamVl ?? item.swageMhlDiamVl ?? "-",
|
||||
wdthLen: formattedItem.wdthLen ?? item.wdthLen ?? "-",
|
||||
vrtcLen: formattedItem.vrtcLen ?? item.vrtcLen ?? "-",
|
||||
altdVl: formattedItem.altdVl ?? item.altdVl ?? "-",
|
||||
dpthlVl: formattedItem.dpthlVl ?? item.dpthlVl ?? "-",
|
||||
};
|
||||
}
|
||||
case 'tb_ms_swage_pmstn': {
|
||||
case "tb_ms_swage_pmstn": {
|
||||
// 하수펌프장
|
||||
return {
|
||||
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? '-',
|
||||
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? '-',
|
||||
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? '-',
|
||||
swagePmstnNm: formattedItem.swagePmstnNm ?? item.swagePmstnNm ?? '-',
|
||||
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? '-',
|
||||
dailyPrcsCpct: formattedItem.dailyPrcsCpct ?? item.dailyPrcsCpct ?? '-',
|
||||
maxImpndCpct: formattedItem.maxImpndCpct ?? item.maxImpndCpct ?? '-',
|
||||
altdVl: formattedItem.altdVl ?? item.altdVl ?? '-',
|
||||
wtlvVl: formattedItem.wtlvVl ?? item.wtlvVl ?? '-',
|
||||
exclnWpmpAbltVl: formattedItem.exclnWpmpAbltVl ?? item.exclnWpmpAbltVl ?? '-'
|
||||
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? "-",
|
||||
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? "-",
|
||||
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? "-",
|
||||
swagePmstnNm:
|
||||
formattedItem.swagePmstnNm ?? item.swagePmstnNm ?? "-",
|
||||
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? "-",
|
||||
dailyPrcsCpct:
|
||||
formattedItem.dailyPrcsCpct ?? item.dailyPrcsCpct ?? "-",
|
||||
maxImpndCpct:
|
||||
formattedItem.maxImpndCpct ?? item.maxImpndCpct ?? "-",
|
||||
altdVl: formattedItem.altdVl ?? item.altdVl ?? "-",
|
||||
wtlvVl: formattedItem.wtlvVl ?? item.wtlvVl ?? "-",
|
||||
exclnWpmpAbltVl:
|
||||
formattedItem.exclnWpmpAbltVl ?? item.exclnWpmpAbltVl ?? "-",
|
||||
};
|
||||
}
|
||||
case 'tb_ms_stofcbr': {
|
||||
case "tb_ms_stofcbr": {
|
||||
// 우수토실
|
||||
return {
|
||||
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? '-',
|
||||
mapshtNo: formattedItem.mapshtNo ?? item.mapshtNo ?? '-',
|
||||
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? '-',
|
||||
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? '-',
|
||||
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? '-',
|
||||
worflwWallHgt: formattedItem.worflwWallHgt ?? item.worflwWallHgt ?? '-',
|
||||
swageMhlMngNo: formattedItem.swageMhlMngNo ?? item.swageMhlMngNo ?? '-',
|
||||
drtnAngl: formattedItem.drtnAngl ?? item.drtnAngl ?? '-'
|
||||
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? "-",
|
||||
mapshtNo: formattedItem.mapshtNo ?? item.mapshtNo ?? "-",
|
||||
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? "-",
|
||||
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? "-",
|
||||
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? "-",
|
||||
worflwWallHgt:
|
||||
formattedItem.worflwWallHgt ?? item.worflwWallHgt ?? "-",
|
||||
swageMhlMngNo:
|
||||
formattedItem.swageMhlMngNo ?? item.swageMhlMngNo ?? "-",
|
||||
drtnAngl: formattedItem.drtnAngl ?? item.drtnAngl ?? "-",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -390,7 +483,7 @@ export default function useBasicTab() {
|
||||
const excelData = mappedContent; // 배열로 감싸서 전달
|
||||
|
||||
// 파일명 생성 (분석번호 기반)
|
||||
const label = BASIC_TYPES.find((type) => type.val === basicType)?.text
|
||||
const label = BASIC_TYPES.find((type) => type.val === basicType)?.text;
|
||||
const fileName = `${label} 조회 결과_${todayYear}${todayMonth}${todayDate}`;
|
||||
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
@@ -398,7 +491,7 @@ export default function useBasicTab() {
|
||||
fileName: fileName,
|
||||
sheetNameArray: [fileName],
|
||||
headerRange: headerRange,
|
||||
headerMapping: headerMapping
|
||||
headerMapping: headerMapping,
|
||||
});
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
@@ -409,6 +502,5 @@ export default function useBasicTab() {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return { fetchBasicFilteredData, handleBasicCellClick, basicExcelDownload };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,72 +15,67 @@
|
||||
* - 유지관리 정보(사업정보) 관련 시설 목록 조회: GET /api/v1/bizInfo/fclts (sewerage-service)
|
||||
*/
|
||||
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
|
||||
import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } from '../components/common/table';
|
||||
import {
|
||||
MS_SWAGE_PPL_CODES,
|
||||
MS_SWAGE_PPL_USE_CODES,
|
||||
} from "../components/common/table";
|
||||
import { getData } from "@/components/lib/api";
|
||||
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
|
||||
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
|
||||
import GisApp from "@common/business_components/ui/map/lib/gis.core";
|
||||
import { confirmToast } from '@/components/ui/toast'
|
||||
import { toast } from "react-hot-toast";
|
||||
import useSearchModalStore from '@/store/useSearchModalStore';
|
||||
import useStore from '@/store/useStore';
|
||||
import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage';
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
|
||||
import type { UiTableSort } from "@common/business_components/ui/table";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import useSearchModalStore from "@/store/useSearchModalStore";
|
||||
import useStore from "@/store/useStore";
|
||||
import { postPopupMessageAndClose } from "@/features/windowPopup/lib/popupMessage";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
|
||||
|
||||
const isPopupWindow = () =>
|
||||
typeof window !== "undefined" &&
|
||||
!!window.opener &&
|
||||
window.location.pathname.startsWith("/popup/");
|
||||
|
||||
// Spring Pageable 단일 정렬 계약을 정보검색 요청 파라미터에 추가한다.
|
||||
const appendSortParameter = (
|
||||
request: Record<string, string>,
|
||||
sort: UiTableSort | undefined,
|
||||
scope: 'list' | 'facility',
|
||||
) => {
|
||||
if (!sort) return;
|
||||
const requestSort = sort.key === 'rowNumber'
|
||||
? {
|
||||
key: scope === 'list' ? 'bizId' : 'ast_id',
|
||||
direction: sort.direction === 'asc' ? 'desc' : 'asc',
|
||||
}
|
||||
: sort;
|
||||
request.sort = `${requestSort.key},${requestSort.direction}`;
|
||||
};
|
||||
|
||||
|
||||
export default function useOperationTab() {
|
||||
const OPERATION_TABLE_SIZE = 5;
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
const {
|
||||
workType,
|
||||
tableData, detailTableData,
|
||||
curPage, detailCurPage,
|
||||
tableData,
|
||||
detailTableData,
|
||||
curPage,
|
||||
detailCurPage,
|
||||
detailBizId,
|
||||
var1, var2,
|
||||
var1,
|
||||
var2,
|
||||
yearFilter,
|
||||
todayYear, todayMonth, todayDate,
|
||||
todayYear,
|
||||
todayMonth,
|
||||
todayDate,
|
||||
setIsSearchModalOpen,
|
||||
setTableData, setDetailTableData,
|
||||
setTableCount, setDetailTableCount,
|
||||
setTableData,
|
||||
setDetailTableData,
|
||||
setTableCount,
|
||||
setDetailTableCount,
|
||||
setDetailCurPage,
|
||||
sort, detailSort,
|
||||
setDetailSort,
|
||||
setIsLoading, setIsDetailLoading,
|
||||
setIsLoading,
|
||||
setIsDetailLoading,
|
||||
setIsResetDetailPage,
|
||||
setIsResetDetailSelection,
|
||||
setDetailBizId,
|
||||
getDateString
|
||||
getDateString,
|
||||
} = useSearchModalStore();
|
||||
const { BIZ_TY_CD } = useStore();
|
||||
|
||||
const WORK_TYPES = [ // 유지관리 정보 '구분' 선택 목록
|
||||
const WORK_TYPES = [
|
||||
// 유지관리 정보 '구분' 선택 목록
|
||||
{ val: "['200', '201', '204']", text: "보수 및 갱신" },
|
||||
{ val: "['100']", text: "점검 및 진단" },
|
||||
{ val: "['203']", text: "청소 및 준설" },
|
||||
];
|
||||
const OPERATION_TABLE_HEADLIST = [ // 유지관리 정보 - 사업 현황
|
||||
{ id: 'rowNumber', title: '일련번호' },
|
||||
const OPERATION_TABLE_HEADLIST = [
|
||||
// 유지관리 정보 - 사업 현황
|
||||
{ id: "rowNumber", title: "순번" },
|
||||
{ id: "bizInfoNm", title: "사업명" },
|
||||
{ id: "flfmtBgngYmd", title: "수행시작일자" },
|
||||
{ id: "flfmtEndYmd", title: "수행종료일자" },
|
||||
@@ -88,8 +83,9 @@ export default function useOperationTab() {
|
||||
{ id: "bizTypeCd", title: "사업유형" },
|
||||
{ id: "bizAmt", title: "사업금액" },
|
||||
];
|
||||
const MAINTENANACE_TABLE_HEADLIST = [ // 유지관리 정보 - 보수 및 갱신 결과표
|
||||
{ id: 'rowNumber', title: '일련번호' },
|
||||
const MAINTENANACE_TABLE_HEADLIST = [
|
||||
// 유지관리 정보 - 보수 및 갱신 결과표
|
||||
{ id: "rowNumber", title: "순번" },
|
||||
{ id: "fcltMngNo", title: "관리번호" },
|
||||
{ id: "swtrmt_zone_nm", title: "처리구역" },
|
||||
{ id: "usgCd", title: "용도" },
|
||||
@@ -101,8 +97,9 @@ export default function useOperationTab() {
|
||||
{ id: "instlYmd", title: "설치일자" },
|
||||
// { id: "instlYmd", title: "사업구분" },
|
||||
];
|
||||
const CLEANING_TABLE_HEADLIST = [ // 유지관리 정보 - 청소 및 준설 결과표
|
||||
{ id: 'rowNumber', title: '일련번호' },
|
||||
const CLEANING_TABLE_HEADLIST = [
|
||||
// 유지관리 정보 - 청소 및 준설 결과표
|
||||
{ id: "rowNumber", title: "순번" },
|
||||
{ id: "fcltMngNo", title: "관리번호" },
|
||||
{ id: "swtrmt_zone_nm", title: "처리구역" },
|
||||
{ id: "usgCd", title: "용도" },
|
||||
@@ -113,8 +110,9 @@ export default function useOperationTab() {
|
||||
{ id: "prlgVl", title: "연장(m)" },
|
||||
{ id: "instlYmd", title: "설치일자" },
|
||||
];
|
||||
const INSPECTION_TABLE_HEADLIST = [ // 유지관리 정보 - 점검 및 진단 결과표
|
||||
{ id: 'rowNumber', title: '일련번호' },
|
||||
const INSPECTION_TABLE_HEADLIST = [
|
||||
// 유지관리 정보 - 점검 및 진단 결과표
|
||||
{ id: "rowNumber", title: "순번" },
|
||||
{ id: "fcltMngNo", title: "관리번호" },
|
||||
{ id: "swtrmt_zone_nm", title: "처리구역" },
|
||||
{ id: "usgCd", title: "용도" },
|
||||
@@ -136,18 +134,13 @@ export default function useOperationTab() {
|
||||
size: `${OPERATION_TABLE_SIZE}`,
|
||||
};
|
||||
if (var1) RequestDTO.bizInfoNm = var1;
|
||||
if (var2 != '0' && var2) RequestDTO.bizTypeCd = var2;
|
||||
if (var2 != "0" && var2) RequestDTO.bizTypeCd = var2;
|
||||
// 수행기간 범위
|
||||
const startDate = getDateString(
|
||||
yearFilter.start,
|
||||
'',
|
||||
'',
|
||||
"19000101",
|
||||
);
|
||||
const startDate = getDateString(yearFilter.start, "", "", "19000101");
|
||||
const endDate = getDateString(
|
||||
yearFilter.end,
|
||||
'',
|
||||
'',
|
||||
"",
|
||||
"",
|
||||
`${todayYear}${todayMonth}${todayDate}`,
|
||||
);
|
||||
RequestDTO.fromflfmtEndYmd = startDate;
|
||||
@@ -155,24 +148,26 @@ export default function useOperationTab() {
|
||||
|
||||
// JSON.parse 사용으로 문자열 -> 배열 변환
|
||||
RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"'));
|
||||
appendSortParameter(RequestDTO, sort, 'list');
|
||||
|
||||
const params = new URLSearchParams(RequestDTO).toString();
|
||||
|
||||
const res = await getData(`v1/bizInfo?${params}`);
|
||||
const totalElements = Number(res.totalElements ?? 0);
|
||||
|
||||
const mappedContent = (res.content || []).map((item: any, index: number) => {
|
||||
const mappedContent = (res.content || []).map(
|
||||
(item: any, index: number) => {
|
||||
const formattedItem = formatDisplayData(item);
|
||||
const matched = BIZ_TY_CD.find(code => code.lwpoCd === item.bizTypeCd);
|
||||
const matched = BIZ_TY_CD.find(
|
||||
(code) => code.lwpoCd === item.bizTypeCd,
|
||||
);
|
||||
return {
|
||||
...formattedItem,
|
||||
rowNumber: sort?.key === 'rowNumber' && sort.direction === 'desc'
|
||||
? totalElements - ((curPage - 1) * OPERATION_TABLE_SIZE + index)
|
||||
: (curPage - 1) * OPERATION_TABLE_SIZE + index + 1,
|
||||
rowNumber:
|
||||
totalElements - ((curPage - 1) * OPERATION_TABLE_SIZE + index),
|
||||
bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
setTableData(mappedContent || []);
|
||||
setTableCount(totalElements);
|
||||
@@ -181,11 +176,13 @@ export default function useOperationTab() {
|
||||
};
|
||||
|
||||
// 유지관리 정보 테이블 cellClick 핸들러
|
||||
const handleOperationCellClick = async (index?: number | undefined, headId?: String) => {
|
||||
const handleOperationCellClick = async (
|
||||
index?: number | undefined,
|
||||
headId?: String,
|
||||
) => {
|
||||
setIsResetDetailPage(true); // 페이지를 1로 초기화
|
||||
setIsResetDetailSelection(true); // 선택 초기화
|
||||
setIsResetDetailPage(true); // 디테일 페이지를 초기화
|
||||
setDetailSort(undefined); // 새 사업을 선택하면 이전 상세 정렬을 해제한다.
|
||||
|
||||
if (typeof index === "number") {
|
||||
setIsDetailLoading(true);
|
||||
@@ -193,33 +190,45 @@ export default function useOperationTab() {
|
||||
setDetailCurPage(1); // 상세 페이지 초기화
|
||||
try {
|
||||
const RequestDTO: Record<string, string> = {
|
||||
page: '0',
|
||||
page: "0",
|
||||
size: `${OPERATION_TABLE_SIZE}`,
|
||||
bizId: tableData[index % OPERATION_TABLE_SIZE].bizId,
|
||||
};
|
||||
|
||||
RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"'));
|
||||
|
||||
const res = await getData(`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`);
|
||||
const res = await getData(
|
||||
`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`,
|
||||
);
|
||||
const totalDetailElements = Number(res.count ?? 0);
|
||||
|
||||
const mappedData = (res.data || []).map((item: any, detailIndex: number) => {
|
||||
const mappedData = (res.data || []).map(
|
||||
(item: any, detailIndex: number) => {
|
||||
const formattedItem = formatDisplayData(item);
|
||||
|
||||
const usgCdObj = MS_SWAGE_PPL_USE_CODES.find(code => code.val === item?.usgCd?.trim());
|
||||
const mqltCdObj = MS_SWAGE_PPL_CODES.find(code => code.val === item?.mqltCd?.trim());
|
||||
const usgCdObj = MS_SWAGE_PPL_USE_CODES.find(
|
||||
(code) => code.val === item?.usgCd?.trim(),
|
||||
);
|
||||
const mqltCdObj = MS_SWAGE_PPL_CODES.find(
|
||||
(code) => code.val === item?.mqltCd?.trim(),
|
||||
);
|
||||
return {
|
||||
...formattedItem,
|
||||
rowNumber: detailIndex + 1,
|
||||
rowNumber: totalDetailElements - detailIndex,
|
||||
usgCd: usgCdObj ? usgCdObj.text : item.usgCd,
|
||||
mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd,
|
||||
swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : '-',
|
||||
swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : "-",
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
setDetailTableData(mappedData);
|
||||
setDetailTableCount(res.count || 0);
|
||||
setDetailTableCount(totalDetailElements);
|
||||
} catch (error) {
|
||||
console.error('유지관리 정보 cellClick 에러:', error);
|
||||
console.error("유지관리 정보 cellClick 에러:", error);
|
||||
setDetailTableData([]);
|
||||
setDetailTableCount(0);
|
||||
alert("선택한 사업의 결과 목록을 불러오지 못했습니다.");
|
||||
} finally {
|
||||
setIsDetailLoading(false);
|
||||
setIsResetDetailPage(false);
|
||||
@@ -229,7 +238,10 @@ export default function useOperationTab() {
|
||||
};
|
||||
|
||||
// 유지관리 정보 디테일 테이블 cellClick 핸들러
|
||||
const handleOperationResultCellClick = async (index?: number | undefined, headId?: String) => {
|
||||
const handleOperationResultCellClick = async (
|
||||
index?: number | undefined,
|
||||
headId?: String,
|
||||
) => {
|
||||
if (typeof index === "number") {
|
||||
const row = detailTableData[index];
|
||||
|
||||
@@ -256,7 +268,7 @@ export default function useOperationTab() {
|
||||
};
|
||||
|
||||
// 유지관리 정보 디테일 테이블 페이지네이션 핸들러
|
||||
const handleOperationPageNation = async (index?: number | undefined, requestedSort?: UiTableSort) => {
|
||||
const handleOperationPageNation = async (index?: number | undefined) => {
|
||||
if (typeof index === "number") {
|
||||
setIsDetailLoading(true);
|
||||
try {
|
||||
@@ -265,31 +277,38 @@ export default function useOperationTab() {
|
||||
size: `${OPERATION_TABLE_SIZE}`,
|
||||
bizId: String(detailBizId),
|
||||
};
|
||||
const activeDetailSort = requestedSort ?? useSearchModalStore.getState().detailSort;
|
||||
appendSortParameter(RequestDTO, activeDetailSort, 'facility');
|
||||
const res = await getData(`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`);
|
||||
const res = await getData(
|
||||
`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`,
|
||||
);
|
||||
const totalElements = Number(res.count ?? 0);
|
||||
|
||||
const mappedData = (res.data || []).map((item: any, detailIndex: number) => {
|
||||
const mappedData = (res.data || []).map(
|
||||
(item: any, detailIndex: number) => {
|
||||
const formattedItem = formatDisplayData(item);
|
||||
|
||||
const usgCdObj = MS_SWAGE_PPL_USE_CODES.find(code => code.val === item?.usgCd?.trim());
|
||||
const mqltCdObj = MS_SWAGE_PPL_CODES.find(code => code.val === item?.mqltCd?.trim());
|
||||
const usgCdObj = MS_SWAGE_PPL_USE_CODES.find(
|
||||
(code) => code.val === item?.usgCd?.trim(),
|
||||
);
|
||||
const mqltCdObj = MS_SWAGE_PPL_CODES.find(
|
||||
(code) => code.val === item?.mqltCd?.trim(),
|
||||
);
|
||||
return {
|
||||
...formattedItem,
|
||||
rowNumber: activeDetailSort?.key === 'rowNumber' && activeDetailSort.direction === 'desc'
|
||||
? totalElements - ((detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex)
|
||||
: (detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex + 1,
|
||||
rowNumber:
|
||||
totalElements -
|
||||
((detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex),
|
||||
usgCd: usgCdObj ? usgCdObj.text : item.usgCd,
|
||||
mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd,
|
||||
swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : '-',
|
||||
swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : "-",
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
setDetailTableData(mappedData);
|
||||
setDetailTableCount(totalElements);
|
||||
} catch (error) {
|
||||
console.error('유지관리 정보 페이지네이션 에러:', error);
|
||||
console.error("유지관리 정보 페이지네이션 에러:", error);
|
||||
alert("선택한 사업의 결과 목록을 불러오지 못했습니다.");
|
||||
} finally {
|
||||
setIsDetailLoading(false);
|
||||
}
|
||||
@@ -303,34 +322,30 @@ export default function useOperationTab() {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss("global-confirm-toast");
|
||||
|
||||
confirmToast(
|
||||
if (
|
||||
!(await confirm(
|
||||
"파일 다운로드에는 2분 내외의 시간이 소요될 수 있습니다.\n파일 다운로드를 진행하시겠습니까?",
|
||||
onAnalysis ?? (() => { }),
|
||||
"text-xl font-medium text-gray-800"
|
||||
);
|
||||
))
|
||||
)
|
||||
return;
|
||||
|
||||
await onAnalysis();
|
||||
|
||||
async function onAnalysis() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const RequestDTO: Record<string, string> = {
|
||||
size: '10000',
|
||||
size: "10000",
|
||||
};
|
||||
if (var1) RequestDTO.bizInfoNm = var1;
|
||||
if (var2 != '0' && var2) RequestDTO.bizTypeCd = var2;
|
||||
if (var2 != "0" && var2) RequestDTO.bizTypeCd = var2;
|
||||
// 수행기간 범위
|
||||
const startDate = getDateString(
|
||||
yearFilter.start,
|
||||
'',
|
||||
'',
|
||||
"19000101",
|
||||
);
|
||||
const startDate = getDateString(yearFilter.start, "", "", "19000101");
|
||||
const endDate = getDateString(
|
||||
yearFilter.end,
|
||||
'',
|
||||
'',
|
||||
"",
|
||||
"",
|
||||
`${todayYear}${todayMonth}${todayDate}`,
|
||||
);
|
||||
RequestDTO.fromflfmtEndYmd = startDate;
|
||||
@@ -338,33 +353,40 @@ export default function useOperationTab() {
|
||||
|
||||
// JSON.parse 사용으로 문자열 -> 배열 변환
|
||||
RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"'));
|
||||
appendSortParameter(RequestDTO, sort, 'list');
|
||||
|
||||
const params = new URLSearchParams(RequestDTO).toString();
|
||||
const res = await getData(`v1/bizInfo?${params}`);
|
||||
|
||||
// 헤더 매핑 정의
|
||||
const headerMapping: { [key: string]: string } = {
|
||||
'bizInfoNm': '사업명',
|
||||
'flfmtBgngYmd': '수행시작일자',
|
||||
'flfmtEndYmd': '수행종료일자',
|
||||
'flfmtYr': '수행년도',
|
||||
'bizTypeCd': '사업유형',
|
||||
'bizAmt': '사업금액(원)',
|
||||
bizInfoNm: "사업명",
|
||||
flfmtBgngYmd: "수행시작일자",
|
||||
flfmtEndYmd: "수행종료일자",
|
||||
flfmtYr: "수행년도",
|
||||
bizTypeCd: "사업유형",
|
||||
bizAmt: "사업금액(원)",
|
||||
};
|
||||
|
||||
const bizTyCdMap = new Map(BIZ_TY_CD.map(code => [code.lwpoCd, code.lwpoCdNm]));
|
||||
const bizTyCdMap = new Map(
|
||||
BIZ_TY_CD.map((code) => [code.lwpoCd, code.lwpoCdNm]),
|
||||
);
|
||||
|
||||
const mappedContent = (res.content || []).map((item: any) => {
|
||||
const formattedItem = formatDisplayData(item);
|
||||
|
||||
return {
|
||||
bizInfoNm: formattedItem.bizInfoNm ?? item.bizInfoNm ?? '',
|
||||
flfmtBgngYmd: formattedItem.flfmtBgngYmd ?? item.flfmtBgngYmd ?? '',
|
||||
flfmtEndYmd: formattedItem.flfmtEndYmd ?? item.flfmtEndYmd ?? '',
|
||||
flfmtYr: formattedItem.flfmtYr ?? item.flfmtYr ?? '',
|
||||
bizTypeCd: bizTyCdMap.get(item.bizTypeCd) || item.bizTypeCd || formattedItem.bizTypeCd || '',
|
||||
bizAmt: ((v) => (v === 0 || v === '0') ? '0' : (v ?? ''))(formattedItem.bizAmt ?? item.bizAmt),
|
||||
bizInfoNm: formattedItem.bizInfoNm ?? item.bizInfoNm ?? "",
|
||||
flfmtBgngYmd: formattedItem.flfmtBgngYmd ?? item.flfmtBgngYmd ?? "",
|
||||
flfmtEndYmd: formattedItem.flfmtEndYmd ?? item.flfmtEndYmd ?? "",
|
||||
flfmtYr: formattedItem.flfmtYr ?? item.flfmtYr ?? "",
|
||||
bizTypeCd:
|
||||
bizTyCdMap.get(item.bizTypeCd) ||
|
||||
item.bizTypeCd ||
|
||||
formattedItem.bizTypeCd ||
|
||||
"",
|
||||
bizAmt: ((v) => (v === 0 || v === "0" ? "0" : (v ?? "")))(
|
||||
formattedItem.bizAmt ?? item.bizAmt,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -372,7 +394,7 @@ export default function useOperationTab() {
|
||||
const excelData = mappedContent; // 배열로 감싸서 전달
|
||||
|
||||
// 파일명 생성 (분석번호 기반)
|
||||
const label = WORK_TYPES.find((type) => type.val === workType)?.text
|
||||
const label = WORK_TYPES.find((type) => type.val === workType)?.text;
|
||||
const fileName = `${label} 사업 현황_${todayYear}${todayMonth}${todayDate}`;
|
||||
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
@@ -380,7 +402,7 @@ export default function useOperationTab() {
|
||||
fileName: fileName,
|
||||
sheetNameArray: [fileName],
|
||||
headerRange: "A1:F1",
|
||||
headerMapping: headerMapping
|
||||
headerMapping: headerMapping,
|
||||
});
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
@@ -398,42 +420,47 @@ export default function useOperationTab() {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss("global-confirm-toast");
|
||||
|
||||
confirmToast(
|
||||
if (
|
||||
!(await confirm(
|
||||
"파일 다운로드에는 2분 내외의 시간이 소요될 수 있습니다.\n파일 다운로드를 진행하시겠습니까?",
|
||||
onAnalysis ?? (() => { }),
|
||||
"text-xl font-medium text-gray-800"
|
||||
);
|
||||
))
|
||||
)
|
||||
return;
|
||||
|
||||
await onAnalysis();
|
||||
|
||||
async function onAnalysis() {
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const RequestDTO: Record<string, string> = {
|
||||
size: '100',
|
||||
size: "100",
|
||||
bizId: String(detailBizId),
|
||||
};
|
||||
appendSortParameter(RequestDTO, detailSort, 'facility');
|
||||
|
||||
const res = await getData(`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`);
|
||||
const res = await getData(
|
||||
`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`,
|
||||
);
|
||||
|
||||
// 헤더 매핑 정의
|
||||
const headerMapping: { [key: string]: string } = {
|
||||
'fcltMngNo': '관리번호',
|
||||
'swtrmt_zone_nm': '처리구역',
|
||||
'usgCd': '용도',
|
||||
'mqltCd': '재질',
|
||||
'pdmtVl': '관경(mm)',
|
||||
'prlgVl': '연장(m)',
|
||||
'instlYmd': '설치일자',
|
||||
'strc_stts_grd': '구조적 상태등급',
|
||||
'oper_stts_grd': '운영적 상태등급',
|
||||
fcltMngNo: "관리번호",
|
||||
swtrmt_zone_nm: "처리구역",
|
||||
usgCd: "용도",
|
||||
mqltCd: "재질",
|
||||
pdmtVl: "관경(mm)",
|
||||
prlgVl: "연장(m)",
|
||||
instlYmd: "설치일자",
|
||||
strc_stts_grd: "구조적 상태등급",
|
||||
oper_stts_grd: "운영적 상태등급",
|
||||
};
|
||||
|
||||
const usgCdMap = new Map(MS_SWAGE_PPL_USE_CODES.map(code => [code.val, code.text]));
|
||||
const mqltCdMap = new Map(MS_SWAGE_PPL_CODES.map(code => [code.val, code.text]));
|
||||
const usgCdMap = new Map(
|
||||
MS_SWAGE_PPL_USE_CODES.map((code) => [code.val, code.text]),
|
||||
);
|
||||
const mqltCdMap = new Map(
|
||||
MS_SWAGE_PPL_CODES.map((code) => [code.val, code.text]),
|
||||
);
|
||||
|
||||
// 🎯 단일 반복문으로 최적화 - 각 아이템마다 필요한 필드만 직접 매핑
|
||||
const mappedData = (res.data || []).map((item: any) => {
|
||||
@@ -444,19 +471,35 @@ export default function useOperationTab() {
|
||||
const trimmedMqltCd = item?.mqltCd?.trim();
|
||||
|
||||
// zone 값 한 번만 계산
|
||||
const zone = item.swtrmtZoneNm ?? formattedItem.swtrmt_zone_nm ?? item.swtrmt_zone_nm ?? '';
|
||||
const zone =
|
||||
item.swtrmtZoneNm ??
|
||||
formattedItem.swtrmt_zone_nm ??
|
||||
item.swtrmt_zone_nm ??
|
||||
"";
|
||||
|
||||
// 직접 매핑 - 반복문 없이 한 번에 객체 생성
|
||||
return {
|
||||
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? '',
|
||||
swtrmt_zone_nm: (zone === null || zone === '') ? '-' : zone,
|
||||
usgCd: usgCdMap.get(trimmedUsgCd) || item.usgCd || formattedItem.usgCd || '',
|
||||
mqltCd: mqltCdMap.get(trimmedMqltCd) || item.mqltCd || formattedItem.mqltCd || '',
|
||||
pdmtVl: formattedItem.pdmtVl ?? item.pdmtVl ?? '',
|
||||
prlgVl: formattedItem.prlgVl ?? item.prlgVl ?? '',
|
||||
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? '',
|
||||
strc_stts_grd: ((v) => (v === 0 || v === '0') ? '0' : (v ?? ''))(formattedItem.strc_stts_grd ?? item.strc_stts_grd),
|
||||
oper_stts_grd: ((v) => (v === 0 || v === '0') ? '0' : (v ?? ''))(formattedItem.oper_stts_grd ?? item.oper_stts_grd),
|
||||
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? "",
|
||||
swtrmt_zone_nm: zone === null || zone === "" ? "-" : zone,
|
||||
usgCd:
|
||||
usgCdMap.get(trimmedUsgCd) ||
|
||||
item.usgCd ||
|
||||
formattedItem.usgCd ||
|
||||
"",
|
||||
mqltCd:
|
||||
mqltCdMap.get(trimmedMqltCd) ||
|
||||
item.mqltCd ||
|
||||
formattedItem.mqltCd ||
|
||||
"",
|
||||
pdmtVl: formattedItem.pdmtVl ?? item.pdmtVl ?? "",
|
||||
prlgVl: formattedItem.prlgVl ?? item.prlgVl ?? "",
|
||||
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? "",
|
||||
strc_stts_grd: ((v) => (v === 0 || v === "0" ? "0" : (v ?? "")))(
|
||||
formattedItem.strc_stts_grd ?? item.strc_stts_grd,
|
||||
),
|
||||
oper_stts_grd: ((v) => (v === 0 || v === "0" ? "0" : (v ?? "")))(
|
||||
formattedItem.oper_stts_grd ?? item.oper_stts_grd,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -464,7 +507,7 @@ export default function useOperationTab() {
|
||||
const excelData = mappedData; // 배열로 감싸서 전달
|
||||
|
||||
// 파일명 생성 (분석번호 기반)
|
||||
const label = WORK_TYPES.find((type) => type.val === workType)?.text
|
||||
const label = WORK_TYPES.find((type) => type.val === workType)?.text;
|
||||
const fileName = `${label} 결과표_${todayYear}${todayMonth}${todayDate}`;
|
||||
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
@@ -472,7 +515,7 @@ export default function useOperationTab() {
|
||||
fileName: fileName,
|
||||
sheetNameArray: [fileName],
|
||||
headerRange: "A1:I1",
|
||||
headerMapping: headerMapping
|
||||
headerMapping: headerMapping,
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
@@ -498,7 +541,6 @@ export default function useOperationTab() {
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
return {
|
||||
WORK_TYPES,
|
||||
OPERATION_TABLE_HEADLIST,
|
||||
@@ -512,6 +554,6 @@ export default function useOperationTab() {
|
||||
handleOperationPageNation,
|
||||
emitMultipleFeaturesToMap,
|
||||
operationExcelDownload,
|
||||
operationResultExcelDownload
|
||||
operationResultExcelDownload,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { getDcData } from '@/components/lib/api';
|
||||
import { fileDownload } from '@/utils';
|
||||
import { useReportTabStore } from '@/store/useReportTabStore';
|
||||
import useSearchModalStore from '@/store/useSearchModalStore';
|
||||
import { useAlertStore } from '@/shared/lib';
|
||||
|
||||
export type Folder = {
|
||||
clsfSystmSn: string;
|
||||
@@ -50,6 +51,7 @@ const buildDisplayFileName = (files: ReportDataFile[]) => {
|
||||
};
|
||||
|
||||
export default function useReportTab() {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const REPORT_TYPES = [
|
||||
{ val: '0', text: '전체' },
|
||||
{ val: '1', text: '보고서' },
|
||||
|
||||
@@ -12,21 +12,23 @@
|
||||
* 🔗 사용 API
|
||||
* - 유지관리 정보 목록 조회: GET /api/v1/bizInfo (sewerage-service)
|
||||
*/
|
||||
import { getData } from '@/components/lib/api';
|
||||
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
|
||||
import useStore from '../../../store/useStore';
|
||||
import GisApp from '@common/business_components/ui/map/lib/gis.core';
|
||||
import useSearchModalStore from '../../../store/useSearchModalStore';
|
||||
import useBasicTab from './useBasicTab';
|
||||
import useOperationTab from './useOperationTab';
|
||||
import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage';
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
|
||||
import type { UiTableSort } from "@common/business_components/ui/table";
|
||||
import { getData } from "@/components/lib/api";
|
||||
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
|
||||
import useStore from "../../../store/useStore";
|
||||
import GisApp from "@common/business_components/ui/map/lib/gis.core";
|
||||
import useSearchModalStore, {
|
||||
SEARCH_MODAL_DEFAULT_BASIC_TYPE,
|
||||
SEARCH_MODAL_DEFAULT_WORK_TYPE,
|
||||
} from "../../../store/useSearchModalStore";
|
||||
import useBasicTab from "./useBasicTab";
|
||||
import useOperationTab from "./useOperationTab";
|
||||
import { postPopupMessageAndClose } from "@/features/windowPopup/lib/popupMessage";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
|
||||
|
||||
const isPopupWindow = () =>
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window !== "undefined" &&
|
||||
!!window.opener &&
|
||||
window.location.pathname.startsWith('/popup/');
|
||||
window.location.pathname.startsWith("/popup/");
|
||||
|
||||
export default function useSearchModal() {
|
||||
const {
|
||||
@@ -49,13 +51,13 @@ export default function useSearchModal() {
|
||||
const { BIZ_TY_CD } = useStore();
|
||||
|
||||
const TABS = [
|
||||
{ val: 'basic', text: '기본 정보' },
|
||||
{ val: 'operation', text: '유지관리 정보' },
|
||||
{ val: 'report', text: '하수도 정비 기본계획 보고서' },
|
||||
{ val: "basic", text: "기본 정보" },
|
||||
{ val: "operation", text: "유지관리 정보" },
|
||||
{ val: "report", text: "하수도 정비 기본계획 보고서" },
|
||||
];
|
||||
|
||||
// 검색 API 호출 함수
|
||||
const fetchFilteredData = async (requestedSort?: UiTableSort) => {
|
||||
const fetchFilteredData = async () => {
|
||||
const {
|
||||
activeTab: currentTab,
|
||||
curPage,
|
||||
@@ -74,62 +76,51 @@ export default function useSearchModal() {
|
||||
setIsLoading,
|
||||
setIsResetSelection,
|
||||
setIsResetDetailSelection,
|
||||
sort,
|
||||
} = useSearchModalStore.getState();
|
||||
const activeSort = requestedSort ?? sort;
|
||||
|
||||
setIsLoading(true);
|
||||
setIsResetSelection(true);
|
||||
setIsResetDetailSelection(true);
|
||||
|
||||
try {
|
||||
if (currentTab === 'basic') {
|
||||
await fetchBasicFilteredData(activeSort);
|
||||
} else if (currentTab === 'operation') {
|
||||
if (currentTab === "basic") {
|
||||
await fetchBasicFilteredData();
|
||||
} else if (currentTab === "operation") {
|
||||
const requestDto: Record<string, string> = {
|
||||
page: `${curPage - 1}`,
|
||||
size: `${OPERATION_TABLE_SIZE}`,
|
||||
};
|
||||
if (var1) requestDto.bizInfoNm = var1;
|
||||
if (var2 !== '0' && var2) requestDto.bizTypeCd = var2;
|
||||
if (var2 !== "0" && var2) requestDto.bizTypeCd = var2;
|
||||
|
||||
const startDate = getDateString(
|
||||
yearFilter.start,
|
||||
'',
|
||||
'',
|
||||
'19000101',
|
||||
);
|
||||
const startDate = getDateString(yearFilter.start, "", "", "19000101");
|
||||
const endDate = getDateString(
|
||||
yearFilter.end,
|
||||
'',
|
||||
'',
|
||||
"",
|
||||
"",
|
||||
`${todayYear}${todayMonth}${todayDate}`,
|
||||
);
|
||||
requestDto.fromflfmtEndYmd = startDate;
|
||||
requestDto.toflfmtEndYmd = endDate;
|
||||
requestDto.workTypes = JSON.parse(workType.replace(/'/g, '"'));
|
||||
if (activeSort) {
|
||||
const requestSort = activeSort.key === 'rowNumber'
|
||||
? { key: 'bizId', direction: activeSort.direction === 'asc' ? 'desc' : 'asc' }
|
||||
: activeSort;
|
||||
requestDto.sort = `${requestSort.key},${requestSort.direction}`;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(requestDto).toString();
|
||||
const res = await getData(`v1/bizInfo?${params}`);
|
||||
const totalElements = Number(res.totalElements ?? 0);
|
||||
|
||||
const mappedContent = (res.content || []).map((item: any, index: number) => {
|
||||
const mappedContent = (res.content || []).map(
|
||||
(item: any, index: number) => {
|
||||
const formattedItem = formatDisplayData(item);
|
||||
const matched = BIZ_TY_CD.find((code) => code.lwpoCd === item.bizTypeCd);
|
||||
const matched = BIZ_TY_CD.find(
|
||||
(code) => code.lwpoCd === item.bizTypeCd,
|
||||
);
|
||||
return {
|
||||
...formattedItem,
|
||||
rowNumber: activeSort?.key === 'rowNumber' && activeSort.direction === 'desc'
|
||||
? totalElements - ((curPage - 1) * OPERATION_TABLE_SIZE + index)
|
||||
: (curPage - 1) * OPERATION_TABLE_SIZE + index + 1,
|
||||
rowNumber:
|
||||
totalElements - ((curPage - 1) * OPERATION_TABLE_SIZE + index),
|
||||
bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
setTableData(mappedContent || []);
|
||||
setTableCount(totalElements);
|
||||
@@ -140,7 +131,7 @@ export default function useSearchModal() {
|
||||
setShowTable(true);
|
||||
setIsResetSelection(false);
|
||||
} catch (err) {
|
||||
console.error('API ERROR:', err);
|
||||
console.error("API ERROR:", err);
|
||||
setTableData([]);
|
||||
setIsResetSelection(false);
|
||||
} finally {
|
||||
@@ -160,9 +151,37 @@ export default function useSearchModal() {
|
||||
setShowTable(true);
|
||||
};
|
||||
|
||||
/** 현재 탭의 조건·페이지·선택을 기본값으로 복원하고 전체 기본 목록을 즉시 재조회한다. */
|
||||
const handleResetSearch = () => {
|
||||
const state = useSearchModalStore.getState();
|
||||
const resetsBasicType =
|
||||
state.activeTab === "basic" &&
|
||||
state.basicType !== SEARCH_MODAL_DEFAULT_BASIC_TYPE;
|
||||
const resetsWorkType =
|
||||
state.activeTab === "operation" &&
|
||||
state.workType !== SEARCH_MODAL_DEFAULT_WORK_TYPE;
|
||||
state.resetFilters();
|
||||
if (state.activeTab === "basic") {
|
||||
state.setBasicType(SEARCH_MODAL_DEFAULT_BASIC_TYPE);
|
||||
}
|
||||
if (state.activeTab === "operation") {
|
||||
state.setWorkType(SEARCH_MODAL_DEFAULT_WORK_TYPE);
|
||||
}
|
||||
state.setCurPage(1);
|
||||
state.setDetailCurPage(1);
|
||||
state.setIsResetPage(true);
|
||||
state.setIsResetDetailPage(true);
|
||||
state.setIsResetSelection(true);
|
||||
state.setIsResetDetailSelection(true);
|
||||
// 구분 변경 시 SearchModal의 자동조회 effect가 실행되므로 중복 요청을 만들지 않는다.
|
||||
if (!resetsBasicType && !resetsWorkType) {
|
||||
void fetchFilteredData();
|
||||
}
|
||||
};
|
||||
|
||||
// 여러 시설 보기
|
||||
const viewMultipleFeature = async () => {
|
||||
if (activeTab === 'basic') {
|
||||
if (activeTab === "basic") {
|
||||
if (!(tableData.length === 0)) {
|
||||
if (isPopupWindow()) {
|
||||
const row = tableData[0];
|
||||
@@ -186,7 +205,7 @@ export default function useSearchModal() {
|
||||
setIsLoading(false);
|
||||
setIsSearchModalOpen(false);
|
||||
}
|
||||
} else if (activeTab === 'operation') {
|
||||
} else if (activeTab === "operation") {
|
||||
if (!(detailTableData.length === 0)) {
|
||||
if (isPopupWindow()) {
|
||||
const row = detailTableData[0];
|
||||
@@ -213,5 +232,12 @@ export default function useSearchModal() {
|
||||
}
|
||||
};
|
||||
|
||||
return { TABS, getDateString, handleSearch, viewMultipleFeature, fetchFilteredData };
|
||||
return {
|
||||
TABS,
|
||||
getDateString,
|
||||
handleSearch,
|
||||
handleResetSearch,
|
||||
viewMultipleFeature,
|
||||
fetchFilteredData,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ import { Label } from "@common/components/ui/label";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const WATER_DEPTH_LAYER_TYPES: string[] = [
|
||||
'tb_ms_flood_dngr_map', // 홍수위험지도
|
||||
'tb_ms_sbmrg_dngr_map' // 침수위험지도
|
||||
"tb_ms_flood_dngr_map", // 홍수위험지도
|
||||
"tb_ms_sbmrg_dngr_map", // 침수위험지도
|
||||
];
|
||||
|
||||
export type WaterDepthLegendItem = {
|
||||
@@ -29,7 +29,9 @@ export type WaterDepthLegendItem = {
|
||||
};
|
||||
|
||||
const getLayerTypeName = (layerInfo: any) =>
|
||||
String(layerInfo?.lyrTyNm ?? layerInfo?.lyrTypeNm ?? layerInfo?.lyr_type_nm ?? "").trim();
|
||||
String(
|
||||
layerInfo?.lyrTyNm ?? layerInfo?.lyrTypeNm ?? layerInfo?.lyr_type_nm ?? "",
|
||||
).trim();
|
||||
|
||||
const getFiniteSortOrder = (value: unknown) => {
|
||||
const parsed = Number(value);
|
||||
@@ -65,20 +67,36 @@ const getLegendNumericOrder = (label: string) => {
|
||||
};
|
||||
|
||||
const getLegendColor = (layerInfo: any) =>
|
||||
String(layerInfo?.polgnColor ?? layerInfo?.spacColor ?? layerInfo?.lnColor ?? layerInfo?.lineColor ?? "").trim();
|
||||
String(
|
||||
layerInfo?.polgnColor ??
|
||||
layerInfo?.spacColor ??
|
||||
layerInfo?.lnColor ??
|
||||
layerInfo?.lineColor ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
const getLegendBorderColor = (layerInfo: any) =>
|
||||
String(layerInfo?.lnColor ?? layerInfo?.lineColor ?? layerInfo?.polgnColor ?? layerInfo?.spacColor ?? "").trim();
|
||||
String(
|
||||
layerInfo?.lnColor ??
|
||||
layerInfo?.lineColor ??
|
||||
layerInfo?.polgnColor ??
|
||||
layerInfo?.spacColor ??
|
||||
"",
|
||||
).trim();
|
||||
|
||||
const hasLegendStyle = (layerInfo: any) => getLegendColor(layerInfo) !== "" || getLegendBorderColor(layerInfo) !== "";
|
||||
const hasLegendStyle = (layerInfo: any) =>
|
||||
getLegendColor(layerInfo) !== "" || getLegendBorderColor(layerInfo) !== "";
|
||||
|
||||
const normalizeLegendLabel = (label: string) => {
|
||||
const normalized = String(label).trim();
|
||||
return normalized.endsWith("미만") ? normalized : `${normalized} 미만`;
|
||||
};
|
||||
|
||||
export const extractWaterDepthLegendItems = (checkedNodesData: any[]): WaterDepthLegendItem[] => {
|
||||
if (!Array.isArray(checkedNodesData) || checkedNodesData.length === 0) return [];
|
||||
export const extractWaterDepthLegendItems = (
|
||||
checkedNodesData: any[],
|
||||
): WaterDepthLegendItem[] => {
|
||||
if (!Array.isArray(checkedNodesData) || checkedNodesData.length === 0)
|
||||
return [];
|
||||
|
||||
const items = new Map<string, WaterDepthLegendItem>();
|
||||
|
||||
@@ -108,8 +126,10 @@ export const extractWaterDepthLegendItems = (checkedNodesData: any[]): WaterDept
|
||||
const leftOrder = getLegendNumericOrder(left.key);
|
||||
const rightOrder = getLegendNumericOrder(right.key);
|
||||
|
||||
if (leftOrder.start !== rightOrder.start) return leftOrder.start - rightOrder.start;
|
||||
if (leftOrder.priority !== rightOrder.priority) return leftOrder.priority - rightOrder.priority;
|
||||
if (leftOrder.start !== rightOrder.start)
|
||||
return leftOrder.start - rightOrder.start;
|
||||
if (leftOrder.priority !== rightOrder.priority)
|
||||
return leftOrder.priority - rightOrder.priority;
|
||||
if (leftOrder.end !== rightOrder.end) return leftOrder.end - rightOrder.end;
|
||||
if (left.sortSeq !== right.sortSeq) return left.sortSeq - right.sortSeq;
|
||||
return left.label.localeCompare(right.label, "ko");
|
||||
@@ -133,7 +153,6 @@ export function WaterDepth({
|
||||
currentStep = 0,
|
||||
legendItems = [],
|
||||
}: WaterDepthProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (setIsPlaying) setIsPlaying(isPlaying);
|
||||
}, [isPlaying, setIsPlaying]);
|
||||
@@ -143,33 +162,50 @@ export function WaterDepth({
|
||||
};
|
||||
|
||||
// 진행률 계산 (0-100%)
|
||||
const progressPercentage = totalSteps > 0 ? (currentStep / totalSteps) * 100 : 100;
|
||||
|
||||
const progressPercentage =
|
||||
totalSteps > 0 ? (currentStep / totalSteps) * 100 : 100;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-white rounded-lg shadow-lg border border-gray-200 w-[160px] px-4 py-3">
|
||||
<div className="flex items-center justify-between mx-2 my-4">
|
||||
<Label className="text-3xl text-gray-900 font-bold">침수심 범례</Label>
|
||||
<button type="button" className="mt-1 ml-2" onClick={handlePlayToggle}>
|
||||
<div className="w-[160px] rounded-lg border border-border bg-background px-4 py-3 shadow-lg">
|
||||
<div className="mx-2 my-4 flex items-center justify-between">
|
||||
<Label className="text-3xl font-bold text-foreground">
|
||||
침수심 범례
|
||||
</Label>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 mt-1"
|
||||
onClick={handlePlayToggle}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<img src="/images/map/toolbar/pause_button.png" alt="pause" className="w-6 h-6 object-contain" />
|
||||
<img
|
||||
src="/images/map/toolbar/pause_button.png"
|
||||
alt="pause"
|
||||
className="h-6 w-6 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<img src="/images/map/toolbar/play_button_arrowhead.png" alt="play" className="w-6 h-6 object-contain" />
|
||||
<img
|
||||
src="/images/map/toolbar/play_button_arrowhead.png"
|
||||
alt="play"
|
||||
className="h-6 w-6 object-contain"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<div className="w-full bg-gray-200 rounded-full h-1">
|
||||
<div className="h-1 w-full rounded-full bg-muted">
|
||||
<div
|
||||
className="bg-blue-900 h-1 rounded-full transition-all duration-300 ease-in-out"
|
||||
className="h-1 rounded-full bg-blue-900 transition-all duration-300 ease-in-out"
|
||||
style={{ width: `${progressPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[140px] mx-auto flex flex-col items-center">
|
||||
<div className="bg-blue-900 text-white text-2xl text-center py-1 mb-2 rounded w-full">침수심 범례</div>
|
||||
<div className="space-y-1 w-full">
|
||||
<div className="mx-auto flex w-[140px] flex-col items-center">
|
||||
{/* 침수심 범례 제목은 지도 수심정보를 구분하는 의미색이므로 고정 청색을 유지한다. */}
|
||||
<div className="mb-2 w-full rounded bg-blue-900 py-1 text-center text-2xl text-white">
|
||||
침수심 범례
|
||||
</div>
|
||||
<div className="w-full space-y-1">
|
||||
{legendItems.map((item) => (
|
||||
<LegendItem
|
||||
key={item.key}
|
||||
@@ -179,21 +215,29 @@ export function WaterDepth({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t-2 border-blue-900 mt-1 mx-auto w-full" />
|
||||
<div className="mx-auto mt-1 w-full border-t-2 border-blue-900" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LegendItem({ color, borderColor, label }: { color: string; borderColor: string; label: string }) {
|
||||
function LegendItem({
|
||||
color,
|
||||
borderColor,
|
||||
label,
|
||||
}: {
|
||||
color: string;
|
||||
borderColor: string;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<span
|
||||
className="inline-block w-16 h-6"
|
||||
className="inline-block h-6 w-16"
|
||||
style={{ backgroundColor: color, border: `1px solid ${borderColor}` }}
|
||||
/>
|
||||
<span className="text-xl text-gray-900 font-bold">{label}</span>
|
||||
<span className="text-xl font-bold text-foreground">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ export default function PopupLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<main className="min-h-dvh bg-white text-foreground">
|
||||
{children}
|
||||
</main>
|
||||
<main className="min-h-dvh bg-background text-foreground">{children}</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isWindowPopupKey } from "@/features/windowPopup/model/popupDefinitions";
|
||||
import { windowPopupRegistry } from "@/features/windowPopup/registry";
|
||||
import WindowPopupRenderer from "@/features/windowPopup/ui/WindowPopupRenderer";
|
||||
|
||||
type PopupPageProps = {
|
||||
params: Promise<{
|
||||
@@ -19,12 +19,15 @@ export default async function PopupPage({
|
||||
return (
|
||||
<section className="flex min-h-dvh flex-col items-center justify-center gap-4 p-8 text-center">
|
||||
<h1 className="text-2xl font-semibold">등록되지 않은 팝업입니다.</h1>
|
||||
<p className="text-base text-gray-600">popupKey: {popupKey}</p>
|
||||
<p className="text-base text-label">popupKey: {popupKey}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const PopupComponent = windowPopupRegistry[popupKey].component;
|
||||
|
||||
return <PopupComponent searchParams={resolvedSearchParams} />;
|
||||
return (
|
||||
<WindowPopupRenderer
|
||||
popupKey={popupKey}
|
||||
searchParams={resolvedSearchParams}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,58 +11,57 @@
|
||||
* - 양식 파일 다운로드 링크 제공
|
||||
* - 폼 제출 및 이벤트 핸들링
|
||||
*/
|
||||
'use client'
|
||||
import { DateMonthPicker, Form, SelectBox } from '@common/business_components/ui'
|
||||
import ListDiv from '@common/business_components/ui/list_div'
|
||||
import { Button, Label } from '@common/components/ui'
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox'
|
||||
import { confirmToast } from '@/components/ui/toast'
|
||||
import { toast, Toaster } from "react-hot-toast";
|
||||
"use client";
|
||||
import { DateMonthPicker, Form } from "@common/business_components/ui";
|
||||
import { Button, Label } from "@common/components/ui";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import type { ReactNode } from "react";
|
||||
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
|
||||
|
||||
type TitleName = {
|
||||
mainTitle: string
|
||||
firstCardTitle: string
|
||||
secondCardTitle?: string
|
||||
}
|
||||
mainTitle: string;
|
||||
firstCardTitle: string;
|
||||
secondCardTitle?: string;
|
||||
};
|
||||
type SelectBox = {
|
||||
labelNm?: Label
|
||||
searchChk?: boolean
|
||||
labelNm?: Label;
|
||||
searchChk?: boolean;
|
||||
title?: string;
|
||||
onSearchClick?: () => Promise<void>;
|
||||
}
|
||||
onResetClick?: () => void | Promise<void>;
|
||||
};
|
||||
type Label = {
|
||||
first: string
|
||||
second: string
|
||||
third: string
|
||||
fourth: string
|
||||
}
|
||||
first: string;
|
||||
second: string;
|
||||
third: string;
|
||||
fourth: string;
|
||||
};
|
||||
// API로부터 받아올 데이터 타입을 정의 (SelectBox에 사용될 형태)
|
||||
interface ApiSelectOption {
|
||||
text: string;
|
||||
val: string;
|
||||
}
|
||||
type BaseLayout = {
|
||||
children?: ReactNode[]
|
||||
titleName: TitleName
|
||||
form: any
|
||||
onSubmitEvent: Function
|
||||
graphBtn?: boolean
|
||||
analysisBtn?: string
|
||||
resultViewBtn?: string
|
||||
firstDivBtn?: boolean
|
||||
selectBox?: SelectBox
|
||||
selectionComponent?: ReactNode
|
||||
children?: ReactNode[];
|
||||
titleName: TitleName;
|
||||
form: any;
|
||||
onSubmitEvent: Function;
|
||||
graphBtn?: boolean;
|
||||
analysisBtn?: string;
|
||||
resultViewBtn?: string;
|
||||
firstDivBtn?: boolean;
|
||||
selectBox?: SelectBox;
|
||||
selectionComponent?: ReactNode;
|
||||
showDatePicker?: boolean;
|
||||
ctpvCd: string;
|
||||
sggCd: string;
|
||||
sggName?: string;
|
||||
sigungu?: ApiSelectOption
|
||||
sigungu?: ApiSelectOption;
|
||||
selectedSwagePrcsFclt?: string;
|
||||
onSwagePrcsFcltChange?: (value: string) => void;
|
||||
aplcnYmFrom?: string
|
||||
aplcnYmFrom?: string;
|
||||
setAplcnYmFrom?: (aplcnYmFrom: string) => void;
|
||||
aplcnYmTo?: string
|
||||
aplcnYmTo?: string;
|
||||
setAplcnYmTo?: (aplcnYmTo: string) => void;
|
||||
setCtpvCd: (ctpv: string) => void;
|
||||
setSggCd: (sgg: string) => void;
|
||||
@@ -72,12 +71,12 @@ type BaseLayout = {
|
||||
setIsLoading?: (isLoading: boolean) => void;
|
||||
onExcelDownload?: () => void;
|
||||
onDeleteFile?: () => void;
|
||||
onAnalysis?: () => void;
|
||||
onAnalysis?: () => void | Promise<void>;
|
||||
onResultView?: () => void;
|
||||
selectedRowData?: any;
|
||||
isIr?: boolean;
|
||||
isRdii?: boolean;
|
||||
}
|
||||
};
|
||||
|
||||
const BaseLayout = ({
|
||||
children,
|
||||
@@ -113,14 +112,8 @@ const BaseLayout = ({
|
||||
isIr = false,
|
||||
isRdii = false,
|
||||
}: BaseLayout) => {
|
||||
|
||||
// 컴포넌트 언마운트 시 confirmToast 정리
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// 컴포넌트가 언마운트될 때 global-confirm-toast ID를 가진 Toast 제거
|
||||
toast.dismiss("global-confirm-toast");
|
||||
};
|
||||
}, []);
|
||||
// 분석 실행 전 CS와 동일한 전역 확인 대화상자를 호출한다.
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
// 파일 다운로드
|
||||
const isChckFile = () => {
|
||||
@@ -129,8 +122,8 @@ const BaseLayout = ({
|
||||
onExcelDownload();
|
||||
return;
|
||||
}
|
||||
return true
|
||||
}
|
||||
return true;
|
||||
};
|
||||
// 파일 삭제
|
||||
const isDeleteFile = async () => {
|
||||
// Excel 다운로드 함수가 있으면 Excel 다운로드 실행
|
||||
@@ -138,36 +131,41 @@ const BaseLayout = ({
|
||||
onDeleteFile();
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
// 기존 confirmToast가 있다면 먼저 제거
|
||||
toast.dismiss("global-confirm-toast");
|
||||
|
||||
confirmToast(
|
||||
const handleConfirm = async () => {
|
||||
const shouldRunAnalysis = await confirm(
|
||||
"분석에는 2분 내외의 시간이 소요될 수 있습니다.\n분석을 진행하시겠습니까?",
|
||||
onAnalysis ?? (() => { }),
|
||||
"text-xl font-medium text-gray-800"
|
||||
);
|
||||
if (!shouldRunAnalysis) return;
|
||||
|
||||
await onAnalysis?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form {...form} className="min-h-screen flex flex-col">
|
||||
<ListDiv>
|
||||
<Form {...form} className="flex min-h-screen flex-col">
|
||||
<section className="card card-border">
|
||||
{/* searchBox.title이 있으면 h3 태그로 제목을 표시합니다. */}
|
||||
{selectBox?.title && (
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl font-medium">{selectBox.title}</h3>
|
||||
<h3 className="text-2xl font-medium text-label">
|
||||
{selectBox.title}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-2 mt-2 flex items-center w-full">
|
||||
<div className="flex w-full flex-wrap items-center gap-3 p-[1.2rem]">
|
||||
{showDatePicker && ( // showDatePicker 값에 따라 DatePicker 렌더링
|
||||
<>
|
||||
<Label className="mr-4 w-20 text-xl font-medium text-gray-600 whitespace-nowrap">상수사용기간</Label>
|
||||
<Label className="whitespace-nowrap text-label">
|
||||
상수사용기간
|
||||
</Label>
|
||||
<DateMonthPicker
|
||||
field={{
|
||||
value: aplcnYmFrom || (() => { // aplcnYm이 없으면 현재 연월을 기본값으로 설정
|
||||
value:
|
||||
aplcnYmFrom ||
|
||||
(() => {
|
||||
// aplcnYm이 없으면 현재 연월을 기본값으로 설정
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
// const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
@@ -176,25 +174,31 @@ const BaseLayout = ({
|
||||
})(), // yyyyMM 형식
|
||||
onChange: (value: string) => {
|
||||
setAplcnYmFrom?.(value); // string 값을 직접 전달
|
||||
}
|
||||
},
|
||||
}}
|
||||
selectClass="ml-8 w-[100px]"
|
||||
selectClass="w-[10rem]"
|
||||
startYear={1900} // 시작 연도 설정
|
||||
/>
|
||||
<span className="font-semibold text-xl mx-4">~</span>
|
||||
<span className="text-muted-foreground">~</span>
|
||||
<DateMonthPicker
|
||||
field={{
|
||||
value: aplcnYmTo || (() => { // aplcnYm이 없으면 현재 연월을 기본값으로 설정
|
||||
value:
|
||||
aplcnYmTo ||
|
||||
(() => {
|
||||
// aplcnYm이 없으면 현재 연월을 기본값으로 설정
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const month = String(now.getMonth() + 1).padStart(
|
||||
2,
|
||||
"0",
|
||||
);
|
||||
return `${year}${month}`;
|
||||
})(), // yyyyMM 형식
|
||||
onChange: (value: string) => {
|
||||
setAplcnYmTo?.(value); // string 값을 직접 전달
|
||||
}
|
||||
},
|
||||
}}
|
||||
selectClass="w-[100px] mr-8"
|
||||
selectClass="w-[10rem]"
|
||||
startYear={1900} // 시작 연도 설정
|
||||
/>
|
||||
</>
|
||||
@@ -207,7 +211,7 @@ const BaseLayout = ({
|
||||
sggName={sggName}
|
||||
onSidoChange={(sidoId: string) => {
|
||||
setCtpvCd(sidoId);
|
||||
setSggCd('0');
|
||||
setSggCd("0");
|
||||
}}
|
||||
onSigunguChange={setSggCd}
|
||||
setSggName={setSggName}
|
||||
@@ -219,86 +223,123 @@ const BaseLayout = ({
|
||||
/>
|
||||
)}
|
||||
{selectBox?.searchChk && (
|
||||
<div className='flex flex-1 justify-end mr-6'>
|
||||
<Button size="sm" onClick={() => selectBox.onSearchClick?.()}>조회</Button>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => selectBox.onSearchClick?.()}
|
||||
>
|
||||
조회
|
||||
</Button>
|
||||
{selectBox.onResetClick ? (
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
onClick={() => selectBox.onResetClick?.()}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ListDiv>
|
||||
<ListDiv>
|
||||
</section>
|
||||
<section className="card card-border">
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl font-medium">{titleName.firstCardTitle}</h3>
|
||||
<div>
|
||||
<h3 className="text-2xl font-medium text-label">
|
||||
{titleName.firstCardTitle}
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{graphBtn && (
|
||||
<Button className="px-4 py-2 !mr-[1.2rem] bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" onClick={isChckFile}>파일 다운로드</Button>
|
||||
)}
|
||||
{/* 그래프 있을 시 */}
|
||||
{graphBtn && (
|
||||
<Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 !mr-[1.2rem]" size="sm" onClick={isDeleteFile}>삭제</Button>
|
||||
<Button type="button" onClick={isChckFile}>
|
||||
엑셀다운로드
|
||||
</Button>
|
||||
)}
|
||||
{/* 침입수 분석 */}
|
||||
{isIr && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="mx-4 py-2 text-white rounded-sm text-lg font-medium green"
|
||||
<Button type="button">
|
||||
<a
|
||||
href="/samples/ir_setting_sample.zip"
|
||||
download="침입수 분석 업로드 파일 양식.zip"
|
||||
>
|
||||
<a href="/samples/ir_setting_sample.zip" download="침입수 분석 업로드 파일 양식.zip">양식 받기</a>
|
||||
양식다운로드
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{/* RDII 분석 */}
|
||||
{isRdii && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="mx-4 py-2 text-white rounded-sm text-lg font-medium green"
|
||||
<Button type="button">
|
||||
<a
|
||||
href="/samples/rdii_sample.zip"
|
||||
download="RDII 분석 업로드 파일 양식.zip"
|
||||
>
|
||||
<a href="/samples/rdii_sample.zip" download="RDII 분석 업로드 파일 양식.zip">양식 받기</a>
|
||||
양식다운로드
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{/* 분석 버튼 */}
|
||||
{analysisBtn && (
|
||||
<Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 !mr-[1.2rem]" size="sm" onClick={handleConfirm}>{analysisBtn}</Button>
|
||||
<Button type="button" onClick={handleConfirm}>
|
||||
{analysisBtn}
|
||||
</Button>
|
||||
)}
|
||||
{resultViewBtn && (
|
||||
<Button
|
||||
className="px-4 py-2 bg-slate-600 text-white rounded-sm text-sm font-medium hover:bg-slate-700 !mr-[1.2rem]"
|
||||
size="sm"
|
||||
type="button"
|
||||
color="white"
|
||||
onClick={onResultView}
|
||||
>
|
||||
{resultViewBtn}
|
||||
</Button>
|
||||
)}
|
||||
{graphBtn && (
|
||||
<Button
|
||||
type="button"
|
||||
color="red"
|
||||
onClick={isDeleteFile}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmitEvent)} className="flex-grow flex flex-col">
|
||||
<div className="card !m-[1.2rem] flex-grow flex flex-col">
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmitEvent)}
|
||||
className="flex flex-grow flex-col"
|
||||
>
|
||||
<div className="min-w-0 flex-grow p-[1.2rem]">
|
||||
{/* 폼 정보 입력 칸 */}
|
||||
{children?.[0]}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</ListDiv>
|
||||
</section>
|
||||
{children?.[1] != null && (
|
||||
<ListDiv>
|
||||
<section className="card card-border">
|
||||
<div className="card-header flex items-center">
|
||||
<h3 className="text-2xl font-medium">{titleName?.secondCardTitle}</h3>
|
||||
<div className='flex flex-1 justify-end mr-8'>
|
||||
<h3 className="text-2xl font-medium text-label">
|
||||
{titleName?.secondCardTitle}
|
||||
</h3>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{graphBtn && (
|
||||
<Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 !mr-[1.2rem]" size="sm">파일 다운로드</Button>
|
||||
<Button type="button">
|
||||
엑셀다운로드
|
||||
</Button>
|
||||
)}
|
||||
{/* 그래프 있을 시 */}
|
||||
{graphBtn && (
|
||||
<Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 !mr-[1.2rem]" size="sm">삭제</Button>
|
||||
<Button type="button" color="red">
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card !m-[1.2rem]">{children?.[1]}</div>
|
||||
</ListDiv>
|
||||
<div className="p-[1.2rem]">{children?.[1]}</div>
|
||||
</section>
|
||||
)}
|
||||
</Form>
|
||||
<Toaster position="top-center"></Toaster>
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default BaseLayout
|
||||
export default BaseLayout;
|
||||
|
||||
@@ -6,14 +6,21 @@ type AnalysisResultPopupKey = "inftwtResultPopup" | "rdiiResultPopup";
|
||||
|
||||
const ANALYSIS_RESULT_MAP_WINDOW_NAME = "sn-analysis-result-map";
|
||||
|
||||
export const openAnalysisResultMapView = (popupKey: AnalysisResultPopupKey) => {
|
||||
type AnalysisResultAlert = (message: string) => void;
|
||||
|
||||
export const openAnalysisResultMapView = (
|
||||
popupKey: AnalysisResultPopupKey,
|
||||
alert: AnalysisResultAlert,
|
||||
) => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const mapUrl = `/map?analysisPopupKey=${encodeURIComponent(popupKey)}`;
|
||||
const mapWindow = window.open(mapUrl, ANALYSIS_RESULT_MAP_WINDOW_NAME);
|
||||
|
||||
if (!mapWindow) {
|
||||
alert("하수관망도 창이 차단되었습니다. 브라우저 팝업 허용 후 다시 시도해주세요.");
|
||||
alert(
|
||||
"하수관망도 창이 차단되었습니다. 브라우저 팝업 허용 후 다시 시도해주세요.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -23,7 +30,9 @@ export const openAnalysisResultMapView = (popupKey: AnalysisResultPopupKey) => {
|
||||
mapWindow.focus();
|
||||
|
||||
if (!popupWindow) {
|
||||
alert("분석결과 popup이 차단되었습니다. 브라우저 팝업 허용 후 다시 시도해주세요.");
|
||||
alert(
|
||||
"분석결과 popup이 차단되었습니다. 브라우저 팝업 허용 후 다시 시도해주세요.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,28 +16,37 @@
|
||||
* - 침입수분석 등록 첨부파일INP 포함: POST /api/v1/rdiiAnls/savewithfile (sewerage-service)
|
||||
* - 사용자 단 건 조회: GET /api/v1/users/{userUnqId} (user-service)
|
||||
*/
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
// FileUpload.js
|
||||
import React, { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { SITE_ID } from '@/shared/config';
|
||||
import React, { useState } from "react";
|
||||
import axios from "axios";
|
||||
import { SITE_ID } from "@/shared/config";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import {
|
||||
StandardFormField,
|
||||
StandardFormSection,
|
||||
} from "@common/business_components";
|
||||
import { Button, Input } from "@common/components";
|
||||
const SEWERAGE_URL = "/sewerage-service/api";
|
||||
|
||||
function FileUpload() {
|
||||
const [file, setFile] = useState(null);
|
||||
const [text, setText] = useState('');
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [text, setText] = useState("");
|
||||
|
||||
const handleFileChange = (e: any) => {
|
||||
setFile(e.target.files[0]);
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFile(event.target.files?.[0] ?? null);
|
||||
};
|
||||
|
||||
const handleTextChange = (e: any) => {
|
||||
setText(e.target.value);
|
||||
const handleTextChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setText(event.target.value);
|
||||
};
|
||||
|
||||
function getCookie(name:string) {
|
||||
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
|
||||
function getCookie(name: string) {
|
||||
const match = document.cookie.match(
|
||||
new RegExp("(^| )" + name + "=([^;]+)"),
|
||||
);
|
||||
if (match && match[2]) {
|
||||
return decodeURIComponent(match[2]);
|
||||
}
|
||||
@@ -46,80 +55,100 @@ function FileUpload() {
|
||||
const handleFindUser = () => {
|
||||
const auth = getCookie("Authorization");
|
||||
if (auth) console.log("쿠키 auth : " + auth);
|
||||
fetch(SEWERAGE_URL+"/v1/inftwtAnls/users/65a00f65-8460-49af-98ec-042977e56f4b", {
|
||||
fetch(
|
||||
SEWERAGE_URL +
|
||||
"/v1/inftwtAnls/users/65a00f65-8460-49af-98ec-042977e56f4b",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Site-Id": SITE_ID,
|
||||
},
|
||||
body: JSON.stringify({authToken: auth})
|
||||
}).then((data) => {
|
||||
body: JSON.stringify({ authToken: auth }),
|
||||
},
|
||||
).then((data) => {
|
||||
console.log(data);
|
||||
});
|
||||
};
|
||||
const handleFindUserOna = () => {
|
||||
const auth = getCookie("Authorization");
|
||||
if (auth) console.log("쿠키 auth : " + auth);
|
||||
fetch("/server/user-service/api/v1/users/65a00f65-8460-49af-98ec-042977e56f4b", {
|
||||
fetch(
|
||||
"/server/user-service/api/v1/users/65a00f65-8460-49af-98ec-042977e56f4b",
|
||||
{
|
||||
headers: {
|
||||
"X-Site-Id": SITE_ID,
|
||||
},
|
||||
}).then((data) => {
|
||||
},
|
||||
).then((data) => {
|
||||
console.log(data);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: any) => {
|
||||
e.preventDefault();
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!file) {
|
||||
alert('파일을 선택하세요.');
|
||||
alert("파일을 선택하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const jsonData = {
|
||||
aplcnYm: '202507',
|
||||
enoCd: 'ABC'
|
||||
aplcnYm: "202507",
|
||||
enoCd: "ABC",
|
||||
};
|
||||
|
||||
// JSON → Blob 으로 변환 + MIME 지정
|
||||
const jsonBlob = new Blob([JSON.stringify(jsonData)], {
|
||||
type: 'application/json'
|
||||
type: "application/json",
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('data', jsonBlob);
|
||||
formData.append('inpFile', file);
|
||||
formData.append("data", jsonBlob);
|
||||
formData.append("inpFile", file);
|
||||
|
||||
try {
|
||||
const response = await axios.post('http://localhost:8883/sewerage-service/api/v1/rdiiAnls/savewithfile', formData, {
|
||||
const response = await axios.post(
|
||||
"http://localhost:8883/sewerage-service/api/v1/rdiiAnls/savewithfile",
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
"X-Site-Id": SITE_ID,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
alert('업로드 성공: ' + response.data.message);
|
||||
alert("업로드 성공: " + response.data.message);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('업로드 실패');
|
||||
alert("업로드 실패");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="text-2xl">
|
||||
<div>
|
||||
<label>텍스트 입력:</label>
|
||||
<input type="text" value={text} onChange={handleTextChange} />
|
||||
<form onSubmit={handleSubmit} className="card card-border">
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl font-medium text-label">파일 업로드 테스트</h3>
|
||||
</div>
|
||||
<div>
|
||||
<label>파일 선택:</label>
|
||||
<input type="file" onChange={handleFileChange} />
|
||||
<div className="p-[1.2rem]">
|
||||
<StandardFormSection title="업로드 정보" columns={2}>
|
||||
<StandardFormField label="텍스트 입력" columns={2}>
|
||||
<Input type="text" value={text} onChange={handleTextChange} />
|
||||
</StandardFormField>
|
||||
<StandardFormField label="파일 선택" required columns={2}>
|
||||
<Input type="file" onChange={handleFileChange} />
|
||||
</StandardFormField>
|
||||
</StandardFormSection>
|
||||
</div>
|
||||
<div className="card-footer flex justify-end gap-2">
|
||||
<Button type="submit">업로드</Button>
|
||||
<Button type="button" color="white" onClick={handleFindUser}>
|
||||
하수관로 사용자 조회
|
||||
</Button>
|
||||
<Button type="button" color="white" onClick={handleFindUserOna}>
|
||||
사용자서비스 사용자 조회
|
||||
</Button>
|
||||
</div>
|
||||
<button type="submit" className="px-4 py-2 bg-gray-500 text-white rounded">업로드</button>
|
||||
<button type="button" className="px-4 py-2 bg-gray-400 text-white rounded"
|
||||
onClick={handleFindUser}>사용자조회</button>
|
||||
<button type="button" className="px-4 py-2 bg-gray-200 text-white rounded"
|
||||
onClick={handleFindUserOna}>사용자조회ona</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,30 +15,41 @@
|
||||
* - 침입수분석 등록 첨부파일INP 포함: POST /api/v1/inftwtAnls/savewithfile (sewerage-service)
|
||||
* - 로그인된 사용자의 사용자 정보 조회: GET /api/v1/users/info/me (user-service)
|
||||
*/
|
||||
'use client'
|
||||
import { postMutiPart } from '@/components/lib/api'
|
||||
import FileUploadForm from '@/components/ui/fileUploadForm'
|
||||
import FormSection from '@/components/ui/formSection'
|
||||
import { DateMonthPicker, filePicked } from '@common/business_components/ui'
|
||||
import ListDiv from '@common/business_components/ui/list_div'
|
||||
import { Input } from '@common/components/ui'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { excelToFlowRateData, excelToMatchingResultData, excelToTimePatternData, excelToRcptrInfoData } from '@/components/lib/utils'
|
||||
import BaseLayout from '../BaseLayout'
|
||||
import { useAnalysisCompletionWatcher } from '../useAnalysisCompletionWatcher'
|
||||
import { openAnalysisResultMapView } from '../analysisResultView'
|
||||
import { validateMultipleFiles, FILE_EXTENSIONS, type FileValidationConfig } from '../../fileValidation';
|
||||
import { validateBasicSelectionsWithAlert } from '../../selectionValidation';
|
||||
import { tokenManager } from '@/entities/auth/lib/token-manager';
|
||||
"use client";
|
||||
import { postMutiPart } from "@/components/lib/api";
|
||||
import FileUploadForm from "@/components/ui/fileUploadForm";
|
||||
import {
|
||||
StandardFormField,
|
||||
StandardFormSection,
|
||||
} from "@common/business_components";
|
||||
import { DateMonthPicker, filePicked } from "@common/business_components/ui";
|
||||
import { Input } from "@common/components/ui";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
excelToFlowRateData,
|
||||
excelToMatchingResultData,
|
||||
excelToTimePatternData,
|
||||
excelToRcptrInfoData,
|
||||
} from "@/components/lib/utils";
|
||||
import BaseLayout from "../BaseLayout";
|
||||
import { useAnalysisCompletionWatcher } from "../useAnalysisCompletionWatcher";
|
||||
import { openAnalysisResultMapView } from "../analysisResultView";
|
||||
import {
|
||||
validateMultipleFiles,
|
||||
FILE_EXTENSIONS,
|
||||
type FileValidationConfig,
|
||||
} from "../../fileValidation";
|
||||
import { validateBasicSelections } from "../../selectionValidation";
|
||||
import { tokenManager } from "@/entities/auth/lib/token-manager";
|
||||
import { AUTH_USER_ID } from "@/shared/config";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ENDPOINT } from "@common/components/config";
|
||||
import { getUserInfo } from "@common/components/api";
|
||||
import LoadingOverlay from '@common/business_components/ui/loadingBar';
|
||||
|
||||
import LoadingOverlay from "@common/business_components/ui/loadingBar";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
|
||||
const formSchema = z.object({
|
||||
managenm: z.string(),
|
||||
@@ -47,8 +58,8 @@ const formSchema = z.object({
|
||||
inputdate: z.coerce.date(),
|
||||
rtkSource: z.string(),
|
||||
suyeongaPlace: z.string(),
|
||||
pattern: z.string()
|
||||
})
|
||||
pattern: z.string(),
|
||||
});
|
||||
|
||||
// 각 파일 업로드 폼의 데이터를 저장할 인터페이스 정의
|
||||
interface UploadedFileInfo {
|
||||
@@ -59,33 +70,43 @@ interface UploadedFileInfo {
|
||||
// fileName: string;
|
||||
}
|
||||
// 초기 파일 정보 상태
|
||||
const initialFileInfo: UploadedFileInfo = { file: null, atflCd: null, atflId: null };
|
||||
const DEFAULT_CTPV_CD = '41';
|
||||
const DEFAULT_SGG_CD = '41830';
|
||||
const DEFAULT_SGG_NAME = '\uC591\uD3C9\uAD70';
|
||||
const DEFAULT_SWTRMT_FCLT_NM = '\uC591\uD3C9';
|
||||
const initialFileInfo: UploadedFileInfo = {
|
||||
file: null,
|
||||
atflCd: null,
|
||||
atflId: null,
|
||||
};
|
||||
const DEFAULT_CTPV_CD = "41";
|
||||
const DEFAULT_SGG_CD = "41830";
|
||||
const DEFAULT_SGG_NAME = "\uC591\uD3C9\uAD70";
|
||||
const DEFAULT_SWTRMT_FCLT_NM = "\uC591\uD3C9";
|
||||
|
||||
const IrSetting = () => {
|
||||
const [input1, setInput1] = useState('0.8'); // 오수전환율 초기값
|
||||
const [input2, setInput2] = useState('');
|
||||
const [selectedSwagePrcsFclt, setSelectedSwagePrcsFclt] = useState<string>('0'); // ?????? ?? ??
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [input1, setInput1] = useState("0.8"); // 오수전환율 초기값
|
||||
const [input2, setInput2] = useState("");
|
||||
const [selectedSwagePrcsFclt, setSelectedSwagePrcsFclt] =
|
||||
useState<string>("0"); // ?????? ?? ??
|
||||
const [formResetKey, setFormResetKey] = useState(0); // FileUploadForm 리셋을 위한 key 상태
|
||||
const [isLoadingResult, setIsLoadingResult] = useState<boolean>(false); // 로딩 상태
|
||||
const [ctpvCd, setCtpvCd] = useState<string>(DEFAULT_CTPV_CD); // 시도 코드 상태
|
||||
const [sggCd, setSggCd] = useState<string>(DEFAULT_SGG_CD); // 시군구 코드 상태
|
||||
const [sggName, setSggName] = useState<string>(DEFAULT_SGG_NAME); // 시군구 이름 상태
|
||||
const [swtrmtFcltNm, setSwtrmtFcltNm] = useState<string>(DEFAULT_SWTRMT_FCLT_NM); // 하수처리시설명 상태
|
||||
const [uploadedFilesData, setUploadedFilesData] = useState<UploadedFileInfo[]>([
|
||||
const [swtrmtFcltNm, setSwtrmtFcltNm] = useState<string>(
|
||||
DEFAULT_SWTRMT_FCLT_NM,
|
||||
); // 하수처리시설명 상태
|
||||
const [uploadedFilesData, setUploadedFilesData] = useState<
|
||||
UploadedFileInfo[]
|
||||
>([
|
||||
initialFileInfo, // 0: 오수관망도
|
||||
initialFileInfo, // 1: 수용가-맨홀 매칭결과
|
||||
initialFileInfo, // 2: 오수량 시간패턴
|
||||
initialFileInfo // 3: 유량자료 등록
|
||||
initialFileInfo, // 3: 유량자료 등록
|
||||
]);
|
||||
// selectedDate를 yyyyMM 문자열 형식으로 변경
|
||||
const [selectedDate, setSelectedDate] = useState(() => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
return `${year}${month}`;
|
||||
});
|
||||
|
||||
@@ -97,13 +118,13 @@ const IrSetting = () => {
|
||||
|
||||
const { startWatching } = useAnalysisCompletionWatcher({
|
||||
buildStatusUrl: (analysisSn) => `v1/inftwtAnls/${analysisSn}/status`,
|
||||
successMessage: '침입수 분석이 완료되었습니다.',
|
||||
failureMessage: '침입수 분석이 실패했습니다. 로그를 확인해주세요.',
|
||||
analysisType: 'INFTWT',
|
||||
analysisName: '침입수 분석',
|
||||
successMessage: "침입수 분석이 완료되었습니다.",
|
||||
failureMessage: "침입수 분석이 실패했습니다. 로그를 확인해주세요.",
|
||||
analysisType: "INFTWT",
|
||||
analysisName: "침입수 분석",
|
||||
initialProgressPercent: 10,
|
||||
initialProgressStep: 'REQUEST_ACCEPTED',
|
||||
initialProgressMessage: '요청 접수 완료',
|
||||
initialProgressStep: "REQUEST_ACCEPTED",
|
||||
initialProgressMessage: "요청 접수 완료",
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
@@ -115,40 +136,42 @@ const IrSetting = () => {
|
||||
inputdate: new Date(),
|
||||
rtkSource: "",
|
||||
suyeongaPlace: "",
|
||||
pattern: ""
|
||||
}
|
||||
})
|
||||
pattern: "",
|
||||
},
|
||||
});
|
||||
|
||||
// 폼 초기화 함수
|
||||
const resetAllForms = () => {
|
||||
form.reset({ // react-hook-form 초기화
|
||||
form.reset({
|
||||
// react-hook-form 초기화
|
||||
managenm: "",
|
||||
region: "",
|
||||
suyeonga: 0,
|
||||
inputdate: new Date(),
|
||||
rtkSource: "",
|
||||
suyeongaPlace: "",
|
||||
pattern: ""
|
||||
pattern: "",
|
||||
});
|
||||
setUploadedFilesData([ // 파일 업로드 상태 초기화
|
||||
setUploadedFilesData([
|
||||
// 파일 업로드 상태 초기화
|
||||
initialFileInfo,
|
||||
initialFileInfo,
|
||||
initialFileInfo,
|
||||
initialFileInfo,
|
||||
initialFileInfo
|
||||
]);
|
||||
// selectedDate를 현재 연월로 초기화
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
setSelectedDate(`${year}${month}`);
|
||||
setInput1(''); // 입력 필드 초기화
|
||||
setInput2(''); // 입력 필드 초기화
|
||||
setSelectedSwagePrcsFclt('0');
|
||||
setInput1(""); // 입력 필드 초기화
|
||||
setInput2(""); // 입력 필드 초기화
|
||||
setSelectedSwagePrcsFclt("0");
|
||||
setCtpvCd(DEFAULT_CTPV_CD);
|
||||
setSggCd(DEFAULT_SGG_CD);
|
||||
setSggName(DEFAULT_SGG_NAME);
|
||||
setSwtrmtFcltNm(DEFAULT_SWTRMT_FCLT_NM);
|
||||
setFormResetKey(prevKey => prevKey + 1); // FileUploadForm 재마운트를 위해 key 변경
|
||||
setFormResetKey((prevKey) => prevKey + 1); // FileUploadForm 재마운트를 위해 key 변경
|
||||
};
|
||||
|
||||
// FileUploadForm의 onFileSelect prop으로 전달될 콜백 함수
|
||||
@@ -158,7 +181,7 @@ const IrSetting = () => {
|
||||
cd: string | null,
|
||||
id: string | null,
|
||||
) => {
|
||||
setUploadedFilesData(prevData => {
|
||||
setUploadedFilesData((prevData) => {
|
||||
const newData = [...prevData];
|
||||
newData[index] = {
|
||||
file: selectedFile,
|
||||
@@ -169,23 +192,21 @@ const IrSetting = () => {
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const registFile = async (analysisData: any) => {
|
||||
const data = await postMutiPart('v1/inftwtAnls/savewithfile', analysisData);
|
||||
const data = await postMutiPart("v1/inftwtAnls/savewithfile", analysisData);
|
||||
if (!data) {
|
||||
throw new Error('분석 요청 접수 응답이 비어 있습니다.');
|
||||
throw new Error("분석 요청 접수 응답이 비어 있습니다.");
|
||||
}
|
||||
|
||||
const nextAnalysisSn = Number(data?.inftwtAnlsSn ?? 0);
|
||||
setIsLoadingResult(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
alert("분석 데이터가 성공적으로 제출되었습니다.");
|
||||
|
||||
alert("분석 데이터가 성공적으로 제출되었습니다.", () => {
|
||||
if (Number.isFinite(nextAnalysisSn) && nextAnalysisSn > 0) {
|
||||
startWatching(nextAnalysisSn);
|
||||
}
|
||||
|
||||
resetAllForms();
|
||||
});
|
||||
};
|
||||
|
||||
// 침입수 분석 실행 버튼 클릭 핸들러
|
||||
@@ -193,14 +214,18 @@ const IrSetting = () => {
|
||||
setIsLoadingResult(true); // 로딩 상태 시작
|
||||
|
||||
// 기본 선택 항목들 검증 (환경청, 시도, 시군구, 하수처리장)
|
||||
if (!validateBasicSelectionsWithAlert(selectedSwagePrcsFclt, {
|
||||
const selectionValidation = validateBasicSelections(selectedSwagePrcsFclt, {
|
||||
requireEnv: true,
|
||||
requireSido: true,
|
||||
requireSigungu: true,
|
||||
requireTreatmentPlant: true,
|
||||
treatmentPlantLabel: '하수처리시설'
|
||||
})) {
|
||||
treatmentPlantLabel: "하수처리시설",
|
||||
});
|
||||
if (!selectionValidation.isValid) {
|
||||
setIsLoadingResult(false);
|
||||
alert(
|
||||
selectionValidation.errorMessage ?? "필수 선택 항목을 확인해주세요.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -214,7 +239,7 @@ const IrSetting = () => {
|
||||
// 유량자료는 선택값
|
||||
if (!file1?.file || !file2?.file || !file3?.file) {
|
||||
setIsLoadingResult(false);
|
||||
alert('모든 분석용 파일을 선택해주세요.');
|
||||
alert("모든 분석용 파일을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -223,18 +248,18 @@ const IrSetting = () => {
|
||||
{
|
||||
file: file1.file,
|
||||
allowedExtensions: FILE_EXTENSIONS.INP,
|
||||
fileDescription: '오수관망도'
|
||||
fileDescription: "오수관망도",
|
||||
},
|
||||
{
|
||||
file: file2.file,
|
||||
allowedExtensions: FILE_EXTENSIONS.EXCEL,
|
||||
fileDescription: '수용가-맨홀 매칭결과'
|
||||
fileDescription: "수용가-맨홀 매칭결과",
|
||||
},
|
||||
{
|
||||
file: file3.file,
|
||||
allowedExtensions: FILE_EXTENSIONS.EXCEL,
|
||||
fileDescription: '오수량 시간패턴'
|
||||
}
|
||||
fileDescription: "오수량 시간패턴",
|
||||
},
|
||||
];
|
||||
|
||||
// 유량자료 파일이 있는 경우 검증 목록에 추가
|
||||
@@ -242,7 +267,7 @@ const IrSetting = () => {
|
||||
validationConfigs.push({
|
||||
file: file4.file,
|
||||
allowedExtensions: FILE_EXTENSIONS.EXCEL,
|
||||
fileDescription: '유량자료'
|
||||
fileDescription: "유량자료",
|
||||
});
|
||||
}
|
||||
// 수용가 상수사용량 파일이 있는 경우 검증 목록에 추가
|
||||
@@ -250,7 +275,7 @@ const IrSetting = () => {
|
||||
validationConfigs.push({
|
||||
file: file5.file,
|
||||
allowedExtensions: FILE_EXTENSIONS.EXCEL,
|
||||
fileDescription: '수용가 상수사용량'
|
||||
fileDescription: "수용가 상수사용량",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -258,43 +283,55 @@ const IrSetting = () => {
|
||||
const validationResult = validateMultipleFiles(validationConfigs);
|
||||
if (!validationResult.isValid) {
|
||||
setIsLoadingResult(false);
|
||||
alert(validationResult.errorMessage);
|
||||
alert(validationResult.errorMessage ?? "파일 형식을 확인해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 오수전환율 값 0 ~ 1 사이인지 검증
|
||||
if (input1 === '' || input1 === null || input1 === undefined) {
|
||||
if (input1 === "" || input1 === null || input1 === undefined) {
|
||||
setIsLoadingResult(false);
|
||||
console.error('오수전환율은 0에서 1 사이의 값으로 입력해야 합니다.');
|
||||
alert('오수전환율을 입력해주세요.');
|
||||
console.error("오수전환율은 0에서 1 사이의 값으로 입력해야 합니다.");
|
||||
alert("오수전환율을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const numericInput1 = parseFloat(input1);
|
||||
if (isNaN(numericInput1) || numericInput1 < 0 || numericInput1 > 1) {
|
||||
setIsLoadingResult(false);
|
||||
console.error('오수전환율은 0에서 1 사이의 값으로 입력해야 합니다.');
|
||||
alert('오수전환율은 0에서 1 사이의 값으로 입력해야 합니다.');
|
||||
console.error("오수전환율은 0에서 1 사이의 값으로 입력해야 합니다.");
|
||||
alert("오수전환율은 0에서 1 사이의 값으로 입력해야 합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
type MatchingResult = { anlsSn: number; mhlMngNo: string; rcptrNo: string; };
|
||||
type TimePattern = { hrId: number; rt?: number | null | undefined; };
|
||||
type FlowRate = { pplMngNo: string; mtrngDt: string; oilq?: number | null | undefined; };
|
||||
type rcptrInfo = { rcptrNo: string; useYm: string; usqty?: number | null | undefined; };
|
||||
type MatchingResult = { anlsSn: number; mhlMngNo: string; rcptrNo: string };
|
||||
type TimePattern = { hrId: number; rt?: number | null | undefined };
|
||||
type FlowRate = {
|
||||
pplMngNo: string;
|
||||
mtrngDt: string;
|
||||
oilq?: number | null | undefined;
|
||||
};
|
||||
type rcptrInfo = {
|
||||
rcptrNo: string;
|
||||
useYm: string;
|
||||
usqty?: number | null | undefined;
|
||||
};
|
||||
|
||||
const filePickers: {
|
||||
file: UploadedFileInfo;
|
||||
parser: (excelData: any) => MatchingResult[] | TimePattern[] | FlowRate[] | rcptrInfo[];
|
||||
parser: (
|
||||
excelData: any,
|
||||
) => MatchingResult[] | TimePattern[] | FlowRate[] | rcptrInfo[];
|
||||
}[] = [
|
||||
{ file: file2, parser: excelToMatchingResultData },
|
||||
{ file: file3, parser: excelToTimePatternData }
|
||||
{ file: file3, parser: excelToTimePatternData },
|
||||
];
|
||||
|
||||
if (file4?.file) { // 유량자료 파일이 있는 경우
|
||||
if (file4?.file) {
|
||||
// 유량자료 파일이 있는 경우
|
||||
filePickers.push({ file: file4, parser: excelToFlowRateData });
|
||||
}
|
||||
if (file5?.file) { // 수용가 상수사용량 파일이 있는 경우
|
||||
if (file5?.file) {
|
||||
// 수용가 상수사용량 파일이 있는 경우
|
||||
filePickers.push({ file: file5, parser: excelToRcptrInfoData });
|
||||
}
|
||||
|
||||
@@ -306,12 +343,14 @@ const IrSetting = () => {
|
||||
const jsonData = await filePicked(file.file, 0);
|
||||
|
||||
if (jsonData === undefined) {
|
||||
console.warn(`Warning: filePicked returned undefined for ${file.file.name}`);
|
||||
console.warn(
|
||||
`Warning: filePicked returned undefined for ${file.file.name}`,
|
||||
);
|
||||
return {
|
||||
fileName: file.file.name,
|
||||
atflCd: file.atflCd,
|
||||
atflId: file.atflId,
|
||||
parsedData: null
|
||||
parsedData: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -322,42 +361,55 @@ const IrSetting = () => {
|
||||
fileName: file.file.name,
|
||||
atflCd: file.atflCd,
|
||||
atflId: file.atflId,
|
||||
parsedData: parsedDataBySpecificFunction
|
||||
parsedData: parsedDataBySpecificFunction,
|
||||
};
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// 각 파싱 결과 접근
|
||||
const matchingResultData = parsedResults.find(r => r && r.fileName === file2.file?.name)?.parsedData ?? [];
|
||||
const timePatternData = parsedResults.find(r => r && r.fileName === file3.file?.name)?.parsedData ?? [];
|
||||
const matchingResultData =
|
||||
parsedResults.find((r) => r && r.fileName === file2.file?.name)
|
||||
?.parsedData ?? [];
|
||||
const timePatternData =
|
||||
parsedResults.find((r) => r && r.fileName === file3.file?.name)
|
||||
?.parsedData ?? [];
|
||||
const flowRateData = file4?.file
|
||||
? parsedResults.find(r => r && r.fileName === file4.file?.name)?.parsedData
|
||||
? parsedResults.find((r) => r && r.fileName === file4.file?.name)
|
||||
?.parsedData
|
||||
: null;
|
||||
const rcptrInfoData = file5?.file
|
||||
? parsedResults.find(r => r && r.fileName === file5.file?.name)?.parsedData
|
||||
? parsedResults.find((r) => r && r.fileName === file5.file?.name)
|
||||
?.parsedData
|
||||
: null;
|
||||
|
||||
if (!swtrmtFcltNm || !swtrmtFcltNm.trim()) {
|
||||
setIsLoadingResult(false);
|
||||
alert('하수처리시설명이 비어 있습니다. 시설을 다시 선택해주세요.');
|
||||
alert("하수처리시설명이 비어 있습니다. 시설을 다시 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(matchingResultData) || matchingResultData.length === 0) {
|
||||
if (
|
||||
!Array.isArray(matchingResultData) ||
|
||||
matchingResultData.length === 0
|
||||
) {
|
||||
setIsLoadingResult(false);
|
||||
alert('수용가-맨홀 매칭결과 파일에서 저장할 데이터를 읽지 못했습니다. 엑셀 헤더를 확인해주세요.');
|
||||
alert(
|
||||
"수용가-맨홀 매칭결과 파일에서 저장할 데이터를 읽지 못했습니다. 엑셀 헤더를 확인해주세요.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(timePatternData) || timePatternData.length === 0) {
|
||||
setIsLoadingResult(false);
|
||||
alert('오수량 시간패턴 파일에서 저장할 데이터를 읽지 못했습니다. 엑셀 헤더를 확인해주세요.');
|
||||
alert(
|
||||
"오수량 시간패턴 파일에서 저장할 데이터를 읽지 못했습니다. 엑셀 헤더를 확인해주세요.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 새로운 JSON 데이터 생성
|
||||
const jsonData = {
|
||||
ctpvCd: ctpvCd !== '0' ? String(ctpvCd).trim() : null,
|
||||
sggCd: sggCd !== '0' ? String(sggCd) : null, // 시군구코드 (선택값)
|
||||
ctpvCd: ctpvCd !== "0" ? String(ctpvCd).trim() : null,
|
||||
sggCd: sggCd !== "0" ? String(sggCd) : null, // 시군구코드 (선택값)
|
||||
swtrmtFcltNm: swtrmtFcltNm ?? null,
|
||||
aplcnYm: selectedDate, // 적용연월
|
||||
drtwSwtcRt: parseFloat(input1) || null, // 오수전환율 (숫자로 변환)
|
||||
@@ -377,36 +429,40 @@ const IrSetting = () => {
|
||||
};
|
||||
|
||||
const jsonBlob = new Blob([JSON.stringify(jsonData)], {
|
||||
type: 'application/json'
|
||||
type: "application/json",
|
||||
});
|
||||
const combinedAnalysisData = new FormData();
|
||||
combinedAnalysisData.append('data', jsonBlob);
|
||||
if (file1?.file) combinedAnalysisData.append('inpFile', file1.file);
|
||||
combinedAnalysisData.append("data", jsonBlob);
|
||||
if (file1?.file) combinedAnalysisData.append("inpFile", file1.file);
|
||||
|
||||
await registFile(combinedAnalysisData);
|
||||
setIsLoadingResult(false);
|
||||
} catch (error) {
|
||||
setIsLoadingResult(false);
|
||||
console.error("침입수 분석 요청 처리 중 오류 발생:", error);
|
||||
alert(error instanceof Error && error.message.trim()
|
||||
alert(
|
||||
error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "파일 처리 중 오류가 발생했습니다.");
|
||||
: "파일 처리 중 오류가 발생했습니다.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadingOverlay isLoading={isLoadingResult} />
|
||||
<BaseLayout
|
||||
titleName={{ mainTitle: '침입수 분석', firstCardTitle: '침입수 분석 자료 입력' }}
|
||||
titleName={{
|
||||
mainTitle: "침입수 분석",
|
||||
firstCardTitle: "침입수 분석 자료 입력",
|
||||
}}
|
||||
form={form}
|
||||
onSubmitEvent={() => { }}
|
||||
onSubmitEvent={() => {}}
|
||||
graphBtn={false}
|
||||
resultViewBtn={'결과보기'}
|
||||
analysisBtn={'침입수분석 실행'}
|
||||
selectBox={{ title: '분석 대상지역 선택' }}
|
||||
resultViewBtn={"결과보기"}
|
||||
analysisBtn={"침입수분석 실행"}
|
||||
selectBox={{ title: "분석 대상지역 선택" }}
|
||||
ctpvCd={ctpvCd}
|
||||
sggCd={sggCd}
|
||||
sggName={sggName}
|
||||
@@ -418,69 +474,101 @@ const IrSetting = () => {
|
||||
setSwtrmtFcltNm={setSwtrmtFcltNm}
|
||||
defaultSwtrmtFcltNm={swtrmtFcltNm}
|
||||
setIsLoading={setIsLoadingResult}
|
||||
onResultView={() => openAnalysisResultMapView('inftwtResultPopup')}
|
||||
onResultView={() =>
|
||||
openAnalysisResultMapView("inftwtResultPopup", alert)
|
||||
}
|
||||
onAnalysis={handleAnalysisClick}
|
||||
isIr={true}
|
||||
>
|
||||
{[<div key="irsetting-layout-key" className="grid gap-4">
|
||||
<FormSection name='inputdate' form={form} titleNm='▷ 상수사용량 적용월' required={true}>
|
||||
{[
|
||||
<StandardFormSection
|
||||
key="irsetting-layout-key"
|
||||
title="분석 조건 및 자료"
|
||||
columns={2}
|
||||
>
|
||||
<StandardFormField label="상수사용량 적용월" required columns={2}>
|
||||
{/* 연월 선택 라이브러리로 수정 필요 */}
|
||||
<DateMonthPicker
|
||||
field={{
|
||||
value: selectedDate, // yyyyMM 형식
|
||||
onChange: (value: string) => {
|
||||
setSelectedDate(value);
|
||||
}
|
||||
},
|
||||
}}
|
||||
selectClass="ml-5 w-[225px]"
|
||||
selectClass="w-full"
|
||||
startYear={1900} // 시작 연도 설정
|
||||
/>
|
||||
</FormSection>
|
||||
<FormSection name='rcptrInfo' form={form} titleNm='▷ 수용가 상수사용량'>
|
||||
</StandardFormField>
|
||||
<StandardFormField
|
||||
label="수용가 상수사용량"
|
||||
columns={2}
|
||||
className="[&_.card]:!m-0 [&_.card]:!border-0 [&_.card]:!p-0"
|
||||
>
|
||||
<FileUploadForm
|
||||
key={`file-upload-4-${formResetKey}`} // 폼 초기화를 위한 key
|
||||
onFileSelect={(file, cd, id) => handleFileSelection(4, file, cd, id)}
|
||||
onFileSelect={(file, cd, id) =>
|
||||
handleFileSelection(4, file, cd, id)
|
||||
}
|
||||
showButton={false}
|
||||
showPreview={true}
|
||||
accept={".xls, .xlsx, .xlsm, .xltx"}
|
||||
fileIdx={4} />
|
||||
</FormSection>
|
||||
<FormSection name='suyeongaPlace' form={form} titleNm='▷ 오수관망도' required={true}>
|
||||
fileIdx={4}
|
||||
/>
|
||||
</StandardFormField>
|
||||
<StandardFormField
|
||||
label="오수관망도"
|
||||
required
|
||||
columns={2}
|
||||
className="[&_.card]:!m-0 [&_.card]:!border-0 [&_.card]:!p-0"
|
||||
>
|
||||
<FileUploadForm
|
||||
key={`file-upload-0-${formResetKey}`} // 폼 초기화를 위한 key
|
||||
onFileSelect={(file, cd, id) => handleFileSelection(0, file, cd, id)}
|
||||
onFileSelect={(file, cd, id) =>
|
||||
handleFileSelection(0, file, cd, id)
|
||||
}
|
||||
showButton={false}
|
||||
showPreview={true}
|
||||
accept={".inp"}
|
||||
fileIdx={0} />
|
||||
</FormSection>
|
||||
<FormSection name='suyeonga' form={form} titleNm='▷ 수용가-맨홀 매칭결과' required={true}>
|
||||
fileIdx={0}
|
||||
/>
|
||||
</StandardFormField>
|
||||
<StandardFormField
|
||||
label="수용가-맨홀 매칭결과"
|
||||
required
|
||||
columns={2}
|
||||
className="[&_.card]:!m-0 [&_.card]:!border-0 [&_.card]:!p-0"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<FileUploadForm
|
||||
key={`file-upload-1-${formResetKey}`} // 폼 초기화를 위한 key
|
||||
onFileSelect={(file, cd, id) => handleFileSelection(1, file, cd, id)}
|
||||
onFileSelect={(file, cd, id) =>
|
||||
handleFileSelection(1, file, cd, id)
|
||||
}
|
||||
showButton={false}
|
||||
showPreview={true}
|
||||
accept={".xls, .xlsx, .xlsm, .xltx"}
|
||||
fileIdx={1} />
|
||||
{/* <Button
|
||||
size={"sm"}
|
||||
className={`text-white rounded-sm text-lg font-medium green`}
|
||||
>
|
||||
<a href="/samples/manhole-consumer-matching-template.xlsx" download="수용가-맨홀 매칭결과 파일 양식.xlsx">양식 받기</a>
|
||||
</Button> */}
|
||||
fileIdx={1}
|
||||
/>
|
||||
</div>
|
||||
</FormSection>
|
||||
<FormSection name='pattern' form={form} titleNm='▷ 오수량 시간패턴' required={true}>
|
||||
</StandardFormField>
|
||||
<StandardFormField
|
||||
label="오수량 시간패턴"
|
||||
required
|
||||
columns={2}
|
||||
className="[&_.card]:!m-0 [&_.card]:!border-0 [&_.card]:!p-0"
|
||||
>
|
||||
<FileUploadForm
|
||||
key={`file-upload-2-${formResetKey}`} // 폼 초기화를 위한 key
|
||||
onFileSelect={(file, cd, id) => handleFileSelection(2, file, cd, id)}
|
||||
onFileSelect={(file, cd, id) =>
|
||||
handleFileSelection(2, file, cd, id)
|
||||
}
|
||||
showButton={false}
|
||||
showPreview={true}
|
||||
accept={".xls, .xlsx, .xlsm, .xltx"}
|
||||
fileIdx={2} />
|
||||
</FormSection>
|
||||
<FormSection name='rtkSource' form={form} titleNm='▷ 오수전환율' required={true}>
|
||||
fileIdx={2}
|
||||
/>
|
||||
</StandardFormField>
|
||||
<StandardFormField label="오수전환율" required columns={2}>
|
||||
<Input
|
||||
value={input1}
|
||||
onChange={(e) => {
|
||||
@@ -496,50 +584,59 @@ const IrSetting = () => {
|
||||
if (!isValidChars) return; // 유효하지 않은 문자 또는 여러 개의 '.' 포함
|
||||
|
||||
// '.' 기호가 포함된 경우, 처음이나 끝에 오면 안 됨
|
||||
if (newValue.includes('.') && newValue.startsWith('.')) return; // '.'이 처음에는 올 수 없음
|
||||
if (newValue.includes(".") && newValue.startsWith("."))
|
||||
return; // '.'이 처음에는 올 수 없음
|
||||
|
||||
// 숫자 값으로 변환하여 범위 체크 (0 이상 1 이하)
|
||||
const numericValue = parseFloat(newValue);
|
||||
if (isNaN(numericValue) || numericValue < 0 || numericValue > 1) return;
|
||||
if (
|
||||
isNaN(numericValue) ||
|
||||
numericValue < 0 ||
|
||||
numericValue > 1
|
||||
)
|
||||
return;
|
||||
|
||||
setInput1(newValue);
|
||||
}}
|
||||
className="ml-5 h-[3.2rem] text-center text-xl w-[225px]"
|
||||
className="w-full"
|
||||
placeholder="예: 0.75"
|
||||
/>
|
||||
</FormSection>
|
||||
<FormSection name='analysis information' form={form} titleNm='▷ 분석 정보' required={true}>
|
||||
</StandardFormField>
|
||||
<StandardFormField label="분석 정보" required columns={2}>
|
||||
<Input
|
||||
value={input2}
|
||||
onChange={(e) => setInput2(e.target.value)}
|
||||
className="ml-5 h-[3.2rem] text-center text-xl w-[225px]"
|
||||
placeholder='세부 분석정보 입력'
|
||||
className="w-full"
|
||||
placeholder="세부 분석정보 입력"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault(); // Enter 키 기본 동작 방지
|
||||
handleAnalysisClick();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormSection>
|
||||
<ListDiv>
|
||||
<div className="card !m-[1.2rem]">
|
||||
<FormSection name='region' form={form} titleNm='▷ 유량자료 등록'>
|
||||
</StandardFormField>
|
||||
<StandardFormField
|
||||
label="유량자료"
|
||||
columns={2}
|
||||
className="[&_.card]:!m-0 [&_.card]:!border-0 [&_.card]:!p-0"
|
||||
>
|
||||
<FileUploadForm
|
||||
key={`file-upload-3-${formResetKey}`} // 폼 초기화를 위한 key
|
||||
onFileSelect={(file, cd, id) => handleFileSelection(3, file, cd, id)}
|
||||
onFileSelect={(file, cd, id) =>
|
||||
handleFileSelection(3, file, cd, id)
|
||||
}
|
||||
showButton={false}
|
||||
showPreview={true}
|
||||
accept={".xls, .xlsx, .xlsm, .xltx"}
|
||||
fileIdx={3}
|
||||
/>
|
||||
</FormSection>
|
||||
</div>
|
||||
</ListDiv>
|
||||
</div>]}
|
||||
</StandardFormField>
|
||||
</StandardFormSection>,
|
||||
]}
|
||||
</BaseLayout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default IrSetting
|
||||
export default IrSetting;
|
||||
|
||||
@@ -16,33 +16,44 @@
|
||||
* - RDII분석 등록 첨부파일INP 포함: POST /api/v1/rdiiAnls/savewithfile (sewerage-service)
|
||||
* - 로그인된 사용자의 사용자 정보 조회: GET /api/v1/users/info/me (user-service)
|
||||
*/
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { postMutiPart } from '@/components/lib/api'
|
||||
import FileUploadForm from "@/components/ui/fileUploadForm"
|
||||
import FormSection from "@/components/ui/formSection"
|
||||
import BaseLayout from "../BaseLayout"
|
||||
import { useAnalysisCompletionWatcher } from "../useAnalysisCompletionWatcher"
|
||||
import { openAnalysisResultMapView } from "../analysisResultView"
|
||||
import { DateMonthPicker, filePicked } from "@common/business_components/ui"
|
||||
import { Input } from "@common/components/ui"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Label } from '@radix-ui/react-label'
|
||||
import { useState } from "react"
|
||||
import { useFieldArray, useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
import { excelToMatchingResultData, excelToRainData } from '@/components/lib/utils'
|
||||
import { EditableFormTable, type EditableTableHeadItem } from '../../../components/ui/EditableFormTable'
|
||||
import { validateMultipleFiles, FILE_EXTENSIONS, type FileValidationConfig } from '../../fileValidation';
|
||||
import { validateBasicSelectionsWithAlert } from '../../selectionValidation';
|
||||
import { tokenManager } from '@/entities/auth/lib/token-manager';
|
||||
import { postMutiPart } from "@/components/lib/api";
|
||||
import FileUploadForm from "@/components/ui/fileUploadForm";
|
||||
import BaseLayout from "../BaseLayout";
|
||||
import { useAnalysisCompletionWatcher } from "../useAnalysisCompletionWatcher";
|
||||
import { openAnalysisResultMapView } from "../analysisResultView";
|
||||
import { DateMonthPicker, filePicked } from "@common/business_components/ui";
|
||||
import {
|
||||
StandardFormField,
|
||||
StandardFormSection,
|
||||
} from "@common/business_components";
|
||||
import { Input } from "@common/components/ui";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
excelToMatchingResultData,
|
||||
excelToRainData,
|
||||
} from "@/components/lib/utils";
|
||||
import {
|
||||
EditableFormTable,
|
||||
type EditableTableHeadItem,
|
||||
} from "../../../components/ui/EditableFormTable";
|
||||
import {
|
||||
validateMultipleFiles,
|
||||
FILE_EXTENSIONS,
|
||||
type FileValidationConfig,
|
||||
} from "../../fileValidation";
|
||||
import { validateBasicSelections } from "../../selectionValidation";
|
||||
import { tokenManager } from "@/entities/auth/lib/token-manager";
|
||||
import { AUTH_USER_ID } from "@/shared/config";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ENDPOINT } from "@common/components/config";
|
||||
import { getUserInfo } from "@common/components/api";
|
||||
import LoadingOverlay from '@common/business_components/ui/loadingBar';
|
||||
|
||||
|
||||
import LoadingOverlay from "@common/business_components/ui/loadingBar";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
|
||||
// zod 스키마 정의
|
||||
const formSchema = z.object({
|
||||
@@ -51,18 +62,24 @@ const formSchema = z.object({
|
||||
swtrmtFcltNm: z.string(), // 하수처리시설명
|
||||
rtkSource: z.string(), // RTK계수 출처
|
||||
rdiiArea: z.string(), // RDII 기여면적
|
||||
unitFactors: z.array(z.object({ // 단위도 계수
|
||||
unitFactors: z.array(
|
||||
z.object({
|
||||
// 단위도 계수
|
||||
response: z.string(),
|
||||
R: z.string(),
|
||||
T: z.string(),
|
||||
K: z.string(),
|
||||
})),
|
||||
initialDepths: z.array(z.object({ // 초기 차단 깊이
|
||||
}),
|
||||
),
|
||||
initialDepths: z.array(
|
||||
z.object({
|
||||
// 초기 차단 깊이
|
||||
response: z.string(),
|
||||
Dmax: z.string(),
|
||||
Drec: z.string(),
|
||||
Do: z.string(),
|
||||
})),
|
||||
}),
|
||||
),
|
||||
// osumap: z.instanceof(File).optional(), // 오수관망도
|
||||
// matching: z.instanceof(File).optional(), // 수용가-맨홀 매칭결과
|
||||
aplcnYm: z.string(), // 상수사용량 적용월 (YYYYMM 형식)
|
||||
@@ -71,7 +88,7 @@ const formSchema = z.object({
|
||||
analysisInfo: z.string().optional(), // 분석 정보
|
||||
date: z.string(), // Date 객체에서 string(yyyyMM 형식)으로 변경
|
||||
// date: z.coerce.date(),
|
||||
})
|
||||
});
|
||||
// zod 스키마 타입 추론
|
||||
type FormSchemaType = z.infer<typeof formSchema>;
|
||||
// 기본 폼 값 정의 (테스트 편의를 위해 초기값 설정, 추후 삭제 필요)
|
||||
@@ -97,10 +114,11 @@ const defaultFormValues: FormSchemaType = {
|
||||
rate: "0.8", // 문자열로 기본값 설정
|
||||
// rainFile: undefined,
|
||||
analysisInfo: "",
|
||||
date: (() => { // 현재 연월을 yyyyMM 형식으로 설정
|
||||
date: (() => {
|
||||
// 현재 연월을 yyyyMM 형식으로 설정
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
return `${year}${month}`;
|
||||
})(),
|
||||
};
|
||||
@@ -113,29 +131,38 @@ interface UploadedFileInfo {
|
||||
}
|
||||
|
||||
// 초기 파일 정보 상태
|
||||
const initialFileInfo: UploadedFileInfo = { file: null, atflCd: null, atflId: null };
|
||||
const DEFAULT_CTPV_CD = '41';
|
||||
const DEFAULT_SGG_CD = '41830';
|
||||
const DEFAULT_SGG_NAME = '\uC591\uD3C9\uAD70';
|
||||
const DEFAULT_SWTRMT_FCLT_NM = '\uC591\uD3C9';
|
||||
const initialFileInfo: UploadedFileInfo = {
|
||||
file: null,
|
||||
atflCd: null,
|
||||
atflId: null,
|
||||
};
|
||||
const DEFAULT_CTPV_CD = "41";
|
||||
const DEFAULT_SGG_CD = "41830";
|
||||
const DEFAULT_SGG_NAME = "\uC591\uD3C9\uAD70";
|
||||
const DEFAULT_SWTRMT_FCLT_NM = "\uC591\uD3C9";
|
||||
|
||||
export default function RTKForm() {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [formResetKey, setFormResetKey] = useState(0); // FileUploadForm 리셋을 위한 key 상태
|
||||
const [isLoadingResult, setIsLoadingResult] = useState<boolean>(false); // 로딩 상태
|
||||
|
||||
// FileUploadForm에서 전달받은 파일 정보를 저장할 상태 배열
|
||||
// 각 인덱스는 FileUploadForm의 fileIdx와 매칭됨
|
||||
const [uploadedFilesData, setUploadedFilesData] = useState<UploadedFileInfo[]>([
|
||||
const [uploadedFilesData, setUploadedFilesData] = useState<
|
||||
UploadedFileInfo[]
|
||||
>([
|
||||
initialFileInfo, // 0: 오수관망도
|
||||
initialFileInfo, // 1: 수용가-맨홀 매칭결과
|
||||
initialFileInfo, // 2: 강우자료
|
||||
]);
|
||||
|
||||
const [select1, setSelect1] = useState<string>('0'); // 하수처리장 (swtrmtFcltCd)
|
||||
const [select1, setSelect1] = useState<string>("0"); // 하수처리장 (swtrmtFcltCd)
|
||||
const [ctpvCd, setCtpvCd] = useState<string>(DEFAULT_CTPV_CD); // 시도 코드 상태
|
||||
const [sggCd, setSggCd] = useState<string>(DEFAULT_SGG_CD); // 시군구 코드 상태
|
||||
const [sggName, setSggName] = useState<string>(DEFAULT_SGG_NAME); // 시군구 이름 상태
|
||||
const [swtrmtFcltNm, setSwtrmtFcltNm] = useState<string>(DEFAULT_SWTRMT_FCLT_NM); // 하수처리시설명 상태
|
||||
const [swtrmtFcltNm, setSwtrmtFcltNm] = useState<string>(
|
||||
DEFAULT_SWTRMT_FCLT_NM,
|
||||
); // 하수처리시설명 상태
|
||||
|
||||
const { data: userInfo } = useQuery({
|
||||
queryKey: [ENDPOINT.USER_SERVICE.USERS],
|
||||
@@ -145,31 +172,32 @@ export default function RTKForm() {
|
||||
|
||||
const { startWatching } = useAnalysisCompletionWatcher({
|
||||
buildStatusUrl: (analysisSn) => `v1/rdiiAnls/${analysisSn}/status`,
|
||||
successMessage: 'RDII 분석이 완료되었습니다.',
|
||||
failureMessage: 'RDII 분석이 실패했습니다. 로그를 확인해주세요.',
|
||||
analysisType: 'RDII',
|
||||
analysisName: 'RDII 분석',
|
||||
successMessage: "RDII 분석이 완료되었습니다.",
|
||||
failureMessage: "RDII 분석이 실패했습니다. 로그를 확인해주세요.",
|
||||
analysisType: "RDII",
|
||||
analysisName: "RDII 분석",
|
||||
initialProgressPercent: 10,
|
||||
initialProgressStep: 'REQUEST_ACCEPTED',
|
||||
initialProgressMessage: '요청 접수 완료',
|
||||
initialProgressStep: "REQUEST_ACCEPTED",
|
||||
initialProgressMessage: "요청 접수 완료",
|
||||
});
|
||||
|
||||
// form 인스턴스 생성
|
||||
const form = useForm<FormSchemaType>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: defaultFormValues
|
||||
defaultValues: defaultFormValues,
|
||||
});
|
||||
|
||||
// 폼 초기화 함수
|
||||
const resetAllForms = () => {
|
||||
form.reset(defaultFormValues); // 폼 값 초기화
|
||||
setUploadedFilesData([ // 파일 업로드 상태 초기화 (3개 파일)
|
||||
setUploadedFilesData([
|
||||
// 파일 업로드 상태 초기화 (3개 파일)
|
||||
initialFileInfo,
|
||||
initialFileInfo,
|
||||
initialFileInfo,
|
||||
]);
|
||||
setFormResetKey(prevKey => prevKey + 1); // FileUploadForm 재마운트를 위해 key 변경
|
||||
setSelect1('0');
|
||||
setFormResetKey((prevKey) => prevKey + 1); // FileUploadForm 재마운트를 위해 key 변경
|
||||
setSelect1("0");
|
||||
setCtpvCd(DEFAULT_CTPV_CD);
|
||||
setSggCd(DEFAULT_SGG_CD);
|
||||
setSggName(DEFAULT_SGG_NAME);
|
||||
@@ -181,9 +209,9 @@ export default function RTKForm() {
|
||||
index: number, // FileUploadForm의 fileIdx 값
|
||||
selectedFile: File | null,
|
||||
cd: string | null,
|
||||
id: string | null
|
||||
id: string | null,
|
||||
) => {
|
||||
setUploadedFilesData(prevData => {
|
||||
setUploadedFilesData((prevData) => {
|
||||
const newData = [...prevData];
|
||||
newData[index] = {
|
||||
file: selectedFile,
|
||||
@@ -195,27 +223,28 @@ export default function RTKForm() {
|
||||
};
|
||||
|
||||
const registFile = async (analysisData: any) => {
|
||||
const data = await postMutiPart('v1/rdiiAnls/savewithfile', analysisData);
|
||||
const data = await postMutiPart("v1/rdiiAnls/savewithfile", analysisData);
|
||||
if (!data) {
|
||||
throw new Error('분석 요청 접수 응답이 비어 있습니다.');
|
||||
throw new Error("분석 요청 접수 응답이 비어 있습니다.");
|
||||
}
|
||||
|
||||
const nextAnalysisSn = Number(data?.rainInfwtAnlsSn ?? 0);
|
||||
const canWatchCompletion = Number.isFinite(nextAnalysisSn) && nextAnalysisSn > 0;
|
||||
const canWatchCompletion =
|
||||
Number.isFinite(nextAnalysisSn) && nextAnalysisSn > 0;
|
||||
|
||||
setIsLoadingResult(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
alert(
|
||||
canWatchCompletion
|
||||
? '분석 요청이 접수되었습니다. 완료 시 안내됩니다.'
|
||||
: '분석 요청이 접수되었습니다. 결과 목록에서 진행 상태를 확인해주세요.'
|
||||
);
|
||||
|
||||
? "분석 요청이 접수되었습니다. 완료 시 안내됩니다."
|
||||
: "분석 요청이 접수되었습니다. 결과 목록에서 진행 상태를 확인해주세요.",
|
||||
() => {
|
||||
if (canWatchCompletion) {
|
||||
startWatching(nextAnalysisSn);
|
||||
}
|
||||
|
||||
resetAllForms();
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// 침입수 분석 실행 버튼 클릭 핸들러
|
||||
@@ -223,13 +252,17 @@ export default function RTKForm() {
|
||||
setIsLoadingResult(true); // 로딩 상태 시작
|
||||
|
||||
// 기본 선택 항목들 검증 (환경청, 시도, 시군구, 하수처리장)
|
||||
if (!validateBasicSelectionsWithAlert(select1, {
|
||||
const selectionValidation = validateBasicSelections(select1, {
|
||||
requireEnv: true,
|
||||
requireSido: true,
|
||||
requireSigungu: true,
|
||||
requireTreatmentPlant: true
|
||||
})) {
|
||||
requireTreatmentPlant: true,
|
||||
});
|
||||
if (!selectionValidation.isValid) {
|
||||
setIsLoadingResult(false);
|
||||
alert(
|
||||
selectionValidation.errorMessage ?? "필수 선택 항목을 확인해주세요.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -241,7 +274,7 @@ export default function RTKForm() {
|
||||
// 모든 필수 파일이 선택되었는지 확인
|
||||
if (!file1?.file || !file2?.file || !file3?.file) {
|
||||
setIsLoadingResult(false);
|
||||
alert('모든 분석용 파일을 선택해주세요.');
|
||||
alert("모든 분석용 파일을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -250,42 +283,42 @@ export default function RTKForm() {
|
||||
{
|
||||
file: file1.file,
|
||||
allowedExtensions: FILE_EXTENSIONS.INP,
|
||||
fileDescription: '오수관망도'
|
||||
fileDescription: "오수관망도",
|
||||
},
|
||||
{
|
||||
file: file2.file,
|
||||
allowedExtensions: FILE_EXTENSIONS.EXCEL,
|
||||
fileDescription: '수용가-맨홀 매칭결과'
|
||||
fileDescription: "수용가-맨홀 매칭결과",
|
||||
},
|
||||
{
|
||||
file: file3.file,
|
||||
allowedExtensions: FILE_EXTENSIONS.EXCEL,
|
||||
fileDescription: '오수량 시간패턴'
|
||||
}
|
||||
fileDescription: "오수량 시간패턴",
|
||||
},
|
||||
];
|
||||
|
||||
// 파일 확장자 검증 실행
|
||||
const validationResult = validateMultipleFiles(validationConfigs);
|
||||
if (!validationResult.isValid) {
|
||||
setIsLoadingResult(false);
|
||||
alert(validationResult.errorMessage);
|
||||
alert(validationResult.errorMessage ?? "파일 형식을 확인해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 오수전환율 값 0 ~ 1 사이인지 검증
|
||||
const rateValue = form.getValues().rate;
|
||||
if (rateValue === '' || rateValue === null || rateValue === undefined) {
|
||||
if (rateValue === "" || rateValue === null || rateValue === undefined) {
|
||||
setIsLoadingResult(false);
|
||||
console.error('오수전환율은 0에서 1 사이의 값으로 입력해야 합니다.');
|
||||
alert('오수전환율을 입력해주세요.');
|
||||
console.error("오수전환율은 0에서 1 사이의 값으로 입력해야 합니다.");
|
||||
alert("오수전환율을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const numericInput1 = parseFloat(rateValue);
|
||||
if (isNaN(numericInput1) || numericInput1 < 0 || numericInput1 > 1) {
|
||||
setIsLoadingResult(false);
|
||||
console.error('오수전환율은 0에서 1 사이의 값으로 입력해야 합니다.');
|
||||
alert('오수전환율은 0에서 1 사이의 값으로 입력해야 합니다.');
|
||||
console.error("오수전환율은 0에서 1 사이의 값으로 입력해야 합니다.");
|
||||
alert("오수전환율은 0에서 1 사이의 값으로 입력해야 합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -303,12 +336,14 @@ export default function RTKForm() {
|
||||
const ExcelToJsonData = await filePicked(file.file, 0);
|
||||
|
||||
if (ExcelToJsonData === undefined) {
|
||||
console.warn(`Warning: filePicked returned undefined for ${file.file.name}`);
|
||||
console.warn(
|
||||
`Warning: filePicked returned undefined for ${file.file.name}`,
|
||||
);
|
||||
return {
|
||||
fileName: file.file.name,
|
||||
atflCd: file.atflCd,
|
||||
atflId: file.atflId,
|
||||
parsedData: null // 또는 적절한 기본값
|
||||
parsedData: null, // 또는 적절한 기본값
|
||||
};
|
||||
}
|
||||
|
||||
@@ -320,23 +355,28 @@ export default function RTKForm() {
|
||||
fileName: file.file.name,
|
||||
atflCd: file.atflCd,
|
||||
atflId: file.atflId,
|
||||
parsedData: parsedDataBySpecificFunction
|
||||
parsedData: parsedDataBySpecificFunction,
|
||||
};
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// 각 파싱 결과 접근
|
||||
const matchingResultData = parsedResults.find(r => r && r.fileName === file2.file?.name)?.parsedData ?? [];
|
||||
const rainData = parsedResults.find(r => r && r.fileName === file3.file?.name)?.parsedData ?? [];
|
||||
const matchingResultData =
|
||||
parsedResults.find((r) => r && r.fileName === file2.file?.name)
|
||||
?.parsedData ?? [];
|
||||
const rainData =
|
||||
parsedResults.find((r) => r && r.fileName === file3.file?.name)
|
||||
?.parsedData ?? [];
|
||||
|
||||
// 상수사용량 적용월과 강우자료의 날짜 일치 여부 검증
|
||||
let dateValidated = true;
|
||||
|
||||
if (rainData) {
|
||||
rainData
|
||||
.filter(data => data && 'mtrngDt' in data)
|
||||
.map(data => {
|
||||
if (formData.date !== data.mtrngDt.replace(/-/g, '').slice(0, 6)) dateValidated = false;
|
||||
.filter((data) => data && "mtrngDt" in data)
|
||||
.map((data) => {
|
||||
if (formData.date !== data.mtrngDt.replace(/-/g, "").slice(0, 6))
|
||||
dateValidated = false;
|
||||
});
|
||||
}
|
||||
if (!dateValidated) {
|
||||
@@ -345,12 +385,11 @@ export default function RTKForm() {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 새로운 JSON 데이터 생성
|
||||
const jsonData = {
|
||||
aplcnYm: formData.date, // 적용연월 (YYYYMM 형식)
|
||||
ctpvCd: ctpvCd !== '0' ? String(ctpvCd).trim() : null, // 시도코드 (선택값)
|
||||
sggCd: sggCd !== '0' ? String(sggCd) : null, // 시군구코드 (선택값)
|
||||
ctpvCd: ctpvCd !== "0" ? String(ctpvCd).trim() : null, // 시도코드 (선택값)
|
||||
sggCd: sggCd !== "0" ? String(sggCd) : null, // 시군구코드 (선택값)
|
||||
swtrmtFcltNm: swtrmtFcltNm ?? null,
|
||||
atflCd: file1?.atflCd, // 첨부파일CD (오수관망도)
|
||||
atflId: file1?.atflId, // 첨부파일ID (오수관망도)
|
||||
@@ -394,61 +433,71 @@ export default function RTKForm() {
|
||||
};
|
||||
|
||||
const jsonBlob = new Blob([JSON.stringify(jsonData)], {
|
||||
type: 'application/json'
|
||||
type: "application/json",
|
||||
});
|
||||
const combinedAnalysisData = new FormData();
|
||||
combinedAnalysisData.append('data', jsonBlob);
|
||||
if (file1?.file) combinedAnalysisData.append('inpFile', file1.file);
|
||||
combinedAnalysisData.append("data", jsonBlob);
|
||||
if (file1?.file) combinedAnalysisData.append("inpFile", file1.file);
|
||||
|
||||
await registFile(combinedAnalysisData);
|
||||
setIsLoadingResult(false);
|
||||
} catch (error) {
|
||||
setIsLoadingResult(false);
|
||||
console.error("RDII 분석 요청 처리 중 오류 발생:", error);
|
||||
alert(error instanceof Error && error.message.trim()
|
||||
alert(
|
||||
error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "파일 처리 중 오류가 발생했습니다.");
|
||||
: "파일 처리 중 오류가 발생했습니다.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const { fields: unitFactorFields } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "unitFactors"
|
||||
})
|
||||
name: "unitFactors",
|
||||
});
|
||||
|
||||
const { fields: initialDepthFields } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "initialDepths"
|
||||
})
|
||||
name: "initialDepths",
|
||||
});
|
||||
|
||||
// 단위도 계수 테이블 설정 (EditableTableHeadItem 타입 사용 및 id 수정)
|
||||
const unitFactorHeadlist: EditableTableHeadItem<FormSchemaType['unitFactors'][number]>[] = [
|
||||
{ id: 'response', title: 'Response', editable: false, width: '150px' }, // width 속성 추가
|
||||
{ id: 'R', title: 'R', editable: true, inputProps: { type: 'text' } },
|
||||
{ id: 'T', title: 'T', editable: true, inputProps: { type: 'text' } },
|
||||
{ id: 'K', title: 'K', editable: true, inputProps: { type: 'text' } }
|
||||
const unitFactorHeadlist: EditableTableHeadItem<
|
||||
FormSchemaType["unitFactors"][number]
|
||||
>[] = [
|
||||
{ id: "response", title: "Response", editable: false, width: "150px" }, // width 속성 추가
|
||||
{ id: "R", title: "R", editable: true, inputProps: { type: "text" } },
|
||||
{ id: "T", title: "T", editable: true, inputProps: { type: "text" } },
|
||||
{ id: "K", title: "K", editable: true, inputProps: { type: "text" } },
|
||||
];
|
||||
|
||||
// 초기 차단 깊이 테이블 설정 (EditableTableHeadItem 타입 사용)
|
||||
const initialDepthHeadlist: EditableTableHeadItem<FormSchemaType['initialDepths'][number]>[] = [
|
||||
{ id: 'response', title: 'Response', editable: false, width: '150px' }, // width 속성 추가
|
||||
{ id: 'Dmax', title: 'Dmax', editable: true, inputProps: { type: 'text' } },
|
||||
{ id: 'Drec', title: 'Drec', editable: true, inputProps: { type: 'text' } },
|
||||
{ id: 'Do', title: 'Do', editable: true, inputProps: { type: 'text' } }
|
||||
const initialDepthHeadlist: EditableTableHeadItem<
|
||||
FormSchemaType["initialDepths"][number]
|
||||
>[] = [
|
||||
{ id: "response", title: "Response", editable: false, width: "150px" }, // width 속성 추가
|
||||
{ id: "Dmax", title: "Dmax", editable: true, inputProps: { type: "text" } },
|
||||
{ id: "Drec", title: "Drec", editable: true, inputProps: { type: "text" } },
|
||||
{ id: "Do", title: "Do", editable: true, inputProps: { type: "text" } },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadingOverlay isLoading={isLoadingResult} />
|
||||
<BaseLayout
|
||||
selectBox={{ title: '분석 대상지역 선택' }}
|
||||
titleName={{ mainTitle: 'RDII 분석', firstCardTitle: 'RDII 분석자료 입력', secondCardTitle: '실측자료 등록' }}
|
||||
selectBox={{ title: "분석 대상지역 선택" }}
|
||||
titleName={{
|
||||
mainTitle: "RDII 분석",
|
||||
firstCardTitle: "RDII 분석자료 입력",
|
||||
secondCardTitle: "실측자료 등록",
|
||||
}}
|
||||
form={form} // react-hook-form 인스턴스 전달
|
||||
onSubmitEvent={() => { }} // BaseLayout의 form 태그에 연결될 onSubmit
|
||||
onSubmitEvent={() => {}} // BaseLayout의 form 태그에 연결될 onSubmit
|
||||
graphBtn={false}
|
||||
resultViewBtn={'결과보기'}
|
||||
analysisBtn={'RDII분석 실행'}
|
||||
resultViewBtn={"결과보기"}
|
||||
analysisBtn={"RDII분석 실행"}
|
||||
ctpvCd={ctpvCd}
|
||||
sggCd={sggCd}
|
||||
sggName={sggName}
|
||||
@@ -460,77 +509,102 @@ export default function RTKForm() {
|
||||
setSwtrmtFcltNm={setSwtrmtFcltNm}
|
||||
defaultSwtrmtFcltNm={swtrmtFcltNm}
|
||||
setIsLoading={setIsLoadingResult}
|
||||
onResultView={() => openAnalysisResultMapView('rdiiResultPopup')}
|
||||
onResultView={() => openAnalysisResultMapView("rdiiResultPopup", alert)}
|
||||
onAnalysis={handleAnalysisClick}
|
||||
isRdii={true}
|
||||
>
|
||||
{[<div className="space-y-[1.2rem] overflow-x-auto pt-4 pb-4 px-2" key="form-section">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormSection name="rtkSource" form={form} titleNm="○ RTK계수 출처" required={true}>
|
||||
<Input {...form.register("rtkSource")} className="h-12 text-center text-xl w-[225px]" />
|
||||
</FormSection>
|
||||
<FormSection name="rdiiArea" form={form} titleNm="○ RDII 기여면적(ha)" required={true}>
|
||||
<Input {...form.register("rdiiArea")} className="h-12 text-center text-xl w-[225px]" />
|
||||
</FormSection></div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="mb-2 text-xl font-medium">○ 단위도 계수</Label>
|
||||
<span className="text-2xl text-red-500">*</span>
|
||||
{[
|
||||
<StandardFormSection
|
||||
key="form-section"
|
||||
title="분석 조건 및 자료"
|
||||
columns={2}
|
||||
>
|
||||
<StandardFormField label="RTK계수 출처" required columns={2}>
|
||||
<Input {...form.register("rtkSource")} className="w-full" />
|
||||
</StandardFormField>
|
||||
<StandardFormField label="RDII 기여면적(ha)" required columns={2}>
|
||||
<Input {...form.register("rdiiArea")} className="w-full" />
|
||||
</StandardFormField>
|
||||
<StandardFormField label="단위도 계수" required columns={2}>
|
||||
<div className="overflow-x-auto">
|
||||
<EditableFormTable
|
||||
form={form}
|
||||
name="unitFactors"
|
||||
fields={unitFactorFields}
|
||||
headlist={unitFactorHeadlist}
|
||||
tableClassName="w-[400px]"
|
||||
tableClassName="min-w-[40rem] w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-[1.2rem] text-xl font-medium">○ 초기 차단 깊이</Label>
|
||||
<span className="text-2xl text-red-500">*</span>
|
||||
</StandardFormField>
|
||||
<StandardFormField label="초기 차단 깊이" required columns={2}>
|
||||
<div className="overflow-x-auto">
|
||||
<EditableFormTable
|
||||
form={form}
|
||||
name="initialDepths"
|
||||
fields={initialDepthFields}
|
||||
headlist={initialDepthHeadlist}
|
||||
tableClassName="w-[400px]"
|
||||
tableClassName="min-w-[40rem] w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormSection name="suyeonga" form={form} titleNm="○ 오수관망도" required={true}><div className="w-[350px]">
|
||||
</StandardFormField>
|
||||
<StandardFormField
|
||||
label="오수관망도"
|
||||
required
|
||||
columns={2}
|
||||
className="[&_.card]:!m-0 [&_.card]:!border-0 [&_.card]:!p-0"
|
||||
>
|
||||
<div className="w-full">
|
||||
<FileUploadForm
|
||||
key={`file-upload-0-${formResetKey}`} // 폼 초기화를 위한 key
|
||||
onFileSelect={(file, cd, id) => handleFileSelection(0, file, cd, id)}
|
||||
onFileSelect={(file, cd, id) =>
|
||||
handleFileSelection(0, file, cd, id)
|
||||
}
|
||||
showButton={false}
|
||||
showPreview={true}
|
||||
accept={".inp"}
|
||||
fileIdx={0} />
|
||||
</div></FormSection>
|
||||
<FormSection name="regopm" form={form} titleNm="○ 수용가-맨홀 매칭결과" required={true}><div className="flex items-center">
|
||||
fileIdx={0}
|
||||
/>
|
||||
</div>
|
||||
</StandardFormField>
|
||||
<StandardFormField
|
||||
label="수용가-맨홀 매칭결과"
|
||||
required
|
||||
columns={2}
|
||||
className="[&_.card]:!m-0 [&_.card]:!border-0 [&_.card]:!p-0"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<FileUploadForm
|
||||
key={`file-upload-1-${formResetKey}`} // 폼 초기화를 위한 key
|
||||
onFileSelect={(file, cd, id) => handleFileSelection(1, file, cd, id)}
|
||||
onFileSelect={(file, cd, id) =>
|
||||
handleFileSelection(1, file, cd, id)
|
||||
}
|
||||
showButton={false}
|
||||
showPreview={true}
|
||||
accept={".xls, .xlsx, .xlsm, .xltx"}
|
||||
fileIdx={1} />
|
||||
{/* <Button
|
||||
size={"sm"}
|
||||
className={`text-white rounded-sm text-lg font-medium green`}
|
||||
fileIdx={1}
|
||||
/>
|
||||
</div>
|
||||
</StandardFormField>
|
||||
<StandardFormField
|
||||
label="강우자료"
|
||||
required
|
||||
columns={2}
|
||||
className="[&_.card]:!m-0 [&_.card]:!border-0 [&_.card]:!p-0"
|
||||
>
|
||||
<a href="/samples/manhole-consumer-matching-template.xlsx" download="수용가-맨홀 매칭결과 파일 양식.xlsx">양식 받기</a>
|
||||
</Button> */}
|
||||
</div></FormSection>
|
||||
<FormSection name="rain" form={form} titleNm="○ 강우자료" required={true}><div className="w-[350px]">
|
||||
<div className="w-full">
|
||||
<FileUploadForm
|
||||
key={`file-upload-2-${formResetKey}`} // 폼 초기화를 위한 key
|
||||
onFileSelect={(file, cd, id) => handleFileSelection(2, file, cd, id)}
|
||||
onFileSelect={(file, cd, id) =>
|
||||
handleFileSelection(2, file, cd, id)
|
||||
}
|
||||
showButton={false}
|
||||
showPreview={true}
|
||||
accept={".xls, .xlsx, .xlsm, .xltx"}
|
||||
fileIdx={2} />
|
||||
</div></FormSection>
|
||||
<FormSection name='rate' form={form} titleNm='○ 오수전환율' required={true}>
|
||||
fileIdx={2}
|
||||
/>
|
||||
</div>
|
||||
</StandardFormField>
|
||||
<StandardFormField label="오수전환율" required columns={2}>
|
||||
<Input
|
||||
value={form.watch("rate")} // form의 rate 값을 가져옴
|
||||
onChange={(e) => {
|
||||
@@ -546,49 +620,53 @@ export default function RTKForm() {
|
||||
if (!isValidChars) return; // 유효하지 않은 문자 또는 여러 개의 '.' 포함
|
||||
|
||||
// '.' 기호가 처음에 오면 안 됨
|
||||
if (newValue.startsWith('.')) return;
|
||||
if (newValue.startsWith(".")) return;
|
||||
|
||||
// 완성된 숫자인 경우에만 범위 체크 (입력 중인 경우 허용)
|
||||
if (!newValue.endsWith('.')) {
|
||||
if (!newValue.endsWith(".")) {
|
||||
const numericValue = parseFloat(newValue);
|
||||
if (isNaN(numericValue) || numericValue < 0 || numericValue > 1) return;
|
||||
if (
|
||||
isNaN(numericValue) ||
|
||||
numericValue < 0 ||
|
||||
numericValue > 1
|
||||
)
|
||||
return;
|
||||
}
|
||||
|
||||
form.setValue("rate", newValue, { shouldValidate: true }); // form의 rate 필드에 설정
|
||||
}}
|
||||
className="h-12 text-center text-xl w-[225px] ml-5"
|
||||
className="w-full"
|
||||
placeholder="0 ~ 1 사이의 값 입력"
|
||||
/>
|
||||
</FormSection>
|
||||
<FormSection name='date' form={form} titleNm='○ 상수사용량 적용월' required={true}>
|
||||
</StandardFormField>
|
||||
<StandardFormField label="상수사용량 적용월" required columns={2}>
|
||||
<DateMonthPicker
|
||||
field={{
|
||||
value: form.watch("date"), // form의 date 값을 직접 사용
|
||||
onChange: (value: string) => {
|
||||
form.setValue("date", value, { shouldValidate: true }); // form의 date 필드에 직접 설정
|
||||
}
|
||||
},
|
||||
}}
|
||||
selectClass="ml-5 w-[225px]"
|
||||
selectClass="w-full"
|
||||
startYear={1900} // 시작 연도 설정
|
||||
/>
|
||||
</FormSection>
|
||||
<FormSection name='rate' form={form} titleNm='○ 분석 정보' required={true}>
|
||||
</StandardFormField>
|
||||
<StandardFormField label="분석 정보" required columns={2}>
|
||||
<Input
|
||||
{...form.register("analysisInfo")}
|
||||
className="h-12 text-center text-xl w-[225px] ml-5"
|
||||
placeholder='세부 분석정보 입력'
|
||||
className="w-full"
|
||||
placeholder="세부 분석정보 입력"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault(); // Enter 키 기본 동작 방지
|
||||
handleAnalysisClick();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormSection>
|
||||
</div>
|
||||
</div>
|
||||
</StandardFormField>
|
||||
</StandardFormSection>,
|
||||
]}
|
||||
</BaseLayout >
|
||||
</BaseLayout>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,163 +17,189 @@
|
||||
* - RDII분석결과 삭제: DELETE /api/v1/rdiiAnlsRslt/{rainInfwtAnlsSn} (sewerage-service)
|
||||
* - RDII분석 삭제: DELETE /api/v1/rdiiAnls/{rainInfwtAnlsSn} (sewerage-service)
|
||||
*/
|
||||
'use client'
|
||||
import { deleteData, getData } from '@/components/lib/api'
|
||||
import { UiTable } from '@common/business_components/ui/table'
|
||||
import { downloadExcelWithKoreanHeaders } from '@/shared/lib/download-excel'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import BaseLayout from '../BaseLayout'
|
||||
import { handleDelete } from '@/components/lib/utils'
|
||||
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
|
||||
import useStore from '@/store/useStore';
|
||||
|
||||
"use client";
|
||||
import { deleteData, getData } from "@/components/lib/api";
|
||||
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import BaseLayout from "../BaseLayout";
|
||||
import { handleDelete } from "@/components/lib/utils";
|
||||
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
|
||||
import useStore from "@/store/useStore";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
|
||||
const formSchema = z.object({
|
||||
swtrmtFcltCd: z.string().default('0'), // 하수처리시설코드
|
||||
swtrmtFcltNm: z.string().default(''), // 하수처리시설명
|
||||
aplcnYmFrom: z.string().default(() => { // Date에서 string(yyyyMM 형식)으로 변경
|
||||
swtrmtFcltCd: z.string().default("0"), // 하수처리시설코드
|
||||
swtrmtFcltNm: z.string().default(""), // 하수처리시설명
|
||||
aplcnYmFrom: z.string().default(() => {
|
||||
// Date에서 string(yyyyMM 형식)으로 변경
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
// const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
// return `${year}${month}`;
|
||||
return `${year}01`; // 기본값을 1월로 설정
|
||||
}), // 분석기간 시작연월
|
||||
aplcnYmTo: z.string().default(() => { // Date에서 string(yyyyMM 형식)으로 변경
|
||||
aplcnYmTo: z.string().default(() => {
|
||||
// Date에서 string(yyyyMM 형식)으로 변경
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
return `${year}${month}`;
|
||||
}), // 분석기간 종료연월
|
||||
})
|
||||
});
|
||||
|
||||
type ResultFormValues = z.infer<typeof formSchema>
|
||||
type ResultFormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const tableColumn = [
|
||||
{ id: 'rowNumber', title: '일련번호' },
|
||||
{ id: 'ctpvNm', title: '시도' },
|
||||
{ id: 'sggNm', title: '시군구' },
|
||||
{ id: 'swtrmtFcltNm', title: '하수처리시설' },
|
||||
{ id: 'aplcnYm', title: '상수사용연월' },
|
||||
{ id: 'drtwSwtcRt', title: '오수 전환율' },
|
||||
{ id: 'srcNm', title: 'RTK 계수 출처' },
|
||||
{ id: 'cntbArea', title: 'RDII 기여면적' },
|
||||
{ id: 'anlsInfo', title: 'RDII 분석정보' },
|
||||
{ id: 'regDt', title: '분석일자' },
|
||||
{ id: 'rgtrNm', title: '분석자' },
|
||||
{ id: 'scsYn', title: '분석성공여부' }
|
||||
]
|
||||
{ id: "rowNumber", title: "순번" },
|
||||
{ id: "ctpvNm", title: "시도" },
|
||||
{ id: "sggNm", title: "시군구" },
|
||||
{ id: "swtrmtFcltNm", title: "하수처리시설" },
|
||||
{ id: "aplcnYm", title: "상수사용연월" },
|
||||
{ id: "drtwSwtcRt", title: "오수 전환율" },
|
||||
{ id: "srcNm", title: "RTK 계수 출처" },
|
||||
{ id: "cntbArea", title: "RDII 기여면적" },
|
||||
{ id: "anlsInfo", title: "RDII 분석정보" },
|
||||
{ id: "regDt", title: "분석일자" },
|
||||
{ id: "rgtrNm", title: "분석자" },
|
||||
{ id: "scsYn", title: "분석성공여부" },
|
||||
];
|
||||
|
||||
const Result = () => {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { confirm } = useConfirm();
|
||||
//const AUTH = useCookieAuth();
|
||||
|
||||
const form = useForm<ResultFormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
swtrmtFcltCd: '0',
|
||||
aplcnYmFrom: (() => { // 현재 연월을 yyyyMM 형식으로 설정
|
||||
swtrmtFcltCd: "0",
|
||||
aplcnYmFrom: (() => {
|
||||
// 현재 연월을 yyyyMM 형식으로 설정
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
// const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
// return `${year}${month}`;
|
||||
return `${year}01`;
|
||||
})(),
|
||||
aplcnYmTo: (() => { // 현재 연월을 yyyyMM 형식으로 설정
|
||||
aplcnYmTo: (() => {
|
||||
// 현재 연월을 yyyyMM 형식으로 설정
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
return `${year}${month}`;
|
||||
})(),
|
||||
}
|
||||
})
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
const [tableData, setTableData] = useState<any[]>([]); // API 조회 결과 저장 상태
|
||||
const [totalData, setTotalData] = useState(0) //총 데이터 수
|
||||
const [totalData, setTotalData] = useState(0); //총 데이터 수
|
||||
const [isLoadingResult, setIsLoadingResult] = useState<boolean>(false); // 로딩 상태
|
||||
const [selectedRowData, setSelectedRowData] = useState<any>(null);
|
||||
const [resetSelection, setResetSelection] = useState<boolean>(false);
|
||||
const [isResetPage, setIsResetPage] = useState<boolean>(false);
|
||||
const [curPage, setCurPage] = useState(1); // 현재 페이지
|
||||
const [ctpvCd, setCtpvCd] = useState<string>('0'); // 시도 코드 상태
|
||||
const [sggCd, setSggCd] = useState<string>('0'); // 시군구 코드 상태
|
||||
const [sggName, setSggName] = useState<string>(''); // 시군구 이름 상태
|
||||
const [ctpvCd, setCtpvCd] = useState<string>("0"); // 시도 코드 상태
|
||||
const [sggCd, setSggCd] = useState<string>("0"); // 시군구 코드 상태
|
||||
const [sggName, setSggName] = useState<string>(""); // 시군구 이름 상태
|
||||
|
||||
const { ctpv, sgg } = useStore();
|
||||
|
||||
useEffect(() => {
|
||||
// setCurPage(1);
|
||||
handleSearchButtonClick();
|
||||
}, [])
|
||||
|
||||
// 페이지 변경 시마다 데이터 fetch
|
||||
useEffect(() => {
|
||||
setIsResetPage(false);
|
||||
handleSearchButtonClick();
|
||||
}, [curPage]);
|
||||
|
||||
// 조회 버튼 클릭 시 1페이지로 이동 및 데이터 초기화
|
||||
const handleSearch: () => Promise<void> = async () => {
|
||||
setCurPage(1); // 페이지를 1로 초기화
|
||||
setIsResetPage(true); // 페이지를 초기화
|
||||
await handleSearchButtonClick();
|
||||
await handleSearchButtonClick({ page: 1 });
|
||||
};
|
||||
|
||||
|
||||
const handleSearchButtonClick = async () => {
|
||||
const handleSearchButtonClick = async (options?: {
|
||||
page?: number;
|
||||
ctpvCd?: string;
|
||||
sggCd?: string;
|
||||
}) => {
|
||||
setIsLoadingResult(true);
|
||||
setResetSelection(true);
|
||||
|
||||
const formValues = form.getValues(); // react-hook-form에서 현재 값 가져오기
|
||||
|
||||
const inftwtAnlsSearchDto = {
|
||||
aplcnYmFrom: formValues.aplcnYmFrom?.trim() ? formValues.aplcnYmFrom : null,
|
||||
aplcnYmFrom: formValues.aplcnYmFrom?.trim()
|
||||
? formValues.aplcnYmFrom
|
||||
: null,
|
||||
aplcnYmTo: formValues.aplcnYmTo?.trim() ? formValues.aplcnYmTo : null,
|
||||
ctpvCd: ctpvCd !== '0' ? ctpvCd.trim() : null,
|
||||
sggCd: sggCd !== '0' ? sggCd : null,
|
||||
swtrmtFcltNm: formValues.swtrmtFcltNm?.trim() ? formValues.swtrmtFcltNm : null,
|
||||
ctpvCd:
|
||||
(options?.ctpvCd ?? ctpvCd) !== "0"
|
||||
? (options?.ctpvCd ?? ctpvCd).trim()
|
||||
: null,
|
||||
sggCd:
|
||||
(options?.sggCd ?? sggCd) !== "0" ? (options?.sggCd ?? sggCd) : null,
|
||||
swtrmtFcltNm: formValues.swtrmtFcltNm?.trim()
|
||||
? formValues.swtrmtFcltNm
|
||||
: null,
|
||||
//auth: AUTH,
|
||||
};
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
page: `${curPage - 1}`,
|
||||
size: '10',
|
||||
sort: 'rainInfwtAnlsSn,desc'
|
||||
page: `${(options?.page ?? curPage) - 1}`,
|
||||
size: "10",
|
||||
sort: "rainInfwtAnlsSn,desc",
|
||||
scsYn: "Y",
|
||||
});
|
||||
Object.entries(inftwtAnlsSearchDto).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && String(value).trim() !== '') { // undefined, null, 빈 문자열이 아닌 경우에만 추가
|
||||
if (
|
||||
value !== undefined &&
|
||||
value !== null &&
|
||||
String(value).trim() !== ""
|
||||
) {
|
||||
// undefined, null, 빈 문자열이 아닌 경우에만 추가
|
||||
queryParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
const queryString = queryParams.toString();
|
||||
|
||||
try {
|
||||
const data = await getData(`v1/rdiiAnls${queryString ? `?${queryString}` : ''}`);
|
||||
const data = await getData(
|
||||
`v1/rdiiAnls${queryString ? `?${queryString}` : ""}`,
|
||||
);
|
||||
|
||||
// 🎯 각 아이템을 개별적으로 포맷팅하면서 일련번호 추가
|
||||
const dataWithRowNumber = (data.content || []).map((item: any, index: number) => {
|
||||
const totalElements = Number(data?.totalElements ?? 0);
|
||||
|
||||
// 전체 건수를 기준으로 현재 페이지의 순번을 내림차순 계산한다.
|
||||
const dataWithRowNumber = (data.content || [])
|
||||
.map((item: any, index: number) => {
|
||||
// 🎯 먼저 각 아이템을 포맷팅
|
||||
const formattedItem = formatDisplayData(item);
|
||||
const ctpvOne = ctpv.find(code => code.val.trim() === item?.ctpvCd?.trim());
|
||||
const sggOne = sgg.find(code => code.val === item.sggCd);
|
||||
// 추가 처리 및 일련번호 추가
|
||||
const ctpvOne = ctpv.find(
|
||||
(code) => code.val.trim() === item?.ctpvCd?.trim(),
|
||||
);
|
||||
const sggOne = sgg.find((code) => code.val === item.sggCd);
|
||||
// 추가 처리 및 전체 건수 기준 순번 추가
|
||||
return {
|
||||
...formattedItem,
|
||||
rowNumber: (curPage - 1) * 10 + index + 1, // 1부터 시작하는 일련번호 추가
|
||||
scsYn: item.scsYn === null || item.scsYn === undefined ? 'N' : item.scsYn, // scsYn이 null이면 'N'으로 변경
|
||||
rowNumber: Math.max(
|
||||
totalElements - (((options?.page ?? curPage) - 1) * 10 + index),
|
||||
0,
|
||||
),
|
||||
scsYn:
|
||||
item.scsYn === null || item.scsYn === undefined
|
||||
? "N"
|
||||
: item.scsYn, // scsYn이 null이면 'N'으로 변경
|
||||
ctpvNm: ctpvOne ? ctpvOne.text.trim() : item?.ctpvCd?.trim(),
|
||||
sggNm: sggOne ? sggOne.text : item.sggCd,
|
||||
};
|
||||
})
|
||||
.filter((item: any) => item.scsYn === 'Y');
|
||||
.filter((item: any) => item.scsYn === "Y");
|
||||
|
||||
// 선택된 행 초기화
|
||||
setSelectedRowData(null);
|
||||
|
||||
setTableData(dataWithRowNumber);
|
||||
setTotalData(dataWithRowNumber.length);
|
||||
setTotalData(totalElements);
|
||||
setIsLoadingResult(false);
|
||||
} catch (error) {
|
||||
setIsLoadingResult(false);
|
||||
@@ -183,14 +209,24 @@ const Result = () => {
|
||||
setTotalData(0);
|
||||
setSelectedRowData(null);
|
||||
}
|
||||
setResetSelection(false);
|
||||
};
|
||||
|
||||
// 테이블 셀 클릭 핸들러 추가
|
||||
const handleCellClick = (index: number | undefined, headId: string) => {
|
||||
if (index !== undefined && tableData[index]) {
|
||||
setSelectedRowData(tableData[index]);
|
||||
}
|
||||
/** 검색 조건과 선택 상태를 기본값으로 되돌리고 첫 페이지를 다시 조회한다. */
|
||||
const handleReset = async () => {
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const currentMonth = String(now.getMonth() + 1).padStart(2, "0");
|
||||
form.reset({
|
||||
swtrmtFcltCd: "0",
|
||||
swtrmtFcltNm: "",
|
||||
aplcnYmFrom: `${currentYear}01`,
|
||||
aplcnYmTo: `${currentYear}${currentMonth}`,
|
||||
});
|
||||
setCtpvCd("0");
|
||||
setSggCd("0");
|
||||
setSggName("");
|
||||
setCurPage(1);
|
||||
await handleSearchButtonClick({ page: 1, ctpvCd: "0", sggCd: "0" });
|
||||
};
|
||||
|
||||
// Excel 다운로드 핸들러 추가
|
||||
@@ -198,22 +234,24 @@ const Result = () => {
|
||||
setIsLoadingResult(true);
|
||||
if (!selectedRowData || selectedRowData.rainInfwtAnlsSn === undefined) {
|
||||
setIsLoadingResult(false);
|
||||
alert('다운로드할 데이터를 선택해주세요.');
|
||||
alert("다운로드할 데이터를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await getData(`v1/rdiiAnlsRslt/ppl/${selectedRowData.rainInfwtAnlsSn}`);
|
||||
const data = await getData(
|
||||
`v1/rdiiAnlsRslt/ppl/${selectedRowData.rainInfwtAnlsSn}`,
|
||||
);
|
||||
|
||||
// 선택된 행 데이터를 Excel 다운로드 형식으로 변환
|
||||
const excelData = data.content; // 배열로 감싸서 전달
|
||||
|
||||
// 헤더 매핑 정의
|
||||
const headerMapping: { [key: string]: string } = {
|
||||
'rainInfwtAnlsSn': 'RDII분석\n일련번호',
|
||||
'pplMngNo': '관로\n관리번호',
|
||||
'anlsTm': '분석시각',
|
||||
'predcOilq': '예측유량\n(m³/h)'
|
||||
rainInfwtAnlsSn: "RDII분석\n분석번호",
|
||||
pplMngNo: "관로\n관리번호",
|
||||
anlsTm: "분석시각",
|
||||
predcOilq: "예측유량\n(m³/h)",
|
||||
};
|
||||
|
||||
// 파일명 생성 (분석번호 기반)
|
||||
@@ -224,7 +262,7 @@ const Result = () => {
|
||||
fileName: fileName,
|
||||
sheetNameArray: ["RDII 분석결과"],
|
||||
headerRange: "A1:D1",
|
||||
headerMapping: headerMapping
|
||||
headerMapping: headerMapping,
|
||||
});
|
||||
setIsLoadingResult(false);
|
||||
} catch (error) {
|
||||
@@ -236,17 +274,17 @@ const Result = () => {
|
||||
|
||||
// 파일 삭제 핸들러 추가
|
||||
const handleFileDelete = async () => {
|
||||
setIsLoadingResult(true);
|
||||
if (!selectedRowData || selectedRowData.rainInfwtAnlsSn === undefined) {
|
||||
alert('삭제할 파일을 선택해주세요.');
|
||||
alert("삭제할 파일을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
// 삭제 확인 대화상자
|
||||
const confirmDelete = window.confirm('선택된 분석 결과를 삭제하시겠습니까?');
|
||||
const confirmDelete = await confirm("선택된 분석 결과를 삭제하시겠습니까?");
|
||||
if (!confirmDelete) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingResult(true);
|
||||
try {
|
||||
// 삭제 API 호출
|
||||
await deleteData(`v1/rdiiAnlsRslt/${selectedRowData.rainInfwtAnlsSn}`);
|
||||
@@ -254,23 +292,17 @@ const Result = () => {
|
||||
// 첨부파일(inp파일) 삭제 API 호출
|
||||
handleDelete(selectedRowData.atflCd, {
|
||||
handleDeleteStart: () => console.log("파일 삭제 시작"),
|
||||
handleDeleteSuccess: (response) => console.log("파일 삭제 성공:", response),
|
||||
handleDeleteSuccess: (response) =>
|
||||
console.log("파일 삭제 성공:", response),
|
||||
handleDeleteError: (error) => console.error("파일 삭제 실패:", error),
|
||||
});
|
||||
|
||||
// 테이블 선택 상태 초기화 트리거
|
||||
setResetSelection(true);
|
||||
|
||||
// 검색 버튼 클릭 이벤트 트리거
|
||||
await handleSearchButtonClick();
|
||||
|
||||
setIsLoadingResult(false);
|
||||
|
||||
alert('삭제가 완료되었습니다.');
|
||||
|
||||
setTimeout(() => {
|
||||
setResetSelection(false);
|
||||
}, 100);
|
||||
alert("삭제가 완료되었습니다.");
|
||||
} catch (error) {
|
||||
console.error("분석결과 파일 삭제 중 오류 발생:", error);
|
||||
alert("분석결과 파일 삭제 중 오류가 발생했습니다. 다시 시도해주세요.");
|
||||
@@ -280,50 +312,69 @@ const Result = () => {
|
||||
|
||||
return (
|
||||
<BaseLayout
|
||||
selectBox={{ title: '분석 대상지역 선택', searchChk: true, onSearchClick: handleSearch }}
|
||||
titleName={{ mainTitle: '분석결과', firstCardTitle: 'RDII 산정결과 현황' }}
|
||||
selectBox={{
|
||||
title: "분석 대상지역 선택",
|
||||
searchChk: true,
|
||||
onSearchClick: handleSearch,
|
||||
onResetClick: handleReset,
|
||||
}}
|
||||
titleName={{
|
||||
mainTitle: "RDII 분석결과",
|
||||
firstCardTitle: "RDII 분석결과 목록",
|
||||
}}
|
||||
form={form} // form 인스턴스 전달
|
||||
onSubmitEvent={() => { }} // BaseLayout의 form 태그에 연결될 onSubmit
|
||||
onSubmitEvent={() => {}} // BaseLayout의 form 태그에 연결될 onSubmit
|
||||
graphBtn={true}
|
||||
showDatePicker={true}
|
||||
ctpvCd={ctpvCd}
|
||||
sggCd={sggCd}
|
||||
sggName={sggName}
|
||||
selectedSwagePrcsFclt={form.watch("swtrmtFcltCd")}
|
||||
onSwagePrcsFcltChange={(val) => form.setValue("swtrmtFcltCd", String(val))}
|
||||
onSwagePrcsFcltChange={(val) =>
|
||||
form.setValue("swtrmtFcltCd", String(val))
|
||||
}
|
||||
aplcnYmFrom={form.watch("aplcnYmFrom")}
|
||||
setAplcnYmFrom={(val: string) => { form.setValue("aplcnYmFrom", val) }}
|
||||
setAplcnYmFrom={(val: string) => {
|
||||
form.setValue("aplcnYmFrom", val);
|
||||
}}
|
||||
aplcnYmTo={form.watch("aplcnYmTo")}
|
||||
setAplcnYmTo={(val: string) => { form.setValue("aplcnYmTo", val) }}
|
||||
setAplcnYmTo={(val: string) => {
|
||||
form.setValue("aplcnYmTo", val);
|
||||
}}
|
||||
setCtpvCd={setCtpvCd}
|
||||
setSggCd={setSggCd}
|
||||
setSggName={setSggName}
|
||||
setSwtrmtFcltNm={(val: string) => { form.setValue("swtrmtFcltNm", val) }}
|
||||
setSwtrmtFcltNm={(val: string) => {
|
||||
form.setValue("swtrmtFcltNm", val);
|
||||
}}
|
||||
setIsLoading={setIsLoadingResult}
|
||||
// Excel 다운로드 핸들러 추가
|
||||
onExcelDownload={handleExcelDownload}
|
||||
onDeleteFile={handleFileDelete}
|
||||
selectedRowData={selectedRowData}
|
||||
>
|
||||
{[<div key='table1' className="flex-1 flex flex-col border rounded-lg bg-white shadow-md min-h-0">
|
||||
{/* <div className="mb-[1.2rem] text-xl font-medium">○ RDII 분석결과 현황</div> */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<UiTable
|
||||
headName=''
|
||||
{[
|
||||
<div
|
||||
key="table1"
|
||||
className="min-w-0 [&_.standard-data-table]:!m-0 [&_.standard-data-table]:!border-0"
|
||||
>
|
||||
<MapPopupDataTable
|
||||
rows={tableData}
|
||||
headlist={tableColumn}
|
||||
tableData={tableData}
|
||||
title=""
|
||||
total={totalData}
|
||||
page={curPage}
|
||||
pageSize={10}
|
||||
cellClick={handleCellClick}
|
||||
onPageChange={setCurPage}
|
||||
resetSelection={resetSelection}
|
||||
resetPageNo={isResetPage}
|
||||
onRowClick={(row) => setSelectedRowData(row)}
|
||||
loading={isLoadingResult}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="125rem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
]}
|
||||
</BaseLayout>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Result
|
||||
export default Result;
|
||||
|
||||
@@ -17,156 +17,189 @@
|
||||
* - 침입수분석결과 삭제: DELETE /api/v1/inftwtAnlsRslt/{inftwtAnlsSn} (sewerage-service)
|
||||
* - 침입수분석 삭제: DELETE /api/v1/inftwtAnls/{inftwtAnlsSn} (sewerage-service)
|
||||
*/
|
||||
'use client'
|
||||
import { deleteData, getData } from '@/components/lib/api'
|
||||
import { UiTable } from '@common/business_components/ui/table'
|
||||
import { downloadExcelWithKoreanHeaders } from '@/shared/lib/download-excel'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import BaseLayout from '../BaseLayout'
|
||||
import { handleDelete } from '@/components/lib/utils'
|
||||
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
|
||||
import useStore from '../../../store/useStore';
|
||||
"use client";
|
||||
import { deleteData, getData } from "@/components/lib/api";
|
||||
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import BaseLayout from "../BaseLayout";
|
||||
import { handleDelete } from "@/components/lib/utils";
|
||||
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
|
||||
import useStore from "../../../store/useStore";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
|
||||
|
||||
const formSchema = z.object({
|
||||
swtrmtFcltCd: z.string().default('0'), // 하수처리시설코드
|
||||
swtrmtFcltNm: z.string().default(''), // 하수처리시설명
|
||||
aplcnYmFrom: z.string().default(() => { // Date에서 string(yyyyMM 형식)으로 변경
|
||||
swtrmtFcltCd: z.string().default("0"), // 하수처리시설코드
|
||||
swtrmtFcltNm: z.string().default(""), // 하수처리시설명
|
||||
aplcnYmFrom: z.string().default(() => {
|
||||
// Date에서 string(yyyyMM 형식)으로 변경
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
// const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
// return `${year}${month}`;
|
||||
return `${year}01`;
|
||||
}), // 분석기간 시작연월
|
||||
aplcnYmTo: z.string().default(() => { // Date에서 string(yyyyMM 형식)으로 변경
|
||||
aplcnYmTo: z.string().default(() => {
|
||||
// Date에서 string(yyyyMM 형식)으로 변경
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
return `${year}${month}`;
|
||||
}), // 분석기간 종료연월
|
||||
})
|
||||
});
|
||||
|
||||
type ResultFormValues = z.infer<typeof formSchema>
|
||||
type ResultFormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const tableColumn = [
|
||||
{ id: 'rowNumber', title: '일련번호' },
|
||||
{ id: 'ctpvNm', title: '시도' },
|
||||
{ id: 'sggNm', title: '시군구' },
|
||||
{ id: 'swtrmtFcltNm', title: '하수처리시설' },
|
||||
{ id: 'aplcnYm', title: '적용연월' },
|
||||
{ id: 'drtwSwtcRt', title: '오수 전환율' },
|
||||
{ id: 'anlsInfo', title: '침입수 분석정보' },
|
||||
{ id: 'regDt', title: '분석일자' },
|
||||
{ id: 'rgtrNm', title: '분석자' },
|
||||
{ id: 'scsYn', title: '분석성공여부' }
|
||||
]
|
||||
{ id: "rowNumber", title: "순번" },
|
||||
{ id: "ctpvNm", title: "시도" },
|
||||
{ id: "sggNm", title: "시군구" },
|
||||
{ id: "swtrmtFcltNm", title: "하수처리시설" },
|
||||
{ id: "aplcnYm", title: "적용연월" },
|
||||
{ id: "drtwSwtcRt", title: "오수 전환율" },
|
||||
{ id: "anlsInfo", title: "침입수 분석정보" },
|
||||
{ id: "regDt", title: "분석일자" },
|
||||
{ id: "rgtrNm", title: "분석자" },
|
||||
{ id: "scsYn", title: "분석성공여부" },
|
||||
];
|
||||
|
||||
const Result = () => {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { confirm } = useConfirm();
|
||||
//const AUTH = useCookieAuth();
|
||||
|
||||
const form = useForm<ResultFormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
swtrmtFcltCd: '0',
|
||||
swtrmtFcltNm: '',
|
||||
aplcnYmFrom: (() => { // 현재 연월을 yyyyMM 형식으로 설정
|
||||
swtrmtFcltCd: "0",
|
||||
swtrmtFcltNm: "",
|
||||
aplcnYmFrom: (() => {
|
||||
// 현재 연월을 yyyyMM 형식으로 설정
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
// const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
// return `${year}${month}`;
|
||||
return `${year}01`;
|
||||
})(),
|
||||
aplcnYmTo: (() => { // 현재 연월을 yyyyMM 형식으로 설정
|
||||
aplcnYmTo: (() => {
|
||||
// 현재 연월을 yyyyMM 형식으로 설정
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
return `${year}${month}`;
|
||||
})(),
|
||||
}
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
const [tableData, setTableData] = useState<any[]>([]); // API 조회 결과 저장 상태
|
||||
const [totalData, setTotalData] = useState(0) //총 데이터 수l
|
||||
const [totalData, setTotalData] = useState(0); //총 데이터 수l
|
||||
const [isLoadingResult, setIsLoadingResult] = useState<boolean>(false); // 로딩 상태
|
||||
const [selectedRowData, setSelectedRowData] = useState<any>(null);
|
||||
const [resetSelection, setResetSelection] = useState<boolean>(false);
|
||||
const [isResetPage, setIsResetPage] = useState<boolean>(false);
|
||||
const [curPage, setCurPage] = useState(1); // 현재 페이지
|
||||
const [ctpvCd, setCtpvCd] = useState<string>('0'); // 시도 코드 상태
|
||||
const [sggCd, setSggCd] = useState<string>('0'); // 시군구 코드 상태
|
||||
const [sggName, setSggName] = useState<string>(''); // 시군구 이름 상태
|
||||
const [ctpvCd, setCtpvCd] = useState<string>("0"); // 시도 코드 상태
|
||||
const [sggCd, setSggCd] = useState<string>("0"); // 시군구 코드 상태
|
||||
const [sggName, setSggName] = useState<string>(""); // 시군구 이름 상태
|
||||
const { ctpv, sgg } = useStore();
|
||||
|
||||
useEffect(() => {
|
||||
// setCurPage(1);
|
||||
handleSearchButtonClick();
|
||||
}, [])
|
||||
|
||||
// 페이지 변경 시마다 데이터 fetch
|
||||
useEffect(() => {
|
||||
setIsResetPage(false);
|
||||
handleSearchButtonClick();
|
||||
}, [curPage]);
|
||||
|
||||
// 조회 버튼 클릭 시 1페이지로 이동 및 데이터 초기화
|
||||
const handleSearch: () => Promise<void> = async () => {
|
||||
setCurPage(1); // 페이지를 1로 초기화
|
||||
setIsResetPage(true); // 페이지를 초기화
|
||||
await handleSearchButtonClick();
|
||||
await handleSearchButtonClick({ page: 1 });
|
||||
};
|
||||
|
||||
const handleSearchButtonClick = async () => {
|
||||
const handleSearchButtonClick = async (options?: {
|
||||
page?: number;
|
||||
ctpvCd?: string;
|
||||
sggCd?: string;
|
||||
}) => {
|
||||
setIsLoadingResult(true);
|
||||
setResetSelection(true);
|
||||
const formValues = form.getValues(); // react-hook-form에서 현재 값 가져오기
|
||||
|
||||
let inftwtAnlsSearchDto = {
|
||||
"aplcnYmFrom": formValues.aplcnYmFrom && formValues.aplcnYmFrom !== '' ? formValues.aplcnYmFrom : null,
|
||||
"aplcnYmTo": formValues.aplcnYmTo && formValues.aplcnYmTo !== '' ? formValues.aplcnYmTo : null,
|
||||
"ctpvCd": ctpvCd === '0' ? null : ctpvCd.trim(),
|
||||
"sggCd": sggCd === '0' ? null : sggCd,
|
||||
"swtrmtFcltNm": formValues.swtrmtFcltNm?.trim() ? formValues.swtrmtFcltNm : null,
|
||||
aplcnYmFrom:
|
||||
formValues.aplcnYmFrom && formValues.aplcnYmFrom !== ""
|
||||
? formValues.aplcnYmFrom
|
||||
: null,
|
||||
aplcnYmTo:
|
||||
formValues.aplcnYmTo && formValues.aplcnYmTo !== ""
|
||||
? formValues.aplcnYmTo
|
||||
: null,
|
||||
ctpvCd:
|
||||
(options?.ctpvCd ?? ctpvCd) === "0"
|
||||
? null
|
||||
: (options?.ctpvCd ?? ctpvCd).trim(),
|
||||
sggCd:
|
||||
(options?.sggCd ?? sggCd) === "0" ? null : (options?.sggCd ?? sggCd),
|
||||
swtrmtFcltNm: formValues.swtrmtFcltNm?.trim()
|
||||
? formValues.swtrmtFcltNm
|
||||
: null,
|
||||
//"auth": AUTH,
|
||||
};
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
page: `${curPage - 1}`,
|
||||
size: '10',
|
||||
sort: 'inftwtAnlsSn,desc'
|
||||
page: `${(options?.page ?? curPage) - 1}`,
|
||||
size: "10",
|
||||
sort: "inftwtAnlsSn,desc",
|
||||
scsYn: "Y",
|
||||
});
|
||||
Object.entries(inftwtAnlsSearchDto).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && String(value).trim() !== '') { // undefined, null, 빈 문자열이 아닌 경우에만 추가
|
||||
if (
|
||||
value !== undefined &&
|
||||
value !== null &&
|
||||
String(value).trim() !== ""
|
||||
) {
|
||||
// undefined, null, 빈 문자열이 아닌 경우에만 추가
|
||||
queryParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
const queryString = queryParams.toString();
|
||||
try {
|
||||
const data = await getData(`v1/inftwtAnls${queryString ? `?${queryString}` : ''}`);
|
||||
const data = await getData(
|
||||
`v1/inftwtAnls${queryString ? `?${queryString}` : ""}`,
|
||||
);
|
||||
|
||||
// 🎯 각 아이템을 개별적으로 포맷팅하면서 일련번호 추가
|
||||
const dataWithRowNumber = (data.content || []).map((item: any, index: number) => {
|
||||
const totalElements = Number(data?.totalElements ?? 0);
|
||||
|
||||
// 전체 건수를 기준으로 현재 페이지의 순번을 내림차순 계산한다.
|
||||
const dataWithRowNumber = (data.content || [])
|
||||
.map((item: any, index: number) => {
|
||||
// 🎯 먼저 각 아이템을 포맷팅
|
||||
const formattedItem = formatDisplayData(item);
|
||||
const ctpvOne = ctpv.find(code => code.val.trim() === item?.ctpvCd?.trim());
|
||||
const sggOne = sgg.find(code => code.val === item.sggCd);
|
||||
// 추가 처리 및 일련번호 추가
|
||||
const ctpvOne = ctpv.find(
|
||||
(code) => code.val.trim() === item?.ctpvCd?.trim(),
|
||||
);
|
||||
const sggOne = sgg.find((code) => code.val === item.sggCd);
|
||||
// 추가 처리 및 전체 건수 기준 순번 추가
|
||||
return {
|
||||
...formattedItem,
|
||||
rowNumber: (curPage - 1) * 10 + index + 1, // 1부터 시작하는 일련번호 추가
|
||||
scsYn: item.scsYn === null || item.scsYn === undefined ? 'N' : item.scsYn, // scsYn이 null이면 'N'으로 변경
|
||||
rowNumber: Math.max(
|
||||
totalElements - (((options?.page ?? curPage) - 1) * 10 + index),
|
||||
0,
|
||||
),
|
||||
scsYn:
|
||||
item.scsYn === null || item.scsYn === undefined
|
||||
? "N"
|
||||
: item.scsYn, // scsYn이 null이면 'N'으로 변경
|
||||
ctpvNm: ctpvOne ? ctpvOne.text : item.ctpvCd,
|
||||
sggNm: sggOne ? sggOne.text : item.sggCd,
|
||||
};
|
||||
})
|
||||
.filter((item: any) => item.scsYn === 'Y');
|
||||
.filter((item: any) => item.scsYn === "Y");
|
||||
|
||||
// 선택된 행 초기화
|
||||
setSelectedRowData(null);
|
||||
|
||||
setTableData(dataWithRowNumber);
|
||||
setTotalData(data?.totalElements || 0); // totalElements가 없을 경우 0으로 설정
|
||||
setTotalData(totalElements);
|
||||
setIsLoadingResult(false);
|
||||
} catch (error) {
|
||||
setIsLoadingResult(false);
|
||||
@@ -176,14 +209,24 @@ const Result = () => {
|
||||
setTotalData(0);
|
||||
setSelectedRowData(null);
|
||||
}
|
||||
setResetSelection(false);
|
||||
};
|
||||
|
||||
// 테이블 셀 클릭 핸들러 추가
|
||||
const handleCellClick = (index: number | undefined, headId: string) => {
|
||||
if (index !== undefined && tableData[index]) {
|
||||
setSelectedRowData(tableData[index]);
|
||||
}
|
||||
/** 검색 조건과 선택 상태를 기본값으로 되돌리고 첫 페이지를 다시 조회한다. */
|
||||
const handleReset = async () => {
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const currentMonth = String(now.getMonth() + 1).padStart(2, "0");
|
||||
form.reset({
|
||||
swtrmtFcltCd: "0",
|
||||
swtrmtFcltNm: "",
|
||||
aplcnYmFrom: `${currentYear}01`,
|
||||
aplcnYmTo: `${currentYear}${currentMonth}`,
|
||||
});
|
||||
setCtpvCd("0");
|
||||
setSggCd("0");
|
||||
setSggName("");
|
||||
setCurPage(1);
|
||||
await handleSearchButtonClick({ page: 1, ctpvCd: "0", sggCd: "0" });
|
||||
};
|
||||
|
||||
// Excel 다운로드 핸들러 추가
|
||||
@@ -191,22 +234,24 @@ const Result = () => {
|
||||
setIsLoadingResult(true);
|
||||
if (!selectedRowData || selectedRowData.inftwtAnlsSn === undefined) {
|
||||
setIsLoadingResult(false);
|
||||
alert('다운로드할 데이터를 선택해주세요.');
|
||||
alert("다운로드할 데이터를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await getData(`v1/inftwtAnlsRslt/ppl/${selectedRowData.inftwtAnlsSn}`);
|
||||
const data = await getData(
|
||||
`v1/inftwtAnlsRslt/ppl/${selectedRowData.inftwtAnlsSn}`,
|
||||
);
|
||||
|
||||
// 선택된 행 데이터를 Excel 다운로드 형식으로 변환
|
||||
const excelData = data.content; // 배열로 감싸서 전달
|
||||
|
||||
// 헤더 매핑 정의
|
||||
const headerMapping: { [key: string]: string } = {
|
||||
'inftwtAnlsSn': '침입수분석\n일련번호',
|
||||
'pplMngNo': '관로\n관리번호',
|
||||
'anlsTm': '분석시각',
|
||||
'predcOilq': '예측유량\n(m³/h)'
|
||||
inftwtAnlsSn: "침입수분석\n분석번호",
|
||||
pplMngNo: "관로\n관리번호",
|
||||
anlsTm: "분석시각",
|
||||
predcOilq: "예측유량\n(m³/h)",
|
||||
};
|
||||
|
||||
// 파일명 생성 (분석번호 기반)
|
||||
@@ -217,7 +262,7 @@ const Result = () => {
|
||||
fileName: fileName,
|
||||
sheetNameArray: ["침입수 분석결과"],
|
||||
headerRange: "A1:D1",
|
||||
headerMapping: headerMapping
|
||||
headerMapping: headerMapping,
|
||||
});
|
||||
setIsLoadingResult(false);
|
||||
} catch (error) {
|
||||
@@ -229,18 +274,17 @@ const Result = () => {
|
||||
|
||||
// 파일 삭제 핸들러 추가
|
||||
const handleFileDelete = async () => {
|
||||
setIsLoadingResult(true);
|
||||
if (!selectedRowData || selectedRowData.inftwtAnlsSn === undefined) {
|
||||
setIsLoadingResult(false);
|
||||
alert('삭제할 파일을 선택해주세요.');
|
||||
alert("삭제할 파일을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
// 삭제 확인 대화상자
|
||||
const confirmDelete = window.confirm('선택된 분석 결과를 삭제하시겠습니까?');
|
||||
const confirmDelete = await confirm("선택된 분석 결과를 삭제하시겠습니까?");
|
||||
if (!confirmDelete) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingResult(true);
|
||||
try {
|
||||
// // 삭제 API 호출
|
||||
await deleteData(`v1/inftwtAnlsRslt/${selectedRowData.inftwtAnlsSn}`);
|
||||
@@ -248,21 +292,17 @@ const Result = () => {
|
||||
// 첨부파일(inp파일) 삭제 API 호출
|
||||
handleDelete(selectedRowData.atflCd, {
|
||||
handleDeleteStart: () => console.log("파일 삭제 시작"),
|
||||
handleDeleteSuccess: (response) => console.log("파일 삭제 성공:", response),
|
||||
handleDeleteSuccess: (response) =>
|
||||
console.log("파일 삭제 성공:", response),
|
||||
handleDeleteError: (error) => console.error("파일 삭제 실패:", error),
|
||||
});
|
||||
|
||||
// 테이블 선택 상태 초기화 트리거
|
||||
setResetSelection(true);
|
||||
|
||||
// 검색 버튼 클릭 이벤트 트리거
|
||||
await handleSearchButtonClick();
|
||||
|
||||
setIsLoadingResult(false);
|
||||
|
||||
alert('삭제가 완료되었습니다.');
|
||||
|
||||
setResetSelection(false);
|
||||
alert("삭제가 완료되었습니다.");
|
||||
} catch (error) {
|
||||
setIsLoadingResult(false);
|
||||
console.error("분석결과 파일 삭제 중 오류 발생:", error);
|
||||
@@ -270,52 +310,70 @@ const Result = () => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<BaseLayout
|
||||
selectBox={{ title: '분석 대상지역 선택', searchChk: true, onSearchClick: handleSearch }}
|
||||
titleName={{ mainTitle: '분석결과', firstCardTitle: '분석이력' }}
|
||||
selectBox={{
|
||||
title: "분석 대상지역 선택",
|
||||
searchChk: true,
|
||||
onSearchClick: handleSearch,
|
||||
onResetClick: handleReset,
|
||||
}}
|
||||
titleName={{
|
||||
mainTitle: "침입수 분석결과",
|
||||
firstCardTitle: "침입수 분석결과 목록",
|
||||
}}
|
||||
form={form} // form 인스턴스 전달
|
||||
onSubmitEvent={() => { }} // BaseLayout의 form 태그에 연결될 onSubmit
|
||||
onSubmitEvent={() => {}} // BaseLayout의 form 태그에 연결될 onSubmit
|
||||
graphBtn={true}
|
||||
showDatePicker={true}
|
||||
ctpvCd={ctpvCd}
|
||||
sggCd={sggCd}
|
||||
sggName={sggName}
|
||||
selectedSwagePrcsFclt={form.watch("swtrmtFcltCd")}
|
||||
onSwagePrcsFcltChange={(val) => form.setValue("swtrmtFcltCd", String(val))}
|
||||
onSwagePrcsFcltChange={(val) =>
|
||||
form.setValue("swtrmtFcltCd", String(val))
|
||||
}
|
||||
aplcnYmFrom={form.watch("aplcnYmFrom")}
|
||||
setAplcnYmFrom={(val: string) => { form.setValue("aplcnYmFrom", val) }}
|
||||
setAplcnYmFrom={(val: string) => {
|
||||
form.setValue("aplcnYmFrom", val);
|
||||
}}
|
||||
aplcnYmTo={form.watch("aplcnYmTo")}
|
||||
setAplcnYmTo={(val: string) => { form.setValue("aplcnYmTo", val) }}
|
||||
setAplcnYmTo={(val: string) => {
|
||||
form.setValue("aplcnYmTo", val);
|
||||
}}
|
||||
setCtpvCd={setCtpvCd}
|
||||
setSggCd={setSggCd}
|
||||
setSggName={setSggName}
|
||||
setSwtrmtFcltNm={(val: string) => { form.setValue("swtrmtFcltNm", val) }}
|
||||
setSwtrmtFcltNm={(val: string) => {
|
||||
form.setValue("swtrmtFcltNm", val);
|
||||
}}
|
||||
setIsLoading={setIsLoadingResult}
|
||||
onExcelDownload={handleExcelDownload}
|
||||
onDeleteFile={handleFileDelete}
|
||||
selectedRowData={selectedRowData}
|
||||
>
|
||||
{[<div key='table1' className="flex-1 flex flex-col border rounded-lg bg-white shadow-md min-h-0">
|
||||
{/* <div className="mb-[1.2rem] text-xl font-medium">○ 침입수 산정결과 현황</div> */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<UiTable
|
||||
headName=''
|
||||
{[
|
||||
<div
|
||||
key="table1"
|
||||
className="min-w-0 [&_.standard-data-table]:!m-0 [&_.standard-data-table]:!border-0"
|
||||
>
|
||||
<MapPopupDataTable
|
||||
rows={tableData}
|
||||
headlist={tableColumn}
|
||||
tableData={tableData}
|
||||
title=""
|
||||
total={totalData}
|
||||
page={curPage}
|
||||
pageSize={10}
|
||||
cellClick={handleCellClick}
|
||||
onPageChange={setCurPage}
|
||||
resetSelection={resetSelection}
|
||||
resetPageNo={isResetPage}
|
||||
onRowClick={(row) => setSelectedRowData(row)}
|
||||
loading={isLoadingResult}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth="110rem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
]}
|
||||
</BaseLayout>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Result
|
||||
export default Result;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { getRawJson } from "@/components/lib/api";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
|
||||
type AnalysisStatusResponse = {
|
||||
scsYn?: string | null;
|
||||
@@ -21,7 +22,7 @@ type AnalysisStatusEnvelope = {
|
||||
};
|
||||
|
||||
const unwrapAnalysisStatusResponse = (
|
||||
response: AnalysisStatusResponse | AnalysisStatusEnvelope | null
|
||||
response: AnalysisStatusResponse | AnalysisStatusEnvelope | null,
|
||||
): AnalysisStatusResponse | null => {
|
||||
if (!response || typeof response !== "object") {
|
||||
return null;
|
||||
@@ -72,7 +73,8 @@ const PROGRESS_OVERLAY_ANALYSIS_TYPES = new Set([
|
||||
RDII_ANALYSIS_TYPE,
|
||||
]);
|
||||
const PENDING_ANALYSIS_WATCHES_STORAGE_KEY = "SN_PENDING_ANALYSIS_WATCHES";
|
||||
const PENDING_ANALYSIS_WATCHES_CHANGED_EVENT = "sn-pending-analysis-watches-changed";
|
||||
const PENDING_ANALYSIS_WATCHES_CHANGED_EVENT =
|
||||
"sn-pending-analysis-watches-changed";
|
||||
|
||||
const clampProgressPercent = (value?: number | null) => {
|
||||
if (!Number.isFinite(value)) {
|
||||
@@ -82,7 +84,8 @@ const clampProgressPercent = (value?: number | null) => {
|
||||
return Math.max(0, Math.min(100, Number(value)));
|
||||
};
|
||||
|
||||
const createWatchKey = (statusUrl: string, analysisSn: number) => `${statusUrl}::${analysisSn}`;
|
||||
const createWatchKey = (statusUrl: string, analysisSn: number) =>
|
||||
`${statusUrl}::${analysisSn}`;
|
||||
|
||||
const dispatchPendingAnalysisChanged = () => {
|
||||
if (typeof window === "undefined") {
|
||||
@@ -92,7 +95,9 @@ const dispatchPendingAnalysisChanged = () => {
|
||||
window.dispatchEvent(new Event(PENDING_ANALYSIS_WATCHES_CHANGED_EVENT));
|
||||
};
|
||||
|
||||
const normalizePendingAnalysisWatch = (raw: unknown): PendingAnalysisWatch | null => {
|
||||
const normalizePendingAnalysisWatch = (
|
||||
raw: unknown,
|
||||
): PendingAnalysisWatch | null => {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null;
|
||||
}
|
||||
@@ -107,7 +112,7 @@ const normalizePendingAnalysisWatch = (raw: unknown): PendingAnalysisWatch | nul
|
||||
const progressPercent = clampProgressPercent(
|
||||
typeof candidate.progressPercent === "number"
|
||||
? candidate.progressPercent
|
||||
: Number(candidate.progressPercent)
|
||||
: Number(candidate.progressPercent),
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -145,7 +150,9 @@ const loadPendingAnalysisWatches = (): PendingAnalysisWatch[] => {
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(PENDING_ANALYSIS_WATCHES_STORAGE_KEY);
|
||||
const raw = window.localStorage.getItem(
|
||||
PENDING_ANALYSIS_WATCHES_STORAGE_KEY,
|
||||
);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
@@ -177,14 +184,14 @@ const savePendingAnalysisWatches = (items: PendingAnalysisWatch[]) => {
|
||||
|
||||
window.localStorage.setItem(
|
||||
PENDING_ANALYSIS_WATCHES_STORAGE_KEY,
|
||||
JSON.stringify(items)
|
||||
JSON.stringify(items),
|
||||
);
|
||||
dispatchPendingAnalysisChanged();
|
||||
};
|
||||
|
||||
const replacePendingAnalysisWatch = (
|
||||
items: PendingAnalysisWatch[],
|
||||
nextItem: PendingAnalysisWatch
|
||||
nextItem: PendingAnalysisWatch,
|
||||
): PendingAnalysisWatch[] => {
|
||||
const filteredItems = items.filter((item) => item.key !== nextItem.key);
|
||||
return [...filteredItems, nextItem];
|
||||
@@ -224,7 +231,7 @@ const AnalysisProgressOverlay = ({ item }: { item: PendingAnalysisWatch }) => {
|
||||
const progressText = toProgressText(item);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-4 left-4 z-[90] w-[190px] max-w-[calc(100vw-2rem)] rounded-lg border border-primary bg-white/95 p-3 shadow-[0_10px_24px_rgba(37,139,232,0.18)] backdrop-blur-sm">
|
||||
<div className="pointer-events-none fixed bottom-4 left-4 z-[90] w-[190px] max-w-[calc(100vw-2rem)] rounded-lg border border-primary bg-background p-3 shadow-[0_10px_24px_rgba(37,139,232,0.18)] backdrop-blur-sm">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<p className="truncate text-[1.05rem] font-semibold text-primary">
|
||||
{item.analysisName}
|
||||
@@ -233,13 +240,13 @@ const AnalysisProgressOverlay = ({ item }: { item: PendingAnalysisWatch }) => {
|
||||
{progressPercent}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-2 h-[6px] overflow-hidden rounded-full bg-[#dbeafe]">
|
||||
<div className="mb-2 h-[6px] overflow-hidden rounded-full bg-primary-transparent">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="truncate text-[0.95rem] text-slate-600">{progressText}</p>
|
||||
<p className="truncate text-[0.95rem] text-label">{progressText}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -280,7 +287,7 @@ export function useAnalysisCompletionWatcher({
|
||||
|
||||
const nextItems = replacePendingAnalysisWatch(
|
||||
loadPendingAnalysisWatches(),
|
||||
nextItem
|
||||
nextItem,
|
||||
);
|
||||
savePendingAnalysisWatches(nextItems);
|
||||
};
|
||||
@@ -296,7 +303,7 @@ export function useAnalysisCompletionWatcher({
|
||||
}
|
||||
|
||||
const nextItems = loadPendingAnalysisWatches().filter(
|
||||
(item) => item.analysisSn !== analysisSn
|
||||
(item) => item.analysisSn !== analysisSn,
|
||||
);
|
||||
savePendingAnalysisWatches(nextItems);
|
||||
};
|
||||
@@ -308,11 +315,15 @@ export function useAnalysisCompletionWatcher({
|
||||
}
|
||||
|
||||
export function GlobalAnalysisCompletionWatcher() {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const pathname = usePathname();
|
||||
// 전역 overlay와 완료 alert가 같은 storage 소스를 바라보도록 local state로 동기화한다.
|
||||
const [pendingItems, setPendingItems] = useState<PendingAnalysisWatch[]>([]);
|
||||
|
||||
const activeOverlayItem = useMemo(() => toOverlayItem(pendingItems), [pendingItems]);
|
||||
const activeOverlayItem = useMemo(
|
||||
() => toOverlayItem(pendingItems),
|
||||
[pendingItems],
|
||||
);
|
||||
const shouldHideOverlay = pathname?.includes("/popup/") ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -348,17 +359,21 @@ export function GlobalAnalysisCompletionWatcher() {
|
||||
try {
|
||||
for (const item of currentItems) {
|
||||
const pollUrl = `${item.statusUrl}${item.statusUrl.includes("?") ? "&" : "?"}_ts=${Date.now()}`;
|
||||
const rawResponse = (await getRawJson(pollUrl, { cache: "no-store" })) as AnalysisStatusResponse | AnalysisStatusEnvelope | null;
|
||||
const rawResponse = (await getRawJson(pollUrl, {
|
||||
cache: "no-store",
|
||||
})) as AnalysisStatusResponse | AnalysisStatusEnvelope | null;
|
||||
const response = unwrapAnalysisStatusResponse(rawResponse);
|
||||
if (!response) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const scsYn = String(response.scsYn ?? "").trim().toUpperCase();
|
||||
const scsYn = String(response.scsYn ?? "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
const message = String(response.message ?? "").trim();
|
||||
const latestItems = loadPendingAnalysisWatches();
|
||||
const latestItem = latestItems.find(
|
||||
(latestWatch) => latestWatch.key === item.key
|
||||
(latestWatch) => latestWatch.key === item.key,
|
||||
);
|
||||
if (!latestItem) {
|
||||
continue;
|
||||
@@ -368,17 +383,18 @@ export function GlobalAnalysisCompletionWatcher() {
|
||||
...latestItem,
|
||||
progressMessage:
|
||||
String(
|
||||
response.progressMessage ?? latestItem.progressMessage ?? ""
|
||||
response.progressMessage ?? latestItem.progressMessage ?? "",
|
||||
).trim() || latestItem.progressMessage,
|
||||
progressPercent:
|
||||
clampProgressPercent(
|
||||
typeof response.progressPercent === "number"
|
||||
? response.progressPercent
|
||||
: Number(response.progressPercent)
|
||||
: Number(response.progressPercent),
|
||||
) ?? latestItem.progressPercent,
|
||||
progressStep:
|
||||
String(response.progressStep ?? latestItem.progressStep ?? "").trim() ||
|
||||
latestItem.progressStep,
|
||||
String(
|
||||
response.progressStep ?? latestItem.progressStep ?? "",
|
||||
).trim() || latestItem.progressStep,
|
||||
updatedAt:
|
||||
String(response.updatedAt ?? latestItem.updatedAt ?? "").trim() ||
|
||||
latestItem.updatedAt,
|
||||
@@ -386,15 +402,18 @@ export function GlobalAnalysisCompletionWatcher() {
|
||||
|
||||
if (scsYn !== "Y" && scsYn !== "N") {
|
||||
savePendingAnalysisWatches(
|
||||
replacePendingAnalysisWatch(latestItems, updatedItem)
|
||||
replacePendingAnalysisWatch(latestItems, updatedItem),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
savePendingAnalysisWatches(
|
||||
latestItems.filter((latestWatch) => latestWatch.key !== item.key)
|
||||
latestItems.filter((latestWatch) => latestWatch.key !== item.key),
|
||||
);
|
||||
alert(
|
||||
message ||
|
||||
(scsYn === "Y" ? item.successMessage : item.failureMessage),
|
||||
);
|
||||
alert(message || (scsYn === "Y" ? item.successMessage : item.failureMessage));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("분석 완료 상태 조회 중 오류가 발생했습니다.", error);
|
||||
@@ -416,7 +435,7 @@ export function GlobalAnalysisCompletionWatcher() {
|
||||
window.addEventListener("storage", handlePendingAnalysisChanged);
|
||||
window.addEventListener(
|
||||
PENDING_ANALYSIS_WATCHES_CHANGED_EVENT,
|
||||
handlePendingAnalysisChanged
|
||||
handlePendingAnalysisChanged,
|
||||
);
|
||||
document.addEventListener("visibilitychange", handlePendingAnalysisChanged);
|
||||
syncPendingItems();
|
||||
@@ -429,15 +448,20 @@ export function GlobalAnalysisCompletionWatcher() {
|
||||
window.removeEventListener("storage", handlePendingAnalysisChanged);
|
||||
window.removeEventListener(
|
||||
PENDING_ANALYSIS_WATCHES_CHANGED_EVENT,
|
||||
handlePendingAnalysisChanged
|
||||
handlePendingAnalysisChanged,
|
||||
);
|
||||
document.removeEventListener(
|
||||
"visibilitychange",
|
||||
handlePendingAnalysisChanged,
|
||||
);
|
||||
document.removeEventListener("visibilitychange", handlePendingAnalysisChanged);
|
||||
};
|
||||
}, [shouldHideOverlay]);
|
||||
}, [alert, shouldHideOverlay]);
|
||||
|
||||
if (shouldHideOverlay) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return activeOverlayItem ? <AnalysisProgressOverlay item={activeOverlayItem} /> : null;
|
||||
return activeOverlayItem ? (
|
||||
<AnalysisProgressOverlay item={activeOverlayItem} />
|
||||
) : null;
|
||||
}
|
||||
@@ -50,6 +50,7 @@ export default function WithBaseLayout({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sn-base-layout w-full min-w-0 max-w-full">
|
||||
<BaseLayout
|
||||
queryClient={queryClient}
|
||||
authService={authService}
|
||||
@@ -59,5 +60,6 @@ export default function WithBaseLayout({
|
||||
<GlobalAnalysisCompletionWatcher />
|
||||
<PageLayout>{children}</PageLayout>
|
||||
</BaseLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+290
-154
@@ -1,12 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import { FileUpload } from "@common/business_components/ui/file-upload";
|
||||
import { Button } from "@common/components/ui/button";
|
||||
import { Input } from "@common/components/ui/input";
|
||||
import { Label } from "@common/components/ui/label";
|
||||
import { StandardPopupLayout } from "@common/business_components";
|
||||
import {
|
||||
Button,
|
||||
InputDatePicker,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
import { MS_SWAGE_PPL_CODES } from "@/app/(map-layout)/map/components/common/table";
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchDredgingPhotoBlob,
|
||||
@@ -22,6 +31,7 @@ import {
|
||||
type DredgingPhoto,
|
||||
type DredgingPhotoDraft,
|
||||
type DredgingRecord,
|
||||
type DredgingRegistrationMode,
|
||||
type GeoPoint,
|
||||
type PipeMatch,
|
||||
} from "../lib/types";
|
||||
@@ -45,11 +55,6 @@ type NewPhotoPreview = DredgingPhotoDraft & {
|
||||
previewUrl: string;
|
||||
};
|
||||
|
||||
const PRIMARY_BUTTON_CLASS =
|
||||
"min-w-[6.4rem] border !border-blue-600 !bg-blue-600 px-4 font-medium !text-white hover:!bg-blue-700";
|
||||
const SECONDARY_BUTTON_CLASS =
|
||||
"min-w-[6.4rem] border !border-blue-300 !bg-white px-4 font-medium !text-blue-700 hover:!bg-blue-50";
|
||||
|
||||
const pipeMaterialNameByCode = new Map(
|
||||
MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text]),
|
||||
);
|
||||
@@ -74,12 +79,16 @@ const formatLocalDate = (date: Date) => {
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const parseLocalDate = (value: string) => new Date(`${value}T00:00:00`);
|
||||
|
||||
/** 상세 UI를 공통 기반으로 사용해 조회와 수정 상태의 시각 구조를 동일하게 유지한다. */
|
||||
const DredgingDetailPopupPage = ({
|
||||
record,
|
||||
mode = "detail",
|
||||
onUpdated,
|
||||
}: Props) => {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { confirm } = useConfirm();
|
||||
const editable = mode === "edit";
|
||||
const [photos, setPhotos] = useState<DredgingPhotoPreview[]>([]);
|
||||
const [newPhotos, setNewPhotos] = useState<NewPhotoPreview[]>([]);
|
||||
@@ -94,6 +103,14 @@ const DredgingDetailPopupPage = ({
|
||||
});
|
||||
const [locationDraft, setLocationDraft] = useState<GeoPoint | null>(null);
|
||||
const [isEditingLocation, setIsEditingLocation] = useState(false);
|
||||
// 등록방식은 사진 필수 여부와 마스터의 등록구분을 함께 결정한다.
|
||||
const [registrationMode, setRegistrationMode] =
|
||||
useState<DredgingRegistrationMode>(
|
||||
record.registrationMode ??
|
||||
(record.photoCount === 0 && record.registrationSource === "MANUAL"
|
||||
? "MANUAL"
|
||||
: "PHOTO"),
|
||||
);
|
||||
const [locationSource, setLocationSource] = useState(
|
||||
record.registrationSource,
|
||||
);
|
||||
@@ -220,7 +237,12 @@ const DredgingDetailPopupPage = ({
|
||||
],
|
||||
["위치등록", locationSourceLabel[locationSource]],
|
||||
];
|
||||
const pipeInfoRows = Array.from(
|
||||
{ length: Math.ceil(pipeInfo.length / 2) },
|
||||
(_, index) => pipeInfo.slice(index * 2, index * 2 + 2),
|
||||
);
|
||||
const automaticMappingValue = `${matchedPipe.distanceMeters.toFixed(2)} m`;
|
||||
const totalPhotoCount = photos.length + newPhotos.length;
|
||||
|
||||
const handleNewFiles = async (files: File[] | null) => {
|
||||
const selectedFiles = files ?? [];
|
||||
@@ -256,9 +278,16 @@ const DredgingDetailPopupPage = ({
|
||||
};
|
||||
|
||||
const removeExistingPhoto = (photoSn: number) => {
|
||||
setPhotos((current) =>
|
||||
current.filter((photo) => photo.photoSn !== photoSn),
|
||||
const remaining = photos.filter((photo) => photo.photoSn !== photoSn);
|
||||
setPhotos(remaining);
|
||||
if (
|
||||
registrationMode === "PHOTO" &&
|
||||
remaining.length + newPhotos.length === 0
|
||||
) {
|
||||
setStatusMessage(
|
||||
"사진 기반 등록은 사진이 한 장 이상 필요합니다. 사진을 추가하거나 사진 없이 직접 등록을 선택해주세요.",
|
||||
);
|
||||
}
|
||||
if (selectedPhotoKey === `existing-${photoSn}`) setSelectedPhotoKey(null);
|
||||
};
|
||||
|
||||
@@ -270,12 +299,50 @@ const DredgingDetailPopupPage = ({
|
||||
(url) => url !== removed.previewUrl,
|
||||
);
|
||||
}
|
||||
setNewPhotos((current) =>
|
||||
current.filter((photo) => photo.key !== photoKey),
|
||||
const remaining = newPhotos.filter((photo) => photo.key !== photoKey);
|
||||
setNewPhotos(remaining);
|
||||
if (
|
||||
registrationMode === "PHOTO" &&
|
||||
photos.length + remaining.length === 0
|
||||
) {
|
||||
setStatusMessage(
|
||||
"사진 기반 등록은 사진이 한 장 이상 필요합니다. 사진을 추가하거나 사진 없이 직접 등록을 선택해주세요.",
|
||||
);
|
||||
}
|
||||
if (selectedPhotoKey === `new-${photoKey}`) setSelectedPhotoKey(null);
|
||||
};
|
||||
|
||||
/** 수정 중 등록방식을 바꾸고 직접 등록 선택 시 사진을 모두 삭제 대상으로 전환한다. */
|
||||
const changeRegistrationMode = async (nextMode: DredgingRegistrationMode) => {
|
||||
if (registrationMode === nextMode) return;
|
||||
if (
|
||||
nextMode === "MANUAL" &&
|
||||
totalPhotoCount > 0 &&
|
||||
!(await confirm(
|
||||
"사진 없이 직접 등록으로 변경하면 현재 사진이 모두 삭제됩니다. 변경하시겠습니까?",
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextMode === "MANUAL") {
|
||||
newPhotoUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
|
||||
newPhotoUrlsRef.current = [];
|
||||
setPhotos([]);
|
||||
setNewPhotos([]);
|
||||
setSelectedPhotoKey(null);
|
||||
setLocationSource("MANUAL");
|
||||
setStatusMessage(
|
||||
"사진 없이 직접 등록으로 변경했습니다. 현재 위치를 확인하고 필요하면 수정해주세요.",
|
||||
);
|
||||
} else {
|
||||
setStatusMessage(
|
||||
"사진 기반 등록은 사진이 한 장 이상 필요합니다. 준설사진을 추가해주세요.",
|
||||
);
|
||||
}
|
||||
setRegistrationMode(nextMode);
|
||||
};
|
||||
|
||||
const startLocationEditing = () => {
|
||||
setLocationDraft(location);
|
||||
setIsEditingLocation(true);
|
||||
@@ -332,6 +399,13 @@ const DredgingDetailPopupPage = ({
|
||||
alert("미래 날짜는 준설일자로 수정할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
if (registrationMode === "PHOTO" && totalPhotoCount === 0) {
|
||||
const message =
|
||||
"사진 기반 등록은 사진이 한 장 이상 필요합니다. 사진을 추가하거나 사진 없이 직접 등록을 선택해주세요.";
|
||||
setStatusMessage(message);
|
||||
alert(message);
|
||||
return;
|
||||
}
|
||||
if (isEditingLocation) {
|
||||
alert("선택한 지도 위치를 먼저 적용해주세요.");
|
||||
return;
|
||||
@@ -353,8 +427,10 @@ const DredgingDetailPopupPage = ({
|
||||
{
|
||||
dredgingDate,
|
||||
originalPipeFid: record.pipeFid,
|
||||
registrationMode,
|
||||
location,
|
||||
locationSource,
|
||||
locationSource:
|
||||
registrationMode === "MANUAL" ? "MANUAL" : locationSource,
|
||||
pipe: matchedPipe,
|
||||
retainedPhotoSns: photos.map((photo) => photo.photoSn),
|
||||
newPhotos,
|
||||
@@ -364,8 +440,7 @@ const DredgingDetailPopupPage = ({
|
||||
uploadedAttachmentsRef.current.clear();
|
||||
setStatusMessage("준설정보 수정을 완료했습니다.");
|
||||
onUpdated?.();
|
||||
alert("준설정보를 수정했습니다.");
|
||||
window.close();
|
||||
alert("준설정보를 수정했습니다.", () => window.close());
|
||||
} catch (saveError) {
|
||||
const message =
|
||||
saveError instanceof Error
|
||||
@@ -378,117 +453,151 @@ const DredgingDetailPopupPage = ({
|
||||
}
|
||||
};
|
||||
|
||||
const totalPhotoCount = photos.length + newPhotos.length;
|
||||
|
||||
return (
|
||||
<main className="sn-popup-page sn-dredging-register bg-[#eef3f7]">
|
||||
<header className="sn-popup-header shrink-0">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<h1 className="sn-popup-title shrink-0">
|
||||
준설결과 {editable ? "수정" : "상세"}
|
||||
</h1>
|
||||
<span className="truncate text-[1.15rem] text-white/90">
|
||||
관로번호 {matchedPipe.fcltMngNo || "확인불가"}
|
||||
</span>
|
||||
</div>
|
||||
<StandardPopupLayout
|
||||
title={`준설결과 ${editable ? "수정" : "상세"}`}
|
||||
showDefaultCancelAction={false}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
{editable ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="sn-popup-header-button min-w-[6.4rem] !border-white !bg-white/10 !text-white hover:!bg-white/20"
|
||||
disabled={isSaving}
|
||||
onClick={() => window.close()}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="sn-popup-header-button min-w-[6.4rem] !border-white !bg-white !text-blue-700 hover:!bg-blue-50"
|
||||
disabled={isSaving || isMatching}
|
||||
onClick={() => void handleSave()}
|
||||
data-testid="dredging-edit-save-button"
|
||||
>
|
||||
{isSaving ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<section className="grid min-h-0 flex-1 grid-cols-[minmax(440px,31%)_minmax(0,1fr)] gap-2 p-2">
|
||||
<aside className="flex min-h-0 flex-col gap-2 overflow-y-auto pr-1">
|
||||
<section className="shrink-0 rounded-sm border border-slate-300 bg-white">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-3 py-2">
|
||||
<h2 className="text-base font-semibold text-slate-900">
|
||||
관로정보
|
||||
</h2>
|
||||
{editable ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label
|
||||
htmlFor="dredging-edit-date"
|
||||
className="text-sm font-medium text-slate-700"
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
disabled={isSaving}
|
||||
onClick={() => window.close()}
|
||||
>
|
||||
준설일자 <span className="text-rose-600">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="dredging-edit-date"
|
||||
type="date"
|
||||
value={dredgingDate}
|
||||
max={formatLocalDate(new Date())}
|
||||
onChange={(event) => setDredgingDate(event.target.value)}
|
||||
className="sn-dredging-date-input h-[3.2rem] w-[155px] text-base"
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
bodyClassName="sn-dredging-register block overflow-y-auto"
|
||||
>
|
||||
<div className="grid min-h-0 grid-cols-[minmax(44rem,48rem)_minmax(0,1fr)] gap-3">
|
||||
<aside className="flex min-h-0 flex-col gap-3 pr-1">
|
||||
<section className="card card-border !m-0 shrink-0 overflow-hidden !p-0">
|
||||
<div className="card-header !m-0">
|
||||
<h2 className="text-2xl font-medium text-label">관로 정보</h2>
|
||||
<span className="truncate text-base text-muted-foreground">
|
||||
관로번호 {matchedPipe.fcltMngNo || "확인불가"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<Table variant="secondary">
|
||||
<colgroup>
|
||||
<col width="18%" />
|
||||
<col width="32%" />
|
||||
<col width="18%" />
|
||||
<col width="32%" />
|
||||
</colgroup>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableHead data-required={editable || undefined}>
|
||||
준설일자
|
||||
</TableHead>
|
||||
<TableCell className="border px-4">
|
||||
{editable ? (
|
||||
<div className="w-full max-w-[15.5rem]">
|
||||
<InputDatePicker
|
||||
value={parseLocalDate(dredgingDate)}
|
||||
onChange={(date) =>
|
||||
date && setDredgingDate(formatLocalDate(date))
|
||||
}
|
||||
className="w-full"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<strong className="text-sm text-slate-700">
|
||||
{dredgingDate}
|
||||
</strong>
|
||||
dredgingDate
|
||||
)}
|
||||
</div>
|
||||
<dl className="grid grid-cols-[82px_minmax(0,1fr)_82px_minmax(0,1fr)] gap-px bg-slate-200">
|
||||
{pipeInfo.map(([label, value]) => (
|
||||
<Fragment key={label}>
|
||||
<dt className="flex min-h-8 items-center bg-blue-100 px-2 text-xs font-semibold text-slate-700">
|
||||
{label}
|
||||
</dt>
|
||||
<dd
|
||||
className="flex min-h-8 min-w-0 items-center truncate bg-white px-2 text-sm font-medium text-slate-900"
|
||||
title={value}
|
||||
>
|
||||
{value}
|
||||
</dd>
|
||||
</Fragment>
|
||||
))}
|
||||
<dt className="flex min-h-8 items-center bg-blue-100 px-2 text-xs font-semibold text-slate-700">
|
||||
자동매핑
|
||||
</dt>
|
||||
<dd
|
||||
className="col-span-3 flex min-h-8 min-w-0 items-center truncate bg-white px-2 text-sm font-medium text-slate-900"
|
||||
</TableCell>
|
||||
<TableHead>자동매핑</TableHead>
|
||||
<TableCell
|
||||
className="truncate border px-4"
|
||||
title={automaticMappingValue}
|
||||
>
|
||||
{automaticMappingValue}
|
||||
</dd>
|
||||
</dl>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{pipeInfoRows.map((row, index) => {
|
||||
const first = row[0];
|
||||
const second = row[1];
|
||||
return (
|
||||
<TableRow key={`${first?.[0]}-${index}`}>
|
||||
<TableHead>{first?.[0]}</TableHead>
|
||||
<TableCell
|
||||
className="truncate border px-4"
|
||||
title={first?.[1]}
|
||||
>
|
||||
{first?.[1]}
|
||||
</TableCell>
|
||||
{second ? (
|
||||
<>
|
||||
<TableHead>{second[0]}</TableHead>
|
||||
<TableCell
|
||||
className="truncate border px-4"
|
||||
title={second[1]}
|
||||
>
|
||||
{second[1]}
|
||||
</TableCell>
|
||||
</>
|
||||
) : (
|
||||
<TableCell colSpan={2} className="border" />
|
||||
)}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-[360px] flex-1 flex-col overflow-hidden rounded-sm border border-slate-300 bg-white">
|
||||
<div className="flex min-h-[30px] shrink-0 items-center justify-between gap-2 overflow-visible border-b border-slate-200 bg-slate-50 px-3 py-1">
|
||||
<div className="flex min-h-[20px] min-w-0 items-center">
|
||||
<p className="truncate text-xs leading-[18px] text-slate-500">
|
||||
{editable
|
||||
? "기존 사진을 삭제하거나 새 사진을 추가할 수 있습니다."
|
||||
: "사진을 선택하면 아래에서 원본을 확인합니다."}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-medium leading-[18px] text-slate-600">
|
||||
<section className="card card-border !m-0 flex min-h-[36rem] flex-1 flex-col overflow-hidden !p-0">
|
||||
<div className="card-header !m-0 shrink-0 gap-2">
|
||||
<h2 className="text-2xl font-medium text-label">준설 사진</h2>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{editable ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color={registrationMode === "PHOTO" ? "primary" : "white"}
|
||||
aria-pressed={registrationMode === "PHOTO"}
|
||||
onClick={() => void changeRegistrationMode("PHOTO")}
|
||||
data-testid="dredging-edit-photo-mode"
|
||||
>
|
||||
사진 기반 등록
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color={
|
||||
registrationMode === "MANUAL" ? "primary" : "white"
|
||||
}
|
||||
aria-pressed={registrationMode === "MANUAL"}
|
||||
onClick={() => void changeRegistrationMode("MANUAL")}
|
||||
data-testid="dredging-edit-manual-mode"
|
||||
>
|
||||
사진 없이 직접 등록
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<span className="text-base text-muted-foreground">
|
||||
{totalPhotoCount}/5장
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editable ? (
|
||||
<div className="sn-dredging-photo-upload shrink-0 border-b border-slate-200 p-2">
|
||||
{editable && registrationMode === "PHOTO" ? (
|
||||
<div className="sn-dredging-photo-upload shrink-0 border-b border-border p-3">
|
||||
<FileUpload
|
||||
files={newPhotos.map((photo) => photo.file)}
|
||||
setFiles={(files) => void handleNewFiles(files)}
|
||||
@@ -496,11 +605,15 @@ const DredgingDetailPopupPage = ({
|
||||
maxFiles={Math.max(1, 5 - photos.length)}
|
||||
/>
|
||||
</div>
|
||||
) : editable ? (
|
||||
<div className="bg-secondary/30 shrink-0 border-b border-border px-3 py-3 text-center text-sm text-muted-foreground">
|
||||
사진 없이 지도에서 준설위치를 직접 지정합니다.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="max-h-[190px] shrink-0 overflow-y-auto border-b border-slate-200 p-2">
|
||||
<div className="shrink-0 border-b border-border p-3">
|
||||
{isLoading ? (
|
||||
<p className="flex h-[88px] items-center justify-center text-sm text-slate-500">
|
||||
<p className="flex h-[8.8rem] items-center justify-center text-sm text-muted-foreground">
|
||||
등록된 사진을 불러오는 중입니다.
|
||||
</p>
|
||||
) : error ? (
|
||||
@@ -521,40 +634,45 @@ const DredgingDetailPopupPage = ({
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`flex items-stretch rounded-sm border ${selected ? "border-blue-500 bg-blue-50" : "border-slate-200 bg-white"}`}
|
||||
className={`flex items-stretch overflow-hidden rounded-sm border border-border ${selected ? "bg-primary-transparent" : "bg-background"}`}
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2 text-left"
|
||||
variant="ghost"
|
||||
color="white"
|
||||
className="h-auto min-w-0 flex-1 justify-start rounded-none border-0 px-3 py-2 text-left"
|
||||
onClick={() => setSelectedPhotoKey(key)}
|
||||
aria-pressed={selected}
|
||||
data-testid={`dredging-detail-photo-${photo.photoSn}`}
|
||||
>
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-600">
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-secondary text-sm font-semibold text-label">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<strong
|
||||
className="min-w-0 flex-1 truncate text-[11px]"
|
||||
className="min-w-0 flex-1 truncate text-sm"
|
||||
title={photo.originalFileName}
|
||||
>
|
||||
{photo.originalFileName ||
|
||||
`준설사진 ${index + 1}`}
|
||||
</strong>
|
||||
<span className="shrink-0 whitespace-nowrap text-[10px] text-slate-500">
|
||||
<span className="shrink-0 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{photo.capturedAt ?? "촬영일시 없음"}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
{editable ? (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
color="red"
|
||||
size="icon"
|
||||
aria-label={`${photo.originalFileName} 삭제`}
|
||||
className="w-9 border-l border-slate-200 text-base font-bold text-slate-400 hover:bg-rose-50 hover:text-rose-700"
|
||||
className="h-auto w-10 rounded-none border-y-0 border-l border-r-0 border-border p-0"
|
||||
onClick={() => removeExistingPhoto(photo.photoSn)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
@@ -565,50 +683,58 @@ const DredgingDetailPopupPage = ({
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`flex items-stretch rounded-sm border ${selected ? "border-blue-500 bg-blue-50" : "border-emerald-200 bg-emerald-50/40"}`}
|
||||
className={`flex items-stretch overflow-hidden rounded-sm border border-border ${selected ? "bg-primary-transparent" : "bg-background"}`}
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2 text-left"
|
||||
variant="ghost"
|
||||
color="white"
|
||||
className="h-auto min-w-0 flex-1 justify-start rounded-none border-0 px-3 py-2 text-left"
|
||||
onClick={() => setSelectedPhotoKey(key)}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-xs font-semibold text-emerald-700">
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-secondary text-sm font-semibold text-label">
|
||||
{photos.length + index + 1}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<strong
|
||||
className="min-w-0 flex-1 truncate text-[11px]"
|
||||
className="min-w-0 flex-1 truncate text-sm"
|
||||
title={photo.file.name}
|
||||
>
|
||||
신규 · {photo.file.name}
|
||||
</strong>
|
||||
<span className="shrink-0 whitespace-nowrap text-[10px] text-slate-500">
|
||||
<span className="shrink-0 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{photo.metadata.capturedAt ?? "촬영일시 없음"}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
color="red"
|
||||
size="icon"
|
||||
aria-label={`${photo.file.name} 삭제`}
|
||||
className="w-9 border-l border-emerald-200 text-base font-bold text-slate-400 hover:bg-rose-50 hover:text-rose-700"
|
||||
className="h-auto w-10 rounded-none border-y-0 border-l border-r-0 border-border p-0"
|
||||
onClick={() => removeNewPhoto(photo.key)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="flex h-[88px] items-center justify-center text-sm text-slate-500">
|
||||
등록된 사진이 없습니다.
|
||||
<p className="flex h-[8.8rem] items-center justify-center text-sm text-muted-foreground">
|
||||
{editable && registrationMode === "PHOTO"
|
||||
? "사진을 추가하거나 사진 없이 직접 등록을 선택해주세요."
|
||||
: "등록된 사진이 없습니다."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 사진 미리보기는 밝은 사진의 경계를 보장하는 미디어 캔버스이므로 고정 암색을 유지한다. */}
|
||||
<div
|
||||
className="flex min-h-[220px] flex-1 items-center justify-center overflow-hidden bg-slate-900 p-2"
|
||||
className="flex min-h-[22rem] flex-1 items-center justify-center overflow-hidden bg-slate-900 p-2"
|
||||
data-testid="dredging-detail-photo-preview"
|
||||
>
|
||||
{selectedExistingPhoto?.previewUrl ? (
|
||||
@@ -629,52 +755,52 @@ const DredgingDetailPopupPage = ({
|
||||
</p>
|
||||
) : (
|
||||
<p className="px-4 text-center text-sm text-slate-300">
|
||||
사진목록에서 확인할 사진을 선택해주세요.
|
||||
{editable && registrationMode === "MANUAL"
|
||||
? "사진 없이 직접 등록 중입니다. 오른쪽 지도에서 위치를 확인해주세요."
|
||||
: "사진목록에서 확인할 사진을 선택해주세요."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{editable ? (
|
||||
<div className="shrink-0 rounded-sm border border-slate-300 bg-white px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<section className="card card-border !m-0 shrink-0 overflow-hidden !p-0">
|
||||
<div className="card-header !m-0 flex-wrap gap-2">
|
||||
<div className="min-w-0">
|
||||
<strong className="block truncate text-sm text-slate-800">
|
||||
자동 연결 관로 · {matchedPipe.fcltMngNo}
|
||||
</strong>
|
||||
<span
|
||||
className={`block truncate text-xs ${matchError ? "text-rose-700" : "text-slate-500"}`}
|
||||
<h2 className="text-2xl font-medium text-label">
|
||||
위치 및 관로
|
||||
</h2>
|
||||
<p
|
||||
className={`mt-1 truncate text-sm ${matchError ? "text-rose-700" : "text-muted-foreground"}`}
|
||||
>
|
||||
{matchError ??
|
||||
`${matchedPipe.distanceMeters.toFixed(2)}m · 지도에서 위치를 수정할 수 있습니다.`}
|
||||
</span>
|
||||
자동 연결 관로 · {matchedPipe.fcltMngNo} ·{" "}
|
||||
{matchError ?? `${matchedPipe.distanceMeters.toFixed(2)}m`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<div className="flex shrink-0 gap-2">
|
||||
{isEditingLocation ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className={SECONDARY_BUTTON_CLASS}
|
||||
onClick={cancelLocationEditing}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className={PRIMARY_BUTTON_CLASS}
|
||||
disabled={!locationDraft || isMatching}
|
||||
onClick={() => void applyLocation()}
|
||||
>
|
||||
{isMatching ? "관로 찾는 중..." : "선택 위치 적용"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="white"
|
||||
onClick={cancelLocationEditing}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className={PRIMARY_BUTTON_CLASS}
|
||||
onClick={startLocationEditing}
|
||||
>
|
||||
위치 수정
|
||||
@@ -683,20 +809,29 @@ const DredgingDetailPopupPage = ({
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mt-1 min-h-5 rounded-sm bg-slate-50 px-2 py-1 text-xs text-slate-600"
|
||||
className="bg-secondary/30 px-4 py-2 text-sm text-muted-foreground"
|
||||
data-testid="dredging-edit-status"
|
||||
>
|
||||
{statusMessage ||
|
||||
"준설일자·사진·위치와 자동 연결 관로를 확인해주세요."}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</aside>
|
||||
|
||||
<section
|
||||
className="min-h-0 overflow-hidden rounded-sm bg-white"
|
||||
className="card card-border !m-0 flex min-h-0 min-w-0 flex-col overflow-hidden !p-0"
|
||||
data-testid="dredging-detail-map"
|
||||
>
|
||||
<div className="card-header !m-0 shrink-0">
|
||||
<h2 className="text-2xl font-medium text-label">준설 위치 지도</h2>
|
||||
{editable ? (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
위치 수정 버튼을 누른 뒤 지도에서 위치를 선택하세요.
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-h-[56rem] flex-1 p-2">
|
||||
<DredgingLocationMap
|
||||
value={isEditingLocation ? locationDraft : location}
|
||||
matchedPipe={
|
||||
@@ -711,11 +846,12 @@ const DredgingDetailPopupPage = ({
|
||||
? "현재 준설위치와 자동 연결된 관로를 표시하고 있습니다."
|
||||
: "준설위치와 자동 연결된 관로를 표시하고 있습니다."
|
||||
}
|
||||
height="h-full min-h-[560px]"
|
||||
height="h-full min-h-[56rem]"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -281,7 +281,7 @@ const LocationInteraction = ({
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="rounded-sm border border-slate-300 bg-white/95 px-2 py-1.5 text-xs text-slate-700 shadow">
|
||||
<div className="rounded-sm border border-border bg-background px-2 py-1.5 text-xs text-label shadow">
|
||||
<p className="font-medium">
|
||||
{interactive
|
||||
? "지도를 클릭해 준설위치를 선택하세요."
|
||||
@@ -290,12 +290,15 @@ const LocationInteraction = ({
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<span>관로 색상·굵기: 관망도 레이어 설정</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{/* 선택 관로를 지도에서 식별하는 범례 의미색이므로 고정 주황색을 유지한다. */}
|
||||
<span className="inline-block h-1.5 w-6 rounded bg-[#f97316]" />
|
||||
자동 연결 관로
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
pipeLoadState === "error" ? "text-rose-600" : "text-slate-500"
|
||||
pipeLoadState === "error"
|
||||
? "text-rose-600"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
data-testid="dredging-pipe-map-status"
|
||||
>
|
||||
@@ -350,7 +353,7 @@ const DredgingLocationMap = ({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-sm border border-slate-300">
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-sm border border-border">
|
||||
<Gis
|
||||
treeData={[]}
|
||||
gisMaps={gisMaps}
|
||||
@@ -379,7 +382,7 @@ const DredgingLocationMap = ({
|
||||
treeTogglePosition="top"
|
||||
/>
|
||||
<div
|
||||
className="shrink-0 border-t bg-slate-50 px-3 py-1.5 text-xs text-slate-700"
|
||||
className="shrink-0 border-t bg-secondary px-3 py-1.5 text-xs text-label"
|
||||
data-testid="dredging-map-status"
|
||||
>
|
||||
{statusText ??
|
||||
|
||||
+237
-193
@@ -1,11 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import { FileUpload } from "@common/business_components/ui/file-upload";
|
||||
import { UiModal } from "@common/business_components/ui/dialog_ui";
|
||||
import { Button } from "@common/components/ui/button";
|
||||
import { Input } from "@common/components/ui/input";
|
||||
import { Label } from "@common/components/ui/label";
|
||||
import { StandardPopupLayout } from "@common/business_components";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
InputDatePicker,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
@@ -36,11 +50,6 @@ type DredgingRegisterModalProps = {
|
||||
presentation?: "modal" | "popup";
|
||||
};
|
||||
|
||||
const POPUP_PRIMARY_BUTTON_CLASS =
|
||||
"min-w-[6.4rem] border !border-blue-600 !bg-blue-600 px-4 font-medium !text-white hover:!bg-blue-700";
|
||||
const POPUP_SECONDARY_BUTTON_CLASS =
|
||||
"min-w-[6.4rem] border !border-blue-300 !bg-white px-4 font-medium !text-blue-700 hover:!bg-blue-50";
|
||||
|
||||
const createGroup = (): DredgingDraftGroup => ({
|
||||
id: crypto.randomUUID(),
|
||||
registrationMode: "PHOTO",
|
||||
@@ -78,12 +87,16 @@ const formatLocalDate = (date: Date) => {
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const parseLocalDate = (value: string) => new Date(`${value}T00:00:00`);
|
||||
|
||||
const DredgingRegisterModal = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
presentation = "modal",
|
||||
}: DredgingRegisterModalProps) => {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { confirm } = useConfirm();
|
||||
const [groups, setGroups] = useState<DredgingDraftGroup[]>([]);
|
||||
const groupsRef = useRef<DredgingDraftGroup[]>([]);
|
||||
const [activeGroupId, setActiveGroupId] = useState<string | null>(null);
|
||||
@@ -319,7 +332,7 @@ const DredgingRegisterModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
const changeRegistrationMode = (
|
||||
const changeRegistrationMode = async (
|
||||
groupId: string,
|
||||
registrationMode: DredgingDraftGroup["registrationMode"],
|
||||
) => {
|
||||
@@ -333,9 +346,9 @@ const DredgingRegisterModal = ({
|
||||
currentGroup.photos.length > 0 || currentGroup.manualLocation !== null;
|
||||
if (
|
||||
hasEnteredData &&
|
||||
!window.confirm(
|
||||
!(await confirm(
|
||||
"등록 방식을 변경하면 현재 사진과 입력한 위치가 초기화됩니다. 변경하시겠습니까?",
|
||||
)
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -475,8 +488,7 @@ const DredgingRegisterModal = ({
|
||||
await saveDredgingRegistrations(saveGroups, attachments);
|
||||
uploadedAttachmentsRef.current.clear();
|
||||
onSaved(saveGroups);
|
||||
alert("준설 결과를 등록했습니다.");
|
||||
onOpenChange(false);
|
||||
alert("준설 결과를 등록했습니다.", () => onOpenChange(false));
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
@@ -543,12 +555,12 @@ const DredgingRegisterModal = ({
|
||||
<div
|
||||
className={
|
||||
presentation === "popup"
|
||||
? "sn-dredging-register flex h-full min-h-0 flex-col overflow-hidden pr-2"
|
||||
: "sn-dredging-register max-h-[78vh] overflow-y-auto pr-2"
|
||||
? "sn-dredging-register flex h-full min-h-0 flex-col overflow-hidden"
|
||||
: "sn-dredging-register"
|
||||
}
|
||||
>
|
||||
<div className="mb-2 flex shrink-0 flex-wrap items-center gap-x-4 gap-y-1 rounded-sm border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs text-slate-700">
|
||||
<strong className="text-blue-900">등록 안내</strong>
|
||||
<div className="bg-secondary/40 mb-3 flex shrink-0 flex-wrap items-center gap-x-4 gap-y-1 rounded-sm border border-border px-4 py-2 text-sm text-label">
|
||||
<strong className="text-foreground">등록 안내</strong>
|
||||
<span>관로별 사진은 최대 5장까지 등록할 수 있습니다.</span>
|
||||
<span>
|
||||
모든 등록 건은 하나의 준설작업이며 동일한 준설일자를 사용합니다.
|
||||
@@ -557,37 +569,55 @@ const DredgingRegisterModal = ({
|
||||
|
||||
{activeGroup ? (
|
||||
<div
|
||||
className="grid min-h-0 flex-1 grid-cols-[minmax(470px,520px)_minmax(0,1fr)] gap-2"
|
||||
className="grid min-h-0 flex-1 grid-cols-[minmax(48rem,52rem)_minmax(0,1fr)] gap-3"
|
||||
data-testid={`dredging-group-${
|
||||
groups.findIndex((group) => group.id === activeGroup.id) + 1
|
||||
}`}
|
||||
>
|
||||
<aside className="flex min-h-0 min-w-0 flex-col gap-2 overflow-y-auto pr-1">
|
||||
<section className="shrink-0 rounded-sm border border-slate-300 bg-white p-2 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Label
|
||||
htmlFor="representative-dredging-date"
|
||||
className="shrink-0 !text-[12.5px] !font-medium !leading-[17.5px] !text-gray-600"
|
||||
>
|
||||
준설일자 <span className="text-red-600">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="representative-dredging-date"
|
||||
data-testid="representative-dredging-date"
|
||||
type="date"
|
||||
value={representativeDredgingDate}
|
||||
max={formatLocalDate(new Date())}
|
||||
onChange={(event) =>
|
||||
setRepresentativeDredgingDate(event.target.value)
|
||||
<aside className="flex min-h-0 min-w-0 flex-col gap-3 pr-1">
|
||||
<section className="card card-border !m-0 shrink-0 overflow-hidden !p-0">
|
||||
<div className="card-header !m-0">
|
||||
<h3 className="text-2xl font-medium text-label">등록 정보</h3>
|
||||
<Button type="button" size="sm" onClick={addGroup}>
|
||||
관로 추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<Table variant="secondary">
|
||||
<colgroup>
|
||||
<col width="22%" />
|
||||
<col width="78%" />
|
||||
</colgroup>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableHead data-required>준설일자</TableHead>
|
||||
<TableCell className="border px-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div data-testid="representative-dredging-date">
|
||||
<InputDatePicker
|
||||
value={
|
||||
representativeDredgingDate
|
||||
? parseLocalDate(representativeDredgingDate)
|
||||
: null
|
||||
}
|
||||
className="sn-dredging-date-input h-[3.2rem] w-[155px] text-base"
|
||||
onChange={(date) =>
|
||||
setRepresentativeDredgingDate(
|
||||
date ? formatLocalDate(date) : "",
|
||||
)
|
||||
}
|
||||
className="w-[15.5rem]"
|
||||
/>
|
||||
<span className="text-xs text-slate-500">
|
||||
첫 사진 촬영일로 자동 입력되며 직접 수정할 수 있습니다.
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
첫 사진 촬영일로 자동 입력되며 직접 수정할 수
|
||||
있습니다.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-2 border-t border-slate-200 pt-2">
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>등록 관로</TableHead>
|
||||
<TableCell className="border px-4">
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
role="tablist"
|
||||
@@ -596,22 +626,26 @@ const DredgingRegisterModal = ({
|
||||
{groups.map((group, groupIndex) => {
|
||||
const active = group.id === activeGroup.id;
|
||||
return (
|
||||
<div key={group.id} className="inline-flex items-stretch">
|
||||
<div
|
||||
key={group.id}
|
||||
className="inline-flex items-stretch"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color={active ? "primary" : "white"}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
className={`${
|
||||
active
|
||||
? POPUP_PRIMARY_BUTTON_CLASS
|
||||
: POPUP_SECONDARY_BUTTON_CLASS
|
||||
} ${groupIndex > 0 ? "!min-w-0 rounded-r-none border-r-0" : ""}`}
|
||||
className={
|
||||
groupIndex > 0
|
||||
? "rounded-r-none border-r-0"
|
||||
: undefined
|
||||
}
|
||||
onClick={() => selectGroup(group.id)}
|
||||
>
|
||||
등록 건 {groupIndex + 1}
|
||||
<span
|
||||
className={`ml-2 inline-block h-2 w-2 rounded-full ${
|
||||
className={`ml-1 inline-block size-2 rounded-full ${
|
||||
getResolvedLocation(group)
|
||||
? "bg-emerald-400"
|
||||
: "bg-amber-400"
|
||||
@@ -624,60 +658,50 @@ const DredgingRegisterModal = ({
|
||||
/>
|
||||
</Button>
|
||||
{groupIndex > 0 ? (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
color="red"
|
||||
variant="destructive"
|
||||
aria-label={`등록 건 ${groupIndex + 1} 삭제`}
|
||||
title={`등록 건 ${groupIndex + 1} 삭제`}
|
||||
className={`min-w-8 rounded-r border px-2 text-sm font-bold ${
|
||||
active
|
||||
? "border-blue-600 bg-blue-600 text-white hover:bg-blue-700"
|
||||
: "border-blue-300 bg-white text-blue-700 hover:bg-blue-50"
|
||||
}`}
|
||||
className="h-10 min-w-8 rounded-l-none px-2"
|
||||
onClick={() => removeGroup(group.id)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className={POPUP_PRIMARY_BUTTON_CLASS}
|
||||
onClick={addGroup}
|
||||
>
|
||||
<span aria-hidden="true" className="mr-1">
|
||||
+
|
||||
</span>
|
||||
관로 추가
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="shrink-0 rounded-sm border border-slate-300 bg-white p-2 shadow-sm">
|
||||
<div className="mb-1 flex flex-wrap items-center justify-between gap-2 border-b pb-1">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-lg font-semibold text-slate-800">
|
||||
사진 및 위치 정보
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500">
|
||||
촬영일과 GPS 상태를 확인합니다.
|
||||
<section className="card card-border !m-0 shrink-0 overflow-hidden !p-0">
|
||||
<div className="card-header !m-0 flex-wrap gap-2">
|
||||
<div>
|
||||
<h3 className="text-2xl font-medium text-label">준설 사진</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
촬영일과 GPS 정보를 확인합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-1">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
aria-pressed={activeGroup.registrationMode === "PHOTO"}
|
||||
className={
|
||||
color={
|
||||
activeGroup.registrationMode === "PHOTO"
|
||||
? POPUP_PRIMARY_BUTTON_CLASS
|
||||
: POPUP_SECONDARY_BUTTON_CLASS
|
||||
? "primary"
|
||||
: "white"
|
||||
}
|
||||
aria-pressed={activeGroup.registrationMode === "PHOTO"}
|
||||
onClick={() =>
|
||||
changeRegistrationMode(activeGroup.id, "PHOTO")
|
||||
void changeRegistrationMode(activeGroup.id, "PHOTO")
|
||||
}
|
||||
>
|
||||
사진 기반 등록
|
||||
@@ -685,21 +709,21 @@ const DredgingRegisterModal = ({
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
aria-pressed={activeGroup.registrationMode === "MANUAL"}
|
||||
className={
|
||||
color={
|
||||
activeGroup.registrationMode === "MANUAL"
|
||||
? POPUP_PRIMARY_BUTTON_CLASS
|
||||
: POPUP_SECONDARY_BUTTON_CLASS
|
||||
? "primary"
|
||||
: "white"
|
||||
}
|
||||
aria-pressed={activeGroup.registrationMode === "MANUAL"}
|
||||
onClick={() =>
|
||||
changeRegistrationMode(activeGroup.id, "MANUAL")
|
||||
void changeRegistrationMode(activeGroup.id, "MANUAL")
|
||||
}
|
||||
>
|
||||
사진 없이 직접 등록
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
{activeGroup.registrationMode === "PHOTO" ? (
|
||||
<>
|
||||
<div className="sn-dredging-photo-upload">
|
||||
@@ -714,48 +738,42 @@ const DredgingRegisterModal = ({
|
||||
</div>
|
||||
|
||||
{activeGroup.photos.length > 0 ? (
|
||||
<div className="mt-1.5 max-h-[210px] overflow-auto rounded-sm border">
|
||||
<table className="w-full table-fixed text-left text-xs">
|
||||
<thead className="sticky top-0 bg-blue-100 text-slate-800">
|
||||
<tr>
|
||||
<th className="w-[38%] px-2 py-1.5">사진</th>
|
||||
<th className="w-[27%] px-2 py-1.5">촬영일시</th>
|
||||
<th className="w-[17%] px-2 py-1.5">위치</th>
|
||||
<th className="w-[10%] px-2 py-1.5">상태</th>
|
||||
<th className="w-[8%] px-1 py-1.5 text-center">
|
||||
삭제
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<div className="mt-2 rounded-sm border border-border">
|
||||
<Table className="table-fixed" variant="default">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[34%]">사진</TableHead>
|
||||
<TableHead className="w-[25%]">
|
||||
촬영일시
|
||||
</TableHead>
|
||||
<TableHead className="w-[16%]">위치</TableHead>
|
||||
<TableHead className="w-[17%]">상태</TableHead>
|
||||
<TableHead className="w-[8%]">삭제</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{activeGroup.photos.map((photo) => (
|
||||
<tr key={photo.key} className="border-t">
|
||||
<td
|
||||
className="truncate px-2 py-1.5"
|
||||
<TableRow key={photo.key}>
|
||||
<TableCell
|
||||
className="truncate px-3"
|
||||
title={photo.file.name}
|
||||
>
|
||||
{photo.file.name}
|
||||
</td>
|
||||
<td
|
||||
className="truncate px-2 py-1.5"
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="truncate px-3"
|
||||
title={photo.metadata.capturedAt ?? "-"}
|
||||
>
|
||||
{photo.metadata.capturedAt ?? "-"}
|
||||
</td>
|
||||
<td className="truncate px-2 py-1.5">
|
||||
{photo.metadata.location ? (
|
||||
<span className="font-medium text-emerald-700">
|
||||
GPS
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium text-amber-700">
|
||||
지정 필요
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
</TableCell>
|
||||
<TableCell className="px-3">
|
||||
{photo.metadata.location
|
||||
? "GPS"
|
||||
: "지정 필요"}
|
||||
</TableCell>
|
||||
<TableCell className="px-3">
|
||||
<span
|
||||
className={`whitespace-nowrap rounded-sm px-2 py-1 text-xs font-medium ${
|
||||
className={`whitespace-nowrap rounded-sm px-2 py-1 text-sm font-medium ${
|
||||
photo.metadata.status === "COMPLETE"
|
||||
? "bg-emerald-100 text-emerald-800"
|
||||
: "bg-amber-100 text-amber-800"
|
||||
@@ -763,42 +781,46 @@ const DredgingRegisterModal = ({
|
||||
>
|
||||
{statusLabel(photo.metadata.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1 text-center">
|
||||
<button
|
||||
</TableCell>
|
||||
<TableCell className="px-2 text-center">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
color="red"
|
||||
aria-label={`${photo.file.name} 삭제`}
|
||||
title="사진 삭제"
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded border border-slate-300 bg-white font-bold text-slate-500 hover:border-rose-300 hover:bg-rose-50 hover:text-rose-700"
|
||||
className="size-8 p-0"
|
||||
onClick={() =>
|
||||
removePhoto(activeGroup.id, photo.key)
|
||||
}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1.5 rounded-sm border border-dashed border-slate-300 bg-slate-50 px-3 py-1.5 text-center text-xs text-slate-500">
|
||||
<div className="bg-secondary/30 mt-2 rounded-sm border border-dashed border-border px-3 py-2 text-center text-sm text-muted-foreground">
|
||||
준설사진을 선택하면 촬영일과 위치정보를 자동으로
|
||||
확인합니다.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-sm border border-dashed border-blue-300 bg-blue-50 px-3 py-2 text-center text-xs text-slate-600">
|
||||
사진 없이 아래 지도에서 준설위치를 직접 지정합니다.
|
||||
<div className="bg-secondary/30 rounded-sm border border-dashed border-border px-3 py-3 text-center text-sm text-muted-foreground">
|
||||
사진 없이 지도에서 준설위치를 직접 지정합니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="shrink-0 rounded-sm border border-slate-300 bg-white p-2 shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 pb-1.5">
|
||||
<h3 className="text-lg font-semibold text-slate-800">
|
||||
<section className="card card-border !m-0 shrink-0 overflow-hidden !p-0">
|
||||
<div className="card-header !m-0 flex-wrap gap-2">
|
||||
<h3 className="text-2xl font-medium text-label">
|
||||
위치 및 관로
|
||||
</h3>
|
||||
<div className="flex shrink-0 flex-wrap justify-end gap-1">
|
||||
@@ -807,15 +829,6 @@ const DredgingRegisterModal = ({
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className={POPUP_SECONDARY_BUTTON_CLASS}
|
||||
onClick={cancelLocationEditing}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className={POPUP_PRIMARY_BUTTON_CLASS}
|
||||
disabled={!locationDraft || activeGroup.isMatching}
|
||||
onClick={() => {
|
||||
void applyLocationDraft();
|
||||
@@ -825,12 +838,19 @@ const DredgingRegisterModal = ({
|
||||
? "관로 찾는 중..."
|
||||
: "선택 위치 적용"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="white"
|
||||
onClick={cancelLocationEditing}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className={POPUP_PRIMARY_BUTTON_CLASS}
|
||||
onClick={() => startLocationEditing(activeGroup)}
|
||||
>
|
||||
{activeResolvedLocation ? "위치 수정" : "위치 지정"}
|
||||
@@ -838,10 +858,18 @@ const DredgingRegisterModal = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5 flex min-w-0 items-start gap-2">
|
||||
<div className="p-3">
|
||||
<Table variant="secondary">
|
||||
<colgroup>
|
||||
<col width="22%" />
|
||||
<col width="78%" />
|
||||
</colgroup>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableHead>위치등록</TableHead>
|
||||
<TableCell className="border px-4">
|
||||
<span
|
||||
className={`inline-flex shrink-0 items-center rounded-full px-3 py-1 text-xs font-semibold ${
|
||||
className={`inline-flex rounded-sm px-2 py-1 text-sm font-medium ${
|
||||
hasUnappliedLocationDraft
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: activeGroup.manualLocation
|
||||
@@ -859,11 +887,12 @@ const DredgingRegisterModal = ({
|
||||
? "사진 GPS 위치"
|
||||
: "위치 지정 필요"}
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<strong className="text-sm text-slate-800">
|
||||
자동 연결 관로 ·{" "}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>자동 연결 관로</TableHead>
|
||||
<TableCell className="border px-4">
|
||||
<strong className="mr-3 text-foreground">
|
||||
{activeGroup.isMatching
|
||||
? "탐색 중"
|
||||
: hasUnappliedLocationDraft
|
||||
@@ -873,11 +902,11 @@ const DredgingRegisterModal = ({
|
||||
: "미연결"}
|
||||
</strong>
|
||||
<span
|
||||
className={`text-xs ${
|
||||
className={
|
||||
!hasUnappliedLocationDraft && activeGroup.matchError
|
||||
? "font-medium text-rose-700"
|
||||
: "text-slate-500"
|
||||
}`}
|
||||
? "text-rose-700"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{hasUnappliedLocationDraft
|
||||
? "선택 위치를 적용하면 최근접 관로를 다시 찾습니다."
|
||||
@@ -886,13 +915,15 @@ const DredgingRegisterModal = ({
|
||||
: activeGroup.matchError ||
|
||||
"위치를 지정하면 50m 이내 최근접 관로를 자동으로 연결합니다."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
className="min-h-5 shrink-0 rounded-sm bg-slate-50 px-3 py-1 text-xs text-slate-600"
|
||||
className="bg-secondary/30 shrink-0 rounded-sm border border-border px-3 py-2 text-sm text-muted-foreground"
|
||||
data-testid="dredging-registration-status"
|
||||
>
|
||||
{statusMessage ||
|
||||
@@ -900,8 +931,13 @@ const DredgingRegisterModal = ({
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="flex min-h-0 min-w-0 flex-col rounded-sm border border-slate-300 bg-white p-1.5 shadow-sm">
|
||||
<div className="min-h-[520px] flex-1">
|
||||
<section className="card card-border !m-0 flex min-h-0 min-w-0 flex-col overflow-hidden !p-0">
|
||||
<div className="card-header !m-0 shrink-0">
|
||||
<h3 className="text-2xl font-medium text-label">
|
||||
위치 선택 지도
|
||||
</h3>
|
||||
</div>
|
||||
<div className="min-h-[52rem] flex-1 p-2">
|
||||
<DredgingLocationMap
|
||||
value={
|
||||
isEditingActiveLocation
|
||||
@@ -922,7 +958,7 @@ const DredgingRegisterModal = ({
|
||||
: "위치등록 버튼을 누른 뒤 지도에서 준설위치를 선택해주세요."
|
||||
}
|
||||
onChange={changeLocationDraft}
|
||||
height="h-full min-h-[520px]"
|
||||
height="h-full min-h-[52rem]"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -933,15 +969,13 @@ const DredgingRegisterModal = ({
|
||||
|
||||
if (presentation === "popup") {
|
||||
return (
|
||||
<>
|
||||
<main className="sn-popup-page sn-dredging-register">
|
||||
<header className="sn-popup-header">
|
||||
<h1 className="sn-popup-title">준설결과 등록</h1>
|
||||
<StandardPopupLayout
|
||||
title="준설결과 등록"
|
||||
showDefaultCancelAction={false}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
size="sm"
|
||||
className="sn-popup-header-button min-w-[6.4rem] !border-white !bg-white !text-blue-700 hover:!bg-blue-50"
|
||||
disabled={isSaving}
|
||||
onClick={() => {
|
||||
void handleSave();
|
||||
@@ -950,37 +984,38 @@ const DredgingRegisterModal = ({
|
||||
>
|
||||
{isSaving ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</header>
|
||||
<section className="sn-popup-section min-h-0 overflow-hidden !p-2">
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
disabled={isSaving}
|
||||
onClick={() => window.close()}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
bodyClassName="block overflow-y-auto"
|
||||
>
|
||||
{modalBody}
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
</StandardPopupLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<UiModal
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!isSaving) onOpenChange(nextOpen);
|
||||
}}
|
||||
bodyClass="!max-w-[1200px] w-[92vw]"
|
||||
headerTitle="준설결과 업로드"
|
||||
headerTitleClass="text-2xl font-semibold text-slate-900"
|
||||
body={modalBody}
|
||||
footerContent={
|
||||
<div className="flex w-full justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className={POPUP_SECONDARY_BUTTON_CLASS}
|
||||
disabled={isSaving}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<DialogContent className="w-[92vw] !max-w-[1200px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>준설결과 업로드</DialogTitle>
|
||||
</DialogHeader>
|
||||
{open ? modalBody : null}
|
||||
<DialogFooter>
|
||||
<div className="flex w-full justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -988,14 +1023,23 @@ const DredgingRegisterModal = ({
|
||||
onClick={() => {
|
||||
void handleSave();
|
||||
}}
|
||||
className={POPUP_PRIMARY_BUTTON_CLASS}
|
||||
data-testid="dredging-save-button"
|
||||
>
|
||||
{isSaving ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="white"
|
||||
disabled={isSaving}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -152,6 +152,7 @@ export const updateDredgingRecord = async (
|
||||
await putData(`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}`, {
|
||||
dredgingDate: update.dredgingDate,
|
||||
pipeFid: update.originalPipeFid,
|
||||
registrationMode: update.registrationMode,
|
||||
location: {
|
||||
source: update.locationSource,
|
||||
longitude: update.location.longitude,
|
||||
|
||||
@@ -76,6 +76,7 @@ export type DredgingRecord = {
|
||||
latitude: number;
|
||||
snappedLongitude: number;
|
||||
snappedLatitude: number;
|
||||
registrationMode: DredgingRegistrationMode;
|
||||
registrationSource: DredgingLocationSource;
|
||||
matchDistanceMeters: number;
|
||||
isMock: boolean;
|
||||
@@ -104,6 +105,7 @@ export type DredgingSaveGroup = {
|
||||
export type DredgingRecordUpdate = {
|
||||
dredgingDate: string;
|
||||
originalPipeFid: string;
|
||||
registrationMode: DredgingRegistrationMode;
|
||||
location: GeoPoint;
|
||||
locationSource: DredgingLocationSource;
|
||||
pipe: PipeMatch;
|
||||
|
||||
@@ -7,18 +7,31 @@
|
||||
*/
|
||||
"use client";
|
||||
|
||||
import { SelectBox } from "@common/business_components/ui";
|
||||
import { SearchDiv } from "@common/business_components/ui/search_form";
|
||||
import { UiTable } from "@common/business_components/ui/table";
|
||||
import ListDiv from "@common/business_components/ui/list_div";
|
||||
import { Button } from "@common/components/ui/button";
|
||||
import { Input } from "@common/components/ui/input";
|
||||
import { Label } from "@common/components/ui/label";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { StandardDataTable } from "@common/business_components";
|
||||
import {
|
||||
Button,
|
||||
InputDatePicker,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@common/components";
|
||||
import { MS_SWAGE_PPL_CODES } from "@/app/(map-layout)/map/components/common/table";
|
||||
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
|
||||
import useStore from "@/store/useStore";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type PaginationState,
|
||||
} from "@tanstack/react-table";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
|
||||
import {
|
||||
deleteDredgingRecord,
|
||||
@@ -49,9 +62,6 @@ const resolvePipeMaterialName = (value: string) => {
|
||||
const DREDGING_LOAD_ERROR_MESSAGE =
|
||||
"준설현황을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.";
|
||||
const currentYear = new Date().getFullYear();
|
||||
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 formatLocalDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
@@ -59,6 +69,17 @@ const formatLocalDate = (date: Date) => {
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
/** 준설일자 기본 검색기간을 오늘까지의 최근 1년으로 생성한다. */
|
||||
const createDefaultDredgingDateRange = () => {
|
||||
const to = new Date();
|
||||
const from = new Date(to);
|
||||
from.setFullYear(from.getFullYear() - 1);
|
||||
return {
|
||||
fromDate: formatLocalDate(from),
|
||||
toDate: formatLocalDate(to),
|
||||
};
|
||||
};
|
||||
|
||||
const initialRecords: DredgingRecord[] = [
|
||||
{
|
||||
id: "mock-dredging-1",
|
||||
@@ -80,6 +101,7 @@ const initialRecords: DredgingRecord[] = [
|
||||
latitude: 37.4916,
|
||||
snappedLongitude: 127.48751,
|
||||
snappedLatitude: 37.49159,
|
||||
registrationMode: "PHOTO",
|
||||
registrationSource: "EXIF",
|
||||
matchDistanceMeters: 4.28,
|
||||
isMock: true,
|
||||
@@ -104,6 +126,7 @@ const initialRecords: DredgingRecord[] = [
|
||||
latitude: 37.4954,
|
||||
snappedLongitude: 126.88741,
|
||||
snappedLatitude: 37.49539,
|
||||
registrationMode: "MANUAL",
|
||||
registrationSource: "MANUAL",
|
||||
matchDistanceMeters: 8.15,
|
||||
isMock: true,
|
||||
@@ -132,6 +155,11 @@ const buildDredgingPopupParams = (record: DredgingRecord) => ({
|
||||
latitude: record.latitude,
|
||||
snappedLongitude: record.snappedLongitude,
|
||||
snappedLatitude: record.snappedLatitude,
|
||||
registrationMode:
|
||||
record.registrationMode ??
|
||||
(record.photoCount === 0 && record.registrationSource === "MANUAL"
|
||||
? "MANUAL"
|
||||
: "PHOTO"),
|
||||
registrationSource: record.registrationSource,
|
||||
matchDistanceMeters: record.matchDistanceMeters,
|
||||
isMock: record.isMock,
|
||||
@@ -141,7 +169,13 @@ type TableRow = Record<string, unknown> & {
|
||||
recordIds: string[];
|
||||
};
|
||||
|
||||
const municipalityHeadlist = [
|
||||
type TableHeadDefinition = {
|
||||
id: string;
|
||||
title: string;
|
||||
align?: "left" | "center" | "right";
|
||||
};
|
||||
|
||||
const municipalityHeadlist: TableHeadDefinition[] = [
|
||||
{ id: "rowNumber", title: "순번" },
|
||||
{ id: "ctpv", title: "시도" },
|
||||
{ id: "sgg", title: "시군구" },
|
||||
@@ -157,7 +191,7 @@ const municipalityHeadlist = [
|
||||
{ id: "registeredDate", title: "등록일자" },
|
||||
];
|
||||
|
||||
const diameterHeadlist = [
|
||||
const diameterHeadlist: TableHeadDefinition[] = [
|
||||
{ id: "ctpv", title: "시도" },
|
||||
{ id: "totalLengthKm", title: "관로연장(km)", align: "right" },
|
||||
{ id: "diameter450", title: "450 이하", align: "right" },
|
||||
@@ -169,7 +203,7 @@ const diameterHeadlist = [
|
||||
{ id: "diameter1500", title: "1500 이상", align: "right" },
|
||||
];
|
||||
|
||||
const elapsedYearHeadlist = [
|
||||
const elapsedYearHeadlist: TableHeadDefinition[] = [
|
||||
{ id: "ctpv", title: "시도" },
|
||||
{ id: "totalLengthKm", title: "관로연장(km)", align: "right" },
|
||||
{ id: "under10", title: "10년 미만", align: "right" },
|
||||
@@ -181,6 +215,14 @@ const elapsedYearHeadlist = [
|
||||
|
||||
const roundKm = (meters: number) => Number((meters / 1000).toFixed(3));
|
||||
|
||||
const parseLocalDate = (value: string) => new Date(`${value}T00:00:00`);
|
||||
|
||||
const formatTableValue = (value: unknown) => {
|
||||
if (typeof value === "number") return value.toLocaleString("ko-KR");
|
||||
if (value === null || value === undefined) return "";
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const groupByCtpv = (records: DredgingRecord[]) => {
|
||||
const groups = new Map<string, DredgingRecord[]>();
|
||||
for (const record of records) {
|
||||
@@ -266,15 +308,24 @@ const buildElapsedYearRows = (records: DredgingRecord[]): TableRow[] =>
|
||||
});
|
||||
|
||||
const Page = () => {
|
||||
const today = formatLocalDate(new Date());
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { confirm } = useConfirm();
|
||||
const defaultDredgingDateRange = useMemo(createDefaultDredgingDateRange, []);
|
||||
const {
|
||||
ctpv: ctpvRegionOptions,
|
||||
sgg: sggRegionOptions,
|
||||
fetchCtpv,
|
||||
fetchSgg,
|
||||
} = useStore();
|
||||
const [fromDate, setFromDate] = useState(`${currentYear}-01-01`);
|
||||
const [toDate, setToDate] = useState(today);
|
||||
const [fromDate, setFromDate] = useState(defaultDredgingDateRange.fromDate);
|
||||
const [toDate, setToDate] = useState(defaultDredgingDateRange.toDate);
|
||||
// 날짜 입력 중에는 기존 결과를 유지하고 조회·초기화 시점에만 적용 기간을 갱신한다.
|
||||
const [appliedFromDate, setAppliedFromDate] = useState(
|
||||
defaultDredgingDateRange.fromDate,
|
||||
);
|
||||
const [appliedToDate, setAppliedToDate] = useState(
|
||||
defaultDredgingDateRange.toDate,
|
||||
);
|
||||
// 선택 UI는 행정구역 코드를, 준설 조회 API는 행정구역 명칭을 사용한다.
|
||||
const [ctpvCd, setCtpvCd] = useState("0");
|
||||
const [sggCd, setSggCd] = useState("0");
|
||||
@@ -318,8 +369,8 @@ const Page = () => {
|
||||
setLoadError(null);
|
||||
try {
|
||||
const response = await fetchDredgingRecords({
|
||||
fromDate,
|
||||
toDate,
|
||||
fromDate: appliedFromDate,
|
||||
toDate: appliedToDate,
|
||||
ctpv: appliedCtpvName,
|
||||
sgg: appliedSggName,
|
||||
size: 2000,
|
||||
@@ -346,12 +397,19 @@ const Page = () => {
|
||||
() =>
|
||||
records.filter(
|
||||
(record) =>
|
||||
record.dredgingDate >= fromDate &&
|
||||
record.dredgingDate <= toDate &&
|
||||
record.dredgingDate >= appliedFromDate &&
|
||||
record.dredgingDate <= appliedToDate &&
|
||||
(!appliedCtpvName || record.ctpv === appliedCtpvName) &&
|
||||
(!appliedSggName || record.sgg === appliedSggName),
|
||||
),
|
||||
[records, fromDate, toDate, appliedCtpvName, appliedSggName, searchVersion],
|
||||
[
|
||||
records,
|
||||
appliedFromDate,
|
||||
appliedToDate,
|
||||
appliedCtpvName,
|
||||
appliedSggName,
|
||||
searchVersion,
|
||||
],
|
||||
);
|
||||
|
||||
const tableRows = useMemo<TableRow[]>(() => {
|
||||
@@ -360,7 +418,7 @@ const Page = () => {
|
||||
return buildElapsedYearRows(filteredRecords);
|
||||
|
||||
return filteredRecords.map((record, index) => ({
|
||||
rowNumber: index + 1,
|
||||
rowNumber: filteredRecords.length - index,
|
||||
...record,
|
||||
pipeLengthM: Number(record.pipeLengthM.toFixed(2)),
|
||||
pipeMaterial: resolvePipeMaterialName(record.pipeMaterial),
|
||||
@@ -388,9 +446,6 @@ const Page = () => {
|
||||
: [];
|
||||
const canManageSelectedRecord =
|
||||
viewType === "MUNICIPALITY" && selectedRecord !== null && !isMutating;
|
||||
// 관경별·경과년도별 행은 여러 관로를 합산한 집계 행이므로 단일 관로 상세를 열지 않는다.
|
||||
const canViewSelectedRecord =
|
||||
viewType === "MUNICIPALITY" && selectedRecord !== null;
|
||||
|
||||
const headlist =
|
||||
viewType === "MUNICIPALITY"
|
||||
@@ -399,12 +454,49 @@ const Page = () => {
|
||||
? diameterHeadlist
|
||||
: elapsedYearHeadlist;
|
||||
|
||||
const title =
|
||||
viewType === "MUNICIPALITY"
|
||||
? "지자체별 준설현황"
|
||||
: viewType === "DIAMETER"
|
||||
? "관경별 준설현황"
|
||||
: "경과년도별 준설현황";
|
||||
const title = "준설 목록";
|
||||
|
||||
const columns = useMemo<ColumnDef<TableRow>[]>(
|
||||
() =>
|
||||
headlist.map((head) => ({
|
||||
id: head.id,
|
||||
accessorFn: (row) => row[head.id],
|
||||
header: head.title,
|
||||
cell: ({ getValue }) => formatTableValue(getValue()),
|
||||
meta: {
|
||||
align: head.align ?? "center",
|
||||
},
|
||||
})),
|
||||
[headlist],
|
||||
);
|
||||
const pagination: PaginationState = {
|
||||
pageIndex: currentPage - 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
};
|
||||
const selectedTableRowId = pageRows
|
||||
.find(
|
||||
(row) =>
|
||||
selectedRecordId !== null && row.recordIds.includes(selectedRecordId),
|
||||
)
|
||||
?.recordIds.join("|");
|
||||
const table = useReactTable({
|
||||
data: pageRows,
|
||||
columns,
|
||||
state: {
|
||||
pagination,
|
||||
rowSelection: selectedTableRowId ? { [selectedTableRowId]: true } : {},
|
||||
},
|
||||
rowCount: tableRows.length,
|
||||
manualPagination: true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (row) => row.recordIds.join("|"),
|
||||
onPaginationChange: (updater) => {
|
||||
const next =
|
||||
typeof updater === "function" ? updater(pagination) : updater;
|
||||
setCurrentPage(next.pageIndex + 1);
|
||||
setSelectedRecordId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSearch = () => {
|
||||
if (fromDate > toDate) {
|
||||
@@ -413,11 +505,30 @@ const Page = () => {
|
||||
}
|
||||
setCurrentPage(1);
|
||||
setSelectedRecordId(null);
|
||||
setAppliedFromDate(fromDate);
|
||||
setAppliedToDate(toDate);
|
||||
setAppliedCtpvName(ctpvName);
|
||||
setAppliedSggName(sggName);
|
||||
setSearchVersion((version) => version + 1);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFromDate(defaultDredgingDateRange.fromDate);
|
||||
setToDate(defaultDredgingDateRange.toDate);
|
||||
setAppliedFromDate(defaultDredgingDateRange.fromDate);
|
||||
setAppliedToDate(defaultDredgingDateRange.toDate);
|
||||
setViewType("MUNICIPALITY");
|
||||
setCtpvCd("0");
|
||||
setSggCd("0");
|
||||
setCtpvName("");
|
||||
setSggName("");
|
||||
setAppliedCtpvName("");
|
||||
setAppliedSggName("");
|
||||
setCurrentPage(1);
|
||||
setSelectedRecordId(null);
|
||||
setSearchVersion((version) => version + 1);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
const visibleHeads = headlist.filter((head) => head.id !== "recordIds");
|
||||
@@ -475,6 +586,7 @@ const Page = () => {
|
||||
latitude: group.location.latitude,
|
||||
snappedLongitude: group.pipe.snappedLongitude,
|
||||
snappedLatitude: group.pipe.snappedLatitude,
|
||||
registrationMode: group.registrationMode,
|
||||
registrationSource: group.locationSource,
|
||||
matchDistanceMeters: group.pipe.distanceMeters,
|
||||
isMock: group.pipe.isMock || !isDredgingBackendEnabled,
|
||||
@@ -504,7 +616,7 @@ const Page = () => {
|
||||
/** 선택 행이 속한 준설작업과 연결 관로·사진 참조를 물리 삭제하고 목록을 재조회한다. */
|
||||
const handleDeleteRecord = async () => {
|
||||
if (!canManageSelectedRecord || !selectedRecord) return;
|
||||
const confirmed = window.confirm(
|
||||
const confirmed = await confirm(
|
||||
`선택한 준설작업 전체를 삭제하시겠습니까?\n준설일자: ${selectedRecord.dredgingDate}\n연결 관로: ${selectedJobRecords.length}건 · 등록사진: ${selectedRecord.photoCount}건`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
@@ -548,23 +660,6 @@ const Page = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/** 선택 관로의 정보·사진·위치를 하나의 최대화 WindowPopup에서 확인한다. */
|
||||
const openDredgingDetail = () => {
|
||||
if (!canViewSelectedRecord || !selectedRecord) return;
|
||||
|
||||
const popup = openWindowPopup(
|
||||
"dredgingDetail",
|
||||
buildDredgingPopupParams(selectedRecord),
|
||||
{ maximize: true },
|
||||
);
|
||||
|
||||
if (!popup) {
|
||||
alert(
|
||||
"준설결과 상세 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** 등록·수정 팝업의 저장 완료 메시지를 받아 목록을 최신 상태로 갱신한다. */
|
||||
useEffect(() => {
|
||||
const handlePopupMessage = (event: MessageEvent) => {
|
||||
@@ -597,55 +692,61 @@ const Page = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchDiv>
|
||||
<div className="flex w-full flex-wrap items-center gap-y-3 px-5 py-4">
|
||||
<div className="mr-8 flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap text-xl font-medium text-gray-600">
|
||||
<LoadingOverlay isLoading={isLoading || isMutating} />
|
||||
<div className="card card-border !mt-6">
|
||||
<form
|
||||
className="flex flex-col gap-6 p-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
handleSearch();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="mr-2 whitespace-nowrap text-label">
|
||||
준설일자
|
||||
</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={fromDate}
|
||||
max={today}
|
||||
onChange={(event) => setFromDate(event.target.value)}
|
||||
className="h-[3.2rem] w-[155px] text-base"
|
||||
<InputDatePicker
|
||||
value={parseLocalDate(fromDate)}
|
||||
onChange={(date) => date && setFromDate(formatLocalDate(date))}
|
||||
className="w-[15.5rem]"
|
||||
/>
|
||||
<span>~</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={toDate}
|
||||
max={today}
|
||||
onChange={(event) => setToDate(event.target.value)}
|
||||
className="h-[3.2rem] w-[155px] text-base"
|
||||
<InputDatePicker
|
||||
value={parseLocalDate(toDate)}
|
||||
onChange={(date) => date && setToDate(formatLocalDate(date))}
|
||||
className="w-[15.5rem]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="mr-8 flex items-center gap-2 whitespace-nowrap text-xl font-medium text-gray-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="mr-2 whitespace-nowrap text-label">
|
||||
조회구분
|
||||
<select
|
||||
</Label>
|
||||
<Select
|
||||
value={viewType}
|
||||
onChange={(event) => {
|
||||
setViewType(event.target.value as DredgingViewType);
|
||||
onValueChange={(value) => {
|
||||
setViewType(value as DredgingViewType);
|
||||
setCurrentPage(1);
|
||||
setSelectedRecordId(null);
|
||||
}}
|
||||
className="h-[3.2rem] min-w-[145px] rounded-lg border border-input bg-white px-3 text-base font-normal text-slate-900"
|
||||
>
|
||||
<option value="MUNICIPALITY">지자체별</option>
|
||||
<option value="DIAMETER">관경별</option>
|
||||
<option value="ELAPSED_YEAR">경과년도별</option>
|
||||
</select>
|
||||
</label>
|
||||
<SelectTrigger className="w-[14.5rem]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="MUNICIPALITY">지자체별</SelectItem>
|
||||
<SelectItem value="DIAMETER">관경별</SelectItem>
|
||||
<SelectItem value="ELAPSED_YEAR">경과년도별</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="mr-8 flex items-center">
|
||||
<SelectBox
|
||||
label="시도"
|
||||
className=""
|
||||
labelClass="w-16 whitespace-nowrap text-xl font-medium text-gray-600"
|
||||
selectClass="w-[140px] rounded-lg bg-white"
|
||||
selectArray={[ALL_REGION_OPTION, ...ctpvRegionOptions]}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="mr-2 whitespace-nowrap text-label">시도</Label>
|
||||
<Select
|
||||
value={ctpvCd}
|
||||
onSelectValue={(value) => {
|
||||
onValueChange={(value) => {
|
||||
const nextCtpvCd = String(value);
|
||||
setCtpvCd(nextCtpvCd);
|
||||
setSggCd("0");
|
||||
@@ -656,18 +757,30 @@ const Page = () => {
|
||||
);
|
||||
setSggName("");
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger className="w-[14rem]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[ALL_REGION_OPTION, ...ctpvRegionOptions].map((option) => (
|
||||
<SelectItem
|
||||
key={String(option.val)}
|
||||
value={String(option.val)}
|
||||
>
|
||||
{option.text}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="mr-8 flex items-center">
|
||||
<SelectBox
|
||||
label="시군구"
|
||||
className=""
|
||||
labelClass="w-20 whitespace-nowrap text-xl font-medium text-gray-600"
|
||||
selectClass="w-[140px] rounded-lg bg-white"
|
||||
selectArray={[ALL_REGION_OPTION, ...availableSggOptions]}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="mr-2 whitespace-nowrap text-label">
|
||||
시군구
|
||||
</Label>
|
||||
<Select
|
||||
value={sggCd}
|
||||
onSelectValue={(value) => {
|
||||
onValueChange={(value) => {
|
||||
const nextSggCd = String(value);
|
||||
setSggCd(nextSggCd);
|
||||
setSggName(
|
||||
@@ -676,65 +789,37 @@ const Page = () => {
|
||||
)?.text ?? "",
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSearch}
|
||||
className={ACTION_BUTTON_CLASS}
|
||||
>
|
||||
<SelectTrigger className="w-[14rem]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[ALL_REGION_OPTION, ...availableSggOptions].map((option) => (
|
||||
<SelectItem
|
||||
key={String(option.val)}
|
||||
value={String(option.val)}
|
||||
>
|
||||
{option.text}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-[7.4rem]">
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void handleDownload();
|
||||
}}
|
||||
className={ACTION_BUTTON_CLASS}
|
||||
type="button"
|
||||
color="white"
|
||||
className="w-[8.8rem]"
|
||||
onClick={handleReset}
|
||||
>
|
||||
다운로드
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={openDredgingRegister}
|
||||
className={ACTION_BUTTON_CLASS}
|
||||
data-testid="dredging-register-button"
|
||||
>
|
||||
등록
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={openEditRecord}
|
||||
className={ACTION_BUTTON_CLASS}
|
||||
disabled={!canManageSelectedRecord}
|
||||
data-testid="dredging-edit-button"
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void handleDeleteRecord();
|
||||
}}
|
||||
className={ACTION_BUTTON_CLASS}
|
||||
disabled={!canManageSelectedRecord}
|
||||
data-testid="dredging-delete-button"
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className={ACTION_BUTTON_CLASS}
|
||||
disabled={!canViewSelectedRecord}
|
||||
onClick={openDredgingDetail}
|
||||
data-testid="dredging-detail-button"
|
||||
>
|
||||
상세보기
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</SearchDiv>
|
||||
|
||||
{loadError ? (
|
||||
<div
|
||||
@@ -745,7 +830,8 @@ const Page = () => {
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 border border-amber-300 bg-white px-3 text-amber-800 hover:bg-amber-100"
|
||||
color="primary"
|
||||
className="h-8 shrink-0 px-3"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
다시 시도
|
||||
@@ -753,24 +839,47 @@ const Page = () => {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ListDiv className="mt-4">
|
||||
<UiTable
|
||||
headName={isLoading ? `${title} (조회 중...)` : title}
|
||||
tableData={pageRows}
|
||||
headlist={headlist}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={tableRows.length}
|
||||
curPage={currentPage}
|
||||
onPageChange={setCurrentPage}
|
||||
cellClick={(index) => {
|
||||
const recordId =
|
||||
index === undefined ? undefined : pageRows[index]?.recordIds[0];
|
||||
setSelectedRecordId(recordId ?? null);
|
||||
<div className="mt-4 w-full min-w-0 max-w-full overflow-hidden">
|
||||
<StandardDataTable
|
||||
table={table}
|
||||
title={title}
|
||||
onRowClick={(row) => {
|
||||
setSelectedRecordId(row.recordIds[0] ?? null);
|
||||
}}
|
||||
resetSelection={selectedRecordId === null}
|
||||
widthFixed={false}
|
||||
actions={
|
||||
<>
|
||||
<Button type="button" onClick={() => void handleDownload()}>
|
||||
엑셀다운로드
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={openDredgingRegister}
|
||||
data-testid="dredging-register-button"
|
||||
>
|
||||
등록
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={openEditRecord}
|
||||
disabled={!canManageSelectedRecord}
|
||||
data-testid="dredging-edit-button"
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
color="red"
|
||||
onClick={() => void handleDeleteRecord()}
|
||||
disabled={!canManageSelectedRecord}
|
||||
data-testid="dredging-delete-button"
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</ListDiv>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
*/
|
||||
import { TableHead, TableRow } from "@common/components/ui";
|
||||
|
||||
|
||||
export type TableUpperProps = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -23,9 +22,9 @@ export type TableUpperProps = {
|
||||
type?: string;
|
||||
selectData?: string; // type 이 seelct 일경우 selectarray 정하기 위해
|
||||
row?: number;
|
||||
mergeCell?: boolean // 셀을 합칠경우
|
||||
maxLength?: number
|
||||
readonly?: boolean
|
||||
mergeCell?: boolean; // 셀을 합칠경우
|
||||
maxLength?: number;
|
||||
readonly?: boolean;
|
||||
};
|
||||
type chHeadType = {
|
||||
headNm: string;
|
||||
@@ -36,7 +35,7 @@ type chHeadType = {
|
||||
|
||||
export const HeadMakeFloodPre = (
|
||||
upHeadList: TableUpperProps[],
|
||||
makeHeadNm: chHeadType[]
|
||||
makeHeadNm: chHeadType[],
|
||||
) => {
|
||||
return (
|
||||
<>
|
||||
@@ -46,7 +45,7 @@ export const HeadMakeFloodPre = (
|
||||
.map((head: TableUpperProps) =>
|
||||
!head.upSequnce ? (
|
||||
<TableHead
|
||||
className={`border border-gray-600 px-2 py-1 `}
|
||||
className="border border-border px-2 py-1"
|
||||
rowSpan={2}
|
||||
key={head.id}
|
||||
>
|
||||
@@ -54,7 +53,7 @@ export const HeadMakeFloodPre = (
|
||||
</TableHead>
|
||||
) : head.upSequnce === 1 ? (
|
||||
<TableHead
|
||||
className={`border border-gray-600 px-2 py-1 `}
|
||||
className="border border-border px-2 py-1"
|
||||
key={head.id}
|
||||
rowSpan={1}
|
||||
colSpan={
|
||||
@@ -63,7 +62,7 @@ export const HeadMakeFloodPre = (
|
||||
>
|
||||
{makeHeadNm?.find((ch) => ch.headNm === head.upName)?.text}
|
||||
</TableHead>
|
||||
) : null
|
||||
) : null,
|
||||
)}
|
||||
</TableRow>
|
||||
|
||||
@@ -73,12 +72,12 @@ export const HeadMakeFloodPre = (
|
||||
.map((head: TableUpperProps) =>
|
||||
makeHeadNm?.find((ch) => ch.headNm === head.upName) ? (
|
||||
<TableHead
|
||||
className={`border border-gray-600 px-2 py-1 `}
|
||||
className="border border-border px-2 py-1"
|
||||
key={head.id}
|
||||
>
|
||||
{head.title}
|
||||
</TableHead>
|
||||
) : null
|
||||
) : null,
|
||||
)}
|
||||
</TableRow>
|
||||
</>
|
||||
|
||||
@@ -12,10 +12,9 @@
|
||||
import type { TableUpperProps } from "./headMakeFloodPre";
|
||||
import { Input, TableCell, TableRow } from "@common/components/ui";
|
||||
|
||||
|
||||
// 숫자입력시 , 삽입
|
||||
const formatCommaNumber = (
|
||||
value: number | string | undefined | null
|
||||
value: number | string | undefined | null,
|
||||
): string => {
|
||||
if (value === undefined || value === null || value === "") return "";
|
||||
const num = Number(value);
|
||||
@@ -29,7 +28,7 @@ const formatCommaNumber = (
|
||||
// onchange 텍스트를 숫자로 and ,제거
|
||||
const handleCommaNumberChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
onChange: (value: number | "") => void
|
||||
onChange: (value: number | "") => void,
|
||||
) => {
|
||||
const raw = e.target.value.replace(/,/g, "");
|
||||
if (!/^\d*$/.test(raw)) return;
|
||||
@@ -40,7 +39,7 @@ const handleChange = (
|
||||
rowIndex: number,
|
||||
field: any,
|
||||
value: string,
|
||||
setInputData: (data: any) => void
|
||||
setInputData: (data: any) => void,
|
||||
) => {
|
||||
if (Number(value) < 0 || !Number(value)) {
|
||||
value = "0";
|
||||
@@ -54,7 +53,7 @@ const handleChange = (
|
||||
};
|
||||
}
|
||||
return row;
|
||||
})
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -62,11 +61,15 @@ export const InputTableBody = (
|
||||
chgRes: any[] | [],
|
||||
headList: TableUpperProps[],
|
||||
setInputData: (data: any[]) => void,
|
||||
isEditable: boolean = true
|
||||
isEditable: boolean = true,
|
||||
) => {
|
||||
// mergeCell 처리용
|
||||
const getRowSpan = (data: any[], key: string, rowIndex: number) => {
|
||||
if (rowIndex > 0 && data[rowIndex - 1][key] && String(data[rowIndex][key]).trim() === "") {
|
||||
if (
|
||||
rowIndex > 0 &&
|
||||
data[rowIndex - 1][key] &&
|
||||
String(data[rowIndex][key]).trim() === ""
|
||||
) {
|
||||
return 0; // 이전과 같으면 셀을 그리지 않음
|
||||
}
|
||||
let span = 1;
|
||||
@@ -84,9 +87,9 @@ export const InputTableBody = (
|
||||
const isTotal = data["구분"] === 0;
|
||||
|
||||
const bgClass = isTotal
|
||||
? "bg-[#d5e1eb] font-bold"
|
||||
? "bg-secondary font-bold"
|
||||
: isSubTotal
|
||||
? "bg-[#e6eef5] font-semibold"
|
||||
? "bg-muted font-semibold"
|
||||
: "";
|
||||
|
||||
return (
|
||||
@@ -106,7 +109,7 @@ export const InputTableBody = (
|
||||
// <TableCell
|
||||
// key={head.id + headIndex}
|
||||
// colSpan={2}
|
||||
// className="font-bold text-center bg-[#d5e1eb]"
|
||||
// className="bg-secondary text-center font-bold"
|
||||
// >
|
||||
// 총합계
|
||||
// </TableCell>
|
||||
@@ -125,7 +128,7 @@ export const InputTableBody = (
|
||||
// return (
|
||||
// <TableCell
|
||||
// key={head.id + headIndex}
|
||||
// className="bg-[#d5e1eb] font-bold text-right"
|
||||
// className="bg-secondary text-right font-bold"
|
||||
// >
|
||||
// {Number(data[head.id])
|
||||
// ? formatCommaNumber(data[head.id as keyof any])
|
||||
@@ -141,7 +144,7 @@ export const InputTableBody = (
|
||||
<TableCell
|
||||
key={head.id + headIndex}
|
||||
rowSpan={rowSpan}
|
||||
className={`w-36 align-middle text-center whitespace-nowrap ${bgClass}`}
|
||||
className={`w-36 whitespace-nowrap text-center align-middle ${bgClass}`}
|
||||
>
|
||||
{Number(data[head.id])
|
||||
? formatCommaNumber(data[head.id as keyof any])
|
||||
@@ -158,15 +161,15 @@ export const InputTableBody = (
|
||||
>
|
||||
<Input
|
||||
// className={`${head.id !== "utztnUsgCd" ? "text-right" : "text-center"} !text-xl h-10 w-full`}
|
||||
className={`${textAlignClass} !text-xl h-10 w-full`}
|
||||
className={`${textAlignClass} h-10 w-full !text-xl`}
|
||||
onChange={(e) =>
|
||||
handleCommaNumberChange(e, (val) =>
|
||||
handleChange(
|
||||
dataIndex,
|
||||
head.id,
|
||||
String(val),
|
||||
setInputData
|
||||
)
|
||||
setInputData,
|
||||
),
|
||||
)
|
||||
}
|
||||
value={formatCommaNumber(data[head.id as keyof any]) ?? ""}
|
||||
@@ -183,7 +186,7 @@ export const InputTableBody = (
|
||||
// ${head.id !== "utztnUsgCd" ? "text-right" : "text-center"}
|
||||
// !text-xl w-full h-10
|
||||
// `}
|
||||
className={`${textAlignClass} !text-xl w-full h-10`}
|
||||
className={`${textAlignClass} h-10 w-full !text-xl`}
|
||||
>
|
||||
{Number(data[head.id])
|
||||
? formatCommaNumber(data[head.id as keyof any])
|
||||
@@ -199,7 +202,7 @@ export const InputTableBody = (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={headList.length}
|
||||
className="h-24 text-center text-gray-500"
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
조회된 결과가 없습니다.
|
||||
</TableCell>
|
||||
|
||||
@@ -16,161 +16,238 @@
|
||||
* - 도시침수예방사업 등록: POST /api/v1/sbmrgPrevnt (sewerage-service)
|
||||
* - 첨부파일 삭제및 Soft Delete 삭제: DELETE /api/v1/atfl/attachments/{atflId} (attach-service)
|
||||
*/
|
||||
'use client'
|
||||
import { deleteData, useCookieAuth } from '@/components/lib/api'
|
||||
import { searchData } from '@/components/lib/common/apiHelpers'
|
||||
import { formatDate, handleDownload } from '@/components/lib/utils'
|
||||
import LoadingOverlay from '@/components/ui/LoadingBar'
|
||||
import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui'
|
||||
import ListDiv from '@common/business_components/ui/list_div'
|
||||
import { Button, Input, Label } from '@common/components/ui'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { tableHeadlist } from '..'
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ENDPOINT } from "@common/components/config";
|
||||
import { getUserInfo } from "@common/components/api";
|
||||
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
|
||||
import { SearchDatePicker } from '../components/SearchDatePicker';
|
||||
import type { UiTableSort } from '@common/business_components/ui';
|
||||
"use client";
|
||||
import { deleteData, useCookieAuth } from "@/components/lib/api";
|
||||
import { searchData } from "@/components/lib/common/apiHelpers";
|
||||
import { formatDate, handleDownload } from "@/components/lib/utils";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import {
|
||||
StandardDataTable,
|
||||
getStandardRowNumber,
|
||||
} from "@common/business_components";
|
||||
import { Button, Input, Label } from "@common/components";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type PaginationState,
|
||||
} from "@tanstack/react-table";
|
||||
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
|
||||
import { SearchDatePicker } from "../components/SearchDatePicker";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
type FloodPreRow = Record<string, unknown> & {
|
||||
rowNumber: number;
|
||||
sbmrgPrevntBizSn: string;
|
||||
sbmrgPrevntBizNm?: string;
|
||||
atflId?: string;
|
||||
};
|
||||
type FloodPreSearch = {
|
||||
fromDate: Date;
|
||||
toDate: Date;
|
||||
projectName: string;
|
||||
};
|
||||
|
||||
const FLOOD_PRE_COLUMNS: Array<{
|
||||
id: keyof FloodPreRow;
|
||||
title: string;
|
||||
width: number;
|
||||
}> = [
|
||||
{ id: "rowNumber", title: "순번", width: 70 },
|
||||
{ id: "sbmrgPrevntBizNm", title: "파일명", width: 260 },
|
||||
{ id: "mngYr", title: "년도", width: 90 },
|
||||
{ id: "mngQu", title: "분기", width: 90 },
|
||||
{ id: "regDt", title: "등록 일자", width: 120 },
|
||||
{ id: "rgtrNm", title: "등록자", width: 120 },
|
||||
];
|
||||
|
||||
const Page = () => {
|
||||
const AUTH = useCookieAuth();
|
||||
const { confirm } = useConfirm();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
|
||||
// 조회조건
|
||||
const [fromDate, setFromDate] = useState(() => { const date = new Date(); date.setDate(date.getDate() - 7); return date })
|
||||
const [toDate, setToDate] = useState(new Date())
|
||||
const [usr, setUsr] = useState('')
|
||||
const [pjNm, setpjNm] = useState('')
|
||||
const [fromDate, setFromDate] = useState(() => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - 7);
|
||||
return date;
|
||||
});
|
||||
const [toDate, setToDate] = useState(new Date());
|
||||
const [usr, setUsr] = useState("");
|
||||
const [pjNm, setpjNm] = useState("");
|
||||
// 파일업로드 관련 상태
|
||||
const [fileNm, setFileNm] = useState('') //파일명
|
||||
const [atflId, setAtflId] = useState('') //파일 ID
|
||||
const [sbmrgPrevntBizSn, setSbmrgPrevntBizSn] = useState('') //파일 일련번호
|
||||
const [tableData, setTableData] = useState<any>([{}]) //테이블 데이터
|
||||
const [curPage, setCurPage] = useState(1) //현재 페이지
|
||||
const [totalData, setTotalData] = useState(0) //총 데이터 수
|
||||
const [isLoading, setIsLoading] = useState(true) //로딩 상태
|
||||
const [resetSelection, setResetSelection] = useState<boolean>(false); // 선택 초기화 상태
|
||||
const [isResetPage, setIsResetPage] = useState<boolean>(false);
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
|
||||
const { data: userInfo } = useQuery({
|
||||
queryKey: [ENDPOINT.USER_SERVICE.USERS],
|
||||
queryFn: () => getUserInfo(),
|
||||
enabled: true,
|
||||
const [fileNm, setFileNm] = useState(""); //파일명
|
||||
const [atflId, setAtflId] = useState(""); //파일 ID
|
||||
const [sbmrgPrevntBizSn, setSbmrgPrevntBizSn] = useState(""); //파일 일련번호
|
||||
const [tableData, setTableData] = useState<FloodPreRow[]>([]); //테이블 데이터
|
||||
const [curPage, setCurPage] = useState(1); //현재 페이지
|
||||
const [totalData, setTotalData] = useState(0); //총 데이터 수
|
||||
const [isLoading, setIsLoading] = useState(true); //로딩 상태
|
||||
const columns = useMemo<ColumnDef<FloodPreRow>[]>(
|
||||
() =>
|
||||
FLOOD_PRE_COLUMNS.map((column) => ({
|
||||
id: String(column.id),
|
||||
accessorFn: (row) => row[column.id],
|
||||
header: column.title,
|
||||
cell: ({ getValue }) => String(getValue() ?? ""),
|
||||
meta: { width: column.width, align: "center" },
|
||||
})),
|
||||
[],
|
||||
);
|
||||
const pagination: PaginationState = {
|
||||
pageIndex: curPage - 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
};
|
||||
const table = useReactTable({
|
||||
data: tableData,
|
||||
columns,
|
||||
state: { pagination },
|
||||
rowCount: totalData,
|
||||
manualPagination: true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (row) => String(row.sbmrgPrevntBizSn),
|
||||
onPaginationChange: (updater) => {
|
||||
const next =
|
||||
typeof updater === "function" ? updater(pagination) : updater;
|
||||
setCurPage(next.pageIndex + 1);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.FLOOD_PRE_UPLOAD_COMPLETED) return;
|
||||
if (
|
||||
event.data?.type !==
|
||||
WINDOW_POPUP_MESSAGE_TYPE.FLOOD_PRE_UPLOAD_COMPLETED
|
||||
)
|
||||
return;
|
||||
handleSearch();
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
}, [fromDate, toDate, pjNm, curPage, sort]);
|
||||
}, [fromDate, toDate, pjNm, curPage]);
|
||||
|
||||
// 페이지 변경 시마다 데이터 fetch
|
||||
useEffect(() => {
|
||||
setIsResetPage(false);
|
||||
fetchData();
|
||||
}, [curPage]);
|
||||
|
||||
// 조회 버튼 클릭 시 1페이지로 이동 및 데이터 초기화
|
||||
const handleSearch: () => Promise<void> = async () => {
|
||||
setIsResetPage(true); // 페이지를 초기화
|
||||
if (curPage === 1) {
|
||||
await fetchData(1, sort);
|
||||
await fetchData(1);
|
||||
return;
|
||||
}
|
||||
setCurPage(1); // 페이지 변경 effect에서 한 번만 조회
|
||||
};
|
||||
|
||||
// 조회
|
||||
const fetchData = async (page = curPage, activeSort = sort) => {
|
||||
setIsLoading(true)
|
||||
setResetSelection(true);
|
||||
// searchCmmnCd('SITEKNDCD')
|
||||
try {
|
||||
const { data, total } = await searchData('v1/sbmrgPrevnt', {
|
||||
fromRegDt: formatDate(fromDate),
|
||||
toRegDt: formatDate(toDate),
|
||||
sbmrgPrevntBizNm: pjNm,
|
||||
auth: AUTH,
|
||||
page: page - 1,
|
||||
size: '10',
|
||||
sort: activeSort ? `${activeSort.key},${activeSort.direction}` : undefined,
|
||||
})
|
||||
// 일련번호를 추가한 데이터로 변환 (전체 데이터 기준으로 일련번호 계산)
|
||||
const descendingSequence =
|
||||
activeSort?.key === 'sbmrgPrevntBizSn' && activeSort.direction === 'desc';
|
||||
const dataWithRowNumber = (data || []).map((item: any, index: number) => ({
|
||||
...item,
|
||||
rowNumber: descendingSequence
|
||||
? total - ((page - 1) * 10 + index)
|
||||
: (page - 1) * 10 + index + 1,
|
||||
}));
|
||||
setTableData(dataWithRowNumber)
|
||||
setTotalData(total)
|
||||
}
|
||||
catch (error) { console.error('Error fetching data:', error) }
|
||||
finally {
|
||||
setFileNm('');
|
||||
setAtflId('');
|
||||
setSbmrgPrevntBizSn('');
|
||||
setIsLoading(false);
|
||||
setResetSelection(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleSortChange = (nextSort?: UiTableSort) => {
|
||||
setSort(nextSort);
|
||||
setIsResetPage(true);
|
||||
setResetSelection(true);
|
||||
if (curPage === 1) {
|
||||
void fetchData(1, nextSort);
|
||||
// 검색조건을 초기값으로 되돌리고 첫 페이지부터 다시 조회한다.
|
||||
const handleReset = async () => {
|
||||
const nextFromDate = new Date();
|
||||
nextFromDate.setDate(nextFromDate.getDate() - 7);
|
||||
const nextToDate = new Date();
|
||||
setFromDate(nextFromDate);
|
||||
setToDate(nextToDate);
|
||||
setUsr("");
|
||||
setpjNm("");
|
||||
if (curPage !== 1) {
|
||||
setCurPage(1);
|
||||
return;
|
||||
}
|
||||
setCurPage(1);
|
||||
await fetchData(1, {
|
||||
fromDate: nextFromDate,
|
||||
toDate: nextToDate,
|
||||
projectName: "",
|
||||
});
|
||||
};
|
||||
|
||||
// 조회
|
||||
const fetchData = async (
|
||||
page = curPage,
|
||||
condition: FloodPreSearch = {
|
||||
fromDate,
|
||||
toDate,
|
||||
projectName: pjNm,
|
||||
},
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
// searchCmmnCd('SITEKNDCD')
|
||||
try {
|
||||
const { data, total } = await searchData("v1/sbmrgPrevnt", {
|
||||
fromRegDt: formatDate(condition.fromDate),
|
||||
toRegDt: formatDate(condition.toDate),
|
||||
sbmrgPrevntBizNm: condition.projectName,
|
||||
auth: AUTH,
|
||||
page: page - 1,
|
||||
size: String(PAGE_SIZE),
|
||||
sort: "sbmrgPrevntBizSn,desc",
|
||||
});
|
||||
// 일련번호를 추가한 데이터로 변환 (전체 데이터 기준으로 일련번호 계산)
|
||||
const dataWithRowNumber = (data || []).map(
|
||||
(item: any, index: number) => ({
|
||||
...item,
|
||||
rowNumber: getStandardRowNumber({
|
||||
total,
|
||||
rowIndex: index,
|
||||
pageIndex: page - 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
setTableData(dataWithRowNumber);
|
||||
setTotalData(total);
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
} finally {
|
||||
setFileNm("");
|
||||
setAtflId("");
|
||||
setSbmrgPrevntBizSn("");
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 파일 다운로드
|
||||
const isChckFile = () => {
|
||||
if (fileNm === '' || fileNm === undefined) {
|
||||
alert('파일을 선택해주세요')
|
||||
return false
|
||||
if (fileNm === "" || fileNm === undefined) {
|
||||
alert("파일을 선택해주세요");
|
||||
return false;
|
||||
}
|
||||
handleDownload(`/attach-service/api/v1/download/${atflId}`, fileNm);
|
||||
}
|
||||
};
|
||||
|
||||
// 선택 파일 삭제
|
||||
const isDeleteFile = async () => {
|
||||
if (sbmrgPrevntBizSn === '' || sbmrgPrevntBizSn === undefined) {
|
||||
alert('삭제할 파일을 선택해주세요')
|
||||
return false
|
||||
if (sbmrgPrevntBizSn === "" || sbmrgPrevntBizSn === undefined) {
|
||||
alert("삭제할 파일을 선택해주세요");
|
||||
return false;
|
||||
}
|
||||
const data = await deleteData(`/attach-service/api/v1/atfl/attachments/${atflId}`)
|
||||
if (!(await confirm("삭제하시겠습니까?"))) return false;
|
||||
const data = await deleteData(
|
||||
`/attach-service/api/v1/atfl/attachments/${atflId}`,
|
||||
);
|
||||
if (data) {
|
||||
const delData = await deleteData(`v1/sbmrgPrevnt/${sbmrgPrevntBizSn}`)
|
||||
const delData = await deleteData(`v1/sbmrgPrevnt/${sbmrgPrevntBizSn}`);
|
||||
if (delData.status === 200) {
|
||||
alert('삭제되었습니다.')
|
||||
fetchData()
|
||||
}
|
||||
}
|
||||
else {
|
||||
alert('삭제에 실패했습니다.')
|
||||
alert("삭제되었습니다.", () => void fetchData());
|
||||
}
|
||||
} else {
|
||||
alert("삭제에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 등록 버튼 클릭 핸들러
|
||||
const handleRegisterClick = () => {
|
||||
const popupWindow = openWindowPopup("floodPreUpload");
|
||||
if (!popupWindow) {
|
||||
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
|
||||
}
|
||||
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTablePopupOpen = (fileName: string, id: string) => {
|
||||
const popupWindow = openWindowPopup("floodPreTable", {
|
||||
@@ -180,101 +257,100 @@ const Page = () => {
|
||||
});
|
||||
|
||||
if (!popupWindow) {
|
||||
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
|
||||
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadingOverlay isLoading={isLoading ?? false} />
|
||||
<SearchDiv>
|
||||
<SearchFormLeft>
|
||||
<div className='flex items-center gap-5'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label className="w-20 text-xl text-gray-600">등록기간</Label>
|
||||
<div className="card card-border !mt-6">
|
||||
<form
|
||||
className="flex flex-wrap items-center gap-2 p-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleSearch();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="mr-2 whitespace-nowrap text-label">
|
||||
등록기간
|
||||
</Label>
|
||||
<SearchDatePicker
|
||||
selected={fromDate}
|
||||
onSelect={(date: Date | undefined) => { if (date) setFromDate(date) }}
|
||||
onSelect={(date: Date | undefined) => {
|
||||
if (date) setFromDate(date);
|
||||
}}
|
||||
disabled={{ after: new Date() }}
|
||||
/>
|
||||
<span>~</span>
|
||||
<SearchDatePicker
|
||||
selected={toDate}
|
||||
onSelect={(date: Date | undefined) => { if (date) setToDate(date) }}
|
||||
onSelect={(date: Date | undefined) => {
|
||||
if (date) setToDate(date);
|
||||
}}
|
||||
disabled={{ after: new Date() }}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center'>
|
||||
<Label className="w-20 text-xl text-gray-600">등록자</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="mr-2 whitespace-nowrap text-label">등록자</Label>
|
||||
<Input
|
||||
className='w-[100px]'
|
||||
className="w-[12rem] !text-[1.3rem]"
|
||||
value={usr}
|
||||
onChange={(e) => setUsr(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault(); // Enter 키 기본 동작 방지
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
onChange={(event) => setUsr(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center'>
|
||||
<Label className="w-20 text-xl text-gray-600">파일명</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="mr-2 whitespace-nowrap text-label">파일명</Label>
|
||||
<Input
|
||||
className='w-[100px]'
|
||||
className="w-[18rem] !text-[1.3rem]"
|
||||
value={pjNm}
|
||||
onChange={(e) => setpjNm(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault(); // Enter 키 기본 동작 방지
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
onChange={(event) => setpjNm(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-[7.4rem]">
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
className="w-[8.8rem]"
|
||||
onClick={() => void handleReset()}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</SearchFormLeft>
|
||||
<SearchFormRight>
|
||||
<div className='flex items-center gap-5'>
|
||||
<Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" onClick={async () => await handleSearch()} >조회</Button>
|
||||
<Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" onClick={handleRegisterClick} >등록</Button>
|
||||
</div>
|
||||
</SearchFormRight>
|
||||
</SearchDiv>
|
||||
<ListDiv className="mt-4">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="text-2xl font-medium">사업정보 등록현황</h3>
|
||||
<div>
|
||||
<Button className="px-4 py-2 !mr-[1.2rem] px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium green" size="sm" onClick={isChckFile} >파일 다운로드</Button>
|
||||
<Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 !mr-[1.2rem]" size="sm" onClick={isDeleteFile} >삭제</Button>
|
||||
</div>
|
||||
</div>
|
||||
<UiTable
|
||||
headName=''
|
||||
tableData={tableData}
|
||||
headlist={tableHeadlist}
|
||||
pageSize={10}
|
||||
total={totalData}
|
||||
cellClick={(index) => {
|
||||
if (index != null) {
|
||||
const fileName = tableData[index]?.sbmrgPrevntBizNm ?? "";
|
||||
const id = tableData[index]?.atflId ?? '';
|
||||
|
||||
<div className="mt-4 w-full min-w-0 max-w-full overflow-hidden">
|
||||
<StandardDataTable
|
||||
table={table}
|
||||
title="도시침수예방사업 목록"
|
||||
onRowClick={(row) => {
|
||||
const fileName = String(row.sbmrgPrevntBizNm ?? "");
|
||||
const id = String(row.atflId ?? "");
|
||||
setFileNm(fileName);
|
||||
setAtflId(id);
|
||||
setSbmrgPrevntBizSn(tableData[index]?.sbmrgPrevntBizSn);
|
||||
setSbmrgPrevntBizSn(String(row.sbmrgPrevntBizSn ?? ""));
|
||||
handleTablePopupOpen(fileName, id);
|
||||
}
|
||||
}}
|
||||
onPageChange={setCurPage}
|
||||
resetSelection={resetSelection}
|
||||
resetPageNo={isResetPage}
|
||||
sort={sort}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
</ListDiv>
|
||||
actions={
|
||||
<>
|
||||
<Button type="button" onClick={isChckFile}>
|
||||
엑셀다운로드
|
||||
</Button>
|
||||
<Button type="button" onClick={handleRegisterClick}>
|
||||
등록
|
||||
</Button>
|
||||
<Button type="button" color="red" onClick={isDeleteFile}>
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page
|
||||
export default Page;
|
||||
|
||||
@@ -1,291 +1,292 @@
|
||||
/**
|
||||
* 📄 Table Modal
|
||||
* 침수예방 엑셀 데이터 테이블 모달 컴포넌트
|
||||
* 침수예방 엑셀 데이터 테이블 팝업
|
||||
*
|
||||
* - 엑셀 파일 다운로드 및 특정 시트 파싱 기능
|
||||
* - 탭 기반 테이블 표시 (관리SHEET, 운영관리현황 점검결과)
|
||||
* - 데이터 필터링 및 빈 행 제거 처리
|
||||
* - 로딩 오버레이 및 에러 핸들링 지원
|
||||
* - XLSX 라이브러리를 통한 엑셀 데이터 변환
|
||||
* - 테이블 헤더 및 데이터 동적 생성
|
||||
*
|
||||
* 🔗 사용 API
|
||||
* - 첨부파일 다운로드(파일ID): GET /attach-service/api/v1/download/{atflId} (attach-service)
|
||||
* 첨부 엑셀의 관리SHEET와 운영관리현황 점검결과를 읽어 공통 표준
|
||||
* 테이블로 표시한다. 원본 엑셀의 열 순서와 값은 그대로 유지한다.
|
||||
*/
|
||||
'use client'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import LoadingOverlay from '@/components/ui/LoadingBar'
|
||||
import { UITab, UiTable } from '@common/business_components/ui'
|
||||
import * as XLSX from 'xlsx';
|
||||
import { getBlobData } from '@/components/lib/api';
|
||||
import type { UiTableSort } from '@common/business_components/ui';
|
||||
import { makeSortableHeaders, sortLocalTableRows } from '../tableSort';
|
||||
// import { Table, TableBody, TableHeader } from "@common/components/ui";
|
||||
// import { HeadMakeFloodPre } from './headMakeFloodPre';
|
||||
// import { InputTableBody } from './inputTableBody';
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
import { getBlobData } from "@/components/lib/api";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { StandardDataTable } from "@common/business_components";
|
||||
import { UITab } from "@common/business_components/ui";
|
||||
|
||||
const TABS = [
|
||||
{ val: "management", text: "관리SHEET" },
|
||||
{ val: "Inspection results", text: "운영관리현황 점검결과" },
|
||||
{ val: "inspection-results", text: "운영관리현황 점검결과" },
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
type ExcelTableHeader = {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type ExcelTableRow = Record<string, unknown>;
|
||||
|
||||
interface TableModalProps {
|
||||
fileNm: string;
|
||||
atflId: string;
|
||||
}
|
||||
|
||||
const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
|
||||
function normalizeHeaderTitle(value: unknown) {
|
||||
const title = String(value).replace(/\r\n/g, "\n");
|
||||
return /^no$/i.test(title.trim()) ? "순번" : title;
|
||||
}
|
||||
|
||||
const [activeTab, setActiveTab] = useState(TABS[0]?.val ?? "management");
|
||||
const [managementTableHeader, setManagementTableHeader] = useState<{ id: string; title: string }[]>([]) //테이블 헤더
|
||||
const [managementTableData, setManagementTableData] = useState<any>([{}]) //테이블 데이터
|
||||
const [resultTableHeader, setResultTableHeader] = useState<{ id: string; title: string, mergeCell?: boolean }[]>([]) //테이블 헤더
|
||||
const [resultTableData, setResultTableData] = useState<any>([{}]) //테이블 데이터
|
||||
const [isLoading, setIsLoading] = useState(false) //로딩 상태
|
||||
const [managementSort, setManagementSort] = useState<UiTableSort>();
|
||||
const [resultSort, setResultSort] = useState<UiTableSort>();
|
||||
|
||||
const sortedManagementTableData = useMemo(
|
||||
() => sortLocalTableRows(managementTableData, managementSort),
|
||||
[managementSort, managementTableData],
|
||||
);
|
||||
const sortedResultTableData = useMemo(
|
||||
() => sortLocalTableRows(resultTableData, resultSort),
|
||||
[resultSort, resultTableData],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
isChckFile();
|
||||
}, []);
|
||||
|
||||
// 파일 다운로드
|
||||
const isChckFile = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (fileNm === '' || fileNm === undefined) {
|
||||
alert('파일을 선택해주세요')
|
||||
setIsLoading(false);
|
||||
return false
|
||||
}
|
||||
|
||||
if (atflId === '' || atflId === undefined) {
|
||||
alert('첨부파일 ID가 없어 파일을 불러올 수 없습니다.');
|
||||
setIsLoading(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const blob = await getBlobData(`/attach-service/api/v1/download/${atflId}`);
|
||||
|
||||
const file = new File([blob], fileNm, {
|
||||
type: blob.type || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
});
|
||||
|
||||
const targetSheets = ['관리SHEET', '운영관리현황 점검결과'];
|
||||
const jsonDataBySheet = await filePickedSelectedSheets(file, 0, targetSheets);
|
||||
|
||||
// 관리 SHEET
|
||||
const managementHeader = jsonDataBySheet['관리SHEET']?.[7] || [];
|
||||
const filteredManagementHeader = Object.entries(managementHeader)
|
||||
.filter(([key, value]) => {
|
||||
const match = key.match(/^__EMPTY_(\d+)$/);
|
||||
if (match) {
|
||||
const index = parseInt(match[1] ?? '', 10);
|
||||
return (0 < index && index < 47) || (73 < index);
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.map(([key, value]) => ({
|
||||
id: key,
|
||||
title: String(value).replace(/\r\n/g, '\n')
|
||||
function createColumns(
|
||||
headers: ExcelTableHeader[],
|
||||
): ColumnDef<ExcelTableRow>[] {
|
||||
return headers.map((header) => ({
|
||||
id: header.id,
|
||||
accessorFn: (row) => row[header.id],
|
||||
header: header.title,
|
||||
cell: ({ getValue }) => String(getValue() ?? "-"),
|
||||
size: 140,
|
||||
}));
|
||||
}
|
||||
|
||||
setManagementTableHeader(makeSortableHeaders(filteredManagementHeader));
|
||||
|
||||
const managementData = Array.isArray(jsonDataBySheet['관리SHEET'])
|
||||
? jsonDataBySheet['관리SHEET'].slice(8)
|
||||
: [];
|
||||
const filteredManagementData = filterEmptyRows(managementData);
|
||||
setManagementTableData(filteredManagementData);
|
||||
|
||||
// 운영관리현황 점검결과
|
||||
const resultHeader = jsonDataBySheet['운영관리현황 점검결과']?.[2] || [];
|
||||
const dynamicResultHeader = Object.entries(resultHeader)
|
||||
.map(([key, value]) => ({
|
||||
id: key,
|
||||
title: String(value).replace(/\r\n/g, '\n'),
|
||||
mergeCell: false,
|
||||
...(key === '__EMPTY_3' && { mergeCell: true }) // __EMPTY_3인 경우 mergeCell: true 추가
|
||||
}));
|
||||
dynamicResultHeader.splice(1, 0, {
|
||||
id: "__EMPTY_2",
|
||||
title: '',
|
||||
mergeCell: false
|
||||
});
|
||||
setResultTableHeader(makeSortableHeaders(dynamicResultHeader));
|
||||
|
||||
const resultData = Array.isArray(jsonDataBySheet['운영관리현황 점검결과'])
|
||||
? jsonDataBySheet['운영관리현황 점검결과'].slice(3)
|
||||
: [];
|
||||
const filteredResultData = filterEmptyRows(resultData);
|
||||
setResultTableData(filteredResultData);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('FloodPre attachment fetch details:', { atflId, fileNm, message });
|
||||
console.error('파일 처리 중 오류 발생:', error);
|
||||
alert('파일 처리 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function filterEmptyRows(data: any[]) {
|
||||
function filterEmptyRows(data: ExcelTableRow[]) {
|
||||
return data
|
||||
.filter(row => {
|
||||
return Object.entries(row).some(([key, value]) => {
|
||||
.filter((row) =>
|
||||
Object.entries(row).some(([key, value]) => {
|
||||
const match = key.match(/^__EMPTY_(\d+)$/);
|
||||
if (!match || parseInt(match[1] ?? '', 10) < 2) return false;
|
||||
if (!match || parseInt(match[1] ?? "", 10) < 2) return false;
|
||||
|
||||
return value !== undefined &&
|
||||
return (
|
||||
value !== undefined &&
|
||||
value !== null &&
|
||||
value !== '' &&
|
||||
value !== '-' &&
|
||||
String(value).trim() !== '';
|
||||
});
|
||||
})
|
||||
value !== "" &&
|
||||
value !== "-" &&
|
||||
String(value).trim() !== ""
|
||||
);
|
||||
}),
|
||||
)
|
||||
.map((row) => {
|
||||
const processedRow = { ...row };
|
||||
const keys = Object.keys(processedRow);
|
||||
|
||||
keys.forEach((key, keyIndex) => {
|
||||
// 마지막 속성(비고)이 아닌 경우에만 빈 값을 '-'으로 변경
|
||||
if (keyIndex !== keys.length - 1) {
|
||||
if (keyIndex === keys.length - 1) return;
|
||||
|
||||
const value = processedRow[key];
|
||||
if (value === undefined ||
|
||||
if (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
value === '' ||
|
||||
String(value).trim() === '') {
|
||||
processedRow[key] = '-';
|
||||
}
|
||||
value === "" ||
|
||||
String(value).trim() === ""
|
||||
) {
|
||||
processedRow[key] = "-";
|
||||
}
|
||||
});
|
||||
|
||||
return processedRow;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// 특정 시트만 선택적으로 JSON으로 변환하는 함수
|
||||
function filePickedSelectedSheets(file: File, headerNum: number = 0, sheetNames: string[]): Promise<Record<string, any[]>> {
|
||||
function filePickedSelectedSheets(
|
||||
file: File,
|
||||
headerNum: number = 0,
|
||||
sheetNames: string[],
|
||||
): Promise<Record<string, ExcelTableRow[]>> {
|
||||
const reader = new FileReader();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
reader.onload = function (e) {
|
||||
const data = e.target?.result;
|
||||
reader.onload = (event) => {
|
||||
const data = event.target?.result;
|
||||
if (!data) {
|
||||
console.error("Failed to read file: e.target.result is null");
|
||||
reject(new Error("Failed to read file"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Excel 파일 읽기
|
||||
const wb = XLSX.read(data, { type: 'binary', cellDates: true });
|
||||
const result: Record<string, any[]> = {};
|
||||
const workbook = XLSX.read(data, { type: "binary", cellDates: true });
|
||||
const result: Record<string, ExcelTableRow[]> = {};
|
||||
|
||||
// 지정된 시트 이름만 찾아서 처리
|
||||
sheetNames.forEach(targetSheetName => {
|
||||
// 대소문자 구분 없이 시트 이름 찾기
|
||||
const sheetName = wb.SheetNames.find(name =>
|
||||
name.toLowerCase() === targetSheetName.toLowerCase()
|
||||
sheetNames.forEach((targetSheetName) => {
|
||||
const sheetName = workbook.SheetNames.find(
|
||||
(name) => name.toLowerCase() === targetSheetName.toLowerCase(),
|
||||
);
|
||||
const sheet = sheetName ? workbook.Sheets[sheetName] : undefined;
|
||||
|
||||
if (sheetName) {
|
||||
const sheet = wb.Sheets[sheetName];
|
||||
if (sheet) {
|
||||
const jsonData = XLSX.utils.sheet_to_json(sheet, { range: headerNum, raw: false });
|
||||
result[targetSheetName] = jsonData;
|
||||
}
|
||||
} else {
|
||||
if (!sheet) {
|
||||
console.warn(`Sheet "${targetSheetName}" not found in workbook`);
|
||||
result[targetSheetName] = [];
|
||||
return;
|
||||
}
|
||||
|
||||
result[targetSheetName] = XLSX.utils.sheet_to_json<ExcelTableRow>(
|
||||
sheet,
|
||||
{
|
||||
range: headerNum,
|
||||
raw: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
reader.onerror = function () {
|
||||
console.error("Error reading file");
|
||||
reject(new Error("Error reading file"));
|
||||
};
|
||||
|
||||
// 파일 읽기 시작
|
||||
reader.onerror = () => reject(new Error("Error reading file"));
|
||||
reader.readAsBinaryString(file);
|
||||
});
|
||||
}
|
||||
|
||||
const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [activeTab, setActiveTab] = useState(TABS[0]?.val ?? "management");
|
||||
const [managementHeaders, setManagementHeaders] = useState<
|
||||
ExcelTableHeader[]
|
||||
>([]);
|
||||
const [managementData, setManagementData] = useState<ExcelTableRow[]>([]);
|
||||
const [resultHeaders, setResultHeaders] = useState<ExcelTableHeader[]>([]);
|
||||
const [resultData, setResultData] = useState<ExcelTableRow[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadFile = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
if (!fileNm) {
|
||||
alert("파일을 선택해주세요");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!atflId) {
|
||||
alert("첨부파일 ID가 없어 파일을 불러올 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await getBlobData(
|
||||
`/attach-service/api/v1/download/${atflId}`,
|
||||
);
|
||||
const file = new File([blob], fileNm, {
|
||||
type:
|
||||
blob.type ||
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
const jsonDataBySheet = await filePickedSelectedSheets(file, 0, [
|
||||
"관리SHEET",
|
||||
"운영관리현황 점검결과",
|
||||
]);
|
||||
|
||||
const managementHeaderRow = jsonDataBySheet["관리SHEET"]?.[7] ?? {};
|
||||
setManagementHeaders(
|
||||
Object.entries(managementHeaderRow)
|
||||
.filter(([key]) => {
|
||||
const match = key.match(/^__EMPTY_(\d+)$/);
|
||||
if (!match) return false;
|
||||
|
||||
const index = parseInt(match[1] ?? "", 10);
|
||||
return (index > 0 && index < 47) || index > 73;
|
||||
})
|
||||
.map(([key, value]) => ({
|
||||
id: key,
|
||||
title: normalizeHeaderTitle(value),
|
||||
})),
|
||||
);
|
||||
setManagementData(
|
||||
filterEmptyRows((jsonDataBySheet["관리SHEET"] ?? []).slice(8)),
|
||||
);
|
||||
|
||||
const resultHeaderRow =
|
||||
jsonDataBySheet["운영관리현황 점검결과"]?.[2] ?? {};
|
||||
const nextResultHeaders = Object.entries(resultHeaderRow).map(
|
||||
([key, value]) => ({
|
||||
id: key,
|
||||
title: normalizeHeaderTitle(value),
|
||||
}),
|
||||
);
|
||||
nextResultHeaders.splice(1, 0, { id: "__EMPTY_2", title: "" });
|
||||
setResultHeaders(nextResultHeaders);
|
||||
setResultData(
|
||||
filterEmptyRows(
|
||||
(jsonDataBySheet["운영관리현황 점검결과"] ?? []).slice(3),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("FloodPre attachment fetch details:", {
|
||||
atflId,
|
||||
fileNm,
|
||||
error,
|
||||
});
|
||||
alert("파일 처리 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadFile();
|
||||
}, [alert, atflId, fileNm]);
|
||||
|
||||
const managementColumns = useMemo(
|
||||
() => createColumns(managementHeaders),
|
||||
[managementHeaders],
|
||||
);
|
||||
const resultColumns = useMemo(
|
||||
() => createColumns(resultHeaders),
|
||||
[resultHeaders],
|
||||
);
|
||||
const managementTable = useReactTable({
|
||||
data: managementData,
|
||||
columns: managementColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: PAGE_SIZE } },
|
||||
});
|
||||
const resultTable = useReactTable({
|
||||
data: resultData,
|
||||
columns: resultColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: PAGE_SIZE } },
|
||||
});
|
||||
|
||||
const isManagementTab = activeTab === "management";
|
||||
const activeHeaderCount = isManagementTab
|
||||
? managementHeaders.length
|
||||
: resultHeaders.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 탭 영역 */}
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<UITab
|
||||
defaultValue={activeTab}
|
||||
tabList={TABS.map((tab) => ({
|
||||
value: tab.val,
|
||||
label: tab.text
|
||||
label: tab.text,
|
||||
}))}
|
||||
onValueChange={(value: string) => setActiveTab(value)}
|
||||
onValueChange={setActiveTab}
|
||||
/>
|
||||
{/* 관리SHEET 탭 */}
|
||||
{activeTab === 'management' && (
|
||||
<div className="h-[79vh] rounded-lg border flex flex-col">
|
||||
<div className="flex-1 min-w-[50vw] max-w-[100vw] overflow-auto">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<div className="min-w-max">
|
||||
<UiTable
|
||||
headName={'관리SHEET'}
|
||||
tableData={sortedManagementTableData}
|
||||
headlist={managementTableHeader}
|
||||
pageSize={10}
|
||||
total={0}
|
||||
cellClick={undefined}
|
||||
onPageChange={() => { }}
|
||||
className="whitespace-nowrap"
|
||||
sort={managementSort}
|
||||
onSortChange={setManagementSort}
|
||||
|
||||
{isManagementTab ? (
|
||||
<StandardDataTable
|
||||
table={managementTable}
|
||||
title="관리SHEET 목록"
|
||||
loading={isLoading}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth={`${Math.max(activeHeaderCount * 9, 80)}rem`}
|
||||
/>
|
||||
) : (
|
||||
<StandardDataTable
|
||||
table={resultTable}
|
||||
title="운영관리현황 점검결과 목록"
|
||||
loading={isLoading}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth={`${Math.max(activeHeaderCount * 9, 80)}rem`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 운영관리현황 점검결과 탭 */}
|
||||
{activeTab === 'Inspection results' && (
|
||||
<div className="h-[79vh] w-full overflow-x-hidden overflow-y-auto rounded-lg border">
|
||||
<div className="flex-1 px-2 pb-2">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<UiTable
|
||||
headName={'운영관리현황 점검결과'}
|
||||
tableData={sortedResultTableData}
|
||||
headlist={resultTableHeader}
|
||||
pageSize={10}
|
||||
total={0}
|
||||
className={"overflow-x-auto overflow-y-auto"}
|
||||
cellClick={undefined}
|
||||
onPageChange={() => { }}
|
||||
sort={resultSort}
|
||||
onSortChange={setResultSort}
|
||||
/>
|
||||
{/* <Table variant="secondary">
|
||||
<TableHeader className="font-bold">
|
||||
{HeadMakeFloodPre(resultTableHeader, [])}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{InputTableBody(resultTableData ?? [], resultTableHeader, setResultTableData, false)}
|
||||
</TableBody>
|
||||
</Table> */}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default TableModal;
|
||||
|
||||
@@ -1,277 +1,331 @@
|
||||
/**
|
||||
* ➡️ 경로 : 유지관리 정보 - 노후 하수관로 정비사업
|
||||
* 유지관리 정보 - 노후 하수관로 정비사업 파일 목록
|
||||
*
|
||||
* 📄 OldPp Page
|
||||
* 노후 하수관로 정비사업 추진현황 페이지 컴포넌트
|
||||
*
|
||||
* - 사업 정보 등록 현황 조회 및 테이블 표시
|
||||
* - 페이징 및 검색 기능 (등록기간/등록자/파일명 기반)
|
||||
* - 파일 업로드 및 등록 기능
|
||||
* - 파일 다운로드 및 삭제 기능
|
||||
* - 엑셀 데이터 테이블 모달 표시
|
||||
* - 로딩 오버레이 및 에러 핸들링 지원
|
||||
*
|
||||
* 🔗 사용 API
|
||||
* - 노후관로정비사업 목록 조회: GET /api/v1/dcrpPpl (sewerage-service)
|
||||
* - 노후관로정비사업 목록 등록: POST /api/v1/dcrpPpl (sewerage-service)
|
||||
* - 첨부파일 삭제및 Soft Delete 삭제: DELETE /api/v1/atfl/attachments/{atflId} (attach-service)
|
||||
* 목록 조회, 첨부파일 다운로드·삭제, 업로드·상세 팝업 연결을 제공한다.
|
||||
*/
|
||||
'use client'
|
||||
import { deleteData, useCookieAuth } from '@/components/lib/api'
|
||||
import { searchData } from '@/components/lib/common/apiHelpers'
|
||||
import { formatDate, handleDownload } from '@/components/lib/utils'
|
||||
import LoadingOverlay from '@/components/ui/LoadingBar'
|
||||
import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui'
|
||||
import ListDiv from '@common/business_components/ui/list_div'
|
||||
import { Button, Input, Label } from '@common/components/ui'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { tableHeadlistOldPp } from '..'
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ENDPOINT } from "@common/components/config";
|
||||
import { getUserInfo } from "@common/components/api";
|
||||
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
|
||||
import { SearchDatePicker } from '../components/SearchDatePicker';
|
||||
import type { UiTableSort } from '@common/business_components/ui';
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type PaginationState,
|
||||
} from "@tanstack/react-table";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
|
||||
import { deleteData, useCookieAuth } from "@/components/lib/api";
|
||||
import { searchData } from "@/components/lib/common/apiHelpers";
|
||||
import { formatDate, handleDownload } from "@/components/lib/utils";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import {
|
||||
getStandardRowNumber,
|
||||
StandardDataTable,
|
||||
} from "@common/business_components";
|
||||
import { Button, Input, Label } from "@common/components";
|
||||
import { SearchDatePicker } from "../components/SearchDatePicker";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
type OldPpRow = Record<string, unknown> & {
|
||||
rowNumber: number;
|
||||
dcrpPplMtncBizSn: string;
|
||||
dcrpPplMtncBizNm?: string;
|
||||
atflId?: string;
|
||||
};
|
||||
|
||||
type OldPpSearch = {
|
||||
fromDate: Date;
|
||||
toDate: Date;
|
||||
projectName: string;
|
||||
};
|
||||
|
||||
const COLUMNS: Array<{
|
||||
id: keyof OldPpRow;
|
||||
title: string;
|
||||
width: number;
|
||||
}> = [
|
||||
{ id: "rowNumber", title: "순번", width: 70 },
|
||||
{ id: "dcrpPplMtncBizNm", title: "파일명", width: 260 },
|
||||
{ id: "mngYr", title: "년도", width: 90 },
|
||||
{ id: "mngQu", title: "분기", width: 90 },
|
||||
{ id: "regDt", title: "등록 일자", width: 120 },
|
||||
{ id: "rgtrNm", title: "등록자", width: 120 },
|
||||
];
|
||||
|
||||
const Page = () => {
|
||||
const AUTH = useCookieAuth();
|
||||
|
||||
const [fromDate, setFromDate] = useState<Date>(() => { const date = new Date(); date.setDate(date.getDate() - 7); return date });
|
||||
const [toDate, setToDate] = useState<Date>(new Date());
|
||||
const [usr, setUsr] = useState('')
|
||||
const [pjNm, setpjNm] = useState('')
|
||||
const [fileNm, setFileNm] = useState('')
|
||||
const [atflId, setAtflId] = useState('')
|
||||
const [dcrpPplMtncBizSn, setDcrpPplMtncBizSn] = useState('')
|
||||
const [tableData, setTableData] = useState<any>([{}])
|
||||
const [curPage, setCurPage] = useState(1)
|
||||
const [totalData, setTotalData] = useState(0) //총 데이터 수
|
||||
const [isLoading, setIsLoading] = useState(true) //로딩 상태
|
||||
const [resetSelection, setResetSelection] = useState<boolean>(false); // 선택 초기화 상태
|
||||
const [isResetPage, setIsResetPage] = useState<boolean>(false);
|
||||
const [sort, setSort] = useState<UiTableSort>();
|
||||
|
||||
const { data: userInfo } = useQuery({
|
||||
queryKey: [ENDPOINT.USER_SERVICE.USERS],
|
||||
queryFn: () => getUserInfo(),
|
||||
enabled: true,
|
||||
const auth = useCookieAuth();
|
||||
const { confirm } = useConfirm();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [fromDate, setFromDate] = useState(() => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - 7);
|
||||
return date;
|
||||
});
|
||||
const [toDate, setToDate] = useState(new Date());
|
||||
const [registrant, setRegistrant] = useState("");
|
||||
const [projectName, setProjectName] = useState("");
|
||||
const [fileName, setFileName] = useState("");
|
||||
const [attachmentId, setAttachmentId] = useState("");
|
||||
const [businessId, setBusinessId] = useState("");
|
||||
const [rows, setRows] = useState<OldPpRow[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const columns = useMemo<ColumnDef<OldPpRow>[]>(
|
||||
() =>
|
||||
COLUMNS.map((column) => ({
|
||||
id: String(column.id),
|
||||
accessorFn: (row) => row[column.id],
|
||||
header: column.title,
|
||||
cell: ({ getValue }) => String(getValue() ?? ""),
|
||||
meta: { width: column.width, align: "center" },
|
||||
})),
|
||||
[],
|
||||
);
|
||||
const pagination: PaginationState = {
|
||||
pageIndex: page - 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
};
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
state: { pagination },
|
||||
rowCount: total,
|
||||
manualPagination: true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (row) => String(row.dcrpPplMtncBizSn),
|
||||
onPaginationChange: (updater) => {
|
||||
const next =
|
||||
typeof updater === "function" ? updater(pagination) : updater;
|
||||
setPage(next.pageIndex + 1);
|
||||
},
|
||||
});
|
||||
|
||||
const fetchData = async (
|
||||
targetPage = page,
|
||||
condition: OldPpSearch = { fromDate, toDate, projectName },
|
||||
) => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await searchData("v1/dcrpPpl", {
|
||||
fromRegDt: formatDate(condition.fromDate),
|
||||
toRegDt: formatDate(condition.toDate),
|
||||
dcrpPplMtncBizNm: condition.projectName,
|
||||
auth,
|
||||
page: targetPage - 1,
|
||||
size: String(PAGE_SIZE),
|
||||
sort: "dcrpPplMtncBizSn,desc",
|
||||
});
|
||||
setRows(
|
||||
(result.data ?? []).map((item: OldPpRow, rowIndex: number) => ({
|
||||
...item,
|
||||
rowNumber: getStandardRowNumber({
|
||||
total: result.total,
|
||||
rowIndex,
|
||||
pageIndex: targetPage - 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
}),
|
||||
})),
|
||||
);
|
||||
setTotal(result.total);
|
||||
} catch (error) {
|
||||
console.error("노후 하수관로 정비사업 목록 조회 실패:", error);
|
||||
} finally {
|
||||
setFileName("");
|
||||
setAttachmentId("");
|
||||
setBusinessId("");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void fetchData();
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.OLD_PP_UPLOAD_COMPLETED) return;
|
||||
handleSearch();
|
||||
if (
|
||||
event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.OLD_PP_UPLOAD_COMPLETED
|
||||
)
|
||||
return;
|
||||
|
||||
if (page === 1) void fetchData(1);
|
||||
else setPage(1);
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
}, [fromDate, toDate, pjNm, curPage, sort]);
|
||||
}, [fromDate, page, projectName, toDate]);
|
||||
|
||||
// 페이지 변경 시마다 데이터 fetch
|
||||
useEffect(() => {
|
||||
setIsResetPage(false);
|
||||
fetchData();
|
||||
}, [curPage]);
|
||||
|
||||
// 조회 버튼 클릭 시 1페이지로 이동 및 데이터 초기화
|
||||
const handleSearch: () => Promise<void> = async () => {
|
||||
setIsResetPage(true); // 페이지를 초기화
|
||||
if (curPage === 1) {
|
||||
await fetchData(1, sort);
|
||||
const handleSearch = async () => {
|
||||
if (page === 1) {
|
||||
await fetchData(1);
|
||||
return;
|
||||
}
|
||||
setCurPage(1); // 페이지 변경 effect에서 한 번만 조회
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
// 데이터 조회
|
||||
const fetchData = async (page = curPage, activeSort = sort) => {
|
||||
setIsLoading(true)
|
||||
setResetSelection(true);
|
||||
try {
|
||||
const { data, total } = await searchData('v1/dcrpPpl', {
|
||||
fromRegDt: formatDate(fromDate),
|
||||
toRegDt: formatDate(toDate),
|
||||
dcrpPplMtncBizNm: pjNm,
|
||||
auth: AUTH,
|
||||
page: page - 1,
|
||||
size: '10',
|
||||
sort: activeSort ? `${activeSort.key},${activeSort.direction}` : undefined,
|
||||
})
|
||||
// 일련번호를 추가한 데이터로 변환 (전체 데이터 기준으로 일련번호 계산)
|
||||
const descendingSequence =
|
||||
activeSort?.key === 'dcrpPplMtncBizSn' && activeSort.direction === 'desc';
|
||||
const dataWithRowNumber = (data || []).map((item: any, index: number) => ({
|
||||
...item,
|
||||
rowNumber: descendingSequence
|
||||
? total - ((page - 1) * 10 + index)
|
||||
: (page - 1) * 10 + index + 1,
|
||||
}));
|
||||
setTableData(dataWithRowNumber)
|
||||
setTotalData(total)
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error)
|
||||
} finally {
|
||||
setFileNm('');
|
||||
setAtflId('');
|
||||
setDcrpPplMtncBizSn('');
|
||||
setIsLoading(false)
|
||||
setResetSelection(false);
|
||||
}
|
||||
}
|
||||
const handleReset = async () => {
|
||||
const nextFromDate = new Date();
|
||||
nextFromDate.setDate(nextFromDate.getDate() - 7);
|
||||
const nextToDate = new Date();
|
||||
setFromDate(nextFromDate);
|
||||
setToDate(nextToDate);
|
||||
setRegistrant("");
|
||||
setProjectName("");
|
||||
|
||||
const handleSortChange = (nextSort?: UiTableSort) => {
|
||||
setSort(nextSort);
|
||||
setIsResetPage(true);
|
||||
setResetSelection(true);
|
||||
if (curPage === 1) {
|
||||
void fetchData(1, nextSort);
|
||||
if (page !== 1) {
|
||||
setPage(1);
|
||||
return;
|
||||
}
|
||||
setCurPage(1);
|
||||
};
|
||||
|
||||
// 파일 다운로드
|
||||
const isChckFile = () => {
|
||||
if (fileNm === '' || fileNm === undefined) {
|
||||
alert('파일을 선택해주세요')
|
||||
return false
|
||||
}
|
||||
handleDownload(`/attach-service/api/v1/download/${atflId}`, fileNm)
|
||||
return true
|
||||
}
|
||||
|
||||
// 파일 삭제
|
||||
const isDeleteFile = async () => {
|
||||
if (dcrpPplMtncBizSn === '' || dcrpPplMtncBizSn === undefined) {
|
||||
alert('삭제할 파일을 선택해주세요')
|
||||
return false
|
||||
}
|
||||
const data = await deleteData(`/attach-service/api/v1/atfl/attachments/${atflId}`)
|
||||
if (data) {
|
||||
const delData = await deleteData(`v1/dcrpPpl/${dcrpPplMtncBizSn}`)
|
||||
if (delData.status == 200) {
|
||||
alert('삭제되었습니다.')
|
||||
fetchData()
|
||||
} else {
|
||||
alert('삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 등록 버튼 클릭 핸들러
|
||||
const handleRegisterClick = () => {
|
||||
const popupWindow = openWindowPopup("oldPpUpload");
|
||||
if (!popupWindow) {
|
||||
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
|
||||
}
|
||||
}
|
||||
|
||||
// 엑셀 데이터는 브라우저 윈도우 팝업에서 독립 조회
|
||||
const handleTablePopupOpen = (fileName: string, id: string) => {
|
||||
const popupWindow = openWindowPopup("oldPpTable", {
|
||||
fileNm: fileName,
|
||||
atflId: id,
|
||||
sourcePath: window.location.pathname,
|
||||
await fetchData(1, {
|
||||
fromDate: nextFromDate,
|
||||
toDate: nextToDate,
|
||||
projectName: "",
|
||||
});
|
||||
};
|
||||
|
||||
if (!popupWindow) {
|
||||
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
|
||||
const handleFileDownload = () => {
|
||||
if (!fileName) {
|
||||
alert("파일을 선택해주세요");
|
||||
return;
|
||||
}
|
||||
handleDownload(`/attach-service/api/v1/download/${attachmentId}`, fileName);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!businessId) {
|
||||
alert("삭제할 파일을 선택해주세요");
|
||||
return;
|
||||
}
|
||||
if (!(await confirm("삭제하시겠습니까?"))) return;
|
||||
|
||||
const attachmentResult = await deleteData(
|
||||
`/attach-service/api/v1/atfl/attachments/${attachmentId}`,
|
||||
);
|
||||
if (!attachmentResult) {
|
||||
alert("삭제에 실패했습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const businessResult = await deleteData(`v1/dcrpPpl/${businessId}`);
|
||||
if (businessResult.status === 200) {
|
||||
alert("삭제되었습니다.", () => void fetchData());
|
||||
return;
|
||||
}
|
||||
alert("삭제에 실패했습니다.");
|
||||
};
|
||||
|
||||
const handleRegister = () => {
|
||||
if (!openWindowPopup("oldPpUpload")) {
|
||||
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenDetail = (row: OldPpRow) => {
|
||||
const nextFileName = String(row.dcrpPplMtncBizNm ?? "");
|
||||
const nextAttachmentId = String(row.atflId ?? "");
|
||||
setFileName(nextFileName);
|
||||
setAttachmentId(nextAttachmentId);
|
||||
setBusinessId(String(row.dcrpPplMtncBizSn ?? ""));
|
||||
|
||||
if (
|
||||
!openWindowPopup("oldPpTable", {
|
||||
fileNm: nextFileName,
|
||||
atflId: nextAttachmentId,
|
||||
sourcePath: window.location.pathname,
|
||||
})
|
||||
) {
|
||||
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadingOverlay isLoading={isLoading ?? false} />
|
||||
<SearchDiv>
|
||||
<SearchFormLeft>
|
||||
<div className='flex items-center gap-4'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Label className="w-20 text-xl text-gray-600">등록기간</Label>
|
||||
<LoadingOverlay isLoading={loading} />
|
||||
<div className="card card-border !mt-6">
|
||||
<form
|
||||
className="flex flex-wrap items-center gap-2 p-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleSearch();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="mr-2 whitespace-nowrap text-label">
|
||||
등록기간
|
||||
</Label>
|
||||
<SearchDatePicker
|
||||
selected={fromDate}
|
||||
onSelect={(date: Date | undefined) => { if (date) setFromDate(date) }}
|
||||
disabled={{ after: new Date() }} />
|
||||
onSelect={(date) => date && setFromDate(date)}
|
||||
disabled={{ after: new Date() }}
|
||||
/>
|
||||
<span>~</span>
|
||||
<SearchDatePicker
|
||||
selected={toDate}
|
||||
onSelect={(date: Date | undefined) => { if (date) setToDate(date) }}
|
||||
disabled={{ after: new Date() }} />
|
||||
<div className='flex items-center'>
|
||||
<Label className="w-20 text-xl text-gray-600">등록자</Label>
|
||||
onSelect={(date) => date && setToDate(date)}
|
||||
disabled={{ after: new Date() }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="mr-2 whitespace-nowrap text-label">등록자</Label>
|
||||
<Input
|
||||
className='w-[100px] !text-2xl'
|
||||
value={usr}
|
||||
onChange={(e) => setUsr(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault(); // Enter 키 기본 동작 방지
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
className="w-[12rem] !text-[1.3rem]"
|
||||
value={registrant}
|
||||
onChange={(event) => setRegistrant(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center'>
|
||||
<Label className="w-20 text-xl text-gray-600">파일명</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="mr-2 whitespace-nowrap text-label">파일명</Label>
|
||||
<Input
|
||||
className='w-[100px] !text-2xl'
|
||||
value={pjNm}
|
||||
onChange={(e) => { setpjNm(e.target.value) }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault(); // Enter 키 기본 동작 방지
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
className="w-[18rem] !text-[1.3rem]"
|
||||
value={projectName}
|
||||
onChange={(event) => setProjectName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-[7.4rem]">
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
className="w-[8.8rem]"
|
||||
onClick={() => void handleReset()}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</SearchFormLeft>
|
||||
<SearchFormRight>
|
||||
<div className='flex items-center gap-5'>
|
||||
<Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" onClick={async () => await handleSearch()} >조회</Button>
|
||||
<Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700" size="sm" onClick={handleRegisterClick} >등록</Button>
|
||||
</div>
|
||||
</SearchFormRight>
|
||||
</SearchDiv>
|
||||
<ListDiv className="mt-4">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="text-2xl font-medium">사업정보 등록현황</h3>
|
||||
<div>
|
||||
<Button className="px-4 py-2 !mr-[1.2rem] px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium green" size="sm" onClick={isChckFile}>파일 다운로드</Button>
|
||||
<Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 !mr-[1.2rem]" size="sm" onClick={isDeleteFile}>삭제</Button>
|
||||
</div>
|
||||
</div>
|
||||
<UiTable
|
||||
headName=''
|
||||
tableData={tableData}
|
||||
headlist={tableHeadlistOldPp}
|
||||
pageSize={10}
|
||||
total={totalData}
|
||||
children={undefined}
|
||||
cellClick={(index) => {
|
||||
if (index != null) {
|
||||
const fileName = tableData[index]?.dcrpPplMtncBizNm ?? "";
|
||||
const id = tableData[index]?.atflId ?? '';
|
||||
setFileNm(fileName);
|
||||
setAtflId(id);
|
||||
setDcrpPplMtncBizSn(tableData[index]?.dcrpPplMtncBizSn);
|
||||
handleTablePopupOpen(fileName, id);
|
||||
}
|
||||
}}
|
||||
onPageChange={setCurPage}
|
||||
resetSelection={resetSelection}
|
||||
resetPageNo={isResetPage}
|
||||
sort={sort}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
</ListDiv>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Page
|
||||
<div className="mt-4 w-full min-w-0 max-w-full overflow-hidden">
|
||||
<StandardDataTable
|
||||
table={table}
|
||||
title="노후 하수관로 정비사업 목록"
|
||||
onRowClick={handleOpenDetail}
|
||||
actions={
|
||||
<>
|
||||
<Button type="button" onClick={handleFileDownload}>
|
||||
엑셀다운로드
|
||||
</Button>
|
||||
<Button type="button" onClick={handleRegister}>
|
||||
등록
|
||||
</Button>
|
||||
<Button type="button" color="red" onClick={handleDelete}>
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
@@ -1,531 +1,365 @@
|
||||
/**
|
||||
* 📄 Table Modal
|
||||
* 노후 하수관로 엑셀 데이터 테이블 모달 컴포넌트
|
||||
*
|
||||
* - 엑셀 파일 다운로드 및 특정 시트 파싱 기능
|
||||
* - 탭 기반 테이블 표시 (raw data, 정밀조사, 정비사업 등)
|
||||
* - 데이터 필터링 및 빈 행 제거 처리
|
||||
* - 로딩 오버레이 및 에러 핸들링 지원
|
||||
* - XLSX 라이브러리를 통한 엑셀 데이터 변환
|
||||
* - 테이블 헤더 및 데이터 동적 생성
|
||||
*
|
||||
* 🔗 사용 API
|
||||
* - 첨부파일 다운로드(파일ID): GET /attach-service/api/v1/download/{atflId} (attach-service)
|
||||
*/
|
||||
'use client'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import LoadingOverlay from '@/components/ui/LoadingBar'
|
||||
import { UITab, UiTable } from '@common/business_components/ui'
|
||||
import * as XLSX from 'xlsx';
|
||||
import { HeadMakeColSpan } from '@common/business_components/ui/table/HeadMakeColSpan';
|
||||
import { oldPpTable2HeadList, oldPpTable2NodeList, oldPpTable6HeadList, oldPpTable6NodeList } from './headList';
|
||||
import { getBlobData } from '@/components/lib/api';
|
||||
import type { UiHeadProps, UiTableSort } from '@common/business_components/ui';
|
||||
import { getNextTableSort, makeSortableHeaders, sortLocalTableRows } from '../tableSort';
|
||||
/** 노후 하수관로 정비사업 첨부 엑셀 상세 팝업 테이블 */
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
import { getBlobData } from "@/components/lib/api";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { StandardDataTable } from "@common/business_components";
|
||||
import { UITab } from "@common/business_components/ui";
|
||||
import { oldPpTable2HeadList, oldPpTable6HeadList } from "./headList";
|
||||
|
||||
const TABS = [
|
||||
{ val: "1", text: "raw data" },
|
||||
{ val: "2", text: "1.정밀조사" },
|
||||
{ val: "3", text: "2.정밀조사_정비사업연계" },
|
||||
{ val: "4", text: " 3.정비사업" },
|
||||
{ val: "4", text: "3.정비사업" },
|
||||
{ val: "5", text: "4.정비사업요약" },
|
||||
{ val: "6", text: "4.정비사업최종" },
|
||||
];
|
||||
|
||||
const sortableTable3HeadList = makeSortableHeaders(oldPpTable2HeadList);
|
||||
const sortableTable3NodeList = makeSortableHeaders(oldPpTable2NodeList);
|
||||
const sortableTable6HeadList = makeSortableHeaders(oldPpTable6HeadList);
|
||||
const sortableTable6NodeList = makeSortableHeaders(oldPpTable6NodeList);
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
type ExcelHeader = {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type ExcelRow = Record<string, unknown>;
|
||||
|
||||
interface TableModalProps {
|
||||
fileNm: string;
|
||||
atflId: string;
|
||||
}
|
||||
|
||||
const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
|
||||
function normalizeHeaderTitle(value: unknown) {
|
||||
const title = String(value).replace(/\r\n/g, "\n");
|
||||
return /^(no|일련번호)$/i.test(title.trim()) ? "순번" : title;
|
||||
}
|
||||
|
||||
const [activeTab, setActiveTab] = useState(TABS[0]?.val ?? "1");
|
||||
const [tableHeader1, setTableHeader1] = useState<{ id: string; title: string }[]>([])
|
||||
const [tableHeader2, setTableHeader2] = useState<{ id: string; title: string }[]>([])
|
||||
const [tableHeader4, setTableHeader4] = useState<{ id: string; title: string }[]>([])
|
||||
const [tableHeader5, setTableHeader5] = useState<{ id: string; title: string }[]>([])
|
||||
const [tableData1, setTableData1] = useState<any>([{}])
|
||||
const [tableData2, setTableData2] = useState<any>([{}])
|
||||
const [tableData3, setTableData3] = useState<any>([{}])
|
||||
const [tableData4, setTableData4] = useState<any>([{}])
|
||||
const [tableData5, setTableData5] = useState<any>([{}])
|
||||
const [tableData6, setTableData6] = useState<any>([{}])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [tableSorts, setTableSorts] = useState<Record<string, UiTableSort | undefined>>({});
|
||||
function createColumns(headers: ExcelHeader[]): ColumnDef<ExcelRow>[] {
|
||||
return headers.map((header) => ({
|
||||
id: header.id,
|
||||
accessorFn: (row) => row[header.id],
|
||||
header: () => <span className="whitespace-pre-line">{header.title}</span>,
|
||||
cell: ({ getValue }) => String(getValue() ?? "-"),
|
||||
size: 140,
|
||||
meta: { align: "center" },
|
||||
}));
|
||||
}
|
||||
|
||||
const sortedTableData = useMemo(
|
||||
() => ({
|
||||
'1': sortLocalTableRows(tableData1, tableSorts['1']),
|
||||
'2': sortLocalTableRows(tableData2, tableSorts['2']),
|
||||
'3': sortLocalTableRows(tableData3, tableSorts['3']),
|
||||
'4': sortLocalTableRows(tableData4, tableSorts['4']),
|
||||
'5': sortLocalTableRows(tableData5, tableSorts['5']),
|
||||
'6': sortLocalTableRows(tableData6, tableSorts['6']),
|
||||
}),
|
||||
[tableData1, tableData2, tableData3, tableData4, tableData5, tableData6, tableSorts],
|
||||
);
|
||||
|
||||
const setTableSort = (tab: string, nextSort?: UiTableSort) => {
|
||||
setTableSorts((current) => ({ ...current, [tab]: nextSort }));
|
||||
};
|
||||
|
||||
const handleCustomHeadSort = (tab: string, head: UiHeadProps) => {
|
||||
setTableSort(tab, getNextTableSort(head, tableSorts[tab]));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
isChckFile();
|
||||
}, []);
|
||||
|
||||
// 파일 다운로드
|
||||
const isChckFile = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (fileNm === '' || fileNm === undefined) {
|
||||
alert('파일을 선택해주세요');
|
||||
setIsLoading(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (atflId === '' || atflId === undefined) {
|
||||
alert('첨부파일 ID가 없어 파일을 불러올 수 없습니다.');
|
||||
setIsLoading(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
const blob = await getBlobData(`/attach-service/api/v1/download/${atflId}`);
|
||||
|
||||
const file = new File([blob], fileNm, {
|
||||
type: blob.type || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
function useExcelTable(data: ExcelRow[], headers: ExcelHeader[]) {
|
||||
const columns = useMemo(() => createColumns(headers), [headers]);
|
||||
return useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: PAGE_SIZE } },
|
||||
});
|
||||
}
|
||||
|
||||
const targetSheets = ['raw data', '1.정밀조사', '2.정밀조사_정비사업연계', ' 3.정비사업', '4.정비사업요약', '4.정비사업최종'];
|
||||
const jsonDataBySheet = await filePickedSelectedSheets(file, 0, targetSheets);
|
||||
|
||||
const header1 = jsonDataBySheet['raw data']?.[1] || [];
|
||||
const header2 = jsonDataBySheet['1.정밀조사']?.[1] || [];
|
||||
const header4 = jsonDataBySheet[' 3.정비사업']?.[1] || [];
|
||||
const header5 = jsonDataBySheet['4.정비사업요약']?.[2] || [];
|
||||
|
||||
// raw data
|
||||
const allEntries1 = Object.entries(header1);
|
||||
const filteredHeader1 = [
|
||||
allEntries1[0],
|
||||
allEntries1[1],
|
||||
allEntries1[2],
|
||||
allEntries1[3],
|
||||
allEntries1[5],
|
||||
allEntries1[6],
|
||||
allEntries1[7],
|
||||
allEntries1[8],
|
||||
allEntries1[9],
|
||||
allEntries1[10],
|
||||
allEntries1[11],
|
||||
allEntries1[12],
|
||||
allEntries1[13],
|
||||
allEntries1[14],
|
||||
allEntries1[15]
|
||||
].filter((e): e is [string, any] => Boolean(e))
|
||||
.map(([key, value]) => ({
|
||||
id: key,
|
||||
title: String(value).replace(/\r\n/g, '\n')
|
||||
}));
|
||||
filteredHeader1.splice(0, 0, {
|
||||
id: "rowNumber",
|
||||
title: '일련번호'
|
||||
});
|
||||
setTableHeader1(makeSortableHeaders(filteredHeader1));
|
||||
|
||||
const data1 = Array.isArray(jsonDataBySheet['raw data'])
|
||||
? jsonDataBySheet['raw data'].slice(2)
|
||||
: [];
|
||||
const filteredData1 = filterEmptyRows(data1).map((item: any, index: number) => ({
|
||||
...item,
|
||||
rowNumber: index + 1,
|
||||
__EMPTY_1: item.__EMPTY_1 || '-'
|
||||
}));
|
||||
setTableData1(filteredData1);
|
||||
|
||||
// 1.정밀조사
|
||||
const allEntries2 = Object.entries(header2);
|
||||
let filteredHeader2 = [
|
||||
allEntries2[0],
|
||||
allEntries2[1],
|
||||
allEntries2[2],
|
||||
allEntries2[3],
|
||||
allEntries2[5],
|
||||
allEntries2[6],
|
||||
allEntries2[7],
|
||||
allEntries2[8],
|
||||
allEntries2[9],
|
||||
allEntries2[10],
|
||||
allEntries2[11],
|
||||
allEntries2[12],
|
||||
allEntries2[13],
|
||||
allEntries2[14],
|
||||
allEntries2[15]
|
||||
].filter((e): e is [string, any] => Boolean(e))
|
||||
.map(([key, value]) => ({
|
||||
id: key,
|
||||
title: String(value).replace(/\r\n/g, '\n')
|
||||
}));
|
||||
filteredHeader2.splice(0, 0, {
|
||||
id: "rowNumber",
|
||||
title: '일련번호'
|
||||
});
|
||||
setTableHeader2(makeSortableHeaders(filteredHeader2));
|
||||
|
||||
const data2 = Array.isArray(jsonDataBySheet['1.정밀조사'])
|
||||
? jsonDataBySheet['1.정밀조사'].slice(2)
|
||||
: [];
|
||||
const filteredData2 = filterEmptyRows(data2).map((item: any, index: number) => ({
|
||||
...item,
|
||||
rowNumber: index + 1
|
||||
}));
|
||||
setTableData2(filteredData2);
|
||||
|
||||
// 2.정밀조사_정비사업연계 (헤더는 import)
|
||||
const data3 = Array.isArray(jsonDataBySheet['2.정밀조사_정비사업연계'])
|
||||
? jsonDataBySheet['2.정밀조사_정비사업연계'].slice(5)
|
||||
: [];
|
||||
const filteredData3 = filterEmptyRows(data3).map((item: any, index: number) => {
|
||||
const newItem = { ...item };
|
||||
// 매핑 에러로 인해 특정 키를 "__EMPTY_0"으로 변경
|
||||
const targetKey = "*1차 출처: 공단자료\r\n*2차 출처: 정산서, 정밀조사보고서 등.\r\n (파랑) 2024년말 기준 실적 제출 자료. 준공 후 재확인 필요\r\n (노랑셀) 준공(또는 정산) 후에 재확인 필요";
|
||||
if (newItem[targetKey] !== undefined) {
|
||||
newItem["__EMPTY_0"] = newItem[targetKey];
|
||||
delete newItem[targetKey];
|
||||
}
|
||||
return {
|
||||
...newItem,
|
||||
__EMPTY_4: newItem?.__EMPTY_4 || '-',
|
||||
rowNumber: index + 1
|
||||
};
|
||||
});
|
||||
setTableData3(filteredData3);
|
||||
|
||||
// 3.정비사업
|
||||
const allEntries4 = Object.entries(header4);
|
||||
const filteredHeader4 = allEntries4.filter((e): e is [string, any] => Boolean(e))
|
||||
.map(([key, value]) => ({
|
||||
id: key,
|
||||
title: String(value).replace(/\r\n/g, '\n')
|
||||
}));
|
||||
|
||||
filteredHeader4.splice(0, 0, {
|
||||
id: "rowNumber",
|
||||
title: '일련번호'
|
||||
});
|
||||
setTableHeader4(makeSortableHeaders(filteredHeader4));
|
||||
|
||||
const data4 = Array.isArray(jsonDataBySheet[' 3.정비사업'])
|
||||
? jsonDataBySheet[' 3.정비사업'].slice(2)
|
||||
: [];
|
||||
const filteredData4 = filterEmptyRows(data4).map((item: any, index: number) => ({
|
||||
...item,
|
||||
rowNumber: index + 1,
|
||||
__EMPTY_2: item?.__EMPTY_2 || '-',
|
||||
__EMPTY_11: item?.__EMPTY_11 || '-',
|
||||
}));
|
||||
setTableData4(filteredData4);
|
||||
|
||||
// 4.정비사업요약
|
||||
const allEntries5 = Object.entries(header5);
|
||||
const excludeKeys5 = [
|
||||
": \"정비사업\"sheet에서 사업코드 ctrl+c, Ctrl+v 한 다음 중복된 데이터 제거실시",
|
||||
"__EMPTY",
|
||||
"__EMPTY_8",
|
||||
"__EMPTY_15"
|
||||
];
|
||||
const filteredHeader5 = allEntries5
|
||||
.filter(([key]) => !excludeKeys5.includes(key))
|
||||
.filter((e): e is [string, any] => Boolean(e))
|
||||
.map(([key, value]) => ({
|
||||
id: key,
|
||||
title: String(value).replace(/\r\n/g, '\n'),
|
||||
}));
|
||||
filteredHeader5.splice(0, 0, {
|
||||
id: "rowNumber",
|
||||
title: '일련번호'
|
||||
});
|
||||
setTableHeader5(makeSortableHeaders(filteredHeader5));
|
||||
|
||||
const data5 = Array.isArray(jsonDataBySheet['4.정비사업요약'])
|
||||
? jsonDataBySheet['4.정비사업요약'].slice(3)
|
||||
: [];
|
||||
const filteredData5 =
|
||||
filterEmptyRows(data5).map((item: any, index: number) => ({
|
||||
...item,
|
||||
rowNumber: index + 1,
|
||||
}));
|
||||
setTableData5(filteredData5);
|
||||
|
||||
// 4.정비사업최종 (헤더는 import)
|
||||
const data6 = Array.isArray(jsonDataBySheet['4.정비사업최종'])
|
||||
? jsonDataBySheet['4.정비사업최종'].slice(6)
|
||||
: [];
|
||||
const filteredData6 =
|
||||
filterEmptyRows(data6).map((item: any, index: number) => ({
|
||||
...item,
|
||||
rowNumber: index + 1,
|
||||
}));
|
||||
setTableData6(filteredData6);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('OldPp attachment fetch details:', { atflId, fileNm, message });
|
||||
console.error('파일 처리 중 오류 발생:', error);
|
||||
alert('파일 처리 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function filterEmptyRows(data: any[]) {
|
||||
function filterEmptyRows(data: ExcelRow[]) {
|
||||
return data
|
||||
.filter(row => {
|
||||
return Object.entries(row).some(([key, value]) => {
|
||||
.filter((row) =>
|
||||
Object.entries(row).some(([key, value]) => {
|
||||
const match = key.match(/^__EMPTY_(\d+)$/);
|
||||
if (!match || parseInt(match[1] ?? '', 10) < 2) return false;
|
||||
if (!match || parseInt(match[1] ?? "", 10) < 2) return false;
|
||||
|
||||
return value !== undefined &&
|
||||
return (
|
||||
value !== undefined &&
|
||||
value !== null &&
|
||||
String(value).trim() !== '' &&
|
||||
String(value).trim() !== '-' &&
|
||||
String(value).trim() !== '';
|
||||
});
|
||||
})
|
||||
String(value).trim() !== "" &&
|
||||
String(value).trim() !== "-"
|
||||
);
|
||||
}),
|
||||
)
|
||||
.map((row) => {
|
||||
const processedRow = { ...row };
|
||||
const keys = Object.keys(processedRow);
|
||||
|
||||
keys.forEach((key, keyIndex) => {
|
||||
// 마지막 속성(사업 근거)이 아닌 경우에만 빈 값을 '-'으로 변경
|
||||
if (keyIndex !== keys.length - 1) {
|
||||
if (keyIndex === keys.length - 1) return;
|
||||
|
||||
const value = processedRow[key];
|
||||
if (value === undefined ||
|
||||
if (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
value === '' ||
|
||||
String(value).trim() === '') {
|
||||
processedRow[key] = '-';
|
||||
}
|
||||
value === "" ||
|
||||
String(value).trim() === ""
|
||||
) {
|
||||
processedRow[key] = "-";
|
||||
}
|
||||
});
|
||||
|
||||
return processedRow;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// 특정 시트만 선택적으로 JSON으로 변환하는 함수
|
||||
function filePickedSelectedSheets(file: File, headerNum: number = 0, sheetNames: string[]): Promise<Record<string, any[]>> {
|
||||
function withDescendingRowNumber(rows: ExcelRow[]) {
|
||||
return rows.map((row, index) => ({
|
||||
...row,
|
||||
rowNumber: rows.length - index,
|
||||
}));
|
||||
}
|
||||
|
||||
function filePickedSelectedSheets(
|
||||
file: File,
|
||||
headerNum: number = 0,
|
||||
sheetNames: string[],
|
||||
): Promise<Record<string, ExcelRow[]>> {
|
||||
const reader = new FileReader();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
reader.onload = function (e) {
|
||||
const data = e.target?.result;
|
||||
reader.onload = (event) => {
|
||||
const data = event.target?.result;
|
||||
if (!data) {
|
||||
console.error("Failed to read file: e.target.result is null");
|
||||
reject(new Error("Failed to read file"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Excel 파일 읽기
|
||||
const wb = XLSX.read(data, { type: 'binary', cellDates: true });
|
||||
const result: Record<string, any[]> = {};
|
||||
const workbook = XLSX.read(data, { type: "binary", cellDates: true });
|
||||
const result: Record<string, ExcelRow[]> = {};
|
||||
|
||||
// 지정된 시트 이름만 찾아서 처리
|
||||
sheetNames.forEach(targetSheetName => {
|
||||
// 대소문자 구분 없이 시트 이름 찾기
|
||||
const sheetName = wb.SheetNames.find(name =>
|
||||
name.toLowerCase() === targetSheetName.toLowerCase()
|
||||
sheetNames.forEach((targetSheetName) => {
|
||||
const sheetName = workbook.SheetNames.find(
|
||||
(name) => name.toLowerCase() === targetSheetName.toLowerCase(),
|
||||
);
|
||||
|
||||
if (sheetName) {
|
||||
const sheet = wb.Sheets[sheetName];
|
||||
if (sheet) {
|
||||
const jsonData = XLSX.utils.sheet_to_json(sheet, { range: headerNum, raw: false });
|
||||
result[targetSheetName] = jsonData;
|
||||
}
|
||||
} else {
|
||||
const sheet = sheetName ? workbook.Sheets[sheetName] : undefined;
|
||||
if (!sheet) {
|
||||
console.warn(`Sheet "${targetSheetName}" not found in workbook`);
|
||||
result[targetSheetName] = [];
|
||||
return;
|
||||
}
|
||||
|
||||
result[targetSheetName] = XLSX.utils.sheet_to_json<ExcelRow>(sheet, {
|
||||
range: headerNum,
|
||||
raw: false,
|
||||
});
|
||||
});
|
||||
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
reader.onerror = function () {
|
||||
console.error("Error reading file");
|
||||
reject(new Error("Error reading file"));
|
||||
};
|
||||
|
||||
// 파일 읽기 시작
|
||||
reader.onerror = () => reject(new Error("Error reading file"));
|
||||
reader.readAsBinaryString(file);
|
||||
});
|
||||
}
|
||||
|
||||
const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [activeTab, setActiveTab] = useState(TABS[0]?.val ?? "1");
|
||||
const [headers1, setHeaders1] = useState<ExcelHeader[]>([]);
|
||||
const [headers2, setHeaders2] = useState<ExcelHeader[]>([]);
|
||||
const [headers4, setHeaders4] = useState<ExcelHeader[]>([]);
|
||||
const [headers5, setHeaders5] = useState<ExcelHeader[]>([]);
|
||||
const [data1, setData1] = useState<ExcelRow[]>([]);
|
||||
const [data2, setData2] = useState<ExcelRow[]>([]);
|
||||
const [data3, setData3] = useState<ExcelRow[]>([]);
|
||||
const [data4, setData4] = useState<ExcelRow[]>([]);
|
||||
const [data5, setData5] = useState<ExcelRow[]>([]);
|
||||
const [data6, setData6] = useState<ExcelRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const headers3 = useMemo<ExcelHeader[]>(
|
||||
() =>
|
||||
oldPpTable2HeadList.map((header) => ({
|
||||
id: header.id,
|
||||
title: normalizeHeaderTitle(header.title),
|
||||
})),
|
||||
[],
|
||||
);
|
||||
const headers6 = useMemo<ExcelHeader[]>(
|
||||
() =>
|
||||
oldPpTable6HeadList.map((header) => ({
|
||||
id: header.id,
|
||||
title: normalizeHeaderTitle(header.title),
|
||||
})),
|
||||
[],
|
||||
);
|
||||
const table1 = useExcelTable(data1, headers1);
|
||||
const table2 = useExcelTable(data2, headers2);
|
||||
const table3 = useExcelTable(data3, headers3);
|
||||
const table4 = useExcelTable(data4, headers4);
|
||||
const table5 = useExcelTable(data5, headers5);
|
||||
const table6 = useExcelTable(data6, headers6);
|
||||
|
||||
useEffect(() => {
|
||||
const loadFile = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (!fileNm) {
|
||||
alert("파일을 선택해주세요");
|
||||
return;
|
||||
}
|
||||
if (!atflId) {
|
||||
alert("첨부파일 ID가 없어 파일을 불러올 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await getBlobData(
|
||||
`/attach-service/api/v1/download/${atflId}`,
|
||||
);
|
||||
const file = new File([blob], fileNm, {
|
||||
type:
|
||||
blob.type ||
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
const jsonDataBySheet = await filePickedSelectedSheets(file, 0, [
|
||||
"raw data",
|
||||
"1.정밀조사",
|
||||
"2.정밀조사_정비사업연계",
|
||||
" 3.정비사업",
|
||||
"4.정비사업요약",
|
||||
"4.정비사업최종",
|
||||
]);
|
||||
|
||||
const header1Entries = Object.entries(
|
||||
jsonDataBySheet["raw data"]?.[1] ?? {},
|
||||
);
|
||||
const header2Entries = Object.entries(
|
||||
jsonDataBySheet["1.정밀조사"]?.[1] ?? {},
|
||||
);
|
||||
const selectedIndexes = [
|
||||
0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
|
||||
];
|
||||
const selectHeaders = (entries: [string, unknown][]) => [
|
||||
{ id: "rowNumber", title: "순번" },
|
||||
...selectedIndexes
|
||||
.map((index) => entries[index])
|
||||
.filter((entry): entry is [string, unknown] => Boolean(entry))
|
||||
.map(([id, title]) => ({
|
||||
id,
|
||||
title: normalizeHeaderTitle(title),
|
||||
})),
|
||||
];
|
||||
setHeaders1(selectHeaders(header1Entries));
|
||||
setHeaders2(selectHeaders(header2Entries));
|
||||
|
||||
const nextData1 = filterEmptyRows(
|
||||
(jsonDataBySheet["raw data"] ?? []).slice(2),
|
||||
).map((item) => ({
|
||||
...item,
|
||||
__EMPTY_1: item.__EMPTY_1 || "-",
|
||||
}));
|
||||
setData1(withDescendingRowNumber(nextData1));
|
||||
setData2(
|
||||
withDescendingRowNumber(
|
||||
filterEmptyRows((jsonDataBySheet["1.정밀조사"] ?? []).slice(2)),
|
||||
),
|
||||
);
|
||||
|
||||
const sourceDescriptionKey =
|
||||
"*1차 출처: 공단자료\r\n*2차 출처: 정산서, 정밀조사보고서 등.\r\n (파랑) 2024년말 기준 실적 제출 자료. 준공 후 재확인 필요\r\n (노랑셀) 준공(또는 정산) 후에 재확인 필요";
|
||||
const nextData3 = filterEmptyRows(
|
||||
(jsonDataBySheet["2.정밀조사_정비사업연계"] ?? []).slice(5),
|
||||
).map((item) => {
|
||||
const next = { ...item };
|
||||
if (next[sourceDescriptionKey] !== undefined) {
|
||||
next.__EMPTY_0 = next[sourceDescriptionKey];
|
||||
delete next[sourceDescriptionKey];
|
||||
}
|
||||
return { ...next, __EMPTY_4: next.__EMPTY_4 || "-" };
|
||||
});
|
||||
setData3(withDescendingRowNumber(nextData3));
|
||||
|
||||
const header4Entries = Object.entries(
|
||||
jsonDataBySheet[" 3.정비사업"]?.[1] ?? {},
|
||||
);
|
||||
setHeaders4([
|
||||
{ id: "rowNumber", title: "순번" },
|
||||
...header4Entries.map(([id, title]) => ({
|
||||
id,
|
||||
title: normalizeHeaderTitle(title),
|
||||
})),
|
||||
]);
|
||||
const nextData4 = filterEmptyRows(
|
||||
(jsonDataBySheet[" 3.정비사업"] ?? []).slice(2),
|
||||
).map((item) => ({
|
||||
...item,
|
||||
__EMPTY_2: item.__EMPTY_2 || "-",
|
||||
__EMPTY_11: item.__EMPTY_11 || "-",
|
||||
}));
|
||||
setData4(withDescendingRowNumber(nextData4));
|
||||
|
||||
const excludedSummaryKeys = [
|
||||
': "정비사업"sheet에서 사업코드 ctrl+c, Ctrl+v 한 다음 중복된 데이터 제거실시',
|
||||
"__EMPTY",
|
||||
"__EMPTY_8",
|
||||
"__EMPTY_15",
|
||||
];
|
||||
const header5Entries = Object.entries(
|
||||
jsonDataBySheet["4.정비사업요약"]?.[2] ?? {},
|
||||
).filter(([key]) => !excludedSummaryKeys.includes(key));
|
||||
setHeaders5([
|
||||
{ id: "rowNumber", title: "순번" },
|
||||
...header5Entries.map(([id, title]) => ({
|
||||
id,
|
||||
title: normalizeHeaderTitle(title),
|
||||
})),
|
||||
]);
|
||||
setData5(
|
||||
withDescendingRowNumber(
|
||||
filterEmptyRows((jsonDataBySheet["4.정비사업요약"] ?? []).slice(3)),
|
||||
),
|
||||
);
|
||||
setData6(
|
||||
withDescendingRowNumber(
|
||||
filterEmptyRows((jsonDataBySheet["4.정비사업최종"] ?? []).slice(6)),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("OldPp attachment fetch details:", {
|
||||
atflId,
|
||||
fileNm,
|
||||
error,
|
||||
});
|
||||
alert("파일 처리 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadFile();
|
||||
}, [alert, atflId, fileNm]);
|
||||
|
||||
const activeConfig =
|
||||
activeTab === "1"
|
||||
? { table: table1, headers: headers1 }
|
||||
: activeTab === "2"
|
||||
? { table: table2, headers: headers2 }
|
||||
: activeTab === "3"
|
||||
? { table: table3, headers: headers3 }
|
||||
: activeTab === "4"
|
||||
? { table: table4, headers: headers4 }
|
||||
: activeTab === "5"
|
||||
? { table: table5, headers: headers5 }
|
||||
: { table: table6, headers: headers6 };
|
||||
const activeTitle =
|
||||
TABS.find((tab) => tab.val === activeTab)?.text ?? "첨부 데이터";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 탭 영역 */}
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<UITab
|
||||
defaultValue={activeTab}
|
||||
tabList={TABS.map((tab) => ({
|
||||
value: tab.val,
|
||||
label: tab.text
|
||||
}))}
|
||||
onValueChange={(value: string) => setActiveTab(value)}
|
||||
tabList={TABS.map((tab) => ({ value: tab.val, label: tab.text }))}
|
||||
onValueChange={setActiveTab}
|
||||
/>
|
||||
{/* row data 탭 */}
|
||||
{activeTab === '1' && (
|
||||
<div className="h-[79vh] rounded-lg border flex flex-col">
|
||||
<div className="flex-1 min-w-[50vw] max-w-[100vw] overflow-auto">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<div className="min-w-max">
|
||||
<UiTable
|
||||
headName={'raw data'}
|
||||
tableData={sortedTableData['1']}
|
||||
headlist={tableHeader1}
|
||||
pageSize={10}
|
||||
total={0}
|
||||
cellClick={undefined}
|
||||
onPageChange={() => { }}
|
||||
sort={tableSorts['1']}
|
||||
onSortChange={(nextSort) => setTableSort('1', nextSort)}
|
||||
<StandardDataTable
|
||||
table={activeConfig.table}
|
||||
title={`${activeTitle} 목록`}
|
||||
loading={loading}
|
||||
scrollMode="horizontal"
|
||||
minTableWidth={`${Math.max(activeConfig.headers.length * 9, 80)}rem`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 1.정밀조사 탭 */}
|
||||
{activeTab === '2' && (
|
||||
<div className="h-[79vh] w-full overflow-auto rounded-lg border">
|
||||
<div className="flex-1 px-2 pb-2">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<div className="min-w-[150%]">
|
||||
<UiTable
|
||||
headName={'1.정밀조사'}
|
||||
tableData={sortedTableData['2']}
|
||||
headlist={tableHeader2}
|
||||
pageSize={10}
|
||||
total={0}
|
||||
className={"overflow-x-auto overflow-y-auto"}
|
||||
cellClick={undefined}
|
||||
onPageChange={() => { }}
|
||||
sort={tableSorts['2']}
|
||||
onSortChange={(nextSort) => setTableSort('2', nextSort)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 2.정밀조사_정비사업연계 탭 */}
|
||||
{activeTab === '3' && (
|
||||
<div className="h-[79vh] w-full overflow-auto rounded-lg border">
|
||||
<div className="flex-1 px-2 pb-2">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<div className="min-w-[550%]">
|
||||
<UiTable
|
||||
headName={'2.정밀조사_정비사업연계'}
|
||||
tableData={sortedTableData['3']}
|
||||
headlist={sortableTable3HeadList}
|
||||
pageSize={10}
|
||||
total={0}
|
||||
className={"overflow-x-auto overflow-y-auto"}
|
||||
cellClick={undefined}
|
||||
onPageChange={() => { }}
|
||||
sort={tableSorts['3']}
|
||||
onSortChange={(nextSort) => setTableSort('3', nextSort)}
|
||||
>
|
||||
<HeadMakeColSpan
|
||||
upHeadList={sortableTable3NodeList}
|
||||
sort={tableSorts['3']}
|
||||
onSortChange={(head) => handleCustomHeadSort('3', head)}
|
||||
/>
|
||||
</UiTable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 3.정비사업 탭 */}
|
||||
{activeTab === '4' && (
|
||||
<div className="h-[79vh] w-full overflow-auto rounded-lg border">
|
||||
<div className="flex-1 px-2 pb-2">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<div className="min-w-[160%]">
|
||||
<UiTable
|
||||
headName={'3.정비사업'}
|
||||
tableData={sortedTableData['4']}
|
||||
headlist={tableHeader4}
|
||||
pageSize={10}
|
||||
total={0}
|
||||
className={"overflow-x-auto overflow-y-auto"}
|
||||
cellClick={undefined}
|
||||
onPageChange={() => { }}
|
||||
sort={tableSorts['4']}
|
||||
onSortChange={(nextSort) => setTableSort('4', nextSort)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 4.정비사업요약 탭 */}
|
||||
{activeTab === '5' && (
|
||||
<div className="h-[79vh] w-full overflow-auto rounded-lg border">
|
||||
<div className="flex-1 px-2 pb-2">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<div className="min-w-[130%]">
|
||||
<UiTable
|
||||
headName={'4.정비사업요약'}
|
||||
tableData={sortedTableData['5']}
|
||||
headlist={tableHeader5}
|
||||
pageSize={10}
|
||||
total={0}
|
||||
className={"overflow-x-auto overflow-y-auto"}
|
||||
cellClick={undefined}
|
||||
onPageChange={() => { }}
|
||||
sort={tableSorts['5']}
|
||||
onSortChange={(nextSort) => setTableSort('5', nextSort)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 4.정비사업최종 탭 */}
|
||||
{activeTab === '6' && (
|
||||
<div className="h-[79vh] w-full overflow-auto rounded-lg border">
|
||||
<div className="flex-1 px-2 pb-2">
|
||||
<LoadingOverlay isLoading={isLoading} />
|
||||
<div className="min-w-[200%]">
|
||||
<UiTable
|
||||
headName={'4.정비사업최종'}
|
||||
tableData={sortedTableData['6']}
|
||||
headlist={sortableTable6HeadList}
|
||||
pageSize={10}
|
||||
total={0}
|
||||
className={"overflow-x-auto overflow-y-auto"}
|
||||
cellClick={undefined}
|
||||
onPageChange={() => { }}
|
||||
sort={tableSorts['6']}
|
||||
onSortChange={(nextSort) => setTableSort('6', nextSort)}
|
||||
>
|
||||
<HeadMakeColSpan
|
||||
upHeadList={sortableTable6NodeList}
|
||||
sort={tableSorts['6']}
|
||||
onSortChange={(head) => handleCustomHeadSort('6', head)}
|
||||
/>
|
||||
</UiTable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default TableModal;
|
||||
|
||||
@@ -10,7 +10,8 @@ import useOnsemiroAddrStore from "@/store/useOnsemiroAddrStore";
|
||||
const DEFAULT_OPTION = { text: "전체", val: "0" };
|
||||
|
||||
export default function Page() {
|
||||
const { govArea, city, sgg, fetchGovArea, fetchCity, fetchSgg } = useOnsemiroAddrStore();
|
||||
const { govArea, city, sgg, fetchGovArea, fetchCity, fetchSgg } =
|
||||
useOnsemiroAddrStore();
|
||||
const [selectedGovArea, setSelectedGovArea] = useState("0");
|
||||
const [selectedCity, setSelectedCity] = useState("0");
|
||||
const [selectedSgg, setSelectedSgg] = useState("0");
|
||||
@@ -27,7 +28,11 @@ export default function Page() {
|
||||
await Promise.all([fetchGovArea(), fetchCity(), fetchSgg()]);
|
||||
} catch (nextError) {
|
||||
if (!mounted) return;
|
||||
setError(nextError instanceof Error ? nextError.message : "주소 공통코드를 조회하지 못했습니다.");
|
||||
setError(
|
||||
nextError instanceof Error
|
||||
? nextError.message
|
||||
: "주소 공통코드를 조회하지 못했습니다.",
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
@@ -56,40 +61,41 @@ export default function Page() {
|
||||
<section className="space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Onsemiro 주소 Store 테스트</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
`sewerage-service`의 환경청, 시도, 시군구 조회 결과를 확인하는 임시 화면입니다.
|
||||
<p className="mt-2 text-sm text-label">
|
||||
`sewerage-service`의 환경청, 시도, 시군구 조회 결과를 확인하는 임시
|
||||
화면입니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 rounded-lg border bg-white p-6 md:grid-cols-3">
|
||||
<div className="grid gap-4 rounded-lg border bg-background p-6 md:grid-cols-3">
|
||||
<div>
|
||||
<Label className="mb-2 block text-base text-gray-600">환경청</Label>
|
||||
<Label className="mb-2 block text-base text-label">환경청</Label>
|
||||
<SelectBox
|
||||
label=""
|
||||
className="w-full"
|
||||
selectClass="w-full rounded-lg bg-white"
|
||||
selectClass="w-full rounded-lg bg-background"
|
||||
selectArray={[DEFAULT_OPTION, ...govArea]}
|
||||
value={selectedGovArea}
|
||||
onSelectValue={(value) => setSelectedGovArea(String(value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block text-base text-gray-600">시도</Label>
|
||||
<Label className="mb-2 block text-base text-label">시도</Label>
|
||||
<SelectBox
|
||||
label=""
|
||||
className="w-full"
|
||||
selectClass="w-full rounded-lg bg-white"
|
||||
selectClass="w-full rounded-lg bg-background"
|
||||
selectArray={[DEFAULT_OPTION, ...city]}
|
||||
value={selectedCity}
|
||||
onSelectValue={(value) => setSelectedCity(String(value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block text-base text-gray-600">시군구</Label>
|
||||
<Label className="mb-2 block text-base text-label">시군구</Label>
|
||||
<SelectBox
|
||||
label=""
|
||||
className="w-full"
|
||||
selectClass="w-full rounded-lg bg-white"
|
||||
selectClass="w-full rounded-lg bg-background"
|
||||
selectArray={[DEFAULT_OPTION, ...filteredSgg]}
|
||||
value={selectedSgg}
|
||||
onSelectValue={(value) => setSelectedSgg(String(value))}
|
||||
@@ -97,7 +103,7 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-slate-50 p-4 text-sm">
|
||||
<div className="rounded-lg border bg-secondary p-4 text-sm">
|
||||
<p>로딩: {loading ? "Y" : "N"}</p>
|
||||
<p>환경청 건수: {govArea.length}</p>
|
||||
<p>시도 건수: {city.length}</p>
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface SelectionValidationResult {
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export type SelectionValidationNotifier = (message: string) => void;
|
||||
|
||||
/**
|
||||
* 단일 선택 항목이 유효한지 검증하는 함수
|
||||
* @param value 검증할 값
|
||||
@@ -35,17 +37,28 @@ export interface SelectionValidationResult {
|
||||
*/
|
||||
export const isValidSelection = (
|
||||
value: string | number | ApiSelectOption,
|
||||
isRequired: boolean = true
|
||||
isRequired: boolean = true,
|
||||
): boolean => {
|
||||
if (!isRequired) return true;
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
// ApiSelectOption 객체인 경우
|
||||
return value.val !== '0' && value.val !== '' && value.val !== null && value.val !== undefined;
|
||||
return (
|
||||
value.val !== "0" &&
|
||||
value.val !== "" &&
|
||||
value.val !== null &&
|
||||
value.val !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
// 문자열 또는 숫자인 경우
|
||||
return value !== '0' && value !== 0 && value !== '' && value !== null && value !== undefined;
|
||||
return (
|
||||
value !== "0" &&
|
||||
value !== 0 &&
|
||||
value !== "" &&
|
||||
value !== null &&
|
||||
value !== undefined
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -54,13 +67,13 @@ export const isValidSelection = (
|
||||
* @returns 검증 결과. 실패 시 첫 번째 에러 메시지 반환
|
||||
*/
|
||||
export const validateMultipleSelections = (
|
||||
validationConfigs: SelectionValidationConfig[]
|
||||
validationConfigs: SelectionValidationConfig[],
|
||||
): SelectionValidationResult => {
|
||||
for (const config of validationConfigs) {
|
||||
if (!isValidSelection(config.value, config.required)) {
|
||||
return {
|
||||
isValid: false,
|
||||
errorMessage: `${config.description}을(를) 선택해주세요.`
|
||||
errorMessage: `${config.description}을(를) 선택해주세요.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -69,16 +82,18 @@ export const validateMultipleSelections = (
|
||||
};
|
||||
|
||||
/**
|
||||
* 여러 선택 항목을 한 번에 검증하고 alert 표시하는 함수
|
||||
* 여러 선택 항목을 한 번에 검증하고 주입된 CS 알림을 표시하는 함수
|
||||
* @param validationConfigs 검증할 선택 항목들의 설정 배열
|
||||
* @param notify 호출자가 주입하는 CS 알림 함수
|
||||
* @returns 검증 결과
|
||||
*/
|
||||
export const validateMultipleSelectionsWithAlert = (
|
||||
validationConfigs: SelectionValidationConfig[]
|
||||
validationConfigs: SelectionValidationConfig[],
|
||||
notify: SelectionValidationNotifier,
|
||||
): boolean => {
|
||||
const result = validateMultipleSelections(validationConfigs);
|
||||
if (!result.isValid && result.errorMessage) {
|
||||
alert(result.errorMessage);
|
||||
notify(result.errorMessage);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -101,46 +116,48 @@ export const validateBasicSelections = (
|
||||
requireSigungu?: boolean;
|
||||
requireTreatmentPlant?: boolean;
|
||||
treatmentPlantLabel?: string;
|
||||
} = {}
|
||||
} = {},
|
||||
): SelectionValidationResult => {
|
||||
const {
|
||||
requireEnv = true,
|
||||
requireSido = true,
|
||||
requireSigungu = true,
|
||||
requireTreatmentPlant = true,
|
||||
treatmentPlantLabel = '하수처리장'
|
||||
treatmentPlantLabel = "하수처리장",
|
||||
} = options;
|
||||
|
||||
const validationConfigs: SelectionValidationConfig[] = [
|
||||
{
|
||||
value: select1,
|
||||
description: treatmentPlantLabel,
|
||||
required: requireTreatmentPlant
|
||||
}
|
||||
required: requireTreatmentPlant,
|
||||
},
|
||||
];
|
||||
|
||||
return validateMultipleSelections(validationConfigs);
|
||||
};
|
||||
|
||||
/**
|
||||
* 기본 선택 항목들 검증 및 alert 표시
|
||||
* 기본 선택 항목들 검증 및 주입된 CS 알림 표시
|
||||
* @param select1 하수처리장코드
|
||||
* @param notify 호출자가 주입하는 CS 알림 함수
|
||||
* @param options 각 항목의 필수 여부 설정
|
||||
* @returns 검증 결과
|
||||
*/
|
||||
export const validateBasicSelectionsWithAlert = (
|
||||
select1: string | number,
|
||||
notify: SelectionValidationNotifier,
|
||||
options: {
|
||||
requireEnv?: boolean;
|
||||
requireSido?: boolean;
|
||||
requireSigungu?: boolean;
|
||||
requireTreatmentPlant?: boolean;
|
||||
treatmentPlantLabel?: string;
|
||||
} = {}
|
||||
} = {},
|
||||
): boolean => {
|
||||
const result = validateBasicSelections(select1, options);
|
||||
if (!result.isValid && result.errorMessage) {
|
||||
alert(result.errorMessage);
|
||||
notify(result.errorMessage);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
* - 고정 위치 및 z-index를 통한 최상위 레이어 배치
|
||||
* - 조건부 렌더링을 통한 성능 최적화
|
||||
*/
|
||||
import React from 'react';
|
||||
import { LoadingWithAnimation } from '@common/components/ui/loading';
|
||||
import React from "react";
|
||||
import { LoadingWithAnimation } from "@common/components/ui/loading";
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
isLoading: boolean;
|
||||
@@ -19,8 +19,9 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ isLoading }) => {
|
||||
if (!isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50">
|
||||
<div className="relative w-16 h-16">
|
||||
// 화면을 차단하는 로딩 오버레이는 배경 콘텐츠를 가리는 의미색이므로 반투명 검정을 유지한다.
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
|
||||
<div className="relative h-16 w-16">
|
||||
<LoadingWithAnimation color="primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* - 콜백 함수를 통한 선택 값 전달
|
||||
*/
|
||||
import React from "react";
|
||||
import { SelectBox } from '@common/business_components/ui'
|
||||
import { SelectBox } from "@common/business_components/ui";
|
||||
|
||||
interface YearRangeSelectBoxProps {
|
||||
start: string;
|
||||
@@ -37,11 +37,11 @@ export function getStartYearArray({
|
||||
endYear?: string;
|
||||
} = {}) {
|
||||
const years = Array.from({ length: maxYear - minYear + 1 }, (_, i) =>
|
||||
(maxYear - i).toString()
|
||||
).filter(y => !endYear || parseInt(y) <= parseInt(endYear));
|
||||
(maxYear - i).toString(),
|
||||
).filter((y) => !endYear || parseInt(y) <= parseInt(endYear));
|
||||
return [
|
||||
{ val: "0", text: placeholder },
|
||||
...years.map(y => ({ val: y, text: y })),
|
||||
...years.map((y) => ({ val: y, text: y })),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -58,11 +58,11 @@ export function getEndYearArray({
|
||||
startYear?: string;
|
||||
} = {}) {
|
||||
const years = Array.from({ length: maxYear - minYear + 1 }, (_, i) =>
|
||||
(maxYear - i).toString()
|
||||
).filter(y => !startYear || parseInt(y) >= parseInt(startYear));
|
||||
(maxYear - i).toString(),
|
||||
).filter((y) => !startYear || parseInt(y) >= parseInt(startYear));
|
||||
return [
|
||||
{ val: "9999", text: placeholder },
|
||||
...years.map(y => ({ val: y, text: y })),
|
||||
...years.map((y) => ({ val: y, text: y })),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -75,47 +75,47 @@ export function YearRangeSelectBox({
|
||||
minYear = 1900,
|
||||
maxYear = new Date().getFullYear(),
|
||||
className = "",
|
||||
selectClass = "rounded-lg bg-white flex-shrink-0 w-[70px]"
|
||||
selectClass = "rounded-lg bg-background flex-shrink-0 w-[70px]",
|
||||
}: YearRangeSelectBoxProps) {
|
||||
// 시작년도 배열
|
||||
const getStartYearArray = () => {
|
||||
const years = Array.from({ length: maxYear - minYear + 1 }, (_, i) =>
|
||||
(maxYear - i).toString()
|
||||
).filter(y => !end || parseInt(y) <= parseInt(end));
|
||||
(maxYear - i).toString(),
|
||||
).filter((y) => !end || parseInt(y) <= parseInt(end));
|
||||
return [
|
||||
{ val: "0", text: "전체" },
|
||||
...years.map(y => ({ val: y, text: y })),
|
||||
...years.map((y) => ({ val: y, text: y })),
|
||||
];
|
||||
};
|
||||
// 종료년도 배열
|
||||
const getEndYearArray = () => {
|
||||
const years = Array.from({ length: maxYear - minYear + 1 }, (_, i) =>
|
||||
(maxYear - i).toString()
|
||||
).filter(y => !start || parseInt(y) >= parseInt(start));
|
||||
(maxYear - i).toString(),
|
||||
).filter((y) => !start || parseInt(y) >= parseInt(start));
|
||||
return [
|
||||
{ val: "9999", text: "전체" },
|
||||
...years.map(y => ({ val: y, text: y })),
|
||||
...years.map((y) => ({ val: y, text: y })),
|
||||
];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center mr-8 ${className}`}>
|
||||
<div className={`mr-8 flex items-center ${className}`}>
|
||||
<SelectBox
|
||||
className="flex-1"
|
||||
selectClass={selectClass}
|
||||
label={startLabel}
|
||||
selectArray={getStartYearArray()}
|
||||
onSelectValue={val => onChange({ start: String(val), end })}
|
||||
onSelectValue={(val) => onChange({ start: String(val), end })}
|
||||
value={start}
|
||||
selectContentClass="z-[101]"
|
||||
/>
|
||||
<span className="font-semibold text-xl mx-2">~</span>
|
||||
<span className="mx-2 text-xl font-semibold">~</span>
|
||||
<SelectBox
|
||||
className="flex-1"
|
||||
selectClass={selectClass}
|
||||
label={endLabel}
|
||||
selectArray={getEndYearArray()}
|
||||
onSelectValue={val => onChange({ start, end: String(val) })}
|
||||
onSelectValue={(val) => onChange({ start, end: String(val) })}
|
||||
value={end}
|
||||
selectContentClass="z-[101]"
|
||||
/>
|
||||
|
||||
@@ -13,11 +13,10 @@
|
||||
* - 관망도 시설물 목록 조회: GET /api/v1/fclts (sewerage-service)
|
||||
*/
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { SelectBox } from '@common/business_components/ui'
|
||||
import { Label } from '@common/components';
|
||||
import useStore from '../../store/useStore';
|
||||
import { getData } from '@/components/lib/api/index';
|
||||
|
||||
import { SelectBox } from "@common/business_components/ui";
|
||||
import { Label } from "@common/components";
|
||||
import useStore from "../../store/useStore";
|
||||
import { getData } from "@/components/lib/api/index";
|
||||
|
||||
interface ApiSelectOption {
|
||||
text: string;
|
||||
@@ -43,9 +42,9 @@ interface CtpvSggPrcsFcltSelectBoxProps {
|
||||
selectValueClassName?: string;
|
||||
}
|
||||
|
||||
const defaultOption: ApiSelectOption = { val: '0', text: '전체' };
|
||||
const defaultOption: ApiSelectOption = { val: "0", text: "전체" };
|
||||
const buildFacilityDefaultOption = (facilityName: string): ApiSelectOption => ({
|
||||
val: '0',
|
||||
val: "0",
|
||||
text: facilityName || defaultOption.text,
|
||||
});
|
||||
|
||||
@@ -66,15 +65,16 @@ export function CtpvSggPrcsFcltSelectBox({
|
||||
compact = false,
|
||||
selectValueClassName = "",
|
||||
}: CtpvSggPrcsFcltSelectBoxProps) {
|
||||
|
||||
const [prcsFcltOptions, setPrcsFcltOptions] = useState<ApiSelectOption[]>([]);
|
||||
const { ctpv, sgg } = useStore();
|
||||
const sidoStr = String(sido ?? '');
|
||||
const sidoStr = String(sido ?? "");
|
||||
const sidoPrefix = sidoStr.slice(0, 2);
|
||||
const normalizedDefaultFacilityName = String(defaultSwtrmtFcltNm ?? '').trim();
|
||||
const normalizedDefaultFacilityName = String(
|
||||
defaultSwtrmtFcltNm ?? "",
|
||||
).trim();
|
||||
|
||||
useEffect(() => {
|
||||
onSwagePrcsFcltChange?.('0');
|
||||
onSwagePrcsFcltChange?.("0");
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!sggName) {
|
||||
@@ -85,25 +85,32 @@ export function CtpvSggPrcsFcltSelectBox({
|
||||
|
||||
if (setIsLoading) setIsLoading(true);
|
||||
try {
|
||||
|
||||
const queryParamsFacility = new URLSearchParams({
|
||||
lyrTypeNm: 'tb_il_swage_prcs_fclt',
|
||||
page: '0',
|
||||
size: '100000', // 요청하신 URL에 맞게 50으로 변경 가능
|
||||
sort: 'string',
|
||||
lyrTypeNm: "tb_il_swage_prcs_fclt",
|
||||
page: "0",
|
||||
size: "100000", // 요청하신 URL에 맞게 50으로 변경 가능
|
||||
sort: "string",
|
||||
});
|
||||
const cqlFiltersFacility: string[] = [];
|
||||
if (sggName) cqlFiltersFacility.push(`sgg_nm ILIKE '%${sggName}%'`);
|
||||
if (cqlFiltersFacility.length > 0) queryParamsFacility.append('cqlFiter', cqlFiltersFacility.join(' and '));
|
||||
if (cqlFiltersFacility.length > 0)
|
||||
queryParamsFacility.append(
|
||||
"cqlFiter",
|
||||
cqlFiltersFacility.join(" and "),
|
||||
);
|
||||
|
||||
const facilityRes = await getData(`v1/fclts?${queryParamsFacility.toString()}`);
|
||||
const facilityRes = await getData(
|
||||
`v1/fclts?${queryParamsFacility.toString()}`,
|
||||
);
|
||||
|
||||
const facilities = (facilityRes.data || [])
|
||||
.map((item: any) => ({
|
||||
text: item.swtrmtFcltNm,
|
||||
val: item.fid
|
||||
val: item.fid,
|
||||
}))
|
||||
.sort((a: ApiSelectOption, b: ApiSelectOption) => a.text.localeCompare(b.text, 'ko'));
|
||||
.sort((a: ApiSelectOption, b: ApiSelectOption) =>
|
||||
a.text.localeCompare(b.text, "ko"),
|
||||
);
|
||||
setPrcsFcltOptions(facilities);
|
||||
} catch (error) {
|
||||
console.error("BaseLayout: 지역/시설 데이터 조회 중 오류 발생:", error);
|
||||
@@ -121,7 +128,8 @@ export function CtpvSggPrcsFcltSelectBox({
|
||||
if (!selectedSwagePrcsFclt || selectedSwagePrcsFclt !== "0") return;
|
||||
if (prcsFcltOptions.length === 0) return;
|
||||
|
||||
const matchedDefaultFacility = prcsFcltOptions.find((option) =>
|
||||
const matchedDefaultFacility = prcsFcltOptions.find(
|
||||
(option) =>
|
||||
String(option.text ?? "").trim() === normalizedDefaultFacilityName,
|
||||
);
|
||||
if (!matchedDefaultFacility) return;
|
||||
@@ -137,47 +145,77 @@ export function CtpvSggPrcsFcltSelectBox({
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-center ${className} ${compact ? "mr-0" : "mr-8"}`}>
|
||||
<Label className={`${compact ? "mr-1.5 text-base" : "mr-2 text-xl"} whitespace-nowrap font-medium text-gray-600`}>시도</Label>
|
||||
<div
|
||||
className={`flex items-center justify-center ${className} ${compact ? "mr-0" : "mr-8"}`}
|
||||
>
|
||||
<Label
|
||||
className={`${compact ? "mr-1.5" : "mr-2"} whitespace-nowrap text-[1.3rem] font-medium text-label`}
|
||||
>
|
||||
시도
|
||||
</Label>
|
||||
<SelectBox
|
||||
className={compact ? "mr-4 w-[180px]" : "mr-8 w-48"}
|
||||
selectClass={`${compact ? "w-[180px]" : ""} rounded-lg bg-white ${selectValueClassName}`}
|
||||
selectClass={`${compact ? "w-[180px]" : ""} rounded-lg bg-background ${selectValueClassName}`}
|
||||
label=""
|
||||
selectArray={[defaultOption, ...ctpv]}
|
||||
value={sido}
|
||||
onSelectValue={val => {
|
||||
onSelectValue={(val) => {
|
||||
onSidoChange(String(val));
|
||||
if (setCtpvName) setCtpvName(ctpv.find(opt => String(opt.val) === String(val))?.text || "");
|
||||
if (setCtpvName)
|
||||
setCtpvName(
|
||||
ctpv.find((opt) => String(opt.val) === String(val))?.text || "",
|
||||
);
|
||||
if (setSwtrmtFcltNm) setSwtrmtFcltNm("");
|
||||
}}
|
||||
selectContentClass="z-[101]"
|
||||
/>
|
||||
<Label className={`${compact ? "mr-1.5 text-base" : "mr-2 text-xl"} whitespace-nowrap font-medium text-gray-600`}>시군구</Label>
|
||||
<Label
|
||||
className={`${compact ? "mr-1.5" : "mr-2"} whitespace-nowrap text-[1.3rem] font-medium text-label`}
|
||||
>
|
||||
시군구
|
||||
</Label>
|
||||
<SelectBox
|
||||
className={compact ? "w-36" : "w-48"}
|
||||
selectClass={`${compact ? "w-36" : "w-48"} truncate rounded-lg bg-white ${selectValueClassName}`}
|
||||
selectClass={`${compact ? "w-36" : "w-48"} truncate rounded-lg bg-background ${selectValueClassName}`}
|
||||
label=""
|
||||
selectArray={[defaultOption, ...sgg.filter(opt => opt.val.startsWith(sidoPrefix))]}
|
||||
selectArray={[
|
||||
defaultOption,
|
||||
...sgg.filter((opt) => opt.val.startsWith(sidoPrefix)),
|
||||
]}
|
||||
value={sigungu}
|
||||
onSelectValue={val => {
|
||||
onSigunguChange(String(val))
|
||||
if (setSggName) setSggName(sgg.find(opt => String(opt.val) === String(val))?.text || "");
|
||||
if (setSwtrmtFcltNm) setSwtrmtFcltNm("")
|
||||
onSelectValue={(val) => {
|
||||
onSigunguChange(String(val));
|
||||
if (setSggName)
|
||||
setSggName(
|
||||
sgg.find((opt) => String(opt.val) === String(val))?.text || "",
|
||||
);
|
||||
if (setSwtrmtFcltNm) setSwtrmtFcltNm("");
|
||||
}}
|
||||
selectContentClass="z-[101]"
|
||||
/>
|
||||
{selectedSwagePrcsFclt && onSwagePrcsFcltChange && (
|
||||
<>
|
||||
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap ml-8 mr-4">하수처리시설명</Label>
|
||||
<Label
|
||||
className={`${compact ? "ml-4 mr-1.5" : "ml-8 mr-4"} whitespace-nowrap text-[1.3rem] font-medium text-label`}
|
||||
>
|
||||
하수처리시설명
|
||||
</Label>
|
||||
<SelectBox
|
||||
className="w-[160px]"
|
||||
selectClass="rounded-lg bg-white truncate"
|
||||
selectClass={`truncate rounded-lg bg-background ${selectValueClassName}`}
|
||||
label=""
|
||||
selectArray={[buildFacilityDefaultOption(normalizedDefaultFacilityName), ...prcsFcltOptions]}
|
||||
selectArray={[
|
||||
buildFacilityDefaultOption(normalizedDefaultFacilityName),
|
||||
...prcsFcltOptions,
|
||||
]}
|
||||
value={selectedSwagePrcsFclt}
|
||||
onSelectValue={(val) => {
|
||||
onSwagePrcsFcltChange?.(String(val))
|
||||
if (setSwtrmtFcltNm) setSwtrmtFcltNm(prcsFcltOptions.find(opt => String(opt.val) === String(val))?.text || "")
|
||||
onSwagePrcsFcltChange?.(String(val));
|
||||
if (setSwtrmtFcltNm)
|
||||
setSwtrmtFcltNm(
|
||||
prcsFcltOptions.find((opt) => String(opt.val) === String(val))
|
||||
?.text || "",
|
||||
);
|
||||
}}
|
||||
selectContentClass="z-[101]"
|
||||
/>
|
||||
|
||||
@@ -9,16 +9,27 @@
|
||||
* - 파일 형식 검증 및 미리보기 지원
|
||||
* - TUS 프로토콜을 통한 대용량 파일 업로드
|
||||
*/
|
||||
"use client"
|
||||
"use client";
|
||||
import { FileUploader } from "@common/components";
|
||||
import { Button } from '@common/components/ui';
|
||||
import { Button } from "@common/components/ui";
|
||||
import { handleUpload } from "@/components/lib/utils";
|
||||
import { useId, useState } from 'react';
|
||||
import { useId, useState } from "react";
|
||||
import type { HttpResponse, Upload } from "tus-js-client";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
|
||||
interface FileUploadFormProps {
|
||||
onSubmit?: (files: string, year: string, quarter: string, code: string, id: string) => void;
|
||||
onFileSelect?: (file: File | null, atflCd: string | null, atflId: string | null) => void; // 파일 선택 시 호출될 콜백 추가
|
||||
onSubmit?: (
|
||||
files: string,
|
||||
year: string,
|
||||
quarter: string,
|
||||
code: string,
|
||||
id: string,
|
||||
) => void;
|
||||
onFileSelect?: (
|
||||
file: File | null,
|
||||
atflCd: string | null,
|
||||
atflId: string | null,
|
||||
) => void; // 파일 선택 시 호출될 콜백 추가
|
||||
showButton?: boolean; // 버튼 표시 여부 (기본값 true)
|
||||
showPreview?: boolean; // 파일명 프리뷰 표시 여부
|
||||
accept?: string; // 파일 형식 지정
|
||||
@@ -31,38 +42,48 @@ const FileUploadForm = ({
|
||||
showButton = true,
|
||||
showPreview = false,
|
||||
accept = ".jpeg, .jpg, .png",
|
||||
fileIdx // props에서 fileIdx 받기
|
||||
fileIdx, // props에서 fileIdx 받기
|
||||
}: FileUploadFormProps) => {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [internalAtflCd, setInternalAtflCd] = useState<string | null>(null);
|
||||
const [internalAtflId, setInternalAtflId] = useState<string | null>(null);
|
||||
const [currentFile, setCurrentFile] = useState<File | null>(null); // 현재 선택된 파일을 저장할 상태
|
||||
const [fileName, setFileName] = useState<string>('');
|
||||
const [fileName, setFileName] = useState<string>("");
|
||||
const [files, setFiles] = useState<FileList | null>(null);
|
||||
const [upload, setUpload] = useState<Upload | null>(null);
|
||||
const uniqueId = useId(); // 각 FileUploadForm 인스턴스에 대한 고유 ID 생성
|
||||
// FileUploader 컴포넌트를 강제로 재마운트하기 위한 key 상태 추가
|
||||
const [uploaderKey, setUploaderKey] = useState(0);
|
||||
|
||||
const quater = [{ text: '1분기', val: '1' }, { text: '2분기', val: '2' }, { text: '3분기', val: '3' }, { text: '4분기', val: '4' }];
|
||||
const quater = [
|
||||
{ text: "1분기", val: "1" },
|
||||
{ text: "2분기", val: "2" },
|
||||
{ text: "3분기", val: "3" },
|
||||
{ text: "4분기", val: "4" },
|
||||
];
|
||||
// 현재 년도부터 10년 전까지의 년도 옵션 생성
|
||||
const yearOptions = Array.from({ length: 10 }, (_, i) => {
|
||||
const year = new Date().getFullYear() - i;
|
||||
return { text: `${year}년`, val: year.toString() };
|
||||
});
|
||||
const [selectedYear, setSelectedYear] = useState<string>(yearOptions[0]?.val || '');
|
||||
const [selectedQuarter, setSelectedQuarter] = useState<string>(quater[0]?.val || '');
|
||||
const [selectedYear, setSelectedYear] = useState<string>(
|
||||
yearOptions[0]?.val || "",
|
||||
);
|
||||
const [selectedQuarter, setSelectedQuarter] = useState<string>(
|
||||
quater[0]?.val || "",
|
||||
);
|
||||
|
||||
function registFile() {
|
||||
if (!currentFile) {
|
||||
alert('파일을 선택해주세요.');
|
||||
alert("파일을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!selectedYear) {
|
||||
alert('등록년도를 선택해주세요.');
|
||||
alert("등록년도를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!selectedQuarter) {
|
||||
alert('분기를 선택해주세요.');
|
||||
alert("분기를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -72,27 +93,36 @@ const FileUploadForm = ({
|
||||
handleUploadWithResponse(files, currentFile)
|
||||
.then(({ atflCd, atflId }) => {
|
||||
// 업로드 완료 후 onSubmit 실행
|
||||
console.log('fileNm', fileNm);
|
||||
console.log('selectedYear', selectedYear);
|
||||
console.log('selectedQuarter', selectedQuarter);
|
||||
console.log('atflCd', atflCd);
|
||||
console.log('atflId', atflId);
|
||||
onSubmit(fileNm, selectedYear, selectedQuarter, atflCd || '', atflId || '');
|
||||
console.log("fileNm", fileNm);
|
||||
console.log("selectedYear", selectedYear);
|
||||
console.log("selectedQuarter", selectedQuarter);
|
||||
console.log("atflCd", atflCd);
|
||||
console.log("atflId", atflId);
|
||||
onSubmit(
|
||||
fileNm,
|
||||
selectedYear,
|
||||
selectedQuarter,
|
||||
atflCd || "",
|
||||
atflId || "",
|
||||
);
|
||||
|
||||
// UI 초기화
|
||||
setFiles(null);
|
||||
setFileName('');
|
||||
setUploaderKey(prev => prev + 1);
|
||||
setFileName("");
|
||||
setUploaderKey((prev) => prev + 1);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('파일 업로드 실패:', error);
|
||||
alert('파일 업로드 중 오류가 발생했습니다.');
|
||||
console.error("파일 업로드 실패:", error);
|
||||
alert("파일 업로드 중 오류가 발생했습니다.");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// handleUpload를 Promise로 감싸는 헬퍼 함수
|
||||
function handleUploadWithResponse(files: FileList, file: File): Promise<{ atflCd: string | null, atflId: string | null }> {
|
||||
function handleUploadWithResponse(
|
||||
files: FileList,
|
||||
file: File,
|
||||
): Promise<{ atflCd: string | null; atflId: string | null }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const uploadResponseHandler = async (res: HttpResponse) => {
|
||||
try {
|
||||
@@ -102,7 +132,8 @@ const FileUploadForm = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const { atflCd: newAtflCd, atflId: newAtflId } = JSON.parse(fileInfo) ?? {};
|
||||
const { atflCd: newAtflCd, atflId: newAtflId } =
|
||||
JSON.parse(fileInfo) ?? {};
|
||||
|
||||
// 내부 상태도 업데이트 (다른 용도로 사용할 수 있음)
|
||||
setInternalAtflCd(newAtflCd);
|
||||
@@ -121,11 +152,11 @@ const FileUploadForm = ({
|
||||
};
|
||||
|
||||
const uploadErrorHandler = (error: Error) => {
|
||||
console.error('Upload failed:', error);
|
||||
console.error("Upload failed:", error);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
console.log('Starting upload for files:', files);
|
||||
console.log("Starting upload for files:", files);
|
||||
|
||||
// 기존 handleUpload 유틸리티 사용 (수정하지 않음)
|
||||
handleUpload(files, {
|
||||
@@ -136,16 +167,23 @@ const FileUploadForm = ({
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div> {/* 컴포넌트 루트 div */}
|
||||
<div className="card p-6"> {/* 카드 스타일링 */}
|
||||
<div className='flex items-center gap-2'> {/* FileUploader와 Button을 정렬하는 메인 flex 컨테이너 */}
|
||||
<div className="w-[300px] mr-4"> {/* FileUploader의 너비와 오른쪽 여백을 제어하는 컨테이너 */}
|
||||
<div>
|
||||
{" "}
|
||||
{/* 컴포넌트 루트 div */}
|
||||
<div className="card p-6">
|
||||
{" "}
|
||||
{/* 카드 스타일링 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{" "}
|
||||
{/* FileUploader와 Button을 정렬하는 메인 flex 컨테이너 */}
|
||||
<div className="mr-4 w-[300px]">
|
||||
{" "}
|
||||
{/* FileUploader의 너비와 오른쪽 여백을 제어하는 컨테이너 */}
|
||||
<FileUploader
|
||||
key={uploaderKey} // key 추가: 변경 시 컴포넌트 재마운트됨
|
||||
id={uniqueId}
|
||||
className="!h-auto !max-w-none flex-row items-center gap-2"
|
||||
className="!h-auto !max-w-none flex-row items-center gap-2 [&_.file-uploader-label]:h-[3.2rem]"
|
||||
accept={accept}
|
||||
onUpload={(e) => {
|
||||
const selectedFiles = e.target.files;
|
||||
@@ -167,9 +205,9 @@ const FileUploadForm = ({
|
||||
setFileName(firstFile.name);
|
||||
setCurrentFile(firstFile);
|
||||
|
||||
console.log('selectedFiles', selectedFiles);
|
||||
console.log('firstFile', firstFile);
|
||||
console.log('firstFile name', firstFile.name);
|
||||
console.log("selectedFiles", selectedFiles);
|
||||
console.log("firstFile", firstFile);
|
||||
console.log("firstFile name", firstFile.name);
|
||||
|
||||
if (onFileSelect) {
|
||||
onFileSelect(firstFile, internalAtflCd, internalAtflId);
|
||||
@@ -188,8 +226,7 @@ const FileUploadForm = ({
|
||||
</div>
|
||||
{showButton ? ( // showButton이 true일 때만 버튼 렌더링
|
||||
<Button
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700"
|
||||
size="sm"
|
||||
className="hover:bg-primary/90 rounded-sm bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
|
||||
onClick={() => registFile()}
|
||||
>
|
||||
등록
|
||||
@@ -198,7 +235,7 @@ const FileUploadForm = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default FileUploadForm
|
||||
export default FileUploadForm;
|
||||
|
||||
@@ -9,23 +9,41 @@
|
||||
* - 콜백 함수를 통한 업로드 결과 전달
|
||||
* - TUS 프로토콜을 통한 대용량 파일 업로드
|
||||
*/
|
||||
"use client"
|
||||
import { SelectBox } from '@common/business_components/ui';
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import FileUploadForm from './fileUploadForm';
|
||||
import { yearOptions, getQuarterOptions } from '@/components/lib/utils';
|
||||
"use client";
|
||||
import { SelectBox } from "@common/business_components/ui";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import FileUploadForm from "./fileUploadForm";
|
||||
import { yearOptions, getQuarterOptions } from "@/components/lib/utils";
|
||||
|
||||
const operFileUploadForm = ({ title, onSubmit }: { title: string, onSubmit: (files: string, year: string, quarter: string, code: string, id: string) => void }) => {
|
||||
const [selectedYear, setSelectedYear] = useState<string>(yearOptions[0]?.val || '');
|
||||
const [quaterOptions, setQuaterOptions] = useState(() => getQuarterOptions(selectedYear));
|
||||
const [selectedQuarter, setSelectedQuarter] = useState<string>(quaterOptions[0]?.val || '');
|
||||
const operFileUploadForm = ({
|
||||
title,
|
||||
onSubmit,
|
||||
}: {
|
||||
title: string;
|
||||
onSubmit: (
|
||||
files: string,
|
||||
year: string,
|
||||
quarter: string,
|
||||
code: string,
|
||||
id: string,
|
||||
) => void;
|
||||
}) => {
|
||||
const [selectedYear, setSelectedYear] = useState<string>(
|
||||
yearOptions[0]?.val || "",
|
||||
);
|
||||
const [quaterOptions, setQuaterOptions] = useState(() =>
|
||||
getQuarterOptions(selectedYear),
|
||||
);
|
||||
const [selectedQuarter, setSelectedQuarter] = useState<string>(
|
||||
quaterOptions[0]?.val || "",
|
||||
);
|
||||
|
||||
// 년도가 변경될 때 분기 옵션 업데이트
|
||||
useEffect(() => {
|
||||
const updatedQuarterOptions = getQuarterOptions(selectedYear);
|
||||
setQuaterOptions(updatedQuarterOptions);
|
||||
// 분기 옵션이 변경되면 첫 번째 옵션으로 자동 선택
|
||||
setSelectedQuarter(updatedQuarterOptions[0]?.val || '');
|
||||
setSelectedQuarter(updatedQuarterOptions[0]?.val || "");
|
||||
}, [selectedYear]);
|
||||
|
||||
// 년도 변경 핸들러
|
||||
@@ -36,15 +54,15 @@ const operFileUploadForm = ({ title, onSubmit }: { title: string, onSubmit: (fil
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='card-header'>
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl font-medium">{title}</h3>
|
||||
</div>
|
||||
<div className="card p-6">
|
||||
<div className="grid grid-col-2 items-center space-x-4">
|
||||
<div className='flex'>
|
||||
<div className="grid-col-2 grid items-center space-x-4">
|
||||
<div className="flex">
|
||||
<SelectBox
|
||||
className="w-50 mr-3 ml-4"
|
||||
selectClass="rounded-lg bg-white"
|
||||
className="w-50 ml-4 mr-3"
|
||||
selectClass="rounded-lg bg-background"
|
||||
label="년도"
|
||||
selectArray={yearOptions}
|
||||
onSelectValue={handleYearChange}
|
||||
@@ -52,24 +70,33 @@ const operFileUploadForm = ({ title, onSubmit }: { title: string, onSubmit: (fil
|
||||
/>
|
||||
<SelectBox
|
||||
className="w-50"
|
||||
selectClass="rounded-lg bg-white"
|
||||
selectClass="rounded-lg bg-background"
|
||||
label="분기"
|
||||
selectArray={quaterOptions}
|
||||
onSelectValue={(value: string | number) => setSelectedQuarter(String(value))}
|
||||
onSelectValue={(value: string | number) =>
|
||||
setSelectedQuarter(String(value))
|
||||
}
|
||||
value={selectedQuarter}
|
||||
/>
|
||||
<FileUploadForm
|
||||
onSubmit={(files, year, quarter, code, id) => {
|
||||
onSubmit(files, (selectedYear ?? year) || '', (selectedQuarter ?? quarter) || '', code, id);
|
||||
onSubmit(
|
||||
files,
|
||||
(selectedYear ?? year) || "",
|
||||
(selectedQuarter ?? quarter) || "",
|
||||
code,
|
||||
id,
|
||||
);
|
||||
}}
|
||||
showButton={true}
|
||||
showPreview={true}
|
||||
accept={""} />
|
||||
accept={""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default operFileUploadForm
|
||||
export default operFileUploadForm;
|
||||
|
||||
@@ -12,11 +12,15 @@
|
||||
import { Button } from "@common/components/ui";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
export const confirmToast = (content: string, saveFunc: () => void, contentClass?:string) => {
|
||||
export const confirmToast = (
|
||||
content: string,
|
||||
saveFunc: () => void,
|
||||
contentClass?: string,
|
||||
) => {
|
||||
toast(
|
||||
(t) => (
|
||||
<span className="flex flex-col items-center">
|
||||
<div className={`mb-6 ${contentClass}`} >{content}</div>
|
||||
<div className={`mb-6 ${contentClass}`}>{content}</div>
|
||||
<div className="flex flex-row space-x-2">
|
||||
<Button
|
||||
size={"sm"}
|
||||
@@ -36,7 +40,7 @@ export const confirmToast = (content: string, saveFunc: () => void, contentClass
|
||||
{
|
||||
id: "global-confirm-toast", // 중복 토스트 방지
|
||||
duration: Infinity, // 자동 사라짐 방지
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -44,16 +48,15 @@ export const showConfirmToast = (message: string) => {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<div
|
||||
className={`w-[500px] min-h-[140px] bg-white shadow-lg rounded-xl p-6 border border-gray-200
|
||||
${t.visible ? "animate-enter" : "animate-leave"}`}
|
||||
className={`min-h-[140px] w-[500px] rounded-xl border border-border bg-background p-6 shadow-lg ${t.visible ? "animate-enter" : "animate-leave"}`}
|
||||
>
|
||||
<div className="text-3xl font-extrabold text-red-600 bg-yellow-100 px-4 py-2 rounded-md shadow-md">
|
||||
<div className="rounded-md bg-yellow-100 px-4 py-2 text-3xl font-extrabold text-red-600 shadow-md">
|
||||
{message}
|
||||
</div>{" "}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size={"sm"}
|
||||
className="px-4 py-1.5 rounded bg-gray-200 text-sm hover:bg-gray-300"
|
||||
className="rounded bg-secondary px-4 py-1.5 text-sm text-secondary-foreground hover:bg-accent"
|
||||
onClick={() => toast.dismiss(t.id)}
|
||||
>
|
||||
확인완료
|
||||
@@ -61,6 +64,6 @@ export const showConfirmToast = (message: string) => {
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{ duration: Infinity }
|
||||
{ duration: Infinity },
|
||||
); // 수동 닫기
|
||||
};
|
||||
@@ -10,10 +10,14 @@
|
||||
* - 폼 데이터 변경 콜백 및 저장 기능
|
||||
*/
|
||||
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
|
||||
import { Table, TableBody, TableCell, TableRow, Button } from "@common/components";
|
||||
import {
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
import { useEffect, useState } from "react";
|
||||
import { confirmToast, showConfirmToast } from '@/components/ui/toast'
|
||||
import { toast, Toaster } from "react-hot-toast";
|
||||
|
||||
type UiTableProps = {
|
||||
headName: React.ReactNode | undefined;
|
||||
@@ -34,7 +38,9 @@ type UiTableProps = {
|
||||
pageSize2?: number;
|
||||
total2?: number;
|
||||
children2?: Record<string, React.ReactNode> | undefined;
|
||||
cellClick2?: ((index: number | undefined, headId: string) => void) | undefined;
|
||||
cellClick2?:
|
||||
| ((index: number | undefined, headId: string) => void)
|
||||
| undefined;
|
||||
onPageChange2?: (page: number) => void;
|
||||
className2?: string;
|
||||
resetPageNo2?: boolean;
|
||||
@@ -42,7 +48,9 @@ type UiTableProps = {
|
||||
showSecondTable?: boolean;
|
||||
showSaveButton?: boolean;
|
||||
onSaveClick?: () => void;
|
||||
onFormDataChange?: (formData: { [key: string]: string | number | boolean }) => void; // 추가
|
||||
onFormDataChange?: (formData: {
|
||||
[key: string]: string | number | boolean;
|
||||
}) => void; // 추가
|
||||
};
|
||||
|
||||
type UiSelectOption =
|
||||
@@ -68,10 +76,14 @@ type UiHeadProps = {
|
||||
};
|
||||
|
||||
const getSelectOptionValue = (option: UiSelectOption) =>
|
||||
typeof option === "string" ? option : String(option.value ?? option.val ?? "");
|
||||
typeof option === "string"
|
||||
? option
|
||||
: String(option.value ?? option.val ?? "");
|
||||
|
||||
const getSelectOptionLabel = (option: UiSelectOption) =>
|
||||
typeof option === "string" ? option : String(option.label ?? option.text ?? getSelectOptionValue(option));
|
||||
typeof option === "string"
|
||||
? option
|
||||
: String(option.label ?? option.text ?? getSelectOptionValue(option));
|
||||
|
||||
const isSelectOptionHidden = (option: UiSelectOption) =>
|
||||
typeof option === "string" ? false : Boolean(option.hidden);
|
||||
@@ -97,7 +109,9 @@ export function VerticalTable({
|
||||
onFormDataChange,
|
||||
}: UiTableProps) {
|
||||
const [cur, setCur] = useState<number>(1);
|
||||
const [formData, setFormData] = useState<{ [key: string]: string | number | boolean }>({});
|
||||
const [formData, setFormData] = useState<{
|
||||
[key: string]: string | number | boolean;
|
||||
}>({});
|
||||
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -123,7 +137,7 @@ export function VerticalTable({
|
||||
setFormData(newFormData);
|
||||
|
||||
if (errors[id]) {
|
||||
setErrors(prev => {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[id];
|
||||
return newErrors;
|
||||
@@ -141,9 +155,9 @@ export function VerticalTable({
|
||||
};
|
||||
|
||||
const showError = (fieldId: string, message: string) => {
|
||||
setErrors(prev => ({
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
[fieldId]: message
|
||||
[fieldId]: message,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -151,39 +165,46 @@ export function VerticalTable({
|
||||
<>
|
||||
{headName ? (
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="text-2xl mx-3 font-medium text-label">{headName}</h3>
|
||||
<h3 className="mx-3 text-2xl font-medium text-label">{headName}</h3>
|
||||
{showSaveButton && onSaveClick && (
|
||||
<Button
|
||||
onClick={onSaveClick}
|
||||
className="px-4 mr-3 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700"
|
||||
>
|
||||
<Button type="button" color="primary" onClick={onSaveClick}>
|
||||
저장
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={`card !m-[1.2rem] !p-0r ${className}`}>
|
||||
<div className={`card !m-[1.2rem] !p-0 ${className}`}>
|
||||
{tableName1 && ( // tableName prop이 있으면 표시
|
||||
<div className="text-xl font-semibold my-2 mx-3">{tableName1}</div>
|
||||
<div className="mx-3 my-2 text-xl font-semibold">{tableName1}</div>
|
||||
)}
|
||||
<Table variant="secondary">
|
||||
<TableBody className="text-center">
|
||||
{headlist?.map((head) => (
|
||||
<TableRow key={head.id}>
|
||||
{/* 컬럼 제목 */}
|
||||
<TableCell className={`font-bold bg-blue-100 text-xs w-[120px] whitespace-nowrap`}>
|
||||
<TableCell className="w-[120px] whitespace-nowrap bg-secondary text-xs font-bold text-label">
|
||||
{head.title}
|
||||
{head.required ? <span className="ml-1 text-red-600">*</span> : null}
|
||||
{head.required ? (
|
||||
<span className="ml-1 text-red-600">*</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
{/* 데이터 값 또는 입력 필드 */}
|
||||
<TableCell className="text-xs">
|
||||
{children && children[head.id] ? children[head.id] : isEditable ? (
|
||||
{children && children[head.id] ? (
|
||||
children[head.id]
|
||||
) : isEditable ? (
|
||||
head.type === "select" ? (
|
||||
<select
|
||||
id={head.id}
|
||||
value={formData[head.id] !== undefined ? String(formData[head.id]) : ""}
|
||||
onChange={(e) => handleInputChange(head.id, e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-sm px-2 py-1 text-xl border-radius-0"
|
||||
value={
|
||||
formData[head.id] !== undefined
|
||||
? String(formData[head.id])
|
||||
: ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
handleInputChange(head.id, e.target.value)
|
||||
}
|
||||
className="border-radius-0 w-full rounded-sm border border-border px-2 py-1 text-xl"
|
||||
>
|
||||
{head.options?.map((option, optionIndex) => {
|
||||
const optionValue = getSelectOptionValue(option);
|
||||
@@ -215,35 +236,51 @@ export function VerticalTable({
|
||||
|
||||
const isValidNumber = /^[0-9]+$/.test(newValue);
|
||||
if (!isValidNumber) {
|
||||
showError(head.id, `${head.title}는 숫자만 입력 가능합니다.`);
|
||||
showError(
|
||||
head.id,
|
||||
`${head.title}는 숫자만 입력 가능합니다.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (head.length && newValue.length > head.length) {
|
||||
showError(head.id, `${head.title}는 최대 ${head.length}자까지 입력 가능합니다.`);
|
||||
if (
|
||||
head.length &&
|
||||
newValue.length > head.length
|
||||
) {
|
||||
showError(
|
||||
head.id,
|
||||
`${head.title}는 최대 ${head.length}자까지 입력 가능합니다.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const numericValue = parseInt(newValue);
|
||||
if (isNaN(numericValue) || numericValue < 0) return;
|
||||
if (isNaN(numericValue) || numericValue < 0)
|
||||
return;
|
||||
|
||||
handleInputChange(head.id, numericValue);
|
||||
} else {
|
||||
const newValue = e.target.value;
|
||||
|
||||
if (head.length && newValue.length > head.length) {
|
||||
showError(head.id, `${head.title}는 최대 ${head.length}자까지 입력 가능합니다.`);
|
||||
if (
|
||||
head.length &&
|
||||
newValue.length > head.length
|
||||
) {
|
||||
showError(
|
||||
head.id,
|
||||
`${head.title}는 최대 ${head.length}자까지 입력 가능합니다.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
handleInputChange(head.id, e.target.value);
|
||||
}
|
||||
}}
|
||||
className="w-full text-xl border-radius-0 border border-gray-300 rounded-sm px-2 py-1"
|
||||
className="border-radius-0 w-full rounded-sm border border-border px-2 py-1 text-xl"
|
||||
placeholder={head.placeholder}
|
||||
/>
|
||||
{errors[head.id] && (
|
||||
<div className="text-red-500 font-bold text-xl mt-1 text-left">
|
||||
<div className="mt-1 text-left text-xl font-bold text-red-500">
|
||||
{errors[head.id]}
|
||||
</div>
|
||||
)}
|
||||
@@ -260,7 +297,9 @@ export function VerticalTable({
|
||||
</TableBody>
|
||||
</Table>
|
||||
{tableName2 && ( // tableName prop이 있으면 표시
|
||||
<div className="text-xl font-semibold mt-8 my-2 mx-3">{tableName2}</div>
|
||||
<div className="mx-3 my-2 mt-8 text-xl font-semibold">
|
||||
{tableName2}
|
||||
</div>
|
||||
)}
|
||||
{showSecondTable && (
|
||||
<Table variant="secondary">
|
||||
@@ -268,19 +307,29 @@ export function VerticalTable({
|
||||
{headlist2?.map((head) => (
|
||||
<TableRow key={head.id}>
|
||||
{/* 컬럼 제목 */}
|
||||
<TableCell className={`font-bold bg-blue-100 text-xs w-[120px] whitespace-nowrap`}>
|
||||
<TableCell className="w-[120px] whitespace-nowrap bg-secondary text-xs font-bold text-label">
|
||||
{head.title}
|
||||
{head.required ? <span className="ml-1 text-red-600">*</span> : null}
|
||||
{head.required ? (
|
||||
<span className="ml-1 text-red-600">*</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
{/* 데이터 값 또는 입력 필드 */}
|
||||
<TableCell className="text-xs">
|
||||
{children && children[head.id] ? children[head.id] : isEditable2 ? (
|
||||
{children && children[head.id] ? (
|
||||
children[head.id]
|
||||
) : isEditable2 ? (
|
||||
head.type === "select" ? (
|
||||
<select
|
||||
id={head.id}
|
||||
value={formData[head.id] !== undefined ? String(formData[head.id]) : ""}
|
||||
onChange={(e) => handleInputChange(head.id, e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-sm px-2 py-1 text-xl border-radius-0"
|
||||
value={
|
||||
formData[head.id] !== undefined
|
||||
? String(formData[head.id])
|
||||
: ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
handleInputChange(head.id, e.target.value)
|
||||
}
|
||||
className="border-radius-0 w-full rounded-sm border border-border px-2 py-1 text-xl"
|
||||
>
|
||||
{head.options?.map((option, optionIndex) => {
|
||||
const optionValue = getSelectOptionValue(option);
|
||||
@@ -299,8 +348,18 @@ export function VerticalTable({
|
||||
<div className="w-full">
|
||||
<input
|
||||
id={head.id}
|
||||
type={head.type === "date" ? "date" : head.isNumber ? "number" : "text"}
|
||||
value={formData[head.id] !== undefined ? String(formData[head.id]) : ""}
|
||||
type={
|
||||
head.type === "date"
|
||||
? "date"
|
||||
: head.isNumber
|
||||
? "number"
|
||||
: "text"
|
||||
}
|
||||
value={
|
||||
formData[head.id] !== undefined
|
||||
? String(formData[head.id])
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
if (head.isNumber) {
|
||||
const newValue = e.target.value;
|
||||
@@ -312,35 +371,51 @@ export function VerticalTable({
|
||||
|
||||
const isValidNumber = /^[0-9]+$/.test(newValue);
|
||||
if (!isValidNumber) {
|
||||
showError(head.id, `${head.title}는 숫자만 입력 가능합니다.`);
|
||||
showError(
|
||||
head.id,
|
||||
`${head.title}는 숫자만 입력 가능합니다.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (head.length && newValue.length > head.length) {
|
||||
showError(head.id, `${head.title}는 최대 ${head.length}자까지 입력 가능합니다.`);
|
||||
if (
|
||||
head.length &&
|
||||
newValue.length > head.length
|
||||
) {
|
||||
showError(
|
||||
head.id,
|
||||
`${head.title}는 최대 ${head.length}자까지 입력 가능합니다.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const numericValue = parseInt(newValue);
|
||||
if (isNaN(numericValue) || numericValue < 0) return;
|
||||
if (isNaN(numericValue) || numericValue < 0)
|
||||
return;
|
||||
|
||||
handleInputChange(head.id, numericValue);
|
||||
} else {
|
||||
const newValue = e.target.value;
|
||||
|
||||
if (head.length && newValue.length > head.length) {
|
||||
showError(head.id, `${head.title}는 최대 ${head.length}자까지 입력 가능합니다.`);
|
||||
if (
|
||||
head.length &&
|
||||
newValue.length > head.length
|
||||
) {
|
||||
showError(
|
||||
head.id,
|
||||
`${head.title}는 최대 ${head.length}자까지 입력 가능합니다.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
handleInputChange(head.id, e.target.value);
|
||||
}
|
||||
}}
|
||||
className="w-full text-xl border-radius-0 border border-gray-300 rounded-sm px-2 py-1"
|
||||
className="border-radius-0 w-full rounded-sm border border-border px-2 py-1 text-xl"
|
||||
placeholder={head.placeholder}
|
||||
/>
|
||||
{errors[head.id] && (
|
||||
<div className="text-red-500 font-bold text-xl mt-1 text-left">
|
||||
<div className="mt-1 text-left text-xl font-bold text-red-500">
|
||||
{errors[head.id]}
|
||||
</div>
|
||||
)}
|
||||
@@ -366,7 +441,6 @@ export function VerticalTable({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Toaster position="top-center"></Toaster>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
* - 데이터 변경 감지 및 불필요한 재렌더링 방지
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import type { UiTableSort } from "@common/business_components/ui";
|
||||
|
||||
export const SEARCH_MODAL_DEFAULT_BASIC_TYPE = "tb_ms_swage_ppl";
|
||||
export const SEARCH_MODAL_DEFAULT_WORK_TYPE = "['200', '201', '204']";
|
||||
|
||||
// 날짜 조합 함수
|
||||
const getDateString = (
|
||||
@@ -51,10 +52,6 @@ export interface SearchModalState {
|
||||
// 페이지네이션
|
||||
curPage: number;
|
||||
detailCurPage: number;
|
||||
// 기본정보·유지관리 사업·보고서 목록이 공유하는 현재 단일 정렬 상태
|
||||
sort?: UiTableSort;
|
||||
// 유지관리 상세 목록의 현재 단일 정렬 상태
|
||||
detailSort?: UiTableSort;
|
||||
// 로딩 상태
|
||||
isLoading: boolean;
|
||||
isDetailLoading: boolean;
|
||||
@@ -98,8 +95,6 @@ export interface SearchModalState {
|
||||
setDetailTableCount: (count: number) => void;
|
||||
setCurPage: (page: number) => void;
|
||||
setDetailCurPage: (page: number) => void;
|
||||
setSort: (sort?: UiTableSort) => void;
|
||||
setDetailSort: (sort?: UiTableSort) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setIsDetailLoading: (loading: boolean) => void;
|
||||
setIsResetPage: (reset: boolean) => void;
|
||||
@@ -119,15 +114,20 @@ export interface SearchModalState {
|
||||
setVar2: (val: string) => void;
|
||||
setYearFilter: (filter: { start: string; end: string }) => void;
|
||||
|
||||
getDateString: (year: string, month: string, day: string, defaultDate: string) => string;
|
||||
getDateString: (
|
||||
year: string,
|
||||
month: string,
|
||||
day: string,
|
||||
defaultDate: string,
|
||||
) => string;
|
||||
resetFilters: () => void; // 초기화 액션
|
||||
}
|
||||
|
||||
const useSearchModalStore = create<SearchModalState>((set) => ({
|
||||
isSearchModalOpen: false,
|
||||
activeTab: "basic",
|
||||
basicType: "tb_ms_swage_ppl",
|
||||
workType: "['200', '201', '204']",
|
||||
basicType: SEARCH_MODAL_DEFAULT_BASIC_TYPE,
|
||||
workType: SEARCH_MODAL_DEFAULT_WORK_TYPE,
|
||||
showTable: false,
|
||||
tableData: [],
|
||||
detailTableData: [],
|
||||
@@ -135,8 +135,6 @@ const useSearchModalStore = create<SearchModalState>((set) => ({
|
||||
detailTableCount: 0,
|
||||
curPage: 1,
|
||||
detailCurPage: 1,
|
||||
sort: undefined,
|
||||
detailSort: undefined,
|
||||
isLoading: false,
|
||||
isDetailLoading: false,
|
||||
isResetPage: false,
|
||||
@@ -170,8 +168,6 @@ const useSearchModalStore = create<SearchModalState>((set) => ({
|
||||
setDetailTableCount: (count) => set({ detailTableCount: count }),
|
||||
setCurPage: (page) => set({ curPage: page }),
|
||||
setDetailCurPage: (page) => set({ detailCurPage: page }),
|
||||
setSort: (sort) => set({ sort }),
|
||||
setDetailSort: (detailSort) => set({ detailSort }),
|
||||
setIsLoading: (loading) => set({ isLoading: loading }),
|
||||
setIsDetailLoading: (loading) => set({ isDetailLoading: loading }),
|
||||
setIsResetPage: (reset) => set({ isResetPage: reset }),
|
||||
@@ -203,7 +199,9 @@ const useSearchModalStore = create<SearchModalState>((set) => ({
|
||||
var1: "",
|
||||
var2: "",
|
||||
ctpvCd: "0",
|
||||
ctpvName: "",
|
||||
sggCd: "0",
|
||||
sggName: "",
|
||||
yearFilter: { start: "", end: "" },
|
||||
tableData: [],
|
||||
detailTableData: [],
|
||||
@@ -211,8 +209,6 @@ const useSearchModalStore = create<SearchModalState>((set) => ({
|
||||
detailTableCount: 0,
|
||||
curPage: 1,
|
||||
detailCurPage: 1,
|
||||
sort: undefined,
|
||||
detailSort: undefined,
|
||||
isResetPage: true,
|
||||
isResetDetailPage: true,
|
||||
isResetSelection: true,
|
||||
|
||||
@@ -854,3 +854,83 @@
|
||||
- 공통 지도 선택기가 선택된 피처의 실제 OpenLayers 레이어 스타일 함수를 우선 사용하도록 변경했다.
|
||||
- 점 시설은 선택 원형 강조를 먼저 그리고 원래 아이콘을 위에 다시 그려, 선택 중에도 대상 맨홀을 식별할 수 있게 했다.
|
||||
- 선형 시설의 기존 선택 강조 순서는 유지했으며 API·DB·백엔드 동작은 변경하지 않았다.
|
||||
|
||||
## 41. 2026-08-12 CS 기준 조사·처리분구 화면 UI 통일
|
||||
|
||||
### 추적 근거
|
||||
|
||||
- 요구사항정의서 `3. (기능)요구사항정의 및 추적표`의 `SFR-010-004`~`SFR-010-007`을 다시 확인했다.
|
||||
- 화면설계서의 조사관리 화면 `SN_MN040101`~`SN_MN040110`(슬라이드 37, 41~49)을 대조했다.
|
||||
- 이번 변경은 조회·등록·수정·상세 기능과 API 계약을 유지한 UI 설계 보완이다.
|
||||
|
||||
### 변경 내용
|
||||
|
||||
- 메뉴명이 `관리`로 끝나는 목록 카드 제목은 `관리`를 제외하는 규칙을 적용했다.
|
||||
- `조사관리` → `조사 목록`
|
||||
- `처리분구관리` → `처리분구 목록`
|
||||
- 목록의 총 건수 문구와 열 정렬 UI·정렬 동작을 제거해 CS 목록 화면 기준에 맞췄다.
|
||||
- 검색조건은 공통 `Label`, `Input`, `Select`, `Button`으로 바꾸고 `조회`, `초기화` 순서와 간격을 공통 규칙으로 통일했다.
|
||||
- 조사사업·처리분구 목록은 공통 `StandardDataTable`을 사용하며 페이지당 10건, 자연 높이, 윈도우 세로 스크롤을 적용했다. 가로 폭이 충분하지 않은 표만 내부 가로 스크롤을 표시한다.
|
||||
- 조사 상세현황의 종합집계표는 내부 세로 스크롤을 제거하고 서버 제공 순서를 유지했다.
|
||||
- 조사사업 등록·상세/수정은 공통 `StandardFormSection`, `StandardFormField`를 사용해 사업차수·사업년도·정밀조사 연장과 시도·시군구·사업명을 각각 한 행 3개 항목으로 배치했다.
|
||||
- 처리분구는 별도 등록·상세 화면이 없는 기존 계약을 유지해 목록 안의 추가·수정·삭제·저장 방식은 보존하고 표와 버튼 UI만 통일했다.
|
||||
- 백엔드 API, 요청·응답 DTO, DB 구조는 변경하지 않았다.
|
||||
|
||||
### 공통 컴포넌트와 화면 전용 처리
|
||||
|
||||
- 그대로 사용: 공통 `Button`, `Input`, `Label`, `Select`, `StandardDataTable`.
|
||||
- 공통 래퍼 추가: 업무 비종속 `StandardFormSection`, `StandardFormField`. `sn` 접두어와 조사 업무 로직은 포함하지 않아 다른 앱에서도 재사용할 수 있다.
|
||||
- 화면 전용 유지: 조사 상세현황의 합계 행·결함 건수 링크, 처리분구 인라인 편집·변경 상태 표현, 조사 결과 업로드 흐름.
|
||||
|
||||
### 검증 결과
|
||||
|
||||
- `node node_modules/typescript/bin/tsc --noEmit --pretty false --ignoreDeprecations 5.0`을 `apps/sn`에서 실행해 오류 없이 통과했다.
|
||||
- 로그인 브라우저에서 `/oper/exmn/register`와 `/oper/trtdst`를 직접 확인했다.
|
||||
- `조사 목록`, `처리분구 목록` 제목, 조회·초기화 버튼, 정렬 버튼 미표시, 표 헤더 가운데 정렬, 페이지당 10건, 내부 세로 스크롤 미표시를 확인했다.
|
||||
- 조사사업 행 선택 후 상세현황 표시, 조사사업 등록의 3열 2행 폼, 처리분구 행 선택 후 인라인 수정 입력 전환을 확인했다.
|
||||
- 사용자가 실행 중인 개발 서버를 보호하기 위해 Next 빌드는 수행하지 않았다.
|
||||
|
||||
## 42. 2026-08-12 조사 상세 그리드·WindowPopup UI 및 순번 규칙 통일
|
||||
|
||||
### 추적 근거
|
||||
|
||||
- 요구사항정의서 `3. (기능)요구사항정의 및 추적표`의 `SFR-010-004`~`SFR-010-007`을 직접 재확인했다.
|
||||
- 화면설계서 슬라이드 37, 41~49의 `SN_MN040101`~`SN_MN040110` 조사사업 목록·등록·상세·결과등록 화면을 대조했다.
|
||||
- 기존 기능·API 계약을 유지하면서 사용자가 지정한 CS UI 기준을 팝업까지 확장한 설계 보완이다.
|
||||
|
||||
### 변경 내용
|
||||
|
||||
- 조사 목록, 관로·맨홀 상세 집계 및 처리분구 목록의 `NO` 헤더를 `순번`으로 통일하고 전체 건수에서 시작하는 내림차순 순번을 적용했다.
|
||||
- 조사 상세현황 그리드를 CS 기본 표의 파란 헤더·흰 글씨·가운데 정렬로 바꾸고 내부의 `관로조사 종합집계표`, `맨홀조사 종합집계표` 제목을 삭제했다.
|
||||
- 업무에 종속되지 않는 `StandardPopupLayout`과 공통 순번 계산 함수 `getStandardRowNumber`를 `business_components`에 추가했다.
|
||||
- 조사사업 등록·수정, 조사 결과 업로드, 조사 상세보기, 관로 조사보고서 WindowPopup을 `StandardPopupLayout`과 공통 `Button`, `Input`, `Select`, `StandardFormSection`, `StandardFormField` 기준으로 통일했다.
|
||||
- 조사 결과 업로드의 파일 표와 조사 상세보기의 복합 그리드·본단위구간 그리드도 CS 기본 표 헤더 색상과 가운데 정렬을 적용했다.
|
||||
- 기존 녹색 팝업 버튼과 화면 전용 파란 헤더를 제거했으며 정렬 UI와 정렬 동작은 추가하지 않았다.
|
||||
- 조사사업 저장·양식 다운로드, 결과 파일 선택·검토·저장, 결함 필터·다운로드, 관로 보고서 PDF 조회 기능은 보존했다.
|
||||
- 백엔드 API, 요청·응답 DTO와 DB 구조는 변경하지 않았다.
|
||||
|
||||
### 검증 결과
|
||||
|
||||
- 로그인 브라우저에서 `/oper/exmn/register`를 조회해 목록 `순번`이 `5`부터 `1`까지 표시되는 것을 확인했다.
|
||||
- 첫 사업을 선택해 조사 상세현황의 두 처리분구가 `2`, `1`로 표시되고, 두 종합집계표 제목이 없으며 표 헤더가 CS 기본 스타일로 표시되는 것을 확인했다.
|
||||
- `/popup/exmnBusinessRegister`, `/popup/exmnResultRegister`, `/popup/exmnSurveyDetail`, `/popup/exmnPipeReport`를 직접 열어 카드형 헤더·버튼·폼·표·PDF 상태 영역을 확인했다. 테스트용 임의 ID로 연 화면의 API `ERROR` 표시는 예상된 조회 결과이며 UI 렌더링 검증에만 사용했다.
|
||||
- TypeScript 전체 검사는 이번 변경 파일의 오류가 없음을 확인했으나, 작업 범위 밖 지도·공통 메뉴와 기존 `ExmnSurveyResultSection`, `exmn-business-api`, `ObsltPplFormPage` 오류 때문에 전체 명령은 실패했다.
|
||||
- 사용자가 실행 중인 개발 서버를 보호하기 위해 Next 빌드는 수행하지 않았다.
|
||||
|
||||
## 43. 2026-08-12 조사사업 등록 팝업 버튼·처리분구 영역 재정렬
|
||||
|
||||
- 사용자 확인에 따라 처리분구 선택 UI의 그리드 전환은 보류하고 기존 3열 체크박스 카드와 검색·일괄선택 기능을 유지했다.
|
||||
- 조사사업 등록 상단 버튼을 준설 등록과 같은 공통 기본 규격(높이 32px, 글자 13px)으로 변경하고 취소 버튼을 추가했다.
|
||||
- 처리분구 영역은 CS 카드 헤더 규칙을 적용하고 타이틀, 검색 입력, 선택 버튼, 선택 건수와 빈 상태 안내를 공통 13px 글꼴·색상·간격으로 통일했다.
|
||||
- 동일한 이름의 시군구가 여러 시도에 존재해도 선택 시도의 코드 앞자리 안에서 일치하는 항목을 찾도록 상세값 복원 로직을 보완했다.
|
||||
- 로그인 브라우저에서 `/popup/exmnBusinessRegister`를 직접 열어 상단 버튼 높이·글자 크기, 처리분구 타이틀·검색 입력·선택 건수·빈 상태 안내의 계산 스타일을 확인했다. 인천광역시의 고유 시군구와 동명 시군구를 차례로 선택해 시군구 선택값 유지도 확인했다.
|
||||
- 대상 파일은 Prettier와 `git diff --check`를 통과했다. 앱 전체 TypeScript 검사는 이번 파일 외 지도·API·공통 메뉴 등 기존 오류가 남아 있어 실패했으며, Next 빌드는 실행 중인 개발 서버 보호를 위해 수행하지 않았다.
|
||||
|
||||
## 44. 2026-08-12 버튼 색상·문구·순서 및 팝업 카드 간격 통일
|
||||
|
||||
- 주요 실행 동작인 조회·등록·저장·수정·다운로드는 파란색, 초기화·취소·닫기·상세보기·지도보기는 흰색, 삭제는 빨간색으로 통일했다.
|
||||
- 조사 목록은 `사업등록 → 결과등록 → 수정`, 처리분구 목록은 `추가 → 수정 → 저장 → 삭제` 순서로 정리했다. 처리분구의 삭제대기 상태는 동작 의미에 맞춰 흰색 `삭제취소`로 바뀐다.
|
||||
- 조사사업 등록·수정 팝업은 `양식다운로드 → 조사보고서 양식다운로드 → 저장/수정 → 취소`, 결과 업로드는 `저장 → 취소`, 조사 상세는 `엑셀다운로드 → 닫기` 순서를 적용했다.
|
||||
- Excel 데이터 내보내기는 `엑셀다운로드`, 입력 템플릿은 `양식다운로드`, 일반 첨부파일은 `다운로드`로 구분했다.
|
||||
- `StandardPopupLayout` 본문을 margin 기반 `space-y` 대신 `flex`와 `gap: 1.2rem`으로 변경했다. 자식 카드의 `!m-0` 때문에 간격이 0으로 덮이던 문제를 공통 레이아웃에서 해결했다.
|
||||
- 브라우저에서 조사사업 등록·수정의 `사업 정보`와 `사업대상 처리분구` 카드 사이 실제 간격이 모두 12px인 것을 확인했다.
|
||||
|
||||
@@ -277,3 +277,59 @@
|
||||
|
||||
- 요구사항정의서 `SFR-010-012`, `SFR-010-013`, 화면설계서 51~52번 슬라이드와 화면 ID `SN_MN040201`을 확인했다.
|
||||
- 상세 UI를 수정 기반으로 재사용하고 사진·위치 편집을 추가한 부분은 사용자 지시에 따른 설계 보완이다.
|
||||
|
||||
## 15. 2026-08-12 수정 등록방식·사진 0건 정합성 보완
|
||||
|
||||
- 수정 화면에도 등록 화면과 동일한 `사진 기반 등록`, `사진 없이 직접 등록` 선택을 추가했다.
|
||||
- 사진 기반 등록에서는 유지 사진과 신규 사진을 합쳐 0장이면 저장을 중단하고, 사진 추가 또는 직접 등록 전환을 안내한다.
|
||||
- 직접 등록으로 전환하면 기존·신규 사진을 모두 삭제 대상으로 바꾸고 현재 위치를 지도 지정 위치로 유지한다. 사용자는 필요하면 `위치 수정`으로 좌표와 최근접 관로를 다시 확인할 수 있다.
|
||||
- 부모 목록과 같은 `InputDatePicker`를 유지하되 팝업 전용 글자 규칙으로 18px까지 커지던 날짜 입력을 부모 화면과 같은 12.5px로 맞췄다. 달력 팝오버 자체 크기는 부모와 동일한 공통 컴포넌트 규격을 유지한다.
|
||||
- 추적 근거는 `SFR-010-012`, `SFR-010-013`, 화면설계서 51~56번 슬라이드와 화면 ID `SN_MN040201~203`이다. 수정 화면의 등록방식 전환은 사용자의 오류 재현과 지시에 따른 설계 보완이다.
|
||||
|
||||
### 검증 결과
|
||||
|
||||
- 사진 0건·사진 기반 수정 화면에서 두 등록방식 버튼, 사진 필수 안내, 파일 업로드 영역을 확인했다.
|
||||
- 사진 없이 직접 등록 상태에서 파일 업로드가 숨겨지고 `지도 지정`, 사진 0/5장, 위치 확인 안내가 표시되는 것을 확인했다.
|
||||
- 날짜 입력의 브라우저 계산값은 부모 목록과 같은 `font-size: 12.5px`, `width: 155px`, `height: 32px`이다.
|
||||
- 전체 TypeScript 검사에서 이번 변경 파일 오류는 0건이며, 검사는 기존 조사·침수·GIS·노후관로 파일 오류로 종료된다.
|
||||
|
||||
## 16. 2026-08-12 수정 팝업·목록 UI 후속 통일
|
||||
|
||||
- 수정 팝업 상단의 관로번호를 제거하고 `관로 정보` 카드 헤더 우측으로 이동했다.
|
||||
- 준설일자와 자동매핑을 한 행의 두 항목으로 배치해 관로정보 표를 한 행 줄였다.
|
||||
- 부모 목록과 같은 공통 `InputDatePicker`를 그대로 사용하고, 날짜 선택기 래퍼를 입력 폭 안으로 제한해 달력 버튼이 셀 우측으로 분리되지 않도록 했다.
|
||||
- 수정 팝업 상단 버튼은 `저장`, `취소` 순서로 정렬하고 준설 사진 카드의 기존 안내 문구를 제거했다.
|
||||
- 지자체별·관경별·경과년도별 결과 카드 타이틀을 모두 `준설 목록`으로 통일했다.
|
||||
- CS 설문관리 검색 폼의 `gap-2` 규칙을 준설관리 검색조건에도 적용해 조회·초기화 버튼 간격을 10px에서 5px로 맞췄다.
|
||||
- 준설관리는 선행 검증 화면으로만 사용하고, 버튼·입력·달력·선택·목록·페이징 규칙은 하수관망도를 제외한 SN 전체 화면과 WR·DM·FO에서도 재사용할 수 있는 공통 기준으로 관리한다. 업무 고유 사진·관로 매핑 흐름만 준설 화면에 남긴다.
|
||||
|
||||
### 검증 결과
|
||||
|
||||
- 수정 팝업에서 상단 버튼 순서, 관로번호 위치, 안내 문구 제거와 5행 관로정보 표를 확인했다.
|
||||
- 날짜 입력은 공통 달력 팝오버가 227×247px로 열리고 달력 버튼이 입력 영역 안에 배치되는 것을 확인했다.
|
||||
- 목록의 관경별·경과년도별 조회구분에서 결과 타이틀이 모두 `준설 목록`으로 유지되는 것을 확인했다.
|
||||
- 조회 버튼 우측 312px, 초기화 버튼 좌측 317px로 계산되어 두 버튼의 실제 간격이 CS와 같은 5px임을 확인했다.
|
||||
|
||||
## 17. 2026-08-12 목록 조회 로딩 UI 통일
|
||||
|
||||
- 준설 목록만 `StandardDataTable` 내부의 `조회 중입니다.` 문구를 표시하던 차이를 제거했다.
|
||||
- 조사관리·처리분구관리·노후 하수관로 정비사업과 같은 공통 `LoadingOverlay`를 준설관리 조회·삭제 처리에도 적용했다.
|
||||
- 조회 중에는 기존 그리드를 유지한 채 화면 전체 로딩 애니메이션으로 사용자 입력을 차단하며, 그리드 내부 조회 문구는 사용하지 않는다.
|
||||
- API 요청·응답, 검색조건, 페이징과 목록 데이터 처리 방식은 변경하지 않았다.
|
||||
- 추적 기준은 요구사항 `SFR-010-012`, 화면설계서 51~56번 슬라이드와 화면 ID `SN_MN040201~203`이며 로딩 표현 통일은 사용자 지시에 따른 UI 설계 보완이다.
|
||||
|
||||
## 18. 2026-08-12 버튼 색상·문구·순서 통일
|
||||
|
||||
- 목록 액션을 `엑셀다운로드 → 등록 → 수정 → 상세보기 → 삭제` 순서로 정리하고 삭제를 항상 마지막에 배치했다.
|
||||
- 엑셀다운로드·등록·수정은 파란색, 상세보기·초기화·취소는 흰색, 삭제는 빨간색으로 통일했다.
|
||||
- 등록·수정 팝업은 `저장 → 취소`, 지도 위치 편집은 `선택 위치 적용 → 취소` 순서를 적용했다.
|
||||
- 등록 건과 사진 삭제 아이콘은 모두 빨간색 destructive 동작으로 표시하고, 사진 행 선택 버튼은 흰색 보조 동작으로 유지했다.
|
||||
- 검색은 `조회 → 초기화` 순서를 유지했으며 기존 이벤트와 비활성화 조건은 변경하지 않았다.
|
||||
|
||||
## 19. 2026-08-13 상세보기 기능 제거
|
||||
|
||||
- 목록 액션에서 `상세보기`를 제거하고 `엑셀다운로드 → 등록 → 수정 → 삭제`만 유지했다. 삭제는 기존 규칙대로 마지막에 배치한다.
|
||||
- 별도 `dredgingDetail` WindowPopup 정의와 레지스트리 항목, 상세 전용 content를 제거해 URL로 직접 진입하는 상세 기능도 함께 정리했다.
|
||||
- 수정 화면은 기존과 같이 관로정보·준설일자·사진·위치·자동매핑 정보를 모두 제공하므로 조회에 필요한 정보 확인과 편집 기능은 유지된다.
|
||||
- 수정 화면에서도 사용하는 검색 파라미터 복원 로직은 중립 유틸 파일로 분리했으며 API, 저장 payload, 부모창 갱신 메시지는 변경하지 않았다.
|
||||
- 요구사항정의서 sheet3의 `SFR-010-013`은 준설 등록·수정·삭제·조회를 요구하지만 별도 상세 버튼을 강제하지 않는다. 화면설계서 슬라이드 51의 `SN_MN040201`과 슬라이드 53의 `SN_MN040202`도 등록·조회·다운로드 중심이며, 슬라이드 57의 별도 세부현황은 `화면 삭제`로 표시되어 있다.
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# SN CS UI 통일 2차 구현 기록
|
||||
|
||||
## 1. 작업 목적
|
||||
|
||||
- CS의 카드, 검색, 버튼, 폼, 목록, 페이징, 팝업 UI를 SN의 도시침수예방사업, 침입수 분석, RDII 분석과 하수관망도 WindowPopup에 적용한다.
|
||||
- 하수관망도 지도 자체는 변경하지 않고 지도에서 여는 WindowPopup만 통일한다.
|
||||
- 기존 API, 파일 업로드, 분석 실행, 부모 지도 메시지와 지도 하이라이트 흐름은 유지한다.
|
||||
|
||||
## 2. 원본 요구사항 확인
|
||||
|
||||
- 원본 `화면설계서.pptx`와 `요구사항정의서.xlsx`를 직접 대조했다.
|
||||
- 하수관망도 WindowPopup은 화면설계서 7~17쪽, 20~25쪽과 SFR-010/011/012 계열의 지도 조회 흐름을 유지했다.
|
||||
- 도시침수예방사업 업무 CRUD와 침입수·RDII 업무 화면은 원본에 직접 대응하는 화면 ID 및 요구사항 ID가 없어, 기존 기능 계약을 보존한 `UI 통일 설계 제안`으로 적용했다.
|
||||
- 도시침수예방사업은 승인된 기존 작업계획의 `/oper/ctyFldngPrvtnBiz`와 기존 호환 경로 `/oper/floodPre`를 함께 유지했다.
|
||||
|
||||
## 3. 공통 적용 규칙
|
||||
|
||||
- 검색 버튼은 `조회` 파란색, `초기화` 흰색 순서로 통일했다.
|
||||
- 엑셀 파일 액션은 `엑셀다운로드`, 업로드 양식은 `양식다운로드`로 통일했다.
|
||||
- 삭제 버튼은 빨간색이며 액션의 마지막에 배치했다.
|
||||
- 목록의 `NO`는 `순번`으로 바꾸고 전체 건수에서 현재 페이지 위치를 차감해 내림차순으로 표시한다.
|
||||
- 목록 타이틀 옆 총 건수 표시는 제거했다.
|
||||
- 헤더는 항상 가운데 정렬하고 데이터 셀은 내용 성격에 따라 정렬한다.
|
||||
- 사용자 헤더 정렬 기능과 관련 정렬 상태·이벤트를 제거하고 서버 기본 최신순만 유지한다.
|
||||
- 일반 페이지 크기의 목록은 내부 세로 스크롤을 만들지 않고 창 스크롤을 사용한다. 열이 많은 표만 내부 가로 스크롤을 사용한다.
|
||||
- 로딩은 공통 `LoadingOverlay`, 사용자 메시지는 CS와 같은 `AlertDialog`와 `ConfirmDialog` 흐름을 유지한다.
|
||||
|
||||
## 4. CS 컴포넌트 그대로 사용한 범위
|
||||
|
||||
- `BaseLayout`, `Header`, `LNB`, `Footer`, `PageTitle`
|
||||
- `Button`, `Input`, `Label`, `Select`, 날짜/월 선택 컴포넌트
|
||||
- `DataTable`, `Table`, `Dialog`, `LoadingWithAnimation`
|
||||
- `AlertDialog`, `ConfirmDialog`와 `useAlertStore`, `useConfirm` 메시지 흐름
|
||||
- 공통 카드 클래스 `card`, `card-border`, `card-header`
|
||||
|
||||
## 5. 커스텀 유지 범위
|
||||
|
||||
- CS 배치 규칙을 재사용 가능하게 묶은 중립 래퍼 `StandardDataTable`, `StandardFormSection`, `StandardFormField`, `StandardPopupLayout`
|
||||
- SN의 서버 페이징·역순 순번 계약을 공통 표에 연결하는 앱 로컬 `MapPopupDataTable`, `ExmnStandardDataTable`
|
||||
- 도시침수예방사업의 다단 Excel 헤더와 시설 가변 행
|
||||
- 관로분석 연도·재질 병합표의 `rowSpan`
|
||||
- 침입수·RDII 결과 차트와 결과 전용 모드
|
||||
- 지도 WindowPopup의 부모창 `postMessage`, 관로 선택, 지도 이동과 하이라이트
|
||||
- 준설 사진 업로드·EXIF 위치·지도 선택과 GIS GeoPackage 업로드/미리보기처럼 CS에 동일 기능이 없는 업무 UI
|
||||
|
||||
## 6. 대상 화면
|
||||
|
||||
- 도시침수예방사업 목록, 등록·상세·수정, 업로드와 Excel 상세 팝업
|
||||
- 침입수 분석 입력·결과 목록·지도 결과 팝업·차트 팝업
|
||||
- RDII 분석 입력·결과 목록·지도 결과 팝업·차트 팝업
|
||||
- 노후관로, 시도별 노후관로, 침수이력, 지반침하, 침수위험지도, 도시침수지도, 관로분석, 보수이력, 통수위험, GIS 관리, 관망도 검색, 보고서, 우수토실, GIS 편집 세션 등 하수관망도 WindowPopup
|
||||
|
||||
## 7. 검증
|
||||
|
||||
- 대상 파일 Prettier 검사를 통과했다.
|
||||
- `git diff --check`를 통과했다.
|
||||
- `pnpm exec tsc --noEmit --incremental false --pretty false --ignoreDeprecations 5.0`을 통과했다.
|
||||
- 실행 중인 사용자 개발 서버를 보호하기 위해 Next build는 실행하지 않았다.
|
||||
- `http://localhost:3002`에서 하수관망도, 도시침수예방사업 목록·등록, 노후 하수관로 목록, 조사·준설·처리분구, 침입수·RDII 분석을 직접 열어 확인했다.
|
||||
- 관망도 검색의 기본·유지관리·보고서 탭, 관로분석, GIS 관리, 노후관로, 지반침하, 침입수 결과와 준설·조사 등록 WindowPopup을 직접 열어 버튼·헤더·스크롤·로딩을 확인했다.
|
||||
- 긴 도시침수예방사업 등록 화면에서 `scrollY=1300` 상태에도 공통 헤더가 `sticky`, `top=0`으로 유지됨을 확인했다.
|
||||
- 일반 목록의 내부 세로 오버플로가 없고, 팝업은 공통 팝업 본문 스크롤만 사용하며, 브라우저 콘솔 오류가 없음을 확인했다.
|
||||
|
||||
## 8. 영향 및 롤백
|
||||
|
||||
- 변경은 `apps/sn` 화면과 `packages/common/business_components`의 중립 신규 컴포넌트·클라이언트 경계 보완에 한정한다.
|
||||
- 현재 `StandardDataTable`, `StandardForm*`, `StandardPopupLayout`의 실제 소비자는 SN뿐이며 WR, DM, FO 화면은 자동 변경되지 않는다.
|
||||
- 기존 훅 기반 비즈니스 컴포넌트의 `use client` 선언은 Next 클라이언트 경계를 명시하는 변경이며 시각 규격이나 공개 props를 바꾸지 않는다.
|
||||
- 롤백 시 SN 업무·지도 팝업 UI, 앱 로컬 어댑터와 중립 `Standard*` 신규 컴포넌트 및 export/dependency 변경을 함께 되돌린다.
|
||||
|
||||
## 9. 남은 리스크
|
||||
|
||||
- 원본에 직접 대응 화면이 없는 도시침수 업무 CRUD와 침입수·RDII 업무화면은 현행 코드와 API 계약을 기준으로 한 설계 제안이다.
|
||||
- 저장·삭제·파일 업로드, 실제 분석 실행과 실데이터 행 선택 후 지도 이동·하이라이트는 데이터 변경을 피하기 위해 이번 전수 UI 회귀검증에서는 실행하지 않았다.
|
||||
|
||||
## 10. 2026-08-13 지도 상단 검색바 정렬 보완
|
||||
|
||||
- 지도 상단의 선택박스는 높이 35px, 조회 버튼은 높이 25px인데 버튼에만 상단 여백이 적용되어 두 요소의 중심선이 5px 어긋난 상태를 브라우저에서 확인했다.
|
||||
- `ProcSearchBar`와 `SearchBar`의 검색 행 및 버튼 영역에 세로 중앙 정렬을 적용하고 버튼의 임의 상단 여백을 제거했다. 버튼 크기, 색상, 이벤트와 선택박스 동작은 변경하지 않았다.
|
||||
- 공통 `SearchBar`는 FO와 WR 지도에서 사용되고, `ProcSearchBar`는 SN 하수관망도에서 사용된다. DM과 WR 일부 화면의 앱 로컬 검색바는 이번 변경 대상이 아니다.
|
||||
- 수정 후 SN 하수관망도에서 선택박스 3개와 조회 버튼의 중심선이 모두 103px로 일치함을 확인했다.
|
||||
- SN 전체 TypeScript 검사와 대상 파일 `git diff --check`를 통과했다.
|
||||
|
||||
## 11. 2026-08-13 분석도구 팝업·버튼·로딩 재점검
|
||||
|
||||
- 침입수·RDII 입력 화면의 상단 액션, 조회, 파일 선택 버튼을 CS 기본 높이 32px로 통일했다. 버튼 순서는 `양식다운로드 → 분석 실행 → 결과보기`, `조회 → 초기화`를 유지했다.
|
||||
- 침입수·RDII 결과 목록에서 최초 조회가 두 번 실행되던 중복 effect를 제거했다. 입력 화면은 전체 화면 로더 하나, 결과 목록은 표 로더 하나만 사용한다.
|
||||
- 관망도 정보검색은 compact underline 탭, 탭별 검색 카드와 기본 높이 버튼을 적용했다. 중첩 카드와 중복 로더를 제거하고 필요한 가로 스크롤만 유지했다.
|
||||
- `mapSearch` WindowPopup은 `maximize: true`로 설정하여 `screen.availWidth`와 `screen.availHeight` 기준의 가용 화면 전체 크기로 연다.
|
||||
- 관로분석·관로현황, 시도별 노후관로, GIS 관리, 통수위험, 침입수·RDII 분석결과 팝업의 검색 필드·버튼·카드·탭·표 헤더를 다시 점검했다. 통수위험은 공통 underline 탭으로 전환했고, 관로분석의 연도·재질 병합표는 업무상 `rowSpan`을 유지하면서 CS 카드와 파란 중앙 헤더를 적용했다.
|
||||
- 분석도구 메뉴 9개 항목을 확인했다. 정보검색, 관로분석, 시도별 노후관로, GIS 관리, 통수위험, 침입수·RDII 결과는 실제 팝업이며, GIS 편집은 지도 내부 편집 토글이다. 침수예측현황은 기존 placeholder로서 요구 기능을 임의 구현하지 않았다.
|
||||
- 전역 로딩 사용처를 정적 감사하고 전체 화면 `LoadingOverlay`와 `StandardDataTable` 로딩을 동시에 쓰던 7개 목록에서 표 로딩을 제거했다. 이후 검사에서 동일 파일 내 전체 화면 로더와 표 로더 중복은 0건이다.
|
||||
- `packages/common`에서 이번 UI 통일 변경이 있는 범위는 사용자 소유의 `packages/common/business_components`뿐이며, 다른 공용 패키지는 수정하지 않았다.
|
||||
- 원본 추적 근거는 화면설계서 슬라이드 8~17, 19~23과 요구사항정의서 sheet3 87~95행이다. 정보검색과 침입수·RDII 업무 화면은 직접 대응 화면 ID가 없어 기존 기능 계약을 보존한 UI 설계 제안으로 분류한다.
|
||||
- 침입수·RDII 네 화면은 브라우저에서 버튼 높이와 콘솔 오류 0건을 확인했다. 목록 로딩은 지연 응답을 주입해 전체 화면 로더 1개·표 로더 0개를 확인했다. 정보검색은 3개 탭 전환과 조회·초기화·엑셀다운로드·지도에서 보기 버튼 32px를 확인했고, 관로분석·통수위험은 파란 중앙 헤더, 순번, 32px 버튼, 내부 세로스크롤 0건을 확인했다. 분석도구의 8개 WindowPopup 경로는 모두 HTTP 200이며 런타임 오류가 없음을 확인했다. 사용자 개발 서버가 일시 중단된 동안에는 임의 재기동하지 않았으며 자동 복구된 뒤 최종 브라우저 회귀검증을 완료했다.
|
||||
|
||||
## 12. 2026-08-13 조사 상세·분석 차트·WindowPopup 취소·GIS 편집 재점검
|
||||
|
||||
- 조사 목록 행 선택 시 상세 카드 헤더 안에 직접 렌더되던 절대배치 스피너를 제거하고 공통 전체 화면 `LoadingOverlay` 하나만 사용하도록 변경했다. 런타임 소스의 직접 `LoadingWithAnimation` 사용처는 안전한 공통 로더 래퍼 한 곳만 남겼다.
|
||||
- 조사 목록과 상세 집계표 헤더 높이를 공통 상수 `5.6rem`으로 묶었다. 브라우저에서 목록과 상세의 첫 헤더 높이가 모두 `56px`임을 실측했다.
|
||||
- 침입수·RDII 차트 팝업을 `StandardPopupLayout`, `StandardFormSection`, `StandardFormField` 기반의 2열 조건·결과 요약·그래프 카드 구조로 통일했다. 두 화면 모두 하단 액션은 `조회 → 초기화 → 취소`이고 버튼 높이는 `32px`이다.
|
||||
- WindowPopup 레지스트리 33개 키를 감사했다. 준설 상세 기능 제거 후 자체 저장·수정 보호 로직을 가진 6개 키는 기존 취소를 유지하고, 나머지 27개 키는 공통 팝업 레이아웃이 마지막 액션으로 흰색 `취소(window.close)`를 제공한다.
|
||||
- GIS 편집세션은 레이어·도구·변경 건수 상태, 변경 항목 선택 강조, `선택 취소/최근 취소/전체 초기화`를 그룹화했다. 지도 하단 편집툴바는 편집도구, 스냅핑, `저장 → 취소` 영역으로 나눠 높이를 `37px` 수준으로 줄였다.
|
||||
- 처리구역 분석 표 셀 안의 직접 스피너도 제거해 카드 단일 로딩 상태로 통일했다.
|
||||
- 팝업 레지스트리의 정적 import가 브라우저 전용 엑셀 모듈을 SSR에서 평가해 모든 `/popup/*` 경로를 막던 결합 문제를 발견했다. 팝업 렌더러를 클라이언트 경계로 이동하고 노후관로·도시침수 Excel 상세를 동적 import하여 팝업 간 의존성 전파를 차단했다.
|
||||
- 브라우저에서 조사 목록·상세 헤더 `56px/56px`, 침입수·RDII 차트 팝업의 `조회/초기화/취소` `32px`, 정보검색 팝업 상단 `취소`, GIS 편집세션과 하단 `저장 → 취소` 그룹을 확인했다. 팝업 경로의 Turbopack 런타임 오류는 클라이언트 경계 보완 후 재현되지 않았다.
|
||||
- SN 전체 TypeScript 검사는 `--ignoreDeprecations 5.0` 조건에서 통과했고 `git diff --check`도 통과했다. 원본 근거는 조사 SFR-010-004~007 및 화면설계서 슬라이드 47~49, GIS 편집 SFR-012-003~004 및 화면설계서 슬라이드 32이다. 침입수·RDII 차트는 원본 직접 화면 ID가 없어 UI 설계 제안으로 유지한다.
|
||||
|
||||
## 13. 2026-08-13 통수위험 분석결과 팝업 표준화
|
||||
|
||||
- 통수위험 팝업에 남아 있던 전용 HTML 표를 앱 로컬 `MapPopupDataTable`로 교체해 다른 지도 분석 팝업과 동일한 CS 카드·표·페이징 구조를 적용했다.
|
||||
- 전용 표의 헤더와 빈 행 높이가 각각 `28.5px`로 눌리고 바로 아래에 가로 스크롤바가 붙던 문제를 제거했다. 빈 결과는 공통 `240px` 중앙 안내 영역으로 표시하며 데이터가 있을 때만 표와 필요한 가로 스크롤을 렌더한다.
|
||||
- 현재 위험과 과거 이력 탭별 열 구성, 전체 건수 기준 내림차순 순번, 수치 소수점, 위험 배지, 행 선택 후 부모 지도 강조, API와 `postMessage` 계약은 유지했다.
|
||||
- `MapPopupDataTable`에는 선택 행 ID를 선택적으로 전달하는 앱 로컬 속성을 추가했다. 기존 소비 화면은 속성을 전달하지 않으므로 동작과 스타일에 영향이 없다.
|
||||
- 팝업 제목을 `통수위험 분석결과 목록`으로 정리하고 공통 흰색 `취소` 버튼, `조회 → 초기화`, 32px 검색 컨트롤, underline 탭과 전체 화면 로더 하나를 유지했다.
|
||||
- 원본 근거는 화면설계서 슬라이드 20~23의 `SN_MN010202~010204`, `SFR-011-007~008`과 요구사항정의서 sheet3 93행 `SFR-011-007`, `PR-SN-026`이다. 현재·과거 목록 팝업 자체의 정확한 원본 화면은 없어 목록 배치는 기존 기능을 보존한 UI 설계 제안으로 분류한다.
|
||||
- 브라우저에서 현재 위험·과거 이력 탭 전환, 검색조건 라벨 변경, 공통 취소, 빈 결과 높이 `240px`, 내부 세로 스크롤 없음과 전체 화면 로더 단일 사용을 확인했다. SN 전체 TypeScript 검사와 대상 파일 `git diff --check`를 통과했다.
|
||||
@@ -0,0 +1,127 @@
|
||||
# SN 전체 화면 인벤토리 및 UI 검증 기록
|
||||
|
||||
## 1. 작업 목적
|
||||
|
||||
- 디지털하수관로 SN 앱의 화면 수를 본창 라우트, WindowPopup, 내부 Dialog로 구분해 재현 가능한 기준으로 확정한다.
|
||||
- 원본 화면설계서·요구사항 추적표와 현재 코드를 대조하고, 실제 브라우저에서 주요 UI 규칙을 전수 확인한다.
|
||||
- 화면 수를 단순 `page.tsx` 파일 수로 오인하지 않도록 리다이렉트, 별칭, 개발용 화면과 실제 업무 화면을 분리한다.
|
||||
|
||||
## 2. 원본 요구사항 추적 근거
|
||||
|
||||
- 화면설계서 `화면설계서.pptx` 전체 58장을 직접 렌더링해 확인했다.
|
||||
- 화면설계서 3쪽의 화면 목차에는 하수관망도·유지관리 관련 화면 항목 33개가 기재되어 있다. 마지막 번호가 준설 상세현황과 준설결과 업로드에 중복 표기되어 있어 번호 값이 아니라 행 수로 집계했다.
|
||||
- 요구사항정의서 `3. (기능)요구사항정의 및 추적표` 73~100행의 SFR-010-001~014, SFR-011-001~009, SFR-012-001~005를 대조했다.
|
||||
- 원본 33개는 지도 내부 패널·상세 상태를 각각 화면으로 센 설계 화면 수이며, Next 라우트 수 또는 WindowPopup 수와 일치하는 개념이 아니다.
|
||||
- 도시침수예방사업 CRUD와 침입수·RDII 업무 화면은 원본에 직접 대응하는 화면 ID가 없어 기존 API와 기능을 유지하는 `UI 통일 설계 제안` 범위로 분류한다.
|
||||
|
||||
## 3. 코드 기준 화면 수
|
||||
|
||||
### 3.1 Next 라우트 파일
|
||||
|
||||
`apps/sn/app/**/page.tsx`는 총 27개다.
|
||||
|
||||
| 구분 | 수 | 설명 |
|
||||
| -------------- | --: | ------------------------------------------------------------- |
|
||||
| 실제 업무 본창 | 17 | 지도, I/I 4개, 운영·유지관리 목록/등록/수정 화면 |
|
||||
| 인증 화면 | 1 | `/auth/login` |
|
||||
| 인프라 라우트 | 2 | `/` 자동 로그인·이동, `/popup/[popupKey]` 동적 팝업 호스트 |
|
||||
| 별칭·호환·시안 | 4 | `/map2`, `/map/agedPipePopup`, `/html/map`, `/html/aged-pipe` |
|
||||
| 개발·시험 화면 | 3 | `/ii/fileupload`, `/ii/test`, `/oper/operStore` |
|
||||
| 합계 | 27 | 실제 사용자 화면 수와 단순 동일시하지 않음 |
|
||||
|
||||
실제 업무 본창 17개는 다음과 같다.
|
||||
|
||||
1. `/map`
|
||||
2. `/ii/irsetting`
|
||||
3. `/ii/rdii`
|
||||
4. `/ii/result`
|
||||
5. `/ii/rdiiResult`
|
||||
6. `/oper/ctyFldngPrvtnBiz`
|
||||
7. `/oper/ctyFldngPrvtnBiz/register`
|
||||
8. `/oper/ctyFldngPrvtnBiz/register/[id]`
|
||||
9. `/oper/dredging`
|
||||
10. `/oper/exmn/register`
|
||||
11. `/oper/exmn/status`
|
||||
12. `/oper/floodPre`
|
||||
13. `/oper/obsltPplBiz`
|
||||
14. `/oper/obsltPplBiz/register`
|
||||
15. `/oper/obsltPplBiz/register/[id]`
|
||||
16. `/oper/oldPp`
|
||||
17. `/oper/trtdst`
|
||||
|
||||
### 3.2 WindowPopup
|
||||
|
||||
- `windowPopupDefinitions` 정의: 33개
|
||||
- `windowPopupRegistry` 구현 매핑: 33개
|
||||
- 정의 누락 또는 레지스트리 초과: 0개
|
||||
- 기능 구현 팝업: 31개
|
||||
- 안내용 placeholder: 2개 (`floodPredictionMapPopup`, `gisEditPopup`)
|
||||
|
||||
| 업무군 | WindowPopup key |
|
||||
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 운영·유지관리 | `cfmtnBgt`, `exmnBusinessRegister`, `exmnResultRegister`, `exmnSurveyDetail`, `dredgingRegister`, `dredgingEdit`, `exmnPipeReport`, `oldPpUpload`, `oldPpTable`, `floodPreUpload`, `floodPreTable` |
|
||||
| 보고서·정보검색 | `reportTab`, `reportFiles`, `mapSearch` |
|
||||
| I/I | `inftwtResultPopup`, `rdiiResultPopup`, `infilChart`, `rdiiChart` |
|
||||
| 지도 시설·분석 | `sscDetail`, `agedPipePopup`, `sinkholePopup`, `sbmrgDngrMapPopup`, `floodDngrMapPopup`, `pipeAnalysisPopup`, `pipeAnalysisDetailPopup`, `repairHistoryPopup`, `agedPipeStatusByCtpvPopup`, `fldngHstryPopup`, `gisEditSessionPopup`, `gisManagementPopup`, `flowRiskPopup` |
|
||||
| 구조만 연결된 안내 | `floodPredictionMapPopup`, `gisEditPopup` |
|
||||
|
||||
### 3.3 내부 Dialog
|
||||
|
||||
- 앱 전역 사용자 메시지 Dialog는 `AlertDialog`, `ConfirmDialog`, `PromptDialog` 3종이다.
|
||||
- `DredgingRegisterModal`의 Dialog는 `dredgingRegister` WindowPopup 본문 자체이므로 별도 논리 화면으로 중복 집계하지 않는다.
|
||||
- `PplSummaryModal`, `PplFacilityDetailModal`은 현재 런타임 호출부가 없는 휴면 컴포넌트다. 활성 업무 화면 수에는 포함하지 않는다.
|
||||
|
||||
따라서 현재 운영 대상으로 보는 논리 화면은 `업무 본창 17 + 기능 WindowPopup 31 = 48개`다. 로그인까지 포함하면 49개이며, placeholder 2개까지 포함한 등록 화면은 51개다.
|
||||
|
||||
## 4. 브라우저 전수 검증
|
||||
|
||||
### 4.1 본창
|
||||
|
||||
- 로그인 후 실제 업무 본창 17개와 인증 화면을 직접 열었다.
|
||||
- 각 본창은 최종 재검증에서 최초 문서 요청 HTTP 200, 애플리케이션 오류 문구 없음, 문서 가로 넘침 0을 확인했다.
|
||||
- 목록·등록·수정 화면의 제목, 검색 카드, 버튼 순서·색상, 표 헤더, 가로 오버플로, 빈 상태와 하단 액션을 확인했다.
|
||||
- `/oper/exmn/status`에서 실제 조사사업과 덕풍처리분구 행을 선택하고, 실제 생성된 `exmnSurveyDetail` 팝업에서 관로 3건과 결함 집계를 조회했다.
|
||||
- 조사 목록과 상세 요약 표의 단일 헤더는 모두 56px 규격을 사용한다. 관로조사 상세 팝업의 결함표는 4단 복합 헤더이므로 전체 높이는 데이터 구조에 따라 더 크지만, 각 헤더 셀의 패딩·색상·가운데 정렬은 같은 표 토큰을 사용한다.
|
||||
- 같은 흐름에서 상세 로딩은 공통 로딩 오버레이 하나만 사용하고, 헤더 내부의 로더·`조회하고 있습니다` 보조 문구가 중복되지 않음을 확인했다.
|
||||
- 긴 등록 화면에서 상단 헤더는 스크롤 후에도 `position: sticky`, `top: 0`을 유지했다.
|
||||
- `/oper/ctyFldngPrvtnBiz/register/228`, `/oper/obsltPplBiz/register/128`의 실제 수정 화면까지 열어 저장·취소·삭제 순서와 크기를 확인했다.
|
||||
|
||||
### 4.2 WindowPopup
|
||||
|
||||
- 33개 key를 모두 `/popup/{popupKey}`로 직접 열었다.
|
||||
- 필수 파라미터가 있는 팝업은 시험 파라미터를 제공해 실제 본문을 렌더했다.
|
||||
- 실제 조사 상세·관로보고서는 실데이터 식별자로 확인했고, 데이터가 없는 팝업은 로더 종료 후 빈 상태를 확인했다. 파일 식별자가 필요한 상세 팝업은 필수 파라미터 안내 상태를 확인했다.
|
||||
- 33개 모두 최종 문서 요청 HTTP 200, 팝업 타이틀과 32px `취소` 버튼이 존재하고, 문서 전체의 불필요한 가로 스크롤은 없었다.
|
||||
- `mapSearch`는 최대화 상태, compact 탭, 기본 정보·유지관리·보고서 흐름을 확인했다.
|
||||
- 관로분석, 통수위험, GIS 관리, 노후관로, 지반침하, 침수지도, 침입수·RDII 결과와 차트의 검색·목록·빈 상태를 확인했다.
|
||||
- 안정화 후 주요 액션 버튼은 32px로 렌더됐다. 표 셀 링크, 지도 확대·축소, 페이지 번호와 같은 컨텍스트 컨트롤은 별도 소형 규격으로 유지했다.
|
||||
- 33개 팝업의 최종 상태에서 가시 로딩 스피너 0, `Internal Server Error`, `Application error`, 미등록 popup key 오류 없음으로 종료했다.
|
||||
|
||||
## 5. 이번 보완
|
||||
|
||||
- 도시침수예방사업 등록·수정과 노후 하수관로 정비사업 등록·수정의 하단 저장·취소·삭제가 `size="lg"`로 40px이던 불일치를 제거했다.
|
||||
- 두 화면 모두 CS 기본 Button 32px로 맞추고 `저장 → 취소 → 삭제` 순서와 색상은 유지했다.
|
||||
- 지도 상단 공통 검색바의 조회·초기화와 지도 분석 팝업에 남아 있던 25px 버튼을 공통 32px Button으로 통일했다.
|
||||
- 공통 SelectBox와 노후관로·침수위험지도·침수이력·보수이력 팝업에 남아 있던 35px 입력을 32px로 통일했다.
|
||||
- 관로조사 상세 팝업의 모드·조회 버튼을 32px로 통일하고 중복 로딩 안내를 제거했다.
|
||||
- `business_components`의 Excel export 모듈이 `xlsx-populate`를 서버 평가하면서 모든 본창·팝업 최초 요청을 500으로 만들던 문제를 동적 로딩으로 격리했다. 보완 후 침입수·RDII 본창과 33개 팝업의 최초 문서 요청이 HTTP 200임을 확인했다.
|
||||
- 화면 수와 팝업 정의·레지스트리 일치 여부를 다시 확인할 수 있도록 `apps/sn/scripts/audit-screen-inventory.mjs`를 추가했다.
|
||||
- 다른 회사 공통 코드인 `packages/common/components`는 수정하지 않았다. 공통 변경은 사용자가 허용한 `packages/common/business_components` 범위에만 적용했다.
|
||||
|
||||
## 6. 재검증 명령
|
||||
|
||||
```powershell
|
||||
node apps/sn/scripts/audit-screen-inventory.mjs
|
||||
pnpm --filter sn exec tsc --noEmit --incremental false --pretty false --ignoreDeprecations 5.0
|
||||
git diff --check -- apps/sn packages/common/business_components
|
||||
```
|
||||
|
||||
사용자가 실행 중인 Next 개발 서버와 `.next` 산출물을 보호하기 위해 `next build`는 실행하지 않는다.
|
||||
|
||||
## 7. 영향·롤백·남은 리스크
|
||||
|
||||
- API, DB, 요청·응답 구조와 저장 로직에는 영향이 없다.
|
||||
- 이번 코드 보완의 롤백은 대상 SN 화면의 Button/Select 규격 변경, `business_components`의 검색바·SelectBox·Excel 지연 로딩 변경, 인벤토리 스크립트·문서를 되돌리면 된다.
|
||||
- `floodPredictionMapPopup`은 원본 요구사항이 있으나 현재 안내 화면만 연결된 미구현 기능이다.
|
||||
- `gisEditPopup` 직접 URL은 안내 화면이지만 실제 메뉴의 GIS 편집은 지도 본창의 인라인 편집 세션으로 동작한다.
|
||||
- 저장·삭제·업로드·분석 실행은 외부 데이터 변경을 피하기 위해 누르지 않았고, 화면 렌더·조회·행 선택까지를 회귀검증 범위로 삼았다.
|
||||
@@ -0,0 +1,113 @@
|
||||
# 2026-08-12 SN CS UI 통일 작업내용
|
||||
|
||||
## 1. 작업 목적
|
||||
|
||||
- 디지털하수관로 `sn` 앱의 UI를 통합플랫폼관리 `cs` 기준으로 통일한다.
|
||||
- 준설관리를 우선 적용 화면으로 삼되, 하수관망도 지도 자체를 제외한 전체 업무 화면에 재사용할 수 있는 구조로 설계한다.
|
||||
- `wr`, `dm`, `fo` 등 다른 앱도 사용하는 공통 영역의 영향을 고려하고, 공통 수정은 사용자 소유 범위인 `packages/common/business_components`로 제한한다.
|
||||
|
||||
## 2. 원본 및 기준 확인
|
||||
|
||||
- 화면설계서 `화면설계서.pptx`와 요구사항정의서 `요구사항정의서.xlsx`를 확인했다.
|
||||
- 조사사업은 SFR-010-004~007, 준설관리는 SFR-010-012, 하수관망도 분석은 SFR-011/012 계열을 기준으로 기존 기능을 유지했다.
|
||||
- 도시침수예방사업 CRUD와 침입수·RDII 업무 화면은 원본에 직접 대응하는 화면 ID가 없어 기존 API·기능을 유지한 `UI 통일 설계 제안`으로 분류했다.
|
||||
|
||||
## 3. 주요 작업 내용
|
||||
|
||||
### 3.1 공통 UI 규칙 수립
|
||||
|
||||
- 검색 액션을 `조회 → 초기화` 순서로 통일했다.
|
||||
- 조회·등록·수정·저장·다운로드 계열은 파란색, 취소·닫기·상세보기 계열은 흰색, 삭제는 빨간색으로 정리했다.
|
||||
- 삭제 버튼은 액션 영역의 마지막에 배치했다.
|
||||
- 다운로드 문구는 데이터 파일이면 `엑셀다운로드`, 업로드 템플릿이면 `양식다운로드`로 구분했다.
|
||||
- 목록의 `NO`와 일련번호 표기를 `순번`으로 통일하고, 전체 건수 기준 내림차순 번호를 사용했다.
|
||||
- 목록 제목의 `(총 N건)` 표시는 제거했다.
|
||||
- 표 헤더는 파란색 배경, 흰색 글자, 가운데 정렬로 통일하고 사용자 정렬 UI는 제거했다.
|
||||
- 일반 목록은 내부 세로 스크롤을 사용하지 않고 브라우저 스크롤로 제어하며, 열이 많은 표만 가로 스크롤을 사용하도록 했다.
|
||||
|
||||
### 3.2 재사용 컴포넌트 구성
|
||||
|
||||
- CS 표 구조를 재사용하기 위한 `StandardDataTable`을 구성했다.
|
||||
- 등록·수정·상세 폼의 2~3열 배치를 위한 `StandardFormSection`, `StandardFormField`를 구성했다.
|
||||
- WindowPopup의 타이틀·본문·액션 배치를 통일하는 `StandardPopupLayout`을 구성했다.
|
||||
- SN 서버 페이징과 역순 순번을 연결하는 앱 로컬 `MapPopupDataTable`, `ExmnStandardDataTable`을 구성했다.
|
||||
- 컴포넌트명에는 `Sn` 접두사를 사용하지 않아 다른 앱에서도 재사용할 수 있도록 했다.
|
||||
|
||||
### 3.3 준설관리
|
||||
|
||||
- 경과년도별·관경별·지자체별 목록 제목을 `준설 목록`으로 통일했다.
|
||||
- 검색 카드, 조회·초기화, 목록 표, 페이징과 액션 버튼을 CS 규격으로 변경했다.
|
||||
- 등록·수정 화면을 공통 팝업과 표형 폼 구조로 변경했다.
|
||||
- 준설일자와 자동매핑을 같은 행에 배치하고 관로번호를 관로정보 영역 우측으로 이동했다.
|
||||
- 사진 기반 수정에서 사진이 없을 때 검증 메시지를 표시하고 직접 등록 방식으로 전환할 수 있도록 기존 흐름을 보완했다.
|
||||
- 달력을 부모 화면과 같은 날짜 선택 컴포넌트로 통일했다.
|
||||
- 사진 삭제·위치정보·저장 후 상세 진입 흐름을 보완했다.
|
||||
- 목록의 중복 세로 스크롤을 제거하고 가로 스크롤과 페이징 사이의 간격을 공통 표 규격으로 맞췄다.
|
||||
|
||||
### 3.4 조사관리·처리분구관리
|
||||
|
||||
- 조사 목록, 조사사업 등록·수정, 결과 업로드, 상세현황 팝업을 CS 카드·표·버튼 규격으로 변경했다.
|
||||
- 처리분구 선택 영역은 기존 체크박스 카드 방식을 유지하면서 제목·검색·빈 상태·간격을 통일했다.
|
||||
- 사업정보와 사업대상 처리분구 카드 사이에 공통 간격을 적용했다.
|
||||
- 맨홀·관로 조사 집계표의 전용 녹색/레거시 스타일을 제거하고 파란 중앙 헤더로 통일했다.
|
||||
- 상세 화면의 불필요한 `맨홀조사 종합집계표`, `관로조사 종합집계표` 중복 타이틀을 제거했다.
|
||||
- 조사사업 등록, 결과등록, 조사상세, 관로보고서 팝업을 공통 팝업 구조로 변경했다.
|
||||
|
||||
### 3.5 노후 하수관로·도시침수 예방사업
|
||||
|
||||
- 노후 하수관로 정비사업 목록·등록·수정과 최종확정예산사업 팝업을 공통 검색·표·폼 구조로 변경했다.
|
||||
- 도시침수예방사업의 신규 CRUD 경로와 기존 파일형 호환 경로를 모두 통일했다.
|
||||
- Excel 다중 탭 상세는 업무상 가변 열을 유지하면서 공통 표, 10건 페이징, 가로 스크롤을 적용했다.
|
||||
- 등록·수정 폼은 기본정보·연도결산·단계별 정보를 2~3열로 배치했다.
|
||||
|
||||
### 3.6 침입수·RDII 분석과 지도 WindowPopup
|
||||
|
||||
- 침입수·RDII 분석 입력, 결과 목록, 지도 결과, 차트 팝업에 공통 카드·폼·표 구조를 적용했다.
|
||||
- 관망도 정보검색, 관로분석, 노후관로, 지반침하, 침수위험지도, 도시침수지도, 보수이력, GIS 관리, 통수위험 등 지도 팝업을 통일했다.
|
||||
- `mapSearch`는 기본정보·유지관리·보고서 3개 탭의 기능과 지도 연계 `postMessage`를 유지했다.
|
||||
- 관로분석의 연도·재질 병합표와 조사 결함 다단 헤더처럼 CS에 동일 구조가 없는 표는 업무 구조를 유지한 커스텀 표로 처리했다.
|
||||
|
||||
### 3.7 사용자 메시지
|
||||
|
||||
- 네이티브 `window.alert`, `window.confirm`을 CS 방식의 `useAlertStore`, `useConfirm`으로 변경했다.
|
||||
- 성공 후 이동·팝업 닫기처럼 확인 이후 수행해야 하는 동작은 Alert callback으로 연결했다.
|
||||
- 화면 내 지속형 오류·검증 메시지는 인라인 상태로 유지했다.
|
||||
|
||||
## 4. 주요 수정 범위
|
||||
|
||||
- `apps/sn/app/(with-base-layout)/oper/**`
|
||||
- `apps/sn/app/(with-base-layout)/ii/**`
|
||||
- `apps/sn/app/(map-layout)/map/**`
|
||||
- `apps/sn/src/features/exmn/**`
|
||||
- `apps/sn/src/features/ctyFldngPrvtnBiz/**`
|
||||
- `apps/sn/src/features/obsltPpl/**`
|
||||
- `apps/sn/src/features/windowPopup/**`
|
||||
- `packages/common/business_components/src/ui/standard_*`
|
||||
|
||||
`packages/common/components`와 다른 회사 앱은 수정하지 않았다.
|
||||
|
||||
## 5. 검증
|
||||
|
||||
- 대상 화면의 목록·등록·수정·팝업을 로컬 브라우저에서 확인했다.
|
||||
- 버튼 순서·색상, 표 헤더, 페이징, 가로·세로 스크롤, 사용자 메시지와 팝업 부모창 연계를 점검했다.
|
||||
- TypeScript `--noEmit --ignoreDeprecations 5.0` 검사를 수행했다.
|
||||
- 대상 파일 Prettier와 `git diff --check`를 수행했다.
|
||||
- 사용자 개발 서버를 보호하기 위해 Next production build는 실행하지 않았다.
|
||||
|
||||
## 6. 영향 범위
|
||||
|
||||
- API endpoint, 요청·응답 구조, DB와 저장 계약은 변경하지 않았다.
|
||||
- 공통 `Standard*` 컴포넌트의 현재 실제 소비자는 SN이며, 다른 앱 화면이 자동으로 변경되지는 않는다.
|
||||
- 지도 자체의 레이어·이동·선택 기능은 유지하고 지도에서 여는 WindowPopup UI만 변경했다.
|
||||
|
||||
## 7. 롤백 방법
|
||||
|
||||
- 각 업무 화면의 CS UI 전환 변경을 되돌린다.
|
||||
- 앱 로컬 `MapPopupDataTable`, `ExmnStandardDataTable`과 `business_components`의 `Standard*` 추가·export를 함께 되돌린다.
|
||||
- 사용자 메시지 전환 시 기존 alert/confirm 이후 실행되던 callback 흐름도 함께 복원한다.
|
||||
|
||||
## 8. 남은 리스크
|
||||
|
||||
- 저장·삭제·파일 업로드·분석 실행은 실제 데이터를 변경하므로 UI 검증 단계에서는 실행하지 않았다.
|
||||
- 원본에 직접 대응 화면이 없는 도시침수예방사업 CRUD와 침입수·RDII 업무 화면은 설계 제안 상태다.
|
||||
- 지도 팝업의 실제 데이터 유무에 따라 빈 상태와 가로 스크롤 폭을 추가 확인할 필요가 있었다.
|
||||
@@ -0,0 +1,149 @@
|
||||
# 2026-08-13 SN CS UI 통일 작업내용
|
||||
|
||||
## 1. 작업 목적
|
||||
|
||||
- 전날 적용한 CS UI 통일 결과를 디지털하수관로 전체 화면에서 실제로 검증한다.
|
||||
- 단순 URL 렌더링이 아니라 본창 조회, 탭 전환, 행 선택, WindowPopup 생성과 실데이터 상세 흐름을 확인한다.
|
||||
- 검증 중 발견한 버튼·입력 높이, 로딩 중복, 팝업 취소, 헤더, 스크롤과 SSR 오류를 보완한다.
|
||||
|
||||
## 2. 원본 및 화면 인벤토리 확인
|
||||
|
||||
- 화면설계서 전체 58장과 요구사항정의서 `3. (기능)요구사항정의 및 추적표` 73~100행을 대조했다.
|
||||
- Next `page.tsx` 라우트는 27개로 확인했다.
|
||||
- 실제 업무 본창은 17개, WindowPopup 정의·레지스트리는 각각 33개이며 누락은 0개다.
|
||||
- WindowPopup 33개 중 실제 기능 팝업은 31개, 안내용 placeholder는 `floodPredictionMapPopup`, `gisEditPopup` 2개다.
|
||||
- 현재 운영 대상 논리 화면은 업무 본창 17개와 기능 팝업 31개를 합한 48개다.
|
||||
|
||||
## 3. 실제 브라우저 검증
|
||||
|
||||
### 3.1 본창 17개
|
||||
|
||||
- `/map`
|
||||
- `/ii/irsetting`, `/ii/rdii`, `/ii/result`, `/ii/rdiiResult`
|
||||
- `/oper/ctyFldngPrvtnBiz` 목록·등록·수정
|
||||
- `/oper/dredging`
|
||||
- `/oper/exmn/register`, `/oper/exmn/status`
|
||||
- `/oper/floodPre`
|
||||
- `/oper/obsltPplBiz` 목록·등록·수정
|
||||
- `/oper/oldPp`
|
||||
- `/oper/trtdst`
|
||||
|
||||
각 화면에서 최초 문서 요청 HTTP 200, 애플리케이션 오류 문구 없음, 문서 가로 넘침 없음, 로딩 종료 후 잔존 스피너 없음으로 확인했다.
|
||||
|
||||
### 3.2 WindowPopup 33개
|
||||
|
||||
- 팝업 33개를 동일한 로그인 세션에서 실제로 열었다.
|
||||
- 실데이터가 있는 팝업은 목록과 상세 데이터를 확인했다.
|
||||
- 데이터가 없는 팝업은 로더 종료 후 공통 빈 상태를 확인했다.
|
||||
- 파일·시설 식별자가 필요한 팝업은 필수 파라미터 안내를 확인했다.
|
||||
- 모든 팝업에 마지막 액션으로 32px 흰색 `취소`가 존재함을 확인했다.
|
||||
- 팝업 문서 전체의 불필요한 가로 넘침은 없고, 열이 많은 표 내부에서만 가로 스크롤이 발생함을 확인했다.
|
||||
|
||||
### 3.3 관로조사 상세현황
|
||||
|
||||
- 조사관리 본창에서 실제 조사사업과 처리분구를 선택해 상세현황을 확인했다.
|
||||
- 덕풍처리분구의 관로 3건과 결함 집계가 조회되는 상태를 확인했다.
|
||||
- `관로`, `본단위구간`, `조회`, `초기화`, `엑셀다운로드`, `취소` 버튼 높이를 모두 32px로 통일했다.
|
||||
- 상세 헤더 내부에 있던 별도 로딩 애니메이션과 `조회하고 있습니다` 문구를 제거하고 전체 오버레이 하나만 사용하도록 했다.
|
||||
- 조사 목록과 단일 상세 집계표 헤더는 56px 규격으로 통일했다.
|
||||
- 결함표는 4단 복합 헤더이므로 전체 높이는 더 크지만 헤더 셀의 파란 배경·흰색 글자·가운데 정렬·패딩 토큰은 동일하게 적용했다.
|
||||
|
||||
## 4. 검증 중 보완한 내용
|
||||
|
||||
### 4.1 버튼과 입력 높이
|
||||
|
||||
- 지도 상단 공통 검색바의 25px 조회 버튼을 32px로 변경했다.
|
||||
- 침입수·RDII 화면의 상단 액션·조회·파일 선택 버튼을 32px로 통일했다.
|
||||
- 관로분석, 노후관로, 지반침하, 침수지도, 보수이력, 침수이력 등 분석 팝업의 주요 액션을 32px로 통일했다.
|
||||
- 공통 SelectBox와 지도 팝업에 남아 있던 35px 선택박스를 32px로 통일했다.
|
||||
- 페이지 번호, 표 셀 링크, 지도 확대·축소처럼 문맥상 소형이어야 하는 컨트롤은 기존 소형 규격을 유지했다.
|
||||
|
||||
### 4.2 로딩 구조
|
||||
|
||||
- 입력 화면은 전체 화면 `LoadingOverlay` 하나만 사용하도록 했다.
|
||||
- 목록 화면은 공통 표 로더 또는 전체 오버레이 중 하나만 사용하도록 정리했다.
|
||||
- 침입수·RDII 결과 목록의 최초 중복 조회 effect를 제거했다.
|
||||
- 정보검색, 조사 상세, 통수위험, 분석 결과 팝업의 중복 로딩 문구와 표 내부 스피너를 제거했다.
|
||||
|
||||
### 4.3 정보검색·분석도구 팝업
|
||||
|
||||
- `mapSearch`를 가용 화면 전체 크기로 열도록 하고 compact underline 탭을 적용했다.
|
||||
- 기본정보·유지관리·보고서 탭의 조회·초기화·엑셀다운로드·지도에서 보기 버튼을 통일했다.
|
||||
- 관로분석, 관로현황, 시도별 노후관로, GIS 관리, 통수위험, 침입수·RDII 결과 팝업을 재점검했다.
|
||||
- 통수위험 전용 표를 `MapPopupDataTable`로 변경해 공통 빈 상태·페이징·순번·가로 스크롤 규칙을 적용했다.
|
||||
|
||||
### 4.4 WindowPopup 취소와 GIS 편집세션
|
||||
|
||||
- `StandardPopupLayout`에 기본 흰색 `취소(window.close)` 액션을 추가했다.
|
||||
- 자체 저장·수정 보호 로직이 있는 팝업은 기본 취소를 끄고 기존 핸들러를 유지했다.
|
||||
- 정의된 33개 팝업 모두 공통 취소 또는 자체 취소 중 하나를 가지도록 점검했다.
|
||||
- GIS 편집세션 상단 패널을 레이어·현재 모드·변경 건수·되돌리기 액션 중심으로 재구성했다.
|
||||
- 지도 하단 편집툴바를 편집도구·스냅핑·`저장 → 취소` 그룹으로 정리했다.
|
||||
|
||||
### 4.5 준설관리
|
||||
|
||||
- 목록의 `상세보기` 기능을 제거하고 `수정` 기능만 유지했다.
|
||||
- 최종 액션은 `엑셀다운로드 → 등록 → 수정 → 삭제`이며 삭제는 빨간색 마지막 버튼이다.
|
||||
- 등록·수정 팝업의 32px 저장·취소, 사진·지도·직접등록 전환 흐름을 재확인했다.
|
||||
|
||||
### 4.6 최초 요청 SSR 500 오류
|
||||
|
||||
- 전체 본창·팝업의 최초 SSR 요청에서 `dynamic usage of require is not supported` 오류가 발생하는 문제를 확인했다.
|
||||
- 원인은 `business_components` Excel export 모듈이 `@xlsx/xlsx-populate`를 서버에서 즉시 평가하는 구조였다.
|
||||
- Excel 스타일 모듈을 사용자 다운로드 시점에 동적으로 로딩하도록 변경했다.
|
||||
- 변경 후 침입수·RDII 본창과 WindowPopup 33개의 최초 문서 요청이 HTTP 200으로 응답함을 확인했다.
|
||||
|
||||
## 5. 공통 컴포넌트 사용 구분
|
||||
|
||||
### 5.1 CS/공통 컴포넌트를 그대로 사용
|
||||
|
||||
- `Button`, `Input`, `Select`, 날짜·월 선택 컴포넌트
|
||||
- `Table`, `Dialog`, `AlertDialog`, `ConfirmDialog`
|
||||
- `card`, `card-border`, `card-header`
|
||||
- `StandardDataTable`, `StandardFormSection`, `StandardFormField`, `StandardPopupLayout`
|
||||
|
||||
### 5.2 업무 특성상 커스텀 유지
|
||||
|
||||
- 관로조사 결함 4단 복합 헤더
|
||||
- 관로분석 연도·재질 `rowSpan` 표
|
||||
- 도시침수·노후관로 Excel 가변 다중 탭
|
||||
- 준설 사진·EXIF·지도 위치 선택
|
||||
- GIS GeoPackage 업로드·미리보기와 지도 `postMessage`
|
||||
- 침입수·RDII 분석 차트
|
||||
|
||||
## 6. 수정 범위
|
||||
|
||||
- `apps/sn/**`
|
||||
- `packages/common/business_components/**`
|
||||
|
||||
다른 회사 코드인 `packages/common/components`와 타 앱은 수정하지 않았다.
|
||||
|
||||
## 7. 테스트 및 검증
|
||||
|
||||
- 브라우저에서 업무 본창 17개와 WindowPopup 33개를 확인했다.
|
||||
- 실제 조사사업 → 처리분구 → 관로조사 상세현황 흐름을 확인했다.
|
||||
- `node apps/sn/scripts/audit-screen-inventory.mjs` 결과 라우트 27개, 팝업 정의 33개, 레지스트리 33개, 누락 0개를 확인했다.
|
||||
- `tsc --noEmit --incremental false --ignoreDeprecations 5.0` 전체 검사를 통과했다.
|
||||
- 대상 파일 Prettier 검사를 통과했다.
|
||||
- `git diff --check -- apps/sn packages/common/business_components`를 통과했다.
|
||||
- 사용자 개발 서버를 보호하기 위해 Next production build는 실행하지 않았다.
|
||||
|
||||
## 8. 영향 범위
|
||||
|
||||
- API, DB, 요청·응답 구조와 저장 로직에는 영향이 없다.
|
||||
- `business_components`의 검색바·SelectBox 규격 변경은 해당 컴포넌트의 다른 소비 화면에 영향을 줄 수 있으므로 32px 공통 규격을 기준으로 적용했다.
|
||||
- Excel 모듈은 사용자 다운로드 시점에만 로딩되며 기존 다운로드 파일 생성 흐름은 유지한다.
|
||||
|
||||
## 9. 롤백 방법
|
||||
|
||||
- SN 각 화면의 32px Button·Select 및 로딩 구조 변경을 되돌린다.
|
||||
- `StandardPopupLayout` 기본 취소와 개별 팝업의 중복 방지 속성을 함께 되돌린다.
|
||||
- `business_components`의 검색바, SelectBox, Excel 동적 로딩 변경을 되돌린다.
|
||||
- 화면 인벤토리 스크립트와 일자별 검증 문서를 삭제한다.
|
||||
|
||||
## 10. 남은 리스크
|
||||
|
||||
- `floodPredictionMapPopup`은 원본 요구사항이 있으나 현재 안내용 placeholder다.
|
||||
- `gisEditPopup` 직접 URL은 placeholder이며 실제 GIS 편집은 지도 본창의 인라인 편집세션으로 동작한다.
|
||||
- 저장·삭제·업로드·분석 실행은 실제 데이터 변경을 피하기 위해 실행하지 않았다.
|
||||
- Next 개발 모드에서 Radix 자동 ID 관련 hydration 경고가 간헐적으로 발생하지만 화면 표시와 기능에는 영향이 없었다. Production build 검증은 사용자 개발 서버 보호 때문에 수행하지 않았다.
|
||||
@@ -0,0 +1,64 @@
|
||||
# SN CS 사용자 메시지 통일 구현기록
|
||||
|
||||
## 1. 작업 목적
|
||||
|
||||
- SN 일반 화면의 브라우저 기본 `window.alert`, `window.confirm`을 CS와 동일한 공통 알림·확인 대화상자 방식으로 통일한다.
|
||||
- 성공 메시지를 확인하기 전에 화면이나 팝업이 닫히지 않도록 후속 이동, 창 닫기, 선택 초기화는 공통 알림의 확인 콜백에서 수행한다.
|
||||
- 기존 API 요청·응답, 저장·삭제 조건과 DB 구조는 변경하지 않는다.
|
||||
|
||||
## 2. 적용 기준
|
||||
|
||||
- 알림은 SN 전역 Provider에 이미 마운트된 `AlertDialog`와 `useAlertStore(state => state.setMessage)`를 사용한다.
|
||||
- 사용자 의사 확인은 전역 `ConfirmDialog`와 `useConfirm()`의 `await confirm(message)`를 사용한다.
|
||||
- 별도 SN 업무 전용 래퍼나 신규 시각 컴포넌트는 만들지 않고 CS가 사용하는 것과 동일한 공통 UI 계약을 따른다.
|
||||
- 조회 오류나 편집 상태처럼 화면에 지속해서 보여야 하는 인라인 안내는 브라우저 기본 알림이 아니므로 유지한다.
|
||||
- 침입수·강우유입수 분석 실행 전 확인도 기존 `confirmToast` 대신 동일한 `ConfirmDialog`를 사용한다.
|
||||
|
||||
## 3. 적용 범위
|
||||
|
||||
- 조사관리, 조사사업 등록·결과 등록·상세 및 처리분구관리
|
||||
- 준설관리 목록·등록·상세·수정
|
||||
- 노후 하수관로 정비사업과 최종확정예산사업 팝업
|
||||
- 노후관로·도시침수 예방사업의 목록, 업로드 및 보고서 파일 팝업
|
||||
- 침입수·강우유입수 분석 접수, 파일 업로드, 결과 조회·삭제 및 분석 완료 감시
|
||||
- 공통 파일 업로드와 도시침수 예방사업 등록·상세·수정
|
||||
- 하수관망도 버튼에서 여는 WindowPopup 21종과 해당 팝업의 조회·검증·다운로드·팝업 차단 메시지
|
||||
- 하수관망도 시설물 상세창의 저장 확인·결과 메시지, 상류관로 조회와 조사보고서 팝업 진입 메시지
|
||||
|
||||
하수관망도의 지도 캔버스·레이어 UI 자체는 UI 통일 예외로 유지하되, 사용자가 추가로 지정한 지도 버튼의 WindowPopup 서비스와 그 진입 메시지는 이번 전환에 포함했다. 실행 화면이 아닌 `app/html` 시안은 제외했다.
|
||||
|
||||
## 4. 동작 보존
|
||||
|
||||
- 등록·수정·삭제 성공 후 목록 이동 또는 팝업 닫기는 사용자가 공통 알림의 `확인`을 누른 뒤 실행한다.
|
||||
- 삭제, 저장, 등록방식 변경과 미저장 변경 폐기는 공통 확인창에서 `확인`한 경우에만 기존 로직을 실행한다.
|
||||
- 로딩 상태, 버튼 비활성화, 목록 재조회, 부모창 `postMessage`, 분석 완료 감시 시작 조건은 기존 흐름을 유지한다.
|
||||
- 지도 버튼의 팝업 키·URL·크기·필수 파라미터와 부모창 연계는 기존 `openWindowPopup` 계약을 유지하고, 팝업 차단 안내만 공통 알림으로 교체한다.
|
||||
- 관로분석 상세 팝업의 두 진입점에도 팝업 차단 안내를 보완했으며 업무 조회 조건은 변경하지 않았다.
|
||||
|
||||
## 5. 검증 결과
|
||||
|
||||
- 로컬 브라우저에서 준설 등록의 필수값 검증 메시지가 CS형 공통 알림창과 `확인` 버튼으로 표시되는 것을 확인했다.
|
||||
- 처리분구 행을 수정한 뒤 저장했을 때 `변경사항을 저장하시겠습니까?` 공통 확인창과 `확인`, `취소` 버튼이 표시되는 것을 확인했다. 검증 입력은 `취소` 후 페이지를 다시 조회해 폐기했다.
|
||||
- 침입수 분석 실행 시 소요시간 안내가 `확인`, `취소` 공통 확인창으로 표시되고, 확인 후 필수 파일 누락 메시지가 공통 알림창으로 이어지는 것을 확인했다. 필수 파일이 없어 API 요청과 데이터 변경은 발생하지 않았다.
|
||||
- RDII 분석 실행에서도 동일한 소요시간 공통 확인창과 필수 파일 누락 공통 알림창이 이어지는 것을 확인했다. 공통 `BaseLayout` 적용으로 침입수와 RDII가 같은 메시지 계약을 사용한다.
|
||||
- 도시침수 예방사업의 파일 기반 목록에서 선택 없이 삭제했을 때와 등록 팝업에서 파일 없이 등록했을 때 CS 공통 알림창이 표시되는 것을 확인했다. 별도 사업정보 화면의 등록·수정 성공과 삭제 확인도 동일한 공통 훅을 사용하며, 지속 표시가 필요한 조회·업로드 상태는 인라인 안내로 유지한다.
|
||||
- 하수관망도 레이어의 `노후관로 > 목록보기` 버튼을 실제 클릭해 WindowPopup 실행 흐름을 확인했다. 별도 팝업 라우트에서도 노후관로 경과년도를 `0`으로 조회했을 때 CS 공통 알림창과 `확인` 버튼이 표시되는 것을 확인했다.
|
||||
- 하수관망도 `정보검색` WindowPopup에서 파일 다운로드를 실행했을 때 장시간 소요 안내가 CS 공통 확인창과 `확인`, `취소` 버튼으로 표시되는 것을 확인하고 `취소`했다. 데이터 다운로드나 저장 변경은 수행하지 않았다.
|
||||
- GIS 관리 WindowPopup을 직접 열어 공통 Provider가 적용된 독립 팝업 렌더링을 확인했다. 테스트 계정에 선택 가능한 관리기관이 없어 업로드 검증 메시지 조작은 수행하지 않았다.
|
||||
- `apps/sn/app/(with-base-layout)`와 `apps/sn/src`를 검색해 `window.alert`, `window.confirm`, `globalThis.alert`, `globalThis.confirm`이 남아 있지 않은 것을 확인했다.
|
||||
- 실제 하수관망도 `apps/sn/app/(map-layout)/map`과 지도에서 도달 가능한 WindowPopup 콘텐츠를 검색해 `window.alert`, `window.confirm`, `globalThis.alert`, `globalThis.confirm`, `confirmToast`, `react-hot-toast`가 남아 있지 않은 것을 확인했다.
|
||||
- `git diff --check`를 통과했다.
|
||||
- 전체 TypeScript 검사는 이번 메시지 변경 파일이 아닌 기존 하수관망도, 조사 API 타입, 공통 메뉴 경로 오류 때문에 완료되지 않았다. 사용자 개발 서버 보호를 위해 Next 빌드는 실행하지 않았다.
|
||||
|
||||
## 6. 영향 범위와 롤백
|
||||
|
||||
- 영향은 사용자 메시지 표시 방식과 확인 시점에 한정되며 백엔드 API와 DB 영향은 없다.
|
||||
- 롤백 시 각 화면의 `useAlertStore`, `useConfirm` 적용 부분과 성공 후 콜백 이동만 이전 호출 방식으로 되돌리면 된다.
|
||||
|
||||
## 7. 원본 확인
|
||||
|
||||
- `요구사항정의서.xlsx`의 기능 요구사항 및 추적표와 `화면설계서.pptx`를 `침입수`, `유입수`, `I/I` 기준으로 직접 확인했다.
|
||||
- 하수관망도 WindowPopup은 화면설계서 슬라이드 7~17의 `SN_MN010102~SN_MN010108`, `SFR-011-001~006` 및 슬라이드 23~25의 GIS 편집·업로드·조회 화면을 확인했다. 요구사항정의서 기능 요구사항 및 추적표의 `SFR-011-005~006`, `SFR-012-002~005`와 `PR-SN-025`, `PR-SN-030`을 대조했다.
|
||||
- 제공 원본에는 침입수 분석 실행 확인창의 표현 방식을 직접 지정한 화면이 없어, 이번 변경은 사용자의 CS UI 통일 지시에 따른 설계 보완으로 기록한다.
|
||||
- 원본은 하수관망도 버튼과 팝업의 업무 흐름을 정의하지만 메시지의 시각 표현 방식은 지정하지 않아, CS 공통 대화상자 적용은 사용자의 UI 통일 지시에 따른 설계 보완으로 기록한다.
|
||||
- 기존 분석 입력값, 파일 검증, 분석 접수, 결과 조회와 완료 감시 흐름은 변경하지 않았다.
|
||||
@@ -48,3 +48,94 @@
|
||||
- 지역 입력 행 제목을 `시군구 선택`에서 `시도,시군구 선택`으로 변경하고 필수 표시를 추가했다.
|
||||
- 저장 전 시도 선택 여부를 검증하며, 미선택 시 `시도는 필수입니다.`를 표시한다.
|
||||
- 시군구는 선택사항으로 유지하여 시도만 선택한 자료도 저장 요청할 수 있다.
|
||||
|
||||
## 2026-08-12 CS UI 기준 목록·입력 화면 전환
|
||||
|
||||
### 요구사항 추적
|
||||
|
||||
- 원본 화면설계서 슬라이드 8~9의 `SFR-011-001`, 화면 ID `SN_MN010102`와 요구사항정의서의 `SFR-011-001`~`SFR-011-004`를 재확인했다.
|
||||
- 원본은 지도 기반 노후관로 관리·현황과 점검 대상 그리드·Excel 다운로드를 다루며, Excel 기반 `노후 하수관로 정밀조사 및 정비사업 현황` 목록과 직접 일치하지 않는다.
|
||||
- 따라서 이번 변경은 원본 기능 요구사항의 신규 구현이 아니라, 사용자가 지정한 CS UI 기준을 기존 목록 기능에 적용한 `설계 보완`으로 구분한다.
|
||||
|
||||
### 구현 내용
|
||||
|
||||
- 검색 영역을 CS와 동일한 공통 `Label`, `Select`, `Input`, `Button` 조합과 카드 구조로 변경하고 `조회`, `초기화` 간격을 공통 `gap-2`로 통일했다.
|
||||
- 등록·최종확정예산사업·업로드·엑셀다운로드는 검색 영역에서 분리해 목록 카드 헤더 우측 액션으로 배치했다.
|
||||
- 22개 컬럼 목록을 공통 `DataTable` 기반 `StandardDataTable`로 교체하고, 컬럼별 실제 폭·정렬과 서버 페이징을 TanStack Table 상태에 연결했다.
|
||||
- `StandardDataTable`은 업무명에 종속되지 않는 공통 컴포넌트로 유지하며 화면별 가로·세로 스크롤 선택, 고정 헤더, 빈 상태, 목록 액션, 스크롤 영역 밖 페이징을 제공한다.
|
||||
- 이 화면은 서버가 10건씩 페이징하므로 내부 세로 스크롤은 사용하지 않고 가로 스크롤만 적용해 브라우저 세로 스크롤과 중복되지 않게 했다.
|
||||
- 공통 표가 실제 `scrollWidth > clientWidth`인지와 운영체제 스크롤바 높이를 측정하여, 가로 스크롤바가 있으면 그 높이만큼 페이징 상단 여백을 줄인다. 가로 스크롤이 없는 표는 CS 기본 24px 여백을 유지한다.
|
||||
- `StandardDataTable` 헤더는 컬럼 본문 정렬값과 분리해 항상 가운데 정렬하고, 사업명·비고·수치 등 본문 셀은 기존 컬럼별 좌·중·우 정렬을 유지한다.
|
||||
- SN 전체 일반 페이지의 flex 항목에 `min-width: 0`을 적용하여 넓은 표가 페이지 전체 폭을 확장하지 않고 그리드 내부에서만 가로 스크롤되게 했다. 지도 레이아웃에는 적용하지 않았다.
|
||||
- 기존 등록·수정 이동, 최종확정예산 팝업, Excel 업로드·다운로드, 검색조건·페이지 복귀 계약은 유지했다.
|
||||
- 목록 카드 제목은 메뉴명 뒤에 `목록`을 붙이는 규칙에 맞춰 `노후 하수관로 정비사업 목록`으로 변경했다.
|
||||
- 등록 및 상세·수정 화면은 공통 `Label`, `Input`, `Select`, `Textarea`, `Button`을 그대로 사용하고 `기본 정보`, `지역 및 일정`, `사업량`, `담당자 및 의견`의 네 구역으로 재구성했다.
|
||||
- 입력 구역은 화면 폭에 따라 1열·2열·3열로 반응하며, 데스크톱에서는 연관된 짧은 항목을 한 행에 2~3개 배치한다. 사업명·연락처는 두 칸, 부진사유·비고·공단 의견은 세 칸을 사용하여 빈 셀과 불필요한 세로 길이를 줄였다.
|
||||
- 사업코드·사업명·시도 필수 검증, 지역 연동, 숫자·연도·일자 처리, 등록·수정·삭제 API 계약은 변경하지 않았다.
|
||||
|
||||
### 영향 범위
|
||||
|
||||
- 현재 직접 전환한 업무 화면은 `/oper/obsltPplBiz` 목록과 `/oper/obsltPplBiz/register`, `/oper/obsltPplBiz/register/[id]` 등록·상세·수정 화면이다.
|
||||
- SN 일반 BaseLayout의 폭 수축 규칙은 다른 일반 페이지에도 적용되지만, 콘텐츠를 넓히지 않고 기존 flex overflow를 정상화하는 규칙만 추가했다.
|
||||
- `StandardDataTable`은 `business_components`에 두어 WR·DM·FO 등에서도 재사용할 수 있으며 SN 또는 노후하수관로 업무 분기는 포함하지 않았다. 다른 앱의 기존 화면 코드는 수정하지 않았다.
|
||||
|
||||
### 검증 결과
|
||||
|
||||
- 로컬 브라우저에서 실제 정밀조사 128건을 조회하고 첫 페이지 10행, `128`부터의 역순 번호를 확인했다.
|
||||
- 1,265px 문서 폭에서 문서 `scrollWidth`가 1,265px로 유지되고, 표 스크롤 영역은 `clientWidth 995px / scrollWidth 2,800px`로 내부 가로 스크롤되는 것을 확인했다.
|
||||
- 표의 10행은 `clientHeight 493px / scrollHeight 493px`로 내부 세로 스크롤 없이 자연 높이로 표시된다. 720px 브라우저 높이에서 문서 높이는 993px이므로 페이지 세로 이동은 브라우저 기본 스크롤만 사용함을 확인했다.
|
||||
- 노후 하수관로 표는 가로 스크롤바 높이 15px을 감지해 페이징 상단 여백을 9px로 보정하고, 마지막 행부터 페이징까지 총 24px을 유지함을 확인했다. 가로 스크롤이 없는 준설 목록은 기존 24px 여백과 총 24px 간격을 그대로 유지했다.
|
||||
- 페이징이 스크롤 컨테이너 바깥에 있고, 2페이지 이동 시 첫 번호 `118`, 1페이지·초기화 복귀 시 `128`로 조회되는 것을 확인했다.
|
||||
- 사업명 임시 입력 후 초기화하면 입력값이 비워지고 사업구분 `정밀조사`, 1페이지로 복귀함을 확인했다.
|
||||
- 첫 행 클릭 시 `/oper/obsltPplBiz/register/128`로 이동하는 기존 상세·수정 진입을 확인했다.
|
||||
- 등록 화면을 1,280px 폭에서 확인한 결과 기본 정보 3열·2행, 지역 및 일정 3열·3행, 사업량 3열·1행, 담당자 및 의견 3열·4행으로 배치되며 문서 가로 스크롤은 발생하지 않았다.
|
||||
- 빈 등록 화면에서 저장을 시도했을 때 API 저장 없이 `사업코드는 필수입니다.`, `사업명은 필수입니다.`, `시도는 필수입니다.`가 각 항목에 표시됨을 확인했다.
|
||||
- 상세·수정 화면에서 128번 자료의 사업구분, 사업코드, 사업명, 시도·시군구·환경청, 일정, 사업량, 담당자와 의견 값이 기존 데이터와 동일하게 로딩되고 `저장`, `목록`, `삭제` 버튼이 유지됨을 확인했다.
|
||||
- 목록 제목이 `노후 하수관로 정비사업 목록`으로 표시되고, 공통 표 헤더가 모두 가운데 정렬됨을 브라우저 계산 스타일로 확인했다.
|
||||
|
||||
## 2026-08-12 최종확정예산사업 팝업 UI·순번 통일
|
||||
|
||||
### 설계 구분
|
||||
|
||||
- 원본 요구사항정의서와 화면설계서에는 현재 Excel 기반 노후 하수관로 정비사업 및 최종확정예산사업 팝업과 직접 일치하는 화면이 없어 기존 통합설계와 사용자 지시에 따른 `설계 보완`으로 적용했다.
|
||||
|
||||
### 구현 내용
|
||||
|
||||
- 최종확정예산사업 WindowPopup의 구형 `SearchDiv`, `ListDiv`, `UiTable`을 공통 `StandardPopupLayout`, `StandardDataTable`, `Button`, `Input`, `Select` 구조로 교체했다.
|
||||
- 행정구역·사업 분류·년도·사업명 검색, 조회·초기화, 다중 선택·등록, Excel 다운로드와 부모창 완료 메시지 계약은 보존했다.
|
||||
- 행정구역·사업규모·총사업비·년차 사업비의 다단 헤더를 CS 기본 파란 헤더·흰 글씨·가운데 정렬로 통일했다.
|
||||
- 표 헤더는 `순번`으로 표시하고 전체 건수와 서버 페이지를 기준으로 내림차순 번호를 계산한다. 정렬 기능은 추가하지 않았다.
|
||||
- 백엔드 API, 저장 계약과 DB 구조는 변경하지 않았다.
|
||||
|
||||
### 검증 결과
|
||||
|
||||
- 로그인 브라우저에서 `/oper/obsltPplBiz`의 목록 헤더가 `순번`이며 첫 페이지가 `128`부터 내림차순으로 표시되는 것을 확인했다.
|
||||
- `/popup/cfmtnBgt?bizSe=정비사업`에서 조회 후 다단 헤더, 내부 가로 스크롤, 첫 페이지 `170`부터의 내림차순 번호와 2페이지 첫 번호 `160`을 확인했다.
|
||||
- 검색·초기화·등록 비활성 상태·Excel 다운로드 버튼과 10건 서버 페이징을 확인했다.
|
||||
- TypeScript 전체 검사는 이번 팝업 파일의 오류가 없음을 확인했으나 작업 범위 밖 기존 오류로 전체 명령은 실패했다. 개발 서버 보호를 위해 Next 빌드는 수행하지 않았다.
|
||||
|
||||
## 2026-08-12 SN 일반 화면 상단 헤더 고정 회귀 보완
|
||||
|
||||
- 넓은 표의 페이지 전체 가로 스크롤을 막기 위해 `sn-base-layout` 상위 요소에 적용했던 `overflow-x-hidden`이 공통 `Header`의 `sticky` 기준을 변경해, 세로 스크롤 시 헤더가 콘텐츠와 함께 올라가는 회귀를 확인했다.
|
||||
- 상위 레이아웃의 가로 넘침 처리를 스크롤 컨테이너를 만들지 않는 `overflow-x: clip`으로 변경했다.
|
||||
- flex 레이아웃에는 `min-width: 0`, `max-width: 100%`만 유지하고, 실제 다열 표의 좌우 이동은 `StandardDataTable` 내부 가로 스크롤이 계속 담당한다.
|
||||
- 브라우저에서 준설관리와 노후 하수관로 정비사업 화면을 최하단까지 이동한 뒤 헤더 위치가 `top: 0`으로 유지되는 것을 확인했다.
|
||||
- 노후 하수관로 화면의 문서 폭은 `clientWidth 1265px`, `scrollWidth 1265px`로 페이지 전체 가로 스크롤이 다시 생기지 않았음을 확인했다.
|
||||
- 하수관망도와 팝업 레이아웃, API·DB 계약은 변경하지 않았다.
|
||||
|
||||
## 2026-08-12 버튼 색상·문구·순서 통일
|
||||
|
||||
- 목록 검색은 `조회` 파란색, `초기화` 흰색 순서로 유지했다.
|
||||
- 목록 액션은 `엑셀다운로드 → 등록 → 최종확정예산사업 → 업로드` 순서로 정리하고 실행 동작은 파란색으로 통일했다.
|
||||
- 등록·상세/수정 화면은 `저장` 파란색, `취소` 흰색, `삭제` 빨간색 순서로 변경해 삭제가 항상 마지막에 오도록 했다.
|
||||
- 최종확정예산사업 팝업은 `조회 → 초기화`, 목록 액션은 `엑셀다운로드 → 등록` 순서를 적용했다.
|
||||
- 기존 Excel 업로드·다운로드, 등록·수정·삭제 기능과 비활성화 조건은 변경하지 않았다.
|
||||
|
||||
## 2026-08-13 최종확정예산사업 팝업 여백·검색 배치 보완
|
||||
|
||||
- 원본 화면설계서 슬라이드 8의 `SFR-011-001`, 요구사항정의서 추적표 87~90행의 `SFR-011-001`~`SFR-011-004`를 다시 확인했다. 원본에는 현재 최종확정예산사업 선택 팝업과 직접 일치하는 화면이 없어 기존 조회·선택·등록 계약을 유지한 `설계 보완`으로 처리했다.
|
||||
- 검색조건을 `환경청·시도·시군구·년도`와 `세부사업·내역사업·세부내역·사업명`의 4개씩 두 행으로 고정해 창 너비에 따라 임의로 끊기던 배치를 제거했다.
|
||||
- 팝업에서만 사용하던 25px 소형 버튼을 제거하고 조회·초기화·엑셀다운로드·등록을 CS 공통 32px 버튼 규격으로 통일했다.
|
||||
- 공통 팝업의 12px 간격과 중복되던 별도 여백을 제거하고, WindowPopup 크기를 `1400×720`으로 조정했다. 빈 목록은 목록 카드가 남은 세로 공간을 채우도록 해 카드 밖에 과도한 공백이 남지 않게 했다.
|
||||
- `StandardDataTable`에 팝업 전용 바깥 margin 제거 옵션을 추가해 이 팝업의 검색 카드와 목록 카드 좌우 기준선을 동일하게 맞췄다. 일반 페이지의 기존 카드 margin은 그대로 보존한다. 빈 목록은 남은 높이를 채우고, 조회 결과가 있으면 표의 자연 높이를 보존해 팝업 본문만 세로 이동하도록 공통 동작을 구분했다.
|
||||
- API, 조회조건, 다중선택, Excel 다운로드, 등록과 부모창 메시지 계약은 변경하지 않았다.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user