From 2f11b1185b3564c4f3e8e5e7809e553784b8e17d Mon Sep 17 00:00:00 2001 From: bicochan Date: Fri, 3 Jul 2026 17:23:18 +0900 Subject: [PATCH] =?UTF-8?q?[as]=20feat:=20=EC=9E=AC=EC=9B=90=ED=98=91?= =?UTF-8?q?=EC=9D=98=20=EC=8B=A0=EC=B2=AD=20=EB=B0=8F=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/as/src/entities/fnrs-cnsltn/index.ts | 1 + .../model/fnrs-cnsltn-interface.ts | 48 ++- .../lib/fnrs-cnsltn-regist-schema.ts | 10 + .../pages/fnrs-cnsltn/lib/form-data-mapper.ts | 7 + .../fnrs-cnsltn/lib/save-payload-utils.ts | 2 + .../pages/fnrs-cnsltn/ui/fnrs-form-page.tsx | 308 ++++++++++-------- .../pages/fnrs-cnsltn/ui/fnrs-view-page.tsx | 3 + .../ui/sections/fnrs-shar-section.tsx | 77 +++++ apps/as/src/widgets/pbadms-sprt/index.ts | 57 ++-- .../pbadms-sprt/lib/fnrs-shar-form-utils.ts | 246 ++++++++++++++ .../ui/biz-cst-total-display-table.tsx | 87 +++++ .../ui/business-cost-form-section.tsx | 4 +- .../pbadms-sprt/ui/fnrs-shar-form-section.tsx | 283 ++++++++++++++++ 13 files changed, 965 insertions(+), 168 deletions(-) create mode 100644 apps/as/src/pages/fnrs-cnsltn/ui/sections/fnrs-shar-section.tsx create mode 100644 apps/as/src/widgets/pbadms-sprt/lib/fnrs-shar-form-utils.ts create mode 100644 apps/as/src/widgets/pbadms-sprt/ui/biz-cst-total-display-table.tsx create mode 100644 apps/as/src/widgets/pbadms-sprt/ui/fnrs-shar-form-section.tsx diff --git a/apps/as/src/entities/fnrs-cnsltn/index.ts b/apps/as/src/entities/fnrs-cnsltn/index.ts index 274b38c23..645065ffe 100644 --- a/apps/as/src/entities/fnrs-cnsltn/index.ts +++ b/apps/as/src/entities/fnrs-cnsltn/index.ts @@ -35,4 +35,5 @@ export type { FnrsCnsltnRvwOpnnSaveRequestDto, FnrsCnsltnSplmntDmndItem, FnrsCnsltnSplmntDmndResponseDto, + FnrsSharItem, } from "./model/fnrs-cnsltn-interface"; diff --git a/apps/as/src/entities/fnrs-cnsltn/model/fnrs-cnsltn-interface.ts b/apps/as/src/entities/fnrs-cnsltn/model/fnrs-cnsltn-interface.ts index 0b1975ccd..d0a43a71b 100644 --- a/apps/as/src/entities/fnrs-cnsltn/model/fnrs-cnsltn-interface.ts +++ b/apps/as/src/entities/fnrs-cnsltn/model/fnrs-cnsltn-interface.ts @@ -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; /** 백엔드 FnrsCnsltnMngSearchDto + Pageable */ -export type FnrsCnsltnMngSearchDto = PageableParams & Partial; +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 메서드명·시그니처와 동일 */ diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/fnrs-cnsltn-regist-schema.ts b/apps/as/src/pages/fnrs-cnsltn/lib/fnrs-cnsltn-regist-schema.ts index 789a56993..332ef9e14 100644 --- a/apps/as/src/pages/fnrs-cnsltn/lib/fnrs-cnsltn-regist-schema.ts +++ b/apps/as/src/pages/fnrs-cnsltn/lib/fnrs-cnsltn-regist-schema.ts @@ -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(), diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/form-data-mapper.ts b/apps/as/src/pages/fnrs-cnsltn/lib/form-data-mapper.ts index e23da5444..807ab30a4 100644 --- a/apps/as/src/pages/fnrs-cnsltn/lib/form-data-mapper.ts +++ b/apps/as/src/pages/fnrs-cnsltn/lib/form-data-mapper.ts @@ -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", diff --git a/apps/as/src/pages/fnrs-cnsltn/lib/save-payload-utils.ts b/apps/as/src/pages/fnrs-cnsltn/lib/save-payload-utils.ts index 43775d839..cd561c762 100644 --- a/apps/as/src/pages/fnrs-cnsltn/lib/save-payload-utils.ts +++ b/apps/as/src/pages/fnrs-cnsltn/lib/save-payload-utils.ts @@ -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 ?? [])), }; } diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx index 625a20c43..43fec9115 100644 --- a/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx +++ b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-form-page.tsx @@ -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({ - 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 ( - -
- - - - - - - - -
-
- - -
-
- -
- ); -} - -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({ + 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 ( + +
+ + + + + + + + + +
+
+ + +
+
+ +
+ ); +} + +export { FnrsFormPage }; diff --git a/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-view-page.tsx b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-view-page.tsx index c55335c85..3e955ee0f 100644 --- a/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-view-page.tsx +++ b/apps/as/src/pages/fnrs-cnsltn/ui/fnrs-view-page.tsx @@ -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) { + [...fnrsSharList].sort((a, b) => a.sortPath.localeCompare(b.sortPath)), + [fnrsSharList], + ); + + return ( + +
+

(단위: 백만원)

+ + + + + 구분 + + 현행(A) + 변경요구(B) + 증감 + 비고 + + + (B-A) + % + + + + {sortedList.length === 0 ? ( + + + 조회된 재원분담 항목이 없습니다. + + + ) : ( + sortedList.map((node) => { + const pl = indentPx(node.indFnrsSharSeCdNm); + const showSubIcon = node.treeLevel >= 2; + + return ( + + + {showSubIcon && └} + {node.fnrsSharSeCdNm} + + +
{fmtNumber(node.exstnAmt)}
+
+ +
{fmtNumber(node.chgRqtAmt)}
+
+ + + {node.rmrkCn ?? ""} +
+ ); + }) + )} +
+
+
+
+ ); +} + +export { FnrsSharSection }; diff --git a/apps/as/src/widgets/pbadms-sprt/index.ts b/apps/as/src/widgets/pbadms-sprt/index.ts index a6c115d81..18508752a 100644 --- a/apps/as/src/widgets/pbadms-sprt/index.ts +++ b/apps/as/src/widgets/pbadms-sprt/index.ts @@ -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"; diff --git a/apps/as/src/widgets/pbadms-sprt/lib/fnrs-shar-form-utils.ts b/apps/as/src/widgets/pbadms-sprt/lib/fnrs-shar-form-utils.ts new file mode 100644 index 000000000..01c73fcea --- /dev/null +++ b/apps/as/src/widgets/pbadms-sprt/lib/fnrs-shar-form-utils.ts @@ -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(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(); + + 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 ?? "", + }; + }); +} diff --git a/apps/as/src/widgets/pbadms-sprt/ui/biz-cst-total-display-table.tsx b/apps/as/src/widgets/pbadms-sprt/ui/biz-cst-total-display-table.tsx new file mode 100644 index 000000000..0e3cbebe5 --- /dev/null +++ b/apps/as/src/widgets/pbadms-sprt/ui/biz-cst-total-display-table.tsx @@ -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(); + 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 ( +
+
+

