분할 패널 RepeaterFieldGroup 저장 및 DB webType 자동 매핑 구현
This commit is contained in:
parent
c2d473bf59
commit
93443c98ee
|
|
@ -164,8 +164,8 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [rows] = await this.pool.execute(sql, params);
|
const [rows] = await this.pool.execute(sql, params);
|
||||||
return rows;
|
return rows;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// 연결 닫힘 오류 감지
|
// 연결 닫힘 오류 감지
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,13 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react";
|
import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition, CalculationFormula } from "@/types/repeater";
|
import {
|
||||||
|
RepeaterFieldGroupConfig,
|
||||||
|
RepeaterData,
|
||||||
|
RepeaterItemData,
|
||||||
|
RepeaterFieldDefinition,
|
||||||
|
CalculationFormula,
|
||||||
|
} from "@/types/repeater";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useBreakpoint } from "@/hooks/useBreakpoint";
|
import { useBreakpoint } from "@/hooks/useBreakpoint";
|
||||||
import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal";
|
import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal";
|
||||||
|
|
@ -46,7 +52,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
const breakpoint = previewBreakpoint || globalBreakpoint;
|
const breakpoint = previewBreakpoint || globalBreakpoint;
|
||||||
|
|
||||||
// 카테고리 매핑 데이터 (값 -> {label, color})
|
// 카테고리 매핑 데이터 (값 -> {label, color})
|
||||||
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, { label: string; color: string }>>>({});
|
const [categoryMappings, setCategoryMappings] = useState<
|
||||||
|
Record<string, Record<string, { label: string; color: string }>>
|
||||||
|
>({});
|
||||||
|
|
||||||
// 설정 기본값
|
// 설정 기본값
|
||||||
const {
|
const {
|
||||||
|
|
@ -78,10 +86,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
|
|
||||||
// 접힌 상태 관리 (각 항목별)
|
// 접힌 상태 관리 (각 항목별)
|
||||||
const [collapsedItems, setCollapsedItems] = useState<Set<number>>(new Set());
|
const [collapsedItems, setCollapsedItems] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
// 🆕 초기 계산 완료 여부 추적 (무한 루프 방지)
|
// 🆕 초기 계산 완료 여부 추적 (무한 루프 방지)
|
||||||
const initialCalcDoneRef = useRef(false);
|
const initialCalcDoneRef = useRef(false);
|
||||||
|
|
||||||
// 🆕 삭제된 항목 ID 목록 추적 (ref로 관리하여 즉시 반영)
|
// 🆕 삭제된 항목 ID 목록 추적 (ref로 관리하여 즉시 반영)
|
||||||
const deletedItemIdsRef = useRef<string[]>([]);
|
const deletedItemIdsRef = useRef<string[]>([]);
|
||||||
|
|
||||||
|
|
@ -98,47 +106,60 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
|
|
||||||
// 외부 value 변경 시 동기화 및 초기 계산식 필드 업데이트
|
// 외부 value 변경 시 동기화 및 초기 계산식 필드 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (value.length > 0) {
|
// 🆕 빈 배열도 처리 (FK 기반 필터링 시 데이터가 없을 수 있음)
|
||||||
// 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행)
|
if (value.length === 0) {
|
||||||
const calculatedFields = fields.filter(f => f.type === "calculated");
|
// minItems가 설정되어 있으면 빈 항목 생성, 아니면 빈 배열로 초기화
|
||||||
|
if (minItems > 0) {
|
||||||
if (calculatedFields.length > 0 && !initialCalcDoneRef.current) {
|
const emptyItems = Array(minItems)
|
||||||
const updatedValue = value.map(item => {
|
.fill(null)
|
||||||
const updatedItem = { ...item };
|
.map(() => createEmptyItem());
|
||||||
let hasChange = false;
|
setItems(emptyItems);
|
||||||
|
|
||||||
calculatedFields.forEach(calcField => {
|
|
||||||
const calculatedValue = calculateValue(calcField.formula, updatedItem);
|
|
||||||
if (calculatedValue !== null && updatedItem[calcField.name] !== calculatedValue) {
|
|
||||||
updatedItem[calcField.name] = calculatedValue;
|
|
||||||
hasChange = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 🆕 기존 레코드임을 표시 (id가 있는 경우)
|
|
||||||
if (updatedItem.id) {
|
|
||||||
updatedItem._existingRecord = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasChange ? updatedItem : item;
|
|
||||||
});
|
|
||||||
|
|
||||||
setItems(updatedValue);
|
|
||||||
initialCalcDoneRef.current = true;
|
|
||||||
|
|
||||||
// 계산된 값이 있으면 onChange 호출 (초기 1회만)
|
|
||||||
const dataWithMeta = config.targetTable
|
|
||||||
? updatedValue.map((item) => ({ ...item, _targetTable: config.targetTable }))
|
|
||||||
: updatedValue;
|
|
||||||
onChange?.(dataWithMeta);
|
|
||||||
} else {
|
} else {
|
||||||
// 🆕 기존 레코드 플래그 추가
|
setItems([]);
|
||||||
const valueWithFlag = value.map(item => ({
|
|
||||||
...item,
|
|
||||||
_existingRecord: !!item.id,
|
|
||||||
}));
|
|
||||||
setItems(valueWithFlag);
|
|
||||||
}
|
}
|
||||||
|
initialCalcDoneRef.current = false; // 다음 데이터 로드 시 계산식 재실행
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행)
|
||||||
|
const calculatedFields = fields.filter((f) => f.type === "calculated");
|
||||||
|
|
||||||
|
if (calculatedFields.length > 0 && !initialCalcDoneRef.current) {
|
||||||
|
const updatedValue = value.map((item) => {
|
||||||
|
const updatedItem = { ...item };
|
||||||
|
let hasChange = false;
|
||||||
|
|
||||||
|
calculatedFields.forEach((calcField) => {
|
||||||
|
const calculatedValue = calculateValue(calcField.formula, updatedItem);
|
||||||
|
if (calculatedValue !== null && updatedItem[calcField.name] !== calculatedValue) {
|
||||||
|
updatedItem[calcField.name] = calculatedValue;
|
||||||
|
hasChange = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🆕 기존 레코드임을 표시 (id가 있는 경우)
|
||||||
|
if (updatedItem.id) {
|
||||||
|
updatedItem._existingRecord = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasChange ? updatedItem : item;
|
||||||
|
});
|
||||||
|
|
||||||
|
setItems(updatedValue);
|
||||||
|
initialCalcDoneRef.current = true;
|
||||||
|
|
||||||
|
// 계산된 값이 있으면 onChange 호출 (초기 1회만)
|
||||||
|
const dataWithMeta = config.targetTable
|
||||||
|
? updatedValue.map((item) => ({ ...item, _targetTable: config.targetTable }))
|
||||||
|
: updatedValue;
|
||||||
|
onChange?.(dataWithMeta);
|
||||||
|
} else {
|
||||||
|
// 🆕 기존 레코드 플래그 추가
|
||||||
|
const valueWithFlag = value.map((item) => ({
|
||||||
|
...item,
|
||||||
|
_existingRecord: !!item.id,
|
||||||
|
}));
|
||||||
|
setItems(valueWithFlag);
|
||||||
}
|
}
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
|
|
@ -164,14 +185,14 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
if (items.length <= minItems) {
|
if (items.length <= minItems) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 삭제되는 항목의 ID 저장 (DB에서 삭제할 때 필요)
|
// 🆕 삭제되는 항목의 ID 저장 (DB에서 삭제할 때 필요)
|
||||||
const removedItem = items[index];
|
const removedItem = items[index];
|
||||||
if (removedItem?.id) {
|
if (removedItem?.id) {
|
||||||
console.log("🗑️ [RepeaterInput] 삭제할 항목 ID 추가:", removedItem.id);
|
console.log("🗑️ [RepeaterInput] 삭제할 항목 ID 추가:", removedItem.id);
|
||||||
deletedItemIdsRef.current = [...deletedItemIdsRef.current, removedItem.id];
|
deletedItemIdsRef.current = [...deletedItemIdsRef.current, removedItem.id];
|
||||||
}
|
}
|
||||||
|
|
||||||
const newItems = items.filter((_, i) => i !== index);
|
const newItems = items.filter((_, i) => i !== index);
|
||||||
setItems(newItems);
|
setItems(newItems);
|
||||||
|
|
||||||
|
|
@ -179,10 +200,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
// 🆕 삭제된 항목 ID 목록도 함께 전달 (ref에서 최신값 사용)
|
// 🆕 삭제된 항목 ID 목록도 함께 전달 (ref에서 최신값 사용)
|
||||||
const currentDeletedIds = deletedItemIdsRef.current;
|
const currentDeletedIds = deletedItemIdsRef.current;
|
||||||
console.log("🗑️ [RepeaterInput] 현재 삭제 목록:", currentDeletedIds);
|
console.log("🗑️ [RepeaterInput] 현재 삭제 목록:", currentDeletedIds);
|
||||||
|
|
||||||
const dataWithMeta = config.targetTable
|
const dataWithMeta = config.targetTable
|
||||||
? newItems.map((item, idx) => ({
|
? newItems.map((item, idx) => ({
|
||||||
...item,
|
...item,
|
||||||
_targetTable: config.targetTable,
|
_targetTable: config.targetTable,
|
||||||
// 첫 번째 항목에만 삭제 ID 목록 포함
|
// 첫 번째 항목에만 삭제 ID 목록 포함
|
||||||
...(idx === 0 ? { _deletedItemIds: currentDeletedIds } : {}),
|
...(idx === 0 ? { _deletedItemIds: currentDeletedIds } : {}),
|
||||||
|
|
@ -205,16 +226,16 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
...newItems[itemIndex],
|
...newItems[itemIndex],
|
||||||
[fieldName]: value,
|
[fieldName]: value,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🆕 계산식 필드 자동 업데이트: 변경된 항목의 모든 계산식 필드 값을 재계산
|
// 🆕 계산식 필드 자동 업데이트: 변경된 항목의 모든 계산식 필드 값을 재계산
|
||||||
const calculatedFields = fields.filter(f => f.type === "calculated");
|
const calculatedFields = fields.filter((f) => f.type === "calculated");
|
||||||
calculatedFields.forEach(calcField => {
|
calculatedFields.forEach((calcField) => {
|
||||||
const calculatedValue = calculateValue(calcField.formula, newItems[itemIndex]);
|
const calculatedValue = calculateValue(calcField.formula, newItems[itemIndex]);
|
||||||
if (calculatedValue !== null) {
|
if (calculatedValue !== null) {
|
||||||
newItems[itemIndex][calcField.name] = calculatedValue;
|
newItems[itemIndex][calcField.name] = calculatedValue;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setItems(newItems);
|
setItems(newItems);
|
||||||
console.log("✏️ RepeaterInput 필드 변경, onChange 호출:", {
|
console.log("✏️ RepeaterInput 필드 변경, onChange 호출:", {
|
||||||
itemIndex,
|
itemIndex,
|
||||||
|
|
@ -227,8 +248,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
// 🆕 삭제된 항목 ID 목록도 유지
|
// 🆕 삭제된 항목 ID 목록도 유지
|
||||||
const currentDeletedIds = deletedItemIdsRef.current;
|
const currentDeletedIds = deletedItemIdsRef.current;
|
||||||
const dataWithMeta = config.targetTable
|
const dataWithMeta = config.targetTable
|
||||||
? newItems.map((item, idx) => ({
|
? newItems.map((item, idx) => ({
|
||||||
...item,
|
...item,
|
||||||
_targetTable: config.targetTable,
|
_targetTable: config.targetTable,
|
||||||
// 첫 번째 항목에만 삭제 ID 목록 포함 (삭제된 항목이 있는 경우에만)
|
// 첫 번째 항목에만 삭제 ID 목록 포함 (삭제된 항목이 있는 경우에만)
|
||||||
...(idx === 0 && currentDeletedIds.length > 0 ? { _deletedItemIds: currentDeletedIds } : {}),
|
...(idx === 0 && currentDeletedIds.length > 0 ? { _deletedItemIds: currentDeletedIds } : {}),
|
||||||
|
|
@ -288,14 +309,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
*/
|
*/
|
||||||
const calculateValue = (formula: CalculationFormula | undefined, item: RepeaterItemData): number | null => {
|
const calculateValue = (formula: CalculationFormula | undefined, item: RepeaterItemData): number | null => {
|
||||||
if (!formula || !formula.field1) return null;
|
if (!formula || !formula.field1) return null;
|
||||||
|
|
||||||
const value1 = parseFloat(item[formula.field1]) || 0;
|
const value1 = parseFloat(item[formula.field1]) || 0;
|
||||||
const value2 = formula.field2
|
const value2 = formula.field2 ? parseFloat(item[formula.field2]) || 0 : (formula.constantValue ?? 0);
|
||||||
? (parseFloat(item[formula.field2]) || 0)
|
|
||||||
: (formula.constantValue ?? 0);
|
|
||||||
|
|
||||||
let result: number;
|
let result: number;
|
||||||
|
|
||||||
switch (formula.operator) {
|
switch (formula.operator) {
|
||||||
case "+":
|
case "+":
|
||||||
result = value1 + value2;
|
result = value1 + value2;
|
||||||
|
|
@ -331,7 +350,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
default:
|
default:
|
||||||
result = value1;
|
result = value1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -341,42 +360,44 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
* @param format 포맷 설정
|
* @param format 포맷 설정
|
||||||
* @returns 포맷된 문자열
|
* @returns 포맷된 문자열
|
||||||
*/
|
*/
|
||||||
const formatNumber = (
|
const formatNumber = (value: number | null, format?: RepeaterFieldDefinition["numberFormat"]): string => {
|
||||||
value: number | null,
|
|
||||||
format?: RepeaterFieldDefinition["numberFormat"]
|
|
||||||
): string => {
|
|
||||||
if (value === null || isNaN(value)) return "-";
|
if (value === null || isNaN(value)) return "-";
|
||||||
|
|
||||||
let formattedValue = value;
|
let formattedValue = value;
|
||||||
|
|
||||||
// 소수점 자릿수 적용
|
// 소수점 자릿수 적용
|
||||||
if (format?.decimalPlaces !== undefined) {
|
if (format?.decimalPlaces !== undefined) {
|
||||||
formattedValue = parseFloat(value.toFixed(format.decimalPlaces));
|
formattedValue = parseFloat(value.toFixed(format.decimalPlaces));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 천 단위 구분자
|
// 천 단위 구분자
|
||||||
let result = format?.useThousandSeparator !== false
|
let result =
|
||||||
? formattedValue.toLocaleString("ko-KR", {
|
format?.useThousandSeparator !== false
|
||||||
minimumFractionDigits: format?.minimumFractionDigits ?? 0,
|
? formattedValue.toLocaleString("ko-KR", {
|
||||||
maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0,
|
minimumFractionDigits: format?.minimumFractionDigits ?? 0,
|
||||||
})
|
maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0,
|
||||||
: formattedValue.toString();
|
})
|
||||||
|
: formattedValue.toString();
|
||||||
|
|
||||||
// 접두사/접미사 추가
|
// 접두사/접미사 추가
|
||||||
if (format?.prefix) result = format.prefix + result;
|
if (format?.prefix) result = format.prefix + result;
|
||||||
if (format?.suffix) result = result + format.suffix;
|
if (format?.suffix) result = result + format.suffix;
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 개별 필드 렌더링
|
// 개별 필드 렌더링
|
||||||
const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => {
|
const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => {
|
||||||
const isReadonly = disabled || readonly || field.readonly;
|
const isReadonly = disabled || readonly || field.readonly;
|
||||||
|
|
||||||
|
// 🆕 placeholder 기본값: 필드에 설정된 값 > 필드 라벨 기반 자동 생성
|
||||||
|
// "id(를) 입력하세요" 같은 잘못된 기본값 방지
|
||||||
|
const defaultPlaceholder = field.placeholder || `${field.label || field.name}`;
|
||||||
|
|
||||||
const commonProps = {
|
const commonProps = {
|
||||||
value: value || "",
|
value: value || "",
|
||||||
disabled: isReadonly,
|
disabled: isReadonly,
|
||||||
placeholder: field.placeholder,
|
placeholder: defaultPlaceholder,
|
||||||
required: field.required,
|
required: field.required,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -385,25 +406,21 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
const item = items[itemIndex];
|
const item = items[itemIndex];
|
||||||
const calculatedValue = calculateValue(field.formula, item);
|
const calculatedValue = calculateValue(field.formula, item);
|
||||||
const formattedValue = formatNumber(calculatedValue, field.numberFormat);
|
const formattedValue = formatNumber(calculatedValue, field.numberFormat);
|
||||||
|
|
||||||
return (
|
return <span className="inline-block min-w-[80px] text-sm font-medium text-blue-700">{formattedValue}</span>;
|
||||||
<span className="text-sm font-medium text-blue-700 min-w-[80px] inline-block">
|
|
||||||
{formattedValue}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카테고리 타입은 항상 배지로 표시 (카테고리 관리에서 설정한 색상 적용)
|
// 카테고리 타입은 항상 배지로 표시 (카테고리 관리에서 설정한 색상 적용)
|
||||||
if (field.type === "category") {
|
if (field.type === "category") {
|
||||||
if (!value) return <span className="text-muted-foreground text-sm">-</span>;
|
if (!value) return <span className="text-muted-foreground text-sm">-</span>;
|
||||||
|
|
||||||
// field.name을 키로 사용 (테이블 리스트와 동일)
|
// field.name을 키로 사용 (테이블 리스트와 동일)
|
||||||
const mapping = categoryMappings[field.name];
|
const mapping = categoryMappings[field.name];
|
||||||
const valueStr = String(value); // 값을 문자열로 변환
|
const valueStr = String(value); // 값을 문자열로 변환
|
||||||
const categoryData = mapping?.[valueStr];
|
const categoryData = mapping?.[valueStr];
|
||||||
const displayLabel = categoryData?.label || valueStr;
|
const displayLabel = categoryData?.label || valueStr;
|
||||||
const displayColor = categoryData?.color || "#64748b"; // 기본 색상 (slate)
|
const displayColor = categoryData?.color || "#64748b"; // 기본 색상 (slate)
|
||||||
|
|
||||||
console.log(`🏷️ [RepeaterInput] 카테고리 배지 렌더링:`, {
|
console.log(`🏷️ [RepeaterInput] 카테고리 배지 렌더링:`, {
|
||||||
fieldName: field.name,
|
fieldName: field.name,
|
||||||
value: valueStr,
|
value: valueStr,
|
||||||
|
|
@ -412,12 +429,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
displayLabel,
|
displayLabel,
|
||||||
displayColor,
|
displayColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 색상이 "none"이면 일반 텍스트로 표시
|
// 색상이 "none"이면 일반 텍스트로 표시
|
||||||
if (displayColor === "none") {
|
if (displayColor === "none") {
|
||||||
return <span className="text-sm">{displayLabel}</span>;
|
return <span className="text-sm">{displayLabel}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -436,10 +453,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
if (field.displayMode === "readonly") {
|
if (field.displayMode === "readonly") {
|
||||||
// select 타입인 경우 옵션에서 라벨 찾기
|
// select 타입인 경우 옵션에서 라벨 찾기
|
||||||
if (field.type === "select" && value && field.options) {
|
if (field.type === "select" && value && field.options) {
|
||||||
const option = field.options.find(opt => opt.value === value);
|
const option = field.options.find((opt) => opt.value === value);
|
||||||
return <span className="text-sm">{option?.label || value}</span>;
|
return <span className="text-sm">{option?.label || value}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 카테고리 매핑이 있는 경우 라벨로 변환 (조인된 테이블의 카테고리 필드)
|
// 🆕 카테고리 매핑이 있는 경우 라벨로 변환 (조인된 테이블의 카테고리 필드)
|
||||||
const mapping = categoryMappings[field.name];
|
const mapping = categoryMappings[field.name];
|
||||||
if (mapping && value) {
|
if (mapping && value) {
|
||||||
|
|
@ -461,16 +478,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 색상이 없으면 텍스트로 표시
|
// 색상이 없으면 텍스트로 표시
|
||||||
return <span className="text-sm text-foreground">{categoryData.label}</span>;
|
return <span className="text-foreground text-sm">{categoryData.label}</span>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 일반 텍스트
|
// 일반 텍스트
|
||||||
return (
|
return <span className="text-foreground text-sm">{value || "-"}</span>;
|
||||||
<span className="text-sm text-foreground">
|
|
||||||
{value || "-"}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (field.type) {
|
switch (field.type) {
|
||||||
|
|
@ -500,35 +513,46 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
{...commonProps}
|
{...commonProps}
|
||||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="resize-none min-w-[100px]"
|
className="min-w-[100px] resize-none"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "date":
|
case "date": {
|
||||||
|
// 날짜 값 정규화: ISO 형식이면 YYYY-MM-DD로 변환
|
||||||
|
let dateValue = value || "";
|
||||||
|
if (dateValue && typeof dateValue === "string") {
|
||||||
|
// ISO 형식(YYYY-MM-DDTHH:mm:ss)이면 날짜 부분만 추출
|
||||||
|
if (dateValue.includes("T")) {
|
||||||
|
dateValue = dateValue.split("T")[0];
|
||||||
|
}
|
||||||
|
// 유효한 날짜인지 확인
|
||||||
|
const parsedDate = new Date(dateValue);
|
||||||
|
if (isNaN(parsedDate.getTime())) {
|
||||||
|
dateValue = ""; // 유효하지 않은 날짜면 빈 값
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
{...commonProps}
|
{...commonProps}
|
||||||
|
value={dateValue}
|
||||||
type="date"
|
type="date"
|
||||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value || null)}
|
||||||
className="min-w-[120px]"
|
className="min-w-[120px]"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
case "number":
|
case "number":
|
||||||
// 숫자 포맷이 설정된 경우 포맷팅된 텍스트로 표시
|
// 숫자 포맷이 설정된 경우 포맷팅된 텍스트로 표시
|
||||||
if (field.numberFormat?.useThousandSeparator || field.numberFormat?.prefix || field.numberFormat?.suffix) {
|
if (field.numberFormat?.useThousandSeparator || field.numberFormat?.prefix || field.numberFormat?.suffix) {
|
||||||
const numValue = parseFloat(value) || 0;
|
const numValue = parseFloat(value) || 0;
|
||||||
const formattedDisplay = formatNumber(numValue, field.numberFormat);
|
const formattedDisplay = formatNumber(numValue, field.numberFormat);
|
||||||
|
|
||||||
// 읽기 전용이면 포맷팅된 텍스트만 표시
|
// 읽기 전용이면 포맷팅된 텍스트만 표시
|
||||||
if (isReadonly) {
|
if (isReadonly) {
|
||||||
return (
|
return <span className="inline-block min-w-[80px] text-sm">{formattedDisplay}</span>;
|
||||||
<span className="text-sm min-w-[80px] inline-block">
|
|
||||||
{formattedDisplay}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 편집 가능: 입력은 숫자로, 표시는 포맷팅
|
// 편집 가능: 입력은 숫자로, 표시는 포맷팅
|
||||||
return (
|
return (
|
||||||
<div className="relative min-w-[80px]">
|
<div className="relative min-w-[80px]">
|
||||||
|
|
@ -540,15 +564,11 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
max={field.validation?.max}
|
max={field.validation?.max}
|
||||||
className="pr-1"
|
className="pr-1"
|
||||||
/>
|
/>
|
||||||
{value && (
|
{value && <div className="text-muted-foreground mt-0.5 text-[10px]">{formattedDisplay}</div>}
|
||||||
<div className="text-muted-foreground text-[10px] mt-0.5">
|
|
||||||
{formattedDisplay}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
{...commonProps}
|
{...commonProps}
|
||||||
|
|
@ -597,31 +617,31 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
// 테이블 리스트와 동일한 API 사용: /table-categories/{tableName}/{columnName}/values
|
// 테이블 리스트와 동일한 API 사용: /table-categories/{tableName}/{columnName}/values
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 카테고리 타입 필드 + readonly 필드 (조인된 테이블에서 온 데이터일 가능성)
|
// 카테고리 타입 필드 + readonly 필드 (조인된 테이블에서 온 데이터일 가능성)
|
||||||
const categoryFields = fields.filter(f => f.type === "category");
|
const categoryFields = fields.filter((f) => f.type === "category");
|
||||||
const readonlyFields = fields.filter(f => f.displayMode === "readonly" && f.type === "text");
|
const readonlyFields = fields.filter((f) => f.displayMode === "readonly" && f.type === "text");
|
||||||
|
|
||||||
if (categoryFields.length === 0 && readonlyFields.length === 0) return;
|
if (categoryFields.length === 0 && readonlyFields.length === 0) return;
|
||||||
|
|
||||||
const loadCategoryMappings = async () => {
|
const loadCategoryMappings = async () => {
|
||||||
const apiClient = (await import("@/lib/api/client")).apiClient;
|
const apiClient = (await import("@/lib/api/client")).apiClient;
|
||||||
|
|
||||||
// 1. 카테고리 타입 필드 매핑 로드
|
// 1. 카테고리 타입 필드 매핑 로드
|
||||||
for (const field of categoryFields) {
|
for (const field of categoryFields) {
|
||||||
const columnName = field.name;
|
const columnName = field.name;
|
||||||
|
|
||||||
if (categoryMappings[columnName]) continue;
|
if (categoryMappings[columnName]) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tableName = config.targetTable;
|
const tableName = config.targetTable;
|
||||||
if (!tableName) continue;
|
if (!tableName) continue;
|
||||||
|
|
||||||
console.log(`📡 [RepeaterInput] 카테고리 매핑 로드: ${tableName}/${columnName}`);
|
console.log(`📡 [RepeaterInput] 카테고리 매핑 로드: ${tableName}/${columnName}`);
|
||||||
|
|
||||||
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
|
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
|
||||||
|
|
||||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||||
const mapping: Record<string, { label: string; color: string }> = {};
|
const mapping: Record<string, { label: string; color: string }> = {};
|
||||||
|
|
||||||
response.data.data.forEach((item: any) => {
|
response.data.data.forEach((item: any) => {
|
||||||
const key = String(item.valueCode);
|
const key = String(item.valueCode);
|
||||||
mapping[key] = {
|
mapping[key] = {
|
||||||
|
|
@ -629,10 +649,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
color: item.color || "#64748b",
|
color: item.color || "#64748b",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`✅ [RepeaterInput] 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
|
console.log(`✅ [RepeaterInput] 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
|
||||||
|
|
||||||
setCategoryMappings(prev => ({
|
setCategoryMappings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[columnName]: mapping,
|
[columnName]: mapping,
|
||||||
}));
|
}));
|
||||||
|
|
@ -641,29 +661,29 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
console.error(`❌ [RepeaterInput] 카테고리 매핑 로드 실패 (${columnName}):`, error);
|
console.error(`❌ [RepeaterInput] 카테고리 매핑 로드 실패 (${columnName}):`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 🆕 readonly 필드에 대해 조인된 테이블 (item_info)에서 카테고리 매핑 로드
|
// 2. 🆕 readonly 필드에 대해 조인된 테이블 (item_info)에서 카테고리 매핑 로드
|
||||||
// material, division 등 조인된 테이블의 카테고리 필드
|
// material, division 등 조인된 테이블의 카테고리 필드
|
||||||
const joinedTableFields = ['material', 'division', 'status', 'currency_code'];
|
const joinedTableFields = ["material", "division", "status", "currency_code"];
|
||||||
const fieldsToLoadFromJoinedTable = readonlyFields.filter(f => joinedTableFields.includes(f.name));
|
const fieldsToLoadFromJoinedTable = readonlyFields.filter((f) => joinedTableFields.includes(f.name));
|
||||||
|
|
||||||
if (fieldsToLoadFromJoinedTable.length > 0) {
|
if (fieldsToLoadFromJoinedTable.length > 0) {
|
||||||
// item_info 테이블에서 카테고리 매핑 로드
|
// item_info 테이블에서 카테고리 매핑 로드
|
||||||
const joinedTableName = 'item_info';
|
const joinedTableName = "item_info";
|
||||||
|
|
||||||
for (const field of fieldsToLoadFromJoinedTable) {
|
for (const field of fieldsToLoadFromJoinedTable) {
|
||||||
const columnName = field.name;
|
const columnName = field.name;
|
||||||
|
|
||||||
if (categoryMappings[columnName]) continue;
|
if (categoryMappings[columnName]) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log(`📡 [RepeaterInput] 조인 테이블 카테고리 매핑 로드: ${joinedTableName}/${columnName}`);
|
console.log(`📡 [RepeaterInput] 조인 테이블 카테고리 매핑 로드: ${joinedTableName}/${columnName}`);
|
||||||
|
|
||||||
const response = await apiClient.get(`/table-categories/${joinedTableName}/${columnName}/values`);
|
const response = await apiClient.get(`/table-categories/${joinedTableName}/${columnName}/values`);
|
||||||
|
|
||||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||||
const mapping: Record<string, { label: string; color: string }> = {};
|
const mapping: Record<string, { label: string; color: string }> = {};
|
||||||
|
|
||||||
response.data.data.forEach((item: any) => {
|
response.data.data.forEach((item: any) => {
|
||||||
const key = String(item.valueCode);
|
const key = String(item.valueCode);
|
||||||
mapping[key] = {
|
mapping[key] = {
|
||||||
|
|
@ -671,10 +691,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
color: item.color || "#64748b",
|
color: item.color || "#64748b",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`✅ [RepeaterInput] 조인 테이블 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
|
console.log(`✅ [RepeaterInput] 조인 테이블 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
|
||||||
|
|
||||||
setCategoryMappings(prev => ({
|
setCategoryMappings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[columnName]: mapping,
|
[columnName]: mapping,
|
||||||
}));
|
}));
|
||||||
|
|
@ -694,9 +714,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
if (fields.length === 0) {
|
if (fields.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-4", className)}>
|
<div className={cn("space-y-4", className)}>
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-destructive/30 bg-destructive/5 p-8 text-center">
|
<div className="border-destructive/30 bg-destructive/5 flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center">
|
||||||
<p className="text-sm font-medium text-destructive">필드가 정의되지 않았습니다</p>
|
<p className="text-destructive text-sm font-medium">필드가 정의되지 않았습니다</p>
|
||||||
<p className="mt-2 text-xs text-muted-foreground">속성 패널에서 필드를 추가하세요.</p>
|
<p className="text-muted-foreground mt-2 text-xs">속성 패널에서 필드를 추가하세요.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -706,8 +726,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-4", className)}>
|
<div className={cn("space-y-4", className)}>
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30 p-8 text-center">
|
<div className="border-border bg-muted/30 flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center">
|
||||||
<p className="mb-4 text-sm text-muted-foreground">{emptyMessage}</p>
|
<p className="text-muted-foreground mb-4 text-sm">{emptyMessage}</p>
|
||||||
{!readonly && !disabled && items.length < maxItems && (
|
{!readonly && !disabled && items.length < maxItems && (
|
||||||
<Button type="button" onClick={handleAddItem} size="sm">
|
<Button type="button" onClick={handleAddItem} size="sm">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
|
@ -740,7 +760,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
{fields.map((field) => (
|
{fields.map((field) => (
|
||||||
<TableHead key={field.name} className="h-10 px-2.5 py-2 text-sm font-semibold">
|
<TableHead key={field.name} className="h-10 px-2.5 py-2 text-sm font-semibold">
|
||||||
{field.label}
|
{field.label}
|
||||||
{field.required && <span className="ml-1 text-destructive">*</span>}
|
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
<TableHead className="h-10 w-14 px-2.5 py-2 text-center text-sm font-semibold">작업</TableHead>
|
<TableHead className="h-10 w-14 px-2.5 py-2 text-center text-sm font-semibold">작업</TableHead>
|
||||||
|
|
@ -751,7 +771,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
<TableRow
|
<TableRow
|
||||||
key={itemIndex}
|
key={itemIndex}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background transition-colors hover:bg-muted/50",
|
"bg-background hover:bg-muted/50 transition-colors",
|
||||||
draggedIndex === itemIndex && "opacity-50",
|
draggedIndex === itemIndex && "opacity-50",
|
||||||
)}
|
)}
|
||||||
draggable={allowReorder && !readonly && !disabled}
|
draggable={allowReorder && !readonly && !disabled}
|
||||||
|
|
@ -762,15 +782,13 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
>
|
>
|
||||||
{/* 인덱스 번호 */}
|
{/* 인덱스 번호 */}
|
||||||
{showIndex && (
|
{showIndex && (
|
||||||
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">
|
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">{itemIndex + 1}</TableCell>
|
||||||
{itemIndex + 1}
|
|
||||||
</TableCell>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 드래그 핸들 */}
|
{/* 드래그 핸들 */}
|
||||||
{allowReorder && !readonly && !disabled && (
|
{allowReorder && !readonly && !disabled && (
|
||||||
<TableCell className="h-12 px-2.5 py-2 text-center">
|
<TableCell className="h-12 px-2.5 py-2 text-center">
|
||||||
<GripVertical className="h-4 w-4 cursor-move text-muted-foreground" />
|
<GripVertical className="text-muted-foreground h-4 w-4 cursor-move" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -789,7 +807,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleRemoveItem(itemIndex)}
|
onClick={() => handleRemoveItem(itemIndex)}
|
||||||
className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-8 w-8"
|
||||||
title="항목 제거"
|
title="항목 제거"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
|
|
@ -829,12 +847,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* 드래그 핸들 */}
|
{/* 드래그 핸들 */}
|
||||||
{allowReorder && !readonly && !disabled && (
|
{allowReorder && !readonly && !disabled && (
|
||||||
<GripVertical className="h-4 w-4 flex-shrink-0 cursor-move text-muted-foreground" />
|
<GripVertical className="text-muted-foreground h-4 w-4 flex-shrink-0 cursor-move" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 인덱스 번호 */}
|
{/* 인덱스 번호 */}
|
||||||
{showIndex && (
|
{showIndex && (
|
||||||
<CardTitle className="text-sm font-semibold text-foreground">항목 {itemIndex + 1}</CardTitle>
|
<CardTitle className="text-foreground text-sm font-semibold">항목 {itemIndex + 1}</CardTitle>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -859,7 +877,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleRemoveItem(itemIndex)}
|
onClick={() => handleRemoveItem(itemIndex)}
|
||||||
className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-8 w-8"
|
||||||
title="항목 제거"
|
title="항목 제거"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
|
|
@ -873,9 +891,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
<div className={getFieldsLayoutClass()}>
|
<div className={getFieldsLayoutClass()}>
|
||||||
{fields.map((field) => (
|
{fields.map((field) => (
|
||||||
<div key={field.name} className="space-y-1" style={{ width: field.width }}>
|
<div key={field.name} className="space-y-1" style={{ width: field.width }}>
|
||||||
<label className="text-sm font-medium text-foreground">
|
<label className="text-foreground text-sm font-medium">
|
||||||
{field.label}
|
{field.label}
|
||||||
{field.required && <span className="ml-1 text-destructive">*</span>}
|
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||||
</label>
|
</label>
|
||||||
{renderField(field, itemIndex, item[field.name])}
|
{renderField(field, itemIndex, item[field.name])}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -906,7 +924,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 제한 안내 */}
|
{/* 제한 안내 */}
|
||||||
<div className="flex justify-between text-xs text-muted-foreground">
|
<div className="text-muted-foreground flex justify-between text-xs">
|
||||||
<span>현재: {items.length}개 항목</span>
|
<span>현재: {items.length}개 항목</span>
|
||||||
<span>
|
<span>
|
||||||
(최소: {minItems}, 최대: {maxItems})
|
(최소: {minItems}, 최대: {maxItems})
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,13 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Plus, X, GripVertical, Check, ChevronsUpDown, Calculator } from "lucide-react";
|
import { Plus, X, GripVertical, Check, ChevronsUpDown, Calculator } from "lucide-react";
|
||||||
import { RepeaterFieldGroupConfig, RepeaterFieldDefinition, RepeaterFieldType, CalculationOperator, CalculationFormula } from "@/types/repeater";
|
import {
|
||||||
|
RepeaterFieldGroupConfig,
|
||||||
|
RepeaterFieldDefinition,
|
||||||
|
RepeaterFieldType,
|
||||||
|
CalculationOperator,
|
||||||
|
CalculationFormula,
|
||||||
|
} from "@/types/repeater";
|
||||||
import { ColumnInfo } from "@/types/screen";
|
import { ColumnInfo } from "@/types/screen";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -34,10 +40,10 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const [localFields, setLocalFields] = useState<RepeaterFieldDefinition[]>(config.fields || []);
|
const [localFields, setLocalFields] = useState<RepeaterFieldDefinition[]>(config.fields || []);
|
||||||
const [fieldNamePopoverOpen, setFieldNamePopoverOpen] = useState<Record<number, boolean>>({});
|
const [fieldNamePopoverOpen, setFieldNamePopoverOpen] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
// 로컬 입력 상태 (각 필드의 라벨, placeholder 등)
|
// 로컬 입력 상태 (각 필드의 라벨, placeholder 등)
|
||||||
const [localInputs, setLocalInputs] = useState<Record<number, { label: string; placeholder: string }>>({});
|
const [localInputs, setLocalInputs] = useState<Record<number, { label: string; placeholder: string }>>({});
|
||||||
|
|
||||||
// 설정 입력 필드의 로컬 상태
|
// 설정 입력 필드의 로컬 상태
|
||||||
const [localConfigInputs, setLocalConfigInputs] = useState({
|
const [localConfigInputs, setLocalConfigInputs] = useState({
|
||||||
addButtonText: config.addButtonText || "",
|
addButtonText: config.addButtonText || "",
|
||||||
|
|
@ -88,13 +94,13 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
// 필드 수정 (입력 중 - 로컬 상태만)
|
// 필드 수정 (입력 중 - 로컬 상태만)
|
||||||
const updateFieldLocal = (index: number, field: 'label' | 'placeholder', value: string) => {
|
const updateFieldLocal = (index: number, field: "label" | "placeholder", value: string) => {
|
||||||
setLocalInputs(prev => ({
|
setLocalInputs((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[index]: {
|
[index]: {
|
||||||
...prev[index],
|
...prev[index],
|
||||||
[field]: value
|
[field]: value,
|
||||||
}
|
},
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -106,7 +112,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
newFields[index] = {
|
newFields[index] = {
|
||||||
...newFields[index],
|
...newFields[index],
|
||||||
label: localInput.label,
|
label: localInput.label,
|
||||||
placeholder: localInput.placeholder
|
placeholder: localInput.placeholder,
|
||||||
};
|
};
|
||||||
handleFieldsChange(newFields);
|
handleFieldsChange(newFields);
|
||||||
}
|
}
|
||||||
|
|
@ -218,6 +224,32 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 🆕 FK 컬럼 설정 (분할 패널용) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">FK 연결 컬럼 (분할 패널용)</Label>
|
||||||
|
<Select
|
||||||
|
value={(config as any).fkColumn || "__none__"}
|
||||||
|
onValueChange={(value) => handleChange("fkColumn" as any, value === "__none__" ? undefined : value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9">
|
||||||
|
<SelectValue placeholder="FK 컬럼 선택 (선택사항)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">사용 안함 (그룹화 컬럼 사용)</SelectItem>
|
||||||
|
{tableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
분할 패널에서 좌측 테이블 선택 시 이 컬럼을 기준으로 데이터를 필터링합니다.
|
||||||
|
<br />
|
||||||
|
예: serial_no를 선택하면 좌측에서 선택한 장비의 serial_no에 해당하는 데이터만 표시됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 필드 정의 */}
|
{/* 필드 정의 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-sm font-semibold">필드 정의</Label>
|
<Label className="text-sm font-semibold">필드 정의</Label>
|
||||||
|
|
@ -263,8 +295,9 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
// input_type (DB에서 설정한 타입) 우선 사용, 없으면 webType/widgetType
|
// input_type (DB에서 설정한 타입) 우선 사용, 없으면 webType/widgetType
|
||||||
const col = column as any;
|
const col = column as any;
|
||||||
const fieldType = col.input_type || col.inputType || col.webType || col.widgetType || "text";
|
const fieldType =
|
||||||
|
col.input_type || col.inputType || col.webType || col.widgetType || "text";
|
||||||
|
|
||||||
console.log("🔍 [RepeaterConfigPanel] 필드 타입 결정:", {
|
console.log("🔍 [RepeaterConfigPanel] 필드 타입 결정:", {
|
||||||
columnName: column.columnName,
|
columnName: column.columnName,
|
||||||
input_type: col.input_type,
|
input_type: col.input_type,
|
||||||
|
|
@ -273,19 +306,19 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
widgetType: col.widgetType,
|
widgetType: col.widgetType,
|
||||||
finalType: fieldType,
|
finalType: fieldType,
|
||||||
});
|
});
|
||||||
|
|
||||||
updateField(index, {
|
updateField(index, {
|
||||||
name: column.columnName,
|
name: column.columnName,
|
||||||
label: column.columnLabel || column.columnName,
|
label: column.columnLabel || column.columnName,
|
||||||
type: fieldType as RepeaterFieldType,
|
type: fieldType as RepeaterFieldType,
|
||||||
});
|
});
|
||||||
// 로컬 입력 상태도 업데이트
|
// 로컬 입력 상태도 업데이트
|
||||||
setLocalInputs(prev => ({
|
setLocalInputs((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[index]: {
|
[index]: {
|
||||||
label: column.columnLabel || column.columnName,
|
label: column.columnLabel || column.columnName,
|
||||||
placeholder: prev[index]?.placeholder || ""
|
placeholder: prev[index]?.placeholder || "",
|
||||||
}
|
},
|
||||||
}));
|
}));
|
||||||
setFieldNamePopoverOpen({ ...fieldNamePopoverOpen, [index]: false });
|
setFieldNamePopoverOpen({ ...fieldNamePopoverOpen, [index]: false });
|
||||||
}}
|
}}
|
||||||
|
|
@ -313,7 +346,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<Label className="text-xs">라벨</Label>
|
<Label className="text-xs">라벨</Label>
|
||||||
<Input
|
<Input
|
||||||
value={localInputs[index]?.label !== undefined ? localInputs[index].label : field.label}
|
value={localInputs[index]?.label !== undefined ? localInputs[index].label : field.label}
|
||||||
onChange={(e) => updateFieldLocal(index, 'label', e.target.value)}
|
onChange={(e) => updateFieldLocal(index, "label", e.target.value)}
|
||||||
onBlur={() => handleFieldBlur(index)}
|
onBlur={() => handleFieldBlur(index)}
|
||||||
placeholder="필드 라벨"
|
placeholder="필드 라벨"
|
||||||
className="h-8 w-full text-xs"
|
className="h-8 w-full text-xs"
|
||||||
|
|
@ -358,8 +391,12 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">Placeholder</Label>
|
<Label className="text-xs">Placeholder</Label>
|
||||||
<Input
|
<Input
|
||||||
value={localInputs[index]?.placeholder !== undefined ? localInputs[index].placeholder : (field.placeholder || "")}
|
value={
|
||||||
onChange={(e) => updateFieldLocal(index, 'placeholder', e.target.value)}
|
localInputs[index]?.placeholder !== undefined
|
||||||
|
? localInputs[index].placeholder
|
||||||
|
: field.placeholder || ""
|
||||||
|
}
|
||||||
|
onChange={(e) => updateFieldLocal(index, "placeholder", e.target.value)}
|
||||||
onBlur={() => handleFieldBlur(index)}
|
onBlur={() => handleFieldBlur(index)}
|
||||||
placeholder="입력 안내"
|
placeholder="입력 안내"
|
||||||
className="h-8 w-full text-xs"
|
className="h-8 w-full text-xs"
|
||||||
|
|
@ -374,15 +411,17 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<Calculator className="h-4 w-4 text-blue-600" />
|
<Calculator className="h-4 w-4 text-blue-600" />
|
||||||
<Label className="text-xs font-semibold text-blue-800">계산식 설정</Label>
|
<Label className="text-xs font-semibold text-blue-800">계산식 설정</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 필드 1 선택 */}
|
{/* 필드 1 선택 */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-[10px] text-blue-700">필드 1</Label>
|
<Label className="text-[10px] text-blue-700">필드 1</Label>
|
||||||
<Select
|
<Select
|
||||||
value={field.formula?.field1 || ""}
|
value={field.formula?.field1 || ""}
|
||||||
onValueChange={(value) => updateField(index, {
|
onValueChange={(value) =>
|
||||||
formula: { ...field.formula, field1: value } as CalculationFormula
|
updateField(index, {
|
||||||
})}
|
formula: { ...field.formula, field1: value } as CalculationFormula,
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue placeholder="필드 선택" />
|
<SelectValue placeholder="필드 선택" />
|
||||||
|
|
@ -398,54 +437,75 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 연산자 선택 */}
|
{/* 연산자 선택 */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-[10px] text-blue-700">연산자</Label>
|
<Label className="text-[10px] text-blue-700">연산자</Label>
|
||||||
<Select
|
<Select
|
||||||
value={field.formula?.operator || "+"}
|
value={field.formula?.operator || "+"}
|
||||||
onValueChange={(value) => updateField(index, {
|
onValueChange={(value) =>
|
||||||
formula: { ...field.formula, operator: value as CalculationOperator } as CalculationFormula
|
updateField(index, {
|
||||||
})}
|
formula: { ...field.formula, operator: value as CalculationOperator } as CalculationFormula,
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="z-[9999]">
|
<SelectContent className="z-[9999]">
|
||||||
<SelectItem value="+" className="text-xs">+ 더하기</SelectItem>
|
<SelectItem value="+" className="text-xs">
|
||||||
<SelectItem value="-" className="text-xs">- 빼기</SelectItem>
|
+ 더하기
|
||||||
<SelectItem value="*" className="text-xs">× 곱하기</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="/" className="text-xs">÷ 나누기</SelectItem>
|
<SelectItem value="-" className="text-xs">
|
||||||
<SelectItem value="%" className="text-xs">% 나머지</SelectItem>
|
- 빼기
|
||||||
<SelectItem value="round" className="text-xs">반올림</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="floor" className="text-xs">내림</SelectItem>
|
<SelectItem value="*" className="text-xs">
|
||||||
<SelectItem value="ceil" className="text-xs">올림</SelectItem>
|
× 곱하기
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="/" className="text-xs">
|
||||||
|
÷ 나누기
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="%" className="text-xs">
|
||||||
|
% 나머지
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="round" className="text-xs">
|
||||||
|
반올림
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="floor" className="text-xs">
|
||||||
|
내림
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="ceil" className="text-xs">
|
||||||
|
올림
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 두 번째 필드 또는 상수값 */}
|
{/* 두 번째 필드 또는 상수값 */}
|
||||||
{!["round", "floor", "ceil", "abs"].includes(field.formula?.operator || "") ? (
|
{!["round", "floor", "ceil", "abs"].includes(field.formula?.operator || "") ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-[10px] text-blue-700">필드 2 / 상수</Label>
|
<Label className="text-[10px] text-blue-700">필드 2 / 상수</Label>
|
||||||
<Select
|
<Select
|
||||||
value={field.formula?.field2 || (field.formula?.constantValue !== undefined ? `__const__${field.formula.constantValue}` : "")}
|
value={
|
||||||
|
field.formula?.field2 ||
|
||||||
|
(field.formula?.constantValue !== undefined ? `__const__${field.formula.constantValue}` : "")
|
||||||
|
}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (value.startsWith("__const__")) {
|
if (value.startsWith("__const__")) {
|
||||||
updateField(index, {
|
updateField(index, {
|
||||||
formula: {
|
formula: {
|
||||||
...field.formula,
|
...field.formula,
|
||||||
field2: undefined,
|
field2: undefined,
|
||||||
constantValue: 0
|
constantValue: 0,
|
||||||
} as CalculationFormula
|
} as CalculationFormula,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
updateField(index, {
|
updateField(index, {
|
||||||
formula: {
|
formula: {
|
||||||
...field.formula,
|
...field.formula,
|
||||||
field2: value,
|
field2: value,
|
||||||
constantValue: undefined
|
constantValue: undefined,
|
||||||
} as CalculationFormula
|
} as CalculationFormula,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
@ -475,14 +535,19 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
min={0}
|
min={0}
|
||||||
max={10}
|
max={10}
|
||||||
value={field.formula?.decimalPlaces ?? 0}
|
value={field.formula?.decimalPlaces ?? 0}
|
||||||
onChange={(e) => updateField(index, {
|
onChange={(e) =>
|
||||||
formula: { ...field.formula, decimalPlaces: parseInt(e.target.value) || 0 } as CalculationFormula
|
updateField(index, {
|
||||||
})}
|
formula: {
|
||||||
|
...field.formula,
|
||||||
|
decimalPlaces: parseInt(e.target.value) || 0,
|
||||||
|
} as CalculationFormula,
|
||||||
|
})
|
||||||
|
}
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 상수값 입력 필드 */}
|
{/* 상수값 입력 필드 */}
|
||||||
{field.formula?.constantValue !== undefined && (
|
{field.formula?.constantValue !== undefined && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -490,15 +555,20 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={field.formula.constantValue}
|
value={field.formula.constantValue}
|
||||||
onChange={(e) => updateField(index, {
|
onChange={(e) =>
|
||||||
formula: { ...field.formula, constantValue: parseFloat(e.target.value) || 0 } as CalculationFormula
|
updateField(index, {
|
||||||
})}
|
formula: {
|
||||||
|
...field.formula,
|
||||||
|
constantValue: parseFloat(e.target.value) || 0,
|
||||||
|
} as CalculationFormula,
|
||||||
|
})
|
||||||
|
}
|
||||||
placeholder="숫자 입력"
|
placeholder="숫자 입력"
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 숫자 포맷 설정 */}
|
{/* 숫자 포맷 설정 */}
|
||||||
<div className="space-y-2 border-t border-blue-200 pt-2">
|
<div className="space-y-2 border-t border-blue-200 pt-2">
|
||||||
<Label className="text-[10px] text-blue-700">숫자 표시 형식</Label>
|
<Label className="text-[10px] text-blue-700">숫자 표시 형식</Label>
|
||||||
|
|
@ -507,9 +577,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={`thousand-sep-${index}`}
|
id={`thousand-sep-${index}`}
|
||||||
checked={field.numberFormat?.useThousandSeparator ?? true}
|
checked={field.numberFormat?.useThousandSeparator ?? true}
|
||||||
onCheckedChange={(checked) => updateField(index, {
|
onCheckedChange={(checked) =>
|
||||||
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean }
|
updateField(index, {
|
||||||
})}
|
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean },
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor={`thousand-sep-${index}`} className="cursor-pointer text-[10px]">
|
<Label htmlFor={`thousand-sep-${index}`} className="cursor-pointer text-[10px]">
|
||||||
천 단위 구분자
|
천 단위 구분자
|
||||||
|
|
@ -519,9 +591,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<Label className="text-[10px]">소수점:</Label>
|
<Label className="text-[10px]">소수점:</Label>
|
||||||
<Input
|
<Input
|
||||||
value={field.numberFormat?.decimalPlaces ?? 0}
|
value={field.numberFormat?.decimalPlaces ?? 0}
|
||||||
onChange={(e) => updateField(index, {
|
onChange={(e) =>
|
||||||
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 }
|
updateField(index, {
|
||||||
})}
|
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 },
|
||||||
|
})
|
||||||
|
}
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={10}
|
max={10}
|
||||||
|
|
@ -532,31 +606,34 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={field.numberFormat?.prefix || ""}
|
value={field.numberFormat?.prefix || ""}
|
||||||
onChange={(e) => updateField(index, {
|
onChange={(e) =>
|
||||||
numberFormat: { ...field.numberFormat, prefix: e.target.value }
|
updateField(index, {
|
||||||
})}
|
numberFormat: { ...field.numberFormat, prefix: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
placeholder="접두사 (₩)"
|
placeholder="접두사 (₩)"
|
||||||
className="h-7 text-[10px]"
|
className="h-7 text-[10px]"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
value={field.numberFormat?.suffix || ""}
|
value={field.numberFormat?.suffix || ""}
|
||||||
onChange={(e) => updateField(index, {
|
onChange={(e) =>
|
||||||
numberFormat: { ...field.numberFormat, suffix: e.target.value }
|
updateField(index, {
|
||||||
})}
|
numberFormat: { ...field.numberFormat, suffix: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
placeholder="접미사 (원)"
|
placeholder="접미사 (원)"
|
||||||
className="h-7 text-[10px]"
|
className="h-7 text-[10px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 계산식 미리보기 */}
|
{/* 계산식 미리보기 */}
|
||||||
<div className="rounded bg-white p-2 text-xs">
|
<div className="rounded bg-white p-2 text-xs">
|
||||||
<span className="text-gray-500">계산식: </span>
|
<span className="text-gray-500">계산식: </span>
|
||||||
<code className="font-mono text-blue-700">
|
<code className="font-mono text-blue-700">
|
||||||
{field.formula?.field1 || "필드1"} {field.formula?.operator || "+"} {
|
{field.formula?.field1 || "필드1"} {field.formula?.operator || "+"}{" "}
|
||||||
field.formula?.field2 ||
|
{field.formula?.field2 ||
|
||||||
(field.formula?.constantValue !== undefined ? field.formula.constantValue : "필드2")
|
(field.formula?.constantValue !== undefined ? field.formula.constantValue : "필드2")}
|
||||||
}
|
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -571,9 +648,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={`number-thousand-sep-${index}`}
|
id={`number-thousand-sep-${index}`}
|
||||||
checked={field.numberFormat?.useThousandSeparator ?? false}
|
checked={field.numberFormat?.useThousandSeparator ?? false}
|
||||||
onCheckedChange={(checked) => updateField(index, {
|
onCheckedChange={(checked) =>
|
||||||
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean }
|
updateField(index, {
|
||||||
})}
|
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean },
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor={`number-thousand-sep-${index}`} className="cursor-pointer text-[10px]">
|
<Label htmlFor={`number-thousand-sep-${index}`} className="cursor-pointer text-[10px]">
|
||||||
천 단위 구분자
|
천 단위 구분자
|
||||||
|
|
@ -583,9 +662,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<Label className="text-[10px]">소수점:</Label>
|
<Label className="text-[10px]">소수점:</Label>
|
||||||
<Input
|
<Input
|
||||||
value={field.numberFormat?.decimalPlaces ?? 0}
|
value={field.numberFormat?.decimalPlaces ?? 0}
|
||||||
onChange={(e) => updateField(index, {
|
onChange={(e) =>
|
||||||
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 }
|
updateField(index, {
|
||||||
})}
|
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 },
|
||||||
|
})
|
||||||
|
}
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={10}
|
max={10}
|
||||||
|
|
@ -596,17 +677,21 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={field.numberFormat?.prefix || ""}
|
value={field.numberFormat?.prefix || ""}
|
||||||
onChange={(e) => updateField(index, {
|
onChange={(e) =>
|
||||||
numberFormat: { ...field.numberFormat, prefix: e.target.value }
|
updateField(index, {
|
||||||
})}
|
numberFormat: { ...field.numberFormat, prefix: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
placeholder="접두사 (₩)"
|
placeholder="접두사 (₩)"
|
||||||
className="h-7 text-[10px]"
|
className="h-7 text-[10px]"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
value={field.numberFormat?.suffix || ""}
|
value={field.numberFormat?.suffix || ""}
|
||||||
onChange={(e) => updateField(index, {
|
onChange={(e) =>
|
||||||
numberFormat: { ...field.numberFormat, suffix: e.target.value }
|
updateField(index, {
|
||||||
})}
|
numberFormat: { ...field.numberFormat, suffix: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
placeholder="접미사 (원)"
|
placeholder="접미사 (원)"
|
||||||
className="h-7 text-[10px]"
|
className="h-7 text-[10px]"
|
||||||
/>
|
/>
|
||||||
|
|
@ -624,7 +709,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
||||||
placeholder="카테고리 코드 (예: INBOUND_TYPE)"
|
placeholder="카테고리 코드 (예: INBOUND_TYPE)"
|
||||||
className="h-8 w-full text-xs"
|
className="h-8 w-full text-xs"
|
||||||
/>
|
/>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-muted-foreground text-[10px]">
|
||||||
카테고리 관리에서 설정한 색상으로 배지가 표시됩니다
|
카테고리 관리에서 설정한 색상으로 배지가 표시됩니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { createContext, useContext, useCallback, useRef } from "react";
|
import React, { createContext, useContext, useCallback, useRef, useState } from "react";
|
||||||
import type { DataProvidable, DataReceivable } from "@/types/data-transfer";
|
import type { DataProvidable, DataReceivable } from "@/types/data-transfer";
|
||||||
import { logger } from "@/lib/utils/logger";
|
import { logger } from "@/lib/utils/logger";
|
||||||
import type { SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
import type { SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||||
|
|
@ -14,17 +14,21 @@ interface ScreenContextValue {
|
||||||
screenId?: number;
|
screenId?: number;
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right)
|
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right)
|
||||||
|
|
||||||
|
// 🆕 폼 데이터 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
|
||||||
|
formData: Record<string, any>;
|
||||||
|
updateFormData: (fieldName: string, value: any) => void;
|
||||||
|
|
||||||
// 컴포넌트 등록
|
// 컴포넌트 등록
|
||||||
registerDataProvider: (componentId: string, provider: DataProvidable) => void;
|
registerDataProvider: (componentId: string, provider: DataProvidable) => void;
|
||||||
unregisterDataProvider: (componentId: string) => void;
|
unregisterDataProvider: (componentId: string) => void;
|
||||||
registerDataReceiver: (componentId: string, receiver: DataReceivable) => void;
|
registerDataReceiver: (componentId: string, receiver: DataReceivable) => void;
|
||||||
unregisterDataReceiver: (componentId: string) => void;
|
unregisterDataReceiver: (componentId: string) => void;
|
||||||
|
|
||||||
// 컴포넌트 조회
|
// 컴포넌트 조회
|
||||||
getDataProvider: (componentId: string) => DataProvidable | undefined;
|
getDataProvider: (componentId: string) => DataProvidable | undefined;
|
||||||
getDataReceiver: (componentId: string) => DataReceivable | undefined;
|
getDataReceiver: (componentId: string) => DataReceivable | undefined;
|
||||||
|
|
||||||
// 모든 컴포넌트 조회
|
// 모든 컴포넌트 조회
|
||||||
getAllDataProviders: () => Map<string, DataProvidable>;
|
getAllDataProviders: () => Map<string, DataProvidable>;
|
||||||
getAllDataReceivers: () => Map<string, DataReceivable>;
|
getAllDataReceivers: () => Map<string, DataReceivable>;
|
||||||
|
|
@ -42,10 +46,31 @@ interface ScreenContextProviderProps {
|
||||||
/**
|
/**
|
||||||
* 화면 컨텍스트 프로바이더
|
* 화면 컨텍스트 프로바이더
|
||||||
*/
|
*/
|
||||||
export function ScreenContextProvider({ screenId, tableName, splitPanelPosition, children }: ScreenContextProviderProps) {
|
export function ScreenContextProvider({
|
||||||
|
screenId,
|
||||||
|
tableName,
|
||||||
|
splitPanelPosition,
|
||||||
|
children,
|
||||||
|
}: ScreenContextProviderProps) {
|
||||||
const dataProvidersRef = useRef<Map<string, DataProvidable>>(new Map());
|
const dataProvidersRef = useRef<Map<string, DataProvidable>>(new Map());
|
||||||
const dataReceiversRef = useRef<Map<string, DataReceivable>>(new Map());
|
const dataReceiversRef = useRef<Map<string, DataReceivable>>(new Map());
|
||||||
|
|
||||||
|
// 🆕 폼 데이터 상태 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
|
||||||
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
|
// 🆕 폼 데이터 업데이트 함수
|
||||||
|
const updateFormData = useCallback((fieldName: string, value: any) => {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const updated = { ...prev, [fieldName]: value };
|
||||||
|
logger.debug("ScreenContext formData 업데이트", {
|
||||||
|
fieldName,
|
||||||
|
valueType: typeof value,
|
||||||
|
isArray: Array.isArray(value),
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const registerDataProvider = useCallback((componentId: string, provider: DataProvidable) => {
|
const registerDataProvider = useCallback((componentId: string, provider: DataProvidable) => {
|
||||||
dataProvidersRef.current.set(componentId, provider);
|
dataProvidersRef.current.set(componentId, provider);
|
||||||
logger.debug("데이터 제공자 등록", { componentId, componentType: provider.componentType });
|
logger.debug("데이터 제공자 등록", { componentId, componentType: provider.componentType });
|
||||||
|
|
@ -83,31 +108,38 @@ export function ScreenContextProvider({ screenId, tableName, splitPanelPosition,
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
|
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
|
||||||
const value = React.useMemo<ScreenContextValue>(() => ({
|
const value = React.useMemo<ScreenContextValue>(
|
||||||
screenId,
|
() => ({
|
||||||
tableName,
|
screenId,
|
||||||
splitPanelPosition,
|
tableName,
|
||||||
registerDataProvider,
|
splitPanelPosition,
|
||||||
unregisterDataProvider,
|
formData,
|
||||||
registerDataReceiver,
|
updateFormData,
|
||||||
unregisterDataReceiver,
|
registerDataProvider,
|
||||||
getDataProvider,
|
unregisterDataProvider,
|
||||||
getDataReceiver,
|
registerDataReceiver,
|
||||||
getAllDataProviders,
|
unregisterDataReceiver,
|
||||||
getAllDataReceivers,
|
getDataProvider,
|
||||||
}), [
|
getDataReceiver,
|
||||||
screenId,
|
getAllDataProviders,
|
||||||
tableName,
|
getAllDataReceivers,
|
||||||
splitPanelPosition,
|
}),
|
||||||
registerDataProvider,
|
[
|
||||||
unregisterDataProvider,
|
screenId,
|
||||||
registerDataReceiver,
|
tableName,
|
||||||
unregisterDataReceiver,
|
splitPanelPosition,
|
||||||
getDataProvider,
|
formData,
|
||||||
getDataReceiver,
|
updateFormData,
|
||||||
getAllDataProviders,
|
registerDataProvider,
|
||||||
getAllDataReceivers,
|
unregisterDataProvider,
|
||||||
]);
|
registerDataReceiver,
|
||||||
|
unregisterDataReceiver,
|
||||||
|
getDataProvider,
|
||||||
|
getDataReceiver,
|
||||||
|
getAllDataProviders,
|
||||||
|
getAllDataReceivers,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return <ScreenContext.Provider value={value}>{children}</ScreenContext.Provider>;
|
return <ScreenContext.Provider value={value}>{children}</ScreenContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
@ -130,4 +162,3 @@ export function useScreenContext() {
|
||||||
export function useScreenContextOptional() {
|
export function useScreenContextOptional() {
|
||||||
return useContext(ScreenContext);
|
return useContext(ScreenContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -907,8 +907,27 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 분할 패널 우측이면 screenContext.formData와 props.formData를 병합
|
||||||
|
// screenContext.formData: RepeaterFieldGroup 등 컴포넌트가 직접 업데이트한 데이터
|
||||||
|
// props.formData: 부모에서 전달된 폼 데이터
|
||||||
|
const screenContextFormData = screenContext?.formData || {};
|
||||||
|
const propsFormData = formData || {};
|
||||||
|
|
||||||
|
// 병합: props.formData를 기본으로 하고, screenContext.formData로 오버라이드
|
||||||
|
// (RepeaterFieldGroup 데이터는 screenContext에만 있음)
|
||||||
|
const effectiveFormData = { ...propsFormData, ...screenContextFormData };
|
||||||
|
|
||||||
|
console.log("🔍 [ButtonPrimary] formData 선택:", {
|
||||||
|
hasScreenContextFormData: Object.keys(screenContextFormData).length > 0,
|
||||||
|
screenContextKeys: Object.keys(screenContextFormData),
|
||||||
|
hasPropsFormData: Object.keys(propsFormData).length > 0,
|
||||||
|
propsFormDataKeys: Object.keys(propsFormData),
|
||||||
|
splitPanelPosition,
|
||||||
|
effectiveFormDataKeys: Object.keys(effectiveFormData),
|
||||||
|
});
|
||||||
|
|
||||||
const context: ButtonActionContext = {
|
const context: ButtonActionContext = {
|
||||||
formData: formData || {},
|
formData: effectiveFormData,
|
||||||
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
|
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
|
||||||
screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용
|
screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용
|
||||||
tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용
|
tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용
|
||||||
|
|
|
||||||
|
|
@ -20,24 +20,56 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
const screenContext = useScreenContextOptional();
|
const screenContext = useScreenContextOptional();
|
||||||
const splitPanelContext = useSplitPanelContext();
|
const splitPanelContext = useSplitPanelContext();
|
||||||
const receiverRef = useRef<DataReceivable | null>(null);
|
const receiverRef = useRef<DataReceivable | null>(null);
|
||||||
|
|
||||||
// 🆕 그룹화된 데이터를 저장하는 상태
|
// 🆕 그룹화된 데이터를 저장하는 상태
|
||||||
const [groupedData, setGroupedData] = useState<any[] | null>(null);
|
const [groupedData, setGroupedData] = useState<any[] | null>(null);
|
||||||
const [isLoadingGroupData, setIsLoadingGroupData] = useState(false);
|
const [isLoadingGroupData, setIsLoadingGroupData] = useState(false);
|
||||||
const groupDataLoadedRef = useRef(false);
|
const groupDataLoadedRef = useRef(false);
|
||||||
|
|
||||||
// 🆕 원본 데이터 ID 목록 (삭제 추적용)
|
// 🆕 원본 데이터 ID 목록 (삭제 추적용)
|
||||||
const [originalItemIds, setOriginalItemIds] = useState<string[]>([]);
|
const [originalItemIds, setOriginalItemIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 🆕 DB에서 로드한 컬럼 정보 (webType 등)
|
||||||
|
const [columnInfo, setColumnInfo] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
// 컴포넌트의 필드명 (formData 키)
|
// 컴포넌트의 필드명 (formData 키)
|
||||||
const fieldName = (component as any).columnName || component.id;
|
const fieldName = (component as any).columnName || component.id;
|
||||||
|
|
||||||
// repeaterConfig 또는 componentConfig에서 설정 가져오기
|
// repeaterConfig 또는 componentConfig에서 설정 가져오기
|
||||||
const config = (component as any).repeaterConfig || component.componentConfig || { fields: [] };
|
const rawConfig = (component as any).repeaterConfig || component.componentConfig || { fields: [] };
|
||||||
|
|
||||||
// 🆕 그룹화 설정 (예: groupByColumn: "inbound_number")
|
// 🆕 그룹화 설정 (예: groupByColumn: "inbound_number")
|
||||||
const groupByColumn = config.groupByColumn;
|
const groupByColumn = rawConfig.groupByColumn;
|
||||||
const targetTable = config.targetTable;
|
const targetTable = rawConfig.targetTable;
|
||||||
|
|
||||||
|
// 🆕 DB 컬럼 정보를 적용한 config 생성 (webType → type 매핑)
|
||||||
|
const config = useMemo(() => {
|
||||||
|
const rawFields = rawConfig.fields || [];
|
||||||
|
console.log("📋 [RepeaterFieldGroup] config 생성:", {
|
||||||
|
rawFieldsCount: rawFields.length,
|
||||||
|
rawFieldNames: rawFields.map((f: any) => f.name),
|
||||||
|
columnInfoKeys: Object.keys(columnInfo),
|
||||||
|
hasColumnInfo: Object.keys(columnInfo).length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fields = rawFields.map((field: any) => {
|
||||||
|
const colInfo = columnInfo[field.name];
|
||||||
|
// DB의 webType 또는 web_type을 field.type으로 적용
|
||||||
|
const dbWebType = colInfo?.webType || colInfo?.web_type;
|
||||||
|
|
||||||
|
// 타입 오버라이드 조건:
|
||||||
|
// 1. field.type이 없거나
|
||||||
|
// 2. field.type이 'direct'(기본값)이고 DB에 더 구체적인 타입이 있는 경우
|
||||||
|
const shouldOverride = !field.type || (field.type === "direct" && dbWebType && dbWebType !== "text");
|
||||||
|
|
||||||
|
if (colInfo && dbWebType && shouldOverride) {
|
||||||
|
console.log(`✅ [RepeaterFieldGroup] 필드 타입 매핑: ${field.name} → ${dbWebType}`);
|
||||||
|
return { ...field, type: dbWebType };
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
});
|
||||||
|
return { ...rawConfig, fields };
|
||||||
|
}, [rawConfig, columnInfo]);
|
||||||
|
|
||||||
// formData에서 값 가져오기 (value prop보다 우선)
|
// formData에서 값 가져오기 (value prop보다 우선)
|
||||||
const rawValue = formData?.[fieldName] ?? value;
|
const rawValue = formData?.[fieldName] ?? value;
|
||||||
|
|
@ -45,21 +77,127 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
// 🆕 수정 모드 감지: formData에 id가 있고, fieldName으로 값을 찾지 못한 경우
|
// 🆕 수정 모드 감지: formData에 id가 있고, fieldName으로 값을 찾지 못한 경우
|
||||||
// formData 자체를 배열의 첫 번째 항목으로 사용 (단일 행 수정 시)
|
// formData 자체를 배열의 첫 번째 항목으로 사용 (단일 행 수정 시)
|
||||||
const isEditMode = formData?.id && !rawValue && !value;
|
const isEditMode = formData?.id && !rawValue && !value;
|
||||||
|
|
||||||
// 🆕 반복 필드 그룹의 필드들이 formData에 있는지 확인
|
// 🆕 반복 필드 그룹의 필드들이 formData에 있는지 확인
|
||||||
const configFields = config.fields || [];
|
const configFields = config.fields || [];
|
||||||
const hasRepeaterFieldsInFormData = configFields.length > 0 &&
|
const hasRepeaterFieldsInFormData =
|
||||||
configFields.some((field: any) => formData?.[field.name] !== undefined);
|
configFields.length > 0 && configFields.some((field: any) => formData?.[field.name] !== undefined);
|
||||||
|
|
||||||
// 🆕 formData와 config.fields의 필드 이름 매칭 확인
|
// 🆕 formData와 config.fields의 필드 이름 매칭 확인
|
||||||
const matchingFields = configFields.filter((field: any) => formData?.[field.name] !== undefined);
|
const matchingFields = configFields.filter((field: any) => formData?.[field.name] !== undefined);
|
||||||
|
|
||||||
// 🆕 그룹 키 값 (예: formData.inbound_number)
|
// 🆕 그룹 키 값 (예: formData.inbound_number)
|
||||||
const groupKeyValue = groupByColumn ? formData?.[groupByColumn] : null;
|
const groupKeyValue = groupByColumn ? formData?.[groupByColumn] : null;
|
||||||
|
|
||||||
console.log("🔄 [RepeaterFieldGroup] 렌더링:", {
|
// 🆕 분할 패널 위치 및 좌측 선택 데이터 확인
|
||||||
fieldName,
|
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||||
hasFormData: !!formData,
|
const isRightPanel = splitPanelPosition === "right";
|
||||||
|
const selectedLeftData = splitPanelContext?.selectedLeftData;
|
||||||
|
|
||||||
|
// 🆕 연결 필터 설정에서 FK 컬럼 정보 가져오기
|
||||||
|
// screen-split-panel에서 설정한 linkedFilters 사용
|
||||||
|
const linkedFilters = splitPanelContext?.linkedFilters || [];
|
||||||
|
const getLinkedFilterValues = splitPanelContext?.getLinkedFilterValues;
|
||||||
|
|
||||||
|
// 🆕 FK 컬럼 설정 우선순위:
|
||||||
|
// 1. linkedFilters에서 targetTable에 해당하는 설정 찾기
|
||||||
|
// 2. config.fkColumn (컴포넌트 설정)
|
||||||
|
// 3. config.groupByColumn (그룹화 컬럼)
|
||||||
|
let fkSourceColumn: string | null = null;
|
||||||
|
let fkTargetColumn: string | null = null;
|
||||||
|
let linkedFilterTargetTable: string | null = null;
|
||||||
|
|
||||||
|
// linkedFilters에서 FK 컬럼 찾기
|
||||||
|
if (linkedFilters.length > 0 && selectedLeftData) {
|
||||||
|
// 첫 번째 linkedFilter 사용 (일반적으로 하나만 설정됨)
|
||||||
|
const linkedFilter = linkedFilters[0];
|
||||||
|
fkSourceColumn = linkedFilter.sourceColumn;
|
||||||
|
|
||||||
|
// targetColumn이 "테이블명.컬럼명" 형식일 수 있음 → 분리
|
||||||
|
// 예: "dtg_maintenance_history.serial_no" → table: "dtg_maintenance_history", column: "serial_no"
|
||||||
|
const targetColumnParts = linkedFilter.targetColumn.split(".");
|
||||||
|
if (targetColumnParts.length === 2) {
|
||||||
|
linkedFilterTargetTable = targetColumnParts[0];
|
||||||
|
fkTargetColumn = targetColumnParts[1];
|
||||||
|
} else {
|
||||||
|
fkTargetColumn = linkedFilter.targetColumn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 targetTable 우선순위: config.targetTable > linkedFilters에서 추출한 테이블
|
||||||
|
const effectiveTargetTable = targetTable || linkedFilterTargetTable;
|
||||||
|
|
||||||
|
// 🆕 DB에서 컬럼 정보 로드 (webType 등)
|
||||||
|
useEffect(() => {
|
||||||
|
const loadColumnInfo = async () => {
|
||||||
|
if (!effectiveTargetTable) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/table-management/tables/${effectiveTargetTable}/columns`);
|
||||||
|
console.log("📋 [RepeaterFieldGroup] 컬럼 정보 응답:", response.data);
|
||||||
|
|
||||||
|
// 응답 구조에 따라 데이터 추출
|
||||||
|
// 실제 응답: { success: true, data: { columns: [...], page, size, total, totalPages } }
|
||||||
|
let columns: any[] = [];
|
||||||
|
if (response.data?.success && response.data?.data) {
|
||||||
|
// data.columns가 배열인 경우 (실제 응답 구조)
|
||||||
|
if (Array.isArray(response.data.data.columns)) {
|
||||||
|
columns = response.data.data.columns;
|
||||||
|
}
|
||||||
|
// data가 배열인 경우
|
||||||
|
else if (Array.isArray(response.data.data)) {
|
||||||
|
columns = response.data.data;
|
||||||
|
}
|
||||||
|
// data 자체가 객체이고 배열이 아닌 경우 (키-값 형태)
|
||||||
|
else if (typeof response.data.data === "object") {
|
||||||
|
columns = Object.values(response.data.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// success 없이 바로 배열인 경우
|
||||||
|
else if (Array.isArray(response.data)) {
|
||||||
|
columns = response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("📋 [RepeaterFieldGroup] 파싱된 컬럼 배열:", columns.length, "개");
|
||||||
|
|
||||||
|
if (columns.length > 0) {
|
||||||
|
const colMap: Record<string, any> = {};
|
||||||
|
columns.forEach((col: any) => {
|
||||||
|
// columnName 또는 column_name 또는 name 키 사용
|
||||||
|
const colName = col.columnName || col.column_name || col.name;
|
||||||
|
if (colName) {
|
||||||
|
colMap[colName] = col;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setColumnInfo(colMap);
|
||||||
|
console.log("📋 [RepeaterFieldGroup] 컬럼 정보 로드 완료:", {
|
||||||
|
table: effectiveTargetTable,
|
||||||
|
columns: Object.keys(colMap),
|
||||||
|
webTypes: Object.entries(colMap).map(
|
||||||
|
([name, info]: [string, any]) => `${name}: ${info.webType || info.web_type || "unknown"}`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ [RepeaterFieldGroup] 컬럼 정보 로드 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadColumnInfo();
|
||||||
|
}, [effectiveTargetTable]);
|
||||||
|
|
||||||
|
// linkedFilters가 없으면 config에서 가져오기
|
||||||
|
const fkColumn = fkTargetColumn || config.fkColumn || config.groupByColumn;
|
||||||
|
const fkValue =
|
||||||
|
fkSourceColumn && selectedLeftData
|
||||||
|
? selectedLeftData[fkSourceColumn]
|
||||||
|
: fkColumn && selectedLeftData
|
||||||
|
? selectedLeftData[fkColumn]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
console.log("🔄 [RepeaterFieldGroup] 렌더링:", {
|
||||||
|
fieldName,
|
||||||
|
hasFormData: !!formData,
|
||||||
formDataId: formData?.id,
|
formDataId: formData?.id,
|
||||||
formDataValue: formData?.[fieldName],
|
formDataValue: formData?.[fieldName],
|
||||||
propsValue: value,
|
propsValue: value,
|
||||||
|
|
@ -72,8 +210,24 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
groupByColumn,
|
groupByColumn,
|
||||||
groupKeyValue,
|
groupKeyValue,
|
||||||
targetTable,
|
targetTable,
|
||||||
|
linkedFilterTargetTable,
|
||||||
|
effectiveTargetTable,
|
||||||
hasGroupedData: groupedData !== null,
|
hasGroupedData: groupedData !== null,
|
||||||
groupedDataLength: groupedData?.length,
|
groupedDataLength: groupedData?.length,
|
||||||
|
// 🆕 분할 패널 관련 정보
|
||||||
|
linkedFiltersCount: linkedFilters.length,
|
||||||
|
linkedFilters: linkedFilters.map((f) => `${f.sourceColumn} → ${f.targetColumn}`),
|
||||||
|
fkSourceColumn,
|
||||||
|
fkTargetColumn,
|
||||||
|
splitPanelPosition,
|
||||||
|
isRightPanel,
|
||||||
|
hasSelectedLeftData: !!selectedLeftData,
|
||||||
|
// 🆕 selectedLeftData 상세 정보 (디버깅용)
|
||||||
|
selectedLeftDataId: selectedLeftData?.id,
|
||||||
|
selectedLeftDataFkValue: fkSourceColumn ? selectedLeftData?.[fkSourceColumn] : "N/A",
|
||||||
|
selectedLeftData: selectedLeftData ? JSON.stringify(selectedLeftData).slice(0, 200) : null,
|
||||||
|
fkColumn,
|
||||||
|
fkValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🆕 수정 모드에서 그룹화된 데이터 로드
|
// 🆕 수정 모드에서 그룹화된 데이터 로드
|
||||||
|
|
@ -82,16 +236,16 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
// 이미 로드했거나 조건이 맞지 않으면 스킵
|
// 이미 로드했거나 조건이 맞지 않으면 스킵
|
||||||
if (groupDataLoadedRef.current) return;
|
if (groupDataLoadedRef.current) return;
|
||||||
if (!isEditMode || !groupByColumn || !groupKeyValue || !targetTable) return;
|
if (!isEditMode || !groupByColumn || !groupKeyValue || !targetTable) return;
|
||||||
|
|
||||||
console.log("📥 [RepeaterFieldGroup] 그룹 데이터 로드 시작:", {
|
console.log("📥 [RepeaterFieldGroup] 그룹 데이터 로드 시작:", {
|
||||||
groupByColumn,
|
groupByColumn,
|
||||||
groupKeyValue,
|
groupKeyValue,
|
||||||
targetTable,
|
targetTable,
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsLoadingGroupData(true);
|
setIsLoadingGroupData(true);
|
||||||
groupDataLoadedRef.current = true;
|
groupDataLoadedRef.current = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// API 호출: 같은 그룹 키를 가진 모든 데이터 조회
|
// API 호출: 같은 그룹 키를 가진 모든 데이터 조회
|
||||||
// search 파라미터 사용 (filters가 아닌 search)
|
// search 파라미터 사용 (filters가 아닌 search)
|
||||||
|
|
@ -100,14 +254,14 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
size: 100, // 충분히 큰 값
|
size: 100, // 충분히 큰 값
|
||||||
search: { [groupByColumn]: groupKeyValue },
|
search: { [groupByColumn]: groupKeyValue },
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("🔍 [RepeaterFieldGroup] API 응답 구조:", {
|
console.log("🔍 [RepeaterFieldGroup] API 응답 구조:", {
|
||||||
success: response.data?.success,
|
success: response.data?.success,
|
||||||
hasData: !!response.data?.data,
|
hasData: !!response.data?.data,
|
||||||
dataType: typeof response.data?.data,
|
dataType: typeof response.data?.data,
|
||||||
dataKeys: response.data?.data ? Object.keys(response.data.data) : [],
|
dataKeys: response.data?.data ? Object.keys(response.data.data) : [],
|
||||||
});
|
});
|
||||||
|
|
||||||
// 응답 구조: { success, data: { data: [...], total, page, totalPages } }
|
// 응답 구조: { success, data: { data: [...], total, page, totalPages } }
|
||||||
if (response.data?.success && response.data?.data?.data) {
|
if (response.data?.success && response.data?.data?.data) {
|
||||||
const items = response.data.data.data; // 실제 데이터 배열
|
const items = response.data.data.data; // 실제 데이터 배열
|
||||||
|
|
@ -118,17 +272,17 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
firstItem: items[0],
|
firstItem: items[0],
|
||||||
});
|
});
|
||||||
setGroupedData(items);
|
setGroupedData(items);
|
||||||
|
|
||||||
// 🆕 원본 데이터 ID 목록 저장 (삭제 추적용)
|
// 🆕 원본 데이터 ID 목록 저장 (삭제 추적용)
|
||||||
const itemIds = items.map((item: any) => String(item.id || item.po_item_id || item.item_id)).filter(Boolean);
|
const itemIds = items.map((item: any) => String(item.id || item.po_item_id || item.item_id)).filter(Boolean);
|
||||||
setOriginalItemIds(itemIds);
|
setOriginalItemIds(itemIds);
|
||||||
console.log("📋 [RepeaterFieldGroup] 원본 데이터 ID 목록 저장:", itemIds);
|
console.log("📋 [RepeaterFieldGroup] 원본 데이터 ID 목록 저장:", itemIds);
|
||||||
|
|
||||||
// 🆕 SplitPanelContext에 기존 항목 ID 등록 (좌측 테이블 필터링용)
|
// 🆕 SplitPanelContext에 기존 항목 ID 등록 (좌측 테이블 필터링용)
|
||||||
if (splitPanelContext?.addItemIds && itemIds.length > 0) {
|
if (splitPanelContext?.addItemIds && itemIds.length > 0) {
|
||||||
splitPanelContext.addItemIds(itemIds);
|
splitPanelContext.addItemIds(itemIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
// onChange 호출하여 부모에게 알림
|
// onChange 호출하여 부모에게 알림
|
||||||
if (onChange && items.length > 0) {
|
if (onChange && items.length > 0) {
|
||||||
const dataWithMeta = items.map((item: any) => ({
|
const dataWithMeta = items.map((item: any) => ({
|
||||||
|
|
@ -150,15 +304,126 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
setIsLoadingGroupData(false);
|
setIsLoadingGroupData(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadGroupedData();
|
loadGroupedData();
|
||||||
}, [isEditMode, groupByColumn, groupKeyValue, targetTable, onChange]);
|
}, [isEditMode, groupByColumn, groupKeyValue, targetTable, onChange]);
|
||||||
|
|
||||||
|
// 🆕 분할 패널에서 좌측 데이터 선택 시 FK 기반으로 데이터 로드
|
||||||
|
// 좌측 테이블의 serial_no 등을 기준으로 우측 repeater 데이터 필터링
|
||||||
|
const prevFkValueRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDataByFK = async () => {
|
||||||
|
// 우측 패널이 아니면 스킵
|
||||||
|
if (!isRightPanel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 fkValue가 없거나 빈 값이면 빈 상태로 초기화
|
||||||
|
if (!fkValue || fkValue === "" || fkValue === null || fkValue === undefined) {
|
||||||
|
console.log("🔄 [RepeaterFieldGroup] FK 값 없음 - 빈 상태로 초기화:", {
|
||||||
|
fkColumn,
|
||||||
|
fkValue,
|
||||||
|
prevFkValue: prevFkValueRef.current,
|
||||||
|
});
|
||||||
|
// 이전에 데이터가 있었다면 초기화
|
||||||
|
if (prevFkValueRef.current !== null) {
|
||||||
|
setGroupedData([]);
|
||||||
|
setOriginalItemIds([]);
|
||||||
|
onChange?.([]);
|
||||||
|
prevFkValueRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FK 컬럼이나 타겟 테이블이 없으면 스킵
|
||||||
|
if (!fkColumn || !effectiveTargetTable) {
|
||||||
|
console.log("⏭️ [RepeaterFieldGroup] FK 기반 로드 스킵 (설정 부족):", {
|
||||||
|
fkColumn,
|
||||||
|
effectiveTargetTable,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 같은 FK 값으로 이미 로드했으면 스킵
|
||||||
|
const currentFkValueStr = String(fkValue);
|
||||||
|
if (prevFkValueRef.current === currentFkValueStr) {
|
||||||
|
console.log("⏭️ [RepeaterFieldGroup] 같은 FK 값 - 스킵:", currentFkValueStr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prevFkValueRef.current = currentFkValueStr;
|
||||||
|
|
||||||
|
console.log("📥 [RepeaterFieldGroup] 분할 패널 FK 기반 데이터 로드:", {
|
||||||
|
fkColumn,
|
||||||
|
fkValue,
|
||||||
|
effectiveTargetTable,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsLoadingGroupData(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// API 호출: FK 값을 기준으로 데이터 조회
|
||||||
|
const response = await apiClient.post(`/table-management/tables/${effectiveTargetTable}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 100,
|
||||||
|
search: { [fkColumn]: fkValue },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data?.success) {
|
||||||
|
const items = response.data?.data?.data || [];
|
||||||
|
console.log("✅ [RepeaterFieldGroup] FK 기반 데이터 로드 완료:", {
|
||||||
|
count: items.length,
|
||||||
|
fkColumn,
|
||||||
|
fkValue,
|
||||||
|
effectiveTargetTable,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🆕 데이터가 있든 없든 항상 상태 업데이트 (빈 배열도 명확히 설정)
|
||||||
|
setGroupedData(items);
|
||||||
|
|
||||||
|
// 원본 데이터 ID 목록 저장
|
||||||
|
const itemIds = items.map((item: any) => String(item.id)).filter(Boolean);
|
||||||
|
setOriginalItemIds(itemIds);
|
||||||
|
|
||||||
|
// onChange 호출 (effectiveTargetTable 사용)
|
||||||
|
if (onChange) {
|
||||||
|
if (items.length > 0) {
|
||||||
|
const dataWithMeta = items.map((item: any) => ({
|
||||||
|
...item,
|
||||||
|
_targetTable: effectiveTargetTable,
|
||||||
|
_existingRecord: !!item.id,
|
||||||
|
}));
|
||||||
|
onChange(dataWithMeta);
|
||||||
|
} else {
|
||||||
|
// 🆕 데이터가 없으면 빈 배열 전달 (이전 데이터 클리어)
|
||||||
|
console.log("ℹ️ [RepeaterFieldGroup] FK 기반 데이터 없음 - 빈 상태로 초기화");
|
||||||
|
onChange([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// API 실패 시 빈 배열로 설정
|
||||||
|
console.log("⚠️ [RepeaterFieldGroup] FK 기반 데이터 로드 실패 - 빈 상태로 초기화");
|
||||||
|
setGroupedData([]);
|
||||||
|
setOriginalItemIds([]);
|
||||||
|
onChange?.([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ [RepeaterFieldGroup] FK 기반 데이터 로드 오류:", error);
|
||||||
|
setGroupedData([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingGroupData(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadDataByFK();
|
||||||
|
}, [isRightPanel, fkColumn, fkValue, effectiveTargetTable, onChange]);
|
||||||
|
|
||||||
// 값이 JSON 문자열인 경우 파싱
|
// 값이 JSON 문자열인 경우 파싱
|
||||||
let parsedValue: any[] = [];
|
let parsedValue: any[] = [];
|
||||||
|
|
||||||
// 🆕 그룹화된 데이터가 있으면 우선 사용
|
// 🆕 그룹화된 데이터가 설정되어 있으면 우선 사용 (빈 배열 포함!)
|
||||||
if (groupedData !== null && groupedData.length > 0) {
|
// groupedData가 null이 아니면 (빈 배열이라도) 해당 값을 사용
|
||||||
|
if (groupedData !== null) {
|
||||||
parsedValue = groupedData;
|
parsedValue = groupedData;
|
||||||
} else if (isEditMode && hasRepeaterFieldsInFormData && !groupByColumn) {
|
} else if (isEditMode && hasRepeaterFieldsInFormData && !groupByColumn) {
|
||||||
// 그룹화 설정이 없는 경우에만 단일 행 사용
|
// 그룹화 설정이 없는 경우에만 단일 행 사용
|
||||||
|
|
@ -201,7 +466,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
// 데이터 수신 핸들러
|
// 데이터 수신 핸들러
|
||||||
const handleReceiveData = useCallback((data: any[], mappingRulesOrMode?: any[] | string) => {
|
const handleReceiveData = useCallback((data: any[], mappingRulesOrMode?: any[] | string) => {
|
||||||
console.log("📥 [RepeaterFieldGroup] 데이터 수신:", { data, mappingRulesOrMode });
|
console.log("📥 [RepeaterFieldGroup] 데이터 수신:", { data, mappingRulesOrMode });
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
toast.warning("전달할 데이터가 없습니다");
|
toast.warning("전달할 데이터가 없습니다");
|
||||||
return;
|
return;
|
||||||
|
|
@ -230,13 +495,20 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
const definedFields = configRef.current.fields || [];
|
const definedFields = configRef.current.fields || [];
|
||||||
const definedFieldNames = new Set(definedFields.map((f: any) => f.name));
|
const definedFieldNames = new Set(definedFields.map((f: any) => f.name));
|
||||||
// 시스템 필드 및 필수 필드 추가 (id는 제외 - 새 레코드로 처리하기 위해)
|
// 시스템 필드 및 필수 필드 추가 (id는 제외 - 새 레코드로 처리하기 위해)
|
||||||
const systemFields = new Set(['_targetTable', '_isNewItem', 'created_date', 'updated_date', 'writer', 'company_code']);
|
const systemFields = new Set([
|
||||||
|
"_targetTable",
|
||||||
|
"_isNewItem",
|
||||||
|
"created_date",
|
||||||
|
"updated_date",
|
||||||
|
"writer",
|
||||||
|
"company_code",
|
||||||
|
]);
|
||||||
|
|
||||||
const filteredData = normalizedData.map((item: any) => {
|
const filteredData = normalizedData.map((item: any) => {
|
||||||
const filteredItem: Record<string, any> = {};
|
const filteredItem: Record<string, any> = {};
|
||||||
Object.keys(item).forEach(key => {
|
Object.keys(item).forEach((key) => {
|
||||||
// 🆕 id 필드는 제외 (새 레코드로 저장되도록)
|
// 🆕 id 필드는 제외 (새 레코드로 저장되도록)
|
||||||
if (key === 'id') {
|
if (key === "id") {
|
||||||
return; // id 필드 제외
|
return; // id 필드 제외
|
||||||
}
|
}
|
||||||
// 정의된 필드이거나 시스템 필드인 경우만 포함
|
// 정의된 필드이거나 시스템 필드인 경우만 포함
|
||||||
|
|
@ -254,25 +526,21 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
|
|
||||||
// 기존 데이터에 새 데이터 추가 (기본 모드: append)
|
// 기존 데이터에 새 데이터 추가 (기본 모드: append)
|
||||||
const currentValue = parsedValueRef.current;
|
const currentValue = parsedValueRef.current;
|
||||||
|
|
||||||
// mode가 "replace"인 경우 기존 데이터 대체, 그 외에는 추가
|
// mode가 "replace"인 경우 기존 데이터 대체, 그 외에는 추가
|
||||||
const mode = typeof mappingRulesOrMode === "string" ? mappingRulesOrMode : "append";
|
const mode = typeof mappingRulesOrMode === "string" ? mappingRulesOrMode : "append";
|
||||||
|
|
||||||
let newItems: any[];
|
let newItems: any[];
|
||||||
let addedCount = 0;
|
let addedCount = 0;
|
||||||
let duplicateCount = 0;
|
let duplicateCount = 0;
|
||||||
|
|
||||||
if (mode === "replace") {
|
if (mode === "replace") {
|
||||||
newItems = filteredData;
|
newItems = filteredData;
|
||||||
addedCount = filteredData.length;
|
addedCount = filteredData.length;
|
||||||
} else {
|
} else {
|
||||||
// 🆕 중복 체크: item_code를 기준으로 이미 존재하는 항목 제외 (id는 사용하지 않음)
|
// 🆕 중복 체크: item_code를 기준으로 이미 존재하는 항목 제외 (id는 사용하지 않음)
|
||||||
const existingItemCodes = new Set(
|
const existingItemCodes = new Set(currentValue.map((item: any) => item.item_code).filter(Boolean));
|
||||||
currentValue
|
|
||||||
.map((item: any) => item.item_code)
|
|
||||||
.filter(Boolean)
|
|
||||||
);
|
|
||||||
|
|
||||||
const uniqueNewItems = filteredData.filter((item: any) => {
|
const uniqueNewItems = filteredData.filter((item: any) => {
|
||||||
const itemCode = item.item_code;
|
const itemCode = item.item_code;
|
||||||
if (itemCode && existingItemCodes.has(itemCode)) {
|
if (itemCode && existingItemCodes.has(itemCode)) {
|
||||||
|
|
@ -281,14 +549,14 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
newItems = [...currentValue, ...uniqueNewItems];
|
newItems = [...currentValue, ...uniqueNewItems];
|
||||||
addedCount = uniqueNewItems.length;
|
addedCount = uniqueNewItems.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("📥 [RepeaterFieldGroup] 최종 데이터:", {
|
console.log("📥 [RepeaterFieldGroup] 최종 데이터:", {
|
||||||
currentValue,
|
currentValue,
|
||||||
newItems,
|
newItems,
|
||||||
mode,
|
mode,
|
||||||
addedCount,
|
addedCount,
|
||||||
duplicateCount,
|
duplicateCount,
|
||||||
|
|
@ -300,21 +568,19 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
// 🆕 SplitPanelContext에 추가된 항목 ID 등록 (좌측 테이블 필터링용)
|
// 🆕 SplitPanelContext에 추가된 항목 ID 등록 (좌측 테이블 필터링용)
|
||||||
// item_code를 기준으로 등록 (id는 새 레코드라 없을 수 있음)
|
// item_code를 기준으로 등록 (id는 새 레코드라 없을 수 있음)
|
||||||
if (splitPanelContext?.addItemIds && addedCount > 0) {
|
if (splitPanelContext?.addItemIds && addedCount > 0) {
|
||||||
const newItemCodes = newItems
|
const newItemCodes = newItems.map((item: any) => String(item.item_code)).filter(Boolean);
|
||||||
.map((item: any) => String(item.item_code))
|
|
||||||
.filter(Boolean);
|
|
||||||
splitPanelContext.addItemIds(newItemCodes);
|
splitPanelContext.addItemIds(newItemCodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSON 문자열로 변환하여 저장
|
// JSON 문자열로 변환하여 저장
|
||||||
const jsonValue = JSON.stringify(newItems);
|
const jsonValue = JSON.stringify(newItems);
|
||||||
console.log("📥 [RepeaterFieldGroup] onChange/onFormDataChange 호출:", {
|
console.log("📥 [RepeaterFieldGroup] onChange/onFormDataChange 호출:", {
|
||||||
jsonValue,
|
jsonValue,
|
||||||
hasOnChange: !!onChangeRef.current,
|
hasOnChange: !!onChangeRef.current,
|
||||||
hasOnFormDataChange: !!onFormDataChangeRef.current,
|
hasOnFormDataChange: !!onFormDataChangeRef.current,
|
||||||
fieldName: fieldNameRef.current,
|
fieldName: fieldNameRef.current,
|
||||||
});
|
});
|
||||||
|
|
||||||
// onFormDataChange가 있으면 우선 사용 (EmbeddedScreen의 formData 상태 업데이트)
|
// onFormDataChange가 있으면 우선 사용 (EmbeddedScreen의 formData 상태 업데이트)
|
||||||
if (onFormDataChangeRef.current) {
|
if (onFormDataChangeRef.current) {
|
||||||
onFormDataChangeRef.current(fieldNameRef.current, jsonValue);
|
onFormDataChangeRef.current(fieldNameRef.current, jsonValue);
|
||||||
|
|
@ -337,18 +603,21 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// DataReceivable 인터페이스 구현
|
// DataReceivable 인터페이스 구현
|
||||||
const dataReceiver = useMemo<DataReceivable>(() => ({
|
const dataReceiver = useMemo<DataReceivable>(
|
||||||
componentId: component.id,
|
() => ({
|
||||||
componentType: "repeater-field-group",
|
componentId: component.id,
|
||||||
receiveData: handleReceiveData,
|
componentType: "repeater-field-group",
|
||||||
}), [component.id, handleReceiveData]);
|
receiveData: handleReceiveData,
|
||||||
|
}),
|
||||||
|
[component.id, handleReceiveData],
|
||||||
|
);
|
||||||
|
|
||||||
// ScreenContext에 데이터 수신자로 등록
|
// ScreenContext에 데이터 수신자로 등록
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (screenContext && component.id) {
|
if (screenContext && component.id) {
|
||||||
console.log("📋 [RepeaterFieldGroup] ScreenContext에 데이터 수신자 등록:", component.id);
|
console.log("📋 [RepeaterFieldGroup] ScreenContext에 데이터 수신자 등록:", component.id);
|
||||||
screenContext.registerDataReceiver(component.id, dataReceiver);
|
screenContext.registerDataReceiver(component.id, dataReceiver);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
screenContext.unregisterDataReceiver(component.id);
|
screenContext.unregisterDataReceiver(component.id);
|
||||||
};
|
};
|
||||||
|
|
@ -358,16 +627,16 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
// SplitPanelContext에 데이터 수신자로 등록 (분할 패널 내에서만)
|
// SplitPanelContext에 데이터 수신자로 등록 (분할 패널 내에서만)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const splitPanelPosition = screenContext?.splitPanelPosition;
|
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||||
|
|
||||||
if (splitPanelContext?.isInSplitPanel && splitPanelPosition && component.id) {
|
if (splitPanelContext?.isInSplitPanel && splitPanelPosition && component.id) {
|
||||||
console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에 데이터 수신자 등록:", {
|
console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에 데이터 수신자 등록:", {
|
||||||
componentId: component.id,
|
componentId: component.id,
|
||||||
position: splitPanelPosition,
|
position: splitPanelPosition,
|
||||||
});
|
});
|
||||||
|
|
||||||
splitPanelContext.registerReceiver(splitPanelPosition, component.id, dataReceiver);
|
splitPanelContext.registerReceiver(splitPanelPosition, component.id, dataReceiver);
|
||||||
receiverRef.current = dataReceiver;
|
receiverRef.current = dataReceiver;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에서 데이터 수신자 해제:", component.id);
|
console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에서 데이터 수신자 해제:", component.id);
|
||||||
splitPanelContext.unregisterReceiver(splitPanelPosition, component.id);
|
splitPanelContext.unregisterReceiver(splitPanelPosition, component.id);
|
||||||
|
|
@ -380,13 +649,13 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleSplitPanelDataTransfer = (event: CustomEvent) => {
|
const handleSplitPanelDataTransfer = (event: CustomEvent) => {
|
||||||
const { data, mode, mappingRules } = event.detail;
|
const { data, mode, mappingRules } = event.detail;
|
||||||
|
|
||||||
console.log("📥 [RepeaterFieldGroup] splitPanelDataTransfer 이벤트 수신:", {
|
console.log("📥 [RepeaterFieldGroup] splitPanelDataTransfer 이벤트 수신:", {
|
||||||
dataCount: data?.length,
|
dataCount: data?.length,
|
||||||
mode,
|
mode,
|
||||||
componentId: component.id,
|
componentId: component.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 우측 패널의 리피터 필드 그룹만 데이터를 수신
|
// 우측 패널의 리피터 필드 그룹만 데이터를 수신
|
||||||
const splitPanelPosition = screenContext?.splitPanelPosition;
|
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||||
if (splitPanelPosition === "right" && data && data.length > 0) {
|
if (splitPanelPosition === "right" && data && data.length > 0) {
|
||||||
|
|
@ -395,51 +664,113 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
|
window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
|
window.removeEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
|
||||||
};
|
};
|
||||||
}, [screenContext?.splitPanelPosition, handleReceiveData, component.id]);
|
}, [screenContext?.splitPanelPosition, handleReceiveData, component.id]);
|
||||||
|
|
||||||
// 🆕 RepeaterInput에서 항목 변경 시 SplitPanelContext의 addedItemIds 동기화
|
// 🆕 RepeaterInput에서 항목 변경 시 SplitPanelContext의 addedItemIds 동기화
|
||||||
const handleRepeaterChange = useCallback((newValue: any[]) => {
|
const handleRepeaterChange = useCallback(
|
||||||
// 배열을 JSON 문자열로 변환하여 저장
|
(newValue: any[]) => {
|
||||||
const jsonValue = JSON.stringify(newValue);
|
// 🆕 분할 패널에서 우측인 경우, 새 항목에 FK 값과 targetTable 추가
|
||||||
onChange?.(jsonValue);
|
let valueWithMeta = newValue;
|
||||||
|
|
||||||
// 🆕 groupedData 상태도 업데이트
|
if (isRightPanel && effectiveTargetTable) {
|
||||||
setGroupedData(newValue);
|
valueWithMeta = newValue.map((item: any) => {
|
||||||
|
const itemWithMeta = {
|
||||||
// 🆕 SplitPanelContext의 addedItemIds 동기화
|
...item,
|
||||||
if (splitPanelContext?.isInSplitPanel && screenContext?.splitPanelPosition === "right") {
|
_targetTable: effectiveTargetTable,
|
||||||
// 현재 항목들의 ID 목록
|
};
|
||||||
const currentIds = newValue
|
|
||||||
.map((item: any) => String(item.id || item.po_item_id || item.item_id))
|
// 🆕 FK 값이 있고 새 항목이면 FK 컬럼에 값 추가
|
||||||
.filter(Boolean);
|
if (fkColumn && fkValue && item._isNewItem) {
|
||||||
|
itemWithMeta[fkColumn] = fkValue;
|
||||||
// 기존 addedItemIds와 비교하여 삭제된 ID 찾기
|
console.log("🔗 [RepeaterFieldGroup] 새 항목에 FK 값 추가:", {
|
||||||
const addedIds = splitPanelContext.addedItemIds;
|
fkColumn,
|
||||||
const removedIds = Array.from(addedIds).filter(id => !currentIds.includes(id));
|
fkValue,
|
||||||
|
});
|
||||||
if (removedIds.length > 0) {
|
}
|
||||||
console.log("🗑️ [RepeaterFieldGroup] 삭제된 항목 ID 제거:", removedIds);
|
|
||||||
splitPanelContext.removeItemIds(removedIds);
|
return itemWithMeta;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 새로 추가된 ID가 있으면 등록
|
// 배열을 JSON 문자열로 변환하여 저장
|
||||||
const newIds = currentIds.filter((id: string) => !addedIds.has(id));
|
const jsonValue = JSON.stringify(valueWithMeta);
|
||||||
if (newIds.length > 0) {
|
console.log("📤 [RepeaterFieldGroup] 데이터 변경:", {
|
||||||
console.log("➕ [RepeaterFieldGroup] 새 항목 ID 추가:", newIds);
|
fieldName,
|
||||||
splitPanelContext.addItemIds(newIds);
|
itemCount: valueWithMeta.length,
|
||||||
|
isRightPanel,
|
||||||
|
hasScreenContextUpdateFormData: !!screenContext?.updateFormData,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🆕 분할 패널 우측에서는 ScreenContext.updateFormData만 사용
|
||||||
|
// (중복 저장 방지: onChange/onFormDataChange는 부모에게 전달되어 다시 formData로 돌아옴)
|
||||||
|
if (isRightPanel && screenContext?.updateFormData) {
|
||||||
|
screenContext.updateFormData(fieldName, jsonValue);
|
||||||
|
console.log("📤 [RepeaterFieldGroup] screenContext.updateFormData 호출 (우측 패널):", { fieldName });
|
||||||
|
} else {
|
||||||
|
// 분할 패널이 아니거나 좌측 패널인 경우 기존 방식 사용
|
||||||
|
onChange?.(jsonValue);
|
||||||
|
if (onFormDataChange) {
|
||||||
|
onFormDataChange(fieldName, jsonValue);
|
||||||
|
console.log("📤 [RepeaterFieldGroup] onFormDataChange(props) 호출:", { fieldName });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, [onChange, splitPanelContext, screenContext?.splitPanelPosition]);
|
// 🆕 groupedData 상태도 업데이트
|
||||||
|
setGroupedData(valueWithMeta);
|
||||||
|
|
||||||
|
// 🆕 SplitPanelContext의 addedItemIds 동기화
|
||||||
|
if (splitPanelContext?.isInSplitPanel && screenContext?.splitPanelPosition === "right") {
|
||||||
|
// 현재 항목들의 ID 목록
|
||||||
|
const currentIds = newValue
|
||||||
|
.map((item: any) => String(item.id || item.po_item_id || item.item_id))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// 기존 addedItemIds와 비교하여 삭제된 ID 찾기
|
||||||
|
const addedIds = splitPanelContext.addedItemIds;
|
||||||
|
const removedIds = Array.from(addedIds).filter((id) => !currentIds.includes(id));
|
||||||
|
|
||||||
|
if (removedIds.length > 0) {
|
||||||
|
console.log("🗑️ [RepeaterFieldGroup] 삭제된 항목 ID 제거:", removedIds);
|
||||||
|
splitPanelContext.removeItemIds(removedIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새로 추가된 ID가 있으면 등록
|
||||||
|
const newIds = currentIds.filter((id: string) => !addedIds.has(id));
|
||||||
|
if (newIds.length > 0) {
|
||||||
|
console.log("➕ [RepeaterFieldGroup] 새 항목 ID 추가:", newIds);
|
||||||
|
splitPanelContext.addItemIds(newIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
onChange,
|
||||||
|
onFormDataChange,
|
||||||
|
splitPanelContext,
|
||||||
|
screenContext?.splitPanelPosition,
|
||||||
|
screenContext?.updateFormData,
|
||||||
|
isRightPanel,
|
||||||
|
effectiveTargetTable,
|
||||||
|
fkColumn,
|
||||||
|
fkValue,
|
||||||
|
fieldName,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🆕 config에 effectiveTargetTable 병합 (linkedFilters에서 추출된 테이블도 포함)
|
||||||
|
const effectiveConfig = {
|
||||||
|
...config,
|
||||||
|
targetTable: effectiveTargetTable || config.targetTable,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RepeaterInput
|
<RepeaterInput
|
||||||
value={parsedValue}
|
value={parsedValue}
|
||||||
onChange={handleRepeaterChange}
|
onChange={handleRepeaterChange}
|
||||||
config={config}
|
config={effectiveConfig}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
readonly={readonly}
|
readonly={readonly}
|
||||||
menuObjid={menuObjid}
|
menuObjid={menuObjid}
|
||||||
|
|
|
||||||
|
|
@ -379,10 +379,41 @@ export class ButtonActionExecutor {
|
||||||
/**
|
/**
|
||||||
* 저장 액션 처리 (INSERT/UPDATE 자동 판단 - DB 기반)
|
* 저장 액션 처리 (INSERT/UPDATE 자동 판단 - DB 기반)
|
||||||
*/
|
*/
|
||||||
|
private static saveCallCount = 0; // 🆕 호출 횟수 추적
|
||||||
|
private static saveLock: Map<string, number> = new Map(); // 🆕 중복 호출 방지 락
|
||||||
|
|
||||||
private static async handleSave(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
private static async handleSave(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||||
|
this.saveCallCount++;
|
||||||
|
const callId = this.saveCallCount;
|
||||||
|
|
||||||
const { formData, originalData, tableName, screenId, onSave } = context;
|
const { formData, originalData, tableName, screenId, onSave } = context;
|
||||||
|
|
||||||
console.log("💾 [handleSave] 저장 시작:", { formData, tableName, screenId, hasOnSave: !!onSave });
|
// 🆕 중복 호출 방지: 같은 screenId + tableName + formData 조합으로 2초 내 재호출 시 무시
|
||||||
|
const formDataHash = JSON.stringify(Object.keys(formData).sort());
|
||||||
|
const lockKey = `${screenId}-${tableName}-${formDataHash}`;
|
||||||
|
const lastCallTime = this.saveLock.get(lockKey) || 0;
|
||||||
|
const now = Date.now();
|
||||||
|
const timeDiff = now - lastCallTime;
|
||||||
|
|
||||||
|
console.log(`🔒 [handleSave #${callId}] 락 체크:`, { lockKey: lockKey.slice(0, 50), timeDiff, threshold: 2000 });
|
||||||
|
|
||||||
|
if (timeDiff < 2000) {
|
||||||
|
console.log(`⏭️ [handleSave #${callId}] 중복 호출 무시 (2초 내 재호출):`, {
|
||||||
|
lockKey: lockKey.slice(0, 50),
|
||||||
|
timeDiff,
|
||||||
|
});
|
||||||
|
return true; // 중복 호출은 성공으로 처리
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveLock.set(lockKey, now);
|
||||||
|
|
||||||
|
console.log(`💾 [handleSave #${callId}] 저장 시작:`, {
|
||||||
|
callId,
|
||||||
|
formDataKeys: Object.keys(formData),
|
||||||
|
tableName,
|
||||||
|
screenId,
|
||||||
|
hasOnSave: !!onSave,
|
||||||
|
});
|
||||||
|
|
||||||
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
|
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
|
||||||
if (onSave) {
|
if (onSave) {
|
||||||
|
|
@ -807,6 +838,107 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 RepeaterFieldGroup 데이터 저장 처리 (_targetTable이 있는 배열 데이터)
|
||||||
|
// formData에서 _targetTable 메타데이터가 포함된 배열 필드 찾기
|
||||||
|
console.log("🔎 [handleSave] formData 키 목록:", Object.keys(context.formData));
|
||||||
|
console.log("🔎 [handleSave] formData 전체:", context.formData);
|
||||||
|
|
||||||
|
for (const [fieldKey, fieldValue] of Object.entries(context.formData)) {
|
||||||
|
console.log(`🔎 [handleSave] 필드 검사: ${fieldKey}`, {
|
||||||
|
type: typeof fieldValue,
|
||||||
|
isArray: Array.isArray(fieldValue),
|
||||||
|
valuePreview: typeof fieldValue === "string" ? fieldValue.slice(0, 100) : fieldValue,
|
||||||
|
});
|
||||||
|
// JSON 문자열인 경우 파싱
|
||||||
|
let parsedData = fieldValue;
|
||||||
|
if (typeof fieldValue === "string" && fieldValue.startsWith("[")) {
|
||||||
|
try {
|
||||||
|
parsedData = JSON.parse(fieldValue);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 배열이고 첫 번째 항목에 _targetTable이 있는 경우만 처리
|
||||||
|
if (!Array.isArray(parsedData) || parsedData.length === 0) continue;
|
||||||
|
|
||||||
|
const firstItem = parsedData[0];
|
||||||
|
const repeaterTargetTable = firstItem?._targetTable;
|
||||||
|
|
||||||
|
// _targetTable이 없거나, _repeatScreenModal_ 키면 스킵 (다른 로직에서 처리)
|
||||||
|
if (!repeaterTargetTable || fieldKey.startsWith("_repeatScreenModal_")) continue;
|
||||||
|
|
||||||
|
console.log(`📦 [handleSave] RepeaterFieldGroup 데이터 저장: ${fieldKey} → ${repeaterTargetTable}`, {
|
||||||
|
itemCount: parsedData.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of parsedData) {
|
||||||
|
// 메타 필드 제거 (eslint 경고 무시 - 의도적으로 분리)
|
||||||
|
|
||||||
|
const {
|
||||||
|
_targetTable: _,
|
||||||
|
_isNewItem,
|
||||||
|
_existingRecord: __,
|
||||||
|
_originalItemIds: ___,
|
||||||
|
_deletedItemIds: ____,
|
||||||
|
...dataToSave
|
||||||
|
} = item;
|
||||||
|
|
||||||
|
// 🆕 빈 id 필드 제거 (새 항목인 경우)
|
||||||
|
if (!dataToSave.id || dataToSave.id === "" || dataToSave.id === null) {
|
||||||
|
delete dataToSave.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 정보 추가
|
||||||
|
const dataWithMeta: Record<string, unknown> = {
|
||||||
|
...dataToSave,
|
||||||
|
created_by: context.userId,
|
||||||
|
updated_by: context.userId,
|
||||||
|
company_code: context.companyCode,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 🆕 새 항목 판단: _isNewItem 플래그 또는 id가 없거나 빈 문자열인 경우
|
||||||
|
const isNewRecord = _isNewItem || !item.id || item.id === "" || item.id === undefined;
|
||||||
|
|
||||||
|
if (isNewRecord) {
|
||||||
|
// INSERT (새 항목)
|
||||||
|
// id 필드 완전히 제거 (자동 생성되도록)
|
||||||
|
delete dataWithMeta.id;
|
||||||
|
// 빈 문자열 id도 제거
|
||||||
|
if ("id" in dataWithMeta && (dataWithMeta.id === "" || dataWithMeta.id === null)) {
|
||||||
|
delete dataWithMeta.id;
|
||||||
|
}
|
||||||
|
console.log(`📝 [handleSave] RepeaterFieldGroup INSERT (${repeaterTargetTable}):`, dataWithMeta);
|
||||||
|
const insertResult = await apiClient.post(
|
||||||
|
`/table-management/tables/${repeaterTargetTable}/add`,
|
||||||
|
dataWithMeta,
|
||||||
|
);
|
||||||
|
console.log("✅ [handleSave] RepeaterFieldGroup INSERT 완료:", insertResult.data);
|
||||||
|
} else if (item.id) {
|
||||||
|
// UPDATE (기존 항목)
|
||||||
|
const originalData = { id: item.id };
|
||||||
|
const updatedData = { ...dataWithMeta, id: item.id };
|
||||||
|
console.log("📝 [handleSave] RepeaterFieldGroup UPDATE:", {
|
||||||
|
id: item.id,
|
||||||
|
table: repeaterTargetTable,
|
||||||
|
});
|
||||||
|
const updateResult = await apiClient.put(`/table-management/tables/${repeaterTargetTable}/edit`, {
|
||||||
|
originalData,
|
||||||
|
updatedData,
|
||||||
|
});
|
||||||
|
console.log("✅ [handleSave] RepeaterFieldGroup UPDATE 완료:", updateResult.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as { response?: { data?: unknown }; message?: string };
|
||||||
|
console.error(
|
||||||
|
`❌ [handleSave] RepeaterFieldGroup 저장 실패 (${repeaterTargetTable}):`,
|
||||||
|
error.response?.data || error.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🆕 v3.9: RepeatScreenModal의 외부 테이블 데이터 저장 처리
|
// 🆕 v3.9: RepeatScreenModal의 외부 테이블 데이터 저장 처리
|
||||||
const repeatScreenModalKeys = Object.keys(context.formData).filter(
|
const repeatScreenModalKeys = Object.keys(context.formData).filter(
|
||||||
(key) => key.startsWith("_repeatScreenModal_") && key !== "_repeatScreenModal_aggregations",
|
(key) => key.startsWith("_repeatScreenModal_") && key !== "_repeatScreenModal_aggregations",
|
||||||
|
|
@ -814,11 +946,36 @@ export class ButtonActionExecutor {
|
||||||
|
|
||||||
// RepeatScreenModal 데이터가 있으면 해당 테이블에 대한 메인 저장은 건너뜀
|
// RepeatScreenModal 데이터가 있으면 해당 테이블에 대한 메인 저장은 건너뜀
|
||||||
const repeatScreenModalTables = repeatScreenModalKeys.map((key) => key.replace("_repeatScreenModal_", ""));
|
const repeatScreenModalTables = repeatScreenModalKeys.map((key) => key.replace("_repeatScreenModal_", ""));
|
||||||
const shouldSkipMainSave = repeatScreenModalTables.includes(tableName);
|
|
||||||
|
// 🆕 RepeaterFieldGroup 테이블 목록 수집 (메인 저장 건너뛰기 판단용)
|
||||||
|
const repeaterFieldGroupTables: string[] = [];
|
||||||
|
for (const [, fieldValue] of Object.entries(context.formData)) {
|
||||||
|
let parsedData = fieldValue;
|
||||||
|
if (typeof fieldValue === "string" && fieldValue.startsWith("[")) {
|
||||||
|
try {
|
||||||
|
parsedData = JSON.parse(fieldValue);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(parsedData) && parsedData.length > 0 && parsedData[0]?._targetTable) {
|
||||||
|
repeaterFieldGroupTables.push(parsedData[0]._targetTable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메인 저장 건너뛰기 조건: RepeatScreenModal 또는 RepeaterFieldGroup에서 같은 테이블 처리
|
||||||
|
const shouldSkipMainSave =
|
||||||
|
repeatScreenModalTables.includes(tableName) || repeaterFieldGroupTables.includes(tableName);
|
||||||
|
|
||||||
if (shouldSkipMainSave) {
|
if (shouldSkipMainSave) {
|
||||||
console.log(`⏭️ [handleSave] ${tableName} 메인 저장 건너뜀 (RepeatScreenModal에서 처리)`);
|
console.log(
|
||||||
saveResult = { success: true, message: "RepeatScreenModal에서 처리" };
|
`⏭️ [handleSave] ${tableName} 메인 저장 건너뜀 (RepeaterFieldGroup/RepeatScreenModal에서 처리)`,
|
||||||
|
{
|
||||||
|
repeatScreenModalTables,
|
||||||
|
repeaterFieldGroupTables,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
saveResult = { success: true, message: "RepeaterFieldGroup/RepeatScreenModal에서 처리" };
|
||||||
} else {
|
} else {
|
||||||
saveResult = await DynamicFormApi.saveFormData({
|
saveResult = await DynamicFormApi.saveFormData({
|
||||||
screenId,
|
screenId,
|
||||||
|
|
|
||||||
|
|
@ -5,21 +5,21 @@
|
||||||
/**
|
/**
|
||||||
* 테이블 타입 관리(table_type_columns)에서 사용하는 input_type 값들
|
* 테이블 타입 관리(table_type_columns)에서 사용하는 input_type 값들
|
||||||
*/
|
*/
|
||||||
export type RepeaterFieldType =
|
export type RepeaterFieldType =
|
||||||
| "text" // 텍스트
|
| "text" // 텍스트
|
||||||
| "number" // 숫자
|
| "number" // 숫자
|
||||||
| "textarea" // 텍스트영역
|
| "textarea" // 텍스트영역
|
||||||
| "date" // 날짜
|
| "date" // 날짜
|
||||||
| "select" // 선택박스
|
| "select" // 선택박스
|
||||||
| "checkbox" // 체크박스
|
| "checkbox" // 체크박스
|
||||||
| "radio" // 라디오
|
| "radio" // 라디오
|
||||||
| "category" // 카테고리
|
| "category" // 카테고리
|
||||||
| "entity" // 엔티티 참조
|
| "entity" // 엔티티 참조
|
||||||
| "code" // 공통코드
|
| "code" // 공통코드
|
||||||
| "image" // 이미지
|
| "image" // 이미지
|
||||||
| "direct" // 직접입력
|
| "direct" // 직접입력
|
||||||
| "calculated" // 계산식 필드
|
| "calculated" // 계산식 필드
|
||||||
| string; // 기타 커스텀 타입 허용
|
| string; // 기타 커스텀 타입 허용
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 계산식 연산자
|
* 계산식 연산자
|
||||||
|
|
@ -32,11 +32,11 @@ export type CalculationOperator = "+" | "-" | "*" | "/" | "%" | "round" | "floor
|
||||||
* 예: { field1: "amount", operator: "round", decimalPlaces: 2 } → round(amount, 2)
|
* 예: { field1: "amount", operator: "round", decimalPlaces: 2 } → round(amount, 2)
|
||||||
*/
|
*/
|
||||||
export interface CalculationFormula {
|
export interface CalculationFormula {
|
||||||
field1: string; // 첫 번째 필드명
|
field1: string; // 첫 번째 필드명
|
||||||
operator: CalculationOperator; // 연산자
|
operator: CalculationOperator; // 연산자
|
||||||
field2?: string; // 두 번째 필드명 (단항 연산자의 경우 불필요)
|
field2?: string; // 두 번째 필드명 (단항 연산자의 경우 불필요)
|
||||||
constantValue?: number; // 상수값 (field2 대신 사용 가능)
|
constantValue?: number; // 상수값 (field2 대신 사용 가능)
|
||||||
decimalPlaces?: number; // 소수점 자릿수 (round, floor, ceil에서 사용)
|
decimalPlaces?: number; // 소수점 자릿수 (round, floor, ceil에서 사용)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -84,6 +84,7 @@ export interface RepeaterFieldGroupConfig {
|
||||||
fields: RepeaterFieldDefinition[]; // 반복될 필드 정의
|
fields: RepeaterFieldDefinition[]; // 반복될 필드 정의
|
||||||
targetTable?: string; // 저장할 대상 테이블 (미지정 시 메인 화면 테이블)
|
targetTable?: string; // 저장할 대상 테이블 (미지정 시 메인 화면 테이블)
|
||||||
groupByColumn?: string; // 수정 모드에서 그룹화할 컬럼 (예: "inbound_number")
|
groupByColumn?: string; // 수정 모드에서 그룹화할 컬럼 (예: "inbound_number")
|
||||||
|
fkColumn?: string; // 분할 패널에서 좌측 선택 데이터와 연결할 FK 컬럼 (예: "serial_no")
|
||||||
minItems?: number; // 최소 항목 수
|
minItems?: number; // 최소 항목 수
|
||||||
maxItems?: number; // 최대 항목 수
|
maxItems?: number; // 최대 항목 수
|
||||||
addButtonText?: string; // 추가 버튼 텍스트
|
addButtonText?: string; // 추가 버튼 텍스트
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue