Compare commits

..
33 Commits
Author SHA1 Message Date
mind-one005 ee0049722f Merge branch 'iipbs/sn/dev' 2026-10-22 15:01:40 +09:00
jcchoi 74934d4559 수정
Deploy Frontend / deploy (push) Successful in 4m0s
2026-10-22 14:28:31 +09:00
jcchoi 027f88da6c 대시보드 ㅁ이상치리포트 명칭 변경 2026-10-22 14:24:21 +09:00
mind-one005 eddb1b24d6 sn: 지반침하 지도상세정보 엑셀다운로드 2026-10-22 10:19:52 +09:00
mind-one005 cf2cc11167 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-10-22 09:44:24 +09:00
mind-one005 9e71f529e7 sn: 통수위험 위험여부란 수정 외 2026-10-22 09:33:27 +09:00
changwng 6dc2965209 [as fr] 필수첨부 파일에 필수파일검토내용에 CRUD 처리 및 AI 요청시 같이 전송처리
Deploy Frontend / deploy (push) Successful in 4m5s
2026-10-21 16:14:39 +09:00
jcchoi c69c1aa855 Merge branch 'iipbs/st/dev'
Deploy Frontend / deploy (push) Successful in 9m0s
2026-10-21 13:15:44 +09:00
jcchoi 9f0e03c151 이상치 선별보고서 2026-10-21 11:31:50 +09:00
jcchoi 471a431af5 대시보드 진행상태 변경 2026-10-21 10:37:32 +09:00
jcchoi 26d8410649 Merge remote-tracking branch 'origin/iipbs/st/dev'
Deploy Frontend / deploy (push) Successful in 5m0s
2026-10-20 21:52:31 +09:00
jcchoi 8403ad556c 설치인가 버튼 이벤트 수정 2026-10-20 21:50:41 +09:00
rsone001 d26c15f857 Merge branch 'iipbs/st/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/st/dev 2026-10-20 17:49:42 +09:00
rsone001 86ed664e3e [input] - 오류 로직 수정 2026-10-20 17:49:09 +09:00
jcchoi a105f395dd 설치인가 프로세스 변경 2026-10-20 15:50:05 +09:00
jcchoi a5ebc12580 검토의견 수정 오류 처리 2026-10-20 13:48:39 +09:00
jcchoi 270f6e366f Merge branch 'iipbs/st/dev' 2026-10-20 10:53:41 +09:00
jcchoi acf215c1bf 대시보드 이상치선별보고서 수정 2026-10-20 10:45:29 +09:00
kiminchan 36b11d6b00 맨홀 조사보고서 단독 교체 및 GIS 번호 기반 조회 수정 2026-10-19 17:01:37 +09:00
mind-one005 79f70d8b4b sn: 도시침수지도 geoserver style적용 2026-10-16 18:05:48 +09:00
mind-one005 7911cff96a sn: GIS편집,GIS관리, 통수위험 정보카드 X버튼 2026-10-16 17:36:31 +09:00
mind-one005 fe501965bf sn: 조사관리>>조사사업 2026-10-16 16:51:47 +09:00
mind-one001 fb82cadc82 준설관리 텍스트 수정 2026-10-16 11:10:47 +09:00
mind-one001 f5b6effeb3 준설관리 관로 미표출로 인한 기능수정 2026-10-16 11:00:13 +09:00
mind-one001 63c2901ae3 feat(sn): allow dredging records without a mapped pipe 2026-10-16 10:09:44 +09:00
mind-one001 0356aef7b2 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-10-15 11:23:53 +09:00
mind-one001 f709dc99f6 chore: stop tracking local VS Code settings 2026-10-15 11:21:44 +09:00
mind-one005 728a66bac2 sn: apigateway.onasys.co.kr 2026-10-13 15:55:31 +09:00
mind-one005 c850b9ce1e sn: 해당 도시침수지도가 없습니다. 2026-10-13 15:48:24 +09:00
mind-one005 791bc20e8e sn: 지도 위치 정보가 없는 경우 404>>위치 정보가 없습니다. 2026-10-13 15:22:16 +09:00
mind-one001 fed1383ced 시도별 노후관로현황 기본 조회기간을 당해연도로 변경 2026-10-08 10:30:51 +09:00
mind-one001 91ff80a255 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-10-07 15:15:32 +09:00
mind-one001 7f10e20a41 전국 지도 보이게 줌 레벨 수정 2026-10-07 15:03:03 +09:00
114 changed files with 2160 additions and 1298 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
# turbo
.turbo/
.vscode/**/*
**/.vscode/
*.code-workspace
# dependencies
node_modules/
-88
View File
@@ -1,88 +0,0 @@
{
// IntelliSense를 사용하여 가능한 특성에 대해 알아보세요.
// 기존 특성에 대한 설명을 보려면 가리킵니다.
// 자세한 내용을 보려면 https://go.microsoft.com/fwlink/?linkid=830387을(를) 방문하세요.
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "dev:as 디버그",
"runtimeExecutable": "pnpm",
"runtimeArgs": [
"run",
"dev:as"
],
"skipFiles": [
"<node_internals>/**"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"sourceMaps": true,
"env": {
"NODE_OPTIONS": "--inspect"
}
},
{
"type": "node",
"request": "launch",
"name": "dev:cs 디버그",
"runtimeExecutable": "pnpm",
"runtimeArgs": [
"run",
"dev:cs"
],
"skipFiles": [
"<node_internals>/**"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"sourceMaps": true,
"env": {
"NODE_OPTIONS": "--inspect"
}
},
{
"type": "node",
"request": "attach",
"name": "dev:cs 서버 연결 (attach)",
"port": 9229,
"restart": true,
"skipFiles": [
"<node_internals>/**"
],
"sourceMaps": true,
"resolveSourceMapLocations": [
"${workspaceFolder}/apps/cs/**",
"!**/node_modules/**"
]
},
{
"type": "chrome",
"request": "launch",
"name": "dev:cs 클라이언트 디버그 (Chrome)",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}/apps/cs",
"sourceMaps": true
},
{
"type": "node",
"request": "launch",
"name": "dev:st 디버그",
"runtimeExecutable": "pnpm",
"runtimeArgs": [
"run",
"dev:st"
],
"skipFiles": [
"<node_internals>/**"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"sourceMaps": true,
"env": {
"NODE_OPTIONS": "--inspect"
}
}
]
}
-5
View File
@@ -1,5 +0,0 @@
{
"chat.tools.terminal.autoApprove": {
"pnpm": true
}
}
@@ -33,10 +33,10 @@
├──────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ [영역 B] 필수첨부파일 목록 │
│ ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ 필수첨부파일아이디 | 문서구분 | 필수첨부파일명 | 필수첨부여부 | 비고 | 등록일시 | 수정일시 | 관리 │
│ │ 필수첨부파일아이디 | 문서구분 | 필수첨부파일명 | 필수첨부여부 | 필수파일검토내용 | 비고 | 관리 │ │
│ ├────────────────────────────────────────────────────────────────────────────────────────────────┤ │
│ │ EAF-0001 | 설치인가 | 사업계획서 | 예 | 서명본 첨부 | 2026-04-22 10:10:00 | — | [수정][삭제] │ │
│ │ EAF-0002 | 기본계획 | 변경사유서 | 예 | — | 2026-04-22 11:00:00 | — | [수정][삭제] │ │
│ │ EAF-0001 | 설치인가 | 사업계획서 | 예 | 사업비 내역 검토 | 서명본 첨부 | [수정][삭제] │ │
│ │ EAF-0002 | 기본계획 | 변경사유서 | 예 | 변경 전후 비교표 | — | [수정][삭제] │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────┘
@@ -47,7 +47,9 @@
│ 필수첨부파일아이디(자동발급) [EAF-0004] │
│ 문서구분 [설치인가 ▼] │
│ 필수첨부파일명 [예: 사업계획서] │
│ 양식파일 [파일 선택] │
│ 필수첨부여부 [ ] 예 [ ] 아니오 │
│ 필수파일검토내용 [검토내용을 입력하세요]│
│ 비고 [비고를 입력하세요] │
├──────────────────────────────────────────────────────┤
│ [취소] [저장] │
@@ -139,16 +141,16 @@ END
### 5-2. 그리드 컬럼
| 컬럼명 | Field | 타입 | 비고 |
| ------------------ | ------------------- | ------ | ------------------------------------- |
| 필수첨부파일아이디 | `esntlAtchFileId` | string | 예: `EAF-0001`, 클라이언트 생성/입력 |
| 문서구분 | `docSeComCd` | string | 코드명 표시 — 공통코드 (TBD 상위코드) |
| 필수첨부파일명 | `esntlAtchFileNm` | string | |
| 필수첨부여부 | `esntlAtchYn` | string | `Y`→예, `N`→아니오 |
| 비고 | `esntlAtchFileRmrk` | string | 없으면 `—` 표시 |
| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` |
| 수정일시 | `mdfcnDt` | string | 미수정 시 빈 값 또는 `—` |
| 관리 | — | — | 행별 **[수정] · [삭제]** |
| 컬럼명 | Field | 타입 | 비고 |
| ------------------ | -------------------- | ------ | ------------------------------------- |
| 필수첨부파일아이디 | `esntlAtchFileId` | string | 예: `EAF-0001`, 클라이언트 생성/입력 |
| 문서구분 | `docSeComCd` | string | 코드명 표시 — 공통코드 (TBD 상위코드) |
| 필수첨부파일명 | `esntlAtchFileNm` | string | |
| 필수첨부여부 | `esntlAtchYn` | string | `Y`→예, `N`→아니오 |
| 필수파일검토내용 | `esntlAtchFileRvwCn` | string | 없으면 `—` 표시, 최대 2줄 clamp |
| 비고 | `esntlAtchFileRmrk` | string | 없으면 `—` 표시 |
| 양식 내려받기 | `atchFileGroupId` | string | 양식파일 다운로드 버튼 |
| 관리 | — | — | 행별 **[수정] · [삭제]** |
- 행 클릭 시 별도 상세 화면 없음. 편집은 **[수정]** 버튼으로 다이얼로그 C를 연다.
@@ -167,13 +169,15 @@ END
### 6-1. 폼 필드
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| ------------------ | ---------- | ---- | ------ | ------------------- | -------------------------------- |
| 필수첨부파일아이디 | (Readonly) | Y | — | `esntlAtchFileId` | 등록 시 미노출 |
| 문서구분 | CodeSelect | Y | — | `docSeComCd` | 예: 설치인가, 기본계획 |
| 필수첨부파일명 | Text | Y | — | `esntlAtchFileNm` | placeholder: "예: 사업계획서" |
| 필수첨부여부 | RadioGroup | Y | Y | `esntlAtchYn` | Y/N, UI 표시: 예/아니오 |
| 비고 | Textarea | N | — | `esntlAtchFileRmrk` | placeholder: "비고를 입력하세요" |
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| ------------------ | ------------------- | ---- | ------ | -------------------- | ---------------------------------- |
| 필수첨부파일아이디 | (Readonly) | Y | — | `esntlAtchFileId` | 등록 시 미노출 |
| 문서구분 | CodeSelect | Y | — | `docSeComCd` | 예: 설치인가, 기본계획 |
| 필수첨부파일명 | Text | Y | — | `esntlAtchFileNm` | placeholder: "예: 사업계획서" |
| 양식파일 | AttachmentFileField | N | — | `atchFileGroupId` | TUS 파일 업로드 |
| 필수첨부여부 | RadioGroup | Y | Y | `esntlAtchYn` | Y/N, UI 표시: 예/아니오 |
| 필수파일검토내용 | Textarea | N | — | `esntlAtchFileRvwCn` | placeholder: "검토내용을 입력하세요"|
| 비고 | Textarea | N | — | `esntlAtchFileRmrk` | placeholder: "비고를 입력하세요" |
### 6-2. 버튼
@@ -210,28 +214,32 @@ END
**응답 DTO**
| Field | 타입 | 설명 |
| ------------------- | ------- | -------------------------------- |
| `esntlAtchFileId` | string | 필수첨부파일아이디 |
| `docSeComCd` | string | 문서구분공통코드 |
| `esntlAtchFileNm` | string | 필수첨부파일명 |
| `esntlAtchYn` | string | 필수첨부여부 (Y/N) |
| `esntlAtchFileRmrk` | string? | 필수첨부파일비고 |
| `rgtr` | string? | 등록자 UUID |
| `rgtrNm` | string? | 등록자명 |
| `mdfr` | string? | 수정자 UUID |
| `regDt` | string? | 등록일시 (`yyyy-MM-dd HH:mm:ss`) |
| `mdfcnDt` | string? | 수정일시 |
| Field | 타입 | 설명 |
| -------------------- | -------------------------- | -------------------------------- |
| `esntlAtchFileId` | string | 필수첨부파일아이디 |
| `docSeComCd` | string | 문서구분공통코드 |
| `esntlAtchFileNm` | string | 필수첨부파일명 |
| `esntlAtchYn` | string | 필수첨부여부 (Y/N) |
| `esntlAtchFileRvwCn` | string? | 필수첨부파일검토내용 |
| `esntlAtchFileRmrk` | string? | 필수첨부파일비고 |
| `atchFileGroupId` | string? | 첨부파일그룹아이디 (양식파일) |
| `atchFileList` | AtflMngClientResponseDto[]?| 첨부파일 목록 |
| `rgtr` | string? | 등록자 UUID |
| `rgtrNm` | string? | 등록자명 |
| `mdfr` | string? | 수정자 UUID |
| `regDt` | string? | 등록일시 (`yyyy-MM-dd HH:mm:ss`) |
| `mdfcnDt` | string? | 수정일시 |
**저장 Request**
| Field | 필수 | 설명 |
| ------------------- | ---- | ------------------ |
| `esntlAtchFileId` | Y | 필수첨부파일아이디 |
| `docSeComCd` | N | 문서구분 |
| `esntlAtchFileNm` | N | 필수첨부파일명 |
| `esntlAtchYn` | N | 필수첨부여부 |
| `esntlAtchFileRmrk` | N | 비고 |
| Field | 필수 | 설명 |
| -------------------- | ---- | -------------------- |
| `docSeComCd` | Y | 문서구분 |
| `esntlAtchFileNm` | N | 필수첨부파일명 |
| `esntlAtchYn` | N | 필수첨부여부 |
| `esntlAtchFileRvwCn` | N | 필수첨부파일검토내용 |
| `esntlAtchFileRmrk` | N | 비고 |
| `atchFileGroupId` | N | 첨부파일그룹아이디 |
**검색 Query**
@@ -19,6 +19,8 @@ export interface EsntlAtchFileMng {
esntlAtchYn: "Y" | "N";
/** 필수첨부파일비고 */
esntlAtchFileRmrk?: string;
/** 필수첨부파일검토내용 */
esntlAtchFileRvwCn?: string;
/** 첨부파일그룹아이디 (양식파일 그룹) */
atchFileGroupId?: string;
/** 첨부파일 목록 (양식파일) */
@@ -77,6 +79,8 @@ export interface EsntlAtchFileMngSaveRequest {
esntlAtchYn?: "Y" | "N";
/** 필수첨부파일비고 */
esntlAtchFileRmrk?: string;
/** 필수첨부파일검토내용 */
esntlAtchFileRvwCn?: string;
/** 첨부파일그룹아이디 (양식파일 그룹) */
atchFileGroupId?: string;
}
@@ -50,7 +50,6 @@ export interface TechReviewOpinionSectionHandle {
interface TechReviewOpinionTableProps {
items: DesignCnstnRvwOpnnResponseDto[];
isReviewTurn: boolean;
}
interface UploadedOpinionFile {
@@ -296,7 +295,7 @@ const TechReviewOpinionSection = forwardRef<
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<div className="space-y-6">
{kecoRvwList.length > 0 ? (
<TechReviewOpinionTable items={kecoRvwList} isReviewTurn={isReviewTurn("KECO")} />
<TechReviewOpinionTable items={kecoRvwList} />
) : null}
{renderOpinionForm("KECO")}
</div>
@@ -311,7 +310,7 @@ function getRvwOpnnIds(item: DesignCnstnRvwOpnnResponseDto) {
return [item.designCnstnDocNo, item.rvwOpnnSn] as const;
}
function TechReviewOpinionTable({ items, isReviewTurn }: TechReviewOpinionTableProps) {
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
const queryClient = useQueryClient();
const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
@@ -320,7 +319,6 @@ function TechReviewOpinionTable({ items, isReviewTurn }: TechReviewOpinionTableP
items={items}
getRowKey={(item) => `${item.designCnstnDocNo}_${item.rvwOpnnSn}`}
currentUserUuid={currentUserUuid}
isReviewTurn={isReviewTurn}
onUpdate={async (item, body) => {
await designCnstnRvwOpnnService.update(...getRvwOpnnIds(item), body);
await queryClient.invalidateQueries({ queryKey: designCnstnKeys.all });
@@ -51,6 +51,19 @@ export function createEsntlAtchFileColumns({
meta: { align: "center" },
cell: ({ getValue }) => formatYn(getValue()),
}),
columnHelper.accessor("esntlAtchFileRvwCn", {
header: "필수파일검토내용",
meta: { align: "left" },
cell: ({ getValue }) => {
const val = getValue();
if (!val?.trim()) return "-";
return (
<span className="line-clamp-2 max-w-[20rem] text-left" title={val}>
{val}
</span>
);
},
}),
columnHelper.accessor("esntlAtchFileRmrk", {
header: "비고",
meta: { align: "center" },
@@ -26,6 +26,7 @@ export interface EsntlAtchFileFormValues {
docSeComCd: string;
esntlAtchFileNm: string;
esntlAtchYn: "Y" | "N";
esntlAtchFileRvwCn: string;
esntlAtchFileRmrk: string;
atchFileGroupId: string;
}
@@ -68,6 +69,7 @@ function EsntlAtchFileFormDialog({
docSeComCd: "",
esntlAtchFileNm: "",
esntlAtchYn: "Y",
esntlAtchFileRvwCn: "",
esntlAtchFileRmrk: "",
atchFileGroupId: "",
},
@@ -115,6 +117,7 @@ function EsntlAtchFileFormDialog({
docSeComCd: initial.docSeComCd ?? "",
esntlAtchFileNm: initial.esntlAtchFileNm ?? "",
esntlAtchYn: initial.esntlAtchYn ?? "Y",
esntlAtchFileRvwCn: initial.esntlAtchFileRvwCn ?? "",
esntlAtchFileRmrk: initial.esntlAtchFileRmrk ?? "",
atchFileGroupId: initial.atchFileGroupId ?? "",
});
@@ -127,6 +130,7 @@ function EsntlAtchFileFormDialog({
docSeComCd: "",
esntlAtchFileNm: "",
esntlAtchYn: "Y",
esntlAtchFileRvwCn: "",
esntlAtchFileRmrk: "",
atchFileGroupId: "",
});
@@ -249,6 +253,17 @@ function EsntlAtchFileFormDialog({
</div>
</RadioGroup>
</div>
<div className="space-y-2">
<Label htmlFor="esntlAtchFileRvwCn">필수파일검토내용</Label>
<Textarea
id="esntlAtchFileRvwCn"
rows={3}
placeholder="검토내용을 입력하세요"
maxLength={4000}
{...register("esntlAtchFileRvwCn")}
className="!text-xl"
/>
</div>
<div className="space-y-2">
<Label htmlFor="esntlAtchFileRmrk">비고</Label>
<Textarea
@@ -100,7 +100,7 @@ function EsntlAtchFileList({
manualPagination: true,
},
dataTableOptions: {
caption: "필수첨부파일 목록으로 필수첨부파일아이디, 문서구분, 필수첨부파일명, 필수첨부여부, 비고, 양식 내려받기, 관리 정보 제공",
caption: "필수첨부파일 목록으로 필수첨부파일아이디, 문서구분, 필수첨부파일명, 필수첨부여부, 필수파일검토내용, 비고, 양식 내려받기, 관리 정보 제공",
viewPagination: true,
},
});
@@ -112,6 +112,7 @@ function EsntlAtchFileList({
docSeComCd: values.docSeComCd,
esntlAtchFileNm: values.esntlAtchFileNm,
esntlAtchYn: values.esntlAtchYn,
esntlAtchFileRvwCn: values.esntlAtchFileRvwCn || undefined,
esntlAtchFileRmrk: values.esntlAtchFileRmrk || undefined,
atchFileGroupId: values.atchFileGroupId || undefined,
};
@@ -48,7 +48,6 @@ export interface TechReviewOpinionSectionHandle {
interface TechReviewOpinionTableProps {
items: FnrsCnsltnRvwOpnnResponseDto[];
isReviewTurn: boolean;
}
interface UploadedOpinionFile {
@@ -292,7 +291,7 @@ const TechReviewOpinionSection = forwardRef<
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
<div className="space-y-6">
{envRvwList.length > 0 ? (
<TechReviewOpinionTable items={envRvwList} isReviewTurn={isReviewTurn("ENV")} />
<TechReviewOpinionTable items={envRvwList} />
) : null}
{renderOpinionForm("ENV")}
</div>
@@ -302,7 +301,7 @@ const TechReviewOpinionSection = forwardRef<
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<div className="space-y-6">
{kecoRvwList.length > 0 ? (
<TechReviewOpinionTable items={kecoRvwList} isReviewTurn={isReviewTurn("KECO")} />
<TechReviewOpinionTable items={kecoRvwList} />
) : null}
{/*
재원협의에서 환경공단 검토의견은 자동 생성
@@ -322,7 +321,7 @@ function getRvwOpnnIds(item: FnrsCnsltnRvwOpnnResponseDto) {
return [item.fnrsCnsltnDocNo, item.rvwOpnnSn] as const;
}
function TechReviewOpinionTable({ items, isReviewTurn }: TechReviewOpinionTableProps) {
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
const queryClient = useQueryClient();
const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
@@ -331,7 +330,6 @@ function TechReviewOpinionTable({ items, isReviewTurn }: TechReviewOpinionTableP
items={items}
getRowKey={(item) => `${item.fnrsCnsltnDocNo}_${item.rvwOpnnSn}`}
currentUserUuid={currentUserUuid}
isReviewTurn={isReviewTurn}
onUpdate={async (item, body) => {
await fnrsCnsltnRvwOpnnService.update(...getRvwOpnnIds(item), body);
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
@@ -3,7 +3,6 @@ export const INSTL_SNCN_VIEW_BUTTON_ACTION = {
aply: "aply",
del: "del",
instlSncnAncmnt: "instlSncnAncmnt",
envoSbmsn: "envoSbmsn",
rjct: "rjct",
sncnAprv: "sncnAprv",
rvwRply: "rvwRply",
@@ -33,14 +32,16 @@ const ROLE_STATUS_BUTTON_ACTIONS: Record<string, Record<string, InstlSncnViewBut
],
TA: [INSTL_SNCN_VIEW_BUTTON_ACTION.instlSncnAncmnt],
},
ROLE_USER04: {
ST: [INSTL_SNCN_VIEW_BUTTON_ACTION.envoSbmsn, INSTL_SNCN_VIEW_BUTTON_ACTION.rjct],
EP: [INSTL_SNCN_VIEW_BUTTON_ACTION.sncnAprv],
},
ROLE_USER03: {
LE: [INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply, INSTL_SNCN_VIEW_BUTTON_ACTION.rqstTechRvw],
TP: [INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply],
},
// 환경청 단계 제거: 기존 환경청(LE·TP) 기능을 광역자치단체가 수행
ROLE_USER04: {
ST: [
INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply,
INSTL_SNCN_VIEW_BUTTON_ACTION.rqstTechRvw,
INSTL_SNCN_VIEW_BUTTON_ACTION.rjct,
],
TP: [INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply],
EP: [INSTL_SNCN_VIEW_BUTTON_ACTION.sncnAprv],
},
};
const ROLE_USER01_ATRZ_STATUS_CODES = ["ER", "TE"] as const;
@@ -55,16 +55,8 @@ export const instlSncnRegistSchema = z.object({
tstmDesignAmt: z.number(),
}),
),
cklstResultList: z.array(
instlSncnCklstResultItemSchema.extend({
cklstSn: z.number(),
cklstArtclSn: z.number(),
cklstRvwSn: z.number(),
rvwStpComCd: z.string(),
selected: z.enum(["Y", "N"]).nullish(),
picRvwCn: z.string().nullish(),
}),
),
// 설치인가 화면에서 체크리스트 미사용: 숨겨진 값 때문에 신청·저장 검증이 막히지 않도록 필수값을 두지 않음
cklstResultList: z.array(instlSncnCklstResultItemSchema),
esntlAtchFileList: z.array(
instlSncnEsntlAtchFileItemSchema.extend({
esntlAtchFileId: z.string(),
@@ -50,7 +50,7 @@ export function getEditableReviewStep(
dtlInstCd?: string,
prgrsSttsComCd?: string,
): ReviewStepCode | undefined {
if (dtlInstCd === "ROLE_USER03" && (prgrsSttsComCd === "LE" || prgrsSttsComCd === "TP")) {
if (dtlInstCd === "ROLE_USER04" && (prgrsSttsComCd === "ST" || prgrsSttsComCd === "TP")) {
return "ENV";
}
@@ -50,13 +50,13 @@ export const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"];
export const CHECKLIST_TAB_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "자가체크",
ENV: "환경청검토",
ENV: "광역자치단체검토",
KECO: "기술검토",
};
export const REVIEW_STEP_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "지자체",
ENV: "환경청",
ENV: "광역자치단체",
KECO: "환경공단",
};
@@ -89,7 +89,7 @@ export const INSTL_PROGRESS_ORDER = [
export const ROLE_TO_REVIEW_STEP: Record<string, ReviewStepCode> = {
ROLE_USER05: "LOCAL",
ROLE_USER03: "ENV",
ROLE_USER04: "ENV",
ROLE_USER01: "KECO",
};
@@ -35,7 +35,6 @@ import {
type InstlSncnViewButtonAction,
needsRoleUser01AtrzResolve,
} from "../../lib/button-config";
import { computeReviewStepChecklistProgress } from "../../lib/checklist-utils";
import {
buildSavePayload,
buildSupplementReqPayload,
@@ -179,11 +178,6 @@ function FloatingActionButtons({
}
async function handleAplyClick() {
const { percent } = computeReviewStepChecklistProgress(
data.cklstResultList ?? [],
checklistFormState.results,
"LOCAL",
);
const payload: InstlSncnApplySaveRequestDto = buildSavePayload(
data,
checklistFormState,
@@ -191,11 +185,6 @@ function FloatingActionButtons({
"ST",
);
if (percent !== 100) {
alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다.");
return;
}
await executeAction({
action: INSTL_SNCN_VIEW_BUTTON_ACTION.aply,
request: payload,
@@ -231,22 +220,6 @@ function FloatingActionButtons({
});
}
async function handleEnvoSbmsnClick() {
const payload: DocPrgrsUpdateRequestDto = {
docNo,
docSeComCd,
prgrsSttsComCd: "LE",
};
await executeAction({
action: INSTL_SNCN_VIEW_BUTTON_ACTION.envoSbmsn,
request: payload,
successMessage: "제출되었습니다.",
});
queryClient.invalidateQueries({
queryKey: instlSncnKeys.list(),
});
}
async function handleRjctClick() {
const payload: DocPrgrsUpdateRequestDto = {
docNo,
@@ -284,16 +257,6 @@ function FloatingActionButtons({
}
async function handleRvwRplyClick() {
const { percent } = computeReviewStepChecklistProgress(
data.cklstResultList ?? [],
checklistFormState.results,
"ENV",
);
if (percent !== 100) {
alert("모든 체크리스트 항목을 체크해야 검토결과 회신이 가능합니다.");
return;
}
const payload = buildTechReviewReplyPayload(
data,
@@ -369,16 +332,6 @@ function FloatingActionButtons({
}
async function handleRvwCmptnClick() {
const { percent } = computeReviewStepChecklistProgress(
data.cklstResultList ?? [],
checklistFormState.results,
"KECO",
);
if (percent !== 100) {
alert("모든 체크리스트 항목을 체크해야 검토완료가 가능합니다.");
return;
}
const payload = {
...buildTechReviewReplyPayload(
@@ -447,11 +400,6 @@ function FloatingActionButtons({
설치인가 고시
</Button>
)}
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.envoSbmsn) && (
<Button onClick={handleEnvoSbmsnClick} disabled={isProcessing}>
환경청제출
</Button>
)}
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.rjct) && (
<Button color="red" onClick={handleRjctClick} disabled={isProcessing}>
반려
@@ -618,7 +566,6 @@ const buttonActionHandlers: Record<InstlSncnViewButtonAction, ActionHandler> = {
aply: handleSaveApply,
del: handleDel,
instlSncnAncmnt: handleChangePrgrsSttsComCd,
envoSbmsn: handleChangePrgrsSttsComCd,
rjct: handleChangePrgrsSttsComCd,
sncnAprv: handleChangePrgrsSttsComCd,
rvwRply: handleTechReview,
@@ -20,9 +20,7 @@ import {
AttachmentsFormSection,
BusinessCostFormSection,
BusinessScaleFormSection,
ChecklistFormSection,
ContactInfoFormSection,
isFormChecklistComplete,
isFormEsntlAtchComplete,
PrivacyConsentSection,
ProgressStatusSection,
@@ -154,11 +152,6 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
return;
}
if (!isFormChecklistComplete(data.cklstResultList)) {
alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다.");
return;
}
if (!isFormEsntlAtchComplete(essentialFiles, data.esntlAtchFileList)) {
alert("필수 첨부파일을 모두 등록해야 신청이 가능합니다.");
return;
@@ -228,7 +221,6 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
<PrivacyConsentSection />
<BusinessScaleFormSection />
<BusinessCostFormSection />
<ChecklistFormSection docSeComCd="INSTL" fieldPrefix="instlSncnMng" />
<AttachmentsFormSection
docSeComCd="INSTL"
fieldPrefix="instlSncnMng"
@@ -1,149 +1,141 @@
"use client";
import { useMemo, useRef } from "react";
import { useUserStore } from "@/shared/lib";
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
import { useInstlSncnDetail } from "@/entities/instl-sncn";
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
import {
AttachmentsViewSection,
ProgressStatusSection,
SupplementDemandSection,
} from "@/widgets/pbadms-sprt";
import { Skeleton } from "@common/components";
import { needsRoleUser01AtrzResolve } from "../lib/button-config";
import { getEditableReviewStep } from "../lib/review-step-utils";
import { EMPTY_CKLST_RESULT_LIST, useChecklistFormState } from "../lib/use-checklist-form-state";
import { PROGRESS_STATUS_COLUMNS } from "../model/columns";
import { FloatingActionButtons } from "./components/floating-action-buttons";
import { BasicInfoSection } from "./sections/basic-info-section";
import { BusinessCostSection } from "./sections/business-cost-section";
import { BusinessScaleSection } from "./sections/business-scale-section";
import { ChecklistSection } from "./sections/checklist-section";
import { ContactInfoSection } from "./sections/contact-info-section";
import {
TechReviewOpinionSection,
type TechReviewOpinionSectionHandle,
} from "./sections/tech-review-opinion-section";
interface InstlViewPageProps {
id: string;
}
function InstlViewPage({ id }: InstlViewPageProps) {
const userInfo = useUserStore((state) => state.userInfo);
const techReviewOpinionRef = useRef<TechReviewOpinionSectionHandle>(null);
const { data, isLoading, isError } = useInstlSncnDetail(id);
const { formState: checklistFormState, setFormState: setChecklistFormState } =
useChecklistFormState(data?.cklstResultList ?? EMPTY_CKLST_RESULT_LIST);
const prgrsSttsComCd = data?.detDto?.prgrsSttsComCd || "";
const needsAtrzResolve = needsRoleUser01AtrzResolve(userInfo?.dtlInstCd, prgrsSttsComCd);
const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList(
{},
{ enabled: needsAtrzResolve },
);
const atrzSeComCdList = useMemo(
() => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [],
[rvwLoginUserData],
);
if (isLoading) {
return (
<div className="mt-6 flex flex-col gap-4 px-6">
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[16rem] rounded-xl" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[16rem] rounded-xl" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[32rem] rounded-xl" />
</div>
</div>
);
}
if (isError || !data) {
return <div className="mt-6 text-[1.4rem]">상세 정보를 찾을 수 없습니다.</div>;
}
const {
detDto,
bizSclList,
bizCstList,
cklstResultList = [],
esntlFileList,
etcFileList,
rvwList = [],
splmntDmndList = [],
sttsChgHstryList = [],
} = data;
const editableReviewStep = getEditableReviewStep(
userInfo?.dtlInstCd,
detDto.prgrsSttsComCd || "",
);
return (
<div className="mt-4">
<SectionCollapsibleGroup>
<div className="sticky top-[6rem] z-30 bg-container px-4 py-2">
<SectionCollapsibleToggle />
</div>
<div className="space-y-4">
<ProgressStatusSection
columns={PROGRESS_STATUS_COLUMNS}
sttsChgHstryList={sttsChgHstryList}
elaprDocNo={detDto.instlSncnDocNo}
/>
<BasicInfoSection det={detDto} />
<ContactInfoSection det={detDto} />
<ElaprDocPrgrsStatus elaprDocNo={detDto.instlSncnDocNo} />
<BusinessScaleSection bizSclList={bizSclList} />
<BusinessCostSection bizCstList={bizCstList} />
<ChecklistSection
cklstResultList={cklstResultList}
prgrsSttsComCd={detDto.prgrsSttsComCd || ""}
userRoleCode={userInfo?.dtlInstCd}
formState={checklistFormState}
setFormState={setChecklistFormState}
/>
<AttachmentsViewSection
esntlFileList={esntlFileList ?? []}
etcFileList={etcFileList ?? []}
/>
<TechReviewOpinionSection
ref={techReviewOpinionRef}
rvwList={rvwList}
editableReviewStep={editableReviewStep}
atrzSeComCdList={atrzSeComCdList}
isAtrzLoading={needsAtrzResolve && isRvwLoginUserLoading}
/>
<SupplementDemandSection splmntDmndList={splmntDmndList} />
<FloatingActionButtons
data={data}
docSeComCd="INSTL"
instlSncnDocNo={detDto.instlSncnDocNo}
prgrsSttsComCd={detDto.prgrsSttsComCd || ""}
dtlInstCd={userInfo?.dtlInstCd}
atrzSeComCdList={atrzSeComCdList}
isRvwLoginUserLoading={needsAtrzResolve && isRvwLoginUserLoading}
checklistFormState={checklistFormState}
techReviewOpinionRef={techReviewOpinionRef}
opnrNm={userInfo?.userName}
/>
</div>
</SectionCollapsibleGroup>
</div>
);
}
export { InstlViewPage };
"use client";
import { useMemo, useRef } from "react";
import { useUserStore } from "@/shared/lib";
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
import { useInstlSncnDetail } from "@/entities/instl-sncn";
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
import {
AttachmentsViewSection,
ProgressStatusSection,
SupplementDemandSection,
} from "@/widgets/pbadms-sprt";
import { Skeleton } from "@common/components";
import { needsRoleUser01AtrzResolve } from "../lib/button-config";
import { getEditableReviewStep } from "../lib/review-step-utils";
import { EMPTY_CKLST_RESULT_LIST, useChecklistFormState } from "../lib/use-checklist-form-state";
import { PROGRESS_STATUS_COLUMNS } from "../model/columns";
import { FloatingActionButtons } from "./components/floating-action-buttons";
import { BasicInfoSection } from "./sections/basic-info-section";
import { BusinessCostSection } from "./sections/business-cost-section";
import { BusinessScaleSection } from "./sections/business-scale-section";
import { ContactInfoSection } from "./sections/contact-info-section";
import {
TechReviewOpinionSection,
type TechReviewOpinionSectionHandle,
} from "./sections/tech-review-opinion-section";
interface InstlViewPageProps {
id: string;
}
function InstlViewPage({ id }: InstlViewPageProps) {
const userInfo = useUserStore((state) => state.userInfo);
const techReviewOpinionRef = useRef<TechReviewOpinionSectionHandle>(null);
const { data, isLoading, isError } = useInstlSncnDetail(id);
const { formState: checklistFormState } = useChecklistFormState(
data?.cklstResultList ?? EMPTY_CKLST_RESULT_LIST,
);
const prgrsSttsComCd = data?.detDto?.prgrsSttsComCd || "";
const needsAtrzResolve = needsRoleUser01AtrzResolve(userInfo?.dtlInstCd, prgrsSttsComCd);
const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList(
{},
{ enabled: needsAtrzResolve },
);
const atrzSeComCdList = useMemo(
() => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [],
[rvwLoginUserData],
);
if (isLoading) {
return (
<div className="mt-6 flex flex-col gap-4 px-6">
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[16rem] rounded-xl" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[16rem] rounded-xl" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[32rem] rounded-xl" />
</div>
</div>
);
}
if (isError || !data) {
return <div className="mt-6 text-[1.4rem]">상세 정보를 찾을 수 없습니다.</div>;
}
const {
detDto,
bizSclList,
bizCstList,
esntlFileList,
etcFileList,
rvwList = [],
splmntDmndList = [],
sttsChgHstryList = [],
} = data;
const editableReviewStep = getEditableReviewStep(
userInfo?.dtlInstCd,
detDto.prgrsSttsComCd || "",
);
return (
<div className="mt-4">
<SectionCollapsibleGroup>
<div className="sticky top-[6rem] z-30 bg-container px-4 py-2">
<SectionCollapsibleToggle />
</div>
<div className="space-y-4">
<ProgressStatusSection
columns={PROGRESS_STATUS_COLUMNS}
sttsChgHstryList={sttsChgHstryList}
elaprDocNo={detDto.instlSncnDocNo}
/>
<BasicInfoSection det={detDto} />
<ContactInfoSection det={detDto} />
<ElaprDocPrgrsStatus elaprDocNo={detDto.instlSncnDocNo} />
<BusinessScaleSection bizSclList={bizSclList} />
<BusinessCostSection bizCstList={bizCstList} />
<AttachmentsViewSection
esntlFileList={esntlFileList ?? []}
etcFileList={etcFileList ?? []}
/>
<TechReviewOpinionSection
ref={techReviewOpinionRef}
rvwList={rvwList}
editableReviewStep={editableReviewStep}
atrzSeComCdList={atrzSeComCdList}
isAtrzLoading={needsAtrzResolve && isRvwLoginUserLoading}
/>
<SupplementDemandSection splmntDmndList={splmntDmndList} />
<FloatingActionButtons
data={data}
docSeComCd="INSTL"
instlSncnDocNo={detDto.instlSncnDocNo}
prgrsSttsComCd={detDto.prgrsSttsComCd || ""}
dtlInstCd={userInfo?.dtlInstCd}
atrzSeComCdList={atrzSeComCdList}
isRvwLoginUserLoading={needsAtrzResolve && isRvwLoginUserLoading}
checklistFormState={checklistFormState}
techReviewOpinionRef={techReviewOpinionRef}
opnrNm={userInfo?.userName}
/>
</div>
</SectionCollapsibleGroup>
</div>
);
}
export { InstlViewPage };
@@ -47,7 +47,6 @@ export interface TechReviewOpinionSectionHandle {
interface TechReviewOpinionTableProps {
items: InstlSncnRvwOpnnResponseDto[];
isReviewTurn: boolean;
}
interface UploadedOpinionFile {
@@ -300,7 +299,7 @@ const TechReviewOpinionSection = forwardRef<
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
<div className="space-y-6">
{envRvwList.length > 0 ? (
<TechReviewOpinionTable items={envRvwList} isReviewTurn={isReviewTurn("ENV")} />
<TechReviewOpinionTable items={envRvwList} />
) : null}
{renderOpinionForm("ENV")}
</div>
@@ -310,7 +309,7 @@ const TechReviewOpinionSection = forwardRef<
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<div className="space-y-6">
{kecoRvwList.length > 0 ? (
<TechReviewOpinionTable items={kecoRvwList} isReviewTurn={isReviewTurn("KECO")} />
<TechReviewOpinionTable items={kecoRvwList} />
) : null}
{renderOpinionForm("KECO")}
</div>
@@ -327,7 +326,7 @@ function getRvwOpnnIds(item: InstlSncnRvwOpnnResponseDto) {
return [item.instlSncnDocNo, item.rvwOpnnSn] as const;
}
function TechReviewOpinionTable({ items, isReviewTurn }: TechReviewOpinionTableProps) {
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
const queryClient = useQueryClient();
const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
@@ -336,7 +335,6 @@ function TechReviewOpinionTable({ items, isReviewTurn }: TechReviewOpinionTableP
items={items}
getRowKey={(item) => `${item.instlSncnDocNo}_${item.rvwOpnnSn}`}
currentUserUuid={currentUserUuid}
isReviewTurn={isReviewTurn}
onUpdate={async (item, body) => {
await instlSncnRvwOpnnService.update(...getRvwOpnnIds(item), body);
await queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
@@ -50,7 +50,6 @@ export interface TechReviewOpinionSectionHandle {
interface TechReviewOpinionTableProps {
items: SewerBscPlanRvwOpnnResponseDto[];
isReviewTurn: boolean;
}
interface UploadedOpinionFile {
@@ -304,7 +303,7 @@ const TechReviewOpinionSection = forwardRef<
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
<div className="space-y-6">
{envRvwList.length > 0 ? (
<TechReviewOpinionTable items={envRvwList} isReviewTurn={isReviewTurn("ENV")} />
<TechReviewOpinionTable items={envRvwList} />
) : null}
{renderOpinionForm("ENV")}
</div>
@@ -314,7 +313,7 @@ const TechReviewOpinionSection = forwardRef<
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<div className="space-y-6">
{kecoRvwList.length > 0 ? (
<TechReviewOpinionTable items={kecoRvwList} isReviewTurn={isReviewTurn("KECO")} />
<TechReviewOpinionTable items={kecoRvwList} />
) : null}
{renderOpinionForm("KECO")}
</div>
@@ -331,7 +330,7 @@ function getRvwOpnnIds(item: SewerBscPlanRvwOpnnResponseDto) {
return [item.sewerBscPlanDocNo, item.chgCycl, item.rvwOpnnSn] as const;
}
function TechReviewOpinionTable({ items, isReviewTurn }: TechReviewOpinionTableProps) {
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
const queryClient = useQueryClient();
const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
@@ -340,7 +339,6 @@ function TechReviewOpinionTable({ items, isReviewTurn }: TechReviewOpinionTableP
items={items}
getRowKey={(item) => `${item.sewerBscPlanDocNo}_${item.rvwOpnnSn}`}
currentUserUuid={currentUserUuid}
isReviewTurn={isReviewTurn}
onUpdate={async (item, body) => {
await sewerBscPlanRvwOpnnService.update(...getRvwOpnnIds(item), body);
await queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
@@ -45,8 +45,6 @@ interface RvwOpnnListTableProps<T extends RvwOpnnListItem> {
getRowKey: (item: T) => string;
/** 로그인 사용자 UUID — 검토의견 등록자(rgtr)와 같을 때만 수정·삭제 버튼 노출 */
currentUserUuid?: string;
/** 현재 검토 단계가 로그인 사용자 차례인지 여부 — false면 등록자라도 수정·삭제 불가 */
isReviewTurn?: boolean;
onUpdate: (item: T, body: RvwOpnnUpdateRequestDto) => Promise<void>;
onDelete: (item: T) => Promise<void>;
}
@@ -76,7 +74,6 @@ function RvwOpnnListTable<T extends RvwOpnnListItem>({
items,
getRowKey,
currentUserUuid,
isReviewTurn = false,
onUpdate,
onDelete,
}: RvwOpnnListTableProps<T>) {
@@ -102,7 +99,7 @@ function RvwOpnnListTable<T extends RvwOpnnListItem>({
const editFileUploading = editFile ? (isUploading[editFile.fileKey] ?? false) : false;
function canModify(item: T) {
return isReviewTurn && Boolean(currentUserUuid) && item.rgtr === currentUserUuid;
return Boolean(currentUserUuid) && item.rgtr === currentUserUuid;
}
async function discardNewFile(file: EditFile | null) {
+1 -1
View File
@@ -11,7 +11,7 @@ NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
#NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr/openapi/Gettile.do?apiKey=04trYP9_xwLAfALjwZ-B8g&Service=WMTS&Request=GetTile&Version=1.0.0&Format=image/png&layer=korean_map&style=korean&tilematrixset=EPSG:5179&TileMatrix={z}&TileCol={x}&TileRow={y}
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
SERVER_API_URL = http://dev.onasys.co.kr:8000
SERVER_API_URL = https://apigateway.onasys.co.kr
SEWERAGE_API_URL = http://localhost:8883
# SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
DC_URL = https://apigateway.apps.rsonesoft.com
+1 -1
View File
@@ -6,7 +6,7 @@ NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11302
NEXT_PUBLIC_SUB_DOMAIN = http://con.mind-one.co.kr:13000
SERVER_API_URL = http://dev.onasys.co.kr:8000
SERVER_API_URL = https://apigateway.onasys.co.kr
NEXT_PUBLIC_GEOSERVER_URL = https://hasudo.geo.rsonesoft.com/geoserver
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
+1
View File
@@ -166,6 +166,7 @@ $commonDocsRoot = Join-Path $beRepoRoot 'docs'
## 작업 원칙
- `sn` 프론트엔드 엑셀 다운로드 기능을 신규 개발하거나 수정할 때는 `src/shared/util/download-excel-workbook.ts` 재사용을 먼저 검토한다. 세부 기준은 [컴포넌트정책의 공통 유틸 재사용 기준](docs/기준문서/004-컴포넌트정책.md#공통-유틸-재사용-기준)을 따른다. 이 코드 경로는 `FE_APP_ROOT` 기준이다.
- 일반 목록·모든 팝업은 공통 AGENTS.md의 서버 페이징·지도·Excel 계약을 따른다. 지도 전체 표출에 현재 그리드 페이지 배열을 재사용하지 않는다. Excel은 적용조건 전체를 사용하고 대량 원시 자료는 서버 생성으로 분리한다. 화면 페이징/페이징 없음 예외는 사유와 검증 범위를 메뉴 > 화면명별로 기록한다.
1. 모든 변경은 원본 요구사항정의서와 화면설계서, 2차 요구사항, 기능 명세 또는 승인된 작업 문서를 근거로 한다.
@@ -66,7 +66,8 @@ export const normalizeAgedPipeStatusByCtpvFilters = (
filters: Partial<AgedPipeStatusByCtpvFilters>,
): AgedPipeStatusByCtpvFilters => {
const currentYear = new Date().getFullYear();
let startYear = Math.trunc(Number(filters.startYear ?? currentYear - 2));
// 최초 진입·초기화는 당해연도 1년 조회로 시작하며 직접 선택한 범위는 유지한다.
let startYear = Math.trunc(Number(filters.startYear ?? currentYear));
let endYear = Math.trunc(Number(filters.endYear ?? currentYear));
let elapsedYears = Math.trunc(
Number(
@@ -74,7 +75,7 @@ export const normalizeAgedPipeStatusByCtpvFilters = (
),
);
if (!Number.isFinite(startYear)) startYear = currentYear - 2;
if (!Number.isFinite(startYear)) startYear = currentYear;
if (!Number.isFinite(endYear)) endYear = currentYear;
if (
!Number.isFinite(elapsedYears) ||
@@ -10,9 +10,7 @@ import {
ChartColumnBig,
ChevronUp,
PanelsTopLeft,
PencilRuler,
Search,
Settings2,
TriangleAlert,
Waves,
} from "lucide-react";
@@ -28,8 +26,6 @@ import { getRawJson } from "@/shared/api/sewerage-api";
type Props = {
onOpenPopup: (popupKey: WindowPopupKey) => Window | null;
onOpenInfoSearch: () => void;
onToggleGisEdit?: () => void;
isGisEditActive?: boolean;
isInfoSearchActive?: boolean;
initialActiveKey?: WindowPopupKey | null;
};
@@ -89,16 +85,6 @@ const ANALYSIS_POPUP_ITEMS: Array<{
label: windowPopupDefinitions.agedPipeStatusByCtpvPopup.title,
icon: ChartColumnBig,
},
{
key: "gisEditPopup",
label: windowPopupDefinitions.gisEditPopup.title,
icon: PencilRuler,
},
{
key: "gisManagementPopup",
label: windowPopupDefinitions.gisManagementPopup.title,
icon: Settings2,
},
{
key: "flowRiskPopup",
label: windowPopupDefinitions.flowRiskPopup.title,
@@ -119,8 +105,6 @@ const ANALYSIS_POPUP_ITEMS: Array<{
export default function AnalysisPopupMenu({
onOpenPopup,
onOpenInfoSearch,
onToggleGisEdit,
isGisEditActive = false,
isInfoSearchActive = false,
initialActiveKey = null,
}: Props) {
@@ -155,12 +139,6 @@ export default function AnalysisPopupMenu({
};
}, []);
useEffect(() => {
if (!isGisEditActive && activeKey === "gisEditPopup") {
setActiveKey(null);
}
}, [activeKey, isGisEditActive]);
useEffect(() => {
if (!isInfoSearchActive && activeKey === "infoSearch") {
setActiveKey(null);
@@ -214,10 +192,7 @@ export default function AnalysisPopupMenu({
<div className="flex flex-col gap-[3px] p-[6px]">
{ANALYSIS_POPUP_ITEMS.map((item) => {
const Icon = item.icon;
const isActive =
item.key === "gisEditPopup"
? isGisEditActive
: activeKey === item.key;
const isActive = activeKey === item.key;
const label =
item.key === "fldngPredcPrst"
? `침수예측현황(${seriousPredictionCount}건)`
@@ -233,12 +208,6 @@ export default function AnalysisPopupMenu({
: "border-transparent bg-background text-foreground hover:border-border hover:bg-accent hover:text-primary"
}`}
onClick={() => {
if (item.key === "gisEditPopup" && onToggleGisEdit) {
setActiveKey(item.key);
onToggleGisEdit();
return;
}
setActiveKey(item.key);
if (item.key === "infoSearch") {
@@ -127,7 +127,7 @@ const normalizeControlNode = (
...node,
lyrSn: nodeId,
id: nodeId,
// DB에 저장된 과거 명칭과 무관하게 조사관리 표준 화면 명칭으로 표시한다.
// DB에 저장된 과거 명칭과 무관하게 조사사업 표준 화면 명칭으로 표시한다.
lyrNm: fallbackName,
upperLyrSn: upperId,
upId: upperId,
@@ -87,7 +87,7 @@ export const useExmnResultMap = (options: Options) => {
[gisApp, mapRef],
);
/** 지역 검색값 없이 전체를 요청하고, 선택 진입 context는 위치 이동 범위 조회에만 사용한다. */
/** 일반 진입은 전체, 조사관리 지도보기는 선택 사업·처리분구만 조회해 겹친 타 사업 선택을 방지한다. */
const loadLayer = useCallback(
async (job: ExmnMapJobType, filters: ExmnMapFilters) => {
const map = mapRef.current;
@@ -109,7 +109,9 @@ export const useExmnResultMap = (options: Options) => {
const response = await getExmnMapLayer(
job,
filters,
{},
entry.exmnBizId && entry.trtdstId
? { exmnBizId: entry.exmnBizId, trtdstId: entry.trtdstId }
: {},
controller.signal,
);
if (!active()) return;
@@ -5,7 +5,7 @@
"use client";
import { SelectBox } from "@common/business_components/ui";
import { FLDNG_HSTRY_MIN_YEAR, type FldngHstryYearRange } from "@/entities/flood-history/model/flood-history-types";
import { FLDNG_HSTRY_MIN_YEAR, FLDNG_HSTRY_MAX_YEAR, type FldngHstryYearRange } from "@/entities/flood-history/model/flood-history-types";
import { normalizeFldngHstryYearRange } from "@/entities/flood-history/lib/flood-history-utils";
import LayerSettingCard from "../layer-setting/layer-setting-card";
@@ -26,10 +26,10 @@ export default function FldngHstryLayerSettingPanel({
onClose,
}: Props) {
const normalizedRange = normalizeFldngHstryYearRange(yearRange);
const currentYear = new Date().getFullYear();
// 제공 자료의 최대 연도부터 최소 연도까지 선택 목록을 구성한다.
const yearOptions = Array.from(
{ length: currentYear - FLDNG_HSTRY_MIN_YEAR + 1 },
(_, index) => String(currentYear - index),
{ length: FLDNG_HSTRY_MAX_YEAR - FLDNG_HSTRY_MIN_YEAR + 1 },
(_, index) => String(FLDNG_HSTRY_MAX_YEAR - index),
);
const startYearOptions = yearOptions
.filter((year) => Number(year) <= Number(normalizedRange.endYear))
@@ -14,6 +14,7 @@ import { useAlertStore } from "@/shared/lib";
import useStore from "@/entities/reference-data/model/reference-data-store";
import LoadingOverlay from "@/components/ui/loading-bar";
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
import { FLDNG_HSTRY_LAYER_TYPE, FLDNG_HSTRY_DEFAULT_FILTERS, FLDNG_HSTRY_LIST_HEADLIST, FLDNG_HSTRY_PAGE_SIZE, FLDNG_HSTRY_ROW_SELECT_MESSAGE, type FldngHstryApiItem, type FldngHstryFilters, type FldngHstryTableRow } from "@/entities/flood-history/model/flood-history-types";
import { attachFldngHstryGeometry, buildFldngHstryListQueryString, buildFldngHstryYearOptions, normalizeFldngHstryFilters, toFldngHstryTableRows } from "@/entities/flood-history/lib/flood-history-utils";
@@ -421,9 +422,13 @@ export default function FldngHstryPopupPage() {
}
/>
<div className="rounded-sm bg-secondary px-4 py-3 text-[1.2rem] text-muted-foreground">
행을 클릭하면 메인 지도에서 해당 침수흔적을 표시합니다.
</div>
{/* 기존 행 클릭 안내에 인수테스트 요구(자료 원천 표시) 출처 문구를 글머리표로 더한다. */}
<PopupFooterNote
items={[
"행을 클릭하면 메인 지도에서 해당 침수흔적을 표시합니다.",
"출처 : 행정안전부 재난보험과(2002년~2025년)",
]}
/>
</StandardPopupLayout>
);
}
@@ -11,6 +11,7 @@ import { getData } from "@/shared/api/sewerage-api";
import LoadingOverlay from "@/components/ui/loading-bar";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
import useStore from "@/entities/reference-data/model/reference-data-store";
import {
@@ -226,7 +227,12 @@ export default function FloodDngrMapPopupPage() {
const response = await getFloodDngrMapJson(
`/sewerage-service/api/v1/fldng/floodDngrMap/group/extent?${params.toString()}`,
);
const payload = response as FloodDngrMapExtent;
const payload = response as FloodDngrMapExtent | null | undefined;
// 성공 응답의 null·생략된 data는 위치 부재이다. 좌표 접근과 지도 메시지를 중단한다.
if (payload == null) {
setErrorMessage("위치 정보가 없습니다");
return;
}
const extent = [
payload?.minX,
payload?.minY,
@@ -237,7 +243,7 @@ export default function FloodDngrMapPopupPage() {
extent.length !== 4 ||
extent.some((value) => !Number.isFinite(value))
) {
throw new Error("해당 도시침수지도 피처의 위치 정보가 없습니다.");
throw new Error("도시침수지도 위치 응답의 좌표가 올바르지 않습니다.");
}
window.opener?.postMessage(
{
@@ -253,11 +259,9 @@ export default function FloodDngrMapPopupPage() {
} catch (error) {
console.error("도시침수지도 위치 이동 중 오류:", error);
const message =
error instanceof Error && error.message.includes("404")
? "해당 도시침수지도 데이터에 위치 정보가 없어 이동할 수 없습니다."
: error instanceof Error
? error.message
: "도시침수지도 위치 이동 중 오류가 발생했습니다.";
error instanceof Error
? error.message
: "도시침수지도 위치 이동 중 오류가 발생했습니다.";
setErrorMessage(message);
} finally {
setIsLoading(false);
@@ -395,6 +399,11 @@ export default function FloodDngrMapPopupPage() {
onRowClick={(row) => void handleRowClick(row)}
rowId={(row, index) => `${row.sggCd}-${row.sbmrgFrqExpln}-${index}`}
/>
{/* 인수테스트 요구: 팝업 자료의 원천을 목록 아래에 표시한다. */}
<PopupFooterNote
items={["출처 : 한강홍수통제소, 행정구역별 도시침수지도(2025.09)"]}
/>
</StandardPopupLayout>
);
}
@@ -34,7 +34,7 @@ import type {
export const useGisEdit = (treeData: any[]) => {
const editableLayers = useMemo(() => collectEditableLayers(treeData), [treeData]);
// GIS 편집 모드 on/off는 우측 분석 메뉴의 GIS 편집 버튼과 직접 연결된다.
// GIS 편집 모드 on/off는 지도 우측 GIS 편집 아이콘과 직접 연결된다.
const [isEditMode, setIsEditMode] = useState(false);
// 현재 선택 레이어를 기준으로 허용 도구와 저장 payload의 layerId를 결정한다.
const [selectedLayerId, setSelectedLayerId] = useState("tb_ms_swage_ppl");
@@ -0,0 +1,85 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { PencilRuler, Settings2 } from "lucide-react";
type Props = {
isGisEditActive: boolean;
onToggleGisEdit: () => void;
onOpenGisManagement: () => Window | null;
};
/** 기존 지도 도구와 같은 크기로 GIS 버튼의 기본·활성·키보드 포커스를 표시한다. */
const getButtonClass = (isActive: boolean) =>
`flex h-[40px] w-[40px] items-center justify-center p-0 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary ${
isActive
? "bg-primary text-primary-foreground"
: "bg-background text-label hover:bg-primary hover:text-primary-foreground"
}`;
/** 분석도구 메뉴와 독립적으로 GIS 편집 모드와 관리 팝업의 진입 버튼을 제공한다. */
export default function GisToolButtons({
isGisEditActive,
onToggleGisEdit,
onOpenGisManagement,
}: Props) {
// 관리 팝업이 닫히면 버튼의 활성 표시도 해제한다.
const [isGisManagementActive, setIsGisManagementActive] = useState(false);
// 렌더링과 무관한 감지 타이머를 보관해 재클릭·화면 이탈 시 중복 실행을 방지한다.
const closeWatcherRef = useRef<number | null>(null);
useEffect(() => () => {
if (closeWatcherRef.current !== null) {
window.clearInterval(closeWatcherRef.current);
}
}, []);
/** 기존 팝업 실행 함수를 호출하고 차단·닫힘 상태를 버튼에 반영한다. */
const handleOpenGisManagement = () => {
if (closeWatcherRef.current !== null) {
window.clearInterval(closeWatcherRef.current);
closeWatcherRef.current = null;
}
const popup = onOpenGisManagement();
setIsGisManagementActive(Boolean(popup && !popup.closed));
if (!popup || popup.closed) return;
closeWatcherRef.current = window.setInterval(() => {
if (!popup.closed) return;
if (closeWatcherRef.current !== null) {
window.clearInterval(closeWatcherRef.current);
closeWatcherRef.current = null;
}
setIsGisManagementActive(false);
}, 300);
};
return (
<div
role="group"
aria-label="GIS 도구"
className="absolute right-[16px] top-[222.67px] z-[45] flex w-[40px] flex-col overflow-hidden rounded-[3px] bg-background shadow-[0_1px_2px_rgba(60,64,67,0.3),0_1px_3px_1px_rgba(60,64,67,0.15)]"
>
<button
type="button"
title="GIS 편집"
aria-label="GIS 편집"
aria-pressed={isGisEditActive}
onClick={onToggleGisEdit}
className={`${getButtonClass(isGisEditActive)} border-b border-[#eeeeee]`}
>
<PencilRuler className="h-[20px] w-[20px]" aria-hidden="true" />
</button>
<button
type="button"
title="GIS 관리"
aria-label="GIS 관리"
aria-haspopup="dialog"
className={getButtonClass(isGisManagementActive)}
onClick={handleOpenGisManagement}
>
<Settings2 className="h-[20px] w-[20px]" aria-hidden="true" />
</button>
</div>
);
}
@@ -17,6 +17,7 @@ import { useUserStore } from "@/shared/lib/use-user-store";
import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup";
import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions";
import PipeAnalysisSummaryTable from "./pipe-analysis-summary-table";
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
import {
PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS,
@@ -522,6 +523,13 @@ export default function PipeAnalysisPopupPage() {
</Button>
}
/>
{/* 인수테스트 요구: 관로분석의 기반 자료(원천)를 목록 아래에 표시한다. */}
<PopupFooterNote
items={[
"국토교통부로부터 제공받은 GIS 관로정보(2025년)를 기반으로 분석하고 있음",
]}
/>
</StandardPopupLayout>
);
}
@@ -10,6 +10,7 @@ import { StandardPopupLayout } from "@common/business_components";
import { getRawJson } from "@/shared/api/sewerage-api";
import LoadingOverlay from "@/components/ui/loading-bar";
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
import { useAlertStore } from "@/shared/lib";
@@ -309,6 +310,11 @@ export default function SbmrgDngrMapPopupPage() {
].join(":")
}
/>
{/* 인수테스트 요구: 팝업 자료의 원천을 목록 아래에 표시한다. */}
<PopupFooterNote
items={["출처 : 하수도정비 중점관리지역 20개소 과업 산출물"]}
/>
</StandardPopupLayout>
);
}
@@ -0,0 +1,126 @@
/** 지반침하 상세의 표시값과 다운로드 시점 관로 조회 결과를 두 시트로 저장한다. */
import { getRawJson } from "@/shared/api/sewerage-api";
import {
downloadExcelWorkbook,
type ExcelCellValue,
type ExcelSheetSpec,
} from "@/shared/util/download-excel-workbook";
import type { SinkholeDetail, SinkholeNearbyPipe } from "./sinkhole-types";
type DownloadOptions = {
detail: SinkholeDetail;
radiusMeters: number;
summaryEntries: Array<[string, string]>;
getPipeUseName: (pipe: SinkholeNearbyPipe) => string;
};
/** 식별자는 문자열을 유지하고 결측 숫자는 0으로 바꾸지 않는다. */
const numberCell = (value: unknown): ExcelCellValue => {
if (value == null || String(value).trim() === "") return "-";
const number = Number(value);
return Number.isFinite(number) ? number : String(value);
};
/** 화면과 같은 순서의 발생정보 및 반경 내 관로 목록 시트 명세를 만든다. */
export const buildSinkholeDetailSheets = (
options: DownloadOptions,
pipes: SinkholeNearbyPipe[],
noCoordinates: boolean,
): ExcelSheetSpec[] => {
const pipeRows: ExcelCellValue[][] = pipes.map((pipe, index) => [
index + 1,
String(pipe.pipeId ?? "").trim() || "-",
options.getPipeUseName(pipe),
numberCell(pipe.elapsedYears),
numberCell(pipe.distanceMeters),
]);
if (pipeRows.length === 0)
pipeRows.push([
noCoordinates
? "좌표가 없어 반경 내 하수관로를 조회할 수 없습니다."
: "반경 내 하수관로 정보가 없습니다.",
]);
return [
{
name: "지반침하정보",
rows: [["항목", "내용"], ...options.summaryEntries],
columnWidths: [22, 75],
},
{
name: "하수관로목록",
rows: [
["사고번호", String(options.detail.acdntNo)],
["조회 반경(m)", options.radiusMeters],
["조회 기준", "기존 반경 조회 기준, 최대 200건 (가까운 거리순)"],
[],
["NO", "관로 ID", "용도", "경과년도(년)", "거리(m)"],
...pipeRows,
],
columnWidths: [12, 36, 24, 20, 18],
autoFilterRange: `A5:E${5 + pipes.length}`,
mergeRanges: ["B3:E3", ...(pipes.length === 0 ? ["A6:E6"] : [])],
},
];
};
/** 기존 반경 API를 다시 조회하여 실패를 빈 목록으로 내보내지 않는다. */
export const downloadSinkholeDetailExcel = async (options: DownloadOptions) => {
// 비동기 재조회 동안 호출자의 상세·반경·표시 행이 변경되어도 클릭 시점을 유지한다.
const snapshot: DownloadOptions = {
...options,
detail: { ...options.detail },
summaryEntries: options.summaryEntries.map(([title, value]) => [
title,
value,
]),
};
const { detail, radiusMeters } = snapshot;
const noCoordinates = String(detail.coordStatus ?? "").trim() !== "SUCCESS";
let pipes: SinkholeNearbyPipe[] = [];
if (!noCoordinates) {
const hasPoint =
detail.x != null &&
detail.y != null &&
String(detail.x).trim() !== "" &&
String(detail.y).trim() !== "" &&
Number.isFinite(Number(detail.x)) &&
Number.isFinite(Number(detail.y));
const params = new URLSearchParams({ radiusMeters: String(radiusMeters) });
if (hasPoint) {
params.set("x", String(detail.x));
params.set("y", String(detail.y));
}
const path = hasPoint
? "nearbyPipesByPoint"
: `${encodeURIComponent(detail.acdntNo)}/nearbyPipes`;
const raw = await getRawJson(
`/sewerage-service/api/v1/sinkholes/${path}?${params}`,
);
const response = raw?.data ?? raw;
if (response?.coordStatus !== "SUCCESS")
throw new Error(
"관로 조회 좌표를 확인할 수 없습니다. 상세정보를 다시 조회해 주세요.",
);
if (
!Array.isArray(response?.items) ||
response.items.some(
(item: unknown) =>
!item ||
typeof item !== "object" ||
Array.isArray(item) ||
!("pipeId" in item) ||
!("fid" in item),
)
)
throw new Error(
"관로 조회 응답이 올바르지 않습니다. 다시 시도해 주세요.",
);
pipes = response.items;
}
const now = new Date();
const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
await downloadExcelWorkbook({
fileName: `지반침하정보_${detail.acdntNo}_${date}.xlsx`,
sheets: buildSinkholeDetailSheets(snapshot, pipes, noCoordinates),
});
};
@@ -4,12 +4,13 @@
*/
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@common/components";
import { VerticalTable } from "@/components/ui/vertical-table";
import { getCmsData } from "@/shared/api/external-service-api";
import type { SinkholeDetail, SinkholeNearbyPipe } from "./sinkhole-types";
import { useSinkholeCommonCodes } from "./use-sinkhole-common-codes";
import { downloadSinkholeDetailExcel } from "./sinkhole-detail-excel";
type Props = {
detail: SinkholeDetail;
@@ -81,6 +82,9 @@ export default function SinkholeDetailPanel({
}: Props) {
// 상세의 원천 복구상태 값은 유지하고 CMS에 대응하는 코드명만 표시한다.
const { restoreStatusLabels } = useSinkholeCommonCodes();
// 파일 생성 중 버튼 표시와 같은 이벤트 루프의 중복 클릭을 함께 제어한다.
const [isDownloading, setIsDownloading] = useState(false);
const downloadInProgressRef = useRef(false);
const [pipeUseCodeMap, setPipeUseCodeMap] = useState<Map<string, string>>(
new Map(),
);
@@ -127,27 +131,24 @@ export default function SinkholeDetailPanel({
return { average, oldest };
}, [nearbyPipes]);
const summaryTableData = useMemo(
() => {
const restoreStatus = String(detail.restoreStatus ?? "").trim();
return [
{
acdntNo: display(detail.acdntNo),
ocrnYmd: display(detail.ocrnYmd),
fullAddress: display(detail.fullAddress || detail.daddr),
sizeWidth: display(detail.sizeWidth),
sizeLength: display(detail.sizeLength),
sizeDepth: display(detail.sizeDepth),
detailCause: display(detail.detailCause),
restoreStatus: display(
restoreStatusLabels[restoreStatus] || restoreStatus,
),
restoreCompletedYmd: display(detail.restoreCompletedYmd),
},
];
},
[detail, restoreStatusLabels],
);
const summaryTableData = useMemo(() => {
const restoreStatus = String(detail.restoreStatus ?? "").trim();
return [
{
acdntNo: display(detail.acdntNo),
ocrnYmd: display(detail.ocrnYmd),
fullAddress: display(detail.fullAddress || detail.daddr),
sizeWidth: display(detail.sizeWidth),
sizeLength: display(detail.sizeLength),
sizeDepth: display(detail.sizeDepth),
detailCause: display(detail.detailCause),
restoreStatus: display(
restoreStatusLabels[restoreStatus] || restoreStatus,
),
restoreCompletedYmd: display(detail.restoreCompletedYmd),
},
];
}, [detail, restoreStatusLabels]);
const summaryHeadList = [
{ id: "acdntNo", title: "사고번호" },
@@ -161,6 +162,35 @@ export default function SinkholeDetailPanel({
{ id: "restoreCompletedYmd", title: "복구완료일자" },
];
/** 클릭 시의 상세·반경·표시값을 고정해 탭 전환 중에도 한 사고만 내보낸다. */
const handleDownload = async () => {
if (downloadInProgressRef.current) return;
downloadInProgressRef.current = true;
setIsDownloading(true);
const summary = summaryTableData[0] as Record<string, string>;
try {
await downloadSinkholeDetailExcel({
detail: { ...detail },
radiusMeters,
summaryEntries: summaryHeadList.map(({ id, title }) => [
title,
summary[id] ?? "-",
]),
getPipeUseName,
});
} catch (error) {
console.error("지반침하 상세 엑셀 다운로드 실패:", error);
alert(
error instanceof Error
? error.message
: "엑셀 다운로드 중 오류가 발생했습니다.",
);
} finally {
downloadInProgressRef.current = false;
setIsDownloading(false);
}
};
return (
<div
id="popup"
@@ -198,9 +228,20 @@ export default function SinkholeDetailPanel({
<section className="pb-[1.2rem]">
<div className="card-header !block px-[1.2rem]">
<h3 className="mx-3 whitespace-nowrap text-2xl font-medium text-label">
침하발생 반경내 관로정보
</h3>
<div className="flex items-center justify-between gap-2">
<h3 className="ml-3 min-w-0 flex-1 text-2xl font-medium text-label">
침하발생 반경내 관로정보
</h3>
<Button
type="button"
className="hover:bg-primary/90 mr-3 shrink-0 bg-primary text-primary-foreground"
onClick={handleDownload}
disabled={isDownloading}
aria-busy={isDownloading}
>
{isDownloading ? "다운로드 중..." : "엑셀다운로드"}
</Button>
</div>
{canShowNearby ? (
<div className="mx-3 mt-2 whitespace-nowrap text-left text-[0.95rem] leading-5 text-muted-foreground">
<span>반경 {radiusMeters}m · </span>
@@ -18,6 +18,7 @@ import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import useStore from "@/entities/reference-data/model/reference-data-store";
import LoadingOverlay from "@/components/ui/loading-bar";
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
import PopupSearchPanel, {
PopupSearchActions,
PopupSearchField,
@@ -514,6 +515,13 @@ export default function SinkholePopupPage() {
</Button>
}
/>
{/* 인수테스트 요구: 지반침하 정보의 연계 원천과 연계 주기를 목록 아래에 표시한다. */}
<PopupFooterNote
items={[
"출처 : 지하안전정보시스템 지반침하사고 정보 연계 표출(연계 주기 : 매 시간 연계)",
]}
/>
</StandardPopupLayout>
);
}
@@ -13,7 +13,19 @@ import type {
export type WaterRiskResultQuery = {
startDate: string;
endDate: string;
/** 현재 탭 분석기준시간(정시) 정확 일치 조건. 분석시각 기간과 함께 보내지 않는다. */
analysisBaseAt?: string;
/** 과거이력 분석시각 기간 시작(yyyy-MM-ddTHH:00:00, 포함). 종료와 함께 보낸다. */
analysisBaseFrom?: string;
/** 과거이력 분석시각 기간 종료(yyyy-MM-ddTHH:00:00, 포함). 시작과 함께 보낸다. */
analysisBaseTo?: string;
/**
* 과거이력 강우시각(강우 시작 일시) 기간 시작(yyyy-MM-ddTHH:00:00, 포함). 종료와 함께 보낸다.
* 보내면 백엔드가 startDate·endDate보다 이 기간을 우선한다.
*/
rainStartedFrom?: string;
/** 과거이력 강우시각(강우 시작 일시) 기간 종료(yyyy-MM-ddTHH:00:00, 포함). 시작과 함께 보낸다. */
rainStartedTo?: string;
ctpvCd?: string;
sggCd?: string;
areaId?: string;
@@ -54,6 +66,14 @@ export const fetchWaterRiskResults = async (
});
if (query.ctpvCd) params.set("ctpvCd", query.ctpvCd);
if (query.analysisBaseAt) params.set("analysisBaseAt", query.analysisBaseAt);
if (query.analysisBaseFrom && query.analysisBaseTo) {
params.set("analysisBaseFrom", query.analysisBaseFrom);
params.set("analysisBaseTo", query.analysisBaseTo);
}
if (query.rainStartedFrom && query.rainStartedTo) {
params.set("rainStartedFrom", query.rainStartedFrom);
params.set("rainStartedTo", query.rainStartedTo);
}
if (query.sggCd) params.set("sggCd", query.sggCd);
if (query.areaId) params.set("areaId", query.areaId);
if (query.riskStatus) params.set("riskStatus", query.riskStatus);
@@ -78,15 +98,12 @@ export const fetchWaterRiskResults = async (
export const fetchWaterRiskMapView = async (
row: WaterRiskResultRow,
): Promise<WaterRiskMapView> => {
if (!row.analysisDate || !row.ctpvCd || !row.sggCd) {
throw new Error("지도 조회에 필요한 분석일·행정구역 정보가 없습니다.");
// 날짜·지역이 아니라 누른 행의 SWMM 실행 ID로 조회해야 같은 날 같은 지역 결과가 여러 건이어도 같은 결과를 그린다.
if (!row.executionId) {
throw new Error("지도 조회에 필요한 SWMM 실행 ID가 없습니다.");
}
const params = new URLSearchParams({
ctpvCd: row.ctpvCd,
sggCd: row.sggCd,
});
const response = await getData(
`v1/waterRisk/results/${encodeURIComponent(row.analysisDate)}/areas/${encodeURIComponent(row.areaId)}/mapView?${params.toString()}`,
`v1/waterRisk/executions/${encodeURIComponent(row.executionId)}/mapView`,
);
return {
...response,
@@ -1,4 +1,4 @@
/** 백엔드의 양 끝 날짜 포함 최대 366일 계약을 캘린더와 조회 검증에서 함께 사용한다. */
/** 백엔드의 양 끝 날짜 포함 최대 366일 계약을 기본값과 조회 검증에서 함께 사용한다. */
export const WATER_RISK_MAX_SEARCH_DAYS = 366;
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -24,31 +24,82 @@ export const getWaterRiskMinStartDate = (
.slice(0, 10);
};
/** 달력 제한을 우회한 직접 입력도 API 요청 전에 검증한다. */
export const validateWaterRiskDateRange = (
startDate: string,
endDate: string,
today: string,
/**
* 시간 단위 검색 입력(`datetime-local`, `step=3600`)의 값 형식 `yyyy-MM-ddTHH:00`이다.
* 분은 항상 00이며, 첫 번째 그룹은 날짜(yyyy-MM-dd)다.
*/
const HOUR_VALUE_PATTERN = /^(\d{4}-\d{2}-\d{2})T\d{2}:00$/;
/**
* `datetime-local` 입력값의 분을 00으로 맞춘다.
* 브라우저는 값의 분이 00이면 분 칸을 고정하지만, 빈 값에서 처음 입력하거나 직접 입력해 분이 들어온 경우는
* 스스로 고치지 않으므로 저장 전에 시간 단위로 내린다. 형식이 아니면(빈 값 포함) 그대로 둔다.
*/
export const toWaterRiskHourValue = (value: string) =>
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value)
? `${value.slice(0, 13)}:00`
: value;
/** 로컬 시각을 시간 단위 입력값(yyyy-MM-ddTHH:00)으로 내린다. 기본 종료값과 미래 시각 제한에 쓴다. */
export const formatWaterRiskHourValue = (date: Date) => {
const pad = (value: number) => String(value).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:00`;
};
/** 시간 단위 입력값을 API 정시 일시(yyyy-MM-ddTHH:00:00)로 바꾼다. 빈 값은 빈 문자열이다. */
export const toWaterRiskApiDateTime = (value: string) =>
value ? `${value}:00` : "";
/**
* 시간 단위 기간을 백엔드 계약(시작·종료 함께, 정시, 시작 ≤ 종료, 날짜 기준 최대 366일)과
* 화면 조건(종료 ≤ 현재 정시)으로 검증한다. 오류가 없으면 빈 문자열을 반환한다.
* `required`가 아니면 시작·종료를 모두 비웠을 때 조건 없이 조회한다.
*/
const validateHourPeriod = (
from: string,
to: string,
currentHourValue: string,
{ label, required }: { label: string; required: boolean },
): string => {
if (!startDate || !endDate) return "조회 시작일과 종료일을 입력해야 합니다.";
const start = parseDate(startDate);
const end = parseDate(endDate);
if (start === null || end === null)
return "올바른 조회 날짜를 입력해 주세요.";
if (endDate > today) return "조회 종료일은 오늘보다 늦을 수 없습니다.";
if (start > end) return "조회 시작일은 종료일보다 늦을 수 없습니다.";
if (!from && !to) {
return required ? `${label} 시작과 종료를 입력해야 합니다.` : "";
}
if (!from || !to) {
return required
? `${label} 시작과 종료를 입력해야 합니다.`
: `${label}은 시작과 종료를 모두 입력하거나 모두 비워야 합니다.`;
}
const fromMatch = HOUR_VALUE_PATTERN.exec(from);
const toMatch = HOUR_VALUE_PATTERN.exec(to);
const start = fromMatch ? parseDate(fromMatch[1]!) : null;
const end = toMatch ? parseDate(toMatch[1]!) : null;
if (start === null || end === null) return `올바른 ${label}을 입력해 주세요.`;
if (to > currentHourValue) return `${label} 종료는 현재 시각보다 늦을 수 없습니다.`;
if (from > to) return `${label} 시작은 종료보다 늦을 수 없습니다.`;
if ((end - start) / DAY_MS + 1 > WATER_RISK_MAX_SEARCH_DAYS) {
return "강우 발생기간은 종료일 기준 최대 1년(366일)까지 조회할 수 있습니다.";
return `${label}은 최대 1년(366일)까지 조회할 수 있습니다.`;
}
return "";
};
/** 종료일 변경으로 기존 시작일이 허용 범위를 벗어난 경우 최근 1년 시작일로 맞춘다. */
export const adjustWaterRiskStartDate = (
startDate: string,
endDate: string,
): string => {
const min = getWaterRiskMinStartDate(endDate);
if (!min) return startDate;
return !startDate || startDate < min || startDate > endDate ? min : startDate;
};
/** 과거이력 강우시각(강우 시작 일시) 기간을 검증한다. 필수 조건이다. */
export const validateWaterRiskRainStartedPeriod = (
from: string,
to: string,
currentHourValue: string,
) =>
validateHourPeriod(from, to, currentHourValue, {
label: "강우시각",
required: true,
});
/** 과거이력 분석시각 기간을 검증한다. 선택 조건이며 모두 비우면 적용하지 않는다. */
export const validateWaterRiskAnalysisPeriod = (
from: string,
to: string,
currentHourValue: string,
) =>
validateHourPeriod(from, to, currentHourValue, {
label: "분석시각",
required: false,
});
@@ -12,6 +12,7 @@ import {
type WindowPopupContentProps,
} from "@/features/window-popup/model/types";
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
import {
StandardPopupLayout,
@@ -21,9 +22,12 @@ import {
import type { UiTableSort } from "@common/business_components/ui/table";
import { Button } from "@common/components";
import {
adjustWaterRiskStartDate,
formatWaterRiskHourValue,
getWaterRiskMinStartDate,
validateWaterRiskDateRange,
toWaterRiskApiDateTime,
toWaterRiskHourValue,
validateWaterRiskAnalysisPeriod,
validateWaterRiskRainStartedPeriod,
} from "./water-risk-date-range";
import {
@@ -34,25 +38,42 @@ import {
import {
buildWaterRiskStatusLabel,
EMPTY_WATER_RISK_SESSION_SNAPSHOT,
isWaterRiskDanger,
type WaterRiskAreaOption,
type WaterRiskResultPage,
type WaterRiskResultRow,
type WaterRiskSessionSnapshot,
type WaterRiskStatus,
} from "./water-risk-types";
import LoadingOverlay from "@/components/ui/loading-bar";
type WaterRiskResultMode = "current" | "history";
/**
* 과거이력 검색조건이다. 시각 값은 모두 시간 단위 입력값(yyyy-MM-ddTHH:00)이며
* 시작·종료 정시를 모두 포함해 조회한다. 현재 탭은 중점관리지역만 이 값을 쓴다.
*/
type ResultFilters = {
startDate: string;
endDate: string;
ctpvCd: string;
sggCd: string;
areaId: string;
riskStatus: "ALL" | "DANGER" | "NORMAL";
riskStatus: "ALL" | WaterRiskStatus;
/** 과거이력 분석시각 기간 시작. 비우면 분석시각 조건을 적용하지 않는다. */
analysisFrom: string;
/** 과거이력 분석시각 기간 종료. 시작과 함께 입력한다. */
analysisTo: string;
/** 과거이력 강우시각(강우 시작 일시) 기간 시작. 필수이며 기본은 최근 1년 시작일 00시다. */
rainStartedFrom: string;
/** 과거이력 강우시각(강우 시작 일시) 기간 종료. 필수이며 기본은 현재 정시다. */
rainStartedTo: string;
};
/** 과거이력 시각 범위 입력 키. 분석시각·강우시각 입력을 같은 모양으로 그릴 때 쓴다. */
type HourRangeKeys =
| { fromKey: "analysisFrom"; toKey: "analysisTo" }
| { fromKey: "rainStartedFrom"; toKey: "rainStartedTo" };
const WATER_RISK_PAGE_SIZE = 10;
const EMPTY_RESULT_PAGE: WaterRiskResultPage = {
items: [],
@@ -69,6 +90,8 @@ type WaterRiskTableRow = Record<string, unknown> & {
sidoName: string;
sggName: string;
areaName: string;
/** 대표실행 분석기준시각(excn.anls_crtr_dt)을 yyyy.MM.dd HH:mm으로 표시한 값 */
analysisBaseAt: string;
rainStartedAt: string;
currentAccumRainQty: string;
forecastRainQty: string;
@@ -81,53 +104,56 @@ const WATER_RISK_RESULT_HEADLIST: UiHeadProps[] = [
{
id: "rowNo",
title: "순번",
widthPercent: 6,
widthPercent: 5,
sortable: true,
sortKey: "rowNumber",
},
{
id: "sidoName",
title: "시도",
widthPercent: 8,
widthPercent: 7,
sortable: true,
sortKey: "ctpvName",
},
{ id: "sggName", title: "시군구", widthPercent: 8, sortable: true },
{ id: "areaName", title: "중점관리지역", widthPercent: 14, sortable: true },
{ id: "sggName", title: "시군구", widthPercent: 7, sortable: true },
{ id: "areaName", title: "중점관리지역", widthPercent: 12, sortable: true },
// 대표실행 분석기준시각. 컬럼 id가 백엔드 정렬 키(analysisBaseAt)와 같다.
{ id: "analysisBaseAt", title: "분석시각", widthPercent: 11, sortable: true },
{
id: "rainStartedAt",
title: "강우시작시간",
widthPercent: 13,
title: "강우시작시각",
widthPercent: 11,
sortable: true,
},
{
id: "currentAccumRainQty",
title: "누적강우량(mm)",
widthPercent: 11,
widthPercent: 9,
align: "right",
sortable: true,
},
{
id: "forecastRainQty",
title: "예측강우량(mm)",
widthPercent: 11,
widthPercent: 9,
align: "right",
sortable: true,
},
{
id: "maxFloodingAt",
title: "최대 침수 예상시점",
widthPercent: 13,
widthPercent: 11,
sortable: true,
},
{
id: "maxFloodingFlow",
title: "최대 침수유량(m³/s)",
widthPercent: 10,
widthPercent: 9,
align: "right",
sortable: true,
},
{ id: "riskStatus", title: "위험 여부", widthPercent: 7 },
// "위험(매핑관로없음)" 문구가 들어가도록 기존보다 넓게 둔다.
{ id: "riskStatus", title: "위험 여부", widthPercent: 9 },
];
const formatOptionalNumber = (
@@ -155,17 +181,20 @@ const formatAnalysisBaseAt = (value?: string | null) => {
return `${date.replaceAll("-", ".")} ${time.slice(0, 5)}`.trim();
};
/** 현재 위험 조회와 검색 초기화에 사용하는 최근 1년의 기본 조회조건이다. */
/** 최초 진입과 검색 초기화에 사용하는 기본 조회조건이다. 강우시각은 최근 1년이다. */
const buildDefaultResultFilters = (): ResultFilters => {
const endDate = new Date();
const endDateValue = formatLocalDate(endDate);
const now = new Date();
return {
startDate: getWaterRiskMinStartDate(endDateValue)!,
endDate: endDateValue,
ctpvCd: "ALL",
sggCd: "ALL",
areaId: "ALL",
riskStatus: "ALL",
// 분석시각 기간은 선택 조건이므로 기본·초기화 상태는 비워 둔다.
analysisFrom: "",
analysisTo: "",
// 강우시각은 필수 조건이다. 365일 전 00시 ~ 현재 정시(종료일 포함 최대 366일)를 기본으로 한다.
rainStartedFrom: `${getWaterRiskMinStartDate(formatLocalDate(now))!}T00:00`,
rainStartedTo: formatWaterRiskHourValue(now),
};
};
@@ -178,12 +207,16 @@ const postMessageToOpener = (
window.opener.postMessage({ type, payload }, window.location.origin);
};
/** 위험 여부에 맞는 기존 팝업 badge 스타일을 반환한다. */
const getRiskBadgeClassName = (riskStatus: "DANGER" | "NORMAL") =>
riskStatus === "DANGER" ? "sn-popup-badge-danger" : "sn-popup-badge-normal";
/** 위험 여부에 맞는 기존 팝업 badge 스타일을 반환한다. 위험(매핑관로없음)도 위험 배지를 쓴다. */
const getRiskBadgeClassName = (riskStatus: WaterRiskStatus) =>
isWaterRiskDanger(riskStatus) ? "sn-popup-badge-danger" : "sn-popup-badge-normal";
/** 동일 날짜·지역에 여러 강우이벤트가 있어도 실행별 행 키가 겹치지 않게 식별한다. */
/**
* 목록 행을 대표 SWMM 실행 ID로 식별한다. 행 하나가 실행 하나이므로 같은 날·지역 행도 겹치지 않는다.
* 실행 ID가 없는 응답(구 BE)에서는 기존 날짜·시각·지역 조합으로 대체한다.
*/
const buildResultRowKey = (row: WaterRiskResultRow) =>
row.executionId ||
[
row.analysisDate ?? "",
row.rainStartedAt ?? "",
@@ -195,10 +228,10 @@ const buildResultRowKey = (row: WaterRiskResultRow) =>
/** 실제 통수위험 API를 조회하고 선택 결과를 부모 지도에 전달하는 팝업이다. */
export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
// 최초 진입과 초기화 모두 오늘을 종료일로 하는 최근 1년을 사용한다.
// 최초 진입과 초기화 모두 현재 정시를 종료로 하는 최근 1년 강우시각을 사용한다.
const initialFilters = useMemo(() => buildDefaultResultFilters(), []);
// 과거분석은 오늘까지 선택 가능하며, 시작일의 범위는 선택 종료일에 따라 달라진다.
const today = formatLocalDate(new Date());
// 과거이력 시각 입력은 현재 정시까지 고를 수 있다. 렌더링마다 다시 계산해 정시가 바뀌면 따라간다.
const currentHourValue = formatWaterRiskHourValue(new Date());
const [resultMode, setResultMode] = useState<WaterRiskResultMode>("current");
const [draftResultFilters, setDraftResultFilters] =
useState<ResultFilters>(initialFilters);
@@ -241,15 +274,38 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
let active = true;
setIsLoading(true);
setErrorMessage("");
// 과거 이력의 고정 기본기간이 현재 진행 중인 강우 조회를 제한하지 않게 분리한다.
const dateFilters = resultMode === "current"
? buildDefaultResultFilters()
: appliedResultFilters;
const isHistory = resultMode === "history";
// 현재 탭은 진행 중 강우를 오늘까지 최근 1년 강우시작일로 찾고, 과거이력의 적용 기간이 이를 제한하지 않게 분리한다.
// 과거이력은 적용 시 검증을 통과한 강우시각 기간을 보내며, 강우시작일도 그 날짜로 맞춘다.
const requestToday = formatLocalDate(new Date());
const startDate = isHistory
? appliedResultFilters.rainStartedFrom.slice(0, 10)
: getWaterRiskMinStartDate(requestToday)!;
const endDate = isHistory
? appliedResultFilters.rainStartedTo.slice(0, 10)
: requestToday;
// 과거이력의 분석시각 기간은 적용 시 검증을 통과한 값이며, 비어 있으면 조건 없이 조회한다.
const analysisBaseFrom = isHistory
? toWaterRiskApiDateTime(appliedResultFilters.analysisFrom)
: "";
const analysisBaseTo = isHistory
? toWaterRiskApiDateTime(appliedResultFilters.analysisTo)
: "";
const rainStartedFrom = isHistory
? toWaterRiskApiDateTime(appliedResultFilters.rainStartedFrom)
: "";
const rainStartedTo = isHistory
? toWaterRiskApiDateTime(appliedResultFilters.rainStartedTo)
: "";
fetchWaterRiskResults({
startDate: dateFilters.startDate,
endDate: dateFilters.endDate,
startDate,
endDate,
analysisBaseAt:
resultMode === "current" ? currentAnalysisBaseAt : undefined,
analysisBaseFrom: analysisBaseFrom || undefined,
analysisBaseTo: analysisBaseTo || undefined,
rainStartedFrom: rainStartedFrom || undefined,
rainStartedTo: rainStartedTo || undefined,
ctpvCd:
resultMode === "current" || appliedResultFilters.ctpvCd === "ALL"
? undefined
@@ -313,9 +369,14 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
const nextSnapshot = (event.data.payload ??
EMPTY_WATER_RISK_SESSION_SNAPSHOT) as WaterRiskSessionSnapshot;
if (nextSnapshot.selectedAreaId) {
const matchingRow = resultPage.items.find(
(row) => row.areaId === nextSnapshot.selectedAreaId,
);
// 지도에 표시 중인 실행 ID가 있으면 그 행만 선택한다. 같은 지역 첫 행으로 바꾸면 다른 결과가 선택돼 보인다.
const matchingRow = nextSnapshot.selectedExecutionId
? resultPage.items.find(
(row) => row.executionId === nextSnapshot.selectedExecutionId,
)
: resultPage.items.find(
(row) => row.areaId === nextSnapshot.selectedAreaId,
);
if (matchingRow) setSelectedResultKey(buildResultRowKey(matchingRow));
}
};
@@ -418,6 +479,7 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
sidoName: row.sidoName || "-",
sggName: row.sggName || "-",
areaName: row.areaName,
analysisBaseAt: formatAnalysisBaseAt(row.analysisBaseAt),
rainStartedAt: formatAnalysisBaseAt(row.rainStartedAt),
currentAccumRainQty: formatOptionalNumber(row.currentAccumRainQty, 1),
forecastRainQty: formatOptionalNumber(row.forecastRainQty, 1),
@@ -432,11 +494,25 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
/** 검증된 검색조건을 적용하고 첫 페이지를 새로 조회한다. */
const handleApplyResultFilters = () => {
if (resultMode === "history") {
const dateError = validateWaterRiskDateRange(
draftResultFilters.startDate, draftResultFilters.endDate, today,
const nowHourValue = formatWaterRiskHourValue(new Date());
// 분석시각 기간은 선택 조건이다. 입력한 경우에만 시작·종료 짝, 순서, 기간, 현재 정시 이하를 확인한다.
const analysisPeriodError = validateWaterRiskAnalysisPeriod(
draftResultFilters.analysisFrom,
draftResultFilters.analysisTo,
nowHourValue,
);
if (dateError) {
setErrorMessage(dateError);
if (analysisPeriodError) {
setErrorMessage(analysisPeriodError);
return;
}
// 강우시각 기간은 필수 조건이다. 시작·종료 짝, 순서, 최대 366일, 현재 정시 이하를 확인한다.
const rainStartedPeriodError = validateWaterRiskRainStartedPeriod(
draftResultFilters.rainStartedFrom,
draftResultFilters.rainStartedTo,
nowHourValue,
);
if (rainStartedPeriodError) {
setErrorMessage(rainStartedPeriodError);
return;
}
}
@@ -496,6 +572,44 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
}
};
/**
* 침수예측현황과 같은 형태(라벨 + 시작 `~` 종료 `datetime-local`)로 과거이력 시각 범위를 그린다.
* `step=3600`이라 값의 분이 00이면 브라우저가 분 칸을 고정하고, 분이 들어온 경우는 저장 전에 00으로 내린다.
* 현재 정시 이후는 고를 수 없게 `max`만 둔다(`min`은 step 기준점을 바꾸므로 두지 않는다).
*/
const renderHourRange = (label: string, { fromKey, toKey }: HourRangeKeys) => (
<div className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
{label}
</div>
<input
type="datetime-local"
step={3600}
max={currentHourValue}
value={draftResultFilters[fromKey]}
onChange={(event) => {
const nextValue = toWaterRiskHourValue(event.target.value);
setDraftResultFilters((current) => ({ ...current, [fromKey]: nextValue }));
}}
aria-label={`${label} 시작`}
className="h-[3.2rem] w-[17rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
/>
<span className="text-xl font-medium text-label">~</span>
<input
type="datetime-local"
step={3600}
max={currentHourValue}
value={draftResultFilters[toKey]}
onChange={(event) => {
const nextValue = toWaterRiskHourValue(event.target.value);
setDraftResultFilters((current) => ({ ...current, [toKey]: nextValue }));
}}
aria-label={`${label} 종료`}
className="h-[3.2rem] w-[17rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
/>
</div>
);
return (
<StandardPopupLayout title={windowPopupDefinitions.flowRiskPopup.title}>
<section
@@ -529,45 +643,9 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
</div>
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
{/* 과거이력 검색조건 순서: 시도 → 시군구 → 중점관리지역 → 분석시각 → 강우시각 → 위험상태 */}
{resultMode === "history" ? (
<>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
강우 발생기간
</div>
<span className="flex shrink-0 items-center gap-2">
<input
type="date"
value={draftResultFilters.startDate}
min={getWaterRiskMinStartDate(draftResultFilters.endDate)}
max={draftResultFilters.endDate || today}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
startDate: event.target.value,
}))
}
aria-label="조회 시작일"
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
/>
<span className="text-xl font-medium text-label">~</span>
<input
type="date"
value={draftResultFilters.endDate}
max={today}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
startDate: adjustWaterRiskStartDate(current.startDate, event.target.value),
endDate: event.target.value,
}))
}
aria-label="조회 종료일"
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
/>
</span>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
시도
@@ -652,27 +730,39 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
</label>
{resultMode === "history" ? (
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
위험상태
</div>
<select
value={draftResultFilters.riskStatus}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
riskStatus: event.target
.value as ResultFilters["riskStatus"],
}))
}
aria-label="위험상태"
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
>
<option value="ALL">전체</option>
<option value="DANGER">위험</option>
<option value="NORMAL">정상</option>
</select>
</label>
<>
{/* 분석시각(선택)·강우시각(필수): 시간 단위로만 고르며 시작·종료 정시를 모두 포함해 조회한다. */}
{renderHourRange("분석시각", {
fromKey: "analysisFrom",
toKey: "analysisTo",
})}
{renderHourRange("강우시각", {
fromKey: "rainStartedFrom",
toKey: "rainStartedTo",
})}
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
위험상태
</div>
<select
value={draftResultFilters.riskStatus}
onChange={(event) =>
setDraftResultFilters((current) => ({
...current,
riskStatus: event.target
.value as ResultFilters["riskStatus"],
}))
}
aria-label="위험상태"
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
>
<option value="ALL">전체</option>
<option value="DANGER">위험</option>
<option value="DANGER_UNMAPPED">위험(매핑관로없음)</option>
<option value="NORMAL">정상</option>
</select>
</label>
</>
) : null}
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
@@ -722,25 +812,38 @@ export default function WaterRiskPopupPage(_props: WindowPopupContentProps) {
renderCell={(row, headId) =>
headId === "riskStatus" ? (
<span
className={`sn-popup-badge min-w-[5.6rem] ${getRiskBadgeClassName(row.riskStatus)}`}
className={`sn-popup-badge min-w-[5.6rem] whitespace-nowrap ${getRiskBadgeClassName(row.riskStatus)}`}
>
{buildWaterRiskStatusLabel(row.riskStatus)}
</span>
) : undefined
}
scrollMode="horizontal"
minTableWidth="124rem"
// 분석시각 컬럼 추가로 기존 124rem보다 넓힌다.
minTableWidth="136rem"
emptyMessage="조회된 결과가 없습니다."
sort={resultSort}
onSortChange={handleSortChange}
actions={
<span className="text-xl font-normal text-muted-foreground">
SWMM 시뮬레이션 기반 통수위험 분석 결과 (GIS 매핑 관로 중
Capacity 0.8 이상이 1개라도 있으면{" "}
<span className="font-bold text-[#ff1c15]">위험</span>, 없으면{" "}
<span className="font-bold text-[#1677ff]">정상</span>)
</span>
}
/>
{/*
인수테스트 요구: 판정 기준을 목록 머리글 오른쪽에서 침수예측현황과 같은 하단 위치로 옮기고,
분석 원천·실행 주기·침수 항목 설명을 글머리표로 함께 표시한다.
*/}
<PopupFooterNote
items={[
<>
분석 결과 관로의 Capacity(통수능) 값이 0.8 이상이 1개라도 있으면{" "}
<span className="font-bold text-[#ff1c15]">위험</span>, 매핑되지
않은 관로만 0.8 이상이면{" "}
<span className="font-bold text-[#ff1c15]">위험(매핑관로없음)</span>,
없으면 <span className="font-bold text-[#1677ff]">정상</span>
</>,
"통수능이 위험에 해당하지만 지도화면에 해당 관로를 매핑할 수 없을 때, 빨간색 관로는 보이지 않을 수 있습니다.",
"분석 실행 시기 : 통수위험은 관측강우가 0mm이상으로 강우가 발생시 1시간 주기로 분석 자동실행",
"분석 방법 : 기상청 향후 6시간 예측강우를 20개소 중점관리지역의 관망모델(SWMM모델)에 연동하여 관로 통수량을 분석하여 통수위험여부를 분석",
"최대 침수예상시점과 최대침수유량은 맨홀 월류량이 있을때 그 시점과 값이 나타나는것입니다.",
]}
/>
</section>
</StandardPopupLayout>
@@ -1,7 +1,13 @@
/** 통수위험 분석 화면에서 사용하는 타입·상수와 표시값 변환을 정의한다. */
"use client";
export type WaterRiskStatus = "DANGER" | "NORMAL";
/**
* 백엔드 위험상태 코드이다.
* DANGER: 유효 GIS 매핑 관로 중 Capacity 기준 이상 관로가 있음.
* DANGER_UNMAPPED: 매핑 위험 관로는 없고, 유효 GIS 매핑이 없는 관로만 기준 이상임.
* NORMAL: 기준 이상 관로가 없음.
*/
export type WaterRiskStatus = "DANGER" | "DANGER_UNMAPPED" | "NORMAL";
export type WaterRiskCalculationMethod = "SWMM";
/** 대표실행에서 capacity가 가장 큰 관로의 실제 SWMM 결과이다. */
@@ -14,6 +20,9 @@ export type WaterRiskPipeSummary = {
/** 강우이벤트 대표실행 한 건을 팝업 목록에 표시하는 모델이다. */
export type WaterRiskResultRow = {
/** 대표 SWMM 실행 ID(UUID). 목록 행 식별과 지도 조회(executions/{executionId}/mapView) 키다. */
executionId: string;
/** 강우시작일(yyyy-MM-dd). 분석시각(analysisBaseAt)과 다른 값이다. */
analysisDate?: string;
analysisBaseAt?: string | null;
rainStartedAt?: string | null;
@@ -36,14 +45,25 @@ export type WaterRiskResultRow = {
/** 부모 지도와 별도 팝업 사이에서 공유하는 최소 선택 상태이다. */
export type WaterRiskSessionSnapshot = {
selectedAreaId: string | null;
/** 지도에 표시 중인 SWMM 실행 ID. 같은 지역 행이 여러 개여도 팝업이 정확한 행을 다시 선택하게 한다. */
selectedExecutionId: string | null;
};
export const EMPTY_WATER_RISK_SESSION_SNAPSHOT: WaterRiskSessionSnapshot = {
selectedAreaId: null,
selectedExecutionId: null,
};
export const buildWaterRiskStatusLabel = (status: WaterRiskStatus) =>
status === "DANGER" ? "위험" : "정상";
/** 위험상태 코드를 목록 배지·지도 라벨·정보카드에 쓰는 화면 문구로 바꾼다. */
export const buildWaterRiskStatusLabel = (status: WaterRiskStatus) => {
if (status === "DANGER") return "위험";
if (status === "DANGER_UNMAPPED") return "위험(매핑관로없음)";
return "정상";
};
/** 배지·지도 색을 위험 계열(빨강)로 표시할 상태인지 판별한다. 매핑관로없음도 위험 색을 쓴다. */
export const isWaterRiskDanger = (status: WaterRiskStatus | null | undefined) =>
status === "DANGER" || status === "DANGER_UNMAPPED";
export type WaterRiskAreaOption = {
areaId: string;
@@ -70,7 +90,14 @@ export type WaterRiskGeoJsonGeometry = {
/** 선택한 대표실행의 실제 중점관리지역 및 관로 지도 결과이다. */
export type WaterRiskMapView = {
/** 지도에 표시한 SWMM 실행 ID. 요청한 목록 행의 executionId와 같다. */
executionId?: string;
/** 강우시작일(yyyy-MM-dd) */
analysisDate: string;
/** 분석기준시각 */
analysisBaseAt?: string | null;
/** 강우시작시각 */
rainStartedAt?: string | null;
areaId: string;
areaName: string;
ctpvCd: string;
@@ -104,7 +104,7 @@ export const getRightPanelFeatureDisplayLabel = (
? "관로조사"
: jobType === "MNHL"
? "맨홀조사"
: "조사관리";
: "조사사업";
return `${surveyLabel}(${managementNo})`;
}
@@ -101,7 +101,8 @@ export function useWaterRiskLayer({
[selectedWaterRiskApiResult],
);
// 지도카드는 실제 GIS geometry까지 결합된 mapView 위험상태를 목록 상태보다 우선한다.
// 지도카드는 mapView 위험상태를 우선하고, mapView 응답 전에는 목록 상태를 쓴다.
// 2026-10-20부터 mapView 위험상태는 목록과 같은 대표결과 판정값이므로 두 값이 다르지 않다.
const selectedWaterRiskDisplayStatus = useMemo<
WaterRiskResultRow["riskStatus"]
>(
@@ -127,7 +128,7 @@ export function useWaterRiskLayer({
const [pixelX = 0, pixelY = 0] =
map.getPixelFromCoordinate?.(waterRiskInfoAnchor) ?? [];
const cardWidth = 244;
const cardHeight = 180;
const cardHeight = 190;
const left = pixelX + 22;
const top = pixelY - cardHeight / 2;
@@ -148,6 +149,12 @@ export function useWaterRiskLayer({
setWaterRiskInfoCardStyle({ left, top });
}, [waterRiskInfoAnchor]);
/** 정보 카드만 닫고 지도 결과·범례는 유지하며, 다음 결과 선택 시 다시 표시한다. */
const dismissWaterRiskInfoCard = useCallback(() => {
setWaterRiskInfoAnchor(null);
setWaterRiskInfoCardStyle(null);
}, []);
const clearWaterRiskPopupCloseWatcher = useCallback(() => {
if (waterRiskPopupCloseWatcherRef.current !== null) {
window.clearInterval(waterRiskPopupCloseWatcherRef.current);
@@ -413,11 +420,20 @@ export function useWaterRiskLayer({
| undefined;
const mapView = data.payload?.mapView as WaterRiskMapView | undefined;
if (!resultRow || !mapView || mapView.areaId !== areaId) return;
// 지도 응답이 누른 행과 다른 실행이면 표시하지 않는다(실행 ID 기준 조회의 안전장치).
if (
mapView.executionId &&
resultRow.executionId &&
mapView.executionId !== resultRow.executionId
) {
return;
}
setSelectedWaterRiskApiResult(resultRow);
setSelectedWaterRiskMapView(mapView);
setWaterRiskSession((current) => ({
...current,
selectedAreaId: areaId,
selectedExecutionId: resultRow.executionId || null,
}));
onSelectResult();
return;
@@ -470,6 +486,7 @@ export function useWaterRiskLayer({
waterRiskInfoCardStyle,
selectedWaterRiskMapView,
selectedWaterRiskDisplayStatus,
dismissWaterRiskInfoCard,
watchPopup,
};
}
@@ -4,19 +4,21 @@ import CircleStyle from "ol/style/Circle.js";
import Stroke from "ol/style/Stroke.js";
import Style from "ol/style/Style.js";
import Text from "ol/style/Text.js";
import { buildWaterRiskStatusLabel } from "@/app/(map-layout)/map/components/water-risk/water-risk-types";
import {
buildWaterRiskStatusLabel,
isWaterRiskDanger,
type WaterRiskStatus,
} from "@/app/(map-layout)/map/components/water-risk/water-risk-types";
export const buildWaterRiskStatusFillColor = (
riskStatus: "DANGER" | "NORMAL",
) =>
riskStatus === "DANGER"
/** 위험·위험(매핑관로없음)은 빨강, 정상은 파랑 반투명으로 중점관리지역 면을 채운다(화면설계서 21쪽). */
export const buildWaterRiskStatusFillColor = (riskStatus: WaterRiskStatus) =>
isWaterRiskDanger(riskStatus)
? "rgba(198, 40, 40, 0.24)"
: "rgba(21, 101, 192, 0.2)";
export const buildWaterRiskStatusStrokeColor = (
riskStatus: "DANGER" | "NORMAL",
) =>
riskStatus === "DANGER"
/** 중점관리지역 경계선·라벨 배경색. 위험 계열 판정은 면 색과 같다. */
export const buildWaterRiskStatusStrokeColor = (riskStatus: WaterRiskStatus) =>
isWaterRiskDanger(riskStatus)
? "rgba(198, 40, 40, 0.92)"
: "rgba(21, 101, 192, 0.92)";
@@ -31,9 +33,9 @@ export const buildWaterRiskCapacityColor = (capacityRatio: number) => {
export const createWaterRiskAreaStyle = (feature: any) => {
const areaName = String(feature.get("areaName") ?? "");
const riskStatus = String(feature.get("riskStatus") ?? "NORMAL") as
| "DANGER"
| "NORMAL";
const riskStatus = String(
feature.get("riskStatus") ?? "NORMAL",
) as WaterRiskStatus;
return new Style({
fill: new Fill({ color: buildWaterRiskStatusFillColor(riskStatus) }),
stroke: new Stroke({
@@ -25,6 +25,8 @@ export const EXMN_PROC_SEARCH_EVENT = "SN_INFTWT_PROC_SEARCH";
// 지반침하 지도 포인트와 같은 파란 계열의 기존 레이어 트리 심볼 자산을 재사용한다.
export const SINKHOLE_TREE_ICON_NAME = "SN_SINKHOLE_SPOT_BLUE.png";
// EPSG:5179의 남한·도서 주변 여유 범위 [minX, minY, maxX, maxY].
// 화면 비율 때문에 전체 범위가 잘리지 않도록 Gis의 viewShowFullExtent와 함께 사용한다.
export const SOUTH_KOREA_VIEW_EXTENT: [number, number, number, number] = [
600000, 1300000, 1500000, 2500000,
];
@@ -1,6 +1,6 @@
"use client";
/** 지도 route에만 적용하는 기존 전역 컨트롤 CSS. 선택자와 스타일 값은 유지한다. */
/** 지도 route의 기존 배치를 유지하고 SN 컨트롤의 쌓임 순서를 맞춘다. */
export default function MapControlStyles() {
return (
<style jsx global>{`
@@ -8,6 +8,7 @@ export default function MapControlStyles() {
top: 16px;
right: 120px;
bottom: auto;
z-index: 45;
}
.sn-map-control-layout .map-type-tabs,
@@ -28,6 +29,7 @@ export default function MapControlStyles() {
top: 16px;
right: 16px;
bottom: auto;
z-index: 45;
}
.sn-map-control-layout .tool-bar-vector-icons ul li div {
@@ -63,7 +63,7 @@ import {
normalizeRainObservationLayerInteraction,
getFlowDirectionMinZoom,
} from "@/features/map-layers";
import { ListTree } from "lucide-react";
import { ListTree, X } from "lucide-react";
import {
Fragment,
useCallback,
@@ -182,7 +182,12 @@ import {
WATER_DEPTH_LAYER_TYPES,
extractWaterDepthLegendItems,
} from "../water-depth/water-depth";
import { buildWaterRiskStatusLabel } from "../components/water-risk/water-risk-types";
import {
buildWaterRiskStatusLabel,
isWaterRiskDanger,
} from "../components/water-risk/water-risk-types";
import GisToolButtons from "../components/gis-tools/gis-tool-buttons";
const AnalysisPopupMenu = dynamic(
() => import("../components/analysis-menu/analysis-popup-menu"),
@@ -296,6 +301,7 @@ const MapPageClient = () => {
waterRiskInfoCardStyle,
selectedWaterRiskMapView,
selectedWaterRiskDisplayStatus,
dismissWaterRiskInfoCard,
watchPopup: watchWaterRiskPopup,
} = useWaterRiskLayer({
gisMaps,
@@ -3126,26 +3132,38 @@ const MapPageClient = () => {
// 위험/정상 배지는 통수위험 상태를 즉시 구분하는 업무 의미색이므로 고정색을 유지한다.
<div
style={waterRiskInfoCardStyle}
className="absolute z-[61] w-[244px] overflow-hidden rounded-[12px] border border-border bg-background shadow-blur"
className="absolute z-[44] w-[244px] overflow-hidden rounded-[12px] border border-border bg-background shadow-blur"
>
<div className="flex items-center justify-between bg-secondary px-[9px] py-[7px]">
<div>
<div className="bg-secondary px-[9px] py-[7px]">
<div className="flex items-center justify-between">
<div className="text-[10px] font-semibold text-muted-foreground">
중점관리지역
</div>
<div className="mt-[2px] text-[13px] font-bold text-foreground">
<button
type="button"
className="flex h-[20px] w-[20px] items-center justify-center rounded-[3px] text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
onClick={dismissWaterRiskInfoCard}
aria-label="통수위험 정보 카드 닫기"
title="닫기"
>
<X className="h-[14px] w-[14px]" aria-hidden="true" />
</button>
</div>
<div className="mt-[2px] flex items-center justify-between gap-2">
<div className="min-w-0 text-[13px] font-bold text-foreground">
{selectedWaterRiskResultRow.areaName}
</div>
<span
className={`inline-flex min-w-[48px] shrink-0 items-center justify-center rounded-full px-[7px] py-[5px] text-[10px] font-bold ${
// 위험(매핑관로없음)도 위험과 같은 빨간 배지로 표시하고 문구로 구분한다.
isWaterRiskDanger(selectedWaterRiskDisplayStatus)
? "bg-[#ff1c15] text-white"
: "bg-[#e8f6f4] text-[#0f766e]"
}`}
>
{buildWaterRiskStatusLabel(selectedWaterRiskDisplayStatus)}
</span>
</div>
<span
className={`inline-flex min-w-[48px] items-center justify-center rounded-full px-[7px] py-[5px] text-[10px] font-bold ${
selectedWaterRiskDisplayStatus === "DANGER"
? "bg-[#ff1c15] text-white"
: "bg-[#e8f6f4] text-[#0f766e]"
}`}
>
{buildWaterRiskStatusLabel(selectedWaterRiskDisplayStatus)}
</span>
</div>
<div className="bg-background px-[9px] py-[6px] text-[9px] text-muted-foreground">
<div>GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면</div>
@@ -3197,7 +3215,7 @@ const MapPageClient = () => {
) : null}
{selectedWaterRiskMapView ? (
// Capacity와 Flooding 색상은 SWMM 결과의 업무 의미색이므로 테마와 무관하게 유지한다.
<div className="absolute bottom-4 left-[270px] z-[61] rounded-md border border-border bg-background px-3 py-2 text-[10px] text-foreground shadow-md">
<div className="absolute bottom-4 left-[270px] z-[44] rounded-md border border-border bg-background px-3 py-2 text-[10px] text-foreground shadow-md">
<div className="mb-1 font-bold">Capacity</div>
<div className="space-y-1 font-semibold">
{[
@@ -3236,10 +3254,13 @@ const MapPageClient = () => {
initialActiveKey={initialAnalysisPopupKey}
onOpenPopup={openAnalysisPopup}
onOpenInfoSearch={() => setIsSearchModalOpen(true)}
onToggleGisEdit={handleToggleGisEdit}
isGisEditActive={gisEdit.isEditMode}
isInfoSearchActive={isSearchModalOpen}
/>
<GisToolButtons
isGisEditActive={gisEdit.isEditMode}
onToggleGisEdit={handleToggleGisEdit}
onOpenGisManagement={() => openAnalysisPopup("gisManagementPopup")}
/>
{isGisEditSessionInlineVisible ? (
<div
data-testid="gis-edit-session-inline"
@@ -3335,6 +3356,7 @@ const MapPageClient = () => {
defaultZoom={7.5}
defaultLonLat={[129, 36.3]}
viewExtent={SOUTH_KOREA_VIEW_EXTENT}
viewShowFullExtent={true}
/>
{isAgedPipeListMinimized && (
<div className="fixed left-[480px] top-[68px] z-[90] flex min-w-[250px] items-center gap-3 rounded-sm border border-red-200 bg-background px-3 py-2 shadow-[0_8px_22px_rgba(15,23,42,0.22)] backdrop-blur">
@@ -10,6 +10,14 @@
- 확인됨: 공통 컴포넌트 수정은 `sn`에 실제로 필요한 범위로 제한한다. 근거: `apps/sn/AGENTS.md`
- 추정: 여러 화면에서 반복되는 `sn` 전용 로직은 먼저 `apps/sn` 내부 공통화로 해결하고, 여러 업무 화면에서 공유되는 비즈니스 UI일 때만 `business_components`를 검토하는 것이 안전하다. 근거: `apps/sn/AGENTS.md`
## 공통 유틸 재사용 기준
- `sn` 프론트엔드의 엑셀 다운로드 기능을 신규 개발하거나 수정할 때는 `apps/sn/src/shared/util/download-excel-workbook.ts`의 재사용을 먼저 검토한다. 근거: 사용자 지시(2026-10-22).
- 업무별 데이터 조회·가공·한글 항목 매핑은 각 기능에서 담당하고, 워크북 생성·시트 구성·파일 저장은 공통 유틸을 우선 사용한다. 시트 생성은 `buildExcelWorkbook`, 브라우저 다운로드는 `downloadExcelWorkbook`을 검토한다.
- 기존 다운로드 기능을 관련 없는 작업에서 일괄 전환하지 않는다. 변경 대상 기능의 요구사항과 기존 동작을 확인한 뒤 적용한다.
- 공통 유틸로 요구사항을 충족하기 어려우면 그 사유와 대안을 작업계획에 기록한다. 특수 서식이나 기존 템플릿 등 요구사항에 맞춰 적용 여부를 판단한다.
- 다운로드 대상 데이터의 범위·전체 건수·API 제한과 대량 자료의 서버 생성 기준은 별도로 확인한다. 공통 유틸 사용만으로 데이터 수집 범위가 보장되지는 않는다.
## 작업카드에 남길 항목
- 확인 필요: 신규 또는 수정 컴포넌트 경로
- 확인 필요: 호출하는 화면 경로
@@ -19,4 +27,4 @@
- 확인 필요: 브라우저 확인 화면
## 외부 범위 이슈
- 확인됨: 권한 밖 공통 패키지 수정이 필요해 보이면 직접 고치지 않고 문제 원인, 수정 필요 경로, `sn` 영향 범위, 타 앱 영향 가능성을 문서화한다. 근거: `apps/sn/AGENTS.md`
- 확인됨: 권한 밖 공통 패키지 수정이 필요해 보이면 직접 고치지 않고 문제 원인, 수정 필요 경로, `sn` 영향 범위, 타 앱 영향 가능성을 문서화한다. 근거: `apps/sn/AGENTS.md`
@@ -275,3 +275,8 @@
- 레이어 API 단위 테스트는 실제 서비스가 호출하는 Repository 메서드와 동일한 메서드를 모킹해야 한다.
- 전체 빌드가 장시간 걸리는 환경에서는 변경 파일 정적 검사 후 백엔드 대상 테스트와 로그인 브라우저 회귀를 분리한다.
## 2026-10-19 맨홀 PDF GIS 번호 검증
- 188001 테스트 PDF 실제 판독·쪽 추출, PPL/MNHL 첨부 분리, 미첨부·없는 번호·중복 시설·다중/비연속 번호를 검증한다.
- 실제 서버에서는 맨홀 첨부 저장 → 상류맨홀 지도 선택 → 맨홀 보고서 1쪽 표시와 관로 회귀를 확인한다. 자동검증/실브라우저 여부는 통합검증결과에 구분한다.
@@ -678,3 +678,44 @@
- 검증: SN 전체 타입검사 exit0, 숫자/소수/삭제 및 거부 문자열 Node 검증 통과, git diff --check 통과. 실브라우저 입력 검증은 미수행.
- 수정 소스: `frontend/apps/sn/src/features/exmn/ui/exmn-business-register-popup-page.tsx`. API/백엔드 코드·저장 계약 변경 없음. 기존 GIS 작업은 변경하지 않았다.
- 롤백: 해당 소스의 이번 필수 표시/입력 제한만 복원한다.
## 2026-10-19 맨홀 조사보고서 GIS 번호 연결 (maintenance)
- 목적: 별도 맨홀 식별체계 확정 전 맨홀 PDF의 `맨홀번호`에 조사시설 GIS 번호(테스트 188001)를 넣고 지도에서 해당 보고서를 열람한다. 지도 도형은 기존 상류맨홀 매칭을 유지한다.
- 근거: 요구사항정의서 `3. (기능)요구사항정의 및 추적표` SFR-010-006/007, 화면설계서 실제 42쪽의 식별기준 미확정 메모 및 43쪽 `SN_MN040101` 조사결과 등록. 이번 사용자 지시로 임시 GIS 번호 연결 기준을 확정했다.
- BE: `ExmnManholeReportService.java` 신규, `ExmnPipePdfValidator.java` 표제별 불변 판독기, `ExmnBusinessController.java`의 `GET /api/v1/exmnBusinesses/{exmnBizId}/manholeReports/{pipeNumber}` 및 `/availability`, `ExmnFacilityRepository.java`의 조사유형별 첨부 존재 여부 분기.
- 맨홀 첨부 `mnhlExmnAtchFileId`와 활성 MNHL의 `gisManagementNo`만 사용한다. 선택 번호가 없거나 활성 시설이 중복되면 임의 보고서를 반환하지 않는다. 매 요청 현재 PDF를 판독하므로 이전 관로 쪽 캐시를 사용하지 않는다. DB 스키마 및 업무 데이터 변경 없음.
- FE: `exmn-business-api.ts`, `exmn-survey-result-section.tsx`, `exmn-pipe-report-popup-content.tsx`, `exmn-result-register-popup-page.tsx`. MNHL에서도 보고서 버튼을 표시하고 jobType에 따라 API·제목·번호 안내를 선택한다. PPL 기본 호출은 유지한다.
- 업로드 검토의 PDF 내부 번호 대조는 이번 범위에 추가하지 않았다. 번호 입력 기준과 열람 시 대조한다는 안내를 결과등록 화면에 표시한다.
- 산출물: 작업루트 `output/pdf/맨홀_현황조사_보고서_188001.pdf`. 사용자 원본 1쪽의 맨홀번호만 188001로 변경했으며 보고서번호·노선명 등 예시값과 사진은 원본을 유지했다. 실제 납품 조사자료가 아닌 기능 테스트용이다.
- 검증: BE compileJava --offline 성공, SN 전체 typecheck 성공, 양 저장소 diff --check 성공. 임시 JUnit 5건 성공(실제 PDF 번호/쪽 추출, PPL 표제 분리, 다중/비연속 번호 거부·연속 쪽, MNHL 첨부 선택/DB 캐시 미사용/미첨부, 없는 번호/중복 시설). PDF 재열람·텍스트 및 렌더링 육안 검증 완료.
- 미검증: 로컬 3002/8883 실행 포트가 없으므로 실제 서버 업로드·저장·지도 선택·브라우저 팝업은 수행하지 않았다. 188001 MNHL 조사시설 및 상류맨홀 GIS 도형의 실제 존재를 확인한 것으로 간주하지 않는다.
- 수동 절차: 변경 BE/FE 실행 → 188001을 가진 MNHL 조사자료의 사업에서 결과등록 → CCTV 조사결과(맨홀)에 테스트 PDF 등록·저장 → 맨홀조사 레이어의 해당 상류맨홀 선택 → 조사보고서 보기 → 제목 맨홀 조사보고서/GIS 번호188001/1쪽 확인. 관로 보고서와 미첨부·없는 번호도 회귀 확인한다.
- 롤백: 위 소스의 이번 변경과 신규 ExmnManholeReportService를 함께 복원한다. DB 롤백은 없다. 실제 첨부 등록을 수행한 경우에는 별도로 이전 첨부를 복원한다.
- 남은 리스크: 번호는 확정 맨홀번호가 아닌 조사시설 GIS 번호의 임시 계약이며, 스캔 PDF는 판독 불가하다. 맨홀 PDF는 availability/열람 때 각각 판독하므로 대형 파일 성능은 후속 검증 대상이다. 테스트 소스·원시 결과·렌더링 임시 이미지는 요약 후 정리하고 최종 PDF는 보존한다.
## 2026-10-19 후속: 기존 사업 맨홀 PDF 단독 교체
- 원인: 보고서 열람을 추가했지만 결과등록 화면은 모든 검토에 Excel을 요구하고 저장에 Excel·PPL 검토 성공을 요구했다.
- 변경: 맨홀 PDF 하나만 선택하면 기존 활성 MNHL 조사시설의 GIS 번호로 PDF 번호를 대조한다. 기존 조사자료 없음·불일치·중복·잘못된 PDF는 차단한다. 선택 PDF의 일부 시설 포함은 허용한다.
- 저장 직전 다시 검토한 뒤 선택한 MNHL 첨부만 기존 attachments API로 연결한다. Excel imports/commit과 양식 생성 확인은 호출하지 않아 기존 시설·집계·관로첨부를 유지한다. Excel을 선택한 신규/전체 결과 저장 흐름은 유지한다.
- 수정: BE ExmnBusinessController(선택 mnhlFile 입력 및 Excel 없는 MNHL 검토 분기), ExmnManholeReportService(validateReplacement), FE exmn-result-register-popup-page(검토·저장 조건과 검토안내).
- 검증: BE compileJava, SN typecheck, 임시 JUnit 4건(기존번호 정상·무쓰기, 기존결과 없음, 번호 불일치/중복, 누락/손상 PDF) 통과. 원본 요구사항·지도 기준은 앞선 절과 동일하다. DB 스키마 변경 없음.
- 롤백: 이번 단독 교체 분기만 복원한다. 앞선 맨홀 보고서 열람 구현은 유지할 수 있다.
- 실제 브라우저 확인: 기존 테스트 사업 `6fe818cb-0a7a-4379-969c-150150155f91`에서 Excel/PPL 미선택·188001 맨홀 PDF만 선택 → 검토 완료 → 저장 → 부모 사업목록 성공 안내 확인. 등록한 맨홀 PDF를 보고서 팝업에서 GIS 번호188001/1쪽으로 실제 표시했다. 지도 클릭 경로 자체는 이번 후속에서 재실행하지 않았다.
- 검토 중 기존8883 서버의 닫힌 Spring 컨텍스트로 일반 API도500 발생. 사용자 명시 승인 후 동일 실행인수·local 프로필·포트8883·context /sewerage-service로 재시작하여 정상화했다. 교체 서버 PID24204는 사용자가 이어서 테스트하도록 유지한다. 종료명령: Stop-Process -Id 24204. 실행중 필요한 classpath 사본과 stdout/stderr는 작업루트 .codex-tmp/manhole-backend-classpath.jar 및 manhole-backend.out.log/err.log에 유지한다(테스트용 임시 서버가 아닌 승인된 사용자 서버 교체).
- 부모 성공문구도 첨부 교체에 맞게 `조사결과 저장을 완료했습니다.`로 보정했다(exmn-registration-page.tsx). 실제 첨부는 변경했으며 기존 시설·집계는 Excel commit을 호출하지 않아 유지한다. 되돌릴 경우 이전 맨홀 첨부를 재등록한다.
## 2026-10-19 최종 정정: 실제 GIS 맨홀번호와 선택 사업 기준
- 사용자 확정: PDF 맨홀번호는 조사자료 관로번호가 아닌 실제 GIS 맨홀번호이다. 앞선 gisManagementNo 기준 설명·구현은 이 절로 대체한다.
- 원인: 지도보기 진입은 테스트 사업이었지만 전체 사업 레이어의 겹친 시설이 선택됐다. 실제 화면은 오류 확인 테스트_수정/조사관로188003/시설관리번호188001이었다. 사용자 선택 실수가 아닌 지도 범위와 번호 계약 불일치이다.
- FE use-exmn-result-map.ts: 조사관리에서 사업·처리분구를 지정해 진입하면 해당 범위만 표출·선택한다. 일반 지도 진입은 기존 전체 범위를 유지한다. 기존 2026-10-01 전체표출 정책의 조사관리 진입 부분을 이 결정으로 대체한다.
- FE exmn-survey-result-section.tsx: MNHL 보고서 키는 fclt_mng_no/fcltMngNo, PPL은 기존 gisManagementNo. 조사 결과 표에 GIS 번호를 명시한다. 결과등록 안내도 실제 GIS 맨홀번호로 정정했다.
- BE ExmnManholeReportService: 기존 ExmnMapLayerService의 동일 지역·상류맨홀 매칭 결과 fclt_mng_no 집합으로 업로드 검토·열람 대상을 확인한다. 조사 관로번호를 맨홀번호로 대체하지 않는다. 같은 실제 맨홀에 여러 조사관로가 연결되면 번호 집합으로 처리한다. 스키마·조사시설·원천 GIS 변경 없음.
- 검증: 실제 GIS188001/조사관로188003 분리, PDF 검토·열람 성공, 조사관로 번호 직접 요청 거부 및 GIS 미연결 거부를 묶은 JUnit1건 통과. BE 컴파일·FE 타입검사·diff --check 통과. 임시 테스트 소스와 결과 정리 완료.
- 실브라우저: 테스트 사업→초이처리분구→지도3개 표시→실제188001 클릭→사업명 테스트/GIS 맨홀번호188001 확인→조사보고서 보기→1쪽 PDF와188001 표시 성공. 사용자도 최종 정상동작 확인했다.
- 실행 서버: 기존 재시작 승인 범위에서 코드 반영 후8883 PID27788로 유지한다(이전PID24204 종료). classpath/실행로그는 기존 .codex-tmp/manhole-backend-* 경로 유지. 종료명령 Stop-Process -Id 27788.
- 롤백: 위 FE3개 소스와 ExmnManholeReportService의 이번 변경을 함께 복원한다. DB 롤백 없음. 잔여 리스크는 GIS 미연결 자료 열람 불가 및 대형 사업의 지도 매칭·PDF 판독 비용이며, 임의 위치/다른 사업 fallback을 하지 않는다.
@@ -15,3 +15,14 @@
- 순번은 전체 건수 기준 역순으로 계산해 로컬 3002에서 1페이지 18~9, 2페이지 8~1 표출을 확인했다.
- 원본 근거는 요구사항정의서 `SFR-011-001`, 화면설계서 8~9번 `SN_MN010102`이며 순번·제목 건수는 원본 필수항목이 아닌 공통 그리드 표시 규칙이다.
- API, 조회조건, 연도 그룹 헤더와 Excel 다운로드 형식은 변경하지 않았다.
## 4. 2026-10-08 기본 검색기간 단년 변경
- 사용자 요청으로 디지털하수관로의 시도별 노후관로현황 기본 검색기간을 최근 3년에서 당해연도 1년으로 변경했다. 최초 진입·초기화·잘못된 연도값의 기본 처리가 동일한 정규화 함수를 사용한다.
- 원본 근거: 요구사항정의서 `3. (기능)요구사항정의 및 추적표` 87행 `SFR-011-001`, 화면설계서 슬라이드 9 `SN_MN010102`. 화면설계서의 시작·종료 연도 예시는 모두 2026년이다.
- 수정: `aged-pipe-status-by-ctpv-utils.ts`의 기본 시작연도와 비정상 시작연도 fallback만 당해연도로 변경. 사용자가 선택한 최대 3년 범위, 경과년도 기본 30년, API 계약·DB·지도 기능·Excel 적용조건 처리는 유지한다.
- 로컬 3002 실제 브라우저: 최초 진입 `2026~2026` → 2026년 단일 그룹/10행, 직접 `2024~2026` 선택 후 조회 → 3개 연도 그룹, 초기화 → `2026~2026` 복귀 확인. 해당 API 요청 모두 HTTP 200.
- 기본값·초기화 입력·비정상 연도·명시적 3년 유지·4년 제한 등 6개 확인과 `git diff --check` 통과. 사용자 개발 서버가 실행 중이므로 빌드/서버 재시작은 하지 않았다. 일회성 테스트 파일은 생성하지 않았다.
- 시도 집계 목록의 기존 클라이언트 페이징 예외(18개 고정 집계)는 변경하지 않았다. 지도 호출 영향 없음. Excel은 동일한 마지막 적용조건을 사용하는 기존 코드 유지, 실제 다운로드 재검증은 이번 범위에서 수행하지 않았다.
- 배포는 사용자가 수행한다. 원복은 기본 시작연도 및 fallback을 `currentYear - 2`로 되돌린다. 단년 조건의 성능 통과 여부는 배포 후 재시험 전까지 미확정이다.
- 후속 결과서: 시스템명은 `디지털하수관로`, 표시 메뉴명에는 `SN01` 등 시험 ID를 붙이지 않는다. 사용자가 수정한 HWP를 기준으로 추가 편집하고, 실제 캡처는 마우스 커서가 보이지 않게 준비한다. 기존 측정 원본은 변경하지 않는다.
@@ -0,0 +1,139 @@
# 지반침하 상세정보·하수관로 목록 엑셀 다운로드 작업계획
- 작성일: 2026-10-22
- 요구사항: SFR-010-010
- 상태: 사용자 승인 후 구현 완료. 타입·XLSX 검증 완료, 실제 브라우저 클릭 검증은 미완료.
- 작업 목적: 지도 우측 지반침하 상세정보에서 발생정보와 해당 반경 내 하수관로 목록을 하나의 XLSX 파일로 내려받는다.
- 범위: FE만 수정한다. 공통 유틸은 사용자 지정 경로인 `apps/sn/src/shared/util`에 둔다.
## 1. 요구사항 및 원본 확인
| 근거 | 직접 확인한 내용 |
| --- | --- |
| 사용자 요청 | 우측 상세정보에 엑셀 다운로드 추가. 상세 상단을 첫 시트, 하수관로 목록을 둘째 시트로 구성. 다른 화면을 참조해 공통화하고 승인 후 구현 |
| `COMMON_DOCS_ROOT/기준문서/요구사항정의서.xlsx` | `3. (기능)요구사항정의 및 추적표` 시트 82행, `SFR-010-010`, 싱크홀 발생 정보 지도 서비스. 지반침하 용어 변경, 연계 정보 지도 표출, 기본 반경 100m 및 사용자 입력 반경, 좌표 없는 자료의 목록 표출 근거 확인 |
| `COMMON_DOCS_ROOT/기준문서/화면설계서.pptx` | 슬라이드 7, 화면 ID `SN_MN010101`, 하수관망도 > 지반침하정보. 발생정보 상세, 반경 내 관로 추출, 관로 행 클릭 강조, 발생이력 목록 다운로드 확인 |
| 기존 작업카드 | 같은 폴더의 `001-fe-SFR-010-010-지반침하정보-작업카드.md` 확인. 현재 경로와 실제 구현을 기준으로 추가 계획 작성 |
원본에는 우측 상세정보를 두 시트로 내려받는 구체적 구성이 없으므로 이번 사용자 요청을 추가 요구사항으로 삼는다. 원본의 피해 인원 항목을 새로 추가하지 않고 현재 상세 상단에 표시되는 9개 항목을 내보낸다.
원본 파일 정보: 화면설계서 10,736,400바이트 / 수정일 2026-08-13, 요구사항정의서 124,187바이트 / 수정일 2026-08-06.
## 2. 현재 구현과 참조 결과
| 파일 | 확인 결과 / 활용 방향 |
| --- | --- |
| `app/(map-layout)/map/components/sinkhole/sinkhole-detail-panel.tsx` | `detail`, `nearbyPipes`, `radiusMeters`를 받아 발생정보 9개 항목과 관로 5개 열을 표시한다. 다운로드 버튼은 없다. 복구상태와 관로 용도는 공통코드 표시명 사용 |
| `app/(map-layout)/map/components/sinkhole/sinkhole-popup-page.tsx` | 기존 지반침하 목록 다운로드는 적용조건 전체를 페이지 순회하여 수집하고 한글 헤더, 로딩 및 오류 안내를 적용한다. 해당 목록 기능은 유지 |
| `src/shared/lib/download-excel.ts` | `xlsx` 동적 import, 한글 헤더, 열 너비, 시트명 정리, 압축 저장 참조. `sheetNameArray`는 현재 첫 이름만 사용하며 동일 구조 데이터의 대량 분할용이므로 이종 데이터 두 시트를 직접 표현하기 어렵다 |
| `app/(map-layout)/map/components/aged-pipe-status-by-ctpv/aged-pipe-status-by-ctpv-export.ts` | `aoa_to_sheet`, 병합, 열 너비, 자동필터, `book_append_sheet`, XLSX 저장 방식 참조 |
| `app/(map-layout)/map/ui/map-page-client.tsx` | 활성 지반침하 상세 탭의 상세와 관로 배열을 패널에 전달한다. 기존 조회 실패도 빈 배열이 되므로 관로 0건과 조회 실패를 현재 props만으로 구분할 수 없다 |
백엔드는 읽기 전용으로 현행 제약만 확인했다. `SinkholeQueryService.getNearbyPipes`, `getNearbyPipesByPoint` 모두 관로 조회 상한이 200건이다. 호출 인자로 전체 건수나 페이지를 요청할 수 없다.
## 3. 결과 파일과 UI 제안
- 버튼: 사용자 최종 후속 요청에 따라 하수관로 목록의 `침하발생 반경내 관로정보` 제목과 같은 헤더 행의 오른쪽에 배치한다. 하수처리구역 상세의 `보수이력`과 동일한 Button 기본 크기와 색상·여백 클래스를 사용한다. 좁은 패널에서는 제목 줄바꿈을 허용하고 버튼 크기를 유지한다.
- 파일명: `지반침하정보_사고번호_YYYY-MM-DD.xlsx`. 파일명에 쓸 수 없는 문자는 정리하고 날짜는 사용자 로컬 날짜를 사용한다.
- 두 시트의 순서는 고정한다.
| 순서 / 시트명 | 구성 |
| --- | --- |
| 1 / `지반침하정보` | `항목`, `내용`의 세로 표. 사고번호, 발생일자, 주소, 발생규모폭, 발생규모연장, 발생규모깊이, 상세발생원인, 복구상태, 복구완료일자 순으로 현재 상세와 동일한 표시값 사용 |
| 2 / `하수관로목록` | 상단에 사고번호와 조회 반경(m)을 표시하고, `NO`, `관로 ID`, `용도`, `경과년도(년)`, `거리(m)` 열로 관로 목록 구성. 조회 순서를 유지하고 자동필터는 목록 헤더부터 적용 |
- 첫 시트는 현재 `summaryTableData`와 `summaryHeadList`를 재사용해 화면과 엑셀의 값·항목 순서가 달라지지 않게 한다.
- 관로 용도는 현재 화면의 공통코드명 변환 함수를 재사용한다. 코드명 미확보 시 화면과 동일하게 원래 코드를 사용한다.
- 관로 ID와 사고번호는 문자열로 보존한다. 숫자형 경과년도·거리는 숫자로 저장하며 헤더에 단위를 표기한다. 결측값은 `-`로 처리하고 0은 유지한다.
- 관로가 정상 조회되어 0건이면 둘째 시트에 헤더와 `반경 내 하수관로 정보가 없습니다.` 안내를 넣어 두 시트를 모두 생성한다.
- 좌표가 없는 상세도 첫 시트는 다운로드할 수 있게 하고, 둘째 시트에는 좌표 부재로 관로를 조회할 수 없다는 안내를 넣는다.
- 다운로드 중에는 버튼을 비활성화하고 진행 상태를 표시한다. 실패하면 안내 후 버튼을 복구한다.
## 4. 공통화 설계
### 신규 공통 유틸
경로: `apps/sn/src/shared/util/download-excel-workbook.ts`
`downloadExcelWorkbook({ fileName, sheets })` 형태로 서로 다른 구조의 시트를 순서대로 받는다. 각 시트는 시트명, 명시적 셀 행 배열, 열 너비, 선택적인 자동필터 범위·병합 범위를 전달한다. 셀 값은 문자열·숫자·불리언·null로 제한하고 업무 데이터나 API 호출은 유틸에 넣지 않는다.
공통 책임은 XLSX 동적 import, 워크북/시트 생성, 시트명 금지문자·31자 제한·중복 이름 정리, 열 너비 설정, 자동필터 및 병합 적용, 파일 확장자·파일명 정리, 브라우저 저장이다. 문자열을 수식으로 전환하지 않는다. 빈 데이터에서도 전달된 헤더·안내 행을 저장할 수 있어야 한다.
기존 `src/shared/lib/download-excel.ts`의 공개 인터페이스와 호출부는 이번에 변경하지 않는다. 두 함수의 목적이 달라 기존 목록 다운로드 전체를 이관하는 작업은 제외하고 새 기능에서 공통 유틸을 사용한다. 다른 화면도 이종 시트가 필요할 때 같은 유틸을 사용할 수 있다.
### 지반침하 전용 변환
경로: `app/(map-layout)/map/components/sinkhole/sinkhole-detail-excel.ts`
화면에서 전달한 발생정보 표시값, 관로 표시값, 사고번호, 반경을 XLSX 시트 명세로 변환한다. 지반침하 항목명과 빈 목록·좌표 부재 안내는 이 파일에서 관리하여 공통 유틸과 분리한다.
## 5. 데이터 범위와 오류 처리
- 이번 다운로드 대상은 선택한 사고의 현재 적용 반경에 대해 기존 API가 제공하는 관로 목록이다. 전체 사고 목록이나 전체 하수관로를 내보내는 기능이 아니다.
- 기존 관로 API가 최대 200건을 반환하므로 FE만으로 200건 초과 전체를 보장할 수 없다. 둘째 시트에 `기존 반경 조회 기준, 최대 200건`을 표시한다. 200건 초과 전체 추출은 백엔드 계약 보완이 필요한 별도 범위다.
- 기존 패널 props의 빈 배열은 조회 실패와 0건을 구분하지 못한다. 이를 숨기지 않도록 다운로드 클릭 시 기존 조회 API로 선택 사고·반경의 관로 목록을 다시 확인한다. 새 API는 만들지 않는다.
- 유효한 상세 x/y가 있으면 기존 `nearbyPipesByPoint`, 없으면 좌표 조회 가능 상태를 확인한 뒤 기존 사고번호별 `nearbyPipes`를 사용한다. 좌표 부재 상태는 조회를 생략하고 해당 사유를 시트에 남긴다.
- 요청 실패·비정상 응답은 0건으로 바꾸지 않고 다운로드를 중단해 안내한다. 정상 응답의 빈 `items`만 0건으로 처리한다.
- 클릭 시 활성 상세, 반경, 코드명 정보를 고정하여 비동기 요청 도중 다른 사고로 이동해도 서로 다른 사고 정보가 섞이지 않게 한다.
- 재조회 중 데이터가 변경될 수 있으므로 두 번째 시트는 다운로드 시점의 같은 사고·반경 조회 결과다. 지도·화면의 상태를 다운로드 때문에 갱신하지 않는다.
## 6. 수정 예정 파일
| 파일 | 변경 내용 |
| --- | --- |
| `src/shared/util/download-excel-workbook.ts` | 신규. 업무에 독립적인 다중 시트 XLSX 생성/저장 |
| `app/(map-layout)/map/components/sinkhole/sinkhole-detail-excel.ts` | 신규. 발생정보/관로 두 시트 구성 및 기존 API 재조회 처리를 담당. API 호출은 공통 유틸에 포함하지 않음 |
| `app/(map-layout)/map/components/sinkhole/sinkhole-detail-panel.tsx` | 버튼, 진행/오류 처리, 기존 표시값 전달 |
| 이 작업계획 문서 | 승인 및 구현 후 검증 결과 기록 |
백엔드, 의존성, 공용 패키지 및 다른 페이지의 코드는 변경하지 않는다. 기존에 수정 중인 파일은 덮어쓰지 않는다. 메인 지도 상태 변경은 현재 계획에서 제외한다.
## 7. 구현 절차
1. 공통 워크북 유틸을 작성한다. 업무 데이터와 분리해 서로 다른 행 구조를 가진 시트를 하나의 파일로 생성할 수 있게 한다.
2. 지반침하 전용 변환·조회 함수를 작성한다. 클릭 시 사고와 반경을 고정하고 기존 API 응답을 검증한 뒤 발생정보와 관로 시트 명세를 만든다.
3. 우측 상세에 다운로드 버튼을 연결한다. 현재 표시값을 재사용하고 진행 중 중복 실행과 오류 안내를 처리한다.
4. 아래 검증을 수행하고 결과를 기록한다. 파일 자체와 실제 다운로드 흐름이 요구사항에 맞는지 확인한 뒤 완료 보고한다.
## 8. 검증 및 완료 기준
- `git diff --check`, `pnpm --filter sn run typecheck`로 변경 오류를 확인한다.
- 사용자의 개발 서버가 실행 중이면 종료·재시작 및 `next build`를 하지 않는다. 실제 앱 포트와 실행 상태를 확인한 뒤 가능한 환경에서 UI를 검증한다.
- 실제 XLSX 파일을 다시 읽어 시트가 정확히 두 개이고 순서·이름·9개 발생정보 항목·관로 5개 열·행 수·값이 맞는지 확인한다.
- 정상 관로 여러 건, 정상 0건, 좌표 없음, API 실패, 비정상 응답, 코드명/원코드 대체, 결측값과 0, 숫자형 ID의 문자열 보존, 중복 클릭을 확인한다.
- 반경 변경 후 다운로드와 활성 사고 탭 전환 중 다운로드에서 사고·반경이 섞이지 않는지 확인한다.
- 한글·긴 주소·발생원인의 열 너비와 줄바꿈, 필터 위치, 파일명과 날짜를 확인한다. XLSX 라이브러리에서 지원하지 않는 장식 스타일을 완료 조건으로 약속하지 않는다.
- 기존 관로 행 클릭·지도 강조·닫기·다른 상세 탭과 기존 지반침하 목록 다운로드가 유지되는지 확인한다.
- 검증용 파일/스크립트는 결과 기록 후 정리하며 사용자 원본과 다른 작업자의 파일은 건드리지 않는다.
## 9. 영향 범위·롤백·잔여 제약
- 영향: 지반침하 우측 상세의 다운로드 기능과 신규 FE 공통 유틸에 한정한다. 기존 조회·지도·목록 다운로드 계약은 유지한다.
- 롤백: 패널의 이번 버튼/핸들러/import 변경만 되돌리고 신규 전용 함수와 신규 공통 유틸을 제거한다. 기존 타 작업 변경은 보존한다.
- 잔여 제약: 최대 200건 API 상한, 다운로드 시점 재조회와 기존 화면 사이의 데이터 갱신 차이, 대량 XLSX 생성의 브라우저 메모리 사용. 전체 관로 추출이나 서버 생성 방식은 별도 요청/승인 범위다.
## 10. 승인 및 구현 결과
- 사용자 승인: 이 작업계획대로 코드 수정 요청을 받아 구현했다.
- 신규 공통 유틸 `src/shared/util/download-excel-workbook.ts`: `buildExcelWorkbook`으로 시트 생성과 다운로드를 분리하고 `downloadExcelWorkbook`으로 브라우저 저장을 제공한다. 동적 XLSX 로딩, 시트 순서, 이름 중복/금지문자/길이 정리, 파일명 정리, 열 너비·필터·병합을 지원한다.
- 신규 `sinkhole-detail-excel.ts`: 발생정보 9개 항목과 관로 5개 열을 두 시트로 구성한다. API 재조회 실패와 비정상 응답은 다운로드를 중단하고, 정상 0건·좌표 부재는 별도 안내를 내보낸다. 사고·반경·표시 행은 재조회 전에 복사해 고정한다.
- 수정 `sinkhole-detail-panel.tsx`: 하수관로 목록 제목 오른쪽에 버튼을 배치했다. 사용자 후속 요청에 따라 하수처리구역 상세의 보수이력 버튼과 같은 기본 크기·색상·여백을 적용했다. 진행 상태를 state로 표시하고 ref로 즉시 중복 실행을 막으며 오류 후 상태를 복구한다.
- 기존 목록 다운로드, 메인 지도, 백엔드, 의존성 및 타 페이지는 수정하지 않았다. 공통 유틸 우선 검토 기준문서 추가는 별도 제안 단계이므로 이번 구현 범위에 포함하지 않았다.
### 검증 결과
| 항목 | 결과 / 한계 |
| --- | --- |
| `pnpm --filter sn run typecheck` | 통과 |
| `git diff --check` | 통과 |
| XLSX 실제 직렬화 후 재읽기 | 두 시트 이름·순서, 첫 시트 9개 항목, 둘째 시트 5개 열, 필터 범위, 관로 순서 확인 |
| 데이터 형식 | 문자열 관로 ID `00123`의 앞자리 0 보존, 용도명/원코드 대체, 경과년도·거리의 숫자 0과 결측 `-` 확인 |
| 예외 / 빈 목록 | 정상 0건 안내, 좌표 부재 시 API 요청 생략 및 안내, 요청 실패·비정상 응답 시 저장 중단 확인 |
| 비동기 데이터 고정 | 조회 중 호출자의 사고번호·반경·요약 행을 바꿔도 파일에는 클릭 시 데이터 유지 확인 |
| 공통 유틸 | 금지문자, 대소문자 중복, 31자 제한, 빈 시트, 수식처럼 보이는 문자열의 일반 문자열 저장 확인 |
| API 분기 / 파일명 | 좌표 기준 및 사고번호 기준 요청, 사고번호 URL 인코딩, 파일명 금지문자 정리 확인 |
| 개발 서버 | 기존 3002 포트 서버 유지, `/map` HTTP 200 확인. 빌드·재시작하지 않음 |
| 브라우저 / 실제 API | 연결된 브라우저가 없어 실제 버튼 클릭·다운로드·지도 회귀 및 실제 API 응답 검증은 미완료. 위 데이터/예외 검증은 요청·저장 함수를 대체한 메모리 기반 검증이며 XLSX 직렬화/재읽기는 실제 라이브러리 사용 |
| 시각 서식 | 열 너비·병합 설정 확인. 줄바꿈 스타일은 라이브러리에 전달하되 무료 SheetJS의 지원 및 Excel 실제 화면은 미검증 |
검증 코드는 셸 표준입력으로 실행했고 디스크에 테스트 파일·XLSX를 남기지 않았다. 롤백과 최대 200건 제약은 9절을 따른다.
@@ -41,3 +41,41 @@
- 롤백: `waterRiskClient.ts`를 제거하고 popup의 실제 조회/부모 메시지 변경 및 `map/page.tsx` 통수위험 mapView 처리를 되돌린다.
- 자동화 도구가 새 창 popup을 부모 탭과 동시에 제어하는 데 제한이 있어, popup의 실제 mapView 요청과 부모 메시지 처리 코드를 각각 확인했다. 최종 사용자 회귀 시 부모 지도에서 popup을 열고 한 행을 선택하는 시나리오를 한 번 더 수행할 수 있다.
- 현재누적강우량은 백엔드 원천 미확보로 `-`이며, 예측강우 격자 overlay는 이번 합리식 결과 범위에 포함하지 않는다.
## 5. 2026-10-20 위험(매핑관로없음)·분석시각 컬럼·분석시각 기간 검색
- 근거: `COMMON_DOCS_ROOT/작업계획/SFR-011-007-통수위험분석/004-de-...-매핑관로없음위험-분석시각-작업계획.md` 1~8 승인. 원본 화면설계서 19·21쪽(SN_MN010201·010202), 요구사항 SFR-011-007.
- `components/water-risk/water-risk-types.ts`: `WaterRiskStatus`에 `DANGER_UNMAPPED` 추가, 라벨 `위험(매핑관로없음)`, 위험 색 판별 `isWaterRiskDanger()`.
- `components/water-risk/water-risk-popup-page.tsx`
- 목록: `중점관리지역` 다음 `분석시각` 컬럼(정렬 가능, 컬럼 id = BE 정렬 키 `analysisBaseAt`), 컬럼 폭 재배분, 최소 폭 124rem → 136rem, 위험 배지 줄바꿈 방지.
- 과거이력 검색패널: `분석시각` 기간(시작 날짜·시 ~ 종료 날짜·시, 선택). 기본·초기화는 빈 값이며 적용 시 `validateWaterRiskAnalysisPeriod`로 검증한다. 위험상태 필터에 `위험(매핑관로없음)` 추가. 하단 판정 안내 문구 갱신.
- 현재 탭은 기존처럼 현재 정시 `analysisBaseAt`만 보낸다.
- `components/water-risk/water-risk-client.ts`: `analysisBaseFrom`·`analysisBaseTo`를 둘 다 있을 때만 쿼리스트링에 넣는다.
- `components/water-risk/water-risk-date-range.ts`: `buildWaterRiskAnalysisDateTime`, `validateWaterRiskAnalysisPeriod`(짝 입력, 시작 ≤ 종료, 종료 ≤ 현재 정시, 366일) 추가.
- `lib/water-risk-styles.ts`, `ui/map-page-client.tsx`: 지도 구역 색·정보카드 배지를 `isWaterRiskDanger()`로 판정해 위험(매핑관로없음)도 빨강으로 표시한다.
- 검증: `git diff --check` 통과, `tsc --noEmit`(sn 전체) 통과, 변경 6개 파일 ESLint 문제 수가 수정 전(HEAD)과 동일(기존 `map-page-client.tsx` 46 errors 등은 baseline). 개발 서버가 꺼져 있어 화면 확인은 미실행, `next build` 미실행.
- 롤백: 위 6개 파일을 되돌린다.
- 2026-10-20 추가: BE mapView 위험상태가 목록과 같은 대표결과 판정값으로 바뀌어, `lib/use-water-risk-layer.ts`의 정보카드 상태 우선순위 주석만 갱신했다(동작 변경 없음).
## 6. 2026-10-21 지도 조회 키를 SWMM 실행 ID로 변경
- 근거: `COMMON_DOCS_ROOT/작업계획/SFR-011-007-통수위험분석/004-de-...-작업계획.md` 15절 9~13 승인.
- `components/water-risk/water-risk-types.ts`: `WaterRiskResultRow.executionId`, `WaterRiskMapView.executionId·analysisBaseAt·rainStartedAt`, `WaterRiskSessionSnapshot.selectedExecutionId` 추가.
- `components/water-risk/water-risk-client.ts`: 지도 조회를 `v1/waterRisk/executions/{executionId}/mapView`로 변경(날짜·지역 경로 미사용).
- `components/water-risk/water-risk-popup-page.tsx`: 행 식별 키를 `executionId`로 변경(없으면 기존 조합), 부모 동기화 메시지에 `selectedExecutionId`가 있으면 그 행만 선택 표시.
- `lib/use-water-risk-layer.ts`: 지도 응답과 행의 실행 ID가 다르면 무시, 세션에 `selectedExecutionId` 저장.
- 검증: `tsc --noEmit` 통과, 변경 4개 파일 ESLint 문제 수 수정 전과 동일, `git diff --check` 통과. 화면 확인은 BE 재기동 후 필요(현재 BE는 이전 코드라 새 지도 API 없음).
- 롤백: 위 4개 파일 복원. BE의 구 지도 API가 유지되는 동안은 FE만 되돌려도 기존 동작으로 복귀한다.
## 7. 2026-10-21 과거이력 검색조건 순서·명칭·시간 단위 입력
- 근거: `COMMON_DOCS_ROOT/작업계획/SFR-011-007-통수위험분석/004-de-...-작업계획.md` 16절 결정 14~17 승인. 원본 화면설계서 19쪽 SN_MN010201, 요구사항 SFR-011-007.
- `components/water-risk/water-risk-popup-page.tsx`
- 과거이력 검색조건 순서를 시도 → 시군구 → 중점관리지역 → 분석시각 → 강우시각 → 위험상태로 바꿨다(현재 탭은 중점관리지역만).
- 명칭: 검색란 `강우 발생기간` → `강우시각`, 목록 컬럼 `강우시작시간` → `강우시작시각`(컬럼 id·정렬 키 `rainStartedAt` 유지).
- 분석시각·강우시각 입력을 침수예측현황과 같은 `datetime-local` 시작 `~` 종료(`w-[17rem]`)로 바꾸고 `step={3600}`을 줬다. 분은 00으로 고정되고, 분이 들어오면 저장 전에 00으로 내린다. 현재 정시 이후는 `max`로 막는다.
- 검색조건 상태를 `rainStartedFrom`·`rainStartedTo`(필수, 기본 365일 전 00시 ~ 현재 정시), `analysisFrom`·`analysisTo`(선택)로 정리했다. 조회 시 `yyyy-MM-ddTHH:00:00`으로 보내고, `startDate`·`endDate`는 강우시각 날짜로 맞춘다. 현재 탭은 기존처럼 최근 1년 날짜와 현재 정시 `analysisBaseAt`만 보낸다.
- `components/water-risk/water-risk-date-range.ts`: 시간 단위 값 변환(`toWaterRiskHourValue`·`formatWaterRiskHourValue`·`toWaterRiskApiDateTime`)과 기간 검증(`validateWaterRiskRainStartedPeriod` 필수, `validateWaterRiskAnalysisPeriod` 선택: 짝 입력·정시·시작 ≤ 종료·종료 ≤ 현재 정시·366일)으로 정리. 오류 문구는 `강우시각…`/`분석시각…`.
- `components/water-risk/water-risk-client.ts`: `rainStartedFrom`·`rainStartedTo`를 둘 다 있을 때만 전달.
- 검증: `tsc --noEmit`(sn 전체) 통과, 변경 3개 파일 ESLint 0건(수정 전과 같음), `git diff --check` 통과. `step=3600` 입력의 분 고정은 앱 내장 브라우저(Chromium 152)에서 따로 확인했다. 로그인이 필요해 실제 팝업 화면은 미확인, `next build` 미실행.
- 롤백: 위 3개 파일을 되돌린다. BE의 새 파라미터는 선택값이라 FE만 되돌려도 동작한다.
@@ -77,3 +77,53 @@
- `fldng-predc-prst-excel.ts`가 화면 열 순서의 단일 시트 `침수예측현황.xlsx`를 브라우저에서 생성한다.
- 서버 `/prst/excel` Blob 호출과 `getBlobData`, `fileDownload` 사용을 제거했다.
- 독립 TypeScript `--noEmit` 검증을 통과했다.
## 2026-10-21 강우 명칭·시간 단위 입력 통일 계획 (2026-10-21 승인·구현)
### 배경
- 사용자 지시(2026-10-21)
- 강우 관련 명칭: 검색란 `강우시각`, 목록 `강우시작시각`. 침수예측현황도 바꿔야 하면 이 문서에 추가한 뒤 승인 후 수정한다.
- 검색란의 분석시각·강우시각은 `datetime-local`(`2026-10-21 오후 03:00` 형태)로 두되 **시간까지만 선택**한다. 분은 `00`으로 고정하고 수정할 수 없다. 침수예측현황도 같이 수정한다.
- 통수위험 쪽 계획: `COMMON_DOCS_ROOT/작업계획/SFR-011-007-통수위험분석/004-de-SFR-011-007-통수위험분석-매핑관로없음위험-분석시각-작업계획.md` 16절.
### 현재 명칭
| 위치 | 현재 | 기준과 일치 |
| --- | --- | --- |
| 검색란 라벨 (`ui/fldng-predc-prst-popup-content.tsx`) | `강우시각` | 예 |
| 목록 컬럼 (`ui/fldng-predc-prst-popup-content.tsx`) | `강우시작시각` | 예 |
| 지도 정보 (`lib/use-flood-prediction-map.ts`) | `강우시작시각` | 예 |
| 검색 오류 문구 (`ui/fldng-predc-prst-popup-content.tsx` `validateFilters`) | `강우 시작시각의 시작은 종료보다 늦을 수 없습니다.` | 아니오(검색란 이름은 `강우시각`) |
| Excel 머리글 (`lib/fldng-predc-prst-excel.ts` `HEADERS.rainBeginningDateTime`) | `강우 시작시각` | 아니오(목록은 붙여 쓴 `강우시작시각`) |
- 화면에 보이는 라벨과 목록 컬럼 이름은 이미 기준과 같다. 이름은 문구 2곳만 맞춘다.
### 시간 단위 입력 결론
- 현재 분석시각·강우시각 입력(`renderDateTimeRange`)은 `datetime-local`이라 분까지 고를 수 있다.
- `step={3600}`(1시간)을 주면 값의 분이 `00`일 때 브라우저가 분 칸을 고정해 바꿀 수 없다. 빈 값에서 처음 입력할 때처럼 분이 들어오는 경우는 `onChange`에서 분을 `00`으로 맞춘다. 입력 폭(`w-[17rem]`)과 표시 형태는 그대로다.
- API는 지금처럼 `yyyyMMddHHmm`을 보내며 분만 항상 `00`이 된다. BE는 문자열 시작 이상·종료 이하로 비교하므로(`FldngPredcPrstRepository.addRange`) **BE 변경은 없다.**
- 개발 DB `tb_sn_fldng_predc_anls` 208건의 분석시각(`obsrvn_tm`)·강우시작시각(`rain_bgng_tm`)이 모두 정시(분 `00`)라서 시간 단위 조건으로 빠지는 자료는 없다.
### 수정 계획
| 번호 | 파일 | 변경 |
| --- | --- | --- |
| 1 | `src/features/fldng-predc-prst/ui/fldng-predc-prst-popup-content.tsx` | 오류 문구 `강우 시작시각의 시작은 종료보다 늦을 수 없습니다.` → `강우시각의 시작은 종료보다 늦을 수 없습니다.` |
| 2 | `src/features/fldng-predc-prst/lib/fldng-predc-prst-excel.ts` | Excel 머리글 `강우 시작시각` → `강우시작시각` |
| 3 | `src/features/fldng-predc-prst/ui/fldng-predc-prst-popup-content.tsx` | `renderDateTimeRange`의 시작·종료 입력 4개(분석시각·강우시각)에 `step={3600}` 추가, `onChange`에서 분을 `00`으로 맞춰 저장 |
- 바꾸지 않는 것: API 파라미터·정렬 키, 기본값(빈 값), 분석도구 메뉴의 침수예측 건수 조회 범위(`model/analysis-time-range.ts`, 입력란과 무관).
### 검증·영향·롤백
- 검증: 분석시각·강우시각 입력에서 분 칸이 `00`으로 고정되는지, 빈 값에서 새로 입력해도 분이 `00`이 되는지 확인. 시작 > 종료로 강우시각을 넣어 바뀐 오류 문구 확인, 조회 요청의 시각 파라미터 끝 두 자리가 `00`인지 확인, Excel 다운로드 머리글 확인. `tsc --noEmit`·`git diff --check`.
- 영향: 침수예측현황 팝업의 검색 입력 4개, 오류 문구, Excel 머리글 1칸. API·DB·지도 영향 없음.
- 롤백: 위 2개 파일을 되돌린다.
### 구현 결과 (2026-10-21)
- 계획 1~3을 그대로 반영했다. `ui/fldng-predc-prst-popup-content.tsx`: 오류 문구 `강우시각의 시작은 종료보다 늦을 수 없습니다.`, 분석시각·강우시각 입력 4개에 `step={3600}`과 분 00 고정(`toHourInputValue`). `lib/fldng-predc-prst-excel.ts`: Excel 머리글 `강우시작시각`.
- 검증: `tsc --noEmit`(sn 전체) 통과, `git diff --check` 통과. ESLint는 수정 전과 같은 1건(기존 미사용 import `createPredictionAnalysisTimeRange`, 이번 범위 밖). 로그인이 필요해 실제 팝업 화면과 Excel 다운로드는 미확인.
- BE·API·DB 변경 없음.
@@ -0,0 +1,31 @@
# 관망도 남한 전체 축소 표시
- 작업일: 2026-10-07
- 목적: 최소 줌을 낮춰도 지정 영역 제한 때문에 남한 전체까지 축소되지 않는 현상 보완.
- 근거: 사용자 요청. 화면설계서 7번 SN_MN010101의 하수관망도, 요구사항정의서 기능 추적표 97~100행(PR-SN-030)을 확인했다. 해당 원본에는 정확한 줌/좌표 제한값이 없으므로 이번 값은 원본 요구사항으로 주장하지 않는다.
## 수정
| 파일 | 내용 |
| --- | --- |
| app/(map-layout)/map/model/map-constants.ts | SOUTH_KOREA_VIEW_EXTENT의 좌표계·용도 주석 추가. 기존 좌표 유지 |
| app/(map-layout)/map/ui/map-page-client.tsx | Gis에 viewShowFullExtent=true 전달 |
| packages/common/business_components/src/ui/map/Gis.js | 선택 속성을 View.showFullExtent에 연결. 기본 false로 다른 앱의 기존 동작 유지 |
범위는 EPSG:5179 `[600000, 1300000, 1500000, 2500000]`이다. 이 범위에는 남한이 포함되어 있으나, OpenLayers의 기본 extent 제약은 화면 가로·세로 중 어느 방향도 범위 밖으로 나가지 못하도록 축소를 제한한다. showFullExtent는 지정 영역 전체가 보이도록 한 축의 화면이 범위를 넘는 축소를 허용한다. 영역을 무제한으로 확장하거나 minZoom을 무시하는 설정이 아니다.
## 검증
- 사용자 SN 개발서버 3002(PID 69796)를 유지하고 HMR로 검증했다. 서버 재시작과 Next build는 수행하지 않았다.
- 기존 Chrome 탭 연결은 시간 초과하여 별도 인앱 브라우저 검증 탭을 사용했다.
- localhost:3002에서 승인된 테스트 계정으로 로그인 후 관망도 진입.
- 1280×720 브라우저에서 축소 버튼으로 줌 7.5 → 6.5 → 6.0 이동 확인.
- 줌 6.0에서 남한 본토, 제주도와 울릉도·독도가 함께 보이는 화면을 확인했다. 레이어 패널과 분석도구 패널을 유지한 상태다.
- git diff --check 통과. 기존 배경지도 타일의 모든 외부 네트워크 요청, 전체 업무 API 및 다른 화면 회귀까지 검증한 것은 아니다.
## 영향 및 롤백
- API/DB/레이어 검색·조회조건 변경 없음. 초기 줌 7.5와 중심좌표는 유지한다.
- 다른 앱은 선택 속성을 전달하지 않으므로 기존 기본값 false를 유지한다.
- 롤백은 이번 선택 속성 전달과 View 연결, 설명 주석만 제거한다. 사용자 변경이나 공통 minZoom은 되돌리지 않는다.
- 별도 테스트 파일·캡처 파일을 생성하지 않았다. 최종 문서만 보존하며 검증용 브라우저 탭은 종료한다.
@@ -0,0 +1,28 @@
# FE 도시침수지도 빈 결과 안내 작업카드
- 작성일: 2026-10-13 / 상태: 구현 완료 / 실서버·브라우저 검증 대기
- 근거: 사용자 요청. extent 위치 정보가 없으면 `위치 정보가 없습니다`로 안내한다.
## 변경 계획
- 목록 200·items=[]·total=0: 총 0건과 빈 표를 표시하고 이전 행·오류·로딩 상태를 정리한다.
- 단건·그룹 extent 200 응답에 위치 데이터가 없으면 `위치 정보가 없습니다`를 표시하고 지도 이동·선택 강조를 수행하지 않는다.
- 공통 응답에서 data가 null이거나 생략된 경우를 좌표 접근 전에 처리한다. 404 문자열로 위치 부재를 판단하는 기존 분기는 제거한다.
- 위치 데이터가 있으면 기존 지도 이동을 유지한다. 실제 HTTP 오류·잘못된 좌표는 기존 오류 처리로 구분한다.
## 수정 대상
- `app/(map-layout)/map/components/flood-dngr-map/flood-dngr-map-popup-page.tsx`: 목록·그룹 extent·안내 처리.
- sn 내 단건·그룹 extent 호출부(침수예측 현황·지도 포함): 검색으로 사용처를 확정하고 동일한 빈 결과 처리 적용.
- `src/shared/api/sewerage-api.ts`, `http-transport.ts`: null·data 생략 응답의 전달 방식 확인. 전역 오류 치환은 하지 않는다.
## 검증·영향
- 두 extent의 위치 없음: 200, 지정 안내 문구, 지도 이동 없음, null 접근 오류 없음.
- 위치 있음: 기존 이동 정상. 실제 404·401·403·5xx·timeout: 오류 안내 유지.
- 목록은 데이터 있음 → 0건 → 데이터 있음 재검색 시 행·건수·로딩이 일치한다.
- BE 성공 JSON의 data 생략·null 처리를 연동 확인한다. 빈 본문·비정상 응답을 임의로 위치 없음으로 처리하지 않는다.
- 영향은 sn의 extent 사용 화면. UI·DB 변경 없음. 롤백은 실제 수정한 기능 파일 복원.
- 남은 확인: 전체 extent 호출부와 실제 응답 형태·브라우저 동작.
FE 구현 및 타입 검사·클릭 처리 모의 검증 완료. 로그인 브라우저 검증은 미수행이다.
@@ -0,0 +1,44 @@
# SN 인수테스트 팝업 자료 출처 표시 FE 작업 결과
## 1. 작업 목적
- 인수테스트(2026-10-16) 고객 요구: "팝업화면 자료의 원천 표시 및 자료 업데이트 주기 표시".
- 대상 팝업 7개(침수예측, 침수흔적도, 지반침하정보, 통수위험, 침수위험지도, 도시침수지도, 관로분석)의 목록 아래에 자료 출처·분석 주기 문구를 넣는다.
- 기존 하단 설명이 있는 팝업은 그 설명을 유지하고 글머리표로 문구를 더한다(사용자 지시 2026-10-21).
## 2. 근거
- `인수테스트_요구사항_1016.pptx` 1쪽(공통 요청), 2~8쪽(화면별 문구). 원본 파일은 사용자 공유 드라이브에 있으며 저장소에는 넣지 않았다.
- 문구는 슬라이드 내용을 그대로 옮겼다. 줄 끝 쉼표 제거, `출처 :` 띄어쓰기 통일, 문장 끝 마침표 정도만 맞췄다.
## 3. 화면별 반영 내용
| 슬라이드 | 팝업 | 수정 파일 | 하단 글머리표 |
| --- | --- | --- | --- |
| 2 | 침수예측현황 | `src/features/fldng-predc-prst/ui/fldng-predc-prst-popup-content.tsx` | (기존) 빈도별 위험·경계·주의 기준, (기존) 행 선택 안내, 분석 실행 시기, 분석 방법(확률강우량 출처 map.wamis.go.kr, 도시침수지도 출처 한강홍수통제소) |
| 3 | 통수위험 | `app/(map-layout)/map/components/water-risk/water-risk-popup-page.tsx` | 판정 기준(위험·위험(매핑관로없음)·정상), 지도에 빨간 관로가 안 보일 수 있는 경우, 분석 실행 시기, 분석 방법(SWMM), 최대 침수예상시점·최대침수유량 설명 |
| 4 | 침수발생이력(침수흔적도) | `app/(map-layout)/map/components/fldng-hstry/fldng-hstry-popup-page.tsx` | (기존) 행 클릭 안내, 출처 : 행정안전부 재난보험과(2002년~2025년) |
| 5 | 도시침수지도 목록 | `app/(map-layout)/map/components/flood-dngr-map/flood-dngr-map-popup-page.tsx` | 출처 : 한강홍수통제소, 행정구역별 도시침수지도(2025.09) |
| 6 | 침수위험지도 목록 | `app/(map-layout)/map/components/sbmrg-dngr-map/sbmrg-dngr-map-popup-page.tsx` | 출처 : 하수도정비 중점관리지역 20개소 과업 산출물 |
| 7 | 지반침하 목록 | `app/(map-layout)/map/components/sinkhole/sinkhole-popup-page.tsx` | 출처 : 지하안전정보시스템 지반침하사고 정보 연계 표출(연계 주기 : 매 시간 연계) |
| 8 | 관로분석 | `app/(map-layout)/map/components/pipe-analysis/pipe-analysis-popup-page.tsx` | 국토교통부로부터 제공받은 GIS 관로정보(2025년)를 기반으로 분석하고 있음 |
- 공통 표시 컴포넌트 `src/features/window-popup/ui/popup-footer-note.tsx`(신규): 침수예측현황 하단 안내와 같은 글꼴(`text-xl text-muted-foreground`)의 글머리표 목록. 7개 팝업이 같은 모양을 쓴다.
- 통수위험: 판정 기준 문구를 목록 머리글 오른쪽에서 하단으로 옮겼다(슬라이드 3 "침수예측과 동일한 위치에 표시할것").
- 침수발생이력: 기존 회색 상자 안내를 공통 글머리표 모양으로 바꿨다(문구 유지).
## 4. 검증
- `tsc --noEmit`(sn 전체) 통과.
- ESLint: 변경 8개 파일 모두 수정 전과 같은 결과(침수예측현황의 기존 미사용 import `createPredictionAnalysisTimeRange` 1건 외 0건).
- `git diff --check` 통과.
- 미실행: 로그인이 필요해 실제 팝업 화면 확인 못 함. `next build` 미실행.
## 5. 영향·롤백·남은 위험
- 영향: 7개 팝업의 목록 아래 문구 표시만 바뀐다. API·상태·데이터 변경 없음.
- 롤백: 위 7개 파일을 되돌리고 `popup-footer-note.tsx`를 삭제한다.
- 남은 위험
- 팝업 높이가 낮으면 하단 문구가 길어(통수위험 5줄) 목록 영역이 그만큼 줄어든다. 화면 확인 후 필요하면 글꼴·간격을 조정한다.
- 슬라이드 문구 중 "관측강우가 0mm이상으로 강우가 발생시"는 고객 문구 그대로 두었다. 의미상 "0mm 초과"인지 고객 확인이 필요할 수 있다.
- 슬라이드 4의 "25년까지 데이터가 있는데 자동으로 26년으로 설정되는 문제"(지도 레이어 침수흔적도 연도 기본값)는 이번 범위(하단 문구)가 아니라 수정하지 않았다.
@@ -1004,3 +1004,44 @@
- 검증: SN 전체 타입검사 exit0, 숫자/소수/삭제 및 거부 문자열 Node 검증 통과, git diff --check 통과. 실브라우저 입력 검증은 미수행.
- 수정 소스: `frontend/apps/sn/src/features/exmn/ui/exmn-business-register-popup-page.tsx`. API/백엔드 코드·저장 계약 변경 없음. 기존 GIS 작업은 변경하지 않았다.
- 롤백: 해당 소스의 이번 필수 표시/입력 제한만 복원한다.
## 2026-10-19 맨홀 조사보고서 GIS 번호 연결 (maintenance)
- 목적: 별도 맨홀 식별체계 확정 전 맨홀 PDF의 `맨홀번호`에 조사시설 GIS 번호(테스트 188001)를 넣고 지도에서 해당 보고서를 열람한다. 지도 도형은 기존 상류맨홀 매칭을 유지한다.
- 근거: 요구사항정의서 `3. (기능)요구사항정의 및 추적표` SFR-010-006/007, 화면설계서 실제 42쪽의 식별기준 미확정 메모 및 43쪽 `SN_MN040101` 조사결과 등록. 이번 사용자 지시로 임시 GIS 번호 연결 기준을 확정했다.
- BE: `ExmnManholeReportService.java` 신규, `ExmnPipePdfValidator.java` 표제별 불변 판독기, `ExmnBusinessController.java`의 `GET /api/v1/exmnBusinesses/{exmnBizId}/manholeReports/{pipeNumber}` 및 `/availability`, `ExmnFacilityRepository.java`의 조사유형별 첨부 존재 여부 분기.
- 맨홀 첨부 `mnhlExmnAtchFileId`와 활성 MNHL의 `gisManagementNo`만 사용한다. 선택 번호가 없거나 활성 시설이 중복되면 임의 보고서를 반환하지 않는다. 매 요청 현재 PDF를 판독하므로 이전 관로 쪽 캐시를 사용하지 않는다. DB 스키마 및 업무 데이터 변경 없음.
- FE: `exmn-business-api.ts`, `exmn-survey-result-section.tsx`, `exmn-pipe-report-popup-content.tsx`, `exmn-result-register-popup-page.tsx`. MNHL에서도 보고서 버튼을 표시하고 jobType에 따라 API·제목·번호 안내를 선택한다. PPL 기본 호출은 유지한다.
- 업로드 검토의 PDF 내부 번호 대조는 이번 범위에 추가하지 않았다. 번호 입력 기준과 열람 시 대조한다는 안내를 결과등록 화면에 표시한다.
- 산출물: 작업루트 `output/pdf/맨홀_현황조사_보고서_188001.pdf`. 사용자 원본 1쪽의 맨홀번호만 188001로 변경했으며 보고서번호·노선명 등 예시값과 사진은 원본을 유지했다. 실제 납품 조사자료가 아닌 기능 테스트용이다.
- 검증: BE compileJava --offline 성공, SN 전체 typecheck 성공, 양 저장소 diff --check 성공. 임시 JUnit 5건 성공(실제 PDF 번호/쪽 추출, PPL 표제 분리, 다중/비연속 번호 거부·연속 쪽, MNHL 첨부 선택/DB 캐시 미사용/미첨부, 없는 번호/중복 시설). PDF 재열람·텍스트 및 렌더링 육안 검증 완료.
- 미검증: 로컬 3002/8883 실행 포트가 없으므로 실제 서버 업로드·저장·지도 선택·브라우저 팝업은 수행하지 않았다. 188001 MNHL 조사시설 및 상류맨홀 GIS 도형의 실제 존재를 확인한 것으로 간주하지 않는다.
- 수동 절차: 변경 BE/FE 실행 → 188001을 가진 MNHL 조사자료의 사업에서 결과등록 → CCTV 조사결과(맨홀)에 테스트 PDF 등록·저장 → 맨홀조사 레이어의 해당 상류맨홀 선택 → 조사보고서 보기 → 제목 맨홀 조사보고서/GIS 번호188001/1쪽 확인. 관로 보고서와 미첨부·없는 번호도 회귀 확인한다.
- 롤백: 위 소스의 이번 변경과 신규 ExmnManholeReportService를 함께 복원한다. DB 롤백은 없다. 실제 첨부 등록을 수행한 경우에는 별도로 이전 첨부를 복원한다.
- 남은 리스크: 번호는 확정 맨홀번호가 아닌 조사시설 GIS 번호의 임시 계약이며, 스캔 PDF는 판독 불가하다. 맨홀 PDF는 availability/열람 때 각각 판독하므로 대형 파일 성능은 후속 검증 대상이다. 테스트 소스·원시 결과·렌더링 임시 이미지는 요약 후 정리하고 최종 PDF는 보존한다.
## 2026-10-19 후속: 기존 사업 맨홀 PDF 단독 교체
- 원인: 보고서 열람을 추가했지만 결과등록 화면은 모든 검토에 Excel을 요구하고 저장에 Excel·PPL 검토 성공을 요구했다.
- 변경: 맨홀 PDF 하나만 선택하면 기존 활성 MNHL 조사시설의 GIS 번호로 PDF 번호를 대조한다. 기존 조사자료 없음·불일치·중복·잘못된 PDF는 차단한다. 선택 PDF의 일부 시설 포함은 허용한다.
- 저장 직전 다시 검토한 뒤 선택한 MNHL 첨부만 기존 attachments API로 연결한다. Excel imports/commit과 양식 생성 확인은 호출하지 않아 기존 시설·집계·관로첨부를 유지한다. Excel을 선택한 신규/전체 결과 저장 흐름은 유지한다.
- 수정: BE ExmnBusinessController(선택 mnhlFile 입력 및 Excel 없는 MNHL 검토 분기), ExmnManholeReportService(validateReplacement), FE exmn-result-register-popup-page(검토·저장 조건과 검토안내).
- 검증: BE compileJava, SN typecheck, 임시 JUnit 4건(기존번호 정상·무쓰기, 기존결과 없음, 번호 불일치/중복, 누락/손상 PDF) 통과. 원본 요구사항·지도 기준은 앞선 절과 동일하다. DB 스키마 변경 없음.
- 롤백: 이번 단독 교체 분기만 복원한다. 앞선 맨홀 보고서 열람 구현은 유지할 수 있다.
- 실제 브라우저 확인: 기존 테스트 사업 `6fe818cb-0a7a-4379-969c-150150155f91`에서 Excel/PPL 미선택·188001 맨홀 PDF만 선택 → 검토 완료 → 저장 → 부모 사업목록 성공 안내 확인. 등록한 맨홀 PDF를 보고서 팝업에서 GIS 번호188001/1쪽으로 실제 표시했다. 지도 클릭 경로 자체는 이번 후속에서 재실행하지 않았다.
- 검토 중 기존8883 서버의 닫힌 Spring 컨텍스트로 일반 API도500 발생. 사용자 명시 승인 후 동일 실행인수·local 프로필·포트8883·context /sewerage-service로 재시작하여 정상화했다. 교체 서버 PID24204는 사용자가 이어서 테스트하도록 유지한다. 종료명령: Stop-Process -Id 24204. 실행중 필요한 classpath 사본과 stdout/stderr는 작업루트 .codex-tmp/manhole-backend-classpath.jar 및 manhole-backend.out.log/err.log에 유지한다(테스트용 임시 서버가 아닌 승인된 사용자 서버 교체).
- 부모 성공문구도 첨부 교체에 맞게 `조사결과 저장을 완료했습니다.`로 보정했다(exmn-registration-page.tsx). 실제 첨부는 변경했으며 기존 시설·집계는 Excel commit을 호출하지 않아 유지한다. 되돌릴 경우 이전 맨홀 첨부를 재등록한다.
## 2026-10-19 최종 정정: 실제 GIS 맨홀번호와 선택 사업 기준
- 사용자 확정: PDF 맨홀번호는 조사자료 관로번호가 아닌 실제 GIS 맨홀번호이다. 앞선 gisManagementNo 기준 설명·구현은 이 절로 대체한다.
- 원인: 지도보기 진입은 테스트 사업이었지만 전체 사업 레이어의 겹친 시설이 선택됐다. 실제 화면은 오류 확인 테스트_수정/조사관로188003/시설관리번호188001이었다. 사용자 선택 실수가 아닌 지도 범위와 번호 계약 불일치이다.
- FE use-exmn-result-map.ts: 조사관리에서 사업·처리분구를 지정해 진입하면 해당 범위만 표출·선택한다. 일반 지도 진입은 기존 전체 범위를 유지한다. 기존 2026-10-01 전체표출 정책의 조사관리 진입 부분을 이 결정으로 대체한다.
- FE exmn-survey-result-section.tsx: MNHL 보고서 키는 fclt_mng_no/fcltMngNo, PPL은 기존 gisManagementNo. 조사 결과 표에 GIS 번호를 명시한다. 결과등록 안내도 실제 GIS 맨홀번호로 정정했다.
- BE ExmnManholeReportService: 기존 ExmnMapLayerService의 동일 지역·상류맨홀 매칭 결과 fclt_mng_no 집합으로 업로드 검토·열람 대상을 확인한다. 조사 관로번호를 맨홀번호로 대체하지 않는다. 같은 실제 맨홀에 여러 조사관로가 연결되면 번호 집합으로 처리한다. 스키마·조사시설·원천 GIS 변경 없음.
- 검증: 실제 GIS188001/조사관로188003 분리, PDF 검토·열람 성공, 조사관로 번호 직접 요청 거부 및 GIS 미연결 거부를 묶은 JUnit1건 통과. BE 컴파일·FE 타입검사·diff --check 통과. 임시 테스트 소스와 결과 정리 완료.
- 실브라우저: 테스트 사업→초이처리분구→지도3개 표시→실제188001 클릭→사업명 테스트/GIS 맨홀번호188001 확인→조사보고서 보기→1쪽 PDF와188001 표시 성공. 사용자도 최종 정상동작 확인했다.
- 실행 서버: 기존 재시작 승인 범위에서 코드 반영 후8883 PID27788로 유지한다(이전PID24204 종료). classpath/실행로그는 기존 .codex-tmp/manhole-backend-* 경로 유지. 종료명령 Stop-Process -Id 27788.
- 롤백: 위 FE3개 소스와 ExmnManholeReportService의 이번 변경을 함께 복원한다. DB 롤백 없음. 잔여 리스크는 GIS 미연결 자료 열람 불가 및 대형 사업의 지도 매칭·PDF 판독 비용이며, 임의 위치/다른 사업 fallback을 하지 않는다.
@@ -52,7 +52,7 @@
| 행 선택 지도 이동 | 기존 그룹 범위 이동 유지 |
| 일반 지도 통신 | 빈도 부모 3개 TileWMS |
| 빈도 제한 | 부모 `flterSql`을 WMS `CQL_FILTER`로 전달 |
| 침수심도 스타일 | 자식 5건 기반 동적 SLD |
| 침수심도 스타일 | GeoServer 레이어 기본 스타일(2026-10-16부터, 11절). 이전에는 자식 5건 기반 동적 SLD |
| 범례 | 숨겨진 자식 설정의 기존 색상·명칭 유지 |
| 100년 빈도 | 화면 및 WMS 전환 대상에서 제외 |
| 기존 다른 레이어 | 기존 WFS 경로 유지 |
@@ -88,3 +88,16 @@
- 운영에서 행 선택 후 그룹 extent API가 `ST_Envelope` 스키마 해석 오류로 실패해 지도 이동이 중단된 원인을 백엔드 SQL에서 보완했다.
- 기존 시군구·침수빈도 그룹 범위 이동 계약과 화면 동작은 유지한다.
- 재배포 후 목록 행 클릭, 지도 중심 이동, 선택 빈도 WMS 표출을 회귀 확인한다.
## 11. 2026-10-16 WMS SLD_BODY 제거
- 근거: `COMMON_DOCS_ROOT/작업계획/SN-도시침수지도-정보제공포털-목록보기/003-de-SN-도시침수지도-WMS502-SLD_BODY제거-작업계획.md` (SFR-011-008·009, 화면설계서 24·28쪽 SN_MN010205·SN_MN010206)
- 운영에서 `SLD_BODY`가 포함된 WMS 요청이 nginx 502로 실패해, 침수심도 스타일을 GeoServer 레이어 기본 스타일로 옮기고 요청에서 `SLD_BODY`를 뺐다.
- `gis.layer.js`
- `getWmsLayer()`에서 도시침수지도 `SLD_BODY` 생성 분기와 `gis.wms-sld.js` import를 제거했다.
- `STYLES=''`(레이어 기본 스타일), 빈도 `CQL_FILTER`, `drawOrdr`, `minViewSize`는 그대로다.
- `gis.wms-sld.js`: 삭제했다(사용처가 `gis.layer.js` 하나뿐).
- 범례(`water-depth.tsx`)는 기존처럼 숨겨진 자식 5건의 DB 색상을 쓴다. GeoServer 기본 스타일이 제공 SLD(`tb_ms_flood_dngr_map_depth.sld`)로 교체되어야 지도 색과 범례가 일치한다.
- 검증: 남은 참조 없음, `git diff --check`·ESLint 통과, 개발 GeoServer 동일 파라미터 요청 HTTP 200 PNG.
- 미수행: 사용자 개발 서버 실행 중이라 `business_components`·`sn` 빌드 보류, 로그인 필요로 브라우저 Network 확인 보류.
- 롤백: `gis.layer.js`의 SLD 분기와 `gis.wms-sld.js`를 복원한다.
@@ -9,6 +9,7 @@ import {
FLDNG_HSTRY_LAYER_NAME,
FLDNG_HSTRY_LAYER_TYPE,
FLDNG_HSTRY_MIN_YEAR,
FLDNG_HSTRY_MAX_YEAR,
FLDNG_HSTRY_PREVIEW_LAYER_TYPE,
FLDNG_HSTRY_ROOT_NAME,
type FldngHstryApiItem,
@@ -50,11 +51,10 @@ export const formatFldngHstryDateTime = (
return [normalizedDate, normalizedTime].filter(Boolean).join(" ");
};
export const buildFldngHstryDefaultYearRange = (
currentYear = new Date().getFullYear(),
): FldngHstryYearRange => ({
/** 침수흔적도 초기 조회 범위를 제공 자료의 연도 범위로 설정한다. */
export const buildFldngHstryDefaultYearRange = (): FldngHstryYearRange => ({
startYear: String(FLDNG_HSTRY_MIN_YEAR),
endYear: String(currentYear),
endYear: String(FLDNG_HSTRY_MAX_YEAR),
});
export const normalizeFldngHstryYearRange = (
@@ -14,6 +14,8 @@ export const FLDNG_HSTRY_DEPTH_LAYER_ID = "SN_FLOOD_HISTORY_DEPTH";
export const FLDNG_HSTRY_PAGE_SIZE = 10;
export const FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE = 2000;
export const FLDNG_HSTRY_MIN_YEAR = 2002;
/** 침수흔적도 연도 선택 목록과 초기 조회 범위의 최대 연도다. */
export const FLDNG_HSTRY_MAX_YEAR = 2025;
export type FldngHstryDetailCacheValue = {
rainAnalysis: Record<string, unknown>;
@@ -1,3 +1,4 @@
import { DREDGING_EXTERNAL_MODE } from "../model/disclosure";
/** 준설 조회·등록·수정·삭제와 최근접 관로 HTTP 계약을 담당한다. */
import {
deleteData,
@@ -110,12 +111,13 @@ export const fetchDredgingRecords = async (params: {
/** 등록된 준설사진의 공통 첨부파일 참조를 조회한다. */
export const fetchDredgingPhotos = async (
dredgingJobSn: string | number,
pipeFid: string,
pipeFid: string | null,
targetSn?: number,
): Promise<DredgingPhoto[]> => {
if (!isDredgingBackendEnabled) return [];
try {
const body = await getRawJson(
`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}/photos?fid=${encodeURIComponent(pipeFid)}`,
`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}/photos?${targetSn ? `targetSn=${targetSn}` : `fid=${encodeURIComponent(pipeFid ?? "")}`}`,
);
return toDredgingPhotos(body);
} catch (error) {
@@ -137,18 +139,18 @@ export const updateDredgingRecord = async (
try {
await putData(`v1/dredging/records/${encodeURIComponent(dredgingJobSn)}`, {
dredgingDate: update.dredgingDate,
pipeFid: update.originalPipeFid,
targetSn: update.targetSn,
registrationMode: update.registrationMode,
location: {
source: update.locationSource,
longitude: update.location.longitude,
latitude: update.location.latitude,
},
expectedPipe: {
expectedPipe: update.pipe && !DREDGING_EXTERNAL_MODE ? {
fid: update.pipe.fid,
mngInstCd: update.pipe.mngInstCd,
fcltMngNo: update.pipe.fcltMngNo,
},
} : null,
retainedPhotoSns: update.retainedPhotoSns,
newPhotos: update.newPhotos.map((photo) => {
const attachment = attachments.get(photo.key);
@@ -242,7 +244,7 @@ const mockPipeForLocations = (locations: LocationRequest[]): PipeMatch => {
export const findNearestPipeMatches = async (
locations: LocationRequest[],
): Promise<Map<string, PipeMatch>> => {
if (locations.length === 0) return new Map();
if (DREDGING_EXTERNAL_MODE || locations.length === 0) return new Map();
if (!isDredgingBackendEnabled) {
const mockPipe = mockPipeForLocations(locations);
@@ -55,7 +55,7 @@ export const toNearestPipeMatches = (body: any): Map<string, PipeMatch> => {
for (const item of items ?? []) {
if (!item.selected) {
failureMessages.push(nearestPipeFailureMessage(item));
if (item.failureCode !== "PIPE_NOT_FOUND") failureMessages.push(nearestPipeFailureMessage(item));
continue;
}
matches.set(item.locationKey, {
@@ -0,0 +1,2 @@
/** 외부용 앱은 관망 정보를 요청하거나 화면에 표시하지 않는다. */
export const DREDGING_EXTERNAL_MODE = false;
@@ -1,3 +1,4 @@
import { DREDGING_EXTERNAL_MODE } from "./disclosure";
/** 준설 목록·집계 표의 컬럼·표시 변환. 서버 조회/React 상태와 분리한다. */
import type { DredgingRecord } from "./types";
import { MS_SWAGE_PPL_CODES } from "@/entities/facility/model/facility-code-fallbacks";
@@ -103,6 +104,8 @@ export const buildDredgingPopupParams = (record: DredgingRecord) => ({
sourcePath: window.location.pathname,
id: record.id,
dredgingJobSn: record.dredgingJobSn,
targetSn: record.targetSn,
mappingStatus: record.mappingStatus,
pipeFid: record.pipeFid,
mngInstCd: record.mngInstCd,
ctpv: record.ctpv,
@@ -140,7 +143,8 @@ export type TableHeadDefinition = {
align?: "left" | "center" | "right";
};
export const municipalityHeadlist: TableHeadDefinition[] = [
const fullMunicipalityHeadlist: TableHeadDefinition[] = [
{ id: "mappingStatusLabel", title: "관로 지정" },
{ id: "rowNumber", title: "순번" },
{ id: "ctpv", title: "시도" },
{ id: "sgg", title: "시군구" },
@@ -156,6 +160,10 @@ export const municipalityHeadlist: TableHeadDefinition[] = [
{ id: "registeredDate", title: "등록일자" },
];
export const municipalityHeadlist = DREDGING_EXTERNAL_MODE
? fullMunicipalityHeadlist.filter(column => ["rowNumber", "ctpv", "sgg", "dredgingDate", "photoCount", "registeredDate"].includes(column.id))
: fullMunicipalityHeadlist;
export const diameterHeadlist: TableHeadDefinition[] = [
{ id: "ctpv", title: "시도" },
{ id: "totalLengthKm", title: "관로연장(km)", align: "right" },
@@ -202,33 +210,33 @@ export const buildDiameterRows = (records: DredgingRecord[]): TableRow[] =>
roundKm(
items
.filter(predicate)
.reduce((sum, record) => sum + record.pipeLengthM, 0),
.reduce((sum, record) => sum + (record.pipeLengthM ?? 0), 0),
);
return {
ctpv,
totalLengthKm: roundKm(
items.reduce((sum, record) => sum + record.pipeLengthM, 0),
items.reduce((sum, record) => sum + (record.pipeLengthM ?? 0), 0),
),
diameter450: sumLength((record) => record.pipeDiameterMm <= 450),
diameter450: sumLength((record) => (record.pipeDiameterMm ?? 0) <= 450),
diameter600: sumLength(
(record) => record.pipeDiameterMm > 450 && record.pipeDiameterMm <= 600,
(record) => (record.pipeDiameterMm ?? 0) > 450 && (record.pipeDiameterMm ?? 0) <= 600,
),
diameter700: sumLength(
(record) => record.pipeDiameterMm > 600 && record.pipeDiameterMm <= 700,
(record) => (record.pipeDiameterMm ?? 0) > 600 && (record.pipeDiameterMm ?? 0) <= 700,
),
diameter900: sumLength(
(record) => record.pipeDiameterMm > 700 && record.pipeDiameterMm <= 900,
(record) => (record.pipeDiameterMm ?? 0) > 700 && (record.pipeDiameterMm ?? 0) <= 900,
),
diameter1000: sumLength(
(record) =>
record.pipeDiameterMm > 900 && record.pipeDiameterMm <= 1000,
(record.pipeDiameterMm ?? 0) > 900 && (record.pipeDiameterMm ?? 0) <= 1000,
),
diameter1200: sumLength(
(record) =>
record.pipeDiameterMm > 1000 && record.pipeDiameterMm <= 1200,
(record.pipeDiameterMm ?? 0) > 1000 && (record.pipeDiameterMm ?? 0) <= 1200,
),
diameter1500: sumLength((record) => record.pipeDiameterMm >= 1500),
diameter1500: sumLength((record) => (record.pipeDiameterMm ?? 0) >= 1500),
recordIds: items.map((record) => record.id),
};
});
@@ -239,7 +247,7 @@ export const buildElapsedYearRows = (records: DredgingRecord[]): TableRow[] =>
roundKm(
items
.filter(predicate)
.reduce((sum, record) => sum + record.pipeLengthM, 0),
.reduce((sum, record) => sum + (record.pipeLengthM ?? 0), 0),
);
const elapsedYears = (record: DredgingRecord) =>
record.installationYear === null
@@ -249,7 +257,7 @@ export const buildElapsedYearRows = (records: DredgingRecord[]): TableRow[] =>
return {
ctpv,
totalLengthKm: roundKm(
items.reduce((sum, record) => sum + record.pipeLengthM, 0),
items.reduce((sum, record) => sum + (record.pipeLengthM ?? 0), 0),
),
under10: sumLength((record) => {
const elapsed = elapsedYears(record);
@@ -19,11 +19,11 @@ const buildRegistrationBody = (
longitude: group.location.longitude,
latitude: group.location.latitude,
},
expectedPipe: {
expectedPipe: group.pipe ? {
fid: group.pipe.fid,
mngInstCd: group.pipe.mngInstCd,
fcltMngNo: group.pipe.fcltMngNo,
},
} : null,
photos: group.photos.map((photo) => {
const attachment = attachments.get(photo.key);
if (!attachment && requireAttachments) {
+18 -14
View File
@@ -59,32 +59,35 @@ export type DredgingDraftGroup = {
export type DredgingRecord = {
id: string;
dredgingJobSn: number | string;
pipeFid: string;
mngInstCd: string;
targetSn?: number;
mappingStatus?: "AUTO" | "UNMAPPED";
pipeFid: string | null;
mngInstCd: string | null;
ctpv: string;
sgg: string;
treatmentZone: string;
fcltMngNo: string;
pipeDiameterMm: number;
pipeLengthM: number;
pipeMaterial: string;
treatmentZone: string | null;
fcltMngNo: string | null;
pipeDiameterMm: number | null;
pipeLengthM: number | null;
pipeMaterial: string | null;
installationYear: number | null;
dredgingDate: string;
photoCount: number;
registeredDate: string;
longitude: number;
latitude: number;
snappedLongitude: number;
snappedLatitude: number;
snappedLongitude: number | null;
snappedLatitude: number | null;
registrationMode: DredgingRegistrationMode;
registrationSource: DredgingLocationSource;
matchDistanceMeters: number;
matchDistanceMeters: number | null;
isMock: boolean;
};
export type DredgingPhoto = {
targetSn?: number;
photoSn: number;
pipeFid: string;
pipeFid?: string | null;
atflCd: string;
atflId: string;
originalFileName: string;
@@ -97,18 +100,19 @@ export type DredgingSaveGroup = {
dredgingDate: string;
location: GeoPoint;
locationSource: DredgingLocationSource;
pipe: PipeMatch;
pipe: PipeMatch | null;
photos: DredgingPhotoDraft[];
};
/** 상세 기반 수정 팝업에서 선택한 한 관로의 위치·사진을 함께 갱신한다. */
export type DredgingRecordUpdate = {
dredgingDate: string;
originalPipeFid: string;
originalPipeFid: string | null;
targetSn: number;
registrationMode: DredgingRegistrationMode;
location: GeoPoint;
locationSource: DredgingLocationSource;
pipe: PipeMatch;
pipe: PipeMatch | null;
retainedPhotoSns: number[];
newPhotos: DredgingPhotoDraft[];
};
@@ -1,4 +1,6 @@
"use client";
import { DredgingRegistrationModeSelector } from "./dredging-registration-mode-selector";
import { DREDGING_EXTERNAL_MODE } from "../model/disclosure";
import dynamic from "next/dynamic";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
@@ -134,25 +136,25 @@ const DredgingDetailPopupPage = ({
const [statusMessage, setStatusMessage] = useState("");
const uploadedAttachmentsRef = useRef(new Map<string, AttachmentReference>());
const initialMatchedPipe = useMemo<PipeMatch>(
() => ({
const initialMatchedPipe = useMemo<PipeMatch | null>(
() => !record.pipeFid || DREDGING_EXTERNAL_MODE ? null : ({
fid: record.pipeFid,
mngInstCd: record.mngInstCd,
fcltMngNo: record.fcltMngNo,
pipeDiameterMm: record.pipeDiameterMm,
pipeLengthM: record.pipeLengthM,
pipeMaterial: record.pipeMaterial,
mngInstCd: record.mngInstCd ?? "",
fcltMngNo: record.fcltMngNo ?? "",
pipeDiameterMm: record.pipeDiameterMm ?? 0,
pipeLengthM: record.pipeLengthM ?? 0,
pipeMaterial: record.pipeMaterial ?? "",
installationYear: record.installationYear,
swtrmtZoneFid: null,
swtrmtZoneNm: record.treatmentZone,
distanceMeters: record.matchDistanceMeters,
swtrmtZoneNm: record.treatmentZone ?? "",
distanceMeters: record.matchDistanceMeters ?? 0,
snappedLongitude: record.snappedLongitude || record.longitude,
snappedLatitude: record.snappedLatitude || record.latitude,
isMock: record.isMock,
}),
[record],
);
const [matchedPipe, setMatchedPipe] = useState<PipeMatch>(initialMatchedPipe);
const [matchedPipe, setMatchedPipe] = useState<PipeMatch | null>(initialMatchedPipe);
useEffect(() => {
let cancelled = false;
@@ -168,6 +170,7 @@ const DredgingDetailPopupPage = ({
const items = await fetchDredgingPhotos(
record.dredgingJobSn,
record.pipeFid,
record.targetSn,
);
const previews = await Promise.all(
items.map(async (photo): Promise<DredgingPhotoPreview> => {
@@ -214,7 +217,7 @@ const DredgingDetailPopupPage = ({
cancelled = true;
createdUrls.forEach((url) => URL.revokeObjectURL(url));
};
}, [record.dredgingJobSn, record.pipeFid]);
}, [record.dredgingJobSn, record.pipeFid, record.targetSn]);
useEffect(
() => () => {
@@ -258,19 +261,22 @@ const DredgingDetailPopupPage = ({
}
};
const pipeInfo = [
const pipeInfo = DREDGING_EXTERNAL_MODE ? [
["시도", record.ctpv || "확인불가"], ["시군구", record.sgg || "확인불가"],
["위치등록", locationSourceLabels[locationSource] || locationSource],
] : [
["시도", record.ctpv || "확인불가"],
["시군구", record.sgg || "확인불가"],
[
"처리구역",
matchedPipe.swtrmtZoneNm || record.treatmentZone || "확인불가",
matchedPipe?.swtrmtZoneNm || record.treatmentZone || "확인불가",
],
["관경", `${matchedPipe.pipeDiameterMm.toLocaleString()} mm`],
["관로연장", `${matchedPipe.pipeLengthM.toLocaleString()} m`],
["관재질", resolvePipeMaterialName(matchedPipe.pipeMaterial)],
["관경", matchedPipe ? `${matchedPipe.pipeDiameterMm.toLocaleString()} mm` : "미산정"],
["관로연장", matchedPipe ? `${matchedPipe.pipeLengthM.toLocaleString()} m` : "미산정"],
["관재질", matchedPipe ? resolvePipeMaterialName(matchedPipe.pipeMaterial) : "미확인"],
[
"매설년도",
matchedPipe.installationYear
matchedPipe?.installationYear
? `${matchedPipe.installationYear}년`
: "확인불가",
],
@@ -280,7 +286,7 @@ const DredgingDetailPopupPage = ({
{ length: Math.ceil(pipeInfo.length / 2) },
(_, index) => pipeInfo.slice(index * 2, index * 2 + 2),
);
const automaticMappingValue = `${matchedPipe.distanceMeters.toFixed(2)} m`;
const automaticMappingValue = DREDGING_EXTERNAL_MODE ? "위치 등록" : matchedPipe ? `${matchedPipe.distanceMeters.toFixed(2)} m` : "관로 미지정";
const totalPhotoCount = photos.length + newPhotos.length;
const handleNewFiles = async (files: File[] | null) => {
@@ -407,17 +413,12 @@ const DredgingDetailPopupPage = ({
{ locationKey, ...locationDraft },
]);
const nextPipe = matches.get(locationKey);
if (!nextPipe) {
throw new Error(
"50m 이내에 관로가 없습니다. 지도에서 준설위치를 다시 확인해주세요.",
);
}
setLocation(locationDraft);
setLocationSource("MANUAL");
setMatchedPipe(nextPipe);
setMatchedPipe(nextPipe ?? null);
setLocationDraft(null);
setIsEditingLocation(false);
setStatusMessage("변경한 위치와 최근접 관로를 확인했습니다.");
setStatusMessage(DREDGING_EXTERNAL_MODE ? "변경한 준설위치를 적용했습니다." : nextPipe ? "변경한 위치와 최근접 관로를 확인했습니다." : "관로 미지정으로 저장할 수 있습니다.");
} catch (matchFailure) {
setMatchError(
matchFailure instanceof Error
@@ -449,6 +450,8 @@ const DredgingDetailPopupPage = ({
alert("선택한 지도 위치를 먼저 적용해주세요.");
return;
}
if (!record.targetSn) { alert("화면을 새로고침해 준설대상번호를 확인해주세요."); return; }
if (!DREDGING_EXTERNAL_MODE && !matchedPipe && !(await confirm("관로 미지정 상태로 수정 내용을 저장하시겠습니까?"))) return;
setIsSaving(true);
try {
setStatusMessage(
@@ -466,6 +469,7 @@ const DredgingDetailPopupPage = ({
{
dredgingDate,
originalPipeFid: record.pipeFid,
targetSn: record.targetSn,
registrationMode,
location,
locationSource:
@@ -524,10 +528,12 @@ const DredgingDetailPopupPage = ({
<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">
<h2 className="text-2xl font-medium text-label">관로 정보</h2>
<span className="truncate text-base text-muted-foreground">
관로번호 {matchedPipe.fcltMngNo || "확인불가"}
</span>
<h2 className="text-2xl font-medium text-label">{DREDGING_EXTERNAL_MODE ? "준설 정보" : "관로 정보"}</h2>
{!DREDGING_EXTERNAL_MODE && (
<span className="truncate text-base text-muted-foreground">
{`관로번호 ${matchedPipe?.fcltMngNo || "관로 미지정"}`}
</span>
)}
</div>
<div className="p-3">
<Table variant="secondary">
@@ -558,7 +564,7 @@ const DredgingDetailPopupPage = ({
dredgingDate
)}
</TableCell>
<TableHead>자동매핑</TableHead>
<TableHead>{DREDGING_EXTERNAL_MODE ? "위치" : "자동매핑"}</TableHead>
<TableCell
className="truncate border px-4"
title={automaticMappingValue}
@@ -604,34 +610,16 @@ const DredgingDetailPopupPage = ({
className="card card-border !m-0 flex !min-h-[40rem] flex-1 flex-col overflow-hidden !p-0"
data-testid="dredging-detail-photo-panel"
>
<div className="card-header !m-0 shrink-0 gap-2">
<div className="card-header !m-0 shrink-0 flex-wrap gap-2">
<h2 className="text-2xl font-medium text-label">준설 사진</h2>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{editable ? (
<>
<Button
type="button"
size="sm"
color={registrationMode === "PHOTO" ? "primary" : "white"}
aria-pressed={registrationMode === "PHOTO"}
onClick={() => void changeRegistrationMode("PHOTO")}
data-testid="dredging-edit-photo-mode"
>
{registrationModeLabels.PHOTO}
</Button>
<Button
type="button"
size="sm"
color={
registrationMode === "MANUAL" ? "primary" : "white"
}
aria-pressed={registrationMode === "MANUAL"}
onClick={() => void changeRegistrationMode("MANUAL")}
data-testid="dredging-edit-manual-mode"
>
{registrationModeLabels.MANUAL}
</Button>
</>
<DredgingRegistrationModeSelector
value={registrationMode}
labels={registrationModeLabels}
onChange={(mode) => void changeRegistrationMode(mode)}
testIdPrefix="dredging-edit"
/>
) : null}
<span className="text-base text-muted-foreground">
{totalPhotoCount}/5장
@@ -838,13 +826,13 @@ const DredgingDetailPopupPage = ({
<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">
위치 및 관로
{DREDGING_EXTERNAL_MODE ? "준설 위치" : "위치 및 관로"}
</h2>
<p
className={`mt-1 truncate text-sm ${matchError ? "text-rose-700" : "text-muted-foreground"}`}
>
자동 연결 관로 · {matchedPipe.fcltMngNo} ·{" "}
{matchError ?? `${matchedPipe.distanceMeters.toFixed(2)}m`}
{DREDGING_EXTERNAL_MODE ? "배경지도에서 실제 준설 위치를 지정해주세요." :
matchError ?? (matchedPipe ? `자동 연결 관로 · ${matchedPipe.fcltMngNo} · ${matchedPipe.distanceMeters.toFixed(2)}m` : "관로 미지정")}
</p>
</div>
<div className="flex shrink-0 gap-2">
@@ -883,7 +871,7 @@ const DredgingDetailPopupPage = ({
data-testid="dredging-edit-status"
>
{statusMessage ||
"준설일자·사진·위치와 자동 연결 관로를 확인해주세요."}
(DREDGING_EXTERNAL_MODE ? "준설일자·사진·위치를 확인해주세요." : "준설일자·사진·위치와 자동 연결 관로를 확인해주세요.")}
</p>
</div>
</section>
@@ -914,8 +902,8 @@ const DredgingDetailPopupPage = ({
isEditingLocation
? "지도에서 변경할 준설위치를 클릭한 뒤 선택 위치 적용을 눌러주세요."
: editable
? "현재 준설위치와 자동 연결된 관로를 표시하고 있습니다."
: "준설위치와 자동 연결된 관로를 표시하고 있습니다."
? (DREDGING_EXTERNAL_MODE ? "현재 준설위치를 표시하고 있습니다." : "현재 준설위치와 자동 연결된 관로를 표시하고 있습니다.")
: (DREDGING_EXTERNAL_MODE ? "준설위치를 표시하고 있습니다." : "준설위치와 자동 연결된 관로를 표시하고 있습니다.")
}
height="h-full min-h-0"
/>
@@ -6,6 +6,7 @@
* - 지자체별/관경별/경과년도별 조회와 XLSX 다운로드 제공
*/
"use client";
import { DREDGING_EXTERNAL_MODE } from "../model/disclosure";
import { useFacilityCodeCatalog } from "@/entities/facility/lib/use-facility-code-catalog";
import { useDredgingListData } from "../lib/use-dredging-list-data";
import {
@@ -187,8 +188,8 @@ const Page = () => {
return filteredRecords.map((record, index) => ({
rowNumber: index + 1,
...record,
pipeLengthM: Number(record.pipeLengthM.toFixed(2)),
pipeMaterial: resolvePipeMaterialName(record.pipeMaterial, materialNames),
pipeLengthM: Number((record.pipeLengthM ?? 0).toFixed(2)),
pipeMaterial: resolvePipeMaterialName(record.pipeMaterial ?? "", materialNames),
installationYear: record.installationYear ?? "확인불가",
elapsedYears:
record.installationYear === null
@@ -372,27 +373,28 @@ const Page = () => {
const addedRecords = savedGroups.map<DredgingRecord>((group, index) => ({
id: `${localJobSn}-${index + 1}`,
dredgingJobSn: localJobSn,
pipeFid: group.pipe.fid,
mngInstCd: group.pipe.mngInstCd,
targetSn: index + 1,
pipeFid: group.pipe?.fid ?? null,
mngInstCd: group.pipe?.mngInstCd ?? null,
ctpv: ctpvName || "경기도",
sgg: sggName || "양평군",
treatmentZone: group.pipe.swtrmtZoneNm || "확인불가",
fcltMngNo: group.pipe.fcltMngNo,
pipeDiameterMm: group.pipe.pipeDiameterMm,
pipeLengthM: group.pipe.pipeLengthM,
pipeMaterial: group.pipe.pipeMaterial,
installationYear: group.pipe.installationYear,
treatmentZone: group.pipe?.swtrmtZoneNm || "확인불가",
fcltMngNo: group.pipe?.fcltMngNo ?? null,
pipeDiameterMm: group.pipe?.pipeDiameterMm ?? null,
pipeLengthM: group.pipe?.pipeLengthM ?? null,
pipeMaterial: group.pipe?.pipeMaterial ?? null,
installationYear: group.pipe?.installationYear ?? null,
dredgingDate: group.dredgingDate,
photoCount: group.photos.length,
registeredDate,
longitude: group.location.longitude,
latitude: group.location.latitude,
snappedLongitude: group.pipe.snappedLongitude,
snappedLatitude: group.pipe.snappedLatitude,
snappedLongitude: group.pipe?.snappedLongitude ?? null,
snappedLatitude: group.pipe?.snappedLatitude ?? null,
registrationMode: group.registrationMode,
registrationSource: group.locationSource,
matchDistanceMeters: group.pipe.distanceMeters,
isMock: group.pipe.isMock || !isDredgingBackendEnabled,
matchDistanceMeters: group.pipe?.distanceMeters ?? null,
isMock: Boolean(group.pipe?.isMock) || !isDredgingBackendEnabled,
}));
prependDemoRecords(addedRecords);
setCurrentPage(1);
@@ -536,8 +538,8 @@ const Page = () => {
</SelectTrigger>
<SelectContent>
<SelectItem value="MUNICIPALITY">지자체별</SelectItem>
<SelectItem value="DIAMETER">관경별</SelectItem>
<SelectItem value="ELAPSED_YEAR">경과년도별</SelectItem>
{!DREDGING_EXTERNAL_MODE && (<SelectItem value="DIAMETER">관경별</SelectItem>)}
{!DREDGING_EXTERNAL_MODE && (<SelectItem value="ELAPSED_YEAR">경과년도별</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -1,4 +1,5 @@
"use client";
import { DREDGING_EXTERNAL_MODE } from "../model/disclosure";
import Feature from "ol/Feature.js";
import type { FeatureLike } from "ol/Feature.js";
@@ -265,6 +266,12 @@ const LocationInteraction = ({
previousValueRef.current = value;
}, [gisMap, interactive, value?.latitude, value?.longitude]);
if (DREDGING_EXTERNAL_MODE) {
return <div className="rounded-sm border border-border bg-background px-2 py-1.5 text-xs text-label shadow">
{interactive ? "지도를 클릭해 준설위치를 선택하세요." : "등록된 준설위치를 확인합니다."}
</div>;
}
const pipeStatusText = (() => {
if (pipeLayerSettings.length === 0)
return "관망도 레이어 설정을 불러오는 중입니다.";
@@ -329,6 +336,7 @@ const DredgingLocationMap = ({
);
useEffect(() => {
if (DREDGING_EXTERNAL_MODE) return; // 외부에서는 관망 레이어 설정과 도형을 요청하지 않는다.
let cancelled = false;
const load = async () => {
try {
@@ -370,8 +378,8 @@ const DredgingLocationMap = ({
value={value}
onChange={onChange}
interactive={interactive}
matchedPipe={matchedPipe}
pipeLayerSettings={pipeLayerSettings}
matchedPipe={DREDGING_EXTERNAL_MODE ? null : matchedPipe}
pipeLayerSettings={DREDGING_EXTERNAL_MODE ? [] : pipeLayerSettings}
/>
}
defaultZoom={value ? 17 : 7.5}
@@ -1,8 +1,10 @@
"use client";
import { DREDGING_EXTERNAL_MODE } from "../model/disclosure";
/**
* 화면명: 준설실적 등록
* 준설 사진을 등록하고 촬영정보·위치·인접 관로를 확인하여 준설실적을 저장하는 화면이다.
*/
"use client";
import dynamic from "next/dynamic";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
@@ -196,7 +198,9 @@ const DredgingRegisterModal = ({
try {
const matches = await findNearestPipeMatches(locations);
if (matches.size !== locations.length) {
throw new Error("탐색거리 안에서 관로를 찾지 못한 위치가 있습니다.");
commitGroups(current => current.map(item => item.id === group.id
? { ...item, matchedPipe: null, isMatching: false, matchError: DREDGING_EXTERNAL_MODE ? null : "관로 미지정으로 저장할 수 있습니다." } : item));
return;
}
const matchedPipes = Array.from(matches.values());
@@ -453,18 +457,15 @@ const DredgingRegisterModal = ({
if (!location) {
throw new Error(`${label}: 지도에서 준설위치를 등록해주세요.`);
}
if (!group.matchedPipe) {
throw new Error(
`${label}: ${group.matchError ?? "최근접 관로 자동 매핑을 완료해주세요."}`,
);
}
if (group.matchError?.startsWith("사진 위치가 서로 다른 관로")) throw new Error(group.matchError);
if (group.isMatching) throw new Error(`${label}: 위치 확인이 끝난 뒤 저장해주세요.`);
saveGroups.push({
localId: group.id,
registrationMode: group.registrationMode,
dredgingDate: representativeDredgingDate,
location,
locationSource: group.manualLocation ? "MANUAL" : "EXIF",
pipe: group.matchedPipe,
pipe: DREDGING_EXTERNAL_MODE ? null : group.matchedPipe,
photos: group.photos,
});
}
@@ -476,6 +477,8 @@ const DredgingRegisterModal = ({
if (savingRef.current) return;
try {
const saveGroups = validateAndBuildSaveGroups();
if (!DREDGING_EXTERNAL_MODE && saveGroups.some(group => !group.pipe)
&& !(await confirm("관로가 지정되지 않은 대상이 있습니다. 사진과 위치를 저장하시겠습니까?"))) return;
savingRef.current = true;
setIsSaving(true);
setStatusMessage(
@@ -491,7 +494,7 @@ const DredgingRegisterModal = ({
uploadedAttachmentsRef.current = attachments;
setStatusMessage(
isDredgingBackendEnabled
? "준설 결과와 관로 매핑을 저장하고 있습니다."
? (DREDGING_EXTERNAL_MODE ? "준설 결과를 저장하고 있습니다." : "준설 결과와 관로 매핑을 저장하고 있습니다.")
: "준설 결과 등록을 완료했습니다.",
);
const session =
@@ -1,4 +1,7 @@
"use client";
import { DredgingRegistrationModeSelector } from "./dredging-registration-mode-selector";
import { DREDGING_EXTERNAL_MODE } from "../model/disclosure";
import type { ComponentType } from "react";
import { FileUpload } from "@common/business_components/ui/file-upload";
import {
@@ -96,7 +99,7 @@ export const DredgingRegistrationBody = ({
>
<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>{DREDGING_EXTERNAL_MODE ? "등록 건별" : "관로별"} 사진은 최대 5장까지 등록할 수 있습니다.</span>
<span>
모든 등록 건은 하나의 준설작업이며 동일한 준설일자를 사용합니다.
</span>
@@ -114,7 +117,7 @@ export const DredgingRegistrationBody = ({
<div className="card-header !m-0">
<h3 className="text-2xl font-medium text-label">등록 정보</h3>
<Button type="button" size="sm" onClick={addGroup}>
관로 추가
{DREDGING_EXTERNAL_MODE ? "등록 건 추가" : "관로 추가"}
</Button>
</div>
<div className="p-3">
@@ -151,7 +154,7 @@ export const DredgingRegistrationBody = ({
</TableCell>
</TableRow>
<TableRow>
<TableHead>등록 관로</TableHead>
<TableHead>{DREDGING_EXTERNAL_MODE ? "등록 대상" : "등록 관로"}</TableHead>
<TableCell className="border px-4">
<div
className="flex flex-wrap items-center gap-2"
@@ -237,38 +240,12 @@ export const DredgingRegistrationBody = ({
촬영일과 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>
<DredgingRegistrationModeSelector
value={activeGroup.registrationMode}
labels={registrationModeLabels}
onChange={(mode) => void changeRegistrationMode(activeGroup.id, mode)}
testIdPrefix="dredging-register"
/>
</div>
<div className="flex min-h-0 flex-1 flex-col p-3">
{activeGroup.registrationMode === "PHOTO" ? (
@@ -379,7 +356,7 @@ export const DredgingRegistrationBody = ({
<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>
<h3 className="text-2xl font-medium text-label">{DREDGING_EXTERNAL_MODE ? "준설 위치" : "위치 및 관로"}</h3>
<div className="flex shrink-0 flex-wrap justify-end gap-1">
{isEditingActiveLocation ? (
<>
@@ -446,8 +423,9 @@ export const DredgingRegistrationBody = ({
</span>
</TableCell>
</TableRow>
{!DREDGING_EXTERNAL_MODE && (
<TableRow>
<TableHead data-required>자동 연결 관로</TableHead>
<TableHead>자동 연결 관로</TableHead>
<TableCell className="border px-4">
<strong className="mr-3 text-foreground">
{activeGroup.isMatching
@@ -473,7 +451,7 @@ export const DredgingRegistrationBody = ({
"위치를 지정하면 50m 이내 최근접 관로를 자동으로 연결합니다."}
</span>
</TableCell>
</TableRow>
</TableRow>)}
</TableBody>
</Table>
</div>
@@ -484,7 +462,7 @@ export const DredgingRegistrationBody = ({
data-testid="dredging-registration-status"
>
{statusMessage ||
"사진·준설일자·위치와 자동 연결 관로를 확인해주세요."}
(DREDGING_EXTERNAL_MODE ? "사진·준설일자·준설위치를 확인해주세요." : "사진·준설일자·위치를 확인해주세요. 관로 미지정 상태도 저장할 수 있습니다.")}
</div>
</aside>
@@ -504,7 +482,7 @@ export const DredgingRegistrationBody = ({
statusText={
isEditingActiveLocation
? hasUnappliedLocationDraft
? "새 위치를 선택했습니다. 관로 다시 찾기를 눌러 적용해주세요."
? (DREDGING_EXTERNAL_MODE ? "새 위치를 선택했습니다. 선택 위치 적용을 눌러주세요." : "새 위치를 선택했습니다. 관로 다시 찾기를 눌러 적용해주세요.")
: "지도에서 새 준설위치를 클릭해주세요."
: activeResolvedLocation
? "등록된 준설위치를 표시하고 있습니다."
@@ -0,0 +1,38 @@
"use client";
import { useId } from "react";
import type { DredgingRegistrationMode } from "../model/types";
type Props = {
value: DredgingRegistrationMode;
labels: Record<string, string>;
onChange: (mode: DredgingRegistrationMode) => void;
testIdPrefix: string;
};
/** 업로드 실행 버튼과 구분되는 등록 방식 선택. 기존 전환 처리는 호출 화면이 담당한다. */
export const DredgingRegistrationModeSelector = ({
value, labels, onChange, testIdPrefix,
}: Props) => {
const name = useId();
return (
<fieldset className="m-0 flex flex-wrap items-center gap-x-4 gap-y-2 border-0 p-0">
<legend className="sr-only">등록 방식</legend>
{(["PHOTO", "MANUAL"] as const).map((mode) => (
<label key={mode} className="inline-flex cursor-pointer items-center gap-2 py-1 text-sm text-label">
<input
type="radio"
name={name}
value={mode}
checked={value === mode}
onChange={() => onChange(mode)}
className="h-4 w-4 shrink-0 cursor-pointer accent-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
data-testid={`${testIdPrefix}-${mode.toLowerCase()}-mode`}
/>
<span className={value === mode ? "font-medium text-foreground" : ""}>{labels[mode]}</span>
</label>
))}
</fieldset>
);
};
@@ -341,9 +341,10 @@ export const downloadExmnBusinessReport = async (
export const downloadExmnPipeReport = async (
exmnBizId: string,
pipeNumber: string,
jobType: "PPL" | "MNHL" = "PPL",
): Promise<Blob> =>
getBlobData(
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/pipeReports/${encodeURIComponent(pipeNumber)}`,
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/${jobType === "MNHL" ? "manholeReports" : "pipeReports"}/${encodeURIComponent(pipeNumber)}`,
);
/** 사업 파일별 첨부서비스 키 쌍을 조사사업에 연결한다. */
@@ -351,9 +352,10 @@ export const downloadExmnPipeReport = async (
export const getExmnPipeReportAvailability = async (
exmnBizId: string,
pipeNumber: string,
jobType: "PPL" | "MNHL" = "PPL",
): Promise<ExmnPipeReportAvailability> =>
getData(
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/pipeReports/${encodeURIComponent(pipeNumber)}/availability`,
`${EXMN_BUSINESS_API_PATH}/${encodeURIComponent(exmnBizId)}/${jobType === "MNHL" ? "manholeReports" : "pipeReports"}/${encodeURIComponent(pipeNumber)}/availability`,
) as Promise<ExmnPipeReportAvailability>;
export const updateExmnAttachments = async (
@@ -9,12 +9,12 @@ export const getExmnTemplatePrerequisiteMessage = (
detail: Pick<ExmnBusinessDetail, "districts">,
): string | null => {
if (!detail.districts?.length) {
return "조사관리에서 해당 사업 행을 선택하고 ‘수정’을 눌러 처리분구를 등록한 뒤 ‘조사양식 받기’를 실행해 주세요. 이후 결과등록을 다시 진행해 주세요.";
return "조사사업에서 해당 사업 행을 선택하고 ‘수정’을 눌러 처리분구를 등록한 뒤 ‘조사양식 받기’를 실행해 주세요. 이후 결과등록을 다시 진행해 주세요.";
}
if (detail.districts.some(district =>
!district.pplExmnSheetNm?.trim() || !district.mnhlExmnSheetNm?.trim(),
)) {
return "조사관리에서 해당 사업 행 선택 → ‘수정’ → ‘조사양식 받기’를 먼저 실행한 후 결과등록을 다시 진행해 주세요.";
return "조사사업에서 해당 사업 행 선택 → ‘수정’ → ‘조사양식 받기’를 먼저 실행한 후 결과등록을 다시 진행해 주세요.";
}
return null;
};
@@ -287,7 +287,7 @@ export function ExmnRegistrationPage() {
if (!popup) {
setNotice({
type: "error",
text: "사업등록 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.",
text: "조사사업 등록 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.",
});
}
};
@@ -385,7 +385,7 @@ export function ExmnRegistrationPage() {
if (refreshed)
setNotice({
type: "success",
text: "조사결과 최종 저장과 PPL/MNHL 집계를 완료했습니다.",
text: "조사결과 저장을 완료했습니다.",
});
});
}
@@ -660,7 +660,7 @@ export function ExmnRegistrationPage() {
<div className="mt-4 min-w-0">
<StandardDataTable
table={table}
title="조사 목록"
title="조사사업 목록"
className={EXMN_TABLE_HEADER_CLASS}
scrollMode="horizontal"
minTableWidth="110rem"
@@ -673,7 +673,7 @@ export function ExmnRegistrationPage() {
disabled={isLoading}
onClick={openBusinessRegister}
>
사업등록
조사사업등록
</Button>
<Button
type="button"
@@ -71,7 +71,7 @@ const VALIDATION_CHECK_GUIDES: Partial<
},
MNHL_EXMN_PDF: {
checkedItems: ["PDF 파일 형식", "조사결과 Excel 구조 및 저장 가능 여부"],
pendingNote: "PDF 내부 맨홀번호 대조는 검토 기준 협의 후 적용 예정입니다.",
pendingNote: "맨홀보고서의 맨홀번호에는 실제 GIS 맨홀번호를 입력해주세요. 보고서 열람 시 번호를 대조하며, 업로드 검토에는 PDF 내부 번호 대조가 포함되지 않습니다.",
},
};
@@ -179,6 +179,9 @@ export function ExmnResultRegisterPopupPage({
const [isLoading, setIsLoading] = useState(false);
const [notice, setNotice] = useState<Notice>(null);
/** 맨홀 PDF 하나만 선택한 경우 기존 조사자료의 첨부 교체로 처리한다. */
const isManholeOnly = Boolean(files.MNHL_EXMN_PDF) && Object.keys(files).length === 1;
/** popup layout 상위 html/body에서 생기는 문서 스크롤바를 팝업이 열린 동안 잠근다. */
useEffect(() => {
const previousHtmlOverflow = document.documentElement.style.overflow;
@@ -310,7 +313,9 @@ export function ExmnResultRegisterPopupPage({
const fileMap = Object.fromEntries(
Object.entries(files).map(([type, selected]) => [type, selected?.file]),
) as Partial<Record<ExmnFileType, File>>;
const validationMessage = validateExmnFiles(fileMap);
const validationMessage = isManholeOnly
? validateExmnFile("MNHL_EXMN_PDF", files.MNHL_EXMN_PDF!.file)
: validateExmnFiles(fileMap);
if (validationMessage) {
setFileValidationStatuses((current) => ({
...current,
@@ -373,22 +378,22 @@ export function ExmnResultRegisterPopupPage({
/** 정상 검토된 조사결과 Excel을 저장하고 부모 화면에 완료 메시지를 보낸 뒤 팝업을 닫는다. */
const handleCommit = async () => {
const excelValidation = validationResults.EXMN_EXCEL;
if (
!excelValidation?.valid ||
fileValidationStatuses.EXMN_EXCEL !== "success" ||
fileValidationStatuses.PPL_EXMN_PDF !== "success"
)
return;
if (!canCommit) return;
setIsLoading(true);
setNotice(null);
try {
if (!(await ensureTemplateReady())) return;
if (!isManholeOnly && !(await ensureTemplateReady())) return;
// 로딩 화면이 저장 확인창을 가리지 않도록 사전 확인과 업로드 상태를 나눈다.
setIsLoading(false);
if (!(await confirm("조사결과를 저장하시겠습니까?"))) return;
setIsLoading(true);
if (isManholeOnly) {
// 검토 이후 기존 조사자료가 변경됐을 수 있어 첨부 업로드 직전에 다시 대조한다.
const result = await validateExmnImport(exmnBizId, buildImportFormData(files, null, "MNHL_EXMN_PDF"));
if (!result.valid) throw new Error(result.errors.map((item) => item.message).join("\n"));
}
await uploadAndLinkAttachments();
await commitExmnImport(
if (!isManholeOnly) await commitExmnImport(
exmnBizId,
buildImportFormData(files, excelValidation),
);
@@ -418,6 +423,11 @@ export function ExmnResultRegisterPopupPage({
(fileType) => fileValidationStatuses[fileType],
);
const excelValidation = validationResults.EXMN_EXCEL;
/** 단독 첨부 교체와 신규 Excel 결과 저장의 필수 검토 조건을 분리한다. */
const canCommit = isManholeOnly
? fileValidationStatuses.MNHL_EXMN_PDF === "success" && Boolean(validationResults.MNHL_EXMN_PDF?.valid)
: Boolean(excelValidation?.valid) && fileValidationStatuses.EXMN_EXCEL === "success"
&& fileValidationStatuses.PPL_EXMN_PDF === "success";
return (
<StandardPopupLayout
@@ -429,10 +439,7 @@ export function ExmnResultRegisterPopupPage({
type="button"
color="primary"
disabled={
isLoading ||
!excelValidation?.valid ||
fileValidationStatuses.EXMN_EXCEL !== "success" ||
fileValidationStatuses.PPL_EXMN_PDF !== "success"
isLoading || !canCommit
}
onClick={() => void handleCommit()}
>
@@ -614,7 +621,9 @@ export function ExmnResultRegisterPopupPage({
const status = fileValidationStatuses[fileType]!;
const result = validationResults[fileType];
const messages = fileValidationMessages[fileType] ?? [];
const guide = VALIDATION_CHECK_GUIDES[fileType];
const guide = fileType === "MNHL_EXMN_PDF" && isManholeOnly
? { checkedItems: ["PDF 맨홀번호 판독", "기존 조사자료에 연결된 실제 GIS 맨홀번호와 대조"], pendingNote: "저장 시 기존 조사자료는 유지하고 맨홀 보고서만 교체합니다." }
: VALIDATION_CHECK_GUIDES[fileType];
const countText =
fileType === "EXMN_EXCEL"
? `관로 ${result?.summary.pplCount ?? 0}건 · 맨홀 ${result?.summary.mnhlCount ?? 0}건`
@@ -43,7 +43,7 @@ const formatValue = (value: unknown) => {
: String(value);
};
/** 조사관리 표를 CS 목록 규격과 페이지 연속 오름차순 순번으로 연결한다. */
/** 조사사업 표를 CS 목록 규격과 페이지 연속 오름차순 순번으로 연결한다. */
export function ExmnStandardDataTable<T extends Record<string, unknown>>({
rows,
headlist,
@@ -52,21 +52,22 @@ export default function ExmnSurveyResultSection({
const alert = useAlertStore((state) => state.setMessage);
const exmnBizId = readFeatureProperty(featureData, ["exmnBizId"]);
const jobType = readFeatureProperty(featureData, ["jobType"]);
const pipeNumber = readFeatureProperty(featureData, [
"gisManagementNo",
"fclt_mng_no",
"fcltMngNo",
]);
// 맨홀은 조사 관로번호가 아니라 지도 원천의 실제 시설관리번호로 보고서를 조회한다.
const pipeNumber = readFeatureProperty(featureData, jobType === "MNHL"
? ["fclt_mng_no", "fcltMngNo"]
: ["gisManagementNo", "fclt_mng_no", "fcltMngNo"]);
const reportAvailable = readBooleanFeatureProperty(featureData, [
"reportAvailable",
]);
/** 지도는 상류맨홀 위치를 유지하고 맨홀 보고서는 실제 GIS 맨홀번호로 연결한다. */
const reportLabel = jobType === "MNHL" ? "맨홀" : "관로";
const canOpenPipeReport =
jobType === "PPL" && Boolean(exmnBizId && pipeNumber);
(jobType === "PPL" || jobType === "MNHL") && Boolean(exmnBizId && pipeNumber);
const openPipeReport = async () => {
if (!canOpenPipeReport) return;
if (!reportAvailable) {
alert("등록된 관로 조사보고서가 없습니다.");
alert(`등록된 ${reportLabel} 조사보고서가 없습니다.`);
return;
}
@@ -74,11 +75,12 @@ export default function ExmnSurveyResultSection({
const availability = await getExmnPipeReportAvailability(
exmnBizId,
pipeNumber,
jobType === "MNHL" ? "MNHL" : "PPL",
);
if (!availability.available) {
alert(
availability.message?.trim() ||
`선택한 관로번호의 조사보고서가 등록된 PDF에 없습니다: ${pipeNumber}`,
`선택한 GIS 번호의 조사보고서가 등록된 PDF에 없습니다: ${pipeNumber}`,
);
return;
}
@@ -86,6 +88,7 @@ export default function ExmnSurveyResultSection({
const reportWindow = openWindowPopup("exmnPipeReport", {
exmnBizId,
pipeNumber,
jobType,
reportNo: readFeatureProperty(featureData, ["reportNo"]),
});
if (!reportWindow) {
@@ -97,7 +100,7 @@ export default function ExmnSurveyResultSection({
alert(
error instanceof Error && error.message
? error.message
: "관로 조사보고서 열람 가능 여부를 확인하지 못했습니다.",
: `${reportLabel} 조사보고서 열람 가능 여부를 확인하지 못했습니다.`,
);
}
};
@@ -107,6 +110,7 @@ export default function ExmnSurveyResultSection({
["사업차수", readFeatureProperty(featureData, ["bizCycl"])],
["처리분구", readFeatureProperty(featureData, ["trtdstNm"])],
["조사유형", jobType === "MNHL" ? "맨홀조사" : "관로조사"],
[jobType === "MNHL" ? "GIS 맨홀번호" : "GIS 관로번호", pipeNumber],
["보고서번호", readFeatureProperty(featureData, ["reportNo"])],
["구조적결함 등급", readFeatureProperty(featureData, ["structuralGrade"])],
["운영적결함 등급", readFeatureProperty(featureData, ["operationalGrade"])],
@@ -116,7 +120,7 @@ export default function ExmnSurveyResultSection({
<section className="border-b border-border bg-background">
<div className="card-header flex items-center justify-between">
<h3 className="mx-3 text-2xl font-medium text-label">조사 결과</h3>
{jobType === "PPL" && (
{(jobType === "PPL" || jobType === "MNHL") && (
<Button
onClick={openPipeReport}
disabled={!canOpenPipeReport}
@@ -130,7 +130,7 @@ const toSavedTableRows = (
const getErrorMessage = (error: unknown, fallback: string) =>
error instanceof Error && error.message.trim() ? error.message : fallback;
/** 사업등록 후보로 사용할 처리분구 기준정보를 직접 관리하는 FE-0 화면이다. */
/** 조사사업 등록 후보로 사용할 처리분구 기준정보를 직접 관리하는 FE-0 화면이다. */
export function TrtdstManagementPage() {
const { ctpv, sgg } = useStore();
const { confirm } = useConfirm();
@@ -766,7 +766,7 @@ const FeatureClickEventPopup = ({
<div className="min-w-0">
{surveyMode && (
<h3 className="break-words text-2xl font-medium text-primary-foreground">
조사관리 <br />
조사사업 <br />
관로번호 :{" "}
{featureData?.get?.("gisManagementNo") ??
featureData?.values_?.gisManagementNo ??
@@ -81,7 +81,7 @@ const MsSwagePpl = ({
const requestFcltMngNo = String(
featureData?.fclt_mng_no ?? featureData?.fcltMngNo ?? "",
).trim();
// 조사관리 전용 탭은 기본·입력정보와 조사결과만 표시하고 일반 시설 유지관리 이력은 숨긴다.
// 조사사업 전용 탭은 기본·입력정보와 조사결과만 표시하고 일반 시설 유지관리 이력은 숨긴다.
const showOperationSections = !surveyResultContent;
// 마운트 및 업데이트 시 API 호출
@@ -17,7 +17,7 @@ const HEADERS: Record<string, string> = {
ctpvNm: "시도",
sggNm: "시군구",
analysisDateTime: "분석시각",
rainBeginningDateTime: "강우 시작시각",
rainBeginningDateTime: "강우시작시각",
observedAccumulatedRainfall: "관측 누적강수량(mm)",
predictionHour: "예측시간",
predictedAccumulatedRainfall: "예측 누적강수량(mm)",
@@ -7,7 +7,7 @@ import Overlay from "ol/Overlay.js";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { getRawJson } from "@/shared/api/sewerage-api";
import { getData } from "@/shared/api/sewerage-api";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
import { getFloodRiskGradeClassName } from "./flood-risk-grade";
@@ -251,11 +251,11 @@ export const useFloodPredictionMap = ({
removeFloodPredictionOverlay();
try {
const response = await getRawJson(
// 공통 응답의 data만 읽어 null·생략된 값을 빈 오버레이 결과로 처리한다.
const payload = await getData(
`/sewerage-service/api/v1/fldng/predc/prst/gridMap?sggCd=${encodeURIComponent(sggCd)}&analysisDateTime=${encodeURIComponent(analysisDateTime)}`,
);
if (!navigation?.isCurrent()) return;
const payload = response?.data ?? response;
const row =
payload && typeof payload === "object" && !Array.isArray(payload)
? (payload as FloodPredictionOverlayRow)
@@ -14,6 +14,7 @@ import LoadingOverlay from "@/components/ui/loading-bar";
import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table";
import PopupFooterNote from "@/features/window-popup/ui/popup-footer-note";
import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel";
import { useAlertStore } from "@/shared/lib";
import useStore from "@/entities/reference-data/model/reference-data-store";
@@ -135,6 +136,16 @@ const buildInitialFilters = (): Filters => ({
const apiDate = (value: string) => value.replace(/[^0-9]/g, "").slice(0, 12);
/**
* 검색 시각 입력(`datetime-local`, `step=3600`)의 분을 00으로 맞춘다.
* 브라우저는 값의 분이 00이면 분 칸을 고정하지만, 빈 값에서 처음 입력하거나 직접 입력해 분이 들어온 경우는
* 스스로 고치지 않으므로 저장 전에 시간 단위로 내린다. 형식이 아니면(빈 값 포함) 그대로 둔다.
*/
const toHourInputValue = (value: string) =>
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value)
? `${value.slice(0, 13)}:00`
: value;
const displayDate = (value: string) =>
/^\d{12}$/.test(value)
? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)} ${value.slice(8, 10)}:${value.slice(10, 12)}`
@@ -184,7 +195,7 @@ const validateFilters = (filters: Filters) => {
filters.rainBgngTo &&
filters.rainBgngFrom > filters.rainBgngTo
) {
return "강우 시작시각의 시작은 종료보다 늦을 수 없습니다.";
return "강우시각의 시작은 종료보다 늦을 수 없습니다.";
}
if (
filters.analysisFrom &&
@@ -367,6 +378,11 @@ export default function FldngPredcPrstPopupContent() {
const extentData = await getData(
`/sewerage-service/api/v1/fldng/floodDngrMap/group/extent?${params.toString()}`,
);
// null·생략된 data는 정상적인 위치 부재이며 부모 지도 이동·강조를 요청하지 않는다.
if (extentData == null) {
alert("해당 도시침수지도가 없습니다.");
return;
}
const extent = [
extentData?.minX,
extentData?.minY,
@@ -374,7 +390,7 @@ export default function FldngPredcPrstPopupContent() {
extentData?.maxY,
].map(Number);
if (extent.some((value) => !Number.isFinite(value))) {
throw new Error("도시침수지도 위치 정보가 없습니다.");
throw new Error("도시침수지도 위치 응답의 좌표가 올바르지 않습니다.");
}
window.opener.postMessage(
{
@@ -472,6 +488,11 @@ export default function FldngPredcPrstPopupContent() {
}
};
/**
* 분석시각·강우시각 범위를 시간 단위로만 고르게 그린다.
* `step=3600`이라 값의 분이 00이면 브라우저가 분 칸을 고정해 바꿀 수 없고, 분이 들어온 경우는 저장 전에 00으로 내린다.
* 침수예측 자료의 분석·강우시작 시각은 정시 단위이므로 시간 단위 조건으로 빠지는 자료는 없다.
*/
const renderDateTimeRange = (
label: string,
fromKey: "rainBgngFrom" | "analysisFrom",
@@ -484,27 +505,31 @@ export default function FldngPredcPrstPopupContent() {
<input
className="h-[3.2rem] w-[17rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
type="datetime-local"
step={3600}
value={filters[fromKey]}
aria-label={`${label} 시작`}
onChange={(event) =>
onChange={(event) => {
const nextValue = toHourInputValue(event.target.value);
setFilters((current) => ({
...current,
[fromKey]: event.target.value,
}))
}
[fromKey]: nextValue,
}));
}}
/>
<span className="text-xl font-medium text-label">~</span>
<input
className="h-[3.2rem] w-[17rem] rounded-md border border-input bg-field px-3 text-xl text-foreground outline-none focus:ring-2 focus:ring-ring"
type="datetime-local"
step={3600}
value={filters[toKey]}
aria-label={`${label} 종료`}
onChange={(event) =>
onChange={(event) => {
const nextValue = toHourInputValue(event.target.value);
setFilters((current) => ({
...current,
[toKey]: event.target.value,
}))
}
[toKey]: nextValue,
}));
}}
/>
</label>
);
@@ -662,17 +687,22 @@ export default function FldngPredcPrstPopupContent() {
}
/>
<div className="space-y-1 px-1 text-xl text-muted-foreground">
<p>
30년빈도 이상인 경우{" "}
<span className="font-semibold text-[#dc2626]">위험</span>, 10년빈도
이상인 경우{" "}
<span className="font-semibold text-[#f97316]">경계</span>, 2년빈도
이상인 경우{" "}
<span className="font-semibold text-[#ca8a04]">주의</span>
</p>
<p>행을 선택하면 해당 시군구·빈도의 도시침수지도로 이동합니다.</p>
</div>
{/* 판정 기준·행 선택 안내에 인수테스트 요구(자료 원천·분석 주기 표시) 문구를 글머리표로 더한다. */}
<PopupFooterNote
items={[
<>
30년빈도 이상인 경우{" "}
<span className="font-semibold text-[#dc2626]">위험</span>,
10년빈도 이상인 경우{" "}
<span className="font-semibold text-[#f97316]">경계</span>,
2년빈도 이상인 경우{" "}
<span className="font-semibold text-[#ca8a04]">주의</span>
</>,
"행을 선택하면 해당 시군구·빈도의 도시침수지도로 이동합니다.",
"분석 실행 시기 : 침수예측은 관측강우가 10mm이상으로 강우가 발생시 1시간 주기로 분석 자동실행",
"분석 방법 : 기상청 관측강우와 향후 6시간 예측강우를 누적하여 지속시간별 빈도별 확률강우량(출처 : map.wamis.go.kr)과 근접한 강우량을 찾아 해당되는 빈도의 도시침수지도(출처 : 한강홍수통제소)를 표출",
]}
/>
<PrbbtRnflDialog
open={probabilityRow !== null}
onOpenChange={handleProbabilityOpenChange}
@@ -39,7 +39,7 @@ export const windowPopupDefinitions = {
title: "준설결과 수정",
width: 1280,
height: 900,
requiredParams: ["dredgingJobSn", "pipeFid", "longitude", "latitude"],
requiredParams: ["dredgingJobSn", "targetSn", "longitude", "latitude"],
},
exmnPipeReport: {
key: "exmnPipeReport",
@@ -23,14 +23,16 @@ export const buildDredgingPopupRecord = (
): DredgingRecord => ({
id: getParam(searchParams.id),
dredgingJobSn: getParam(searchParams.dredgingJobSn),
pipeFid: getParam(searchParams.pipeFid),
targetSn: getNumber(searchParams.targetSn),
mappingStatus: getParam(searchParams.mappingStatus) === "UNMAPPED" ? "UNMAPPED" : "AUTO",
pipeFid: getParam(searchParams.pipeFid) || null,
mngInstCd: getParam(searchParams.mngInstCd),
ctpv: getParam(searchParams.ctpv),
sgg: getParam(searchParams.sgg),
treatmentZone: getParam(searchParams.treatmentZone),
fcltMngNo: getParam(searchParams.fcltMngNo),
pipeDiameterMm: getNumber(searchParams.pipeDiameterMm),
pipeLengthM: getNumber(searchParams.pipeLengthM),
pipeDiameterMm: getNullableNumber(searchParams.pipeDiameterMm),
pipeLengthM: getNullableNumber(searchParams.pipeLengthM),
pipeMaterial: getParam(searchParams.pipeMaterial),
installationYear: getNullableNumber(searchParams.installationYear),
dredgingDate: getParam(searchParams.dredgingDate),
@@ -44,6 +46,6 @@ export const buildDredgingPopupRecord = (
getParam(searchParams.registrationMode) === "MANUAL" ? "MANUAL" : "PHOTO",
registrationSource:
getParam(searchParams.registrationSource) === "MANUAL" ? "MANUAL" : "EXIF",
matchDistanceMeters: getNumber(searchParams.matchDistanceMeters),
matchDistanceMeters: getNullableNumber(searchParams.matchDistanceMeters),
isMock: getParam(searchParams.isMock) === "true",
});
@@ -14,7 +14,7 @@ const getParam = (value: string | string[] | undefined) =>
/** 공통 Blob API 오류 문자열에서 백엔드의 사용자 안내 메시지만 추출한다. */
const getReportErrorMessage = (error: unknown) => {
if (!(error instanceof Error)) {
return "관로 조사보고서를 불러오지 못했습니다.";
return "조사보고서를 불러오지 못했습니다.";
}
const backendMessage = error.message.match(
@@ -35,6 +35,9 @@ export default function ExmnPipeReportPopupContent({
}: WindowPopupContentProps) {
const exmnBizId = getParam(searchParams.exmnBizId);
const pipeNumber = getParam(searchParams.pipeNumber);
/** 조사유형별 첨부 API와 표시 제목을 선택한다. */
const jobType = getParam(searchParams.jobType) === "MNHL" ? "MNHL" : "PPL";
const reportLabel = jobType === "MNHL" ? "맨홀" : "관로";
const reportNo = getParam(searchParams.reportNo);
const [reportUrl, setReportUrl] = useState("");
const [errorMessage, setErrorMessage] = useState("");
@@ -46,7 +49,7 @@ export default function ExmnPipeReportPopupContent({
setReportUrl("");
setErrorMessage("");
void downloadExmnPipeReport(exmnBizId, pipeNumber)
void downloadExmnPipeReport(exmnBizId, pipeNumber, jobType)
.then((blob) => {
if (!active) return;
@@ -59,7 +62,7 @@ export default function ExmnPipeReportPopupContent({
})
.catch((error) => {
if (!active) return;
console.warn("관로 조사보고서 조회 실패:", error);
console.warn("조사보고서 조회 실패:", error);
setErrorMessage(getReportErrorMessage(error));
});
@@ -67,16 +70,16 @@ export default function ExmnPipeReportPopupContent({
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [exmnBizId, pipeNumber]);
}, [exmnBizId, pipeNumber, jobType]);
return (
<StandardPopupLayout
title="관로 조사보고서"
title={`${reportLabel} 조사보고서`}
showDefaultCancelAction={false}
actions={
<>
<span className="text-[1.05rem] text-label">
관로번호 {pipeNumber}
{jobType === "MNHL" ? "GIS 번호" : "관로번호"} {pipeNumber}
{reportNo ? ` · 보고서번호 ${reportNo}` : ""}
</span>
<Button type="button" color="white" onClick={() => window.close()}>
@@ -93,7 +96,7 @@ export default function ExmnPipeReportPopupContent({
</div>
) : reportUrl ? (
<iframe
title={`${pipeNumber} 관로 조사보고서`}
title={`${pipeNumber} ${reportLabel} 조사보고서`}
src={reportUrl}
className="h-full w-full border border-border bg-background"
/>
@@ -0,0 +1,35 @@
"use client";
import type { ReactNode } from "react";
import { cn } from "@common/components";
type PopupFooterNoteProps = {
/** 글머리표 한 줄씩 표시할 안내·자료 출처 문구. 강조가 필요한 줄은 JSX로 넘긴다. */
items: ReactNode[];
className?: string;
};
/**
* WindowPopup 목록 아래에 판정 기준·자료 출처·분석 주기 안내를 글머리표 목록으로 표시한다.
* 인수테스트 요구(2026-10-16 "팝업화면 자료의 원천 표시 및 자료 업데이트 주기 표시")에 따라
* 침수예측현황 하단 안내와 같은 위치·글꼴로 모든 대상 팝업이 이 컴포넌트를 쓴다.
*/
export default function PopupFooterNote({ items, className }: PopupFooterNoteProps) {
return (
<ul
data-popup-footer-note
className={cn(
"shrink-0 list-disc space-y-1 pl-[2.4rem] pr-1 text-xl text-muted-foreground",
className,
)}
>
{items.map((item, index) => (
// 문구 목록은 화면마다 고정 순서라 순번을 키로 쓴다.
<li key={index}>{item}</li>
))}
</ul>
);
}
export type { PopupFooterNoteProps };
+1 -1
View File
@@ -1,7 +1,7 @@
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "SN";
export const PORT = process.env.NEXT_PUBLIC_PORT || "3000";
export const PROXY_HOST = process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
export const SERVER_API_URL = process.env.SERVER_API_URL||'http://dev.onasys.co.kr:8000';
export const SERVER_API_URL = process.env.SERVER_API_URL||'https://apigateway.onasys.co.kr';
export const AUTH_LOGIN_REDIRECT_URL =
process.env.NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL || "/auth/login";
@@ -0,0 +1,90 @@
/** 서로 다른 구조의 시트를 하나의 브라우저 다운로드 파일로 생성한다. */
import type { WorkBook } from "xlsx";
export type ExcelCellValue = string | number | boolean | null;
export type ExcelSheetSpec = {
name: string;
rows: ExcelCellValue[][];
columnWidths?: number[];
autoFilterRange?: string;
mergeRanges?: string[];
};
export type ExcelWorkbookOptions = {
fileName: string;
sheets: ExcelSheetSpec[];
};
/** 금지 문자·길이·대소문자 중복을 처리해 유효한 시트명을 만든다. */
const uniqueSheetName = (name: string, used: Set<string>) => {
const base =
name
.replace(/[\\/?*[\]:\u0000-\u001f]/g, "_")
.replace(/^'+|'+$/g, "")
.trim()
.slice(0, 31) || "Sheet";
let result = base;
let index = 2;
while (used.has(result.toLowerCase())) {
const suffix = `_${index++}`;
result = `${base.slice(0, 31 - suffix.length)}${suffix}`;
}
used.add(result.toLowerCase());
return result;
};
/** 명시적인 셀 배열을 사용해 문자열을 수식으로 해석하지 않고 시트를 구성한다. */
export const buildExcelWorkbook = async (
sheets: ExcelSheetSpec[],
): Promise<WorkBook> => {
if (sheets.length === 0) throw new Error("다운로드할 시트가 없습니다.");
const XLSX = await import("xlsx");
const workbook = XLSX.utils.book_new();
const usedNames = new Set<string>();
for (const sheet of sheets) {
const worksheet = XLSX.utils.aoa_to_sheet(sheet.rows);
worksheet["!cols"] = sheet.columnWidths?.map((wch) => ({ wch }));
if (sheet.autoFilterRange)
worksheet["!autofilter"] = { ref: sheet.autoFilterRange };
if (sheet.mergeRanges)
worksheet["!merges"] = sheet.mergeRanges.map(XLSX.utils.decode_range);
// 긴 주소·원인과 명시적인 개행이 들어간 셀을 읽기 좋게 표시한다.
sheet.rows.forEach((row, r) =>
row.forEach((value, c) => {
const cell = worksheet[XLSX.utils.encode_cell({ r, c })];
if (cell && typeof value === "string")
cell.s = { alignment: { wrapText: true, vertical: "top" } };
}),
);
XLSX.utils.book_append_sheet(
workbook,
worksheet,
uniqueSheetName(sheet.name, usedNames),
);
}
return workbook;
};
/** 업무별 조회와 값 변환이 끝난 시트들을 순서대로 XLSX 파일에 저장한다. */
export const downloadExcelWorkbook = async ({
fileName,
sheets,
}: ExcelWorkbookOptions) => {
if (typeof window === "undefined")
throw new Error("엑셀 다운로드는 브라우저에서만 사용할 수 있습니다.");
const workbook = await buildExcelWorkbook(sheets);
const normalized =
fileName
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_")
.replace(/[. ]+$/g, "")
.trim() || "download";
const XLSX = await import("xlsx");
XLSX.writeFile(
workbook,
/\.xlsx$/i.test(normalized) ? normalized : `${normalized}.xlsx`,
{
bookType: "xlsx",
compression: true,
cellStyles: true,
},
);
};
@@ -784,7 +784,6 @@ const ExcretaTreatmentFacilityInp = () => {
lclgvIdfrTelnoCn: submitInfo.lclgvIdfrTelnoCn,
lclgvIdfrDeptNm: submitInfo.lclgvIdfrDeptNm,
lclgvIdfrJbgdNm: submitInfo.lclgvIdfrJbgdNm,
...(isAdminInfo ? { superAdmin: "superAdmin" } : {}),
noApplicable,
submitList,
deleteList: deleteListForSubmit,
@@ -797,7 +797,6 @@ const PrivateSewageFacilityInp = () => {
const activeTabStatus = tabStatusInfoMap.get(tabKey) ?? null;
const statsCdForTab = tabStatsCdMap.get(tabKey) ?? tabKey;
const adminOpt = isAdminInfo ? { superAdmin: "superAdmin" as const } : {};
const submitInfoBase = {
statsYr: lastSearchedValues.year ?? "",
@@ -842,7 +841,6 @@ const PrivateSewageFacilityInp = () => {
...submitInfoBase,
userUuid: userInfo.userUnqId,
submitList: buildSewageTreatFactStatusSaveList(currentData),
...adminOpt,
};
response =
await statisticsInputPrivateSewageFacilityApi.submitSewageTreatFactStatus(
@@ -853,7 +851,6 @@ const PrivateSewageFacilityInp = () => {
...submitInfoBase,
userUuid: userInfo.userUnqId,
submitList: buildSepticTankStatusSaveList(currentData),
...adminOpt,
};
response =
await statisticsInputPrivateSewageFacilityApi.submitSepticTankStatus(param);
@@ -862,7 +859,6 @@ const PrivateSewageFacilityInp = () => {
...submitInfoBase,
userUuid: userInfo.userUnqId,
submitList: buildPrivateSewageDesigStatusSaveList(currentData),
...adminOpt,
};
response =
await statisticsInputPrivateSewageFacilityApi.submitPrivateSewageDesigStatus(
@@ -768,7 +768,6 @@ const PumpingStationInp = () => {
lclgvIdfrTelnoCn: submitInfo.lclgvIdfrTelnoCn,
lclgvIdfrDeptNm: submitInfo.lclgvIdfrDeptNm,
lclgvIdfrJbgdNm: submitInfo.lclgvIdfrJbgdNm,
...(isAdminInfo ? { superAdmin: "superAdmin" } : {}),
noApplicable,
submitList,
deleteList,
@@ -1410,7 +1410,6 @@ const SewageExcretaRelatedInp = () => {
lclgvIdfrDeptNm: submitInfo.lclgvIdfrDeptNm,
lclgvIdfrJbgdNm: submitInfo.lclgvIdfrJbgdNm,
...(isPublicInstitutionTab ? { noApplicable } : {}),
...(isAdminInfo ? { superAdmin: "superAdmin" as const } : {}),
};
const verificationQuery = {
@@ -1357,11 +1357,6 @@ const SewageExcretaSlurgeInp = () => {
: rowsForSubmit.map((row) =>
buildExcretaSlurgeProcessingStatusSaveItem(row, list),
);
const slurgeDeleteList = isSlurgeFacTab
? (deleteListByTab.get(tabKey) ?? [])
.filter((row) => isPersistedArticleRowForTab(tabKey, row))
.map((row) => buildSlurgeProcessingFacStatusSaveItem(row, list))
: [];
const sewageSlurgeFacDeleteList = isSewageSlurgeFacTab
? (deleteListByTab.get(tabKey) ?? [])
.filter((row) => isPersistedArticleRowForTab(tabKey, row))
@@ -1432,7 +1427,6 @@ const SewageExcretaSlurgeInp = () => {
lclgvIdfrTelnoCn: submitInfo.lclgvIdfrTelnoCn,
lclgvIdfrDeptNm: submitInfo.lclgvIdfrDeptNm,
lclgvIdfrJbgdNm: submitInfo.lclgvIdfrJbgdNm,
...(isAdminInfo ? { superAdmin: "superAdmin" as const } : {}),
noApplicable,
};
@@ -1445,7 +1439,6 @@ const SewageExcretaSlurgeInp = () => {
...submitRequestBase,
userUuid: userInfo.userUnqId,
submitList: slurgeSubmitList,
deleteList: slurgeDeleteList,
})
: isDigestorTab
? await statisticsInputSewageExcretaSlurgeApi.submitDigestorOperatingStatus({
@@ -1655,9 +1648,6 @@ const SewageExcretaSlurgeInp = () => {
: rowsForSave.map((row) =>
buildSlurgeProcessingFacStatusSaveItem(row, list),
);
const deleteList = (deleteListByTab.get(tabKey) ?? [])
.filter((row) => isPersistedArticleRowForTab(tabKey, row))
.map((row) => buildSlurgeProcessingFacStatusSaveItem(row, list));
if (!noApplicable && saveList.length === 0 && buildSubmitOkForToolbar(searchContextByTab.get(tabKey) ?? lastSearchedValues)) {
alert("해당사항 없음을 체크해주세요.");
@@ -1670,7 +1660,6 @@ const SewageExcretaSlurgeInp = () => {
...baseSaveRequest,
...userFields,
saveList,
deleteList,
},
);
} else if (tabKey === DigestorOperatingStatusInpColumn.tabKey) {
@@ -350,7 +350,6 @@ function buildRegionalFactSubmitListFromGrid(
};
return currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm),
goalYr: nullableTrimmedString(d.goalYr),
wholPplMtncPlanPrlg: formatNumber(d.wholPplMtncPlanPrlg, "wholPplMtncPlanPrlg"),
@@ -1264,7 +1263,6 @@ const SewagePipeInp = () => {
};
const submitList = currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm),
fcltSn: nullableTrimmedString(d.fcltSn),
swtrmtFcltCd: nullableTrimmedString(d.swtrmtFcltCd),
@@ -1637,7 +1635,6 @@ const SewagePipeInp = () => {
const saveList = currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm),
goalYr: nullableTrimmedString(d.goalYr),
wholPplMtncPlanPrlg: formatNumber(d.wholPplMtncPlanPrlg, "wholPplMtncPlanPrlg"),
@@ -1728,7 +1725,6 @@ const SewagePipeInp = () => {
const saveList = currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm),
fcltSn: nullableTrimmedString(d.fcltSn),
swtrmtFcltCd: nullableTrimmedString(d.swtrmtFcltCd),
@@ -218,7 +218,6 @@ function buildRepairRowList(
return currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm),
dfrpWholPrlg: formatNumber(d.dfrpWholPrlg, "dfrpWholPrlg"),
cbsysDfrpPrlg: formatNumber(d.cbsysDfrpPrlg, "cbsysDfrpPrlg"),
@@ -247,7 +246,6 @@ function buildDredgingRowList(
return currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm),
wholFcltPrlg: formatNumber(d.wholFcltPrlg, "wholFcltPrlg"),
wholSwagePpl: formatNumber(d.wholSwagePpl, "wholSwagePpl"),
@@ -215,7 +215,6 @@ function buildSewerageFinanceSettleSaveList(
return currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm),
soaGovSprtAmt: formatNumber(d.soaGovSprtAmt, "soaGovSprtAmt"),
mctpvLcexpAmt: formatNumber(d.mctpvLcexpAmt, "mctpvLcexpAmt"),
@@ -257,7 +256,6 @@ function buildSewerageChargeSaveList(
return currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm),
anlLevyQnt: formatNumber(d.anlLevyQnt, "anlLevyQnt"),
sewerLevyAmt: formatNumber(d.sewerLevyAmt, "sewerLevyAmt"),
@@ -276,7 +274,6 @@ function buildSewerageUsageFeeSaveList(
return currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm),
hshusLevyAmt: formatNumber(d.hshusLevyAmt, "hshusLevyAmt"),
hshusLevyQnt: formatNumber(d.hshusLevyQnt, "hshusLevyQnt"),
@@ -312,7 +309,6 @@ function buildCauserBurdenSaveList(
return currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm),
brdnAmtLevyNocs: formatNumber(d.brdnAmtLevyNocs, "brdnAmtLevyNocs"),
brdnAmtLevyAmt: formatNumber(d.brdnAmtLevyAmt, "brdnAmtLevyAmt"),
@@ -849,7 +845,6 @@ const SewerageFinanceInp = () => {
const activeTabStatus = tabStatusInfoMap.get(tabKey) ?? null;
const statsCdForTab = tabStatsCdMap.get(tabKey) ?? tabKey;
const adminOpt = isAdminInfo ? { superAdmin: "superAdmin" as const } : {};
const submitInfoBase = {
statsYr: lastSearchedValues.year ?? "",
@@ -894,7 +889,6 @@ const SewerageFinanceInp = () => {
...submitInfoBase,
userUuid: userInfo.userUnqId,
submitList: buildSewerageFinanceSettleSaveList(currentData),
...adminOpt,
};
response =
await statisticsInputSewerageFinanceApi.submitSewerageFinanceSettle(
@@ -905,7 +899,6 @@ const SewerageFinanceInp = () => {
...submitInfoBase,
userUuid: userInfo.userUnqId,
submitList: buildSewerageChargeSaveList(currentData),
...adminOpt,
};
response = await statisticsInputSewerageFinanceApi.submitSewerageCharge(param);
} else if (isUsageFeeTab) {
@@ -913,7 +906,6 @@ const SewerageFinanceInp = () => {
...submitInfoBase,
userUuid: userInfo.userUnqId,
submitList: buildSewerageUsageFeeSaveList(currentData),
...adminOpt,
};
response =
await statisticsInputSewerageFinanceApi.submitSewerageUsageFee(param);
@@ -922,7 +914,6 @@ const SewerageFinanceInp = () => {
...submitInfoBase,
userUuid: userInfo.userUnqId,
submitList: buildCauserBurdenSaveList(currentData),
...adminOpt,
};
response = await statisticsInputSewerageFinanceApi.submitCauserBurden(param);
}
@@ -399,10 +399,6 @@ const SeweragePenetrationRateInp = () => {
statsCd: statsCd,
};
if (isAdminInfo) {
params.superAdmin = 'superAdmin';
}
try {
setIsLoading(true);
@@ -498,7 +494,6 @@ const SeweragePenetrationRateInp = () => {
};
const convertedData: SewerageSupplyRateSaveItem[] = currentData.map((d) => ({
sggNm: nullableTrimmedString(d.sggNm),
admdstCd: nullableTrimmedString(d.admdstCd),
admdstNm: nullableTrimmedString(d.admdstNm),
statsTrbtrNm: nullableTrimmedString(d.statsTrbtrNm),
@@ -677,8 +672,6 @@ const SeweragePenetrationRateInp = () => {
};
const convertedData: SewerageSupplyRateSaveItem[] = currentData.map((d) => ({
// 행정구역
sggNm: nullableTrimmedString(d.sggNm), // 시군구
admdstCd: nullableTrimmedString(d.admdstCd), // 행정코드
admdstNm: nullableTrimmedString(d.admdstNm), // 행정구역명
statsTrbtrNm: nullableTrimmedString(d.statsTrbtrNm), // 지류

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