처리구역 관로분석정보 기능 추가

This commit is contained in:
2026-04-28 15:04:08 +09:00
parent 8689b0a8a6
commit c6575a8182
11 changed files with 311 additions and 27 deletions
+3
View File
@@ -178,6 +178,9 @@ Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작
## 빌드 및 검증 기준
VSCode나 별도 터미널에서 `sn` Next 개발 서버가 실행 중이면 `next build`, `pnpm --filter sn run build`, `pnpm turbo run build --filter=sn` 같은 빌드 명령을 실행하지 않는다.
개발 서버와 빌드가 동시에 `.next` 산출물을 갱신하면 개발 서버가 종료되거나 꼬일 수 있으므로, 빌드 검증은 사용자에게 확인한 뒤 개발 서버를 끄고 수행한다.
이 저장소는 `main` 브랜치 push 전에 `.husky/pre-push`에서 아래 빌드를 수행한다.
```sh
@@ -4,6 +4,7 @@ import smh from "../popupComponent/smh";
import Ssc from "../popupComponent/Ssc";
import StormWaterPump from "../popupComponent/stormWaterPump";
import SwtrmtFclt from "../popupComponent/SwtrmtFclt";
import SwtrmtPrcsZoneAnalysis from "../popupComponent/SwtrmtPrcsZoneAnalysis";
type LayerComponentProps = {
featureData: any;
@@ -15,6 +16,7 @@ type LayerComponentProps = {
chmbrXcnts: string;
chmbrYdnts: string;
gisObject?: Record<string, any>; // GIS 객체 추가
rawFeatureData?: any;
};
type LayerContentMap = {
[key: string]: {
@@ -26,7 +28,7 @@ type LayerContentMap = {
// 레이어 선택시 변경되는 팝업 컨텐츠 동적으로 변경하도록 작업
const layerContentMap: LayerContentMap = {
"하수처리시설": { component: SwtrmtFclt, }, // 디지털 자료관 연계정보가 없으니 일단 주석 처리
"하수처리구역": {},
"하수처리구역": { component: SwtrmtPrcsZoneAnalysis },
"우수토실": { component: Ssc, },
"중점우수토실": { component: Ssc, }, // 중점우수토실 여부 설정
"하수관로": { component: MsSwagePpl, },
@@ -65,4 +67,4 @@ export const handleInputChange = (section: string,
updated[index][field as keyof typeof updated[number]] = value;
}
target.setData(updated);
};
};
@@ -20,7 +20,7 @@ const headList = [
{ id: "title", title: "하수도정비 기본계획보고서" },
];
const SwtrmtFclt = ({ gisObject }: { gisObject: any }) => {
const SwtrmtFclt = ({ gisObject }: { gisObject?: any }) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const ctpvNm = gisObject?.ctpv_nm;
@@ -70,4 +70,4 @@ const SwtrmtFclt = ({ gisObject }: { gisObject: any }) => {
)
}
export default SwtrmtFclt
export default SwtrmtFclt
@@ -0,0 +1,161 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { Table, TableBody, TableCell, TableRow } from "@common/components";
import { fetchSwtrmtPrcsZoneAnalysis } from "./swtrmtPrcsZoneAnalysisApi";
import type { SwtrmtPrcsZoneAnalysisMetric, SwtrmtPrcsZoneAnalysisResponse } from "./swtrmtPrcsZoneAnalysisApi";
const analysisRows: { key: keyof NonNullable<SwtrmtPrcsZoneAnalysisResponse["analysis"]>; title: string }[] = [
{ key: "total", title: "전체" },
{ key: "combined", title: "합류관" },
{ key: "rain", title: "우수관" },
{ key: "sewage", title: "오수관" },
];
const formatValue = (value: unknown, unit = "") => {
if (value === null || value === undefined || value === "") return "데이터 없음";
const numberValue = typeof value === "number" ? value : Number(String(value).replaceAll(",", ""));
if (!Number.isNaN(numberValue) && Number.isFinite(numberValue)) {
const formatted = numberValue.toLocaleString("ko-KR", { maximumFractionDigits: 2 });
return unit ? `${formatted}${unit}` : formatted;
}
return String(value);
};
const getMetric = (
analysis: SwtrmtPrcsZoneAnalysisResponse["analysis"] | undefined,
key: keyof NonNullable<SwtrmtPrcsZoneAnalysisResponse["analysis"]>
): SwtrmtPrcsZoneAnalysisMetric => analysis?.[key] ?? {};
const metricGroups: {
title: string;
field: keyof SwtrmtPrcsZoneAnalysisMetric;
}[] = [
{ title: "평균매설년도(년)", field: "avgInstlYear" },
{ title: "최장매설년도(년)", field: "oldestInstlYear" },
{ title: "최신매설년도(년)", field: "latestInstlYear" },
];
const MetricVerticalGrid = ({
title,
field,
analysis,
isLoading,
}: {
title: string;
field: keyof SwtrmtPrcsZoneAnalysisMetric;
analysis: SwtrmtPrcsZoneAnalysisResponse["analysis"] | undefined;
isLoading?: boolean;
}) => (
<div className="mb-4 last:mb-0">
<div className="text-xl mx-3 mb-2 mt-1 font-medium text-label">{title}</div>
<Table variant="secondary">
<TableBody className="text-center">
{analysisRows.map((row) => {
const metric = getMetric(analysis, row.key);
return (
<TableRow key={`${title}-${row.key}`}>
<TableCell className="font-bold bg-blue-100 text-[13px] w-[120px] whitespace-nowrap">
{row.title}
</TableCell>
<TableCell className="text-[13px]">{isLoading ? "" : formatValue(metric[field])}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
const SwtrmtPrcsZoneAnalysis = ({
featureData,
rawFeatureData,
}: {
featureData: any;
rawFeatureData?: any;
}) => {
const [analysisData, setAnalysisData] = useState<SwtrmtPrcsZoneAnalysisResponse | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const requestKey = useMemo(() => {
const rawValues = rawFeatureData?.values_ ?? rawFeatureData ?? {};
return [
rawFeatureData?.getId?.(),
rawFeatureData?.id_,
rawFeatureData?.fid,
rawValues?.fid,
rawValues?.swtrmt_zone_sn,
rawValues?.swtrmt_fclt_cd,
featureData?.fid,
featureData?.swtrmtZoneSn,
featureData?.swtrmt_zone_sn,
featureData?.swtrmtFcltCd,
featureData?.swtrmt_fclt_cd,
].filter(Boolean).join("|");
}, [featureData, rawFeatureData]);
useEffect(() => {
let isMounted = true;
const fetchAnalysis = async () => {
setIsLoading(true);
setErrorMessage("");
setAnalysisData(null);
try {
const data = await fetchSwtrmtPrcsZoneAnalysis(rawFeatureData, featureData);
if (!isMounted) return;
if (!data) {
setErrorMessage("분석정보 조회에 필요한 처리구역 식별값이 없습니다.");
return;
}
setAnalysisData(data);
} catch (error) {
if (!isMounted) return;
console.error("하수처리구역 분석정보 로딩 중 오류:", error);
setErrorMessage("분석정보를 불러오지 못했습니다.");
} finally {
if (isMounted) setIsLoading(false);
}
};
fetchAnalysis();
return () => {
isMounted = false;
};
}, [featureData, rawFeatureData, requestKey]);
return (
<>
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl mx-3 font-medium text-label">처리구역내 관로분석정보</h3>
</div>
<div className="card !mx-[1.2rem] !mb-[1.2rem] !mt-1 !p-0">
<div className="px-3 pb-3 pt-1">
{!isLoading && errorMessage && (
<div className="py-6 text-center text-[13px] text-gray-500">{errorMessage}</div>
)}
{(isLoading || !errorMessage) && (
metricGroups.map((group) => (
<MetricVerticalGrid
key={group.field}
title={group.title}
field={group.field}
analysis={analysisData?.analysis}
isLoading={isLoading}
/>
))
)}
</div>
</div>
</>
);
};
export default SwtrmtPrcsZoneAnalysis;
@@ -626,7 +626,8 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
onSaveClick={handleSaveClick}
chmbrXcnts={chmbrXcnts}
chmbrYdnts={chmbrYdnts}
gisObject={featureData.values_}
gisObject={featureData?.values_ ?? featureData}
rawFeatureData={featureData}
/>
}
</div>
@@ -0,0 +1,99 @@
import { getData } from "@/components/lib/api";
export type SwtrmtPrcsZoneAnalysisMetric = {
avgInstlYear?: number | string | null;
oldestInstlYear?: number | string | null;
latestInstlYear?: number | string | null;
pipeCount?: number | string | null;
pipeLength?: number | string | null;
};
export type SwtrmtPrcsZoneAnalysisResponse = {
zone?: {
zoneId?: string | number | null;
swtrmtZoneNm?: string | null;
ctpvCd?: string | null;
ctpvNm?: string | null;
sggCd?: string | null;
sggNm?: string | null;
swtrmtFcltCd?: string | null;
swtrmtFcltNm?: string | null;
};
analysis?: {
total?: SwtrmtPrcsZoneAnalysisMetric | null;
combined?: SwtrmtPrcsZoneAnalysisMetric | null;
rain?: SwtrmtPrcsZoneAnalysisMetric | null;
sewage?: SwtrmtPrcsZoneAnalysisMetric | null;
};
excluded?: {
missingInstlYearCount?: number | string | null;
};
};
type ZoneAnalysisSource = Record<string, any> | null | undefined;
const ANALYSIS_ENDPOINT_PREFIX = "v1/swagePrcsZones";
const firstValue = (...values: unknown[]) => {
const value = values.find((item) => {
if (item === null || item === undefined) return false;
const text = String(item).trim();
return text !== "" && text !== "-";
});
return value === undefined ? "" : String(value).trim();
};
export const getSwtrmtPrcsZoneId = (rawFeatureData: any, featureData: ZoneAnalysisSource) => {
const rawValues = rawFeatureData?.values_ ?? rawFeatureData ?? {};
return firstValue(
rawFeatureData?.getId?.(),
rawFeatureData?.id_,
rawFeatureData?.fid,
rawValues?.fid,
featureData?.fid,
rawValues?.swtrmt_zone_sn,
rawValues?.swtrmtZoneSn,
featureData?.swtrmt_zone_sn,
featureData?.swtrmtZoneSn,
rawValues?.zoneId,
featureData?.zoneId,
rawValues?.swtrmt_fclt_cd,
rawValues?.swtrmtFcltCd,
featureData?.swtrmt_fclt_cd,
featureData?.swtrmtFcltCd
);
};
export const getSwtrmtPrcsZoneAnalysisParams = (rawFeatureData: any, featureData: ZoneAnalysisSource) => {
const rawValues = rawFeatureData?.values_ ?? rawFeatureData ?? {};
const params = new URLSearchParams();
const entries = {
ctpvCd: firstValue(rawValues?.ctpv_cd, rawValues?.ctpvCd, featureData?.ctpv_cd, featureData?.ctpvCd),
sggCd: firstValue(rawValues?.sgg_cd, rawValues?.sggCd, featureData?.sgg_cd, featureData?.sggCd),
swtrmtFcltCd: firstValue(rawValues?.swtrmt_fclt_cd, rawValues?.swtrmtFcltCd, featureData?.swtrmt_fclt_cd, featureData?.swtrmtFcltCd),
swtrmtZoneNm: firstValue(rawValues?.swtrmt_zone_nm, rawValues?.swtrmtZoneNm, featureData?.swtrmt_zone_nm, featureData?.swtrmtZoneNm),
};
Object.entries(entries).forEach(([key, value]) => {
if (value) params.append(key, value);
});
return params;
};
export const fetchSwtrmtPrcsZoneAnalysis = async (
rawFeatureData: any,
featureData: ZoneAnalysisSource
): Promise<SwtrmtPrcsZoneAnalysisResponse | null> => {
const zoneId = getSwtrmtPrcsZoneId(rawFeatureData, featureData);
if (!zoneId) return null;
const params = getSwtrmtPrcsZoneAnalysisParams(rawFeatureData, featureData);
const query = params.toString();
const path = `${ANALYSIS_ENDPOINT_PREFIX}/${encodeURIComponent(zoneId)}/pplAnalysis${query ? `?${query}` : ""}`;
return (await getData(path)) ?? {};
};
+25 -19
View File
@@ -6,7 +6,7 @@
*
* - 쿠키 기반 인증 토큰 검증 및 자동 로그인 처리
* - 로그인 성공 시 지도 페이지로 리다이렉트
* - 기존 토큰 존재 시 바로 지도 페이지 이동
* - 개발 환경에서는 루트 진입 시 자동 로그인 후 지도 페이지 이동
* - 로그인 실패 시 에러 로깅 및 처리
* - 사용자 세션 관리 및 초기 라우팅 제어
* - Next.js useRouter를 통한 클라이언트 사이드 라우팅
@@ -17,28 +17,34 @@
"use client";
import { LoginApi } from "@/entities/auth";
import { useEffect } from "react";
import { getCookie } from "@/shared/lib";
import { AUTH_USER_ID } from "@/shared/config";
const DEV_HOSTNAMES = new Set(["localhost", "127.0.0.1", "con.mind-one.co.kr"]);
const DEV_LOGIN_PAYLOAD = {
userId: process.env.NEXT_PUBLIC_DEV_LOGIN_USER_ID || "CLEANWATER",
password: process.env.NEXT_PUBLIC_DEV_LOGIN_PASSWORD || "gktneh12!@",
};
export default function Home() {
const checkToken = getCookie(AUTH_USER_ID);
useEffect(() => {
const hostname = window.location.hostname;
const isDevHost = DEV_HOSTNAMES.has(hostname);
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "con.mind-one.co.kr") {
if (!checkToken) {
async function doLogin() {
try {
await LoginApi.login({ userId: "CLEANWATER", password: "gktneh12!@" });
window.location.href = "/map";
} catch (err) {
console.error("로그인 실패", err);
}
}
doLogin();
} else window.location.href = "/map";
} else window.location.href = "/map";
if (!isDevHost) {
window.location.replace("/map");
return;
}
async function doLogin() {
try {
// 개발 환경에서는 남아 있는 만료/부분 쿠키로 인한 /map <-> / 루프를 막기 위해 항상 토큰을 재발급한다.
await LoginApi.login(DEV_LOGIN_PAYLOAD);
window.location.replace("/map");
} catch (err) {
console.error("로그인 실패", err);
}
}
void doLogin();
}, []);
}
+3 -1
View File
@@ -4,6 +4,7 @@
- 확인됨: 프론트엔드는 백엔드 `sewerage-service` API와 연동한다. 근거: `apps/sn/AGENTS.md`
- 확인됨: 백엔드 응답 구조나 API 계약을 프론트엔드에서 임의로 변경했다고 가정하지 않는다. 근거: `apps/sn/AGENTS.md`
- 확인됨: API 계약 변경이 필요하면 백엔드 작업으로 분리하고 영향 범위를 먼저 정리한다. 근거: `apps/sn/AGENTS.md`
- 확인됨: 신규 또는 변경 API URL은 백엔드 기준에 맞춰 업무 리소스 path segment를 lowerCamelCase로 맞춘다. 근거: `COMMON_DOCS_ROOT/기준문서/010-AI개발작업흐름.md`, `BE_DOCS_ROOT/기준문서/005-API정책.md`
## 응답 처리 기준
- 확인됨: 백엔드 공통 응답은 `status`, `message`, `data`, `timestamp` 형태로 내려오는 API가 존재한다. 근거: 사용자 확인 응답 예시, 백엔드 `docs/응답표준화/02-백엔드응답규칙.md`
@@ -13,6 +14,7 @@
## 작업카드에 남길 항목
- 확인 필요: HTTP method
- 확인 필요: URL
- 확인 필요: 신규 또는 변경 URL의 lowerCamelCase 준수 여부
- 확인 필요: query/body 파라미터
- 확인 필요: 정상 응답 예시
- 확인 필요: 오류 응답 처리 방식
@@ -21,4 +23,4 @@
## 금지 기준
- 확인됨: 프론트엔드에서 백엔드 API 계약 변경을 전제로 구현하지 않는다. 근거: `apps/sn/AGENTS.md`
- 확인됨: 백엔드 응답을 임시로 우회하기 위한 화면 전용 하드코딩을 추가하지 않는다. 근거: `apps/sn/AGENTS.md`
- 확인됨: 백엔드 응답을 임시로 우회하기 위한 화면 전용 하드코딩을 추가하지 않는다. 근거: `apps/sn/AGENTS.md`
@@ -4,6 +4,10 @@
- 확인됨: 기존 `sn` 화면 흐름과 사용자 동작을 가능한 유지하면서 2차 요구사항을 반영한다. 근거: `apps/sn/AGENTS.md`
- 확인됨: 복잡한 컴포넌트, 탭 분기, 폼 처리, 데이터 변환, 지도 연동, 공통 컴포넌트 조합 로직은 함수 또는 메서드 단위 주석을 상세히 작성한다. 근거: `apps/sn/AGENTS.md`
- 추정: 화면별 상태는 기존 패턴을 우선 따르고, 새 전역 상태 구조는 필요성이 확인된 경우에만 검토해야 한다. 근거: `apps/sn/AGENTS.md`
- 확인됨: 서버 조회 결과와 화면 UI 상태는 용도를 구분한다. 서버 상태는 React Query 또는 화면 전용 조회 훅을 우선 검토하고, Zustand에는 화면 전역 UI 상태, 사용자 선택, 모달 상태, 장기간 공유되는 기준 데이터만 둔다.
- 확인됨: Zustand에서 서버 기준 데이터를 조회해 보관하는 경우에는 로딩, 오류, 중복 호출 방지, 재조회 조건을 함께 정의한다.
- 확인됨: 같은 의미의 기준 데이터 store를 두 계열 이상 유지하지 않는다. 기존 store가 겹치면 조회 출처, 데이터 shape, 필터링 규칙을 비교하고 하나의 기준으로 정리한다.
- 확인됨: React Query를 사용하는 Provider는 QueryClient 인스턴스가 렌더마다 새로 생성되지 않도록 안정적으로 유지한다.
## 화면 흐름 기록 항목
- 확인 필요: 초기 진입 시 호출되는 API
@@ -11,9 +15,11 @@
- 확인 필요: 페이지네이션 또는 무한스크롤 방식
- 확인 필요: 선택 행 또는 상세 패널 상태
- 확인 필요: 등록, 수정, 삭제 후 재조회 방식
- 확인 필요: 서버 상태 캐시, 전역 store, 컴포넌트 local state 중 어디에 데이터를 둘지
- 확인 필요: 로딩, 오류, 중복 호출 방지, 재조회 조건
- 확인 필요: 팝업, 모달, 지도, 파일, 엑셀 흐름
- 확인 필요: 에러 메시지 표시 위치
## 주의 사항
- 확인됨: 요구사항 근거 없는 기능 추가는 금지한다. 근거: `apps/sn/AGENTS.md`
- 확인됨: 다른 앱에서 사용하는 공통 상태 로직을 `sn` 문제 해결 목적으로 직접 바꾸지 않는다. 근거: `apps/sn/AGENTS.md`
- 확인됨: 다른 앱에서 사용하는 공통 상태 로직을 `sn` 문제 해결 목적으로 직접 바꾸지 않는다. 근거: `apps/sn/AGENTS.md`
@@ -7,6 +7,7 @@
## 2. API 연동
- 실제 백엔드 요청 파라미터와 응답 필드를 기준으로 구현했는가
- 신규 또는 변경 API URL이 백엔드 lowerCamelCase 기준과 일치하는가
- 에러 응답 또는 예외 케이스를 화면에서 처리하는가
- 프론트 임의 가공 로직이 계약 불일치를 숨기고 있지 않은가
@@ -17,6 +18,9 @@
## 4. 코드 품질
- 컴포넌트 책임이 과도하게 섞이지 않았는가
- 상태, 이벤트, 비동기 호출이 읽기 쉽게 분리되어 있는가
- 서버 조회 결과를 store에 둘 때 로딩/오류/중복 호출 방지/재조회 기준이 있는가
- 같은 기준 데이터를 관리하는 store가 중복되어 있지 않은가
- React Query를 쓰는 경우 QueryClient가 안정적으로 유지되는가
- 이름이 역할을 설명하고 있는가
## 5. 검증
@@ -26,7 +26,7 @@ export class TokenManager {
secure: window.location.protocol === "https:",
sameSite: "lax",
//domain: domain,
//path: "/",
path: "/",
});
}