From dfc83f611445a23f0b1fcc39adae789f5bd0e812 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Thu, 4 Dec 2025 14:32:04 +0900 Subject: [PATCH] =?UTF-8?q?feat(split-panel-layout2):=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EB=AA=A8=EB=93=9C,=20=EC=88=98=EC=A0=95/=EC=82=AD?= =?UTF-8?q?=EC=A0=9C,=20=EB=B3=B5=EC=88=98=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 표시 모드 추가 (card/table) - 카드 모드 라벨 표시 옵션 (이름 행/정보 행 가로 배치) - 체크박스 선택 기능 (전체/개별 선택) - 개별 수정/삭제 핸들러 구현 (openEditModal, DELETE API) - 복수 액션 버튼 배열 지원 (add, edit, bulk-delete, custom) - 설정 패널에 표시 라벨 입력 필드 추가 - 기본키 컬럼 설정 옵션 추가 --- .../SplitPanelLayout2Component.tsx | 581 +++++++++++++++--- .../SplitPanelLayout2ConfigPanel.tsx | 266 ++++++++ .../components/split-panel-layout2/types.ts | 29 +- 3 files changed, 796 insertions(+), 80 deletions(-) diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index 8a9d73a7..0dd00543 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -6,10 +6,30 @@ import { SplitPanelLayout2Config, ColumnConfig, DataTransferField, + ActionButtonConfig, } from "./types"; import { defaultConfig } from "./config"; import { cn } from "@/lib/utils"; -import { Search, Plus, ChevronRight, ChevronDown, Edit, Trash2, Users, Building2 } from "lucide-react"; +import { Search, Plus, ChevronRight, ChevronDown, Edit, Trash2, Users, Building2, Check, MoreHorizontal } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -59,6 +79,14 @@ export const SplitPanelLayout2Component: React.FC>({}); const [rightColumnLabels, setRightColumnLabels] = useState>({}); + // 우측 패널 선택 상태 (체크박스용) + const [selectedRightItems, setSelectedRightItems] = useState>(new Set()); + + // 삭제 확인 다이얼로그 상태 + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [itemToDelete, setItemToDelete] = useState(null); + const [isBulkDelete, setIsBulkDelete] = useState(false); + // 좌측 데이터 로드 const loadLeftData = useCallback(async () => { @@ -233,6 +261,178 @@ export const SplitPanelLayout2Component: React.FC { + return config.rightPanel?.primaryKeyColumn || "id"; + }, [config.rightPanel?.primaryKeyColumn]); + + // 우측 패널 수정 버튼 클릭 + const handleEditItem = useCallback((item: any) => { + // 수정용 모달 화면 ID 결정 (editModalScreenId 우선, 없으면 addModalScreenId 사용) + const modalScreenId = config.rightPanel?.editModalScreenId || config.rightPanel?.addModalScreenId; + + if (!modalScreenId) { + toast.error("연결된 모달 화면이 없습니다."); + return; + } + + // EditModal 열기 이벤트 발생 (수정 모드) + const event = new CustomEvent("openEditModal", { + detail: { + screenId: modalScreenId, + title: "수정", + modalSize: "lg", + editData: item, // 기존 데이터 전달 + isCreateMode: false, // 수정 모드 + onSave: () => { + if (selectedLeftItem) { + loadRightData(selectedLeftItem); + } + }, + }, + }); + window.dispatchEvent(event); + console.log("[SplitPanelLayout2] 수정 모달 열기:", item); + }, [config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, selectedLeftItem, loadRightData]); + + // 우측 패널 삭제 버튼 클릭 (확인 다이얼로그 표시) + const handleDeleteClick = useCallback((item: any) => { + setItemToDelete(item); + setIsBulkDelete(false); + setDeleteDialogOpen(true); + }, []); + + // 일괄 삭제 버튼 클릭 (확인 다이얼로그 표시) + const handleBulkDeleteClick = useCallback(() => { + if (selectedRightItems.size === 0) { + toast.error("삭제할 항목을 선택해주세요."); + return; + } + setIsBulkDelete(true); + setDeleteDialogOpen(true); + }, [selectedRightItems.size]); + + // 실제 삭제 실행 + const executeDelete = useCallback(async () => { + if (!config.rightPanel?.tableName) { + toast.error("테이블 설정이 없습니다."); + return; + } + + const pkColumn = getPrimaryKeyColumn(); + + try { + if (isBulkDelete) { + // 일괄 삭제 + const idsToDelete = Array.from(selectedRightItems); + console.log("[SplitPanelLayout2] 일괄 삭제:", idsToDelete); + + for (const id of idsToDelete) { + await apiClient.delete(`/table-management/tables/${config.rightPanel.tableName}/data/${id}`); + } + + toast.success(`${idsToDelete.length}개 항목이 삭제되었습니다.`); + setSelectedRightItems(new Set()); + } else if (itemToDelete) { + // 단일 삭제 + const itemId = itemToDelete[pkColumn]; + console.log("[SplitPanelLayout2] 단일 삭제:", itemId); + + await apiClient.delete(`/table-management/tables/${config.rightPanel.tableName}/data/${itemId}`); + toast.success("항목이 삭제되었습니다."); + } + + // 데이터 새로고침 + if (selectedLeftItem) { + loadRightData(selectedLeftItem); + } + } catch (error: any) { + console.error("[SplitPanelLayout2] 삭제 실패:", error); + toast.error(`삭제 실패: ${error.message}`); + } finally { + setDeleteDialogOpen(false); + setItemToDelete(null); + setIsBulkDelete(false); + } + }, [config.rightPanel?.tableName, getPrimaryKeyColumn, isBulkDelete, selectedRightItems, itemToDelete, selectedLeftItem, loadRightData]); + + // 개별 체크박스 선택/해제 + const handleSelectItem = useCallback((itemId: string | number, checked: boolean) => { + setSelectedRightItems((prev) => { + const newSet = new Set(prev); + if (checked) { + newSet.add(itemId); + } else { + newSet.delete(itemId); + } + return newSet; + }); + }, []); + + // 액션 버튼 클릭 핸들러 + const handleActionButton = useCallback((btn: ActionButtonConfig) => { + switch (btn.action) { + case "add": + if (btn.modalScreenId) { + // 데이터 전달 필드 설정 + const initialData: Record = {}; + if (selectedLeftItem && config.dataTransferFields) { + for (const field of config.dataTransferFields) { + if (field.sourceColumn && field.targetColumn) { + initialData[field.targetColumn] = selectedLeftItem[field.sourceColumn]; + } + } + } + + const event = new CustomEvent("openEditModal", { + detail: { + screenId: btn.modalScreenId, + title: btn.label || "추가", + modalSize: "lg", + editData: initialData, + isCreateMode: true, + onSave: () => { + if (selectedLeftItem) { + loadRightData(selectedLeftItem); + } + }, + }, + }); + window.dispatchEvent(event); + } + break; + + case "edit": + // 선택된 항목이 1개일 때만 수정 + if (selectedRightItems.size === 1) { + const pkColumn = getPrimaryKeyColumn(); + const selectedId = Array.from(selectedRightItems)[0]; + const item = rightData.find((d) => d[pkColumn] === selectedId); + if (item) { + handleEditItem(item); + } + } else if (selectedRightItems.size > 1) { + toast.error("수정할 항목을 1개만 선택해주세요."); + } else { + toast.error("수정할 항목을 선택해주세요."); + } + break; + + case "delete": + case "bulk-delete": + handleBulkDeleteClick(); + break; + + case "custom": + // 커스텀 액션 (추후 확장) + console.log("[SplitPanelLayout2] 커스텀 액션:", btn); + break; + + default: + break; + } + }, [selectedLeftItem, config.dataTransferFields, loadRightData, selectedRightItems, getPrimaryKeyColumn, rightData, handleEditItem, handleBulkDeleteClick]); + // 컬럼 라벨 로드 const loadColumnLabels = useCallback(async (tableName: string, setLabels: (labels: Record) => void) => { if (!tableName) return; @@ -366,6 +566,17 @@ export const SplitPanelLayout2Component: React.FC { + if (checked) { + const pkColumn = getPrimaryKeyColumn(); + const allIds = new Set(filteredRightData.map((item) => item[pkColumn])); + setSelectedRightItems(allIds); + } else { + setSelectedRightItems(new Set()); + } + }, [filteredRightData, getPrimaryKeyColumn]); + // 리사이즈 핸들러 const handleResizeStart = useCallback((e: React.MouseEvent) => { if (!config.resizable) return; @@ -564,6 +775,10 @@ export const SplitPanelLayout2Component: React.FC { const displayColumns = config.rightPanel?.displayColumns || []; + const showLabels = config.rightPanel?.showLabels ?? false; + const showCheckbox = config.rightPanel?.showCheckbox ?? false; + const pkColumn = getPrimaryKeyColumn(); + const itemId = item[pkColumn]; // displayRow 설정에 따라 컬럼 분류 // displayRow가 "name"이면 이름 행, "info"이면 정보 행 (기본값: 첫 번째는 name, 나머지는 info) @@ -577,72 +792,113 @@ export const SplitPanelLayout2Component: React.FC -
+
+ {/* 체크박스 */} + {showCheckbox && ( + handleSelectItem(itemId, !!checked)} + className="mt-1" + /> + )} +
- {/* 이름 행 (Name Row) */} - {nameRowColumns.length > 0 && ( -
- {nameRowColumns.map((col, idx) => { - const value = item[col.name]; - if (!value && idx > 0) return null; - - // 첫 번째 컬럼은 굵게 표시 - if (idx === 0) { - return ( - - {formatValue(value, col.format) || "이름 없음"} - - ); - } - // 나머지는 배지 스타일 - return ( - - {formatValue(value, col.format)} - - ); - })} + {/* showLabels가 true이면 라벨: 값 형식으로 가로 배치 */} + {showLabels ? ( +
+ {/* 이름 행: 라벨: 값, 라벨: 값 형식으로 가로 배치 */} + {nameRowColumns.length > 0 && ( +
+ {nameRowColumns.map((col, idx) => { + const value = item[col.name]; + if (value === null || value === undefined) return null; + return ( + + {col.label || col.name}: + {formatValue(value, col.format)} + + ); + })} +
+ )} + {/* 정보 행: 라벨: 값, 라벨: 값 형식으로 가로 배치 */} + {infoRowColumns.length > 0 && ( +
+ {infoRowColumns.map((col, idx) => { + const value = item[col.name]; + if (value === null || value === undefined) return null; + return ( + + {col.label || col.name}: + {formatValue(value, col.format)} + + ); + })} +
+ )}
- )} - - {/* 정보 행 (Info Row) */} - {infoRowColumns.length > 0 && ( -
- {infoRowColumns.map((col, idx) => { - const value = item[col.name]; - if (!value) return null; - - // 아이콘 결정 - let icon = null; - const colName = col.name.toLowerCase(); - if (colName.includes("tel") || colName.includes("phone")) { - icon = tel; - } else if (colName.includes("email")) { - icon = @; - } else if (colName.includes("sabun") || colName.includes("id")) { - icon = ID; - } - - return ( - - {icon} - {formatValue(value, col.format)} - - ); - })} + ) : ( + // showLabels가 false일 때 기존 방식 유지 (라벨 없이 값만) +
+ {/* 이름 행 */} + {nameRowColumns.length > 0 && ( +
+ {nameRowColumns.map((col, idx) => { + const value = item[col.name]; + if (value === null || value === undefined) return null; + if (idx === 0) { + return ( + + {formatValue(value, col.format)} + + ); + } + return ( + + {formatValue(value, col.format)} + + ); + })} +
+ )} + {/* 정보 행 */} + {infoRowColumns.length > 0 && ( +
+ {infoRowColumns.map((col, idx) => { + const value = item[col.name]; + if (value === null || value === undefined) return null; + return ( + + {formatValue(value, col.format)} + + ); + })} +
+ )}
)}
- {/* 액션 버튼 */} + {/* 액션 버튼 (개별 수정/삭제) */}
{config.rightPanel?.showEditButton && ( - )} {config.rightPanel?.showDeleteButton && ( - )}
@@ -652,6 +908,139 @@ export const SplitPanelLayout2Component: React.FC { + const displayColumns = config.rightPanel?.displayColumns || []; + const showCheckbox = config.rightPanel?.showCheckbox ?? true; // 테이블 모드는 기본 체크박스 표시 + const pkColumn = getPrimaryKeyColumn(); + const allSelected = filteredRightData.length > 0 && + filteredRightData.every((item) => selectedRightItems.has(item[pkColumn])); + const someSelected = filteredRightData.some((item) => selectedRightItems.has(item[pkColumn])); + + return ( +
+ + + + {showCheckbox && ( + + { + if (el) { + (el as any).indeterminate = someSelected && !allSelected; + } + }} + onCheckedChange={handleSelectAll} + /> + + )} + {displayColumns.map((col, idx) => ( + + {col.label || col.name} + + ))} + {(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && ( + 작업 + )} + + + + {filteredRightData.length === 0 ? ( + + + 등록된 항목이 없습니다 + + + ) : ( + filteredRightData.map((item, index) => { + const itemId = item[pkColumn]; + return ( + + {showCheckbox && ( + + handleSelectItem(itemId, !!checked)} + /> + + )} + {displayColumns.map((col, colIdx) => ( + + {formatValue(item[col.name], col.format)} + + ))} + {(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && ( + +
+ {config.rightPanel?.showEditButton && ( + + )} + {config.rightPanel?.showDeleteButton && ( + + )} +
+
+ )} +
+ ); + }) + )} +
+
+
+ ); + }; + + // 액션 버튼 렌더링 + const renderActionButtons = () => { + const actionButtons = config.rightPanel?.actionButtons; + if (!actionButtons || actionButtons.length === 0) return null; + + return ( +
+ {actionButtons.map((btn) => ( + + ))} +
+ ); + }; + // 디자인 모드 렌더링 if (isDesignMode) { return ( @@ -765,20 +1154,32 @@ export const SplitPanelLayout2Component: React.FC
-

- {selectedLeftItem - ? config.leftPanel?.displayColumns?.[0] - ? selectedLeftItem[config.leftPanel.displayColumns[0].name] - : config.rightPanel?.title || "상세" - : config.rightPanel?.title || "상세"} -

-
+
+

+ {selectedLeftItem + ? config.leftPanel?.displayColumns?.[0] + ? selectedLeftItem[config.leftPanel.displayColumns[0].name] + : config.rightPanel?.title || "상세" + : config.rightPanel?.title || "상세"} +

{selectedLeftItem && ( - {rightData.length}명 + ({rightData.length}건) )} - {config.rightPanel?.showAddButton && selectedLeftItem && ( + {/* 선택된 항목 수 표시 */} + {selectedRightItems.size > 0 && ( + + {selectedRightItems.size}개 선택됨 + + )} +
+
+ {/* 복수 액션 버튼 (actionButtons 설정 시) */} + {selectedLeftItem && renderActionButtons()} + + {/* 기존 단일 추가 버튼 (하위 호환성) */} + {config.rightPanel?.showAddButton && selectedLeftItem && !config.rightPanel?.actionButtons?.length && (
- ) : filteredRightData.length === 0 ? ( -
- - 등록된 항목이 없습니다 -
) : ( -
- {filteredRightData.map((item, index) => renderRightCard(item, index))} -
+ <> + {/* displayMode에 따라 카드 또는 테이블 렌더링 */} + {config.rightPanel?.displayMode === "table" ? ( + renderRightTable() + ) : filteredRightData.length === 0 ? ( +
+ + 등록된 항목이 없습니다 +
+ ) : ( +
+ {filteredRightData.map((item, index) => renderRightCard(item, index))} +
+ )} + )}
+ + {/* 삭제 확인 다이얼로그 */} + + + + 삭제 확인 + + {isBulkDelete + ? `선택한 ${selectedRightItems.size}개 항목을 삭제하시겠습니까?` + : "이 항목을 삭제하시겠습니까?"} +
+ 이 작업은 되돌릴 수 없습니다. +
+
+ + 취소 + + 삭제 + + +
+
); }; diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx index db3638cb..da520d92 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx @@ -530,6 +530,15 @@ export const SplitPanelLayout2ConfigPanel: React.FC updateDisplayColumn("left", index, "name", value)} placeholder="컬럼 선택" /> +
+ + updateDisplayColumn("left", index, "label", e.target.value)} + placeholder="라벨명 (미입력 시 컬럼명 사용)" + className="h-8 text-xs" + /> +
updateDisplayColumn("right", index, "label", e.target.value)} + placeholder="라벨명 (미입력 시 컬럼명 사용)" + className="h-8 text-xs" + /> +
updateConfig("rightPanel.displayMode", value)} + > + + + + + 카드형 + 테이블형 + + +

