Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary, ; especially if it merges an updated upstream into a topic branch. ; ; Lines starting with ';' will be ignored, and an empty message aborts ; the commit.
This commit is contained in:
commit
54c674f3c9
|
|
@ -164,8 +164,8 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
|
|||
}
|
||||
|
||||
try {
|
||||
const [rows] = await this.pool.execute(sql, params);
|
||||
return rows;
|
||||
const [rows] = await this.pool.execute(sql, params);
|
||||
return rows;
|
||||
} catch (error: any) {
|
||||
// 연결 닫힘 오류 감지
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -10,7 +10,13 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
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 { useBreakpoint } from "@/hooks/useBreakpoint";
|
||||
import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal";
|
||||
|
|
@ -46,7 +52,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
const breakpoint = previewBreakpoint || globalBreakpoint;
|
||||
|
||||
// 카테고리 매핑 데이터 (값 -> {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 {
|
||||
|
|
@ -78,10 +86,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
|
||||
// 접힌 상태 관리 (각 항목별)
|
||||
const [collapsedItems, setCollapsedItems] = useState<Set<number>>(new Set());
|
||||
|
||||
|
||||
// 🆕 초기 계산 완료 여부 추적 (무한 루프 방지)
|
||||
const initialCalcDoneRef = useRef(false);
|
||||
|
||||
|
||||
// 🆕 삭제된 항목 ID 목록 추적 (ref로 관리하여 즉시 반영)
|
||||
const deletedItemIdsRef = useRef<string[]>([]);
|
||||
|
||||
|
|
@ -98,47 +106,60 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
|
||||
// 외부 value 변경 시 동기화 및 초기 계산식 필드 업데이트
|
||||
useEffect(() => {
|
||||
if (value.length > 0) {
|
||||
// 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행)
|
||||
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);
|
||||
// 🆕 빈 배열도 처리 (FK 기반 필터링 시 데이터가 없을 수 있음)
|
||||
if (value.length === 0) {
|
||||
// minItems가 설정되어 있으면 빈 항목 생성, 아니면 빈 배열로 초기화
|
||||
if (minItems > 0) {
|
||||
const emptyItems = Array(minItems)
|
||||
.fill(null)
|
||||
.map(() => createEmptyItem());
|
||||
setItems(emptyItems);
|
||||
} else {
|
||||
// 🆕 기존 레코드 플래그 추가
|
||||
const valueWithFlag = value.map(item => ({
|
||||
...item,
|
||||
_existingRecord: !!item.id,
|
||||
}));
|
||||
setItems(valueWithFlag);
|
||||
setItems([]);
|
||||
}
|
||||
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]);
|
||||
|
||||
|
|
@ -164,14 +185,14 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
if (items.length <= minItems) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 🆕 삭제되는 항목의 ID 저장 (DB에서 삭제할 때 필요)
|
||||
const removedItem = items[index];
|
||||
if (removedItem?.id) {
|
||||
console.log("🗑️ [RepeaterInput] 삭제할 항목 ID 추가:", removedItem.id);
|
||||
deletedItemIdsRef.current = [...deletedItemIdsRef.current, removedItem.id];
|
||||
}
|
||||
|
||||
|
||||
const newItems = items.filter((_, i) => i !== index);
|
||||
setItems(newItems);
|
||||
|
||||
|
|
@ -179,10 +200,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
// 🆕 삭제된 항목 ID 목록도 함께 전달 (ref에서 최신값 사용)
|
||||
const currentDeletedIds = deletedItemIdsRef.current;
|
||||
console.log("🗑️ [RepeaterInput] 현재 삭제 목록:", currentDeletedIds);
|
||||
|
||||
|
||||
const dataWithMeta = config.targetTable
|
||||
? newItems.map((item, idx) => ({
|
||||
...item,
|
||||
? newItems.map((item, idx) => ({
|
||||
...item,
|
||||
_targetTable: config.targetTable,
|
||||
// 첫 번째 항목에만 삭제 ID 목록 포함
|
||||
...(idx === 0 ? { _deletedItemIds: currentDeletedIds } : {}),
|
||||
|
|
@ -205,16 +226,16 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
...newItems[itemIndex],
|
||||
[fieldName]: value,
|
||||
};
|
||||
|
||||
|
||||
// 🆕 계산식 필드 자동 업데이트: 변경된 항목의 모든 계산식 필드 값을 재계산
|
||||
const calculatedFields = fields.filter(f => f.type === "calculated");
|
||||
calculatedFields.forEach(calcField => {
|
||||
const calculatedFields = fields.filter((f) => f.type === "calculated");
|
||||
calculatedFields.forEach((calcField) => {
|
||||
const calculatedValue = calculateValue(calcField.formula, newItems[itemIndex]);
|
||||
if (calculatedValue !== null) {
|
||||
newItems[itemIndex][calcField.name] = calculatedValue;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
setItems(newItems);
|
||||
console.log("✏️ RepeaterInput 필드 변경, onChange 호출:", {
|
||||
itemIndex,
|
||||
|
|
@ -227,8 +248,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
// 🆕 삭제된 항목 ID 목록도 유지
|
||||
const currentDeletedIds = deletedItemIdsRef.current;
|
||||
const dataWithMeta = config.targetTable
|
||||
? newItems.map((item, idx) => ({
|
||||
...item,
|
||||
? newItems.map((item, idx) => ({
|
||||
...item,
|
||||
_targetTable: config.targetTable,
|
||||
// 첫 번째 항목에만 삭제 ID 목록 포함 (삭제된 항목이 있는 경우에만)
|
||||
...(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 => {
|
||||
if (!formula || !formula.field1) return null;
|
||||
|
||||
|
||||
const value1 = parseFloat(item[formula.field1]) || 0;
|
||||
const value2 = formula.field2
|
||||
? (parseFloat(item[formula.field2]) || 0)
|
||||
: (formula.constantValue ?? 0);
|
||||
|
||||
const value2 = formula.field2 ? parseFloat(item[formula.field2]) || 0 : (formula.constantValue ?? 0);
|
||||
|
||||
let result: number;
|
||||
|
||||
|
||||
switch (formula.operator) {
|
||||
case "+":
|
||||
result = value1 + value2;
|
||||
|
|
@ -331,7 +350,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
default:
|
||||
result = value1;
|
||||
}
|
||||
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
|
|
@ -341,42 +360,44 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
* @param format 포맷 설정
|
||||
* @returns 포맷된 문자열
|
||||
*/
|
||||
const formatNumber = (
|
||||
value: number | null,
|
||||
format?: RepeaterFieldDefinition["numberFormat"]
|
||||
): string => {
|
||||
const formatNumber = (value: number | null, format?: RepeaterFieldDefinition["numberFormat"]): string => {
|
||||
if (value === null || isNaN(value)) return "-";
|
||||
|
||||
|
||||
let formattedValue = value;
|
||||
|
||||
|
||||
// 소수점 자릿수 적용
|
||||
if (format?.decimalPlaces !== undefined) {
|
||||
formattedValue = parseFloat(value.toFixed(format.decimalPlaces));
|
||||
}
|
||||
|
||||
|
||||
// 천 단위 구분자
|
||||
let result = format?.useThousandSeparator !== false
|
||||
? formattedValue.toLocaleString("ko-KR", {
|
||||
minimumFractionDigits: format?.minimumFractionDigits ?? 0,
|
||||
maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0,
|
||||
})
|
||||
: formattedValue.toString();
|
||||
|
||||
let result =
|
||||
format?.useThousandSeparator !== false
|
||||
? formattedValue.toLocaleString("ko-KR", {
|
||||
minimumFractionDigits: format?.minimumFractionDigits ?? 0,
|
||||
maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0,
|
||||
})
|
||||
: formattedValue.toString();
|
||||
|
||||
// 접두사/접미사 추가
|
||||
if (format?.prefix) result = format.prefix + result;
|
||||
if (format?.suffix) result = result + format.suffix;
|
||||
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// 개별 필드 렌더링
|
||||
const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => {
|
||||
const isReadonly = disabled || readonly || field.readonly;
|
||||
|
||||
|
||||
// 🆕 placeholder 기본값: 필드에 설정된 값 > 필드 라벨 기반 자동 생성
|
||||
// "id(를) 입력하세요" 같은 잘못된 기본값 방지
|
||||
const defaultPlaceholder = field.placeholder || `${field.label || field.name}`;
|
||||
|
||||
const commonProps = {
|
||||
value: value || "",
|
||||
disabled: isReadonly,
|
||||
placeholder: field.placeholder,
|
||||
placeholder: defaultPlaceholder,
|
||||
required: field.required,
|
||||
};
|
||||
|
||||
|
|
@ -385,25 +406,21 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
const item = items[itemIndex];
|
||||
const calculatedValue = calculateValue(field.formula, item);
|
||||
const formattedValue = formatNumber(calculatedValue, field.numberFormat);
|
||||
|
||||
return (
|
||||
<span className="text-sm font-medium text-blue-700 min-w-[80px] inline-block">
|
||||
{formattedValue}
|
||||
</span>
|
||||
);
|
||||
|
||||
return <span className="inline-block min-w-[80px] text-sm font-medium text-blue-700">{formattedValue}</span>;
|
||||
}
|
||||
|
||||
// 카테고리 타입은 항상 배지로 표시 (카테고리 관리에서 설정한 색상 적용)
|
||||
if (field.type === "category") {
|
||||
if (!value) return <span className="text-muted-foreground text-sm">-</span>;
|
||||
|
||||
|
||||
// field.name을 키로 사용 (테이블 리스트와 동일)
|
||||
const mapping = categoryMappings[field.name];
|
||||
const valueStr = String(value); // 값을 문자열로 변환
|
||||
const categoryData = mapping?.[valueStr];
|
||||
const displayLabel = categoryData?.label || valueStr;
|
||||
const displayColor = categoryData?.color || "#64748b"; // 기본 색상 (slate)
|
||||
|
||||
|
||||
console.log(`🏷️ [RepeaterInput] 카테고리 배지 렌더링:`, {
|
||||
fieldName: field.name,
|
||||
value: valueStr,
|
||||
|
|
@ -412,12 +429,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
displayLabel,
|
||||
displayColor,
|
||||
});
|
||||
|
||||
|
||||
// 색상이 "none"이면 일반 텍스트로 표시
|
||||
if (displayColor === "none") {
|
||||
return <span className="text-sm">{displayLabel}</span>;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Badge
|
||||
style={{
|
||||
|
|
@ -436,10 +453,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
if (field.displayMode === "readonly") {
|
||||
// select 타입인 경우 옵션에서 라벨 찾기
|
||||
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>;
|
||||
}
|
||||
|
||||
|
||||
// 🆕 카테고리 매핑이 있는 경우 라벨로 변환 (조인된 테이블의 카테고리 필드)
|
||||
const mapping = categoryMappings[field.name];
|
||||
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 (
|
||||
<span className="text-sm text-foreground">
|
||||
{value || "-"}
|
||||
</span>
|
||||
);
|
||||
return <span className="text-foreground text-sm">{value || "-"}</span>;
|
||||
}
|
||||
|
||||
switch (field.type) {
|
||||
|
|
@ -500,35 +513,46 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
{...commonProps}
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
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 (
|
||||
<Input
|
||||
{...commonProps}
|
||||
value={dateValue}
|
||||
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]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case "number":
|
||||
// 숫자 포맷이 설정된 경우 포맷팅된 텍스트로 표시
|
||||
if (field.numberFormat?.useThousandSeparator || field.numberFormat?.prefix || field.numberFormat?.suffix) {
|
||||
const numValue = parseFloat(value) || 0;
|
||||
const formattedDisplay = formatNumber(numValue, field.numberFormat);
|
||||
|
||||
|
||||
// 읽기 전용이면 포맷팅된 텍스트만 표시
|
||||
if (isReadonly) {
|
||||
return (
|
||||
<span className="text-sm min-w-[80px] inline-block">
|
||||
{formattedDisplay}
|
||||
</span>
|
||||
);
|
||||
return <span className="inline-block min-w-[80px] text-sm">{formattedDisplay}</span>;
|
||||
}
|
||||
|
||||
|
||||
// 편집 가능: 입력은 숫자로, 표시는 포맷팅
|
||||
return (
|
||||
<div className="relative min-w-[80px]">
|
||||
|
|
@ -540,15 +564,11 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
max={field.validation?.max}
|
||||
className="pr-1"
|
||||
/>
|
||||
{value && (
|
||||
<div className="text-muted-foreground text-[10px] mt-0.5">
|
||||
{formattedDisplay}
|
||||
</div>
|
||||
)}
|
||||
{value && <div className="text-muted-foreground mt-0.5 text-[10px]">{formattedDisplay}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
|
|
@ -597,31 +617,31 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
// 테이블 리스트와 동일한 API 사용: /table-categories/{tableName}/{columnName}/values
|
||||
useEffect(() => {
|
||||
// 카테고리 타입 필드 + readonly 필드 (조인된 테이블에서 온 데이터일 가능성)
|
||||
const categoryFields = fields.filter(f => f.type === "category");
|
||||
const readonlyFields = fields.filter(f => f.displayMode === "readonly" && f.type === "text");
|
||||
|
||||
const categoryFields = fields.filter((f) => f.type === "category");
|
||||
const readonlyFields = fields.filter((f) => f.displayMode === "readonly" && f.type === "text");
|
||||
|
||||
if (categoryFields.length === 0 && readonlyFields.length === 0) return;
|
||||
|
||||
const loadCategoryMappings = async () => {
|
||||
const apiClient = (await import("@/lib/api/client")).apiClient;
|
||||
|
||||
|
||||
// 1. 카테고리 타입 필드 매핑 로드
|
||||
for (const field of categoryFields) {
|
||||
const columnName = field.name;
|
||||
|
||||
|
||||
if (categoryMappings[columnName]) continue;
|
||||
|
||||
|
||||
try {
|
||||
const tableName = config.targetTable;
|
||||
if (!tableName) continue;
|
||||
|
||||
|
||||
console.log(`📡 [RepeaterInput] 카테고리 매핑 로드: ${tableName}/${columnName}`);
|
||||
|
||||
|
||||
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
|
||||
|
||||
|
||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||
const mapping: Record<string, { label: string; color: string }> = {};
|
||||
|
||||
|
||||
response.data.data.forEach((item: any) => {
|
||||
const key = String(item.valueCode);
|
||||
mapping[key] = {
|
||||
|
|
@ -629,10 +649,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
color: item.color || "#64748b",
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
console.log(`✅ [RepeaterInput] 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
|
||||
|
||||
setCategoryMappings(prev => ({
|
||||
|
||||
setCategoryMappings((prev) => ({
|
||||
...prev,
|
||||
[columnName]: mapping,
|
||||
}));
|
||||
|
|
@ -641,29 +661,29 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
console.error(`❌ [RepeaterInput] 카테고리 매핑 로드 실패 (${columnName}):`, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 2. 🆕 readonly 필드에 대해 조인된 테이블 (item_info)에서 카테고리 매핑 로드
|
||||
// material, division 등 조인된 테이블의 카테고리 필드
|
||||
const joinedTableFields = ['material', 'division', 'status', 'currency_code'];
|
||||
const fieldsToLoadFromJoinedTable = readonlyFields.filter(f => joinedTableFields.includes(f.name));
|
||||
|
||||
const joinedTableFields = ["material", "division", "status", "currency_code"];
|
||||
const fieldsToLoadFromJoinedTable = readonlyFields.filter((f) => joinedTableFields.includes(f.name));
|
||||
|
||||
if (fieldsToLoadFromJoinedTable.length > 0) {
|
||||
// item_info 테이블에서 카테고리 매핑 로드
|
||||
const joinedTableName = 'item_info';
|
||||
|
||||
const joinedTableName = "item_info";
|
||||
|
||||
for (const field of fieldsToLoadFromJoinedTable) {
|
||||
const columnName = field.name;
|
||||
|
||||
|
||||
if (categoryMappings[columnName]) continue;
|
||||
|
||||
|
||||
try {
|
||||
console.log(`📡 [RepeaterInput] 조인 테이블 카테고리 매핑 로드: ${joinedTableName}/${columnName}`);
|
||||
|
||||
|
||||
const response = await apiClient.get(`/table-categories/${joinedTableName}/${columnName}/values`);
|
||||
|
||||
|
||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||
const mapping: Record<string, { label: string; color: string }> = {};
|
||||
|
||||
|
||||
response.data.data.forEach((item: any) => {
|
||||
const key = String(item.valueCode);
|
||||
mapping[key] = {
|
||||
|
|
@ -671,10 +691,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
color: item.color || "#64748b",
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
console.log(`✅ [RepeaterInput] 조인 테이블 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
|
||||
|
||||
setCategoryMappings(prev => ({
|
||||
|
||||
setCategoryMappings((prev) => ({
|
||||
...prev,
|
||||
[columnName]: mapping,
|
||||
}));
|
||||
|
|
@ -694,9 +714,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
if (fields.length === 0) {
|
||||
return (
|
||||
<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">
|
||||
<p className="text-sm font-medium text-destructive">필드가 정의되지 않았습니다</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">속성 패널에서 필드를 추가하세요.</p>
|
||||
<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-destructive text-sm font-medium">필드가 정의되지 않았습니다</p>
|
||||
<p className="text-muted-foreground mt-2 text-xs">속성 패널에서 필드를 추가하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -706,8 +726,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
if (items.length === 0) {
|
||||
return (
|
||||
<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">
|
||||
<p className="mb-4 text-sm text-muted-foreground">{emptyMessage}</p>
|
||||
<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="text-muted-foreground mb-4 text-sm">{emptyMessage}</p>
|
||||
{!readonly && !disabled && items.length < maxItems && (
|
||||
<Button type="button" onClick={handleAddItem} size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
|
|
@ -740,7 +760,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
{fields.map((field) => (
|
||||
<TableHead key={field.name} className="h-10 px-2.5 py-2 text-sm font-semibold">
|
||||
{field.label}
|
||||
{field.required && <span className="ml-1 text-destructive">*</span>}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</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
|
||||
key={itemIndex}
|
||||
className={cn(
|
||||
"bg-background transition-colors hover:bg-muted/50",
|
||||
"bg-background hover:bg-muted/50 transition-colors",
|
||||
draggedIndex === itemIndex && "opacity-50",
|
||||
)}
|
||||
draggable={allowReorder && !readonly && !disabled}
|
||||
|
|
@ -762,15 +782,13 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
>
|
||||
{/* 인덱스 번호 */}
|
||||
{showIndex && (
|
||||
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">
|
||||
{itemIndex + 1}
|
||||
</TableCell>
|
||||
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">{itemIndex + 1}</TableCell>
|
||||
)}
|
||||
|
||||
{/* 드래그 핸들 */}
|
||||
{allowReorder && !readonly && !disabled && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
|
@ -789,7 +807,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
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="항목 제거"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
|
|
@ -829,12 +847,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
<div className="flex items-center gap-2">
|
||||
{/* 드래그 핸들 */}
|
||||
{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 && (
|
||||
<CardTitle className="text-sm font-semibold text-foreground">항목 {itemIndex + 1}</CardTitle>
|
||||
<CardTitle className="text-foreground text-sm font-semibold">항목 {itemIndex + 1}</CardTitle>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -859,7 +877,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
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="항목 제거"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
|
|
@ -873,9 +891,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
<div className={getFieldsLayoutClass()}>
|
||||
{fields.map((field) => (
|
||||
<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.required && <span className="ml-1 text-destructive">*</span>}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</label>
|
||||
{renderField(field, itemIndex, item[field.name])}
|
||||
</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>
|
||||
(최소: {minItems}, 최대: {maxItems})
|
||||
|
|
|
|||
|
|
@ -10,7 +10,13 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "
|
|||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
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 { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -34,10 +40,10 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
}) => {
|
||||
const [localFields, setLocalFields] = useState<RepeaterFieldDefinition[]>(config.fields || []);
|
||||
const [fieldNamePopoverOpen, setFieldNamePopoverOpen] = useState<Record<number, boolean>>({});
|
||||
|
||||
|
||||
// 로컬 입력 상태 (각 필드의 라벨, placeholder 등)
|
||||
const [localInputs, setLocalInputs] = useState<Record<number, { label: string; placeholder: string }>>({});
|
||||
|
||||
|
||||
// 설정 입력 필드의 로컬 상태
|
||||
const [localConfigInputs, setLocalConfigInputs] = useState({
|
||||
addButtonText: config.addButtonText || "",
|
||||
|
|
@ -88,13 +94,13 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
};
|
||||
|
||||
// 필드 수정 (입력 중 - 로컬 상태만)
|
||||
const updateFieldLocal = (index: number, field: 'label' | 'placeholder', value: string) => {
|
||||
setLocalInputs(prev => ({
|
||||
const updateFieldLocal = (index: number, field: "label" | "placeholder", value: string) => {
|
||||
setLocalInputs((prev) => ({
|
||||
...prev,
|
||||
[index]: {
|
||||
...prev[index],
|
||||
[field]: value
|
||||
}
|
||||
[field]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
|
|
@ -106,7 +112,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
newFields[index] = {
|
||||
...newFields[index],
|
||||
label: localInput.label,
|
||||
placeholder: localInput.placeholder
|
||||
placeholder: localInput.placeholder,
|
||||
};
|
||||
handleFieldsChange(newFields);
|
||||
}
|
||||
|
|
@ -218,6 +224,32 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
</p>
|
||||
</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">
|
||||
<Label className="text-sm font-semibold">필드 정의</Label>
|
||||
|
|
@ -263,8 +295,9 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
onSelect={() => {
|
||||
// input_type (DB에서 설정한 타입) 우선 사용, 없으면 webType/widgetType
|
||||
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] 필드 타입 결정:", {
|
||||
columnName: column.columnName,
|
||||
input_type: col.input_type,
|
||||
|
|
@ -273,19 +306,19 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
widgetType: col.widgetType,
|
||||
finalType: fieldType,
|
||||
});
|
||||
|
||||
|
||||
updateField(index, {
|
||||
name: column.columnName,
|
||||
label: column.columnLabel || column.columnName,
|
||||
type: fieldType as RepeaterFieldType,
|
||||
});
|
||||
// 로컬 입력 상태도 업데이트
|
||||
setLocalInputs(prev => ({
|
||||
setLocalInputs((prev) => ({
|
||||
...prev,
|
||||
[index]: {
|
||||
label: column.columnLabel || column.columnName,
|
||||
placeholder: prev[index]?.placeholder || ""
|
||||
}
|
||||
placeholder: prev[index]?.placeholder || "",
|
||||
},
|
||||
}));
|
||||
setFieldNamePopoverOpen({ ...fieldNamePopoverOpen, [index]: false });
|
||||
}}
|
||||
|
|
@ -313,7 +346,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<Label className="text-xs">라벨</Label>
|
||||
<Input
|
||||
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)}
|
||||
placeholder="필드 라벨"
|
||||
className="h-8 w-full text-xs"
|
||||
|
|
@ -358,8 +391,12 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<div className="space-y-1">
|
||||
<Label className="text-xs">Placeholder</Label>
|
||||
<Input
|
||||
value={localInputs[index]?.placeholder !== undefined ? localInputs[index].placeholder : (field.placeholder || "")}
|
||||
onChange={(e) => updateFieldLocal(index, 'placeholder', e.target.value)}
|
||||
value={
|
||||
localInputs[index]?.placeholder !== undefined
|
||||
? localInputs[index].placeholder
|
||||
: field.placeholder || ""
|
||||
}
|
||||
onChange={(e) => updateFieldLocal(index, "placeholder", e.target.value)}
|
||||
onBlur={() => handleFieldBlur(index)}
|
||||
placeholder="입력 안내"
|
||||
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" />
|
||||
<Label className="text-xs font-semibold text-blue-800">계산식 설정</Label>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 필드 1 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-blue-700">필드 1</Label>
|
||||
<Select
|
||||
value={field.formula?.field1 || ""}
|
||||
onValueChange={(value) => updateField(index, {
|
||||
formula: { ...field.formula, field1: value } as CalculationFormula
|
||||
})}
|
||||
onValueChange={(value) =>
|
||||
updateField(index, {
|
||||
formula: { ...field.formula, field1: value } as CalculationFormula,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
|
|
@ -398,54 +437,75 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-blue-700">연산자</Label>
|
||||
<Select
|
||||
value={field.formula?.operator || "+"}
|
||||
onValueChange={(value) => updateField(index, {
|
||||
formula: { ...field.formula, operator: value as CalculationOperator } as CalculationFormula
|
||||
})}
|
||||
onValueChange={(value) =>
|
||||
updateField(index, {
|
||||
formula: { ...field.formula, operator: value as CalculationOperator } as CalculationFormula,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[9999]">
|
||||
<SelectItem value="+" className="text-xs">+ 더하기</SelectItem>
|
||||
<SelectItem value="-" className="text-xs">- 빼기</SelectItem>
|
||||
<SelectItem value="*" className="text-xs">× 곱하기</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>
|
||||
<SelectItem value="+" className="text-xs">
|
||||
+ 더하기
|
||||
</SelectItem>
|
||||
<SelectItem value="-" className="text-xs">
|
||||
- 빼기
|
||||
</SelectItem>
|
||||
<SelectItem value="*" className="text-xs">
|
||||
× 곱하기
|
||||
</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>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 두 번째 필드 또는 상수값 */}
|
||||
{!["round", "floor", "ceil", "abs"].includes(field.formula?.operator || "") ? (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-blue-700">필드 2 / 상수</Label>
|
||||
<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) => {
|
||||
if (value.startsWith("__const__")) {
|
||||
updateField(index, {
|
||||
formula: {
|
||||
...field.formula,
|
||||
field2: undefined,
|
||||
constantValue: 0
|
||||
} as CalculationFormula
|
||||
updateField(index, {
|
||||
formula: {
|
||||
...field.formula,
|
||||
field2: undefined,
|
||||
constantValue: 0,
|
||||
} as CalculationFormula,
|
||||
});
|
||||
} else {
|
||||
updateField(index, {
|
||||
formula: {
|
||||
...field.formula,
|
||||
field2: value,
|
||||
constantValue: undefined
|
||||
} as CalculationFormula
|
||||
updateField(index, {
|
||||
formula: {
|
||||
...field.formula,
|
||||
field2: value,
|
||||
constantValue: undefined,
|
||||
} as CalculationFormula,
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
|
@ -475,14 +535,19 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
min={0}
|
||||
max={10}
|
||||
value={field.formula?.decimalPlaces ?? 0}
|
||||
onChange={(e) => updateField(index, {
|
||||
formula: { ...field.formula, decimalPlaces: parseInt(e.target.value) || 0 } as CalculationFormula
|
||||
})}
|
||||
onChange={(e) =>
|
||||
updateField(index, {
|
||||
formula: {
|
||||
...field.formula,
|
||||
decimalPlaces: parseInt(e.target.value) || 0,
|
||||
} as CalculationFormula,
|
||||
})
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* 상수값 입력 필드 */}
|
||||
{field.formula?.constantValue !== undefined && (
|
||||
<div className="space-y-1">
|
||||
|
|
@ -490,15 +555,20 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<Input
|
||||
type="number"
|
||||
value={field.formula.constantValue}
|
||||
onChange={(e) => updateField(index, {
|
||||
formula: { ...field.formula, constantValue: parseFloat(e.target.value) || 0 } as CalculationFormula
|
||||
})}
|
||||
onChange={(e) =>
|
||||
updateField(index, {
|
||||
formula: {
|
||||
...field.formula,
|
||||
constantValue: parseFloat(e.target.value) || 0,
|
||||
} as CalculationFormula,
|
||||
})
|
||||
}
|
||||
placeholder="숫자 입력"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* 숫자 포맷 설정 */}
|
||||
<div className="space-y-2 border-t border-blue-200 pt-2">
|
||||
<Label className="text-[10px] text-blue-700">숫자 표시 형식</Label>
|
||||
|
|
@ -507,9 +577,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<Checkbox
|
||||
id={`thousand-sep-${index}`}
|
||||
checked={field.numberFormat?.useThousandSeparator ?? true}
|
||||
onCheckedChange={(checked) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean }
|
||||
})}
|
||||
onCheckedChange={(checked) =>
|
||||
updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
<Input
|
||||
value={field.numberFormat?.decimalPlaces ?? 0}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 }
|
||||
})}
|
||||
onChange={(e) =>
|
||||
updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 },
|
||||
})
|
||||
}
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
|
|
@ -532,31 +606,34 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
value={field.numberFormat?.prefix || ""}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, prefix: e.target.value }
|
||||
})}
|
||||
onChange={(e) =>
|
||||
updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, prefix: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="접두사 (₩)"
|
||||
className="h-7 text-[10px]"
|
||||
/>
|
||||
<Input
|
||||
value={field.numberFormat?.suffix || ""}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, suffix: e.target.value }
|
||||
})}
|
||||
onChange={(e) =>
|
||||
updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, suffix: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="접미사 (원)"
|
||||
className="h-7 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 계산식 미리보기 */}
|
||||
<div className="rounded bg-white p-2 text-xs">
|
||||
<span className="text-gray-500">계산식: </span>
|
||||
<code className="font-mono text-blue-700">
|
||||
{field.formula?.field1 || "필드1"} {field.formula?.operator || "+"} {
|
||||
field.formula?.field2 ||
|
||||
(field.formula?.constantValue !== undefined ? field.formula.constantValue : "필드2")
|
||||
}
|
||||
{field.formula?.field1 || "필드1"} {field.formula?.operator || "+"}{" "}
|
||||
{field.formula?.field2 ||
|
||||
(field.formula?.constantValue !== undefined ? field.formula.constantValue : "필드2")}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -571,9 +648,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<Checkbox
|
||||
id={`number-thousand-sep-${index}`}
|
||||
checked={field.numberFormat?.useThousandSeparator ?? false}
|
||||
onCheckedChange={(checked) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean }
|
||||
})}
|
||||
onCheckedChange={(checked) =>
|
||||
updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
<Input
|
||||
value={field.numberFormat?.decimalPlaces ?? 0}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 }
|
||||
})}
|
||||
onChange={(e) =>
|
||||
updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 },
|
||||
})
|
||||
}
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
|
|
@ -596,17 +677,21 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
value={field.numberFormat?.prefix || ""}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, prefix: e.target.value }
|
||||
})}
|
||||
onChange={(e) =>
|
||||
updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, prefix: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="접두사 (₩)"
|
||||
className="h-7 text-[10px]"
|
||||
/>
|
||||
<Input
|
||||
value={field.numberFormat?.suffix || ""}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, suffix: e.target.value }
|
||||
})}
|
||||
onChange={(e) =>
|
||||
updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, suffix: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="접미사 (원)"
|
||||
className="h-7 text-[10px]"
|
||||
/>
|
||||
|
|
@ -624,7 +709,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
placeholder="카테고리 코드 (예: INBOUND_TYPE)"
|
||||
className="h-8 w-full text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
카테고리 관리에서 설정한 색상으로 배지가 표시됩니다
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
"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 { logger } from "@/lib/utils/logger";
|
||||
import type { SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||
|
|
@ -14,17 +14,21 @@ interface ScreenContextValue {
|
|||
screenId?: number;
|
||||
tableName?: string;
|
||||
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right)
|
||||
|
||||
|
||||
// 🆕 폼 데이터 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
|
||||
formData: Record<string, any>;
|
||||
updateFormData: (fieldName: string, value: any) => void;
|
||||
|
||||
// 컴포넌트 등록
|
||||
registerDataProvider: (componentId: string, provider: DataProvidable) => void;
|
||||
unregisterDataProvider: (componentId: string) => void;
|
||||
registerDataReceiver: (componentId: string, receiver: DataReceivable) => void;
|
||||
unregisterDataReceiver: (componentId: string) => void;
|
||||
|
||||
|
||||
// 컴포넌트 조회
|
||||
getDataProvider: (componentId: string) => DataProvidable | undefined;
|
||||
getDataReceiver: (componentId: string) => DataReceivable | undefined;
|
||||
|
||||
|
||||
// 모든 컴포넌트 조회
|
||||
getAllDataProviders: () => Map<string, DataProvidable>;
|
||||
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 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) => {
|
||||
dataProvidersRef.current.set(componentId, provider);
|
||||
logger.debug("데이터 제공자 등록", { componentId, componentType: provider.componentType });
|
||||
|
|
@ -83,31 +108,38 @@ export function ScreenContextProvider({ screenId, tableName, splitPanelPosition,
|
|||
}, []);
|
||||
|
||||
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
|
||||
const value = React.useMemo<ScreenContextValue>(() => ({
|
||||
screenId,
|
||||
tableName,
|
||||
splitPanelPosition,
|
||||
registerDataProvider,
|
||||
unregisterDataProvider,
|
||||
registerDataReceiver,
|
||||
unregisterDataReceiver,
|
||||
getDataProvider,
|
||||
getDataReceiver,
|
||||
getAllDataProviders,
|
||||
getAllDataReceivers,
|
||||
}), [
|
||||
screenId,
|
||||
tableName,
|
||||
splitPanelPosition,
|
||||
registerDataProvider,
|
||||
unregisterDataProvider,
|
||||
registerDataReceiver,
|
||||
unregisterDataReceiver,
|
||||
getDataProvider,
|
||||
getDataReceiver,
|
||||
getAllDataProviders,
|
||||
getAllDataReceivers,
|
||||
]);
|
||||
const value = React.useMemo<ScreenContextValue>(
|
||||
() => ({
|
||||
screenId,
|
||||
tableName,
|
||||
splitPanelPosition,
|
||||
formData,
|
||||
updateFormData,
|
||||
registerDataProvider,
|
||||
unregisterDataProvider,
|
||||
registerDataReceiver,
|
||||
unregisterDataReceiver,
|
||||
getDataProvider,
|
||||
getDataReceiver,
|
||||
getAllDataProviders,
|
||||
getAllDataReceivers,
|
||||
}),
|
||||
[
|
||||
screenId,
|
||||
tableName,
|
||||
splitPanelPosition,
|
||||
formData,
|
||||
updateFormData,
|
||||
registerDataProvider,
|
||||
unregisterDataProvider,
|
||||
registerDataReceiver,
|
||||
unregisterDataReceiver,
|
||||
getDataProvider,
|
||||
getDataReceiver,
|
||||
getAllDataProviders,
|
||||
getAllDataReceivers,
|
||||
],
|
||||
);
|
||||
|
||||
return <ScreenContext.Provider value={value}>{children}</ScreenContext.Provider>;
|
||||
}
|
||||
|
|
@ -130,4 +162,3 @@ export function useScreenContext() {
|
|||
export function useScreenContextOptional() {
|
||||
return useContext(ScreenContext);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -907,8 +907,27 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// 🆕 분할 패널 우측이면 screenContext.formData와 props.formData를 병합
|
||||
// screenContext.formData: RepeaterFieldGroup 등 컴포넌트가 직접 업데이트한 데이터
|
||||
// props.formData: 부모에서 전달된 폼 데이터
|
||||
const screenContextFormData = screenContext?.formData || {};
|
||||
const propsFormData = formData || {};
|
||||
|
||||
// 병합: props.formData를 기본으로 하고, screenContext.formData로 오버라이드
|
||||
// (RepeaterFieldGroup 데이터는 screenContext에만 있음)
|
||||
const effectiveFormData = { ...propsFormData, ...screenContextFormData };
|
||||
|
||||
console.log("🔍 [ButtonPrimary] formData 선택:", {
|
||||
hasScreenContextFormData: Object.keys(screenContextFormData).length > 0,
|
||||
screenContextKeys: Object.keys(screenContextFormData),
|
||||
hasPropsFormData: Object.keys(propsFormData).length > 0,
|
||||
propsFormDataKeys: Object.keys(propsFormData),
|
||||
splitPanelPosition,
|
||||
effectiveFormDataKeys: Object.keys(effectiveFormData),
|
||||
});
|
||||
|
||||
const context: ButtonActionContext = {
|
||||
formData: formData || {},
|
||||
formData: effectiveFormData,
|
||||
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
|
||||
screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용
|
||||
tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용
|
||||
|
|
|
|||
|
|
@ -20,24 +20,56 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
const screenContext = useScreenContextOptional();
|
||||
const splitPanelContext = useSplitPanelContext();
|
||||
const receiverRef = useRef<DataReceivable | null>(null);
|
||||
|
||||
|
||||
// 🆕 그룹화된 데이터를 저장하는 상태
|
||||
const [groupedData, setGroupedData] = useState<any[] | null>(null);
|
||||
const [isLoadingGroupData, setIsLoadingGroupData] = useState(false);
|
||||
const groupDataLoadedRef = useRef(false);
|
||||
|
||||
|
||||
// 🆕 원본 데이터 ID 목록 (삭제 추적용)
|
||||
const [originalItemIds, setOriginalItemIds] = useState<string[]>([]);
|
||||
|
||||
// 🆕 DB에서 로드한 컬럼 정보 (webType 등)
|
||||
const [columnInfo, setColumnInfo] = useState<Record<string, any>>({});
|
||||
|
||||
// 컴포넌트의 필드명 (formData 키)
|
||||
const fieldName = (component as any).columnName || component.id;
|
||||
|
||||
// repeaterConfig 또는 componentConfig에서 설정 가져오기
|
||||
const config = (component as any).repeaterConfig || component.componentConfig || { fields: [] };
|
||||
|
||||
const rawConfig = (component as any).repeaterConfig || component.componentConfig || { fields: [] };
|
||||
|
||||
// 🆕 그룹화 설정 (예: groupByColumn: "inbound_number")
|
||||
const groupByColumn = config.groupByColumn;
|
||||
const targetTable = config.targetTable;
|
||||
const groupByColumn = rawConfig.groupByColumn;
|
||||
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보다 우선)
|
||||
const rawValue = formData?.[fieldName] ?? value;
|
||||
|
|
@ -45,21 +77,127 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
// 🆕 수정 모드 감지: formData에 id가 있고, fieldName으로 값을 찾지 못한 경우
|
||||
// formData 자체를 배열의 첫 번째 항목으로 사용 (단일 행 수정 시)
|
||||
const isEditMode = formData?.id && !rawValue && !value;
|
||||
|
||||
|
||||
// 🆕 반복 필드 그룹의 필드들이 formData에 있는지 확인
|
||||
const configFields = config.fields || [];
|
||||
const hasRepeaterFieldsInFormData = configFields.length > 0 &&
|
||||
configFields.some((field: any) => formData?.[field.name] !== undefined);
|
||||
const hasRepeaterFieldsInFormData =
|
||||
configFields.length > 0 && configFields.some((field: any) => formData?.[field.name] !== undefined);
|
||||
|
||||
// 🆕 formData와 config.fields의 필드 이름 매칭 확인
|
||||
const matchingFields = configFields.filter((field: any) => formData?.[field.name] !== undefined);
|
||||
|
||||
|
||||
// 🆕 그룹 키 값 (예: formData.inbound_number)
|
||||
const groupKeyValue = groupByColumn ? formData?.[groupByColumn] : null;
|
||||
|
||||
console.log("🔄 [RepeaterFieldGroup] 렌더링:", {
|
||||
fieldName,
|
||||
hasFormData: !!formData,
|
||||
|
||||
// 🆕 분할 패널 위치 및 좌측 선택 데이터 확인
|
||||
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||
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,
|
||||
formDataValue: formData?.[fieldName],
|
||||
propsValue: value,
|
||||
|
|
@ -72,8 +210,24 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
groupByColumn,
|
||||
groupKeyValue,
|
||||
targetTable,
|
||||
linkedFilterTargetTable,
|
||||
effectiveTargetTable,
|
||||
hasGroupedData: groupedData !== null,
|
||||
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 (!isEditMode || !groupByColumn || !groupKeyValue || !targetTable) return;
|
||||
|
||||
|
||||
console.log("📥 [RepeaterFieldGroup] 그룹 데이터 로드 시작:", {
|
||||
groupByColumn,
|
||||
groupKeyValue,
|
||||
targetTable,
|
||||
});
|
||||
|
||||
|
||||
setIsLoadingGroupData(true);
|
||||
groupDataLoadedRef.current = true;
|
||||
|
||||
|
||||
try {
|
||||
// API 호출: 같은 그룹 키를 가진 모든 데이터 조회
|
||||
// search 파라미터 사용 (filters가 아닌 search)
|
||||
|
|
@ -100,14 +254,14 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
size: 100, // 충분히 큰 값
|
||||
search: { [groupByColumn]: groupKeyValue },
|
||||
});
|
||||
|
||||
|
||||
console.log("🔍 [RepeaterFieldGroup] API 응답 구조:", {
|
||||
success: response.data?.success,
|
||||
hasData: !!response.data?.data,
|
||||
dataType: typeof response.data?.data,
|
||||
dataKeys: response.data?.data ? Object.keys(response.data.data) : [],
|
||||
});
|
||||
|
||||
|
||||
// 응답 구조: { success, data: { data: [...], total, page, totalPages } }
|
||||
if (response.data?.success && response.data?.data?.data) {
|
||||
const items = response.data.data.data; // 실제 데이터 배열
|
||||
|
|
@ -118,17 +272,17 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
firstItem: items[0],
|
||||
});
|
||||
setGroupedData(items);
|
||||
|
||||
|
||||
// 🆕 원본 데이터 ID 목록 저장 (삭제 추적용)
|
||||
const itemIds = items.map((item: any) => String(item.id || item.po_item_id || item.item_id)).filter(Boolean);
|
||||
setOriginalItemIds(itemIds);
|
||||
console.log("📋 [RepeaterFieldGroup] 원본 데이터 ID 목록 저장:", itemIds);
|
||||
|
||||
|
||||
// 🆕 SplitPanelContext에 기존 항목 ID 등록 (좌측 테이블 필터링용)
|
||||
if (splitPanelContext?.addItemIds && itemIds.length > 0) {
|
||||
splitPanelContext.addItemIds(itemIds);
|
||||
}
|
||||
|
||||
|
||||
// onChange 호출하여 부모에게 알림
|
||||
if (onChange && items.length > 0) {
|
||||
const dataWithMeta = items.map((item: any) => ({
|
||||
|
|
@ -150,15 +304,126 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
setIsLoadingGroupData(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
loadGroupedData();
|
||||
}, [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 문자열인 경우 파싱
|
||||
let parsedValue: any[] = [];
|
||||
|
||||
// 🆕 그룹화된 데이터가 있으면 우선 사용
|
||||
if (groupedData !== null && groupedData.length > 0) {
|
||||
|
||||
// 🆕 그룹화된 데이터가 설정되어 있으면 우선 사용 (빈 배열 포함!)
|
||||
// groupedData가 null이 아니면 (빈 배열이라도) 해당 값을 사용
|
||||
if (groupedData !== null) {
|
||||
parsedValue = groupedData;
|
||||
} else if (isEditMode && hasRepeaterFieldsInFormData && !groupByColumn) {
|
||||
// 그룹화 설정이 없는 경우에만 단일 행 사용
|
||||
|
|
@ -201,7 +466,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
// 데이터 수신 핸들러
|
||||
const handleReceiveData = useCallback((data: any[], mappingRulesOrMode?: any[] | string) => {
|
||||
console.log("📥 [RepeaterFieldGroup] 데이터 수신:", { data, mappingRulesOrMode });
|
||||
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
toast.warning("전달할 데이터가 없습니다");
|
||||
return;
|
||||
|
|
@ -230,13 +495,20 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
const definedFields = configRef.current.fields || [];
|
||||
const definedFieldNames = new Set(definedFields.map((f: any) => f.name));
|
||||
// 시스템 필드 및 필수 필드 추가 (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 filteredItem: Record<string, any> = {};
|
||||
Object.keys(item).forEach(key => {
|
||||
Object.keys(item).forEach((key) => {
|
||||
// 🆕 id 필드는 제외 (새 레코드로 저장되도록)
|
||||
if (key === 'id') {
|
||||
if (key === "id") {
|
||||
return; // id 필드 제외
|
||||
}
|
||||
// 정의된 필드이거나 시스템 필드인 경우만 포함
|
||||
|
|
@ -254,25 +526,21 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
|
||||
// 기존 데이터에 새 데이터 추가 (기본 모드: append)
|
||||
const currentValue = parsedValueRef.current;
|
||||
|
||||
|
||||
// mode가 "replace"인 경우 기존 데이터 대체, 그 외에는 추가
|
||||
const mode = typeof mappingRulesOrMode === "string" ? mappingRulesOrMode : "append";
|
||||
|
||||
|
||||
let newItems: any[];
|
||||
let addedCount = 0;
|
||||
let duplicateCount = 0;
|
||||
|
||||
|
||||
if (mode === "replace") {
|
||||
newItems = filteredData;
|
||||
addedCount = filteredData.length;
|
||||
} else {
|
||||
// 🆕 중복 체크: item_code를 기준으로 이미 존재하는 항목 제외 (id는 사용하지 않음)
|
||||
const existingItemCodes = new Set(
|
||||
currentValue
|
||||
.map((item: any) => item.item_code)
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
const existingItemCodes = new Set(currentValue.map((item: any) => item.item_code).filter(Boolean));
|
||||
|
||||
const uniqueNewItems = filteredData.filter((item: any) => {
|
||||
const itemCode = item.item_code;
|
||||
if (itemCode && existingItemCodes.has(itemCode)) {
|
||||
|
|
@ -281,14 +549,14 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
newItems = [...currentValue, ...uniqueNewItems];
|
||||
addedCount = uniqueNewItems.length;
|
||||
}
|
||||
|
||||
console.log("📥 [RepeaterFieldGroup] 최종 데이터:", {
|
||||
currentValue,
|
||||
newItems,
|
||||
console.log("📥 [RepeaterFieldGroup] 최종 데이터:", {
|
||||
currentValue,
|
||||
newItems,
|
||||
mode,
|
||||
addedCount,
|
||||
duplicateCount,
|
||||
|
|
@ -300,21 +568,19 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
// 🆕 SplitPanelContext에 추가된 항목 ID 등록 (좌측 테이블 필터링용)
|
||||
// item_code를 기준으로 등록 (id는 새 레코드라 없을 수 있음)
|
||||
if (splitPanelContext?.addItemIds && addedCount > 0) {
|
||||
const newItemCodes = newItems
|
||||
.map((item: any) => String(item.item_code))
|
||||
.filter(Boolean);
|
||||
const newItemCodes = newItems.map((item: any) => String(item.item_code)).filter(Boolean);
|
||||
splitPanelContext.addItemIds(newItemCodes);
|
||||
}
|
||||
|
||||
// JSON 문자열로 변환하여 저장
|
||||
const jsonValue = JSON.stringify(newItems);
|
||||
console.log("📥 [RepeaterFieldGroup] onChange/onFormDataChange 호출:", {
|
||||
jsonValue,
|
||||
console.log("📥 [RepeaterFieldGroup] onChange/onFormDataChange 호출:", {
|
||||
jsonValue,
|
||||
hasOnChange: !!onChangeRef.current,
|
||||
hasOnFormDataChange: !!onFormDataChangeRef.current,
|
||||
fieldName: fieldNameRef.current,
|
||||
});
|
||||
|
||||
|
||||
// onFormDataChange가 있으면 우선 사용 (EmbeddedScreen의 formData 상태 업데이트)
|
||||
if (onFormDataChangeRef.current) {
|
||||
onFormDataChangeRef.current(fieldNameRef.current, jsonValue);
|
||||
|
|
@ -337,18 +603,21 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
}, []);
|
||||
|
||||
// DataReceivable 인터페이스 구현
|
||||
const dataReceiver = useMemo<DataReceivable>(() => ({
|
||||
componentId: component.id,
|
||||
componentType: "repeater-field-group",
|
||||
receiveData: handleReceiveData,
|
||||
}), [component.id, handleReceiveData]);
|
||||
const dataReceiver = useMemo<DataReceivable>(
|
||||
() => ({
|
||||
componentId: component.id,
|
||||
componentType: "repeater-field-group",
|
||||
receiveData: handleReceiveData,
|
||||
}),
|
||||
[component.id, handleReceiveData],
|
||||
);
|
||||
|
||||
// ScreenContext에 데이터 수신자로 등록
|
||||
useEffect(() => {
|
||||
if (screenContext && component.id) {
|
||||
console.log("📋 [RepeaterFieldGroup] ScreenContext에 데이터 수신자 등록:", component.id);
|
||||
screenContext.registerDataReceiver(component.id, dataReceiver);
|
||||
|
||||
|
||||
return () => {
|
||||
screenContext.unregisterDataReceiver(component.id);
|
||||
};
|
||||
|
|
@ -358,16 +627,16 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
// SplitPanelContext에 데이터 수신자로 등록 (분할 패널 내에서만)
|
||||
useEffect(() => {
|
||||
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||
|
||||
|
||||
if (splitPanelContext?.isInSplitPanel && splitPanelPosition && component.id) {
|
||||
console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에 데이터 수신자 등록:", {
|
||||
componentId: component.id,
|
||||
position: splitPanelPosition,
|
||||
});
|
||||
|
||||
|
||||
splitPanelContext.registerReceiver(splitPanelPosition, component.id, dataReceiver);
|
||||
receiverRef.current = dataReceiver;
|
||||
|
||||
|
||||
return () => {
|
||||
console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에서 데이터 수신자 해제:", component.id);
|
||||
splitPanelContext.unregisterReceiver(splitPanelPosition, component.id);
|
||||
|
|
@ -380,13 +649,13 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
useEffect(() => {
|
||||
const handleSplitPanelDataTransfer = (event: CustomEvent) => {
|
||||
const { data, mode, mappingRules } = event.detail;
|
||||
|
||||
|
||||
console.log("📥 [RepeaterFieldGroup] splitPanelDataTransfer 이벤트 수신:", {
|
||||
dataCount: data?.length,
|
||||
mode,
|
||||
componentId: component.id,
|
||||
});
|
||||
|
||||
|
||||
// 우측 패널의 리피터 필드 그룹만 데이터를 수신
|
||||
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||
if (splitPanelPosition === "right" && data && data.length > 0) {
|
||||
|
|
@ -395,51 +664,113 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
};
|
||||
|
||||
window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
|
||||
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
|
||||
};
|
||||
}, [screenContext?.splitPanelPosition, handleReceiveData, component.id]);
|
||||
|
||||
// 🆕 RepeaterInput에서 항목 변경 시 SplitPanelContext의 addedItemIds 동기화
|
||||
const handleRepeaterChange = useCallback((newValue: any[]) => {
|
||||
// 배열을 JSON 문자열로 변환하여 저장
|
||||
const jsonValue = JSON.stringify(newValue);
|
||||
onChange?.(jsonValue);
|
||||
|
||||
// 🆕 groupedData 상태도 업데이트
|
||||
setGroupedData(newValue);
|
||||
|
||||
// 🆕 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);
|
||||
const handleRepeaterChange = useCallback(
|
||||
(newValue: any[]) => {
|
||||
// 🆕 분할 패널에서 우측인 경우, 새 항목에 FK 값과 targetTable 추가
|
||||
let valueWithMeta = newValue;
|
||||
|
||||
if (isRightPanel && effectiveTargetTable) {
|
||||
valueWithMeta = newValue.map((item: any) => {
|
||||
const itemWithMeta = {
|
||||
...item,
|
||||
_targetTable: effectiveTargetTable,
|
||||
};
|
||||
|
||||
// 🆕 FK 값이 있고 새 항목이면 FK 컬럼에 값 추가
|
||||
if (fkColumn && fkValue && item._isNewItem) {
|
||||
itemWithMeta[fkColumn] = fkValue;
|
||||
console.log("🔗 [RepeaterFieldGroup] 새 항목에 FK 값 추가:", {
|
||||
fkColumn,
|
||||
fkValue,
|
||||
});
|
||||
}
|
||||
|
||||
return itemWithMeta;
|
||||
});
|
||||
}
|
||||
|
||||
// 새로 추가된 ID가 있으면 등록
|
||||
const newIds = currentIds.filter((id: string) => !addedIds.has(id));
|
||||
if (newIds.length > 0) {
|
||||
console.log("➕ [RepeaterFieldGroup] 새 항목 ID 추가:", newIds);
|
||||
splitPanelContext.addItemIds(newIds);
|
||||
|
||||
// 배열을 JSON 문자열로 변환하여 저장
|
||||
const jsonValue = JSON.stringify(valueWithMeta);
|
||||
console.log("📤 [RepeaterFieldGroup] 데이터 변경:", {
|
||||
fieldName,
|
||||
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 (
|
||||
<RepeaterInput
|
||||
value={parsedValue}
|
||||
onChange={handleRepeaterChange}
|
||||
config={config}
|
||||
config={effectiveConfig}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
menuObjid={menuObjid}
|
||||
|
|
|
|||
|
|
@ -379,10 +379,41 @@ export class ButtonActionExecutor {
|
|||
/**
|
||||
* 저장 액션 처리 (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> {
|
||||
this.saveCallCount++;
|
||||
const callId = this.saveCallCount;
|
||||
|
||||
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 콜백이 있으면 우선 사용
|
||||
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의 외부 테이블 데이터 저장 처리
|
||||
const repeatScreenModalKeys = Object.keys(context.formData).filter(
|
||||
(key) => key.startsWith("_repeatScreenModal_") && key !== "_repeatScreenModal_aggregations",
|
||||
|
|
@ -814,11 +946,36 @@ export class ButtonActionExecutor {
|
|||
|
||||
// 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) {
|
||||
console.log(`⏭️ [handleSave] ${tableName} 메인 저장 건너뜀 (RepeatScreenModal에서 처리)`);
|
||||
saveResult = { success: true, message: "RepeatScreenModal에서 처리" };
|
||||
console.log(
|
||||
`⏭️ [handleSave] ${tableName} 메인 저장 건너뜀 (RepeaterFieldGroup/RepeatScreenModal에서 처리)`,
|
||||
{
|
||||
repeatScreenModalTables,
|
||||
repeaterFieldGroupTables,
|
||||
},
|
||||
);
|
||||
saveResult = { success: true, message: "RepeaterFieldGroup/RepeatScreenModal에서 처리" };
|
||||
} else {
|
||||
saveResult = await DynamicFormApi.saveFormData({
|
||||
screenId,
|
||||
|
|
|
|||
|
|
@ -5,21 +5,21 @@
|
|||
/**
|
||||
* 테이블 타입 관리(table_type_columns)에서 사용하는 input_type 값들
|
||||
*/
|
||||
export type RepeaterFieldType =
|
||||
| "text" // 텍스트
|
||||
| "number" // 숫자
|
||||
| "textarea" // 텍스트영역
|
||||
| "date" // 날짜
|
||||
| "select" // 선택박스
|
||||
| "checkbox" // 체크박스
|
||||
| "radio" // 라디오
|
||||
| "category" // 카테고리
|
||||
| "entity" // 엔티티 참조
|
||||
| "code" // 공통코드
|
||||
| "image" // 이미지
|
||||
| "direct" // 직접입력
|
||||
export type RepeaterFieldType =
|
||||
| "text" // 텍스트
|
||||
| "number" // 숫자
|
||||
| "textarea" // 텍스트영역
|
||||
| "date" // 날짜
|
||||
| "select" // 선택박스
|
||||
| "checkbox" // 체크박스
|
||||
| "radio" // 라디오
|
||||
| "category" // 카테고리
|
||||
| "entity" // 엔티티 참조
|
||||
| "code" // 공통코드
|
||||
| "image" // 이미지
|
||||
| "direct" // 직접입력
|
||||
| "calculated" // 계산식 필드
|
||||
| string; // 기타 커스텀 타입 허용
|
||||
| string; // 기타 커스텀 타입 허용
|
||||
|
||||
/**
|
||||
* 계산식 연산자
|
||||
|
|
@ -32,11 +32,11 @@ export type CalculationOperator = "+" | "-" | "*" | "/" | "%" | "round" | "floor
|
|||
* 예: { field1: "amount", operator: "round", decimalPlaces: 2 } → round(amount, 2)
|
||||
*/
|
||||
export interface CalculationFormula {
|
||||
field1: string; // 첫 번째 필드명
|
||||
field1: string; // 첫 번째 필드명
|
||||
operator: CalculationOperator; // 연산자
|
||||
field2?: string; // 두 번째 필드명 (단항 연산자의 경우 불필요)
|
||||
constantValue?: number; // 상수값 (field2 대신 사용 가능)
|
||||
decimalPlaces?: number; // 소수점 자릿수 (round, floor, ceil에서 사용)
|
||||
field2?: string; // 두 번째 필드명 (단항 연산자의 경우 불필요)
|
||||
constantValue?: number; // 상수값 (field2 대신 사용 가능)
|
||||
decimalPlaces?: number; // 소수점 자릿수 (round, floor, ceil에서 사용)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -84,6 +84,7 @@ export interface RepeaterFieldGroupConfig {
|
|||
fields: RepeaterFieldDefinition[]; // 반복될 필드 정의
|
||||
targetTable?: string; // 저장할 대상 테이블 (미지정 시 메인 화면 테이블)
|
||||
groupByColumn?: string; // 수정 모드에서 그룹화할 컬럼 (예: "inbound_number")
|
||||
fkColumn?: string; // 분할 패널에서 좌측 선택 데이터와 연결할 FK 컬럼 (예: "serial_no")
|
||||
minItems?: number; // 최소 항목 수
|
||||
maxItems?: number; // 최대 항목 수
|
||||
addButtonText?: string; // 추가 버튼 텍스트
|
||||
|
|
|
|||
Loading…
Reference in New Issue