[as] feat: 재원협의 신청 및 수정 페이지 작업
This commit is contained in:
@@ -35,4 +35,5 @@ export type {
|
||||
FnrsCnsltnRvwOpnnSaveRequestDto,
|
||||
FnrsCnsltnSplmntDmndItem,
|
||||
FnrsCnsltnSplmntDmndResponseDto,
|
||||
FnrsSharItem,
|
||||
} from "./model/fnrs-cnsltn-interface";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AtflMngClientResponseDto,
|
||||
BizCstSaveBase,
|
||||
BzScaleSaveBase,
|
||||
CklstResultSaveBase,
|
||||
@@ -13,6 +14,7 @@ import type {
|
||||
SplmntDmndResponseExtension,
|
||||
SplmntDmndSaveBase,
|
||||
} from "@/shared/model";
|
||||
import type { FnrsSharBizCstTreeDto } from "@/entities/fnrs-shar-se";
|
||||
|
||||
export type {
|
||||
AtflMngClientResponseDto,
|
||||
@@ -45,19 +47,39 @@ export interface FnrsCnsltnMngSaveRequestDto {
|
||||
dsgnrOgdpNm?: string;
|
||||
dsgnrEmlAddr?: string;
|
||||
atchFileGroupId?: string;
|
||||
sharAtchFileGroupId?: string;
|
||||
prgrsSttsComCd?: string;
|
||||
rtlztnFcltSeComCd?: string;
|
||||
rtlztnUsgSeComCd?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 FnrsCnsltnMngUpdateRequestDto */
|
||||
export type FnrsCnsltnMngUpdateRequestDto = Omit<
|
||||
FnrsCnsltnMngSaveRequestDto,
|
||||
"fnrsCnsltnDocNo"
|
||||
>;
|
||||
export type FnrsCnsltnMngUpdateRequestDto = Omit<FnrsCnsltnMngSaveRequestDto, "fnrsCnsltnDocNo">;
|
||||
|
||||
/** 백엔드 FnrsCnsltnMngSearchDto + Pageable */
|
||||
export type FnrsCnsltnMngSearchDto = PageableParams & Partial<FnrsCnsltnMngSaveRequestDto>;
|
||||
export interface FnrsCnsltnMngSearchDto extends PageableParams {
|
||||
fnrsCnsltnDocNo?: string;
|
||||
admdstCd?: string;
|
||||
envoSeComCd?: string;
|
||||
taskSeComCd?: string;
|
||||
cfmtnBgtBizNo?: string;
|
||||
bgtAplyYr?: string;
|
||||
bizNm?: string;
|
||||
bizBgngYr?: string;
|
||||
bizEndYr?: string;
|
||||
bizCn?: string;
|
||||
aplcntNm?: string;
|
||||
aplcntMblTelno?: string;
|
||||
aplcntOgdpNm?: string;
|
||||
aplcntEmlAddr?: string;
|
||||
dsgnrNm?: string;
|
||||
dsgnrTelno?: string;
|
||||
dsgnrOgdpNm?: string;
|
||||
dsgnrEmlAddr?: string;
|
||||
atchFileGroupId?: string;
|
||||
sharAtchFileGroupId?: string;
|
||||
prgrsSttsComCd?: string;
|
||||
}
|
||||
|
||||
interface FnrsCnsltnMngResponseExtension {
|
||||
fnrsCnsltnDocNo: string;
|
||||
@@ -88,6 +110,16 @@ export type EsntlAtchFileItem = EsntlAtchFileSaveBase;
|
||||
|
||||
export type FnrsCnsltnSplmntDmndItem = SplmntDmndSaveBase;
|
||||
|
||||
/** 백엔드 FnrsCnsltnApplySaveRequestDto.FnrsSharItem */
|
||||
export interface FnrsSharItem {
|
||||
fnrsSharSeCd?: string;
|
||||
exstnAmt?: number;
|
||||
chgRqtAmt?: number;
|
||||
ajmtAmt?: number;
|
||||
rmrkCn?: string;
|
||||
envoRmrkCn?: string;
|
||||
}
|
||||
|
||||
/** 백엔드 FnrsCnsltnRvwOpnnSaveRequestDto */
|
||||
export interface FnrsCnsltnRvwOpnnSaveRequestDto extends RvwOpnnSaveBase {
|
||||
fnrsCnsltnDocNo: string;
|
||||
@@ -112,7 +144,10 @@ export type FnrsCnsltnMngDetResponseDto = PbadmsMngDetResponseBase<
|
||||
FnrsCnsltnMngResponseDto,
|
||||
FnrsCnsltnRvwOpnnResponseDto,
|
||||
FnrsCnsltnSplmntDmndResponseDto
|
||||
>;
|
||||
> & {
|
||||
fnrsSharList?: FnrsSharBizCstTreeDto[];
|
||||
sharAtchFileList?: AtflMngClientResponseDto[];
|
||||
};
|
||||
|
||||
/** 백엔드 FnrsCnsltnApplySaveRequestDto */
|
||||
export interface FnrsCnsltnApplySaveRequestDto extends PbadmsApplySaveHeader {
|
||||
@@ -124,6 +159,7 @@ export interface FnrsCnsltnApplySaveRequestDto extends PbadmsApplySaveHeader {
|
||||
esntlAtchFileList?: EsntlAtchFileItem[];
|
||||
rvwOpnnList?: FnrsCnsltnRvwOpnnSaveRequestDto[];
|
||||
splmntDmndList?: FnrsCnsltnSplmntDmndItem[];
|
||||
fnrsSharList?: FnrsSharItem[];
|
||||
}
|
||||
|
||||
/** Controller public 메서드명·시그니처와 동일 */
|
||||
|
||||
@@ -27,6 +27,7 @@ export const fnrsCnsltnRegistSchema = z.object({
|
||||
rtlztnUsgSeComCd: z.string().optional(),
|
||||
prgrsSttsComCd: z.string().optional(),
|
||||
atchFileGroupId: z.string().optional(),
|
||||
sharAtchFileGroupId: z.string().optional(),
|
||||
envoSeCdNm: z.string().optional(),
|
||||
taskSeCdNm: z.string().optional(),
|
||||
prgrsSttsCdNm: z.string().optional(),
|
||||
@@ -54,6 +55,15 @@ export const fnrsCnsltnRegistSchema = z.object({
|
||||
tstmDesignAmt: z.number(),
|
||||
}),
|
||||
),
|
||||
fnrsSharList: z.array(
|
||||
z.object({
|
||||
fnrsSharSeCd: z.string(),
|
||||
exstnAmt: z.number(),
|
||||
chgRqtAmt: z.number(),
|
||||
ajmtAmt: z.number().optional(),
|
||||
rmrkCn: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
cklstResultList: z.array(
|
||||
z.object({
|
||||
cklstSn: z.number(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { IUserInfo } from "@/shared/store/user-store";
|
||||
import type { FnrsCnsltnMngDetResponseDto } from "@/entities/fnrs-cnsltn";
|
||||
import { mapFnrsSharDetToFormList } from "@/widgets/pbadms-sprt";
|
||||
|
||||
import type { FnrsCnsltnRegistFormData } from "./fnrs-cnsltn-regist-schema";
|
||||
import { parseTaskSeComCdFromApi } from "./fnrs-cnsltn-regist-schema";
|
||||
@@ -58,6 +59,7 @@ export function mapDetailToFormData(data: FnrsCnsltnMngDetResponseDto): FnrsCnsl
|
||||
rtlztnUsgSeComCd: detDto.rtlztnUsgSeComCd ?? "",
|
||||
prgrsSttsComCd: detDto.prgrsSttsComCd,
|
||||
atchFileGroupId: detDto.atchFileGroupId ?? "",
|
||||
sharAtchFileGroupId: detDto.sharAtchFileGroupId ?? "",
|
||||
envoSeCdNm: detDto.envoSeCdNm,
|
||||
taskSeCdNm: detDto.taskSeCdNm,
|
||||
prgrsSttsCdNm: detDto.prgrsSttsCdNm,
|
||||
@@ -81,6 +83,9 @@ export function mapDetailToFormData(data: FnrsCnsltnMngDetResponseDto): FnrsCnsl
|
||||
bgtRfltAmt: item.bgtRfltAmt,
|
||||
tstmDesignAmt: item.tstmDesignAmt,
|
||||
})),
|
||||
fnrsSharList: data.fnrsSharList?.length
|
||||
? mapFnrsSharDetToFormList(data.fnrsSharList)
|
||||
: [],
|
||||
cklstResultList:
|
||||
cklstResultList?.map((item) => ({
|
||||
cklstSn: item.cklstSn,
|
||||
@@ -118,11 +123,13 @@ export function getCreateDefaultValues(
|
||||
aplcntEmlAddr: userInfo?.email ?? "",
|
||||
prgrsSttsComCd: "SV",
|
||||
atchFileGroupId: "",
|
||||
sharAtchFileGroupId: "",
|
||||
rtlztnFcltSeComCd: "",
|
||||
rtlztnUsgSeComCd: "",
|
||||
},
|
||||
bzScaleList: [],
|
||||
bizCstList: [],
|
||||
fnrsSharList: [],
|
||||
cklstResultList: [],
|
||||
esntlAtchFileList: [],
|
||||
privacyConsent: "N",
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
FnrsCnsltnMngResponseDto,
|
||||
FnrsCnsltnSplmntDmndItem,
|
||||
} from "@/entities/fnrs-cnsltn";
|
||||
import { mapFnrsSharDetToFormList, toFnrsSharSaveList } from "@/widgets/pbadms-sprt";
|
||||
|
||||
import type { ChecklistFormState, ReviewStepCode } from "../model/view-types";
|
||||
|
||||
@@ -77,6 +78,7 @@ export function buildSavePayload(
|
||||
bizCstList: toBizCstSaveDto(bizCstList),
|
||||
cklstResultList: toCklstSaveDto(cklstResultList, checklistFormState, reviewStep),
|
||||
esntlAtchFileList: toEsntlAtchFileSaveDto(esntlFileList),
|
||||
fnrsSharList: toFnrsSharSaveList(mapFnrsSharDetToFormList(data.fnrsSharList ?? [])),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,137 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAlertStore, useUserStore } from "@/shared/lib";
|
||||
import {
|
||||
fnrsCnsltnMngBaseKey,
|
||||
type FnrsCnsltnMngDetResponseDto,
|
||||
fnrsCnsltnMngService,
|
||||
} from "@/entities/fnrs-cnsltn";
|
||||
import {
|
||||
AttachmentsFormSection,
|
||||
BusinessCostFormSection,
|
||||
BusinessScaleFormSection,
|
||||
ChecklistFormSection,
|
||||
ContactInfoFormSection,
|
||||
PrivacyConsentSection,
|
||||
} from "@/widgets/pbadms-sprt";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import {
|
||||
type FnrsCnsltnRegistFormData,
|
||||
fnrsCnsltnRegistSchema,
|
||||
formatTaskSeComCdForApi,
|
||||
} from "../lib/fnrs-cnsltn-regist-schema";
|
||||
import { getCreateDefaultValues, mapDetailToFormData } from "../lib/form-data-mapper";
|
||||
|
||||
import { BasicInfoFormSection } from "./sections/basic-info-form-section";
|
||||
interface FnrsFormPageProps {
|
||||
id?: string;
|
||||
mode: "create" | "edit";
|
||||
initialData?: FnrsCnsltnMngDetResponseDto;
|
||||
taskSeComCd?: string[];
|
||||
}
|
||||
|
||||
function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const isEditMode = mode === "edit";
|
||||
|
||||
const form = useForm<FnrsCnsltnRegistFormData>({
|
||||
resolver: zodResolver(fnrsCnsltnRegistSchema),
|
||||
mode: "onChange",
|
||||
defaultValues:
|
||||
isEditMode && initialData
|
||||
? mapDetailToFormData(initialData)
|
||||
: getCreateDefaultValues(userInfo, taskSeComCd),
|
||||
});
|
||||
|
||||
async function onSave(data: FnrsCnsltnRegistFormData) {
|
||||
if (isSubmitting) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
const { taskSeComCd, ...fnrsCnsltnMngRest } = data.fnrsCnsltnMng;
|
||||
|
||||
const payload = {
|
||||
fnrsCnsltnMng: {
|
||||
...fnrsCnsltnMngRest,
|
||||
taskSeComCd: formatTaskSeComCdForApi(taskSeComCd),
|
||||
fnrsCnsltnDocNo: isEditMode ? initialData?.detDto?.fnrsCnsltnDocNo : undefined,
|
||||
prgrsSttsComCd: "SV",
|
||||
},
|
||||
bzScaleList: data.bzScaleList,
|
||||
bizCstList: data.bizCstList,
|
||||
cklstResultList: data.cklstResultList.map((item) => ({
|
||||
...item,
|
||||
selected: item.selected ?? undefined,
|
||||
})),
|
||||
esntlAtchFileList: data.esntlAtchFileList,
|
||||
};
|
||||
|
||||
const fnrsCnsltnDocNo = await fnrsCnsltnMngService.saveApply(payload);
|
||||
|
||||
alert("저장되었습니다.");
|
||||
router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}`);
|
||||
if (isEditMode && id) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...fnrsCnsltnMngBaseKey, "findById", id],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("저장 실패:", error);
|
||||
alert("저장 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSave)} className="mt-6 space-y-4">
|
||||
<BasicInfoFormSection />
|
||||
<ContactInfoFormSection fieldPrefix="fnrsCnsltnMng" />
|
||||
<PrivacyConsentSection />
|
||||
<BusinessScaleFormSection />
|
||||
<BusinessCostFormSection />
|
||||
<ChecklistFormSection docSeComCd="FNRS" fieldPrefix="fnrsCnsltnMng" />
|
||||
<AttachmentsFormSection
|
||||
docSeComCd="FNRS"
|
||||
fieldPrefix="fnrsCnsltnMng"
|
||||
initialEsntlFiles={isEditMode ? initialData?.esntlFileList : undefined}
|
||||
initialEtcFiles={isEditMode ? initialData?.etcFileList : undefined}
|
||||
/>
|
||||
|
||||
<div className="sticky bottom-0 z-40 !-mb-2 border-t bg-white pb-8 pt-6 shadow-[0_-4px_12px_rgba(0,0,0,0.06)]">
|
||||
<div className="mx-4 flex flex-wrap justify-center gap-3 sm:gap-4">
|
||||
<Button
|
||||
color="white"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
router.back();
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
목록
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export { FnrsFormPage };
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAlertStore, useUserStore } from "@/shared/lib";
|
||||
import { useAcntgSeMngTree } from "@/entities/acntg";
|
||||
import {
|
||||
fnrsCnsltnMngBaseKey,
|
||||
type FnrsCnsltnMngDetResponseDto,
|
||||
fnrsCnsltnMngService,
|
||||
} from "@/entities/fnrs-cnsltn";
|
||||
import { useFnrsSharSeMngTree } from "@/entities/fnrs-shar-se";
|
||||
import {
|
||||
AttachmentsFormSection,
|
||||
buildAcntgCostItems,
|
||||
buildFnrsSharItems,
|
||||
BusinessCostFormSection,
|
||||
BusinessScaleFormSection,
|
||||
ChecklistFormSection,
|
||||
ContactInfoFormSection,
|
||||
FnrsSharFormSection,
|
||||
PrivacyConsentSection,
|
||||
recalculateFnrsSharTotals,
|
||||
toFnrsSharSaveList,
|
||||
validateBizCstSharBalance,
|
||||
} from "@/widgets/pbadms-sprt";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import {
|
||||
type FnrsCnsltnRegistFormData,
|
||||
fnrsCnsltnRegistSchema,
|
||||
formatTaskSeComCdForApi,
|
||||
} from "../lib/fnrs-cnsltn-regist-schema";
|
||||
import { getCreateDefaultValues, mapDetailToFormData } from "../lib/form-data-mapper";
|
||||
|
||||
import { BasicInfoFormSection } from "./sections/basic-info-form-section";
|
||||
interface FnrsFormPageProps {
|
||||
id?: string;
|
||||
mode: "create" | "edit";
|
||||
initialData?: FnrsCnsltnMngDetResponseDto;
|
||||
taskSeComCd?: string[];
|
||||
}
|
||||
|
||||
function FnrsFormPage({ id, mode, initialData, taskSeComCd }: FnrsFormPageProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const isEditMode = mode === "edit";
|
||||
|
||||
const { data: masterSharTree = [] } = useFnrsSharSeMngTree();
|
||||
const { data: acntgTree = [] } = useAcntgSeMngTree();
|
||||
const sharTree =
|
||||
isEditMode && initialData?.fnrsSharList?.length
|
||||
? initialData.fnrsSharList
|
||||
: masterSharTree;
|
||||
const sharItems = useMemo(() => buildFnrsSharItems(sharTree), [sharTree]);
|
||||
const costItems = useMemo(() => buildAcntgCostItems(acntgTree), [acntgTree]);
|
||||
|
||||
const form = useForm<FnrsCnsltnRegistFormData>({
|
||||
resolver: zodResolver(fnrsCnsltnRegistSchema),
|
||||
mode: "onChange",
|
||||
defaultValues:
|
||||
isEditMode && initialData
|
||||
? mapDetailToFormData(initialData)
|
||||
: getCreateDefaultValues(userInfo, taskSeComCd),
|
||||
});
|
||||
|
||||
async function onSave(data: FnrsCnsltnRegistFormData) {
|
||||
if (isSubmitting) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
const withTotals = recalculateFnrsSharTotals(data.fnrsSharList, sharItems);
|
||||
const validationMessage = validateBizCstSharBalance(
|
||||
data.bizCstList,
|
||||
withTotals,
|
||||
sharItems,
|
||||
costItems,
|
||||
);
|
||||
if (validationMessage) {
|
||||
alert(validationMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const { taskSeComCd, ...fnrsCnsltnMngRest } = data.fnrsCnsltnMng;
|
||||
|
||||
const payload = {
|
||||
fnrsCnsltnMng: {
|
||||
...fnrsCnsltnMngRest,
|
||||
taskSeComCd: formatTaskSeComCdForApi(taskSeComCd),
|
||||
fnrsCnsltnDocNo: isEditMode ? initialData?.detDto?.fnrsCnsltnDocNo : undefined,
|
||||
prgrsSttsComCd: "SV",
|
||||
},
|
||||
bzScaleList: data.bzScaleList,
|
||||
bizCstList: data.bizCstList,
|
||||
fnrsSharList: toFnrsSharSaveList(withTotals),
|
||||
cklstResultList: data.cklstResultList.map((item) => ({
|
||||
...item,
|
||||
selected: item.selected ?? undefined,
|
||||
})),
|
||||
esntlAtchFileList: data.esntlAtchFileList,
|
||||
};
|
||||
|
||||
const fnrsCnsltnDocNo = await fnrsCnsltnMngService.saveApply(payload);
|
||||
|
||||
alert("저장되었습니다.");
|
||||
router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}`);
|
||||
if (isEditMode && id) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...fnrsCnsltnMngBaseKey, "findById", id],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("저장 실패:", error);
|
||||
alert("저장 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSave)} className="mt-6 space-y-4">
|
||||
<BasicInfoFormSection />
|
||||
<ContactInfoFormSection fieldPrefix="fnrsCnsltnMng" />
|
||||
<PrivacyConsentSection />
|
||||
<BusinessScaleFormSection />
|
||||
<BusinessCostFormSection />
|
||||
<FnrsSharFormSection
|
||||
initialSharTree={isEditMode ? initialData?.fnrsSharList : undefined}
|
||||
initialSharAtchFiles={isEditMode ? initialData?.sharAtchFileList : undefined}
|
||||
/>
|
||||
<ChecklistFormSection docSeComCd="FNRS" fieldPrefix="fnrsCnsltnMng" />
|
||||
<AttachmentsFormSection
|
||||
docSeComCd="FNRS"
|
||||
fieldPrefix="fnrsCnsltnMng"
|
||||
initialEsntlFiles={isEditMode ? initialData?.esntlFileList : undefined}
|
||||
initialEtcFiles={isEditMode ? initialData?.etcFileList : undefined}
|
||||
/>
|
||||
|
||||
<div className="sticky bottom-0 z-40 !-mb-2 border-t bg-white pb-8 pt-6 shadow-[0_-4px_12px_rgba(0,0,0,0.06)]">
|
||||
<div className="mx-4 flex flex-wrap justify-center gap-3 sm:gap-4">
|
||||
<Button
|
||||
color="white"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
router.back();
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
목록
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export { FnrsFormPage };
|
||||
|
||||
@@ -16,6 +16,7 @@ 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 { FnrsSharSection } from "./sections/fnrs-shar-section";
|
||||
import { SupplementDemandSection } from "./sections/supplement-demand-section";
|
||||
import {
|
||||
TechReviewOpinionSection,
|
||||
@@ -64,6 +65,7 @@ function FnrsViewPage({ id }: FnrsViewPageProps) {
|
||||
const etcFileList = data.etcFileList ?? [];
|
||||
const rvwList = data.rvwList ?? [];
|
||||
const splmntDmndList = data.splmntDmndList ?? [];
|
||||
const fnrsSharList = data.fnrsSharList ?? [];
|
||||
|
||||
const editableReviewStep = getEditableReviewStep(
|
||||
userInfo?.dtlInstCd,
|
||||
@@ -76,6 +78,7 @@ function FnrsViewPage({ id }: FnrsViewPageProps) {
|
||||
<ContactInfoSection det={detDto} />
|
||||
<BusinessScaleSection bizSclList={bizSclList} />
|
||||
<BusinessCostSection bizCstList={bizCstList} />
|
||||
<FnrsSharSection fnrsSharList={fnrsSharList} />
|
||||
<ChecklistSection
|
||||
cklstResultList={cklstResultList}
|
||||
prgrsSttsComCd={detDto.prgrsSttsComCd || ""}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { FnrsSharBizCstTreeDto } from "@/entities/fnrs-shar-se";
|
||||
import { fmtNumber, indentPx, SectionCollapsible } from "@/widgets/pbadms-sprt";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
|
||||
|
||||
interface FnrsSharSectionProps {
|
||||
fnrsSharList: FnrsSharBizCstTreeDto[];
|
||||
}
|
||||
|
||||
function FnrsSharSection({ fnrsSharList }: FnrsSharSectionProps) {
|
||||
const sortedList = useMemo(
|
||||
() => [...fnrsSharList].sort((a, b) => a.sortPath.localeCompare(b.sortPath)),
|
||||
[fnrsSharList],
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionCollapsible title="재원분담">
|
||||
<div className="overflow-x-auto">
|
||||
<p className="mb-2 text-right font-normal text-muted-foreground">(단위: 백만원)</p>
|
||||
<Table variant="secondary" className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[24rem]" rowSpan={2}>
|
||||
구분
|
||||
</TableHead>
|
||||
<TableHead rowSpan={2}>현행(A)</TableHead>
|
||||
<TableHead rowSpan={2}>변경요구(B)</TableHead>
|
||||
<TableHead colSpan={2}>증감</TableHead>
|
||||
<TableHead rowSpan={2}>비고</TableHead>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead className="w-[10rem]">(B-A)</TableHead>
|
||||
<TableHead className="w-[6rem]">%</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedList.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-12 text-center text-muted-foreground">
|
||||
조회된 재원분담 항목이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
sortedList.map((node) => {
|
||||
const pl = indentPx(node.indFnrsSharSeCdNm);
|
||||
const showSubIcon = node.treeLevel >= 2;
|
||||
|
||||
return (
|
||||
<TableRow key={node.fnrsSharSeCd}>
|
||||
<TableHead className="text-left" style={{ paddingLeft: `${pl + 8}px` }}>
|
||||
{showSubIcon && <span className="mr-1 text-gray-400">└</span>}
|
||||
{node.fnrsSharSeCdNm}
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
<div className="text-right">{fmtNumber(node.exstnAmt)}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-right">{fmtNumber(node.chgRqtAmt)}</div>
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell>{node.rmrkCn ?? ""}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export { FnrsSharSection };
|
||||
@@ -1,23 +1,34 @@
|
||||
export {
|
||||
atchFileGroupIdPath,
|
||||
createEtcUploadKey,
|
||||
createUploadKey,
|
||||
isEtcFileKey,
|
||||
} from "./lib/attachments-form-utils";
|
||||
export { buildAcntgCostItems, recalculateBizCstTotals } from "./lib/business-cost-form-utils";
|
||||
export { buildCnstpScaleItems, recalculateBzScaleTotals } from "./lib/business-scale-form-utils";
|
||||
export {
|
||||
buildChecklistTabs,
|
||||
orderCklstTreeNodes,
|
||||
syncCklstResultListFromTree,
|
||||
} from "./lib/checklist-form-utils";
|
||||
export { calcDiffPercent, fmtNumber, indentPx } from "./lib/format-utils";
|
||||
export { AttachmentsFormSection } from "./ui/attachments-form-section";
|
||||
export { BusinessCostFormSection } from "./ui/business-cost-form-section";
|
||||
export { BusinessScaleFormSection } from "./ui/business-scale-form-section";
|
||||
export { ChecklistFormSection } from "./ui/checklist-form-section";
|
||||
export { ContactInfoFormSection } from "./ui/contact-info-form-section";
|
||||
export { PrivacyConsentSection } from "./ui/privacy-consent-section";
|
||||
export { SectionCollapsible } from "./ui/section-collapsible";
|
||||
export { TaskSePickerDialog } from "./ui/task-se-picker-dialog";
|
||||
export { TextInputDialog } from "./ui/text-input-dialog";
|
||||
export {
|
||||
atchFileGroupIdPath,
|
||||
createEtcUploadKey,
|
||||
createUploadKey,
|
||||
isEtcFileKey,
|
||||
} from "./lib/attachments-form-utils";
|
||||
export { buildAcntgCostItems, recalculateBizCstTotals } from "./lib/business-cost-form-utils";
|
||||
export { buildCnstpScaleItems, recalculateBzScaleTotals } from "./lib/business-scale-form-utils";
|
||||
export {
|
||||
buildChecklistTabs,
|
||||
orderCklstTreeNodes,
|
||||
syncCklstResultListFromTree,
|
||||
} from "./lib/checklist-form-utils";
|
||||
export type { FnrsSharItemDef, FnrsSharListItem } from "./lib/fnrs-shar-form-utils";
|
||||
export {
|
||||
buildFnrsSharItems,
|
||||
calcBizCstSharRemaining,
|
||||
mapFnrsSharDetToFormList,
|
||||
mergeFnrsSharTreeWithForm,
|
||||
recalculateFnrsSharTotals,
|
||||
toFnrsSharSaveList,
|
||||
validateBizCstSharBalance,
|
||||
} from "./lib/fnrs-shar-form-utils";
|
||||
export { calcDiffPercent, fmtNumber, indentPx } from "./lib/format-utils";
|
||||
export { AttachmentsFormSection } from "./ui/attachments-form-section";
|
||||
export { BusinessCostFormSection } from "./ui/business-cost-form-section";
|
||||
export { BusinessScaleFormSection } from "./ui/business-scale-form-section";
|
||||
export { ChecklistFormSection } from "./ui/checklist-form-section";
|
||||
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 { TaskSePickerDialog } from "./ui/task-se-picker-dialog";
|
||||
export { TextInputDialog } from "./ui/text-input-dialog";
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import type { FnrsSharItem } from "@/entities/fnrs-cnsltn";
|
||||
import type { FnrsSharBizCstTreeDto, FnrsSharSeMngTreeDto } from "@/entities/fnrs-shar-se";
|
||||
|
||||
import type { BizCstListItem, CostItemDef } from "./business-cost-form-utils";
|
||||
|
||||
export interface FnrsSharItemDef {
|
||||
code: string;
|
||||
label: string;
|
||||
level: number;
|
||||
readonly?: boolean;
|
||||
syncFromBizCst?: boolean;
|
||||
children?: string[];
|
||||
}
|
||||
|
||||
export interface FnrsSharListItem {
|
||||
fnrsSharSeCd: string;
|
||||
exstnAmt: number;
|
||||
chgRqtAmt: number;
|
||||
ajmtAmt?: number;
|
||||
rmrkCn?: string;
|
||||
}
|
||||
|
||||
export type FnrsSharTreeNode = FnrsSharSeMngTreeDto | FnrsSharBizCstTreeDto;
|
||||
|
||||
function getParentCode(node: FnrsSharTreeNode): string | undefined {
|
||||
if ("upFnrsSharSeCd" in node && node.upFnrsSharSeCd) {
|
||||
return node.upFnrsSharSeCd;
|
||||
}
|
||||
|
||||
const parts = node.treePath.split("|");
|
||||
if (parts.length <= 1) return undefined;
|
||||
return parts[parts.length - 2];
|
||||
}
|
||||
|
||||
function sortTreeNodes<T extends { sortPath: string }>(nodes: T[]): T[] {
|
||||
return [...nodes].sort((a, b) => a.sortPath.localeCompare(b.sortPath));
|
||||
}
|
||||
|
||||
export function buildFnrsSharItems(tree: FnrsSharTreeNode[]): FnrsSharItemDef[] {
|
||||
if (tree.length === 0) return [];
|
||||
|
||||
const activeNodes = sortTreeNodes(tree.filter((node) => node.useYn === "Y"));
|
||||
const childrenMap = new Map<string, string[]>();
|
||||
|
||||
activeNodes.forEach((node) => {
|
||||
const parentCode = getParentCode(node);
|
||||
if (!parentCode) return;
|
||||
|
||||
const siblings = childrenMap.get(parentCode) ?? [];
|
||||
siblings.push(node.fnrsSharSeCd);
|
||||
childrenMap.set(parentCode, siblings);
|
||||
});
|
||||
|
||||
const levelOneNodes = activeNodes.filter((node) => node.treeLevel === 1);
|
||||
const syncFromBizCstCode = sortTreeNodes(levelOneNodes)[0]?.fnrsSharSeCd;
|
||||
|
||||
return activeNodes.map((node) => {
|
||||
const children = childrenMap.get(node.fnrsSharSeCd);
|
||||
return {
|
||||
code: node.fnrsSharSeCd,
|
||||
label: node.fnrsSharSeCdNm,
|
||||
level: node.treeLevel,
|
||||
readonly: Boolean(children && children.length > 0),
|
||||
syncFromBizCst: node.fnrsSharSeCd === syncFromBizCstCode,
|
||||
children,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function sumChildSharAmounts(list: FnrsSharListItem[], childCodes: string[]) {
|
||||
let exstnAmt = 0;
|
||||
let chgRqtAmt = 0;
|
||||
|
||||
childCodes.forEach((childCode) => {
|
||||
const child = list.find((item) => item.fnrsSharSeCd === childCode);
|
||||
if (!child) return;
|
||||
exstnAmt += child.exstnAmt || 0;
|
||||
chgRqtAmt += child.chgRqtAmt || 0;
|
||||
});
|
||||
|
||||
return { exstnAmt, chgRqtAmt };
|
||||
}
|
||||
|
||||
export function recalculateFnrsSharTotals(
|
||||
list: FnrsSharListItem[],
|
||||
items: FnrsSharItemDef[],
|
||||
): FnrsSharListItem[] {
|
||||
const newList = list.map((item) => ({ ...item }));
|
||||
let hasChanges = false;
|
||||
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
const item = items[i];
|
||||
if (!item.readonly || !item.children) continue;
|
||||
|
||||
const idx = newList.findIndex((row) => row.fnrsSharSeCd === item.code);
|
||||
if (idx < 0) continue;
|
||||
|
||||
const { exstnAmt, chgRqtAmt } = sumChildSharAmounts(newList, item.children);
|
||||
const current = newList[idx];
|
||||
|
||||
if ((current.exstnAmt || 0) !== exstnAmt || (current.chgRqtAmt || 0) !== chgRqtAmt) {
|
||||
newList[idx] = {
|
||||
...current,
|
||||
exstnAmt,
|
||||
chgRqtAmt,
|
||||
ajmtAmt: exstnAmt - chgRqtAmt,
|
||||
};
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasChanges ? newList : list;
|
||||
}
|
||||
|
||||
function findBizCstSyncItem(costItems: CostItemDef[]): CostItemDef | undefined {
|
||||
const levelOneReadonly = costItems.filter((item) => item.level === 1 && item.readonly);
|
||||
return levelOneReadonly[0];
|
||||
}
|
||||
|
||||
export interface BizCstTotalAmounts {
|
||||
bscPlanAmt: number;
|
||||
tstmDesignAmt: number;
|
||||
}
|
||||
|
||||
export interface BizCstSharRemaining {
|
||||
exstnRemaining: number;
|
||||
chgRqtRemaining: number;
|
||||
}
|
||||
|
||||
export function getBizCstTotalRow(
|
||||
bizCstList: BizCstListItem[],
|
||||
costItems: CostItemDef[],
|
||||
): BizCstTotalAmounts {
|
||||
const syncCostItem = findBizCstSyncItem(costItems);
|
||||
if (!syncCostItem) return { bscPlanAmt: 0, tstmDesignAmt: 0 };
|
||||
|
||||
const costRow = bizCstList.find((item) => item.acntgSeCd === syncCostItem.code);
|
||||
if (!costRow) return { bscPlanAmt: 0, tstmDesignAmt: 0 };
|
||||
|
||||
return {
|
||||
bscPlanAmt: costRow.bscPlanAmt || 0,
|
||||
tstmDesignAmt: costRow.tstmDesignAmt || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function sumFnrsSharLeafAmounts(
|
||||
sharList: FnrsSharListItem[],
|
||||
sharItems: FnrsSharItemDef[],
|
||||
): { exstnAmt: number; chgRqtAmt: number } {
|
||||
const leafItems = sharItems.filter((item) => !item.readonly);
|
||||
let exstnAmt = 0;
|
||||
let chgRqtAmt = 0;
|
||||
|
||||
leafItems.forEach((item) => {
|
||||
const row = sharList.find((entry) => entry.fnrsSharSeCd === item.code);
|
||||
if (!row) return;
|
||||
exstnAmt += row.exstnAmt || 0;
|
||||
chgRqtAmt += row.chgRqtAmt || 0;
|
||||
});
|
||||
|
||||
return { exstnAmt, chgRqtAmt };
|
||||
}
|
||||
|
||||
export function calcBizCstSharRemaining(
|
||||
bizCstList: BizCstListItem[],
|
||||
sharList: FnrsSharListItem[],
|
||||
sharItems: FnrsSharItemDef[],
|
||||
costItems: CostItemDef[],
|
||||
): BizCstSharRemaining {
|
||||
const bizCstTotal = getBizCstTotalRow(bizCstList, costItems);
|
||||
const leafSum = sumFnrsSharLeafAmounts(sharList, sharItems);
|
||||
|
||||
return {
|
||||
exstnRemaining: bizCstTotal.bscPlanAmt - leafSum.exstnAmt,
|
||||
chgRqtRemaining: bizCstTotal.tstmDesignAmt - leafSum.chgRqtAmt,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateBizCstSharBalance(
|
||||
bizCstList: BizCstListItem[],
|
||||
sharList: FnrsSharListItem[],
|
||||
sharItems: FnrsSharItemDef[],
|
||||
costItems: CostItemDef[],
|
||||
): string | null {
|
||||
const { exstnRemaining, chgRqtRemaining } = calcBizCstSharRemaining(
|
||||
bizCstList,
|
||||
sharList,
|
||||
sharItems,
|
||||
costItems,
|
||||
);
|
||||
|
||||
if (exstnRemaining !== 0 || chgRqtRemaining !== 0) {
|
||||
return "총사업비를 확인해주세요.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function toFnrsSharSaveList(list: FnrsSharListItem[]): FnrsSharItem[] {
|
||||
return list.map((row) => ({
|
||||
fnrsSharSeCd: row.fnrsSharSeCd,
|
||||
exstnAmt: row.exstnAmt || 0,
|
||||
chgRqtAmt: row.chgRqtAmt || 0,
|
||||
ajmtAmt: (row.exstnAmt || 0) - (row.chgRqtAmt || 0),
|
||||
rmrkCn: row.rmrkCn,
|
||||
}));
|
||||
}
|
||||
|
||||
export function mergeFnrsSharTreeWithForm(
|
||||
tree: FnrsSharTreeNode[],
|
||||
existing: FnrsSharListItem[],
|
||||
): FnrsSharListItem[] {
|
||||
const items = buildFnrsSharItems(tree);
|
||||
const existingMap = new Map(existing.map((row) => [row.fnrsSharSeCd, row]));
|
||||
|
||||
return items.map((item) => {
|
||||
const saved = existingMap.get(item.code);
|
||||
if (saved) return saved;
|
||||
|
||||
return {
|
||||
fnrsSharSeCd: item.code,
|
||||
exstnAmt: 0,
|
||||
chgRqtAmt: 0,
|
||||
ajmtAmt: 0,
|
||||
rmrkCn: "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function mapFnrsSharDetToFormList(tree: FnrsSharBizCstTreeDto[]): FnrsSharListItem[] {
|
||||
const items = buildFnrsSharItems(tree);
|
||||
|
||||
return items.map((item) => {
|
||||
const node = tree.find((row) => row.fnrsSharSeCd === item.code);
|
||||
const exstnAmt = Number(node?.exstnAmt) || 0;
|
||||
const chgRqtAmt = Number(node?.chgRqtAmt) || 0;
|
||||
|
||||
return {
|
||||
fnrsSharSeCd: item.code,
|
||||
exstnAmt,
|
||||
chgRqtAmt,
|
||||
ajmtAmt: exstnAmt - chgRqtAmt,
|
||||
rmrkCn: node?.rmrkCn ?? "",
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import {
|
||||
cn,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import type { CostItemDef } from "../lib/business-cost-form-utils";
|
||||
import {
|
||||
calcBizCstSharRemaining,
|
||||
type FnrsSharItemDef,
|
||||
type FnrsSharListItem,
|
||||
} from "../lib/fnrs-shar-form-utils";
|
||||
|
||||
interface BizCstTotalFormData {
|
||||
bizCstList: Array<{
|
||||
acntgSeCd: string;
|
||||
bscPlanAmt: number;
|
||||
bgtRfltAmt: number;
|
||||
tstmDesignAmt: number;
|
||||
}>;
|
||||
fnrsSharList: FnrsSharListItem[];
|
||||
}
|
||||
|
||||
interface BizCstTotalDisplayTableProps {
|
||||
costItems: CostItemDef[];
|
||||
sharItems: FnrsSharItemDef[];
|
||||
}
|
||||
|
||||
function formatRemaining(value: number) {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
function BizCstTotalDisplayTable({ costItems, sharItems }: BizCstTotalDisplayTableProps) {
|
||||
const { control } = useFormContext<BizCstTotalFormData>();
|
||||
const bizCstList = useWatch({ control, name: "bizCstList" }) ?? [];
|
||||
const fnrsSharList = useWatch({ control, name: "fnrsSharList" }) ?? [];
|
||||
|
||||
const remaining = useMemo(
|
||||
() => calcBizCstSharRemaining(bizCstList, fnrsSharList, sharItems, costItems),
|
||||
[bizCstList, fnrsSharList, sharItems, costItems],
|
||||
);
|
||||
|
||||
const hasExstnMismatch = remaining.exstnRemaining !== 0;
|
||||
const hasChgRqtMismatch = remaining.chgRqtRemaining !== 0;
|
||||
|
||||
return (
|
||||
<div className="mt-2 overflow-x-auto px-2">
|
||||
<div className="mb-2 flex w-1/2 items-center justify-between">
|
||||
<h3 className="text-xl font-semibold">세부별 총사업비(①) - 재원분담 총사업비(②)</h3>
|
||||
<span className="font-normal text-muted-foreground">(단위: 백만원)</span>
|
||||
</div>
|
||||
<Table variant="secondary" className="w-1/2 table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>① 예산반영(A) - ② 현행(A)</TableHead>
|
||||
<TableHead>① 금회설계시(C) - ② 변경요구(B)</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className={cn("text-right", hasExstnMismatch ? "text-destructive" : "")}>
|
||||
{formatRemaining(remaining.exstnRemaining)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className={cn("text-right", hasChgRqtMismatch ? "text-destructive" : "")}>
|
||||
{formatRemaining(remaining.chgRqtRemaining)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { BizCstTotalDisplayTable };
|
||||
@@ -71,7 +71,7 @@ function BusinessCostFormSection() {
|
||||
|
||||
if (costItems.length === 0) {
|
||||
return (
|
||||
<SectionCollapsible title="총사업비">
|
||||
<SectionCollapsible title="세부별 총사업비">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-[8rem] rounded-xl" />
|
||||
<Skeleton className="h-[64rem] rounded-xl" />
|
||||
@@ -81,7 +81,7 @@ function BusinessCostFormSection() {
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCollapsible title="총사업비">
|
||||
<SectionCollapsible title="세부별 총사업비">
|
||||
<div className="overflow-x-auto">
|
||||
<p className="mb-2 text-right font-normal text-muted-foreground">(단위: 백만원)</p>
|
||||
<Table variant="secondary" className="table-fixed">
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
|
||||
import { getDefaultFileKey, useTus } from "@/shared/lib";
|
||||
import { Field } from "@/shared/ui/field";
|
||||
import { useAcntgSeMngTree } from "@/entities/acntg";
|
||||
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,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@common/components";
|
||||
|
||||
import { buildAcntgCostItems } from "../lib/business-cost-form-utils";
|
||||
import {
|
||||
buildFnrsSharItems,
|
||||
type FnrsSharListItem,
|
||||
type FnrsSharTreeNode,
|
||||
mergeFnrsSharTreeWithForm,
|
||||
recalculateFnrsSharTotals,
|
||||
} from "../lib/fnrs-shar-form-utils";
|
||||
import { calcDiffPercent } from "../lib/format-utils";
|
||||
|
||||
import { BizCstTotalDisplayTable } from "./biz-cst-total-display-table";
|
||||
import { SectionCollapsible } from "./section-collapsible";
|
||||
|
||||
const SHAR_FILE_KEY_PREFIX = "fnrs-shar::";
|
||||
|
||||
interface FnrsSharFormData {
|
||||
fnrsSharList: FnrsSharListItem[];
|
||||
fnrsCnsltnMng: {
|
||||
sharAtchFileGroupId?: string;
|
||||
};
|
||||
bizCstList: Array<{
|
||||
acntgSeCd: string;
|
||||
bscPlanAmt: number;
|
||||
bgtRfltAmt: number;
|
||||
tstmDesignAmt: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
type SharFieldKey = "exstnAmt" | "chgRqtAmt" | "rmrkCn";
|
||||
|
||||
interface FnrsSharFormSectionProps {
|
||||
initialSharTree?: FnrsSharBizCstTreeDto[];
|
||||
initialSharAtchFiles?: AtflMngClientResponseDto[];
|
||||
}
|
||||
|
||||
function createSharUploadKey(file: File) {
|
||||
return `${SHAR_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
|
||||
}
|
||||
|
||||
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 { data: masterTree = [], isLoading: isMasterLoading } = useFnrsSharSeMngTree();
|
||||
const { data: acntgTree = [] } = useAcntgSeMngTree();
|
||||
|
||||
const sharTree: FnrsSharTreeNode[] = initialSharTree?.length ? initialSharTree : masterTree;
|
||||
const sharItems = useMemo(() => buildFnrsSharItems(sharTree), [sharTree]);
|
||||
const costItems = useMemo(() => buildAcntgCostItems(acntgTree), [acntgTree]);
|
||||
const isLoading = initialSharTree?.length ? false : isMasterLoading;
|
||||
|
||||
const { uploadFileAsync, isUploading } = useTus({
|
||||
onSuccess: (atflCd) => {
|
||||
if (!atflCd) return;
|
||||
setValue("fnrsCnsltnMng.sharAtchFileGroupId", atflCd, { shouldDirty: true });
|
||||
},
|
||||
});
|
||||
const isSharUploading = Object.values(isUploading).some(Boolean);
|
||||
|
||||
useEffect(() => {
|
||||
if (sharItems.length === 0) return;
|
||||
|
||||
const existing = getValues("fnrsSharList");
|
||||
if (initialSharTree?.length && existing.length > 0) return;
|
||||
const merged =
|
||||
existing.length > 0
|
||||
? mergeFnrsSharTreeWithForm(sharTree, existing)
|
||||
: mergeFnrsSharTreeWithForm(sharTree, []);
|
||||
|
||||
const withTotals = recalculateFnrsSharTotals(merged, sharItems);
|
||||
setValue("fnrsSharList", withTotals);
|
||||
}, [sharItems, sharTree, initialSharTree, getValues, setValue]);
|
||||
|
||||
function applySharList(nextList: FnrsSharListItem[]) {
|
||||
const withTotals = recalculateFnrsSharTotals(nextList, sharItems);
|
||||
setValue("fnrsSharList", withTotals, { shouldDirty: true });
|
||||
return withTotals;
|
||||
}
|
||||
|
||||
function handleSharChange(index: number, fieldKey: SharFieldKey, value: string) {
|
||||
const draft = [...getValues("fnrsSharList")];
|
||||
const row = draft[index];
|
||||
if (!row) return;
|
||||
|
||||
if (fieldKey === "rmrkCn") {
|
||||
draft[index] = { ...row, rmrkCn: value };
|
||||
} else {
|
||||
const numValue = Number(value) || 0;
|
||||
draft[index] = { ...row, [fieldKey]: numValue };
|
||||
}
|
||||
|
||||
applySharList(draft);
|
||||
}
|
||||
|
||||
async function handleSharFileChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const fileKey = createSharUploadKey(file);
|
||||
setUploadedFileName(file.name);
|
||||
await uploadFileAsync(file, { fileKey });
|
||||
event.target.value = "";
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SectionCollapsible title="재원분담">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-[8rem] rounded-xl" />
|
||||
<Skeleton className="h-[32rem] rounded-xl" />
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCollapsible title="재원분담">
|
||||
<BizCstTotalDisplayTable costItems={costItems} sharItems={sharItems} />
|
||||
|
||||
<div className="mt-6 overflow-x-auto px-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-xl font-semibold">재원분담</h3>
|
||||
<span className="font-normal text-muted-foreground">(단위: 백만원)</span>
|
||||
</div>
|
||||
<Table variant="secondary" className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[24rem]" rowSpan={2}>
|
||||
구분
|
||||
</TableHead>
|
||||
<TableHead rowSpan={2}>현행(A)</TableHead>
|
||||
<TableHead rowSpan={2}>변경요구(B)</TableHead>
|
||||
<TableHead colSpan={2}>증감</TableHead>
|
||||
<TableHead rowSpan={2}>비고</TableHead>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead className="w-[10rem]">(B-A)</TableHead>
|
||||
<TableHead className="w-[6rem]">%</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sharItems.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-12 text-center text-muted-foreground">
|
||||
조회된 재원분담 항목이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
sharItems.map((item, idx) => {
|
||||
const data = fnrsSharList[idx];
|
||||
if (!data) return null;
|
||||
|
||||
const paddingLeft = `${item.level * 1.6}rem`;
|
||||
const showSubIcon = item.level >= 2;
|
||||
const diff = (data.chgRqtAmt || 0) - (data.exstnAmt || 0);
|
||||
|
||||
return (
|
||||
<TableRow key={item.code}>
|
||||
<TableHead className="text-left" style={{ paddingLeft }}>
|
||||
{showSubIcon && <span className="mr-1 text-gray-400">└</span>}
|
||||
{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>
|
||||
)}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-right">
|
||||
{diff > 0 ? `+${diff.toLocaleString()}` : diff.toLocaleString()}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</SectionCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export { FnrsSharFormSection };
|
||||
Reference in New Issue
Block a user