430 lines
14 KiB
TypeScript
430 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState } from "react";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Plus, X, Calculator } from "lucide-react";
|
||
import { CalculationNode, CalculationStep, AdditionalFieldDefinition } from "./types";
|
||
|
||
interface CalculationBuilderProps {
|
||
steps: CalculationStep[];
|
||
availableFields: AdditionalFieldDefinition[];
|
||
onChange: (steps: CalculationStep[]) => void;
|
||
}
|
||
|
||
export const CalculationBuilder: React.FC<CalculationBuilderProps> = ({
|
||
steps,
|
||
availableFields,
|
||
onChange,
|
||
}) => {
|
||
const [previewValues, setPreviewValues] = useState<Record<string, number>>({});
|
||
|
||
// 새 단계 추가
|
||
const addStep = () => {
|
||
const newStep: CalculationStep = {
|
||
id: `step_${Date.now()}`,
|
||
label: `단계 ${steps.length + 1}`,
|
||
expression: {
|
||
type: "field",
|
||
fieldName: "",
|
||
},
|
||
};
|
||
onChange([...steps, newStep]);
|
||
};
|
||
|
||
// 단계 삭제
|
||
const removeStep = (stepId: string) => {
|
||
onChange(steps.filter((s) => s.id !== stepId));
|
||
};
|
||
|
||
// 단계 업데이트
|
||
const updateStep = (stepId: string, updates: Partial<CalculationStep>) => {
|
||
onChange(
|
||
steps.map((s) => (s.id === stepId ? { ...s, ...updates } : s))
|
||
);
|
||
};
|
||
|
||
// 간단한 표현식 렌더링
|
||
const renderSimpleExpression = (step: CalculationStep) => {
|
||
return (
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
{/* 왼쪽 항 */}
|
||
<Select
|
||
value={step.expression.type === "field" ? step.expression.fieldName || "" : step.expression.type}
|
||
onValueChange={(value) => {
|
||
if (value === "previous") {
|
||
updateStep(step.id, {
|
||
expression: { type: "previous" },
|
||
});
|
||
} else if (value === "constant") {
|
||
updateStep(step.id, {
|
||
expression: { type: "constant", value: 0 },
|
||
});
|
||
} else {
|
||
updateStep(step.id, {
|
||
expression: { type: "field", fieldName: value },
|
||
});
|
||
}
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-8 flex-1 text-xs">
|
||
<SelectValue placeholder="항목 선택" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="previous">이전 결과</SelectItem>
|
||
<SelectItem value="constant">상수값</SelectItem>
|
||
{availableFields.map((field) => (
|
||
<SelectItem key={field.name} value={field.name}>
|
||
{field.label || field.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
|
||
{step.expression.type === "constant" && (
|
||
<Input
|
||
type="number"
|
||
value={step.expression.value || 0}
|
||
onChange={(e) => {
|
||
updateStep(step.id, {
|
||
expression: {
|
||
...step.expression,
|
||
value: parseFloat(e.target.value) || 0,
|
||
},
|
||
});
|
||
}}
|
||
className="h-8 w-24 text-xs"
|
||
placeholder="값"
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* 연산 추가 버튼 */}
|
||
{step.expression.type !== "operation" && (
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => {
|
||
const currentExpression = step.expression;
|
||
updateStep(step.id, {
|
||
expression: {
|
||
type: "operation",
|
||
operator: "+",
|
||
left: currentExpression,
|
||
right: { type: "constant", value: 0 },
|
||
},
|
||
});
|
||
}}
|
||
className="h-7 text-xs"
|
||
>
|
||
<Plus className="h-3 w-3 mr-1" />
|
||
연산 추가
|
||
</Button>
|
||
)}
|
||
|
||
{/* 연산식 */}
|
||
{step.expression.type === "operation" && (
|
||
<div className="space-y-2 border-l-2 border-primary pl-3 ml-2">
|
||
{renderOperationExpression(step)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// 연산식 렌더링
|
||
const renderOperationExpression = (step: CalculationStep) => {
|
||
if (step.expression.type !== "operation") return null;
|
||
|
||
return (
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
{/* 왼쪽 항 */}
|
||
<div className="text-xs text-muted-foreground">
|
||
{renderNodeLabel(step.expression.left)}
|
||
</div>
|
||
|
||
{/* 연산자 */}
|
||
<Select
|
||
value={step.expression.operator || "+"}
|
||
onValueChange={(value) => {
|
||
updateStep(step.id, {
|
||
expression: {
|
||
...step.expression,
|
||
operator: value as any,
|
||
},
|
||
});
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-7 w-16 text-xs">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="+">+</SelectItem>
|
||
<SelectItem value="-">-</SelectItem>
|
||
<SelectItem value="*">×</SelectItem>
|
||
<SelectItem value="/">÷</SelectItem>
|
||
<SelectItem value="%">%</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
|
||
{/* 오른쪽 항 */}
|
||
<Select
|
||
value={
|
||
step.expression.right?.type === "field"
|
||
? step.expression.right.fieldName || ""
|
||
: step.expression.right?.type || ""
|
||
}
|
||
onValueChange={(value) => {
|
||
if (value === "constant") {
|
||
updateStep(step.id, {
|
||
expression: {
|
||
...step.expression,
|
||
right: { type: "constant", value: 0 },
|
||
},
|
||
});
|
||
} else {
|
||
updateStep(step.id, {
|
||
expression: {
|
||
...step.expression,
|
||
right: { type: "field", fieldName: value },
|
||
},
|
||
});
|
||
}
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-7 flex-1 text-xs">
|
||
<SelectValue placeholder="항목 선택" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="constant">상수값</SelectItem>
|
||
{availableFields.map((field) => (
|
||
<SelectItem key={field.name} value={field.name}>
|
||
{field.label || field.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
|
||
{step.expression.right?.type === "constant" && (
|
||
<Input
|
||
type="number"
|
||
value={step.expression.right.value || 0}
|
||
onChange={(e) => {
|
||
updateStep(step.id, {
|
||
expression: {
|
||
...step.expression,
|
||
right: {
|
||
...step.expression.right!,
|
||
value: parseFloat(e.target.value) || 0,
|
||
},
|
||
},
|
||
});
|
||
}}
|
||
className="h-7 w-24 text-xs"
|
||
placeholder="값"
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// 노드 라벨 표시
|
||
const renderNodeLabel = (node?: CalculationNode): string => {
|
||
if (!node) return "";
|
||
|
||
switch (node.type) {
|
||
case "field":
|
||
const field = availableFields.find((f) => f.name === node.fieldName);
|
||
return field?.label || node.fieldName || "필드";
|
||
case "constant":
|
||
return String(node.value || 0);
|
||
case "previous":
|
||
return "이전 결과";
|
||
case "operation":
|
||
const left = renderNodeLabel(node.left);
|
||
const right = renderNodeLabel(node.right);
|
||
const op = node.operator === "*" ? "×" : node.operator === "/" ? "÷" : node.operator;
|
||
return `(${left} ${op} ${right})`;
|
||
default:
|
||
return "";
|
||
}
|
||
};
|
||
|
||
// 함수 적용 UI
|
||
const renderFunctionStep = (step: CalculationStep) => {
|
||
if (step.expression.type !== "function") return null;
|
||
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<Select
|
||
value={step.expression.functionName || "round"}
|
||
onValueChange={(value) => {
|
||
updateStep(step.id, {
|
||
expression: {
|
||
type: "function",
|
||
functionName: value as any,
|
||
params: [{ type: "previous" }],
|
||
},
|
||
});
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-8 w-32 text-xs">
|
||
<SelectValue placeholder="함수 선택" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="round">반올림</SelectItem>
|
||
<SelectItem value="floor">절삭</SelectItem>
|
||
<SelectItem value="ceil">올림</SelectItem>
|
||
<SelectItem value="abs">절댓값</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
|
||
{(step.expression.functionName === "round" ||
|
||
step.expression.functionName === "floor" ||
|
||
step.expression.functionName === "ceil") && (
|
||
<>
|
||
<span className="text-xs text-muted-foreground">단위:</span>
|
||
<Select
|
||
value={
|
||
step.expression.params?.[1]?.type === "field"
|
||
? step.expression.params[1].fieldName || ""
|
||
: String(step.expression.params?.[1]?.value || "1")
|
||
}
|
||
onValueChange={(value) => {
|
||
const isField = availableFields.some((f) => f.name === value);
|
||
updateStep(step.id, {
|
||
expression: {
|
||
...step.expression,
|
||
params: [
|
||
{ type: "previous" },
|
||
isField
|
||
? { type: "field", fieldName: value }
|
||
: { type: "constant", value: parseFloat(value) },
|
||
],
|
||
},
|
||
});
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-8 w-32 text-xs">
|
||
<SelectValue placeholder="단위" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="1">1</SelectItem>
|
||
<SelectItem value="10">10</SelectItem>
|
||
<SelectItem value="100">100</SelectItem>
|
||
<SelectItem value="1000">1,000</SelectItem>
|
||
{availableFields.map((field) => (
|
||
<SelectItem key={field.name} value={field.name}>
|
||
{field.label || field.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<Label className="text-xs font-semibold">계산식 빌더</Label>
|
||
<Button variant="outline" size="sm" onClick={addStep} className="h-7 text-xs">
|
||
<Plus className="h-3 w-3 mr-1" />
|
||
단계 추가
|
||
</Button>
|
||
</div>
|
||
|
||
{steps.length === 0 ? (
|
||
<Card className="border-dashed">
|
||
<CardContent className="py-6 text-center">
|
||
<Calculator className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
|
||
<p className="text-sm text-muted-foreground">
|
||
계산 단계를 추가하여 계산식을 만드세요
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{steps.map((step, idx) => (
|
||
<Card key={step.id} className="border-primary/30">
|
||
<CardHeader className="pb-2 pt-3 px-3">
|
||
<div className="flex items-center justify-between">
|
||
<CardTitle className="text-xs font-medium">
|
||
{step.label || `단계 ${idx + 1}`}
|
||
</CardTitle>
|
||
<div className="flex items-center gap-1">
|
||
<Input
|
||
value={step.label}
|
||
onChange={(e) => updateStep(step.id, { label: e.target.value })}
|
||
placeholder={`단계 ${idx + 1}`}
|
||
className="h-6 w-24 text-xs"
|
||
/>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => removeStep(step.id)}
|
||
className="h-6 w-6 p-0"
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="pb-3 px-3">
|
||
{step.expression.type === "function"
|
||
? renderFunctionStep(step)
|
||
: renderSimpleExpression(step)}
|
||
|
||
{/* 함수 적용 버튼 */}
|
||
{step.expression.type !== "function" && (
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => {
|
||
updateStep(step.id, {
|
||
expression: {
|
||
type: "function",
|
||
functionName: "round",
|
||
params: [{ type: "previous" }, { type: "constant", value: 1 }],
|
||
},
|
||
});
|
||
}}
|
||
className="h-7 text-xs mt-2"
|
||
>
|
||
함수 적용
|
||
</Button>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* 미리보기 */}
|
||
{steps.length > 0 && (
|
||
<Card className="bg-muted/30">
|
||
<CardContent className="py-3 px-3">
|
||
<div className="text-xs">
|
||
<span className="font-semibold">계산식:</span>
|
||
<div className="mt-1 font-mono text-muted-foreground">
|
||
{steps.map((step, idx) => (
|
||
<div key={step.id}>
|
||
{idx + 1}. {renderNodeLabel(step.expression)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|