수정/삭제 기능 구현

This commit is contained in:
dohyeons 2025-11-07 16:02:01 +09:00
parent 672aba8404
commit afea879920
7 changed files with 534 additions and 49 deletions

View File

@ -561,6 +561,11 @@ class DataService {
return validation.error!;
}
// _relationInfo 추출 (조인 관계 업데이트용)
const relationInfo = data._relationInfo;
const cleanData = { ...data };
delete cleanData._relationInfo;
// Primary Key 컬럼 찾기
const pkResult = await query<{ attname: string }>(
`SELECT a.attname
@ -575,8 +580,8 @@ class DataService {
pkColumn = pkResult[0].attname;
}
const columns = Object.keys(data);
const values = Object.values(data);
const columns = Object.keys(cleanData);
const values = Object.values(cleanData);
const setClause = columns
.map((col, index) => `"${col}" = $${index + 1}`)
.join(", ");
@ -599,6 +604,35 @@ class DataService {
};
}
// 🔗 조인 관계가 있는 경우, 연결된 테이블의 FK도 업데이트
if (relationInfo && relationInfo.rightTable && relationInfo.leftColumn && relationInfo.rightColumn) {
const { rightTable, leftColumn, rightColumn, oldLeftValue } = relationInfo;
const newLeftValue = cleanData[leftColumn];
// leftColumn 값이 변경된 경우에만 우측 테이블 업데이트
if (newLeftValue !== undefined && newLeftValue !== oldLeftValue) {
console.log("🔗 조인 관계 FK 업데이트:", {
rightTable,
rightColumn,
oldValue: oldLeftValue,
newValue: newLeftValue,
});
try {
const updateRelatedQuery = `
UPDATE "${rightTable}"
SET "${rightColumn}" = $1
WHERE "${rightColumn}" = $2
`;
const updateResult = await query(updateRelatedQuery, [newLeftValue, oldLeftValue]);
console.log(`✅ 연결된 ${rightTable} 테이블의 ${updateResult.length}개 레코드 업데이트 완료`);
} catch (relError) {
console.error("❌ 연결된 테이블 업데이트 실패:", relError);
// 연결된 테이블 업데이트 실패 시 롤백은 하지 않고 경고만 로그
}
}
}
return {
success: true,
data: result[0],

View File

@ -183,6 +183,15 @@ body {
background: hsl(var(--background));
}
/* Button 기본 커서 스타일 */
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
}
/* ===== Dialog/Modal Overlay ===== */
/* Radix UI Dialog Overlay - 60% 불투명도 배경 */
[data-radix-dialog-overlay],

View File

@ -94,7 +94,7 @@ export const dataApi = {
*/
updateRecord: async (tableName: string, id: string | number, data: Record<string, any>): Promise<any> => {
const response = await apiClient.put(`/data/${tableName}/${id}`, data);
return response.data?.data || response.data;
return response.data; // success, data, message 포함된 전체 응답 반환
},
/**
@ -102,7 +102,8 @@ export const dataApi = {
* @param tableName
* @param id ID
*/
deleteRecord: async (tableName: string, id: string | number): Promise<void> => {
await apiClient.delete(`/data/${tableName}/${id}`);
deleteRecord: async (tableName: string, id: string | number): Promise<any> => {
const response = await apiClient.delete(`/data/${tableName}/${id}`);
return response.data; // success, message 포함된 전체 응답 반환
},
};

View File

@ -771,7 +771,17 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
return;
}
console.log("🖼️ 대표 이미지 로드 시작:", file.realFileName);
// objid가 없거나 유효하지 않으면 로드 중단
if (!file.objid || file.objid === "0" || file.objid === "") {
console.warn("⚠️ 대표 이미지 로드 실패: objid가 없음", file);
setRepresentativeImageUrl(null);
return;
}
console.log("🖼️ 대표 이미지 로드 시작:", {
objid: file.objid,
fileName: file.realFileName,
});
// API 클라이언트를 통해 Blob으로 다운로드 (인증 토큰 포함)
const response = await apiClient.get(`/files/download/${file.objid}`, {
@ -792,8 +802,12 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
setRepresentativeImageUrl(url);
console.log("✅ 대표 이미지 로드 성공:", url);
} catch (error) {
console.error("❌ 대표 이미지 로드 실패:", error);
} catch (error: any) {
console.error("❌ 대표 이미지 로드 실패:", {
file: file.realFileName,
objid: file.objid,
error: error?.response?.status || error?.message,
});
setRepresentativeImageUrl(null);
}
},

View File

@ -6,7 +6,7 @@ import { SplitPanelLayoutConfig } from "./types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Plus, Search, GripVertical, Loader2, ChevronDown, ChevronUp, Save, ChevronRight } from "lucide-react";
import { Plus, Search, GripVertical, Loader2, ChevronDown, ChevronUp, Save, ChevronRight, Pencil, Trash2 } from "lucide-react";
import { dataApi } from "@/lib/api/data";
import { useToast } from "@/hooks/use-toast";
import { tableTypeApi } from "@/lib/api/screen";
@ -54,6 +54,17 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [showAddModal, setShowAddModal] = useState(false);
const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | "left-item" | null>(null);
const [addModalFormData, setAddModalFormData] = useState<Record<string, any>>({});
// 수정 모달 상태
const [showEditModal, setShowEditModal] = useState(false);
const [editModalPanel, setEditModalPanel] = useState<"left" | "right" | null>(null);
const [editModalItem, setEditModalItem] = useState<any>(null);
const [editModalFormData, setEditModalFormData] = useState<Record<string, any>>({});
// 삭제 확인 모달 상태
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleteModalPanel, setDeleteModalPanel] = useState<"left" | "right" | null>(null);
const [deleteModalItem, setDeleteModalItem] = useState<any>(null);
// 리사이저 드래그 상태
const [isDragging, setIsDragging] = useState(false);
@ -286,6 +297,169 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
setShowAddModal(true);
}, []);
// 수정 버튼 핸들러
const handleEditClick = useCallback((panel: "left" | "right", item: any) => {
setEditModalPanel(panel);
setEditModalItem(item);
setEditModalFormData({ ...item });
setShowEditModal(true);
}, []);
// 수정 모달 저장
const handleEditModalSave = useCallback(async () => {
const tableName = editModalPanel === "left"
? componentConfig.leftPanel?.tableName
: componentConfig.rightPanel?.tableName;
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || 'id';
const primaryKey = editModalItem[sourceColumn] || editModalItem.id || editModalItem.ID;
if (!tableName || !primaryKey) {
toast({
title: "수정 오류",
description: "테이블명 또는 Primary Key가 없습니다.",
variant: "destructive",
});
return;
}
try {
console.log("📝 데이터 수정:", { tableName, primaryKey, data: editModalFormData });
// 프론트엔드 전용 필드 제거 (children, level 등)
const cleanData = { ...editModalFormData };
delete cleanData.children;
delete cleanData.level;
// 좌측 패널 수정 시, 조인 관계 정보 포함
let updatePayload: any = cleanData;
if (editModalPanel === "left" && componentConfig.rightPanel?.relation?.type === "join") {
// 조인 관계가 있는 경우, 관계 정보를 페이로드에 추가
updatePayload._relationInfo = {
rightTable: componentConfig.rightPanel.tableName,
leftColumn: componentConfig.rightPanel.relation.leftColumn,
rightColumn: componentConfig.rightPanel.relation.rightColumn,
oldLeftValue: editModalItem[componentConfig.rightPanel.relation.leftColumn],
};
console.log("🔗 조인 관계 정보 추가:", updatePayload._relationInfo);
}
const result = await dataApi.updateRecord(tableName, primaryKey, updatePayload);
if (result.success) {
toast({
title: "성공",
description: "데이터가 성공적으로 수정되었습니다.",
});
// 모달 닫기
setShowEditModal(false);
setEditModalFormData({});
setEditModalItem(null);
// 데이터 새로고침
if (editModalPanel === "left") {
loadLeftData();
// 우측 패널도 새로고침 (FK가 변경되었을 수 있음)
if (selectedLeftItem) {
loadRightData(selectedLeftItem);
}
} else if (editModalPanel === "right" && selectedLeftItem) {
loadRightData(selectedLeftItem);
}
} else {
toast({
title: "수정 실패",
description: result.message || "데이터 수정에 실패했습니다.",
variant: "destructive",
});
}
} catch (error: any) {
console.error("데이터 수정 오류:", error);
toast({
title: "오류",
description: error?.response?.data?.message || "데이터 수정 중 오류가 발생했습니다.",
variant: "destructive",
});
}
}, [editModalPanel, componentConfig, editModalItem, editModalFormData, toast, selectedLeftItem, loadLeftData, loadRightData]);
// 삭제 버튼 핸들러
const handleDeleteClick = useCallback((panel: "left" | "right", item: any) => {
setDeleteModalPanel(panel);
setDeleteModalItem(item);
setShowDeleteModal(true);
}, []);
// 삭제 확인
const handleDeleteConfirm = useCallback(async () => {
const tableName = deleteModalPanel === "left"
? componentConfig.leftPanel?.tableName
: componentConfig.rightPanel?.tableName;
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || 'id';
const primaryKey = deleteModalItem[sourceColumn] || deleteModalItem.id || deleteModalItem.ID;
if (!tableName || !primaryKey) {
toast({
title: "삭제 오류",
description: "테이블명 또는 Primary Key가 없습니다.",
variant: "destructive",
});
return;
}
try {
console.log("🗑️ 데이터 삭제:", { tableName, primaryKey });
const result = await dataApi.deleteRecord(tableName, primaryKey);
if (result.success) {
toast({
title: "성공",
description: "데이터가 성공적으로 삭제되었습니다.",
});
// 모달 닫기
setShowDeleteModal(false);
setDeleteModalItem(null);
// 데이터 새로고침
if (deleteModalPanel === "left") {
loadLeftData();
// 삭제된 항목이 선택되어 있었으면 선택 해제
if (selectedLeftItem && selectedLeftItem[sourceColumn] === primaryKey) {
setSelectedLeftItem(null);
setRightData(null);
}
} else if (deleteModalPanel === "right" && selectedLeftItem) {
loadRightData(selectedLeftItem);
}
} else {
toast({
title: "삭제 실패",
description: result.message || "데이터 삭제에 실패했습니다.",
variant: "destructive",
});
}
} catch (error: any) {
console.error("데이터 삭제 오류:", error);
// 외래키 제약조건 에러 처리
let errorMessage = "데이터 삭제 중 오류가 발생했습니다.";
if (error?.response?.data?.error?.includes("foreign key")) {
errorMessage = "이 데이터를 참조하는 다른 데이터가 있어 삭제할 수 없습니다.";
}
toast({
title: "오류",
description: errorMessage,
variant: "destructive",
});
}
}, [deleteModalPanel, componentConfig, deleteModalItem, toast, selectedLeftItem, loadLeftData, loadRightData]);
// 항목별 추가 버튼 핸들러 (좌측 항목의 + 버튼 - 하위 항목 추가)
const handleItemAddClick = useCallback((item: any) => {
const itemAddConfig = componentConfig.leftPanel?.itemAddConfig;
@ -527,7 +701,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<CardTitle className="text-base font-semibold">
{componentConfig.leftPanel?.title || "좌측 패널"}
</CardTitle>
{componentConfig.leftPanel?.showAdd && !isDesignMode && (
{!isDesignMode && componentConfig.leftPanel?.showAdd && (
<Button
size="sm"
variant="outline"
@ -693,25 +867,54 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<div className="w-5" />
)}
{/* 항목 내용 */}
<div className="flex-1 min-w-0">
<div className="truncate font-medium">{displayTitle}</div>
{displaySubtitle && <div className="truncate text-xs text-muted-foreground">{displaySubtitle}</div>}
</div>
{/* 항목별 추가 버튼 */}
{componentConfig.leftPanel?.showItemAddButton && !isDesignMode && (
<button
onClick={(e) => {
e.stopPropagation();
handleItemAddClick(item);
}}
className="flex-shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
title="하위 항목 추가"
>
<Plus className="h-5 w-5 rounded-md bg-primary p-1 text-primary-foreground hover:bg-primary/90" />
</button>
)}
{/* 항목 내용 */}
<div className="flex-1 min-w-0">
<div className="truncate font-medium">{displayTitle}</div>
{displaySubtitle && <div className="truncate text-xs text-muted-foreground">{displaySubtitle}</div>}
</div>
{/* 항목별 버튼들 */}
{!isDesignMode && (
<div className="flex flex-shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{/* 수정 버튼 */}
<button
onClick={(e) => {
e.stopPropagation();
handleEditClick("left", item);
}}
className="rounded p-1 hover:bg-gray-200 transition-colors"
title="수정"
>
<Pencil className="h-4 w-4 text-gray-600" />
</button>
{/* 삭제 버튼 */}
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteClick("left", item);
}}
className="rounded p-1 hover:bg-red-100 transition-colors"
title="삭제"
>
<Trash2 className="h-4 w-4 text-red-600" />
</button>
{/* 항목별 추가 버튼 */}
{componentConfig.leftPanel?.showItemAddButton && (
<button
onClick={(e) => {
e.stopPropagation();
handleItemAddClick(item);
}}
className="rounded p-1 hover:bg-gray-200 transition-colors"
title="하위 항목 추가"
>
<Plus className="h-4 w-4 text-gray-600" />
</button>
)}
</div>
)}
</div>
</div>
@ -765,15 +968,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<CardTitle className="text-base font-semibold">
{componentConfig.rightPanel?.title || "우측 패널"}
</CardTitle>
{componentConfig.rightPanel?.showAdd && !isDesignMode && (
<Button
size="sm"
variant="outline"
onClick={() => handleAddClick("right")}
>
<Plus className="mr-1 h-4 w-4" />
</Button>
{!isDesignMode && (
<div className="flex items-center gap-2">
{componentConfig.rightPanel?.showAdd && (
<Button
size="sm"
variant="outline"
onClick={() => handleAddClick("right")}
>
<Plus className="mr-1 h-4 w-4" />
</Button>
)}
{/* 우측 패널 수정/삭제는 각 카드에서 처리 */}
</div>
)}
</div>
{componentConfig.rightPanel?.showSearch && (
@ -871,13 +1079,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
key={itemId}
className="bg-card overflow-hidden rounded-lg border shadow-sm transition-all hover:shadow-md"
>
{/* 요약 정보 (클릭 가능) */}
<div
onClick={() => toggleRightItemExpansion(itemId)}
className="cursor-pointer p-3 transition-colors hover:bg-muted"
>
{/* 요약 정보 */}
<div className="p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div
className="min-w-0 flex-1 cursor-pointer"
onClick={() => toggleRightItemExpansion(itemId)}
>
{firstValues.map(([key, value], idx) => (
<div key={key} className="mb-1 last:mb-0">
<div className="text-xs font-medium text-muted-foreground">{getColumnLabel(key)}</div>
@ -887,12 +1095,44 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div>
))}
</div>
<div className="flex flex-shrink-0 items-start pt-1">
{isExpanded ? (
<ChevronUp className="h-5 w-5 text-muted-foreground" />
) : (
<ChevronDown className="h-5 w-5 text-muted-foreground" />
<div className="flex flex-shrink-0 items-start gap-1 pt-1">
{/* 수정 버튼 */}
{!isDesignMode && (
<button
onClick={(e) => {
e.stopPropagation();
handleEditClick("right", item);
}}
className="rounded p-1 hover:bg-gray-200 transition-colors"
title="수정"
>
<Pencil className="h-4 w-4 text-gray-600" />
</button>
)}
{/* 삭제 버튼 */}
{!isDesignMode && (
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteClick("right", item);
}}
className="rounded p-1 hover:bg-red-100 transition-colors"
title="삭제"
>
<Trash2 className="h-4 w-4 text-red-600" />
</button>
)}
{/* 확장/접기 버튼 */}
<button
onClick={() => toggleRightItemExpansion(itemId)}
className="rounded p-1 hover:bg-gray-200 transition-colors"
>
{isExpanded ? (
<ChevronUp className="h-5 w-5 text-muted-foreground" />
) : (
<ChevronDown className="h-5 w-5 text-muted-foreground" />
)}
</button>
</div>
</div>
</div>
@ -1085,6 +1325,157 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 수정 모달 */}
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{editModalPanel === "left"
? `${componentConfig.leftPanel?.title} 수정`
: `${componentConfig.rightPanel?.title} 수정`}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
. .
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{editModalItem && (() => {
// 좌측 패널 수정: leftColumn만 수정 가능
if (editModalPanel === "left") {
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
// leftColumn만 표시
if (!leftColumn || editModalFormData[leftColumn] === undefined) {
return <p className="text-sm text-muted-foreground"> .</p>;
}
return (
<div>
<Label htmlFor={`edit-${leftColumn}`} className="text-xs sm:text-sm">
{leftColumn}
</Label>
<Input
id={`edit-${leftColumn}`}
value={editModalFormData[leftColumn] || ""}
onChange={(e) => {
setEditModalFormData(prev => ({
...prev,
[leftColumn]: e.target.value
}));
}}
placeholder={`${leftColumn} 입력`}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
);
}
// 우측 패널 수정: 우측 패널에 설정된 표시 컬럼들만
if (editModalPanel === "right") {
const rightColumns = componentConfig.rightPanel?.columns;
if (rightColumns && rightColumns.length > 0) {
// 설정된 컬럼만 표시
return rightColumns.map((col) => (
<div key={col.name}>
<Label htmlFor={`edit-${col.name}`} className="text-xs sm:text-sm">
{col.label || col.name}
</Label>
<Input
id={`edit-${col.name}`}
value={editModalFormData[col.name] || ""}
onChange={(e) => {
setEditModalFormData(prev => ({
...prev,
[col.name]: e.target.value
}));
}}
placeholder={`${col.label || col.name} 입력`}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
));
} else {
// 설정이 없으면 모든 컬럼 표시 (company_code, company_name 제외)
return Object.entries(editModalFormData)
.filter(([key]) => key !== 'company_code' && key !== 'company_name')
.map(([key, value]) => (
<div key={key}>
<Label htmlFor={`edit-${key}`} className="text-xs sm:text-sm">
{key}
</Label>
<Input
id={`edit-${key}`}
value={editModalFormData[key] || ""}
onChange={(e) => {
setEditModalFormData(prev => ({
...prev,
[key]: e.target.value
}));
}}
placeholder={`${key} 입력`}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
));
}
}
return null;
})()}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setShowEditModal(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleEditModalSave}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
<Save className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 삭제 확인 모달 */}
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
?
<br /> .
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setShowDeleteModal(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
<Trash2 className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};

View File

@ -363,6 +363,22 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
/>
</div>
<div className="flex items-center justify-between">
<Label> </Label>
<Switch
checked={config.leftPanel?.showEdit ?? false}
onCheckedChange={(checked) => updateLeftPanel({ showEdit: checked })}
/>
</div>
<div className="flex items-center justify-between">
<Label> </Label>
<Switch
checked={config.leftPanel?.showDelete ?? false}
onCheckedChange={(checked) => updateLeftPanel({ showDelete: checked })}
/>
</div>
<div className="flex items-center justify-between">
<Label> + </Label>
<Switch
@ -1007,6 +1023,22 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
/>
</div>
<div className="flex items-center justify-between">
<Label> </Label>
<Switch
checked={config.rightPanel?.showEdit ?? false}
onCheckedChange={(checked) => updateRightPanel({ showEdit: checked })}
/>
</div>
<div className="flex items-center justify-between">
<Label> </Label>
<Switch
checked={config.rightPanel?.showDelete ?? false}
onCheckedChange={(checked) => updateRightPanel({ showDelete: checked })}
/>
</div>
{/* 우측 패널 표시 컬럼 설정 */}
<div className="space-y-3 rounded-lg border border-green-200 bg-green-50 p-3">
<div className="flex items-center justify-between">

View File

@ -10,6 +10,8 @@ export interface SplitPanelLayoutConfig {
dataSource?: string; // API 엔드포인트
showSearch?: boolean;
showAdd?: boolean;
showEdit?: boolean; // 수정 버튼
showDelete?: boolean; // 삭제 버튼
columns?: Array<{
name: string;
label: string;
@ -45,6 +47,8 @@ export interface SplitPanelLayoutConfig {
dataSource?: string;
showSearch?: boolean;
showAdd?: boolean;
showEdit?: boolean; // 수정 버튼
showDelete?: boolean; // 삭제 버튼
columns?: Array<{
name: string;
label: string;