[as] feat: 설치인가 보완요청 및 완료 기능 구현
This commit is contained in:
@@ -65,6 +65,14 @@ async function techReview(id: string, payload: InstlSncnApplySaveRequestDto): Pr
|
||||
await client.post(`${BASE}/${id}/tech-review`, payload);
|
||||
}
|
||||
|
||||
async function submitSupplement(
|
||||
id: string,
|
||||
payload: InstlSncnApplySaveRequestDto,
|
||||
): Promise<string> {
|
||||
const response = await client.post<string>(`${BASE}/${id}/submitsupplement`, payload);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function submitReqSupplement(
|
||||
id: string,
|
||||
payload: InstlSncnApplySaveRequestDto,
|
||||
@@ -88,6 +96,7 @@ export const instlSncnService = {
|
||||
saveApply,
|
||||
update,
|
||||
techReview,
|
||||
submitSupplement,
|
||||
submitReqSupplement,
|
||||
remove,
|
||||
changePrgrsSttsComCd,
|
||||
|
||||
@@ -99,6 +99,19 @@ export function useInstlSncnTechReview() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstlSncnSubmitSupplement() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, payload }: { id: string; payload: InstlSncnApplySaveRequestDto }) =>
|
||||
instlSncnService.submitSupplement(id, payload),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: instlSncnKeys.detail(variables.id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstlSncnSubmitReqSupplement() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
useInstlSncnSidoCombo,
|
||||
useInstlSncnSigunguCombo,
|
||||
useInstlSncnSubmitReqSupplement,
|
||||
useInstlSncnSubmitSupplement,
|
||||
useInstlSncnTechReview,
|
||||
} from "./api/queries";
|
||||
export {
|
||||
@@ -29,6 +30,7 @@ export {
|
||||
type InstlSncnMngSearchParams,
|
||||
type InstlSncnRvwOpnnResponseDto,
|
||||
type InstlSncnRvwOpnnSaveRequestDto,
|
||||
type InstlSncnSplmntDmndResponseDto,
|
||||
type InstlSncnSplmntDmndSaveDto,
|
||||
type ReviewStepCode,
|
||||
} from "./model/instl-sncn-interface";
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
PbadmsMngDetResponseBase,
|
||||
RvwOpnnResponseExtension,
|
||||
RvwOpnnSaveBase,
|
||||
SplmntDmndResponseExtension,
|
||||
SplmntDmndSaveBase,
|
||||
} from "@/shared/model";
|
||||
|
||||
@@ -185,6 +186,12 @@ export interface InstlSncnRvwOpnnSaveRequestDto extends RvwOpnnSaveBase {
|
||||
/** 설치인가 보완요구 저장 DTO(배열 원소) */
|
||||
export type InstlSncnSplmntDmndSaveDto = SplmntDmndSaveBase;
|
||||
|
||||
/** 설치인가 보완요구 응답 DTO */
|
||||
export type InstlSncnSplmntDmndResponseDto = InstlSncnSplmntDmndSaveDto &
|
||||
SplmntDmndResponseExtension & {
|
||||
instlSncnDocNo?: string;
|
||||
};
|
||||
|
||||
/** 설치인가 신청 - 통합 저장 요청 DTO */
|
||||
export interface InstlSncnApplySaveRequestDto extends PbadmsApplySaveHeader {
|
||||
/** 설치인가관리 정보 */
|
||||
@@ -225,7 +232,7 @@ export type InstlSncnRvwOpnnResponseDto = InstlSncnRvwOpnnSaveRequestDto &
|
||||
export interface InstlSncnMngDetResponse extends PbadmsMngDetResponseBase<
|
||||
InstlSncnMngResponseDto,
|
||||
InstlSncnRvwOpnnResponseDto,
|
||||
InstlSncnSplmntDmndSaveDto
|
||||
InstlSncnSplmntDmndResponseDto
|
||||
> {
|
||||
detDto: InstlSncnMngResponseDto;
|
||||
bizSclList: BizSclTreeDto[];
|
||||
@@ -242,6 +249,7 @@ export interface InstlSncnMngService {
|
||||
saveApply: (payload: InstlSncnApplySaveRequestDto) => Promise<string>;
|
||||
update: (id: string, payload: InstlSncnMngUpdateRequestDto) => Promise<void>;
|
||||
techReview: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise<void>;
|
||||
submitSupplement: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise<string>;
|
||||
submitReqSupplement: (id: string, payload: InstlSncnApplySaveRequestDto) => Promise<string>;
|
||||
remove: (id: string) => Promise<void>;
|
||||
changePrgrsSttsComCd: (payload: DocPrgrsUpdateRequestDto) => Promise<void>;
|
||||
|
||||
@@ -24,7 +24,7 @@ function mapEsntlAtchFileList(esntlFileList: InstlSncnMngDetResponse["esntlFileL
|
||||
}
|
||||
|
||||
export function mapDetailToFormData(data: InstlSncnMngDetResponse): InstlSncnRegistFormData {
|
||||
const { detDto, bizSclList, bizCstList, cklstResultList, esntlFileList } = data;
|
||||
const { detDto, bizSclList, bizCstList, cklstResultList, esntlFileList, splmntDmndList } = data;
|
||||
|
||||
return {
|
||||
instlSncnMng: {
|
||||
@@ -85,6 +85,23 @@ export function mapDetailToFormData(data: InstlSncnMngDetResponse): InstlSncnReg
|
||||
picRvwCn: item.picRvwCn,
|
||||
})) ?? [],
|
||||
esntlAtchFileList: mapEsntlAtchFileList(esntlFileList),
|
||||
splmntDmndList:
|
||||
splmntDmndList?.map((item) => ({
|
||||
splmntDmndSn: item.splmntDmndSn,
|
||||
splmntArtclComCd: item.splmntArtclComCd,
|
||||
splmntArtclCdNm: item.splmntArtclCdNm,
|
||||
splmntDmndCn: item.splmntDmndCn,
|
||||
splmntDmndDt: item.splmntDmndDt,
|
||||
splmntDmndAtchFileGroupId: item.splmntDmndAtchFileGroupId ?? "",
|
||||
splmntDmndAtchFileNm: item.splmntDmndAtchFileInfo?.orgnlFileNm ?? "",
|
||||
splmntCn: item.splmntCn ?? "",
|
||||
splmntPicNm: item.splmntPicNm ?? "",
|
||||
splmntCmptnDt: item.splmntCmptnDt ?? "",
|
||||
splmntAtchFileGroupId: item.splmntAtchFileGroupId ?? "",
|
||||
splmntAtchFileNm: item.splmntAtchFileInfo?.orgnlFileNm ?? "",
|
||||
splmntAtchFileAtflId: item.splmntAtchFileInfo?.atflId ?? "",
|
||||
cmptnYn: item.cmptnYn,
|
||||
})) ?? [],
|
||||
privacyConsent: "Y",
|
||||
};
|
||||
}
|
||||
@@ -116,6 +133,7 @@ export function getCreateDefaultValues(
|
||||
bizCstList: [],
|
||||
cklstResultList: [],
|
||||
esntlAtchFileList: [],
|
||||
splmntDmndList: [],
|
||||
privacyConsent: "N",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,6 +75,24 @@ export const instlSncnRegistSchema = z.object({
|
||||
atchFileGroupId: z.string(),
|
||||
}),
|
||||
),
|
||||
splmntDmndList: z.array(
|
||||
z.object({
|
||||
splmntDmndSn: z.number().optional(),
|
||||
splmntArtclComCd: z.string().optional(),
|
||||
splmntArtclCdNm: z.string().nullish(),
|
||||
splmntDmndCn: z.string().optional(),
|
||||
splmntDmndDt: z.string().optional(),
|
||||
splmntDmndAtchFileGroupId: z.string().optional(),
|
||||
splmntDmndAtchFileNm: z.string().optional(),
|
||||
splmntCn: z.string().optional(),
|
||||
splmntPicNm: z.string().optional(),
|
||||
splmntCmptnDt: z.string().optional(),
|
||||
splmntAtchFileGroupId: z.string().optional(),
|
||||
splmntAtchFileNm: z.string().optional(),
|
||||
splmntAtchFileAtflId: z.string().optional(),
|
||||
cmptnYn: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
privacyConsent: z.enum(["Y", "N"], {
|
||||
required_error: "개인정보 수집/이용 동의는 필수입니다",
|
||||
}),
|
||||
|
||||
@@ -115,7 +115,7 @@ export function buildSupplementReqPayload(
|
||||
};
|
||||
}
|
||||
|
||||
function toSplmntDmndSaveItem(item: InstlSncnSplmntDmndSaveDto): InstlSncnSplmntDmndSaveDto {
|
||||
export function toSplmntDmndSaveItem(item: InstlSncnSplmntDmndSaveDto): InstlSncnSplmntDmndSaveDto {
|
||||
return {
|
||||
splmntDmndSn: item.splmntDmndSn,
|
||||
splmntArtclComCd: item.splmntArtclComCd,
|
||||
|
||||
@@ -41,8 +41,10 @@ import {
|
||||
buildSavePayload,
|
||||
buildSupplementReqPayload,
|
||||
buildTechReviewReplyPayload,
|
||||
toSplmntDmndSaveItem,
|
||||
} from "../../lib/save-payload-utils";
|
||||
import { type ChecklistFormState } from "../../model/view-types";
|
||||
import type { SupplementDemandSectionHandle } from "../sections/supplement-demand-section";
|
||||
import type { TechReviewOpinionSectionHandle } from "../sections/tech-review-opinion-section";
|
||||
|
||||
interface FloatingActionButtonsProps {
|
||||
@@ -53,6 +55,7 @@ interface FloatingActionButtonsProps {
|
||||
dtlInstCd: string | undefined;
|
||||
checklistFormState: ChecklistFormState;
|
||||
techReviewOpinionRef?: React.RefObject<TechReviewOpinionSectionHandle | null>;
|
||||
supplementDemandRef?: React.RefObject<SupplementDemandSectionHandle | null>;
|
||||
opnrNm?: string;
|
||||
}
|
||||
|
||||
@@ -66,6 +69,7 @@ function FloatingActionButtons({
|
||||
dtlInstCd,
|
||||
checklistFormState,
|
||||
techReviewOpinionRef,
|
||||
supplementDemandRef,
|
||||
opnrNm,
|
||||
}: FloatingActionButtonsProps) {
|
||||
const router = useRouter();
|
||||
@@ -383,15 +387,20 @@ function FloatingActionButtons({
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildTechReviewReplyPayload(
|
||||
data,
|
||||
checklistFormState,
|
||||
"KECO",
|
||||
"TE",
|
||||
"SV",
|
||||
getReviewOpinionFormData(),
|
||||
opnrNm,
|
||||
);
|
||||
const payload = {
|
||||
...buildTechReviewReplyPayload(
|
||||
data,
|
||||
checklistFormState,
|
||||
"KECO",
|
||||
"TE",
|
||||
"SV",
|
||||
getReviewOpinionFormData(),
|
||||
opnrNm,
|
||||
),
|
||||
splmntDmndList:
|
||||
supplementDemandRef?.current?.getSplmntDmndList() ??
|
||||
(data.splmntDmndList ?? []).map(toSplmntDmndSaveItem),
|
||||
};
|
||||
|
||||
await executeAction({
|
||||
action: INSTL_SNCN_VIEW_BUTTON_ACTION.rvwCmptn,
|
||||
@@ -566,9 +575,17 @@ async function handleDel(context: ActionHandlerContext): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSplmntCmptn(context: ActionHandlerContext): Promise<void> {
|
||||
// TODO: 보완완료 API 호출
|
||||
context.onSuccess?.();
|
||||
async function handleSplmntCmptn(
|
||||
context: ActionHandlerContext<InstlSncnApplySaveRequestDto>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const docNo = context.data.detDto?.instlSncnDocNo;
|
||||
if (!docNo) throw new Error("문서번호가 없습니다.");
|
||||
await instlSncnService.submitSupplement(docNo, context.request);
|
||||
context.onSuccess?.();
|
||||
} catch (error) {
|
||||
context.onError?.(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangePrgrsSttsComCd(
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAlertStore, useUserStore } from "@/shared/lib";
|
||||
import {
|
||||
type InstlSncnApplySaveRequestDto,
|
||||
instlSncnKeys,
|
||||
type InstlSncnMngDetResponse,
|
||||
instlSncnService,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
ContactInfoFormSection,
|
||||
isFormChecklistComplete,
|
||||
PrivacyConsentSection,
|
||||
SplmntDmndFormSection,
|
||||
} from "@/widgets/pbadms-sprt";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
@@ -59,13 +61,13 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
|
||||
: getCreateDefaultValues(userInfo, taskSeComCd),
|
||||
});
|
||||
|
||||
async function submitForm(
|
||||
function buildFormPayload(
|
||||
data: InstlSncnRegistFormData,
|
||||
prgrsSttsComCd: "SV" | "ST",
|
||||
): Promise<string | undefined> {
|
||||
prgrsSttsComCd: "SV" | "ST" | "TE",
|
||||
): InstlSncnApplySaveRequestDto {
|
||||
const { taskSeComCd: formTaskSeComCd, ...instlSncnMngRest } = data.instlSncnMng;
|
||||
|
||||
const payload = {
|
||||
return {
|
||||
instlSncnMng: {
|
||||
...instlSncnMngRest,
|
||||
instlSncnDocNo: isEditMode ? initialData?.detDto.instlSncnDocNo : undefined,
|
||||
@@ -79,9 +81,50 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
|
||||
selected: item.selected,
|
||||
})),
|
||||
esntlAtchFileList: data.esntlAtchFileList,
|
||||
splmntDmndList: data.splmntDmndList.map(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
({ splmntAtchFileNm, splmntAtchFileAtflId, splmntDmndAtchFileNm, ...item }) => item,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return instlSncnService.saveApply(payload);
|
||||
async function submitForm(
|
||||
data: InstlSncnRegistFormData,
|
||||
prgrsSttsComCd: "SV" | "ST",
|
||||
): Promise<string | undefined> {
|
||||
return instlSncnService.saveApply(buildFormPayload(data, prgrsSttsComCd));
|
||||
}
|
||||
|
||||
async function submitSupplementForm(data: InstlSncnRegistFormData): Promise<string | undefined> {
|
||||
const docNo = initialData?.detDto.instlSncnDocNo;
|
||||
if (!docNo) return undefined;
|
||||
return instlSncnService.submitSupplement(docNo, buildFormPayload(data, "TE"));
|
||||
}
|
||||
|
||||
async function onSplmntCmptn(data: InstlSncnRegistFormData) {
|
||||
if (isSubmitting) return;
|
||||
|
||||
const docNo = initialData?.detDto.instlSncnDocNo;
|
||||
if (!docNo) {
|
||||
alert("문서번호가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const result = await submitSupplementForm(data);
|
||||
if (!result) return;
|
||||
|
||||
alert("보완완료 처리되었습니다.");
|
||||
router.push(`/instl-sncn/${docNo}`);
|
||||
queryClient.invalidateQueries({ queryKey: instlSncnKeys.list() });
|
||||
queryClient.invalidateQueries({ queryKey: instlSncnKeys.detail(docNo) });
|
||||
} catch (error) {
|
||||
console.error("보완완료 실패:", error);
|
||||
alert("보완완료 처리 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave(data: InstlSncnRegistFormData) {
|
||||
@@ -132,10 +175,6 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
|
||||
}
|
||||
}
|
||||
|
||||
function handleApplyClick() {
|
||||
form.handleSubmit(onApply)();
|
||||
}
|
||||
|
||||
async function handleDelClick() {
|
||||
if (isSubmitting) return;
|
||||
|
||||
@@ -176,6 +215,7 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
|
||||
initialEsntlFiles={isEditMode ? initialData?.esntlFileList : undefined}
|
||||
initialEtcFiles={isEditMode ? initialData?.etcFileList : undefined}
|
||||
/>
|
||||
<SplmntDmndFormSection 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">
|
||||
@@ -197,7 +237,7 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
|
||||
<Button
|
||||
color="green"
|
||||
type="button"
|
||||
onClick={handleApplyClick}
|
||||
onClick={form.handleSubmit(onApply)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
신청완료
|
||||
@@ -214,7 +254,11 @@ function InstlFormPage({ id, mode, initialData, taskSeComCd }: InstlFormPageProp
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={form.handleSubmit(onSplmntCmptn)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
보완완료
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -18,6 +18,10 @@ import { BusinessCostSection } from "./sections/business-cost-section";
|
||||
import { BusinessScaleSection } from "./sections/business-scale-section";
|
||||
import { ChecklistSection } from "./sections/checklist-section";
|
||||
import { ContactInfoSection } from "./sections/contact-info-section";
|
||||
import {
|
||||
SupplementDemandSection,
|
||||
type SupplementDemandSectionHandle,
|
||||
} from "./sections/supplement-demand-section";
|
||||
import {
|
||||
TechReviewOpinionSection,
|
||||
type TechReviewOpinionSectionHandle,
|
||||
@@ -30,6 +34,7 @@ interface InstlViewPageProps {
|
||||
function InstlViewPage({ id }: InstlViewPageProps) {
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
const techReviewOpinionRef = useRef<TechReviewOpinionSectionHandle>(null);
|
||||
const supplementDemandRef = useRef<SupplementDemandSectionHandle>(null);
|
||||
const { data, isLoading, isError } = useInstlSncnDetail(id);
|
||||
const { formState: checklistFormState, setFormState: setChecklistFormState } =
|
||||
useChecklistFormState(data?.cklstResultList ?? EMPTY_CKLST_RESULT_LIST);
|
||||
@@ -65,6 +70,7 @@ function InstlViewPage({ id }: InstlViewPageProps) {
|
||||
esntlFileList,
|
||||
etcFileList,
|
||||
rvwList = [],
|
||||
splmntDmndList = [],
|
||||
} = data;
|
||||
|
||||
const editableReviewStep = getEditableReviewStep(
|
||||
@@ -94,6 +100,11 @@ function InstlViewPage({ id }: InstlViewPageProps) {
|
||||
rvwList={rvwList}
|
||||
editableReviewStep={editableReviewStep}
|
||||
/>
|
||||
<SupplementDemandSection
|
||||
ref={supplementDemandRef}
|
||||
splmntDmndList={splmntDmndList}
|
||||
canEditCmptnYn={userInfo?.dtlInstCd === "ROLE_USER01"}
|
||||
/>
|
||||
<FloatingActionButtons
|
||||
data={data}
|
||||
docSeComCd="INSTL"
|
||||
@@ -102,6 +113,7 @@ function InstlViewPage({ id }: InstlViewPageProps) {
|
||||
dtlInstCd={userInfo?.dtlInstCd}
|
||||
checklistFormState={checklistFormState}
|
||||
techReviewOpinionRef={techReviewOpinionRef}
|
||||
supplementDemandRef={supplementDemandRef}
|
||||
opnrNm={userInfo?.userName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef, useImperativeHandle, useState } from "react";
|
||||
|
||||
import { Paperclip } from "lucide-react";
|
||||
|
||||
import { dayjs } from "@/shared/lib";
|
||||
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
|
||||
import { useDownloadAttachmentByAtflCd } from "@/entities/attach";
|
||||
import type {
|
||||
InstlSncnSplmntDmndResponseDto,
|
||||
InstlSncnSplmntDmndSaveDto,
|
||||
} from "@/entities/instl-sncn";
|
||||
import { SectionCollapsible } from "@/widgets/pbadms-sprt";
|
||||
import {
|
||||
Badge,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import { toSplmntDmndSaveItem } from "../../lib/save-payload-utils";
|
||||
|
||||
interface SupplementDemandSectionProps {
|
||||
splmntDmndList: InstlSncnSplmntDmndResponseDto[];
|
||||
canEditCmptnYn: boolean;
|
||||
}
|
||||
|
||||
export interface SupplementDemandSectionHandle {
|
||||
getSplmntDmndList: () => InstlSncnSplmntDmndSaveDto[];
|
||||
}
|
||||
|
||||
const SupplementDemandSection = forwardRef<
|
||||
SupplementDemandSectionHandle,
|
||||
SupplementDemandSectionProps
|
||||
>(function SupplementDemandSection({ splmntDmndList, canEditCmptnYn }, ref) {
|
||||
const [cmptnYnBySn, setCmptnYnBySn] = useState<Record<number, string>>({});
|
||||
const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
getSplmntDmndList: () =>
|
||||
splmntDmndList.map((item) => {
|
||||
const saveItem = toSplmntDmndSaveItem(item);
|
||||
const sn = item.splmntDmndSn;
|
||||
if (sn == null || cmptnYnBySn[sn] == null) return saveItem;
|
||||
return { ...saveItem, cmptnYn: cmptnYnBySn[sn] };
|
||||
}),
|
||||
}),
|
||||
[splmntDmndList, cmptnYnBySn],
|
||||
);
|
||||
|
||||
function getCmptnYn(item: InstlSncnSplmntDmndResponseDto) {
|
||||
const sn = item.splmntDmndSn;
|
||||
if (sn != null && cmptnYnBySn[sn] != null) return cmptnYnBySn[sn];
|
||||
return item.cmptnYn ?? "N";
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCollapsible title="보완요청 및 완료 현황">
|
||||
<div className="overflow-x-auto">
|
||||
<Table variant="secondary">
|
||||
<colgroup>
|
||||
<col width="14%" />
|
||||
<col width="8%" />
|
||||
<col width="30%" />
|
||||
<col width="8%" />
|
||||
<col width="30%" />
|
||||
<col width="10%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>보완항목</TableHead>
|
||||
<TableHead>보완요청일시</TableHead>
|
||||
<TableHead>보완요청내용</TableHead>
|
||||
<TableHead>보완완료일시</TableHead>
|
||||
<TableHead>보완완료내용</TableHead>
|
||||
<TableHead>완료여부</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{splmntDmndList.map((item) => {
|
||||
const sn = item.splmntDmndSn;
|
||||
const cmptnYn = getCmptnYn(item);
|
||||
const dmndAtchFileNm = item.splmntDmndAtchFileInfo?.orgnlFileNm;
|
||||
const cmptnAtchFileNm = item.splmntAtchFileInfo?.orgnlFileNm;
|
||||
|
||||
return (
|
||||
<TableRow key={sn ?? `${item.splmntArtclComCd}-${item.splmntDmndDt}`}>
|
||||
<TableCell align="center" className="align-top">
|
||||
{item.splmntArtclCdNm || "-"}
|
||||
</TableCell>
|
||||
<TableCell align="center" className="whitespace-nowrap align-top">
|
||||
{item.splmntDmndDt ? dayjs(item.splmntDmndDt).format("YYYY-MM-DD HH:mm") : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<div className="space-y-2">
|
||||
<div className="whitespace-pre-wrap">{item.splmntDmndCn || "-"}</div>
|
||||
{item.splmntDmndAtchFileGroupId ? (
|
||||
<a
|
||||
className="inline-flex cursor-pointer items-start gap-1 text-blue-600 hover:underline"
|
||||
onClick={() => downloadAttachment(item.splmntDmndAtchFileGroupId!)}
|
||||
>
|
||||
<Paperclip className="my-1 size-5 shrink-0" />
|
||||
{dmndAtchFileNm || "다운로드"}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell align="center" className="whitespace-nowrap align-top">
|
||||
{item.splmntCmptnDt
|
||||
? dayjs(item.splmntCmptnDt).format("YYYY-MM-DD HH:mm")
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<div className="space-y-2">
|
||||
<div className="whitespace-pre-wrap">{item.splmntCn || "-"}</div>
|
||||
{item.splmntAtchFileGroupId ? (
|
||||
<a
|
||||
className="inline-flex cursor-pointer items-start gap-1 text-blue-600 hover:underline"
|
||||
onClick={() => downloadAttachment(item.splmntAtchFileGroupId!)}
|
||||
>
|
||||
<Paperclip className="my-1 size-5 shrink-0" />
|
||||
{cmptnAtchFileNm || "다운로드"}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell align="center" className="align-top">
|
||||
{canEditCmptnYn && sn != null ? (
|
||||
<FieldSet>
|
||||
<RadioGroup
|
||||
value={cmptnYn}
|
||||
onValueChange={(value) =>
|
||||
setCmptnYnBySn((prev) => ({ ...prev, [sn]: value }))
|
||||
}
|
||||
className="flex justify-center gap-4"
|
||||
>
|
||||
<FieldLabel
|
||||
htmlFor={`splmnt-cmptn-y-${sn}`}
|
||||
className="!w-auto border-none"
|
||||
>
|
||||
<Field orientation="horizontal" className="w-auto">
|
||||
<RadioGroupItem value="Y" id={`splmnt-cmptn-y-${sn}`} />
|
||||
<span className="font-normal">완료</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
<FieldLabel
|
||||
htmlFor={`splmnt-cmptn-n-${sn}`}
|
||||
className="!w-auto border-none"
|
||||
>
|
||||
<Field orientation="horizontal" className="w-auto">
|
||||
<RadioGroupItem value="N" id={`splmnt-cmptn-n-${sn}`} />
|
||||
<span className="font-normal">미완료</span>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
</RadioGroup>
|
||||
</FieldSet>
|
||||
) : (
|
||||
<Badge size="lg" color={cmptnYn === "Y" ? "green" : "gray"}>
|
||||
{cmptnYn === "Y" ? "완료" : "미완료"}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
});
|
||||
|
||||
export { SupplementDemandSection };
|
||||
@@ -167,8 +167,8 @@ const TechReviewOpinionSection = forwardRef<
|
||||
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
|
||||
|
||||
return (
|
||||
<div className="p-2">
|
||||
<h4 className="mb-4 text-xl font-semibold">검토의견 작성</h4>
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xl font-semibold">검토의견 작성</h4>
|
||||
<Table variant="secondary">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -267,14 +267,18 @@ const TechReviewOpinionSection = forwardRef<
|
||||
<>
|
||||
{hasEnvSection ? (
|
||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
|
||||
{envRvwList.length > 0 ? <TechReviewOpinionTable items={envRvwList} /> : null}
|
||||
{renderOpinionForm("ENV")}
|
||||
<div className="space-y-6">
|
||||
{envRvwList.length > 0 ? <TechReviewOpinionTable items={envRvwList} /> : null}
|
||||
{renderOpinionForm("ENV")}
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
) : null}
|
||||
{hasKecoSection ? (
|
||||
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
|
||||
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null}
|
||||
{renderOpinionForm("KECO")}
|
||||
<div className="space-y-6">
|
||||
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null}
|
||||
{renderOpinionForm("KECO")}
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -35,4 +35,5 @@ export {
|
||||
type SplmntDmndDialogConfirmItem,
|
||||
type SplmntDmndDialogConfirmPayload,
|
||||
} from "./ui/splmnt-dmnd-dialog";
|
||||
export { type SplmntDmndFormItem, SplmntDmndFormSection } from "./ui/splmnt-dmnd-form-section";
|
||||
export { TaskSePickerDialog } from "./ui/task-se-picker-dialog";
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { Paperclip } from "lucide-react";
|
||||
|
||||
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
|
||||
import { attachService, useDownloadAttachmentByAtflCd } from "@/entities/attach";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
import { SectionCollapsible } from "./section-collapsible";
|
||||
|
||||
const SPLMNT_CMPTN_FILE_KEY_PREFIX = "splmnt-cmptn::";
|
||||
|
||||
interface UploadedSplmntFile {
|
||||
fileName: string;
|
||||
fileKey: string;
|
||||
atflId?: string;
|
||||
atflCd?: string;
|
||||
}
|
||||
|
||||
interface SplmntDmndFormItem {
|
||||
splmntDmndSn?: number;
|
||||
splmntArtclComCd?: string;
|
||||
splmntArtclCdNm?: string | null;
|
||||
splmntDmndCn?: string;
|
||||
splmntDmndDt?: string;
|
||||
splmntDmndAtchFileGroupId?: string;
|
||||
splmntDmndAtchFileNm?: string;
|
||||
splmntCn?: string;
|
||||
splmntPicNm?: string;
|
||||
splmntCmptnDt?: string;
|
||||
splmntAtchFileGroupId?: string;
|
||||
splmntAtchFileNm?: string;
|
||||
splmntAtchFileAtflId?: string;
|
||||
cmptnYn?: string;
|
||||
}
|
||||
|
||||
interface SplmntDmndFormData {
|
||||
splmntDmndList: SplmntDmndFormItem[];
|
||||
}
|
||||
|
||||
interface SplmntDmndFormSectionProps {
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
function createUploadKey(rowKey: string, file: File) {
|
||||
return `${SPLMNT_CMPTN_FILE_KEY_PREFIX}${rowKey}::${getDefaultFileKey(file)}`;
|
||||
}
|
||||
|
||||
function getRowKeyFromFileKey(fileKey: string): string | null {
|
||||
if (!fileKey.startsWith(SPLMNT_CMPTN_FILE_KEY_PREFIX)) return null;
|
||||
const rest = fileKey.slice(SPLMNT_CMPTN_FILE_KEY_PREFIX.length);
|
||||
const separatorIndex = rest.indexOf("::");
|
||||
if (separatorIndex < 0) return null;
|
||||
return rest.slice(0, separatorIndex);
|
||||
}
|
||||
|
||||
function getRowKey(item: SplmntDmndFormItem, index: number) {
|
||||
return item.splmntDmndSn != null ? String(item.splmntDmndSn) : `idx-${index}`;
|
||||
}
|
||||
|
||||
function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps) {
|
||||
const formId = useId();
|
||||
const { control, setValue, getValues } = useFormContext<SplmntDmndFormData>();
|
||||
const splmntDmndList = useWatch({ control, name: "splmntDmndList" }) ?? [];
|
||||
const [uploadedFiles, setUploadedFiles] = useState<Record<string, UploadedSplmntFile | null>>({});
|
||||
const initialFilesMappedRef = useRef(false);
|
||||
const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
|
||||
|
||||
const { uploadFileAsync, isUploading, uploads } = useTus({
|
||||
onSuccess: (atflCd, atflId, _file, fileKey) => {
|
||||
if (!atflCd || !atflId) return;
|
||||
const rowKey = getRowKeyFromFileKey(fileKey);
|
||||
if (!rowKey) return;
|
||||
|
||||
setUploadedFiles((prev) => {
|
||||
const current = prev[rowKey];
|
||||
if (!current || current.fileKey !== fileKey) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[rowKey]: { ...current, atflCd, atflId },
|
||||
};
|
||||
});
|
||||
|
||||
const list = getValues("splmntDmndList") ?? [];
|
||||
const index = list.findIndex((item, idx) => getRowKey(item, idx) === rowKey);
|
||||
if (index < 0) return;
|
||||
|
||||
setValue(`splmntDmndList.${index}.splmntAtchFileGroupId`, atflCd, { shouldDirty: true });
|
||||
setValue(`splmntDmndList.${index}.splmntAtchFileAtflId`, atflId, { shouldDirty: true });
|
||||
},
|
||||
onError: (_error, _file, fileKey) => {
|
||||
const rowKey = getRowKeyFromFileKey(fileKey);
|
||||
if (!rowKey) return;
|
||||
|
||||
setUploadedFiles((prev) => {
|
||||
const current = prev[rowKey];
|
||||
if (!current || current.fileKey !== fileKey) return prev;
|
||||
return { ...prev, [rowKey]: null };
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (initialFilesMappedRef.current) return;
|
||||
if (splmntDmndList.length === 0) return;
|
||||
|
||||
const initial: Record<string, UploadedSplmntFile | null> = {};
|
||||
for (let index = 0; index < splmntDmndList.length; index += 1) {
|
||||
const item = splmntDmndList[index];
|
||||
const rowKey = getRowKey(item, index);
|
||||
if (item.splmntAtchFileGroupId && item.splmntAtchFileNm) {
|
||||
initial[rowKey] = {
|
||||
fileName: item.splmntAtchFileNm,
|
||||
fileKey: `${SPLMNT_CMPTN_FILE_KEY_PREFIX}${rowKey}::initial`,
|
||||
atflCd: item.splmntAtchFileGroupId,
|
||||
atflId: item.splmntAtchFileAtflId,
|
||||
};
|
||||
} else {
|
||||
initial[rowKey] = null;
|
||||
}
|
||||
}
|
||||
|
||||
setUploadedFiles(initial);
|
||||
initialFilesMappedRef.current = true;
|
||||
}, [splmntDmndList]);
|
||||
|
||||
if (splmntDmndList.length === 0) return null;
|
||||
|
||||
async function handleFileUpload(index: number, files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const item = splmntDmndList[index];
|
||||
const rowKey = getRowKey(item, index);
|
||||
const file = files[0];
|
||||
const fileKey = createUploadKey(rowKey, file);
|
||||
const existingAtflCd = item.splmntAtchFileGroupId;
|
||||
|
||||
setUploadedFiles((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: { fileName: file.name, fileKey },
|
||||
}));
|
||||
setValue(`splmntDmndList.${index}.splmntAtchFileNm`, file.name, { shouldDirty: true });
|
||||
|
||||
try {
|
||||
await uploadFileAsync(file, {
|
||||
fileKey,
|
||||
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
|
||||
});
|
||||
} catch {
|
||||
// onError callback handles cleanup
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileRemove(index: number) {
|
||||
const item = splmntDmndList[index];
|
||||
const rowKey = getRowKey(item, index);
|
||||
const uploaded = uploadedFiles[rowKey];
|
||||
if (!uploaded) return;
|
||||
|
||||
const { fileKey, atflId } = uploaded;
|
||||
uploads[fileKey]?.abort();
|
||||
|
||||
if (atflId) {
|
||||
try {
|
||||
await attachService.deleteAttachmentByAtflId(atflId);
|
||||
} catch (error) {
|
||||
console.error("보완완료 첨부파일 삭제 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
setUploadedFiles((prev) => ({ ...prev, [rowKey]: null }));
|
||||
setValue(`splmntDmndList.${index}.splmntAtchFileGroupId`, "", { shouldDirty: true });
|
||||
setValue(`splmntDmndList.${index}.splmntAtchFileNm`, "", { shouldDirty: true });
|
||||
setValue(`splmntDmndList.${index}.splmntAtchFileAtflId`, "", { shouldDirty: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCollapsible title="보완요청">
|
||||
<div className="overflow-x-auto">
|
||||
<Table variant="secondary">
|
||||
<colgroup>
|
||||
<col width="12%" />
|
||||
<col width="14%" />
|
||||
<col width="22%" />
|
||||
<col width="22%" />
|
||||
<col width="18%" />
|
||||
<col width="12%" />
|
||||
</colgroup>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>보완항목</TableHead>
|
||||
<TableHead>보완요청일시</TableHead>
|
||||
<TableHead>보완요청내용</TableHead>
|
||||
<TableHead>보완완료내용</TableHead>
|
||||
<TableHead>파일첨부</TableHead>
|
||||
<TableHead>완료여부</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{splmntDmndList.map((item, index) => {
|
||||
const rowKey = getRowKey(item, index);
|
||||
const uploaded = uploadedFiles[rowKey] ?? null;
|
||||
const uploading = uploaded ? (isUploading[uploaded.fileKey] ?? false) : false;
|
||||
const fileInputId = `${formId}-${rowKey}-file`;
|
||||
const isRowReadOnly = readOnly || item.cmptnYn === "Y";
|
||||
|
||||
return (
|
||||
<TableRow key={rowKey}>
|
||||
<TableCell align="center" className="align-top">
|
||||
{item.splmntArtclCdNm || "-"}
|
||||
</TableCell>
|
||||
<TableCell align="center" className="whitespace-nowrap align-top">
|
||||
{item.splmntDmndDt ? dayjs(item.splmntDmndDt).format("YYYY-MM-DD HH:mm") : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<div className="space-y-2">
|
||||
<div className="whitespace-pre-wrap">{item.splmntDmndCn || "-"}</div>
|
||||
{item.splmntDmndAtchFileGroupId ? (
|
||||
<a
|
||||
className="inline-flex cursor-pointer items-start gap-1 text-blue-600 hover:underline"
|
||||
onClick={() => downloadAttachment(item.splmntDmndAtchFileGroupId!)}
|
||||
>
|
||||
<Paperclip className="my-1 size-5 shrink-0" />
|
||||
{item.splmntDmndAtchFileNm || "다운로드"}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
{isRowReadOnly ? (
|
||||
<span className="whitespace-pre-wrap">{item.splmntCn || "-"}</span>
|
||||
) : (
|
||||
<Controller
|
||||
control={control}
|
||||
name={`splmntDmndList.${index}.splmntCn`}
|
||||
render={({ field }) => (
|
||||
<Textarea
|
||||
value={field.value ?? ""}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="보완완료 내용을 입력하세요."
|
||||
className="min-h-[6rem] !text-xl"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
{uploaded ? (
|
||||
<div className="flex items-stretch gap-2">
|
||||
{uploaded.atflCd || item.splmntAtchFileGroupId ? (
|
||||
<a
|
||||
className="inline-flex cursor-pointer items-start gap-1 text-blue-600 hover:underline"
|
||||
onClick={() =>
|
||||
downloadAttachment((uploaded.atflCd || item.splmntAtchFileGroupId)!)
|
||||
}
|
||||
>
|
||||
<Paperclip className="my-1 size-5 shrink-0" />
|
||||
{uploaded.fileName}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-xl">{uploaded.fileName}</span>
|
||||
)}
|
||||
{!isRowReadOnly ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="size-8 shrink-0 !bg-destructive"
|
||||
disabled={uploading}
|
||||
onClick={() => void handleFileRemove(index)}
|
||||
>
|
||||
<i className="diveicon di-x text-xs" aria-label="삭제" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : isRowReadOnly ? (
|
||||
<span>-</span>
|
||||
) : (
|
||||
<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(index, event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="center" className="align-top">
|
||||
<Badge size="lg" color={item.cmptnYn === "Y" ? "green" : "gray"}>
|
||||
{item.cmptnYn === "Y" ? "완료" : "미완료"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export { SplmntDmndFormSection };
|
||||
export type { SplmntDmndFormItem };
|
||||
Reference in New Issue
Block a user