sn: 노후관로관리 1차 디자인

This commit is contained in:
2026-03-26 10:53:43 +09:00
parent b40a974ba3
commit 41504e6e0f
7 changed files with 964 additions and 2 deletions
@@ -0,0 +1,55 @@
"use client";
import { Button } from "@common/components";
import {
AGED_PIPE_DETAIL_FIELDS,
getElapsedYears,
getPipeDisplayValue,
} from "./agedPipeUtils";
type Props = {
featureData: any;
onClose: () => void;
};
export default function AgedPipeDetailPanel({ featureData, onClose }: Props) {
const source = featureData?.values_ ?? featureData ?? {};
const facilityNo = String(getPipeDisplayValue(source, "fclt_mng_no") ?? "-");
const elapsedYears = getElapsedYears(source.instl_ymd ?? source.instlYmd);
return (
<div className="absolute right-0 top-0 z-[50] flex h-[calc(100vh-5rem)] w-[320px] flex-col overflow-hidden border-l border-slate-200 bg-white shadow-[-12px_0_24px_rgba(15,23,42,0.1)]">
<div className="flex items-start justify-between border-b border-slate-200 bg-slate-900 px-4 py-3">
<div>
<div className="text-[1.45rem] font-semibold text-white">노후관로 상세</div>
<div className="mt-1 text-[1.05rem] text-slate-300">시설관리번호 {facilityNo}</div>
<div className="mt-1 text-[1.05rem] text-amber-300">
경과년도 {elapsedYears ?? "-"}년
</div>
</div>
<Button
type="button"
color="white"
size="xs"
className="border border-white/30 px-3 py-1 text-[1.05rem] text-white"
onClick={onClose}
>
닫기
</Button>
</div>
<div className="overflow-y-auto px-4 py-3">
<div className="space-y-2">
{AGED_PIPE_DETAIL_FIELDS.map((field) => (
<div key={field.id} className="rounded-lg border border-slate-200 px-3 py-2.5">
<div className="text-[1rem] font-medium text-slate-500">{field.title}</div>
<div className="mt-1 break-words text-[1.15rem] leading-6 text-slate-900">
{String(getPipeDisplayValue(source, field.id) ?? "-")}
</div>
</div>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,101 @@
"use client";
import { Button, Input } from "@common/components";
import useStore from "@/store/useStore";
import type { AgedPipeFilters } from "./agedPipeUtils";
type OverlayProps = {
onClose?: () => void;
isPlaying?: boolean;
setIsPlaying?: (value: boolean) => void;
totalSteps?: number;
currentStep?: number;
};
type Props = OverlayProps & {
filters: AgedPipeFilters;
onFiltersChange: (filters: AgedPipeFilters) => void;
onApply: () => void;
};
export default function AgedPipeFilterPanel({
filters,
onFiltersChange,
onApply,
}: Props) {
const { ctpv, sgg } = useStore();
const sggOptions = filters.ctpvCd === "0"
? sgg
: sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2)));
return (
<div className="w-[280px] rounded-xl border border-slate-200 bg-white/95 p-3 shadow-[0_14px_30px_rgba(15,23,42,0.16)] backdrop-blur">
<div className="mb-2">
<div className="text-[1.35rem] font-semibold text-slate-900">노후관로 기준</div>
<div className="mt-1 text-[1.05rem] leading-5 text-slate-500">
경과년수 이상 관로만 지도와 목록에 반영합니다.
</div>
</div>
<div className="space-y-2.5">
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">경과년수</div>
<Input
className="h-10 !text-[1.15rem]"
inputMode="numeric"
value={String(filters.elapsedYears)}
onChange={(event) => {
const next = Number(event.target.value.replace(/[^0-9]/g, "") || "0");
onFiltersChange({ ...filters, elapsedYears: next });
}}
onKeyDown={(event) => {
if (event.key === "Enter") onApply();
}}
/>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.ctpvCd}
onChange={(event) =>
onFiltersChange({
...filters,
ctpvCd: event.target.value,
sggCd: "0",
})
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<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>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.sggCd}
onChange={(event) => onFiltersChange({ ...filters, sggCd: event.target.value })}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
</div>
<Button className="mt-3 h-10 w-full !text-[1.15rem]" onClick={onApply}>
GIS 반영
</Button>
</div>
);
}
@@ -0,0 +1,180 @@
"use client";
import { useRef } from "react";
import { Button, Input } from "@common/components";
import { UiTable } from "@common/business_components/ui";
import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore";
import {
AGED_PIPE_LIST_HEADLIST,
type AgedPipeFilters,
type AgedPipeRow,
} from "./agedPipeUtils";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
filters: AgedPipeFilters;
onFiltersChange: (filters: AgedPipeFilters) => void;
rows: AgedPipeRow[];
total: number;
currentPage: number;
isLoading: boolean;
onSearch: (page?: number) => void;
onDownload: () => void;
onRowDoubleClick: (row: AgedPipeRow) => void;
};
export default function AgedPipeListModal({
open,
onOpenChange,
filters,
onFiltersChange,
rows,
total,
currentPage,
isLoading,
onSearch,
onDownload,
onRowDoubleClick,
}: Props) {
const { ctpv, sgg } = useStore();
const lastClickRef = useRef<{ index: number; clickedAt: number } | null>(null);
const sggOptions = filters.ctpvCd === "0"
? sgg
: sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2)));
const handleRowClick = (index?: number) => {
if (typeof index !== "number") return;
const row = rows[index];
if (!row) return;
const now = Date.now();
const lastClick = lastClickRef.current;
if (lastClick && lastClick.index === index && now - lastClick.clickedAt < 300) {
lastClickRef.current = null;
onRowDoubleClick(row);
return;
}
lastClickRef.current = { index, clickedAt: now };
};
if (!open) return null;
return (
<div className="pointer-events-none absolute inset-0 z-[70] flex items-center justify-center px-6 py-8">
<div className="pointer-events-auto relative flex max-h-[82vh] w-[min(1120px,94vw)] flex-col overflow-hidden rounded-2xl border border-slate-300 bg-white shadow-[0_28px_80px_rgba(15,23,42,0.28)]">
<LoadingOverlay isLoading={isLoading} />
<div className="flex items-center justify-between border-b border-slate-200 bg-slate-900 px-5 py-3">
<div>
<div className="text-[1.45rem] font-semibold text-white">노후관로 목록</div>
<div className="mt-1 text-[1rem] text-slate-300">
지도 위에 띄워지는 윈도우형 팝업 미리보기
</div>
</div>
<Button
type="button"
color="white"
size="xs"
className="border border-white/30 px-3 py-1 text-[1.05rem] text-white"
onClick={() => onOpenChange(false)}
>
닫기
</Button>
</div>
<div className="relative overflow-y-auto p-4">
<div className="grid grid-cols-1 gap-3 pb-4 md:grid-cols-[110px_minmax(0,1fr)_110px_minmax(0,1fr)_auto_auto]">
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">경과년수</div>
<Input
className="h-10 !text-[1.1rem]"
inputMode="numeric"
value={String(filters.elapsedYears)}
onChange={(event) => {
const next = Number(event.target.value.replace(/[^0-9]/g, "") || "0");
onFiltersChange({ ...filters, elapsedYears: next });
}}
onKeyDown={(event) => {
if (event.key === "Enter") onSearch(1);
}}
/>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.ctpvCd}
onChange={(event) =>
onFiltersChange({
...filters,
ctpvCd: event.target.value,
sggCd: "0",
})
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<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>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.sggCd}
onChange={(event) => onFiltersChange({ ...filters, sggCd: event.target.value })}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<div className="flex flex-wrap gap-2 md:col-span-3 md:justify-end">
<Button type="button" size="sm" className="h-10 px-5 !text-[1.1rem]" onClick={() => onSearch(1)}>
조회
</Button>
<Button
type="button"
size="sm"
color="green"
className="h-10 px-5 !text-[1.1rem]"
onClick={onDownload}
>
다운로드
</Button>
</div>
</div>
<div className="border-t border-slate-200 pt-3">
<UiTable<AgedPipeRow>
headName={<>노후관로 조회 결과</>}
tableData={rows}
headlist={AGED_PIPE_LIST_HEADLIST}
pageSize={10}
total={total}
cellClick={(index) => handleRowClick(index)}
onPageChange={(page) => onSearch(page)}
curPage={currentPage}
widthFixed={true}
/>
</div>
<div className="px-[1.2rem] pb-1 text-[1rem] text-slate-500">
목록 행을 더블클릭하면 지도 이동 후 상세 패널이 열립니다.
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,221 @@
"use client";
import type { SelectDataType } from "@common/business_components";
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
import {
MS_SWAGE_PPL_CODES,
MS_SWAGE_PPL_USE_CODES,
MsSwagePplCol,
} from "../map/components/common/table";
export type AgedPipeFilters = {
elapsedYears: number;
ctpvCd: string;
sggCd: string;
};
export type AgedPipeRow = {
fid: string;
lyrTypeNm: string;
rowNumber: number;
fcltMngNo: string;
instlYmd: string;
pdmtVl: string | number;
prlgVl: string | number;
usgCd: string;
mqltCd: string;
elapsedYears: number | string;
ctpvNm?: string;
sggNm?: string;
[key: string]: unknown;
};
type PipeSource = Record<string, unknown>;
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]));
export const AGED_PIPE_DEFAULT_FILTERS: AgedPipeFilters = {
elapsedYears: 20,
ctpvCd: "0",
sggCd: "0",
};
export const AGED_PIPE_LIST_HEADLIST = [
{ id: "rowNumber", title: "NO", 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: 14 },
{ id: "mqltCd", title: "재질", widthPercent: 18 },
{ id: "elapsedYears", title: "경과년도", widthPercent: 12, align: "right" },
];
export const AGED_PIPE_EXCEL_HEADER = {
rowNumber: "NO",
fcltMngNo: "시설관리번호",
instlYmd: "설치일자",
pdmtVl: "관경(mm)",
prlgVl: "연장(m)",
usgCd: "용도",
mqltCd: "재질",
elapsedYears: "경과년도",
};
export const AGED_PIPE_DETAIL_FIELDS = MsSwagePplCol;
const getToday = () => new Date();
const pad2 = (value: number) => String(value).padStart(2, "0");
export const getCutoffYmd = (elapsedYears: number) => {
const today = getToday();
const cutoff = new Date(today.getFullYear() - elapsedYears, today.getMonth(), today.getDate());
return `${cutoff.getFullYear()}${pad2(cutoff.getMonth() + 1)}${pad2(cutoff.getDate())}`;
};
const normalizeYmd = (value: unknown) => String(value ?? "").replace(/[^0-9]/g, "").slice(0, 8);
const getCodePrefix = (source: PipeSource) =>
String(source.pbadmsEmdCd ?? source.pbadms_emd_cd ?? "").trim();
const resolveRegionName = (
code: string,
directName: unknown,
options: SelectDataType[],
prefixLength: number,
) => {
if (String(directName ?? "").trim()) return String(directName);
const prefix = code.slice(0, prefixLength);
return options.find((item) => String(item.val).trim() === prefix)?.text ?? "";
};
export const getElapsedYears = (value: unknown) => {
const normalized = normalizeYmd(value);
if (normalized.length < 4) return null;
const year = Number(normalized.slice(0, 4));
const month = Number(normalized.slice(4, 6) || "1");
const day = Number(normalized.slice(6, 8) || "1");
if (!year) return null;
const today = getToday();
let elapsed = today.getFullYear() - year;
if (
today.getMonth() + 1 < month ||
(today.getMonth() + 1 === month && today.getDate() < day)
) {
elapsed -= 1;
}
return Math.max(elapsed, 0);
};
export const buildAgedPipeCqlFilters = (filters: AgedPipeFilters) => {
const cqlFilters: string[] = [];
cqlFilters.push(`instl_ymd <= '${getCutoffYmd(filters.elapsedYears)}'`);
if (filters.sggCd !== "0") {
cqlFilters.push(`pbadms_emd_cd LIKE '${filters.sggCd}%'`);
} else if (filters.ctpvCd !== "0") {
cqlFilters.push(`pbadms_emd_cd LIKE '${filters.ctpvCd}%'`);
}
return cqlFilters;
};
export const matchesAgedPipeFilters = (source: PipeSource, filters: AgedPipeFilters) => {
const instlYmd = normalizeYmd(source.instl_ymd ?? source.instlYmd);
if (!instlYmd || instlYmd > getCutoffYmd(filters.elapsedYears)) return false;
const regionCode = getCodePrefix(source);
if (filters.sggCd !== "0") return regionCode.startsWith(filters.sggCd);
if (filters.ctpvCd !== "0") return regionCode.startsWith(filters.ctpvCd);
return true;
};
export const toDisplayPipeRow = (
source: PipeSource,
index: number,
page: number,
ctpvOptions: SelectDataType[],
sggOptions: SelectDataType[],
): AgedPipeRow => {
const formatted = formatDisplayData(source) as PipeSource;
const codePrefix = getCodePrefix(source);
const instlYmd = formatted.instlYmd ?? source.instlYmd ?? formatted.instl_ymd ?? source.instl_ymd;
const trimmedUse = String(source.usgCd ?? source.usg_cd ?? "").trim();
const trimmedMaterial = String(source.mqltCd ?? source.mqlt_cd ?? "").trim();
const pdmtVl = formatted.pdmtVl ?? source.pdmtVl ?? source.pdmt_vl ?? "-";
const prlgVl = formatted.prlgVl ?? source.prlgVl ?? source.prlg_vl ?? "-";
return {
...source,
...formatted,
fid: String(source.fid ?? ""),
lyrTypeNm: String(source.lyrTypeNm ?? "tb_ms_swage_ppl"),
rowNumber: (page - 1) * 10 + index + 1,
fcltMngNo: String(formatted.fcltMngNo ?? source.fcltMngNo ?? source.fclt_mng_no ?? "-"),
instlYmd: String(instlYmd ?? "-"),
pdmtVl: typeof pdmtVl === "number" ? pdmtVl : String(pdmtVl),
prlgVl: typeof prlgVl === "number" ? prlgVl : String(prlgVl),
usgCd: useMap.get(trimmedUse) ?? String(formatted.usgCd ?? source.usgCd ?? source.usg_cd ?? "-"),
mqltCd:
materialMap.get(trimmedMaterial) ??
String(formatted.mqltCd ?? source.mqltCd ?? source.mqlt_cd ?? "-"),
elapsedYears: getElapsedYears(source.instlYmd ?? source.instl_ymd) ?? "-",
ctpvNm: resolveRegionName(
codePrefix,
formatted.ctpvNm ?? source.ctpvNm ?? source.ctpv_nm,
ctpvOptions,
2,
),
sggNm: resolveRegionName(
codePrefix,
formatted.sggNm ?? source.sggNm ?? source.sgg_nm,
sggOptions,
5,
),
};
};
export const getPipeDisplayValue = (source: PipeSource, key: string) => {
const formatted = formatDisplayData(source) as PipeSource;
const keyMap: Record<string, string[]> = {
fclt_mng_no: ["fclt_mng_no", "fcltMngNo"],
inst_mng_no: ["inst_mng_no", "instMngNo"],
instl_ymd: ["instl_ymd", "instlYmd"],
mapsht_no: ["mapsht_no", "mapshtNo"],
pdmt_vl: ["pdmt_vl", "pdmtVl"],
prlg_vl: ["prlg_vl", "prlgVl"],
usg_cd: ["usg_cd", "usgCd"],
mqlt_cd: ["mqlt_cd", "mqltCd"],
avg_grdn: ["avg_grdn", "avgGrdn"],
drtw_mltv_area: ["drtw_mltv_area", "drtwMltvArea"],
excln_mltv_area: ["excln_mltv_area", "exclnMltvArea"],
tmpt_dpth: ["tmpt_dpth", "tmptDpth"],
tmpt_mhl_mng_no: ["tmpt_mhl_mng_no", "tmptMhlMngNo"],
scend_dpth: ["scend_dpth", "scendDpth"],
scend_mhl_mng_no: ["scend_mhl_mng_no", "scendMhlMngNo"],
trfln_cnt: ["trfln_cnt", "trflnCnt"],
vrtc_len: ["vrtc_len", "vrtcLen"],
wdth_len: ["wdth_len", "wdthLen"],
};
const candidates = keyMap[key] ?? [key];
for (const candidate of candidates) {
const value = formatted[candidate] ?? source[candidate];
if (value !== undefined && value !== null && value !== "") {
if (candidate === "usg_cd" || candidate === "usgCd") {
return useMap.get(String(source.usg_cd ?? source.usgCd ?? "").trim()) ?? value;
}
if (candidate === "mqlt_cd" || candidate === "mqltCd") {
return materialMap.get(String(source.mqlt_cd ?? source.mqltCd ?? "").trim()) ?? value;
}
return value;
}
}
return "-";
};
@@ -0,0 +1,403 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Gis from "@common/business_components/ui/map/Gis";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import Stroke from "ol/style/Stroke.js";
import Style from "ol/style/Style.js";
import useStore from "@/store/useStore";
import { getData } from "@/components/lib/api";
import AgedPipeFilterPanel from "./AgedPipeFilterPanel";
import AgedPipeDetailPanel from "./AgedPipeDetailPanel";
import AgedPipeListModal from "./AgedPipeListModal";
import {
AGED_PIPE_DEFAULT_FILTERS,
AGED_PIPE_EXCEL_HEADER,
buildAgedPipeCqlFilters,
matchesAgedPipeFilters,
toDisplayPipeRow,
type AgedPipeFilters,
type AgedPipeRow,
} from "./agedPipeUtils";
const agedPipeHighlightStyle = new Style({
stroke: new Stroke({
color: "rgba(239, 68, 68, 0.95)",
width: 5,
}),
zIndex: 999,
});
const PIPE_LAYER_TYPE = "tb_ms_swage_ppl";
const EmptySearchView = () => null;
const DESIGN_PREVIEW_FEATURE = {
fid: "preview-aged-pipe",
lyrTypeNm: PIPE_LAYER_TYPE,
fcltMngNo: "SN-PPL-000123",
instMngNo: "INST-4458",
instlYmd: "19981215",
mapshtNo: "M-12-34",
pdmtVl: 450,
prlgVl: 132.5,
usgCd: "합류관",
mqltCd: "철근콘크리트관(RC)",
avgGrdn: 0.0021,
drtwMltvArea: 2140,
exclnMltvArea: 980,
tmptDpth: 2.1,
tmptMhlMngNo: "TMH-2201",
scendDpth: 2.4,
scendMhlMngNo: "SMH-2202",
trflnCnt: 2,
vrtcLen: 1.2,
wdthLen: 1.2,
};
const Map = () => {
const gisMaps = useRef<any | null>(null);
const [mapReady, setMapReady] = useState(false);
const [treeData, setTreeData] = useState<any>([]);
const [isDetailOpen, setIsDetailOpen] = useState(true);
const [featureData, setFeatureData] = useState<any>(DESIGN_PREVIEW_FEATURE);
const [draftFilters, setDraftFilters] = useState<AgedPipeFilters>(AGED_PIPE_DEFAULT_FILTERS);
const [appliedFilters, setAppliedFilters] = useState<AgedPipeFilters>(AGED_PIPE_DEFAULT_FILTERS);
const [isListOpen, setIsListOpen] = useState(true);
const [listRows, setListRows] = useState<AgedPipeRow[]>([
{
fid: "preview-aged-pipe",
lyrTypeNm: PIPE_LAYER_TYPE,
rowNumber: 1,
fcltMngNo: "SN-PPL-000123",
instlYmd: "1998-12-15",
pdmtVl: 450,
prlgVl: 132.5,
usgCd: "합류관",
mqltCd: "철근콘크리트관(RC)",
elapsedYears: 27,
ctpvNm: "서울특별시",
sggNm: "강남구",
},
{
fid: "preview-aged-pipe-2",
lyrTypeNm: PIPE_LAYER_TYPE,
rowNumber: 2,
fcltMngNo: "SN-PPL-000124",
instlYmd: "2001-03-10",
pdmtVl: 300,
prlgVl: 88.4,
usgCd: "오수관",
mqltCd: "염화비닐관(PVC)",
elapsedYears: 25,
ctpvNm: "서울특별시",
sggNm: "강남구",
},
]);
const [totalCount, setTotalCount] = useState(2);
const [currentPage, setCurrentPage] = useState(1);
const [isListLoading, setIsListLoading] = useState(false);
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
const { ctpv, sgg } = useStore();
useEffect(() => {
const fetchData = async () => {
try {
let data;
const response = await fetch("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK", {
method: "GET",
});
if (response.ok) {
const contentLength = response.headers.get("content-length");
if (contentLength === "0") data = null;
const text = await response.text();
if (!text || text.trim() === "") data = null;
else data = JSON.parse(text);
}
setTreeData(data);
} catch (error) {
console.error("노후관로 레이어 설정 조회 중 오류:", error);
throw new Error("Invalid JSON response from server");
}
};
fetchData();
}, []);
useEffect(() => {
const timer = window.setInterval(() => {
if (gisMaps.current) {
setMapReady(true);
window.clearInterval(timer);
}
}, 300);
return () => window.clearInterval(timer);
}, []);
const fetchAgedPipeRows = useCallback(
async (page: number, filters: AgedPipeFilters, size: number) => {
const queryParams = new URLSearchParams({
lyrTypeNm: PIPE_LAYER_TYPE,
page: String(page - 1),
size: String(size),
sort: "string",
});
const cqlFilters = buildAgedPipeCqlFilters(filters);
if (cqlFilters.length > 0) {
queryParams.append("cqlFiter", cqlFilters.join(" and "));
}
const response = await getData(`v1/fclts?${queryParams.toString()}`);
const rows = Array.isArray(response?.data) ? response.data : [];
return {
rows: rows.map((item: Record<string, unknown>, index: number) =>
toDisplayPipeRow(item, index, page, ctpv, sgg),
),
total: Number(response?.count ?? 0),
};
},
[ctpv, sgg],
);
const loadAgedPipeList = useCallback(
async (page = 1, filters = appliedFilters) => {
setIsListLoading(true);
try {
const response = await fetchAgedPipeRows(page, filters, 10);
setListRows(response.rows);
setTotalCount(response.total);
setCurrentPage(page);
} catch (error) {
console.error("노후관로 목록 조회 중 오류:", error);
setListRows([]);
setTotalCount(0);
} finally {
setIsListLoading(false);
}
},
[appliedFilters, fetchAgedPipeRows],
);
useEffect(() => {
loadAgedPipeList(1, appliedFilters);
}, [appliedFilters, loadAgedPipeList]);
const handleApplyFilters = useCallback(() => {
const normalized = {
...draftFilters,
elapsedYears: draftFilters.elapsedYears > 0 ? draftFilters.elapsedYears : 20,
};
setDraftFilters(normalized);
setAppliedFilters(normalized);
if (isListOpen) {
loadAgedPipeList(1, normalized);
}
}, [draftFilters, isListOpen, loadAgedPipeList]);
const handleFeatureSelect = useCallback((data: any) => {
const map = gisMaps.current;
if (!map) return;
const select = GisApp.Module.select as any;
if (!select) return;
select.resetSelectedFeature();
let foundFeature = null;
if (data && typeof data.getGeometry === "function" && typeof data.setStyle === "function") {
foundFeature = data;
} else {
const featureId = data?.fid ?? data?.ftrIdn ?? data?.id;
map
.getLayers()
.getArray()
.forEach((layer: any) => {
const source = layer.getSource?.();
if (!source?.getFeatureById) return;
const feature = source.getFeatureById(String(featureId).trim());
if (feature) foundFeature = feature;
});
}
if (foundFeature?.getGeometry) {
map.getView().fit(foundFeature.getGeometry().getExtent(), {
duration: 300,
padding: [50, 50, 50, 50],
maxZoom: 16,
});
select.selectFeature(foundFeature, true);
}
setFeatureData(data);
setIsDetailOpen(true);
}, []);
const handleOpenPipeList = useCallback(() => {
setIsListOpen(true);
loadAgedPipeList(1, appliedFilters);
}, [appliedFilters, loadAgedPipeList]);
const handleTreePopup = useCallback(
(layerNm: string) => {
if (String(layerNm).includes("하수관로")) {
handleOpenPipeList();
}
},
[handleOpenPipeList],
);
const handleListRowDoubleClick = useCallback(
async (row: AgedPipeRow) => {
try {
const core = GisApp.Module.layer as any;
await core.showSearchResultsFromGeoJSON(PIPE_LAYER_TYPE, row.fid);
} catch (error) {
console.error("노후관로 위치 이동 중 오류:", error);
}
setFeatureData(row);
setIsDetailOpen(true);
setIsListOpen(false);
},
[],
);
const handleDownload = useCallback(async () => {
try {
setIsListLoading(true);
const response = await fetchAgedPipeRows(1, appliedFilters, 10000);
await downloadExcelWithKoreanHeaders({
data: response.rows as JSON[],
fileName: `노후관로_목록_${new Date().toISOString().slice(0, 10)}`,
sheetNameArray: ["노후관로 목록"],
headerRange: "A1:H1",
headerMapping: AGED_PIPE_EXCEL_HEADER,
});
} catch (error) {
console.error("노후관로 목록 다운로드 중 오류:", error);
alert("노후관로 목록 다운로드 중 오류가 발생했습니다.");
} finally {
setIsListLoading(false);
}
}, [appliedFilters, fetchAgedPipeRows]);
useEffect(() => {
if (!mapReady || !gisMaps.current) return;
const map = gisMaps.current;
const cleanups: Array<() => void> = [];
const filterFn = (feature: any) =>
matchesAgedPipeFilters(feature?.getProperties?.() ?? feature?.values_ ?? {}, appliedFilters);
const decorateLayer = (layer: any) => {
if (layer?.get?.("lyrTyNm") !== PIPE_LAYER_TYPE) return;
const originalStyle =
layer.get("_agedPipeOriginalStyle") ?? layer.getStyleFunction?.() ?? layer.getStyle?.();
if (!layer.get("_agedPipeOriginalStyle")) {
layer.set("_agedPipeOriginalStyle", originalStyle);
}
layer.setStyle((feature: any, resolution: number) => {
const baseStyle =
typeof originalStyle === "function" ? originalStyle(feature, resolution) : originalStyle;
const styles = Array.isArray(baseStyle) ? [...baseStyle] : baseStyle ? [baseStyle] : [];
if (filterFn(feature)) {
styles.push(agedPipeHighlightStyle);
}
return styles;
});
const source = layer.getSource?.();
if (source?.on) {
const onLoadEnd = () => layer.changed?.();
source.on("featuresloadend", onLoadEnd);
cleanups.push(() => source.un?.("featuresloadend", onLoadEnd));
}
layer.changed?.();
};
map.getLayers().getArray().forEach(decorateLayer);
const layers = map.getLayers();
const onAdd = (event: any) => decorateLayer(event.element);
layers.on?.("add", onAdd);
cleanups.push(() => layers.un?.("add", onAdd));
return () => {
cleanups.forEach((cleanup) => cleanup());
};
}, [appliedFilters, mapReady]);
const FilterOverlay = useMemo(
() =>
function FilterOverlayComponent(props: any) {
return (
<AgedPipeFilterPanel
{...props}
filters={draftFilters}
onFiltersChange={setDraftFilters}
onApply={handleApplyFilters}
/>
);
},
[draftFilters, handleApplyFilters],
);
return (
<>
<div className="bg-container">
<Gis
treeData={treeData}
isSearch={false}
gisMaps={gisMaps}
isMiniMap={false}
searchView={EmptySearchView}
detailView={AgedPipeDetailPanel}
onFeatureSelect={handleFeatureSelect}
onOpenPopup={handleTreePopup}
featureData={featureData}
setFeatureData={setFeatureData}
isOpen={isDetailOpen}
setIsOpen={setIsDetailOpen}
isSearchModalOpen={isSearchModalOpen}
setIsSearchModalOpen={setIsSearchModalOpen}
onCheckedLayersChange={() => undefined}
searchBar={<></>}
waterDepth={FilterOverlay}
showZoomLevel={true}
defaultZoom={7.5}
defaultLonLat={[127.75, 36]}
/>
</div>
<AgedPipeListModal
open={isListOpen}
onOpenChange={setIsListOpen}
filters={draftFilters}
onFiltersChange={setDraftFilters}
rows={listRows}
total={totalCount}
currentPage={currentPage}
isLoading={isListLoading}
onSearch={(page = 1) => {
const normalized = {
...draftFilters,
elapsedYears: draftFilters.elapsedYears > 0 ? draftFilters.elapsedYears : 20,
};
setDraftFilters(normalized);
setAppliedFilters(normalized);
loadAgedPipeList(page, normalized);
}}
onDownload={handleDownload}
onRowDoubleClick={handleListRowDoubleClick}
/>
</>
);
};
export default Map;
@@ -4,6 +4,7 @@
## 화면구성
### GIS 기본화면
-노후관로 조건에 맞지 않는 관로를 숨기지 않고, 기존 시설물 레이어는 원래대로 모두 보이게 두고 조건에 맞는 관로만 빨간 강조선을 추가하도록 한다.
- 좌측 상단 (시설물 레이어 트리 및 목록 아이콘): 시설, 침수위험지도, 지반침하정보(우측에 목록 아이콘), 노후관로(우측에 목록 아이콘)
- 좌측 하단 (레이어별 설정 항목 및 적용 버튼): 경과년도(년) [ 디폴트 30년 ]
- 우측 영역(배경 지도 및 시설레이어 표출): 하수관로 중 경과년도가 30년 이상된 관로들을 시각적으로 강조 표시
@@ -12,7 +13,8 @@
- GIS 화면에서 관로 위치 클릭시 우측 정렬로 세로형 영역으로 관로정보 상세화면 팝업
- 1차년도에서 개발한 관로정보 표출 : 시설관리번호, 기관관리번호, 설치일자, 도엽번호 등 전체 항목 그대로 표출
### 노후관로정보 목록화면
### 노후관로정보 목록화면
- 윈도우형 팝업
- GIS 기본화면 좌측 상단의 레이어 트리에서 노후관로 목록 아이콘 클릭시 윈도우 팝업으로 가운데 영역으로 노후관로정보 목록화면 팝업
- 상단 조회조건( 조회조건 영역 ) : 경과년도, 시도, 시군구, 버튼 2개 (조회, 다운로드)
- 하단 가로형 그리드 (노후관로 현황) : NO, 시설관리번호, 설치일자, 관경(mm), 연장(m), 용도, 재질, 경과년도
+1 -1
View File
@@ -3,7 +3,7 @@
- 작업시 html 디렉토리 이외의 수정이 되지 않도록 해야 한다.
- 추가 *.md 파일의 요청대로 만들되 상위의 <Gis>를 건드리지 않고 apps\sn\app\html 에 추가파일을 만들어서 처리한다.
- 기존의 package/common/component 와 package/common/business_component를 잘 활용한다.
- <Gis> 의 시설물 layer는 api 호출 결과 값 설정된 대로 zoom level에 따라 모두 보여야 한다.
## Encoding
- UTF-8
- 한글이 깨지지 않도록 주의할 것