버튼 삭제 수정기능 구현

This commit is contained in:
kjs 2025-09-18 18:49:30 +09:00
parent 87f3959036
commit 004bf28d17
14 changed files with 1637 additions and 134 deletions

View File

@ -99,6 +99,58 @@ export const updateFormData = async (
}
};
// 폼 데이터 부분 업데이트 (변경된 필드만)
export const updateFormDataPartial = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { id } = req.params;
const { companyCode, userId } = req.user as any;
const { tableName, originalData, newData } = req.body;
if (!tableName || !originalData || !newData) {
return res.status(400).json({
success: false,
message:
"필수 필드가 누락되었습니다. (tableName, originalData, newData)",
});
}
console.log("🔄 컨트롤러: 부분 업데이트 요청:", {
id,
tableName,
originalData,
newData,
});
// 메타데이터 추가
const newDataWithMeta = {
...newData,
updated_by: userId,
};
const result = await dynamicFormService.updateFormDataPartial(
parseInt(id),
tableName,
originalData,
newDataWithMeta
);
res.json({
success: true,
data: result,
message: "데이터가 성공적으로 업데이트되었습니다.",
});
} catch (error: any) {
console.error("❌ 부분 업데이트 실패:", error);
res.status(500).json({
success: false,
message: error.message || "부분 업데이트에 실패했습니다.",
});
}
};
// 폼 데이터 삭제
export const deleteFormData = async (
req: AuthenticatedRequest,
@ -131,6 +183,41 @@ export const deleteFormData = async (
}
};
// 테이블의 기본키 조회
export const getTablePrimaryKeys = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { tableName } = req.params;
if (!tableName) {
return res.status(400).json({
success: false,
message: "테이블명이 누락되었습니다.",
});
}
console.log(`🔑 테이블 ${tableName}의 기본키 조회 요청`);
const primaryKeys = await dynamicFormService.getTablePrimaryKeys(tableName);
console.log(`✅ 테이블 ${tableName}의 기본키:`, primaryKeys);
res.json({
success: true,
data: primaryKeys,
message: "기본키 조회가 완료되었습니다.",
});
} catch (error: any) {
console.error("❌ 기본키 조회 실패:", error);
res.status(500).json({
success: false,
message: error.message || "기본키 조회에 실패했습니다.",
});
}
};
// 단일 폼 데이터 조회
export const getFormData = async (
req: AuthenticatedRequest,

View File

@ -3,11 +3,13 @@ import { authenticateToken } from "../middleware/authMiddleware";
import {
saveFormData,
updateFormData,
updateFormDataPartial,
deleteFormData,
getFormData,
getFormDataList,
validateFormData,
getTableColumns,
getTablePrimaryKeys,
} from "../controllers/dynamicFormController";
const router = express.Router();
@ -18,6 +20,7 @@ router.use(authenticateToken);
// 폼 데이터 CRUD
router.post("/save", saveFormData);
router.put("/:id", updateFormData);
router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트
router.delete("/:id", deleteFormData);
router.get("/:id", getFormData);
@ -30,4 +33,7 @@ router.post("/validate", validateFormData);
// 테이블 컬럼 정보 조회 (검증용)
router.get("/table/:tableName/columns", getTableColumns);
// 테이블 기본키 조회
router.get("/table/:tableName/primary-keys", getTablePrimaryKeys);
export default router;

View File

@ -14,6 +14,12 @@ export interface FormDataResult {
updatedBy: string;
}
export interface PartialUpdateResult {
success: boolean;
data: any;
message: string;
}
export interface PaginatedFormData {
content: FormDataResult[];
totalElements: number;
@ -128,9 +134,9 @@ export class DynamicFormService {
}
/**
* Primary Key
* Primary Key ( )
*/
private async getTablePrimaryKeys(tableName: string): Promise<string[]> {
async getTablePrimaryKeys(tableName: string): Promise<string[]> {
try {
const result = (await prisma.$queryRawUnsafe(`
SELECT kcu.column_name
@ -385,6 +391,118 @@ export class DynamicFormService {
}
}
/**
* ( )
*/
async updateFormDataPartial(
id: number,
tableName: string,
originalData: Record<string, any>,
newData: Record<string, any>
): Promise<PartialUpdateResult> {
try {
console.log("🔄 서비스: 부분 업데이트 시작:", {
id,
tableName,
originalData,
newData,
});
// 테이블의 실제 컬럼 정보 조회
const tableColumns = await this.getTableColumnNames(tableName);
console.log(`📋 테이블 ${tableName}의 컬럼:`, tableColumns);
// 변경된 필드만 찾기
const changedFields: Record<string, any> = {};
for (const [key, value] of Object.entries(newData)) {
// 메타데이터 필드 제외
if (
["created_by", "updated_by", "company_code", "screen_id"].includes(
key
)
) {
continue;
}
// 테이블에 존재하지 않는 컬럼 제외
if (!tableColumns.includes(key)) {
console.log(
`⚠️ 컬럼 ${key}는 테이블 ${tableName}에 존재하지 않아 제외됨`
);
continue;
}
// 값이 실제로 변경된 경우만 포함
if (originalData[key] !== value) {
changedFields[key] = value;
console.log(
`📝 변경된 필드: ${key} = "${originalData[key]}" → "${value}"`
);
}
}
// 변경된 필드가 없으면 업데이트 건너뛰기
if (Object.keys(changedFields).length === 0) {
console.log("📋 변경된 필드가 없습니다. 업데이트를 건너뜁니다.");
return {
success: true,
data: originalData,
message: "변경사항이 없어 업데이트하지 않았습니다.",
};
}
// 업데이트 관련 필드 추가 (변경사항이 있는 경우에만)
if (tableColumns.includes("updated_at")) {
changedFields.updated_at = new Date();
}
console.log("🎯 실제 업데이트할 필드들:", changedFields);
// 동적으로 기본키 조회
const primaryKeys = await this.getTablePrimaryKeys(tableName);
if (!primaryKeys || primaryKeys.length === 0) {
throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`);
}
const primaryKeyColumn = primaryKeys[0];
console.log(`🔑 테이블 ${tableName}의 기본키: ${primaryKeyColumn}`);
// 동적 UPDATE SQL 생성 (변경된 필드만)
const setClause = Object.keys(changedFields)
.map((key, index) => `${key} = $${index + 1}`)
.join(", ");
const values: any[] = Object.values(changedFields);
values.push(id); // WHERE 조건용 ID 추가
const updateQuery = `
UPDATE ${tableName}
SET ${setClause}
WHERE ${primaryKeyColumn} = $${values.length}
RETURNING *
`;
console.log("📝 실행할 부분 UPDATE SQL:", updateQuery);
console.log("📊 SQL 파라미터:", values);
const result = await prisma.$queryRawUnsafe(updateQuery, ...values);
console.log("✅ 서비스: 부분 업데이트 성공:", result);
const updatedRecord = Array.isArray(result) ? result[0] : result;
return {
success: true,
data: updatedRecord,
message: "데이터가 성공적으로 업데이트되었습니다.",
};
} catch (error: any) {
console.error("❌ 서비스: 부분 업데이트 실패:", error);
throw new Error(`부분 업데이트 실패: ${error}`);
}
}
/**
* ( )
*/
@ -448,11 +566,19 @@ export class DynamicFormService {
const values: any[] = Object.values(dataToUpdate);
values.push(id); // WHERE 조건용 ID 추가
// ID 또는 objid로 찾기 시도
// 동적으로 기본키 조회
const primaryKeys = await this.getTablePrimaryKeys(tableName);
if (!primaryKeys || primaryKeys.length === 0) {
throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`);
}
const primaryKeyColumn = primaryKeys[0]; // 첫 번째 기본키 사용
console.log(`🔑 테이블 ${tableName}의 기본키: ${primaryKeyColumn}`);
const updateQuery = `
UPDATE ${tableName}
SET ${setClause}
WHERE (id = $${values.length} OR objid = $${values.length})
WHERE ${primaryKeyColumn} = $${values.length}
RETURNING *
`;
@ -524,10 +650,40 @@ export class DynamicFormService {
tableName,
});
// 동적 DELETE SQL 생성
// 1. 먼저 테이블의 기본키 컬럼명을 동적으로 조회
const primaryKeyQuery = `
SELECT kcu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
WHERE tc.table_name = $1
AND tc.constraint_type = 'PRIMARY KEY'
LIMIT 1
`;
console.log("🔍 기본키 조회 SQL:", primaryKeyQuery);
console.log("🔍 테이블명:", tableName);
const primaryKeyResult = await prisma.$queryRawUnsafe(
primaryKeyQuery,
tableName
);
if (
!primaryKeyResult ||
!Array.isArray(primaryKeyResult) ||
primaryKeyResult.length === 0
) {
throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`);
}
const primaryKeyColumn = (primaryKeyResult[0] as any).column_name;
console.log("🔑 발견된 기본키 컬럼:", primaryKeyColumn);
// 2. 동적으로 발견된 기본키를 사용한 DELETE SQL 생성
const deleteQuery = `
DELETE FROM ${tableName}
WHERE (id = $1 OR objid = $1)
WHERE ${primaryKeyColumn} = $1
RETURNING *
`;

View File

@ -12,6 +12,7 @@ import { DynamicWebTypeRenderer } from "@/lib/registry";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { initializeComponents } from "@/lib/registry/components";
import { EditModal } from "@/components/screen/EditModal";
export default function ScreenViewPage() {
const params = useParams();
@ -24,6 +25,22 @@ export default function ScreenViewPage() {
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState<Record<string, any>>({});
// 테이블 선택된 행 상태 (화면 레벨에서 관리)
const [selectedRows, setSelectedRows] = useState<any[]>([]);
const [selectedRowsData, setSelectedRowsData] = useState<any[]>([]);
// 테이블 새로고침을 위한 키 상태
const [refreshKey, setRefreshKey] = useState(0);
// 편집 모달 상태
const [editModalOpen, setEditModalOpen] = useState(false);
const [editModalConfig, setEditModalConfig] = useState<{
screenId?: number;
modalSize?: "sm" | "md" | "lg" | "xl" | "full";
editData?: any;
onSave?: () => void;
}>({});
useEffect(() => {
const initComponents = async () => {
try {
@ -38,6 +55,29 @@ export default function ScreenViewPage() {
initComponents();
}, []);
// 편집 모달 이벤트 리스너 등록
useEffect(() => {
const handleOpenEditModal = (event: CustomEvent) => {
console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
setEditModalConfig({
screenId: event.detail.screenId,
modalSize: event.detail.modalSize,
editData: event.detail.editData,
onSave: event.detail.onSave,
});
setEditModalOpen(true);
};
// @ts-ignore
window.addEventListener("openEditModal", handleOpenEditModal);
return () => {
// @ts-ignore
window.removeEventListener("openEditModal", handleOpenEditModal);
};
}, []);
useEffect(() => {
const loadScreen = async () => {
try {
@ -262,10 +302,24 @@ export default function ScreenViewPage() {
tableName={screen?.tableName}
onRefresh={() => {
console.log("화면 새로고침 요청");
// 테이블 컴포넌트 강제 새로고침을 위한 키 업데이트
setRefreshKey((prev) => prev + 1);
// 선택된 행 상태도 초기화
setSelectedRows([]);
setSelectedRowsData([]);
}}
onClose={() => {
console.log("화면 닫기 요청");
}}
// 테이블 선택된 행 정보 전달
selectedRows={selectedRows}
selectedRowsData={selectedRowsData}
onSelectedRowsChange={(newSelectedRows, newSelectedRowsData) => {
setSelectedRows(newSelectedRows);
setSelectedRowsData(newSelectedRowsData);
}}
// 테이블 새로고침 키 전달
refreshKey={refreshKey}
/>
) : (
<DynamicWebTypeRenderer
@ -327,6 +381,31 @@ export default function ScreenViewPage() {
</div>
</div>
)}
{/* 편집 모달 */}
<EditModal
isOpen={editModalOpen}
onClose={() => {
setEditModalOpen(false);
setEditModalConfig({});
}}
screenId={editModalConfig.screenId}
modalSize={editModalConfig.modalSize}
editData={editModalConfig.editData}
onSave={editModalConfig.onSave}
onDataChange={(changedFormData) => {
console.log("📝 EditModal에서 데이터 변경 수신:", changedFormData);
// 변경된 데이터를 메인 폼에 반영
setFormData((prev) => {
const updatedFormData = {
...prev,
...changedFormData, // 변경된 필드들만 업데이트
};
console.log("📊 메인 폼 데이터 업데이트:", updatedFormData);
return updatedFormData;
});
}}
/>
</div>
);
}

View File

@ -0,0 +1,312 @@
"use client";
import React, { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { X, Save, RotateCcw } from "lucide-react";
import { toast } from "sonner";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { screenApi } from "@/lib/api/screen";
import { ComponentData } from "@/lib/types/screen";
interface EditModalProps {
isOpen: boolean;
onClose: () => void;
screenId?: number;
modalSize?: "sm" | "md" | "lg" | "xl" | "full";
editData?: any;
onSave?: () => void;
onDataChange?: (formData: Record<string, any>) => void; // 폼 데이터 변경 콜백 추가
}
/**
*
*
*/
export const EditModal: React.FC<EditModalProps> = ({
isOpen,
onClose,
screenId,
modalSize = "lg",
editData,
onSave,
onDataChange,
}) => {
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState<any>({});
const [originalData, setOriginalData] = useState<any>({}); // 부분 업데이트용 원본 데이터
const [screenData, setScreenData] = useState<any>(null);
const [components, setComponents] = useState<ComponentData[]>([]);
// 컴포넌트 기반 동적 크기 계산
const calculateModalSize = () => {
if (components.length === 0) {
return { width: 600, height: 400 }; // 기본 크기
}
const maxWidth = Math.max(...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)), 500) + 100; // 더 넉넉한 여백
const maxHeight = Math.max(...components.map((c) => (c.position?.y || 0) + (c.size?.height || 40)), 400) + 20; // 최소한의 여백만 추가
console.log(`🎯 계산된 모달 크기: ${maxWidth}px x ${maxHeight}px`);
console.log(
`📍 컴포넌트 위치들:`,
components.map((c) => ({ x: c.position?.x, y: c.position?.y, w: c.size?.width, h: c.size?.height })),
);
return { width: maxWidth, height: maxHeight };
};
const dynamicSize = calculateModalSize();
// DialogContent 크기 강제 적용
useEffect(() => {
if (isOpen && dynamicSize) {
// 모달이 렌더링된 후 DOM 직접 조작으로 크기 강제 적용
setTimeout(() => {
const dialogContent = document.querySelector('[role="dialog"] > div');
const modalContent = document.querySelector('[role="dialog"] [class*="overflow-auto"]');
if (dialogContent) {
const targetWidth = dynamicSize.width;
const targetHeight = dynamicSize.height;
console.log(`🔧 DialogContent 크기 강제 적용: ${targetWidth}px x ${targetHeight}px`);
dialogContent.style.width = `${targetWidth}px`;
dialogContent.style.height = `${targetHeight}px`;
dialogContent.style.minWidth = `${targetWidth}px`;
dialogContent.style.minHeight = `${targetHeight}px`;
dialogContent.style.maxWidth = "95vw";
dialogContent.style.maxHeight = "95vh";
dialogContent.style.padding = "0";
}
// 스크롤 완전 제거
if (modalContent) {
modalContent.style.overflow = "hidden";
console.log(`🚫 스크롤 완전 비활성화`);
}
}, 100); // 100ms 지연으로 렌더링 완료 후 실행
}
}, [isOpen, dynamicSize]);
// 편집 데이터가 변경되면 폼 데이터 및 원본 데이터 초기화
useEffect(() => {
if (editData) {
console.log("📋 편집 데이터 로드:", editData);
console.log("📋 편집 데이터 키들:", Object.keys(editData));
// 원본 데이터와 현재 폼 데이터 모두 설정
const dataClone = { ...editData };
setOriginalData(dataClone); // 원본 데이터 저장 (부분 업데이트용)
setFormData(dataClone); // 편집용 폼 데이터 설정
console.log("📋 originalData 설정 완료:", dataClone);
console.log("📋 formData 설정 완료:", dataClone);
} else {
console.log("⚠️ editData가 없습니다.");
setOriginalData({});
setFormData({});
}
}, [editData]);
// formData 변경 시 로그
useEffect(() => {
console.log("🔄 EditModal formData 상태 변경:", formData);
console.log("🔄 formData 키들:", Object.keys(formData || {}));
}, [formData]);
// 화면 데이터 로드
useEffect(() => {
const fetchScreenData = async () => {
if (!screenId || !isOpen) return;
try {
setLoading(true);
console.log("🔄 화면 데이터 로드 시작:", screenId);
// 화면 정보와 레이아웃 데이터를 동시에 로드
const [screenInfo, layoutData] = await Promise.all([
screenApi.getScreen(screenId),
screenApi.getLayout(screenId),
]);
console.log("📋 화면 정보:", screenInfo);
console.log("🎨 레이아웃 데이터:", layoutData);
setScreenData(screenInfo);
if (layoutData && layoutData.components) {
setComponents(layoutData.components);
console.log("✅ 화면 컴포넌트 로드 완료:", layoutData.components);
// 컴포넌트와 formData 매칭 정보 출력
console.log("🔍 컴포넌트-formData 매칭 분석:");
layoutData.components.forEach((comp) => {
if (comp.columnName) {
const formValue = formData[comp.columnName];
console.log(` - ${comp.columnName}: "${formValue}" (컴포넌트 ID: ${comp.id})`);
}
});
} else {
console.log("⚠️ 레이아웃 데이터가 없습니다:", layoutData);
}
} catch (error) {
console.error("❌ 화면 데이터 로드 실패:", error);
toast.error("화면을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
};
fetchScreenData();
}, [screenId, isOpen]);
// 저장 처리
const handleSave = async () => {
try {
setLoading(true);
console.log("💾 편집 데이터 저장:", formData);
// TODO: 실제 저장 API 호출
// const result = await DynamicFormApi.updateFormData({
// screenId,
// data: formData,
// });
// 임시: 저장 성공 시뮬레이션
await new Promise((resolve) => setTimeout(resolve, 1000));
toast.success("수정이 완료되었습니다.");
onSave?.();
onClose();
} catch (error) {
console.error("❌ 저장 실패:", error);
toast.error("저장 중 오류가 발생했습니다.");
} finally {
setLoading(false);
}
};
// 초기화 처리
const handleReset = () => {
if (editData) {
setFormData({ ...editData });
toast.info("초기값으로 되돌렸습니다.");
}
};
// 모달 크기 클래스 매핑
const getModalSizeClass = () => {
switch (modalSize) {
case "sm":
return "max-w-md";
case "md":
return "max-w-lg";
case "lg":
return "max-w-4xl";
case "xl":
return "max-w-6xl";
case "full":
return "max-w-[95vw] max-h-[95vh]";
default:
return "max-w-4xl";
}
};
if (!screenId) {
return null;
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
className="p-0"
style={{
// 실제 컨텐츠 크기 그대로 적용 (패딩/여백 제거)
width: dynamicSize.width,
height: dynamicSize.height,
minWidth: dynamicSize.width,
minHeight: dynamicSize.height,
maxWidth: "95vw",
maxHeight: "95vh",
}}
>
<DialogHeader className="sr-only">
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-hidden">
{loading ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-500"></div>
<p className="text-gray-600"> ...</p>
</div>
</div>
) : screenData && components.length > 0 ? (
// 원본 화면과 동일한 레이아웃으로 렌더링
<div
className="relative bg-white"
style={{
// 실제 컨텐츠 크기 그대로 적용 (여백 제거)
width: dynamicSize.width,
height: dynamicSize.height,
overflow: "hidden",
}}
>
{/* 화면 컴포넌트들 원본 레이아웃 유지하여 렌더링 */}
<div className="relative" style={{ minHeight: "300px" }}>
{components.map((component) => (
<div
key={component.id}
style={{
position: "absolute",
top: component.position?.y || 0,
left: component.position?.x || 0,
width: component.size?.width || 200,
height: component.size?.height || 40,
zIndex: 1,
}}
>
<DynamicComponentRenderer
component={component}
screenId={screenId}
tableName={screenData.tableName}
formData={formData}
originalData={originalData} // 부분 업데이트용 원본 데이터 전달
onFormDataChange={(fieldName, value) => {
console.log("📝 폼 데이터 변경:", fieldName, value);
const newFormData = { ...formData, [fieldName]: value };
setFormData(newFormData);
// 변경된 데이터를 즉시 부모로 전달
if (onDataChange) {
console.log("📤 EditModal -> 부모로 데이터 전달:", newFormData);
onDataChange(newFormData);
}
}}
// 편집 모드로 설정
mode="edit"
// 모달 내에서 렌더링되고 있음을 표시
isInModal={true}
// 인터랙티브 모드 활성화 (formData 사용을 위해 필수)
isInteractive={true}
/>
</div>
))}
</div>
</div>
) : (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-gray-500"> .</p>
<p className="mt-1 text-sm text-gray-400"> ID: {screenId}</p>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -272,6 +272,136 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
</div>
)}
{/* 수정 액션 설정 */}
{config.action?.type === "edit" && (
<div className="mt-4 space-y-4 rounded-lg border bg-green-50 p-4">
<h4 className="text-sm font-medium text-gray-700"> </h4>
<div>
<Label htmlFor="edit-screen"> </Label>
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={modalScreenOpen}
className="h-10 w-full justify-between"
disabled={screensLoading}
>
{config.action?.targetScreenId
? screens.find((screen) => screen.id === config.action?.targetScreenId)?.name ||
"수정 폼 화면을 선택하세요..."
: "수정 폼 화면을 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<div className="flex flex-col">
{/* 검색 입력 */}
<div className="flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
placeholder="화면 검색..."
value={modalSearchTerm}
onChange={(e) => setModalSearchTerm(e.target.value)}
className="border-0 p-0 focus-visible:ring-0"
/>
</div>
{/* 검색 결과 */}
<div className="max-h-[200px] overflow-auto">
{(() => {
const filteredScreens = filterScreens(modalSearchTerm);
if (screensLoading) {
return <div className="p-3 text-sm text-gray-500"> ...</div>;
}
if (filteredScreens.length === 0) {
return <div className="p-3 text-sm text-gray-500"> .</div>;
}
return filteredScreens.map((screen, index) => (
<div
key={`edit-screen-${screen.id}-${index}`}
className="flex cursor-pointer items-center px-3 py-2 hover:bg-gray-100"
onClick={() => {
onUpdateProperty("componentConfig.action", {
...config.action,
targetScreenId: screen.id,
});
setModalScreenOpen(false);
setModalSearchTerm("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.action?.targetScreenId === screen.id ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{screen.name}</span>
{screen.description && <span className="text-xs text-gray-500">{screen.description}</span>}
</div>
</div>
));
})()}
</div>
</div>
</PopoverContent>
</Popover>
<p className="mt-1 text-xs text-gray-500">
</p>
</div>
<div>
<Label htmlFor="edit-mode"> </Label>
<Select
value={config.action?.editMode || "modal"}
onValueChange={(value) =>
onUpdateProperty("componentConfig.action", {
...config.action,
editMode: value,
})
}
>
<SelectTrigger>
<SelectValue placeholder="수정 모드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="modal"> </SelectItem>
<SelectItem value="navigate"> </SelectItem>
<SelectItem value="inline"> </SelectItem>
</SelectContent>
</Select>
</div>
{config.action?.editMode === "modal" && (
<div>
<Label htmlFor="edit-modal-size"> </Label>
<Select
value={config.action?.modalSize || "lg"}
onValueChange={(value) =>
onUpdateProperty("componentConfig.action", {
...config.action,
modalSize: value,
})
}
>
<SelectTrigger>
<SelectValue placeholder="모달 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"> (Small)</SelectItem>
<SelectItem value="md"> (Medium)</SelectItem>
<SelectItem value="lg"> (Large)</SelectItem>
<SelectItem value="xl"> (Extra Large)</SelectItem>
<SelectItem value="full"> (Full)</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
)}
{/* 페이지 이동 액션 설정 */}
{config.action?.type === "navigate" && (
<div className="mt-4 space-y-4 rounded-lg border bg-gray-50 p-4">

View File

@ -91,6 +91,53 @@ export class DynamicFormApi {
}
}
/**
* ( )
* @param id ID
* @param originalData
* @param newData
* @param tableName
* @returns
*/
static async updateFormDataPartial(
id: number,
originalData: Record<string, any>,
newData: Record<string, any>,
tableName: string,
): Promise<ApiResponse<SaveFormDataResponse>> {
try {
console.log("🔄 폼 데이터 부분 업데이트 요청:", {
id,
originalData,
newData,
tableName,
});
const response = await apiClient.patch(`/dynamic-form/${id}/partial`, {
tableName,
originalData,
newData,
});
console.log("✅ 폼 데이터 부분 업데이트 성공:", response.data);
return {
success: true,
data: response.data,
message: "데이터가 성공적으로 업데이트되었습니다.",
};
} catch (error: any) {
console.error("❌ 폼 데이터 부분 업데이트 실패:", error);
const errorMessage = error.response?.data?.message || error.message || "부분 업데이트 중 오류가 발생했습니다.";
return {
success: false,
message: errorMessage,
errorCode: error.response?.data?.errorCode,
};
}
}
/**
*
* @param id ID
@ -313,6 +360,36 @@ export class DynamicFormApi {
};
}
}
/**
*
* @param tableName
* @returns
*/
static async getTablePrimaryKeys(tableName: string): Promise<ApiResponse<string[]>> {
try {
console.log("🔑 테이블 기본키 조회 요청:", tableName);
const response = await apiClient.get(`/dynamic-form/table/${tableName}/primary-keys`);
console.log("✅ 테이블 기본키 조회 성공:", response.data);
return {
success: true,
data: response.data.data,
message: "기본키 조회가 완료되었습니다.",
};
} catch (error: any) {
console.error("❌ 테이블 기본키 조회 실패:", error);
const errorMessage = error.response?.data?.message || error.message || "기본키 조회 중 오류가 발생했습니다.";
return {
success: false,
message: errorMessage,
errorCode: error.response?.data?.errorCode,
};
}
}
}
// 편의를 위한 기본 export

View File

@ -12,6 +12,7 @@ export interface ComponentRenderer {
isSelected?: boolean;
isInteractive?: boolean;
formData?: Record<string, any>;
originalData?: Record<string, any>; // 부분 업데이트용 원본 데이터
onFormDataChange?: (fieldName: string, value: any) => void;
onClick?: (e?: React.MouseEvent) => void;
onDragStart?: (e: React.DragEvent) => void;
@ -24,6 +25,14 @@ export interface ComponentRenderer {
tableName?: string;
onRefresh?: () => void;
onClose?: () => void;
// 테이블 선택된 행 정보 (다중 선택 액션용)
selectedRows?: any[];
selectedRowsData?: any[];
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
// 테이블 새로고침 키
refreshKey?: number;
// 편집 모드
mode?: "view" | "edit";
[key: string]: any;
}): React.ReactElement;
}
@ -68,11 +77,19 @@ export interface DynamicComponentRendererProps {
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: () => void;
children?: React.ReactNode;
// 폼 데이터 관련
formData?: Record<string, any>;
originalData?: Record<string, any>; // 부분 업데이트용 원본 데이터
onFormDataChange?: (fieldName: string, value: any) => void;
// 버튼 액션을 위한 추가 props
screenId?: number;
tableName?: string;
onRefresh?: () => void;
onClose?: () => void;
// 편집 모드
mode?: "view" | "edit";
// 모달 내에서 렌더링 여부
isInModal?: boolean;
[key: string]: any;
}

View File

@ -28,6 +28,13 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
tableName?: string;
onRefresh?: () => void;
onClose?: () => void;
// 폼 데이터 관련
originalData?: Record<string, any>; // 부분 업데이트용 원본 데이터
// 테이블 선택된 행 정보 (다중 선택 액션용)
selectedRows?: any[];
selectedRowsData?: any[];
}
/**
@ -46,11 +53,14 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
className,
style,
formData,
originalData,
onFormDataChange,
screenId,
tableName,
onRefresh,
onClose,
selectedRows,
selectedRowsData,
...props
}) => {
// 확인 다이얼로그 상태
@ -84,6 +94,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
tableName,
onRefresh,
onClose,
selectedRows,
selectedRowsData,
});
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
@ -109,40 +121,48 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
let loadingToast: string | number | undefined;
try {
console.log("📱 로딩 토스트 표시 시작");
// 로딩 토스트 표시
loadingToast = toast.loading(
actionConfig.type === "save"
? "저장 중..."
: actionConfig.type === "delete"
? "삭제 중..."
: actionConfig.type === "submit"
? "제출 중..."
: "처리 중...",
);
console.log("📱 로딩 토스트 ID:", loadingToast);
// edit 액션을 제외하고만 로딩 토스트 표시
if (actionConfig.type !== "edit") {
console.log("📱 로딩 토스트 표시 시작");
loadingToast = toast.loading(
actionConfig.type === "save"
? "저장 중..."
: actionConfig.type === "delete"
? "삭제 중..."
: actionConfig.type === "submit"
? "제출 중..."
: "처리 중...",
);
console.log("📱 로딩 토스트 ID:", loadingToast);
}
console.log("⚡ ButtonActionExecutor.executeAction 호출 시작");
const success = await ButtonActionExecutor.executeAction(actionConfig, context);
console.log("⚡ ButtonActionExecutor.executeAction 완료, success:", success);
// 로딩 토스트 제거
console.log("📱 로딩 토스트 제거");
toast.dismiss(loadingToast);
// 로딩 토스트 제거 (있는 경우에만)
if (loadingToast) {
console.log("📱 로딩 토스트 제거");
toast.dismiss(loadingToast);
}
// 성공 시 토스트 표시
const successMessage =
actionConfig.successMessage ||
(actionConfig.type === "save"
? "저장되었습니다."
: actionConfig.type === "delete"
? "삭제되었습니다."
: actionConfig.type === "submit"
? "제출되었습니다."
: "완료되었습니다.");
// edit 액션은 조용히 처리 (모달 열기만 하므로 토스트 불필요)
if (actionConfig.type !== "edit") {
const successMessage =
actionConfig.successMessage ||
(actionConfig.type === "save"
? "저장되었습니다."
: actionConfig.type === "delete"
? "삭제되었습니다."
: actionConfig.type === "submit"
? "제출되었습니다."
: "완료되었습니다.");
console.log("🎉 성공 토스트 표시:", successMessage);
toast.success(successMessage);
console.log("🎉 성공 토스트 표시:", successMessage);
toast.success(successMessage);
} else {
console.log("🔕 edit 액션은 조용히 처리 (토스트 없음)");
}
console.log("✅ 버튼 액션 실행 성공:", actionConfig.type);
} catch (error) {
@ -186,11 +206,15 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
if (isInteractive && processedConfig.action) {
const context: ButtonActionContext = {
formData: formData || {},
originalData: originalData || {}, // 부분 업데이트용 원본 데이터 추가
screenId,
tableName,
onFormDataChange,
onRefresh,
onClose,
// 테이블 선택된 행 정보 추가
selectedRows,
selectedRowsData,
};
// 확인이 필요한 액션인지 확인
@ -245,6 +269,13 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
tableName: _tableName,
onRefresh: _onRefresh,
onClose: _onClose,
selectedRows: _selectedRows,
selectedRowsData: _selectedRowsData,
onSelectedRowsChange: _onSelectedRowsChange,
originalData: _originalData, // 부분 업데이트용 원본 데이터 필터링
refreshKey: _refreshKey, // 필터링 추가
isInModal: _isInModal, // 필터링 추가
mode: _mode, // 필터링 추가
...domProps
} = props;

View File

@ -21,6 +21,7 @@ import {
ArrowDown,
TableIcon,
} from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
export interface TableListComponentProps {
@ -48,6 +49,12 @@ export interface TableListComponentProps {
onRefresh?: () => void;
onClose?: () => void;
screenId?: string;
// 선택된 행 정보 전달 핸들러
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
// 테이블 새로고침 키
refreshKey?: number;
}
/**
@ -66,6 +73,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
style,
onFormDataChange,
componentConfig,
onSelectedRowsChange,
refreshKey,
}) => {
// 컴포넌트 설정
const tableConfig = {
@ -90,6 +99,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const [selectedSearchColumn, setSelectedSearchColumn] = useState<string>(""); // 선택된 검색 컬럼
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]); // 🎯 표시할 컬럼 (Entity 조인 적용됨)
const [columnMeta, setColumnMeta] = useState<Record<string, { webType?: string; codeCategory?: string }>>({}); // 🎯 컬럼 메타정보 (웹타입, 코드카테고리)
// 체크박스 상태 관리
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set()); // 선택된 행들의 키 집합
const [isAllSelected, setIsAllSelected] = useState(false); // 전체 선택 상태
// 🎯 Entity 조인 최적화 훅 사용
const { optimizedConvertCode } = useEntityJoinOptimization(columnMeta, {
enableBatchLoading: true,
@ -407,6 +421,74 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
fetchTableData();
};
// 체크박스 핸들러들
const getRowKey = (row: any, index: number) => {
// 기본키가 있으면 사용, 없으면 인덱스 사용
return row.id || row.objid || row.pk || index.toString();
};
const handleRowSelection = (rowKey: string, checked: boolean) => {
const newSelectedRows = new Set(selectedRows);
if (checked) {
newSelectedRows.add(rowKey);
} else {
newSelectedRows.delete(rowKey);
}
setSelectedRows(newSelectedRows);
setIsAllSelected(newSelectedRows.size === data.length && data.length > 0);
// 선택된 실제 데이터를 상위 컴포넌트로 전달
const selectedKeys = Array.from(newSelectedRows);
const selectedData = selectedKeys
.map((key) => {
// rowKey를 사용하여 데이터 찾기 (ID 기반 또는 인덱스 기반)
return data.find((row, index) => {
const currentRowKey = getRowKey(row, index);
return currentRowKey === key;
});
})
.filter(Boolean);
console.log("🔍 handleRowSelection 디버그:", {
rowKey,
checked,
selectedKeys,
selectedData,
dataCount: data.length,
});
onSelectedRowsChange?.(selectedKeys, selectedData);
if (tableConfig.onSelectionChange) {
tableConfig.onSelectionChange(selectedData);
}
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
const allKeys = data.map((row, index) => getRowKey(row, index));
setSelectedRows(new Set(allKeys));
setIsAllSelected(true);
// 선택된 실제 데이터를 상위 컴포넌트로 전달
onSelectedRowsChange?.(allKeys, data);
if (tableConfig.onSelectionChange) {
tableConfig.onSelectionChange(data);
}
} else {
setSelectedRows(new Set());
setIsAllSelected(false);
// 빈 선택을 상위 컴포넌트로 전달
onSelectedRowsChange?.([], []);
if (tableConfig.onSelectionChange) {
tableConfig.onSelectionChange([]);
}
}
};
// 효과
useEffect(() => {
if (tableConfig.selectedTable) {
@ -442,15 +524,66 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
}, [tableConfig.selectedTable, localPageSize, currentPage, searchTerm, sortColumn, sortDirection, columnLabels]);
// 표시할 컬럼 계산 (Entity 조인 적용됨)
// refreshKey 변경 시 테이블 데이터 새로고침
useEffect(() => {
if (refreshKey && refreshKey > 0 && !isDesignMode) {
console.log("🔄 refreshKey 변경 감지, 테이블 데이터 새로고침:", refreshKey);
// 선택된 행 상태 초기화
setSelectedRows(new Set());
setIsAllSelected(false);
// 부모 컴포넌트에 빈 선택 상태 전달
console.log("🔄 선택 상태 초기화 - 빈 배열 전달");
onSelectedRowsChange?.([], []);
// 테이블 데이터 새로고침
fetchTableData();
}
}, [refreshKey]);
// 표시할 컬럼 계산 (Entity 조인 적용됨 + 체크박스 컬럼 추가)
const visibleColumns = useMemo(() => {
// 기본값 처리: checkbox 설정이 없으면 기본값 사용
const checkboxConfig = tableConfig.checkbox || {
enabled: true,
multiple: true,
position: "left",
selectAll: true,
};
let columns: ColumnConfig[] = [];
if (!displayColumns || displayColumns.length === 0) {
// displayColumns가 아직 설정되지 않은 경우 기본 컬럼 사용
if (!tableConfig.columns) return [];
return tableConfig.columns.filter((col) => col.visible).sort((a, b) => a.order - b.order);
columns = tableConfig.columns.filter((col) => col.visible).sort((a, b) => a.order - b.order);
} else {
columns = displayColumns.filter((col) => col.visible).sort((a, b) => a.order - b.order);
}
return displayColumns.filter((col) => col.visible).sort((a, b) => a.order - b.order);
}, [displayColumns, tableConfig.columns]);
// 체크박스가 활성화된 경우 체크박스 컬럼을 추가
if (checkboxConfig.enabled) {
const checkboxColumn: ColumnConfig = {
columnName: "__checkbox__",
displayName: "",
visible: true,
sortable: false,
searchable: false,
width: 50,
align: "center",
order: -1, // 가장 앞에 위치
fixed: checkboxConfig.position === "left" ? "left" : false,
fixedOrder: 0, // 가장 앞에 고정
};
// 체크박스 위치에 따라 추가
if (checkboxConfig.position === "left") {
columns.unshift(checkboxColumn);
} else {
columns.push(checkboxColumn);
}
}
return columns;
}, [displayColumns, tableConfig.columns, tableConfig.checkbox]);
// 컬럼을 고정 위치별로 분류
const columnsByPosition = useMemo(() => {
@ -502,6 +635,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const getColumnWidth = (column: ColumnConfig) => {
if (column.width) return column.width;
// 체크박스 컬럼인 경우 고정 너비
if (column.columnName === "__checkbox__") {
return 50;
}
// 컬럼 헤더 텍스트 길이 기반으로 계산
const headerText = columnLabels[column.columnName] || column.displayName || column.columnName;
const headerLength = headerText.length;
@ -518,6 +656,49 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return Math.max(minWidth, calculatedWidth);
};
// 체크박스 헤더 렌더링
const renderCheckboxHeader = () => {
// 기본값 처리: checkbox 설정이 없으면 기본값 사용
const checkboxConfig = tableConfig.checkbox || {
enabled: true,
multiple: true,
position: "left",
selectAll: true,
};
if (!checkboxConfig.enabled || !checkboxConfig.selectAll) {
return null;
}
return <Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} aria-label="전체 선택" />;
};
// 체크박스 셀 렌더링
const renderCheckboxCell = (row: any, index: number) => {
// 기본값 처리: checkbox 설정이 없으면 기본값 사용
const checkboxConfig = tableConfig.checkbox || {
enabled: true,
multiple: true,
position: "left",
selectAll: true,
};
if (!checkboxConfig.enabled) {
return null;
}
const rowKey = getRowKey(row, index);
const isSelected = selectedRows.has(rowKey);
return (
<Checkbox
checked={isSelected}
onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean)}
aria-label={`${index + 1} 선택`}
/>
);
};
// 🎯 값 포맷팅 (전역 코드 캐시 사용)
const formatCellValue = useMemo(() => {
return (value: any, format?: string, columnName?: string) => {
@ -597,6 +778,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</div>
<div className="flex items-center space-x-2">
{/* 선택된 항목 정보 표시 */}
{selectedRows.size > 0 && (
<div className="mr-4 flex items-center space-x-2">
<span className="text-sm text-gray-600">{selectedRows.size} </span>
</div>
)}
{/* 검색 */}
{tableConfig.filter?.enabled && tableConfig.filter?.quickSearch && (
<div className="flex items-center space-x-2">
@ -657,36 +845,52 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{/* 왼쪽 고정 컬럼 */}
{columnsByPosition.leftFixed.length > 0 && (
<div className="flex-shrink-0 border-r bg-gray-50/50">
<table className="table-auto">
<table
className="table-fixed-layout table-auto"
style={{ borderCollapse: "collapse", margin: 0, padding: 0 }}
>
<thead className={tableConfig.stickyHeader ? "sticky top-0 z-20 bg-white" : ""}>
<tr>
{columnsByPosition.leftFixed.map((column) => (
<th
key={`fixed-left-${column.columnName}`}
style={{ minWidth: `${getColumnWidth(column)}px` }}
className={cn(
"cursor-pointer border-b px-4 py-3 text-left font-medium whitespace-nowrap text-gray-900 select-none",
column.columnName === "__checkbox__"
? "h-12 border-b px-4 py-3 text-center align-middle"
: "h-12 cursor-pointer border-b px-4 py-3 text-left align-middle font-medium whitespace-nowrap text-gray-900 select-none",
`text-${column.align}`,
column.sortable && "hover:bg-gray-50",
)}
style={{
minWidth: `${getColumnWidth(column)}px`,
minHeight: "48px",
height: "48px",
verticalAlign: "middle",
lineHeight: "1",
boxSizing: "border-box",
}}
onClick={() => column.sortable && handleSort(column.columnName)}
>
<div className="flex items-center space-x-1">
<span className="text-sm">{columnLabels[column.columnName] || column.displayName}</span>
{column.sortable && (
<div className="flex flex-col">
{sortColumn === column.columnName ? (
sortDirection === "asc" ? (
<ArrowUp className="h-3 w-3" />
{column.columnName === "__checkbox__" ? (
renderCheckboxHeader()
) : (
<div className="flex items-center space-x-1">
<span className="text-sm">{columnLabels[column.columnName] || column.displayName}</span>
{column.sortable && (
<div className="flex flex-col">
{sortColumn === column.columnName ? (
sortDirection === "asc" ? (
<ArrowUp className="h-3 w-3" />
) : (
<ArrowDown className="h-3 w-3" />
)
) : (
<ArrowDown className="h-3 w-3" />
)
) : (
<ArrowUpDown className="h-3 w-3 text-gray-400" />
)}
</div>
)}
</div>
<ArrowUpDown className="h-3 w-3 text-gray-400" />
)}
</div>
)}
</div>
)}
</th>
))}
</tr>
@ -703,18 +907,25 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<tr
key={`fixed-left-row-${index}`}
className={cn(
"cursor-pointer border-b",
"h-12 cursor-pointer border-b leading-none",
tableConfig.tableStyle?.hoverEffect && "hover:bg-gray-50",
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-50/50",
)}
style={{ minHeight: "48px", height: "48px", lineHeight: "1" }}
onClick={() => handleRowClick(row)}
>
{columnsByPosition.leftFixed.map((column) => (
<td
key={`fixed-left-cell-${column.columnName}`}
className={cn("px-4 py-3 text-sm whitespace-nowrap", `text-${column.align}`)}
className={cn(
"h-12 px-4 py-3 align-middle text-sm whitespace-nowrap",
`text-${column.align}`,
)}
style={{ minHeight: "48px", height: "48px", verticalAlign: "middle" }}
>
{formatCellValue(row[column.columnName], column.format, column.columnName)}
{column.columnName === "__checkbox__"
? renderCheckboxCell(row, index)
: formatCellValue(row[column.columnName], column.format, column.columnName) || "\u00A0"}
</td>
))}
</tr>
@ -727,7 +938,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{/* 스크롤 가능한 중앙 컬럼들 */}
<div className="flex-1 overflow-x-auto">
<table className="w-full table-auto">
<table
className="table-fixed-layout w-full table-auto"
style={{ borderCollapse: "collapse", margin: 0, padding: 0 }}
>
<thead className={tableConfig.stickyHeader ? "sticky top-0 z-10 bg-white" : ""}>
<tr>
{columnsByPosition.normal.map((column) => (
@ -735,28 +949,34 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
key={`normal-${column.columnName}`}
style={{ minWidth: `${getColumnWidth(column)}px` }}
className={cn(
"cursor-pointer border-b px-4 py-3 text-left font-medium whitespace-nowrap text-gray-900 select-none",
column.columnName === "__checkbox__"
? "h-12 border-b px-4 py-3 text-center"
: "cursor-pointer border-b px-4 py-3 text-left font-medium whitespace-nowrap text-gray-900 select-none",
`text-${column.align}`,
column.sortable && "hover:bg-gray-50",
)}
onClick={() => column.sortable && handleSort(column.columnName)}
>
<div className="flex items-center space-x-1">
<span className="text-sm">{columnLabels[column.columnName] || column.displayName}</span>
{column.sortable && (
<div className="flex flex-col">
{sortColumn === column.columnName ? (
sortDirection === "asc" ? (
<ArrowUp className="h-3 w-3" />
{column.columnName === "__checkbox__" ? (
renderCheckboxHeader()
) : (
<div className="flex items-center space-x-1">
<span className="text-sm">{columnLabels[column.columnName] || column.displayName}</span>
{column.sortable && (
<div className="flex flex-col">
{sortColumn === column.columnName ? (
sortDirection === "asc" ? (
<ArrowUp className="h-3 w-3" />
) : (
<ArrowDown className="h-3 w-3" />
)
) : (
<ArrowDown className="h-3 w-3" />
)
) : (
<ArrowUpDown className="h-3 w-3 text-gray-400" />
)}
</div>
)}
</div>
<ArrowUpDown className="h-3 w-3 text-gray-400" />
)}
</div>
)}
</div>
)}
</th>
))}
</tr>
@ -784,9 +1004,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{columnsByPosition.normal.map((column) => (
<td
key={`normal-cell-${column.columnName}`}
className={cn("px-4 py-3 text-sm whitespace-nowrap", `text-${column.align}`)}
className={cn(
"h-12 px-4 py-3 align-middle text-sm whitespace-nowrap",
`text-${column.align}`,
)}
style={{ minHeight: "48px", height: "48px", verticalAlign: "middle" }}
>
{formatCellValue(row[column.columnName], column.format, column.columnName)}
{column.columnName === "__checkbox__"
? renderCheckboxCell(row, index)
: formatCellValue(row[column.columnName], column.format, column.columnName) || "\u00A0"}
</td>
))}
</tr>
@ -799,36 +1025,52 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{/* 오른쪽 고정 컬럼 */}
{columnsByPosition.rightFixed.length > 0 && (
<div className="flex-shrink-0 border-l bg-gray-50/50">
<table className="table-auto">
<table
className="table-fixed-layout table-auto"
style={{ borderCollapse: "collapse", margin: 0, padding: 0 }}
>
<thead className={tableConfig.stickyHeader ? "sticky top-0 z-20 bg-white" : ""}>
<tr>
{columnsByPosition.rightFixed.map((column) => (
<th
key={`fixed-right-${column.columnName}`}
style={{ minWidth: `${getColumnWidth(column)}px` }}
className={cn(
"cursor-pointer border-b px-4 py-3 text-left font-medium whitespace-nowrap text-gray-900 select-none",
column.columnName === "__checkbox__"
? "h-12 border-b px-4 py-3 text-center align-middle"
: "h-12 cursor-pointer border-b px-4 py-3 text-left align-middle font-medium whitespace-nowrap text-gray-900 select-none",
`text-${column.align}`,
column.sortable && "hover:bg-gray-50",
)}
style={{
minWidth: `${getColumnWidth(column)}px`,
minHeight: "48px",
height: "48px",
verticalAlign: "middle",
lineHeight: "1",
boxSizing: "border-box",
}}
onClick={() => column.sortable && handleSort(column.columnName)}
>
<div className="flex items-center space-x-1">
<span className="text-sm">{columnLabels[column.columnName] || column.displayName}</span>
{column.sortable && (
<div className="flex flex-col">
{sortColumn === column.columnName ? (
sortDirection === "asc" ? (
<ArrowUp className="h-3 w-3" />
{column.columnName === "__checkbox__" ? (
renderCheckboxHeader()
) : (
<div className="flex items-center space-x-1">
<span className="text-sm">{columnLabels[column.columnName] || column.displayName}</span>
{column.sortable && (
<div className="flex flex-col">
{sortColumn === column.columnName ? (
sortDirection === "asc" ? (
<ArrowUp className="h-3 w-3" />
) : (
<ArrowDown className="h-3 w-3" />
)
) : (
<ArrowDown className="h-3 w-3" />
)
) : (
<ArrowUpDown className="h-3 w-3 text-gray-400" />
)}
</div>
)}
</div>
<ArrowUpDown className="h-3 w-3 text-gray-400" />
)}
</div>
)}
</div>
)}
</th>
))}
</tr>
@ -847,18 +1089,25 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<tr
key={`fixed-right-row-${index}`}
className={cn(
"cursor-pointer border-b",
"h-12 cursor-pointer border-b leading-none",
tableConfig.tableStyle?.hoverEffect && "hover:bg-gray-50",
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-50/50",
)}
style={{ minHeight: "48px", height: "48px", lineHeight: "1" }}
onClick={() => handleRowClick(row)}
>
{columnsByPosition.rightFixed.map((column) => (
<td
key={`fixed-right-cell-${column.columnName}`}
className={cn("px-4 py-3 text-sm whitespace-nowrap", `text-${column.align}`)}
className={cn(
"h-12 px-4 py-3 align-middle text-sm whitespace-nowrap",
`text-${column.align}`,
)}
style={{ minHeight: "48px", height: "48px", verticalAlign: "middle" }}
>
{formatCellValue(row[column.columnName], column.format, column.columnName)}
{column.columnName === "__checkbox__"
? renderCheckboxCell(row, index)
: formatCellValue(row[column.columnName], column.format, column.columnName) || "\u00A0"}
</td>
))}
</tr>
@ -873,34 +1122,47 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 기존 테이블 (가로 스크롤이 필요 없는 경우)
<Table>
<TableHeader className={tableConfig.stickyHeader ? "sticky top-0 z-10 bg-white" : ""}>
<TableRow>
<TableRow style={{ minHeight: "48px !important", height: "48px !important", lineHeight: "1" }}>
{visibleColumns.map((column) => (
<TableHead
key={column.columnName}
style={{ width: column.width ? `${column.width}px` : undefined }}
style={{
width: column.width ? `${column.width}px` : undefined,
minHeight: "48px !important",
height: "48px !important",
verticalAlign: "middle",
lineHeight: "1",
boxSizing: "border-box",
}}
className={cn(
"cursor-pointer whitespace-nowrap select-none",
column.columnName === "__checkbox__"
? "h-12 text-center align-middle"
: "h-12 cursor-pointer align-middle whitespace-nowrap select-none",
`text-${column.align}`,
column.sortable && "hover:bg-gray-50",
)}
onClick={() => column.sortable && handleSort(column.columnName)}
>
<div className="flex items-center space-x-1">
<span>{columnLabels[column.columnName] || column.displayName}</span>
{column.sortable && (
<div className="flex flex-col">
{sortColumn === column.columnName ? (
sortDirection === "asc" ? (
<ArrowUp className="h-3 w-3" />
{column.columnName === "__checkbox__" ? (
renderCheckboxHeader()
) : (
<div className="flex items-center space-x-1">
<span>{columnLabels[column.columnName] || column.displayName}</span>
{column.sortable && (
<div className="flex flex-col">
{sortColumn === column.columnName ? (
sortDirection === "asc" ? (
<ArrowUp className="h-3 w-3" />
) : (
<ArrowDown className="h-3 w-3" />
)
) : (
<ArrowDown className="h-3 w-3" />
)
) : (
<ArrowUpDown className="h-3 w-3 text-gray-400" />
)}
</div>
)}
</div>
<ArrowUpDown className="h-3 w-3 text-gray-400" />
)}
</div>
)}
</div>
)}
</TableHead>
))}
</TableRow>
@ -917,15 +1179,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<TableRow
key={index}
className={cn(
"cursor-pointer",
"h-12 cursor-pointer leading-none",
tableConfig.tableStyle?.hoverEffect && "hover:bg-gray-50",
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-50/50",
)}
style={{ minHeight: "48px", height: "48px", lineHeight: "1" }}
onClick={() => handleRowClick(row)}
>
{visibleColumns.map((column) => (
<TableCell key={column.columnName} className={cn("whitespace-nowrap", `text-${column.align}`)}>
{formatCellValue(row[column.columnName], column.format, column.columnName)}
<TableCell
key={column.columnName}
className={cn("h-12 align-middle whitespace-nowrap", `text-${column.align}`)}
style={{ minHeight: "48px", height: "48px", verticalAlign: "middle" }}
>
{column.columnName === "__checkbox__"
? renderCheckboxCell(row, index)
: formatCellValue(row[column.columnName], column.format, column.columnName) || "\u00A0"}
</TableCell>
))}
</TableRow>

View File

@ -548,6 +548,63 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="checkboxEnabled"
checked={config.checkbox?.enabled}
onCheckedChange={(checked) => handleNestedChange("checkbox", "enabled", checked)}
/>
<Label htmlFor="checkboxEnabled"> </Label>
</div>
{config.checkbox?.enabled && (
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="checkboxMultiple"
checked={config.checkbox?.multiple}
onCheckedChange={(checked) => handleNestedChange("checkbox", "multiple", checked)}
/>
<Label htmlFor="checkboxMultiple"> ()</Label>
</div>
<div className="space-y-1">
<Label htmlFor="checkboxPosition" className="text-sm">
</Label>
<Select
value={config.checkbox?.position || "left"}
onValueChange={(value) => handleNestedChange("checkbox", "position", value)}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="위치 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="left"></SelectItem>
<SelectItem value="right"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="checkboxSelectAll"
checked={config.checkbox?.selectAll}
onCheckedChange={(checked) => handleNestedChange("checkbox", "selectAll", checked)}
/>
<Label htmlFor="checkboxSelectAll"> / </Label>
</div>
</div>
)}
</CardContent>
</Card>
</ScrollArea>
</TabsContent>

View File

@ -26,6 +26,14 @@ export const TableListDefinition = createComponentDefinition({
showFooter: true,
height: "auto",
// 체크박스 설정
checkbox: {
enabled: true,
multiple: true,
position: "left",
selectAll: true,
},
// 컬럼 설정
columns: [],
autoWidth: true,

View File

@ -89,6 +89,16 @@ export interface PaginationConfig {
pageSizeOptions: number[];
}
/**
*
*/
export interface CheckboxConfig {
enabled: boolean; // 체크박스 활성화 여부
multiple: boolean; // 다중 선택 가능 여부 (true: 체크박스, false: 라디오)
position: "left" | "right"; // 체크박스 위치
selectAll: boolean; // 전체 선택/해제 버튼 표시 여부
}
/**
* TableList
*/
@ -100,6 +110,9 @@ export interface TableListConfig extends ComponentConfig {
showHeader: boolean;
showFooter: boolean;
// 체크박스 설정
checkbox: CheckboxConfig;
// 높이 설정
height: "auto" | "fixed" | "viewport";
fixedHeight?: number;
@ -140,6 +153,9 @@ export interface TableListConfig extends ComponentConfig {
onPageChange?: (page: number, pageSize: number) => void;
onSortChange?: (column: string, direction: "asc" | "desc") => void;
onFilterChange?: (filters: any) => void;
// 선택된 행 정보 전달 핸들러
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
}
/**
@ -182,4 +198,7 @@ export interface TableListProps {
onSortChange?: (column: string, direction: "asc" | "desc") => void;
onFilterChange?: (filters: any) => void;
onRefresh?: () => void;
// 선택된 행 정보 전달 핸들러
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
}

View File

@ -53,11 +53,16 @@ export interface ButtonActionConfig {
*/
export interface ButtonActionContext {
formData: Record<string, any>;
originalData?: Record<string, any>; // 부분 업데이트용 원본 데이터
screenId?: number;
tableName?: string;
onFormDataChange?: (fieldName: string, value: any) => void;
onClose?: () => void;
onRefresh?: () => void;
// 테이블 선택된 행 정보 (다중 선택 액션용)
selectedRows?: any[];
selectedRowsData?: any[];
}
/**
@ -123,10 +128,10 @@ export class ButtonActionExecutor {
}
/**
*
* (INSERT/UPDATE - DB )
*/
private static async handleSave(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
const { formData, tableName, screenId } = context;
const { formData, originalData, tableName, screenId } = context;
// 폼 유효성 검사
if (config.validateForm) {
@ -152,16 +157,59 @@ export class ButtonActionExecutor {
throw new Error(`저장 실패: ${response.statusText}`);
}
} else if (tableName && screenId) {
// 기본 테이블 저장 로직
console.log("테이블 저장:", { tableName, formData, screenId });
// DB에서 실제 기본키 조회하여 INSERT/UPDATE 자동 판단
const primaryKeyResult = await DynamicFormApi.getTablePrimaryKeys(tableName);
// 실제 저장 API 호출
const saveResult = await DynamicFormApi.saveFormData({
screenId,
if (!primaryKeyResult.success) {
throw new Error(primaryKeyResult.message || "기본키 조회에 실패했습니다.");
}
const primaryKeys = primaryKeyResult.data || [];
const primaryKeyValue = this.extractPrimaryKeyValueFromDB(formData, primaryKeys);
const isUpdate = primaryKeyValue !== null && primaryKeyValue !== undefined && primaryKeyValue !== "";
console.log("💾 저장 모드 판단 (DB 기반):", {
tableName,
data: formData,
formData,
primaryKeys,
primaryKeyValue,
isUpdate: isUpdate ? "UPDATE" : "INSERT",
});
let saveResult;
if (isUpdate) {
// UPDATE 처리 - 부분 업데이트 사용 (원본 데이터가 있는 경우)
console.log("🔄 UPDATE 모드로 저장:", {
primaryKeyValue,
formData,
originalData,
hasOriginalData: !!originalData,
});
if (originalData) {
// 부분 업데이트: 변경된 필드만 업데이트
console.log("📝 부분 업데이트 실행 (변경된 필드만)");
saveResult = await DynamicFormApi.updateFormDataPartial(primaryKeyValue, originalData, formData, tableName);
} else {
// 전체 업데이트 (기존 방식)
console.log("📝 전체 업데이트 실행 (모든 필드)");
saveResult = await DynamicFormApi.updateFormData(primaryKeyValue, {
tableName,
data: formData,
});
}
} else {
// INSERT 처리
console.log("🆕 INSERT 모드로 저장:", { formData });
saveResult = await DynamicFormApi.saveFormData({
screenId,
tableName,
data: formData,
});
}
if (!saveResult.success) {
throw new Error(saveResult.message || "저장에 실패했습니다.");
}
@ -179,6 +227,76 @@ export class ButtonActionExecutor {
}
}
/**
* DB에서 formData에서
* @param formData
* @param primaryKeys DB에서
* @returns ( )
*/
private static extractPrimaryKeyValueFromDB(formData: Record<string, any>, primaryKeys: string[]): any {
if (!primaryKeys || primaryKeys.length === 0) {
console.log("🔍 DB에서 기본키를 찾을 수 없습니다. INSERT 모드로 처리됩니다.");
return null;
}
// 첫 번째 기본키 컬럼의 값을 사용 (복합키의 경우)
const primaryKeyColumn = primaryKeys[0];
if (formData.hasOwnProperty(primaryKeyColumn)) {
const value = formData[primaryKeyColumn];
console.log(`🔑 DB 기본키 발견: ${primaryKeyColumn} = ${value}`);
// 복합키인 경우 로그 출력
if (primaryKeys.length > 1) {
console.log(`🔗 복합 기본키 감지:`, primaryKeys);
console.log(`📍 첫 번째 키 (${primaryKeyColumn}) 값을 사용: ${value}`);
}
return value;
}
// 기본키 컬럼이 formData에 없는 경우
console.log(`❌ 기본키 컬럼 '${primaryKeyColumn}'이 formData에 없습니다. INSERT 모드로 처리됩니다.`);
console.log("📋 DB 기본키 컬럼들:", primaryKeys);
console.log("📋 사용 가능한 필드들:", Object.keys(formData));
return null;
}
/**
* @deprecated DB . extractPrimaryKeyValueFromDB
* formData에서 ( )
*/
private static extractPrimaryKeyValue(formData: Record<string, any>): any {
// 일반적인 기본 키 필드명들 (우선순위 순)
const commonPrimaryKeys = [
"id",
"ID", // 가장 일반적
"objid",
"OBJID", // 이 프로젝트에서 자주 사용
"pk",
"PK", // Primary Key 줄임말
"_id", // MongoDB 스타일
"uuid",
"UUID", // UUID 방식
"key",
"KEY", // 기타
];
// 우선순위에 따라 기본 키값 찾기
for (const keyName of commonPrimaryKeys) {
if (formData.hasOwnProperty(keyName)) {
const value = formData[keyName];
console.log(`🔑 추측 기반 기본 키 발견: ${keyName} = ${value}`);
return value;
}
}
// 기본 키를 찾지 못한 경우
console.log("🔍 추측 기반으로 기본 키를 찾을 수 없습니다. INSERT 모드로 처리됩니다.");
console.log("📋 사용 가능한 필드들:", Object.keys(formData));
return null;
}
/**
*
*/
@ -191,20 +309,50 @@ export class ButtonActionExecutor {
*
*/
private static async handleDelete(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
const { formData, tableName, screenId } = context;
const { formData, tableName, screenId, selectedRowsData } = context;
try {
// 다중 선택된 행이 있는 경우 (테이블에서 체크박스로 선택)
if (selectedRowsData && selectedRowsData.length > 0) {
console.log(`다중 삭제 액션 실행: ${selectedRowsData.length}개 항목`, selectedRowsData);
// 각 선택된 항목을 삭제
for (const rowData of selectedRowsData) {
// 더 포괄적인 ID 찾기 (테이블 구조에 따라 다양한 필드명 시도)
const deleteId = rowData.id || rowData.objid || rowData.pk || rowData.ID || rowData.OBJID || rowData.PK;
console.log("선택된 행 데이터:", rowData);
console.log("추출된 deleteId:", deleteId);
if (deleteId) {
console.log("다중 데이터 삭제:", { tableName, screenId, id: deleteId });
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deleteId, tableName);
if (!deleteResult.success) {
throw new Error(`ID ${deleteId} 삭제 실패: ${deleteResult.message}`);
}
} else {
console.error("삭제 ID를 찾을 수 없습니다. 행 데이터:", rowData);
throw new Error(`삭제 ID를 찾을 수 없습니다. 사용 가능한 필드: ${Object.keys(rowData).join(", ")}`);
}
}
console.log(`✅ 다중 삭제 성공: ${selectedRowsData.length}개 항목`);
context.onRefresh?.(); // 테이블 새로고침
return true;
}
// 단일 삭제 (기존 로직)
if (tableName && screenId && formData.id) {
console.log("데이터 삭제:", { tableName, screenId, id: formData.id });
console.log("단일 데이터 삭제:", { tableName, screenId, id: formData.id });
// 실제 삭제 API 호출
const deleteResult = await DynamicFormApi.deleteFormData(formData.id);
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(formData.id, tableName);
if (!deleteResult.success) {
throw new Error(deleteResult.message || "삭제에 실패했습니다.");
}
console.log("✅ 삭제 성공:", deleteResult);
console.log("✅ 단일 삭제 성공:", deleteResult);
} else {
throw new Error("삭제에 필요한 정보가 부족합니다. (ID, 테이블명 또는 화면ID 누락)");
}
@ -284,7 +432,7 @@ export class ButtonActionExecutor {
size: config.modalSize || "md",
},
});
window.dispatchEvent(modalEvent);
toast.success("모달 화면이 열렸습니다.");
} else {
@ -383,11 +531,118 @@ export class ButtonActionExecutor {
*
*/
private static handleEdit(config: ButtonActionConfig, context: ButtonActionContext): boolean {
console.log("편집 액션 실행:", context);
// 편집 로직 구현 (예: 편집 모드로 전환)
const { selectedRowsData } = context;
// 선택된 행이 없는 경우
if (!selectedRowsData || selectedRowsData.length === 0) {
toast.error("수정할 항목을 선택해주세요.");
return false;
}
// 편집 화면이 설정되지 않은 경우
if (!config.targetScreenId) {
toast.error("수정 폼 화면이 설정되지 않았습니다. 버튼 설정에서 수정 폼 화면을 선택해주세요.");
return false;
}
console.log(`📝 편집 액션 실행: ${selectedRowsData.length}개 항목`, {
selectedRowsData,
targetScreenId: config.targetScreenId,
editMode: config.editMode,
});
if (selectedRowsData.length === 1) {
// 단일 항목 편집
const rowData = selectedRowsData[0];
console.log("📝 단일 항목 편집:", rowData);
this.openEditForm(config, rowData, context);
} else {
// 다중 항목 편집 - 현재는 단일 편집만 지원
toast.error("현재 단일 항목 편집만 지원됩니다. 하나의 항목만 선택해주세요.");
return false;
// TODO: 향후 다중 편집 지원
// console.log("📝 다중 항목 편집:", selectedRowsData);
// this.openBulkEditForm(config, selectedRowsData, context);
}
return true;
}
/**
* ( )
*/
private static openEditForm(config: ButtonActionConfig, rowData: any, context: ButtonActionContext): void {
const editMode = config.editMode || "modal";
switch (editMode) {
case "modal":
// 모달로 편집 폼 열기
this.openEditModal(config, rowData, context);
break;
case "navigate":
// 새 페이지로 이동
this.navigateToEditScreen(config, rowData, context);
break;
case "inline":
// 현재 화면에서 인라인 편집 (향후 구현)
toast.info("인라인 편집 기능은 향후 지원 예정입니다.");
break;
default:
// 기본값: 모달
this.openEditModal(config, rowData, context);
}
}
/**
*
*/
private static openEditModal(config: ButtonActionConfig, rowData: any, context: ButtonActionContext): void {
console.log("🎭 편집 모달 열기:", {
targetScreenId: config.targetScreenId,
modalSize: config.modalSize,
rowData,
});
// 모달 열기 이벤트 발생
const modalEvent = new CustomEvent("openEditModal", {
detail: {
screenId: config.targetScreenId,
modalSize: config.modalSize || "lg",
editData: rowData,
onSave: () => {
// 저장 후 테이블 새로고침
console.log("💾 편집 저장 완료 - 테이블 새로고침");
context.onRefresh?.();
},
},
});
window.dispatchEvent(modalEvent);
// 편집 모달 열기는 조용히 처리 (토스트 없음)
}
/**
*
*/
private static navigateToEditScreen(config: ButtonActionConfig, rowData: any, context: ButtonActionContext): void {
const rowId = rowData.id || rowData.objid || rowData.pk || rowData.ID || rowData.OBJID || rowData.PK;
if (!rowId) {
toast.error("수정할 항목의 ID를 찾을 수 없습니다.");
return;
}
const editUrl = `/screens/${config.targetScreenId}?mode=edit&id=${rowId}`;
console.log("🔄 편집 화면으로 이동:", editUrl);
window.location.href = editUrl;
}
/**
*
*/