From e5f7778861a048e7bcca4ea78fb47c5c48655c03 Mon Sep 17 00:00:00 2001 From: ljpark Date: Fri, 2 Oct 2026 18:07:00 +0900 Subject: [PATCH 1/2] =?UTF-8?q?sn:=20=ED=99=95=EB=A5=A0=EA=B0=95=EC=9A=B0?= =?UTF-8?q?=EB=9F=89=20=EB=AA=A8=EB=8B=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ce-SFR-011-008-침수예측현황-확률강우량-작업계획.md | 41 ++++ ...fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md | 19 ++ .../fldngPredcPrst/ui/PrbbtRnflDialog.tsx | 204 ++++++++++++++++++ 3 files changed, 264 insertions(+) create mode 100644 apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md create mode 100644 apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md create mode 100644 apps/sn/src/features/fldngPredcPrst/ui/PrbbtRnflDialog.tsx diff --git a/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md b/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md new file mode 100644 index 000000000..18497c7c6 --- /dev/null +++ b/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md @@ -0,0 +1,41 @@ +# SFR-011-008 침수예측현황 확률강우량 FE 유지보수 작업계획 + +## 작업 목적 + +침수예측현황의 선택 시군구가 어떤 확률강우량 기준으로 위험도가 산정되는지 확인할 수 있도록 목록 열과 기준표 팝업을 추가한다. + +## 승인 상태 + +- `maintenance` 작업계획 +- 2026-10-02 사용자 승인, 코드 구현 진행 + +## 구현 계획 + +- `FldngPredcPrstPopupContent`의 `침수위험도` 오른쪽에 `확률강우량` 액션을 추가한다. +- 기준표 팝업 컴포넌트 파일은 표준용어를 사용한 `PrbbtRnflDialog.tsx`로 둔다. +- 목록의 확률강우량은 `predc_rain_hr` 기준 `N시간` 형식의 클릭 가능한 지속시간으로 표시한다. 기존 `predictionHour`(`predc_hr`)와 구분한다. +- 선택 행의 `sggCd`로 BE 확률강우량 조회 API를 호출한다. +- API 경로는 사용자 지정 `GET /api/v1/fldng/predc/prst/prbbt-rnfl`을 사용한다. +- 팝업 표는 재현기간을 행, `1시간`, `2시간`, `3시간` 형식의 지속시간을 열로 표시한다. +- 팝업 하단에 확률관측소코드·관측소명·면적비율(%) 비율표를 표시한다. +- 팝업 맨 아래에 `시간은 강우가 지속된 시간입니다` 안내를 표시한다. +- 지속시간 헤더는 기존 팝업 표 헤더 스타일을 유지한다. +- 선택 행의 대표 지속시간에 해당하는 열의 확률강우량 기준값 셀들을 강조 색상으로 표시한다. 표 헤더는 기존 스타일을 유지한다. +- 강수량은 mm 단위로 표시하고 로딩·빈 결과·오류 상태를 제공한다. +- 기존 행 클릭의 도시침수지도 이동과 별도 액션으로 동작시킨다. + +## 검증 계획 + +- 제주·서울 등 서로 다른 시군구 기준표 조회 +- 1~72시간 행과 빈도년도 열 표시 +- API 오류·빈 결과 표시 +- 기존 지도 이동 회귀 +- `pnpm --filter sn run build` + +## 구현 결과 (2026-10-02) + +- 현황 목록에 `확률강우량` 열을 추가하고 대표 지속시간 `predcRainHr`를 `N시간` 링크로 표시한다. +- 링크 클릭은 행 선택 지도 이동을 실행하지 않으며, 시군구별 기준표 팝업을 연다. +- 팝업은 재현기간 행×지속시간 열의 확률강우량, 하단 관측소 면적비율과 API 합계를 표시한다. +- 선택 지속시간 열의 값 셀만 강조하고 헤더는 기존 스타일을 유지한다. +- `pnpm --filter sn run typecheck`, `git diff --check`가 통과했다. 3002 포트 개발 서버 실행 중이므로 build는 실행하지 않았다. diff --git a/apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md b/apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md new file mode 100644 index 000000000..a7071cccf --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md @@ -0,0 +1,19 @@ +# SFR-011-008 침수예측현황 확률강우량 FE 구현기록 + +## 구현 내용 +- 확률강우량 팝업 컴포넌트는 `PrbbtRnflDialog.tsx`에 둔다. +- 침수위험도 오른쪽에 확률강우량 열을 추가하고 대표 누적강우 지속시간 `predcRainHr`를 `N시간` 링크로 표시했다. +- 링크는 `GET /api/v1/fldng/predc/prst/prbbt-rnfl?sggCd=...`를 호출한다. +- 팝업 본문에 재현기간 행×지속시간 열의 확률강우량표를 표시한다. +- 선택 지속시간 열의 값 셀만 강조하고 표 헤더는 기존 스타일을 유지한다. +- 팝업 하단에 확률관측소코드·관측소명·면적비율 및 API 합계를 표시한다. +- 팝업 맨 아래에 `시간은 강우가 지속된 시간입니다` 안내를 표시한다. +- 링크 클릭 이벤트는 기존 행 선택의 지도 이동으로 전파하지 않는다. + +## 검증 +- `pnpm --filter sn run typecheck` 성공 +- `git diff --check` 성공 +- 개발 서버(3002)가 실행 중이어서 build는 실행하지 않았다. + +## 수동 확인 필요 +- 실제 로그인 화면에서 팝업 크기·가로 스크롤·강조 색상 및 지도 이동 회귀 확인 diff --git a/apps/sn/src/features/fldngPredcPrst/ui/PrbbtRnflDialog.tsx b/apps/sn/src/features/fldngPredcPrst/ui/PrbbtRnflDialog.tsx new file mode 100644 index 000000000..c870d5ab2 --- /dev/null +++ b/apps/sn/src/features/fldngPredcPrst/ui/PrbbtRnflDialog.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { useMemo } from "react"; + +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@common/components"; + +/** 시군구별 면적가중 확률강우량 한 셀의 API 응답이다. */ +export type PrbbtRnflCell = { + frequencyYear: number; + durationHour: number; + rainfall: number; +}; + +/** 확률관측소의 시군구 교차면적 비율 API 응답이다. */ +export type PrbbtRnflStation = { + prbbtObsvtrCd: string; + prbbtObsvtrNm: string; + areaRt: number; +}; + +/** 확률강우량 표와 그 계산에 사용한 관측소 비율을 함께 받는다. */ +export type PrbbtRnflResponse = { + sggCd: string; + rainfall: PrbbtRnflCell[]; + stations: PrbbtRnflStation[]; + areaRtTotal: number; +}; + +type PrbbtRnflDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + sggName: string; + selectedDurationHour: number | null; + data: PrbbtRnflResponse | null; + loading: boolean; + error: string | null; +}; + +const formatRainfall = (value: number) => + Number.isFinite(Number(value)) ? Number(value).toFixed(4) : "-"; + +const formatAreaRatio = (value: number) => + Number.isFinite(Number(value)) ? Number(value).toFixed(3) : "-"; + +/** 재현기간을 행, 강우지속시간을 열로 구성하고 목록에서 선택한 지속시간의 기준값을 강조한다. */ +export default function PrbbtRnflDialog({ + open, + onOpenChange, + sggName, + selectedDurationHour, + data, + loading, + error, +}: PrbbtRnflDialogProps) { + const { frequencies, durations, rainfallByKey } = useMemo(() => { + const rainfall = data?.rainfall ?? []; + return { + frequencies: [...new Set(rainfall.map((item) => item.frequencyYear))].sort( + (a, b) => a - b, + ), + durations: [...new Set(rainfall.map((item) => item.durationHour))].sort( + (a, b) => a - b, + ), + rainfallByKey: new Map( + rainfall.map((item) => [ + `${item.frequencyYear}:${item.durationHour}`, + item.rainfall, + ]), + ), + }; + }, [data]); + + return ( + + + + {sggName} 확률강우량 + + + {loading ? ( +