세부별 총사업비(①) - 재원분담 총사업비(②)

+ (단위: 백만원) +
+ + + + ① 예산반영(A) - ② 현행(A) + ① 금회설계시(C) - ② 변경요구(B) + + + + + +
+ {formatRemaining(remaining.exstnRemaining)} +
+
+ +
+ {formatRemaining(remaining.chgRqtRemaining)} +
+
+
+
+
+
+ ); +} + +export { BizCstTotalDisplayTable }; diff --git a/apps/as/src/widgets/pbadms-sprt/ui/business-cost-form-section.tsx b/apps/as/src/widgets/pbadms-sprt/ui/business-cost-form-section.tsx index e1a44bd4e..51d7a82a1 100644 --- a/apps/as/src/widgets/pbadms-sprt/ui/business-cost-form-section.tsx +++ b/apps/as/src/widgets/pbadms-sprt/ui/business-cost-form-section.tsx @@ -71,7 +71,7 @@ function BusinessCostFormSection() { if (costItems.length === 0) { return ( - +
@@ -81,7 +81,7 @@ function BusinessCostFormSection() { } return ( - +

(단위: 백만원)

diff --git a/apps/as/src/widgets/pbadms-sprt/ui/fnrs-shar-form-section.tsx b/apps/as/src/widgets/pbadms-sprt/ui/fnrs-shar-form-section.tsx new file mode 100644 index 000000000..e8bff5b35 --- /dev/null +++ b/apps/as/src/widgets/pbadms-sprt/ui/fnrs-shar-form-section.tsx @@ -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(); + 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) { + 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 ( + +
+ + +
+
+ ); + } + + return ( + + + +
+
+

재원분담

+ (단위: 백만원) +
+
+ + + + 구분 + + 현행(A) + 변경요구(B) + 증감 + 비고 + + + (B-A) + % + + + + {sharItems.length === 0 ? ( + + + 조회된 재원분담 항목이 없습니다. + + + ) : ( + 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 ( + + + {showSubIcon && └} + {item.label} + + + ( + + handleSharChange(idx, "exstnAmt", e.target.value)} + readOnly={item.readonly} + aria-invalid={fieldState.invalid} + className={cn("text-right", item.readonly && "bg-muted")} + /> + + )} + /> + + + ( + + handleSharChange(idx, "chgRqtAmt", e.target.value)} + readOnly={item.readonly} + aria-invalid={fieldState.invalid} + className={cn("text-right", item.readonly && "bg-muted")} + /> + + )} + /> + + +
+ {diff > 0 ? `+${diff.toLocaleString()}` : diff.toLocaleString()} +
+
+ +
{calcDiffPercent(diff, data.exstnAmt || 0)}
+
+ + ( + + handleSharChange(idx, "rmrkCn", e.target.value)} + aria-invalid={fieldState.invalid} + /> + + )} + /> + +
+ ); + }) + )} +
+
+
+ +
+

첨부파일

+
+ + +
+
+
+ ); +} + +export { FnrsSharFormSection };