ERP-node/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputCom...

2652 lines
109 KiB
TypeScript
Raw Normal View History

"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 } from "lucide-react";
import * as LucideIcons from "lucide-react";
import { commonCodeApi } from "@/lib/api/commonCode";
import { cn } from "@/lib/utils";
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],
);
// 🆕 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>[]> = {};
if (firstRecord.customer_id && firstRecord.item_id) {
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: {
customer_id: firstRecord.customer_id,
item_id: firstRecord.item_id,
},
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 = {
// 수정 모드: item_id를 우선 사용 (id는 가격레코드의 PK일 수 있음)
id: String(firstRecord.item_id || 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 의존성 추가
2025-11-20 11:58:43 +09:00
// 🆕 Cartesian Product 생성 함수 (items에서 모든 그룹의 조합을 생성)
const generateCartesianProduct = useCallback(
(itemsList: ItemData[]): Record<string, any>[] => {
const allRecords: Record<string, any>[] = [];
const groups = componentConfig.fieldGroups || [];
const additionalFields = componentConfig.additionalFields || [];
2025-11-20 11:58:43 +09:00
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;
});
});
2025-11-20 11:58:43 +09:00
// 🆕 모든 그룹이 비어있는지 확인
const allGroupsEmpty = groupEntriesArrays.every((arr) => arr.length === 0);
if (allGroupsEmpty) {
// 디테일 데이터가 없어도 기본 레코드 생성 (품목-거래처 매핑 유지)
// autoFillFrom 필드 (item_id 등)는 반드시 포함시켜야 나중에 식별 가능
const baseRecord: Record<string, any> = {};
additionalFields.forEach((f) => {
if (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;
}
2025-11-20 11:58:43 +09:00
const currentGroupEntries = arrays[currentIndex];
if (currentGroupEntries.length === 0) {
// 🆕 현재 그룹에 데이터가 없으면 빈 조합으로 다음 그룹 진행
// (그룹이 비어있어도 다른 그룹의 데이터로 레코드 생성)
cartesian(arrays, currentIndex + 1, currentCombination);
return;
}
2025-11-20 11:58:43 +09:00
// 현재 그룹의 각 엔트리마다 재귀
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);
2025-11-20 11:58:43 +09:00
});
};
2025-11-20 11:58:43 +09:00
// 재귀 시작
cartesian(groupEntriesArrays, 0, {});
});
2025-11-20 11:58:43 +09:00
return allRecords;
},
[componentConfig.fieldGroups, componentConfig.additionalFields],
);
2025-11-20 11:58:43 +09:00
// 🆕 저장 요청 시에만 데이터 전달 (이벤트 리스너 방식)
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;
2025-11-19 10:03:38 +09:00
// 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 {
// 부모 키 추출 (parentDataMapping에서)
const parentKeys: Record<string, any> = {};
// formData 또는 items[0].originalData에서 부모 데이터 가져오기
2025-11-20 11:58:43 +09:00
// 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) => {
// 1차: formData(sourceData)에서 찾기
let value = getFieldValue(sourceData, mapping.sourceField);
// 2차: formData에 없으면 dataRegistry[sourceTable]에서 찾기
// v2-split-panel-layout에서 좌측 항목 선택 시 dataRegistry에 저장한 데이터 활용
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;
2025-11-20 11:58:43 +09:00
} 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;
}
2025-11-20 11:58:43 +09:00
// 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;
2025-11-20 11:58:43 +09:00
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!;
// 수정 모드 감지 (2가지 방법으로 확인)
// 1. URL에 mode=edit 파라미터 확인
// 2. 로드된 데이터에 DB id(PK)가 존재하는지 확인
// 수정 모드에서는 항상 deleteOrphans=true (기존 레코드 교체, 복제 방지)
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;
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);
2025-11-20 11:58:43 +09:00
});
// 디테일 테이블이 있는지 확인 (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) {
// item_id 추출: originalData.item_id를 최우선 사용
// (수정 모드에서 autoFillFrom:"id"가 가격 레코드 PK를 반환하는 문제 방지)
let itemId: string | null = null;
// 1순위: originalData에 item_id가 직접 있으면 사용 (수정 모드에서 정확한 값)
if (item.originalData && item.originalData.item_id) {
itemId = item.originalData.item_id;
}
// 2순위: autoFillFrom 로직 (신규 등록 모드에서 사용)
if (!itemId) {
mainGroups.forEach((group) => {
const groupFields = additionalFields.filter((f) => f.groupId === group.id);
groupFields.forEach((field) => {
if (field.name === "item_id" && field.autoFillFrom && item.originalData) {
itemId = item.originalData[field.autoFillFrom] || null;
}
});
});
}
// 3순위: fallback (최후의 수단)
if (!itemId && item.originalData) {
itemId = item.originalData.id || null;
}
if (!itemId) {
console.error("❌ [2단계 저장] item_id를 찾을 수 없음:", item);
continue;
}
// upsert 공통 parentKeys: customer_id + item_id (정확한 매칭)
const itemParentKeys = { ...parentKeys, item_id: itemId };
// === 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;
}
// item_id는 정확한 itemId 변수 사용 (autoFillFrom:"id" 오작동 방지)
record.item_id = itemId;
// 나머지 autoFillFrom 필드 처리
groupFields.forEach((field) => {
if (field.name !== "item_id" && 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 = (LucideIcons as any)[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>
);
2025-11-20 11:58:43 +09:00
}
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 !== "item_id" && 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 && <span className="text-destructive ml-0.5">*</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 && <span className="text-destructive ml-0.5">*</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 && <span className="text-destructive ml-1">*</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} />;
};