Compare commits

...
Author SHA1 Message Date
yangsein 7ffc02e887 refactor: 대시보드 통계처리현황 표준화 수정 2026-10-14 15:17:25 +09:00
yangsein cbacb27bb1 refactor: 행정동 갱신 표준화 수정 2026-10-14 15:16:40 +09:00
yangsein 5331e10c96 [refactor] 대시보드 - 통계처리현황 반려되어 제출중인 상태 구분 처리 포함 2026-09-30 16:59:05 +09:00
rsone001 de8971bea6 [ST] fix: 대시보드 통계 처리 현황 panel css 수정 2026-09-08 13:43:26 +09:00
rsone001 a7fd6a8e16 [ST] - fix: 취약점 점검 관련 조치 2026-09-07 14:26:00 +09:00
rsone001 8039ab8281 [ST] - refactor: 2-5 맨홀 지표 변경된 테이블에 맞게 수정, 일반검증 항목 입력기간일 시 등록/수정 불가 2026-09-04 16:05:26 +09:00
rsone001 794691f05e [ST] - fix: 제출결과(검증)화면에 시설명 컬럼 추가 2026-09-03 16:29:40 +09:00
rsone001 e58b84a01b [ST] - fix: input 제출 - 시계열 검증 요청 response & request 수정, 대시보드 구성 수정 2026-09-03 15:27:19 +09:00
rsone001 3d51f003c0 Merge remote-tracking branch 'origin/feature/sein' into iipbs/st/dev 2026-09-03 13:52:34 +09:00
yangsein b5a99e5f59 [feat] 대쉬 보드 일정관리 제거 , 통계 현황상태 추가 2026-09-03 13:44:09 +09:00
rsone001 afca685fd4 [ST] - fix: 2-3 매설연장 관리자일시 수정 가능하도록 적용, SearchFrom 로직 수정 2026-09-03 11:26:23 +09:00
rsone001 261941452a [ST] fix - 2-2 지표 컬럼 설정 및 저장 제출 로직 수정 2026-09-02 16:41:12 +09:00
rsone001 8576ef83b5 [ST] - refactor: 6-1 중심처리시설 row는 위탁계약기간 직접 수정 못하도록 적용 2026-09-01 16:25:55 +09:00
rsone001 e80b8953a0 [ST] - fix: 일반검증 수식 사용자 직접입력 등록시 공백제거 2026-09-01 14:41:49 +09:00
rsone001 e8b309460e [ST] input 특광역시 로직 수정, 일반검증 엑셀 다운로드 추가, 모든 number 타입 컬럼에 footer 추가 2026-08-28 17:08:20 +09:00
chanii8 37ac927c45 [st] feat: 시계열 검토 이력 > 지자체 진입 시 시도/시군구 자동 선택 2026-08-28 17:05:40 +09:00
chanii8 bf9a34cbe8 [st] feat: 하수도 통계 검증 > 시도/시군구 셀렉트에 '전체' 옵션 추가 2026-08-28 15:55:05 +09:00
rsone001 18a11ab5fa [ST] - refactor: 입력화면 전용 시도 조회 api 추가 2026-08-27 17:22:34 +09:00
rsone001 214ff1a435 [ST] - fix: 일반 검증 타입 총계 검증일 시 group by 항목 렌더링 되지 않게 수정 2026-08-27 15:44:45 +09:00
rsone001 d7cc579335 [ST] - refactor: 지난검증결과 불러오기 필드 추가(시설명) 2026-08-27 15:32:12 +09:00
rsone001 9b53c1ae08 [ST] - fix: 일반검증 항목 관리, 참조 지표(컬럼) 오류 로직 수정 2026-08-27 13:07:33 +09:00
rsone001 64ed53cf4d [ST] refactor: input - 자동 계산 cell 부동소수점 문제 해결을 위해 decimal.js 라이브러리 추가 및 계산 로직 수정, 6-2 지표 로직 수정 2026-08-25 16:04:31 +09:00
rsone001 043fd3d4a8 [ST] - 입력 6-1 공공하수처리시설 컬럼 설정 변경 2026-08-24 17:19:33 +09:00
rsone001 6dd09971f1 [ST] - 컬럼 자동 계산식 오류 수정 2026-08-24 16:39:24 +09:00
chanii8 23424b5eee [st] chore: 통계 검증 리포트 초기 조회 파라미터 수정 2026-08-24 14:09:12 +09:00
chanii8 3f190dfff6 Merge branch 'iipbs/st/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/st/dev 2026-08-21 17:27:37 +09:00
chanii8 6cc02da7a4 [st] feat: 통계 검증 리포트 목록 조회 API 수정 2026-08-21 17:27:30 +09:00
rsone001 c87ccf8788 [ST] - fix: 입력 저장&제출시 빈값 null처리 2026-08-21 14:56:34 +09:00
rsone001 8e757ab9db Merge branch 'iipbs/st/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/st/dev 2026-08-20 16:31:13 +09:00
rsone001 de7e66afb4 [ST] - fix: 11-4 데이터 체크 수정 2026-08-20 16:31:00 +09:00
chanii8 c981b83284 Merge branch 'iipbs/st/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/st/dev 2026-08-20 13:07:50 +09:00
chanii8 927bb7cb3a [st] chore: eslint 해결 2026-08-20 11:48:18 +09:00
chanii8 725976b5c0 [st] feat: 시도/시군구 조회 쿼리 변경 2026-08-20 11:47:50 +09:00
rsone001 cdbe6a855f [ST] - fix: 검토 하수관로 컬럼 수정 및 엑셀 내보내기 셀 스타일 수정 2026-08-19 12:31:37 +09:00
chanii8 a1c96e9e98 Merge branch 'iipbs/st/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/st/dev 2026-08-19 11:58:06 +09:00
chanii8 1488805242 [as] feat: 통계 검증 리포트 새로고침 버튼 추가 2026-08-19 11:57:58 +09:00
rsone001 5b12f81b8f [ST] - fix: 통계 집계표 조회 기본값 수정, 엑셀 다운로드 스타일 적용, 요약표 마우스 툴팁 추가 / 입력단 오류 수정 및 검토단 컬럼 설정 수정 2026-08-18 17:44:22 +09:00
rsone001 df24e7c2a8 [ST] - fix: 일반 검증 수식 안내 문구 수정 2026-08-13 16:20:54 +09:00
rsone001 3b5fee047b [ST] - fix: qa 조치, 일반 검증 개발 수정 2026-08-13 15:30:00 +09:00
rsone001 181730bcde [ST] - fix: 입력: 새로고침 시 열려있는 모든 텝의 공통 api가 실행되는 현상 수정, 하수도보급률 저장&제출 request 수정(외국인총인구 제거, 총면적 추가), 검증 툴팁 max-width 추가, 지난 검증 결과 및 제출 결과 데이터 마우스 툴팁 추가 2026-08-10 17:58:05 +09:00
chanii8 678af2f06e [st] chore: 승인 정보 입력 폼 수정 2026-08-10 15:48:19 +09:00
rsone001 82d67bb4c6 [ST] - fix: 검증 항목 명칭 변경 2026-08-07 17:22:08 +09:00
rsone001 85ef1805e5 [ST] - 공단 오류 및 개선 요청사항 처리 2026-08-07 16:24:39 +09:00
chanii8 26f64988ef [st] typo: 404 페이지 문구 수정 2026-08-06 17:06:00 +09:00
chanii8 29e39f98af Merge branch 'iipbs/st/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/st/dev 2026-08-06 15:15:09 +09:00
chanii8 5192f3e3c0 [st] feat: 404 페이지 구현 2026-08-06 15:15:04 +09:00
rsone001 83de317308 Merge branch 'iipbs/st/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/st/dev 2026-08-05 10:09:58 +09:00
rsone001 84b1fd07ae [ST] - management service code refresh 로직 추가 2026-08-05 10:08:26 +09:00
chanii8 a88f8ab33e [st] feat: 로그아웃 라우트 핸들러 적용 2026-08-03 15:56:48 +09:00
chanii8 c67b5844b6 [st] chore: metadata 수정 2026-08-03 11:14:48 +09:00
chanii8 3c65e6f8f5 chore: css 모듈 처리 2026-08-03 10:43:17 +09:00
chanii8 1efe2dbe89 remove: ClientIpGuard 제거 2026-07-31 17:56:21 +09:00
chanii8 36e56dcaef Merge branch 'iipbs/st/dev' 2026-07-30 11:28:08 +09:00
chanii8 e42e28d062 [st] chore: 통계검증리포트 > 승인정보입력 다이얼로그 수정 2026-07-30 11:27:36 +09:00
chanii8 0786053022 Merge remote-tracking branch 'origin/iipbs/st/dev' 2026-07-30 11:24:13 +09:00
rsone001 791ec1ae2d [ST] - code-refresh 로직 추가 2026-07-30 11:01:02 +09:00
rsone001 4e4942313a [ST] - fix: 통계일정관리 불필요 컬럼 제거 2026-07-28 11:34:46 +09:00
chanii8 e6fcbfbde4 chore: tsserver 용량 관리 2026-07-24 16:25:55 +09:00
chanii8 ed4d1b73cb refactor: X-Device-Id 공통 로직으로 변경 2026-07-24 15:53:23 +09:00
chanii8 e3dac52d7e Merge branch 'iipbs/st/dev' 2026-07-24 10:56:09 +09:00
chanii8 d1d8a1eff3 [st] feat: 통계 검증 리포트 시도/시군구 셀렉트 로직 수정 2026-07-24 10:47:57 +09:00
jcchoi 4502d55ce1 Merge remote-tracking branch 'origin/iipbs/st/dev' 2026-07-23 20:49:31 +09:00
rsone001 3d3b978070 [st] - fix: 검토이력조회 request 누락 수정 2026-07-23 16:36:29 +09:00
rsone001 117082c862 [st] - fix: 입력 메뉴 데이터 항목 추가 시 해당사항없음 false로 갱신되지 않던 오류 수정 2026-07-23 14:26:11 +09:00
chanii8 bfa03aabc0 Merge branch 'iipbs/st/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2026-07-23 11:43:30 +09:00
chanii8 f535cd1010 [st] feat: 대시보드 일정안내 empty 구현 및 일정관리 월 이동 구현 2026-07-23 11:33:21 +09:00
chanii8 2f6d4ebec1 chore: 코드 정리 2026-07-22 14:13:38 +09:00
rsone001 1c3cc5b61b Merge branch 'iipbs/st/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/st/dev 2026-07-22 13:39:57 +09:00
rsone001 db7584cb01 [st] - fix: 집계06.공공하수처리시설 조회 조건 추가, 입력06.공공하수처리시설 매핑 계약기간 DatePicker 수정, 입력자 예외 제출 valuedation 수정 2026-07-22 13:38:58 +09:00
chanii8 b842408f41 Merge remote-tracking branch 'origin/iipbs/as/dev' 2026-07-22 11:36:12 +09:00
chanii8 6e33a62ed9 [as] feat: 설계자문 페이지 구현 2026-07-22 11:34:38 +09:00
mind-one005 36231a5eca Merge branch 'iipbs/wr/dev' 2026-07-22 09:23:43 +09:00
mind-one005 b41a317788 wr: fo: sn: 지도화면 session confirm 2026-07-21 17:59:15 +09:00
chanii8 84fb81b245 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/as/dev 2026-07-21 15:56:50 +09:00
chanii8 e18f759fab [as] feat: 설계자문 엔티티 생성 2026-07-21 14:32:14 +09:00
chanii8 ce9c7b17a2 Merge remote-tracking branch 'origin/iipbs/st/dev' 2026-07-21 13:54:32 +09:00
chanii8 c2d6f7fe37 Merge remote-tracking branch 'origin/iipbs/cs/dev' 2026-07-21 13:54:28 +09:00
chanii8 9b18aa9cef Merge remote-tracking branch 'origin/iipbs/cm/dev' 2026-07-21 13:54:22 +09:00
chanii8 5eccb58aa9 Merge remote-tracking branch 'origin/iipbs/as/dev' 2026-07-21 13:54:16 +09:00
chanii8 acce1364fa Merge branch 'iipbs/st/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/st/dev 2026-07-21 13:53:30 +09:00
chanii8 625c5f5fe2 [st] chore: 토큰 자동 리프레시 코드 주석 처리 2026-07-21 13:53:24 +09:00
chanii8 dffa9d74a0 [cs] chore: 토큰 자동 리프레시 코드 주석 처리 2026-07-21 13:52:59 +09:00
chanii8 668a8ef844 [cm] chore: 토큰 자동 리프레시 코드 주석 처리 2026-07-21 13:52:26 +09:00
chanii8 df4d708981 [as] chore: 토큰 자동 리프레시 코드 주석 처리 2026-07-21 13:51:10 +09:00
chanii8 c48a46c7ab feat: 토큰 시간 만료 시 authService.logout 함수 실행 2026-07-21 13:50:19 +09:00
chanii8 da844fb3a7 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2026-07-21 11:50:36 +09:00
chanii8 3e1f89d90f [common/components] chore: 로그인 연장 콘솔 추가(임시) 2026-07-21 11:47:39 +09:00
mind-one005 559e4ee352 Merge remote-tracking branch 'origin/main' into iipbs/wr/dev 2026-07-21 11:38:33 +09:00
jcchoi a1d43dc65c 차수 단어 제외 2026-07-21 11:11:55 +09:00
chanii8 d26cd90b02 [common/components] feat: nofooter에 authService props 추가 2026-07-21 09:20:37 +09:00
chanii8 45f72f7333 [common/components] chore: auth-header 참조 경로 수정 2026-07-20 18:42:15 +09:00
chanii8 196d31a084 [common/components] feat: raw fetch 헤더에 토큰 주입 2026-07-20 18:35:14 +09:00
chanii8 aaf898fb4c Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/cm/dev 2026-07-20 17:47:19 +09:00
rsone001 533e89c919 [st] - fix: 이상치선별보고서 css 수정 2026-07-20 17:11:10 +09:00
chanii8 2f0448a98e [common/components] refactor: banner, menu, site를 기본 fetch 로 수정 2026-07-20 17:08:36 +09:00
chanii8 aab8cf9622 [cm] feat: 인증 서비스에 "X-Device-Id" 적용 2026-07-20 15:37:17 +09:00
mind-one005 88113fca1c wr: fo: sn: NEXT_PUBLIC_COOKIE_COMAIN 개발 추가 2026-07-20 14:52:48 +09:00
chanii8 81fc947c9d chore: as, st 앱 standalone 활성 2026-07-20 13:49:22 +09:00
chanii8 c684440e65 Merge branch 'iipbs/st/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2026-07-20 13:45:21 +09:00
chanii8 4c6222acf1 Merge branch 'iipbs/cs/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2026-07-20 13:45:12 +09:00
chanii8 2131ecc315 Merge branch 'iipbs/cm/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2026-07-20 13:45:07 +09:00
chanii8 1d3910c157 [st] chore: route handler 쿠키 도메인 설정 구문 수정 2026-07-20 13:43:46 +09:00
chanii8 3b2f5c2d5d [cm] chore: route handler 쿠키 도메인 설정 구문 수정 2026-07-20 13:42:55 +09:00
chanii8 386fc61a3f [cs] chore: route handler 쿠키 도메인 설정 구문 수정 2026-07-20 13:39:43 +09:00
377 changed files with 13281 additions and 5756 deletions
+9 -5
View File
@@ -19,11 +19,15 @@
}
],
"paths": {
"@/*": ["./src/*"],
"@common/components": ["../../packages/common/components/dist/"],
"@common/network": ["../../packages/common/network/"]
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"../../packages/common/types/**/*.d.ts"
],
"exclude": ["node_modules", ".next", "dist", "coverage", "storybook-static", "public"]
}
@@ -0,0 +1,10 @@
import { DesignEditPage } from "@/pages/design-cnstn";
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function Page({ params }: PageProps) {
const { id } = await params;
return <DesignEditPage id={id} />;
}
@@ -0,0 +1,10 @@
import { DesignViewPage } from "@/pages/design-cnstn";
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function Page({ params }: PageProps) {
const { id } = await params;
return <DesignViewPage id={id} />;
}
@@ -0,0 +1,3 @@
import { DesignListPage } from "@/pages/design-cnstn";
export default DesignListPage;
@@ -0,0 +1,12 @@
import { DesignRegistPage } from "@/pages/design-cnstn";
import { parseTaskSeComCdFromApi } from "@/pages/design-cnstn/lib/design-cnstn-regist-schema";
interface PageProps {
searchParams: Promise<{ taskSeComCd?: string }>;
}
export default async function Page({ searchParams }: PageProps) {
const params = await searchParams;
const taskSeComCd = parseTaskSeComCdFromApi(params.taskSeComCd);
return <DesignRegistPage taskSeComCd={taskSeComCd.length ? taskSeComCd : undefined} />;
}
+1
View File
@@ -1,6 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
reactStrictMode: true,
transpilePackages: ["@common/assets"],
};
-1
View File
@@ -32,7 +32,6 @@
"next": "15.5.18",
"react-hook-form": "^7.54.2",
"tus-js-client": "^4.3.1",
"uuid": "^14.0.1",
"zod": "^3.24.2"
},
"devDependencies": {
+15 -11
View File
@@ -1,8 +1,12 @@
import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config";
import { type AuthPayload, type AuthService, getRemainingMs } from "@common/auth";
import {
type AuthPayload,
type AuthService,
clearDeviceId,
getOrCreateDeviceId,
} from "@common/auth";
import { clearDeviceId, getOrCreateDeviceId } from "../lib/device-id";
import { clearRefreshTimer, setToken, setupRefreshTimer, tokenManager } from "../lib/token-manager";
import { clearRefreshTimer, setToken, tokenManager } from "../lib/token-manager";
async function login(payload: AuthPayload): Promise<void> {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
@@ -26,10 +30,10 @@ async function login(payload: AuthPayload): Promise<void> {
setToken(CLAIM_NAME, accessToken);
setToken(AUTH_USER_ID, tokenId);
const remainingMs = getRemainingMs(accessToken);
if (remainingMs > 0) {
setupRefreshTimer(silentRefresh, remainingMs);
}
// const remainingMs = getRemainingMs(accessToken);
// if (remainingMs > 0) {
// setupRefreshTimer(silentRefresh, remainingMs);
// }
}
async function silentRefresh(): Promise<void> {
@@ -58,10 +62,10 @@ async function silentRefresh(): Promise<void> {
setToken(CLAIM_NAME, accessToken);
setToken(AUTH_USER_ID, tokenId);
const remainingMs = getRemainingMs(accessToken);
if (remainingMs > 0) {
setupRefreshTimer(silentRefresh, remainingMs);
}
// const remainingMs = getRemainingMs(accessToken);
// if (remainingMs > 0) {
// setupRefreshTimer(silentRefresh, remainingMs);
// }
} catch (error) {
console.error(`[silentRefresh] Failed at ${new Date().toISOString()}`, error);
tokenManager.clearAuthToken();
@@ -1,17 +0,0 @@
import { v4 as uuidv4 } from "uuid";
const DEVICE_ID_KEY = "x-device-id";
export function getOrCreateDeviceId(): string {
if (typeof window === "undefined") return "";
const existing = localStorage.getItem(DEVICE_ID_KEY);
if (existing) return existing;
const id = uuidv4();
localStorage.setItem(DEVICE_ID_KEY, id);
return id;
}
export function clearDeviceId(): void {
if (typeof window === "undefined") return;
localStorage.removeItem(DEVICE_ID_KEY);
}
+2 -8
View File
@@ -18,13 +18,7 @@ interface CodeSelectProps extends React.ComponentPropsWithoutRef<typeof Select>
className?: string;
}
function CodeSelect({
upCd,
useYn,
placeholder = "선택",
className,
...props
}: CodeSelectProps) {
function CodeSelect({ upCd, useYn, placeholder = "선택", className, ...props }: CodeSelectProps) {
const listQuery = useCodeList(useYn ? undefined : upCd);
const listByUseYnQuery = useCodeListByUseYn(useYn ? upCd : undefined, useYn ?? "");
@@ -34,7 +28,7 @@ function CodeSelect({
return (
<Select {...props} disabled={props.disabled || isLoading}>
<SelectTrigger className={cn("w-[12rem]", className)}>
<SelectValue placeholder={isLoading ? "로딩 중…" : placeholder} />
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{codes?.map((item) => (
@@ -0,0 +1,58 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
DesignCnstnBizCstId,
DesignCnstnBizCstResponseDto,
DesignCnstnBizCstSaveRequestDto,
DesignCnstnBizCstSearchDto,
DesignCnstnBizCstService,
DesignCnstnBizCstUpdateRequestDto,
} from "../model/design-cnstn-biz-cst-interface";
const BASE = ENDPOINT.AS_SERVICE.DESIGN_CNSTN_BIZ_CST;
async function findPage(
searchDto: DesignCnstnBizCstSearchDto,
): Promise<PageResponse<DesignCnstnBizCstResponseDto>> {
const response = await client.get<PageResponse<DesignCnstnBizCstResponseDto>>(BASE, {
params: searchDto,
});
return response.data;
}
async function findById(
designCnstnDocNo: string,
acntgSeCd: string,
): Promise<DesignCnstnBizCstResponseDto> {
const response = await client.get<DesignCnstnBizCstResponseDto>(
`${BASE}/${designCnstnDocNo}/${acntgSeCd}`,
);
return response.data;
}
async function save(body: DesignCnstnBizCstSaveRequestDto): Promise<DesignCnstnBizCstId> {
const response = await client.post<DesignCnstnBizCstId>(BASE, body);
return response.data;
}
async function update(
designCnstnDocNo: string,
acntgSeCd: string,
body: DesignCnstnBizCstUpdateRequestDto,
): Promise<void> {
await client.put<void>(`${BASE}/${designCnstnDocNo}/${acntgSeCd}`, body);
}
async function deleteDesignCnstnBizCst(designCnstnDocNo: string, acntgSeCd: string): Promise<void> {
await client.delete<void>(`${BASE}/${designCnstnDocNo}/${acntgSeCd}`);
}
export const designCnstnBizCstService = {
findPage,
findById,
save,
update,
delete: deleteDesignCnstnBizCst,
} satisfies DesignCnstnBizCstService;
@@ -0,0 +1,58 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
DesignCnstnBizSclId,
DesignCnstnBizSclResponseDto,
DesignCnstnBizSclSaveRequestDto,
DesignCnstnBizSclSearchDto,
DesignCnstnBizSclService,
DesignCnstnBizSclUpdateRequestDto,
} from "../model/design-cnstn-biz-scl-interface";
const BASE = ENDPOINT.AS_SERVICE.DESIGN_CNSTN_BIZ_SCL;
async function findPage(
searchDto: DesignCnstnBizSclSearchDto,
): Promise<PageResponse<DesignCnstnBizSclResponseDto>> {
const response = await client.get<PageResponse<DesignCnstnBizSclResponseDto>>(BASE, {
params: searchDto,
});
return response.data;
}
async function findById(
designCnstnDocNo: string,
cnstpSeCd: string,
): Promise<DesignCnstnBizSclResponseDto> {
const response = await client.get<DesignCnstnBizSclResponseDto>(
`${BASE}/${designCnstnDocNo}/${cnstpSeCd}`,
);
return response.data;
}
async function save(body: DesignCnstnBizSclSaveRequestDto): Promise<DesignCnstnBizSclId> {
const response = await client.post<DesignCnstnBizSclId>(BASE, body);
return response.data;
}
async function update(
designCnstnDocNo: string,
cnstpSeCd: string,
body: DesignCnstnBizSclUpdateRequestDto,
): Promise<void> {
await client.put<void>(`${BASE}/${designCnstnDocNo}/${cnstpSeCd}`, body);
}
async function deleteDesignCnstnBizScl(designCnstnDocNo: string, cnstpSeCd: string): Promise<void> {
await client.delete<void>(`${BASE}/${designCnstnDocNo}/${cnstpSeCd}`);
}
export const designCnstnBizSclService = {
findPage,
findById,
save,
update,
delete: deleteDesignCnstnBizScl,
} satisfies DesignCnstnBizSclService;
@@ -0,0 +1,63 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
DesignCnstnEsntlAtchFileId,
DesignCnstnEsntlAtchFileResponseDto,
DesignCnstnEsntlAtchFileSaveRequestDto,
DesignCnstnEsntlAtchFileSearchDto,
DesignCnstnEsntlAtchFileService,
DesignCnstnEsntlAtchFileUpdateRequestDto,
} from "../model/design-cnstn-esntl-atch-file-interface";
const BASE = ENDPOINT.AS_SERVICE.DESIGN_CNSTN_ESNTL_ATCH_FILE;
async function findPage(
searchDto: DesignCnstnEsntlAtchFileSearchDto,
): Promise<PageResponse<DesignCnstnEsntlAtchFileResponseDto>> {
const response = await client.get<PageResponse<DesignCnstnEsntlAtchFileResponseDto>>(BASE, {
params: searchDto,
});
return response.data;
}
async function findById(
designCnstnDocNo: string,
esntlAtchFileId: string,
): Promise<DesignCnstnEsntlAtchFileResponseDto> {
const response = await client.get<DesignCnstnEsntlAtchFileResponseDto>(
`${BASE}/${designCnstnDocNo}/${esntlAtchFileId}`,
);
return response.data;
}
async function save(
body: DesignCnstnEsntlAtchFileSaveRequestDto,
): Promise<DesignCnstnEsntlAtchFileId> {
const response = await client.post<DesignCnstnEsntlAtchFileId>(BASE, body);
return response.data;
}
async function update(
designCnstnDocNo: string,
esntlAtchFileId: string,
body: DesignCnstnEsntlAtchFileUpdateRequestDto,
): Promise<void> {
await client.put<void>(`${BASE}/${designCnstnDocNo}/${esntlAtchFileId}`, body);
}
async function deleteDesignCnstnEsntlAtchFile(
designCnstnDocNo: string,
esntlAtchFileId: string,
): Promise<void> {
await client.delete<void>(`${BASE}/${designCnstnDocNo}/${esntlAtchFileId}`);
}
export const designCnstnEsntlAtchFileService = {
findPage,
findById,
save,
update,
delete: deleteDesignCnstnEsntlAtchFile,
} satisfies DesignCnstnEsntlAtchFileService;
@@ -0,0 +1,61 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
DesignCnstnApplySaveRequestDto,
DesignCnstnMngDetResponseDto,
DesignCnstnMngResponseDto,
DesignCnstnMngSaveRequestDto,
DesignCnstnMngSearchDto,
DesignCnstnMngService,
DesignCnstnMngUpdateRequestDto,
} from "../model/design-cnstn-mng-interface";
const BASE = ENDPOINT.AS_SERVICE.DESIGN_CNSTN;
async function findPage(
searchDto: DesignCnstnMngSearchDto,
): Promise<PageResponse<DesignCnstnMngResponseDto>> {
const response = await client.get<PageResponse<DesignCnstnMngResponseDto>>(BASE, {
params: searchDto,
});
return response.data;
}
async function findById(id: string): Promise<DesignCnstnMngDetResponseDto> {
const response = await client.get<DesignCnstnMngDetResponseDto>(`${BASE}/${id}`);
return response.data;
}
async function save(body: DesignCnstnMngSaveRequestDto): Promise<string> {
const response = await client.post<string>(BASE, body);
return response.data;
}
async function saveApply(body: DesignCnstnApplySaveRequestDto): Promise<string> {
const response = await client.post<string>(`${BASE}/save-apply`, body);
return response.data;
}
async function update(id: string, body: DesignCnstnMngUpdateRequestDto): Promise<void> {
await client.put<void>(`${BASE}/${id}`, body);
}
async function techReview(id: string, body: DesignCnstnApplySaveRequestDto): Promise<void> {
await client.post<void>(`${BASE}/${id}/tech-review`, body);
}
async function deleteDesignCnstnMng(id: string): Promise<void> {
await client.delete<void>(`${BASE}/${id}`);
}
export const designCnstnMngService = {
findPage,
findById,
save,
saveApply,
update,
techReview,
delete: deleteDesignCnstnMng,
} satisfies DesignCnstnMngService;
@@ -0,0 +1,61 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
DesignCnstnRvwOpnnId,
DesignCnstnRvwOpnnResponseDto,
DesignCnstnRvwOpnnSaveRequestDto,
DesignCnstnRvwOpnnSearchDto,
DesignCnstnRvwOpnnService,
DesignCnstnRvwOpnnUpdateRequestDto,
} from "../model/design-cnstn-rvw-opnn-interface";
const BASE = ENDPOINT.AS_SERVICE.DESIGN_CNSTN_RVW_OPNN;
async function findPage(
searchDto: DesignCnstnRvwOpnnSearchDto,
): Promise<PageResponse<DesignCnstnRvwOpnnResponseDto>> {
const response = await client.get<PageResponse<DesignCnstnRvwOpnnResponseDto>>(BASE, {
params: searchDto,
});
return response.data;
}
async function findById(
designCnstnDocNo: string,
rvwOpnnSn: number,
): Promise<DesignCnstnRvwOpnnResponseDto> {
const response = await client.get<DesignCnstnRvwOpnnResponseDto>(
`${BASE}/${designCnstnDocNo}/${rvwOpnnSn}`,
);
return response.data;
}
async function save(body: DesignCnstnRvwOpnnSaveRequestDto): Promise<DesignCnstnRvwOpnnId> {
const response = await client.post<DesignCnstnRvwOpnnId>(BASE, body);
return response.data;
}
async function update(
designCnstnDocNo: string,
rvwOpnnSn: number,
body: DesignCnstnRvwOpnnUpdateRequestDto,
): Promise<void> {
await client.put<void>(`${BASE}/${designCnstnDocNo}/${rvwOpnnSn}`, body);
}
async function deleteDesignCnstnRvwOpnn(
designCnstnDocNo: string,
rvwOpnnSn: number,
): Promise<void> {
await client.delete<void>(`${BASE}/${designCnstnDocNo}/${rvwOpnnSn}`);
}
export const designCnstnRvwOpnnService = {
findPage,
findById,
save,
update,
delete: deleteDesignCnstnRvwOpnn,
} satisfies DesignCnstnRvwOpnnService;
@@ -0,0 +1,411 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import type {
DesignCnstnBizCstSaveRequestDto,
DesignCnstnBizCstSearchDto,
DesignCnstnBizCstUpdateRequestDto,
} from "../model/design-cnstn-biz-cst-interface";
import type {
DesignCnstnBizSclSaveRequestDto,
DesignCnstnBizSclSearchDto,
DesignCnstnBizSclUpdateRequestDto,
} from "../model/design-cnstn-biz-scl-interface";
import type {
DesignCnstnEsntlAtchFileSaveRequestDto,
DesignCnstnEsntlAtchFileSearchDto,
DesignCnstnEsntlAtchFileUpdateRequestDto,
} from "../model/design-cnstn-esntl-atch-file-interface";
import type {
DesignCnstnApplySaveRequestDto,
DesignCnstnMngSaveRequestDto,
DesignCnstnMngSearchDto,
DesignCnstnMngUpdateRequestDto,
} from "../model/design-cnstn-mng-interface";
import type {
DesignCnstnRvwOpnnSaveRequestDto,
DesignCnstnRvwOpnnSearchDto,
DesignCnstnRvwOpnnUpdateRequestDto,
} from "../model/design-cnstn-rvw-opnn-interface";
import { designCnstnBizCstService } from "./design-cnstn-biz-cst-service";
import { designCnstnBizSclService } from "./design-cnstn-biz-scl-service";
import { designCnstnEsntlAtchFileService } from "./design-cnstn-esntl-atch-file-service";
import { designCnstnMngService } from "./design-cnstn-mng-service";
import { designCnstnRvwOpnnService } from "./design-cnstn-rvw-opnn-service";
export const designCnstnMngBaseKey = [ENDPOINT.AS_SERVICE.DESIGN_CNSTN] as const;
export const designCnstnBizCstBaseKey = [ENDPOINT.AS_SERVICE.DESIGN_CNSTN_BIZ_CST] as const;
export const designCnstnBizSclBaseKey = [ENDPOINT.AS_SERVICE.DESIGN_CNSTN_BIZ_SCL] as const;
export const designCnstnEsntlAtchFileBaseKey = [
ENDPOINT.AS_SERVICE.DESIGN_CNSTN_ESNTL_ATCH_FILE,
] as const;
export const designCnstnRvwOpnnBaseKey = [ENDPOINT.AS_SERVICE.DESIGN_CNSTN_RVW_OPNN] as const;
/* ─── DesignCnstnMng ─── */
export function useDesignCnstnMngPage(params: DesignCnstnMngSearchDto) {
return useQuery({
queryKey: [...designCnstnMngBaseKey, "findPage", params],
queryFn: () => designCnstnMngService.findPage(params),
});
}
export function useDesignCnstnMng(id: string | null | undefined) {
return useQuery({
queryKey: [...designCnstnMngBaseKey, "findById", id],
queryFn: () => designCnstnMngService.findById(id!),
enabled: Boolean(id),
});
}
export function useSaveDesignCnstnMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: DesignCnstnMngSaveRequestDto) => designCnstnMngService.save(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: designCnstnMngBaseKey });
},
});
}
export function useSaveApplyDesignCnstnMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: DesignCnstnApplySaveRequestDto) => designCnstnMngService.saveApply(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: designCnstnMngBaseKey });
},
});
}
export function useUpdateDesignCnstnMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: DesignCnstnMngUpdateRequestDto }) =>
designCnstnMngService.update(id, body),
onSuccess: (_data, { id }) => {
queryClient.invalidateQueries({ queryKey: designCnstnMngBaseKey });
queryClient.invalidateQueries({
queryKey: [...designCnstnMngBaseKey, "findById", id],
});
},
});
}
export function useTechReviewDesignCnstnMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: DesignCnstnApplySaveRequestDto }) =>
designCnstnMngService.techReview(id, body),
onSuccess: (_data, { id }) => {
queryClient.invalidateQueries({ queryKey: designCnstnMngBaseKey });
queryClient.invalidateQueries({
queryKey: [...designCnstnMngBaseKey, "findById", id],
});
},
});
}
export function useDeleteDesignCnstnMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => designCnstnMngService.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: designCnstnMngBaseKey });
},
});
}
/* ─── DesignCnstnBizCst ─── */
export function useDesignCnstnBizCstPage(params: DesignCnstnBizCstSearchDto) {
return useQuery({
queryKey: [...designCnstnBizCstBaseKey, "findPage", params],
queryFn: () => designCnstnBizCstService.findPage(params),
});
}
export function useDesignCnstnBizCst(
designCnstnDocNo: string | null | undefined,
acntgSeCd: string | null | undefined,
) {
return useQuery({
queryKey: [...designCnstnBizCstBaseKey, "findById", designCnstnDocNo, acntgSeCd],
queryFn: () => designCnstnBizCstService.findById(designCnstnDocNo!, acntgSeCd!),
enabled: Boolean(designCnstnDocNo && acntgSeCd),
});
}
export function useSaveDesignCnstnBizCst() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: DesignCnstnBizCstSaveRequestDto) => designCnstnBizCstService.save(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: designCnstnBizCstBaseKey });
queryClient.invalidateQueries({ queryKey: designCnstnMngBaseKey });
},
});
}
export function useUpdateDesignCnstnBizCst() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
designCnstnDocNo,
acntgSeCd,
body,
}: {
designCnstnDocNo: string;
acntgSeCd: string;
body: DesignCnstnBizCstUpdateRequestDto;
}) => designCnstnBizCstService.update(designCnstnDocNo, acntgSeCd, body),
onSuccess: (_data, { designCnstnDocNo, acntgSeCd }) => {
queryClient.invalidateQueries({ queryKey: designCnstnBizCstBaseKey });
queryClient.invalidateQueries({
queryKey: [...designCnstnBizCstBaseKey, "findById", designCnstnDocNo, acntgSeCd],
});
queryClient.invalidateQueries({ queryKey: designCnstnMngBaseKey });
},
});
}
export function useDeleteDesignCnstnBizCst() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
designCnstnDocNo,
acntgSeCd,
}: {
designCnstnDocNo: string;
acntgSeCd: string;
}) => designCnstnBizCstService.delete(designCnstnDocNo, acntgSeCd),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: designCnstnBizCstBaseKey });
queryClient.invalidateQueries({ queryKey: designCnstnMngBaseKey });
},
});
}
/* ─── DesignCnstnBizScl ─── */
export function useDesignCnstnBizSclPage(params: DesignCnstnBizSclSearchDto) {
return useQuery({
queryKey: [...designCnstnBizSclBaseKey, "findPage", params],
queryFn: () => designCnstnBizSclService.findPage(params),
});
}
export function useDesignCnstnBizScl(
designCnstnDocNo: string | null | undefined,
cnstpSeCd: string | null | undefined,
) {
return useQuery({
queryKey: [...designCnstnBizSclBaseKey, "findById", designCnstnDocNo, cnstpSeCd],
queryFn: () => designCnstnBizSclService.findById(designCnstnDocNo!, cnstpSeCd!),
enabled: Boolean(designCnstnDocNo && cnstpSeCd),
});
}
export function useSaveDesignCnstnBizScl() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: DesignCnstnBizSclSaveRequestDto) => designCnstnBizSclService.save(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: designCnstnBizSclBaseKey });
queryClient.invalidateQueries({ queryKey: designCnstnMngBaseKey });
},
});
}
export function useUpdateDesignCnstnBizScl() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
designCnstnDocNo,
cnstpSeCd,
body,
}: {
designCnstnDocNo: string;
cnstpSeCd: string;
body: DesignCnstnBizSclUpdateRequestDto;
}) => designCnstnBizSclService.update(designCnstnDocNo, cnstpSeCd, body),
onSuccess: (_data, { designCnstnDocNo, cnstpSeCd }) => {
queryClient.invalidateQueries({ queryKey: designCnstnBizSclBaseKey });
queryClient.invalidateQueries({
queryKey: [...designCnstnBizSclBaseKey, "findById", designCnstnDocNo, cnstpSeCd],
});
queryClient.invalidateQueries({ queryKey: designCnstnMngBaseKey });
},
});
}
export function useDeleteDesignCnstnBizScl() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
designCnstnDocNo,
cnstpSeCd,
}: {
designCnstnDocNo: string;
cnstpSeCd: string;
}) => designCnstnBizSclService.delete(designCnstnDocNo, cnstpSeCd),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: designCnstnBizSclBaseKey });
queryClient.invalidateQueries({ queryKey: designCnstnMngBaseKey });
},
});
}
/* ─── DesignCnstnEsntlAtchFile ─── */
export function useDesignCnstnEsntlAtchFilePage(params: DesignCnstnEsntlAtchFileSearchDto) {
return useQuery({
queryKey: [...designCnstnEsntlAtchFileBaseKey, "findPage", params],
queryFn: () => designCnstnEsntlAtchFileService.findPage(params),
});
}
export function useDesignCnstnEsntlAtchFile(
designCnstnDocNo: string | null | undefined,
esntlAtchFileId: string | null | undefined,
) {
return useQuery({
queryKey: [
...designCnstnEsntlAtchFileBaseKey,
"findById",
designCnstnDocNo,
esntlAtchFileId,
],
queryFn: () =>
designCnstnEsntlAtchFileService.findById(designCnstnDocNo!, esntlAtchFileId!),
enabled: Boolean(designCnstnDocNo && esntlAtchFileId),
});
}
export function useSaveDesignCnstnEsntlAtchFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: DesignCnstnEsntlAtchFileSaveRequestDto) =>
designCnstnEsntlAtchFileService.save(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: designCnstnEsntlAtchFileBaseKey });
queryClient.invalidateQueries({ queryKey: designCnstnMngBaseKey });
},
});
}
export function useUpdateDesignCnstnEsntlAtchFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
designCnstnDocNo,
esntlAtchFileId,
body,
}: {
designCnstnDocNo: string;
esntlAtchFileId: string;
body: DesignCnstnEsntlAtchFileUpdateRequestDto;
}) => designCnstnEsntlAtchFileService.update(designCnstnDocNo, esntlAtchFileId, body),
onSuccess: (_data, { designCnstnDocNo, esntlAtchFileId }) => {
queryClient.invalidateQueries({ queryKey: designCnstnEsntlAtchFileBaseKey });
queryClient.invalidateQueries({
queryKey: [
...designCnstnEsntlAtchFileBaseKey,
"findById",
designCnstnDocNo,
esntlAtchFileId,
],
});
queryClient.invalidateQueries({ queryKey: designCnstnMngBaseKey });
},
});
}
export function useDeleteDesignCnstnEsntlAtchFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
designCnstnDocNo,
esntlAtchFileId,
}: {
designCnstnDocNo: string;
esntlAtchFileId: string;
}) => designCnstnEsntlAtchFileService.delete(designCnstnDocNo, esntlAtchFileId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: designCnstnEsntlAtchFileBaseKey });
queryClient.invalidateQueries({ queryKey: designCnstnMngBaseKey });
},
});
}
/* ─── DesignCnstnRvwOpnn ─── */
export function useDesignCnstnRvwOpnnPage(params: DesignCnstnRvwOpnnSearchDto) {
return useQuery({
queryKey: [...designCnstnRvwOpnnBaseKey, "findPage", params],
queryFn: () => designCnstnRvwOpnnService.findPage(params),
});
}
export function useDesignCnstnRvwOpnn(
designCnstnDocNo: string | null | undefined,
rvwOpnnSn: number | null | undefined,
) {
return useQuery({
queryKey: [...designCnstnRvwOpnnBaseKey, "findById", designCnstnDocNo, rvwOpnnSn],
queryFn: () => designCnstnRvwOpnnService.findById(designCnstnDocNo!, rvwOpnnSn!),
enabled: Boolean(designCnstnDocNo && rvwOpnnSn != null),
});
}
export function useSaveDesignCnstnRvwOpnn() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: DesignCnstnRvwOpnnSaveRequestDto) => designCnstnRvwOpnnService.save(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: designCnstnRvwOpnnBaseKey });
queryClient.invalidateQueries({ queryKey: designCnstnMngBaseKey });
},
});
}
export function useUpdateDesignCnstnRvwOpnn() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
designCnstnDocNo,
rvwOpnnSn,
body,
}: {
designCnstnDocNo: string;
rvwOpnnSn: number;
body: DesignCnstnRvwOpnnUpdateRequestDto;
}) => designCnstnRvwOpnnService.update(designCnstnDocNo, rvwOpnnSn, body),
onSuccess: (_data, { designCnstnDocNo, rvwOpnnSn }) => {
queryClient.invalidateQueries({ queryKey: designCnstnRvwOpnnBaseKey });
queryClient.invalidateQueries({
queryKey: [...designCnstnRvwOpnnBaseKey, "findById", designCnstnDocNo, rvwOpnnSn],
});
queryClient.invalidateQueries({ queryKey: designCnstnMngBaseKey });
},
});
}
export function useDeleteDesignCnstnRvwOpnn() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
designCnstnDocNo,
rvwOpnnSn,
}: {
designCnstnDocNo: string;
rvwOpnnSn: number;
}) => designCnstnRvwOpnnService.delete(designCnstnDocNo, rvwOpnnSn),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: designCnstnRvwOpnnBaseKey });
queryClient.invalidateQueries({ queryKey: designCnstnMngBaseKey });
},
});
}
@@ -0,0 +1,88 @@
export { designCnstnBizCstService } from "./api/design-cnstn-biz-cst-service";
export { designCnstnBizSclService } from "./api/design-cnstn-biz-scl-service";
export { designCnstnEsntlAtchFileService } from "./api/design-cnstn-esntl-atch-file-service";
export { designCnstnMngService } from "./api/design-cnstn-mng-service";
export { designCnstnRvwOpnnService } from "./api/design-cnstn-rvw-opnn-service";
export {
designCnstnBizCstBaseKey,
designCnstnBizSclBaseKey,
designCnstnEsntlAtchFileBaseKey,
designCnstnMngBaseKey,
designCnstnRvwOpnnBaseKey,
useDeleteDesignCnstnBizCst,
useDeleteDesignCnstnBizScl,
useDeleteDesignCnstnEsntlAtchFile,
useDeleteDesignCnstnMng,
useDeleteDesignCnstnRvwOpnn,
useDesignCnstnBizCst,
useDesignCnstnBizCstPage,
useDesignCnstnBizScl,
useDesignCnstnBizSclPage,
useDesignCnstnEsntlAtchFile,
useDesignCnstnEsntlAtchFilePage,
useDesignCnstnMng,
useDesignCnstnMngPage,
useDesignCnstnRvwOpnn,
useDesignCnstnRvwOpnnPage,
useSaveApplyDesignCnstnMng,
useSaveDesignCnstnBizCst,
useSaveDesignCnstnBizScl,
useSaveDesignCnstnEsntlAtchFile,
useSaveDesignCnstnMng,
useSaveDesignCnstnRvwOpnn,
useTechReviewDesignCnstnMng,
useUpdateDesignCnstnBizCst,
useUpdateDesignCnstnBizScl,
useUpdateDesignCnstnEsntlAtchFile,
useUpdateDesignCnstnMng,
useUpdateDesignCnstnRvwOpnn,
} from "./api/queries";
export type {
DesignCnstnBizCstId,
DesignCnstnBizCstResponseDto,
DesignCnstnBizCstSaveRequestDto,
DesignCnstnBizCstSearchDto,
DesignCnstnBizCstService,
DesignCnstnBizCstUpdateRequestDto,
} from "./model/design-cnstn-biz-cst-interface";
export type {
DesignCnstnBizSclId,
DesignCnstnBizSclResponseDto,
DesignCnstnBizSclSaveRequestDto,
DesignCnstnBizSclSearchDto,
DesignCnstnBizSclService,
DesignCnstnBizSclUpdateRequestDto,
} from "./model/design-cnstn-biz-scl-interface";
export type {
DesignCnstnEsntlAtchFileId,
DesignCnstnEsntlAtchFileResponseDto,
DesignCnstnEsntlAtchFileSaveRequestDto,
DesignCnstnEsntlAtchFileSearchDto,
DesignCnstnEsntlAtchFileService,
DesignCnstnEsntlAtchFileUpdateRequestDto,
} from "./model/design-cnstn-esntl-atch-file-interface";
export type {
AtflMngClientResponseDto,
BizCstItem,
BizCstTreeDto,
BizSclTreeDto,
BzScaleItem,
DesignCnstnApplySaveRequestDto,
DesignCnstnMngDetResponseDto,
DesignCnstnMngResponseDto,
DesignCnstnMngSaveRequestDto,
DesignCnstnMngSearchDto,
DesignCnstnMngService,
DesignCnstnMngUpdateRequestDto,
DesignCnstnRvwOpnnResponseDto,
DesignCnstnRvwOpnnSaveRequestDto,
ElaprDocPrgrsClientResponseDto,
EsntlAtchFileItem,
EsntlAtchFileMngTreeResponseDto,
} from "./model/design-cnstn-mng-interface";
export type {
DesignCnstnRvwOpnnId,
DesignCnstnRvwOpnnSearchDto,
DesignCnstnRvwOpnnService,
DesignCnstnRvwOpnnUpdateRequestDto,
} from "./model/design-cnstn-rvw-opnn-interface";
@@ -0,0 +1,54 @@
import type { PageableParams, PageResponse } from "@/shared/model";
/** 백엔드 DesignCnstnBizCstId */
export interface DesignCnstnBizCstId {
designCnstnMng: string;
acntgSeCd: string;
}
/** 백엔드 DesignCnstnBizCstSaveRequestDto */
export interface DesignCnstnBizCstSaveRequestDto {
designCnstnDocNo: string;
acntgSeCd: string;
bscPlanAmt?: number;
bgtRfltAmt?: number;
tstmDesignAmt?: number;
}
/** 백엔드 DesignCnstnBizCstUpdateRequestDto */
export interface DesignCnstnBizCstUpdateRequestDto {
bscPlanAmt?: number;
bgtRfltAmt?: number;
tstmDesignAmt?: number;
}
/** 백엔드 DesignCnstnBizCstSearchDto + Pageable */
export interface DesignCnstnBizCstSearchDto extends PageableParams {
designCnstnDocNo?: string;
acntgSeCd?: string;
}
/** 백엔드 DesignCnstnBizCstResponseDto */
export interface DesignCnstnBizCstResponseDto {
designCnstnDocNo: string;
acntgSeCd: string;
bscPlanAmt?: number;
bgtRfltAmt?: number;
tstmDesignAmt?: number;
regDt?: string;
}
/** Controller public 메서드명·시그니처와 동일 */
export interface DesignCnstnBizCstService {
findPage(
searchDto: DesignCnstnBizCstSearchDto,
): Promise<PageResponse<DesignCnstnBizCstResponseDto>>;
findById(designCnstnDocNo: string, acntgSeCd: string): Promise<DesignCnstnBizCstResponseDto>;
save(body: DesignCnstnBizCstSaveRequestDto): Promise<DesignCnstnBizCstId>;
update(
designCnstnDocNo: string,
acntgSeCd: string,
body: DesignCnstnBizCstUpdateRequestDto,
): Promise<void>;
delete(designCnstnDocNo: string, acntgSeCd: string): Promise<void>;
}
@@ -0,0 +1,54 @@
import type { PageableParams, PageResponse } from "@/shared/model";
/** 백엔드 DesignCnstnBizSclId */
export interface DesignCnstnBizSclId {
designCnstnMng: string;
cnstpSeCd: string;
}
/** 백엔드 DesignCnstnBizSclSaveRequestDto */
export interface DesignCnstnBizSclSaveRequestDto {
designCnstnDocNo: string;
cnstpSeCd: string;
bscPlanQty?: number;
bgtRfltQty?: number;
tstmDesignQty?: number;
}
/** 백엔드 DesignCnstnBizSclUpdateRequestDto */
export interface DesignCnstnBizSclUpdateRequestDto {
bscPlanQty?: number;
bgtRfltQty?: number;
tstmDesignQty?: number;
}
/** 백엔드 DesignCnstnBizSclSearchDto + Pageable */
export interface DesignCnstnBizSclSearchDto extends PageableParams {
designCnstnDocNo?: string;
cnstpSeCd?: string;
}
/** 백엔드 DesignCnstnBizSclResponseDto */
export interface DesignCnstnBizSclResponseDto {
designCnstnDocNo: string;
cnstpSeCd: string;
bscPlanQty?: number;
bgtRfltQty?: number;
tstmDesignQty?: number;
regDt?: string;
}
/** Controller public 메서드명·시그니처와 동일 */
export interface DesignCnstnBizSclService {
findPage(
searchDto: DesignCnstnBizSclSearchDto,
): Promise<PageResponse<DesignCnstnBizSclResponseDto>>;
findById(designCnstnDocNo: string, cnstpSeCd: string): Promise<DesignCnstnBizSclResponseDto>;
save(body: DesignCnstnBizSclSaveRequestDto): Promise<DesignCnstnBizSclId>;
update(
designCnstnDocNo: string,
cnstpSeCd: string,
body: DesignCnstnBizSclUpdateRequestDto,
): Promise<void>;
delete(designCnstnDocNo: string, cnstpSeCd: string): Promise<void>;
}
@@ -0,0 +1,51 @@
import type { PageableParams, PageResponse } from "@/shared/model";
/** 백엔드 DesignCnstnEsntlAtchFileId */
export interface DesignCnstnEsntlAtchFileId {
designCnstnMng: string;
esntlAtchFileId: string;
}
/** 백엔드 DesignCnstnEsntlAtchFileSaveRequestDto */
export interface DesignCnstnEsntlAtchFileSaveRequestDto {
designCnstnDocNo: string;
esntlAtchFileId: string;
atchFileGroupId?: string;
}
/** 백엔드 DesignCnstnEsntlAtchFileUpdateRequestDto */
export interface DesignCnstnEsntlAtchFileUpdateRequestDto {
atchFileGroupId?: string;
}
/** 백엔드 DesignCnstnEsntlAtchFileSearchDto + Pageable */
export interface DesignCnstnEsntlAtchFileSearchDto extends PageableParams {
designCnstnDocNo?: string;
esntlAtchFileId?: string;
}
/** 백엔드 DesignCnstnEsntlAtchFileResponseDto */
export interface DesignCnstnEsntlAtchFileResponseDto {
designCnstnDocNo: string;
esntlAtchFileId: string;
atchFileGroupId?: string;
regDt?: string;
}
/** Controller public 메서드명·시그니처와 동일 */
export interface DesignCnstnEsntlAtchFileService {
findPage(
searchDto: DesignCnstnEsntlAtchFileSearchDto,
): Promise<PageResponse<DesignCnstnEsntlAtchFileResponseDto>>;
findById(
designCnstnDocNo: string,
esntlAtchFileId: string,
): Promise<DesignCnstnEsntlAtchFileResponseDto>;
save(body: DesignCnstnEsntlAtchFileSaveRequestDto): Promise<DesignCnstnEsntlAtchFileId>;
update(
designCnstnDocNo: string,
esntlAtchFileId: string,
body: DesignCnstnEsntlAtchFileUpdateRequestDto,
): Promise<void>;
delete(designCnstnDocNo: string, esntlAtchFileId: string): Promise<void>;
}
@@ -0,0 +1,124 @@
import type {
BizCstSaveBase,
BzScaleSaveBase,
EsntlAtchFileSaveBase,
PageableParams,
PageResponse,
PbadmsApplySaveHeader,
PbadmsMngDetResponseBase,
} from "@/shared/model";
import type {
DesignCnstnRvwOpnnResponseDto,
DesignCnstnRvwOpnnSaveRequestDto,
} from "./design-cnstn-rvw-opnn-interface";
export type {
DesignCnstnRvwOpnnResponseDto,
DesignCnstnRvwOpnnSaveRequestDto,
} from "./design-cnstn-rvw-opnn-interface";
export type {
AtflMngClientResponseDto,
BizCstTreeDto,
BizSclTreeDto,
ElaprDocPrgrsClientResponseDto,
EsntlAtchFileMngTreeResponseDto,
} from "@/shared/model";
/** 백엔드 DesignCnstnMngSaveRequestDto */
export interface DesignCnstnMngSaveRequestDto {
designCnstnDocNo?: string;
admdstCd?: string;
envoSeComCd?: string;
taskSeComCd?: string;
bizNm?: string;
bizBgngYr?: string;
bizEndYr?: string;
bizCn?: string;
aplcntNm?: string;
aplcntMblTelno?: string;
aplcntOgdpNm?: string;
aplcntEmlAddr?: string;
dsgnrNm?: string;
dsgnrTelno?: string;
dsgnrOgdpNm?: string;
dsgnrEmlAddr?: string;
atchFileGroupId?: string;
prgrsSttsComCd?: string;
}
/** 백엔드 DesignCnstnMngUpdateRequestDto */
export type DesignCnstnMngUpdateRequestDto = Omit<DesignCnstnMngSaveRequestDto, "designCnstnDocNo">;
/** 백엔드 DesignCnstnMngSearchDto + Pageable */
export interface DesignCnstnMngSearchDto extends PageableParams {
designCnstnDocNo?: string;
admdstCd?: string;
envoSeComCd?: string;
taskSeComCd?: string;
bizNm?: string;
bizBgngYr?: string;
bizEndYr?: string;
bizCn?: string;
aplcntNm?: string;
aplcntMblTelno?: string;
aplcntOgdpNm?: string;
aplcntEmlAddr?: string;
dsgnrNm?: string;
dsgnrTelno?: string;
dsgnrOgdpNm?: string;
dsgnrEmlAddr?: string;
atchFileGroupId?: string;
}
interface DesignCnstnMngResponseExtension {
designCnstnDocNo: string;
envoSeCdNm?: string;
taskSeCdNm?: string;
prgrsSttsCdNm?: string;
regDt?: string;
rgtr?: string;
rgtrNm?: string;
mdfcnDt?: string;
sido?: string;
gugun?: string;
}
/** 백엔드 DesignCnstnMngResponseDto */
export type DesignCnstnMngResponseDto = Omit<DesignCnstnMngSaveRequestDto, "designCnstnDocNo"> &
DesignCnstnMngResponseExtension;
/** 백엔드 DesignCnstnApplySaveRequestDto.BzScaleItem */
export type BzScaleItem = BzScaleSaveBase;
/** 백엔드 DesignCnstnApplySaveRequestDto.BizCstItem */
export type BizCstItem = BizCstSaveBase;
/** 백엔드 DesignCnstnApplySaveRequestDto.EsntlAtchFileItem */
export type EsntlAtchFileItem = EsntlAtchFileSaveBase;
/** 백엔드 DesignCnstnMngDetResponseDto */
export type DesignCnstnMngDetResponseDto = PbadmsMngDetResponseBase<
DesignCnstnMngResponseDto,
DesignCnstnRvwOpnnResponseDto
>;
/** 백엔드 DesignCnstnApplySaveRequestDto */
export interface DesignCnstnApplySaveRequestDto extends PbadmsApplySaveHeader {
designCnstnMng: DesignCnstnMngSaveRequestDto;
bzScaleList?: BzScaleItem[];
bizCstList?: BizCstItem[];
esntlAtchFileList?: EsntlAtchFileItem[];
rvwOpnnList?: DesignCnstnRvwOpnnSaveRequestDto[];
}
/** Controller public 메서드명·시그니처와 동일 */
export interface DesignCnstnMngService {
findPage(searchDto: DesignCnstnMngSearchDto): Promise<PageResponse<DesignCnstnMngResponseDto>>;
findById(id: string): Promise<DesignCnstnMngDetResponseDto>;
save(body: DesignCnstnMngSaveRequestDto): Promise<string>;
saveApply(body: DesignCnstnApplySaveRequestDto): Promise<string>;
update(id: string, body: DesignCnstnMngUpdateRequestDto): Promise<void>;
techReview(id: string, body: DesignCnstnApplySaveRequestDto): Promise<void>;
delete(id: string): Promise<void>;
}
@@ -0,0 +1,59 @@
import type { PageableParams, PageResponse, RvwOpnnResponseExtension } from "@/shared/model";
/** 백엔드 DesignCnstnRvwOpnnId */
export interface DesignCnstnRvwOpnnId {
designCnstnMng: string;
rvwOpnnSn: number;
}
/** 백엔드 DesignCnstnRvwOpnnSaveRequestDto */
export interface DesignCnstnRvwOpnnSaveRequestDto {
designCnstnDocNo: string;
rvwOpnnSn?: number;
opnrSeComCd?: string;
opnrNm?: string;
rvwRsltCn?: string;
rvwRsltAnmlyYn?: string;
atchFileGroupId?: string;
}
/** 백엔드 DesignCnstnRvwOpnnUpdateRequestDto */
export interface DesignCnstnRvwOpnnUpdateRequestDto {
opnrSeComCd?: string;
opnrNm?: string;
rvwRsltCn?: string;
rvwRsltAnmlyYn?: string;
atchFileGroupId?: string;
}
/** 백엔드 DesignCnstnRvwOpnnSearchDto + Pageable */
export interface DesignCnstnRvwOpnnSearchDto extends PageableParams {
designCnstnDocNo?: string;
opnrNm?: string;
opnrSeComCd?: string;
rvwRsltCn?: string;
rvwRsltAnmlyYn?: string;
atchFileGroupId?: string;
}
/** 백엔드 DesignCnstnRvwOpnnResponseDto */
export type DesignCnstnRvwOpnnResponseDto = Partial<
Pick<DesignCnstnRvwOpnnSaveRequestDto, "designCnstnDocNo" | "rvwOpnnSn">
> &
Omit<DesignCnstnRvwOpnnSaveRequestDto, "designCnstnDocNo" | "rvwOpnnSn"> &
RvwOpnnResponseExtension;
/** Controller public 메서드명·시그니처와 동일 */
export interface DesignCnstnRvwOpnnService {
findPage(
searchDto: DesignCnstnRvwOpnnSearchDto,
): Promise<PageResponse<DesignCnstnRvwOpnnResponseDto>>;
findById(designCnstnDocNo: string, rvwOpnnSn: number): Promise<DesignCnstnRvwOpnnResponseDto>;
save(body: DesignCnstnRvwOpnnSaveRequestDto): Promise<DesignCnstnRvwOpnnId>;
update(
designCnstnDocNo: string,
rvwOpnnSn: number,
body: DesignCnstnRvwOpnnUpdateRequestDto,
): Promise<void>;
delete(designCnstnDocNo: string, rvwOpnnSn: number): Promise<void>;
}
+5
View File
@@ -0,0 +1,5 @@
export { DesignEditPage } from "./ui/design-edit-page";
export { DesignFormPage } from "./ui/design-form-page";
export { DesignListPage } from "./ui/design-list-page";
export { DesignRegistPage } from "./ui/design-regist-page";
export { DesignViewPage } from "./ui/design-view-page";
@@ -0,0 +1,118 @@
export const DESIGN_CNSTN_VIEW_BUTTON_ACTION = {
strg: "strg",
aply: "aply",
del: "del",
rqstRvw: "rqstRvw",
rvwStrg: "rvwStrg",
rvwCmptn: "rvwCmptn",
atrzAprv: "atrzAprv",
atrzRply: "atrzRply",
} as const;
export type DesignCnstnViewButtonAction =
(typeof DESIGN_CNSTN_VIEW_BUTTON_ACTION)[keyof typeof DESIGN_CNSTN_VIEW_BUTTON_ACTION];
const ROLE_STATUS_BUTTON_ACTIONS: Record<string, Record<string, DesignCnstnViewButtonAction[]>> = {
ROLE_USER05: {
"": [
DESIGN_CNSTN_VIEW_BUTTON_ACTION.strg,
DESIGN_CNSTN_VIEW_BUTTON_ACTION.aply,
DESIGN_CNSTN_VIEW_BUTTON_ACTION.del,
],
CV: [
DESIGN_CNSTN_VIEW_BUTTON_ACTION.strg,
DESIGN_CNSTN_VIEW_BUTTON_ACTION.aply,
DESIGN_CNSTN_VIEW_BUTTON_ACTION.del,
],
},
};
const ROLE_USER01_ATRZ_STATUS_CODES = ["CT", "CS"] as const;
const ROLE_USER01_ATRZ_STATUS_ACTIONS: Record<
string,
Record<string, DesignCnstnViewButtonAction[]>
> = {
CT: {
ATRZ01: [DESIGN_CNSTN_VIEW_BUTTON_ACTION.rqstRvw],
},
CS: {
ATRZ02: [DESIGN_CNSTN_VIEW_BUTTON_ACTION.rvwStrg, DESIGN_CNSTN_VIEW_BUTTON_ACTION.rvwCmptn],
ATRZ03: [DESIGN_CNSTN_VIEW_BUTTON_ACTION.atrzAprv],
ATRZ99: [DESIGN_CNSTN_VIEW_BUTTON_ACTION.atrzRply],
},
};
function resolveButtonConfigs(
actions: DesignCnstnViewButtonAction[],
): DesignCnstnViewButtonAction[] {
return actions.map((action) => DESIGN_CNSTN_VIEW_BUTTON_ACTION[action]);
}
interface GetButtonConfigsOptions {
hasPendingApprovalRequest?: boolean;
}
function resolveRoleUser01AtrzButtonActions(
prgrsSttsComCd: string,
atrzSeComCdList: string[],
options?: GetButtonConfigsOptions,
): DesignCnstnViewButtonAction[] {
if (prgrsSttsComCd === "CS" && options?.hasPendingApprovalRequest === false) {
return [];
}
const atrzStatusMap = ROLE_USER01_ATRZ_STATUS_ACTIONS[prgrsSttsComCd];
if (!atrzStatusMap || atrzSeComCdList.length === 0) return [];
const seen = new Set<DesignCnstnViewButtonAction>();
const actions: DesignCnstnViewButtonAction[] = [];
for (const atrzSeComCd of atrzSeComCdList) {
const mappedActions = atrzStatusMap[atrzSeComCd];
if (!mappedActions) continue;
for (const action of mappedActions) {
if (seen.has(action)) continue;
seen.add(action);
actions.push(action);
}
}
return actions;
}
export function needsRoleUser01AtrzResolve(
dtlInstCd: string | undefined,
prgrsSttsComCd: string,
): boolean {
return (
dtlInstCd === "ROLE_USER01" &&
ROLE_USER01_ATRZ_STATUS_CODES.includes(
prgrsSttsComCd as (typeof ROLE_USER01_ATRZ_STATUS_CODES)[number],
)
);
}
export function getButtonConfigs(
dtlInstCd: string | undefined,
prgrsSttsComCd: string,
atrzSeComCdList?: string[],
options?: GetButtonConfigsOptions,
): DesignCnstnViewButtonAction[] {
if (!dtlInstCd) return [];
if (needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd)) {
return resolveButtonConfigs(
resolveRoleUser01AtrzButtonActions(prgrsSttsComCd, atrzSeComCdList ?? [], options),
);
}
const statusMap = ROLE_STATUS_BUTTON_ACTIONS[dtlInstCd];
if (!statusMap) return [];
const actions = statusMap[prgrsSttsComCd];
if (!actions) return [];
return resolveButtonConfigs(actions);
}
@@ -0,0 +1,68 @@
import { z } from "zod";
export const designCnstnRegistSchema = z.object({
designCnstnMng: z.object({
bizNm: z.string().min(1, "사업명은 필수입니다"),
bizBgngYr: z.string().min(1, "사업시작연도는 필수입니다"),
bizEndYr: z.string().min(1, "사업종료연도는 필수입니다"),
bizCn: z.string().min(1, "사업내용은 필수입니다"),
taskSeComCd: z.array(z.string()).min(1, "구분은 필수입니다"),
dsgnrNm: z.string().min(1, "설계자 이름은 필수입니다"),
dsgnrTelno: z
.string()
.min(1, "설계자 전화번호는 필수입니다")
.regex(/^[0-9-]+$/, "숫자와 하이픈(-)만 입력 가능합니다"),
dsgnrOgdpNm: z.string().min(1, "설계자 소속은 필수입니다"),
dsgnrEmlAddr: z
.string()
.min(1, "설계자 이메일은 필수입니다")
.email("올바른 이메일 형식이 아닙니다"),
aplcntNm: z.string().optional(),
aplcntMblTelno: z.string().optional(),
aplcntOgdpNm: z.string().optional(),
aplcntEmlAddr: z.string().optional(),
designCnstnDocNo: z.string().optional(),
admdstCd: z.string().optional(),
envoSeComCd: z.string().optional(),
prgrsSttsComCd: z.string().optional(),
atchFileGroupId: z.string().optional(),
envoSeCdNm: z.string().optional(),
taskSeCdNm: z.string().optional(),
prgrsSttsCdNm: z.string().optional(),
sido: z.string().optional(),
gugun: z.string().optional(),
regDt: z.string().optional(),
rgtr: z.string().optional(),
rgtrNm: z.string().optional(),
mdfcnDt: z.string().optional(),
}),
bzScaleList: z.array(
z.object({
cnstpSeCd: z.string(),
bscPl: z.number(),
budgetReflct: z.number(),
thtiDsgn: z.number(),
diff: z.number().optional(),
}),
),
esntlAtchFileList: z.array(
z.object({
esntlAtchFileId: z.string(),
atchFileGroupId: z.string(),
}),
),
privacyConsent: z.enum(["Y", "N"], {
required_error: "개인정보 수집/이용 동의는 필수입니다",
}),
});
export type DesignCnstnRegistFormData = z.infer<typeof designCnstnRegistSchema>;
export function formatTaskSeComCdForApi(taskSeComCd: string[]): string {
return taskSeComCd.join(",");
}
export function parseTaskSeComCdFromApi(taskSeComCd?: string): string[] {
if (!taskSeComCd) return [];
return taskSeComCd.split(",").filter(Boolean);
}
@@ -0,0 +1,97 @@
import type { IUserInfo } from "@/shared/store";
import type { DesignCnstnMngDetResponseDto } from "@/entities/design-cnstn";
import type { DesignCnstnRegistFormData } from "./design-cnstn-regist-schema";
import { parseTaskSeComCdFromApi } from "./design-cnstn-regist-schema";
function mapEsntlAtchFileList(esntlFileList: DesignCnstnMngDetResponseDto["esntlFileList"]) {
const esntlMap = new Map<string, string>();
for (const item of esntlFileList ?? []) {
if (!item.esntlAtchFileId || !item.atflCd) continue;
esntlMap.set(item.esntlAtchFileId, item.atflCd);
}
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
esntlAtchFileId,
atchFileGroupId,
}));
}
export function mapDetailToFormData(data: DesignCnstnMngDetResponseDto): DesignCnstnRegistFormData {
const detDto = data.detDto;
if (!detDto) {
throw new Error("상세 정보(detDto)가 없습니다.");
}
const bizSclList = data.bizSclList ?? [];
return {
designCnstnMng: {
designCnstnDocNo: detDto.designCnstnDocNo,
bizNm: detDto.bizNm ?? "",
bizBgngYr: detDto.bizBgngYr ?? "",
bizEndYr: detDto.bizEndYr ?? "",
bizCn: detDto.bizCn ?? "",
taskSeComCd: parseTaskSeComCdFromApi(detDto.taskSeComCd),
dsgnrNm: detDto.dsgnrNm ?? "",
dsgnrTelno: detDto.dsgnrTelno ?? "",
dsgnrOgdpNm: detDto.dsgnrOgdpNm ?? "",
dsgnrEmlAddr: detDto.dsgnrEmlAddr ?? "",
aplcntNm: detDto.aplcntNm ?? "",
aplcntMblTelno: detDto.aplcntMblTelno ?? "",
aplcntOgdpNm: detDto.aplcntOgdpNm ?? "",
aplcntEmlAddr: detDto.aplcntEmlAddr ?? "",
admdstCd: detDto.admdstCd,
envoSeComCd: detDto.envoSeComCd,
prgrsSttsComCd: detDto.prgrsSttsComCd,
atchFileGroupId: detDto.atchFileGroupId ?? "",
envoSeCdNm: detDto.envoSeCdNm,
taskSeCdNm: detDto.taskSeCdNm,
prgrsSttsCdNm: detDto.prgrsSttsCdNm,
sido: detDto.sido,
gugun: detDto.gugun,
regDt: detDto.regDt,
rgtr: detDto.rgtr,
rgtrNm: detDto.rgtrNm,
mdfcnDt: detDto.mdfcnDt,
},
bzScaleList: bizSclList.map((item) => ({
cnstpSeCd: item.cnstpSeCd,
bscPl: item.bscPlanQty,
budgetReflct: item.bgtRfltQty,
thtiDsgn: item.tstmDesignQty,
diff: item.tstmDesignQty - item.bgtRfltQty,
})),
esntlAtchFileList: mapEsntlAtchFileList(data.esntlFileList),
privacyConsent: "Y",
};
}
export function getCreateDefaultValues(
userInfo: IUserInfo | null,
taskSeComCd?: string[],
): DesignCnstnRegistFormData {
return {
designCnstnMng: {
bizNm: "",
bizBgngYr: "",
bizEndYr: "",
bizCn: "",
taskSeComCd: taskSeComCd ?? [],
dsgnrNm: "",
dsgnrTelno: "",
dsgnrOgdpNm: "",
dsgnrEmlAddr: "",
aplcntNm: userInfo?.userName ?? "",
aplcntMblTelno: userInfo?.mphn ?? "",
aplcntOgdpNm: userInfo?.ogdpSeNm ?? "",
aplcntEmlAddr: userInfo?.email ?? "",
prgrsSttsComCd: "CV",
atchFileGroupId: "",
},
bzScaleList: [],
esntlAtchFileList: [],
privacyConsent: "N",
};
}
@@ -0,0 +1,94 @@
import type {
DesignCnstnMngDetResponseDto,
DesignCnstnRvwOpnnResponseDto,
DesignCnstnRvwOpnnSaveRequestDto,
} from "@/entities/design-cnstn";
export interface ReviewOpinionFormData {
rvwRsltCn: string;
rvwRsltAnmlyYn: "Y" | "N" | "";
atchFileGroupId: string;
opnrSeComCd: string;
rvwOpnnSn?: number;
}
export interface ReviewOpinionGetFormDataResult {
formData: ReviewOpinionFormData;
rvwList: DesignCnstnRvwOpnnResponseDto[];
}
function hasReviewOpinionContent(formData: ReviewOpinionFormData) {
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
}
function mapServerItemToSaveDto(
item: DesignCnstnRvwOpnnResponseDto,
designCnstnDocNo: string,
): DesignCnstnRvwOpnnSaveRequestDto | null {
if (item.rvwOpnnSn == null) return null;
return {
designCnstnDocNo,
rvwOpnnSn: item.rvwOpnnSn,
opnrSeComCd: item.opnrSeComCd,
opnrNm: item.opnrNm,
rvwRsltCn: item.rvwRsltCn,
rvwRsltAnmlyYn: item.rvwRsltAnmlyYn || undefined,
atchFileGroupId: item.atchFileGroupId || undefined,
};
}
function mapFormDataToSaveDto(
formData: ReviewOpinionFormData,
designCnstnDocNo: string,
opnrNm?: string,
): DesignCnstnRvwOpnnSaveRequestDto {
const base = {
designCnstnDocNo,
opnrSeComCd: formData.opnrSeComCd,
opnrNm: opnrNm ?? "",
rvwRsltCn: formData.rvwRsltCn,
rvwRsltAnmlyYn: formData.rvwRsltAnmlyYn || undefined,
atchFileGroupId: formData.atchFileGroupId || undefined,
};
if (formData.rvwOpnnSn != null) {
return { ...base, rvwOpnnSn: formData.rvwOpnnSn };
}
return base as DesignCnstnRvwOpnnSaveRequestDto;
}
export function toRvwOpnnSaveDto(
data: DesignCnstnMngDetResponseDto,
result: ReviewOpinionGetFormDataResult | null | undefined,
opnrNm?: string,
): DesignCnstnRvwOpnnSaveRequestDto[] | undefined {
if (!result || !data.detDto) return undefined;
const { formData, rvwList } = result;
const designCnstnDocNo = data.detDto.designCnstnDocNo;
const list: DesignCnstnRvwOpnnSaveRequestDto[] = rvwList
.map((item) => mapServerItemToSaveDto(item, designCnstnDocNo))
.filter((item): item is DesignCnstnRvwOpnnSaveRequestDto => item != null);
if (!hasReviewOpinionContent(formData)) {
return list.length > 0 ? list : undefined;
}
const formDto = mapFormDataToSaveDto(formData, designCnstnDocNo, opnrNm);
if (formData.rvwOpnnSn != null) {
const existingIndex = list.findIndex((item) => item.rvwOpnnSn === formData.rvwOpnnSn);
if (existingIndex >= 0) {
list[existingIndex] = formDto;
} else {
list.push(formDto);
}
} else {
list.push(formDto);
}
return list.length > 0 ? list : undefined;
}
@@ -0,0 +1,12 @@
import type { ReviewStepCode } from "../model/view-types";
export function getEditableReviewStep(
dtlInstCd?: string,
prgrsSttsComCd?: string,
): ReviewStepCode | undefined {
if (dtlInstCd === "ROLE_USER01" && prgrsSttsComCd === "CS") {
return "KECO";
}
return undefined;
}
@@ -0,0 +1,71 @@
import type {
BizSclTreeDto,
BzScaleItem,
DesignCnstnApplySaveRequestDto,
DesignCnstnMngDetResponseDto,
EsntlAtchFileItem,
EsntlAtchFileMngTreeResponseDto,
} from "@/entities/design-cnstn";
import type { ReviewStepCode } from "../model/view-types";
import { type ReviewOpinionGetFormDataResult, toRvwOpnnSaveDto } from "./review-opinion-utils";
export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): BzScaleItem[] {
return bizSclList
.filter((node) => node.useYn === "Y")
.map((node) => ({
cnstpSeCd: node.cnstpSeCd,
bscPl: node.bscPlanQty,
budgetReflct: node.bgtRfltQty,
thtiDsgn: node.tstmDesignQty,
}));
}
export function toEsntlAtchFileSaveDto(
esntlFileList: EsntlAtchFileMngTreeResponseDto[],
): EsntlAtchFileItem[] {
const esntlMap = new Map<string, string>();
for (const item of esntlFileList) {
if (!item.esntlAtchFileId || !item.atflCd) continue;
esntlMap.set(item.esntlAtchFileId, item.atflCd);
}
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
esntlAtchFileId,
atchFileGroupId,
}));
}
export function buildSavePayload(
data: DesignCnstnMngDetResponseDto,
prgrsSttsComCd: string,
): DesignCnstnApplySaveRequestDto {
const detDto = data.detDto;
if (!detDto) {
throw new Error("상세 정보(detDto)가 없습니다.");
}
return {
designCnstnMng: { ...detDto, prgrsSttsComCd },
bzScaleList: toBzScaleSaveDto(data.bizSclList ?? []),
esntlAtchFileList: toEsntlAtchFileSaveDto(data.esntlFileList ?? []),
};
}
export function buildTechReviewPayload(
data: DesignCnstnMngDetResponseDto,
prgrsSttsComCd: string,
techReViewSaveCd: string,
reviewStep: ReviewStepCode,
reviewOpinionFormData?: ReviewOpinionGetFormDataResult | null,
opnrNm?: string,
): DesignCnstnApplySaveRequestDto {
return {
...buildSavePayload(data, prgrsSttsComCd),
techReViewSaveCd,
rvwStpCd: reviewStep,
rvwOpnnList: toRvwOpnnSaveDto(data, reviewOpinionFormData, opnrNm),
};
}
@@ -0,0 +1,161 @@
"use client";
import { Fragment } from "react";
import { createColumnHelper } from "@tanstack/react-table";
import { dayjs } from "@/shared/lib";
import type { DesignCnstnMngResponseDto } from "@/entities/design-cnstn";
import { Badge } from "@common/components";
const columnHelper = createColumnHelper<DesignCnstnMngResponseDto>();
const MAIN_STEP_LABELS = {
save: "저장",
apply: "신청완료",
review: "설계자문 검토",
reply: "설계검토 회신",
} as const;
const REVIEW_STATUS_CODES = new Set(["CS", "CR"]);
type BadgeState = "completed" | "current" | "pending" | "action";
interface ProgressBadge {
label: string;
state: BadgeState;
}
function buildProgressBadges(prgrsSttsComCd: string, prgrsSttsCdNm: string): ProgressBadge[] {
const reviewLabel = prgrsSttsCdNm || MAIN_STEP_LABELS.review;
const replyLabel = prgrsSttsCdNm || MAIN_STEP_LABELS.reply;
if (prgrsSttsComCd === "CV") {
return [
{ label: MAIN_STEP_LABELS.save, state: "current" },
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.review, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "CT") {
return [
{ label: MAIN_STEP_LABELS.save, state: "completed" },
{ label: MAIN_STEP_LABELS.apply, state: "current" },
{ label: MAIN_STEP_LABELS.review, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (REVIEW_STATUS_CODES.has(prgrsSttsComCd)) {
return [
{ label: MAIN_STEP_LABELS.save, state: "completed" },
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: reviewLabel, state: "action" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "CP" || prgrsSttsComCd === "EP") {
return [
{ label: MAIN_STEP_LABELS.save, state: "completed" },
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.review, state: "completed" },
{ label: replyLabel, state: "action" },
];
}
return [{ label: prgrsSttsCdNm || prgrsSttsComCd || "-", state: "current" }];
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
return "green";
case "current":
return "blue";
case "pending":
return "lightGray";
case "action":
return "orange";
}
}
function formatOptional(value: string | undefined) {
return value?.trim() ? value : "-";
}
interface ProgressStepBadgeProps {
prgrsSttsComCd: string;
prgrsSttsCdNm: string;
}
function ProgressStepBadge({ prgrsSttsComCd, prgrsSttsCdNm }: ProgressStepBadgeProps) {
const badges = buildProgressBadges(prgrsSttsComCd, prgrsSttsCdNm);
return (
<div className="flex flex-wrap items-center justify-start">
{badges.map((badge, index) => (
<Fragment key={`${badge.label}-${index}`}>
{index > 0 && <span className="mx-2">&gt;</span>}
<Badge color={getBadgeColor(badge.state)}>{badge.label}</Badge>
</Fragment>
))}
</div>
);
}
export function createDesignCnstnColumns() {
return [
columnHelper.accessor("sido", {
header: "시도",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("gugun", {
header: "시군구",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("bizNm", {
header: "사업명",
meta: { align: "left" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("taskSeCdNm", {
header: "업무구분",
meta: { align: "center" },
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
}),
columnHelper.accessor("aplcntNm", {
header: "담당자",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("envoSeCdNm", {
header: "해당기관",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.display({
id: "progress",
header: "진행상황",
meta: { align: "center" },
cell: ({ row }) => (
<ProgressStepBadge
prgrsSttsComCd={row.original.prgrsSttsComCd || ""}
prgrsSttsCdNm={row.original.prgrsSttsCdNm || ""}
/>
),
}),
columnHelper.accessor("regDt", {
header: "신청일",
meta: { align: "center" },
cell: ({ getValue }) => {
const value = getValue();
return value ? dayjs(value).format("YYYY-MM-DD") : "-";
},
}),
];
}
@@ -0,0 +1,15 @@
export type ReviewStepCode = "LOCAL" | "ENV" | "KECO";
export const REVIEW_STEP_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "지자체",
ENV: "환경청",
KECO: "환경공단",
};
export const DESIGN_PROGRESS_ORDER = ["CV", "CT", "CS", "CR", "CP", "EP"] as const;
export const ROLE_TO_REVIEW_STEP: Record<string, ReviewStepCode> = {
ROLE_USER05: "LOCAL",
ROLE_USER03: "ENV",
ROLE_USER01: "KECO",
};
@@ -0,0 +1,391 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useQueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useUserStore } from "@/shared/lib";
import {
type DesignCnstnApplySaveRequestDto,
designCnstnMngBaseKey,
type DesignCnstnMngDetResponseDto,
designCnstnMngService,
} from "@/entities/design-cnstn";
import {
buildElaprPrgrsSttsListFromAplnRows,
ElaprApprovalDialog,
type ElaprDmndSaveRequestDto,
elaprDmndService,
type ElaprTmpltPickerConfirmPayload,
ElaprTmpltPickerDialog,
hasCurrentUserPendingApprovalRequest,
useElaprDocPrgrsByDocNo,
} from "@/entities/elapr";
import { Button } from "@common/components";
import {
DESIGN_CNSTN_VIEW_BUTTON_ACTION,
type DesignCnstnViewButtonAction,
getButtonConfigs,
needsRoleUser01AtrzResolve,
} from "../../lib/button-config";
import { buildSavePayload, buildTechReviewPayload } from "../../lib/save-payload-utils";
import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section";
interface FloatingActionButtonsProps {
data: DesignCnstnMngDetResponseDto;
docSeComCd: string;
designCnstnDocNo: string;
prgrsSttsComCd: string;
dtlInstCd: string | undefined;
atrzSeComCdList: string[];
isRvwLoginUserLoading: boolean;
techReviewOpinionRef?: React.RefObject<TechReviewOpinionSectionHandle | null>;
opnrNm?: string;
}
type ActiveDialog = "elaprTmplt" | "elaprApproval" | null;
function FloatingActionButtons({
data,
docSeComCd,
designCnstnDocNo,
prgrsSttsComCd,
dtlInstCd,
atrzSeComCdList,
isRvwLoginUserLoading,
techReviewOpinionRef,
opnrNm,
}: FloatingActionButtonsProps) {
const router = useRouter();
const { confirm } = useConfirm();
const queryClient = useQueryClient();
const userInfo = useUserStore((state) => state.userInfo);
const alert = useAlertStore((state) => state.setMessage);
const [isProcessing, setIsProcessing] = useState(false);
const [activeDialog, setActiveDialog] = useState<ActiveDialog>(null);
const docNo = data.detDto?.designCnstnDocNo ?? designCnstnDocNo;
const isRegister = data.detDto?.rgtr === userInfo?.userUnqId;
const needsAtrzResolve = needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd);
const needsCsApprovalCheck = needsAtrzResolve && prgrsSttsComCd === "CS";
const { data: elaprDocPrgrsList = [], isLoading: isElaprDocPrgrsLoading } =
useElaprDocPrgrsByDocNo(needsCsApprovalCheck ? docNo : null);
const hasPendingApprovalRequest = hasCurrentUserPendingApprovalRequest(
elaprDocPrgrsList,
userInfo?.userUnqId,
);
const actionButtons = useMemo(() => {
if (needsAtrzResolve && isRvwLoginUserLoading) return [];
if (needsCsApprovalCheck && isElaprDocPrgrsLoading) return [];
return getButtonConfigs(dtlInstCd, prgrsSttsComCd, atrzSeComCdList, {
hasPendingApprovalRequest: needsCsApprovalCheck ? hasPendingApprovalRequest : undefined,
});
}, [
atrzSeComCdList,
dtlInstCd,
hasPendingApprovalRequest,
isElaprDocPrgrsLoading,
isRvwLoginUserLoading,
needsAtrzResolve,
needsCsApprovalCheck,
prgrsSttsComCd,
]);
const canEdit =
isRegister && actionButtons.some((button) => button === DESIGN_CNSTN_VIEW_BUTTON_ACTION.strg);
function handleGoToList() {
router.push("/design-cnstn");
}
function handleEdit() {
router.push(`/design-cnstn/${designCnstnDocNo}/edit`);
}
function shouldShowButton(action: DesignCnstnViewButtonAction) {
return actionButtons.some((button) => button === action);
}
async function executeAction({
action,
request,
successMessage,
}: {
action: DesignCnstnViewButtonAction;
request?: unknown;
successMessage?: string;
}) {
const handler = buttonActionHandlers[action];
if (!handler) return;
setIsProcessing(true);
try {
await handler({
request,
data,
onSuccess: () => {
if (successMessage) alert(successMessage);
queryClient.invalidateQueries({
queryKey: [...designCnstnMngBaseKey, "findById", designCnstnDocNo],
});
},
onError: (error) => {
let errorMessage = "";
if (error instanceof AxiosError) {
errorMessage = error.response?.data?.message ?? "";
} else {
errorMessage = error.message ?? "처리 중 오류가 발생했습니다.";
}
alert(errorMessage);
},
});
} finally {
setIsProcessing(false);
}
}
async function handleStrgClick() {
const payload: DesignCnstnApplySaveRequestDto = buildSavePayload(data, "CV");
await executeAction({
action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.strg,
request: payload,
successMessage: "저장되었습니다.",
});
}
async function handleAplyClick() {
const payload: DesignCnstnApplySaveRequestDto = buildSavePayload(data, "CT");
await executeAction({
action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.aply,
request: payload,
successMessage: "신청 완료되었습니다.",
});
queryClient.invalidateQueries({ queryKey: designCnstnMngBaseKey });
}
async function handleDelClick() {
if (!(await confirm("삭제하시겠습니까?"))) return;
await executeAction({
action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.del,
successMessage: "삭제되었습니다.",
});
router.push("/design-cnstn");
}
function getReviewOpinionFormData() {
return techReviewOpinionRef?.current?.getFormData() ?? null;
}
async function handleElaprTmpltConfirm({ aplnRows }: ElaprTmpltPickerConfirmPayload) {
const payload: ElaprDmndSaveRequestDto = {
elaprDocNo: docNo,
dmndTtl: data.detDto?.bizNm ?? "",
dmndCn: data.detDto?.bizCn ?? "",
elaprPrgrsSttsList: buildElaprPrgrsSttsListFromAplnRows(aplnRows),
};
await executeAction({
action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.rqstRvw,
request: payload,
successMessage: "검토자지정이 완료되었습니다.",
});
await queryClient.invalidateQueries({
queryKey: [ENDPOINT.AS_ELAPR_SERVICE.ELAPR_DMND, "findDocPrgrsByDocNo", docNo],
});
}
async function handleRvwStrgClick() {
const payload = buildTechReviewPayload(
data,
"CS",
"SV",
"KECO",
getReviewOpinionFormData(),
opnrNm,
);
await executeAction({
action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.rvwStrg,
request: payload,
successMessage: "저장되었습니다.",
});
techReviewOpinionRef?.current?.resetForm();
}
async function handleRvwCmptnClick() {
const payload = buildTechReviewPayload(
data,
"CS",
"CP",
"KECO",
getReviewOpinionFormData(),
opnrNm,
);
await executeAction({
action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.rvwCmptn,
request: payload,
});
techReviewOpinionRef?.current?.resetForm();
setActiveDialog("elaprApproval");
}
return (
<>
<div className="sticky bottom-0 z-40 !-mb-2 border-t bg-white pb-8 pt-6 shadow-[0_-4px_12px_rgba(0,0,0,0.06)]">
<div className="mx-4 flex flex-wrap justify-center gap-3 sm:gap-4">
<Button color="white" type="button" onClick={handleGoToList} disabled={isProcessing}>
목록
</Button>
{canEdit && (
<Button color="gray" onClick={handleEdit} disabled={isProcessing}>
수정
</Button>
)}
{shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.strg) && (
<Button onClick={handleStrgClick} disabled={isProcessing}>
저장
</Button>
)}
{shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.aply) && (
<Button color="green" onClick={handleAplyClick} disabled={isProcessing}>
신청완료
</Button>
)}
{shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.del) && (
<Button color="red" onClick={handleDelClick} disabled={isProcessing}>
삭제
</Button>
)}
{shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.rqstRvw) && (
<Button onClick={() => setActiveDialog("elaprTmplt")} disabled={isProcessing}>
검토자지정
</Button>
)}
{shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.rvwStrg) && (
<Button onClick={handleRvwStrgClick} disabled={isProcessing}>
저장
</Button>
)}
{shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.rvwCmptn) && (
<Button color="green" onClick={handleRvwCmptnClick} disabled={isProcessing}>
검토완료
</Button>
)}
{shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.atrzAprv) && (
<Button onClick={() => setActiveDialog("elaprApproval")} disabled={isProcessing}>
결재(승인)
</Button>
)}
{shouldShowButton(DESIGN_CNSTN_VIEW_BUTTON_ACTION.atrzRply) && (
<Button onClick={() => setActiveDialog("elaprApproval")} disabled={isProcessing}>
결재(회신)
</Button>
)}
</div>
</div>
<ElaprTmpltPickerDialog
open={activeDialog === "elaprTmplt"}
onClose={() => setActiveDialog(null)}
docSeComCd={docSeComCd}
onConfirm={handleElaprTmpltConfirm}
/>
<ElaprApprovalDialog
open={activeDialog === "elaprApproval"}
onOpenChange={(open) => setActiveDialog(open ? "elaprApproval" : null)}
elaprDocNo={data.detDto?.designCnstnDocNo ?? designCnstnDocNo}
currentUserUuid={userInfo?.userUnqId ?? ""}
onSuccess={() => {
queryClient.invalidateQueries({
queryKey: [...designCnstnMngBaseKey, "findById", designCnstnDocNo],
});
}}
/>
</>
);
}
export { FloatingActionButtons };
async function handleSaveApply(
context: ActionHandlerContext<DesignCnstnApplySaveRequestDto>,
): Promise<void> {
try {
await designCnstnMngService.saveApply(context.request);
context.onSuccess?.();
} catch (error) {
context.onError?.(error as Error);
}
}
async function handleDel(context: ActionHandlerContext): Promise<void> {
try {
await designCnstnMngService.delete(context.data.detDto?.designCnstnDocNo ?? "");
context.onSuccess?.();
} catch (error) {
context.onError?.(error as Error);
}
}
async function handleTechReview(
context: ActionHandlerContext<DesignCnstnApplySaveRequestDto>,
): Promise<void> {
try {
await designCnstnMngService.techReview(
context.data.detDto?.designCnstnDocNo ?? "",
context.request,
);
context.onSuccess?.();
} catch (error) {
context.onError?.(error as Error);
}
}
async function handleRqstRvw(
context: ActionHandlerContext<ElaprDmndSaveRequestDto>,
): Promise<void> {
try {
await elaprDmndService.save(context.request);
context.onSuccess?.();
} catch (error) {
context.onError?.(error as Error);
}
}
async function handleAtrzAprv(context: ActionHandlerContext): Promise<void> {
context.onSuccess?.();
}
async function handleAtrzRply(context: ActionHandlerContext): Promise<void> {
context.onSuccess?.();
}
const buttonActionHandlers: Record<DesignCnstnViewButtonAction, ActionHandler> = {
strg: handleSaveApply,
aply: handleSaveApply,
del: handleDel,
rqstRvw: handleRqstRvw,
rvwStrg: handleTechReview,
rvwCmptn: handleTechReview,
atrzAprv: handleAtrzAprv,
atrzRply: handleAtrzRply,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
interface ActionHandlerContext<T = any> {
request: T;
data: DesignCnstnMngDetResponseDto;
onSuccess?: () => void;
onError?: (error: Error) => void;
}
type ActionHandler = (context: ActionHandlerContext) => Promise<void>;
@@ -0,0 +1,41 @@
"use client";
import { useDesignCnstnMng } from "@/entities/design-cnstn";
import { Skeleton } from "@common/components";
import { DesignFormPage } from "./design-form-page";
interface DesignEditPageProps {
id: string;
}
function DesignEditPage({ id }: DesignEditPageProps) {
const { data, isLoading } = useDesignCnstnMng(id);
if (isLoading) {
return (
<div className="mt-6 space-y-6 px-4">
<div className="space-y-2">
<Skeleton className="h-[4rem] w-full" />
<Skeleton className="h-[20rem] w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-[4rem] w-full" />
<Skeleton className="h-[20rem] w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-[4rem] w-full" />
<Skeleton className="h-[20rem] w-full" />
</div>
</div>
);
}
if (!data?.detDto) {
return <div className="mt-6 text-[1.4rem]">상세 정보를 찾을 수 없습니다.</div>;
}
return <DesignFormPage id={id} mode="edit" initialData={data} />;
}
export { DesignEditPage };
@@ -0,0 +1,209 @@
"use client";
import { useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { useRouter } from "next/navigation";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQueryClient } from "@tanstack/react-query";
import { useAlertStore, useUserStore } from "@/shared/lib";
import {
type DesignCnstnApplySaveRequestDto,
designCnstnMngBaseKey,
type DesignCnstnMngDetResponseDto,
designCnstnMngService,
} from "@/entities/design-cnstn";
import {
AttachmentsFormSection,
BusinessScaleFormSection,
ContactInfoFormSection,
PrivacyConsentSection,
} from "@/widgets/pbadms-sprt";
import { Button } from "@common/components";
import {
type DesignCnstnRegistFormData,
designCnstnRegistSchema,
formatTaskSeComCdForApi,
} from "../lib/design-cnstn-regist-schema";
import { getCreateDefaultValues, mapDetailToFormData } from "../lib/form-data-mapper";
import { BasicInfoFormSection } from "./sections/basic-info-form-section";
interface DesignFormPageProps {
id?: string;
mode: "create" | "edit";
initialData?: DesignCnstnMngDetResponseDto;
taskSeComCd?: string[];
}
function DesignFormPage({ id, mode, initialData, taskSeComCd }: DesignFormPageProps) {
const queryClient = useQueryClient();
const router = useRouter();
const { confirm } = useConfirm();
const userInfo = useUserStore((state) => state.userInfo);
const alert = useAlertStore((state) => state.setMessage);
const [isSubmitting, setIsSubmitting] = useState(false);
const isEditMode = mode === "edit";
const form = useForm<DesignCnstnRegistFormData>({
resolver: zodResolver(designCnstnRegistSchema),
mode: "onChange",
defaultValues:
isEditMode && initialData
? mapDetailToFormData(initialData)
: getCreateDefaultValues(userInfo, taskSeComCd),
});
function buildFormPayload(
data: DesignCnstnRegistFormData,
prgrsSttsComCd: "CV" | "CT",
): DesignCnstnApplySaveRequestDto {
const { taskSeComCd: formTaskSeComCd, ...designCnstnMngRest } = data.designCnstnMng;
return {
designCnstnMng: {
...designCnstnMngRest,
taskSeComCd: formatTaskSeComCdForApi(formTaskSeComCd),
designCnstnDocNo: isEditMode ? initialData?.detDto?.designCnstnDocNo : undefined,
prgrsSttsComCd,
},
bzScaleList: data.bzScaleList,
esntlAtchFileList: data.esntlAtchFileList,
};
}
async function submitForm(
data: DesignCnstnRegistFormData,
prgrsSttsComCd: "CV" | "CT",
): Promise<string | undefined> {
return designCnstnMngService.saveApply(buildFormPayload(data, prgrsSttsComCd));
}
async function onSave(data: DesignCnstnRegistFormData) {
if (isSubmitting) return;
try {
setIsSubmitting(true);
const designCnstnDocNo = await submitForm(data, "CV");
if (!designCnstnDocNo) return;
alert("저장되었습니다.");
router.push(`/design-cnstn/${designCnstnDocNo}/edit`);
if (isEditMode && id) {
queryClient.invalidateQueries({
queryKey: [...designCnstnMngBaseKey, "findById", id],
});
}
} catch (error) {
console.error("저장 실패:", error);
alert("저장 중 오류가 발생했습니다.");
} finally {
setIsSubmitting(false);
}
}
async function onApply(data: DesignCnstnRegistFormData) {
if (isSubmitting) return;
try {
setIsSubmitting(true);
const designCnstnDocNo = await submitForm(data, "CT");
if (!designCnstnDocNo) return;
alert("신청 완료되었습니다.");
router.push(`/design-cnstn/${designCnstnDocNo}`);
queryClient.invalidateQueries({ queryKey: designCnstnMngBaseKey });
if (id) {
queryClient.invalidateQueries({
queryKey: [...designCnstnMngBaseKey, "findById", id],
});
}
} catch (error) {
console.error("신청 실패:", error);
alert("신청 중 오류가 발생했습니다.");
} finally {
setIsSubmitting(false);
}
}
function handleApplyClick() {
form.handleSubmit(onApply)();
}
async function handleDelClick() {
if (isSubmitting) return;
const docNo = initialData?.detDto?.designCnstnDocNo;
if (!docNo) {
alert("문서번호가 없습니다.");
return;
}
if (!(await confirm("삭제하시겠습니까?"))) return;
try {
setIsSubmitting(true);
await designCnstnMngService.delete(docNo);
alert("삭제되었습니다.");
router.push("/design-cnstn");
} catch (error) {
console.error("삭제 실패:", error);
alert("삭제 중 오류가 발생했습니다.");
} finally {
setIsSubmitting(false);
}
}
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSave)} className="mt-6 space-y-4">
<BasicInfoFormSection />
<ContactInfoFormSection fieldPrefix="designCnstnMng" />
<PrivacyConsentSection />
<BusinessScaleFormSection />
<AttachmentsFormSection
docSeComCd="CNFM"
fieldPrefix="designCnstnMng"
initialEsntlFiles={isEditMode ? initialData?.esntlFileList : undefined}
initialEtcFiles={isEditMode ? initialData?.etcFileList : undefined}
/>
<div className="sticky bottom-0 z-40 !-mb-2 border-t bg-white pb-8 pt-6 shadow-[0_-4px_12px_rgba(0,0,0,0.06)]">
<div className="mx-4 flex flex-wrap justify-center gap-3 sm:gap-4">
<Button
color="white"
type="button"
onClick={() => router.push("/design-cnstn")}
disabled={isSubmitting}
>
목록
</Button>
<Button type="submit" disabled={isSubmitting}>
저장
</Button>
{isEditMode && (
<>
<Button
color="green"
type="button"
onClick={handleApplyClick}
disabled={isSubmitting}
>
신청완료
</Button>
<Button color="red" type="button" onClick={handleDelClick} disabled={isSubmitting}>
삭제
</Button>
</>
)}
</div>
</div>
</form>
</FormProvider>
);
}
export { DesignFormPage };
@@ -0,0 +1,152 @@
"use client";
import { useState } from "react";
import type { PaginationState } from "@tanstack/react-table";
import {
type CmCdAdstDstrComboResponseDto,
useFindGugunCombo,
useFindSidoCombo,
} from "@/entities/adstdstr";
import { CodeSelect } from "@/entities/code";
import type { DesignCnstnMngSearchDto } from "@/entities/design-cnstn";
import {
Button,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import { DesignList } from "./design-list";
const DEFAULT_PAGE_INDEX = 0;
const DEFAULT_PAGE_SIZE = 10;
const DEFAULT_SORT = "designCnstnDocNo,desc";
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
const DEFAULT_SEARCH_PARAMS: DesignCnstnMngSearchDto = {
bizNm: "",
taskSeComCd: "",
admdstCd: "",
page: DEFAULT_PAGE_INDEX,
size: DEFAULT_PAGE_SIZE,
sort: DEFAULT_SORT,
};
const ALL_SELECT_VALUE = "__ALL__";
function DesignListPage() {
const [bizNm, setBizNm] = useState("");
const [taskSeComCd, setTaskSeComCd] = useState("");
const [sidoComCd, setSidoComCd] = useState("");
const [sggComCd, setSggComCd] = useState("");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: DEFAULT_PAGE_INDEX,
pageSize: DEFAULT_PAGE_SIZE,
});
const [searchParams, setSearchParams] = useState<DesignCnstnMngSearchDto>(DEFAULT_SEARCH_PARAMS);
const { data: sidoData } = useFindSidoCombo();
const { data: gugunData } = useFindGugunCombo(sidoComCd);
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
const handleSearch = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setPagination((prev) => ({ ...prev, pageIndex: DEFAULT_PAGE_INDEX }));
setSearchParams({
...DEFAULT_SEARCH_PARAMS,
bizNm,
taskSeComCd,
admdstCd: sggComCd || sidoComCd || "",
page: DEFAULT_PAGE_INDEX,
size: pagination.pageSize,
});
};
return (
<div className="space-y-4 px-[1.8rem] pb-6">
<section className="card card-border mx-0 bg-background">
<form className="flex flex-wrap items-center gap-4 p-2" onSubmit={handleSearch}>
<div className="flex items-center gap-2">
<Label htmlFor="sidoComCd" className="whitespace-nowrap">
시도
</Label>
<Select
value={sidoComCd}
onValueChange={(value) => {
setSidoComCd(value === ALL_SELECT_VALUE ? "" : value);
setSggComCd("");
}}
>
<SelectTrigger id="sidoComCd" className="w-[12rem]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
{sidoOptions.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="sggComCd" className="whitespace-nowrap">
시군구
</Label>
<Select
value={sggComCd}
onValueChange={(value) => setSggComCd(value === ALL_SELECT_VALUE ? "" : value)}
disabled={!sidoComCd}
>
<SelectTrigger id="sggComCd" className="w-[12rem]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
{gugunOptions.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="taskSeComCd" className="whitespace-nowrap">
구분
</Label>
<CodeSelect upCd="CNFM" value={taskSeComCd} onValueChange={setTaskSeComCd} />
</div>
<div className="flex w-[24rem] items-center gap-2">
<Label htmlFor="bizNm" className="whitespace-nowrap">
사업명
</Label>
<Input
id="bizNm"
value={bizNm}
onChange={(event) => setBizNm(event.target.value)}
placeholder="사업명"
/>
</div>
<Button type="submit">조회</Button>
</form>
</section>
<DesignList
searchParams={searchParams}
pagination={pagination}
onPaginationChange={setPagination}
/>
</div>
);
}
export { DesignListPage };
@@ -0,0 +1,101 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { useDataTable, useUserStore } from "@/shared/lib";
import {
type DesignCnstnMngResponseDto,
type DesignCnstnMngSearchDto,
useDesignCnstnMngPage,
} from "@/entities/design-cnstn";
import { TaskSePickerDialog } from "@/widgets/pbadms-sprt";
import { Button } from "@common/components";
import { formatTaskSeComCdForApi } from "../lib/design-cnstn-regist-schema";
import { createDesignCnstnColumns } from "../model/columns";
interface DesignListProps {
searchParams: DesignCnstnMngSearchDto;
pagination: PaginationState;
onPaginationChange: (pagination: PaginationState) => void;
}
function DesignList({ searchParams, pagination, onPaginationChange }: DesignListProps) {
const userInfo = useUserStore((state) => state.userInfo);
const router = useRouter();
const [isTaskSeDialogOpen, setIsTaskSeDialogOpen] = useState(false);
const queryParams = useMemo(
() => ({
bizNm: searchParams.bizNm,
taskSeComCd: searchParams.taskSeComCd,
admdstCd: searchParams.admdstCd,
page: pagination.pageIndex,
size: pagination.pageSize,
sort: searchParams.sort ?? "designCnstnDocNo,desc",
}),
[searchParams, pagination],
);
const pageQuery = useDesignCnstnMngPage(queryParams);
const columns = useMemo(() => createDesignCnstnColumns(), []);
const handleRowClick = (row: DesignCnstnMngResponseDto) => {
const canEdit = row.prgrsSttsComCd === "CV" && row.rgtr === userInfo?.userUnqId;
router.push(
canEdit
? `/design-cnstn/${row.designCnstnDocNo}/edit`
: `/design-cnstn/${row.designCnstnDocNo}`,
);
};
const { tableComponent } = useDataTable({
query: pageQuery,
tableOptions: {
columns,
state: { pagination },
onPaginationChange: (updater) => {
const next = typeof updater === "function" ? updater(pagination) : updater;
onPaginationChange(next);
},
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
},
dataTableOptions: {
viewPagination: true,
onRowClick: handleRowClick,
},
});
const handleConfirmTaskSe = (selectedCodes: string[]) => {
setIsTaskSeDialogOpen(false);
router.push(`/design-cnstn/regist?taskSeComCd=${formatTaskSeComCdForApi(selectedCodes)}`);
};
return (
<>
<section className="card card-border mx-0 bg-background pb-8 pt-0">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-semibold">설계자문 목록</h3>
<Button type="button" color="green" onClick={() => setIsTaskSeDialogOpen(true)}>
신청
</Button>
</div>
{tableComponent}
</section>
<TaskSePickerDialog
open={isTaskSeDialogOpen}
onOpenChange={setIsTaskSeDialogOpen}
onConfirm={handleConfirmTaskSe}
title="설계자문 신청"
codeUpCd="CNFM"
/>
</>
);
}
export { DesignList };
@@ -0,0 +1,11 @@
import { DesignFormPage } from "./design-form-page";
interface DesignRegistPageProps {
taskSeComCd?: string[];
}
function DesignRegistPage({ taskSeComCd }: DesignRegistPageProps) {
return <DesignFormPage mode="create" taskSeComCd={taskSeComCd} />;
}
export { DesignRegistPage };
@@ -0,0 +1,110 @@
"use client";
import { useMemo, useRef } from "react";
import { useUserStore } from "@/shared/lib";
import { useDesignCnstnMng } from "@/entities/design-cnstn";
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
import { Skeleton } from "@common/components";
import { needsRoleUser01AtrzResolve } from "../lib/button-config";
import { getEditableReviewStep } from "../lib/review-step-utils";
import { FloatingActionButtons } from "./components/floating-action-buttons";
import { AttachmentsSection } from "./sections/attachments-section";
import { BasicInfoSection } from "./sections/basic-info-section";
import { BusinessScaleSection } from "./sections/business-scale-section";
import { ContactInfoSection } from "./sections/contact-info-section";
import {
TechReviewOpinionSection,
type TechReviewOpinionSectionHandle,
} from "./sections/tech-review-opinion-section";
interface DesignViewPageProps {
id: string;
}
function DesignViewPage({ id }: DesignViewPageProps) {
const userInfo = useUserStore((state) => state.userInfo);
const techReviewOpinionRef = useRef<TechReviewOpinionSectionHandle>(null);
const { data, isLoading, isError } = useDesignCnstnMng(id);
const prgrsSttsComCd = data?.detDto?.prgrsSttsComCd || "";
const needsAtrzResolve = needsRoleUser01AtrzResolve(userInfo?.dtlInstCd, prgrsSttsComCd);
const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList(
{},
{ enabled: needsAtrzResolve },
);
const atrzSeComCdList = useMemo(
() => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [],
[rvwLoginUserData],
);
if (isLoading) {
return (
<div className="mt-6 flex flex-col gap-4 px-6">
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[16rem] rounded-xl" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[16rem] rounded-xl" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[32rem] rounded-xl" />
</div>
</div>
);
}
if (isError || !data?.detDto) {
return <div className="mt-6 text-[1.4rem]">상세 정보를 찾을 수 없습니다.</div>;
}
const detDto = data.detDto;
const bizSclList = data.bizSclList ?? [];
const esntlFileList = data.esntlFileList ?? [];
const etcFileList = data.etcFileList ?? [];
const rvwList = data.rvwList ?? [];
const editableReviewStep = getEditableReviewStep(
userInfo?.dtlInstCd,
detDto.prgrsSttsComCd || "",
);
return (
<div className="mt-6 space-y-4">
<BasicInfoSection det={detDto} />
<ContactInfoSection det={detDto} />
<SectionCollapsible title="결재현황">
<ElaprDocPrgrsStatus elaprDocNo={detDto.designCnstnDocNo} />
</SectionCollapsible>
<BusinessScaleSection bizSclList={bizSclList} />
<AttachmentsSection esntlFileList={esntlFileList} etcFileList={etcFileList} />
<TechReviewOpinionSection
ref={techReviewOpinionRef}
rvwList={rvwList}
editableReviewStep={editableReviewStep}
atrzSeComCdList={atrzSeComCdList}
isAtrzLoading={needsAtrzResolve && isRvwLoginUserLoading}
/>
<FloatingActionButtons
data={data}
docSeComCd="CNFM"
designCnstnDocNo={detDto.designCnstnDocNo}
prgrsSttsComCd={detDto.prgrsSttsComCd || ""}
dtlInstCd={userInfo?.dtlInstCd}
atrzSeComCdList={atrzSeComCdList}
isRvwLoginUserLoading={needsAtrzResolve && isRvwLoginUserLoading}
techReviewOpinionRef={techReviewOpinionRef}
opnrNm={userInfo?.userName}
/>
</div>
);
}
export { DesignViewPage };
@@ -0,0 +1,112 @@
"use client";
import { formatFileSize } from "@/shared/lib/file-utils";
import { useDownloadAttachmentByAtflCd } from "@/entities/attach";
import type {
AtflMngClientResponseDto,
EsntlAtchFileMngTreeResponseDto,
} from "@/entities/design-cnstn";
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
interface AttachmentsSectionProps {
esntlFileList: EsntlAtchFileMngTreeResponseDto[];
etcFileList: AtflMngClientResponseDto[];
}
function AttachmentsSection({ esntlFileList, etcFileList }: AttachmentsSectionProps) {
const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
return (
<>
{esntlFileList.length > 0 && (
<SectionCollapsible title="필수 첨부파일">
<div className="overflow-x-auto">
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead>필수 자료명</TableHead>
<TableHead>파일명</TableHead>
<TableHead className="w-[8rem] text-right">크기</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{esntlFileList.map((file) => (
<TableRow key={file.esntlAtchFileId}>
<TableCell className="pl-2 text-left">
{file.esntlAtchFileNm}
{file.esntlAtchYn === "Y" ? (
<span className="ml-1 text-red-500">*</span>
) : null}
</TableCell>
<TableCell className="text-left">
{file.atflCd ? (
<a
className="cursor-pointer text-blue-600 hover:underline"
onClick={() => downloadAttachment(file.atflCd ?? "")}
target="_blank"
rel="noreferrer"
>
{file.orgnlFileNm || "다운로드"}
</a>
) : (
<span className="text-gray-500">첨부없음</span>
)}
</TableCell>
<TableCell className="text-right">{formatFileSize(file.atflFlieSz)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</SectionCollapsible>
)}
<SectionCollapsible title="기타 첨부파일">
<div className="overflow-x-auto">
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead>파일명</TableHead>
<TableHead className="w-[8rem] text-right">크기</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{etcFileList.length === 0 ? (
<TableRow>
<TableCell colSpan={2} className="py-12 text-center text-muted-foreground">
등록된 기타첨부파일이 없습니다.
</TableCell>
</TableRow>
) : (
etcFileList.map((file) => (
<TableRow key={file.atflId}>
<TableCell className="text-left">
{file.atflCd ? (
<a
className="cursor-pointer text-blue-600 hover:underline"
onClick={() => downloadAttachment(file.atflCd)}
target="_blank"
rel="noreferrer"
>
{file.orgnlFileNm || file.physFileNm || file.atflId}
</a>
) : (
<span>{file.orgnlFileNm || file.physFileNm || file.atflId}</span>
)}
</TableCell>
<TableCell className="text-right text-gray-500">
{formatFileSize(file.atflFlieSz)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</SectionCollapsible>
</>
);
}
export { AttachmentsSection };
@@ -0,0 +1,159 @@
"use client";
import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useUserStore } from "@/shared/lib";
import { Field, FieldError, FieldGroup } from "@/shared/ui/field";
import { findCodeLabel, useCodeList } from "@/entities/code";
import { type BnsApplyData, BnsPickerDialog } from "@/widgets/bns-picker";
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
import {
Button,
Input,
Table,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import type { DesignCnstnRegistFormData } from "../../lib/design-cnstn-regist-schema";
function BasicInfoFormSection() {
const userInfo = useUserStore((state) => state.userInfo);
const { control, watch, setValue } = useFormContext<DesignCnstnRegistFormData>();
const [isPickerOpen, setIsPickerOpen] = useState(false);
const taskSeComCd = watch("designCnstnMng.taskSeComCd");
const { data: taskOptions } = useCodeList("CNFM");
function handleBnsSelect(data: BnsApplyData) {
setValue("designCnstnMng.bizNm", data.bizNm);
setValue("designCnstnMng.bizBgngYr", data.bizBgngYr);
setValue("designCnstnMng.bizEndYr", data.bizEndYr);
setValue("designCnstnMng.bizCn", data.bizCn);
}
const taskSeComCdLabel =
taskSeComCd
.map((code) => findCodeLabel(taskOptions, code))
.filter(Boolean)
.join(", ") || "-";
return (
<SectionCollapsible title="기본 정보">
<Table variant="secondary">
<colgroup>
<col width="15%" />
<col width="35%" />
<col width="15%" />
<col width="35%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="w-[20rem]" data-required>
사업명
</TableHead>
<TableCell colSpan={3}>
<Controller
name="designCnstnMng.bizNm"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<div className="flex gap-2">
<Input
{...field}
id={field.name}
placeholder="사업명 입력"
aria-invalid={fieldState.invalid}
className="w-[40rem]"
/>
<Button type="button" onClick={() => setIsPickerOpen(true)}>
사업찾기
</Button>
</div>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead data-required>사업기간</TableHead>
<TableCell colSpan={3}>
<FieldGroup className="flex-row items-center gap-2">
<Controller
name="designCnstnMng.bizBgngYr"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid} className="w-auto">
<Input
{...field}
id={field.name}
placeholder="시작연도"
className="max-w-[12rem]"
aria-invalid={fieldState.invalid}
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
<span>~</span>
<Controller
name="designCnstnMng.bizEndYr"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid} className="w-auto">
<Input
{...field}
id={field.name}
placeholder="종료연도"
className="max-w-[12rem]"
aria-invalid={fieldState.invalid}
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
</FieldGroup>
</TableCell>
</TableRow>
<TableRow>
<TableHead data-required>사업내용</TableHead>
<TableCell colSpan={3}>
<Controller
name="designCnstnMng.bizCn"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
{...field}
id={field.name}
placeholder="사업내용 입력"
aria-invalid={fieldState.invalid}
/>
<FieldError errors={[fieldState.error]} />
</Field>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead data-required>구분</TableHead>
<TableCell>{taskSeComCdLabel}</TableCell>
<TableHead>신청 지자체</TableHead>
<TableCell>{userInfo?.instSeNm}</TableCell>
</TableRow>
</TableHeader>
</Table>
<BnsPickerDialog
open={isPickerOpen}
onClose={() => setIsPickerOpen(false)}
onSelect={handleBnsSelect}
/>
</SectionCollapsible>
);
}
export { BasicInfoFormSection };
@@ -0,0 +1,50 @@
"use client";
import type { DesignCnstnMngResponseDto } from "@/entities/design-cnstn";
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
interface BasicInfoSectionProps {
det: DesignCnstnMngResponseDto;
}
function BasicInfoSection({ det }: BasicInfoSectionProps) {
return (
<SectionCollapsible title="기본 정보">
<Table variant="secondary">
<colgroup>
<col width="15%" />
<col width="35%" />
<col width="15%" />
<col width="35%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>사업명</TableHead>
<TableCell>{det.bizNm || "-"}</TableCell>
<TableHead>사업기간</TableHead>
<TableCell>{(det.bizBgngYr || "-") + " ~ " + (det.bizEndYr || "-")}</TableCell>
</TableRow>
<TableRow>
<TableHead>사업내용</TableHead>
<TableCell colSpan={3}>{det.bizCn || "-"}</TableCell>
</TableRow>
<TableRow>
<TableHead>환경청구분</TableHead>
<TableCell>{det.envoSeCdNm || "-"}</TableCell>
<TableHead>구분</TableHead>
<TableCell>{det.taskSeCdNm || "-"}</TableCell>
</TableRow>
<TableRow>
<TableHead>신청지자체</TableHead>
<TableCell colSpan={3}>
{det.sido || "-"} {det.gugun || "-"}
</TableCell>
</TableRow>
</TableHeader>
</Table>
</SectionCollapsible>
);
}
export { BasicInfoSection };
@@ -0,0 +1,70 @@
"use client";
import type { BizSclTreeDto } from "@/entities/design-cnstn";
import { fmtNumber, indentPx, SectionCollapsible } from "@/widgets/pbadms-sprt";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
interface BusinessScaleSectionProps {
bizSclList: BizSclTreeDto[];
}
function BusinessScaleSection({ bizSclList }: BusinessScaleSectionProps) {
return (
<SectionCollapsible title="사업규모">
<div className="overflow-x-auto">
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead className="w-[30rem]">구분</TableHead>
<TableHead>기본계획(A)</TableHead>
<TableHead>예산반영(B)</TableHead>
<TableHead>금회설계시(C)</TableHead>
<TableHead>증감(C-B)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bizSclList.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="py-12 text-center text-muted-foreground">
조회된 사업규모 항목이 없습니다.
</TableCell>
</TableRow>
) : (
bizSclList.map((node) => {
const pl = indentPx(node.indCnstpSeCdNm);
const bscPlanQty = Number(node.bscPlanQty) || 0;
const budgetReflct = Number(node.bgtRfltQty) || 0;
const tstmDesignQty = Number(node.tstmDesignQty) || 0;
const diff = tstmDesignQty - budgetReflct;
return (
<TableRow key={node.cnstpSeCd}>
<TableHead className="text-left" style={{ paddingLeft: `${pl + 8}px` }}>
{node.cnstpSeCdNm}
</TableHead>
<TableCell>
<div className="text-right">{fmtNumber(bscPlanQty)}</div>
</TableCell>
<TableCell>
<div className="text-right">{fmtNumber(budgetReflct)}</div>
</TableCell>
<TableCell>
<div className="text-right">{fmtNumber(tstmDesignQty)}</div>
</TableCell>
<TableCell>
<div className="text-right">
{diff > 0 ? `+${fmtNumber(diff)}` : fmtNumber(diff)}
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</SectionCollapsible>
);
}
export { BusinessScaleSection };
@@ -0,0 +1,56 @@
"use client";
import type { DesignCnstnMngResponseDto } from "@/entities/design-cnstn";
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
interface ContactInfoSectionProps {
det: DesignCnstnMngResponseDto;
}
function ContactInfoSection({ det }: ContactInfoSectionProps) {
return (
<SectionCollapsible title="담당자 정보">
<Table variant="secondary">
<TableHeader>
{det.aplcntNm && (
<>
<TableRow>
<TableHead rowSpan={2}>신청자</TableHead>
<TableHead>이름</TableHead>
<TableCell>{det.aplcntNm || "-"}</TableCell>
<TableHead>연락처</TableHead>
<TableCell>{det.aplcntMblTelno || "-"}</TableCell>
</TableRow>
<TableRow>
<TableHead>소속</TableHead>
<TableCell>{det.aplcntOgdpNm || "-"}</TableCell>
<TableHead>이메일</TableHead>
<TableCell>{det.aplcntEmlAddr || "-"}</TableCell>
</TableRow>
</>
)}
{det.dsgnrNm && (
<>
<TableRow>
<TableHead rowSpan={2}>설계자</TableHead>
<TableHead>이름</TableHead>
<TableCell>{det.dsgnrNm || "-"}</TableCell>
<TableHead>연락처</TableHead>
<TableCell>{det.dsgnrTelno || "-"}</TableCell>
</TableRow>
<TableRow>
<TableHead>소속</TableHead>
<TableCell>{det.dsgnrOgdpNm || "-"}</TableCell>
<TableHead>이메일</TableHead>
<TableCell>{det.dsgnrEmlAddr || "-"}</TableCell>
</TableRow>
</>
)}
</TableHeader>
</Table>
</SectionCollapsible>
);
}
export { ContactInfoSection };
@@ -0,0 +1,342 @@
"use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { PROXY_HOST } from "@/shared/config";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import { attachService } from "@/entities/attach";
import type { DesignCnstnRvwOpnnResponseDto } from "@/entities/design-cnstn";
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
import {
Button,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
import type {
ReviewOpinionFormData,
ReviewOpinionGetFormDataResult,
} from "../../lib/review-opinion-utils";
import type { ReviewStepCode } from "../../model/view-types";
import { REVIEW_STEP_LABELS } from "../../model/view-types";
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
interface TechReviewOpinionSectionProps {
rvwList: DesignCnstnRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode;
atrzSeComCdList?: string[];
isAtrzLoading?: boolean;
}
export interface TechReviewOpinionSectionHandle {
getFormData: () => ReviewOpinionGetFormDataResult | null;
resetForm: () => void;
}
interface TechReviewOpinionTableProps {
items: DesignCnstnRvwOpnnResponseDto[];
}
interface UploadedOpinionFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
}
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
rvwRsltCn: "",
rvwRsltAnmlyYn: "",
atchFileGroupId: "",
opnrSeComCd: "",
};
function createRvwOpnnUploadKey(file: File) {
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
}
function hasFormContent(formData: ReviewOpinionFormData) {
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
}
const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps
>(function TechReviewOpinionSection({
rvwList,
editableReviewStep,
atrzSeComCdList = [],
isAtrzLoading = false,
}, ref) {
const canWriteOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState<ReviewOpinionFormData>({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
useEffect(() => {
setFormData((prev) => ({
...prev,
opnrSeComCd: editableReviewStep ?? "",
}));
}, [editableReviewStep]);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
},
onError: (_error, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
});
useImperativeHandle(
ref,
() => ({
getFormData: () => {
if (!canWriteOpinion || !editableReviewStep || !hasFormContent(formData)) {
return null;
}
return {
formData: {
...formData,
opnrSeComCd: editableReviewStep,
},
rvwList,
};
},
resetForm: () => {
setFormData({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
setUploadedFile(null);
},
}),
[canWriteOpinion, editableReviewStep, formData, rvwList],
);
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasKecoSection =
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteOpinion);
if (!hasKecoSection) {
return null;
}
async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const fileKey = createRvwOpnnUploadKey(file);
const existingAtflCd = formData.atchFileGroupId;
setUploadedFile({ fileName: file.name, fileKey });
try {
await uploadFileAsync(file, {
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
} catch {
// onError callback handles cleanup
}
}
async function handleFileRemove() {
if (!uploadedFile) return;
uploads[uploadedFile.fileKey]?.abort();
if (uploadedFile.atflId) {
try {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error);
}
}
setUploadedFile(null);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
}
function renderOpinionForm(reviewStep: ReviewStepCode) {
if (editableReviewStep !== reviewStep || !canWriteOpinion) return null;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
return (
<div className="space-y-2">
<h4 className="text-xl font-semibold">검토의견 작성</h4>
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead className="w-[12rem]">의견내용</TableHead>
<TableCell>
<Textarea
value={formData.rvwRsltCn}
onChange={(event) =>
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
}
placeholder="의견내용을 입력하세요"
rows={4}
className="min-h-[8rem] !text-xl"
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>검토결과</TableHead>
<TableCell>
<FieldSet>
<RadioGroup
value={formData.rvwRsltAnmlyYn}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
}))
}
>
<div className="flex flex-wrap gap-4">
<FieldLabel
htmlFor={`rvw-opnn-normal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
<span className="font-normal">정상</span>
</Field>
</FieldLabel>
<FieldLabel
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
<span className="font-normal">이상</span>
</Field>
</FieldLabel>
</div>
</RadioGroup>
</FieldSet>
</TableCell>
</TableRow>
<TableRow>
<TableHead>첨부파일</TableHead>
<TableCell>
{uploadedFile ? (
<div className="flex items-center gap-2">
<span className="text-xl">{uploadedFile.fileName}</span>
<Button
size="icon"
onClick={(e) => {
e.preventDefault();
void handleFileRemove();
}}
disabled={uploading}
className="size-8 !bg-destructive"
>
<i className="diveicon di-x text-xs" aria-label="삭제" />
</Button>
</div>
) : (
<label>
<input
type="file"
className="hidden"
onChange={(event) => {
void handleFileUpload(event.target.files);
event.target.value = "";
}}
/>
<Button size="sm" variant="outline" type="button" asChild>
<span>파일 선택</span>
</Button>
</label>
)}
</TableCell>
</TableRow>
</TableHeader>
</Table>
</div>
);
}
return (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<div className="space-y-6">
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null}
{renderOpinionForm("KECO")}
</div>
</SectionCollapsible>
);
});
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
return (
<div className="overflow-x-auto">
<Table variant="secondary">
<colgroup>
<col width="10%" />
<col width="55%" />
<col width="5%" />
<col width="20%" />
<col width="10%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>의견자명</TableHead>
<TableHead>의견내용</TableHead>
<TableHead>검토결과</TableHead>
<TableHead>첨부파일</TableHead>
<TableHead>등록일시</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={`${item.designCnstnDocNo}_${item.rvwOpnnSn}`}>
<TableCell align="center">{item.opnrNm}</TableCell>
<TableCell align="left">{item.rvwRsltCn || "-"}</TableCell>
<TableCell align="center">
{item.rvwRsltAnmlyYn === "Y" ? "이상" : item.rvwRsltAnmlyYn === "N" ? "정상" : "-"}
</TableCell>
<TableCell align="center">
{item.atchFileInfo?.atflCd ? (
<a
className="text-blue-600 hover:underline"
href={`${PROXY_HOST}/attach-service/api/v1/atflcd/download/${item.atchFileInfo.atflCd}`}
target="_blank"
rel="noreferrer"
>
{item.atchFileInfo.orgnlFileNm || "다운로드"}
</a>
) : (
"-"
)}
</TableCell>
<TableCell align="center">
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
export { TechReviewOpinionSection };
@@ -26,7 +26,6 @@ import {
type FnrsCnsltnMngDetResponseDto,
fnrsCnsltnMngService,
} from "@/entities/fnrs-cnsltn";
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
import { SplmntDmndDialog, type SplmntDmndDialogConfirmPayload } from "@/widgets/pbadms-sprt";
import { Button } from "@common/components";
@@ -52,6 +51,8 @@ interface FloatingActionButtonsProps {
fnrsCnsltnDocNo: string;
prgrsSttsComCd: string;
dtlInstCd: string | undefined;
atrzSeComCdList: string[];
isRvwLoginUserLoading: boolean;
checklistFormState: ChecklistFormState;
techReviewOpinionRef?: React.RefObject<TechReviewOpinionSectionHandle | null>;
opnrNm?: string;
@@ -65,6 +66,8 @@ function FloatingActionButtons({
fnrsCnsltnDocNo,
prgrsSttsComCd,
dtlInstCd,
atrzSeComCdList,
isRvwLoginUserLoading,
checklistFormState,
techReviewOpinionRef,
opnrNm,
@@ -82,18 +85,9 @@ function FloatingActionButtons({
const needsAtrzResolve = needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd);
const needsTeApprovalCheck = needsAtrzResolve && prgrsSttsComCd === "TE";
const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList(
{},
{ enabled: needsAtrzResolve },
);
const { data: elaprDocPrgrsList = [], isLoading: isElaprDocPrgrsLoading } =
useElaprDocPrgrsByDocNo(needsTeApprovalCheck ? docNo : null);
const atrzSeComCdList = useMemo(
() => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [],
[rvwLoginUserData],
);
const hasPendingApprovalRequest = hasCurrentUserPendingApprovalRequest(
elaprDocPrgrsList,
userInfo?.userUnqId,
@@ -1,13 +1,15 @@
"use client";
import { useRef } from "react";
import { useMemo, useRef } from "react";
import { useUserStore } from "@/shared/lib";
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
import { useFnrsCnsltnMng } from "@/entities/fnrs-cnsltn";
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
import { SectionCollapsible, SupplementDemandSection } from "@/widgets/pbadms-sprt";
import { Skeleton } from "@common/components";
import { needsRoleUser01AtrzResolve } from "../lib/button-config";
import { getEditableReviewStep } from "../lib/review-step-utils";
import { EMPTY_CKLST_RESULT_LIST, useChecklistFormState } from "../lib/use-checklist-form-state";
@@ -35,6 +37,17 @@ function FnrsViewPage({ id }: FnrsViewPageProps) {
const { formState: checklistFormState, setFormState: setChecklistFormState } =
useChecklistFormState(data?.cklstResultList ?? EMPTY_CKLST_RESULT_LIST);
const prgrsSttsComCd = data?.detDto?.prgrsSttsComCd || "";
const needsAtrzResolve = needsRoleUser01AtrzResolve(userInfo?.dtlInstCd, prgrsSttsComCd);
const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList(
{},
{ enabled: needsAtrzResolve },
);
const atrzSeComCdList = useMemo(
() => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [],
[rvwLoginUserData],
);
if (isLoading) {
return (
<div className="mt-6 flex flex-col gap-4 px-6">
@@ -96,6 +109,8 @@ function FnrsViewPage({ id }: FnrsViewPageProps) {
ref={techReviewOpinionRef}
rvwList={rvwList}
editableReviewStep={editableReviewStep}
atrzSeComCdList={atrzSeComCdList}
isAtrzLoading={needsAtrzResolve && isRvwLoginUserLoading}
/>
<FloatingActionButtons
data={data}
@@ -103,6 +118,8 @@ function FnrsViewPage({ id }: FnrsViewPageProps) {
fnrsCnsltnDocNo={detDto.fnrsCnsltnDocNo}
prgrsSttsComCd={detDto.prgrsSttsComCd || ""}
dtlInstCd={userInfo?.dtlInstCd}
atrzSeComCdList={atrzSeComCdList}
isRvwLoginUserLoading={needsAtrzResolve && isRvwLoginUserLoading}
checklistFormState={checklistFormState}
techReviewOpinionRef={techReviewOpinionRef}
opnrNm={userInfo?.userName}
@@ -12,6 +12,12 @@ function BasicInfoSection({ det }: BasicInfoSectionProps) {
return (
<SectionCollapsible title="기본 정보">
<Table variant="secondary">
<colgroup>
<col width="15%" />
<col width="35%" />
<col width="15%" />
<col width="35%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>사업명</TableHead>
@@ -33,6 +33,8 @@ const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
interface TechReviewOpinionSectionProps {
rvwList: FnrsCnsltnRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode;
atrzSeComCdList?: string[];
isAtrzLoading?: boolean;
}
export interface TechReviewOpinionSectionHandle {
@@ -69,7 +71,13 @@ function hasFormContent(formData: ReviewOpinionFormData) {
const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps
>(function TechReviewOpinionSection({ rvwList, editableReviewStep }, ref) {
>(function TechReviewOpinionSection({
rvwList,
editableReviewStep,
atrzSeComCdList = [],
isAtrzLoading = false,
}, ref) {
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState<ReviewOpinionFormData>({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
@@ -102,6 +110,9 @@ const TechReviewOpinionSection = forwardRef<
if (!editableReviewStep || !hasFormContent(formData)) {
return null;
}
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
return null;
}
return {
formData: {
@@ -119,14 +130,15 @@ const TechReviewOpinionSection = forwardRef<
setUploadedFile(null);
},
}),
[editableReviewStep, formData, rvwList],
[canWriteKecoOpinion, editableReviewStep, formData, rvwList],
);
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
const hasKecoSection = kecoRvwList.length > 0 || editableReviewStep === "KECO";
const hasKecoSection =
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteKecoOpinion);
if (!hasEnvSection && !hasKecoSection) {
return null;
@@ -170,6 +182,7 @@ const TechReviewOpinionSection = forwardRef<
function renderOpinionForm(reviewStep: ReviewStepCode) {
if (editableReviewStep !== reviewStep) return null;
if (reviewStep === "KECO" && !canWriteKecoOpinion) return null;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
@@ -26,7 +26,6 @@ import {
type InstlSncnMngDetResponse,
instlSncnService,
} from "@/entities/instl-sncn";
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
import { SplmntDmndDialog, type SplmntDmndDialogConfirmPayload } from "@/widgets/pbadms-sprt";
import { Button } from "@common/components";
@@ -52,6 +51,8 @@ interface FloatingActionButtonsProps {
instlSncnDocNo: string;
prgrsSttsComCd: string;
dtlInstCd: string | undefined;
atrzSeComCdList: string[];
isRvwLoginUserLoading: boolean;
checklistFormState: ChecklistFormState;
techReviewOpinionRef?: React.RefObject<TechReviewOpinionSectionHandle | null>;
opnrNm?: string;
@@ -65,6 +66,8 @@ function FloatingActionButtons({
instlSncnDocNo,
prgrsSttsComCd,
dtlInstCd,
atrzSeComCdList,
isRvwLoginUserLoading,
checklistFormState,
techReviewOpinionRef,
opnrNm,
@@ -82,18 +85,9 @@ function FloatingActionButtons({
const needsAtrzResolve = needsRoleUser01AtrzResolve(dtlInstCd, prgrsSttsComCd);
const needsTeApprovalCheck = needsAtrzResolve && prgrsSttsComCd === "TE";
const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList(
{},
{ enabled: needsAtrzResolve },
);
const { data: elaprDocPrgrsList = [], isLoading: isElaprDocPrgrsLoading } =
useElaprDocPrgrsByDocNo(needsTeApprovalCheck ? docNo : null);
const atrzSeComCdList = useMemo(
() => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [],
[rvwLoginUserData],
);
const hasPendingApprovalRequest = hasCurrentUserPendingApprovalRequest(
elaprDocPrgrsList,
userInfo?.userUnqId,
@@ -1,13 +1,15 @@
"use client";
import { useRef } from "react";
import { useMemo, useRef } from "react";
import { useUserStore } from "@/shared/lib";
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
import { useInstlSncnDetail } from "@/entities/instl-sncn";
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
import { SectionCollapsible, SupplementDemandSection } from "@/widgets/pbadms-sprt";
import { Skeleton } from "@common/components";
import { needsRoleUser01AtrzResolve } from "../lib/button-config";
import { getEditableReviewStep } from "../lib/review-step-utils";
import { EMPTY_CKLST_RESULT_LIST, useChecklistFormState } from "../lib/use-checklist-form-state";
@@ -34,6 +36,17 @@ function InstlViewPage({ id }: InstlViewPageProps) {
const { formState: checklistFormState, setFormState: setChecklistFormState } =
useChecklistFormState(data?.cklstResultList ?? EMPTY_CKLST_RESULT_LIST);
const prgrsSttsComCd = data?.detDto?.prgrsSttsComCd || "";
const needsAtrzResolve = needsRoleUser01AtrzResolve(userInfo?.dtlInstCd, prgrsSttsComCd);
const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList(
{},
{ enabled: needsAtrzResolve },
);
const atrzSeComCdList = useMemo(
() => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [],
[rvwLoginUserData],
);
if (isLoading) {
return (
<div className="mt-6 flex flex-col gap-4 px-6">
@@ -94,6 +107,8 @@ function InstlViewPage({ id }: InstlViewPageProps) {
ref={techReviewOpinionRef}
rvwList={rvwList}
editableReviewStep={editableReviewStep}
atrzSeComCdList={atrzSeComCdList}
isAtrzLoading={needsAtrzResolve && isRvwLoginUserLoading}
/>
<SupplementDemandSection splmntDmndList={splmntDmndList} />
<FloatingActionButtons
@@ -102,6 +117,8 @@ function InstlViewPage({ id }: InstlViewPageProps) {
instlSncnDocNo={detDto.instlSncnDocNo}
prgrsSttsComCd={detDto.prgrsSttsComCd || ""}
dtlInstCd={userInfo?.dtlInstCd}
atrzSeComCdList={atrzSeComCdList}
isRvwLoginUserLoading={needsAtrzResolve && isRvwLoginUserLoading}
checklistFormState={checklistFormState}
techReviewOpinionRef={techReviewOpinionRef}
opnrNm={userInfo?.userName}
@@ -12,6 +12,12 @@ function BasicInfoSection({ det }: BasicInfoSectionProps) {
return (
<SectionCollapsible title="기본 정보">
<Table variant="secondary">
<colgroup>
<col width="15%" />
<col width="35%" />
<col width="15%" />
<col width="35%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>사업명</TableHead>
@@ -29,6 +29,8 @@ const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
interface TechReviewOpinionSectionProps {
rvwList: InstlSncnRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode;
atrzSeComCdList?: string[];
isAtrzLoading?: boolean;
}
export interface TechReviewOpinionSectionHandle {
@@ -65,7 +67,13 @@ function hasFormContent(formData: ReviewOpinionFormData) {
const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps
>(function TechReviewOpinionSection({ rvwList, editableReviewStep }, ref) {
>(function TechReviewOpinionSection({
rvwList,
editableReviewStep,
atrzSeComCdList = [],
isAtrzLoading = false,
}, ref) {
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState<ReviewOpinionFormData>({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
@@ -98,6 +106,9 @@ const TechReviewOpinionSection = forwardRef<
if (!editableReviewStep || !hasFormContent(formData)) {
return null;
}
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
return null;
}
return {
...formData,
@@ -112,14 +123,15 @@ const TechReviewOpinionSection = forwardRef<
setUploadedFile(null);
},
}),
[editableReviewStep, formData],
[canWriteKecoOpinion, editableReviewStep, formData],
);
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
const hasKecoSection = kecoRvwList.length > 0 || editableReviewStep === "KECO";
const hasKecoSection =
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteKecoOpinion);
if (!hasEnvSection && !hasKecoSection) {
return null;
@@ -163,6 +175,7 @@ const TechReviewOpinionSection = forwardRef<
function renderOpinionForm(reviewStep: ReviewStepCode) {
if (editableReviewStep !== reviewStep) return null;
if (reviewStep === "KECO" && !canWriteKecoOpinion) return null;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
+5
View File
@@ -17,6 +17,11 @@ export const ENDPOINT = {
ADSTDSTR: "/as-service/api/v1/adstdstr",
FNRS_CNSLTN_MNG: "/as-service/api/v1/fnrs-cnsltn-mng",
FNRS_SHAR_SE: "/as-service/api/v1/fnrs-shar-se-mng",
DESIGN_CNSTN: "/as-service/api/v1/design-cnstn",
DESIGN_CNSTN_BIZ_CST: "/as-service/api/v1/design-cnstn-biz-cst",
DESIGN_CNSTN_BIZ_SCL: "/as-service/api/v1/design-cnstn-biz-scl",
DESIGN_CNSTN_ESNTL_ATCH_FILE: "/as-service/api/v1/design-cnstn-esntl-atch-file",
DESIGN_CNSTN_RVW_OPNN: "/as-service/api/v1/design-cnstn-rvw-opnn",
},
USER_SERVICE: {
LOGIN: "/user-service/login",
@@ -1,6 +1,6 @@
import { getDefaultFileKey } from "@/shared/lib";
export type FieldPrefix = "fnrsCnsltnMng" | "instlSncnMng";
export type FieldPrefix = "fnrsCnsltnMng" | "instlSncnMng" | "designCnstnMng";
export const ETC_FILE_KEY_PREFIX = "etc::";
@@ -29,7 +29,7 @@ import { buildChecklistTabs, syncCklstResultListFromTree } from "../lib/checklis
import { SectionCollapsible } from "./section-collapsible";
type FieldPrefix = "fnrsCnsltnMng" | "instlSncnMng";
type FieldPrefix = "fnrsCnsltnMng" | "instlSncnMng" | "designCnstnMng";
interface CklstResultListItem {
cklstSn: number;
@@ -7,7 +7,7 @@ import { Field, FieldError } from "@/shared/ui/field";
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
import { Input, Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
type FieldPrefix = "fnrsCnsltnMng" | "instlSncnMng";
type FieldPrefix = "fnrsCnsltnMng" | "instlSncnMng" | "designCnstnMng";
interface ContactInfoFormSectionProps {
fieldPrefix: FieldPrefix;
+8 -2
View File
@@ -22,6 +22,12 @@
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"../../packages/common/types/**/*.d.ts"
],
"exclude": ["node_modules", ".next", "dist", "coverage", "storybook-static", "public"]
}
+2
View File
@@ -2,6 +2,7 @@ import {
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
COOKIE_DOMAIN,
REFRESH_TOKEN,
SERVER_API_URL,
SITE_ID,
@@ -135,6 +136,7 @@ async function handler(
cookieStore.set(REFRESH_TOKEN, refreshToken, {
httpOnly: true,
sameSite: "lax",
domain: COOKIE_DOMAIN,
path: "/",
});
}
+1 -2
View File
@@ -12,7 +12,7 @@
"@common/assets": "workspace:*",
"@common/auth": "workspace:*",
"@common/components": "workspace:*",
"@common/config": "workspace:*",
"@common/network": "workspace:*",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -47,7 +47,6 @@
"sonner": "^2.0.1",
"tailwindcss-animate": "^1.0.7",
"tus-js-client": "^4.3.1",
"uuid": "^14.0.1",
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
+20 -32
View File
@@ -5,59 +5,42 @@ import {
ENDPOINT,
} from "@/shared/config";
import {
clearDeviceId,
getOrCreateDeviceId,
type AuthPayload,
type AuthService,
getRemainingMs,
} from "@common/auth";
import {
clearRefreshTimer,
setToken,
setupRefreshTimer,
tokenManager,
} from "../lib/token-manager";
import { clearDeviceId } from "../lib/device-id";
function resolveLoginError(message: string | undefined): string {
switch (message) {
case "Invalid Credentials":
return "아이디와 비밀번호가 일치하지 않습니다.";
case "User not found":
return "존재하지 않는 아이디입니다.";
case "User is not active":
return "활성화되지 않은 계정입니다.";
case "User is not verified":
return "이메일 인증이 필요합니다.";
default:
return message ?? "로그인에 실패했습니다.";
}
}
function completeSession(accessToken: string, tokenId: string): void {
setToken(CLAIM_NAME, accessToken);
setToken(AUTH_USER_ID, tokenId);
const remainingMs = getRemainingMs(accessToken);
if (remainingMs > 0) {
setupRefreshTimer(silentRefresh, remainingMs);
}
// const remainingMs = getRemainingMs(accessToken);
// if (remainingMs > 0) {
// setupRefreshTimer(silentRefresh, remainingMs);
// }
}
async function login(payload: AuthPayload): Promise<void> {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
"X-Device-Id": getOrCreateDeviceId(),
},
body: JSON.stringify(payload),
});
const data = await response.json();
if (!response.ok) {
throw new Error(resolveLoginError(data?.message));
}
const accessToken = data[ACCESS_TOKEN] as string | undefined;
const tokenId = data[AUTH_USER_ID] as string | undefined;
const accessToken = data[ACCESS_TOKEN];
const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) {
throw new Error("인증 토큰을 받지 못했습니다.");
@@ -71,7 +54,12 @@ async function silentRefresh(): Promise<void> {
try {
const response = await fetch(
`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`,
{ method: "PUT" },
{
method: "PUT",
headers: {
"X-Device-Id": getOrCreateDeviceId(),
},
},
);
const data = await response.json();
@@ -80,8 +68,8 @@ async function silentRefresh(): Promise<void> {
throw new Error(data?.message ?? "토큰 갱신에 실패했습니다.");
}
const accessToken = data[ACCESS_TOKEN] as string | undefined;
const tokenId = data[AUTH_USER_ID] as string | undefined;
const accessToken = data[ACCESS_TOKEN];
const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) {
throw new Error("갱신된 인증 토큰을 받지 못했습니다.");
@@ -1,20 +0,0 @@
import { v4 as uuidv4 } from "uuid";
const DEVICE_ID_KEY = "x-device-id";
export function getOrCreateDeviceId(): string {
if (typeof window === "undefined") return "";
const existing = localStorage.getItem(DEVICE_ID_KEY);
if (existing) return existing;
const id = uuidv4();
localStorage.setItem(DEVICE_ID_KEY, id);
return id;
}
export function clearDeviceId(): void {
if (typeof window === "undefined") return;
localStorage.removeItem(DEVICE_ID_KEY);
}
+11 -14
View File
@@ -22,26 +22,23 @@
"name": "next"
}
],
"baseUrl": "./src",
"paths": {
"@/*": ["./*"],
"@common/components": ["../../../packages/common/components/src/"],
"@common/config": ["../../../packages/common/config/dist/"],
"@common/network": ["../../../packages/common/network/"]
"@/*": ["./src/*"]
}
},
"include": [
".eslintrc.*",
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"**/*.mts",
"**/*.js",
"**/*.cjs",
"**/*.mjs",
"**/*.jsx",
"**/*.json",
".next/types/**/*.ts"
".next/types/**/*.ts",
"../../packages/common/types/**/*.d.ts"
],
"exclude": ["**/node_modules", "**/.*/*"]
"exclude": [
"node_modules",
".next",
"dist",
"coverage",
"storybook-static",
"public"
]
}
+10 -19
View File
@@ -22,26 +22,17 @@
"name": "next"
}
],
"baseUrl": "./src",
"paths": {
"@/*": ["./*"],
"@common/assets": ["../../../../packages/common/assets/dist/"],
"@common/components": ["../../../../packages/common/components/dist/"],
"@common/network": ["../../../../packages/common/network/"]
"@/*": ["./src/*"]
}
},
"include": [
".eslintrc.*",
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"**/*.mts",
"**/*.js",
"**/*.cjs",
"**/*.mjs",
"**/*.jsx",
"**/*.json",
".next/types/**/*.ts"
],
"exclude": ["**/node_modules", "**/.*/*"]
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": [
"node_modules",
".next",
"dist",
"coverage",
"storybook-static",
"public"
]
}
+1 -12
View File
@@ -70,7 +70,7 @@ async function handler(
response.cookies.set(REFRESH_TOKEN, refreshToken, {
httpOnly: true,
sameSite: "strict",
domain: getCookieDomain(),
domain: COOKIE_DOMAIN,
path: "/",
});
}
@@ -105,14 +105,3 @@ export async function PUT(
const params = await props.params;
return handler(request, params, "PUT");
}
function getCookieDomain(): string | undefined {
if (typeof window === "undefined") return undefined;
if (COOKIE_DOMAIN) return COOKIE_DOMAIN;
const { hostname } = window.location;
if (hostname === "localhost" || hostname === "127.0.0.1") return undefined;
return hostname;
}
+4 -4
View File
@@ -41,7 +41,7 @@ export function middleware(req: NextRequest) {
logMessages.push(` Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
// console.log(logMessages.join("\n"));
console.log(logMessages.join("\n"));
const response = NextResponse.rewrite(new URL(newUrl));
return addSecurityHeaders(response);
@@ -58,7 +58,7 @@ export function middleware(req: NextRequest) {
logMessages.push(` Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
// console.log(logMessages.join("\n"));
console.log(logMessages.join("\n"));
const response = NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
@@ -83,7 +83,7 @@ export function middleware(req: NextRequest) {
logMessages.push(` Link API Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
// console.log(logMessages.join("\n"));
console.log(logMessages.join("\n"));
const response = NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
@@ -97,7 +97,7 @@ export function middleware(req: NextRequest) {
}
logMessages.push("\n--------------------------------\n");
// console.log(logMessages.join("\n"));
console.log(logMessages.join("\n"));
const response = NextResponse.next();
return addSecurityHeaders(response);
-1
View File
@@ -45,7 +45,6 @@
"sonner": "^2.0.1",
"tailwindcss-animate": "^1.0.7",
"tus-js-client": "^4.3.1",
"uuid": "^14.0.1",
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
+14 -34
View File
@@ -7,32 +7,16 @@ import {
import {
type AuthPayload,
type AuthService,
getRemainingMs,
clearDeviceId,
getOrCreateDeviceId,
} from "@common/auth";
import { clearDeviceId, getOrCreateDeviceId } from "../lib/device-id";
import {
clearRefreshTimer,
setToken,
setupRefreshTimer,
tokenManager,
} from "../lib/token-manager";
function resolveLoginError(message: string | undefined): string {
switch (message) {
case "Invalid Credentials":
return "아이디와 비밀번호가 일치하지 않습니다.";
case "User not found":
return "존재하지 않는 아이디입니다.";
case "User is not active":
return "활성화되지 않은 계정입니다.";
case "User is not verified":
return "이메일 인증이 필요합니다.";
default:
return message ?? "로그인에 실패했습니다.";
}
}
async function login(payload: AuthPayload): Promise<void> {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
method: "POST",
@@ -45,12 +29,8 @@ async function login(payload: AuthPayload): Promise<void> {
const data = await response.json();
if (!response.ok) {
throw new Error(resolveLoginError(data?.message));
}
const accessToken = data[ACCESS_TOKEN] as string | undefined;
const tokenId = data[AUTH_USER_ID] as string | undefined;
const accessToken = data[ACCESS_TOKEN];
const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) {
throw new Error("인증 토큰을 받지 못했습니다.");
@@ -59,10 +39,10 @@ async function login(payload: AuthPayload): Promise<void> {
setToken(CLAIM_NAME, accessToken);
setToken(AUTH_USER_ID, tokenId);
const remainingMs = getRemainingMs(accessToken);
if (remainingMs > 0) {
setupRefreshTimer(silentRefresh, remainingMs);
}
// const remainingMs = getRemainingMs(accessToken);
// if (remainingMs > 0) {
// setupRefreshTimer(silentRefresh, remainingMs);
// }
}
async function silentRefresh(): Promise<void> {
@@ -84,8 +64,8 @@ async function silentRefresh(): Promise<void> {
throw new Error(data?.message ?? "토큰 갱신에 실패했습니다.");
}
const accessToken = data[ACCESS_TOKEN] as string | undefined;
const tokenId = data[AUTH_USER_ID] as string | undefined;
const accessToken = data[ACCESS_TOKEN];
const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) {
throw new Error("갱신된 인증 토큰을 받지 못했습니다.");
@@ -94,10 +74,10 @@ async function silentRefresh(): Promise<void> {
setToken(CLAIM_NAME, accessToken);
setToken(AUTH_USER_ID, tokenId);
const remainingMs = getRemainingMs(accessToken);
if (remainingMs > 0) {
setupRefreshTimer(silentRefresh, remainingMs);
}
// const remainingMs = getRemainingMs(accessToken);
// if (remainingMs > 0) {
// setupRefreshTimer(silentRefresh, remainingMs);
// }
} catch (error) {
console.error(
`[silentRefresh] Failed at ${new Date().toISOString()}`,
@@ -1,20 +0,0 @@
import { v4 as uuidv4 } from "uuid";
const DEVICE_ID_KEY = "x-device-id";
export function getOrCreateDeviceId(): string {
if (typeof window === "undefined") return "";
const existing = localStorage.getItem(DEVICE_ID_KEY);
if (existing) return existing;
const id = uuidv4();
localStorage.setItem(DEVICE_ID_KEY, id);
return id;
}
export function clearDeviceId(): void {
if (typeof window === "undefined") return;
localStorage.removeItem(DEVICE_ID_KEY);
}
+10 -9
View File
@@ -27,17 +27,18 @@
}
},
"include": [
".eslintrc.*",
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"**/*.mts",
"**/*.js",
"**/*.cjs",
"**/*.mjs",
"**/*.jsx",
"**/*.json",
".next/types/**/*.ts"
".next/types/**/*.ts",
"../../packages/common/types/**/*.d.ts"
],
"exclude": ["**/node_modules", "**/.*/*"]
"exclude": [
"node_modules",
".next",
"dist",
"coverage",
"storybook-static",
"public"
]
}
+1 -2
View File
@@ -19,7 +19,6 @@ NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
# NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /
#NEXT_PUBLIC_SERVER_API_URL = http://localhost:8883/operation-service
NEXT_PUBLIC_COOKIE_COMAIN=.mind-one.co.kr
REACT_EDITOR=atom
+1
View File
@@ -18,3 +18,4 @@ SEWERAGE_API_URL = http://con.mind-one.co.kr:11083/sewerage-service
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /
NEXT_PUBLIC_COOKIE_COMAIN=.mind-one.co.kr
+11 -2
View File
@@ -1,9 +1,10 @@
'use client'
import { NoFooterLayout } from "@common/components";
import { useQueryClient } from "@tanstack/react-query";
import type { MenuItem } from "../../store/common";
import { useFoStore } from "../../store";
import { usePathname, useRouter } from "next/navigation";
// import { authService } from "@/src/entities/auth";
import { authService } from "@/src/entities/auth";
export default function RootLayout({
children,
@@ -14,12 +15,20 @@ export default function RootLayout({
const { getMenuAuth } = useFoStore((state) => state);
const pathname = usePathname();
const router = useRouter();
const queryClient = useQueryClient();
const handleClick = (id: MenuItem) => {
getMenuAuth(id.menuCd);
router.push(id.urlPath);
};
return (
<NoFooterLayout currentPath={pathname ?? ""} handleMenuClick={handleClick}>{children}</NoFooterLayout>
<NoFooterLayout
queryClient={queryClient}
authService={authService}
currentPath={pathname ?? ""}
handleMenuClick={handleClick}
>
{children}
</NoFooterLayout>
);
}
+1 -2
View File
@@ -35,8 +35,7 @@
"react-hook-form": "^7.54.2",
"zod": "^3.24.2",
"axios": "^1.8.1",
"proj4": "^2.9.0",
"uuid": "^14.0.1"
"proj4": "^2.9.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@@ -6,18 +6,18 @@ import {
} from "@/src/shared/config";
import { getCookie } from "@/src/shared/lib";
import {
clearDeviceId,
getOrCreateDeviceId,
type AuthPayload,
type AuthService,
getRemainingMs,
} from "@common/auth";
import { clearDeviceId, getOrCreateDeviceId } from "../lib/device-id";
import {
clearRefreshTimer,
setToken,
setupRefreshTimer,
tokenManager,
} from "../lib/token-manager";
import { clearTabStorePersist } from "@/src/store/tab-store";
async function login(payload: AuthPayload): Promise<void> {
@@ -1,20 +0,0 @@
import { v4 as uuidv4 } from "uuid";
const DEVICE_ID_KEY = "x-device-id";
export function getOrCreateDeviceId(): string {
if (typeof window === "undefined") return "";
const existing = localStorage.getItem(DEVICE_ID_KEY);
if (existing) return existing;
const id = uuidv4();
localStorage.setItem(DEVICE_ID_KEY, id);
return id;
}
export function clearDeviceId(): void {
if (typeof window === "undefined") return;
localStorage.removeItem(DEVICE_ID_KEY);
}
+2
View File
@@ -17,3 +17,5 @@ SEWERAGE_API_URL = http://localhost:8883
DC_URL = https://apigateway.apps.rsonesoft.com
# NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login
# NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883
NEXT_PUBLIC_COOKIE_COMAIN=.mind-one.co.kr
+1
View File
@@ -14,3 +14,4 @@ NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&ver
SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
DC_URL = https://apigateway.apps.rsonesoft.com
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login
NEXT_PUBLIC_COOKIE_COMAIN=.mind-one.co.kr
+2
View File
@@ -20,6 +20,7 @@ import { NoFooterLayout } from "@common/components";
import type { MenuItem } from "@/entities/menu";
import { useMenuStore } from "@/shared/lib";
import useStore from '../store/useStore';
import { authService } from "@/entities/auth";
export default function MapLayout({
children,
@@ -57,6 +58,7 @@ export default function MapLayout({
return (
<NoFooterLayout
queryClient={queryClient}
authService={authService}
currentPath={pathname ?? ""}
handleMenuClick={handleMenuClick}
>
-1
View File
@@ -37,7 +37,6 @@
"react-hook-form": "^7.54.2",
"tailwindcss-animate": "^1.0.7",
"tus-js-client": "^4.3.1",
"uuid": "^14.0.1",
"xlsx": "^0.18.5",
"xlsx-populate": "^1.21.0",
"zod": "^3.24.2",
@@ -6,11 +6,12 @@ import {
} from "@/shared/config";
import { getCookie } from "@/shared/lib";
import {
clearDeviceId,
getOrCreateDeviceId,
type AuthPayload,
type AuthService,
getRemainingMs,
} from "@common/auth";
import { clearDeviceId, getOrCreateDeviceId } from "../lib/device-id";
import {
clearRefreshTimer,
setToken,
@@ -1,20 +0,0 @@
import { v4 as uuidv4 } from "uuid";
const DEVICE_ID_KEY = "x-device-id";
export function getOrCreateDeviceId(): string {
if (typeof window === "undefined") return "";
const existing = localStorage.getItem(DEVICE_ID_KEY);
if (existing) return existing;
const id = uuidv4();
localStorage.setItem(DEVICE_ID_KEY, id);
return id;
}
export function clearDeviceId(): void {
if (typeof window === "undefined") return;
localStorage.removeItem(DEVICE_ID_KEY);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import "@/app/styles/base.css";
export const metadata = {
title: "유역하수도 통합 정보플랫폼",
title: "하수도통계 관리시스템",
};
export default function AuthLayout({
@@ -9,7 +9,7 @@ import {
} from "@rsone/components";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { ENDPOINT, CLAIM_NAME } from "@/shared/config";
import { getCookie, useAlertStore, useMenuStore } from "@/shared/lib";
import { isBbsNoticeMenu } from "@/shared/lib/bbs-notice-menu";
import {
@@ -20,8 +20,10 @@ import { useStatisticsTabUrlSync } from "@/shared/lib/use-statistics-tab-url-syn
import { isVerificationSelectionReportMenu } from "@/shared/lib/verification-selection-report";
import { StatisticsPageLayout } from "@/shared/ui/statistics-page-layout";
import { authService } from "@/entities/auth";
import { getToken } from "@/entities/auth/lib/token-manager";
import type { MenuItem } from "@/entities/menu";
import { MenuApi } from "@/entities/menu";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import { BaseLayout } from "@common/components";
import { getConfig } from "@common/config";
@@ -93,6 +95,13 @@ function WithBaseLayoutInner({ children }: { children: React.ReactNode }) {
enabled: isLoggedIn,
});
useEffect(() => {
if (!getToken(CLAIM_NAME)) return;
void statisticsApi.refreshAllCommonCodes().catch(() => {
// 캐시 동기화 실패는 화면 진입을 막지 않음
});
}, []);
useEffect(() => {
if (menuListData) {
setMenuList(menuListData);
+2 -1
View File
@@ -1,8 +1,9 @@
import type { Metadata } from "next";
import { App } from "@/app";
export const metadata: Metadata = {
title: "유역하수도 통합 정보플랫폼",
title: "하수도통계 관리시스템",
};
export default App;
+10
View File
@@ -0,0 +1,10 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { COOKIE_DOMAIN, REFRESH_TOKEN } from "@/shared/config";
export async function POST() {
const cookieStore = await cookies();
cookieStore.delete({ name: REFRESH_TOKEN, domain: COOKIE_DOMAIN });
return NextResponse.json({ message: "Logged out" }, { status: 200 });
}
+2
View File
@@ -5,6 +5,7 @@ import {
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
COOKIE_DOMAIN,
REFRESH_TOKEN,
SERVER_API_URL,
SITE_ID,
@@ -75,6 +76,7 @@ async function handler(
cookieStore.set(REFRESH_TOKEN, refreshToken, {
httpOnly: true,
sameSite: "lax",
domain: COOKIE_DOMAIN,
path: "/",
});
}
+47
View File
@@ -0,0 +1,47 @@
import type { Metadata } from "next";
import Image from "next/image";
import "@/app/styles/base.css";
export const metadata: Metadata = {
title: "하수도통계 관리시스템",
description: "페이지가 존재하지 않습니다. 관리자에게 문의하여 주십시오.",
};
export default function GlobalNotFound() {
return (
<html lang="ko">
<body>
<main className="flex min-h-screen flex-col items-center justify-center bg-[#f0f4f8] px-4 py-16">
<div className="mb-16 text-center">
<h1 className="text-[3.2rem] font-bold leading-snug tracking-tight">
<span className="text-primary">
하수도통계 관리시스템
</span>
에 오신 것을 환영합니다.
</h1>
<p className="mt-6 text-[2rem] text-[#666]">
오류가 발생하였습니다.
</p>
</div>
<section className="card card-border flex w-full max-w-[72rem] flex-col items-center !rounded-2xl !py-16">
<Image
src="/images/magnifying-glass-with-warning.png"
alt=""
width={280}
height={280}
className="size-[28rem] object-contain"
/>
<p className="mt-4 text-[2.4rem] font-bold text-[#444]">
페이지가 존재하지 않습니다.
</p>
<p className="mt-2 text-[1.6rem] text-[#666]">
관리자에게 문의하여 주십시오
</p>
</section>
</main>
</body>
</html>
);
}
+4 -1
View File
@@ -4,7 +4,10 @@ const subDomain =
process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000";
const nextConfig: NextConfig = {
// output: "standalone",
output: "standalone",
experimental: {
globalNotFound: true,
},
async headers() {
return [
{
+1 -1
View File
@@ -41,6 +41,7 @@
"chart.js": "^4.4.8",
"chartjs-plugin-zoom": "^2.2.0",
"dayjs": "^1.11.13",
"decimal.js": "^10.6.0",
"js-cookie": "^3.0.5",
"jszip": "^3.10.1",
"lucide-react": "^0.474.0",
@@ -57,7 +58,6 @@
"swr": "^2.3.7",
"tailwindcss-animate": "^1.0.7",
"tus-js-client": "^4.3.1",
"uuid": "^14.0.1",
"xlsx": "^0.18.5",
"zod": "^3.24.2",
"zustand": "^5.0.3"
Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

+7 -5
View File
@@ -1,12 +1,14 @@
import { cookies } from "next/headers";
import Script from "next/script";
import "@fontsource-variable/noto-sans-kr";
import { AppProvider } from "./providers/app-provider";
import "./styles/base.css";
import "./styles/components.css";
import "@fontsource-variable/noto-sans-kr";
import "realgrid/dist/realgrid-style.css";
import "../components/css/DataGrid.css";
import { cookies } from "next/headers";
import { AppProvider } from "./providers/app-provider";
import Script from "next/script";
const App = async ({ children }: Readonly<{ children: React.ReactNode }>) => {
const theme = (await cookies()).get("site-theme-key");
+12 -10
View File
@@ -1,20 +1,22 @@
"use client";
import { DndProvider } from "react-dnd";
import { MultiBackend, getBackendOptions } from "@minoru/react-dnd-treeview";
import { ConfirmProvider } from "@frontend-opensource/use-react-hooks";
import { getBackendOptions,MultiBackend } from "@minoru/react-dnd-treeview";
import { AlertDialog as RsoneAlertDialog } from "@rsone/components";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ConfirmProvider } from "@frontend-opensource/use-react-hooks";
import { PromptProvider, StoreProvider } from "@/shared/providers";
import { AlertDialog } from "@/shared/ui/alert-dialog";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { GlobalProgressOverlay } from "@/shared/ui/global-progress-overlay";
import { PromptDialog } from "@/shared/ui/prompt-dialog";
import { SelectPromptDialog } from "@/shared/ui/select-prompt-dialog";
import { Toaster } from "@/shared/ui/sonner";
import { AuthWrapper } from "@/entities/auth";
import { MenuWrapper } from "@/entities/menu";
import { Toaster } from "@/shared/ui/sonner";
import { AlertDialog } from "@/shared/ui/alert-dialog";
import { PromptDialog } from "@/shared/ui/prompt-dialog";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { SelectPromptDialog } from "@/shared/ui/select-prompt-dialog";
import { GlobalProgressOverlay } from "@/shared/ui/global-progress-overlay";
import { PromptProvider, StoreProvider } from "@/shared/providers";
import { AlertDialog as RsoneAlertDialog } from "@rsone/components";
const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
const queryClient = new QueryClient();
@@ -1,6 +1,7 @@
import { Button } from "@common/components";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { Button } from "@common/components";
interface LastYearDataButtonProps {
getLastYearDataFunc: () => void;
}
@@ -1,6 +1,7 @@
import { Checkbox, buttonVariants, cn } from "@common/components";
import type React from "react";
import { buttonVariants, Checkbox, cn } from "@common/components";
interface NotApplicableCheckButtonProp {
isCheck: boolean;
setIsCheck: React.Dispatch<React.SetStateAction<boolean>>;
@@ -2,13 +2,15 @@ import { Button } from "@common/components";
interface SubmitButtonProps {
submitFunc: () => void;
/** 버튼 문구 (기본: 제출) */
text?: string;
}
const SubmitButton = ({ submitFunc }: SubmitButtonProps) => {
const SubmitButton = ({ submitFunc, text = "제출" }: SubmitButtonProps) => {
return (
<Button className="h-[3.2rem] w-[96px]" onClick={submitFunc}>
<Button className="h-[3.2rem] min-w-[96px] px-4" onClick={submitFunc}>
<i className="diveicon di-upload" />
<span>제출</span>
<span>{text}</span>
</Button>
);
};
+49
View File
@@ -1,5 +1,22 @@
@tailwind components;
/* RealGrid 기본은 white-space: nowrap → max-width만 주면 잘림. 너비 초과 시에만 줄바꿈 */
.rg-tooltip {
max-width: 60rem;
white-space: pre-line;
}
/* RealGrid Excel export — applyDynamicStyles가 class background-color를 읽을 수 있도록 */
.rg-cell-rowtype-biz-sum {
background-color: #ffe9cf !important;
}
.rg-cell-rowtype-summary {
background-color: #feee85 !important;
}
.rg-cell-rowtype-other {
background-color: #fff7bf !important;
}
@layer components {
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell {
@apply !bg-white;
@@ -8,6 +25,15 @@
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-readonly {
@apply !bg-[#f0f0f0] !text-[#666];
}
/* 고정 컬럼 — readonly와 동일한 화면 스타일 (엑셀 export 시 exportStyleName에서 제외) */
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-readonly,
.rg-grid .rg-body .rg-data-cell.rg-cell-readonly {
@apply !bg-[#f0f0f0] !text-[#666];
}
.rg-grid .rg-fixed-body .rg-table .rg-data-row td.rg-cell-readonly,
.rg-grid .rg-fixed-body .rg-data-row .rg-data-cell.rg-cell-readonly {
@apply !bg-[#f0f0f0] !text-[#666];
}
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-readonly.rg-cell-rowtype-curr {
@apply !bg-white !text-[#333];
}
@@ -17,6 +43,15 @@
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-verify-error-imr {
@apply !bg-[#fff7bf] !text-[#b45309];
}
/* 총계 footer 검증 오류 (commonInfo.verificationResult) */
.rg-grid .rg-footer .rg-table .rg-footer-cell.rg-cell-verify-error,
.rg-grid .rg-footers .rg-table .rg-footer-cell.rg-cell-verify-error {
@apply !bg-[#ffe6e6] !text-[#c00];
}
.rg-grid .rg-footer .rg-table .rg-footer-cell.rg-cell-verify-error-imr,
.rg-grid .rg-footers .rg-table .rg-footer-cell.rg-cell-verify-error-imr {
@apply !bg-[#fff7bf] !text-[#b45309];
}
/* 검증결과 내역: 처리여부(prcsYn 0·4 미처리 등) */
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-vrf-prcs-bad {
@apply !font-semibold !text-[#dc2626];
@@ -399,6 +434,13 @@
background-color: #ffe6e6 !important;
color: #c00 !important;
}
.rg-grid .rg-footer .rg-footer-cell.rg-cell-verify-error.rg-cell-verify-error,
.rg-grid .rg-footers .rg-footer-cell.rg-cell-verify-error.rg-cell-verify-error,
.rg-grid .rg-footer .rg-table .rg-footer-cell.rg-cell-verify-error.rg-cell-verify-error,
.rg-grid .rg-footers .rg-table .rg-footer-cell.rg-cell-verify-error.rg-cell-verify-error {
background-color: #ffe6e6 !important;
color: #c00 !important;
}
.rg-grid .rg-body .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr,
.rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr,
.rg-grid .rg-fixed-body .rg-data-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr,
@@ -406,6 +448,13 @@
background-color: #fff7bf !important;
color: #b45309 !important;
}
.rg-grid .rg-footer .rg-footer-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr,
.rg-grid .rg-footers .rg-footer-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr,
.rg-grid .rg-footer .rg-table .rg-footer-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr,
.rg-grid .rg-footers .rg-table .rg-footer-cell.rg-cell-verify-error-imr.rg-cell-verify-error-imr {
background-color: #fff7bf !important;
color: #b45309 !important;
}
/* dark theme에서도 검증 오류 색을 유지하고 싶다면 동일 색 유지(가독성 우선) */
[data-theme="black"] .rg-grid .rg-body .rg-data-cell.rg-cell-verify-error.rg-cell-verify-error,
@@ -1,10 +1,13 @@
import { useRef, useEffect, useLayoutEffect } from "react"
import { ValueType } from "realgrid"
import { useEffect, useLayoutEffect,useRef } from "react"
import { ValueType } from "realgrid"
import RealGrid, { GridFitStyle } from "realgrid"
import { RealGridReact, RGDataColumn } from "realgrid-react"
import type { DataGridProps, RealGridColumnInfo, GridViewWithSetColumns, FooterItemWithCallback, CellStyleCallback, CellStyleCallbackResult, CellStyleCallbackParams } from "../type/DataGrid.type"
import type { CellStyleCallback, CellStyleCallbackParams,CellStyleCallbackResult, DataGridProps, FooterItemWithCallback, GridViewWithSetColumns, RealGridColumnInfo } from "../type/DataGrid.type"
import { getComputedValueCallback } from "@/components/util/grid/computedFieldCallbacks"
import { wrapWithFormatTruncate } from "@/components/util/grid/decimalNumber"
function mergeCellStyleCallbackResult(
baseStyleName: string | undefined,
@@ -30,7 +33,7 @@ declare module "realgrid" {
setOptions(options: {
footer?: { visible?: boolean };
summaryMode?: 'aggregate' | 'statistical' | 'none';
checkBar?: { visible?: boolean };
checkBar?: { visible?: boolean; checkableOnly?: boolean };
}): void;
}
}
@@ -47,10 +50,11 @@ type GridViewWithCheckApi = GridViewWithSetColumns & {
checkItem?: (itemIndex: number, checked: boolean, exclusive?: boolean) => void;
checkRow?: (dataRow: number, checked: boolean, exclusive?: boolean) => void;
checkAll?: (checked: boolean) => void;
setCheckable?: (itemIndex: number, checkable: boolean) => void;
};
/** columnInfo → RealGrid DataField 배열 (computed → valueCallback 포함, setFields용) */
function buildFieldsFromColumns(columns: { fieldName: string; valueType?: ValueType; computed?: import("../type/DataGrid.type").ComputedFieldConfig }[]) {
function buildFieldsFromColumns(columns: { fieldName: string; valueType?: ValueType; dataFormat?: string; computed?: import("../type/DataGrid.type").ComputedFieldConfig }[]) {
return columns.map((col) => {
const field: { fieldName: string; dataType: string; valueCallback?: (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number } = {
fieldName: col.fieldName,
@@ -66,7 +70,7 @@ function buildFieldsFromColumns(columns: { fieldName: string; valueType?: ValueT
const hasSumOfMaxGroups = "groups" in col.computed && Array.isArray(col.computed.groups) && col.computed.groups.length > 0;
const hasSumPerTenThousand = col.computed.type === "sumPerTenThousand" && "numeratorFields" in col.computed && col.computed.numeratorFields.length > 0 && "denominatorField" in col.computed && Boolean(col.computed.denominatorField);
if (hasFields || hasNumeratorDenominator || hasMaxOfFieldAndSum || hasDifferenceOfSums || hasHalfSumOfSums || hasSumOfTwoHalfSumOfSums || hasSumOfMaxGroups || hasSumPerTenThousand) {
field.valueCallback = getComputedValueCallback(col.computed) as (prod: unknown, dataRow: number, fieldName: string, fieldNames: string[], values: unknown[]) => number;
field.valueCallback = wrapWithFormatTruncate(getComputedValueCallback(col.computed), col.dataFormat);
}
}
return field;
@@ -136,6 +140,7 @@ const CompactDataGrid = ({
onRowClick,
onItemChecked,
onItemAllChecked,
checkableCallback,
compactFullWidth = false,
compactGridHeightClass = "h-[55vh]",
headerHeight = 60,
@@ -145,9 +150,11 @@ const CompactDataGrid = ({
const columnsRef = useRef(columns);
const rowDataRef = useRef<Record<string, string | number>[]>(rowData);
const cellStyleCallbackRef = useRef<CellStyleCallback | undefined>(cellStyleCallback);
const checkableCallbackRef = useRef<DataGridProps["checkableCallback"]>(checkableCallback);
columnsRef.current = columns;
rowDataRef.current = rowData;
cellStyleCallbackRef.current = cellStyleCallback;
checkableCallbackRef.current = checkableCallback;
useLayoutEffect(() => {
if (!cellStyleCallback || !gridRef.current?.gridView) return;
@@ -176,8 +183,10 @@ const CompactDataGrid = ({
try {
checkGrid.checkAll?.(false);
rowData.forEach((row, itemIndex) => {
const checkable = checkableCallbackRef.current?.(row, itemIndex) ?? true;
checkGrid.setCheckable?.(itemIndex, checkable);
const checked = checkByRowFlag((row as Record<string, unknown>).__checked);
if (!checked) return;
if (!checked || !checkable) return;
if (checkGrid.checkItem) {
checkGrid.checkItem(itemIndex, true, false);
return;
@@ -274,7 +283,7 @@ const CompactDataGrid = ({
// 헤더 체크박스 표시 여부 설정
gridView.setOptions({
checkBar: { visible: showHeaderCheckbox },
checkBar: { visible: showHeaderCheckbox, checkableOnly: true },
rowIndicator: { visible: false },
});
@@ -306,6 +315,7 @@ const CompactDataGrid = ({
if (fixedColumns !== undefined && fixedColumns > 0) {
gridView.setFixedOptions({
colCount: fixedColumns,
resizable: true,
});
}
@@ -496,6 +506,7 @@ const CompactDataGrid = ({
compactFullWidth,
compactGridHeightClass,
headerHeight,
checkableCallback,
]);
// 행 클릭 이벤트
@@ -524,6 +535,17 @@ const CompactDataGrid = ({
if (onItemChecked) {
gridView.onItemChecked = (_grid, itemIndex, checked) => {
const row = rowDataRef.current[itemIndex];
if (
row &&
checkableCallbackRef.current &&
!checkableCallbackRef.current(row, itemIndex)
) {
if (checked) {
(_grid as GridViewWithCheckApi).checkItem?.(itemIndex, false, false);
}
return;
}
onItemChecked(itemIndex, checked);
};
} else {
File diff suppressed because it is too large Load Diff
@@ -4,7 +4,6 @@ import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback,
import { RealGridReact } from "realgrid-react";
import { getUserInfo } from "../../../../../packages/common/components/src/api";
import { useAlertStore } from "@/shared/lib";
import { statisticsApi } from "@/entities/statistics/api/statistics-service";
import type {
@@ -29,6 +28,8 @@ import {
Textarea,
} from "@common/components";
import { getUserInfo } from "../../../../../packages/common/components/src/api";
import DataGrid from "@/components/module/DataGrid";
import { VerificationResultHistoryPagination } from "@/components/module/VerificationResultHistoryPagination";
import type { CellStyleCallbackParams, ColumnInfo } from "@/components/type/DataGrid.type";
@@ -56,8 +57,8 @@ type ExceptionHistoryTabKey = "NV" | "IMR" | "HB";
const exceptionHistoryTabs: { value: ExceptionHistoryTabKey; label: string }[] = [
{ value: "NV", label: "일반검증 내역" },
{ value: "IMR", label: "I-MR 검증내역" },
{ value: "HB", label: "HB 검증내역" },
{ value: "IMR", label: "시계열 검증내역" },
{ value: "HB", label: "모집단 검증내역" },
];
const TITLE = "예외처리 내역 조회";
@@ -274,7 +275,7 @@ export function ExceptionProcessingHistoryDialog({
setImrPageCount(response?.data?.pageInfo?.totalPages ?? 1);
setImrPageIndex(pageIndex);
} catch {
setErrorMessage("I-MR 검증내역 조회 중 오류가 발생했습니다.");
setErrorMessage("시계열 검증내역 조회 중 오류가 발생했습니다.");
} finally {
setImrLoading(false);
}
@@ -293,7 +294,7 @@ export function ExceptionProcessingHistoryDialog({
setHbPageCount(response?.data?.pageInfo?.totalPages ?? 1);
setHbPageIndex(pageIndex);
} catch {
setErrorMessage("HB 검증내역 조회 중 오류가 발생했습니다.");
setErrorMessage("모집단 검증내역 조회 중 오류가 발생했습니다.");
} finally {
setHbLoading(false);
}
@@ -1,11 +1,13 @@
"use client";
import { useState } from "react";
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle } from "@common/components";
import SidoSggFacilityNameSearchForm from "./SidoSggFacilityNameSearchForm";
import type { SearchFormValues } from "../type/SearchForm.type";
import SidoSggFacilityNameSearchForm from "./SidoSggFacilityNameSearchForm";
type GridTopActionButtonProps = {
text: string;
onClick?: (helpers: { openDialog: (title?: string) => void; closeDialog: () => void }) => void;

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