화면 복사기능 수정

This commit is contained in:
kjs 2025-11-19 10:03:38 +09:00
parent 5f026e88ab
commit b74cb94191
6 changed files with 887 additions and 180 deletions

View File

@ -2101,55 +2101,109 @@ export class ScreenManagementService {
}
/**
*
* popup targetScreenId를
* /
* - 컴포넌트: popup/modal/edit/openModalWithData targetScreenId
* - 컨테이너: sections[].screenId ( )
* - ()
*/
async detectLinkedModalScreens(
screenId: number
): Promise<{ screenId: number; screenName: string; screenCode: string }[]> {
// 화면의 모든 레이아웃 조회
const layouts = await query<any>(
`SELECT layout_id, properties
FROM screen_layouts
WHERE screen_id = $1
AND component_type = 'component'
AND properties IS NOT NULL`,
[screenId]
);
console.log(`\n🔍 [재귀 감지 시작] 화면 ID: ${screenId}`);
const allLinkedScreenIds = new Set<number>();
const visited = new Set<number>(); // 무한 루프 방지
const queue: number[] = [screenId]; // BFS 큐
const linkedScreenIds = new Set<number>();
// BFS로 연결된 모든 화면 탐색
while (queue.length > 0) {
const currentScreenId = queue.shift()!;
// 이미 방문한 화면은 스킵 (순환 참조 방지)
if (visited.has(currentScreenId)) {
console.log(`⏭️ 이미 방문한 화면 스킵: ${currentScreenId}`);
continue;
}
visited.add(currentScreenId);
console.log(`\n📋 현재 탐색 중인 화면: ${currentScreenId} (깊이: ${visited.size})`);
// 각 레이아웃에서 버튼의 popup/modal/edit 액션 확인
for (const layout of layouts) {
try {
const properties = layout.properties;
// 버튼 컴포넌트인지 확인
if (properties?.componentType === "button" || properties?.componentType?.startsWith("button-")) {
const action = properties?.componentConfig?.action;
// 현재 화면의 모든 레이아웃 조회
const layouts = await query<any>(
`SELECT layout_id, properties
FROM screen_layouts
WHERE screen_id = $1
AND component_type = 'component'
AND properties IS NOT NULL`,
[currentScreenId]
);
console.log(` 📦 레이아웃 개수: ${layouts.length}`);
// 각 레이아웃에서 연결된 화면 ID 확인
for (const layout of layouts) {
try {
const properties = layout.properties;
// popup, modal, edit 액션이고 targetScreenId가 있는 경우
// edit 액션도 수정 폼 모달을 열기 때문에 포함
if ((action?.type === "popup" || action?.type === "modal" || action?.type === "edit") && action?.targetScreenId) {
const targetScreenId = parseInt(action.targetScreenId);
if (!isNaN(targetScreenId)) {
linkedScreenIds.add(targetScreenId);
console.log(`🔗 연결된 모달 화면 발견: screenId=${targetScreenId}, actionType=${action.type} (레이아웃 ${layout.layout_id})`);
// 1. 버튼 컴포넌트의 액션 확인
if (properties?.componentType === "button" || properties?.componentType?.startsWith("button-")) {
const action = properties?.componentConfig?.action;
const modalActionTypes = ["popup", "modal", "edit", "openModalWithData"];
if (modalActionTypes.includes(action?.type) && action?.targetScreenId) {
const targetScreenId = parseInt(action.targetScreenId);
if (!isNaN(targetScreenId) && targetScreenId !== currentScreenId) {
// 메인 화면이 아닌 경우에만 추가
if (targetScreenId !== screenId) {
allLinkedScreenIds.add(targetScreenId);
}
// 아직 방문하지 않은 화면이면 큐에 추가
if (!visited.has(targetScreenId)) {
queue.push(targetScreenId);
console.log(` 🔗 [버튼] 연결된 화면 발견: ${targetScreenId} (action: ${action.type}) → 큐에 추가`);
}
}
}
}
// 2. conditional-container 컴포넌트의 sections 확인
if (properties?.componentType === "conditional-container") {
const sections = properties?.componentConfig?.sections || [];
for (const section of sections) {
if (section?.screenId) {
const sectionScreenId = parseInt(section.screenId);
if (!isNaN(sectionScreenId) && sectionScreenId !== currentScreenId) {
// 메인 화면이 아닌 경우에만 추가
if (sectionScreenId !== screenId) {
allLinkedScreenIds.add(sectionScreenId);
}
// 아직 방문하지 않은 화면이면 큐에 추가
if (!visited.has(sectionScreenId)) {
queue.push(sectionScreenId);
console.log(` 🔗 [조건부컨테이너] 연결된 화면 발견: ${sectionScreenId} (condition: ${section.condition}) → 큐에 추가`);
}
}
}
}
}
} catch (error) {
console.warn(` ⚠️ 레이아웃 ${layout.layout_id} 파싱 오류:`, error);
}
} catch (error) {
// JSON 파싱 오류 등은 무시하고 계속 진행
console.warn(`레이아웃 ${layout.layout_id} 파싱 오류:`, error);
}
}
console.log(`\n✅ [재귀 감지 완료] 총 방문한 화면: ${visited.size}개, 연결된 화면: ${allLinkedScreenIds.size}`);
console.log(` 방문한 화면 ID: [${Array.from(visited).join(", ")}]`);
console.log(` 연결된 화면 ID: [${Array.from(allLinkedScreenIds).join(", ")}]`);
// 감지된 화면 ID들의 정보 조회
if (linkedScreenIds.size === 0) {
if (allLinkedScreenIds.size === 0) {
console.log(` 연결된 화면이 없습니다.`);
return [];
}
const screenIds = Array.from(linkedScreenIds);
const screenIds = Array.from(allLinkedScreenIds);
const placeholders = screenIds.map((_, i) => `$${i + 1}`).join(", ");
const linkedScreens = await query<any>(
@ -2161,6 +2215,11 @@ export class ScreenManagementService {
screenIds
);
console.log(`\n📋 최종 감지된 화면 목록:`);
linkedScreens.forEach((s: any) => {
console.log(` - ${s.screen_name} (ID: ${s.screen_id}, 코드: ${s.screen_code})`);
});
return linkedScreens.map((s) => ({
screenId: s.screen_id,
screenName: s.screen_name,
@ -2430,23 +2489,23 @@ export class ScreenManagementService {
for (const layout of layouts) {
try {
const properties = layout.properties;
let needsUpdate = false;
// 버튼 컴포넌트인지 확인
// 1. 버튼 컴포넌트의 targetScreenId 업데이트
if (
properties?.componentType === "button" ||
properties?.componentType?.startsWith("button-")
) {
const action = properties?.componentConfig?.action;
// targetScreenId가 있는 액션 (popup, modal, edit)
// targetScreenId가 있는 액션 (popup, modal, edit, openModalWithData)
const modalActionTypes = ["popup", "modal", "edit", "openModalWithData"];
if (
(action?.type === "popup" ||
action?.type === "modal" ||
action?.type === "edit") &&
modalActionTypes.includes(action?.type) &&
action?.targetScreenId
) {
const oldScreenId = parseInt(action.targetScreenId);
console.log(`🔍 버튼 발견: layout ${layout.layout_id}, action=${action.type}, targetScreenId=${oldScreenId}`);
console.log(`🔍 [버튼] 발견: layout ${layout.layout_id}, action=${action.type}, targetScreenId=${oldScreenId}`);
// 매핑에 있으면 업데이트
if (screenIdMapping.has(oldScreenId)) {
@ -2456,31 +2515,63 @@ export class ScreenManagementService {
// properties 업데이트
properties.componentConfig.action.targetScreenId =
newScreenId.toString();
needsUpdate = true;
// 데이터베이스 업데이트
await query(
`UPDATE screen_layouts
SET properties = $1
WHERE layout_id = $2`,
[JSON.stringify(properties), layout.layout_id]
);
updateCount++;
console.log(
`🔗 버튼 targetScreenId 업데이트: ${oldScreenId}${newScreenId} (layout ${layout.layout_id})`
`🔗 [버튼] targetScreenId 업데이트 준비: ${oldScreenId}${newScreenId} (layout ${layout.layout_id})`
);
} else {
console.log(`⚠️ 매핑 없음: ${oldScreenId} (업데이트 건너뜀)`);
}
}
}
// 2. conditional-container 컴포넌트의 sections[].screenId 업데이트
if (properties?.componentType === "conditional-container") {
const sections = properties?.componentConfig?.sections || [];
for (const section of sections) {
if (section?.screenId) {
const oldScreenId = parseInt(section.screenId);
console.log(`🔍 [조건부컨테이너] section 발견: layout ${layout.layout_id}, condition=${section.condition}, screenId=${oldScreenId}`);
// 매핑에 있으면 업데이트
if (screenIdMapping.has(oldScreenId)) {
const newScreenId = screenIdMapping.get(oldScreenId)!;
console.log(`✅ 매핑 발견: ${oldScreenId}${newScreenId}`);
// section.screenId 업데이트
section.screenId = newScreenId;
needsUpdate = true;
console.log(
`🔗 [조건부컨테이너] screenId 업데이트 준비: ${oldScreenId}${newScreenId} (layout ${layout.layout_id}, condition=${section.condition})`
);
} else {
console.log(`⚠️ 매핑 없음: ${oldScreenId} (업데이트 건너뜀)`);
}
}
}
}
// 3. 업데이트가 필요한 경우 DB 저장
if (needsUpdate) {
await query(
`UPDATE screen_layouts
SET properties = $1
WHERE layout_id = $2`,
[JSON.stringify(properties), layout.layout_id]
);
updateCount++;
console.log(`💾 레이아웃 ${layout.layout_id} 업데이트 완료`);
}
} catch (error) {
console.warn(`❌ 레이아웃 ${layout.layout_id} 업데이트 오류:`, error);
// 개별 레이아웃 오류는 무시하고 계속 진행
}
}
console.log(`✅ 총 ${updateCount}개 버튼의 targetScreenId 업데이트 완료`);
console.log(`✅ 총 ${updateCount}레이아웃의 연결된 화면 ID 업데이트 완료 (버튼 + 조건부컨테이너)`);
return updateCount;
}
}

View File

@ -0,0 +1,429 @@
"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>
);
};

View File

@ -258,10 +258,32 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
// 🆕 저장 요청 시에만 데이터 전달 (이벤트 리스너 방식)
useEffect(() => {
const handleSaveRequest = () => {
// component.id를 문자열로 안전하게 변환
const componentKey = String(component.id || "selected_items");
console.log("🔔 [SelectedItemsDetailInput] beforeFormSave 이벤트 수신!", {
itemsCount: items.length,
hasOnFormDataChange: !!onFormDataChange,
componentId: component.id,
componentIdType: typeof component.id,
componentKey,
});
if (items.length > 0 && onFormDataChange) {
const dataToSave = { [component.id || "selected_items"]: items };
console.log("📝 [SelectedItemsDetailInput] 저장 요청 시 데이터 전달:", dataToSave);
const dataToSave = { [componentKey]: items };
console.log("📝 [SelectedItemsDetailInput] 저장 요청 시 데이터 전달:", {
key: componentKey,
itemsCount: items.length,
fullData: dataToSave,
firstItem: items[0],
});
onFormDataChange(dataToSave);
} else {
console.warn("⚠️ [SelectedItemsDetailInput] 저장 데이터 전달 실패:", {
hasItems: items.length > 0,
hasCallback: !!onFormDataChange,
itemsLength: items.length,
});
}
};
@ -342,6 +364,14 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
// 🆕 그룹별 필드 변경 핸들러: itemId + groupId + entryId + fieldName
const handleFieldChange = useCallback((itemId: string, groupId: string, entryId: string, fieldName: string, value: any) => {
console.log("📝 [handleFieldChange] 필드 값 변경:", {
itemId,
groupId,
entryId,
fieldName,
value,
});
setItems((prevItems) => {
return prevItems.map((item) => {
if (item.id !== itemId) return item;
@ -357,6 +387,12 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
[fieldName]: value,
};
console.log("✅ [handleFieldChange] Entry 업데이트:", {
beforeKeys: Object.keys(updatedEntries[existingEntryIndex]),
afterKeys: Object.keys(updatedEntry),
updatedEntry,
});
// 🆕 가격 관련 필드가 변경되면 자동 계산
if (componentConfig.autoCalculation) {
const { inputFields, targetField } = componentConfig.autoCalculation;

View File

@ -16,6 +16,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { getSecondLevelMenus, getCategoryColumns, getCategoryValues } from "@/lib/api/tableCategoryValue";
import { CalculationBuilder } from "./CalculationBuilder";
export interface SelectedItemsDetailInputConfigPanelProps {
config: SelectedItemsDetailInputConfig;
@ -1127,132 +1128,223 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
<div className="space-y-3 rounded-lg border p-3 sm:p-4">
<div className="flex items-center justify-between">
<Label className="text-xs font-semibold sm:text-sm"> </Label>
<Checkbox
id="enable-auto-calc"
checked={!!config.autoCalculation}
onCheckedChange={(checked) => {
if (checked) {
handleChange("autoCalculation", {
targetField: "calculated_price",
inputFields: {
basePrice: "current_unit_price",
discountType: "discount_type",
discountValue: "discount_value",
roundingType: "rounding_type",
roundingUnit: "rounding_unit_value",
},
calculationType: "price",
});
} else {
handleChange("autoCalculation", undefined);
}
}}
/>
<Checkbox
id="enable-auto-calc"
checked={!!config.autoCalculation}
onCheckedChange={(checked) => {
if (checked) {
handleChange("autoCalculation", {
targetField: "",
mode: "template",
inputFields: {
basePrice: "",
discountType: "",
discountValue: "",
roundingType: "",
roundingUnit: "",
},
calculationType: "price",
valueMapping: {},
calculationSteps: [],
});
} else {
handleChange("autoCalculation", undefined);
}
}}
/>
</div>
{config.autoCalculation && (
<div className="space-y-2 border-t pt-2">
{/* 계산 모드 선택 */}
<div className="space-y-1">
<Label className="text-[10px] sm:text-xs"> </Label>
<Input
value={config.autoCalculation.targetField || ""}
onChange={(e) => handleChange("autoCalculation", {
...config.autoCalculation,
targetField: e.target.value,
})}
placeholder="calculated_price"
className="h-7 text-xs"
/>
<Label className="text-[10px] sm:text-xs"> </Label>
<Select
value={config.autoCalculation.mode || "template"}
onValueChange={(value: "template" | "custom") => {
handleChange("autoCalculation", {
...config.autoCalculation,
mode: value,
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="template">릿 ( )</SelectItem>
<SelectItem value="custom"> ( )</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[10px] sm:text-xs"> </Label>
<Input
value={config.autoCalculation.inputFields?.basePrice || ""}
onChange={(e) => handleChange("autoCalculation", {
...config.autoCalculation,
inputFields: {
...config.autoCalculation.inputFields,
basePrice: e.target.value,
},
})}
placeholder="current_unit_price"
className="h-7 text-xs"
/>
</div>
{/* 템플릿 모드 */}
{config.autoCalculation.mode === "template" && (
<>
{/* 계산 필드 선택 */}
<div className="space-y-2 border-t pt-2 mt-2">
<Label className="text-[10px] font-semibold sm:text-xs"> </Label>
<div className="space-y-2">
{/* 계산 결과 필드 */}
<div className="space-y-1">
<Label className="text-[9px] sm:text-[10px]"> </Label>
<Select
value={config.autoCalculation.targetField || ""}
onValueChange={(value) => handleChange("autoCalculation", {
...config.autoCalculation,
targetField: value,
})}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{(config.additionalFields || []).map((field) => (
<SelectItem key={field.name} value={field.name}>
{field.label || field.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px] sm:text-xs"> </Label>
<Input
value={config.autoCalculation.inputFields?.discountType || ""}
onChange={(e) => handleChange("autoCalculation", {
...config.autoCalculation,
inputFields: {
...config.autoCalculation.inputFields,
discountType: e.target.value,
},
})}
placeholder="discount_type"
className="h-7 text-xs"
/>
</div>
{/* 기준 단가 필드 */}
<div className="space-y-1">
<Label className="text-[9px] sm:text-[10px]"> </Label>
<Select
value={config.autoCalculation.inputFields?.basePrice || ""}
onValueChange={(value) => handleChange("autoCalculation", {
...config.autoCalculation,
inputFields: {
...config.autoCalculation.inputFields,
basePrice: value,
},
})}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{(config.additionalFields || []).map((field) => (
<SelectItem key={field.name} value={field.name}>
{field.label || field.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[10px] sm:text-xs"></Label>
<Input
value={config.autoCalculation.inputFields?.discountValue || ""}
onChange={(e) => handleChange("autoCalculation", {
...config.autoCalculation,
inputFields: {
...config.autoCalculation.inputFields,
discountValue: e.target.value,
},
})}
placeholder="discount_value"
className="h-7 text-xs"
/>
<div className="grid grid-cols-2 gap-2">
{/* 할인 방식 필드 */}
<div className="space-y-1">
<Label className="text-[9px] sm:text-[10px]"> </Label>
<Select
value={config.autoCalculation.inputFields?.discountType || ""}
onValueChange={(value) => handleChange("autoCalculation", {
...config.autoCalculation,
inputFields: {
...config.autoCalculation.inputFields,
discountType: value,
},
})}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{(config.additionalFields || []).map((field) => (
<SelectItem key={field.name} value={field.name}>
{field.label || field.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 할인값 필드 */}
<div className="space-y-1">
<Label className="text-[9px] sm:text-[10px]"></Label>
<Select
value={config.autoCalculation.inputFields?.discountValue || ""}
onValueChange={(value) => handleChange("autoCalculation", {
...config.autoCalculation,
inputFields: {
...config.autoCalculation.inputFields,
discountValue: value,
},
})}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{(config.additionalFields || []).map((field) => (
<SelectItem key={field.name} value={field.name}>
{field.label || field.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
{/* 반올림 방식 필드 */}
<div className="space-y-1">
<Label className="text-[9px] sm:text-[10px]"> </Label>
<Select
value={config.autoCalculation.inputFields?.roundingType || ""}
onValueChange={(value) => handleChange("autoCalculation", {
...config.autoCalculation,
inputFields: {
...config.autoCalculation.inputFields,
roundingType: value,
},
})}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{(config.additionalFields || []).map((field) => (
<SelectItem key={field.name} value={field.name}>
{field.label || field.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 반올림 단위 필드 */}
<div className="space-y-1">
<Label className="text-[9px] sm:text-[10px]"> </Label>
<Select
value={config.autoCalculation.inputFields?.roundingUnit || ""}
onValueChange={(value) => handleChange("autoCalculation", {
...config.autoCalculation,
inputFields: {
...config.autoCalculation.inputFields,
roundingUnit: value,
},
})}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{(config.additionalFields || []).map((field) => (
<SelectItem key={field.name} value={field.name}>
{field.label || field.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px] sm:text-xs"> </Label>
<Input
value={config.autoCalculation.inputFields?.roundingType || ""}
onChange={(e) => handleChange("autoCalculation", {
...config.autoCalculation,
inputFields: {
...config.autoCalculation.inputFields,
roundingType: e.target.value,
},
})}
placeholder="rounding_type"
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px] sm:text-xs"> </Label>
<Input
value={config.autoCalculation.inputFields?.roundingUnit || ""}
onChange={(e) => handleChange("autoCalculation", {
...config.autoCalculation,
inputFields: {
...config.autoCalculation.inputFields,
roundingUnit: e.target.value,
},
})}
placeholder="rounding_unit_value"
className="h-7 text-xs"
/>
</div>
</div>
<p className="text-[9px] text-amber-600 sm:text-[10px]">
💡
</p>
{/* 카테고리 값 매핑 */}
<div className="space-y-3 border-t pt-3 mt-3">
<Label className="text-[10px] font-semibold sm:text-xs"> </Label>
@ -1591,6 +1683,24 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
💡 1단계: 메뉴 2단계: 카테고리 3단계:
</p>
</div>
</>
)}
{/* 커스텀 모드 (계산식 빌더) */}
{config.autoCalculation.mode === "custom" && (
<div className="space-y-2 border-t pt-2 mt-2">
<CalculationBuilder
steps={config.autoCalculation.calculationSteps || []}
availableFields={config.additionalFields || []}
onChange={(steps) => {
handleChange("autoCalculation", {
...config.autoCalculation,
calculationSteps: steps,
});
}}
/>
</div>
)}
</div>
)}
</div>

View File

@ -58,37 +58,67 @@ export interface FieldGroup {
displayItems?: DisplayItem[];
}
/**
* 🆕
*/
export type CalculationNodeType = "field" | "constant" | "operation" | "function" | "previous";
export interface CalculationNode {
type: CalculationNodeType;
// field: 필드명
fieldName?: string;
// constant: 상수값
value?: number;
// operation: 연산
operator?: "+" | "-" | "*" | "/" | "%" | "^";
left?: CalculationNode;
right?: CalculationNode;
// function: 함수
functionName?: "round" | "floor" | "ceil" | "abs" | "max" | "min";
params?: CalculationNode[];
}
/**
* 🆕
*/
export interface CalculationStep {
id: string;
label: string;
expression: CalculationNode;
}
/**
* 🆕
*/
export interface AutoCalculationConfig {
/** 계산 대상 필드명 (예: calculated_price) */
targetField: string;
/** 계산에 사용할 입력 필드들 */
inputFields: {
basePrice: string; // 기본 단가 필드명
discountType: string; // 할인 방식 필드명
discountValue: string; // 할인값 필드명
roundingType: string; // 반올림 방식 필드명
roundingUnit: string; // 반올림 단위 필드명
/** 🆕 계산 방식 */
mode: "template" | "custom";
/** 템플릿 모드 (기존 방식) */
inputFields?: {
basePrice: string;
discountType: string;
discountValue: string;
roundingType: string;
roundingUnit: string;
};
/** 계산 함수 타입 */
calculationType: "price" | "custom";
/** 🆕 카테고리 값 → 연산 매핑 */
calculationType?: "price" | "custom";
valueMapping?: {
/** 할인 방식 매핑 */
discountType?: {
[valueCode: string]: "none" | "rate" | "amount"; // 예: { "CATEGORY_544740": "rate" }
[valueCode: string]: "none" | "rate" | "amount";
};
/** 반올림 방식 매핑 */
roundingType?: {
[valueCode: string]: "none" | "round" | "floor" | "ceil";
};
/** 반올림 단위 매핑 (숫자로 변환) */
roundingUnit?: {
[valueCode: string]: number; // 예: { "10": 10, "100": 100 }
[valueCode: string]: number;
};
};
/** 커스텀 모드 (계산식 빌더) */
calculationSteps?: CalculationStep[];
}
/**

View File

@ -230,6 +230,16 @@ export class ButtonActionExecutor {
const selectedItemsKeys = Object.keys(context.formData).filter(key => {
const value = context.formData[key];
console.log(`🔍 [handleSave] 필터링 체크 - ${key}:`, {
isArray: Array.isArray(value),
length: Array.isArray(value) ? value.length : 0,
firstItem: Array.isArray(value) && value.length > 0 ? {
keys: Object.keys(value[0] || {}),
hasOriginalData: !!value[0]?.originalData,
hasFieldGroups: !!value[0]?.fieldGroups,
actualValue: value[0],
} : null
});
return Array.isArray(value) && value.length > 0 && value[0]?.originalData && value[0]?.fieldGroups;
});
@ -238,6 +248,7 @@ export class ButtonActionExecutor {
return await this.handleBatchSave(config, context, selectedItemsKeys);
} else {
console.log("⚠️ [handleSave] SelectedItemsDetailInput 데이터 감지 실패 - 일반 저장 진행");
console.log("⚠️ [handleSave] formData 전체 내용:", context.formData);
}
// 폼 유효성 검사