Merge pull request 'feature/screen-management' (#321) from feature/screen-management into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/321
This commit is contained in:
kjs 2025-12-31 14:17:53 +09:00
commit c15ec8f7b9
5 changed files with 523 additions and 70 deletions

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import React, { useState, useEffect, useRef, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@ -84,6 +84,9 @@ export function RepeaterTable({
onSelectionChange,
equalizeWidthsTrigger,
}: RepeaterTableProps) {
// 히든 컬럼을 제외한 표시 가능한 컬럼만 필터링
const visibleColumns = useMemo(() => columns.filter((col) => !col.hidden), [columns]);
// 컨테이너 ref - 실제 너비 측정용
const containerRef = useRef<HTMLDivElement>(null);
@ -145,7 +148,7 @@ export function RepeaterTable({
// 컬럼 너비 상태 관리
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => {
const widths: Record<string, number> = {};
columns.forEach((col) => {
columns.filter((col) => !col.hidden).forEach((col) => {
widths[col.field] = col.width ? parseInt(col.width) : 120;
});
return widths;
@ -154,11 +157,11 @@ export function RepeaterTable({
// 기본 너비 저장 (리셋용)
const defaultWidths = React.useMemo(() => {
const widths: Record<string, number> = {};
columns.forEach((col) => {
visibleColumns.forEach((col) => {
widths[col.field] = col.width ? parseInt(col.width) : 120;
});
return widths;
}, [columns]);
}, [visibleColumns]);
// 리사이즈 상태
const [resizing, setResizing] = useState<{ field: string; startX: number; startWidth: number } | null>(null);
@ -206,7 +209,7 @@ export function RepeaterTable({
// 해당 컬럼의 가장 긴 글자 너비 계산
// equalWidth: 균등 분배 시 너비 (값이 없는 컬럼의 최소값으로 사용)
const calculateColumnContentWidth = (field: string, equalWidth: number): number => {
const column = columns.find((col) => col.field === field);
const column = visibleColumns.find((col) => col.field === field);
if (!column) return equalWidth;
// 날짜 필드는 110px (yyyy-MM-dd)
@ -257,7 +260,7 @@ export function RepeaterTable({
// 헤더 더블클릭: 해당 컬럼만 글자 너비에 맞춤
const handleDoubleClick = (field: string) => {
const availableWidth = getAvailableWidth();
const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
const equalWidth = Math.max(60, Math.floor(availableWidth / visibleColumns.length));
const contentWidth = calculateColumnContentWidth(field, equalWidth);
setColumnWidths((prev) => ({
...prev,
@ -268,10 +271,10 @@ export function RepeaterTable({
// 균등 분배: 컬럼 수로 테이블 너비를 균등 분배
const applyEqualizeWidths = () => {
const availableWidth = getAvailableWidth();
const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
const equalWidth = Math.max(60, Math.floor(availableWidth / visibleColumns.length));
const newWidths: Record<string, number> = {};
columns.forEach((col) => {
visibleColumns.forEach((col) => {
newWidths[col.field] = equalWidth;
});
@ -280,15 +283,15 @@ export function RepeaterTable({
// 자동 맞춤: 각 컬럼을 글자 너비에 맞추고, 컨테이너보다 작으면 남는 공간 분배
const applyAutoFitWidths = () => {
if (columns.length === 0) return;
if (visibleColumns.length === 0) return;
// 균등 분배 너비 계산 (값이 없는 컬럼의 최소값)
const availableWidth = getAvailableWidth();
const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
const equalWidth = Math.max(60, Math.floor(availableWidth / visibleColumns.length));
// 1. 각 컬럼의 글자 너비 계산 (값이 없으면 균등 분배 너비 사용)
const newWidths: Record<string, number> = {};
columns.forEach((col) => {
visibleColumns.forEach((col) => {
newWidths[col.field] = calculateColumnContentWidth(col.field, equalWidth);
});
@ -298,8 +301,8 @@ export function RepeaterTable({
// 3. 컨테이너보다 작으면 남는 공간을 균등 분배 (테이블 꽉 참 유지)
if (totalContentWidth < availableWidth) {
const extraSpace = availableWidth - totalContentWidth;
const extraPerColumn = Math.floor(extraSpace / columns.length);
columns.forEach((col) => {
const extraPerColumn = Math.floor(extraSpace / visibleColumns.length);
visibleColumns.forEach((col) => {
newWidths[col.field] += extraPerColumn;
});
}
@ -311,7 +314,7 @@ export function RepeaterTable({
// 초기 마운트 시 균등 분배 적용
useEffect(() => {
if (initializedRef.current) return;
if (!containerRef.current || columns.length === 0) return;
if (!containerRef.current || visibleColumns.length === 0) return;
const timer = setTimeout(() => {
applyEqualizeWidths();
@ -319,7 +322,7 @@ export function RepeaterTable({
}, 100);
return () => clearTimeout(timer);
}, [columns]);
}, [visibleColumns]);
// 트리거 감지: 1=균등분배, 2=자동맞춤
useEffect(() => {
@ -357,7 +360,7 @@ export function RepeaterTable({
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [resizing, columns, data]);
}, [resizing, visibleColumns, data]);
// 데이터 변경 감지 (필요시 활성화)
// useEffect(() => {
@ -531,7 +534,7 @@ export function RepeaterTable({
className={cn("border-gray-400", isIndeterminate && "data-[state=checked]:bg-primary")}
/>
</th>
{columns.map((col) => {
{visibleColumns.map((col) => {
const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0;
const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId;
const activeOption = hasDynamicSource
@ -631,7 +634,7 @@ export function RepeaterTable({
{data.length === 0 ? (
<tr>
<td
colSpan={columns.length + 2}
colSpan={visibleColumns.length + 2}
className="border-b border-gray-200 px-4 py-8 text-center text-gray-500"
>
@ -672,7 +675,7 @@ export function RepeaterTable({
/>
</td>
{/* 데이터 컬럼들 */}
{columns.map((col) => (
{visibleColumns.map((col) => (
<td
key={col.field}
className="overflow-hidden border-r border-b border-gray-200 px-1 py-1"

View File

@ -48,6 +48,7 @@ export interface RepeaterColumnConfig {
calculated?: boolean; // 계산 필드 여부
width?: string; // 컬럼 너비
required?: boolean; // 필수 입력 여부
hidden?: boolean; // 히든 필드 여부 (UI에서 숨기지만 데이터는 유지)
defaultValue?: string | number | boolean; // 기본값
selectOptions?: { value: string; label: string }[]; // select일 때 옵션

View File

@ -16,7 +16,13 @@ import { ItemSelectionModal } from "../modal-repeater-table/ItemSelectionModal";
import { RepeaterColumnConfig, CalculationRule } from "../modal-repeater-table/types";
// 타입 정의
import { TableSectionConfig, TableColumnConfig, TableJoinCondition, FormDataState } from "./types";
import {
TableSectionConfig,
TableColumnConfig,
TableJoinCondition,
FormDataState,
TableCalculationRule,
} from "./types";
interface TableSectionRendererProps {
sectionId: string;
@ -47,6 +53,7 @@ function convertToRepeaterColumn(col: TableColumnConfig): RepeaterColumnConfig {
calculated: col.calculated ?? false,
width: col.width || "150px",
required: col.required,
hidden: col.hidden ?? false,
defaultValue: col.defaultValue,
selectOptions: col.selectOptions,
// valueMapping은 별도로 처리
@ -811,39 +818,69 @@ export function TableSectionRenderer({
});
}, [tableConfig.columns, dynamicSelectOptionsMap]);
// 계산 규칙 변환
const calculationRules: CalculationRule[] = (tableConfig.calculations || []).map(convertToCalculationRule);
// 원본 계산 규칙 (조건부 계산 포함)
const originalCalculationRules: TableCalculationRule[] = useMemo(
() => tableConfig.calculations || [],
[tableConfig.calculations],
);
// 계산 로직
// 기본 계산 규칙 변환 (RepeaterTable용 - 조건부 계산이 없는 경우에 사용)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const calculationRules: CalculationRule[] = originalCalculationRules.map(convertToCalculationRule);
// 조건부 계산 로직: 행의 조건 필드 값에 따라 적절한 계산식 선택
const getFormulaForRow = useCallback((rule: TableCalculationRule, row: Record<string, unknown>): string => {
// 조건부 계산이 활성화된 경우
if (rule.conditionalCalculation?.enabled && rule.conditionalCalculation.conditionField) {
const conditionValue = row[rule.conditionalCalculation.conditionField];
// 조건값과 일치하는 규칙 찾기
const matchedRule = rule.conditionalCalculation.rules?.find((r) => r.conditionValue === conditionValue);
if (matchedRule) {
return matchedRule.formula;
}
// 일치하는 규칙이 없으면 기본 계산식 사용
if (rule.conditionalCalculation.defaultFormula) {
return rule.conditionalCalculation.defaultFormula;
}
}
// 조건부 계산이 비활성화되었거나 기본값이 없으면 원래 계산식 사용
return rule.formula;
}, []);
// 계산 로직 (조건부 계산 지원)
const calculateRow = useCallback(
(row: any): any => {
if (calculationRules.length === 0) return row;
if (originalCalculationRules.length === 0) return row;
const updatedRow = { ...row };
for (const rule of calculationRules) {
for (const rule of originalCalculationRules) {
try {
let formula = rule.formula;
// 조건부 계산에 따라 적절한 계산식 선택
let formula = getFormulaForRow(rule, row);
if (!formula) continue;
const fieldMatches = formula.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || [];
const dependencies = rule.dependencies.length > 0 ? rule.dependencies : fieldMatches;
for (const dep of dependencies) {
if (dep === rule.result) continue;
if (dep === rule.resultField) continue;
const value = parseFloat(row[dep]) || 0;
formula = formula.replace(new RegExp(`\\b${dep}\\b`, "g"), value.toString());
}
const result = new Function(`return ${formula}`)();
updatedRow[rule.result] = result;
updatedRow[rule.resultField] = result;
} catch (error) {
console.error(`계산 오류 (${rule.formula}):`, error);
updatedRow[rule.result] = 0;
updatedRow[rule.resultField] = 0;
}
}
return updatedRow;
},
[calculationRules],
[originalCalculationRules, getFormulaForRow],
);
const calculateAll = useCallback(

View File

@ -24,6 +24,8 @@ import {
TablePreFilter,
TableModalFilter,
TableCalculationRule,
ConditionalCalculationRule,
ConditionalCalculationConfig,
LookupOption,
LookupCondition,
ConditionalTableOption,
@ -52,6 +54,414 @@ const HelpText = ({ children }: { children: React.ReactNode }) => (
<p className="text-[10px] text-muted-foreground mt-0.5">{children}</p>
);
// 계산 규칙 편집 컴포넌트 (조건부 계산 지원)
interface CalculationRuleEditorProps {
calc: TableCalculationRule;
index: number;
columns: TableColumnConfig[];
sourceTableName?: string; // 소스 테이블명 추가
onUpdate: (updates: Partial<TableCalculationRule>) => void;
onRemove: () => void;
}
const CalculationRuleEditor: React.FC<CalculationRuleEditorProps> = ({
calc,
index,
columns,
sourceTableName,
onUpdate,
onRemove,
}) => {
const [categoryOptions, setCategoryOptions] = useState<{ value: string; label: string }[]>([]);
const [loadingOptions, setLoadingOptions] = useState(false);
const [categoryColumns, setCategoryColumns] = useState<Record<string, boolean>>({});
// 조건부 계산 활성화 여부
const isConditionalEnabled = calc.conditionalCalculation?.enabled ?? false;
// 소스 테이블의 카테고리 컬럼 정보 로드
useEffect(() => {
const loadCategoryColumns = async () => {
if (!sourceTableName) {
setCategoryColumns({});
return;
}
try {
const { getCategoryColumns } = await import("@/lib/api/tableCategoryValue");
const result = await getCategoryColumns(sourceTableName);
if (result && result.success && Array.isArray(result.data)) {
const categoryMap: Record<string, boolean> = {};
result.data.forEach((col: any) => {
// API 응답은 camelCase (columnName)
const colName = col.columnName || col.column_name;
if (colName) {
categoryMap[colName] = true;
}
});
setCategoryColumns(categoryMap);
}
} catch (error) {
console.error("카테고리 컬럼 조회 실패:", error);
}
};
loadCategoryColumns();
}, [sourceTableName]);
// 조건 필드가 선택되었을 때 옵션 로드 (테이블 타입 관리의 카테고리 기준)
useEffect(() => {
const loadConditionOptions = async () => {
if (!isConditionalEnabled || !calc.conditionalCalculation?.conditionField) {
setCategoryOptions([]);
return;
}
const conditionField = calc.conditionalCalculation.conditionField;
// 소스 필드(sourceField)가 있으면 해당 필드명 사용, 없으면 field명 사용
const selectedColumn = columns.find((col) => col.field === conditionField);
const actualFieldName = selectedColumn?.sourceField || conditionField;
// 소스 테이블에서 해당 컬럼이 카테고리 타입인지 확인
if (sourceTableName && categoryColumns[actualFieldName]) {
try {
setLoadingOptions(true);
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
const result = await getCategoryValues(sourceTableName, actualFieldName, false);
if (result && result.success && Array.isArray(result.data)) {
const options = result.data.map((item: any) => ({
// API 응답은 camelCase (valueCode, valueLabel)
value: item.valueCode || item.value_code || item.value,
label: item.valueLabel || item.displayLabel || item.display_label || item.label || item.valueCode || item.value_code || item.value,
}));
setCategoryOptions(options);
} else {
setCategoryOptions([]);
}
} catch (error) {
console.error("카테고리 값 로드 실패:", error);
setCategoryOptions([]);
} finally {
setLoadingOptions(false);
}
return;
}
// 카테고리 키가 직접 설정된 경우 (저장된 값)
const categoryKey = calc.conditionalCalculation?.conditionFieldCategoryKey;
if (categoryKey) {
try {
setLoadingOptions(true);
const [tableName, columnName] = categoryKey.split(".");
if (tableName && columnName) {
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
const result = await getCategoryValues(tableName, columnName, false);
if (result && result.success && Array.isArray(result.data)) {
setCategoryOptions(
result.data.map((item: any) => ({
// API 응답은 camelCase (valueCode, valueLabel)
value: item.valueCode || item.value_code || item.value,
label: item.valueLabel || item.displayLabel || item.display_label || item.label || item.valueCode || item.value_code || item.value,
}))
);
}
}
} catch (error) {
console.error("카테고리 옵션 로드 실패:", error);
} finally {
setLoadingOptions(false);
}
return;
}
// 그 외 타입은 옵션 없음 (직접 입력)
setCategoryOptions([]);
};
loadConditionOptions();
}, [isConditionalEnabled, calc.conditionalCalculation?.conditionField, calc.conditionalCalculation?.conditionFieldCategoryKey, columns, sourceTableName, categoryColumns]);
// 조건부 계산 토글
const toggleConditionalCalculation = (enabled: boolean) => {
onUpdate({
conditionalCalculation: enabled
? {
enabled: true,
conditionField: "",
conditionFieldType: "static",
rules: [],
defaultFormula: calc.formula || "",
}
: undefined,
});
};
// 조건 필드 변경
const updateConditionField = (field: string) => {
const selectedColumn = columns.find((col) => col.field === field);
const actualFieldName = selectedColumn?.sourceField || field;
// 컬럼의 타입과 옵션 확인 (테이블 타입 관리의 카테고리 기준)
let conditionFieldType: "static" | "code" | "table" = "static";
let conditionFieldCategoryKey = "";
// 소스 테이블에서 해당 컬럼이 카테고리 타입인지 확인
if (sourceTableName && categoryColumns[actualFieldName]) {
conditionFieldType = "code";
conditionFieldCategoryKey = `${sourceTableName}.${actualFieldName}`;
}
onUpdate({
conditionalCalculation: {
...calc.conditionalCalculation!,
conditionField: field,
conditionFieldType,
conditionFieldCategoryKey,
rules: [], // 필드 변경 시 규칙 초기화
},
});
};
// 조건 규칙 추가
const addConditionRule = () => {
const newRule: ConditionalCalculationRule = {
conditionValue: "",
formula: calc.formula || "",
};
onUpdate({
conditionalCalculation: {
...calc.conditionalCalculation!,
rules: [...(calc.conditionalCalculation?.rules || []), newRule],
},
});
};
// 조건 규칙 업데이트
const updateConditionRule = (ruleIndex: number, updates: Partial<ConditionalCalculationRule>) => {
const newRules = [...(calc.conditionalCalculation?.rules || [])];
newRules[ruleIndex] = { ...newRules[ruleIndex], ...updates };
onUpdate({
conditionalCalculation: {
...calc.conditionalCalculation!,
rules: newRules,
},
});
};
// 조건 규칙 삭제
const removeConditionRule = (ruleIndex: number) => {
onUpdate({
conditionalCalculation: {
...calc.conditionalCalculation!,
rules: (calc.conditionalCalculation?.rules || []).filter((_, i) => i !== ruleIndex),
},
});
};
// 기본 계산식 업데이트
const updateDefaultFormula = (formula: string) => {
onUpdate({
conditionalCalculation: {
...calc.conditionalCalculation!,
defaultFormula: formula,
},
});
};
// 조건 필드로 사용 가능한 컬럼 (모든 컬럼)
const availableColumns = columns.filter((col) => col.field);
return (
<div className="border rounded-lg p-3 bg-muted/30 space-y-3">
{/* 기본 계산 규칙 */}
<div className="flex items-center gap-2">
<Select
value={calc.resultField || ""}
onValueChange={(value) => onUpdate({ resultField: value })}
>
<SelectTrigger className="h-8 text-xs w-[150px]">
<SelectValue placeholder="결과 필드 선택" />
</SelectTrigger>
<SelectContent>
{columns.length === 0 ? (
<SelectItem value="__no_columns__" disabled>
</SelectItem>
) : (
columns
.filter((col) => col.field)
.map((col, idx) => (
<SelectItem key={col.field || `col_${idx}`} value={col.field}>
{col.label || col.field}
</SelectItem>
))
)}
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">=</span>
<Input
value={calc.formula}
onChange={(e) => onUpdate({ formula: e.target.value })}
placeholder="수식 (예: qty * unit_price)"
className="h-8 text-xs flex-1"
disabled={isConditionalEnabled}
/>
<Button
size="sm"
variant="ghost"
onClick={onRemove}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{/* 조건부 계산 토글 */}
<div className="flex items-center gap-2">
<Switch
id={`conditional-calc-${index}`}
checked={isConditionalEnabled}
onCheckedChange={toggleConditionalCalculation}
className="scale-75"
/>
<Label htmlFor={`conditional-calc-${index}`} className="text-xs cursor-pointer">
</Label>
{availableColumns.length === 0 && !isConditionalEnabled && (
<span className="text-[10px] text-muted-foreground ml-2">
( )
</span>
)}
</div>
{/* 조건부 계산 설정 */}
{isConditionalEnabled && (
<div className="border-t pt-3 space-y-3">
{/* 조건 필드 선택 */}
<div className="flex items-center gap-2">
<Label className="text-xs w-[80px] shrink-0"> :</Label>
<Select
value={calc.conditionalCalculation?.conditionField || ""}
onValueChange={updateConditionField}
>
<SelectTrigger className="h-8 text-xs flex-1">
<SelectValue placeholder="조건 기준 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{availableColumns.length === 0 ? (
<SelectItem value="__no_columns__" disabled>
</SelectItem>
) : (
availableColumns.map((col, idx) => {
// 소스 필드명으로 카테고리 여부 확인
const actualFieldName = col.sourceField || col.field;
const isCategoryColumn = categoryColumns[actualFieldName];
return (
<SelectItem key={col.field || `col_${idx}`} value={col.field}>
{col.label || col.field} {isCategoryColumn ? "(카테고리)" : `(${col.type})`}
</SelectItem>
);
})
)}
</SelectContent>
</Select>
</div>
{/* 조건별 계산식 목록 */}
{calc.conditionalCalculation?.conditionField && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> :</Label>
<Button
size="sm"
variant="outline"
onClick={addConditionRule}
className="h-6 text-[10px] px-2"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
{(calc.conditionalCalculation?.rules || []).map((rule, ruleIndex) => (
<div key={ruleIndex} className="flex items-center gap-2 bg-background rounded p-2">
{/* 조건값 선택 */}
{categoryOptions.length > 0 ? (
<Select
value={rule.conditionValue}
onValueChange={(value) =>
updateConditionRule(ruleIndex, { conditionValue: value })
}
>
<SelectTrigger className="h-7 text-xs w-[120px]">
<SelectValue placeholder="조건값" />
</SelectTrigger>
<SelectContent>
{categoryOptions.map((opt, optIdx) => (
<SelectItem key={`${opt.value}_${optIdx}`} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={rule.conditionValue}
onChange={(e) =>
updateConditionRule(ruleIndex, { conditionValue: e.target.value })
}
placeholder="조건값"
className="h-7 text-xs w-[120px]"
/>
)}
<span className="text-xs text-muted-foreground"></span>
<Input
value={rule.formula}
onChange={(e) =>
updateConditionRule(ruleIndex, { formula: e.target.value })
}
placeholder="계산식"
className="h-7 text-xs flex-1"
/>
<Button
size="sm"
variant="ghost"
onClick={() => removeConditionRule(ruleIndex)}
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
{/* 기본 계산식 */}
<div className="flex items-center gap-2 bg-background/50 rounded p-2 border-dashed border">
<span className="text-xs text-muted-foreground w-[120px] text-center">
()
</span>
<span className="text-xs text-muted-foreground"></span>
<Input
value={calc.conditionalCalculation?.defaultFormula || ""}
onChange={(e) => updateDefaultFormula(e.target.value)}
placeholder="기본 계산식 (조건 미해당 시)"
className="h-7 text-xs flex-1"
/>
</div>
</div>
)}
{loadingOptions && (
<p className="text-[10px] text-muted-foreground"> ...</p>
)}
</div>
)}
</div>
);
};
// 옵션 소스 설정 컴포넌트 (검색 가능한 Combobox)
interface OptionSourceConfigProps {
optionSource: {
@ -669,6 +1079,14 @@ function ColumnSettingItem({
/>
<span></span>
</label>
<label className="flex items-center gap-2 text-xs cursor-pointer" title="UI에서 숨기지만 데이터는 유지됩니다">
<Switch
checked={col.hidden ?? false}
onCheckedChange={(checked) => onUpdate({ hidden: checked })}
className="scale-75"
/>
<span></span>
</label>
<label className="flex items-center gap-2 text-xs cursor-pointer" title="부모 화면에서 전달받은 값을 모든 행에 적용">
<Switch
checked={col.receiveFromParent ?? false}
@ -3034,46 +3452,15 @@ export function TableSectionSettingsModal({
</div>
{(tableConfig.calculations || []).map((calc, index) => (
<div key={index} className="flex items-center gap-2 border rounded-lg p-2 bg-muted/30">
<Select
value={calc.resultField || ""}
onValueChange={(value) => updateCalculation(index, { resultField: value })}
>
<SelectTrigger className="h-8 text-xs w-[150px]">
<SelectValue placeholder="결과 필드 선택" />
</SelectTrigger>
<SelectContent>
{(tableConfig.columns || []).length === 0 ? (
<SelectItem value="__no_columns__" disabled>
</SelectItem>
) : (
(tableConfig.columns || [])
.filter((col) => col.field) // 빈 필드명 제외
.map((col, idx) => (
<SelectItem key={col.field || `col_${idx}`} value={col.field}>
{col.label || col.field}
</SelectItem>
))
)}
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">=</span>
<Input
value={calc.formula}
onChange={(e) => updateCalculation(index, { formula: e.target.value })}
placeholder="수식 (예: quantity * unit_price)"
className="h-8 text-xs flex-1"
<CalculationRuleEditor
key={index}
calc={calc}
index={index}
columns={tableConfig.columns || []}
sourceTableName={tableConfig.source?.tableName}
onUpdate={(updates) => updateCalculation(index, updates)}
onRemove={() => removeCalculation(index)}
/>
<Button
size="sm"
variant="ghost"
onClick={() => removeCalculation(index)}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>

View File

@ -378,6 +378,7 @@ export interface TableColumnConfig {
editable?: boolean; // 편집 가능 여부 (기본: true)
calculated?: boolean; // 계산 필드 여부 (자동 읽기전용)
required?: boolean; // 필수 입력 여부
hidden?: boolean; // 히든 필드 여부 (UI에서 숨기지만 데이터는 유지)
// 너비 설정
width?: string; // 기본 너비 (예: "150px")
@ -604,6 +605,27 @@ export interface ColumnModeConfig {
valueMapping: ValueMappingConfig; // 이 모드의 값 매핑
}
/**
*
*
*/
export interface ConditionalCalculationRule {
conditionValue: string; // 조건 값 (예: "국내", "해외")
formula: string; // 해당 조건일 때 사용할 계산식
}
/**
*
*/
export interface ConditionalCalculationConfig {
enabled: boolean; // 조건부 계산 활성화 여부
conditionField: string; // 조건 기준 필드 (예: "sales_type")
conditionFieldType?: "static" | "code" | "table"; // 조건 필드의 옵션 타입
conditionFieldCategoryKey?: string; // 카테고리 키 (예: "sales_order_mng.sales_type")
rules: ConditionalCalculationRule[]; // 조건별 계산 규칙
defaultFormula?: string; // 조건에 해당하지 않을 때 기본 계산식
}
/**
*
*
@ -612,6 +634,9 @@ export interface TableCalculationRule {
resultField: string; // 결과를 저장할 필드
formula: string; // 계산 공식 (예: "quantity * unit_price")
dependencies: string[]; // 의존하는 필드들
// 조건부 계산 (선택사항)
conditionalCalculation?: ConditionalCalculationConfig;
}
// 다중 행 저장 설정