Compare commits

..
33 Commits
Author SHA1 Message Date
rsone001 b5f5938e51 [ST] - fix: excel 가져오기 시 코드값이 숫자로 인식되어 저장이 이루어지지 않던 문제 수정 2026-10-29 10:27:42 +09:00
rsone001 038b2d441e [ST] - refactor: 처리내역 컬럼 제거 및 표준화 누락 부분 수정 2026-10-28 17:24:12 +09:00
rsone001 2ec58f1f5b [ST] - 기본 페이지에서 대시보드 도움말을 불러오기 위해 메뉴코드 전달 2026-10-27 15:52:00 +09:00
rsone001 3cf3617513 [ST] - 검토 & 집계 2-5 컬럼 추가 2026-10-23 14:45:14 +09:00
rsone001 b611538180 [ST] - input 2-5 맨홀 컬럼 추가 2026-10-23 13:48:17 +09:00
rsone001 1cffe06cf9 [ST] - input : 시계열 검증 오류 시 진행상황 코드 롤백 api 추가, 일반 검증 관리 interface 수정 2026-10-22 18:02:48 +09:00
rsone001 9a24e4aa23 [ST] - 입력 verificationResult - cmprCn, nowCn 값 null 일시 치환 2026-10-22 16:48:34 +09:00
rsone001 3732c9380b [ST] - doList 누락 수정 2026-10-22 11:44:35 +09:00
rsone001 9e15b23eda [ST] - fix: input 2-3 저장, 제출 request 수정 2026-10-21 17:17:54 +09:00
rsone001 6f300918cd [ST] - fix: 그리드 여러행 복사 붙여넣기 시 초과된 행 수 많큼 빈행이 추가되지 않도록 수정 2026-10-21 16:04:41 +09:00
rsone001 619878bc03 Merge branch 'iipbs/st/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/st/dev 2026-10-21 16:01:05 +09:00
jcchoi 9f0e03c151 이상치 선별보고서 2026-10-21 11:31:50 +09:00
jcchoi 471a431af5 대시보드 진행상태 변경 2026-10-21 10:37:32 +09:00
jcchoi 26d8410649 Merge remote-tracking branch 'origin/iipbs/st/dev'
Deploy Frontend / deploy (push) Successful in 5m0s
2026-10-20 21:52:31 +09:00
jcchoi 8403ad556c 설치인가 버튼 이벤트 수정 2026-10-20 21:50:41 +09:00
rsone001 d26c15f857 Merge branch 'iipbs/st/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/st/dev 2026-10-20 17:49:42 +09:00
rsone001 86ed664e3e [input] - 오류 로직 수정 2026-10-20 17:49:09 +09:00
jcchoi a105f395dd 설치인가 프로세스 변경 2026-10-20 15:50:05 +09:00
jcchoi a5ebc12580 검토의견 수정 오류 처리 2026-10-20 13:48:39 +09:00
jcchoi 270f6e366f Merge branch 'iipbs/st/dev' 2026-10-20 10:53:41 +09:00
jcchoi acf215c1bf 대시보드 이상치선별보고서 수정 2026-10-20 10:45:29 +09:00
jcchoi 8a98cd7d19 행정지원 검토의견 수정 2026-10-20 10:04:42 +09:00
jcchoi 3a5b48b608 통계 도움말 오류 수정 2026-10-20 10:04:26 +09:00
jcchoi d735ad7dd7 통계 파라미터 변경
Deploy Frontend / deploy (push) Successful in 1m57s
2026-10-19 21:28:01 +09:00
jcchoi 25dd0f1504 getVerifyResultList 파라미터 변경
Deploy Frontend / deploy (push) Successful in 2m2s
2026-10-19 19:17:01 +09:00
jcchoi 8e14c6cef5 Merge remote-tracking branch 'origin/iipbs/st/dev' 2026-10-19 19:07:14 +09:00
jcchoi 9f7abe4413 필수첨부파일 위치 업데이트 2026-10-19 18:41:58 +09:00
rsone001 de730205af [ST] - 일반 검증 등록 및 수정 다이얼로그 css 수정 2026-10-19 17:50:49 +09:00
rsone001 a873f0e0dc [ST] - api 표준화 누락 수정 2026-10-19 15:14:58 +09:00
jcchoi 32e3765b52 초안 평가 단계 수정
Deploy Frontend / deploy (push) Successful in 1m53s
2026-10-15 19:56:54 +09:00
rsone001 4f62095e8e [ST] - fix: input 2-3 검증 cell 스타일 로직 수정 2026-10-15 16:03:40 +09:00
jcchoi 5234448ae5 기본계획 조회 전체 적용
Deploy Frontend / deploy (push) Successful in 1m40s
2026-10-15 15:53:38 +09:00
jcchoi f284c67988 hwp 서버 도메인 연결 2026-10-15 10:43:15 +09:00
124 changed files with 4732 additions and 4046 deletions
+4 -4
View File
@@ -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>
+44 -27
View File
@@ -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>
+5 -1
View File
@@ -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;
}
@@ -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,
};
+66 -65
View File
@@ -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
View File
@@ -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,
};
+65 -64
View File
@@ -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";
+183 -183
View File
@@ -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">&gt;</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">&gt;</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 };
+230 -230
View File
@@ -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">&gt;</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">&gt;</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";
}
+231 -231
View File
@@ -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">&gt;</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">&gt;</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 };
+235 -235
View File
@@ -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">&gt;</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">&gt;</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} />
+3
View File
@@ -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",
+1
View File
@@ -46,6 +46,7 @@ export type {
PbadmsMngDetResponseBase,
RvwOpnnResponseExtension,
RvwOpnnSaveBase,
RvwOpnnUpdateRequestDto,
SplmntDmndResponseExtension,
SplmntDmndSaveBase,
} from "./pbadms-sprt";
+11
View File
@@ -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;
}
/** 보완요구 응답 공통 확장 필드 */
+1
View File
@@ -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 };
@@ -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>
+35 -69
View File
@@ -33,6 +33,7 @@ import {
hasAdmdstCdColumn,
isExcelImportAdmdstCdInvalid,
mergeExcelImportWithExistingRows,
parseExcelImportCellValue,
prepareGridColumnsForExcelExport,
releaseFixedOptionsForExcelExport,
resolveColumnStyleName,
@@ -289,14 +290,28 @@ type GridViewWithSetColumnProperty = {
};
type GridViewWithEditOptions = {
setEditOptions?: (options: { commitWhenLeave?: boolean }) => void;
setEditOptions?: (options: {
commitWhenLeave?: boolean;
appendable?: boolean;
insertable?: boolean;
}) => void;
setPasteOptions?: (options: { enableAppend?: boolean }) => void;
};
/** 포커스가 그리드 밖으로 나갈 때 편집 중인 값을 자동 commit */
function applyDefaultGridEditOptions(
gridView: GridViewWithEditOptions | null | undefined,
options?: { preventRowAppend?: boolean },
) {
gridView?.setEditOptions?.({ commitWhenLeave: true });
gridView?.setEditOptions?.({
commitWhenLeave: true,
...(options?.preventRowAppend
? { appendable: false, insertable: false }
: {}),
});
if (options?.preventRowAppend) {
gridView?.setPasteOptions?.({ enableAppend: false });
}
}
function isDateEditorColumn(column: {
@@ -853,6 +868,7 @@ const DataGrid = ({
gridHeight = 55, // vh 단위
headerHeight = 60,
fitStyle = GridFitStyle.EVEN,
preventRowAppend = true,
}: DataGridProps) => {
const { columns, columnLayout } = columnInfo;
const fixedColumnNameSet = useMemo(
@@ -1331,6 +1347,11 @@ useEffect(() => {
columnLayout,
{ skipNonEditable: false },
);
const valueTypeByFieldName = new Map(
columns
.filter((c) => c.fieldName)
.map((c) => [c.fieldName as string, c.valueType]),
);
// 데이터 행 읽기
const dataRows: Record<string, string | number>[] = [];
@@ -1352,36 +1373,13 @@ useEffect(() => {
];
const fieldName = excelColumnFieldNames[colIndex];
const allFieldName = excelColumnFieldNamesAll[colIndex];
let cellValue: string | number = "";
if (
cell &&
cell.v !== undefined &&
cell.v !== null
) {
// 엑셀 셀 타입이 숫자이거나 값이 숫자인 경우 숫자로 변환
if (
cell.t === "n" ||
(typeof cell.v === "number" &&
!isNaN(cell.v))
) {
cellValue = cell.v as number;
} else {
const stringValue = String(cell.v);
// 문자열이지만 숫자로 변환 가능한 경우 숫자로 변환
const numericValue = Number(
stringValue.replace(/,/g, ""),
);
if (
!isNaN(numericValue) &&
stringValue.trim() !== ""
) {
cellValue = numericValue;
} else {
cellValue = stringValue;
}
}
}
const typeFieldName = fieldName || allFieldName;
const cellValue = parseExcelImportCellValue(
cell,
typeFieldName
? valueTypeByFieldName.get(typeFieldName)
: undefined,
);
if (allFieldName === "admdstCd" && cellValue !== "") {
rowAdmdstCd = cellValue;
@@ -1467,7 +1465,9 @@ useEffect(() => {
const scheduleFooterTask = (task: () => void, delayMs: number) => {
footerTimerIds.push(setTimeout(task, delayMs));
};
applyDefaultGridEditOptions(gridView as GridViewWithEditOptions);
applyDefaultGridEditOptions(gridView as GridViewWithEditOptions, {
preventRowAppend,
});
const columnsFieldsSig = columns.map((c) => c.fieldName).join("\u0001");
if (columnsFieldsSig !== columnsFieldsSigRef.current) {
@@ -1990,44 +1990,9 @@ useEffect(() => {
fitStyle,
enableSort,
footerVerificationResult,
preventRowAppend,
]);
/** footerVerificationResult만 바뀐 경우에도 총계 스타일 즉시 재적용/초기화 */
useEffect(() => {
const gridView = gridRef.current?.gridView as
| {
columnByName?: (name: string) => {
footer?: { styleName?: string } | Array<{ styleName?: string }>;
} | null;
refresh?: () => void;
}
| undefined;
if (!gridView?.columnByName) return;
columns.forEach((col) => {
if (!col.footer) return;
try {
const column = gridView.columnByName?.(col.name);
if (!column?.footer) return;
const styleName =
resolveFooterVerificationStyleName(
col,
footerVerificationResult,
) ?? "";
if (Array.isArray(column.footer)) {
column.footer.forEach((item) => {
item.styleName = styleName;
});
} else {
column.footer.styleName = styleName;
}
} catch {
console.error('총계 스타일 재적용/초기화 error');
}
});
gridView.refresh?.();
}, [footerVerificationResult, columns]);
// 행·셀 클릭 이벤트 (검증 오류 툴팁은 hover만 — onShowTooltip / 빈 셀 오버레이)
useEffect(() => {
const gridView = gridRef.current?.gridView;
@@ -2563,6 +2528,7 @@ useEffect(() => {
const gridView = gridRef.current?.gridView ?? grid;
applyDefaultGridEditOptions(
gridView as GridViewWithEditOptions,
{ preventRowAppend },
);
if (!gridView?.columnByName) return;
setTimeout(() => {
@@ -63,7 +63,7 @@ const exceptionHistoryTabs: { value: ExceptionHistoryTabKey; label: string }[] =
const TITLE = "예외처리 내역 조회";
/** 예외처리 건만 */
const PRCS_YN_LIST = "2";
const VRFC_STTS_CD_LIST = "2";
export type ExceptionRejectSubmitItem = {
vrfcSn: number;
@@ -196,8 +196,7 @@ export function ExceptionProcessingHistoryDialog({
statsYr: queryContext.statsYr,
statsCd: queryContext.statsCd,
vrfcMthdCd,
prcsYnList: PRCS_YN_LIST,
rejectCodeList: "0,1,2",
vrfcSttsCdList: VRFC_STTS_CD_LIST,
page: pageIndex + 1,
size: 30,
};
+13 -17
View File
@@ -53,6 +53,7 @@ import {
hasAdmdstCdColumn,
isExcelImportAdmdstCdInvalid,
mergeExcelImportWithExistingRows,
parseExcelImportCellValue,
prepareGridColumnsForExcelExport,
releaseFixedOptionsForExcelExport,
resolveColumnStyleName,
@@ -611,6 +612,11 @@ const TabDataGrid = ({
columnLayout,
{ skipNonEditable: false },
);
const valueTypeByFieldName = new Map(
columns
.filter((c) => c.fieldName)
.map((c) => [c.fieldName as string, c.valueType]),
);
// 데이터 행 읽기
const dataRows: Record<string, string | number>[] = [];
@@ -625,23 +631,13 @@ const TabDataGrid = ({
const cell = worksheet[XLSX.utils.encode_cell({ r: row, c: col })];
const fieldName = excelColumnFieldNames[colIndex];
const allFieldName = excelColumnFieldNamesAll[colIndex];
let cellValue: string | number = '';
if (cell && cell.v !== undefined && cell.v !== null) {
// 엑셀 셀 타입이 숫자이거나 값이 숫자인 경우 숫자로 변환
if (cell.t === 'n' || (typeof cell.v === 'number' && !isNaN(cell.v))) {
cellValue = cell.v as number;
} else {
const stringValue = String(cell.v);
// 문자열이지만 숫자로 변환 가능한 경우 숫자로 변환
const numericValue = Number(stringValue.replace(/,/g, ''));
if (!isNaN(numericValue) && stringValue.trim() !== '') {
cellValue = numericValue;
} else {
cellValue = stringValue;
}
}
}
const typeFieldName = fieldName || allFieldName;
const cellValue = parseExcelImportCellValue(
cell,
typeFieldName
? valueTypeByFieldName.get(typeFieldName)
: undefined,
);
if (allFieldName === 'admdstCd' && cellValue !== '') {
rowAdmdstCd = cellValue;
@@ -232,8 +232,7 @@ export function VerificationResultDialog({
statsYr: stableQueryContext.statsYr,
statsCd: stableQueryContext.statsCd,
vrfcMthdCd,
prcsYnList: "0",
rejectCodeList: "0,1,2",
vrfcSttsCdList: "0",
page: page + 1,
size: 10000,
};
@@ -262,8 +262,7 @@ export function VerificationResultHistoryDialog({
statsYr: stableQueryContext.statsYr,
statsCd: stableQueryContext.statsCd,
vrfcMthdCd,
prcsYnList: "0,2",
rejectCodeList: "0,1,2",
vrfcSttsCdList: "0,2",
page: pageIndex + 1,
size: PAGE_SIZE,
};
@@ -22,7 +22,7 @@ function responseHasVerificationResults(response: VerificationResultListResponse
}
/**
* 제출 직후 `VerificationResultDialog`에 표시할 미통과(prcsYn=0) 검증 결과가
* 제출 직후 `VerificationResultDialog`에 표시할 미통과(vrfcSttsCd=0) 검증 결과가
* 일반검증(NV)·I-MR(IMR)·HB 중 하나라도 있는지 조회합니다.
*/
export async function hasAnyPostSubmitVerificationResult(
@@ -34,8 +34,7 @@ export async function hasAnyPostSubmitVerificationResult(
sggNm: ctx.sggNm,
statsYr: ctx.statsYr,
statsCd: ctx.statsCd,
prcsYnList: "0",
rejectCodeList: "0,1,2",
vrfcSttsCdList: "0",
page: 1,
size: 1,
};
@@ -20,6 +20,7 @@ import { nullableTrimmedString } from "@/components/util/input/nullableString";
import { statisticsInputExcretaTreatmentFacilityApi } from "@/entities/statistics/api/input/statistics-input-excreta-treatment-facility-service";
import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import { callImrServiceWithRollbackOnFailure } from "@/components/util/input/call-imr-with-rollback";
import {
EXCRETA_TREATMENT_FACILITY_STATS_CD,
type ExcretaTreatmentFacilityItem,
@@ -75,7 +76,11 @@ import { toGridRows } from "@/components/util/grid/gridFunction";
import { formatCompactYmdForGrid, toCompactYmdOrNull } from "@/components/util/grid/ymdFormat";
import { toExcelExportSearchContextFromSearchForm } from "@/components/util/gridExcelExport";
import { isVerificationFailedFromCommonInfo } from "@/components/util/input/verification-failed-flag";
import type { VerificationResultLikeMap } from "@/components/util/input/verification-result-display";
import {
formatVerificationTooltip,
normalizeVerificationResultMap,
type VerificationResultLikeMap,
} from "@/components/util/input/verification-result-display";
import { useAdminStore } from "@/store/admin-store";
import { useTabStore } from "@/store/tab-store";
@@ -207,7 +212,7 @@ function mapExcretaTreatmentFacilityListToGridRows(
},
])[0] as GridRowWithVerification;
if (item.verificationResult) {
row.verificationResult = item.verificationResult;
row.verificationResult = normalizeVerificationResultMap(item.verificationResult);
}
return row;
});
@@ -502,7 +507,9 @@ const ExcretaTreatmentFacilityInp = () => {
setStatusInfo(nextStatusInfo);
setStatsCd(commonInfo.statsCd);
setIsVerificationFailed(isVerificationFailedFromCommonInfo(commonInfo));
setFooterVerificationResult(commonInfo.verificationResult ?? null);
setFooterVerificationResult(
normalizeVerificationResultMap(commonInfo.verificationResult) ?? null,
);
if (overrides?.mode !== "PRE") {
setIsNotApplicable(gridRows.length === 0);
} else if (gridRows.length > 0) {
@@ -784,7 +791,6 @@ const ExcretaTreatmentFacilityInp = () => {
lclgvIdfrTelnoCn: submitInfo.lclgvIdfrTelnoCn,
lclgvIdfrDeptNm: submitInfo.lclgvIdfrDeptNm,
lclgvIdfrJbgdNm: submitInfo.lclgvIdfrJbgdNm,
...(isAdminInfo ? { superAdmin: "superAdmin" } : {}),
noApplicable,
submitList,
deleteList: deleteListForSubmit,
@@ -814,7 +820,13 @@ const ExcretaTreatmentFacilityInp = () => {
};
try {
const imrResponse = await statisticsApi.callImrService(imrParam);
const imrResponse = await callImrServiceWithRollbackOnFailure(imrParam, {
statsYr: lastSearchedValues.year ?? "",
statsCd: statsCdForSubmit,
sewerBzmnCd: lastSearchedValues?.bsnmCd ?? "",
rollbackStatusCode: statusInfo?.lclgvSbmsnSttsCd ?? "",
mdfrUuid: userInfo.userUnqId,
});
if (imrResponse.job_id) {
await waitForImrJobCompleted(
imrResponse.job_id,
@@ -953,19 +965,7 @@ const ExcretaTreatmentFacilityInp = () => {
if (!verification) return;
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
return errors
.map((e) => {
const prefix =
e.vrfcCd === "IMR"
? "시계열 검증 - "
: e.vrfcCd === "NV"
? "일반검증 - "
: "모집단 검증 -";
return `${prefix}${e.rsnExpln}
(검증 기준값: ${e.cmprCn} / 현재 입력값: ${e.nowCn})`;
})
.filter(Boolean)
.join("\n");
return formatVerificationTooltip(errors);
}
};
@@ -9,6 +9,7 @@ import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { statisticsInputPrivateSewageFacilityApi } from "@/entities/statistics/api/input/statistics-input-private-sewage-facility-service";
import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import { callImrServiceWithRollbackOnFailure } from "@/components/util/input/call-imr-with-rollback";
import type {
PrivateSewageDesigStatusItem,
PrivateSewageDesigStatusSaveItem,
@@ -68,7 +69,11 @@ import {
patchFooterVerificationByTab,
patchVerificationFailedByTab,
} from "@/components/util/input/verification-failed-flag";
import type { VerificationResultLikeMap } from "@/components/util/input/verification-result-display";
import {
formatVerificationTooltip,
normalizeVerificationResultMap,
type VerificationResultLikeMap,
} from "@/components/util/input/verification-result-display";
import { nullableTrimmedString } from "@/components/util/input/nullableString";
import { useAdminStore } from "@/store/admin-store";
import { useTabStore } from "@/store/tab-store";
@@ -175,7 +180,9 @@ function mapRegionalListToGridRows<
return filterBySelectedAdmdstCd(list, sidoListRef.current, values).map((item) => {
const { verificationResult, ...rest } = item;
const row = toGridRows([{ ctpvNm, ...rest }])[0] as GridRowWithVerification;
if (verificationResult) row.verificationResult = verificationResult;
if (verificationResult) {
row.verificationResult = normalizeVerificationResultMap(verificationResult);
}
return row;
});
}
@@ -797,7 +804,6 @@ const PrivateSewageFacilityInp = () => {
const activeTabStatus = tabStatusInfoMap.get(tabKey) ?? null;
const statsCdForTab = tabStatsCdMap.get(tabKey) ?? tabKey;
const adminOpt = isAdminInfo ? { superAdmin: "superAdmin" as const } : {};
const submitInfoBase = {
statsYr: lastSearchedValues.year ?? "",
@@ -842,7 +848,6 @@ const PrivateSewageFacilityInp = () => {
...submitInfoBase,
userUuid: userInfo.userUnqId,
submitList: buildSewageTreatFactStatusSaveList(currentData),
...adminOpt,
};
response =
await statisticsInputPrivateSewageFacilityApi.submitSewageTreatFactStatus(
@@ -853,7 +858,6 @@ const PrivateSewageFacilityInp = () => {
...submitInfoBase,
userUuid: userInfo.userUnqId,
submitList: buildSepticTankStatusSaveList(currentData),
...adminOpt,
};
response =
await statisticsInputPrivateSewageFacilityApi.submitSepticTankStatus(param);
@@ -862,7 +866,6 @@ const PrivateSewageFacilityInp = () => {
...submitInfoBase,
userUuid: userInfo.userUnqId,
submitList: buildPrivateSewageDesigStatusSaveList(currentData),
...adminOpt,
};
response =
await statisticsInputPrivateSewageFacilityApi.submitPrivateSewageDesigStatus(
@@ -892,7 +895,13 @@ const PrivateSewageFacilityInp = () => {
};
try {
const imrResponse = await statisticsApi.callImrService(imrParam);
const imrResponse = await callImrServiceWithRollbackOnFailure(imrParam, {
statsYr: lastSearchedValues.year ?? "",
statsCd: statsCdForTab,
sewerBzmnCd: lastSearchedValues?.bsnmCd ?? "",
rollbackStatusCode: activeTabStatus?.lclgvSbmsnSttsCd ?? "",
mdfrUuid: userInfo.userUnqId,
});
if (imrResponse.job_id) {
await waitForImrJobCompleted(
imrResponse.job_id,
@@ -1148,18 +1157,7 @@ const PrivateSewageFacilityInp = () => {
if (!verification) return;
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
return errors
.map((e) => {
const prefix =
e.vrfcCd === "IMR"
? "시계열 검증 - "
: e.vrfcCd === "NV"
? "일반검증 - "
: "모집단 검증 - ";
return `${prefix}${e.rsnExpln}(검증 기준값: ${e.cmprCn} / 현재 입력값: ${e.nowCn})`;
})
.filter(Boolean)
.join("\n");
return formatVerificationTooltip(errors);
}
};
@@ -8,6 +8,7 @@ import { useStatisticsInputPendingNavigation } from '@/shared/lib/use-statistics
import { statisticsInputSewSupplyRateApi } from '@/entities/statistics/api/input/statistics-input-sew-supply-rate-service';
import { statisticsInputPublicSewageFacilityApiService } from '@/entities/statistics/api/input/statistics-public-sewage-facility-input-service';
import { statisticsApi } from '@/entities/statistics/api/statistics-service';
import { callImrServiceWithRollbackOnFailure } from '@/components/util/input/call-imr-with-rollback';
import type {
PublicSewageFacilityRequestParams,
PublicSewageFacilitySaveItem,
@@ -52,7 +53,11 @@ import { fetchColumnGuidesRecord } from '@/components/util/grid/columnGuides';
import { toGridRows } from '@/components/util/grid/gridFunction';
import { toExcelExportSearchContextFromSearchForm } from "@/components/util/gridExcelExport";
import { patchFooterVerificationByTab,patchVerificationFailedByTab } from '@/components/util/input/verification-failed-flag';
import type { VerificationResultLikeMap } from '@/components/util/input/verification-result-display';
import {
formatVerificationTooltip,
withNormalizedVerificationResult,
type VerificationResultLikeMap,
} from '@/components/util/input/verification-result-display';
import { nullableTrimmedString } from '@/components/util/input/nullableString';
import { useAdminStore } from '@/store/admin-store';
import { useTabStore } from '@/store/tab-store';
@@ -438,7 +443,13 @@ const PublicSewageFacilityInp = () => {
};
try {
const imrResponse = await statisticsApi.callImrService(imrParam);
const imrResponse = await callImrServiceWithRollbackOnFailure(imrParam, {
statsYr: lastSearchedValues.year ?? '',
statsCd: statsCdForTab,
sewerBzmnCd: lastSearchedValues?.bsnmCd ?? '',
rollbackStatusCode: activeTabStatus?.lclgvSbmsnSttsCd ?? '',
mdfrUuid: userInfo.userUnqId,
});
if (imrResponse.job_id) {
await waitForImrJobCompleted(
imrResponse.job_id,
@@ -789,11 +800,13 @@ const PublicSewageFacilityInp = () => {
sidoListRef.current,
searchContext,
);
const convertedData = list.map((item) => ({
ctpvNm: commonInfo.ctpvNm,
...item,
rmrk: item.rmrk ?? null,
}));
const convertedData = list.map((item) =>
withNormalizedVerificationResult({
ctpvNm: commonInfo.ctpvNm,
...item,
rmrk: item.rmrk ?? null,
}),
);
if (list.length === 0) {
setShowIsNotApplicable(true);
@@ -1575,17 +1588,7 @@ const PublicSewageFacilityInp = () => {
if (!verification) return;
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
return errors
.map((e) => {
const prefix =
e.vrfcCd === "IMR" ? "시계열 검증 - " : (e.vrfcCd === "NV" ? "일반검증 - " : "모집단 검증 - ");
const comp = e.cmprCn ?? "";
const now = e.nowCn ?? "";
return `${prefix}${e.rsnExpln}
(검증 기준값: ${comp} / 현재 입력값: ${now})`;
})
.filter(Boolean)
.join("\n");
return formatVerificationTooltip(errors);
}
}}
/>
@@ -20,6 +20,7 @@ import { nullableTrimmedString } from "@/components/util/input/nullableString";
import { statisticsInputPumpingStationApi } from "@/entities/statistics/api/input/statistics-input-pumping-station-service";
import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import { callImrServiceWithRollbackOnFailure } from "@/components/util/input/call-imr-with-rollback";
import {
PUMPING_STATION_STATS_CD,
type PumpingStationItem,
@@ -75,7 +76,11 @@ import { toGridRows } from "@/components/util/grid/gridFunction";
import { formatCompactYmdForGrid, toCompactYmdOrNull } from "@/components/util/grid/ymdFormat";
import { toExcelExportSearchContextFromSearchForm } from "@/components/util/gridExcelExport";
import { isVerificationFailedFromCommonInfo } from "@/components/util/input/verification-failed-flag";
import type { VerificationResultLikeMap } from "@/components/util/input/verification-result-display";
import {
formatVerificationTooltip,
normalizeVerificationResultMap,
type VerificationResultLikeMap,
} from "@/components/util/input/verification-result-display";
import { useAdminStore } from "@/store/admin-store";
import { useTabStore } from "@/store/tab-store";
@@ -206,7 +211,7 @@ function mapPumpingStationListToGridRows(
},
])[0] as GridRowWithVerification;
if (item.verificationResult) {
row.verificationResult = item.verificationResult;
row.verificationResult = normalizeVerificationResultMap(item.verificationResult);
}
return row;
});
@@ -493,7 +498,9 @@ const PumpingStationInp = () => {
setStatusInfo(nextStatusInfo);
setStatsCd(commonInfo.statsCd);
setIsVerificationFailed(isVerificationFailedFromCommonInfo(commonInfo));
setFooterVerificationResult(commonInfo.verificationResult ?? null);
setFooterVerificationResult(
normalizeVerificationResultMap(commonInfo.verificationResult) ?? null,
);
if (overrides?.mode !== "PRE") {
setIsNotApplicable(gridRows.length === 0);
} else if (gridRows.length > 0) {
@@ -768,7 +775,6 @@ const PumpingStationInp = () => {
lclgvIdfrTelnoCn: submitInfo.lclgvIdfrTelnoCn,
lclgvIdfrDeptNm: submitInfo.lclgvIdfrDeptNm,
lclgvIdfrJbgdNm: submitInfo.lclgvIdfrJbgdNm,
...(isAdminInfo ? { superAdmin: "superAdmin" } : {}),
noApplicable,
submitList,
deleteList,
@@ -797,7 +803,13 @@ const PumpingStationInp = () => {
};
try {
const imrResponse = await statisticsApi.callImrService(imrParam);
const imrResponse = await callImrServiceWithRollbackOnFailure(imrParam, {
statsYr: lastSearchedValues.year ?? "",
statsCd: statsCdForSubmit,
sewerBzmnCd: lastSearchedValues?.bsnmCd ?? "",
rollbackStatusCode: statusInfo?.lclgvSbmsnSttsCd ?? "",
mdfrUuid: userInfo.userUnqId,
});
if (imrResponse.job_id) {
await waitForImrJobCompleted(
imrResponse.job_id,
@@ -936,19 +948,7 @@ const PumpingStationInp = () => {
if (!verification) return;
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
return errors
.map((e) => {
const prefix =
e.vrfcCd === "IMR"
? "시계열 검증 - "
: e.vrfcCd === "NV"
? "일반검증 - "
: "모집단 검증 -";
return `${prefix}${e.rsnExpln}
(검증 기준값: ${e.cmprCn} / 현재 입력값: ${e.nowCn})`;
})
.filter(Boolean)
.join("\n");
return formatVerificationTooltip(errors);
}
};
@@ -13,6 +13,7 @@ import { nullableTrimmedString } from "@/components/util/input/nullableString";
import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service";
import { statisticsInputSewageExcretaRelatedApi } from "@/entities/statistics/api/input/statistics-input-sewage-excreta-related-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import { callImrServiceWithRollbackOnFailure } from "@/components/util/input/call-imr-with-rollback";
import type {
ExcretaRelatedIndustrySaveItem,
PrivateSewageTreatRelatedIndustrySaveItem,
@@ -84,7 +85,11 @@ import {
patchFooterVerificationByTab,
patchVerificationFailedByTab,
} from "@/components/util/input/verification-failed-flag";
import type { VerificationResultLikeMap } from "@/components/util/input/verification-result-display";
import {
formatVerificationTooltip,
normalizeVerificationResultMap,
type VerificationResultLikeMap,
} from "@/components/util/input/verification-result-display";
import { useAdminStore } from "@/store/admin-store";
import { useTabStore } from "@/store/tab-store";
@@ -552,19 +557,7 @@ function verificationCellTooltip({
if (!verification) return;
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
return errors
.map((e) => {
const prefix =
e.vrfcCd === "IMR"
? "시계열 검증 - "
: e.vrfcCd === "NV"
? "일반검증 - "
: "모집단 검증 - ";
return `${prefix}${e.rsnExpln}
(검증 기준값: ${e.cmprCn} / 현재 입력값: ${e.nowCn})`;
})
.filter(Boolean)
.join("\n");
return formatVerificationTooltip(errors);
}
}
@@ -588,7 +581,7 @@ function mapListToGridRows<T extends { verificationResult?: SewageExcretaRelated
return filterBySelectedAdmdstCd(list, sidoListRef.current, values).map((item) => {
const row = toGridRows([{ ctpvNm, ...item }])[0] as GridRowWithVerification;
if (item.verificationResult) {
row.verificationResult = item.verificationResult;
row.verificationResult = normalizeVerificationResultMap(item.verificationResult);
}
return row;
});
@@ -1410,7 +1403,6 @@ const SewageExcretaRelatedInp = () => {
lclgvIdfrDeptNm: submitInfo.lclgvIdfrDeptNm,
lclgvIdfrJbgdNm: submitInfo.lclgvIdfrJbgdNm,
...(isPublicInstitutionTab ? { noApplicable } : {}),
...(isAdminInfo ? { superAdmin: "superAdmin" as const } : {}),
};
const verificationQuery = {
@@ -1504,7 +1496,13 @@ const SewageExcretaRelatedInp = () => {
};
try {
const imrResponse = await statisticsApi.callImrService(imrParam);
const imrResponse = await callImrServiceWithRollbackOnFailure(imrParam, {
statsYr: lastSearchedValues.year ?? "",
statsCd: statsCdForTab,
sewerBzmnCd: lastSearchedValues?.bsnmCd ?? "",
rollbackStatusCode: activeTabStatus?.lclgvSbmsnSttsCd ?? "",
mdfrUuid: userInfo.userUnqId,
});
if (imrResponse.job_id) {
await waitForImrJobCompleted(
imrResponse.job_id,
@@ -21,6 +21,7 @@ import { nullableTrimmedString } from "@/components/util/input/nullableString";
import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service";
import { statisticsInputSewageExcretaSlurgeApi } from "@/entities/statistics/api/input/statistics-input-sewage-excreta-slurge-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import { callImrServiceWithRollbackOnFailure } from "@/components/util/input/call-imr-with-rollback";
import type {
DigestorOperatingStatusSaveItem,
ExcretaSlurgeProcessingStatusSaveItem,
@@ -90,7 +91,11 @@ import {
patchFooterVerificationByTab,
patchVerificationFailedByTab,
} from "@/components/util/input/verification-failed-flag";
import type { VerificationResultLikeMap } from "@/components/util/input/verification-result-display";
import {
formatVerificationTooltip,
normalizeVerificationResultMap,
type VerificationResultLikeMap,
} from "@/components/util/input/verification-result-display";
import { useAdminStore } from "@/store/admin-store";
import { useTabStore } from "@/store/tab-store";
@@ -148,19 +153,7 @@ function verificationCellTooltip({
if (!verification) return;
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
return errors
.map((e) => {
const prefix =
e.vrfcCd === "IMR"
? "시계열 검증 - "
: e.vrfcCd === "NV"
? "일반검증 - "
: "모집단 검증 - ";
return `${prefix}${e.rsnExpln}
(검증 기준값: ${e.cmprCn} / 현재 입력값: ${e.nowCn})`;
})
.filter(Boolean)
.join("\n");
return formatVerificationTooltip(errors);
}
}
@@ -185,7 +178,7 @@ function mapListToGridRows<T extends { verificationResult?: SewageExcretaSlurgeV
return filterBySelectedAdmdstCd(list, sidoListRef.current, values).map((item) => {
const row = toGridRows([{ ctpvNm, ...item }])[0] as GridRowWithVerification;
if (item.verificationResult) {
row.verificationResult = item.verificationResult;
row.verificationResult = normalizeVerificationResultMap(item.verificationResult);
}
return row;
});
@@ -1357,11 +1350,6 @@ const SewageExcretaSlurgeInp = () => {
: rowsForSubmit.map((row) =>
buildExcretaSlurgeProcessingStatusSaveItem(row, list),
);
const slurgeDeleteList = isSlurgeFacTab
? (deleteListByTab.get(tabKey) ?? [])
.filter((row) => isPersistedArticleRowForTab(tabKey, row))
.map((row) => buildSlurgeProcessingFacStatusSaveItem(row, list))
: [];
const sewageSlurgeFacDeleteList = isSewageSlurgeFacTab
? (deleteListByTab.get(tabKey) ?? [])
.filter((row) => isPersistedArticleRowForTab(tabKey, row))
@@ -1432,7 +1420,6 @@ const SewageExcretaSlurgeInp = () => {
lclgvIdfrTelnoCn: submitInfo.lclgvIdfrTelnoCn,
lclgvIdfrDeptNm: submitInfo.lclgvIdfrDeptNm,
lclgvIdfrJbgdNm: submitInfo.lclgvIdfrJbgdNm,
...(isAdminInfo ? { superAdmin: "superAdmin" as const } : {}),
noApplicable,
};
@@ -1445,7 +1432,6 @@ const SewageExcretaSlurgeInp = () => {
...submitRequestBase,
userUuid: userInfo.userUnqId,
submitList: slurgeSubmitList,
deleteList: slurgeDeleteList,
})
: isDigestorTab
? await statisticsInputSewageExcretaSlurgeApi.submitDigestorOperatingStatus({
@@ -1493,7 +1479,13 @@ const SewageExcretaSlurgeInp = () => {
};
try {
const imrResponse = await statisticsApi.callImrService(imrParam);
const imrResponse = await callImrServiceWithRollbackOnFailure(imrParam, {
statsYr: lastSearchedValues.year ?? "",
statsCd: statsCdForSubmit,
sewerBzmnCd: lastSearchedValues?.bsnmCd ?? "",
rollbackStatusCode: activeTabStatus?.lclgvSbmsnSttsCd ?? "",
mdfrUuid: userInfo.userUnqId,
});
if (imrResponse.job_id) {
await waitForImrJobCompleted(
imrResponse.job_id,
@@ -1655,9 +1647,6 @@ const SewageExcretaSlurgeInp = () => {
: rowsForSave.map((row) =>
buildSlurgeProcessingFacStatusSaveItem(row, list),
);
const deleteList = (deleteListByTab.get(tabKey) ?? [])
.filter((row) => isPersistedArticleRowForTab(tabKey, row))
.map((row) => buildSlurgeProcessingFacStatusSaveItem(row, list));
if (!noApplicable && saveList.length === 0 && buildSubmitOkForToolbar(searchContextByTab.get(tabKey) ?? lastSearchedValues)) {
alert("해당사항 없음을 체크해주세요.");
@@ -1670,7 +1659,6 @@ const SewageExcretaSlurgeInp = () => {
...baseSaveRequest,
...userFields,
saveList,
deleteList,
},
);
} else if (tabKey === DigestorOperatingStatusInpColumn.tabKey) {
@@ -10,6 +10,7 @@ import {
import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service";
import { statisticsInputSewPipeApiService } from "@/entities/statistics/api/input/statistics-sewage-pipe-input-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import { callImrServiceWithRollbackOnFailure } from "@/components/util/input/call-imr-with-rollback";
import type {
SeweragePipeInputFactInstallStatusSaveRequest,
SeweragePipeInputFactInstallStatusSubmitRequest,
@@ -81,7 +82,13 @@ import {
patchVerificationFailedByTab,
} from "@/components/util/input/verification-failed-flag";
import { nullableTrimmedString } from "@/components/util/input/nullableString";
import type { VerificationResultLikeMap } from "@/components/util/input/verification-result-display";
import {
formatVerificationTooltip,
getVerificationErrorsForField,
normalizeVerificationResultMap,
withNormalizedVerificationResult,
type VerificationResultLikeMap,
} from "@/components/util/input/verification-result-display";
import { useAdminStore } from "@/store/admin-store";
import { useStatisticsInputFocusStore } from "@/store/statistics-input-focus-store";
import { useTabStore } from "@/store/tab-store";
@@ -219,18 +226,33 @@ const applyPipeTypeStatusEditableColumns = (
});
};
/** 2-3 관종별: verificationResult.prlgWhol → 총계 계(pipeByPrstWhol) */
const PIPE_TYPE_TOTAL_WHOL_ALIASES = ["pipeWholPrlg", "prlgWhol"] as const;
/** 2-3 관종별: 총계 검증 키(pipeWholPrlg/prlgWhol) → 총계 계(pipeByPrstWhol) */
function remapPipeTypePrlgWholVerification(
result?: SeweragePipeInputVerificationResult | null,
): SeweragePipeInputVerificationResult | null | undefined {
if (!result) return result;
const fromAlias = result.prlgWhol;
if (!Array.isArray(fromAlias) || fromAlias.length === 0) return result;
const existing = result.pipeByPrstWhol;
if (Array.isArray(existing) && existing.length > 0) return result;
const fromAlias = result.pipeWholPrlg ?? result.prlgWhol;
if (!Array.isArray(fromAlias) || fromAlias.length === 0) return result;
return { ...result, pipeByPrstWhol: fromAlias };
}
function getSewagePipeRowVerificationErrors(
verification: SeweragePipeInputVerificationResult | undefined,
fieldName: string,
tabKey: string,
) {
if (!verification) return undefined;
const aliases =
tabKey === PIPE_TYPE_STATUS_TAB_KEY && fieldName === "pipeByPrstWhol"
? PIPE_TYPE_TOTAL_WHOL_ALIASES
: undefined;
return getVerificationErrorsForField(verification, fieldName, aliases);
}
/** 관종별 신 응답(yearlyStatList) -> 그리드 y{year}* 필드로 정규화 */
function normalizePipeTypeStatusRow(
row: SeweragePipeInputPipeTypeStatusItem,
@@ -271,7 +293,10 @@ function normalizePipeTypeStatusRow(
lyngPrlg: Number(lyngPrlgWhol ?? 0),
dmltnPrlg: Number(dmltnPrlgWhol ?? 0),
...yearFields,
verificationResult: remapPipeTypePrlgWholVerification(verificationResult) ?? undefined,
verificationResult:
normalizeVerificationResultMap(
remapPipeTypePrlgWholVerification(verificationResult),
) ?? undefined,
};
}
@@ -332,7 +357,6 @@ function buildRegionalFactSubmitListFromGrid(
};
return currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm),
goalYr: nullableTrimmedString(d.goalYr),
wholPplMtncPlanPrlg: formatNumber(d.wholPplMtncPlanPrlg, "wholPplMtncPlanPrlg"),
@@ -702,10 +726,12 @@ const SewagePipeInp = () => {
const response = await statisticsInputSewPipeApiService.getRegionalFactStatus(searchParam);
if (response.status === 'success') {
const commonInfo = response.data.commonInfo;
const convertedData = filterFetched(response.data.regionalFactStatusList).map((d) => ({
ctpvNm: commonInfo.ctpvNm,
...d,
}));
const convertedData = filterFetched(response.data.regionalFactStatusList).map((d) =>
withNormalizedVerificationResult({
ctpvNm: commonInfo.ctpvNm,
...d,
}),
);
applyRowMap((m) => m.set('ST00020001', toGridRows(convertedData)));
setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, response.data.statusInfo));
setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd));
@@ -732,10 +758,12 @@ const SewagePipeInp = () => {
const response = await statisticsInputSewPipeApiService.getFactInstallStatus(searchParam);
if (response.status === 'success') {
const commonInfo = response.data.commonInfo;
const convertedData = filterFetched(response.data.factInstallStatusList).map((d) => ({
ctpvNm: commonInfo.ctpvNm,
...d,
}));
const convertedData = filterFetched(response.data.factInstallStatusList).map((d) =>
withNormalizedVerificationResult({
ctpvNm: commonInfo.ctpvNm,
...d,
}),
);
if (convertedData.length === 0) {
const alreadyHandledForSameSearch =
factInstallNoDataHandledKeyRef.current === searchConditionKey;
@@ -812,10 +840,12 @@ const SewagePipeInp = () => {
const response = await statisticsInputSewPipeApiService.getPipeDiameterStatus(searchParam);
if (response.status === 'success') {
const commonInfo = response.data.commonInfo;
const convertedData = filterFetched(response.data.pipeDiameterStatusList).map((d) => ({
ctpvNm: commonInfo.ctpvNm,
...d,
}));
const convertedData = filterFetched(response.data.pipeDiameterStatusList).map((d) =>
withNormalizedVerificationResult({
ctpvNm: commonInfo.ctpvNm,
...d,
}),
);
applyRowMap((m) => m.set('ST00020004', toGridRows(convertedData)));
setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, response.data.statusInfo));
setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd));
@@ -842,10 +872,12 @@ const SewagePipeInp = () => {
const response = await statisticsInputSewPipeApiService.getManholeStatus(searchParam);
if (response.status === 'success') {
const commonInfo = response.data.commonInfo;
const convertedData = filterFetched(response.data.manholeStatusList).map((d) => ({
ctpvNm: commonInfo.ctpvNm,
...d,
}));
const convertedData = filterFetched(response.data.manholeStatusList).map((d) =>
withNormalizedVerificationResult({
ctpvNm: commonInfo.ctpvNm,
...d,
}),
);
applyRowMap((m) => m.set('ST00020005', toGridRows(convertedData)));
setTabStatusInfoMap((prev) => new Map(prev).set(tabKey, response.data.statusInfo));
setTabStatsCdMap((prev) => new Map(prev).set(tabKey, commonInfo.statsCd));
@@ -872,13 +904,15 @@ const SewagePipeInp = () => {
const response = await statisticsInputSewPipeApiService.getConnectionStatus(searchParam);
if (response.status === 'success') {
const commonInfo = response.data.commonInfo;
const facilityConvertedData = filterFetched(response.data.facilityConnectionList).map((d) => ({
...d,
}));
const adminConvertedData = filterFetched(response.data.adminAreaConnectionList).map((d) => ({
...d,
ctpvNm: d.ctpvNm || commonInfo.ctpvNm,
}));
const facilityConvertedData = filterFetched(response.data.facilityConnectionList).map((d) =>
withNormalizedVerificationResult({ ...d }),
);
const adminConvertedData = filterFetched(response.data.adminAreaConnectionList).map((d) =>
withNormalizedVerificationResult({
...d,
ctpvNm: d.ctpvNm || commonInfo.ctpvNm,
}),
);
applyRowMap((m) => {
m.set('treatmentPlantConnectionInp', toGridRows(facilityConvertedData));
m.set('adminAreaConnectionInp', toGridRows(adminConvertedData));
@@ -1246,7 +1280,6 @@ const SewagePipeInp = () => {
};
const submitList = currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm),
fcltSn: nullableTrimmedString(d.fcltSn),
swtrmtFcltCd: nullableTrimmedString(d.swtrmtFcltCd),
@@ -1322,7 +1355,6 @@ const SewagePipeInp = () => {
rmrk: nullableTrimmedString(d.rmrk),
lyngPrlgWhol: formatNumber(d.lyngPrlg, "lyngPrlg"),
dmltnPrlgWhol: formatNumber(d.dmltnPrlg, "dmltnPrlg"),
pipeByPrstWhol: formatNumber(d.pipeByPrstWhol, "pipeByPrstWhol"),
};
});
const requestData: SeweragePipeInputPipeTypeStatusSubmitRequest = {
@@ -1439,7 +1471,13 @@ const SewagePipeInp = () => {
};
try {
const imrResponse = await statisticsApi.callImrService(imrParam);
const imrResponse = await callImrServiceWithRollbackOnFailure(imrParam, {
statsYr: values.year ?? "",
statsCd: statsCdForTab,
sewerBzmnCd: values?.bsnmCd ?? "",
rollbackStatusCode: statusForTab?.lclgvSbmsnSttsCd ?? "",
mdfrUuid: userInfo.userUnqId,
});
if (imrResponse.job_id) {
await waitForImrJobCompleted(
imrResponse.job_id,
@@ -1619,7 +1657,6 @@ const SewagePipeInp = () => {
const saveList = currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm),
goalYr: nullableTrimmedString(d.goalYr),
wholPplMtncPlanPrlg: formatNumber(d.wholPplMtncPlanPrlg, "wholPplMtncPlanPrlg"),
@@ -1710,7 +1747,6 @@ const SewagePipeInp = () => {
const saveList = currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm),
fcltSn: nullableTrimmedString(d.fcltSn),
swtrmtFcltCd: nullableTrimmedString(d.swtrmtFcltCd),
@@ -1816,7 +1852,6 @@ const SewagePipeInp = () => {
rmrk: nullableTrimmedString(d.rmrk),
lyngPrlgWhol: formatNumber(d.lyngPrlg, "lyngPrlg"),
dmltnPrlgWhol: formatNumber(d.dmltnPrlg, "dmltnPrlg"),
pipeByPrstWhol: formatNumber(d.pipeByPrstWhol, "pipeByPrstWhol"),
};
});
@@ -2202,8 +2237,11 @@ const SewagePipeInp = () => {
}
cellStyleCallback={({ row, fieldName }) => {
const verification = (row as GridRowWithVerification).verificationResult;
if (!verification) return;
const errors = verification[fieldName];
const errors = getSewagePipeRowVerificationErrors(
verification,
fieldName,
tabKey,
);
if (Array.isArray(errors) && errors.length > 0) {
const hasImr = errors.some((e) => e.vrfcCd === "IMR" || e.vrfcCd === "HB");
return hasImr ? "rg-cell-verify-error-imr" : "rg-cell-verify-error";
@@ -2214,20 +2252,13 @@ const SewagePipeInp = () => {
}
cellTooltipCallback={({ row, fieldName }) => {
const verification = (row as GridRowWithVerification).verificationResult;
if (!verification) return;
const errors = verification[fieldName];
const errors = getSewagePipeRowVerificationErrors(
verification,
fieldName,
tabKey,
);
if (Array.isArray(errors) && errors.length > 0) {
return errors
.map((e) => {
const prefix =
e.vrfcCd === "IMR" ? "시계열 검증 - " : (e.vrfcCd === "NV" ? "일반검증 - " : "모집단 검증 - ");
const comp = e.cmprCn ?? "";
const now = e.nowCn ?? "";
return `${prefix}${e.rsnExpln}
(검증 기준값: ${comp} / 현재 입력값: ${now})`;
})
.filter(Boolean)
.join("\n");
return formatVerificationTooltip(errors);
}
}}
/>
@@ -11,6 +11,7 @@ import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service";
import { sewagePipeMaintainApiService } from "@/entities/statistics/api/input/statistics-input-sewage-pipe-maintain-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import { callImrServiceWithRollbackOnFailure } from "@/components/util/input/call-imr-with-rollback";
import type {
SewagePipeMaintainDredgingSaveItem,
SewagePipeMaintainDredgingSaveRequest,
@@ -69,7 +70,11 @@ import {
patchFooterVerificationByTab,
patchVerificationFailedByTab,
} from "@/components/util/input/verification-failed-flag";
import type { VerificationResultLikeMap } from "@/components/util/input/verification-result-display";
import {
formatVerificationTooltip,
withNormalizedVerificationResult,
type VerificationResultLikeMap,
} from "@/components/util/input/verification-result-display";
import { nullableTrimmedString } from "@/components/util/input/nullableString";
import { useAdminStore } from "@/store/admin-store";
import { useStatisticsInputFocusStore } from "@/store/statistics-input-focus-store";
@@ -218,7 +223,6 @@ function buildRepairRowList(
return currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm),
dfrpWholPrlg: formatNumber(d.dfrpWholPrlg, "dfrpWholPrlg"),
cbsysDfrpPrlg: formatNumber(d.cbsysDfrpPrlg, "cbsysDfrpPrlg"),
@@ -247,7 +251,6 @@ function buildDredgingRowList(
return currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm),
wholFcltPrlg: formatNumber(d.wholFcltPrlg, "wholFcltPrlg"),
wholSwagePpl: formatNumber(d.wholSwagePpl, "wholSwagePpl"),
@@ -446,10 +449,12 @@ const SewagePipeMaintainInp = () => {
repairStatusList ?? [],
sidoListRef.current,
searchContext,
).map((d) => ({
ctpvNm: commonInfo.ctpvNm,
...d,
}));
).map((d) =>
withNormalizedVerificationResult({
ctpvNm: commonInfo.ctpvNm,
...d,
}),
);
const gridRows = toGridRows(convertedData);
applyRowMap(mergeIntoExistingMap, (m) => {
@@ -489,10 +494,12 @@ const SewagePipeMaintainInp = () => {
pipeDredgingStatusList ?? [],
sidoListRef.current,
searchContext,
).map((d) => ({
ctpvNm: commonInfo.ctpvNm,
...d,
}));
).map((d) =>
withNormalizedVerificationResult({
ctpvNm: commonInfo.ctpvNm,
...d,
}),
);
const gridRows = toGridRows(convertedData);
applyRowMap(mergeIntoExistingMap, (m) => {
@@ -680,7 +687,13 @@ const SewagePipeMaintainInp = () => {
};
try {
const imrResponse = await statisticsApi.callImrService(imrParam);
const imrResponse = await callImrServiceWithRollbackOnFailure(imrParam, {
statsYr: lastSearchedValues.year ?? "",
statsCd: statsCdForTab,
sewerBzmnCd: lastSearchedValues?.bsnmCd ?? "",
rollbackStatusCode: activeTabStatus?.lclgvSbmsnSttsCd ?? "",
mdfrUuid: userInfo.userUnqId,
});
if (imrResponse.job_id) {
await waitForImrJobCompleted(
imrResponse.job_id,
@@ -1105,14 +1118,7 @@ const SewagePipeMaintainInp = () => {
if (!verification) return;
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
return errors
.map((e) => {
const prefix =
e.vrfcCd === "IMR" ? "시계열 검증 - " : e.vrfcCd === "NV" ? "일반검증 - " : "모집단 검증 - ";
return `${prefix}${e.rsnExpln}(검증 기준값: ${e.cmprCn} / 현재 입력값: ${e.nowCn})`;
})
.filter(Boolean)
.join("\n");
return formatVerificationTooltip(errors);
}
};
@@ -21,6 +21,7 @@ import { nullableTrimmedString } from "@/components/util/input/nullableString";
import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service";
import { statisticsInputSewageReservoirPumpApi } from "@/entities/statistics/api/input/statistics-input-sewage-reservoir-pump-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import { callImrServiceWithRollbackOnFailure } from "@/components/util/input/call-imr-with-rollback";
import {
type RainfallPumpStationItem,
type RainfallPumpStationSaveItem,
@@ -89,7 +90,11 @@ import {
patchFooterVerificationByTab,
patchVerificationFailedByTab,
} from "@/components/util/input/verification-failed-flag";
import type { VerificationResultLikeMap } from "@/components/util/input/verification-result-display";
import {
formatVerificationTooltip,
normalizeVerificationResultMap,
type VerificationResultLikeMap,
} from "@/components/util/input/verification-result-display";
import { useAdminStore } from "@/store/admin-store";
import { useTabStore } from "@/store/tab-store";
@@ -279,7 +284,7 @@ function mapSewageReservoirListToGridRows(
},
])[0] as GridRowWithVerification;
if (item.verificationResult) {
row.verificationResult = item.verificationResult;
row.verificationResult = normalizeVerificationResultMap(item.verificationResult);
}
return row;
});
@@ -435,7 +440,7 @@ function mapRainfallPumpStationListToGridRows(
])[0] as GridRowWithVerification;
if (item.verificationResult) {
row.verificationResult = item.verificationResult;
row.verificationResult = normalizeVerificationResultMap(item.verificationResult);
}
return row;
});
@@ -864,7 +869,13 @@ const SewageReservoirPumpInp = () => {
};
try {
const imrResponse = await statisticsApi.callImrService(imrParam);
const imrResponse = await callImrServiceWithRollbackOnFailure(imrParam, {
statsYr: lastSearchedValues.year ?? "",
statsCd: statsCdForTab,
sewerBzmnCd: lastSearchedValues?.bsnmCd ?? "",
rollbackStatusCode: activeTabStatus?.lclgvSbmsnSttsCd ?? "",
mdfrUuid: userInfo.userUnqId,
});
if (imrResponse.job_id) {
await waitForImrJobCompleted(
imrResponse.job_id,
@@ -1516,19 +1527,7 @@ const SewageReservoirPumpInp = () => {
if (!verification) return;
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
return errors
.map((e) => {
const prefix =
e.vrfcCd === "IMR"
? "시계열 검증 - "
: e.vrfcCd === "NV"
? "일반검증 - "
: "모집단 검증 -";
return `${prefix}${e.rsnExpln}
(검증 기준값: ${e.cmprCn} / 현재 입력값: ${e.nowCn})`;
})
.filter(Boolean)
.join("\n");
return formatVerificationTooltip(errors);
}
};
@@ -8,6 +8,7 @@ import { useStatisticsInputPendingNavigation } from "@/shared/lib/use-statistics
import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service";
import { statisticsInputSewerageFinanceApi } from "@/entities/statistics/api/input/statistics-input-sewerage-finance-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import { callImrServiceWithRollbackOnFailure } from "@/components/util/input/call-imr-with-rollback";
import type {
CauserBurdenItem,
CauserBurdenSaveItem,
@@ -73,7 +74,11 @@ import {
patchFooterVerificationByTab,
patchVerificationFailedByTab,
} from "@/components/util/input/verification-failed-flag";
import type { VerificationResultLikeMap } from "@/components/util/input/verification-result-display";
import {
formatVerificationTooltip,
normalizeVerificationResultMap,
type VerificationResultLikeMap,
} from "@/components/util/input/verification-result-display";
import { nullableTrimmedString } from "@/components/util/input/nullableString";
import { useAdminStore } from "@/store/admin-store";
import { useTabStore } from "@/store/tab-store";
@@ -174,7 +179,9 @@ function mapRegionalListToGridRows<
return filterBySelectedAdmdstCd(list, sidoListRef.current, values).map((item) => {
const { verificationResult, ...rest } = item;
const row = toGridRows([{ ctpvNm, ...rest }])[0] as GridRowWithVerification;
if (verificationResult) row.verificationResult = verificationResult;
if (verificationResult) {
row.verificationResult = normalizeVerificationResultMap(verificationResult);
}
return row;
});
}
@@ -215,7 +222,6 @@ function buildSewerageFinanceSettleSaveList(
return currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm),
soaGovSprtAmt: formatNumber(d.soaGovSprtAmt, "soaGovSprtAmt"),
mctpvLcexpAmt: formatNumber(d.mctpvLcexpAmt, "mctpvLcexpAmt"),
@@ -257,7 +263,6 @@ function buildSewerageChargeSaveList(
return currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm),
anlLevyQnt: formatNumber(d.anlLevyQnt, "anlLevyQnt"),
sewerLevyAmt: formatNumber(d.sewerLevyAmt, "sewerLevyAmt"),
@@ -276,7 +281,6 @@ function buildSewerageUsageFeeSaveList(
return currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm),
hshusLevyAmt: formatNumber(d.hshusLevyAmt, "hshusLevyAmt"),
hshusLevyQnt: formatNumber(d.hshusLevyQnt, "hshusLevyQnt"),
@@ -312,7 +316,6 @@ function buildCauserBurdenSaveList(
return currentData.map((d) => ({
admdstCd: nullableTrimmedString(d.admdstCd),
sggNm: nullableTrimmedString(d.sggNm),
admdstNm: nullableTrimmedString(d.admdstNm),
brdnAmtLevyNocs: formatNumber(d.brdnAmtLevyNocs, "brdnAmtLevyNocs"),
brdnAmtLevyAmt: formatNumber(d.brdnAmtLevyAmt, "brdnAmtLevyAmt"),
@@ -849,7 +852,6 @@ const SewerageFinanceInp = () => {
const activeTabStatus = tabStatusInfoMap.get(tabKey) ?? null;
const statsCdForTab = tabStatsCdMap.get(tabKey) ?? tabKey;
const adminOpt = isAdminInfo ? { superAdmin: "superAdmin" as const } : {};
const submitInfoBase = {
statsYr: lastSearchedValues.year ?? "",
@@ -894,7 +896,6 @@ const SewerageFinanceInp = () => {
...submitInfoBase,
userUuid: userInfo.userUnqId,
submitList: buildSewerageFinanceSettleSaveList(currentData),
...adminOpt,
};
response =
await statisticsInputSewerageFinanceApi.submitSewerageFinanceSettle(
@@ -905,7 +906,6 @@ const SewerageFinanceInp = () => {
...submitInfoBase,
userUuid: userInfo.userUnqId,
submitList: buildSewerageChargeSaveList(currentData),
...adminOpt,
};
response = await statisticsInputSewerageFinanceApi.submitSewerageCharge(param);
} else if (isUsageFeeTab) {
@@ -913,7 +913,6 @@ const SewerageFinanceInp = () => {
...submitInfoBase,
userUuid: userInfo.userUnqId,
submitList: buildSewerageUsageFeeSaveList(currentData),
...adminOpt,
};
response =
await statisticsInputSewerageFinanceApi.submitSewerageUsageFee(param);
@@ -922,7 +921,6 @@ const SewerageFinanceInp = () => {
...submitInfoBase,
userUuid: userInfo.userUnqId,
submitList: buildCauserBurdenSaveList(currentData),
...adminOpt,
};
response = await statisticsInputSewerageFinanceApi.submitCauserBurden(param);
}
@@ -949,7 +947,13 @@ const SewerageFinanceInp = () => {
};
try {
const imrResponse = await statisticsApi.callImrService(imrParam);
const imrResponse = await callImrServiceWithRollbackOnFailure(imrParam, {
statsYr: lastSearchedValues.year ?? "",
statsCd: statsCdForTab,
sewerBzmnCd: lastSearchedValues?.bsnmCd ?? "",
rollbackStatusCode: activeTabStatus?.lclgvSbmsnSttsCd ?? "",
mdfrUuid: userInfo.userUnqId,
});
if (imrResponse.job_id) {
await waitForImrJobCompleted(
imrResponse.job_id,
@@ -1224,18 +1228,7 @@ const SewerageFinanceInp = () => {
if (!verification) return;
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
return errors
.map((e) => {
const prefix =
e.vrfcCd === "IMR"
? "시계열 검증 - "
: e.vrfcCd === "NV"
? "일반검증 - "
: "모집단 검증 - ";
return `${prefix}${e.rsnExpln}(검증 기준값: ${e.cmprCn} / 현재 입력값: ${e.nowCn})`;
})
.filter(Boolean)
.join("\n");
return formatVerificationTooltip(errors);
}
};
@@ -10,6 +10,7 @@ import {
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import { callImrServiceWithRollbackOnFailure } from "@/components/util/input/call-imr-with-rollback";
import {
type CheckInputPeriodItem,
getVerificationPeriodActionBlockedMessage,
@@ -58,7 +59,11 @@ import { toGridRows } from "@/components/util/grid/gridFunction";
import { toExcelExportSearchContextFromSearchForm } from "@/components/util/gridExcelExport";
import { nullableTrimmedString } from "@/components/util/input/nullableString";
import { isVerificationFailedFromCommonInfo } from "@/components/util/input/verification-failed-flag";
import type { VerificationResultLikeMap } from "@/components/util/input/verification-result-display";
import {
formatVerificationTooltip,
normalizeVerificationResultMap,
type VerificationResultLikeMap,
} from "@/components/util/input/verification-result-display";
import { useAdminStore } from "@/store/admin-store";
import { useStatisticsInputFocusStore } from "@/store/statistics-input-focus-store";
import { useTabStore } from "@/store/tab-store";
@@ -87,7 +92,7 @@ const remapCommonInfoVerificationToGridFields = (
// API 키로도 조회 가능하도록 유지
if (gridField !== key) remapped[key] = errors;
}
return remapped;
return normalizeVerificationResultMap(remapped) ?? remapped;
};
/** 컬럼 가이드 API 조회용 통계코드 (하수도 보급률) */
@@ -302,7 +307,11 @@ const SeweragePenetrationRateInp = () => {
setRowData(
convertedData.map((d) => {
const row = toGridRows([d])[0] as GridRowWithVerification;
if (d.verificationResult) row.verificationResult = d.verificationResult;
if (d.verificationResult) {
row.verificationResult = normalizeVerificationResultMap(
d.verificationResult,
);
}
return row;
})
);
@@ -399,10 +408,6 @@ const SeweragePenetrationRateInp = () => {
statsCd: statsCd,
};
if (isAdminInfo) {
params.superAdmin = 'superAdmin';
}
try {
setIsLoading(true);
@@ -498,7 +503,6 @@ const SeweragePenetrationRateInp = () => {
};
const convertedData: SewerageSupplyRateSaveItem[] = currentData.map((d) => ({
sggNm: nullableTrimmedString(d.sggNm),
admdstCd: nullableTrimmedString(d.admdstCd),
admdstNm: nullableTrimmedString(d.admdstNm),
statsTrbtrNm: nullableTrimmedString(d.statsTrbtrNm),
@@ -563,7 +567,13 @@ const SeweragePenetrationRateInp = () => {
};
try {
const imrResponse = await statisticsApi.callImrService(imrParam);
const imrResponse = await callImrServiceWithRollbackOnFailure(imrParam, {
statsYr: searchValues?.year ?? "",
statsCd,
sewerBzmnCd: searchValues?.bsnmCd ?? "",
rollbackStatusCode: statusInfo?.lclgvSbmsnSttsCd ?? "",
mdfrUuid: userInfo.userUnqId,
});
if (imrResponse.job_id) {
await waitForImrJobCompleted(imrResponse.job_id, (id) =>
statisticsApi.getImrStatus(id),
@@ -677,8 +687,6 @@ const SeweragePenetrationRateInp = () => {
};
const convertedData: SewerageSupplyRateSaveItem[] = currentData.map((d) => ({
// 행정구역
sggNm: nullableTrimmedString(d.sggNm), // 시군구
admdstCd: nullableTrimmedString(d.admdstCd), // 행정코드
admdstNm: nullableTrimmedString(d.admdstNm), // 행정구역명
statsTrbtrNm: nullableTrimmedString(d.statsTrbtrNm), // 지류
@@ -874,14 +882,7 @@ const SeweragePenetrationRateInp = () => {
if (!verification) return;
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
return errors
.map((e) => {
const prefix = e.vrfcCd === 'IMR' ? '시계열 검증 - ' : (e.vrfcCd === 'NV' ? '일반검증 - ' : '모집단 검증 -');
return `${prefix}${e.rsnExpln}
(검증 기준값: ${e.cmprCn} / 현재 입력값: ${e.nowCn})`;
})
.filter(Boolean)
.join('\n');
return formatVerificationTooltip(errors);
}
}}
/>
@@ -9,6 +9,7 @@ import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service";
import { statisticsInputSewerageServiceLevelApi } from "@/entities/statistics/api/input/statistics-input-sewerage-service-level-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import { callImrServiceWithRollbackOnFailure } from "@/components/util/input/call-imr-with-rollback";
import {
SEWERAGE_SERVICE_LEVEL_COMPLAINT_STATS_CD,
SEWERAGE_SERVICE_LEVEL_RESIDENT_FRIENDLY_FACT_STATS_CD,
@@ -63,7 +64,11 @@ import {
patchFooterVerificationByTab,
patchVerificationFailedByTab,
} from "@/components/util/input/verification-failed-flag";
import type { VerificationResultLikeMap } from "@/components/util/input/verification-result-display";
import {
formatVerificationTooltip,
normalizeVerificationResultMap,
type VerificationResultLikeMap,
} from "@/components/util/input/verification-result-display";
import { nullableTrimmedString } from "@/components/util/input/nullableString";
import { useAdminStore } from "@/store/admin-store";
import { useTabStore } from "@/store/tab-store";
@@ -187,7 +192,9 @@ function mapServiceLevelListToGridRows<
return filterBySelectedAdmdstCd(list, sidoListRef.current, values).map((item) => {
const { verificationResult, ...rest } = item;
const row = toGridRows([{ ctpvNm, ...rest }])[0] as GridRowWithVerification;
if (verificationResult) row.verificationResult = verificationResult;
if (verificationResult) {
row.verificationResult = normalizeVerificationResultMap(verificationResult);
}
return row;
});
}
@@ -808,13 +815,11 @@ const SewerageServiceLevelInp = () => {
? await statisticsInputSewerageServiceLevelApi.submitComplaintList({
...submitInfoBase,
userUuid: userInfo.userUnqId,
...(isAdminInfo ? { superAdmin: "superAdmin" } : {}),
submitList: complaintSubmitList,
})
: await statisticsInputSewerageServiceLevelApi.submitResidentFriendlyFactList({
...submitInfoBase,
userUuid: userInfo.userUnqId,
...(isAdminInfo ? { superAdmin: "superAdmin" } : {}),
noApplicable,
submitList: residentSubmitList,
});
@@ -841,7 +846,13 @@ const SewerageServiceLevelInp = () => {
};
try {
const imrResponse = await statisticsApi.callImrService(imrParam);
const imrResponse = await callImrServiceWithRollbackOnFailure(imrParam, {
statsYr: lastSearchedValues.year ?? "",
statsCd: statsCdForTab,
sewerBzmnCd: lastSearchedValues?.bsnmCd ?? "",
rollbackStatusCode: activeTabStatus?.lclgvSbmsnSttsCd ?? "",
mdfrUuid: userInfo.userUnqId,
});
if (imrResponse.job_id) {
await waitForImrJobCompleted(
imrResponse.job_id,
@@ -1148,18 +1159,7 @@ const SewerageServiceLevelInp = () => {
if (!verification) return;
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
return errors
.map((e) => {
const prefix =
e.vrfcCd === "IMR"
? "시계열 검증 - "
: e.vrfcCd === "NV"
? "일반검증 - "
: "모집단 검증 - ";
return `${prefix}${e.rsnExpln}(검증 기준값: ${e.cmprCn} / 현재 입력값: ${e.nowCn})`;
})
.filter(Boolean)
.join("\n");
return formatVerificationTooltip(errors);
}
};
@@ -20,8 +20,8 @@ const StatisticsReviewHistory = () => {
{ name: "ctpvNm", fieldName: "ctpvNm", header: { text: "시도" }, editable: false },
{ name: "sggNm", fieldName: "sggNm", header: { text: "시군구" }, editable: false },
{ name: "menuNm", fieldName: "menuNm", header: { text: "통계항목" }, editable: false },
{ name: "chckerOpinionSm", fieldName: "chckerOpinionSm", header: { text: "검토자의견" }, editable: false },
{ name: "wrterOpinionSm", fieldName: "wrterOpinionSm", header: { text: "처리내역" }, editable: false },
{ name: "splmntOpnnCn", fieldName: "splmntOpnnCn", header: { text: "검토자의견" }, editable: false },
{ name: "rjctOpnnCn", fieldName: "rjctOpnnCn", header: { text: "반려의견" }, editable: false },
];
const columnLayout = [
@@ -29,8 +29,8 @@ const StatisticsReviewHistory = () => {
"ctpvNm",
"sggNm",
"menuNm",
"chckerOpinionSm",
"wrterOpinionSm",
"splmntOpnnCn",
"rjctOpnnCn",
];
const handleSearch = async (values: SearchFormValues) => {
@@ -21,6 +21,7 @@ import { nullableTrimmedString } from "@/components/util/input/nullableString";
import { statisticsInputSewSupplyRateApi } from "@/entities/statistics/api/input/statistics-input-sew-supply-rate-service";
import { statisticsInputWaterReuseApi } from "@/entities/statistics/api/input/statistics-input-water-reuse-service";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import { callImrServiceWithRollbackOnFailure } from "@/components/util/input/call-imr-with-rollback";
import {
type PublicSewageTreatWaterReuseItem,
type PublicSewageTreatWaterReuseSaveItem,
@@ -93,7 +94,11 @@ import {
patchFooterVerificationByTab,
patchVerificationFailedByTab,
} from "@/components/util/input/verification-failed-flag";
import type { VerificationResultLikeMap } from "@/components/util/input/verification-result-display";
import {
formatVerificationTooltip,
normalizeVerificationResultMap,
type VerificationResultLikeMap,
} from "@/components/util/input/verification-result-display";
import { useAdminStore } from "@/store/admin-store";
import { useTabStore } from "@/store/tab-store";
@@ -443,7 +448,9 @@ function mapPublicSewageTreatWaterReuseListToGridRows(
instlAprvYmd: formatCompactYmdForGrid(item.instlAprvYmd),
},
])[0] as GridRowWithVerification;
if (verificationResult) row.verificationResult = verificationResult;
if (verificationResult) {
row.verificationResult = normalizeVerificationResultMap(verificationResult);
}
return row;
});
}
@@ -462,7 +469,9 @@ function mapRecycledWaterStatusListToGridRows(
oprtngStrtYmd: formatCompactYmdForGrid(item.oprtngStrtYmd),
},
])[0] as GridRowWithVerification;
if (verificationResult) row.verificationResult = verificationResult;
if (verificationResult) {
row.verificationResult = normalizeVerificationResultMap(verificationResult);
}
return row;
});
}
@@ -480,7 +489,9 @@ function mapRainFallUtilFactStatusListToGridRows(
cmptnYmd: formatCompactYmdForGrid(item.cmptnYmd),
},
])[0] as GridRowWithVerification;
if (verificationResult) row.verificationResult = verificationResult;
if (verificationResult) {
row.verificationResult = normalizeVerificationResultMap(verificationResult);
}
return row;
});
}
@@ -497,7 +508,9 @@ function mapTreatWaterReuseStatusListToGridRows<
coRegYmd: formatCompactYmdForGrid(item.coRegYmd),
},
])[0] as GridRowWithVerification;
if (verificationResult) row.verificationResult = verificationResult;
if (verificationResult) {
row.verificationResult = normalizeVerificationResultMap(verificationResult);
}
return row;
});
}
@@ -1153,7 +1166,6 @@ const WaterReuseInp = () => {
lclgvIdfrTelnoCn: submitInfo.lclgvIdfrTelnoCn,
lclgvIdfrDeptNm: submitInfo.lclgvIdfrDeptNm,
lclgvIdfrJbgdNm: submitInfo.lclgvIdfrJbgdNm,
...(isAdminInfo ? { superAdmin: "superAdmin" } : {}),
};
let articleSggOptions: Sgg[] | null = null;
@@ -1356,7 +1368,13 @@ const WaterReuseInp = () => {
};
try {
const imrResponse = await statisticsApi.callImrService(imrParam);
const imrResponse = await callImrServiceWithRollbackOnFailure(imrParam, {
statsYr: lastSearchedValues.year ?? "",
statsCd: statsCdForTab,
sewerBzmnCd: lastSearchedValues?.bsnmCd ?? "",
rollbackStatusCode: activeTabStatus?.lclgvSbmsnSttsCd ?? "",
mdfrUuid: userInfo.userUnqId,
});
if (imrResponse.job_id) {
await waitForImrJobCompleted(
imrResponse.job_id,
@@ -2064,18 +2082,7 @@ const WaterReuseInp = () => {
if (!verification) return;
const errors = verification[fieldName];
if (Array.isArray(errors) && errors.length > 0) {
return errors
.map((e) => {
const prefix =
e.vrfcCd === "IMR"
? "시계열 검증 - "
: e.vrfcCd === "NV"
? "일반검증 - "
: "모집단 검증 - ";
return `${prefix}${e.rsnExpln}(검증 기준값: ${e.cmprCn} / 현재 입력값: ${e.nowCn})`;
})
.filter(Boolean)
.join("\n");
return formatVerificationTooltip(errors);
}
};
@@ -49,7 +49,6 @@ const ExcretaTreatmentFacilityRvw = () => {
const [reviewOpinionContext, setReviewOpinionContext] =
useState<ReviewOpinionDialogContext | null>(null);
const [latestReviewOpinion, setLatestReviewOpinion] = useState("");
const [latestProcessHistory, setLatestProcessHistory] = useState("");
const [lastSearchValues, setLastSearchValues] = useState<SearchFormValues | null>(null);
const columnInfo = useMemo(() => {
@@ -116,7 +115,6 @@ const ExcretaTreatmentFacilityRvw = () => {
setStatsStatusInfo({});
setReviewOpinionContext(null);
setLatestReviewOpinion("");
setLatestProcessHistory("");
setLastSearchValues(null);
setReviewOpinionOpen(false);
setReturnDialogOpen(false);
@@ -170,14 +168,12 @@ const ExcretaTreatmentFacilityRvw = () => {
statsCd: statsInfo.statsCd ?? "",
});
setLatestReviewOpinion(statsInfo.splmntOpnnCn ?? "");
setLatestProcessHistory(statsInfo.splmntRsltCn ?? "");
setRowData(toGridRows(convertedData as unknown as object[]));
} else {
setRowData([]);
setStatsStatusInfo({});
setReviewOpinionContext(null);
setLatestReviewOpinion("");
setLatestProcessHistory("");
alert(response.message || "데이터가 없습니다.");
}
} catch {
@@ -216,7 +212,6 @@ const ExcretaTreatmentFacilityRvw = () => {
const row = detailRes.data?.[0];
setLatestReviewOpinion(row?.splmntOpnnCn ?? "");
setLatestProcessHistory(row?.splmntRsltCn ?? "");
setReviewOpinionOpen(true);
} catch {
alert("검토의견 조회 중 오류가 발생했습니다.");
@@ -234,10 +229,8 @@ const ExcretaTreatmentFacilityRvw = () => {
setStatsStatusInfo((prev) => ({
...prev,
splmntOpnnCn: param.splmntOpnnCn,
splmntRsltCn: param.splmntRsltCn,
}));
setLatestReviewOpinion(param.splmntOpnnCn);
setLatestProcessHistory(param.splmntRsltCn);
} catch {
alert("저장 중 오류가 발생했습니다.");
}
@@ -297,7 +290,6 @@ const ExcretaTreatmentFacilityRvw = () => {
onOpenChange={setReviewOpinionOpen}
context={reviewOpinionContext}
initialReviewOpinion={latestReviewOpinion}
initialProcessHistory={latestProcessHistory}
onSave={saveReviewOpinion}
/>
<ReviewSubmitDialog
@@ -102,7 +102,6 @@ const PrivateSewageFacilityRvw = () => {
}, [activeStatsStatusInfo.statsCd, lastSearchValues]);
const latestReviewOpinion = activeStatsStatusInfo.splmntOpnnCn ?? "";
const latestProcessHistory = activeStatsStatusInfo.splmntRsltCn ?? "";
const submitDialogContext = useMemo<ReviewerInfoRequest | null>(() => {
if (lastSearchValues == null) return null;
@@ -284,7 +283,6 @@ const PrivateSewageFacilityRvw = () => {
next.set(activeTab, {
...(next.get(activeTab) ?? {}),
splmntOpnnCn: row?.splmntOpnnCn ?? "",
splmntRsltCn: row?.splmntRsltCn ?? "",
});
return next;
});
@@ -307,7 +305,6 @@ const PrivateSewageFacilityRvw = () => {
next.set(activeTab, {
...(next.get(activeTab) ?? {}),
splmntOpnnCn: param.splmntOpnnCn,
splmntRsltCn: param.splmntRsltCn,
});
return next;
});
@@ -411,7 +408,6 @@ const PrivateSewageFacilityRvw = () => {
onOpenChange={setReviewOpinionOpen}
context={reviewOpinionContext}
initialReviewOpinion={latestReviewOpinion}
initialProcessHistory={latestProcessHistory}
onSave={saveReviewOpinion}
/>
<ReviewSubmitDialog
@@ -41,7 +41,6 @@ const PublicSewageFacilityRvw = () => {
const [reviewOpinionContext, setReviewOpinionContext] =
useState<ReviewOpinionDialogContext | null>(null);
const [latestReviewOpinion, setLatestReviewOpinion] = useState("");
const [latestProcessHistory, setLatestProcessHistory] = useState("");
const [lastSearchValues, setLastSearchValues] = useState<SearchFormValues | null>(null);
const columnInfo = useMemo(() => {
@@ -100,7 +99,6 @@ const PublicSewageFacilityRvw = () => {
setStatsStatusInfo({});
setReviewOpinionContext(null);
setLatestReviewOpinion("");
setLatestProcessHistory("");
setLastSearchValues(null);
setSubmitDialogOpen(false);
};
@@ -143,16 +141,12 @@ const PublicSewageFacilityRvw = () => {
setLatestReviewOpinion(
response.data.statsStatusInfo.splmntOpnnCn ?? "",
);
setLatestProcessHistory(
response.data.statsStatusInfo.splmntRsltCn ?? "",
);
setRowData(toGridRows(convertedData as unknown as object[]));
} else {
setRowData([]);
setStatsStatusInfo({});
setReviewOpinionContext(null);
setLatestReviewOpinion("");
setLatestProcessHistory("");
alert(response.message || "데이터가 없습니다.");
}
} catch (error) {
@@ -192,7 +186,6 @@ const PublicSewageFacilityRvw = () => {
const row = detailRes.data?.[0];
setLatestReviewOpinion(row?.splmntOpnnCn ?? "");
setLatestProcessHistory(row?.splmntRsltCn ?? "");
setReviewOpinionOpen(true);
} catch {
alert("검토의견 조회 중 오류가 발생했습니다.");
@@ -210,10 +203,8 @@ const PublicSewageFacilityRvw = () => {
setStatsStatusInfo((prev) => ({
...prev,
splmntOpnnCn: param.splmntOpnnCn,
splmntRsltCn: param.splmntRsltCn,
}));
setLatestReviewOpinion(param.splmntOpnnCn);
setLatestProcessHistory(param.splmntRsltCn);
} catch {
alert("저장 중 오류가 발생했습니다.");
}
@@ -267,7 +258,6 @@ const PublicSewageFacilityRvw = () => {
onOpenChange={setReviewOpinionOpen}
context={reviewOpinionContext}
initialReviewOpinion={latestReviewOpinion}
initialProcessHistory={latestProcessHistory}
onSave={saveReviewOpinion}
/>
<ReviewSubmitDialog
@@ -47,7 +47,6 @@ const PumpingStationRvw = () => {
const [reviewOpinionContext, setReviewOpinionContext] =
useState<ReviewOpinionDialogContext | null>(null);
const [latestReviewOpinion, setLatestReviewOpinion] = useState("");
const [latestProcessHistory, setLatestProcessHistory] = useState("");
const [lastSearchValues, setLastSearchValues] = useState<SearchFormValues | null>(null);
const columnInfo = useMemo(() => {
@@ -111,7 +110,6 @@ const PumpingStationRvw = () => {
setStatsStatusInfo({});
setReviewOpinionContext(null);
setLatestReviewOpinion("");
setLatestProcessHistory("");
setLastSearchValues(null);
setReviewOpinionOpen(false);
setReturnDialogOpen(false);
@@ -157,14 +155,12 @@ const PumpingStationRvw = () => {
statsCd: statsInfo.statsCd ?? "",
});
setLatestReviewOpinion(statsInfo.splmntOpnnCn ?? "");
setLatestProcessHistory(statsInfo.splmntRsltCn ?? "");
setRowData(toGridRows(convertedData));
} else {
setRowData([]);
setStatsStatusInfo({});
setReviewOpinionContext(null);
setLatestReviewOpinion("");
setLatestProcessHistory("");
alert(response.message || "데이터가 없습니다.");
}
} catch {
@@ -203,7 +199,6 @@ const PumpingStationRvw = () => {
const row = detailRes.data?.[0];
setLatestReviewOpinion(row?.splmntOpnnCn ?? "");
setLatestProcessHistory(row?.splmntRsltCn ?? "");
setReviewOpinionOpen(true);
} catch {
alert("검토의견 조회 중 오류가 발생했습니다.");
@@ -221,10 +216,8 @@ const PumpingStationRvw = () => {
setStatsStatusInfo((prev) => ({
...prev,
splmntOpnnCn: param.splmntOpnnCn,
splmntRsltCn: param.splmntRsltCn,
}));
setLatestReviewOpinion(param.splmntOpnnCn);
setLatestProcessHistory(param.splmntRsltCn);
} catch {
alert("저장 중 오류가 발생했습니다.");
}
@@ -284,7 +277,6 @@ const PumpingStationRvw = () => {
onOpenChange={setReviewOpinionOpen}
context={reviewOpinionContext}
initialReviewOpinion={latestReviewOpinion}
initialProcessHistory={latestProcessHistory}
onSave={saveReviewOpinion}
/>
<ReviewSubmitDialog
@@ -30,8 +30,6 @@ export interface ReviewOpinionDialogProps {
context: ReviewOpinionDialogContext | null;
/** 다이얼로그 열릴 때 검토의견 초기값 */
initialReviewOpinion?: string;
/** 다이얼로그 열릴 때 처리내역 초기값 */
initialProcessHistory?: string;
/** 저장 시 생성된 param 전달 (API 호출 등) */
onSave?: (param: ReviewOpinionSaveRequest) => void | Promise<void>;
}
@@ -41,18 +39,15 @@ export function ReviewOpinionDialog({
onOpenChange,
context,
initialReviewOpinion = "",
initialProcessHistory = "",
onSave,
}: ReviewOpinionDialogProps) {
const { setMessage: alert } = useAlertStore((state) => state);
const [reviewOpinion, setReviewOpinion] = useState("");
const [processHistory, setProcessHistory] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [reviewOpinionError, setReviewOpinionError] = useState(false);
const resetForm = () => {
setReviewOpinion("");
setProcessHistory("");
setReviewOpinionError(false);
};
@@ -64,8 +59,7 @@ export function ReviewOpinionDialog({
useEffect(() => {
if (!open) return;
setReviewOpinion(initialReviewOpinion);
setProcessHistory(initialProcessHistory);
}, [open, initialReviewOpinion, initialProcessHistory]);
}, [open, initialReviewOpinion]);
const handleSave = async () => {
if (reviewOpinion.trim() === "") {
@@ -96,7 +90,6 @@ export function ReviewOpinionDialog({
sewerBzmnCd: context.sewerBzmnCd,
statsCd: context.statsCd,
splmntOpnnCn: reviewOpinion,
splmntRsltCn: processHistory,
mdfrUuid: userInfo.userUnqId,
};
await onSave?.(param);
@@ -136,16 +129,6 @@ export function ReviewOpinionDialog({
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label className="text-label mb-2">처리내역</Label>
<Textarea
value={processHistory}
onChange={(e) => setProcessHistory(e.target.value)}
className="min-h-[10rem] resize-y !text-[1.3rem] !leading-6"
placeholder="처리내역을 입력하세요."
disabled={isSaving}
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
@@ -30,8 +30,8 @@ export interface ReviewSubmitDialogProps {
context: ReviewerInfoRequest | null;
onSubmitted?: () => void | Promise<void>;
/**
* true면 검토의견·처리내역 UI를 숨기고
* submit body에 statsCd / splmntOpnnCn / splmntRsltCn을 넣지 않음
* true면 검토의견 UI를 숨기고
* submit body에 statsCd / splmntOpnnCn을 넣지 않음
*/
hideOpinionFields?: boolean;
}
@@ -116,7 +116,6 @@ export function ReviewSubmitDialog({
statsCd: context.statsCd,
mdfrUuid: userInfo.userUnqId,
splmntOpnnCn: reviewerInfo?.splmntOpnnCn ?? "",
splmntRsltCn: reviewerInfo?.splmntRsltCn ?? "",
};
const response = await statisticsApi.submitReview(submitParam);
@@ -175,14 +174,6 @@ export function ReviewSubmitDialog({
className="min-h-[10rem] p-2 resize-none !text-xl"
/>
</div>
<div className="col-span-2 flex flex-col gap-2">
<Label className="text-label">처리내역</Label>
<Textarea
value={reviewerInfo?.splmntRsltCn ?? "-"}
readOnly
className="min-h-[10rem] p-2 resize-none !text-xl"
/>
</div>
</>
)}
</div>
@@ -105,7 +105,6 @@ const SewageExcretaRelatedRvw = () => {
}, [activeStatsStatusInfo.statsCd, lastSearchValues]);
const latestReviewOpinion = activeStatsStatusInfo.splmntOpnnCn ?? "";
const latestProcessHistory = activeStatsStatusInfo.splmntRsltCn ?? "";
const submitDialogContext = useMemo<ReviewerInfoRequest | null>(() => {
if (lastSearchValues == null) return null;
@@ -289,7 +288,6 @@ const SewageExcretaRelatedRvw = () => {
next.set(activeTab, {
...(next.get(activeTab) ?? {}),
splmntOpnnCn: row?.splmntOpnnCn ?? "",
splmntRsltCn: row?.splmntRsltCn ?? "",
});
return next;
});
@@ -312,7 +310,6 @@ const SewageExcretaRelatedRvw = () => {
next.set(activeTab, {
...(next.get(activeTab) ?? {}),
splmntOpnnCn: param.splmntOpnnCn,
splmntRsltCn: param.splmntRsltCn,
});
return next;
});
@@ -416,7 +413,6 @@ const SewageExcretaRelatedRvw = () => {
onOpenChange={setReviewOpinionOpen}
context={reviewOpinionContext}
initialReviewOpinion={latestReviewOpinion}
initialProcessHistory={latestProcessHistory}
onSave={saveReviewOpinion}
/>
<ReviewSubmitDialog
@@ -105,7 +105,6 @@ const SewageExcretaSlurgeRvw = () => {
}, [activeStatsStatusInfo.statsCd, lastSearchValues]);
const latestReviewOpinion = activeStatsStatusInfo.splmntOpnnCn ?? "";
const latestProcessHistory = activeStatsStatusInfo.splmntRsltCn ?? "";
const submitDialogContext = useMemo<ReviewerInfoRequest | null>(() => {
if (lastSearchValues == null) return null;
@@ -290,7 +289,6 @@ const SewageExcretaSlurgeRvw = () => {
next.set(activeTab, {
...(next.get(activeTab) ?? {}),
splmntOpnnCn: row?.splmntOpnnCn ?? "",
splmntRsltCn: row?.splmntRsltCn ?? "",
});
return next;
});
@@ -313,7 +311,6 @@ const SewageExcretaSlurgeRvw = () => {
next.set(activeTab, {
...(next.get(activeTab) ?? {}),
splmntOpnnCn: param.splmntOpnnCn,
splmntRsltCn: param.splmntRsltCn,
});
return next;
});
@@ -417,7 +414,6 @@ const SewageExcretaSlurgeRvw = () => {
onOpenChange={setReviewOpinionOpen}
context={reviewOpinionContext}
initialReviewOpinion={latestReviewOpinion}
initialProcessHistory={latestProcessHistory}
onSave={saveReviewOpinion}
/>
<ReviewSubmitDialog
@@ -120,7 +120,6 @@ const SewagePipeMaintainRvw = () => {
}, [activeStatsStatusInfo.statsCd, lastSearchValues]);
const latestReviewOpinion = activeStatsStatusInfo.splmntOpnnCn ?? "";
const latestProcessHistory = activeStatsStatusInfo.splmntRsltCn ?? "";
const submitDialogContext = useMemo<ReviewerInfoRequest | null>(() => {
if (lastSearchValues == null) return null;
@@ -302,7 +301,6 @@ const SewagePipeMaintainRvw = () => {
next.set(activeTab, {
...(next.get(activeTab) ?? {}),
splmntOpnnCn: row?.splmntOpnnCn ?? "",
splmntRsltCn: row?.splmntRsltCn ?? "",
});
return next;
});
@@ -325,7 +323,6 @@ const SewagePipeMaintainRvw = () => {
next.set(activeTab, {
...(next.get(activeTab) ?? {}),
splmntOpnnCn: param.splmntOpnnCn,
splmntRsltCn: param.splmntRsltCn,
});
return next;
});
@@ -429,7 +426,6 @@ const SewagePipeMaintainRvw = () => {
onOpenChange={setReviewOpinionOpen}
context={reviewOpinionContext}
initialReviewOpinion={latestReviewOpinion}
initialProcessHistory={latestProcessHistory}
onSave={saveReviewOpinion}
/>
<ReviewSubmitDialog
@@ -181,7 +181,6 @@ const SewagePipeRvw = () => {
};
}, [activeStatsStatusInfo.statsCd, lastSearchValues]);
const latestReviewOpinion = activeStatsStatusInfo.splmntOpnnCn ?? "";
const latestProcessHistory = activeStatsStatusInfo.splmntRsltCn ?? "";
const submitDialogContext = useMemo<ReviewerInfoRequest | null>(() => {
if (lastSearchValues == null) return null;
@@ -362,7 +361,6 @@ const SewagePipeRvw = () => {
next.set(activeTab, {
...(next.get(activeTab) ?? {}),
splmntOpnnCn: row?.splmntOpnnCn ?? "",
splmntRsltCn: row?.splmntRsltCn ?? "",
});
return next;
});
@@ -385,7 +383,6 @@ const SewagePipeRvw = () => {
next.set(activeTab, {
...(next.get(activeTab) ?? {}),
splmntOpnnCn: param.splmntOpnnCn,
splmntRsltCn: param.splmntRsltCn,
});
return next;
});
@@ -488,7 +485,6 @@ const SewagePipeRvw = () => {
onOpenChange={setReviewOpinionOpen}
context={reviewOpinionContext}
initialReviewOpinion={latestReviewOpinion}
initialProcessHistory={latestProcessHistory}
onSave={saveReviewOpinion}
/>
<ReviewSubmitDialog
@@ -126,7 +126,6 @@ const SewageReservoirPumpRvw = () => {
}, [activeStatsCd, lastSearchValues]);
const latestReviewOpinion = activeStatsStatusInfo.splmntOpnnCn ?? "";
const latestProcessHistory = activeStatsStatusInfo.splmntRsltCn ?? "";
const submitDialogContext = useMemo<ReviewerInfoRequest | null>(() => {
if (lastSearchValues == null) return null;
@@ -308,7 +307,6 @@ const SewageReservoirPumpRvw = () => {
next.set(activeTab, {
...(next.get(activeTab) ?? {}),
splmntOpnnCn: row?.splmntOpnnCn ?? "",
splmntRsltCn: row?.splmntRsltCn ?? "",
});
return next;
});
@@ -331,7 +329,6 @@ const SewageReservoirPumpRvw = () => {
next.set(activeTab, {
...(next.get(activeTab) ?? {}),
splmntOpnnCn: param.splmntOpnnCn,
splmntRsltCn: param.splmntRsltCn,
});
return next;
});
@@ -435,7 +432,6 @@ const SewageReservoirPumpRvw = () => {
onOpenChange={setReviewOpinionOpen}
context={reviewOpinionContext}
initialReviewOpinion={latestReviewOpinion}
initialProcessHistory={latestProcessHistory}
onSave={saveReviewOpinion}
/>
<ReviewSubmitDialog
@@ -105,7 +105,6 @@ const SewerageFinanceRvw = () => {
}, [activeStatsStatusInfo.statsCd, lastSearchValues]);
const latestReviewOpinion = activeStatsStatusInfo.splmntOpnnCn ?? "";
const latestProcessHistory = activeStatsStatusInfo.splmntRsltCn ?? "";
const submitDialogContext = useMemo<ReviewerInfoRequest | null>(() => {
if (lastSearchValues == null) return null;
@@ -287,7 +286,6 @@ const SewerageFinanceRvw = () => {
next.set(activeTab, {
...(next.get(activeTab) ?? {}),
splmntOpnnCn: row?.splmntOpnnCn ?? "",
splmntRsltCn: row?.splmntRsltCn ?? "",
});
return next;
});
@@ -310,7 +308,6 @@ const SewerageFinanceRvw = () => {
next.set(activeTab, {
...(next.get(activeTab) ?? {}),
splmntOpnnCn: param.splmntOpnnCn,
splmntRsltCn: param.splmntRsltCn,
});
return next;
});
@@ -414,7 +411,6 @@ const SewerageFinanceRvw = () => {
onOpenChange={setReviewOpinionOpen}
context={reviewOpinionContext}
initialReviewOpinion={latestReviewOpinion}
initialProcessHistory={latestProcessHistory}
onSave={saveReviewOpinion}
/>
<ReviewSubmitDialog
@@ -40,7 +40,6 @@ const SeweragePenetrationRateRvw = () => {
const [reviewOpinionContext, setReviewOpinionContext] =
useState<ReviewOpinionDialogContext | null>(null);
const [latestReviewOpinion, setLatestReviewOpinion] = useState("");
const [latestProcessHistory, setLatestProcessHistory] = useState("");
const [lastSearchValues, setLastSearchValues] = useState<SearchFormValues | null>(null);
const columnInfo = useMemo(() => {
@@ -100,7 +99,6 @@ const SeweragePenetrationRateRvw = () => {
setStatsStatusInfo({});
setReviewOpinionContext(null);
setLatestReviewOpinion("");
setLatestProcessHistory("");
setLastSearchValues(null);
};
@@ -139,15 +137,11 @@ const SeweragePenetrationRateRvw = () => {
setLatestReviewOpinion(
response.data.statsStatusInfo.splmntOpnnCn ?? "",
);
setLatestProcessHistory(
response.data.statsStatusInfo.splmntRsltCn ?? "",
);
setRowData(toGridRows(convertedData));
} else {
setRowData([]);
setReviewOpinionContext(null);
setLatestReviewOpinion("");
setLatestProcessHistory("");
alert(response.message || "데이터가 없습니다.");
}
} catch (error) {
@@ -187,7 +181,6 @@ const SeweragePenetrationRateRvw = () => {
const row = detailRes.data?.[0];
setLatestReviewOpinion(row?.splmntOpnnCn ?? "");
setLatestProcessHistory(row?.splmntRsltCn ?? "");
setReviewOpinionOpen(true);
} catch {
alert("검토의견 조회 중 오류가 발생했습니다.");
@@ -205,10 +198,8 @@ const SeweragePenetrationRateRvw = () => {
setStatsStatusInfo((prev) => ({
...prev,
splmntOpnnCn: param.splmntOpnnCn,
splmntRsltCn: param.splmntRsltCn,
}));
setLatestReviewOpinion(param.splmntOpnnCn);
setLatestProcessHistory(param.splmntRsltCn);
} catch {
alert("저장 중 오류가 발생했습니다.");
}
@@ -264,7 +255,6 @@ const SeweragePenetrationRateRvw = () => {
onOpenChange={setReviewOpinionOpen}
context={reviewOpinionContext}
initialReviewOpinion={latestReviewOpinion}
initialProcessHistory={latestProcessHistory}
onSave={saveReviewOpinion}
/>
<ReviewSubmitDialog
@@ -103,7 +103,6 @@ const SewerageServiceLevelRvw = () => {
}, [activeStatsStatusInfo.statsCd, lastSearchValues]);
const latestReviewOpinion = activeStatsStatusInfo.splmntOpnnCn ?? "";
const latestProcessHistory = activeStatsStatusInfo.splmntRsltCn ?? "";
const submitDialogContext = useMemo<ReviewerInfoRequest | null>(() => {
if (lastSearchValues == null) return null;
@@ -288,7 +287,6 @@ const SewerageServiceLevelRvw = () => {
next.set(activeTab, {
...(next.get(activeTab) ?? {}),
splmntOpnnCn: row?.splmntOpnnCn ?? "",
splmntRsltCn: row?.splmntRsltCn ?? "",
});
return next;
});
@@ -311,7 +309,6 @@ const SewerageServiceLevelRvw = () => {
next.set(activeTab, {
...(next.get(activeTab) ?? {}),
splmntOpnnCn: param.splmntOpnnCn,
splmntRsltCn: param.splmntRsltCn,
});
return next;
});
@@ -415,7 +412,6 @@ const SewerageServiceLevelRvw = () => {
onOpenChange={setReviewOpinionOpen}
context={reviewOpinionContext}
initialReviewOpinion={latestReviewOpinion}
initialProcessHistory={latestProcessHistory}
onSave={saveReviewOpinion}
/>
<ReviewSubmitDialog
@@ -39,22 +39,14 @@ import { toGridRows } from "@/components/util/grid/gridFunction";
type ReviewHistoryRow = ReviewHistoryResponse["data"][number];
const checkPrvnStatusCd = (code: string) => {
return (code === '0' || code === '1') ? '미작성' : (code === '2' ? '제출완료' : (code === '3' ? '승인완료' : '반려'));
};
const checkSubmitStatusCd = (code: string) => {
return code === '0' ? '미작성' : (code === '1' ? '작성중' : (code === '2' ? '제출완료' : (code === '3' ? '승인완료' : '보완요청')));
};
const isStatsInputStatusRowApprovable = (row: Record<string, string | number>) =>
String(row.submitStatusCd ?? "") === "3" && (String(row.prvnStatusCd ?? "") === "2" || String(row.prvnStatusCd ?? "") === "3");
String(row.lclgvSbmsnSttsCd ?? "") === "3" &&
(String(row.mctpvPicSbmsnSttsCd ?? "") === "2" ||
String(row.mctpvPicSbmsnSttsCd ?? "") === "3");
const mapSewerageListToGridRows = (rawList: StatsInputStatusReviewItem[]) =>
rawList.map((r) => ({
...r,
prvnStatusNm: checkPrvnStatusCd(r.prvnStatusCd || "0"),
submitStatusNm: checkSubmitStatusCd(r.submitStatusCd || "0"),
reviewHistory: "검토이력",
}));
@@ -97,7 +89,7 @@ const StatsInputStatusRvw = () => {
const reviewHistoryColumns = useMemo(() => {
const col = createColumnHelper<ReviewHistoryRow>();
return [
col.accessor("logDt", {
col.accessor("regDt", {
header: "등록일",
cell: (info) => info.getValue(),
}),
@@ -238,23 +230,23 @@ const StatsInputStatusRvw = () => {
const allSubmitStatusApproved = useMemo(
() =>
rowData.length > 0 &&
rowData.every((row) => String(row.submitStatusCd ?? "") === "3"),
rowData.every((row) => String(row.lclgvSbmsnSttsCd ?? "") === "3"),
[rowData],
);
/** 진행상태(prvn) 코드가 모두 승인완료(3) */
/** 진행상태(광역) 코드가 모두 승인완료(3) */
const allPrvnStatusApproved = useMemo(
() =>
rowData.length > 0 &&
rowData.every((row) => String(row.prvnStatusCd ?? "") === "3"),
rowData.every((row) => String(row.mctpvPicSbmsnSttsCd ?? "") === "3"),
[rowData],
);
/** 진행상태(prvn) 코드가 모두 제출완료(2) */
/** 진행상태(광역) 코드가 모두 제출완료(2) */
const allPrvnStatusSubmitted = useMemo(
() =>
rowData.length > 0 &&
rowData.every((row) => String(row.prvnStatusCd ?? "") === "2"),
rowData.every((row) => String(row.mctpvPicSbmsnSttsCd ?? "") === "2"),
[rowData],
);
@@ -392,8 +384,8 @@ const StatsInputStatusRvw = () => {
setRowData={setRowData}
onDataCellClick={handleReviewHistoryCellClick}
cellStyleCallback={({ row, fieldName }) => {
if (fieldName === "submitStatusNm") {
const statusCd = String(row.submitStatusCd ?? "");
if (fieldName === "lclgvSbmsnSttsNm") {
const statusCd = String(row.lclgvSbmsnSttsCd ?? "");
if (statusCd === "0" || statusCd === "1") {
return "rg-cell-stats-status-pending";
@@ -408,8 +400,8 @@ const StatsInputStatusRvw = () => {
return "rg-cell-stats-status-rejected";
}
return undefined;
} else if (fieldName === "prvnStatusNm") {
const statusCd = String(row.prvnStatusCd ?? "");
} else if (fieldName === "mctpvPicSbmsnSttsNm") {
const statusCd = String(row.mctpvPicSbmsnSttsCd ?? "");
if (statusCd === "0" || statusCd === "1") {
return "rg-cell-stats-status-pending";
@@ -108,7 +108,6 @@ const WaterReuseRvw = () => {
}, [activeStatsStatusInfo.statsCd, lastSearchValues]);
const latestReviewOpinion = activeStatsStatusInfo.splmntOpnnCn ?? "";
const latestProcessHistory = activeStatsStatusInfo.splmntRsltCn ?? "";
const submitDialogContext = useMemo<ReviewerInfoRequest | null>(() => {
if (lastSearchValues == null) return null;
@@ -291,7 +290,6 @@ const WaterReuseRvw = () => {
next.set(activeTab, {
...(next.get(activeTab) ?? {}),
splmntOpnnCn: row?.splmntOpnnCn ?? "",
splmntRsltCn: row?.splmntRsltCn ?? "",
});
return next;
});
@@ -314,7 +312,6 @@ const WaterReuseRvw = () => {
next.set(activeTab, {
...(next.get(activeTab) ?? {}),
splmntOpnnCn: param.splmntOpnnCn,
splmntRsltCn: param.splmntRsltCn,
});
return next;
});
@@ -418,7 +415,6 @@ const WaterReuseRvw = () => {
onOpenChange={setReviewOpinionOpen}
context={reviewOpinionContext}
initialReviewOpinion={latestReviewOpinion}
initialProcessHistory={latestProcessHistory}
onSave={saveReviewOpinion}
/>
<ReviewSubmitDialog
@@ -1,162 +1,162 @@
"use client";
import { useEffect, useState } from "react";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
} from "@common/components";
export interface ApprovalFormData {
lastCfmtnPicNm: string;
lastCfmtnPicTelNo: string;
lastCfmtnPicDeptNm: string;
lastCfmtnPicJbgdNm: string;
}
export interface ApprovalInputDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm?: (data: ApprovalFormData) => void | Promise<void>;
}
export function ApprovalInputDialog({
open,
onOpenChange,
onConfirm,
}: ApprovalInputDialogProps) {
const [lastCfmtnPicNm, setLastCfmtnPicNm] = useState("");
const [lastCfmtnPicTelNo, setLastCfmtnPicTelNo] = useState("");
const [lastCfmtnPicDeptNm, setLastCfmtnPicDeptNm] = useState("");
const [lastCfmtnPicJbgdNm, setLastCfmtnPicJbgdNm] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => {
setLastCfmtnPicNm("");
setLastCfmtnPicTelNo("");
setLastCfmtnPicDeptNm("");
setLastCfmtnPicJbgdNm("");
};
const handleDialogOpenChange = (nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
};
useEffect(() => {
if (!open) {
resetForm();
}
}, [open]);
const handleConfirm = async () => {
try {
setIsSubmitting(true);
const formData: ApprovalFormData = {
lastCfmtnPicNm: lastCfmtnPicNm.trim(),
lastCfmtnPicTelNo: lastCfmtnPicTelNo.trim(),
lastCfmtnPicDeptNm: lastCfmtnPicDeptNm.trim(),
lastCfmtnPicJbgdNm: lastCfmtnPicJbgdNm.trim(),
};
await onConfirm?.(formData);
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogContent
className="max-w-[56rem]"
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>승인 정보 입력</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-2">
<Label className="text-label">최종 확인 담당자명</Label>
<Input
value={lastCfmtnPicNm}
onChange={(e) => {
setLastCfmtnPicNm(e.target.value);
}}
className="!text-[1.3rem]"
placeholder="담당자명을 입력하세요."
disabled={isSubmitting}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-label">
최종 확인 담당자 전화번호
</Label>
<Input
value={lastCfmtnPicTelNo}
onChange={(e) => {
setLastCfmtnPicTelNo(e.target.value);
}}
className="!text-[1.3rem]"
placeholder="전화번호를 입력하세요."
disabled={isSubmitting}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-label">
최종 확인 담당자 부서명
</Label>
<Input
value={lastCfmtnPicDeptNm}
onChange={(e) => {
setLastCfmtnPicDeptNm(e.target.value);
}}
className="!text-[1.3rem]"
placeholder="부서명을 입력하세요."
disabled={isSubmitting}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-label">
최종 확인 담당자 직급명
</Label>
<Input
value={lastCfmtnPicJbgdNm}
onChange={(e) => {
setLastCfmtnPicJbgdNm(e.target.value);
}}
className="!text-[1.3rem]"
placeholder="직급명을 입력하세요."
disabled={isSubmitting}
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button
type="button"
color="gray"
disabled={isSubmitting}
>
취소
</Button>
</DialogClose>
<Button
type="button"
color="primary"
disabled={isSubmitting}
onClick={() => void handleConfirm()}
>
확인
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
"use client";
import { useEffect, useState } from "react";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
} from "@common/components";
export interface ApprovalFormData {
lastCfmtnPicNm: string;
lastCfmtnPicTelnoCn: string;
lastCfmtnPicDeptNm: string;
lastCfmtnPicJbgdNm: string;
}
export interface ApprovalInputDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm?: (data: ApprovalFormData) => void | Promise<void>;
}
export function ApprovalInputDialog({
open,
onOpenChange,
onConfirm,
}: ApprovalInputDialogProps) {
const [lastCfmtnPicNm, setLastCfmtnPicNm] = useState("");
const [lastCfmtnPicTelnoCn, setLastCfmtnPicTelnoCn] = useState("");
const [lastCfmtnPicDeptNm, setLastCfmtnPicDeptNm] = useState("");
const [lastCfmtnPicJbgdNm, setLastCfmtnPicJbgdNm] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => {
setLastCfmtnPicNm("");
setLastCfmtnPicTelnoCn("");
setLastCfmtnPicDeptNm("");
setLastCfmtnPicJbgdNm("");
};
const handleDialogOpenChange = (nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
};
useEffect(() => {
if (!open) {
resetForm();
}
}, [open]);
const handleConfirm = async () => {
try {
setIsSubmitting(true);
const formData: ApprovalFormData = {
lastCfmtnPicNm: lastCfmtnPicNm.trim(),
lastCfmtnPicTelnoCn: lastCfmtnPicTelnoCn.trim(),
lastCfmtnPicDeptNm: lastCfmtnPicDeptNm.trim(),
lastCfmtnPicJbgdNm: lastCfmtnPicJbgdNm.trim(),
};
await onConfirm?.(formData);
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogContent
className="max-w-[56rem]"
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>승인 정보 입력</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-2">
<Label className="text-label">최종 확인 담당자명</Label>
<Input
value={lastCfmtnPicNm}
onChange={(e) => {
setLastCfmtnPicNm(e.target.value);
}}
className="!text-[1.3rem]"
placeholder="담당자명을 입력하세요."
disabled={isSubmitting}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-label">
최종 확인 담당자 전화번호
</Label>
<Input
value={lastCfmtnPicTelnoCn}
onChange={(e) => {
setLastCfmtnPicTelnoCn(e.target.value);
}}
className="!text-[1.3rem]"
placeholder="전화번호를 입력하세요."
disabled={isSubmitting}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-label">
최종 확인 담당자 부서명
</Label>
<Input
value={lastCfmtnPicDeptNm}
onChange={(e) => {
setLastCfmtnPicDeptNm(e.target.value);
}}
className="!text-[1.3rem]"
placeholder="부서명을 입력하세요."
disabled={isSubmitting}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-label">
최종 확인 담당자 직급명
</Label>
<Input
value={lastCfmtnPicJbgdNm}
onChange={(e) => {
setLastCfmtnPicJbgdNm(e.target.value);
}}
className="!text-[1.3rem]"
placeholder="직급명을 입력하세요."
disabled={isSubmitting}
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button
type="button"
color="gray"
disabled={isSubmitting}
>
취소
</Button>
</DialogClose>
<Button
type="button"
color="primary"
disabled={isSubmitting}
onClick={() => void handleConfirm()}
>
확인
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -141,9 +141,9 @@ const DEFAULT_FILTERS: StatisticsVerificationFilterState = {
const DEFAULT_SUB_FILTERS: StatisticsVerificationSubFilterState = {
...DEFAULT_FILTERS,
laeSbmsnSttsCd: "3",
prcsYnList: "1,2",
rejectCode: "0",
lclgvSbmsnSttsCd: "3",
vrfcSttsCdList: "1,2",
statsVrfcRjtComCd: "0",
pbcpAprvYn: "N",
};
@@ -211,10 +211,10 @@ function StatisticsVerificationReport() {
subFilters.statsCd !== ""
? subFilters.statsCd
: filters.statsCd,
prcsYnList: subFilters.prcsYnList,
rejectCode: subFilters.rejectCode,
vrfcSttsCdList: subFilters.vrfcSttsCdList,
statsVrfcRjtComCd: subFilters.statsVrfcRjtComCd,
pbcpAprvYn: subFilters.pbcpAprvYn,
laeSbmsnSttsCd: subFilters.laeSbmsnSttsCd,
lclgvSbmsnSttsCd: subFilters.lclgvSbmsnSttsCd,
}),
[filters, subFilters],
);
@@ -712,8 +712,8 @@ function StatisticsVerificationReport() {
<div className="flex items-center gap-6">
<FilterCombobox
label="상태"
value={subFilters.prcsYnList}
onChange={handleSubFilterChange("prcsYnList")}
value={subFilters.vrfcSttsCdList}
onChange={handleSubFilterChange("vrfcSttsCdList")}
options={statusOptions ?? []}
/>
<FilterSelect
@@ -724,8 +724,8 @@ function StatisticsVerificationReport() {
/>
<FilterSelect
label="반려상태"
value={subFilters.rejectCode}
onChange={handleSubFilterChange("rejectCode")}
value={subFilters.statsVrfcRjtComCd}
onChange={handleSubFilterChange("statsVrfcRjtComCd")}
options={REJECT_STATUS_OPTIONS}
/>
</div>
@@ -743,7 +743,7 @@ function StatisticsVerificationReport() {
selectedResultRows.length === 0 ||
!selectedResultRows.every(
(row) =>
row.prcsYn === "1" || row.prcsYn === "2",
row.vrfcSttsCd === "1" || row.vrfcSttsCd === "2",
)
}
>
@@ -554,17 +554,20 @@ const DetailComponent = ({
const userInfo = await getUserInfo();
let response;
const vrfcSn = detailInfo?.commonInfo.vrfcSn;
if (vrfcSn == null) {
alert("검증 일련번호가 없습니다.");
return;
}
if (!hasPersistedDetails) {
response =
await generalverificationApiService.RegistVrfcDetail({
vrfcSn: detailInfo?.commonInfo.vrfcSn ?? null,
await generalverificationApiService.RegistVrfcDetail(vrfcSn, {
detailList: rows,
rgtrUuid: userInfo.userUnqId,
});
} else {
response =
await generalverificationApiService.UpdateVrfcDetail({
vrfcSn: detailInfo?.commonInfo.vrfcSn ?? null,
await generalverificationApiService.UpdateVrfcDetail(vrfcSn, {
detailList: rows,
mdfrUuid: userInfo.userUnqId,
});
@@ -9,10 +9,10 @@ import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore } from "@/shared/lib";
import { Form, FormControl,FormField, FormItem } from "@/shared/ui/form";
import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form";
import { CodeSelect } from "@/entities/code";
import { generalverificationApiService } from '@/entities/statistics/api/general-verification-service';
import type { RegistVrfcMasterRequest,UpdateVrfcMasterRequest } from "@/entities/statistics/model/general-verification-interface";
import type { RegistVrfcMasterRequest, UpdateVrfcMasterRequest } from "@/entities/statistics/model/general-verification-interface";
import { type GeneralVerificationMasterSubmitFormData, generalVerificationMasterSubmitSchema } from '@/entities/statistics/model/general-verification-master-submit-schema';
import { UserApi } from "@/entities/user/api/user-service";
import {
@@ -146,9 +146,15 @@ const RegistDialog = memo(
}
};
const handleUpdateVrfcMaster = async (data: GeneralVerificationMasterSubmitFormData) => {
const handleUpdateVrfcMaster = async (
updateVrfcSn: number,
body: UpdateVrfcMasterRequest,
) => {
try {
const response = await generalverificationApiService.UpdateVrfcMaster(data as UpdateVrfcMasterRequest);
const response = await generalverificationApiService.UpdateVrfcMaster(
updateVrfcSn,
body,
);
if (response.status === 'success') {
alert('수정되었습니다.');
@@ -228,7 +234,7 @@ const RegistDialog = memo(
if (mutationBlocked) {
alert(
blockedMessage ??
"입력기간에는 검증항목을 등록하거나 수정할 수 없습니다.",
"입력기간에는 검증항목을 등록하거나 수정할 수 없습니다.",
);
return;
}
@@ -258,8 +264,11 @@ const RegistDialog = memo(
if (await confirm(confirmMessage)) {
const userInfo = await getUserInfo();
if (isVrfcSn) {
const updateData = {
vrfcSn: data.vrfcSn,
if (data.vrfcSn == null) {
alert('수정 대상이 없습니다.');
return;
}
await handleUpdateVrfcMaster(data.vrfcSn, {
vrfcNm: data.vrfcNm,
vrfcClsfCd: data.vrfcClsfCd,
dcptDgt: data.dcptDgt,
@@ -267,10 +276,8 @@ const RegistDialog = memo(
...(data.dcptDgt === 0
? {}
: { dcptPrcsMthCd: data.dcptPrcsMthCd }),
trgtStatsCd: data.trgtStatsCd,
mdfrUuid: userInfo.userUnqId,
};
await handleUpdateVrfcMaster(updateData);
});
} else {
const registData = {
vrfcNm: data.vrfcNm,
@@ -298,14 +305,16 @@ const RegistDialog = memo(
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-w-[60rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>
</DialogHeader>
<DialogTitle>
<div className="pr-8">
{isVrfcSn ?
detailData ? detailData.data.vrfcNm
:
<Skeleton className="h-[14px] rounded-xl w-2/3" />
: "일반 검증 등록"}
</DialogTitle>
</DialogHeader>
</div>
</DialogTitle>
<Form {...form}>
<form
className="space-y-4"
@@ -481,6 +481,11 @@ export type DataGridProps = {
headerHeight?: number;
/** RealGrid `displayOptions.fitStyle` (기본 EVEN). 컬럼별 width 유지 시 NONE */
fitStyle?: GridFitStyle;
/**
* true면 붙여넣기·편집으로 행을 추가하지 않음.
* 검증 결과처럼 조회 행 수가 고정된 그리드에서 사용.
*/
preventRowAppend?: boolean;
};
export type TabDataGridProps = {
@@ -8,6 +8,7 @@ export const doList = [
"4600000000",
"4700000000",
"4800000000",
"5000000000",
];
export const needBsnmFrgnrPoplSmSidoList = [
@@ -49,6 +49,43 @@ export const ManholeStatusInpColumn = {
styleName: "rg-cell-readonly",
footer: { text: "합계" },
},
{
name: "wholMnhlCntSum",
fieldName: "wholMnhlCntSum",
header: { text: "맨홀총계" },
footer: footerInt,
editable: false,
valueType: ValueType.NUMBER,
dataFormat: "#,##0",
styleName: "rg-cell-readonly",
},
{
name: "frlvlMnhlCntSum",
fieldName: "frlvlMnhlCntSum",
header: { text: "주철계" },
footer: footerInt,
editable: false,
valueType: ValueType.NUMBER,
dataFormat: "#,##0",
styleName: "rg-cell-readonly",
},
{
name: "thlvlMnhlCntSum",
fieldName: "thlvlMnhlCntSum",
header: { text: "콘크리트계" },
footer: footerInt,
editable: false,
valueType: ValueType.NUMBER,
dataFormat: "#,##0",
styleName: "rg-cell-readonly",
},
{
name: "picNm",
fieldName: "picNm",
header: { text: "담당자명" },
editable: false,
styleName: "rg-cell-readonly",
},
{
...intCol("wholMnhlCnt", "계", false),
computed: {
@@ -88,6 +125,12 @@ export const ManholeStatusInpColumn = {
},
"admdstNm",
"emphsMngSeNm",
{
name: "adminRefGroup",
hideChildHeaders: false,
header: { text: "행정참고자료" },
items: ["wholMnhlCntSum", "frlvlMnhlCntSum", "thlvlMnhlCntSum", "picNm"],
},
{
name: "mtrlGroup",
hideChildHeaders: false,
@@ -491,6 +491,10 @@ const rawList = [
{ name: 'sggNm', fieldName: 'sggNm', header: { text: '시군구' }, mergeCells: true, sortdisable: true },
{ name: 'admdstNm', fieldName: 'admdstNm', header: { text: '행정구역명' }, mergeCells: true, sortdisable: true },
{ name: 'emphsMngSeNm', fieldName: 'emphsMngSeNm', header: { text: '집중강우\n중점관리\n구역구분' }, mergeCells: true },
{ name: 'wholMnhlCntSum', fieldName: 'wholMnhlCntSum', header: { text: '맨홀총계' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'frlvlMnhlCntSum', fieldName: 'frlvlMnhlCntSum', header: { text: '주철계' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'thlvlMnhlCntSum', fieldName: 'thlvlMnhlCntSum', header: { text: '콘크리트계' }, footer: footerIntArrayByDivision, valueType: ValueType.NUMBER, dataFormat: '#,##0' },
{ name: 'picNm', fieldName: 'picNm', header: { text: '담당자명' } },
{
name: 'displayYear',
fieldName: 'displayYear',
@@ -544,6 +548,12 @@ const rawList = [
],
},
'emphsMngSeNm',
{
name: 'adminRefGroup',
hideChildHeaders: false,
header: { text: '행정참고자료' },
items: ['wholMnhlCntSum', 'frlvlMnhlCntSum', 'thlvlMnhlCntSum', 'picNm'],
},
'displayYear',
{
name: 'mtrlGroup',
@@ -17,12 +17,12 @@ const StatsInputStatusInfoColumnRaw = {
styleName: 'rg-cell-outline-action-btn',
},
{ name: 'chargerNm', fieldName: 'chargerNm', header: { text: '제출자' } },
{ name: 'chargerCttpc', fieldName: 'chargerCttpc', header: { text: '연락처' } },
{ name: 'submitStatusNm', fieldName: 'submitStatusNm', header: { text: '지자체진행상태' } },
{ name: 'submitStatusCd', fieldName: 'submitStatusCd', header: { text: '지자체진행상태코드' }, visible: false },
{ name: 'prvnStatusNm', fieldName: 'prvnStatusNm', header: { text: '진행상태' } },
{ name: 'prvnStatusCd', fieldName: 'prvnStatusCd', header: { text: '진행상태코드' }, visible: false },
{ name: 'lclgvPicNm', fieldName: 'lclgvPicNm', header: { text: '제출자' } },
{ name: 'lclgvPicTelnoCn', fieldName: 'lclgvPicTelnoCn', header: { text: '연락처' } },
{ name: 'lclgvSbmsnSttsNm', fieldName: 'lclgvSbmsnSttsNm', header: { text: '지자체진행상태' } },
{ name: 'lclgvSbmsnSttsCd', fieldName: 'lclgvSbmsnSttsCd', header: { text: '지자체진행상태코드' }, visible: false },
{ name: 'mctpvPicSbmsnSttsNm', fieldName: 'mctpvPicSbmsnSttsNm', header: { text: '진행상태' } },
{ name: 'mctpvPicSbmsnSttsCd', fieldName: 'mctpvPicSbmsnSttsCd', header: { text: '진행상태코드' }, visible: false },
],
columnLayout: [
@@ -30,14 +30,14 @@ const StatsInputStatusInfoColumnRaw = {
'sggNm',
'tabNm',
'reviewHistory',
'chargerNm',
'chargerCttpc',
'submitStatusNm',
'prvnStatusNm',
'lclgvPicNm',
'lclgvPicTelnoCn',
'lclgvSbmsnSttsNm',
'mctpvPicSbmsnSttsNm',
'statsCd',
'sewerBzmnCd',
'submitStatusCd',
'prvnStatusCd'
'lclgvSbmsnSttsCd',
'mctpvPicSbmsnSttsCd',
]
};
@@ -710,6 +710,10 @@ const rawSewagePipeTotList = [
{ name: "sggNm", fieldName: "sggNm", header: { text: "시군구" }, editable: false },
{ name: "admdstNm", fieldName: "admdstNm", header: { text: "행정구역명" }, editable: false },
{ name: "emphsMngSeNm", fieldName: "emphsMngSeNm", header: { text: "집중강우\n중점관리\n구역구분" }, editable: false },
{ name: "wholMnhlCntSum", fieldName: "wholMnhlCntSum", header: { text: "맨홀총계" }, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "frlvlMnhlCntSum", fieldName: "frlvlMnhlCntSum", header: { text: "주철계" }, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "thlvlMnhlCntSum", fieldName: "thlvlMnhlCntSum", header: { text: "콘크리트계" }, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "picNm", fieldName: "picNm", header: { text: "담당자명" }, editable: false },
{ name: "wholMnhlCnt", fieldName: "wholMnhlCnt", header: { text: "계" }, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "frlvlMnhlCnt", fieldName: "frlvlMnhlCnt", header: { text: "주철" }, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
{ name: "sclvlMnhlCnt", fieldName: "sclvlMnhlCnt", header: { text: "주철(조화)" }, valueType: ValueType.NUMBER, dataFormat: "#,##0", editable: false },
@@ -720,6 +724,11 @@ const rawSewagePipeTotList = [
columnLayout: [
{ name: "addrGroup", header: { text: "행정구역" }, items: ["ctpvNm", "sggNm", "admdstNm"] },
"emphsMngSeNm",
{
name: "adminRefGroup",
header: { text: "행정참고자료" },
items: ["wholMnhlCntSum", "frlvlMnhlCntSum", "thlvlMnhlCntSum", "picNm"],
},
{
name: "mtrlGroup",
header: { text: "맨홀뚜껑 재질(개)" },
+85 -3
View File
@@ -1,4 +1,4 @@
import type { GridView } from "realgrid";
import { type GridView, ValueType } from "realgrid";
import type {
ColumnConfig,
@@ -124,6 +124,82 @@ export function getNonEditableColumnFieldNames(
return fieldNames;
}
type ExcelImportCell = {
t?: string;
v?: unknown;
w?: string;
};
/** 엑셀 셀 → 그리드 값. NUMBER 컬럼만 숫자화하고, 코드(01)는 문자열로 유지 */
export function parseExcelImportCellValue(
cell: ExcelImportCell | undefined,
valueType?: ColumnConfig["valueType"],
): string | number {
if (!cell || cell.v === undefined || cell.v === null || cell.v === "") {
return "";
}
if (valueType === ValueType.NUMBER) {
if (typeof cell.v === "number" && !Number.isNaN(cell.v)) {
return cell.v;
}
const stringValue = String(cell.v);
const numericValue = Number(stringValue.replace(/,/g, ""));
if (!Number.isNaN(numericValue) && stringValue.trim() !== "") {
return numericValue;
}
return stringValue;
}
const formatted = typeof cell.w === "string" ? cell.w.trim() : "";
if (/^0\d+$/.test(formatted)) {
return formatted;
}
return String(cell.v);
}
function collectZeroPaddedCodeWidths(
rows: Record<string, string | number>[],
): Map<string, number> {
const widthByField = new Map<string, number>();
for (const row of rows) {
for (const [field, value] of Object.entries(row)) {
const s = String(value ?? "").trim();
if (!/^0\d+$/.test(s)) continue;
widthByField.set(field, Math.max(widthByField.get(field) ?? 0, s.length));
}
}
return widthByField;
}
function restoreImportedCodeLeadingZeros(
importedRow: Record<string, string | number>,
existingRow: Record<string, string | number> | undefined,
codeWidthByField: Map<string, number>,
): Record<string, string | number> {
const next: Record<string, string | number> = { ...importedRow };
for (const [field, value] of Object.entries(next)) {
if (value === "" || value == null) continue;
if (typeof value === "number" && !Number.isInteger(value)) continue;
const importedStr = String(value).trim();
if (!/^\d+$/.test(importedStr)) continue;
const existingStr =
existingRow != null ? String(existingRow[field] ?? "").trim() : "";
let width = codeWidthByField.get(field);
if (!width && /^0\d+$/.test(existingStr)) {
width = existingStr.length;
}
if (!width && field.endsWith("Cd") && importedStr.length <= 2) {
width = 2;
}
if (width && importedStr.length < width) {
next[field] = importedStr.padStart(width, "0");
}
}
return next;
}
/** 엑셀 import 결과를 기존 행과 병합 — preserveFieldNames는 기존 그리드 값 유지 */
export function mergeExcelImportWithExistingRows(
existingRows: Record<string, string | number>[],
@@ -134,14 +210,20 @@ export function mergeExcelImportWithExistingRows(
options?.preserveFieldNames && options.preserveFieldNames.length > 0
? new Set(options.preserveFieldNames)
: null;
const codeWidthByField = collectZeroPaddedCodeWidths(existingRows);
return importedRows.map((importedRow, index) => {
const existingRow = existingRows[index];
if (!existingRow) return { ...importedRow };
const normalizedImported = restoreImportedCodeLeadingZeros(
importedRow,
existingRow,
codeWidthByField,
);
if (!existingRow) return normalizedImported;
const merged: Record<string, string | number> = {
...existingRow,
...importedRow,
...normalizedImported,
};
if (preserveSet) {
for (const fieldName of preserveSet) {
@@ -0,0 +1,23 @@
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type {
ImrRequest,
ImrResponse,
rollbackStatusRequest,
} from "@/entities/statistics/model/statistics-interface";
/** 제출 직후 IMR 요청이 실패하면 검증 이전 상태로 롤백 */
export async function callImrServiceWithRollbackOnFailure(
imrParam: ImrRequest,
rollbackParam: rollbackStatusRequest,
): Promise<ImrResponse> {
try {
return await statisticsApi.callImrService(imrParam);
} catch (error) {
try {
await statisticsApi.rollbackStatus(rollbackParam);
} catch {
// 롤백 실패는 IMR 실패를 가리지 않음
}
throw error;
}
}
@@ -1,6 +1,9 @@
import type { StatisticsInputCommonInfoBase } from "@/entities/statistics/model/statistics-interface";
import type { VerificationResultLikeMap } from "./verification-result-display";
import {
normalizeVerificationResultMap,
type VerificationResultLikeMap,
} from "./verification-result-display";
export function isVerificationFailedFromCommonInfo(
commonInfo: Pick<StatisticsInputCommonInfoBase, "isVerificationFailed"> | null | undefined,
@@ -28,6 +31,9 @@ export function patchFooterVerificationByTab(
| undefined,
): Map<string, VerificationResultLikeMap | null> {
const next = new Map(prev);
next.set(tabKey, commonInfo?.verificationResult ?? null);
next.set(
tabKey,
normalizeVerificationResultMap(commonInfo?.verificationResult) ?? null,
);
return next;
}
@@ -13,6 +13,49 @@ export type VerificationResultLikeMap = Record<
VerificationResultLikeItem[]
>;
/** 입력 조회 verificationResult의 기준값/입력값이 null이면 "-" */
export function displayVerificationCn(
value: string | null | undefined,
): string {
if (value == null || String(value).trim() === "") return "-";
return String(value);
}
export function normalizeVerificationResultItem<T extends VerificationResultLikeItem>(
item: T,
): T {
return {
...item,
nowCn: displayVerificationCn(item.nowCn ?? item.nowVlCn),
cmprCn: displayVerificationCn(item.cmprCn ?? item.compVlCn),
};
}
export function normalizeVerificationResultMap<T extends VerificationResultLikeMap>(
result: T | null | undefined,
): T | undefined {
if (!result || typeof result !== "object") return undefined;
const next = {} as T;
for (const [key, items] of Object.entries(result)) {
(next as VerificationResultLikeMap)[key] = Array.isArray(items)
? items.map((item) => normalizeVerificationResultItem(item))
: [];
}
return next;
}
/** 조회 행/객체에 붙은 verificationResult의 cmprCn·nowCn null을 "-"로 바꿈 */
export function withNormalizedVerificationResult<T extends object>(item: T): T {
if (!("verificationResult" in item) || item.verificationResult == null) {
return item;
}
const verificationResult = normalizeVerificationResultMap(
item.verificationResult as VerificationResultLikeMap,
);
if (!verificationResult) return item;
return { ...item, verificationResult };
}
/** 검증 오류 배열 → RealGrid styleName */
export function getVerificationStyleName(
errors: VerificationResultLikeItem[] | null | undefined,
@@ -37,8 +80,8 @@ export function formatVerificationTooltip(
: e.vrfcCd === "NV"
? "일반검증 - "
: "모집단 검증 - ";
const comp = e.cmprCn ?? e.compVlCn ?? "";
const now = e.nowCn ?? e.nowVlCn ?? "";
const comp = displayVerificationCn(e.cmprCn ?? e.compVlCn);
const now = displayVerificationCn(e.nowCn ?? e.nowVlCn);
return `${prefix}${e.rsnExpln}\n(검증 기준값: ${comp} / 현재 입력값: ${now})`;
})
.filter(Boolean)
@@ -56,10 +56,11 @@ export class GeneralverificationApiService implements GeneralverificationService
}
public async UpdateVrfcMaster(
vrfcSn: number,
param: UpdateVrfcMasterRequest,
): Promise<UpdateVrfcMasterResponse> {
const response = await stClient.put<UpdateVrfcMasterResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/masters/${param.vrfcSn}`,
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/masters/${vrfcSn}`,
param,
);
return response.data;
@@ -127,20 +128,22 @@ export class GeneralverificationApiService implements GeneralverificationService
}
public async RegistVrfcDetail(
vrfcSn: number,
param: VerifyDetailRegistRequest,
): Promise<RegistVrfcMasterResponse> {
const response = await stClient.post<RegistVrfcMasterResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/${param.vrfcSn}/details`,
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/${vrfcSn}/details`,
param,
);
return response.data;
}
public async UpdateVrfcDetail(
vrfcSn: number,
param: VerifyDetailUpdateRequest,
): Promise<RegistVrfcMasterResponse> {
const response = await stClient.put<RegistVrfcMasterResponse>(
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/${param.vrfcSn}/details`,
`${ENDPOINT.ST_SERVICE.INPUTVERIFY}/${vrfcSn}/details`,
param,
);
return response.data;

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