Files
vexplor/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx
kjs 28ef7e1226 fix: Enhance error handling and validation messages in form data operations
- Integrated `formatPgError` utility to provide user-friendly error messages based on PostgreSQL error codes during form data operations.
- Updated error responses in `saveFormData`, `saveFormDataEnhanced`, `updateFormData`, and `updateFormDataPartial` to include specific messages based on the company context.
- Improved error handling in the frontend components to display relevant error messages from the server response, ensuring users receive clear feedback on save operations.
- Enhanced the required field validation by incorporating NOT NULL metadata checks across various components, improving the accuracy of form submissions.

These changes improve the overall user experience by providing clearer error messages and ensuring that required fields are properly validated based on both manual settings and database constraints.
2026-03-10 14:47:05 +09:00

2693 lines
110 KiB
TypeScript

"use client";
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { useSearchParams } from "next/navigation";
import { ComponentRendererProps } from "@/types/component";
import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, ItemData, GroupEntry, DisplayItem } from "./types";
import { useModalDataStore, ModalDataItem } from "@/stores/modalDataStore";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
X,
Check,
Plus,
Minus,
Edit,
Trash2,
Search,
Save,
RefreshCw,
AlertCircle,
Info,
Settings,
ChevronDown,
ChevronUp,
ChevronRight,
Copy,
Download,
Upload,
ExternalLink,
type LucideIcon,
} from "lucide-react";
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
X, Check, Plus, Minus, Edit, Trash2, Search, Save, RefreshCw,
AlertCircle, Info, Settings, ChevronDown, ChevronUp, ChevronRight,
Copy, Download, Upload, ExternalLink,
};
import { commonCodeApi } from "@/lib/api/commonCode";
import { cn } from "@/lib/utils";
import { isColumnRequiredByMeta } from "@/lib/registry/DynamicComponentRenderer";
export interface SelectedItemsDetailInputComponentProps extends ComponentRendererProps {
config?: SelectedItemsDetailInputConfig;
}
/**
* SelectedItemsDetailInput 컴포넌트
* 선택된 항목들의 상세 정보를 입력하는 컴포넌트
*/
export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInputComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
isInteractive = false,
onClick,
onDragStart,
onDragEnd,
config,
className,
style,
formData,
onFormDataChange,
screenId,
...props
}) => {
// 🆕 groupedData 추출 (DynamicComponentRenderer에서 전달)
const groupedData = (props as any).groupedData || (props as any)._groupedData;
// 🆕 URL 파라미터에서 dataSourceId 읽기
const searchParams = useSearchParams();
const urlDataSourceId = searchParams?.get("dataSourceId") || undefined;
// 컴포넌트 설정
const componentConfig = useMemo(
() =>
({
dataSourceId: component.id || "default",
displayColumns: [],
additionalFields: [],
layout: "grid",
inputMode: "inline", // 🆕 기본값
showIndex: true,
allowRemove: false,
emptyMessage: "전달받은 데이터가 없습니다.",
targetTable: "",
...config,
...component.config,
}) as SelectedItemsDetailInputConfig,
[config, component.config, component.id],
);
// 소스 테이블의 키 필드명
// 우선순위: 1) config에서 명시적 설정 → 2) additionalFields에서 autoFillFrom:"id" 필드 감지 → 3) 하위 호환 "item_id"
const sourceKeyField = useMemo(() => {
// sourceKeyField는 config에서 직접 지정 (ConfigPanel 자동 감지에서 설정됨)
return componentConfig.sourceKeyField || "item_id";
}, [componentConfig.sourceKeyField]);
// 🆕 dataSourceId 우선순위: URL 파라미터 > 컴포넌트 설정 > component.id
const dataSourceId = useMemo(
() => urlDataSourceId || componentConfig.dataSourceId || component.id || "default",
[urlDataSourceId, componentConfig.dataSourceId, component.id],
);
// 중복 저장 방지 가드
const isSavingRef = useRef(false);
// 전체 레지스트리를 가져와서 컴포넌트 내부에서 필터링 (캐싱 문제 회피)
const dataRegistry = useModalDataStore((state) => state.dataRegistry);
const modalData = useMemo(() => dataRegistry[dataSourceId] || [], [dataRegistry, dataSourceId]);
// 전체 dataRegistry를 사용 (모든 누적 데이터에 접근 가능)
const updateItemData = useModalDataStore((state) => state.updateItemData);
// 🆕 새로운 데이터 구조: 품목별로 여러 개의 상세 데이터
const [items, setItems] = useState<ItemData[]>([]);
// 🆕 입력 모드 상태 (modal 모드일 때 사용)
const [isEditing, setIsEditing] = useState(false);
const [editingItemId, setEditingItemId] = useState<string | null>(null); // 현재 편집 중인 품목 ID
const [editingGroupId, setEditingGroupId] = useState<string | null>(null); // 현재 편집 중인 그룹 ID (레거시 호환)
const [editingDetailId, setEditingDetailId] = useState<string | null>(null); // 현재 편집 중인 항목 ID (레거시 호환)
// 🆕 그룹별 독립 편집 상태: { [groupId]: entryId }
const [editingEntries, setEditingEntries] = useState<Record<string, string | null>>({});
// 🆕 코드 카테고리별 옵션 캐싱
const [codeOptions, setCodeOptions] = useState<Record<string, Array<{ label: string; value: string }>>>({});
// 디버깅 로그 (제거됨)
// 🆕 필드에 codeCategory가 있으면 자동으로 옵션 로드
useEffect(() => {
const loadCodeOptions = async () => {
// code/category 타입 필드 + codeCategory가 있는 필드 모두 처리
const codeFields = componentConfig.additionalFields?.filter(
(field) => field.inputType === "code" || field.inputType === "category",
);
if (!codeFields || codeFields.length === 0) {
return;
}
const newOptions: Record<string, Array<{ label: string; value: string }>> = { ...codeOptions };
// 🆕 그룹별 sourceTable 매핑 구성
const groups = componentConfig.fieldGroups || [];
const groupSourceTableMap: Record<string, string> = {};
groups.forEach((g) => {
if (g.sourceTable) {
groupSourceTableMap[g.id] = g.sourceTable;
}
});
const defaultTargetTable = componentConfig.targetTable;
// 테이블별 컬럼 메타데이터 캐시
const tableColumnsCache: Record<string, any[]> = {};
const getTableColumns = async (tableName: string) => {
if (tableColumnsCache[tableName]) return tableColumnsCache[tableName];
try {
const { tableTypeApi } = await import("@/lib/api/screen");
const columnsResponse = await tableTypeApi.getColumns(tableName);
tableColumnsCache[tableName] = columnsResponse || [];
return tableColumnsCache[tableName];
} catch (error) {
console.error(`❌ 테이블 컬럼 조회 실패 (${tableName}):`, error);
return [];
}
};
for (const field of codeFields) {
if (newOptions[field.name]) {
continue;
}
// 🆕 필드의 그룹 sourceTable 결정
const fieldSourceTable = (field.groupId && groupSourceTableMap[field.groupId]) || defaultTargetTable;
try {
if (field.inputType === "category" && fieldSourceTable) {
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
const response = await getCategoryValues(fieldSourceTable, field.name, false);
if (response.success && response.data && response.data.length > 0) {
newOptions[field.name] = response.data.map((item: any) => ({
label: item.value_label || item.valueLabel,
value: item.value_code || item.valueCode,
}));
} else {
}
} else if (field.inputType === "code") {
let codeCategory = field.codeCategory;
if (!codeCategory && fieldSourceTable) {
const targetTableColumns = await getTableColumns(fieldSourceTable);
if (targetTableColumns.length > 0) {
const columnMeta = targetTableColumns.find(
(col: any) => (col.columnName || col.column_name) === field.name,
);
if (columnMeta) {
codeCategory = columnMeta.codeCategory || columnMeta.code_category;
}
}
}
if (!codeCategory) {
continue;
}
const response = await commonCodeApi.options.getOptions(codeCategory);
if (response.success && response.data) {
newOptions[field.name] = response.data.map((opt) => ({
label: opt.label,
value: opt.value,
}));
}
}
} catch (error) {
console.error(`❌ 옵션 로드 실패 (${field.name}):`, error);
}
}
setCodeOptions(newOptions);
};
loadCodeOptions();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [componentConfig.additionalFields, componentConfig.targetTable]);
// 🆕 모달 데이터를 ItemData 구조로 변환 (그룹별 구조)
useEffect(() => {
// 🆕 수정 모드: groupedData 또는 formData에서 데이터 로드 (URL에 mode=edit이 있으면)
const urlParams = new URLSearchParams(window.location.search);
const mode = urlParams.get("mode");
// 🔧 데이터 소스 우선순위: groupedData > formData (배열) > formData (객체)
const sourceData = groupedData && Array.isArray(groupedData) && groupedData.length > 0
? groupedData
: formData;
if (mode === "edit" && sourceData) {
const loadEditData = async () => {
const isArray = Array.isArray(sourceData);
const dataArray = isArray ? sourceData : [sourceData];
if (dataArray.length === 0 || (dataArray.length === 1 && Object.keys(dataArray[0]).length === 0)) {
return;
}
const groups = componentConfig.fieldGroups || [];
const additionalFields = componentConfig.additionalFields || [];
const firstRecord = dataArray[0];
// 수정 모드: 모든 관련 테이블의 데이터를 API로 전체 로드
// sourceData는 클릭한 1개 레코드만 포함할 수 있으므로, API로 전체를 다시 가져옴
const editTableName = new URLSearchParams(window.location.search).get("tableName");
const allTableData: Record<string, Record<string, any>[]> = {};
// 동적 필터 구성: parentDataMapping의 targetField + sourceKeyField
const editFilters: Record<string, any> = {};
const parentMappings = componentConfig.parentDataMapping || [];
parentMappings.forEach((mapping: any) => {
if (mapping.targetField && firstRecord[mapping.targetField]) {
editFilters[mapping.targetField] = firstRecord[mapping.targetField];
}
});
if (firstRecord[sourceKeyField]) {
editFilters[sourceKeyField] = firstRecord[sourceKeyField];
}
const hasRequiredKeys = Object.keys(editFilters).length >= 2;
if (hasRequiredKeys) {
try {
const { dataApi } = await import("@/lib/api/data");
// 모든 sourceTable의 데이터를 API로 전체 로드 (중복 테이블 제거)
const allTables = groups
.map((g) => g.sourceTable || editTableName)
.filter((v, i, a) => v && a.indexOf(v) === i) as string[];
for (const table of allTables) {
const response = await dataApi.getTableData(table, {
filters: editFilters,
sortBy: "created_date",
sortOrder: "desc",
});
if (response.data && response.data.length > 0) {
allTableData[table] = response.data;
}
}
} catch (err) {
console.error("❌ 편집 데이터 전체 로드 실패:", err);
}
}
const mainFieldGroups: Record<string, GroupEntry[]> = {};
groups.forEach((group) => {
const groupFields = additionalFields.filter((field: any) => field.groupId === group.id);
if (groupFields.length === 0) {
mainFieldGroups[group.id] = [];
return;
}
// 이 그룹의 sourceTable 결정 → API에서 가져온 전체 데이터 사용
const groupTable = group.sourceTable || editTableName || "";
// 현재 테이블만 sourceData fallback 허용 (다른 테이블은 빈 배열 → id 크로스오염 방지)
const isCurrentTable = !group.sourceTable || group.sourceTable === editTableName;
const groupDataList = allTableData[groupTable] || (isCurrentTable ? dataArray : []);
{
// 모든 테이블 그룹: API에서 가져온 전체 레코드를 entry로 변환
const entriesMap = new Map<string, GroupEntry>();
groupDataList.forEach((record) => {
const entryData: Record<string, any> = {};
groupFields.forEach((field: any) => {
let fieldValue = record[field.name];
// 값이 없으면 autoFillFrom 로직 적용
if ((fieldValue === undefined || fieldValue === null) && field.autoFillFrom) {
let src: any = null;
if (field.autoFillFromTable) {
const tableData = dataRegistry[field.autoFillFromTable];
if (tableData && tableData.length > 0) {
src = tableData[0].originalData || tableData[0];
} else {
src = record;
}
} else {
src = record;
}
if (src && src[field.autoFillFrom] !== undefined) {
fieldValue = src[field.autoFillFrom];
} else {
const possibleKeys = Object.keys(src || {}).filter((key) =>
key.endsWith(`_${field.autoFillFrom}`),
);
if (possibleKeys.length > 0) {
fieldValue = src[possibleKeys[0]];
}
}
}
if (fieldValue === undefined || fieldValue === null) {
if (field.defaultValue !== undefined) {
fieldValue = field.defaultValue;
} else if (field.type === "checkbox") {
fieldValue = false;
} else {
return;
}
}
// 날짜 타입이면 YYYY-MM-DD 형식으로 변환
if (field.type === "date" || field.type === "datetime") {
const dateStr = String(fieldValue);
const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (match) {
const [, year, month, day] = match;
fieldValue = `${year}-${month}-${day}`;
}
}
entryData[field.name] = fieldValue;
});
const entryKey = JSON.stringify(entryData);
if (!entriesMap.has(entryKey)) {
entriesMap.set(entryKey, {
id: `${group.id}_entry_${entriesMap.size + 1}`,
// DB 레코드의 고유 id(UUID PK) 보존 → 수정 시 이 id로 UPDATE
_dbRecordId: record.id || null,
...entryData,
});
}
});
mainFieldGroups[group.id] = Array.from(entriesMap.values());
}
});
if (groups.length === 0) {
mainFieldGroups["default"] = [];
}
const newItem: ItemData = {
// 수정 모드: sourceKeyField를 우선 사용 (id는 가격레코드의 PK일 수 있음)
id: String(firstRecord[sourceKeyField] || firstRecord.id || "edit"),
originalData: firstRecord,
fieldGroups: mainFieldGroups,
};
setItems([newItem]);
};
loadEditData();
return;
}
// 생성 모드: modalData에서 데이터 로드
if (modalData && modalData.length > 0) {
// 🆕 각 품목마다 빈 fieldGroups 객체를 가진 ItemData 생성
const groups = componentConfig.fieldGroups || [];
const newItems: ItemData[] = modalData.map((item) => {
const fieldGroups: Record<string, GroupEntry[]> = {};
// 각 그룹에 대해 초기화 (maxEntries === 1이면 자동 1개 생성)
groups.forEach((group) => {
if (group.maxEntries === 1) {
// 1:1 관계: 빈 entry 1개 자동 생성
fieldGroups[group.id] = [{ id: `${group.id}_auto_1` }];
} else {
fieldGroups[group.id] = [];
}
});
// 그룹이 없으면 기본 그룹 생성
if (groups.length === 0) {
fieldGroups["default"] = [];
}
// 🔧 modalData의 구조 확인: item.originalData가 있으면 그것을 사용, 없으면 item 자체를 사용
const actualData = (item as any).originalData || item;
return {
id: String(item.id),
originalData: actualData, // 🔧 실제 데이터 추출
fieldGroups,
};
});
setItems(newItems);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [modalData, component.id, componentConfig.fieldGroups, formData, groupedData]); // groupedData 의존성 추가
// 🆕 Cartesian Product 생성 함수 (items에서 모든 그룹의 조합을 생성)
const generateCartesianProduct = useCallback(
(itemsList: ItemData[]): Record<string, any>[] => {
const allRecords: Record<string, any>[] = [];
const groups = componentConfig.fieldGroups || [];
const additionalFields = componentConfig.additionalFields || [];
itemsList.forEach((item, itemIndex) => {
// 각 그룹의 엔트리 배열들을 준비
// 🔧 빈 엔트리 필터링: id만 있고 실제 필드 값이 없는 엔트리는 제외
const groupEntriesArrays: GroupEntry[][] = groups.map((group) => {
const entries = item.fieldGroups[group.id] || [];
const groupFields = additionalFields.filter((f) => f.groupId === group.id);
// 실제 필드 값이 하나라도 있는 엔트리만 포함
return entries.filter((entry) => {
const hasAnyFieldValue = groupFields.some((field) => {
const value = entry[field.name];
return value !== undefined && value !== null && value !== "";
});
return hasAnyFieldValue;
});
});
// 🆕 모든 그룹이 비어있는지 확인
const allGroupsEmpty = groupEntriesArrays.every((arr) => arr.length === 0);
if (allGroupsEmpty) {
// 디테일 데이터가 없어도 기본 레코드 생성 (품목-거래처 매핑 유지)
const baseRecord: Record<string, any> = {};
// sourceKeyField 자동 매핑 (item_id = originalData.id)
if (sourceKeyField && item.originalData?.id) {
baseRecord[sourceKeyField] = item.originalData.id;
}
// 나머지 autoFillFrom 필드 (sourceKeyField 제외)
additionalFields.forEach((f) => {
if (f.name !== sourceKeyField && f.autoFillFrom && item.originalData) {
const value = item.originalData[f.autoFillFrom];
if (value !== undefined && value !== null) {
baseRecord[f.name] = value;
}
}
});
allRecords.push(baseRecord);
return;
}
// Cartesian Product 재귀 함수
const cartesian = (arrays: GroupEntry[][], currentIndex: number, currentCombination: Record<string, any>) => {
if (currentIndex === arrays.length) {
// 모든 그룹을 순회했으면 조합 완성
allRecords.push({ ...currentCombination });
return;
}
const currentGroupEntries = arrays[currentIndex];
if (currentGroupEntries.length === 0) {
// 🆕 현재 그룹에 데이터가 없으면 빈 조합으로 다음 그룹 진행
// (그룹이 비어있어도 다른 그룹의 데이터로 레코드 생성)
cartesian(arrays, currentIndex + 1, currentCombination);
return;
}
// 현재 그룹의 각 엔트리마다 재귀
currentGroupEntries.forEach((entry) => {
const newCombination = { ...currentCombination };
// 🆕 기존 레코드의 id가 있으면 포함 (UPDATE를 위해)
if (entry.id) {
newCombination.id = entry.id;
}
// 현재 그룹의 필드들을 조합에 추가
const groupFields = additionalFields.filter((f) => f.groupId === groups[currentIndex].id);
groupFields.forEach((field) => {
if (entry[field.name] !== undefined) {
newCombination[field.name] = entry[field.name];
}
});
cartesian(arrays, currentIndex + 1, newCombination);
});
};
// 재귀 시작
cartesian(groupEntriesArrays, 0, {});
});
return allRecords;
},
[componentConfig.fieldGroups, componentConfig.additionalFields, sourceKeyField],
);
// 🆕 저장 요청 시에만 데이터 전달 (이벤트 리스너 방식)
useEffect(() => {
const handleSaveRequest = async (event: Event) => {
// 중복 저장 방지
// 항상 skipDefaultSave 설정 (buttonActions.ts의 이중 저장 방지)
if (event instanceof CustomEvent && event.detail) {
(event.detail as any).skipDefaultSave = true;
}
if (isSavingRef.current) return;
isSavingRef.current = true;
// component.id를 문자열로 안전하게 변환
const componentKey = String(component.id || "selected_items");
if (items.length === 0) {
isSavingRef.current = false;
return;
}
// parentDataMapping이 있으면 UPSERT API로 직접 저장
const hasParentMapping = componentConfig.parentDataMapping && componentConfig.parentDataMapping.length > 0;
if (hasParentMapping) {
try {
// 수정 모드 감지 (parentKeys 구성 전에 필요)
const urlParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null;
const urlEditMode = urlParams?.get("mode") === "edit";
const dataHasDbId = items.some(item => !!item.originalData?.id);
const isEditMode = urlEditMode || dataHasDbId;
// 부모 키 추출 (parentDataMapping에서)
const parentKeys: Record<string, any> = {};
// formData 또는 items[0].originalData에서 부모 데이터 가져오기
// formData가 배열이면 첫 번째 항목 사용
let sourceData: any = formData;
if (Array.isArray(formData) && formData.length > 0) {
sourceData = formData[0];
} else if (!formData) {
sourceData = items[0]?.originalData || {};
}
componentConfig.parentDataMapping.forEach((mapping) => {
let value: any;
// 수정 모드: originalData의 targetField 값 우선 사용
// 로드(editFilters)와 동일한 방식으로 FK 값을 가져와야
// 백엔드에서 기존 레코드를 정확히 매칭하여 UPDATE 수행 가능
if (isEditMode && items.length > 0 && items[0].originalData) {
value = items[0].originalData[mapping.targetField];
}
// 신규 모드 또는 originalData에 값 없으면 기존 로직
if (value === undefined || value === null) {
value = getFieldValue(sourceData, mapping.sourceField);
if ((value === undefined || value === null) && mapping.sourceTable) {
const registryData = dataRegistry[mapping.sourceTable];
if (registryData && registryData.length > 0) {
const registryItem = registryData[0].originalData || registryData[0];
value = registryItem[mapping.sourceField];
}
}
}
if (value !== undefined && value !== null) {
parentKeys[mapping.targetField] = value;
} else {
console.warn(`⚠️ 부모 키 누락: ${mapping.sourceField}${mapping.targetField}`);
}
});
// 🔒 parentKeys 유효성 검증 - 빈 값이 있으면 저장 중단
const parentKeyValues = Object.values(parentKeys);
const hasEmptyParentKey = parentKeyValues.length === 0 ||
parentKeyValues.some(v => v === null || v === undefined || v === "");
if (hasEmptyParentKey) {
console.error("❌ parentKeys 비어있음:", parentKeys);
window.dispatchEvent(
new CustomEvent("formSaveError", {
detail: { message: "부모 키 값이 비어있어 저장할 수 없습니다. 먼저 상위 데이터를 선택해주세요." },
}),
);
// 🔧 기본 저장 건너뛰기 - event.detail 객체 직접 수정
if (event instanceof CustomEvent && event.detail) {
(event.detail as any).skipDefaultSave = true;
}
isSavingRef.current = false;
return;
}
// targetTable 검증
if (!componentConfig.targetTable) {
window.dispatchEvent(
new CustomEvent("formSaveError", {
detail: { message: "대상 테이블이 설정되지 않았습니다." },
}),
);
if (event instanceof CustomEvent && event.detail) {
(event.detail as any).skipDefaultSave = true;
}
isSavingRef.current = false;
return;
}
// 🔧 기본 저장 건너뛰기 설정 (UPSERT 전에!)
if (event instanceof CustomEvent && event.detail) {
(event.detail as any).skipDefaultSave = true;
}
const { dataApi } = await import("@/lib/api/data");
const groups = componentConfig.fieldGroups || [];
const additionalFields = componentConfig.additionalFields || [];
const mainTable = componentConfig.targetTable!;
console.log("[SelectedItemsDetailInput] 수정 모드 감지:", {
urlEditMode,
dataHasDbId,
isEditMode,
itemCount: items.length,
firstItemId: items[0]?.originalData?.id,
});
// fieldGroup별 sourceTable 분류
const groupsByTable = new Map<string, typeof groups>();
groups.forEach((group) => {
const table = group.sourceTable || mainTable;
if (!groupsByTable.has(table)) {
groupsByTable.set(table, []);
}
groupsByTable.get(table)!.push(group);
});
// 디테일 테이블이 있는지 확인 (mainTable과 다른 sourceTable)
const detailTables = [...groupsByTable.keys()].filter((t) => t !== mainTable);
const hasDetailTable = detailTables.length > 0;
if (hasDetailTable) {
// ============================================================
// 2단계 저장: 메인 테이블 + 디테일 테이블 분리 저장
// upsertGroupedRecords를 양쪽 모두 사용 (정확한 매칭 보장)
// ============================================================
const mainGroups = groupsByTable.get(mainTable) || [];
for (const item of items) {
// sourceKeyField 값 추출 (예: item_id 또는 customer_id)
let sourceKeyValue: string | null = null;
// 1순위: originalData에 sourceKeyField가 직접 있으면 사용 (수정 모드)
if (item.originalData && item.originalData[sourceKeyField]) {
sourceKeyValue = item.originalData[sourceKeyField];
}
// 2순위: 원본 데이터의 id를 sourceKeyField 값으로 사용 (신규 등록 모드)
if (!sourceKeyValue && item.originalData) {
sourceKeyValue = item.originalData.id || null;
}
if (!sourceKeyValue) {
console.error(`❌ [2단계 저장] ${sourceKeyField}를 찾을 수 없음:`, item);
continue;
}
// upsert 공통 parentKeys: parentMapping 키 + sourceKeyField (정확한 매칭)
const itemParentKeys = { ...parentKeys, [sourceKeyField]: sourceKeyValue };
// === Step 1: 메인 테이블(customer_item_mapping) 저장 ===
// 여러 개의 매핑 레코드 지원 (거래처 품번/품명이 다중일 수 있음)
const mappingRecords: Record<string, any>[] = [];
mainGroups.forEach((group) => {
const entries = item.fieldGroups[group.id] || [];
const groupFields = additionalFields.filter((f) => f.groupId === group.id);
entries.forEach((entry) => {
const record: Record<string, any> = {};
groupFields.forEach((field) => {
const val = entry[field.name];
if (val !== undefined && val !== null && val !== "") {
record[field.name] = val;
}
});
// 기존 DB 레코드의 고유 id(PK)가 있으면 포함 → 백엔드에서 이 id로 UPDATE
if (entry._dbRecordId) {
record.id = entry._dbRecordId;
}
// sourceKeyField는 정확한 sourceKeyValue 변수 사용 (autoFillFrom:"id" 오작동 방지)
record[sourceKeyField] = sourceKeyValue;
// 나머지 autoFillFrom 필드 처리
groupFields.forEach((field) => {
if (field.name !== sourceKeyField && field.autoFillFrom && item.originalData) {
const value = item.originalData[field.autoFillFrom];
if (value !== undefined && value !== null && !record[field.name]) {
record[field.name] = value;
}
}
});
mappingRecords.push(record);
});
});
// 수정 모드이거나 레코드에 id(기존 DB PK)가 있으면 → 고아 삭제 (기존 레코드 교체)
// 신규 등록이고 id 없으면 → 기존 레코드 건드리지 않음
const mappingHasDbIds = mappingRecords.some((r) => !!r.id);
const shouldDeleteOrphans = isEditMode || mappingHasDbIds;
console.log(`[SelectedItemsDetailInput] ${mainTable} 저장:`, {
isEditMode,
mappingHasDbIds,
shouldDeleteOrphans,
recordCount: mappingRecords.length,
recordIds: mappingRecords.map(r => r.id || "NEW"),
parentKeys: itemParentKeys,
});
// 저장된 매핑 ID를 추적 (디테일 테이블에 mapping_id 주입용)
let savedMappingIds: string[] = [];
try {
const mappingResult = await dataApi.upsertGroupedRecords(
mainTable,
itemParentKeys,
mappingRecords,
{ deleteOrphans: shouldDeleteOrphans },
);
// 백엔드에서 반환된 저장된 레코드 ID 목록
if (mappingResult.success && mappingResult.savedIds) {
savedMappingIds = mappingResult.savedIds;
console.log(`${mainTable} 저장 완료, savedIds:`, savedMappingIds);
}
} catch (err) {
console.error(`${mainTable} 저장 실패:`, err);
}
// === Step 2: 디테일 테이블(customer_item_prices) 저장 ===
for (const detailTable of detailTables) {
const detailGroups = groupsByTable.get(detailTable) || [];
const priceRecords: Record<string, any>[] = [];
detailGroups.forEach((group) => {
const entries = item.fieldGroups[group.id] || [];
const groupFields = additionalFields.filter((f) => f.groupId === group.id);
entries.forEach((entry) => {
// 사용자가 실제 입력한 값이 있는지 확인
// select/category 필드는 항상 기본값이 있으므로 제외하고 판별
const hasUserInput = groupFields.some((field) => {
// 셀렉트/카테고리 필드는 기본값이 자동 설정되므로 무시
if (field.type === "select" || field.inputType === "code" || field.inputType === "category") {
return false;
}
const value = entry[field.name];
if (value === undefined || value === null || value === "") return false;
if (value === 0 || value === "0" || value === "0.00") return false;
return true;
});
if (hasUserInput) {
const priceRecord: Record<string, any> = {};
groupFields.forEach((field) => {
const val = entry[field.name];
if (val !== undefined && val !== null) {
priceRecord[field.name] = val;
}
});
// 기존 DB 레코드의 고유 id(PK)가 있으면 포함 → 백엔드에서 이 id로 UPDATE
if (entry._dbRecordId) {
priceRecord.id = entry._dbRecordId;
}
priceRecords.push(priceRecord);
}
});
});
// 빈 항목이라도 최소 레코드 생성 (우측 패널에 표시되도록)
if (priceRecords.length === 0) {
// select/category 필드를 명시적 null로 설정 (DB DEFAULT 'KRW' 등 방지)
const emptyRecord: Record<string, any> = {};
const detailGroupFields = additionalFields.filter((f) =>
detailGroups.some((g) => g.id === f.groupId),
);
detailGroupFields.forEach((field) => {
if (field.type === "select" || field.inputType === "code" || field.inputType === "category") {
emptyRecord[field.name] = null;
}
});
priceRecords.push(emptyRecord);
}
// Step1에서 저장된 매핑 ID를 디테일 레코드에 주입
// (customer_item_prices.mapping_id ← customer_item_mapping.id)
if (savedMappingIds.length > 0) {
const mappingId = savedMappingIds[0]; // 일반적으로 1:N (매핑 1개 : 단가 N개)
priceRecords.forEach((record) => {
if (!record.mapping_id) {
record.mapping_id = mappingId;
}
});
console.log(`🔗 디테일 레코드에 mapping_id 주입: ${mappingId}`);
}
const priceHasDbIds = priceRecords.some((r) => !!r.id);
const shouldDeleteDetailOrphans = isEditMode || priceHasDbIds;
console.log(`[SelectedItemsDetailInput] ${detailTable} 저장:`, {
isEditMode,
priceHasDbIds,
shouldDeleteDetailOrphans,
recordCount: priceRecords.length,
recordIds: priceRecords.map(r => r.id || "NEW"),
parentKeys: itemParentKeys,
});
try {
const detailResult = await dataApi.upsertGroupedRecords(
detailTable,
itemParentKeys,
priceRecords,
{ deleteOrphans: shouldDeleteDetailOrphans },
);
if (!detailResult.success) {
console.error(`${detailTable} 저장 실패:`, detailResult.error);
}
} catch (err) {
console.error(`${detailTable} 오류:`, err);
}
}
}
// 저장 성공 이벤트 + 테이블 새로고침 (모든 아이템 저장 완료 후)
window.dispatchEvent(
new CustomEvent("formSaveSuccess", {
detail: { message: "데이터가 저장되었습니다." },
}),
);
// 분할 패널 우측 데이터 새로고침
window.dispatchEvent(new CustomEvent("refreshTable"));
} else {
// ============================================================
// 단일 테이블 저장 (기존 로직 - detailTable 없는 경우)
// ============================================================
const records = generateCartesianProduct(items);
const singleHasDbIds = records.some((r) => !!r.id);
const shouldDeleteSingleOrphans = isEditMode || singleHasDbIds;
const result = await dataApi.upsertGroupedRecords(mainTable, parentKeys, records, { deleteOrphans: shouldDeleteSingleOrphans });
if (result.success) {
window.dispatchEvent(
new CustomEvent("formSaveSuccess", {
detail: { message: "데이터가 저장되었습니다." },
}),
);
} else {
window.dispatchEvent(
new CustomEvent("formSaveError", {
detail: { message: result.error || "데이터 저장 실패" },
}),
);
}
}
} catch (error) {
console.error("❌ UPSERT 오류:", error);
window.dispatchEvent(
new CustomEvent("formSaveError", {
detail: { message: "데이터 저장 중 오류가 발생했습니다." },
}),
);
// 🆕 오류 발생 시에도 기본 저장 건너뛰기 (중복 저장 방지)
if (event instanceof CustomEvent && event.detail) {
event.detail.skipDefaultSave = true;
}
} finally {
// 저장 완료 후 가드 해제
isSavingRef.current = false;
}
} else {
// 생성 모드: 기존 로직
if (event instanceof CustomEvent && event.detail) {
event.detail.formData[componentKey] = items;
}
// 기존 onFormDataChange도 호출 (호환성)
if (onFormDataChange) {
onFormDataChange(componentKey, items);
}
isSavingRef.current = false;
}
};
// 저장 버튼 클릭 시 데이터 수집
window.addEventListener("beforeFormSave", handleSaveRequest as EventListener);
return () => {
window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener);
};
}, [items, component.id, onFormDataChange, componentConfig, formData, generateCartesianProduct, dataRegistry]);
// 스타일 계산
const componentStyle: React.CSSProperties = {
width: "100%",
height: "100%",
overflowY: "auto", // 항목이 많을 때 스크롤 지원
...component.style,
...style,
};
// 디자인 모드 스타일
if (isDesignMode) {
componentStyle.border = "1px dashed #cbd5e1";
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
componentStyle.padding = "16px";
componentStyle.borderRadius = "8px";
}
// 이벤트 핸들러
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
// 🆕 카테고리 코드 → 라벨명 변환 헬퍼
const getOptionLabel = useCallback(
(fieldName: string, valueCode: string): string => {
const options = codeOptions[fieldName] || [];
const matched = options.find((opt) => opt.value === valueCode);
return matched?.label || valueCode || "";
},
[codeOptions],
);
// 🆕 실시간 단가 계산 함수 (라벨명 기반 - 회사별 코드 무관)
const calculatePrice = useCallback(
(entry: GroupEntry): number => {
if (!componentConfig.autoCalculation) return 0;
const { inputFields } = componentConfig.autoCalculation;
// 기본 단가
const basePrice = parseFloat(entry[inputFields.basePrice] || "0");
if (basePrice === 0) return 0;
let price = basePrice;
// 1단계: 할인 적용 (라벨명으로 판단)
const discountTypeCode = entry[inputFields.discountType];
const discountTypeLabel = getOptionLabel("discount_type", discountTypeCode);
const discountValue = parseFloat(entry[inputFields.discountValue] || "0");
if (discountTypeLabel.includes("할인율") || discountTypeLabel.includes("%")) {
// 할인율(%)
price = price * (1 - discountValue / 100);
} else if (discountTypeLabel.includes("할인금액") || discountTypeLabel.includes("금액")) {
// 할인금액
price = price - discountValue;
}
// "할인없음"이면 그대로
// 2단계: 반올림 적용
// rounding_type = 단위 (10원, 100원, 1000원)
// rounding_unit_value = 방법 (반올림, 절삭, 올림, 반올림없음)
const roundingTypeCode = entry[inputFields.roundingType];
const roundingTypeLabel = getOptionLabel("rounding_type", roundingTypeCode);
const roundingUnitCode = entry[inputFields.roundingUnit];
const roundingUnitLabel = getOptionLabel("rounding_unit_value", roundingUnitCode);
// roundingType 라벨에서 단위 숫자 추출 (예: "10원" → 10, "1000원" → 1000)
const unitMatch = roundingTypeLabel.match(/(\d+)/);
const unit = unitMatch ? parseInt(unitMatch[1]) : parseFloat(roundingTypeCode) || 1;
const priceBeforeRounding = price;
// roundingUnit 라벨로 반올림 방법 결정
if (roundingUnitLabel.includes("없음") || !roundingUnitCode) {
// 반올림없음: 할인 적용된 원래 값 그대로
// price 변경 없음
} else if (roundingUnitLabel.includes("절삭")) {
price = Math.floor(price / unit) * unit;
} else if (roundingUnitLabel.includes("올림")) {
price = Math.ceil(price / unit) * unit;
} else if (roundingUnitLabel.includes("반올림")) {
price = Math.round(price / unit) * unit;
}
return price;
},
[componentConfig.autoCalculation, getOptionLabel],
);
// 🆕 그룹별 필드 변경 핸들러: itemId + groupId + entryId + fieldName
const handleFieldChange = useCallback(
(itemId: string, groupId: string, entryId: string, fieldName: string, value: any) => {
setItems((prevItems) => {
return prevItems.map((item) => {
if (item.id !== itemId) return item;
const groupEntries = item.fieldGroups[groupId] || [];
const existingEntryIndex = groupEntries.findIndex((e) => e.id === entryId);
if (existingEntryIndex >= 0) {
const currentEntry = groupEntries[existingEntryIndex];
// 날짜 검증: 종료일이 시작일보다 앞서면 차단
if (fieldName === "end_date" && value && currentEntry.start_date) {
if (new Date(value) < new Date(currentEntry.start_date as string)) {
alert("종료일은 시작일보다 이후여야 합니다.");
return item; // 변경 취소
}
}
if (fieldName === "start_date" && value && currentEntry.end_date) {
if (new Date(value) > new Date(currentEntry.end_date as string)) {
alert("시작일은 종료일보다 이전이어야 합니다.");
return item; // 변경 취소
}
}
// 기존 entry 업데이트
const updatedEntries = [...groupEntries];
const updatedEntry = {
...updatedEntries[existingEntryIndex],
[fieldName]: value,
};
// 가격 관련 필드가 변경되면 자동 계산
if (componentConfig.autoCalculation) {
const { inputFields, targetField } = componentConfig.autoCalculation;
const priceRelatedFields = [
inputFields.basePrice,
inputFields.discountType,
inputFields.discountValue,
inputFields.roundingType,
inputFields.roundingUnit,
];
if (priceRelatedFields.includes(fieldName)) {
const calculatedPrice = calculatePrice(updatedEntry);
updatedEntry[targetField] = calculatedPrice;
}
}
updatedEntries[existingEntryIndex] = updatedEntry;
return {
...item,
fieldGroups: {
...item.fieldGroups,
[groupId]: updatedEntries,
},
};
} else {
// 이 경로는 발생하면 안 됨 (handleAddGroupEntry에서 미리 추가함)
return item;
}
});
});
},
[calculatePrice],
);
// 🆕 품목 제거 핸들러
const handleRemoveItem = (itemId: string) => {
setItems((prevItems) => prevItems.filter((item) => item.id !== itemId));
};
// 🆕 그룹 항목 추가 핸들러 (특정 그룹에 새 항목 추가)
const handleAddGroupEntry = (itemId: string, groupId: string) => {
const newEntryId = `entry-${Date.now()}`;
// 🔧 미리 빈 entry를 추가하여 리렌더링 방지 (autoFillFrom 처리)
setItems((prevItems) => {
return prevItems.map((item) => {
if (item.id !== itemId) return item;
const groupEntries = item.fieldGroups[groupId] || [];
const newEntry: GroupEntry = { id: newEntryId };
// 🆕 autoFillFrom 필드 자동 채우기 (tableName으로 직접 접근)
const groupFields = (componentConfig.additionalFields || []).filter((f) => f.groupId === groupId);
groupFields.forEach((field) => {
if (!field.autoFillFrom) return;
// 데이터 소스 결정
let sourceData: any = null;
if (field.autoFillFromTable) {
// 특정 테이블에서 가져오기
const tableData = dataRegistry[field.autoFillFromTable];
if (tableData && tableData.length > 0) {
// 첫 번째 항목 사용 (또는 매칭 로직 추가 가능)
sourceData = tableData[0].originalData || tableData[0];
} else {
sourceData = item.originalData;
}
} else {
sourceData = item.originalData;
}
// 🆕 getFieldValue 사용하여 Entity Join 필드도 찾기
if (sourceData) {
const fieldValue = getFieldValue(sourceData, field.autoFillFrom);
if (fieldValue !== undefined && fieldValue !== null) {
newEntry[field.name] = fieldValue;
}
}
});
return {
...item,
fieldGroups: {
...item.fieldGroups,
[groupId]: [...groupEntries, newEntry],
},
};
});
});
setIsEditing(true);
setEditingItemId(itemId);
setEditingDetailId(newEntryId);
setEditingGroupId(groupId);
// 그룹별 독립 편집: 해당 그룹만 열기 (다른 그룹은 유지)
setEditingEntries((prev) => ({ ...prev, [groupId]: newEntryId }));
};
// 🆕 그룹 항목 제거 핸들러
const handleRemoveGroupEntry = (itemId: string, groupId: string, entryId: string) => {
setItems((prevItems) =>
prevItems.map((item) => {
if (item.id !== itemId) return item;
return {
...item,
fieldGroups: {
...item.fieldGroups,
[groupId]: (item.fieldGroups[groupId] || []).filter((e) => e.id !== entryId),
},
};
}),
);
// 제거된 항목이 편집 중이었으면 해당 그룹 편집 닫기
setEditingEntries((prev) => {
if (prev[groupId] === entryId) {
const next = { ...prev };
delete next[groupId];
return next;
}
return prev;
});
};
// 🆕 그룹 항목 편집 핸들러 (클릭하면 수정 가능) - 독립 편집
const handleEditGroupEntry = (itemId: string, groupId: string, entryId: string) => {
setIsEditing(true);
setEditingItemId(itemId);
setEditingGroupId(groupId);
setEditingDetailId(entryId);
// 그룹별 독립 편집: 해당 그룹만 토글 (다른 그룹은 유지)
setEditingEntries((prev) => ({ ...prev, [groupId]: entryId }));
};
// 🆕 특정 그룹의 편집 닫기 (다른 그룹은 유지)
const closeGroupEditing = (groupId: string) => {
setEditingEntries((prev) => {
const next = { ...prev };
delete next[groupId];
return next;
});
};
// 🆕 다음 품목으로 이동
const handleNextItem = () => {
const currentIndex = items.findIndex((item) => item.id === editingItemId);
if (currentIndex < items.length - 1) {
// 다음 품목으로
const nextItem = items[currentIndex + 1];
setEditingItemId(nextItem.id);
const groups = componentConfig.fieldGroups || [];
const firstGroupId = groups.length > 0 ? groups[0].id : "default";
const newEntryId = `entry-${Date.now()}`;
setEditingDetailId(newEntryId);
setEditingGroupId(firstGroupId);
setIsEditing(true);
} else {
// 마지막 품목이면 편집 모드 종료
setIsEditing(false);
setEditingItemId(null);
setEditingDetailId(null);
setEditingGroupId(null);
}
};
// 🆕 개별 필드 렌더링 (itemId, groupId, entryId, entry 데이터 전달)
const renderField = (
field: AdditionalFieldDefinition,
itemId: string,
groupId: string,
entryId: string,
entry: GroupEntry,
) => {
const value = entry[field.name] || field.defaultValue || "";
// 🆕 계산된 필드는 읽기 전용 (자동 계산 설정 기반)
const isCalculatedField = componentConfig.autoCalculation?.targetField === field.name;
const commonProps = {
value: value || "",
disabled: componentConfig.disabled || componentConfig.readonly,
placeholder: field.placeholder,
required: field.required,
};
// 🆕 inputType이 있으면 우선 사용, 없으면 field.type 사용
const renderType = field.inputType || field.type;
// 🆕 inputType에 따라 적절한 컴포넌트 렌더링
switch (renderType) {
// 기본 타입들
case "text":
case "varchar":
case "char":
return (
<Input
{...commonProps}
type="text"
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
maxLength={field.validation?.maxLength}
className="h-7 text-xs"
/>
);
case "number":
case "int":
case "integer":
case "bigint":
case "decimal":
case "numeric": {
// 숫자 포맷팅 헬퍼: 콤마 표시 + 실제 값은 숫자만 저장
const rawNum = value ? String(value).replace(/,/g, "") : "";
const displayNum = rawNum && !isNaN(Number(rawNum))
? new Intl.NumberFormat("ko-KR").format(Number(rawNum))
: rawNum;
// 계산된 단가는 읽기 전용 + 강조 표시
if (isCalculatedField) {
return (
<div className="relative">
<Input
value={displayNum}
readOnly
disabled
className={cn(
"h-7 text-xs",
"bg-primary/10 border-primary/30 text-primary font-semibold",
"cursor-not-allowed",
)}
/>
<div className="text-primary/70 absolute top-1/2 right-2 -translate-y-1/2 text-[9px]"> </div>
</div>
);
}
return (
<Input
value={displayNum}
placeholder={field.placeholder}
disabled={componentConfig.disabled || componentConfig.readonly}
type="text"
inputMode="numeric"
onChange={(e) => {
// 콤마 제거 후 숫자만 저장
const cleaned = e.target.value.replace(/,/g, "").replace(/[^0-9.\-]/g, "");
handleFieldChange(itemId, groupId, entryId, field.name, cleaned);
}}
className="h-7 text-xs"
/>
);
}
case "date":
case "timestamp":
case "datetime":
return (
<Input
{...commonProps}
type="date"
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
onClick={(e) => {
// 날짜 선택기 강제 열기
const target = e.target as HTMLInputElement;
if (target && target.showPicker) {
target.showPicker();
}
}}
className="h-7 cursor-pointer text-xs"
/>
);
case "checkbox":
case "boolean":
case "bool":
return (
<Checkbox
checked={value === true || value === "true"}
onCheckedChange={(checked) => handleFieldChange(itemId, groupId, entryId, field.name, checked)}
disabled={componentConfig.disabled || componentConfig.readonly}
/>
);
case "textarea":
return (
<Textarea
{...commonProps}
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
rows={1}
className="min-h-[28px] resize-none text-xs"
/>
);
// 🆕 추가 inputType들
case "code":
case "category":
// 🆕 옵션을 field.name 또는 field.codeCategory 키로 찾기
let categoryOptions = field.options; // 기본값
// 1순위: 필드 이름으로 직접 찾기 (category 타입에서 사용)
if (codeOptions[field.name]) {
categoryOptions = codeOptions[field.name];
}
// 2순위: codeCategory로 찾기 (code 타입에서 사용)
else if (field.codeCategory && codeOptions[field.codeCategory]) {
categoryOptions = codeOptions[field.codeCategory];
}
return (
<Select
value={value || ""}
onValueChange={(val) => handleFieldChange(itemId, groupId, entryId, field.name, val)}
disabled={componentConfig.disabled || componentConfig.readonly}
>
<SelectTrigger className="h-7 w-full text-xs">
<SelectValue placeholder={field.placeholder || "선택하세요"} />
</SelectTrigger>
<SelectContent>
{categoryOptions && categoryOptions.length > 0 ? (
categoryOptions
.filter((option) => option.value !== "")
.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))
) : (
<div className="text-muted-foreground py-6 text-center text-xs"> ...</div>
)}
</SelectContent>
</Select>
);
case "entity":
// TODO: EntitySelect 컴포넌트 사용
return (
<Input
{...commonProps}
type="text"
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
className="h-7 text-xs"
/>
);
case "select":
return (
<Select
value={value || ""}
onValueChange={(val) => handleFieldChange(itemId, groupId, entryId, field.name, val)}
disabled={componentConfig.disabled || componentConfig.readonly}
>
<SelectTrigger className="h-7 w-full text-xs">
<SelectValue placeholder={field.placeholder || "선택하세요"} />
</SelectTrigger>
<SelectContent>
{field.options
?.filter((option) => option.value !== "")
.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
// 기본값: 텍스트 입력
default:
return (
<Input
{...commonProps}
type="text"
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
maxLength={field.validation?.maxLength}
className="h-7 text-xs"
/>
);
}
};
// 🆕 Entity Join된 필드명도 찾는 헬퍼 함수
const getFieldValue = useCallback((data: Record<string, any>, fieldName: string) => {
// 1. Entity Join 형식으로 먼저 찾기 (*_fieldName) - 우선순위!
// 예: item_id_item_name (품목의 품명) vs customer_item_name (거래처 품명)
const possibleKeys = Object.keys(data).filter(
(key) => key.endsWith(`_${fieldName}`) && key !== fieldName, // 자기 자신은 제외
);
if (possibleKeys.length > 0) {
// 🆕 여러 개 있으면 가장 긴 키 선택 (더 구체적인 것)
// 예: item_id_item_name (18자) vs customer_item_name (18자) 중 정렬 순서로 선택
// 실제로는 item_id로 시작하는 것을 우선
const entityJoinKey = possibleKeys.find((key) => key.includes("_id_")) || possibleKeys[0];
return data[entityJoinKey];
}
// 2. 직접 필드명으로 찾기 (Entity Join이 없을 때만)
if (data[fieldName] !== undefined) {
return data[fieldName];
}
return null;
}, []);
// 🆕 displayItems를 렌더링하는 헬퍼 함수 (그룹별)
const renderDisplayItems = useCallback(
(entry: GroupEntry, item: ItemData, groupId: string) => {
// 🆕 해당 그룹의 displayItems 가져오기
const group = (componentConfig.fieldGroups || []).find((g) => g.id === groupId);
const displayItems = group?.displayItems || [];
if (displayItems.length === 0) {
const fields = (componentConfig.additionalFields || []).filter((f) => {
const matchGroup = componentConfig.fieldGroups && componentConfig.fieldGroups.length > 0
? f.groupId === groupId
: true;
const isVisible = f.width !== "0px";
return matchGroup && isVisible;
});
// 헬퍼: 값을 사람이 읽기 좋은 형태로 변환
const formatValue = (f: any, value: any): string => {
if (!value && value !== 0) return "";
const strValue = String(value);
// 날짜 포맷
const isoDateMatch = strValue.match(/^(\d{4})-(\d{2})-(\d{2})(T|\s|$)/);
if (isoDateMatch) {
const [, year, month, day] = isoDateMatch;
return `${year}.${month}.${day}`;
}
// 카테고리/코드 -> 라벨명
const renderType = f.inputType || f.type;
if (renderType === "category" || renderType === "code" || renderType === "select") {
const options = codeOptions[f.name] || f.options || [];
const matched = options.find((opt: any) => opt.value === strValue);
if (matched) return matched.label;
}
// 숫자는 천 단위 구분
if (renderType === "number" && !isNaN(Number(strValue))) {
return new Intl.NumberFormat("ko-KR").format(Number(strValue));
}
return strValue;
};
// 간결한 요약 생성 (그룹별 핵심 정보만)
const hasAnyValue = fields.some((f) => {
const v = entry[f.name];
return v !== undefined && v !== null && v !== "";
});
if (!hasAnyValue) {
const fieldLabels = fields.slice(0, 2).map(f => f.label).join("/");
return `신규 ${fieldLabels} 입력`;
}
// 날짜 범위가 있으면 우선 표시
const startDate = entry["start_date"] ? formatValue({ inputType: "date" }, entry["start_date"]) : "";
const endDate = entry["end_date"] ? formatValue({ inputType: "date" }, entry["end_date"]) : "";
// 기준단가(calculated_price) 또는 기준가(base_price) 표시
const calcPrice = entry["calculated_price"] ? formatValue({ inputType: "number" }, entry["calculated_price"]) : "";
const basePrice = entry["base_price"] ? formatValue({ inputType: "number" }, entry["base_price"]) : "";
// 통화코드
const currencyCode = entry["currency_code"] ? formatValue(
fields.find(f => f.name === "currency_code") || { inputType: "category", name: "currency_code" },
entry["currency_code"]
) : "";
if (startDate || calcPrice || basePrice) {
// 날짜 + 단가 간결 표시
const parts: string[] = [];
if (startDate) {
parts.push(endDate ? `${startDate} ~ ${endDate}` : `${startDate} ~`);
}
if (calcPrice) {
parts.push(`${currencyCode || ""} ${calcPrice}`.trim());
} else if (basePrice) {
parts.push(`${currencyCode || ""} ${basePrice}`.trim());
}
return parts.join(" | ");
}
// 그 외 그룹 (거래처 품번 등): 첫 2개 필드만 표시
const summaryParts = fields
.slice(0, 3)
.map((f) => {
const value = entry[f.name];
if (!value && value !== 0) return null;
return `${f.label}: ${formatValue(f, value)}`;
})
.filter(Boolean);
return summaryParts.join(" ");
}
// displayItems 설정대로 렌더링
return (
<>
{displayItems.map((displayItem) => {
const styleClasses = cn(
displayItem.bold && "font-bold",
displayItem.underline && "underline",
displayItem.italic && "italic",
);
const inlineStyle: React.CSSProperties = {
color: displayItem.color,
backgroundColor: displayItem.backgroundColor,
};
switch (displayItem.type) {
case "icon": {
if (!displayItem.icon) return null;
const IconComponent = LUCIDE_ICON_MAP[displayItem.icon];
if (!IconComponent) return null;
return <IconComponent key={displayItem.id} className="mr-1 inline-block h-3 w-3" style={inlineStyle} />;
}
case "text":
return (
<span key={displayItem.id} className={styleClasses} style={inlineStyle}>
{displayItem.value}
</span>
);
case "field": {
const fieldValue = entry[displayItem.fieldName || ""];
const isEmpty = fieldValue === null || fieldValue === undefined || fieldValue === "";
// 🆕 빈 값 처리
if (isEmpty) {
switch (displayItem.emptyBehavior) {
case "hide":
return null; // 항목 숨김
case "default":
// 기본값 표시
const defaultValue = displayItem.defaultValue || "-";
return (
<span
key={displayItem.id}
className={cn(styleClasses, "text-muted-foreground")}
style={inlineStyle}
>
{displayItem.label}
{defaultValue}
</span>
);
case "blank":
default:
// 빈 칸으로 표시
return (
<span key={displayItem.id} className={styleClasses} style={inlineStyle}>
{displayItem.label}
</span>
);
}
}
// 값이 있는 경우, 형식에 맞게 표시
let formattedValue = fieldValue;
// 🔧 자동 날짜 감지 (format 설정 없어도 ISO 날짜 자동 변환)
const strValue = String(fieldValue);
const isoDateMatch = strValue.match(/^(\d{4})-(\d{2})-(\d{2})(T|\s|$)/);
if (isoDateMatch && !displayItem.format) {
const [, year, month, day] = isoDateMatch;
formattedValue = `${year}.${month}.${day}`;
}
switch (displayItem.format) {
case "currency":
// 천 단위 구분
formattedValue = new Intl.NumberFormat("ko-KR").format(Number(fieldValue) || 0);
break;
case "number":
formattedValue = new Intl.NumberFormat("ko-KR").format(Number(fieldValue) || 0);
break;
case "date":
// YYYY.MM.DD 형식
if (fieldValue) {
// 날짜 문자열을 직접 파싱 (타임존 문제 방지)
const dateStr = String(fieldValue);
const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (match) {
const [, year, month, day] = match;
formattedValue = `${year}.${month}.${day}`;
} else {
// Date 객체로 변환 시도 (fallback)
const date = new Date(fieldValue);
if (!isNaN(date.getTime())) {
formattedValue = date
.toLocaleDateString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
.replace(/\. /g, ".")
.replace(/\.$/, "");
}
}
}
break;
case "badge":
// 배지로 표시
return (
<Badge
key={displayItem.id}
variant={displayItem.badgeVariant || "default"}
className={styleClasses}
style={inlineStyle}
>
{displayItem.label}
{formattedValue}
</Badge>
);
case "text":
default:
// 일반 텍스트
break;
}
// 🔧 마지막 안전장치: formattedValue가 여전히 ISO 형식이면 한번 더 변환
let finalValue = formattedValue;
if (typeof formattedValue === "string") {
const isoCheck = formattedValue.match(/^(\d{4})-(\d{2})-(\d{2})(T|\s|$)/);
if (isoCheck) {
const [, year, month, day] = isoCheck;
finalValue = `${year}.${month}.${day}`;
}
}
return (
<span key={displayItem.id} className={styleClasses} style={inlineStyle}>
{displayItem.label}
{finalValue}
</span>
);
}
case "badge": {
const fieldValue = displayItem.fieldName ? entry[displayItem.fieldName] : displayItem.value;
return (
<Badge
key={displayItem.id}
variant={displayItem.badgeVariant || "default"}
className={styleClasses}
style={inlineStyle}
>
{displayItem.label}
{fieldValue}
</Badge>
);
}
default:
return null;
}
})}
</>
);
},
[componentConfig.fieldGroups, componentConfig.additionalFields, codeOptions],
);
// 빈 상태 렌더링
if (items.length === 0) {
// 디자인 모드: 샘플 데이터로 미리보기 표시
if (isDesignMode) {
const sampleDisplayCols = componentConfig.displayColumns || [];
const sampleFields = (componentConfig.additionalFields || []).filter(f => f.name !== sourceKeyField && f.width !== "0px");
const sampleGroups = componentConfig.fieldGroups || [{ id: "default", title: "입력 정보", order: 0 }];
const gridCols = sampleGroups.length === 1 ? "grid-cols-1" : "grid-cols-2";
return (
<div style={componentStyle} className={className} onClick={handleClick}>
<div className="bg-card space-y-3 p-3">
{/* 미리보기 안내 배너 */}
<div className="bg-muted/50 flex items-center gap-2 rounded-md px-3 py-2 text-xs">
<span className="text-primary font-medium">[]</span>
<span className="text-muted-foreground"> </span>
</div>
{/* 샘플 품목 카드 2개 */}
{[1, 2].map((idx) => (
<Card key={idx} className="border shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between text-sm font-semibold">
<span>
{idx}. {sampleDisplayCols.length > 0 ? `샘플 ${sampleDisplayCols[0]?.label || "품목"} ${idx}` : `샘플 항목 ${idx}`}
</span>
<Button type="button" variant="ghost" size="sm" className="h-6 w-6 p-0 text-red-400" disabled>
<X className="h-3 w-3" />
</Button>
</CardTitle>
{sampleDisplayCols.length > 0 && (
<div className="text-muted-foreground text-xs">
{sampleDisplayCols.map((col, i) => (
<span key={col.name}>
{i > 0 && " | "}
<span className="text-muted-foreground/60">{col.label}: </span>
</span>
))}
</div>
)}
</CardHeader>
<CardContent className="pt-0">
<div className={`grid ${gridCols} gap-2`}>
{sampleGroups.map((group) => {
const groupFields = sampleFields.filter(f =>
sampleGroups.length <= 1 || f.groupId === group.id
);
if (groupFields.length === 0) return null;
const isSingle = group.maxEntries === 1;
return (
<Card key={group.id} className="border shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between text-xs font-semibold">
<span>{group.title}</span>
{!isSingle && (
<Button type="button" size="sm" variant="outline" className="h-6 text-[10px]" disabled>
+
</Button>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-1 pb-3">
{isSingle ? (
/* 1:1 그룹: 인라인 폼 미리보기 */
<div className="grid grid-cols-2 gap-1">
{groupFields.slice(0, 4).map(f => (
<div key={f.name} className="space-y-0.5">
<span className="text-muted-foreground text-[9px]">{f.label}</span>
<div className="bg-muted/40 h-5 rounded border text-[10px] leading-5 px-1"></div>
</div>
))}
{groupFields.length > 4 && (
<div className="text-muted-foreground col-span-2 text-[9px]"> {groupFields.length - 4} </div>
)}
</div>
) : (
/* 1:N 그룹: 다중 항목 미리보기 */
<>
<div className="bg-muted/30 flex items-center justify-between rounded border p-1.5 text-[10px]">
<span className="truncate">
1. {groupFields.slice(0, 2).map(f => `${f.label}: 샘플`).join(" / ")}
</span>
<X className="h-2.5 w-2.5 shrink-0 text-gray-400" />
</div>
{idx === 1 && (
<div className="bg-muted/30 flex items-center justify-between rounded border p-1.5 text-[10px]">
<span className="truncate">
2. {groupFields.slice(0, 2).map(f => `${f.label}: 샘플`).join(" / ")}
</span>
<X className="h-2.5 w-2.5 shrink-0 text-gray-400" />
</div>
)}
</>
)}
</CardContent>
</Card>
);
})}
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
// 런타임 빈 상태
return (
<div style={componentStyle} className={className} onClick={handleClick}>
<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 text-sm">{componentConfig.emptyMessage}</p>
</div>
</div>
);
}
// 🆕 그룹별로 입력 항목들 렌더링 (각 그룹이 독립적으로 여러 항목 관리)
const renderFieldsByGroup = (item: ItemData) => {
const fields = componentConfig.additionalFields || [];
const groups = componentConfig.fieldGroups || [];
// 그룹이 정의되지 않은 경우, 기본 그룹 사용
const effectiveGroups = groups.length > 0 ? groups : [{ id: "default", title: "입력 정보", order: 0 }];
const sortedGroups = [...effectiveGroups].sort((a, b) => (a.order || 0) - (b.order || 0));
// 그룹 수에 따라 grid 열 수 결정
const gridCols = sortedGroups.length === 1 ? "grid-cols-1" : "grid-cols-2";
return (
<div className={`grid ${gridCols} gap-3`}>
{sortedGroups.map((group) => {
const groupFields = fields.filter((f) => (groups.length === 0 ? true : f.groupId === group.id));
if (groupFields.length === 0) return null;
const groupEntries = item.fieldGroups[group.id] || [];
// 그룹별 독립 편집 상태 사용
const editingEntryIdForGroup = editingEntries[group.id] || null;
// 1:1 관계 그룹 (maxEntries === 1): 인라인 폼으로 바로 표시
const isSingleEntry = group.maxEntries === 1;
const singleEntry = isSingleEntry ? (groupEntries[0] || { id: `${group.id}_auto_1` }) : null;
// hidden 필드 제외 (width: "0px"인 필드)
const visibleFields = groupFields.filter((f) => f.width !== "0px");
return (
<Card key={group.id} className="border-2 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between text-sm font-semibold">
<span>{group.title}</span>
{/* 1:N 그룹만 + 추가 버튼 표시 */}
{!isSingleEntry && (
<Button
type="button"
onClick={() => handleAddGroupEntry(item.id, group.id)}
size="sm"
variant="outline"
className="h-6 text-[11px]"
>
+
</Button>
)}
</CardTitle>
{group.description && <p className="text-muted-foreground text-[11px]">{group.description}</p>}
</CardHeader>
<CardContent className="space-y-1.5 pt-0">
{/* === 1:1 그룹: 인라인 폼 (항상 편집 모드) - 컴팩트 === */}
{isSingleEntry && singleEntry && (
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5">
{visibleFields.map((field) => (
<div key={field.name} className={cn(
"space-y-0.5",
field.type === "textarea" && "col-span-2"
)}>
<label className="text-[11px] font-medium leading-none">
{field.label}{(field.required || isColumnRequiredByMeta(componentConfig.targetTable, field.name)) && <span className="text-orange-500">*</span>}
</label>
{renderField(field, item.id, group.id, singleEntry.id, singleEntry)}
</div>
))}
</div>
)}
{/* === 1:N 그룹: 다중 입력 (독립 편집) === */}
{!isSingleEntry && (
<>
{groupEntries.length > 0 ? (
<div className="space-y-1.5">
{groupEntries.map((entry, idx) => {
// 그룹별 독립 편집 상태 확인
const isEditingThisEntry = editingEntryIdForGroup === entry.id;
return (
<div key={entry.id} className="bg-muted/30 rounded border">
{/* 헤더 (항상 표시) */}
<div
className="hover:bg-muted/50 flex cursor-pointer items-center justify-between px-2 py-1.5 text-xs"
onClick={() => {
if (isEditingThisEntry) {
// 이 그룹만 닫기 (다른 그룹은 유지)
closeGroupEditing(group.id);
} else {
handleEditGroupEntry(item.id, group.id, entry.id);
}
}}
>
<span className="flex items-center gap-1">
{idx + 1}. {renderDisplayItems(entry, item, group.id)}
</span>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleRemoveGroupEntry(item.id, group.id, entry.id);
}}
className="h-5 w-5 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
{/* 폼 영역 (편집 시에만 아래로 펼침) - 컴팩트 */}
{isEditingThisEntry && (
<div className="border-t px-2 pb-2 pt-1.5">
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5">
{visibleFields.map((field) => (
<div key={field.name} className={cn(
"space-y-0.5",
field.type === "textarea" && "col-span-2"
)}>
<label className="text-[11px] font-medium leading-none">
{field.label}{(field.required || isColumnRequiredByMeta(componentConfig.targetTable, field.name)) && <span className="text-orange-500">*</span>}
</label>
{renderField(field, item.id, group.id, entry.id, entry)}
</div>
))}
</div>
<div className="mt-1.5 flex justify-end">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => closeGroupEditing(group.id)}
className="h-6 px-3 text-[11px]"
>
</Button>
</div>
</div>
)}
</div>
);
})}
</div>
) : (
<p className="text-muted-foreground text-xs italic"> .</p>
)}
{/* 새 항목은 handleAddGroupEntry에서 아코디언 항목으로 직접 추가됨 */}
</>
)}
</CardContent>
</Card>
);
})}
</div>
);
};
// 🆕 Grid 레이아웃 렌더링 (완전히 재작성 - 그룹별 독립 관리)
const renderGridLayout = () => {
return (
<div className="bg-card space-y-4 p-4">
{items.map((item, index) => {
// 제목용 첫 번째 컬럼 값
const titleValue = componentConfig.displayColumns?.[0]?.name
? getFieldValue(item.originalData, componentConfig.displayColumns[0].name)
: null;
// 요약용 모든 컬럼 값들
const summaryValues = componentConfig.displayColumns
?.map((col) => getFieldValue(item.originalData, col.name))
.filter(Boolean);
return (
<Card key={item.id} className="border shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="flex items-center justify-between text-base font-semibold">
<span>
{index + 1}. {titleValue || "항목"}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveItem(item.id)}
className="h-7 w-7 p-0 text-red-500"
>
<X className="h-4 w-4" />
</Button>
</CardTitle>
{/* 원본 데이터 요약 */}
{summaryValues && summaryValues.length > 0 && (
<div className="text-muted-foreground text-xs">{summaryValues.join(" | ")}</div>
)}
</CardHeader>
<CardContent>
{/* 그룹별 입력 항목들 렌더링 */}
{renderFieldsByGroup(item)}
</CardContent>
</Card>
);
})}
</div>
);
};
// 🔧 기존 renderGridLayout (백업 - 사용 안 함)
const renderGridLayout_OLD = () => {
return (
<div className="bg-card space-y-4 p-4">
{/* Modal 모드: 추가 버튼 */}
{isModalMode && !isEditing && items.length === 0 && (
<div className="py-8 text-center">
<p className="text-muted-foreground mb-4 text-sm"> </p>
<Button type="button" onClick={() => setIsEditing(true)} size="sm" className="text-xs sm:text-sm">
+
</Button>
</div>
)}
{/* Modal 모드: 편집 중인 항목 (입력창 표시) */}
{isModalMode &&
isEditing &&
editingItemId &&
(() => {
const editingItem = items.find((item) => item.id === editingItemId);
if (!editingItem) {
return null;
}
return (
<Card className="border-primary border-2 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="flex items-center justify-between text-sm font-semibold">
<span>
:{" "}
{getFieldValue(editingItem.originalData, componentConfig.displayColumns?.[0]?.name || "") ||
"항목"}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setIsEditing(false);
setEditingItemId(null);
setEditingDetailId(null);
}}
className="h-7 text-xs"
>
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* 원본 데이터 요약 */}
<div className="text-muted-foreground bg-muted rounded p-2 text-xs">
{componentConfig.displayColumns
?.map((col) => editingItem.originalData[col.name])
.filter(Boolean)
.join(" | ")}
</div>
{/* 🆕 이미 입력된 상세 항목들 표시 */}
{editingItem.details.length > 0 && (
<div className="space-y-2">
<div className="text-xs font-medium"> ({editingItem.details.length})</div>
{editingItem.details.map((detail, idx) => (
<div
key={detail.id}
className="bg-muted/30 flex items-center justify-between rounded border p-2 text-xs"
>
<span>
{idx + 1}. {detail[componentConfig.additionalFields?.[0]?.name] || "입력됨"}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveDetail(editingItem.id, detail.id)}
className="h-6 w-6 p-0"
>
X
</Button>
</div>
))}
</div>
)}
{/* 추가 입력 필드 */}
{componentConfig.additionalFields &&
componentConfig.additionalFields.length > 0 &&
editingDetailId &&
(() => {
// 현재 편집 중인 detail 찾기 (없으면 빈 객체)
const currentDetail = editingItem.details.find((d) => d.id === editingDetailId) || {
id: editingDetailId,
};
return renderFieldsByGroup(editingItem.id, editingDetailId, currentDetail);
})()}
{/* 액션 버튼들 */}
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
onClick={() => handleAddDetail(editingItem.id)}
size="sm"
variant="outline"
className="text-xs"
>
+
</Button>
<Button type="button" variant="default" onClick={handleNextItem} size="sm" className="text-xs">
</Button>
</div>
</CardContent>
</Card>
);
})()}
{/* 저장된 항목들 (inline 모드 또는 modal 모드에서 편집 완료된 항목) */}
{items.map((item, index) => {
// Modal 모드에서 현재 편집 중인 항목은 위에서 렌더링하므로 스킵
if (isModalMode && isEditing && item.id === editingItemId) {
return null;
}
// Modal 모드: 작은 요약 카드
if (isModalMode) {
return (
<Card key={item.id} className="bg-muted/50 border shadow-sm">
<CardContent className="p-3">
<div className="mb-2 flex items-center justify-between">
<div className="flex-1">
<div className="mb-1 text-sm font-semibold">
{index + 1}.{" "}
{getFieldValue(item.originalData, componentConfig.displayColumns?.[0]?.name || "") || "항목"}
</div>
<div className="text-muted-foreground text-xs">
{componentConfig.displayColumns
?.map((col) => getFieldValue(item.originalData, col.name))
.filter(Boolean)
.join(" | ")}
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setIsEditing(true);
setEditingItemId(item.id);
const newDetailId = `detail-${Date.now()}`;
setEditingDetailId(newDetailId);
}}
className="h-7 text-xs text-orange-600"
>
</Button>
{componentConfig.allowRemove && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveItem(item.id)}
className="h-7 w-7 text-red-500"
>
<X className="h-3 w-3" />
</Button>
)}
</div>
</div>
{/* 🆕 입력된 상세 항목들 표시 */}
{item.details && item.details.length > 0 && (
<div className="border-primary mt-2 space-y-1 border-l-2 pl-4">
{item.details.map((detail, detailIdx) => (
<div key={detail.id} className="text-primary text-xs">
{detailIdx + 1}. {detail[componentConfig.additionalFields?.[0]?.name] || "입력됨"}
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
// Inline 모드: 각 품목마다 여러 상세 항목 표시
return (
<Card key={item.id} className="border shadow-sm">
<CardContent className="space-y-3 p-4">
{/* 제목 (품명) */}
<div className="flex items-center justify-between">
<div className="text-base font-semibold">
{index + 1}.{" "}
{getFieldValue(item.originalData, componentConfig.displayColumns?.[0]?.name || "") || "항목"}
</div>
<Button
type="button"
onClick={() => {
const newDetailId = `detail-${Date.now()}`;
handleAddDetail(item.id);
}}
size="sm"
variant="outline"
className="text-xs"
>
+
</Button>
</div>
{/* 원본 데이터 요약 (작은 텍스트, | 구분자) */}
<div className="text-muted-foreground text-xs">
{componentConfig.displayColumns
?.map((col) => getFieldValue(item.originalData, col.name))
.filter(Boolean)
.join(" | ")}
</div>
{/* 🆕 각 상세 항목 표시 */}
{item.details && item.details.length > 0 ? (
<div className="border-primary space-y-3 border-l-2 pl-4">
{item.details.map((detail, detailIdx) => (
<Card key={detail.id} className="border-dashed">
<CardContent className="space-y-2 p-3">
<div className="flex items-center justify-between">
<div className="text-xs font-medium"> {detailIdx + 1}</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveDetail(item.id, detail.id)}
className="h-6 w-6 p-0 text-red-500"
>
X
</Button>
</div>
{/* 입력 필드들 */}
{renderFieldsByGroup(item.id, detail.id, detail)}
</CardContent>
</Card>
))}
</div>
) : (
<div className="text-muted-foreground pl-4 text-xs italic"> .</div>
)}
</CardContent>
</Card>
);
})}
{/* Modal 모드: 하단 추가 버튼 (항목이 있을 때) */}
{isModalMode && !isEditing && items.length > 0 && (
<Button
type="button"
onClick={() => {
// 새 항목 추가 로직은 여기서 처리하지 않고, 기존 항목이 있으면 첫 항목을 편집 모드로
setIsEditing(true);
setEditingItemId(items[0]?.id || null);
}}
variant="outline"
size="sm"
className="w-full border-dashed text-xs sm:text-sm"
>
+
</Button>
)}
</div>
);
};
// 기존 테이블 레이아웃 (사용 안 함, 삭제 예정)
const renderOldGridLayout = () => {
return (
<div className="bg-card overflow-auto">
<Table>
<TableHeader>
<TableRow className="bg-background">
{componentConfig.showIndex && (
<TableHead className="h-12 w-12 px-4 py-3 text-center text-xs font-semibold sm:text-sm">#</TableHead>
)}
{/* 원본 데이터 컬럼 */}
{componentConfig.displayColumns?.map((col) => (
<TableHead key={col.name} className="h-12 px-4 py-3 text-xs font-semibold sm:text-sm">
{col.label || col.name}
</TableHead>
))}
{/* 추가 입력 필드 컬럼 */}
{componentConfig.additionalFields?.map((field) => (
<TableHead key={field.name} className="h-12 px-4 py-3 text-xs font-semibold sm:text-sm">
{field.label}{(field.required || isColumnRequiredByMeta(componentConfig.targetTable, field.name)) && <span className="text-orange-500">*</span>}
</TableHead>
))}
{componentConfig.allowRemove && (
<TableHead className="h-12 w-20 px-4 py-3 text-center text-xs font-semibold sm:text-sm"></TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, index) => (
<TableRow key={item.id} className="bg-background hover:bg-muted/50 transition-colors">
{/* 인덱스 번호 */}
{componentConfig.showIndex && (
<TableCell className="h-14 px-4 py-3 text-center text-xs font-medium sm:text-sm">
{index + 1}
</TableCell>
)}
{/* 원본 데이터 표시 */}
{componentConfig.displayColumns?.map((col) => (
<TableCell key={col.name} className="h-14 px-4 py-3 text-xs sm:text-sm">
{getFieldValue(item.originalData, col.name) || "-"}
</TableCell>
))}
{/* 추가 입력 필드 */}
{componentConfig.additionalFields?.map((field) => (
<TableCell key={field.name} className="h-14 px-4 py-3">
{renderField(field, item)}
</TableCell>
))}
{/* 삭제 버튼 */}
{componentConfig.allowRemove && (
<TableCell className="h-14 px-4 py-3 text-center">
{!componentConfig.disabled && !componentConfig.readonly && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveItem(item.id)}
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-7 w-7 sm:h-8 sm:w-8"
title="항목 제거"
>
<X className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
)}
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};
// 🆕 Card 레이아웃 렌더링 (Grid와 동일)
const renderCardLayout = () => {
return renderGridLayout();
};
// 🔧 기존 renderCardLayout (백업 - 사용 안 함)
const renderCardLayout_OLD = () => {
const isModalMode = componentConfig.inputMode === "modal";
return (
<div className="bg-card space-y-3 p-4">
{/* Modal 모드: 추가 버튼 */}
{isModalMode && !isEditing && items.length === 0 && (
<div className="py-8 text-center">
<p className="text-muted-foreground mb-4 text-sm"> </p>
<Button type="button" onClick={() => setIsEditing(true)} size="sm" className="text-xs sm:text-sm">
+
</Button>
</div>
)}
{/* Modal 모드: 편집 중인 항목 (입력창 표시) */}
{isModalMode &&
isEditing &&
editingItemId &&
(() => {
const editingItem = items.find((item) => item.id === editingItemId);
if (!editingItem) {
return null;
}
return (
<Card className="border-primary border-2 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="flex items-center justify-between text-sm font-semibold">
<span>
:{" "}
{getFieldValue(editingItem.originalData, componentConfig.displayColumns?.[0]?.name || "") ||
"항목"}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setIsEditing(false);
setEditingItemId(null);
setEditingDetailId(null);
}}
className="h-7 text-xs"
>
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* 원본 데이터 요약 */}
<div className="text-muted-foreground bg-muted rounded p-2 text-xs">
{componentConfig.displayColumns
?.map((col) => editingItem.originalData[col.name])
.filter(Boolean)
.join(" | ")}
</div>
{/* 🆕 이미 입력된 상세 항목들 표시 */}
{editingItem.details.length > 0 && (
<div className="space-y-2">
<div className="text-xs font-medium"> ({editingItem.details.length})</div>
{editingItem.details.map((detail, idx) => (
<div
key={detail.id}
className="bg-muted/30 flex items-center justify-between rounded border p-2 text-xs"
>
<span>
{idx + 1}. {detail[componentConfig.additionalFields?.[0]?.name] || "입력됨"}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveDetail(editingItem.id, detail.id)}
className="h-6 w-6 p-0"
>
X
</Button>
</div>
))}
</div>
)}
{/* 추가 입력 필드 */}
{componentConfig.additionalFields &&
componentConfig.additionalFields.length > 0 &&
editingDetailId &&
(() => {
// 현재 편집 중인 detail 찾기 (없으면 빈 객체)
const currentDetail = editingItem.details.find((d) => d.id === editingDetailId) || {
id: editingDetailId,
};
return renderFieldsByGroup(editingItem.id, editingDetailId, currentDetail);
})()}
{/* 액션 버튼들 */}
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
onClick={() => handleAddDetail(editingItem.id)}
size="sm"
variant="outline"
className="text-xs"
>
+
</Button>
<Button type="button" variant="default" onClick={handleNextItem} size="sm" className="text-xs">
</Button>
</div>
</CardContent>
</Card>
);
})()}
{/* 저장된 항목들 (inline 모드 또는 modal 모드에서 편집 완료된 항목) */}
{items.map((item, index) => {
// Modal 모드에서 현재 편집 중인 항목은 위에서 렌더링하므로 스킵
if (isModalMode && isEditing && item.id === editingItemId) {
return null;
}
// Modal 모드: 작은 요약 카드
if (isModalMode) {
return (
<Card key={item.id} className="bg-muted/50 border shadow-sm">
<CardContent className="flex items-center justify-between p-3">
<div className="flex-1">
<div className="mb-1 text-sm font-semibold">
{index + 1}.{" "}
{getFieldValue(item.originalData, componentConfig.displayColumns?.[0]?.name || "") || "항목"}
</div>
<div className="text-muted-foreground text-xs">
{componentConfig.displayColumns
?.map((col) => getFieldValue(item.originalData, col.name))
.filter(Boolean)
.join(" | ")}
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setIsEditing(true);
setEditingItemId(item.id);
}}
className="h-7 text-xs text-orange-600"
>
</Button>
{componentConfig.allowRemove && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveItem(item.id)}
className="text-destructive h-7 text-xs"
>
X
</Button>
)}
</div>
</CardContent>
</Card>
);
}
// Inline 모드: 각 품목마다 여러 상세 항목 표시
return (
<Card key={item.id} className="border shadow-sm">
<CardContent className="space-y-3 p-4">
{/* 제목 (품명) */}
<div className="flex items-center justify-between">
<div className="text-base font-semibold">
{index + 1}.{" "}
{getFieldValue(item.originalData, componentConfig.displayColumns?.[0]?.name || "") || "항목"}
</div>
<Button
type="button"
onClick={() => {
const newDetailId = `detail-${Date.now()}`;
handleAddDetail(item.id);
}}
size="sm"
variant="outline"
className="text-xs"
>
+
</Button>
</div>
{/* 원본 데이터 요약 (작은 텍스트, | 구분자) */}
<div className="text-muted-foreground text-xs">
{componentConfig.displayColumns
?.map((col) => getFieldValue(item.originalData, col.name))
.filter(Boolean)
.join(" | ")}
</div>
{/* 🆕 각 상세 항목 표시 */}
{item.details && item.details.length > 0 ? (
<div className="border-primary space-y-3 border-l-2 pl-4">
{item.details.map((detail, detailIdx) => (
<Card key={detail.id} className="border-dashed">
<CardContent className="space-y-2 p-3">
<div className="flex items-center justify-between">
<div className="text-xs font-medium"> {detailIdx + 1}</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveDetail(item.id, detail.id)}
className="h-6 w-6 p-0 text-red-500"
>
X
</Button>
</div>
{/* 입력 필드들 */}
{renderFieldsByGroup(item.id, detail.id, detail)}
</CardContent>
</Card>
))}
</div>
) : (
<div className="text-muted-foreground pl-4 text-xs italic"> .</div>
)}
</CardContent>
</Card>
);
})}
{/* Modal 모드: 하단 추가 버튼 (항목이 있을 때) */}
{isModalMode && !isEditing && items.length > 0 && (
<Button
type="button"
onClick={() => {
// 새 항목 추가 로직은 여기서 처리하지 않고, 기존 항목이 있으면 첫 항목을 편집 모드로
setIsEditing(true);
setEditingItemId(items[0]?.id || null);
}}
variant="outline"
size="sm"
className="w-full border-dashed text-xs sm:text-sm"
>
+
</Button>
)}
</div>
);
};
return (
<div style={componentStyle} className={cn("space-y-4", className)} onClick={handleClick}>
{/* 레이아웃에 따라 렌더링 */}
{componentConfig.layout === "grid" ? renderGridLayout() : renderCardLayout()}
{/* 항목 수 표시 */}
<div className="text-muted-foreground flex justify-between text-xs">
<span> {items.length} </span>
{componentConfig.targetTable && <span> : {componentConfig.targetTable}</span>}
</div>
</div>
);
};
/**
* SelectedItemsDetailInput 래퍼 컴포넌트
* 추가적인 로직이나 상태 관리가 필요한 경우 사용
*/
export const SelectedItemsDetailInputWrapper: React.FC<SelectedItemsDetailInputComponentProps> = (props) => {
return <SelectedItemsDetailInputComponent {...props} />;
};