분할 패널 RepeaterFieldGroup 저장 및 DB webType 자동 매핑 구현

This commit is contained in:
dohyeons 2025-12-15 15:40:29 +09:00
parent c2d473bf59
commit 93443c98ee
8 changed files with 1034 additions and 392 deletions

View File

@ -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 (

View File

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

View File

@ -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>

View File

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

View File

@ -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에서 가져온 값 사용

View File

@ -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}

View File

@ -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,

View File

@ -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; // 추가 버튼 텍스트