Compare commits

...
40 Commits
Author SHA1 Message Date
jcchoi a323c42acf 테이블에 scope="col" 및 캡션 추가
Deploy Frontend / deploy (push) Successful in 4m16s
2026-10-01 22:15:11 +09:00
jcchoi 095101ad2f 필수항목 tooltip 2026-10-01 22:14:28 +09:00
jcchoi 7ee5ebd506 글자 확대 축소 2026-10-01 20:26:45 +09:00
jcchoi f90ef6297d 상단메뉴 클릭 표준 2026-10-01 18:03:03 +09:00
kiminchan 90c1cb1f52 Merge branch 'iipbs/sn/dev' 2026-10-01 16:40:10 +09:00
kiminchan e346417c29 Merge branch 'iipbs/sn/dev' 2026-10-01 10:15:58 +09:00
chanii8 c0c2d3d1c9 Merge remote-tracking branch 'origin/iipbs/as/dev' 2026-09-29 17:32:18 +09:00
chanii8 858844e81a [as] chore: 설계자문 진행상태 모델 수정 2026-09-29 16:58:01 +09:00
chanii8 1ff2731121 Merge branch 'iipbs/cs/dev' 2026-09-29 15:37:15 +09:00
chanii8 70db7662bb [cs] chore: 모듈API관리 폼 수정 2026-09-29 15:36:37 +09:00
chanii8 bb57ff72e7 [cs] feat: 모듈API관리 "API URL" 검색 조건 추가 2026-09-29 15:19:42 +09:00
chanii8 7946c08c72 Merge branch 'iipbs/as/dev' 2026-09-29 14:29:32 +09:00
chanii8 6f20ef7273 [as] feat: 보완 요청 사항 반영 (260929_1429) 2026-09-29 14:29:13 +09:00
mind-one005 657ae77dde Merge branch 'iipbs/sn/dev' 2026-09-29 14:03:36 +09:00
chanii8 78d8fea7f5 Merge branch 'iipbs/as/dev' 2026-09-28 16:44:05 +09:00
chanii8 22ed78c87b [as] feat: 행정지원 페이지 useSearchParamsState 적용 2026-09-28 16:37:20 +09:00
chanii8 3b914fbc2e [as] feat: 보완 요청 사항 반영 (20260928_1521) 2026-09-28 15:21:19 +09:00
kiminchan 533df0eb16 Merge branch 'iipbs/sn/dev' 2026-09-28 13:23:36 +09:00
chanii8 2b7c497c3e Merge branch 'iipbs/as/dev' 2026-09-28 11:20:57 +09:00
chanii8 4dee1309eb [as] feat: 보완 요청 사항 반영 (260928_1120) 2026-09-28 11:20:31 +09:00
kiminchan f3f8909e45 Merge branch 'iipbs/sn/dev' 2026-09-28 10:00:24 +09:00
chanii8 f5ce74d0ee Merge remote-tracking branch 'origin/iipbs/as/dev' 2026-09-23 14:39:57 +09:00
chanii8 3114280e83 [as] chore: 알림이력 배지 크기 조정 2026-09-23 14:39:24 +09:00
chanii8 c7b8b36f4a Merge branch 'iipbs/common/dev' 2026-09-23 14:17:15 +09:00
chanii8 60ee194cc9 [common/components] design: NoticePopover 스타일 수정 2026-09-23 13:55:16 +09:00
chanii8 db8ee298d9 Merge branch 'iipbs/cs/dev' 2026-09-23 11:40:04 +09:00
chanii8 9eef99a435 [cs] chore: 게시판 댓글 사용 여부 적용 2026-09-23 11:39:32 +09:00
chanii8 3d027ed374 [common/features] chore: 게시판 댓글 사용 여부 적용 2026-09-23 11:37:16 +09:00
chanii8 d70eb17a72 Merge branch 'iipbs/as/dev' 2026-09-23 09:50:30 +09:00
chanii8 ddd619babc [as] feat: 행정지원 문서 체크리스트 레이블 변경 2026-09-23 09:48:08 +09:00
chanii8 5d27ee830e [as] feat: 행정지원 문서 체크리스트 탭 노출 알고리즘 수정 2026-09-23 09:35:35 +09:00
mind-one005 212b9a43a9 Merge branch 'iipbs/wr/dev' 2026-09-22 20:49:40 +09:00
mind-one005 f092a23ee4 wr: font 크기 조정 2026-09-22 20:45:22 +09:00
chanii8 25c357a423 [as] chore: 보완요청 섹션 첨부파일 관련 코드 수정 2026-09-22 16:21:13 +09:00
chanii8 a77246a1db [as] fix: 타입 에러 2026-09-22 15:39:06 +09:00
chanii8 f00ce31648 [as] refactor: zod 스키마 .optional()-> .nullish() 변경 2026-09-22 15:12:28 +09:00
chanii8 dc57a491dd [as] chore: 재원협의 코드 수정 2026-09-22 15:02:36 +09:00
mind-one005 c9b7266959 wr: 재이용실적 수정 2026-09-22 11:24:32 +09:00
chanii8 0dd2111cb9 [as] refactor: 재원협의 폼 useWatch + Controller 방식 제거 2026-09-22 10:57:41 +09:00
chanii8 87fbf15e2b [as] feat: 재원협의 수정 2026-09-22 10:23:10 +09:00
97 changed files with 2013 additions and 1302 deletions
@@ -1,3 +1,11 @@
import { Suspense } from "react";
import { DesignListPage } from "@/pages/design-cnstn";
export default DesignListPage;
export default function Page() {
return (
<Suspense>
<DesignListPage />
</Suspense>
);
}
@@ -1,3 +1,11 @@
import { Suspense } from "react";
import { FnrsListPage } from "@/pages/fnrs-cnsltn";
export default FnrsListPage;
export default function Page() {
return (
<Suspense>
<FnrsListPage />
</Suspense>
);
}
@@ -1,3 +1,11 @@
import { Suspense } from "react";
import { InstlListPage } from "@/pages/instl-sncn";
export default InstlListPage;
export default function Page() {
return (
<Suspense>
<InstlListPage />
</Suspense>
);
}
@@ -1,3 +1,11 @@
import { Suspense } from "react";
import { SewerBscPlanListPage } from "@/pages/sewer-bsc-plan";
export default SewerBscPlanListPage;
export default function Page() {
return (
<Suspense>
<SewerBscPlanListPage />
</Suspense>
);
}
+1
View File
@@ -5,6 +5,7 @@ export {
useFindUserGugunCombo,
useFindUserSidoCombo,
} from "./api/queries";
export { useFindUserSidoGugunCombo } from "./lib/use-find-user-sido-gugun-combo";
export type {
CmCdAdstDstrComboResponseDto,
CmCdAdstDstrService,
@@ -0,0 +1,26 @@
import { useUserStore } from "@/shared/lib";
import { useFindUserGugunCombo, useFindUserSidoCombo } from "../api/queries";
import type { CmCdAdstDstrComboResponseDto } from "../model/adstdstr-interface";
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
export function useFindUserSidoGugunCombo(sidoComCd: string) {
const userInfo = useUserStore((state) => state.userInfo);
const sidoFilterUserInfo =
userInfo?.dtlInstCd === "ROLE_USER04" || userInfo?.dtlInstCd === "ROLE_USER05"
? userInfo
: undefined;
const { data: userSidoData, isPending: isUserSidoPending } =
useFindUserSidoCombo(sidoFilterUserInfo);
const { data: userGugunData, isPending: isUserGugunPending } =
useFindUserGugunCombo(sidoComCd);
return {
userSidoData: userSidoData ?? EMPTY_ADST_LIST,
userGugunData: userGugunData ?? EMPTY_ADST_LIST,
isUserSidoPending,
isUserGugunPending,
};
}
@@ -2,35 +2,35 @@ import { z } from "zod";
/** 백엔드 DcRagFileInfoDto */
export const dcRagFileInfoSchema = z.object({
fileId: z.string().optional(),
fileGroupId: z.string().optional(),
fileName: z.string().optional(),
physicalPath: z.string().optional(),
downloadUrl: z.string().optional(),
fileSize: z.number().optional(),
esntlAtchFileId: z.string().optional(),
esntlAtchFileNm: z.string().optional(),
fileId: z.string().nullish(),
fileGroupId: z.string().nullish(),
fileName: z.string().nullish(),
physicalPath: z.string().nullish(),
downloadUrl: z.string().nullish(),
fileSize: z.number().nullish(),
esntlAtchFileId: z.string().nullish(),
esntlAtchFileNm: z.string().nullish(),
});
/** 백엔드 DcRagDocumentTransferRequestDto */
export const dcRagDocumentTransferRequestSchema = z.object({
fnrsCnsltnDocNo: z.string(),
taskSeComCd: z.string().optional(),
taskSeCdNm: z.string().optional(),
essentialFiles: z.array(dcRagFileInfoSchema).optional(),
etcFiles: z.array(dcRagFileInfoSchema).optional(),
callbackUrl: z.string().optional(),
taskSeComCd: z.string().nullish(),
taskSeCdNm: z.string().nullish(),
essentialFiles: z.array(dcRagFileInfoSchema).nullish(),
etcFiles: z.array(dcRagFileInfoSchema).nullish(),
callbackUrl: z.string().nullish(),
});
/** 백엔드 DcRagDraftGenerateRequestDto */
export const dcRagDraftGenerateRequestSchema = z.object({
fnrsCnsltnDocNo: z.string(),
pwrkCnsltnDocNo: z.string(),
crtDmndUuid: z.string().optional(),
essentialFiles: z.array(dcRagFileInfoSchema).optional(),
etcFiles: z.array(dcRagFileInfoSchema).optional(),
rqstrUuid: z.string().optional(),
callbackUrl: z.string().optional(),
crtDmndUuid: z.string().nullish(),
essentialFiles: z.array(dcRagFileInfoSchema).nullish(),
etcFiles: z.array(dcRagFileInfoSchema).nullish(),
rqstrUuid: z.string().nullish(),
callbackUrl: z.string().nullish(),
});
/** 백엔드 DcRagCostVerifyRequestDto */
@@ -2,68 +2,68 @@ import { z } from "zod";
/** 백엔드 DesignCnstnMngSaveRequestDto */
export const designCnstnMngSaveSchema = z.object({
designCnstnDocNo: z.string().optional(),
admdstCd: z.string().optional(),
envoSeComCd: z.string().optional(),
taskSeComCd: z.string().optional(),
bizNm: z.string().optional(),
bizBgngYr: z.string().optional(),
bizEndYr: z.string().optional(),
bizCn: z.string().optional(),
aplcntNm: z.string().optional(),
aplcntMblTelno: z.string().optional(),
aplcntOgdpNm: z.string().optional(),
aplcntEmlAddr: z.string().optional(),
dsgnrNm: z.string().optional(),
dsgnrTelno: z.string().optional(),
dsgnrOgdpNm: z.string().optional(),
dsgnrEmlAddr: z.string().optional(),
atchFileGroupId: z.string().optional(),
prgrsSttsComCd: z.string().optional(),
designCnstnDocNo: z.string().nullish(),
admdstCd: z.string().nullish(),
envoSeComCd: z.string().nullish(),
taskSeComCd: z.string().nullish(),
bizNm: z.string().nullish(),
bizBgngYr: z.string().nullish(),
bizEndYr: z.string().nullish(),
bizCn: z.string().nullish(),
aplcntNm: z.string().nullish(),
aplcntMblTelno: z.string().nullish(),
aplcntOgdpNm: z.string().nullish(),
aplcntEmlAddr: z.string().nullish(),
dsgnrNm: z.string().nullish(),
dsgnrTelno: z.string().nullish(),
dsgnrOgdpNm: z.string().nullish(),
dsgnrEmlAddr: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
prgrsSttsComCd: z.string().nullish(),
});
/** 백엔드 DesignCnstnApplySaveRequestDto.BzScaleItem */
export const designCnstnBzScaleItemSchema = z.object({
cnstpSeCd: z.string().optional(),
bscPl: z.number().optional(),
budgetReflct: z.number().optional(),
thtiDsgn: z.number().optional(),
cnstpSeCd: z.string().nullish(),
bscPl: z.number().nullish(),
budgetReflct: z.number().nullish(),
thtiDsgn: z.number().nullish(),
});
/** 백엔드 DesignCnstnApplySaveRequestDto.BizCstItem */
export const designCnstnBizCstItemSchema = z.object({
acntgSeCd: z.string().optional(),
bscPlanAmt: z.number().optional(),
bgtRfltAmt: z.number().optional(),
tstmDesignAmt: z.number().optional(),
acntgSeCd: z.string().nullish(),
bscPlanAmt: z.number().nullish(),
bgtRfltAmt: z.number().nullish(),
tstmDesignAmt: z.number().nullish(),
});
/** 백엔드 DesignCnstnApplySaveRequestDto.EsntlAtchFileItem */
export const designCnstnEsntlAtchFileItemSchema = z.object({
esntlAtchFileId: z.string().optional(),
atchFileGroupId: z.string().optional(),
esntlAtchFileId: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 DesignCnstnRvwOpnnSaveRequestDto */
export const designCnstnRvwOpnnSaveSchema = z.object({
designCnstnDocNo: z.string(),
rvwOpnnSn: z.number().optional(),
opnrSeComCd: z.string().optional(),
opnrNm: z.string().optional(),
rvwRsltCn: z.string().optional(),
rvwRsltAnmlyYn: z.string().optional(),
atchFileGroupId: z.string().optional(),
rvwOpnnSn: z.number().nullish(),
opnrSeComCd: z.string().nullish(),
opnrNm: z.string().nullish(),
rvwRsltCn: z.string().nullish(),
rvwRsltAnmlyYn: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 DesignCnstnApplySaveRequestDto */
export const designCnstnApplySaveSchema = z.object({
techReViewSaveCd: z.string().optional(),
rvwStpCd: z.string().optional(),
techReViewSaveCd: z.string().nullish(),
rvwStpCd: z.string().nullish(),
designCnstnMng: designCnstnMngSaveSchema,
bzScaleList: z.array(designCnstnBzScaleItemSchema).optional(),
bizCstList: z.array(designCnstnBizCstItemSchema).optional(),
esntlAtchFileList: z.array(designCnstnEsntlAtchFileItemSchema).optional(),
rvwOpnnList: z.array(designCnstnRvwOpnnSaveSchema).optional(),
bzScaleList: z.array(designCnstnBzScaleItemSchema).nullish(),
bizCstList: z.array(designCnstnBizCstItemSchema).nullish(),
esntlAtchFileList: z.array(designCnstnEsntlAtchFileItemSchema).nullish(),
rvwOpnnList: z.array(designCnstnRvwOpnnSaveSchema).nullish(),
});
export type DesignCnstnMngSaveRequestDto = z.infer<typeof designCnstnMngSaveSchema>;
+1
View File
@@ -41,6 +41,7 @@ export {
validateAllRowsForSave,
validateAplnRow,
validateAtrzOrder,
validateUniqueAtrzAprvr,
validateUniqueSeqNo,
} from "./lib/apln-validators";
export {
@@ -76,3 +76,16 @@ export function validateUniqueSeqNo(rows: ElaprAplnRow[]): string | null {
}
return null;
}
export function validateUniqueAtrzAprvr(rows: ElaprAplnRow[]): string | null {
const pairSet = new Set<string>();
for (const row of getVisibleRows(rows)) {
const atrz = row.atrzSeComCd?.trim();
const aprvr = row.aprvrUuid?.trim();
if (!atrz || !aprvr) continue;
const key = `${atrz}::${aprvr}`;
if (pairSet.has(key)) return "동일한 결재구분과 결재자가 중복됩니다.";
pairSet.add(key);
}
return null;
}
@@ -7,8 +7,8 @@ export interface ElaprDmndSaveRequestDto {
elaprDocNo: string;
dmndTtl: string;
dmndCn: string;
elaprCycl?: number;
docPrgrsSttsComCd?: string;
elaprCycl?: number | null;
docPrgrsSttsComCd?: string | null;
elaprPrgrsSttsList?: ElaprPrgrsSttsSaveRequestDto[];
}
@@ -17,7 +17,7 @@ import {
} from "@common/components";
import { useElaprAplnPage } from "../api/queries";
import { validateAplnRow } from "../lib/apln-validators";
import { validateAplnRow, validateUniqueAtrzAprvr } from "../lib/apln-validators";
import { useAplnRows } from "../lib/use-apln-rows";
import { useDndSensors } from "../lib/use-dnd-sensors";
import type { ElaprApln } from "../model/elapr-apln-interface";
@@ -110,6 +110,12 @@ function ElaprAplnPickerGrid({
return;
}
const duplicateError = validateUniqueAtrzAprvr(rows);
if (duplicateError) {
alert(duplicateError);
return;
}
commitEdit(row.rowKey);
}
@@ -103,6 +103,8 @@ function ElaprApprovalDialog({
const currentUserPrgrs = findCurrentUserPendingApprovalRequest(cyclDocPrgrsList, currentUserUuid);
const canApprove = Boolean(currentUserPrgrs?.elaprPrgrsId);
const canReject =
currentUserPrgrs?.atrzSeComCd === "ATRZ03" || currentUserPrgrs?.atrzSeComCd === "ATRZ99";
const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined;
const isBusy = isProcessing || savePrgrsSttsMutation.isPending;
@@ -392,9 +394,11 @@ function ElaprApprovalDialog({
<Button onClick={handleApprove} disabled={isBusy}>
결재
</Button>
<Button color="red" onClick={handleReject} disabled={isBusy}>
반려
</Button>
{canReject && (
<Button color="red" onClick={handleReject} disabled={isBusy}>
반려
</Button>
)}
</div>
)}
</div>
@@ -17,6 +17,7 @@ import {
getVisibleRows,
validateAllRowsForSave,
validateAtrzOrder,
validateUniqueAtrzAprvr,
validateUniqueSeqNo,
} from "../lib/apln-validators";
import { mapDocPrgrsToAplnRows } from "../lib/doc-prgrs-to-apln-rows";
@@ -90,6 +91,12 @@ function ElaprDocPrgrsPickerDialog({
return;
}
const atrzAprvrError = validateUniqueAtrzAprvr(aplnRows);
if (atrzAprvrError) {
alert(atrzAprvrError);
return;
}
const visibleRows = getVisibleRows(aplnRows);
onConfirm?.({ aplnRows: visibleRows });
@@ -17,6 +17,7 @@ import {
getVisibleRows,
validateAllRowsForSave,
validateAtrzOrder,
validateUniqueAtrzAprvr,
validateUniqueSeqNo,
} from "../lib/apln-validators";
import type { ElaprAplnRow } from "../model/elapr-apln-row-types";
@@ -106,6 +107,12 @@ function ElaprTmpltPickerDialog({
return;
}
const atrzAprvrError = validateUniqueAtrzAprvr(aplnRows);
if (atrzAprvrError) {
alert(atrzAprvrError);
return;
}
const visibleRows = getVisibleRows(aplnRows);
const payload: ElaprTmpltPickerConfirmPayload = {
@@ -2,52 +2,52 @@ import { z } from "zod";
/** 백엔드 FnrsCnsltnMngSaveRequestDto */
export const fnrsCnsltnMngSaveSchema = z.object({
fnrsCnsltnDocNo: z.string().optional(),
admdstCd: z.string().optional(),
envoSeComCd: z.string().optional(),
taskSeComCd: z.string().optional(),
cfmtnBgtBizNo: z.string().optional(),
bgtAplyYr: z.string().optional(),
bizNm: z.string().optional(),
bizBgngYr: z.string().optional(),
bizEndYr: z.string().optional(),
bizCn: z.string().optional(),
aplcntNm: z.string().optional(),
aplcntMblTelno: z.string().optional(),
aplcntOgdpNm: z.string().optional(),
aplcntEmlAddr: z.string().optional(),
dsgnrNm: z.string().optional(),
dsgnrTelno: z.string().optional(),
dsgnrOgdpNm: z.string().optional(),
dsgnrEmlAddr: z.string().optional(),
atchFileGroupId: z.string().optional(),
sharAtchFileGroupId: z.string().optional(),
prgrsSttsComCd: z.string().optional(),
rtlztnFcltSeComCd: z.string().optional(),
rtlztnUsgSeComCd: z.string().optional(),
fnrsCnsltnDocNo: z.string().nullish(),
admdstCd: z.string().nullish(),
envoSeComCd: z.string().nullish(),
taskSeComCd: z.string().nullish(),
cfmtnBgtBizNo: z.string().nullish(),
bgtAplyYr: z.string().nullish(),
bizNm: z.string().nullish(),
bizBgngYr: z.string().nullish(),
bizEndYr: z.string().nullish(),
bizCn: z.string().nullish(),
aplcntNm: z.string().nullish(),
aplcntMblTelno: z.string().nullish(),
aplcntOgdpNm: z.string().nullish(),
aplcntEmlAddr: z.string().nullish(),
dsgnrNm: z.string().nullish(),
dsgnrTelno: z.string().nullish(),
dsgnrOgdpNm: z.string().nullish(),
dsgnrEmlAddr: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
sharAtchFileGroupId: z.string().nullish(),
prgrsSttsComCd: z.string().nullish(),
rtlztnFcltSeComCd: z.string().nullish(),
rtlztnUsgSeComCd: z.string().nullish(),
});
/** 백엔드 FnrsCnsltnApplySaveRequestDto.BzScaleItem */
export const fnrsCnsltnBzScaleItemSchema = z.object({
cnstpSeCd: z.string().optional(),
bscPl: z.number().optional(),
budgetReflct: z.number().optional(),
thtiDsgn: z.number().optional(),
cnstpSeCd: z.string().nullish(),
bscPl: z.number().nullish(),
budgetReflct: z.number().nullish(),
thtiDsgn: z.number().nullish(),
});
/** 백엔드 FnrsCnsltnApplySaveRequestDto.BizCstItem */
export const fnrsCnsltnBizCstItemSchema = z.object({
acntgSeCd: z.string().optional(),
bscPlanAmt: z.number().optional(),
bgtRfltAmt: z.number().optional(),
tstmDesignAmt: z.number().optional(),
acntgSeCd: z.string().nullish(),
bscPlanAmt: z.number().nullish(),
bgtRfltAmt: z.number().nullish(),
tstmDesignAmt: z.number().nullish(),
});
/** 백엔드 FnrsCnsltnApplySaveRequestDto.CklstResultItem */
export const fnrsCnsltnCklstResultItemSchema = z.object({
cklstRvwSn: z.number().optional(),
cklstArtclSn: z.number().optional(),
cklstSn: z.number().optional(),
cklstRvwSn: z.number().nullish(),
cklstArtclSn: z.number().nullish(),
cklstSn: z.number().nullish(),
rvwStpComCd: z.string().nullish(),
selected: z.enum(["Y", "N"]).nullish(),
picRvwCn: z.string().nullish(),
@@ -55,58 +55,58 @@ export const fnrsCnsltnCklstResultItemSchema = z.object({
/** 백엔드 FnrsCnsltnApplySaveRequestDto.EsntlAtchFileItem */
export const fnrsCnsltnEsntlAtchFileItemSchema = z.object({
esntlAtchFileId: z.string().optional(),
atchFileGroupId: z.string().optional(),
esntlAtchFileId: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 FnrsCnsltnApplySaveRequestDto.FnrsCnsltnSplmntDmndItem */
export const fnrsCnsltnSplmntDmndItemSchema = z.object({
splmntDmndSn: z.number().nullish(),
splmntArtclComCd: z.string().nullish(),
splmntDmndCn: z.string().optional(),
splmntDmndDt: z.string().optional(),
splmntDmndAtchFileGroupId: z.string().optional(),
splmntCn: z.string().optional(),
splmntPicNm: z.string().optional(),
splmntCmptnDt: z.string().optional(),
splmntAtchFileGroupId: z.string().optional(),
cmptnYn: z.string().optional(),
splmntDmndCn: z.string().nullish(),
splmntDmndDt: z.string().nullish(),
splmntDmndAtchFileGroupId: z.string().nullish(),
splmntCn: z.string().nullish(),
splmntPicNm: z.string().nullish(),
splmntCmptnDt: z.string().nullish(),
splmntAtchFileGroupId: z.string().nullish(),
cmptnYn: z.string().nullish(),
});
/** 백엔드 FnrsCnsltnRvwOpnnSaveRequestDto */
export const fnrsCnsltnRvwOpnnSaveSchema = z.object({
fnrsCnsltnDocNo: z.string(),
rvwOpnnSn: z.number().optional(),
opnrSeComCd: z.string().optional(),
opnrNm: z.string().optional(),
rvwRsltCn: z.string().optional(),
rvwRsltAnmlyYn: z.string().optional(),
atchFileGroupId: z.string().optional(),
rvwOpnnSn: z.number().nullish(),
opnrSeComCd: z.string().nullish(),
opnrNm: z.string().nullish(),
rvwRsltCn: z.string().nullish(),
rvwRsltAnmlyYn: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 FnrsCnsltnApplySaveRequestDto.FnrsSharItem */
export const fnrsSharItemSchema = z.object({
fnrsSharSeCd: z.string().optional(),
exstnAmt: z.number().optional(),
chgRqtAmt: z.number().optional(),
ajmtAmt: z.number().optional(),
rmrkCn: z.string().optional(),
envoRmrkCn: z.string().optional(),
fnrsSharSeCd: z.string().nullish(),
exstnAmt: z.number().nullish(),
chgRqtAmt: z.number().nullish(),
ajmtAmt: z.number().nullish(),
rmrkCn: z.string().nullish(),
envoRmrkCn: z.string().nullish(),
});
/** 백엔드 FnrsCnsltnApplySaveRequestDto */
export const fnrsCnsltnApplySaveSchema = z.object({
techReViewSaveCd: z.string().optional(),
rvwStpCd: z.string().optional(),
techReViewSaveCd: z.string().nullish(),
rvwStpCd: z.string().nullish(),
fnrsCnsltnMng: fnrsCnsltnMngSaveSchema,
bzScaleList: z.array(fnrsCnsltnBzScaleItemSchema).optional(),
bizCstList: z.array(fnrsCnsltnBizCstItemSchema).optional(),
cklstResultList: z.array(fnrsCnsltnCklstResultItemSchema).optional(),
cklstResultMap: z.record(z.array(fnrsCnsltnCklstResultItemSchema)).optional(),
esntlAtchFileList: z.array(fnrsCnsltnEsntlAtchFileItemSchema).optional(),
rvwOpnnList: z.array(fnrsCnsltnRvwOpnnSaveSchema).optional(),
splmntDmndList: z.array(fnrsCnsltnSplmntDmndItemSchema).optional(),
fnrsSharList: z.array(fnrsSharItemSchema).optional(),
bzScaleList: z.array(fnrsCnsltnBzScaleItemSchema).nullish(),
bizCstList: z.array(fnrsCnsltnBizCstItemSchema).nullish(),
cklstResultList: z.array(fnrsCnsltnCklstResultItemSchema).nullish(),
cklstResultMap: z.record(z.array(fnrsCnsltnCklstResultItemSchema)).nullish(),
esntlAtchFileList: z.array(fnrsCnsltnEsntlAtchFileItemSchema).nullish(),
rvwOpnnList: z.array(fnrsCnsltnRvwOpnnSaveSchema).nullish(),
splmntDmndList: z.array(fnrsCnsltnSplmntDmndItemSchema).nullish(),
fnrsSharList: z.array(fnrsSharItemSchema).nullish(),
});
export type FnrsCnsltnMngSaveRequestDto = z.infer<typeof fnrsCnsltnMngSaveSchema>;
@@ -2,12 +2,12 @@ import { z } from "zod";
/** 백엔드 FnrsExamineRequestDto */
export const fnrsExamineRequestSchema = z.object({
prgrsSttsComCd: z.string().optional(),
prgrsSttsComCd: z.string().nullish(),
});
/** 백엔드 EnvoActnReviewReqDto */
export const envoActnReviewReqSchema = z.object({
specialRqstCn: z.string().optional(),
specialRqstCn: z.string().nullish(),
});
export type FnrsExamineRequestDto = z.infer<typeof fnrsExamineRequestSchema>;
@@ -2,52 +2,52 @@ import { z } from "zod";
/** 백엔드 InstlSncnMngSaveRequestDto */
export const instlSncnMngSaveSchema = z.object({
instlSncnDocNo: z.string().optional(),
admdstCd: z.string().optional(),
envoSeComCd: z.string().optional(),
sncnSeComCd: z.string().optional(),
taskSeComCd: z.string().optional(),
bgtBizCd: z.string().optional(),
bgtAplyYr: z.string().optional(),
bizNm: z.string().optional(),
bizBgngYr: z.string().optional(),
bizEndYr: z.string().optional(),
bizCn: z.string().optional(),
aplcntNm: z.string().optional(),
aplcntMblTelno: z.string().optional(),
aplcntOgdpNm: z.string().optional(),
aplcntEmlAddr: z.string().optional(),
dsgnrNm: z.string().optional(),
dsgnrTelno: z.string().optional(),
dsgnrOgdpNm: z.string().optional(),
dsgnrEmlAddr: z.string().optional(),
atchFileGroupId: z.string().optional(),
prgrsSttsComCd: z.string().optional(),
rtlztnFcltSeComCd: z.string().optional(),
rtlztnUsgSeComCd: z.string().optional(),
instlSncnDocNo: z.string().nullish(),
admdstCd: z.string().nullish(),
envoSeComCd: z.string().nullish(),
sncnSeComCd: z.string().nullish(),
taskSeComCd: z.string().nullish(),
bgtBizCd: z.string().nullish(),
bgtAplyYr: z.string().nullish(),
bizNm: z.string().nullish(),
bizBgngYr: z.string().nullish(),
bizEndYr: z.string().nullish(),
bizCn: z.string().nullish(),
aplcntNm: z.string().nullish(),
aplcntMblTelno: z.string().nullish(),
aplcntOgdpNm: z.string().nullish(),
aplcntEmlAddr: z.string().nullish(),
dsgnrNm: z.string().nullish(),
dsgnrTelno: z.string().nullish(),
dsgnrOgdpNm: z.string().nullish(),
dsgnrEmlAddr: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
prgrsSttsComCd: z.string().nullish(),
rtlztnFcltSeComCd: z.string().nullish(),
rtlztnUsgSeComCd: z.string().nullish(),
});
/** 백엔드 InstlSncnApplySaveRequestDto.BzScaleItem (diff는 API 미전송) */
export const instlSncnBzScaleItemSchema = z.object({
cnstpSeCd: z.string().optional(),
bscPl: z.number().optional(),
budgetReflct: z.number().optional(),
thtiDsgn: z.number().optional(),
cnstpSeCd: z.string().nullish(),
bscPl: z.number().nullish(),
budgetReflct: z.number().nullish(),
thtiDsgn: z.number().nullish(),
});
/** 백엔드 InstlSncnApplySaveRequestDto.BizCstItem */
export const instlSncnBizCstItemSchema = z.object({
acntgSeCd: z.string().optional(),
bscPlanAmt: z.number().optional(),
bgtRfltAmt: z.number().optional(),
tstmDesignAmt: z.number().optional(),
acntgSeCd: z.string().nullish(),
bscPlanAmt: z.number().nullish(),
bgtRfltAmt: z.number().nullish(),
tstmDesignAmt: z.number().nullish(),
});
/** 백엔드 InstlSncnApplySaveRequestDto.CklstResultItem */
export const instlSncnCklstResultItemSchema = z.object({
cklstRvwSn: z.number().optional(),
cklstArtclSn: z.number().optional(),
cklstSn: z.number().optional(),
cklstRvwSn: z.number().nullish(),
cklstArtclSn: z.number().nullish(),
cklstSn: z.number().nullish(),
rvwStpComCd: z.string().nullish(),
selected: z.enum(["Y", "N"]).nullish(),
picRvwCn: z.string().nullish(),
@@ -55,47 +55,47 @@ export const instlSncnCklstResultItemSchema = z.object({
/** 백엔드 InstlSncnApplySaveRequestDto.EsntlAtchFileItem */
export const instlSncnEsntlAtchFileItemSchema = z.object({
esntlAtchFileId: z.string().optional(),
atchFileGroupId: z.string().optional(),
esntlAtchFileId: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 InstlSncnRvwOpnnSaveRequestDto */
export const instlSncnRvwOpnnSaveSchema = z.object({
instlSncnDocNo: z.string(),
rvwOpnnSn: z.number().optional(),
opnrSeComCd: z.string().optional(),
opnrNm: z.string().optional(),
rvwRsltCn: z.string().optional(),
rvwRsltAnmlyYn: z.string().optional(),
atchFileGroupId: z.string().optional(),
rvwOpnnSn: z.number().nullish(),
opnrSeComCd: z.string().nullish(),
opnrNm: z.string().nullish(),
rvwRsltCn: z.string().nullish(),
rvwRsltAnmlyYn: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 InstlSncnApplySaveRequestDto.InstlSncnSplmntDmndItem */
export const instlSncnSplmntDmndItemSchema = z.object({
splmntDmndSn: z.number().nullish(),
splmntArtclComCd: z.string().nullish(),
splmntDmndCn: z.string().optional(),
splmntDmndDt: z.string().optional(),
splmntDmndAtchFileGroupId: z.string().optional(),
splmntCn: z.string().optional(),
splmntPicNm: z.string().optional(),
splmntCmptnDt: z.string().optional(),
splmntAtchFileGroupId: z.string().optional(),
cmptnYn: z.string().optional(),
splmntDmndCn: z.string().nullish(),
splmntDmndDt: z.string().nullish(),
splmntDmndAtchFileGroupId: z.string().nullish(),
splmntCn: z.string().nullish(),
splmntPicNm: z.string().nullish(),
splmntCmptnDt: z.string().nullish(),
splmntAtchFileGroupId: z.string().nullish(),
cmptnYn: z.string().nullish(),
});
/** 백엔드 InstlSncnApplySaveRequestDto */
export const instlSncnApplySaveSchema = z.object({
techReViewSaveCd: z.string().optional(),
rvwStpCd: z.string().optional(),
techReViewSaveCd: z.string().nullish(),
rvwStpCd: z.string().nullish(),
instlSncnMng: instlSncnMngSaveSchema,
bzScaleList: z.array(instlSncnBzScaleItemSchema).optional(),
bizCstList: z.array(instlSncnBizCstItemSchema).optional(),
cklstResultList: z.array(instlSncnCklstResultItemSchema).optional(),
cklstResultMap: z.record(z.array(instlSncnCklstResultItemSchema)).optional(),
esntlAtchFileList: z.array(instlSncnEsntlAtchFileItemSchema).optional(),
rvwOpnnList: z.array(instlSncnRvwOpnnSaveSchema).optional(),
splmntDmndList: z.array(instlSncnSplmntDmndItemSchema).optional(),
bzScaleList: z.array(instlSncnBzScaleItemSchema).nullish(),
bizCstList: z.array(instlSncnBizCstItemSchema).nullish(),
cklstResultList: z.array(instlSncnCklstResultItemSchema).nullish(),
cklstResultMap: z.record(z.array(instlSncnCklstResultItemSchema)).nullish(),
esntlAtchFileList: z.array(instlSncnEsntlAtchFileItemSchema).nullish(),
rvwOpnnList: z.array(instlSncnRvwOpnnSaveSchema).nullish(),
splmntDmndList: z.array(instlSncnSplmntDmndItemSchema).nullish(),
});
export type InstlSncnMngSaveRequestDto = z.infer<typeof instlSncnMngSaveSchema>;
@@ -4,7 +4,7 @@ import { z } from "zod";
export const pwrkCnsltnDocCrtRsltSaveRequestSchema = z.object({
crtDmndUuid: z.string().uuid(),
pwrkCnsltnDocNo: z.string().min(1).max(100),
atchFileGroupId: z.string().max(30).optional(),
atchFileGroupId: z.string().max(30).nullish(),
});
export type PwrkCnsltnDocCrtRsltSaveRequestDto = z.infer<
@@ -3,10 +3,10 @@ import { z } from "zod";
/** 백엔드 PwrkCnsltnDocEvlSaveRequestDto */
export const pwrkCnsltnDocEvlSaveRequestSchema = z.object({
pwrkCnsltnDocNo: z.string().min(1),
pwrkCnsltnDocEvlUuid: z.string().uuid().optional(),
pwrkCnsltnDocEvlCn: z.string().max(2000).optional(),
pwrkCnsltnDocEvlScr: z.number().optional(),
pwrkCnsltnDocEvlDt: z.string().optional(),
pwrkCnsltnDocEvlUuid: z.string().uuid().nullish(),
pwrkCnsltnDocEvlCn: z.string().max(2000).nullish(),
pwrkCnsltnDocEvlScr: z.number().nullish(),
pwrkCnsltnDocEvlDt: z.string().nullish(),
});
export type PwrkCnsltnDocEvlSaveRequestDto = z.infer<typeof pwrkCnsltnDocEvlSaveRequestSchema>;
@@ -3,18 +3,18 @@ import { z } from "zod";
/** 백엔드 PwrkActnResultSubmitDto.ActnItemDto */
export const pwrkActnResultActnItemSchema = z.object({
rvwOpnnSn: z.number(),
actnRsltCn: z.string().optional(),
rfltYn: z.string().optional(),
atchFileGroupId: z.string().optional(),
actnRsltCn: z.string().nullish(),
rfltYn: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 PwrkActnResultSubmitDto */
export const pwrkActnResultSubmitSchema = z.object({
isSubmit: z.boolean(),
actnList: z.array(pwrkActnResultActnItemSchema).optional(),
totActnSummary: z.string().optional(),
muniDocNo: z.string().optional(),
pwrkAtchFileGroupId: z.string().optional(),
actnList: z.array(pwrkActnResultActnItemSchema).nullish(),
totActnSummary: z.string().nullish(),
muniDocNo: z.string().nullish(),
pwrkAtchFileGroupId: z.string().nullish(),
});
export type PwrkActnResultSubmitDto = z.infer<typeof pwrkActnResultSubmitSchema>;
@@ -2,50 +2,50 @@ import { z } from "zod";
/** 백엔드 SewerBscPlanSaveRequestDto */
export const sewerBscPlanSaveSchema = z.object({
sewerBscPlanDocNo: z.string().optional(),
admdstCd: z.string().optional(),
envoSeComCd: z.string().optional(),
sewerBscPlanSeComCd: z.string().optional(),
sewerBscPlanNm: z.string().optional(),
taskSeComCd: z.string().optional(),
prgrsSttsComCd: z.string().optional(),
sewerBscPlanDocNo: z.string().nullish(),
admdstCd: z.string().nullish(),
envoSeComCd: z.string().nullish(),
sewerBscPlanSeComCd: z.string().nullish(),
sewerBscPlanNm: z.string().nullish(),
taskSeComCd: z.string().nullish(),
prgrsSttsComCd: z.string().nullish(),
});
/** 백엔드 SewerBscPlanDtlSaveRequestDto */
export const sewerBscPlanDtlSaveSchema = z.object({
sewerBscPlanDocNo: z.string().optional(),
chgCycl: z.number().optional(),
aplcntNm: z.string().optional(),
aplcntMblTelno: z.string().optional(),
aplcntOgdpNm: z.string().optional(),
aplcntEmlAddr: z.string().optional(),
dsgnrNm: z.string().optional(),
dsgnrTelno: z.string().optional(),
dsgnrOgdpNm: z.string().optional(),
dsgnrEmlAddr: z.string().optional(),
planYr: z.string().optional(),
crtrYr: z.string().optional(),
bizOtlnCn: z.string().optional(),
frlvlGoalYr: z.string().optional(),
sclvlGoalYr: z.string().optional(),
thlvlGoalYr: z.string().optional(),
folvlGoalYr: z.string().optional(),
atchFileGroupId: z.string().optional(),
sewerBscPlanDocNo: z.string().nullish(),
chgCycl: z.number().nullish(),
aplcntNm: z.string().nullish(),
aplcntMblTelno: z.string().nullish(),
aplcntOgdpNm: z.string().nullish(),
aplcntEmlAddr: z.string().nullish(),
dsgnrNm: z.string().nullish(),
dsgnrTelno: z.string().nullish(),
dsgnrOgdpNm: z.string().nullish(),
dsgnrEmlAddr: z.string().nullish(),
planYr: z.string().nullish(),
crtrYr: z.string().nullish(),
bizOtlnCn: z.string().nullish(),
frlvlGoalYr: z.string().nullish(),
sclvlGoalYr: z.string().nullish(),
thlvlGoalYr: z.string().nullish(),
folvlGoalYr: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanOtlnItem */
export const sewerBscPlanOtlnItemSchema = z.object({
planStpNo: z.number().optional(),
sewerPlanPpltnCnt: z.number().optional(),
sewerPrcsPpltnCnt: z.number().optional(),
sewerPsplyRt: z.number().optional(),
planStpNo: z.number().nullish(),
sewerPlanPpltnCnt: z.number().nullish(),
sewerPrcsPpltnCnt: z.number().nullish(),
sewerPsplyRt: z.number().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.CklstResultItem */
export const sewerBscPlanCklstResultItemSchema = z.object({
cklstRvwSn: z.number().optional(),
cklstArtclSn: z.number().optional(),
cklstSn: z.number().optional(),
cklstRvwSn: z.number().nullish(),
cklstArtclSn: z.number().nullish(),
cklstSn: z.number().nullish(),
rvwStpComCd: z.string(),
selected: z.enum(["Y", "N"]).nullish(),
picRvwCn: z.string().nullish(),
@@ -53,109 +53,109 @@ export const sewerBscPlanCklstResultItemSchema = z.object({
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanFnrsCstItem */
export const sewerBscPlanFnrsCstItemSchema = z.object({
planStpNo: z.number().optional(),
gvexpAmt: z.number().optional(),
lcexpAmt: z.number().optional(),
orgprBdchgAmt: z.number().optional(),
etcAmt: z.number().optional(),
sumAmt: z.number().optional(),
planStpNo: z.number().nullish(),
gvexpAmt: z.number().nullish(),
lcexpAmt: z.number().nullish(),
orgprBdchgAmt: z.number().nullish(),
etcAmt: z.number().nullish(),
sumAmt: z.number().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanSplmntDmndItem */
export const sewerBscPlanSplmntDmndItemSchema = z.object({
splmntDmndSn: z.number().nullish(),
splmntArtclComCd: z.string().nullish(),
splmntDmndCn: z.string().optional(),
splmntDmndDt: z.string().optional(),
splmntDmndAtchFileGroupId: z.string().optional(),
splmntCn: z.string().optional(),
splmntPicNm: z.string().optional(),
splmntCmptnDt: z.string().optional(),
splmntAtchFileGroupId: z.string().optional(),
cmptnYn: z.string().optional(),
splmntDmndCn: z.string().nullish(),
splmntDmndDt: z.string().nullish(),
splmntDmndAtchFileGroupId: z.string().nullish(),
splmntCn: z.string().nullish(),
splmntPicNm: z.string().nullish(),
splmntCmptnDt: z.string().nullish(),
splmntAtchFileGroupId: z.string().nullish(),
cmptnYn: z.string().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanRvwOpnnItem */
export const sewerBscPlanRvwOpnnItemSchema = z.object({
rvwOpnnSn: z.number().optional(),
opnrSeComCd: z.string().optional(),
opnrNm: z.string().optional(),
rvwRsltCn: z.string().optional(),
rvwRsltAnmlyYn: z.string().optional(),
atchFileGroupId: z.string().optional(),
rvwOpnnSn: z.number().nullish(),
opnrSeComCd: z.string().nullish(),
opnrNm: z.string().nullish(),
rvwRsltCn: z.string().nullish(),
rvwRsltAnmlyYn: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.EsntlAtchFileItem */
export const sewerBscPlanEsntlAtchFileItemSchema = z.object({
esntlAtchFileId: z.string().optional(),
atchFileGroupId: z.string().optional(),
esntlAtchFileId: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanBizCstItem */
export const sewerBscPlanBizCstItemSchema = z.object({
planStpNo: z.number().optional(),
acntgSeCd: z.string().optional(),
cstrnAmt: z.number().optional(),
planStpNo: z.number().nullish(),
acntgSeCd: z.string().nullish(),
cstrnAmt: z.number().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanTmplntDtlItem */
export const sewerBscPlanTmplntDtlItemSchema = z.object({
planStpNo: z.number().optional(),
swtrmtFcltCd: z.string().optional(),
prcsZoneArea: z.number().optional(),
swtrmtPpltnCnt: z.number().optional(),
planSwqty: z.number().optional(),
fcltCpct: z.number().optional(),
sprsysDrtwPplLen: z.number().optional(),
sprsysExlnPplLen: z.number().optional(),
cbsysPplLen: z.number().optional(),
icptgPplLen: z.number().optional(),
drgCnt: z.number().optional(),
drtwRlyPumpCnt: z.number().optional(),
drtwPumpCnt: z.number().optional(),
exlnPumpCnt: z.number().optional(),
planStpNo: z.number().nullish(),
swtrmtFcltCd: z.string().nullish(),
prcsZoneArea: z.number().nullish(),
swtrmtPpltnCnt: z.number().nullish(),
planSwqty: z.number().nullish(),
fcltCpct: z.number().nullish(),
sprsysDrtwPplLen: z.number().nullish(),
sprsysExlnPplLen: z.number().nullish(),
cbsysPplLen: z.number().nullish(),
icptgPplLen: z.number().nullish(),
drgCnt: z.number().nullish(),
drtwRlyPumpCnt: z.number().nullish(),
drtwPumpCnt: z.number().nullish(),
exlnPumpCnt: z.number().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanTmplntItem */
export const sewerBscPlanTmplntItemSchema = z.object({
swtrmtFcltCd: z.string().optional(),
atchFileGroupId: z.string().optional(),
sewerBscPlanTmplntDtlList: z.array(sewerBscPlanTmplntDtlItemSchema).optional(),
swtrmtFcltCd: z.string().nullish(),
atchFileGroupId: z.string().nullish(),
sewerBscPlanTmplntDtlList: z.array(sewerBscPlanTmplntDtlItemSchema).nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto.SewerBscPlanTmplntTmprStrgItem */
export const sewerBscPlanTmplntTmprStrgItemSchema = z.object({
dtStrgDt: z.string().optional(),
swtrmtFcltCd: z.string().optional(),
prcsZoneArea: z.number().optional(),
swtrmtPpltnCnt: z.number().optional(),
planSwqty: z.number().optional(),
fcltCpct: z.number().optional(),
sprsysDrtwPplLen: z.number().optional(),
sprsysExlnPplLen: z.number().optional(),
cbsysPplLen: z.number().optional(),
icptgPplLen: z.number().optional(),
drgCnt: z.number().optional(),
drtwRlyPumpCnt: z.number().optional(),
drtwPumpCnt: z.number().optional(),
exlnPumpCnt: z.number().optional(),
dtStrgDt: z.string().nullish(),
swtrmtFcltCd: z.string().nullish(),
prcsZoneArea: z.number().nullish(),
swtrmtPpltnCnt: z.number().nullish(),
planSwqty: z.number().nullish(),
fcltCpct: z.number().nullish(),
sprsysDrtwPplLen: z.number().nullish(),
sprsysExlnPplLen: z.number().nullish(),
cbsysPplLen: z.number().nullish(),
icptgPplLen: z.number().nullish(),
drgCnt: z.number().nullish(),
drtwRlyPumpCnt: z.number().nullish(),
drtwPumpCnt: z.number().nullish(),
exlnPumpCnt: z.number().nullish(),
});
/** 백엔드 SewerBscPlanApplySaveRequestDto */
export const sewerBscPlanApplySaveSchema = z.object({
techReViewSaveCd: z.string().optional(),
rvwStpCd: z.string().optional(),
techReViewSaveCd: z.string().nullish(),
rvwStpCd: z.string().nullish(),
sewerBscPlan: sewerBscPlanSaveSchema,
sewerBscPlanDtl: sewerBscPlanDtlSaveSchema,
sewerBscPlanOtlnList: z.array(sewerBscPlanOtlnItemSchema).optional(),
cklstResultList: z.array(sewerBscPlanCklstResultItemSchema).optional(),
fnrsCstList: z.array(sewerBscPlanFnrsCstItemSchema).optional(),
splmntDmndList: z.array(sewerBscPlanSplmntDmndItemSchema).optional(),
rvwOpnnList: z.array(sewerBscPlanRvwOpnnItemSchema).optional(),
esntlAtchFileList: z.array(sewerBscPlanEsntlAtchFileItemSchema).optional(),
bizCstList: z.array(sewerBscPlanBizCstItemSchema).optional(),
sewerBscPlanTmplntList: z.array(sewerBscPlanTmplntItemSchema).optional(),
tmplntTmprStrgList: z.array(sewerBscPlanTmplntTmprStrgItemSchema).optional(),
sewerBscPlanOtlnList: z.array(sewerBscPlanOtlnItemSchema).nullish(),
cklstResultList: z.array(sewerBscPlanCklstResultItemSchema).nullish(),
fnrsCstList: z.array(sewerBscPlanFnrsCstItemSchema).nullish(),
splmntDmndList: z.array(sewerBscPlanSplmntDmndItemSchema).nullish(),
rvwOpnnList: z.array(sewerBscPlanRvwOpnnItemSchema).nullish(),
esntlAtchFileList: z.array(sewerBscPlanEsntlAtchFileItemSchema).nullish(),
bizCstList: z.array(sewerBscPlanBizCstItemSchema).nullish(),
sewerBscPlanTmplntList: z.array(sewerBscPlanTmplntItemSchema).nullish(),
tmplntTmprStrgList: z.array(sewerBscPlanTmplntTmprStrgItemSchema).nullish(),
});
export type SewerBscPlanSaveRequestDto = z.infer<typeof sewerBscPlanSaveSchema>;
@@ -23,8 +23,8 @@ export const designCnstnRegistSchema = z.object({
.string()
.min(1, "설계자 이메일은 필수입니다")
.email("올바른 이메일 형식이 아닙니다"),
sido: z.string().optional(),
gugun: z.string().optional(),
sido: z.string().nullish(),
gugun: z.string().nullish(),
}),
bzScaleList: z.array(
designCnstnBzScaleItemSchema.extend({
@@ -32,7 +32,7 @@ export const designCnstnRegistSchema = z.object({
bscPl: z.number(),
budgetReflct: z.number(),
thtiDsgn: z.number(),
diff: z.number().optional(),
diff: z.number().nullish(),
}),
),
esntlAtchFileList: z.array(
@@ -52,7 +52,7 @@ export function formatTaskSeComCdForApi(taskSeComCd: string[]): string {
return taskSeComCd.join(",");
}
export function parseTaskSeComCdFromApi(taskSeComCd?: string): string[] {
export function parseTaskSeComCdFromApi(taskSeComCd?: string | null): string[] {
if (!taskSeComCd) return [];
return taskSeComCd.split(",").filter(Boolean);
}
@@ -17,8 +17,8 @@ const MAIN_STEP_LABELS = {
} as const;
export const PROGRESS_STATUS_COLUMNS = [
{ label: "설계자문 신청 (시군)", sttsComCd: "ER" },
{ label: "기술검토", sttsComCd: "TE" },
{ label: "설계자문 신청 (시군)", sttsComCd: "CT" },
{ label: "기술검토", sttsComCd: "CS" },
{ label: "기술검토 회신", sttsComCd: "CP" },
];
@@ -38,7 +38,7 @@ function buildProgressBadges(prgrsSttsComCd: string): ProgressBadge[] {
];
}
if (prgrsSttsComCd === "ER" || prgrsSttsComCd === "CS" || prgrsSttsComCd === "SL") {
if (prgrsSttsComCd === "CT" || prgrsSttsComCd === "CS" || prgrsSttsComCd === "SL") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "current" },
@@ -61,6 +61,13 @@ function buildProgressBadges(prgrsSttsComCd: string): ProgressBadge[] {
];
}
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>;
}
return null;
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
@@ -72,7 +79,7 @@ function getBadgeColor(state: BadgeState) {
}
}
function formatOptional(value: string | undefined) {
function formatOptional(value: string | null | undefined) {
return value?.trim() ? value : "-";
}
@@ -110,7 +117,11 @@ export function createDesignCnstnColumns() {
columnHelper.accessor("bizNm", {
header: "사업명",
meta: { align: "left" },
cell: ({ getValue }) => formatOptional(getValue()),
cell: ({ row, getValue }) => (
<>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
</>
),
}),
columnHelper.accessor("taskSeCdNm", {
header: "업무구분",
@@ -8,7 +8,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
import {
type DesignCnstnApplySaveRequestDto,
designCnstnKeys,
@@ -103,11 +103,11 @@ function FloatingActionButtons({
isRegister && actionButtons.some((button) => button === DESIGN_CNSTN_VIEW_BUTTON_ACTION.strg);
function handleGoToList() {
router.push("/design-cnstn");
router.push(getListHref("/design-cnstn"));
}
function handleEdit() {
router.push(`/design-cnstn/${designCnstnDocNo}/edit`);
router.push(withListQs(`/design-cnstn/${designCnstnDocNo}/edit`));
}
function shouldShowButton(action: DesignCnstnViewButtonAction) {
@@ -177,7 +177,7 @@ function FloatingActionButtons({
action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.del,
successMessage: "삭제되었습니다.",
});
router.push("/design-cnstn");
router.push(getListHref("/design-cnstn"));
}
function getReviewOpinionFormData() {
@@ -8,8 +8,8 @@ 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 { RhfDevTool, SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import {
designCnstnKeys,
type DesignCnstnMngDetResponseDto,
@@ -26,7 +26,10 @@ import {
} from "@/widgets/pbadms-sprt";
import { Button } from "@common/components";
import { type DesignCnstnRegistFormData, designCnstnRegistSchema } from "../lib/design-cnstn-regist-schema";
import {
type DesignCnstnRegistFormData,
designCnstnRegistSchema,
} from "../lib/design-cnstn-regist-schema";
import {
buildFormPayload,
getCreateDefaultValues,
@@ -91,7 +94,7 @@ function DesignFormPage({ mode, initialData, createDefaults }: DesignFormPagePro
alert("저장되었습니다.");
queryClient.invalidateQueries({ queryKey: designCnstnKeys.all });
router.push(`/design-cnstn/${designCnstnDocNo}/edit`);
router.push(withListQs(`/design-cnstn/${designCnstnDocNo}/edit`));
} catch (error) {
console.error("저장 실패:", error);
alert("저장 중 오류가 발생했습니다.");
@@ -115,7 +118,7 @@ function DesignFormPage({ mode, initialData, createDefaults }: DesignFormPagePro
alert("신청 완료되었습니다.");
queryClient.invalidateQueries({ queryKey: designCnstnKeys.all });
router.push(`/design-cnstn/${designCnstnDocNo}`);
router.push(withListQs(`/design-cnstn/${designCnstnDocNo}`));
} catch (error) {
console.error("신청 실패:", error);
alert("신청 중 오류가 발생했습니다.");
@@ -144,7 +147,7 @@ function DesignFormPage({ mode, initialData, createDefaults }: DesignFormPagePro
await designCnstnMngService.delete(docNo);
alert("삭제되었습니다.");
queryClient.invalidateQueries({ queryKey: designCnstnKeys.list() });
router.push("/design-cnstn");
router.push(getListHref("/design-cnstn"));
} catch (error) {
console.error("삭제 실패:", error);
alert("삭제 중 오류가 발생했습니다.");
@@ -184,7 +187,7 @@ function DesignFormPage({ mode, initialData, createDefaults }: DesignFormPagePro
<Button
color="white"
type="button"
onClick={() => router.push("/design-cnstn")}
onClick={() => router.push(getListHref("/design-cnstn"))}
disabled={isSubmitting}
>
목록
@@ -217,7 +220,6 @@ function DesignFormPage({ mode, initialData, createDefaults }: DesignFormPagePro
</div>
</SectionCollapsibleGroup>
</form>
<RhfDevTool control={form.control} />
</FormProvider>
);
}
@@ -1,14 +1,11 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import type { PaginationState } from "@tanstack/react-table";
import {
type CmCdAdstDstrComboResponseDto,
useFindGugunCombo,
useFindSidoCombo,
} from "@/entities/adstdstr";
import { useSearchParamsState } from "@/shared/lib";
import { useFindUserSidoGugunCombo } from "@/entities/adstdstr";
import { CodeSelect } from "@/entities/code";
import type { DesignCnstnMngSearchDto } from "@/entities/design-cnstn";
import {
@@ -27,48 +24,58 @@ 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 DEFAULT_PARAMS = {
sidoComCd: "",
sggComCd: "",
taskSeComCd: "",
bizNm: "",
page: DEFAULT_PAGE_INDEX,
size: DEFAULT_PAGE_SIZE,
};
const { data: sidoData } = useFindSidoCombo();
const { data: gugunData } = useFindGugunCombo(sidoComCd);
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
function DesignListPage() {
const { params, setParams } = useSearchParamsState(DEFAULT_PARAMS);
const [bizNm, setBizNm] = useState(params.bizNm);
const [taskSeComCd, setTaskSeComCd] = useState(params.taskSeComCd);
const [sidoComCd, setSidoComCd] = useState(params.sidoComCd);
const [sggComCd, setSggComCd] = useState(params.sggComCd);
const { userSidoData, userGugunData } = useFindUserSidoGugunCombo(sidoComCd);
const pagination: PaginationState = {
pageIndex: params.page,
pageSize: params.size,
};
const searchParams: DesignCnstnMngSearchDto = useMemo(
() => ({
bizNm: params.bizNm,
taskSeComCd: params.taskSeComCd,
admdstCd: params.sggComCd || params.sidoComCd || "",
page: params.page,
size: params.size,
sort: DEFAULT_SORT,
}),
[params],
);
const handleSearch = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setPagination((prev) => ({ ...prev, pageIndex: DEFAULT_PAGE_INDEX }));
setSearchParams({
...DEFAULT_SEARCH_PARAMS,
setParams({
bizNm,
taskSeComCd,
admdstCd: sggComCd || sidoComCd || "",
sidoComCd,
sggComCd,
page: DEFAULT_PAGE_INDEX,
size: pagination.pageSize,
});
};
const handlePaginationChange = (next: PaginationState) => {
setParams({ page: next.pageIndex, size: next.pageSize });
};
return (
<div className="space-y-4 px-[1.8rem] pb-6">
<section className="card card-border mx-0 bg-background">
@@ -89,7 +96,7 @@ function DesignListPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
{sidoOptions.map((option) => (
{userSidoData.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
@@ -111,7 +118,7 @@ function DesignListPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
{gugunOptions.map((option) => (
{userGugunData.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
@@ -143,7 +150,7 @@ function DesignListPage() {
<DesignList
searchParams={searchParams}
pagination={pagination}
onPaginationChange={setPagination}
onPaginationChange={handlePaginationChange}
/>
</div>
);
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { useDataTable } from "@/shared/lib";
import { useDataTable, withListQs } from "@/shared/lib";
import {
type DesignCnstnMngResponseDto,
type DesignCnstnMngSearchDto,
@@ -43,7 +43,7 @@ function DesignList({ searchParams, pagination, onPaginationChange }: DesignList
const columns = useMemo(() => createDesignCnstnColumns(), []);
const handleRowClick = (row: DesignCnstnMngResponseDto) => {
router.push(`/design-cnstn/${row.designCnstnDocNo}`);
router.push(withListQs(`/design-cnstn/${row.designCnstnDocNo}`));
};
const { tableComponent } = useDataTable({
@@ -83,7 +83,9 @@ function DesignViewPage({ id }: DesignViewPageProps) {
return (
<div className="mt-4">
<SectionCollapsibleGroup>
<SectionCollapsibleToggle />
<div className="sticky top-[6rem] z-30 bg-container px-4 py-2">
<SectionCollapsibleToggle />
</div>
<div className="space-y-4">
<ProgressStatusSection
columns={PROGRESS_STATUS_COLUMNS}
@@ -7,15 +7,7 @@ import { SectionCollapsible } from "@/shared/ui";
import { Field, FieldError, FieldGroup } from "@/shared/ui/field";
import { findCodeLabel, useCodeList } from "@/entities/code";
import { type BnsApplyData, BnsPickerDialog } from "@/widgets/bns-picker";
import {
Button,
Input,
Table,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import { Input, Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
import type { DesignCnstnRegistFormData } from "../../lib/design-cnstn-regist-schema";
@@ -69,9 +61,9 @@ function BasicInfoFormSection() {
aria-invalid={fieldState.invalid}
className="w-[40rem]"
/>
<Button type="button" onClick={() => setIsPickerOpen(true)}>
{/* <Button type="button" onClick={() => setIsPickerOpen(true)}>
사업찾기
</Button>
</Button> */}
</div>
<FieldError errors={[fieldState.error]} />
</Field>
@@ -51,14 +51,19 @@ const ROLE_STATUS_BUTTON_ACTIONS: Record<string, Record<string, FnrsCnsltnViewBu
LE: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply, FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstTechRvw],
TP: [
FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwOpnnRply,
],
RB: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
RA: [
FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstActnRvw,
],
AV: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
EV: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
RK: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
RR: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng, FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply],
EP: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
TA: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
},
};
@@ -4,7 +4,6 @@ import {
type ChecklistFormState,
type ChecklistProgress,
type ChecklistRowKey,
type ChecklistStepMap,
REVIEW_STEP_CODES,
type ReviewStepCode,
} from "../model/view-types";
@@ -17,15 +16,6 @@ export function buildChecklistRowKey(
return `${row.taskSeComCd}_${row.cklstSn}_${row.cklstArtclSn}_${row.cklstRvwSn}_${step}`;
}
export function groupByReviewStep(rows: CklstRvwRsltTreeDto[]): ChecklistStepMap {
const base: ChecklistStepMap = { LOCAL: [], ENV: [], KECO: [] };
rows.forEach((row) => {
const step = (row.rvwStpComCd ?? "LOCAL") as ReviewStepCode;
if (REVIEW_STEP_CODES.includes(step)) base[step].push(row);
});
return base;
}
export function extractTaskCodes(rows: CklstRvwRsltTreeDto[]): string[] {
return Array.from(new Set(rows.map((row) => row.taskSeComCd).filter(Boolean)));
}
@@ -70,13 +60,6 @@ export function computeChecklistProgress(
return { total, completed, percent };
}
export function filterChecklistRowsByReviewStep(
rows: CklstRvwRsltTreeDto[],
reviewStep: ReviewStepCode,
): CklstRvwRsltTreeDto[] {
return rows.filter((row) => row.rvwStpComCd === reviewStep);
}
export function computeReviewStepChecklistProgress(
rows: CklstRvwRsltTreeDto[],
results: ChecklistFormState["results"],
@@ -93,23 +76,6 @@ export function computeReviewStepChecklistProgress(
return { total, completed, percent };
}
export function buildChecklistFormState(rows: CklstRvwRsltTreeDto[]): ChecklistFormState {
const results: ChecklistFormState["results"] = {};
const comments: ChecklistFormState["comments"] = {};
rows.forEach((row) => {
const key = buildChecklistRowKey(row);
if (row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N") {
results[key] = row.rvwRsltAnmlyYn;
}
if (row.picRvwCn) comments[key] = row.picRvwCn;
});
return {
results,
comments,
progress: computeChecklistProgress(rows, results),
};
}
export function mergeChecklistFormStateFromServer(
prev: ChecklistFormState,
allRows: CklstRvwRsltTreeDto[],
@@ -27,8 +27,8 @@ export const fnrsCnsltnRegistSchema = z.object({
.string()
.min(1, "설계자 이메일은 필수입니다")
.email("올바른 이메일 형식이 아닙니다"),
sido: z.string().optional(),
gugun: z.string().optional(),
sido: z.string().nullish(),
gugun: z.string().nullish(),
}),
bzScaleList: z.array(
fnrsCnsltnBzScaleItemSchema.extend({
@@ -36,7 +36,7 @@ export const fnrsCnsltnRegistSchema = z.object({
bscPl: z.number(),
budgetReflct: z.number(),
thtiDsgn: z.number(),
diff: z.number().optional(),
diff: z.number().nullish(),
}),
),
bizCstList: z.array(
@@ -52,8 +52,8 @@ export const fnrsCnsltnRegistSchema = z.object({
fnrsSharSeCd: z.string(),
exstnAmt: z.number(),
chgRqtAmt: z.number(),
ajmtAmt: z.number().optional(),
rmrkCn: z.string().optional(),
ajmtAmt: z.number().nullish(),
rmrkCn: z.string().nullish(),
}),
),
cklstResultList: z.array(
@@ -74,7 +74,7 @@ export const fnrsCnsltnRegistSchema = z.object({
splmntDmndList: z.array(
fnrsCnsltnSplmntDmndItemSchema.extend({
splmntArtclCdNm: z.string().nullish(),
splmntDmndAtchFileNm: z.string().optional(),
splmntDmndAtchFileNm: z.string().nullish(),
}),
),
privacyConsent: z.enum(["Y", "N"], {
@@ -88,7 +88,7 @@ export function formatTaskSeComCdForApi(taskSeComCd: string[]): string {
return taskSeComCd.join(",");
}
export function parseTaskSeComCdFromApi(taskSeComCd?: string): string[] {
export function parseTaskSeComCdFromApi(taskSeComCd?: string | null): string[] {
if (!taskSeComCd) return [];
return taskSeComCd.split(",").filter(Boolean);
}
@@ -15,7 +15,7 @@ export const pwrkCnsltnDocEvlFormSchema = z.object({
required_error: "만족도를 선택해 주세요.",
invalid_type_error: "만족도를 선택해 주세요.",
}),
pwrkCnsltnDocEvlCn: z.string().max(2000).optional(),
pwrkCnsltnDocEvlCn: z.string().max(2000).nullish(),
});
export type PwrkCnsltnDocEvlFormValues = z.infer<typeof pwrkCnsltnDocEvlFormSchema>;
@@ -1,3 +1,5 @@
import type { CklstRvwRsltTreeDto } from "@/entities/fnrs-cnsltn";
import {
PBADMS_PROGRESS_ORDER,
REVIEW_STEP_CODES,
@@ -28,6 +30,25 @@ export function getVisibleReviewSteps(
return REVIEW_STEP_CODES.slice(0, count);
}
export function resolveVisibleReviewSteps(
prgrsSttsComCd: string,
userRoleCode: string | undefined,
cklstResultList: CklstRvwRsltTreeDto[],
): ReviewStepCode[] {
const byProgressAndRole = getVisibleReviewSteps(prgrsSttsComCd, userRoleCode);
const currentStep = byProgressAndRole.at(-1);
const stepsWithData = new Set<ReviewStepCode>();
for (const row of cklstResultList) {
const step = row.rvwStpComCd as ReviewStepCode | undefined;
if (step && REVIEW_STEP_CODES.includes(step)) stepsWithData.add(step);
}
return REVIEW_STEP_CODES.filter(
(step) => stepsWithData.has(step) || step === currentStep,
);
}
export function getEditableReviewStep(
dtlInstCd?: string,
prgrsSttsComCd?: string,
@@ -102,6 +102,13 @@ function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N")
];
}
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>;
}
return null;
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
@@ -113,7 +120,7 @@ function getBadgeColor(state: BadgeState) {
}
}
function formatOptional(value: string | undefined) {
function formatOptional(value: string | null | undefined) {
return value?.trim() ? value : "-";
}
@@ -152,7 +159,11 @@ export function createFnrsCnsltnColumns() {
columnHelper.accessor("bizNm", {
header: "사업명",
meta: { align: "left" },
cell: ({ getValue }) => formatOptional(getValue()),
cell: ({ row, getValue }) => (
<>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
</>
),
}),
columnHelper.accessor("taskSeCdNm", {
header: "업무구분",
@@ -44,10 +44,14 @@ export interface ChecklistFormState {
progress: ChecklistProgress;
}
export type ChecklistStepMap = Record<ReviewStepCode, CklstRvwRsltTreeDto[]>;
export const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"];
export const CHECKLIST_TAB_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "자가체크",
ENV: "환경청검토",
KECO: "기술검토",
};
export const REVIEW_STEP_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "지자체",
ENV: "환경청",
@@ -8,7 +8,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
import {
AI_AUTO_SE,
type AiAutoPrcsLatestResponseDto,
@@ -180,7 +180,9 @@ function FloatingActionButtons({
userInfo?.userUnqId,
);
const viewOnlyPwrkCnsltnDoc =
prgrsSttsComCd === "SL" || (prgrsSttsComCd === "RB" && dtlInstCd !== "ROLE_USER05");
prgrsSttsComCd === "SL" ||
(prgrsSttsComCd === "RB" && dtlInstCd !== "ROLE_USER05") ||
(prgrsSttsComCd === "RK" && dtlInstCd === "ROLE_USER03");
const actionButtons = useMemo(() => {
if (needsAtrzResolve && isRvwLoginUserLoading) return [];
@@ -217,11 +219,11 @@ function FloatingActionButtons({
isRegister && actionButtons.some((button) => button === FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg);
function handleGoToList() {
router.push("/fnrs-cnsltn");
router.push(getListHref("/fnrs-cnsltn"));
}
function handleEdit() {
router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}/edit`);
router.push(withListQs(`/fnrs-cnsltn/${fnrsCnsltnDocNo}/edit`));
}
function shouldShowButton(action: FnrsCnsltnViewButtonAction) {
@@ -312,7 +314,7 @@ function FloatingActionButtons({
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.del,
successMessage: "삭제되었습니다.",
});
router.push("/fnrs-cnsltn");
router.push(getListHref("/fnrs-cnsltn"));
}
async function handleEnvoSbmsnClick() {
@@ -113,7 +113,8 @@ function PwrkCnsltnDocEvlDialog({ open, pwrkCnsltnDocNo, onClose }: PwrkCnsltnDo
<Textarea
id="pwrkCnsltnDocEvlCn"
rows={4}
placeholder="의견을 입력해 주세요 (선택)"
placeholder="의견을 입력해 주세요"
className="!text-xl"
{...register("pwrkCnsltnDocEvlCn")}
/>
</div>
@@ -8,8 +8,8 @@ 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 { RhfDevTool, SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import { useAcntgSeMngTree } from "@/entities/acntg";
import { useEsntlAtchFileMngTree } from "@/entities/esntl";
import {
@@ -18,6 +18,7 @@ import {
fnrsCnsltnMngService,
} from "@/entities/fnrs-cnsltn";
import { useFnrsSharSeMngTree } from "@/entities/fnrs-shar-se";
import { findCompletedPwrkDraft, usePwrkCnsltnMngByUpDocNo } from "@/entities/pwrk-cnsltn-mng";
import {
AttachmentsFormSection,
buildAcntgCostItems,
@@ -35,7 +36,10 @@ import {
} from "@/widgets/pbadms-sprt";
import { Button } from "@common/components";
import { type FnrsCnsltnRegistFormData, fnrsCnsltnRegistSchema } from "../lib/fnrs-cnsltn-regist-schema";
import {
type FnrsCnsltnRegistFormData,
fnrsCnsltnRegistSchema,
} from "../lib/fnrs-cnsltn-regist-schema";
import {
buildFormPayload,
getCreateDefaultValues,
@@ -68,6 +72,12 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
const isSplmntPrgrsStts = initialData?.detDto?.prgrsSttsComCd === "SL";
const fnrsCnsltnDocNo = initialData?.detDto?.fnrsCnsltnDocNo;
const { data: pwrkCnsltnData } = usePwrkCnsltnMngByUpDocNo(
fnrsCnsltnDocNo,
isSplmntPrgrsStts && Boolean(fnrsCnsltnDocNo),
);
const hasCompletedDraft = Boolean(findCompletedPwrkDraft(pwrkCnsltnData));
const { data: masterSharTree = [] } = useFnrsSharSeMngTree();
const { data: acntgTree = [] } = useAcntgSeMngTree();
const { data: essentialFiles = [] } = useEsntlAtchFileMngTree({ docSeComCd: "FNRS" });
@@ -126,7 +136,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
(item) => !item.splmntCn || !item.splmntCn.trim(),
);
if (hasEmptySplmntCn) {
alert("보완요청내용이 있습니다. 보완 완료내용을 입력해 주세요.");
alert("보완 요청 내용이 있습니다.\n보완 완료 내용을 입력해 주세요.");
return;
}
@@ -143,7 +153,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
alert("보완 완료 처리되었습니다.");
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
router.push(`/fnrs-cnsltn/${docNo}`);
router.push(withListQs(`/fnrs-cnsltn/${docNo}`));
} catch (error) {
console.error("보완 완료 실패:", error);
alert("보완 완료 처리 중 오류가 발생했습니다.");
@@ -162,7 +172,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
alert("저장되었습니다.");
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}/edit`);
router.push(withListQs(`/fnrs-cnsltn/${fnrsCnsltnDocNo}/edit`));
} catch (error) {
console.error("저장 실패:", error);
alert("저장 중 오류가 발생했습니다.");
@@ -174,6 +184,11 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
async function onApply(data: FnrsCnsltnRegistFormData) {
if (isSubmitting) return;
if (data.privacyConsent !== "Y") {
alert("개인정보 수집/이용에 동의해야 신청이 가능합니다.");
return;
}
if (!isFormChecklistComplete(data.cklstResultList)) {
alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다.");
return;
@@ -191,7 +206,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
alert("신청 완료되었습니다.");
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}`);
router.push(withListQs(`/fnrs-cnsltn/${fnrsCnsltnDocNo}`));
} catch (error) {
console.error("신청 실패:", error);
alert("신청 중 오류가 발생했습니다.");
@@ -220,7 +235,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
await fnrsCnsltnMngService.remove(docNo);
alert("삭제되었습니다.");
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
router.push("/fnrs-cnsltn");
router.push(getListHref("/fnrs-cnsltn"));
} catch (error) {
console.error("삭제 실패:", error);
alert("삭제 중 오류가 발생했습니다.");
@@ -260,14 +275,17 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
initialEsntlFiles={isEditMode ? initialData?.esntlFileList : undefined}
initialEtcFiles={isEditMode ? initialData?.etcFileList : undefined}
/>
<SplmntDmndFormSection readOnly={!isSplmntPrgrsStts} />
<SplmntDmndFormSection
initialData={initialData?.splmntDmndList}
readOnly={!isSplmntPrgrsStts}
/>
<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("/fnrs-cnsltn")}
onClick={() => router.push(getListHref("/fnrs-cnsltn"))}
disabled={isSubmitting}
>
목록
@@ -285,7 +303,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
onClick={handleApplyClick}
disabled={isSubmitting}
>
신청완료
신청 완료
</Button>
<Button
color="red"
@@ -300,7 +318,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
</>
) : (
<>
{fnrsCnsltnDocNo ? (
{hasCompletedDraft ? (
<Button
color="orange"
type="button"
@@ -324,7 +342,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
</div>
</SectionCollapsibleGroup>
</form>
{fnrsCnsltnDocNo ? (
{hasCompletedDraft && fnrsCnsltnDocNo ? (
<PwrkCnsltnDocMngDialog
open={isPwrkCnsltnDocMngOpen}
fnrsCnsltnDocNo={fnrsCnsltnDocNo}
@@ -332,7 +350,6 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
onClose={() => setIsPwrkCnsltnDocMngOpen(false)}
/>
) : null}
<RhfDevTool control={form.control} />
</FormProvider>
);
}
@@ -1,14 +1,11 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import type { PaginationState } from "@tanstack/react-table";
import {
type CmCdAdstDstrComboResponseDto,
useFindGugunCombo,
useFindSidoCombo,
} from "@/entities/adstdstr";
import { useSearchParamsState } from "@/shared/lib";
import { useFindUserSidoGugunCombo } from "@/entities/adstdstr";
import { CodeSelect } from "@/entities/code";
import type { FnrsCnsltnMngSearchDto } from "@/entities/fnrs-cnsltn";
import {
@@ -27,48 +24,58 @@ import { FnrsList } from "./fnrs-list";
const DEFAULT_PAGE_INDEX = 0;
const DEFAULT_PAGE_SIZE = 10;
const DEFAULT_SORT = "fnrsCnsltnDocNo,desc";
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
const DEFAULT_SEARCH_PARAMS: FnrsCnsltnMngSearchDto = {
bizNm: "",
taskSeComCd: "",
admdstCd: "",
page: DEFAULT_PAGE_INDEX,
size: DEFAULT_PAGE_SIZE,
sort: DEFAULT_SORT,
};
const ALL_SELECT_VALUE = "__ALL__";
function FnrsListPage() {
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<FnrsCnsltnMngSearchDto>(DEFAULT_SEARCH_PARAMS);
const DEFAULT_PARAMS = {
sidoComCd: "",
sggComCd: "",
taskSeComCd: "",
bizNm: "",
page: DEFAULT_PAGE_INDEX,
size: DEFAULT_PAGE_SIZE,
};
const { data: sidoData } = useFindSidoCombo();
const { data: gugunData } = useFindGugunCombo(sidoComCd);
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
function FnrsListPage() {
const { params, setParams } = useSearchParamsState(DEFAULT_PARAMS);
const [bizNm, setBizNm] = useState(params.bizNm);
const [taskSeComCd, setTaskSeComCd] = useState(params.taskSeComCd);
const [sidoComCd, setSidoComCd] = useState(params.sidoComCd);
const [sggComCd, setSggComCd] = useState(params.sggComCd);
const { userSidoData, userGugunData } = useFindUserSidoGugunCombo(sidoComCd);
const pagination: PaginationState = {
pageIndex: params.page,
pageSize: params.size,
};
const searchParams: FnrsCnsltnMngSearchDto = useMemo(
() => ({
bizNm: params.bizNm,
taskSeComCd: params.taskSeComCd,
admdstCd: params.sggComCd || params.sidoComCd || "",
page: params.page,
size: params.size,
sort: DEFAULT_SORT,
}),
[params],
);
const handleSearch = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setPagination((prev) => ({ ...prev, pageIndex: DEFAULT_PAGE_INDEX }));
setSearchParams({
...DEFAULT_SEARCH_PARAMS,
setParams({
bizNm,
taskSeComCd,
admdstCd: sggComCd || sidoComCd || "",
sidoComCd,
sggComCd,
page: DEFAULT_PAGE_INDEX,
size: pagination.pageSize,
});
};
const handlePaginationChange = (next: PaginationState) => {
setParams({ page: next.pageIndex, size: next.pageSize });
};
return (
<div className="space-y-4 px-[1.8rem] pb-6">
<section className="card card-border mx-0 bg-background">
@@ -89,7 +96,7 @@ function FnrsListPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
{sidoOptions.map((option) => (
{userSidoData.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
@@ -111,7 +118,7 @@ function FnrsListPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
{gugunOptions.map((option) => (
{userGugunData.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
@@ -143,7 +150,7 @@ function FnrsListPage() {
<FnrsList
searchParams={searchParams}
pagination={pagination}
onPaginationChange={setPagination}
onPaginationChange={handlePaginationChange}
/>
</div>
);
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { useDataTable } from "@/shared/lib";
import { useDataTable, withListQs } from "@/shared/lib";
import {
FnrsCnsltnMngResponseDto,
type FnrsCnsltnMngSearchDto,
@@ -43,7 +43,7 @@ function FnrsList({ searchParams, pagination, onPaginationChange }: FnrsListProp
const columns = useMemo(() => createFnrsCnsltnColumns(), []);
const handleRowClick = (row: FnrsCnsltnMngResponseDto) => {
router.push(`/fnrs-cnsltn/${row.fnrsCnsltnDocNo}`);
router.push(withListQs(`/fnrs-cnsltn/${row.fnrsCnsltnDocNo}`));
};
const { tableComponent } = useDataTable({
@@ -14,7 +14,11 @@ import {
} from "@/widgets/pbadms-sprt";
import { Skeleton } from "@common/components";
import { getAiPendingMessage, isAiPendingStatus, isRagIndexingInProgress } from "../lib/ai-pending-utils";
import {
getAiPendingMessage,
isAiPendingStatus,
isRagIndexingInProgress,
} from "../lib/ai-pending-utils";
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";
@@ -11,11 +11,11 @@ import {
extractTaskCodes,
getChecklistDisplayRows,
} from "../../lib/checklist-utils";
import { getVisibleReviewSteps } from "../../lib/review-step-utils";
import { resolveVisibleReviewSteps } from "../../lib/review-step-utils";
import type { ReviewStepCode } from "../../model/view-types";
import {
CHECKLIST_TAB_LABELS,
type ChecklistFormState,
REVIEW_STEP_LABELS,
ROLE_TO_REVIEW_STEP,
TASK_CODE_LABELS,
} from "../../model/view-types";
@@ -37,8 +37,8 @@ function ChecklistSection({
setFormState,
}: ChecklistSectionProps) {
const visibleReviewSteps = useMemo(
() => getVisibleReviewSteps(prgrsSttsComCd, userRoleCode),
[prgrsSttsComCd, userRoleCode],
() => resolveVisibleReviewSteps(prgrsSttsComCd, userRoleCode, cklstResultList),
[prgrsSttsComCd, userRoleCode, cklstResultList],
);
const taskCodes = useMemo(() => extractTaskCodes(cklstResultList), [cklstResultList]);
@@ -87,7 +87,7 @@ function ChecklistSection({
<TabsList>
{visibleReviewSteps.map((stepCode) => (
<TabsTrigger key={stepCode} value={stepCode} className="!px-6 !py-3 text-xl">
{REVIEW_STEP_LABELS[stepCode]}
{CHECKLIST_TAB_LABELS[stepCode]}
</TabsTrigger>
))}
</TabsList>
@@ -68,7 +68,7 @@ export const instlSncnRegistSchema = z.object({
splmntDmndList: z.array(
instlSncnSplmntDmndItemSchema.extend({
splmntArtclCdNm: z.string().nullish(),
splmntDmndAtchFileNm: z.string().optional(),
splmntDmndAtchFileNm: z.string().nullish(),
}),
),
privacyConsent: z.enum(["Y", "N"], {
@@ -82,7 +82,7 @@ export function formatTaskSeComCdForApi(taskSeComCd: string[]): string {
return taskSeComCd.join(",");
}
export function parseTaskSeComCdFromApi(taskSeComCd?: string): string[] {
export function parseTaskSeComCdFromApi(taskSeComCd?: string | null): string[] {
if (!taskSeComCd) return [];
return taskSeComCd.split(",").filter(Boolean);
}
@@ -1,4 +1,4 @@
import type { ReviewStepCode } from "@/entities/instl-sncn";
import type { CklstRvwRsltTreeDto, ReviewStepCode } from "@/entities/instl-sncn";
import {
INSTL_PROGRESS_ORDER,
@@ -27,6 +27,25 @@ export function getVisibleReviewSteps(
return REVIEW_STEP_CODES.slice(0, count);
}
export function resolveVisibleReviewSteps(
prgrsSttsComCd: string,
userRoleCode: string | undefined,
cklstResultList: CklstRvwRsltTreeDto[],
): ReviewStepCode[] {
const byProgressAndRole = getVisibleReviewSteps(prgrsSttsComCd, userRoleCode);
const currentStep = byProgressAndRole.at(-1);
const stepsWithData = new Set<ReviewStepCode>();
for (const row of cklstResultList) {
const step = row.rvwStpComCd as ReviewStepCode | undefined;
if (step && REVIEW_STEP_CODES.includes(step)) stepsWithData.add(step);
}
return REVIEW_STEP_CODES.filter(
(step) => stepsWithData.has(step) || step === currentStep,
);
}
export function getEditableReviewStep(
dtlInstCd?: string,
prgrsSttsComCd?: string,
@@ -9,7 +9,10 @@ import type {
ReviewStepCode,
} from "@/entities/instl-sncn";
import { instlSncnApplySaveSchema } from "@/entities/instl-sncn";
import { type SplmntDmndDialogConfirmPayload, toEsntlAtchFileSaveList } from "@/widgets/pbadms-sprt";
import {
type SplmntDmndDialogConfirmPayload,
toEsntlAtchFileSaveList,
} from "@/widgets/pbadms-sprt";
import type { ChecklistFormState } from "../model/view-types";
+13 -2
View File
@@ -103,6 +103,13 @@ function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N")
];
}
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>;
}
return null;
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
@@ -114,7 +121,7 @@ function getBadgeColor(state: BadgeState) {
}
}
function formatOptional(value: string | undefined) {
function formatOptional(value: string | null | undefined) {
return value?.trim() ? value : "-";
}
@@ -153,7 +160,11 @@ export function createInstlSncnColumns() {
columnHelper.accessor("bizNm", {
header: "사업명",
meta: { align: "left" },
cell: ({ getValue }) => formatOptional(getValue()),
cell: ({ row, getValue }) => (
<>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
</>
),
}),
columnHelper.accessor("taskSeCdNm", {
header: "업무구분",
@@ -48,6 +48,12 @@ export type ChecklistStepMap = Record<ReviewStepCode, CklstRvwRsltTreeDto[]>;
export const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"];
export const CHECKLIST_TAB_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "자가체크",
ENV: "환경청검토",
KECO: "기술검토",
};
export const REVIEW_STEP_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "지자체",
ENV: "환경청",
@@ -8,7 +8,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
import {
buildElaprPrgrsSttsListFromAplnRows,
ElaprApprovalDialog,
@@ -114,11 +114,11 @@ function FloatingActionButtons({
isRegister && actionButtons.some((button) => button === INSTL_SNCN_VIEW_BUTTON_ACTION.strg);
function handleGoToList() {
router.push("/instl-sncn");
router.push(getListHref("/instl-sncn"));
}
function handleEdit() {
router.push(`/instl-sncn/${instlSncnDocNo}/edit`);
router.push(withListQs(`/instl-sncn/${instlSncnDocNo}/edit`));
}
function shouldShowButton(action: InstlSncnViewButtonAction) {
@@ -212,7 +212,7 @@ function FloatingActionButtons({
action: INSTL_SNCN_VIEW_BUTTON_ACTION.del,
successMessage: "삭제되었습니다.",
});
router.push("/instl-sncn");
router.push(getListHref("/instl-sncn"));
}
async function handleInstlSncnAncmntClick() {
@@ -8,8 +8,8 @@ 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 { RhfDevTool, SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import { useEsntlAtchFileMngTree } from "@/entities/esntl";
import {
instlSncnKeys,
@@ -30,8 +30,15 @@ import {
} from "@/widgets/pbadms-sprt";
import { Button } from "@common/components";
import { buildFormPayload, getCreateDefaultValues, mapDetailToFormData } from "../lib/form-data-mapper";
import { type InstlSncnRegistFormData, instlSncnRegistSchema } from "../lib/instl-sncn-regist-schema";
import {
buildFormPayload,
getCreateDefaultValues,
mapDetailToFormData,
} from "../lib/form-data-mapper";
import {
type InstlSncnRegistFormData,
instlSncnRegistSchema,
} from "../lib/instl-sncn-regist-schema";
import { PROGRESS_STATUS_COLUMNS } from "../model/columns";
import { BasicInfoFormSection } from "./sections/basic-info-form-section";
@@ -94,7 +101,7 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
(item) => !item.splmntCn || !item.splmntCn.trim(),
);
if (hasEmptySplmntCn) {
alert("보완요청내용이 있습니다. 보완완료내용을 입력해 주세요.");
alert("보완 요청 내용이 있습니다.\n보완 완료 내용을 입력해 주세요.");
return;
}
@@ -109,12 +116,12 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
const result = await submitSupplementForm(data);
if (!result) return;
alert("보완완료 처리되었습니다.");
router.push(`/instl-sncn/${docNo}`);
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
alert("보완 완료 처리되었습니다.");
await queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
router.push(withListQs(`/instl-sncn/${docNo}`));
} catch (error) {
console.error("보완완료 실패:", error);
alert("보완완료 처리 중 오류가 발생했습니다.");
console.error("보완 완료 실패:", error);
alert("보완 완료 처리 중 오류가 발생했습니다.");
} finally {
setIsSubmitting(false);
}
@@ -129,7 +136,7 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
if (!instlSncnDocNo) return;
alert("저장되었습니다.");
router.push(`/instl-sncn/${instlSncnDocNo}/edit`);
router.push(withListQs(`/instl-sncn/${instlSncnDocNo}/edit`));
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
} catch (error) {
console.error("저장 실패:", error);
@@ -158,7 +165,7 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
if (!instlSncnDocNo) return;
alert("신청 완료되었습니다.");
router.push(`/instl-sncn/${instlSncnDocNo}`);
router.push(withListQs(`/instl-sncn/${instlSncnDocNo}`));
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
} catch (error) {
console.error("신청 실패:", error);
@@ -184,7 +191,7 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
await instlSncnService.remove(docNo);
alert("삭제되었습니다.");
queryClient.invalidateQueries({ queryKey: instlSncnKeys.list() });
router.push("/instl-sncn");
router.push(getListHref("/instl-sncn"));
} catch (error) {
console.error("삭제 실패:", error);
alert("삭제 중 오류가 발생했습니다.");
@@ -227,7 +234,7 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
<Button
color="white"
type="button"
onClick={() => router.push("/instl-sncn")}
onClick={() => router.push(getListHref("/instl-sncn"))}
disabled={isSubmitting}
>
목록
@@ -245,7 +252,7 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
onClick={form.handleSubmit(onApply)}
disabled={isSubmitting}
>
신청완료
신청 완료
</Button>
<Button
color="red"
@@ -264,7 +271,7 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
onClick={form.handleSubmit(onSplmntCmptn)}
disabled={isSubmitting}
>
보완완료
보완 완료
</Button>
)}
</div>
@@ -272,7 +279,6 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
</div>
</SectionCollapsibleGroup>
</form>
<RhfDevTool control={form.control} />
</FormProvider>
);
}
@@ -1,11 +1,11 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import type { PaginationState } from "@tanstack/react-table";
import type { CmCdAdstDstrComboResponseDto } from "@/entities/adstdstr";
import { useFindGugunCombo, useFindSidoCombo } from "@/entities/adstdstr";
import { useSearchParamsState } from "@/shared/lib";
import { useFindUserSidoGugunCombo } from "@/entities/adstdstr";
import { CodeSelect } from "@/entities/code";
import type { InstlSncnMngSearchParams } from "@/entities/instl-sncn";
import {
@@ -24,46 +24,58 @@ import { InstlList } from "./instl-list";
const DEFAULT_PAGE_INDEX = 0;
const DEFAULT_PAGE_SIZE = 10;
const DEFAULT_SORT = "instlSncnDocNo,desc";
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
const ALL_SELECT_VALUE = "__ALL__";
const DEFAULT_SEARCH_PARAMS: InstlSncnMngSearchParams = {
bizNm: "",
const DEFAULT_PARAMS = {
sidoComCd: "",
sggComCd: "",
taskSeComCd: "",
admdstCd: "",
bizNm: "",
page: DEFAULT_PAGE_INDEX,
size: DEFAULT_PAGE_SIZE,
sort: DEFAULT_SORT,
};
function InstlListPage() {
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<InstlSncnMngSearchParams>(DEFAULT_SEARCH_PARAMS);
const { params, setParams } = useSearchParamsState(DEFAULT_PARAMS);
const [bizNm, setBizNm] = useState(params.bizNm);
const [taskSeComCd, setTaskSeComCd] = useState(params.taskSeComCd);
const [sidoComCd, setSidoComCd] = useState(params.sidoComCd);
const [sggComCd, setSggComCd] = useState(params.sggComCd);
const { data: sidoData } = useFindSidoCombo();
const { data: gugunData } = useFindGugunCombo(sidoComCd);
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
const { userSidoData, userGugunData } = useFindUserSidoGugunCombo(sidoComCd);
const pagination: PaginationState = {
pageIndex: params.page,
pageSize: params.size,
};
const searchParams: InstlSncnMngSearchParams = useMemo(
() => ({
bizNm: params.bizNm,
taskSeComCd: params.taskSeComCd,
admdstCd: params.sggComCd || params.sidoComCd || "",
page: params.page,
size: params.size,
sort: DEFAULT_SORT,
}),
[params],
);
const handleSearch = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setPagination((prev) => ({ ...prev, pageIndex: DEFAULT_PAGE_INDEX }));
setSearchParams({
...DEFAULT_SEARCH_PARAMS,
setParams({
bizNm,
taskSeComCd,
admdstCd: sggComCd || sidoComCd || "",
sidoComCd,
sggComCd,
page: DEFAULT_PAGE_INDEX,
size: pagination.pageSize,
});
};
const handlePaginationChange = (next: PaginationState) => {
setParams({ page: next.pageIndex, size: next.pageSize });
};
return (
<div className="space-y-4 px-[1.8rem] pb-6">
<section className="card card-border mx-0 bg-background">
@@ -75,7 +87,7 @@ function InstlListPage() {
<Select
value={sidoComCd}
onValueChange={(value) => {
setSidoComCd(value);
setSidoComCd(value === ALL_SELECT_VALUE ? "" : value);
setSggComCd("");
}}
>
@@ -83,7 +95,8 @@ function InstlListPage() {
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
{sidoOptions.map((option) => (
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
{userSidoData.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
@@ -95,12 +108,17 @@ function InstlListPage() {
<Label htmlFor="sggComCd" className="whitespace-nowrap">
시군구
</Label>
<Select value={sggComCd} onValueChange={setSggComCd} disabled={!sidoComCd}>
<Select
value={sggComCd}
onValueChange={(value) => setSggComCd(value === ALL_SELECT_VALUE ? "" : value)}
disabled={!sidoComCd}
>
<SelectTrigger id="sggComCd" className="w-[12rem]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
{gugunOptions.map((option) => (
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
{userGugunData.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
@@ -132,7 +150,7 @@ function InstlListPage() {
<InstlList
searchParams={searchParams}
pagination={pagination}
onPaginationChange={setPagination}
onPaginationChange={handlePaginationChange}
/>
</div>
);
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { useDataTable } from "@/shared/lib";
import { useDataTable, withListQs } from "@/shared/lib";
import {
type InstlSncnMngResponseDto,
type InstlSncnMngSearchParams,
@@ -43,7 +43,7 @@ function InstlList({ searchParams, pagination, onPaginationChange }: InstlListPr
const columns = useMemo(() => createInstlSncnColumns(), []);
const handleRowClick = (row: InstlSncnMngResponseDto) => {
router.push(`/instl-sncn/${row.instlSncnDocNo}`);
router.push(withListQs(`/instl-sncn/${row.instlSncnDocNo}`));
};
const { tableComponent } = useDataTable({
@@ -95,7 +95,9 @@ function InstlViewPage({ id }: InstlViewPageProps) {
return (
<div className="mt-4">
<SectionCollapsibleGroup>
<SectionCollapsibleToggle />
<div className="sticky top-[6rem] z-30 bg-container px-4 py-2">
<SectionCollapsibleToggle />
</div>
<div className="space-y-4">
<ProgressStatusSection
columns={PROGRESS_STATUS_COLUMNS}
@@ -11,10 +11,10 @@ import {
extractTaskCodes,
getChecklistDisplayRows,
} from "../../lib/checklist-utils";
import { getVisibleReviewSteps } from "../../lib/review-step-utils";
import { resolveVisibleReviewSteps } from "../../lib/review-step-utils";
import {
CHECKLIST_TAB_LABELS,
type ChecklistFormState,
REVIEW_STEP_LABELS,
ROLE_TO_REVIEW_STEP,
TASK_CODE_LABELS,
} from "../../model/view-types";
@@ -36,8 +36,8 @@ function ChecklistSection({
setFormState,
}: ChecklistSectionProps) {
const visibleReviewSteps = useMemo(
() => getVisibleReviewSteps(prgrsSttsComCd, userRoleCode),
[prgrsSttsComCd, userRoleCode],
() => resolveVisibleReviewSteps(prgrsSttsComCd, userRoleCode, cklstResultList),
[prgrsSttsComCd, userRoleCode, cklstResultList],
);
const taskCodes = useMemo(() => extractTaskCodes(cklstResultList), [cklstResultList]);
@@ -86,7 +86,7 @@ function ChecklistSection({
<TabsList>
{visibleReviewSteps.map((stepCode) => (
<TabsTrigger key={stepCode} value={stepCode} className="!px-6 !py-3 text-xl">
{REVIEW_STEP_LABELS[stepCode]}
{CHECKLIST_TAB_LABELS[stepCode]}
</TabsTrigger>
))}
</TabsList>
@@ -55,7 +55,7 @@ export function resolveNotiStatus(
return {
label: "미조회",
badgeProps: { color: "yellow" },
badgeProps: { color: "yellow", size: "lg" },
actionLabel: "처리하기",
};
}
@@ -1,14 +1,14 @@
import { type CostItemDef, roundAmount } from "@/widgets/pbadms-sprt";
import { type BizCstMatrixItem,getAmt, PLAN_STEPS } from "./biz-cst-matrix-utils";
import { type BizCstMatrixItem, getAmt, PLAN_STEPS } from "./biz-cst-matrix-utils";
export interface FnrsCstItem {
planStpNo: number;
gvexpAmt?: number;
lcexpAmt?: number;
orgprBdchgAmt?: number;
etcAmt?: number;
sumAmt?: number;
gvexpAmt?: number | null;
lcexpAmt?: number | null;
orgprBdchgAmt?: number | null;
etcAmt?: number | null;
sumAmt?: number | null;
}
export interface FnrsCstStepRemaining {
@@ -32,10 +32,7 @@ function getBizCstStepTotal(
function sumFnrsCstLeaf(item: FnrsCstItem): number {
return roundAmount(
(item.gvexpAmt ?? 0) +
(item.lcexpAmt ?? 0) +
(item.orgprBdchgAmt ?? 0) +
(item.etcAmt ?? 0),
(item.gvexpAmt ?? 0) + (item.lcexpAmt ?? 0) + (item.orgprBdchgAmt ?? 0) + (item.etcAmt ?? 0),
);
}
@@ -1,3 +1,5 @@
import type { CklstRvwRsltTreeDto } from "@/entities/sewer-bsc-plan";
import {
PBADMS_PROGRESS_ORDER,
REVIEW_STEP_CODES,
@@ -28,6 +30,25 @@ export function getVisibleReviewSteps(
return REVIEW_STEP_CODES.slice(0, count);
}
export function resolveVisibleReviewSteps(
prgrsSttsComCd: string,
userRoleCode: string | undefined,
cklstResultList: CklstRvwRsltTreeDto[],
): ReviewStepCode[] {
const byProgressAndRole = getVisibleReviewSteps(prgrsSttsComCd, userRoleCode);
const currentStep = byProgressAndRole.at(-1);
const stepsWithData = new Set<ReviewStepCode>();
for (const row of cklstResultList) {
const step = row.rvwStpComCd as ReviewStepCode | undefined;
if (step && REVIEW_STEP_CODES.includes(step)) stepsWithData.add(step);
}
return REVIEW_STEP_CODES.filter(
(step) => stepsWithData.has(step) || step === currentStep,
);
}
export function getEditableReviewStep(
dtlInstCd?: string,
prgrsSttsComCd?: string,
@@ -16,9 +16,9 @@ import {
export const sewerBscPlanRegistSchema = z.object({
sewerBscPlan: sewerBscPlanSaveSchema.extend({
sewerBscPlanNm: z.string().min(1, "기본계획명은 필수입니다"),
sewerBscPlanSeCdNm: z.string().optional(),
taskSeCdNm: z.string().optional(),
sido: z.string().optional(),
sewerBscPlanSeCdNm: z.string().nullish(),
taskSeCdNm: z.string().nullish(),
sido: z.string().nullish(),
gugun: z.string().nullish(),
}),
sewerBscPlanDtl: sewerBscPlanDtlSaveSchema.extend({
@@ -61,7 +61,7 @@ export const sewerBscPlanRegistSchema = z.object({
sewerBscPlanTmplntItemSchema.extend({
swtrmtFcltCd: z.string(),
swtrmtFcltNm: z.string().nullish(),
atchFileNm: z.string().optional(),
atchFileNm: z.string().nullish(),
sewerBscPlanTmplntDtlList: z
.array(
sewerBscPlanTmplntDtlItemSchema.extend({
@@ -69,7 +69,7 @@ export const sewerBscPlanRegistSchema = z.object({
swtrmtFcltCd: z.string(),
}),
)
.optional(),
.nullish(),
}),
),
cklstResultList: z.array(
@@ -78,7 +78,7 @@ export const sewerBscPlanRegistSchema = z.object({
cklstArtclSn: z.number(),
cklstRvwSn: z.number(),
selected: z.enum(["Y", "N"]).nullish(),
picRvwCn: z.string().nullable().optional(),
picRvwCn: z.string().nullish(),
}),
),
esntlAtchFileList: z.array(
@@ -90,9 +90,9 @@ export const sewerBscPlanRegistSchema = z.object({
splmntDmndList: z.array(
sewerBscPlanSplmntDmndItemSchema.extend({
splmntArtclCdNm: z.string().nullish(),
splmntDmndAtchFileNm: z.string().optional(),
splmntAtchFileNm: z.string().optional(),
splmntAtchFileAtflId: z.string().optional(),
splmntDmndAtchFileNm: z.string().nullish(),
splmntAtchFileNm: z.string().nullish(),
splmntAtchFileAtflId: z.string().nullish(),
}),
),
privacyConsent: z.enum(["Y", "N"], {
@@ -101,6 +101,13 @@ function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N")
];
}
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>;
}
return null;
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
@@ -151,7 +158,11 @@ export function createSewerBscPlanColumns() {
columnHelper.accessor("sewerBscPlanNm", {
header: "기본계획명",
meta: { align: "left" },
cell: ({ getValue }) => formatOptional(getValue()),
cell: ({ row, getValue }) => (
<>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
</>
),
}),
columnHelper.accessor("sewerBscPlanSeCdNm", {
header: "계획구분",
@@ -42,6 +42,12 @@ export type ChecklistStepMap = Record<ReviewStepCode, CklstRvwRsltTreeDto[]>;
export const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"];
export const CHECKLIST_TAB_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "자가체크",
ENV: "환경청검토",
KECO: "기술검토",
};
export const REVIEW_STEP_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "지자체",
ENV: "환경청",
@@ -8,7 +8,7 @@ import { type QueryClient, useQueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
import {
buildElaprPrgrsSttsListFromAplnRows,
ElaprApprovalDialog,
@@ -116,11 +116,11 @@ function FloatingActionButtons({
isRegister && actionButtons.some((button) => button === SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.strg);
function handleGoToList() {
router.push("/sewer-bsc-plan");
router.push(getListHref("/sewer-bsc-plan"));
}
function handleEdit() {
router.push(`/sewer-bsc-plan/${sewerBscPlanDocNo}/edit?chgCycl=${chgCycl}`);
router.push(withListQs(`/sewer-bsc-plan/${sewerBscPlanDocNo}/edit?chgCycl=${chgCycl}`));
}
function shouldShowButton(action: SewerBscPlanViewButtonAction) {
@@ -201,7 +201,7 @@ function FloatingActionButtons({
action: SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.del,
successMessage: "삭제되었습니다.",
});
router.push("/sewer-bsc-plan");
router.push(getListHref("/sewer-bsc-plan"));
}
function getReviewOpinionFormData() {
@@ -502,7 +502,9 @@ async function handleDmndSplmnt(
context.request,
);
await context.queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
context.router.replace(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}?chgCycl=${result.chgCycl}`);
context.router.replace(
withListQs(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}?chgCycl=${result.chgCycl}`),
);
context.onSuccess?.();
} catch (error) {
context.onError?.(error as Error);
@@ -31,7 +31,7 @@ interface PreviousPlanLookupDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 부모 폼 `sewerBscPlan.sido` 표시명 */
initialSidoNm?: string;
initialSidoNm?: string | null;
/** 부모 폼 `sewerBscPlan.gugun` 표시명 */
initialGugunNm?: string | null;
}
@@ -11,11 +11,11 @@ import {
extractTaskCodes,
getChecklistDisplayRows,
} from "../../lib/checklist-utils";
import { getVisibleReviewSteps } from "../../lib/review-step-utils";
import { resolveVisibleReviewSteps } from "../../lib/review-step-utils";
import type { ReviewStepCode } from "../../model/view-types";
import {
CHECKLIST_TAB_LABELS,
type ChecklistFormState,
REVIEW_STEP_LABELS,
ROLE_TO_REVIEW_STEP,
TASK_CODE_LABELS,
} from "../../model/view-types";
@@ -37,8 +37,8 @@ function ChecklistSection({
setFormState,
}: ChecklistSectionProps) {
const visibleReviewSteps = useMemo(
() => getVisibleReviewSteps(prgrsSttsComCd, userRoleCode),
[prgrsSttsComCd, userRoleCode],
() => resolveVisibleReviewSteps(prgrsSttsComCd, userRoleCode, cklstResultList),
[prgrsSttsComCd, userRoleCode, cklstResultList],
);
const taskCodes = useMemo(() => extractTaskCodes(cklstResultList), [cklstResultList]);
@@ -87,7 +87,7 @@ function ChecklistSection({
<TabsList>
{visibleReviewSteps.map((stepCode) => (
<TabsTrigger key={stepCode} value={stepCode} className="!px-6 !py-3 text-xl">
{REVIEW_STEP_LABELS[stepCode]}
{CHECKLIST_TAB_LABELS[stepCode]}
</TabsTrigger>
))}
</TabsList>
@@ -214,8 +214,8 @@ function TmplntFacilityMatrix({
interface TmplntFacilityAttachmentsProps {
facilityIndex: number;
facilityName?: string;
atchFileGroupId?: string;
atchFileNm?: string;
atchFileGroupId?: string | null;
atchFileNm?: string | null;
}
function TmplntFacilityAttachments({
@@ -4,11 +4,7 @@ import { useEffect, useState } from "react";
import { useUserStore } from "@/shared/lib";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import {
type CmCdAdstDstrComboResponseDto,
useFindUserGugunCombo,
useFindUserSidoCombo,
} from "@/entities/adstdstr";
import { useFindUserSidoGugunCombo } from "@/entities/adstdstr";
import { useCodeList } from "@/entities/code";
import {
Button,
@@ -48,8 +44,6 @@ interface SewerBscPlanApplyDialogProps {
onConfirm: (selection: SewerBscPlanApplySelection) => void;
}
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
function SewerBscPlanApplyDialog({ open, onOpenChange, onConfirm }: SewerBscPlanApplyDialogProps) {
const userInfo = useUserStore((state) => state.userInfo);
const [sidoComCd, setSidoComCd] = useState("");
@@ -57,37 +51,48 @@ function SewerBscPlanApplyDialog({ open, onOpenChange, onConfirm }: SewerBscPlan
const [sewerBscPlanSeComCd, setSewerBscPlanSeComCd] = useState("");
const [taskSeComCd, setTaskSeComCd] = useState("");
const sidoFilterUserInfo =
userInfo?.dtlInstCd === "ROLE_USER04" || userInfo?.dtlInstCd === "ROLE_USER05"
? userInfo
: undefined;
const { data: sidoData, isPending: isSidoPending } = useFindUserSidoCombo(sidoFilterUserInfo);
const { data: gugunData, isPending: isGugunPending } = useFindUserGugunCombo(sidoComCd);
const canSidoGugunSelectable = userInfo?.dtlInstCd !== "ROLE_USER05";
const {
userSidoData,
userGugunData,
isUserSidoPending,
isUserGugunPending,
} = useFindUserSidoGugunCombo(sidoComCd);
const { data: planSeOptions, isPending: isPlanSePending } = useCodeList("SEWERBSCPLANSE");
const { data: taskSeOptions, isPending: isTaskSePending } = useCodeList("SEWER");
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
const isPending = isSidoPending || isPlanSePending || isTaskSePending;
const isPending = isUserSidoPending || isPlanSePending || isTaskSePending;
useEffect(() => {
if (!open) return;
setSidoComCd("");
setSggComCd("");
setSewerBscPlanSeComCd("");
setTaskSeComCd("");
}, [open]);
const canSidoGugunSelectable = userInfo?.dtlInstCd !== "ROLE_USER05";
if (!canSidoGugunSelectable) return;
setSidoComCd("");
setSggComCd("");
}, [open, canSidoGugunSelectable]);
useEffect(() => {
if (canSidoGugunSelectable || userSidoData.length === 0) return;
const nextSidoComCd = userSidoData[0].adstDstrCd;
setSidoComCd((current) => (current === nextSidoComCd ? current : nextSidoComCd));
}, [canSidoGugunSelectable, userSidoData]);
useEffect(() => {
if (canSidoGugunSelectable || !sidoComCd || userGugunData.length === 0) return;
const nextSggComCd = userGugunData[0].adstDstrCd;
setSggComCd((current) => current || nextSggComCd);
}, [canSidoGugunSelectable, sidoComCd, userGugunData]);
const canConfirm =
!!sewerBscPlanSeComCd && !!taskSeComCd && (!canSidoGugunSelectable || !!sidoComCd);
function handleConfirm() {
if (!canConfirm) return;
const sidoOption = sidoOptions.find((option) => option.adstDstrCd === sidoComCd);
const gugunOption = gugunOptions.find((option) => option.adstDstrCd === sggComCd);
const sidoOption = userSidoData.find((option) => option.adstDstrCd === sidoComCd);
const gugunOption = userGugunData.find((option) => option.adstDstrCd === sggComCd);
const planSeOption = planSeOptions?.find((option) => option.lwpoCd === sewerBscPlanSeComCd);
const taskSeOption = taskSeOptions?.find((option) => option.lwpoCd === taskSeComCd);
@@ -135,7 +140,7 @@ function SewerBscPlanApplyDialog({ open, onOpenChange, onConfirm }: SewerBscPlan
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{sidoOptions.map((option) => (
{userSidoData.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
@@ -148,13 +153,13 @@ function SewerBscPlanApplyDialog({ open, onOpenChange, onConfirm }: SewerBscPlan
<Select
value={sggComCd || undefined}
onValueChange={setSggComCd}
disabled={!sidoComCd || isGugunPending}
disabled={!sidoComCd || isUserGugunPending}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{gugunOptions.map((option) => (
{userGugunData.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
@@ -8,8 +8,8 @@ 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 { RhfDevTool, SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import { useAcntgSeMngTree } from "@/entities/acntg";
import { useEsntlAtchFileMngTree } from "@/entities/esntl";
import {
@@ -123,7 +123,9 @@ function SewerBscPlanFormPage({
if (!result?.sewerBscPlanDocNo) return;
alert("저장되었습니다.");
router.push(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}/edit?chgCycl=${result.chgCycl}`);
router.push(
withListQs(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}/edit?chgCycl=${result.chgCycl}`),
);
queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
} catch (error) {
console.error("저장 실패:", error);
@@ -162,7 +164,9 @@ function SewerBscPlanFormPage({
if (!result?.sewerBscPlanDocNo) return;
alert("신청 완료되었습니다.");
router.push(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}?chgCycl=${result.chgCycl ?? 1}`);
router.push(
withListQs(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}?chgCycl=${result.chgCycl ?? 1}`),
);
queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
} catch (error) {
console.error("신청 실패:", error);
@@ -179,7 +183,7 @@ function SewerBscPlanFormPage({
(item) => item.cmptnYn === "N" && (!item.splmntCn || !item.splmntCn.trim()),
);
if (hasEmptySplmntCn) {
alert("보완요청 항목이 있습니다.\n보완완료 내용을 입력해 주세요.");
alert("보완 요청 항목이 있습니다.\n보완 완료 내용을 입력해 주세요.");
return;
}
@@ -197,13 +201,13 @@ function SewerBscPlanFormPage({
const result = await sewerBscPlanService.submitSupplement(docNo, payload);
if (!result) return;
alert("보완완료 처리되었습니다.");
alert("보완 완료 처리되었습니다.");
await queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
const chgCycl = data.sewerBscPlanDtl.chgCycl ?? initialData?.detDto?.chgCycl ?? 1;
router.push(`/sewer-bsc-plan/${docNo}?chgCycl=${chgCycl}`);
queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
router.push(withListQs(`/sewer-bsc-plan/${docNo}?chgCycl=${chgCycl}`));
} catch (error) {
console.error("보완완료 실패:", error);
alert("보완완료 처리 중 오류가 발생했습니다.");
console.error("보완 완료 실패:", error);
alert("보완 완료 처리 중 오류가 발생했습니다.");
} finally {
setIsSubmitting(false);
}
@@ -228,7 +232,7 @@ function SewerBscPlanFormPage({
setIsSubmitting(true);
await sewerBscPlanService.delete(docNo);
alert("삭제되었습니다.");
router.push("/sewer-bsc-plan");
router.push(getListHref("/sewer-bsc-plan"));
queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.list() });
} catch (error) {
console.error("삭제 실패:", error);
@@ -240,7 +244,7 @@ function SewerBscPlanFormPage({
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSave)} className="mt-6 space-y-4">
<form onSubmit={form.handleSubmit(onSave)} className="mt-4">
<SectionCollapsibleGroup>
<div className="sticky top-[6rem] z-30 bg-container px-4 py-2">
<SectionCollapsibleToggle />
@@ -274,7 +278,7 @@ function SewerBscPlanFormPage({
<Button
color="white"
type="button"
onClick={() => router.push("/sewer-bsc-plan")}
onClick={() => router.push(getListHref("/sewer-bsc-plan"))}
disabled={isSubmitting}
>
목록
@@ -292,7 +296,7 @@ function SewerBscPlanFormPage({
onClick={handleApplyClick}
disabled={isSubmitting}
>
신청완료
신청 완료
</Button>
<Button
color="red"
@@ -311,7 +315,7 @@ function SewerBscPlanFormPage({
onClick={form.handleSubmit(onSplmntCmptn)}
disabled={isSubmitting}
>
보완완료
보완 완료
</Button>
)}
</div>
@@ -319,7 +323,6 @@ function SewerBscPlanFormPage({
</div>
</SectionCollapsibleGroup>
</form>
<RhfDevTool control={form.control} />
</FormProvider>
);
}
@@ -1,14 +1,11 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import type { PaginationState } from "@tanstack/react-table";
import {
type CmCdAdstDstrComboResponseDto,
useFindGugunCombo,
useFindSidoCombo,
} from "@/entities/adstdstr";
import { useSearchParamsState } from "@/shared/lib";
import { useFindUserSidoGugunCombo } from "@/entities/adstdstr";
import { CodeSelect } from "@/entities/code";
import type { SewerBscPlanSearchRequestDto } from "@/entities/sewer-bsc-plan";
import {
@@ -27,52 +24,62 @@ import { SewerBscPlanList } from "./sewer-bsc-plan-list";
const DEFAULT_PAGE_INDEX = 0;
const DEFAULT_PAGE_SIZE = 10;
const DEFAULT_SORT = "sewerBscPlanDocNo,desc";
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
const DEFAULT_SEARCH_PARAMS: SewerBscPlanSearchRequestDto = {
sewerBscPlanNm: "",
sewerBscPlanSeComCd: "",
taskSeComCd: "",
admdstCd: "",
page: DEFAULT_PAGE_INDEX,
size: DEFAULT_PAGE_SIZE,
sort: DEFAULT_SORT,
};
const ALL_SELECT_VALUE = "__ALL__";
function SewerBscPlanListPage() {
const [sewerBscPlanNm, setSewerBscPlanNm] = useState("");
const [sewerBscPlanSeComCd, setSewerBscPlanSeComCd] = 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<SewerBscPlanSearchRequestDto>(DEFAULT_SEARCH_PARAMS);
const DEFAULT_PARAMS = {
sidoComCd: "",
sggComCd: "",
sewerBscPlanSeComCd: "",
taskSeComCd: "",
sewerBscPlanNm: "",
page: DEFAULT_PAGE_INDEX,
size: DEFAULT_PAGE_SIZE,
};
const { data: sidoData } = useFindSidoCombo();
const { data: gugunData } = useFindGugunCombo(sidoComCd);
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
function SewerBscPlanListPage() {
const { params, setParams } = useSearchParamsState(DEFAULT_PARAMS);
const [sewerBscPlanNm, setSewerBscPlanNm] = useState(params.sewerBscPlanNm);
const [sewerBscPlanSeComCd, setSewerBscPlanSeComCd] = useState(params.sewerBscPlanSeComCd);
const [taskSeComCd, setTaskSeComCd] = useState(params.taskSeComCd);
const [sidoComCd, setSidoComCd] = useState(params.sidoComCd);
const [sggComCd, setSggComCd] = useState(params.sggComCd);
const { userSidoData, userGugunData } = useFindUserSidoGugunCombo(sidoComCd);
const pagination: PaginationState = {
pageIndex: params.page,
pageSize: params.size,
};
const searchParams: SewerBscPlanSearchRequestDto = useMemo(
() => ({
sewerBscPlanNm: params.sewerBscPlanNm,
sewerBscPlanSeComCd: params.sewerBscPlanSeComCd,
taskSeComCd: params.taskSeComCd,
admdstCd: params.sggComCd || params.sidoComCd || "",
page: params.page,
size: params.size,
sort: DEFAULT_SORT,
}),
[params],
);
const handleSearch = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setPagination((prev) => ({ ...prev, pageIndex: DEFAULT_PAGE_INDEX }));
setSearchParams({
...DEFAULT_SEARCH_PARAMS,
setParams({
sewerBscPlanNm,
sewerBscPlanSeComCd,
taskSeComCd,
admdstCd: sggComCd || sidoComCd || "",
sidoComCd,
sggComCd,
page: DEFAULT_PAGE_INDEX,
size: pagination.pageSize,
});
};
const handlePaginationChange = (next: PaginationState) => {
setParams({ page: next.pageIndex, size: next.pageSize });
};
return (
<div className="space-y-4 px-[1.8rem] pb-6">
<section className="card card-border mx-0 bg-background">
@@ -93,7 +100,7 @@ function SewerBscPlanListPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
{sidoOptions.map((option) => (
{userSidoData.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
@@ -115,7 +122,7 @@ function SewerBscPlanListPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
{gugunOptions.map((option) => (
{userGugunData.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
@@ -163,7 +170,7 @@ function SewerBscPlanListPage() {
<SewerBscPlanList
searchParams={searchParams}
pagination={pagination}
onPaginationChange={setPagination}
onPaginationChange={handlePaginationChange}
/>
</div>
);
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { useDataTable } from "@/shared/lib";
import { useDataTable, withListQs } from "@/shared/lib";
import {
type SewerBscPlanResponseDto,
type SewerBscPlanSearchRequestDto,
@@ -47,7 +47,7 @@ function SewerBscPlanList({ searchParams, pagination, onPaginationChange }: Sewe
const columns = useMemo(() => createSewerBscPlanColumns(), []);
const handleRowClick = (row: SewerBscPlanResponseDto) => {
router.push(`/sewer-bsc-plan/${row.sewerBscPlanDocNo}?chgCycl=${row.chgCycl}`);
router.push(withListQs(`/sewer-bsc-plan/${row.sewerBscPlanDocNo}?chgCycl=${row.chgCycl}`));
};
const { tableComponent } = useDataTable({
@@ -102,7 +102,9 @@ function SewerBscPlanViewPage({ id, propChgCycl }: SewerBscPlanViewPageProps) {
return (
<div className="mt-4">
<SectionCollapsibleGroup>
<SectionCollapsibleToggle />
<div className="sticky top-[6rem] z-30 bg-container px-4 py-2">
<SectionCollapsibleToggle />
</div>
<div className="space-y-4">
<ProgressStatusSection
columns={PROGRESS_STATUS_COLUMNS}
+2
View File
@@ -1,10 +1,12 @@
export { getCookie, removeCookie, setCookie } from "./cookie";
export { dayjs } from "./dayjs";
export { getListHref, withListQs } from "./list-return-href";
export { matchQueryStatus } from "./match-query-status";
export { queryClient } from "./query-client";
export { useAlertStore } from "./use-alert-store";
export { useDataTable } from "./use-data-table";
export { useMenuStore } from "./use-menu-store";
export { useMutationWrapper } from "./use-mutation-wrapper";
export { useSearchParamsState } from "./use-search-params-state";
export { getDefaultFileKey, useTus } from "./use-tus";
export { useUserStore } from "./use-user-store";
@@ -0,0 +1,50 @@
/**
* 목록 조회 조건을 sessionStorage에 저장하고, 목록 복귀 시 복원하기 위한 헬퍼.
*
* - 목록 → 상세: 현재 목록 쿼리를 `list-qs-{listPath}` 키로 저장
* - 상세 → 수정·저장 후 이동: storage를 건드리지 않음 (같은 탭에서 유지)
* - 목록/삭제 후 이동: storage에서 읽어 `/listPath?bizNm=...` 로 복원
*/
function toListSlug(listPath: string) {
return listPath.replace(/^\/+|\/+$/g, "").replaceAll("/", "-");
}
function storageKey(listPath: string) {
return `list-qs-${toListSlug(listPath)}`;
}
/** 상세/수정 href에서 목록 slug를 추출한다. */
function inferListPath(href: string) {
const path = href.split("?")[0] ?? "";
const segments = path.split("/").filter(Boolean);
return segments[0] ?? "";
}
function isOnListPage(slug: string) {
if (typeof window === "undefined" || !slug) return false;
const { pathname } = window.location;
return pathname === `/${slug}` || pathname === `/${slug}/`;
}
/** 목록에 있을 때 현재 쿼리를 저장하고, href는 그대로 반환한다. */
export function withListQs(href: string) {
if (typeof window === "undefined") return href;
const slug = inferListPath(href);
if (!isOnListPage(slug)) return href;
const search = window.location.search.replace(/^\?/, "");
if (search) sessionStorage.setItem(storageKey(slug), search);
else sessionStorage.removeItem(storageKey(slug));
return href;
}
/** sessionStorage에 저장된 목록 쿼리로 href를 만든다. */
export function getListHref(listPath: string) {
if (typeof window === "undefined") return listPath;
const listQs = sessionStorage.getItem(storageKey(listPath));
return listQs ? `${listPath}?${listQs}` : listPath;
}
@@ -0,0 +1,90 @@
"use client";
import { useCallback, useMemo, useRef } from "react";
import { useSearchParams } from "next/navigation";
/**
* 조회 조건을 URL searchParams와 동기화하는 훅.
*
* - URL이 단일 소스 오브 트루스(SSOT)이며, 새로고침·뒤로가기 시에도 조건이 유지된다.
* - 기본값과 동일한 값은 URL에서 제거해 쿼리스트링을 깨끗하게 유지한다.
* - `window.history.replaceState` 기반 shallow 갱신으로 서버 라운드트립 없이 동작한다.
* - 숫자 기본값이 주어진 키는 자동으로 `Number` 파싱한다.
*
* @example
* const { params, setParams, resetParams } = useSearchParamsState({
* keyword: "",
* page: 0,
* size: 10,
* });
*/
export function useSearchParamsState<T extends Record<string, ParamValue>>(
defaults: T,
): UseSearchParamsStateReturn<T> {
const searchParams = useSearchParams();
const defaultsRef = useRef(defaults);
const params = useMemo(
() => parseParams(searchParams, defaultsRef.current),
[searchParams],
);
const setParams = useCallback((partial: Partial<T>) => {
const next = new URLSearchParams(window.location.search);
for (const [key, value] of Object.entries(partial)) {
if (value === undefined || value === defaultsRef.current[key]) {
next.delete(key);
} else {
next.set(key, String(value));
}
}
replaceUrl(next);
}, []);
const resetParams = useCallback(() => {
const next = new URLSearchParams(window.location.search);
for (const key of Object.keys(defaultsRef.current)) {
next.delete(key);
}
replaceUrl(next);
}, []);
return { params, setParams, resetParams };
}
function parseParams<T extends Record<string, ParamValue>>(
searchParams: ReturnType<typeof useSearchParams>,
defaults: T,
): T {
const result = { ...defaults };
for (const key of Object.keys(defaults) as (keyof T)[]) {
const raw = searchParams?.get(key as string) ?? null;
if (raw === null) continue;
if (typeof defaults[key] === "number") {
const parsed = Number(raw);
if (!Number.isNaN(parsed)) result[key] = parsed as T[keyof T];
} else {
result[key] = raw as T[keyof T];
}
}
return result;
}
function replaceUrl(searchParams: URLSearchParams) {
const qs = searchParams.toString();
const url = qs
? `${window.location.pathname}?${qs}`
: window.location.pathname;
window.history.replaceState(null, "", url);
}
type ParamValue = string | number | null;
interface UseSearchParamsStateReturn<T> {
params: T;
setParams: (partial: Partial<T>) => void;
resetParams: () => void;
}
+8 -8
View File
@@ -205,14 +205,14 @@ export interface EsntlAtchFileSaveBase {
export interface SplmntDmndSaveBase {
splmntDmndSn?: number | null;
splmntArtclComCd?: string | null;
splmntDmndCn?: string;
splmntDmndDt?: string;
splmntDmndAtchFileGroupId?: string;
splmntCn?: string;
splmntPicNm?: string;
splmntCmptnDt?: string;
splmntAtchFileGroupId?: string;
cmptnYn?: string;
splmntDmndCn?: string | null;
splmntDmndDt?: string | null;
splmntDmndAtchFileGroupId?: string | null;
splmntCn?: string | null;
splmntPicNm?: string | null;
splmntCmptnDt?: string | null;
splmntAtchFileGroupId?: string | null;
cmptnYn?: string | null;
}
/** 검토의견 저장 공통 필드 (문서번호 제외) */
@@ -1,8 +1,8 @@
interface CanEditPbadmsDocParams {
docSeComCd: string;
prgrsSttsComCd: string | undefined;
rgtr: string | undefined;
loggedInUserUnqId: string | undefined;
prgrsSttsComCd?: string | null;
rgtr?: string | null;
loggedInUserUnqId?: string | null;
}
function canEditPbadmsDoc({
@@ -17,8 +17,8 @@ export interface FnrsSharListItem {
fnrsSharSeCd: string;
exstnAmt: number;
chgRqtAmt: number;
ajmtAmt?: number;
rmrkCn?: string;
ajmtAmt?: number | null;
rmrkCn?: string | null;
}
export type FnrsSharTreeNode = FnrsSharSeMngTreeDto | FnrsSharBizCstTreeDto;
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useFormContext } from "react-hook-form";
import { useQueryClient } from "@tanstack/react-query";
import { Download } from "lucide-react";
@@ -15,7 +15,11 @@ import {
SectionCollapsible,
UploadedFileChip,
} from "@/shared/ui";
import { attachService, useDownloadAttachmentByAtflCd, useDownloadAttachmentByAtflId } from "@/entities/attach";
import {
attachService,
useDownloadAttachmentByAtflCd,
useDownloadAttachmentByAtflId,
} from "@/entities/attach";
import { useEsntlAtchFileMngTree } from "@/entities/esntl";
import {
Button,
@@ -78,7 +82,7 @@ function AttachmentsFormSection({
initialEsntlFiles,
initialEtcFiles,
}: AttachmentsFormSectionProps) {
const { control, getValues, setValue } = useFormContext<AttachmentsFormData>();
const { getValues, setValue } = useFormContext<AttachmentsFormData>();
const queryClient = useQueryClient();
const [uploadedFiles, setUploadedFiles] = useState<Record<string, UploadedFileItem[]>>({});
const [etcFiles, setEtcFiles] = useState<EtcFileItem[]>([]);
@@ -373,89 +377,77 @@ function AttachmentsFormSection({
return (
<SectionCollapsible title="첨부파일">
<div className="space-y-6">
<Controller
name="esntlAtchFileList"
control={control}
render={() => (
<div className="px-2">
<h3 className="mb-4 text-xl font-semibold">필수 첨부파일</h3>
<Table variant="secondary" className="table-fixed">
<colgroup>
<col width="44%" />
<col width="12%" />
<col width="44%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="w-[30rem]" align="left">
파일명
</TableHead>
<TableHead>파일 확장자</TableHead>
<TableHead>파일첨부</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{essentialFiles.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="py-12 text-center text-muted-foreground">
조회된 필수 첨부파일 항목이 없습니다.
{essentialFiles.length > 0 && (
<div className="px-2">
<h3 className="mb-4 text-xl font-semibold">필수 첨부파일</h3>
<Table variant="secondary" className="table-fixed">
<colgroup>
<col width="44%" />
<col width="12%" />
<col width="44%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="w-[30rem]" align="left">
파일명
</TableHead>
<TableHead>파일 확장자</TableHead>
<TableHead>파일첨부</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{essentialFiles.map((file) => {
const atflCd = file.atchFileGroupId ?? "";
return (
<TableRow key={file.esntlAtchFileId}>
<TableHead
className="group break-keep font-medium"
data-required={file.esntlAtchYn === "Y"}
align="left"
>
<div className="inline-flex w-[calc(100%-1rem)] items-center justify-between gap-10">
{file.esntlAtchFileNm}
{atflCd ? (
<Button
size="sm"
color="white"
onClick={() => downloadTemplate(atflCd)}
>
<Download className="!size-5 shrink-0" />
양식내려받기
</Button>
) : null}
</div>
</TableHead>
<TableCell align="center">{file.esntlAtchFileRmrk}</TableCell>
<TableCell className="space-y-2">
<AttachmentFileField
files={(uploadedFiles[file.esntlAtchFileId] ?? []).map((item) => ({
fileKey: item.fileKey,
fileName: item.file?.name ?? item.fileName,
onDownload: item.atflId
? () => downloadByAtflId(item.atflId!)
: undefined,
}))}
inputId={`${file.esntlAtchFileId}-file-input`}
isUploadingByKey={isUploading}
accept={toFileAccept(file.esntlAtchFileRmrk) || undefined}
onAdd={(fileList) =>
void handleEssentialFileUpload(file.esntlAtchFileId, fileList)
}
onRemove={(index) =>
void removeEssentialFile(file.esntlAtchFileId, index)
}
/>
</TableCell>
</TableRow>
) : (
essentialFiles.map((file) => {
const atflCd = file.atchFileGroupId ?? "";
return (
<TableRow key={file.esntlAtchFileId}>
<TableHead
className="group break-keep font-medium"
data-required={file.esntlAtchYn === "Y"}
align="left"
>
<div className="inline-flex w-[calc(100%-1rem)] items-center justify-between gap-10">
{file.esntlAtchFileNm}
{atflCd ? (
<Button
size="sm"
color="white"
onClick={() => downloadTemplate(atflCd)}
>
<Download className="!size-5 shrink-0" />
양식내려받기
</Button>
) : null}
</div>
</TableHead>
<TableCell align="center">{file.esntlAtchFileRmrk}</TableCell>
<TableCell className="space-y-2">
<AttachmentFileField
files={(uploadedFiles[file.esntlAtchFileId] ?? []).map((item) => ({
fileKey: item.fileKey,
fileName: item.file?.name ?? item.fileName,
onDownload: item.atflId
? () => downloadByAtflId(item.atflId!)
: undefined,
}))}
inputId={`${file.esntlAtchFileId}-file-input`}
isUploadingByKey={isUploading}
accept={toFileAccept(file.esntlAtchFileRmrk) || undefined}
onAdd={(fileList) =>
void handleEssentialFileUpload(file.esntlAtchFileId, fileList)
}
onRemove={(index) =>
void removeEssentialFile(file.esntlAtchFileId, index)
}
/>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
)}
/>
);
})}
</TableBody>
</Table>
</div>
)}
<div className="px-2">
<div className="mb-4 flex items-center justify-between">
@@ -474,9 +466,7 @@ function AttachmentsFormSection({
fileName={item.file?.name ?? item.fileName}
isUploading={isUploading[item.fileKey] ?? false}
onRemove={() => void removeEtcFile(idx)}
onDownload={
item.atflId ? () => downloadByAtflId(item.atflId!) : undefined
}
onDownload={item.atflId ? () => downloadByAtflId(item.atflId!) : undefined}
/>
))}
</div>
@@ -43,12 +43,13 @@ function AttachmentsViewSection({ esntlFileList, etcFileList }: AttachmentsViewS
) : (
<div className="space-y-2">
{files.map((file) => (
<AttachmentDownloadLink
key={file.atflId}
atflId={file.atflId}
fileName={file.orgnlFileNm || "다운로드"}
className="items-center"
/>
<div key={file.atflId}>
<AttachmentDownloadLink
atflId={file.atflId}
fileName={file.orgnlFileNm || "다운로드"}
className="items-center"
/>
</div>
))}
</div>
)}
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useFormContext, useWatch } from "react-hook-form";
import { SectionCollapsible } from "@/shared/ui";
import { Field } from "@/shared/ui/field";
@@ -31,6 +31,16 @@ interface BusinessCostFormData {
type CostFieldKey = "bscPlanAmt" | "bgtRfltAmt" | "tstmDesignAmt";
function isSameBizCstCodes(
current: BusinessCostFormData["bizCstList"],
next: BusinessCostFormData["bizCstList"],
) {
return (
current.length === next.length &&
current.every((item, index) => item.acntgSeCd === next[index]?.acntgSeCd)
);
}
function BusinessCostFormSection() {
const { control, setValue, getValues } = useFormContext<BusinessCostFormData>();
const bizCstList = useWatch({ control, name: "bizCstList" }) ?? [];
@@ -42,22 +52,22 @@ function BusinessCostFormSection() {
useEffect(() => {
if (costItems.length === 0) return;
const existingMap = new Map(getValues("bizCstList").map((item) => [item.acntgSeCd, item]));
const current = getValues("bizCstList") ?? [];
const existingMap = new Map(current.map((item) => [item.acntgSeCd, item]));
const next = costItems.map((item) => {
const existing = existingMap.get(item.code);
if (existing) return existing;
setValue(
"bizCstList",
costItems.map((item) => {
const existing = existingMap.get(item.code);
if (existing) return existing;
return {
acntgSeCd: item.code,
bscPlanAmt: 0,
bgtRfltAmt: 0,
tstmDesignAmt: 0,
};
});
return {
acntgSeCd: item.code,
bscPlanAmt: 0,
bgtRfltAmt: 0,
tstmDesignAmt: 0,
};
}),
);
if (isSameBizCstCodes(current, next)) return;
setValue("bizCstList", next);
}, [costItems, getValues, setValue]);
function handleCostChange(index: number, fieldKey: CostFieldKey, value: string) {
@@ -119,58 +129,37 @@ function BusinessCostFormSection() {
</div>
</TableHead>
<TableCell>
<Controller
name={`bizCstList.${idx}.bgtRfltAmt`}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
type="number"
value={field.value || ""}
onChange={(e) => handleCostChange(idx, "bgtRfltAmt", e.target.value)}
readOnly={item.readonly}
aria-invalid={fieldState.invalid}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
)}
/>
<Field>
<Input
type="number"
value={data.bgtRfltAmt ?? ""}
onChange={(e) => handleCostChange(idx, "bgtRfltAmt", e.target.value)}
readOnly={item.readonly}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
</TableCell>
<TableCell>
<Controller
name={`bizCstList.${idx}.bscPlanAmt`}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
type="number"
value={field.value || ""}
onChange={(e) => handleCostChange(idx, "bscPlanAmt", e.target.value)}
readOnly={item.readonly}
aria-invalid={fieldState.invalid}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
)}
/>
<Field>
<Input
type="number"
value={data.bscPlanAmt ?? ""}
onChange={(e) => handleCostChange(idx, "bscPlanAmt", e.target.value)}
readOnly={item.readonly}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
</TableCell>
<TableCell>
<Controller
name={`bizCstList.${idx}.tstmDesignAmt`}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
type="number"
value={field.value || ""}
onChange={(e) => handleCostChange(idx, "tstmDesignAmt", e.target.value)}
readOnly={item.readonly}
aria-invalid={fieldState.invalid}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
)}
/>
<Field>
<Input
type="number"
value={data.tstmDesignAmt ?? ""}
onChange={(e) => handleCostChange(idx, "tstmDesignAmt", e.target.value)}
readOnly={item.readonly}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
</TableCell>
<TableCell>
<div className="text-right">
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useFormContext, useWatch } from "react-hook-form";
import { SectionCollapsible } from "@/shared/ui";
import { Field } from "@/shared/ui/field";
@@ -31,6 +31,16 @@ interface BusinessScaleFormData {
type ScaleFieldKey = "bscPl" | "budgetReflct" | "thtiDsgn";
function isSameBzScaleCodes(
current: BusinessScaleFormData["bzScaleList"],
next: BusinessScaleFormData["bzScaleList"],
) {
return (
current.length === next.length &&
current.every((item, index) => item.cnstpSeCd === next[index]?.cnstpSeCd)
);
}
function BusinessScaleFormSection() {
const { control, setValue, getValues } = useFormContext<BusinessScaleFormData>();
const bzScaleList = useWatch({ control, name: "bzScaleList" }) ?? [];
@@ -42,7 +52,8 @@ function BusinessScaleFormSection() {
useEffect(() => {
if (scaleItems.length === 0) return;
const existingMap = new Map(getValues("bzScaleList").map((item) => [item.cnstpSeCd, item]));
const current = getValues("bzScaleList") ?? [];
const existingMap = new Map(current.map((item) => [item.cnstpSeCd, item]));
const next = scaleItems.map((item) => {
const existing = existingMap.get(item.code);
@@ -57,6 +68,7 @@ function BusinessScaleFormSection() {
};
});
if (isSameBzScaleCodes(current, next)) return;
setValue("bzScaleList", recalculateBzScaleTotals(next, scaleItems));
}, [scaleItems, getValues, setValue]);
@@ -112,58 +124,37 @@ function BusinessScaleFormSection() {
</div>
</TableHead>
<TableCell>
<Controller
name={`bzScaleList.${idx}.bscPl`}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
type="number"
value={field.value || ""}
onChange={(e) => handleScaleChange(idx, "bscPl", e.target.value)}
readOnly={item.readonly}
aria-invalid={fieldState.invalid}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
)}
/>
<Field>
<Input
type="number"
value={data.bscPl ?? ""}
onChange={(e) => handleScaleChange(idx, "bscPl", e.target.value)}
readOnly={item.readonly}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
</TableCell>
<TableCell>
<Controller
name={`bzScaleList.${idx}.budgetReflct`}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
type="number"
value={field.value || ""}
onChange={(e) => handleScaleChange(idx, "budgetReflct", e.target.value)}
readOnly={item.readonly}
aria-invalid={fieldState.invalid}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
)}
/>
<Field>
<Input
type="number"
value={data.budgetReflct ?? ""}
onChange={(e) => handleScaleChange(idx, "budgetReflct", e.target.value)}
readOnly={item.readonly}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
</TableCell>
<TableCell>
<Controller
name={`bzScaleList.${idx}.thtiDsgn`}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
type="number"
value={field.value || ""}
onChange={(e) => handleScaleChange(idx, "thtiDsgn", e.target.value)}
readOnly={item.readonly}
aria-invalid={fieldState.invalid}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
)}
/>
<Field>
<Input
type="number"
value={data.thtiDsgn ?? ""}
onChange={(e) => handleScaleChange(idx, "thtiDsgn", e.target.value)}
readOnly={item.readonly}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
</TableCell>
<TableCell>
<div className="text-right">
@@ -1,18 +1,15 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useFormContext, useWatch } from "react-hook-form";
import { useQuery } from "@tanstack/react-query";
import { SectionCollapsible } from "@/shared/ui";
import { Field, FieldLabel } from "@/shared/ui/field";
import { cklstMngService, type CklstTreeNode } from "@/entities/cklst";
import { useCodeList } from "@/entities/code";
import {
Button,
RadioGroup,
RadioGroupItem,
Skeleton,
Table,
TableBody,
@@ -125,11 +122,21 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
}
}, [checklistTabs, activeTab]);
function handleSelectChange(resultIdx: number, value: "Y" | "N") {
const currentList = getValues("cklstResultList") as CklstResultListItem[];
const row = currentList[resultIdx];
if (!row) return;
const newList = [...currentList];
newList[resultIdx] = { ...row, selected: value };
setValue("cklstResultList", newList, { shouldDirty: true });
}
function handleSelectAll(tabCode: string, value: "Y" | "N") {
const tab = checklistTabs.find((t) => t.code === tabCode);
if (!tab) return;
const currentList = getValues("cklstResultList");
const currentList = getValues("cklstResultList") as CklstResultListItem[];
const newList = [...currentList];
tab.items
@@ -141,7 +148,7 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
}
});
setValue("cklstResultList", newList);
setValue("cklstResultList", newList, { shouldDirty: true });
}
const hasSelectedTaskCodes = Boolean(taskSeComCdArray?.length);
@@ -234,6 +241,8 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
{tab.items.map((item) => {
const resultIdx = cklstRvwSnIndexMap.get(item.cklstRvwSn) ?? -1;
const showRadios = item.bchckUseYn === "Y";
const selected = cklstResultList[resultIdx]?.selected;
const radioName = `check-${item.cklstRvwSn}`;
return (
<TableRow key={item.cklstRvwSn}>
@@ -248,66 +257,30 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
{item.content}
</TableCell>
{showRadios && resultIdx >= 0 ? (
<Controller
name={`cklstResultList.${resultIdx}.selected`}
control={control}
render={({ field, fieldState }) => (
<>
<TableCell className="text-center">
<RadioGroup
name={field.name}
value={field.value === "Y" ? "Y" : undefined}
onValueChange={field.onChange}
className="flex justify-center"
>
<FieldLabel
htmlFor={`check-${item.cklstRvwSn}-Y`}
className="!w-auto justify-center border-none"
>
<Field
orientation="horizontal"
data-invalid={fieldState.invalid}
className="w-auto"
>
<RadioGroupItem
value="Y"
id={`check-${item.cklstRvwSn}-Y`}
aria-invalid={fieldState.invalid}
aria-label="예"
/>
</Field>
</FieldLabel>
</RadioGroup>
</TableCell>
<TableCell className="text-center">
<RadioGroup
name={field.name}
value={field.value === "N" ? "N" : undefined}
onValueChange={field.onChange}
className="flex justify-center"
>
<FieldLabel
htmlFor={`check-${item.cklstRvwSn}-N`}
className="!w-auto justify-center border-none"
>
<Field
orientation="horizontal"
data-invalid={fieldState.invalid}
className="w-auto"
>
<RadioGroupItem
value="N"
id={`check-${item.cklstRvwSn}-N`}
aria-invalid={fieldState.invalid}
aria-label="아니오"
/>
</Field>
</FieldLabel>
</RadioGroup>
</TableCell>
</>
)}
/>
<>
<TableCell className="text-center">
<input
type="radio"
name={radioName}
value="Y"
checked={selected === "Y"}
onChange={() => handleSelectChange(resultIdx, "Y")}
aria-label="예"
className="cursor-pointer"
/>
</TableCell>
<TableCell className="text-center">
<input
type="radio"
name={radioName}
value="N"
checked={selected === "N"}
onChange={() => handleSelectChange(resultIdx, "N")}
aria-label="아니오"
className="cursor-pointer"
/>
</TableCell>
</>
) : null}
</TableRow>
);
@@ -1,18 +1,17 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useFormContext, useWatch } from "react-hook-form";
import { getDefaultFileKey, useTus } from "@/shared/lib";
import { TextShimmer } from "@/shared/ui";
import { SectionCollapsible } from "@/shared/ui";
import { AttachmentFileField, SectionCollapsible } from "@/shared/ui";
import { Field } from "@/shared/ui/field";
import { useAcntgSeMngTree } from "@/entities/acntg";
import { useDownloadAttachmentByAtflId } from "@/entities/attach";
import type { AtflMngClientResponseDto } from "@/entities/fnrs-cnsltn";
import type { FnrsSharBizCstTreeDto } from "@/entities/fnrs-shar-se";
import { useFnrsSharSeMngTree } from "@/entities/fnrs-shar-se";
import {
Button,
cn,
Input,
Skeleton,
@@ -37,6 +36,7 @@ import { calcDiffPercent } from "../lib/format-utils";
import { BizCstTotalDisplayTable } from "./biz-cst-total-display-table";
const SHAR_FILE_KEY_PREFIX = "fnrs-shar::";
const SHAR_FILE_ACCEPT = ".hwp,.hwpx";
interface FnrsSharFormData {
fnrsSharList: FnrsSharListItem[];
@@ -53,6 +53,12 @@ interface FnrsSharFormData {
type SharFieldKey = "exstnAmt" | "chgRqtAmt" | "rmrkCn";
interface SharUploadedFile {
fileKey: string;
fileName: string;
atflId?: string;
}
interface FnrsSharFormSectionProps {
initialSharTree?: FnrsSharBizCstTreeDto[];
initialSharAtchFiles?: AtflMngClientResponseDto[];
@@ -62,12 +68,30 @@ function createSharUploadKey(file: File) {
return `${SHAR_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
}
function toInitialSharFile(file?: AtflMngClientResponseDto): SharUploadedFile | null {
if (!file?.orgnlFileNm) return null;
return {
fileKey: file.atflId ? `server::${file.atflId}` : `${SHAR_FILE_KEY_PREFIX}${file.atflCd}`,
fileName: file.orgnlFileNm,
atflId: file.atflId,
};
}
function isSameFnrsSharCodes(current: FnrsSharListItem[], next: FnrsSharListItem[]) {
return (
current.length === next.length &&
current.every((item, index) => item.fnrsSharSeCd === next[index]?.fnrsSharSeCd)
);
}
function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsSharFormSectionProps) {
const { control, setValue, getValues } = useFormContext<FnrsSharFormData>();
const fnrsSharList = useWatch({ control, name: "fnrsSharList" }) ?? [];
const [uploadedFileName, setUploadedFileName] = useState(
() => initialSharAtchFiles?.[0]?.orgnlFileNm ?? "",
const [uploadedFile, setUploadedFile] = useState<SharUploadedFile | null>(() =>
toInitialSharFile(initialSharAtchFiles?.[0]),
);
const { mutate: downloadByAtflId } = useDownloadAttachmentByAtflId();
const { data: masterTree = [], isLoading: isMasterLoading } = useFnrsSharSeMngTree();
const { data: acntgTree = [] } = useAcntgSeMngTree();
@@ -78,9 +102,13 @@ function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsShar
const isLoading = initialSharTree?.length ? false : isMasterLoading;
const { uploadFileAsync, isUploading } = useTus({
onSuccess: (atflCd) => {
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd) return;
setValue("fnrsCnsltnMng.sharAtchFileGroupId", atflCd, { shouldDirty: true });
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflId } : prev));
},
onError: (_error, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
});
const isSharUploading = Object.values(isUploading).some(Boolean);
@@ -96,6 +124,7 @@ function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsShar
: mergeFnrsSharTreeWithForm(sharTree, []);
const withTotals = recalculateFnrsSharTotals(merged, sharItems);
if (isSameFnrsSharCodes(existing, withTotals)) return;
setValue("fnrsSharList", withTotals);
}, [sharItems, sharTree, initialSharTree, getValues, setValue]);
@@ -120,16 +149,32 @@ function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsShar
applySharList(draft);
}
async function handleSharFileChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
async function handleSharFileAdd(files: FileList | null) {
const file = files?.[0];
if (!file) return;
const fileKey = createSharUploadKey(file);
setUploadedFileName(file.name);
await uploadFileAsync(file, { fileKey });
event.target.value = "";
setUploadedFile({ fileName: file.name, fileKey });
try {
await uploadFileAsync(file, { fileKey });
} catch {
// onError callback handles cleanup
}
}
const sharAtchFiles = uploadedFile
? [
{
fileKey: uploadedFile.fileKey,
fileName: uploadedFile.fileName,
onDownload: uploadedFile.atflId
? () => downloadByAtflId(uploadedFile.atflId!)
: undefined,
},
]
: [];
if (isLoading) {
return (
<SectionCollapsible title="재원분담">
@@ -189,40 +234,26 @@ function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsShar
{item.label}
</TableHead>
<TableCell>
<Controller
name={`fnrsSharList.${idx}.exstnAmt`}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
type="number"
value={field.value ?? ""}
onChange={(e) => handleSharChange(idx, "exstnAmt", e.target.value)}
readOnly={item.readonly}
aria-invalid={fieldState.invalid}
className={cn("text-right", item.readonly && "bg-muted")}
/>
</Field>
)}
/>
<Field>
<Input
type="number"
value={data.exstnAmt ?? ""}
onChange={(e) => handleSharChange(idx, "exstnAmt", e.target.value)}
readOnly={item.readonly}
className={cn("text-right", item.readonly && "bg-muted")}
/>
</Field>
</TableCell>
<TableCell>
<Controller
name={`fnrsSharList.${idx}.chgRqtAmt`}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
type="number"
value={field.value ?? ""}
onChange={(e) => handleSharChange(idx, "chgRqtAmt", e.target.value)}
readOnly={item.readonly}
aria-invalid={fieldState.invalid}
className={cn("text-right", item.readonly && "bg-muted")}
/>
</Field>
)}
/>
<Field>
<Input
type="number"
value={data.chgRqtAmt ?? ""}
onChange={(e) => handleSharChange(idx, "chgRqtAmt", e.target.value)}
readOnly={item.readonly}
className={cn("text-right", item.readonly && "bg-muted")}
/>
</Field>
</TableCell>
<TableCell>
<div className="text-right">
@@ -233,19 +264,12 @@ function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsShar
<div className="text-right">{calcDiffPercent(diff, data.exstnAmt || 0)}</div>
</TableCell>
<TableCell>
<Controller
name={`fnrsSharList.${idx}.rmrkCn`}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
value={field.value ?? ""}
onChange={(e) => handleSharChange(idx, "rmrkCn", e.target.value)}
aria-invalid={fieldState.invalid}
/>
</Field>
)}
/>
<Field>
<Input
value={data.rmrkCn ?? ""}
onChange={(e) => handleSharChange(idx, "rmrkCn", e.target.value)}
/>
</Field>
</TableCell>
</TableRow>
);
@@ -257,31 +281,20 @@ function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsShar
<div className="mt-6 px-2 pb-2">
<h3 className="mb-2 text-xl font-semibold">첨부파일</h3>
<div className="flex flex-wrap items-center gap-2">
{isSharUploading && uploadedFileName ? (
<TextShimmer className="max-w-lg text-xl" active>
{uploadedFileName}
</TextShimmer>
) : (
<Input
value={uploadedFileName}
readOnly
placeholder="총사업비 신청서(.hwp) 파일을 등록해주세요."
className="max-w-lg rounded-sm"
/>
)}
<Button type="button" color="white" disabled={isSharUploading} asChild>
<label className="cursor-pointer">
첨부파일
<input
type="file"
accept=".hwp,.hwpx"
className="hidden"
onChange={handleSharFileChange}
/>
</label>
</Button>
</div>
{!uploadedFile ? (
<p className="mb-2 px-1 text-xl text-muted-foreground">
총사업비 신청서(.hwp) 파일을 등록해주세요.
</p>
) : null}
<AttachmentFileField
files={sharAtchFiles}
inputId="fnrs-shar-atch-file-input"
isUploadingByKey={isUploading}
accept={SHAR_FILE_ACCEPT}
multiple={false}
addDisabled={isSharUploading}
onAdd={(fileList) => void handleSharFileAdd(fileList)}
/>
</div>
</SectionCollapsible>
);
@@ -4,11 +4,7 @@ import { useEffect, useState } from "react";
import { useUserStore } from "@/shared/lib";
import { Field, FieldGroup, FieldLabel, FieldSet } from "@/shared/ui/field";
import {
type CmCdAdstDstrComboResponseDto,
useFindUserGugunCombo,
useFindUserSidoCombo,
} from "@/entities/adstdstr";
import { useFindUserSidoGugunCombo } from "@/entities/adstdstr";
import { useCodeList } from "@/entities/code";
import {
Button,
@@ -46,8 +42,6 @@ interface PbadmsTaskApplyDialogProps {
codeUpCd?: string;
}
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
function PbadmsTaskApplyDialog({
open,
onOpenChange,
@@ -60,18 +54,15 @@ function PbadmsTaskApplyDialog({
const [sggComCd, setSggComCd] = useState("");
const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
const sidoFilterUserInfo =
userInfo?.dtlInstCd === "ROLE_USER04" || userInfo?.dtlInstCd === "ROLE_USER05"
? userInfo
: undefined;
const { data: sidoData, isPending: isSidoPending } = useFindUserSidoCombo(sidoFilterUserInfo);
const { data: gugunData, isPending: isGugunPending } = useFindUserGugunCombo(sidoComCd);
const {
userSidoData,
userGugunData,
isUserSidoPending,
isUserGugunPending,
} = useFindUserSidoGugunCombo(sidoComCd);
const { data: taskOptions, isPending: isTaskSePending } = useCodeList(codeUpCd);
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
const isPending = isSidoPending || isTaskSePending;
const isPending = isUserSidoPending || isTaskSePending;
const canSidoGugunSelectable = userInfo?.dtlInstCd !== "ROLE_USER05";
const canConfirm = selectedCodes.length > 0 && (!canSidoGugunSelectable || !!sidoComCd);
@@ -93,8 +84,8 @@ function PbadmsTaskApplyDialog({
function handleConfirm() {
if (!canConfirm) return;
const sidoOption = sidoOptions.find((option) => option.adstDstrCd === sidoComCd);
const gugunOption = gugunOptions.find((option) => option.adstDstrCd === sggComCd);
const sidoOption = userSidoData.find((option) => option.adstDstrCd === sidoComCd);
const gugunOption = userGugunData.find((option) => option.adstDstrCd === sggComCd);
onConfirm({
admdstCd: sggComCd || sidoComCd,
@@ -137,7 +128,7 @@ function PbadmsTaskApplyDialog({
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{sidoOptions.map((option) => (
{userSidoData.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
@@ -150,13 +141,13 @@ function PbadmsTaskApplyDialog({
<Select
value={sggComCd || undefined}
onValueChange={setSggComCd}
disabled={!sidoComCd || isGugunPending}
disabled={!sidoComCd || isUserGugunPending}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{gugunOptions.map((option) => (
{userGugunData.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
@@ -5,7 +5,12 @@ import { Controller, useFormContext, useWatch } from "react-hook-form";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
import { AttachmentFileField, SectionCollapsible } from "@/shared/ui";
import { AttachmentDownloadLink, attachService, useDownloadAttachmentByAtflId } from "@/entities/attach";
import {
AttachmentDownloadLink,
attachService,
useDownloadAttachmentByAtflId,
} from "@/entities/attach";
import { FnrsCnsltnSplmntDmndResponseDto } from "@/entities/fnrs-cnsltn";
import {
Badge,
Table,
@@ -46,6 +51,7 @@ interface SplmntDmndFormData {
}
interface SplmntDmndFormSectionProps {
initialData?: FnrsCnsltnSplmntDmndResponseDto[];
readOnly?: boolean;
}
@@ -65,7 +71,7 @@ function getRowKey(item: SplmntDmndFormItem, index: number) {
return item.splmntDmndSn != null ? String(item.splmntDmndSn) : `idx-${index}`;
}
function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps) {
function SplmntDmndFormSection({ initialData, readOnly = false }: SplmntDmndFormSectionProps) {
const formId = useId();
const { control, setValue, getValues } = useFormContext<SplmntDmndFormData>();
const splmntDmndList = useWatch({ control, name: "splmntDmndList" }) ?? [];
@@ -260,6 +266,7 @@ function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps)
const isRowUploading = rowFiles.some((file) => isUploading[file.fileKey] ?? false);
const fileInputId = `${formId}-${rowKey}-file`;
const isRowReadOnly = readOnly || item.cmptnYn === "Y";
const splmntAtchFileInfo = initialData?.[index]?.splmntAtchFileInfo;
return (
<TableRow key={rowKey}>
@@ -282,7 +289,15 @@ function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps)
</TableCell>
<TableCell className="align-top">
{isRowReadOnly ? (
<span className="whitespace-pre-wrap">{item.splmntCn || "-"}</span>
<div className="space-y-2">
<div className="whitespace-pre-wrap">{item.splmntCn || "-"}</div>
{splmntAtchFileInfo?.atflCd ? (
<AttachmentDownloadLink
atflCd={splmntAtchFileInfo?.atflCd}
fileName={splmntAtchFileInfo?.orgnlFileNm}
/>
) : null}
</div>
) : (
<Controller
control={control}
@@ -292,7 +307,7 @@ function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps)
value={field.value ?? ""}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="보완완료 내용을 입력하세요."
placeholder="보완 완료 내용을 입력하세요."
className="min-h-[6rem] !text-xl"
/>
)}
@@ -1,3 +1,7 @@
"use client";
export { PostPage as NoticePage } from "@/widgets/post";
import { PostPage } from "@/widgets/post";
export function NoticePage({ bbsId }: { bbsId: string }) {
return <PostPage bbsId={bbsId} tableCaption="공지사항 목록" />;
}
@@ -35,7 +35,13 @@ import { columns as tableColumns } from "../model/post-table-columns";
import { createColumns as createGridViewColumns } from "../model/grid-view-table-columns";
import { SortSelect } from "./sort-select";
function PostPage({ bbsId }: { bbsId: string }) {
function PostPage({
bbsId,
tableCaption,
}: {
bbsId: string;
tableCaption?: string;
}) {
const router = useRouter();
const pathname = usePathname();
const { confirm } = useConfirm();
@@ -116,8 +122,6 @@ function PostPage({ bbsId }: { bbsId: string }) {
meta: { bbsTypeCd: bbsInfo?.bbsTypeCd },
});
const DataTableComponent = isGridViewPage ? GridViewDataTable : DataTable;
const onPostDelete = async () => {
const selectedPosts = table
.getFilteredSelectedRowModel()
@@ -245,11 +249,20 @@ function PostPage({ bbsId }: { bbsId: string }) {
{isLoading ? (
<Skeleton className="h-[36rem] w-full" />
) : (
<DataTableComponent
table={table}
onRowClick={handleRowClick}
viewPagination
/>
isGridViewPage ? (
<GridViewDataTable
table={table}
onRowClick={handleRowClick}
viewPagination
/>
) : (
<DataTable
table={table}
onRowClick={handleRowClick}
viewPagination
caption={tableCaption}
/>
)
)}
</div>
</div>
@@ -276,7 +276,9 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
)}
</div>
</PostAction>
<PostComment bbsId={parseInt(bbsId)} pstNo={parseInt(pstNo)} />
{bbsInfo?.cmntUseYn === "Y" && (
<PostComment bbsId={parseInt(bbsId)} pstNo={parseInt(pstNo)} />
)}
</>
);
}
@@ -1,10 +1,12 @@
import { z } from "zod";
import type { PageableList, PageableParams } from "@/shared/model";
export interface AuthorizationsParams extends PageableParams {
keywordType?: string;
keyword?: string;
authorizationName?: string;
urlPatternValue?: string;
httpMethodCode?: string;
useYn?: string;
}
+19 -2
View File
@@ -23,6 +23,7 @@ import { columns } from "../model/authorizations-table-columns";
const DEFAULT_PARAMS = {
authorizationName: "",
urlPatternValue: "",
httpMethodCode: "",
sort: "regDt,desc",
page: 0,
@@ -36,6 +37,9 @@ function ModulePage() {
const [authorizationName, setAuthorizationName] = useState(
params.authorizationName,
);
const [urlPatternValue, setUrlPatternValue] = useState(
params.urlPatternValue,
);
const [httpMethodCode, setHttpMethodCode] = useState(params.httpMethodCode);
const query = useQuery({
@@ -43,6 +47,7 @@ function ModulePage() {
queryFn: () =>
AuthorizationsApi.getAuthorizationsList({
authorizationName: params.authorizationName,
urlPatternValue: params.urlPatternValue,
httpMethodCode: params.httpMethodCode,
sort: params.sort,
page: params.page,
@@ -63,11 +68,12 @@ function ModulePage() {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setParams({ authorizationName, httpMethodCode, page: 0 });
setParams({ authorizationName, urlPatternValue, httpMethodCode, page: 0 });
};
const handleReset = () => {
setAuthorizationName("");
setUrlPatternValue("");
setHttpMethodCode("");
resetParams();
};
@@ -113,12 +119,23 @@ function ModulePage() {
API명
</Label>
<Input
id="keyword"
id="authorizationName"
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
value={authorizationName}
onChange={(e) => setAuthorizationName(e.target.value)}
/>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="urlPatternValue" className="text-label">
API URL
</Label>
<Input
id="urlPatternValue"
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
value={urlPatternValue}
onChange={(e) => setUrlPatternValue(e.target.value)}
/>
</div>
</div>
<Button className="h-[3.2rem]" type="submit">
조회
+15 -9
View File
@@ -1,10 +1,17 @@
"use client";
import dayjs from "dayjs";
import Image from "next/image";
import { useEffect, useState } from "react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import dayjs from "dayjs";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useMenuStore, useMutationWrapper } from "@/shared/lib";
import { AttachmentApi } from "@/entities/attachment";
import { BoardApi } from "@/entities/board";
import { PostApi } from "@/entities/post";
import {
Button,
Label,
@@ -12,14 +19,11 @@ import {
RadioGroupItem,
Skeleton,
} from "@common/components";
import { AttachmentApi } from "@/entities/attachment";
import { PostApi } from "@/entities/post";
import { BoardApi } from "@/entities/board";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useMutationWrapper, useMenuStore } from "@/shared/lib";
import { PostEvaluationApi } from "../api/post-evaluation-service";
import { PostAction } from "./post-action";
import { PostComment } from "./post-comment";
import { PostEvaluationApi } from "../api/post-evaluation-service";
function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
const router = useRouter();
@@ -255,7 +259,9 @@ function Post({ bbsId, pstNo }: { bbsId: string; pstNo: string }) {
)}
</div>
</PostAction>
<PostComment bbsId={parseInt(bbsId)} pstNo={parseInt(pstNo)} />
{bbsInfo?.cmntUseYn === "Y" && (
<PostComment bbsId={parseInt(bbsId)} pstNo={parseInt(pstNo)} />
)}
</>
);
}
+193 -191
View File
@@ -1,220 +1,222 @@
/**
* JWT를 decoding하면 만료시간을 읽을 수 있다.
* JWT는 3부분으로 구성 : Header.Payload.Signature
* Payload 부분에 만료시간(exp)이 있다.
* 티베로(하수도정보시스템) 조회 콘솔
*
* - reuse-service 의 티베로 데이터소스로 조회 SQL 한 문장(SELECT, WITH)을 실행하고 결과를 표로 보여준다.
* - 서버에서 조회 문장만 통과시키고, 읽기 전용 연결과 최대 행 수(기본 100, 최대 1000) 제한을 적용한다.
* - 서버 설정 tibero.sql-console.enabled=true 일 때만 동작한다.
*
* 🔗 사용 API
* 티베로 조회 SQL 실행 POST /ones/sql body: { sql, maxRows }
*
* 이전 JWT 만료시간 테스트 화면은 같은 폴더의 page.tsx.bak 에 보관되어 있다.
*/
"use client";
import { useEffect, useRef, useState } from "react";
import { authService } from "@/src/entities/auth";
import { CLAIM_NAME } from "@/src/shared/config";
import { getCookie } from "@/src/shared/lib";
import { useCallback, useMemo, useState } from "react";
import { fetchType, postFectch } from "@/components/lib/api";
interface JwtPayload {
sub?: string; // 토큰 subject. 일반적으로 사용자 식별자나 주체 정보
exp?: number; // 만료 시각(Unix timestamp, 초 단위)
iat?: number; // 토큰 발급 시각(Unix timestamp, 초 단위)
[key: string]: unknown; // 그 외 JWT payload에 포함될 수 있는 추가 클레임
}
interface JwtState {
token: string; // 쿠키에서 읽어온 JWT 원본 문자열
payload: JwtPayload | null; // JWT를 파싱한 payload 객체
exp: number | null; // payload에서 추출한 exp 값
expDateLocal: string | null; // exp를 로컬 시간 문자열로 변환한 값
remainingMs: number | null; // 현재 시각 기준 남은 시간(ms)
error: string | null; // 쿠키 없음, 파싱 실패 등 오류 메시지
}
const initialJwtState: JwtState = {
token: "",
payload: null,
exp: null,
expDateLocal: null,
remainingMs: null,
error: null,
type TiberoSqlResult = {
success: boolean;
message: string | null;
columns: string[];
rows: (string | null)[][];
rowCount: number;
truncated: boolean;
maxRows: number;
elapsedMs: number;
};
function parseJwt(token: string): JwtPayload {
const parts = token.split(".");
if (parts.length !== 3) {
throw new Error("유효한 JWT 형식이 아닙니다.");
}
type TiberoSqlResponse = {
status: number;
data: TiberoSqlResult | null;
count: number;
msg: string | null;
};
const base64Url = parts[1] ?? ""; // Payload를 가져온다.
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
const jsonPayload = decodeURIComponent(
atob(padded)
.split("")
.map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, "0")}`)
.join("")
);
const DEFAULT_SQL = `SELECT BSC_FCT_CD, BSC_FCT_NM, RGN_CD, ADST_DSTR_CD, FCT_CPC
FROM ONSEMIRO_NEW.OM_MS_VW
WHERE ROWNUM <= 20`;
return JSON.parse(jsonPayload) as JwtPayload;
}
const MAX_ROWS_LIMIT = 1000;
function formatRemainingMs(remainingMs: number | null): string {
if (remainingMs === null) return "-";
if (remainingMs <= 0) return "만료됨";
const toTsv = (columns: string[], rows: (string | null)[][]) =>
[columns, ...rows]
.map((row) => row.map((value) => (value ?? "").replace(/[\t\r\n]+/g, " ")).join("\t"))
.join("\n");
const totalSeconds = Math.floor(remainingMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
export default function TiberoSqlConsolePage() {
const [sql, setSql] = useState<string>(DEFAULT_SQL);
const [maxRows, setMaxRows] = useState<number>(100);
const [loading, setLoading] = useState<boolean>(false);
const [result, setResult] = useState<TiberoSqlResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState<boolean>(false);
return `${hours}시간 ${minutes}분 ${seconds}초`;
}
function readJwtState(): JwtState {
const token = getCookie(CLAIM_NAME) ?? "";
if (!token) {
return {
token: "",
payload: null,
exp: null,
expDateLocal: null,
remainingMs: null,
error: `${CLAIM_NAME} 쿠키가 없습니다.`,
};
}
try {
const payload = parseJwt(token);
if (typeof payload.exp !== "number") {
return {
token,
payload,
exp: null,
expDateLocal: null,
remainingMs: null,
error: "JWT payload에 exp 값이 없습니다.",
};
const runQuery = useCallback(async () => {
if (loading) return;
if (!sql.trim()) {
setError("SQL을 입력해주세요.");
return;
}
const expMs = payload.exp * 1000;
return {
token,
payload,
exp: payload.exp,
expDateLocal: new Date(expMs).toLocaleString(),
remainingMs: Math.max(expMs - Date.now(), 0),
error: null,
};
} catch (error) {
return {
token,
payload: null,
exp: null,
expDateLocal: null,
remainingMs: null,
error:
error instanceof Error
? error.message
: "JWT 파싱 중 오류가 발생했습니다.",
};
}
}
export default function Page() {
const [jwtState, setJwtState] = useState<JwtState>(initialJwtState);
const isRefreshingRef = useRef(false);
const handleSilentRefresh = async () => {
setLoading(true);
setError(null);
setCopied(false);
try {
await authService.silentRefresh();
} catch (error) {
console.error("Manual silent refresh failed:", error);
const response = (await postFectch(
"/ones/sql",
{ sql, maxRows },
fetchType.WR
)) as TiberoSqlResponse;
const data = response?.data ?? null;
setResult(data);
if (!data || !data.success) {
setError(response?.msg || data?.message || "조회에 실패했습니다.");
}
} catch (e) {
setResult(null);
setError(e instanceof Error ? e.message : "요청 중 오류가 발생했습니다.");
} finally {
setJwtState(readJwtState());
setLoading(false);
}
};
}, [loading, sql, maxRows]);
useEffect(() => {
const syncJwtState = async () => {
const nextState = readJwtState();
const copyTsv = useCallback(async () => {
if (!result?.success) return;
try {
await navigator.clipboard.writeText(toTsv(result.columns, result.rows));
setCopied(true);
} catch {
setError("클립보드 복사에 실패했습니다.");
}
}, [result]);
if (
nextState.remainingMs !== null &&
nextState.remainingMs > 0 &&
nextState.remainingMs < 5 * 60 * 1000 &&
!isRefreshingRef.current
) {
isRefreshingRef.current = true;
try {
await authService.silentRefresh();
} catch (error) {
console.error("JWT test silent refresh failed:", error);
}
}
setJwtState(readJwtState());
if (
nextState.remainingMs === null ||
nextState.remainingMs >= 5 * 60 * 1000 ||
nextState.remainingMs <= 0
) {
isRefreshingRef.current = false;
}
};
void syncJwtState();
const timer = window.setInterval(() => {
void syncJwtState();
}, 1000);
return () => window.clearInterval(timer);
}, []);
const summary = useMemo(() => {
if (!result?.success) return null;
return `${result.rowCount.toLocaleString()}행 · ${result.elapsedMs.toLocaleString()}ms · 최대 ${result.maxRows.toLocaleString()}행`;
}, [result]);
return (
<div className="space-y-4 p-8 text-base">
<h1 className="text-2xl font-bold">JWT Exp Test</h1>
<div className="flex h-full flex-col gap-4 p-6 text-base">
<div className="flex items-baseline justify-between">
<h1 className="text-2xl font-bold">티베로 조회 콘솔</h1>
<span className="text-sm text-gray-500">
SELECT, WITH 한 문장만 실행됩니다 · Ctrl+Enter 실행
</span>
</div>
<button
type="button"
className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
onClick={() => {
void handleSilentRefresh();
<textarea
className="h-48 w-full resize-y rounded border border-gray-300 p-3 font-mono text-xl focus:border-blue-500 focus:outline-none"
value={sql}
spellCheck={false}
placeholder="SELECT ... FROM ONSEMIRO_NEW.테이블명"
onChange={(e) => setSql(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
void runQuery();
}
}}
>
Silent Refresh 호출
</button>
/>
<div className="rounded border p-4">
<div className="font-semibold">쿠키명</div>
<div>{CLAIM_NAME}</div>
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 text-sm">
최대 행 수
<input
type="number"
min={1}
max={MAX_ROWS_LIMIT}
className="w-24 rounded border border-gray-300 px-2 py-1 text-right"
value={maxRows}
onChange={(e) => {
const next = Number(e.target.value);
if (Number.isFinite(next)) {
setMaxRows(Math.min(Math.max(Math.trunc(next), 1), MAX_ROWS_LIMIT));
}
}}
/>
</label>
<button
type="button"
className="rounded bg-blue-600 px-5 py-2 text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
disabled={loading}
onClick={() => void runQuery()}
>
{loading ? "실행 중..." : "실행"}
</button>
<button
type="button"
className="rounded border border-gray-300 px-4 py-2 hover:bg-gray-50 disabled:cursor-not-allowed disabled:text-gray-400"
disabled={!result?.success || result.rowCount === 0}
onClick={() => void copyTsv()}
>
{copied ? "복사됨" : "결과 복사(TSV)"}
</button>
{summary && <span className="text-sm text-gray-600">{summary}</span>}
</div>
<div className="rounded border p-4">
<div className="font-semibold">exp</div>
<div>{jwtState.exp ?? "-"}</div>
</div>
{error && (
<div className="whitespace-pre-wrap break-all rounded border border-red-300 bg-red-50 p-3 text-sm text-red-700">
{error}
</div>
)}
{result?.success && result.truncated && (
<div className="rounded border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800">
{result.message}
</div>
)}
<div className="rounded border p-4">
<div className="font-semibold">만료시각</div>
<div>{jwtState.expDateLocal ?? "-"}</div>
</div>
<div className="rounded border p-4">
<div className="font-semibold">남은시간</div>
<div>{formatRemainingMs(jwtState.remainingMs)}</div>
</div>
<div className="rounded border p-4">
<div className="font-semibold">상태</div>
<div>{jwtState.error ?? "정상"}</div>
</div>
<div className="rounded border p-4">
<div className="font-semibold">payload</div>
<pre className="overflow-auto whitespace-pre-wrap break-all text-sm">
{jwtState.payload ? JSON.stringify(jwtState.payload, null, 2) : "-"}
</pre>
</div>
{result?.success && (
<div className="min-h-0 flex-1 overflow-auto rounded border border-gray-300">
{result.columns.length === 0 ? (
<div className="p-4 text-sm text-gray-500">컬럼이 없습니다.</div>
) : (
<table className="min-w-full border-collapse text-sm">
<thead className="sticky top-0 bg-gray-100">
<tr>
<th className="border-b border-r border-gray-300 px-2 py-1 text-right text-gray-500">#</th>
{result.columns.map((column, index) => (
<th
key={`${column}-${index}`}
className="whitespace-nowrap border-b border-r border-gray-300 px-2 py-1 text-left font-semibold"
>
{column}
</th>
))}
</tr>
</thead>
<tbody>
{result.rows.length === 0 ? (
<tr>
<td
className="px-3 py-4 text-center text-gray-500"
colSpan={result.columns.length + 1}
>
조회 결과가 없습니다.
</td>
</tr>
) : (
result.rows.map((row, rowIndex) => (
<tr key={rowIndex} className="odd:bg-white even:bg-gray-50 hover:bg-blue-50">
<td className="border-b border-r border-gray-200 px-2 py-1 text-right text-gray-400">
{rowIndex + 1}
</td>
{row.map((value, colIndex) => (
<td
key={colIndex}
className="max-w-md truncate whitespace-nowrap border-b border-r border-gray-200 px-2 py-1"
title={value ?? "NULL"}
>
{value === null ? <span className="italic text-gray-400">NULL</span> : value}
</td>
))}
</tr>
))
)}
</tbody>
</table>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,220 @@
/**
* JWT를 decoding하면 만료시간을 읽을 수 있다.
* JWT는 3부분으로 구성 : Header.Payload.Signature
* Payload 부분에 만료시간(exp)이 있다.
*/
"use client";
import { useEffect, useRef, useState } from "react";
import { authService } from "@/src/entities/auth";
import { CLAIM_NAME } from "@/src/shared/config";
import { getCookie } from "@/src/shared/lib";
interface JwtPayload {
sub?: string; // 토큰 subject. 일반적으로 사용자 식별자나 주체 정보
exp?: number; // 만료 시각(Unix timestamp, 초 단위)
iat?: number; // 토큰 발급 시각(Unix timestamp, 초 단위)
[key: string]: unknown; // 그 외 JWT payload에 포함될 수 있는 추가 클레임
}
interface JwtState {
token: string; // 쿠키에서 읽어온 JWT 원본 문자열
payload: JwtPayload | null; // JWT를 파싱한 payload 객체
exp: number | null; // payload에서 추출한 exp 값
expDateLocal: string | null; // exp를 로컬 시간 문자열로 변환한 값
remainingMs: number | null; // 현재 시각 기준 남은 시간(ms)
error: string | null; // 쿠키 없음, 파싱 실패 등 오류 메시지
}
const initialJwtState: JwtState = {
token: "",
payload: null,
exp: null,
expDateLocal: null,
remainingMs: null,
error: null,
};
function parseJwt(token: string): JwtPayload {
const parts = token.split(".");
if (parts.length !== 3) {
throw new Error("유효한 JWT 형식이 아닙니다.");
}
const base64Url = parts[1] ?? ""; // Payload를 가져온다.
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
const jsonPayload = decodeURIComponent(
atob(padded)
.split("")
.map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, "0")}`)
.join("")
);
return JSON.parse(jsonPayload) as JwtPayload;
}
function formatRemainingMs(remainingMs: number | null): string {
if (remainingMs === null) return "-";
if (remainingMs <= 0) return "만료됨";
const totalSeconds = Math.floor(remainingMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return `${hours}시간 ${minutes}분 ${seconds}초`;
}
function readJwtState(): JwtState {
const token = getCookie(CLAIM_NAME) ?? "";
if (!token) {
return {
token: "",
payload: null,
exp: null,
expDateLocal: null,
remainingMs: null,
error: `${CLAIM_NAME} 쿠키가 없습니다.`,
};
}
try {
const payload = parseJwt(token);
if (typeof payload.exp !== "number") {
return {
token,
payload,
exp: null,
expDateLocal: null,
remainingMs: null,
error: "JWT payload에 exp 값이 없습니다.",
};
}
const expMs = payload.exp * 1000;
return {
token,
payload,
exp: payload.exp,
expDateLocal: new Date(expMs).toLocaleString(),
remainingMs: Math.max(expMs - Date.now(), 0),
error: null,
};
} catch (error) {
return {
token,
payload: null,
exp: null,
expDateLocal: null,
remainingMs: null,
error:
error instanceof Error
? error.message
: "JWT 파싱 중 오류가 발생했습니다.",
};
}
}
export default function Page() {
const [jwtState, setJwtState] = useState<JwtState>(initialJwtState);
const isRefreshingRef = useRef(false);
const handleSilentRefresh = async () => {
try {
await authService.silentRefresh();
} catch (error) {
console.error("Manual silent refresh failed:", error);
} finally {
setJwtState(readJwtState());
}
};
useEffect(() => {
const syncJwtState = async () => {
const nextState = readJwtState();
if (
nextState.remainingMs !== null &&
nextState.remainingMs > 0 &&
nextState.remainingMs < 5 * 60 * 1000 &&
!isRefreshingRef.current
) {
isRefreshingRef.current = true;
try {
await authService.silentRefresh();
} catch (error) {
console.error("JWT test silent refresh failed:", error);
}
}
setJwtState(readJwtState());
if (
nextState.remainingMs === null ||
nextState.remainingMs >= 5 * 60 * 1000 ||
nextState.remainingMs <= 0
) {
isRefreshingRef.current = false;
}
};
void syncJwtState();
const timer = window.setInterval(() => {
void syncJwtState();
}, 1000);
return () => window.clearInterval(timer);
}, []);
return (
<div className="space-y-4 p-8 text-base">
<h1 className="text-2xl font-bold">JWT Exp Test</h1>
<button
type="button"
className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
onClick={() => {
void handleSilentRefresh();
}}
>
Silent Refresh 호출
</button>
<div className="rounded border p-4">
<div className="font-semibold">쿠키명</div>
<div>{CLAIM_NAME}</div>
</div>
<div className="rounded border p-4">
<div className="font-semibold">exp</div>
<div>{jwtState.exp ?? "-"}</div>
</div>
<div className="rounded border p-4">
<div className="font-semibold">만료시각</div>
<div>{jwtState.expDateLocal ?? "-"}</div>
</div>
<div className="rounded border p-4">
<div className="font-semibold">남은시간</div>
<div>{formatRemainingMs(jwtState.remainingMs)}</div>
</div>
<div className="rounded border p-4">
<div className="font-semibold">상태</div>
<div>{jwtState.error ?? "정상"}</div>
</div>
<div className="rounded border p-4">
<div className="font-semibold">payload</div>
<pre className="overflow-auto whitespace-pre-wrap break-all text-sm">
{jwtState.payload ? JSON.stringify(jwtState.payload, null, 2) : "-"}
</pre>
</div>
</div>
);
}
@@ -11,7 +11,14 @@ import {
} from "@radix-ui/react-icons";
import { cn } from "../../lib";
import { Button } from "../button";
import { Table, TableCell, TableHead, TableHeader, TableRow } from "../table";
import {
Table,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../table";
import type { HTMLAttributes } from "react";
declare module "@tanstack/react-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -28,6 +35,7 @@ type DataTableProps<T> = {
className?: HTMLAttributes<HTMLTableElement>["className"];
viewPagination?: boolean;
onRowClick?: (row: T) => void;
caption?: string;
};
function DataTable<T>({
@@ -36,10 +44,12 @@ function DataTable<T>({
className,
viewPagination,
onRowClick,
caption,
}: DataTableProps<T>) {
return (
<>
<Table className={cn("w-full", className)} variant={variant}>
{caption && <TableCaption className="sr-only">{caption}</TableCaption>}
<THead table={table} />
<tbody>
{table.getRowModel().rows.map((row) => {
@@ -2,6 +2,7 @@ import "./header.css";
import { useEffect, useCallback, useState, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { Minus, Plus } from "lucide-react";
import { images } from "@common/assets";
import type { AuthService, TokenManager } from "@common/auth";
import { getRemainingMs } from "@common/auth";
@@ -346,9 +347,9 @@ export function Header({
{userSiteList.content.map((site) => (
<a
key={site.siteId}
href={site.dmnAddr}
href={siteId === site.siteId ? "#" : site.dmnAddr}
className="w-full py-2 text-center text-xl font-medium text-foreground transition-colors hover:text-primary"
target="_blank"
target={siteId === site.siteId ? "_self" : "_blank"}
rel="noopener noreferrer"
onClick={() => setIsMobileMenuOpen(false)}
>
@@ -368,9 +369,9 @@ export function Header({
{userSiteList.content.map((site) => (
<a
key={site.siteId}
href={site.dmnAddr}
href={siteId === site.siteId ? "#" : site.dmnAddr}
className="header-nav-link whitespace-nowrap text-sm font-medium text-foreground transition-colors hover:text-primary md:text-lg lg:text-2xl"
target="_blank"
target={siteId === site.siteId ? "_self" : "_blank"}
rel="noopener noreferrer"
>
{site.siteNm}
@@ -383,6 +384,7 @@ export function Header({
{/* 데스크톱에서 오른쪽 요소들 */}
<div className="hidden items-center gap-2 px-2 sm:flex md:px-4">
<SearchForm />
<ScreenZoomControls />
{mounted ? (
isLoggedIn && (
<Popover>
@@ -411,6 +413,48 @@ export function Header({
);
}
function ScreenZoomControls() {
const [scale, setScale] = useState(1);
const updateScale = (nextScale: number) => {
const boundedScale = Math.min(2, Math.max(0.5, nextScale));
document.body.style.zoom = String(boundedScale);
setScale(boundedScale);
};
return (
<div className="flex items-center gap-1" aria-label="화면 크기 조절">
<Button
type="button"
variant="ghost"
size="icon"
className="size-[2.8rem] !bg-background p-2 text-foreground hover:!bg-accent"
aria-label="화면 축소"
title="화면 축소"
disabled={scale <= 0.5}
onClick={() => updateScale(Number((scale - 0.1).toFixed(1)))}
>
<Minus />
</Button>
<span className="min-w-12 text-center text-sm" aria-live="polite">
{Math.round(scale * 100)}%
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-[2.8rem] !bg-background p-2 text-foreground hover:!bg-accent"
aria-label="화면 확대"
title="화면 확대"
disabled={scale >= 2}
onClick={() => updateScale(Number((scale + 0.1).toFixed(1)))}
>
<Plus />
</Button>
</div>
);
}
function UserMenuContent({
remainingMs,
onLogout,
@@ -107,7 +107,7 @@ export function NoticePopover() {
전체 선택
</Label>
</div>
<ul>
<ul className="max-h-[48rem] overflow-y-auto">
{noticeUserList?.map((item) => (
<li
key={item.notiId}
@@ -79,12 +79,31 @@ const TableRow = React.forwardRef<
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th ref={ref} className={cn("align-middle", className)} {...props} />
));
type TableHeadProps = React.ThHTMLAttributes<HTMLTableCellElement> & {
"data-required"?: boolean | string;
};
const TableHead = React.forwardRef<HTMLTableCellElement, TableHeadProps>(
({ children, className, ...props }, ref) => {
const isRequired = props["data-required"] !== undefined;
return (
<th
ref={ref}
scope="col"
className={cn("align-middle", className)}
{...props}
>
{children}
{isRequired && (
<span className="table-required-marker" title="필수 항목">
(*)
</span>
)}
</th>
);
},
);
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
@@ -7,9 +7,8 @@
.table-ui th {
@apply px-4 py-[1.2rem] text-[1.3rem] font-medium leading-[1.2];
}
.table-ui th[data-required]::before {
content: "*";
color: red;
.table-ui th[data-required] .table-required-marker {
@apply ml-1 text-red-600;
}
.table-ui td {
@apply px-2 py-[1.2rem] text-[1.3rem] font-normal leading-[1.2];