+ {/* 원본 데이터 요약 */}
+
+ {componentConfig.displayColumns?.map((col) => editingItem.originalData[col.name]).filter(Boolean).join(" | ")}
- ))}
-
- {/* 추가 입력 필드 */}
- {componentConfig.additionalFields?.map((field) => (
-
-
- {renderField(field, item)}
+
+ {/* 🆕 이미 입력된 상세 항목들 표시 */}
+ {editingItem.details.length > 0 && (
+
+
입력된 품번 ({editingItem.details.length}개)
+ {editingItem.details.map((detail, idx) => (
+
+ {idx + 1}. {detail[componentConfig.additionalFields?.[0]?.name] || "입력됨"}
+
+
+ ))}
+
+ )}
+
+ {/* 추가 입력 필드 */}
+ {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);
+ })()}
+
+ {/* 액션 버튼들 */}
+
+
+
- ))}
-
-
- ))}
+
+
+ );
+ })()}
+
+ {/* 저장된 항목들 (inline 모드 또는 modal 모드에서 편집 완료된 항목) */}
+ {items.map((item, index) => {
+ // Modal 모드에서 현재 편집 중인 항목은 위에서 렌더링하므로 스킵
+ if (isModalMode && isEditing && item.id === editingItemId) {
+ return null;
+ }
+
+ // Modal 모드: 작은 요약 카드
+ if (isModalMode) {
+ return (
+
+
+
+
+ {index + 1}. {item.originalData[componentConfig.displayColumns?.[0]?.name] || "항목"}
+
+
+ {componentConfig.displayColumns?.map((col) => item.originalData[col.name]).filter(Boolean).join(" | ")}
+
+ {/* 입력된 값 표시 */}
+ {item.additionalData && Object.keys(item.additionalData).length > 0 && (
+
+ 품번: {item.additionalData.customer_item_name} / 품명: {item.additionalData.customer_item_code}
+
+ )}
+
+
+
+ {componentConfig.allowRemove && (
+
+ )}
+
+
+
+ );
+ }
+
+ // Inline 모드: 각 품목마다 여러 상세 항목 표시
+ return (
+
+
+ {/* 제목 (품명) */}
+
+
+ {index + 1}. {item.originalData[componentConfig.displayColumns?.[0]?.name] || "항목"}
+
+
+
+
+ {/* 원본 데이터 요약 (작은 텍스트, | 구분자) */}
+
+ {componentConfig.displayColumns?.map((col) => item.originalData[col.name]).filter(Boolean).join(" | ")}
+
+
+ {/* 🆕 각 상세 항목 표시 */}
+ {item.details && item.details.length > 0 ? (
+
+ {item.details.map((detail, detailIdx) => (
+
+
+
+
상세 항목 {detailIdx + 1}
+
+
+ {/* 입력 필드들 */}
+ {renderFieldsByGroup(item.id, detail.id, detail)}
+
+
+ ))}
+
+ ) : (
+
+ 아직 입력된 상세 항목이 없습니다.
+
+ )}
+
+
+ );
+ })}
+
+ {/* Modal 모드: 하단 추가 버튼 (항목이 있을 때) */}
+ {isModalMode && !isEditing && items.length > 0 && (
+
+ )}
);
};
+ console.log("🎨 [메인 렌더] 레이아웃 결정:", {
+ layout: componentConfig.layout,
+ willUseGrid: componentConfig.layout === "grid",
+ inputMode: componentConfig.inputMode,
+ });
+
return (
{/* 레이아웃에 따라 렌더링 */}
diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx
index 3957ba45..da972454 100644
--- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx
+++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx
@@ -8,7 +8,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card, CardContent } from "@/components/ui/card";
import { Plus, X } from "lucide-react";
-import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition } from "./types";
+import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, FieldGroup } from "./types";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Check, ChevronsUpDown } from "lucide-react";
@@ -43,6 +43,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC>(config.displayColumns || []);
const [fieldPopoverOpen, setFieldPopoverOpen] = useState>({});
+ // 🆕 필드 그룹 상태
+ const [localFieldGroups, setLocalFieldGroups] = useState(config.fieldGroups || []);
+
// 🆕 원본 테이블 선택 상태
const [sourceTableSelectOpen, setSourceTableSelectOpen] = useState(false);
const [sourceTableSearchValue, setSourceTableSearchValue] = useState("");
@@ -100,6 +103,38 @@ export const SelectedItemsDetailInputConfigPanel: React.FC {
+ setLocalFieldGroups(groups);
+ handleChange("fieldGroups", groups);
+ };
+
+ const addFieldGroup = () => {
+ const newGroup: FieldGroup = {
+ id: `group_${localFieldGroups.length + 1}`,
+ title: `그룹 ${localFieldGroups.length + 1}`,
+ order: localFieldGroups.length,
+ };
+ handleFieldGroupsChange([...localFieldGroups, newGroup]);
+ };
+
+ const removeFieldGroup = (groupId: string) => {
+ // 그룹 삭제 시 해당 그룹에 속한 필드들의 groupId도 제거
+ const updatedFields = localFields.map(field =>
+ field.groupId === groupId ? { ...field, groupId: undefined } : field
+ );
+ setLocalFields(updatedFields);
+ handleChange("additionalFields", updatedFields);
+ handleFieldGroupsChange(localFieldGroups.filter(g => g.id !== groupId));
+ };
+
+ const updateFieldGroup = (groupId: string, updates: Partial) => {
+ const newGroups = localFieldGroups.map(g =>
+ g.id === groupId ? { ...g, ...updates } : g
+ );
+ handleFieldGroupsChange(newGroups);
+ };
+
// 표시 컬럼 추가
const addDisplayColumn = (columnName: string, columnLabel: string) => {
if (!displayColumns.some(col => col.name === columnName)) {
@@ -461,6 +496,32 @@ export const SelectedItemsDetailInputConfigPanel: React.FC
+ {/* 🆕 필드 그룹 선택 */}
+ {localFieldGroups.length > 0 && (
+
+
+
+
+ 같은 그룹 ID를 가진 필드들은 같은 카드에 표시됩니다
+
+
+ )}
+
+ {/* 🆕 필드 그룹 관리 */}
+
+
+
+ 추가 입력 필드를 여러 카드로 나눠서 표시 (예: 거래처 정보, 단가 정보)
+
+
+ {localFieldGroups.map((group, index) => (
+
+
+
+ 그룹 {index + 1}
+
+
+
+ {/* 그룹 ID */}
+
+
+ updateFieldGroup(group.id, { id: e.target.value })}
+ className="h-7 text-xs sm:h-8 sm:text-sm"
+ placeholder="group_customer"
+ />
+
+
+ {/* 그룹 제목 */}
+
+
+ updateFieldGroup(group.id, { title: e.target.value })}
+ className="h-7 text-xs sm:h-8 sm:text-sm"
+ placeholder="거래처 정보"
+ />
+
+
+ {/* 그룹 설명 */}
+
+
+ updateFieldGroup(group.id, { description: e.target.value })}
+ className="h-7 text-xs sm:h-8 sm:text-sm"
+ placeholder="거래처 관련 정보를 입력합니다"
+ />
+
+
+ {/* 표시 순서 */}
+
+
+ updateFieldGroup(group.id, { order: parseInt(e.target.value) || 0 })}
+ className="h-7 text-xs sm:h-8 sm:text-sm"
+ min="0"
+ />
+
+
+
+ ))}
+
+
+
+ {localFieldGroups.length > 0 && (
+
+ 💡 추가 입력 필드의 "필드 그룹 ID"에 위에서 정의한 그룹 ID를 입력하세요
+
+ )}
+
+
+ {/* 입력 모드 설정 */}
+
+
+
+
+ {config.inputMode === "modal"
+ ? "추가 버튼 클릭 시 입력창 표시, 완료 후 작은 카드로 표시"
+ : "모든 항목의 입력창을 항상 표시"}
+
+
+
{/* 레이아웃 설정 */}
diff --git a/frontend/lib/registry/components/selected-items-detail-input/types.ts b/frontend/lib/registry/components/selected-items-detail-input/types.ts
index d69afd2d..c8a67a62 100644
--- a/frontend/lib/registry/components/selected-items-detail-input/types.ts
+++ b/frontend/lib/registry/components/selected-items-detail-input/types.ts
@@ -26,6 +26,8 @@ export interface AdditionalFieldDefinition {
options?: Array<{ label: string; value: string }>;
/** 필드 너비 (px 또는 %) */
width?: string;
+ /** 🆕 필드 그룹 ID (같은 그룹ID를 가진 필드들은 같은 카드에 표시) */
+ groupId?: string;
/** 검증 규칙 */
validation?: {
min?: number;
@@ -36,6 +38,20 @@ export interface AdditionalFieldDefinition {
};
}
+/**
+ * 필드 그룹 정의
+ */
+export interface FieldGroup {
+ /** 그룹 ID */
+ id: string;
+ /** 그룹 제목 */
+ title: string;
+ /** 그룹 설명 (선택사항) */
+ description?: string;
+ /** 그룹 표시 순서 */
+ order?: number;
+}
+
/**
* SelectedItemsDetailInput 컴포넌트 설정 타입
*/
@@ -64,6 +80,12 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig {
*/
additionalFields?: AdditionalFieldDefinition[];
+ /**
+ * 🆕 필드 그룹 정의
+ * 추가 입력 필드를 여러 카드로 나눠서 표시
+ */
+ fieldGroups?: FieldGroup[];
+
/**
* 저장 대상 테이블
*/
@@ -86,6 +108,13 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig {
*/
allowRemove?: boolean;
+ /**
+ * 🆕 입력 모드
+ * - inline: 항상 입력창 표시 (기본)
+ * - modal: 추가 버튼 클릭 시 입력창 표시, 완료 후 작은 카드로 표시
+ */
+ inputMode?: "inline" | "modal";
+
/**
* 빈 상태 메시지
*/
@@ -96,6 +125,30 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig {
readonly?: boolean;
}
+/**
+ * 🆕 그룹별 입력 항목 (예: 그룹1의 한 줄)
+ */
+export interface GroupEntry {
+ /** 입력 항목 고유 ID */
+ id: string;
+ /** 입력된 필드 데이터 */
+ [key: string]: any;
+}
+
+/**
+ * 🆕 품목 + 그룹별 여러 입력 항목
+ * 각 필드 그룹마다 독립적으로 여러 개의 입력을 추가할 수 있음
+ * 예: { "group1": [entry1, entry2], "group2": [entry1, entry2, entry3] }
+ */
+export interface ItemData {
+ /** 품목 고유 ID */
+ id: string;
+ /** 원본 데이터 (품목 정보) */
+ originalData: Record;
+ /** 필드 그룹별 입력 항목들 { groupId: [entry1, entry2, ...] } */
+ fieldGroups: Record;
+}
+
/**
* SelectedItemsDetailInput 컴포넌트 Props 타입
*/
diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts
index 28dc2ae1..9ac4426d 100644
--- a/frontend/lib/utils/buttonActions.ts
+++ b/frontend/lib/utils/buttonActions.ts
@@ -198,6 +198,19 @@ export class ButtonActionExecutor {
private static async handleSave(config: ButtonActionConfig, context: ButtonActionContext): Promise {
const { formData, originalData, tableName, screenId } = context;
+ console.log("💾 [handleSave] 저장 시작:", { formData, tableName, screenId });
+
+ // 🆕 SelectedItemsDetailInput 배치 저장 처리 (새로운 데이터 구조)
+ const selectedItemsKeys = Object.keys(formData).filter(key => {
+ const value = formData[key];
+ return Array.isArray(value) && value.length > 0 && value[0]?.originalData && value[0]?.details;
+ });
+
+ if (selectedItemsKeys.length > 0) {
+ console.log("🔄 [handleSave] SelectedItemsDetailInput 배치 저장 감지:", selectedItemsKeys);
+ return await this.handleBatchSave(config, context, selectedItemsKeys);
+ }
+
// 폼 유효성 검사
if (config.validateForm) {
const validation = this.validateFormData(formData);
@@ -446,6 +459,128 @@ export class ButtonActionExecutor {
return await this.handleSave(config, context);
}
+ /**
+ * 🆕 배치 저장 액션 처리 (SelectedItemsDetailInput용 - 새로운 데이터 구조)
+ * ItemData[] → 각 품목의 details 배열을 개별 레코드로 저장
+ */
+ private static async handleBatchSave(
+ config: ButtonActionConfig,
+ context: ButtonActionContext,
+ selectedItemsKeys: string[]
+ ): Promise {
+ const { formData, tableName, screenId } = context;
+
+ if (!tableName || !screenId) {
+ toast.error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)");
+ return false;
+ }
+
+ try {
+ let successCount = 0;
+ let failCount = 0;
+ const errors: string[] = [];
+
+ // 각 SelectedItemsDetailInput 컴포넌트의 데이터 처리
+ for (const key of selectedItemsKeys) {
+ // 🆕 새로운 데이터 구조: ItemData[] with fieldGroups
+ const items = formData[key] as Array<{
+ id: string;
+ originalData: any;
+ fieldGroups: Record>;
+ }>;
+
+ console.log(`📦 [handleBatchSave] ${key} 처리 중 (${items.length}개 품목)`);
+
+ // 각 품목의 모든 그룹의 모든 항목을 개별 저장
+ for (const item of items) {
+ const allGroupEntries = Object.values(item.fieldGroups).flat();
+ console.log(`🔍 [handleBatchSave] 품목 처리: ${item.id} (${allGroupEntries.length}개 입력 항목)`);
+
+ // 모든 그룹의 모든 항목을 개별 레코드로 저장
+ for (const entry of allGroupEntries) {
+ try {
+ // 원본 데이터 + 입력 데이터 병합
+ const mergedData = {
+ ...item.originalData,
+ ...entry,
+ };
+
+ // id 필드 제거 (entry.id는 임시 ID이므로)
+ delete mergedData.id;
+
+ // 사용자 정보 추가
+ if (!context.userId) {
+ throw new Error("사용자 정보를 불러올 수 없습니다.");
+ }
+
+ const writerValue = context.userId;
+ const companyCodeValue = context.companyCode || "";
+
+ const dataWithUserInfo = {
+ ...mergedData,
+ writer: mergedData.writer || writerValue,
+ created_by: writerValue,
+ updated_by: writerValue,
+ company_code: mergedData.company_code || companyCodeValue,
+ };
+
+ console.log(`💾 [handleBatchSave] 입력 항목 저장:`, {
+ itemId: item.id,
+ entryId: entry.id,
+ data: dataWithUserInfo
+ });
+
+ // INSERT 실행
+ const { DynamicFormApi } = await import("@/lib/api/dynamicForm");
+ const saveResult = await DynamicFormApi.saveFormData({
+ screenId,
+ tableName,
+ data: dataWithUserInfo,
+ });
+
+ if (saveResult.success) {
+ successCount++;
+ console.log(`✅ [handleBatchSave] 입력 항목 저장 성공: ${item.id} > ${entry.id}`);
+ } else {
+ failCount++;
+ errors.push(`품목 ${item.id} > 항목 ${entry.id}: ${saveResult.message}`);
+ console.error(`❌ [handleBatchSave] 입력 항목 저장 실패: ${item.id} > ${entry.id}`, saveResult.message);
+ }
+ } catch (error: any) {
+ failCount++;
+ errors.push(`품목 ${item.id} > 항목 ${entry.id}: ${error.message}`);
+ console.error(`❌ [handleBatchSave] 입력 항목 저장 오류: ${item.id} > ${entry.id}`, error);
+ }
+ }
+ }
+ }
+
+ // 결과 토스트
+ if (failCount === 0) {
+ toast.success(`${successCount}개 항목이 저장되었습니다.`);
+ } else if (successCount === 0) {
+ toast.error(`저장 실패: ${errors.join(", ")}`);
+ return false;
+ } else {
+ toast.warning(`${successCount}개 성공, ${failCount}개 실패: ${errors.join(", ")}`);
+ }
+
+ // 테이블과 플로우 새로고침
+ context.onRefresh?.();
+ context.onFlowRefresh?.();
+
+ // 저장 성공 후 이벤트 발생
+ window.dispatchEvent(new CustomEvent("closeEditModal"));
+ window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
+
+ return true;
+ } catch (error: any) {
+ console.error("배치 저장 오류:", error);
+ toast.error(`저장 오류: ${error.message}`);
+ return false;
+ }
+ }
+
/**
* 삭제 액션 처리
*/
diff --git a/동적_테이블_접근_시스템_개선_완료.md b/동적_테이블_접근_시스템_개선_완료.md
index d143a6a5..da8f5e82 100644
--- a/동적_테이블_접근_시스템_개선_완료.md
+++ b/동적_테이블_접근_시스템_개선_완료.md
@@ -377,3 +377,4 @@ interface TablePermission {
+