Merge branch 'iipbs/sn/dev'

This commit is contained in:
2026-09-22 09:28:50 +09:00
232 changed files with 10965 additions and 18035 deletions
@@ -185,7 +185,9 @@ export default function DemandStatusPage() {
const userData = JSON.parse(user) as SessionUser;
const dmdInfoId = userData.dmdInfoId?.trim();
if (dmdInfoId) {
getSdmInfoStatusList(dmdInfoId);
void getSdmInfoStatusList(dmdInfoId).then((active) => {
if (!active) window.location.href = "/";
});
}
} catch (error) {
console.error("Error parsing user data:", error);
@@ -341,7 +343,9 @@ export default function DemandStatusPage() {
if (res) {
const user = JSON.parse(sessionStorage.getItem("user") || "{}");
if (user?.dmdInfoId) {
getSdmInfoStatusList(user.dmdInfoId);
void getSdmInfoStatusList(user.dmdInfoId).then((active) => {
if (!active) window.location.href = "/";
});
}
setIsEditMode(false);
}
+48 -38
View File
@@ -211,18 +211,32 @@ const selectMiddleRegionData: SelectDataType[] = [
* STORE - API
*/
/** API 선택 목록의 빈 값과 중복 값을 제거하고 전체 항목을 한 번만 추가한다. */
function normalizeSelectData<T extends SelectDataType>(items: T[], allItem: T): T[] {
const uniqueItems = new Map<string, T>();
items.forEach((item) => {
const val = String(item.val ?? '').trim();
if (!val || val === '0' || uniqueItems.has(val)) return;
uniqueItems.set(val, { ...item, val });
});
return [allItem, ...uniqueItems.values()];
}
// 시도 API 호출 함수 추가
const fetchSidoData = async (): Promise<SelectDataType[]> => {
try {
const res = await getFetch(fetchType.FO, '/addr/city');
const data = res.data;
selectSidoData = [
{ text: "전체", val: '0'},
...data.map((item: sidoDataType) => ({
text: item.sido || '',
val: String(item.adstDstrCd) || ''
}))
];
selectSidoData = normalizeSelectData(
data.map((item: sidoDataType) => ({
text: item.sido || '',
val: String(item.adstDstrCd) || ''
})),
{ text: "전체", val: '0' }
);
return selectSidoData;
} catch (error) {
console.error('시도 데이터를 가져오는데 실패했습니다:', error);
@@ -235,14 +249,13 @@ const fetchSigunData = async (): Promise<SelectDataType[]> => {
try {
const res = await getFetch(fetchType.FO, '/addr/sgg');
const data = res.data;
selectSigunData = [
{ text: "전체", val: '0' },
...data
.map((item: sigunDataType) => ({
text: item.gugun || '',
val: String(item.adstDstrCd) || ''
}))
];
selectSigunData = normalizeSelectData(
data.map((item: sigunDataType) => ({
text: item.gugun || '',
val: String(item.adstDstrCd) || ''
})),
{ text: "전체", val: '0' }
);
return selectSigunData;
} catch (error) {
console.error('시군구 데이터를 가져오는데 실패했습니다:', error);
@@ -255,13 +268,13 @@ const fetchRegionData = async (): Promise<SelectDataType[]> => {
try {
const res = await getFetch(fetchType.FO, '/addr/sarea');
const data = res.data;
return [
{ text: "전체", val: '0'},
...data.map((item: regionDataType) => ({
return normalizeSelectData(
data.map((item: regionDataType) => ({
text: item.rvbsn || '',
val: String(item.rvbsnCd) || ''
}))
];
})),
{ text: "전체", val: '0' }
);
} catch (error) {
console.error('권역 데이터를 가져오는데 실패했습니다:', error);
return selectRegionData;
@@ -273,14 +286,13 @@ const fetchMiddleRegionData = async (): Promise<SelectDataType[]> => {
try {
const res = await getFetch(fetchType.FO, '/addr/midArea');
const data = res.data;
return [
{ text: "전체", val: '0' },
...data
.map((item: middleRegionDataType) => ({
return normalizeSelectData(
data.map((item: middleRegionDataType) => ({
text: item.mdmbsn || '',
val: String(item.rvbsnCd) || ''
}))
];
})),
{ text: "전체", val: '0' }
);
} catch (error) {
console.error('중권역 데이터를 가져오는데 실패했습니다:', error);
return selectMiddleRegionData;
@@ -292,14 +304,13 @@ const fetchUnitWaterShedData = async (): Promise<SelectDataType[]> => {
try {
const res = await getFetch(fetchType.FO, '/addr/largeArea');
const data = res.data;
return [
{ text: "전체", val: '0' },
...data
.map((item: unitWaterShedDataType) => ({
return normalizeSelectData(
data.map((item: unitWaterShedDataType) => ({
text: item.lgbsn || '',
val: String(item.rvbsnCd) || ''
}))
];
})),
{ text: "전체", val: '0' }
);
} catch (error) {
console.error('단위유역 데이터를 가져오는데 실패했습니다:', error);
return selectUnitWaterShedData;
@@ -311,15 +322,14 @@ const fetchEnvPartData = async (): Promise<envPartSelectDataType[]> => {
try {
const res = await getFetch(fetchType.FO, '/addr/govArea');
const data = res.data;
selectEnvPartData = [
{ text: "전체", val: '0', adstDliStrCd: '' },
...data
.map((item: envPartDataType) => ({
selectEnvPartData = normalizeSelectData(
data.map((item: envPartDataType) => ({
text: item.lclgvrnmntNm || '',
val: String(item.lclgvrnmntCd) || '',
adstDliStrCd: item.adstDliStrCd || ''
}))
];
})),
{ text: "전체", val: '0', adstDliStrCd: '' }
);
return selectEnvPartData;
} catch (error) {
console.error('환경청 데이터를 가져오는데 실패했습니다:', error);
+53 -24
View File
@@ -134,22 +134,23 @@ export type SupplyCandidate = {
export type DemandMatchings = {
splyFcltFk: string | '';
splyFcltFlg: string | '';
pbnstRuseFcltBizFk: number | '';
splyBgngDay: string | '';
splyEndDay: string | '';
splyCpct: number | '';
splyFmYn: string | '';
useUsgCd: string | '';
dmdInfoSn: number | '';
splyCndtList: string | '';
mtchnCmptnYmd: string | '';
rvwCmptnYmd: string | '';
regDt: string | '';
rgtr: string | '';
mdfcnDt: string | '';
mdfr: string | '';
splyFcltFk: string | ''; // 공급시설 외래키
splyFcltFlg: string | ''; // 공급시설 진행상태
pbnstRuseFcltBizFk: number | ''; // 공공하수 재이용시설 사업 외래키
splyBgngDay: string | ''; // 공급시작일
splyEndDay: string | ''; // 공급종료일
splyCpct: number | ''; // 계약 공급량
splyFmYn: string | ''; // 공급 유량계 유무
useUsgCd: string | ''; // 사용용도 코드
dmdInfoSn: number | ''; // 수요정보 일련번호
ruseUtztnFcltNm?: string | ''; // 재이용시설명
splyCndtList: string | ''; // 공급 후보지 목록
mtchnCmptnYmd: string | ''; // 매칭 완료일자
rvwCmptnYmd: string | ''; // 검토 완료일자
regDt: string | ''; // 등록일시
rgtr: string | ''; // 등록자
mdfcnDt: string | ''; // 수정일시
mdfr: string | ''; // 수정자
}
// 유사사례
@@ -503,6 +504,14 @@ const postSdmData = async (data: DemandCompType) => {
// 수요정보 등록
const postSdmInfoData = async (data: SdmInfoFormData): Promise<boolean> => {
try {
const activeDemand = data.dmdInfoId
? await getSdmInfoStatusList(data.dmdInfoId)
: null;
if (!activeDemand?.dmdInfoId) {
sessionStorage.removeItem("user");
alert('사용 중지되었거나 등록되지 않은 수요처입니다. 다시 로그인해 주세요.');
return false;
}
await postFetch("/reuseFc/request/demandInfo", data);
alert('수요 정보 등록에 성공하였습니다.');
return true;
@@ -516,6 +525,14 @@ const postSdmInfoData = async (data: SdmInfoFormData): Promise<boolean> => {
// 수요정보 수정
const fetchUpdateSdmInfoData = async (data: SdmInfoFormData): Promise<boolean> => {
try {
const activeDemand = data.dmdInfoId
? await getSdmInfoStatusList(data.dmdInfoId)
: null;
if (!activeDemand?.dmdInfoId) {
sessionStorage.removeItem("user");
alert('사용 중지되었거나 등록되지 않은 수요처입니다. 다시 로그인해 주세요.');
return false;
}
await postFetch("/reuseFc/request/updateDemandInfo", data);
alert('수요 정보 수정에 성공하였습니다.');
return true;
@@ -584,14 +601,19 @@ const getDemandCompList = async (searchText: string, pageCnt: number) => {
}
}
const getSdmInfoStatusList = async (dmdInfoId: string) => {
type DemandStatusResponse = DemandCompType & {
demandInfo?: DemandCompBizType[];
demandMatchings?: DemandMatchings[];
};
const getSdmInfoStatusList = async (dmdInfoId: string): Promise<DemandStatusResponse | null> => {
try {
const url = '/reuseFc/request/' + dmdInfoId + '?infoListYn=Y';
const res = await getFetch(fetchType.WR, url);
return res?.data || [];
return res?.data ?? null;
} catch (error) {
console.error(error + ' 수요정보 신청현황을 불러오지 못했습니다.');
return [];
return null;
}
}
@@ -620,7 +642,7 @@ export type SdmActions = {
updateSdmInfoData: (data: SdmInfoFormData) => Promise<boolean>;
getDemandCompList: (searchText: string, pageCnt: number) => Promise<void>;
actionLogin: (data: DemandCompType) => Promise<boolean>;
getSdmInfoStatusList: (dmdInfoId: string) => Promise<void>;
getSdmInfoStatusList: (dmdInfoId: string) => Promise<boolean>;
getSimilarCaseList: (part: string, value: number | string) => Promise<void>;
actionLogout: () => Promise<void>;
@@ -736,19 +758,25 @@ export const SdmReducer = (set: any): SdmActions => {
getSdmInfoStatusList: async (dmdInfoId: string) => {
const res = await getSdmInfoStatusList(dmdInfoId);
if (res.length === 0) {
if (!res?.dmdInfoId) {
sessionStorage.removeItem("user");
set((state: SdmType) => ({
Sdm: {
...state.Sdm,
sdmInfoStatusList: [],
successLogin: false,
loginUser: null,
}
}))
return;
toast.error("사용 중지되었거나 등록되지 않은 수요처입니다.");
return false;
}
//const sdmInfoStatusList : DemandCompBizType[] = [];
const sdmInfoStatusList: DemandCompBizType[] = res.demandInfo.map((el: DemandCompBizType, index: number) => {
const matchingInfo = res.demandMatchings.find((matching: DemandMatchings) => matching.dmdInfoSn === el.dmdInfoSn);
const demandInfo = Array.isArray(res.demandInfo) ? res.demandInfo : [];
const demandMatchings = Array.isArray(res.demandMatchings) ? res.demandMatchings : [];
const sdmInfoStatusList: DemandCompBizType[] = demandInfo.map((el: DemandCompBizType, index: number) => {
const matchingInfo = demandMatchings.find((matching: DemandMatchings) => matching.dmdInfoSn === el.dmdInfoSn);
const status = matchingInfo?.splyFcltFlg?.trim() === 'Sub' ? '검토' : matchingInfo?.splyFcltFlg?.trim() === 'matched' ? '매칭완료' : '신청';
const ruseUtztnFcltNm = matchingInfo?.ruseUtztnFcltNm ? matchingInfo.ruseUtztnFcltNm : '';
@@ -781,6 +809,7 @@ export const SdmReducer = (set: any): SdmActions => {
sdmInfoStatusList: sdmInfoStatusList,
}
}))
return true;
},
actionLogin: async (data: DemandCompType): Promise<boolean> => {
+4 -2
View File
@@ -8,8 +8,10 @@ NEXT_PUBLIC_PROXY_HOST = http://localhost:3000
NEXT_PUBLIC_SUB_DOMAIN = http://localhost:13000
#NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://localhost:3001/auth/login
SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
# FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
FO_SERVER_API_URL = http://localhost:3000/operation-service/api/v1
# WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
WR_SERVER_API_URL = http://localhost:3000/reuse-service/api/v1
CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
+34 -18
View File
@@ -237,6 +237,26 @@ export const COD_YEAR = 2021;
* STORE - API
*/
/** API 선택 목록을 값 기준으로 정규화하고 프런트 표준 전체 항목을 한 번만 추가한다. */
const normalizeSelectData = (items: SelectDataType[]): SelectDataType[] => {
const uniqueItems = new Map<string, SelectDataType>();
items.forEach((item) => {
const val = String(item.val ?? '').trim();
if (!val || val === '0' || uniqueItems.has(val)) return;
uniqueItems.set(val, {
...item,
val,
});
});
return [
{ text: '전체', val: '0' },
...uniqueItems.values(),
];
};
// 처리시설 계획정보 호출 (알에스원)
export const planInfoUrl = '/dc-service/api/v1/nowDmgMtncGnsvy';
@@ -263,13 +283,12 @@ const fetchSidoData = async (): Promise<SelectDataType[]> => {
try {
const res = await getFetch('fo', '/addr/city');
const data = res.data;
selectSidoData = [
{ text: "전체", val: '0' },
...data.map((item: sidoDataType) => ({
selectSidoData = normalizeSelectData(
data.map((item: sidoDataType) => ({
text: item.sido || '',
val: String(item.adstDstrCd) || ''
}))
];
})),
);
return selectSidoData;
} catch (error) {
console.error('시도 데이터를 가져오는데 실패했습니다:', error);
@@ -282,14 +301,12 @@ const fetchSigunData = async (): Promise<SelectDataType[]> => {
try {
const res = await getFetch('fo', '/addr/sgg');
const data = res.data;
selectSigunData = [
{ text: "전체", val: '0' },
...data
.map((item: sigunDataType) => ({
text: item.gugun || '',
val: String(item.adstDstrCd) || ''
}))
];
selectSigunData = normalizeSelectData(
data.map((item: sigunDataType) => ({
text: item.gugun || '',
val: String(item.adstDstrCd) || ''
})),
);
return selectSigunData;
} catch (error) {
console.error('시군구 데이터를 가져오는데 실패했습니다:', error);
@@ -302,13 +319,12 @@ const fetchRegionData = async (): Promise<SelectDataType[]> => {
try {
const res = await getFetch('fo', '/addr/sarea');
const data = res.data;
return [
{ text: "전체", val: '0' },
...data.map((item: regionDataType) => ({
return normalizeSelectData(
data.map((item: regionDataType) => ({
text: item.rvbsn || '',
val: String(item.rvbsnCd) || ''
}))
];
})),
);
} catch (error) {
console.error('권역 데이터를 가져오는데 실패했습니다:', error);
return selectRegionData;
+2
View File
@@ -228,6 +228,8 @@ VSCode나 별도 터미널에서 `sn` Next 개발 서버가 실행 중이면 `ne
### 프론트엔드 실행 프로세스 관리
- 모든 세션·역할·문서/검증 섹션에 공통 D:/workspace/AGENTS.md의 테스트 임시 파일·결과 파일 정리 기준을 적용한다. 일회성 검증 스크립트·캡처·Network/서버 로그·원시 결과 파일은 최종 보고서에 요약 후 삭제한다. 정식 회귀 테스트·최종 산출물·사용자 원본/복구 백업은 유지한다. 사용자 dev server가 쓰는 .next·공유 캐시·node_modules는 임시 검증물이라는 이유로 포괄 삭제하지 않는다.
- Codex 에이전트가 작업 또는 검증을 위해 직접 기동한 `next dev`, `dev:sn`, Turborepo dev와 기타 프론트엔드 장시간 실행 프로세스는 작업 종료 전에 반드시 종료한다.
- 셸, 백그라운드, 숨김 창 등 VSCode 밖에서 에이전트가 기동한 프로세스도 종료 대상이다.
- 사용자가 VSCode 또는 별도 터미널에서 직접 실행한 프론트엔드 개발 서버는 임의로 종료·재시작·교체하지 않는다. 종료가 필요하면 먼저 사용자에게 확인한다.
@@ -7,6 +7,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Input } from "@common/components";
import {
getStandardRowNumber,
StandardDataTable,
StandardPopupLayout,
} from "@common/business_components";
@@ -66,7 +67,15 @@ export default function AgedPipeStatusByCtpvPopupPage() {
const yearOptions = useMemo(() => buildAgedPipeStatusYearOptions(), []);
const columns = useMemo<ColumnDef<AgedPipeStatusByCtpvRow>[]>(
() => [
{ accessorKey: "rowNumber", header: "순번" },
{
id: "rowNumber",
header: "순번",
cell: ({ row }) =>
getStandardRowNumber({
total: rows.length,
rowIndex: row.index,
}),
},
{ accessorKey: "ctpvNm", header: "시도" },
...years.map((year) => ({
id: `year_${year}`,
@@ -87,7 +96,7 @@ export default function AgedPipeStatusByCtpvPopupPage() {
],
})),
],
[years],
[rows.length, years],
);
const pagination: PaginationState = {
@@ -118,7 +127,7 @@ export default function AgedPipeStatusByCtpvPopupPage() {
elapsedYears: String(normalized.elapsedYears),
});
const rawResponse = await getRawJson(
`v1/swagePpl/aged-status-by-ctpv?${queryParams.toString()}`,
`v1/swagePpl/agedStatusByCtpv?${queryParams.toString()}`,
);
const response = normalizeAgedPipeStatusByCtpvResponse(
rawResponse,
@@ -292,7 +301,6 @@ export default function AgedPipeStatusByCtpvPopupPage() {
table={table}
className="!w-full !max-w-full"
title="시도별 노후관로 목록"
total={rows.length}
scrollMode="horizontal"
minTableWidth={years.length > 0 ? "120rem" : "100%"}
actions={
@@ -23,7 +23,7 @@ export const downloadAgedPipeStatusByCtpvExcel = async (
elapsedYears: String(normalized.elapsedYears),
});
const response: any = await getRawJson(
`v1/swagePpl/aged-status-by-ctpv?${params.toString()}`,
`v1/swagePpl/agedStatusByCtpv?${params.toString()}`,
);
const payload = (response?.data ?? response) as AgedPipeStatusByCtpvResponse;
const years = Array.isArray(payload?.years)
@@ -4,7 +4,8 @@
*/
"use client";
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
import { type ComponentType, type ReactNode,useEffect, useState } from "react";
import {
ChartColumnBig,
ChevronUp,
@@ -15,13 +16,15 @@ import {
TriangleAlert,
Waves,
} from "lucide-react";
import { getRawJson } from "@/components/lib/api";
import { createPredictionAnalysisTimeRange } from "@/features/fldngPredcPrst/model/analysisTimeRange";
import {
windowPopupDefinitions,
type WindowPopupKey,
} from "@/features/windowPopup/model/popupDefinitions";
import { getRawJson } from "@/components/lib/api";
type Props = {
onOpenPopup: (popupKey: WindowPopupKey) => Window | null;
onOpenInfoSearch: () => void;
@@ -175,8 +178,8 @@ export default function AnalysisPopupMenu({
type="button"
className={`pointer-events-auto flex h-[40px] w-[40px] shrink-0 items-center justify-center overflow-hidden rounded-[3px] p-0 shadow-[0_1px_2px_rgba(60,64,67,0.3),0_1px_3px_1px_rgba(60,64,67,0.15)] ${
isOpen
? "bg-[#0279FA] text-white"
: "bg-white text-[#666666] hover:bg-[#0279FA] hover:text-white"
? "bg-primary text-primary-foreground"
: "bg-background text-label hover:bg-primary hover:text-primary-foreground"
}`}
onClick={() => setIsOpen((value) => !value)}
title={isOpen ? "분석 메뉴 숨기기" : "분석도구"}
@@ -192,14 +195,14 @@ export default function AnalysisPopupMenu({
id="analysis-popup-menu"
className="pointer-events-auto flex w-max min-w-[154px] flex-col overflow-hidden rounded-[6px] bg-background shadow-[0_1px_2px_rgba(60,64,67,0.3),0_1px_3px_1px_rgba(60,64,67,0.15)]"
>
<div className="flex h-[36px] shrink-0 items-center justify-between gap-2 border-b border-border bg-white pl-[10px] pr-[4px] text-[13px] font-bold text-foreground">
<div className="flex h-[36px] shrink-0 items-center justify-between gap-2 border-b border-border bg-background pl-[10px] pr-[4px] text-[13px] font-bold text-foreground">
<div className="flex items-center gap-2">
<PanelsTopLeft className="h-4 w-4 text-primary" />
<span>분석도구</span>
</div>
<button
type="button"
className="flex h-[28px] w-[28px] shrink-0 items-center justify-center rounded-[4px] text-label hover:bg-accent hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[#0279FA]"
className="flex h-[28px] w-[28px] shrink-0 items-center justify-center rounded-[4px] text-label hover:bg-accent hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
onClick={() => setIsOpen(false)}
title="분석 메뉴 숨기기"
aria-label="분석 메뉴 숨기기"
@@ -224,7 +227,7 @@ export default function AnalysisPopupMenu({
<button
key={item.key}
type="button"
className={`relative flex h-[33px] items-center gap-[7px] rounded-[5px] border px-[8px] pl-[10px] text-left text-[12px] font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[#0279FA] ${
className={`relative flex h-[33px] items-center gap-[7px] rounded-[5px] border px-[8px] pl-[10px] text-left text-[12px] font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary ${
isActive
? "border-primary bg-secondary font-bold text-primary before:absolute before:bottom-[7px] before:left-0 before:top-[7px] before:w-[3px] before:rounded-r-[2px] before:bg-primary"
: "border-transparent bg-background text-foreground hover:border-border hover:bg-accent hover:text-primary"
@@ -457,7 +457,7 @@ export default function FldngHstryPopupPage() {
onRowClick={handleRowClick}
rowId={(row, index) => String(row.fid ?? index)}
scrollMode="horizontal"
minTableWidth="220rem"
minTableWidth="140rem"
actions={
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
@@ -22,8 +22,8 @@ export const loadFldngHstryDetail = async (
fldngHstryFid: string,
): Promise<FldngHstryDetailCacheValue> => {
const [rainResult, depthResult] = await Promise.allSettled([
getData(`v1/fldng/hstry/rain-anls/${encodeURIComponent(fldngHstryFid)}`),
getData(`v1/fldng/hstry/depth-map/${encodeURIComponent(fldngHstryFid)}`),
getData(`v1/fldng/hstry/rainAnls/${encodeURIComponent(fldngHstryFid)}`),
getData(`v1/fldng/hstry/depthMap/${encodeURIComponent(fldngHstryFid)}`),
]);
if (rainResult.status === "rejected") {
@@ -127,21 +127,21 @@ export const FLDNG_HSTRY_DEPTH_LEGEND_ITEMS = [
{ color: "#f2528b", label: "5.0m 이상" },
] as const;
export const FLDNG_HSTRY_LIST_HEADLIST: UiHeadProps[] = [
{ id: "rowNo", title: "순번", widthPercent: 3 },
{ id: "floodYearDisplay", title: "침수\n년도", widthPercent: 4 },
{ id: "ctpvNm", title: "시도", widthPercent: 7 },
{ id: "sggNm", title: "시군구", widthPercent: 8 },
{ id: "disasterName", title: "침수재해명", widthPercent: 20 },
{ id: "rainBgngTmDisplay", title: "강우\n시작일시", widthPercent: 9 },
{ id: "rainEndTmDisplay", title: "강우\n종료일시", widthPercent: 9 },
{ id: "rainTotDisplay", title: "총강우량(mm)", widthPercent: 8 },
{ id: "rainStrDisplay", title: "강우강도(mm/hr)", widthPercent: 8 },
{ id: "floodHourDisplay", title: "침수시간", widthPercent: 7 },
{ id: "districtName", title: "침수지구명", widthPercent: 10 },
{ id: "avgFloodDepthDisplay", title: "평균\n침수위", widthPercent: 7 },
{ id: "floodAreaDisplay", title: "침수\n면적(ha)", widthPercent: 7 },
{ id: "exmnYearDisplay", title: "조사년도", widthPercent: 6 },
{ id: "damageAmountDisplay", title: "피해금액", widthPercent: 9 },
{ id: "rowNo", title: "순번" },
{ id: "floodYearDisplay", title: "침수\n년도" },
{ id: "ctpvNm", title: "시도" },
{ id: "sggNm", title: "시군구" },
{ id: "disasterName", title: "침수재해명" },
{ id: "rainBgngTmDisplay", title: "강우\n시작일시" },
{ id: "rainEndTmDisplay", title: "강우\n종료일시" },
{ id: "rainTotDisplay", title: "총강우량(mm)" },
{ id: "rainStrDisplay", title: "강우강도(mm/hr)" },
{ id: "floodHourDisplay", title: "침수시간" },
{ id: "districtName", title: "침수지구명" },
{ id: "avgFloodDepthDisplay", title: "평균\n침수위" },
{ id: "floodAreaDisplay", title: "침수\n면적(ha)" },
{ id: "exmnYearDisplay", title: "조사년도" },
{ id: "damageAmountDisplay", title: "피해금액" },
];
export const FLDNG_HSTRY_DOWNLOAD_HEADERS: Record<string, string> = {
rowNo: "순번",
@@ -155,7 +155,7 @@ export default function FloodDngrMapPopupPage() {
FLOOD_DNGR_MAP_PAGE_SIZE,
);
const response = await getFloodDngrMapJson(
`/sewerage-service/api/v1/fldng/flood-dngr-map?${queryString}`,
`/sewerage-service/api/v1/fldng/floodDngrMap?${queryString}`,
);
const payload = response as {
items?: FloodDngrMapApiItem[];
@@ -190,7 +190,7 @@ export default function FloodDngrMapPopupPage() {
const loadAdministrativeScope = useCallback(async () => {
try {
const response = await getFloodDngrMapJson(
"/sewerage-service/api/v1/fldng/flood-dngr-map/scope",
"/sewerage-service/api/v1/fldng/floodDngrMap/scope",
);
if (!Array.isArray(response)) {
throw new Error(
@@ -224,7 +224,7 @@ export default function FloodDngrMapPopupPage() {
sbmrgFrqExpln: row.sbmrgFrqExpln,
});
const response = await getFloodDngrMapJson(
`/sewerage-service/api/v1/fldng/flood-dngr-map/group/extent?${params.toString()}`,
`/sewerage-service/api/v1/fldng/floodDngrMap/group/extent?${params.toString()}`,
);
const payload = response as FloodDngrMapExtent;
const extent = [
@@ -287,7 +287,7 @@ export default function GisEditPipeSessionPopup({
const fetchMngInstOptions = async () => {
try {
const result = await getData("v1/fcltppl/mngInsts");
const result = await getData("v1/fcltPpl/mngInsts");
if (!isMounted) return;
setMngInstOptions(Array.isArray(result) ? result : []);
} catch {
@@ -351,7 +351,7 @@ export default function GisEditPipeSessionPopup({
const safeFcltMngNo = encodeURIComponent(fcltMngNo);
const safeMngInstCd = encodeURIComponent(mngInstCd);
const result = await getData(
`v1/fcltppl/${safeFcltMngNo}/${safeMngInstCd}`,
`v1/fcltPpl/${safeFcltMngNo}/${safeMngInstCd}`,
);
if (!isMounted || !result) return;
@@ -1,112 +1,2 @@
/** 하수관로 GIS 편집에서 사용하는 도구·세션·변경 데이터 타입을 정의한다. */
export type GisEditTool = "ADD" | "MOVE" | "VERTEX_EDIT" | "DELETE";
export type GisEditGeometryType = "Point" | "LineString" | "Polygon";
export type GisEditPreviewColors = {
pointColor: string;
lineColor: string;
fillColor: string;
};
export type GisEditFeatureRef = {
fcltMngNo?: string;
mngInstCd?: string;
fid?: string;
};
export type GisEditPipeBasicInfo = {
[key: string]: string | null | undefined;
fcltMngNo?: string | null;
mngInstCd?: string | null;
instMngNo?: string | null;
instlYmd?: string | null;
mapshtNo?: string | null;
pdmtVl?: string | null;
prlgVl?: string | null;
usgCd?: string | null;
mqltCd?: string | null;
avgGrdn?: string | null;
drtwMltvArea?: string | null;
exclnMltvArea?: string | null;
tmptDpth?: string | null;
tmptMhlMngNo?: string | null;
scendDpth?: string | null;
scendMhlMngNo?: string | null;
trflnCnt?: string | null;
vrtcLen?: string | null;
wdthLen?: string | null;
instlMbdyCd?: string | null;
swtrmtZoneNm?: string | null;
mltvZoneNm?: string | null;
mltvDtdvNm?: string | null;
lineCd?: string | null;
floCd?: string | null;
layCd?: string | null;
};
export type GisEditLayerInfo = {
layerId: string;
layerName: string;
geometryType: GisEditGeometryType;
editableYn: "Y" | "N";
allowedTools: GisEditTool[];
previewColors: GisEditPreviewColors;
disabledReason?: string;
};
export type GisEditChange = {
changeId: string;
editType: GisEditTool;
layerId: string;
layerName: string;
objectId?: string;
fid?: string;
baseEditSn?: string;
featureRef?: GisEditFeatureRef;
objectLabel: string;
beforeGeometry?: Record<string, unknown> | null;
afterGeometry?: Record<string, unknown> | null;
featureProperties?: Record<string, unknown> | null;
basicInfo?: GisEditPipeBasicInfo | null;
basicInfoSavedAt?: string | null;
attributeOnly?: boolean;
summary: string;
createdAt: string;
source: "MAP_SELECTION" | "MAP_CENTER" | "MAP_DRAW";
};
export type GisEditSavePayload = {
screenId: string;
layerId: string;
layerName: string;
saveTargetType: "VERSION" | "FINAL";
view: {
srs: string;
bbox: string | null;
};
changes: Array<{
changeId: string;
editType: GisEditTool;
objectId?: string;
clientTempId?: string;
fid?: string;
baseEditSn?: string;
featureRef?: GisEditFeatureRef;
attributes?: GisEditPipeBasicInfo | null;
beforeGeometry?: Record<string, unknown> | null;
afterGeometry?: Record<string, unknown> | null;
geometry?: Record<string, unknown> | null;
}>;
};
export type GisEditSessionSnapshot = {
isEditMode: boolean;
selectedLayerName: string;
activeTool: GisEditTool | null;
isSnapEnabled: boolean;
changes: GisEditChange[];
lastSavedPayload: GisEditSavePayload | null;
canUndo: boolean;
focusedChangeId: string | null;
};
/** GIS 편집 모델은 기능이 소유하고 기존 화면 import 경로는 유지한다. */
export * from "@/features/gisEdit/model/types";
@@ -2,484 +2,36 @@
"use client";
import { useCallback, useMemo, useRef, useState } from "react";
import GeoJSON from "ol/format/GeoJSON.js";
import {
type GisEditInteractionRequest,
isActionableChange,
isGeometryChange,
buildPreviewFocusMessage,
FLOW_DIRECTION_SRS,
SCREEN_ID,
buildPipeSaveAttributes,
EMPTY_GIS_EDIT_PIPE_BASIC_INFO,
normalizeGeometryType,
collectEditableLayers,
toGeometryObject,
buildBbox,
getFeatureLayerId,
getFeatureObjectId,
getFeatureObjectLabel,
getFeatureRef,
getFeatureProperties,
normalizePipeGeometry,
createChangeId,
} from "@/features/gisEdit";
export { EMPTY_GIS_EDIT_PIPE_BASIC_INFO } from "@/features/gisEdit";
import type {
GisEditChange,
GisEditFeatureRef,
GisEditGeometryType,
GisEditLayerInfo,
GisEditPipeBasicInfo,
GisEditPreviewColors,
GisEditSavePayload,
GisEditTool,
} from "./gisEditTypes";
type GisEditInteractionRequest = {
changeId: string;
editType: "MOVE" | "VERTEX_EDIT";
};
const isActionableChange = (change: GisEditChange) =>
!change.attributeOnly || Boolean(change.basicInfoSavedAt);
const isGeometryChange = (change: GisEditChange) =>
!change.attributeOnly && change.editType !== "ADD";
const buildPreviewFocusMessage = (
change: Pick<GisEditChange, "editType" | "layerName" | "objectLabel">,
) => {
if (change.editType === "MOVE") {
return `${change.layerName} ${change.objectLabel} 미리보기 객체를 드래그해 위치를 조정하세요.`;
}
if (change.editType === "VERTEX_EDIT") {
return `${change.layerName} ${change.objectLabel}의 주황색 실꼭지점을 드래그하거나, 선분 위 흰색 midpoint를 끌어 새 꼭지점을 추가하세요.`;
}
if (change.editType === "DELETE") {
return `${change.layerName} ${change.objectLabel} 삭제 미리보기를 유지 중입니다.`;
}
return `${change.layerName} ${change.objectLabel} 신규 객체 미리보기를 확인하세요.`;
};
const EDITABLE_LAYER_RULES: Record<
string,
Omit<GisEditLayerInfo, "layerId" | "editableYn">
> = {
tb_ms_swage_ppl: {
layerName: "하수관로",
geometryType: "LineString",
allowedTools: ["ADD", "MOVE", "VERTEX_EDIT", "DELETE"],
previewColors: {
pointColor: "rgba(22, 163, 74, 0.92)",
lineColor: "rgba(22, 163, 74, 0.95)",
fillColor: "rgba(22, 163, 74, 0.18)",
},
},
tb_ms_swage_mhl: {
layerName: "하수맨홀",
geometryType: "Point",
allowedTools: ["ADD", "MOVE", "DELETE"],
previewColors: {
pointColor: "rgba(5, 150, 105, 0.92)",
lineColor: "rgba(5, 150, 105, 0.95)",
fillColor: "rgba(5, 150, 105, 0.18)",
},
},
tb_ms_swage_pmstn: {
layerName: "하수펌프장",
geometryType: "Point",
allowedTools: ["ADD", "MOVE", "DELETE"],
previewColors: {
pointColor: "rgba(59, 130, 246, 0.92)",
lineColor: "rgba(59, 130, 246, 0.95)",
fillColor: "rgba(59, 130, 246, 0.18)",
},
},
tb_il_swage_prcs_fclt: {
layerName: "하수처리시설",
geometryType: "Point",
allowedTools: ["ADD", "MOVE", "DELETE"],
previewColors: {
pointColor: "rgba(168, 85, 247, 0.92)",
lineColor: "rgba(168, 85, 247, 0.95)",
fillColor: "rgba(168, 85, 247, 0.18)",
},
},
tb_ms_swage_prcs_zone: {
layerName: "하수처리구역",
geometryType: "Polygon",
allowedTools: ["ADD", "MOVE", "VERTEX_EDIT", "DELETE"],
previewColors: {
pointColor: "rgba(249, 115, 22, 0.92)",
lineColor: "rgba(249, 115, 22, 0.95)",
fillColor: "rgba(249, 115, 22, 0.22)",
},
},
tb_ms_stofcbr: {
layerName: "우수토실",
geometryType: "Point",
allowedTools: ["ADD", "MOVE", "DELETE"],
previewColors: {
pointColor: "rgba(234, 88, 12, 0.92)",
lineColor: "rgba(234, 88, 12, 0.95)",
fillColor: "rgba(234, 88, 12, 0.18)",
},
},
om_dt_untrt_swg_rain: {
layerName: "중점우수토실",
geometryType: "Point",
allowedTools: ["ADD", "MOVE", "DELETE"],
previewColors: {
pointColor: "rgba(220, 38, 38, 0.92)",
lineColor: "rgba(220, 38, 38, 0.95)",
fillColor: "rgba(220, 38, 38, 0.18)",
},
},
};
const FLOW_DIRECTION_SRS = "EPSG:5179";
const SCREEN_ID = "SN_MN010301";
const GIS_EDIT_ALLOWED_LAYER_IDS = new Set(["tb_ms_swage_ppl"]);
const GIS_EDIT_PIPE_ATTRIBUTE_FIELD_IDS = [
"mngInstCd",
"fcltMngNo",
"instMngNo",
"instlYmd",
"mapshtNo",
"pdmtVl",
"prlgVl",
"usgCd",
"mqltCd",
"avgGrdn",
"drtwMltvArea",
"exclnMltvArea",
"tmptDpth",
"tmptMhlMngNo",
"scendDpth",
"scendMhlMngNo",
"trflnCnt",
"vrtcLen",
"wdthLen",
"instlMbdyCd",
"swtrmtZoneNm",
"mltvZoneNm",
"mltvDtdvNm",
"lineCd",
"floCd",
"layCd",
] as const;
const buildPipeSaveAttributes = (basicInfo: GisEditPipeBasicInfo | null | undefined) => {
if (!basicInfo) return undefined;
return GIS_EDIT_PIPE_ATTRIBUTE_FIELD_IDS.reduce<GisEditPipeBasicInfo>((acc, fieldId) => {
if (!Object.prototype.hasOwnProperty.call(basicInfo, fieldId)) return acc;
const value = basicInfo[fieldId];
const normalizedValue = fieldId === "instlYmd" ? String(value ?? "").replaceAll("-", "") : value;
acc[fieldId] =
typeof normalizedValue === "string" && normalizedValue.trim() === ""
? null
: normalizedValue;
return acc;
}, {});
};
export const EMPTY_GIS_EDIT_PIPE_BASIC_INFO: GisEditPipeBasicInfo = {
mngInstCd: "",
fcltMngNo: "",
instMngNo: "",
instlYmd: "",
usgCd: "",
instlMbdyCd: "",
swtrmtZoneNm: "",
mltvZoneNm: "",
mltvDtdvNm: "",
lineCd: "",
floCd: "",
layCd: "",
};
const normalizeLayerIdCandidate = (value: unknown) => {
const raw = String(value ?? "").trim();
if (!raw) return "";
const withoutNamespace = raw.includes(":") ? raw.split(":").pop() ?? raw : raw;
return withoutNamespace.split(".")[0] ?? withoutNamespace;
};
// 현재 레이어 트리 구조 안에서 실제 편집 대상으로 풀 수 있는 레이어만 추출한다.
const getLayerTypeName = (layerInfo: any) =>
String(layerInfo?.lyrTyNm ?? layerInfo?.lyrTypeNm ?? layerInfo?.lyr_type_nm ?? "").trim();
const getLayerName = (layerInfo: any) =>
String(layerInfo?.lyrNm ?? layerInfo?.name ?? "").trim();
const normalizeGeometryType = (value: unknown): GisEditGeometryType => {
const normalized = String(value ?? "").toLowerCase();
if (normalized.includes("polygon")) return "Polygon";
if (normalized.includes("line")) return "LineString";
return "Point";
};
const getColorString = (value: unknown) => {
const resolved = String(value ?? "").trim();
return resolved || null;
};
const withAlpha = (color: string | null | undefined, alpha: number) => {
const resolvedColor = String(color ?? "").trim();
if (!resolvedColor) return null;
const rgbaMatch = resolvedColor.match(/^rgba?\(([^)]+)\)$/i);
if (rgbaMatch) {
const [red = "40", green = "90", blue = "180"] = (rgbaMatch[1] ?? "")
.split(",")
.map((part) => part.trim());
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
const hexMatch = resolvedColor.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (hexMatch) {
const hex = hexMatch[1] ?? "";
const normalized = hex.length === 3
? hex.split("").map((char) => char + char).join("")
: hex;
const red = parseInt(normalized.slice(0, 2), 16);
const green = parseInt(normalized.slice(2, 4), 16);
const blue = parseInt(normalized.slice(4, 6), 16);
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
return resolvedColor;
};
const resolvePreviewColors = (
layerInfo: any,
fallback: GisEditPreviewColors,
): GisEditPreviewColors => {
const lineColor =
withAlpha(
getColorString(
layerInfo?.lineColr ??
layerInfo?.line_colr ??
layerInfo?.lineColor ??
layerInfo?.fontInsdColr ??
layerInfo?.font_insd_colr,
),
0.95,
) ?? fallback.lineColor;
const pointColor =
withAlpha(
getColorString(
layerInfo?.lineColr ??
layerInfo?.line_colr ??
layerInfo?.fontInsdColr ??
layerInfo?.font_insd_colr ??
layerInfo?.fontExtnlColr ??
layerInfo?.font_extnl_colr,
),
0.92,
) ?? fallback.pointColor;
const fillColor =
withAlpha(
getColorString(
layerInfo?.spacColr ??
layerInfo?.spac_colr ??
layerInfo?.spaceColor ??
layerInfo?.lineColr ??
layerInfo?.line_colr,
),
0.22,
) ?? fallback.fillColor;
return {
pointColor,
lineColor,
fillColor,
};
};
// 현행 운영 기준상 GIS 편집은 하수관로만 다루므로 treeData에서도 해당 레이어만 추린다.
const collectEditableLayers = (treeData: any[]): GisEditLayerInfo[] => {
const results = new Map<string, GisEditLayerInfo>();
const visit = (items: any[]) => {
items.forEach((item) => {
const data = item?.data ?? item;
const layerTypeName = getLayerTypeName(data);
const layerRule = EDITABLE_LAYER_RULES[layerTypeName];
if (
layerRule &&
GIS_EDIT_ALLOWED_LAYER_IDS.has(layerTypeName) &&
!results.has(layerTypeName)
) {
results.set(layerTypeName, {
layerId: layerTypeName,
layerName: layerRule.layerName,
geometryType: layerRule.geometryType,
editableYn: "Y",
allowedTools: layerRule.allowedTools,
previewColors: resolvePreviewColors(data, layerRule.previewColors),
});
}
if (Array.isArray(item?.children)) {
visit(item.children);
}
if (Array.isArray(data?.children)) {
visit(data.children);
}
});
};
visit(Array.isArray(treeData) ? treeData : []);
if (results.size === 0) {
Object.entries(EDITABLE_LAYER_RULES).forEach(([layerId, layerRule]) => {
if (!GIS_EDIT_ALLOWED_LAYER_IDS.has(layerId)) return;
results.set(layerId, {
layerId,
layerName: layerRule.layerName,
geometryType: layerRule.geometryType,
editableYn: "Y",
allowedTools: layerRule.allowedTools,
previewColors: layerRule.previewColors,
});
});
}
return Array.from(results.values());
};
// 지도에서 선택한 OpenLayers Feature의 현재 geometry를 저장 payload 형태로 스냅샷한다.
const toGeometryObject = (featureData: any) => {
if (!featureData || typeof featureData.getGeometry !== "function") return null;
try {
return new GeoJSON().writeGeometryObject(featureData.getGeometry(), {
featureProjection: FLOW_DIRECTION_SRS,
dataProjection: FLOW_DIRECTION_SRS,
}) as Record<string, unknown>;
} catch (error) {
console.error("GIS 편집 geometry 변환 실패:", error);
return null;
}
};
// 저장 시점의 지도 범위를 함께 남겨 BE가 편집 세션 컨텍스트를 해석할 수 있게 한다.
const buildBbox = (map: any) => {
try {
const size = map?.getSize?.();
const view = map?.getView?.();
if (!size || !view?.calculateExtent) return null;
return view.calculateExtent(size).map((value: number) => value.toFixed(3)).join(",");
} catch (error) {
console.error("GIS 편집 BBOX 계산 실패:", error);
return null;
}
};
const getFeatureLayerId = (featureData: any) => {
if (typeof featureData?.get === "function") {
const previewLayerId = normalizeLayerIdCandidate(featureData.get("layerId"));
if (previewLayerId) return previewLayerId;
}
if (typeof featureData?.getId === "function") {
return normalizeLayerIdCandidate(featureData.getId());
}
if (typeof featureData?.id_ === "string") {
return normalizeLayerIdCandidate(featureData.id_);
}
return normalizeLayerIdCandidate(featureData?.lyrTypeNm ?? featureData?.lyrTyNm ?? "");
};
const stripLayerPrefix = (value: unknown) => {
const raw = String(value ?? "").trim();
if (!raw) return "";
const withoutNamespace = raw.includes(":") ? raw.split(":").pop() ?? raw : raw;
if (withoutNamespace.startsWith("tb_ms_swage_ppl.")) {
return withoutNamespace.slice("tb_ms_swage_ppl.".length);
}
return withoutNamespace;
};
const getFeatureFid = (featureData: any) =>
stripLayerPrefix(
featureData?.get?.("fid") ??
featureData?.values_?.fid ??
featureData?.fid ??
featureData?.getId?.() ??
featureData?.id_ ??
"",
);
const getFeatureObjectId = (featureData: any) =>
String(
getFeatureFid(featureData) ||
String(
featureData?.get?.("objectId") ??
featureData?.values_?.fclt_mng_no ??
featureData?.values_?.swtrmt_fclt_cd ??
featureData?.fcltMngNo ??
featureData?.fid ??
"",
),
).trim();
const getFeatureObjectLabel = (featureData: any) => {
const values = featureData?.values_ ?? {};
return String(
featureData?.get?.("fclt_mng_no") ??
values?.fclt_mng_no ??
featureData?.fcltMngNo ??
featureData?.get?.("objectId") ??
values?.swtrmt_fclt_cd ??
getFeatureFid(featureData) ??
"",
).trim();
};
const getFeatureRef = (featureData: any): GisEditFeatureRef | undefined => {
const mapValues = featureData?.values_ ?? {};
const featureRef: GisEditFeatureRef = {
fcltMngNo: String(
featureData?.get?.("fclt_mng_no") ??
mapValues?.fclt_mng_no ??
featureData?.fcltMngNo ??
"",
).trim() || undefined,
mngInstCd: String(
featureData?.get?.("mng_inst_cd") ??
mapValues?.mng_inst_cd ??
featureData?.mngInstCd ??
"",
).trim() || undefined,
fid: getFeatureFid(featureData) || undefined,
};
if (!featureRef.fcltMngNo && !featureRef.mngInstCd && !featureRef.fid) {
return undefined;
}
return featureRef;
};
// 선택한 원본 관로의 속성 snapshot을 보관해 preview 재선택 시 상세정보가 비지 않게 한다.
const getFeatureProperties = (featureData: any): Record<string, unknown> | null => {
const values = featureData?.values_ ?? featureData?.getProperties?.() ?? featureData;
if (!values || typeof values !== "object") return null;
const { geometry, ...properties } = values;
return properties;
};
const normalizePipeGeometry = (
layerId: string,
geometry: Record<string, unknown> | null,
): Record<string, unknown> | null => {
if (layerId !== "tb_ms_swage_ppl" || !geometry) return geometry;
if (geometry.type !== "LineString") return geometry;
const coordinates = geometry.coordinates;
if (!Array.isArray(coordinates)) return geometry;
return {
...geometry,
type: "MultiLineString",
coordinates: [coordinates],
};
};
const createChangeId = (prefix: string, sequence: number) => {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return `${prefix}-${crypto.randomUUID()}`;
}
return `${prefix}-${Date.now()}-${sequence}`;
};
export const useGisEdit = (treeData: any[]) => {
const editableLayers = useMemo(() => collectEditableLayers(treeData), [treeData]);
// GIS 편집 모드 on/off는 우측 분석 메뉴의 GIS 편집 버튼과 직접 연결된다.
@@ -10,16 +10,7 @@ import {
StandardPopupLayout,
type UiHeadProps,
} from "@common/business_components";
import {
Button,
Input,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import { Button, Input } from "@common/components";
import {
getBlobDataWithHeaders,
@@ -29,43 +20,23 @@ import {
} from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
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";
import type { UploadFacilityType, MngInstOption, UploadHistoryItem, GisUploadResponse, ValidationErrorItem, ViewMode } from "@/features/gisManagement/model/upload-types";
import { getLocalizedValidationErrorMessage, getFileExtension, formatDateTime, toScopeKey, buildManagementLabel, getErrorMessage } from "@/features/gisManagement/lib/upload-display";
import { createGisDownloadClient, saveBlob } from "@/features/gisManagement/api/gis-download";
import { postRefreshMessageToOpener } from "@/features/gisManagement/lib/popup-refresh";
type UploadFacilityType = {
facilityTypeCode: string;
facilityTypeName: string;
layerId: string;
allowedExtensions: string[];
maxFileSizeMb: number;
requiredDownloadFormat: string;
};
import { createGisUploadRetryGuard, getGisPartialSuccessNotice } from "@/features/gisManagement";
type MngInstOption = {
mngInstCd: string;
lwrInstNm: string;
};
// 기존 전송 경로를 유지하며 다운로드 작업의 비동기 처리는 기능 모듈에 위임한다.
const { downloadGeoPackage, startDownloadJob, waitForDownloadJob } =
createGisDownloadClient({ getData, postRawJson, getBlobDataWithHeaders });
type UploadHistoryItem = {
uploadId: string;
downloadId?: string;
mngInstCd?: string | null;
mngInstNm?: string | null;
fileName: string;
status?: string;
totalCount?: number | null;
changedCount?: number | null;
invalidCount?: number | null;
registeredAt: string;
registeredBy: string;
registeredByName?: string | null;
message?: string | null;
};
type UploadHistoryRow = UploadHistoryItem &
Record<string, unknown> & {
@@ -85,45 +56,23 @@ const GIS_UPLOAD_HISTORY_HEADLIST: UiHeadProps[] = [
{ id: "registeredByLabel", title: "등록자", widthPercent: 11 },
];
type GisUploadResponse = {
uploadId: string;
status: "COMPLETED" | "VALIDATION_FAILED" | string;
mapRefreshRequired?: boolean;
refreshLayerTypeNames?: string[];
validationErrors?: ValidationErrorItem[];
message?: string;
};
type ValidationErrorItem = {
rowNo?: number | null;
fid?: string | null;
errorCode?: string | null;
errorMessage?: string | null;
};
/** 오류 원본 행번호와 공통 표의 역순 순번을 별도 열로 표시한다. */
const GIS_VALIDATION_ERROR_HEADLIST: UiHeadProps[] = [
{ id: "no", title: "순번", widthPercent: 8 },
{ id: "sourceRowNo", title: "행번호", widthPercent: 10 },
{ id: "fid", title: "FID", widthPercent: 22 },
{ id: "errorMessage", title: "오류내용", align: "left", widthPercent: 60 },
];
type ViewMode = "history" | "validationErrors" | "preview";
type DownloadJob = {
jobId: string;
status: "QUEUED" | "RUNNING" | "READY" | "FAILED" | string;
message?: string | null;
errorCode?: string | null;
downloadId?: string | null;
fileName?: string | null;
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";
const GIS_UPLOAD_PAGE_SIZE = 10;
const GIS_ERROR_PAGE_SIZE = 10;
const SELECT_MNG_INST_VALUE = "";
// A current/history download is an idempotent GET, so one retry is safe after a transient connection drop.
const DOWNLOAD_NETWORK_RETRY_DELAY_MS = 500;
const DOWNLOAD_JOB_POLL_INTERVAL_MS = 1_000;
const DOWNLOAD_JOB_MAX_WAIT_MS = 30 * 60 * 1_000;
const DEFAULT_FACILITY: UploadFacilityType = {
facilityTypeCode: FACILITY_TYPE_CODE,
facilityTypeName: "하수관로",
@@ -133,175 +82,24 @@ const DEFAULT_FACILITY: UploadFacilityType = {
requiredDownloadFormat: "GPKG",
};
/** 백엔드 검증 코드를 사용자가 이해할 수 있는 한글 오류내용으로 변환한다. */
const GIS_VALIDATION_ERROR_MESSAGES: Record<string, string> = {
GIS_UPLOAD_MNG_INST_MISMATCH:
"선택한 관리기관과 다른 관로가 포함되어 있습니다.",
GIS_UPLOAD_FID_DUPLICATED: "업로드 파일에 중복된 시설물 식별번호가 있습니다.",
GIS_UPLOAD_DELETE_LIMIT_EXCEEDED:
"삭제 대상 건수가 허용 범위를 초과했습니다.",
};
/** 형상 오류는 같은 오류코드 안에서도 누락과 형식 오류를 구분해 안내한다. */
const getLocalizedValidationErrorMessage = (error: ValidationErrorItem) => {
const errorCode = String(error.errorCode ?? "").trim();
const errorMessage = String(error.errorMessage ?? "").trim();
if (errorCode === "GIS_UPLOAD_INVALID_GEOMETRY") {
return errorMessage.toLowerCase().includes("없는 feature")
? "공간정보가 없는 시설물입니다."
: "하수관로는 선 또는 다중선 형식의 공간정보만 업로드할 수 있습니다.";
}
const localizedMessage = GIS_VALIDATION_ERROR_MESSAGES[errorCode];
if (localizedMessage) {
const count = errorMessage.match(/count\s*=\s*(\d+)/i)?.[1];
return count ? `${localizedMessage} (대상: ${count}건)` : localizedMessage;
}
// 서버가 완전한 한글 문구를 제공한 신규 오류만 보존하고 영문이 섞인 문구는 일반 안내로 대체한다.
return /[가-힣]/.test(errorMessage) && !/[A-Za-z]/.test(errorMessage)
? errorMessage
: "업로드 파일 검증 중 오류가 발생했습니다.";
};
const getFileExtension = (fileName: string) => {
const lastDotIndex = fileName.lastIndexOf(".");
if (lastDotIndex < 0) return "";
return fileName.slice(lastDotIndex).toLowerCase();
};
const formatDateTime = (value?: string | null) => {
if (!value) return "-";
return value.replace("T", " ").slice(0, 19);
};
const toScopeKey = (mngInstCd: string) => mngInstCd || "__UNSELECTED__";
const buildManagementLabel = (
mngInstCd?: string | null,
mngInstNm?: string | null,
) => {
if (!mngInstCd) return "-";
return mngInstNm || mngInstCd;
};
// Chrome reports an interrupted fetch as TypeError without an HTTP response.
const isDownloadNetworkError = (error: unknown) => {
if (!(error instanceof TypeError)) return false;
const message = error.message.toLowerCase();
return (
message.includes("failed to fetch") || message.includes("networkerror")
);
};
// Retry only transport failures; HTTP validation and server errors must be shown immediately.
const downloadGeoPackage = async (url: string) => {
try {
return await getBlobDataWithHeaders(url);
} catch (error) {
if (!isDownloadNetworkError(error)) throw error;
console.warn("GIS 다운로드 연결이 중단되어 1회 재시도합니다.", error);
await new Promise((resolve) =>
window.setTimeout(resolve, DOWNLOAD_NETWORK_RETRY_DELAY_MS),
);
return getBlobDataWithHeaders(url);
}
};
// 파일 생성 요청과 상태 조회를 짧은 HTTP 요청으로 분리해 서버의 10초 요청 제한을 넘지 않는다.
const startDownloadJob = async (url: string): Promise<DownloadJob> => {
const response = await postRawJson(url, {});
const job = (response?.data ?? response) as DownloadJob | null;
if (!job?.jobId) {
throw new Error("다운로드 작업 접수 정보를 확인하지 못했습니다.");
}
return job;
};
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(
job.message || "GeoPackage 다운로드 파일 생성에 실패했습니다.",
);
}
const retryAfter = Math.max(
Number(job?.retryAfterMillis) || DOWNLOAD_JOB_POLL_INTERVAL_MS,
DOWNLOAD_JOB_POLL_INTERVAL_MS,
);
await new Promise((resolve) => window.setTimeout(resolve, retryAfter));
}
throw new Error(
"GeoPackage 파일 생성 대기 시간이 초과되었습니다. 다시 시도하세요.",
);
};
// Keep the object URL alive until Chrome has accepted the synthetic download click.
const saveBlob = (blob: Blob, fileName: string) => {
const url = window.URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => window.URL.revokeObjectURL(url), 1_000);
};
const getErrorMessage = (error: unknown, fallback: string) => {
const apiError = error as any;
return (
apiError?.response?.data?.message ??
apiError?.response?.data?.data?.message ??
apiError?.body?.message ??
apiError?.message ??
fallback
);
};
const postRefreshMessageToOpener = (response: GisUploadResponse) => {
if (
!response.mapRefreshRequired ||
typeof window === "undefined" ||
!window.opener
) {
return;
}
window.opener.postMessage(
{
type: WINDOW_POPUP_MESSAGE_TYPE.GIS_UPLOAD_COMPLETED,
payload: {
refreshLayerTypeNames: JSON.stringify(
response.refreshLayerTypeNames ?? [LAYER_ID],
),
},
},
window.location.origin,
);
};
export default function GisManagementPopupPage() {
const alert = useAlertStore((state) => state.setMessage);
const userInfo = useUserStore((state) => state.userInfo);
// GIS 관리 코드값은 기존 분기에 사용하고 CMS 공통코드명은 화면 안내에만 사용한다.
const {
uploadStatusLabels,
downloadStatusLabels,
facilityTypeLabels,
} = useGisManagementCommonCodes();
const { uploadStatusLabels, downloadStatusLabels, facilityTypeLabels } =
useGisManagementCommonCodes();
// 파일 input은 브라우저 보안 정책상 value 직접 제어가 제한돼 ref로 선택/초기화한다.
const fileInputRef = useRef<HTMLInputElement | null>(null);
// 파일명·기관 선택을 바꿔도 부분 성공 업로드 차단을 같은 팝업 세션에 유지한다.
const uploadRetryGuardRef = useRef<ReturnType<typeof createGisUploadRetryGuard> | null>(null);
if (!uploadRetryGuardRef.current) uploadRetryGuardRef.current = createGisUploadRetryGuard();
const uploadRetryGuard = uploadRetryGuardRef.current;
const [facility, setFacility] =
useState<UploadFacilityType>(DEFAULT_FACILITY);
@@ -317,6 +115,10 @@ export default function GisManagementPopupPage() {
const [validationErrors, setValidationErrors] = useState<
ValidationErrorItem[]
>([]);
// 오류 페이지는 이력 목록의 선택·페이지와 독립적으로 업로드 ID에 고정한다.
const [errorUploadId, setErrorUploadId] = useState("");
const [errorPage, setErrorPage] = useState(1);
const [errorTotal, setErrorTotal] = useState(0);
const [viewMode, setViewMode] = useState<ViewMode>("history");
const [selectedUploadId, setSelectedUploadId] = useState("");
const [currentPage, setCurrentPage] = useState(1);
@@ -324,6 +126,17 @@ export default function GisManagementPopupPage() {
const [isLoading, setIsLoading] = useState(false);
const [notice, setNotice] = useState("");
// 오류 목록도 업로드 이력과 같은 표준 그리드를 사용하며 기존 오류 설명을 그대로 표시한다.
const validationErrorRows = useMemo(
() =>
validationErrors.map((error) => ({
sourceRowNo: error.rowNo === 0 ? "파일 전체" : (error.rowNo ?? "-"),
fid: error.fid || "-",
errorMessage: getLocalizedValidationErrorMessage(error),
})),
[validationErrors],
);
const selectedHistory = useMemo(
() =>
uploadHistory.find((history) => history.uploadId === selectedUploadId) ??
@@ -365,6 +178,9 @@ export default function GisManagementPopupPage() {
facilityTypeLabels[FACILITY_TYPE_CODE] || facility.facilityTypeName;
const latestDownloadId = downloadIdsByScope[selectedScopeKey] ?? "";
const isManagementSelected = Boolean(selectedMngInstCd);
// 기존 notice가 조회·다운로드 안내로 바뀌어도 미해결 부분 성공 경고는 유지한다.
const partialSuccessBlock = uploadRetryGuard.getBlock(selectedScopeKey);
const displayedNotice = partialSuccessBlock ? getGisPartialSuccessNotice(partialSuccessBlock) : notice;
const resetSelectedFile = () => {
setSelectedFile(null);
@@ -393,7 +209,7 @@ export default function GisManagementPopupPage() {
try {
const response = await getData(
`v1/map/gisUploads?${params.toString()}`,
`v1/map/gisUploadHistories?${params.toString()}`,
);
const items = Array.isArray(response?.items) ? response.items : [];
setUploadHistory(items);
@@ -453,7 +269,7 @@ export default function GisManagementPopupPage() {
// 관리기관 목록은 시설유형 조회 결과와 무관하게 즉시 화면 상태에 반영한다.
const loadManagementOptions = async () => {
try {
const managementOptions = await getData("v1/fcltppl/mngInsts");
const managementOptions = await getData("v1/fcltPpl/mngInsts");
if (!mounted) return;
const sortedOptions = Array.isArray(managementOptions)
@@ -590,8 +406,11 @@ export default function GisManagementPopupPage() {
readyJob.fileName ??
`swage_ppl_${selectedScopeKey}.gpkg`,
);
// 새 현재본을 실제 수신한 경우에만 다음 업로드를 허용하고 이전 선택파일을 버린다.
const previousPartialSuccess = uploadRetryGuard.acceptFreshDownload(selectedScopeKey, downloadId);
if (previousPartialSuccess) resetSelectedFile();
if (downloadId) {
setNotice(`${selectedScopeLabel} 기준 GeoPackage를 다운로드했습니다.`);
setNotice(`${selectedScopeLabel} 기준 GeoPackage를 다운로드했습니다.${previousPartialSuccess ? ` 이전 업로드 ${previousPartialSuccess.uploadId || "식별번호 확인 필요"}의 이력 복구는 관리자 확인이 필요합니다.` : ""}`);
}
} catch (error) {
console.error("GIS 다운로드 실패:", error);
@@ -604,6 +423,12 @@ export default function GisManagementPopupPage() {
};
const handleUpload = async () => {
// 버튼 disabled 외에 호출 경로에서도 동일 세션의 재업로드를 차단한다.
const blocked = uploadRetryGuard.getBlock(selectedScopeKey);
if (blocked) {
alert(getGisPartialSuccessNotice(blocked));
return;
}
if (!isManagementSelected) {
alert("관리기관을 선택하세요.");
return;
@@ -628,13 +453,20 @@ export default function GisManagementPopupPage() {
setNotice("");
try {
const response = (await postMutiPart(
"/api/gis-upload",
"/api/gisUpload",
formData,
)) as GisUploadResponse;
if (response.status === "VALIDATION_FAILED") {
await fetchHistories(1, selectedMngInstCd);
setSelectedUploadId(response.uploadId);
setErrorUploadId(response.uploadId);
setErrorPage(1);
setErrorTotal(
response.invalidCount ?? response.validationErrors?.length ?? 0,
);
setValidationErrors(
Array.isArray(response.validationErrors)
? response.validationErrors
? response.validationErrors.slice(0, GIS_ERROR_PAGE_SIZE)
: [],
);
setViewMode("validationErrors");
@@ -658,6 +490,13 @@ export default function GisManagementPopupPage() {
);
} catch (error) {
console.error("GIS 업로드 실패:", error);
const partial = uploadRetryGuard.recordFailure(selectedScopeKey, latestDownloadId, error);
if (partial) {
const message = getGisPartialSuccessNotice(partial);
setNotice(message);
alert(message);
return;
}
alert(
getErrorMessage(error, "GeoPackage 업로드 중 오류가 발생했습니다."),
);
@@ -672,6 +511,12 @@ export default function GisManagementPopupPage() {
alert("다운로드할 업로드 현황 행을 선택하세요.");
return;
}
if (selectedHistory.status === "VALIDATION_FAILED") {
alert(
"검증에 실패한 파일은 반영되지 않았습니다. 오류 확인을 이용해주세요.",
);
return;
}
setIsLoading(true);
setNotice("");
@@ -712,7 +557,10 @@ export default function GisManagementPopupPage() {
}
};
const fetchValidationErrors = async (uploadId = selectedUploadId) => {
const fetchValidationErrors = async (
uploadId = selectedUploadId,
page = 1,
) => {
if (!uploadId) {
alert("오류를 확인할 업로드 현황 행을 선택하세요.");
return;
@@ -722,11 +570,14 @@ export default function GisManagementPopupPage() {
setNotice("");
try {
const response = await getData(
`v1/map/gisUploads/${encodeURIComponent(uploadId)}/validationErrors?page=0&size=500`,
`v1/map/gisUploads/${encodeURIComponent(uploadId)}/validationErrors?page=${page - 1}&size=${GIS_ERROR_PAGE_SIZE}`,
);
setValidationErrors(
Array.isArray(response?.errors) ? response.errors : [],
);
setErrorUploadId(uploadId);
setErrorPage(page);
setErrorTotal(Number(response?.total ?? 0));
setViewMode("validationErrors");
} catch (error) {
console.error("GIS 업로드 오류 조회 실패:", error);
@@ -747,18 +598,66 @@ export default function GisManagementPopupPage() {
alert("미리보기할 업로드 현황 행을 선택하세요.");
return;
}
if (selectedHistory.status === "VALIDATION_FAILED") {
alert(
"검증에 실패한 파일은 반영되지 않았습니다. 오류 확인을 이용해주세요.",
);
return;
}
setViewMode("preview");
};
// 미리보기와 오류 목록의 기존 보조 액션을 같은 버튼 구성으로 유지한다.
const auxiliaryActions = (
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
color="primary"
disabled={!isManagementSelected || isLoading}
onClick={() => void handleRefreshHistories()}
>
목록
</Button>
<Button
type="button"
color="primary"
disabled
onClick={() => void handleDownloadSelectedHistory()}
>
GeoPackage 다운로드
</Button>
<Button
type="button"
color="white"
disabled={
!selectedHistory ||
selectedHistory.status === "VALIDATION_FAILED" ||
isLoading
}
onClick={handleOpenPreview}
>
미리보기
</Button>
<Button
type="button"
color="white"
disabled={!selectedHistory || isLoading}
onClick={() => void fetchValidationErrors()}
>
오류 확인
</Button>
</div>
);
return (
<StandardPopupLayout
title={windowPopupDefinitions.gisManagementPopup.title}
>
<LoadingOverlay isLoading={isLoading} />
{notice ? (
{displayedNotice ? (
<div className="border-b border-border bg-secondary px-5 py-2 text-[1rem] text-label">
{notice}
{displayedNotice}
</div>
) : null}
@@ -829,7 +728,7 @@ export default function GisManagementPopupPage() {
<Button
type="button"
color="primary"
disabled={!isManagementSelected || !selectedFile || isLoading}
disabled={!isManagementSelected || !selectedFile || isLoading || Boolean(partialSuccessBlock)}
onClick={() => void handleUpload()}
>
업로드
@@ -845,47 +744,12 @@ export default function GisManagementPopupPage() {
onChange={handleFileSelected}
/>
{viewMode !== "history" ? (
{viewMode === "preview" ? (
<div className="card-header flex shrink-0 items-center justify-between">
<h3 className="text-2xl font-medium text-label">
{viewMode === "preview"
? "GIS 업로드 미리보기"
: "GIS 업로드 오류 목록"}
GIS 업로드 미리보기
</h3>
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
color="primary"
disabled={!isManagementSelected || isLoading}
onClick={() => void handleRefreshHistories()}
>
목록
</Button>
<Button
type="button"
color="primary"
disabled
onClick={() => void handleDownloadSelectedHistory()}
>
GeoPackage 다운로드
</Button>
<Button
type="button"
color="white"
disabled={!selectedHistory || isLoading}
onClick={handleOpenPreview}
>
미리보기
</Button>
<Button
type="button"
color="white"
disabled={!selectedHistory || isLoading}
onClick={() => void fetchValidationErrors()}
>
오류 확인
</Button>
</div>
{auxiliaryActions}
</div>
) : null}
@@ -897,56 +761,34 @@ export default function GisManagementPopupPage() {
/>
</div>
) : viewMode === "validationErrors" ? (
<div className="card !m-0 overflow-x-auto !p-0">
<Table
variant="secondary"
className="w-full min-w-[640px] caption-bottom text-sm"
>
<TableHeader className="font-bold">
<TableRow>
{["순번", "행번호", "FID", "오류내용"].map((head) => (
<TableHead
key={head}
className="border border-border !bg-primary px-4 py-[1.1rem] text-center text-[1.2rem] font-medium !text-primary-foreground"
>
{head}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody className="text-center">
{validationErrors.length > 0 ? (
validationErrors.map((error, index) => (
<TableRow
key={`${error.rowNo ?? index}-${error.fid ?? ""}-${error.errorCode ?? ""}`}
>
<TableCell className="border border-border px-3 py-[1rem] text-[1.15rem]">
{validationErrors.length - index}
</TableCell>
<TableCell className="border border-border px-3 py-[1rem] text-[1.15rem]">
{error.rowNo ?? "-"}
</TableCell>
<TableCell className="border border-border px-3 py-[1rem] text-[1.15rem]">
{error.fid || "-"}
</TableCell>
<TableCell className="border border-border px-3 py-[1rem] text-left text-[1.15rem]">
{getLocalizedValidationErrorMessage(error)}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={4}
className="h-[calc(100dvh-28rem)] min-h-96 border border-border text-center text-[1.2rem] text-muted-foreground"
>
표시할 오류가 없습니다.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<MapPopupDataTable
rows={validationErrorRows}
headlist={GIS_VALIDATION_ERROR_HEADLIST}
title="GIS 업로드 오류 목록"
actions={auxiliaryActions}
total={errorTotal}
page={errorPage}
pageSize={GIS_ERROR_PAGE_SIZE}
onPageChange={(page) =>
void fetchValidationErrors(errorUploadId, page)
}
scrollMode="horizontal"
minTableWidth="64rem"
emptyMessage="표시할 오류가 없습니다."
fillAvailableHeight
rowId={(_row, index) => String(index)}
renderCell={(row, headId) =>
headId === "errorMessage" ? (
<span className="block whitespace-normal break-words text-left">
{row.errorMessage}
</span>
) : headId === "fid" ? (
<span className="block whitespace-normal break-all">
{row.fid}
</span>
) : undefined
}
/>
) : (
<MapPopupDataTable
rows={uploadHistoryRows}
@@ -971,7 +813,11 @@ export default function GisManagementPopupPage() {
<Button
type="button"
color="primary"
disabled={!selectedHistory || isLoading}
disabled={
!selectedHistory ||
selectedHistory.status === "VALIDATION_FAILED" ||
isLoading
}
onClick={() => void handleDownloadSelectedHistory()}
>
GeoPackage 다운로드
@@ -979,7 +825,11 @@ export default function GisManagementPopupPage() {
<Button
type="button"
color="white"
disabled={!selectedHistory || isLoading}
disabled={
!selectedHistory ||
selectedHistory.status === "VALIDATION_FAILED" ||
isLoading
}
onClick={handleOpenPreview}
>
미리보기
@@ -390,13 +390,6 @@ export const useInftwtResultMap = ({
pipeLayerRef.current?.setVisible?.(true);
pipeLayerRef.current?.changed?.();
gisMaps.current?.renderSync?.();
console.info("移⑥엯??FE overlay ?뚮뜑 寃곌낵", {
focusTargetType: mapView.focusTargetType,
zoneFeatureCount: zoneFeatures.length,
pipeFeatureCount: pipeFeatures.length,
});
await moveToStableFocus(mapView);
},
[clearLayers, ensurePipeLayer, ensureZoneLayer, gisMaps, moveToStableFocus],
@@ -149,7 +149,7 @@ export default function PipeAnalysisDetailPopupPage() {
if (scope.regionCode) params.set("scopeRegionCode", scope.regionCode);
const response = await getData(
`v1/fclts/ppl-analysis/details?${params.toString()}`,
`v1/fclts/pplAnalysis/details?${params.toString()}`,
);
const responseRows = Array.isArray(response?.data) ? response.data : [];
@@ -395,12 +395,14 @@ export default function PipeAnalysisDetailPopupPage() {
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
관경 시작
관경(mm)
</div>
<Input
className="sn-popup-input !h-[3.2rem] w-[12rem] !text-xl"
aria-label="관경 최소값"
placeholder="입력"
value={filters.diameterFrom}
onChange={(event) =>
setFilters((current) => ({
@@ -412,14 +414,11 @@ export default function PipeAnalysisDetailPopupPage() {
if (event.key === "Enter") handleSearch();
}}
/>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
관경 종료
</div>
<span className="shrink-0 text-xl font-semibold text-foreground">~</span>
<Input
className="sn-popup-input !h-[3.2rem] w-[12rem] !text-xl"
aria-label="관경 최대값"
placeholder="입력"
value={filters.diameterTo}
onChange={(event) =>
setFilters((current) => ({
@@ -431,7 +430,7 @@ export default function PipeAnalysisDetailPopupPage() {
if (event.key === "Enter") handleSearch();
}}
/>
</label>
</div>
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
<Button type="button" color="primary" onClick={handleSearch}>
@@ -5,17 +5,8 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Button,
Table,
TableBody,
TableCell,
TableHeader,
TableHead,
TableRow,
} from "@common/components";
import { Button } from "@common/components";
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";
@@ -25,7 +16,7 @@ import { useAlertStore } from "@/shared/lib";
import { useUserStore } from "@/shared/lib/use-user-store";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PipeAnalysisSummaryTable from "./PipeAnalysisSummaryTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import {
PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS,
@@ -66,17 +57,6 @@ const toExcelColumnName = (index: number) => {
return result || "A";
};
const formatGridNumber = (value: unknown) => {
const num = Number(value ?? 0);
if (!Number.isFinite(num)) {
return String(value ?? "");
}
return num.toLocaleString("ko-KR", {
minimumFractionDigits: 0,
maximumFractionDigits: 3,
});
};
const resolveErrorMessage = (error: unknown, fallbackMessage: string) =>
error instanceof Error && error.message.trim()
? error.message
@@ -158,7 +138,7 @@ export default function PipeAnalysisPopupPage() {
params.set("scopeRegionCode", nextFilters.scopeRegionCode);
const response = (await getData(
`v1/fclts/ppl-analysis/summaries?${params.toString()}`,
`v1/fclts/pplAnalysis/summaries?${params.toString()}`,
)) as PipeAnalysisSummaryResponse;
const nextMaterialColumns = Array.isArray(response?.materialColumns)
@@ -359,7 +339,6 @@ export default function PipeAnalysisPopupPage() {
[yearMaterialGroups],
);
const useHorizontalScrollTable = appliedFilters.analysisType !== "YEAR";
const summaryPageOffset =
(summaryCurrentPage - 1) * PIPE_ANALYSIS_SUMMARY_PAGE_SIZE;
const pagedRows = useMemo(
@@ -372,15 +351,6 @@ export default function PipeAnalysisPopupPage() {
),
[appliedFilters.analysisType, rows, summaryPageOffset],
);
const clickableHeadIds = useMemo(
() =>
headList
.filter((head) =>
rows.some((row) => Boolean(row.__cellFilters[head.id])),
)
.map((head) => head.id),
[headList, rows],
);
const visibleYearMaterialGroups = useMemo(
() =>
orderedYearMaterialGroups.slice(
@@ -527,165 +497,28 @@ export default function PipeAnalysisPopupPage() {
</div>
</PopupSearchPanel>
{appliedFilters.analysisType === "YEAR_MATERIAL" ? (
<div className="card card-border !m-0 min-w-0 overflow-hidden !p-0">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
{resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 목록
</h3>
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
</Button>
</div>
<div className="card !m-[1.2rem] overflow-x-auto !p-0">
<Table
variant="secondary"
className="w-max min-w-full caption-bottom text-sm"
>
<TableHeader className="font-bold">
<TableRow>
{headList.map((head) => (
<TableHead
key={head.id}
className="border border-border bg-primary px-4 py-[1.2rem] text-center text-[1.3rem] font-medium text-primary-foreground"
>
{head.id === "rowNo" ? "순번" : head.title}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody className="text-center">
{visibleYearMaterialGroups.length > 0 ? (
visibleYearMaterialGroups.flatMap((group) =>
group.rows.map((yearRow, rowIndex) => (
<TableRow
key={`${group.base.ctpvCd}-${group.base.sggCd}-${group.base.swtrmtZoneNm}-${yearRow.elapsedYearBucket}`}
>
{rowIndex === 0 ? (
<>
<TableCell
rowSpan={group.rows.length}
className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]"
>
{group.rowNo}
</TableCell>
<TableCell
rowSpan={group.rows.length}
className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]"
>
{group.ctpvNm}
</TableCell>
<TableCell
rowSpan={group.rows.length}
className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]"
>
{group.sggNm}
</TableCell>
<TableCell
rowSpan={group.rows.length}
className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]"
>
{group.swtrmtZoneNm}
</TableCell>
<TableCell
rowSpan={group.rows.length}
className="border border-border px-2 py-[1.2rem] text-right text-[1.3rem]"
>
{formatGridNumber(group.totalLengthKm)}
</TableCell>
</>
) : null}
<TableCell className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]">
{yearRow.elapsedYearLabel}
</TableCell>
{materialColumns.map((column) => {
const value = Number(
yearRow.metrics[column.key] ?? 0,
);
const isClickable =
value > 0 &&
Boolean(yearRow.cellFilters[column.key]);
return (
<TableCell
key={`${group.base.ctpvCd}-${group.base.sggCd}-${group.base.swtrmtZoneNm}-${yearRow.elapsedYearBucket}-${column.key}`}
className={`border border-border px-2 py-[1.2rem] text-right text-[1.3rem] ${
isClickable
? "cursor-pointer font-semibold text-primary underline underline-offset-2"
: ""
}`}
onClick={() => {
if (isClickable) {
handleYearMaterialCellClick(
group,
yearRow,
column.key,
);
}
}}
>
{formatGridNumber(value)}
</TableCell>
);
})}
</TableRow>
)),
)
) : (
<TableRow>
<TableCell
colSpan={headList.length}
className="h-24 border border-border text-center text-[1.3rem] text-label"
>
조회 결과가 없습니다.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{orderedYearMaterialGroups.length >
PIPE_ANALYSIS_SUMMARY_PAGE_SIZE ? (
<div className="mb-[1.2rem] flex justify-center">
<UiPageNations
total={orderedYearMaterialGroups.length}
cur={summaryCurrentPage}
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
onPageChange={setSummaryCurrentPage}
/>
</div>
) : null}
</div>
) : null}
{appliedFilters.analysisType !== "YEAR_MATERIAL" ? (
<MapPopupDataTable
rows={
pagedRows as Array<
PipeAnalysisSummaryViewRow & Record<string, unknown>
>
}
headlist={headList}
title={`${resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 목록`}
total={rows.length}
page={summaryCurrentPage}
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
onPageChange={setSummaryCurrentPage}
scrollMode={useHorizontalScrollTable ? "horizontal" : "none"}
minTableWidth={useHorizontalScrollTable ? "160rem" : "100%"}
clickableHeadIds={clickableHeadIds}
onCellClick={(row, _rowIndex, columnId) =>
handleCellClick(row, columnId)
}
rowId={(row, index) =>
`${String(row.__base.ctpvCd)}-${String(row.__base.sggCd)}-${String(row.__base.swtrmtZoneNm)}-${index}`
}
actions={
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
</Button>
}
/>
) : null}
<PipeAnalysisSummaryTable
rows={pagedRows}
groups={visibleYearMaterialGroups}
grouped={appliedFilters.analysisType === "YEAR_MATERIAL"}
heads={headList}
title={`${resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 목록`}
total={
appliedFilters.analysisType === "YEAR_MATERIAL"
? orderedYearMaterialGroups.length
: rows.length
}
page={summaryCurrentPage}
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
onPageChange={setSummaryCurrentPage}
onCellClick={handleCellClick}
onGroupedCellClick={handleYearMaterialCellClick}
actions={
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
</Button>
}
/>
</StandardPopupLayout>
);
}
@@ -0,0 +1,249 @@
"use client";
import { useMemo, type ReactNode } from "react";
import {
getCoreRowModel,
useReactTable,
type ColumnDef,
} from "@tanstack/react-table";
import {
StandardDataTable,
type StandardDataTableLayout,
type UiHeadProps,
} from "@common/business_components";
import type {
PipeAnalysisSummaryViewRow,
PipeAnalysisYearMaterialGroup,
PipeAnalysisYearMaterialGroupRow,
} from "./pipeAnalysisUtils";
type SummaryRow =
| {
kind: "flat";
value: PipeAnalysisSummaryViewRow;
sourceIndex: number;
}
| {
kind: "grouped";
group: PipeAnalysisYearMaterialGroup;
value: PipeAnalysisYearMaterialGroupRow;
groupIndex: number;
groupRowIndex: number;
};
type Props = {
rows: PipeAnalysisSummaryViewRow[];
groups: PipeAnalysisYearMaterialGroup[];
grouped: boolean;
heads: UiHeadProps[];
title: string;
total: number;
page: number;
pageSize: number;
actions: ReactNode;
onPageChange: (page: number) => void;
onCellClick: (row: PipeAnalysisSummaryViewRow, columnId: string) => void;
onGroupedCellClick: (
group: PipeAnalysisYearMaterialGroup,
row: PipeAnalysisYearMaterialGroupRow,
columnId: string,
) => void;
};
/** 지역 식별 열은 가로 스크롤해도 남겨 두고, 모든 집계 방식에 같은 너비를 사용한다. */
const FIXED_WIDTHS: Record<string, number> = {
rowNo: 56,
ctpvNm: 120,
sggNm: 120,
swtrmtZoneNm: 160,
totalLengthKm: 100,
};
/** 공통 그리드가 같은 순서로 왼쪽 고정 위치를 계산할 관로 식별 열이다. */
const PINNED_COLUMN_IDS = Object.keys(FIXED_WIDTHS);
/** 관로분석에서 선택하는 공통 표 레이아웃이며 화면 전용 CSS를 대체한다. */
const PIPE_SUMMARY_LAYOUT: StandardDataTableLayout = {
tableLayout: "fixed",
stickyHeader: true,
headerHeight: "64px",
headerWrap: true,
cellOverflow: "ellipsis",
};
/** 기존 집계와 같은 천 단위 구분 및 소수점 세 자리 표시를 유지한다. */
const formatValue = (value: unknown) => {
if (value === null || value === undefined || value === "") return "-";
return typeof value === "number"
? value.toLocaleString("ko-KR", { maximumFractionDigits: 3 })
: String(value);
};
/** 결과가 있는 셀만 링크형 버튼으로 표시하고 나머지는 전체값 title을 유지한다. */
const renderValue = (value: unknown, onClick?: () => void) => {
const formatted = formatValue(value);
return onClick ? (
<button
type="button"
className="w-full text-inherit text-primary underline underline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
onClick={onClick}
>
{formatted}
</button>
) : (
<span className="block overflow-hidden text-ellipsis" title={formatted}>
{formatted}
</span>
);
};
/** 표준 카드·페이징 안에서 관로 집계의 병합 셀과 고정 열을 렌더링한다. */
export default function PipeAnalysisSummaryTable({
rows,
groups,
grouped,
heads,
title,
total,
page,
pageSize,
actions,
onPageChange,
onCellClick,
onGroupedCellClick,
}: Props) {
// 병합 표도 공통 렌더러가 행을 순회할 수 있도록 처리구역의 4개 경과년도 행만 펼친다.
const data = useMemo<SummaryRow[]>(
() =>
grouped
? groups.flatMap((group, groupIndex) =>
group.rows.map((value, groupRowIndex) => ({
kind: "grouped" as const,
group,
value,
groupIndex,
groupRowIndex,
})),
)
: rows.map((value, sourceIndex) => ({
kind: "flat" as const,
value,
sourceIndex,
})),
[grouped, groups, rows],
);
// 공통 렌더러가 colgroup과 고정 위치에 사용할 열별 픽셀 너비를 한곳에서 계산한다.
const layout = useMemo(() => {
return heads.map((head) => {
const width =
FIXED_WIDTHS[head.id] ??
(head.id === "elapsedYearLabel"
? 112
: String(head.title).length > 10
? 160
: 120);
const fixed = head.id in FIXED_WIDTHS;
return { ...head, width, fixed };
});
}, [heads]);
// 모든 재질 열의 너비 합계를 최소 폭으로 사용해 좁은 팝업에서 열 압축을 막는다.
const tableWidth = layout.reduce((sum, head) => sum + head.width, 0);
// 열은 값·업무 클릭만 정의하고 실제 th/td, 고정 열과 overflow는 공통 렌더러가 소유한다.
const columns = useMemo<ColumnDef<SummaryRow>[]>(
() =>
layout.map((head) => ({
id: head.id,
size: head.width,
minSize: head.width,
maxSize: head.width,
header: () => <span title={String(head.title)}>{head.title}</span>,
cell: ({ row }) => {
const summaryRow = row.original;
if (summaryRow.kind === "flat") {
const value =
head.id === "rowNo"
? total - ((page - 1) * pageSize + summaryRow.sourceIndex)
: summaryRow.value[head.id];
const onClick =
summaryRow.value.__cellFilters[head.id] && Number(value) > 0
? () => onCellClick(summaryRow.value, head.id)
: undefined;
return renderValue(value, onClick);
}
if (head.fixed) {
return renderValue(
summaryRow.group[head.id as keyof PipeAnalysisYearMaterialGroup],
);
}
if (head.id === "elapsedYearLabel") {
return renderValue(summaryRow.value.elapsedYearLabel);
}
const value = Number(summaryRow.value.metrics[head.id] ?? 0);
const onClick =
value > 0 && summaryRow.value.cellFilters[head.id]
? () =>
onGroupedCellClick(
summaryRow.group,
summaryRow.value,
head.id,
)
: undefined;
return renderValue(value, onClick);
},
meta: {
align: head.align === "right" ? "right" : "center",
},
})),
[layout, onCellClick, onGroupedCellClick, page, pageSize, total],
);
// 페이지 단위는 기존과 동일하게 일반 표는 행, 병합 표는 처리구역 그룹이다.
const pagination = { pageIndex: Math.max(0, page - 1), pageSize };
const table = useReactTable({
data,
columns,
state: { pagination },
rowCount: total,
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) =>
row.kind === "flat"
? `flat-${row.sourceIndex}`
: `group-${row.groupIndex}-${row.value.elapsedYearBucket}`,
onPaginationChange: (updater) => {
const next =
typeof updater === "function" ? updater(pagination) : updater;
if (next.pageIndex !== pagination.pageIndex)
onPageChange(next.pageIndex + 1);
},
});
return (
<StandardDataTable
key={`${title}-${page}`}
table={table}
title={title}
flushOuterMargin
scrollMode="both"
maxHeight="max(240px, calc(100dvh - 34rem))"
minTableWidth={`${tableWidth}px`}
layout={PIPE_SUMMARY_LAYOUT}
pinnedColumns={PINNED_COLUMN_IDS}
getCellProps={({ row, columnId }) => {
const summaryRow = row.original;
const isPinnedColumn = PINNED_COLUMN_IDS.includes(columnId);
const isGrouped = summaryRow.kind === "grouped";
return {
hidden: isGrouped && isPinnedColumn && summaryRow.groupRowIndex > 0,
rowSpan:
isGrouped && isPinnedColumn && summaryRow.groupRowIndex === 0
? summaryRow.group.rows.length
: undefined,
className:
isGrouped && summaryRow.groupRowIndex === 0
? "border-t border-border"
: undefined,
};
}}
actions={actions}
/>
);
}
@@ -13,14 +13,14 @@
* - 모달을 통한 저장 확인 및 API 연동
*
* 🔗 사용 API
* - 하수관로 자체입력정보 조회: GET /api/v1/fcltppl/{fcltMngNo}/{mngInstCd} (sewerage-service)
* - 우수토실 자체입력정보 조회: GET /api/v1/fcltstofcbr/{fcltMngNo}/{mngInstCd} (sewerage-service)
* - 하수펌프장 자체입력정보 조회: GET /api/v1/fcltpump/{fcltMngNo}/{mngInstCd} (sewerage-service)
* - 하수맨홀 자체입력정보 조회: GET /api/v1/fcltmhl/{fcltMngNo}/{mngInstCd} (sewerage-service)
* - 하수관로 자체입력정보 저장: POST /api/v1/fcltppl (sewerage-service)
* - 우수토실 자체입력정보 저장: POST /api/v1/fcltstofcbr (sewerage-service)
* - 하수펌프장 자체입력정보 저장: POST /api/v1/fcltpump (sewerage-service)
* - 하수맨홀 자체입력정보 저장: POST /api/v1/fcltmhl (sewerage-service)
* - 하수관로 자체입력정보 조회: GET /api/v1/fcltPpl/{fcltMngNo}/{mngInstCd} (sewerage-service)
* - 우수토실 자체입력정보 조회: GET /api/v1/fcltStofcbr/{fcltMngNo}/{mngInstCd} (sewerage-service)
* - 하수펌프장 자체입력정보 조회: GET /api/v1/fcltPump/{fcltMngNo}/{mngInstCd} (sewerage-service)
* - 하수맨홀 자체입력정보 조회: GET /api/v1/fcltMhl/{fcltMngNo}/{mngInstCd} (sewerage-service)
* - 하수관로 자체입력정보 저장: POST /api/v1/fcltPpl (sewerage-service)
* - 우수토실 자체입력정보 저장: POST /api/v1/fcltStofcbr (sewerage-service)
* - 하수펌프장 자체입력정보 저장: POST /api/v1/fcltPump (sewerage-service)
* - 하수맨홀 자체입력정보 저장: POST /api/v1/fcltMhl (sewerage-service)
*/
"use client";
import { getData, postData } from "@/components/lib/api";
@@ -186,7 +186,7 @@ const FeatureClickEventPopup = ({
const fetchMngInstOptions = async () => {
try {
const result = await getData("v1/fcltppl/mngInsts");
const result = await getData("v1/fcltPpl/mngInsts");
if (!isMounted) return;
const sortedOptions = Array.isArray(result)
? [...result].sort((a, b) =>
@@ -720,13 +720,13 @@ const FeatureClickEventPopup = ({
isValidDetailIdentifier(fcltMngNo) &&
isValidDetailIdentifier(mngInstCd);
if (selectedLayerName === "하수관로")
apiEndpoint = `v1/fcltppl/${fcltMngNo}/${mngInstCd}`;
apiEndpoint = `v1/fcltPpl/${fcltMngNo}/${mngInstCd}`;
else if (selectedLayerName === "우수토실")
apiEndpoint = `v1/fcltstofcbr/${fcltMngNo}/${mngInstCd}`;
apiEndpoint = `v1/fcltStofcbr/${fcltMngNo}/${mngInstCd}`;
else if (selectedLayerName === "하수펌프장")
apiEndpoint = `v1/fcltpump/${fcltMngNo}/${mngInstCd}`;
apiEndpoint = `v1/fcltPump/${fcltMngNo}/${mngInstCd}`;
else if (selectedLayerName === "하수맨홀")
apiEndpoint = `v1/fcltmhl/${fcltMngNo}/${mngInstCd}`;
apiEndpoint = `v1/fcltMhl/${fcltMngNo}/${mngInstCd}`;
// API에서 상세 데이터 가져오기
if (apiEndpoint && canRequestDetail) {
@@ -886,10 +886,10 @@ const FeatureClickEventPopup = ({
let apiEndpoint = "";
let result = null;
if (layerNm === "하수관로") apiEndpoint = "v1/fcltppl";
else if (layerNm === "우수토실") apiEndpoint = "v1/fcltstofcbr";
else if (layerNm === "하수펌프장") apiEndpoint = "v1/fcltpump";
else if (layerNm === "하수맨홀") apiEndpoint = "v1/fcltmhl";
if (layerNm === "하수관로") apiEndpoint = "v1/fcltPpl";
else if (layerNm === "우수토실") apiEndpoint = "v1/fcltStofcbr";
else if (layerNm === "하수펌프장") apiEndpoint = "v1/fcltPump";
else if (layerNm === "하수맨홀") apiEndpoint = "v1/fcltMhl";
bodyData["fcltMngNo"] =
featureData.values_.fclt_mng_no.toString() || null;
@@ -11,7 +11,7 @@
* - 저장 버튼을 통한 데이터 저장 기능
*
* 🔗 사용 API
* - 상류관로 조회: GET /api/v1/fcltmhl/upperPpls/{fcltMngNo} (sewerage-service)
* - 상류관로 조회: GET /api/v1/fcltMhl/upperPpls/{fcltMngNo} (sewerage-service)
*/
import { VerticalTable } from '@/components/ui/verticalTable';
import { type ReactNode, useEffect, useState } from 'react';
@@ -26,7 +26,7 @@ export async function searchUpperPpls(
) {
let data: any = null;
try {
data = await getData(`v1/fcltmhl/upperPpls/${fcltMngNo}`);
data = await getData(`v1/fcltMhl/upperPpls/${fcltMngNo}`);
} catch (error) {
console.error("JSON parsing error:", error);
notify(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
@@ -0,0 +1,163 @@
/** 우측 상세 탭의 표시·분류 모델을 지도 조회 수명에서 분리한다. */
import type { ExmnMapJobType } from "../exmn/exmnMapTypes";
import type { SinkholeDetail, SinkholeNearbyPipe } from "../sinkhole/sinkholeTypes";
export type RightPanelTabKind = "feature" | "sinkhole" | "gisEdit";
export type RightPanelTabCategory =
| "sinkhole"
| "pipe"
| "exmnSurvey"
| "treatmentFacility"
| "treatmentZone"
| "defaultFeature"
| "gisEdit";
export type RightPanelTab = {
id: string;
kind: RightPanelTabKind;
category: RightPanelTabCategory;
displayLabel: string;
surveyJobType?: ExmnMapJobType;
parentTabId?: string | null;
featureData: any;
sinkholeDetail?: SinkholeDetail | null;
nearbyPipes?: SinkholeNearbyPipe[];
gisEditChangeId?: string | null;
lastActiveAt: number;
};
export type RightPanelTabStyle = {
active: string;
inactive: string;
};
export type RightPanelBookmarkTabsProps = {
tabs: RightPanelTab[];
activeTabId: string | null;
onActivate: (tabId: string) => void;
onBack: (tabId: string) => void;
};
// 지도 객체 유형과 조사구분을 식별하는 범례 의미색이므로 테마와 무관한 정적 class를 유지한다.
export const RIGHT_PANEL_TAB_STYLE_BY_CATEGORY: Record<
RightPanelTabCategory,
RightPanelTabStyle
> = {
sinkhole: {
active: "border-[#d97706] bg-[#d97706] text-white",
inactive: "border-[#fdba74] bg-[#fff7ed] text-[#9a3412] hover:bg-[#ffedd5]",
},
pipe: {
active: "border-[#2563eb] bg-[#2563eb] text-white",
inactive: "border-[#93c5fd] bg-[#eff6ff] text-[#1e40af] hover:bg-[#dbeafe]",
},
exmnSurvey: {
active: "border-[#0f766e] bg-[#0f766e] text-white",
inactive: "border-[#5eead4] bg-[#f0fdfa] text-[#115e59] hover:bg-[#ccfbf1]",
},
treatmentFacility: {
active: "border-[#15803d] bg-[#15803d] text-white",
inactive: "border-[#86efac] bg-[#f0fdf4] text-[#166534] hover:bg-[#dcfce7]",
},
treatmentZone: {
active: "border-[#7e22ce] bg-[#7e22ce] text-white",
inactive: "border-[#d8b4fe] bg-[#faf5ff] text-[#6b21a8] hover:bg-[#f3e8ff]",
},
gisEdit: {
active: "border-[#0f766e] bg-[#0f766e] text-white",
inactive: "border-[#5eead4] bg-[#f0fdfa] text-[#115e59] hover:bg-[#ccfbf1]",
},
defaultFeature: {
active: "border-[#475569] bg-[#475569] text-white",
inactive: "border-[#cbd5e1] bg-[#f8fafc] text-[#334155] hover:bg-[#f1f5f9]",
},
};
export const RIGHT_PANEL_EXMN_TAB_STYLE_BY_JOB_TYPE: Record<
ExmnMapJobType,
RightPanelTabStyle
> = {
PPL: {
active: "border-[#eab308] bg-[#facc15] text-[#713f12]",
inactive: "border-[#fde68a] bg-[#fefce8] text-[#854d0e] hover:bg-[#fef9c3]",
},
MNHL: {
active: "border-[#8b5cf6] bg-[#8b5cf6] text-white",
inactive: "border-[#c4b5fd] bg-[#f5f3ff] text-[#5b21b6] hover:bg-[#ede9fe]",
},
};
// 공용 우측 탭은 유형과 활성 상태에 관계없이 먼저 열린 탭부터 제거한다.
export const findRightPanelTabOverflowCandidate = (
tabs: RightPanelTab[],
nextTabId: string,
) => {
return tabs.find((tab) => tab.id !== nextTabId) ?? null;
};
// 부모 지반침하가 제거되면 그 부모에서 열린 연계 관로도 같은 탭 그룹으로 제거한다.
export const removeRightPanelTabGroup = (
tabs: RightPanelTab[],
removedTabId: string,
) =>
tabs.filter(
(tab) => tab.id !== removedTabId && tab.parentTabId !== removedTabId,
);
// 오른쪽 상세 패널에 상세가 2개 이상 쌓였을 때만 책갈피형 전환 버튼을 보여준다.
export const RightPanelBookmarkTabs = ({
tabs,
activeTabId,
onActivate,
onBack,
}: RightPanelBookmarkTabsProps) => {
if (tabs.length < 2) return null;
return (
<div className="pointer-events-auto absolute right-[350px] top-[92px] z-[70] flex w-[112px] flex-col items-end gap-2">
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
const style =
tab.category === "exmnSurvey" && tab.surveyJobType
? RIGHT_PANEL_EXMN_TAB_STYLE_BY_JOB_TYPE[tab.surveyJobType]
: RIGHT_PANEL_TAB_STYLE_BY_CATEGORY[tab.category];
const parentTab = tab.parentTabId
? tabs.find((candidate) => candidate.id === tab.parentTabId)
: null;
return (
<div
key={tab.id}
className={[
"flex min-h-[42px] w-[112px] items-center border border-r-0 px-1 py-1 shadow-sm transition-colors",
isActive ? style.active : style.inactive,
].join(" ")}
>
{tab.category === "pipe" && parentTab ? (
<button
type="button"
className="mr-1 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded text-sm font-bold transition-colors hover:bg-accent focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1"
title={parentTab.displayLabel + "로 돌아가기"}
aria-label={parentTab.displayLabel + "로 돌아가기"}
onClick={() => onBack(parentTab.id)}
>
←
</button>
) : null}
<button
type="button"
className="inline-flex min-w-0 flex-1 items-center justify-center self-stretch px-1 text-center text-[0.82rem] font-semibold leading-tight"
title={tab.displayLabel}
aria-pressed={isActive}
onClick={() => onActivate(tab.id)}
>
<span className="block max-h-[2.5rem] overflow-hidden break-all">
{tab.displayLabel}
</span>
</button>
</div>
);
})}
</div>
);
};
@@ -124,7 +124,7 @@ export default function SbmrgDngrMapPopupPage() {
SBMRG_DNGR_MAP_PAGE_SIZE,
);
const response = await getRawJson(
`/sewerage-service/api/v1/fldng/sbmrg-dngr-map?${queryString}`,
`/sewerage-service/api/v1/fldng/sbmrgDngrMap?${queryString}`,
);
const payload = unwrapPayload<{
items?: SbmrgDngrMapApiItem[];
@@ -160,7 +160,7 @@ export default function SbmrgDngrMapPopupPage() {
SBMRG_DNGR_MAP_SCOPE_SIZE,
);
const response = await getRawJson(
`/sewerage-service/api/v1/fldng/sbmrg-dngr-map?${queryString}`,
`/sewerage-service/api/v1/fldng/sbmrgDngrMap?${queryString}`,
);
const payload = unwrapPayload<{ items?: SbmrgDngrMapApiItem[] }>(
response,
@@ -697,7 +697,10 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
onSortChange={handleSortChange}
actions={
<span className="text-xl font-normal text-muted-foreground">
SWMM 시뮬레이션 기반 통수위험 분석 결과
SWMM 시뮬레이션 기반 통수위험 분석 결과 (GIS 매핑 관로 중
Capacity 0.8 이상이 1개라도 있으면{" "}
<span className="font-bold text-[#ff1c15]">위험</span>, 없으면{" "}
<span className="font-bold text-[#1677ff]">정상</span>)
</span>
}
/>
File diff suppressed because it is too large Load Diff
@@ -81,8 +81,8 @@ export default function useBasicTab() {
page: `${curPage - 1}`,
size: "10",
});
if (basicType === "tb_ms_swage_ppl")
queryParams.set("includeFlowDirectionBasis", "true");
// 현재 서버의 유하방향 배치 보강 옵션은 실패 시 목록 전체를 500으로 중단한다.
// 상세 유하방향 조회와 분리해 기본 정보검색 목록을 우선 정상 제공한다.
if (basicType === "tb_ms_swage_ppl")
cqlFilters = swagePplCqlFilters(); // 하수관로
-1
View File
@@ -1 +0,0 @@
export { default } from "../map/page";
@@ -1,4 +1,9 @@
import { isWindowPopupKey } from "@/features/windowPopup/model/popupDefinitions";
import {
resolveWindowPopupTheme,
WINDOW_POPUP_THEME_PARAM,
} from "@/features/windowPopup/model/popupTheme";
import PopupThemeSync from "@/features/windowPopup/ui/PopupThemeSync";
import WindowPopupRenderer from "@/features/windowPopup/ui/WindowPopupRenderer";
type PopupPageProps = {
@@ -14,6 +19,9 @@ export default async function PopupPage({
}: PopupPageProps) {
const { popupKey } = await params;
const resolvedSearchParams = await searchParams;
const popupTheme = resolveWindowPopupTheme(
resolvedSearchParams[WINDOW_POPUP_THEME_PARAM],
);
if (!isWindowPopupKey(popupKey)) {
return (
@@ -25,9 +33,14 @@ export default async function PopupPage({
}
return (
<WindowPopupRenderer
popupKey={popupKey}
searchParams={resolvedSearchParams}
/>
<>
{popupTheme ? (
<PopupThemeSync theme={popupTheme} />
) : null}
<WindowPopupRenderer
popupKey={popupKey}
searchParams={resolvedSearchParams}
/>
</>
);
}
@@ -13,7 +13,7 @@
*
* 🔗 사용 API
* - 사이트 정보 조회: POST /api/v1/inftwtAnls/users/{userId} (sewerage-service)
* - 침입수분석 등록 첨부파일INP 포함: POST /api/v1/rdiiAnls/savewithfile (sewerage-service)
* - 침입수분석 등록 첨부파일INP 포함: POST /api/v1/rdiiAnls/saveWithFile (sewerage-service)
* - 사용자 단 건 조회: GET /api/v1/users/{userUnqId} (user-service)
*/
"use client";
@@ -109,7 +109,7 @@ function FileUpload() {
try {
const response = await axios.post(
"http://localhost:8883/sewerage-service/api/v1/rdiiAnls/savewithfile",
"http://localhost:8883/sewerage-service/api/v1/rdiiAnls/saveWithFile",
formData,
{
headers: {
@@ -12,7 +12,7 @@
* - 로딩 오버레이 및 에러 핸들링 지원
*
* 🔗 사용 API
* - 침입수분석 등록 첨부파일INP 포함: POST /api/v1/inftwtAnls/savewithfile (sewerage-service)
* - 침입수분석 등록 첨부파일INP 포함: POST /api/v1/inftwtAnls/saveWithFile (sewerage-service)
* - 로그인된 사용자의 사용자 정보 조회: GET /api/v1/users/info/me (user-service)
*/
"use client";
@@ -193,7 +193,7 @@ const IrSetting = () => {
};
const registFile = async (analysisData: any) => {
const data = await postMutiPart("v1/inftwtAnls/savewithfile", analysisData);
const data = await postMutiPart("v1/inftwtAnls/saveWithFile", analysisData);
if (!data) {
throw new Error("분석 요청 접수 응답이 비어 있습니다.");
}
@@ -13,7 +13,7 @@
* - 로딩 오버레이 및 에러 핸들링 지원
*
* 🔗 사용 API
* - RDII분석 등록 첨부파일INP 포함: POST /api/v1/rdiiAnls/savewithfile (sewerage-service)
* - RDII분석 등록 첨부파일INP 포함: POST /api/v1/rdiiAnls/saveWithFile (sewerage-service)
* - 로그인된 사용자의 사용자 정보 조회: GET /api/v1/users/info/me (user-service)
*/
"use client";
@@ -223,7 +223,7 @@ export default function RTKForm() {
};
const registFile = async (analysisData: any) => {
const data = await postMutiPart("v1/rdiiAnls/savewithfile", analysisData);
const data = await postMutiPart("v1/rdiiAnls/saveWithFile", analysisData);
if (!data) {
throw new Error("분석 요청 접수 응답이 비어 있습니다.");
}
@@ -1,143 +0,0 @@
"use client";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
type ChartOptions,
} from "chart.js";
import { Bar } from "react-chartjs-2";
import { Card, CardHeader, CardTitle, CardContent } from "@common/business_components/ui/chart/lib/card";
import { Button } from "@common/components";
// ChartJS 컴포넌트 등록
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend
);
export default function TestPage() {
// 차트 데이터
const labels = ["한강", "낙동강", "영산강", "금강"];
const datasets = [
{
label: '20-25년',
data: [3859, 1066, 1929, 2828],
backgroundColor: '#3B82F6', // 파란색
borderRadius: 4,
},
{
label: '25년-30년',
data: [3153, 1420, 1204, 2877],
backgroundColor: '#EF4444', // 빨간색
borderRadius: 4,
},
{
label: '30년이상',
data: [9404, 1835, 1821, 8115],
backgroundColor: '#F87171', // 연한 빨간색/분홍색
borderRadius: 4,
},
];
// 각 지역별 총 길이 계산
const totalLengths = [44251, 33786, 11888, 14878];
// 차트 옵션 설정
const options: ChartOptions<"bar"> = {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
grid: {
display: true,
},
},
y: {
beginAtZero: true,
min: 0,
max: 10000,
ticks: {
stepSize: 2000,
callback: function(value) {
return value + 'km';
}
},
grid: {
color: 'rgba(200, 200, 200, 0.3)',
}
}
},
plugins: {
legend: {
position: 'top',
align: 'start',
labels: {
boxWidth: 15,
padding: 15,
usePointStyle: false,
},
},
tooltip: {
callbacks: {
label: function(context) {
return `${context.dataset.label}: ${context.parsed.y}km`;
}
}
},
},
// 총 길이 데이터 표시를 위한 애니메이션 후 함수 설정
animation: {
onComplete: function(animation) {
const chart = animation.chart;
const ctx = chart.ctx;
// 각 데이터셋의 마지막 데이터셋의 인덱스
const lastDatasetIndex = datasets.length - 1;
// 텍스트 스타일 설정
ctx.font = '11px Arial';
ctx.fillStyle = '#333';
ctx.textAlign = 'center';
// 각 지역별로 총 길이 표시
labels.forEach((_, i) => {
const xPos = chart.scales.x ? chart.scales.x.getPixelForTick(i) : 0;
const yPos = chart.chartArea.top; // 차트 영역 최상단 위에 표시
// 막대 위에 총 길이 텍스트 표시
if (totalLengths[i]) ctx.fillText(`총 ${totalLengths[i].toLocaleString()}km`, xPos, yPos);
});
}
}
};
const data = {
labels,
datasets,
};
return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-base">노후하수관로현황</CardTitle>
<Button size="sm">더보기</Button>
</CardHeader>
<CardContent className="pb-4">
{/* 너비 400px, 높이 150px로 조정 */}
<div className="w-[400px] h-[150px] mx-auto">
<Bar options={options} data={data} />
</div>
</CardContent>
</Card>
);
}
@@ -1,146 +0,0 @@
// ...existing code...
"use client";
import React from "react";
import { Chart as ChartJS, ArcElement, Tooltip, Legend, type ChartOptions } from "chart.js";
import { Pie } from "react-chartjs-2";
ChartJS.register(ArcElement, Tooltip, Legend);
export default function Page() {
const labels = ["한강", "낙동강", "영산강", "금강"];
const datasets = [
{ label: "20년 미만", data: [27835, 29465, 6934, 1058], color: "rgba(169, 169, 169, 0.8)" },
{ label: "20-25년", data: [3859, 1066, 1929, 2828], color: "rgba(255, 160, 122, 0.8)" },
{ label: "25년-30년", data: [3153, 1420, 1204, 2877], color: "rgba(152, 251, 152, 0.8)" },
{ label: "30년이상", data: [9404, 1835, 1821, 8115], color: "rgba(135, 206, 235, 0.8)" },
];
// 각 지역별 총 길이 계산
const totalLengths = [44251, 33786, 11888, 14878];
// 외부 툴팁을 사용하여 컨테이너 넘침과 관계없이 전체 툴팁을 보여주고,
// 각 항목의 전체에서 차지하는 비율(%)을 표시합니다.
const pieOptions: ChartOptions<"pie"> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
enabled: false, // 기본 캔버스 툴팁 비활성화 (외부 툴팁 사용)
external: function (context) {
const { chart, tooltip } = context;
let tooltipEl = document.getElementById("chartjs-external-tooltip");
// tooltip DOM이 없으면 생성
if (!tooltipEl) {
tooltipEl = document.createElement("div");
tooltipEl.id = "chartjs-external-tooltip";
tooltipEl.style.position = "absolute";
tooltipEl.style.pointerEvents = "auto";
tooltipEl.style.background = "rgba(0,0,0,0.75)";
tooltipEl.style.color = "#fff";
tooltipEl.style.padding = "6px 8px";
tooltipEl.style.borderRadius = "6px";
tooltipEl.style.fontSize = "12px";
tooltipEl.style.zIndex = "9999";
tooltipEl.style.transform = "translate(-50%, -120%)";
document.body.appendChild(tooltipEl);
}
// 사라짐 처리
if (tooltip.opacity === 0) {
tooltipEl.style.opacity = "0";
return;
}
// 툴팁 내용 구성 (파이의 전체 합 대비 비율 표시)
const data = chart.data;
const dataset = data.datasets && data.datasets[0] && data.datasets[0].data
? (data.datasets[0].data as number[])
: [];
const total = dataset.reduce((s, n) => s + (Number(n) || 0), 0);
const innerHtmlParts: string[] = [];
if (tooltip.dataPoints) {
tooltip.dataPoints.forEach((dp) => {
const idx = dp.dataIndex;
const label = (data.labels && data.labels[idx]) || dp.label || "";
const value = Number(dp.raw ?? dp.parsed ?? 0);
const pct = total > 0 ? (value / total) * 100 : 0;
innerHtmlParts.push(
`<div style="white-space:nowrap">
<strong style="display:inline-block;width:8px;height:8px;border-radius:2px;background:${(dp as any).element?.options?.backgroundColor || (dataset as any)[idx] || "#999"};margin-right:6px;vertical-align:middle"></strong>
<span style="vertical-align:middle">${label}: ${value.toLocaleString()}km</span>
<span style="margin-left:8px;color:#ddd">(${pct.toFixed(1)}%)</span>
</div>`
);
});
}
tooltipEl.innerHTML = innerHtmlParts.join("");
tooltipEl.style.opacity = "1";
// 위치: 차트 캔버스 기준 툴팁 caret 위치에 맞춤 (윈도우 스크롤 보정 포함)
const canvasRect = chart.canvas.getBoundingClientRect();
const caretX = tooltip.caretX ?? (canvasRect.left + canvasRect.width / 2);
const caretY = tooltip.caretY ?? (canvasRect.top + canvasRect.height / 2);
tooltipEl.style.left = `${canvasRect.left + window.pageXOffset + caretX}px`;
tooltipEl.style.top = `${canvasRect.top + window.pageYOffset + caretY}px`;
},
},
},
};
// 상단에 공통 범례(연도) 가로로 표시
const SharedLegend = () => (
<div className="flex gap-4 items-center justify-center">
{datasets.map((d) => (
<div key={d.label} className="flex items-center gap-2">
<span style={{ width: 12, height: 12, backgroundColor: d.color, borderRadius: 3, display: "inline-block" }} />
<span className="text-xl text-gray-600">{d.label}</span>
</div>
))}
</div>
);
// 레이블(강) 당 pie 데이터 생성 (레이블은 아래로 이동)
const Charts = () =>
labels.map((river, i) => {
const data = {
labels: datasets.map((d) => d.label),
datasets: [
{
data: datasets.map((d) => d.data[i]),
backgroundColor: datasets.map((d) => d.color),
borderColor: datasets.map(() => "rgba(255,255,255,0.8)"),
borderWidth: 1,
},
],
};
return (
<div key={river} className="flex flex-col items-center gap-2">
<div style={{ width: 80, height: 80 }}>
<Pie data={data} options={pieOptions} />
</div>
{/* 레이블을 차트 하단으로 이동 */}
<div className="text-lg font-medium text-gray-600">{totalLengths[i]?.toLocaleString()} km</div>
<div className="text-xl font-bold text-gray-600">{river}</div>
</div>
);
});
return (
<div className="p-4 w-[400px]">
{/* 상단 공통 범례 */}
<div className="mb-3">
<SharedLegend />
</div>
<div className="flex items-start justify-between gap-6">
<Charts />
</div>
</div>
);
}
// ...existing code...
@@ -686,14 +686,15 @@ const DredgingDetailPopupPage = ({
{index + 1}
</span>
<span className="flex min-w-0 flex-1 items-center gap-2">
{/* 파일명 밑줄과 글자 하단이 잘리지 않도록 글자 크기보다 넉넉한 줄 높이를 사용한다. */}
<strong
className="min-w-0 flex-1 truncate text-sm"
className="min-w-0 flex-1 truncate !text-[1.2rem] !font-medium !leading-[1.8rem]"
title={photo.originalFileName}
>
{photo.originalFileName ||
`준설사진 ${index + 1}`}
</strong>
<span className="shrink-0 whitespace-nowrap text-sm text-muted-foreground">
<span className="shrink-0 whitespace-nowrap !text-[1.2rem] !leading-[1.8rem] text-muted-foreground">
{formatDredgingCapturedDate(photo.capturedAt) ??
"촬영일시 없음"}
</span>
@@ -736,12 +737,12 @@ const DredgingDetailPopupPage = ({
</span>
<span className="flex min-w-0 flex-1 items-center gap-2">
<strong
className="min-w-0 flex-1 truncate text-sm"
className="min-w-0 flex-1 truncate !text-[1.2rem] !font-medium !leading-[1.8rem]"
title={photo.file.name}
>
신규 · {photo.file.name}
</strong>
<span className="shrink-0 whitespace-nowrap text-sm text-muted-foreground">
<span className="shrink-0 whitespace-nowrap !text-[1.2rem] !leading-[1.8rem] text-muted-foreground">
{formatDredgingCapturedDate(
photo.metadata.capturedAt,
) ?? "촬영일시 없음"}
@@ -824,7 +825,7 @@ const DredgingDetailPopupPage = ({
{editable ? (
<section className="card card-border !m-0 shrink-0 overflow-hidden !p-0">
<div className="card-header !m-0 flex-wrap gap-2">
<div className="card-header !m-0 flex-wrap gap-x-2 gap-y-1 !border-b-0">
<div className="min-w-0">
<h2 className="text-2xl font-medium text-label">
위치 및 관로
@@ -866,13 +867,14 @@ const DredgingDetailPopupPage = ({
</Button>
)}
</div>
</div>
<div
className="bg-secondary/30 h-[5.2rem] overflow-y-auto px-4 py-2 text-sm text-muted-foreground"
data-testid="dredging-edit-status"
>
{statusMessage ||
"준설일자·사진·위치와 자동 연결 관로를 확인해주세요."}
{/* 안내와 처리 상태는 위치·관로 카드의 마지막 줄에서 함께 확인한다. */}
<p
className="m-0 min-w-0 basis-full break-words text-sm leading-5 text-muted-foreground"
data-testid="dredging-edit-status"
>
{statusMessage ||
"준설일자·사진·위치와 자동 연결 관로를 확인해주세요."}
</p>
</div>
</section>
) : null}
@@ -6,7 +6,7 @@
import dynamic from "next/dynamic";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { FileUpload } from "@common/business_components/ui/file-upload";
import { DredgingRegistrationBody } from "@/features/dredging/ui/DredgingRegistrationBody";
import { StandardPopupLayout } from "@common/business_components";
import {
Button,
@@ -15,13 +15,6 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
InputDatePicker,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import { useAlertStore } from "@/shared/lib";
import { useEffect, useRef, useState } from "react";
@@ -33,10 +26,10 @@ import {
type AttachmentReference,
uploadDredgingPhotos,
} from "../lib/client";
import { formatDredgingCapturedDate } from "../lib/date";
import { readPhotoMetadata } from "../lib/exif";
import { useDredgingCommonCodes } from "../lib/useDredgingCommonCodes";
import { createDredgingClientUuid } from "../lib/uuid";
import { createDredgingRegistrationSession } from "../lib/registration-request";
import {
EMPTY_PHOTO_METADATA,
type DredgingDraftGroup,
@@ -115,6 +108,12 @@ const DredgingRegisterModal = ({
useState("");
const [isSaving, setIsSaving] = useState(false);
const uploadedAttachmentsRef = useRef(new Map<string, AttachmentReference>());
// 재렌더와 실패 재시도 사이에 요청 ID를 유지하며 모달 재진입에서만 새 세션으로 교체한다.
const registrationSessionRef = useRef<ReturnType<
typeof createDredgingRegistrationSession
> | null>(null);
// React의 다음 렌더 전에 발생하는 연속 저장 클릭도 같은 실행으로 제한한다.
const savingRef = useRef(false);
const [statusMessage, setStatusMessage] = useState("");
const commitGroups = (
@@ -127,6 +126,8 @@ const DredgingRegisterModal = ({
useEffect(() => {
if (!open) return;
registrationSessionRef.current = createDredgingRegistrationSession();
uploadedAttachmentsRef.current.clear();
const initial = [createGroup()];
groupsRef.current = initial;
setGroups(initial);
@@ -470,9 +471,12 @@ const DredgingRegisterModal = ({
return saveGroups;
};
// 검증·업로드 후 등록 payload를 확정하며 HTTP 실패 시 세션 ID와 첨부를 유지한다.
const handleSave = async () => {
if (savingRef.current) return;
try {
const saveGroups = validateAndBuildSaveGroups();
savingRef.current = true;
setIsSaving(true);
setStatusMessage(
isDredgingBackendEnabled
@@ -490,8 +494,13 @@ const DredgingRegisterModal = ({
? "준설 결과와 관로 매핑을 저장하고 있습니다."
: "준설 결과 등록을 완료했습니다.",
);
await saveDredgingRegistrations(saveGroups, attachments);
uploadedAttachmentsRef.current.clear();
const session =
registrationSessionRef.current ?? createDredgingRegistrationSession();
registrationSessionRef.current = session;
await saveDredgingRegistrations(
session.prepare(saveGroups, attachments, isDredgingBackendEnabled),
);
// 성공 안내가 닫히기 전 재클릭에도 같은 ID·첨부를 사용한다. 다음 open에서 초기화한다.
onSaved(saveGroups);
alert("준설 결과를 등록했습니다.", () => onOpenChange(false));
} catch (error) {
@@ -502,6 +511,7 @@ const DredgingRegisterModal = ({
setStatusMessage(message);
alert(message);
} finally {
savingRef.current = false;
setIsSaving(false);
}
};
@@ -557,441 +567,35 @@ const DredgingRegisterModal = ({
};
const modalBody = (
<div
className={
presentation === "popup"
? "sn-dredging-register flex h-full min-h-0 flex-col overflow-hidden"
: "sn-dredging-register flex h-[min(72rem,70dvh)] min-h-0 flex-col overflow-hidden"
}
>
<div className="bg-secondary/40 mb-3 flex shrink-0 flex-wrap items-center gap-x-4 gap-y-1 rounded-sm border border-border px-4 py-2 text-sm text-label">
<strong className="text-foreground">등록 안내</strong>
<span>관로별 사진은 최대 5장까지 등록할 수 있습니다.</span>
<span>
모든 등록 건은 하나의 준설작업이며 동일한 준설일자를 사용합니다.
</span>
</div>
{activeGroup ? (
<div
className="grid min-h-0 flex-1 grid-cols-[minmax(48rem,52rem)_minmax(0,1fr)] gap-3"
data-testid={`dredging-group-${
groups.findIndex((group) => group.id === activeGroup.id) + 1
}`}
>
<aside className="flex min-h-0 min-w-0 flex-col gap-3 overflow-y-auto overscroll-contain pr-1 [scrollbar-gutter:stable]">
<section className="card card-border !m-0 shrink-0 overflow-hidden !p-0">
<div className="card-header !m-0">
<h3 className="text-2xl font-medium text-label">등록 정보</h3>
<Button type="button" size="sm" onClick={addGroup}>
관로 추가
</Button>
</div>
<div className="p-3">
<Table variant="secondary">
<colgroup>
<col width="22%" />
<col width="78%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead data-required>준설일자</TableHead>
<TableCell className="border px-4">
<div className="flex flex-wrap items-center gap-3">
<div data-testid="representative-dredging-date">
<InputDatePicker
value={
representativeDredgingDate
? parseLocalDate(representativeDredgingDate)
: null
}
onChange={(date) =>
setRepresentativeDredgingDate(
date ? formatLocalDate(date) : "",
)
}
className="w-[15.5rem]"
/>
</div>
<span className="text-sm text-muted-foreground">
첫 사진 촬영일로 자동 입력되며 직접 수정할 수
있습니다.
</span>
</div>
</TableCell>
</TableRow>
<TableRow>
<TableHead>등록 관로</TableHead>
<TableCell className="border px-4">
<div
className="flex flex-wrap items-center gap-2"
role="tablist"
aria-label="준설 등록 건"
>
{groups.map((group, groupIndex) => {
const active = group.id === activeGroup.id;
return (
<div
key={group.id}
className="inline-flex items-stretch"
>
<Button
type="button"
size="sm"
color={active ? "primary" : "white"}
role="tab"
aria-selected={active}
className={
groupIndex > 0
? "rounded-r-none border-r-0"
: undefined
}
onClick={() => selectGroup(group.id)}
>
등록 건 {groupIndex + 1}
<span
className={`ml-1 inline-block size-2 rounded-full ${
getResolvedLocation(group)
? "bg-emerald-400"
: "bg-amber-400"
}`}
aria-label={
getResolvedLocation(group)
? "위치 등록 완료"
: "위치 등록 필요"
}
/>
</Button>
{groupIndex > 0 ? (
<Button
type="button"
size="icon"
color="red"
variant="destructive"
aria-label={`등록 건 ${groupIndex + 1} 삭제`}
title={`등록 건 ${groupIndex + 1} 삭제`}
className="h-10 min-w-8 rounded-l-none px-2"
onClick={() => removeGroup(group.id)}
>
×
</Button>
) : null}
</div>
);
})}
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</section>
{/* 등록 건별 사진 수가 달라도 목록 카드 높이를 고정해 지도와 하단 정보가 밀리지 않게 한다. */}
<section
className="card card-border !m-0 flex h-[28rem] shrink-0 flex-col overflow-hidden !p-0"
data-testid="dredging-register-photo-panel"
>
<div className="card-header !m-0 flex-wrap gap-2">
<div>
<h3 className="text-2xl font-medium text-label">준설 사진</h3>
<p className="mt-1 text-sm text-muted-foreground">
촬영일과 GPS 정보를 확인합니다.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
color={
activeGroup.registrationMode === "PHOTO"
? "primary"
: "white"
}
aria-pressed={activeGroup.registrationMode === "PHOTO"}
onClick={() =>
void changeRegistrationMode(activeGroup.id, "PHOTO")
}
>
{registrationModeLabels.PHOTO}
</Button>
<Button
type="button"
size="sm"
color={
activeGroup.registrationMode === "MANUAL"
? "primary"
: "white"
}
aria-pressed={activeGroup.registrationMode === "MANUAL"}
onClick={() =>
void changeRegistrationMode(activeGroup.id, "MANUAL")
}
>
{registrationModeLabels.MANUAL}
</Button>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col p-3">
{activeGroup.registrationMode === "PHOTO" ? (
<>
<div className="sn-dredging-photo-upload shrink-0">
<FileUpload
files={activeGroup.photos.map((photo) => photo.file)}
setFiles={(files) => {
void handleFilesChange(activeGroup.id, files);
}}
fileTypes=".jpg,.jpeg,.png"
maxFiles={5}
/>
</div>
{activeGroup.photos.length > 0 ? (
<div
className="mt-2 min-h-0 flex-1 overflow-y-auto overscroll-contain rounded-sm border border-border [scrollbar-gutter:stable]"
data-testid="dredging-register-photo-list-viewport"
>
<Table className="table-fixed" variant="default">
<TableHeader>
<TableRow>
<TableHead className="w-[34%]">사진</TableHead>
<TableHead className="w-[25%]">
촬영일시
</TableHead>
<TableHead className="w-[16%]">위치</TableHead>
<TableHead className="w-[17%]">상태</TableHead>
<TableHead className="w-[8%] whitespace-nowrap px-1">
삭제
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{activeGroup.photos.map((photo) => (
<TableRow key={photo.key}>
<TableCell
className="truncate px-3"
title={photo.file.name}
>
{photo.file.name}
</TableCell>
<TableCell
className="truncate px-3"
title={
formatDredgingCapturedDate(
photo.metadata.capturedAt,
) ?? "-"
}
>
{formatDredgingCapturedDate(
photo.metadata.capturedAt,
) ?? "-"}
</TableCell>
<TableCell className="px-3">
{photo.metadata.location
? "GPS"
: "지정 필요"}
</TableCell>
<TableCell className="overflow-hidden px-3">
<span
title={
photoStatusLabels[
photo.metadata.status
] || "수동 입력 필요"
}
className={`inline-block max-w-full truncate rounded-sm px-2 py-1 align-middle text-sm font-medium ${
photo.metadata.status === "COMPLETE"
? "bg-emerald-100 text-emerald-800"
: "bg-amber-100 text-amber-800"
}`}
>
{photoStatusLabels[
photo.metadata.status
] || "수동 입력 필요"}
</span>
</TableCell>
<TableCell className="px-2 text-center">
<Button
type="button"
size="icon"
variant="destructive"
color="red"
aria-label={`${photo.file.name} 삭제`}
title="사진 삭제"
className="size-8 p-0"
onClick={() =>
removePhoto(activeGroup.id, photo.key)
}
>
×
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="bg-secondary/30 mt-2 flex min-h-0 flex-1 items-center justify-center rounded-sm border border-dashed border-border px-3 py-2 text-center text-sm text-muted-foreground">
준설사진을 선택하면 촬영일과 위치정보를 자동으로
확인합니다.
</div>
)}
</>
) : (
<div className="bg-secondary/30 flex min-h-0 flex-1 items-center justify-center rounded-sm border border-dashed border-border px-3 py-3 text-center text-sm text-muted-foreground">
사진 없이 지도에서 준설위치를 직접 지정합니다.
</div>
)}
</div>
</section>
<section className="card card-border !m-0 shrink-0 overflow-hidden !p-0">
<div className="card-header !m-0 flex-wrap gap-2">
<h3 className="text-2xl font-medium text-label">
위치 및 관로
</h3>
<div className="flex shrink-0 flex-wrap justify-end gap-1">
{isEditingActiveLocation ? (
<>
<Button
type="button"
size="sm"
disabled={!locationDraft || activeGroup.isMatching}
onClick={() => {
void applyLocationDraft();
}}
>
{activeGroup.isMatching
? "관로 찾는 중..."
: "선택 위치 적용"}
</Button>
<Button
type="button"
size="sm"
color="white"
onClick={cancelLocationEditing}
>
취소
</Button>
</>
) : (
<Button
type="button"
size="sm"
onClick={() => startLocationEditing(activeGroup)}
>
{activeResolvedLocation ? "위치 수정" : "위치 지정"}
</Button>
)}
</div>
</div>
<div className="p-3">
<Table variant="secondary">
<colgroup>
<col width="22%" />
<col width="78%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>위치등록</TableHead>
<TableCell className="border px-4">
<span
className={`inline-flex rounded-sm px-2 py-1 text-sm font-medium ${
hasUnappliedLocationDraft
? "bg-blue-100 text-blue-800"
: activeGroup.manualLocation
? "bg-violet-100 text-violet-800"
: activeResolvedLocation
? "bg-emerald-100 text-emerald-800"
: "bg-amber-100 text-amber-800"
}`}
>
{hasUnappliedLocationDraft
? "새 위치 선택됨"
: activeGroup.manualLocation
? `${locationSourceLabels.MANUAL} 위치`
: activeResolvedLocation
? `${locationSourceLabels.EXIF} 위치`
: "위치 지정 필요"}
</span>
</TableCell>
</TableRow>
<TableRow>
<TableHead>자동 연결 관로</TableHead>
<TableCell className="border px-4">
<strong className="mr-3 text-foreground">
{activeGroup.isMatching
? "탐색 중"
: hasUnappliedLocationDraft
? "재탐색 필요"
: activeGroup.matchedPipe
? activeGroup.matchedPipe.fcltMngNo
: "미연결"}
</strong>
<span
className={
!hasUnappliedLocationDraft && activeGroup.matchError
? "text-rose-700"
: "text-muted-foreground"
}
>
{hasUnappliedLocationDraft
? "선택 위치를 적용하면 최근접 관로를 다시 찾습니다."
: activeGroup.matchedPipe
? `${activeGroup.matchedPipe.distanceMeters.toFixed(2)}m · ${activeGroup.matchedPipe.swtrmtZoneNm || "처리구역 확인불가"}`
: activeGroup.matchError ||
"위치를 지정하면 50m 이내 최근접 관로를 자동으로 연결합니다."}
</span>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</section>
<div
className="bg-secondary/30 shrink-0 rounded-sm border border-border px-3 py-2 text-sm text-muted-foreground"
data-testid="dredging-registration-status"
>
{statusMessage ||
"사진·준설일자·위치와 자동 연결 관로를 확인해주세요."}
</div>
</aside>
<section className="card card-border !m-0 flex min-h-0 min-w-0 flex-col overflow-hidden !p-0">
<div className="card-header !m-0 shrink-0">
<h3 className="text-2xl font-medium text-label">
위치 선택 지도
</h3>
</div>
<div className="min-h-0 flex-1 p-2">
<DredgingLocationMap
value={
isEditingActiveLocation
? locationDraft
: activeResolvedLocation
}
interactive={isEditingActiveLocation}
matchedPipe={
hasUnappliedLocationDraft ? null : activeGroup.matchedPipe
}
statusText={
isEditingActiveLocation
? hasUnappliedLocationDraft
? "새 위치를 선택했습니다. 관로 다시 찾기를 눌러 적용해주세요."
: "지도에서 새 준설위치를 클릭해주세요."
: activeResolvedLocation
? "등록된 준설위치를 표시하고 있습니다."
: "위치등록 버튼을 누른 뒤 지도에서 준설위치를 선택해주세요."
}
onChange={changeLocationDraft}
height="h-full min-h-0"
/>
</div>
</section>
</div>
) : null}
</div>
<DredgingRegistrationBody
presentation={presentation}
groups={groups}
activeGroup={activeGroup}
representativeDredgingDate={representativeDredgingDate}
setRepresentativeDredgingDate={setRepresentativeDredgingDate}
parseLocalDate={parseLocalDate}
formatLocalDate={formatLocalDate}
getResolvedLocation={getResolvedLocation}
addGroup={addGroup}
removeGroup={removeGroup}
selectGroup={selectGroup}
changeRegistrationMode={changeRegistrationMode}
handleFilesChange={handleFilesChange}
removePhoto={removePhoto}
registrationModeLabels={registrationModeLabels}
photoStatusLabels={photoStatusLabels}
locationSourceLabels={locationSourceLabels}
isEditingActiveLocation={isEditingActiveLocation}
locationDraft={locationDraft}
applyLocationDraft={applyLocationDraft}
cancelLocationEditing={cancelLocationEditing}
startLocationEditing={startLocationEditing}
activeResolvedLocation={activeResolvedLocation}
hasUnappliedLocationDraft={hasUnappliedLocationDraft}
statusMessage={statusMessage}
changeLocationDraft={changeLocationDraft}
LocationMap={DredgingLocationMap}
/>
);
if (presentation === "popup") {
@@ -1,450 +1,4 @@
import {
deleteData,
getBlobData,
getRawJson,
postRawJson,
putData,
} from "@/components/lib/api";
import { handleUpload } from "@/components/lib/utils";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import type { HttpResponse } from "tus-js-client";
import type {
DredgingSaveGroup,
DredgingRecordUpdate,
DredgingPhotoDraft,
DredgingPhoto,
DredgingRecord,
GeoPoint,
PipeMatch,
} from "./types";
import { createDredgingClientUuid } from "./uuid";
type LocationRequest = GeoPoint & {
locationKey: string;
};
export type AttachmentReference = {
atflCd: string;
atflId: string;
};
type NearestPipeResponseItem = {
locationKey: string;
failureCode?: "PIPE_NOT_FOUND" | string | null;
failureMessage?: string | null;
selected?: {
fid: string;
mngInstCd: string;
fcltMngNo: string;
pipeDiameterMm?: number;
pipeLengthM?: number;
pipeMaterial?: string;
installationYear?: number | null;
swtrmtZoneFid?: string | null;
swtrmtZoneNm?: string | null;
distanceMeters: number;
snappedLongitude: number;
snappedLatitude: number;
} | null;
};
const nearestPipeFailureMessage = (item: NearestPipeResponseItem) => {
switch (item.failureCode) {
case "PIPE_NOT_FOUND":
return "50m 이내에 관로가 없습니다. 지도에서 준설위치를 다시 확인해주세요.";
default:
return (
item.failureMessage?.trim() ??
"최근접 관로를 자동으로 연결하지 못했습니다."
);
}
};
export const isDredgingBackendEnabled =
process.env.NEXT_PUBLIC_DREDGING_BACKEND_ENABLED !== "false";
/**
* 백엔드·프록시의 상태코드, HTML 오류 본문과 파싱 문구를 화면에 전달하지 않는다.
* 상세 응답은 브라우저 Network에서 확인하고 화면에는 업무별 재시도 안내만 노출한다.
*/
const toDredgingApiError = (
error: unknown,
fallbackMessage: string,
operation: string,
) => {
const status =
typeof error === "object" && error !== null && "status" in error
? (error as { status?: unknown }).status
: undefined;
console.warn(`[준설관리] ${operation} 실패`, {
status: typeof status === "number" ? status : undefined,
errorType: error instanceof Error ? error.name : typeof error,
});
return new Error(fallbackMessage);
};
type DredgingRecordListResponse = {
items: DredgingRecord[];
total: number;
page: number;
size: number;
};
/** 실제 준설관리 API에서 기간 내 등록 기록을 조회한다. */
export const fetchDredgingRecords = async (params: {
fromDate: string;
toDate: string;
ctpv?: string;
sgg?: string;
page?: number;
size?: number;
}): Promise<DredgingRecordListResponse> => {
if (!isDredgingBackendEnabled) {
return { items: [], total: 0, page: 0, size: params.size ?? 2000 };
}
const search = new URLSearchParams({
fromDate: params.fromDate,
toDate: params.toDate,
page: String(params.page ?? 0),
size: String(params.size ?? 2000),
});
if (params.ctpv?.trim()) search.set("ctpv", params.ctpv.trim());
if (params.sgg?.trim()) search.set("sgg", params.sgg.trim());
try {
const body = await getRawJson(`v1/dredging/records?${search.toString()}`);
return (body?.data ?? body) as DredgingRecordListResponse;
} catch (error) {
throw toDredgingApiError(
error,
"준설현황을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.",
"준설현황 조회",
);
}
};
/** 조회조건에 맞는 준설기록 전체 페이지를 순회해 목록·Excel 건수를 일치시킨다. */
export const fetchAllDredgingRecords = async (params: {
fromDate: string;
toDate: string;
ctpv?: string;
sgg?: string;
}) =>
fetchAllPages({
fetchPage: (page, size) => fetchDredgingRecords({ ...params, page, size }),
getItems: (response) => response.items ?? [],
getTotal: (response) => Number(response.total ?? 0),
});
/** 등록된 준설사진의 공통 첨부파일 참조를 조회한다. */
export const fetchDredgingPhotos = async (
dredgingJobSn: string | number,
pipeFid: string,
): Promise<DredgingPhoto[]> => {
if (!isDredgingBackendEnabled) return [];
try {
const body = await getRawJson(
`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}/photos?fid=${encodeURIComponent(pipeFid)}`,
);
return (body?.data ?? body ?? []) as DredgingPhoto[];
} catch (error) {
throw toDredgingApiError(
error,
"등록된 준설사진을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.",
"준설사진 목록 조회",
);
}
};
/** 상세 기반 수정 팝업에서 선택 관로의 일자·위치·사진 구성을 함께 수정한다. */
export const updateDredgingRecord = async (
dredgingJobSn: string | number,
update: DredgingRecordUpdate,
attachments: Map<string, AttachmentReference>,
) => {
if (!isDredgingBackendEnabled) return true;
try {
await putData(`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}`, {
dredgingDate: update.dredgingDate,
pipeFid: update.originalPipeFid,
registrationMode: update.registrationMode,
location: {
source: update.locationSource,
longitude: update.location.longitude,
latitude: update.location.latitude,
},
expectedPipe: {
fid: update.pipe.fid,
mngInstCd: update.pipe.mngInstCd,
fcltMngNo: update.pipe.fcltMngNo,
},
retainedPhotoSns: update.retainedPhotoSns,
newPhotos: update.newPhotos.map((photo) => {
const attachment = attachments.get(photo.key);
if (!attachment) {
throw new Error(`${photo.file.name}의 첨부 식별자가 없습니다.`);
}
return {
...attachment,
originalFileName: photo.file.name,
clientCapturedAt: photo.metadata.capturedAt,
clientLongitude: photo.metadata.location?.longitude ?? null,
clientLatitude: photo.metadata.location?.latitude ?? null,
};
}),
});
return true;
} catch (error) {
throw toDredgingApiError(
error,
"준설정보를 수정하지 못했습니다. 잠시 후 다시 시도해주세요.",
"준설작업 수정",
);
}
};
/** 목록에서 선택한 준설작업과 관로·사진 참조를 모두 물리 삭제한다. */
export const deleteDredgingRecord = async (dredgingJobSn: string | number) => {
if (!isDredgingBackendEnabled) return true;
try {
const response = await deleteData(
`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}`,
);
if (response instanceof Response && !response.ok) {
throw new Error(`준설작업 삭제 응답 오류: ${response.status}`);
}
return true;
} catch (error) {
throw toDredgingApiError(
error,
"준설정보를 삭제하지 못했습니다. 잠시 후 다시 시도해주세요.",
"준설작업 삭제",
);
}
};
/** 공통 첨부서비스에서 준설사진 원본을 Blob으로 내려받는다. */
export const fetchDredgingPhotoBlob = async (atflId: string) => {
try {
return await getBlobData(
`/attach-service/api/v1/download/${encodeURIComponent(atflId)}`,
);
} catch (error) {
throw toDredgingApiError(
error,
"사진 원본을 불러오지 못했습니다.",
"준설사진 원본 조회",
);
}
};
const mockPipeForLocations = (locations: LocationRequest[]): PipeMatch => {
const first = locations[0];
const longitude = first?.longitude ?? 127.5;
const latitude = first?.latitude ?? 36.5;
const seed = Math.abs(
Math.round(longitude * 100_000) + Math.round(latitude * 100_000),
);
const distanceMeters = Number(((seed % 850) / 100 + 1.2).toFixed(2));
return {
fid: `MOCK-${String(seed % 1_000_000).padStart(6, "0")}`,
mngInstCd: "4173000",
fcltMngNo: `PPL-${String(seed % 1_000_000).padStart(6, "0")}`,
pipeDiameterMm: [450, 600, 700, 900][seed % 4] ?? 450,
pipeLengthM: Number((((seed % 8_000) + 2_000) / 100).toFixed(2)),
pipeMaterial: ["콘크리트", "PVC", "PE"][seed % 3] ?? "콘크리트",
installationYear: 1995 + (seed % 25),
swtrmtZoneFid: "MOCK-ZONE",
swtrmtZoneNm: "모의처리구역",
distanceMeters,
snappedLongitude: longitude + 0.00001,
snappedLatitude: latitude - 0.00001,
isMock: true,
};
};
/**
* 위치 목록을 한 번에 보내고 사진별 최근접 관로를 받는다.
* FE 선행 모드에서는 같은 그룹의 상태 전이를 검증할 수 있는 명시적 모의 관로를 반환한다.
*/
export const findNearestPipeMatches = async (
locations: LocationRequest[],
): Promise<Map<string, PipeMatch>> => {
if (locations.length === 0) return new Map();
if (!isDredgingBackendEnabled) {
const mockPipe = mockPipeForLocations(locations);
return new Map(
locations.map((location) => [location.locationKey, mockPipe]),
);
}
let body: any;
try {
body = await postRawJson("v1/dredging/nearestPipes", { locations });
} catch (error) {
throw toDredgingApiError(
error,
"최근접 관로를 조회하지 못했습니다. 잠시 후 다시 시도해주세요.",
"최근접 관로 조회",
);
}
const items = (body?.data ?? body) as NearestPipeResponseItem[];
const matches = new Map<string, PipeMatch>();
const failureMessages: string[] = [];
for (const item of items ?? []) {
if (!item.selected) {
failureMessages.push(nearestPipeFailureMessage(item));
continue;
}
matches.set(item.locationKey, {
fid: item.selected.fid,
mngInstCd: item.selected.mngInstCd,
fcltMngNo: item.selected.fcltMngNo,
pipeDiameterMm: item.selected.pipeDiameterMm ?? 0,
pipeLengthM: item.selected.pipeLengthM ?? 0,
pipeMaterial: item.selected.pipeMaterial ?? "",
installationYear: item.selected.installationYear ?? null,
swtrmtZoneFid: item.selected.swtrmtZoneFid ?? null,
swtrmtZoneNm: item.selected.swtrmtZoneNm ?? "",
distanceMeters: item.selected.distanceMeters,
snappedLongitude: item.selected.snappedLongitude,
snappedLatitude: item.selected.snappedLatitude,
isMock: false,
});
}
if (failureMessages.length > 0) {
throw new Error(Array.from(new Set(failureMessages)).join(" "));
}
return matches;
};
const uploadOnePhoto = (file: File): Promise<AttachmentReference> =>
new Promise((resolve, reject) => {
const transfer = new DataTransfer();
transfer.items.add(file);
const timeoutId = window.setTimeout(() => {
reject(new Error(`${file.name} 첨부 업로드 응답 시간이 초과되었습니다.`));
}, 90_000);
const handleResponse = (response: HttpResponse) => {
const fileInfo = response.getHeader("X-File-Info");
if (!fileInfo) return;
try {
const parsed = JSON.parse(fileInfo) as Partial<AttachmentReference>;
if (!parsed.atflCd || !parsed.atflId) {
throw new Error("첨부 식별자가 응답에 없습니다.");
}
window.clearTimeout(timeoutId);
resolve({ atflCd: parsed.atflCd, atflId: parsed.atflId });
} catch (error) {
window.clearTimeout(timeoutId);
reject(error);
}
};
try {
handleUpload(transfer.files, { handleResponse });
} catch (error) {
window.clearTimeout(timeoutId);
reject(error);
}
});
/**
* 공통 attach-service TUS 업로드를 순차 실행한다.
* 등록 API가 아직 없는 FE 선행 모드에서는 고아 첨부 생성을 막기 위해 전송하지 않는다.
*/
export const uploadDredgingPhotos = async (
groups: DredgingSaveGroup[],
existingReferences: Map<string, AttachmentReference> = new Map(),
) => {
return uploadDredgingPhotoDrafts(
groups.flatMap((group) => group.photos),
existingReferences,
);
};
/** 등록·수정 화면이 공통으로 사용하는 신규 준설사진 첨부 업로드이다. */
export const uploadDredgingPhotoDrafts = async (
photos: DredgingPhotoDraft[],
existingReferences: Map<string, AttachmentReference> = new Map(),
) => {
const references = new Map(existingReferences);
if (!isDredgingBackendEnabled) return references;
try {
for (const photo of photos) {
// DB 저장만 실패한 재시도에서는 이미 성공한 공통 첨부 업로드를 재사용한다.
if (references.has(photo.key)) continue;
references.set(photo.key, await uploadOnePhoto(photo.file));
}
} catch (error) {
throw toDredgingApiError(
error,
"준설사진을 업로드하지 못했습니다. 잠시 후 다시 시도해주세요.",
"준설사진 업로드",
);
}
return references;
};
export const saveDredgingRegistrations = async (
groups: DredgingSaveGroup[],
attachments: Map<string, AttachmentReference>,
) => {
if (!isDredgingBackendEnabled) {
return {
mock: true,
registeredCount: 1,
clientRequestId: createDredgingClientUuid(),
};
}
const clientRequestId = createDredgingClientUuid();
try {
return await postRawJson("v1/dredging/registrations", {
clientRequestId,
dredgingDate: groups[0]?.dredgingDate,
groups: groups.map((group, groupIndex) => ({
groupSn: groupIndex + 1,
registrationMode: group.registrationMode,
location: {
source: group.locationSource,
longitude: group.location.longitude,
latitude: group.location.latitude,
},
expectedPipe: {
fid: group.pipe.fid,
mngInstCd: group.pipe.mngInstCd,
fcltMngNo: group.pipe.fcltMngNo,
},
photos: group.photos.map((photo) => {
const attachment = attachments.get(photo.key);
if (!attachment) {
throw new Error(`${photo.file.name}의 첨부 식별자가 없습니다.`);
}
return {
...attachment,
originalFileName: photo.file.name,
clientCapturedAt: photo.metadata.capturedAt,
clientLongitude: photo.metadata.location?.longitude ?? null,
clientLatitude: photo.metadata.location?.latitude ?? null,
};
}),
})),
});
} catch (error) {
throw toDredgingApiError(
error,
"준설결과를 저장하지 못했습니다. 잠시 후 다시 시도해주세요.",
"준설결과 저장",
);
}
};
/** 기존 준설 화면의 API import를 유지한다. 구현과 첨부 업로드 책임은 feature로 분리했다. */
export * from "@/features/dredging/api/dredging-api";
export * from "@/features/dredging/api/photo-upload";
export { isDredgingBackendEnabled } from "@/features/dredging/api/backend-mode";
@@ -1,21 +1,2 @@
/** 촬영일시 원본은 유지하고 등록·수정 화면 표출값만 YYYY-MM-DD로 정규화한다. */
export const formatDredgingCapturedDate = (
value: string | null | undefined,
) => {
const normalized = String(value ?? "").trim();
const matched = normalized.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!matched) return null;
const year = Number(matched[1]);
const month = Number(matched[2]);
const day = Number(matched[3]);
const date = new Date(year, month - 1, day);
if (
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day
) {
return null;
}
return matched[0];
};
/** 기존 준설 화면 import 경로를 유지하는 호환 진입점. 구현은 features/dredging가 소유한다. */
export * from "@/features/dredging/lib/date";
@@ -1,334 +1,2 @@
import type { ExifStatus, GeoPoint, PhotoMetadata } from "./types";
type ByteOrder = "LE" | "BE";
type IfdEntry = {
type: number;
count: number;
valueOffset: number;
entryOffset: number;
};
const JPEG_START = 0xffd8;
const APP1_MARKER = 0xe1;
const EXIF_IFD_POINTER = 0x8769;
const GPS_IFD_POINTER = 0x8825;
const DATE_TIME = 0x0132;
const DATE_TIME_ORIGINAL = 0x9003;
const DATE_TIME_DIGITIZED = 0x9004;
const typeByteLength = (type: number) => {
if (type === 1 || type === 2 || type === 7) return 1;
if (type === 3) return 2;
if (type === 4 || type === 9) return 4;
if (type === 5 || type === 10) return 8;
return 0;
};
/**
* JPEG EXIF TIFF 블록을 읽는 범위 제한 reader이다.
* 손상된 offset이 파일 범위를 벗어나면 예외를 발생시켜 수동 입력 흐름으로 전환한다.
*/
class TiffReader {
private readonly view: DataView;
private readonly tiffStart: number;
private readonly order: ByteOrder;
constructor(buffer: ArrayBuffer, tiffStart: number) {
this.view = new DataView(buffer);
this.tiffStart = tiffStart;
this.ensureRange(tiffStart, 8);
const byteOrder = this.view.getUint16(tiffStart, false);
if (byteOrder === 0x4949) {
this.order = "LE";
} else if (byteOrder === 0x4d4d) {
this.order = "BE";
} else {
throw new Error("지원하지 않는 TIFF 바이트 순서입니다.");
}
if (this.uint16(2) !== 0x002a) {
throw new Error("유효한 TIFF 헤더가 아닙니다.");
}
}
private get littleEndian() {
return this.order === "LE";
}
private absolute(relativeOffset: number) {
return this.tiffStart + relativeOffset;
}
private ensureRange(absoluteOffset: number, byteLength: number) {
if (
absoluteOffset < 0 ||
byteLength < 0 ||
absoluteOffset + byteLength > this.view.byteLength
) {
throw new Error("EXIF offset이 파일 범위를 벗어났습니다.");
}
}
uint16(relativeOffset: number) {
const absoluteOffset = this.absolute(relativeOffset);
this.ensureRange(absoluteOffset, 2);
return this.view.getUint16(absoluteOffset, this.littleEndian);
}
uint32(relativeOffset: number) {
const absoluteOffset = this.absolute(relativeOffset);
this.ensureRange(absoluteOffset, 4);
return this.view.getUint32(absoluteOffset, this.littleEndian);
}
private readIfd(relativeOffset: number) {
const entryCount = this.uint16(relativeOffset);
const entries = new Map<number, IfdEntry>();
for (let index = 0; index < entryCount; index += 1) {
const entryOffset = relativeOffset + 2 + index * 12;
const tag = this.uint16(entryOffset);
const type = this.uint16(entryOffset + 2);
const count = this.uint32(entryOffset + 4);
const valueOffset = this.uint32(entryOffset + 8);
entries.set(tag, { type, count, valueOffset, entryOffset });
}
return entries;
}
private dataRelativeOffset(entry: IfdEntry) {
const byteLength = typeByteLength(entry.type) * entry.count;
if (byteLength <= 4) return entry.entryOffset + 8;
return entry.valueOffset;
}
private ascii(entry: IfdEntry) {
const relativeOffset = this.dataRelativeOffset(entry);
const absoluteOffset = this.absolute(relativeOffset);
const byteLength = typeByteLength(entry.type) * entry.count;
this.ensureRange(absoluteOffset, byteLength);
const bytes = new Uint8Array(this.view.buffer, absoluteOffset, byteLength);
return new TextDecoder("ascii").decode(bytes).replace(/\0+$/g, "").trim();
}
private rational(relativeOffset: number) {
const numerator = this.uint32(relativeOffset);
const denominator = this.uint32(relativeOffset + 4);
if (denominator === 0) return null;
return numerator / denominator;
}
private rationals(entry: IfdEntry) {
const relativeOffset = this.dataRelativeOffset(entry);
const values: number[] = [];
for (let index = 0; index < entry.count; index += 1) {
const value = this.rational(relativeOffset + index * 8);
if (value === null) return null;
values.push(value);
}
return values;
}
private pointer(entry: IfdEntry | undefined) {
if (!entry || entry.type !== 4 || entry.count !== 1) return null;
return entry.valueOffset;
}
private parseDate(value: string | null) {
if (!value) return null;
const match = value.match(
/^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/,
);
if (!match) return null;
const [, year, month, day, hour, minute, second] = match;
if (!year || !month || !day || !hour || !minute || !second) return null;
const monthNumber = Number(month);
const dayNumber = Number(day);
const hourNumber = Number(hour);
const minuteNumber = Number(minute);
const secondNumber = Number(second);
if (
monthNumber < 1 ||
monthNumber > 12 ||
dayNumber < 1 ||
dayNumber > 31 ||
hourNumber > 23 ||
minuteNumber > 59 ||
secondNumber > 59
) {
return null;
}
return {
capturedAt: `${year}-${month}-${day}T${hour}:${minute}:${second}`,
dredgingDate: `${year}-${month}-${day}`,
};
}
private parseGps(entries: Map<number, IfdEntry>): GeoPoint | null {
const latitudeRefEntry = entries.get(0x0001);
const latitudeEntry = entries.get(0x0002);
const longitudeRefEntry = entries.get(0x0003);
const longitudeEntry = entries.get(0x0004);
if (
!latitudeRefEntry ||
!latitudeEntry ||
!longitudeRefEntry ||
!longitudeEntry
) {
return null;
}
const latitudeRef = this.ascii(latitudeRefEntry).toUpperCase();
const longitudeRef = this.ascii(longitudeRefEntry).toUpperCase();
const latitudeDms = this.rationals(latitudeEntry);
const longitudeDms = this.rationals(longitudeEntry);
if (
!latitudeDms ||
latitudeDms.length < 3 ||
!longitudeDms ||
longitudeDms.length < 3
) {
return null;
}
const toDecimal = (values: number[]) =>
(values[0] ?? 0) + (values[1] ?? 0) / 60 + (values[2] ?? 0) / 3600;
let latitude = toDecimal(latitudeDms);
let longitude = toDecimal(longitudeDms);
if (latitudeRef === "S") latitude *= -1;
if (longitudeRef === "W") longitude *= -1;
if (
!Number.isFinite(latitude) ||
!Number.isFinite(longitude) ||
latitude < -90 ||
latitude > 90 ||
longitude < -180 ||
longitude > 180
) {
return null;
}
return { latitude, longitude };
}
parse() {
const ifd0Offset = this.uint32(4);
const ifd0 = this.readIfd(ifd0Offset);
const exifIfdOffset = this.pointer(ifd0.get(EXIF_IFD_POINTER));
const exifIfd = exifIfdOffset === null ? null : this.readIfd(exifIfdOffset);
const gpsIfdOffset = this.pointer(ifd0.get(GPS_IFD_POINTER));
const gpsIfd = gpsIfdOffset === null ? null : this.readIfd(gpsIfdOffset);
const dateValue =
(exifIfd?.get(DATE_TIME_ORIGINAL)
? this.ascii(exifIfd.get(DATE_TIME_ORIGINAL)!)
: null) ??
(exifIfd?.get(DATE_TIME_DIGITIZED)
? this.ascii(exifIfd.get(DATE_TIME_DIGITIZED)!)
: null) ??
(ifd0.get(DATE_TIME) ? this.ascii(ifd0.get(DATE_TIME)!) : null);
return {
date: this.parseDate(dateValue),
location: gpsIfd ? this.parseGps(gpsIfd) : null,
};
}
}
const toMetadata = (
capturedAt: string | null,
dredgingDate: string | null,
location: GeoPoint | null,
unsupported = false,
): PhotoMetadata => {
let status: ExifStatus;
let message: string;
if (unsupported) {
status = "UNSUPPORTED";
message = "JPEG EXIF가 없어 준설일자와 위치를 직접 입력해야 합니다.";
} else if (dredgingDate && location) {
status = "COMPLETE";
message = "촬영일자와 GPS를 확인했습니다.";
} else if (!dredgingDate && !location) {
status = "MISSING_BOTH";
message = "촬영일자와 GPS가 없어 수동 입력이 필요합니다.";
} else if (!dredgingDate) {
status = "MISSING_DATE";
message = "촬영일자가 없어 준설일자를 직접 입력해야 합니다.";
} else {
status = "MISSING_GPS";
message = "GPS가 없어 지도에서 준설위치를 등록해야 합니다.";
}
return { capturedAt, dredgingDate, location, status, message };
};
/**
* 브라우저에서 즉시 보여 줄 JPEG EXIF 미리보기를 만든다.
* 서버는 저장 시 원본 첨부파일을 기준으로 동일 정보를 다시 검증해야 한다.
*/
export const readPhotoMetadata = async (file: File): Promise<PhotoMetadata> => {
if (file.type !== "image/jpeg" && !/\.jpe?g$/i.test(file.name)) {
return toMetadata(null, null, null, true);
}
try {
const buffer = await file.arrayBuffer();
const view = new DataView(buffer);
if (view.byteLength < 4 || view.getUint16(0, false) !== JPEG_START) {
return toMetadata(null, null, null, true);
}
let offset = 2;
while (offset + 4 <= view.byteLength) {
if (view.getUint8(offset) !== 0xff) break;
const marker = view.getUint8(offset + 1);
if (marker === 0xda || marker === 0xd9) break;
const segmentLength = view.getUint16(offset + 2, false);
if (segmentLength < 2 || offset + 2 + segmentLength > view.byteLength) {
break;
}
if (marker === APP1_MARKER) {
const exifStart = offset + 4;
const exifSignature =
exifStart + 6 <= view.byteLength
? String.fromCharCode(
view.getUint8(exifStart),
view.getUint8(exifStart + 1),
view.getUint8(exifStart + 2),
view.getUint8(exifStart + 3),
)
: "";
if (exifSignature === "Exif") {
const parsed = new TiffReader(buffer, exifStart + 6).parse();
return toMetadata(
parsed.date?.capturedAt ?? null,
parsed.date?.dredgingDate ?? null,
parsed.location,
);
}
}
offset += 2 + segmentLength;
}
} catch (error) {
console.warn("준설 사진 EXIF 분석 실패", error);
}
return toMetadata(null, null, null, false);
};
/** 기존 준설 화면 import 경로를 유지하는 호환 진입점. 구현은 features/dredging가 소유한다. */
export * from "@/features/dredging/lib/exif";
@@ -0,0 +1,2 @@
/** 기존 준설 화면 import 경로를 유지하는 호환 진입점. 구현은 features/dredging가 소유한다. */
export * from "@/features/dredging/model/registration-request";
@@ -1,122 +1,2 @@
export type DredgingViewType = "MUNICIPALITY" | "DIAMETER" | "ELAPSED_YEAR";
export type DredgingRegistrationMode = "PHOTO" | "MANUAL";
export type DredgingLocationSource = "EXIF" | "MANUAL";
export type ExifStatus =
| "COMPLETE"
| "MISSING_DATE"
| "MISSING_GPS"
| "MISSING_BOTH"
| "UNSUPPORTED";
export type GeoPoint = {
longitude: number;
latitude: number;
};
export type PhotoMetadata = {
capturedAt: string | null;
dredgingDate: string | null;
location: GeoPoint | null;
status: ExifStatus;
message: string;
};
export type DredgingPhotoDraft = {
key: string;
file: File;
metadata: PhotoMetadata;
};
export type PipeMatch = {
fid: string;
mngInstCd: string;
fcltMngNo: string;
pipeDiameterMm: number;
pipeLengthM: number;
pipeMaterial: string;
installationYear: number | null;
swtrmtZoneFid: string | null;
swtrmtZoneNm: string;
distanceMeters: number;
snappedLongitude: number;
snappedLatitude: number;
isMock: boolean;
};
export type DredgingDraftGroup = {
id: string;
registrationMode: DredgingRegistrationMode;
photos: DredgingPhotoDraft[];
manualLocation: GeoPoint | null;
matchedPipe: PipeMatch | null;
matchError: string | null;
isMatching: boolean;
};
export type DredgingRecord = {
id: string;
dredgingJobSn: number | string;
pipeFid: string;
mngInstCd: string;
ctpv: string;
sgg: string;
treatmentZone: string;
fcltMngNo: string;
pipeDiameterMm: number;
pipeLengthM: number;
pipeMaterial: string;
installationYear: number | null;
dredgingDate: string;
photoCount: number;
registeredDate: string;
longitude: number;
latitude: number;
snappedLongitude: number;
snappedLatitude: number;
registrationMode: DredgingRegistrationMode;
registrationSource: DredgingLocationSource;
matchDistanceMeters: number;
isMock: boolean;
};
export type DredgingPhoto = {
photoSn: number;
pipeFid: string;
atflCd: string;
atflId: string;
originalFileName: string;
capturedAt: string | null;
};
export type DredgingSaveGroup = {
localId: string;
registrationMode: DredgingRegistrationMode;
dredgingDate: string;
location: GeoPoint;
locationSource: DredgingLocationSource;
pipe: PipeMatch;
photos: DredgingPhotoDraft[];
};
/** 상세 기반 수정 팝업에서 선택한 한 관로의 위치·사진을 함께 갱신한다. */
export type DredgingRecordUpdate = {
dredgingDate: string;
originalPipeFid: string;
registrationMode: DredgingRegistrationMode;
location: GeoPoint;
locationSource: DredgingLocationSource;
pipe: PipeMatch;
retainedPhotoSns: number[];
newPhotos: DredgingPhotoDraft[];
};
export const EMPTY_PHOTO_METADATA: PhotoMetadata = {
capturedAt: null,
dredgingDate: null,
location: null,
status: "UNSUPPORTED",
message: "메타정보를 확인하고 있습니다.",
};
/** 기존 준설 화면 import 경로를 유지하는 호환 진입점. 구현은 features/dredging가 소유한다. */
export * from "@/features/dredging/model/types";
@@ -1,135 +1,2 @@
"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,
};
};
/** 기존 준설 화면 import 경로를 유지하는 호환 진입점. 구현은 features/dredging가 소유한다. */
export * from "@/features/dredging/lib/use-dredging-common-codes";
@@ -1,31 +1,2 @@
/**
* 일반 HTTP에서는 `crypto.randomUUID`가 제공되지 않을 수 있어 Web Crypto 난수로 UUID v4를 생성한다.
* 백엔드 `clientRequestId`가 UUID 타입이므로 fallback도 같은 36자 형식을 유지한다.
*/
export const createDredgingClientUuid = () => {
if (
typeof crypto !== "undefined" &&
typeof crypto.randomUUID === "function"
) {
return crypto.randomUUID();
}
const bytes = new Uint8Array(16);
if (
typeof crypto !== "undefined" &&
typeof crypto.getRandomValues === "function"
) {
crypto.getRandomValues(bytes);
} else {
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Math.floor(Math.random() * 256);
}
}
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = Array.from(bytes, (value) =>
value.toString(16).padStart(2, "0"),
).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
};
/** 기존 준설 화면 import 경로를 유지하는 호환 진입점. 구현은 features/dredging가 소유한다. */
export * from "@/features/dredging/lib/uuid";
@@ -1,85 +0,0 @@
/**
* 📄 HeadMakeFloodPre Component
* 침수예방 테이블 헤더 생성 컴포넌트
*
* - 테이블 상단 헤더 생성 및 렌더링
* - 하위 헤더 목록 기반 동적 헤더 구성
* - rowspan 및 colspan을 통한 테이블 구조 조정
* - 가시성 필터링 및 조건부 렌더링 지원
* - 상위 헤더와 하위 헤더의 계층적 표시
*/
import { TableHead, TableRow } from "@common/components/ui";
export type TableUpperProps = {
id: string;
title: string;
upName?: string;
upSequnce?: number;
edit?: boolean;
visible?: boolean;
rowSpan?: number;
colSpan?: number;
type?: string;
selectData?: string; // type 이 seelct 일경우 selectarray 정하기 위해
row?: number;
mergeCell?: boolean; // 셀을 합칠경우
maxLength?: number;
readonly?: boolean;
};
type chHeadType = {
headNm: string;
text: string;
length: number;
rows?: number;
};
export const HeadMakeFloodPre = (
upHeadList: TableUpperProps[],
makeHeadNm: chHeadType[],
) => {
return (
<>
<TableRow>
{upHeadList
.filter((data) => data.visible != false)
.map((head: TableUpperProps) =>
!head.upSequnce ? (
<TableHead
className="border border-border px-2 py-1"
rowSpan={2}
key={head.id}
>
{head.title}
</TableHead>
) : head.upSequnce === 1 ? (
<TableHead
className="border border-border px-2 py-1"
key={head.id}
rowSpan={1}
colSpan={
makeHeadNm?.find((ch) => ch.headNm === head.upName)?.length
}
>
{makeHeadNm?.find((ch) => ch.headNm === head.upName)?.text}
</TableHead>
) : null,
)}
</TableRow>
<TableRow>
{upHeadList
.filter((data) => data.visible != false)
.map((head: TableUpperProps) =>
makeHeadNm?.find((ch) => ch.headNm === head.upName) ? (
<TableHead
className="border border-border px-2 py-1"
key={head.id}
>
{head.title}
</TableHead>
) : null,
)}
</TableRow>
</>
);
};
@@ -1,213 +0,0 @@
/**
* 📄 InputTableBody Component
* 침수예방 입력 테이블 바디 컴포넌트
*
* - 숫자 입력 및 콤마 포맷팅 기능
* - mergeCell을 통한 셀 병합 처리
* - 편집 가능/불가능 상태 관리
* - 소계/총합계 행 스타일링 및 표시
* - 입력 값 검증 및 변경 핸들링
* - 빈 데이터 상태 처리
*/
import type { TableUpperProps } from "./headMakeFloodPre";
import { Input, TableCell, TableRow } from "@common/components/ui";
// 숫자입력시 , 삽입
const formatCommaNumber = (
value: number | string | undefined | null,
): string => {
if (value === undefined || value === null || value === "") return "";
const num = Number(value);
// 숫자로 변환할 수 없는 값이 들어올 경우 빈 문자열을 반환하여 예상치 못한 "NaN" 출력을 방지합니다.
if (isNaN(num)) {
return "";
}
return num.toLocaleString();
};
// onchange 텍스트를 숫자로 and ,제거
const handleCommaNumberChange = (
e: React.ChangeEvent<HTMLInputElement>,
onChange: (value: number | "") => void,
) => {
const raw = e.target.value.replace(/,/g, "");
if (!/^\d*$/.test(raw)) return;
onChange(raw === "" ? "" : Number(raw));
};
const handleChange = (
rowIndex: number,
field: any,
value: string,
setInputData: (data: any) => void,
) => {
if (Number(value) < 0 || !Number(value)) {
value = "0";
}
setInputData((prevData: any) =>
prevData?.map((row: any, index: number) => {
if (index === rowIndex) {
return {
...row,
[field]: Number(value.replace(/,/g, "")),
};
}
return row;
}),
);
};
export const InputTableBody = (
chgRes: any[] | [],
headList: TableUpperProps[],
setInputData: (data: any[]) => void,
isEditable: boolean = true,
) => {
// mergeCell 처리용
const getRowSpan = (data: any[], key: string, rowIndex: number) => {
if (
rowIndex > 0 &&
data[rowIndex - 1][key] &&
String(data[rowIndex][key]).trim() === ""
) {
return 0; // 이전과 같으면 셀을 그리지 않음
}
let span = 1;
for (let i = rowIndex + 1; i < data.length; i++) {
if (data[i][key] === data[rowIndex][key]) span++;
else break;
}
return span;
};
return (
<>
{chgRes.map((data, dataIndex) => {
// 소계/총합계 행 판별
const isSubTotal = data["용수명"] === "소계";
const isTotal = data["구분"] === 0;
const bgClass = isTotal
? "bg-secondary font-bold"
: isSubTotal
? "bg-muted font-semibold"
: "";
return (
<TableRow key={dataIndex + "row"}>
{headList.map((head, headIndex) => {
const isTextColumn = head.id === "__EMPTY_3";
const textAlignClass = isTextColumn
? "text-center"
: "text-right";
// // 총합계 행이면 "구분"에서 colSpan=2로 병합
// if (
// (data["구분"] === 0 || data["용수명"] === 0) &&
// head.id === "구분"
// ) {
// return (
// <TableCell
// key={head.id + headIndex}
// colSpan={2}
// className="bg-secondary text-center font-bold"
// >
// 총합계
// </TableCell>
// );
// }
// // 총합계(합계) 행이면 "용수명" 컬럼은 렌더링하지 않음
// if (
// (data["구분"] === 0 || data["용수명"] === 0) &&
// head.id === "용수명"
// ) {
// return null;
// }
// // 총합계 행의 나머지 셀에도 색상 적용
// if (data["구분"] === 0 || data["용수명"] === 0) {
// return (
// <TableCell
// key={head.id + headIndex}
// className="bg-secondary text-right font-bold"
// >
// {Number(data[head.id])
// ? formatCommaNumber(data[head.id as keyof any])
// : (data[head.id as keyof any] ?? "")}
// </TableCell>
// );
// }
// mergeCell 처리
if (head.mergeCell === true) {
const rowSpan = getRowSpan(chgRes, head.id, dataIndex);
if (rowSpan === 0) return null;
return (
<TableCell
key={head.id + headIndex}
rowSpan={rowSpan}
className={`w-36 whitespace-nowrap text-center align-middle ${bgClass}`}
>
{Number(data[head.id])
? formatCommaNumber(data[head.id as keyof any])
: (data[head.id as keyof any] ?? "")}
</TableCell>
);
}
// 기본 셀
// return head.edit === true && data.edit !== false ? (
return isEditable && head.edit === true && data.edit !== false ? (
<TableCell
key={head.id + headIndex}
className={`w-36 ${bgClass}`}
>
<Input
// className={`${head.id !== "utztnUsgCd" ? "text-right" : "text-center"} !text-xl h-10 w-full`}
className={`${textAlignClass} h-10 w-full !text-xl`}
onChange={(e) =>
handleCommaNumberChange(e, (val) =>
handleChange(
dataIndex,
head.id,
String(val),
setInputData,
),
)
}
value={formatCommaNumber(data[head.id as keyof any]) ?? ""}
maxLength={12}
/>
</TableCell>
) : (
<TableCell
key={head.id + headIndex}
className={`w-36 ${bgClass}`}
>
<div
// className={`
// ${head.id !== "utztnUsgCd" ? "text-right" : "text-center"}
// !text-xl w-full h-10
// `}
className={`${textAlignClass} h-10 w-full !text-xl`}
>
{Number(data[head.id])
? formatCommaNumber(data[head.id as keyof any])
: (data[head.id as keyof any] ?? "")}
</div>
</TableCell>
);
})}
</TableRow>
);
})}
{chgRes.length === 0 && (
<TableRow>
<TableCell
colSpan={headList.length}
className="h-24 text-center text-muted-foreground"
>
조회된 결과가 없습니다.
</TableCell>
</TableRow>
)}
</>
);
};
@@ -222,21 +222,18 @@ const Page = () => {
};
// 선택 파일 삭제
// 첨부·업무 삭제 중 어느 하나라도 실패하면 성공 안내와 목록 갱신을 실행하지 않는다.
const isDeleteFile = async () => {
if (sbmrgPrevntBizSn === "" || sbmrgPrevntBizSn === undefined) {
alert("삭제할 파일을 선택해주세요");
return false;
}
if (!(await confirm("삭제하시겠습니까?"))) return false;
const data = await deleteData(
`/attach-service/api/v1/atfl/attachments/${atflId}`,
);
if (data) {
const delData = await deleteData(`v1/sbmrgPrevnt/${sbmrgPrevntBizSn}`);
if (delData.status === 200) {
alert("삭제되었습니다.", () => void fetchData());
}
} else {
try {
await deleteData(`/attach-service/api/v1/atfl/attachments/${atflId}`);
await deleteData(`v1/sbmrgPrevnt/${sbmrgPrevntBizSn}`);
alert("삭제되었습니다.", () => void fetchData());
} catch {
alert("삭제에 실패했습니다.");
}
};
@@ -1,28 +0,0 @@
/**
* 📄 Oper Index Configuration
* 운영 관련 테이블 헤더 설정 파일
*
* - 침수예방 및 노후 하수관로 테이블 헤더 정의
* - 일련번호, 파일명, 년도, 분기, 등록 일자, 등록자 필드 포함
* - 테이블 컴포넌트에서 재사용 가능한 헤더 구조 제공
* - 데이터 표시 및 정렬을 위한 표준화된 헤더 포맷
* - 운영 관리 페이지에서 공통으로 사용되는 헤더 리스트
* - 동적 테이블 렌더링을 위한 ID와 타이틀 매핑
*/
export const tableHeadlist = [
{ id: 'rowNumber', title: '일련번호', sortable: true, sortKey: 'sbmrgPrevntBizSn' },
{ id: 'sbmrgPrevntBizNm', title: '파일명', sortable: true },
{ id: 'mngYr', title: '년도', sortable: true },
{ id: 'mngQu', title: '분기', sortable: true },
{ id: 'regDt', title: '등록 일자', sortable: true },
{ id: 'rgtrNm', title: '등록자' }
]
export const tableHeadlistOldPp = [
{ id: 'rowNumber', title: '일련번호', sortable: true, sortKey: 'dcrpPplMtncBizSn' },
{ id: 'dcrpPplMtncBizNm', title: '파일명', sortable: true },
{ id: 'mngYr', title: '년도', sortable: true },
{ id: 'mngQu', title: '분기', sortable: true },
{ id: 'regDt', title: '등록 일자', sortable: true },
{ id: 'rgtrNm', title: '등록자' }
]
@@ -200,6 +200,7 @@ const Page = () => {
handleDownload(`/attach-service/api/v1/download/${attachmentId}`, fileName);
};
// HTTP 실패는 catch에서 안내하고 첨부·업무 삭제가 모두 성공한 뒤 목록을 갱신한다.
const handleDelete = async () => {
if (!businessId) {
alert("삭제할 파일을 선택해주세요");
@@ -207,20 +208,13 @@ const Page = () => {
}
if (!(await confirm("삭제하시겠습니까?"))) return;
const attachmentResult = await deleteData(
`/attach-service/api/v1/atfl/attachments/${attachmentId}`,
);
if (!attachmentResult) {
alert("삭제에 실패했습니다.");
return;
}
const businessResult = await deleteData(`v1/dcrpPpl/${businessId}`);
if (businessResult.status === 200) {
try {
await deleteData(`/attach-service/api/v1/atfl/attachments/${attachmentId}`);
await deleteData(`v1/dcrpPpl/${businessId}`);
alert("삭제되었습니다.", () => void fetchData());
return;
} catch {
alert("삭제에 실패했습니다.");
}
alert("삭제에 실패했습니다.");
};
const handleRegister = () => {
@@ -9,6 +9,12 @@ import useOnsemiroAddrStore from "@/store/useOnsemiroAddrStore";
const DEFAULT_OPTION = { text: "전체", val: "0" };
/** API가 이미 전체 항목을 반환해도 Select의 `0` key가 중복되지 않게 정리한다. */
const withDefaultOption = (options: Array<{ text: string; val: string }>) => [
DEFAULT_OPTION,
...options.filter((option) => String(option.val) !== DEFAULT_OPTION.val),
];
export default function Page() {
const { govArea, city, sgg, fetchGovArea, fetchCity, fetchSgg } =
useOnsemiroAddrStore();
@@ -74,7 +80,7 @@ export default function Page() {
label=""
className="w-full"
selectClass="w-full rounded-lg bg-background"
selectArray={[DEFAULT_OPTION, ...govArea]}
selectArray={withDefaultOption(govArea)}
value={selectedGovArea}
onSelectValue={(value) => setSelectedGovArea(String(value))}
/>
@@ -85,7 +91,7 @@ export default function Page() {
label=""
className="w-full"
selectClass="w-full rounded-lg bg-background"
selectArray={[DEFAULT_OPTION, ...city]}
selectArray={withDefaultOption(city)}
value={selectedCity}
onSelectValue={(value) => setSelectedCity(String(value))}
/>
@@ -96,7 +102,7 @@ export default function Page() {
label=""
className="w-full"
selectClass="w-full rounded-lg bg-background"
selectArray={[DEFAULT_OPTION, ...filteredSgg]}
selectArray={withDefaultOption(filteredSgg)}
value={selectedSgg}
onSelectValue={(value) => setSelectedSgg(String(value))}
/>
@@ -1,71 +0,0 @@
import type { UiHeadProps, UiTableSort } from "@common/business_components/ui";
const TABLE_SORT_COLLATOR = new Intl.Collator("ko-KR", {
numeric: true,
sensitivity: "base",
});
const isEmptySortValue = (value: unknown) =>
value === null ||
value === undefined ||
String(value).trim() === "" ||
String(value).trim() === "-";
const toComparableNumber = (value: unknown) => {
const normalized = String(value ?? "")
.trim()
.replace(/,/g, "")
.replace(/%$/, "");
if (!/^-?\d+(?:\.\d+)?$/.test(normalized)) return undefined;
const number = Number(normalized);
return Number.isFinite(number) ? number : undefined;
};
/** 업로드된 엑셀의 실제 데이터 컬럼만 공통 UiTable 정렬 대상으로 만든다. */
export const makeSortableHeaders = <T extends UiHeadProps>(headers: T[]) =>
headers.map((header) => ({ ...header, sortable: true }));
/** 서버 페이징이 없는 엑셀 상세 그리드의 전체 행을 안정 정렬한다. */
export const sortLocalTableRows = <T extends Record<string, unknown>>(
rows: T[],
sort?: UiTableSort,
) => {
if (!sort) return rows;
return rows
.map((row, originalIndex) => ({ row, originalIndex }))
.sort((leftEntry, rightEntry) => {
const leftValue = leftEntry.row[sort.key];
const rightValue = rightEntry.row[sort.key];
const leftEmpty = isEmptySortValue(leftValue);
const rightEmpty = isEmptySortValue(rightValue);
if (leftEmpty || rightEmpty) {
if (leftEmpty && rightEmpty) return leftEntry.originalIndex - rightEntry.originalIndex;
return leftEmpty ? 1 : -1;
}
const leftNumber = toComparableNumber(leftValue);
const rightNumber = toComparableNumber(rightValue);
const comparison =
leftNumber !== undefined && rightNumber !== undefined
? leftNumber - rightNumber
: TABLE_SORT_COLLATOR.compare(String(leftValue), String(rightValue));
if (comparison === 0) return leftEntry.originalIndex - rightEntry.originalIndex;
return sort.direction === "asc" ? comparison : -comparison;
})
.map(({ row }) => row);
};
/** children으로 다단 헤더를 전달하는 UiTable에서도 동일한 3상태 정렬을 사용한다. */
export const getNextTableSort = (
head: UiHeadProps,
currentSort?: UiTableSort,
): UiTableSort | undefined => {
const key = head.sortKey ?? head.id;
if (currentSort?.key !== key) return { key, direction: "asc" };
if (currentSort.direction === "asc") return { key, direction: "desc" };
return undefined;
};
+10 -216
View File
@@ -15,6 +15,8 @@ import { AxiosError } from 'axios';
import { CLAIM_NAME, SITE_ID } from "@/shared/config";
import { tokenManager } from "@/entities/auth/lib/token-manager";
import { getRemainingMs } from "@common/auth";
// 기존 shared/api barrel의 인증 client 초기화 부수효과를 불러오지 않는 순수 transport다.
import { createHttpTransport } from "@/shared/api/http-transport";
const BASE_URL = '/sewerage-service/api';
const DEFAULT_HEADERS = {
@@ -31,35 +33,6 @@ const withSiteIdHeaders = (headers: HeadersInit = {}) => ({
const resolveApiUrl = (url: string) =>
url.startsWith('/') ? `${url}` : `${BASE_URL}/${url}`;
const buildInvalidJsonResponseError = (url: string, text: string, contentType?: string | null) => {
const preview = text.trim().slice(0, 200);
const responseType = contentType ? ` (${contentType})` : "";
return new Error(
`JSON 응답이 아닌 데이터를 받았습니다${responseType}: ${url}${preview ? ` - ${preview}` : ""}`,
);
};
const buildRequestError = (
fallbackMessage: string,
status?: number,
body?: unknown,
) => {
const bodyMessage =
typeof body === "object" && body !== null
? (
(body as { message?: unknown }).message
?? (body as { error?: { message?: unknown } }).error?.message
)
: undefined;
const message =
typeof bodyMessage === "string" && bodyMessage.trim()
? bodyMessage
: fallbackMessage;
const error = new Error(message) as Error & { status?: number; body?: unknown };
error.status = status;
error.body = body;
return error;
};
const handleUnauthorizedResponse = () => {
if (typeof window === "undefined") return;
@@ -80,137 +53,14 @@ const handleUnauthorizedResponse = () => {
}
};
export const getData = async (url: string): Promise<any> => {
const res = await fetch(resolveApiUrl(url), {
method: "GET",
headers: withSiteIdHeaders(),
});
console.log(res);
if (res.status === 401) {
handleUnauthorizedResponse();
}
if (res.ok) {
try {
// 응답이 비어있는지 확인
const contentLength = res.headers.get('content-length');
if (contentLength === '0') {
return null;
}
// 응답 텍스트를 먼저 가져와서 빈 문자열인지 확인
const text = await res.text();
if (!text || text.trim() === '') {
return null;
}
// JSON 파싱 시도
const data = JSON.parse(text);
return data.data;
} catch (error) {
console.error('JSON parsing error:', error);
// JSON 파싱에 실패한 경우, 원본 텍스트 또는 null 반환
throw new Error('Invalid JSON response from server');
}
}
const text = await res.text().catch(() => "");
let body: unknown = null;
if (text.trim()) {
try {
body = JSON.parse(text);
} catch {
body = text;
}
}
throw buildRequestError(
`Failed to fetch data: ${res.status} ${res.statusText}`,
res.status,
body,
);
};
export const postData = async (url: string, data: object): Promise<any> => {
const options: RequestInit = {
method: "POST",
headers: withSiteIdHeaders({
"Content-Type": "application/json",
}),
body: JSON.stringify(data),
};
const res = await fetch(`${BASE_URL}/${url}`, options);
if (res.status === 401) {
handleUnauthorizedResponse();
}
if (res.ok) {
const resData = await res.json();
console.log('postData response:', resData);
return resData.data;
}
};
export const postRawJson = async (url: string, data: object): Promise<any> => {
const res = await fetch(resolveApiUrl(url), {
method: "POST",
headers: withSiteIdHeaders({
"Content-Type": "application/json",
}),
body: JSON.stringify(data),
});
if (res.status === 401) {
handleUnauthorizedResponse();
}
const text = await res.text();
const body = text && text.trim() ? JSON.parse(text) : null;
if (!res.ok) {
const message =
body?.message ??
`Failed to post data: ${res.status} ${res.statusText}`;
const error = new Error(message) as Error & { status?: number; body?: any };
error.status = res.status;
error.body = body;
throw error;
}
return body;
};
/** 공통 응답 래퍼를 사용하는 JSON PUT 요청을 전송하고 data 필드만 반환한다. */
export const putData = async (url: string, data: object): Promise<any> => {
const res = await fetch(resolveApiUrl(url), {
method: "PUT",
headers: withSiteIdHeaders({
"Content-Type": "application/json",
}),
body: JSON.stringify(data),
});
if (res.status === 401) {
handleUnauthorizedResponse();
}
const text = await res.text();
let body: unknown = null;
if (text.trim()) {
try {
body = JSON.parse(text);
} catch {
throw buildInvalidJsonResponseError(url, text, res.headers.get("content-type"));
}
}
if (!res.ok) {
throw buildRequestError(
`Failed to update data: ${res.status} ${res.statusText}`,
res.status,
body,
);
}
if (typeof body === "object" && body !== null && "data" in body) {
return (body as { data?: unknown }).data ?? null;
}
return body;
};
// 기존 import 경로를 유지하는 호환 진입점이다. HTTP 처리는 shared/api가 소유한다.
const httpTransport = createHttpTransport({
baseUrl: BASE_URL,
headers: DEFAULT_HEADERS,
onUnauthorized: handleUnauthorizedResponse,
});
export const { getData, postData, putData, getRawJson, postRawJson, deleteData } =
httpTransport;
const parseDownloadFileName = (contentDisposition: string | null) => {
if (!contentDisposition) return undefined;
@@ -283,62 +133,6 @@ export const postMutiPart = async (url: string, data: FormData): Promise<any> =>
}
};
export const deleteData = async (url: string, data?: object): Promise<any> => {
const options: RequestInit = {
method: "DELETE",
headers: withSiteIdHeaders({
"Content-Type": "application/json",
}),
body: data ? JSON.stringify(data) : undefined,
};
const res = await fetch(resolveApiUrl(url), options);
if (res.status === 401) {
handleUnauthorizedResponse();
}
if (res.status === 204) return true
return res;
};
// GET 응답을 공통 data wrapper 없이 원문 JSON으로 읽는다. init은 지도 bbox 요청처럼 AbortSignal이 필요한 호출에서 사용한다.
export const getRawJson = async (url: string, init: RequestInit = {}): Promise<any> => {
const res = await fetch(resolveApiUrl(url), {
...init,
method: "GET",
headers: withSiteIdHeaders(init.headers),
});
if (res.status === 401) {
handleUnauthorizedResponse();
}
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(
`Failed to fetch data: ${res.status} ${res.statusText}${errorText ? ` - ${errorText.slice(0, 200)}` : ""}`,
);
}
const contentLength = res.headers.get('content-length');
if (contentLength === '0') {
return null;
}
const text = await res.text();
if (!text || text.trim() === '') {
return null;
}
const trimmed = text.trim();
if (trimmed.startsWith("<")) {
throw buildInvalidJsonResponseError(url, text, res.headers.get("content-type"));
}
try {
return JSON.parse(text);
} catch {
throw buildInvalidJsonResponseError(url, text, res.headers.get("content-type"));
}
};
export const getBlobData = async (url: string): Promise<Blob> => {
const res = await fetch(resolveApiUrl(url), {
method: "GET",
@@ -1,33 +0,0 @@
/**
* 📄 Content Layout Component
* 콘텐츠 레이아웃 컴포넌트
*
* - 제목 표시 및 콘텐츠 영역 제공
* - 로딩 오버레이를 통한 사용자 피드백
* - 공통 레이아웃 구조 적용 (MainDiv, Title, ContentsDiv)
* - 조건부 로딩 상태 렌더링
* - 클래스명 기반 스타일링 지원
* - 재사용 가능한 레이아웃 컴포넌트
*/
import { ContentsDiv, MainDiv, Title } from '@common/business_components/ui'
import React, { type ReactNode } from 'react'
import LoadingOverlay from './LoadingBar'
type LayoutNode = {
children : ReactNode
title : string
isLoading?: boolean
className?: string
}
const ContentLayout = ({children, title, isLoading, className} : LayoutNode) => {
return (
<MainDiv>
<Title title={title} />
<ContentsDiv>
<LoadingOverlay isLoading={isLoading ?? false}/>
{children}
</ContentsDiv>
</MainDiv>
)
}
export default ContentLayout
-36
View File
@@ -1,36 +0,0 @@
/**
* 📄 Form Section Component
* 폼 섹션 컴포넌트
*
* - 폼 필드 렌더링 및 제목 표시
* - 필수 입력 필드 표시 (별표 표시)
* - FormField를 통한 폼 상태 관리
* - 그리드 레이아웃을 통한 정렬 및 배치
* - 재사용 가능한 폼 섹션 구조 제공
* - react-hook-form 통합 지원
*/
import { FormField } from '@common/business_components/ui'
import React, { type ReactElement } from 'react'
interface FormSectionProps {
children: ReactElement
titleNm: string
form: any
name: string
required?: boolean
}
const FormSection = ({ children, titleNm, form, name, required }: FormSectionProps) => {
return (
<div className="grid grid-cols-[200px_1fr] items-center">
<div className="text-xl font-medium">{titleNm}{required && <span className="text-2xl text-red-500">*</span>}</div>
<FormField
control={form.control}
name={name}
render={({ field }) => children}
/>
</div>
)
}
export default FormSection
@@ -1,65 +0,0 @@
"use client";
import { Button } from "@common/components";
import { VerticalTable } from "@/components/ui/verticalTable";
import {
AGED_PIPE_DETAIL_FIELDS,
getElapsedYears,
getPipeDisplayValue,
} from "./agedPipeUtils";
type Props = {
featureData: any;
onClose: () => void;
};
export default function AgedPipeDetailPanel({ featureData, onClose }: Props) {
const source = featureData?.values_ ?? featureData ?? {};
const facilityNo = String(getPipeDisplayValue(source, "fclt_mng_no") ?? "-");
const elapsedYears = getElapsedYears(source.instl_ymd ?? source.instlYmd);
const detailFields = [
...AGED_PIPE_DETAIL_FIELDS,
{ id: "elapsedYears", title: "경과년도" },
];
const tableData = detailFields.reduce<Record<string, string>>((acc, field) => {
acc[field.id] =
field.id === "elapsedYears"
? elapsedYears === null
? "확인불가"
: `${elapsedYears}년`
: String(getPipeDisplayValue(source, field.id) ?? "-");
return acc;
}, {});
return (
<div className="absolute right-0 top-0 z-[50] flex h-[calc(100vh-5rem)] w-[350px] flex-col overflow-hidden border-l border-gray-300 bg-white">
<div className="card-header relative flex items-center justify-between bg-primary p-4">
<div>
<div className="text-2xl font-medium text-white text-label">
하수관로 정보 <br />관리번호 : {facilityNo}
</div>
</div>
<Button
type="button"
size="xs"
className="text-white hover:text-gray-300"
onClick={onClose}
>
✕
</Button>
</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden">
<VerticalTable
headName="기본정보"
tableData={[tableData]}
headlist={detailFields}
pageSize={20}
total={0}
cellClick={undefined}
onPageChange={() => undefined}
/>
</div>
</div>
);
}
@@ -1,73 +0,0 @@
"use client";
import { useState } from "react";
import type { AgedPipeFilters } from "./agedPipeUtils";
type OverlayProps = {
onClose?: () => void;
isPlaying?: boolean;
setIsPlaying?: (value: boolean) => void;
totalSteps?: number;
currentStep?: number;
};
type Props = OverlayProps & {
title?: string;
filters: AgedPipeFilters;
onFiltersChange: (filters: AgedPipeFilters) => void;
onApply: () => void;
};
export default function AgedPipeFilterPanel({
title = "지반침하정보",
filters,
onFiltersChange,
onApply,
}: Props) {
const [isCollapsed, setIsCollapsed] = useState(false);
return (
<div className="overflow-hidden rounded-sm border border-[#bdbdbd] bg-white">
<div className="relative flex min-h-[34px] items-center justify-center border-b border-[#eeeeee] bg-white px-8">
<button
type="button"
className="absolute left-2 top-1/2 h-[18px] w-[18px] -translate-y-1/2 bg-transparent text-[1.2rem] leading-none text-[#555]"
onClick={() => setIsCollapsed((value) => !value)}
title={isCollapsed ? "펼치기" : "접기"}
>
{isCollapsed ? "›" : "⌄"}
</button>
<span className="text-[1.3rem] font-semibold text-[#4b5563]">{title}</span>
</div>
{!isCollapsed && (
<div className="px-3 py-2">
<div className="flex min-h-[30px] items-center gap-1 whitespace-nowrap text-[1.2rem] text-[#333]">
<label htmlFor="aged-pipe-elapsed-years">경과년도(년)</label>
<input
id="aged-pipe-elapsed-years"
className="h-[22px] w-10 border border-[#ccd6df] px-1 text-center text-[1.2rem]"
inputMode="numeric"
value={String(filters.elapsedYears)}
onChange={(event) => {
const next = Number(event.target.value.replace(/[^0-9]/g, "") || "0");
onFiltersChange({ ...filters, elapsedYears: next });
}}
onKeyDown={(event) => {
if (event.key === "Enter") onApply();
}}
/>
<button
type="button"
className="grid h-[22px] w-[22px] place-items-center rounded-full bg-[#31a83b] text-[1.4rem] font-bold leading-none text-white"
onClick={onApply}
title="적용"
>
✓
</button>
</div>
</div>
)}
</div>
);
}
@@ -1,177 +0,0 @@
"use client";
import { useRef } from "react";
import { Button, Input } from "@common/components";
import { UiTable } from "@common/business_components/ui";
import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore";
import {
AGED_PIPE_LIST_HEADLIST,
type AgedPipeFilters,
type AgedPipeRow,
} from "./agedPipeUtils";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
filters: AgedPipeFilters;
onFiltersChange: (filters: AgedPipeFilters) => void;
rows: AgedPipeRow[];
total: number;
currentPage: number;
isLoading: boolean;
onSearch: (page?: number) => void;
onDownload: () => void;
onRowDoubleClick: (row: AgedPipeRow) => void;
};
export default function AgedPipeListModal({
open,
onOpenChange,
filters,
onFiltersChange,
rows,
total,
currentPage,
isLoading,
onSearch,
onDownload,
onRowDoubleClick,
}: Props) {
const { ctpv, sgg } = useStore();
const lastClickRef = useRef<{ index: number; clickedAt: number } | null>(null);
const sggOptions = filters.ctpvCd === "0"
? []
: sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2)));
const handleRowClick = (index?: number) => {
if (typeof index !== "number") return;
const row = rows[index];
if (!row) return;
const now = Date.now();
const lastClick = lastClickRef.current;
if (lastClick && lastClick.index === index && now - lastClick.clickedAt < 300) {
lastClickRef.current = null;
onRowDoubleClick(row);
return;
}
lastClickRef.current = { index, clickedAt: now };
};
if (!open) return null;
return (
<div className="pointer-events-none absolute inset-0 z-[70] flex items-center justify-center px-6 py-8">
<div className="pointer-events-auto relative flex max-h-[82vh] w-[min(1120px,94vw)] flex-col overflow-hidden border border-[#dbe2e6] bg-white shadow-[0_0_12px_rgba(0,0,0,0.25)]">
<LoadingOverlay isLoading={isLoading} />
<div className="flex items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3">
<div>
<div className="text-[1.45rem] font-semibold text-white">노후관로 목록</div>
</div>
<Button
type="button"
color="white"
size="xs"
className="border border-white/30 px-3 py-1 text-[1.05rem] text-white"
onClick={() => onOpenChange(false)}
>
닫기
</Button>
</div>
<div className="relative overflow-y-auto p-4">
<div className="grid grid-cols-1 gap-3 bg-slate-50 pb-4 md:grid-cols-[110px_minmax(0,1fr)_110px_minmax(0,1fr)_auto_auto]">
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">경과년수</div>
<Input
className="h-10 !text-[1.1rem]"
inputMode="numeric"
value={String(filters.elapsedYears)}
onChange={(event) => {
const next = Number(event.target.value.replace(/[^0-9]/g, "") || "0");
onFiltersChange({ ...filters, elapsedYears: next });
}}
onKeyDown={(event) => {
if (event.key === "Enter") onSearch(1);
}}
/>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.ctpvCd}
onChange={(event) =>
onFiltersChange({
...filters,
ctpvCd: event.target.value,
sggCd: "0",
})
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시군구</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.sggCd}
onChange={(event) => onFiltersChange({ ...filters, sggCd: event.target.value })}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<div className="flex flex-wrap gap-2 md:col-span-3 md:justify-end">
<Button type="button" size="sm" color="primary" className="h-10 px-5 !text-[1.1rem]" onClick={() => onSearch(1)}>
조회
</Button>
<Button
type="button"
size="sm"
color="green"
className="h-10 px-5 !text-[1.1rem]"
onClick={onDownload}
>
다운로드
</Button>
</div>
</div>
<div className="border-t border-slate-200 pt-3">
<UiTable<AgedPipeRow>
headName={<>노후관로 조회 결과</>}
tableData={rows}
headlist={AGED_PIPE_LIST_HEADLIST}
pageSize={10}
total={total}
cellClick={(index) => handleRowClick(index)}
onPageChange={(page) => onSearch(page)}
curPage={currentPage}
widthFixed={true}
/>
</div>
<div className="px-[1.2rem] pb-1 text-[1rem] text-slate-500">
목록 행을 더블클릭하면 지도 이동 후 상세 패널이 열립니다.
</div>
</div>
</div>
</div>
);
}
@@ -1,221 +0,0 @@
"use client";
import type { SelectDataType } from "@common/business_components";
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
import {
MS_SWAGE_PPL_CODES,
MS_SWAGE_PPL_USE_CODES,
MsSwagePplCol,
} from "../map/components/common/table";
export type AgedPipeFilters = {
elapsedYears: number;
ctpvCd: string;
sggCd: string;
};
export type AgedPipeRow = {
fid: string;
lyrTypeNm: string;
rowNumber: number;
fcltMngNo: string;
instlYmd: string;
pdmtVl: string | number;
prlgVl: string | number;
usgCd: string;
mqltCd: string;
elapsedYears: number | string;
ctpvNm?: string;
sggNm?: string;
[key: string]: unknown;
};
type PipeSource = Record<string, unknown>;
const useMap = new Map(MS_SWAGE_PPL_USE_CODES.map((item) => [item.val.trim(), item.text]));
const materialMap = new Map(MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text]));
export const AGED_PIPE_DEFAULT_FILTERS: AgedPipeFilters = {
elapsedYears: 20,
ctpvCd: "0",
sggCd: "0",
};
export const AGED_PIPE_LIST_HEADLIST = [
{ id: "rowNumber", title: "NO", widthPercent: 6 },
{ id: "fcltMngNo", title: "시설관리번호", widthPercent: 18 },
{ id: "instlYmd", title: "설치일자", widthPercent: 12 },
{ id: "pdmtVl", title: "관경(mm)", widthPercent: 10 },
{ id: "prlgVl", title: "연장(m)", widthPercent: 10 },
{ id: "usgCd", title: "용도", widthPercent: 14 },
{ id: "mqltCd", title: "재질", widthPercent: 18 },
{ id: "elapsedYears", title: "경과년도", widthPercent: 12, align: "right" },
];
export const AGED_PIPE_EXCEL_HEADER = {
rowNumber: "NO",
fcltMngNo: "시설관리번호",
instlYmd: "설치일자",
pdmtVl: "관경(mm)",
prlgVl: "연장(m)",
usgCd: "용도",
mqltCd: "재질",
elapsedYears: "경과년도",
};
export const AGED_PIPE_DETAIL_FIELDS = MsSwagePplCol;
const getToday = () => new Date();
const pad2 = (value: number) => String(value).padStart(2, "0");
export const getCutoffYmd = (elapsedYears: number) => {
const today = getToday();
const cutoff = new Date(today.getFullYear() - elapsedYears, today.getMonth(), today.getDate());
return `${cutoff.getFullYear()}${pad2(cutoff.getMonth() + 1)}${pad2(cutoff.getDate())}`;
};
const normalizeYmd = (value: unknown) => String(value ?? "").replace(/[^0-9]/g, "").slice(0, 8);
const getCodePrefix = (source: PipeSource) =>
String(source.pbadmsEmdCd ?? source.pbadms_emd_cd ?? "").trim();
const resolveRegionName = (
code: string,
directName: unknown,
options: SelectDataType[],
prefixLength: number,
) => {
if (String(directName ?? "").trim()) return String(directName);
const prefix = code.slice(0, prefixLength);
return options.find((item) => String(item.val).trim() === prefix)?.text ?? "";
};
export const getElapsedYears = (value: unknown) => {
const normalized = normalizeYmd(value);
if (normalized.length < 4) return null;
const year = Number(normalized.slice(0, 4));
const month = Number(normalized.slice(4, 6) || "1");
const day = Number(normalized.slice(6, 8) || "1");
if (!year) return null;
const today = getToday();
let elapsed = today.getFullYear() - year;
if (
today.getMonth() + 1 < month ||
(today.getMonth() + 1 === month && today.getDate() < day)
) {
elapsed -= 1;
}
return Math.max(elapsed, 0);
};
export const buildAgedPipeCqlFilters = (filters: AgedPipeFilters) => {
const cqlFilters: string[] = [];
cqlFilters.push(`instl_ymd <= '${getCutoffYmd(filters.elapsedYears)}'`);
if (filters.sggCd !== "0") {
cqlFilters.push(`pbadms_emd_cd LIKE '${filters.sggCd}%'`);
} else if (filters.ctpvCd !== "0") {
cqlFilters.push(`pbadms_emd_cd LIKE '${filters.ctpvCd}%'`);
}
return cqlFilters;
};
export const matchesAgedPipeFilters = (source: PipeSource, filters: AgedPipeFilters) => {
const instlYmd = normalizeYmd(source.instl_ymd ?? source.instlYmd);
if (!instlYmd || instlYmd > getCutoffYmd(filters.elapsedYears)) return false;
const regionCode = getCodePrefix(source);
if (filters.sggCd !== "0") return regionCode.startsWith(filters.sggCd);
if (filters.ctpvCd !== "0") return regionCode.startsWith(filters.ctpvCd);
return true;
};
export const toDisplayPipeRow = (
source: PipeSource,
index: number,
page: number,
ctpvOptions: SelectDataType[],
sggOptions: SelectDataType[],
): AgedPipeRow => {
const formatted = formatDisplayData(source) as PipeSource;
const codePrefix = getCodePrefix(source);
const instlYmd = formatted.instlYmd ?? source.instlYmd ?? formatted.instl_ymd ?? source.instl_ymd;
const trimmedUse = String(source.usgCd ?? source.usg_cd ?? "").trim();
const trimmedMaterial = String(source.mqltCd ?? source.mqlt_cd ?? "").trim();
const pdmtVl = formatted.pdmtVl ?? source.pdmtVl ?? source.pdmt_vl ?? "-";
const prlgVl = formatted.prlgVl ?? source.prlgVl ?? source.prlg_vl ?? "-";
return {
...source,
...formatted,
fid: String(source.fid ?? ""),
lyrTypeNm: String(source.lyrTypeNm ?? "tb_ms_swage_ppl"),
rowNumber: (page - 1) * 10 + index + 1,
fcltMngNo: String(formatted.fcltMngNo ?? source.fcltMngNo ?? source.fclt_mng_no ?? "-"),
instlYmd: String(instlYmd ?? "-"),
pdmtVl: typeof pdmtVl === "number" ? pdmtVl : String(pdmtVl),
prlgVl: typeof prlgVl === "number" ? prlgVl : String(prlgVl),
usgCd: useMap.get(trimmedUse) ?? String(formatted.usgCd ?? source.usgCd ?? source.usg_cd ?? "-"),
mqltCd:
materialMap.get(trimmedMaterial) ??
String(formatted.mqltCd ?? source.mqltCd ?? source.mqlt_cd ?? "-"),
elapsedYears: getElapsedYears(source.instlYmd ?? source.instl_ymd) ?? "-",
ctpvNm: resolveRegionName(
codePrefix,
formatted.ctpvNm ?? source.ctpvNm ?? source.ctpv_nm,
ctpvOptions,
2,
),
sggNm: resolveRegionName(
codePrefix,
formatted.sggNm ?? source.sggNm ?? source.sgg_nm,
sggOptions,
5,
),
};
};
export const getPipeDisplayValue = (source: PipeSource, key: string) => {
const formatted = formatDisplayData(source) as PipeSource;
const keyMap: Record<string, string[]> = {
fclt_mng_no: ["fclt_mng_no", "fcltMngNo"],
inst_mng_no: ["inst_mng_no", "instMngNo"],
instl_ymd: ["instl_ymd", "instlYmd"],
mapsht_no: ["mapsht_no", "mapshtNo"],
pdmt_vl: ["pdmt_vl", "pdmtVl"],
prlg_vl: ["prlg_vl", "prlgVl"],
usg_cd: ["usg_cd", "usgCd"],
mqlt_cd: ["mqlt_cd", "mqltCd"],
avg_grdn: ["avg_grdn", "avgGrdn"],
drtw_mltv_area: ["drtw_mltv_area", "drtwMltvArea"],
excln_mltv_area: ["excln_mltv_area", "exclnMltvArea"],
tmpt_dpth: ["tmpt_dpth", "tmptDpth"],
tmpt_mhl_mng_no: ["tmpt_mhl_mng_no", "tmptMhlMngNo"],
scend_dpth: ["scend_dpth", "scendDpth"],
scend_mhl_mng_no: ["scend_mhl_mng_no", "scendMhlMngNo"],
trfln_cnt: ["trfln_cnt", "trflnCnt"],
vrtc_len: ["vrtc_len", "vrtcLen"],
wdth_len: ["wdth_len", "wdthLen"],
};
const candidates = keyMap[key] ?? [key];
for (const candidate of candidates) {
const value = formatted[candidate] ?? source[candidate];
if (value !== undefined && value !== null && value !== "") {
if (candidate === "usg_cd" || candidate === "usgCd") {
return useMap.get(String(source.usg_cd ?? source.usgCd ?? "").trim()) ?? value;
}
if (candidate === "mqlt_cd" || candidate === "mqltCd") {
return materialMap.get(String(source.mqlt_cd ?? source.mqltCd ?? "").trim()) ?? value;
}
return value;
}
}
return "-";
};
@@ -1,407 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Gis from "@common/business_components/ui/map/Gis";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import Stroke from "ol/style/Stroke.js";
import Style from "ol/style/Style.js";
import useStore from "@/store/useStore";
import { getData, getRawJson } from "@/components/lib/api";
import AgedPipeFilterPanel from "./AgedPipeFilterPanel";
import AgedPipeDetailPanel from "./AgedPipeDetailPanel";
import AgedPipeListModal from "./AgedPipeListModal";
import {
AGED_PIPE_DEFAULT_FILTERS,
AGED_PIPE_EXCEL_HEADER,
buildAgedPipeCqlFilters,
matchesAgedPipeFilters,
toDisplayPipeRow,
type AgedPipeFilters,
type AgedPipeRow,
} from "./agedPipeUtils";
const agedPipeHighlightStyle = new Style({
stroke: new Stroke({
color: "rgba(239, 68, 68, 0.95)",
width: 5,
}),
zIndex: 999,
});
const PIPE_LAYER_TYPE = "tb_ms_swage_ppl";
const EmptySearchView = () => null;
const normalizeTreeRowSpacing = (data: any[]) => {
if (!Array.isArray(data)) return data;
return data.map((item) => {
if (item?.lyrNm !== "지반침하정보") {
return item;
}
return {
...item,
iconNm: null,
gobjTy: "",
};
});
};
const DESIGN_PREVIEW_FEATURE = {
fid: "preview-aged-pipe",
lyrTypeNm: PIPE_LAYER_TYPE,
fcltMngNo: "SN-PPL-000123",
instMngNo: "INST-4458",
instlYmd: "19981215",
mapshtNo: "M-12-34",
pdmtVl: 450,
prlgVl: 132.5,
usgCd: "합류관",
mqltCd: "철근콘크리트관(RC)",
avgGrdn: 0.0021,
drtwMltvArea: 2140,
exclnMltvArea: 980,
tmptDpth: 2.1,
tmptMhlMngNo: "TMH-2201",
scendDpth: 2.4,
scendMhlMngNo: "SMH-2202",
trflnCnt: 2,
vrtcLen: 1.2,
wdthLen: 1.2,
};
const Map = () => {
const gisMaps = useRef<any | null>(null);
const [mapReady, setMapReady] = useState(false);
const [treeData, setTreeData] = useState<any>([]);
const [isDetailOpen, setIsDetailOpen] = useState(true);
const [featureData, setFeatureData] = useState<any>(DESIGN_PREVIEW_FEATURE);
const [draftFilters, setDraftFilters] = useState<AgedPipeFilters>(AGED_PIPE_DEFAULT_FILTERS);
const [appliedFilters, setAppliedFilters] = useState<AgedPipeFilters>(AGED_PIPE_DEFAULT_FILTERS);
const [isListOpen, setIsListOpen] = useState(true);
const [listRows, setListRows] = useState<AgedPipeRow[]>([
{
fid: "preview-aged-pipe",
lyrTypeNm: PIPE_LAYER_TYPE,
rowNumber: 1,
fcltMngNo: "SN-PPL-000123",
instlYmd: "1998-12-15",
pdmtVl: 450,
prlgVl: 132.5,
usgCd: "합류관",
mqltCd: "철근콘크리트관(RC)",
elapsedYears: 27,
ctpvNm: "서울특별시",
sggNm: "강남구",
},
{
fid: "preview-aged-pipe-2",
lyrTypeNm: PIPE_LAYER_TYPE,
rowNumber: 2,
fcltMngNo: "SN-PPL-000124",
instlYmd: "2001-03-10",
pdmtVl: 300,
prlgVl: 88.4,
usgCd: "오수관",
mqltCd: "염화비닐관(PVC)",
elapsedYears: 25,
ctpvNm: "서울특별시",
sggNm: "강남구",
},
]);
const [totalCount, setTotalCount] = useState(2);
const [currentPage, setCurrentPage] = useState(1);
const [isListLoading, setIsListLoading] = useState(false);
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
const { ctpv, sgg } = useStore();
useEffect(() => {
const fetchData = async () => {
try {
const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK");
setTreeData(normalizeTreeRowSpacing(data));
} catch (error) {
console.error("노후관로 레이어 설정 조회 중 오류:", error);
throw new Error("Invalid JSON response from server");
}
};
fetchData();
}, []);
useEffect(() => {
const timer = window.setInterval(() => {
if (gisMaps.current) {
setMapReady(true);
window.clearInterval(timer);
}
}, 300);
return () => window.clearInterval(timer);
}, []);
const fetchAgedPipeRows = useCallback(
async (page: number, filters: AgedPipeFilters, size: number) => {
const queryParams = new URLSearchParams({
lyrTypeNm: PIPE_LAYER_TYPE,
page: String(page - 1),
size: String(size),
sort: "string",
});
const cqlFilters = buildAgedPipeCqlFilters(filters);
if (cqlFilters.length > 0) {
queryParams.append("cqlFiter", cqlFilters.join(" and "));
}
const response = await getData(`v1/fclts?${queryParams.toString()}`);
const rows = Array.isArray(response?.data) ? response.data : [];
return {
rows: rows.map((item: Record<string, unknown>, index: number) =>
toDisplayPipeRow(item, index, page, ctpv, sgg),
),
total: Number(response?.count ?? 0),
};
},
[ctpv, sgg],
);
const loadAgedPipeList = useCallback(
async (page = 1, filters = appliedFilters) => {
setIsListLoading(true);
try {
const response = await fetchAgedPipeRows(page, filters, 10);
setListRows(response.rows);
setTotalCount(response.total);
setCurrentPage(page);
} catch (error) {
console.error("노후관로 목록 조회 중 오류:", error);
setListRows([]);
setTotalCount(0);
} finally {
setIsListLoading(false);
}
},
[appliedFilters, fetchAgedPipeRows],
);
useEffect(() => {
loadAgedPipeList(1, appliedFilters);
}, [appliedFilters, loadAgedPipeList]);
const handleApplyFilters = useCallback(() => {
const normalized = {
...draftFilters,
elapsedYears: draftFilters.elapsedYears > 0 ? draftFilters.elapsedYears : 20,
};
setDraftFilters(normalized);
setAppliedFilters(normalized);
if (isListOpen) {
loadAgedPipeList(1, normalized);
}
}, [draftFilters, isListOpen, loadAgedPipeList]);
const handleFeatureSelect = useCallback((data: any) => {
const map = gisMaps.current;
if (!map) return;
const select = GisApp.Module.select as any;
if (!select) return;
select.resetSelectedFeature();
let foundFeature = null;
if (data && typeof data.getGeometry === "function" && typeof data.setStyle === "function") {
foundFeature = data;
} else {
const featureId = data?.fid ?? data?.ftrIdn ?? data?.id;
map
.getLayers()
.getArray()
.forEach((layer: any) => {
const source = layer.getSource?.();
if (!source?.getFeatureById) return;
const feature = source.getFeatureById(String(featureId).trim());
if (feature) foundFeature = feature;
});
}
if (foundFeature?.getGeometry) {
map.getView().fit(foundFeature.getGeometry().getExtent(), {
duration: 300,
padding: [50, 50, 50, 50],
maxZoom: 16,
});
select.selectFeature(foundFeature, true);
}
setFeatureData(data);
setIsDetailOpen(true);
}, []);
const handleOpenPipeList = useCallback(() => {
setIsListOpen(true);
loadAgedPipeList(1, appliedFilters);
}, [appliedFilters, loadAgedPipeList]);
const handleTreePopup = useCallback(
(layerNm: string) => {
if (String(layerNm).includes("하수관로")) {
handleOpenPipeList();
}
},
[handleOpenPipeList],
);
const handleListRowDoubleClick = useCallback(
async (row: AgedPipeRow) => {
try {
const core = GisApp.Module.layer as any;
await core.showSearchResultsFromGeoJSON(PIPE_LAYER_TYPE, row.fid);
} catch (error) {
console.error("노후관로 위치 이동 중 오류:", error);
}
setFeatureData(row);
setIsDetailOpen(true);
setIsListOpen(false);
},
[],
);
const handleDownload = useCallback(async () => {
try {
setIsListLoading(true);
const response = await fetchAgedPipeRows(1, appliedFilters, 10000);
await downloadExcelWithKoreanHeaders({
data: response.rows as JSON[],
fileName: `노후관로_목록_${new Date().toISOString().slice(0, 10)}`,
sheetNameArray: ["노후관로 목록"],
headerRange: "A1:H1",
headerMapping: AGED_PIPE_EXCEL_HEADER,
});
} catch (error) {
console.error("노후관로 목록 다운로드 중 오류:", error);
alert("노후관로 목록 다운로드 중 오류가 발생했습니다.");
} finally {
setIsListLoading(false);
}
}, [appliedFilters, fetchAgedPipeRows]);
useEffect(() => {
if (!mapReady || !gisMaps.current) return;
const map = gisMaps.current;
const cleanups: Array<() => void> = [];
const filterFn = (feature: any) =>
matchesAgedPipeFilters(feature?.getProperties?.() ?? feature?.values_ ?? {}, appliedFilters);
const decorateLayer = (layer: any) => {
if (layer?.get?.("lyrTyNm") !== PIPE_LAYER_TYPE) return;
const originalStyle =
layer.get("_agedPipeOriginalStyle") ?? layer.getStyleFunction?.() ?? layer.getStyle?.();
if (!layer.get("_agedPipeOriginalStyle")) {
layer.set("_agedPipeOriginalStyle", originalStyle);
}
layer.setStyle((feature: any, resolution: number) => {
const baseStyle =
typeof originalStyle === "function" ? originalStyle(feature, resolution) : originalStyle;
const styles = Array.isArray(baseStyle) ? [...baseStyle] : baseStyle ? [baseStyle] : [];
if (filterFn(feature)) {
styles.push(agedPipeHighlightStyle);
}
return styles;
});
const source = layer.getSource?.();
if (source?.on) {
const onLoadEnd = () => layer.changed?.();
source.on("featuresloadend", onLoadEnd);
cleanups.push(() => source.un?.("featuresloadend", onLoadEnd));
}
layer.changed?.();
};
map.getLayers().getArray().forEach(decorateLayer);
const layers = map.getLayers();
const onAdd = (event: any) => decorateLayer(event.element);
layers.on?.("add", onAdd);
cleanups.push(() => layers.un?.("add", onAdd));
return () => {
cleanups.forEach((cleanup) => cleanup());
};
}, [appliedFilters, mapReady]);
const LayerSettingView = useMemo(
() =>
function LayerSettingComponent() {
return (
<AgedPipeFilterPanel
title="지반침하정보"
filters={draftFilters}
onFiltersChange={setDraftFilters}
onApply={handleApplyFilters}
/>
);
},
[draftFilters, handleApplyFilters],
);
return (
<>
<div className="bg-container">
<Gis
treeData={treeData}
isSearch={false}
gisMaps={gisMaps}
isMiniMap={false}
searchView={EmptySearchView}
detailView={AgedPipeDetailPanel}
onFeatureSelect={handleFeatureSelect}
onOpenPopup={handleTreePopup}
featureData={featureData}
setFeatureData={setFeatureData}
isOpen={isDetailOpen}
setIsOpen={setIsDetailOpen}
isSearchModalOpen={isSearchModalOpen}
setIsSearchModalOpen={setIsSearchModalOpen}
onCheckedLayersChange={() => undefined}
searchBar={<></>}
layerSettingView={LayerSettingView}
showZoomLevel={true}
defaultZoom={7.5}
defaultLonLat={[127.75, 36]}
/>
</div>
<AgedPipeListModal
open={isListOpen}
onOpenChange={setIsListOpen}
filters={draftFilters}
onFiltersChange={setDraftFilters}
rows={listRows}
total={totalCount}
currentPage={currentPage}
isLoading={isListLoading}
onSearch={(page = 1) => {
const normalized = {
...draftFilters,
elapsedYears: draftFilters.elapsedYears > 0 ? draftFilters.elapsedYears : 20,
};
setDraftFilters(normalized);
setAppliedFilters(normalized);
loadAgedPipeList(page, normalized);
}}
onDownload={handleDownload}
onRowDoubleClick={handleListRowDoubleClick}
/>
</>
);
};
export default Map;
-58
View File
@@ -1,58 +0,0 @@
/**
* ➡️ 경로 : 하수관망도 모든 페이지
*
* 📄 Map Layout
* Next.js App Router (app/) 기반 맵 레이아웃 컴포넌트
*
* - 메뉴 클릭 이벤트 처리 및 라우팅
* - 현재 메뉴 정보 Zustand를 통한 상태 관리
* - 시도/시군구/사업 구분/사업 유형 데이터 초기 로드
* - NoFooterLayout을 통한 공통 레이아웃 적용
* - QueryClient를 통한 데이터 캐싱 및 관리
* - 경로 변경 시 메뉴 정보 동기화
*/
"use client";
import React, { useEffect } from 'react';
import { usePathname, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { NoFooterLayout } from "@common/components";
import type { MenuItem } from "@/entities/menu";
import { useMenuStore } from "@/shared/lib";
import useStore from "@/store/useStore";
export default function MapLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
const queryClient = useQueryClient();
const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo);
const { fetchCtpv, fetchSgg, fetchBIZ_SE_CD, fetchBIZ_TY_CD } = useStore();
useEffect(() => {
fetchCtpv();
fetchSgg();
fetchBIZ_SE_CD();
fetchBIZ_TY_CD();
}, []);
const handleMenuClick = (item: MenuItem) => {
setCurrentMenuInfo(item);
router.push(item.urlPath);
};
return (
<NoFooterLayout
queryClient={queryClient}
currentPath={pathname ?? ""}
handleMenuClick={handleMenuClick}
>
{children}
</NoFooterLayout>
);
}
@@ -1,58 +0,0 @@
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@common/components/ui";
import React from "react";
interface InputSectionProps {
title: string;
data: { year: string; type: string, bizInfoNm: string, bizType: string }[];
sectionKey: string;
onInputChange: (section: string, index: number, field: string, value: string) => void;
}
const InputSection: React.FC<InputSectionProps> = ({ title, data, sectionKey, onInputChange }) => {
return (
<div>
{/* 섹션 제목 */}
<div className="card-header">
<h3 className="text-2xl mx-3 font-medium text-label whitespace-nowrap">{title}</h3>
</div>
{/* 테이블 */}
<div className="card !m-[1.2rem] !p-0r">
<Table variant="secondary" className="w-full">
<TableHeader>
<TableRow>
<TableHead className="border border-gray-300 px-2 py-2 text-sm w-auto whitespace-nowrap">구분</TableHead>
<TableHead className="border border-gray-300 px-2 py-2 text-sm w-[80px] whitespace-nowrap">진단연도</TableHead>
<TableHead className="border border-gray-300 px-2 py-2 text-sm w-auto whitespace-nowrap">사업명</TableHead>
<TableHead className="border border-gray-300 px-2 py-2 text-sm w-auto whitespace-nowrap">유형</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((row, index) => (
<TableRow key={index}>
<TableCell className="border border-gray-300 px-2 py-2 text-sm text-center leading-normal w-auto whitespace-nowrap">
{row.type}
</TableCell>
<TableCell className="border border-gray-300 px-2 py-2 text-sm w-[80px] whitespace-nowrap">
<input
type="text"
value={row.year}
onChange={(e) => onInputChange(sectionKey, index, "year", e.target.value)}
className="w-full text-xl border-radius-0 px-3 py-2 text-base text-center leading-normal"
/>
</TableCell>
<TableCell className="border border-gray-300 px-2 py-2 text-sm text-center leading-normal w-auto">
{row.bizInfoNm}
</TableCell>
<TableCell className="border border-gray-300 px-2 py-2 text-sm text-center leading-normal w-auto whitespace-nowrap">
{row.bizType}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
};
export default InputSection;
@@ -1,162 +0,0 @@
import { MS_SWAGE_PPL_USE_CODES, MS_SWAGE_PPL_CODES, MS_SWAGE_MHL_CODES, MS_SWAGE_MHL_USE_CODES, DRTW_SEWAGE_PUMP_USE_CODES } from './table';
interface CodeMapperProps {
layerName: string;
data: any;
}
/**
* 레이어별 코드 변환 함수
* @param layerName - 레이어 이름
* @param data - 변환할 데이터 객체
* @returns 변환된 데이터 객체
*/
export const codeMapper = ({ layerName, data }: CodeMapperProps): any => {
if (!data) return data;
const processedData = { ...data };
switch (layerName) {
case '하수관로':
// usg_cd 값 변환 (용도코드)
if (processedData.usg_cd) {
const usageCodeItem = MS_SWAGE_PPL_USE_CODES.find(
code => code.val === processedData.usg_cd.trim()
);
if (usageCodeItem) {
processedData.usg_cd = usageCodeItem.text;
}
}
// mqlt_cd 값 변환 (재질코드)
if (processedData.mqlt_cd) {
const materialCodeItem = MS_SWAGE_PPL_CODES.find(
code => code.val === processedData.mqlt_cd.trim()
);
if (materialCodeItem) {
processedData.mqlt_cd = materialCodeItem.text;
}
}
break;
case '하수맨홀':
// usg_cd 값 변환 (용도코드)
if (processedData.usg_cd) {
const usageCodeItem = MS_SWAGE_MHL_USE_CODES.find(
code => code.val.trim() === processedData.usg_cd.trim()
);
if (usageCodeItem) {
processedData.usg_cd = usageCodeItem.text;
}
}
// mqlt_cd 값 변환 (재질코드)
if (processedData.mqlt_cd) {
const materialCodeItem = MS_SWAGE_MHL_CODES.find(
code => code.val === processedData.mqlt_cd.trim()
);
if (materialCodeItem) {
processedData.mqlt_cd = materialCodeItem.text;
}
}
break;
case '하수펌프장':
// usg_cd 값 변환 (용도코드)
if (processedData.usg_cd) {
const usageCodeItem = DRTW_SEWAGE_PUMP_USE_CODES.find(
code => code.val === processedData.usg_cd.trim()
);
if (usageCodeItem) {
processedData.usg_cd = usageCodeItem.text;
}
}
break;
case '우수토실':
// prrtymngYn 값 변환 (중점여부)
if (processedData.prrtymngYn) {
if (processedData.prrtymngYn === 'Y') {
processedData.prrtymngYn = '중점우수토실';
} else if (processedData.prrtymngYn === 'N') {
processedData.prrtymngYn = '우수토실';
}
}
break;
case '중점우수토실':
if (processedData.manage_no) {
processedData.mqlt_cd = processedData.manage_no;
}
if (processedData.mesure_spot_se) {
const mesureSpotSe = [{ id: '0', text: '합류식' }, { id: '1', text: '분류식' }];
const mesureSpotSeItem = mesureSpotSe.find(
code => code.id === processedData.mesure_spot_se.trim()
);
if (mesureSpotSeItem) processedData.mesure_spot_se = mesureSpotSeItem.text;
}
break;
default:
// 다른 레이어는 변환하지 않음
break;
}
return processedData;
};
/**
* 저장 시 코드 역변환 함수 (한글 → 코드)
* @param layerName - 레이어 이름
* @param data - 역변환할 데이터 객체
* @returns 역변환된 데이터 객체
*/
export const reverseCodeMapper = ({ layerName, data }: CodeMapperProps): any => {
if (!data) return data;
const processedData = { ...data };
switch (layerName) {
case '하수맨홀':
// mqlt_cd 값 역변환 (텍스트 → 코드)
if (processedData.mqlt_cd && typeof processedData.mqlt_cd === 'string') {
const materialCodeItem = MS_SWAGE_MHL_CODES.find(
code => code.text === processedData.mqlt_cd.trim()
);
if (materialCodeItem) {
processedData.mqlt_cd = materialCodeItem.val;
}
}
break;
case '우수토실':
// prrtymngYn 값 역변환 (텍스트 → Y/N)
if (processedData.prrtymngYn) {
if (processedData.prrtymngYn === '중점우수토실') {
processedData.prrtymngYn = 'Y';
} else if (processedData.prrtymngYn === '우수토실') {
processedData.prrtymngYn = 'N';
}
}
break;
case '하수펌프장':
// usg_cd 값 역변환 (텍스트 → 코드)
if (processedData.usg_cd && typeof processedData.usg_cd === 'string') {
const usageCodeItem = DRTW_SEWAGE_PUMP_USE_CODES.find(
code => code.text === processedData.usg_cd.trim()
);
if (usageCodeItem) {
processedData.usg_cd = usageCodeItem.val;
}
}
break;
default:
break;
}
return processedData;
};
// 기본 내보내기
export default { codeMapper, reverseCodeMapper };
@@ -1,68 +0,0 @@
import MsSwagePpl from "../popupComponent/MsSwagePpl"; // 하수관로
import sewagePump from "../popupComponent/sewagePump"; // 하수펌프
import smh from "../popupComponent/smh";
import Ssc from "../popupComponent/Ssc";
import StormWaterPump from "../popupComponent/stormWaterPump";
import SwtrmtFclt from "../popupComponent/SwtrmtFclt";
type LayerComponentProps = {
featureData: any;
isMainSsc?: boolean;
isDrtwSewagePump?: boolean;
rainMeasureData?: any;
onSaveClick?: () => void;
onFormDataChange?: (data: any) => void; // 추가
chmbrXcnts: string;
chmbrYdnts: string;
gisObject?: Record<string, any>; // GIS 객체 추가
};
type LayerContentMap = {
[key: string]: {
component?: React.ComponentType<any>;
props?: any;
};
};
// 레이어 이름과 컴포넌트를 매핑
// 레이어 선택시 변경되는 팝업 컨텐츠 동적으로 변경하도록 작업
const layerContentMap: LayerContentMap = {
"하수처리시설": { component: SwtrmtFclt, }, // 디지털 자료관 연계정보가 없으니 일단 주석 처리
"하수처리구역": {},
"우수토실": { component: Ssc, },
"중점우수토실": { component: Ssc, }, // 중점우수토실 여부 설정
"하수관로": { component: MsSwagePpl, },
"하수맨홀": { component: smh, },
"하수펌프장": { component: sewagePump },
// "빗물펌프": { component: StormWaterPump },
// "오수펌프": { component: sewagePump },
};
// 동적 컴포넌트 반환 함수
export const getLayerContentComponent = (layerNm: string): React.FC<LayerComponentProps> | null => {
const component = layerContentMap[layerNm];
if (!component || !component.component) {
// component가 없으면 null 반환
return () => null;
}
return component.component as React.FC<LayerComponentProps>
};
// 사용자 입력 필드 (보수 및 갱신, 청소 및 준설, 점검 및 진단)
export const handleInputChange = (section: string,
index: number,
field: string,
value: string,
dataMap: {
[key: string]: {
data: { year: string; type: string, bizInfoNm: string, bizType: string }[];
setData: React.Dispatch<React.SetStateAction<{ year: string; type: string, bizInfoNm: string, bizType: string }[]>>;
};
}
) => {
const target = dataMap[section];
if (!target) return;
const updated = [...target.data];
if (updated[index]) {
updated[index][field as keyof typeof updated[number]] = value;
}
target.setData(updated);
};
@@ -1,848 +0,0 @@
// 하수관로
export const MsSwagePplCol = [
{ id: 'fclt_mng_no', title: '시설관리번호' },
{ id: 'inst_mng_no', title: '기관관리번호' },
{ id: 'instl_ymd', title: '설치일자' },
{ id: 'mapsht_no', title: '도엽번호' },
{ id: 'pdmt_vl', title: '관경(mm)' },
{ id: 'prlg_vl', title: '연장(m)' },
{ id: 'usg_cd', title: '용도' },
{ id: 'mqlt_cd', title: '재질' },
{ id: 'avg_grdn', title: '평균구배(m/m)' },
{ id: 'drtw_mltv_area', title: '오수배수면적(m²)' },
{ id: 'excln_mltv_area', title: '우수배수면적(m²)' },
{ id: 'tmpt_dpth', title: '시점깊이(m)' },
{ id: 'tmpt_mhl_mng_no', title: '시점맨홀관리번호' },
{ id: 'scend_dpth', title: '종점깊이(m)' },
{ id: 'scend_mhl_mng_no', title: '종점맨홀관리번호' },
{ id: 'trfln_cnt', title: '차선수' },
{ id: 'vrtc_len', title: '세로길이(m)' },
{ id: 'wdth_len', title: '가로길이(m)' },
// { id: 'rnyssn_fvlc', title: '우기 시 유속(m/s)' },
// { id: 'clar_fvlc', title: '건기 시 유속(m/s)' },
// { id: 'mng_inst_cd', title: '관리기관코드' },
// { id: 'pbadms_emd_cd', title: '행정읍면동코드' },
// { id: 'fclty_shp_cd', title: '시설물형태코드' },
// { id: 'fid', title: 'FID' },
// { id: 'ftre_smbol_cd', title: '지형지물부호코드' },
// { id: 'lyrTypeNm', title: '레이어타입명' },
]
// 하수관로 searchModal 기본정보tab
export const MsSwagePplCol2 = [
{ id: 'rowNumber', title: '일련\n번호' },
{ id: 'fcltMngNo', title: '시설\n관리번호' },
{ id: 'instMngNo', title: '기관\n관리번호' },
{ id: 'ctpvNm', title: '시도' },
{ id: 'sggNm', title: '시군구' },
{ id: "swtrmt_zone_nm", title: "처리구역" },
{ id: 'instlYmd', title: '설치일자' },
// { id: 'mapshtNo', title: '도엽번호' },
{ id: 'usgCd', title: '용도' },
{ id: 'pdmtVl', title: '관경\n(mm)' },
{ id: 'prlgVl', title: '연장\n(m)' },
// { id: 'avgGrdn', title: '평균구배\n(m/m)' },
{ id: 'tmptDpth', title: '시점깊이\n(m)' },
{ id: 'tmptMhlMngNo', title: '시점맨홀\n관리번호' },
{ id: 'scendDpth', title: '종점깊이\n(m)' },
{ id: 'scendMhlMngNo', title: '종점맨홀\n관리번호' },
{ id: 'vrtcLen', title: '세로길이\n(m)' },
{ id: 'wdthLen', title: '가로길이\n(m)' },
{ id: 'fcltyShpCd', title: '시설물형태코드' },
// { id: 'fid', title: 'FID' },
// { id: 'ftreSmbolCd', title: '지형지물부호코드' },
// { id: 'mngInstCd', title: '관리기관코드' },
{ id: 'mqltCd', title: '재질코드' },
// { id: 'pbadmsEmdCd', title: '행정읍면동코드' },
// { id: 'usgCd', title: '용도코드' },
// { id: 'lyrTypeNm', title: '레이어타입명' },
]
export const MsSwagePplExcelCol = {
'fcltMngNo': '시설 관리번호',
'instMngNo': '기관 관리번호',
'ctpvNm': '시도',
'sggNm': '시군구',
'swtrmt_zone_nm': '처리구역',
'instlYmd': '설치일자',
'usgCd': '용도',
'pdmtVl': '관경(mm)',
'prlgVl': '연장(m)',
'tmptDpth': '시점 깊이(m)',
'tmptMhlMngNo': '시점맨홀 관리번호',
'scendDpth': '종점 깊이(m)',
'scendMhlMngNo': '종점맨홀 관리번호',
'vrtcLen': '세로길이(m)',
'wdthLen': '가로길이(m)',
'fcltyShpCd': '시설물형태코드',
'mqltCd': '재질코드',
};
// 하수관로 용도 코드표
export const MS_SWAGE_PPL_USE_CODES = [
{ val: "SBA000", text: "미분류" },
{ val: "SBA001", text: "합류관" },
{ val: "SBA002", text: "차집관" },
{ val: "SBA003", text: "우수관" },
{ val: "SBA004", text: "오수관" },
{ val: "SBA999", text: "기타" },
];
// 하수관로 재질 코드표
export const MS_SWAGE_PPL_CODES = [
{ val: "MOP000", text: "미분류" },
{ val: "MOP001", text: "닥타일주철관(DCIP)" },
{ val: "MOP002", text: "강관(SP)" },
{ val: "MOP003", text: "아연도강관(GS)" },
{ val: "MOP004", text: "염화비닐관(PVC)" },
{ val: "MOP005", text: "폴리에틸렌관(PE)" },
{ val: "MOP006", text: "스테인레스관(SSP)" },
{ val: "MOP007", text: "동관(COP)" },
{ val: "MOP008", text: "P.S.콘크리트관(PC)" },
{ val: "MOP009", text: "석면시멘트관(AS)" },
{ val: "MOP010", text: "회주철관(CIP)" },
{ val: "MOP011", text: "연관(LP)" },
{ val: "MOP012", text: "흉관(HP)" },
{ val: "MOP013", text: "철근콘크리트관(RC)" },
{ val: "MOP014", text: "강관(ST)" },
{ val: "MOP015", text: "주철관(CI)" },
{ val: "MOP016", text: "화학제품류관(PV)" },
{ val: "MOP017", text: "도관(VC)" },
{ val: "MOP018", text: "석축(SE)" },
{ val: "MOP019", text: "도복장 강관(SP)" },
{ val: "MOP020", text: "흉관(EP)" },
{ val: "MOP021", text: "아연도강관(GP)" },
{ val: "MOP022", text: "청동관(BR)" },
{ val: "MOP023", text: "고무몰딩(RM)" },
{ val: "MOP024", text: "주강(CS)" },
{ val: "MOP025", text: "VR관" },
{ val: "MOP026", text: "HDPE관" },
{ val: "MOP027", text: "시멘트라이닝주철관(C)" },
{ val: "MOP028", text: "수도용 PE관(PE)" },
{ val: "MOP029", text: "석면시멘트관(AC)" },
{ val: "MOP030", text: "폴리에틸렌분체라이닝강관(PEP)" },
{ val: "MOP031", text: "에폭시코팅강관(EPS)" },
{ val: "MOP032", text: "HI-3P" },
{ val: "MOP033", text: "철근콘크리트박스(RCB)" },
{ val: "MOP034", text: "콘크리트중선함(CD)" },
{ val: "MOP035", text: "후랙시불전선함(FP)" },
{ val: "MOP036", text: "전선함(FD)" },
{ val: "MOP038", text: "이중보온관" },
{ val: "MOP039", text: "폴리에틸렌피복강관(PLP)" },
{ val: "MOP041", text: "FRP" },
{ val: "MOP042", text: "강재" },
{ val: "MOP999", text: "기타" },
{ val: "MOP800", text: "2중벽PE관" },
{ val: "MOP801", text: "3중벽PE관" },
{ val: "MOP802", text: "가스배관용 강관" },
{ val: "MOP803", text: "강관(피복 Double, 용접접합)" },
{ val: "MOP804", text: "강관(피복 Single, 용접접합)" },
{ val: "MOP805", text: "강관(CP)" },
{ val: "MOP806", text: "강철" },
{ val: "MOP807", text: "강화플라스틱복합관" },
{ val: "MOP808", text: "경질염화비닐관(HI-VP)" },
{ val: "MOP809", text: "고강성PVC이중벽관" },
{ val: "MOP810", text: "고광도PVC이중벽관" },
{ val: "MOP811", text: "고밀도폴리에틸렌관(THP)" },
{ val: "MOP812", text: "고밀도폴리에틸렌파상형이중벽관" },
{ val: "MOP813", text: "공장제 박스관거" },
{ val: "MOP814", text: "관로없음(알케이블)" },
{ val: "MOP815", text: "나선형강관" },
{ val: "MOP816", text: "내충격수도관" },
{ val: "MOP817", text: "내충격용하수도용경질염화비닐관(HI-VG)" },
{ val: "MOP818", text: "내충격HI-VG2관" },
{ val: "MOP819", text: "내충격PVC하수관" },
{ val: "MOP820", text: "녹석(돌산호과 일종)" },
{ val: "MOP821", text: "동관(구리관, 용접)" },
{ val: "MOP822", text: "둥근잔돌" },
{ val: "MOP823", text: "무근콘크리트" },
{ val: "MOP824", text: "백관" },
{ val: "MOP825", text: "복합철판파형관" },
{ val: "MOP826", text: "분체라이닝관" },
{ val: "MOP827", text: "석축박스" },
{ val: "MOP828", text: "석축철콘" },
{ val: "MOP829", text: "수도용경질염화비관" },
{ val: "MOP830", text: "수도용경질염화비닐이음관" },
{ val: "MOP831", text: "수도용엑포시수지분체내외면코팅강관" },
{ val: "MOP832", text: "수지파형강관(PCS)" },
{ val: "MOP833", text: "수지CSP" },
{ val: "MOP834", text: "스테인레스 주름관" },
{ val: "MOP835", text: "스테인레스관(STS)" },
{ val: "MOP836", text: "스테인레스스틸파이프관" },
{ val: "MOP837", text: "아스콘" },
{ val: "MOP838", text: "아스팔트" },
{ val: "MOP839", text: "아스팔트도복장강관(ASP)" },
{ val: "MOP840", text: "아연" },
{ val: "MOP841", text: "아연도금(백관)" },
{ val: "MOP842", text: "알루미늄" },
{ val: "MOP843", text: "알루미늄합금" },
{ val: "MOP844", text: "압력배관용 탄소강관" },
{ val: "MOP845", text: "양철관" },
{ val: "MOP846", text: "양철주름관(CSP)" },
{ val: "MOP847", text: "연합인슈강판" },
{ val: "MOP848", text: "와사관(GSP)" },
{ val: "MOP849", text: "원심력철근콘크리트" },
{ val: "MOP850", text: "유리섬유관(GRP)" },
{ val: "MOP851", text: "유리섬유복합관(GRP)" },
{ val: "MOP852", text: "이음매없는동및동합금관" },
{ val: "MOP853", text: "일반배관용스테인리스강관" },
{ val: "MOP854", text: "전압 콘크리트관" },
{ val: "MOP855", text: "주름관" },
{ val: "MOP856", text: "주름주철파형강관" },
{ val: "MOP857", text: "지중 저압케이블 입상관" },
{ val: "MOP858", text: "진동및전압철근콘크리트관" },
{ val: "MOP859", text: "철근콘크리트" },
{ val: "MOP860", text: "철근콘크리트 블록" },
{ val: "MOP861", text: "철근콘크리트조립흙막이" },
{ val: "MOP862", text: "철판매입관" },
{ val: "MOP863", text: "청동" },
{ val: "MOP864", text: "코아식프리스트레스트콘크리트관" },
{ val: "MOP865", text: "콘크리트" },
{ val: "MOP866", text: "콘크리트 폴륨관" },
{ val: "MOP867", text: "콘크리트블록" },
{ val: "MOP868", text: "탄소강" },
{ val: "MOP869", text: "탄소강(카본스틸)" },
{ val: "MOP870", text: "탄소주강" },
{ val: "MOP871", text: "트러후" },
{ val: "MOP872", text: "파선형강관(RCF)" },
{ val: "MOP873", text: "파형강관(CSP)" },
{ val: "MOP874", text: "파형관" },
{ val: "MOP875", text: "파형플라스틱관" },
{ val: "MOP876", text: "폴리머관" },
{ val: "MOP877", text: "폴리에스테르수지콘크리트관" },
{ val: "MOP878", text: "폴리에틸렌 전선관" },
{ val: "MOP879", text: "폴리에틸렌 피복강관" },
{ val: "MOP880", text: "폴리에틸렌관 1호관" },
{ val: "MOP881", text: "폴리에틸렌관 2호관" },
{ val: "MOP882", text: "폴리에틸렌관(2중벽)" },
{ val: "MOP883", text: "폴리에틸렌관(3중벽)" },
{ val: "MOP884", text: "폴리에틸렌분체라이닝" },
{ val: "MOP885", text: "폴리에틸렌이중벽하수관" },
{ val: "MOP886", text: "폴리탄산에스테르" },
{ val: "MOP887", text: "프리스트레스트콘크리트실린더관" },
{ val: "MOP888", text: "플라스틱" },
{ val: "MOP889", text: "플라스틱주름관" },
{ val: "MOP890", text: "하수도용고강성경질염화비닐이중벽주름관" },
{ val: "MOP891", text: "현장타설관" },
{ val: "MOP892", text: "현장타설철근콘크리트관" },
{ val: "MOP893", text: "흑관" },
{ val: "MOP894", text: "흙" },
{ val: "MOP895", text: "AP" },
{ val: "MOP896", text: "API관" },
{ val: "MOP897", text: "ASP" },
{ val: "MOP898", text: "CMCIP" },
{ val: "MOP899", text: "CSP" },
{ val: "MOP900", text: "DRP" },
{ val: "MOP901", text: "DSP" },
{ val: "MOP902", text: "DTC관" },
{ val: "MOP903", text: "ELP" },
{ val: "MOP904", text: "EPP" },
{ val: "MOP905", text: "FC관" },
{ val: "MOP906", text: "H" },
{ val: "MOP907", text: "H6" },
{ val: "MOP908", text: "HCP" },
{ val: "MOP909", text: "HI_2P관" },
{ val: "MOP910", text: "HI-KP" },
{ val: "MOP911", text: "HL" },
{ val: "MOP912", text: "IP" },
{ val: "MOP913", text: "KCGP" },
{ val: "MOP914", text: "KP관" },
{ val: "MOP915", text: "P2S관" },
{ val: "MOP916", text: "PC박스(PCB)" },
{ val: "MOP917", text: "PCS" },
{ val: "MOP918", text: "PDP" },
{ val: "MOP919", text: "PE이중벽관" },
{ val: "MOP920", text: "PE적측벽1종관" },
{ val: "MOP921", text: "PFP관" },
{ val: "MOP922", text: "PG관" },
{ val: "MOP923", text: "PIP관(이중보온관)" },
{ val: "MOP924", text: "PLCSP" },
{ val: "MOP925", text: "PS" },
{ val: "MOP926", text: "PUB" },
{ val: "MOP927", text: "PVB" },
{ val: "MOP928", text: "PVC이중벽관(PVC2)" },
{ val: "MOP929", text: "PVC-DC" },
{ val: "MOP930", text: "PVC-DV" },
{ val: "MOP931", text: "SBK" },
{ val: "MOP932", text: "SCP" },
{ val: "MOP933", text: "SEB" },
{ val: "MOP934", text: "SIS관(이중보온관)" },
{ val: "MOP935", text: "SMC" },
{ val: "MOP936", text: "STW" },
{ val: "MOP937", text: "SUS" },
{ val: "MOP938", text: "THP나선관" },
{ val: "MOP939", text: "U관" },
{ val: "MOP940", text: "VG2" },
{ val: "MOP941", text: "WSP(와사피)" },
{ val: "MOP942", text: "Steel Grating" },
{ val: "MOP943", text: "모래" },
{ val: "MOP944", text: "인조석" },
{ val: "MOP945", text: "SPOL" },
{ val: "MOP946", text: "PCF관(강판보강 PE 나선형관)" },
{ val: "MOP947", text: "PVC3(PVC3중벽관)" },
{ val: "MOP948", text: "PE다중벽관" },
{ val: "MOP977", text: "전주용입상관" },
{ val: "MOP978", text: "MD관" },
];
// 하수관로 'fcltyShpCd' '시설물형태코드'
export const MS_SWAGE_PPL_SHAPE = [
{ val: "FOR000 ", text: "미분류" },
{ val: "FOR001 ", text: "원형" },
{ val: "FOR002 ", text: "원추형" },
{ val: "FOR003 ", text: "원통형" },
{ val: "FOR004 ", text: "구형" },
{ val: "FOR005 ", text: "타원형" },
{ val: "FOR006 ", text: "난원" },
{ val: "FOR007 ", text: "반원형" },
{ val: "FOR010 ", text: "피라미드형" },
{ val: "FOR011 ", text: "사면체형" },
{ val: "FOR012 ", text: "기둥,축" },
{ val: "FOR013 ", text: "돛대,활대처럼 둥글고 긴 원재" },
{ val: "FOR014 ", text: "아치형" },
{ val: "FOR015 ", text: "다중아치형" },
{ val: "FOR016 ", text: "돔형" },
{ val: "FOR017 ", text: "고형체형" },
{ val: "FOR020 ", text: "사각형" },
{ val: "FOR021 ", text: "평방형" },
{ val: "FOR022 ", text: "다이아몬드형" },
{ val: "FOR023 ", text: "사다리꼴" },
{ val: "FOR024 ", text: "각형" },
{ val: "FOR025 ", text: "마제형관" },
{ val: "FOR026 ", text: "태형" },
{ val: "FOR030 ", text: "A형" },
{ val: "FOR031 ", text: "H형" },
{ val: "FOR032 ", text: "I형" },
{ val: "FOR033 ", text: "Y형" },
{ val: "FOR034 ", text: "T형" },
{ val: "FOR050 ", text: "불규칙한 형태" },
{ val: "FOR900 ", text: "계란형" },
{ val: "FOR901 ", text: "구(사각)형" },
{ val: "FOR902 ", text: "높이조절철개형" },
{ val: "FOR903 ", text: "박스형(RCB)" },
{ val: "FOR904 ", text: "반월개거" },
{ val: "FOR905 ", text: "반월개거(측구)" },
{ val: "FOR906 ", text: "벽돌조" },
{ val: "FOR907 ", text: "사각철판" },
{ val: "FOR908 ", text: "석축개거" },
{ val: "FOR909 ", text: "석축개거(측구)" },
{ val: "FOR910 ", text: "승수로" },
{ val: "FOR911 ", text: "옹벽개거" },
{ val: "FOR912 ", text: "옹벽개거(측구)" },
{ val: "FOR913 ", text: "이단" },
{ val: "FOR914 ", text: "이중침전형" },
{ val: "FOR915 ", text: "자연개거" },
{ val: "FOR916 ", text: "자연개거(측구)" },
{ val: "FOR917 ", text: "조립식" },
{ val: "FOR918 ", text: "지상관" },
{ val: "FOR919 ", text: "지상단구" },
{ val: "FOR920 ", text: "지상쌍구" },
{ val: "FOR921 ", text: "지하단구" },
{ val: "FOR922 ", text: "지하쌍구" },
{ val: "FOR923 ", text: "직사각형" },
{ val: "FOR924 ", text: "타설식" },
{ val: "FOR925 ", text: "토류형" },
{ val: "FOR926 ", text: "흄관원형" },
{ val: "FOR927 ", text: "L형측구" },
{ val: "FOR928 ", text: "LO형측구" },
{ val: "FOR929 ", text: "LU형측구" },
{ val: "FOR930 ", text: "PVC이중벽관" },
{ val: "FOR931 ", text: "RCB 맨홀" },
{ val: "FOR932 ", text: "U형" },
{ val: "FOR933 ", text: "U형측구(미복개)" },
{ val: "FOR934 ", text: "U형측구(복개)" },
{ val: "FOR999 ", text: "기타" }
];
// 하수처리시설
export const SwtrmtFcltCol = [
{ id: 'swtrmt_fclt_nm', title: '하수처리시설명' },
{ id: 'fclt_cpct', title: '시설용량(㎥/d)' },
{ id: 'rgn_expln', title: '지역설명' },
{ id: 'cmcn_ymd', title: '준공일자' },
{ id: 'oprtng_strt_ymd', title: '가동개시일자' },
{ id: 'clsg_ymd', title: '폐쇄일자' },
// { id: 'prcs_msrc_cd', title: '처리공법코드' },
// { id: 'lae_cd', title: '지자체코드' },
{ id: 'lae_nm', title: '지자체명' },
{ id: 'ctpv_nm', title: '시도명' },
{ id: 'sgg_nm', title: '시군구명' },
// { id: 'sarea_cd', title: '권역코드' },
{ id: 'sarea_nm', title: '권역명' },
{ id: 'lrge_sarea_nm', title: '대권역명' },
{ id: 'rvr_nm', title: '하천명' },
{ id: 'addr', title: '주소' },
{ id: 'dtl_addr', title: '상세주소' },
{ id: 'rdnm_addr', title: '도로명주소' },
{ id: 'dtl_rdnm_addr', title: '상세도로명주소' },
{ id: 'dschg_wtst_nm', title: '방류수계명' },
{ id: 'plln_area', title: '부지면적(m²)' },
{ id: 'mng_rprtv_bzenty_nm', title: '관리대행업제명' },
{ id: 'swtrmt_ppltn_cnt', title: '하수처리인구수(인)' },
{ id: 'prcs_zone_area_sum', title: '처리구역면적합계(m²)' },
{ id: 'xcrd', title: 'X좌표' },
{ id: 'ycrd', title: 'Y좌표' },
// { id: 'oprtngSttsCd', title: '가동상태코드' },
{ id: 'rprs_msrc_cn', title: '대표공법내용' },
// { id: 'stty_crtr_bod', title: '법정기준생물화학적산소요구량(mg/L)' },
// { id: 'stty_crtr_toc', title: '법정기준TOC((mg/L))' },
// { id: 'stty_crtr_ss', title: '법정기준SS(mg/L)' },
// { id: 'stty_crtr_tn', title: '법정기준총질소(mg/L)' },
// { id: 'stty_crtr_tph', title: '법정기준총인(mg/L)' },
// { id: 'stty_crtr_clbcl_cnt', title: '법정기준대장균수(군수/100mL)' },
{ id: 'bsn_nm', title: '유역명' },
// { id: 'obsrvnSptCd', title: '관측지점코드' },
];
export const SwtrmtFcltCol2 = [
{ id: 'rowNumber', title: '일련\n번호' },
{ id: 'swtrmtFcltNm', title: '하수처리\n시설명' },
{ id: 'fcltCpct', title: '시설\n용량\n(㎥/d)' },
{ id: 'rgnExpln', title: '지역설명' },
// { id: 'cmcnYmd', title: '준공일자' },
{ id: 'oprtngStrtYmd', title: '가동개시일자' },
// { id: 'clsgYmd', title: '폐쇄일자' },
// { id: 'prcsMsrcCd', title: '처리공법코드' },
// { id: 'laeCd', title: '지자체코드' },
{ id: 'laeNm', title: '지자체명' },
// { id: 'ctpvNm', title: '시도명' },
// { id: 'sggNm', title: '시군구명' },
// { id: 'sareaCd', title: '권역코드' },
{ id: 'sareaNm', title: '권역명' },
// { id: 'lrgeSareaNm', title: '대권역명' },
{ id: 'rvrNm', title: '하천명' },
// { id: 'addr', title: '주소' },
// { id: 'dtlAddr', title: '상세주소' },
// { id: 'rdnmAddr', title: '도로명주소' },
// { id: 'dtlRdnmAddr', title: '상세도로명주소' },
{ id: 'dschgWtstNm', title: '방류수계명' },
// { id: 'pllnArea', title: '부지면적(m²)' },
// { id: 'mngRprtvBzentyNm', title: '관리대행업체명' },
{ id: 'swtrmtPpltnCnt', title: '하수\n처리\n인구수\n(인)' },
// { id: 'prcsZoneAreaSum', title: '처리구역면적합계(m²)' },
// { id: 'xcrd', title: 'X좌표' },
// { id: 'ycrd', title: 'Y좌표' },
// { id: 'oprtngSttsCd', title: '가동상태코드' },
{ id: 'rprsMsrcCn', title: '대표\n공법\n내용' },
// { id: 'sttyCrtrBod', title: '법정기준생물화학적산소요구량(mg/L)' },
// { id: 'sttyCrtrToc', title: '법정기준TOC(mg/L)' },
// { id: 'sttyCrtrSs', title: '법정기준SS(mg/L)' },
// { id: 'sttyCrtrTn', title: '법정기준총질소(mg/L)' },
// { id: 'sttyCrtrTph', title: '법정기준총인(mg/L)' },
// { id: 'sttyCrtrClbclCnt', title: '법정기준대장균수(군수/100mL)' },
// { id: 'bsnNm', title: '유역명' },
// { id: 'obsrvnSptCd', title: '관측지점코드' },
];
export const SwtrmtFcltExcelCol = {
'swtrmtFcltNm': '하수처리시설명',
'fcltCpct': '시설 용량(㎥/d)',
'rgnExpln': '지역설명',
'oprtngStrtYmd': '가동개시 일자',
'laeNm': '지자체명',
'sareaNm': '권역명',
'rvrNm': '하천명',
'dschgWtstNm': '방류수계명',
'swtrmtPpltnCnt': '하수 처리 인구수(인)',
'rprsMsrcCn': '대표 공법 내용'
};
// 하수처리구역
export const SwtrmtPrcsZoneCol = [
// { id: 'swtrmtZoneSn', title: '하수처리구역 일련번호' },
{ id: 'swtrmt_zone_nm', title: '하수처리구역명' },
// { id: 'swtrmt_fclt_cd', title: '하수처리시설코드' },
// { id: 'ctpv_cd', title: '시도코드' },
{ id: 'ctpv_nm', title: '시도명' },
// { id: 'sgg_cd', title: '시군구코드' },
{ id: 'sgg_nm', title: '시군구명' },
{ id: 'swtrmt_fclt_nm', title: '하수처리시설명' },
];
export const SwtrmtPrcsZoneCol2 = [
{ id: 'rowNumber', title: '일련번호' },
// { id: 'swtrmtZoneSn', title: '하수처리구역 일련번호' },
{ id: 'swtrmtZoneNm', title: '하수처리구역명' },
// { id: 'swtrmt_fclt_cd', title: '하수처리시설코드' },
// { id: 'ctpv_cd', title: '시도코드' },
{ id: 'ctpvNm', title: '시도' },
// { id: 'sgg_cd', title: '시군구코드' },
{ id: 'sggNm', title: '시군구' },
{ id: 'swtrmtFcltNm', title: '하수처리시설명' },
];
export const SwtrmtPrcsZoneExcelCol = {
'swtrmtZoneNm': '하수처리구역명',
'ctpvNm': '시도',
'sggNm': '시군구',
'swtrmtFcltNm': '하수처리시설명'
};
// 하수맨홀
export const MsSwageMhlCol = [
// { id: 'fid', title: 'FID' },
// { id: 'geom', title: 'geometry' },
// { id: 'ftre_smbol_cd', title: '지형지물부호코드' },
{ id: 'fclt_mng_no', title: '시설관리번호' },
// { id: 'pbadms_emd_cd', title: '행정읍면동코드' },
{ id: 'mapsht_no', title: '도엽번호' },
// { id: 'mng_inst_cd', title: '관리기관코드' },
{ id: 'instl_ymd', title: '설치일자' },
{ id: 'last_drdgn_ymd', title: '최종준설일자' },
// { id: 'usg_cd', title: '용도코드' },
// { id: 'fclty_shp_cd', title: '시설물형태코드' },
// { id: 'mhl_knd_cd', title: '맨홀종류코드' },
{ id: 'swage_mhL_diam_vl', title: '하수맨홀구경(mm)' },
{ id: 'mqlt_cd', title: '재질' },
{ id: 'wdth_len', title: '가로길이(m)' },
{ id: 'vrtc_len', title: '세로길이(m)' },
// { id: 'ang_dir', title: '표고' },
{ id: 'altd_vl', title: '지표고(EL.m)' },
{ id: 'dpthl_vl', title: '맨홀저고(EL.m)' },
// { id: 'inst_mng_no', title: '기관관리번호' }
];
export const MsSwageMhlCol2 = [
{ id: 'rowNumber', title: '일련번호' },
// { id: 'fid', title: 'FID' },
// { id: 'geom', title: 'geometry' },
// { id: 'ftreSmbolCd', title: '지형지물부호코드' },
{ id: 'fcltMngNo', title: '시설관리번호' },
{ id: 'ctpvNm', title: '시도' },
{ id: 'sggNm', title: '시군구' },
// { id: 'pbadmsEmdCd', title: '행정읍면동코드' },
// { id: 'mapshtNo', title: '도엽번호' },
// { id: 'mngInstCd', title: '관리기관코드' },
{ id: 'instlYmd', title: '설치일자' },
// { id: 'lastDrdgnYmd', title: '최종준설일자' },
{ id: 'usgCd', title: '용도' },
// { id: 'fcltyShpCd', title: '시설물형태코드' },
// { id: 'mhlKndCd', title: '맨홀종류코드' },
{ id: 'swageMhlDiamVl', title: '하수맨홀구경(mm)' },
// { id: 'mqltCd', title: '재질코드' },
{ id: 'wdthLen', title: '가로길이(m)' },
{ id: 'vrtcLen', title: '세로길이(m)' },
// { id: 'angDir', title: '표고' },
{ id: 'altdVl', title: '지표고(EL.m)' },
{ id: 'dpthlVl', title: '맨홀저고(EL.m)' },
// { id: 'instMngNo', title: '기관관리번호' }
];
export const MsSwageMhlExcelCol = {
'fcltMngNo': '시설관리번호',
'ctpvNm': '시도',
'sggNm': '시군구',
'instlYmd': '설치일자',
'usgCd': '용도',
'swageMhlDiamVl': '하수맨홀구경(mm)',
'wdthLen': '가로길이(m)',
'vrtcLen': '세로길이(m)',
'altdVl': '지표고(EL.m)',
'dpthlVl': '맨홀저고(EL.m)'
};
// 하수맨홀 뚜껑재질 코드표
export const MS_SWAGE_MHL_CODES = [
{ val: "SBC000", text: "미분류" },
{ val: "SBC001", text: "주철" },
{ val: "SBC002", text: "철판" },
{ val: "SBC003", text: "콘크리트" },
{ val: "SBC004", text: "그레이팅" },
{ val: "SBC091", text: "칼라" },
{ val: "SBC092", text: "S.G" },
{ val: "SBC999", text: "기타" },
{ val: "SBC900", text: "각주철" },
{ val: "SBC901", text: "강재" },
{ val: "SBC902", text: "나무" },
{ val: "SBC903", text: "나무,널판지,합판" },
{ val: "SBC904", text: "대리석" },
{ val: "SBC905", text: "뚜껑없음" },
{ val: "SBC906", text: "보도용콘크리트" },
{ val: "SBC907", text: "스틸(ST)" },
{ val: "SBC908", text: "신형 CON" },
{ val: "SBC909", text: "알루미늄" },
{ val: "SBC910", text: "염화비닐(PE)" },
{ val: "SBC911", text: "인조석" },
{ val: "SBC912", text: "인조석(주철)" },
{ val: "SBC913", text: "인조CONC" },
{ val: "SBC914", text: "철, 철근" },
{ val: "SBC915", text: "칼라콘크리트" },
{ val: "SBC916", text: "플라스틱" },
{ val: "SBC917", text: "PVC" },
{ val: "SBC918", text: "SP" },
{ val: "SBC919", text: "SS" },
{ val: "SBC920", text: "닥타일주철" },
{ val: "SBC921", text: "철근G" },
{ val: "SBC922", text: "폴리에틸렌(PE)" },
{ val: "SBC923", text: "회주철" }
];
// 하수맨홀 용도 코드표
export const MS_SWAGE_MHL_USE_CODES = [
{ val: "SMU000 ", text: "미분류" },
{ val: "SMU001 ", text: "우수" },
{ val: "SMU002 ", text: "오수" },
{ val: "SMU003 ", text: "집수" },
{ val: "SMU004 ", text: "합류" },
{ val: "SMU005 ", text: "차집" },
{ val: "SMU999 ", text: "기타" },
{ val: "SMU900 ", text: "1호" },
{ val: "SMU901 ", text: "2호" },
{ val: "SMU902 ", text: "3호" },
{ val: "SMU903 ", text: "4호" },
{ val: "SMU904 ", text: "5호" },
{ val: "SMU905 ", text: "6호" },
{ val: "SMU906 ", text: "7호" },
{ val: "SMU907 ", text: "8호" },
{ val: "SMU908 ", text: "구거용맨홀" },
{ val: "SMU909 ", text: "부관부착맨홀" },
{ val: "SMU910 ", text: "조립식맨홀" },
{ val: "SMU911 ", text: "특1호" },
{ val: "SMU912 ", text: "특2호" },
{ val: "SMU913 ", text: "특3호" },
{ val: "SMU914 ", text: "특4호" },
{ val: "SMU915 ", text: "특5호" },
{ val: "SMU916 ", text: "하수맨홀용도" },
{ val: "SMU917 ", text: "현장타설관거용" }
];
// 우수펌프장
export const ExclnSewagePumpCol = [
// { id: 'fid', title: 'FID' },
// { id: 'geom', title: 'geometry' },
// { id: 'ftreSmbolCd', title: '지형지물부호코드' },
{ id: 'fclt_mng_no', title: '시설관리번호' },
// { id: 'pbadmsEmdCd', title: '행정읍면동코드' },
{ id: 'mapsht_no', title: '도엽번호' },
// { id: 'mngInstCd', title: '관리기관코드' },
{ id: 'instl_ymd', title: '설치일자' },
{ id: 'swage_pmstn_nm', title: '하수펌프장명' },
{ id: 'plln_area', title: '부지면적(m²)' },
// { id: 'opngSttsCd', title: '개통상태코드' },
// { id: 'usgCd', title: '용도코드' },
{ id: 'daily_prcs_cpct', title: '일일처리용량(㎥)' },
{ id: 'max_impnd_cpct', title: '최대저수용량(㎥)' },
{ id: 'altd_vl', title: '표고(EL.m)' },
{ id: 'wtlv_vl', title: '수위(m)' },
{ id: 'excln_wpmp_ablt_vl', title: '우수양수능력(m²/d)' },
// { id: 'instMngNo', title: '기관관리번호' },
]
export const ExclnSewagePumpCol2 = [
{ id: 'rowNumber', title: '일련번호' },
// { id: 'fid', title: 'FID' },
// { id: 'geom', title: 'geometry' },
// { id: 'ftreSmbolCd', title: '지형지물부호코드' },
{ id: 'fcltMngNo', title: '시설관리번호' },
{ id: 'ctpvNm', title: '시도' },
{ id: 'sggNm', title: '시군구' },
// { id: 'pbadmsEmdCd', title: '행정읍면동코드' },
// { id: 'mapshtNo', title: '도엽번호' },
// { id: 'mngInstCd', title: '관리기관코드' },
{ id: 'swagePmstnNm', title: '하수펌프장명' },
{ id: 'instlYmd', title: '설치일자' },
// { id: 'pllnArea', title: '부지면적(m²)' },
// { id: 'opngSttsCd', title: '개통상태코드' },
// { id: 'usgCd', title: '용도코드' },
{ id: 'dailyPrcsCpct', title: '일일처리용량(㎥)' },
{ id: 'maxImpndCpct', title: '최대저수용량(㎥)' },
{ id: 'altdVl', title: '표고(EL.m)' },
{ id: 'wtlvVl', title: '수위(m)' },
{ id: 'exclnWpmpAbltVl', title: '우수양수능력(m²/d)' },
// { id: 'instMngNo', title: '기관관리번호' },
]
export const ExclnSewagePumpExcelCol = {
'fcltMngNo': '시설관리번호',
'ctpvNm': '시도',
'sggNm': '시군구',
'swagePmstnNm': '하수펌프장명',
'instlYmd': '설치일자',
'dailyPrcsCpct': '일일처리용량(㎥)',
'maxImpndCpct': '최대저수용량(㎥)',
'altdVl': '표고(EL.m)',
'wtlvVl': '수위(m)',
'exclnWpmpAbltVl': '우수양수능력(m²/d)'
};
// 오수펌프장
export const DrtwSewagePumpCol = [
// { id: 'fid', title: 'FID' },
// { id: 'geom', title: 'geometry' },
// { id: 'ftreSmbolCd', title: '지형지물부호코드' },
{ id: 'fclt_mng_no', title: '시설관리번호' },
// { id: 'pbadmsEmdCd', title: '행정읍면동코드' },
{ id: 'mapsht_no', title: '도엽번호' },
// { id: 'mngInstCd', title: '관리기관코드' },
{ id: 'instl_ymd', title: '설치일자' },
{ id: 'swage_pmstn_nm', title: '하수펌프장명' },
{ id: 'plln_area', title: '부지면적(m²)' },
// { id: 'opngSttsCd', title: '개통상태코드' },
// { id: 'usgCd', title: '용도코드' },
{ id: 'daily_prcs_cpct', title: '일일처리용량(㎥)' },
{ id: 'max_impnd_cpct', title: '최대저수용량(㎥)' },
{ id: 'altd_vl', title: '표고(EL.m)' },
{ id: 'wtlv_vl', title: '수위(m)' },
{ id: 'excln_wpmp_ablt_vl', title: '오수양수능력(m²/d)' },
// { id: 'instMngNo', title: '기관관리번호' },
]
export const DrtwSewagePumpCol2 = [
{ id: 'rowNumber', title: '일련번호' },
// { id: 'fid', title: 'FID' },
// { id: 'geom', title: 'geometry' },
// { id: 'ftreSmbolCd', title: '지형지물부호코드' },
{ id: 'fcltMngNo', title: '시설관리번호' },
// { id: 'pbadmsEmdCd', title: '행정읍면동코드' },
// { id: 'mapshtNo', title: '도엽번호' },
{ id: 'ctpvNm', title: '시도' },
{ id: 'sggNm', title: '시군구' },
// { id: 'mngInstCd', title: '관리기관코드' },
{ id: 'swagePmstnNm', title: '하수펌프장명' },
{ id: 'instlYmd', title: '설치일자' },
// { id: 'pllnArea', title: '부지면적(m²)' },
// { id: 'opngSttsCd', title: '개통상태코드' },
// { id: 'usgCd', title: '용도코드' },
{ id: 'dailyPrcsCpct', title: '일일처리용량(㎥)' },
{ id: 'maxImpndCpct', title: '최대저수용량(㎥)' },
{ id: 'altdVl', title: '표고(EL.m)' },
{ id: 'wtlvVl', title: '수위(m)' },
{ id: 'exclnWpmpAbltVl', title: '오수양수능력(m²/d)' },
// { id: 'instMngNo', title: '기관관리번호' },
]
// 펌프장 용도 코드표
export const DRTW_SEWAGE_PUMP_USE_CODES = [
{ val: "SBE000", text: "미분류" },
{ val: "SBE001", text: "빗물펌프장" },
{ val: "SBE002", text: "오수펌프장" },
{ val: "SBE003", text: "처리장내펌프장" },
{ val: "SBE900", text: "오수배제" },
{ val: "SBE901", text: "우수배제" },
{ val: "SBE999", text: "기타" },
];
// 우수토실
export const stofcbrCol = [
// { id: 'ftre_smbol_cd', title: '지형지물부호코드' },
{ id: 'fclt_mng_no', title: '시설관리번호' },
// { id: 'pbadms_emd_cd', title: '행정읍면동코드' },
{ id: 'mapsht_no', title: '도엽번호' },
// { id: 'mng_inst_cd', title: '관리기관코드' },
{ id: 'instl_ymd', title: '설치일자' },
// { id: 'fclty_shp_cd', title: '시설물형태코드' },
{ id: 'worflw_wall_hgt', title: '월류벽높이(m)' },
// { id: 'mhl_exst_yn_cd', title: '맨홀존재여부코드' },
// { id: 'swage_mhl_ftre_smbol_cd', title: '하수맨홀지형지물부호코드' },
{ id: 'swage_mhl_mng_no', title: '하수맨홀관리번호' },
{ id: 'drtn_angl', title: '방향각도(°)' },
]
export const stofcbrCol2 = [
{ id: 'rowNumber', title: '일련번호' },
// { id: 'ftreSmbolCd', title: '지형지물부호코드' },
{ id: 'fcltMngNo', title: '시설관리번호' },
// { id: 'pbadmsEmdCd', title: '행정읍면동코드' },
{ id: 'mapshtNo', title: '도엽번호' },
{ id: 'ctpvNm', title: '시도' },
{ id: 'sggNm', title: '시군구' },
// { id: 'mngInstCd', title: '관리기관코드' },
{ id: 'instlYmd', title: '설치일자' },
// { id: 'fcltyShpCd', title: '시설물형태코드' },
{ id: 'worflwWallHgt', title: '월류벽높이(m)' },
// { id: 'mhlExstYnCd', title: '맨홀존재여부코드' },
// { id: 'swageMhlFtreSmbolCd', title: '하수맨홀지형지물부호코드' },
{ id: 'swageMhlMngNo', title: '하수맨홀관리번호' },
{ id: 'drtnAngl', title: '방향각도(°)' },
]
export const stofcbrExcelCol = {
'fcltMngNo': '시설관리번호',
'mapshtNo': '도엽번호',
'ctpvNm': '시도',
'sggNm': '시군구',
'instlYmd': '설치일자',
'worflwWallHgt': '월류벽높이(m)',
'swageMhlMngNo': '하수맨홀관리번호',
'drtnAngl': '방향각도(°)'
};
// 중점우수토실 측정지점
export const untrtSwgRainCol1 = [
{ id: 'manage_no', title: '관리번호' },
{ id: 'swr_ppl_eqp_nm', title: '처리구역' },
{ id: 'swr_ppl_trt_nm', title: '처리분구' },
{ id: 'addr', title: '인근주소' },
{ id: 'chmbr_xcnts', title: '토실 위도' },
{ id: 'chmbr_ydnts', title: '토실 경도' }
];
// 중점우수토실 측정지점 시설현황
export const untrtSwgRainCol2 = [
{ id: 'mesure_spot_se', title: '구분' }, // 유량계측정방식 기타
{ id: 'discharge_pipe_spec', title: '방류관로 규격(m)' },
{ id: 'swr_ppl_eqp_ar', title: '배수구역 면적(m²)' },
{ id: 'swr_trt_popl', title: '하수처리 인구(인)' },
{ id: 'screen_instl_at', title: '스크린설치 여부' },
{ id: 'weir_hg', title: '위어 높이(m)' },
{ id: 'weir_lt', title: '위어 길이(m)' }
];
// 중점우수토실 강우정보 (id값 틀릴 수 있음)
export const untrtSwgRainCol3 = [
{ id: 'rainBeginDt', title: '강우시작일시' },
{ id: 'rainEndDt', title: '강우종료일시' },
{ id: 'precdngDry', title: '선행건기(h)' },
{ id: 'rainInfo', title: '강우정보' },
{ id: 'rainFall', title: '누적강우량(mm)' },
{ id: 'cntncTime', title: '지속시간(h)' },
{ id: 'rainIn', title: '강우강도(mm/h)' }
];
// 중점우수토실 측정정보 (id값 틀릴 수 있음)
export const untrtSwgRainCol4 = [
{ id: 'mesureBeginDt', title: '측정시작일시' },
{ id: 'mesureEndDt', title: '측정종료일시' },
{ id: 'mesureIntrvl', title: '측정간격(분)' },
{ id: 'mesureMhrlsInstlLc', title: '측정기기 설치위치' },
{ id: 'flwmtrCombined', title: '유량계 종류 및 모델명' }, // 합쳐진 컬럼
{ id: 'qltwtrBeginDt', title: '채수시작일시' },
{ id: 'qltwtrEndDt', title: '채수종료일시' },
{ id: 'qltwtrCo', title: '채수횟수' }
];
// 중점우수토실 수량/수질측정 정보
export const untrtSwgRainCol5 = [
{ id: 'odr', title: '회차' },
{ id: 'mesureDt', title: '측정일자' },
{ id: 'mesureQy', title: '수량(m³)' },
{ id: 'mesureBod', title: 'BOD(mg/L)' },
{ id: 'mesureToc', title: 'TOC(mg/L)' },
{ id: 'mesureLoad', title: '오염부하량(kg)' }
];
// 🎯 날짜 필드들 (yyyymmdd -> yyyy년 mm월 dd일)
export const dateFields = [
'instl_ymd', 'last_drdgn_ymd', 'cmcn_ymd', 'oprtng_strt_ymd', 'clsg_ymd',
'mesure_begin_dt', 'mesure_end_dt', 'qltwtr_begin_dt', 'qltwtr_end_dt',
'rain_begin_dt', 'rain_end_dt', 'mesureDt',
// 🎯 camelCase 버전 추가
'instlYmd', 'lastDrdgnYmd', 'cmcnYmd', 'oprtngStrtYmd', 'clsgYmd',
'mesureBeginDt', 'mesureEndDt', 'qltwtrBeginDt', 'qltwtrEndDt',
'rainBeginDt', 'rainEndDt',
'flfmtBgngYmd', 'flfmtEndYmd', 'flfmtYr'
];
// 🎯 연월 필드들 (yyyymm -> yyyy년 m월)
export const yearMonthFields = ['aplcnYm',];
// 🎯 날짜시간 필드들 (yyyy-mm-dd hh:mm:ss -> yyyy년 m월 d일 hh:mm)
export const dateTimeFields = ['regDt',];
// 🎯 숫자 필드들 (천 단위 구분자)
export const numberFields = [
'pdmt', 'prlg', 'avg_grdn', 'drtw_mltv_area', 'excln_mltv_area', 'tmpt_dpth', 'scend_dpth', 'trfln_cnt', 'vrtc_len', 'wdth_len',
'fclt_cpct', 'plln_area', 'prcs_zone_area_sum', 'swtrmt_ppltn_cnt', 'stty_crtr_bod', 'stty_crtr_toc', 'stty_crtr_ss', 'stty_crtr_tn', 'stty_crtr_tph', 'stty_crtr_clbcl_cnt',
'swage_mh_diam', 'wdth_len', 'vrtc_len', 'altd_vl', 'dpthlVl',
'plln_area', 'daily_prcs_cpct', 'max_impnd_cpct', 'altd_vl', 'wtlv_vl', 'excln_wpmp_ablt',
'worflw_wall_hgt',
'swr_ppl_eqp_ar', 'swr_trt_popl', 'weir_hg', 'weir_lt',
'precdng_dry', 'rain_fall', 'cntnc_time', 'rain_in',
'mesure_intrvl', 'qltwtr_co', 'mesureQy', 'mesureBod', 'mesureToc', 'mesureLoad',
// 🎯 camelCase 버전 추가
'avgGrdn', 'drtwMltvArea', 'exclnMltvArea', 'tmptDpth', 'scendDpth', 'trflnCnt', 'vrtcLen', 'wdthLen',
'fcltCpct', 'pllnArea', 'prcsZoneAreaSum', 'swtrmtPpltnCnt', 'sttyCltrBod', 'sttyCltrToc', 'sttyCltrSs', 'sttyCltrTn', 'sttyCltrTph', 'sttyCltrClbclCnt',
'swageMhDiam', 'altdVl', 'dpthlVl',
'dailyPrcsCpct', 'maxImpndCpct', 'wtlvVl', 'exclnWpmpAblt',
'worflwWallHgt',
'swrPplEqpAr', 'swrTrtPopl', 'weirHg', 'weirLt',
'precdngDry', 'rainFall', 'cntncTime', 'rainIn',
'mesureIntrvl', 'qltwtrCo', 'rnysnFvlc', 'clarFvlc',
'bizAmt', 'count',
'chmbr_xcnts', 'chmbr_ydnts'
];
@@ -1,163 +0,0 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수관로)
*
* 📄 MsSwagePpl
* 하수관로 정보 컴포넌트
*
* - 하수관로의 자체입력정보 표시 및 편집 기능
* - 보수 및 갱신, 청소 및 준설, 점검 및 진단 데이터 조회 및 표시
* - VerticalTable을 통한 입력 폼 렌더링
* - InputSection을 통한 운영 데이터 관리
* - 저장 버튼을 통한 데이터 저장 기능
*
* 🔗 사용 API
* - 시설별 운영 정보 조회: GET /api/v1/bizInfo/bizByFclt (sewerage-service)
*/
'use client'
import { VerticalTable } from '@/components/ui/verticalTable';
import React, { useState, useEffect } from 'react'
import InputSection from '../InputSection';
import { handleInputChange } from '../common';
import { getData } from "@/components/lib/api";
import useStore from '@/store/useStore';
// 하수관로
const inputColumn = [
{ id: "instlMbdyCd", title: "설치주제", type: "select", options: ["BTL", "재정사업"] },
{ id: "swtrmtZoneNm", title: "처리구역", type: "input" },
{ id: "mltvZoneNm", title: "배수구역", type: "input" },
{ id: "mltvDtdvNm", title: "배수분구", type: "input" },
{ id: "lineCd", title: "지선/간선", type: "select", options: ["지선", "간선"] },
{ id: "floCd", title: "흐름방식", type: "select", options: ["자연유하", "압송"] },
{ id: "layCd", title: "매설위치", type: "select", options: ["도로", "하상"] },
];
type operationDataType = { year: string; type: string, bizInfoNm: string, bizType: string };
const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: () => void }) => {
const [formData, setFormData] = useState<any>({});
const [repairData, setRepairData] = useState<operationDataType[]>([]);
const [cleaningData, setCleaningData] = useState<operationDataType[]>([]);
const [inspectionData, setInspectionData] = useState<operationDataType[]>([]);
const dataMap = {
repair: { data: repairData, setData: setRepairData },
cleaning: { data: cleaningData, setData: setCleaningData },
inspection: { data: inspectionData, setData: setInspectionData },
};
const { BIZ_SE_CD, BIZ_TY_CD } = useStore();
// 마운트 및 업데이트 시 API 호출
useEffect(() => {
if (featureData) {
setFormData(featureData);
}
const fetchAll = async () => {
const operationData = await getData(`v1/bizInfo/bizByFclt?mngInstCd=${featureData?.mng_inst_cd}&fcltMngNo=${featureData?.fclt_mng_no}&page=0&size=1`);
let inspectionArr: operationDataType[] = [];
let cleaningArr: operationDataType[] = [];
let repairArr: operationDataType[] = [];
if (Array.isArray(operationData)) {
operationData.forEach(item => {
if (!item.flfmt_yr) return;
// biz_se_cd에 해당하는 type명 찾기
const seCodeObj = BIZ_SE_CD.find(code => code.lwpoCd === item.biz_se_cd);
const tyCodeObj = BIZ_TY_CD.find(code => code.lwpoCd === item.biz_ty_cd);
const seTypeName = seCodeObj ? seCodeObj.lwpoCdNm : "";
const tyTypeName = tyCodeObj ? tyCodeObj.lwpoCdNm : "";
switch (item.biz_se_cd) {
case "100":
inspectionArr.push({
year: String(item.flfmt_yr),
type: seTypeName,
bizInfoNm: String(item.biz_info_nm),
bizType: tyTypeName,
});
break;
case "203":
cleaningArr.push({
year: String(item.flfmt_yr),
type: seTypeName,
bizInfoNm: String(item.biz_info_nm),
bizType: tyTypeName,
});
break;
case "200":
case "201":
case "204":
repairArr.push({
year: String(item.flfmt_yr),
type: seTypeName,
bizInfoNm: String(item.biz_info_nm),
bizType: tyTypeName,
});
break;
}
});
}
setInspectionData(inspectionArr);
setCleaningData(cleaningArr);
setRepairData(repairArr);
};
fetchAll();
}, [featureData]);
// 폼 데이터가 변경될 때 호출될 핸들러
const handleFormDataChange = (newFormData: any) => {
setFormData(newFormData);
};
// 저장 버튼 클릭 시 최신 formData를 부모에게 전달
const handleSaveClick = () => {
if (onSaveClick) {
// 저장 전에 formData를 부모 컴포넌트의 handleSaveClick에 전달
onSaveClick();
}
};
return (
<>
<VerticalTable
headName='입력정보'
tableData={featureData ? [formData] : []}
headlist={inputColumn}
pageSize={20}
total={0}
cellClick={undefined}
isEditable={true}
onPageChange={function (page: number): void {
throw new Error("Function not implemented.");
}}
showSaveButton={true}
onSaveClick={handleSaveClick}
onFormDataChange={handleFormDataChange} // 폼 데이터 변경 콜백 추가
/>
<InputSection
title="보수 및 갱신"
data={repairData}
sectionKey="repair"
onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)}
/>
<InputSection
title="청소 및 준설"
data={cleaningData}
sectionKey="cleaning"
onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)}
/>
<InputSection
title="점검 및 진단"
data={inspectionData}
sectionKey="inspection"
onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)}
/>
</>
)
}
export default MsSwagePpl
@@ -1,328 +0,0 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (우수토실)
*
* 📄 Ssc
* 우수토실 정보 컴포넌트
*
* - 우수토실의 자체입력정보 표시 및 편집 기능
* - 중점우수토실 운영정보 모달 표시 (강우/측정 정보, 수량/수질측정 정보)
* - VerticalTable을 통한 입력 폼 렌더링
* - 저장 버튼을 통한 데이터 저장 기능
*
* 🔗 사용 API
* - 위경도로 강우시 미처리 하수 목록 조회: GET /api/v1/onsemiro/getSwgRainsByGeomety (sewerage-service)
* - 강우시 월류 하수 관리 모니터링 조회: GET /api/v1/onsemiro/swgRain/{adstDstrCd}/{manageNo}/mntrngs (sewerage-service)
*/
import { UiModal, UiTable } from '@common/business_components/ui';
import { Button, TableHead, TableRow } from '@common/components/ui';
import React, { useState, useEffect } from 'react';
import { untrtSwgRainCol3, untrtSwgRainCol4, untrtSwgRainCol5 } from '../common/table';
import { VerticalTable } from '@/components/ui/verticalTable';
import { getRawJson } from "@/components/lib/api";
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
import { Label } from '@radix-ui/react-label';
const inputColumn = [
{ id: "prrtymngYn", title: "중점여부", type: "select", options: ["우수토실", "중점우수토실"] },
{ id: "swtrmtZoneNm", title: "처리구역", type: "input" },
];
const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: {
featureData: any,
isMainSsc?: boolean,
chmbrXcnts?: string,
chmbrYdnts?: string,
onSaveClick?: () => void
}) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDataLoaded, setIsDataLoaded] = useState(false);
const [formData, setFormData] = useState<any>({});
const [rainMeasureData, setRainMeasureData] = useState<any>({}); // 강우, 측정 정보
const [mntrngsData, setMntrngsData] = useState<any[]>([]); // 수량/수질측정 정보
const [totalCount, setTotalCount] = useState<number>(0);
const [totalDetailCount, setTotalDetailCount] = useState<number>(0);
const [selectedRowIndex, setSelectedRowIndex] = useState<number>(-1);
const [curPage, setCurPage] = useState<number>(1);
const [curDetailPage, setCurDetailPage] = useState<number>(1);
const [isResetPage, setIsResetPage] = useState<boolean>(true);
const [isResetDetailPage, setIsResetDetailPage] = useState<boolean>(true);
const [isResetSelection, setIsResetSelection] = useState<boolean>(false);
// 강우, 측정 정보 API 호출 함수
const fetchRainMeasureData = async (chmbrXcnts: string, chmbrYdnts: string) => {
setIsResetDetailPage(true);
setIsResetSelection(true);
if (!chmbrXcnts || !chmbrYdnts) {
console.warn('추가 데이터 호출 실패: 필수 매개변수 누락', { chmbrXcnts, chmbrYdnts });
return [];
}
try {
const queryParams = new URLSearchParams({
chmbrXcnts: chmbrXcnts,
chmbrYdnts: chmbrYdnts,
page: `${curPage - 1}`,
size: '3',
});
const result = await getRawJson(`/sewerage-service/api/v1/onsemiro/getSwgRainsByGeomety?${queryParams.toString()}`);
// helper: "YYYY-MM-DD HH:MM:SS" -> "MM-DD HH:MM"
const formatNoYearNoSec = (val: any) => {
if (!val && val !== 0) return "-";
const s = String(val);
// 정규식으로 안전하게 매칭 후 변환
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::\d{2})?$/);
if (m) {
const [, , mm, dd, hh, min] = m;
return `${mm}-${dd} ${hh}:${min}`;
}
// 포맷이 예상과 다르면, 길이에 따라 잘라보기
if (s.length >= 16) return s.substring(5, 16); // "YYYY-" 제거하고 초 전까지
return s;
};
const processedDataArray: any[] = result.content.map((data: any) => {
// 🎯 formatDisplayData로 기본 포맷팅 적용
const formattedItem = formatDisplayData(data);
const flwmtrInstTp = data.flwmtrInstTp === 'R' ? '이동식' : data.flwmtrInstTp === 'S' ? '고정식' : '-';
const flwmtrMesureTp = data.flwmtrMesureTp === 'R' ? '레이더' : data.flwmtrMesureTp === 'P' ? '플륨'
: data.flwmtrMesureTp === 'U' ? '초음파' : data.flwmtrMesureTp === 'E' ? '기타' : '-';
const flwmtrCombined = `${flwmtrInstTp}, ${flwmtrMesureTp}`;
// 🎯 추가 데이터 조합
return {
...formattedItem,
rainInfo: `${data.rainInfo === '0' ? '강우량계' : data.observatory || '-'}`, // 강우정보 0: 강우량계,1: 관측기관 이면 OBSERVATORY 이값사용
mesureIntrvl: `${data.mesureIntrvlGbn === '1' ? '1분' : (data.mesureIntrvlGbn === '0' ? data.mesureIntrvl + '분' : '-')}`, // 측정간격
flwmtrCombined: `${flwmtrCombined} / ${data.flwmtrKnd || '-'}`, // 유량계종류 및 모델명
mesureBeginDt: data.mesureBeginDt ? formatNoYearNoSec(data.mesureBeginDt) : '-', // 수량 측정시작일
mesureEndDt: data.mesureEndDt ? formatNoYearNoSec(data.mesureEndDt) : '-', // 수량 측정종료일
qltwtrBeginDt: data.qltwtrBeginDt ? formatNoYearNoSec(data.qltwtrBeginDt) : '-', // 수질 측정시작일(채수시작일)
qltwtrEndDt: data.qltwtrEndDt ? formatNoYearNoSec(data.qltwtrEndDt) : '-', // 수질 측정종료일(채수종료일)
};
});
setRainMeasureData(processedDataArray);
setTotalCount(result?.totalElements || 0);
} catch (error) {
console.error('추가 데이터 로딩 실패:', error);
return [];
}
};
// 수량/수질측정 정보 API 호출 함수
const fetchMntrngsData = async (adstDstrCd: string, manageNo: string) => {
setIsResetDetailPage(true);
if (!adstDstrCd || !manageNo) {
console.warn('추가 데이터 호출 실패: 필수 매개변수 누락', { adstDstrCd, manageNo });
return [];
}
try {
const queryParams = new URLSearchParams({
page: `${curDetailPage - 1}`,
size: '3',
});
const result = await getRawJson(`/sewerage-service/api/v1/onsemiro/swgRain/${adstDstrCd}/${manageNo}/mntrngs?${queryParams.toString()}`);
// 각 아이템 전처리
const processedDataArray = result.content.map((data: any) => {
// 기본 포맷팅 적용
const formattedItem = formatDisplayData(data);
return {
...formattedItem,
};
});
setMntrngsData(processedDataArray);
setTotalDetailCount(result?.totalElements || 0);
} catch (error) {
console.error('추가 데이터 로딩 실패:', error);
return [];
}
};
// 🎯 추가 데이터는 별도 useEffect로 비동기 로딩
useEffect(() => {
// 강우/측정 정보 데이터 호출
if (isMainSsc && chmbrXcnts && chmbrYdnts) {
fetchRainMeasureData(chmbrXcnts, chmbrYdnts)
}
}, [chmbrXcnts, chmbrYdnts]);
const openModal = () => {
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
if (!isDataLoaded) {
setTimeout(() => setIsDataLoaded(true), 100);
}
};
// featureData가 변경될 때 formData 초기화
useEffect(() => {
if (featureData) {
setFormData(featureData);
setMntrngsData([]);
}
}, [featureData]);
// featureData가 변경될 때 formData 초기화
useEffect(() => {
// showTable이 true일 때만 fetch (최초 렌더 방지)
setIsResetDetailPage(false);
setSelectedRowIndex(-1);
if (chmbrXcnts && chmbrYdnts) fetchRainMeasureData(chmbrXcnts, chmbrYdnts);
}, [curPage]);
// 페이지 변경 시마다 데이터 fetch
useEffect(() => {
if (chmbrXcnts && chmbrYdnts && selectedRowIndex !== -1) fetchMntrngsData(rainMeasureData[selectedRowIndex].adstDstrCd, rainMeasureData[selectedRowIndex].manageNo);
}, [curDetailPage]);
// 폼 데이터가 변경될 때 호출될 핸들러
const handleFormDataChange = (newFormData: any) => {
setFormData(newFormData);
};
// 저장 버튼 클릭 시 최신 formData를 부모에게 전달
const handleSaveClick = () => {
if (onSaveClick) {
// 저장 전에 formData를 부모 컴포넌트의 handleSaveClick에 전달
onSaveClick();
}
};
// 테이블 셀 클릭 핸들러 추가
const handleCellClick = async (index: number | undefined, headId: string) => {
if (index !== undefined && rainMeasureData) {
await fetchMntrngsData(rainMeasureData[index].adstDstrCd, rainMeasureData[index].manageNo);
setSelectedRowIndex(index);
}
};
const renderModalBody = () => (
<div className='grid grid-cols-1 gap-4 p-4 mr-4 mt-4'>
<div className='border rounded-lg pt-4 bg-white'>
<Label className='text-2xl font-bold text-label ml-4'>강우/측정 정보</Label>
<hr className="my-4 ml-4 mr-4 border-t border-gray-300" />
{/* 강우정보 테이블 */}
<UiTable
headName=''
tableData={rainMeasureData}
headlist={untrtSwgRainCol3}
pageSize={3}
total={0}
cellClick={handleCellClick}
onPageChange={setCurPage}
resetPageNo={isResetPage}
resetSelection={isResetSelection}
index={selectedRowIndex}
/>
{/* 측정정보 테이블 */}
<UiTable
headName=''
tableData={rainMeasureData}
headlist={untrtSwgRainCol4}
pageSize={3}
total={totalCount}
cellClick={handleCellClick}
onPageChange={setCurPage}
resetPageNo={isResetPage}
resetSelection={isResetSelection}
index={selectedRowIndex}
>
{/* 커스텀 헤더 */}
<TableRow>
<TableHead colSpan={5} className="text-center bg-blue-100 font-bold">수량</TableHead>
<TableHead colSpan={3} className="text-center bg-blue-100 font-bold">수질</TableHead>
</TableRow>
<TableRow>
<TableHead className="text-center">측정시작일시</TableHead>
<TableHead className="text-center">측정종료일시</TableHead>
<TableHead className="text-center">측정간격(분)</TableHead>
<TableHead className="text-center">측정기기설치위치</TableHead>
<TableHead className="text-center">유량계 종류 및 모델명</TableHead>
<TableHead className="text-center">채수시작일시</TableHead>
<TableHead className="text-center">채수종료일시</TableHead>
<TableHead className="text-center">채수횟수</TableHead>
</TableRow>
</UiTable>
</div>
<div className='border rounded-lg pt-4 bg-white'>
{/* 수량/수질측정 정보 테이블 */}
<Label className='text-2xl font-bold text-label ml-4'>수량/수질측정 정보</Label>
<hr className="my-4 ml-4 mr-4 border-t border-gray-300" />
<UiTable
headName=''
tableData={mntrngsData}
headlist={untrtSwgRainCol5}
pageSize={3}
total={totalDetailCount}
cellClick={undefined}
onPageChange={setCurDetailPage}
resetPageNo={isResetDetailPage}
/>
</div>
</div>
)
return (
<div>
{!isMainSsc && (
<VerticalTable
headName='입력정보'
tableData={featureData ? [formData] : []}
headlist={inputColumn}
pageSize={0}
total={0}
cellClick={undefined}
isEditable={true}
onPageChange={function (): void {
throw new Error('Function not implemented.');
}}
showSaveButton={true}
onSaveClick={handleSaveClick}
onFormDataChange={handleFormDataChange} // 폼 데이터 변경 콜백 추가
/>
)}
{isMainSsc && (
<div>
<div className="card-header">
<h3 className="text-2xl mx-3 font-medium text-label">중점우수토실 운영정보</h3>
</div>
<Button
className='mx-auto block w-64 mt-3 py-2 text-white rounded-sm text-sm font-medium hover:bg-blue-700 bg-blue-600'
onClick={openModal}
>
상세보기
</Button>
<UiModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
bodyClass={`
h-[100vh]
w-[60vw]
max-w-none
max-h-none
overflow-y-auto
z-[101]
`}
body={renderModalBody()}
noHeaderYn='Y'
noFooterYn='Y'
/>
</div>
)}
</div>
)
}
export default Ssc
@@ -1,73 +0,0 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수처리시설)
*
* 📄 SwtrmtFclt
* 하수처리시설 정보 컴포넌트
*
* - 하수처리시설의 디지털 자료관 연계 정보 표시
* - VerticalTable을 통한 테이블 렌더링
* - 조회 버튼을 통한 모달 표시 및 ReportTab 연동
*/
'use client'
import React, { useState } from 'react'
import { VerticalTable } from '@/components/ui/verticalTable';
import { UiModal } from '@common/business_components/ui';
import { Button } from '@common/components/ui';
import ReportTab from '../../searchModal/reportTab';
// 하수처리시설
const headList = [
{ id: "title", title: "하수도정비 기본계획보고서" },
];
const SwtrmtFclt = ({ gisObject }: { gisObject: any }) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const ctpvNm = gisObject?.ctpv_nm;
const sggNm = gisObject?.sgg_nm;
return (
<div>
<VerticalTable
children={{
title: <Button className="gray" onClick={() => setIsModalOpen(true)}>조회</Button>
}}
headName='디지털 자료관 연계정보'
tableData={[]}
headlist={headList}
pageSize={20}
total={0}
cellClick={undefined}
isEditable={true}
onPageChange={function (page: number): void {
throw new Error("Function not implemented.");
}}
/>
<UiModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
headerTitle="하수도정비 기본계획보고서"
headerTitleClass="text-3xl font-semibold text-gray-700"
bodyClass={`
h-[100vh]
w-[60vw]
max-w-[60vw]
max-h-[100vh]
overflow-hidden
z-[101]
`}
body={
<ReportTab
open={isModalOpen}
isSearch={false}
ctpvNm={ctpvNm}
sggNm={sggNm}
/>
}
useDirectRender={true}
/>
</div >
)
}
export default SwtrmtFclt
@@ -1,271 +0,0 @@
import {useState, useEffect} from 'react';
import { getData } from '@/components/lib/api';
import moment from "moment";
export type TInftwtAnls = {
inftwtAnlsSn:number;
aplcnYm: string;
ctpvCd: string;
sggCd: string;
swtrmtFcltNm: string;
atflCd: string;
atflId: string;
atflInpNm: string;
atflHrPttnNm: string;
atflMpngNm: string;
atflOilqNm: string;
atflNm: string;
drtwSwtcRt:number;
anlsInfo: string;
scsYn: string;
};
export type TInftwtAnlsRslt = {
inftwtAnlsSn: number;
pplMngNo: string;
anlsTm: string;
predcOilq:number;
}
export type TRdiiAnls = {
rainInfwtAnlsSn:number;
aplcnYm: string;
ctpvCd: string;
sggCd: string;
swtrmtFcltNm: string;
atflCd: string;
atflId: string;
atflInpNm: string;
atflHrPttnNm: string;
atflMpngNm: string;
atflRainNm: string;
atflNm: string;
drtwSwtcRt:number;
anlsInfo: string;
scsYn: string;
srcNm: string; // 출처이름
};
export type TRdiiAnlsRslt = {
rainInfwtAnlsSn: number;
pplMngNo: string;
anlsTm: string;
predcOilq:number;
minq:number;
}
export type TOilqData = {
inftwtAnlsSn: number; // 침입수분석 일련번호
pplMngNo: string; // 관로관리번호
mtrngDt: string; // 계측일시
oilq: number; // 유량
}
export type TRainData = {
rainInfwtAnlsSn: number;
mtrngDt: string;
amrf: number; // 강우량
}
function roundTo(n: number, digits: number): number {
const factor = Math.pow(10, digits);
return Math.round(n * factor) / factor;
}
const getOptionsAplcnYm = (yyyymms: string[]): { text: string; val: string, hidden?: boolean }[] => {
const yyyymms1 : { text: string; val: string; hidden?: boolean }[] = yyyymms.map(dateStr => {
const year = dateStr.slice(0, 4);
const month = dateStr.slice(4, 6);
return {
text: `${year}-${month}`,
val: dateStr
};
});
yyyymms1.unshift({ val: '0', text: '선택', hidden: true });
return yyyymms1;
};
const selectAplcnYms = async(gubun:string) => { // 상수사용월
try {
const queryString = '';
const result = await getData(`v1/${gubun}/aplcnYm${queryString ? `?${queryString}` : ''}`);
if (result && result.length > 0) {
return getOptionsAplcnYm(result);
}
} catch (error) {
console.error(error);
}
};
export const useAplcnYms = (gubun:string) => {
const [data, setData] = useState<{ text: string; val: string }[]>([]);
useEffect(() => {
const fetch = async (gubun:string) => {
const result = await selectAplcnYms(gubun);
if (result) setData(result);
};
fetch(gubun);
}, [gubun]);
return data;
};
export const getOptionsInftwtAnlsInfo = (anlsArr: TInftwtAnls[]): { text: string; val: number, hidden?: boolean }[] => {
const options : { text: string; val: number; hidden?: boolean }[] = anlsArr.map(anls => ({
text: anls.anlsInfo,
val: anls.inftwtAnlsSn,
}));
options.unshift({ val: 0, text: '선택', hidden: true });
return options;
};
export const selectInftwtAnls = async(aplcnYm:string) => { // 침입수분석
try {
const queryString = aplcnYm ? `aplcnYmFrom=${aplcnYm}&aplcnYmTo=${aplcnYm}` : '';
const result = await getData(`v1/inftwtAnls${queryString ? `?${queryString}` : ''}`);
if (result && result.numberOfElements > 0) {
return result.content;
}
} catch (error) {
console.error(error);
}
};
export const getChartDataInftwtAnlsRslt = (arr: TInftwtAnlsRslt[]): { label: string; val: number }[] => {
const options = arr.map(anlsRslt => ({
label: moment(anlsRslt.anlsTm, "YYYYMMDDHHmmss").format("HH시"),
val: roundTo(anlsRslt.predcOilq, 2),
}));
return options;
};
export const selectInftwtAnlsRslt = async(inftwtAnlsSn:number, pplMngNo:string) => { // 침입수분석 결과
try {
if ((inftwtAnlsSn === undefined || inftwtAnlsSn === 0) || !pplMngNo) {
console.warn("inftwtAnlsSn or pplMngNo is not provided");
return [];
}
const result = await getData(`v1/inftwtAnlsRslt/ppl/${inftwtAnlsSn}/${pplMngNo}`);
if (result && result.numberOfElements > 0) {
return result.content;
}
} catch (error) {
console.error(error);
}
};
export const getChartDataOilq = (arr: TOilqData[]): { label: string; val: number }[] => {
const options = arr.map(anlsRslt => ({
label: anlsRslt.mtrngDt,
val: roundTo(anlsRslt.oilq, 2),
}));
return options;
};
export const selectOilq = async(inftwtAnlsSn:number, pplMngNo:string) => { // 유량
try {
if ((inftwtAnlsSn === undefined || inftwtAnlsSn === 0) || !pplMngNo) {
console.warn("inftwtAnlsSn or pplMngNo is not provided");
return [];
}
const result = await getData(`v1/inftwtAnlsRslt/oilqPpl/${inftwtAnlsSn}/${pplMngNo}`);
if (result && result.numberOfElements > 0) {
return result.content;
}
} catch (error) {
console.error(error);
}
};
export const selectPplInftwtQty = async(inftwtAnlsSn:number, pplMngNo:string) => { // 침입수량
try {
if ((inftwtAnlsSn === undefined || inftwtAnlsSn === 0) || !pplMngNo) {
console.warn("inftwtAnlsSn or pplMngNo is not provided");
return [];
}
const result = await getData(`v1/inftwtAnlsRslt/pplInftwtQty/${inftwtAnlsSn}/${pplMngNo}`);
if (result.length > 0) {
return result[0].avg_day_flow;
} else {
return 0; // 데이터가 없을 경우 0 반환
}
} catch (error) {
console.error(error);
}
};
//----------
export const selectPplRdiiQty = async(rainInfwtAnlsSn:number, pplMngNo:string) => { // rdii값
try {
if ((rainInfwtAnlsSn === undefined || rainInfwtAnlsSn === 0) || !pplMngNo) {
console.warn("rainInfwtAnlsSn or pplMngNo is not provided");
return [];
}
const result = await getData(`v1/rdiiAnlsRslt/pplRdiiQty/${rainInfwtAnlsSn}/${pplMngNo}`);
if (result?.sustaintime > 0) {
return result;
}
} catch (error) {
console.error(error);
}
};
export const getOptionsRdiiAnlsInfo = (anlsArr: TRdiiAnls[]): { text: string; val: number, hidden?: boolean }[] => {
const options : { text: string; val: number; hidden?: boolean }[] = anlsArr.map(anls => ({
text: anls.anlsInfo,
val: anls.rainInfwtAnlsSn,
}));
options.unshift({ val: 0, text: '선택', hidden: true });
return options;
};
export const selectRdiiAnls = async(aplcnYm:string) => { // 침입수분석
try {
const queryString = aplcnYm ? `aplcnYmFrom=${aplcnYm}&aplcnYmTo=${aplcnYm}` : '';
const result = await getData(`v1/rdiiAnls${queryString ? `?${queryString}` : ''}`);
if (result && result.numberOfElements > 0) {
return result.content;
}
} catch (error) {
console.error(error);
}
};
export const getChartDataRdiiAnlsRslt = (arr: TRdiiAnlsRslt[]): { label: string; lineVal0: number, barVal0:number }[] => {
const options = arr.map(anlsRslt => ({
label: moment(anlsRslt.anlsTm, "YYYYMMDDHHmmss").format("MM-DD HH시"),
lineVal0: roundTo(anlsRslt.predcOilq, 2),
barVal0: 0
}));
return options;
};
export const getChartDataRdiiAnlsRsltMinq = (arr: TRdiiAnlsRslt[]): { label: string; val: number }[] => {
const options = arr.map(anlsRslt => ({
label: moment(anlsRslt.anlsTm, "YYYYMMDDHHmmss").format("MM-DD HH시"),
val: roundTo(anlsRslt.minq, 2),
}));
return options;
};
export const selectRdiiAnlsRslt = async(rainInfwtAnlsSn:number, pplMngNo:string) => { // RDII분석 결과
try {
if ((rainInfwtAnlsSn === undefined || rainInfwtAnlsSn === 0) || !pplMngNo) {
console.warn("RdiiAnlsSn or pplMngNo is not provided");
return [];
}
const result = await getData(`v1/rdiiAnlsRslt/ppl/${rainInfwtAnlsSn}/${pplMngNo}`);
if (result && result.numberOfElements > 0) {
return result.content;
}
} catch (error) {
console.error(error);
}
};
export const selectRainData = async(rainInfwtAnlsSn:number) => { // 강우데이터
try {
if ((rainInfwtAnlsSn === undefined || rainInfwtAnlsSn === 0)) {
console.warn("RdiiAnlsSn or pplMngNo is not provided");
return [];
}
const result = await getData(`v1/rdiiAnlsRslt/rainData/${rainInfwtAnlsSn}`);
if (result && result.numberOfElements > 0) {
return result.content;
}
} catch (error) {
console.error(error);
}
};
export const getChartDataRainData = (arr: TRainData[]): { label: string; lineVal0: number, barVal0: number }[] => {
const options = arr.map(data => ({
label: moment(data.mtrngDt, "YYYY-MM-DD HH:mm:ss").format("MM-DD HH시"),
lineVal0: 0,
barVal0: roundTo(data.amrf,2),
}));
return options;
};
@@ -1,297 +0,0 @@
'use client'
import React, { useEffect, useMemo, useState } from 'react';
import moment from 'moment';
import {
CHART_FORM,
type ChartConfig,
MainChart,
UiModal,
} from '@common/business_components';
import { SelectBox } from '@common/business_components/ui';
import { Button, Input, Label } from '@common/components';
import type { TInftwtAnls } from './aplcnYms';
import * as fn from './aplcnYms';
import { useInfilChartModalStore } from '../chartModalStore';
type ModalOpenProp = {
pplMngNo?: string; // 관로관리번호
};
type DataType = {
label: string;
val: number;
};
const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: '선택' }];
const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
const { isOpen: isModalOpen, setIsOpen: setIsModalOpen } = useInfilChartModalStore();
const [selectedAplcnYm, setSelectedAplcnYm] = useState('');
const [inftwtAnlss, setInftwtAnlss] = useState<TInftwtAnls[]>([]);
const [inftwtAnlsInfos, setInftwtAnlsInfos] = useState<{ text: string; val: number; hidden?: boolean }[]>(
INITIAL_ANALYSIS_OPTIONS,
);
const [inftwtAnls, setInftwtAnls] = useState<TInftwtAnls | null>(null);
const [selectedInftwtAnls, setSelectedInftwtAnls] = useState<number>(0);
const [chartData, setChartData] = useState<{ chartData1: DataType[]; chartData2: DataType[] }>({
chartData1: [],
chartData2: [],
});
const [inftwtQty, setInftwtQty] = useState<string>('0');
const aplcnYms = fn.useAplcnYms('inftwtAnlsRslt');
const normalizedPplMngNo = pplMngNo ? `L${pplMngNo}` : '';
const chartForm = CHART_FORM.LINE;
const chartConfig = {
chartData1: { label: '순오수량', color: 'red' },
chartData2: { label: '실측하수량', color: 'orange' },
} as ChartConfig;
const hasChartData = useMemo(() => {
return chartData.chartData1.length > 0 || chartData.chartData2.length > 0;
}, [chartData]);
const resetState = () => {
setSelectedAplcnYm('');
setInftwtAnlss([]);
setInftwtAnlsInfos(INITIAL_ANALYSIS_OPTIONS);
setInftwtAnls(null);
setSelectedInftwtAnls(0);
setChartData({ chartData1: [], chartData2: [] });
setInftwtQty('0');
};
useEffect(() => {
if (!isModalOpen) {
resetState(); // 모달이 닫힐 때 리셋
}
}, [isModalOpen]);
const handleAplcnYmChange = async (val: string | number) => {
const selectedValue = String(val);
setSelectedAplcnYm(selectedValue);
setInftwtAnlss([]);
setInftwtAnls(null);
setSelectedInftwtAnls(0);
setInftwtQty('0');
setChartData({ chartData1: [], chartData2: [] });
if (!selectedValue || selectedValue === '0') {
setInftwtAnlsInfos(INITIAL_ANALYSIS_OPTIONS);
return;
}
try {
const data = await fn.selectInftwtAnls(selectedValue);
const list = data ?? [];
setInftwtAnlss(list);
setInftwtAnlsInfos(fn.getOptionsInftwtAnlsInfo(list));
} catch (error) {
console.error('selectInftwtAnls 에러:', error);
setInftwtAnlss([]);
setInftwtAnlsInfos(INITIAL_ANALYSIS_OPTIONS);
}
};
const handleInftwtAnlsSnChange = (val: string | number) => {
const selectedValue = Number(val);
setSelectedInftwtAnls(selectedValue);
setChartData({ chartData1: [], chartData2: [] });
setInftwtQty('0');
if (selectedValue === 0) {
setInftwtAnls(null);
return;
}
const selectedAnls = inftwtAnlss.find((anls) => anls.inftwtAnlsSn === selectedValue) ?? null;
setInftwtAnls(selectedAnls);
};
const infilRsltChart = async () => {
if (!selectedAplcnYm || selectedInftwtAnls === 0 || !normalizedPplMngNo) {
setChartData({ chartData1: [], chartData2: [] });
setInftwtQty('0');
return;
}
try {
const [inftwtAnlsRslt, oilq, qty] = await Promise.all([
fn.selectInftwtAnlsRslt(selectedInftwtAnls, normalizedPplMngNo),
fn.selectOilq(selectedInftwtAnls, normalizedPplMngNo).catch((err) => {
console.error('selectOilq 에러:', err);
return [];
}),
fn.selectPplInftwtQty(selectedInftwtAnls, normalizedPplMngNo).catch((err) => {
console.error('selectPplInftwtQty 에러:', err);
return 0;
}),
]);
setChartData({
chartData1: fn.getChartDataInftwtAnlsRslt(inftwtAnlsRslt ?? []),
chartData2: fn.getChartDataOilq(oilq ?? []),
});
const normalizedQty = Number(qty ?? 0);
setInftwtQty(normalizedQty.toLocaleString('ko-KR'));
} catch (error) {
console.error('infilRsltChart 에러:', error);
setChartData({ chartData1: [], chartData2: [] });
setInftwtQty('0');
}
};
const renderInfoInput = (label: string, value?: string | number | null) => (
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3">
<Label className="w-auto text-[14px] font-semibold text-[#5c6470] whitespace-nowrap">{label}</Label>
<Input
className="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white px-3 text-xl md:text-xl text-[#2b3440]"
placeholder=""
value={value ?? ''}
readOnly
/>
</div>
);
const handleReset = () => {
resetState();
}
const renderBody = () => (
<div className="text-black">
<div className="mb-4 flex items-start justify-between">
<div className="text-[18px] font-semibold text-[#111827]" />
</div>
<div className="grid min-h-[68vh] grid-cols-[minmax(0,38%)_minmax(0,62%)] gap-5">
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-5">
<h3 className="mb-6 text-[20px] font-extrabold tracking-[-0.02em] text-black">
침입수 분석 선택 조건
</h3>
<div className="rounded-[6px] border border-[#d9dde3] bg-[#eff1f4] px-4 py-6">
<div className="space-y-3">
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3">
<Label className="w-auto text-[14px] font-semibold text-[#5c6470] whitespace-nowrap">
상수사용월:
</Label>
<SelectBox
className="w-full"
selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl"
label=""
selectArray={aplcnYms}
value={selectedAplcnYm}
onSelectValue={handleAplcnYmChange}
/>
</div>
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3">
<Label className="w-auto text-[14px] font-semibold text-[#5c6470] whitespace-nowrap">
분석정보:
</Label>
<SelectBox
className="w-full"
selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl"
label=""
selectArray={inftwtAnlsInfos}
value={selectedInftwtAnls}
onSelectValue={handleInftwtAnlsSnChange}
/>
</div>
{renderInfoInput('오수전환율:', inftwtAnls?.drtwSwtcRt ?? '')}
{renderInfoInput('시간패턴 파일명:', inftwtAnls?.atflHrPttnNm ?? '')}
{renderInfoInput('수용가-맨홀 매칭:', inftwtAnls?.atflMpngNm ?? '')}
{renderInfoInput('관망도 파일명:', inftwtAnls?.atflInpNm ?? '')}
{renderInfoInput('유량 파일명:', inftwtAnls?.atflOilqNm ?? '')}
</div>
</div>
<div className="absolute inset-x-0 bottom-0 mb-[40px] flex justify-center">
<Button size="lg" onClick={infilRsltChart} >
조회
</Button>&nbsp;&nbsp;
<Button size="lg" color="gray" onClick={handleReset} >
초기화
</Button>
</div>
</section>
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-5">
<h3 className="mb-6 text-[20px] font-extrabold tracking-[-0.02em] text-black">침입수 분석 결과</h3>
<div className="mb-6 grid grid-cols-2 gap-5">
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
<div className="flex items-center gap-2 text-[16px] font-extrabold text-[#ef4444]">
{/* <span className="text-[14px] leading-none">●</span> */}
<span>상수사용량 적용연월</span>
</div>
<div className="text-[20px] font-extrabold text-[#222]">
{selectedAplcnYm ? moment(selectedAplcnYm, 'YYYYMM').format('YYYY-MM') : '-'}
</div>
</div>
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
<div className="flex items-center gap-2 text-[16px] font-extrabold text-[#ef4444]">
{/* <span className="text-[14px] leading-none">●</span> */}
<span>침입수량 산정결과</span>
</div>
<div className="grid grid-cols-[1fr_auto] items-center gap-7 text-[#222]">
<span className="text-[16px] text-[#666]">침입수량</span>
<div className='w-[66px] flex items-end justify-start gap-2'>
<span className="text-[20px] font-extrabold">{inftwtQty}</span>
<span className="pb-[2px] text-[16px]">m³/d</span>
</div>
</div>
</div>
</div>
<div className="rounded-[10px] bg-white px-2 pb-2 pt-3">
{hasChartData ? (
<MainChart
chartConfig={chartConfig}
chartForm={chartForm}
chartData={chartData}
chartTitle={'침입수량 그래프'}
yFieldText="유량(m³/h)"
yFieldText2=""
/>
) : (
<div className="flex h-[464px] items-center justify-center text-[18px] text-[#2c2f33]">
데이터가 없습니다.
</div>
)}
</div>
</section>
</div>
</div>
);
return (
<UiModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
headerTitle=""
bodyClass={`
w-[78vw]
max-w-none
min-w-[1000px]
min-h-[72vh]
overflow-visible
z-[50]
`}
body={renderBody()}
noFooterYn="Y"
/>
);
};
export default React.memo(InfilChart);
@@ -1,357 +0,0 @@
'use client'
import React, { useEffect, useMemo, useState } from 'react';
import moment from 'moment';
import {
CHART_FORM,
type ChartConfig,
MainChart,
UiModal,
} from '@common/business_components';
import { SelectBox } from '@common/business_components/ui';
import { Button, Input, Label } from '@common/components';
import type { TRdiiAnls } from './aplcnYms';
import * as fn from './aplcnYms';
import { useRdiiChartModalStore } from '../chartModalStore';
type ModalOpenProp = {
pplMngNo?: string; // 관로관리번호
};
type LineBarChartData = {
label: string;
lineVal0: number;
barVal0: number;
};
const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: '선택' }];
const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => {
const { isOpen: isModalOpen, setIsOpen: setIsModalOpen } = useRdiiChartModalStore();
const [aplcnYm, setAplcnYm] = useState('');
const [rdiiAnlss, setRdiiAnlss] = useState<TRdiiAnls[]>([]);
const [rdiiAnlsInfos, setRdiiAnlsInfos] = useState<{ text: string; val: number; hidden?: boolean }[]>(
INITIAL_ANALYSIS_OPTIONS,
);
const [rdiiAnls, setRdiiAnls] = useState<TRdiiAnls | null>(null); // 강우유입수분석정보
const [rainInfwtAnlsSn, setRainInfwtAnlsSn] = useState<number>(0);// 강우유입수분석 정보 선택시 사용
const [chartData, setChartData] = useState<LineBarChartData[]>([]);
const [rdiiQty, setRdiiQty] = useState<string>('0'); // 강우유입수량
const [rainTotal, setRainTotal] = useState<string>('0'); // 적용강우량
const [totalVolume, setTotalVolume] = useState<string>('0');// 총체적 m³/mm
const aplcnYms = fn.useAplcnYms('rdiiAnlsRslt'); // 상수사용월 데이터 가져오기
const normalizedPplMngNo = pplMngNo ? `L${pplMngNo}` : '';
const chartForm = CHART_FORM.LINEBAR2;
const chartConfig = {
barVal0: {
label: '강수량',
color: '#33AAFF',
},
lineVal0: {
label: 'RDII',
color: '#FF0000',
},
} as ChartConfig;
const hasChartData = useMemo(() => chartData.length > 0, [chartData]);
const resetState = () => {
setAplcnYm(''); // 상수사용월 초기화
setRdiiAnlss([]); // 강우유입수분석 초기화
setRdiiAnlsInfos(INITIAL_ANALYSIS_OPTIONS); // 강우유입수분석정보 초기화
setRdiiAnls(null); // 강우유입수분석정보 초기화
setRainInfwtAnlsSn(0); // 강우유입수분석 일련번호 초기화
setChartData([]);
setRdiiQty('0'); // 강우유입수량 초기화
setRainTotal('0');// 적용강우량 초기화
setTotalVolume('0');// 총체적 m³/mm 초기화
};
useEffect(() => {
if (!isModalOpen) {
resetState();
}
}, [isModalOpen]);
const handleAplcnYmChange = async (val: string | number) => {
const selectedValue = String(val);
setAplcnYm(selectedValue);
setRdiiAnlss([]);
setRdiiAnls(null);
setRainInfwtAnlsSn(0);
setChartData([]);
setRdiiQty('0');
setRainTotal('0');
setTotalVolume('0');
if (!selectedValue || selectedValue === '0') {
setRdiiAnlsInfos(INITIAL_ANALYSIS_OPTIONS);
return;
}
try {
const data = await fn.selectRdiiAnls(selectedValue); // 상수사용월 변경시 분석 정보 리스트 가져오기
const list = data ?? [];
setRdiiAnlss(list);
setRdiiAnlsInfos(fn.getOptionsRdiiAnlsInfo(list));
} catch (error) {
console.error('selectRdiiAnls 에러:', error);
setRdiiAnlss([]);
setRdiiAnlsInfos(INITIAL_ANALYSIS_OPTIONS);
}
};
const handleRdiiAnlsSnChange = (val: string | number) => {
const selectedValue = Number(val);
setRainInfwtAnlsSn(selectedValue);
setChartData([]);
setRdiiQty('0');
setRainTotal('0');
setTotalVolume('0');
if (selectedValue === 0) {
setRdiiAnls(null);
return;
}
const selectedAnls = rdiiAnlss.find((anls) => anls.rainInfwtAnlsSn === selectedValue) ?? null;
setRdiiAnls(selectedAnls);
};
const fetchFilteredData = async () => {
if (!rainInfwtAnlsSn || !normalizedPplMngNo) {
setChartData([]);
setRdiiQty('0');
setRainTotal('0');
setTotalVolume('0');
return;
}
try {
const [rsltData, rainData, qtyData] = await Promise.all([
fn.selectRdiiAnlsRslt(rainInfwtAnlsSn, normalizedPplMngNo),
fn.selectRainData(rainInfwtAnlsSn),
fn.selectPplRdiiQty(rainInfwtAnlsSn, normalizedPplMngNo),
]);
const rsltDataFormatted = fn.getChartDataRdiiAnlsRslt(rsltData ?? []);
const rainDataFormatted = fn.getChartDataRainData(rainData ?? []);
const rainMap = new Map(rainDataFormatted.map((item) => [item.label, item.barVal0]));
rsltDataFormatted.forEach((item) => {
const matchedVal = rainMap.get(item.label);
if (matchedVal !== undefined) {
item.barVal0 = matchedVal;
}
});
setChartData(rsltDataFormatted);// 차트 데이터 설정
const avgRdii = Number(qtyData?.rdiih ?? 0);
const appliedRainfall = Number(qtyData?.raintotal ?? 0);
const rdiiPerRainfall =
qtyData?.raintotal && qtyData?.raintotal !== 0
? Math.round(avgRdii * Number(qtyData?.sustaintime ?? 0) / qtyData.raintotal)
: 0;
setRdiiQty(Math.round(avgRdii * 24).toLocaleString('ko-KR'));
setRainTotal(appliedRainfall.toLocaleString('ko-KR'));
setTotalVolume(rdiiPerRainfall.toLocaleString('ko-KR'));
} catch (error) {
console.error('rdiiRsltChart 에러:', error);
setChartData([]);
setRdiiQty('0');
setRainTotal('0');
setTotalVolume('0');
}
};
const renderInfoInput = (label: string, value?: string | number | null) => (
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3">
<Label className="w-auto whitespace-nowrap text-[14px] font-semibold text-[#5c6470]">
{label}
</Label>
<Input
className="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white px-3 text-xl text-[#2b3440]"
placeholder=""
value={value ?? ''}
readOnly
/>
</div>
);
const renderBody = () => (
<div className="text-black">
<div className="mb-4 flex items-start justify-between">
<div className="text-[18px] font-semibold text-[#111827]" />
</div>
<div className="grid min-h-[68vh] grid-cols-[minmax(0,36%)_minmax(0,64%)] gap-5">
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-5">
<h3 className="mb-6 text-[20px] font-extrabold tracking-[-0.02em] text-black">
강우유입수분석 선택 조건
</h3>
<div className="rounded-[6px] border border-[#d9dde3] bg-[#eff1f4] px-4 py-6">
<div className="space-y-3">
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3">
<Label className="w-auto whitespace-nowrap text-[14px] font-semibold text-[#5c6470]">
상수사용월:
</Label>
<SelectBox
className="w-full"
selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl"
label=""
selectArray={aplcnYms}
value={aplcnYm}
onSelectValue={handleAplcnYmChange}
/>
</div>
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3">
<Label className="w-auto whitespace-nowrap text-[14px] font-semibold text-[#5c6470]">
분석정보:
</Label>
<SelectBox
className="w-full"
selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl"
label=""
selectArray={rdiiAnlsInfos}
value={rainInfwtAnlsSn}
onSelectValue={handleRdiiAnlsSnChange}
/>
</div>
{renderInfoInput('오수전환율:', rdiiAnls?.drtwSwtcRt ?? '')}
{renderInfoInput('수용가-맨홀 매칭:', rdiiAnls?.atflMpngNm ?? '')}
{renderInfoInput('관망도 파일명:', rdiiAnls?.atflInpNm ?? '')}
{renderInfoInput('강우 파일명:', rdiiAnls?.atflRainNm ?? '')}
{renderInfoInput('RTK계수 출처:', rdiiAnls?.srcNm ?? '')}
</div>
</div>
<div className="absolute inset-x-0 bottom-0 mb-[40px] flex justify-center">
<Button size="lg" onClick={fetchFilteredData}>
조회
</Button>
&nbsp;&nbsp;
<Button size="lg" color="gray" onClick={resetState}>
초기화
</Button>
</div>
</section>
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-5">
<h3 className="mb-6 text-[20px] font-extrabold tracking-[-0.02em] text-black">
강우유입수분석 결과
</h3>
<div className="mb-6 flex gap-5">
{/* 좌측 카드 */}
<div className="flex min-h-[98px] min-w-0 basis-2/5 grow shrink-[1.4] items-center justify-between rounded-[16px] border border-[#5fa3ea] bg-white px-6 py-4">
<div className="text-[16px] font-bold text-[#ef4444]">
상수사용량 적용연월
</div>
<div className="text-[24px] font-extrabold leading-none text-[#111]">
{aplcnYm ? moment(aplcnYm, 'YYYYMM').format('YYYY-MM') : '-'}
</div>
</div>
{/* 우측 카드 */}
<div className="flex min-h-[98px] min-w-0 basis-3/5 grow shrink items-center gap-5 rounded-[16px] border border-[#5fa3ea] bg-white px-6 py-4">
{/* 좌측: 제목 (세로 가운데 정렬) */}
<div className="flex min-w-0 basis-[32%] items-center">
<div className="text-[16px] font-bold text-[#ef4444] break-keep">
강우유입수량 분석결과
</div>
</div>
{/* 우측: 지표 영역 */}
<div className="flex min-w-0 basis-[68%] flex-col gap-[2px]">
<div className="flex min-w-0 items-center gap-3">
<div className="min-w-0 basis-[48%] text-right text-[15px] text-[#555]">
평균RDII
</div>
<div className="min-w-0 basis-[32%] text-right text-[18px] font-extrabold text-[#111] tabular-nums">
{rdiiQty}
</div>
<div className="min-w-0 basis-[20%] text-left text-[14px] text-[#111]">
m³/d
</div>
</div>
<div className="flex min-w-0 items-center gap-3">
<div className="min-w-0 basis-[48%] text-right text-[15px] text-[#555]">
적용 강우량
</div>
<div className="min-w-0 basis-[32%] text-right text-[18px] font-extrabold text-[#111] tabular-nums">
{rainTotal}
</div>
<div className="min-w-0 basis-[20%] text-left text-[14px] text-[#111]">
mm
</div>
</div>
<div className="flex min-w-0 items-center gap-3">
<div className="min-w-0 basis-[48%] text-right text-[15px] text-[#555]">
강우량 대비 RDII
</div>
<div className="min-w-0 basis-[32%] text-right text-[18px] font-extrabold text-[#111] tabular-nums">
{totalVolume}
</div>
<div className="min-w-0 basis-[20%] text-left text-[14px] text-[#111]">
m³/mm
</div>
</div>
</div>
</div>
</div>
<div className="rounded-[10px] bg-white px-2 pb-2 pt-3">
{hasChartData ? (
<MainChart
chartConfig={chartConfig}
chartForm={chartForm}
chartData={chartData}
chartTitle={'RDII 그래프'}
yFieldText2="강수량(mm)"
yFieldText="유량(m³/h)"
/>
) : (
<div className="flex h-[464px] items-center justify-center text-[18px] text-[#2c2f33]">
데이터가 없습니다.
</div>
)}
</div>
</section>
</div>
</div>
);
return (
<UiModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
headerTitle=""
bodyClass={`
w-[78vw]
max-w-none
min-w-[1100px]
min-h-[72vh]
overflow-visible
z-[50]
`}
body={renderBody()}
noFooterYn="Y"
/>
);
};
export default React.memo(RdiiChart);
@@ -1,16 +0,0 @@
import { create } from 'zustand';
interface ModalStore {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
}
export const useInfilChartModalStore = create<ModalStore>((set) => ({
isOpen: false,
setIsOpen: (open: boolean) => set({ isOpen: open }),
}));
export const useRdiiChartModalStore = create<ModalStore>((set) => ({
isOpen: false,
setIsOpen: (open: boolean) => set({ isOpen: open }),
}));
@@ -1,629 +0,0 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창
*
* 📄 FeatureClickEventPopup
* 시설물(피처) 클릭시 오른쪽 팝업창 컴포넌트
*
* - 지도 시설물(피처) 클릭 시 팝업 표시 및 데이터 로딩
* - 레이어 유형에 따른 동적 테이블 및 헤드리스트 설정
* - 자체입력정보 조회 및 저장 기능 (하수관로, 우수토실, 하수펌프장, 하수맨홀)
* - 코드 매핑 및 데이터 포맷팅 처리
* - 상류관로 검색, 침입수/RDII 분석 차트 표시
* - 모달을 통한 저장 확인 및 API 연동
*
* 🔗 사용 API
* - 하수관로 자체입력정보 조회: GET /api/v1/fcltppl/{fcltMngNo}/{mngInstCd} (sewerage-service)
* - 우수토실 자체입력정보 조회: GET /api/v1/fcltstofcbr/{fcltMngNo}/{mngInstCd} (sewerage-service)
* - 하수펌프장 자체입력정보 조회: GET /api/v1/fcltpump/{fcltMngNo}/{mngInstCd} (sewerage-service)
* - 하수맨홀 자체입력정보 조회: GET /api/v1/fcltmhl/{fcltMngNo}/{mngInstCd} (sewerage-service)
* - 하수관로 자체입력정보 저장: POST /api/v1/fcltppl (sewerage-service)
* - 우수토실 자체입력정보 저장: POST /api/v1/fcltstofcbr (sewerage-service)
* - 하수펌프장 자체입력정보 저장: POST /api/v1/fcltpump (sewerage-service)
* - 하수맨홀 자체입력정보 저장: POST /api/v1/fcltmhl (sewerage-service)
*/
'use client'
import { getData, postData } from "@/components/lib/api";
import { VerticalTable } from "@/components/ui/verticalTable";
import { useGisContext } from "@common/business_components/ui/map/Gis";
import { Button } from "@common/components/ui";
import { useEffect, useRef, useState, useMemo } from "react";
import Style from "ol/style/Style.js";
import Fill from "ol/style/Fill.js";
import Stroke from "ol/style/Stroke.js";
import CircleStyle from "ol/style/Circle.js";
import { getLayerContentComponent } from "../common";
import { SwtrmtFcltCol, SwtrmtFcltCol2, SwtrmtPrcsZoneCol, SwtrmtPrcsZoneCol2, untrtSwgRainCol1, untrtSwgRainCol2, MsSwagePplCol, MsSwagePplCol2, ExclnSewagePumpCol, ExclnSewagePumpCol2, DrtwSewagePumpCol, DrtwSewagePumpCol2, MsSwageMhlCol, MsSwageMhlCol2, stofcbrCol, stofcbrCol2, MS_SWAGE_MHL_CODES, DRTW_SEWAGE_PUMP_USE_CODES } from "../common/table";
import InfilChart from './chart/infilChart';
import RdiiChart from './chart/rdiiChart';
import { useInfilChartModalStore, useRdiiChartModalStore } from "./chartModalStore";
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
import { codeMapper } from "../common/codeMapper";
import LoadingOverlay from '@/components/ui/LoadingBar';
import { searchUpperPpls } from "./smh";
const linkedFacilityHighlightStyle = new Style({
image: new CircleStyle({
radius: 18,
fill: new Fill({
color: 'rgba(255, 235, 59, 0.35)',
}),
stroke: new Stroke({
color: 'rgba(255, 193, 7, 0.45)',
width: 2,
}),
}),
fill: new Fill({
color: 'rgba(255, 235, 59, 0.2)',
}),
stroke: new Stroke({
color: 'rgba(255, 193, 7, 0.35)',
width: 2.5,
}),
zIndex: 9999,
});
const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void, featureData: any }) => {
const { gisMap } = useGisContext() as unknown as { checkedLayerIds: React.MutableRefObject<number[]>; treeData: { lyrSn: number }[] | null; gisCore: any; gisMap: any; };
const [layerNm, setLayerNm] = useState<string>(""); // 레이어 이름 상태
const [layerSpecificNm, setLayerSpecificNm] = useState<string>(""); // 레이어 이름 상태
const [tableData, setTableData] = useState<any>(null); // 피처 데이터
const [headlist, setHeadlist] = useState<{ id: string; title: string; }[]>([]); // 컬럼리스트 변경상태
const [headlist2, setHeadlist2] = useState<{ id: string; title: string; }[]>([]); // 컬럼리스트 변경상태
const [buttonChk, setButtonChk] = useState(true)
const [fetchData, setFetchData] = useState<any>(); // api 호출여부
const contentRef = useRef<HTMLDivElement>(null);
const [isMainSsc, setIsMainSsc] = useState(false); // 중점우수토실 여부
const [isDrtwSewagePump, setIsDrtwSewagePump] = useState(false); // 오수펌프장 여부
// 🎯 InfilChart 및 RdiiChart 관련 상태 추가
const { setIsOpen: setIsModalInfil } = useInfilChartModalStore();
const { setIsOpen: setIsModalRdii } = useRdiiChartModalStore();
const [isClicked, setIsClicked] = useState(true); // 클릭 여부 상태
const [isLoading, setIsLoading] = useState(false); // 로딩 상태
const [chmbrXcnts, setChmbrXcnts] = useState<string>(''); // 로딩 상태
const [chmbrYdnts, setChmbrYdnts] = useState<string>(''); // 로딩 상태
const linkedFacilityFeatureRef = useRef<any>(null);
const linkedFacilityOriginalStyleRef = useRef<any>(null);
// 🎯 관로관리번호 추출
const pplMngNo = featureData?.values_?.fclt_mng_no || "";
const featureId = typeof featureData.getId === 'function'
? featureData.getId()
: featureData.fid;
// 🎯 데이터 전처리 메모이제이션
const processedData = useMemo(() => {
if (!featureData?.values_) return null;
return {
...featureData.values_,
discharge_pipe_spec: `직경: ${featureData.values_.pipe_diameter || '-'} / 높이: ${featureData.values_.pipe_hg || '-'} / 길이: ${featureData.values_.pipe_lt || '-'}`
};
}, [featureData?.values_]);
const clearLinkedFacilityHighlight = () => {
if (linkedFacilityFeatureRef.current) {
linkedFacilityFeatureRef.current.setStyle(linkedFacilityOriginalStyleRef.current ?? undefined);
linkedFacilityFeatureRef.current.changed?.();
}
linkedFacilityFeatureRef.current = null;
linkedFacilityOriginalStyleRef.current = null;
};
// 하수처리구역에 연결된 하수처리시설 피처를 찾아 기존 아이콘 위에 노랑 하이라이트를 덧씌운다.
const highlightLinkedFacility = (facilityCode: string | null | undefined) => {
clearLinkedFacilityHighlight();
if (!gisMap || !facilityCode) return;
const normalizedFacilityCode = String(facilityCode).trim();
const layers = gisMap.getLayers().getArray();
for (const layer of layers) {
const source = layer?.getSource?.();
if (!source?.getFeatures) continue;
const features = source.getFeatures();
const targetFeature = features.find((feature: any) => {
const featureId = feature?.getId?.();
const isFacilityLayer = typeof featureId === 'string' && featureId.startsWith('tb_il_swage_prcs_fclt.');
const featureFacilityCode = feature?.get?.('swtrmt_fclt_cd');
return isFacilityLayer && String(featureFacilityCode ?? '').trim() === normalizedFacilityCode;
});
if (targetFeature) {
const originalFeatureStyle = targetFeature.getStyle?.();
const featureLayer = targetFeature.getLayer?.();
const layerStyleFn = featureLayer?.getStyleFunction?.() ?? null;
linkedFacilityOriginalStyleRef.current = originalFeatureStyle;
linkedFacilityFeatureRef.current = targetFeature;
targetFeature.setStyle((feature: any, resolution: number) => {
let styles: any[] = [];
if (typeof originalFeatureStyle === 'function') {
const originalStyles = originalFeatureStyle(feature, resolution);
if (Array.isArray(originalStyles)) styles.push(...originalStyles);
else if (originalStyles) styles.push(originalStyles);
} else if (originalFeatureStyle) {
if (Array.isArray(originalFeatureStyle)) styles.push(...originalFeatureStyle);
else styles.push(originalFeatureStyle);
} else if (layerStyleFn) {
const layerStyles = layerStyleFn(feature, resolution);
if (Array.isArray(layerStyles)) styles.push(...layerStyles);
else if (layerStyles) styles.push(layerStyles);
}
styles.push(linkedFacilityHighlightStyle);
return styles;
});
targetFeature.changed?.();
return;
}
}
};
// 피쳐데이터의 현재 레이어 이름 가져오는 함수
const getSelectedLayers = () => {
let result = null;
const layers = gisMap.getLayers().getArray(); // gisMap에서 모든 레이어 가져오기
let featureLayer: any = null; // 피처가 속한 레이어를 찾기 위한 변수
layers.some((layer: any) => {
const source = layer.getSource();
// if (source && featureData && featureData.fid) {
// featureLayer = layer; // 피처가 속한 레이어 찾기
// }
if (source && source.hasFeature && featureData
&& typeof featureData.getId === 'function' && source.hasFeature(featureData)) {
featureLayer = layer; // 피처가 속한 레이어 찾기
return true; // 반복 종료
}
return false;
});
if (featureData['ol_uid']) {
// Gis 레이어 클릭 이벤트
setIsClicked(true);
const lyrTyNm = featureData.id_.split('.')[0];
switch (lyrTyNm) {
case 'tb_ms_swage_ppl': {
result = '하수관로';
setHeadlist(MsSwagePplCol);
setLayerSpecificNm(featureData.values_.fclt_mng_no);
break;
}
case 'tb_ms_swage_mhl': {
result = '하수맨홀';
setHeadlist(MsSwageMhlCol);
setLayerSpecificNm(featureData.values_.fclt_mng_no);
break;
}
case 'tb_ms_swage_pmstn': {
const drtwPumpCode = DRTW_SEWAGE_PUMP_USE_CODES.find(code => code.text === '오수펌프장');
if (drtwPumpCode && featureData.values_.usg_cd.trim() === drtwPumpCode.val) {
setIsDrtwSewagePump(true);
setHeadlist(DrtwSewagePumpCol);
}
else {
setIsDrtwSewagePump(false);
setHeadlist(ExclnSewagePumpCol);
}
setLayerSpecificNm(featureData.values_.fclt_mng_no);
result = '하수펌프장';
break;
}
case 'tb_il_swage_prcs_fclt': {
result = '하수처리시설';
setHeadlist(SwtrmtFcltCol);
setLayerSpecificNm(featureData.values_.swtrmt_fclt_cd);
break;
}
case 'tb_ms_swage_prcs_zone': {
result = '하수처리구역';
setHeadlist(SwtrmtPrcsZoneCol);
setLayerSpecificNm(featureData.values_.swtrmt_fclt_cd);
break;
}
case 'tb_ms_stofcbr': {
result = '우수토실';
setHeadlist(stofcbrCol);
setLayerSpecificNm(featureData.values_.fclt_mng_no);
break;
}
case 'om_dt_untrt_swg_rain': {
result = '중점우수토실';
setHeadlist(untrtSwgRainCol1);
setHeadlist2(untrtSwgRainCol2);
setLayerSpecificNm(featureData.values_.manage_no);
break;
}
default: {
result = featureLayer ? featureLayer.get('lyrNm') : null;
break;
}
}
}
else {
// SearchModal 클릭 이벤트
setIsClicked(false);
const lyrTyNm = featureData.lyrTypeNm;
switch (lyrTyNm) {
case 'tb_ms_swage_ppl': {
result = '하수관로';
setHeadlist(MsSwagePplCol2)
setLayerSpecificNm(featureData.swtrmtFcltCd);
break;
}
case 'tb_ms_swage_mhl': {
result = '하수맨홀';
setHeadlist(MsSwageMhlCol2)
setLayerSpecificNm(featureData.fcltMngNo);
break;
}
case 'tb_ms_swage_pmstn': {
const drtwPumpCode = DRTW_SEWAGE_PUMP_USE_CODES.find(code => code.text === '오수펌프장');
if (drtwPumpCode && featureData.usgCd.trim() === drtwPumpCode.val) {
setIsDrtwSewagePump(false);
setHeadlist(ExclnSewagePumpCol2)
}
else {
setIsDrtwSewagePump(true);
setHeadlist(DrtwSewagePumpCol2)
}
setLayerSpecificNm(featureData.fcltMngNo);
result = '하수펌프장';
break;
}
case 'tb_il_swage_prcs_fclt': {
result = '하수처리시설';
setHeadlist(SwtrmtFcltCol2)
setLayerSpecificNm(featureData.swtrmtFcltCd);
break;
}
case 'tb_ms_swage_prcs_zone': {
result = '하수처리구역';
setHeadlist(SwtrmtPrcsZoneCol2);
setLayerSpecificNm(featureData.swtrmtFcltCd);
break;
}
case 'tb_ms_stofcbr': {
result = '우수토실';
setHeadlist(stofcbrCol2)
setLayerSpecificNm(featureData.fcltMngNo);
break;
}
}
}
return result;
};
useEffect(() => {
const fetchData = async () => {
if (!featureId) return;
try {
const selectedLayerName = getSelectedLayers(); // 레이어 이름 가져오기
setLayerNm(selectedLayerName); // 상태 업데이트
let data;
if (featureData['ol_uid']) data = featureData.values_; // Gis 레이어 클릭 이벤트일 때
else data = featureData; // SearchModal 클릭 이벤트일 때
// 하수맨홀인 경우 용도코드를 텍스트로 변환
if (selectedLayerName === '하수맨홀' && data) {
// mqlt_cd 값 변환 (재질코드)
if (data.mqlt_cd) {
const materialCodeItem = MS_SWAGE_MHL_CODES.find(
code => code.val === data.mqlt_cd.trim()
);
if (materialCodeItem) data.mqlt_cd = `${materialCodeItem.text}`;
}
} else {
// 중점우수토실인 경우 구분 값
if (selectedLayerName === '중점우수토실' && data) {
if (data.mesure_spot_se) {
const mesureSpotSe = [{id:'0', text: '합류식'}, {id:'1', text: '분류식'}]
const mesureSpotSeItem = mesureSpotSe.find(
code => code.id === data.mesure_spot_se.trim()
);
if (mesureSpotSeItem) data.mesure_spot_se = mesureSpotSeItem.text;
}
}
}
// 🎯 기본 데이터 포맷팅
const formattedData = formatDisplayData(data);
setFetchData(formattedData);
setTableData(formattedData);
switch (selectedLayerName) {
case '하수관로':
setButtonChk(true)
// ✅ codeMapper 사용하여 API 결과도 변환
setTableData(codeMapper({
layerName: selectedLayerName,
data: { ...formattedData }
}));
break;
case '하수맨홀':
setButtonChk(true)
// ✅ codeMapper 사용하여 API 결과도 변환
setTableData(codeMapper({
layerName: selectedLayerName,
data: { ...formattedData }
}));
break;
case '하수펌프장':
setButtonChk(true)
setTableData(codeMapper({
layerName: selectedLayerName,
data: { ...formattedData }
}));
break;
case '하수처리시설':
setButtonChk(false)
break;
case '하수처리구역':
setButtonChk(false)
break;
case '우수토실':
setButtonChk(true)
setIsMainSsc(false)
break;
case '중점우수토실':
setButtonChk(false)
setIsMainSsc(true)
// 🎯 processedData도 포맷팅하여 설정
const processedFormattedData = formatDisplayData(processedData);
setFetchData([processedFormattedData])
setTableData(processedFormattedData);
// setIsLoading(false); // 기본 렌더링 완료
setTableData(codeMapper({
layerName: selectedLayerName,
data: { ...processedFormattedData }
}));
setChmbrXcnts(data.chmbr_xcnts);
setChmbrYdnts(data.chmbr_ydnts);
break;
default:
break;
}
// 자체입력정보 API 엔드포인트 선택
let apiEndpoint = '';
const fcltMngNo = featureData?.values_?.fclt_mng_no || null;
const mngInstCd = featureData?.values_?.mng_inst_cd || null;
if (selectedLayerName === '하수관로') apiEndpoint = `v1/fcltppl/${fcltMngNo}/${mngInstCd}`;
else if (selectedLayerName === '우수토실') apiEndpoint = `v1/fcltstofcbr/${fcltMngNo}/${mngInstCd}`;
else if (selectedLayerName === '하수펌프장') apiEndpoint = `v1/fcltpump/${fcltMngNo}/${mngInstCd}`;
else if (selectedLayerName === '하수맨홀') apiEndpoint = `v1/fcltmhl/${fcltMngNo}/${mngInstCd}`;
// API에서 상세 데이터 가져오기
if (apiEndpoint) {
try {
const result = await getData(apiEndpoint);
if (result) {
const processedApiData = codeMapper({
layerName: selectedLayerName,
data: { ...result, ...tableData }
});
setFetchData(processedApiData);
}
} catch (apiError) {
console.error('API 데이터 로딩 중 오류:', apiError);
}
}
} catch (error) {
console.error('데이터 로딩 중 오류:', error);
}
};
fetchData();
}, [featureId, processedData]);
useEffect(() => {
const layerTypeNm = featureData?.values_
? featureData?.id_?.split('.')?.[0]
: featureData?.lyrTypeNm;
if (layerTypeNm === 'tb_ms_swage_prcs_zone') {
const facilityCode = featureData?.values_?.swtrmt_fclt_cd ?? featureData?.swtrmtFcltCd;
highlightLinkedFacility(facilityCode);
return () => {
clearLinkedFacilityHighlight();
};
}
clearLinkedFacilityHighlight();
return () => {
clearLinkedFacilityHighlight();
};
}, [featureData, gisMap]);
// Use the browser confirm dialog for lightweight save confirmation.
const handleSaveClick = () => {
const shouldSave = window.confirm('정보를 저장하시겠습니까?');
if (!shouldSave) return;
void btnClickEvent();
};
// 저장 버튼 이벤트
const btnClickEvent = async () => {
const bodyData: Record<string, any> = {}
if (buttonChk && contentRef.current) {
const inputElements = contentRef.current.querySelectorAll('input');
const selectElements = contentRef.current.querySelectorAll('select');
// 카멜케이스 변환 함수
const toCamelCase = (str: string) =>
str.replace(/_([a-z])/g, (_, char) => char.toUpperCase());
// select 태그의 값 가져오기
selectElements.forEach((select) => {
const camelCaseId = toCamelCase(select.id);
bodyData[camelCaseId] = select.value;
});
// // input 태그의 값 가져오기
inputElements.forEach((input) => {
const camelCaseId = toCamelCase(input.id);
bodyData[camelCaseId] = input.value;
});
// 레이어명에 따라 API 엔드포인트 분기처리
let apiEndpoint = '';
let result = null;
if (layerNm === '하수관로') apiEndpoint = 'v1/fcltppl';
else if (layerNm === '우수토실') apiEndpoint = 'v1/fcltstofcbr';
else if (layerNm === '하수펌프장') apiEndpoint = 'v1/fcltpump';
else if (layerNm === '하수맨홀') apiEndpoint = 'v1/fcltmhl';
bodyData['fcltMngNo'] = featureData.values_.fclt_mng_no.toString() || null;
bodyData['mngInstCd'] = featureData.values_.mng_inst_cd.toString() || null;
// 빈 키 제거
if (bodyData[""] !== undefined) delete bodyData[""];
// 🎯 우수토실일 경우 prrtymngYn 값 변환
if (layerNm === '우수토실' && bodyData.prrtymngYn) {
if (bodyData.prrtymngYn === '중점우수토실') {
bodyData.prrtymngYn = 'Y';
} else if (bodyData.prrtymngYn === '우수토실') {
bodyData.prrtymngYn = 'N';
}
}
try {
result = await postData(apiEndpoint, bodyData);
if (result) {
alert('저장되었습니다');
setFetchData(result.data || result);
} else {
alert('저장에 실패했습니다');
console.error('API 응답 오류:', result);
}
} catch (error) {
alert('저장 중 오류가 발생했습니다');
console.error('API 요청 오류:', error);
}
}
}
// 레이어명에 따라 상세페이지 렌더링
const ContentComponent = layerNm ? getLayerContentComponent(layerNm) : null;
return (
<div id="popup" className="absolute top-0 right-0 w-[350px] h-[calc(100vh-5rem)] bg-white border-r border-gray-300 flex flex-col overflow-hidden transition-all duration-300 z-[50]">
<LoadingOverlay isLoading={isLoading} />
<div className="card-header bg-primary flex items-center justify-between relative p-4">
<div className="flex items-center space-x-4 flex-1">
<h3 className="text-2xl font-medium text-white text-label whitespace-nowrap">{layerNm}
{tableData?.usg_cd && (
<>
({tableData.usg_cd})
</>
)} 정보 <br />관리번호 : {layerSpecificNm}
</h3>
</div>
<div className="flex items-center space-x-3">
{/* 하수관로일 때만 Infil/RDII 버튼 표시 */}
{layerNm === '하수관로' && (
<>
<Button onClick={() => setIsModalInfil(true)}
className="py-1 text-xl text-blue-600 border border-blue-600 rounded hover:bg-gray-700"
color="white"
size="sm"
>
침입수분석
</Button>
<Button onClick={() => setIsModalRdii(true)}
className="py-1 text-xl text-gray-600 border border-gray-600 rounded hover:bg-gray-700 mr-4"
color="white"
size="sm"
>
RDII분석
</Button>
</>
)}
{layerNm === '하수맨홀' && (
<>
<Button
color="white"
className="px-2 py-1 text-xl text-gray-600 border border-gray-600 rounded hover:bg-gray-700 mr-4"
onClick={() => {
setIsLoading(true);
searchUpperPpls(gisMap, layerSpecificNm).finally(() => setIsLoading(false));
}}
>
상류관로 보기
</Button>
</>
)}
</div>
<Button
onClick={onClose}
className="flex items-center space-x-4 text-white hover:text-gray-300 focus:outline-none"
size="xs"
>
✕
</Button>
</div>
<div className="overflow-y-auto overflow-x-hidden flex-1" ref={contentRef}>
<VerticalTable
headName='기본정보'
tableName1={layerNm === '중점우수토실' ? "측정지점" : undefined}
tableData={[tableData]}
headlist={headlist}
pageSize={20}
total={0}
cellClick={undefined}
onPageChange={function (): void {
throw new Error("Function not implemented.");
}}
// 두 번째 테이블 조건부 props
showSecondTable={layerNm === '중점우수토실' ? true : false}
tableName2={layerNm === '중점우수토실' ? "시설현황" : undefined}
tableData2={layerNm === '중점우수토실' && tableData ? [tableData] : []}
headlist2={layerNm === '중점우수토실' ? headlist2 : undefined}
pageSize2={20}
total2={0}
isEditable2={buttonChk}
/>
{ContentComponent &&
<ContentComponent
featureData={fetchData}
isMainSsc={isMainSsc}
isDrtwSewagePump={isDrtwSewagePump}
onSaveClick={handleSaveClick}
chmbrXcnts={chmbrXcnts}
chmbrYdnts={chmbrYdnts}
gisObject={featureData.values_}
/>
}
</div>
<InfilChart pplMngNo={pplMngNo} />
<RdiiChart pplMngNo={pplMngNo} />
</div>
)
}
export default FeatureClickEventPopup;
@@ -1,69 +0,0 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수펌프장)
*
* 📄 sewagePump
* 하수펌프장 정보 컴포넌트
*
* - 빗물펌프장 및 오수펌프장 구분에 따른 자체 입력 정보 표시 및 편집 기능
* - VerticalTable을 통한 입력 폼 렌더링
* - 저장 버튼을 통한 데이터 저장 기능
*/
import { VerticalTable } from '@/components/ui/verticalTable';
import React, { useState, useEffect } from 'react'
// 빗물펌프장 자체 입력 정보
const exclnInputColumn = [
{ id: "swtrmtZoneNm", title: "배수구역", type: "input" },
{ id: "mltvDtdvNm", title: "배수분구", type: "input" },
{ id: "designFrqYr", title: "설계빈도(년)", type: "input", placeholder: "예: 10", isNumber: true, length: 4 },
]
// 오수펌프장 자체 입력 정보
const drtwInputColumn = [
{ id: "swtrmtZoneNm", title: "처리구역", type: "input" },
{ id: "mltvDtdvNm", title: "처리분구", type: "input" },
]
const sewagePump = ({ featureData, isDrtwSewagePump, onSaveClick }: { featureData: any, isDrtwSewagePump?: boolean, onSaveClick?: () => void }) => {
const [formData, setFormData] = useState<any>({});
// featureData가 변경될 때 formData 초기화
useEffect(() => {
if (featureData) {
setFormData(featureData);
}
}, [featureData]);
// 폼 데이터가 변경될 때 호출될 핸들러
const handleFormDataChange = (newFormData: any) => {
setFormData(newFormData);
};
// 저장 버튼 클릭 시 최신 formData를 부모에게 전달
const handleSaveClick = () => {
if (onSaveClick) {
// 저장 전에 formData를 부모 컴포넌트의 handleSaveClick에 전달
onSaveClick();
}
};
return (
<VerticalTable
headName='입력정보'
tableData={featureData ? [formData] : []}
headlist={isDrtwSewagePump ? drtwInputColumn : exclnInputColumn}
pageSize={0}
total={0}
children={undefined}
cellClick={undefined}
isEditable={true}
onPageChange={function (page: number): void {
throw new Error('Function not implemented.');
}}
showSaveButton={true}
onSaveClick={handleSaveClick}
onFormDataChange={handleFormDataChange} // 폼 데이터 변경 콜백 추가
/>
)
}
export default sewagePump
@@ -1,126 +0,0 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수맨홀)
*
* 📄 Smh
* 하수맨홀 정보 컴포넌트
*
* - 하수맨홀의 자체입력정보 표시 및 편집 기능
* - VerticalTable을 통한 입력 폼 렌더링
* - 상류관로 검색 및 지도 포커스 기능
* - 저장 버튼을 통한 데이터 저장 기능
*
* 🔗 사용 API
* - 상류관로 조회: GET /api/v1/fcltmhl/upperPpls/{fcltMngNo} (sewerage-service)
*/
import { VerticalTable } from '@/components/ui/verticalTable';
import { useEffect, useState } from 'react';
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { getData } from '@/components/lib/api';
// 상류관로 보기 함수 (FeatureClickEventPopup에서 사용)
export async function searchUpperPpls(gisMap: any, fcltMngNo: any) {
let data: any = null;
try {
data = await getData(`v1/fcltmhl/upperPpls/${fcltMngNo}`);
} catch (error) {
console.error("JSON parsing error:", error);
alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
return;
}
if (!data) {
alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
return;
}
const select: any = GisApp.Module.select;
let foundFeature: any = null;
(data || []).forEach((item: any) => {
const pplMngNo = item.ppl_mng_no?.trim?.();
gisMap
.getLayers()
.getArray()
.filter((e: any) => e.get("lyrTyNm") == "tb_ms_swage_ppl")
.forEach((layer: any) => {
const source = layer.getSource?.();
if (source?.getFeatures) {
const features = source.getFeatures();
for (const f of features) {
if (f.values_.fclt_mng_no?.trim?.() === pplMngNo) {
foundFeature = f;
break;
}
}
}
});
if (foundFeature) select?.selectMultiFeature?.(foundFeature, true);
});
if (!foundFeature) alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
const geometry = foundFeature?.getGeometry();
if (geometry) {
const extent = geometry.getExtent();
gisMap.getView().fit(extent, {
duration: 300,
padding: [50, 50, 50, 50],
maxZoom: 14,
constrainResolution: true,
nearest: true,
});
}
}
const inputColumn = [
{ id: "swtrmtZoneNm", title: "처리구역", type: "input" },
{ id: "mltvZoneNm", title: "배수구역", type: "input" },
{ id: "mltvDtdvNm", title: "배수분구", type: "input" },
{ id: "layCd", title: "매설위치", type: "select", options: ["도로", "하상"] },
];
const Smh = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: () => void }) => {
const [formData, setFormData] = useState<any>({});
// featureData가 변경될 때 formData 초기화
useEffect(() => {
if (featureData) {
setFormData(featureData);
}
}, [featureData]);
// 폼 데이터가 변경될 때 호출될 핸들러
const handleFormDataChange = (newFormData: any) => {
setFormData(newFormData);
};
// 저장 버튼 클릭 시 최신 formData를 부모에게 전달
const handleSaveClick = () => {
if (onSaveClick) {
// 저장 전에 formData를 부모 컴포넌트의 handleSaveClick에 전달
onSaveClick();
}
};
return (
<VerticalTable
headName='입력정보'
tableData={featureData ? [formData] : []}
headlist={inputColumn}
pageSize={20}
total={0}
children={undefined}
cellClick={undefined}
isEditable={true}
onPageChange={function (page: number): void {
throw new Error("Function not implemented.");
}}
showSaveButton={true}
onSaveClick={handleSaveClick}
onFormDataChange={handleFormDataChange} // 폼 데이터 변경 콜백 추가
/>
)
}
export default Smh;
@@ -1,37 +0,0 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (빗물펌프)
*
* 📄 StormWaterPump
* 빗물펌프 정보 컴포넌트
*
* - 빗물펌프의 자체입력정보 표시 및 편집 기능
* - VerticalTable을 통한 입력 폼 렌더링
*/
import { VerticalTable } from '@/components/ui/verticalTable';
import React from 'react'
const inputColumn = [
{ id: "swtrmtZoneNm", title: "처리구역", type: "input" },
{ id: "mltvDtdvNm", title: "배수분구", type: "input" },
]
// 빗물펌프
const StormWaterPump = ({ featureData }: { featureData: Record<string, any> }) => {
const validKey = inputColumn.map((item) => item.id);
const filteredData = featureData && Object.entries(featureData).filter(([key]) => validKey.includes(key)).reduce((obj: Record<string, any>, [key, value]) => { obj[key] = value; return obj; }, {});
return (
<VerticalTable
headName='입력정보'
tableData={[filteredData]}
headlist={inputColumn}
pageSize={0}
total={0}
children={undefined}
cellClick={undefined}
isEditable={true}
onPageChange={function (page: number): void {
throw new Error('Function not implemented.');
}}
/>
)
}
export default StormWaterPump
-169
View File
@@ -1,169 +0,0 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지
*
* 📄 Map Page
* 지도 페이지 컴포넌트
*
* - OpenLayers 기반 GIS 지도 렌더링 및 상호작용
* - 레이어 트리 데이터 관리 및 동적 레이어 표시/숨김
* - 피처 검색 및 선택 기능 (SearchModal, ProcSearchBar)
* - 피처 클릭 이벤트 처리 및 상세 정보 팝업 표시
* - 수심(WaterDepth) 레이어 애니메이션 재생 기능
* - Zustand를 통한 검색 모달 상태 관리
*
* 🔗 사용 API
* - 레이어 설정 조회: GET /api/v1/map/lyrStngs/{lyrGrp} (sewerage-service)
*/
"use client";
import Gis from "@common/business_components/ui/map/Gis";
import { getRawJson } from "../../../components/lib/api";
import { useCallback, useEffect, useRef, useState } from "react";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import SearchModal from "./searchModal/searchModal";
import FeatureClickEventPopup from "./components/popupComponent/featureClickEventPopup";
import { WaterDepth, WATER_DEPTH_LAYER_TYPES } from "./waterDepth/waterDepth";
import { ProcSearchBar } from "@common/business_components/ui/map/searchBar";
import useSearchModalStore from "@/store/useSearchModalStore";
const Map = () => {
const { isSearchModalOpen, setIsSearchModalOpen } = useSearchModalStore();
const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정
const gisMaps = useRef<any | null>(null);
const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태
const [featureData, setFeatureData] = useState(null); // 선택된 피쳐 데이터
const [showWaterDepth, setShowWaterDepth] = useState(false); // WaterDepth 표시 여부
const [isPlaying, setIsPlaying] = useState(false);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK");
setTreeData(data);
} catch (error) {
console.error('JSON parsing error:', error);
// JSON 파싱에 실패한 경우, 원본 텍스트 또는 null 반환
throw new Error('Invalid JSON response from server');
}
};
// SearchModal, GisContent 등에서 호출
const onFeatureSelect = (data: any) => {
let map = gisMaps.current;
if (!map) {
console.warn("지도가 초기화되지 않았습니다.");
return;
}
// 항상 같은 select 객체 사용
const select = GisApp.Module.select as any;
if (!select) return;
// 1. 기존 선택 초기화
select.resetSelectedFeature();
// 2. data가 OpenLayers Feature 인스턴스인지 확인
let foundFeature = null;
if (
data &&
typeof data.getGeometry === "function" &&
typeof data.setStyle === "function"
) {
// 이미 Feature 인스턴스라면 그대로 사용
foundFeature = data;
} else {
// 아니면 featureId로 맵에서 Feature 인스턴스를 찾아서 사용
const featureId = data.fid || data.ftrIdn || data.id;
map
.getLayers()
.getArray()
.forEach((layer: any) => {
const source = layer.getSource?.();
if (source?.getFeatureById) {
const feature = source.getFeatureById(String(featureId).trim());
if (feature) foundFeature = feature;
}
});
}
// 3. 피처 선택
if (foundFeature) {
let geometry = foundFeature.getGeometry();
if (geometry) {
let extent = geometry.getExtent();
map.getView().fit(extent, {
duration: 300,
padding: [50, 50, 50, 50],
maxZoom: 16,
constrainResolution: true,
nearest: true,
});
}
select.selectFeature(foundFeature, true);
} else {
console.warn("OpenLayers Feature를 찾을 수 없습니다:", data);
}
setFeatureData(data);
setIsOpen(true);
setIsSearchModalOpen(false);
};
const facilityTypes = [{ text: "하수처리시설", val: "tb_il_swage_prcs_fclt" }];
// WaterDepth 표시 여부를 결정하는 함수
const handleWaterDepthVisibility = useCallback((checkedIds: string[], checkedNodesData: any[]) => {
const waterDepthLayers = checkedNodesData.filter(node =>
WATER_DEPTH_LAYER_TYPES.includes(node.data?.lyrTyNm)
);
const hasWaterDepthLayer = waterDepthLayers.length > 0;
setShowWaterDepth(hasWaterDepthLayer);
}, []);
// 체크된 레이어 변경 처리 함수
const handleCheckedLayersChange = useCallback((checkedIds: string[], checkedNodesData: any[]) => {
handleWaterDepthVisibility(checkedIds, checkedNodesData);
}, [handleWaterDepthVisibility]);
return (
<>
<div className="bg-container">
<Gis
treeData={treeData}
isSearch={true}
gisMaps={gisMaps}
isMiniMap={false}
searchView={SearchModal}
detailView={FeatureClickEventPopup}
onFeatureSelect={onFeatureSelect}
featureData={featureData}
setFeatureData={setFeatureData}
isOpen={isOpen}
setIsOpen={setIsOpen}
isSearchModalOpen={isSearchModalOpen}
setIsSearchModalOpen={setIsSearchModalOpen}
onCheckedLayersChange={handleCheckedLayersChange}
searchBar={<ProcSearchBar gisMaps={gisMaps} facilityTypes={facilityTypes} />}
waterDepth={showWaterDepth ? WaterDepth : undefined}
isPlaying={isPlaying}
setIsPlaying={setIsPlaying as any}
targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any}
checkInterval={2500}
showZoomLevel={true}
defaultZoom={7.5}
defaultLonLat={[127.75, 36]}
/>
</div>
</>
);
};
export default Map;
@@ -1,66 +0,0 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭
*
* 📄 BasicTab Component
* Next.js App Router (app/) 기반 검색 모달의 기본정보 탭 컴포넌트
*
* - 기본정보 검색을 위한 구분 선택 및 조건 입력 UI 제공
* - 하수관로, 하수처리시설, 하수처리구역, 하수맨홀, 하수펌프장, 우수토실 등 다양한 유형별 컴포넌트 렌더링
* - useSearchModalStore를 통해 상태 관리 및 사용자 상호작용 처리
*/
import React from 'react';
import { SelectBox } from '@common/business_components/ui';
import { Label } from '@common/components';
import SwagePpl from './swagePpl';
import SwagePrcsFclt from './swagePrcsFclt';
import SwagePrcsZone from './swagePrcsZone';
import SwageMhl from './swageMhl';
import SwagePmstn from './swagePmstn';
import Stofcbr from './stofcbr';
import useSearchModalStore from '@/store/useSearchModalStore';
// 기본정보 '구분' 선택 목록
export const BASIC_TYPES = [
{ val: "tb_ms_swage_ppl", text: "하수관로" },
{ val: "tb_il_swage_prcs_fclt", text: "하수처리시설" },
{ val: "tb_ms_swage_prcs_zone", text: "하수처리구역" },
{ val: "tb_ms_swage_mhl", text: "하수맨홀" },
{ val: "tb_ms_swage_pmstn", text: "하수펌프장" },
{ val: "tb_ms_stofcbr", text: "우수토실" },
];
const BasicTab: React.FC = () => {
const { basicType, setBasicType } = useSearchModalStore();
return (
<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="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
className="w-[150px] mr-3"
selectClass="rounded-lg bg-white"
label=""
selectArray={BASIC_TYPES}
onSelectValue={(value: string | number) =>
setBasicType(String(value))
}
value={basicType}
selectContentClass="z-[101]"
/>
</div>
</div>
{/* 조회 조건: 구분 선택과 같은 행에 배치 */}
{basicType === "tb_ms_swage_ppl" && (<SwagePpl />)} {/* 하수관로 */}
{basicType === "tb_il_swage_prcs_fclt" && (<SwagePrcsFclt />)} {/* 하수처리시설 */}
{basicType === "tb_ms_swage_prcs_zone" && (<SwagePrcsZone />)} {/* 하수처리구역 */}
{basicType === "tb_ms_swage_mhl" && (<SwageMhl />)} {/* 하수맨홀 */}
{basicType === "tb_ms_swage_pmstn" && (<SwagePmstn />)} {/* 하수펌프장 */}
{basicType === "tb_ms_stofcbr" && (<Stofcbr />)} {/* 우수토실 */}
</div>
);
};
export default React.memo(BasicTab);
@@ -1,98 +0,0 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 유지관리 정보 탭
*
* 📄 Operation Tab
* Next.js App Router (app/) 기반 검색 모달 유지관리 정보 탭 컴포넌트
*
* - 업무구분 선택 (보수 및 갱신, 점검 및 진단, 청소 및 준설)
* - 사업명 입력 및 수행기간 범위 선택
* - 사업 유형 필터링 기능
* - 조회 버튼을 통한 데이터 검색 트리거
* - Zustand를 통한 상태 관리 및 필터 값 저장
* - Enter 키 입력 시 자동 조회 지원
*/
import React from 'react';
import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox';
import { SelectBox } from '@common/business_components/ui';
import { Button, Input, Label } from '@common/components';
import useStore from '@/store/useStore';
import useSearchModalStore from '@/store/useSearchModalStore';
import useSearchModal from './useSearchModal';
import useOperationTab from './useOperationTab';
const OperationTab: React.FC = () => {
const { BIZ_TY_CD } = useStore();
const {
workType, setWorkType,
var1, setVar1, var2, setVar2,
yearFilter, setYearFilter,
} = useSearchModalStore();
const { handleSearch } = useSearchModal();
const { WORK_TYPES } = useOperationTab();
const BIZ_TY_CD_SELECT_ARRAY = BIZ_TY_CD.map(item => ({
val: item.lwpoCd,
text: item.lwpoCdNm,
}));
return (
<div className="relative flex flex-col border rounded-lg pt-5 px-5 w-full min-h-[10vh]">
<div className="flex items-center mb-3">
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">업무구분</Label>
<SelectBox
className="w-40 mr-8"
selectClass="rounded-lg bg-white text-xl"
label=""
selectArray={WORK_TYPES.map((w) => ({
val: w.val,
text: w.text,
}))}
onSelectValue={(val) => setWorkType(String(val))}
value={workType}
selectContentClass="z-[101]"
/>
</div>
<div className="flex items-center mb-6">
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">사업명</Label>
<Input
className="w-[100px] !text-xl placeholder:text-center mr-8"
placeholder="입력"
value={var1}
onChange={(e) => { setVar1(e.target.value); }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
<YearRangeSelectBox
start={yearFilter.start}
end={yearFilter.end}
onChange={range => setYearFilter({ ...yearFilter, start: range.start, end: range.end })}
startLabel="수행기간"
endLabel=""
/>
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">사업 유형</Label>
<SelectBox
className="w-60 mr-8"
selectClass="rounded-lg bg-white text-xl"
label=""
selectArray={[{ val: "0", text: "전체" }, ...BIZ_TY_CD_SELECT_ARRAY]}
value={var2}
onSelectValue={(val) => {
setVar2(String(val));
}}
selectContentClass="z-[101]"
/>
{/* 조회 버튼 */}
<div className="flex-1 flex justify-end">
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
</div>
</div>
</div>
)
};
export default React.memo(OperationTab);
@@ -1,108 +0,0 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 하수도정비기본계획보고서 탭 (셀 클릭)
*
* 📄 Report Files Modal
* Next.js App Router (app/) 기반 보고서 파일 모달 컴포넌트
*
* - 하수도정비 기본계획 보고서 파일 목록 표시
* - 파일명 및 등록일자 정보 테이블 출력
* - 페이지네이션 및 로딩 오버레이 지원
* - 파일 클릭 이벤트 처리 및 상세 조회
* - Zustand를 통한 모달 상태 및 데이터 관리
* - 모달 열림/닫힘 시 데이터 초기화 기능
*/
'use client'
import React, { useEffect } from 'react';
import LoadingOverlay from '@/components/ui/LoadingBar';
import useReportTab from './useReportTab';
import { useReportTabStore } from '@/store/useReportTabStore';
import { UiTable } from '@common/business_components/ui';
import { UiModal } from "@common/business_components";
import useSearchModalStore from '@/store/useSearchModalStore';
type ModalOpenProp = { clsfSystmSn: string } // 자료관 기본계획 일련번호
const ReportFilesModal: React.FC<ModalOpenProp> = ({ clsfSystmSn }: ModalOpenProp) => {
// 하수도정비 기본계획 보고서 파일목록
const REPORT_FILES_HEADLIST = [
{ id: "no", title: "NO" },
{ id: "fileTitle", title: "파일명" },
{ id: "regDt", title: "등록일자" },
];
const {
curPage, setCurPage,
isLoading,
isResetPage,
isResetSelection,
setIsResetPage,
setIsResetSelection
} = useSearchModalStore();
const {
isOpen: isModalOpen,
files,
dcFiles: filesOrigin,
setIsOpen: setIsModalOpen,
setFiles,
fetchDcFiles
} = useReportTabStore();
const { getFiles, handleCellClick } = useReportTab();
useEffect(() => {
fetchDcFiles(clsfSystmSn);
}, [clsfSystmSn]);
useEffect(() => {
if (isModalOpen === true) {
getFiles(); // 파일 목록 조회
} else {
setFiles([]); // 모달이 닫힐 때 파일 목록 초기화
setCurPage(1); // 페이지를 1로 초기화
setIsResetPage(true); // 페이지를 초기화
setIsResetSelection(true); // 선택 초기화
}
}, [filesOrigin, isModalOpen, curPage]);
const renderBody = () => (
<>
<div className="flex-1 px-2 pb-2">
<LoadingOverlay isLoading={isLoading} />
<UiTable
headName={(() => {
return (<>조회 결과 ({filesOrigin.length.toLocaleString('ko-KR')})건</>);
})()
}
tableData={files}
headlist={REPORT_FILES_HEADLIST}
pageSize={10}
total={filesOrigin.length}
cellClick={handleCellClick}
onPageChange={setCurPage}
resetPageNo={isResetPage}
resetSelection={isResetSelection}
/>
</div>
</>
);
return (
<UiModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
headerTitle=""
bodyClass={`
min-h-[50vh] max-h-[90dvh]
max-w-[750px]
h-auto w-auto
overflow-auto
z-[5000]
flex items-start
`}
body={renderBody()}
noFooterYn='Y'
/>
);
};
export default React.memo(ReportFilesModal);
@@ -1,272 +0,0 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 하수도정비기본계획보고서 탭
*
* 📄 Report Tab
* Next.js App Router (app/) 기반 검색 모달 보고서 탭 컴포넌트
*
* - 하수도정비 기본계획 보고서 목록 표시 및 필터링
* - 시도/시군구/보고서 구분/승인년도 기반 검색 기능
* - 테이블 데이터 표시 및 페이지네이션 지원
* - 보고서 클릭 시 파일 목록 모달 열기
* - Zustand를 통한 모달 상태 및 데이터 관리
* - 로딩 오버레이 및 선택 초기화 기능
*/
'use client';
import React, { useEffect, useState } from 'react';
import { Button, Label } from '@common/components';
import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox';
import { SelectBox, UiTable } from '@common/business_components/ui';
import LoadingOverlay from '@/components/ui/LoadingBar';
import useReportTab from './useReportTab';
import useStore from '@/store/useStore';
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
import useSearchModalStore from '@/store/useSearchModalStore';
import { useReportTabStore } from '@/store/useReportTabStore';
import ReportFilesModal from './reportFilesModal'; // 파일목록모달
// 하수도정비 기본계획 보고서
export const reportTableHeadlist = [
{ id: "no", title: "NO" },
{ id: "sido", title: "시도" },
{ id: "sigungu", title: "시군구" },
{ id: "approvalYear", title: "승인년도" },
{ id: "plantZoneGubun", title: "구분" },
{ id: "reportGubun", title: "보고서구분" },
{ id: "fileName", title: "파일명" },
{ id: "regDate", title: "등록일자" },
];
interface ReportTabProps {
open?: boolean;
isSearch?: boolean;
ctpvNm?: string;
sggNm?: string;
}
const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sggNm }) => {
// const [isLoading, setIsLoading] = useState(false);
const [isResetPage, setIsResetPage] = useState<boolean>(false);
const [isResetSelection, setIsResetSelection] = useState<boolean>(false);
const [showTable, setShowTable] = useState(false);
// const [tableData, setTableData] = useState<any[]>([]);
// const [tableCount, setTableCount] = useState(0);
// const [curPage, setCurPage] = useState(1);
// const [yearFilter, setYearFilter] = useState({ start: "", end: "" });
const [ctpvCd, setCtpvCd] = useState('0');
const [sggCd, setSggCd] = useState('0');
const [reportGubun, setReportGubun] = useState("0");
const [clsfSystmSn, setClsfSystmSn] = useState(''); // 자료관 기본계획 일련번호
const {
tableData,
tableCount,
isLoading,
yearFilter,
curPage,
setTableData,
setTableCount,
setIsLoading,
setYearFilter,
setCurPage
} = useSearchModalStore();
const { ctpv, sgg } = useStore();
const {
isOpen: isReportFilesModalOpen,
setIsOpen: setIsReportFilesModalOpen,
dcTree: reportOriginData,
fetchDcTree
} = useReportTabStore(); // 모달 상태 관리 스토어
const { REPORT_TYPES, flattenFolders, addFileName } = useReportTab();
useEffect(() => {
fetchDcTree("기본계획"); // 하수정비기본계획 report
}, []);
useEffect(() => {
// filterParams 초기화
setCtpvCd('0');
setSggCd('0');
setReportGubun('0');
setYearFilter({ start: "", end: "" });
setShowTable(false);
setTableData([]);
setTableCount(0);
setCurPage(1); // 페이지를 1로 초기화
setIsResetPage(true); // 페이지를 초기화
setIsResetSelection(true); // 선택 초기화
}, [open]);
useEffect(() => {
const newCtpvCd = ctpv.find((n) => n.text === ctpvNm)?.val || '0';
const newSggCd = sgg.find((n) => n.text === sggNm)?.val || '0';
setCtpvCd(newCtpvCd);
setSggCd(newSggCd);
}, [ctpvNm, sggNm]);
// ctpvCd, sggCd가 업데이트된 후에 fetchFilteredData 실행
useEffect(() => {
if (!isSearch && ctpvCd && sggCd) {
fetchFilteredData();
}
}, [reportOriginData, ctpvCd, sggCd, isSearch]);
// 페이지 변경 시마다 데이터 fetch
useEffect(() => {
// showTable이 true일 때만 fetch (최초 렌더 방지)
setIsResetPage(false);
if (showTable) fetchFilteredData();
}, [curPage]);
const handleCtpvCdChange = (sidoId: string) => {
setCtpvCd(sidoId);
setSggCd('0');
}
// 조회 버튼 클릭 시 1페이지로 이동 및 데이터 초기화
const handleSearch = () => {
setCurPage(1); // 페이지를 1로 초기화
fetchFilteredData();
setIsResetPage(true); // 페이지를 초기화
setIsResetSelection(false); // 선택 초기화
setShowTable(true);
};
// 하수도정비기본계획 테이블 cellClick 핸들러
const handleReportCellClick = (index: number | undefined) => {
if (typeof index === "number" && tableData[index]) {
const reportData = tableData[index];
if (reportData.fileSn !== "") {
setClsfSystmSn(reportData.clsfSystmSn); // 자료관 기본계획 일련번호 설정
setIsReportFilesModalOpen(true); // 모달 열기
}
}
};
// 검색 API 호출 함수
const fetchFilteredData = async () => {
setIsLoading(true); // 로딩 시작
setIsResetSelection(true); // 선택 초기화
try {
const reportData = flattenFolders(reportOriginData);
let filterReportData = reportData;
if (ctpvCd !== '0') { // 시도검색
const sidoOption = ctpv.find((n) => n.val.trim() === ctpvCd.trim());
filterReportData = filterReportData.filter(item => item.sido === sidoOption?.text);
}
if (sggCd !== '0') { // 시군구검색
const sigunguOption = sgg.find((n) => String(n.val) === String(sggCd));
filterReportData = filterReportData.filter(item => item.sigungu === sigunguOption?.text);
}
if (reportGubun !== "0") { // 보고서구분검색
const reportGubunOption = REPORT_TYPES.find((n) => n.val === reportGubun);
filterReportData = filterReportData.filter(item => item.reportGubun === reportGubunOption?.text);
}
const { start, end } = yearFilter;
if (start && !end) //승인년도검색
filterReportData = filterReportData.filter(item => (item.approvalYear) ? item.approvalYear >= yearFilter.start + "년" : null);
else if (!start && end)
filterReportData = filterReportData.filter(item => (item.approvalYear) ? item.approvalYear <= yearFilter.end + "년" : null);
else if (start && end) {
filterReportData = filterReportData.filter(item => {
return (item.approvalYear) ? item.approvalYear >= yearFilter.start + "년" && item.approvalYear <= yearFilter.end + "년" : null;
});
}
// if (gubun !== "") { // 구분검색 (추후 사용할 수도 있을 것 같아 남겨둠)
// filterReportData = filterReportData.filter(item => (item.plantZoneGubun) ? String(item.plantZoneGubun).includes(String(gubun)) : null);
// }
setTableCount(filterReportData.length || 0);
filterReportData = filterReportData.slice((curPage - 1) * 10, curPage * 10); // paging
filterReportData = filterReportData?.map((item, index) => ({ ...item, no: (curPage - 1) * 10 + index + 1 }));
addFileName(filterReportData).then(data => {
filterReportData = data;
setTableData(filterReportData || []);
setIsResetSelection(false); // 데이터 설정 후 선택 초기화 해제
});
setTableData(filterReportData || []);
setShowTable(true);
} catch (err) {
console.error('API ERROR:', err);
setTableData([]);
setIsResetSelection(false); // 에러 시에도 선택 초기화 해제
} finally {
setIsLoading(false); // 로딩 종료
}
};
return (
<>
<div className={`relative flex flex-col border rounded-lg pt-5 px-5 w-full ${isSearch ? "min-h-[10vh]" : "min-h-[6vh]"}`}>
{isSearch && (
<>
<div className="flex items-center mb-3">
<CtpvSggPrcsFcltSelectBox
sido={ctpvCd}
sigungu={sggCd}
onSidoChange={(sidoId: string) => { handleCtpvCdChange(sidoId) }}
onSigunguChange={setSggCd}
setIsLoading={setIsLoading}
/>
</div>
</>
)}
<div className="flex items-center mb-6">
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">보고서 구분</Label>
<SelectBox className="w-44 mr-8"
selectClass="rounded-lg bg-white"
label=""
selectArray={[...REPORT_TYPES]}
value={reportGubun} onSelectValue={val => setReportGubun(String(val))}
selectContentClass="z-[101]" labelClass='whitespace-nowrap text-xl text-gray-600 pr-2'
/>
{/* 승인년도 필터 */}
<YearRangeSelectBox
start={yearFilter.start}
end={yearFilter.end}
onChange={range => setYearFilter({ ...yearFilter, start: range.start, end: range.end })}
startLabel="승인년도" endLabel=""
/>
{/* 조회 버튼 */}
<div className="flex-1 flex justify-end">
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
</div>
</div>
</div>
<div className="h-[79vh] w-full overflow-hidden rounded-lg border">
{/* 테이블2 영역 (가로/세로 스크롤 가능) */}
<div className="flex-1 px-2 pb-2">
<LoadingOverlay isLoading={isLoading} />
<UiTable
headName={(() => {
return (<>조회 결과</>);
})()
}
tableData={tableData}
headlist={reportTableHeadlist}
pageSize={10}
total={tableCount}
cellClick={handleReportCellClick}
onPageChange={setCurPage}
resetPageNo={isResetPage}
resetSelection={isResetSelection}
/>
</div>
</div>
{/* 모달 컴포넌트 */}
{isReportFilesModalOpen && <ReportFilesModal clsfSystmSn={clsfSystmSn} />}
</>
);
};
export default React.memo(ReportTab);
@@ -1,238 +0,0 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창
*
* 📄 Search Modal
* Next.js App Router (app/) 기반 검색 모달 컴포넌트
*
* - 탭 기반 검색 인터페이스 (기본정보, 유지관리 정보, 보고서)
* - 테이블 데이터 표시 및 페이지네이션 기능
* - 시설물 조회 결과 및 유지관리 사업 현황 표시
* - 엑셀 다운로드 및 지도에서 보기 기능
* - Zustand를 통한 검색 모달 상태 관리
* - 로딩 오버레이 및 선택 초기화 지원
*/
import LoadingOverlay from '@/components/ui/LoadingBar';
import { UiModal, UITab, UiTable } from '@common/business_components/ui';
import { Button } from '@common/components/ui';
import React, { useEffect, useMemo } from 'react';
import { Toaster } from "react-hot-toast";
import useSearchModalStore from '@/store/useSearchModalStore';
import { ExclnSewagePumpCol2, MsSwageMhlCol2, MsSwagePplCol2, stofcbrCol2, SwtrmtFcltCol2, SwtrmtPrcsZoneCol2 } from '../components/common/table';
import BasicTab, { BASIC_TYPES } from './basicTab';
import OperationTab from './operationTab';
import ReportTab from './reportTab';
import useSearchModal from './useSearchModal';
import useBasicTab from './useBasicTab';
import useOperationTab from './useOperationTab';
const SearchModal: React.FC = () => {
const {
showTable,
isSearchModalOpen, setIsSearchModalOpen,
activeTab, basicType, workType,
tableData, detailTableData,
tableCount, detailTableCount,
curPage, detailCurPage,
detailBizId,
isLoading, isDetailLoading,
isResetPage, isResetDetailPage,
isResetSelection, isResetDetailSelection,
setActiveTab,
setCurPage, setDetailCurPage,
setIsResetPage, setIsResetDetailPage,
setDetailBizId,
setIsResetDetailSelection,
resetFilters
} = useSearchModalStore();
const { TABS, viewMultipleFeature, fetchFilteredData } = useSearchModal();
const { handleBasicCellClick, basicExcelDownload } = useBasicTab();
const {
WORK_TYPES,
OPERATION_TABLE_SIZE,
OPERATION_TABLE_HEADLIST,
MAINTENANACE_TABLE_HEADLIST,
CLEANING_TABLE_HEADLIST,
INSPECTION_TABLE_HEADLIST,
handleOperationCellClick,
handleOperationResultCellClick,
operationExcelDownload,
operationResultExcelDownload,
handleOperationPageNation
} = useOperationTab();
const dynamicHeadlist: { id: string, title: string }[] = useMemo(() => {
if (activeTab === 'basic') { // 기본정보
if (basicType === 'tb_ms_swage_ppl') return MsSwagePplCol2;// 하수관로
if (basicType === 'tb_il_swage_prcs_fclt') return SwtrmtFcltCol2;// 하수처리시설
if (basicType === 'tb_ms_swage_prcs_zone') return SwtrmtPrcsZoneCol2; // 하수처리구역
if (basicType === 'tb_ms_swage_mhl') return MsSwageMhlCol2; // 하수맨홀
if (basicType === 'tb_ms_swage_pmstn') return ExclnSewagePumpCol2; // 하수펌프장
if (basicType === 'tb_ms_stofcbr') return stofcbrCol2; // 우수토실
return [];
}
if (activeTab === 'operation') return OPERATION_TABLE_HEADLIST; // 유지관리 정보
return [];
}, [activeTab, basicType, workType]);
const dynamicHeadlist2: { id: string, title: string }[] = useMemo(() => {
if (activeTab === 'operation') {
if (workType === WORK_TYPES[0]?.val) return MAINTENANACE_TABLE_HEADLIST; // 보수 및 갱신
if (workType === WORK_TYPES[1]?.val) return CLEANING_TABLE_HEADLIST; // 점검 및 진단
if (workType === WORK_TYPES[2]?.val) return INSPECTION_TABLE_HEADLIST; // 청소 및 준설
}
return [];
}, [activeTab, workType]);
useEffect(() => {
resetFilters();
}, [activeTab, basicType, workType]);
// 페이지 변경 시마다 데이터 fetch
useEffect(() => {
setIsResetPage(false);
setIsResetDetailPage(true);
setDetailBizId(0);
if (showTable) fetchFilteredData();
}, [curPage]);
// 페이지 변경 시마다 데이터 fetch
useEffect(() => {
setIsResetDetailSelection(false);
if (showTable) handleOperationPageNation(Number(detailBizId));
}, [detailCurPage]);
const renderBody = () => (
<>
{/* 탭 영역 */}
<UITab
defaultValue={activeTab}
tabList={TABS.map((tab: any) => ({
value: tab.val,
label: tab.text
}))}
onValueChange={(value: string) => setActiveTab(value)}
/>
{activeTab === 'basic' && (<BasicTab />)} {/* 기본정보 탭 */}
{activeTab === 'operation' && (<OperationTab />)} {/* 유지관리 정보 탭 */}
{activeTab === 'report' && (<ReportTab open={isSearchModalOpen} />)} {/* 하수도정비기본계획보고서 탭 */}
{/* 📋 결과 리스트 */}
{/* 기본정보 탭 */}
{activeTab === 'basic' && (
<div className="h-[79dvh] w-full rounded-lg border mt-4 overflow-x-scroll">
<LoadingOverlay isLoading={isLoading} />
<UiTable className='w-[120%]'
headName={(() => {
const label = BASIC_TYPES.find((type) => type.val === basicType)?.text
return (<>{label} 조회 결과</>);
})()
}
tableData={tableData}
headlist={dynamicHeadlist}
pageSize={10}
total={tableCount}
cellClick={handleBasicCellClick}
onPageChange={setCurPage}
resetPageNo={isResetPage}
resetSelection={isResetSelection}
curPage={curPage}
headButton={
<div className='flex'>
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium primary" size="sm" onClick={viewMultipleFeature}>지도에서 보기</Button>
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium green" size="sm" onClick={basicExcelDownload}>파일 다운로드</Button>
</div>
}
/>
</div>
)}
{/* 유지관리 정보 탭 */}
{activeTab === 'operation' && (
<>
<div className="h-[39dvh] w-full overflow-x-hidden overflow-y-auto rounded-lg border mt-4">
{/* <div className="flex-1 px-2 pb-2"> */}
<LoadingOverlay isLoading={isLoading} />
<UiTable
headName={
(() => {
const label = WORK_TYPES.find((type) => type.val === workType)?.text
return (<>{label} 사업 현황</>);
})()
}
tableData={tableData}
headlist={dynamicHeadlist}
pageSize={OPERATION_TABLE_SIZE}
total={tableCount}
cellClick={handleOperationCellClick}
onPageChange={setCurPage}
resetPageNo={isResetPage}
resetSelection={isResetSelection}
curPage={curPage}
headButton={
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-sm font-medium green" size="sm" onClick={operationExcelDownload}>파일 다운로드</Button>
}
/>
{/* </div> */}
</div>
<div className="h-[39dvh] w-full overflow-x-hidden overflow-y-auto rounded-lg border">
{/* 테이블2 영역 */}
{/* <div className="flex-1 px-2 pb-2"> */}
<LoadingOverlay isLoading={isDetailLoading} />
<UiTable
headName={
(() => {
const label = WORK_TYPES.find((type) => type.val === workType)?.text
return (<>{label} 결과표</>);
})()
}
tableData={detailTableData}
headlist={dynamicHeadlist2}
pageSize={OPERATION_TABLE_SIZE}
total={detailTableCount}
cellClick={handleOperationResultCellClick}
onPageChange={setDetailCurPage}
resetPageNo={isResetDetailPage}
resetSelection={isResetDetailSelection}
curPage={detailCurPage}
headButton={
<div className='flex'>
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium primary" size="sm" onClick={viewMultipleFeature}>지도에서 보기</Button>
<Button className="px-4 py-2 !mr-[1.2rem] text-white rounded-sm text-lg font-medium green" size="sm" onClick={operationResultExcelDownload}>파일 다운로드</Button>
</div>
}
/>
{/* </div> */}
</div>
</>
)}
</>
);
return (
<div className='overflow-x-auto overflow-y-hidden'>
<UiModal
open={isSearchModalOpen}
onOpenChange={setIsSearchModalOpen}
bodyClass={`
h-[100dvh]
w-[90dvw]
max-w-[1220px]
max-h-[970px]
overflow-y-hidden
z-[101]
`}
body={renderBody()}
useDirectRender={true}
/>
<Toaster position="top-center"></Toaster>
</div>
);
};
export default React.memo(SearchModal);
@@ -1,92 +0,0 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 우수토실)
*
* 📄 Stofcbr Tab
* Next.js App Router (app/) 기반 검색 모달 우수토실 탭 컴포넌트
*
* - 시도/시군구 선택 및 설치년도 범위 필터링
* - CQL 필터 생성을 통한 쿼리 조건 설정
* - 조회 버튼을 통한 데이터 검색 트리거
* - Zustand를 통한 상태 관리 및 필터 값 저장
* - Enter 키 입력 시 자동 조회 지원
*/
import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox';
import { Button } from '@common/components';
import React from 'react';
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
import useSearchModalStore from "@/store/useSearchModalStore";
import useSearchModal from './useSearchModal';
export const stofcbrCqlFilters = () => {
const {
ctpvCd, sggCd,
yearFilter,
todayYear, todayMonth, todayDate,
getDateString
} = useSearchModalStore.getState();
const cqlFilters: string[] = [];
// 시도, 시군구
if (ctpvCd !== '0' && sggCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`);
else if (ctpvCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`);
// 설치년도 범위
const startDate = getDateString(
yearFilter.start,
'',
'',
"19000101",
);
const endDate = getDateString(
yearFilter.end,
'',
'',
`${todayYear}${todayMonth}${todayDate}`,
);
cqlFilters.push(
`instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`,
);
return cqlFilters;
};
const Stofcbr: React.FC = () => {
const {
ctpvCd, setCtpvCd,
sggCd, setSggCd,
yearFilter, setYearFilter,
} = useSearchModalStore();
const { handleSearch } = useSearchModal();
return (
<div className="mb-3">
<div className="flex items-center mb-2">
{/* 시도, 시군구 */}
<CtpvSggPrcsFcltSelectBox
sido={ctpvCd}
sigungu={sggCd}
onSidoChange={(sidoId: string) => {
setCtpvCd(sidoId);
setSggCd("0");
}}
onSigunguChange={setSggCd}
/>
{/* 설치년도 */}
<YearRangeSelectBox
start={yearFilter.start}
end={yearFilter.end}
onChange={range => setYearFilter({ ...yearFilter, start: range.start, end: range.end })}
startLabel="설치년도"
endLabel=""
/>
{/* 조회 버튼 */}
<div className="flex-1 flex justify-end">
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
</div>
</div>
</div>
);
};
export default React.memo(Stofcbr);
@@ -1,156 +0,0 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수맨홀)
*
* 📄 SwageMhl Tab
* Next.js App Router (app/) 기반 검색 모달 하수맨홀 탭 컴포넌트
*
* - 시도/시군구 선택 및 설치년도 범위 필터링
* - 용도 및 구경 범위 기반 검색 기능
* - CQL 필터 생성을 통한 쿼리 조건 설정
* - 조회 버튼을 통한 데이터 검색 트리거
* - Zustand를 통한 상태 관리 및 필터 값 저장
* - Enter 키 입력 시 자동 조회 지원
*/
import React from 'react';
import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox';
import { Button, Input, Label } from '@common/components';
import { MS_SWAGE_MHL_USE_CODES } from '../components/common/table';
import { SelectBox } from '@common/business_components/ui';
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
import useSearchModalStore from "@/store/useSearchModalStore";
import useSearchModal from './useSearchModal';
export const swageMhlCqlFilters = () => {
const {
ctpvCd, sggCd,
yearFilter,
start1, end1,
var1,
todayYear, todayMonth, todayDate,
getDateString
} = useSearchModalStore.getState();
const cqlFilters: string[] = [];
// 시도, 시군구
if (ctpvCd !== '0' && sggCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`);
else if (ctpvCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`);
// // 설치년도 범위
const startDate = getDateString(
yearFilter.start,
'',
'',
"19000101",
);
const endDate = getDateString(
yearFilter.end,
'',
'',
`${todayYear}${todayMonth}${todayDate}`,
);
cqlFilters.push(
`instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`,
);
if (var1) cqlFilters.push(`usg_cd='${var1}'`);
// 구경(mm) 범위
if (start1 && end1) cqlFilters.push(`swage_mhL_diam_vl BETWEEN ${start1} AND ${end1}`);
else if (start1) cqlFilters.push(`swage_mhL_diam_vl >= ${start1}`);
else if (end1) cqlFilters.push(`swage_mhL_diam_vl <= ${end1}`);
return cqlFilters;
};
const SwageMhl: React.FC = () => {
const {
var1, setVar1,
start1, setStart1, end1, setEnd1,
ctpvCd, setCtpvCd,
sggCd, setSggCd,
yearFilter, setYearFilter,
} = useSearchModalStore();
const { handleSearch } = useSearchModal();
return (
<div className="mb-3">
<div className="mb-2 flex items-center">
<CtpvSggPrcsFcltSelectBox
sido={ctpvCd}
sigungu={sggCd}
onSidoChange={(sidoId: string) => {
setCtpvCd(sidoId);
setSggCd("0");
}}
onSigunguChange={setSggCd}
/>
{/* 설치년도 */}
<YearRangeSelectBox
start={yearFilter.start}
end={yearFilter.end}
onChange={(range) =>
setYearFilter({
...yearFilter,
start: range.start,
end: range.end,
})
}
startLabel="설치년도"
endLabel=""
/>
{/* 용도 */}
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">용도</Label>
<SelectBox
className="w-48 mr-8"
selectClass="rounded-lg bg-white text-xl"
label=""
selectArray={[{ val: "0", text: "전체" }, ...MS_SWAGE_MHL_USE_CODES]}
value={var1}
onSelectValue={(val) => {
setVar1(String(val));
}}
selectContentClass="z-[101]"
/>
{/* 하수맨홀 구경 */}
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">구경(mm)</Label>
<Input
className="w-[100px] !text-xl placeholder:text-center"
placeholder="입력"
value={start1}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setStart1(val);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
<span className="mx-2 text-xl font-semibold">~</span>
<Input
className="mr-8 w-[100px] !text-xl placeholder:text-center"
placeholder="입력"
value={end1}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setEnd1(val);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
{/* 조회 버튼 */}
<div className="flex-1 flex justify-end">
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
</div>
</div>
</div>
);
};
export default React.memo(SwageMhl);
@@ -1,175 +0,0 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수펌프장)
*
* 📄 SwagePmstn Tab
* Next.js App Router (app/) 기반 검색 모달 하수펌프장 탭 컴포넌트
*
* - 시도/시군구 선택 및 설치년도 범위 필터링
* - 일일처리용량 및 양수능력 범위 기반 검색 기능
* - CQL 필터 생성을 통한 쿼리 조건 설정
* - 조회 버튼을 통한 데이터 검색 트리거
* - Zustand를 통한 상태 관리 및 필터 값 저장
* - Enter 키 입력 시 자동 조회 지원
*/
import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox';
import { Button, Input, Label } from '@common/components';
import React from 'react';
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
import useSearchModalStore from "@/store/useSearchModalStore";
import useSearchModal from './useSearchModal';
export const swagePmstnCqlFilters = () => {
const {
ctpvCd, sggCd,
yearFilter,
start1, start2, end1, end2,
todayYear, todayMonth, todayDate,
getDateString
} = useSearchModalStore.getState();
const cqlFilters: string[] = [];
// 시도, 시군구
if (ctpvCd !== '0' && sggCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`);
else if (ctpvCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`);
// 설치년도 범위
const startDate = getDateString(
yearFilter.start,
'',
'',
"19000101",
);
const endDate = getDateString(
yearFilter.end,
'',
'',
`${todayYear}${todayMonth}${todayDate}`,
);
cqlFilters.push(
`instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`,
);
// 일일처리용량 범위
if (start1 && end1)
cqlFilters.push(`daily_prcs_cpct BETWEEN ${start1} AND ${end1}`);
else if (start1) cqlFilters.push(`daily_prcs_cpct >= ${start1}`);
else if (end1) cqlFilters.push(`daily_prcs_cpct <= ${end1}`);
// 양수능력 범위
if (start2 && end2)
cqlFilters.push(`excln_wpmp_ablt_vl BETWEEN ${start2} AND ${end2}`);
else if (start2) cqlFilters.push(`excln_wpmp_ablt_vl >= ${start2}`);
else if (end2) cqlFilters.push(`excln_wpmp_ablt_vl <= ${end2}`);
return cqlFilters;
};
const SwagePmstn: React.FC = () => {
const {
start1, setStart1,
end1, setEnd1,
ctpvCd, setCtpvCd,
sggCd, setSggCd,
yearFilter, setYearFilter,
} = useSearchModalStore();
const { handleSearch } = useSearchModal();
return (
<div className="mb-3">
<div className="flex items-center mb-2">
{/* 시도, 시군구 */}
<CtpvSggPrcsFcltSelectBox
sido={ctpvCd}
sigungu={sggCd}
onSidoChange={(sidoId: string) => {
setCtpvCd(sidoId);
setSggCd("0");
}}
onSigunguChange={setSggCd}
/>
{/* 설치년도 */}
<YearRangeSelectBox
start={yearFilter.start}
end={yearFilter.end}
onChange={range => setYearFilter({ ...yearFilter, start: range.start, end: range.end })}
startLabel="설치년도"
endLabel=""
/>
{/* 일일처리용량 */}
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">일일처리용량(㎥)</Label>
<Input
className="w-[100px] !text-xl placeholder:text-center"
placeholder="입력"
value={start1}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setStart1(val);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
<span className="mx-2 text-xl font-semibold">~</span>
<Input
className="mr-8 w-[100px] !text-xl placeholder:text-center"
placeholder="입력"
value={end1}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setEnd1(val);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
{/* 양수능력 */}
{/* <Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">양수능력(m²/d)</Label>
<Input
className="w-[100px] !text-xl placeholder:text-center"
placeholder="입력"
value={start2}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setStart2(val);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
<span className="mx-2 text-xl font-semibold">~</span>
<Input
className="mr-8 w-[100px] !text-xl placeholder:text-center"
placeholder="입력"
value={end2}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setEnd2(val);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/> */}
{/* 조회 버튼 */}
<div className="flex-1 flex justify-end">
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
</div>
</div>
</div>
);
};
export default React.memo(SwagePmstn);
@@ -1,209 +0,0 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수관로)
*
* 📄 SwagePpl Tab
* Next.js App Router (app/) 기반 검색 모달 하수관로 탭 컴포넌트
*
* - 시도/시군구 선택 및 설치년도 범위 필터링
* - 용도 및 관경 범위 기반 검색 기능
* - CQL 필터 생성을 통한 쿼리 조건 설정
* - 조회 버튼을 통한 데이터 검색 트리거
* - Zustand를 통한 상태 관리 및 필터 값 저장
* - Enter 키 입력 시 자동 조회 지원
*/
import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox';
import { SelectBox } from '@common/business_components/ui';
import { Button, Input, Label } from '@common/components';
import React from 'react';
import { MS_SWAGE_PPL_USE_CODES } from '../components/common/table';
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
import useSearchModalStore from "@/store/useSearchModalStore";
import useSearchModal from './useSearchModal';
// 하수관로용 CQL 필터 생성
export const swagePplCqlFilters = () => {
const {
var1, var2,
ctpvCd, sggCd,
yearFilter,
start1, start2, end1, end2,
todayYear, todayMonth, todayDate,
getDateString
} = useSearchModalStore.getState();
const cqlFilters: string[] = [];
if (var1) cqlFilters.push(`usg_cd='${var1}'`);
if (var2) cqlFilters.push(`mqlt_cd='${var2}'`);
if (ctpvCd !== '0' && sggCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${sggCd}%'`);
else if (ctpvCd !== '0') cqlFilters.push(`pbadms_emd_cd LIKE '${ctpvCd?.trim()}%'`);
// 설치년도 범위
const startDate = getDateString(
yearFilter.start,
'',
'',
"19000101",
);
const endDate = getDateString(
yearFilter.end,
'',
'',
`${todayYear}${todayMonth}${todayDate}`,
);
cqlFilters.push(
`instl_ymd >= '${startDate}' and instl_ymd <= '${endDate}'`,
);
// 관경(mm) 범위
if (start1 && end1) cqlFilters.push(`pdmt_vl BETWEEN ${start1} AND ${end1}`);
else if (start1) cqlFilters.push(`pdmt_vl >= ${start1}`);
else if (end1) cqlFilters.push(`pdmt_vl <= ${end1}`);
// 연장(m) 범위
if (start2 && end2) cqlFilters.push(`prlg_vl BETWEEN ${start2} AND ${end2}`);
else if (start2) cqlFilters.push(`prlg_vl >= ${start2}`);
else if (end2) cqlFilters.push(`prlg_vl <= ${end2}`);
return cqlFilters;
};
const SwagePpl: React.FC = () => {
const {
var1, setVar1,
start1, setStart1, end1, setEnd1,
ctpvCd, setCtpvCd, sggCd, setSggCd,
yearFilter, setYearFilter,
} = useSearchModalStore();
const { handleSearch } = useSearchModal();
return (
<div className="mb-6">
<div className="mb-2 flex items-center">
<CtpvSggPrcsFcltSelectBox
sido={ctpvCd}
sigungu={sggCd}
onSidoChange={(sidoId: string) => {
setCtpvCd(sidoId);
setSggCd("0");
}}
onSigunguChange={setSggCd}
/>
{/* 설치년도 */}
<YearRangeSelectBox
start={yearFilter.start}
end={yearFilter.end}
onChange={(range) =>
setYearFilter({
...yearFilter,
start: range.start,
end: range.end,
})
}
startLabel="설치년도"
endLabel=""
/>
{/* 용도 */}
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">용도</Label>
<SelectBox
className="w-32 mr-8"
selectClass="rounded-lg bg-white text-xl"
label=""
selectArray={[{ val: "0", text: "전체" }, ...MS_SWAGE_PPL_USE_CODES]}
value={var1}
onSelectValue={(val) => {
setVar1(String(val));
}}
selectContentClass="z-[101]"
/>
{/* 재질 */}
{/* <Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">재질</Label>
<SelectBox
className="w-60 mr-8"
selectClass="rounded-lg bg-white text-xl"
label=""
selectArray={MS_SWAGE_PPL_CODES}
value={var2}
onSelectValue={(val) => {
setVar2(String(val));
}}
selectContentClass="z-[101]"
/> */}
{/* 관경 */}
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">관경(mm)</Label>
<Input
className="w-[60px] !text-xl placeholder:text-center"
placeholder="입력"
value={start1}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setStart1(val);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
<span className="mx-2 text-xl font-semibold">~</span>
<Input
className="mr-8 w-[60px] !text-xl placeholder:text-center"
placeholder="입력"
value={end1}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setEnd1(val);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
{/* 연장 */}
{/* <Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">연장(m)</Label>
<Input
className="w-[60px] !text-xl placeholder:text-center"
placeholder="입력"
value={start2}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setStart2(val);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
<span className="mx-2 text-xl font-semibold">~</span>
<Input
className="mr-8 w-[60px] !text-xl placeholder:text-center"
placeholder="입력"
value={end2}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setEnd2(val);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/> */}
{/* 조회 버튼 */}
<div className="flex-1 flex justify-end">
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
</div>
</div>
</div>
);
};
export default React.memo(SwagePpl);
@@ -1,150 +0,0 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수처리시설)
*
* 📄 SwagePrcsFclt Tab
* Next.js App Router (app/) 기반 검색 모달 하수처리시설 탭 컴포넌트
*
* - 시도/시군구 선택 및 가동개시일자 범위 필터링
* - 시설용량 범위 기반 검색 기능
* - CQL 필터 생성을 통한 쿼리 조건 설정
* - 조회 버튼을 통한 데이터 검색 트리거
* - Zustand를 통한 상태 관리 및 필터 값 저장
* - Enter 키 입력 시 자동 조회 지원
*/
import React from 'react';
import { YearRangeSelectBox } from '@/components/ui/YearRangeSelectBox';
import { Button, Input, Label } from '@common/components';
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
import useSearchModalStore from "@/store/useSearchModalStore";
import useSearchModal from './useSearchModal';
// 하수처리시설용 CQL 필터 생성
export const swagePrcsFcltCqlFilters = () => {
const {
ctpvName, sggName,
yearFilter,
start1, end1,
todayYear, todayMonth, todayDate,
getDateString
} = useSearchModalStore.getState();
const cqlFilters: string[] = [];
// 가동개시일자 범위
const startDate = getDateString(
yearFilter.start,
'',
'',
"19000101",
);
const endDate = getDateString(
yearFilter.end,
'',
'',
`${todayYear}${todayMonth}${todayDate}`,
);
cqlFilters.push(
`oprtng_strt_ymd >= '${startDate}' and oprtng_strt_ymd <= '${endDate}'`,
);
// 시설용량 범위
if (start1 && end1)
cqlFilters.push(`fclt_cpct BETWEEN ${start1} AND ${end1}`);
else if (start1) cqlFilters.push(`fclt_cpct >= ${start1}`);
else if (end1) cqlFilters.push(`fclt_cpct <= ${end1}`);
// 시도
if (ctpvName) cqlFilters.push(`ctpv_nm ILIKE '%${ctpvName}%'`);
if (sggName) cqlFilters.push(`sgg_nm ILIKE '%${sggName}%'`);
// // 처리구역 면적
// if (start2 && end2)
// cqlFilters.push(`prcsZoneAreaSum BETWEEN ${start2} AND ${end2}`);
// else if (start2) cqlFilters.push(`prcsZoneAreaSum >= ${start2}`);
// else if (end2) cqlFilters.push(`prcsZoneAreaSum <= ${end2}`);
// // 하수처리시설명
// if (var1) cqlFilters.push(`swtrmt_fclt_nm ILIKE '%${var1}%'`);
return cqlFilters;
};
const SwagePrcsFclt: React.FC = () => {
const {
start1, setStart1, end1, setEnd1,
ctpvCd, setCtpvCd, setCtpvName,
sggCd, setSggCd, setSggName,
yearFilter, setYearFilter,
} = useSearchModalStore();
const { handleSearch } = useSearchModal();
return (
<div className="mb-3">
<div className="mb-2 flex items-center">
<CtpvSggPrcsFcltSelectBox
sido={ctpvCd}
sigungu={sggCd}
onSidoChange={(sidoId) => {
setCtpvCd(sidoId);
setSggCd("0");
}}
onSigunguChange={setSggCd}
setCtpvName={setCtpvName}
setSggName={setSggName}
/>
{/* 가동개시일자 */}
<Label className="mr-2 w-auto text-xl text-gray-600">가동개시일자</Label>
<YearRangeSelectBox
start={yearFilter.start}
end={yearFilter.end}
onChange={(range) =>
setYearFilter({
...yearFilter,
start: range.start,
end: range.end,
})
}
startLabel=""
endLabel=""
/>
{/* 시설용량 */}
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">시설용량(㎥/d)</Label>
<Input
className="w-[60px] !text-xl placeholder:text-center"
placeholder="입력"
value={start1}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setStart1(val);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
<span className="mx-2 text-xl font-semibold">~</span>
<Input
className="mr-8 w-[60px] !text-xl placeholder:text-center"
placeholder="입력"
value={end1}
onChange={(e) => {
// 숫자만 입력 허용
const val = e.target.value.replace(/[^0-9]/g, "");
setEnd1(val);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
{/* 조회 버튼 */}
<div className="flex-1 flex justify-end">
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
</div>
</div>
</div>
);
};
export default React.memo(SwagePrcsFclt);
@@ -1,93 +0,0 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 기본정보 탭 (구분: 하수처리구역)
*
* 📄 SwagePrcsZone Tab
* Next.js App Router (app/) 기반 검색 모달 하수처리구역 탭 컴포넌트
*
* - 시도/시군구 선택 및 하수처리시설명/구역명 기반 검색 기능
* - CQL 필터 생성을 통한 쿼리 조건 설정
* - 조회 버튼을 통한 데이터 검색 트리거
* - Zustand를 통한 상태 관리 및 필터 값 저장
* - Enter 키 입력 시 자동 조회 지원
*/
import React from 'react';
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
import { Button, Input, Label } from '@common/components';
import useSearchModalStore from "@/store/useSearchModalStore";
import useSearchModal from './useSearchModal';
// 하수처리구역용 CQL 필터 생성
export const swagePrcsZoneCqlFilters = () => {
const {
ctpvCd, sggCd,
var1, var2
} = useSearchModalStore.getState();
const cqlFilters: string[] = [];
if (ctpvCd !== '' && ctpvCd !== '0') cqlFilters.push(`ctpv_cd='${ctpvCd?.trim()}'`);
if (sggCd !== '' && sggCd !== '0') cqlFilters.push(`sgg_cd='${sggCd}'`);
if (var1) cqlFilters.push(`swtrmt_zone_nm ILIKE '%${var1}%'`);
if (var2) cqlFilters.push(`swtrmt_fclt_nm ILIKE '%${var2}%'`);
return cqlFilters;
};
const swagePrcsZone: React.FC = () => {
const {
var1, setVar1, var2, setVar2,
ctpvCd, setCtpvCd,
sggCd, setSggCd,
} = useSearchModalStore();
const { handleSearch } = useSearchModal();
return (
<div className="mb-3">
<div className="mb-2 flex items-center">
<CtpvSggPrcsFcltSelectBox
sido={ctpvCd}
sigungu={sggCd}
onSidoChange={(sidoId: string) => {
setCtpvCd(sidoId);
setSggCd("0");
}}
onSigunguChange={setSggCd}
/>
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">하수처리시설명</Label>
<Input
className='!text-xl w-[120px] placeholder:text-center mr-8'
placeholder="입력"
value={var2}
onChange={(e) => {
setVar2(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
<Label className="text-xl text-gray-600 font-medium whitespace-nowrap mr-2">하수처리구역명</Label>
<Input
className="mr-8 w-[100px] !text-xl placeholder:text-center"
placeholder="입력"
value={var1}
onChange={(e) => {
setVar1(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault(); // Enter 키 기본 동작 방지
handleSearch();
}
}}
/>
{/* 조회 버튼 */}
<div className="flex-1 flex justify-end">
<Button className="text-base py-2" onClick={handleSearch}>조회</Button>
</div>
</div>
</div>
);
};
export default React.memo(swagePrcsZone);
@@ -1,348 +0,0 @@
/**
* 📄 useBasicTab Hook
* 검색 모달 기본정보 탭 훅
*
* - 필터링된 시설물 데이터 조회 및 포맷팅
* - 테이블 셀 클릭 시 지도 피처 선택 및 줌 기능
* - 엑셀 다운로드 기능 (한글 헤더 지원)
* - CQL 필터 생성을 통한 쿼리 조건 설정
* - Zustand를 통한 상태 관리 및 로딩 처리
* - 데이터 매핑 및 코드 변환 (시도/시군구, 용도 등)
*
* 🔗 사용 API
* - 관망도 시설물 목록 조회: GET /api/v1/fclts (sewerage-service)
*/
import { downloadExcelWithKoreanHeaders } from '@common/business_components/ui';
import {
MS_SWAGE_PPL_USE_CODES, MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_SHAPE, MS_SWAGE_MHL_CODES, MS_SWAGE_MHL_USE_CODES,
MsSwagePplExcelCol, SwtrmtFcltExcelCol, SwtrmtPrcsZoneExcelCol, MsSwageMhlExcelCol, ExclnSewagePumpExcelCol, stofcbrExcelCol
} from '../components/common/table';
import { getData } from "@/components/lib/api";
import { BASIC_TYPES } from './basicTab';
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
import useStore from '@/store/useStore';
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { confirmToast } from '@/components/ui/toast'
import { toast } from "react-hot-toast";
import useSearchModalStore from '@/store/useSearchModalStore';
import { swagePplCqlFilters } from './swagePpl';
import { swagePrcsFcltCqlFilters } from './swagePrcsFclt';
import { swagePrcsZoneCqlFilters } from './swagePrcsZone';
import { swageMhlCqlFilters } from './swageMhl';
import { swagePmstnCqlFilters } from './swagePmstn';
import { stofcbrCqlFilters } from './stofcbr';
export default function useBasicTab() {
const {
basicType,
tableData,
curPage,
todayYear, todayMonth, todayDate,
setIsSearchModalOpen,
setTableData,
setTableCount,
setIsLoading,
} = useSearchModalStore();
const { ctpv, sgg } = useStore();
const fetchBasicFilteredData = async () => {
let cqlFilters: string[] = [];
const queryParams = new URLSearchParams({
lyrTypeNm: basicType,
page: `${curPage - 1}`,
size: '10',
sort: 'string',
});
if (basicType === 'tb_ms_swage_ppl') cqlFilters = swagePplCqlFilters(); // 하수관로
else if (basicType === 'tb_il_swage_prcs_fclt') cqlFilters = swagePrcsFcltCqlFilters(); // 하수처리시설
else if (basicType === 'tb_ms_swage_prcs_zone') cqlFilters = swagePrcsZoneCqlFilters(); // 하수처리구역
else if (basicType === 'tb_ms_swage_mhl') cqlFilters = swageMhlCqlFilters(); // 하수맨홀
else if (basicType === 'tb_ms_swage_pmstn') cqlFilters = swagePmstnCqlFilters(); // 하수펌프장
else if (basicType === 'tb_ms_stofcbr') cqlFilters = stofcbrCqlFilters(); // 우수토실
if (cqlFilters.length > 0) queryParams.append('cqlFiter', cqlFilters.join(' and '));
const res = await getData(`v1/fclts?${queryParams.toString()}`);
// 🎯 각 아이템을 개별적으로 포맷팅하면서 일련번호 추가
const dataWithRowNumber = (res.data || []).map((item: any, index: number) => {
// 🎯 먼저 각 아이템을 포맷팅
const formattedItem = formatDisplayData(item);
let ctpvCd = item?.ctpvNm ?? formattedItem?.ctpvNm ?? '';
let sggCd = item?.sggNm ?? formattedItem?.sggNm ?? '';
if (!ctpvCd || !sggCd) {
const code = String(item?.pbadmsEmdCd ?? formattedItem?.pbadmsEmdCd ?? '')?.trim();
if (!ctpvCd && code) ctpvCd = code.slice(0, 2);
if (!sggCd && code) sggCd = code.slice(0, 5);
}
const matchedMqltCode = basicType === 'tb_ms_swage_ppl'
? MS_SWAGE_PPL_CODES.find(code => code.val === item?.mqltCd?.trim()) // 하수관로 재질
: basicType === 'tb_ms_swage_mhl'
? MS_SWAGE_MHL_CODES.find(code => code.val === item?.mqltCd?.trim()) // 맨홀 재질
: undefined;
const matchedMhlUsgCode = MS_SWAGE_MHL_USE_CODES.find(code => code.val === item?.usgCd); // 맨홀 용도
const matchedPplShpCode = MS_SWAGE_PPL_SHAPE.find(code => code.val === item?.fcltyShpCd); // 하수관로 시설물형태코드
const ctpvNm = ctpv.find(code => code.val.trim() === ctpvCd)?.text || item?.ctpvNm;
const sggNm = sgg.find(code => code.val.trim() === sggCd)?.text || item?.sggNm;
const usgCdObj = MS_SWAGE_PPL_USE_CODES.find(code => code.val === item?.usgCd?.trim());
// 추가 처리 및 일련번호 추가
return {
...formattedItem,
rowNumber: (curPage - 1) * 10 + index + 1, // 1부터 시작하는 일련번호 추가
scsYn: item.scsYn === null || item.scsYn === undefined ? 'N' : item?.scsYn, // scsYn이 null이면 'N'으로 변경
mqltCd: matchedMqltCode ? matchedMqltCode.text : item?.mqltCd,
usgCd: matchedMhlUsgCode ? matchedMhlUsgCode.text : (usgCdObj ? usgCdObj.text : item?.usgCd),
ctpvNm: ctpvNm ?? '-',
sggNm: sggNm ?? '-',
swtrmt_zone_nm: item.swtrmtZoneNm || item.swtrmt_zone_nm || '-',
fcltyShpCd: matchedPplShpCode? matchedPplShpCode.text : item?.fcltyShpCd,
};
});
setTableData(dataWithRowNumber);
// console.log(tableData);
setTableCount(res.count || []);
};
// 기본정보 테이블 cellClick 핸들러
const handleBasicCellClick = async (index?: number | undefined, headId?: String) => {
if (typeof index === "number") {
setIsLoading(true);
const row = tableData[index];
const layerNm = row.lyrTypeNm;
const fid = row.fid;
const core = GisApp.Module.layer as any;
await core.showSearchResultsFromGeoJSON(layerNm, fid);
setIsLoading(false);
setIsSearchModalOpen(false); // SearchModal 닫기
}
};
// 기본정보 Excel 다운로드 핸들러
const basicExcelDownload = async () => {
if (tableData.length === 0) {
alert("다운로드 할 Excel 파일이 없습니다.\n조회 후 다시 시도해주세요.");
return;
}
toast.dismiss("global-confirm-toast");
confirmToast(
"파일 다운로드에는 2분 내외의 시간이 소요될 수 있습니다.\n파일 다운로드를 진행하시겠습니까?",
onDownload ?? (() => { }),
"text-xl font-medium text-gray-800"
);
async function onDownload() {
setIsLoading(true);
try {
const queryParams = new URLSearchParams({
lyrTypeNm: basicType,
size: '10000',
});
let cqlFilters: string[] = [];
let headerMapping: { [key: string]: string } = {};
let headerRange: string = "A1:F1";
switch (basicType) {
case 'tb_ms_swage_ppl': {
// 하수관로
cqlFilters = swagePplCqlFilters()
headerMapping = MsSwagePplExcelCol;
headerRange = "A1:Q1";
break;
}
case 'tb_il_swage_prcs_fclt': {
// 하수처리시설
cqlFilters = swagePrcsFcltCqlFilters();
headerMapping = SwtrmtFcltExcelCol;
headerRange = "A1:J1";
break;
}
case 'tb_ms_swage_prcs_zone': {
// 하수처리구역
cqlFilters = swagePrcsZoneCqlFilters();
headerMapping = SwtrmtPrcsZoneExcelCol;
headerRange = "A1:D1";
break;
}
case 'tb_ms_swage_mhl': {
// 하수맨홀
cqlFilters = swageMhlCqlFilters();
headerMapping = MsSwageMhlExcelCol;
headerRange = "A1:J1";
break;
}
case 'tb_ms_swage_pmstn': {
// 하수펌프장
cqlFilters = swagePmstnCqlFilters();
headerMapping = ExclnSewagePumpExcelCol;
headerRange = "A1:J1";
break;
}
case 'tb_ms_stofcbr': {
// 우수토실
cqlFilters = stofcbrCqlFilters();
headerMapping = stofcbrExcelCol;
headerRange = "A1:H1";
break;
}
}
if (cqlFilters.length > 0) queryParams.append('cqlFiter', cqlFilters.join(' and '));
const res = await getData(`v1/fclts?${queryParams.toString()}`);
const mhlUsgCdMap = new Map(MS_SWAGE_MHL_USE_CODES.map(code => [code.val?.trim(), code.text]));
const pplUsgCdMap = new Map(MS_SWAGE_PPL_USE_CODES.map(code => [code.val?.trim(), code.text]));
const pplMqltCdMap = new Map(MS_SWAGE_PPL_CODES.map(code => [code.val?.trim(), code.text]));
const pplShpCdMap = new Map(MS_SWAGE_PPL_SHAPE.map(code => [code.val?.trim(), code.text])); // 관로 시설물형태코드
const ctpvMap = new Map(ctpv.map(code => [code.val?.trim(), code.text]));
const sggMap = new Map(sgg.map(code => [code.val?.trim(), code.text]));
const mappedContent = (res.data || []).map((item: any) => {
const formattedItem = formatDisplayData(item);
let ctpvCd = item?.ctpvCd ?? formattedItem?.ctpvCd ?? '';
let sggCd = item?.sggCd ?? formattedItem?.sggCd ?? '';
if (!ctpvCd || !sggCd) {
const code = String(item?.pbadmsEmdCd ?? formattedItem?.pbadmsEmdCd ?? '')?.trim();
if (!ctpvCd && code) ctpvCd = code.slice(0, 2);
if (!sggCd && code) sggCd = code.slice(0, 5);
}
// trim 한 번만 수행
const trimmedUsgCd = item?.usgCd?.trim();
// const trimmedMqltCd = item?.mqltCd?.trim();
switch (basicType) {
case 'tb_ms_swage_ppl': {
// 하수관로
return {
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? '-',
instMngNo: formattedItem.instMngNo ?? item.instMngNo ?? '-',
ctpvNm: ctpvMap.get(ctpvCd) ?? item.ctpvNm ?? '-',
sggNm: sggMap.get(sggCd) ?? item.sggNm ?? '-',
swtrmt_zone_nm: formattedItem.swtrmt_zone_nm ?? item.swtrmt_zone_nm ?? '-',
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? '-',
usgCd: pplUsgCdMap.get(trimmedUsgCd) || item.usgCd || formattedItem.usgCd || '-',
pdmtVl: formattedItem.pdmtVl ?? item.pdmtVl ?? '-',
prlgVl: formattedItem.prlgVl ?? item.prlgVl ?? '-',
tmptDpth: formattedItem.tmptDpth ?? item.tmptDpth ?? '-',
tmptMhlMngNo: formattedItem.tmptMhlMngNo ?? item.tmptMhlMngNo ?? '-',
scendDpth: formattedItem.scendDpth ?? item.scendDpth ?? '-',
scendMhlMngNo: formattedItem.scendMhlMngNo ?? item.scendMhlMngNo ?? '-',
vrtcLen: formattedItem.vrtcLen ?? item.vrtcLen ?? '-',
wdthLen: formattedItem.wdthLen ?? item.wdthLen ?? '-',
fcltyShpCd: pplShpCdMap.get(item?.fcltyShpCd?.trim()) || item.fcltyShpCd || formattedItem.fcltyShpCd || '-',
mqltCd: pplMqltCdMap.get(item?.mqltCd?.trim()) || item.mqltCd || formattedItem.mqltCd || '-',
};
}
case 'tb_il_swage_prcs_fclt': {
// 하수처리시설
return {
swtrmtFcltNm: formattedItem.swtrmtFcltNm ?? item.swtrmtFcltNm ?? '-',
fcltCpct: formattedItem.fcltCpct ?? item.fcltCpct ?? '-',
rgnExpln: formattedItem.rgnExpln ?? item.rgnExpln ?? '-',
oprtngStrtYmd: formattedItem.oprtngStrtYmd ?? item.oprtngStrtYmd ?? '-',
laeNm: formattedItem.laeNm ?? item.laeNm ?? '-',
sareaNm: formattedItem.sareaNm ?? item.sareaNm ?? '-',
rvrNm: formattedItem.rvrNm ?? item.rvrNm ?? '-',
dschgWtstNm: formattedItem.dschgWtstNm ?? item.dschgWtstNm ?? '-',
swtrmtPpltnCnt: formattedItem.swtrmtPpltnCnt ?? item.swtrmtPpltnCnt ?? '-',
rprsMsrcCn: formattedItem.rprsMsrcCn ?? item.rprsMsrcCn ?? '-'
};
}
case 'tb_ms_swage_prcs_zone': {
// 하수처리구역
return {
swtrmtZoneNm: formattedItem.swtrmtZoneNm ?? item.swtrmtZoneNm ?? '-',
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? '-',
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? '-',
swtrmtFcltNm: formattedItem.swtrmtFcltNm ?? item.swtrmtFcltNm ?? '-'
};
}
case 'tb_ms_swage_mhl': {
// 하수맨홀
return {
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? '-',
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? '-',
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? '-',
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? '-',
usgCd: mhlUsgCdMap.get(trimmedUsgCd) || item.usgCd || formattedItem.usgCd || '-',
swageMhlDiamVl: formattedItem.swageMhlDiamVl ?? item.swageMhlDiamVl ?? '-',
wdthLen: formattedItem.wdthLen ?? item.wdthLen ?? '-',
vrtcLen: formattedItem.vrtcLen ?? item.vrtcLen ?? '-',
altdVl: formattedItem.altdVl ?? item.altdVl ?? '-',
dpthlVl: formattedItem.dpthlVl ?? item.dpthlVl ?? '-'
};
}
case 'tb_ms_swage_pmstn': {
// 하수펌프장
return {
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? '-',
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? '-',
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? '-',
swagePmstnNm: formattedItem.swagePmstnNm ?? item.swagePmstnNm ?? '-',
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? '-',
dailyPrcsCpct: formattedItem.dailyPrcsCpct ?? item.dailyPrcsCpct ?? '-',
maxImpndCpct: formattedItem.maxImpndCpct ?? item.maxImpndCpct ?? '-',
altdVl: formattedItem.altdVl ?? item.altdVl ?? '-',
wtlvVl: formattedItem.wtlvVl ?? item.wtlvVl ?? '-',
exclnWpmpAbltVl: formattedItem.exclnWpmpAbltVl ?? item.exclnWpmpAbltVl ?? '-'
};
}
case 'tb_ms_stofcbr': {
// 우수토실
return {
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? '-',
mapshtNo: formattedItem.mapshtNo ?? item.mapshtNo ?? '-',
ctpvNm: ctpvMap.get(ctpvCd) ?? formattedItem.ctpvNm ?? '-',
sggNm: sggMap.get(sggCd) ?? formattedItem.sggNm ?? '-',
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? '-',
worflwWallHgt: formattedItem.worflwWallHgt ?? item.worflwWallHgt ?? '-',
swageMhlMngNo: formattedItem.swageMhlMngNo ?? item.swageMhlMngNo ?? '-',
drtnAngl: formattedItem.drtnAngl ?? item.drtnAngl ?? '-'
};
}
}
});
// 선택된 행 데이터를 Excel 다운로드 형식으로 변환
const excelData = mappedContent; // 배열로 감싸서 전달
// 파일명 생성 (분석번호 기반)
const label = BASIC_TYPES.find((type) => type.val === basicType)?.text
const fileName = `${label} 조회 결과_${todayYear}${todayMonth}${todayDate}`;
await downloadExcelWithKoreanHeaders({
data: excelData as JSON[],
fileName: fileName,
sheetNameArray: [fileName],
headerRange: headerRange,
headerMapping: headerMapping
});
setIsLoading(false);
} catch (error) {
setIsLoading(false);
console.error("Excel 다운로드 중 오류 발생:", error);
alert("Excel 다운로드 중 오류가 발생했습니다. 다시 시도해주세요.");
}
}
};
return { fetchBasicFilteredData, handleBasicCellClick, basicExcelDownload };
};
@@ -1,452 +0,0 @@
/**
* 📄 useOperationTab Hook
* 검색 모달 유지관리 정보 탭 훅
*
* - 필터링된 사업 현황 데이터 조회 및 포맷팅
* - 사업 현황 테이블 셀 클릭 시 상세 결과표 조회
* - 결과표 셀 클릭 시 지도 피처 선택 및 줌 기능
* - 엑셀 다운로드 기능 (사업 현황 및 결과표)
* - CQL 필터 생성을 통한 쿼리 조건 설정
* - Zustand를 통한 상태 관리 및 로딩 처리
* - 데이터 매핑 및 코드 변환 (사업 유형, 용도, 재질 등)
*
* 🔗 사용 API
* - 유지관리 정보 목록 조회: GET /api/v1/bizInfo (sewerage-service)
* - 유지관리 정보(사업정보) 관련 시설 목록 조회: GET /api/v1/bizInfo/fclts (sewerage-service)
*/
import { downloadExcelWithKoreanHeaders } from '@common/business_components/ui';
import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } from '../components/common/table';
import { getData } from "@/components/lib/api";
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { confirmToast } from '@/components/ui/toast'
import { toast } from "react-hot-toast";
import useSearchModalStore from '@/store/useSearchModalStore';
import useStore from '@/store/useStore';
export default function useOperationTab() {
const OPERATION_TABLE_SIZE = 5;
const {
workType,
tableData, detailTableData,
curPage, detailCurPage,
detailBizId,
var1, var2,
yearFilter,
todayYear, todayMonth, todayDate,
setIsSearchModalOpen,
setTableData, setDetailTableData,
setTableCount, setDetailTableCount,
setDetailCurPage,
setIsLoading, setIsDetailLoading,
setIsResetDetailPage,
setIsResetDetailSelection,
setDetailBizId,
getDateString
} = useSearchModalStore();
const { BIZ_TY_CD } = useStore();
const WORK_TYPES = [ // 유지관리 정보 '구분' 선택 목록
{ val: "['200', '201', '204']", text: "보수 및 갱신" },
{ val: "['100']", text: "점검 및 진단" },
{ val: "['203']", text: "청소 및 준설" },
];
const OPERATION_TABLE_HEADLIST = [ // 유지관리 정보 - 사업 현황
{ id: 'rowNumber', title: '일련번호' },
{ id: "bizInfoNm", title: "사업명" },
{ id: "flfmtBgngYmd", title: "수행시작일자" },
{ id: "flfmtEndYmd", title: "수행종료일자" },
{ id: "flfmtYr", title: "수행년도" },
{ id: "bizTypeCd", title: "사업유형" },
{ id: "bizAmt", title: "사업금액" },
];
const MAINTENANACE_TABLE_HEADLIST = [ // 유지관리 정보 - 보수 및 갱신 결과표
{ id: 'rowNumber', title: '일련번호' },
{ id: "fcltMngNo", title: "관리번호" },
{ id: "swtrmt_zone_nm", title: "처리구역" },
{ id: "usgCd", title: "용도" },
// { id: "lgalDrblyr", title: "내용연수" },
// { id: "acqsPrmpc", title: "취득금액" },
{ id: "mqltCd", title: "재질" },
{ id: "pdmtVl", title: "관경(mm)" },
{ id: "prlgVl", title: "연장(m)" },
{ id: "instlYmd", title: "설치일자" },
// { id: "instlYmd", title: "사업구분" },
];
const CLEANING_TABLE_HEADLIST = [ // 유지관리 정보 - 청소 및 준설 결과표
{ id: 'rowNumber', title: '일련번호' },
{ id: "fcltMngNo", title: "관리번호" },
{ id: "swtrmt_zone_nm", title: "처리구역" },
{ id: "usgCd", title: "용도" },
// { id: "lgalDrblyr", title: "내용연수" },
// { id: "acqsPrmpc", title: "취득금액" },
{ id: "mqltCd", title: "재질" },
{ id: "pdmtVl", title: "관경(mm)" },
{ id: "prlgVl", title: "연장(m)" },
{ id: "instlYmd", title: "설치일자" },
];
const INSPECTION_TABLE_HEADLIST = [ // 유지관리 정보 - 점검 및 진단 결과표
{ id: 'rowNumber', title: '일련번호' },
{ id: "fcltMngNo", title: "관리번호" },
{ id: "swtrmt_zone_nm", title: "처리구역" },
{ id: "usgCd", title: "용도" },
// { id: "riskEval", title: "위험평가" },
// { id: "acqsPrmpc", title: "취득금액" },
// { id: "lgalDrblyr", title: "내용연수" },
// { id: "remLife", title: "잔존수명" },
{ id: "mqltCd", title: "재질" },
{ id: "pdmtVl", title: "관경(mm)" },
{ id: "prlgVl", title: "연장(m)" },
{ id: "instlYmd", title: "설치일자" },
{ id: "strc_stts_grd", title: "구조적 상태등급" },
{ id: "oper_stts_grd", title: "운영적 상태등급" },
];
const fetchOperationFilteredData = async () => {
const RequestDTO: Record<string, string> = {
page: `${curPage - 1}`,
size: `${OPERATION_TABLE_SIZE}`,
};
if (var1) RequestDTO.bizInfoNm = var1;
if (var2 != '0' && var2) RequestDTO.bizTypeCd = var2;
// 수행기간 범위
const startDate = getDateString(
yearFilter.start,
'',
'',
"19000101",
);
const endDate = getDateString(
yearFilter.end,
'',
'',
`${todayYear}${todayMonth}${todayDate}`,
);
RequestDTO.fromflfmtEndYmd = startDate;
RequestDTO.toflfmtEndYmd = endDate;
// JSON.parse 사용으로 문자열 -> 배열 변환
RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"'));
const params = new URLSearchParams(RequestDTO).toString();
const res = await getData(`v1/bizInfo?${params}`);
const mappedContent = (res.content || []).map((item: any, index: number) => {
const formattedItem = formatDisplayData(item);
const matched = BIZ_TY_CD.find(code => code.lwpoCd === item.bizTypeCd);
return {
...formattedItem,
rowNumber: (curPage - 1) * OPERATION_TABLE_SIZE + index + 1, // 1부터 시작하는 일련번호 추가
bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd,
};
});
setTableData(mappedContent || []);
setTableCount(res.totalElements || 0);
setDetailTableData([]);
setDetailTableCount(0);
};
// 유지관리 정보 테이블 cellClick 핸들러
const handleOperationCellClick = async (index?: number | undefined, headId?: String) => {
setIsResetDetailPage(true); // 페이지를 1로 초기화
setIsResetDetailSelection(true); // 선택 초기화
setIsResetDetailPage(true); // 디테일 페이지를 초기화
if (typeof index === "number") {
setIsDetailLoading(true);
setDetailBizId(tableData[index].bizId);
setDetailCurPage(1); // 상세 페이지 초기화
try {
const RequestDTO: Record<string, string> = {
page: `${detailCurPage - 1}`,
size: `${OPERATION_TABLE_SIZE}`,
bizId: tableData[index % OPERATION_TABLE_SIZE].bizId,
};
RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"'));
const res = await getData(`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`);
const mappedData = (res.data || []).map((item: any, detailIndex: number) => {
const formattedItem = formatDisplayData(item);
const usgCdObj = MS_SWAGE_PPL_USE_CODES.find(code => code.val === item?.usgCd?.trim());
const mqltCdObj = MS_SWAGE_PPL_CODES.find(code => code.val === item?.mqltCd?.trim());
return {
...formattedItem,
rowNumber: (detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex + 1,
usgCd: usgCdObj ? usgCdObj.text : item.usgCd,
mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd,
swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : '-',
};
});
setDetailTableData(mappedData);
setDetailTableCount(res.count || 0);
} catch (error) {
console.error('유지관리 정보 cellClick 에러:', error);
} finally {
setIsDetailLoading(false);
setIsResetDetailPage(false);
setIsResetDetailSelection(false);
}
}
};
// 유지관리 정보 디테일 테이블 cellClick 핸들러
const handleOperationResultCellClick = async (index?: number | undefined, headId?: String) => {
if (typeof index === "number") {
setIsLoading(true);
const row = detailTableData[index];
const layerNm = row.lyrTypeNm;
const fid = row.fid;
const core = GisApp.Module.layer as any;
await core.showSearchResultsFromGeoJSON(layerNm, fid);
setIsLoading(false);
setIsSearchModalOpen(false); // SearchModal 닫기
}
};
// 유지관리 정보 디테일 테이블 페이지네이션 핸들러
const handleOperationPageNation = async (index?: number | undefined) => {
if (typeof index === "number") {
setIsDetailLoading(true);
try {
const RequestDTO: Record<string, string> = {
page: `${detailCurPage - 1}`,
size: `${OPERATION_TABLE_SIZE}`,
bizId: String(detailBizId),
};
const res = await getData(`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`);
const mappedData = (res.data || []).map((item: any, detailIndex: number) => {
const formattedItem = formatDisplayData(item);
const usgCdObj = MS_SWAGE_PPL_USE_CODES.find(code => code.val === item?.usgCd?.trim());
const mqltCdObj = MS_SWAGE_PPL_CODES.find(code => code.val === item?.mqltCd?.trim());
return {
...formattedItem,
rowNumber: (detailCurPage - 1) * OPERATION_TABLE_SIZE + detailIndex + 1,
usgCd: usgCdObj ? usgCdObj.text : item.usgCd,
mqltCd: mqltCdObj ? mqltCdObj.text : item.mqltCd,
swtrmt_zone_nm: item.swtrmt_zone_nm ? item.swtrmt_zone_nm : '-',
};
});
setDetailTableData(mappedData);
setDetailTableCount(res.count || 0);
} catch (error) {
console.error('유지관리 정보 페이지네이션 에러:', error);
} finally {
setIsDetailLoading(false);
}
}
};
// 유지관리 정보 Excel 다운로드 핸들러 추가
const operationExcelDownload = async () => {
if (tableData.length === 0) {
alert("다운로드 할 Excel 파일이 없습니다.\n조회 후 다시 시도해주세요.");
return;
}
toast.dismiss("global-confirm-toast");
confirmToast(
"파일 다운로드에는 2분 내외의 시간이 소요될 수 있습니다.\n파일 다운로드를 진행하시겠습니까?",
onAnalysis ?? (() => { }),
"text-xl font-medium text-gray-800"
);
async function onAnalysis() {
setIsLoading(true);
try {
const RequestDTO: Record<string, string> = {
size: '10000',
};
if (var1) RequestDTO.bizInfoNm = var1;
if (var2 != '0' && var2) RequestDTO.bizTypeCd = var2;
// 수행기간 범위
const startDate = getDateString(
yearFilter.start,
'',
'',
"19000101",
);
const endDate = getDateString(
yearFilter.end,
'',
'',
`${todayYear}${todayMonth}${todayDate}`,
);
RequestDTO.fromflfmtEndYmd = startDate;
RequestDTO.toflfmtEndYmd = endDate;
// JSON.parse 사용으로 문자열 -> 배열 변환
RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"'));
const params = new URLSearchParams(RequestDTO).toString();
const res = await getData(`v1/bizInfo?${params}`);
// 헤더 매핑 정의
const headerMapping: { [key: string]: string } = {
'bizInfoNm': '사업명',
'flfmtBgngYmd': '수행시작일자',
'flfmtEndYmd': '수행종료일자',
'flfmtYr': '수행년도',
'bizTypeCd': '사업유형',
'bizAmt': '사업금액(원)',
};
const bizTyCdMap = new Map(BIZ_TY_CD.map(code => [code.lwpoCd, code.lwpoCdNm]));
const mappedContent = (res.content || []).map((item: any) => {
const formattedItem = formatDisplayData(item);
return {
bizInfoNm: formattedItem.bizInfoNm ?? item.bizInfoNm ?? '',
flfmtBgngYmd: formattedItem.flfmtBgngYmd ?? item.flfmtBgngYmd ?? '',
flfmtEndYmd: formattedItem.flfmtEndYmd ?? item.flfmtEndYmd ?? '',
flfmtYr: formattedItem.flfmtYr ?? item.flfmtYr ?? '',
bizTypeCd: bizTyCdMap.get(item.bizTypeCd) || item.bizTypeCd || formattedItem.bizTypeCd || '',
bizAmt: ((v) => (v === 0 || v === '0') ? '0' : (v ?? ''))(formattedItem.bizAmt ?? item.bizAmt),
};
});
// 선택된 행 데이터를 Excel 다운로드 형식으로 변환
const excelData = mappedContent; // 배열로 감싸서 전달
// 파일명 생성 (분석번호 기반)
const label = WORK_TYPES.find((type) => type.val === workType)?.text
const fileName = `${label} 사업 현황_${todayYear}${todayMonth}${todayDate}`;
await downloadExcelWithKoreanHeaders({
data: excelData as JSON[],
fileName: fileName,
sheetNameArray: [fileName],
headerRange: "A1:F1",
headerMapping: headerMapping
});
setIsLoading(false);
} catch (error) {
setIsLoading(false);
console.error("Excel 다운로드 중 오류 발생:", error);
alert("Excel 다운로드 중 오류가 발생했습니다. 다시 시도해주세요.");
}
}
};
// 유지관리 정보 결과표 Excel 다운로드 핸들러 추가
const operationResultExcelDownload = async () => {
if (detailTableData.length === 0) {
alert("다운로드 할 Excel 파일이 없습니다.\n조회 후 다시 시도해주세요.");
return;
}
toast.dismiss("global-confirm-toast");
confirmToast(
"파일 다운로드에는 2분 내외의 시간이 소요될 수 있습니다.\n파일 다운로드를 진행하시겠습니까?",
onAnalysis ?? (() => { }),
"text-xl font-medium text-gray-800"
);
async function onAnalysis() {
setIsLoading(true);
try {
const RequestDTO: Record<string, string> = {
size: '100',
bizId: String(detailBizId),
};
const res = await getData(`v1/bizInfo/fclts?${new URLSearchParams(RequestDTO).toString()}`);
// 헤더 매핑 정의
const headerMapping: { [key: string]: string } = {
'fcltMngNo': '관리번호',
'swtrmt_zone_nm': '처리구역',
'usgCd': '용도',
'mqltCd': '재질',
'pdmtVl': '관경(mm)',
'prlgVl': '연장(m)',
'instlYmd': '설치일자',
'strc_stts_grd': '구조적 상태등급',
'oper_stts_grd': '운영적 상태등급',
};
const usgCdMap = new Map(MS_SWAGE_PPL_USE_CODES.map(code => [code.val, code.text]));
const mqltCdMap = new Map(MS_SWAGE_PPL_CODES.map(code => [code.val, code.text]));
// 🎯 단일 반복문으로 최적화 - 각 아이템마다 필요한 필드만 직접 매핑
const mappedData = (res.data || []).map((item: any) => {
const formattedItem = formatDisplayData(item);
// trim 한 번만 수행
const trimmedUsgCd = item?.usgCd?.trim();
const trimmedMqltCd = item?.mqltCd?.trim();
// zone 값 한 번만 계산
const zone = item.swtrmtZoneNm ?? formattedItem.swtrmt_zone_nm ?? item.swtrmt_zone_nm ?? '';
// 직접 매핑 - 반복문 없이 한 번에 객체 생성
return {
fcltMngNo: formattedItem.fcltMngNo ?? item.fcltMngNo ?? '',
swtrmt_zone_nm: (zone === null || zone === '') ? '-' : zone,
usgCd: usgCdMap.get(trimmedUsgCd) || item.usgCd || formattedItem.usgCd || '',
mqltCd: mqltCdMap.get(trimmedMqltCd) || item.mqltCd || formattedItem.mqltCd || '',
pdmtVl: formattedItem.pdmtVl ?? item.pdmtVl ?? '',
prlgVl: formattedItem.prlgVl ?? item.prlgVl ?? '',
instlYmd: formattedItem.instlYmd ?? item.instlYmd ?? '',
strc_stts_grd: ((v) => (v === 0 || v === '0') ? '0' : (v ?? ''))(formattedItem.strc_stts_grd ?? item.strc_stts_grd),
oper_stts_grd: ((v) => (v === 0 || v === '0') ? '0' : (v ?? ''))(formattedItem.oper_stts_grd ?? item.oper_stts_grd),
};
});
// 선택된 행 데이터를 Excel 다운로드 형식으로 변환
const excelData = mappedData; // 배열로 감싸서 전달
// 파일명 생성 (분석번호 기반)
const label = WORK_TYPES.find((type) => type.val === workType)?.text
const fileName = `${label} 결과표_${todayYear}${todayMonth}${todayDate}`;
await downloadExcelWithKoreanHeaders({
data: excelData as JSON[],
fileName: fileName,
sheetNameArray: [fileName],
headerRange: "A1:I1",
headerMapping: headerMapping
});
setIsLoading(false);
} catch (error) {
setIsLoading(false);
console.error("Excel 다운로드 중 오류 발생:", error);
alert("Excel 다운로드 중 오류가 발생했습니다. 다시 시도해주세요.");
}
}
};
return {
WORK_TYPES,
OPERATION_TABLE_HEADLIST,
MAINTENANACE_TABLE_HEADLIST,
CLEANING_TABLE_HEADLIST,
INSPECTION_TABLE_HEADLIST,
OPERATION_TABLE_SIZE,
fetchOperationFilteredData,
handleOperationCellClick,
handleOperationResultCellClick,
handleOperationPageNation,
operationExcelDownload,
operationResultExcelDownload
};
};
@@ -1,188 +0,0 @@
/**
* 📄 useReportTab Hook
* 검색 모달 보고서 탭 훅
*
* - 계층적 폴더 구조 평탄화 및 데이터 변환
* - 파일 목록 조회 및 페이징 처리
* - 파일 다운로드 기능
* - 셀 클릭 이벤트 처리
* - Zustand를 통한 상태 관리 및 로딩 처리
* - 데이터 매핑 및 코드 변환 (시도/시군구, 보고서 유형 등)
*
* 🔗 사용 API
* - 보고서 파일 조회: GET /api/v1/file (dc-service)
* - 파일 다운로드: GET /api/v1/fileDownload (dc-service)
*/
import { getDcData } from '@/components/lib/api';
import { fileDownload } from '@/utils';
import { useReportTabStore } from '@/store/useReportTabStore';
import useSearchModalStore from '@/store/useSearchModalStore';
export type Folder = {
clsfSystmSn: string;
upClsfSystmSn: string;
clsfSystmNm: string;
children: Folder[];
};
export type FlattenedFolder = {
[key: string]: string | number | undefined;
};
export type SelectDataType = {
text: string;
val: string;
sido?: string; // 시도 코드 (선택 사항)
}
export type ReportDataFile = {
no: number;
fileTitle: string;
regDt: string;
fileSn: string;
filePath: string;
}
export default function useReportTab() {
const REPORT_TYPES = [
{ val: '0', text: '전체' },
{ val: '1', text: '보고서' },
{ val: '2', text: '요약보고서' },
{ val: '3', text: '부록' },
{ val: '4', text: '기타' }
];
const { curPage, setIsLoading } = useSearchModalStore();
const { dcFiles: filesOrigin, files, setFiles } = useReportTabStore();
/**
* Flatten a hierarchical folder structure into a flat array.
* @param data - The hierarchical folder data to flatten.
* @returns An array of flattened folders with their paths and levels.
*/
const flattenFolders = (data: Folder[]): FlattenedFolder[] => {
const result: FlattenedFolder[] = [];
const exceptNodeNm = ["부분", "통합", "전체"];
const addNodeNm = ["보고서", "요약보고서", "부록", "기타"]; // level5
const columnNms: { [key: number]: string } = {
0: "root", 1: "sido", 2: "sigungu", 3: "approvalYear"
, 4: "plantZoneGubun", 5: "reportGubun", 7: "fileName", 8: "regDate"
};
const traverse = (
node: Folder,
path: { [key: string]: string | number },
level: number
) => {
const isYear = (value: string): boolean => {
return /^[0-9]{4}년$/.test(value);
};
if (level === 2 && isYear(node.clsfSystmNm)) {
level++;
}
if (level === 4 && addNodeNm.includes(node.clsfSystmNm)) {
level++; // 레벨을 증가시켜 하위값에 추가되도록 함.
}
const newPath = {
...path,
[`${columnNms[level]}`]: node.clsfSystmNm,
};
if (node.children.length === 0) {
newPath.no = result.length + 1;
newPath.clsfSystmSn = node.clsfSystmSn; // 파일 조회를 위한 고유번호
result.push(newPath);
} else {
if (level === 4) {
if (exceptNodeNm.includes(node.clsfSystmNm)) {
level--; // 제외할 노드 이름인 경우, 현재의 레벨에 하위값이 써지도록 함.
}
}
node.children.forEach((child) => {
traverse(child, newPath, level + 1);
});
}
}
if (data && data.length > 0 && data[0]?.clsfSystmNm == "기본계획") { // data가 비어있지 않고 children이 있는 경우에만 처리
data.forEach((root) => traverse(root, {}, 0));
} else {
data.forEach((root) => traverse(root, {}, 1));
}
return result;
}
const getDcFiles = async (clsfSystmSn: string): Promise<ReportDataFile[]> => {
if (!clsfSystmSn) {
console.error("clsfSystmSn is required to get files.");
return [];
}
const queryString = 'clsfSystmSn=' + clsfSystmSn;
const result = await getDcData(`/api/v1/file${queryString ? `?${queryString}` : ''}`);
return result;
};
// 파일 이름 추가
const addFileName = async (data: FlattenedFolder[]) => {
const fileReportData = await Promise.all(
data.map(async (item) => {
const fileArr: ReportDataFile[] = await getDcFiles(String(item.clsfSystmSn));
let result: FlattenedFolder;
if (fileArr.length > 0) {
const f = fileArr[0];
const fCount = fileArr.length;
result = { ...item, fileName: f?.fileTitle + " 외 " + fCount + "건", regDate: f?.regDt, fileSn: f?.fileSn, filePath: f?.filePath };
} else {
result = { ...item, fileName: "", regDate: "", fileSn: "", filePath: "" };
}
return result;
})
);
return fileReportData;
};
const getDcFileDownload = async (fileSn: string, fileName: string, filePath: string) => {
if (filePath === "") {
alert(`filePath 정보가 없으므로 다운로드가 불가합니다.\nfilesn: ${fileSn}`);
return;
}
const queryString = 'filePath=' + encodeURIComponent(filePath);
const responseBlob = await getDcData(`/api/v1/fileDownload${queryString ? `?${queryString}` : ''}`, "blob");
fileDownload(responseBlob, fileName);
}
// 파일 목록 조회
const getFiles = async () => {
setIsLoading(true); // 로딩 시작
try {
let data = filesOrigin;
data = data?.slice((curPage - 1) * 10, curPage * 10); // paging
data = data?.map((item, index) => ({ ...item, no: (curPage - 1) * 10 + index + 1 }));
setFiles(data);
} catch (error) {
console.error("파일 목록 조회 에러:", error);
} finally {
setIsLoading(false); // 로딩 종료
}
};
// cellClick 핸들러
const handleCellClick = (index: number | undefined) => {
if (typeof index === "number" && files[index]) {
const fileName = files[index].fileTitle;
const filePath = files[index].filePath;
if (files[index].fileSn !== "") {
getDcFileDownload(files[index].fileSn, fileName, filePath);
}
}
};
return {
REPORT_TYPES,
flattenFolders,
addFileName,
getDcFiles,
getDcFileDownload,
getFiles,
handleCellClick
};
}
@@ -1,161 +0,0 @@
/**
* 📄 useSearchModal Hook
* 검색 모달 훅
*
* - 탭 기반 검색 인터페이스 관리 (기본정보, 유지관리 정보, 보고서)
* - 필터링된 데이터 조회 및 포맷팅
* - 조회 버튼을 통한 데이터 검색 트리거
* - 여러 시설 보기 기능
* - Zustand를 통한 상태 관리 및 로딩 처리
* - 데이터 매핑 및 코드 변환 (사업 유형 등)
*
* 🔗 사용 API
* - 유지관리 정보 목록 조회: GET /api/v1/bizInfo (sewerage-service)
*/
import { getData } from "@/components/lib/api";
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
import useStore from "@/store/useStore";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import useSearchModalStore from "@/store/useSearchModalStore";
import useBasicTab from './useBasicTab';
import useOperationTab from './useOperationTab';
export default function useSearchModal() {
const {
activeTab, workType,
tableData, detailTableData,
curPage,
var1, var2,
yearFilter,
todayYear, todayMonth, todayDate,
setIsSearchModalOpen,
setShowTable,
setTableData, setDetailTableData,
setTableCount, setDetailTableCount,
setCurPage,
setIsLoading,
setIsResetPage, setIsResetDetailPage,
setIsResetSelection, setIsResetDetailSelection,
getDateString
} = useSearchModalStore();
const { fetchBasicFilteredData } = useBasicTab();
const { OPERATION_TABLE_SIZE } = useOperationTab();
const { BIZ_TY_CD } = useStore();
const TABS = [
{ val: "basic", text: "기본 정보" },
{ val: "operation", text: "유지관리 정보" },
{ val: "report", text: "하수도 정비 기본계획 보고서" },
];
// 검색 API 호출 함수
const fetchFilteredData = async () => {
setIsLoading(true); // 로딩 시작
setIsResetSelection(true); // 선택 초기화
setIsResetDetailSelection(true); // 선택 초기화
try {
if (activeTab === 'basic') {
fetchBasicFilteredData();
} else if (activeTab === 'operation') {
const RequestDTO: Record<string, string> = {
page: `${curPage - 1}`,
size: `${OPERATION_TABLE_SIZE}`,
};
if (var1) RequestDTO.bizInfoNm = var1;
if (var2 != '0' && var2) RequestDTO.bizTypeCd = var2;
// 수행기간 범위
const startDate = getDateString(
yearFilter.start,
'',
'',
"19000101",
);
const endDate = getDateString(
yearFilter.end,
'',
'',
`${todayYear}${todayMonth}${todayDate}`,
);
RequestDTO.fromflfmtEndYmd = startDate;
RequestDTO.toflfmtEndYmd = endDate;
// JSON.parse 사용으로 문자열 -> 배열 변환
RequestDTO.workTypes = JSON.parse(workType.replace(/'/g, '"'));
const params = new URLSearchParams(RequestDTO).toString();
const res = await getData(`v1/bizInfo?${params}`);
const mappedContent = (res.content || []).map((item: any, index: number) => {
const formattedItem = formatDisplayData(item);
const matched = BIZ_TY_CD.find(code => code.lwpoCd === item.bizTypeCd);
return {
...formattedItem,
rowNumber: (curPage - 1) * OPERATION_TABLE_SIZE + index + 1, // 1부터 시작하는 일련번호 추가
bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd,
};
});
setTableData(mappedContent || []);
setTableCount(res.totalElements || 0);
setDetailTableData([]);
setDetailTableCount(0);
}
setShowTable(true);
setIsResetSelection(false);
} catch (err) {
console.error('API ERROR:', err);
setTableData([]);
setIsResetSelection(false);
} finally {
setIsLoading(false); // 로딩 종료
setIsResetSelection(false);
// setIsResetDetailSelection(false);
}
};
// 조회 버튼 클릭 시 1페이지로 이동 및 데이터 초기화
const handleSearch = () => {
setCurPage(1); // 페이지를 1로 초기화
fetchFilteredData();
setIsResetPage(true); // 페이지를 초기화
setIsResetDetailPage(true); // 디테일 페이지를 초기화
setIsResetSelection(false); // 선택 초기화
setIsResetDetailSelection(false); // 선택 초기화
setShowTable(true);
};
// 여러 시설 보기
const viewMultipleFeature = async () => {
if (activeTab === 'basic') {
if (!(tableData.length === 0)) {
setIsLoading(true);
const row = tableData[0];
const layerNm = row.lyrTypeNm;
const fids = tableData.map((item) => item.fid);
const core = GisApp.Module.layer as any;
await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids);
setIsLoading(false);
setIsSearchModalOpen(false);
}
} else if (activeTab === 'operation') {
if (!(detailTableData.length === 0)) {
setIsLoading(true);
const row = detailTableData[0];
const layerNm = row.lyrTypeNm;
const fids = detailTableData.map((item) => item.fid);
const core = GisApp.Module.layer as any;
await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids);
setIsLoading(false);
setIsSearchModalOpen(false);
}
}
}
return { TABS, getDateString, handleSearch, viewMultipleFeature, fetchFilteredData };
};
@@ -1,98 +0,0 @@
/**
* ➡️ 경로 : 하수관망도 메인페이지 내 침수심 범례
*
* 📄 WaterDepth Component
* 침수심 범례 컴포넌트
*
* - 침수심 레이어의 범례 표시 및 애니메이션 재생 제어
* - 진행률 바를 통한 애니메이션 상태 시각화
* - 플레이/일시정지 버튼을 통한 사용자 상호작용
* - 범례 항목별 색상과 레이블 표시
*/
"use client";
import { Label } from "@common/components/ui/label";
import { useEffect } from "react";
export const WATER_DEPTH_LAYER_TYPES: string[] = [
'tb_ms_flood_dngr_map', // 홍수위험지도
'tb_ms_sbmrg_dngr_map' // 침수위험지도
];
interface WaterDepthProps {
onClose?: () => void;
isPlaying: boolean;
setIsPlaying: (isPlaying: boolean) => void;
totalSteps?: number;
currentStep?: number;
}
export function WaterDepth({
onClose,
isPlaying,
setIsPlaying,
totalSteps = 0,
currentStep = 0,
}: WaterDepthProps) {
useEffect(() => {
if (setIsPlaying) setIsPlaying(isPlaying);
}, [isPlaying, setIsPlaying]);
const handlePlayToggle = () => {
setIsPlaying(!isPlaying);
};
// 진행률 계산 (0-100%)
const progressPercentage = totalSteps > 0 ? (currentStep / totalSteps) * 100 : 100;
return (
<>
<div className="bg-white rounded-lg shadow-lg border border-gray-200 w-[160px] px-4 py-3">
<div className="flex items-center justify-between mx-2 my-4">
<Label className="text-3xl text-gray-900 font-bold">침수심 범례</Label>
<button type="button" className="mt-1 ml-2" onClick={handlePlayToggle}>
{isPlaying ? (
<img src="/images/map/toolbar/pause_button.png" alt="pause" className="w-6 h-6 object-contain" />
) : (
<img src="/images/map/toolbar/play_button_arrowhead.png" alt="play" className="w-6 h-6 object-contain" />
)}
</button>
</div>
<div className="mb-3">
<div className="w-full bg-gray-200 rounded-full h-1">
<div
className="bg-blue-900 h-1 rounded-full transition-all duration-300 ease-in-out"
style={{ width: `${progressPercentage}%` }}
/>
</div>
</div>
<div className="w-[140px] mx-auto flex flex-col items-center">
<div className="bg-blue-900 text-white text-2xl text-center py-1 mb-2 rounded w-full">침 수 심</div>
<div className="space-y-1 w-full">
<LegendItem color="rgb(255, 255, 127)" label="0.0~0.5m 미만" /> {/* 코드: N330 */}
<LegendItem color="rgb(191, 255, 0)" label="0.5~1.0m 미만" /> {/* 코드: N331 */}
<LegendItem color="rgb(0, 255, 255)" label="1.0~2.0m 미만" /> {/* 코드: N332 */}
<LegendItem color="rgb(191, 127, 255)" label="2.0~5.0m 미만" /> {/* 코드: N333 */}
<LegendItem color="rgb(255, 0, 127)" label="5.0m 이상" /> {/* 코드: N334 */}
</div>
<div className="border-t-2 border-blue-900 mt-1 mx-auto w-full" />
</div>
</div>
</>
);
}
function LegendItem({ color, label }: { color: string; label: string }) {
return (
<div className="flex items-center gap-2 w-full">
<span
className="inline-block w-16 h-6"
style={{ backgroundColor: color, border: "1px solid #ccc" }}
/>
<span className="text-xl text-gray-900 font-bold">{label}</span>
</div>
);
}
@@ -1,59 +0,0 @@
/**
* ➡️ 경로 : I/I분석, 유지관리 정보 모든 페이지
*
* 📄 With Base Layout
* Next.js App Router (app/) 기반 분석 페이지 기본 레이아웃 컴포넌트
*
* - 메뉴 클릭 이벤트 처리 및 라우팅
* - 현재 메뉴 정보 Zustand를 통한 상태 관리
* - 시도/시군구/사업 구분/사업 유형 데이터 초기 로드
* - BaseLayout을 통한 공통 레이아웃 적용
* - QueryClient를 통한 데이터 캐싱 및 관리
* - 경로 변경 시 메뉴 정보 동기화
*/
"use client";
import React, { useEffect} from 'react';
import { usePathname, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { BaseLayout } from "@common/components";
import type { MenuItem } from "@/entities/menu";
import { useMenuStore } from "@/shared/lib";
import { PageLayout } from "@/shared/ui/page-layout";
import useStore from '../../store/useStore';
export default function WithBaseLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
const queryClient = useQueryClient();
const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo);
const { fetchCtpv, fetchSgg, fetchBIZ_SE_CD, fetchBIZ_TY_CD } = useStore();
useEffect(() => {
fetchCtpv();
fetchSgg();
fetchBIZ_SE_CD();
fetchBIZ_TY_CD();
}, []);
const handleMenuClick = (item: MenuItem) => {
setCurrentMenuInfo(item);
router.push(item.urlPath);
};
return (
<BaseLayout
queryClient={queryClient}
currentPath={pathname ?? ""}
handleMenuClick={handleMenuClick}
>
<PageLayout>{children}</PageLayout>
</BaseLayout>
);
}
@@ -1,97 +0,0 @@
# 노후관로 관리
---
## 화면구성
### GIS 기본화면
-노후관로 조건에 맞지 않는 관로를 숨기지 않고, 기존 시설물 레이어는 원래대로 모두 보이게 두고 조건에 맞는 관로만 빨간 강조선을 추가하도록 한다.
- 좌측 상단 (시설물 레이어 트리 및 목록 아이콘): 시설, 침수위험지도, 지반침하정보(우측에 목록 아이콘), 노후관로(우측에 목록 아이콘)
- 좌측 하단 (레이어별 설정 항목 및 적용 버튼): 경과년도(년) [ 디폴트 30년 ]
- 우측 영역(배경 지도 및 시설레이어 표출): 하수관로 중 경과년도가 30년 이상된 관로들을 시각적으로 강조 표시
### 관로정보 상세화면
- GIS 화면에서 관로 위치 클릭시 우측 정렬로 세로형 영역으로 관로정보 상세화면 팝업
- 1차년도에서 개발한 관로정보 표출 : 시설관리번호, 기관관리번호, 설치일자, 도엽번호 등 전체 항목 그대로 표출
### 노후관로정보 목록화면
- 윈도우형 팝업
- GIS 기본화면 좌측 상단의 레이어 트리에서 노후관로 목록 아이콘 클릭시 윈도우 팝업으로 가운데 영역으로 노후관로정보 목록화면 팝업
- 상단 조회조건( 조회조건 영역 ) : 경과년도, 시도, 시군구, 버튼 2개 (조회, 다운로드)
- 하단 가로형 그리드 (노후관로 현황) : NO, 시설관리번호, 설치일자, 관경(mm), 연장(m), 용도, 재질, 경과년도
---
## 화면동작
### 초기화 및 기본 설정
```구조적표기
// 화면 로드 시 초기 데이터 및 환경 설정
ON_LOAD:
CALL GIS_지도_레이어_초기화()
SET 기본_경과년도 = 20 // 회의록(260311) 반영 디폴트 설정
SET 현재_조회_조건 = { "경과년도": 20, "관종": "전체" }
// 초기 데이터 로드
CALL 노후관로_데이터_조회(현재_조회_조건)
CALL 지도_상_노후관로_표출(조회결과)
```
### 좌측 사이드바 제어 (분석 설정)
```구조적표기
// 노후관로 조회 조건 변경 및 적용
IF 노후관로_조회_버튼.CLICK THEN
SET 입력_경과년도 = 사이드바.경과년도_INPUT.Value
SET 선택_관종 = 사이드바.관종_SELECT.Value
// 공간 및 속성 필터링 실행
CALL 노후관로_필터링_프로세스(입력_경과년도, 선택_관종)
REFRESH_GIS_LAYER(노후관로_레이어)
SHOW MESSAGE "설정된 조건으로 노후관로가 필터링되었습니다."
END IF
// 목록 아이콘 클릭 (모달 호출)
IF 레이어트리.노후관로_목록아이콘.CLICK THEN
DISPLAY 노후관로_목록_모달 = TRUE
CALL 노후관로_목록_그리드_로드()
END IF
```
#### 노후관로 상세화면 (우측 패널)
```구조적표기
// 지도 상의 특정 관로 클릭 시
IF GIS.노후관로_객체.CLICK THEN
SET 선택관로_ID = 클릭객체.ID
// 상세 속성 정보 조회 및 매핑
CALL 관로_상세속성_API_호출(선택관로_ID)
SET 우측패널.기본정보 = [관로ID, 시설명, 주소, 관리기관]
SET 우측패널.제원정보 = [관경, 연장, 관종, 준공일자, 경과년도]
SET 우측패널.상태정보 = [최종조사일, 조사결과등급]
DISPLAY 우측_상세_패널 = TRUE
END IF
```
#### 노후관로 목록 및 데이터 출력 (모달 윈도우)
```구조적표기
// 엑셀 다운로드 (회의록 요구사항 반영)
IF 모달.다운로드_버튼.CLICK THEN
// 엑셀 파일에는 관로의 '모든 기본정보'를 포함
SET 다운로드_컬럼 = [관로ID, 주소, 관경, 연장, 관종, 준공일, 경과년도, ...전체필드]
CALL 시스템_엑셀_추출_모듈(현재_조회_데이터, 다운로드_컬럼)
END IF
// 목록 그리드 행 선택 시 로직
IF 모달.그리드_행.DOUBLE_CLICK THEN
SET 좌표 = 현재행.중심좌표
CALL GIS_지도_줌인(좌표)
CALL 노후관로_상세정보_표출(현재행.ID)
CLOSE_MODAL(노후관로_목록_모달)
END IF
```
-38
View File
@@ -1,38 +0,0 @@
## html 디렉터리
- 이 디렉터리는 하수관로 운영관리 시스템 `sn` 2차사업 고도화 과정에서 퍼블리싱, 정적 HTML 시안, 로직 검토용 화면을 다루는 영역이다.
- 작업 시 `apps/sn/app/html` 이외의 수정이 되지 않도록 해야 한다.
- 추가 `*.md` 파일의 요청대로 만들되 상위 앱 전체를 건드리지 않고 `apps/sn/app/html` 하위에 필요한 파일을 만들어서 처리한다.
- 기존 `packages/common/components`, `packages/common/business_components`는 활용할 수 있지만, 직접 수정 권한은 상위 `apps/sn/AGENTS.md` 기준을 따른다.
- 지도/html 시안에서 `<Gis>` 시설물 layer를 다루는 작업은 API 호출 결과 값이 설정된 대로 zoom level에 따라 모두 보이는지 확인한다.
## 2차사업 원칙
- 작업은 리팩토링 전용이 아니라 2차사업 화면 고도화 목적에 맞춰 수행한다.
- 화면 변경은 요구사항, 화면설계, 기능 명세를 근거로 한다.
- html 시안 작업과 실제 앱 로직 작업이 함께 필요해 보이면 먼저 구분해서 설명한다.
## Encoding
- UTF-8
- 한글이 깨지지 않도록 주의할 것
## 주석 comment
- 한글 주석을 삭제하지 말 것
- 복잡한 화면 흐름이나 데이터 바인딩 로직은 함수/메서드 단위 설명을 충분히 작성할 것
## Monorepo structure
apps/
_template
as
cm
cs
dc
dm
dm_new
fo
sn
st
wr
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,7 @@
# 시설 상세 패널 레이아웃 수정 검증
> 2026-09-11 API 계약 대체 이력: 본문의 과거 호출 URL은 당시 검증·구현 증거이며 현재 호출 계약이 아니다. 현재는 lowerCamelCase 단일 경로만 지원하고 기존 alias는 제거된다. [경로 단일화 변경기록](../../작업계획/SN-API경로단일화/001-fe-SN-API경로단일화-변경기록.md)의 현재 경로로 검증한다.
## 목적 및 원본 근거
- 검증일: 2026-09-04
@@ -8,3 +8,10 @@
- `pnpm --filter sn build`
- 결과: 시간 제한 내 완료되지 않아 최종 성공/실패를 확정하지 못했다.
- 비고: 이 저장소는 기존에도 FE build가 환경 영향으로 지연 또는 `EMFILE` 이슈가 있었으므로 사용자 로컬 실행 확인이 추가로 필요하다.
## 3. 2026-09-10 공통 목록 정합성 확인
- 사용자 지시에 따라 그리드 제목의 `총 18건` 표기를 제거하고 `시도별 노후관로 목록`만 유지했다.
- 순번은 전체 건수 기준 역순으로 계산해 로컬 3002에서 1페이지 18~9, 2페이지 8~1 표출을 확인했다.
- 원본 근거는 요구사항정의서 `SFR-011-001`, 화면설계서 8~9번 `SN_MN010102`이며 순번·제목 건수는 원본 필수항목이 아닌 공통 그리드 표시 규칙이다.
- API, 조회조건, 연도 그룹 헤더와 Excel 다운로드 형식은 변경하지 않았다.
@@ -212,3 +212,12 @@
| 브라우저 수동 검증 | 대기 | 실제 결과 행 클릭 후 격자망 체크·WFS 표출 확인 필요 |
- Prettier 실행 파일은 현재 workspace에 설치되어 있지 않아 별도 포맷 검사는 수행하지 못했다.
## 16. 2026-09-17 현재·과거분석이력 위험 판정기준 안내 검증
- 현재·과거분석이력이 같은 `MapPopupDataTable`의 `actions` 안내문을 공유하므로 두 탭에 동일한 판정기준이 표시되는 구조를 정적 확인했다.
- 안내문이 `(GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면 위험, 없으면 정상)`으로 반영됐고 지도 요약카드의 기존 기준과 일치함을 확인했다.
- 안내문의 `위험=#ff1c15`, `정상=#1677ff` 색상과 굵기 또한 지도 요약카드와 동일하게 적용했다.
- 데이터 조회, 행 선택, 위험 판정, API 요청·응답 코드는 변경하지 않았다.
- 대상 `WaterRiskPopupPage.tsx` ESLint와 프론트·공통 문서 저장소의 `git diff --check`가 통과했다.
- 실행 중인 `sn` 개발 서버가 없어 로그인 화면의 실제 줄바꿈·배치는 확인하지 않았다.

Some files were not shown because too many files have changed in this diff Show More