Merge branch 'iipbs/sn/dev'
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 또는 별도 터미널에서 직접 실행한 프론트엔드 개발 서버는 임의로 종료·재시작·교체하지 않는다. 종료가 필요하면 먼저 사용자에게 확인한다.
|
||||
|
||||
+12
-4
@@ -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={
|
||||
|
||||
+1
-1
@@ -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 편집 버튼과 직접 연결된다.
|
||||
|
||||
+175
-325
@@ -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],
|
||||
|
||||
+9
-10
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
+104
-1864
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 +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...
|
||||
+14
-12
@@ -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}
|
||||
|
||||
+50
-446
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
<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>
|
||||
|
||||
<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 }),
|
||||
}));
|
||||
-629
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user