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

This commit is contained in:
2026-09-08 10:29:27 +09:00
parent b38be38169
commit c47c130fc3
6 changed files with 298 additions and 8 deletions
@@ -8,7 +8,7 @@ export const FNRS_CNSLTN_VIEW_BUTTON_ACTION = {
sncnAprv: "sncnAprv",
rvwRply: "rvwRply",
rqstTechRvw: "rqstTechRvw",
fileVrfc: "fileVrfc",
fileSplmnt: "fileSplmnt",
dsgntRvwr: "dsgntRvwr",
pwrkCnsltnDocCrt: "pwrkCnsltnDocCrt",
pwrkCnsltnDocMng: "pwrkCnsltnDocMng",
@@ -53,7 +53,10 @@ const ROLE_USER01_ATRZ_STATUS_ACTIONS: Record<
Record<string, FnrsCnsltnViewButtonAction[]>
> = {
ER: {
ATRZ01: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.fileVrfc, FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr],
ATRZ01: [
FNRS_CNSLTN_VIEW_BUTTON_ACTION.fileSplmnt,
FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr,
],
},
TE: {
ATRZ02: [
@@ -5,6 +5,7 @@ import type {
BzScaleItem,
CklstResultItem,
FnrsCnsltnApplySaveRequestDto,
FnrsCnsltnFileVrfcRsltResponseDto,
FnrsCnsltnMngDetResponseDto,
FnrsCnsltnMngResponseDto,
FnrsCnsltnSplmntDmndItem,
@@ -111,6 +112,23 @@ export function buildSupplementReqPayload(
};
}
export function buildFileSplmntReqPayload(
data: FnrsCnsltnMngDetResponseDto,
selected: FnrsCnsltnFileVrfcRsltResponseDto[],
checklistFormState: ChecklistFormState,
): FnrsCnsltnApplySaveRequestDto {
const newItems: FnrsCnsltnSplmntDmndItem[] = selected.map((row) => ({
splmntArtclComCd: row.fileSeComCd,
splmntDmndCn: row.vrfcRsltCn,
cmptnYn: "N",
}));
return {
...buildSavePayload(data, checklistFormState, "LOCAL", "SL"),
splmntDmndList: [...newItems],
};
}
export function toSplmntDmndSaveItem(item: FnrsCnsltnSplmntDmndItem): FnrsCnsltnSplmntDmndItem {
return {
splmntDmndSn: item.splmntDmndSn,
@@ -0,0 +1,73 @@
"use client";
import { createColumnHelper } from "@tanstack/react-table";
import type { FnrsCnsltnFileVrfcRsltResponseDto } from "@/entities/fnrs-cnsltn";
import { Checkbox } from "@common/components";
const FILE_SE_COM_CD_MAP = {
ESNTL: "필수",
ETC: "기타",
};
const columnHelper = createColumnHelper<FnrsCnsltnFileVrfcRsltResponseDto>();
function formatOptional(value: string | undefined) {
return value?.trim() ? value : "-";
}
export function getFileVrfcRsltRowKey(row: FnrsCnsltnFileVrfcRsltResponseDto, index: number) {
if (row.atflId) return row.atflId;
return [row.esntlAtchFileId, row.fileSeComCd, row.orgnlFileNm, String(index)]
.filter(Boolean)
.join("::");
}
export function createFileVrfcSplmntColumns() {
return [
columnHelper.display({
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllRowsSelected()
? true
: table.getIsSomeRowsSelected()
? "indeterminate"
: false
}
onCheckedChange={(checked) => table.toggleAllRowsSelected(!!checked)}
onClick={(event) => event.stopPropagation()}
aria-label="전체 선택"
/>
),
meta: { align: "center", width: 48 },
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onCheckedChange={(checked) => row.toggleSelected(!!checked)}
onClick={(event) => event.stopPropagation()}
aria-label="행 선택"
/>
),
}),
columnHelper.accessor("fileSeComCd", {
header: () => <p className="whitespace-nowrap">파일유형</p>,
meta: { align: "left" },
cell: ({ getValue }) => (
<p>{formatOptional(FILE_SE_COM_CD_MAP[getValue() as keyof typeof FILE_SE_COM_CD_MAP])}</p>
),
}),
columnHelper.accessor("fileSeNm", {
header: () => <p className="whitespace-nowrap">파일구분</p>,
meta: { align: "left" },
cell: ({ getValue }) => <p className="whitespace-nowrap">{formatOptional(getValue())}</p>,
}),
columnHelper.accessor("vrfcRsltCn", {
header: "검증내역",
meta: { align: "left" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
];
}
@@ -0,0 +1,168 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import type { UseQueryResult } from "@tanstack/react-query";
import { getCoreRowModel, type RowSelectionState } from "@tanstack/react-table";
import { useDataTable } from "@/shared/lib";
import type { PageResponse } from "@/shared/model";
import {
type FnrsCnsltnFileVrfcRsltResponseDto,
useFnrsCnsltnFileVrfcResults,
} from "@/entities/fnrs-cnsltn";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@common/components";
import {
createFileVrfcSplmntColumns,
getFileVrfcRsltRowKey,
} from "../../model/file-vrfc-splmnt-columns";
interface FileVrfcSplmntDialogProps {
open: boolean;
fnrsCnsltnDocNo: string;
isSubmitting?: boolean;
onClose: () => void;
onConfirm: (selected: FnrsCnsltnFileVrfcRsltResponseDto[]) => void;
}
function toFileVrfcPageResponse(
rows: FnrsCnsltnFileVrfcRsltResponseDto[],
): PageResponse<FnrsCnsltnFileVrfcRsltResponseDto> {
const content = rows.filter((row) => row.vrfcSttsComCd === "WARNING");
const totalElements = content.length;
return {
content,
pageable: {
sort: { empty: true, sorted: false, unsorted: true },
offset: 0,
pageNumber: 0,
pageSize: totalElements || 1,
paged: false,
unpaged: true,
},
sort: { empty: true, sorted: false, unsorted: true },
totalPages: 1,
totalElements,
number: 0,
numberOfElements: totalElements,
size: totalElements || 1,
first: true,
last: true,
empty: totalElements === 0,
};
}
function FileVrfcSplmntDialog({
open,
fnrsCnsltnDocNo,
isSubmitting = false,
onClose,
onConfirm,
}: FileVrfcSplmntDialogProps) {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const listQuery = useFnrsCnsltnFileVrfcResults(open ? fnrsCnsltnDocNo : null);
const pageData = useMemo(
() => (listQuery.data ? toFileVrfcPageResponse(listQuery.data) : undefined),
[listQuery.data],
);
const pageQuery = useMemo(
() =>
({
isPending: listQuery.isPending,
isError: listQuery.isError,
isSuccess: listQuery.isSuccess,
data: pageData,
error: listQuery.error,
status: listQuery.status,
fetchStatus: listQuery.fetchStatus,
}) as UseQueryResult<PageResponse<FnrsCnsltnFileVrfcRsltResponseDto>>,
[
listQuery.error,
listQuery.fetchStatus,
listQuery.isError,
listQuery.isPending,
listQuery.isSuccess,
listQuery.status,
pageData,
],
);
const columns = useMemo(() => createFileVrfcSplmntColumns(), []);
const { tableComponent } = useDataTable({
query: pageQuery,
tableOptions: {
columns,
state: { rowSelection },
enableRowSelection: true,
enableMultiRowSelection: true,
getRowId: (row, index) => getFileVrfcRsltRowKey(row, index),
onRowSelectionChange: (updater) => {
const next = typeof updater === "function" ? updater(rowSelection) : updater;
setRowSelection(next);
},
getCoreRowModel: getCoreRowModel(),
},
dataTableOptions: {
viewPagination: false,
},
});
const selectedRows = useMemo(() => {
const selectedIds = new Set(Object.keys(rowSelection));
const content = pageData?.content ?? [];
return content.filter((row, index) => selectedIds.has(getFileVrfcRsltRowKey(row, index)));
}, [pageData?.content, rowSelection]);
useEffect(() => {
if (!open) setRowSelection({});
}, [open]);
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen) onClose();
}
function handleConfirm() {
if (selectedRows.length === 0) return;
onConfirm(selectedRows);
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-[80rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>파일보완</DialogTitle>
</DialogHeader>
<div className="space-y-3">{tableComponent}</div>
<DialogFooter>
<Button type="button" color="white" onClick={onClose} disabled={isSubmitting}>
취소
</Button>
<Button
type="button"
onClick={handleConfirm}
disabled={isSubmitting || selectedRows.length === 0}
>
보완요청
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { FileVrfcSplmntDialog };
export type { FileVrfcSplmntDialogProps };
@@ -22,6 +22,7 @@ import {
import {
type DocPrgrsUpdateRequestDto,
type FnrsCnsltnApplySaveRequestDto,
type FnrsCnsltnFileVrfcRsltResponseDto,
fnrsCnsltnKeys,
type FnrsCnsltnMngDetResponseDto,
fnrsCnsltnMngService,
@@ -38,6 +39,7 @@ import {
} from "../../lib/button-config";
import { computeReviewStepChecklistProgress } from "../../lib/checklist-utils";
import {
buildFileSplmntReqPayload,
buildSavePayload,
buildSupplementReqPayload,
buildTechReviewReplyPayload,
@@ -46,6 +48,8 @@ import {
import { type ChecklistFormState } from "../../model/view-types";
import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section";
import { FileVrfcSplmntDialog } from "./file-vrfc-splmnt-dialog";
interface FloatingActionButtonsProps {
data: FnrsCnsltnMngDetResponseDto;
docSeComCd: string;
@@ -60,7 +64,7 @@ interface FloatingActionButtonsProps {
isAiPending?: boolean;
}
type ActiveDialog = "elaprTmplt" | "rjct" | "dmndSplmnt" | "elaprApproval" | null;
type ActiveDialog = "elaprTmplt" | "rjct" | "dmndSplmnt" | "fileSplmnt" | "elaprApproval" | null;
function FloatingActionButtons({
data,
@@ -397,6 +401,20 @@ function FloatingActionButtons({
});
}
function handleFileSplmntClick() {
setActiveDialog("fileSplmnt");
}
async function handleFileSplmntConfirm(selected: FnrsCnsltnFileVrfcRsltResponseDto[]) {
setActiveDialog(null);
const requestPayload = buildFileSplmntReqPayload(data, selected, checklistFormState);
await executeAction({
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.fileSplmnt,
request: requestPayload,
successMessage: "파일보완 요청이 처리되었습니다.",
});
}
return (
<>
<div className="sticky bottom-0 z-40 !-mb-2 border-t bg-white pb-8 pt-6 shadow-[0_-4px_12px_rgba(0,0,0,0.06)]">
@@ -456,9 +474,9 @@ function FloatingActionButtons({
기술검토 의뢰
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.fileVrfc) && (
<Button color="gray" onClick={() => {}} disabled={isProcessing}>
파일 검증
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.fileSplmnt) && (
<Button color="gray" onClick={handleFileSplmntClick} disabled={isProcessing}>
파일보완 요청
</Button>
)}
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.dsgntRvwr) && (
@@ -523,6 +541,14 @@ function FloatingActionButtons({
onConfirm={handleDmndSplmntConfirm}
/>
<FileVrfcSplmntDialog
open={activeDialog === "fileSplmnt"}
fnrsCnsltnDocNo={docNo}
isSubmitting={isProcessing}
onClose={() => setActiveDialog(null)}
onConfirm={handleFileSplmntConfirm}
/>
<ElaprApprovalDialog
open={activeDialog === "elaprApproval"}
onOpenChange={(open) => setActiveDialog(open ? "elaprApproval" : null)}
@@ -644,7 +670,7 @@ const buttonActionHandlers: Record<FnrsCnsltnViewButtonAction, ActionHandler> =
sncnAprv: handleChangePrgrsSttsComCd,
rvwRply: handleTechReview,
rqstTechRvw: handleRequestExamine,
fileVrfc: () => Promise.resolve(),
fileSplmnt: handleDmndSplmnt,
dsgntRvwr: handleDsgntRvwr,
pwrkCnsltnDocCrt: () => Promise.resolve(),
pwrkCnsltnDocMng: () => Promise.resolve(),
@@ -262,7 +262,9 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSave)} 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">
{initialData?.detDto?.fnrsCnsltnDocNo && (
<ProgressStatusSection