Files
frontend/apps/as/src/widgets/pbadms-sprt/ui/business-cost-form-section.tsx
T
2026-10-06 14:32:26 +09:00

185 lines
6.4 KiB
TypeScript

"use client";
import { useEffect, useMemo } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import { NON_NEGATIVE_INPUT_PROPS, sanitizeNumberInput } from "@/shared/lib";
import { SectionCollapsible } from "@/shared/ui";
import { Field } from "@/shared/ui/field";
import { useAcntgSeMngTree } from "@/entities/acntg";
import {
Input,
Skeleton,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import {
buildAcntgCostItems,
COST_DECIMAL_PLACES,
recalculateBizCstTotals,
} from "../lib/business-cost-form-utils";
import { calcDiffPercent, roundAmount, truncateDecimals } from "../lib/format-utils";
interface BusinessCostFormData {
bizCstList: Array<{
acntgSeCd: string;
bscPlanAmt: number;
bgtRfltAmt: number;
tstmDesignAmt: number;
}>;
}
type CostFieldKey = "bscPlanAmt" | "bgtRfltAmt" | "tstmDesignAmt";
/** 컬럼 순서: 예산반영(A), 표준사업비(B), 금회설계시(C) */
const COST_FIELDS: CostFieldKey[] = ["bgtRfltAmt", "bscPlanAmt", "tstmDesignAmt"];
const COST_STEP = 0.001;
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" }) ?? [];
const { data: acntgTree = [] } = useAcntgSeMngTree();
const costItems = useMemo(() => buildAcntgCostItems(acntgTree), [acntgTree]);
useEffect(() => {
if (costItems.length === 0) return;
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;
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) {
const numValue = Number(truncateDecimals(sanitizeNumberInput(value), COST_DECIMAL_PLACES)) || 0;
const draft = [...getValues("bizCstList")];
draft[index] = { ...draft[index], [fieldKey]: numValue };
const next = recalculateBizCstTotals(draft, costItems);
setValue("bizCstList", next, { shouldDirty: true });
}
if (costItems.length === 0) {
return (
<SectionCollapsible title="세부별 총사업비">
<div className="space-y-2">
<Skeleton className="h-[8rem] rounded-xl" />
<Skeleton className="h-[64rem] rounded-xl" />
</div>
</SectionCollapsible>
);
}
return (
<SectionCollapsible title="세부별 총사업비">
<div className="overflow-x-auto">
<p className="mb-2 text-right font-normal text-muted-foreground">(단위: 백만원)</p>
<Table caption="세부별 총사업비 입력: 구분, 예산반영, 표준사업비, 금회설계, 증감 정보 제공" variant="secondary" className="table-fixed">
<colgroup>
<col width="20%" />
<col width="20%" />
<col width="20%" />
<col width="20%" />
<col width="10%" />
<col width="10%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="w-[30rem]" rowSpan={2}>
구분
</TableHead>
<TableHead rowSpan={2}>예산반영(A)</TableHead>
<TableHead rowSpan={2}>표준사업비(B)</TableHead>
<TableHead rowSpan={2}>금회설계시(C)</TableHead>
<TableHead colSpan={2}>증감</TableHead>
</TableRow>
<TableRow>
<TableHead className="w-[12rem]">(C-A)</TableHead>
<TableHead className="w-[6rem]">%</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{costItems.map((item, idx) => {
const data = bizCstList[idx];
if (!data) return null;
const diff = roundAmount(
(data.tstmDesignAmt || 0) - (data.bgtRfltAmt || 0),
COST_DECIMAL_PLACES,
);
const paddingLeft = `${item.level * 1.6}rem`;
const showSubIcon = item.level >= 2;
return (
<TableRow key={item.code}>
<TableHead scope="row" className="text-left" style={{ paddingLeft }}>
<div className="relative">
{showSubIcon && (
<span className="absolute -left-6 top-0 text-gray-400">└</span>
)}
{item.label}
</div>
</TableHead>
{COST_FIELDS.map((fieldKey) => (
<TableCell key={fieldKey}>
<Field>
<Input
{...NON_NEGATIVE_INPUT_PROPS}
type="number"
step={COST_STEP}
value={data[fieldKey] ?? ""}
onChange={(e) => handleCostChange(idx, fieldKey, e.target.value)}
readOnly={item.readonly}
className={`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.bgtRfltAmt || 0)}</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</SectionCollapsible>
);
}
export { BusinessCostFormSection };