+ 확률강우량을 조회하고 있습니다. +

+ ) : error ? ( +

+ {error} +

+ ) : !frequencies.length || !durations.length ? ( +

+ 해당 시군구의 확률강우량 기준자료가 없습니다. +

+ ) : ( + <> +
+ + + + + + {durations.map((durationHour) => ( + + ))} + + + + {frequencies.map((frequencyYear) => ( + + + {durations.map((durationHour) => { + const rainfall = rainfallByKey.get( + `${frequencyYear}:${durationHour}`, + ); + return ( + + ); + })} + + ))} + +
+ {sggName} 재현기간 및 강우지속시간별 확률강우량(mm) +
+ 재현기간(년) + + {durationHour}시간 +
+ {frequencyYear} + + {rainfall == null ? "-" : formatRainfall(rainfall)} +
+
+ +
+

확률관측소 면적비율

+
+ + + + + + + + + + {(data?.stations ?? []).map((station) => ( + + + + + + ))} + + + + + +
+ 확률관측소코드 + + 관측소명 + + 면적비율(%) +
+ {station.prbbtObsvtrCd} + + {station.prbbtObsvtrNm || "-"} + + {formatAreaRatio(station.areaRt)} +
+ 합계 + + {formatAreaRatio(data?.areaRtTotal ?? Number.NaN)} +
+
+
+ + )} +

+ 시간은 강우가 지속된 시간입니다 +

