feat(split-panel-layout2): 테이블 모드, 수정/삭제, 복수 버튼 기능 추가

- 표시 모드 추가 (card/table)
- 카드 모드 라벨 표시 옵션 (이름 행/정보 행 가로 배치)
- 체크박스 선택 기능 (전체/개별 선택)
- 개별 수정/삭제 핸들러 구현 (openEditModal, DELETE API)
- 복수 액션 버튼 배열 지원 (add, edit, bulk-delete, custom)
- 설정 패널에 표시 라벨 입력 필드 추가
- 기본키 컬럼 설정 옵션 추가
This commit is contained in:
SeongHyun Kim 2025-12-04 14:32:04 +09:00
parent 40c43bab16
commit dfc83f6114
3 changed files with 796 additions and 80 deletions

View File

@ -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<SplitPanelLayout2ComponentProp
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({});
const [rightColumnLabels, setRightColumnLabels] = useState<Record<string, string>>({});
// 우측 패널 선택 상태 (체크박스용)
const [selectedRightItems, setSelectedRightItems] = useState<Set<string | number>>(new Set());
// 삭제 확인 다이얼로그 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState<any>(null);
const [isBulkDelete, setIsBulkDelete] = useState(false);
// 좌측 데이터 로드
const loadLeftData = useCallback(async () => {
@ -233,6 +261,178 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
console.log("[SplitPanelLayout2] 우측 추가 모달 열기");
}, [config.rightPanel?.addModalScreenId, config.rightPanel?.addButtonLabel, config.dataTransferFields, selectedLeftItem, loadRightData]);
// 기본키 컬럼명 가져오기
const getPrimaryKeyColumn = useCallback(() => {
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<string, any> = {};
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<string, string>) => void) => {
if (!tableName) return;
@ -366,6 +566,17 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
});
}, [rightData, rightSearchTerm, config.rightPanel?.searchColumns, config.rightPanel?.searchColumn]);
// 체크박스 전체 선택/해제 (filteredRightData 정의 이후에 위치해야 함)
const handleSelectAll = useCallback((checked: boolean) => {
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<SplitPanelLayout2ComponentProp
// 우측 패널 카드 렌더링
const renderRightCard = (item: any, index: number) => {
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<SplitPanelLayout2ComponentProp
return (
<Card key={index} className="mb-2 py-0 hover:shadow-md transition-shadow">
<CardContent className="px-4 py-2">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
{/* 체크박스 */}
{showCheckbox && (
<Checkbox
checked={selectedRightItems.has(itemId)}
onCheckedChange={(checked) => handleSelectItem(itemId, !!checked)}
className="mt-1"
/>
)}
<div className="flex-1">
{/* 이름 행 (Name Row) */}
{nameRowColumns.length > 0 && (
<div className="flex items-center gap-2 mb-2">
{nameRowColumns.map((col, idx) => {
const value = item[col.name];
if (!value && idx > 0) return null;
// 첫 번째 컬럼은 굵게 표시
if (idx === 0) {
return (
<span key={idx} className="font-semibold text-lg">
{formatValue(value, col.format) || "이름 없음"}
</span>
);
}
// 나머지는 배지 스타일
return (
<span key={idx} className="text-sm bg-muted px-2 py-0.5 rounded">
{formatValue(value, col.format)}
</span>
);
})}
{/* showLabels가 true이면 라벨: 값 형식으로 가로 배치 */}
{showLabels ? (
<div className="space-y-1">
{/* 이름 행: 라벨: 값, 라벨: 값 형식으로 가로 배치 */}
{nameRowColumns.length > 0 && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
{nameRowColumns.map((col, idx) => {
const value = item[col.name];
if (value === null || value === undefined) return null;
return (
<span key={idx} className="flex items-center gap-1">
<span className="text-sm text-muted-foreground">{col.label || col.name}:</span>
<span className="text-sm font-semibold">{formatValue(value, col.format)}</span>
</span>
);
})}
</div>
)}
{/* 정보 행: 라벨: 값, 라벨: 값 형식으로 가로 배치 */}
{infoRowColumns.length > 0 && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground">
{infoRowColumns.map((col, idx) => {
const value = item[col.name];
if (value === null || value === undefined) return null;
return (
<span key={idx} className="flex items-center gap-1">
<span className="text-sm">{col.label || col.name}:</span>
<span className="text-sm">{formatValue(value, col.format)}</span>
</span>
);
})}
</div>
)}
</div>
)}
{/* 정보 행 (Info Row) */}
{infoRowColumns.length > 0 && (
<div className="flex flex-wrap gap-x-4 gap-y-1 text-base text-muted-foreground">
{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 = <span className="text-sm">tel</span>;
} else if (colName.includes("email")) {
icon = <span className="text-sm">@</span>;
} else if (colName.includes("sabun") || colName.includes("id")) {
icon = <span className="text-sm">ID</span>;
}
return (
<span key={idx} className="flex items-center gap-1">
{icon}
{formatValue(value, col.format)}
</span>
);
})}
) : (
// showLabels가 false일 때 기존 방식 유지 (라벨 없이 값만)
<div className="space-y-1">
{/* 이름 행 */}
{nameRowColumns.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
{nameRowColumns.map((col, idx) => {
const value = item[col.name];
if (value === null || value === undefined) return null;
if (idx === 0) {
return (
<span key={idx} className="font-semibold text-base">
{formatValue(value, col.format)}
</span>
);
}
return (
<span key={idx} className="text-sm bg-muted px-2 py-0.5 rounded">
{formatValue(value, col.format)}
</span>
);
})}
</div>
)}
{/* 정보 행 */}
{infoRowColumns.length > 0 && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground">
{infoRowColumns.map((col, idx) => {
const value = item[col.name];
if (value === null || value === undefined) return null;
return (
<span key={idx} className="text-sm">
{formatValue(value, col.format)}
</span>
);
})}
</div>
)}
</div>
)}
</div>
{/* 액션 버튼 */}
{/* 액션 버튼 (개별 수정/삭제) */}
<div className="flex gap-1">
{config.rightPanel?.showEditButton && (
<Button variant="outline" size="sm" className="h-8">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleEditItem(item)}
>
<Edit className="h-4 w-4" />
</Button>
)}
{config.rightPanel?.showDeleteButton && (
<Button variant="outline" size="sm" className="h-8">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => handleDeleteClick(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
@ -652,6 +908,139 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
);
};
// 우측 패널 테이블 렌더링
const renderRightTable = () => {
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 (
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
{showCheckbox && (
<TableHead className="w-12">
<Checkbox
checked={allSelected}
ref={(el) => {
if (el) {
(el as any).indeterminate = someSelected && !allSelected;
}
}}
onCheckedChange={handleSelectAll}
/>
</TableHead>
)}
{displayColumns.map((col, idx) => (
<TableHead
key={idx}
style={{ width: col.width ? `${col.width}px` : "auto" }}
>
{col.label || col.name}
</TableHead>
))}
{(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
<TableHead className="w-24 text-center"></TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{filteredRightData.length === 0 ? (
<TableRow>
<TableCell
colSpan={displayColumns.length + (showCheckbox ? 1 : 0) + ((config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) ? 1 : 0)}
className="h-24 text-center text-muted-foreground"
>
</TableCell>
</TableRow>
) : (
filteredRightData.map((item, index) => {
const itemId = item[pkColumn];
return (
<TableRow key={index} className="hover:bg-muted/50">
{showCheckbox && (
<TableCell>
<Checkbox
checked={selectedRightItems.has(itemId)}
onCheckedChange={(checked) => handleSelectItem(itemId, !!checked)}
/>
</TableCell>
)}
{displayColumns.map((col, colIdx) => (
<TableCell key={colIdx}>
{formatValue(item[col.name], col.format)}
</TableCell>
))}
{(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
<TableCell className="text-center">
<div className="flex justify-center gap-1">
{config.rightPanel?.showEditButton && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEditItem(item)}
>
<Edit className="h-3.5 w-3.5" />
</Button>
)}
{config.rightPanel?.showDeleteButton && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDeleteClick(item)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</TableCell>
)}
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
);
};
// 액션 버튼 렌더링
const renderActionButtons = () => {
const actionButtons = config.rightPanel?.actionButtons;
if (!actionButtons || actionButtons.length === 0) return null;
return (
<div className="flex gap-2">
{actionButtons.map((btn) => (
<Button
key={btn.id}
variant={btn.variant || "default"}
size="sm"
className="h-8 text-sm"
onClick={() => handleActionButton(btn)}
disabled={
// 일괄 삭제 버튼은 선택된 항목이 없으면 비활성화
(btn.action === "bulk-delete" || btn.action === "delete") && selectedRightItems.size === 0
}
>
{btn.icon === "Plus" && <Plus className="h-4 w-4 mr-1" />}
{btn.icon === "Edit" && <Edit className="h-4 w-4 mr-1" />}
{btn.icon === "Trash2" && <Trash2 className="h-4 w-4 mr-1" />}
{btn.label}
</Button>
))}
</div>
);
};
// 디자인 모드 렌더링
if (isDesignMode) {
return (
@ -765,20 +1154,32 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
{/* 헤더 */}
<div className="p-4 border-b bg-muted/30">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-base">
{selectedLeftItem
? config.leftPanel?.displayColumns?.[0]
? selectedLeftItem[config.leftPanel.displayColumns[0].name]
: config.rightPanel?.title || "상세"
: config.rightPanel?.title || "상세"}
</h3>
<div className="flex items-center gap-2">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-base">
{selectedLeftItem
? config.leftPanel?.displayColumns?.[0]
? selectedLeftItem[config.leftPanel.displayColumns[0].name]
: config.rightPanel?.title || "상세"
: config.rightPanel?.title || "상세"}
</h3>
{selectedLeftItem && (
<span className="text-sm text-muted-foreground">
{rightData.length}
({rightData.length})
</span>
)}
{config.rightPanel?.showAddButton && selectedLeftItem && (
{/* 선택된 항목 수 표시 */}
{selectedRightItems.size > 0 && (
<span className="text-sm text-primary font-medium">
{selectedRightItems.size}
</span>
)}
</div>
<div className="flex items-center gap-2">
{/* 복수 액션 버튼 (actionButtons 설정 시) */}
{selectedLeftItem && renderActionButtons()}
{/* 기존 단일 추가 버튼 (하위 호환성) */}
{config.rightPanel?.showAddButton && selectedLeftItem && !config.rightPanel?.actionButtons?.length && (
<Button size="sm" variant="default" className="h-8 text-sm" onClick={handleRightAddClick}>
<Plus className="h-4 w-4 mr-1" />
{config.rightPanel?.addButtonLabel || "추가"}
@ -812,18 +1213,50 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
<div className="flex items-center justify-center h-full text-muted-foreground text-base">
...
</div>
) : filteredRightData.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Users className="h-16 w-16 mb-3 opacity-30" />
<span className="text-base"> </span>
</div>
) : (
<div>
{filteredRightData.map((item, index) => renderRightCard(item, index))}
</div>
<>
{/* displayMode에 따라 카드 또는 테이블 렌더링 */}
{config.rightPanel?.displayMode === "table" ? (
renderRightTable()
) : filteredRightData.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Users className="h-16 w-16 mb-3 opacity-30" />
<span className="text-base"> </span>
</div>
) : (
<div>
{filteredRightData.map((item, index) => renderRightCard(item, index))}
</div>
)}
</>
)}
</div>
</div>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{isBulkDelete
? `선택한 ${selectedRightItems.size}개 항목을 삭제하시겠습니까?`
: "이 항목을 삭제하시겠습니까?"}
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={executeDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};

View File

@ -530,6 +530,15 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
onValueChange={(value) => updateDisplayColumn("left", index, "name", value)}
placeholder="컬럼 선택"
/>
<div>
<Label className="text-xs text-muted-foreground"> </Label>
<Input
value={col.label || ""}
onChange={(e) => updateDisplayColumn("left", index, "label", e.target.value)}
placeholder="라벨명 (미입력 시 컬럼명 사용)"
className="h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground"> </Label>
<Select
@ -707,6 +716,15 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
onValueChange={(value) => updateDisplayColumn("right", index, "name", value)}
placeholder="컬럼 선택"
/>
<div>
<Label className="text-xs text-muted-foreground"> </Label>
<Input
value={col.label || ""}
onChange={(e) => updateDisplayColumn("right", index, "label", e.target.value)}
placeholder="라벨명 (미입력 시 컬럼명 사용)"
className="h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground"> </Label>
<Select
@ -826,6 +844,254 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
</div>
</>
)}
{/* 표시 모드 설정 */}
<div className="pt-3 border-t">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.rightPanel?.displayMode || "card"}
onValueChange={(value) => updateConfig("rightPanel.displayMode", value)}
>
<SelectTrigger className="h-9 text-sm mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="card"></SelectItem>
<SelectItem value="table"></SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground mt-1">
카드형: 카드 , 테이블형:
</p>
</div>
{/* 카드 모드 전용 옵션 */}
{(config.rightPanel?.displayMode || "card") === "card" && (
<div className="flex items-center justify-between">
<div>
<Label className="text-xs"> </Label>
<p className="text-[10px] text-muted-foreground">라벨: </p>
</div>
<Switch
checked={config.rightPanel?.showLabels || false}
onCheckedChange={(checked) => updateConfig("rightPanel.showLabels", checked)}
/>
</div>
)}
{/* 체크박스 표시 */}
<div className="flex items-center justify-between">
<div>
<Label className="text-xs"> </Label>
<p className="text-[10px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.rightPanel?.showCheckbox || false}
onCheckedChange={(checked) => updateConfig("rightPanel.showCheckbox", checked)}
/>
</div>
{/* 수정/삭제 버튼 */}
<div className="pt-3 border-t">
<Label className="text-xs font-medium"> /</Label>
<div className="mt-2 space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.rightPanel?.showEditButton || false}
onCheckedChange={(checked) => updateConfig("rightPanel.showEditButton", checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.rightPanel?.showDeleteButton || false}
onCheckedChange={(checked) => updateConfig("rightPanel.showDeleteButton", checked)}
/>
</div>
</div>
</div>
{/* 수정 모달 화면 (수정 버튼 활성화 시) */}
{config.rightPanel?.showEditButton && (
<div>
<Label className="text-xs"> </Label>
<ScreenSelect
value={config.rightPanel?.editModalScreenId}
onValueChange={(value) => updateConfig("rightPanel.editModalScreenId", value)}
placeholder="수정 모달 화면 선택 (미선택 시 추가 모달 사용)"
open={false}
onOpenChange={() => {}}
/>
<p className="text-[10px] text-muted-foreground mt-1">
</p>
</div>
)}
{/* 기본키 컬럼 */}
<div>
<Label className="text-xs"> </Label>
<ColumnSelect
columns={rightColumns}
value={config.rightPanel?.primaryKeyColumn || ""}
onValueChange={(value) => updateConfig("rightPanel.primaryKeyColumn", value)}
placeholder="기본키 컬럼 선택 (기본: id)"
/>
<p className="text-[10px] text-muted-foreground mt-1">
/ ( id )
</p>
</div>
{/* 복수 액션 버튼 설정 */}
<div className="pt-3 border-t">
<div className="flex items-center justify-between mb-2">
<Label className="text-xs font-medium"> ()</Label>
<Button
size="sm"
variant="ghost"
className="h-6 text-xs"
onClick={() => {
const current = config.rightPanel?.actionButtons || [];
updateConfig("rightPanel.actionButtons", [
...current,
{
id: `btn-${Date.now()}`,
label: "새 버튼",
variant: "default",
action: "add",
},
]);
}}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<p className="text-[10px] text-muted-foreground mb-2">
</p>
<div className="space-y-3">
{(config.rightPanel?.actionButtons || []).map((btn, index) => (
<div key={btn.id} className="rounded-md border p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground"> {index + 1}</span>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => {
const current = config.rightPanel?.actionButtons || [];
updateConfig(
"rightPanel.actionButtons",
current.filter((_, i) => i !== index)
);
}}
>
<X className="h-3 w-3" />
</Button>
</div>
<div>
<Label className="text-xs text-muted-foreground"> </Label>
<Input
value={btn.label}
onChange={(e) => {
const current = [...(config.rightPanel?.actionButtons || [])];
current[index] = { ...current[index], label: e.target.value };
updateConfig("rightPanel.actionButtons", current);
}}
placeholder="버튼 라벨"
className="h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs text-muted-foreground"></Label>
<Select
value={btn.action || "add"}
onValueChange={(value) => {
const current = [...(config.rightPanel?.actionButtons || [])];
current[index] = { ...current[index], action: value as any };
updateConfig("rightPanel.actionButtons", current);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="add"> ( )</SelectItem>
<SelectItem value="edit"> ( )</SelectItem>
<SelectItem value="bulk-delete"> ( )</SelectItem>
<SelectItem value="custom"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-muted-foreground"></Label>
<Select
value={btn.variant || "default"}
onValueChange={(value) => {
const current = [...(config.rightPanel?.actionButtons || [])];
current[index] = { ...current[index], variant: value as any };
updateConfig("rightPanel.actionButtons", current);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"> (Primary)</SelectItem>
<SelectItem value="outline"></SelectItem>
<SelectItem value="destructive"> ()</SelectItem>
<SelectItem value="ghost"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-muted-foreground"></Label>
<Select
value={btn.icon || "none"}
onValueChange={(value) => {
const current = [...(config.rightPanel?.actionButtons || [])];
current[index] = { ...current[index], icon: value === "none" ? undefined : value };
updateConfig("rightPanel.actionButtons", current);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="Plus">+ ()</SelectItem>
<SelectItem value="Edit"></SelectItem>
<SelectItem value="Trash2"></SelectItem>
</SelectContent>
</Select>
</div>
{btn.action === "add" && (
<div>
<Label className="text-xs text-muted-foreground"> </Label>
<ScreenSelect
value={btn.modalScreenId}
onValueChange={(value) => {
const current = [...(config.rightPanel?.actionButtons || [])];
current[index] = { ...current[index], modalScreenId: value };
updateConfig("rightPanel.actionButtons", current);
}}
placeholder="모달 화면 선택"
open={false}
onOpenChange={() => {}}
/>
</div>
)}
</div>
))}
{(config.rightPanel?.actionButtons || []).length === 0 && (
<div className="text-center py-4 text-xs text-muted-foreground border rounded-md">
()
</div>
)}
</div>
</div>
</div>
</div>

View File

@ -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; // 데이터 없을 때 메시지
}