[as] feat: 재원협의 신청 및 수정 페이지 작업

This commit is contained in:
2026-07-03 17:23:18 +09:00
parent 716a20cc22
commit 2f11b1185b
13 changed files with 965 additions and 168 deletions
@@ -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 };
+34 -23
View File
@@ -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 };