+
+
+ ); +} From 8ed4f959cbc3c3de4066747841e6fef5c4475a0c Mon Sep 17 00:00:00 2001 From: ljpark Date: Fri, 2 Oct 2026 18:12:03 +0900 Subject: [PATCH 2/2] =?UTF-8?q?sn:=20=ED=99=95=EB=A5=A0=EA=B0=95=EC=9A=B0?= =?UTF-8?q?=EB=9F=89=20=EB=AA=A8=EB=8B=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/dm/AGENTS.md | 13 ++ apps/fo/AGENTS.md | 13 ++ apps/sn/AGENTS.md | 38 +++++- ...ce-SFR-011-008-침수예측현황-확률강우량-작업계획.md | 10 +- ...fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md | 5 +- .../api/cty-fldng-prvtn-biz-api.ts | 6 + .../ui/CtyFldngPrvtnBizFormPage.tsx | 118 +++++++++++++++--- .../ui/CtyFldngPrvtnBizListPage.tsx | 20 +-- .../ui/FldngPredcPrstPopupContent.tsx | 95 +++++++++++++- .../fldngPredcPrst/ui/PrbbtRnflDialog.tsx | 45 +++++-- .../windowPopup/ui/MapPopupDataTable.tsx | 13 +- apps/wr/AGENTS.md | 16 ++- 12 files changed, 347 insertions(+), 45 deletions(-) diff --git a/apps/dm/AGENTS.md b/apps/dm/AGENTS.md index 7eefb1676..ef14f5097 100644 --- a/apps/dm/AGENTS.md +++ b/apps/dm/AGENTS.md @@ -1,6 +1,19 @@ ## Project This repository contains a Next.js web application. +## Portable paths + +Resolve paths from this `apps/dm/AGENTS.md` file, not from a developer-specific drive or the current shell directory. + +- `FE_APP_ROOT`: directory containing this file (`apps/dm`) +- `FE_REPO_ROOT`: `FE_APP_ROOT/../..` (`frontend`) +- `WORKSPACE_ROOT`: `FE_REPO_ROOT/..` +- `COMMON_ROOT_AGENTS`: `WORKSPACE_ROOT/AGENTS.md` +- `BE_REPO_ROOT`: `WORKSPACE_ROOT/IIPBS` (Backend repository root) +- `BE_SERVICE_ROOT`: `BE_REPO_ROOT/sewerage-service` + +Use an explicitly supplied repository path when the repositories are installed under different parent directories. Verify a calculated path exists before using it. + Main technologies: - Next.js - TypeScript diff --git a/apps/fo/AGENTS.md b/apps/fo/AGENTS.md index 7eefb1676..14b50f71b 100644 --- a/apps/fo/AGENTS.md +++ b/apps/fo/AGENTS.md @@ -1,6 +1,19 @@ ## Project This repository contains a Next.js web application. +## Portable paths + +Resolve paths from this `apps/fo/AGENTS.md` file, not from a developer-specific drive or the current shell directory. + +- `FE_APP_ROOT`: directory containing this file (`apps/fo`) +- `FE_REPO_ROOT`: `FE_APP_ROOT/../..` (`frontend`) +- `WORKSPACE_ROOT`: `FE_REPO_ROOT/..` +- `COMMON_ROOT_AGENTS`: `WORKSPACE_ROOT/AGENTS.md` +- `BE_REPO_ROOT`: `WORKSPACE_ROOT/IIPBS` (Backend repository root) +- `BE_SERVICE_ROOT`: `BE_REPO_ROOT/sewerage-service` + +Use an explicitly supplied repository path when the repositories are installed under different parent directories. Verify a calculated path exists before using it. + Main technologies: - Next.js - TypeScript diff --git a/apps/sn/AGENTS.md b/apps/sn/AGENTS.md index f8b84ffa9..76b511b8f 100644 --- a/apps/sn/AGENTS.md +++ b/apps/sn/AGENTS.md @@ -43,14 +43,46 @@ ## 공통 시작 기준 -Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작 기준은 `AGENTS.md`를 따른다. +개발자마다 드라이브와 상위 작업 폴더 위치가 다르므로 특정 드라이브·사용자 디렉터리의 절대경로를 기준으로 사용하지 않는다. + +경로 별칭은 `apps/sn/AGENTS.md`가 있는 디렉터리(`FE_APP_ROOT`)를 기준으로 다음과 같이 해석한다. 상대경로는 현재 셸의 실행 위치가 아니라 이 파일의 위치를 기준으로 계산한다. + +- `FE_APP_ROOT`: 이 파일이 있는 `apps/sn` 디렉터리 +- `FE_REPO_ROOT`: `FE_APP_ROOT/../..` +- `WORKSPACE_ROOT`: `FE_APP_ROOT/../../..` +- `COMMON_ROOT_AGENTS`: `WORKSPACE_ROOT/AGENTS.md` +- `BE_REPO_ROOT`: `WORKSPACE_ROOT/IIPBS` (Backend Git 저장소 루트) +- `BE_SERVICE_ROOT`: `BE_REPO_ROOT/sewerage-service` (하수관로 백엔드 서비스 모듈) +- `COMMON_DOCS_ROOT`: `BE_REPO_ROOT/docs` +- `FE_APP_DOCS_ROOT`: `FE_APP_ROOT/docs` + +경로 결정은 아래 우선순위를 따른다. + +1. 작업 환경이나 사용자가 `WORKSPACE_ROOT`, `COMMON_DOCS_ROOT`, `BE_REPO_ROOT`, `BE_SERVICE_ROOT` 등의 유효한 경로를 명시한 경우 그 값을 우선한다. 저장소를 서로 다른 상위 폴더에 배치한 개발자도 이 방식으로 사용할 수 있다. +2. 명시값이 없으면 위의 `FE_APP_ROOT` 기준 상대경로로 계산한다. 드라이브 문자나 사용자별 상위 디렉터리 이름이 달라도 같은 저장소 배치 구조를 사용할 수 있다. +3. 계산한 경로는 사용 전에 대상 파일이나 디렉터리의 존재 여부를 확인한다. 없거나 후보가 여러 개인 경우 특정 절대경로를 추측하지 말고 현재 저장소에서 확인 가능한 작업만 진행한 뒤 필요한 경로를 사용자에게 확인한다. +4. 작업 문서와 결과 보고에는 특별한 사유가 없으면 개발자별 절대경로 대신 위 경로 별칭이나 저장소 기준 상대경로를 기록한다. + +PowerShell에서 현재 프론트엔드 저장소를 기준으로 확인할 때는 다음처럼 동적으로 계산할 수 있다. 각 단계는 앞 단계에서 찾은 저장소 위치를 기반으로 하므로 개발자별 설치 경로에 의존하지 않는다. + +```powershell +$feRepoRoot = (git rev-parse --show-toplevel).Trim() +$feAppRoot = Join-Path $feRepoRoot 'apps/sn' +$workspaceRoot = Split-Path $feRepoRoot -Parent +$commonRootAgents = Join-Path $workspaceRoot 'AGENTS.md' +$beRepoRoot = Join-Path $workspaceRoot 'IIPBS' +$beServiceRoot = Join-Path $beRepoRoot 'sewerage-service' +$commonDocsRoot = Join-Path $beRepoRoot 'docs' +``` + +공통 시작 기준은 `COMMON_ROOT_AGENTS`를 따른다. 이 파일은 프론트엔드 `sn` 앱 세부 기준이다. 백엔드(`sewerage-service`)와 함께 수정하거나 API/응답/DB/인증/권한 연동 영향이 있는 작업은 아래 파일을 함께 확인한다. - 공통 시작 기준: `AGENTS.md` -- 백엔드 세부 기준: `BE_REPO_ROOT/AGENTS.md` +- 백엔드 세부 기준: `BE_SERVICE_ROOT/AGENTS.md` - 프론트엔드 `sn` 앱 기준: `FE_APP_ROOT/AGENTS.md` --- @@ -230,7 +262,7 @@ VSCode나 별도 터미널에서 `sn` Next 개발 서버가 실행 중이면 `ne ### 프론트엔드 실행 프로세스 관리 -- 모든 세션·역할·문서/검증 섹션에 공통 D:/workspace/AGENTS.md의 테스트 파일 정리 기준을 적용한다. 에이전트가 테스트를 위해 만든 파일은 정식 회귀 테스트·재사용 가능한 소스·fixture·mock·임시 라우트를 포함해 위치와 관계없이 결과를 요약한 뒤 작업 종료 전에 삭제한다(2026-09-23 사용자 지시). 전용 빈 폴더와 끊어진 실행 명령도 정리한다. 명시 보존 요청 파일·최종 산출물·사용자 원본/복구 백업은 유지한다. 기존 타 작업자 테스트 자산은 삭제 권한을 먼저 확인한다. 사용자 dev server의 .next·공유 캐시·node_modules는 포괄 삭제하지 않는다. +- 모든 세션·역할·문서/검증 섹션에 `COMMON_ROOT_AGENTS`의 테스트 파일 정리 기준을 적용한다. 에이전트가 테스트를 위해 만든 파일은 정식 회귀 테스트·재사용 가능한 소스·fixture·mock·임시 라우트를 포함해 위치와 관계없이 결과를 요약한 뒤 작업 종료 전에 삭제한다(2026-09-23 사용자 지시). 전용 빈 폴더와 끊어진 실행 명령도 정리한다. 명시 보존 요청 파일·최종 산출물·사용자 원본/복구 백업은 유지한다. 기존 타 작업자 테스트 자산은 삭제 권한을 먼저 확인한다. 사용자 dev server의 .next·공유 캐시·node_modules는 포괄 삭제하지 않는다. - Codex 에이전트가 작업 또는 검증을 위해 직접 기동한 `next dev`, `dev:sn`, Turborepo dev와 기타 프론트엔드 장시간 실행 프로세스는 작업 종료 전에 반드시 종료한다. - 셸, 백그라운드, 숨김 창 등 VSCode 밖에서 에이전트가 기동한 프로세스도 종료 대상이다. diff --git a/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md b/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md index 18497c7c6..3a0fd344d 100644 --- a/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md +++ b/apps/sn/docs/작업계획/SFR-011-008-침수예측/002-maintenance-SFR-011-008-침수예측현황-확률강우량-작업계획.md @@ -20,9 +20,12 @@ - 팝업 하단에 확률관측소코드·관측소명·면적비율(%) 비율표를 표시한다. - 팝업 맨 아래에 `시간은 강우가 지속된 시간입니다` 안내를 표시한다. - 지속시간 헤더는 기존 팝업 표 헤더 스타일을 유지한다. -- 선택 행의 대표 지속시간에 해당하는 열의 확률강우량 기준값 셀들을 강조 색상으로 표시한다. 표 헤더는 기존 스타일을 유지한다. +- 선택 행의 대표 지속시간과 초과빈도년도가 교차하는 확률강우량 기준값 한 셀만 강조 색상으로 표시한다. 표 헤더는 기존 스타일을 유지한다. +- 팝업 표가 표시되면 선택한 지속시간 열이 보이도록 표 내부 가로 스크롤을 자동으로 맞춘다. +- 선택 행의 초과빈도년도에 해당하는 재현기간 행은 년도와 강수량 글씨를 굵게 표시한다. - 강수량은 mm 단위로 표시하고 로딩·빈 결과·오류 상태를 제공한다. - 기존 행 클릭의 도시침수지도 이동과 별도 액션으로 동작시킨다. +- 현황 목록의 분석시각·강우시작시각은 분까지 말줄임 없이 표시하며, 좁은 팝업에서는 표의 가로 스크롤을 사용한다. ## 검증 계획 @@ -37,5 +40,8 @@ - 현황 목록에 `확률강우량` 열을 추가하고 대표 지속시간 `predcRainHr`를 `N시간` 링크로 표시한다. - 링크 클릭은 행 선택 지도 이동을 실행하지 않으며, 시군구별 기준표 팝업을 연다. - 팝업은 재현기간 행×지속시간 열의 확률강우량, 하단 관측소 면적비율과 API 합계를 표시한다. -- 선택 지속시간 열의 값 셀만 강조하고 헤더는 기존 스타일을 유지한다. +- 선택 지속시간·초과빈도년도의 교차값 한 셀만 색으로 강조하고 헤더는 기존 스타일을 유지한다. +- 팝업 표의 선택 지속시간 열은 로딩 완료 후 표 내부 가로 스크롤 영역 중앙에 자동으로 나타난다. +- 선택 초과빈도년도의 재현기간 행은 년도와 강수량을 굵게 표시한다. +- 분석시각·강우시작시각 열은 고정 폭을 지정하지 않고 말줄임만 해제하여, 전체 시각을 표시하면서 주변 열과 함께 자동으로 너비를 계산한다. - `pnpm --filter sn run typecheck`, `git diff --check`가 통과했다. 3002 포트 개발 서버 실행 중이므로 build는 실행하지 않았다. diff --git a/apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md b/apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md index a7071cccf..d80976eb2 100644 --- a/apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md +++ b/apps/sn/docs/작업내용/SFR-011-008-침수예측/005-fe-SFR-011-008-침수예측현황-확률강우량-구현기록.md @@ -5,10 +5,13 @@ - 침수위험도 오른쪽에 확률강우량 열을 추가하고 대표 누적강우 지속시간 `predcRainHr`를 `N시간` 링크로 표시했다. - 링크는 `GET /api/v1/fldng/predc/prst/prbbt-rnfl?sggCd=...`를 호출한다. - 팝업 본문에 재현기간 행×지속시간 열의 확률강우량표를 표시한다. -- 선택 지속시간 열의 값 셀만 강조하고 표 헤더는 기존 스타일을 유지한다. +- 선택 지속시간·초과빈도년도의 교차값 한 셀만 색으로 강조하고 표 헤더는 기존 스타일을 유지한다. +- 팝업 표가 로딩되면 목록에서 선택한 지속시간 열이 보이도록 표 내부 가로 스크롤을 자동으로 맞춘다. +- 선택 행의 초과빈도년도에 해당하는 재현기간 행은 년도와 강수량을 굵게 표시한다. - 팝업 하단에 확률관측소코드·관측소명·면적비율 및 API 합계를 표시한다. - 팝업 맨 아래에 `시간은 강우가 지속된 시간입니다` 안내를 표시한다. - 링크 클릭 이벤트는 기존 행 선택의 지도 이동으로 전파하지 않는다. +- 침수예측현황 목록의 분석시각·강우시작시각은 말줄임을 적용하지 않고, 고정 열 폭 없이 표의 자동 너비 계산과 가로 스크롤로 분까지 표시한다. ## 검증 - `pnpm --filter sn run typecheck` 성공 diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/api/cty-fldng-prvtn-biz-api.ts b/apps/sn/src/features/ctyFldngPrvtnBiz/api/cty-fldng-prvtn-biz-api.ts index bdbd5d405..4e79e0b11 100644 --- a/apps/sn/src/features/ctyFldngPrvtnBiz/api/cty-fldng-prvtn-biz-api.ts +++ b/apps/sn/src/features/ctyFldngPrvtnBiz/api/cty-fldng-prvtn-biz-api.ts @@ -45,6 +45,12 @@ export const getAllCtyFldngPrvtnBusinesses = async ( return result.items; }; +/** 등록 화면에 자동으로 넣을 다음 사업순번을 조회한다. */ +export const getNextCtyFldngPrvtnSeqNo = async () => { + const body = await getData(`${API_PATH}/nextSeqNo`); + return Number(unwrapData(body)); +}; + /** 수정 화면의 사업 한 건을 조회한다. */ export const getCtyFldngPrvtnBusiness = (id: string) => getData(`${API_PATH}/${encodeURIComponent(id)}`) as Promise; diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage.tsx b/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage.tsx index f0507195b..26b2ab7e6 100644 --- a/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage.tsx +++ b/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage.tsx @@ -22,6 +22,7 @@ import { createCtyFldngPrvtnBusiness, deleteCtyFldngPrvtnBusiness, getCtyFldngPrvtnBusiness, + getNextCtyFldngPrvtnSeqNo, updateCtyFldngPrvtnBusiness, } from "../api/cty-fldng-prvtn-biz-api"; import type { @@ -91,6 +92,35 @@ const DETAIL_FIELDS = [ ["rmrkSrcCn", "비고출처", "text"], ] as const; type BasicKey = (typeof BASIC_FIELDS)[number][0]; +/** 저장 전에 반드시 입력해야 하는 항목이다. 시도는 별도 선택이라 region 키로 관리한다. */ +const REQUIRED_BASIC_KEYS = new Set(["seqNo", "dsgnYmd"]); +type FieldErrorKey = BasicKey | "region" | `soa-${string}`; + +/** 금액·면적 입력의 DB 자릿수 규칙이다. intDigits 는 정수부, scale 은 소수부 한계다. */ +const NUMBER_RULES: Record = { + dsgnArea: { label: "지정면적", intDigits: 10, scale: 9 }, + tpco: { label: "총사업비", intDigits: 20, scale: 2 }, + gvexp: { label: "국비", intDigits: 20, scale: 2 }, + lcexp: { label: "지방비", intDigits: 20, scale: 2 }, +}; +const SETTLEMENT_RULE = { label: "결산금액", intDigits: 20, scale: 2 }; + +/** 숫자 입력이 비었는지, 숫자인지, 0 이상인지, 자릿수를 넘지 않는지 확인한다. */ +const numberError = ( + value: unknown, + rule: { label: string; intDigits: number; scale: number }, +) => { + const text = String(value ?? "").trim(); + if (!text) return undefined; + if (!/^\d*(\.\d+)?$/.test(text)) + return `${rule.label}은(는) 0 이상 숫자로 입력해주세요.`; + const [integerPart = "", decimalPart = ""] = text.split("."); + if (decimalPart.length > rule.scale) + return `${rule.label}은(는) 소수점 ${rule.scale}자리까지 입력할 수 있습니다.`; + if (integerPart.replace(/^0+/, "").length > rule.intDigits) + return `${rule.label}은(는) 정수 ${rule.intDigits}자리를 넘을 수 없습니다.`; + return undefined; +}; type DetailKey = (typeof DETAIL_FIELDS)[number][0]; /** 등록 화면의 빈 기본·결산·4단계 상세 값을 생성한다. */ @@ -294,6 +324,18 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) { const [notice, setNotice] = useState(""); const [ctpvCd, setCtpvCd] = useState("0"); const [sggCd, setSggCd] = useState("0"); + const [fieldErrors, setFieldErrors] = useState< + Partial> + >({}); + + /** 입력을 고치면 해당 항목의 오류 문구를 지운다. */ + const clearFieldError = (key: FieldErrorKey) => + setFieldErrors((current) => { + if (!current[key]) return current; + const next = { ...current }; + delete next[key]; + return next; + }); const listHref = `/oper/ctyFldngPrvtnBiz${searchParams.toString() ? `?${searchParams}` : ""}`; const availableSgg = useMemo( () => @@ -308,6 +350,13 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) { if (!ctpv.length) void fetchCtpv(); if (!sgg.length) void fetchSgg(); }, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]); + useEffect(() => { + if (id) return; + // 등록 화면은 다음 사업순번을 자동으로 채우고 사용자가 바꾸지 못하게 한다. + getNextCtyFldngPrvtnSeqNo() + .then((seqNo) => setForm((current) => ({ ...current, seqNo }))) + .catch(() => setNotice("다음 사업순번을 조회하지 못했습니다.")); + }, [id]); useEffect(() => { if (!id) return; setLoading(true); @@ -337,14 +386,42 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) { /** 필수값·날짜·숫자를 검사한 뒤 등록 또는 수정한다. */ const handleSave = async () => { - const dates = [ - form.dsgnYmd, - ...form.details.flatMap((d) => [d.bgngYmd, d.cmcnYmd]), - ]; + const nextErrors: Partial> = {}; + if (!String(form.ctpvNm ?? "").trim()) nextErrors.region = "시도는 필수입니다."; + if (form.seqNo === null || form.seqNo === undefined || String(form.seqNo) === "") + nextErrors.seqNo = "사업순번은 필수입니다."; + if (!String(form.dsgnYmd ?? "").trim()) + nextErrors.dsgnYmd = "지정일은 필수입니다."; + else if (!validDate(form.dsgnYmd)) + nextErrors.dsgnYmd = "YYYY-MM-DD 형식의 유효한 날짜로 입력해주세요."; + const requiredMissing = Object.keys(nextErrors).length > 0; + Object.entries(NUMBER_RULES).forEach(([key, rule]) => { + const message = numberError(form[key as BasicKey], rule); + if (message) nextErrors[key as BasicKey] = message; + }); + form.settlements.forEach((settlement) => { + const message = numberError(settlement.soaAmt, { + ...SETTLEMENT_RULE, + label: `${settlement.soaYr}년 결산금액`, + }); + if (message) nextErrors[`soa-${settlement.soaYr}`] = message; + }); + if (Object.keys(nextErrors).length > 0) { + setFieldErrors(nextErrors); + setNotice( + requiredMissing + ? "필수 항목을 입력해주세요." + : "입력값을 확인해주세요.", + ); + return; + } + const dates = form.details.flatMap((d) => [d.bgngYmd, d.cmcnYmd]); if (dates.some((value) => !validDate(value))) { setNotice("일자는 YYYY-MM-DD 형식의 유효한 날짜여야 합니다."); return; } + setFieldErrors({}); + setNotice(""); setLoading(true); try { if (id) await updateCtyFldngPrvtnBusiness(id, form); @@ -393,14 +470,16 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) { suggestionOptions.length > 0 ? `${fieldId}-suggestions` : undefined; const span: 1 | 2 | 3 = key === "excptnMttrCn" ? 3 : key === "bizNm" || key === "pstn" ? 2 : 1; - // 수정 화면에서는 중복 판정 업무키인 사업순번과 단계를 변경할 수 없다. - const isBusinessKeyLocked = Boolean(id) && (key === "seqNo" || key === "stp"); + // 사업순번은 등록 시 자동 번호를 보여주고, 수정 시에는 중복 판정 업무키라 바꿀 수 없다. + const isBusinessKeyLocked = + key === "seqNo" || (Boolean(id) && key === "stp"); return ( <> @@ -408,19 +487,21 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) { id={fieldId} className="w-full !text-[1.3rem]" maxLength={standardMaxLengths[key]} + min={NUMBER_RULES[key] ? 0 : undefined} type={type === "date" ? "date" : type} list={suggestionListId} disabled={isBusinessKeyLocked} value={String(form[key] ?? "")} - onChange={(event) => + onChange={(event) => { + clearFieldError(key); setForm((current) => ({ ...current, [key]: type === "date" ? formatDateInput(event.target.value) : inputValue(event.target.value, type), - })) - } + })); + }} /> {suggestionListId ? ( @@ -455,11 +536,17 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) { )} - + + onChange={(event) => { + clearFieldError(`soa-${year}`); setForm((current) => ({ ...current, settlements: [ @@ -554,8 +644,8 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) { : Number(event.target.value), }, ], - })) - } + })); + }} /> ); diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx b/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx index d86dfbb34..2897621ec 100644 --- a/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx +++ b/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx @@ -157,21 +157,11 @@ type Search = { }; /** 브라우저 로컬 날짜를 검색 input이 사용하는 YYYY-MM-DD 형식으로 변환한다. */ -const formatLocalDateInputValue = (date: Date) => { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; -}; - -/** 지정일 기본 검색기간을 최초 사업자료 기준일인 2013-01-01부터 오늘까지로 생성한다. */ -const createDefaultDesignationDateRange = () => { - const to = new Date(); - return { - dsgnYmdFrom: "2013-01-01", - dsgnYmdTo: formatLocalDateInputValue(to), - }; -}; +/** 지정일 검색조건은 처음부터 비워 둔다. 지정일이 없는 사업도 목록에서 보이도록 하기 위함이다. */ +const createDefaultDesignationDateRange = () => ({ + dsgnYmdFrom: "", + dsgnYmdTo: "", +}); type TableRow = Record & { ctyFldngPrvtnBizSn: number; diff --git a/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx b/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx index e13e98b00..306259ac1 100644 --- a/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx +++ b/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx @@ -20,6 +20,9 @@ import useStore from "@/store/useStore"; import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; import { downloadFloodPredictionStatusExcel } from "../lib/fldng-predc-prst-excel"; import { getFloodRiskGradeClassName } from "../lib/flood-risk-grade"; +import PrbbtRnflDialog, { + type PrbbtRnflResponse, +} from "./PrbbtRnflDialog"; type ApiRow = { sggCd: string; @@ -30,6 +33,7 @@ type ApiRow = { analysisDateTime: string; observedAccumulatedRainfall: number | null; predictionHour: number | null; + predcRainHr: number | null; predictedAccumulatedRainfall: number | null; exceededFrequencyYear: number | null; floodRiskGrade: string; @@ -44,6 +48,7 @@ type TableRow = ApiRow & predictionHourDisplay: string; predictedAccumulatedRainfallDisplay: string; exceededFrequencyYearDisplay: string; + probabilityRainfallDisplay: string; }; type Filters = { @@ -109,6 +114,13 @@ const HEADS: UiHeadProps[] = [ align: "right", }, { id: "floodRiskGrade", title: "침수위험도", widthPercent: 10 }, + { id: "probabilityRainfallDisplay", title: "확률강우량", widthPercent: 10 }, +]; + +/** 날짜·시각의 뒷부분이 잘리지 않도록 전체 표시할 목록 열이다. */ +const FULL_TEXT_TIME_HEAD_IDS = [ + "analysisDateTimeDisplay", + "rainBeginningDateTimeDisplay", ]; /** 최초·초기화 시 시각 범위를 제한하지 않고 최신 침수예측 데이터부터 조회한다. */ @@ -135,6 +147,12 @@ const predictionHourText = (value: number | null) => { return hour > 0 ? `${hour}시간 후` : "-"; }; +/** 분석 결과의 대표 누적강우시간을 확률강우량 기준표 링크 문구로 표시한다. */ +const probabilityDurationText = (value: number | null) => { + const hour = Number(value); + return Number.isInteger(hour) && hour > 0 ? `${hour}시간` : "-"; +}; + const formatNumber = (value: number | null, digits: number) => value == null || !Number.isFinite(Number(value)) ? "-" @@ -202,6 +220,14 @@ export default function FldngPredcPrstPopupContent() { const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); const [selectedRowId, setSelectedRowId] = useState(""); + // 확률강우량 기준표는 목록의 선택 행과 별도로 열고 닫는다. + const [probabilityRow, setProbabilityRow] = useState(null); + const [probabilityData, setProbabilityData] = + useState(null); + const [probabilityLoading, setProbabilityLoading] = useState(false); + const [probabilityError, setProbabilityError] = useState(null); + // 연속 클릭 또는 닫기 이후 도착한 이전 응답이 현재 팝업을 덮지 않도록 요청 순서를 보관한다. + const probabilityRequestIdRef = useRef(0); // 개발 StrictMode에서도 최초 목록 API를 한 번만 호출하기 위한 ref다. const didInitialLoadRef = useRef(false); @@ -268,6 +294,9 @@ export default function FldngPredcPrstPopupContent() { item.exceededFrequencyYear == null ? "-" : String(item.exceededFrequencyYear), + probabilityRainfallDisplay: probabilityDurationText( + item.predcRainHr, + ), })), ); setTotal(Number(payload?.totalElements ?? 0)); @@ -373,6 +402,43 @@ export default function FldngPredcPrstPopupContent() { } }; + /** 선택 시군구의 확률강우량과 관측소 비율을 조회한다. 지도 행 클릭은 버튼에서 차단한다. */ + const handleProbabilityClick = async (row: TableRow) => { + if (!row.sggCd || !Number.isInteger(Number(row.predcRainHr)) || Number(row.predcRainHr) <= 0) { + return; + } + const requestId = ++probabilityRequestIdRef.current; + setProbabilityRow(row); + setProbabilityData(null); + setProbabilityError(null); + setProbabilityLoading(true); + try { + const params = new URLSearchParams({ sggCd: row.sggCd }); + const response = await getRawJson( + `/sewerage-service/api/v1/fldng/predc/prst/prbbt-rnfl?${params.toString()}`, + ); + if (requestId !== probabilityRequestIdRef.current) return; + const payload: PrbbtRnflResponse = response?.data ?? response; + setProbabilityData(payload); + } catch (error) { + if (requestId !== probabilityRequestIdRef.current) return; + console.error("시군구 확률강우량 조회 중 오류:", error); + setProbabilityError("확률강우량 기준표를 조회하지 못했습니다."); + } finally { + if (requestId === probabilityRequestIdRef.current) { + setProbabilityLoading(false); + } + } + }; + + /** 닫힌 팝업으로 늦게 도착하는 API 응답을 무시한다. */ + const handleProbabilityOpenChange = (open: boolean) => { + if (open) return; + probabilityRequestIdRef.current += 1; + setProbabilityRow(null); + setProbabilityLoading(false); + }; + const handleDownload = async () => { if (!total) { alert("다운로드할 데이터가 없습니다."); @@ -562,10 +628,27 @@ export default function FldngPredcPrstPopupContent() { > {row.floodRiskGrade || "-"} + ) : headId === "probabilityRainfallDisplay" ? ( + row.probabilityRainfallDisplay === "-" ? ( + "-" + ) : ( + + ) ) : undefined } scrollMode="horizontal" - minTableWidth="100rem" + minTableWidth="112rem" + fullTextHeadIds={FULL_TEXT_TIME_HEAD_IDS} fillAvailableHeight={rows.length === 0} emptyMessage="조회 조건에 해당하는 침수예측현황이 없습니다." actions={ @@ -591,6 +674,16 @@ export default function FldngPredcPrstPopupContent() {

