185 lines
6.4 KiB
TypeScript
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 };
|