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>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|