행을 선택하면 해당 시군구·빈도의 도시침수지도로 이동합니다.

+ ); diff --git a/apps/sn/src/features/fldngPredcPrst/ui/PrbbtRnflDialog.tsx b/apps/sn/src/features/fldngPredcPrst/ui/PrbbtRnflDialog.tsx index c870d5ab2..02275efd2 100644 --- a/apps/sn/src/features/fldngPredcPrst/ui/PrbbtRnflDialog.tsx +++ b/apps/sn/src/features/fldngPredcPrst/ui/PrbbtRnflDialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo } from "react"; +import { useLayoutEffect, useMemo, useRef } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@common/components"; @@ -31,6 +31,7 @@ type PrbbtRnflDialogProps = { onOpenChange: (open: boolean) => void; sggName: string; selectedDurationHour: number | null; + selectedFrequencyYear: number | null; data: PrbbtRnflResponse | null; loading: boolean; error: string | null; @@ -42,16 +43,22 @@ const formatRainfall = (value: number) => const formatAreaRatio = (value: number) => Number.isFinite(Number(value)) ? Number(value).toFixed(3) : "-"; -/** 재현기간을 행, 강우지속시간을 열로 구성하고 목록에서 선택한 지속시간의 기준값을 강조한다. */ +/** 재현기간을 행, 강우지속시간을 열로 구성하고 선택한 두 기준이 교차하는 값만 색으로 강조한다. */ export default function PrbbtRnflDialog({ open, onOpenChange, sggName, selectedDurationHour, + selectedFrequencyYear, data, loading, error, }: PrbbtRnflDialogProps) { + /** 선택한 지속시간 열을 모달 안에서 찾기 위한 표 스크롤 영역이다. */ + const tableScrollRef = useRef(null); + /** 목록에서 클릭한 지속시간에 대응하는 표 헤더이다. */ + const selectedDurationHeaderRef = useRef(null); + const { frequencies, durations, rainfallByKey } = useMemo(() => { const rainfall = data?.rainfall ?? []; return { @@ -70,6 +77,22 @@ export default function PrbbtRnflDialog({ }; }, [data]); + /** 표가 표시되면 선택 지속시간 열을 가로 스크롤 영역의 가운데로 맞춘다. */ + useLayoutEffect(() => { + if (!open || loading || error || selectedDurationHour == null) return; + + const scrollArea = tableScrollRef.current; + const selectedHeader = selectedDurationHeaderRef.current; + if (!scrollArea || !selectedHeader) return; + + const scrollAreaRect = scrollArea.getBoundingClientRect(); + const selectedHeaderRect = selectedHeader.getBoundingClientRect(); + scrollArea.scrollLeft += + selectedHeaderRect.left - + scrollAreaRect.left - + (scrollArea.clientWidth - selectedHeaderRect.width) / 2; + }, [open, loading, error, selectedDurationHour, durations]); + return ( ) : ( <> -
+
{frequencies.map((frequencyYear) => ( - + @@ -135,8 +164,10 @@ export default function PrbbtRnflDialog({
{sggName} 재현기간 및 강우지속시간별 확률강우량(mm) @@ -110,6 +133,7 @@ export default function PrbbtRnflDialog({ {durations.map((durationHour) => (
@@ -120,10 +144,15 @@ export default function PrbbtRnflDialog({
{frequencyYear} diff --git a/apps/sn/src/features/windowPopup/ui/MapPopupDataTable.tsx b/apps/sn/src/features/windowPopup/ui/MapPopupDataTable.tsx index b96b19fab..e371641ea 100644 --- a/apps/sn/src/features/windowPopup/ui/MapPopupDataTable.tsx +++ b/apps/sn/src/features/windowPopup/ui/MapPopupDataTable.tsx @@ -43,6 +43,7 @@ type MapPopupDataTableProps> = { headId: string, rowIndex: number, ) => ReactNode | undefined; + fullTextHeadIds?: string[]; /** 시각처럼 뒤쪽이 잘리면 의미가 달라지는 열은 고정 너비 없이 말줄임만 해제한다. */ sort?: UiTableSort; onSortChange?: (sort: UiTableSort | undefined) => void; }; @@ -77,6 +78,7 @@ export default function MapPopupDataTable>({ onCellClick, clickableHeadIds, renderCell, + fullTextHeadIds, sort, onSortChange, }: MapPopupDataTableProps) { @@ -143,7 +145,15 @@ export default function MapPopupDataTable>({ ? head.align : "center", width: head.widthPercent, - }, + ...(fullTextHeadIds?.includes(head.id) + ? { + "data-sn-cell-wrap": "", + style: { + whiteSpace: "nowrap", + }, + } + : {}), + } as ColumnDef["meta"], })), [ clickableHeadIds, @@ -151,6 +161,7 @@ export default function MapPopupDataTable>({ page, pageSize, renderCell, + fullTextHeadIds, sort, total, visibleHeads, diff --git a/apps/wr/AGENTS.md b/apps/wr/AGENTS.md index 3f05ba328..001e98fa1 100644 --- a/apps/wr/AGENTS.md +++ b/apps/wr/AGENTS.md @@ -1,6 +1,20 @@ ## Project This repository contains a Next.js web application. +## Portable paths + +Resolve paths from this `apps/wr/AGENTS.md` file, not from a developer-specific drive or the current shell directory. + +- `FE_APP_ROOT`: directory containing this file (`apps/wr`) +- `FE_REPO_ROOT`: `FE_APP_ROOT/../..` (`frontend`) +- `WORKSPACE_ROOT`: `FE_REPO_ROOT/..` +- `COMMON_ROOT_AGENTS`: `WORKSPACE_ROOT/AGENTS.md` +- `BE_REPO_ROOT`: `WORKSPACE_ROOT/IIPBS` (Backend repository root) +- `BE_SERVICE_ROOT`: `BE_REPO_ROOT/sewerage-service` +- `WR_BACKEND_ROOT`: `WORKSPACE_ROOT/wr_back_out` when that checkout exists; otherwise use a path explicitly supplied by the developer. + +Verify a calculated path exists before using it. If the repositories are installed under different parent directories, use the explicitly supplied path. + Main technologies: - Next.js - TypeScript @@ -41,4 +55,4 @@ apps/ wr ## local backend src 경로 -D:\iipbs\wr_back_out - http://localhost:8883/reuse-service +`WR_BACKEND_ROOT` - http://localhost:8883/reuse-service