Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev
This commit is contained in:
+2
-2
@@ -12,8 +12,8 @@ NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
|
||||
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
|
||||
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
|
||||
SERVER_API_URL = http://dev.onasys.co.kr:8000
|
||||
SEWERAGE_API_URL = http://localhost:8883
|
||||
# SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
|
||||
# SEWERAGE_API_URL = http://localhost:8883
|
||||
SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
|
||||
DC_URL = https://apigateway.apps.rsonesoft.com
|
||||
# NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
|
||||
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { validateElapsedYears, type AgedPipeFilters } from "./agedPipeUtils";
|
||||
import LayerSettingCard from "../layerSetting/LayerSettingCard";
|
||||
|
||||
type MaybePromise<T = void> = T | Promise<T>;
|
||||
|
||||
@@ -11,6 +12,9 @@ type Props = {
|
||||
onFiltersChange: (filters: AgedPipeFilters) => void;
|
||||
onApply: (filters?: AgedPipeFilters) => MaybePromise;
|
||||
compact?: boolean;
|
||||
enabled?: boolean;
|
||||
onEnabledChange?: (enabled: boolean) => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export default function AgedPipeLayerSettingPanel({
|
||||
@@ -18,6 +22,9 @@ export default function AgedPipeLayerSettingPanel({
|
||||
onFiltersChange,
|
||||
onApply,
|
||||
compact = false,
|
||||
enabled = true,
|
||||
onEnabledChange = () => {},
|
||||
onClose = () => {},
|
||||
}: Props) {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
@@ -47,16 +54,16 @@ export default function AgedPipeLayerSettingPanel({
|
||||
};
|
||||
|
||||
const inputClass =
|
||||
"h-[22px] border border-input bg-background px-[2px] text-center text-[1rem] leading-none text-foreground";
|
||||
"h-[20px] !rounded border border-input bg-background px-[1px] text-center text-[1.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-primary text-[0.95rem] font-bold leading-none text-primary-foreground";
|
||||
"grid h-[16px] w-[16px] shrink-0 place-items-center rounded-full bg-primary text-[0.85rem] 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-label">
|
||||
<div className="flex min-h-[26px] items-center gap-1 whitespace-nowrap text-[1.15rem] font-medium text-label">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{indicator}
|
||||
<label htmlFor="aged-pipe-elapsed-years" className="shrink-0">
|
||||
@@ -88,7 +95,7 @@ export default function AgedPipeLayerSettingPanel({
|
||||
title="적용"
|
||||
aria-label="경과년도 적용"
|
||||
>
|
||||
✓
|
||||
<span className="relative left-[1px]">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,9 +103,14 @@ export default function AgedPipeLayerSettingPanel({
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="w-full rounded-[6px] border border-border bg-background px-[6px] py-2 shadow-blur">
|
||||
<LayerSettingCard
|
||||
title="노후관로"
|
||||
enabled={enabled}
|
||||
onEnabledChange={onEnabledChange}
|
||||
onClose={onClose}
|
||||
>
|
||||
{settingRow}
|
||||
</div>
|
||||
</LayerSettingCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function AgedPipePopupPage() {
|
||||
const sggOptions = useMemo(
|
||||
() =>
|
||||
filters.ctpvCd === "0"
|
||||
? sgg
|
||||
? []
|
||||
: sgg.filter((item) =>
|
||||
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
|
||||
),
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import type { ExmnMapFilters, ExmnMapJobType } from "./exmnMapTypes";
|
||||
import LayerSettingCard from "../layerSetting/LayerSettingCard";
|
||||
|
||||
type Props = {
|
||||
jobType: ExmnMapJobType;
|
||||
filters: ExmnMapFilters;
|
||||
onFiltersChange: (filters: ExmnMapFilters) => void;
|
||||
enabled?: boolean;
|
||||
onEnabledChange?: (enabled: boolean) => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
const GRADE_OPTIONS = ["", "1", "2", "3", "4", "5"];
|
||||
@@ -15,6 +19,9 @@ export default function ExmnLayerSettingPanel({
|
||||
jobType,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
enabled = true,
|
||||
onEnabledChange = () => {},
|
||||
onClose = () => {},
|
||||
}: Props) {
|
||||
const updateFilter = (key: keyof ExmnMapFilters, value: string) => {
|
||||
onFiltersChange({
|
||||
@@ -23,23 +30,25 @@ export default function ExmnLayerSettingPanel({
|
||||
});
|
||||
};
|
||||
const selectClass =
|
||||
"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";
|
||||
"h-[20px] w-[62px] rounded border border-input bg-background px-[1px] text-center text-[1.1rem] leading-none text-foreground outline-none focus:border-primary";
|
||||
const labelClass = "shrink-0 text-[1.15rem] font-medium text-label";
|
||||
const title = jobType === "PPL" ? "관로조사" : "맨홀조사";
|
||||
|
||||
return (
|
||||
<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-label">
|
||||
{jobType === "PPL" ? "관로조사" : "맨홀조사"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex min-h-[25px] items-center justify-between gap-2">
|
||||
<span className={labelClass}>사업차수</span>
|
||||
<LayerSettingCard
|
||||
title={title}
|
||||
enabled={enabled}
|
||||
onEnabledChange={onEnabledChange}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="space-y-[1px]">
|
||||
<label className="flex min-h-[24px] items-center justify-between ">
|
||||
<span className={`flex items-center gap-1 ${labelClass}`}>
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${jobType === "PPL" ? "bg-[#facc15]" : "bg-[#8b5cf6]"}`}
|
||||
/>
|
||||
사업차수
|
||||
</span>
|
||||
<input
|
||||
className={selectClass}
|
||||
type="text"
|
||||
@@ -50,7 +59,7 @@ export default function ExmnLayerSettingPanel({
|
||||
onChange={(event) => updateFilter("bizCycl", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex min-h-[25px] items-center justify-between gap-2">
|
||||
<label className="flex min-h-[24px] items-center justify-between ">
|
||||
<span className={labelClass}>구조적 상태등급</span>
|
||||
<select
|
||||
className={selectClass}
|
||||
@@ -66,7 +75,7 @@ export default function ExmnLayerSettingPanel({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex min-h-[25px] items-center justify-between gap-2">
|
||||
<label className="flex min-h-[24px] items-center justify-between ">
|
||||
<span className={labelClass}>운영적 상태등급</span>
|
||||
<select
|
||||
className={selectClass}
|
||||
@@ -83,6 +92,6 @@ export default function ExmnLayerSettingPanel({
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</LayerSettingCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { SelectBox } from "@common/business_components/ui";
|
||||
import {
|
||||
FLOOD_HISTORY_MIN_YEAR,
|
||||
normalizeFloodHistoryYearRange,
|
||||
type FloodHistoryYearRange,
|
||||
} from "./fldngHstryTypes";
|
||||
|
||||
type Props = {
|
||||
yearRange: FloodHistoryYearRange;
|
||||
enabled: boolean;
|
||||
onYearRangeChange: (yearRange: FloodHistoryYearRange) => void;
|
||||
onEnabledChange: (enabled: boolean) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/** 침수흔적위선·심선에 공통 적용되는 즉시 반영 침수년도 설정 카드다. */
|
||||
export default function FldngHstryLayerSettingPanel({
|
||||
yearRange,
|
||||
enabled,
|
||||
onYearRangeChange,
|
||||
onEnabledChange,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const normalizedRange = normalizeFloodHistoryYearRange(yearRange);
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearOptions = Array.from(
|
||||
{ length: currentYear - FLOOD_HISTORY_MIN_YEAR + 1 },
|
||||
(_, index) => String(currentYear - index),
|
||||
);
|
||||
const startYearOptions = yearOptions
|
||||
.filter((year) => Number(year) <= Number(normalizedRange.endYear))
|
||||
.map((year) => ({ val: year, text: year }));
|
||||
const endYearOptions = yearOptions
|
||||
.filter((year) => Number(year) >= Number(normalizedRange.startYear))
|
||||
.map((year) => ({ val: year, text: year }));
|
||||
const yearSelectClass =
|
||||
"h-[20px] w-[62px] !rounded border border-input bg-background px-[1px] text-center text-[1.1rem] leading-none text-foreground";
|
||||
|
||||
const changeYear = (key: keyof FloodHistoryYearRange, value: string) => {
|
||||
onYearRangeChange(
|
||||
normalizeFloodHistoryYearRange({
|
||||
...normalizedRange,
|
||||
[key]: value,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-visible rounded-[6px] border border-border bg-background shadow-blur">
|
||||
<div className="flex min-h-[28px] items-center justify-between border-b border-border px-2">
|
||||
<span className="text-[1.15rem] font-semibold text-label">
|
||||
침수흔적도
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
aria-label="침수년도 필터 적용 여부"
|
||||
title={enabled ? "침수년도 필터 적용 중" : "침수년도 필터 미적용"}
|
||||
className={`relative h-[16px] w-[28px] rounded-full border transition-colors ${enabled ? "border-primary bg-primary" : "border-[#94a3b8] bg-[#cbd5e1]"}`}
|
||||
onClick={() => onEnabledChange(!enabled)}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1/2 h-[12px] w-[12px] -translate-y-1/2 rounded-full bg-white shadow-sm transition-[left] ${enabled ? "left-[14px]" : "left-[2px]"}`}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="침수년도 설정 닫기"
|
||||
title="닫기"
|
||||
className="grid h-[20px] w-[20px] place-items-center bg-transparent text-[1.3rem] leading-none text-muted-foreground hover:text-foreground"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-[30px] items-center gap-1.5 px-2 py-1 text-[1.15rem] font-medium text-label">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" />
|
||||
<span className="shrink-0">침수년도</span>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
<div className="w-[62px]">
|
||||
<SelectBox
|
||||
className="w-full"
|
||||
selectClass={yearSelectClass}
|
||||
label=""
|
||||
selectArray={startYearOptions}
|
||||
value={normalizedRange.startYear}
|
||||
onSelectValue={(value) => changeYear("startYear", String(value))}
|
||||
selectContentClass="z-[101]"
|
||||
labelClass="hidden"
|
||||
/>
|
||||
</div>
|
||||
<span className="px-[1px] text-[0.95rem] font-normal text-muted-foreground">
|
||||
~
|
||||
</span>
|
||||
<div className="w-[62px]">
|
||||
<SelectBox
|
||||
className="w-full"
|
||||
selectClass={yearSelectClass}
|
||||
label=""
|
||||
selectArray={endYearOptions}
|
||||
value={normalizedRange.endYear}
|
||||
onSelectValue={(value) => changeYear("endYear", String(value))}
|
||||
selectContentClass="z-[101]"
|
||||
labelClass="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -126,7 +126,7 @@ export default function FldngHstryPopupPage() {
|
||||
const sggOptions = useMemo(
|
||||
() =>
|
||||
filters.ctpvCd === "0"
|
||||
? sgg
|
||||
? []
|
||||
: sgg.filter((item) =>
|
||||
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
|
||||
),
|
||||
|
||||
@@ -1,17 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import type { UiHeadProps } from "@common/business_components/ui";
|
||||
import { appendApiSearchParams } from "@/shared/util/util";
|
||||
|
||||
export const FLOOD_HISTORY_POPUP_KEY = "fldngHstryPopup";
|
||||
export const FLDNG_HSTRY_ROW_SELECT_MESSAGE = "SN_FLOOD_HISTORY_ROW_SELECT";
|
||||
export const FLOOD_HISTORY_LAYER_TYPE = "tb_ms_fldng_wtlv_map";
|
||||
export const FLOOD_HISTORY_DEPTH_LAYER_TYPE = "tb_ms_fldng_dpth_map";
|
||||
export const FLOOD_HISTORY_PREVIEW_LAYER_TYPE = "tb_sn_flood_trace_mock";
|
||||
export const FLOOD_HISTORY_LAYER_NAME = "침수흔적위선";
|
||||
export const FLOOD_HISTORY_DEPTH_LAYER_NAME = "침수흔적심선";
|
||||
export const FLOOD_HISTORY_ROOT_NAME = "침수흔적도";
|
||||
export const FLOOD_HISTORY_AREA_LAYER_ID = "SN_FLOOD_HISTORY_AREA";
|
||||
export const FLOOD_HISTORY_DEPTH_LAYER_ID = "SN_FLOOD_HISTORY_DEPTH";
|
||||
export const FLDNG_HSTRY_PAGE_SIZE = 10;
|
||||
export const FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE = 2000;
|
||||
export const FLOOD_HISTORY_MIN_YEAR = 2002;
|
||||
|
||||
/** 지도 침수흔적위선·심선이 공통으로 사용하는 침수년도 범위다. */
|
||||
export type FloodHistoryYearRange = {
|
||||
startYear: string;
|
||||
endYear: string;
|
||||
};
|
||||
|
||||
/** 침수년도 설정의 초기 범위는 데이터 최소연도부터 현재년도까지다. */
|
||||
export const buildFloodHistoryDefaultYearRange = (
|
||||
currentYear = new Date().getFullYear(),
|
||||
): FloodHistoryYearRange => ({
|
||||
startYear: String(FLOOD_HISTORY_MIN_YEAR),
|
||||
endYear: String(currentYear),
|
||||
});
|
||||
|
||||
/** 연도 설정값을 4자리 숫자로 정규화하고 유효하지 않으면 기본 범위를 사용한다. */
|
||||
export const normalizeFloodHistoryYearRange = (
|
||||
range?: Partial<FloodHistoryYearRange> | null,
|
||||
fallback: FloodHistoryYearRange = buildFloodHistoryDefaultYearRange(),
|
||||
): FloodHistoryYearRange => {
|
||||
const normalizeYear = (value: unknown) =>
|
||||
String(value ?? "")
|
||||
.replace(/[^0-9]/g, "")
|
||||
.slice(0, 4);
|
||||
const startYear = normalizeYear(range?.startYear);
|
||||
const endYear = normalizeYear(range?.endYear);
|
||||
return {
|
||||
startYear: /^\d{4}$/.test(startYear) ? startYear : fallback.startYear,
|
||||
endYear: /^\d{4}$/.test(endYear) ? endYear : fallback.endYear,
|
||||
};
|
||||
};
|
||||
|
||||
/** 위선·심선 WFS에 동일하게 결합할 침수년도 CQL 조건을 생성한다. */
|
||||
export const buildFloodHistoryYearCql = (
|
||||
range: FloodHistoryYearRange,
|
||||
enabled: boolean,
|
||||
) => {
|
||||
if (!enabled) return "";
|
||||
const normalized = normalizeFloodHistoryYearRange(range);
|
||||
return `(fldng_yr >= '${normalized.startYear}' AND fldng_yr <= '${normalized.endYear}')`;
|
||||
};
|
||||
|
||||
export type FldngHstryFilters = {
|
||||
ctpvCd: string;
|
||||
@@ -375,15 +420,14 @@ 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.floodYear) params.set("fldngYr", normalized.floodYear);
|
||||
if (normalized.disasterName)
|
||||
params.set("fldngDstrNm", normalized.disasterName);
|
||||
if (normalized.rainStartDate) params.set("rainBgngTm", normalized.rainStartDate);
|
||||
if (normalized.districtName) params.set("fldngDstrctNm", normalized.districtName);
|
||||
appendApiSearchParams(params, {
|
||||
ctpvCd: normalized.ctpvCd === "0" ? "0" : normalized.ctpvCd.slice(0, 2),
|
||||
sggCd: normalized.sggCd === "0" ? "0" : normalized.sggCd.slice(0, 5),
|
||||
fldngYr: normalized.floodYear,
|
||||
fldngDstrNm: normalized.disasterName,
|
||||
rainBgngTm: normalized.rainStartDate,
|
||||
fldngDstrctNm: normalized.districtName,
|
||||
});
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
@@ -494,11 +538,32 @@ export const isFldngHstryNode = (node: any) => {
|
||||
const layerType = resolveFldngHstryLayerType(node);
|
||||
return (
|
||||
name === FLOOD_HISTORY_LAYER_NAME ||
|
||||
name === FLOOD_HISTORY_DEPTH_LAYER_NAME ||
|
||||
layerType === FLOOD_HISTORY_LAYER_TYPE ||
|
||||
layerType === FLOOD_HISTORY_DEPTH_LAYER_TYPE ||
|
||||
layerType === FLOOD_HISTORY_PREVIEW_LAYER_TYPE
|
||||
);
|
||||
};
|
||||
|
||||
/** 침수흔적위선 체크 상태를 심선과 분리해 판별한다. */
|
||||
export const isFloodHistoryWaterLevelNode = (node: any) => {
|
||||
const name = resolveFldngHstryNodeName(node);
|
||||
const layerType = resolveFldngHstryLayerType(node);
|
||||
return (
|
||||
name === FLOOD_HISTORY_LAYER_NAME || layerType === FLOOD_HISTORY_LAYER_TYPE
|
||||
);
|
||||
};
|
||||
|
||||
/** 침수흔적심선 체크 상태를 위선과 분리해 판별한다. */
|
||||
export const isFloodHistoryDepthNode = (node: any) => {
|
||||
const name = resolveFldngHstryNodeName(node);
|
||||
const layerType = resolveFldngHstryLayerType(node);
|
||||
return (
|
||||
name === FLOOD_HISTORY_DEPTH_LAYER_NAME ||
|
||||
layerType === FLOOD_HISTORY_DEPTH_LAYER_TYPE
|
||||
);
|
||||
};
|
||||
|
||||
export const isFldngHstryRootNode = (node: any) =>
|
||||
resolveFldngHstryNodeName(node) === FLOOD_HISTORY_ROOT_NAME;
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ export default function FloodDngrMapPopupPage() {
|
||||
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)),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { UiHeadProps } from "@common/business_components/ui";
|
||||
import { appendApiSearchParams } from "@/shared/util/util";
|
||||
|
||||
export const FLOOD_DNGR_MAP_POPUP_KEY = "floodDngrMapPopup";
|
||||
export const FLOOD_DNGR_MAP_LAYER_TYPE = "tb_ms_flood_dngr_map";
|
||||
@@ -89,16 +90,12 @@ export const buildFloodDngrMapListQueryString = (
|
||||
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);
|
||||
}
|
||||
appendApiSearchParams(params, {
|
||||
ctpvCd: filters.ctpvCd === "0" ? "0" : filters.ctpvCd.slice(0, 2),
|
||||
sggCd: filters.sggCd === "0" ? "0" : filters.sggCd.slice(0, 5),
|
||||
sbmrgDpthlCd: filters.sbmrgDpthlCd,
|
||||
sbmrgFrqExpln: filters.sbmrgFrqExpln,
|
||||
});
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import type { GisEditSessionSnapshot, GisEditTool } from "./gisEditTypes";
|
||||
import { useGisEditOperationLabels } from "./useGisEditCommonCodes";
|
||||
|
||||
type Props = GisEditSessionSnapshot & {
|
||||
onRemoveFocusedChange: () => void;
|
||||
@@ -14,13 +15,6 @@ type Props = GisEditSessionSnapshot & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const TOOL_LABELS: Record<GisEditTool, string> = {
|
||||
ADD: "추가",
|
||||
MOVE: "이동",
|
||||
VERTEX_EDIT: "꼭지점편집",
|
||||
DELETE: "삭제",
|
||||
};
|
||||
|
||||
const buildChangeItemClassName = (isFocused: boolean) =>
|
||||
`block w-full rounded-md border px-3 py-2.5 text-left text-[1.1rem] transition ${
|
||||
isFocused
|
||||
@@ -28,8 +22,13 @@ const buildChangeItemClassName = (isFocused: boolean) =>
|
||||
: "border-border bg-background hover:border-primary hover:bg-background"
|
||||
}`;
|
||||
|
||||
const getChangeLabel = (change: GisEditSessionSnapshot["changes"][number]) =>
|
||||
change.attributeOnly ? "상세정보수정" : TOOL_LABELS[change.editType];
|
||||
const getChangeLabel = (
|
||||
change: GisEditSessionSnapshot["changes"][number],
|
||||
operationLabels: Record<GisEditTool, string>,
|
||||
) =>
|
||||
change.attributeOnly
|
||||
? "상세정보수정"
|
||||
: operationLabels[change.editType];
|
||||
|
||||
const getChangeSummary = (
|
||||
change: GisEditSessionSnapshot["changes"][number],
|
||||
@@ -45,12 +44,14 @@ const getChangeSummary = (
|
||||
type ChangeListProps = {
|
||||
changes: GisEditSessionSnapshot["changes"];
|
||||
focusedChangeId: string | null;
|
||||
operationLabels: Record<GisEditTool, string>;
|
||||
onFocusChange: (changeId: string) => void;
|
||||
};
|
||||
|
||||
const ChangeList = ({
|
||||
changes,
|
||||
focusedChangeId,
|
||||
operationLabels,
|
||||
onFocusChange,
|
||||
}: ChangeListProps) => (
|
||||
<>
|
||||
@@ -65,7 +66,8 @@ const ChangeList = ({
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="shrink-0 whitespace-nowrap text-[1.1rem] font-semibold leading-4 text-label">
|
||||
{index + 1}. {getChangeLabel(change)} / {change.objectLabel}
|
||||
{index + 1}. {getChangeLabel(change, operationLabels)} /{" "}
|
||||
{change.objectLabel}
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{change.basicInfoSavedAt && focusedChangeId !== change.changeId ? (
|
||||
@@ -103,6 +105,8 @@ export default function GisEditSessionPanel({
|
||||
variant = "inline",
|
||||
className = "",
|
||||
}: Props) {
|
||||
// 인라인·윈도우 팝업 세션 모두 동일한 CMS 공통코드 라벨을 공유한다.
|
||||
const operationLabels = useGisEditOperationLabels();
|
||||
const isCompactInline = variant === "inline" && changes.length === 0;
|
||||
|
||||
return (
|
||||
@@ -125,7 +129,7 @@ export default function GisEditSessionPanel({
|
||||
{selectedLayerName || "레이어 미선택"} ·{" "}
|
||||
{isEditMode
|
||||
? activeTool
|
||||
? TOOL_LABELS[activeTool]
|
||||
? operationLabels[activeTool]
|
||||
: "대기"
|
||||
: "편집 모드 종료"}
|
||||
</div>
|
||||
@@ -190,6 +194,7 @@ export default function GisEditSessionPanel({
|
||||
<ChangeList
|
||||
changes={changes}
|
||||
focusedChangeId={focusedChangeId}
|
||||
operationLabels={operationLabels}
|
||||
onFocusChange={onFocusChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Check, Move, PenTool, Plus, Trash2, X } from "lucide-react";
|
||||
import { Button } from "@common/components/ui";
|
||||
|
||||
import type { GisEditTool } from "./gisEditTypes";
|
||||
import { useGisEditOperationLabels } from "./useGisEditCommonCodes";
|
||||
|
||||
type Props = {
|
||||
isEditMode: boolean;
|
||||
@@ -22,13 +23,12 @@ type Props = {
|
||||
|
||||
const TOOL_BUTTONS: Array<{
|
||||
tool: GisEditTool;
|
||||
label: string;
|
||||
icon: typeof Plus;
|
||||
}> = [
|
||||
{ tool: "ADD", label: "추가", icon: Plus },
|
||||
{ tool: "MOVE", label: "이동", icon: Move },
|
||||
{ tool: "VERTEX_EDIT", label: "꼭지점편집", icon: PenTool },
|
||||
{ tool: "DELETE", label: "삭제", icon: Trash2 },
|
||||
{ tool: "ADD", icon: Plus },
|
||||
{ tool: "MOVE", icon: Move },
|
||||
{ tool: "VERTEX_EDIT", icon: PenTool },
|
||||
{ tool: "DELETE", icon: Trash2 },
|
||||
];
|
||||
|
||||
export default function GisEditToolbar({
|
||||
@@ -45,6 +45,9 @@ export default function GisEditToolbar({
|
||||
onSelectTool,
|
||||
onSave,
|
||||
}: Props) {
|
||||
// 버튼의 업무 코드값은 그대로 유지하고 CMS 공통코드명만 화면 라벨로 사용한다.
|
||||
const operationLabels = useGisEditOperationLabels();
|
||||
|
||||
if (!isEditMode) return null;
|
||||
|
||||
return (
|
||||
@@ -67,10 +70,11 @@ export default function GisEditToolbar({
|
||||
</div>
|
||||
<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 }) => {
|
||||
{TOOL_BUTTONS.map(({ tool, icon: Icon }) => {
|
||||
const isActive = activeTool === tool;
|
||||
const isToolEnabled =
|
||||
canUseTools && allowedTools.includes(tool);
|
||||
const label = operationLabels[tool];
|
||||
if (tool === "VERTEX_EDIT") {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getCommonCodes } from "@/components/lib/common/apiHelpers";
|
||||
|
||||
import type { GisEditTool } from "./gisEditTypes";
|
||||
|
||||
const GIS_EDIT_OPERATION_UP_CD = "GISEDITOPRT";
|
||||
|
||||
/** CMS 조회 실패 시 기존 GIS 편집 화면 동작과 문구를 유지하기 위한 최소 fallback이다. */
|
||||
export const DEFAULT_GIS_EDIT_OPERATION_LABELS: Record<GisEditTool, string> = {
|
||||
ADD: "추가",
|
||||
MOVE: "이동",
|
||||
VERTEX_EDIT: "꼭지점편집",
|
||||
DELETE: "삭제",
|
||||
};
|
||||
|
||||
let cachedOperationLabels: Record<GisEditTool, string> | null = null;
|
||||
let operationLabelsPromise: Promise<Record<GisEditTool, string>> | null = null;
|
||||
|
||||
const isGisEditTool = (value: string): value is GisEditTool =>
|
||||
value === "ADD" ||
|
||||
value === "MOVE" ||
|
||||
value === "VERTEX_EDIT" ||
|
||||
value === "DELETE";
|
||||
|
||||
/** 같은 브라우저 세션에서는 GIS 편집작업코드를 한 번만 조회해 툴바와 세션이 공유한다. */
|
||||
const loadGisEditOperationLabels = async () => {
|
||||
if (cachedOperationLabels) return cachedOperationLabels;
|
||||
|
||||
if (!operationLabelsPromise) {
|
||||
operationLabelsPromise = getCommonCodes(GIS_EDIT_OPERATION_UP_CD)
|
||||
.then((codes) => {
|
||||
const labels = { ...DEFAULT_GIS_EDIT_OPERATION_LABELS };
|
||||
codes.forEach((code) => {
|
||||
if (isGisEditTool(code.lwpoCd) && code.lwpoCdNm) {
|
||||
labels[code.lwpoCd] = code.lwpoCdNm;
|
||||
}
|
||||
});
|
||||
cachedOperationLabels = labels;
|
||||
return labels;
|
||||
})
|
||||
.finally(() => {
|
||||
operationLabelsPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
return operationLabelsPromise;
|
||||
};
|
||||
|
||||
/** GIS 편집 도구 코드값은 유지하고 사용자 표시명만 CMS 공통코드로 교체한다. */
|
||||
export const useGisEditOperationLabels = () => {
|
||||
const [operationLabels, setOperationLabels] = useState<
|
||||
Record<GisEditTool, string>
|
||||
>(cachedOperationLabels ?? DEFAULT_GIS_EDIT_OPERATION_LABELS);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
loadGisEditOperationLabels()
|
||||
.then((labels) => {
|
||||
if (!cancelled) setOperationLabels(labels);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("GIS 편집작업 공통코드 조회에 실패했습니다.", error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return operationLabels;
|
||||
};
|
||||
@@ -31,6 +31,7 @@ import { useAlertStore } from "@/shared/lib";
|
||||
import { useUserStore } from "@/shared/lib/use-user-store";
|
||||
import GisUploadPreviewMap from "./GisUploadPreviewMap";
|
||||
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
import { useGisManagementCommonCodes } from "./useGisManagementCommonCodes";
|
||||
|
||||
type UploadFacilityType = {
|
||||
facilityTypeCode: string;
|
||||
@@ -108,6 +109,8 @@ type DownloadJob = {
|
||||
retryAfterMillis?: number | null;
|
||||
};
|
||||
|
||||
type DownloadJobStatusListener = (job: DownloadJob) => void;
|
||||
|
||||
const SCREEN_ID = "SN_MN010302";
|
||||
const FACILITY_TYPE_CODE = "PPL";
|
||||
const LAYER_ID = "tb_ms_swage_ppl";
|
||||
@@ -212,12 +215,16 @@ const startDownloadJob = async (url: string): Promise<DownloadJob> => {
|
||||
return job;
|
||||
};
|
||||
|
||||
const waitForDownloadJob = async (jobId: string): Promise<DownloadJob> => {
|
||||
const waitForDownloadJob = async (
|
||||
jobId: string,
|
||||
onStatus?: DownloadJobStatusListener,
|
||||
): Promise<DownloadJob> => {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < DOWNLOAD_JOB_MAX_WAIT_MS) {
|
||||
const job = (await getData(
|
||||
`v1/map/gisDownloadJobs/${encodeURIComponent(jobId)}`,
|
||||
)) as DownloadJob;
|
||||
onStatus?.(job);
|
||||
if (job?.status === "READY") return job;
|
||||
if (job?.status === "FAILED") {
|
||||
throw new Error(
|
||||
@@ -283,6 +290,12 @@ const postRefreshMessageToOpener = (response: GisUploadResponse) => {
|
||||
export default function GisManagementPopupPage() {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
// GIS 관리 코드값은 기존 분기에 사용하고 CMS 공통코드명은 화면 안내에만 사용한다.
|
||||
const {
|
||||
uploadStatusLabels,
|
||||
downloadStatusLabels,
|
||||
facilityTypeLabels,
|
||||
} = useGisManagementCommonCodes();
|
||||
// 파일 input은 브라우저 보안 정책상 value 직접 제어가 제한돼 ref로 선택/초기화한다.
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -344,6 +357,8 @@ export default function GisManagementPopupPage() {
|
||||
}, [mngInstOptions, selectedMngInstCd]);
|
||||
|
||||
const allowedExtensionText = facility.allowedExtensions.join(", ");
|
||||
const facilityTypeDisplayName =
|
||||
facilityTypeLabels[FACILITY_TYPE_CODE] || facility.facilityTypeName;
|
||||
const latestDownloadId = downloadIdsByScope[selectedScopeKey] ?? "";
|
||||
const isManagementSelected = Boolean(selectedMngInstCd);
|
||||
|
||||
@@ -543,8 +558,13 @@ export default function GisManagementPopupPage() {
|
||||
const job = await startDownloadJob(
|
||||
`v1/map/gisUploads/swagePpl/downloadJobs?${params.toString()}`,
|
||||
);
|
||||
setNotice(`${selectedScopeLabel} GeoPackage 파일을 생성하고 있습니다.`);
|
||||
const readyJob = await waitForDownloadJob(job.jobId);
|
||||
setNotice(
|
||||
`${selectedScopeLabel} ${downloadStatusLabels[job.status] || "GeoPackage 파일을 생성하고 있습니다."}`,
|
||||
);
|
||||
const readyJob = await waitForDownloadJob(job.jobId, (currentJob) => {
|
||||
const statusLabel = downloadStatusLabels[currentJob.status];
|
||||
if (statusLabel) setNotice(`${selectedScopeLabel} ${statusLabel}`);
|
||||
});
|
||||
const response = await downloadGeoPackage(
|
||||
`v1/map/gisDownloadJobs/${encodeURIComponent(job.jobId)}/file`,
|
||||
);
|
||||
@@ -614,7 +634,11 @@ export default function GisManagementPopupPage() {
|
||||
: [],
|
||||
);
|
||||
setViewMode("validationErrors");
|
||||
setNotice(response.message ?? "GeoPackage 검증 오류가 있습니다.");
|
||||
setNotice(
|
||||
response.message ??
|
||||
uploadStatusLabels[response.status] ??
|
||||
"GeoPackage 검증 오류가 있습니다.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -623,7 +647,11 @@ export default function GisManagementPopupPage() {
|
||||
resetSelectedFile();
|
||||
setValidationErrors([]);
|
||||
setViewMode("history");
|
||||
setNotice(response.message ?? "GIS 업로드가 완료되었습니다.");
|
||||
setNotice(
|
||||
response.message ??
|
||||
uploadStatusLabels[response.status] ??
|
||||
"GIS 업로드가 완료되었습니다.",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("GIS 업로드 실패:", error);
|
||||
alert(
|
||||
@@ -647,8 +675,14 @@ export default function GisManagementPopupPage() {
|
||||
const job = await startDownloadJob(
|
||||
`v1/map/gisUploads/${encodeURIComponent(selectedHistory.uploadId)}/downloadJobs`,
|
||||
);
|
||||
setNotice("선택한 현황의 GeoPackage 파일을 생성하고 있습니다.");
|
||||
const readyJob = await waitForDownloadJob(job.jobId);
|
||||
setNotice(
|
||||
downloadStatusLabels[job.status] ||
|
||||
"선택한 현황의 GeoPackage 파일을 생성하고 있습니다.",
|
||||
);
|
||||
const readyJob = await waitForDownloadJob(job.jobId, (currentJob) => {
|
||||
const statusLabel = downloadStatusLabels[currentJob.status];
|
||||
if (statusLabel) setNotice(statusLabel);
|
||||
});
|
||||
const response = await downloadGeoPackage(
|
||||
`v1/map/gisDownloadJobs/${encodeURIComponent(job.jobId)}/file`,
|
||||
);
|
||||
@@ -760,7 +794,7 @@ export default function GisManagementPopupPage() {
|
||||
|
||||
<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})
|
||||
업로드 파일 ({facilityTypeDisplayName}, {allowedExtensionText})
|
||||
</div>
|
||||
<Input
|
||||
value={selectedFile?.name ?? ""}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getCommonCodes } from "@/components/lib/common/apiHelpers";
|
||||
|
||||
type CodeLabelMap = Record<string, string>;
|
||||
|
||||
const COMMON_CODE_GROUPS = {
|
||||
uploadStatus: "GISULDSTTS",
|
||||
downloadStatus: "GISDWNLDSTTS",
|
||||
facilityType: "SNFCLTSE",
|
||||
} as const;
|
||||
|
||||
const DEFAULT_UPLOAD_STATUS_LABELS: CodeLabelMap = {
|
||||
COMPLETED: "업로드완료",
|
||||
VALIDATION_FAILED: "검증실패",
|
||||
FAILED: "업로드실패",
|
||||
};
|
||||
|
||||
const DEFAULT_DOWNLOAD_STATUS_LABELS: CodeLabelMap = {
|
||||
QUEUED: "작업대기",
|
||||
RUNNING: "파일생성중",
|
||||
READY: "다운로드준비완료",
|
||||
FAILED: "파일생성실패",
|
||||
};
|
||||
|
||||
const DEFAULT_FACILITY_TYPE_LABELS: CodeLabelMap = {
|
||||
PPL: "하수관로",
|
||||
MNHL: "맨홀",
|
||||
};
|
||||
|
||||
const cachedLabelMaps = new Map<string, CodeLabelMap>();
|
||||
const pendingLabelMaps = new Map<string, Promise<CodeLabelMap>>();
|
||||
|
||||
/** 같은 브라우저 세션에서 각 GIS 관리 코드그룹을 한 번만 조회한다. */
|
||||
const loadCodeLabelMap = async (
|
||||
upCd: string,
|
||||
fallback: CodeLabelMap,
|
||||
): Promise<CodeLabelMap> => {
|
||||
const cached = cachedLabelMaps.get(upCd);
|
||||
if (cached) return cached;
|
||||
|
||||
const pending = pendingLabelMaps.get(upCd);
|
||||
if (pending) return pending;
|
||||
|
||||
const request = getCommonCodes(upCd)
|
||||
.then((codes) => {
|
||||
const labels = { ...fallback };
|
||||
codes.forEach((code) => {
|
||||
if (code.lwpoCd && code.lwpoCdNm) {
|
||||
labels[code.lwpoCd] = code.lwpoCdNm;
|
||||
}
|
||||
});
|
||||
cachedLabelMaps.set(upCd, labels);
|
||||
return labels;
|
||||
})
|
||||
.finally(() => {
|
||||
pendingLabelMaps.delete(upCd);
|
||||
});
|
||||
|
||||
pendingLabelMaps.set(upCd, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
/** GIS 관리 화면의 코드값은 유지하고 상태·시설구분 표시명만 CMS 기준으로 제공한다. */
|
||||
export const useGisManagementCommonCodes = () => {
|
||||
const [uploadStatusLabels, setUploadStatusLabels] =
|
||||
useState<CodeLabelMap>(
|
||||
cachedLabelMaps.get(COMMON_CODE_GROUPS.uploadStatus) ??
|
||||
DEFAULT_UPLOAD_STATUS_LABELS,
|
||||
);
|
||||
const [downloadStatusLabels, setDownloadStatusLabels] =
|
||||
useState<CodeLabelMap>(
|
||||
cachedLabelMaps.get(COMMON_CODE_GROUPS.downloadStatus) ??
|
||||
DEFAULT_DOWNLOAD_STATUS_LABELS,
|
||||
);
|
||||
const [facilityTypeLabels, setFacilityTypeLabels] =
|
||||
useState<CodeLabelMap>(
|
||||
cachedLabelMaps.get(COMMON_CODE_GROUPS.facilityType) ??
|
||||
DEFAULT_FACILITY_TYPE_LABELS,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const applyLabels = (
|
||||
upCd: string,
|
||||
fallback: CodeLabelMap,
|
||||
setter: (labels: CodeLabelMap) => void,
|
||||
) => {
|
||||
loadCodeLabelMap(upCd, fallback)
|
||||
.then((labels) => {
|
||||
if (!cancelled) setter(labels);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`${upCd} 공통코드 조회에 실패했습니다.`, error);
|
||||
});
|
||||
};
|
||||
|
||||
applyLabels(
|
||||
COMMON_CODE_GROUPS.uploadStatus,
|
||||
DEFAULT_UPLOAD_STATUS_LABELS,
|
||||
setUploadStatusLabels,
|
||||
);
|
||||
applyLabels(
|
||||
COMMON_CODE_GROUPS.downloadStatus,
|
||||
DEFAULT_DOWNLOAD_STATUS_LABELS,
|
||||
setDownloadStatusLabels,
|
||||
);
|
||||
applyLabels(
|
||||
COMMON_CODE_GROUPS.facilityType,
|
||||
DEFAULT_FACILITY_TYPE_LABELS,
|
||||
setFacilityTypeLabels,
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
uploadStatusLabels,
|
||||
downloadStatusLabels,
|
||||
facilityTypeLabels,
|
||||
};
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import { useAlertStore } from "@/shared/lib";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import { getData } from "@/components/lib/api";
|
||||
import { appendApiSearchParams } from "@/shared/util/util";
|
||||
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
|
||||
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
|
||||
import useStore from "@/store/useStore";
|
||||
@@ -132,16 +133,13 @@ export default function InftwtResultPopupPage() {
|
||||
: "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());
|
||||
appendApiSearchParams(queryParams, {
|
||||
aplcnYmFrom: nextFilters.aplcnYmFrom,
|
||||
aplcnYmTo: nextFilters.aplcnYmTo,
|
||||
ctpvCd: nextFilters.ctpvCd,
|
||||
sggCd: nextFilters.sggCd,
|
||||
swtrmtFcltNm: nextFilters.swtrmtFcltNm,
|
||||
});
|
||||
|
||||
const response = await getData(`v1/inftwtAnls?${queryParams.toString()}`);
|
||||
const content = Array.isArray(response?.content) ? response.content : [];
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
enabled: boolean;
|
||||
onEnabledChange: (enabled: boolean) => void;
|
||||
onClose: () => void;
|
||||
children?: ReactNode;
|
||||
showToggle?: boolean;
|
||||
};
|
||||
|
||||
/** 침수흔적도 설정과 같은 제목·토글·닫기·본문 여백을 제공한다. */
|
||||
export default function LayerSettingCard({
|
||||
title,
|
||||
enabled,
|
||||
onEnabledChange,
|
||||
onClose,
|
||||
children,
|
||||
showToggle = true,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="w-full overflow-visible rounded-[6px] border border-border bg-background shadow-blur">
|
||||
<div className="flex min-h-[28px] items-center justify-between border-b border-border px-2">
|
||||
<span className="text-[1.15rem] font-semibold text-label">{title}</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{showToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
aria-label={`${title} 적용 여부`}
|
||||
title={enabled ? `${title} 적용 중` : `${title} 미적용`}
|
||||
className={`relative h-[16px] w-[28px] rounded-full border transition-colors ${enabled ? "border-primary bg-primary" : "border-[#94a3b8] bg-[#cbd5e1]"}`}
|
||||
onClick={() => onEnabledChange(!enabled)}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1/2 h-[12px] w-[12px] -translate-y-1/2 rounded-full bg-white shadow-sm transition-[left] ${enabled ? "left-[14px]" : "left-[2px]"}`}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${title} 설정 닫기`}
|
||||
title="닫기"
|
||||
className="grid h-[20px] w-[20px] place-items-center bg-transparent text-[1.3rem] leading-none text-muted-foreground hover:text-foreground"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{children ? (
|
||||
<div className="px-2 py-1 text-[1.15rem] text-label">{children}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+11
-13
@@ -6,6 +6,7 @@ import { Button, Input } from "@common/components";
|
||||
import { StandardPopupLayout } from "@common/business_components";
|
||||
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
|
||||
import { getData } from "@/components/lib/api";
|
||||
import { appendApiSearchParams } from "@/shared/util/util";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import useStore from "@/store/useStore";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
@@ -131,19 +132,16 @@ export default function PipeAnalysisDetailPopupPage() {
|
||||
size: String(size),
|
||||
});
|
||||
|
||||
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.useCd !== "0") params.set("useCd", nextFilters.useCd);
|
||||
if (nextFilters.diameterFrom)
|
||||
params.set("diameterFrom", nextFilters.diameterFrom);
|
||||
if (nextFilters.diameterTo)
|
||||
params.set("diameterTo", nextFilters.diameterTo);
|
||||
appendApiSearchParams(params, {
|
||||
ctpvCd: nextFilters.ctpvCd,
|
||||
sggCd: nextFilters.sggCd,
|
||||
swtrmtZoneNm: nextFilters.swtrmtZoneNm,
|
||||
elapsedYearBucket: baseElapsedYearBucket,
|
||||
materialCode: nextFilters.materialCode,
|
||||
useCd: nextFilters.useCd,
|
||||
diameterFrom: nextFilters.diameterFrom,
|
||||
diameterTo: nextFilters.diameterTo,
|
||||
});
|
||||
if (scope.regionCode) params.set("scopeRegionCode", scope.regionCode);
|
||||
|
||||
const response = await getData(
|
||||
|
||||
@@ -14,6 +14,7 @@ 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 { appendApiSearchParams } from "@/shared/util/util";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import useStore from "@/store/useStore";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
@@ -144,10 +145,11 @@ export default function PipeAnalysisPopupPage() {
|
||||
analysisType: nextFilters.analysisType,
|
||||
});
|
||||
|
||||
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);
|
||||
appendApiSearchParams(params, {
|
||||
ctpvCd: nextFilters.ctpvCd,
|
||||
sggCd: nextFilters.sggCd,
|
||||
swtrmtZoneNm: nextFilters.swtrmtZoneNm,
|
||||
});
|
||||
if (nextFilters.scopeRegionCode)
|
||||
params.set("scopeRegionCode", nextFilters.scopeRegionCode);
|
||||
|
||||
@@ -386,7 +388,7 @@ export default function PipeAnalysisPopupPage() {
|
||||
const sggOptions = useMemo(
|
||||
() =>
|
||||
draftFilters.ctpvCd === "0"
|
||||
? sgg
|
||||
? []
|
||||
: sgg.filter((item) =>
|
||||
String(item.val).startsWith(draftFilters.ctpvCd.slice(0, 2)),
|
||||
),
|
||||
|
||||
@@ -244,7 +244,7 @@ const InfilChart = ({
|
||||
|
||||
const renderResultPanel = () => (
|
||||
<section className="card card-border !m-0 min-w-0 overflow-hidden !p-[1.2rem]">
|
||||
<StandardFormSection title="침입수 분석결과" columns={2}>
|
||||
<StandardFormSection title="침입수 분석결과" columns={2} wideLabels>
|
||||
<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
|
||||
|
||||
@@ -218,7 +218,7 @@ const RdiiChart = ({
|
||||
|
||||
const renderResultPanel = () => (
|
||||
<section className="card card-border !m-0 min-w-0 overflow-hidden !p-[1.2rem]">
|
||||
<StandardFormSection title="RDII 분석결과" columns={2}>
|
||||
<StandardFormSection title="RDII 분석결과" columns={2} wideLabels>
|
||||
<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
|
||||
|
||||
+172
-85
@@ -186,10 +186,17 @@ const FeatureClickEventPopup = ({
|
||||
const [floodDepthRows, setFloodDepthRows] = useState<
|
||||
Record<string, unknown>[]
|
||||
>([]);
|
||||
const [selectedFloodDepthYear, setSelectedFloodDepthYear] = useState<
|
||||
const [floodWtlvRows, setFloodWtlvRows] = useState<Record<string, unknown>[]>(
|
||||
[],
|
||||
);
|
||||
const [selectedFloodDepthFid, setSelectedFloodDepthFid] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [selectedFloodWtlvFid, setSelectedFloodWtlvFid] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const floodDepthYearListRef = useRef<HTMLDivElement>(null);
|
||||
const floodWtlvListRef = useRef<HTMLDivElement>(null);
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
|
||||
// 🎯 관로관리번호 추출
|
||||
@@ -850,7 +857,9 @@ const FeatureClickEventPopup = ({
|
||||
if (selectedLayerName === "침수흔적위선" && featureId) {
|
||||
const depthLookupKey = normalizeFldngWtlvMapApiFid(featureId);
|
||||
setFloodDepthRows([]);
|
||||
setSelectedFloodDepthYear(null);
|
||||
setFloodWtlvRows([]);
|
||||
setSelectedFloodDepthFid(null);
|
||||
setSelectedFloodWtlvFid(null);
|
||||
try {
|
||||
const normalizedFloodMapFid =
|
||||
normalizeFldngWtlvMapApiFid(featureId);
|
||||
@@ -877,18 +886,25 @@ const FeatureClickEventPopup = ({
|
||||
`v1/fldng/hstry/depth-map/${encodeURIComponent(depthLookupKey)}`,
|
||||
);
|
||||
const depthPayload = depthRows?.data ?? depthRows;
|
||||
const normalizedDepthRows = Array.isArray(depthPayload)
|
||||
? depthPayload.filter((row) => row && typeof row === "object")
|
||||
const depthItems = Array.isArray(depthPayload)
|
||||
? depthPayload
|
||||
: depthPayload?.depthMaps;
|
||||
const waterLevelItems = Array.isArray(
|
||||
depthPayload?.waterLevelMaps,
|
||||
)
|
||||
? depthPayload.waterLevelMaps
|
||||
: [];
|
||||
const normalizedDepthRows = Array.isArray(depthItems)
|
||||
? depthItems.filter((row) => row && typeof row === "object")
|
||||
: [];
|
||||
const normalizedWaterLevelRows = waterLevelItems.filter(
|
||||
(row: unknown) => row && typeof row === "object",
|
||||
);
|
||||
setFloodDepthRows(normalizedDepthRows);
|
||||
const years = normalizedDepthRows
|
||||
.map((row) =>
|
||||
String(row.fldng_yr ?? row.fldngYr ?? row.yr ?? "").trim(),
|
||||
)
|
||||
.filter(Boolean);
|
||||
console.log("depth years:", years);
|
||||
// 최초에는 연도를 선택하지 않고 목록만 표시한다.
|
||||
setSelectedFloodDepthYear(null);
|
||||
setFloodWtlvRows(normalizedWaterLevelRows);
|
||||
// 최초에는 연계 항목을 선택하지 않고 버튼 목록만 표시한다.
|
||||
setSelectedFloodDepthFid(null);
|
||||
setSelectedFloodWtlvFid(null);
|
||||
}
|
||||
} catch (depthError) {
|
||||
console.warn("침수깊이지도 조회 실패:", depthError);
|
||||
@@ -1097,82 +1113,153 @@ const FeatureClickEventPopup = ({
|
||||
>
|
||||
<VerticalTable
|
||||
headName={
|
||||
layerNm === "침수흔적위선" && floodDepthRows.length > 0 ? (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
layerNm === "침수흔적위선" &&
|
||||
(floodDepthRows.length > 0 || floodWtlvRows.length > 0) ? (
|
||||
<div className="flex w-full min-w-0 flex-col items-start gap-2">
|
||||
<span>기본정보</span>
|
||||
<div className="flex w-full items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="이전 연도"
|
||||
className="shrink-0 px-1 text-lg leading-none text-foreground"
|
||||
onClick={() =>
|
||||
floodDepthYearListRef.current?.scrollBy({
|
||||
left: -120,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<div
|
||||
ref={floodDepthYearListRef}
|
||||
className="flex min-w-0 flex-1 gap-2 overflow-x-auto whitespace-nowrap"
|
||||
>
|
||||
{Array.from(
|
||||
new Set(
|
||||
floodDepthRows
|
||||
.map((row) =>
|
||||
String(
|
||||
row.fldng_yr ?? row.fldngYr ?? row.yr ?? "",
|
||||
).trim(),
|
||||
)
|
||||
.filter(Boolean),
|
||||
),
|
||||
).map((year) => (
|
||||
<button
|
||||
key={year}
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setSelectedFloodDepthYear(year);
|
||||
const selectedDepthRow = floodDepthRows.find(
|
||||
(row) =>
|
||||
String(
|
||||
row.fldng_yr ?? row.fldngYr ?? row.yr ?? "",
|
||||
).trim() === year,
|
||||
{floodDepthRows.length > 0 && (
|
||||
<div className="flex w-full items-center gap-1">
|
||||
<span className="w-9 shrink-0 text-[1.1rem] font-semibold">
|
||||
심선
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="이전 연도"
|
||||
className="shrink-0 px-1 text-lg leading-none text-foreground"
|
||||
onClick={() =>
|
||||
floodDepthYearListRef.current?.scrollBy({
|
||||
left: -120,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<div
|
||||
ref={floodDepthYearListRef}
|
||||
className="flex min-w-0 flex-1 gap-2 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{[...floodDepthRows]
|
||||
.map((row, index) => ({
|
||||
row,
|
||||
index,
|
||||
year: String(
|
||||
row.fldng_yr ?? row.fldngYr ?? row.yr ?? "",
|
||||
).trim(),
|
||||
}))
|
||||
.filter(({ year }) => Boolean(year))
|
||||
.sort((left, right) => {
|
||||
const yearOrder = left.year.localeCompare(
|
||||
right.year,
|
||||
undefined,
|
||||
{ numeric: true },
|
||||
);
|
||||
const depthFid = String(
|
||||
selectedDepthRow?.fldng_dpth_map_sn ??
|
||||
selectedDepthRow?.fid ??
|
||||
"",
|
||||
).trim();
|
||||
if (depthFid) {
|
||||
const core = GisApp.Module.layer as any;
|
||||
await core.showSearchResultsFromGeoJSON?.(
|
||||
"tb_ms_fldng_dpth_map",
|
||||
`tb_ms_fldng_dpth_map.${depthFid.replace(/^tb_ms_fldng_dpth_map\./, "")}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
className={`rounded border px-3 py-1 text-[1.2rem] font-medium leading-5 ${selectedFloodDepthYear === year ? "border-blue-600 bg-white text-blue-700" : "border-border bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
{year}년
|
||||
</button>
|
||||
))}
|
||||
return yearOrder !== 0
|
||||
? yearOrder
|
||||
: left.index - right.index;
|
||||
})
|
||||
.map(({ row, index, year }) => (
|
||||
<button
|
||||
key={`${String(row.fldng_dpth_map_sn ?? row.fid ?? "row")}-${index}`}
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const depthFid = String(
|
||||
row.fldng_dpth_map_sn ?? row.fid ?? "",
|
||||
).trim();
|
||||
if (depthFid) {
|
||||
setSelectedFloodDepthFid(depthFid);
|
||||
setSelectedFloodWtlvFid(null);
|
||||
const core = GisApp.Module.layer as any;
|
||||
await core.showSearchResultsFromGeoJSON?.(
|
||||
"tb_ms_fldng_dpth_map",
|
||||
`tb_ms_fldng_dpth_map.${depthFid.replace(/^tb_ms_fldng_dpth_map\./, "")}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
className={`shrink-0 rounded border px-3 py-1 text-[1.2rem] font-medium leading-5 ${selectedFloodDepthFid === String(row.fldng_dpth_map_sn ?? row.fid ?? "").trim() ? "border-blue-600 bg-white text-blue-700" : "border-border bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
{year}년
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="다음 연도"
|
||||
className="shrink-0 px-1 text-lg leading-none text-foreground"
|
||||
onClick={() =>
|
||||
floodDepthYearListRef.current?.scrollBy({
|
||||
left: 120,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="다음 연도"
|
||||
className="shrink-0 px-1 text-lg leading-none text-foreground"
|
||||
onClick={() =>
|
||||
floodDepthYearListRef.current?.scrollBy({
|
||||
left: 120,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{floodWtlvRows.length > 0 && (
|
||||
<div className="flex w-full items-center gap-1">
|
||||
<span className="w-9 shrink-0 text-[1.1rem] font-semibold">
|
||||
위선
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="이전 위선"
|
||||
className="shrink-0 px-1 text-lg leading-none text-foreground"
|
||||
onClick={() =>
|
||||
floodWtlvListRef.current?.scrollBy({
|
||||
left: -120,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<div
|
||||
ref={floodWtlvListRef}
|
||||
className="flex min-w-0 flex-1 gap-2 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{floodWtlvRows.map((row, index) => {
|
||||
const waterLevelFid = String(row.fid ?? "").trim();
|
||||
const disasterName = String(
|
||||
row.fldng_dstr_nm ?? row.fldngDstrNm ?? "",
|
||||
).trim();
|
||||
return (
|
||||
<button
|
||||
key={`${waterLevelFid || "wtlv"}-${index}`}
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!waterLevelFid) return;
|
||||
setSelectedFloodWtlvFid(waterLevelFid);
|
||||
setSelectedFloodDepthFid(null);
|
||||
const core = GisApp.Module.layer as any;
|
||||
await core.showSearchResultsFromGeoJSON?.(
|
||||
"tb_ms_fldng_wtlv_map",
|
||||
`tb_ms_fldng_wtlv_map.${waterLevelFid.replace(/^tb_ms_fldng_wtlv_map\./, "")}`,
|
||||
);
|
||||
}}
|
||||
className={`shrink-0 rounded border px-3 py-1 text-[1.2rem] font-medium leading-5 ${selectedFloodWtlvFid === waterLevelFid ? "border-blue-600 bg-white text-blue-700" : "border-border bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
{disasterName || "재해명 없음"}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="다음 위선"
|
||||
className="shrink-0 px-1 text-lg leading-none text-foreground"
|
||||
onClick={() =>
|
||||
floodWtlvListRef.current?.scrollBy({
|
||||
left: 120,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : layerNm === "하수관로" && tableData?.elapsedYears ? (
|
||||
<>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import { getData } from "@/components/lib/api";
|
||||
import { appendApiSearchParams } from "@/shared/util/util";
|
||||
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
|
||||
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
@@ -134,16 +135,13 @@ export default function RdiiResultPopupPage() {
|
||||
: "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());
|
||||
appendApiSearchParams(queryParams, {
|
||||
aplcnYmFrom: nextFilters.aplcnYmFrom,
|
||||
aplcnYmTo: nextFilters.aplcnYmTo,
|
||||
ctpvCd: nextFilters.ctpvCd,
|
||||
sggCd: nextFilters.sggCd,
|
||||
swtrmtFcltNm: nextFilters.swtrmtFcltNm,
|
||||
});
|
||||
|
||||
const response = await getData(`v1/rdiiAnls?${queryParams.toString()}`);
|
||||
const content = Array.isArray(response?.content) ? response.content : [];
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function SbmrgDngrMapPopupPage() {
|
||||
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)),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { UiHeadProps } from "@common/business_components/ui";
|
||||
import { appendApiSearchParams } from "@/shared/util/util";
|
||||
|
||||
export const SBMRG_DNGR_MAP_POPUP_KEY = "sbmrgDngrMapPopup";
|
||||
export const SBMRG_DNGR_MAP_LAYER_TYPE = "tb_ms_sbmrg_dngr_map";
|
||||
@@ -74,13 +75,11 @@ export const buildSbmrgDngrMapListQueryString = (
|
||||
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);
|
||||
}
|
||||
appendApiSearchParams(params, {
|
||||
ctpvCd: filters.ctpvCd === "0" ? "0" : filters.ctpvCd.slice(0, 2),
|
||||
sggCd: filters.sggCd === "0" ? "0" : filters.sggCd.slice(0, 5),
|
||||
floodWtlvFrqVl: filters.floodWtlvFrqVl,
|
||||
});
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from "@common/components";
|
||||
import { VerticalTable } from "@/components/ui/verticalTable";
|
||||
import { getCmsData } from "@/components/lib/api";
|
||||
import type { SinkholeDetail, SinkholeNearbyPipe } from "./sinkholeTypes";
|
||||
import { useSinkholeCommonCodes } from "./useSinkholeCommonCodes";
|
||||
|
||||
type Props = {
|
||||
detail: SinkholeDetail;
|
||||
@@ -74,6 +75,8 @@ export default function SinkholeDetailPanel({
|
||||
onClose,
|
||||
onSelectPipe,
|
||||
}: Props) {
|
||||
// 상세의 원천 복구상태 값은 유지하고 CMS에 대응하는 코드명만 표시한다.
|
||||
const { restoreStatusLabels } = useSinkholeCommonCodes();
|
||||
const [pipeUseCodeMap, setPipeUseCodeMap] = useState<Map<string, string>>(
|
||||
new Map(),
|
||||
);
|
||||
@@ -121,20 +124,25 @@ export default function SinkholeDetailPanel({
|
||||
}, [nearbyPipes]);
|
||||
|
||||
const summaryTableData = useMemo(
|
||||
() => [
|
||||
{
|
||||
acdntNo: display(detail.acdntNo),
|
||||
ocrnYmd: display(detail.ocrnYmd),
|
||||
fullAddress: display(detail.fullAddress || detail.daddr),
|
||||
sizeWidth: display(detail.sizeWidth),
|
||||
sizeLength: display(detail.sizeLength),
|
||||
sizeDepth: display(detail.sizeDepth),
|
||||
detailCause: display(detail.detailCause),
|
||||
restoreStatus: display(detail.restoreStatus),
|
||||
restoreCompletedYmd: display(detail.restoreCompletedYmd),
|
||||
},
|
||||
],
|
||||
[detail],
|
||||
() => {
|
||||
const restoreStatus = String(detail.restoreStatus ?? "").trim();
|
||||
return [
|
||||
{
|
||||
acdntNo: display(detail.acdntNo),
|
||||
ocrnYmd: display(detail.ocrnYmd),
|
||||
fullAddress: display(detail.fullAddress || detail.daddr),
|
||||
sizeWidth: display(detail.sizeWidth),
|
||||
sizeLength: display(detail.sizeLength),
|
||||
sizeDepth: display(detail.sizeDepth),
|
||||
detailCause: display(detail.detailCause),
|
||||
restoreStatus: display(
|
||||
restoreStatusLabels[restoreStatus] || restoreStatus,
|
||||
),
|
||||
restoreCompletedYmd: display(detail.restoreCompletedYmd),
|
||||
},
|
||||
];
|
||||
},
|
||||
[detail, restoreStatusLabels],
|
||||
);
|
||||
|
||||
const summaryHeadList = [
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type SinkholeSyncSummary,
|
||||
type SinkholeYearRange,
|
||||
} from "./sinkholeTypes";
|
||||
import LayerSettingCard from "../layerSetting/LayerSettingCard";
|
||||
|
||||
type MaybePromise<T = void> = T | Promise<T>;
|
||||
|
||||
@@ -21,6 +22,9 @@ type Props = {
|
||||
lastSyncSummary?: SinkholeSyncSummary | null;
|
||||
isAdmin?: boolean;
|
||||
compact?: boolean;
|
||||
enabled?: boolean;
|
||||
onEnabledChange?: (enabled: boolean) => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export default function SinkholeLayerSettingPanel({
|
||||
@@ -31,6 +35,9 @@ export default function SinkholeLayerSettingPanel({
|
||||
lastSyncSummary,
|
||||
isAdmin = false,
|
||||
compact = false,
|
||||
enabled = true,
|
||||
onEnabledChange = () => {},
|
||||
onClose = () => {},
|
||||
}: Props) {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
@@ -94,7 +101,7 @@ export default function SinkholeLayerSettingPanel({
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearSelectClass =
|
||||
"h-[22px] w-[62px] rounded-none border border-input bg-background px-[4px] text-center text-[1rem] leading-none text-foreground";
|
||||
"h-[20px] !w-[58px] !rounded border border-input bg-background px-[1px] text-center text-[1.1rem] leading-none text-foreground";
|
||||
const startYearOptions = Array.from(
|
||||
{ length: currentYear - SINKHOLE_MIN_SEARCH_YEAR + 1 },
|
||||
(_, index) => String(currentYear - index),
|
||||
@@ -117,17 +124,17 @@ export default function SinkholeLayerSettingPanel({
|
||||
.map((year) => ({ val: year, text: year }));
|
||||
|
||||
const inputClass =
|
||||
"h-[22px] border border-input bg-background px-[2px] text-center text-[1rem] leading-none text-foreground";
|
||||
"h-[20px] !rounded border border-input bg-background px-[1px] text-center text-[1.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-primary text-[0.95rem] font-bold leading-none text-primary-foreground";
|
||||
"grid h-[16px] w-[16px] shrink-0 place-items-center rounded-full bg-primary text-[0.85rem] 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-label">
|
||||
<div className="flex min-h-[25px] items-center gap-1.5 whitespace-nowrap">
|
||||
<div className="space-y-1.5 text-[1.15rem] font-medium text-label">
|
||||
<div className="flex min-h-[26px] 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">
|
||||
@@ -158,15 +165,15 @@ export default function SinkholeLayerSettingPanel({
|
||||
title="적용"
|
||||
aria-label="반경 적용"
|
||||
>
|
||||
✓
|
||||
<span className="relative left-[1px]">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-[25px] items-center gap-1.5 whitespace-nowrap">
|
||||
<div className="flex min-h-[26px] 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>
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">
|
||||
@@ -208,7 +215,7 @@ export default function SinkholeLayerSettingPanel({
|
||||
title="적용"
|
||||
aria-label="연도범위 적용"
|
||||
>
|
||||
✓
|
||||
<span className="relative left-[1px]">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,9 +224,14 @@ export default function SinkholeLayerSettingPanel({
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="w-full rounded-[6px] border border-border bg-background px-2 py-[10px] shadow-blur">
|
||||
<LayerSettingCard
|
||||
title="지반침하정보"
|
||||
enabled={enabled}
|
||||
onEnabledChange={onEnabledChange}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="overflow-visible">{settingRows}</div>
|
||||
</div>
|
||||
</LayerSettingCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,15 +25,14 @@ import {
|
||||
formatDateInputToYmd,
|
||||
formatYmdToDateInput,
|
||||
normalizeSinkholeFilters,
|
||||
SINKHOLE_COORD_STATUS_OPTIONS,
|
||||
SINKHOLE_DOWNLOAD_EXCEL_HEADERS,
|
||||
SINKHOLE_FILTER_SYNC_MESSAGE,
|
||||
SINKHOLE_MIN_SEARCH_YMD,
|
||||
SINKHOLE_RESTORE_STATUS_OPTIONS,
|
||||
SINKHOLE_ROW_SELECT_MESSAGE,
|
||||
type SinkholeFilters,
|
||||
type SinkholeListItem,
|
||||
} from "./sinkholeTypes";
|
||||
import { useSinkholeCommonCodes } from "./useSinkholeCommonCodes";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
const DOWNLOAD_BATCH_SIZE = 2000;
|
||||
@@ -44,6 +43,7 @@ type SinkholeTableRow = SinkholeListItem & {
|
||||
sizeLengthDisplay: string;
|
||||
sizeDepthDisplay: string;
|
||||
detailCauseDisplay: string;
|
||||
restoreStatusDisplay: string;
|
||||
restoreCompletedYmdDisplay: string;
|
||||
coordStatusDisplay: string;
|
||||
};
|
||||
@@ -57,7 +57,7 @@ const HEADLIST: UiHeadProps[] = [
|
||||
{ id: "sizeLengthDisplay", title: "발생규모연장", widthPercent: 9 },
|
||||
{ id: "sizeDepthDisplay", title: "발생규모깊이", widthPercent: 9 },
|
||||
{ id: "detailCauseDisplay", title: "상세발생원인", widthPercent: 11 },
|
||||
{ id: "restoreStatus", title: "복구상태", widthPercent: 8 },
|
||||
{ id: "restoreStatusDisplay", title: "복구상태", widthPercent: 8 },
|
||||
{ id: "coordStatusDisplay", title: "좌표보정상태", widthPercent: 9 },
|
||||
{ id: "restoreCompletedYmdDisplay", title: "복구완료일자", widthPercent: 11 },
|
||||
];
|
||||
@@ -67,10 +67,14 @@ const toDisplay = (value: unknown) => {
|
||||
return normalized ? normalized : "-";
|
||||
};
|
||||
|
||||
const toCoordStatusDisplay = (value: unknown) => {
|
||||
const toCoordStatusDisplay = (
|
||||
value: unknown,
|
||||
labels: Record<string, string> = {},
|
||||
) => {
|
||||
const normalized = String(value ?? "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (labels[normalized]) return labels[normalized];
|
||||
if (normalized === "SUCCESS") return "성공";
|
||||
if (normalized === "NO_ADDRESS") return "주소없음";
|
||||
if (normalized === "FAILED") return "실패";
|
||||
@@ -89,10 +93,29 @@ const toTableRows = (
|
||||
sizeLengthDisplay: toDisplay(item.sizeLength),
|
||||
sizeDepthDisplay: toDisplay(item.sizeDepth),
|
||||
detailCauseDisplay: toDisplay(item.detailCause),
|
||||
restoreStatusDisplay: toDisplay(item.restoreStatus),
|
||||
coordStatusDisplay: toCoordStatusDisplay(item.coordStatus),
|
||||
restoreCompletedYmdDisplay: toDisplay(item.restoreCompletedYmd),
|
||||
}));
|
||||
|
||||
const applyStatusDisplayNames = (
|
||||
rows: SinkholeTableRow[],
|
||||
restoreStatusLabels: Record<string, string>,
|
||||
coordStatusLabels: Record<string, string>,
|
||||
) =>
|
||||
rows.map((row) => {
|
||||
const restoreStatus = String(row.restoreStatus ?? "").trim();
|
||||
return {
|
||||
...row,
|
||||
restoreStatusDisplay:
|
||||
restoreStatusLabels[restoreStatus] || restoreStatus || "-",
|
||||
coordStatusDisplay: toCoordStatusDisplay(
|
||||
row.coordStatus,
|
||||
coordStatusLabels,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const buildDownloadRows = (rows: SinkholeTableRow[]) =>
|
||||
rows.map((row) => ({
|
||||
rowNo: row.rowNo,
|
||||
@@ -102,7 +125,7 @@ const buildDownloadRows = (rows: SinkholeTableRow[]) =>
|
||||
sizeLength: row.sizeLengthDisplay,
|
||||
sizeDepth: row.sizeDepthDisplay,
|
||||
detailCause: row.detailCauseDisplay,
|
||||
restoreStatus: row.restoreStatus || "-",
|
||||
restoreStatus: row.restoreStatusDisplay,
|
||||
restoreCompletedYmd: row.restoreCompletedYmdDisplay,
|
||||
}));
|
||||
|
||||
@@ -111,6 +134,13 @@ const unwrapPayload = <T,>(response: any): T =>
|
||||
|
||||
export default function SinkholePopupPage() {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
// 검색 파라미터 계약은 유지하고 선택지·목록 표시명만 CMS 공통코드로 구성한다.
|
||||
const {
|
||||
restoreStatusOptions,
|
||||
coordStatusOptions,
|
||||
restoreStatusLabels,
|
||||
coordStatusLabels,
|
||||
} = useSinkholeCommonCodes();
|
||||
const searchParams = useSearchParams();
|
||||
const hasInitialFilterParams =
|
||||
searchParams.has("startYmd") ||
|
||||
@@ -160,10 +190,14 @@ export default function SinkholePopupPage() {
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const displayRows = useMemo(
|
||||
() => applyStatusDisplayNames(rows, restoreStatusLabels, coordStatusLabels),
|
||||
[coordStatusLabels, restoreStatusLabels, rows],
|
||||
);
|
||||
const sggOptions = useMemo(
|
||||
() =>
|
||||
filters.ctpvCd === "0"
|
||||
? sgg
|
||||
? []
|
||||
: sgg.filter((item) =>
|
||||
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
|
||||
),
|
||||
@@ -294,7 +328,11 @@ export default function SinkholePopupPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const downloadRows = toTableRows(downloadItems, 0, 1);
|
||||
const downloadRows = applyStatusDisplayNames(
|
||||
toTableRows(downloadItems, 0, 1),
|
||||
restoreStatusLabels,
|
||||
coordStatusLabels,
|
||||
);
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: buildDownloadRows(downloadRows) as unknown as JSON[],
|
||||
fileName: `지반침하정보_${new Date().toISOString().slice(0, 10)}.xlsx`,
|
||||
@@ -395,7 +433,7 @@ export default function SinkholePopupPage() {
|
||||
setFilters({ ...filters, restoreStatus: event.target.value })
|
||||
}
|
||||
>
|
||||
{SINKHOLE_RESTORE_STATUS_OPTIONS.map((option) => (
|
||||
{restoreStatusOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
@@ -411,7 +449,7 @@ export default function SinkholePopupPage() {
|
||||
setFilters({ ...filters, coordStatus: event.target.value })
|
||||
}
|
||||
>
|
||||
{SINKHOLE_COORD_STATUS_OPTIONS.map((option) => (
|
||||
{coordStatusOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
@@ -445,7 +483,7 @@ export default function SinkholePopupPage() {
|
||||
</PopupSearchPanel>
|
||||
|
||||
<MapPopupDataTable
|
||||
rows={rows as Array<SinkholeTableRow & Record<string, unknown>>}
|
||||
rows={displayRows as Array<SinkholeTableRow & Record<string, unknown>>}
|
||||
headlist={HEADLIST}
|
||||
title="지반침하 목록"
|
||||
total={total}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { SelectDataType } from "@common/business_components";
|
||||
import type { IUserInfo } from "@/shared/store";
|
||||
import { appendApiSearchParams } from "@/shared/util/util";
|
||||
|
||||
export const SINKHOLE_CONTROL_NODE_ID = "SN_SINKHOLE";
|
||||
export const SINKHOLE_LAYER_TYPE = "tb_sn_sinkhole_info";
|
||||
@@ -284,19 +285,16 @@ export const buildSinkholeListQueryString = (
|
||||
size: String(size),
|
||||
});
|
||||
|
||||
if (filters.startYmd) params.set("startYmd", filters.startYmd);
|
||||
if (filters.endYmd) params.set("endYmd", filters.endYmd);
|
||||
|
||||
const ctpvNm = resolveRegionNameByCode(filters.ctpvCd, ctpvOptions, 2);
|
||||
const sggNm = resolveRegionNameByCode(filters.sggCd, sggOptions, 5);
|
||||
if (ctpvNm) params.set("ctpvNm", ctpvNm);
|
||||
if (sggNm) params.set("sggNm", sggNm);
|
||||
if (filters.restoreStatus && filters.restoreStatus !== "0") {
|
||||
params.set("restoreStatus", filters.restoreStatus);
|
||||
}
|
||||
if (filters.coordStatus && filters.coordStatus !== "0") {
|
||||
params.set("coordStatus", filters.coordStatus);
|
||||
}
|
||||
appendApiSearchParams(params, {
|
||||
startYmd: filters.startYmd,
|
||||
endYmd: filters.endYmd,
|
||||
ctpvNm,
|
||||
sggNm,
|
||||
restoreStatus: filters.restoreStatus,
|
||||
coordStatus: filters.coordStatus,
|
||||
});
|
||||
if (options?.includeCoordStatus === false) {
|
||||
params.set("includeCoordStatus", "false");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { getCommonCodes } from "@/components/lib/common/apiHelpers";
|
||||
|
||||
import {
|
||||
SINKHOLE_COORD_STATUS_OPTIONS,
|
||||
SINKHOLE_RESTORE_STATUS_OPTIONS,
|
||||
} from "./sinkholeTypes";
|
||||
|
||||
type CodeLabelMap = Record<string, string>;
|
||||
type StatusOption = { value: string; label: string };
|
||||
|
||||
const COMMON_CODE_GROUPS = {
|
||||
restoreStatus: "SNKHLRSTRSTTS",
|
||||
coordStatus: "SNKHLCRDSTTS",
|
||||
} as const;
|
||||
|
||||
const cachedCodeLists = new Map<
|
||||
string,
|
||||
Array<{ lwpoCd: string; lwpoCdNm: string }>
|
||||
>();
|
||||
const pendingCodeLists = new Map<
|
||||
string,
|
||||
Promise<Array<{ lwpoCd: string; lwpoCdNm: string }>>
|
||||
>();
|
||||
|
||||
/** 같은 브라우저 세션에서는 지반침하 상태 코드그룹별 CMS 요청을 한 번만 수행한다. */
|
||||
const loadCodeList = async (upCd: string) => {
|
||||
const cached = cachedCodeLists.get(upCd);
|
||||
if (cached) return cached;
|
||||
|
||||
const pending = pendingCodeLists.get(upCd);
|
||||
if (pending) return pending;
|
||||
|
||||
const request = getCommonCodes(upCd)
|
||||
.then((codes) => {
|
||||
const codeList = codes.map(({ lwpoCd, lwpoCdNm }) => ({
|
||||
lwpoCd,
|
||||
lwpoCdNm,
|
||||
}));
|
||||
cachedCodeLists.set(upCd, codeList);
|
||||
return codeList;
|
||||
})
|
||||
.finally(() => {
|
||||
pendingCodeLists.delete(upCd);
|
||||
});
|
||||
|
||||
pendingCodeLists.set(upCd, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
/** 지반침하 상태의 검색값 계약은 유지하고 선택지와 표시명만 CMS 기준으로 제공한다. */
|
||||
export const useSinkholeCommonCodes = () => {
|
||||
const [restoreCodes, setRestoreCodes] = useState(
|
||||
cachedCodeLists.get(COMMON_CODE_GROUPS.restoreStatus) ?? [],
|
||||
);
|
||||
const [coordCodes, setCoordCodes] = useState(
|
||||
cachedCodeLists.get(COMMON_CODE_GROUPS.coordStatus) ?? [],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
loadCodeList(COMMON_CODE_GROUPS.restoreStatus)
|
||||
.then((codes) => {
|
||||
if (!cancelled) setRestoreCodes(codes);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("SNKHLRSTRSTTS 공통코드 조회에 실패했습니다.", error);
|
||||
});
|
||||
loadCodeList(COMMON_CODE_GROUPS.coordStatus)
|
||||
.then((codes) => {
|
||||
if (!cancelled) setCoordCodes(codes);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("SNKHLCRDSTTS 공통코드 조회에 실패했습니다.", error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const restoreStatusOptions = useMemo<StatusOption[]>(
|
||||
() =>
|
||||
restoreCodes.length > 0
|
||||
? [
|
||||
{ value: "0", label: "전체" },
|
||||
...restoreCodes.map((code) => ({
|
||||
// 백엔드는 복구상태 한글 설명값을 검색조건으로 사용하므로 코드명을 전송한다.
|
||||
value: code.lwpoCdNm,
|
||||
label: code.lwpoCdNm,
|
||||
})),
|
||||
]
|
||||
: [...SINKHOLE_RESTORE_STATUS_OPTIONS],
|
||||
[restoreCodes],
|
||||
);
|
||||
|
||||
const coordStatusOptions = useMemo<StatusOption[]>(
|
||||
() =>
|
||||
coordCodes.length > 0
|
||||
? [
|
||||
{ value: "0", label: "전체" },
|
||||
...coordCodes.map((code) => ({
|
||||
value: code.lwpoCd,
|
||||
label: code.lwpoCdNm,
|
||||
})),
|
||||
]
|
||||
: [...SINKHOLE_COORD_STATUS_OPTIONS],
|
||||
[coordCodes],
|
||||
);
|
||||
|
||||
const restoreStatusLabels = useMemo<CodeLabelMap>(() => {
|
||||
const labels: CodeLabelMap = {};
|
||||
restoreCodes.forEach((code) => {
|
||||
labels[code.lwpoCd] = code.lwpoCdNm;
|
||||
labels[code.lwpoCdNm] = code.lwpoCdNm;
|
||||
});
|
||||
return labels;
|
||||
}, [restoreCodes]);
|
||||
|
||||
const coordStatusLabels = useMemo<CodeLabelMap>(() => {
|
||||
const labels: CodeLabelMap = {};
|
||||
coordCodes.forEach((code) => {
|
||||
labels[code.lwpoCd] = code.lwpoCdNm;
|
||||
});
|
||||
return labels;
|
||||
}, [coordCodes]);
|
||||
|
||||
return {
|
||||
restoreStatusOptions,
|
||||
coordStatusOptions,
|
||||
restoreStatusLabels,
|
||||
coordStatusLabels,
|
||||
};
|
||||
};
|
||||
@@ -366,7 +366,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
areaOptions
|
||||
.filter(
|
||||
(option) =>
|
||||
draftResultFilters.ctpvCd === "ALL" ||
|
||||
draftResultFilters.ctpvCd !== "ALL" &&
|
||||
option.ctpvCd === draftResultFilters.ctpvCd,
|
||||
)
|
||||
.forEach((option) => {
|
||||
@@ -382,7 +382,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
|
||||
() =>
|
||||
areaOptions.filter(
|
||||
(option) =>
|
||||
(draftResultFilters.ctpvCd === "ALL" ||
|
||||
(draftResultFilters.ctpvCd !== "ALL" &&
|
||||
option.ctpvCd === draftResultFilters.ctpvCd) &&
|
||||
(draftResultFilters.sggCd === "ALL" ||
|
||||
option.sggCd === draftResultFilters.sggCd),
|
||||
|
||||
@@ -102,7 +102,9 @@ import { useRdiiResultMap } from "./components/rdiiResult/useRdiiResultMap";
|
||||
import SinkholeDetailPanel from "./components/sinkhole/SinkholeDetailPanel";
|
||||
import SinkholeLayerSettingPanel from "./components/sinkhole/SinkholeLayerSettingPanel";
|
||||
import FldngHstryDetailPanel from "./components/fldngHstry/FldngHstryDetailPanel";
|
||||
import FldngHstryLayerSettingPanel from "./components/fldngHstry/FldngHstryLayerSettingPanel";
|
||||
import ExmnLayerSettingPanel from "./components/exmn/ExmnLayerSettingPanel";
|
||||
import LayerSettingCard from "./components/layerSetting/LayerSettingCard";
|
||||
import { getExmnMapLayer } from "./components/exmn/exmnMapApi";
|
||||
import {
|
||||
EMPTY_EXMN_MAP_FILTERS,
|
||||
@@ -143,14 +145,20 @@ import {
|
||||
} from "./components/sinkhole/sinkholeTypes";
|
||||
import {
|
||||
FLOOD_HISTORY_AREA_LAYER_ID,
|
||||
FLOOD_HISTORY_DEPTH_LAYER_NAME,
|
||||
FLOOD_HISTORY_DEPTH_LAYER_TYPE,
|
||||
FLOOD_HISTORY_DEPTH_LAYER_ID,
|
||||
FLOOD_HISTORY_PREVIEW_LAYER_TYPE,
|
||||
FLOOD_HISTORY_LAYER_TYPE,
|
||||
FLOOD_HISTORY_LAYER_NAME,
|
||||
FLOOD_HISTORY_POPUP_KEY,
|
||||
FLDNG_HSTRY_ROW_SELECT_MESSAGE,
|
||||
isFldngHstryNode,
|
||||
buildFloodHistoryDefaultYearRange,
|
||||
buildFloodHistoryYearCql,
|
||||
isFloodHistoryDepthNode,
|
||||
isFloodHistoryWaterLevelNode,
|
||||
isFldngHstryRootNode,
|
||||
type FloodHistoryYearRange,
|
||||
type FldngHstryItem,
|
||||
} from "./components/fldngHstry/fldngHstryTypes";
|
||||
import {
|
||||
@@ -186,6 +194,8 @@ const AGED_PIPE_HIGHLIGHT_LAYER_ID = "SN_AGED_PIPE_HIGHLIGHT";
|
||||
// 노후관로 지도 강조선과 시설물 트리의 선형 범례가 동일하게 사용하는 빨간색이다.
|
||||
const AGED_PIPE_HIGHLIGHT_COLOR = "rgba(235, 40, 40, 0.82)";
|
||||
const FLDNG_HSTRY_WFS_LAYER_ID = "SN_FLDNG_HSTRY_WFS";
|
||||
// 심선은 동일 레이어 유형에 6개 깊이 등급 설정이 있어 prefix 뒤에 lyrSn을 붙여 각각 생성한다.
|
||||
const FLDNG_DEPTH_WFS_LAYER_ID_PREFIX = "SN_FLDNG_DEPTH_WFS";
|
||||
const AGED_PIPE_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT";
|
||||
const AGED_PIPE_POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE";
|
||||
const AGED_PIPE_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC";
|
||||
@@ -3004,7 +3014,48 @@ const Map = () => {
|
||||
const [checkedLayerLegendVersion, setCheckedLayerLegendVersion] = useState(0);
|
||||
const [isAgedPipeChecked, setIsAgedPipeChecked] = useState(false);
|
||||
const [isSinkholeChecked, setIsSinkholeChecked] = useState(false);
|
||||
const [isSinkholeSettingEnabled, setIsSinkholeSettingEnabled] =
|
||||
useState(true);
|
||||
const [isSinkholeSettingVisible, setIsSinkholeSettingVisible] =
|
||||
useState(true);
|
||||
const [isAgedPipeSettingEnabled, setIsAgedPipeSettingEnabled] =
|
||||
useState(true);
|
||||
const [isAgedPipeSettingVisible, setIsAgedPipeSettingVisible] =
|
||||
useState(true);
|
||||
const [isExmnPplSettingEnabled, setIsExmnPplSettingEnabled] = useState(true);
|
||||
const [isExmnPplSettingVisible, setIsExmnPplSettingVisible] = useState(true);
|
||||
const [isExmnMnhlSettingEnabled, setIsExmnMnhlSettingEnabled] =
|
||||
useState(true);
|
||||
const [isExmnMnhlSettingVisible, setIsExmnMnhlSettingVisible] =
|
||||
useState(true);
|
||||
const [isWaterDepthLegendEnabled, setIsWaterDepthLegendEnabled] =
|
||||
useState(true);
|
||||
const [isWaterDepthLegendVisible, setIsWaterDepthLegendVisible] =
|
||||
useState(true);
|
||||
const [isFldngHstryChecked, setIsFldngHstryChecked] = useState(false);
|
||||
// 침수흔적위선과 심선은 하나의 연도 범위를 공유하지만 체크 상태는 독립적으로 관리한다.
|
||||
const [isFloodWtlvChecked, setIsFloodWtlvChecked] = useState(false);
|
||||
const [isFloodDepthChecked, setIsFloodDepthChecked] = useState(false);
|
||||
// 심선은 6개 깊이 등급 중 사용자가 실제 체크한 lyrSn만 필터 레이어로 생성한다.
|
||||
const [checkedFloodDepthLayerKeys, setCheckedFloodDepthLayerKeys] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [floodYearRange, setFloodYearRange] = useState<FloodHistoryYearRange>(
|
||||
() => buildFloodHistoryDefaultYearRange(),
|
||||
);
|
||||
const [isFloodYearFilterEnabled, setIsFloodYearFilterEnabled] =
|
||||
useState(true);
|
||||
const [isFloodYearSettingVisible, setIsFloodYearSettingVisible] =
|
||||
useState(true);
|
||||
// 닫힌 설정 카드는 레이어가 완전히 해제된 뒤 다시 체크될 때만 자동으로 연다.
|
||||
const floodLayerCheckedRef = useRef(false);
|
||||
const sinkholeLayerCheckedRef = useRef(false);
|
||||
const agedPipeLayerCheckedRef = useRef(false);
|
||||
const exmnPplLayerCheckedRef = useRef(false);
|
||||
const exmnMnhlLayerCheckedRef = useRef(false);
|
||||
const waterDepthLayerCheckedRef = useRef(false);
|
||||
// 체크 등급 변경과 연도 필터 변경을 구분해 연도 변경 때만 기존 선택 상세를 정리한다.
|
||||
const floodYearFilterSignatureRef = useRef("");
|
||||
const [isExmnPplChecked, setIsExmnPplChecked] = useState(false);
|
||||
const [isExmnMnhlChecked, setIsExmnMnhlChecked] = useState(false);
|
||||
const [exmnPplFilters, setExmnPplFilters] = useState<ExmnMapFilters>(() => ({
|
||||
@@ -3463,7 +3514,10 @@ const Map = () => {
|
||||
selectedWaterRiskMapView?.riskStatus ??
|
||||
selectedWaterRiskResultRow?.riskStatus ??
|
||||
"NORMAL",
|
||||
[selectedWaterRiskMapView?.riskStatus, selectedWaterRiskResultRow?.riskStatus],
|
||||
[
|
||||
selectedWaterRiskMapView?.riskStatus,
|
||||
selectedWaterRiskResultRow?.riskStatus,
|
||||
],
|
||||
);
|
||||
|
||||
/** 선택 영역 오른쪽에 중점관리지역 강우·침수 요약카드를 배치한다. */
|
||||
@@ -3521,7 +3575,8 @@ const Map = () => {
|
||||
const loadFloodPredictionGridLayer = useCallback(
|
||||
async (sggCd: string, analysisDateTime: string, extent: number[]) => {
|
||||
const map = gisMaps.current;
|
||||
if (!map || !/^\d{5}$/.test(sggCd) || !/^\d{12}$/.test(analysisDateTime)) return;
|
||||
if (!map || !/^\d{5}$/.test(sggCd) || !/^\d{12}$/.test(analysisDateTime))
|
||||
return;
|
||||
removeFloodPredictionGridLayer();
|
||||
try {
|
||||
const response = await getRawJson(
|
||||
@@ -3529,29 +3584,74 @@ const Map = () => {
|
||||
);
|
||||
const payload = response?.data ?? response;
|
||||
const row = payload && !Array.isArray(payload) ? payload : null;
|
||||
if (!row || extent.length !== 4 || extent.some((value) => !Number.isFinite(value))) return;
|
||||
if (
|
||||
!row ||
|
||||
extent.length !== 4 ||
|
||||
extent.some((value) => !Number.isFinite(value))
|
||||
)
|
||||
return;
|
||||
const center = [
|
||||
(extent[0]! + extent[2]!) / 2,
|
||||
(extent[1]! + extent[3]!) / 2,
|
||||
];
|
||||
const observed = Number(row.observedAccumulatedRainfall ?? 0).toFixed(1);
|
||||
const predicted = Number(row.predictedAccumulatedRainfall ?? 0).toFixed(1);
|
||||
const observed = Number(row.observedAccumulatedRainfall ?? 0).toFixed(
|
||||
1,
|
||||
);
|
||||
const predicted = Number(row.predictedAccumulatedRainfall ?? 0).toFixed(
|
||||
1,
|
||||
);
|
||||
const container = document.createElement("div");
|
||||
container.style.cssText = "position:relative; background:rgba(255,255,255,.55); color:#111827; border:1px solid rgba(17,24,39,.7); box-shadow:0 2px 8px rgba(0,0,0,.2); padding:8px 10px; font:400 11px/1.35 sans-serif; min-width:230px;";
|
||||
container.style.cssText =
|
||||
"position:relative; background:rgba(255,255,255,.55); color:#111827; border:1px solid rgba(17,24,39,.7); box-shadow:0 2px 8px rgba(0,0,0,.2); padding:8px 10px; font:400 11px/1.35 sans-serif; min-width:230px;";
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.textContent = "×";
|
||||
close.title = "침수예측 오버레이 닫기";
|
||||
close.style.cssText = "position:absolute; right:3px; top:1px; border:0; background:transparent; color:#111827; font-size:16px; line-height:16px; cursor:pointer;";
|
||||
close.style.cssText =
|
||||
"position:absolute; right:3px; top:1px; border:0; background:transparent; color:#111827; font-size:16px; line-height:16px; cursor:pointer;";
|
||||
close.addEventListener("click", () => removeFloodPredictionGridLayer());
|
||||
const table = document.createElement("table");
|
||||
table.style.cssText = "border-collapse:collapse; width:100%;";
|
||||
const title = table.createTHead().insertRow();
|
||||
const titleCell = title.insertCell(); titleCell.colSpan = 2; titleCell.textContent = `${row.ctpvNm ?? row.ctpvCd ?? "-"} ${row.sggNm ?? row.sggCd ?? "-"} 침수예측`; titleCell.style.cssText = "font-weight:500; text-align:center; padding:2px 16px 6px 2px; border-bottom:1px solid #9ca3af;";
|
||||
const titleCell = title.insertCell();
|
||||
titleCell.colSpan = 2;
|
||||
titleCell.textContent = `${row.ctpvNm ?? row.ctpvCd ?? "-"} ${row.sggNm ?? row.sggCd ?? "-"} 침수예측`;
|
||||
titleCell.style.cssText =
|
||||
"font-weight:500; text-align:center; padding:2px 16px 6px 2px; border-bottom:1px solid #9ca3af;";
|
||||
const body = table.createTBody();
|
||||
[["분석시각", formatFloodPredictionDateTime(row.analysisDateTime ?? analysisDateTime)], ["강우시작시각", formatFloodPredictionDateTime(row.rainBeginningDateTime)], ["관측 누적강수량(mm)", observed], ["예측시각", formatFloodPredictionDateTime(row.predictionDateTime)], ["예측 누적강수량(mm)", predicted], ["초과 빈도", `${row.exceededFrequencyYear ?? "-"}년`], ["침수위험도", row.floodRiskGrade ?? "-"]].forEach(([label, value]) => { const tr = body.insertRow(); const key = tr.insertCell(); key.textContent = label; key.style.cssText = "font-weight:400; padding:2px 8px 2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;"; const val = tr.insertCell(); val.textContent = value; val.style.cssText = "font-weight:400; text-align:right; padding:2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;"; });
|
||||
[
|
||||
[
|
||||
"분석시각",
|
||||
formatFloodPredictionDateTime(
|
||||
row.analysisDateTime ?? analysisDateTime,
|
||||
),
|
||||
],
|
||||
[
|
||||
"강우시작시각",
|
||||
formatFloodPredictionDateTime(row.rainBeginningDateTime),
|
||||
],
|
||||
["관측 누적강수량(mm)", observed],
|
||||
["예측시각", formatFloodPredictionDateTime(row.predictionDateTime)],
|
||||
["예측 누적강수량(mm)", predicted],
|
||||
["초과 빈도", `${row.exceededFrequencyYear ?? "-"}년`],
|
||||
["침수위험도", row.floodRiskGrade ?? "-"],
|
||||
].forEach(([label, value]) => {
|
||||
const tr = body.insertRow();
|
||||
const key = tr.insertCell();
|
||||
key.textContent = label;
|
||||
key.style.cssText =
|
||||
"font-weight:400; padding:2px 8px 2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;";
|
||||
const val = tr.insertCell();
|
||||
val.textContent = value;
|
||||
val.style.cssText =
|
||||
"font-weight:400; text-align:right; padding:2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;";
|
||||
});
|
||||
container.append(close, table);
|
||||
const overlay = new Overlay({ element: container, positioning: "center-center", stopEvent: true });
|
||||
const overlay = new Overlay({
|
||||
element: container,
|
||||
positioning: "center-center",
|
||||
stopEvent: true,
|
||||
});
|
||||
overlay.set("id", FLOOD_PREDICTION_GRID_LAYER_ID);
|
||||
overlay.setPosition(center);
|
||||
map.addOverlay(overlay);
|
||||
@@ -3606,12 +3706,30 @@ const Map = () => {
|
||||
if (isSinkholePopupFilterActive) {
|
||||
return normalizeSinkholeFilters(currentFilters, sinkholeScope);
|
||||
}
|
||||
if (!isSinkholeSettingEnabled) {
|
||||
return normalizeSinkholeFilters(
|
||||
{
|
||||
...buildSinkholeFiltersFromYearRange(
|
||||
sinkholeYearRange,
|
||||
sinkholeScope,
|
||||
),
|
||||
startYmd: "",
|
||||
endYmd: "",
|
||||
},
|
||||
sinkholeScope,
|
||||
);
|
||||
}
|
||||
return normalizeSinkholeFilters(
|
||||
buildSinkholeFiltersFromYearRange(sinkholeYearRange, sinkholeScope),
|
||||
sinkholeScope,
|
||||
);
|
||||
});
|
||||
}, [isSinkholePopupFilterActive, sinkholeScope, sinkholeYearRange]);
|
||||
}, [
|
||||
isSinkholePopupFilterActive,
|
||||
isSinkholeSettingEnabled,
|
||||
sinkholeScope,
|
||||
sinkholeYearRange,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
@@ -3682,7 +3800,9 @@ const Map = () => {
|
||||
: [];
|
||||
const sggCd = String(data.payload?.sggCd ?? "").trim();
|
||||
const frequency = String(data.payload?.sbmrgFrqExpln ?? "").trim();
|
||||
const analysisDateTime = String(data.payload?.analysisDateTime ?? "").trim();
|
||||
const analysisDateTime = String(
|
||||
data.payload?.analysisDateTime ?? "",
|
||||
).trim();
|
||||
const map = gisMaps.current;
|
||||
const select = GisApp.Module.select as any;
|
||||
if (
|
||||
@@ -5218,6 +5338,30 @@ const Map = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 연계 심선/위선 조회는 공용 search-layer를 교체한다. 이때 탭에 보관된
|
||||
// 기존 침수흔적 feature는 더 이상 지도 레이어에 속하지 않으므로, 단순히
|
||||
// Select 컬렉션에 다시 넣어도 강조가 렌더링되지 않는다. 탭으로 돌아올
|
||||
// 때는 FID로 search-layer를 복원한 뒤 로드된 feature를 선택하게 한다.
|
||||
if (
|
||||
layerTypeName === FLOOD_HISTORY_LAYER_TYPE ||
|
||||
layerTypeName === "tb_ms_fldng_dpth_map"
|
||||
) {
|
||||
clearSinkholeFocusedPipeLayer();
|
||||
const featureIdentifier = getRightPanelFeatureIdentifier(data).replace(
|
||||
new RegExp(`^${layerTypeName}\\.`),
|
||||
"",
|
||||
);
|
||||
if (featureIdentifier) {
|
||||
const core = GisApp.Module.layer as any;
|
||||
void core?.showSearchResultsFromGeoJSON?.(
|
||||
layerTypeName,
|
||||
`${layerTypeName}.${featureIdentifier}`,
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data || typeof data.getGeometry !== "function") {
|
||||
clearSinkholeFocusedPipeLayer();
|
||||
|
||||
@@ -5900,6 +6044,22 @@ const Map = () => {
|
||||
if (!tabId) return;
|
||||
const closingTab = rightPanelTabs.find((tab) => tab.id === tabId);
|
||||
const remainingTabs = removeRightPanelTabGroup(rightPanelTabs, tabId);
|
||||
const closingLayerTypeName = getSelectedFeatureLayerTypeName(
|
||||
closingTab?.featureData,
|
||||
);
|
||||
|
||||
// 침수흔적 연계 조회로 만든 공용 search-layer는 선택 해제만으로
|
||||
// 제거되지 않는다. 마지막 탭을 닫는 경우와 다른 일반 feature 탭으로
|
||||
// 전환되는 경우 모두 잔상으로 남지 않도록 여기서 명시적으로 제거한다.
|
||||
if (
|
||||
closingLayerTypeName === FLOOD_HISTORY_LAYER_TYPE ||
|
||||
closingLayerTypeName === "tb_ms_fldng_dpth_map"
|
||||
) {
|
||||
const layerModule = GisApp.Module.layer as any;
|
||||
if (layerModule?.searchLayer) {
|
||||
layerModule.clearSearchLayer?.();
|
||||
}
|
||||
}
|
||||
|
||||
if (closingTab?.kind === "sinkhole") {
|
||||
setSelectedSinkholeDetail(null);
|
||||
@@ -6107,18 +6267,24 @@ const Map = () => {
|
||||
try {
|
||||
setIsAgedPipeListLoading(true);
|
||||
setAgedPipeDownloadProgressText("Excel 파일을 준비하고 있습니다.");
|
||||
await downloadAgedPipeExcel(appliedAgedPipeFilters, (job) => {
|
||||
if (job.status === "QUEUED") {
|
||||
setAgedPipeDownloadProgressText("Excel 다운로드 작업이 대기 중입니다.");
|
||||
return;
|
||||
}
|
||||
await downloadAgedPipeExcel(
|
||||
appliedAgedPipeFilters,
|
||||
(job) => {
|
||||
if (job.status === "QUEUED") {
|
||||
setAgedPipeDownloadProgressText(
|
||||
"Excel 다운로드 작업이 대기 중입니다.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.status === "RUNNING") {
|
||||
setAgedPipeDownloadProgressText(
|
||||
`Excel 파일 생성 중입니다. (${job.progressPercent ?? 0}%)`,
|
||||
);
|
||||
}
|
||||
}, agedPipeTotalCount);
|
||||
if (job.status === "RUNNING") {
|
||||
setAgedPipeDownloadProgressText(
|
||||
`Excel 파일 생성 중입니다. (${job.progressPercent ?? 0}%)`,
|
||||
);
|
||||
}
|
||||
},
|
||||
agedPipeTotalCount,
|
||||
);
|
||||
setAgedPipeDownloadProgressText("");
|
||||
} catch (error) {
|
||||
console.error("노후관로 목록 다운로드 중 오류:", error);
|
||||
@@ -6302,17 +6468,52 @@ const Map = () => {
|
||||
const waterDepthChecked = checkedNodesData.some((node) =>
|
||||
WATER_DEPTH_LAYER_TYPES.includes(String(node?.data?.lyrTyNm ?? "")),
|
||||
);
|
||||
if (agedPipeChecked && !agedPipeLayerCheckedRef.current) {
|
||||
setIsAgedPipeSettingVisible(true);
|
||||
}
|
||||
if (waterDepthChecked && !waterDepthLayerCheckedRef.current) {
|
||||
setIsWaterDepthLegendVisible(true);
|
||||
}
|
||||
agedPipeLayerCheckedRef.current = agedPipeChecked;
|
||||
waterDepthLayerCheckedRef.current = waterDepthChecked;
|
||||
setIsAgedPipeChecked(agedPipeChecked);
|
||||
const fldngHstryChecked = checkedNodesData.some((node) =>
|
||||
isFldngHstryNode(node),
|
||||
const floodWtlvChecked = checkedNodesData.some((node) =>
|
||||
isFloodHistoryWaterLevelNode(node),
|
||||
);
|
||||
const checkedFloodDepthNodes = checkedNodesData.filter((node) =>
|
||||
isFloodHistoryDepthNode(node),
|
||||
);
|
||||
const checkedDepthLayerKeys = Array.from(
|
||||
new Set(
|
||||
checkedFloodDepthNodes
|
||||
.map((node) => String(node?.data?.lyrSn ?? node?.id ?? "").trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
).sort((left, right) => Number(left) - Number(right));
|
||||
const floodDepthChecked = checkedDepthLayerKeys.length > 0;
|
||||
const fldngHstryChecked = floodWtlvChecked || floodDepthChecked;
|
||||
setIsFloodWtlvChecked(floodWtlvChecked);
|
||||
setIsFloodDepthChecked(floodDepthChecked);
|
||||
setCheckedFloodDepthLayerKeys((currentKeys) =>
|
||||
currentKeys.join(",") === checkedDepthLayerKeys.join(",")
|
||||
? currentKeys
|
||||
: checkedDepthLayerKeys,
|
||||
);
|
||||
setIsFldngHstryChecked(fldngHstryChecked);
|
||||
if (fldngHstryChecked && !floodLayerCheckedRef.current) {
|
||||
setIsFloodYearSettingVisible(true);
|
||||
}
|
||||
floodLayerCheckedRef.current = fldngHstryChecked;
|
||||
if (!fldngHstryChecked) {
|
||||
clearFldngHstrySelection();
|
||||
}
|
||||
const sinkholeChecked = checkedNodesData.some((node) =>
|
||||
isSinkholeLayerNode(node),
|
||||
);
|
||||
if (sinkholeChecked && !sinkholeLayerCheckedRef.current) {
|
||||
setIsSinkholeSettingVisible(true);
|
||||
}
|
||||
sinkholeLayerCheckedRef.current = sinkholeChecked;
|
||||
setIsSinkholeChecked(sinkholeChecked);
|
||||
const exmnPplChecked = checkedNodesData.some((node) =>
|
||||
isExmnResultPplNode(node),
|
||||
@@ -6320,11 +6521,20 @@ const Map = () => {
|
||||
const exmnMnhlChecked = checkedNodesData.some((node) =>
|
||||
isExmnResultMnhlNode(node),
|
||||
);
|
||||
if (exmnPplChecked && !exmnPplLayerCheckedRef.current) {
|
||||
setIsExmnPplSettingVisible(true);
|
||||
}
|
||||
if (exmnMnhlChecked && !exmnMnhlLayerCheckedRef.current) {
|
||||
setIsExmnMnhlSettingVisible(true);
|
||||
}
|
||||
exmnPplLayerCheckedRef.current = exmnPplChecked;
|
||||
exmnMnhlLayerCheckedRef.current = exmnMnhlChecked;
|
||||
setIsExmnPplChecked(exmnPplChecked);
|
||||
setIsExmnMnhlChecked(exmnMnhlChecked);
|
||||
setActiveLayerSettingOrder((currentOrder) => {
|
||||
const activeKeys = [
|
||||
waterDepthChecked ? "waterDepthLegend" : null,
|
||||
fldngHstryChecked ? "fldngHstry" : null,
|
||||
sinkholeChecked ? "sinkhole" : null,
|
||||
agedPipeChecked ? "agedPipe" : null,
|
||||
exmnPplChecked ? "exmnPpl" : null,
|
||||
@@ -6374,15 +6584,9 @@ const Map = () => {
|
||||
);
|
||||
setSinkholeYearRange(normalizedRange);
|
||||
setIsSinkholePopupFilterActive(false);
|
||||
setAppliedSinkholeFilters(
|
||||
normalizeSinkholeFilters(
|
||||
buildSinkholeFiltersFromYearRange(normalizedRange, sinkholeScope),
|
||||
sinkholeScope,
|
||||
),
|
||||
);
|
||||
setSinkholeSelectionVersion((version) => version + 1);
|
||||
},
|
||||
[sinkholeScope, sinkholeYearRange],
|
||||
[sinkholeYearRange],
|
||||
);
|
||||
|
||||
/** 침수위험지도 루트의 목록보기 버튼에서 전용 조회 팝업을 연다. */
|
||||
@@ -6452,7 +6656,7 @@ const Map = () => {
|
||||
return index >= 0 ? index : fallback;
|
||||
};
|
||||
|
||||
if (isExmnPplChecked) {
|
||||
if (isExmnPplChecked && isExmnPplSettingVisible) {
|
||||
nextItems.push({
|
||||
key: "exmnPpl",
|
||||
label: "관로조사",
|
||||
@@ -6462,12 +6666,15 @@ const Map = () => {
|
||||
jobType="PPL"
|
||||
filters={exmnPplFilters}
|
||||
onFiltersChange={setExmnPplFilters}
|
||||
enabled={isExmnPplSettingEnabled}
|
||||
onEnabledChange={setIsExmnPplSettingEnabled}
|
||||
onClose={() => setIsExmnPplSettingVisible(false)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (isExmnMnhlChecked) {
|
||||
if (isExmnMnhlChecked && isExmnMnhlSettingVisible) {
|
||||
nextItems.push({
|
||||
key: "exmnMnhl",
|
||||
label: "맨홀조사",
|
||||
@@ -6477,12 +6684,32 @@ const Map = () => {
|
||||
jobType="MNHL"
|
||||
filters={exmnMnhlFilters}
|
||||
onFiltersChange={setExmnMnhlFilters}
|
||||
enabled={isExmnMnhlSettingEnabled}
|
||||
onEnabledChange={setIsExmnMnhlSettingEnabled}
|
||||
onClose={() => setIsExmnMnhlSettingVisible(false)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (isSinkholeChecked) {
|
||||
if (isFldngHstryChecked && isFloodYearSettingVisible) {
|
||||
nextItems.push({
|
||||
key: "fldngHstry",
|
||||
label: "침수흔적도",
|
||||
order: resolveSettingOrder("fldngHstry", 9997),
|
||||
content: (
|
||||
<FldngHstryLayerSettingPanel
|
||||
yearRange={floodYearRange}
|
||||
enabled={isFloodYearFilterEnabled}
|
||||
onYearRangeChange={setFloodYearRange}
|
||||
onEnabledChange={setIsFloodYearFilterEnabled}
|
||||
onClose={() => setIsFloodYearSettingVisible(false)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (isSinkholeChecked && isSinkholeSettingVisible) {
|
||||
nextItems.push({
|
||||
key: "sinkhole",
|
||||
label: "지반침하정보",
|
||||
@@ -6495,13 +6722,16 @@ const Map = () => {
|
||||
onApplyYearRange={applySinkholeYearRange}
|
||||
lastSyncSummary={lastSinkholeSyncSummary}
|
||||
isAdmin={userInfo?.adminRoleYn === "Y"}
|
||||
enabled={isSinkholeSettingEnabled}
|
||||
onEnabledChange={setIsSinkholeSettingEnabled}
|
||||
onClose={() => setIsSinkholeSettingVisible(false)}
|
||||
compact
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (isAgedPipeChecked) {
|
||||
if (isAgedPipeChecked && isAgedPipeSettingVisible) {
|
||||
nextItems.push({
|
||||
key: "agedPipe",
|
||||
label: "노후관로",
|
||||
@@ -6511,43 +6741,47 @@ const Map = () => {
|
||||
filters={draftAgedPipeFilters}
|
||||
onFiltersChange={setDraftAgedPipeFilters}
|
||||
onApply={handleApplyAgedPipeFilters}
|
||||
enabled={isAgedPipeSettingEnabled}
|
||||
onEnabledChange={setIsAgedPipeSettingEnabled}
|
||||
onClose={() => setIsAgedPipeSettingVisible(false)}
|
||||
compact
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (isWaterDepthLayerActive) {
|
||||
if (isWaterDepthLayerActive && isWaterDepthLegendVisible) {
|
||||
nextItems.push({
|
||||
key: "waterDepthLegend",
|
||||
label: "침수심 범례",
|
||||
order: resolveSettingOrder("waterDepthLegend", 9997),
|
||||
content: (
|
||||
<div className="overflow-hidden rounded-sm border border-border bg-secondary">
|
||||
<div className="px-3 py-2">
|
||||
<div className="rounded-sm border border-border bg-background px-3 py-3">
|
||||
<div className="mb-2 bg-primary py-1 text-center text-[1.1rem] font-semibold text-primary-foreground">
|
||||
침수심 범례
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{waterDepthLegendItems.map((item) => (
|
||||
<div key={item.key} className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block h-4 w-10 shrink-0"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
border: `1px solid ${item.borderColor}`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-[1rem] font-medium text-label">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<LayerSettingCard
|
||||
title="침수심 범례"
|
||||
enabled={isWaterDepthLegendEnabled}
|
||||
onEnabledChange={setIsWaterDepthLegendEnabled}
|
||||
onClose={() => setIsWaterDepthLegendVisible(false)}
|
||||
showToggle={false}
|
||||
>
|
||||
{isWaterDepthLegendEnabled ? (
|
||||
<div className="space-y-1">
|
||||
{waterDepthLegendItems.map((item) => (
|
||||
<div key={item.key} className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block h-4 w-10 shrink-0"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
border: `1px solid ${item.borderColor}`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-[1rem] font-medium text-label">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</LayerSettingCard>
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -6563,11 +6797,25 @@ const Map = () => {
|
||||
exmnMnhlFilters,
|
||||
exmnPplFilters,
|
||||
handleApplyAgedPipeFilters,
|
||||
floodYearRange,
|
||||
isAgedPipeChecked,
|
||||
isAgedPipeSettingEnabled,
|
||||
isAgedPipeSettingVisible,
|
||||
isExmnMnhlChecked,
|
||||
isExmnMnhlSettingEnabled,
|
||||
isExmnMnhlSettingVisible,
|
||||
isExmnPplChecked,
|
||||
isExmnPplSettingEnabled,
|
||||
isExmnPplSettingVisible,
|
||||
isFldngHstryChecked,
|
||||
isFloodYearFilterEnabled,
|
||||
isFloodYearSettingVisible,
|
||||
isSinkholeChecked,
|
||||
isSinkholeSettingEnabled,
|
||||
isSinkholeSettingVisible,
|
||||
isWaterDepthLayerActive,
|
||||
isWaterDepthLegendEnabled,
|
||||
isWaterDepthLegendVisible,
|
||||
lastSinkholeSyncSummary,
|
||||
sinkholeRadiusMeters,
|
||||
sinkholeYearRange,
|
||||
@@ -6606,24 +6854,9 @@ const Map = () => {
|
||||
return (
|
||||
<div className="mt-3 flex h-[34vh] max-h-[32rem] min-h-[24rem] flex-col overflow-hidden rounded-sm border border-border bg-background shadow-[0_4px_14px_rgba(15,23,42,0.08)]">
|
||||
<div className="border-b border-border bg-secondary px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[1.2rem] font-semibold text-label">
|
||||
선택 레이어 설정
|
||||
</span>
|
||||
<span className="text-[1rem] text-muted-foreground">
|
||||
{activeLayerSettingItems.length}건
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{activeLayerSettingItems.map((item) => (
|
||||
<span
|
||||
key={item.key}
|
||||
className="rounded-full border border-border bg-background px-2 py-[1px] text-[1rem] text-label"
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[1.2rem] font-semibold text-label">
|
||||
선택 레이어 설정
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
ref={restoreActiveLayerSettingScroll}
|
||||
@@ -6650,7 +6883,7 @@ const Map = () => {
|
||||
const core = GisApp.Module.core as any;
|
||||
core?.deleteLayer?.(AGED_PIPE_HIGHLIGHT_LAYER_ID);
|
||||
|
||||
if (!isAgedPipeChecked) return;
|
||||
if (!isAgedPipeChecked || !isAgedPipeSettingEnabled) return;
|
||||
|
||||
const baseLayerCode = Object.values(GisApp.layerCode ?? {}).find(
|
||||
(item: any) =>
|
||||
@@ -6686,45 +6919,184 @@ const Map = () => {
|
||||
return () => {
|
||||
core?.deleteLayer?.(AGED_PIPE_HIGHLIGHT_LAYER_ID);
|
||||
};
|
||||
}, [appliedAgedPipeFilters, isAgedPipeChecked, mapReady]);
|
||||
}, [
|
||||
appliedAgedPipeFilters,
|
||||
isAgedPipeChecked,
|
||||
isAgedPipeSettingEnabled,
|
||||
mapReady,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapReady || !gisMaps.current) return;
|
||||
|
||||
const core = GisApp.Module.core as any;
|
||||
core?.deleteLayer?.(FLDNG_HSTRY_WFS_LAYER_ID);
|
||||
const yearCql = buildFloodHistoryYearCql(
|
||||
floodYearRange,
|
||||
isFloodYearFilterEnabled,
|
||||
);
|
||||
const yearFilterSignature = `${floodYearRange.startYear}:${floodYearRange.endYear}:${isFloodYearFilterEnabled}`;
|
||||
if (
|
||||
floodYearFilterSignatureRef.current &&
|
||||
floodYearFilterSignatureRef.current !== yearFilterSignature
|
||||
) {
|
||||
clearFldngHstrySelection();
|
||||
}
|
||||
floodYearFilterSignatureRef.current = yearFilterSignature;
|
||||
|
||||
if (!isFldngHstryChecked) return;
|
||||
|
||||
const baseLayerCode = Object.values(GisApp.layerCode ?? {}).find(
|
||||
(item: any) =>
|
||||
item?.lyrTyNm === FLOOD_HISTORY_LAYER_TYPE && item?.actlLyrYn === "Y",
|
||||
) as any;
|
||||
|
||||
if (!baseLayerCode) return;
|
||||
|
||||
const layerCode = {
|
||||
...baseLayerCode,
|
||||
id: FLDNG_HSTRY_WFS_LAYER_ID,
|
||||
lyrSn: FLDNG_HSTRY_WFS_LAYER_ID,
|
||||
lyrNm: FLOOD_HISTORY_LAYER_NAME,
|
||||
lyrTyNm: FLOOD_HISTORY_LAYER_TYPE,
|
||||
actlLyrYn: "Y",
|
||||
exprsYn: "Y",
|
||||
clickPsblYn: "Y",
|
||||
cmncNm: "bbox",
|
||||
srvcNm: "wfs",
|
||||
/** 기준 레이어 필터를 보존하면서 선택한 침수년도 조건을 결합한다. */
|
||||
const resolveFloodFilter = (baseLayerCode: any) => {
|
||||
const baseFilter = String(baseLayerCode?.flterSql ?? "").trim();
|
||||
return [baseFilter && `(${baseFilter})`, yearCql]
|
||||
.filter(Boolean)
|
||||
.join(" AND ");
|
||||
};
|
||||
|
||||
const layer = core?.addWfsLayer?.(layerCode);
|
||||
layer?.set?.("id", FLDNG_HSTRY_WFS_LAYER_ID);
|
||||
layer?.set?.("lyrNm", FLOOD_HISTORY_LAYER_NAME);
|
||||
layer?.set?.("lyrTyNm", FLOOD_HISTORY_LAYER_TYPE);
|
||||
type FloodLayerSpec = {
|
||||
id: string;
|
||||
layerType: string;
|
||||
layerName: string;
|
||||
baseLayerCode: any;
|
||||
filterSql: string;
|
||||
};
|
||||
const desiredLayers: FloodLayerSpec[] = [];
|
||||
const layerCodes = Object.values(GisApp.layerCode ?? {}) as any[];
|
||||
|
||||
if (isFloodWtlvChecked) {
|
||||
const baseLayerCode = layerCodes.find(
|
||||
(item) =>
|
||||
item?.lyrTyNm === FLOOD_HISTORY_LAYER_TYPE && item?.actlLyrYn === "Y",
|
||||
);
|
||||
if (baseLayerCode) {
|
||||
desiredLayers.push({
|
||||
id: FLDNG_HSTRY_WFS_LAYER_ID,
|
||||
layerType: FLOOD_HISTORY_LAYER_TYPE,
|
||||
layerName: baseLayerCode?.lyrNm || FLOOD_HISTORY_LAYER_NAME,
|
||||
baseLayerCode,
|
||||
filterSql: resolveFloodFilter(baseLayerCode),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isFloodDepthChecked) {
|
||||
const selectedDepthLayerKeys = new Set(checkedFloodDepthLayerKeys);
|
||||
layerCodes
|
||||
.filter((item) => {
|
||||
const layerKey = String(item?.lyrSn ?? item?.id ?? "").trim();
|
||||
return (
|
||||
item?.lyrTyNm === FLOOD_HISTORY_DEPTH_LAYER_TYPE &&
|
||||
item?.actlLyrYn === "Y" &&
|
||||
selectedDepthLayerKeys.has(layerKey)
|
||||
);
|
||||
})
|
||||
.forEach((baseLayerCode) => {
|
||||
const layerKey = String(
|
||||
baseLayerCode?.lyrSn ?? baseLayerCode?.id,
|
||||
).trim();
|
||||
desiredLayers.push({
|
||||
id: `${FLDNG_DEPTH_WFS_LAYER_ID_PREFIX}_${layerKey}`,
|
||||
layerType: FLOOD_HISTORY_DEPTH_LAYER_TYPE,
|
||||
layerName: baseLayerCode?.lyrNm || FLOOD_HISTORY_DEPTH_LAYER_NAME,
|
||||
baseLayerCode,
|
||||
filterSql: resolveFloodFilter(baseLayerCode),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const desiredLayerIds = new Set(desiredLayers.map((item) => item.id));
|
||||
const mapLayers = gisMaps.current?.getLayers?.()?.getArray?.() ?? [];
|
||||
const isManagedFloodLayerId = (id: string) =>
|
||||
id === FLDNG_HSTRY_WFS_LAYER_ID ||
|
||||
id.startsWith(`${FLDNG_DEPTH_WFS_LAYER_ID_PREFIX}_`);
|
||||
|
||||
// 체크 해제된 등급만 삭제하고, 계속 체크된 등급은 지도에 그대로 유지한다.
|
||||
mapLayers.forEach((mapLayer: any) => {
|
||||
const layerId = String(mapLayer?.get?.("id") ?? "");
|
||||
if (isManagedFloodLayerId(layerId) && !desiredLayerIds.has(layerId)) {
|
||||
core?.deleteLayer?.(layerId);
|
||||
}
|
||||
});
|
||||
|
||||
desiredLayers.forEach((spec) => {
|
||||
const filterSignature = `${spec.layerType}|${spec.filterSql}`;
|
||||
// 공통 getLayerById는 ID를 Number로 비교하므로 문자열 ID인 필터 레이어를 찾지 못한다.
|
||||
// 지도 컬렉션에서 문자열 ID를 직접 비교해 기존 레이어와 WFS source를 재사용한다.
|
||||
const existingLayer = gisMaps.current
|
||||
?.getLayers?.()
|
||||
?.getArray?.()
|
||||
?.find(
|
||||
(mapLayer: any) => String(mapLayer?.get?.("id") ?? "") === spec.id,
|
||||
);
|
||||
if (existingLayer?.get?.("floodFilterSignature") === filterSignature) {
|
||||
existingLayer?.setVisible?.(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 연도나 적용 여부가 달라진 레이어만 교체하고, 동일 필터 레이어는 재사용한다.
|
||||
core?.deleteLayer?.(spec.id);
|
||||
const layerCode = {
|
||||
...spec.baseLayerCode,
|
||||
id: spec.id,
|
||||
lyrSn: spec.id,
|
||||
lyrNm: spec.layerName,
|
||||
lyrTyNm: spec.layerType,
|
||||
actlLyrYn: "Y",
|
||||
exprsYn: "Y",
|
||||
clickPsblYn: "Y",
|
||||
cmncNm: "bbox",
|
||||
srvcNm: "wfs",
|
||||
flterSql: spec.filterSql,
|
||||
};
|
||||
const layer = core?.addWfsLayer?.(layerCode);
|
||||
layer?.set?.("id", spec.id);
|
||||
layer?.set?.("lyrNm", spec.layerName);
|
||||
layer?.set?.("lyrTyNm", spec.layerType);
|
||||
layer?.set?.("floodFilterSignature", filterSignature);
|
||||
});
|
||||
|
||||
// 전용 필터 레이어가 담당하는 유형의 원본 레이어만 숨겨 중복 표출을 막는다.
|
||||
const activeFilteredTypes = new Set(
|
||||
desiredLayers.map((item) => item.layerType),
|
||||
);
|
||||
gisMaps.current
|
||||
?.getLayers?.()
|
||||
?.getArray?.()
|
||||
?.forEach((mapLayer: any) => {
|
||||
const layerId = String(mapLayer?.get?.("id") ?? "");
|
||||
const layerType = String(mapLayer?.get?.("lyrTyNm") ?? "");
|
||||
if (
|
||||
activeFilteredTypes.has(layerType) &&
|
||||
!isManagedFloodLayerId(layerId)
|
||||
) {
|
||||
mapLayer?.setVisible?.(false);
|
||||
}
|
||||
});
|
||||
}, [
|
||||
checkedFloodDepthLayerKeys,
|
||||
clearFldngHstrySelection,
|
||||
floodYearRange,
|
||||
isFldngHstryChecked,
|
||||
isFloodDepthChecked,
|
||||
isFloodWtlvChecked,
|
||||
isFloodYearFilterEnabled,
|
||||
mapReady,
|
||||
]);
|
||||
|
||||
// 페이지가 닫힐 때만 관리 대상 필터 레이어 전체를 정리한다.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
core?.deleteLayer?.(FLDNG_HSTRY_WFS_LAYER_ID);
|
||||
const map = gisMaps.current;
|
||||
const layers = [...(map?.getLayers?.()?.getArray?.() ?? [])];
|
||||
layers.forEach((layer: any) => {
|
||||
const layerId = String(layer?.get?.("id") ?? "");
|
||||
if (
|
||||
layerId === FLDNG_HSTRY_WFS_LAYER_ID ||
|
||||
layerId.startsWith(`${FLDNG_DEPTH_WFS_LAYER_ID_PREFIX}_`)
|
||||
) {
|
||||
map?.removeLayer?.(layer);
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [isFldngHstryChecked, mapReady]);
|
||||
}, []);
|
||||
|
||||
const openAnalysisPopup = useCallback(
|
||||
(popupKey: WindowPopupKey) => {
|
||||
@@ -8735,10 +9107,14 @@ const Map = () => {
|
||||
removeExmnResultLayer("PPL");
|
||||
return;
|
||||
}
|
||||
void loadExmnResultLayer("PPL", exmnPplFilters);
|
||||
void loadExmnResultLayer(
|
||||
"PPL",
|
||||
isExmnPplSettingEnabled ? exmnPplFilters : EMPTY_EXMN_MAP_FILTERS,
|
||||
);
|
||||
}, [
|
||||
exmnPplFilters,
|
||||
isExmnPplChecked,
|
||||
isExmnPplSettingEnabled,
|
||||
loadExmnResultLayer,
|
||||
mapReady,
|
||||
removeExmnResultLayer,
|
||||
@@ -8749,10 +9125,14 @@ const Map = () => {
|
||||
removeExmnResultLayer("MNHL");
|
||||
return;
|
||||
}
|
||||
void loadExmnResultLayer("MNHL", exmnMnhlFilters);
|
||||
void loadExmnResultLayer(
|
||||
"MNHL",
|
||||
isExmnMnhlSettingEnabled ? exmnMnhlFilters : EMPTY_EXMN_MAP_FILTERS,
|
||||
);
|
||||
}, [
|
||||
exmnMnhlFilters,
|
||||
isExmnMnhlChecked,
|
||||
isExmnMnhlSettingEnabled,
|
||||
loadExmnResultLayer,
|
||||
mapReady,
|
||||
removeExmnResultLayer,
|
||||
@@ -8896,15 +9276,11 @@ const Map = () => {
|
||||
: "bg-[#e8f6f4] text-[#0f766e]"
|
||||
}`}
|
||||
>
|
||||
{buildWaterRiskStatusLabel(
|
||||
selectedWaterRiskDisplayStatus,
|
||||
)}
|
||||
{buildWaterRiskStatusLabel(selectedWaterRiskDisplayStatus)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-background px-[9px] py-[6px] text-[9px] text-muted-foreground">
|
||||
<div>
|
||||
GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면
|
||||
</div>
|
||||
<div>GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면</div>
|
||||
<div>
|
||||
<span className="font-bold text-[#ff1c15]">위험</span>, 없으면{" "}
|
||||
<span className="font-bold text-[#1677ff]">정상</span>
|
||||
|
||||
@@ -55,7 +55,7 @@ const BasicTab: React.FC = () => {
|
||||
selectContentClass="z-[101]"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-[1_1_60rem]">
|
||||
<div className="contents">
|
||||
{basicType === "tb_ms_swage_ppl" && <SwagePpl />} {/* 하수관로 */}
|
||||
{basicType === "tb_il_swage_prcs_fclt" && <SwagePrcsFclt />}{" "}
|
||||
{/** 하수처리시설 */}
|
||||
|
||||
@@ -23,8 +23,8 @@ const FldngWtlvMap: React.FC = () => {
|
||||
const { handleSearch, handleResetSearch } = useSearchModal();
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
|
||||
<div className="contents">
|
||||
<div className="contents">
|
||||
<CtpvSggPrcsFcltSelectBox
|
||||
sido={ctpvCd}
|
||||
sigungu={sggCd}
|
||||
|
||||
@@ -53,8 +53,8 @@ const Stofcbr: React.FC = () => {
|
||||
const { handleSearch, handleResetSearch } = useSearchModal();
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
|
||||
<div className="contents">
|
||||
<div className="contents">
|
||||
{/* 시도, 시군구 */}
|
||||
<CtpvSggPrcsFcltSelectBox
|
||||
sido={ctpvCd}
|
||||
|
||||
@@ -77,8 +77,8 @@ const SwageMhl: React.FC = () => {
|
||||
const { handleSearch, handleResetSearch } = useSearchModal();
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
|
||||
<div className="contents">
|
||||
<div className="contents">
|
||||
<CtpvSggPrcsFcltSelectBox
|
||||
sido={ctpvCd}
|
||||
sigungu={sggCd}
|
||||
|
||||
@@ -78,8 +78,8 @@ const SwagePmstn: React.FC = () => {
|
||||
const { handleSearch, handleResetSearch } = useSearchModal();
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
|
||||
<div className="contents">
|
||||
<div className="contents">
|
||||
{/* 시도, 시군구 */}
|
||||
<CtpvSggPrcsFcltSelectBox
|
||||
sido={ctpvCd}
|
||||
|
||||
@@ -84,8 +84,8 @@ const SwagePpl: React.FC = () => {
|
||||
const { handleSearch, handleResetSearch } = useSearchModal();
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
|
||||
<div className="contents">
|
||||
<div className="contents">
|
||||
<CtpvSggPrcsFcltSelectBox
|
||||
sido={ctpvCd}
|
||||
sigungu={sggCd}
|
||||
|
||||
@@ -81,8 +81,8 @@ const SwagePrcsFclt: React.FC = () => {
|
||||
const { handleSearch, handleResetSearch } = useSearchModal();
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
|
||||
<div className="contents">
|
||||
<div className="contents">
|
||||
<CtpvSggPrcsFcltSelectBox
|
||||
sido={ctpvCd}
|
||||
sigungu={sggCd}
|
||||
|
||||
@@ -34,8 +34,8 @@ const swagePrcsZone: React.FC = () => {
|
||||
const { handleSearch, handleResetSearch } = useSearchModal();
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
|
||||
<div className="contents">
|
||||
<div className="contents">
|
||||
<CtpvSggPrcsFcltSelectBox
|
||||
sido={ctpvCd}
|
||||
sigungu={sggCd}
|
||||
|
||||
@@ -89,7 +89,9 @@ const hasLegendStyle = (layerInfo: any) =>
|
||||
|
||||
const normalizeLegendLabel = (label: string) => {
|
||||
const normalized = String(label).trim();
|
||||
return normalized.endsWith("미만") ? normalized : `${normalized} 미만`;
|
||||
return /(?:미만|이상)$/.test(normalized)
|
||||
? normalized
|
||||
: `${normalized} 미만`;
|
||||
};
|
||||
|
||||
export const extractWaterDepthLegendItems = (
|
||||
|
||||
@@ -485,6 +485,7 @@ const IrSetting = () => {
|
||||
key="irsetting-layout-key"
|
||||
title="분석 조건 및 자료"
|
||||
columns={2}
|
||||
wideLabels
|
||||
>
|
||||
<StandardFormField label="상수사용량 적용월" required columns={2}>
|
||||
{/* 연월 선택 라이브러리로 수정 필요 */}
|
||||
|
||||
@@ -518,6 +518,7 @@ export default function RTKForm() {
|
||||
key="form-section"
|
||||
title="분석 조건 및 자료"
|
||||
columns={2}
|
||||
wideLabels
|
||||
>
|
||||
<StandardFormField label="RTK계수 출처" required columns={2}>
|
||||
<Input {...form.register("rtkSource")} className="w-full" />
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "../lib/client";
|
||||
import { formatDredgingCapturedDate } from "../lib/date";
|
||||
import { readPhotoMetadata } from "../lib/exif";
|
||||
import { useDredgingCommonCodes } from "../lib/useDredgingCommonCodes";
|
||||
import { createDredgingClientUuid } from "../lib/uuid";
|
||||
import {
|
||||
EMPTY_PHOTO_METADATA,
|
||||
@@ -70,11 +71,6 @@ const resolvePipeMaterialName = (value: string) => {
|
||||
return pipeMaterialNameByCode.get(normalizedCode) ?? "확인불가";
|
||||
};
|
||||
|
||||
const locationSourceLabel = {
|
||||
EXIF: "사진 GPS",
|
||||
MANUAL: "지도 지정",
|
||||
} as const;
|
||||
|
||||
const formatLocalDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
@@ -92,6 +88,9 @@ const DredgingDetailPopupPage = ({
|
||||
}: Props) => {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { confirm } = useConfirm();
|
||||
// 수정 화면도 등록 화면과 같은 CMS 공통코드 표시명을 공유한다.
|
||||
const { registrationModeLabels, locationSourceLabels } =
|
||||
useDredgingCommonCodes();
|
||||
const editable = mode === "edit";
|
||||
const [photos, setPhotos] = useState<DredgingPhotoPreview[]>([]);
|
||||
const [newPhotos, setNewPhotos] = useState<NewPhotoPreview[]>([]);
|
||||
@@ -265,7 +264,7 @@ const DredgingDetailPopupPage = ({
|
||||
? `${matchedPipe.installationYear}년`
|
||||
: "확인불가",
|
||||
],
|
||||
["위치등록", locationSourceLabel[locationSource]],
|
||||
["위치등록", locationSourceLabels[locationSource] || locationSource],
|
||||
];
|
||||
const pipeInfoRows = Array.from(
|
||||
{ length: Math.ceil(pipeInfo.length / 2) },
|
||||
@@ -608,7 +607,7 @@ const DredgingDetailPopupPage = ({
|
||||
onClick={() => void changeRegistrationMode("PHOTO")}
|
||||
data-testid="dredging-edit-photo-mode"
|
||||
>
|
||||
사진 기반 등록
|
||||
{registrationModeLabels.PHOTO}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -620,7 +619,7 @@ const DredgingDetailPopupPage = ({
|
||||
onClick={() => void changeRegistrationMode("MANUAL")}
|
||||
data-testid="dredging-edit-manual-mode"
|
||||
>
|
||||
사진 없이 직접 등록
|
||||
{registrationModeLabels.MANUAL}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "../lib/client";
|
||||
import { formatDredgingCapturedDate } from "../lib/date";
|
||||
import { readPhotoMetadata } from "../lib/exif";
|
||||
import { useDredgingCommonCodes } from "../lib/useDredgingCommonCodes";
|
||||
import { createDredgingClientUuid } from "../lib/uuid";
|
||||
import {
|
||||
EMPTY_PHOTO_METADATA,
|
||||
@@ -74,14 +75,6 @@ const getResolvedLocation = (group: DredgingDraftGroup): GeoPoint | null => {
|
||||
);
|
||||
};
|
||||
|
||||
const statusLabel = (status: DredgingPhotoDraft["metadata"]["status"]) => {
|
||||
if (status === "COMPLETE") return "정상";
|
||||
if (status === "MISSING_DATE") return "촬영일 없음";
|
||||
if (status === "MISSING_GPS") return "GPS 없음";
|
||||
if (status === "MISSING_BOTH") return "촬영일·GPS 없음";
|
||||
return "수동 입력 필요";
|
||||
};
|
||||
|
||||
const formatLocalDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
@@ -99,6 +92,12 @@ const DredgingRegisterModal = ({
|
||||
}: DredgingRegisterModalProps) => {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { confirm } = useConfirm();
|
||||
// 등록방식·위치출처·사진상태 코드값은 유지하고 CMS 코드명을 화면에 표시한다.
|
||||
const {
|
||||
registrationModeLabels,
|
||||
locationSourceLabels,
|
||||
photoStatusLabels,
|
||||
} = useDredgingCommonCodes();
|
||||
const [groups, setGroups] = useState<DredgingDraftGroup[]>([]);
|
||||
const groupsRef = useRef<DredgingDraftGroup[]>([]);
|
||||
const [activeGroupId, setActiveGroupId] = useState<string | null>(null);
|
||||
@@ -710,7 +709,7 @@ const DredgingRegisterModal = ({
|
||||
void changeRegistrationMode(activeGroup.id, "PHOTO")
|
||||
}
|
||||
>
|
||||
사진 기반 등록
|
||||
{registrationModeLabels.PHOTO}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -725,7 +724,7 @@ const DredgingRegisterModal = ({
|
||||
void changeRegistrationMode(activeGroup.id, "MANUAL")
|
||||
}
|
||||
>
|
||||
사진 없이 직접 등록
|
||||
{registrationModeLabels.MANUAL}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -797,7 +796,9 @@ const DredgingRegisterModal = ({
|
||||
: "bg-amber-100 text-amber-800"
|
||||
}`}
|
||||
>
|
||||
{statusLabel(photo.metadata.status)}
|
||||
{photoStatusLabels[
|
||||
photo.metadata.status
|
||||
] || "수동 입력 필요"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="px-2 text-center">
|
||||
@@ -900,9 +901,9 @@ const DredgingRegisterModal = ({
|
||||
{hasUnappliedLocationDraft
|
||||
? "새 위치 선택됨"
|
||||
: activeGroup.manualLocation
|
||||
? "지도 지정 위치"
|
||||
? `${locationSourceLabels.MANUAL} 위치`
|
||||
: activeResolvedLocation
|
||||
? "사진 GPS 위치"
|
||||
? `${locationSourceLabels.EXIF} 위치`
|
||||
: "위치 지정 필요"}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { getCommonCodes } from "@/components/lib/common/apiHelpers";
|
||||
|
||||
import type {
|
||||
DredgingLocationSource,
|
||||
DredgingRegistrationMode,
|
||||
ExifStatus,
|
||||
} from "./types";
|
||||
|
||||
type CodeLabelMap = Record<string, string>;
|
||||
|
||||
const COMMON_CODE_GROUPS = {
|
||||
registrationMode: "DRDGNREGMODE",
|
||||
locationSource: "DRDGNLOCSRC",
|
||||
photoStatus: "DRDGNPHOTOSTTS",
|
||||
} as const;
|
||||
|
||||
const DEFAULT_REGISTRATION_MODE_LABELS: Record<
|
||||
DredgingRegistrationMode,
|
||||
string
|
||||
> = {
|
||||
PHOTO: "사진 기반 등록",
|
||||
MANUAL: "사진 없이 직접 등록",
|
||||
};
|
||||
|
||||
const DEFAULT_LOCATION_SOURCE_LABELS: Record<DredgingLocationSource, string> = {
|
||||
EXIF: "사진 GPS",
|
||||
MANUAL: "지도 지정",
|
||||
};
|
||||
|
||||
const DEFAULT_PHOTO_STATUS_LABELS: Record<ExifStatus, string> = {
|
||||
COMPLETE: "정상",
|
||||
MISSING_DATE: "촬영일 없음",
|
||||
MISSING_GPS: "GPS 없음",
|
||||
MISSING_BOTH: "촬영일·GPS 없음",
|
||||
UNSUPPORTED: "수동 입력 필요",
|
||||
};
|
||||
|
||||
const cachedLabelMaps = new Map<string, CodeLabelMap>();
|
||||
const pendingLabelMaps = new Map<string, Promise<CodeLabelMap>>();
|
||||
|
||||
/** 같은 브라우저 세션에서는 준설 코드그룹별 CMS 요청을 한 번만 수행한다. */
|
||||
const loadCodeLabelMap = async (
|
||||
upCd: string,
|
||||
fallback: CodeLabelMap,
|
||||
): Promise<CodeLabelMap> => {
|
||||
const cached = cachedLabelMaps.get(upCd);
|
||||
if (cached) return cached;
|
||||
|
||||
const pending = pendingLabelMaps.get(upCd);
|
||||
if (pending) return pending;
|
||||
|
||||
const request = getCommonCodes(upCd)
|
||||
.then((codes) => {
|
||||
const labels = { ...fallback };
|
||||
codes.forEach((code) => {
|
||||
if (code.lwpoCd && code.lwpoCdNm) {
|
||||
labels[code.lwpoCd] = code.lwpoCdNm;
|
||||
}
|
||||
});
|
||||
cachedLabelMaps.set(upCd, labels);
|
||||
return labels;
|
||||
})
|
||||
.finally(() => {
|
||||
pendingLabelMaps.delete(upCd);
|
||||
});
|
||||
|
||||
pendingLabelMaps.set(upCd, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
/** 준설 저장 코드값은 유지하고 버튼·상세·사진상태 표시명만 CMS 기준으로 제공한다. */
|
||||
export const useDredgingCommonCodes = () => {
|
||||
const [registrationModeLabels, setRegistrationModeLabels] =
|
||||
useState<CodeLabelMap>(
|
||||
cachedLabelMaps.get(COMMON_CODE_GROUPS.registrationMode) ??
|
||||
DEFAULT_REGISTRATION_MODE_LABELS,
|
||||
);
|
||||
const [locationSourceLabels, setLocationSourceLabels] =
|
||||
useState<CodeLabelMap>(
|
||||
cachedLabelMaps.get(COMMON_CODE_GROUPS.locationSource) ??
|
||||
DEFAULT_LOCATION_SOURCE_LABELS,
|
||||
);
|
||||
const [photoStatusLabels, setPhotoStatusLabels] = useState<CodeLabelMap>(
|
||||
cachedLabelMaps.get(COMMON_CODE_GROUPS.photoStatus) ??
|
||||
DEFAULT_PHOTO_STATUS_LABELS,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const applyLabels = (
|
||||
upCd: string,
|
||||
fallback: CodeLabelMap,
|
||||
setter: (labels: CodeLabelMap) => void,
|
||||
) => {
|
||||
loadCodeLabelMap(upCd, fallback)
|
||||
.then((labels) => {
|
||||
if (!cancelled) setter(labels);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`${upCd} 공통코드 조회에 실패했습니다.`, error);
|
||||
});
|
||||
};
|
||||
|
||||
applyLabels(
|
||||
COMMON_CODE_GROUPS.registrationMode,
|
||||
DEFAULT_REGISTRATION_MODE_LABELS,
|
||||
setRegistrationModeLabels,
|
||||
);
|
||||
applyLabels(
|
||||
COMMON_CODE_GROUPS.locationSource,
|
||||
DEFAULT_LOCATION_SOURCE_LABELS,
|
||||
setLocationSourceLabels,
|
||||
);
|
||||
applyLabels(
|
||||
COMMON_CODE_GROUPS.photoStatus,
|
||||
DEFAULT_PHOTO_STATUS_LABELS,
|
||||
setPhotoStatusLabels,
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
registrationModeLabels,
|
||||
locationSourceLabels,
|
||||
photoStatusLabels,
|
||||
};
|
||||
};
|
||||
@@ -361,7 +361,7 @@ const Page = () => {
|
||||
|
||||
/** 선택한 시도 코드와 앞 두 자리가 같은 시군구만 후보로 제공한다. */
|
||||
const availableSggOptions = useMemo(() => {
|
||||
if (ctpvCd === "0") return sggRegionOptions;
|
||||
if (ctpvCd === "0") return [];
|
||||
const ctpvPrefix = ctpvCd.slice(0, 2);
|
||||
return sggRegionOptions.filter((option) =>
|
||||
String(option.val).startsWith(ctpvPrefix),
|
||||
|
||||
@@ -12,7 +12,47 @@
|
||||
* 🔗 사용 API
|
||||
* - 하위코드페이지 캐시 목록 조회: GET /api/v1/code/cache/{upCd} (cms-service)
|
||||
*/
|
||||
import { getData } from "../api";
|
||||
import { getCmsData, getData } from "../api";
|
||||
|
||||
/** CMS 공통코드 캐시 API가 반환하는 하위코드의 공통 계약이다. */
|
||||
export type CommonCodeItem = {
|
||||
lwpoCd: string;
|
||||
upCd: string;
|
||||
lwpoCdNm: string;
|
||||
unit: string;
|
||||
prntLwpoCd: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 기존 CMS 캐시 조회 URL을 유지하면서 상위코드에 속한 하위코드를 조회한다.
|
||||
* 화면이 잘못된 응답을 정상 코드 목록으로 사용하지 않도록 배열 계약을 확인한다.
|
||||
*/
|
||||
export const getCommonCodes = async (
|
||||
upCd: string,
|
||||
): Promise<CommonCodeItem[]> => {
|
||||
const normalizedUpCd = upCd.trim();
|
||||
if (!normalizedUpCd) return [];
|
||||
|
||||
const data = await getCmsData(
|
||||
`/api/v1/code/cache/${encodeURIComponent(normalizedUpCd)}`,
|
||||
);
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(`공통코드 응답 형식이 올바르지 않습니다: ${normalizedUpCd}`);
|
||||
}
|
||||
|
||||
return data
|
||||
.map((item: any) => ({
|
||||
lwpoCd: String(item?.lwpoCd ?? "").trim(),
|
||||
upCd: String(item?.upCd ?? normalizedUpCd).trim(),
|
||||
lwpoCdNm: String(item?.lwpoCdNm ?? "").trim(),
|
||||
unit: String(item?.unit ?? "").trim(),
|
||||
prntLwpoCd:
|
||||
item?.prntLwpoCd === null || item?.prntLwpoCd === undefined
|
||||
? null
|
||||
: String(item.prntLwpoCd).trim(),
|
||||
}))
|
||||
.filter((item) => item.lwpoCd.length > 0);
|
||||
};
|
||||
|
||||
export const searchData = async<T extends Record<string, any>>(url: string, paramObj: T) => {
|
||||
const params = new URLSearchParams(
|
||||
@@ -29,10 +69,10 @@ export const searchData = async<T extends Record<string, any>>(url: string, para
|
||||
|
||||
// 상위코드로 하위코드 조회
|
||||
export const searchCmmnCd = async (upCd: string) => {
|
||||
const data = await getData(`/cms-service/api/v1/code/cache/${upCd}`);
|
||||
const data = await getCommonCodes(upCd);
|
||||
const filteredData = data.map((item: any) => ({
|
||||
lwpoCd: item.lwpoCd,
|
||||
lwpoCdNm: item.lwpoCdNm,
|
||||
}));
|
||||
return filteredData;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -168,16 +168,16 @@ const FileUploadForm = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="w-full">
|
||||
{" "}
|
||||
{/* 컴포넌트 루트 div */}
|
||||
<div className="card p-6">
|
||||
<div className="card w-full p-6">
|
||||
{" "}
|
||||
{/* 카드 스타일링 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
{" "}
|
||||
{/* FileUploader와 Button을 정렬하는 메인 flex 컨테이너 */}
|
||||
<div className="mr-4 w-[300px]">
|
||||
<div className="min-w-0 flex-1">
|
||||
{" "}
|
||||
{/* FileUploader의 너비와 오른쪽 여백을 제어하는 컨테이너 */}
|
||||
<FileUploader
|
||||
|
||||
@@ -165,7 +165,9 @@ export function VerticalTable({
|
||||
<>
|
||||
{headName ? (
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="mx-3 text-2xl font-medium text-label">{headName}</h3>
|
||||
<h3 className="mx-3 min-w-0 flex-1 text-2xl font-medium text-label">
|
||||
{headName}
|
||||
</h3>
|
||||
{showSaveButton && onSaveClick && (
|
||||
<Button type="button" color="primary" onClick={onSaveClick}>
|
||||
저장
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function AgedPipeListModal({
|
||||
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) => {
|
||||
|
||||
@@ -34,9 +34,9 @@ const BasicTab: React.FC = () => {
|
||||
const { basicType, setBasicType } = useSearchModalStore();
|
||||
|
||||
return (
|
||||
<div className="relative border rounded-lg pt-5 px-5 w-full min-h-[10vh]">
|
||||
<div className="relative flex w-full min-h-[10vh] flex-wrap items-center gap-x-4 gap-y-3 rounded-lg border px-5 pt-5">
|
||||
{/* 구분 */}
|
||||
<div className="mb-3 flex items-center justify-between gap-4">
|
||||
<div className="flex shrink-0 items-center">
|
||||
<div className="flex items-center">
|
||||
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">구분</Label>
|
||||
<SelectBox
|
||||
@@ -52,7 +52,7 @@ const BasicTab: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 조회 조건 */}
|
||||
{/* 조회 조건: 구분 선택과 같은 행에 배치 */}
|
||||
{basicType === "tb_ms_swage_ppl" && (<SwagePpl />)} {/* 하수관로 */}
|
||||
{basicType === "tb_il_swage_prcs_fclt" && (<SwagePrcsFclt />)} {/* 하수처리시설 */}
|
||||
{basicType === "tb_ms_swage_prcs_zone" && (<SwagePrcsZone />)} {/* 하수처리구역 */}
|
||||
@@ -63,4 +63,4 @@ const BasicTab: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(BasicTab);
|
||||
export default React.memo(BasicTab);
|
||||
|
||||
@@ -111,6 +111,22 @@ const loadSelectOptions = async (url: string) => {
|
||||
.filter((item): item is SelectDataType => Boolean(item));
|
||||
};
|
||||
|
||||
/** ONSEMIRO 환경청 응답을 코드/환경청명 기준으로 SelectBox 옵션으로 변환한다. */
|
||||
const loadGovAreaOptions = async () => {
|
||||
const response = await getRawJson(
|
||||
"/sewerage-service/api/v1/onsemiro/addr/govArea",
|
||||
);
|
||||
return toResponseList(response)
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const { lclgvrnmntCd, lclgvrnmntNm } = item as Record<string, unknown>;
|
||||
const code = String(lclgvrnmntCd ?? "").trim();
|
||||
const name = String(lclgvrnmntNm ?? "").trim();
|
||||
return code && name ? { text: name, val: code } : null;
|
||||
})
|
||||
.filter((item): item is SelectDataType => Boolean(item));
|
||||
};
|
||||
|
||||
/** ONSEMIRO 환경청·시도·시군구 선택목록을 공유하는 상태 저장소다. */
|
||||
const useOnsemiroAddrStore = create<OnsemiroAddrStoreState>((set) => ({
|
||||
govArea: [],
|
||||
@@ -118,9 +134,7 @@ const useOnsemiroAddrStore = create<OnsemiroAddrStoreState>((set) => ({
|
||||
sgg: [],
|
||||
/** 환경청 선택목록을 조회해 한글명 순으로 저장한다. */
|
||||
fetchGovArea: async () => {
|
||||
const govArea = await loadSelectOptions(
|
||||
"/sewerage-service/api/v1/onsemiro/addr/govArea",
|
||||
);
|
||||
const govArea = await loadGovAreaOptions();
|
||||
set({
|
||||
govArea: govArea.sort((left, right) =>
|
||||
left.text.localeCompare(right.text, "ko"),
|
||||
|
||||
@@ -629,3 +629,13 @@
|
||||
| 변경 파일 ESLint | 통과 | 오류·경고 0건 |
|
||||
| `sn` TypeScript 검사 | 통과 | `tsc --noEmit`, 기존 설정값은 명령줄 `ignoreDeprecations=5.0`으로 보정 |
|
||||
| 로그인 브라우저 | 대기 | 사업등록 저장 후 두 다운로드와 수정 팝업 버튼 3개 최종 확인 |
|
||||
|
||||
## 40. 2026-09-01 조사사업 공통코드 프론트 표시 빠른 검증
|
||||
|
||||
- 실제 CMS 캐시 API 확인
|
||||
- 결함등급 5건, 기록유형 2건, 결함분류 3건, 심각도 3건, 상태그룹 4건
|
||||
- 관로 조사항목 31건, 맨홀 조사항목 23건
|
||||
- 총 71개 코드가 계획한 그룹별 건수로 조회됐다.
|
||||
- `git diff --check`는 줄바꿈 변환 경고 외 whitespace 오류 없이 통과했다.
|
||||
- 사용자 요청에 따라 전체 TypeScript 검사와 Next 빌드는 수행하지 않았다.
|
||||
- 조사 종합집계 필터, 조사 상세표·Excel, 본단위등급표와 시설 상세의 코드명 수동 검증이 남아 있다.
|
||||
|
||||
@@ -333,3 +333,13 @@
|
||||
- TypeScript compiler API의 `transpileModule`로 `page.tsx`를 TSX 파싱한 결과 error diagnostics 0건이었다.
|
||||
- 프론트 변경 파일 대상 `git diff --check`는 통과했다.
|
||||
- 로그인 세션이 필요한 실제 탭 누적·교체·복귀 상호작용은 수동 검증이 남아 있다.
|
||||
|
||||
### 2.31 2026-09-01 지반침하 상태 공통코드 적용 빠른 검증
|
||||
|
||||
- 실제 CMS 캐시 API 확인
|
||||
- `SNKHLRSTRSTTS`: `RESTORED=복구완료`, `RESTORING=복구중`, `NOT_RESTORED=미복구`
|
||||
- `SNKHLCRDSTTS`: `SUCCESS=보정성공`, `FAILED=보정실패`
|
||||
- 백엔드와 기존 데이터가 복구상태를 `복구완료/복구중/미복구` 설명값으로 처리하는 계약을 코드·기존 검증 문서에서 확인했다.
|
||||
- `git diff --check`는 줄바꿈 변환 경고 외 whitespace 오류 없이 통과했다.
|
||||
- 사용자 요청에 따라 전체 TypeScript 검사와 Next 빌드는 수행하지 않았다.
|
||||
- 실제 지반침하 목록에서 검색조건·목록·Excel·상세 표시와 조회 결과 건수의 수동 검증이 남아 있다.
|
||||
|
||||
@@ -413,3 +413,12 @@ Chrome 확장의 파일 URL 접근 권한이 꺼져 있어 이번 회귀에서 E
|
||||
- 준설 저장·삭제는 수행하지 않았고 검사에 사용한 다운로드 사본은 정리했다.
|
||||
- 화면 증빙: `COMMON_DOCS_ROOT/작업내용/SFR-010-012-준설관리/검증/20260904/03-photo-download.png` (합성 사진).
|
||||
- 사용자 실행 3002 서버는 유지했으며 Next build는 실행하지 않았다.
|
||||
## 16. 2026-09-01 준설 공통코드 적용 빠른 검증
|
||||
|
||||
- 실제 CMS 캐시 API 확인
|
||||
- `DRDGNREGMODE`: `PHOTO=사진등록`, `MANUAL=수기등록`
|
||||
- `DRDGNLOCSRC`: `EXIF=사진 EXIF`, `MANUAL=수기지정`
|
||||
- `DRDGNPHOTOSTTS`: `COMPLETE=메타정보정상`, `MISSING_BOTH=촬영일시·GPS누락`, `MISSING_DATE=촬영일시누락`, `MISSING_GPS=GPS누락`
|
||||
- `git diff --check`는 줄바꿈 변환 경고 외 whitespace 오류 없이 통과했다.
|
||||
- 사용자 요청에 따라 전체 TypeScript 검사와 Next 빌드는 수행하지 않았다.
|
||||
- 등록·수정 팝업에서 버튼명, 위치출처, 사진상태 표시와 기존 저장 흐름의 수동 검증이 남아 있다.
|
||||
|
||||
@@ -313,3 +313,13 @@
|
||||
- 오류 화면의 `목록` 클릭 시 `GIS 업로드 목록`으로 정상 복귀했다.
|
||||
- 전체 TypeScript, 대상 Prettier와 `git diff --check`를 통과했다.
|
||||
- API·DB 변경 없음. 판정: PASS.
|
||||
|
||||
## 11. 2026-09-01 GIS 관리 공통코드 적용 빠른 검증
|
||||
|
||||
- 실제 CMS 캐시 API 확인
|
||||
- `GISULDSTTS`: `COMPLETED=업로드완료`, `VALIDATION_FAILED=검증실패`, `FAILED=업로드실패`
|
||||
- `GISDWNLDSTTS`: `QUEUED=작업대기`, `RUNNING=파일생성중`, `READY=다운로드준비완료`, `FAILED=파일생성실패`
|
||||
- `SNFCLTSE`: `PPL=관로`, `MNHL=맨홀`
|
||||
- `git diff --check`는 줄바꿈 변환 경고 외 whitespace 오류 없이 통과했다.
|
||||
- 사용자 요청에 따라 전체 TypeScript 검사와 Next 빌드는 이번 단계에서 수행하지 않았다.
|
||||
- 실제 GIS 관리 팝업에서 시설구분명, 다운로드 상태 안내와 업로드 결과 fallback을 확인하는 수동 검증이 남아 있다.
|
||||
|
||||
@@ -759,3 +759,18 @@
|
||||
- 사용자가 실제 지도에서 GIS 편집 두 번째 클릭 시 하단 도구바와 인라인 세션이 함께 사라지는 것을 확인했다.
|
||||
- 다시 GIS 편집 진입 시 기존 변경분과 인라인 세션이 복원되는 것을 확인했다.
|
||||
- 결론: 추가 회귀 보정까지 사용자 수동 검증 완료.
|
||||
|
||||
### 2.32 2026-09-01 GIS 편집작업 공통코드 1차 적용 검증
|
||||
|
||||
- 실제 CMS 캐시 API 확인
|
||||
- `GISEDITOPRT`: `ADD=추가`, `MOVE=이동`, `VERTEX_EDIT=꼭지점편집`, `DELETE=삭제` 4건 확인
|
||||
- `GISEDITTYPE`: `C=등록`, `U=수정`, `D=삭제` 3건 확인
|
||||
- 전체 TypeScript 검사
|
||||
- 명령: `node apps/sn/node_modules/typescript/bin/tsc -p apps/sn/tsconfig.json --noEmit --pretty false --ignoreDeprecations 5.0`
|
||||
- 결과: 통과
|
||||
- SN 프로덕션 빌드
|
||||
- Next.js 최적화 컴파일과 정적 페이지 28개 생성까지 통과했다.
|
||||
- standalone traced file 복사 단계에서 Windows symlink `EPERM`으로 최종 종료코드 1이 발생했다.
|
||||
- TypeScript 또는 이번 변경 코드의 컴파일 오류는 아니다.
|
||||
- `git diff --check`는 줄바꿈 변환 경고 외 whitespace 오류 없이 통과했다.
|
||||
- 실제 지도 화면에서 툴바와 인라인·팝업 세션 라벨이 CMS 응답으로 표시되는지는 로컬 배포 또는 개발 서버 기동 후 수동 확인이 남아 있다.
|
||||
|
||||
@@ -24,3 +24,12 @@
|
||||
- 시트명과 B6 `순번`, D6 업무별 사업코드 헤더, B8 데이터 시작 위치를 자동 확인했다.
|
||||
- SN 개발 서버가 3002 포트에서 실행 중이어서 앱 규칙에 따라 `next build`는 실행하지 않았다.
|
||||
- 로그인·실 API 환경에서 조회조건 다운로드와 다운로드→수정→업로드 브라우저 검증은 남은 통합검증이다.
|
||||
|
||||
## 2026-09-01 운영사업 공통코드 화면 적용 빠른 검증
|
||||
|
||||
- 실제 CMS에서 사업구분 2건, 증빙상태 4건, 사업추진상태 7건을 확인했다.
|
||||
- 개발 DB에서 사업구분 `정밀조사 130건/정비사업 391건`, 추진현황의 `준공·공사중·설계중·발주중` 등 비표준 기존값, 증빙의 `O/X/공단/요청/준비중` 값을 확인했다.
|
||||
- 기존값 보존을 위해 추진·증빙 입력은 자유문자 datalist 방식으로 적용했다.
|
||||
- `git diff --check`는 whitespace 오류 없이 통과했다.
|
||||
- 사용자 요청에 따라 전체 TypeScript 검사와 Next 빌드는 수행하지 않았다.
|
||||
- 실제 목록·등록·수정에서 선택 후보와 기존값 저장 회귀 확인이 남아 있다.
|
||||
|
||||
@@ -9,3 +9,12 @@
|
||||
- `/oper/ctyFldngPrvtnBiz/register/1`: HTTP 200
|
||||
- 0811 원본 구조 확인: `사업 추진 현황` 시트, 상세 시작 `AJ/BK/CS/DX`, 결산 2013~2026년, 데이터 257행을 확인했다.
|
||||
- 실제 API·DB 저장과 브라우저 조작, 원본 파일 업로드 후 DB 대조는 테이블 및 인증 환경에서 추가 확인이 필요하다.
|
||||
|
||||
## 2026-09-01 운영사업 공통코드 화면 적용 빠른 검증
|
||||
|
||||
- 실제 CMS에서 도시침수 진행단계 4건, 지정유형 2건, 사업추진상태 7건을 확인했다.
|
||||
- 개발 DB에서 상세단계 `EM/MP/DD/CW` 각 257건, 지정유형 `우려 4건/피해 253건`을 확인했다.
|
||||
- 추진현황에는 표준 6개 값 외 `완료(연월)` 변형이 다수 있어 자유문자 datalist로 적용했다.
|
||||
- `git diff --check`는 whitespace 오류 없이 통과했다.
|
||||
- 사용자 요청에 따라 전체 TypeScript 검사와 Next 빌드는 수행하지 않았다.
|
||||
- 실제 목록·등록·수정에서 구역 제목, 입력 후보와 기존 변형값 유지 확인이 남아 있다.
|
||||
|
||||
@@ -346,3 +346,22 @@
|
||||
- 수정 진입은 `양식다운로드 → 저장 → 취소`만 표시하고 조사보고서 공통 양식 버튼은 숨긴다.
|
||||
- 수정 화면의 양식다운로드는 저장된 최신 처리분구 기준으로 입력양식을 다시 생성한다.
|
||||
- 등록된 실제 사업보고서 PDF는 기존 조사목록의 보고서 열에서 다운로드하는 계약을 유지한다.
|
||||
|
||||
## 27. 2026-09-01 지도 조사 설정 카드 UI 통일 계획
|
||||
|
||||
- `ExmnLayerSettingPanel`의 관로조사·맨홀조사 카드를 침수흔적도 설정과 동일한 header/body 구조로 변경한다.
|
||||
- 제목 오른쪽의 액션 `div`에는 `적용 여부 토글 → 닫기(×)`를 배치하고 토글 기본값은 ON으로 한다.
|
||||
- 토글 OFF에서는 해당 조사유형의 사업차수·구조·운영 필터 적용을 해제하되 입력값은 유지하며, 닫기는 카드만 숨기고 레이어 체크·필터 상태를 유지한다.
|
||||
- 두 조사유형의 적용 여부, 닫기, 입력값은 독립 상태로 관리한다.
|
||||
- 카드 제목 `1.15rem`, 본문 `1.05rem`, header `32px`, 본문 padding `8px`, 선택 입력 `22px`, font `1rem`, 좌우 padding `4px`를 적용한다.
|
||||
- 관로 노랑·맨홀 보라 의미색은 indicator로 유지하고 카드 외곽·제목은 중립 스타일로 통일한다.
|
||||
- 상태등급 select는 침수흔적도 설정의 SelectBox와 같은 font·padding·border/background 스타일을 적용한다.
|
||||
- 기존 자동반영 동작을 유지하며 원래 없는 `✓` 버튼을 새로 추가하지 않는다. 지반침하·노후관로 등 기존 `✓`가 있는 카드의 버튼은 공통 변경으로 제거하지 않는다.
|
||||
- `map/page.tsx`의 선택 레이어 설정 header에서는 활성 카드 개수와 카드명 pill/chip 목록을 제거하고 제목만 유지한다.
|
||||
- 검증은 관로·맨홀 카드 동시 표시, 독립 토글·닫기, 스타일 통일, 자동반영, 상단 개수·chip 미표시와 지도 필터 회귀를 포함한다.
|
||||
- API·DTO·DB 변경은 없다.
|
||||
|
||||
### 27.1 구현 결과
|
||||
|
||||
- `ExmnLayerSettingPanel`을 공통 카드 header·토글·닫기 구조로 변경하고 관로·맨홀 상태를 독립 관리한다.
|
||||
- 사업차수·상태등급의 기존 자동반영은 유지하며 토글 OFF에서는 빈 필터로 다시 조회한다.
|
||||
|
||||
@@ -273,3 +273,24 @@
|
||||
- 부모가 있는 관로 탭은 활성 여부와 무관하게 `←` 버튼을 항상 표시해 일반 관로와 구분하고 지반침하 상세로 복귀한다.
|
||||
- 공용 우측 탭은 최대 5개 FIFO를 적용하되, 제거 대상이 부모 지반침하이면 그 부모에서 열린 연계 관로도 함께 제거한다. 연계 관로만 제거될 때는 부모 지반침하를 유지한다.
|
||||
- 변경 범위는 `app/(map-layout)/map/page.tsx`이며 BE API·DTO·DB 계약은 변경하지 않는다.
|
||||
|
||||
## 16. 2026-09-01 선택 레이어 설정 지반침하 카드 UI 통일 계획
|
||||
|
||||
- 작업 모드: `maintenance`
|
||||
- 이번 단계는 작업계획만 수정하고 코드는 후속 승인 후 변경한다.
|
||||
- `SinkholeLayerSettingPanel`을 침수흔적도 설정 카드와 동일한 compact 카드 구조로 정리한다.
|
||||
- 헤더에 `지반침하정보` 제목을 표시하고 우측 액션 `div`에 `적용 여부 토글 → 닫기(×)` 순서로 배치한다.
|
||||
- 토글 기본값은 ON이며 OFF에서는 연도 조회조건을 제거하되 현재 연도·반경 입력값은 유지한다.
|
||||
- 닫기는 설정 카드만 숨기고 레이어와 현재 설정값은 유지한다.
|
||||
- 침수흔적도 설정과 동일하게 카드 header `32px`, 제목 `1.15rem`, 본문 `1.05rem`, 본문 padding `8px`, SelectBox `62×22px`, SelectBox font `1rem`·좌우 padding `4px`를 적용한다.
|
||||
- 주황 카드·제목 스타일은 제거하고 공통 border/background/label 색상으로 통일한다.
|
||||
- 반경과 년도설정의 `✓` 적용 버튼은 모두 유지하며 SelectBox 선택만으로 자동 적용하지 않는다.
|
||||
- `map/page.tsx`의 선택 레이어 설정 공통 header에서는 활성 카드 개수와 카드명 pill/chip 목록을 제거하고 `선택 레이어 설정` 제목만 유지한다.
|
||||
- 공통 상단 표시 제거는 지반침하·침수흔적도·조사시설·노후관로·침수심 범례 등 모든 설정 카드에 적용한다.
|
||||
- 검증은 토글/닫기 배치, 카드·SelectBox 스타일 일치, `✓` 적용 유지, 상단 개수·chip 미표시, 다중 카드 순서·스크롤 회귀를 포함한다.
|
||||
- API·DTO·DB 변경은 없다.
|
||||
|
||||
### 16.1 구현 결과
|
||||
|
||||
- `SinkholeLayerSettingPanel` compact 카드에 공통 header·토글·닫기와 침수흔적도 기준 입력 스타일을 적용했다.
|
||||
- 반경·년도 `✓` 적용 버튼은 유지했고 토글·닫기 상태는 `map/page.tsx`에서 관리한다.
|
||||
|
||||
@@ -69,3 +69,11 @@
|
||||
- 2026-08-18 목록 강수량을 소수점 한 자리로, 헤더를 `초과빈도년도`로 변경하고 값의 `년` 접미사를 제거했다.
|
||||
- 기존 `.next` 산출물을 재생성한 뒤 `pnpm --filter sn run build`를 최종 통과했다.
|
||||
- TypeScript 정적검사는 저장소 기존 `ignoreDeprecations` 설정과 실행 중 산출물 잠금 때문에 완료하지 못했고, Next 빌드는 최적화 단계 장기 대기 후 중단했다.
|
||||
|
||||
## Excel 프론트 생성 전환 결과 (2026-09-03)
|
||||
|
||||
- 현재 적용 검색조건으로 `/api/v1/fldng/predc/prst`를 500건 단위 전체 페이지 조회한다.
|
||||
- 공통 `fetchAllPages`로 `totalElements`와 실제 수집 건수 일치를 검증한다.
|
||||
- `fldng-predc-prst-excel.ts`가 화면 열 순서의 단일 시트 `침수예측현황.xlsx`를 브라우저에서 생성한다.
|
||||
- 서버 `/prst/excel` Blob 호출과 `getBlobData`, `fileDownload` 사용을 제거했다.
|
||||
- 독립 TypeScript `--noEmit` 검증을 통과했다.
|
||||
|
||||
@@ -951,3 +951,12 @@
|
||||
- 수정 진입에서는 `조사보고서 양식다운로드`를 숨기고 `양식다운로드`, `저장`, `취소`만 표시한다.
|
||||
- 처리분구를 수정한 경우 기존 계약대로 먼저 저장한 뒤 최신 처리분구 기준 입력양식을 다시 생성한다.
|
||||
- 공통 조사보고서 XLSX와 등록된 사업보고서 PDF의 다운로드 경로는 변경하지 않았다.
|
||||
|
||||
## 47. 2026-09-01 조사사업 공통코드 프론트 표시 1차 적용
|
||||
|
||||
- `EXMNDFCGRD`, `EXMNRECDTYPE`, `EXMNDFCTYPE`, `EXMNDFCSEV`, `EXMNSTTSGRP`, `PPLEXMNITEM`, `MNHLEXMNITEM`을 기존 CMS 캐시 API로 조회하고 브라우저 세션에서 그룹별 응답과 진행 중 요청을 재사용한다.
|
||||
- PPL 시설 필터의 구조·운영등급 선택지와 결함분류 표시, 조사 상세표·Excel의 결함분류·심각도·관로·맨홀 항목명에 CMS 코드명을 적용했다.
|
||||
- 본단위등급표와 PPL 시설 상세의 기록유형·결함분류·심각도·상태그룹 표시에도 같은 코드명을 적용했다.
|
||||
- 조사 코드값, 상세표 열 순서, API 파라미터와 백엔드 Excel 파서·분류 목록은 변경하지 않았다.
|
||||
- CMS 조회 실패 또는 미등록 코드에는 기존 화면 라벨과 서버 제공값을 fallback으로 유지한다.
|
||||
- 백엔드 API와 DB는 변경하지 않았다.
|
||||
|
||||
@@ -473,6 +473,7 @@
|
||||
- `SinkholePopupPage.tsx` 그리드 첫 데이터 열에 `사고번호(acdntNo)`를 배치하고 화면 `NO` 열은 제거했다. 내부 `rowNo`와 다운로드 순번은 유지했다.
|
||||
- 반경 내 관로 GeoServer 조회의 기존 storage nullable 타입 경고는 저장소 이름 확인 후 요청하도록 방어 처리했다.
|
||||
- BE API·DTO·Repository·DB 변경은 없다.
|
||||
|
||||
- 브라우저·API·빌드 검증은 사용자 요청에 따라 수행하지 않았으며 사용자 검증 대기 상태다.
|
||||
|
||||
### 4.55 2026-07-14 목록 NO 열 제거 후속 반영
|
||||
@@ -499,3 +500,13 @@
|
||||
- 부모가 있는 지반침하 연계 관로는 비활성 상태에서도 `←`를 항상 표시해 일반 관로와 구분되도록 후속 보완했다.
|
||||
- 탭 최대 5개 초과 시 가장 먼저 열린 탭을 제거하는 FIFO를 유지하고, 제거 대상이 부모 지반침하이면 그 부모를 참조하는 연계 관로 탭도 함께 제거하도록 공통 그룹 제거 함수를 적용했다. 연계 관로 단독 제거는 부모에 영향을 주지 않는다.
|
||||
- BE API·DTO·Repository·DB 변경은 없다.
|
||||
|
||||
### 4.58 2026-09-01 지반침하 상태 공통코드 적용
|
||||
|
||||
- `SNKHLRSTRSTTS`, `SNKHLCRDSTTS`를 기존 CMS 캐시 API로 조회하고 브라우저 세션에서 그룹별 응답과 진행 중 요청을 재사용한다.
|
||||
- 좌표보정상태는 기존 `SUCCESS/FAILED` 검색값을 유지하고 선택지·목록 표시명을 공통코드의 `보정성공/보정실패`로 표시한다.
|
||||
- 복구상태는 백엔드가 외부 원천의 한글 설명값을 검색·응답하므로 `RESTORED/RESTORING/NOT_RESTORED`를 API에 전송하지 않는다. CMS의 코드명을 기존 한글 검색값으로 사용해 계약을 유지한다.
|
||||
- 목록은 원본 상태 필드를 유지한 별도 표시 필드를 사용하고, Excel과 상세 패널에도 같은 CMS 코드명을 적용한다.
|
||||
- CMS에 없는 기존 좌표상태 `NO_ADDRESS`는 `주소없음` fallback을 유지한다.
|
||||
- CMS 조회 실패 시 기존 선택지와 상태 문구를 유지한다.
|
||||
- 백엔드 API, 외부 원천 데이터와 DB는 변경하지 않았다.
|
||||
|
||||
@@ -375,3 +375,11 @@
|
||||
- 변경 범위는 `DredgingDetailPopupPage.tsx`와 기능 문서다. 공통 컴포넌트·유틸리티, 백엔드 API, DB 스키마는 변경하지 않았다.
|
||||
- 근거: 원본 `SFR-010-012/013`, 화면 `SN_MN040201`의 사진 확인 흐름에 사용자가 요청한 다운로드 동작을 추가했다.
|
||||
- 롤백은 선택 사진 다운로드 핸들러·진행 상태·버튼만 제거하면 되며 앞선 사진 영역 고정 변경은 유지할 수 있다.
|
||||
## 22. 2026-09-01 준설 공통코드 표시명 적용
|
||||
|
||||
- `DRDGNREGMODE`, `DRDGNLOCSRC`, `DRDGNPHOTOSTTS`를 기존 CMS 캐시 API로 조회하고 브라우저 세션에서 그룹별 응답과 진행 중 요청을 재사용한다.
|
||||
- 등록·수정 화면의 `PHOTO/MANUAL` 버튼명, 위치출처 표시와 등록 화면 사진 메타상태명을 CMS의 `lwpoCdNm`으로 표시한다.
|
||||
- 등록방식, 위치출처, 사진상태 코드값과 저장·검증 분기는 변경하지 않았다.
|
||||
- CMS에 없는 기존 `UNSUPPORTED` 상태는 `수동 입력 필요` fallback을 유지한다.
|
||||
- CMS 조회 실패 시 기존 화면 문구를 fallback으로 사용한다.
|
||||
- 백엔드 API, 준설 저장 payload와 DB 구조는 변경하지 않았다.
|
||||
|
||||
@@ -170,3 +170,13 @@
|
||||
- 업로드 이력을 다시 조회하고 목록 화면으로 전환하는 버튼명을 `조회`에서 `목록`으로 변경했다.
|
||||
- 기본 업로드 이력 화면과 오류·미리보기 화면의 동일 액션 문구를 함께 통일했다.
|
||||
- 버튼 이벤트와 API 요청은 변경하지 않았다.
|
||||
|
||||
### 8.5 2026-09-01 GIS 관리 공통코드 1차 적용
|
||||
|
||||
- `GISULDSTTS`, `GISDWNLDSTTS`, `SNFCLTSE`를 기존 CMS 캐시 URL로 기능 진입 시 병렬 조회한다.
|
||||
- 같은 브라우저 세션에서는 그룹별 조회 결과와 진행 중 요청을 재사용해 팝업 재진입 시 중복 호출을 줄였다.
|
||||
- 업로드의 `COMPLETED/VALIDATION_FAILED/FAILED`와 다운로드의 `QUEUED/RUNNING/READY/FAILED` 분기는 그대로 유지하고 안내문 fallback에 공통코드명을 사용한다.
|
||||
- 다운로드 대기 중에는 현재 작업상태를 공통코드명으로 갱신한다.
|
||||
- 시설구분은 백엔드 지원범위 `PPL` 단일을 유지하면서 화면 표시명만 `SNFCLTSE`의 `PPL=관로`로 표시한다. `MNHL` 기능은 새로 활성화하지 않았다.
|
||||
- CMS 조회 실패 시 기존 업로드·다운로드·하수관로 문구를 fallback으로 사용한다.
|
||||
- 백엔드 API, 업로드·다운로드 payload와 DB 계약은 변경하지 않았다.
|
||||
|
||||
@@ -488,3 +488,13 @@
|
||||
- 편집 재진입 시 `gisEditSessionView`를 `inline`으로 복원한다.
|
||||
- 팝업 sync effect도 편집 모드가 꺼져 있으면 실행하지 않는다.
|
||||
- `useGisEdit`의 변경분 배열은 유지하므로 다시 진입하면 기존 변경 카드가 복원된다.
|
||||
|
||||
### 4.32 2026-09-01 GIS 편집작업 공통코드 1차 적용
|
||||
|
||||
- 기존 CMS 호출 구조인 `getCmsData('/api/v1/code/cache/{upCd}')`를 유지한 `getCommonCodes(upCd)` 타입 안전 조회 함수를 추가했다.
|
||||
- 기존 `searchCmmnCd()`도 같은 조회 함수를 사용하도록 정리해 CMS의 배열 응답을 `getData()`의 공통 응답 wrapper로 잘못 해제하던 경로를 제거했다.
|
||||
- `useGisEditOperationLabels()`는 `GISEDITOPRT`를 브라우저 세션당 한 번 조회하고 툴바와 인라인·윈도우 편집 세션이 같은 코드명을 공유한다.
|
||||
- `ADD`, `MOVE`, `VERTEX_EDIT`, `DELETE` 타입과 상태 분기는 유지하고 표시명만 공통코드의 `lwpoCdNm`으로 교체했다.
|
||||
- CMS 조회 실패 시에는 기존 `추가/이동/꼭지점편집/삭제` 문구를 fallback으로 사용해 편집 기능 자체가 중단되지 않게 했다.
|
||||
- `GISEDITTYPE(C/U/D)`는 실제 CMS 응답까지 확인했지만 현재 SN GIS 편집 화면에 직접 표시되는 소비처가 없어 불필요한 조회는 추가하지 않았다.
|
||||
- 백엔드 API, 저장 payload, DB 구조는 변경하지 않았다.
|
||||
|
||||
@@ -139,3 +139,12 @@
|
||||
- 공통 팝업의 12px 간격과 중복되던 별도 여백을 제거하고, WindowPopup 크기를 `1400×720`으로 조정했다. 빈 목록은 목록 카드가 남은 세로 공간을 채우도록 해 카드 밖에 과도한 공백이 남지 않게 했다.
|
||||
- `StandardDataTable`에 팝업 전용 바깥 margin 제거 옵션을 추가해 이 팝업의 검색 카드와 목록 카드 좌우 기준선을 동일하게 맞췄다. 일반 페이지의 기존 카드 margin은 그대로 보존한다. 빈 목록은 남은 높이를 채우고, 조회 결과가 있으면 표의 자연 높이를 보존해 팝업 본문만 세로 이동하도록 공통 동작을 구분했다.
|
||||
- API, 조회조건, 다중선택, Excel 다운로드, 등록과 부모창 메시지 계약은 변경하지 않았다.
|
||||
|
||||
## 2026-09-01 운영사업 공통코드 화면 적용
|
||||
|
||||
- `OBSLTBIZSE`, `EVDNCSBMSNSTTS`, `BIZPRGRSSTTS`를 기존 CMS 캐시 API로 조회하고 브라우저 세션에서 재사용한다.
|
||||
- 사업구분 Select는 CMS 코드명을 기존 한글 전송값으로 사용해 `정밀조사/정비사업` DB·API 계약을 유지한다.
|
||||
- 추진현황과 증빙서류 상태는 기존 `준공`, `발주중`, `O/X`, `공단` 등의 자유문자를 보존하면서 CMS 표준값을 datalist 선택 후보로 제공한다.
|
||||
- 목록은 CMS 코드값 또는 코드명과 정확히 일치하는 값만 코드명으로 표시하고 미등록 기존 값은 그대로 표시한다.
|
||||
- 영문 `lwpoCd` 저장 전환과 기존 데이터 이관은 이번 범위에서 수행하지 않았다.
|
||||
- 백엔드 API, Excel 입출력과 DB는 변경하지 않았다.
|
||||
|
||||
@@ -14,3 +14,11 @@
|
||||
|
||||
- 신규 FE feature와 route만 추가했다.
|
||||
- 기존 공통 store와 UI 컴포넌트는 소비만 하며 수정하지 않았다.
|
||||
|
||||
## 2026-09-01 운영사업 공통코드 화면 적용
|
||||
|
||||
- `CTYFLDNGPRGRSSE`, `CTYFLDNGDSGNTYPE`, `BIZPRGRSSTTS`를 기존 CMS 캐시 API로 조회한다.
|
||||
- `EM/MP/DD/CW` 상세 구역 제목을 CMS 코드명으로 표시하되 상세 코드와 Excel 열 순서는 유지한다.
|
||||
- 지정유형과 두 추진현황 입력은 자유문자 기존값을 유지하면서 CMS 표준값을 datalist 후보로 제공한다.
|
||||
- 목록은 CMS와 정확히 일치하는 지정유형·추진현황만 코드명으로 표시하고 `완료(20.06.)` 같은 기존 상세값은 그대로 표시한다.
|
||||
- 영문 코드 저장과 기존 데이터 이관은 수행하지 않았으며 백엔드 API·Excel·DB는 변경하지 않았다.
|
||||
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { useOperBusinessCommonCodes } from "@/shared/lib/use-oper-business-common-codes";
|
||||
import useStore from "@/store/useStore";
|
||||
|
||||
const DETAIL_META = [
|
||||
@@ -281,6 +282,12 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
|
||||
const searchParams = useSearchParams();
|
||||
const { confirm } = useConfirm();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
// 자유문자 기존값은 유지하고 지정유형·추진현황 후보와 단계명을 CMS에서 제공한다.
|
||||
const {
|
||||
cityDesignationTypeOptions,
|
||||
businessProgressOptions,
|
||||
cityProgressStageLabels,
|
||||
} = useOperBusinessCommonCodes();
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
const [form, setForm] = useState<CtyFldngPrvtnBizSaveRequest>(emptyForm);
|
||||
const [loading, setLoading] = useState(Boolean(id));
|
||||
@@ -372,6 +379,14 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
|
||||
type,
|
||||
]: (typeof BASIC_FIELDS)[number]) => {
|
||||
const fieldId = `cty-fldng-basic-${key}`;
|
||||
const suggestionOptions =
|
||||
key === "dsgnType"
|
||||
? cityDesignationTypeOptions
|
||||
: key === "prplPrst" || key === "bizPrplPrst"
|
||||
? businessProgressOptions
|
||||
: [];
|
||||
const suggestionListId =
|
||||
suggestionOptions.length > 0 ? `${fieldId}-suggestions` : undefined;
|
||||
const span: 1 | 2 | 3 =
|
||||
key === "excptnMttrCn" ? 3 : key === "bizNm" || key === "pstn" ? 2 : 1;
|
||||
return (
|
||||
@@ -382,21 +397,33 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
|
||||
required={key === "bizNm"}
|
||||
span={span}
|
||||
>
|
||||
<Input
|
||||
id={fieldId}
|
||||
className="w-full !text-[1.3rem]"
|
||||
type={type === "date" ? "date" : type}
|
||||
value={String(form[key] ?? "")}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
[key]:
|
||||
type === "date"
|
||||
? formatDateInput(event.target.value)
|
||||
: inputValue(event.target.value, type),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<Input
|
||||
id={fieldId}
|
||||
className="w-full !text-[1.3rem]"
|
||||
type={type === "date" ? "date" : type}
|
||||
list={suggestionListId}
|
||||
value={String(form[key] ?? "")}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
[key]:
|
||||
type === "date"
|
||||
? formatDateInput(event.target.value)
|
||||
: inputValue(event.target.value, type),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{suggestionListId ? (
|
||||
<datalist id={suggestionListId}>
|
||||
{suggestionOptions.map((option) => (
|
||||
<option key={option.code} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
) : null}
|
||||
</>
|
||||
</StandardFormField>
|
||||
);
|
||||
};
|
||||
@@ -539,7 +566,7 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) {
|
||||
return (
|
||||
<StandardFormSection
|
||||
key={meta.code}
|
||||
title={`${meta.code} ${meta.title}`}
|
||||
title={`${meta.code} ${cityProgressStageLabels[meta.code] || meta.title}`}
|
||||
>
|
||||
<DetailEditor
|
||||
detail={detail}
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
type PaginationState,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { formatDate } from "../../common/util";
|
||||
import { appendApiSearchParams, formatDate } from "@/shared/util/util";
|
||||
import {
|
||||
getAllCtyFldngPrvtnBusinesses,
|
||||
getCtyFldngPrvtnBusinesses,
|
||||
@@ -42,6 +42,7 @@ import type {
|
||||
} from "../model/cty-fldng-prvtn-biz-types";
|
||||
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { useOperBusinessCommonCodes } from "@/shared/lib/use-oper-business-common-codes";
|
||||
import useStore from "@/store/useStore";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
@@ -276,6 +277,13 @@ const flattenRow = (row: CtyFldngPrvtnBiz, rowNumber: number): TableRow => {
|
||||
export function CtyFldngPrvtnBizListPage() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
// 원본 자유문자 값은 유지하고 지정유형·추진현황·단계 표시명만 CMS 기준으로 변환한다.
|
||||
const {
|
||||
businessProgressOptions,
|
||||
cityProgressStageLabels,
|
||||
cityDesignationTypeLabels,
|
||||
businessProgressLabels,
|
||||
} = useOperBusinessCommonCodes();
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const defaultDesignationDateRange = useMemo(
|
||||
@@ -302,6 +310,21 @@ export function CtyFldngPrvtnBizListPage() {
|
||||
);
|
||||
const [data, setData] = useState(EMPTY_PAGE);
|
||||
const [rows, setRows] = useState<TableRow[]>([]);
|
||||
const displayRows = useMemo(
|
||||
() =>
|
||||
rows.map((row) => ({
|
||||
...row,
|
||||
dsgnType:
|
||||
cityDesignationTypeLabels[String(row.dsgnType ?? "")] ||
|
||||
row.dsgnType,
|
||||
prplPrst:
|
||||
businessProgressLabels[String(row.prplPrst ?? "")] || row.prplPrst,
|
||||
bizPrplPrst:
|
||||
businessProgressLabels[String(row.bizPrplPrst ?? "")] ||
|
||||
row.bizPrplPrst,
|
||||
})),
|
||||
[businessProgressLabels, cityDesignationTypeLabels, rows],
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [ctpvCd, setCtpvCd] = useState(params.get("ctpvCd") ?? "0");
|
||||
@@ -325,6 +348,14 @@ export function CtyFldngPrvtnBizListPage() {
|
||||
),
|
||||
[],
|
||||
);
|
||||
const detailGroups = useMemo(
|
||||
() =>
|
||||
DETAIL_GROUPS.map((group) => ({
|
||||
...group,
|
||||
title: cityProgressStageLabels[group.code] || group.title,
|
||||
})),
|
||||
[cityProgressStageLabels],
|
||||
);
|
||||
/** 정렬 변경 시 서버 첫 페이지로 돌아가고 동일 검색조건을 유지한다. */
|
||||
const handleSortChange = useCallback(
|
||||
(nextSort?: UiTableSort) => {
|
||||
@@ -356,7 +387,7 @@ export function CtyFldngPrvtnBizListPage() {
|
||||
createTableColumn(`soa_${year}`, `${year}년`, 7),
|
||||
),
|
||||
},
|
||||
...DETAIL_GROUPS.map((group) => ({
|
||||
...detailGroups.map((group) => ({
|
||||
id: `detail_${group.code}`,
|
||||
header: group.title,
|
||||
columns: DETAIL_FIELDS.map((field, index) =>
|
||||
@@ -378,14 +409,14 @@ export function CtyFldngPrvtnBizListPage() {
|
||||
),
|
||||
),
|
||||
],
|
||||
[handleSortChange, sort, years],
|
||||
[detailGroups, handleSortChange, sort, years],
|
||||
);
|
||||
const pagination: PaginationState = {
|
||||
pageIndex: page - 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
};
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
data: displayRows,
|
||||
columns,
|
||||
state: { pagination },
|
||||
rowCount: data.totalElements,
|
||||
@@ -423,9 +454,7 @@ export function CtyFldngPrvtnBizListPage() {
|
||||
? `${nextSort.key},${nextSort.direction}`
|
||||
: "ctyFldngPrvtnBizSn,desc",
|
||||
});
|
||||
Object.entries(condition).forEach(([k, v]) => {
|
||||
if (v.trim()) query.set(k, v.trim());
|
||||
});
|
||||
appendApiSearchParams(query, condition);
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await getCtyFldngPrvtnBusinesses(query);
|
||||
@@ -498,9 +527,7 @@ export function CtyFldngPrvtnBizListPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
Object.entries(applied).forEach(([k, v]) => {
|
||||
if (v.trim()) query.set(k, v.trim());
|
||||
});
|
||||
appendApiSearchParams(query, applied);
|
||||
await downloadCtyFldngPrvtnExcel(
|
||||
await getAllCtyFldngPrvtnBusinesses(
|
||||
query,
|
||||
@@ -658,6 +685,7 @@ export function CtyFldngPrvtnBizListPage() {
|
||||
</Label>
|
||||
<Input
|
||||
className="w-[15rem] !text-[1.3rem]"
|
||||
list="cty-fldng-progress-suggestions"
|
||||
value={search.prplPrst}
|
||||
onChange={(event) =>
|
||||
setSearch((current) => ({
|
||||
@@ -666,6 +694,13 @@ export function CtyFldngPrvtnBizListPage() {
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<datalist id="cty-fldng-progress-suggestions">
|
||||
{businessProgressOptions.map((option) => (
|
||||
<option key={option.code} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -682,17 +717,19 @@ export function CtyFldngPrvtnBizListPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-[7.4rem]">
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
className="w-[8.8rem]"
|
||||
onClick={handleReset}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
|
||||
<Button type="submit" className="w-[7.4rem]">
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
className="w-[8.8rem]"
|
||||
onClick={handleReset}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{notice && (
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
getCommonCodes,
|
||||
type CommonCodeItem,
|
||||
} from "@/components/lib/common/apiHelpers";
|
||||
|
||||
type CodeLabelMap = Record<string, string>;
|
||||
type ExmnCodeGroup =
|
||||
| "EXMNDFCGRD"
|
||||
| "EXMNRECDTYPE"
|
||||
| "EXMNDFCTYPE"
|
||||
| "EXMNDFCSEV"
|
||||
| "EXMNSTTSGRP"
|
||||
| "PPLEXMNITEM"
|
||||
| "MNHLEXMNITEM";
|
||||
|
||||
const COMMON_CODE_GROUPS: ExmnCodeGroup[] = [
|
||||
"EXMNDFCGRD",
|
||||
"EXMNRECDTYPE",
|
||||
"EXMNDFCTYPE",
|
||||
"EXMNDFCSEV",
|
||||
"EXMNSTTSGRP",
|
||||
"PPLEXMNITEM",
|
||||
"MNHLEXMNITEM",
|
||||
];
|
||||
|
||||
const cachedCodeGroups = new Map<ExmnCodeGroup, CommonCodeItem[]>();
|
||||
const pendingCodeGroups = new Map<
|
||||
ExmnCodeGroup,
|
||||
Promise<CommonCodeItem[]>
|
||||
>();
|
||||
|
||||
const loadCodeGroup = async (upCd: ExmnCodeGroup) => {
|
||||
const cached = cachedCodeGroups.get(upCd);
|
||||
if (cached) return cached;
|
||||
|
||||
const pending = pendingCodeGroups.get(upCd);
|
||||
if (pending) return pending;
|
||||
|
||||
const request = getCommonCodes(upCd)
|
||||
.then((codes) => {
|
||||
cachedCodeGroups.set(upCd, codes);
|
||||
return codes;
|
||||
})
|
||||
.finally(() => {
|
||||
pendingCodeGroups.delete(upCd);
|
||||
});
|
||||
|
||||
pendingCodeGroups.set(upCd, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
const toLabelMap = (codes: CommonCodeItem[]): CodeLabelMap =>
|
||||
Object.fromEntries(codes.map((code) => [code.lwpoCd, code.lwpoCdNm]));
|
||||
|
||||
/** 조사 코드값과 표 열 순서는 유지하고 화면에서 사용할 CMS 코드명만 제공한다. */
|
||||
export const useExmnCommonCodes = () => {
|
||||
const [codeGroups, setCodeGroups] = useState<
|
||||
Partial<Record<ExmnCodeGroup, CommonCodeItem[]>>
|
||||
>(
|
||||
() =>
|
||||
Object.fromEntries(cachedCodeGroups.entries()) as Partial<
|
||||
Record<ExmnCodeGroup, CommonCodeItem[]>
|
||||
>,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
COMMON_CODE_GROUPS.forEach((upCd) => {
|
||||
loadCodeGroup(upCd)
|
||||
.then((codes) => {
|
||||
if (!cancelled) {
|
||||
setCodeGroups((current) => ({ ...current, [upCd]: codes }));
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`${upCd} 공통코드 조회에 실패했습니다.`, error);
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const defectGradeOptions = useMemo(
|
||||
() =>
|
||||
codeGroups.EXMNDFCGRD?.map((code) => ({
|
||||
value: code.lwpoCd,
|
||||
label: code.lwpoCdNm,
|
||||
})) ??
|
||||
[1, 2, 3, 4, 5].map((grade) => ({
|
||||
value: String(grade),
|
||||
label: `${grade}등급`,
|
||||
})),
|
||||
[codeGroups.EXMNDFCGRD],
|
||||
);
|
||||
|
||||
const recordTypeLabels = useMemo(
|
||||
() => toLabelMap(codeGroups.EXMNRECDTYPE ?? []),
|
||||
[codeGroups.EXMNRECDTYPE],
|
||||
);
|
||||
const defectTypeLabels = useMemo(
|
||||
() => toLabelMap(codeGroups.EXMNDFCTYPE ?? []),
|
||||
[codeGroups.EXMNDFCTYPE],
|
||||
);
|
||||
const severityLabels = useMemo(
|
||||
() => toLabelMap(codeGroups.EXMNDFCSEV ?? []),
|
||||
[codeGroups.EXMNDFCSEV],
|
||||
);
|
||||
const statusGroupLabels = useMemo(
|
||||
() => toLabelMap(codeGroups.EXMNSTTSGRP ?? []),
|
||||
[codeGroups.EXMNSTTSGRP],
|
||||
);
|
||||
const pipeItemLabels = useMemo(
|
||||
() => toLabelMap(codeGroups.PPLEXMNITEM ?? []),
|
||||
[codeGroups.PPLEXMNITEM],
|
||||
);
|
||||
const manholeItemLabels = useMemo(
|
||||
() => toLabelMap(codeGroups.MNHLEXMNITEM ?? []),
|
||||
[codeGroups.MNHLEXMNITEM],
|
||||
);
|
||||
|
||||
return {
|
||||
defectGradeOptions,
|
||||
recordTypeLabels,
|
||||
defectTypeLabels,
|
||||
severityLabels,
|
||||
statusGroupLabels,
|
||||
pipeItemLabels,
|
||||
manholeItemLabels,
|
||||
};
|
||||
};
|
||||
@@ -6,8 +6,9 @@ import {
|
||||
type ExmnDetailSeverity,
|
||||
type ExmnPplUnitDetailRow,
|
||||
} from "../model/exmn-detail-table";
|
||||
import { useExmnCommonCodes } from "../lib/useExmnCommonCodes";
|
||||
|
||||
const SEVERITY_LABELS: Record<ExmnDetailSeverity, string> = {
|
||||
const DEFAULT_SEVERITY_LABELS: Record<ExmnDetailSeverity, string> = {
|
||||
L: "대",
|
||||
M: "중",
|
||||
S: "소",
|
||||
@@ -54,6 +55,13 @@ export function ExmnPplUnitDetailTable({
|
||||
rows,
|
||||
emptyMessage,
|
||||
}: ExmnPplUnitDetailTableProps) {
|
||||
// 본단위 행의 기록유형·심각도·관로항목 표시명만 CMS 코드명으로 교체한다.
|
||||
const { recordTypeLabels, severityLabels: cmsSeverityLabels, pipeItemLabels } =
|
||||
useExmnCommonCodes();
|
||||
const severityLabels: Record<ExmnDetailSeverity, string> = {
|
||||
...DEFAULT_SEVERITY_LABELS,
|
||||
...cmsSeverityLabels,
|
||||
};
|
||||
const pipeSpans = buildRowSpanMap(rows, (row) => row.pipeNo);
|
||||
const unitSpans = buildRowSpanMap(
|
||||
rows,
|
||||
@@ -208,13 +216,15 @@ export function ExmnPplUnitDetailTable({
|
||||
{formatOptionalValue(row.defectPositionM)}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-[1.2rem] text-left">
|
||||
{row.defectName}
|
||||
{row.recordType
|
||||
? `(${recordTypeLabels[row.recordType] || row.recordType})${pipeItemLabels[row.code] || row.defectName.replace(/^\([^)]*\)/, "")}`
|
||||
: row.defectName}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-[1.2rem]">
|
||||
{row.code}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-[1.2rem]">
|
||||
{row.severity ? SEVERITY_LABELS[row.severity] : "-"}
|
||||
{row.severity ? severityLabels[row.severity] : "-"}
|
||||
</td>
|
||||
<td className="border-b border-r border-border px-2 py-[1.2rem]">
|
||||
{formatOptionalValue(row.occurrencePosition)}
|
||||
|
||||
@@ -46,11 +46,12 @@ import {
|
||||
type ExmnSeverityCounts,
|
||||
type ExmnSurveyDetailRow,
|
||||
} from "../model/exmn-detail-table";
|
||||
import { useExmnCommonCodes } from "../lib/useExmnCommonCodes";
|
||||
|
||||
import { ExmnPplUnitDetailTable } from "./ExmnPplUnitDetailTable";
|
||||
import { ExmnSurveyStandardDataTable } from "./ExmnSurveyStandardDataTable";
|
||||
|
||||
const SEVERITY_LABELS: Record<ExmnDetailSeverity, string> = {
|
||||
const DEFAULT_SEVERITY_LABELS: Record<ExmnDetailSeverity, string> = {
|
||||
L: "대",
|
||||
M: "중",
|
||||
S: "소",
|
||||
@@ -62,7 +63,7 @@ const PPL_DETAIL_MODE_LABELS: Record<ExmnPplDetailMode, string> = {
|
||||
UNIT: "본단위구간",
|
||||
};
|
||||
|
||||
const DEFECT_TYPE_LABELS: Record<ExmnDetailDefectType, string> = {
|
||||
const DEFAULT_DEFECT_TYPE_LABELS: Record<ExmnDetailDefectType, string> = {
|
||||
ALL: "전체",
|
||||
STRUCTURAL: "구조적결함",
|
||||
OPERATIONAL: "운영적결함",
|
||||
@@ -157,6 +158,14 @@ export function ExmnSurveyDetailPopupPage({
|
||||
jobType,
|
||||
defectType,
|
||||
}: ExmnSurveyDetailPopupPageProps) {
|
||||
// 표의 코드·열 순서는 유지하고 결함분류·심각도·조사항목 표시명만 CMS 기준으로 사용한다.
|
||||
const {
|
||||
recordTypeLabels,
|
||||
defectTypeLabels: cmsDefectTypeLabels,
|
||||
severityLabels: cmsSeverityLabels,
|
||||
pipeItemLabels,
|
||||
manholeItemLabels,
|
||||
} = useExmnCommonCodes();
|
||||
/** 현재 조사유형에 실제 결과가 등록된 처리분구 목록과 팝업 조회 대상이다. */
|
||||
const [districtOptions, setDistrictOptions] = useState<
|
||||
ExmnSectionSummaryRow[]
|
||||
@@ -183,8 +192,26 @@ export function ExmnSurveyDetailPopupPage({
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const definition = EXMN_DETAIL_TABLE_DEFINITIONS[jobType];
|
||||
const defectTypeLabel = DEFECT_TYPE_LABELS[defectType];
|
||||
const severityLabels: Record<ExmnDetailSeverity, string> = {
|
||||
...DEFAULT_SEVERITY_LABELS,
|
||||
...cmsSeverityLabels,
|
||||
};
|
||||
const defectTypeLabels: Record<ExmnDetailDefectType, string> = {
|
||||
...DEFAULT_DEFECT_TYPE_LABELS,
|
||||
...cmsDefectTypeLabels,
|
||||
};
|
||||
const definition = useMemo(() => {
|
||||
const baseDefinition = EXMN_DETAIL_TABLE_DEFINITIONS[jobType];
|
||||
const itemLabels = jobType === "PPL" ? pipeItemLabels : manholeItemLabels;
|
||||
return {
|
||||
...baseDefinition,
|
||||
defectColumns: baseDefinition.defectColumns.map((column) => ({
|
||||
...column,
|
||||
label: itemLabels[column.code] || column.label,
|
||||
})),
|
||||
};
|
||||
}, [jobType, manholeItemLabels, pipeItemLabels]);
|
||||
const defectTypeLabel = defectTypeLabels[defectType];
|
||||
const selectedDistrict = districtOptions.find(
|
||||
(district) => district.trtdstId === selectedTrtdstId,
|
||||
);
|
||||
@@ -523,9 +550,11 @@ export function ExmnSurveyDetailPopupPage({
|
||||
unitSeq: row.unitSeq,
|
||||
unitLengthM: row.unitLengthM,
|
||||
defectPositionM: row.defectPositionM,
|
||||
defectName: row.defectName,
|
||||
defectName: row.recordType
|
||||
? `(${recordTypeLabels[row.recordType] || row.recordType})${pipeItemLabels[row.code] || row.defectName.replace(/^\([^)]*\)/, "")}`
|
||||
: row.defectName,
|
||||
code: row.code,
|
||||
severity: row.severity ? SEVERITY_LABELS[row.severity] : "-",
|
||||
severity: row.severity ? severityLabels[row.severity] : "-",
|
||||
occurrencePosition: row.occurrencePosition,
|
||||
infiltrationBonus: row.infiltrationBonus,
|
||||
continuous: row.continuous == null ? "-" : row.continuous ? "Y" : "N",
|
||||
@@ -576,7 +605,7 @@ export function ExmnSurveyDetailPopupPage({
|
||||
visibleDefectColumns.forEach((column) => {
|
||||
EXMN_DETAIL_SEVERITIES.forEach((severity) => {
|
||||
detailHeaderMapping[`${column.code}_${severity}`] =
|
||||
`${column.label} (${column.code})-${SEVERITY_LABELS[severity]}`;
|
||||
`${column.label} (${column.code})-${severityLabels[severity]}`;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1049,7 +1078,7 @@ export function ExmnSurveyDetailPopupPage({
|
||||
key={`total-${severity}`}
|
||||
className="border-b border-r border-border !py-[0.6rem] px-2 text-center"
|
||||
>
|
||||
{SEVERITY_LABELS[severity]}
|
||||
{severityLabels[severity]}
|
||||
</th>
|
||||
))}
|
||||
{visibleDefectColumns.flatMap((column) =>
|
||||
@@ -1058,7 +1087,7 @@ export function ExmnSurveyDetailPopupPage({
|
||||
key={`${column.code}-${severity}`}
|
||||
className="border-b border-r border-border !py-[0.6rem] px-2 text-center font-medium"
|
||||
>
|
||||
{SEVERITY_LABELS[severity]}
|
||||
{severityLabels[severity]}
|
||||
</th>
|
||||
)),
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@common/components/ui";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useExmnCommonCodes } from "../lib/useExmnCommonCodes";
|
||||
import type { PplFacility } from "../model/exmn-business-types";
|
||||
import { ExmnStandardDataTable } from "./ExmnStandardDataTable";
|
||||
|
||||
@@ -49,9 +50,25 @@ export function PplFacilityDetailModal({
|
||||
facility,
|
||||
onOpenChange,
|
||||
}: PplFacilityDetailModalProps) {
|
||||
// 정규화 상세의 코드값은 유지하고 기록유형·분류·심각도·상태그룹 표시명만 변환한다.
|
||||
const {
|
||||
defectGradeOptions,
|
||||
recordTypeLabels,
|
||||
defectTypeLabels,
|
||||
severityLabels,
|
||||
statusGroupLabels,
|
||||
} = useExmnCommonCodes();
|
||||
// 선택 단위는 원문 배열 인덱스가 아니라 unitSeq로 보관해 서버 정렬과 무관하게 유지한다.
|
||||
const [selectedUnitSeq, setSelectedUnitSeq] = useState<number | null>(null);
|
||||
|
||||
const defectGradeLabels = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
defectGradeOptions.map((option) => [option.value, option.label]),
|
||||
),
|
||||
[defectGradeOptions],
|
||||
);
|
||||
|
||||
/** 시설이 바뀌면 첫 단위 구간을 기본 선택한다. */
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -62,9 +79,15 @@ export function PplFacilityDetailModal({
|
||||
() =>
|
||||
(facility?.unitSections ?? []).map((unit) => ({
|
||||
...unit,
|
||||
structuralGrade:
|
||||
defectGradeLabels[String(unit.structuralGrade)] ||
|
||||
unit.structuralGrade,
|
||||
operationalGrade:
|
||||
defectGradeLabels[String(unit.operationalGrade)] ||
|
||||
unit.operationalGrade,
|
||||
detailCount: unit.details?.length ?? 0,
|
||||
})),
|
||||
[facility],
|
||||
[defectGradeLabels, facility],
|
||||
);
|
||||
|
||||
const selectedUnit = useMemo(
|
||||
@@ -77,18 +100,32 @@ export function PplFacilityDetailModal({
|
||||
|
||||
const detailRows = useMemo(
|
||||
() =>
|
||||
(selectedUnit?.details ?? []).map((detail) => ({
|
||||
...detail,
|
||||
typeText: detail.type ?? "-",
|
||||
severityText: detail.severity ?? "-",
|
||||
occurrencePositionText: detail.occurrencePosition ?? "-",
|
||||
continuousText:
|
||||
detail.continuous == null ? "-" : detail.continuous ? "Y" : "N",
|
||||
structuralScoreText: detail.structuralScore ?? "-",
|
||||
operationalScoreText: detail.operationalScore ?? "-",
|
||||
statusGroupText: detail.statusGroup ?? "-",
|
||||
})),
|
||||
[selectedUnit],
|
||||
(selectedUnit?.details ?? []).map((detail) => {
|
||||
const defectType = detail.type ?? "";
|
||||
const severity = detail.severity ?? "";
|
||||
const statusGroup = detail.statusGroup ?? "";
|
||||
return {
|
||||
...detail,
|
||||
recordType:
|
||||
recordTypeLabels[detail.recordType] || detail.recordType || "-",
|
||||
typeText: defectTypeLabels[defectType] || defectType || "-",
|
||||
severityText: severityLabels[severity] || severity || "-",
|
||||
occurrencePositionText: detail.occurrencePosition ?? "-",
|
||||
continuousText:
|
||||
detail.continuous == null ? "-" : detail.continuous ? "Y" : "N",
|
||||
structuralScoreText: detail.structuralScore ?? "-",
|
||||
operationalScoreText: detail.operationalScore ?? "-",
|
||||
statusGroupText:
|
||||
statusGroupLabels[statusGroup] || statusGroup || "-",
|
||||
};
|
||||
}),
|
||||
[
|
||||
defectTypeLabels,
|
||||
recordTypeLabels,
|
||||
selectedUnit,
|
||||
severityLabels,
|
||||
statusGroupLabels,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -26,6 +26,7 @@ import { PplFacilityDetailModal } from "./PplFacilityDetailModal";
|
||||
import { ExmnStandardDataTable } from "./ExmnStandardDataTable";
|
||||
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
|
||||
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
|
||||
import { useExmnCommonCodes } from "../lib/useExmnCommonCodes";
|
||||
|
||||
const SUMMARY_TABLE_HEAD = [
|
||||
{ id: "trtdstNm", title: "처리분구", widthPercent: 20 },
|
||||
@@ -128,6 +129,8 @@ export function PplSummaryModal({
|
||||
business,
|
||||
onOpenChange,
|
||||
}: PplSummaryModalProps) {
|
||||
// 조사시설 검색 코드값은 유지하고 등급·결함분류 표시명만 CMS 기준으로 사용한다.
|
||||
const { defectGradeOptions, defectTypeLabels } = useExmnCommonCodes();
|
||||
const [businessDetail, setBusinessDetail] =
|
||||
useState<ExmnBusinessDetail | null>(null);
|
||||
const [summaryRows, setSummaryRows] = useState<PplSummaryRow[]>([]);
|
||||
@@ -491,9 +494,9 @@ export function PplSummaryModal({
|
||||
onChange={(event) => setStructuralGrade(event.target.value)}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{[1, 2, 3, 4, 5].map((grade) => (
|
||||
<option key={grade} value={grade}>
|
||||
{grade}등급
|
||||
{defectGradeOptions.map((grade) => (
|
||||
<option key={grade.value} value={grade.value}>
|
||||
{grade.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -508,9 +511,9 @@ export function PplSummaryModal({
|
||||
}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{[1, 2, 3, 4, 5].map((grade) => (
|
||||
<option key={grade} value={grade}>
|
||||
{grade}등급
|
||||
{defectGradeOptions.map((grade) => (
|
||||
<option key={grade.value} value={grade.value}>
|
||||
{grade.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -518,7 +521,7 @@ export function PplSummaryModal({
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
결함 분류: {defectType}
|
||||
결함 분류: {defectTypeLabels[defectType] || defectType}
|
||||
</span>
|
||||
<Button
|
||||
disabled={isLoading}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
|
||||
|
||||
export type FloodPredictionExcelRow = {
|
||||
ctpvNm: string;
|
||||
sggNm: string;
|
||||
analysisDateTime: string;
|
||||
rainBeginningDateTime: string;
|
||||
observedAccumulatedRainfall: number | null;
|
||||
predictionHour: number | null;
|
||||
predictedAccumulatedRainfall: number | null;
|
||||
exceededFrequencyYear: number | null;
|
||||
floodRiskGrade: string;
|
||||
};
|
||||
|
||||
const HEADERS: Record<string, string> = {
|
||||
rowNo: "순번",
|
||||
ctpvNm: "시도",
|
||||
sggNm: "시군구",
|
||||
analysisDateTime: "분석시각",
|
||||
rainBeginningDateTime: "강우 시작시각",
|
||||
observedAccumulatedRainfall: "관측 누적강수량(mm)",
|
||||
predictionHour: "예측시간",
|
||||
predictedAccumulatedRainfall: "예측 누적강수량(mm)",
|
||||
exceededFrequencyYear: "초과빈도(년)",
|
||||
floodRiskGrade: "침수위험도",
|
||||
};
|
||||
|
||||
/** yyyyMMddHHmm 형식의 API 시각을 Excel 표시 문자열로 변환한다. */
|
||||
const formatDateTime = (value: string) =>
|
||||
/^\d{12}$/.test(value)
|
||||
? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)} ${value.slice(8, 10)}:${value.slice(10, 12)}`
|
||||
: value || "-";
|
||||
|
||||
/** 적용 조회조건의 전체 침수예측현황을 브라우저에서 한 시트 XLSX로 생성한다. */
|
||||
export const downloadFloodPredictionStatusExcel = async (
|
||||
rows: FloodPredictionExcelRow[],
|
||||
) => {
|
||||
const excelRows = rows.map((row, index) => ({
|
||||
rowNo: index + 1,
|
||||
ctpvNm: row.ctpvNm || "-",
|
||||
sggNm: row.sggNm || "-",
|
||||
analysisDateTime: formatDateTime(row.analysisDateTime),
|
||||
rainBeginningDateTime: formatDateTime(row.rainBeginningDateTime),
|
||||
observedAccumulatedRainfall:
|
||||
row.observedAccumulatedRainfall == null
|
||||
? "-"
|
||||
: Number(row.observedAccumulatedRainfall.toFixed(1)),
|
||||
predictionHour:
|
||||
row.predictionHour == null ? "-" : `${row.predictionHour}시간 후`,
|
||||
predictedAccumulatedRainfall:
|
||||
row.predictedAccumulatedRainfall == null
|
||||
? "-"
|
||||
: Number(row.predictedAccumulatedRainfall.toFixed(1)),
|
||||
exceededFrequencyYear:
|
||||
row.exceededFrequencyYear == null ? "-" : row.exceededFrequencyYear,
|
||||
floodRiskGrade: row.floodRiskGrade || "-",
|
||||
}));
|
||||
|
||||
await downloadExcelWithKoreanHeaders({
|
||||
data: excelRows as unknown as JSON[],
|
||||
fileName: "침수예측현황.xlsx",
|
||||
sheetNameArray: ["침수예측현황"],
|
||||
headerRange: "A1:J1",
|
||||
headerMapping: HEADERS,
|
||||
});
|
||||
};
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
} from "@common/business_components";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import { getBlobData, getData, getRawJson } from "@/components/lib/api";
|
||||
import { fileDownload } from "@/components/lib/utils/fileDownload";
|
||||
import { getData, getRawJson } from "@/components/lib/api";
|
||||
import { appendApiSearchParams } from "@/shared/util/util";
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
|
||||
@@ -18,6 +18,8 @@ import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import useStore from "@/store/useStore";
|
||||
import { createPredictionAnalysisTimeRange } from "../model/analysisTimeRange";
|
||||
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
|
||||
import { downloadFloodPredictionStatusExcel } from "../lib/fldng-predc-prst-excel";
|
||||
|
||||
type ApiRow = {
|
||||
sggCd: string;
|
||||
@@ -55,6 +57,7 @@ type Filters = {
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
const EXCEL_DOWNLOAD_PAGE_SIZE = 500;
|
||||
const MAP_FREQUENCIES = new Set([30, 50, 80, 100]);
|
||||
const FREQUENCY_OPTIONS = [30, 50, 80, 100] as const;
|
||||
|
||||
@@ -118,7 +121,7 @@ const buildInitialFilters = (): Filters => {
|
||||
rainBgngTo: "",
|
||||
analysisFrom: toDateTimeLocal(range.analysisFrom),
|
||||
analysisTo: toDateTimeLocal(range.analysisTo),
|
||||
frequencyYear: "",
|
||||
frequencyYear: "0",
|
||||
};
|
||||
};
|
||||
|
||||
@@ -141,21 +144,21 @@ const formatNumber = (value: number | null, digits: number) =>
|
||||
: Number(value).toFixed(digits);
|
||||
|
||||
/** 화면 조회조건을 목록·Excel API 쿼리로 변환한다. */
|
||||
const buildQuery = (filters: Filters, page?: number) => {
|
||||
const buildQuery = (filters: Filters, page?: number, size = PAGE_SIZE) => {
|
||||
const params = new URLSearchParams();
|
||||
if (page !== undefined) {
|
||||
params.set("page", String(page - 1));
|
||||
params.set("size", String(PAGE_SIZE));
|
||||
params.set("size", String(size));
|
||||
}
|
||||
if (filters.ctpvCd !== "0") params.set("ctpvCd", filters.ctpvCd.slice(0, 2));
|
||||
if (filters.sggCd !== "0") params.set("sggCd", filters.sggCd.slice(0, 5));
|
||||
(
|
||||
["rainBgngFrom", "rainBgngTo", "analysisFrom", "analysisTo"] as const
|
||||
).forEach((key) => {
|
||||
const value = apiDate(filters[key]);
|
||||
if (value) params.set(key, value);
|
||||
appendApiSearchParams(params, {
|
||||
ctpvCd: filters.ctpvCd === "0" ? "0" : filters.ctpvCd.slice(0, 2),
|
||||
sggCd: filters.sggCd === "0" ? "0" : filters.sggCd.slice(0, 5),
|
||||
rainBgngFrom: apiDate(filters.rainBgngFrom),
|
||||
rainBgngTo: apiDate(filters.rainBgngTo),
|
||||
analysisFrom: apiDate(filters.analysisFrom),
|
||||
analysisTo: apiDate(filters.analysisTo),
|
||||
frequencyYear: filters.frequencyYear,
|
||||
});
|
||||
if (filters.frequencyYear) params.set("frequencyYear", filters.frequencyYear);
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
@@ -176,7 +179,7 @@ const validateFilters = (filters: Filters) => {
|
||||
return "분석시각의 시작은 종료보다 늦을 수 없습니다.";
|
||||
}
|
||||
if (
|
||||
filters.frequencyYear &&
|
||||
filters.frequencyYear != "0" &&
|
||||
!FREQUENCY_OPTIONS.includes(
|
||||
Number(filters.frequencyYear) as 30 | 50 | 80 | 100,
|
||||
)
|
||||
@@ -237,7 +240,7 @@ export default function FldngPredcPrstPopupContent() {
|
||||
const sggOptions = useMemo(
|
||||
() =>
|
||||
filters.ctpvCd === "0"
|
||||
? sgg
|
||||
? []
|
||||
: sgg.filter((item) =>
|
||||
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
|
||||
),
|
||||
@@ -389,10 +392,25 @@ export default function FldngPredcPrstPopupContent() {
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const blob = await getBlobData(
|
||||
`/sewerage-service/api/v1/fldng/predc/prst/excel?${buildQuery(appliedFilters)}`,
|
||||
);
|
||||
fileDownload(blob, "침수예측현황.xlsx");
|
||||
const result = await fetchAllPages<
|
||||
{ content: ApiRow[]; totalElements: number },
|
||||
ApiRow
|
||||
>({
|
||||
batchSize: EXCEL_DOWNLOAD_PAGE_SIZE,
|
||||
fetchPage: async (pageIndex, size) => {
|
||||
const response = await getRawJson(
|
||||
`/sewerage-service/api/v1/fldng/predc/prst?${buildQuery(appliedFilters, pageIndex + 1, size)}`,
|
||||
);
|
||||
const payload = response?.data ?? response;
|
||||
return {
|
||||
content: Array.isArray(payload?.content) ? payload.content : [],
|
||||
totalElements: Number(payload?.totalElements ?? 0),
|
||||
};
|
||||
},
|
||||
getItems: (response) => response.content,
|
||||
getTotal: (response) => response.totalElements,
|
||||
});
|
||||
await downloadFloodPredictionStatusExcel(result.items);
|
||||
} catch (error) {
|
||||
console.error("침수예측현황 Excel 다운로드 중 오류:", error);
|
||||
alert("침수예측현황 Excel 다운로드에 실패했습니다.");
|
||||
@@ -519,7 +537,7 @@ export default function FldngPredcPrstPopupContent() {
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="0">전체</option>
|
||||
{FREQUENCY_OPTIONS.map((frequency) => (
|
||||
<option key={frequency} value={String(frequency)}>
|
||||
{frequency}년
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as XLSX from "xlsx";
|
||||
import XlsxPopulate from "xlsx-populate/browser/xlsx-populate";
|
||||
|
||||
import { formatDate } from "../../common/util";
|
||||
import { formatDate } from "@/shared/util/util";
|
||||
import type { ObsltPplBusiness, ObsltPplBusinessSaveRequest } from "../model/obslt-ppl-types";
|
||||
|
||||
type BusinessType = ObsltPplBusinessSaveRequest["bizSe"];
|
||||
|
||||
@@ -40,12 +40,15 @@ export const buildObsltPplRepresentativeSggOptions = (
|
||||
sggOptions: RegionOption[],
|
||||
ctpvCd: string,
|
||||
) => {
|
||||
// 시도가 전체(0)이면 시군구를 선택할 수 없도록 목록을 비운다.
|
||||
if (ctpvCd === "0") return [];
|
||||
|
||||
const representativeMap = new Map<string, RegionOption>();
|
||||
|
||||
sggOptions
|
||||
.filter((option) => {
|
||||
const optionCode = String(option.val ?? "").trim();
|
||||
return ctpvCd === "0" || optionCode.startsWith(ctpvCd.slice(0, 2));
|
||||
return optionCode.startsWith(ctpvCd.slice(0, 2));
|
||||
})
|
||||
.forEach((option) => {
|
||||
const representativeName = normalizeObsltPplSigunName(option.text);
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
import { formatDate } from "../../common/util";
|
||||
import { formatDate } from "@/shared/util/util";
|
||||
import {
|
||||
createObsltPplBusiness,
|
||||
deleteObsltPplBusiness,
|
||||
@@ -43,16 +43,13 @@ import type {
|
||||
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { useOperBusinessCommonCodes } from "@/shared/lib/use-oper-business-common-codes";
|
||||
import useStore from "@/store/useStore";
|
||||
|
||||
type FormKey = keyof ObsltPplBusinessSaveRequest;
|
||||
type FormState = Record<FormKey, string>;
|
||||
type FieldErrorKey = FormKey | "region";
|
||||
const REGION_FORM_KEYS: FormKey[] = ["sggCd", "ctpvNm", "sigunNm"];
|
||||
const BIZ_SE_OPTIONS = [
|
||||
{ text: "정밀조사", val: "정밀조사" },
|
||||
{ text: "정비사업", val: "정비사업" },
|
||||
];
|
||||
const EMPTY_SELECT_VALUE = "__none__";
|
||||
type FieldSpan = 1 | 2 | 3;
|
||||
|
||||
@@ -154,6 +151,12 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
|
||||
const searchParams = useSearchParams();
|
||||
const { confirm } = useConfirm();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
// 기존 한글 저장값은 유지하면서 사업구분·추진·증빙 선택 후보를 CMS에서 구성한다.
|
||||
const {
|
||||
obsltBusinessTypeOptions,
|
||||
evidenceStatusOptions,
|
||||
businessProgressOptions,
|
||||
} = useOperBusinessCommonCodes();
|
||||
const isModify = Boolean(id);
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
const [form, setForm] = useState<FormState>(createEmptyForm);
|
||||
@@ -296,9 +299,9 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BIZ_SE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.val} value={option.val}>
|
||||
{option.text}
|
||||
{obsltBusinessTypeOptions.map((option) => (
|
||||
<SelectItem key={option.code} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -346,25 +349,45 @@ export function ObsltPplFormPage({ id }: { id?: string }) {
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const suggestionOptions =
|
||||
field.key === "prplPrst"
|
||||
? businessProgressOptions
|
||||
: field.key === "evdncDcmntSbmsnStts"
|
||||
? evidenceStatusOptions
|
||||
: [];
|
||||
const suggestionListId =
|
||||
suggestionOptions.length > 0 ? `${fieldId}-suggestions` : undefined;
|
||||
control = (
|
||||
<Input
|
||||
id={fieldId}
|
||||
className="w-full !text-[1.3rem]"
|
||||
type={field.type === "number" ? "number" : "text"}
|
||||
step={field.type === "number" ? "any" : undefined}
|
||||
maxLength={DATE_KEYS.has(field.key) ? 10 : undefined}
|
||||
value={form[field.key]}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(event) => {
|
||||
clearFieldError(field.key);
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
[field.key]: DATE_KEYS.has(field.key)
|
||||
? formatDateInput(event.target.value)
|
||||
: event.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<Input
|
||||
id={fieldId}
|
||||
className="w-full !text-[1.3rem]"
|
||||
type={field.type === "number" ? "number" : "text"}
|
||||
step={field.type === "number" ? "any" : undefined}
|
||||
maxLength={DATE_KEYS.has(field.key) ? 10 : undefined}
|
||||
list={suggestionListId}
|
||||
value={form[field.key]}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(event) => {
|
||||
clearFieldError(field.key);
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
[field.key]: DATE_KEYS.has(field.key)
|
||||
? formatDateInput(event.target.value)
|
||||
: event.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
{suggestionListId ? (
|
||||
<datalist id={suggestionListId}>
|
||||
{suggestionOptions.map((option) => (
|
||||
<option key={option.code} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
getObsltPplBusinesses,
|
||||
uploadObsltPplExcel,
|
||||
} from "../api/obslt-ppl-api";
|
||||
import { formatDate } from "../../common/util";
|
||||
import { appendApiSearchParams, formatDate } from "@/shared/util/util";
|
||||
import {
|
||||
downloadObsltPplExcel,
|
||||
parseObsltPplExcel,
|
||||
@@ -46,16 +46,12 @@ import {
|
||||
import type { ObsltPplBusiness, ObsltPplPage } from "../model/obslt-ppl-types";
|
||||
|
||||
import LoadingOverlay from "@/components/ui/LoadingBar";
|
||||
import { useOperBusinessCommonCodes } from "@/shared/lib/use-oper-business-common-codes";
|
||||
import useStore from "@/store/useStore";
|
||||
import { openWindowPopup } from "../../windowPopup/lib/openWindowPopup";
|
||||
import { WINDOW_POPUP_MESSAGE_TYPE } from "../../windowPopup/model/types";
|
||||
|
||||
const DEFAULT_LIST_SIZE = 10;
|
||||
const BIZ_SE_OPTIONS = [
|
||||
{ text: "전체", val: "0" },
|
||||
{ text: "정밀조사", val: "정밀조사" },
|
||||
{ text: "정비사업", val: "정비사업" },
|
||||
];
|
||||
const ALL_REGION_OPTION = { text: "전체", val: "0" };
|
||||
|
||||
type ObsltPplTableRow = ObsltPplBusiness & {
|
||||
@@ -155,6 +151,12 @@ const formatTableValue = (value: unknown) => {
|
||||
export function ObsltPplListPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
// 검색값·원본 행은 유지하고 사업구분·추진·증빙 표시명만 CMS 기준으로 변환한다.
|
||||
const {
|
||||
obsltBusinessTypeOptions,
|
||||
evidenceStatusLabels,
|
||||
businessProgressLabels,
|
||||
} = useOperBusinessCommonCodes();
|
||||
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const initialSearch = useMemo<ObsltPplSearch>(
|
||||
@@ -171,6 +173,22 @@ export function ObsltPplListPage() {
|
||||
);
|
||||
const [data, setData] = useState<ObsltPplPage>(EMPTY_PAGE);
|
||||
const [rows, setRows] = useState<ObsltPplTableRow[]>([]);
|
||||
const displayRows = useMemo(
|
||||
() =>
|
||||
rows.map((row) => ({
|
||||
...row,
|
||||
prplPrst:
|
||||
businessProgressLabels[String(row.prplPrst ?? "")] || row.prplPrst,
|
||||
evdncDcmntSbmsnStts:
|
||||
evidenceStatusLabels[String(row.evdncDcmntSbmsnStts ?? "")] ||
|
||||
row.evdncDcmntSbmsnStts,
|
||||
})),
|
||||
[
|
||||
businessProgressLabels,
|
||||
evidenceStatusLabels,
|
||||
rows,
|
||||
],
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [notice, setNotice] = useState("");
|
||||
// 선택박스의 코드와 API 검색에 사용하는 행정구역명을 함께 관리한다.
|
||||
@@ -257,7 +275,7 @@ export function ObsltPplListPage() {
|
||||
|
||||
/** 서버 페이지 번호와 URL 복귀 조건을 공통 DataTable 페이지 상태에 연결한다. */
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
data: displayRows,
|
||||
columns,
|
||||
state: { pagination },
|
||||
rowCount: data.totalElements,
|
||||
@@ -304,9 +322,7 @@ export function ObsltPplListPage() {
|
||||
? `${nextSort.key},${nextSort.direction}`
|
||||
: "obsltSwagePplBizSn,desc",
|
||||
});
|
||||
Object.entries(targetSearch).forEach(([key, value]) => {
|
||||
if (value.trim()) params.set(key, value.trim());
|
||||
});
|
||||
appendApiSearchParams(params, targetSearch);
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -405,9 +421,7 @@ export function ObsltPplListPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(appliedSearch).forEach(([key, value]) => {
|
||||
if (value.trim()) params.set(key, value.trim());
|
||||
});
|
||||
appendApiSearchParams(params, appliedSearch);
|
||||
const exportRows = await getAllObsltPplBusinesses(
|
||||
params,
|
||||
sort ? `${sort.key},${sort.direction}` : undefined,
|
||||
@@ -510,9 +524,10 @@ export function ObsltPplListPage() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BIZ_SE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.val} value={option.val}>
|
||||
{option.text}
|
||||
<SelectItem value="0">전체</SelectItem>
|
||||
{obsltBusinessTypeOptions.map((option) => (
|
||||
<SelectItem key={option.code} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -604,17 +619,19 @@ export function ObsltPplListPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-[7.4rem]">
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
className="w-[8.8rem]"
|
||||
onClick={handleReset}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
|
||||
<Button type="submit" className="w-[7.4rem]">
|
||||
조회
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
className="w-[8.8rem]"
|
||||
onClick={handleReset}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
getCommonCodes,
|
||||
type CommonCodeItem,
|
||||
} from "@/components/lib/common/apiHelpers";
|
||||
|
||||
type CodeLabelMap = Record<string, string>;
|
||||
type OperCodeGroup =
|
||||
| "OBSLTBIZSE"
|
||||
| "EVDNCSBMSNSTTS"
|
||||
| "CTYFLDNGPRGRSSE"
|
||||
| "CTYFLDNGDSGNTYPE"
|
||||
| "BIZPRGRSSTTS";
|
||||
|
||||
const COMMON_CODE_GROUPS: OperCodeGroup[] = [
|
||||
"OBSLTBIZSE",
|
||||
"EVDNCSBMSNSTTS",
|
||||
"CTYFLDNGPRGRSSE",
|
||||
"CTYFLDNGDSGNTYPE",
|
||||
"BIZPRGRSSTTS",
|
||||
];
|
||||
|
||||
const cachedCodeGroups = new Map<OperCodeGroup, CommonCodeItem[]>();
|
||||
const pendingCodeGroups = new Map<
|
||||
OperCodeGroup,
|
||||
Promise<CommonCodeItem[]>
|
||||
>();
|
||||
|
||||
const loadCodeGroup = async (upCd: OperCodeGroup) => {
|
||||
const cached = cachedCodeGroups.get(upCd);
|
||||
if (cached) return cached;
|
||||
|
||||
const pending = pendingCodeGroups.get(upCd);
|
||||
if (pending) return pending;
|
||||
|
||||
const request = getCommonCodes(upCd)
|
||||
.then((codes) => {
|
||||
cachedCodeGroups.set(upCd, codes);
|
||||
return codes;
|
||||
})
|
||||
.finally(() => {
|
||||
pendingCodeGroups.delete(upCd);
|
||||
});
|
||||
|
||||
pendingCodeGroups.set(upCd, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
const toLegacyValueOptions = (codes: CommonCodeItem[]) =>
|
||||
codes.map((code) => ({
|
||||
// 현행 운영사업 DB는 한글 설명값을 저장하므로 코드명을 기존 전송값으로 유지한다.
|
||||
value: code.lwpoCdNm,
|
||||
label: code.lwpoCdNm,
|
||||
code: code.lwpoCd,
|
||||
}));
|
||||
|
||||
const toLabelMap = (codes: CommonCodeItem[]): CodeLabelMap => {
|
||||
const labels: CodeLabelMap = {};
|
||||
codes.forEach((code) => {
|
||||
labels[code.lwpoCd] = code.lwpoCdNm;
|
||||
labels[code.lwpoCdNm] = code.lwpoCdNm;
|
||||
});
|
||||
return labels;
|
||||
};
|
||||
|
||||
/** 운영사업의 기존 한글 저장계약을 유지하면서 CMS 선택지·표시명을 제공한다. */
|
||||
export const useOperBusinessCommonCodes = () => {
|
||||
const [codeGroups, setCodeGroups] = useState<
|
||||
Partial<Record<OperCodeGroup, CommonCodeItem[]>>
|
||||
>(
|
||||
() =>
|
||||
Object.fromEntries(cachedCodeGroups.entries()) as Partial<
|
||||
Record<OperCodeGroup, CommonCodeItem[]>
|
||||
>,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
COMMON_CODE_GROUPS.forEach((upCd) => {
|
||||
loadCodeGroup(upCd)
|
||||
.then((codes) => {
|
||||
if (!cancelled) {
|
||||
setCodeGroups((current) => ({ ...current, [upCd]: codes }));
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`${upCd} 공통코드 조회에 실패했습니다.`, error);
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const obsltBusinessTypeOptions = useMemo(
|
||||
() =>
|
||||
codeGroups.OBSLTBIZSE?.length
|
||||
? toLegacyValueOptions(codeGroups.OBSLTBIZSE)
|
||||
: [
|
||||
{ value: "정밀조사", label: "정밀조사", code: "PRCIS_EXMN" },
|
||||
{ value: "정비사업", label: "정비사업", code: "MNTN_BIZ" },
|
||||
],
|
||||
[codeGroups.OBSLTBIZSE],
|
||||
);
|
||||
|
||||
const evidenceStatusOptions = useMemo(
|
||||
() => toLegacyValueOptions(codeGroups.EVDNCSBMSNSTTS ?? []),
|
||||
[codeGroups.EVDNCSBMSNSTTS],
|
||||
);
|
||||
const cityDesignationTypeOptions = useMemo(
|
||||
() => toLegacyValueOptions(codeGroups.CTYFLDNGDSGNTYPE ?? []),
|
||||
[codeGroups.CTYFLDNGDSGNTYPE],
|
||||
);
|
||||
const businessProgressOptions = useMemo(
|
||||
() => toLegacyValueOptions(codeGroups.BIZPRGRSSTTS ?? []),
|
||||
[codeGroups.BIZPRGRSSTTS],
|
||||
);
|
||||
const cityProgressStageLabels = useMemo(
|
||||
() => toLabelMap(codeGroups.CTYFLDNGPRGRSSE ?? []),
|
||||
[codeGroups.CTYFLDNGPRGRSSE],
|
||||
);
|
||||
const obsltBusinessTypeLabels = useMemo(
|
||||
() => toLabelMap(codeGroups.OBSLTBIZSE ?? []),
|
||||
[codeGroups.OBSLTBIZSE],
|
||||
);
|
||||
const evidenceStatusLabels = useMemo(
|
||||
() => toLabelMap(codeGroups.EVDNCSBMSNSTTS ?? []),
|
||||
[codeGroups.EVDNCSBMSNSTTS],
|
||||
);
|
||||
const cityDesignationTypeLabels = useMemo(
|
||||
() => toLabelMap(codeGroups.CTYFLDNGDSGNTYPE ?? []),
|
||||
[codeGroups.CTYFLDNGDSGNTYPE],
|
||||
);
|
||||
const businessProgressLabels = useMemo(
|
||||
() => toLabelMap(codeGroups.BIZPRGRSSTTS ?? []),
|
||||
[codeGroups.BIZPRGRSSTTS],
|
||||
);
|
||||
|
||||
return {
|
||||
obsltBusinessTypeOptions,
|
||||
evidenceStatusOptions,
|
||||
cityDesignationTypeOptions,
|
||||
businessProgressOptions,
|
||||
cityProgressStageLabels,
|
||||
obsltBusinessTypeLabels,
|
||||
evidenceStatusLabels,
|
||||
cityDesignationTypeLabels,
|
||||
businessProgressLabels,
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,17 @@
|
||||
import moment from "moment";
|
||||
|
||||
/** 빈 값과 화면의 전체 선택값 `0`을 제외하고 API 검색 파라미터를 추가한다. */
|
||||
export const appendApiSearchParams = (
|
||||
params: URLSearchParams,
|
||||
search: Readonly<Record<string, string>>,
|
||||
) => {
|
||||
Object.entries(search).forEach(([key, value]) => {
|
||||
const normalized = value.trim();
|
||||
if (!normalized || normalized === "0") return;
|
||||
params.set(key, normalized);
|
||||
});
|
||||
};
|
||||
|
||||
/** 8자리 또는 구분자가 섞인 날짜 문자열을 YYYY-MM-DD 형식으로 정규화한다. */
|
||||
export const formatDate = (value?: string | null) => {
|
||||
if (!value) return "";
|
||||
@@ -277,7 +277,7 @@ function StandardDataTable<T>({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"card !m-[1.2rem] min-w-0 overflow-hidden !p-0",
|
||||
"card !mx-[0.4rem] !my-[1.2rem] min-w-0 overflow-hidden !p-0",
|
||||
shouldFillAvailableHeight && "flex min-h-0 flex-1 flex-col",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
|
||||
import { Label } from "@common/components";
|
||||
|
||||
@@ -31,8 +31,11 @@ type StandardFormSectionProps = {
|
||||
children: ReactNode;
|
||||
columns?: StandardFormColumns;
|
||||
className?: string;
|
||||
wideLabels?: boolean;
|
||||
};
|
||||
|
||||
const StandardFormWideLabelsContext = createContext(false);
|
||||
|
||||
type StandardFormFieldProps = {
|
||||
id?: string;
|
||||
label: ReactNode;
|
||||
@@ -50,6 +53,7 @@ function StandardFormSection({
|
||||
children,
|
||||
columns = 3,
|
||||
className = "",
|
||||
wideLabels = false,
|
||||
}: StandardFormSectionProps) {
|
||||
return (
|
||||
<section
|
||||
@@ -58,11 +62,13 @@ function StandardFormSection({
|
||||
<h4 className="border-b border-border bg-container px-4 py-3 text-[1.4rem] font-semibold text-label">
|
||||
{title}
|
||||
</h4>
|
||||
<div
|
||||
className={`grid grid-cols-1 gap-px bg-border ${SECTION_GRID_CLASS[columns]}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<StandardFormWideLabelsContext.Provider value={wideLabels}>
|
||||
<div
|
||||
className={`grid grid-cols-1 gap-px bg-border ${SECTION_GRID_CLASS[columns]}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</StandardFormWideLabelsContext.Provider>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -78,9 +84,11 @@ function StandardFormField({
|
||||
children,
|
||||
className = "",
|
||||
}: StandardFormFieldProps) {
|
||||
const wideLabels = useContext(StandardFormWideLabelsContext);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`grid min-w-0 grid-cols-[9rem_minmax(0,1fr)] bg-background sm:grid-cols-[11.5rem_minmax(0,1fr)] ${FIELD_SPAN_CLASS[columns][span]} ${className}`}
|
||||
className={`grid min-w-0 grid-cols-[9rem_minmax(0,1fr)] bg-background ${wideLabels ? "sm:grid-cols-[18rem_minmax(0,1fr)]" : "sm:grid-cols-[11.5rem_minmax(0,1fr)]"} ${FIELD_SPAN_CLASS[columns][span]} ${className}`}
|
||||
>
|
||||
<Label
|
||||
htmlFor={id}
|
||||
@@ -89,7 +97,7 @@ function StandardFormField({
|
||||
{required ? <span className="mr-1 text-red-600">*</span> : null}
|
||||
{label}
|
||||
</Label>
|
||||
<div className="min-w-0 p-3">
|
||||
<div className="flex min-w-0 flex-col justify-center p-3">
|
||||
{children}
|
||||
{error ? (
|
||||
<p className="mt-1 text-[1.2rem] text-red-600" role="alert">
|
||||
|
||||
Reference in New Issue
Block a user