Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev

This commit is contained in:
2026-09-04 15:27:18 +09:00
86 changed files with 2695 additions and 578 deletions
+2 -2
View File
@@ -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>
);
}
@@ -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
@@ -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),
+493 -117
View File
@@ -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;
}
};
+4 -4
View File
@@ -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
+3 -1
View File
@@ -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);
+17 -3
View File
@@ -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">