+ 카드형: 카드 형태로 정보 표시, 테이블형: 표 형태로 정보 표시 +

+
+ + {/* 카드 모드 전용 옵션 */} + {(config.rightPanel?.displayMode || "card") === "card" && ( +
+
+ +

라벨: 값 형식으로 표시

+
+ updateConfig("rightPanel.showLabels", checked)} + /> +
+ )} + + {/* 체크박스 표시 */} +
+
+ +

항목 선택 기능 활성화

+
+ updateConfig("rightPanel.showCheckbox", checked)} + /> +
+ + {/* 수정/삭제 버튼 */} +
+ +
+
+ + updateConfig("rightPanel.showEditButton", checked)} + /> +
+
+ + updateConfig("rightPanel.showDeleteButton", checked)} + /> +
+
+
+ + {/* 수정 모달 화면 (수정 버튼 활성화 시) */} + {config.rightPanel?.showEditButton && ( +
+ + updateConfig("rightPanel.editModalScreenId", value)} + placeholder="수정 모달 화면 선택 (미선택 시 추가 모달 사용)" + open={false} + onOpenChange={() => {}} + /> +

+ 미선택 시 추가 모달 화면을 수정용으로 사용 +

+
+ )} + + {/* 기본키 컬럼 */} +
+ + updateConfig("rightPanel.primaryKeyColumn", value)} + placeholder="기본키 컬럼 선택 (기본: id)" + /> +

