[as] refactor: 재원협의 폼 useWatch + Controller 방식 제거

This commit is contained in:
2026-09-22 10:57:41 +09:00
parent 87fbf15e2b
commit 0dd2111cb9
5 changed files with 243 additions and 309 deletions
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useFormContext } from "react-hook-form";
import { useQueryClient } from "@tanstack/react-query";
import { Download } from "lucide-react";
@@ -78,7 +78,7 @@ function AttachmentsFormSection({
initialEsntlFiles,
initialEtcFiles,
}: AttachmentsFormSectionProps) {
const { control, getValues, setValue } = useFormContext<AttachmentsFormData>();
const { getValues, setValue } = useFormContext<AttachmentsFormData>();
const queryClient = useQueryClient();
const [uploadedFiles, setUploadedFiles] = useState<Record<string, UploadedFileItem[]>>({});
const [etcFiles, setEtcFiles] = useState<EtcFileItem[]>([]);
@@ -373,89 +373,83 @@ function AttachmentsFormSection({
return (
<SectionCollapsible title="첨부파일">
<div className="space-y-6">
<Controller
name="esntlAtchFileList"
control={control}
render={() => (
<div className="px-2">
<h3 className="mb-4 text-xl font-semibold">필수 첨부파일</h3>
<Table variant="secondary" className="table-fixed">
<colgroup>
<col width="44%" />
<col width="12%" />
<col width="44%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="w-[30rem]" align="left">
파일명
</TableHead>
<TableHead>파일 확장자</TableHead>
<TableHead>파일첨부</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{essentialFiles.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="py-12 text-center text-muted-foreground">
조회된 필수 첨부파일 항목이 없습니다.
<div className="px-2">
<h3 className="mb-4 text-xl font-semibold">필수 첨부파일</h3>
<Table variant="secondary" className="table-fixed">
<colgroup>
<col width="44%" />
<col width="12%" />
<col width="44%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="w-[30rem]" align="left">
파일명
</TableHead>
<TableHead>파일 확장자</TableHead>
<TableHead>파일첨부</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{essentialFiles.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="py-12 text-center text-muted-foreground">
조회된 필수 첨부파일 항목이 없습니다.
</TableCell>
</TableRow>
) : (
essentialFiles.map((file) => {
const atflCd = file.atchFileGroupId ?? "";
return (
<TableRow key={file.esntlAtchFileId}>
<TableHead
className="group break-keep font-medium"
data-required={file.esntlAtchYn === "Y"}
align="left"
>
<div className="inline-flex w-[calc(100%-1rem)] items-center justify-between gap-10">
{file.esntlAtchFileNm}
{atflCd ? (
<Button
size="sm"
color="white"
onClick={() => downloadTemplate(atflCd)}
>
<Download className="!size-5 shrink-0" />
양식내려받기
</Button>
) : null}
</div>
</TableHead>
<TableCell align="center">{file.esntlAtchFileRmrk}</TableCell>
<TableCell className="space-y-2">
<AttachmentFileField
files={(uploadedFiles[file.esntlAtchFileId] ?? []).map((item) => ({
fileKey: item.fileKey,
fileName: item.file?.name ?? item.fileName,
onDownload: item.atflId
? () => downloadByAtflId(item.atflId!)
: undefined,
}))}
inputId={`${file.esntlAtchFileId}-file-input`}
isUploadingByKey={isUploading}
accept={toFileAccept(file.esntlAtchFileRmrk) || undefined}
onAdd={(fileList) =>
void handleEssentialFileUpload(file.esntlAtchFileId, fileList)
}
onRemove={(index) =>
void removeEssentialFile(file.esntlAtchFileId, index)
}
/>
</TableCell>
</TableRow>
) : (
essentialFiles.map((file) => {
const atflCd = file.atchFileGroupId ?? "";
return (
<TableRow key={file.esntlAtchFileId}>
<TableHead
className="group break-keep font-medium"
data-required={file.esntlAtchYn === "Y"}
align="left"
>
<div className="inline-flex w-[calc(100%-1rem)] items-center justify-between gap-10">
{file.esntlAtchFileNm}
{atflCd ? (
<Button
size="sm"
color="white"
onClick={() => downloadTemplate(atflCd)}
>
<Download className="!size-5 shrink-0" />
양식내려받기
</Button>
) : null}
</div>
</TableHead>
<TableCell align="center">{file.esntlAtchFileRmrk}</TableCell>
<TableCell className="space-y-2">
<AttachmentFileField
files={(uploadedFiles[file.esntlAtchFileId] ?? []).map((item) => ({
fileKey: item.fileKey,
fileName: item.file?.name ?? item.fileName,
onDownload: item.atflId
? () => downloadByAtflId(item.atflId!)
: undefined,
}))}
inputId={`${file.esntlAtchFileId}-file-input`}
isUploadingByKey={isUploading}
accept={toFileAccept(file.esntlAtchFileRmrk) || undefined}
onAdd={(fileList) =>
void handleEssentialFileUpload(file.esntlAtchFileId, fileList)
}
onRemove={(index) =>
void removeEssentialFile(file.esntlAtchFileId, index)
}
/>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
)}
/>
);
})
)}
</TableBody>
</Table>
</div>
<div className="px-2">
<div className="mb-4 flex items-center justify-between">
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useFormContext, useWatch } from "react-hook-form";
import { SectionCollapsible } from "@/shared/ui";
import { Field } from "@/shared/ui/field";
@@ -31,6 +31,16 @@ interface BusinessCostFormData {
type CostFieldKey = "bscPlanAmt" | "bgtRfltAmt" | "tstmDesignAmt";
function isSameBizCstCodes(
current: BusinessCostFormData["bizCstList"],
next: BusinessCostFormData["bizCstList"],
) {
return (
current.length === next.length &&
current.every((item, index) => item.acntgSeCd === next[index]?.acntgSeCd)
);
}
function BusinessCostFormSection() {
const { control, setValue, getValues } = useFormContext<BusinessCostFormData>();
const bizCstList = useWatch({ control, name: "bizCstList" }) ?? [];
@@ -42,22 +52,22 @@ function BusinessCostFormSection() {
useEffect(() => {
if (costItems.length === 0) return;
const existingMap = new Map(getValues("bizCstList").map((item) => [item.acntgSeCd, item]));
const current = getValues("bizCstList") ?? [];
const existingMap = new Map(current.map((item) => [item.acntgSeCd, item]));
const next = costItems.map((item) => {
const existing = existingMap.get(item.code);
if (existing) return existing;
setValue(
"bizCstList",
costItems.map((item) => {
const existing = existingMap.get(item.code);
if (existing) return existing;
return {
acntgSeCd: item.code,
bscPlanAmt: 0,
bgtRfltAmt: 0,
tstmDesignAmt: 0,
};
});
return {
acntgSeCd: item.code,
bscPlanAmt: 0,
bgtRfltAmt: 0,
tstmDesignAmt: 0,
};
}),
);
if (isSameBizCstCodes(current, next)) return;
setValue("bizCstList", next);
}, [costItems, getValues, setValue]);
function handleCostChange(index: number, fieldKey: CostFieldKey, value: string) {
@@ -119,58 +129,37 @@ function BusinessCostFormSection() {
</div>
</TableHead>
<TableCell>
<Controller
name={`bizCstList.${idx}.bgtRfltAmt`}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
type="number"
value={field.value || ""}
onChange={(e) => handleCostChange(idx, "bgtRfltAmt", e.target.value)}
readOnly={item.readonly}
aria-invalid={fieldState.invalid}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
)}
/>
<Field>
<Input
type="number"
value={data.bgtRfltAmt ?? ""}
onChange={(e) => handleCostChange(idx, "bgtRfltAmt", e.target.value)}
readOnly={item.readonly}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
</TableCell>
<TableCell>
<Controller
name={`bizCstList.${idx}.bscPlanAmt`}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
type="number"
value={field.value || ""}
onChange={(e) => handleCostChange(idx, "bscPlanAmt", e.target.value)}
readOnly={item.readonly}
aria-invalid={fieldState.invalid}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
)}
/>
<Field>
<Input
type="number"
value={data.bscPlanAmt ?? ""}
onChange={(e) => handleCostChange(idx, "bscPlanAmt", e.target.value)}
readOnly={item.readonly}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
</TableCell>
<TableCell>
<Controller
name={`bizCstList.${idx}.tstmDesignAmt`}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
type="number"
value={field.value || ""}
onChange={(e) => handleCostChange(idx, "tstmDesignAmt", e.target.value)}
readOnly={item.readonly}
aria-invalid={fieldState.invalid}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
)}
/>
<Field>
<Input
type="number"
value={data.tstmDesignAmt ?? ""}
onChange={(e) => handleCostChange(idx, "tstmDesignAmt", e.target.value)}
readOnly={item.readonly}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
</TableCell>
<TableCell>
<div className="text-right">
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useFormContext, useWatch } from "react-hook-form";
import { SectionCollapsible } from "@/shared/ui";
import { Field } from "@/shared/ui/field";
@@ -17,7 +17,7 @@ import {
TableRow,
} from "@common/components";
import { buildCnstpScaleItems, recalculateBzScaleTotals } from "../lib/business-scale-form-utils";
import { buildCnstpScaleItems, recalculateBzScaleTotals } from "../lib/business-scale-form-utils";
interface BusinessScaleFormData {
bzScaleList: Array<{
@@ -31,6 +31,16 @@ interface BusinessScaleFormData {
type ScaleFieldKey = "bscPl" | "budgetReflct" | "thtiDsgn";
function isSameBzScaleCodes(
current: BusinessScaleFormData["bzScaleList"],
next: BusinessScaleFormData["bzScaleList"],
) {
return (
current.length === next.length &&
current.every((item, index) => item.cnstpSeCd === next[index]?.cnstpSeCd)
);
}
function BusinessScaleFormSection() {
const { control, setValue, getValues } = useFormContext<BusinessScaleFormData>();
const bzScaleList = useWatch({ control, name: "bzScaleList" }) ?? [];
@@ -42,7 +52,8 @@ function BusinessScaleFormSection() {
useEffect(() => {
if (scaleItems.length === 0) return;
const existingMap = new Map(getValues("bzScaleList").map((item) => [item.cnstpSeCd, item]));
const current = getValues("bzScaleList") ?? [];
const existingMap = new Map(current.map((item) => [item.cnstpSeCd, item]));
const next = scaleItems.map((item) => {
const existing = existingMap.get(item.code);
@@ -57,6 +68,7 @@ function BusinessScaleFormSection() {
};
});
if (isSameBzScaleCodes(current, next)) return;
setValue("bzScaleList", recalculateBzScaleTotals(next, scaleItems));
}, [scaleItems, getValues, setValue]);
@@ -112,58 +124,37 @@ function BusinessScaleFormSection() {
</div>
</TableHead>
<TableCell>
<Controller
name={`bzScaleList.${idx}.bscPl`}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
type="number"
value={field.value || ""}
onChange={(e) => handleScaleChange(idx, "bscPl", e.target.value)}
readOnly={item.readonly}
aria-invalid={fieldState.invalid}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
)}
/>
<Field>
<Input
type="number"
value={data.bscPl ?? ""}
onChange={(e) => handleScaleChange(idx, "bscPl", e.target.value)}
readOnly={item.readonly}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
</TableCell>
<TableCell>
<Controller
name={`bzScaleList.${idx}.budgetReflct`}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
type="number"
value={field.value || ""}
onChange={(e) => handleScaleChange(idx, "budgetReflct", e.target.value)}
readOnly={item.readonly}
aria-invalid={fieldState.invalid}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
)}
/>
<Field>
<Input
type="number"
value={data.budgetReflct ?? ""}
onChange={(e) => handleScaleChange(idx, "budgetReflct", e.target.value)}
readOnly={item.readonly}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
</TableCell>
<TableCell>
<Controller
name={`bzScaleList.${idx}.thtiDsgn`}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<Input
type="number"
value={field.value || ""}
onChange={(e) => handleScaleChange(idx, "thtiDsgn", e.target.value)}
readOnly={item.readonly}
aria-invalid={fieldState.invalid}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
)}
/>
<Field>
<Input
type="number"
value={data.thtiDsgn ?? ""}
onChange={(e) => handleScaleChange(idx, "thtiDsgn", e.target.value)}
readOnly={item.readonly}
className={`text-right ${item.readonly ? "bg-muted" : ""}`}
/>
</Field>
</TableCell>
<TableCell>
<div className="text-right">
@@ -1,18 +1,15 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useFormContext, useWatch } from "react-hook-form";
import { useQuery } from "@tanstack/react-query";
import { SectionCollapsible } from "@/shared/ui";
import { Field, FieldLabel } from "@/shared/ui/field";
import { cklstMngService, type CklstTreeNode } from "@/entities/cklst";
import { useCodeList } from "@/entities/code";
import {
Button,
RadioGroup,
RadioGroupItem,
Skeleton,
Table,
TableBody,
@@ -125,11 +122,21 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
}
}, [checklistTabs, activeTab]);
function handleSelectChange(resultIdx: number, value: "Y" | "N") {
const currentList = getValues("cklstResultList") as CklstResultListItem[];
const row = currentList[resultIdx];
if (!row) return;
const newList = [...currentList];
newList[resultIdx] = { ...row, selected: value };
setValue("cklstResultList", newList, { shouldDirty: true });
}
function handleSelectAll(tabCode: string, value: "Y" | "N") {
const tab = checklistTabs.find((t) => t.code === tabCode);
if (!tab) return;
const currentList = getValues("cklstResultList");
const currentList = getValues("cklstResultList") as CklstResultListItem[];
const newList = [...currentList];
tab.items
@@ -141,7 +148,7 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
}
});
setValue("cklstResultList", newList);
setValue("cklstResultList", newList, { shouldDirty: true });
}
const hasSelectedTaskCodes = Boolean(taskSeComCdArray?.length);
@@ -234,6 +241,8 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
{tab.items.map((item) => {
const resultIdx = cklstRvwSnIndexMap.get(item.cklstRvwSn) ?? -1;
const showRadios = item.bchckUseYn === "Y";
const selected = cklstResultList[resultIdx]?.selected;
const radioName = `check-${item.cklstRvwSn}`;
return (
<TableRow key={item.cklstRvwSn}>
@@ -248,66 +257,30 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
{item.content}
</TableCell>
{showRadios && resultIdx >= 0 ? (
<Controller
name={`cklstResultList.${resultIdx}.selected`}
control={control}
render={({ field, fieldState }) => (
<>
<TableCell className="text-center">
<RadioGroup
name={field.name}
value={field.value === "Y" ? "Y" : undefined}
onValueChange={field.onChange}
className="flex justify-center"
>
<FieldLabel
htmlFor={`check-${item.cklstRvwSn}-Y`}
className="!w-auto justify-center border-none"
>
<Field
orientation="horizontal"
data-invalid={fieldState.invalid}
className="w-auto"
>
<RadioGroupItem
value="Y"
id={`check-${item.cklstRvwSn}-Y`}
aria-invalid={fieldState.invalid}
aria-label="예"
/>
</Field>
</FieldLabel>
</RadioGroup>
</TableCell>
<TableCell className="text-center">
<RadioGroup
name={field.name}
value={field.value === "N" ? "N" : undefined}
onValueChange={field.onChange}
className="flex justify-center"
>
<FieldLabel
htmlFor={`check-${item.cklstRvwSn}-N`}
className="!w-auto justify-center border-none"
>
<Field
orientation="horizontal"
data-invalid={fieldState.invalid}
className="w-auto"
>
<RadioGroupItem
value="N"
id={`check-${item.cklstRvwSn}-N`}
aria-invalid={fieldState.invalid}
aria-label="아니오"
/>
</Field>
</FieldLabel>
</RadioGroup>
</TableCell>
</>
)}
/>
<>
<TableCell className="text-center">
<input
type="radio"
name={radioName}
value="Y"
checked={selected === "Y"}
onChange={() => handleSelectChange(resultIdx, "Y")}
aria-label="예"
className="cursor-pointer"
/>
</TableCell>
<TableCell className="text-center">
<input
type="radio"
name={radioName}
value="N"
checked={selected === "N"}
onChange={() => handleSelectChange(resultIdx, "N")}
aria-label="아니오"
className="cursor-pointer"
/>
</TableCell>
</>
) : null}
</TableRow>
);
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useFormContext, useWatch } from "react-hook-form";
import { getDefaultFileKey, useTus } from "@/shared/lib";
import { AttachmentFileField, SectionCollapsible } from "@/shared/ui";
@@ -78,6 +78,13 @@ function toInitialSharFile(file?: AtflMngClientResponseDto): SharUploadedFile |
};
}
function isSameFnrsSharCodes(current: FnrsSharListItem[], next: FnrsSharListItem[]) {
return (
current.length === next.length &&
current.every((item, index) => item.fnrsSharSeCd === next[index]?.fnrsSharSeCd)
);
}
function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsSharFormSectionProps) {
const { control, setValue, getValues } = useFormContext<FnrsSharFormData>();
const fnrsSharList = useWatch({ control, name: "fnrsSharList" }) ?? [];
@@ -117,6 +124,7 @@ function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsShar
: mergeFnrsSharTreeWithForm(sharTree, []);
const withTotals = recalculateFnrsSharTotals(merged, sharItems);
if (isSameFnrsSharCodes(existing, withTotals)) return;
setValue("fnrsSharList", withTotals);
}, [sharItems, sharTree, initialSharTree, getValues, setValue]);
@@ -226,40 +234,26 @@ function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsShar
{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>
)}
/>
<Field>
<Input
type="number"
value={data.exstnAmt ?? ""}
onChange={(e) => handleSharChange(idx, "exstnAmt", e.target.value)}
readOnly={item.readonly}
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>
)}
/>
<Field>
<Input
type="number"
value={data.chgRqtAmt ?? ""}
onChange={(e) => handleSharChange(idx, "chgRqtAmt", e.target.value)}
readOnly={item.readonly}
className={cn("text-right", item.readonly && "bg-muted")}
/>
</Field>
</TableCell>
<TableCell>
<div className="text-right">
@@ -270,19 +264,12 @@ function FnrsSharFormSection({ initialSharTree, initialSharAtchFiles }: FnrsShar
<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>
)}
/>
<Field>
<Input
value={data.rmrkCn ?? ""}
onChange={(e) => handleSharChange(idx, "rmrkCn", e.target.value)}
/>
</Field>
</TableCell>
</TableRow>
);