feat(split-panel-layout2): 테이블 모드, 수정/삭제, 복수 버튼 기능 추가
- 표시 모드 추가 (card/table) - 카드 모드 라벨 표시 옵션 (이름 행/정보 행 가로 배치) - 체크박스 선택 기능 (전체/개별 선택) - 개별 수정/삭제 핸들러 구현 (openEditModal, DELETE API) - 복수 액션 버튼 배열 지원 (add, edit, bulk-delete, custom) - 설정 패널에 표시 라벨 입력 필드 추가 - 기본키 컬럼 설정 옵션 추가
This commit is contained in:
parent
40c43bab16
commit
dfc83f6114
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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; // 데이터 없을 때 메시지
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue