Compare commits
45
Commits
2d77813dab
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee0049722f | ||
|
|
74934d4559 | ||
|
|
027f88da6c | ||
|
|
eddb1b24d6 | ||
|
|
cf2cc11167 | ||
|
|
9e71f529e7 | ||
|
|
6dc2965209 | ||
|
|
c69c1aa855 | ||
|
|
9f0e03c151 | ||
|
|
471a431af5 | ||
|
|
26d8410649 | ||
|
|
8403ad556c | ||
|
|
d26c15f857 | ||
|
|
86ed664e3e | ||
|
|
a105f395dd | ||
|
|
a5ebc12580 | ||
|
|
270f6e366f | ||
|
|
acf215c1bf | ||
|
|
8a98cd7d19 | ||
|
|
3a5b48b608 | ||
|
|
d735ad7dd7 | ||
|
|
25dd0f1504 | ||
|
|
8e14c6cef5 | ||
|
|
9f7abe4413 | ||
|
|
de730205af | ||
|
|
36b11d6b00 | ||
|
|
a873f0e0dc | ||
|
|
79f70d8b4b | ||
|
|
7911cff96a | ||
|
|
fe501965bf | ||
|
|
fb82cadc82 | ||
|
|
f5b6effeb3 | ||
|
|
63c2901ae3 | ||
|
|
32e3765b52 | ||
|
|
4f62095e8e | ||
|
|
5234448ae5 | ||
|
|
0356aef7b2 | ||
|
|
f709dc99f6 | ||
|
|
f284c67988 | ||
|
|
728a66bac2 | ||
|
|
c850b9ce1e | ||
|
|
791bc20e8e | ||
|
|
fed1383ced | ||
|
|
91ff80a255 | ||
|
|
7f10e20a41 |
+2
-1
@@ -1,6 +1,7 @@
|
||||
# turbo
|
||||
.turbo/
|
||||
.vscode/**/*
|
||||
**/.vscode/
|
||||
*.code-workspace
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
Vendored
-88
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
-5
@@ -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**
|
||||
|
||||
|
||||
@@ -14,15 +14,15 @@
|
||||
type="text/javascript"
|
||||
data-type="css/app"
|
||||
data-framework="aori|lab"
|
||||
src="https://hwp.onasys.co.kr/webhwpctrl/js/loadVersion.js"
|
||||
src="js/loadVersion.js"
|
||||
></script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
data-type="css/app"
|
||||
data-framework="aori|lab"
|
||||
src="https://hwp.onasys.co.kr/webhwpctrl/js/loadHead.js"
|
||||
src="js/loadHead.js"
|
||||
></script>
|
||||
<script data-main="js/main" data-bundle="dist/main_bundle" src="https://hwp.onasys.co.kr/webhwpctrl/js/loadMain.js"></script>
|
||||
<script data-main="js/main" data-bundle="dist/main_bundle" src="js/loadMain.js"></script>
|
||||
<style>
|
||||
#hcwoViewScroll::-webkit-scrollbar {
|
||||
height: 10px;
|
||||
@@ -57,6 +57,6 @@
|
||||
</div>
|
||||
|
||||
<iframe id="download_iframe" style="width: 0px; height: 0px; border: 0; display: none"></iframe>
|
||||
<script type="text/javascript" src="https://hwp.onasys.co.kr/webhwpctrl/hwpsdk/loadHwpsdk.js"></script>
|
||||
<script type="text/javascript" src="hwpsdk/loadHwpsdk.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,18 +5,6 @@
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<title>웹한글 기안기 브리지</title>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="https://hwp.onasys.co.kr/webhwpctrl/js/hwpctrlapp/utils/util.js"
|
||||
></script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="https://hwp.onasys.co.kr/webhwpctrl/js/hwpctrlapp/hwpCtrlApp.js"
|
||||
></script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="https://hwp.onasys.co.kr/webhwpctrl/js/webhwpctrl.js"
|
||||
></script>
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
@@ -39,27 +27,56 @@
|
||||
<div id="hwpctrl"></div>
|
||||
<script type="text/javascript">
|
||||
(function () {
|
||||
// baseurl: 부모(HangulGiangi)가 NEXT_PUBLIC_WEBHYP_CTRL_URL 값으로 전달.
|
||||
// 없으면 same-origin 프록시 경로(/webhwpctrl/ → next.config rewrites)를 사용한다.
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var baseUrl = params.get("baseurl") || "https://hwp.onasys.co.kr/webhwpctrl";
|
||||
var baseUrl = params.get("baseurl") || new URL("./", window.location.href).href;
|
||||
if (baseUrl.charAt(baseUrl.length - 1) !== "/") {
|
||||
baseUrl += "/";
|
||||
}
|
||||
|
||||
var HwpCtrl = BuildWebHwpCtrl(
|
||||
"hwpctrl",
|
||||
baseUrl,
|
||||
function () {
|
||||
try {
|
||||
window.parent.postMessage({ type: "hangulgiangi-ready" }, "*");
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
},
|
||||
2,
|
||||
);
|
||||
var scripts = [
|
||||
"js/hwpctrlapp/utils/util.js",
|
||||
"js/hwpctrlapp/hwpCtrlApp.js",
|
||||
"js/webhwpctrl.js",
|
||||
];
|
||||
|
||||
// 부모(HangulGiangi)가 contentWindow.HwpCtrl 로 접근
|
||||
window.HwpCtrl = HwpCtrl;
|
||||
function loadScript(index) {
|
||||
if (index >= scripts.length) {
|
||||
init();
|
||||
return;
|
||||
}
|
||||
var script = document.createElement("script");
|
||||
script.type = "text/javascript";
|
||||
script.src = baseUrl + scripts[index];
|
||||
script.onload = function () {
|
||||
loadScript(index + 1);
|
||||
};
|
||||
script.onerror = function () {
|
||||
console.error("웹한글 기안기 스크립트 로드 실패: " + script.src);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
function init() {
|
||||
var HwpCtrl = BuildWebHwpCtrl(
|
||||
"hwpctrl",
|
||||
baseUrl,
|
||||
function () {
|
||||
try {
|
||||
window.parent.postMessage({ type: "hangulgiangi-ready" }, "*");
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
},
|
||||
2,
|
||||
);
|
||||
|
||||
// 부모(HangulGiangi)가 contentWindow.HwpCtrl 로 접근
|
||||
window.HwpCtrl = HwpCtrl;
|
||||
}
|
||||
|
||||
loadScript(0);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -13,12 +13,16 @@ import type {
|
||||
NowDmgMtncGnsvySearchDto,
|
||||
} from "../model/dc-interface";
|
||||
|
||||
/** 빈 문자열 파라미터를 보내면 서버 오류가 나므로 값이 있는 항목만 전송한다. */
|
||||
async function getNowDmgMtncGnsvy(
|
||||
params: NowDmgMtncGnsvySearchDto,
|
||||
): Promise<NowDmgMtncGnsvyResponseDto[]> {
|
||||
const filledParams = Object.fromEntries(
|
||||
Object.entries(params).filter(([, value]) => value !== undefined && value !== ""),
|
||||
);
|
||||
const response = await client.get<NowDmgMtncGnsvyResponseDto[]>(
|
||||
`${ENDPOINT.DC_SERVICE}/nowDmgMtncGnsvy`,
|
||||
{ params },
|
||||
{ params: filledParams },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { RvwOpnnUpdateRequestDto } from "@/shared/model";
|
||||
|
||||
const BASE = ENDPOINT.AS_SERVICE.FNRS_CNSLTN_RVW_OPNN;
|
||||
|
||||
async function update(
|
||||
fnrsCnsltnDocNo: string,
|
||||
rvwOpnnSn: number,
|
||||
body: RvwOpnnUpdateRequestDto,
|
||||
): Promise<void> {
|
||||
await client.put<void>(`${BASE}/${fnrsCnsltnDocNo}/${rvwOpnnSn}`, body);
|
||||
}
|
||||
|
||||
async function deleteFn(fnrsCnsltnDocNo: string, rvwOpnnSn: number): Promise<void> {
|
||||
await client.delete<void>(`${BASE}/${fnrsCnsltnDocNo}/${rvwOpnnSn}`);
|
||||
}
|
||||
|
||||
export const fnrsCnsltnRvwOpnnService = {
|
||||
update,
|
||||
delete: deleteFn,
|
||||
};
|
||||
@@ -1,65 +1,66 @@
|
||||
export { fnrsCnsltnMngService } from "./api/fnrs-cnsltn-service";
|
||||
export { fnrsCnsltnKeys } from "./api/queries";
|
||||
export {
|
||||
useChangePrgrsSttsComCd,
|
||||
useDeleteFnrsCnsltnMng,
|
||||
useFnrsCnsltnFileVrfcResults,
|
||||
useFnrsCnsltnMng,
|
||||
useFnrsCnsltnMngPage,
|
||||
useRequestActnReviewFnrsCnsltnMng,
|
||||
useRequestExamineFnrsCnsltnMng,
|
||||
useSaveApplyFnrsCnsltnMng,
|
||||
useSaveFnrsCnsltnMng,
|
||||
useSubmitReqSupplementFnrsCnsltnMng,
|
||||
useSubmitSupplementFnrsCnsltnMng,
|
||||
useTechReviewFnrsCnsltnMng,
|
||||
useUpdateFnrsCnsltnMng,
|
||||
} from "./api/queries";
|
||||
export type {
|
||||
BizCstItem,
|
||||
BzScaleItem,
|
||||
CklstResultItem,
|
||||
EsntlAtchFileItem,
|
||||
FnrsCnsltnApplySaveRequestDto,
|
||||
FnrsCnsltnMngSaveRequestDto,
|
||||
FnrsCnsltnRvwOpnnSaveRequestDto,
|
||||
FnrsCnsltnSplmntDmndItem,
|
||||
FnrsSharItem,
|
||||
} from "./model/fnrs-cnsltn-apply-payload-schema";
|
||||
export {
|
||||
fnrsCnsltnApplySaveSchema,
|
||||
fnrsCnsltnBizCstItemSchema,
|
||||
fnrsCnsltnBzScaleItemSchema,
|
||||
fnrsCnsltnCklstResultItemSchema,
|
||||
fnrsCnsltnEsntlAtchFileItemSchema,
|
||||
fnrsCnsltnMngSaveSchema,
|
||||
fnrsCnsltnRvwOpnnSaveSchema,
|
||||
fnrsCnsltnSplmntDmndItemSchema,
|
||||
fnrsSharItemSchema,
|
||||
} from "./model/fnrs-cnsltn-apply-payload-schema";
|
||||
export type {
|
||||
AtflMngClientResponseDto,
|
||||
BizCstTreeDto,
|
||||
BizSclTreeDto,
|
||||
CklstRvwRsltTreeDto,
|
||||
DocPrgrsUpdateRequestDto,
|
||||
ElaprDocPrgrsClientResponseDto,
|
||||
EsntlAtchFileMngTreeResponseDto,
|
||||
FnrsCnsltnFileVrfcRsltResponseDto,
|
||||
FnrsCnsltnMngDetResponseDto,
|
||||
FnrsCnsltnMngResponseDto,
|
||||
FnrsCnsltnMngSearchDto,
|
||||
FnrsCnsltnMngService,
|
||||
FnrsCnsltnMngUpdateRequestDto,
|
||||
FnrsCnsltnRvwOpnnResponseDto,
|
||||
FnrsCnsltnSplmntDmndResponseDto,
|
||||
RagIndexingInfoDto,
|
||||
} from "./model/fnrs-cnsltn-interface";
|
||||
export type {
|
||||
EnvoActnReviewReqDto,
|
||||
FnrsExamineRequestDto,
|
||||
} from "./model/fnrs-cnsltn-payload-schema";
|
||||
export {
|
||||
envoActnReviewReqSchema,
|
||||
fnrsExamineRequestSchema,
|
||||
} from "./model/fnrs-cnsltn-payload-schema";
|
||||
export { fnrsCnsltnRvwOpnnService } from "./api/fnrs-cnsltn-rvw-opnn-service";
|
||||
export { fnrsCnsltnMngService } from "./api/fnrs-cnsltn-service";
|
||||
export { fnrsCnsltnKeys } from "./api/queries";
|
||||
export {
|
||||
useChangePrgrsSttsComCd,
|
||||
useDeleteFnrsCnsltnMng,
|
||||
useFnrsCnsltnFileVrfcResults,
|
||||
useFnrsCnsltnMng,
|
||||
useFnrsCnsltnMngPage,
|
||||
useRequestActnReviewFnrsCnsltnMng,
|
||||
useRequestExamineFnrsCnsltnMng,
|
||||
useSaveApplyFnrsCnsltnMng,
|
||||
useSaveFnrsCnsltnMng,
|
||||
useSubmitReqSupplementFnrsCnsltnMng,
|
||||
useSubmitSupplementFnrsCnsltnMng,
|
||||
useTechReviewFnrsCnsltnMng,
|
||||
useUpdateFnrsCnsltnMng,
|
||||
} from "./api/queries";
|
||||
export type {
|
||||
BizCstItem,
|
||||
BzScaleItem,
|
||||
CklstResultItem,
|
||||
EsntlAtchFileItem,
|
||||
FnrsCnsltnApplySaveRequestDto,
|
||||
FnrsCnsltnMngSaveRequestDto,
|
||||
FnrsCnsltnRvwOpnnSaveRequestDto,
|
||||
FnrsCnsltnSplmntDmndItem,
|
||||
FnrsSharItem,
|
||||
} from "./model/fnrs-cnsltn-apply-payload-schema";
|
||||
export {
|
||||
fnrsCnsltnApplySaveSchema,
|
||||
fnrsCnsltnBizCstItemSchema,
|
||||
fnrsCnsltnBzScaleItemSchema,
|
||||
fnrsCnsltnCklstResultItemSchema,
|
||||
fnrsCnsltnEsntlAtchFileItemSchema,
|
||||
fnrsCnsltnMngSaveSchema,
|
||||
fnrsCnsltnRvwOpnnSaveSchema,
|
||||
fnrsCnsltnSplmntDmndItemSchema,
|
||||
fnrsSharItemSchema,
|
||||
} from "./model/fnrs-cnsltn-apply-payload-schema";
|
||||
export type {
|
||||
AtflMngClientResponseDto,
|
||||
BizCstTreeDto,
|
||||
BizSclTreeDto,
|
||||
CklstRvwRsltTreeDto,
|
||||
DocPrgrsUpdateRequestDto,
|
||||
ElaprDocPrgrsClientResponseDto,
|
||||
EsntlAtchFileMngTreeResponseDto,
|
||||
FnrsCnsltnFileVrfcRsltResponseDto,
|
||||
FnrsCnsltnMngDetResponseDto,
|
||||
FnrsCnsltnMngResponseDto,
|
||||
FnrsCnsltnMngSearchDto,
|
||||
FnrsCnsltnMngService,
|
||||
FnrsCnsltnMngUpdateRequestDto,
|
||||
FnrsCnsltnRvwOpnnResponseDto,
|
||||
FnrsCnsltnSplmntDmndResponseDto,
|
||||
RagIndexingInfoDto,
|
||||
} from "./model/fnrs-cnsltn-interface";
|
||||
export type {
|
||||
EnvoActnReviewReqDto,
|
||||
FnrsExamineRequestDto,
|
||||
} from "./model/fnrs-cnsltn-payload-schema";
|
||||
export {
|
||||
envoActnReviewReqSchema,
|
||||
fnrsExamineRequestSchema,
|
||||
} from "./model/fnrs-cnsltn-payload-schema";
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { RvwOpnnUpdateRequestDto } from "@/shared/model";
|
||||
|
||||
const BASE = ENDPOINT.AS_SERVICE.INSTL_SNCN_RVW_OPNN;
|
||||
|
||||
async function update(
|
||||
instlSncnDocNo: string,
|
||||
rvwOpnnSn: number,
|
||||
body: RvwOpnnUpdateRequestDto,
|
||||
): Promise<void> {
|
||||
await client.put<void>(`${BASE}/${instlSncnDocNo}/${rvwOpnnSn}`, body);
|
||||
}
|
||||
|
||||
async function deleteFn(instlSncnDocNo: string, rvwOpnnSn: number): Promise<void> {
|
||||
await client.delete<void>(`${BASE}/${instlSncnDocNo}/${rvwOpnnSn}`);
|
||||
}
|
||||
|
||||
export const instlSncnRvwOpnnService = {
|
||||
update,
|
||||
delete: deleteFn,
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export { instlSncnRvwOpnnService } from "./api/instl-sncn-rvw-opnn-service";
|
||||
export { instlSncnRegionService, instlSncnService } from "./api/instl-sncn-service";
|
||||
export {
|
||||
instlSncnKeys,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { RvwOpnnUpdateRequestDto } from "@/shared/model";
|
||||
|
||||
const BASE = ENDPOINT.AS_SERVICE.SEWER_BSC_PLAN_RVW_OPNNS;
|
||||
|
||||
async function update(
|
||||
sewerBscPlanDocNo: string,
|
||||
chgCycl: number,
|
||||
rvwOpnnSn: number,
|
||||
body: RvwOpnnUpdateRequestDto,
|
||||
): Promise<void> {
|
||||
await client.put<void>(`${BASE}/${sewerBscPlanDocNo}/${chgCycl}/${rvwOpnnSn}`, body);
|
||||
}
|
||||
|
||||
async function deleteFn(sewerBscPlanDocNo: string, chgCycl: number, rvwOpnnSn: number): Promise<void> {
|
||||
await client.delete<void>(`${BASE}/${sewerBscPlanDocNo}/${chgCycl}/${rvwOpnnSn}`);
|
||||
}
|
||||
|
||||
export const sewerBscPlanRvwOpnnService = {
|
||||
update,
|
||||
delete: deleteFn,
|
||||
};
|
||||
@@ -1,64 +1,65 @@
|
||||
export { sewerBscPlanKeys } from "./api/queries";
|
||||
export {
|
||||
useDeleteSewerBscPlan,
|
||||
useSaveApplySewerBscPlan,
|
||||
useSaveSewerBscPlan,
|
||||
useSewerBscPlan,
|
||||
useSewerBscPlanDetails,
|
||||
useSewerBscPlanPage,
|
||||
useSubmitReqSupplementSewerBscPlan,
|
||||
useSubmitSupplementSewerBscPlan,
|
||||
useTechReviewSewerBscPlan,
|
||||
useUpdateSewerBscPlan,
|
||||
} from "./api/queries";
|
||||
export { sewerBscPlanService } from "./api/sewer-bsc-plan-service";
|
||||
export type {
|
||||
CklstResultItem,
|
||||
EsntlAtchFileItem,
|
||||
SewerBscPlanApplySaveRequestDto,
|
||||
SewerBscPlanBizCstItem,
|
||||
SewerBscPlanDtlSaveRequestDto,
|
||||
SewerBscPlanFnrsCstItem,
|
||||
SewerBscPlanOtlnItem,
|
||||
SewerBscPlanRvwOpnnItem,
|
||||
SewerBscPlanSaveRequestDto,
|
||||
SewerBscPlanSplmntDmndItem,
|
||||
SewerBscPlanTmplntDtlItem,
|
||||
SewerBscPlanTmplntItem,
|
||||
SewerBscPlanTmplntTmprStrgItem,
|
||||
} from "./model/sewer-bsc-plan-apply-payload-schema";
|
||||
export {
|
||||
sewerBscPlanApplySaveSchema,
|
||||
sewerBscPlanBizCstItemSchema,
|
||||
sewerBscPlanCklstResultItemSchema,
|
||||
sewerBscPlanDtlSaveSchema,
|
||||
sewerBscPlanEsntlAtchFileItemSchema,
|
||||
sewerBscPlanFnrsCstItemSchema,
|
||||
sewerBscPlanOtlnItemSchema,
|
||||
sewerBscPlanRvwOpnnItemSchema,
|
||||
sewerBscPlanSaveSchema,
|
||||
sewerBscPlanSplmntDmndItemSchema,
|
||||
sewerBscPlanTmplntDtlItemSchema,
|
||||
sewerBscPlanTmplntItemSchema,
|
||||
sewerBscPlanTmplntTmprStrgItemSchema,
|
||||
} from "./model/sewer-bsc-plan-apply-payload-schema";
|
||||
export type {
|
||||
AtflMngClientResponseDto,
|
||||
CklstRvwRsltTreeDto,
|
||||
EsntlAtchFileMngTreeResponseDto,
|
||||
SewerBscPlanApplySaveResponseDto,
|
||||
SewerBscPlanBizCstResponseDto,
|
||||
SewerBscPlanDetResponseDto,
|
||||
SewerBscPlanDtlResponseDto,
|
||||
SewerBscPlanFnrsCstResponseDto,
|
||||
SewerBscPlanOtlnResponseDto,
|
||||
SewerBscPlanResponseDto,
|
||||
SewerBscPlanRvwOpnnResponseDto,
|
||||
SewerBscPlanSearchRequestDto,
|
||||
SewerBscPlanService,
|
||||
SewerBscPlanSplmntDmndResponseDto,
|
||||
SewerBscPlanTmplntDtlResponseDto,
|
||||
SewerBscPlanTmplntResponseDto,
|
||||
SewerBscPlanTmplntTmprStrgResponseDto,
|
||||
SewerBscPlanUpdateRequestDto,
|
||||
} from "./model/sewer-bsc-plan-interface";
|
||||
export { sewerBscPlanKeys } from "./api/queries";
|
||||
export {
|
||||
useDeleteSewerBscPlan,
|
||||
useSaveApplySewerBscPlan,
|
||||
useSaveSewerBscPlan,
|
||||
useSewerBscPlan,
|
||||
useSewerBscPlanDetails,
|
||||
useSewerBscPlanPage,
|
||||
useSubmitReqSupplementSewerBscPlan,
|
||||
useSubmitSupplementSewerBscPlan,
|
||||
useTechReviewSewerBscPlan,
|
||||
useUpdateSewerBscPlan,
|
||||
} from "./api/queries";
|
||||
export { sewerBscPlanRvwOpnnService } from "./api/sewer-bsc-plan-rvw-opnn-service";
|
||||
export { sewerBscPlanService } from "./api/sewer-bsc-plan-service";
|
||||
export type {
|
||||
CklstResultItem,
|
||||
EsntlAtchFileItem,
|
||||
SewerBscPlanApplySaveRequestDto,
|
||||
SewerBscPlanBizCstItem,
|
||||
SewerBscPlanDtlSaveRequestDto,
|
||||
SewerBscPlanFnrsCstItem,
|
||||
SewerBscPlanOtlnItem,
|
||||
SewerBscPlanRvwOpnnItem,
|
||||
SewerBscPlanSaveRequestDto,
|
||||
SewerBscPlanSplmntDmndItem,
|
||||
SewerBscPlanTmplntDtlItem,
|
||||
SewerBscPlanTmplntItem,
|
||||
SewerBscPlanTmplntTmprStrgItem,
|
||||
} from "./model/sewer-bsc-plan-apply-payload-schema";
|
||||
export {
|
||||
sewerBscPlanApplySaveSchema,
|
||||
sewerBscPlanBizCstItemSchema,
|
||||
sewerBscPlanCklstResultItemSchema,
|
||||
sewerBscPlanDtlSaveSchema,
|
||||
sewerBscPlanEsntlAtchFileItemSchema,
|
||||
sewerBscPlanFnrsCstItemSchema,
|
||||
sewerBscPlanOtlnItemSchema,
|
||||
sewerBscPlanRvwOpnnItemSchema,
|
||||
sewerBscPlanSaveSchema,
|
||||
sewerBscPlanSplmntDmndItemSchema,
|
||||
sewerBscPlanTmplntDtlItemSchema,
|
||||
sewerBscPlanTmplntItemSchema,
|
||||
sewerBscPlanTmplntTmprStrgItemSchema,
|
||||
} from "./model/sewer-bsc-plan-apply-payload-schema";
|
||||
export type {
|
||||
AtflMngClientResponseDto,
|
||||
CklstRvwRsltTreeDto,
|
||||
EsntlAtchFileMngTreeResponseDto,
|
||||
SewerBscPlanApplySaveResponseDto,
|
||||
SewerBscPlanBizCstResponseDto,
|
||||
SewerBscPlanDetResponseDto,
|
||||
SewerBscPlanDtlResponseDto,
|
||||
SewerBscPlanFnrsCstResponseDto,
|
||||
SewerBscPlanOtlnResponseDto,
|
||||
SewerBscPlanResponseDto,
|
||||
SewerBscPlanRvwOpnnResponseDto,
|
||||
SewerBscPlanSearchRequestDto,
|
||||
SewerBscPlanService,
|
||||
SewerBscPlanSplmntDmndResponseDto,
|
||||
SewerBscPlanTmplntDtlResponseDto,
|
||||
SewerBscPlanTmplntResponseDto,
|
||||
SewerBscPlanTmplntTmprStrgResponseDto,
|
||||
SewerBscPlanUpdateRequestDto,
|
||||
} from "./model/sewer-bsc-plan-interface";
|
||||
|
||||
@@ -1,183 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
|
||||
import { dayjs } from "@/shared/lib";
|
||||
import type { DesignCnstnMngResponseDto } from "@/entities/design-cnstn";
|
||||
import { Badge } from "@common/components";
|
||||
|
||||
const columnHelper = createColumnHelper<DesignCnstnMngResponseDto>();
|
||||
|
||||
const MAIN_STEP_LABELS = {
|
||||
apply: "설계자문 신청",
|
||||
techReview: "기술검토",
|
||||
reply: "검토결과 회신",
|
||||
} as const;
|
||||
|
||||
export const PROGRESS_STATUS_COLUMNS = [
|
||||
{ label: "설계자문 신청 (시군)", sttsComCd: "CT" },
|
||||
{ label: "기술검토", sttsComCd: "CS" },
|
||||
{ label: "기술검토 회신", sttsComCd: "CP" },
|
||||
];
|
||||
|
||||
type BadgeState = "completed" | "current" | "pending";
|
||||
|
||||
interface ProgressBadge {
|
||||
label: string;
|
||||
state: BadgeState;
|
||||
}
|
||||
|
||||
function buildProgressBadges(prgrsSttsComCd: string): ProgressBadge[] {
|
||||
if (prgrsSttsComCd === "CV") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "CT" || prgrsSttsComCd === "CS" || prgrsSttsComCd === "SL") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "current" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "CP") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "completed" },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
|
||||
if (prgrsSttsComCd === "SL") {
|
||||
return <Badge color="orange">보완요청</Badge>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getBadgeColor(state: BadgeState) {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "green";
|
||||
case "current":
|
||||
return "blue";
|
||||
case "pending":
|
||||
return "lightGray";
|
||||
}
|
||||
}
|
||||
|
||||
function getBadgeStateText(state: BadgeState) {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "완료";
|
||||
case "current":
|
||||
return "진행중";
|
||||
case "pending":
|
||||
return "대기";
|
||||
}
|
||||
}
|
||||
|
||||
/** 단계명을 2줄로 분리: "설치인가 신청" → ["설치인가", "신청"], "기술검토" → ["기술검토", ""] */
|
||||
function splitStepLabel(label: string) {
|
||||
const [title = "", ...rest] = label.split(" ");
|
||||
return { title, detail: rest.join(" ") };
|
||||
}
|
||||
|
||||
function formatOptional(value: string | null | undefined) {
|
||||
return value?.trim() ? value : "-";
|
||||
}
|
||||
|
||||
interface ProgressStepBadgeProps {
|
||||
prgrsSttsComCd: string;
|
||||
}
|
||||
|
||||
function ProgressStepBadge({ prgrsSttsComCd }: ProgressStepBadgeProps) {
|
||||
const badges = buildProgressBadges(prgrsSttsComCd);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-start">
|
||||
{badges.map((badge, index) => (
|
||||
<Fragment key={`${badge.label}-${index}`}>
|
||||
{index > 0 && <span className="mx-2">></span>}
|
||||
{/* 1줄: 단계명 / 2줄: 세부단계 + 상태 (예: 설치인가 / 신청완료, 기술검토 / 완료) */}
|
||||
<Badge
|
||||
color={getBadgeColor(badge.state)}
|
||||
className="flex-col justify-center !px-[20px] py-1 text-center leading-tight"
|
||||
>
|
||||
<span>{splitStepLabel(badge.label).title}</span>
|
||||
<span>
|
||||
{splitStepLabel(badge.label).detail}
|
||||
{getBadgeStateText(badge.state)}
|
||||
</span>
|
||||
</Badge>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function createDesignCnstnColumns() {
|
||||
return [
|
||||
columnHelper.accessor("sido", {
|
||||
header: "시도",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("gugun", {
|
||||
header: "시군구",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("bizNm", {
|
||||
header: "사업명",
|
||||
meta: { align: "left" },
|
||||
cell: ({ row, getValue }) => (
|
||||
<>
|
||||
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
|
||||
</>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("taskSeCdNm", {
|
||||
header: "업무구분",
|
||||
meta: { align: "center" },
|
||||
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
|
||||
}),
|
||||
columnHelper.accessor("aplcntNm", {
|
||||
header: "담당자",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("envoSeCdNm", {
|
||||
header: "해당기관",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "progress",
|
||||
header: "진행상황",
|
||||
meta: { align: "center" },
|
||||
cell: ({ row }) => <ProgressStepBadge prgrsSttsComCd={row.original.prgrsSttsComCd || ""} />,
|
||||
}),
|
||||
columnHelper.accessor("regDt", {
|
||||
header: "신청일",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
return value ? dayjs(value).format("YYYY-MM-DD") : "-";
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
|
||||
import { dayjs } from "@/shared/lib";
|
||||
import type { DesignCnstnMngResponseDto } from "@/entities/design-cnstn";
|
||||
import { Badge } from "@common/components";
|
||||
|
||||
const columnHelper = createColumnHelper<DesignCnstnMngResponseDto>();
|
||||
|
||||
const MAIN_STEP_LABELS = {
|
||||
apply: "설계자문 신청",
|
||||
techReview: "기술검토",
|
||||
reply: "검토결과 회신",
|
||||
} as const;
|
||||
|
||||
export const PROGRESS_STATUS_COLUMNS = [
|
||||
{ label: "설계자문 신청 (시군)", sttsComCd: "CT" },
|
||||
{ label: "기술검토", sttsComCd: "CS" },
|
||||
{ label: "기술검토 회신", sttsComCd: "CP" },
|
||||
];
|
||||
|
||||
type BadgeState = "completed" | "current" | "pending";
|
||||
|
||||
interface ProgressBadge {
|
||||
label: string;
|
||||
state: BadgeState;
|
||||
}
|
||||
|
||||
function buildProgressBadges(prgrsSttsComCd: string): ProgressBadge[] {
|
||||
if (prgrsSttsComCd === "CV") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "CT" || prgrsSttsComCd === "CS" || prgrsSttsComCd === "SL") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "current" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "CP") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "completed" },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
|
||||
if (prgrsSttsComCd === "SL") {
|
||||
return <Badge color="orange">보완요청</Badge>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getBadgeColor(state: BadgeState) {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "green";
|
||||
case "current":
|
||||
return "blue";
|
||||
case "pending":
|
||||
return "lightGray";
|
||||
}
|
||||
}
|
||||
|
||||
function getBadgeStateText(state: BadgeState) {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "완료";
|
||||
case "current":
|
||||
return "진행중";
|
||||
case "pending":
|
||||
return "대기";
|
||||
}
|
||||
}
|
||||
|
||||
/** 단계명을 2줄로 분리: "설치인가 신청" → ["설치인가", "신청"], "기술검토" → ["기술검토", ""] */
|
||||
function splitStepLabel(label: string) {
|
||||
const [title = "", ...rest] = label.split(" ");
|
||||
return { title, detail: rest.join(" ") };
|
||||
}
|
||||
|
||||
function formatOptional(value: string | null | undefined) {
|
||||
return value?.trim() ? value : "-";
|
||||
}
|
||||
|
||||
interface ProgressStepBadgeProps {
|
||||
prgrsSttsComCd: string;
|
||||
}
|
||||
|
||||
function ProgressStepBadge({ prgrsSttsComCd }: ProgressStepBadgeProps) {
|
||||
const badges = buildProgressBadges(prgrsSttsComCd);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-center">
|
||||
{badges.map((badge, index) => (
|
||||
<Fragment key={`${badge.label}-${index}`}>
|
||||
{index > 0 && <span className="mx-2">></span>}
|
||||
{/* 1줄: 단계명 / 2줄: 세부단계 + 상태 (예: 설치인가 / 신청완료, 기술검토 / 완료) */}
|
||||
<Badge
|
||||
color={getBadgeColor(badge.state)}
|
||||
className="w-[100px] flex-col justify-center !px-0 py-1 text-center leading-tight"
|
||||
>
|
||||
<span>{splitStepLabel(badge.label).title}</span>
|
||||
<span>
|
||||
{splitStepLabel(badge.label).detail}
|
||||
{getBadgeStateText(badge.state)}
|
||||
</span>
|
||||
</Badge>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function createDesignCnstnColumns() {
|
||||
return [
|
||||
columnHelper.accessor("sido", {
|
||||
header: "시도",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("gugun", {
|
||||
header: "시군구",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("bizNm", {
|
||||
header: "사업명",
|
||||
meta: { align: "left" },
|
||||
cell: ({ row, getValue }) => (
|
||||
<>
|
||||
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
|
||||
</>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("taskSeCdNm", {
|
||||
header: "업무구분",
|
||||
meta: { align: "center" },
|
||||
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
|
||||
}),
|
||||
columnHelper.accessor("aplcntNm", {
|
||||
header: "담당자",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("envoSeCdNm", {
|
||||
header: "해당기관",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "progress",
|
||||
header: "진행상황",
|
||||
meta: { align: "center" },
|
||||
cell: ({ row }) => <ProgressStepBadge prgrsSttsComCd={row.original.prgrsSttsComCd || ""} />,
|
||||
}),
|
||||
columnHelper.accessor("regDt", {
|
||||
header: "신청일",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
return value ? dayjs(value).format("YYYY-MM-DD") : "-";
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SectionCollapsible } from "@/shared/ui";
|
||||
import { Field, FieldError, FieldGroup } from "@/shared/ui/field";
|
||||
import { findCodeLabel, useCodeList } from "@/entities/code";
|
||||
import { type BnsApplyData, BnsPickerDialog } from "@/widgets/bns-picker";
|
||||
import { PreviousPlanLookupButton } from "@/widgets/previous-plan-lookup";
|
||||
import { Input, Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
|
||||
|
||||
import type { DesignCnstnRegistFormData } from "../../lib/design-cnstn-regist-schema";
|
||||
@@ -16,9 +17,9 @@ function BasicInfoFormSection() {
|
||||
const { control, watch, setValue } = useFormContext<DesignCnstnRegistFormData>();
|
||||
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||
const taskSeComCd = watch("designCnstnMng.taskSeComCd");
|
||||
const localGovLabel = [watch("designCnstnMng.sido"), watch("designCnstnMng.gugun")]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const sido = watch("designCnstnMng.sido");
|
||||
const gugun = watch("designCnstnMng.gugun");
|
||||
const localGovLabel = [sido, gugun].filter(Boolean).join(" ");
|
||||
const { data: taskOptions } = useCodeList("CNFM");
|
||||
|
||||
function handleBnsSelect(data: BnsApplyData) {
|
||||
@@ -36,6 +37,7 @@ function BasicInfoFormSection() {
|
||||
|
||||
return (
|
||||
<SectionCollapsible title="기본 정보">
|
||||
<PreviousPlanLookupButton sidoNm={sido} gugunNm={gugun} />
|
||||
<Table caption="기본 정보 입력으로 사업명, 사업기간, 사업내용, 구분, 신청 지자체 정보 제공" variant="secondary">
|
||||
<colgroup>
|
||||
<col width="15%" />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import type { DesignCnstnMngResponseDto } from "@/entities/design-cnstn";
|
||||
import { PreviousPlanLookupButton } from "@/widgets/previous-plan-lookup";
|
||||
import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
|
||||
|
||||
interface BasicInfoSectionProps {
|
||||
@@ -11,6 +12,7 @@ interface BasicInfoSectionProps {
|
||||
function BasicInfoSection({ det }: BasicInfoSectionProps) {
|
||||
return (
|
||||
<SectionCollapsible title="기본 정보">
|
||||
<PreviousPlanLookupButton sidoNm={det.sido} gugunNm={det.gugun} />
|
||||
<Table caption="기본 정보로 사업명, 사업기간, 사업내용, 환경청구분, 구분, 신청지자체 정보 제공" variant="secondary">
|
||||
<colgroup>
|
||||
<col width="15%" />
|
||||
|
||||
@@ -1,338 +1,334 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
|
||||
|
||||
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
|
||||
import { TextShimmer } from "@/shared/ui";
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
|
||||
import { AttachmentDownloadLink, attachService } from "@/entities/attach";
|
||||
import type { DesignCnstnRvwOpnnResponseDto } from "@/entities/design-cnstn";
|
||||
import {
|
||||
Button,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
import type {
|
||||
ReviewOpinionFormData,
|
||||
ReviewOpinionGetFormDataResult,
|
||||
} from "../../lib/review-opinion-utils";
|
||||
import type { ReviewStepCode } from "../../model/view-types";
|
||||
import { REVIEW_STEP_LABELS } from "../../model/view-types";
|
||||
|
||||
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
|
||||
|
||||
interface TechReviewOpinionSectionProps {
|
||||
rvwList: DesignCnstnRvwOpnnResponseDto[];
|
||||
editableReviewStep?: ReviewStepCode;
|
||||
atrzSeComCdList?: string[];
|
||||
isAtrzLoading?: boolean;
|
||||
}
|
||||
|
||||
export interface TechReviewOpinionSectionHandle {
|
||||
getFormData: () => ReviewOpinionGetFormDataResult | null;
|
||||
resetForm: () => void;
|
||||
}
|
||||
|
||||
interface TechReviewOpinionTableProps {
|
||||
items: DesignCnstnRvwOpnnResponseDto[];
|
||||
}
|
||||
|
||||
interface UploadedOpinionFile {
|
||||
fileName: string;
|
||||
fileKey: string;
|
||||
atflId?: string;
|
||||
atflCd?: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
|
||||
rvwRsltCn: "",
|
||||
rvwRsltAnmlyYn: "",
|
||||
atchFileGroupId: "",
|
||||
opnrSeComCd: "",
|
||||
};
|
||||
|
||||
function createRvwOpnnUploadKey(file: File) {
|
||||
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
|
||||
}
|
||||
|
||||
function hasFormContent(formData: ReviewOpinionFormData) {
|
||||
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
|
||||
}
|
||||
|
||||
const TechReviewOpinionSection = forwardRef<
|
||||
TechReviewOpinionSectionHandle,
|
||||
TechReviewOpinionSectionProps
|
||||
>(function TechReviewOpinionSection(
|
||||
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
|
||||
ref,
|
||||
) {
|
||||
const canWriteOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
|
||||
const [formData, setFormData] = useState<ReviewOpinionFormData>({
|
||||
...EMPTY_FORM_DATA,
|
||||
opnrSeComCd: editableReviewStep ?? "",
|
||||
});
|
||||
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
opnrSeComCd: editableReviewStep ?? "",
|
||||
}));
|
||||
}, [editableReviewStep]);
|
||||
|
||||
const { uploadFileAsync, isUploading, uploads } = useTus({
|
||||
onSuccess: (atflCd, atflId, _file, fileKey) => {
|
||||
if (!atflCd || !atflId) return;
|
||||
|
||||
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
|
||||
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
|
||||
},
|
||||
onError: (_error, _file, fileKey) => {
|
||||
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
|
||||
},
|
||||
});
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
getFormData: () => {
|
||||
if (!canWriteOpinion || !editableReviewStep || !hasFormContent(formData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
formData: {
|
||||
...formData,
|
||||
opnrSeComCd: editableReviewStep,
|
||||
},
|
||||
rvwList,
|
||||
};
|
||||
},
|
||||
resetForm: () => {
|
||||
setFormData({
|
||||
...EMPTY_FORM_DATA,
|
||||
opnrSeComCd: editableReviewStep ?? "",
|
||||
});
|
||||
setUploadedFile(null);
|
||||
},
|
||||
}),
|
||||
[canWriteOpinion, editableReviewStep, formData, rvwList],
|
||||
);
|
||||
|
||||
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
|
||||
const hasKecoSection =
|
||||
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteOpinion);
|
||||
|
||||
if (!hasKecoSection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleFileUpload(files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
const fileKey = createRvwOpnnUploadKey(file);
|
||||
const existingAtflCd = formData.atchFileGroupId;
|
||||
|
||||
setUploadedFile({ fileName: file.name, fileKey });
|
||||
|
||||
try {
|
||||
await uploadFileAsync(file, {
|
||||
fileKey,
|
||||
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
|
||||
});
|
||||
} catch {
|
||||
// onError callback handles cleanup
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileRemove() {
|
||||
if (!uploadedFile) return;
|
||||
|
||||
uploads[uploadedFile.fileKey]?.abort();
|
||||
|
||||
if (uploadedFile.atflId) {
|
||||
try {
|
||||
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
|
||||
} catch (error) {
|
||||
console.error("검토의견 첨부파일 삭제 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
setUploadedFile(null);
|
||||
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
|
||||
}
|
||||
|
||||
function renderOpinionForm(reviewStep: ReviewStepCode) {
|
||||
if (editableReviewStep !== reviewStep || !canWriteOpinion) return null;
|
||||
|
||||
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xl font-semibold">검토의견 작성</h4>
|
||||
<Table caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공" variant="secondary">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead scope="row" className="w-[12rem]">의견내용</TableHead>
|
||||
<TableCell>
|
||||
<Textarea
|
||||
value={formData.rvwRsltCn}
|
||||
onChange={(event) =>
|
||||
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
|
||||
}
|
||||
placeholder="의견내용을 입력하세요"
|
||||
rows={4}
|
||||
className="min-h-[8rem] !text-xl"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead scope="row">검토결과</TableHead>
|
||||
<TableCell>
|
||||
<FieldSet>
|
||||
<RadioGroup
|
||||
value={formData.rvwRsltAnmlyYn}
|
||||
onValueChange={(value) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<FieldLabel
|
||||
htmlFor={`rvw-opnn-normal-${reviewStep}`}
|
||||
className="!w-auto border-none"
|
||||
>
|
||||
<Field orientation="horizontal" className="w-auto">
|
||||
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
|
||||
<span className="font-normal">정상</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
<FieldLabel
|
||||
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
|
||||
className="!w-auto border-none"
|
||||
>
|
||||
<Field orientation="horizontal" className="w-auto">
|
||||
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
|
||||
<span className="font-normal">이상</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FieldSet>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead scope="row">첨부파일</TableHead>
|
||||
<TableCell>
|
||||
{uploadedFile ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<TextShimmer className="text-xl" active={uploading}>
|
||||
{uploadedFile.fileName}
|
||||
</TextShimmer>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleFileRemove();
|
||||
}}
|
||||
disabled={uploading}
|
||||
className="size-8 !bg-destructive"
|
||||
>
|
||||
<i className="diveicon di-x text-xs" aria-label="삭제" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<label>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void handleFileUpload(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" variant="outline" type="button" asChild>
|
||||
<span>파일 선택</span>
|
||||
</Button>
|
||||
</label>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
|
||||
<div className="space-y-6">
|
||||
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null}
|
||||
{renderOpinionForm("KECO")}
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
});
|
||||
|
||||
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<Table caption="검토의견 목록으로 의견자명, 의견내용, 검토결과, 첨부파일, 등록일시 정보 제공" variant="secondary">
|
||||
<colgroup>
|
||||
<col width="10%" />
|
||||
<col width="55%" />
|
||||
<col width="5%" />
|
||||
<col width="20%" />
|
||||
<col width="10%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>의견자명</TableHead>
|
||||
<TableHead>의견내용</TableHead>
|
||||
<TableHead>검토결과</TableHead>
|
||||
<TableHead>첨부파일</TableHead>
|
||||
<TableHead>등록일시</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={`${item.designCnstnDocNo}_${item.rvwOpnnSn}`}>
|
||||
<TableCell align="center">{item.opnrNm}</TableCell>
|
||||
<TableCell align="left">{item.rvwRsltCn || "-"}</TableCell>
|
||||
<TableCell align="center">
|
||||
{item.rvwRsltAnmlyYn === "Y" ? "이상" : item.rvwRsltAnmlyYn === "N" ? "정상" : "-"}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{item.atchFileInfo?.atflCd ? (
|
||||
<AttachmentDownloadLink
|
||||
atflCd={item.atchFileInfo.atflCd}
|
||||
fileName={item.atchFileInfo.orgnlFileNm || "다운로드"}
|
||||
/>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { TechReviewOpinionSection };
|
||||
"use client";
|
||||
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { getDefaultFileKey, useTus, useUserStore } from "@/shared/lib";
|
||||
import { TextShimmer } from "@/shared/ui";
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
|
||||
import { attachService } from "@/entities/attach";
|
||||
import {
|
||||
designCnstnKeys,
|
||||
type DesignCnstnRvwOpnnResponseDto,
|
||||
designCnstnRvwOpnnService,
|
||||
} from "@/entities/design-cnstn";
|
||||
import { RvwOpnnListTable } from "@/widgets/pbadms-sprt";
|
||||
import {
|
||||
Button,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Table,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
import type {
|
||||
ReviewOpinionFormData,
|
||||
ReviewOpinionGetFormDataResult,
|
||||
} from "../../lib/review-opinion-utils";
|
||||
import type { ReviewStepCode } from "../../model/view-types";
|
||||
import { REVIEW_STEP_LABELS } from "../../model/view-types";
|
||||
|
||||
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
|
||||
|
||||
interface TechReviewOpinionSectionProps {
|
||||
rvwList: DesignCnstnRvwOpnnResponseDto[];
|
||||
editableReviewStep?: ReviewStepCode;
|
||||
atrzSeComCdList?: string[];
|
||||
isAtrzLoading?: boolean;
|
||||
}
|
||||
|
||||
export interface TechReviewOpinionSectionHandle {
|
||||
getFormData: () => ReviewOpinionGetFormDataResult | null;
|
||||
resetForm: () => void;
|
||||
}
|
||||
|
||||
interface TechReviewOpinionTableProps {
|
||||
items: DesignCnstnRvwOpnnResponseDto[];
|
||||
}
|
||||
|
||||
interface UploadedOpinionFile {
|
||||
fileName: string;
|
||||
fileKey: string;
|
||||
atflId?: string;
|
||||
atflCd?: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
|
||||
rvwRsltCn: "",
|
||||
rvwRsltAnmlyYn: "",
|
||||
atchFileGroupId: "",
|
||||
opnrSeComCd: "",
|
||||
};
|
||||
|
||||
function createRvwOpnnUploadKey(file: File) {
|
||||
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
|
||||
}
|
||||
|
||||
function hasFormContent(formData: ReviewOpinionFormData) {
|
||||
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
|
||||
}
|
||||
|
||||
const TechReviewOpinionSection = forwardRef<
|
||||
TechReviewOpinionSectionHandle,
|
||||
TechReviewOpinionSectionProps
|
||||
>(function TechReviewOpinionSection(
|
||||
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
|
||||
ref,
|
||||
) {
|
||||
const canWriteOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
|
||||
const [formData, setFormData] = useState<ReviewOpinionFormData>({
|
||||
...EMPTY_FORM_DATA,
|
||||
opnrSeComCd: editableReviewStep ?? "",
|
||||
});
|
||||
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
opnrSeComCd: editableReviewStep ?? "",
|
||||
}));
|
||||
}, [editableReviewStep]);
|
||||
|
||||
const { uploadFileAsync, isUploading, uploads } = useTus({
|
||||
onSuccess: (atflCd, atflId, _file, fileKey) => {
|
||||
if (!atflCd || !atflId) return;
|
||||
|
||||
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
|
||||
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
|
||||
},
|
||||
onError: (_error, _file, fileKey) => {
|
||||
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
|
||||
},
|
||||
});
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
getFormData: () => {
|
||||
if (!canWriteOpinion || !editableReviewStep || !hasFormContent(formData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
formData: {
|
||||
...formData,
|
||||
opnrSeComCd: editableReviewStep,
|
||||
},
|
||||
rvwList,
|
||||
};
|
||||
},
|
||||
resetForm: () => {
|
||||
setFormData({
|
||||
...EMPTY_FORM_DATA,
|
||||
opnrSeComCd: editableReviewStep ?? "",
|
||||
});
|
||||
setUploadedFile(null);
|
||||
},
|
||||
}),
|
||||
[canWriteOpinion, editableReviewStep, formData, rvwList],
|
||||
);
|
||||
|
||||
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
|
||||
const hasKecoSection =
|
||||
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteOpinion);
|
||||
|
||||
if (!hasKecoSection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleFileUpload(files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
const fileKey = createRvwOpnnUploadKey(file);
|
||||
const existingAtflCd = formData.atchFileGroupId;
|
||||
|
||||
setUploadedFile({ fileName: file.name, fileKey });
|
||||
|
||||
try {
|
||||
await uploadFileAsync(file, {
|
||||
fileKey,
|
||||
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
|
||||
});
|
||||
} catch {
|
||||
// onError callback handles cleanup
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileRemove() {
|
||||
if (!uploadedFile) return;
|
||||
|
||||
uploads[uploadedFile.fileKey]?.abort();
|
||||
|
||||
if (uploadedFile.atflId) {
|
||||
try {
|
||||
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
|
||||
} catch (error) {
|
||||
console.error("검토의견 첨부파일 삭제 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
setUploadedFile(null);
|
||||
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
|
||||
}
|
||||
|
||||
function isReviewTurn(reviewStep: ReviewStepCode) {
|
||||
return editableReviewStep === reviewStep && canWriteOpinion;
|
||||
}
|
||||
|
||||
function renderOpinionForm(reviewStep: ReviewStepCode) {
|
||||
if (!isReviewTurn(reviewStep)) return null;
|
||||
|
||||
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xl font-semibold">검토의견 작성</h4>
|
||||
<Table
|
||||
caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공"
|
||||
variant="secondary"
|
||||
>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead scope="row" className="w-[12rem]">
|
||||
의견내용
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
<Textarea
|
||||
value={formData.rvwRsltCn}
|
||||
onChange={(event) =>
|
||||
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
|
||||
}
|
||||
placeholder="의견내용을 입력하세요"
|
||||
rows={4}
|
||||
className="min-h-[8rem] !text-xl"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead scope="row">검토결과</TableHead>
|
||||
<TableCell>
|
||||
<FieldSet>
|
||||
<RadioGroup
|
||||
value={formData.rvwRsltAnmlyYn}
|
||||
onValueChange={(value) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<FieldLabel
|
||||
htmlFor={`rvw-opnn-normal-${reviewStep}`}
|
||||
className="!w-auto border-none"
|
||||
>
|
||||
<Field orientation="horizontal" className="w-auto">
|
||||
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
|
||||
<span className="font-normal">정상</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
<FieldLabel
|
||||
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
|
||||
className="!w-auto border-none"
|
||||
>
|
||||
<Field orientation="horizontal" className="w-auto">
|
||||
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
|
||||
<span className="font-normal">이상</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FieldSet>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead scope="row">첨부파일</TableHead>
|
||||
<TableCell>
|
||||
{uploadedFile ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<TextShimmer className="text-xl" active={uploading}>
|
||||
{uploadedFile.fileName}
|
||||
</TextShimmer>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleFileRemove();
|
||||
}}
|
||||
disabled={uploading}
|
||||
className="size-8 !bg-destructive"
|
||||
>
|
||||
<i className="diveicon di-x text-xs" aria-label="삭제" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<label>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void handleFileUpload(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" variant="outline" type="button" asChild>
|
||||
<span>파일 선택</span>
|
||||
</Button>
|
||||
</label>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
|
||||
<div className="space-y-6">
|
||||
{kecoRvwList.length > 0 ? (
|
||||
<TechReviewOpinionTable items={kecoRvwList} />
|
||||
) : null}
|
||||
{renderOpinionForm("KECO")}
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
});
|
||||
|
||||
function getRvwOpnnIds(item: DesignCnstnRvwOpnnResponseDto) {
|
||||
if (!item.designCnstnDocNo || item.rvwOpnnSn == null) {
|
||||
throw new Error("검토의견 식별값이 없습니다.");
|
||||
}
|
||||
return [item.designCnstnDocNo, item.rvwOpnnSn] as const;
|
||||
}
|
||||
|
||||
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
|
||||
|
||||
return (
|
||||
<RvwOpnnListTable
|
||||
items={items}
|
||||
getRowKey={(item) => `${item.designCnstnDocNo}_${item.rvwOpnnSn}`}
|
||||
currentUserUuid={currentUserUuid}
|
||||
onUpdate={async (item, body) => {
|
||||
await designCnstnRvwOpnnService.update(...getRvwOpnnIds(item), body);
|
||||
await queryClient.invalidateQueries({ queryKey: designCnstnKeys.all });
|
||||
}}
|
||||
onDelete={async (item) => {
|
||||
await designCnstnRvwOpnnService.delete(...getRvwOpnnIds(item));
|
||||
await queryClient.invalidateQueries({ queryKey: designCnstnKeys.all });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { TechReviewOpinionSection };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -1,230 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
|
||||
import { dayjs } from "@/shared/lib";
|
||||
import type { FnrsCnsltnMngResponseDto } from "@/entities/fnrs-cnsltn";
|
||||
import { Badge } from "@common/components";
|
||||
|
||||
const columnHelper = createColumnHelper<FnrsCnsltnMngResponseDto>();
|
||||
|
||||
const MAIN_STEP_LABELS = {
|
||||
apply: "재원협의 신청",
|
||||
envoReview: "재원협의 검토",
|
||||
techReview: "기술검토",
|
||||
reply: "검토결과 회신",
|
||||
} as const;
|
||||
|
||||
export const PROGRESS_STATUS_COLUMNS = [
|
||||
{ label: "재원협의 신청 (시군)", sttsComCd: "ST" },
|
||||
{ label: "재원협의 신청 (시도)", sttsComCd: "LE" },
|
||||
{ label: "재원협의 검토", sttsComCd: "ER" },
|
||||
{ label: "기술검토", sttsComCd: "TE" },
|
||||
{ label: "기술검토 회신", sttsComCd: "TP" },
|
||||
{ label: "환경청 검토 결과 회신", sttsComCd: "EP" },
|
||||
];
|
||||
|
||||
type BadgeState = "completed" | "current" | "pending";
|
||||
|
||||
interface ProgressBadge {
|
||||
label: string;
|
||||
state: BadgeState;
|
||||
}
|
||||
|
||||
function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N"): ProgressBadge[] {
|
||||
if (prgrsSttsComCd === "SV") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "ST") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "current" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "LE") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "current" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (["FV", "ER", "AC", "TE", "RB", "RA", "AV", "EV", "RK", "RR", "SL"].includes(prgrsSttsComCd)) {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "current" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "TP") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
|
||||
{
|
||||
label: MAIN_STEP_LABELS.techReview,
|
||||
state: techRplyHstryYn === "Y" ? "completed" : "pending",
|
||||
},
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "current" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "EP" || prgrsSttsComCd === "TA") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
|
||||
{
|
||||
label: MAIN_STEP_LABELS.techReview,
|
||||
state: techRplyHstryYn === "Y" ? "completed" : "pending",
|
||||
},
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "completed" },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
|
||||
if (prgrsSttsComCd === "SL") {
|
||||
return <Badge color="orange">보완요청</Badge>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getBadgeColor(state: BadgeState) {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "green";
|
||||
case "current":
|
||||
return "blue";
|
||||
case "pending":
|
||||
return "lightGray";
|
||||
}
|
||||
}
|
||||
|
||||
function getBadgeStateText(state: BadgeState) {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "완료";
|
||||
case "current":
|
||||
return "진행중";
|
||||
case "pending":
|
||||
return "대기";
|
||||
}
|
||||
}
|
||||
|
||||
/** 단계명을 2줄로 분리: "설치인가 신청" → ["설치인가", "신청"], "기술검토" → ["기술검토", ""] */
|
||||
function splitStepLabel(label: string) {
|
||||
const [title = "", ...rest] = label.split(" ");
|
||||
return { title, detail: rest.join(" ") };
|
||||
}
|
||||
|
||||
function formatOptional(value: string | null | undefined) {
|
||||
return value?.trim() ? value : "-";
|
||||
}
|
||||
|
||||
interface ProgressStepBadgeProps {
|
||||
prgrsSttsComCd: string;
|
||||
techRplyHstryYn: "Y" | "N";
|
||||
}
|
||||
|
||||
function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadgeProps) {
|
||||
const badges = buildProgressBadges(prgrsSttsComCd, techRplyHstryYn);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-start">
|
||||
{badges.map((badge, index) => (
|
||||
<Fragment key={badge.label}>
|
||||
{index > 0 && <span className="mx-2">></span>}
|
||||
{/* 1줄: 단계명 / 2줄: 세부단계 + 상태 (예: 설치인가 / 신청완료, 기술검토 / 완료) */}
|
||||
<Badge
|
||||
color={getBadgeColor(badge.state)}
|
||||
className="flex-col justify-center !px-[20px] py-1 text-center leading-tight"
|
||||
>
|
||||
<span>{splitStepLabel(badge.label).title}</span>
|
||||
<span>
|
||||
{splitStepLabel(badge.label).detail}
|
||||
{getBadgeStateText(badge.state)}
|
||||
</span>
|
||||
</Badge>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function createFnrsCnsltnColumns() {
|
||||
return [
|
||||
columnHelper.accessor("sido", {
|
||||
header: "시도",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("gugun", {
|
||||
header: "시군구",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("bizNm", {
|
||||
header: "사업명",
|
||||
meta: { align: "left" },
|
||||
cell: ({ row, getValue }) => (
|
||||
<>
|
||||
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
|
||||
</>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("taskSeCdNm", {
|
||||
header: "업무구분",
|
||||
meta: { align: "center" },
|
||||
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
|
||||
}),
|
||||
columnHelper.accessor("aplcntNm", {
|
||||
header: "담당자",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("envoSeCdNm", {
|
||||
header: "해당기관",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "progress",
|
||||
header: "진행상황",
|
||||
meta: { align: "center" },
|
||||
cell: ({ row }) => (
|
||||
<ProgressStepBadge
|
||||
prgrsSttsComCd={row.original.prgrsSttsComCd || ""}
|
||||
techRplyHstryYn={row.original.techRplyHstryYn || "N"}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("regDt", {
|
||||
header: "신청일",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
return value ? dayjs(value).format("YYYY-MM-DD") : "-";
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
|
||||
import { dayjs } from "@/shared/lib";
|
||||
import type { FnrsCnsltnMngResponseDto } from "@/entities/fnrs-cnsltn";
|
||||
import { Badge } from "@common/components";
|
||||
|
||||
const columnHelper = createColumnHelper<FnrsCnsltnMngResponseDto>();
|
||||
|
||||
const MAIN_STEP_LABELS = {
|
||||
apply: "재원협의 신청",
|
||||
envoReview: "재원협의 검토",
|
||||
techReview: "기술검토",
|
||||
reply: "검토결과 회신",
|
||||
} as const;
|
||||
|
||||
export const PROGRESS_STATUS_COLUMNS = [
|
||||
{ label: "재원협의 신청 (시군)", sttsComCd: "ST" },
|
||||
{ label: "재원협의 신청 (시도)", sttsComCd: "LE" },
|
||||
{ label: "재원협의 검토", sttsComCd: "ER" },
|
||||
{ label: "기술검토", sttsComCd: "TE" },
|
||||
{ label: "기술검토 회신", sttsComCd: "TP" },
|
||||
{ label: "환경청 검토 결과 회신", sttsComCd: "EP" },
|
||||
];
|
||||
|
||||
type BadgeState = "completed" | "current" | "pending";
|
||||
|
||||
interface ProgressBadge {
|
||||
label: string;
|
||||
state: BadgeState;
|
||||
}
|
||||
|
||||
function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N"): ProgressBadge[] {
|
||||
if (prgrsSttsComCd === "SV") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "ST") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "current" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "LE") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "current" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (["FV", "ER", "AC", "TE", "RB", "RA", "AV", "EV", "RK", "RR", "SL"].includes(prgrsSttsComCd)) {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "current" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "TP") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
|
||||
{
|
||||
label: MAIN_STEP_LABELS.techReview,
|
||||
state: techRplyHstryYn === "Y" ? "completed" : "pending",
|
||||
},
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "current" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "EP" || prgrsSttsComCd === "TA") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
|
||||
{
|
||||
label: MAIN_STEP_LABELS.techReview,
|
||||
state: techRplyHstryYn === "Y" ? "completed" : "pending",
|
||||
},
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "completed" },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
|
||||
if (prgrsSttsComCd === "SL") {
|
||||
return <Badge color="orange">보완요청</Badge>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getBadgeColor(state: BadgeState) {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "green";
|
||||
case "current":
|
||||
return "blue";
|
||||
case "pending":
|
||||
return "lightGray";
|
||||
}
|
||||
}
|
||||
|
||||
function getBadgeStateText(state: BadgeState) {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "완료";
|
||||
case "current":
|
||||
return "진행중";
|
||||
case "pending":
|
||||
return "대기";
|
||||
}
|
||||
}
|
||||
|
||||
/** 단계명을 2줄로 분리: "설치인가 신청" → ["설치인가", "신청"], "기술검토" → ["기술검토", ""] */
|
||||
function splitStepLabel(label: string) {
|
||||
const [title = "", ...rest] = label.split(" ");
|
||||
return { title, detail: rest.join(" ") };
|
||||
}
|
||||
|
||||
function formatOptional(value: string | null | undefined) {
|
||||
return value?.trim() ? value : "-";
|
||||
}
|
||||
|
||||
interface ProgressStepBadgeProps {
|
||||
prgrsSttsComCd: string;
|
||||
techRplyHstryYn: "Y" | "N";
|
||||
}
|
||||
|
||||
function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadgeProps) {
|
||||
const badges = buildProgressBadges(prgrsSttsComCd, techRplyHstryYn);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-center">
|
||||
{badges.map((badge, index) => (
|
||||
<Fragment key={badge.label}>
|
||||
{index > 0 && <span className="mx-2">></span>}
|
||||
{/* 1줄: 단계명 / 2줄: 세부단계 + 상태 (예: 설치인가 / 신청완료, 기술검토 / 완료) */}
|
||||
<Badge
|
||||
color={getBadgeColor(badge.state)}
|
||||
className="w-[100px] flex-col justify-center !px-0 py-1 text-center leading-tight"
|
||||
>
|
||||
<span>{splitStepLabel(badge.label).title}</span>
|
||||
<span>
|
||||
{splitStepLabel(badge.label).detail}
|
||||
{getBadgeStateText(badge.state)}
|
||||
</span>
|
||||
</Badge>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function createFnrsCnsltnColumns() {
|
||||
return [
|
||||
columnHelper.accessor("sido", {
|
||||
header: "시도",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("gugun", {
|
||||
header: "시군구",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("bizNm", {
|
||||
header: "사업명",
|
||||
meta: { align: "left" },
|
||||
cell: ({ row, getValue }) => (
|
||||
<>
|
||||
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
|
||||
</>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("taskSeCdNm", {
|
||||
header: "업무구분",
|
||||
meta: { align: "center" },
|
||||
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
|
||||
}),
|
||||
columnHelper.accessor("aplcntNm", {
|
||||
header: "담당자",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("envoSeCdNm", {
|
||||
header: "해당기관",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "progress",
|
||||
header: "진행상황",
|
||||
meta: { align: "center" },
|
||||
cell: ({ row }) => (
|
||||
<ProgressStepBadge
|
||||
prgrsSttsComCd={row.original.prgrsSttsComCd || ""}
|
||||
techRplyHstryYn={row.original.techRplyHstryYn || "N"}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("regDt", {
|
||||
header: "신청일",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
return value ? dayjs(value).format("YYYY-MM-DD") : "-";
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ import {
|
||||
interface PwrkCnsltnDocEvlDialogProps {
|
||||
open: boolean;
|
||||
pwrkCnsltnDocNo: string;
|
||||
/** 평가 저장 성공 시 호출 (이후 웹기안기 문서 저장이 진행된다) */
|
||||
onSaved: () => void;
|
||||
/** 평가 없이 닫을 때 호출 (웹기안기 문서는 저장되지 않는다) */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -41,7 +44,12 @@ const DEFAULT_VALUES: Partial<PwrkCnsltnDocEvlFormValues> = {
|
||||
pwrkCnsltnDocEvlCn: "",
|
||||
};
|
||||
|
||||
function PwrkCnsltnDocEvlDialog({ open, pwrkCnsltnDocNo, onClose }: PwrkCnsltnDocEvlDialogProps) {
|
||||
function PwrkCnsltnDocEvlDialog({
|
||||
open,
|
||||
pwrkCnsltnDocNo,
|
||||
onSaved,
|
||||
onClose,
|
||||
}: PwrkCnsltnDocEvlDialogProps) {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const { mutateAsync: saveEvl, isPending } = useSavePwrkCnsltnDocEvl();
|
||||
|
||||
@@ -65,8 +73,7 @@ function PwrkCnsltnDocEvlDialog({ open, pwrkCnsltnDocNo, onClose }: PwrkCnsltnDo
|
||||
try {
|
||||
const payload = buildPwrkCnsltnDocEvlSavePayload(pwrkCnsltnDocNo, values);
|
||||
await saveEvl(payload);
|
||||
alert("AI 초안 만족도 평가를 등록했습니다.");
|
||||
onClose();
|
||||
onSaved();
|
||||
} catch (error) {
|
||||
console.error("AI 초안 만족도 평가 등록 실패:", error);
|
||||
alert("AI 초안 만족도 평가 등록에 실패했습니다.");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -46,6 +46,7 @@ const HangulGiangi = dynamic(
|
||||
|
||||
const PWRK_ATCH_FILE_KEY_PREFIX = "pwrk-cnsltn-doc::";
|
||||
|
||||
/** 현재 버전이 이 값보다 작으면(AI 초안 상태) 저장 전에 만족도 평가를 먼저 받는다. */
|
||||
const AI_DRAFT_EVL_MAX_VER_SN = 2;
|
||||
|
||||
const APPROVER_ATRZ_SE_COM_CDS = new Set(["ATRZ03", "ATRZ99"]);
|
||||
@@ -88,6 +89,8 @@ function PwrkCnsltnDocMngDialog({
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedAtchFile[]>([]);
|
||||
const [isPjtcoAjmtTableOpen, setIsPjtcoAjmtTableOpen] = useState(false);
|
||||
const [isEvlDialogOpen, setIsEvlDialogOpen] = useState(false);
|
||||
// 평가 모달 결과(평가 저장 여부)를 저장 흐름으로 돌려주는 resolver
|
||||
const evlResolverRef = useRef<((isEvlSaved: boolean) => void) | null>(null);
|
||||
const { mutate: downloadByAtflId } = useDownloadAttachmentByAtflId();
|
||||
|
||||
const {
|
||||
@@ -145,6 +148,8 @@ function PwrkCnsltnDocMngDialog({
|
||||
setAtchFileGroupId("");
|
||||
setUploadedFiles([]);
|
||||
setIsEvlDialogOpen(false);
|
||||
evlResolverRef.current?.(false);
|
||||
evlResolverRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -178,9 +183,32 @@ function PwrkCnsltnDocMngDialog({
|
||||
if (!nextOpen) onClose();
|
||||
}
|
||||
|
||||
function requestEvl() {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
evlResolverRef.current = resolve;
|
||||
setIsEvlDialogOpen(true);
|
||||
});
|
||||
}
|
||||
|
||||
function resolveEvl(isEvlSaved: boolean) {
|
||||
setIsEvlDialogOpen(false);
|
||||
evlResolverRef.current?.(isEvlSaved);
|
||||
evlResolverRef.current = null;
|
||||
}
|
||||
|
||||
async function handleSaveDocument(file: Blob) {
|
||||
if (!completedDraft) throw new Error("저장할 실무협의서 초안이 없습니다.");
|
||||
|
||||
// AI 초안(버전 < 2) 저장 시에는 만족도 평가를 먼저 저장해야 웹기안기 문서가 저장된다.
|
||||
const currentVerSn = completedDraft.crtRslt?.verSn ?? 0;
|
||||
if (currentVerSn < AI_DRAFT_EVL_MAX_VER_SN) {
|
||||
const isEvlSaved = await requestEvl();
|
||||
if (!isEvlSaved) {
|
||||
alert("AI 초안 만족도 평가를 등록해야 실무협의서가 저장됩니다.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const saveDto = pwrkCnsltnDocCrtRsltSaveRequestSchema.parse({
|
||||
crtDmndUuid: completedDraft.crtDmndUuid,
|
||||
pwrkCnsltnDocNo: completedDraft.pwrkCnsltnDocNo,
|
||||
@@ -201,10 +229,6 @@ function PwrkCnsltnDocMngDialog({
|
||||
}),
|
||||
]);
|
||||
alert(`실무협의서를 저장했습니다. (버전 ${savedDocument.verSn})`);
|
||||
|
||||
if (savedDocument.verSn <= AI_DRAFT_EVL_MAX_VER_SN) {
|
||||
setIsEvlDialogOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownload() {
|
||||
@@ -386,7 +410,8 @@ function PwrkCnsltnDocMngDialog({
|
||||
<PwrkCnsltnDocEvlDialog
|
||||
open={isEvlDialogOpen}
|
||||
pwrkCnsltnDocNo={completedDraft.pwrkCnsltnDocNo}
|
||||
onClose={() => setIsEvlDialogOpen(false)}
|
||||
onSaved={() => resolveEvl(true)}
|
||||
onClose={() => resolveEvl(false)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SectionCollapsible } from "@/shared/ui";
|
||||
import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from "@/shared/ui/field";
|
||||
import { findCodeLabel, useCodeList } from "@/entities/code";
|
||||
import { type BnsApplyData, BnsPickerDialog } from "@/widgets/bns-picker";
|
||||
import { PreviousPlanLookupButton } from "@/widgets/previous-plan-lookup";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
@@ -27,9 +28,9 @@ function BasicInfoFormSection() {
|
||||
const { control, watch, setValue } = useFormContext<FnrsCnsltnRegistFormData>();
|
||||
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||
const taskSeComCd = watch("fnrsCnsltnMng.taskSeComCd");
|
||||
const localGovLabel = [watch("fnrsCnsltnMng.sido"), watch("fnrsCnsltnMng.gugun")]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const sido = watch("fnrsCnsltnMng.sido");
|
||||
const gugun = watch("fnrsCnsltnMng.gugun");
|
||||
const localGovLabel = [sido, gugun].filter(Boolean).join(" ");
|
||||
const { data: taskOptions } = useCodeList("FNRS");
|
||||
const { data: rtlztnFcltOptions } = useCodeList("RTLZTNFCLTSE");
|
||||
const { data: rtlztnUsgOptions } = useCodeList("RTLZTNUSGSE");
|
||||
@@ -49,6 +50,7 @@ function BasicInfoFormSection() {
|
||||
|
||||
return (
|
||||
<SectionCollapsible title="기본 정보">
|
||||
<PreviousPlanLookupButton sidoNm={sido} gugunNm={gugun} />
|
||||
<Table caption="기본 정보 입력으로 사업명, 사업기간, 사업내용, 구분, 신청 지자체, 시설종류, 재이용용도 정보 제공" variant="secondary">
|
||||
<colgroup>
|
||||
<col width="15%" />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import type { FnrsCnsltnMngResponseDto } from "@/entities/fnrs-cnsltn";
|
||||
import { PreviousPlanLookupButton } from "@/widgets/previous-plan-lookup";
|
||||
import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
|
||||
|
||||
interface BasicInfoSectionProps {
|
||||
@@ -11,6 +12,7 @@ interface BasicInfoSectionProps {
|
||||
function BasicInfoSection({ det }: BasicInfoSectionProps) {
|
||||
return (
|
||||
<SectionCollapsible title="기본 정보">
|
||||
<PreviousPlanLookupButton sidoNm={det.sido} gugunNm={det.gugun} />
|
||||
<Table caption="기본 정보로 사업명, 사업기간, 사업내용, 환경청구분, 구분, 신청지자체, 확정예산사업번호, 예산신청연도 정보 제공" variant="secondary">
|
||||
<colgroup>
|
||||
<col width="15%" />
|
||||
|
||||
@@ -1,352 +1,345 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
|
||||
|
||||
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
|
||||
import { AttachmentFileField, SectionCollapsible } from "@/shared/ui";
|
||||
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
|
||||
import {
|
||||
AttachmentDownloadLink,
|
||||
attachService,
|
||||
useDownloadAttachmentByAtflId,
|
||||
} from "@/entities/attach";
|
||||
import type { FnrsCnsltnRvwOpnnResponseDto } from "@/entities/fnrs-cnsltn";
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
import type {
|
||||
ReviewOpinionFormData,
|
||||
ReviewOpinionGetFormDataResult,
|
||||
} from "../../lib/review-opinion-utils";
|
||||
import type { ReviewStepCode } from "../../model/view-types";
|
||||
import { REVIEW_STEP_LABELS } from "../../model/view-types";
|
||||
|
||||
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
|
||||
|
||||
interface TechReviewOpinionSectionProps {
|
||||
rvwList: FnrsCnsltnRvwOpnnResponseDto[];
|
||||
editableReviewStep?: ReviewStepCode;
|
||||
atrzSeComCdList?: string[];
|
||||
isAtrzLoading?: boolean;
|
||||
}
|
||||
|
||||
export interface TechReviewOpinionSectionHandle {
|
||||
getFormData: () => ReviewOpinionGetFormDataResult | null;
|
||||
resetForm: () => void;
|
||||
}
|
||||
|
||||
interface TechReviewOpinionTableProps {
|
||||
items: FnrsCnsltnRvwOpnnResponseDto[];
|
||||
}
|
||||
|
||||
interface UploadedOpinionFile {
|
||||
fileName: string;
|
||||
fileKey: string;
|
||||
atflId?: string;
|
||||
atflCd?: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
|
||||
rvwRsltCn: "",
|
||||
rvwRsltAnmlyYn: "",
|
||||
atchFileGroupId: "",
|
||||
opnrSeComCd: "",
|
||||
};
|
||||
|
||||
function createRvwOpnnUploadKey(file: File) {
|
||||
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
|
||||
}
|
||||
|
||||
function hasFormContent(formData: ReviewOpinionFormData) {
|
||||
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
|
||||
}
|
||||
|
||||
const TechReviewOpinionSection = forwardRef<
|
||||
TechReviewOpinionSectionHandle,
|
||||
TechReviewOpinionSectionProps
|
||||
>(function TechReviewOpinionSection(
|
||||
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
|
||||
ref,
|
||||
) {
|
||||
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
|
||||
const [formData, setFormData] = useState<ReviewOpinionFormData>({
|
||||
...EMPTY_FORM_DATA,
|
||||
opnrSeComCd: editableReviewStep ?? "",
|
||||
});
|
||||
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
|
||||
const { mutate: downloadByAtflId } = useDownloadAttachmentByAtflId();
|
||||
|
||||
useEffect(() => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
opnrSeComCd: editableReviewStep ?? "",
|
||||
}));
|
||||
}, [editableReviewStep]);
|
||||
|
||||
const { uploadFileAsync, isUploading, uploads } = useTus({
|
||||
onSuccess: (atflCd, atflId, _file, fileKey) => {
|
||||
if (!atflCd || !atflId) return;
|
||||
|
||||
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
|
||||
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
|
||||
},
|
||||
onError: (_error, _file, fileKey) => {
|
||||
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
|
||||
},
|
||||
});
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
getFormData: () => {
|
||||
if (!editableReviewStep || !hasFormContent(formData)) {
|
||||
return null;
|
||||
}
|
||||
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
formData: {
|
||||
...formData,
|
||||
opnrSeComCd: editableReviewStep,
|
||||
},
|
||||
rvwList,
|
||||
};
|
||||
},
|
||||
resetForm: () => {
|
||||
setFormData({
|
||||
...EMPTY_FORM_DATA,
|
||||
opnrSeComCd: editableReviewStep ?? "",
|
||||
});
|
||||
setUploadedFile(null);
|
||||
},
|
||||
}),
|
||||
[canWriteKecoOpinion, editableReviewStep, formData, rvwList],
|
||||
);
|
||||
|
||||
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
|
||||
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
|
||||
|
||||
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
|
||||
const hasKecoSection = kecoRvwList.length > 0 || editableReviewStep === "KECO";
|
||||
|
||||
if (!hasEnvSection && !hasKecoSection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleFileUpload(files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
const fileKey = createRvwOpnnUploadKey(file);
|
||||
const existingAtflCd = formData.atchFileGroupId;
|
||||
|
||||
setUploadedFile({ fileName: file.name, fileKey });
|
||||
|
||||
try {
|
||||
await uploadFileAsync(file, {
|
||||
fileKey,
|
||||
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
|
||||
});
|
||||
} catch {
|
||||
// onError callback handles cleanup
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileRemove() {
|
||||
if (!uploadedFile) return;
|
||||
|
||||
uploads[uploadedFile.fileKey]?.abort();
|
||||
|
||||
if (uploadedFile.atflId) {
|
||||
try {
|
||||
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
|
||||
} catch (error) {
|
||||
console.error("검토의견 첨부파일 삭제 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
setUploadedFile(null);
|
||||
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
|
||||
}
|
||||
|
||||
function renderOpinionForm(reviewStep: ReviewStepCode) {
|
||||
if (editableReviewStep !== reviewStep) return null;
|
||||
if (reviewStep === "KECO" && !canWriteKecoOpinion) return null;
|
||||
|
||||
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xl font-semibold">검토의견 작성</h4>
|
||||
<Table caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공" variant="secondary">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[12rem]">의견내용</TableHead>
|
||||
<TableCell>
|
||||
<Textarea
|
||||
value={formData.rvwRsltCn}
|
||||
onChange={(event) =>
|
||||
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
|
||||
}
|
||||
placeholder="의견내용을 입력하세요"
|
||||
rows={4}
|
||||
className="min-h-[8rem] !text-xl"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>검토결과</TableHead>
|
||||
<TableCell>
|
||||
<FieldSet>
|
||||
<RadioGroup
|
||||
value={formData.rvwRsltAnmlyYn}
|
||||
onValueChange={(value) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<FieldLabel
|
||||
htmlFor={`rvw-opnn-normal-${reviewStep}`}
|
||||
className="!w-auto border-none"
|
||||
>
|
||||
<Field orientation="horizontal" className="w-auto">
|
||||
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
|
||||
<span className="font-normal">정상</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
<FieldLabel
|
||||
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
|
||||
className="!w-auto border-none"
|
||||
>
|
||||
<Field orientation="horizontal" className="w-auto">
|
||||
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
|
||||
<span className="font-normal">이상</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FieldSet>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>첨부파일</TableHead>
|
||||
<TableCell>
|
||||
<AttachmentFileField
|
||||
files={
|
||||
uploadedFile
|
||||
? [
|
||||
{
|
||||
fileKey: uploadedFile.fileKey,
|
||||
fileName: uploadedFile.fileName,
|
||||
onDownload: uploadedFile.atflId
|
||||
? () => downloadByAtflId(uploadedFile.atflId!)
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
inputId={`rvw-opnn-${reviewStep}-file-input`}
|
||||
isUploadingByKey={isUploading}
|
||||
multiple={false}
|
||||
showAddButton={!uploadedFile}
|
||||
addDisabled={uploading}
|
||||
onAdd={(fileList) => void handleFileUpload(fileList)}
|
||||
onRemove={() => void handleFileRemove()}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasEnvSection ? (
|
||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
|
||||
<div className="space-y-6">
|
||||
{envRvwList.length > 0 ? <TechReviewOpinionTable items={envRvwList} /> : null}
|
||||
{renderOpinionForm("ENV")}
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
) : null}
|
||||
{hasKecoSection ? (
|
||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
|
||||
<div className="space-y-6">
|
||||
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null}
|
||||
{/*
|
||||
재원협의에서 환경공단 검토의견은 자동 생성
|
||||
{renderOpinionForm("KECO")}
|
||||
*/}
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<Table caption="검토의견 목록으로 의견자명, 의견내용, 검토결과, 첨부파일, 등록일시 정보 제공" variant="secondary">
|
||||
<colgroup>
|
||||
<col width="10%" />
|
||||
<col width="55%" />
|
||||
<col width="5%" />
|
||||
<col width="20%" />
|
||||
<col width="10%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>의견자명</TableHead>
|
||||
<TableHead>의견내용</TableHead>
|
||||
<TableHead>검토결과</TableHead>
|
||||
<TableHead>첨부파일</TableHead>
|
||||
<TableHead>등록일시</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={`${item.fnrsCnsltnDocNo}_${item.rvwOpnnSn}`}>
|
||||
<TableCell align="center">{item.opnrNm}</TableCell>
|
||||
<TableCell align="left">{item.rvwRsltCn || "-"}</TableCell>
|
||||
<TableCell align="center">
|
||||
{item.rvwRsltAnmlyYn === "Y" ? "이상" : item.rvwRsltAnmlyYn === "N" ? "정상" : "-"}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{item.atchFileInfo?.atflCd ? (
|
||||
<AttachmentDownloadLink
|
||||
atflCd={item.atchFileInfo.atflCd}
|
||||
fileName={item.atchFileInfo.orgnlFileNm || "다운로드"}
|
||||
/>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { TechReviewOpinionSection };
|
||||
"use client";
|
||||
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { getDefaultFileKey, useTus, useUserStore } from "@/shared/lib";
|
||||
import { AttachmentFileField, SectionCollapsible } from "@/shared/ui";
|
||||
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
|
||||
import { attachService, useDownloadAttachmentByAtflId } from "@/entities/attach";
|
||||
import {
|
||||
fnrsCnsltnKeys,
|
||||
type FnrsCnsltnRvwOpnnResponseDto,
|
||||
fnrsCnsltnRvwOpnnService,
|
||||
} from "@/entities/fnrs-cnsltn";
|
||||
import { RvwOpnnListTable } from "@/widgets/pbadms-sprt";
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Table,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
import type {
|
||||
ReviewOpinionFormData,
|
||||
ReviewOpinionGetFormDataResult,
|
||||
} from "../../lib/review-opinion-utils";
|
||||
import type { ReviewStepCode } from "../../model/view-types";
|
||||
import { REVIEW_STEP_LABELS } from "../../model/view-types";
|
||||
|
||||
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
|
||||
|
||||
interface TechReviewOpinionSectionProps {
|
||||
rvwList: FnrsCnsltnRvwOpnnResponseDto[];
|
||||
editableReviewStep?: ReviewStepCode;
|
||||
atrzSeComCdList?: string[];
|
||||
isAtrzLoading?: boolean;
|
||||
}
|
||||
|
||||
export interface TechReviewOpinionSectionHandle {
|
||||
getFormData: () => ReviewOpinionGetFormDataResult | null;
|
||||
resetForm: () => void;
|
||||
}
|
||||
|
||||
interface TechReviewOpinionTableProps {
|
||||
items: FnrsCnsltnRvwOpnnResponseDto[];
|
||||
}
|
||||
|
||||
interface UploadedOpinionFile {
|
||||
fileName: string;
|
||||
fileKey: string;
|
||||
atflId?: string;
|
||||
atflCd?: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
|
||||
rvwRsltCn: "",
|
||||
rvwRsltAnmlyYn: "",
|
||||
atchFileGroupId: "",
|
||||
opnrSeComCd: "",
|
||||
};
|
||||
|
||||
function createRvwOpnnUploadKey(file: File) {
|
||||
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
|
||||
}
|
||||
|
||||
function hasFormContent(formData: ReviewOpinionFormData) {
|
||||
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
|
||||
}
|
||||
|
||||
const TechReviewOpinionSection = forwardRef<
|
||||
TechReviewOpinionSectionHandle,
|
||||
TechReviewOpinionSectionProps
|
||||
>(function TechReviewOpinionSection(
|
||||
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
|
||||
ref,
|
||||
) {
|
||||
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
|
||||
const [formData, setFormData] = useState<ReviewOpinionFormData>({
|
||||
...EMPTY_FORM_DATA,
|
||||
opnrSeComCd: editableReviewStep ?? "",
|
||||
});
|
||||
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
|
||||
const { mutate: downloadByAtflId } = useDownloadAttachmentByAtflId();
|
||||
|
||||
useEffect(() => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
opnrSeComCd: editableReviewStep ?? "",
|
||||
}));
|
||||
}, [editableReviewStep]);
|
||||
|
||||
const { uploadFileAsync, isUploading, uploads } = useTus({
|
||||
onSuccess: (atflCd, atflId, _file, fileKey) => {
|
||||
if (!atflCd || !atflId) return;
|
||||
|
||||
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
|
||||
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
|
||||
},
|
||||
onError: (_error, _file, fileKey) => {
|
||||
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
|
||||
},
|
||||
});
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
getFormData: () => {
|
||||
if (!editableReviewStep || !hasFormContent(formData)) {
|
||||
return null;
|
||||
}
|
||||
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
formData: {
|
||||
...formData,
|
||||
opnrSeComCd: editableReviewStep,
|
||||
},
|
||||
rvwList,
|
||||
};
|
||||
},
|
||||
resetForm: () => {
|
||||
setFormData({
|
||||
...EMPTY_FORM_DATA,
|
||||
opnrSeComCd: editableReviewStep ?? "",
|
||||
});
|
||||
setUploadedFile(null);
|
||||
},
|
||||
}),
|
||||
[canWriteKecoOpinion, editableReviewStep, formData, rvwList],
|
||||
);
|
||||
|
||||
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
|
||||
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
|
||||
|
||||
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
|
||||
const hasKecoSection = kecoRvwList.length > 0 || editableReviewStep === "KECO";
|
||||
|
||||
if (!hasEnvSection && !hasKecoSection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleFileUpload(files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
const fileKey = createRvwOpnnUploadKey(file);
|
||||
const existingAtflCd = formData.atchFileGroupId;
|
||||
|
||||
setUploadedFile({ fileName: file.name, fileKey });
|
||||
|
||||
try {
|
||||
await uploadFileAsync(file, {
|
||||
fileKey,
|
||||
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
|
||||
});
|
||||
} catch {
|
||||
// onError callback handles cleanup
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileRemove() {
|
||||
if (!uploadedFile) return;
|
||||
|
||||
uploads[uploadedFile.fileKey]?.abort();
|
||||
|
||||
if (uploadedFile.atflId) {
|
||||
try {
|
||||
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
|
||||
} catch (error) {
|
||||
console.error("검토의견 첨부파일 삭제 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
setUploadedFile(null);
|
||||
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
|
||||
}
|
||||
|
||||
function isReviewTurn(reviewStep: ReviewStepCode) {
|
||||
if (editableReviewStep !== reviewStep) return false;
|
||||
if (reviewStep === "KECO" && !canWriteKecoOpinion) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderOpinionForm(reviewStep: ReviewStepCode) {
|
||||
if (!isReviewTurn(reviewStep)) return null;
|
||||
|
||||
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xl font-semibold">검토의견 작성</h4>
|
||||
<Table
|
||||
caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공"
|
||||
variant="secondary"
|
||||
>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[12rem]">의견내용</TableHead>
|
||||
<TableCell>
|
||||
<Textarea
|
||||
value={formData.rvwRsltCn}
|
||||
onChange={(event) =>
|
||||
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
|
||||
}
|
||||
placeholder="의견내용을 입력하세요"
|
||||
rows={4}
|
||||
className="min-h-[8rem] !text-xl"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>검토결과</TableHead>
|
||||
<TableCell>
|
||||
<FieldSet>
|
||||
<RadioGroup
|
||||
value={formData.rvwRsltAnmlyYn}
|
||||
onValueChange={(value) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<FieldLabel
|
||||
htmlFor={`rvw-opnn-normal-${reviewStep}`}
|
||||
className="!w-auto border-none"
|
||||
>
|
||||
<Field orientation="horizontal" className="w-auto">
|
||||
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
|
||||
<span className="font-normal">정상</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
<FieldLabel
|
||||
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
|
||||
className="!w-auto border-none"
|
||||
>
|
||||
<Field orientation="horizontal" className="w-auto">
|
||||
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
|
||||
<span className="font-normal">이상</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FieldSet>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>첨부파일</TableHead>
|
||||
<TableCell>
|
||||
<AttachmentFileField
|
||||
files={
|
||||
uploadedFile
|
||||
? [
|
||||
{
|
||||
fileKey: uploadedFile.fileKey,
|
||||
fileName: uploadedFile.fileName,
|
||||
onDownload: uploadedFile.atflId
|
||||
? () => downloadByAtflId(uploadedFile.atflId!)
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
inputId={`rvw-opnn-${reviewStep}-file-input`}
|
||||
isUploadingByKey={isUploading}
|
||||
multiple={false}
|
||||
showAddButton={!uploadedFile}
|
||||
addDisabled={uploading}
|
||||
onAdd={(fileList) => void handleFileUpload(fileList)}
|
||||
onRemove={() => void handleFileRemove()}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasEnvSection ? (
|
||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
|
||||
<div className="space-y-6">
|
||||
{envRvwList.length > 0 ? (
|
||||
<TechReviewOpinionTable items={envRvwList} />
|
||||
) : null}
|
||||
{renderOpinionForm("ENV")}
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
) : null}
|
||||
{hasKecoSection ? (
|
||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
|
||||
<div className="space-y-6">
|
||||
{kecoRvwList.length > 0 ? (
|
||||
<TechReviewOpinionTable items={kecoRvwList} />
|
||||
) : null}
|
||||
{/*
|
||||
재원협의에서 환경공단 검토의견은 자동 생성
|
||||
{renderOpinionForm("KECO")}
|
||||
*/}
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
function getRvwOpnnIds(item: FnrsCnsltnRvwOpnnResponseDto) {
|
||||
if (!item.fnrsCnsltnDocNo || item.rvwOpnnSn == null) {
|
||||
throw new Error("검토의견 식별값이 없습니다.");
|
||||
}
|
||||
return [item.fnrsCnsltnDocNo, item.rvwOpnnSn] as const;
|
||||
}
|
||||
|
||||
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
|
||||
|
||||
return (
|
||||
<RvwOpnnListTable
|
||||
items={items}
|
||||
getRowKey={(item) => `${item.fnrsCnsltnDocNo}_${item.rvwOpnnSn}`}
|
||||
currentUserUuid={currentUserUuid}
|
||||
onUpdate={async (item, body) => {
|
||||
await fnrsCnsltnRvwOpnnService.update(...getRvwOpnnIds(item), body);
|
||||
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
|
||||
}}
|
||||
onDelete={async (item) => {
|
||||
await fnrsCnsltnRvwOpnnService.delete(...getRvwOpnnIds(item));
|
||||
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { TechReviewOpinionSection };
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,231 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
|
||||
import { dayjs } from "@/shared/lib";
|
||||
import type { InstlSncnMngResponseDto } from "@/entities/instl-sncn";
|
||||
import { Badge } from "@common/components";
|
||||
|
||||
const columnHelper = createColumnHelper<InstlSncnMngResponseDto>();
|
||||
|
||||
const MAIN_STEP_LABELS = {
|
||||
apply: "설치인가 신청",
|
||||
envoReview: "설치인가 검토",
|
||||
techReview: "기술검토",
|
||||
reply: "검토결과 회신",
|
||||
} as const;
|
||||
|
||||
export const PROGRESS_STATUS_COLUMNS = [
|
||||
{ label: "설치인가 신청 (시군)", sttsComCd: "ST" },
|
||||
{ label: "설치인가 신청 (시도)", sttsComCd: "LE" },
|
||||
{ label: "설치인가 검토", sttsComCd: "ER" },
|
||||
{ label: "기술검토", sttsComCd: "TE" },
|
||||
{ label: "기술검토 회신", sttsComCd: "TP" },
|
||||
{ label: "검토결과 회신", sttsComCd: "EP" },
|
||||
{ label: "설치인가 승인", sttsComCd: "TA" },
|
||||
];
|
||||
|
||||
type BadgeState = "completed" | "current" | "pending";
|
||||
|
||||
interface ProgressBadge {
|
||||
label: string;
|
||||
state: BadgeState;
|
||||
}
|
||||
|
||||
function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N"): ProgressBadge[] {
|
||||
if (prgrsSttsComCd === "SV") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "ST") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "current" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "LE") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "current" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "ER" || prgrsSttsComCd === "TE" || prgrsSttsComCd === "SL") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "current" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "TP" || prgrsSttsComCd === "EP") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
|
||||
{
|
||||
label: MAIN_STEP_LABELS.techReview,
|
||||
state: techRplyHstryYn === "Y" ? "completed" : "pending",
|
||||
},
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "current" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "TA" || prgrsSttsComCd === "TB") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
|
||||
{
|
||||
label: MAIN_STEP_LABELS.techReview,
|
||||
state: techRplyHstryYn === "Y" ? "completed" : "pending",
|
||||
},
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "completed" },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
|
||||
if (prgrsSttsComCd === "SL") {
|
||||
return <Badge color="orange">보완요청</Badge>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getBadgeColor(state: BadgeState) {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "green";
|
||||
case "current":
|
||||
return "blue";
|
||||
case "pending":
|
||||
return "lightGray";
|
||||
}
|
||||
}
|
||||
|
||||
function getBadgeStateText(state: BadgeState) {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "완료";
|
||||
case "current":
|
||||
return "진행중";
|
||||
case "pending":
|
||||
return "대기";
|
||||
}
|
||||
}
|
||||
|
||||
/** 단계명을 2줄로 분리: "설치인가 신청" → ["설치인가", "신청"], "기술검토" → ["기술검토", ""] */
|
||||
function splitStepLabel(label: string) {
|
||||
const [title = "", ...rest] = label.split(" ");
|
||||
return { title, detail: rest.join(" ") };
|
||||
}
|
||||
|
||||
function formatOptional(value: string | null | undefined) {
|
||||
return value?.trim() ? value : "-";
|
||||
}
|
||||
|
||||
interface ProgressStepBadgeProps {
|
||||
prgrsSttsComCd: string;
|
||||
techRplyHstryYn: "Y" | "N";
|
||||
}
|
||||
|
||||
function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadgeProps) {
|
||||
const badges = buildProgressBadges(prgrsSttsComCd, techRplyHstryYn);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-start">
|
||||
{badges.map((badge, index) => (
|
||||
<Fragment key={badge.label}>
|
||||
{index > 0 && <span className="mx-2">></span>}
|
||||
{/* 1줄: 단계명 / 2줄: 세부단계 + 상태 (예: 설치인가 / 신청완료, 기술검토 / 완료) */}
|
||||
<Badge
|
||||
color={getBadgeColor(badge.state)}
|
||||
className="flex-col justify-center !px-[20px] py-1 text-center leading-tight"
|
||||
>
|
||||
<span>{splitStepLabel(badge.label).title}</span>
|
||||
<span>
|
||||
{splitStepLabel(badge.label).detail}
|
||||
{getBadgeStateText(badge.state)}
|
||||
</span>
|
||||
</Badge>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function createInstlSncnColumns() {
|
||||
return [
|
||||
columnHelper.accessor("sido", {
|
||||
header: "시도",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("gugun", {
|
||||
header: "시군구",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("bizNm", {
|
||||
header: "사업명",
|
||||
meta: { align: "left" },
|
||||
cell: ({ row, getValue }) => (
|
||||
<>
|
||||
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
|
||||
</>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("taskSeCdNm", {
|
||||
header: "업무구분",
|
||||
meta: { align: "center" },
|
||||
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
|
||||
}),
|
||||
columnHelper.accessor("aplcntNm", {
|
||||
header: "담당자",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("envoSeCdNm", {
|
||||
header: "해당기관",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "progress",
|
||||
header: "진행상황",
|
||||
meta: { align: "center" },
|
||||
cell: ({ row }) => (
|
||||
<ProgressStepBadge
|
||||
prgrsSttsComCd={row.original.prgrsSttsComCd || ""}
|
||||
techRplyHstryYn={row.original.techRplyHstryYn || "N"}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("regDt", {
|
||||
header: "신청일",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
return value ? dayjs(value).format("YYYY-MM-DD") : "-";
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
|
||||
import { dayjs } from "@/shared/lib";
|
||||
import type { InstlSncnMngResponseDto } from "@/entities/instl-sncn";
|
||||
import { Badge } from "@common/components";
|
||||
|
||||
const columnHelper = createColumnHelper<InstlSncnMngResponseDto>();
|
||||
|
||||
const MAIN_STEP_LABELS = {
|
||||
apply: "설치인가 신청",
|
||||
envoReview: "설치인가 검토",
|
||||
techReview: "기술검토",
|
||||
reply: "검토결과 회신",
|
||||
} as const;
|
||||
|
||||
export const PROGRESS_STATUS_COLUMNS = [
|
||||
{ label: "설치인가 신청 (시군)", sttsComCd: "ST" },
|
||||
{ label: "설치인가 신청 (시도)", sttsComCd: "LE" },
|
||||
{ label: "설치인가 검토", sttsComCd: "ER" },
|
||||
{ label: "기술검토", sttsComCd: "TE" },
|
||||
{ label: "기술검토 회신", sttsComCd: "TP" },
|
||||
{ label: "검토결과 회신", sttsComCd: "EP" },
|
||||
{ label: "설치인가 승인", sttsComCd: "TA" },
|
||||
];
|
||||
|
||||
type BadgeState = "completed" | "current" | "pending";
|
||||
|
||||
interface ProgressBadge {
|
||||
label: string;
|
||||
state: BadgeState;
|
||||
}
|
||||
|
||||
function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N"): ProgressBadge[] {
|
||||
if (prgrsSttsComCd === "SV") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "ST") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "current" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "LE") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "current" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "ER" || prgrsSttsComCd === "TE" || prgrsSttsComCd === "SL") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "current" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "TP" || prgrsSttsComCd === "EP") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
|
||||
{
|
||||
label: MAIN_STEP_LABELS.techReview,
|
||||
state: techRplyHstryYn === "Y" ? "completed" : "pending",
|
||||
},
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "current" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "TA" || prgrsSttsComCd === "TB") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
|
||||
{
|
||||
label: MAIN_STEP_LABELS.techReview,
|
||||
state: techRplyHstryYn === "Y" ? "completed" : "pending",
|
||||
},
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "completed" },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
|
||||
if (prgrsSttsComCd === "SL") {
|
||||
return <Badge color="orange">보완요청</Badge>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getBadgeColor(state: BadgeState) {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "green";
|
||||
case "current":
|
||||
return "blue";
|
||||
case "pending":
|
||||
return "lightGray";
|
||||
}
|
||||
}
|
||||
|
||||
function getBadgeStateText(state: BadgeState) {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "완료";
|
||||
case "current":
|
||||
return "진행중";
|
||||
case "pending":
|
||||
return "대기";
|
||||
}
|
||||
}
|
||||
|
||||
/** 단계명을 2줄로 분리: "설치인가 신청" → ["설치인가", "신청"], "기술검토" → ["기술검토", ""] */
|
||||
function splitStepLabel(label: string) {
|
||||
const [title = "", ...rest] = label.split(" ");
|
||||
return { title, detail: rest.join(" ") };
|
||||
}
|
||||
|
||||
function formatOptional(value: string | null | undefined) {
|
||||
return value?.trim() ? value : "-";
|
||||
}
|
||||
|
||||
interface ProgressStepBadgeProps {
|
||||
prgrsSttsComCd: string;
|
||||
techRplyHstryYn: "Y" | "N";
|
||||
}
|
||||
|
||||
function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadgeProps) {
|
||||
const badges = buildProgressBadges(prgrsSttsComCd, techRplyHstryYn);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-center">
|
||||
{badges.map((badge, index) => (
|
||||
<Fragment key={badge.label}>
|
||||
{index > 0 && <span className="mx-2">></span>}
|
||||
{/* 1줄: 단계명 / 2줄: 세부단계 + 상태 (예: 설치인가 / 신청완료, 기술검토 / 완료) */}
|
||||
<Badge
|
||||
color={getBadgeColor(badge.state)}
|
||||
className="w-[100px] flex-col justify-center !px-0 py-1 text-center leading-tight"
|
||||
>
|
||||
<span>{splitStepLabel(badge.label).title}</span>
|
||||
<span>
|
||||
{splitStepLabel(badge.label).detail}
|
||||
{getBadgeStateText(badge.state)}
|
||||
</span>
|
||||
</Badge>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function createInstlSncnColumns() {
|
||||
return [
|
||||
columnHelper.accessor("sido", {
|
||||
header: "시도",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("gugun", {
|
||||
header: "시군구",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("bizNm", {
|
||||
header: "사업명",
|
||||
meta: { align: "left" },
|
||||
cell: ({ row, getValue }) => (
|
||||
<>
|
||||
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
|
||||
</>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("taskSeCdNm", {
|
||||
header: "업무구분",
|
||||
meta: { align: "center" },
|
||||
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
|
||||
}),
|
||||
columnHelper.accessor("aplcntNm", {
|
||||
header: "담당자",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("envoSeCdNm", {
|
||||
header: "해당기관",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "progress",
|
||||
header: "진행상황",
|
||||
meta: { align: "center" },
|
||||
cell: ({ row }) => (
|
||||
<ProgressStepBadge
|
||||
prgrsSttsComCd={row.original.prgrsSttsComCd || ""}
|
||||
techRplyHstryYn={row.original.techRplyHstryYn || "N"}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("regDt", {
|
||||
header: "신청일",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
return value ? dayjs(value).format("YYYY-MM-DD") : "-";
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SectionCollapsible } from "@/shared/ui";
|
||||
import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from "@/shared/ui/field";
|
||||
import { findCodeLabel, useCodeList } from "@/entities/code";
|
||||
import { type BnsApplyData, BnsPickerDialog } from "@/widgets/bns-picker";
|
||||
import { PreviousPlanLookupButton } from "@/widgets/previous-plan-lookup";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
@@ -27,9 +28,9 @@ function BasicInfoFormSection() {
|
||||
const { control, watch, setValue } = useFormContext<InstlSncnRegistFormData>();
|
||||
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||
const taskSeComCd = watch("instlSncnMng.taskSeComCd");
|
||||
const localGovLabel = [watch("instlSncnMng.sido"), watch("instlSncnMng.gugun")]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const sido = watch("instlSncnMng.sido");
|
||||
const gugun = watch("instlSncnMng.gugun");
|
||||
const localGovLabel = [sido, gugun].filter(Boolean).join(" ");
|
||||
|
||||
function handleBnsSelect(data: BnsApplyData) {
|
||||
setValue("instlSncnMng.bizNm", data.bizNm);
|
||||
@@ -53,6 +54,7 @@ function BasicInfoFormSection() {
|
||||
|
||||
return (
|
||||
<SectionCollapsible title="기본 정보">
|
||||
<PreviousPlanLookupButton sidoNm={sido} gugunNm={gugun} />
|
||||
<Table caption="기본 정보 입력으로 사업명, 사업기간, 사업내용, 구분, 신청 지자체, 사업구분, 시설종류, 재이용용도 정보 제공" variant="secondary">
|
||||
<colgroup>
|
||||
<col width="15%" />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import type { InstlSncnMngResponseDto } from "@/entities/instl-sncn";
|
||||
import { PreviousPlanLookupButton } from "@/widgets/previous-plan-lookup";
|
||||
import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
|
||||
|
||||
interface BasicInfoSectionProps {
|
||||
@@ -11,6 +12,7 @@ interface BasicInfoSectionProps {
|
||||
function BasicInfoSection({ det }: BasicInfoSectionProps) {
|
||||
return (
|
||||
<SectionCollapsible title="기본 정보">
|
||||
<PreviousPlanLookupButton sidoNm={det.sido} gugunNm={det.gugun} />
|
||||
<Table caption="기본 정보로 사업명, 사업기간, 사업내용, 구분, 신청지자체, 사업구분 정보 제공" variant="secondary">
|
||||
<colgroup>
|
||||
<col width="15%" />
|
||||
|
||||
@@ -1,350 +1,350 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
|
||||
|
||||
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
|
||||
import { TextShimmer } from "@/shared/ui";
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
|
||||
import { AttachmentDownloadLink, attachService } from "@/entities/attach";
|
||||
import type { InstlSncnRvwOpnnResponseDto, ReviewStepCode } from "@/entities/instl-sncn";
|
||||
import {
|
||||
Button,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
import type { ReviewOpinionFormData } from "../../lib/review-opinion-utils";
|
||||
import { REVIEW_STEP_LABELS } from "../../model/view-types";
|
||||
|
||||
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
|
||||
|
||||
interface TechReviewOpinionSectionProps {
|
||||
rvwList: InstlSncnRvwOpnnResponseDto[];
|
||||
editableReviewStep?: ReviewStepCode;
|
||||
atrzSeComCdList?: string[];
|
||||
isAtrzLoading?: boolean;
|
||||
}
|
||||
|
||||
export interface TechReviewOpinionSectionHandle {
|
||||
getFormData: () => ReviewOpinionFormData | null;
|
||||
resetForm: () => void;
|
||||
}
|
||||
|
||||
interface TechReviewOpinionTableProps {
|
||||
items: InstlSncnRvwOpnnResponseDto[];
|
||||
}
|
||||
|
||||
interface UploadedOpinionFile {
|
||||
fileName: string;
|
||||
fileKey: string;
|
||||
atflId?: string;
|
||||
atflCd?: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
|
||||
rvwRsltCn: "",
|
||||
rvwRsltAnmlyYn: "",
|
||||
atchFileGroupId: "",
|
||||
opnrSeComCd: "",
|
||||
};
|
||||
|
||||
function createRvwOpnnUploadKey(file: File) {
|
||||
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
|
||||
}
|
||||
|
||||
function hasFormContent(formData: ReviewOpinionFormData) {
|
||||
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
|
||||
}
|
||||
|
||||
const TechReviewOpinionSection = forwardRef<
|
||||
TechReviewOpinionSectionHandle,
|
||||
TechReviewOpinionSectionProps
|
||||
>(function TechReviewOpinionSection(
|
||||
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
|
||||
ref,
|
||||
) {
|
||||
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
|
||||
const [formData, setFormData] = useState<ReviewOpinionFormData>({
|
||||
...EMPTY_FORM_DATA,
|
||||
opnrSeComCd: editableReviewStep ?? "",
|
||||
});
|
||||
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
opnrSeComCd: editableReviewStep ?? "",
|
||||
}));
|
||||
}, [editableReviewStep]);
|
||||
|
||||
const { uploadFileAsync, isUploading, uploads } = useTus({
|
||||
onSuccess: (atflCd, atflId, _file, fileKey) => {
|
||||
if (!atflCd || !atflId) return;
|
||||
|
||||
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
|
||||
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
|
||||
},
|
||||
onError: (_error, _file, fileKey) => {
|
||||
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
|
||||
},
|
||||
});
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
getFormData: () => {
|
||||
if (!editableReviewStep || !hasFormContent(formData)) {
|
||||
return null;
|
||||
}
|
||||
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...formData,
|
||||
opnrSeComCd: editableReviewStep,
|
||||
};
|
||||
},
|
||||
resetForm: () => {
|
||||
setFormData({
|
||||
...EMPTY_FORM_DATA,
|
||||
opnrSeComCd: editableReviewStep ?? "",
|
||||
});
|
||||
setUploadedFile(null);
|
||||
},
|
||||
}),
|
||||
[canWriteKecoOpinion, editableReviewStep, formData],
|
||||
);
|
||||
|
||||
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
|
||||
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
|
||||
|
||||
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
|
||||
const hasKecoSection =
|
||||
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteKecoOpinion);
|
||||
|
||||
if (!hasEnvSection && !hasKecoSection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleFileUpload(files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
const fileKey = createRvwOpnnUploadKey(file);
|
||||
const existingAtflCd = formData.atchFileGroupId;
|
||||
|
||||
setUploadedFile({ fileName: file.name, fileKey });
|
||||
|
||||
try {
|
||||
await uploadFileAsync(file, {
|
||||
fileKey,
|
||||
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
|
||||
});
|
||||
} catch {
|
||||
// onError callback handles cleanup
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileRemove() {
|
||||
if (!uploadedFile) return;
|
||||
|
||||
uploads[uploadedFile.fileKey]?.abort();
|
||||
|
||||
if (uploadedFile.atflId) {
|
||||
try {
|
||||
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
|
||||
} catch (error) {
|
||||
console.error("검토의견 첨부파일 삭제 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
setUploadedFile(null);
|
||||
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
|
||||
}
|
||||
|
||||
function renderOpinionForm(reviewStep: ReviewStepCode) {
|
||||
if (editableReviewStep !== reviewStep) return null;
|
||||
if (reviewStep === "KECO" && !canWriteKecoOpinion) return null;
|
||||
|
||||
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xl font-semibold">검토의견 작성</h4>
|
||||
<Table caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공" variant="secondary">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead scope="row" className="w-[12rem]">의견내용</TableHead>
|
||||
<TableCell>
|
||||
<Textarea
|
||||
value={formData.rvwRsltCn}
|
||||
onChange={(event) =>
|
||||
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
|
||||
}
|
||||
placeholder="의견내용을 입력하세요"
|
||||
rows={4}
|
||||
className="min-h-[8rem] !text-xl"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead scope="row">검토결과</TableHead>
|
||||
<TableCell>
|
||||
<FieldSet>
|
||||
<RadioGroup
|
||||
value={formData.rvwRsltAnmlyYn}
|
||||
onValueChange={(value) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<FieldLabel
|
||||
htmlFor={`rvw-opnn-normal-${reviewStep}`}
|
||||
className="!w-auto border-none"
|
||||
>
|
||||
<Field orientation="horizontal" className="w-auto">
|
||||
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
|
||||
<span className="font-normal">정상</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
<FieldLabel
|
||||
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
|
||||
className="!w-auto border-none"
|
||||
>
|
||||
<Field orientation="horizontal" className="w-auto">
|
||||
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
|
||||
<span className="font-normal">이상</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FieldSet>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead scope="row">첨부파일</TableHead>
|
||||
<TableCell>
|
||||
{uploadedFile ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<TextShimmer className="text-xl" active={uploading}>
|
||||
{uploadedFile.fileName}
|
||||
</TextShimmer>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleFileRemove();
|
||||
}}
|
||||
disabled={uploading}
|
||||
className="size-8 !bg-destructive"
|
||||
>
|
||||
<i className="diveicon di-x text-xs" aria-label="삭제" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<label>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void handleFileUpload(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" variant="outline" type="button" asChild>
|
||||
<span>파일 선택</span>
|
||||
</Button>
|
||||
</label>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasEnvSection ? (
|
||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
|
||||
<div className="space-y-6">
|
||||
{envRvwList.length > 0 ? <TechReviewOpinionTable items={envRvwList} /> : null}
|
||||
{renderOpinionForm("ENV")}
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
) : null}
|
||||
{hasKecoSection ? (
|
||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
|
||||
<div className="space-y-6">
|
||||
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null}
|
||||
{renderOpinionForm("KECO")}
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<Table caption="검토의견 목록으로 의견자명, 의견내용, 검토결과, 첨부파일, 등록일시 정보 제공" variant="secondary">
|
||||
<colgroup>
|
||||
<col width="10%" />
|
||||
<col width="55%" />
|
||||
<col width="5%" />
|
||||
<col width="20%" />
|
||||
<col width="10%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>의견자명</TableHead>
|
||||
<TableHead>의견내용</TableHead>
|
||||
<TableHead>검토결과</TableHead>
|
||||
<TableHead>첨부파일</TableHead>
|
||||
<TableHead>등록일시</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={`${item.instlSncnDocNo}_${item.rvwOpnnSn}`}>
|
||||
<TableCell align="center">{item.opnrNm}</TableCell>
|
||||
<TableCell align="left">{item.rvwRsltCn || "-"}</TableCell>
|
||||
<TableCell align="center">
|
||||
{item.rvwRsltAnmlyYn === "Y" ? "이상" : item.rvwRsltAnmlyYn === "N" ? "정상" : "-"}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{item.atchFileInfo?.atflCd ? (
|
||||
<AttachmentDownloadLink
|
||||
atflCd={item.atchFileInfo.atflCd}
|
||||
fileName={item.atchFileInfo.orgnlFileNm || "다운로드"}
|
||||
/>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { TechReviewOpinionSection };
|
||||
"use client";
|
||||
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { getDefaultFileKey, useTus, useUserStore } from "@/shared/lib";
|
||||
import { TextShimmer } from "@/shared/ui";
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
|
||||
import { attachService } from "@/entities/attach";
|
||||
import {
|
||||
instlSncnKeys,
|
||||
type InstlSncnRvwOpnnResponseDto,
|
||||
instlSncnRvwOpnnService,
|
||||
type ReviewStepCode,
|
||||
} from "@/entities/instl-sncn";
|
||||
import { RvwOpnnListTable } from "@/widgets/pbadms-sprt";
|
||||
import {
|
||||
Button,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Table,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
import type { ReviewOpinionFormData } from "../../lib/review-opinion-utils";
|
||||
import { REVIEW_STEP_LABELS } from "../../model/view-types";
|
||||
|
||||
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
|
||||
|
||||
interface TechReviewOpinionSectionProps {
|
||||
rvwList: InstlSncnRvwOpnnResponseDto[];
|
||||
editableReviewStep?: ReviewStepCode;
|
||||
atrzSeComCdList?: string[];
|
||||
isAtrzLoading?: boolean;
|
||||
}
|
||||
|
||||
export interface TechReviewOpinionSectionHandle {
|
||||
getFormData: () => ReviewOpinionFormData | null;
|
||||
resetForm: () => void;
|
||||
}
|
||||
|
||||
interface TechReviewOpinionTableProps {
|
||||
items: InstlSncnRvwOpnnResponseDto[];
|
||||
}
|
||||
|
||||
interface UploadedOpinionFile {
|
||||
fileName: string;
|
||||
fileKey: string;
|
||||
atflId?: string;
|
||||
atflCd?: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
|
||||
rvwRsltCn: "",
|
||||
rvwRsltAnmlyYn: "",
|
||||
atchFileGroupId: "",
|
||||
opnrSeComCd: "",
|
||||
};
|
||||
|
||||
function createRvwOpnnUploadKey(file: File) {
|
||||
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
|
||||
}
|
||||
|
||||
function hasFormContent(formData: ReviewOpinionFormData) {
|
||||
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
|
||||
}
|
||||
|
||||
const TechReviewOpinionSection = forwardRef<
|
||||
TechReviewOpinionSectionHandle,
|
||||
TechReviewOpinionSectionProps
|
||||
>(function TechReviewOpinionSection(
|
||||
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
|
||||
ref,
|
||||
) {
|
||||
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
|
||||
const [formData, setFormData] = useState<ReviewOpinionFormData>({
|
||||
...EMPTY_FORM_DATA,
|
||||
opnrSeComCd: editableReviewStep ?? "",
|
||||
});
|
||||
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
opnrSeComCd: editableReviewStep ?? "",
|
||||
}));
|
||||
}, [editableReviewStep]);
|
||||
|
||||
const { uploadFileAsync, isUploading, uploads } = useTus({
|
||||
onSuccess: (atflCd, atflId, _file, fileKey) => {
|
||||
if (!atflCd || !atflId) return;
|
||||
|
||||
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
|
||||
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
|
||||
},
|
||||
onError: (_error, _file, fileKey) => {
|
||||
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
|
||||
},
|
||||
});
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
getFormData: () => {
|
||||
if (!editableReviewStep || !hasFormContent(formData)) {
|
||||
return null;
|
||||
}
|
||||
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...formData,
|
||||
opnrSeComCd: editableReviewStep,
|
||||
};
|
||||
},
|
||||
resetForm: () => {
|
||||
setFormData({
|
||||
...EMPTY_FORM_DATA,
|
||||
opnrSeComCd: editableReviewStep ?? "",
|
||||
});
|
||||
setUploadedFile(null);
|
||||
},
|
||||
}),
|
||||
[canWriteKecoOpinion, editableReviewStep, formData],
|
||||
);
|
||||
|
||||
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
|
||||
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
|
||||
|
||||
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
|
||||
const hasKecoSection =
|
||||
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteKecoOpinion);
|
||||
|
||||
if (!hasEnvSection && !hasKecoSection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleFileUpload(files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
const fileKey = createRvwOpnnUploadKey(file);
|
||||
const existingAtflCd = formData.atchFileGroupId;
|
||||
|
||||
setUploadedFile({ fileName: file.name, fileKey });
|
||||
|
||||
try {
|
||||
await uploadFileAsync(file, {
|
||||
fileKey,
|
||||
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
|
||||
});
|
||||
} catch {
|
||||
// onError callback handles cleanup
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileRemove() {
|
||||
if (!uploadedFile) return;
|
||||
|
||||
uploads[uploadedFile.fileKey]?.abort();
|
||||
|
||||
if (uploadedFile.atflId) {
|
||||
try {
|
||||
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
|
||||
} catch (error) {
|
||||
console.error("검토의견 첨부파일 삭제 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
setUploadedFile(null);
|
||||
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
|
||||
}
|
||||
|
||||
function isReviewTurn(reviewStep: ReviewStepCode) {
|
||||
if (editableReviewStep !== reviewStep) return false;
|
||||
if (reviewStep === "KECO" && !canWriteKecoOpinion) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderOpinionForm(reviewStep: ReviewStepCode) {
|
||||
if (!isReviewTurn(reviewStep)) return null;
|
||||
|
||||
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xl font-semibold">검토의견 작성</h4>
|
||||
<Table
|
||||
caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공"
|
||||
variant="secondary"
|
||||
>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead scope="row" className="w-[12rem]">
|
||||
의견내용
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
<Textarea
|
||||
value={formData.rvwRsltCn}
|
||||
onChange={(event) =>
|
||||
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
|
||||
}
|
||||
placeholder="의견내용을 입력하세요"
|
||||
rows={4}
|
||||
className="min-h-[8rem] !text-xl"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead scope="row">검토결과</TableHead>
|
||||
<TableCell>
|
||||
<FieldSet>
|
||||
<RadioGroup
|
||||
value={formData.rvwRsltAnmlyYn}
|
||||
onValueChange={(value) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<FieldLabel
|
||||
htmlFor={`rvw-opnn-normal-${reviewStep}`}
|
||||
className="!w-auto border-none"
|
||||
>
|
||||
<Field orientation="horizontal" className="w-auto">
|
||||
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
|
||||
<span className="font-normal">정상</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
<FieldLabel
|
||||
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
|
||||
className="!w-auto border-none"
|
||||
>
|
||||
<Field orientation="horizontal" className="w-auto">
|
||||
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
|
||||
<span className="font-normal">이상</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FieldSet>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead scope="row">첨부파일</TableHead>
|
||||
<TableCell>
|
||||
{uploadedFile ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<TextShimmer className="text-xl" active={uploading}>
|
||||
{uploadedFile.fileName}
|
||||
</TextShimmer>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleFileRemove();
|
||||
}}
|
||||
disabled={uploading}
|
||||
className="size-8 !bg-destructive"
|
||||
>
|
||||
<i className="diveicon di-x text-xs" aria-label="삭제" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<label>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void handleFileUpload(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" variant="outline" type="button" asChild>
|
||||
<span>파일 선택</span>
|
||||
</Button>
|
||||
</label>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasEnvSection ? (
|
||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
|
||||
<div className="space-y-6">
|
||||
{envRvwList.length > 0 ? (
|
||||
<TechReviewOpinionTable items={envRvwList} />
|
||||
) : null}
|
||||
{renderOpinionForm("ENV")}
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
) : null}
|
||||
{hasKecoSection ? (
|
||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
|
||||
<div className="space-y-6">
|
||||
{kecoRvwList.length > 0 ? (
|
||||
<TechReviewOpinionTable items={kecoRvwList} />
|
||||
) : null}
|
||||
{renderOpinionForm("KECO")}
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
function getRvwOpnnIds(item: InstlSncnRvwOpnnResponseDto) {
|
||||
if (!item.instlSncnDocNo || item.rvwOpnnSn == null) {
|
||||
throw new Error("검토의견 식별값이 없습니다.");
|
||||
}
|
||||
return [item.instlSncnDocNo, item.rvwOpnnSn] as const;
|
||||
}
|
||||
|
||||
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
|
||||
|
||||
return (
|
||||
<RvwOpnnListTable
|
||||
items={items}
|
||||
getRowKey={(item) => `${item.instlSncnDocNo}_${item.rvwOpnnSn}`}
|
||||
currentUserUuid={currentUserUuid}
|
||||
onUpdate={async (item, body) => {
|
||||
await instlSncnRvwOpnnService.update(...getRvwOpnnIds(item), body);
|
||||
await queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
|
||||
}}
|
||||
onDelete={async (item) => {
|
||||
await instlSncnRvwOpnnService.delete(...getRvwOpnnIds(item));
|
||||
await queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { TechReviewOpinionSection };
|
||||
|
||||
@@ -1,235 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
|
||||
import { dayjs } from "@/shared/lib";
|
||||
import type { SewerBscPlanResponseDto } from "@/entities/sewer-bsc-plan";
|
||||
import { Badge } from "@common/components";
|
||||
|
||||
const columnHelper = createColumnHelper<SewerBscPlanResponseDto>();
|
||||
|
||||
const MAIN_STEP_LABELS = {
|
||||
apply: "기본계획 신청",
|
||||
envoReview: "기본계획 검토",
|
||||
techReview: "기술검토",
|
||||
reply: "검토결과 회신",
|
||||
} as const;
|
||||
|
||||
export const PROGRESS_STATUS_COLUMNS = [
|
||||
{ label: "기본계획 신청 (시군)", sttsComCd: "LE" },
|
||||
{ label: "기본계획 검토", sttsComCd: "ER" },
|
||||
{ label: "기술검토", sttsComCd: "TE" },
|
||||
{ label: "기술검토 회신", sttsComCd: "TP" },
|
||||
{ label: "횐경청 검토 결과 회신", sttsComCd: "EP" },
|
||||
];
|
||||
|
||||
type BadgeState = "completed" | "current" | "pending";
|
||||
|
||||
interface ProgressBadge {
|
||||
label: string;
|
||||
state: BadgeState;
|
||||
}
|
||||
|
||||
function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N"): ProgressBadge[] {
|
||||
if (prgrsSttsComCd === "SV") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "ST") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "current" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "LE") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "current" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "ER" || prgrsSttsComCd === "TE" || prgrsSttsComCd === "SL") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "current" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "TP") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
|
||||
{
|
||||
label: MAIN_STEP_LABELS.techReview,
|
||||
state: techRplyHstryYn === "Y" ? "completed" : "pending",
|
||||
},
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "current" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "EP") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
|
||||
{
|
||||
label: MAIN_STEP_LABELS.techReview,
|
||||
state: techRplyHstryYn === "Y" ? "completed" : "pending",
|
||||
},
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "completed" },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
|
||||
if (prgrsSttsComCd === "SL") {
|
||||
return <Badge color="orange">보완요청</Badge>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getBadgeColor(state: BadgeState) {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "green";
|
||||
case "current":
|
||||
return "blue";
|
||||
case "pending":
|
||||
return "lightGray";
|
||||
}
|
||||
}
|
||||
|
||||
function getBadgeStateText(state: BadgeState) {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "완료";
|
||||
case "current":
|
||||
return "진행중";
|
||||
case "pending":
|
||||
return "대기";
|
||||
}
|
||||
}
|
||||
|
||||
/** 단계명을 2줄로 분리: "설치인가 신청" → ["설치인가", "신청"], "기술검토" → ["기술검토", ""] */
|
||||
function splitStepLabel(label: string) {
|
||||
const [title = "", ...rest] = label.split(" ");
|
||||
return { title, detail: rest.join(" ") };
|
||||
}
|
||||
|
||||
function formatOptional(value: string | undefined) {
|
||||
return value?.trim() ? value : "-";
|
||||
}
|
||||
|
||||
interface ProgressStepBadgeProps {
|
||||
prgrsSttsComCd: string;
|
||||
techRplyHstryYn: "Y" | "N";
|
||||
}
|
||||
|
||||
function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadgeProps) {
|
||||
const badges = buildProgressBadges(prgrsSttsComCd, techRplyHstryYn);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-start">
|
||||
{badges.map((badge, index) => (
|
||||
<Fragment key={`${badge.label}-${index}`}>
|
||||
{index > 0 && <span className="mx-2">></span>}
|
||||
{/* 1줄: 단계명 / 2줄: 세부단계 + 상태 (예: 설치인가 / 신청완료, 기술검토 / 완료) */}
|
||||
<Badge
|
||||
color={getBadgeColor(badge.state)}
|
||||
className="flex-col justify-center !px-[20px] py-1 text-center leading-tight"
|
||||
>
|
||||
<span>{splitStepLabel(badge.label).title}</span>
|
||||
<span>
|
||||
{splitStepLabel(badge.label).detail}
|
||||
{getBadgeStateText(badge.state)}
|
||||
</span>
|
||||
</Badge>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function createSewerBscPlanColumns() {
|
||||
return [
|
||||
columnHelper.accessor("sido", {
|
||||
header: "시도",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("gugun", {
|
||||
header: "시군구",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("sewerBscPlanNm", {
|
||||
header: "기본계획명",
|
||||
meta: { align: "left" },
|
||||
cell: ({ row, getValue }) => (
|
||||
<>
|
||||
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
|
||||
</>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("sewerBscPlanSeCdNm", {
|
||||
header: "계획구분",
|
||||
meta: { align: "center" },
|
||||
cell: ({ row }) =>
|
||||
formatOptional(row.original.sewerBscPlanSeCdNm || row.original.sewerBscPlanSeComCd),
|
||||
}),
|
||||
columnHelper.accessor("taskSeCdNm", {
|
||||
header: "업무구분",
|
||||
meta: { align: "center" },
|
||||
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
|
||||
}),
|
||||
columnHelper.accessor("rgtrNm", {
|
||||
header: "담당자",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("envoSeCdNm", {
|
||||
header: "해당기관",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "progress",
|
||||
header: "진행상황",
|
||||
meta: { align: "center" },
|
||||
cell: ({ row }) => (
|
||||
<ProgressStepBadge
|
||||
prgrsSttsComCd={row.original.prgrsSttsComCd || ""}
|
||||
techRplyHstryYn={row.original.techRplyHstryYn || "N"}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("regDt", {
|
||||
header: "등록일",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
return value ? dayjs(value).format("YYYY-MM-DD") : "-";
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
|
||||
import { dayjs } from "@/shared/lib";
|
||||
import type { SewerBscPlanResponseDto } from "@/entities/sewer-bsc-plan";
|
||||
import { Badge } from "@common/components";
|
||||
|
||||
const columnHelper = createColumnHelper<SewerBscPlanResponseDto>();
|
||||
|
||||
const MAIN_STEP_LABELS = {
|
||||
apply: "기본계획 신청",
|
||||
envoReview: "기본계획 검토",
|
||||
techReview: "기술검토",
|
||||
reply: "검토결과 회신",
|
||||
} as const;
|
||||
|
||||
export const PROGRESS_STATUS_COLUMNS = [
|
||||
{ label: "기본계획 신청 (시군)", sttsComCd: "LE" },
|
||||
{ label: "기본계획 검토", sttsComCd: "ER" },
|
||||
{ label: "기술검토", sttsComCd: "TE" },
|
||||
{ label: "기술검토 회신", sttsComCd: "TP" },
|
||||
{ label: "횐경청 검토 결과 회신", sttsComCd: "EP" },
|
||||
];
|
||||
|
||||
type BadgeState = "completed" | "current" | "pending";
|
||||
|
||||
interface ProgressBadge {
|
||||
label: string;
|
||||
state: BadgeState;
|
||||
}
|
||||
|
||||
function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N"): ProgressBadge[] {
|
||||
if (prgrsSttsComCd === "SV") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "ST") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "current" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "LE") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "current" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "ER" || prgrsSttsComCd === "TE" || prgrsSttsComCd === "SL") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "current" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "TP") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
|
||||
{
|
||||
label: MAIN_STEP_LABELS.techReview,
|
||||
state: techRplyHstryYn === "Y" ? "completed" : "pending",
|
||||
},
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "current" },
|
||||
];
|
||||
}
|
||||
|
||||
if (prgrsSttsComCd === "EP") {
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
|
||||
{
|
||||
label: MAIN_STEP_LABELS.techReview,
|
||||
state: techRplyHstryYn === "Y" ? "completed" : "pending",
|
||||
},
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "completed" },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
|
||||
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
|
||||
];
|
||||
}
|
||||
|
||||
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
|
||||
if (prgrsSttsComCd === "SL") {
|
||||
return <Badge color="orange">보완요청</Badge>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getBadgeColor(state: BadgeState) {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "green";
|
||||
case "current":
|
||||
return "blue";
|
||||
case "pending":
|
||||
return "lightGray";
|
||||
}
|
||||
}
|
||||
|
||||
function getBadgeStateText(state: BadgeState) {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return "완료";
|
||||
case "current":
|
||||
return "진행중";
|
||||
case "pending":
|
||||
return "대기";
|
||||
}
|
||||
}
|
||||
|
||||
/** 단계명을 2줄로 분리: "설치인가 신청" → ["설치인가", "신청"], "기술검토" → ["기술검토", ""] */
|
||||
function splitStepLabel(label: string) {
|
||||
const [title = "", ...rest] = label.split(" ");
|
||||
return { title, detail: rest.join(" ") };
|
||||
}
|
||||
|
||||
function formatOptional(value: string | undefined) {
|
||||
return value?.trim() ? value : "-";
|
||||
}
|
||||
|
||||
interface ProgressStepBadgeProps {
|
||||
prgrsSttsComCd: string;
|
||||
techRplyHstryYn: "Y" | "N";
|
||||
}
|
||||
|
||||
function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadgeProps) {
|
||||
const badges = buildProgressBadges(prgrsSttsComCd, techRplyHstryYn);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-center">
|
||||
{badges.map((badge, index) => (
|
||||
<Fragment key={`${badge.label}-${index}`}>
|
||||
{index > 0 && <span className="mx-2">></span>}
|
||||
{/* 1줄: 단계명 / 2줄: 세부단계 + 상태 (예: 설치인가 / 신청완료, 기술검토 / 완료) */}
|
||||
<Badge
|
||||
color={getBadgeColor(badge.state)}
|
||||
className="w-[100px] flex-col justify-center !px-0 py-1 text-center leading-tight"
|
||||
>
|
||||
<span>{splitStepLabel(badge.label).title}</span>
|
||||
<span>
|
||||
{splitStepLabel(badge.label).detail}
|
||||
{getBadgeStateText(badge.state)}
|
||||
</span>
|
||||
</Badge>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function createSewerBscPlanColumns() {
|
||||
return [
|
||||
columnHelper.accessor("sido", {
|
||||
header: "시도",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("gugun", {
|
||||
header: "시군구",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("sewerBscPlanNm", {
|
||||
header: "기본계획명",
|
||||
meta: { align: "left" },
|
||||
cell: ({ row, getValue }) => (
|
||||
<>
|
||||
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
|
||||
</>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("sewerBscPlanSeCdNm", {
|
||||
header: "계획구분",
|
||||
meta: { align: "center" },
|
||||
cell: ({ row }) =>
|
||||
formatOptional(row.original.sewerBscPlanSeCdNm || row.original.sewerBscPlanSeComCd),
|
||||
}),
|
||||
columnHelper.accessor("taskSeCdNm", {
|
||||
header: "업무구분",
|
||||
meta: { align: "center" },
|
||||
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
|
||||
}),
|
||||
columnHelper.accessor("rgtrNm", {
|
||||
header: "담당자",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("envoSeCdNm", {
|
||||
header: "해당기관",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => formatOptional(getValue()),
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "progress",
|
||||
header: "진행상황",
|
||||
meta: { align: "center" },
|
||||
cell: ({ row }) => (
|
||||
<ProgressStepBadge
|
||||
prgrsSttsComCd={row.original.prgrsSttsComCd || ""}
|
||||
techRplyHstryYn={row.original.techRplyHstryYn || "N"}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("regDt", {
|
||||
header: "등록일",
|
||||
meta: { align: "center" },
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
return value ? dayjs(value).format("YYYY-MM-DD") : "-";
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { NON_NEGATIVE_INPUT_PROPS, sanitizeNumberInput } from "@/shared/lib";
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import { Field } from "@/shared/ui/field";
|
||||
import { truncateDecimals } from "@/widgets/pbadms-sprt";
|
||||
import { PreviousPlanLookupButton } from "@/widgets/previous-plan-lookup";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -21,14 +19,6 @@ import {
|
||||
|
||||
import type { SewerBscPlanRegistFormData } from "../../lib/sewer-bsc-plan-regist-schema";
|
||||
|
||||
const PreviousPlanLookupDialog = dynamic(
|
||||
() =>
|
||||
import("../previous-plan-lookup-dialog").then((mod) => ({
|
||||
default: mod.PreviousPlanLookupDialog,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const OTLN_ROWS = [
|
||||
{ key: "sewerPlanPpltnCnt" as const, label: "계획인구", decimals: 0 },
|
||||
{ key: "sewerPrcsPpltnCnt" as const, label: "처리인구", decimals: 0 },
|
||||
@@ -37,7 +27,6 @@ const OTLN_ROWS = [
|
||||
|
||||
function OutlineFormSection() {
|
||||
const { control, setValue, getValues } = useFormContext<SewerBscPlanRegistFormData>();
|
||||
const [isLookupOpen, setIsLookupOpen] = useState(false);
|
||||
|
||||
const otlnList = useWatch({ control, name: "sewerBscPlanOtlnList" }) ?? [];
|
||||
const frlvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.frlvlGoalYr" });
|
||||
@@ -74,11 +63,7 @@ function OutlineFormSection() {
|
||||
|
||||
return (
|
||||
<SectionCollapsible title="사업개요">
|
||||
<div className="mb-2 flex justify-end px-2">
|
||||
<Button type="button" color="white" onClick={() => setIsLookupOpen(true)}>
|
||||
이전기본계획조회
|
||||
</Button>
|
||||
</div>
|
||||
<PreviousPlanLookupButton sidoNm={sido} gugunNm={gugun} />
|
||||
<Table caption="사업개요 목록으로 구분, 1단계, 2단계, 3단계, 4단계 정보 제공" variant="secondary">
|
||||
<colgroup>
|
||||
<col width="12%" />
|
||||
@@ -126,13 +111,6 @@ function OutlineFormSection() {
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<PreviousPlanLookupDialog
|
||||
open={isLookupOpen}
|
||||
onOpenChange={setIsLookupOpen}
|
||||
initialSidoNm={sido}
|
||||
initialGugunNm={gugun}
|
||||
/>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,14 @@ import type {
|
||||
SewerBscPlanOtlnResponseDto,
|
||||
} from "@/entities/sewer-bsc-plan";
|
||||
import { fmtNumber } from "@/widgets/pbadms-sprt";
|
||||
import { PreviousPlanLookupButton } from "@/widgets/previous-plan-lookup";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
|
||||
|
||||
interface OutlineSectionProps {
|
||||
otlnList: SewerBscPlanOtlnResponseDto[];
|
||||
dtl?: SewerBscPlanDtlResponseDto;
|
||||
sido?: string | null;
|
||||
gugun?: string | null;
|
||||
}
|
||||
|
||||
const OTLN_ROWS = [
|
||||
@@ -19,7 +22,7 @@ const OTLN_ROWS = [
|
||||
{ key: "sewerPsplyRt" as const, label: "하수도보급율" },
|
||||
];
|
||||
|
||||
function OutlineSection({ otlnList, dtl }: OutlineSectionProps) {
|
||||
function OutlineSection({ otlnList, dtl, sido, gugun }: OutlineSectionProps) {
|
||||
const stepHeaders = [
|
||||
{ planStpNo: 1, label: "1단계", year: dtl?.frlvlGoalYr },
|
||||
{ planStpNo: 2, label: "2단계", year: dtl?.sclvlGoalYr },
|
||||
@@ -29,6 +32,7 @@ function OutlineSection({ otlnList, dtl }: OutlineSectionProps) {
|
||||
|
||||
return (
|
||||
<SectionCollapsible title="사업개요">
|
||||
<PreviousPlanLookupButton sidoNm={sido} gugunNm={gugun} />
|
||||
<Table caption="사업개요 목록으로 구분, 1단계, 2단계, 3단계, 4단계 정보 제공" variant="secondary">
|
||||
<colgroup>
|
||||
<col width="12%" />
|
||||
|
||||
@@ -1,357 +1,354 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
|
||||
|
||||
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
|
||||
import { TextShimmer } from "@/shared/ui";
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
|
||||
import { AttachmentDownloadLink, attachService } from "@/entities/attach";
|
||||
import type { SewerBscPlanRvwOpnnResponseDto } from "@/entities/sewer-bsc-plan";
|
||||
import {
|
||||
Button,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
import type {
|
||||
ReviewOpinionFormData,
|
||||
ReviewOpinionGetFormDataResult,
|
||||
} from "../../lib/review-opinion-utils";
|
||||
import type { ReviewStepCode } from "../../model/view-types";
|
||||
import { REVIEW_STEP_LABELS } from "../../model/view-types";
|
||||
|
||||
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
|
||||
|
||||
interface TechReviewOpinionSectionProps {
|
||||
rvwList: SewerBscPlanRvwOpnnResponseDto[];
|
||||
editableReviewStep?: ReviewStepCode;
|
||||
atrzSeComCdList?: string[];
|
||||
isAtrzLoading?: boolean;
|
||||
}
|
||||
|
||||
export interface TechReviewOpinionSectionHandle {
|
||||
getFormData: () => ReviewOpinionGetFormDataResult | null;
|
||||
resetForm: () => void;
|
||||
}
|
||||
|
||||
interface TechReviewOpinionTableProps {
|
||||
items: SewerBscPlanRvwOpnnResponseDto[];
|
||||
}
|
||||
|
||||
interface UploadedOpinionFile {
|
||||
fileName: string;
|
||||
fileKey: string;
|
||||
atflId?: string;
|
||||
atflCd?: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
|
||||
rvwRsltCn: "",
|
||||
rvwRsltAnmlyYn: "",
|
||||
atchFileGroupId: "",
|
||||
opnrSeComCd: "",
|
||||
};
|
||||
|
||||
function createRvwOpnnUploadKey(file: File) {
|
||||
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
|
||||
}
|
||||
|
||||
function hasFormContent(formData: ReviewOpinionFormData) {
|
||||
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
|
||||
}
|
||||
|
||||
const TechReviewOpinionSection = forwardRef<
|
||||
TechReviewOpinionSectionHandle,
|
||||
TechReviewOpinionSectionProps
|
||||
>(function TechReviewOpinionSection(
|
||||
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
|
||||
ref,
|
||||
) {
|
||||
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
|
||||
const [formData, setFormData] = useState<ReviewOpinionFormData>({
|
||||
...EMPTY_FORM_DATA,
|
||||
opnrSeComCd: editableReviewStep ?? "",
|
||||
});
|
||||
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
opnrSeComCd: editableReviewStep ?? "",
|
||||
}));
|
||||
}, [editableReviewStep]);
|
||||
|
||||
const { uploadFileAsync, isUploading, uploads } = useTus({
|
||||
onSuccess: (atflCd, atflId, _file, fileKey) => {
|
||||
if (!atflCd || !atflId) return;
|
||||
|
||||
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
|
||||
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
|
||||
},
|
||||
onError: (_error, _file, fileKey) => {
|
||||
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
|
||||
},
|
||||
});
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
getFormData: () => {
|
||||
if (!editableReviewStep || !hasFormContent(formData)) {
|
||||
return null;
|
||||
}
|
||||
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
formData: {
|
||||
...formData,
|
||||
opnrSeComCd: editableReviewStep,
|
||||
},
|
||||
rvwList,
|
||||
};
|
||||
},
|
||||
resetForm: () => {
|
||||
setFormData({
|
||||
...EMPTY_FORM_DATA,
|
||||
opnrSeComCd: editableReviewStep ?? "",
|
||||
});
|
||||
setUploadedFile(null);
|
||||
},
|
||||
}),
|
||||
[canWriteKecoOpinion, editableReviewStep, formData, rvwList],
|
||||
);
|
||||
|
||||
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
|
||||
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
|
||||
|
||||
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
|
||||
const hasKecoSection =
|
||||
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteKecoOpinion);
|
||||
|
||||
if (!hasEnvSection && !hasKecoSection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleFileUpload(files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
const fileKey = createRvwOpnnUploadKey(file);
|
||||
const existingAtflCd = formData.atchFileGroupId;
|
||||
|
||||
setUploadedFile({ fileName: file.name, fileKey });
|
||||
|
||||
try {
|
||||
await uploadFileAsync(file, {
|
||||
fileKey,
|
||||
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
|
||||
});
|
||||
} catch {
|
||||
// onError callback handles cleanup
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileRemove() {
|
||||
if (!uploadedFile) return;
|
||||
|
||||
uploads[uploadedFile.fileKey]?.abort();
|
||||
|
||||
if (uploadedFile.atflId) {
|
||||
try {
|
||||
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
|
||||
} catch (error) {
|
||||
console.error("검토의견 첨부파일 삭제 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
setUploadedFile(null);
|
||||
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
|
||||
}
|
||||
|
||||
function renderOpinionForm(reviewStep: ReviewStepCode) {
|
||||
if (editableReviewStep !== reviewStep) return null;
|
||||
if (reviewStep === "KECO" && !canWriteKecoOpinion) return null;
|
||||
|
||||
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xl font-semibold">검토의견 작성</h4>
|
||||
<Table caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공" variant="secondary">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[12rem]">의견내용</TableHead>
|
||||
<TableCell>
|
||||
<Textarea
|
||||
value={formData.rvwRsltCn}
|
||||
onChange={(event) =>
|
||||
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
|
||||
}
|
||||
placeholder="의견내용을 입력하세요"
|
||||
rows={4}
|
||||
className="min-h-[8rem] !text-xl"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>검토결과</TableHead>
|
||||
<TableCell>
|
||||
<FieldSet>
|
||||
<RadioGroup
|
||||
value={formData.rvwRsltAnmlyYn}
|
||||
onValueChange={(value) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<FieldLabel
|
||||
htmlFor={`rvw-opnn-normal-${reviewStep}`}
|
||||
className="!w-auto border-none"
|
||||
>
|
||||
<Field orientation="horizontal" className="w-auto">
|
||||
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
|
||||
<span className="font-normal">정상</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
<FieldLabel
|
||||
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
|
||||
className="!w-auto border-none"
|
||||
>
|
||||
<Field orientation="horizontal" className="w-auto">
|
||||
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
|
||||
<span className="font-normal">이상</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FieldSet>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>첨부파일</TableHead>
|
||||
<TableCell>
|
||||
{uploadedFile ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<TextShimmer className="text-xl" active={uploading}>
|
||||
{uploadedFile.fileName}
|
||||
</TextShimmer>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleFileRemove();
|
||||
}}
|
||||
disabled={uploading}
|
||||
className="size-8 !bg-destructive"
|
||||
>
|
||||
<i className="diveicon di-x text-xs" aria-label="삭제" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<label>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void handleFileUpload(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" variant="outline" type="button" asChild>
|
||||
<span>파일 선택</span>
|
||||
</Button>
|
||||
</label>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasEnvSection ? (
|
||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
|
||||
<div className="space-y-6">
|
||||
{envRvwList.length > 0 ? <TechReviewOpinionTable items={envRvwList} /> : null}
|
||||
{renderOpinionForm("ENV")}
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
) : null}
|
||||
{hasKecoSection ? (
|
||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
|
||||
<div className="space-y-6">
|
||||
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null}
|
||||
{renderOpinionForm("KECO")}
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<Table caption="검토의견 목록으로 의견자명, 의견내용, 검토결과, 첨부파일, 등록일시 정보 제공" variant="secondary">
|
||||
<colgroup>
|
||||
<col width="10%" />
|
||||
<col width="55%" />
|
||||
<col width="5%" />
|
||||
<col width="20%" />
|
||||
<col width="10%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>의견자명</TableHead>
|
||||
<TableHead>의견내용</TableHead>
|
||||
<TableHead>검토결과</TableHead>
|
||||
<TableHead>첨부파일</TableHead>
|
||||
<TableHead>등록일시</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={`${item.sewerBscPlanDocNo}_${item.rvwOpnnSn}`}>
|
||||
<TableCell align="center">{item.opnrNm}</TableCell>
|
||||
<TableCell align="left">{item.rvwRsltCn || "-"}</TableCell>
|
||||
<TableCell align="center">
|
||||
{item.rvwRsltAnmlyYn === "Y" ? "이상" : item.rvwRsltAnmlyYn === "N" ? "정상" : "-"}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{item.atchFileInfo?.atflCd ? (
|
||||
<AttachmentDownloadLink
|
||||
atflCd={item.atchFileInfo.atflCd}
|
||||
fileName={item.atchFileInfo.orgnlFileNm || "다운로드"}
|
||||
/>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { TechReviewOpinionSection };
|
||||
"use client";
|
||||
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { getDefaultFileKey, useTus, useUserStore } from "@/shared/lib";
|
||||
import { TextShimmer } from "@/shared/ui";
|
||||
import { SectionCollapsible } from "@/shared/ui";
|
||||
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
|
||||
import { attachService } from "@/entities/attach";
|
||||
import {
|
||||
sewerBscPlanKeys,
|
||||
type SewerBscPlanRvwOpnnResponseDto,
|
||||
sewerBscPlanRvwOpnnService,
|
||||
} from "@/entities/sewer-bsc-plan";
|
||||
import { RvwOpnnListTable } from "@/widgets/pbadms-sprt";
|
||||
import {
|
||||
Button,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Table,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
import type {
|
||||
ReviewOpinionFormData,
|
||||
ReviewOpinionGetFormDataResult,
|
||||
} from "../../lib/review-opinion-utils";
|
||||
import type { ReviewStepCode } from "../../model/view-types";
|
||||
import { REVIEW_STEP_LABELS } from "../../model/view-types";
|
||||
|
||||
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
|
||||
|
||||
interface TechReviewOpinionSectionProps {
|
||||
rvwList: SewerBscPlanRvwOpnnResponseDto[];
|
||||
editableReviewStep?: ReviewStepCode;
|
||||
atrzSeComCdList?: string[];
|
||||
isAtrzLoading?: boolean;
|
||||
}
|
||||
|
||||
export interface TechReviewOpinionSectionHandle {
|
||||
getFormData: () => ReviewOpinionGetFormDataResult | null;
|
||||
resetForm: () => void;
|
||||
}
|
||||
|
||||
interface TechReviewOpinionTableProps {
|
||||
items: SewerBscPlanRvwOpnnResponseDto[];
|
||||
}
|
||||
|
||||
interface UploadedOpinionFile {
|
||||
fileName: string;
|
||||
fileKey: string;
|
||||
atflId?: string;
|
||||
atflCd?: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
|
||||
rvwRsltCn: "",
|
||||
rvwRsltAnmlyYn: "",
|
||||
atchFileGroupId: "",
|
||||
opnrSeComCd: "",
|
||||
};
|
||||
|
||||
function createRvwOpnnUploadKey(file: File) {
|
||||
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
|
||||
}
|
||||
|
||||
function hasFormContent(formData: ReviewOpinionFormData) {
|
||||
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
|
||||
}
|
||||
|
||||
const TechReviewOpinionSection = forwardRef<
|
||||
TechReviewOpinionSectionHandle,
|
||||
TechReviewOpinionSectionProps
|
||||
>(function TechReviewOpinionSection(
|
||||
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
|
||||
ref,
|
||||
) {
|
||||
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
|
||||
const [formData, setFormData] = useState<ReviewOpinionFormData>({
|
||||
...EMPTY_FORM_DATA,
|
||||
opnrSeComCd: editableReviewStep ?? "",
|
||||
});
|
||||
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
opnrSeComCd: editableReviewStep ?? "",
|
||||
}));
|
||||
}, [editableReviewStep]);
|
||||
|
||||
const { uploadFileAsync, isUploading, uploads } = useTus({
|
||||
onSuccess: (atflCd, atflId, _file, fileKey) => {
|
||||
if (!atflCd || !atflId) return;
|
||||
|
||||
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
|
||||
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
|
||||
},
|
||||
onError: (_error, _file, fileKey) => {
|
||||
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
|
||||
},
|
||||
});
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
getFormData: () => {
|
||||
if (!editableReviewStep || !hasFormContent(formData)) {
|
||||
return null;
|
||||
}
|
||||
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
formData: {
|
||||
...formData,
|
||||
opnrSeComCd: editableReviewStep,
|
||||
},
|
||||
rvwList,
|
||||
};
|
||||
},
|
||||
resetForm: () => {
|
||||
setFormData({
|
||||
...EMPTY_FORM_DATA,
|
||||
opnrSeComCd: editableReviewStep ?? "",
|
||||
});
|
||||
setUploadedFile(null);
|
||||
},
|
||||
}),
|
||||
[canWriteKecoOpinion, editableReviewStep, formData, rvwList],
|
||||
);
|
||||
|
||||
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
|
||||
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
|
||||
|
||||
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
|
||||
const hasKecoSection =
|
||||
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteKecoOpinion);
|
||||
|
||||
if (!hasEnvSection && !hasKecoSection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleFileUpload(files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
const fileKey = createRvwOpnnUploadKey(file);
|
||||
const existingAtflCd = formData.atchFileGroupId;
|
||||
|
||||
setUploadedFile({ fileName: file.name, fileKey });
|
||||
|
||||
try {
|
||||
await uploadFileAsync(file, {
|
||||
fileKey,
|
||||
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
|
||||
});
|
||||
} catch {
|
||||
// onError callback handles cleanup
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileRemove() {
|
||||
if (!uploadedFile) return;
|
||||
|
||||
uploads[uploadedFile.fileKey]?.abort();
|
||||
|
||||
if (uploadedFile.atflId) {
|
||||
try {
|
||||
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
|
||||
} catch (error) {
|
||||
console.error("검토의견 첨부파일 삭제 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
setUploadedFile(null);
|
||||
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
|
||||
}
|
||||
|
||||
function isReviewTurn(reviewStep: ReviewStepCode) {
|
||||
if (editableReviewStep !== reviewStep) return false;
|
||||
if (reviewStep === "KECO" && !canWriteKecoOpinion) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderOpinionForm(reviewStep: ReviewStepCode) {
|
||||
if (!isReviewTurn(reviewStep)) return null;
|
||||
|
||||
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xl font-semibold">검토의견 작성</h4>
|
||||
<Table
|
||||
caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공"
|
||||
variant="secondary"
|
||||
>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[12rem]">의견내용</TableHead>
|
||||
<TableCell>
|
||||
<Textarea
|
||||
value={formData.rvwRsltCn}
|
||||
onChange={(event) =>
|
||||
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
|
||||
}
|
||||
placeholder="의견내용을 입력하세요"
|
||||
rows={4}
|
||||
className="min-h-[8rem] !text-xl"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>검토결과</TableHead>
|
||||
<TableCell>
|
||||
<FieldSet>
|
||||
<RadioGroup
|
||||
value={formData.rvwRsltAnmlyYn}
|
||||
onValueChange={(value) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<FieldLabel
|
||||
htmlFor={`rvw-opnn-normal-${reviewStep}`}
|
||||
className="!w-auto border-none"
|
||||
>
|
||||
<Field orientation="horizontal" className="w-auto">
|
||||
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
|
||||
<span className="font-normal">정상</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
<FieldLabel
|
||||
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
|
||||
className="!w-auto border-none"
|
||||
>
|
||||
<Field orientation="horizontal" className="w-auto">
|
||||
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
|
||||
<span className="font-normal">이상</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FieldSet>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>첨부파일</TableHead>
|
||||
<TableCell>
|
||||
{uploadedFile ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<TextShimmer className="text-xl" active={uploading}>
|
||||
{uploadedFile.fileName}
|
||||
</TextShimmer>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleFileRemove();
|
||||
}}
|
||||
disabled={uploading}
|
||||
className="size-8 !bg-destructive"
|
||||
>
|
||||
<i className="diveicon di-x text-xs" aria-label="삭제" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<label>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void handleFileUpload(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" variant="outline" type="button" asChild>
|
||||
<span>파일 선택</span>
|
||||
</Button>
|
||||
</label>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasEnvSection ? (
|
||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
|
||||
<div className="space-y-6">
|
||||
{envRvwList.length > 0 ? (
|
||||
<TechReviewOpinionTable items={envRvwList} />
|
||||
) : null}
|
||||
{renderOpinionForm("ENV")}
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
) : null}
|
||||
{hasKecoSection ? (
|
||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
|
||||
<div className="space-y-6">
|
||||
{kecoRvwList.length > 0 ? (
|
||||
<TechReviewOpinionTable items={kecoRvwList} />
|
||||
) : null}
|
||||
{renderOpinionForm("KECO")}
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
function getRvwOpnnIds(item: SewerBscPlanRvwOpnnResponseDto) {
|
||||
if (!item.sewerBscPlanDocNo || item.chgCycl == null || item.rvwOpnnSn == null) {
|
||||
throw new Error("검토의견 식별값이 없습니다.");
|
||||
}
|
||||
return [item.sewerBscPlanDocNo, item.chgCycl, item.rvwOpnnSn] as const;
|
||||
}
|
||||
|
||||
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
|
||||
|
||||
return (
|
||||
<RvwOpnnListTable
|
||||
items={items}
|
||||
getRowKey={(item) => `${item.sewerBscPlanDocNo}_${item.rvwOpnnSn}`}
|
||||
currentUserUuid={currentUserUuid}
|
||||
onUpdate={async (item, body) => {
|
||||
await sewerBscPlanRvwOpnnService.update(...getRvwOpnnIds(item), body);
|
||||
await queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
|
||||
}}
|
||||
onDelete={async (item) => {
|
||||
await sewerBscPlanRvwOpnnService.delete(...getRvwOpnnIds(item));
|
||||
await queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { TechReviewOpinionSection };
|
||||
|
||||
@@ -114,7 +114,12 @@ function SewerBscPlanViewPage({ id, propChgCycl }: SewerBscPlanViewPageProps) {
|
||||
<BasicInfoSection det={detDto} dtl={activeDtlDto} />
|
||||
<ContactInfoSection dtl={activeDtlDto} />
|
||||
<ElaprDocPrgrsStatus elaprDocNo={detDto.sewerBscPlanDocNo} />
|
||||
<OutlineSection otlnList={otlnList} dtl={activeDtlDto} />
|
||||
<OutlineSection
|
||||
otlnList={otlnList}
|
||||
dtl={activeDtlDto}
|
||||
sido={detDto.sido}
|
||||
gugun={detDto.gugun}
|
||||
/>
|
||||
<BizCstMatrixSection bizCstList={bizCstList} dtl={activeDtlDto} />
|
||||
<FnrsCstSection fnrsCstList={fnrsCstList} dtl={activeDtlDto} />
|
||||
<TmplntSection tmplntList={tmplntList} det={detDto} dtl={activeDtlDto} />
|
||||
|
||||
@@ -12,11 +12,13 @@ export const ENDPOINT = {
|
||||
RVW_MNPW: "/as-service/api/v1/rvw-mnpw",
|
||||
BNS_IF: "/as-service/api/v1/if",
|
||||
INSTL_SNCN: "/as-service/api/v1/instl-sncn",
|
||||
INSTL_SNCN_RVW_OPNN: "/as-service/api/v1/instl-sncn-rvw-opnn",
|
||||
CKLST: "/as-service/api/v1/cklst",
|
||||
CKLST_ARTCL: "/as-service/api/v1/cklst-artcl",
|
||||
CKLST_RVW: "/as-service/api/v1/cklst-rvw",
|
||||
ADSTDSTR: "/as-service/api/v1/adstdstr",
|
||||
FNRS_CNSLTN_MNG: "/as-service/api/v1/fnrs-cnsltn-mng",
|
||||
FNRS_CNSLTN_RVW_OPNN: "/as-service/api/v1/fnrs-cnsltn-rvw-opnn",
|
||||
FNRS_SHAR_SE: "/as-service/api/v1/fnrs-shar-se-mng",
|
||||
DESIGN_CNSTN: "/as-service/api/v1/design-cnstn",
|
||||
DESIGN_CNSTN_BIZ_CST: "/as-service/api/v1/design-cnstn-biz-cst",
|
||||
@@ -24,6 +26,7 @@ export const ENDPOINT = {
|
||||
DESIGN_CNSTN_ESNTL_ATCH_FILE: "/as-service/api/v1/design-cnstn-esntl-atch-file",
|
||||
DESIGN_CNSTN_RVW_OPNN: "/as-service/api/v1/design-cnstn-rvw-opnn",
|
||||
SEWER_BSC_PLAN: "/as-service/api/v1/sewer-bsc-plans",
|
||||
SEWER_BSC_PLAN_RVW_OPNNS: "/as-service/api/v1/sewer-bsc-plan-rvw-opnns",
|
||||
DASHBOARD: "/as-service/api/v1/dashboard",
|
||||
DCRAG_TO: "/as-service/api/v1/dcrag-to",
|
||||
AI_AUTO_PRCS_MNG: "/as-service/api/v1/ai-auto-prcs-mng",
|
||||
|
||||
@@ -46,6 +46,7 @@ export type {
|
||||
PbadmsMngDetResponseBase,
|
||||
RvwOpnnResponseExtension,
|
||||
RvwOpnnSaveBase,
|
||||
RvwOpnnUpdateRequestDto,
|
||||
SplmntDmndResponseExtension,
|
||||
SplmntDmndSaveBase,
|
||||
} from "./pbadms-sprt";
|
||||
|
||||
@@ -231,11 +231,22 @@ export interface PbadmsApplySaveHeader {
|
||||
rvwStpCd?: string;
|
||||
}
|
||||
|
||||
/** 검토의견 수정 공통 요청 DTO */
|
||||
export interface RvwOpnnUpdateRequestDto {
|
||||
opnrSeComCd?: string;
|
||||
opnrNm?: string;
|
||||
rvwRsltCn?: string;
|
||||
rvwRsltAnmlyYn?: string;
|
||||
atchFileGroupId?: string;
|
||||
}
|
||||
|
||||
/** 검토의견 응답 공통 확장 필드 */
|
||||
export interface RvwOpnnResponseExtension {
|
||||
opnrSeCdNm?: string;
|
||||
atchFileInfo?: AtflMngClientResponseDto;
|
||||
regDt?: string;
|
||||
rgtr?: string;
|
||||
rgtrNm?: string;
|
||||
}
|
||||
|
||||
/** 보완요구 응답 공통 확장 필드 */
|
||||
|
||||
@@ -51,6 +51,7 @@ export {
|
||||
} from "./ui/pbadms-task-apply-dialog";
|
||||
export { PrivacyConsentSection } from "./ui/privacy-consent-section";
|
||||
export { ProgressStatusSection } from "./ui/progress-status-section";
|
||||
export { RvwOpnnListTable, type RvwOpnnListTableProps } from "./ui/rvw-opnn-list-table";
|
||||
export {
|
||||
SplmntDmndDialog,
|
||||
type SplmntDmndDialogConfirmItem,
|
||||
|
||||
@@ -404,11 +404,17 @@ function AttachmentsFormSection({
|
||||
<TableHead
|
||||
scope="row"
|
||||
className="group break-keep font-medium"
|
||||
data-required={file.esntlAtchYn === "Y"}
|
||||
align="left"
|
||||
>
|
||||
<div className="inline-flex w-[calc(100%-1rem)] items-center justify-between gap-10">
|
||||
{file.esntlAtchFileNm}
|
||||
<span>
|
||||
{file.esntlAtchFileNm}
|
||||
{file.esntlAtchYn === "Y" && (
|
||||
<span className="whitespace-nowrap text-red-600" title="필수 항목">
|
||||
{" "}(*)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{atflCd ? (
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
|
||||
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
|
||||
import type { AtflMngClientResponseDto, RvwOpnnUpdateRequestDto } from "@/shared/model";
|
||||
import { AttachmentFileField } from "@/shared/ui";
|
||||
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
|
||||
import {
|
||||
AttachmentDownloadLink,
|
||||
attachService,
|
||||
useDownloadAttachmentByAtflId,
|
||||
} from "@/entities/attach";
|
||||
import {
|
||||
Button,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
const RVW_OPNN_EDIT_FILE_KEY_PREFIX = "rvw-opnn-edit::";
|
||||
|
||||
/** 업무별 검토의견 응답 DTO 공통 필드 (업무마다 null 허용 여부가 달라 느슨하게 정의) */
|
||||
interface RvwOpnnListItem {
|
||||
opnrSeComCd?: string | null;
|
||||
opnrNm?: string | null;
|
||||
rvwRsltCn?: string | null;
|
||||
rvwRsltAnmlyYn?: string | null;
|
||||
atchFileGroupId?: string | null;
|
||||
atchFileInfo?: AtflMngClientResponseDto | null;
|
||||
regDt?: string | null;
|
||||
rgtr?: string | null;
|
||||
}
|
||||
|
||||
interface RvwOpnnListTableProps<T extends RvwOpnnListItem> {
|
||||
items: T[];
|
||||
getRowKey: (item: T) => string;
|
||||
/** 로그인 사용자 UUID — 검토의견 등록자(rgtr)와 같을 때만 수정·삭제 버튼 노출 */
|
||||
currentUserUuid?: string;
|
||||
onUpdate: (item: T, body: RvwOpnnUpdateRequestDto) => Promise<void>;
|
||||
onDelete: (item: T) => Promise<void>;
|
||||
}
|
||||
|
||||
interface EditDraft {
|
||||
rvwRsltCn: string;
|
||||
rvwRsltAnmlyYn: string;
|
||||
atchFileGroupId: string;
|
||||
}
|
||||
|
||||
interface EditFile {
|
||||
fileName: string;
|
||||
fileKey: string;
|
||||
atflId?: string;
|
||||
atflCd?: string;
|
||||
/** 이번 수정 중 새로 업로드한 파일 여부 (취소 시 정리 대상) */
|
||||
isNew: boolean;
|
||||
}
|
||||
|
||||
function formatAnmlyYn(value?: string | null) {
|
||||
if (value === "Y") return "이상";
|
||||
if (value === "N") return "정상";
|
||||
return "-";
|
||||
}
|
||||
|
||||
function RvwOpnnListTable<T extends RvwOpnnListItem>({
|
||||
items,
|
||||
getRowKey,
|
||||
currentUserUuid,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: RvwOpnnListTableProps<T>) {
|
||||
const { confirm } = useConfirm();
|
||||
const { mutate: downloadByAtflId } = useDownloadAttachmentByAtflId();
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<EditDraft | null>(null);
|
||||
const [editFile, setEditFile] = useState<EditFile | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { uploadFileAsync, isUploading, uploads } = useTus({
|
||||
onSuccess: (atflCd, atflId, _file, fileKey) => {
|
||||
if (!atflCd || !atflId) return;
|
||||
|
||||
setDraft((prev) => (prev ? { ...prev, atchFileGroupId: atflCd } : prev));
|
||||
setEditFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
|
||||
},
|
||||
onError: (_error, _file, fileKey) => {
|
||||
setEditFile((prev) => (prev?.fileKey === fileKey ? null : prev));
|
||||
},
|
||||
});
|
||||
|
||||
const editFileUploading = editFile ? (isUploading[editFile.fileKey] ?? false) : false;
|
||||
|
||||
function canModify(item: T) {
|
||||
return Boolean(currentUserUuid) && item.rgtr === currentUserUuid;
|
||||
}
|
||||
|
||||
async function discardNewFile(file: EditFile | null) {
|
||||
if (!file?.isNew) return;
|
||||
|
||||
uploads[file.fileKey]?.abort();
|
||||
if (file.atflId) {
|
||||
try {
|
||||
await attachService.deleteAttachmentByAtflId(file.atflId);
|
||||
} catch (error) {
|
||||
console.error("검토의견 첨부파일 삭제 실패:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetEdit() {
|
||||
setEditingKey(null);
|
||||
setDraft(null);
|
||||
setEditFile(null);
|
||||
}
|
||||
|
||||
function startEdit(item: T) {
|
||||
void discardNewFile(editFile);
|
||||
|
||||
setEditingKey(getRowKey(item));
|
||||
setDraft({
|
||||
rvwRsltCn: item.rvwRsltCn ?? "",
|
||||
rvwRsltAnmlyYn: item.rvwRsltAnmlyYn ?? "",
|
||||
atchFileGroupId: item.atchFileGroupId ?? "",
|
||||
});
|
||||
setEditFile(
|
||||
item.atchFileInfo?.atflCd
|
||||
? {
|
||||
fileName: item.atchFileInfo.orgnlFileNm || "첨부파일",
|
||||
fileKey: `${RVW_OPNN_EDIT_FILE_KEY_PREFIX}${item.atchFileInfo.atflCd}`,
|
||||
atflId: item.atchFileInfo.atflId,
|
||||
atflCd: item.atchFileInfo.atflCd,
|
||||
isNew: false,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
void discardNewFile(editFile);
|
||||
resetEdit();
|
||||
}
|
||||
|
||||
async function handleFileAdd(files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
const fileKey = `${RVW_OPNN_EDIT_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
|
||||
|
||||
setEditFile({ fileName: file.name, fileKey, isNew: true });
|
||||
|
||||
try {
|
||||
await uploadFileAsync(file, { fileKey });
|
||||
} catch {
|
||||
// onError callback handles cleanup
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileRemove() {
|
||||
// 기존 파일은 저장 시점에 연결만 해제하고, 새로 올린 파일은 즉시 정리
|
||||
void discardNewFile(editFile);
|
||||
setEditFile(null);
|
||||
setDraft((prev) => (prev ? { ...prev, atchFileGroupId: "" } : prev));
|
||||
}
|
||||
|
||||
async function handleSave(item: T) {
|
||||
if (!draft) return;
|
||||
if (!draft.rvwRsltCn.trim()) {
|
||||
alert("의견내용을 입력하세요.");
|
||||
return;
|
||||
}
|
||||
if (!draft.rvwRsltAnmlyYn) {
|
||||
alert("검토결과를 선택하세요.");
|
||||
return;
|
||||
}
|
||||
if (!(await confirm("검토의견을 수정하시겠습니까?"))) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onUpdate(item, {
|
||||
opnrSeComCd: item.opnrSeComCd ?? undefined,
|
||||
opnrNm: item.opnrNm ?? undefined,
|
||||
rvwRsltCn: draft.rvwRsltCn,
|
||||
rvwRsltAnmlyYn: draft.rvwRsltAnmlyYn,
|
||||
atchFileGroupId: draft.atchFileGroupId,
|
||||
});
|
||||
alert("수정되었습니다.");
|
||||
resetEdit();
|
||||
} catch (error) {
|
||||
console.error("검토의견 수정 실패:", error);
|
||||
alert("검토의견 수정 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: T) {
|
||||
if (!(await confirm("검토의견을 삭제하시겠습니까?"))) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onDelete(item);
|
||||
alert("삭제되었습니다.");
|
||||
if (editingKey === getRowKey(item)) resetEdit();
|
||||
} catch (error) {
|
||||
console.error("검토의견 삭제 실패:", error);
|
||||
alert("검토의견 삭제 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderViewRow(item: T) {
|
||||
return (
|
||||
<>
|
||||
<TableCell align="left">{item.rvwRsltCn || "-"}</TableCell>
|
||||
<TableCell align="center">{formatAnmlyYn(item.rvwRsltAnmlyYn)}</TableCell>
|
||||
<TableCell align="center">
|
||||
{item.atchFileInfo?.atflCd ? (
|
||||
<AttachmentDownloadLink
|
||||
atflCd={item.atchFileInfo.atflCd}
|
||||
fileName={item.atchFileInfo.orgnlFileNm || "다운로드"}
|
||||
/>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableCell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderEditRow(rowKey: string, editDraft: EditDraft) {
|
||||
return (
|
||||
<>
|
||||
<TableCell align="left">
|
||||
<Textarea
|
||||
value={editDraft.rvwRsltCn}
|
||||
onChange={(event) =>
|
||||
setDraft((prev) => (prev ? { ...prev, rvwRsltCn: event.target.value } : prev))
|
||||
}
|
||||
placeholder="의견내용을 입력하세요"
|
||||
rows={4}
|
||||
className="min-h-[8rem] !text-xl"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<FieldSet>
|
||||
<RadioGroup
|
||||
value={editDraft.rvwRsltAnmlyYn}
|
||||
onValueChange={(value) =>
|
||||
setDraft((prev) => (prev ? { ...prev, rvwRsltAnmlyYn: value } : prev))
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<FieldLabel
|
||||
htmlFor={`rvw-opnn-edit-normal-${rowKey}`}
|
||||
className="!w-auto border-none"
|
||||
>
|
||||
<Field orientation="horizontal" className="w-auto">
|
||||
<RadioGroupItem value="N" id={`rvw-opnn-edit-normal-${rowKey}`} />
|
||||
<span className="font-normal">정상</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
<FieldLabel
|
||||
htmlFor={`rvw-opnn-edit-abnormal-${rowKey}`}
|
||||
className="!w-auto border-none"
|
||||
>
|
||||
<Field orientation="horizontal" className="w-auto">
|
||||
<RadioGroupItem value="Y" id={`rvw-opnn-edit-abnormal-${rowKey}`} />
|
||||
<span className="font-normal">이상</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FieldSet>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<AttachmentFileField
|
||||
files={
|
||||
editFile
|
||||
? [
|
||||
{
|
||||
fileKey: editFile.fileKey,
|
||||
fileName: editFile.fileName,
|
||||
onDownload: editFile.atflId
|
||||
? () => downloadByAtflId(editFile.atflId!)
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
inputId={`rvw-opnn-edit-${rowKey}-file-input`}
|
||||
isUploadingByKey={isUploading}
|
||||
multiple={false}
|
||||
showAddButton={!editFile}
|
||||
addDisabled={editFileUploading}
|
||||
onAdd={(fileList) => void handleFileAdd(fileList)}
|
||||
onRemove={handleFileRemove}
|
||||
/>
|
||||
</TableCell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<Table
|
||||
caption="검토의견 목록으로 의견자명, 의견내용, 검토결과, 첨부파일, 등록일시, 관리 정보 제공"
|
||||
variant="secondary"
|
||||
>
|
||||
<colgroup>
|
||||
<col width="10%" />
|
||||
<col width="47%" />
|
||||
<col width="6%" />
|
||||
<col width="17%" />
|
||||
<col width="10%" />
|
||||
<col width="10%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>의견자명</TableHead>
|
||||
<TableHead>의견내용</TableHead>
|
||||
<TableHead>검토결과</TableHead>
|
||||
<TableHead>첨부파일</TableHead>
|
||||
<TableHead>등록일시</TableHead>
|
||||
<TableHead>관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => {
|
||||
const rowKey = getRowKey(item);
|
||||
const isEditing = editingKey === rowKey && draft !== null;
|
||||
|
||||
return (
|
||||
<TableRow key={rowKey}>
|
||||
<TableCell align="center">{item.opnrNm}</TableCell>
|
||||
{isEditing ? renderEditRow(rowKey, draft) : renderViewRow(item)}
|
||||
<TableCell align="center">
|
||||
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{!canModify(item) ? (
|
||||
"-"
|
||||
) : isEditing ? (
|
||||
<div className="flex flex-wrap justify-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => void handleSave(item)}
|
||||
disabled={isSubmitting || editFileUploading}
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="white"
|
||||
onClick={cancelEdit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap justify-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="white"
|
||||
onClick={() => startEdit(item)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="gray"
|
||||
onClick={() => void handleDelete(item)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { RvwOpnnListTable, type RvwOpnnListTableProps };
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
PreviousPlanLookupButton,
|
||||
type PreviousPlanLookupButtonProps,
|
||||
} from "./ui/previous-plan-lookup-button";
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import { Button } from "@common/components";
|
||||
|
||||
const PreviousPlanLookupDialog = dynamic(
|
||||
() =>
|
||||
import("./previous-plan-lookup-dialog").then((mod) => ({
|
||||
default: mod.PreviousPlanLookupDialog,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
interface PreviousPlanLookupButtonProps {
|
||||
/** 조회 다이얼로그 시도 초기값(표시명) */
|
||||
sidoNm?: string | null;
|
||||
/** 조회 다이얼로그 시군구 초기값(표시명) */
|
||||
gugunNm?: string | null;
|
||||
}
|
||||
|
||||
/** 섹션 상단 우측에 [이전기본계획조회] 버튼과 조회 다이얼로그를 배치한다. */
|
||||
function PreviousPlanLookupButton({ sidoNm, gugunNm }: PreviousPlanLookupButtonProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 flex justify-end px-2">
|
||||
<Button type="button" color="white" onClick={() => setIsOpen(true)}>
|
||||
이전기본계획조회
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<PreviousPlanLookupDialog
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
initialSidoNm={sidoNm}
|
||||
initialGugunNm={gugunNm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { PreviousPlanLookupButton };
|
||||
export type { PreviousPlanLookupButtonProps };
|
||||
+33
-14
@@ -51,7 +51,7 @@ function PreviousPlanLookupDialog({
|
||||
const [gnsvyYr, setGnsvyYr] = useState("");
|
||||
const [swtrmtFcltCd, setSwtrmtFcltCd] = useState("");
|
||||
const [hasPrefillApplied, setHasPrefillApplied] = useState(false);
|
||||
const [searchedClsfSystmNm, setSearchedClsfSystmNm] = useState("");
|
||||
const [hasGugunPrefillApplied, setHasGugunPrefillApplied] = useState(false);
|
||||
|
||||
const { data: sidoData, isPending: isSidoPending } = useFindSidoCombo();
|
||||
const { data: gugunData, isPending: isGugunPending } = useFindGugunCombo(sidoComCd);
|
||||
@@ -62,15 +62,19 @@ function PreviousPlanLookupDialog({
|
||||
const ctpvNm = sidoOptions.find((option) => option.adstDstrCd === sidoComCd)?.adstDstrNm ?? "";
|
||||
const sggNm = gugunOptions.find((option) => option.adstDstrCd === sggComCd)?.adstDstrNm ?? "";
|
||||
|
||||
// 창 오픈 시 시도/시군구로 조회하고, 콤보 선택에 따라 파라미터가 추가되며 재조회된다.
|
||||
// 빈 값은 서비스에서 제외되며, 시도가 없으면 조회하지 않는다.
|
||||
const searchParams = {
|
||||
swtrmtFcltCd,
|
||||
ctpvNm,
|
||||
sggNm,
|
||||
targetYear: gnsvyYr,
|
||||
swtrmtFcltCd,
|
||||
};
|
||||
const isSearchEnabled = open && hasGugunPrefillApplied && Boolean(ctpvNm);
|
||||
|
||||
const { data, isFetching, refetch } = useNowDmgMtncGnsvy(searchParams, false);
|
||||
const planData = gnsvyYr && swtrmtFcltCd ? (data?.[0] ?? null) : null;
|
||||
const { data, isFetching, refetch } = useNowDmgMtncGnsvy(searchParams, isSearchEnabled);
|
||||
const planData = isSearchEnabled ? (data?.[0] ?? null) : null;
|
||||
const clsfSystmNm = planData ? sggNm || ctpvNm : "";
|
||||
|
||||
const { data: yearTree, isPending: isYearPending } = useLvNowDmgMtncGnsvy({
|
||||
level: "2",
|
||||
@@ -97,8 +101,8 @@ function PreviousPlanLookupDialog({
|
||||
setSggComCd("");
|
||||
setGnsvyYr("");
|
||||
setSwtrmtFcltCd("");
|
||||
setSearchedClsfSystmNm("");
|
||||
setHasPrefillApplied(false);
|
||||
setHasGugunPrefillApplied(false);
|
||||
return;
|
||||
}
|
||||
}, [open]);
|
||||
@@ -115,12 +119,27 @@ function PreviousPlanLookupDialog({
|
||||
setHasPrefillApplied(true);
|
||||
}, [open, hasPrefillApplied, isSidoPending, sidoOptions, initialSidoNm]);
|
||||
|
||||
// 시군구 초기값 적용이 끝난 뒤에 최초 조회가 나가도록 완료 여부를 표시한다.
|
||||
useEffect(() => {
|
||||
if (!open || !sidoComCd || isGugunPending || !initialGugunNm) return;
|
||||
if (sggComCd) return;
|
||||
if (!open || !hasPrefillApplied || hasGugunPrefillApplied) return;
|
||||
if (!sidoComCd || !initialGugunNm) {
|
||||
setHasGugunPrefillApplied(true);
|
||||
return;
|
||||
}
|
||||
if (isGugunPending) return;
|
||||
|
||||
const matchedGugun = gugunOptions.find((option) => option.adstDstrNm === initialGugunNm);
|
||||
if (matchedGugun) setSggComCd(matchedGugun.adstDstrCd);
|
||||
}, [open, sidoComCd, isGugunPending, gugunOptions, initialGugunNm, sggComCd]);
|
||||
setHasGugunPrefillApplied(true);
|
||||
}, [
|
||||
open,
|
||||
hasPrefillApplied,
|
||||
hasGugunPrefillApplied,
|
||||
sidoComCd,
|
||||
isGugunPending,
|
||||
gugunOptions,
|
||||
initialGugunNm,
|
||||
]);
|
||||
|
||||
function handleSidoChange(value: string) {
|
||||
setSidoComCd(value);
|
||||
@@ -145,18 +164,18 @@ function PreviousPlanLookupDialog({
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
if (!ctpvNm) {
|
||||
alert("시도를 선택하세요.");
|
||||
return;
|
||||
}
|
||||
const { data: list, error } = await refetch();
|
||||
if (error) {
|
||||
alert("데이터 조회 중 오류가 발생했습니다.");
|
||||
setSearchedClsfSystmNm("");
|
||||
return;
|
||||
}
|
||||
if (!list?.length) {
|
||||
alert("조회된 데이터가 없습니다.");
|
||||
setSearchedClsfSystmNm("");
|
||||
return;
|
||||
}
|
||||
setSearchedClsfSystmNm(sggNm || ctpvNm);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -198,8 +217,8 @@ function PreviousPlanLookupDialog({
|
||||
|
||||
<SectionCollapsible title="기본계획파일" defaultOpen>
|
||||
<PreviousPlanFileSection
|
||||
key={searchedClsfSystmNm || "empty"}
|
||||
clsfSystmNm={searchedClsfSystmNm || undefined}
|
||||
key={clsfSystmNm || "empty"}
|
||||
clsfSystmNm={clsfSystmNm || undefined}
|
||||
/>
|
||||
</SectionCollapsible>
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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차 요구사항, 기능 명세 또는 승인된 작업 문서를 근거로 한다.
|
||||
|
||||
+3
-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;
|
||||
|
||||
+4
-4
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+16
-7
@@ -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, {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user