+ 수정/삭제 시 사용할 기본키 컬럼 (미선택 시 id 사용) +

+
+ + {/* 복수 액션 버튼 설정 */} +
+
+ + +
+

+ 복수의 버튼을 추가하면 기존 단일 추가 버튼 대신 사용됩니다 +

+
+ {(config.rightPanel?.actionButtons || []).map((btn, index) => ( +
+
+ 버튼 {index + 1} + +
+
+ + { + const current = [...(config.rightPanel?.actionButtons || [])]; + current[index] = { ...current[index], label: e.target.value }; + updateConfig("rightPanel.actionButtons", current); + }} + placeholder="버튼 라벨" + className="h-8 text-xs" + /> +
+
+ + +
+
+ + +
+
+ + +
+ {btn.action === "add" && ( +
+ + { + const current = [...(config.rightPanel?.actionButtons || [])]; + current[index] = { ...current[index], modalScreenId: value }; + updateConfig("rightPanel.actionButtons", current); + }} + placeholder="모달 화면 선택" + open={false} + onOpenChange={() => {}} + /> +
+ )} +
+ ))} + {(config.rightPanel?.actionButtons || []).length === 0 && ( +
+ 액션 버튼을 추가하세요 (선택사항) +
+ )} +
+
diff --git a/frontend/lib/registry/components/split-panel-layout2/types.ts b/frontend/lib/registry/components/split-panel-layout2/types.ts index a5813600..9d470e7a 100644 --- a/frontend/lib/registry/components/split-panel-layout2/types.ts +++ b/frontend/lib/registry/components/split-panel-layout2/types.ts @@ -22,6 +22,18 @@ export interface ColumnConfig { }; } +/** + * 액션 버튼 설정 + */ +export interface ActionButtonConfig { + id: string; // 고유 ID + label: string; // 버튼 라벨 + variant?: "default" | "outline" | "destructive" | "ghost"; // 버튼 스타일 + icon?: string; // lucide 아이콘명 (예: "Plus", "Edit", "Trash2") + modalScreenId?: number; // 연결할 모달 화면 ID + action?: "add" | "edit" | "delete" | "bulk-delete" | "custom"; // 버튼 동작 유형 +} + /** * 데이터 전달 필드 설정 */ @@ -70,12 +82,17 @@ export interface RightPanelConfig { searchColumn?: string; // 검색 대상 컬럼 (단일, 하위 호환성) searchColumns?: SearchColumnConfig[]; // 검색 대상 컬럼들 (복수) showSearch?: boolean; // 검색 표시 여부 - showAddButton?: boolean; // 추가 버튼 표시 - addButtonLabel?: string; // 추가 버튼 라벨 - addModalScreenId?: number; // 추가 모달 화면 ID - showEditButton?: boolean; // 수정 버튼 표시 - showDeleteButton?: boolean; // 삭제 버튼 표시 - displayMode?: "card" | "list"; // 표시 모드 + showAddButton?: boolean; // 추가 버튼 표시 (하위 호환성) + addButtonLabel?: string; // 추가 버튼 라벨 (하위 호환성) + addModalScreenId?: number; // 추가 모달 화면 ID (하위 호환성) + showEditButton?: boolean; // 수정 버튼 표시 (하위 호환성) + showDeleteButton?: boolean; // 삭제 버튼 표시 (하위 호환성) + editModalScreenId?: number; // 수정 모달 화면 ID + displayMode?: "card" | "table"; // 표시 모드 (card: 카드형, table: 테이블형) + showLabels?: boolean; // 카드 모드에서 라벨 표시 여부 (라벨: 값 형식) + showCheckbox?: boolean; // 체크박스 표시 여부 (테이블 모드에서 일괄 선택용) + actionButtons?: ActionButtonConfig[]; // 복수 액션 버튼 배열 + primaryKeyColumn?: string; // 기본키 컬럼명 (수정/삭제용, 기본: id) emptyMessage?: string; // 데이터 없을 때 메시지 }