Compare commits

...
57 Commits
Author SHA1 Message Date
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
jcchoi 2d77813dab Merge remote-tracking branch 'origin/iipbs/st/dev'
Deploy Frontend / deploy (push) Successful in 4m31s
2026-10-14 17:36:08 +09:00
rsone001 d22a255143 [ST] - fix: type 오류 수정 2026-10-14 17:35:32 +09:00
jcchoi 6703bbeeec Merge remote-tracking branch 'origin/iipbs/st/dev' 2026-10-14 17:25:16 +09:00
rsone001 d444c591ec [ST] - flx: input 표준화에 맞춰 소스 수정 2026-10-14 17:05:45 +09:00
rsone001 f7ddb6cae0 Merge remote-tracking branch 'origin/feature/sein' into iipbs/st/dev 2026-10-14 15:39:21 +09:00
yangsein 7ffc02e887 refactor: 대시보드 통계처리현황 표준화 수정 2026-10-14 15:17:25 +09:00
yangsein cbacb27bb1 refactor: 행정동 갱신 표준화 수정 2026-10-14 15:16:40 +09:00
100JM 03a08a320a [ST] - fix: DB 표준화에 따른 소스 수정 2026-10-14 14:24:03 +09:00
jcchoi 45223be64a 포탈 수정
Deploy Frontend / deploy (push) Successful in 3m45s
2026-10-13 11:44:05 +09:00
jcchoi b22dfdfa4c 웹기안기 도메인 적용 2026-10-13 11:22:08 +09:00
jcchoi ce219f0e40 https 적용
Deploy Frontend / deploy (push) Successful in 4m5s
2026-10-13 10:53:39 +09:00
jcchoi a0f26d38b2 행정지원 리스트 디자인 cs 와 통일
Deploy Frontend / deploy (push) Successful in 3m42s
2026-10-12 19:19:13 +09:00
jcchoi 4efdcfe0fc 공통 테마 적용
Deploy Frontend / deploy (push) Successful in 7m55s
2026-10-12 18:47:26 +09:00
jcchoi 806ccf950f 행정지원 대시보드 테마 적용 2026-10-12 18:38:00 +09:00
jcchoi b96b78fa0b skin 및 레이아웃 작업 2026-10-12 17:22:37 +09:00
jcchoi f816bf005e 파일보안 간격 조절
Deploy Frontend / deploy (push) Successful in 9m15s
2026-10-12 11:54:55 +09:00
jcchoi 791ad6ce37 파일보안 줄바꿈 2026-10-12 11:50:50 +09:00
jcchoi 4bc802cee2 파일보완 내용 자동 줄빠굼 2026-10-12 10:55:19 +09:00
jcchoi 4662c8c896 선형 구조 수정 2026-10-12 10:50:09 +09:00
jcchoi 7058560a03 메뉴 배치 수정 2026-10-12 10:09:52 +09:00
jcchoi 79a5999ad5 콘텐츠의 선형 구조 2026-10-12 10:09:39 +09:00
jcchoi 1aeded5da2 페이지 타이틀 2026-10-12 10:09:16 +09:00
jcchoi e8d1d882c2 페이지 타이틀 수정 2026-10-12 10:01:22 +09:00
jcchoi 39dca810a5 css 오류 수정
Deploy Frontend / deploy (push) Successful in 7m50s
2026-10-11 17:58:09 +09:00
jcchoi 97f94688f1 Html 문법 오류
Deploy Frontend / deploy (push) Successful in 7m57s
2026-10-11 17:36:43 +09:00
jcchoi 0b502cbc99 접근성 체크 박스 (적절한 링크 텍스트)
Deploy Frontend / deploy (push) Successful in 7m28s
2026-10-11 17:09:37 +09:00
jcchoi a8eb8f9ab1 로고 alt 작업
Deploy Frontend / deploy (push) Successful in 7m43s
2026-10-11 16:16:14 +09:00
jcchoi 6471f4365a 접근성 2026-10-11 16:14:08 +09:00
jcchoi 48999f5699 웹 접근성 조치
Deploy Frontend / deploy (push) Successful in 9m6s
2026-10-11 15:32:56 +09:00
jcchoi 5ad64a596b 담당자 정보 caption 수정 2026-10-11 14:27:59 +09:00
jcchoi df60bd0b40 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend
Deploy Frontend / deploy (push) Successful in 7m13s
2026-10-11 13:27:16 +09:00
jcchoi 41e6f1cf48 Table Cpation 처리 2026-10-11 13:16:56 +09:00
jcchoi 0802ba191a DataTable Caption 추가 2026-10-11 12:58:04 +09:00
mind-one005 ad57ed241d Merge branch 'iipbs/sn/dev' 2026-10-08 13:29:37 +09:00
jcchoi f17cb420e3 LNB GNB 탭 포커스 2026-10-07 21:42:43 +09:00
mind-one005 5e43d69d8e sn: null 인경우, 공백으로 보이도록 2026-10-07 15:05:40 +09:00
mind-one005 9971e512c8 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-10-06 13:15:48 +09:00
mind-one005 0c77ed9bba sn: 도시침수예방사업 사업명 필수 2026-10-06 13:15:32 +09:00
kiminchan e759c96ade Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-10-06 13:06:20 +09:00
kiminchan f602b75268 페이지별 도움말 조회를 위한 메뉴 코드 전달 누락 수정 2026-10-06 13:06:01 +09:00
mind-one005 11d0378060 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-10-02 21:26:43 +09:00
mind-one005 8ed4f959cb sn: 확률강우량 모달 추가 2026-10-02 18:12:03 +09:00
mind-one005 e5f7778861 sn: 확률강우량 모달 추가 2026-10-02 18:07:00 +09:00
kiminchan 2d086a05b2 조사사업·준설 등록 필수 표시 및 숫자 입력 제한 보완 2026-10-02 17:11:04 +09:00
kiminchan 6ef4603a7c 지도 대상 선택 이동 시 지역·시설 검색조건 초기화 2026-10-02 16:51:47 +09:00
439 changed files with 10759 additions and 8698 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ import { Providers } from "@/app/providers";
import "@/app/styles/globals.css"; import "@/app/styles/globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "행정지원시스템", title: "유역하수도통합플랫폼 Hasuon",
description: "행정지원시스템", description: "행정지원시스템",
}; };
+1 -1
View File
@@ -1,7 +1,7 @@
import "@/app/styles/globals.css"; import "@/app/styles/globals.css";
export const metadata = { export const metadata = {
title: "디지털 행정지원 시스템", title: "유역하수도통합플랫폼 Hasuon",
}; };
export default function AuthLayout({ children }: { children: React.ReactNode }) { export default function AuthLayout({ children }: { children: React.ReactNode }) {
+1 -1
View File
@@ -6,7 +6,7 @@ import { Providers } from "@/app/providers";
import "@/app/styles/globals.css"; import "@/app/styles/globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "디지털 행정지원 시스템", title: "유역하수도통합플랫폼 Hasuon",
description: "디지털 행정지원 시스템", description: "디지털 행정지원 시스템",
}; };
+1 -1
View File
@@ -4,7 +4,7 @@ import Image from "next/image";
import "@/app/styles/globals.css"; import "@/app/styles/globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "디지털 행정지원 시스템", title: "유역하수도통합플랫폼 Hasuon",
description: "페이지가 존재하지 않습니다. 관리자에게 문의하여 주십시오.", description: "페이지가 존재하지 않습니다. 관리자에게 문의하여 주십시오.",
}; };
+1 -1
View File
@@ -3,7 +3,7 @@ import type { NextConfig } from "next";
const subDomain = process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000"; const subDomain = process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000";
const webhwpCtrlBase = ( const webhwpCtrlBase = (
process.env.NEXT_PUBLIC_WEBHYP_CTRL_URL || "http://hwp.onasys.co.kr/webhwpctrl" process.env.NEXT_PUBLIC_WEBHYP_CTRL_URL || "https://hwp.onasys.co.kr/webhwpctrl"
).replace(/\/$/, ""); ).replace(/\/$/, "");
const webhwpAssetPrefixes = ["js", "hwpsdk", "dist", "commonFrame", "wasm"] as const; const webhwpAssetPrefixes = ["js", "hwpsdk", "dist", "commonFrame", "wasm"] as const;
+44 -27
View File
@@ -5,18 +5,6 @@
<meta http-equiv="X-UA-Compatible" content="IE=Edge" /> <meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>웹한글 기안기 브리지</title> <title>웹한글 기안기 브리지</title>
<script
type="text/javascript"
src="http://hwp.onasys.co.kr/webhwpctrl/js/hwpctrlapp/utils/util.js"
></script>
<script
type="text/javascript"
src="http://hwp.onasys.co.kr/webhwpctrl/js/hwpctrlapp/hwpCtrlApp.js"
></script>
<script
type="text/javascript"
src="http://hwp.onasys.co.kr/webhwpctrl/js/webhwpctrl.js"
></script>
<style> <style>
html, html,
body, body,
@@ -39,27 +27,56 @@
<div id="hwpctrl"></div> <div id="hwpctrl"></div>
<script type="text/javascript"> <script type="text/javascript">
(function () { (function () {
// baseurl: 부모(HangulGiangi)가 NEXT_PUBLIC_WEBHYP_CTRL_URL 값으로 전달.
// 없으면 same-origin 프록시 경로(/webhwpctrl/ → next.config rewrites)를 사용한다.
var params = new URLSearchParams(window.location.search); var params = new URLSearchParams(window.location.search);
var baseUrl = params.get("baseurl") || "http://hwp.onasys.co.kr/webhwpctrl"; var baseUrl = params.get("baseurl") || new URL("./", window.location.href).href;
if (baseUrl.charAt(baseUrl.length - 1) !== "/") { if (baseUrl.charAt(baseUrl.length - 1) !== "/") {
baseUrl += "/"; baseUrl += "/";
} }
var HwpCtrl = BuildWebHwpCtrl( var scripts = [
"hwpctrl", "js/hwpctrlapp/utils/util.js",
baseUrl, "js/hwpctrlapp/hwpCtrlApp.js",
function () { "js/webhwpctrl.js",
try { ];
window.parent.postMessage({ type: "hangulgiangi-ready" }, "*");
} catch (e) {
console.warn(e);
}
},
2,
);
// 부모(HangulGiangi)가 contentWindow.HwpCtrl 로 접근 function loadScript(index) {
window.HwpCtrl = HwpCtrl; 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> </script>
</body> </body>
+1
View File
@@ -36,6 +36,7 @@ export function BaseLayoutWrapper({ children }: BaseLayoutWrapperProps) {
authService={authService} authService={authService}
currentPath={pathname ?? ""} currentPath={pathname ?? ""}
handleMenuClick={handleMenuClick} handleMenuClick={handleMenuClick}
sidebarTriggerPlacement="lnb"
> >
<PageLayout>{children}</PageLayout> <PageLayout>{children}</PageLayout>
</BaseLayout> </BaseLayout>
+13 -2
View File
@@ -16,9 +16,20 @@ interface CodeSelectProps extends React.ComponentPropsWithoutRef<typeof Select>
useYn?: string; useYn?: string;
placeholder?: string; placeholder?: string;
className?: string; className?: string;
/** 레이블(label htmlFor) 연결용 트리거 id */
id?: string;
title?: string;
} }
function CodeSelect({ upCd, useYn, placeholder = "선택", className, ...props }: CodeSelectProps) { function CodeSelect({
upCd,
useYn,
placeholder = "선택",
className,
id,
title,
...props
}: CodeSelectProps) {
const listQuery = useCodeList(useYn ? undefined : upCd); const listQuery = useCodeList(useYn ? undefined : upCd);
const listByUseYnQuery = useCodeListByUseYn(useYn ? upCd : undefined, useYn ?? ""); const listByUseYnQuery = useCodeListByUseYn(useYn ? upCd : undefined, useYn ?? "");
@@ -27,7 +38,7 @@ function CodeSelect({ upCd, useYn, placeholder = "선택", className, ...props }
return ( return (
<Select {...props} disabled={props.disabled || isLoading}> <Select {...props} disabled={props.disabled || isLoading}>
<SelectTrigger className={cn("w-[12rem]", className)}> <SelectTrigger id={id} title={title} className={cn("w-[12rem]", className)}>
<SelectValue placeholder={placeholder} /> <SelectValue placeholder={placeholder} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
+5 -1
View File
@@ -13,12 +13,16 @@ import type {
NowDmgMtncGnsvySearchDto, NowDmgMtncGnsvySearchDto,
} from "../model/dc-interface"; } from "../model/dc-interface";
/** 빈 문자열 파라미터를 보내면 서버 오류가 나므로 값이 있는 항목만 전송한다. */
async function getNowDmgMtncGnsvy( async function getNowDmgMtncGnsvy(
params: NowDmgMtncGnsvySearchDto, params: NowDmgMtncGnsvySearchDto,
): Promise<NowDmgMtncGnsvyResponseDto[]> { ): Promise<NowDmgMtncGnsvyResponseDto[]> {
const filledParams = Object.fromEntries(
Object.entries(params).filter(([, value]) => value !== undefined && value !== ""),
);
const response = await client.get<NowDmgMtncGnsvyResponseDto[]>( const response = await client.get<NowDmgMtncGnsvyResponseDto[]>(
`${ENDPOINT.DC_SERVICE}/nowDmgMtncGnsvy`, `${ENDPOINT.DC_SERVICE}/nowDmgMtncGnsvy`,
{ params }, { params: filledParams },
); );
return response.data; return response.data;
} }
@@ -76,7 +76,7 @@ function AprvrPickerDialog({ open, onOpenChange, atrzSeComCd, onSelect }: AprvrP
배치된 검토인력이 없습니다. 배치된 검토인력이 없습니다.
</p> </p>
) : ( ) : (
<Table caption="결재자 선택 목록으로 성명, 부서, 직위, 직급, 선택 정보 제공" className="w-full"> <Table caption="검토인력 선택 목록으로 성명, 부서, 직위, 직급, 선택 정보 제공" className="w-full">
<colgroup> <colgroup>
<col width="25%" /> <col width="25%" />
<col width="25%" /> <col width="25%" />
@@ -162,7 +162,7 @@ function ElaprAplnPickerGrid({
</p> </p>
) : ( ) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<Table caption="결재선 선택 목록으로 결재순서, 결재구분, 결재자 정보 제공" className="w-full"> <Table caption="결재선 관리 목록으로 결재순서, 결재구분, 결재자, 관리 정보 제공" className="w-full">
<colgroup> <colgroup>
<col width="10%" /> <col width="10%" />
<col width="10%" /> <col width="10%" />
@@ -315,7 +315,7 @@ function ElaprApprovalDialog({
collisionDetection={closestCenter} collisionDetection={closestCenter}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<Table caption="결재 진행 현황 목록으로 결재순서, 결재구분, 결재자, 전결자 지정, 결재상태, 관리 정보 제공" className="w-full"> <Table caption="결재 진행 단계 목록으로 결재순서, 결재구분, 결재자, 전결자 지정, 결재상태, 관리 정보 제공" className="w-full">
<colgroup> <colgroup>
<col width="8%" /> <col width="8%" />
<col width="8%" /> <col width="8%" />
@@ -66,7 +66,7 @@ function ElaprTmpltSelectTable({
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}, },
dataTableOptions: { dataTableOptions: {
caption: "결재서식 선택 목록", caption: "전자결재서식 목록으로 결재서식명 정보 제공",
onRowClick: (row) => { onRowClick: (row) => {
setRowSelection({ [row.elaprTmpltId]: true }); setRowSelection({ [row.elaprTmpltId]: true });
onSelectTmplt(row); onSelectTmplt(row);
@@ -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 { fnrsCnsltnRvwOpnnService } from "./api/fnrs-cnsltn-rvw-opnn-service";
export { fnrsCnsltnKeys } from "./api/queries"; export { fnrsCnsltnMngService } from "./api/fnrs-cnsltn-service";
export { export { fnrsCnsltnKeys } from "./api/queries";
useChangePrgrsSttsComCd, export {
useDeleteFnrsCnsltnMng, useChangePrgrsSttsComCd,
useFnrsCnsltnFileVrfcResults, useDeleteFnrsCnsltnMng,
useFnrsCnsltnMng, useFnrsCnsltnFileVrfcResults,
useFnrsCnsltnMngPage, useFnrsCnsltnMng,
useRequestActnReviewFnrsCnsltnMng, useFnrsCnsltnMngPage,
useRequestExamineFnrsCnsltnMng, useRequestActnReviewFnrsCnsltnMng,
useSaveApplyFnrsCnsltnMng, useRequestExamineFnrsCnsltnMng,
useSaveFnrsCnsltnMng, useSaveApplyFnrsCnsltnMng,
useSubmitReqSupplementFnrsCnsltnMng, useSaveFnrsCnsltnMng,
useSubmitSupplementFnrsCnsltnMng, useSubmitReqSupplementFnrsCnsltnMng,
useTechReviewFnrsCnsltnMng, useSubmitSupplementFnrsCnsltnMng,
useUpdateFnrsCnsltnMng, useTechReviewFnrsCnsltnMng,
} from "./api/queries"; useUpdateFnrsCnsltnMng,
export type { } from "./api/queries";
BizCstItem, export type {
BzScaleItem, BizCstItem,
CklstResultItem, BzScaleItem,
EsntlAtchFileItem, CklstResultItem,
FnrsCnsltnApplySaveRequestDto, EsntlAtchFileItem,
FnrsCnsltnMngSaveRequestDto, FnrsCnsltnApplySaveRequestDto,
FnrsCnsltnRvwOpnnSaveRequestDto, FnrsCnsltnMngSaveRequestDto,
FnrsCnsltnSplmntDmndItem, FnrsCnsltnRvwOpnnSaveRequestDto,
FnrsSharItem, FnrsCnsltnSplmntDmndItem,
} from "./model/fnrs-cnsltn-apply-payload-schema"; FnrsSharItem,
export { } from "./model/fnrs-cnsltn-apply-payload-schema";
fnrsCnsltnApplySaveSchema, export {
fnrsCnsltnBizCstItemSchema, fnrsCnsltnApplySaveSchema,
fnrsCnsltnBzScaleItemSchema, fnrsCnsltnBizCstItemSchema,
fnrsCnsltnCklstResultItemSchema, fnrsCnsltnBzScaleItemSchema,
fnrsCnsltnEsntlAtchFileItemSchema, fnrsCnsltnCklstResultItemSchema,
fnrsCnsltnMngSaveSchema, fnrsCnsltnEsntlAtchFileItemSchema,
fnrsCnsltnRvwOpnnSaveSchema, fnrsCnsltnMngSaveSchema,
fnrsCnsltnSplmntDmndItemSchema, fnrsCnsltnRvwOpnnSaveSchema,
fnrsSharItemSchema, fnrsCnsltnSplmntDmndItemSchema,
} from "./model/fnrs-cnsltn-apply-payload-schema"; fnrsSharItemSchema,
export type { } from "./model/fnrs-cnsltn-apply-payload-schema";
AtflMngClientResponseDto, export type {
BizCstTreeDto, AtflMngClientResponseDto,
BizSclTreeDto, BizCstTreeDto,
CklstRvwRsltTreeDto, BizSclTreeDto,
DocPrgrsUpdateRequestDto, CklstRvwRsltTreeDto,
ElaprDocPrgrsClientResponseDto, DocPrgrsUpdateRequestDto,
EsntlAtchFileMngTreeResponseDto, ElaprDocPrgrsClientResponseDto,
FnrsCnsltnFileVrfcRsltResponseDto, EsntlAtchFileMngTreeResponseDto,
FnrsCnsltnMngDetResponseDto, FnrsCnsltnFileVrfcRsltResponseDto,
FnrsCnsltnMngResponseDto, FnrsCnsltnMngDetResponseDto,
FnrsCnsltnMngSearchDto, FnrsCnsltnMngResponseDto,
FnrsCnsltnMngService, FnrsCnsltnMngSearchDto,
FnrsCnsltnMngUpdateRequestDto, FnrsCnsltnMngService,
FnrsCnsltnRvwOpnnResponseDto, FnrsCnsltnMngUpdateRequestDto,
FnrsCnsltnSplmntDmndResponseDto, FnrsCnsltnRvwOpnnResponseDto,
RagIndexingInfoDto, FnrsCnsltnSplmntDmndResponseDto,
} from "./model/fnrs-cnsltn-interface"; RagIndexingInfoDto,
export type { } from "./model/fnrs-cnsltn-interface";
EnvoActnReviewReqDto, export type {
FnrsExamineRequestDto, EnvoActnReviewReqDto,
} from "./model/fnrs-cnsltn-payload-schema"; FnrsExamineRequestDto,
export { } from "./model/fnrs-cnsltn-payload-schema";
envoActnReviewReqSchema, export {
fnrsExamineRequestSchema, envoActnReviewReqSchema,
} from "./model/fnrs-cnsltn-payload-schema"; 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 { instlSncnRegionService, instlSncnService } from "./api/instl-sncn-service";
export { export {
instlSncnKeys, 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 { sewerBscPlanKeys } from "./api/queries";
export { export {
useDeleteSewerBscPlan, useDeleteSewerBscPlan,
useSaveApplySewerBscPlan, useSaveApplySewerBscPlan,
useSaveSewerBscPlan, useSaveSewerBscPlan,
useSewerBscPlan, useSewerBscPlan,
useSewerBscPlanDetails, useSewerBscPlanDetails,
useSewerBscPlanPage, useSewerBscPlanPage,
useSubmitReqSupplementSewerBscPlan, useSubmitReqSupplementSewerBscPlan,
useSubmitSupplementSewerBscPlan, useSubmitSupplementSewerBscPlan,
useTechReviewSewerBscPlan, useTechReviewSewerBscPlan,
useUpdateSewerBscPlan, useUpdateSewerBscPlan,
} from "./api/queries"; } from "./api/queries";
export { sewerBscPlanService } from "./api/sewer-bsc-plan-service"; export { sewerBscPlanRvwOpnnService } from "./api/sewer-bsc-plan-rvw-opnn-service";
export type { export { sewerBscPlanService } from "./api/sewer-bsc-plan-service";
CklstResultItem, export type {
EsntlAtchFileItem, CklstResultItem,
SewerBscPlanApplySaveRequestDto, EsntlAtchFileItem,
SewerBscPlanBizCstItem, SewerBscPlanApplySaveRequestDto,
SewerBscPlanDtlSaveRequestDto, SewerBscPlanBizCstItem,
SewerBscPlanFnrsCstItem, SewerBscPlanDtlSaveRequestDto,
SewerBscPlanOtlnItem, SewerBscPlanFnrsCstItem,
SewerBscPlanRvwOpnnItem, SewerBscPlanOtlnItem,
SewerBscPlanSaveRequestDto, SewerBscPlanRvwOpnnItem,
SewerBscPlanSplmntDmndItem, SewerBscPlanSaveRequestDto,
SewerBscPlanTmplntDtlItem, SewerBscPlanSplmntDmndItem,
SewerBscPlanTmplntItem, SewerBscPlanTmplntDtlItem,
SewerBscPlanTmplntTmprStrgItem, SewerBscPlanTmplntItem,
} from "./model/sewer-bsc-plan-apply-payload-schema"; SewerBscPlanTmplntTmprStrgItem,
export { } from "./model/sewer-bsc-plan-apply-payload-schema";
sewerBscPlanApplySaveSchema, export {
sewerBscPlanBizCstItemSchema, sewerBscPlanApplySaveSchema,
sewerBscPlanCklstResultItemSchema, sewerBscPlanBizCstItemSchema,
sewerBscPlanDtlSaveSchema, sewerBscPlanCklstResultItemSchema,
sewerBscPlanEsntlAtchFileItemSchema, sewerBscPlanDtlSaveSchema,
sewerBscPlanFnrsCstItemSchema, sewerBscPlanEsntlAtchFileItemSchema,
sewerBscPlanOtlnItemSchema, sewerBscPlanFnrsCstItemSchema,
sewerBscPlanRvwOpnnItemSchema, sewerBscPlanOtlnItemSchema,
sewerBscPlanSaveSchema, sewerBscPlanRvwOpnnItemSchema,
sewerBscPlanSplmntDmndItemSchema, sewerBscPlanSaveSchema,
sewerBscPlanTmplntDtlItemSchema, sewerBscPlanSplmntDmndItemSchema,
sewerBscPlanTmplntItemSchema, sewerBscPlanTmplntDtlItemSchema,
sewerBscPlanTmplntTmprStrgItemSchema, sewerBscPlanTmplntItemSchema,
} from "./model/sewer-bsc-plan-apply-payload-schema"; sewerBscPlanTmplntTmprStrgItemSchema,
export type { } from "./model/sewer-bsc-plan-apply-payload-schema";
AtflMngClientResponseDto, export type {
CklstRvwRsltTreeDto, AtflMngClientResponseDto,
EsntlAtchFileMngTreeResponseDto, CklstRvwRsltTreeDto,
SewerBscPlanApplySaveResponseDto, EsntlAtchFileMngTreeResponseDto,
SewerBscPlanBizCstResponseDto, SewerBscPlanApplySaveResponseDto,
SewerBscPlanDetResponseDto, SewerBscPlanBizCstResponseDto,
SewerBscPlanDtlResponseDto, SewerBscPlanDetResponseDto,
SewerBscPlanFnrsCstResponseDto, SewerBscPlanDtlResponseDto,
SewerBscPlanOtlnResponseDto, SewerBscPlanFnrsCstResponseDto,
SewerBscPlanResponseDto, SewerBscPlanOtlnResponseDto,
SewerBscPlanRvwOpnnResponseDto, SewerBscPlanResponseDto,
SewerBscPlanSearchRequestDto, SewerBscPlanRvwOpnnResponseDto,
SewerBscPlanService, SewerBscPlanSearchRequestDto,
SewerBscPlanSplmntDmndResponseDto, SewerBscPlanService,
SewerBscPlanTmplntDtlResponseDto, SewerBscPlanSplmntDmndResponseDto,
SewerBscPlanTmplntResponseDto, SewerBscPlanTmplntDtlResponseDto,
SewerBscPlanTmplntTmprStrgResponseDto, SewerBscPlanTmplntResponseDto,
SewerBscPlanUpdateRequestDto, SewerBscPlanTmplntTmprStrgResponseDto,
} from "./model/sewer-bsc-plan-interface"; SewerBscPlanUpdateRequestDto,
} from "./model/sewer-bsc-plan-interface";
@@ -73,7 +73,7 @@ function AprvrPickerDialog({
배치된 검토인력이 없습니다. 배치된 검토인력이 없습니다.
</p> </p>
) : ( ) : (
<Table caption="검토인력 목록으로 성명, 부서, 직위, 직급, 선택 정보 제공" className="w-full"> <Table caption="검토인력 선택 목록으로 성명, 부서, 직위, 직급, 선택 정보 제공" className="w-full">
<colgroup> <colgroup>
<col width="25%" /> <col width="25%" />
<col width="25%" /> <col width="25%" />
@@ -185,7 +185,7 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
</p> </p>
) : ( ) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<Table caption="결재선 목록으로 결재순서, 결재구분, 결재자 정보 제공" className="w-full"> <Table caption="결재선 관리 목록으로 결재순서, 결재구분, 결재자 ID, 결재자, 등록일시, 관리 정보 제공" className="w-full">
<colgroup> <colgroup>
<col width="5%" /> <col width="5%" />
<col width="10%" /> <col width="10%" />
@@ -90,7 +90,7 @@ function ElaprTmpltList({ selectedTmpltId, onSelectTmplt }: ElaprTmpltListProps)
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}, },
dataTableOptions: { dataTableOptions: {
caption: "결재서식 목록으로 결재서식명 정보 제공", caption: "전자결재선 관리 목록으로 결재서식명, 문서구분, 등록일시, 관리 정보 제공",
onRowClick: (row) => { onRowClick: (row) => {
setRowSelection({ [row.elaprTmpltId]: true }); setRowSelection({ [row.elaprTmpltId]: true });
onSelectTmplt(row); onSelectTmplt(row);
@@ -84,9 +84,9 @@ function CklstArtclFormDialog({
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>사용여부</Label> <Label htmlFor="cklstArtclUseYn">사용여부</Label>
<Select value={useYn} onValueChange={(v) => setValue("useYn", v as "Y" | "N")}> <Select value={useYn} onValueChange={(v) => setValue("useYn", v as "Y" | "N")}>
<SelectTrigger className="w-full"> <SelectTrigger id="cklstArtclUseYn" className="w-full">
<SelectValue placeholder="사용여부" /> <SelectValue placeholder="사용여부" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -70,7 +70,7 @@ function CklstArtclList({ cklstSn }: CklstArtclListProps) {
Success: () => ( Success: () => (
<DataTable <DataTable
table={table} table={table}
caption="체크리스트 항목 목록: 항목명, 검토내용, 검토결과 정보 제공" caption="점검항목 관리 목록으로 점검항목코드, 점검항목명, 사용여부, 등록일시, 관리 정보 제공"
/> />
), ),
}); });
@@ -100,8 +100,9 @@ function CklstMngFormDialog({
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>문서구분</Label> <Label htmlFor="cklstDocSeComCd">문서구분</Label>
<CodeSelect <CodeSelect
id="cklstDocSeComCd"
upCd="ASDOCSE" upCd="ASDOCSE"
value={docSeComCd} value={docSeComCd}
onValueChange={(v) => setValue("docSeComCd", v)} onValueChange={(v) => setValue("docSeComCd", v)}
@@ -110,8 +111,9 @@ function CklstMngFormDialog({
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>업무구분</Label> <Label htmlFor="cklstTaskSeComCd">업무구분</Label>
<CodeSelect <CodeSelect
id="cklstTaskSeComCd"
upCd={docSeComCd || "ASDOCSE"} upCd={docSeComCd || "ASDOCSE"}
value={watch("taskSeComCd")} value={watch("taskSeComCd")}
onValueChange={(v) => setValue("taskSeComCd", v)} onValueChange={(v) => setValue("taskSeComCd", v)}
@@ -121,9 +123,9 @@ function CklstMngFormDialog({
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>사용여부</Label> <Label htmlFor="cklstUseYn">사용여부</Label>
<Select value={useYn} onValueChange={(v) => setValue("useYn", v as "Y" | "N")}> <Select value={useYn} onValueChange={(v) => setValue("useYn", v as "Y" | "N")}>
<SelectTrigger className="w-full"> <SelectTrigger id="cklstUseYn" className="w-full">
<SelectValue placeholder="사용여부" /> <SelectValue placeholder="사용여부" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -83,7 +83,7 @@ function CklstMngList({ selectedCklstSn, onSelectCklst }: CklstMngListProps) {
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}, },
dataTableOptions: { dataTableOptions: {
caption: "체크리스트 목록", caption: "점검표 관리 목록으로 점검표일련번호, 점검표명, 문서구분, 업무구분, 사용여부, 등록일시, 관리 정보 제공",
onRowClick: (row) => { onRowClick: (row) => {
setRowSelection({ [String(row.cklstSn)]: true }); setRowSelection({ [String(row.cklstSn)]: true });
onSelectCklst(row); onSelectCklst(row);
@@ -44,65 +44,78 @@ function CklstPreviewDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[64rem]"> <DialogContent className="flex max-h-[90vh] max-w-[64rem] flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle>체크리스트 미리보기 — {cklstNm}</DialogTitle> <DialogTitle>체크리스트 미리보기 — {cklstNm}</DialogTitle>
</DialogHeader> </DialogHeader>
{displayRows.length === 0 ? ( {displayRows.length === 0 ? (
<p className="py-6 text-center text-muted-foreground">표시할 항목이 없습니다.</p> <p className="py-6 text-center text-muted-foreground">표시할 항목이 없습니다.</p>
) : ( ) : (
<Table caption={`체크리스트 ${cklstNm} 미리보기`}> // 항목이 많아 창 높이를 넘으면 표 영역만 스크롤 (초점을 받아 키보드로도 스크롤 가능)
<colgroup> <div
<col width="25%" /> tabIndex={0}
<col width="50%" /> role="region"
<col width="25%" /> aria-label="체크리스트 미리보기 목록"
</colgroup> className="min-h-0 flex-1 overflow-y-auto rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
<TableHeader> >
<TableRow> <Table
<TableHead align="left">점검항목</TableHead> caption={`체크리스트 미리보기 ${cklstNm} 목록으로 점검항목, 주요 검토내용, 검토 여부 정보 제공`}
<TableHead align="left">주요 검토내용</TableHead> >
<TableHead>검토 여부</TableHead> <colgroup>
</TableRow> <col width="25%" />
</TableHeader> <col width="50%" />
<TableBody> <col width="25%" />
{displayRows.map(({ row, depth }) => { </colgroup>
const branchPrefix = formatTreeBranchPrefix(depth); <TableHeader>
const showRadios = row.bchckUseYn !== "N"; <TableRow>
<TableHead align="left">점검항목</TableHead>
<TableHead align="left">주요 검토내용</TableHead>
<TableHead>검토 여부</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{displayRows.map(({ row, depth }) => {
const branchPrefix = formatTreeBranchPrefix(depth);
const showRadios = row.bchckUseYn !== "N";
if (row.useYn === "N") return null; if (row.useYn === "N") return null;
return ( return (
<TableRow key={row.rowKey}> <TableRow key={row.rowKey}>
<TableCell align="left"> <TableCell align="left">
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
{branchPrefix ? ( {branchPrefix ? (
<span className="font-mono text-muted">{branchPrefix}</span> <span className="font-mono text-muted">{branchPrefix}</span>
) : null} ) : null}
<span>{getArtclLabel(row.cklstArtclSn)}</span> <span>{getArtclLabel(row.cklstArtclSn)}</span>
</span> </span>
</TableCell> </TableCell>
<TableCell>{row.rvwCn ?? "—"}</TableCell> <TableCell>{row.rvwCn ?? "—"}</TableCell>
<TableCell align="center"> <TableCell align="center">
{showRadios ? ( {showRadios ? (
<RadioGroup className="flex items-center justify-center"> <RadioGroup
<div className="flex items-center gap-1"> aria-label="검토 여부"
<RadioGroupItem value="Y" id={`${row.rowKey}-preview-y`} /> className="flex items-center justify-center"
<Label htmlFor={`${row.rowKey}-preview-y`}>예</Label> >
</div> <div className="flex items-center gap-1">
<div className="flex items-center gap-1"> <RadioGroupItem value="Y" id={`${row.rowKey}-preview-y`} />
<RadioGroupItem value="N" id={`${row.rowKey}-preview-n`} /> <Label htmlFor={`${row.rowKey}-preview-y`}>예</Label>
<Label htmlFor={`${row.rowKey}-preview-n`}>아니오</Label> </div>
</div> <div className="flex items-center gap-1">
</RadioGroup> <RadioGroupItem value="N" id={`${row.rowKey}-preview-n`} />
) : ( <Label htmlFor={`${row.rowKey}-preview-n`}>아니오</Label>
"—" </div>
)} </RadioGroup>
</TableCell> ) : (
</TableRow> "—"
); )}
})} </TableCell>
</TableBody> </TableRow>
</Table> );
})}
</TableBody>
</Table>
</div>
)} )}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -277,7 +277,7 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
등록된 항목이 없습니다. [추가]로 1단계 항목을 등록하세요. 등록된 항목이 없습니다. [추가]로 1단계 항목을 등록하세요.
</p> </p>
) : ( ) : (
<Table caption="체크리스트 항목 목록" className="w-full"> <Table caption="체크리스트 항목 목록으로 점검항목, 주요 검토내용, 사용 여부, 체크 사용, 정렬 순서, 구분, 관리 정보 제공" className="w-full">
<colgroup> <colgroup>
<col width="16%" /> <col width="16%" />
<col width="36%" /> <col width="36%" />
@@ -289,13 +289,13 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
</colgroup> </colgroup>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>점검항목</TableHead> <TableHead scope="col">점검항목</TableHead>
<TableHead>주요 검토내용</TableHead> <TableHead scope="col">주요 검토내용</TableHead>
<TableHead>사용 여부</TableHead> <TableHead scope="col">사용 여부</TableHead>
<TableHead>체크 사용</TableHead> <TableHead scope="col">체크 사용</TableHead>
<TableHead>정렬 순서</TableHead> <TableHead scope="col">정렬 순서</TableHead>
<TableHead>구분</TableHead> <TableHead scope="col">구분</TableHead>
<TableHead>관리</TableHead> <TableHead scope="col">관리</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -55,7 +55,11 @@ function CklstRvwRowComponent({
onValueChange={(v) => onChange({ cklstArtclSn: Number(v), isDirty: true })} onValueChange={(v) => onChange({ cklstArtclSn: Number(v), isDirty: true })}
disabled={!row.isNew} disabled={!row.isNew}
> >
<SelectTrigger className="w-full flex-1"> {/* 비활성(기존 항목) 상태에서도 텍스트 명도 대비 4.5:1 이상 유지 */}
<SelectTrigger
title="점검항목 선택"
className="w-full flex-1 disabled:opacity-100"
>
<SelectValue placeholder="점검항목 선택" /> <SelectValue placeholder="점검항목 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -73,12 +77,14 @@ function CklstRvwRowComponent({
value={row.rvwCn ?? ""} value={row.rvwCn ?? ""}
onChange={(e) => onChange({ rvwCn: e.target.value, isDirty: true })} onChange={(e) => onChange({ rvwCn: e.target.value, isDirty: true })}
placeholder="주요 검토내용" placeholder="주요 검토내용"
title="주요 검토내용"
/> />
</TableCell> </TableCell>
<TableCell align="center"> <TableCell align="center">
<RadioGroup <RadioGroup
value={row.useYn ?? "Y"} value={row.useYn ?? "Y"}
onValueChange={(v) => onChange({ useYn: v as "Y" | "N", isDirty: true })} onValueChange={(v) => onChange({ useYn: v as "Y" | "N", isDirty: true })}
aria-label="사용 여부"
className="flex justify-center gap-4" className="flex justify-center gap-4"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -95,6 +101,7 @@ function CklstRvwRowComponent({
<RadioGroup <RadioGroup
value={row.bchckUseYn ?? "Y"} value={row.bchckUseYn ?? "Y"}
onValueChange={(v) => onChange({ bchckUseYn: v as "Y" | "N", isDirty: true })} onValueChange={(v) => onChange({ bchckUseYn: v as "Y" | "N", isDirty: true })}
aria-label="체크 사용"
className="flex justify-center gap-4" className="flex justify-center gap-4"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -111,6 +118,7 @@ function CklstRvwRowComponent({
<Input <Input
type="number" type="number"
min={0} min={0}
title="정렬 순서"
value={row.sortSeq ?? ""} value={row.sortSeq ?? ""}
onChange={(e) => onChange={(e) =>
onChange({ onChange({
@@ -103,7 +103,7 @@ function AcntgCodeTabPanel() {
</p> </p>
) : ( ) : (
<div className="max-h-[60rem] overflow-y-auto px-4 pb-4 pt-1"> <div className="max-h-[60rem] overflow-y-auto px-4 pb-4 pt-1">
<Table caption="회계구분 코드 목록" className="w-full"> <Table caption="회계코드 목록으로 코드, 코드명, 설명, 사용 여부, 정렬순서, 구분, 관리 정보 제공" className="w-full">
<colgroup> <colgroup>
<col width="14%" /> <col width="14%" />
<col width="16%" /> <col width="16%" />
@@ -115,13 +115,13 @@ function AcntgCodeTabPanel() {
</colgroup> </colgroup>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>코드</TableHead> <TableHead scope="col">코드</TableHead>
<TableHead>코드명</TableHead> <TableHead scope="col">코드명</TableHead>
<TableHead>설명</TableHead> <TableHead scope="col">설명</TableHead>
<TableHead>사용 여부</TableHead> <TableHead scope="col">사용 여부</TableHead>
<TableHead>정렬순서</TableHead> <TableHead scope="col">정렬순서</TableHead>
<TableHead>구분</TableHead> <TableHead scope="col">구분</TableHead>
<TableHead>관리</TableHead> <TableHead scope="col">관리</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -40,9 +40,10 @@ function AcntgSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Acn
value={row.acntgSeCd} value={row.acntgSeCd}
onChange={(e) => onChange({ acntgSeCd: e.target.value })} onChange={(e) => onChange({ acntgSeCd: e.target.value })}
placeholder="코드" placeholder="코드"
title="코드"
maxLength={10} maxLength={10}
disabled={!row.isNew} disabled={!row.isNew}
className="flex-1" className="flex-1 disabled:opacity-100"
/> />
</div> </div>
</TableCell> </TableCell>
@@ -51,6 +52,7 @@ function AcntgSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Acn
value={row.acntgSeCdNm ?? ""} value={row.acntgSeCdNm ?? ""}
onChange={(e) => onChange({ acntgSeCdNm: e.target.value })} onChange={(e) => onChange({ acntgSeCdNm: e.target.value })}
placeholder="코드명" placeholder="코드명"
title="코드명"
maxLength={100} maxLength={100}
/> />
</TableCell> </TableCell>
@@ -59,6 +61,7 @@ function AcntgSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Acn
value={row.expln ?? ""} value={row.expln ?? ""}
onChange={(e) => onChange({ expln: e.target.value })} onChange={(e) => onChange({ expln: e.target.value })}
placeholder="설명" placeholder="설명"
title="설명"
maxLength={1000} maxLength={1000}
/> />
</TableCell> </TableCell>
@@ -66,6 +69,7 @@ function AcntgSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Acn
<RadioGroup <RadioGroup
value={row.useYn === "N" ? "N" : "Y"} value={row.useYn === "N" ? "N" : "Y"}
onValueChange={(v) => onChange({ useYn: v })} onValueChange={(v) => onChange({ useYn: v })}
aria-label="사용 여부"
className="flex justify-center gap-4" className="flex justify-center gap-4"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -82,6 +86,7 @@ function AcntgSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Acn
<Input <Input
type="number" type="number"
min={0} min={0}
title="정렬순서"
value={row.sortSeq ?? ""} value={row.sortSeq ?? ""}
onChange={(e) => onChange={(e) =>
onChange({ sortSeq: e.target.value ? Number(e.target.value) : undefined }) onChange({ sortSeq: e.target.value ? Number(e.target.value) : undefined })
@@ -103,7 +103,7 @@ function CnstpCodeTabPanel() {
</p> </p>
) : ( ) : (
<div className="max-h-[60rem] overflow-y-auto px-4 pb-4 pt-1"> <div className="max-h-[60rem] overflow-y-auto px-4 pb-4 pt-1">
<Table caption="공종구분 코드 목록" className="w-full"> <Table caption="공사코드 목록으로 코드, 코드명, 설명, 숫자 여부, 소숫점 단위, 사용 여부, 정렬순서, 구분, 관리 정보 제공" className="w-full">
<colgroup> <colgroup>
<col width="14%" /> <col width="14%" />
<col width="16%" /> <col width="16%" />
@@ -115,15 +115,15 @@ function CnstpCodeTabPanel() {
</colgroup> </colgroup>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>코드</TableHead> <TableHead scope="col">코드</TableHead>
<TableHead>코드명</TableHead> <TableHead scope="col">코드명</TableHead>
<TableHead>설명</TableHead> <TableHead scope="col">설명</TableHead>
<TableHead>숫자 여부</TableHead> <TableHead scope="col">숫자 여부</TableHead>
<TableHead>소숫점 단위</TableHead> <TableHead scope="col">소숫점 단위</TableHead>
<TableHead>사용 여부</TableHead> <TableHead scope="col">사용 여부</TableHead>
<TableHead>정렬순서</TableHead> <TableHead scope="col">정렬순서</TableHead>
<TableHead>구분</TableHead> <TableHead scope="col">구분</TableHead>
<TableHead>관리</TableHead> <TableHead scope="col">관리</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -40,9 +40,10 @@ function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Cns
value={row.cnstpSeCd} value={row.cnstpSeCd}
onChange={(e) => onChange({ cnstpSeCd: e.target.value })} onChange={(e) => onChange({ cnstpSeCd: e.target.value })}
placeholder="코드" placeholder="코드"
title="코드"
maxLength={10} maxLength={10}
disabled={!row.isNew} disabled={!row.isNew}
className="flex-1" className="flex-1 disabled:opacity-100"
/> />
</div> </div>
</TableCell> </TableCell>
@@ -51,6 +52,7 @@ function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Cns
value={row.cnstpSeCdNm ?? ""} value={row.cnstpSeCdNm ?? ""}
onChange={(e) => onChange({ cnstpSeCdNm: e.target.value })} onChange={(e) => onChange({ cnstpSeCdNm: e.target.value })}
placeholder="코드명" placeholder="코드명"
title="코드명"
maxLength={100} maxLength={100}
/> />
</TableCell> </TableCell>
@@ -59,6 +61,7 @@ function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Cns
value={row.expln ?? ""} value={row.expln ?? ""}
onChange={(e) => onChange({ expln: e.target.value })} onChange={(e) => onChange({ expln: e.target.value })}
placeholder="설명" placeholder="설명"
title="설명"
maxLength={1000} maxLength={1000}
/> />
</TableCell> </TableCell>
@@ -66,15 +69,16 @@ function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Cns
<RadioGroup <RadioGroup
value={row.numYn === "N" ? "N" : "Y"} value={row.numYn === "N" ? "N" : "Y"}
onValueChange={(v) => onChange({ numYn: v })} onValueChange={(v) => onChange({ numYn: v })}
aria-label="숫자 여부"
className="flex justify-center gap-4" className="flex justify-center gap-4"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<RadioGroupItem value="Y" id={`${row.rowKey}-num-y`} /> <RadioGroupItem value="Y" id={`${row.rowKey}-num-y`} />
<Label htmlFor={`${row.rowKey}-use-y`}>예</Label> <Label htmlFor={`${row.rowKey}-num-y`}>예</Label>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<RadioGroupItem value="N" id={`${row.rowKey}-num-n`} /> <RadioGroupItem value="N" id={`${row.rowKey}-num-n`} />
<Label htmlFor={`${row.rowKey}-use-n`}>아니오</Label> <Label htmlFor={`${row.rowKey}-num-n`}>아니오</Label>
</div> </div>
</RadioGroup> </RadioGroup>
</TableCell> </TableCell>
@@ -82,6 +86,7 @@ function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Cns
<Input <Input
type="number" type="number"
min={0} min={0}
title="소숫점 단위"
value={row.dcptUnitCnt ?? ""} value={row.dcptUnitCnt ?? ""}
onChange={(e) => onChange={(e) =>
onChange({ dcptUnitCnt: e.target.value ? Number(e.target.value) : undefined }) onChange({ dcptUnitCnt: e.target.value ? Number(e.target.value) : undefined })
@@ -93,6 +98,7 @@ function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Cns
<RadioGroup <RadioGroup
value={row.useYn === "N" ? "N" : "Y"} value={row.useYn === "N" ? "N" : "Y"}
onValueChange={(v) => onChange({ useYn: v })} onValueChange={(v) => onChange({ useYn: v })}
aria-label="사용 여부"
className="flex justify-center gap-4" className="flex justify-center gap-4"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -109,6 +115,7 @@ function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Cns
<Input <Input
type="number" type="number"
min={0} min={0}
title="정렬순서"
value={row.sortSeq ?? ""} value={row.sortSeq ?? ""}
onChange={(e) => onChange={(e) =>
onChange({ sortSeq: e.target.value ? Number(e.target.value) : undefined }) onChange({ sortSeq: e.target.value ? Number(e.target.value) : undefined })
@@ -103,7 +103,7 @@ function FnrsSharCodeTabPanel() {
</p> </p>
) : ( ) : (
<div className="max-h-[60rem] overflow-y-auto px-4 pb-4 pt-1"> <div className="max-h-[60rem] overflow-y-auto px-4 pb-4 pt-1">
<Table caption="재원분담 코드 목록" className="w-full"> <Table caption="재원분담구분코드 목록으로 코드, 코드명, 설명, 사용 여부, 정렬순서, 구분, 관리 정보 제공" className="w-full">
<colgroup> <colgroup>
<col width="14%" /> <col width="14%" />
<col width="16%" /> <col width="16%" />
@@ -115,13 +115,13 @@ function FnrsSharCodeTabPanel() {
</colgroup> </colgroup>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>코드</TableHead> <TableHead scope="col">코드</TableHead>
<TableHead>코드명</TableHead> <TableHead scope="col">코드명</TableHead>
<TableHead>설명</TableHead> <TableHead scope="col">설명</TableHead>
<TableHead>사용 여부</TableHead> <TableHead scope="col">사용 여부</TableHead>
<TableHead>정렬순서</TableHead> <TableHead scope="col">정렬순서</TableHead>
<TableHead>구분</TableHead> <TableHead scope="col">구분</TableHead>
<TableHead>관리</TableHead> <TableHead scope="col">관리</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -40,9 +40,10 @@ function FnrsSharSeRowComponent({ row, depth, onChange, onAddChild, onDelete }:
value={row.fnrsSharSeCd} value={row.fnrsSharSeCd}
onChange={(e) => onChange({ fnrsSharSeCd: e.target.value })} onChange={(e) => onChange({ fnrsSharSeCd: e.target.value })}
placeholder="코드" placeholder="코드"
title="코드"
maxLength={10} maxLength={10}
disabled={!row.isNew} disabled={!row.isNew}
className="flex-1" className="flex-1 disabled:opacity-100"
/> />
</div> </div>
</TableCell> </TableCell>
@@ -51,6 +52,7 @@ function FnrsSharSeRowComponent({ row, depth, onChange, onAddChild, onDelete }:
value={row.fnrsSharSeCdNm ?? ""} value={row.fnrsSharSeCdNm ?? ""}
onChange={(e) => onChange({ fnrsSharSeCdNm: e.target.value })} onChange={(e) => onChange({ fnrsSharSeCdNm: e.target.value })}
placeholder="코드명" placeholder="코드명"
title="코드명"
maxLength={100} maxLength={100}
/> />
</TableCell> </TableCell>
@@ -59,6 +61,7 @@ function FnrsSharSeRowComponent({ row, depth, onChange, onAddChild, onDelete }:
value={row.expln ?? ""} value={row.expln ?? ""}
onChange={(e) => onChange({ expln: e.target.value })} onChange={(e) => onChange({ expln: e.target.value })}
placeholder="설명" placeholder="설명"
title="설명"
maxLength={1000} maxLength={1000}
/> />
</TableCell> </TableCell>
@@ -66,6 +69,7 @@ function FnrsSharSeRowComponent({ row, depth, onChange, onAddChild, onDelete }:
<RadioGroup <RadioGroup
value={row.useYn === "N" ? "N" : "Y"} value={row.useYn === "N" ? "N" : "Y"}
onValueChange={(v) => onChange({ useYn: v })} onValueChange={(v) => onChange({ useYn: v })}
aria-label="사용 여부"
className="flex justify-center gap-4" className="flex justify-center gap-4"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -82,6 +86,7 @@ function FnrsSharSeRowComponent({ row, depth, onChange, onAddChild, onDelete }:
<Input <Input
type="number" type="number"
min={0} min={0}
title="정렬순서"
value={row.sortSeq ?? ""} value={row.sortSeq ?? ""}
onChange={(e) => onChange={(e) =>
onChange({ sortSeq: e.target.value ? Number(e.target.value) : undefined }) onChange({ sortSeq: e.target.value ? Number(e.target.value) : undefined })
@@ -56,14 +56,12 @@
fill: rgba(0, 150, 255, 0.2) !important; fill: rgba(0, 150, 255, 0.2) !important;
stroke: #0066cc; stroke: #0066cc;
stroke-width: 1; stroke-width: 1;
vector-effect: non-scaling-stroke;
} }
.svgContainer :global(svg) :global(.cls-1) { .svgContainer :global(svg) :global(.cls-1) {
fill: rgba(0, 150, 255, 0.2) !important; fill: rgba(0, 150, 255, 0.2) !important;
stroke: #0066cc; stroke: #0066cc;
stroke-width: 1; stroke-width: 1;
vector-effect: non-scaling-stroke;
} }
.svgContainer :global(svg) :global(.basin) { .svgContainer :global(svg) :global(.basin) {
@@ -93,7 +91,6 @@
fill: rgba(0, 209, 209, 0.5); fill: rgba(0, 209, 209, 0.5);
stroke: #399ac3; stroke: #399ac3;
stroke-width: 1.3; stroke-width: 1.3;
vector-effect: non-scaling-stroke;
} }
.popup { .popup {
@@ -160,3 +157,33 @@
border-color: #96c3ff; border-color: #96c3ff;
box-shadow: 0 4px 12px rgba(0, 107, 248, 0.3); box-shadow: 0 4px 12px rgba(0, 107, 248, 0.3);
} }
/* 블랙 스킨 (onasys 디자인 design/c 색상 기준) */
:global([data-theme="black"]) .basinLabel {
background: #404244ee;
border-color: #5e6165;
color: #f1f1f1;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
:global([data-theme="black"]) .basinLabel.active {
background: #0056b3;
border-color: #96c3ff;
color: #fff;
}
:global([data-theme="black"]) .popup {
background: #404244;
border-color: #4e5154;
color: #f1f1f1;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.5);
}
:global([data-theme="black"]) .popup > div {
border-color: #4e5154;
}
:global([data-theme="black"]) .popup > div:first-child {
background: #4e5154;
color: #fff;
}
@@ -247,6 +247,11 @@ export function useNationalAsMap(basinDocPrgrsList: BasinDocPrgrsResponseDto[] |
svg = svgLayerRef.current.querySelector("svg"); svg = svgLayerRef.current.querySelector("svg");
if (!svg) throw new Error("SVG element not found in loaded content"); if (!svg) throw new Error("SVG element not found in loaded content");
// 확대·축소해도 선 두께 유지. CSS 의 vector-effect 는 W3C CSS 검사 오류로 처리되므로 SVG 속성으로 지정
svg.querySelectorAll("path, polygon, polyline").forEach((shape) => {
shape.setAttribute("vector-effect", "non-scaling-stroke");
});
applyViewBox(svg, stageRef.current); applyViewBox(svg, stageRef.current);
groups = collectBasinGroups(svg); groups = collectBasinGroups(svg);
labels = labelsLayerRef.current ? createLabels(labelsLayerRef.current, groups) : []; labels = labelsLayerRef.current ? createLabels(labelsLayerRef.current, groups) : [];
@@ -40,12 +40,12 @@ export function DashboardNotiList({
); );
return ( return (
<section className="card card-border m-0 bg-[#FAFBFC]"> <section className="card card-border m-0">
<div className="card-header flex items-center justify-between gap-4"> <div className="card-header flex items-center justify-between gap-4">
<h3 className="text-2xl font-semibold"> <h3 className="text-2xl font-semibold">
알림이력 알림이력
{dataUpdatedAt > 0 ? ( {dataUpdatedAt > 0 ? (
<span className="pl-2 text-lg font-medium text-[#4A4A4A]"> <span className="pl-2 text-lg font-medium text-label">
(업데이트 일시: {dayjs(dataUpdatedAt).format("YYYY-MM-DD HH:mm")}) (업데이트 일시: {dayjs(dataUpdatedAt).format("YYYY-MM-DD HH:mm")})
</span> </span>
) : null} ) : null}
@@ -75,7 +75,7 @@ export function DashboardNotiList({
) : null} ) : null}
{isError ? ( {isError ? (
<p className="flex min-h-[12rem] items-center justify-center text-[#4A4A4A]"> <p className="flex min-h-[12rem] items-center justify-center text-label">
알림이력을 불러오지 못했습니다. 알림이력을 불러오지 못했습니다.
</p> </p>
) : null} ) : null}
@@ -110,7 +110,7 @@ interface DashboardNotiRowProps {
function DashboardNotiRow({ row, isActionPending, onAction }: DashboardNotiRowProps) { function DashboardNotiRow({ row, isActionPending, onAction }: DashboardNotiRowProps) {
return ( return (
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-4 border-b px-2 py-4 last:border-b-0"> <div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-4 border-b px-2 py-4 last:border-b-0">
<span className="text-lg text-[#4A4A4A]"> <span className="text-lg text-label">
{row.regDt ? dayjs(row.regDt).format("YYYY-MM-DD") : "-"} {row.regDt ? dayjs(row.regDt).format("YYYY-MM-DD") : "-"}
</span> </span>
<p className="min-w-0 line-clamp-2 text-xl sm:line-clamp-1">{row.notiTtl}</p> <p className="min-w-0 line-clamp-2 text-xl sm:line-clamp-1">{row.notiTtl}</p>
@@ -39,14 +39,14 @@ export function DashboardNoticeTable() {
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}, },
dataTableOptions: { dataTableOptions: {
caption: "공지사항 목록", caption: "공지사항 목록으로 번호, 제목, 작성자, 작성일, 조회수 정보 제공",
variant: "line", variant: "line",
onRowClick: handleRowClick, onRowClick: handleRowClick,
}, },
}); });
return ( return (
<section className="card card-border m-0 bg-[#FAFBFC]"> <section className="card card-border m-0">
<div className="card-header flex items-center justify-between"> <div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-semibold">공지사항</h3> <h3 className="text-2xl font-semibold">공지사항</h3>
<Button type="button" size="sm" color="white" className="!text-[#0056B3]" onClick={handleMoreClick}> <Button type="button" size="sm" color="white" className="!text-[#0056B3]" onClick={handleMoreClick}>
@@ -11,7 +11,8 @@ export function DashboardPage() {
const { data, isPending, isError, dataUpdatedAt } = useGetMainDashboard(); const { data, isPending, isError, dataUpdatedAt } = useGetMainDashboard();
return ( return (
<div className="flex min-h-full gap-4 bg-[#E9EDF2] px-[1.8rem] pb-6 pt-4 text-[#1E1E1E]"> // 배경·글자색은 테마 변수를 따름 (배경 --container, 글자 --foreground)
<div className="flex min-h-full gap-4 bg-container px-[1.8rem] pb-6 pt-4 text-foreground">
<div className="hidden flex-[0.5] sm:block"> <div className="hidden flex-[0.5] sm:block">
<NationalAsMap basinDocPrgrsList={data?.basinDocPrgrsList} /> <NationalAsMap basinDocPrgrsList={data?.basinDocPrgrsList} />
</div> </div>
@@ -24,7 +24,7 @@ export function DocPrgrsSttsCards({ items, isPending, isError }: DocPrgrsSttsCar
if (isError) { if (isError) {
return ( return (
<div className="card card-border m-0 flex min-h-[14rem] items-center justify-center bg-[#FAFBFC] text-[#4A4A4A]"> <div className="card card-border m-0 flex min-h-[14rem] items-center justify-center text-label">
진행상황을 불러오지 못했습니다. 진행상황을 불러오지 못했습니다.
</div> </div>
); );
@@ -39,7 +39,7 @@ export function DocPrgrsSttsCards({ items, isPending, isError }: DocPrgrsSttsCar
return ( return (
<div <div
key={slot.docSeComCd} key={slot.docSeComCd}
className="card card-border hover:bg-muted/40 m-0 block bg-[#FAFBFC] transition-colors" className="card card-border hover:bg-muted/40 m-0 block transition-colors"
> >
<div className="card-header"> <div className="card-header">
<h3 className="text-2xl font-semibold">{slot.title}</h3> <h3 className="text-2xl font-semibold">{slot.title}</h3>
@@ -90,7 +90,7 @@ function DocPrgrsSttsMetric({
> >
<Icon className="size-6" /> <Icon className="size-6" />
</span> </span>
<span className="text-xl text-[#4A4A4A]">{label}</span> <span className="text-xl text-label">{label}</span>
<span className="text-3xl font-semibold tabular-nums">{count}</span> <span className="text-3xl font-semibold tabular-nums">{count}</span>
</div> </div>
); );
@@ -108,21 +108,22 @@ const STAT_METRICS = [
field: "reqCnt", field: "reqCnt",
label: "신청", label: "신청",
iconClassName: "bg-[#4285F4]", iconClassName: "bg-[#4285F4]",
bgColor: "bg-blue-100", // 블랙 스킨: 아이콘 색 반투명 배경
bgColor: "bg-blue-100 [[data-theme=black]_&]:bg-[rgba(66,133,244,0.18)]",
Icon: FilePlus, Icon: FilePlus,
}, },
{ {
field: "prgrsCnt", field: "prgrsCnt",
label: "진행", label: "진행",
iconClassName: "bg-[#34A853]", iconClassName: "bg-[#34A853]",
bgColor: "bg-emerald-100", bgColor: "bg-emerald-100 [[data-theme=black]_&]:bg-[rgba(52,168,83,0.18)]",
Icon: RefreshCw, Icon: RefreshCw,
}, },
{ {
field: "cmptnCnt", field: "cmptnCnt",
label: "완료", label: "완료",
iconClassName: "bg-[#EA4335]", iconClassName: "bg-[#EA4335]",
bgColor: "bg-orange-100", bgColor: "bg-orange-100 [[data-theme=black]_&]:bg-[rgba(234,67,53,0.18)]",
Icon: CircleCheck, Icon: CircleCheck,
}, },
] as const; ] as const;
@@ -18,7 +18,7 @@ export function NationalAsMap({ basinDocPrgrsList }: NationalAsMapProps) {
const popupColumnSpan = Math.max(1, popupContent.items.length); const popupColumnSpan = Math.max(1, popupContent.items.length);
return ( return (
<section className="card card-border m-0 flex h-full min-h-[40rem] flex-col bg-[#FAFBFC]"> <section className="card card-border m-0 flex h-full min-h-[40rem] flex-col">
<div className="card-header"> <div className="card-header">
<h3 className="text-2xl font-semibold">전국 행정지원 지도</h3> <h3 className="text-2xl font-semibold">전국 행정지원 지도</h3>
</div> </div>
+183 -156
View File
@@ -1,156 +1,183 @@
"use client"; "use client";
import { Fragment } from "react"; import { Fragment } from "react";
import { createColumnHelper } from "@tanstack/react-table"; import { createColumnHelper } from "@tanstack/react-table";
import { dayjs } from "@/shared/lib"; import { dayjs } from "@/shared/lib";
import type { DesignCnstnMngResponseDto } from "@/entities/design-cnstn"; import type { DesignCnstnMngResponseDto } from "@/entities/design-cnstn";
import { Badge } from "@common/components"; import { Badge } from "@common/components";
const columnHelper = createColumnHelper<DesignCnstnMngResponseDto>(); const columnHelper = createColumnHelper<DesignCnstnMngResponseDto>();
const MAIN_STEP_LABELS = { const MAIN_STEP_LABELS = {
apply: "설계자문 신청", apply: "설계자문 신청",
techReview: "기술검토", techReview: "기술검토",
reply: "검토결과 회신", reply: "검토결과 회신",
} as const; } as const;
export const PROGRESS_STATUS_COLUMNS = [ export const PROGRESS_STATUS_COLUMNS = [
{ label: "설계자문 신청 (시군)", sttsComCd: "CT" }, { label: "설계자문 신청 (시군)", sttsComCd: "CT" },
{ label: "기술검토", sttsComCd: "CS" }, { label: "기술검토", sttsComCd: "CS" },
{ label: "기술검토 회신", sttsComCd: "CP" }, { label: "기술검토 회신", sttsComCd: "CP" },
]; ];
type BadgeState = "completed" | "current" | "pending"; type BadgeState = "completed" | "current" | "pending";
interface ProgressBadge { interface ProgressBadge {
label: string; label: string;
state: BadgeState; state: BadgeState;
} }
function buildProgressBadges(prgrsSttsComCd: string): ProgressBadge[] { function buildProgressBadges(prgrsSttsComCd: string): ProgressBadge[] {
if (prgrsSttsComCd === "CV") { if (prgrsSttsComCd === "CV") {
return [ return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" }, { label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" }, { label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" }, { label: MAIN_STEP_LABELS.reply, state: "pending" },
]; ];
} }
if (prgrsSttsComCd === "CT" || prgrsSttsComCd === "CS" || prgrsSttsComCd === "SL") { if (prgrsSttsComCd === "CT" || prgrsSttsComCd === "CS" || prgrsSttsComCd === "SL") {
return [ return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" }, { label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "current" }, { label: MAIN_STEP_LABELS.techReview, state: "current" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" }, { label: MAIN_STEP_LABELS.reply, state: "pending" },
]; ];
} }
if (prgrsSttsComCd === "CP") { if (prgrsSttsComCd === "CP") {
return [ return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" }, { label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "completed" }, { label: MAIN_STEP_LABELS.techReview, state: "completed" },
{ label: MAIN_STEP_LABELS.reply, state: "completed" }, { label: MAIN_STEP_LABELS.reply, state: "completed" },
]; ];
} }
return [ return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" }, { label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" }, { label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" }, { label: MAIN_STEP_LABELS.reply, state: "pending" },
]; ];
} }
function getProgressStatusBadge(prgrsSttsComCd?: string | null) { function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") { if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>; return <Badge color="orange">보완요청</Badge>;
} }
return null; return null;
} }
function getBadgeColor(state: BadgeState) { function getBadgeColor(state: BadgeState) {
switch (state) { switch (state) {
case "completed": case "completed":
return "green"; return "green";
case "current": case "current":
return "blue"; return "blue";
case "pending": case "pending":
return "lightGray"; return "lightGray";
} }
} }
function formatOptional(value: string | null | undefined) { function getBadgeStateText(state: BadgeState) {
return value?.trim() ? value : "-"; switch (state) {
} case "completed":
return "완료";
interface ProgressStepBadgeProps { case "current":
prgrsSttsComCd: string; return "진행중";
} case "pending":
return "대기";
function ProgressStepBadge({ prgrsSttsComCd }: ProgressStepBadgeProps) { }
const badges = buildProgressBadges(prgrsSttsComCd); }
return ( /** 단계명을 2줄로 분리: "설치인가 신청" → ["설치인가", "신청"], "기술검토" → ["기술검토", ""] */
<div className="flex flex-wrap items-center justify-start"> function splitStepLabel(label: string) {
{badges.map((badge, index) => ( const [title = "", ...rest] = label.split(" ");
<Fragment key={`${badge.label}-${index}`}> return { title, detail: rest.join(" ") };
{index > 0 && <span className="mx-2">&gt;</span>} }
<Badge color={getBadgeColor(badge.state)}>{badge.label}</Badge>
</Fragment> function formatOptional(value: string | null | undefined) {
))} return value?.trim() ? value : "-";
</div> }
);
} interface ProgressStepBadgeProps {
prgrsSttsComCd: string;
export function createDesignCnstnColumns() { }
return [
columnHelper.accessor("sido", { function ProgressStepBadge({ prgrsSttsComCd }: ProgressStepBadgeProps) {
header: "시도", const badges = buildProgressBadges(prgrsSttsComCd);
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()), return (
}), <div className="flex flex-wrap items-center justify-center">
columnHelper.accessor("gugun", { {badges.map((badge, index) => (
header: "시군구", <Fragment key={`${badge.label}-${index}`}>
meta: { align: "center" }, {index > 0 && <span className="mx-2">&gt;</span>}
cell: ({ getValue }) => formatOptional(getValue()), {/* 1줄: 단계명 / 2줄: 세부단계 + 상태 (예: 설치인가 / 신청완료, 기술검토 / 완료) */}
}), <Badge
columnHelper.accessor("bizNm", { color={getBadgeColor(badge.state)}
header: "사업명", className="w-[100px] flex-col justify-center !px-0 py-1 text-center leading-tight"
meta: { align: "left" }, >
cell: ({ row, getValue }) => ( <span>{splitStepLabel(badge.label).title}</span>
<> <span>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())} {splitStepLabel(badge.label).detail}
</> {getBadgeStateText(badge.state)}
), </span>
}), </Badge>
columnHelper.accessor("taskSeCdNm", { </Fragment>
header: "업무구분", ))}
meta: { align: "center" }, </div>
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd), );
}), }
columnHelper.accessor("aplcntNm", {
header: "담당자", export function createDesignCnstnColumns() {
meta: { align: "center" }, return [
cell: ({ getValue }) => formatOptional(getValue()), columnHelper.accessor("sido", {
}), header: "시도",
columnHelper.accessor("envoSeCdNm", { meta: { align: "center" },
header: "해당기관", cell: ({ getValue }) => formatOptional(getValue()),
meta: { align: "center" }, }),
cell: ({ getValue }) => formatOptional(getValue()), columnHelper.accessor("gugun", {
}), header: "시군구",
columnHelper.display({ meta: { align: "center" },
id: "progress", cell: ({ getValue }) => formatOptional(getValue()),
header: "진행상황", }),
meta: { align: "center" }, columnHelper.accessor("bizNm", {
cell: ({ row }) => <ProgressStepBadge prgrsSttsComCd={row.original.prgrsSttsComCd || ""} />, header: "사업명",
}), meta: { align: "left" },
columnHelper.accessor("regDt", { cell: ({ row, getValue }) => (
header: "신청일", <>
meta: { align: "center" }, {getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
cell: ({ getValue }) => { </>
const value = getValue(); ),
return value ? dayjs(value).format("YYYY-MM-DD") : "-"; }),
}, 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") : "-";
},
}),
];
}
@@ -77,8 +77,8 @@ function DesignListPage() {
}; };
return ( return (
<div className="space-y-4 px-[1.8rem] pb-6"> <div className="card card-border !mt-6">
<section className="card card-border mx-0 bg-background"> <div className="card card-border">
<form className="flex flex-wrap items-center gap-4 p-2" onSubmit={handleSearch}> <form className="flex flex-wrap items-center gap-4 p-2" onSubmit={handleSearch}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label htmlFor="sidoComCd" className="whitespace-nowrap"> <Label htmlFor="sidoComCd" className="whitespace-nowrap">
@@ -145,7 +145,7 @@ function DesignListPage() {
</div> </div>
<Button type="submit">조회</Button> <Button type="submit">조회</Button>
</form> </form>
</section> </div>
<DesignList <DesignList
searchParams={searchParams} searchParams={searchParams}
@@ -59,7 +59,7 @@ function DesignList({ searchParams, pagination, onPaginationChange }: DesignList
manualPagination: true, manualPagination: true,
}, },
dataTableOptions: { dataTableOptions: {
caption: "설계검토 목록", caption: "설계자문 목록으로 시도, 시군구, 사업명, 업무구분, 담당자, 해당기관, 진행상황, 신청일 정보 제공",
viewPagination: true, viewPagination: true,
onRowClick: handleRowClick, onRowClick: handleRowClick,
}, },
@@ -78,15 +78,15 @@ function DesignList({ searchParams, pagination, onPaginationChange }: DesignList
return ( return (
<> <>
<section className="card card-border mx-0 bg-background pb-8 pt-0"> <div className="card card-border pt-0">
<div className="card-header flex items-center justify-between"> <div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-semibold">설계자문 목록</h3> <h3 className="text-2xl font-medium text-label">설계자문 목록</h3>
<Button type="button" color="green" onClick={() => setIsTaskSeDialogOpen(true)}> <Button type="button" color="green" onClick={() => setIsTaskSeDialogOpen(true)}>
신청 신청
</Button> </Button>
</div> </div>
{tableComponent} <div className="card !m-[1.2rem] !p-0">{tableComponent}</div>
</section> </div>
<PbadmsTaskApplyDialog <PbadmsTaskApplyDialog
open={isTaskSeDialogOpen} open={isTaskSeDialogOpen}
onOpenChange={setIsTaskSeDialogOpen} onOpenChange={setIsTaskSeDialogOpen}
@@ -8,6 +8,7 @@ import { SectionCollapsible } from "@/shared/ui";
import { Field, FieldError, FieldGroup } from "@/shared/ui/field"; import { Field, FieldError, FieldGroup } from "@/shared/ui/field";
import { findCodeLabel, useCodeList } from "@/entities/code"; import { findCodeLabel, useCodeList } from "@/entities/code";
import { type BnsApplyData, BnsPickerDialog } from "@/widgets/bns-picker"; 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 { Input, Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
import type { DesignCnstnRegistFormData } from "../../lib/design-cnstn-regist-schema"; import type { DesignCnstnRegistFormData } from "../../lib/design-cnstn-regist-schema";
@@ -16,9 +17,9 @@ function BasicInfoFormSection() {
const { control, watch, setValue } = useFormContext<DesignCnstnRegistFormData>(); const { control, watch, setValue } = useFormContext<DesignCnstnRegistFormData>();
const [isPickerOpen, setIsPickerOpen] = useState(false); const [isPickerOpen, setIsPickerOpen] = useState(false);
const taskSeComCd = watch("designCnstnMng.taskSeComCd"); const taskSeComCd = watch("designCnstnMng.taskSeComCd");
const localGovLabel = [watch("designCnstnMng.sido"), watch("designCnstnMng.gugun")] const sido = watch("designCnstnMng.sido");
.filter(Boolean) const gugun = watch("designCnstnMng.gugun");
.join(" "); const localGovLabel = [sido, gugun].filter(Boolean).join(" ");
const { data: taskOptions } = useCodeList("CNFM"); const { data: taskOptions } = useCodeList("CNFM");
function handleBnsSelect(data: BnsApplyData) { function handleBnsSelect(data: BnsApplyData) {
@@ -36,7 +37,8 @@ function BasicInfoFormSection() {
return ( return (
<SectionCollapsible title="기본 정보"> <SectionCollapsible title="기본 정보">
<Table caption="사업 기본 정보 입력" variant="secondary"> <PreviousPlanLookupButton sidoNm={sido} gugunNm={gugun} />
<Table caption="기본 정보 입력으로 사업명, 사업기간, 사업내용, 구분, 신청 지자체 정보 제공" variant="secondary">
<colgroup> <colgroup>
<col width="15%" /> <col width="15%" />
<col width="35%" /> <col width="35%" />
@@ -2,6 +2,7 @@
import { SectionCollapsible } from "@/shared/ui"; import { SectionCollapsible } from "@/shared/ui";
import type { DesignCnstnMngResponseDto } from "@/entities/design-cnstn"; import type { DesignCnstnMngResponseDto } from "@/entities/design-cnstn";
import { PreviousPlanLookupButton } from "@/widgets/previous-plan-lookup";
import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components"; import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
interface BasicInfoSectionProps { interface BasicInfoSectionProps {
@@ -11,7 +12,8 @@ interface BasicInfoSectionProps {
function BasicInfoSection({ det }: BasicInfoSectionProps) { function BasicInfoSection({ det }: BasicInfoSectionProps) {
return ( return (
<SectionCollapsible title="기본 정보"> <SectionCollapsible title="기본 정보">
<Table caption="사업 기본 정보" variant="secondary"> <PreviousPlanLookupButton sidoNm={det.sido} gugunNm={det.gugun} />
<Table caption="기본 정보로 사업명, 사업기간, 사업내용, 환경청구분, 구분, 신청지자체 정보 제공" variant="secondary">
<colgroup> <colgroup>
<col width="15%" /> <col width="15%" />
<col width="35%" /> <col width="35%" />
@@ -13,7 +13,7 @@ function BusinessScaleSection({ bizSclList }: BusinessScaleSectionProps) {
return ( return (
<SectionCollapsible title="사업규모"> <SectionCollapsible title="사업규모">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table caption="사업규모 목록: 구분, 기본계획, 예산반영, 금회설계, 증감 정보 제공" variant="secondary"> <Table caption="사업규모 목록으로 구분, 기본계획(A), 예산반영(B), 금회설계시(C), 증감(C-B) 정보 제공" variant="secondary">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[30rem]">구분</TableHead> <TableHead className="w-[30rem]">구분</TableHead>
@@ -11,7 +11,7 @@ interface ContactInfoSectionProps {
function ContactInfoSection({ det }: ContactInfoSectionProps) { function ContactInfoSection({ det }: ContactInfoSectionProps) {
return ( return (
<SectionCollapsible title="담당자 정보"> <SectionCollapsible title="담당자 정보">
<Table caption="사업 담당자 정보" variant="secondary"> <Table caption="담당자 정보로 신청자(이름, 연락처, 소속, 이메일)과 설계자(이름, 연락처, 소속, 이메일) 정보 제공" variant="secondary">
<TableHeader> <TableHeader>
{det.aplcntNm && ( {det.aplcntNm && (
<> <>
@@ -1,338 +1,336 @@
"use client"; "use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react"; import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib"; import { useQueryClient } from "@tanstack/react-query";
import { TextShimmer } from "@/shared/ui";
import { SectionCollapsible } from "@/shared/ui"; import { getDefaultFileKey, useTus, useUserStore } from "@/shared/lib";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field"; import { TextShimmer } from "@/shared/ui";
import { AttachmentDownloadLink, attachService } from "@/entities/attach"; import { SectionCollapsible } from "@/shared/ui";
import type { DesignCnstnRvwOpnnResponseDto } from "@/entities/design-cnstn"; import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import { import { attachService } from "@/entities/attach";
Button, import {
RadioGroup, designCnstnKeys,
RadioGroupItem, type DesignCnstnRvwOpnnResponseDto,
Table, designCnstnRvwOpnnService,
TableBody, } from "@/entities/design-cnstn";
TableCell, import { RvwOpnnListTable } from "@/widgets/pbadms-sprt";
TableHead, import {
TableHeader, Button,
TableRow, RadioGroup,
Textarea, RadioGroupItem,
} from "@common/components"; Table,
TableCell,
import type { TableHead,
ReviewOpinionFormData, TableHeader,
ReviewOpinionGetFormDataResult, TableRow,
} from "../../lib/review-opinion-utils"; Textarea,
import type { ReviewStepCode } from "../../model/view-types"; } from "@common/components";
import { REVIEW_STEP_LABELS } from "../../model/view-types";
import type {
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::"; ReviewOpinionFormData,
ReviewOpinionGetFormDataResult,
interface TechReviewOpinionSectionProps { } from "../../lib/review-opinion-utils";
rvwList: DesignCnstnRvwOpnnResponseDto[]; import type { ReviewStepCode } from "../../model/view-types";
editableReviewStep?: ReviewStepCode; import { REVIEW_STEP_LABELS } from "../../model/view-types";
atrzSeComCdList?: string[];
isAtrzLoading?: boolean; const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
}
interface TechReviewOpinionSectionProps {
export interface TechReviewOpinionSectionHandle { rvwList: DesignCnstnRvwOpnnResponseDto[];
getFormData: () => ReviewOpinionGetFormDataResult | null; editableReviewStep?: ReviewStepCode;
resetForm: () => void; atrzSeComCdList?: string[];
} isAtrzLoading?: boolean;
}
interface TechReviewOpinionTableProps {
items: DesignCnstnRvwOpnnResponseDto[]; export interface TechReviewOpinionSectionHandle {
} getFormData: () => ReviewOpinionGetFormDataResult | null;
resetForm: () => void;
interface UploadedOpinionFile { }
fileName: string;
fileKey: string; interface TechReviewOpinionTableProps {
atflId?: string; items: DesignCnstnRvwOpnnResponseDto[];
atflCd?: string; isReviewTurn: boolean;
} }
const EMPTY_FORM_DATA: ReviewOpinionFormData = { interface UploadedOpinionFile {
rvwRsltCn: "", fileName: string;
rvwRsltAnmlyYn: "", fileKey: string;
atchFileGroupId: "", atflId?: string;
opnrSeComCd: "", atflCd?: string;
}; }
function createRvwOpnnUploadKey(file: File) { const EMPTY_FORM_DATA: ReviewOpinionFormData = {
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`; rvwRsltCn: "",
} rvwRsltAnmlyYn: "",
atchFileGroupId: "",
function hasFormContent(formData: ReviewOpinionFormData) { opnrSeComCd: "",
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId); };
}
function createRvwOpnnUploadKey(file: File) {
const TechReviewOpinionSection = forwardRef< return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
TechReviewOpinionSectionHandle, }
TechReviewOpinionSectionProps
>(function TechReviewOpinionSection( function hasFormContent(formData: ReviewOpinionFormData) {
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false }, return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
ref, }
) {
const canWriteOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02"); const TechReviewOpinionSection = forwardRef<
const [formData, setFormData] = useState<ReviewOpinionFormData>({ TechReviewOpinionSectionHandle,
...EMPTY_FORM_DATA, TechReviewOpinionSectionProps
opnrSeComCd: editableReviewStep ?? "", >(function TechReviewOpinionSection(
}); { rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null); ref,
) {
useEffect(() => { const canWriteOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
setFormData((prev) => ({ const [formData, setFormData] = useState<ReviewOpinionFormData>({
...prev, ...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "", opnrSeComCd: editableReviewStep ?? "",
})); });
}, [editableReviewStep]); const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
const { uploadFileAsync, isUploading, uploads } = useTus({ useEffect(() => {
onSuccess: (atflCd, atflId, _file, fileKey) => { setFormData((prev) => ({
if (!atflCd || !atflId) return; ...prev,
opnrSeComCd: editableReviewStep ?? "",
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd })); }));
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev)); }, [editableReviewStep]);
},
onError: (_error, _file, fileKey) => { const { uploadFileAsync, isUploading, uploads } = useTus({
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev)); onSuccess: (atflCd, atflId, _file, fileKey) => {
}, if (!atflCd || !atflId) return;
});
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
useImperativeHandle( setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
ref, },
() => ({ onError: (_error, _file, fileKey) => {
getFormData: () => { setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
if (!canWriteOpinion || !editableReviewStep || !hasFormContent(formData)) { },
return null; });
}
useImperativeHandle(
return { ref,
formData: { () => ({
...formData, getFormData: () => {
opnrSeComCd: editableReviewStep, if (!canWriteOpinion || !editableReviewStep || !hasFormContent(formData)) {
}, return null;
rvwList, }
};
}, return {
resetForm: () => { formData: {
setFormData({ ...formData,
...EMPTY_FORM_DATA, opnrSeComCd: editableReviewStep,
opnrSeComCd: editableReviewStep ?? "", },
}); rvwList,
setUploadedFile(null); };
}, },
}), resetForm: () => {
[canWriteOpinion, editableReviewStep, formData, rvwList], setFormData({
); ...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO"); });
const hasKecoSection = setUploadedFile(null);
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteOpinion); },
}),
if (!hasKecoSection) { [canWriteOpinion, editableReviewStep, formData, rvwList],
return null; );
}
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
async function handleFileUpload(files: FileList | null) { const hasKecoSection =
if (!files || files.length === 0) return; kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteOpinion);
const file = files[0]; if (!hasKecoSection) {
const fileKey = createRvwOpnnUploadKey(file); return null;
const existingAtflCd = formData.atchFileGroupId; }
setUploadedFile({ fileName: file.name, fileKey }); async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
try {
await uploadFileAsync(file, { const file = files[0];
fileKey, const fileKey = createRvwOpnnUploadKey(file);
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined, const existingAtflCd = formData.atchFileGroupId;
});
} catch { setUploadedFile({ fileName: file.name, fileKey });
// onError callback handles cleanup
} try {
} await uploadFileAsync(file, {
fileKey,
async function handleFileRemove() { metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
if (!uploadedFile) return; });
} catch {
uploads[uploadedFile.fileKey]?.abort(); // onError callback handles cleanup
}
if (uploadedFile.atflId) { }
try {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId); async function handleFileRemove() {
} catch (error) { if (!uploadedFile) return;
console.error("검토의견 첨부파일 삭제 실패:", error);
} uploads[uploadedFile.fileKey]?.abort();
}
if (uploadedFile.atflId) {
setUploadedFile(null); try {
setFormData((prev) => ({ ...prev, atchFileGroupId: "" })); await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} } catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error);
function renderOpinionForm(reviewStep: ReviewStepCode) { }
if (editableReviewStep !== reviewStep || !canWriteOpinion) return null; }
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false; setUploadedFile(null);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
return ( }
<div className="space-y-2">
<h4 className="text-xl font-semibold">검토의견 작성</h4> function isReviewTurn(reviewStep: ReviewStepCode) {
<Table caption="검토의견 입력" variant="secondary"> return editableReviewStep === reviewStep && canWriteOpinion;
<TableHeader> }
<TableRow>
<TableHead scope="row" className="w-[12rem]">의견내용</TableHead> function renderOpinionForm(reviewStep: ReviewStepCode) {
<TableCell> if (!isReviewTurn(reviewStep)) return null;
<Textarea
value={formData.rvwRsltCn} const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
onChange={(event) =>
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value })) return (
} <div className="space-y-2">
placeholder="의견내용을 입력하세요" <h4 className="text-xl font-semibold">검토의견 작성</h4>
rows={4} <Table
className="min-h-[8rem] !text-xl" caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공"
/> variant="secondary"
</TableCell> >
</TableRow> <TableHeader>
<TableRow> <TableRow>
<TableHead scope="row">검토결과</TableHead> <TableHead scope="row" className="w-[12rem]">
<TableCell> 의견내용
<FieldSet> </TableHead>
<RadioGroup <TableCell>
value={formData.rvwRsltAnmlyYn} <Textarea
onValueChange={(value) => value={formData.rvwRsltCn}
setFormData((prev) => ({ onChange={(event) =>
...prev, setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"], }
})) placeholder="의견내용을 입력하세요"
} rows={4}
> className="min-h-[8rem] !text-xl"
<div className="flex flex-wrap gap-4"> />
<FieldLabel </TableCell>
htmlFor={`rvw-opnn-normal-${reviewStep}`} </TableRow>
className="!w-auto border-none" <TableRow>
> <TableHead scope="row">검토결과</TableHead>
<Field orientation="horizontal" className="w-auto"> <TableCell>
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} /> <FieldSet>
<span className="font-normal">정상</span> <RadioGroup
</Field> value={formData.rvwRsltAnmlyYn}
</FieldLabel> onValueChange={(value) =>
<FieldLabel setFormData((prev) => ({
htmlFor={`rvw-opnn-abnormal-${reviewStep}`} ...prev,
className="!w-auto border-none" rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
> }))
<Field orientation="horizontal" className="w-auto"> }
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} /> >
<span className="font-normal">이상</span> <div className="flex flex-wrap gap-4">
</Field> <FieldLabel
</FieldLabel> htmlFor={`rvw-opnn-normal-${reviewStep}`}
</div> className="!w-auto border-none"
</RadioGroup> >
</FieldSet> <Field orientation="horizontal" className="w-auto">
</TableCell> <RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
</TableRow> <span className="font-normal">정상</span>
<TableRow> </Field>
<TableHead scope="row">첨부파일</TableHead> </FieldLabel>
<TableCell> <FieldLabel
{uploadedFile ? ( htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
<div className="flex items-center gap-2"> className="!w-auto border-none"
<TextShimmer className="text-xl" active={uploading}> >
{uploadedFile.fileName} <Field orientation="horizontal" className="w-auto">
</TextShimmer> <RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
<Button <span className="font-normal">이상</span>
size="icon" </Field>
onClick={(e) => { </FieldLabel>
e.preventDefault(); </div>
void handleFileRemove(); </RadioGroup>
}} </FieldSet>
disabled={uploading} </TableCell>
className="size-8 !bg-destructive" </TableRow>
> <TableRow>
<i className="diveicon di-x text-xs" aria-label="삭제" /> <TableHead scope="row">첨부파일</TableHead>
</Button> <TableCell>
</div> {uploadedFile ? (
) : ( <div className="flex items-center gap-2">
<label> <TextShimmer className="text-xl" active={uploading}>
<input {uploadedFile.fileName}
type="file" </TextShimmer>
className="hidden" <Button
onChange={(event) => { size="icon"
void handleFileUpload(event.target.files); onClick={(e) => {
event.target.value = ""; e.preventDefault();
}} void handleFileRemove();
/> }}
<Button size="sm" variant="outline" type="button" asChild> disabled={uploading}
<span>파일 선택</span> className="size-8 !bg-destructive"
</Button> >
</label> <i className="diveicon di-x text-xs" aria-label="삭제" />
)} </Button>
</TableCell> </div>
</TableRow> ) : (
</TableHeader> <label>
</Table> <input
</div> type="file"
); className="hidden"
} onChange={(event) => {
void handleFileUpload(event.target.files);
return ( event.target.value = "";
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}> }}
<div className="space-y-6"> />
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null} <Button size="sm" variant="outline" type="button" asChild>
{renderOpinionForm("KECO")} <span>파일 선택</span>
</div> </Button>
</SectionCollapsible> </label>
); )}
}); </TableCell>
</TableRow>
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) { </TableHeader>
return ( </Table>
<div className="overflow-x-auto"> </div>
<Table caption="검토의견 목록: 의견자, 의견내용, 검토결과, 첨부파일, 등록일시 정보 제공" variant="secondary"> );
<colgroup> }
<col width="10%" />
<col width="55%" /> return (
<col width="5%" /> <SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<col width="20%" /> <div className="space-y-6">
<col width="10%" /> {kecoRvwList.length > 0 ? (
</colgroup> <TechReviewOpinionTable items={kecoRvwList} isReviewTurn={isReviewTurn("KECO")} />
<TableHeader> ) : null}
<TableRow> {renderOpinionForm("KECO")}
<TableHead>의견자명</TableHead> </div>
<TableHead>의견내용</TableHead> </SectionCollapsible>
<TableHead>검토결과</TableHead> );
<TableHead>첨부파일</TableHead> });
<TableHead>등록일시</TableHead>
</TableRow> function getRvwOpnnIds(item: DesignCnstnRvwOpnnResponseDto) {
</TableHeader> if (!item.designCnstnDocNo || item.rvwOpnnSn == null) {
<TableBody> throw new Error("검토의견 식별값이 없습니다.");
{items.map((item) => ( }
<TableRow key={`${item.designCnstnDocNo}_${item.rvwOpnnSn}`}> return [item.designCnstnDocNo, item.rvwOpnnSn] as const;
<TableCell align="center">{item.opnrNm}</TableCell> }
<TableCell align="left">{item.rvwRsltCn || "-"}</TableCell>
<TableCell align="center"> function TechReviewOpinionTable({ items, isReviewTurn }: TechReviewOpinionTableProps) {
{item.rvwRsltAnmlyYn === "Y" ? "이상" : item.rvwRsltAnmlyYn === "N" ? "정상" : "-"} const queryClient = useQueryClient();
</TableCell> const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
<TableCell align="center">
{item.atchFileInfo?.atflCd ? ( return (
<AttachmentDownloadLink <RvwOpnnListTable
atflCd={item.atchFileInfo.atflCd} items={items}
fileName={item.atchFileInfo.orgnlFileNm || "다운로드"} getRowKey={(item) => `${item.designCnstnDocNo}_${item.rvwOpnnSn}`}
/> currentUserUuid={currentUserUuid}
) : ( isReviewTurn={isReviewTurn}
"-" onUpdate={async (item, body) => {
)} await designCnstnRvwOpnnService.update(...getRvwOpnnIds(item), body);
</TableCell> await queryClient.invalidateQueries({ queryKey: designCnstnKeys.all });
<TableCell align="center"> }}
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"} onDelete={async (item) => {
</TableCell> await designCnstnRvwOpnnService.delete(...getRvwOpnnIds(item));
</TableRow> await queryClient.invalidateQueries({ queryKey: designCnstnKeys.all });
))} }}
</TableBody> />
</Table> );
</div> }
);
} export { TechReviewOpinionSection };
export { TechReviewOpinionSection };
@@ -100,7 +100,7 @@ function EsntlAtchFileList({
manualPagination: true, manualPagination: true,
}, },
dataTableOptions: { dataTableOptions: {
caption: "필수 첨부파일 관리 목록", caption: "필수첨부파일 목록으로 필수첨부파일아이디, 문서구분, 필수첨부파일명, 필수첨부여부, 비고, 양식 내려받기, 관리 정보 제공",
viewPagination: true, viewPagination: true,
}, },
}); });
@@ -136,14 +136,14 @@ function EsntlAtchFileList({
}; };
return ( return (
<section className="card card-border mx-0 bg-background pt-0"> <div className="card card-border pt-0">
<div className="card-header flex items-center justify-between"> <div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-semibold">필수첨부파일 목록</h3> <h3 className="text-2xl font-medium text-label">필수첨부파일 목록</h3>
<Button type="button" color="green" onClick={handleOpenCreate}> <Button type="button" color="green" onClick={handleOpenCreate}>
등록 등록
</Button> </Button>
</div> </div>
{tableComponent} <div className="card !m-[1.2rem] !p-0">{tableComponent}</div>
{dialogMode ? ( {dialogMode ? (
<EsntlAtchFileFormDialog <EsntlAtchFileFormDialog
mode={dialogMode} mode={dialogMode}
@@ -159,7 +159,7 @@ function EsntlAtchFileList({
onSubmit={handleFormSubmit} onSubmit={handleFormSubmit}
/> />
) : null} ) : null}
</section> </div>
); );
} }
@@ -52,8 +52,8 @@ function EsntlAtchFileMngPage() {
}; };
return ( return (
<div className="space-y-6 px-[1.8rem] pb-6"> <div className="card card-border !mt-6">
<section className="card card-border mx-0 bg-background"> <div className="card card-border">
<form className="flex flex-wrap items-end gap-4 p-4" onSubmit={handleSearch}> <form className="flex flex-wrap items-end gap-4 p-4" onSubmit={handleSearch}>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<Label htmlFor="docSeComCd">문서구분</Label> <Label htmlFor="docSeComCd">문서구분</Label>
@@ -79,7 +79,7 @@ function EsntlAtchFileMngPage() {
<Button type="submit">조회</Button> <Button type="submit">조회</Button>
</div> </div>
</form> </form>
</section> </div>
<EsntlAtchFileList <EsntlAtchFileList
searchParams={searchParams} searchParams={searchParams}
+230 -203
View File
@@ -1,203 +1,230 @@
"use client"; "use client";
import { Fragment } from "react"; import { Fragment } from "react";
import { createColumnHelper } from "@tanstack/react-table"; import { createColumnHelper } from "@tanstack/react-table";
import { dayjs } from "@/shared/lib"; import { dayjs } from "@/shared/lib";
import type { FnrsCnsltnMngResponseDto } from "@/entities/fnrs-cnsltn"; import type { FnrsCnsltnMngResponseDto } from "@/entities/fnrs-cnsltn";
import { Badge } from "@common/components"; import { Badge } from "@common/components";
const columnHelper = createColumnHelper<FnrsCnsltnMngResponseDto>(); const columnHelper = createColumnHelper<FnrsCnsltnMngResponseDto>();
const MAIN_STEP_LABELS = { const MAIN_STEP_LABELS = {
apply: "재원협의 신청", apply: "재원협의 신청",
envoReview: "재원협의 검토", envoReview: "재원협의 검토",
techReview: "기술검토", techReview: "기술검토",
reply: "검토결과 회신", reply: "검토결과 회신",
} as const; } as const;
export const PROGRESS_STATUS_COLUMNS = [ export const PROGRESS_STATUS_COLUMNS = [
{ label: "재원협의 신청 (시군)", sttsComCd: "ST" }, { label: "재원협의 신청 (시군)", sttsComCd: "ST" },
{ label: "재원협의 신청 (시도)", sttsComCd: "LE" }, { label: "재원협의 신청 (시도)", sttsComCd: "LE" },
{ label: "재원협의 검토", sttsComCd: "ER" }, { label: "재원협의 검토", sttsComCd: "ER" },
{ label: "기술검토", sttsComCd: "TE" }, { label: "기술검토", sttsComCd: "TE" },
{ label: "기술검토 회신", sttsComCd: "TP" }, { label: "기술검토 회신", sttsComCd: "TP" },
{ label: "환경청 검토 결과 회신", sttsComCd: "EP" }, { label: "환경청 검토 결과 회신", sttsComCd: "EP" },
]; ];
type BadgeState = "completed" | "current" | "pending"; type BadgeState = "completed" | "current" | "pending";
interface ProgressBadge { interface ProgressBadge {
label: string; label: string;
state: BadgeState; state: BadgeState;
} }
function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N"): ProgressBadge[] { function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N"): ProgressBadge[] {
if (prgrsSttsComCd === "SV") { if (prgrsSttsComCd === "SV") {
return [ return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" }, { label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" }, { label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" }, { label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" }, { label: MAIN_STEP_LABELS.reply, state: "pending" },
]; ];
} }
if (prgrsSttsComCd === "ST") { if (prgrsSttsComCd === "ST") {
return [ return [
{ label: MAIN_STEP_LABELS.apply, state: "current" }, { label: MAIN_STEP_LABELS.apply, state: "current" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" }, { label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" }, { label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" }, { label: MAIN_STEP_LABELS.reply, state: "pending" },
]; ];
} }
if (prgrsSttsComCd === "LE") { if (prgrsSttsComCd === "LE") {
return [ return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" }, { label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "current" }, { label: MAIN_STEP_LABELS.envoReview, state: "current" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" }, { label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" }, { label: MAIN_STEP_LABELS.reply, state: "pending" },
]; ];
} }
if (["FV", "ER", "AC", "TE", "RB", "RA", "AV", "EV", "RK", "RR", "SL"].includes(prgrsSttsComCd)) { if (["FV", "ER", "AC", "TE", "RB", "RA", "AV", "EV", "RK", "RR", "SL"].includes(prgrsSttsComCd)) {
return [ return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" }, { label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" }, { label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "current" }, { label: MAIN_STEP_LABELS.techReview, state: "current" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" }, { label: MAIN_STEP_LABELS.reply, state: "pending" },
]; ];
} }
if (prgrsSttsComCd === "TP") { if (prgrsSttsComCd === "TP") {
return [ return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" }, { label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" }, { label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{ {
label: MAIN_STEP_LABELS.techReview, label: MAIN_STEP_LABELS.techReview,
state: techRplyHstryYn === "Y" ? "completed" : "pending", state: techRplyHstryYn === "Y" ? "completed" : "pending",
}, },
{ label: MAIN_STEP_LABELS.reply, state: "current" }, { label: MAIN_STEP_LABELS.reply, state: "current" },
]; ];
} }
if (prgrsSttsComCd === "EP" || prgrsSttsComCd === "TA") { if (prgrsSttsComCd === "EP" || prgrsSttsComCd === "TA") {
return [ return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" }, { label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" }, { label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{ {
label: MAIN_STEP_LABELS.techReview, label: MAIN_STEP_LABELS.techReview,
state: techRplyHstryYn === "Y" ? "completed" : "pending", state: techRplyHstryYn === "Y" ? "completed" : "pending",
}, },
{ label: MAIN_STEP_LABELS.reply, state: "completed" }, { label: MAIN_STEP_LABELS.reply, state: "completed" },
]; ];
} }
return [ return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" }, { label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" }, { label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" }, { label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" }, { label: MAIN_STEP_LABELS.reply, state: "pending" },
]; ];
} }
function getProgressStatusBadge(prgrsSttsComCd?: string | null) { function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") { if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>; return <Badge color="orange">보완요청</Badge>;
} }
return null; return null;
} }
function getBadgeColor(state: BadgeState) { function getBadgeColor(state: BadgeState) {
switch (state) { switch (state) {
case "completed": case "completed":
return "green"; return "green";
case "current": case "current":
return "blue"; return "blue";
case "pending": case "pending":
return "lightGray"; return "lightGray";
} }
} }
function formatOptional(value: string | null | undefined) { function getBadgeStateText(state: BadgeState) {
return value?.trim() ? value : "-"; switch (state) {
} case "completed":
return "완료";
interface ProgressStepBadgeProps { case "current":
prgrsSttsComCd: string; return "진행중";
techRplyHstryYn: "Y" | "N"; case "pending":
} return "대기";
}
function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadgeProps) { }
const badges = buildProgressBadges(prgrsSttsComCd, techRplyHstryYn);
/** 단계명을 2줄로 분리: "설치인가 신청" → ["설치인가", "신청"], "기술검토" → ["기술검토", ""] */
return ( function splitStepLabel(label: string) {
<div className="flex flex-wrap items-center justify-start"> const [title = "", ...rest] = label.split(" ");
{badges.map((badge, index) => ( return { title, detail: rest.join(" ") };
<Fragment key={badge.label}> }
{index > 0 && <span className="mx-2">&gt;</span>}
<Badge color={getBadgeColor(badge.state)}>{badge.label}</Badge> function formatOptional(value: string | null | undefined) {
</Fragment> return value?.trim() ? value : "-";
))} }
</div>
); interface ProgressStepBadgeProps {
} prgrsSttsComCd: string;
techRplyHstryYn: "Y" | "N";
export function createFnrsCnsltnColumns() { }
return [
columnHelper.accessor("sido", { function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadgeProps) {
header: "시도", const badges = buildProgressBadges(prgrsSttsComCd, techRplyHstryYn);
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()), return (
}), <div className="flex flex-wrap items-center justify-center">
columnHelper.accessor("gugun", { {badges.map((badge, index) => (
header: "시군구", <Fragment key={badge.label}>
meta: { align: "center" }, {index > 0 && <span className="mx-2">&gt;</span>}
cell: ({ getValue }) => formatOptional(getValue()), {/* 1줄: 단계명 / 2줄: 세부단계 + 상태 (예: 설치인가 / 신청완료, 기술검토 / 완료) */}
}), <Badge
columnHelper.accessor("bizNm", { color={getBadgeColor(badge.state)}
header: "사업명", className="w-[100px] flex-col justify-center !px-0 py-1 text-center leading-tight"
meta: { align: "left" }, >
cell: ({ row, getValue }) => ( <span>{splitStepLabel(badge.label).title}</span>
<> <span>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())} {splitStepLabel(badge.label).detail}
</> {getBadgeStateText(badge.state)}
), </span>
}), </Badge>
columnHelper.accessor("taskSeCdNm", { </Fragment>
header: "업무구분", ))}
meta: { align: "center" }, </div>
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd), );
}), }
columnHelper.accessor("aplcntNm", {
header: "담당자", export function createFnrsCnsltnColumns() {
meta: { align: "center" }, return [
cell: ({ getValue }) => formatOptional(getValue()), columnHelper.accessor("sido", {
}), header: "시도",
columnHelper.accessor("envoSeCdNm", { meta: { align: "center" },
header: "해당기관", cell: ({ getValue }) => formatOptional(getValue()),
meta: { align: "center" }, }),
cell: ({ getValue }) => formatOptional(getValue()), columnHelper.accessor("gugun", {
}), header: "시군구",
columnHelper.display({ meta: { align: "center" },
id: "progress", cell: ({ getValue }) => formatOptional(getValue()),
header: "진행상황", }),
meta: { align: "center" }, columnHelper.accessor("bizNm", {
cell: ({ row }) => ( header: "사업명",
<ProgressStepBadge meta: { align: "left" },
prgrsSttsComCd={row.original.prgrsSttsComCd || ""} cell: ({ row, getValue }) => (
techRplyHstryYn={row.original.techRplyHstryYn || "N"} <>
/> {getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
), </>
}), ),
columnHelper.accessor("regDt", { }),
header: "신청일", columnHelper.accessor("taskSeCdNm", {
meta: { align: "center" }, header: "업무구분",
cell: ({ getValue }) => { meta: { align: "center" },
const value = getValue(); cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
return value ? dayjs(value).format("YYYY-MM-DD") : "-"; }),
}, 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") : "-";
},
}),
];
}
@@ -53,21 +53,52 @@ export function createFileVrfcSplmntColumns() {
), ),
}), }),
columnHelper.accessor("fileSeComCd", { columnHelper.accessor("fileSeComCd", {
header: () => <p className="whitespace-nowrap">파일유형</p>, header: "파일유형",
meta: { align: "left" }, // 고정 너비 + 자동 줄바꿈 (긴 텍스트로 표가 가로로 늘어나지 않도록)
meta: { align: "left", width: 100 },
cell: ({ getValue }) => ( cell: ({ getValue }) => (
<p>{formatOptional(FILE_SE_COM_CD_MAP[getValue() as keyof typeof FILE_SE_COM_CD_MAP])}</p> <p className="whitespace-normal break-words">
{formatOptional(FILE_SE_COM_CD_MAP[getValue() as keyof typeof FILE_SE_COM_CD_MAP])}
</p>
), ),
}), }),
columnHelper.accessor("fileSeNm", { columnHelper.accessor("fileSeNm", {
header: () => <p className="whitespace-nowrap">파일구분</p>, header: "파일구분",
meta: { align: "left" }, meta: { align: "left", width: 320 },
cell: ({ getValue }) => <p className="whitespace-nowrap">{formatOptional(getValue())}</p>, // 표 레이아웃과 무관하게 셀 내용 너비를 320px 로 고정하고 넘치는 텍스트는 자동 줄바꿈
cell: ({ getValue }) => (
<p
style={{
width: 320,
maxWidth: 320,
// 검증내역과 글자가 붙어 보이지 않도록 오른쪽 여백
paddingRight: 12,
whiteSpace: "normal",
wordBreak: "normal",
overflowWrap: "anywhere",
}}
>
{formatOptional(getValue())}
</p>
),
}), }),
columnHelper.accessor("vrfcRsltCn", { columnHelper.accessor("vrfcRsltCn", {
header: "검증내역", header: "검증내역",
// 너비 미지정: 나머지 영역을 사용, 넘치는 텍스트는 자동 줄바꿈
meta: { align: "left" }, meta: { align: "left" },
cell: ({ getValue }) => formatOptional(getValue()), cell: ({ getValue }) => (
<p
style={{
// 파일구분과 글자가 붙어 보이지 않도록 왼쪽 여백
paddingLeft: 12,
whiteSpace: "normal",
wordBreak: "normal",
overflowWrap: "anywhere",
}}
>
{formatOptional(getValue())}
</p>
),
}), }),
]; ];
} }
@@ -53,7 +53,7 @@ function ChecklistTaskPanel({
return ( return (
<> <>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table caption="체크리스트 검토 입력: 검토항목, 주요 검토내용, 검토결과 정보 제공" variant="secondary"> <Table caption={`체크리스트 목록으로 검토항목, 주요 검토내용, 검토 결과${selectedReviewStep !== "LOCAL" ? ", 기술검토 의견" : ""} 정보 제공`} variant="secondary">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[20rem]" rowSpan={2}> <TableHead className="w-[20rem]" rowSpan={2}>
@@ -115,7 +115,9 @@ function FileVrfcSplmntDialog({
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}, },
dataTableOptions: { dataTableOptions: {
caption: "파일 검증 보완요청 목록", caption: "파일보완 목록으로 선택, 파일유형, 파일구분, 검증내역 정보 제공",
// 열 너비를 고정해 긴 텍스트는 셀 안에서 줄바꿈
className: "table-fixed",
viewPagination: false, viewPagination: false,
}, },
}); });
@@ -33,6 +33,9 @@ import {
interface PwrkCnsltnDocEvlDialogProps { interface PwrkCnsltnDocEvlDialogProps {
open: boolean; open: boolean;
pwrkCnsltnDocNo: string; pwrkCnsltnDocNo: string;
/** 평가 저장 성공 시 호출 (이후 웹기안기 문서 저장이 진행된다) */
onSaved: () => void;
/** 평가 없이 닫을 때 호출 (웹기안기 문서는 저장되지 않는다) */
onClose: () => void; onClose: () => void;
} }
@@ -41,7 +44,12 @@ const DEFAULT_VALUES: Partial<PwrkCnsltnDocEvlFormValues> = {
pwrkCnsltnDocEvlCn: "", pwrkCnsltnDocEvlCn: "",
}; };
function PwrkCnsltnDocEvlDialog({ open, pwrkCnsltnDocNo, onClose }: PwrkCnsltnDocEvlDialogProps) { function PwrkCnsltnDocEvlDialog({
open,
pwrkCnsltnDocNo,
onSaved,
onClose,
}: PwrkCnsltnDocEvlDialogProps) {
const alert = useAlertStore((state) => state.setMessage); const alert = useAlertStore((state) => state.setMessage);
const { mutateAsync: saveEvl, isPending } = useSavePwrkCnsltnDocEvl(); const { mutateAsync: saveEvl, isPending } = useSavePwrkCnsltnDocEvl();
@@ -65,8 +73,7 @@ function PwrkCnsltnDocEvlDialog({ open, pwrkCnsltnDocNo, onClose }: PwrkCnsltnDo
try { try {
const payload = buildPwrkCnsltnDocEvlSavePayload(pwrkCnsltnDocNo, values); const payload = buildPwrkCnsltnDocEvlSavePayload(pwrkCnsltnDocNo, values);
await saveEvl(payload); await saveEvl(payload);
alert("AI 초안 만족도 평가를 등록했습니다."); onSaved();
onClose();
} catch (error) { } catch (error) {
console.error("AI 초안 만족도 평가 등록 실패:", error); console.error("AI 초안 만족도 평가 등록 실패:", error);
alert("AI 초안 만족도 평가 등록에 실패했습니다."); alert("AI 초안 만족도 평가 등록에 실패했습니다.");
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -46,6 +46,7 @@ const HangulGiangi = dynamic(
const PWRK_ATCH_FILE_KEY_PREFIX = "pwrk-cnsltn-doc::"; const PWRK_ATCH_FILE_KEY_PREFIX = "pwrk-cnsltn-doc::";
/** 현재 버전이 이 값보다 작으면(AI 초안 상태) 저장 전에 만족도 평가를 먼저 받는다. */
const AI_DRAFT_EVL_MAX_VER_SN = 2; const AI_DRAFT_EVL_MAX_VER_SN = 2;
const APPROVER_ATRZ_SE_COM_CDS = new Set(["ATRZ03", "ATRZ99"]); const APPROVER_ATRZ_SE_COM_CDS = new Set(["ATRZ03", "ATRZ99"]);
@@ -88,6 +89,8 @@ function PwrkCnsltnDocMngDialog({
const [uploadedFiles, setUploadedFiles] = useState<UploadedAtchFile[]>([]); const [uploadedFiles, setUploadedFiles] = useState<UploadedAtchFile[]>([]);
const [isPjtcoAjmtTableOpen, setIsPjtcoAjmtTableOpen] = useState(false); const [isPjtcoAjmtTableOpen, setIsPjtcoAjmtTableOpen] = useState(false);
const [isEvlDialogOpen, setIsEvlDialogOpen] = useState(false); const [isEvlDialogOpen, setIsEvlDialogOpen] = useState(false);
// 평가 모달 결과(평가 저장 여부)를 저장 흐름으로 돌려주는 resolver
const evlResolverRef = useRef<((isEvlSaved: boolean) => void) | null>(null);
const { mutate: downloadByAtflId } = useDownloadAttachmentByAtflId(); const { mutate: downloadByAtflId } = useDownloadAttachmentByAtflId();
const { const {
@@ -145,6 +148,8 @@ function PwrkCnsltnDocMngDialog({
setAtchFileGroupId(""); setAtchFileGroupId("");
setUploadedFiles([]); setUploadedFiles([]);
setIsEvlDialogOpen(false); setIsEvlDialogOpen(false);
evlResolverRef.current?.(false);
evlResolverRef.current = null;
return; return;
} }
@@ -178,9 +183,32 @@ function PwrkCnsltnDocMngDialog({
if (!nextOpen) onClose(); 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) { async function handleSaveDocument(file: Blob) {
if (!completedDraft) throw new Error("저장할 실무협의서 초안이 없습니다."); 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({ const saveDto = pwrkCnsltnDocCrtRsltSaveRequestSchema.parse({
crtDmndUuid: completedDraft.crtDmndUuid, crtDmndUuid: completedDraft.crtDmndUuid,
pwrkCnsltnDocNo: completedDraft.pwrkCnsltnDocNo, pwrkCnsltnDocNo: completedDraft.pwrkCnsltnDocNo,
@@ -201,10 +229,6 @@ function PwrkCnsltnDocMngDialog({
}), }),
]); ]);
alert(`실무협의서를 저장했습니다. (버전 ${savedDocument.verSn})`); alert(`실무협의서를 저장했습니다. (버전 ${savedDocument.verSn})`);
if (savedDocument.verSn <= AI_DRAFT_EVL_MAX_VER_SN) {
setIsEvlDialogOpen(true);
}
} }
async function handleDownload() { async function handleDownload() {
@@ -386,7 +410,8 @@ function PwrkCnsltnDocMngDialog({
<PwrkCnsltnDocEvlDialog <PwrkCnsltnDocEvlDialog
open={isEvlDialogOpen} open={isEvlDialogOpen}
pwrkCnsltnDocNo={completedDraft.pwrkCnsltnDocNo} pwrkCnsltnDocNo={completedDraft.pwrkCnsltnDocNo}
onClose={() => setIsEvlDialogOpen(false)} onSaved={() => resolveEvl(true)}
onClose={() => resolveEvl(false)}
/> />
) : null} ) : null}
</> </>
@@ -42,7 +42,7 @@ function PwrkCnsltnDocPjtcoAjmtTable({
} }
return ( return (
<Table caption="사업비 조정 내역: 구분, 검증결과, 검증금액 정보 제공" variant="secondary"> <Table caption="금액검증결과 목록으로 구분, 검증결과, 검증금액 정보 제공" variant="secondary">
<colgroup> <colgroup>
<col width="30%" /> <col width="30%" />
<col width="50%" /> <col width="50%" />
@@ -157,7 +157,7 @@ function CnsltnListTable({
const isSomeSelected = selectedCount > 0 && !isAllSelected; const isSomeSelected = selectedCount > 0 && !isAllSelected;
return ( return (
<Table caption="자문 검토결과 목록: 구분, 검토의견 정보 제공" variant="secondary"> <Table caption="검토 결과 조회 목록으로 선택, 구분, 검토의견 정보 제공" variant="secondary">
<colgroup> <colgroup>
<col width="4%" /> <col width="4%" />
<col width="4%" /> <col width="4%" />
@@ -77,8 +77,8 @@ function FnrsListPage() {
}; };
return ( return (
<div className="space-y-4 px-[1.8rem] pb-6"> <div className="card card-border !mt-6">
<section className="card card-border mx-0 bg-background"> <div className="card card-border">
<form className="flex flex-wrap items-center gap-4 p-2" onSubmit={handleSearch}> <form className="flex flex-wrap items-center gap-4 p-2" onSubmit={handleSearch}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label htmlFor="sidoComCd" className="whitespace-nowrap"> <Label htmlFor="sidoComCd" className="whitespace-nowrap">
@@ -145,7 +145,7 @@ function FnrsListPage() {
</div> </div>
<Button type="submit">조회</Button> <Button type="submit">조회</Button>
</form> </form>
</section> </div>
<FnrsList <FnrsList
searchParams={searchParams} searchParams={searchParams}
@@ -59,7 +59,7 @@ function FnrsList({ searchParams, pagination, onPaginationChange }: FnrsListProp
manualPagination: true, manualPagination: true,
}, },
dataTableOptions: { dataTableOptions: {
caption: "재원협의 목록", caption: "재원협의 목록으로 시도, 시군구, 사업명, 업무구분, 담당자, 해당기관, 진행상황, 신청일 정보 제공",
viewPagination: true, viewPagination: true,
onRowClick: handleRowClick, onRowClick: handleRowClick,
}, },
@@ -78,15 +78,15 @@ function FnrsList({ searchParams, pagination, onPaginationChange }: FnrsListProp
return ( return (
<> <>
<section className="card card-border mx-0 bg-background pb-8 pt-0"> <div className="card card-border pt-0">
<div className="card-header flex items-center justify-between"> <div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-semibold">재원협의 목록</h3> <h3 className="text-2xl font-medium text-label">재원협의 목록</h3>
<Button type="button" color="green" onClick={() => setIsTaskSeDialogOpen(true)}> <Button type="button" color="green" onClick={() => setIsTaskSeDialogOpen(true)}>
신청 신청
</Button> </Button>
</div> </div>
{tableComponent} <div className="card !m-[1.2rem] !p-0">{tableComponent}</div>
</section> </div>
<PbadmsTaskApplyDialog <PbadmsTaskApplyDialog
open={isTaskSeDialogOpen} open={isTaskSeDialogOpen}
onOpenChange={setIsTaskSeDialogOpen} onOpenChange={setIsTaskSeDialogOpen}
@@ -8,6 +8,7 @@ import { SectionCollapsible } from "@/shared/ui";
import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from "@/shared/ui/field"; import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from "@/shared/ui/field";
import { findCodeLabel, useCodeList } from "@/entities/code"; import { findCodeLabel, useCodeList } from "@/entities/code";
import { type BnsApplyData, BnsPickerDialog } from "@/widgets/bns-picker"; import { type BnsApplyData, BnsPickerDialog } from "@/widgets/bns-picker";
import { PreviousPlanLookupButton } from "@/widgets/previous-plan-lookup";
import { import {
Button, Button,
Checkbox, Checkbox,
@@ -27,9 +28,9 @@ function BasicInfoFormSection() {
const { control, watch, setValue } = useFormContext<FnrsCnsltnRegistFormData>(); const { control, watch, setValue } = useFormContext<FnrsCnsltnRegistFormData>();
const [isPickerOpen, setIsPickerOpen] = useState(false); const [isPickerOpen, setIsPickerOpen] = useState(false);
const taskSeComCd = watch("fnrsCnsltnMng.taskSeComCd"); const taskSeComCd = watch("fnrsCnsltnMng.taskSeComCd");
const localGovLabel = [watch("fnrsCnsltnMng.sido"), watch("fnrsCnsltnMng.gugun")] const sido = watch("fnrsCnsltnMng.sido");
.filter(Boolean) const gugun = watch("fnrsCnsltnMng.gugun");
.join(" "); const localGovLabel = [sido, gugun].filter(Boolean).join(" ");
const { data: taskOptions } = useCodeList("FNRS"); const { data: taskOptions } = useCodeList("FNRS");
const { data: rtlztnFcltOptions } = useCodeList("RTLZTNFCLTSE"); const { data: rtlztnFcltOptions } = useCodeList("RTLZTNFCLTSE");
const { data: rtlztnUsgOptions } = useCodeList("RTLZTNUSGSE"); const { data: rtlztnUsgOptions } = useCodeList("RTLZTNUSGSE");
@@ -49,7 +50,8 @@ function BasicInfoFormSection() {
return ( return (
<SectionCollapsible title="기본 정보"> <SectionCollapsible title="기본 정보">
<Table caption="재원협의 기본 정보 입력" variant="secondary"> <PreviousPlanLookupButton sidoNm={sido} gugunNm={gugun} />
<Table caption="기본 정보 입력으로 사업명, 사업기간, 사업내용, 구분, 신청 지자체, 시설종류, 재이용용도 정보 제공" variant="secondary">
<colgroup> <colgroup>
<col width="15%" /> <col width="15%" />
<col width="35%" /> <col width="35%" />
@@ -2,6 +2,7 @@
import { SectionCollapsible } from "@/shared/ui"; import { SectionCollapsible } from "@/shared/ui";
import type { FnrsCnsltnMngResponseDto } from "@/entities/fnrs-cnsltn"; import type { FnrsCnsltnMngResponseDto } from "@/entities/fnrs-cnsltn";
import { PreviousPlanLookupButton } from "@/widgets/previous-plan-lookup";
import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components"; import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
interface BasicInfoSectionProps { interface BasicInfoSectionProps {
@@ -11,7 +12,8 @@ interface BasicInfoSectionProps {
function BasicInfoSection({ det }: BasicInfoSectionProps) { function BasicInfoSection({ det }: BasicInfoSectionProps) {
return ( return (
<SectionCollapsible title="기본 정보"> <SectionCollapsible title="기본 정보">
<Table caption="재원협의 기본 정보" variant="secondary"> <PreviousPlanLookupButton sidoNm={det.sido} gugunNm={det.gugun} />
<Table caption="기본 정보로 사업명, 사업기간, 사업내용, 환경청구분, 구분, 신청지자체, 확정예산사업번호, 예산신청연도 정보 제공" variant="secondary">
<colgroup> <colgroup>
<col width="15%" /> <col width="15%" />
<col width="35%" /> <col width="35%" />
@@ -20,7 +20,7 @@ function BusinessCostSection({ bizCstList }: BusinessCostSectionProps) {
<SectionCollapsible title="총사업비"> <SectionCollapsible title="총사업비">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<p className="mb-2 text-right font-normal text-muted-foreground">(단위: 백만원)</p> <p className="mb-2 text-right font-normal text-muted-foreground">(단위: 백만원)</p>
<Table caption="총사업비 내역: 구분, 예산반영, 표준사업비, 금회설계, 증감 정보 제공" variant="secondary" className="table-fixed"> <Table caption="총사업비 목록으로 구분, 예산반영(A), 표준사업비(B), 금회설계시(C), 증감 정보 제공" variant="secondary" className="table-fixed">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[30rem]" rowSpan={2}> <TableHead className="w-[30rem]" rowSpan={2}>
@@ -13,7 +13,7 @@ function BusinessScaleSection({ bizSclList }: BusinessScaleSectionProps) {
return ( return (
<SectionCollapsible title="사업규모"> <SectionCollapsible title="사업규모">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table caption="사업규모 내역: 구분, 기본계획, 예산반영, 금회설계, 증감 정보 제공" variant="secondary"> <Table caption="사업규모 목록으로 구분, 기본계획(A), 예산반영(B), 금회설계시(C), 증감(C-B) 정보 제공" variant="secondary">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[30rem]">구분</TableHead> <TableHead className="w-[30rem]">구분</TableHead>
@@ -86,7 +86,13 @@ function ChecklistSection({
> >
<TabsList> <TabsList>
{visibleReviewSteps.map((stepCode) => ( {visibleReviewSteps.map((stepCode) => (
<TabsTrigger key={stepCode} value={stepCode} className="!px-6 !py-3 text-xl"> <TabsTrigger
key={stepCode}
value={stepCode}
id={`fnrs-cklst-step-tab-${stepCode}`}
aria-controls="fnrs-cklst-task-tabs"
className="!px-6 !py-3 text-xl"
>
{CHECKLIST_TAB_LABELS[stepCode]} {CHECKLIST_TAB_LABELS[stepCode]}
</TabsTrigger> </TabsTrigger>
))} ))}
@@ -96,6 +102,7 @@ function ChecklistSection({
{taskCodes.length > 0 ? ( {taskCodes.length > 0 ? (
<Tabs <Tabs
id="fnrs-cklst-task-tabs"
value={selectedTaskCode ?? taskCodes[0]} value={selectedTaskCode ?? taskCodes[0]}
onValueChange={setSelectedTaskCode} onValueChange={setSelectedTaskCode}
className="w-full" className="w-full"
@@ -107,6 +114,8 @@ function ChecklistSection({
<TabsTrigger <TabsTrigger
key={code} key={code}
value={code} value={code}
id={`fnrs-cklst-tab-${code}`}
aria-controls={`fnrs-cklst-panel-${code}`}
className="!px-6 !py-3 text-xl [&:after]:hidden" className="!px-6 !py-3 text-xl [&:after]:hidden"
> >
{TASK_CODE_LABELS[code] ?? code} {TASK_CODE_LABELS[code] ?? code}
@@ -120,7 +129,15 @@ function ChecklistSection({
</div> </div>
{taskCodes.map((code) => ( {taskCodes.map((code) => (
<TabsContent key={code} value={code} className="mt-0 border-0 p-0 shadow-none ring-0"> <TabsContent
key={code}
value={code}
id={`fnrs-cklst-panel-${code}`}
aria-labelledby={`fnrs-cklst-tab-${code}`}
className="mt-0 border-0 p-0 shadow-none ring-0"
>
{/* 선택된 탭(검토단계·업무구분)의 패널임을 알 수 있도록 숨김 제목 제공 */}
<h3 className="sr-only">{CHECKLIST_TAB_LABELS[selectedReviewStep]} {TASK_CODE_LABELS[code] ?? code} 체크리스트</h3>
<ChecklistTaskPanel <ChecklistTaskPanel
selectedReviewStep={selectedReviewStep} selectedReviewStep={selectedReviewStep}
selectedRows={getChecklistDisplayRows(cklstResultList, code)} selectedRows={getChecklistDisplayRows(cklstResultList, code)}
@@ -11,7 +11,7 @@ interface ContactInfoSectionProps {
function ContactInfoSection({ det }: ContactInfoSectionProps) { function ContactInfoSection({ det }: ContactInfoSectionProps) {
return ( return (
<SectionCollapsible title="담당자 정보"> <SectionCollapsible title="담당자 정보">
<Table caption="재원협의 담당자 정보" variant="secondary"> <Table caption="담당자 정보로 신청자(이름, 연락처, 소속, 이메일)과 설계자(이름, 연락처, 소속, 이메일) 정보 제공" variant="secondary">
<TableHeader> <TableHeader>
{det.aplcntNm && ( {det.aplcntNm && (
<> <>
@@ -21,7 +21,7 @@ function FnrsSharSection({ fnrsSharList }: FnrsSharSectionProps) {
<SectionCollapsible title="재원분담"> <SectionCollapsible title="재원분담">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<p className="mb-2 text-right font-normal text-muted-foreground">(단위: 백만원)</p> <p className="mb-2 text-right font-normal text-muted-foreground">(단위: 백만원)</p>
<Table caption="재원분담 내역: 구분, 현행, 변경요구, 증감, 비고 정보 제공" variant="secondary" className="table-fixed"> <Table caption="재원분담 목록으로 구분, 현행(A), 변경요구(B), 증감, 비고 정보 제공" variant="secondary" className="table-fixed">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[24rem]" rowSpan={2}> <TableHead className="w-[24rem]" rowSpan={2}>
@@ -1,352 +1,347 @@
"use client"; "use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react"; import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib"; import { useQueryClient } from "@tanstack/react-query";
import { AttachmentFileField, SectionCollapsible } from "@/shared/ui";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field"; import { getDefaultFileKey, useTus, useUserStore } from "@/shared/lib";
import { import { AttachmentFileField, SectionCollapsible } from "@/shared/ui";
AttachmentDownloadLink, import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
attachService, import { attachService, useDownloadAttachmentByAtflId } from "@/entities/attach";
useDownloadAttachmentByAtflId, import {
} from "@/entities/attach"; fnrsCnsltnKeys,
import type { FnrsCnsltnRvwOpnnResponseDto } from "@/entities/fnrs-cnsltn"; type FnrsCnsltnRvwOpnnResponseDto,
import { fnrsCnsltnRvwOpnnService,
RadioGroup, } from "@/entities/fnrs-cnsltn";
RadioGroupItem, import { RvwOpnnListTable } from "@/widgets/pbadms-sprt";
Table, import {
TableBody, RadioGroup,
TableCell, RadioGroupItem,
TableHead, Table,
TableHeader, TableCell,
TableRow, TableHead,
Textarea, TableHeader,
} from "@common/components"; TableRow,
Textarea,
import type { } from "@common/components";
ReviewOpinionFormData,
ReviewOpinionGetFormDataResult, import type {
} from "../../lib/review-opinion-utils"; ReviewOpinionFormData,
import type { ReviewStepCode } from "../../model/view-types"; ReviewOpinionGetFormDataResult,
import { REVIEW_STEP_LABELS } from "../../model/view-types"; } from "../../lib/review-opinion-utils";
import type { ReviewStepCode } from "../../model/view-types";
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::"; import { REVIEW_STEP_LABELS } from "../../model/view-types";
interface TechReviewOpinionSectionProps { const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
rvwList: FnrsCnsltnRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode; interface TechReviewOpinionSectionProps {
atrzSeComCdList?: string[]; rvwList: FnrsCnsltnRvwOpnnResponseDto[];
isAtrzLoading?: boolean; editableReviewStep?: ReviewStepCode;
} atrzSeComCdList?: string[];
isAtrzLoading?: boolean;
export interface TechReviewOpinionSectionHandle { }
getFormData: () => ReviewOpinionGetFormDataResult | null;
resetForm: () => void; export interface TechReviewOpinionSectionHandle {
} getFormData: () => ReviewOpinionGetFormDataResult | null;
resetForm: () => void;
interface TechReviewOpinionTableProps { }
items: FnrsCnsltnRvwOpnnResponseDto[];
} interface TechReviewOpinionTableProps {
items: FnrsCnsltnRvwOpnnResponseDto[];
interface UploadedOpinionFile { isReviewTurn: boolean;
fileName: string; }
fileKey: string;
atflId?: string; interface UploadedOpinionFile {
atflCd?: string; fileName: string;
} fileKey: string;
atflId?: string;
const EMPTY_FORM_DATA: ReviewOpinionFormData = { atflCd?: string;
rvwRsltCn: "", }
rvwRsltAnmlyYn: "",
atchFileGroupId: "", const EMPTY_FORM_DATA: ReviewOpinionFormData = {
opnrSeComCd: "", rvwRsltCn: "",
}; rvwRsltAnmlyYn: "",
atchFileGroupId: "",
function createRvwOpnnUploadKey(file: File) { opnrSeComCd: "",
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`; };
}
function createRvwOpnnUploadKey(file: File) {
function hasFormContent(formData: ReviewOpinionFormData) { return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId); }
}
function hasFormContent(formData: ReviewOpinionFormData) {
const TechReviewOpinionSection = forwardRef< return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
TechReviewOpinionSectionHandle, }
TechReviewOpinionSectionProps
>(function TechReviewOpinionSection( const TechReviewOpinionSection = forwardRef<
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false }, TechReviewOpinionSectionHandle,
ref, TechReviewOpinionSectionProps
) { >(function TechReviewOpinionSection(
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02"); { rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
const [formData, setFormData] = useState<ReviewOpinionFormData>({ ref,
...EMPTY_FORM_DATA, ) {
opnrSeComCd: editableReviewStep ?? "", const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
}); const [formData, setFormData] = useState<ReviewOpinionFormData>({
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null); ...EMPTY_FORM_DATA,
const { mutate: downloadByAtflId } = useDownloadAttachmentByAtflId(); opnrSeComCd: editableReviewStep ?? "",
});
useEffect(() => { const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
setFormData((prev) => ({ const { mutate: downloadByAtflId } = useDownloadAttachmentByAtflId();
...prev,
opnrSeComCd: editableReviewStep ?? "", useEffect(() => {
})); setFormData((prev) => ({
}, [editableReviewStep]); ...prev,
opnrSeComCd: editableReviewStep ?? "",
const { uploadFileAsync, isUploading, uploads } = useTus({ }));
onSuccess: (atflCd, atflId, _file, fileKey) => { }, [editableReviewStep]);
if (!atflCd || !atflId) return;
const { uploadFileAsync, isUploading, uploads } = useTus({
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd })); onSuccess: (atflCd, atflId, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev)); if (!atflCd || !atflId) return;
},
onError: (_error, _file, fileKey) => { setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev)); setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
}, },
}); onError: (_error, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
useImperativeHandle( },
ref, });
() => ({
getFormData: () => { useImperativeHandle(
if (!editableReviewStep || !hasFormContent(formData)) { ref,
return null; () => ({
} getFormData: () => {
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) { if (!editableReviewStep || !hasFormContent(formData)) {
return null; return null;
} }
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
return { return null;
formData: { }
...formData,
opnrSeComCd: editableReviewStep, return {
}, formData: {
rvwList, ...formData,
}; opnrSeComCd: editableReviewStep,
}, },
resetForm: () => { rvwList,
setFormData({ };
...EMPTY_FORM_DATA, },
opnrSeComCd: editableReviewStep ?? "", resetForm: () => {
}); setFormData({
setUploadedFile(null); ...EMPTY_FORM_DATA,
}, opnrSeComCd: editableReviewStep ?? "",
}), });
[canWriteKecoOpinion, editableReviewStep, formData, rvwList], setUploadedFile(null);
); },
}),
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV"); [canWriteKecoOpinion, editableReviewStep, formData, rvwList],
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO"); );
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV"; const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
const hasKecoSection = kecoRvwList.length > 0 || editableReviewStep === "KECO"; const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
if (!hasEnvSection && !hasKecoSection) { const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
return null; const hasKecoSection = kecoRvwList.length > 0 || editableReviewStep === "KECO";
}
if (!hasEnvSection && !hasKecoSection) {
async function handleFileUpload(files: FileList | null) { return null;
if (!files || files.length === 0) return; }
const file = files[0]; async function handleFileUpload(files: FileList | null) {
const fileKey = createRvwOpnnUploadKey(file); if (!files || files.length === 0) return;
const existingAtflCd = formData.atchFileGroupId;
const file = files[0];
setUploadedFile({ fileName: file.name, fileKey }); const fileKey = createRvwOpnnUploadKey(file);
const existingAtflCd = formData.atchFileGroupId;
try {
await uploadFileAsync(file, { setUploadedFile({ fileName: file.name, fileKey });
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined, try {
}); await uploadFileAsync(file, {
} catch { fileKey,
// onError callback handles cleanup metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
} });
} } catch {
// onError callback handles cleanup
async function handleFileRemove() { }
if (!uploadedFile) return; }
uploads[uploadedFile.fileKey]?.abort(); async function handleFileRemove() {
if (!uploadedFile) return;
if (uploadedFile.atflId) {
try { uploads[uploadedFile.fileKey]?.abort();
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} catch (error) { if (uploadedFile.atflId) {
console.error("검토의견 첨부파일 삭제 실패:", error); try {
} await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} } catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error);
setUploadedFile(null); }
setFormData((prev) => ({ ...prev, atchFileGroupId: "" })); }
}
setUploadedFile(null);
function renderOpinionForm(reviewStep: ReviewStepCode) { setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
if (editableReviewStep !== reviewStep) return null; }
if (reviewStep === "KECO" && !canWriteKecoOpinion) return null;
function isReviewTurn(reviewStep: ReviewStepCode) {
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false; if (editableReviewStep !== reviewStep) return false;
if (reviewStep === "KECO" && !canWriteKecoOpinion) return false;
return ( return true;
<div className="space-y-2"> }
<h4 className="text-xl font-semibold">검토의견 작성</h4>
<Table caption="검토의견 입력" variant="secondary"> function renderOpinionForm(reviewStep: ReviewStepCode) {
<TableHeader> if (!isReviewTurn(reviewStep)) return null;
<TableRow>
<TableHead className="w-[12rem]">의견내용</TableHead> const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
<TableCell>
<Textarea return (
value={formData.rvwRsltCn} <div className="space-y-2">
onChange={(event) => <h4 className="text-xl font-semibold">검토의견 작성</h4>
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value })) <Table
} caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공"
placeholder="의견내용을 입력하세요" variant="secondary"
rows={4} >
className="min-h-[8rem] !text-xl" <TableHeader>
/> <TableRow>
</TableCell> <TableHead className="w-[12rem]">의견내용</TableHead>
</TableRow> <TableCell>
<TableRow> <Textarea
<TableHead>검토결과</TableHead> value={formData.rvwRsltCn}
<TableCell> onChange={(event) =>
<FieldSet> setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
<RadioGroup }
value={formData.rvwRsltAnmlyYn} placeholder="의견내용을 입력하세요"
onValueChange={(value) => rows={4}
setFormData((prev) => ({ className="min-h-[8rem] !text-xl"
...prev, />
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"], </TableCell>
})) </TableRow>
} <TableRow>
> <TableHead>검토결과</TableHead>
<div className="flex flex-wrap gap-4"> <TableCell>
<FieldLabel <FieldSet>
htmlFor={`rvw-opnn-normal-${reviewStep}`} <RadioGroup
className="!w-auto border-none" value={formData.rvwRsltAnmlyYn}
> onValueChange={(value) =>
<Field orientation="horizontal" className="w-auto"> setFormData((prev) => ({
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} /> ...prev,
<span className="font-normal">정상</span> rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
</Field> }))
</FieldLabel> }
<FieldLabel >
htmlFor={`rvw-opnn-abnormal-${reviewStep}`} <div className="flex flex-wrap gap-4">
className="!w-auto border-none" <FieldLabel
> htmlFor={`rvw-opnn-normal-${reviewStep}`}
<Field orientation="horizontal" className="w-auto"> className="!w-auto border-none"
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} /> >
<span className="font-normal">이상</span> <Field orientation="horizontal" className="w-auto">
</Field> <RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
</FieldLabel> <span className="font-normal">정상</span>
</div> </Field>
</RadioGroup> </FieldLabel>
</FieldSet> <FieldLabel
</TableCell> htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
</TableRow> className="!w-auto border-none"
<TableRow> >
<TableHead>첨부파일</TableHead> <Field orientation="horizontal" className="w-auto">
<TableCell> <RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
<AttachmentFileField <span className="font-normal">이상</span>
files={ </Field>
uploadedFile </FieldLabel>
? [ </div>
{ </RadioGroup>
fileKey: uploadedFile.fileKey, </FieldSet>
fileName: uploadedFile.fileName, </TableCell>
onDownload: uploadedFile.atflId </TableRow>
? () => downloadByAtflId(uploadedFile.atflId!) <TableRow>
: undefined, <TableHead>첨부파일</TableHead>
}, <TableCell>
] <AttachmentFileField
: [] files={
} uploadedFile
inputId={`rvw-opnn-${reviewStep}-file-input`} ? [
isUploadingByKey={isUploading} {
multiple={false} fileKey: uploadedFile.fileKey,
showAddButton={!uploadedFile} fileName: uploadedFile.fileName,
addDisabled={uploading} onDownload: uploadedFile.atflId
onAdd={(fileList) => void handleFileUpload(fileList)} ? () => downloadByAtflId(uploadedFile.atflId!)
onRemove={() => void handleFileRemove()} : undefined,
/> },
</TableCell> ]
</TableRow> : []
</TableHeader> }
</Table> inputId={`rvw-opnn-${reviewStep}-file-input`}
</div> isUploadingByKey={isUploading}
); multiple={false}
} showAddButton={!uploadedFile}
addDisabled={uploading}
return ( onAdd={(fileList) => void handleFileUpload(fileList)}
<> onRemove={() => void handleFileRemove()}
{hasEnvSection ? ( />
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}> </TableCell>
<div className="space-y-6"> </TableRow>
{envRvwList.length > 0 ? <TechReviewOpinionTable items={envRvwList} /> : null} </TableHeader>
{renderOpinionForm("ENV")} </Table>
</div> </div>
</SectionCollapsible> );
) : null} }
{hasKecoSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}> return (
<div className="space-y-6"> <>
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null} {hasEnvSection ? (
{/* <SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
재원협의에서 환경공단 검토의견은 자동 생성 <div className="space-y-6">
{renderOpinionForm("KECO")} {envRvwList.length > 0 ? (
*/} <TechReviewOpinionTable items={envRvwList} isReviewTurn={isReviewTurn("ENV")} />
</div> ) : null}
</SectionCollapsible> {renderOpinionForm("ENV")}
) : null} </div>
</> </SectionCollapsible>
); ) : null}
}); {hasKecoSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) { <div className="space-y-6">
return ( {kecoRvwList.length > 0 ? (
<div className="overflow-x-auto"> <TechReviewOpinionTable items={kecoRvwList} isReviewTurn={isReviewTurn("KECO")} />
<Table caption="검토의견 목록: 의견자, 의견내용, 검토결과, 첨부파일, 등록일시 정보 제공" variant="secondary"> ) : null}
<colgroup> {/*
<col width="10%" /> 재원협의에서 환경공단 검토의견은 자동 생성
<col width="55%" /> {renderOpinionForm("KECO")}
<col width="5%" /> */}
<col width="20%" /> </div>
<col width="10%" /> </SectionCollapsible>
</colgroup> ) : null}
<TableHeader> </>
<TableRow> );
<TableHead>의견자명</TableHead> });
<TableHead>의견내용</TableHead>
<TableHead>검토결과</TableHead> function getRvwOpnnIds(item: FnrsCnsltnRvwOpnnResponseDto) {
<TableHead>첨부파일</TableHead> if (!item.fnrsCnsltnDocNo || item.rvwOpnnSn == null) {
<TableHead>등록일시</TableHead> throw new Error("검토의견 식별값이 없습니다.");
</TableRow> }
</TableHeader> return [item.fnrsCnsltnDocNo, item.rvwOpnnSn] as const;
<TableBody> }
{items.map((item) => (
<TableRow key={`${item.fnrsCnsltnDocNo}_${item.rvwOpnnSn}`}> function TechReviewOpinionTable({ items, isReviewTurn }: TechReviewOpinionTableProps) {
<TableCell align="center">{item.opnrNm}</TableCell> const queryClient = useQueryClient();
<TableCell align="left">{item.rvwRsltCn || "-"}</TableCell> const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
<TableCell align="center">
{item.rvwRsltAnmlyYn === "Y" ? "이상" : item.rvwRsltAnmlyYn === "N" ? "정상" : "-"} return (
</TableCell> <RvwOpnnListTable
<TableCell align="center"> items={items}
{item.atchFileInfo?.atflCd ? ( getRowKey={(item) => `${item.fnrsCnsltnDocNo}_${item.rvwOpnnSn}`}
<AttachmentDownloadLink currentUserUuid={currentUserUuid}
atflCd={item.atchFileInfo.atflCd} isReviewTurn={isReviewTurn}
fileName={item.atchFileInfo.orgnlFileNm || "다운로드"} onUpdate={async (item, body) => {
/> await fnrsCnsltnRvwOpnnService.update(...getRvwOpnnIds(item), body);
) : ( await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
"-" }}
)} onDelete={async (item) => {
</TableCell> await fnrsCnsltnRvwOpnnService.delete(...getRvwOpnnIds(item));
<TableCell align="center"> await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"} }}
</TableCell> />
</TableRow> );
))} }
</TableBody>
</Table> export { TechReviewOpinionSection };
</div>
);
}
export { TechReviewOpinionSection };
+231 -204
View File
@@ -1,204 +1,231 @@
"use client"; "use client";
import { Fragment } from "react"; import { Fragment } from "react";
import { createColumnHelper } from "@tanstack/react-table"; import { createColumnHelper } from "@tanstack/react-table";
import { dayjs } from "@/shared/lib"; import { dayjs } from "@/shared/lib";
import type { InstlSncnMngResponseDto } from "@/entities/instl-sncn"; import type { InstlSncnMngResponseDto } from "@/entities/instl-sncn";
import { Badge } from "@common/components"; import { Badge } from "@common/components";
const columnHelper = createColumnHelper<InstlSncnMngResponseDto>(); const columnHelper = createColumnHelper<InstlSncnMngResponseDto>();
const MAIN_STEP_LABELS = { const MAIN_STEP_LABELS = {
apply: "설치인가 신청", apply: "설치인가 신청",
envoReview: "설치인가 검토", envoReview: "설치인가 검토",
techReview: "기술검토", techReview: "기술검토",
reply: "검토결과 회신", reply: "검토결과 회신",
} as const; } as const;
export const PROGRESS_STATUS_COLUMNS = [ export const PROGRESS_STATUS_COLUMNS = [
{ label: "설치인가 신청 (시군)", sttsComCd: "ST" }, { label: "설치인가 신청 (시군)", sttsComCd: "ST" },
{ label: "설치인가 신청 (시도)", sttsComCd: "LE" }, { label: "설치인가 신청 (시도)", sttsComCd: "LE" },
{ label: "설치인가 검토", sttsComCd: "ER" }, { label: "설치인가 검토", sttsComCd: "ER" },
{ label: "기술검토", sttsComCd: "TE" }, { label: "기술검토", sttsComCd: "TE" },
{ label: "기술검토 회신", sttsComCd: "TP" }, { label: "기술검토 회신", sttsComCd: "TP" },
{ label: "검토결과 회신", sttsComCd: "EP" }, { label: "검토결과 회신", sttsComCd: "EP" },
{ label: "설치인가 승인", sttsComCd: "TA" }, { label: "설치인가 승인", sttsComCd: "TA" },
]; ];
type BadgeState = "completed" | "current" | "pending"; type BadgeState = "completed" | "current" | "pending";
interface ProgressBadge { interface ProgressBadge {
label: string; label: string;
state: BadgeState; state: BadgeState;
} }
function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N"): ProgressBadge[] { function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N"): ProgressBadge[] {
if (prgrsSttsComCd === "SV") { if (prgrsSttsComCd === "SV") {
return [ return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" }, { label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" }, { label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" }, { label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" }, { label: MAIN_STEP_LABELS.reply, state: "pending" },
]; ];
} }
if (prgrsSttsComCd === "ST") { if (prgrsSttsComCd === "ST") {
return [ return [
{ label: MAIN_STEP_LABELS.apply, state: "current" }, { label: MAIN_STEP_LABELS.apply, state: "current" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" }, { label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" }, { label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" }, { label: MAIN_STEP_LABELS.reply, state: "pending" },
]; ];
} }
if (prgrsSttsComCd === "LE") { if (prgrsSttsComCd === "LE") {
return [ return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" }, { label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "current" }, { label: MAIN_STEP_LABELS.envoReview, state: "current" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" }, { label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" }, { label: MAIN_STEP_LABELS.reply, state: "pending" },
]; ];
} }
if (prgrsSttsComCd === "ER" || prgrsSttsComCd === "TE" || prgrsSttsComCd === "SL") { if (prgrsSttsComCd === "ER" || prgrsSttsComCd === "TE" || prgrsSttsComCd === "SL") {
return [ return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" }, { label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" }, { label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "current" }, { label: MAIN_STEP_LABELS.techReview, state: "current" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" }, { label: MAIN_STEP_LABELS.reply, state: "pending" },
]; ];
} }
if (prgrsSttsComCd === "TP" || prgrsSttsComCd === "EP") { if (prgrsSttsComCd === "TP" || prgrsSttsComCd === "EP") {
return [ return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" }, { label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" }, { label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{ {
label: MAIN_STEP_LABELS.techReview, label: MAIN_STEP_LABELS.techReview,
state: techRplyHstryYn === "Y" ? "completed" : "pending", state: techRplyHstryYn === "Y" ? "completed" : "pending",
}, },
{ label: MAIN_STEP_LABELS.reply, state: "current" }, { label: MAIN_STEP_LABELS.reply, state: "current" },
]; ];
} }
if (prgrsSttsComCd === "TA" || prgrsSttsComCd === "TB") { if (prgrsSttsComCd === "TA" || prgrsSttsComCd === "TB") {
return [ return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" }, { label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" }, { label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{ {
label: MAIN_STEP_LABELS.techReview, label: MAIN_STEP_LABELS.techReview,
state: techRplyHstryYn === "Y" ? "completed" : "pending", state: techRplyHstryYn === "Y" ? "completed" : "pending",
}, },
{ label: MAIN_STEP_LABELS.reply, state: "completed" }, { label: MAIN_STEP_LABELS.reply, state: "completed" },
]; ];
} }
return [ return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" }, { label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" }, { label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" }, { label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" }, { label: MAIN_STEP_LABELS.reply, state: "pending" },
]; ];
} }
function getProgressStatusBadge(prgrsSttsComCd?: string | null) { function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") { if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>; return <Badge color="orange">보완요청</Badge>;
} }
return null; return null;
} }
function getBadgeColor(state: BadgeState) { function getBadgeColor(state: BadgeState) {
switch (state) { switch (state) {
case "completed": case "completed":
return "green"; return "green";
case "current": case "current":
return "blue"; return "blue";
case "pending": case "pending":
return "lightGray"; return "lightGray";
} }
} }
function formatOptional(value: string | null | undefined) { function getBadgeStateText(state: BadgeState) {
return value?.trim() ? value : "-"; switch (state) {
} case "completed":
return "완료";
interface ProgressStepBadgeProps { case "current":
prgrsSttsComCd: string; return "진행중";
techRplyHstryYn: "Y" | "N"; case "pending":
} return "대기";
}
function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadgeProps) { }
const badges = buildProgressBadges(prgrsSttsComCd, techRplyHstryYn);
/** 단계명을 2줄로 분리: "설치인가 신청" → ["설치인가", "신청"], "기술검토" → ["기술검토", ""] */
return ( function splitStepLabel(label: string) {
<div className="flex flex-wrap items-center justify-start"> const [title = "", ...rest] = label.split(" ");
{badges.map((badge, index) => ( return { title, detail: rest.join(" ") };
<Fragment key={badge.label}> }
{index > 0 && <span className="mx-2">&gt;</span>}
<Badge color={getBadgeColor(badge.state)}>{badge.label}</Badge> function formatOptional(value: string | null | undefined) {
</Fragment> return value?.trim() ? value : "-";
))} }
</div>
); interface ProgressStepBadgeProps {
} prgrsSttsComCd: string;
techRplyHstryYn: "Y" | "N";
export function createInstlSncnColumns() { }
return [
columnHelper.accessor("sido", { function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadgeProps) {
header: "시도", const badges = buildProgressBadges(prgrsSttsComCd, techRplyHstryYn);
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()), return (
}), <div className="flex flex-wrap items-center justify-center">
columnHelper.accessor("gugun", { {badges.map((badge, index) => (
header: "시군구", <Fragment key={badge.label}>
meta: { align: "center" }, {index > 0 && <span className="mx-2">&gt;</span>}
cell: ({ getValue }) => formatOptional(getValue()), {/* 1줄: 단계명 / 2줄: 세부단계 + 상태 (예: 설치인가 / 신청완료, 기술검토 / 완료) */}
}), <Badge
columnHelper.accessor("bizNm", { color={getBadgeColor(badge.state)}
header: "사업명", className="w-[100px] flex-col justify-center !px-0 py-1 text-center leading-tight"
meta: { align: "left" }, >
cell: ({ row, getValue }) => ( <span>{splitStepLabel(badge.label).title}</span>
<> <span>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())} {splitStepLabel(badge.label).detail}
</> {getBadgeStateText(badge.state)}
), </span>
}), </Badge>
columnHelper.accessor("taskSeCdNm", { </Fragment>
header: "업무구분", ))}
meta: { align: "center" }, </div>
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd), );
}), }
columnHelper.accessor("aplcntNm", {
header: "담당자", export function createInstlSncnColumns() {
meta: { align: "center" }, return [
cell: ({ getValue }) => formatOptional(getValue()), columnHelper.accessor("sido", {
}), header: "시도",
columnHelper.accessor("envoSeCdNm", { meta: { align: "center" },
header: "해당기관", cell: ({ getValue }) => formatOptional(getValue()),
meta: { align: "center" }, }),
cell: ({ getValue }) => formatOptional(getValue()), columnHelper.accessor("gugun", {
}), header: "시군구",
columnHelper.display({ meta: { align: "center" },
id: "progress", cell: ({ getValue }) => formatOptional(getValue()),
header: "진행상황", }),
meta: { align: "center" }, columnHelper.accessor("bizNm", {
cell: ({ row }) => ( header: "사업명",
<ProgressStepBadge meta: { align: "left" },
prgrsSttsComCd={row.original.prgrsSttsComCd || ""} cell: ({ row, getValue }) => (
techRplyHstryYn={row.original.techRplyHstryYn || "N"} <>
/> {getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
), </>
}), ),
columnHelper.accessor("regDt", { }),
header: "신청일", columnHelper.accessor("taskSeCdNm", {
meta: { align: "center" }, header: "업무구분",
cell: ({ getValue }) => { meta: { align: "center" },
const value = getValue(); cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
return value ? dayjs(value).format("YYYY-MM-DD") : "-"; }),
}, 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") : "-";
},
}),
];
}
@@ -52,7 +52,7 @@ function ChecklistTaskPanel({
return ( return (
<> <>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table caption="체크리스트 검토 입력: 검토항목, 주요 검토내용, 검토결과 정보 제공" variant="secondary"> <Table caption={`체크리스트 목록으로 검토항목, 주요 검토내용, 검토 결과${selectedReviewStep !== "LOCAL" ? ", 기술검토 의견" : ""} 정보 제공`} variant="secondary">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[20rem]" rowSpan={2}> <TableHead className="w-[20rem]" rowSpan={2}>
@@ -77,8 +77,8 @@ function InstlListPage() {
}; };
return ( return (
<div className="space-y-4 px-[1.8rem] pb-6"> <div className="card card-border !mt-6">
<section className="card card-border mx-0 bg-background"> <div className="card card-border">
<form className="flex flex-wrap items-center gap-4 p-2" onSubmit={handleSearch}> <form className="flex flex-wrap items-center gap-4 p-2" onSubmit={handleSearch}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label htmlFor="sidoComCd" className="whitespace-nowrap"> <Label htmlFor="sidoComCd" className="whitespace-nowrap">
@@ -145,7 +145,7 @@ function InstlListPage() {
</div> </div>
<Button type="submit">조회</Button> <Button type="submit">조회</Button>
</form> </form>
</section> </div>
<InstlList <InstlList
searchParams={searchParams} searchParams={searchParams}
@@ -59,7 +59,7 @@ function InstlList({ searchParams, pagination, onPaginationChange }: InstlListPr
manualPagination: true, manualPagination: true,
}, },
dataTableOptions: { dataTableOptions: {
caption: "설치승인 목록", caption: "설치인가 목록으로 시도, 시군구, 사업명, 업무구분, 담당자, 해당기관, 진행상황, 신청일 정보 제공",
viewPagination: true, viewPagination: true,
onRowClick: handleRowClick, onRowClick: handleRowClick,
}, },
@@ -82,15 +82,15 @@ function InstlList({ searchParams, pagination, onPaginationChange }: InstlListPr
return ( return (
<> <>
<section className="card card-border mx-0 bg-background pb-8 pt-0"> <div className="card card-border pt-0">
<div className="card-header flex items-center justify-between"> <div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-semibold">설치인가 목록</h3> <h3 className="text-2xl font-medium text-label">설치인가 목록</h3>
<Button type="button" color="green" onClick={handleRegisterClick}> <Button type="button" color="green" onClick={handleRegisterClick}>
신청 신청
</Button> </Button>
</div> </div>
{tableComponent} <div className="card !m-[1.2rem] !p-0">{tableComponent}</div>
</section> </div>
<PbadmsTaskApplyDialog <PbadmsTaskApplyDialog
open={isTaskSeDialogOpen} open={isTaskSeDialogOpen}
onOpenChange={setIsTaskSeDialogOpen} onOpenChange={setIsTaskSeDialogOpen}
@@ -8,6 +8,7 @@ import { SectionCollapsible } from "@/shared/ui";
import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from "@/shared/ui/field"; import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from "@/shared/ui/field";
import { findCodeLabel, useCodeList } from "@/entities/code"; import { findCodeLabel, useCodeList } from "@/entities/code";
import { type BnsApplyData, BnsPickerDialog } from "@/widgets/bns-picker"; import { type BnsApplyData, BnsPickerDialog } from "@/widgets/bns-picker";
import { PreviousPlanLookupButton } from "@/widgets/previous-plan-lookup";
import { import {
Button, Button,
Checkbox, Checkbox,
@@ -27,9 +28,9 @@ function BasicInfoFormSection() {
const { control, watch, setValue } = useFormContext<InstlSncnRegistFormData>(); const { control, watch, setValue } = useFormContext<InstlSncnRegistFormData>();
const [isPickerOpen, setIsPickerOpen] = useState(false); const [isPickerOpen, setIsPickerOpen] = useState(false);
const taskSeComCd = watch("instlSncnMng.taskSeComCd"); const taskSeComCd = watch("instlSncnMng.taskSeComCd");
const localGovLabel = [watch("instlSncnMng.sido"), watch("instlSncnMng.gugun")] const sido = watch("instlSncnMng.sido");
.filter(Boolean) const gugun = watch("instlSncnMng.gugun");
.join(" "); const localGovLabel = [sido, gugun].filter(Boolean).join(" ");
function handleBnsSelect(data: BnsApplyData) { function handleBnsSelect(data: BnsApplyData) {
setValue("instlSncnMng.bizNm", data.bizNm); setValue("instlSncnMng.bizNm", data.bizNm);
@@ -53,7 +54,8 @@ function BasicInfoFormSection() {
return ( return (
<SectionCollapsible title="기본 정보"> <SectionCollapsible title="기본 정보">
<Table caption="설치승인 기본 정보 입력" variant="secondary"> <PreviousPlanLookupButton sidoNm={sido} gugunNm={gugun} />
<Table caption="기본 정보 입력으로 사업명, 사업기간, 사업내용, 구분, 신청 지자체, 사업구분, 시설종류, 재이용용도 정보 제공" variant="secondary">
<colgroup> <colgroup>
<col width="15%" /> <col width="15%" />
<col width="35%" /> <col width="35%" />
@@ -2,6 +2,7 @@
import { SectionCollapsible } from "@/shared/ui"; import { SectionCollapsible } from "@/shared/ui";
import type { InstlSncnMngResponseDto } from "@/entities/instl-sncn"; import type { InstlSncnMngResponseDto } from "@/entities/instl-sncn";
import { PreviousPlanLookupButton } from "@/widgets/previous-plan-lookup";
import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components"; import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
interface BasicInfoSectionProps { interface BasicInfoSectionProps {
@@ -11,7 +12,8 @@ interface BasicInfoSectionProps {
function BasicInfoSection({ det }: BasicInfoSectionProps) { function BasicInfoSection({ det }: BasicInfoSectionProps) {
return ( return (
<SectionCollapsible title="기본 정보"> <SectionCollapsible title="기본 정보">
<Table caption="설치승인 기본 정보" variant="secondary"> <PreviousPlanLookupButton sidoNm={det.sido} gugunNm={det.gugun} />
<Table caption="기본 정보로 사업명, 사업기간, 사업내용, 구분, 신청지자체, 사업구분 정보 제공" variant="secondary">
<colgroup> <colgroup>
<col width="15%" /> <col width="15%" />
<col width="35%" /> <col width="35%" />
@@ -20,7 +20,7 @@ function BusinessCostSection({ bizCstList }: BusinessCostSectionProps) {
<SectionCollapsible title="총사업비"> <SectionCollapsible title="총사업비">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<p className="mb-2 text-right font-normal text-muted-foreground">(단위: 백만원)</p> <p className="mb-2 text-right font-normal text-muted-foreground">(단위: 백만원)</p>
<Table caption="총사업비 내역: 구분, 예산반영, 표준사업비, 금회설계, 증감 정보 제공" variant="secondary" className="table-fixed"> <Table caption="총사업비 목록으로 구분, 예산반영(A), 표준사업비(B), 금회설계시(C), 증감 정보 제공" variant="secondary" className="table-fixed">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[30rem]" rowSpan={2}> <TableHead className="w-[30rem]" rowSpan={2}>
@@ -13,7 +13,7 @@ function BusinessScaleSection({ bizSclList }: BusinessScaleSectionProps) {
return ( return (
<SectionCollapsible title="사업규모"> <SectionCollapsible title="사업규모">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table caption="사업규모 내역: 구분, 기본계획, 예산반영, 금회설계, 증감 정보 제공" variant="secondary"> <Table caption="사업규모 목록으로 구분, 기본계획(A), 예산반영(B), 금회설계시(C), 증감(C-B) 정보 제공" variant="secondary">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[30rem]">구분</TableHead> <TableHead className="w-[30rem]">구분</TableHead>
@@ -85,7 +85,13 @@ function ChecklistSection({
> >
<TabsList> <TabsList>
{visibleReviewSteps.map((stepCode) => ( {visibleReviewSteps.map((stepCode) => (
<TabsTrigger key={stepCode} value={stepCode} className="!px-6 !py-3 text-xl"> <TabsTrigger
key={stepCode}
value={stepCode}
id={`instl-cklst-step-tab-${stepCode}`}
aria-controls="instl-cklst-task-tabs"
className="!px-6 !py-3 text-xl"
>
{CHECKLIST_TAB_LABELS[stepCode]} {CHECKLIST_TAB_LABELS[stepCode]}
</TabsTrigger> </TabsTrigger>
))} ))}
@@ -95,6 +101,7 @@ function ChecklistSection({
{taskCodes.length > 0 ? ( {taskCodes.length > 0 ? (
<Tabs <Tabs
id="instl-cklst-task-tabs"
value={selectedTaskCode ?? taskCodes[0]} value={selectedTaskCode ?? taskCodes[0]}
onValueChange={setSelectedTaskCode} onValueChange={setSelectedTaskCode}
className="w-full" className="w-full"
@@ -106,6 +113,8 @@ function ChecklistSection({
<TabsTrigger <TabsTrigger
key={code} key={code}
value={code} value={code}
id={`instl-cklst-tab-${code}`}
aria-controls={`instl-cklst-panel-${code}`}
className="!px-6 !py-3 text-xl [&:after]:hidden" className="!px-6 !py-3 text-xl [&:after]:hidden"
> >
{TASK_CODE_LABELS[code] ?? code} {TASK_CODE_LABELS[code] ?? code}
@@ -119,7 +128,15 @@ function ChecklistSection({
</div> </div>
{taskCodes.map((code) => ( {taskCodes.map((code) => (
<TabsContent key={code} value={code} className="mt-0 border-0 p-0 shadow-none ring-0"> <TabsContent
key={code}
value={code}
id={`instl-cklst-panel-${code}`}
aria-labelledby={`instl-cklst-tab-${code}`}
className="mt-0 border-0 p-0 shadow-none ring-0"
>
{/* 선택된 탭(검토단계·업무구분)의 패널임을 알 수 있도록 숨김 제목 제공 */}
<h3 className="sr-only">{CHECKLIST_TAB_LABELS[selectedReviewStep]} {TASK_CODE_LABELS[code] ?? code} 체크리스트</h3>
<ChecklistTaskPanel <ChecklistTaskPanel
selectedReviewStep={selectedReviewStep} selectedReviewStep={selectedReviewStep}
selectedRows={getChecklistDisplayRows(cklstResultList, code)} selectedRows={getChecklistDisplayRows(cklstResultList, code)}
@@ -11,7 +11,7 @@ interface ContactInfoSectionProps {
function ContactInfoSection({ det }: ContactInfoSectionProps) { function ContactInfoSection({ det }: ContactInfoSectionProps) {
return ( return (
<SectionCollapsible title="담당자 정보"> <SectionCollapsible title="담당자 정보">
<Table caption="설치승인 담당자 정보" variant="secondary"> <Table caption="담당자 정보로 신청자(이름, 연락처, 소속, 이메일)과 설계자(이름, 연락처, 소속, 이메일) 정보 제공" variant="secondary">
<TableHeader> <TableHeader>
{det.aplcntNm && ( {det.aplcntNm && (
<> <>
@@ -1,350 +1,352 @@
"use client"; "use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react"; import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib"; import { useQueryClient } from "@tanstack/react-query";
import { TextShimmer } from "@/shared/ui";
import { SectionCollapsible } from "@/shared/ui"; import { getDefaultFileKey, useTus, useUserStore } from "@/shared/lib";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field"; import { TextShimmer } from "@/shared/ui";
import { AttachmentDownloadLink, attachService } from "@/entities/attach"; import { SectionCollapsible } from "@/shared/ui";
import type { InstlSncnRvwOpnnResponseDto, ReviewStepCode } from "@/entities/instl-sncn"; import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import { import { attachService } from "@/entities/attach";
Button, import {
RadioGroup, instlSncnKeys,
RadioGroupItem, type InstlSncnRvwOpnnResponseDto,
Table, instlSncnRvwOpnnService,
TableBody, type ReviewStepCode,
TableCell, } from "@/entities/instl-sncn";
TableHead, import { RvwOpnnListTable } from "@/widgets/pbadms-sprt";
TableHeader, import {
TableRow, Button,
Textarea, RadioGroup,
} from "@common/components"; RadioGroupItem,
Table,
import type { ReviewOpinionFormData } from "../../lib/review-opinion-utils"; TableCell,
import { REVIEW_STEP_LABELS } from "../../model/view-types"; TableHead,
TableHeader,
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::"; TableRow,
Textarea,
interface TechReviewOpinionSectionProps { } from "@common/components";
rvwList: InstlSncnRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode; import type { ReviewOpinionFormData } from "../../lib/review-opinion-utils";
atrzSeComCdList?: string[]; import { REVIEW_STEP_LABELS } from "../../model/view-types";
isAtrzLoading?: boolean;
} const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
export interface TechReviewOpinionSectionHandle { interface TechReviewOpinionSectionProps {
getFormData: () => ReviewOpinionFormData | null; rvwList: InstlSncnRvwOpnnResponseDto[];
resetForm: () => void; editableReviewStep?: ReviewStepCode;
} atrzSeComCdList?: string[];
isAtrzLoading?: boolean;
interface TechReviewOpinionTableProps { }
items: InstlSncnRvwOpnnResponseDto[];
} export interface TechReviewOpinionSectionHandle {
getFormData: () => ReviewOpinionFormData | null;
interface UploadedOpinionFile { resetForm: () => void;
fileName: string; }
fileKey: string;
atflId?: string; interface TechReviewOpinionTableProps {
atflCd?: string; items: InstlSncnRvwOpnnResponseDto[];
} isReviewTurn: boolean;
}
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
rvwRsltCn: "", interface UploadedOpinionFile {
rvwRsltAnmlyYn: "", fileName: string;
atchFileGroupId: "", fileKey: string;
opnrSeComCd: "", atflId?: string;
}; atflCd?: string;
}
function createRvwOpnnUploadKey(file: File) {
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`; const EMPTY_FORM_DATA: ReviewOpinionFormData = {
} rvwRsltCn: "",
rvwRsltAnmlyYn: "",
function hasFormContent(formData: ReviewOpinionFormData) { atchFileGroupId: "",
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId); opnrSeComCd: "",
} };
const TechReviewOpinionSection = forwardRef< function createRvwOpnnUploadKey(file: File) {
TechReviewOpinionSectionHandle, return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
TechReviewOpinionSectionProps }
>(function TechReviewOpinionSection(
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false }, function hasFormContent(formData: ReviewOpinionFormData) {
ref, return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
) { }
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState<ReviewOpinionFormData>({ const TechReviewOpinionSection = forwardRef<
...EMPTY_FORM_DATA, TechReviewOpinionSectionHandle,
opnrSeComCd: editableReviewStep ?? "", TechReviewOpinionSectionProps
}); >(function TechReviewOpinionSection(
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null); { rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
ref,
useEffect(() => { ) {
setFormData((prev) => ({ const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
...prev, const [formData, setFormData] = useState<ReviewOpinionFormData>({
opnrSeComCd: editableReviewStep ?? "", ...EMPTY_FORM_DATA,
})); opnrSeComCd: editableReviewStep ?? "",
}, [editableReviewStep]); });
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => { useEffect(() => {
if (!atflCd || !atflId) return; setFormData((prev) => ({
...prev,
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd })); opnrSeComCd: editableReviewStep ?? "",
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev)); }));
}, }, [editableReviewStep]);
onError: (_error, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev)); const { uploadFileAsync, isUploading, uploads } = useTus({
}, onSuccess: (atflCd, atflId, _file, fileKey) => {
}); if (!atflCd || !atflId) return;
useImperativeHandle( setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
ref, setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
() => ({ },
getFormData: () => { onError: (_error, _file, fileKey) => {
if (!editableReviewStep || !hasFormContent(formData)) { setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
return null; },
} });
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
return null; useImperativeHandle(
} ref,
() => ({
return { getFormData: () => {
...formData, if (!editableReviewStep || !hasFormContent(formData)) {
opnrSeComCd: editableReviewStep, return null;
}; }
}, if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
resetForm: () => { return null;
setFormData({ }
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "", return {
}); ...formData,
setUploadedFile(null); opnrSeComCd: editableReviewStep,
}, };
}), },
[canWriteKecoOpinion, editableReviewStep, formData], resetForm: () => {
); setFormData({
...EMPTY_FORM_DATA,
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV"); opnrSeComCd: editableReviewStep ?? "",
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO"); });
setUploadedFile(null);
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV"; },
const hasKecoSection = }),
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteKecoOpinion); [canWriteKecoOpinion, editableReviewStep, formData],
);
if (!hasEnvSection && !hasKecoSection) {
return null; const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
} const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
async function handleFileUpload(files: FileList | null) { const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
if (!files || files.length === 0) return; const hasKecoSection =
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteKecoOpinion);
const file = files[0];
const fileKey = createRvwOpnnUploadKey(file); if (!hasEnvSection && !hasKecoSection) {
const existingAtflCd = formData.atchFileGroupId; return null;
}
setUploadedFile({ fileName: file.name, fileKey });
async function handleFileUpload(files: FileList | null) {
try { if (!files || files.length === 0) return;
await uploadFileAsync(file, {
fileKey, const file = files[0];
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined, const fileKey = createRvwOpnnUploadKey(file);
}); const existingAtflCd = formData.atchFileGroupId;
} catch {
// onError callback handles cleanup setUploadedFile({ fileName: file.name, fileKey });
}
} try {
await uploadFileAsync(file, {
async function handleFileRemove() { fileKey,
if (!uploadedFile) return; metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
uploads[uploadedFile.fileKey]?.abort(); } catch {
// onError callback handles cleanup
if (uploadedFile.atflId) { }
try { }
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} catch (error) { async function handleFileRemove() {
console.error("검토의견 첨부파일 삭제 실패:", error); if (!uploadedFile) return;
}
} uploads[uploadedFile.fileKey]?.abort();
setUploadedFile(null); if (uploadedFile.atflId) {
setFormData((prev) => ({ ...prev, atchFileGroupId: "" })); try {
} await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} catch (error) {
function renderOpinionForm(reviewStep: ReviewStepCode) { console.error("검토의견 첨부파일 삭제 실패:", error);
if (editableReviewStep !== reviewStep) return null; }
if (reviewStep === "KECO" && !canWriteKecoOpinion) return null; }
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false; setUploadedFile(null);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
return ( }
<div className="space-y-2">
<h4 className="text-xl font-semibold">검토의견 작성</h4> function isReviewTurn(reviewStep: ReviewStepCode) {
<Table caption="검토의견 입력" variant="secondary"> if (editableReviewStep !== reviewStep) return false;
<TableHeader> if (reviewStep === "KECO" && !canWriteKecoOpinion) return false;
<TableRow> return true;
<TableHead scope="row" className="w-[12rem]">의견내용</TableHead> }
<TableCell>
<Textarea function renderOpinionForm(reviewStep: ReviewStepCode) {
value={formData.rvwRsltCn} if (!isReviewTurn(reviewStep)) return null;
onChange={(event) =>
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value })) const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
}
placeholder="의견내용을 입력하세요" return (
rows={4} <div className="space-y-2">
className="min-h-[8rem] !text-xl" <h4 className="text-xl font-semibold">검토의견 작성</h4>
/> <Table
</TableCell> caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공"
</TableRow> variant="secondary"
<TableRow> >
<TableHead scope="row">검토결과</TableHead> <TableHeader>
<TableCell> <TableRow>
<FieldSet> <TableHead scope="row" className="w-[12rem]">
<RadioGroup 의견내용
value={formData.rvwRsltAnmlyYn} </TableHead>
onValueChange={(value) => <TableCell>
setFormData((prev) => ({ <Textarea
...prev, value={formData.rvwRsltCn}
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"], onChange={(event) =>
})) setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
} }
> placeholder="의견내용을 입력하세요"
<div className="flex flex-wrap gap-4"> rows={4}
<FieldLabel className="min-h-[8rem] !text-xl"
htmlFor={`rvw-opnn-normal-${reviewStep}`} />
className="!w-auto border-none" </TableCell>
> </TableRow>
<Field orientation="horizontal" className="w-auto"> <TableRow>
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} /> <TableHead scope="row">검토결과</TableHead>
<span className="font-normal">정상</span> <TableCell>
</Field> <FieldSet>
</FieldLabel> <RadioGroup
<FieldLabel value={formData.rvwRsltAnmlyYn}
htmlFor={`rvw-opnn-abnormal-${reviewStep}`} onValueChange={(value) =>
className="!w-auto border-none" setFormData((prev) => ({
> ...prev,
<Field orientation="horizontal" className="w-auto"> rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} /> }))
<span className="font-normal">이상</span> }
</Field> >
</FieldLabel> <div className="flex flex-wrap gap-4">
</div> <FieldLabel
</RadioGroup> htmlFor={`rvw-opnn-normal-${reviewStep}`}
</FieldSet> className="!w-auto border-none"
</TableCell> >
</TableRow> <Field orientation="horizontal" className="w-auto">
<TableRow> <RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
<TableHead scope="row">첨부파일</TableHead> <span className="font-normal">정상</span>
<TableCell> </Field>
{uploadedFile ? ( </FieldLabel>
<div className="flex items-center gap-2"> <FieldLabel
<TextShimmer className="text-xl" active={uploading}> htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
{uploadedFile.fileName} className="!w-auto border-none"
</TextShimmer> >
<Button <Field orientation="horizontal" className="w-auto">
size="icon" <RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
onClick={(e) => { <span className="font-normal">이상</span>
e.preventDefault(); </Field>
void handleFileRemove(); </FieldLabel>
}} </div>
disabled={uploading} </RadioGroup>
className="size-8 !bg-destructive" </FieldSet>
> </TableCell>
<i className="diveicon di-x text-xs" aria-label="삭제" /> </TableRow>
</Button> <TableRow>
</div> <TableHead scope="row">첨부파일</TableHead>
) : ( <TableCell>
<label> {uploadedFile ? (
<input <div className="flex items-center gap-2">
type="file" <TextShimmer className="text-xl" active={uploading}>
className="hidden" {uploadedFile.fileName}
onChange={(event) => { </TextShimmer>
void handleFileUpload(event.target.files); <Button
event.target.value = ""; size="icon"
}} onClick={(e) => {
/> e.preventDefault();
<Button size="sm" variant="outline" type="button" asChild> void handleFileRemove();
<span>파일 선택</span> }}
</Button> disabled={uploading}
</label> className="size-8 !bg-destructive"
)} >
</TableCell> <i className="diveicon di-x text-xs" aria-label="삭제" />
</TableRow> </Button>
</TableHeader> </div>
</Table> ) : (
</div> <label>
); <input
} type="file"
className="hidden"
return ( onChange={(event) => {
<> void handleFileUpload(event.target.files);
{hasEnvSection ? ( event.target.value = "";
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}> }}
<div className="space-y-6"> />
{envRvwList.length > 0 ? <TechReviewOpinionTable items={envRvwList} /> : null} <Button size="sm" variant="outline" type="button" asChild>
{renderOpinionForm("ENV")} <span>파일 선택</span>
</div> </Button>
</SectionCollapsible> </label>
) : null} )}
{hasKecoSection ? ( </TableCell>
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}> </TableRow>
<div className="space-y-6"> </TableHeader>
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null} </Table>
{renderOpinionForm("KECO")} </div>
</div> );
</SectionCollapsible> }
) : null}
</> return (
); <>
}); {hasEnvSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) { <div className="space-y-6">
return ( {envRvwList.length > 0 ? (
<div className="overflow-x-auto"> <TechReviewOpinionTable items={envRvwList} isReviewTurn={isReviewTurn("ENV")} />
<Table caption="검토의견 목록: 의견자, 의견내용, 검토결과, 첨부파일, 등록일시 정보 제공" variant="secondary"> ) : null}
<colgroup> {renderOpinionForm("ENV")}
<col width="10%" /> </div>
<col width="55%" /> </SectionCollapsible>
<col width="5%" /> ) : null}
<col width="20%" /> {hasKecoSection ? (
<col width="10%" /> <SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
</colgroup> <div className="space-y-6">
<TableHeader> {kecoRvwList.length > 0 ? (
<TableRow> <TechReviewOpinionTable items={kecoRvwList} isReviewTurn={isReviewTurn("KECO")} />
<TableHead>의견자명</TableHead> ) : null}
<TableHead>의견내용</TableHead> {renderOpinionForm("KECO")}
<TableHead>검토결과</TableHead> </div>
<TableHead>첨부파일</TableHead> </SectionCollapsible>
<TableHead>등록일시</TableHead> ) : null}
</TableRow> </>
</TableHeader> );
<TableBody> });
{items.map((item) => (
<TableRow key={`${item.instlSncnDocNo}_${item.rvwOpnnSn}`}> function getRvwOpnnIds(item: InstlSncnRvwOpnnResponseDto) {
<TableCell align="center">{item.opnrNm}</TableCell> if (!item.instlSncnDocNo || item.rvwOpnnSn == null) {
<TableCell align="left">{item.rvwRsltCn || "-"}</TableCell> throw new Error("검토의견 식별값이 없습니다.");
<TableCell align="center"> }
{item.rvwRsltAnmlyYn === "Y" ? "이상" : item.rvwRsltAnmlyYn === "N" ? "정상" : "-"} return [item.instlSncnDocNo, item.rvwOpnnSn] as const;
</TableCell> }
<TableCell align="center">
{item.atchFileInfo?.atflCd ? ( function TechReviewOpinionTable({ items, isReviewTurn }: TechReviewOpinionTableProps) {
<AttachmentDownloadLink const queryClient = useQueryClient();
atflCd={item.atchFileInfo.atflCd} const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
fileName={item.atchFileInfo.orgnlFileNm || "다운로드"}
/> return (
) : ( <RvwOpnnListTable
"-" items={items}
)} getRowKey={(item) => `${item.instlSncnDocNo}_${item.rvwOpnnSn}`}
</TableCell> currentUserUuid={currentUserUuid}
<TableCell align="center"> isReviewTurn={isReviewTurn}
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"} onUpdate={async (item, body) => {
</TableCell> await instlSncnRvwOpnnService.update(...getRvwOpnnIds(item), body);
</TableRow> await queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
))} }}
</TableBody> onDelete={async (item) => {
</Table> await instlSncnRvwOpnnService.delete(...getRvwOpnnIds(item));
</div> await queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
); }}
} />
);
export { TechReviewOpinionSection }; }
export { TechReviewOpinionSection };
@@ -89,8 +89,8 @@ function MyPbadmsSprtListPage() {
}; };
return ( return (
<div className="space-y-4 px-[1.8rem] pb-6"> <div className="card card-border !mt-6">
<section className="card card-border mx-0 bg-background"> <div className="card card-border">
<form className="flex flex-wrap items-center gap-4 p-2" onSubmit={handleSearch}> <form className="flex flex-wrap items-center gap-4 p-2" onSubmit={handleSearch}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="whitespace-nowrap">기간</Label> <Label className="whitespace-nowrap">기간</Label>
@@ -157,7 +157,7 @@ function MyPbadmsSprtListPage() {
<Button type="submit">검색</Button> <Button type="submit">검색</Button>
</form> </form>
</section> </div>
<MyPbadmsSprtList <MyPbadmsSprtList
searchParams={searchParams} searchParams={searchParams}
@@ -70,19 +70,19 @@ function MyPbadmsSprtList({ searchParams, pagination, onPaginationChange }: MyPb
manualPagination: true, manualPagination: true,
}, },
dataTableOptions: { dataTableOptions: {
caption: "나의 행정지원 목록", caption: "내 신청 목록으로 No, 구분, 신청일, 진행상태, 완료(예정)일, 담당자, 담당자연락처 정보 제공",
viewPagination: true, viewPagination: true,
onRowClick: handleRowClick, onRowClick: handleRowClick,
}, },
}); });
return ( return (
<section className="card card-border mx-0 bg-background pb-8 pt-0"> <div className="card card-border pt-0">
<div className="card-header flex items-center justify-between"> <div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-semibold">내 신청 목록</h3> <h3 className="text-2xl font-medium text-label">내 신청 목록</h3>
</div> </div>
{tableComponent} <div className="card !m-[1.2rem] !p-0">{tableComponent}</div>
</section> </div>
); );
} }
@@ -71,8 +71,8 @@ function NtiHstryListPage() {
}; };
return ( return (
<div className="space-y-4 px-[1.8rem] pb-6"> <div className="card card-border !mt-6">
<section className="card card-border mx-0 bg-background"> <div className="card card-border">
<form className="flex flex-wrap items-center gap-4 p-2" onSubmit={handleSearch}> <form className="flex flex-wrap items-center gap-4 p-2" onSubmit={handleSearch}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="whitespace-nowrap">기간</Label> <Label className="whitespace-nowrap">기간</Label>
@@ -114,7 +114,7 @@ function NtiHstryListPage() {
<Button type="submit">검색</Button> <Button type="submit">검색</Button>
</form> </form>
</section> </div>
<NtiHstryList <NtiHstryList
searchParams={searchParams} searchParams={searchParams}
@@ -97,18 +97,18 @@ function NtiHstryList({ searchParams, pagination, onPaginationChange }: NtiHstry
manualPagination: true, manualPagination: true,
}, },
dataTableOptions: { dataTableOptions: {
caption: "알림톡 발송 이력 목록", caption: "알림 이력 목록으로 No, 알림일시, 알림내용, 연계업무, 상태, 처리 정보 제공",
viewPagination: true, viewPagination: true,
}, },
}); });
return ( return (
<section className="card card-border mx-0 bg-background pb-8 pt-0"> <div className="card card-border pt-0">
<div className="card-header flex items-center justify-between"> <div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-semibold">알림 이력 목록</h3> <h3 className="text-2xl font-medium text-label">알림 이력 목록</h3>
</div> </div>
{tableComponent} <div className="card !m-[1.2rem] !p-0">{tableComponent}</div>
</section> </div>
); );
} }
@@ -33,7 +33,7 @@ function InPrgrsDocRcvrList({ selectedRcvrKeys, onRcvrSelectionChange }: InPrgrs
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
...rowSelectionOptions, ...rowSelectionOptions,
}, },
dataTableOptions: { caption: "진행중 문서 담당자 수신자 목록" }, dataTableOptions: { caption: "현재 진행중 문서 담당자(신청자) 목록으로 선택, 문서번호, 문서명, 신청자, 상태 정보 제공" },
}); });
return ( return (
@@ -34,7 +34,7 @@ function RvwMnpwRcvrList({ selectedRcvrKeys, onRcvrSelectionChange }: RvwMnpwRcv
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
...rowSelectionOptions, ...rowSelectionOptions,
}, },
dataTableOptions: { caption: "검토인력 수신자 목록" }, dataTableOptions: { caption: "검토인력 목록으로 선택, 이름, 소속, 전화번호 정보 제공" },
}); });
return ( return (
@@ -35,7 +35,7 @@ function SndngList({
return ( return (
<div className="max-h-[40rem] overflow-auto rounded-md border"> <div className="max-h-[40rem] overflow-auto rounded-md border">
<Table caption="알림톡 발송 대상 목록: 이름, 전화번호, 변수, 버튼 정보 제공" variant="line" className="text-sm"> <Table caption={`발송 목록으로 ${["이름", "전화번호", ...variableNames.map((variableName) => `#${variableName}`), "버튼"].join(", ")} 정보 제공`} variant="line" className="text-sm">
<TableHeader className="top-0"> <TableHeader className="top-0">
<TableRow> <TableRow>
<TableHead className="min-w-[100px]">이름</TableHead> <TableHead className="min-w-[100px]">이름</TableHead>
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { AxiosError } from "axios";
import { useAlertStore } from "@/shared/lib"; import { useAlertStore } from "@/shared/lib";
import type { RvwMnpwMngResponseDto } from "@/entities/rvw-mnpw"; import type { RvwMnpwMngResponseDto } from "@/entities/rvw-mnpw";
@@ -10,6 +11,14 @@ import { Badge, Button } from "@common/components";
import type { RvwMnpwBlockConfig } from "../model/rvw-mnpw-block-config"; import type { RvwMnpwBlockConfig } from "../model/rvw-mnpw-block-config";
/** 서버 오류 응답의 message 를 우선 표시하고, 없으면 기본 문구를 사용한다 */
function getErrorMessage(error: unknown, fallback: string) {
if (error instanceof AxiosError) {
return error.response?.data?.message || fallback;
}
return fallback;
}
interface RvwMnpwBlockProps { interface RvwMnpwBlockProps {
block: RvwMnpwBlockConfig; block: RvwMnpwBlockConfig;
items: RvwMnpwMngResponseDto[]; items: RvwMnpwMngResponseDto[];
@@ -33,8 +42,7 @@ function RvwMnpwBlock({ block, items, selectedPerson }: RvwMnpwBlockProps) {
}); });
alert("배치되었습니다."); alert("배치되었습니다.");
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "배치에 실패했습니다."; alert(getErrorMessage(error, "배치에 실패했습니다."));
alert(message);
} }
}; };
@@ -48,8 +56,7 @@ function RvwMnpwBlock({ block, items, selectedPerson }: RvwMnpwBlockProps) {
userUuid: item.userUuid, userUuid: item.userUuid,
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "제거에 실패했습니다."; alert(getErrorMessage(error, "제거에 실패했습니다."));
alert(message);
} }
}; };
+235 -208
View File
@@ -1,208 +1,235 @@
"use client"; "use client";
import { Fragment } from "react"; import { Fragment } from "react";
import { createColumnHelper } from "@tanstack/react-table"; import { createColumnHelper } from "@tanstack/react-table";
import { dayjs } from "@/shared/lib"; import { dayjs } from "@/shared/lib";
import type { SewerBscPlanResponseDto } from "@/entities/sewer-bsc-plan"; import type { SewerBscPlanResponseDto } from "@/entities/sewer-bsc-plan";
import { Badge } from "@common/components"; import { Badge } from "@common/components";
const columnHelper = createColumnHelper<SewerBscPlanResponseDto>(); const columnHelper = createColumnHelper<SewerBscPlanResponseDto>();
const MAIN_STEP_LABELS = { const MAIN_STEP_LABELS = {
apply: "기본계획 신청", apply: "기본계획 신청",
envoReview: "기본계획 검토", envoReview: "기본계획 검토",
techReview: "기술검토", techReview: "기술검토",
reply: "검토결과 회신", reply: "검토결과 회신",
} as const; } as const;
export const PROGRESS_STATUS_COLUMNS = [ export const PROGRESS_STATUS_COLUMNS = [
{ label: "기본계획 신청 (시군)", sttsComCd: "LE" }, { label: "기본계획 신청 (시군)", sttsComCd: "LE" },
{ label: "기본계획 검토", sttsComCd: "ER" }, { label: "기본계획 검토", sttsComCd: "ER" },
{ label: "기술검토", sttsComCd: "TE" }, { label: "기술검토", sttsComCd: "TE" },
{ label: "기술검토 회신", sttsComCd: "TP" }, { label: "기술검토 회신", sttsComCd: "TP" },
{ label: "횐경청 검토 결과 회신", sttsComCd: "EP" }, { label: "횐경청 검토 결과 회신", sttsComCd: "EP" },
]; ];
type BadgeState = "completed" | "current" | "pending"; type BadgeState = "completed" | "current" | "pending";
interface ProgressBadge { interface ProgressBadge {
label: string; label: string;
state: BadgeState; state: BadgeState;
} }
function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N"): ProgressBadge[] { function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N"): ProgressBadge[] {
if (prgrsSttsComCd === "SV") { if (prgrsSttsComCd === "SV") {
return [ return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" }, { label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" }, { label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" }, { label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" }, { label: MAIN_STEP_LABELS.reply, state: "pending" },
]; ];
} }
if (prgrsSttsComCd === "ST") { if (prgrsSttsComCd === "ST") {
return [ return [
{ label: MAIN_STEP_LABELS.apply, state: "current" }, { label: MAIN_STEP_LABELS.apply, state: "current" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" }, { label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" }, { label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" }, { label: MAIN_STEP_LABELS.reply, state: "pending" },
]; ];
} }
if (prgrsSttsComCd === "LE") { if (prgrsSttsComCd === "LE") {
return [ return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" }, { label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "current" }, { label: MAIN_STEP_LABELS.envoReview, state: "current" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" }, { label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" }, { label: MAIN_STEP_LABELS.reply, state: "pending" },
]; ];
} }
if (prgrsSttsComCd === "ER" || prgrsSttsComCd === "TE" || prgrsSttsComCd === "SL") { if (prgrsSttsComCd === "ER" || prgrsSttsComCd === "TE" || prgrsSttsComCd === "SL") {
return [ return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" }, { label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" }, { label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "current" }, { label: MAIN_STEP_LABELS.techReview, state: "current" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" }, { label: MAIN_STEP_LABELS.reply, state: "pending" },
]; ];
} }
if (prgrsSttsComCd === "TP") { if (prgrsSttsComCd === "TP") {
return [ return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" }, { label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" }, { label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{ {
label: MAIN_STEP_LABELS.techReview, label: MAIN_STEP_LABELS.techReview,
state: techRplyHstryYn === "Y" ? "completed" : "pending", state: techRplyHstryYn === "Y" ? "completed" : "pending",
}, },
{ label: MAIN_STEP_LABELS.reply, state: "current" }, { label: MAIN_STEP_LABELS.reply, state: "current" },
]; ];
} }
if (prgrsSttsComCd === "EP") { if (prgrsSttsComCd === "EP") {
return [ return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" }, { label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" }, { label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{ {
label: MAIN_STEP_LABELS.techReview, label: MAIN_STEP_LABELS.techReview,
state: techRplyHstryYn === "Y" ? "completed" : "pending", state: techRplyHstryYn === "Y" ? "completed" : "pending",
}, },
{ label: MAIN_STEP_LABELS.reply, state: "completed" }, { label: MAIN_STEP_LABELS.reply, state: "completed" },
]; ];
} }
return [ return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" }, { label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" }, { label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" }, { label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" }, { label: MAIN_STEP_LABELS.reply, state: "pending" },
]; ];
} }
function getProgressStatusBadge(prgrsSttsComCd?: string | null) { function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") { if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>; return <Badge color="orange">보완요청</Badge>;
} }
return null; return null;
} }
function getBadgeColor(state: BadgeState) { function getBadgeColor(state: BadgeState) {
switch (state) { switch (state) {
case "completed": case "completed":
return "green"; return "green";
case "current": case "current":
return "blue"; return "blue";
case "pending": case "pending":
return "lightGray"; return "lightGray";
} }
} }
function formatOptional(value: string | undefined) { function getBadgeStateText(state: BadgeState) {
return value?.trim() ? value : "-"; switch (state) {
} case "completed":
return "완료";
interface ProgressStepBadgeProps { case "current":
prgrsSttsComCd: string; return "진행중";
techRplyHstryYn: "Y" | "N"; case "pending":
} return "대기";
}
function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadgeProps) { }
const badges = buildProgressBadges(prgrsSttsComCd, techRplyHstryYn);
/** 단계명을 2줄로 분리: "설치인가 신청" → ["설치인가", "신청"], "기술검토" → ["기술검토", ""] */
return ( function splitStepLabel(label: string) {
<div className="flex flex-wrap items-center justify-start"> const [title = "", ...rest] = label.split(" ");
{badges.map((badge, index) => ( return { title, detail: rest.join(" ") };
<Fragment key={`${badge.label}-${index}`}> }
{index > 0 && <span className="mx-2">&gt;</span>}
<Badge color={getBadgeColor(badge.state)}>{badge.label}</Badge> function formatOptional(value: string | undefined) {
</Fragment> return value?.trim() ? value : "-";
))} }
</div>
); interface ProgressStepBadgeProps {
} prgrsSttsComCd: string;
techRplyHstryYn: "Y" | "N";
export function createSewerBscPlanColumns() { }
return [
columnHelper.accessor("sido", { function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadgeProps) {
header: "시도", const badges = buildProgressBadges(prgrsSttsComCd, techRplyHstryYn);
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()), return (
}), <div className="flex flex-wrap items-center justify-center">
columnHelper.accessor("gugun", { {badges.map((badge, index) => (
header: "시군구", <Fragment key={`${badge.label}-${index}`}>
meta: { align: "center" }, {index > 0 && <span className="mx-2">&gt;</span>}
cell: ({ getValue }) => formatOptional(getValue()), {/* 1줄: 단계명 / 2줄: 세부단계 + 상태 (예: 설치인가 / 신청완료, 기술검토 / 완료) */}
}), <Badge
columnHelper.accessor("sewerBscPlanNm", { color={getBadgeColor(badge.state)}
header: "기본계획명", className="w-[100px] flex-col justify-center !px-0 py-1 text-center leading-tight"
meta: { align: "left" }, >
cell: ({ row, getValue }) => ( <span>{splitStepLabel(badge.label).title}</span>
<> <span>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())} {splitStepLabel(badge.label).detail}
</> {getBadgeStateText(badge.state)}
), </span>
}), </Badge>
columnHelper.accessor("sewerBscPlanSeCdNm", { </Fragment>
header: "계획구분", ))}
meta: { align: "center" }, </div>
cell: ({ row }) => );
formatOptional(row.original.sewerBscPlanSeCdNm || row.original.sewerBscPlanSeComCd), }
}),
columnHelper.accessor("taskSeCdNm", { export function createSewerBscPlanColumns() {
header: "업무구분", return [
meta: { align: "center" }, columnHelper.accessor("sido", {
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd), header: "시도",
}), meta: { align: "center" },
columnHelper.accessor("rgtrNm", { cell: ({ getValue }) => formatOptional(getValue()),
header: "담당자", }),
meta: { align: "center" }, columnHelper.accessor("gugun", {
cell: ({ getValue }) => formatOptional(getValue()), header: "시군구",
}), meta: { align: "center" },
columnHelper.accessor("envoSeCdNm", { cell: ({ getValue }) => formatOptional(getValue()),
header: "해당기관", }),
meta: { align: "center" }, columnHelper.accessor("sewerBscPlanNm", {
cell: ({ getValue }) => formatOptional(getValue()), header: "기본계획명",
}), meta: { align: "left" },
columnHelper.display({ cell: ({ row, getValue }) => (
id: "progress", <>
header: "진행상황", {getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
meta: { align: "center" }, </>
cell: ({ row }) => ( ),
<ProgressStepBadge }),
prgrsSttsComCd={row.original.prgrsSttsComCd || ""} columnHelper.accessor("sewerBscPlanSeCdNm", {
techRplyHstryYn={row.original.techRplyHstryYn || "N"} header: "계획구분",
/> meta: { align: "center" },
), cell: ({ row }) =>
}), formatOptional(row.original.sewerBscPlanSeCdNm || row.original.sewerBscPlanSeComCd),
columnHelper.accessor("regDt", { }),
header: "등록일", columnHelper.accessor("taskSeCdNm", {
meta: { align: "center" }, header: "업무구분",
cell: ({ getValue }) => { meta: { align: "center" },
const value = getValue(); cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
return value ? dayjs(value).format("YYYY-MM-DD") : "-"; }),
}, 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") : "-";
},
}),
];
}
@@ -52,7 +52,7 @@ function ChecklistTaskPanel({
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table caption="체크리스트 검토 입력: 검토항목, 주요 검토내용, 검토결과 정보 제공" variant="secondary"> <Table caption={`체크리스트 목록으로 검토항목, 주요 검토내용, 검토 결과${selectedReviewStep !== "LOCAL" ? ", 기술검토 의견" : ""} 정보 제공`} variant="secondary">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[20rem]" rowSpan={2}> <TableHead className="w-[20rem]" rowSpan={2}>
@@ -57,7 +57,7 @@ function BasicInfoFormSection() {
return ( return (
<SectionCollapsible title="기본계획 신청"> <SectionCollapsible title="기본계획 신청">
<Table caption="기본계획 정보 입력" variant="secondary"> <Table caption="기본계획 신청 입력으로 기본계획명, 계획년도, 기준년도, 1단계 목표연도, 2단계 목표연도, 3단계 목표연도, 4단계 목표연도, 계획개요, 구분, 신청 지자체 정보 제공" variant="secondary">
<colgroup> <colgroup>
<col width="15%" /> <col width="15%" />
<col width="35%" /> <col width="35%" />
@@ -18,7 +18,7 @@ function BasicInfoSection({ det, dtl }: BasicInfoSectionProps) {
return ( return (
<SectionCollapsible title="기본계획 신청"> <SectionCollapsible title="기본계획 신청">
<Table caption="기본계획 정보" variant="secondary"> <Table caption="기본계획 신청으로 기본계획명, 계획년도, 기준년도, 1단계 목표연도, 2단계 목표연도, 3단계 목표연도, 4단계 목표연도, 계획개요, 구분, 신청 지자체 정보 제공" variant="secondary">
<colgroup> <colgroup>
<col width="15%" /> <col width="15%" />
<col width="35%" /> <col width="35%" />
@@ -75,7 +75,7 @@ function BizCstMatrixFormSection() {
<SectionCollapsible title="총사업비"> <SectionCollapsible title="총사업비">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<p className="mb-2 text-right font-normal text-muted-foreground">(단위: 백만원)</p> <p className="mb-2 text-right font-normal text-muted-foreground">(단위: 백만원)</p>
<Table caption="단계별 총사업비 입력" variant="secondary" className="table-fixed"> <Table caption="총사업비 목록으로 구분, 1단계, 2단계, 3단계, 4단계 정보 제공" variant="secondary" className="table-fixed">
<colgroup> <colgroup>
<col width="12%" /> <col width="12%" />
<col width="22%" /> <col width="22%" />
@@ -62,7 +62,7 @@ function BizCstMatrixSection({ bizCstList, dtl }: BizCstMatrixSectionProps) {
<SectionCollapsible title="총사업비"> <SectionCollapsible title="총사업비">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<p className="mb-2 text-right font-normal text-muted-foreground">(단위: 백만원)</p> <p className="mb-2 text-right font-normal text-muted-foreground">(단위: 백만원)</p>
<Table caption="단계별 총사업비 내역" variant="secondary" className="table-fixed"> <Table caption="총사업비 목록으로 구분, 1단계, 2단계, 3단계, 4단계 정보 제공" variant="secondary" className="table-fixed">
<colgroup> <colgroup>
<col width="12%" /> <col width="12%" />
<col width="22%" /> <col width="22%" />
@@ -86,7 +86,13 @@ function ChecklistSection({
> >
<TabsList> <TabsList>
{visibleReviewSteps.map((stepCode) => ( {visibleReviewSteps.map((stepCode) => (
<TabsTrigger key={stepCode} value={stepCode} className="!px-6 !py-3 text-xl"> <TabsTrigger
key={stepCode}
value={stepCode}
id={`sewer-cklst-step-tab-${stepCode}`}
aria-controls="sewer-cklst-task-tabs"
className="!px-6 !py-3 text-xl"
>
{CHECKLIST_TAB_LABELS[stepCode]} {CHECKLIST_TAB_LABELS[stepCode]}
</TabsTrigger> </TabsTrigger>
))} ))}
@@ -96,6 +102,7 @@ function ChecklistSection({
{taskCodes.length > 0 ? ( {taskCodes.length > 0 ? (
<Tabs <Tabs
id="sewer-cklst-task-tabs"
value={selectedTaskCode ?? taskCodes[0]} value={selectedTaskCode ?? taskCodes[0]}
onValueChange={setSelectedTaskCode} onValueChange={setSelectedTaskCode}
className="w-full" className="w-full"
@@ -107,6 +114,8 @@ function ChecklistSection({
<TabsTrigger <TabsTrigger
key={code} key={code}
value={code} value={code}
id={`sewer-cklst-tab-${code}`}
aria-controls={`sewer-cklst-panel-${code}`}
className="!px-6 !py-3 text-xl [&:after]:hidden" className="!px-6 !py-3 text-xl [&:after]:hidden"
> >
{TASK_CODE_LABELS[code] ?? code} {TASK_CODE_LABELS[code] ?? code}
@@ -120,7 +129,15 @@ function ChecklistSection({
</div> </div>
{taskCodes.map((code) => ( {taskCodes.map((code) => (
<TabsContent key={code} value={code} className="mt-0 border-0 p-0 shadow-none ring-0"> <TabsContent
key={code}
value={code}
id={`sewer-cklst-panel-${code}`}
aria-labelledby={`sewer-cklst-tab-${code}`}
className="mt-0 border-0 p-0 shadow-none ring-0"
>
{/* 선택된 탭(검토단계·업무구분)의 패널임을 알 수 있도록 숨김 제목 제공 */}
<h3 className="sr-only">{CHECKLIST_TAB_LABELS[selectedReviewStep]} {TASK_CODE_LABELS[code] ?? code} 체크리스트</h3>
<ChecklistTaskPanel <ChecklistTaskPanel
selectedReviewStep={selectedReviewStep} selectedReviewStep={selectedReviewStep}
selectedRows={getChecklistDisplayRows(cklstResultList, code)} selectedRows={getChecklistDisplayRows(cklstResultList, code)}
@@ -13,7 +13,7 @@ function ContactInfoSection({ dtl }: ContactInfoSectionProps) {
return ( return (
<SectionCollapsible title="담당자 정보"> <SectionCollapsible title="담당자 정보">
<Table caption="기본계획 담당자 정보" variant="secondary"> <Table caption="담당자 정보로 신청자(이름, 연락처, 소속, 이메일)과 설계자(이름, 연락처, 소속, 이메일) 정보 제공" variant="secondary">
<TableHeader> <TableHeader>
{dtl.aplcntNm ? ( {dtl.aplcntNm ? (
<> <>
@@ -85,7 +85,7 @@ function FnrsCstFormSection() {
<h3 className="text-xl font-semibold">세부별 총사업비(①) - 재원분담 총사업비(②)</h3> <h3 className="text-xl font-semibold">세부별 총사업비(①) - 재원분담 총사업비(②)</h3>
<span className="font-normal text-muted-foreground">(단위: 백만원)</span> <span className="font-normal text-muted-foreground">(단위: 백만원)</span>
</div> </div>
<Table caption="총사업비 대비 재원분담 합계 비교" variant="secondary" className="table-fixed"> <Table caption="세부별 총사업비(①) - 재원분담 총사업비(②) 목록으로 구분, 1단계, 2단계, 3단계, 4단계 정보 제공" variant="secondary" className="table-fixed">
<colgroup> <colgroup>
<col width="12%" /> <col width="12%" />
<col width="22%" /> <col width="22%" />
@@ -125,7 +125,7 @@ function FnrsCstFormSection() {
<h3 className="text-xl font-semibold">재원분담</h3> <h3 className="text-xl font-semibold">재원분담</h3>
<span className="font-normal text-muted-foreground">(단위: 백만원)</span> <span className="font-normal text-muted-foreground">(단위: 백만원)</span>
</div> </div>
<Table caption="단계별 재원분담 입력" variant="secondary" className="table-fixed"> <Table caption="재원분담 목록으로 구분, 1단계, 2단계, 3단계, 4단계 정보 제공" variant="secondary" className="table-fixed">
<colgroup> <colgroup>
<col width="12%" /> <col width="12%" />
<col width="22%" /> <col width="22%" />
@@ -79,7 +79,7 @@ function FnrsCstSection({ fnrsCstList, dtl }: FnrsCstSectionProps) {
<h3 className="text-xl font-semibold">세부별 총사업비(①) - 재원분담 총사업비(②)</h3> <h3 className="text-xl font-semibold">세부별 총사업비(①) - 재원분담 총사업비(②)</h3>
<span className="font-normal text-muted-foreground">(단위: 백만원)</span> <span className="font-normal text-muted-foreground">(단위: 백만원)</span>
</div> </div>
<Table caption="단계별 재원분담 내역" variant="secondary" className="table-fixed"> <Table caption="재원분담 목록으로 구분, 1단계, 2단계, 3단계, 4단계 정보 제공" variant="secondary" className="table-fixed">
<colgroup> <colgroup>
<col width="12%" /> <col width="12%" />
<col width="22%" /> <col width="22%" />
@@ -1,15 +1,13 @@
"use client"; "use client";
import { useState } from "react";
import { useFormContext, useWatch } from "react-hook-form"; import { useFormContext, useWatch } from "react-hook-form";
import dynamic from "next/dynamic";
import { NON_NEGATIVE_INPUT_PROPS, sanitizeNumberInput } from "@/shared/lib"; import { NON_NEGATIVE_INPUT_PROPS, sanitizeNumberInput } from "@/shared/lib";
import { SectionCollapsible } from "@/shared/ui"; import { SectionCollapsible } from "@/shared/ui";
import { Field } from "@/shared/ui/field"; import { Field } from "@/shared/ui/field";
import { truncateDecimals } from "@/widgets/pbadms-sprt"; import { truncateDecimals } from "@/widgets/pbadms-sprt";
import { PreviousPlanLookupButton } from "@/widgets/previous-plan-lookup";
import { import {
Button,
Input, Input,
Table, Table,
TableBody, TableBody,
@@ -21,14 +19,6 @@ import {
import type { SewerBscPlanRegistFormData } from "../../lib/sewer-bsc-plan-regist-schema"; 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 = [ const OTLN_ROWS = [
{ key: "sewerPlanPpltnCnt" as const, label: "계획인구", decimals: 0 }, { key: "sewerPlanPpltnCnt" as const, label: "계획인구", decimals: 0 },
{ key: "sewerPrcsPpltnCnt" as const, label: "처리인구", decimals: 0 }, { key: "sewerPrcsPpltnCnt" as const, label: "처리인구", decimals: 0 },
@@ -37,7 +27,6 @@ const OTLN_ROWS = [
function OutlineFormSection() { function OutlineFormSection() {
const { control, setValue, getValues } = useFormContext<SewerBscPlanRegistFormData>(); const { control, setValue, getValues } = useFormContext<SewerBscPlanRegistFormData>();
const [isLookupOpen, setIsLookupOpen] = useState(false);
const otlnList = useWatch({ control, name: "sewerBscPlanOtlnList" }) ?? []; const otlnList = useWatch({ control, name: "sewerBscPlanOtlnList" }) ?? [];
const frlvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.frlvlGoalYr" }); const frlvlGoalYr = useWatch({ control, name: "sewerBscPlanDtl.frlvlGoalYr" });
@@ -74,12 +63,8 @@ function OutlineFormSection() {
return ( return (
<SectionCollapsible title="사업개요"> <SectionCollapsible title="사업개요">
<div className="mb-2 flex justify-end px-2"> <PreviousPlanLookupButton sidoNm={sido} gugunNm={gugun} />
<Button type="button" color="white" onClick={() => setIsLookupOpen(true)}> <Table caption="사업개요 목록으로 구분, 1단계, 2단계, 3단계, 4단계 정보 제공" variant="secondary">
이전기본계획조회
</Button>
</div>
<Table caption="사업개요 단계별 입력" variant="secondary">
<colgroup> <colgroup>
<col width="12%" /> <col width="12%" />
<col width="22%" /> <col width="22%" />
@@ -126,13 +111,6 @@ function OutlineFormSection() {
))} ))}
</TableBody> </TableBody>
</Table> </Table>
<PreviousPlanLookupDialog
open={isLookupOpen}
onOpenChange={setIsLookupOpen}
initialSidoNm={sido}
initialGugunNm={gugun}
/>
</SectionCollapsible> </SectionCollapsible>
); );
} }

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