[as] feat: 보완요청 기능 구현

This commit is contained in:
2026-07-15 17:52:43 +09:00
parent 9e2da003f1
commit 70faadb27e
15 changed files with 735 additions and 406 deletions
@@ -65,6 +65,14 @@ async function techReview(id: string, payload: InstlSncnApplySaveRequestDto): Pr
await client.post(`${BASE}/${id}/tech-review`, payload);
}
async function submitReqSupplement(
id: string,
payload: InstlSncnApplySaveRequestDto,
): Promise<string> {
const response = await client.post<string>(`${BASE}/${id}/reqsupplement`, payload);
return response.data;
}
async function remove(id: string): Promise<void> {
await client.delete(`${BASE}/${id}`);
}
@@ -80,6 +88,7 @@ export const instlSncnService = {
saveApply,
update,
techReview,
submitReqSupplement,
remove,
changePrgrsSttsComCd,
} satisfies InstlSncnMngService;
@@ -99,6 +99,19 @@ export function useInstlSncnTechReview() {
});
}
export function useInstlSncnSubmitReqSupplement() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, payload }: { id: string; payload: InstlSncnApplySaveRequestDto }) =>
instlSncnService.submitReqSupplement(id, payload),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
queryClient.invalidateQueries({ queryKey: instlSncnKeys.detail(variables.id) });
},
});
}
export function useInstlSncnRemove() {
const queryClient = useQueryClient();
+1
View File
@@ -5,6 +5,7 @@ export {
useInstlSncnList,
useInstlSncnSidoCombo,
useInstlSncnSigunguCombo,
useInstlSncnSubmitReqSupplement,
useInstlSncnTechReview,
} from "./api/queries";
export {
@@ -190,15 +190,15 @@ export interface InstlSncnApplySaveRequestDto extends PbadmsApplySaveHeader {
/** 설치인가관리 정보 */
instlSncnMng: InstlSncnMngSaveRequestDto;
/** 사업규모 목록 */
bzScaleList: InstlSncnBzScaleSaveDto[];
bzScaleList?: InstlSncnBzScaleSaveDto[];
/** 총사업비 목록 */
bizCstList: InstlSncnBizCstSaveRequestDto[];
bizCstList?: InstlSncnBizCstSaveRequestDto[];
/** 체크리스트 검토결과 목록 */
cklstResultList: InstlSncnCklstResultSaveDto[];
cklstResultList?: InstlSncnCklstResultSaveDto[];
/** 체크리스트 검토결과 맵 (rvwStpComCd -> 목록) */
cklstResultMap?: Record<string, InstlSncnCklstResultSaveDto[]>;
/** 필수첨부파일 목록 */
esntlAtchFileList: InstlSncnEsntlAtchFileSaveDto[];
esntlAtchFileList?: InstlSncnEsntlAtchFileSaveDto[];
/** 검토의견 목록 */
rvwOpnnList?: InstlSncnRvwOpnnSaveRequestDto[];
/** 보완요구 목록 */
@@ -222,8 +222,11 @@ export type InstlSncnRvwOpnnResponseDto = InstlSncnRvwOpnnSaveRequestDto &
};
/** 설치인가관리 상세 응답 DTO */
export interface InstlSncnMngDetResponse
extends PbadmsMngDetResponseBase<InstlSncnMngResponseDto, InstlSncnRvwOpnnResponseDto> {
export interface InstlSncnMngDetResponse extends PbadmsMngDetResponseBase<
InstlSncnMngResponseDto,
InstlSncnRvwOpnnResponseDto,
InstlSncnSplmntDmndSaveDto
> {
detDto: InstlSncnMngResponseDto;
bizSclList: BizSclTreeDto[];
bizCstList: BizCstTreeDto[];
@@ -239,6 +242,7 @@ export interface InstlSncnMngService {
saveApply: (payload: InstlSncnApplySaveRequestDto) => Promise<string>;
update: (id: string, payload: InstlSncnMngUpdateRequestDto) => Promise<void>;
techReview: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise<void>;
submitReqSupplement: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise<string>;
remove: (id: string) => Promise<void>;
changePrgrsSttsComCd: (payload: DocPrgrsUpdateRequestDto) => Promise<void>;
}
@@ -15,7 +15,6 @@ export const FNRS_CNSLTN_VIEW_BUTTON_ACTION = {
dmndSplmnt: "dmndSplmnt",
atrzAprv: "atrzAprv",
atrzRply: "atrzRply",
grndsChck: "grndsChck",
} as const;
export type FnrsCnsltnViewButtonAction =
@@ -44,10 +43,6 @@ 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.rvwRply],
},
ROLE_USER01: {
TE: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.grndsChck],
SL: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.splmntCmptn],
},
};
const ROLE_USER01_ATRZ_STATUS_CODES = ["ER", "TE"] as const;
@@ -1,150 +1,168 @@
import type {
BizCstItem,
BizCstTreeDto,
BizSclTreeDto,
BzScaleItem,
CklstResultItem,
EsntlAtchFileItem,
EsntlAtchFileMngTreeResponseDto,
FnrsCnsltnApplySaveRequestDto,
FnrsCnsltnMngDetResponseDto,
FnrsCnsltnMngResponseDto,
FnrsCnsltnSplmntDmndItem,
} from "@/entities/fnrs-cnsltn";
import { mapFnrsSharDetToFormList, toFnrsSharSaveList } from "@/widgets/pbadms-sprt";
import type { ChecklistFormState, ReviewStepCode } from "../model/view-types";
import { toCklstSaveDto } from "./checklist-utils";
import { type ReviewOpinionGetFormDataResult, toRvwOpnnSaveDto } from "./review-opinion-utils";
export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): BzScaleItem[] {
return bizSclList
.filter((node) => node.useYn === "Y")
.map((node) => ({
cnstpSeCd: node.cnstpSeCd,
bscPl: node.bscPlanQty,
budgetReflct: node.bgtRfltQty,
thtiDsgn: node.tstmDesignQty,
}));
}
export function toBizCstSaveDto(bizCstList: BizCstTreeDto[]): BizCstItem[] {
return bizCstList
.filter((node) => node.useYn === "Y")
.map((node) => ({
acntgSeCd: node.acntgSeCd,
bscPlanAmt: node.bscPlanAmt,
bgtRfltAmt: node.bgtRfltAmt,
tstmDesignAmt: node.tstmDesignAmt,
}));
}
export function toEsntlAtchFileSaveDto(
esntlFileList: EsntlAtchFileMngTreeResponseDto[],
): EsntlAtchFileItem[] {
const esntlMap = new Map<string, string>();
for (const item of esntlFileList) {
if (!item.esntlAtchFileId || !item.atflCd) continue;
esntlMap.set(item.esntlAtchFileId, item.atflCd);
}
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
esntlAtchFileId,
atchFileGroupId,
}));
}
export function buildSavePayload(
data: FnrsCnsltnMngDetResponseDto,
checklistFormState: ChecklistFormState,
reviewStep: ReviewStepCode,
prgrsSttsComCd: string,
): FnrsCnsltnApplySaveRequestDto {
const detDto = data.detDto;
if (!detDto) {
throw new Error("상세 정보(detDto)가 없습니다.");
}
const bizSclList = data.bizSclList ?? [];
const bizCstList = data.bizCstList ?? [];
const cklstResultList = data.cklstResultList ?? [];
const esntlFileList = data.esntlFileList ?? [];
return {
fnrsCnsltnMng: { ...detDto, prgrsSttsComCd },
bzScaleList: toBzScaleSaveDto(bizSclList),
bizCstList: toBizCstSaveDto(bizCstList),
cklstResultList: toCklstSaveDto(cklstResultList, checklistFormState, reviewStep),
esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList),
fnrsSharList: toFnrsSharSaveList(mapFnrsSharDetToFormList(data.fnrsSharList ?? [])),
};
}
export function buildTechReviewReplyPayload(
data: FnrsCnsltnMngDetResponseDto,
checklistFormState: ChecklistFormState,
reviewStep: ReviewStepCode,
prgrsSttsComCd: string,
techReViewSaveCd: string,
reviewOpinionFormData?: ReviewOpinionGetFormDataResult | null,
opnrNm?: string,
): FnrsCnsltnApplySaveRequestDto {
return {
...buildSavePayload(data, checklistFormState, reviewStep, prgrsSttsComCd),
techReViewSaveCd,
rvwStpCd: reviewStep,
rvwOpnnList: toRvwOpnnSaveDto(data, reviewOpinionFormData, opnrNm),
};
}
export function buildSupplementReqPayload(
data: FnrsCnsltnMngDetResponseDto,
splmntDmndCn: string,
): FnrsCnsltnApplySaveRequestDto {
const detDto = data.detDto;
if (!detDto) {
throw new Error("상세 정보(detDto)가 없습니다.");
}
const nextSn =
(data.splmntDmndList?.reduce((max, item) => Math.max(max, item.splmntDmndSn ?? 0), 0) ?? 0) + 1;
const splmntDmndList: FnrsCnsltnSplmntDmndItem[] = [
...(data.splmntDmndList ?? []),
{
splmntDmndSn: nextSn,
splmntDmndCn,
cmptnYn: "N",
},
];
return {
fnrsCnsltnMng: detDto,
splmntDmndList,
};
}
export function buildSupplementSubmitPayload(
data: FnrsCnsltnMngDetResponseDto,
checklistFormState: ChecklistFormState,
): FnrsCnsltnApplySaveRequestDto {
return {
...buildSavePayload(data, checklistFormState, "LOCAL", "SL"),
splmntDmndList: data.splmntDmndList,
};
}
export function buildTechReviewPayload(
det: FnrsCnsltnMngResponseDto,
cklstResultList: CklstResultItem[],
): FnrsCnsltnApplySaveRequestDto {
return {
fnrsCnsltnMng: { ...det, prgrsSttsComCd: "ER" },
bzScaleList: [],
bizCstList: [],
cklstResultList,
esntlAtchFileList: [],
};
}
import type {
BizCstItem,
BizCstTreeDto,
BizSclTreeDto,
BzScaleItem,
CklstResultItem,
EsntlAtchFileItem,
EsntlAtchFileMngTreeResponseDto,
FnrsCnsltnApplySaveRequestDto,
FnrsCnsltnMngDetResponseDto,
FnrsCnsltnMngResponseDto,
FnrsCnsltnSplmntDmndItem,
} from "@/entities/fnrs-cnsltn";
import {
mapFnrsSharDetToFormList,
type SplmntDmndDialogConfirmPayload,
toFnrsSharSaveList,
} from "@/widgets/pbadms-sprt";
import type { ChecklistFormState, ReviewStepCode } from "../model/view-types";
import { toCklstSaveDto } from "./checklist-utils";
import { type ReviewOpinionGetFormDataResult, toRvwOpnnSaveDto } from "./review-opinion-utils";
export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): BzScaleItem[] {
return bizSclList
.filter((node) => node.useYn === "Y")
.map((node) => ({
cnstpSeCd: node.cnstpSeCd,
bscPl: node.bscPlanQty,
budgetReflct: node.bgtRfltQty,
thtiDsgn: node.tstmDesignQty,
}));
}
export function toBizCstSaveDto(bizCstList: BizCstTreeDto[]): BizCstItem[] {
return bizCstList
.filter((node) => node.useYn === "Y")
.map((node) => ({
acntgSeCd: node.acntgSeCd,
bscPlanAmt: node.bscPlanAmt,
bgtRfltAmt: node.bgtRfltAmt,
tstmDesignAmt: node.tstmDesignAmt,
}));
}
export function toEsntlAtchFileSaveDto(
esntlFileList: EsntlAtchFileMngTreeResponseDto[],
): EsntlAtchFileItem[] {
const esntlMap = new Map<string, string>();
for (const item of esntlFileList) {
if (!item.esntlAtchFileId || !item.atflCd) continue;
esntlMap.set(item.esntlAtchFileId, item.atflCd);
}
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
esntlAtchFileId,
atchFileGroupId,
}));
}
export function buildSavePayload(
data: FnrsCnsltnMngDetResponseDto,
checklistFormState: ChecklistFormState,
reviewStep: ReviewStepCode,
prgrsSttsComCd: string,
): FnrsCnsltnApplySaveRequestDto {
const detDto = data.detDto;
if (!detDto) {
throw new Error("상세 정보(detDto)가 없습니다.");
}
const bizSclList = data.bizSclList ?? [];
const bizCstList = data.bizCstList ?? [];
const cklstResultList = data.cklstResultList ?? [];
const esntlFileList = data.esntlFileList ?? [];
return {
fnrsCnsltnMng: { ...detDto, prgrsSttsComCd },
bzScaleList: toBzScaleSaveDto(bizSclList),
bizCstList: toBizCstSaveDto(bizCstList),
cklstResultList: toCklstSaveDto(cklstResultList, checklistFormState, reviewStep),
esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList),
fnrsSharList: toFnrsSharSaveList(mapFnrsSharDetToFormList(data.fnrsSharList ?? [])),
};
}
export function buildTechReviewReplyPayload(
data: FnrsCnsltnMngDetResponseDto,
checklistFormState: ChecklistFormState,
reviewStep: ReviewStepCode,
prgrsSttsComCd: string,
techReViewSaveCd: string,
reviewOpinionFormData?: ReviewOpinionGetFormDataResult | null,
opnrNm?: string,
): FnrsCnsltnApplySaveRequestDto {
return {
...buildSavePayload(data, checklistFormState, reviewStep, prgrsSttsComCd),
techReViewSaveCd,
rvwStpCd: reviewStep,
rvwOpnnList: toRvwOpnnSaveDto(data, reviewOpinionFormData, opnrNm),
};
}
export function buildSupplementReqPayload(
data: FnrsCnsltnMngDetResponseDto,
payload: SplmntDmndDialogConfirmPayload,
checklistFormState: ChecklistFormState,
): FnrsCnsltnApplySaveRequestDto {
const existingItems: FnrsCnsltnSplmntDmndItem[] = (data.splmntDmndList ?? []).map((item) =>
toSplmntDmndSaveItem(item),
);
const nextSn =
existingItems.reduce((max, item) => Math.max(max, item.splmntDmndSn ?? 0), 0) + 1;
const newItems: FnrsCnsltnSplmntDmndItem[] = payload.items.map((item, index) => ({
splmntDmndSn: nextSn + index,
splmntArtclComCd: item.splmntArtclComCd,
splmntDmndCn: item.splmntDmndCn,
splmntDmndAtchFileGroupId: item.splmntDmndAtchFileGroupId,
cmptnYn: "N",
}));
return {
...buildSavePayload(data, checklistFormState, "LOCAL", "SL"),
splmntDmndList: [...existingItems, ...newItems],
};
}
function toSplmntDmndSaveItem(item: FnrsCnsltnSplmntDmndItem): FnrsCnsltnSplmntDmndItem {
return {
splmntDmndSn: item.splmntDmndSn,
splmntArtclComCd: item.splmntArtclComCd,
splmntDmndCn: item.splmntDmndCn,
splmntDmndDt: item.splmntDmndDt,
splmntDmndAtchFileGroupId: item.splmntDmndAtchFileGroupId,
splmntCn: item.splmntCn,
splmntPicNm: item.splmntPicNm,
splmntCmptnDt: item.splmntCmptnDt,
splmntAtchFileGroupId: item.splmntAtchFileGroupId,
cmptnYn: item.cmptnYn,
};
}
export function buildSupplementSubmitPayload(
data: FnrsCnsltnMngDetResponseDto,
checklistFormState: ChecklistFormState,
): FnrsCnsltnApplySaveRequestDto {
return {
...buildSavePayload(data, checklistFormState, "LOCAL", "SL"),
splmntDmndList: data.splmntDmndList,
};
}
export function buildTechReviewPayload(
det: FnrsCnsltnMngResponseDto,
cklstResultList: CklstResultItem[],
): FnrsCnsltnApplySaveRequestDto {
return {
fnrsCnsltnMng: { ...det, prgrsSttsComCd: "ER" },
bzScaleList: [],
bizCstList: [],
cklstResultList,
esntlAtchFileList: [],
};
}
@@ -27,7 +27,7 @@ import {
fnrsCnsltnMngService,
} from "@/entities/fnrs-cnsltn";
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
import { TextInputDialog } from "@/widgets/pbadms-sprt";
import { SplmntDmndDialog, type SplmntDmndDialogConfirmPayload } from "@/widgets/pbadms-sprt";
import { Button } from "@common/components";
import {
@@ -402,12 +402,12 @@ function FloatingActionButtons({
setActiveDialog("dmndSplmnt");
}
async function handleDmndSplmntConfirm(splmntDmndCn: string) {
async function handleDmndSplmntConfirm(payload: SplmntDmndDialogConfirmPayload) {
setActiveDialog(null);
const payload = buildSupplementReqPayload(data, splmntDmndCn);
const requestPayload = buildSupplementReqPayload(data, payload, checklistFormState);
await executeAction({
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt,
request: payload,
request: requestPayload,
successMessage: "보완요청이 처리되었습니다.",
});
}
@@ -514,13 +514,11 @@ function FloatingActionButtons({
onConfirm={handleElaprTmpltConfirm}
/>
<TextInputDialog
<SplmntDmndDialog
open={activeDialog === "dmndSplmnt"}
onOpenChange={() => setActiveDialog(null)}
title="보완요청 내용 입력"
placeholder="보완요청 내용을 입력하세요."
isSubmitting={isProcessing}
onConfirm={handleDmndSplmntConfirm}
onConfirm={(payload) => void handleDmndSplmntConfirm(payload)}
/>
<ElaprApprovalDialog
@@ -632,11 +630,6 @@ async function handleAtrzRply(context: ActionHandlerContext): Promise<void> {
context.onSuccess?.();
}
async function handleGrndsChck(context: ActionHandlerContext): Promise<void> {
// TODO: 현장점검 등록 API 호출
context.onSuccess?.();
}
const buttonActionHandlers: Record<FnrsCnsltnViewButtonAction, ActionHandler> = {
strg: handleSaveApply,
aply: handleSaveApply,
@@ -654,7 +647,6 @@ const buttonActionHandlers: Record<FnrsCnsltnViewButtonAction, ActionHandler> =
dmndSplmnt: handleDmndSplmnt,
atrzAprv: handleAtrzAprv,
atrzRply: handleAtrzRply,
grndsChck: handleGrndsChck,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -15,7 +15,6 @@ export const INSTL_SNCN_VIEW_BUTTON_ACTION = {
dmndSplmnt: "dmndSplmnt",
atrzAprv: "atrzAprv",
atrzRply: "atrzRply",
grndsChck: "grndsChck",
} as const;
export type InstlSncnViewButtonAction =
@@ -44,10 +43,6 @@ const ROLE_STATUS_BUTTON_ACTIONS: Record<string, Record<string, InstlSncnViewBut
LE: [INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply, INSTL_SNCN_VIEW_BUTTON_ACTION.rqstTechRvw],
TP: [INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply],
},
ROLE_USER01: {
TE: [INSTL_SNCN_VIEW_BUTTON_ACTION.grndsChck],
SL: [INSTL_SNCN_VIEW_BUTTON_ACTION.splmntCmptn],
},
};
const ROLE_USER01_ATRZ_STATUS_CODES = ["ER", "TE"] as const;
@@ -24,23 +24,23 @@ export const instlSncnRegistSchema = z.object({
aplcntMblTelno: z.string().optional(),
aplcntOgdpNm: z.string().optional(),
aplcntEmlAddr: z.string().optional(),
instlSncnDocNo: z.string().optional(),
instlSncnDocNo: z.string().nullish(),
admdstCd: z.string().optional(),
envoSeComCd: z.string().optional(),
prgrsSttsComCd: z.string().optional(),
prgrsSttsComCd: z.string().nullish(),
atchFileGroupId: z.string().optional(),
rtlztnFcltSeComCd: z.string().optional(),
rtlztnUsgSeComCd: z.string().optional(),
envoSeCdNm: z.string().optional(),
sncnSeCdNm: z.string().optional(),
taskSeCdNm: z.string().optional(),
prgrsSttsCdNm: z.string().optional(),
sido: z.string().optional(),
gugun: z.string().optional(),
regDt: z.string().optional(),
rgtr: z.string().optional(),
rgtrNm: z.string().optional(),
mdfcnDt: z.string().optional(),
envoSeCdNm: z.string().nullish(),
sncnSeCdNm: z.string().nullish(),
taskSeCdNm: z.string().nullish(),
prgrsSttsCdNm: z.string().nullish(),
sido: z.string().nullish(),
gugun: z.string().nullish(),
regDt: z.string().nullish(),
rgtr: z.string().nullish(),
rgtrNm: z.string().nullish(),
mdfcnDt: z.string().nullish(),
}),
bzScaleList: z.array(
z.object({
@@ -66,7 +66,7 @@ export const instlSncnRegistSchema = z.object({
cklstRvwSn: z.number(),
rvwStpComCd: z.string(),
selected: z.enum(["Y", "N"]).nullable(),
picRvwCn: z.string().nullable().optional(),
picRvwCn: z.string().nullish(),
}),
),
esntlAtchFileList: z.array(
@@ -1,104 +1,131 @@
import type {
BizCstTreeDto,
BizSclTreeDto,
EsntlAtchFileMngTreeResponseDto,
InstlSncnApplySaveRequestDto,
InstlSncnBizCstSaveRequestDto,
InstlSncnBzScaleSaveDto,
InstlSncnCklstResultSaveDto,
InstlSncnEsntlAtchFileSaveDto,
InstlSncnMngDetResponse,
InstlSncnMngResponseDto,
ReviewStepCode,
} from "@/entities/instl-sncn";
import type { ChecklistFormState } from "../model/view-types";
import { toCklstSaveDto } from "./checklist-utils";
import { type ReviewOpinionFormData, toRvwOpnnSaveDto } from "./review-opinion-utils";
export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): InstlSncnBzScaleSaveDto[] {
return bizSclList
.filter((node) => node.useYn === "Y")
.map((node) => ({
cnstpSeCd: node.cnstpSeCd,
bscPl: node.bscPlanQty,
budgetReflct: node.bgtRfltQty,
thtiDsgn: node.tstmDesignQty,
diff: node.tstmDesignQty - node.bgtRfltQty,
}));
}
export function toBizCstSaveDto(bizCstList: BizCstTreeDto[]): InstlSncnBizCstSaveRequestDto[] {
return bizCstList
.filter((node) => node.useYn === "Y")
.map((node) => ({
acntgSeCd: node.acntgSeCd,
bscPlanAmt: node.bscPlanAmt,
bgtRfltAmt: node.bgtRfltAmt,
tstmDesignAmt: node.tstmDesignAmt,
}));
}
export function toEsntlAtchFileSaveDto(
esntlFileList: EsntlAtchFileMngTreeResponseDto[],
): InstlSncnEsntlAtchFileSaveDto[] {
const esntlMap = new Map<string, string>();
for (const item of esntlFileList) {
if (!item.esntlAtchFileId || !item.atflCd) continue;
esntlMap.set(item.esntlAtchFileId, item.atflCd);
}
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
esntlAtchFileId,
atchFileGroupId,
}));
}
export function buildSavePayload(
data: InstlSncnMngDetResponse,
checklistFormState: ChecklistFormState,
reviewStep: ReviewStepCode,
prgrsSttsComCd: string,
): InstlSncnApplySaveRequestDto {
const { detDto, bizSclList, bizCstList, cklstResultList = [], esntlFileList } = data;
return {
instlSncnMng: { ...detDto, prgrsSttsComCd },
bzScaleList: toBzScaleSaveDto(bizSclList),
bizCstList: toBizCstSaveDto(bizCstList),
cklstResultList: toCklstSaveDto(cklstResultList, checklistFormState, reviewStep),
esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList),
};
}
export function buildTechReviewReplyPayload(
data: InstlSncnMngDetResponse,
checklistFormState: ChecklistFormState,
reviewStep: ReviewStepCode,
prgrsSttsComCd: string,
techReViewSaveCd: string,
reviewOpinionFormData?: ReviewOpinionFormData | null,
opnrNm?: string,
): InstlSncnApplySaveRequestDto {
return {
...buildSavePayload(data, checklistFormState, reviewStep, prgrsSttsComCd),
techReViewSaveCd,
rvwStpCd: reviewStep,
rvwOpnnList: toRvwOpnnSaveDto(data, reviewOpinionFormData, opnrNm),
};
}
export function buildTechReviewPayload(
det: InstlSncnMngResponseDto,
cklstResultList: InstlSncnCklstResultSaveDto[],
): InstlSncnApplySaveRequestDto {
return {
instlSncnMng: { ...det, prgrsSttsComCd: "ER" },
bzScaleList: [],
bizCstList: [],
cklstResultList,
esntlAtchFileList: [],
};
}
import type {
BizCstTreeDto,
BizSclTreeDto,
EsntlAtchFileMngTreeResponseDto,
InstlSncnApplySaveRequestDto,
InstlSncnBizCstSaveRequestDto,
InstlSncnBzScaleSaveDto,
InstlSncnEsntlAtchFileSaveDto,
InstlSncnMngDetResponse,
InstlSncnSplmntDmndSaveDto,
ReviewStepCode,
} from "@/entities/instl-sncn";
import type { SplmntDmndDialogConfirmPayload } from "@/widgets/pbadms-sprt";
import type { ChecklistFormState } from "../model/view-types";
import { toCklstSaveDto } from "./checklist-utils";
import { type ReviewOpinionFormData, toRvwOpnnSaveDto } from "./review-opinion-utils";
export function toBzScaleSaveDto(bizSclList: BizSclTreeDto[]): InstlSncnBzScaleSaveDto[] {
return bizSclList
.filter((node) => node.useYn === "Y")
.map((node) => ({
cnstpSeCd: node.cnstpSeCd,
bscPl: node.bscPlanQty,
budgetReflct: node.bgtRfltQty,
thtiDsgn: node.tstmDesignQty,
diff: node.tstmDesignQty - node.bgtRfltQty,
}));
}
export function toBizCstSaveDto(bizCstList: BizCstTreeDto[]): InstlSncnBizCstSaveRequestDto[] {
return bizCstList
.filter((node) => node.useYn === "Y")
.map((node) => ({
acntgSeCd: node.acntgSeCd,
bscPlanAmt: node.bscPlanAmt,
bgtRfltAmt: node.bgtRfltAmt,
tstmDesignAmt: node.tstmDesignAmt,
}));
}
export function toEsntlAtchFileSaveDto(
esntlFileList: EsntlAtchFileMngTreeResponseDto[],
): InstlSncnEsntlAtchFileSaveDto[] {
const esntlMap = new Map<string, string>();
for (const item of esntlFileList) {
if (!item.esntlAtchFileId || !item.atflCd) continue;
esntlMap.set(item.esntlAtchFileId, item.atflCd);
}
return Array.from(esntlMap.entries()).map(([esntlAtchFileId, atchFileGroupId]) => ({
esntlAtchFileId,
atchFileGroupId,
}));
}
export function buildSavePayload(
data: InstlSncnMngDetResponse,
checklistFormState: ChecklistFormState,
reviewStep: ReviewStepCode,
prgrsSttsComCd: string,
): InstlSncnApplySaveRequestDto {
const { detDto, bizSclList, bizCstList, cklstResultList = [], esntlFileList } = data;
return {
instlSncnMng: { ...detDto, prgrsSttsComCd },
bzScaleList: toBzScaleSaveDto(bizSclList),
bizCstList: toBizCstSaveDto(bizCstList),
cklstResultList: toCklstSaveDto(cklstResultList, checklistFormState, reviewStep),
esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList),
};
}
export function buildTechReviewReplyPayload(
data: InstlSncnMngDetResponse,
checklistFormState: ChecklistFormState,
reviewStep: ReviewStepCode,
prgrsSttsComCd: string,
techReViewSaveCd: string,
reviewOpinionFormData?: ReviewOpinionFormData | null,
opnrNm?: string,
): InstlSncnApplySaveRequestDto {
return {
...buildSavePayload(data, checklistFormState, reviewStep, prgrsSttsComCd),
techReViewSaveCd,
rvwStpCd: reviewStep,
rvwOpnnList: toRvwOpnnSaveDto(data, reviewOpinionFormData, opnrNm),
};
}
export function buildSupplementReqPayload(
data: InstlSncnMngDetResponse,
payload: SplmntDmndDialogConfirmPayload,
checklistFormState: ChecklistFormState,
): InstlSncnApplySaveRequestDto {
const existingItems: InstlSncnSplmntDmndSaveDto[] = (data.splmntDmndList ?? []).map((item) =>
toSplmntDmndSaveItem(item),
);
const nextSn = existingItems.reduce((max, item) => Math.max(max, item.splmntDmndSn ?? 0), 0) + 1;
const newItems: InstlSncnSplmntDmndSaveDto[] = payload.items.map((item, index) => ({
splmntDmndSn: nextSn + index,
splmntArtclComCd: item.splmntArtclComCd,
splmntDmndCn: item.splmntDmndCn,
splmntDmndAtchFileGroupId: item.splmntDmndAtchFileGroupId,
cmptnYn: "N",
}));
return {
...buildSavePayload(data, checklistFormState, "LOCAL", "SL"),
splmntDmndList: [...existingItems, ...newItems],
};
}
function toSplmntDmndSaveItem(item: InstlSncnSplmntDmndSaveDto): InstlSncnSplmntDmndSaveDto {
return {
splmntDmndSn: item.splmntDmndSn,
splmntArtclComCd: item.splmntArtclComCd,
splmntDmndCn: item.splmntDmndCn,
splmntDmndDt: item.splmntDmndDt,
splmntDmndAtchFileGroupId: item.splmntDmndAtchFileGroupId,
splmntCn: item.splmntCn,
splmntPicNm: item.splmntPicNm,
splmntCmptnDt: item.splmntCmptnDt,
splmntAtchFileGroupId: item.splmntAtchFileGroupId,
cmptnYn: item.cmptnYn,
};
}
@@ -27,7 +27,7 @@ import {
instlSncnService,
} from "@/entities/instl-sncn";
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
import { TextInputDialog } from "@/widgets/pbadms-sprt";
import { SplmntDmndDialog, type SplmntDmndDialogConfirmPayload } from "@/widgets/pbadms-sprt";
import { Button } from "@common/components";
import {
@@ -37,7 +37,11 @@ import {
needsRoleUser01AtrzResolve,
} from "../../lib/button-config";
import { computeReviewStepChecklistProgress } from "../../lib/checklist-utils";
import { buildSavePayload, buildTechReviewReplyPayload } from "../../lib/save-payload-utils";
import {
buildSavePayload,
buildSupplementReqPayload,
buildTechReviewReplyPayload,
} from "../../lib/save-payload-utils";
import { type ChecklistFormState } from "../../model/view-types";
import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section";
@@ -401,11 +405,12 @@ function FloatingActionButtons({
setActiveDialog("dmndSplmnt");
}
async function handleDmndSplmntConfirm(splmntCn: string) {
async function handleDmndSplmntConfirm(payload: SplmntDmndDialogConfirmPayload) {
setActiveDialog(null);
const requestPayload = buildSupplementReqPayload(data, payload, checklistFormState);
await executeAction({
action: INSTL_SNCN_VIEW_BUTTON_ACTION.dmndSplmnt,
request: splmntCn,
request: requestPayload,
successMessage: "보완요청이 처리되었습니다.",
});
}
@@ -517,13 +522,11 @@ function FloatingActionButtons({
onConfirm={handleElaprTmpltConfirm}
/>
<TextInputDialog
<SplmntDmndDialog
open={activeDialog === "dmndSplmnt"}
onOpenChange={() => setActiveDialog(null)}
title="보완요청 내용 입력"
placeholder="보완요청 내용을 입력하세요."
isSubmitting={isProcessing}
onConfirm={(value) => void handleDmndSplmntConfirm(value)}
onConfirm={handleDmndSplmntConfirm}
/>
<ElaprApprovalDialog
@@ -601,9 +604,17 @@ async function handleDsgntRvwr(
}
}
async function handleDmndSplmnt(context: ActionHandlerContext): Promise<void> {
// TODO: 보완요청 API 호출
context.onSuccess?.();
async function handleDmndSplmnt(
context: ActionHandlerContext<InstlSncnApplySaveRequestDto>,
): Promise<void> {
try {
const docNo = context.data.detDto.instlSncnDocNo;
if (!docNo) throw new Error("문서번호가 없습니다.");
await instlSncnService.submitReqSupplement(docNo, context.request);
context.onSuccess?.();
} catch (error) {
context.onError?.(error as Error);
}
}
async function handleAtrzAprv(context: ActionHandlerContext): Promise<void> {
@@ -616,11 +627,6 @@ async function handleAtrzRply(context: ActionHandlerContext): Promise<void> {
context.onSuccess?.();
}
async function handleGrndsChck(context: ActionHandlerContext): Promise<void> {
// TODO: 현장점검 등록 API 호출
context.onSuccess?.();
}
const buttonActionHandlers: Record<InstlSncnViewButtonAction, ActionHandler> = {
strg: handleSaveApply,
aply: handleSaveApply,
@@ -638,7 +644,6 @@ const buttonActionHandlers: Record<InstlSncnViewButtonAction, ActionHandler> = {
dmndSplmnt: handleDmndSplmnt,
atrzAprv: handleAtrzAprv,
atrzRply: handleAtrzRply,
grndsChck: handleGrndsChck,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -48,6 +48,7 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
const alert = useAlertStore((state) => state.setMessage);
const [isSubmitting, setIsSubmitting] = useState(false);
const isEditMode = mode === "edit";
const isSplmntPrgrsStts = initialData?.detDto.prgrsSttsComCd === "SL";
const form = useForm<InstlSncnRegistFormData>({
resolver: zodResolver(instlSncnRegistSchema),
@@ -186,23 +187,36 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
>
목록
</Button>
<Button type="submit" disabled={isSubmitting}>
저장
</Button>
{isEditMode && (
{!isSplmntPrgrsStts ? (
<>
<Button
color="green"
type="button"
onClick={handleApplyClick}
disabled={isSubmitting}
>
신청완료
</Button>
<Button color="red" type="button" onClick={handleDelClick} disabled={isSubmitting}>
삭제
<Button type="submit" disabled={isSubmitting}>
저장
</Button>
{isEditMode && (
<>
<Button
color="green"
type="button"
onClick={handleApplyClick}
disabled={isSubmitting}
>
신청완료
</Button>
<Button
color="red"
type="button"
onClick={handleDelClick}
disabled={isSubmitting}
>
삭제
</Button>
</>
)}
</>
) : (
<Button type="submit" disabled={isSubmitting}>
보완완료
</Button>
)}
</div>
</div>
+5 -1
View File
@@ -30,5 +30,9 @@ export { ContactInfoFormSection } from "./ui/contact-info-form-section";
export { FnrsSharFormSection } from "./ui/fnrs-shar-form-section";
export { PrivacyConsentSection } from "./ui/privacy-consent-section";
export { SectionCollapsible } from "./ui/section-collapsible";
export {
SplmntDmndDialog,
type SplmntDmndDialogConfirmItem,
type SplmntDmndDialogConfirmPayload,
} from "./ui/splmnt-dmnd-dialog";
export { TaskSePickerDialog } from "./ui/task-se-picker-dialog";
export { TextInputDialog } from "./ui/text-input-dialog";
@@ -0,0 +1,329 @@
"use client";
import { useEffect, useId, useState } from "react";
import { getDefaultFileKey, useTus } from "@/shared/lib";
import { attachService } from "@/entities/attach";
import { CodeSelect } from "@/entities/code";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
const SPLMNT_ARTCL_UP_CD = "SPLMNTARTCL";
const SPLMNT_DMND_FILE_KEY_PREFIX = "splmnt-dmnd::";
interface UploadedSplmntFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
}
interface SplmntDmndRow {
rowKey: string;
splmntArtclComCd: string;
splmntDmndCn: string;
splmntDmndAtchFileGroupId: string;
uploadedFile: UploadedSplmntFile | null;
}
interface SplmntDmndDialogConfirmItem {
splmntArtclComCd: string;
splmntDmndCn: string;
splmntDmndAtchFileGroupId?: string;
}
interface SplmntDmndDialogConfirmPayload {
items: SplmntDmndDialogConfirmItem[];
}
interface SplmntDmndDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
isSubmitting?: boolean;
onConfirm: (payload: SplmntDmndDialogConfirmPayload) => void;
}
function createRowKey() {
return `splmnt-row-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
function createEmptyRow(): SplmntDmndRow {
return {
rowKey: createRowKey(),
splmntArtclComCd: "",
splmntDmndCn: "",
splmntDmndAtchFileGroupId: "",
uploadedFile: null,
};
}
function createUploadKey(rowKey: string, file: File) {
return `${SPLMNT_DMND_FILE_KEY_PREFIX}${rowKey}::${getDefaultFileKey(file)}`;
}
function getRowKeyFromFileKey(fileKey: string): string | null {
if (!fileKey.startsWith(SPLMNT_DMND_FILE_KEY_PREFIX)) return null;
const rest = fileKey.slice(SPLMNT_DMND_FILE_KEY_PREFIX.length);
const separatorIndex = rest.indexOf("::");
if (separatorIndex < 0) return null;
return rest.slice(0, separatorIndex);
}
function isRowsValid(rows: SplmntDmndRow[]) {
return rows.every((row) => row.splmntArtclComCd.trim() && row.splmntDmndCn.trim());
}
function SplmntDmndDialog({ open, onOpenChange, isSubmitting, onConfirm }: SplmntDmndDialogProps) {
const formId = useId();
const [rows, setRows] = useState<SplmntDmndRow[]>([createEmptyRow()]);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
const rowKey = getRowKeyFromFileKey(fileKey);
if (!rowKey) return;
setRows((prev) =>
prev.map((row) => {
if (row.rowKey !== rowKey || row.uploadedFile?.fileKey !== fileKey) return row;
return {
...row,
splmntDmndAtchFileGroupId: atflCd,
uploadedFile: { ...row.uploadedFile, atflCd, atflId },
};
}),
);
},
onError: (_error, _file, fileKey) => {
const rowKey = getRowKeyFromFileKey(fileKey);
if (!rowKey) return;
setRows((prev) =>
prev.map((row) => {
if (row.rowKey !== rowKey || row.uploadedFile?.fileKey !== fileKey) return row;
return { ...row, uploadedFile: null };
}),
);
},
});
useEffect(() => {
if (!open) return;
setRows([createEmptyRow()]);
}, [open]);
const isAnyUploading = rows.some(
(row) => row.uploadedFile != null && (isUploading[row.uploadedFile.fileKey] ?? false),
);
const canSubmit = isRowsValid(rows) && !isAnyUploading && !isSubmitting;
function updateRow(rowKey: string, patch: Partial<SplmntDmndRow>) {
setRows((prev) => prev.map((row) => (row.rowKey === rowKey ? { ...row, ...patch } : row)));
}
function handleAddRow() {
setRows((prev) => [...prev, createEmptyRow()]);
}
function handleRemoveRow(rowKey: string) {
setRows((prev) => (prev.length <= 1 ? prev : prev.filter((row) => row.rowKey !== rowKey)));
}
async function handleFileUpload(rowKey: string, files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const fileKey = createUploadKey(rowKey, file);
const targetRow = rows.find((row) => row.rowKey === rowKey);
const existingAtflCd = targetRow?.splmntDmndAtchFileGroupId;
updateRow(rowKey, {
uploadedFile: { fileName: file.name, fileKey },
});
try {
await uploadFileAsync(file, {
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
} catch {
// onError callback handles cleanup
}
}
async function handleFileRemove(rowKey: string) {
const targetRow = rows.find((row) => row.rowKey === rowKey);
if (!targetRow?.uploadedFile) return;
const { fileKey, atflId } = targetRow.uploadedFile;
uploads[fileKey]?.abort();
if (atflId) {
try {
await attachService.deleteAttachmentByAtflId(atflId);
} catch (error) {
console.error("보완요청 첨부파일 삭제 실패:", error);
}
}
updateRow(rowKey, {
uploadedFile: null,
splmntDmndAtchFileGroupId: "",
});
}
function handleConfirm() {
if (!canSubmit) return;
onConfirm({
items: rows.map((row) => ({
splmntArtclComCd: row.splmntArtclComCd.trim(),
splmntDmndCn: row.splmntDmndCn.trim(),
splmntDmndAtchFileGroupId: row.splmntDmndAtchFileGroupId || undefined,
})),
});
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[100rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>보완요청</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h4 className="text-xl font-semibold">보완 내용</h4>
<Button type="button" size="sm" color="green" onClick={handleAddRow}>
항목 추가
</Button>
</div>
<div className="overflow-x-auto">
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="50%" />
<col width="20%" />
{rows.length > 1 ? <col width="10%" /> : null}
</colgroup>
<TableHeader>
<TableRow>
<TableHead>보완 항목</TableHead>
<TableHead>보완 의견</TableHead>
<TableHead>첨부파일</TableHead>
{rows.length > 1 ? <TableHead>관리</TableHead> : null}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => {
const uploading = row.uploadedFile
? (isUploading[row.uploadedFile.fileKey] ?? false)
: false;
const fileInputId = `${formId}-${row.rowKey}-file`;
return (
<TableRow key={row.rowKey}>
<TableCell align="center" className="align-top">
<CodeSelect
upCd={SPLMNT_ARTCL_UP_CD}
value={row.splmntArtclComCd || undefined}
onValueChange={(value) =>
updateRow(row.rowKey, { splmntArtclComCd: value })
}
placeholder="보완항목 선택"
className="w-full min-w-[12rem]"
/>
</TableCell>
<TableCell className="align-top">
<Textarea
value={row.splmntDmndCn}
onChange={(event) =>
updateRow(row.rowKey, { splmntDmndCn: event.target.value })
}
placeholder="보완 의견을 입력하세요."
rows={4}
className="min-h-[8rem] !text-xl"
/>
</TableCell>
<TableCell className="align-top">
<div className="flex items-center gap-2">
{row.uploadedFile ? (
<div className="flex items-stretch gap-2">
<span className="text-xl">{row.uploadedFile.fileName}</span>
<Button
type="button"
size="icon"
className="size-8 shrink-0 !bg-destructive"
disabled={uploading}
onClick={() => void handleFileRemove(row.rowKey)}
>
<i className="diveicon di-x text-xs" aria-label="삭제" />
</Button>
</div>
) : (
<Button type="button" size="sm" disabled={uploading} asChild>
<label htmlFor={fileInputId} className="cursor-pointer">
파일추가
<input
id={fileInputId}
type="file"
className="hidden"
onChange={(event) => {
void handleFileUpload(row.rowKey, event.target.files);
event.target.value = "";
}}
/>
</label>
</Button>
)}
</div>
</TableCell>
{rows.length > 1 ? (
<TableCell align="center" className="align-top">
<Button
type="button"
color="white"
size="sm"
onClick={() => handleRemoveRow(row.rowKey)}
>
삭제
</Button>
</TableCell>
) : null}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
<DialogFooter>
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
취소
</Button>
<Button type="button" onClick={handleConfirm} disabled={!canSubmit}>
보완요청
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { SplmntDmndDialog };
export type { SplmntDmndDialogConfirmItem, SplmntDmndDialogConfirmPayload };
@@ -1,77 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Textarea,
} from "@common/components";
interface TextInputDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
placeholder?: string;
defaultValue?: string;
isSubmitting?: boolean;
onConfirm: (text: string) => void;
}
function TextInputDialog({
open,
onOpenChange,
title,
placeholder,
defaultValue,
isSubmitting,
onConfirm,
}: TextInputDialogProps) {
const [value, setValue] = useState("");
useEffect(() => {
if (!open) return;
setValue(defaultValue ?? "");
}, [defaultValue, open]);
function handleConfirm() {
onConfirm(value.trim());
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[44rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<Textarea
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder={placeholder}
rows={6}
className="!text-xl"
/>
<DialogFooter>
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
취소
</Button>
<Button
type="button"
onClick={handleConfirm}
disabled={isSubmitting || value.trim().length === 0}
>
확인
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { TextInputDialog };