sn: 노후관로관리 1차 디자인
This commit is contained in:
@@ -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), 용도, 재질, 경과년도
|
||||
|
||||
@@ -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
|
||||
- 한글이 깨지지 않도록 주의할 것
|
||||
|
||||
Reference in New Issue
Block a user