ui, 외부커넥션에서 쿼리 조회만 가능하도록
This commit is contained in:
parent
9168ab9a41
commit
8c19d57ced
|
|
@ -91,7 +91,7 @@ async function executeMainDatabaseAction(
|
|||
}
|
||||
|
||||
/**
|
||||
* 외부 데이터베이스에서 데이터 액션 실행
|
||||
* 외부 데이터베이스에서 데이터 액션 실행 (보안상 비활성화)
|
||||
*/
|
||||
async function executeExternalDatabaseAction(
|
||||
tableName: string,
|
||||
|
|
@ -99,29 +99,8 @@ async function executeExternalDatabaseAction(
|
|||
actionType: string,
|
||||
connection: any
|
||||
): Promise<any> {
|
||||
try {
|
||||
logger.info(`외부 DB 액션 실행: ${connection.name} (${connection.host}:${connection.port})`);
|
||||
logger.info(`테이블: ${tableName}, 액션: ${actionType}`, data);
|
||||
|
||||
// 🔥 실제 외부 DB 연결 및 실행 로직 구현
|
||||
const { MultiConnectionQueryService } = await import('../services/multiConnectionQueryService');
|
||||
const queryService = new MultiConnectionQueryService();
|
||||
|
||||
let result;
|
||||
switch (actionType.toLowerCase()) {
|
||||
case 'insert':
|
||||
result = await queryService.insertDataToConnection(connection.id, tableName, data);
|
||||
logger.info(`외부 DB INSERT 성공:`, result);
|
||||
break;
|
||||
case 'update':
|
||||
// TODO: UPDATE 로직 구현 (조건 필요)
|
||||
throw new Error('UPDATE 액션은 아직 지원되지 않습니다. 조건 설정이 필요합니다.');
|
||||
case 'delete':
|
||||
// TODO: DELETE 로직 구현 (조건 필요)
|
||||
throw new Error('DELETE 액션은 아직 지원되지 않습니다. 조건 설정이 필요합니다.');
|
||||
default:
|
||||
throw new Error(`지원하지 않는 액션 타입: ${actionType}`);
|
||||
}
|
||||
// 보안상 외부 DB에 대한 모든 데이터 변경 작업은 비활성화
|
||||
throw new Error(`보안상 외부 데이터베이스에 대한 ${actionType.toUpperCase()} 작업은 허용되지 않습니다. SELECT 쿼리만 사용해주세요.`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -551,13 +551,18 @@ export class BatchExternalDbService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 외부 DB 테이블에 데이터 삽입
|
||||
* 외부 DB 테이블에 데이터 삽입 (보안상 비활성화)
|
||||
*/
|
||||
static async insertDataToTable(
|
||||
connectionId: number,
|
||||
tableName: string,
|
||||
data: any[]
|
||||
): Promise<ApiResponse<{ successCount: number; failedCount: number }>> {
|
||||
// 보안상 외부 DB에 대한 INSERT 작업은 비활성화
|
||||
return {
|
||||
success: false,
|
||||
message: "보안상 외부 데이터베이스에 대한 INSERT 작업은 허용되지 않습니다. SELECT 쿼리만 사용해주세요.",
|
||||
};
|
||||
try {
|
||||
console.log(`[BatchExternalDbService] 외부 DB 데이터 삽입: connectionId=${connectionId}, tableName=${tableName}, ${data.length}개 레코드`);
|
||||
|
||||
|
|
|
|||
|
|
@ -937,23 +937,14 @@ export class DataflowControlService {
|
|||
}
|
||||
|
||||
/**
|
||||
* DELETE 액션 실행 - 조건 기반으로만 삭제
|
||||
* DELETE 액션 실행 - 보안상 외부 DB 비활성화
|
||||
*/
|
||||
private async executeDeleteAction(
|
||||
action: ControlAction,
|
||||
sourceData: Record<string, any>
|
||||
): Promise<any> {
|
||||
console.log(`🗑️ DELETE 액션 실행 시작:`, {
|
||||
actionName: action.name,
|
||||
conditions: action.conditions,
|
||||
});
|
||||
|
||||
// DELETE는 조건이 필수
|
||||
if (!action.conditions || action.conditions.length === 0) {
|
||||
throw new Error(
|
||||
"DELETE 액션에는 반드시 조건이 필요합니다. 전체 테이블 삭제는 위험합니다."
|
||||
);
|
||||
}
|
||||
// 보안상 외부 DB에 대한 DELETE 작업은 비활성화
|
||||
throw new Error("보안상 외부 데이터베이스에 대한 DELETE 작업은 허용되지 않습니다. SELECT 쿼리만 사용해주세요.");
|
||||
|
||||
const results = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -699,6 +699,30 @@ export class ExternalDbConnectionService {
|
|||
params: any[] = []
|
||||
): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
// 보안 검증: SELECT 쿼리만 허용
|
||||
const trimmedQuery = query.trim().toUpperCase();
|
||||
if (!trimmedQuery.startsWith('SELECT')) {
|
||||
console.log("보안 오류: SELECT가 아닌 쿼리 시도:", { id, query: query.substring(0, 100) });
|
||||
return {
|
||||
success: false,
|
||||
message: "외부 데이터베이스에서는 SELECT 쿼리만 실행할 수 있습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
// 위험한 키워드 검사
|
||||
const dangerousKeywords = ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER', 'TRUNCATE', 'EXEC', 'EXECUTE', 'CALL', 'MERGE'];
|
||||
const hasDangerousKeyword = dangerousKeywords.some(keyword =>
|
||||
trimmedQuery.includes(keyword)
|
||||
);
|
||||
|
||||
if (hasDangerousKeyword) {
|
||||
console.log("보안 오류: 위험한 키워드 포함 쿼리 시도:", { id, query: query.substring(0, 100) });
|
||||
return {
|
||||
success: false,
|
||||
message: "데이터를 변경하거나 삭제하는 쿼리는 허용되지 않습니다. SELECT 쿼리만 사용해주세요.",
|
||||
};
|
||||
}
|
||||
|
||||
// 연결 정보 조회
|
||||
console.log("연결 정보 조회 시작:", { id });
|
||||
const connection = await prisma.external_db_connections.findUnique({
|
||||
|
|
|
|||
|
|
@ -119,22 +119,25 @@ export class MultiConnectionQueryService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 대상 커넥션에 데이터 삽입
|
||||
* 대상 커넥션에 데이터 삽입 (보안상 외부 DB 비활성화)
|
||||
*/
|
||||
async insertDataToConnection(
|
||||
connectionId: number,
|
||||
tableName: string,
|
||||
data: Record<string, any>
|
||||
): Promise<any> {
|
||||
// 보안상 외부 DB에 대한 INSERT 작업은 비활성화
|
||||
if (connectionId !== 0) {
|
||||
throw new Error("보안상 외부 데이터베이스에 대한 INSERT 작업은 허용되지 않습니다. SELECT 쿼리만 사용해주세요.");
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(
|
||||
`데이터 삽입 시작: connectionId=${connectionId}, table=${tableName}`
|
||||
);
|
||||
|
||||
// connectionId가 0이면 메인 DB 사용
|
||||
if (connectionId === 0) {
|
||||
return await this.executeOnMainDatabase("insert", tableName, data);
|
||||
}
|
||||
// connectionId가 0이면 메인 DB 사용 (내부 DB만 허용)
|
||||
return await this.executeOnMainDatabase("insert", tableName, data);
|
||||
|
||||
// 외부 DB 연결 정보 가져오기
|
||||
const connectionResult =
|
||||
|
|
@ -288,7 +291,7 @@ export class MultiConnectionQueryService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 🆕 대상 커넥션에 데이터 업데이트
|
||||
* 🆕 대상 커넥션에 데이터 업데이트 (보안상 외부 DB 비활성화)
|
||||
*/
|
||||
async updateDataToConnection(
|
||||
connectionId: number,
|
||||
|
|
@ -296,6 +299,11 @@ export class MultiConnectionQueryService {
|
|||
data: Record<string, any>,
|
||||
conditions: Record<string, any>
|
||||
): Promise<any> {
|
||||
// 보안상 외부 DB에 대한 UPDATE 작업은 비활성화
|
||||
if (connectionId !== 0) {
|
||||
throw new Error("보안상 외부 데이터베이스에 대한 UPDATE 작업은 허용되지 않습니다. SELECT 쿼리만 사용해주세요.");
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(
|
||||
`데이터 업데이트 시작: connectionId=${connectionId}, table=${tableName}`
|
||||
|
|
@ -378,7 +386,7 @@ export class MultiConnectionQueryService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 🆕 대상 커넥션에서 데이터 삭제
|
||||
* 🆕 대상 커넥션에서 데이터 삭제 (보안상 외부 DB 비활성화)
|
||||
*/
|
||||
async deleteDataFromConnection(
|
||||
connectionId: number,
|
||||
|
|
@ -386,6 +394,11 @@ export class MultiConnectionQueryService {
|
|||
conditions: Record<string, any>,
|
||||
maxDeleteCount: number = 100
|
||||
): Promise<any> {
|
||||
// 보안상 외부 DB에 대한 DELETE 작업은 비활성화
|
||||
if (connectionId !== 0) {
|
||||
throw new Error("보안상 외부 데이터베이스에 대한 DELETE 작업은 허용되지 않습니다. SELECT 쿼리만 사용해주세요.");
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(
|
||||
`데이터 삭제 시작: connectionId=${connectionId}, table=${tableName}`
|
||||
|
|
|
|||
|
|
@ -76,6 +76,15 @@
|
|||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
|
||||
/* Z-Index 계층 구조 */
|
||||
--z-background: 1;
|
||||
--z-layout: 10;
|
||||
--z-content: 50;
|
||||
--z-floating: 100;
|
||||
--z-modal: 1000;
|
||||
--z-tooltip: 2000;
|
||||
--z-critical: 3000;
|
||||
}
|
||||
|
||||
.dark {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ export default function RootLayout({
|
|||
<Toaster position="top-right" richColors />
|
||||
<ScreenModal />
|
||||
</QueryProvider>
|
||||
{/* Portal 컨테이너 */}
|
||||
<div id="portal-root" data-radix-portal="true" />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -117,6 +117,32 @@ export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, c
|
|||
return;
|
||||
}
|
||||
|
||||
// SELECT 쿼리만 허용하는 검증
|
||||
const trimmedQuery = query.trim().toUpperCase();
|
||||
if (!trimmedQuery.startsWith('SELECT')) {
|
||||
toast({
|
||||
title: "보안 오류",
|
||||
description: "외부 데이터베이스에서는 SELECT 쿼리만 실행할 수 있습니다. INSERT, UPDATE, DELETE는 허용되지 않습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 위험한 키워드 검사
|
||||
const dangerousKeywords = ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER', 'TRUNCATE', 'EXEC', 'EXECUTE'];
|
||||
const hasDangerousKeyword = dangerousKeywords.some(keyword =>
|
||||
trimmedQuery.includes(keyword)
|
||||
);
|
||||
|
||||
if (hasDangerousKeyword) {
|
||||
toast({
|
||||
title: "보안 오류",
|
||||
description: "데이터를 변경하거나 삭제하는 쿼리는 허용되지 않습니다. SELECT 쿼리만 사용해주세요.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("쿼리 실행 시작:", { connectionId, query });
|
||||
setLoading(true);
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -116,14 +116,14 @@ export function UserToolbar({
|
|||
|
||||
{/* 고급 검색 필드들 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
{/* <div>
|
||||
<label className="text-muted-foreground mb-1 block text-xs font-medium">사번</label>
|
||||
<Input
|
||||
placeholder="사번 검색"
|
||||
value={searchFilter.search_sabun || ""}
|
||||
onChange={(e) => handleAdvancedSearchChange("search_sabun", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
<div>
|
||||
<label className="text-muted-foreground mb-1 block text-xs font-medium">회사명</label>
|
||||
|
|
|
|||
|
|
@ -243,7 +243,7 @@ export const EditModal: React.FC<EditModalProps> = ({
|
|||
minHeight: dynamicSize.height,
|
||||
maxWidth: "95vw",
|
||||
maxHeight: "95vh",
|
||||
zIndex: 9999, // 모든 컴포넌트보다 위에 표시
|
||||
zIndex: 1000, // 모든 컴포넌트보다 위에 표시
|
||||
}}
|
||||
data-radix-portal="true"
|
||||
>
|
||||
|
|
@ -251,7 +251,7 @@ export const EditModal: React.FC<EditModalProps> = ({
|
|||
<DialogTitle>수정</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
|
|
@ -282,7 +282,7 @@ export const EditModal: React.FC<EditModalProps> = ({
|
|||
left: component.position?.x || 0,
|
||||
width: component.size?.width || 200,
|
||||
height: component.size?.height || 40,
|
||||
zIndex: component.position?.z || (1000 + index), // 모달 내부에서 충분히 높은 z-index
|
||||
zIndex: component.position?.z || (10 + index), // 모달 내부에서 적절한 z-index
|
||||
}}
|
||||
>
|
||||
{/* 위젯 컴포넌트는 InteractiveScreenViewer 사용 (라벨 표시를 위해) */}
|
||||
|
|
|
|||
|
|
@ -227,7 +227,7 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
|
|||
<div
|
||||
ref={panelRef}
|
||||
className={cn(
|
||||
"fixed z-[9998] rounded-xl border border-gray-200/60 bg-white/95 backdrop-blur-sm shadow-xl shadow-gray-900/10",
|
||||
"fixed z-[100] rounded-xl border border-gray-200/60 bg-white/95 backdrop-blur-sm shadow-xl shadow-gray-900/10",
|
||||
isDragging ? "cursor-move shadow-2xl" : "transition-all duration-200 ease-in-out",
|
||||
isResizing && "cursor-se-resize",
|
||||
className,
|
||||
|
|
@ -239,7 +239,7 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
|
|||
height: `${panelSize.height}px`,
|
||||
transform: isDragging ? "scale(1.01)" : "scale(1)",
|
||||
transition: isDragging ? "none" : "transform 0.1s ease-out, box-shadow 0.1s ease-out",
|
||||
zIndex: isDragging ? 9999 : 9998, // 항상 컴포넌트보다 위에 표시
|
||||
zIndex: isDragging ? 101 : 100, // 항상 컴포넌트보다 위에 표시
|
||||
}}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
|
|
|
|||
|
|
@ -532,12 +532,9 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
return (
|
||||
<>
|
||||
<div className="absolute" style={componentStyle}>
|
||||
<div className="h-full w-full">
|
||||
{/* 라벨 숨김 - 모달에서는 라벨을 표시하지 않음 */}
|
||||
|
||||
{/* 위젯 렌더링 */}
|
||||
<div className="h-full w-full">{renderInteractiveWidget(component)}</div>
|
||||
</div>
|
||||
{/* 라벨 숨김 - 모달에서는 라벨을 표시하지 않음 */}
|
||||
{/* 위젯 렌더링 */}
|
||||
{renderInteractiveWidget(component)}
|
||||
</div>
|
||||
|
||||
{/* 팝업 화면 렌더링 */}
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
? {
|
||||
outline: "2px solid #3b82f6",
|
||||
outlineOffset: "2px",
|
||||
zIndex: 30, // 패널(z-50)과 모달(z-50)보다 낮게 설정
|
||||
zIndex: 20, // 패널과 모달보다 낮게 설정
|
||||
}
|
||||
: {};
|
||||
|
||||
|
|
@ -125,7 +125,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
return (
|
||||
<div
|
||||
id={`component-${id}`}
|
||||
className="absolute cursor-pointer"
|
||||
className="absolute cursor-pointer transition-all duration-200 ease-out"
|
||||
style={{ ...baseStyle, ...selectionStyle }}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
|
|
@ -135,7 +135,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
>
|
||||
{/* 동적 컴포넌트 렌더링 */}
|
||||
<div className={`h-full w-full ${
|
||||
component.componentConfig?.type === "table-list" ? "overflow-visible" : ""
|
||||
component.componentConfig?.type === "table-list" ? "overflow-hidden" : ""
|
||||
}`}>
|
||||
<DynamicComponentRenderer
|
||||
component={component}
|
||||
|
|
@ -155,16 +155,16 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
|
||||
{/* 선택된 컴포넌트 정보 표시 */}
|
||||
{isSelected && (
|
||||
<div className="absolute -top-6 left-0 rounded bg-blue-600 px-2 py-1 text-xs text-white">
|
||||
<div className="absolute -top-8 left-0 rounded-lg bg-gray-800/90 px-3 py-2 text-xs text-white backdrop-blur-sm shadow-lg">
|
||||
{type === "widget" && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{getWidgetIcon((component as WidgetComponent).widgetType)}
|
||||
{(component as WidgetComponent).widgetType || "widget"}
|
||||
<span className="font-medium">{(component as WidgetComponent).widgetType || "widget"}</span>
|
||||
</div>
|
||||
)}
|
||||
{type !== "widget" && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{component.componentConfig?.type || type}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{component.componentConfig?.type || type}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3437,7 +3437,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
>
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="relative h-full w-full overflow-visible bg-white" // overflow-visible로 변경
|
||||
className="relative h-full w-full overflow-auto bg-gradient-to-br from-slate-50/30 to-gray-100/20" // 미묘한 그라데이션 배경
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget && !selectionDrag.wasSelecting) {
|
||||
setSelectedComponent(null);
|
||||
|
|
@ -3502,7 +3502,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
opacity: 0.8,
|
||||
transform: "scale(1.02)",
|
||||
transition: "none",
|
||||
zIndex: 9999,
|
||||
zIndex: 50,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
|
|
@ -3525,7 +3525,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
...component.style,
|
||||
opacity: 0.8,
|
||||
transition: "none",
|
||||
zIndex: 8888, // 주 컴포넌트보다 약간 낮게
|
||||
zIndex: 40, // 주 컴포넌트보다 약간 낮게
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -3610,7 +3610,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
opacity: 0.8,
|
||||
transform: "scale(1.02)",
|
||||
transition: "none",
|
||||
zIndex: 9999,
|
||||
zIndex: 50,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -65,7 +65,14 @@ export const AdvancedSearchFilters: React.FC<AdvancedSearchFiltersProps> = ({
|
|||
return tableColumns
|
||||
.filter((col) => {
|
||||
const webType = col.webType || col.web_type;
|
||||
return filterableWebTypes.includes(webType) && col.isVisible !== false;
|
||||
const columnName = col.columnName || col.column_name;
|
||||
// 체크박스 컬럼과 __checkbox__ 컬럼, 사번 컬럼 제외
|
||||
return filterableWebTypes.includes(webType) &&
|
||||
col.isVisible !== false &&
|
||||
columnName !== "__checkbox__" &&
|
||||
!columnName.toLowerCase().includes('checkbox') &&
|
||||
!columnName.toLowerCase().includes('sabun') &&
|
||||
!columnName.toLowerCase().includes('사번');
|
||||
})
|
||||
.slice(0, 6) // 최대 6개까지만 자동 생성
|
||||
.map((col) => ({
|
||||
|
|
@ -333,8 +340,7 @@ export const AdvancedSearchFilters: React.FC<AdvancedSearchFiltersProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div key={filter.columnName} className={`space-y-0.5 ${getFilterWidth()}`}>
|
||||
<label className="text-muted-foreground text-xs font-medium">{filter.label}</label>
|
||||
<div key={filter.columnName} className={`${getFilterWidth()}`}>
|
||||
{renderFilter(filter)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -703,20 +703,53 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
<div className="w-full space-y-4">
|
||||
{/* 드래그 앤 드롭 영역 */}
|
||||
<div
|
||||
className={`rounded-xl border-2 border-dashed p-8 text-center transition-all duration-300 ${
|
||||
isDragOver ? "border-blue-400 bg-gradient-to-br from-blue-50 to-indigo-50 shadow-sm" : "border-gray-300/60 hover:border-blue-400/60 hover:bg-gray-50/50 hover:shadow-sm"
|
||||
className={`group relative rounded-2xl border-2 border-dashed p-10 text-center transition-all duration-300 ${
|
||||
isDragOver
|
||||
? "border-blue-500 bg-gradient-to-br from-blue-100/90 to-indigo-100/80 shadow-xl shadow-blue-500/20 scale-105"
|
||||
: "border-gray-300/60 bg-gradient-to-br from-gray-50/80 to-blue-50/40 hover:border-blue-400/80 hover:bg-gradient-to-br hover:from-blue-50/90 hover:to-indigo-50/60 hover:shadow-lg hover:shadow-blue-500/10"
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Upload className="mx-auto mb-4 h-12 w-12 text-gray-400" />
|
||||
<p className="mb-2 text-lg font-medium text-gray-900">
|
||||
<div className="relative">
|
||||
<Upload className={`mx-auto mb-4 h-16 w-16 transition-all duration-300 ${
|
||||
isDragOver
|
||||
? "text-blue-500 scale-110"
|
||||
: "text-gray-400 group-hover:text-blue-500 group-hover:scale-105"
|
||||
}`} />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className={`h-20 w-20 rounded-full transition-all duration-300 ${
|
||||
isDragOver
|
||||
? "bg-blue-200/80 scale-110"
|
||||
: "bg-blue-100/50 opacity-0 group-hover:opacity-100 group-hover:scale-110"
|
||||
}`}></div>
|
||||
</div>
|
||||
</div>
|
||||
<p className={`mb-2 text-xl font-semibold transition-colors duration-300 ${
|
||||
isDragOver
|
||||
? "text-blue-600"
|
||||
: "text-gray-700 group-hover:text-blue-600"
|
||||
}`}>
|
||||
{fileConfig.dragDropText || "파일을 드래그하여 업로드하세요"}
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-gray-500">또는 클릭하여 파일을 선택하세요</p>
|
||||
<p className={`mb-4 text-sm transition-colors duration-300 ${
|
||||
isDragOver
|
||||
? "text-blue-500"
|
||||
: "text-gray-500 group-hover:text-blue-500"
|
||||
}`}>
|
||||
또는 클릭하여 파일을 선택하세요
|
||||
</p>
|
||||
|
||||
<Button variant="outline" onClick={handleFileInputClick} className="mb-4 rounded-lg border-gray-300/60 hover:border-blue-400/60 hover:bg-blue-50/50 transition-all duration-200">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleFileInputClick}
|
||||
className={`mb-4 rounded-xl border-2 transition-all duration-200 ${
|
||||
isDragOver
|
||||
? "border-blue-400 bg-blue-50 text-blue-600 shadow-md"
|
||||
: "border-gray-300/60 hover:border-blue-400/60 hover:bg-blue-50/50 hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{fileConfig.uploadButtonText || "파일 선택"}
|
||||
</Button>
|
||||
|
|
@ -740,27 +773,53 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
|
||||
{/* 업로드된 파일 목록 */}
|
||||
{uploadedFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-gray-900">
|
||||
첨부된 파일 ({uploadedFiles.length}/{fileConfig.maxFiles})
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-xl bg-gradient-to-r from-blue-50/80 to-indigo-50/60 px-4 py-3 border border-blue-200/40">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100">
|
||||
<File className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-gray-800">
|
||||
업로드된 파일 ({uploadedFiles.length}/{fileConfig.maxFiles})
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
총 {formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="rounded-lg border-blue-200/60 bg-white/80 hover:bg-blue-50/80">
|
||||
자세히보기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
{uploadedFiles.map((fileInfo) => (
|
||||
<div key={fileInfo.objid} className="flex items-center justify-between rounded-lg border bg-gray-50 p-3">
|
||||
<div className="flex flex-1 items-center space-x-3">
|
||||
{getFileIcon(fileInfo.fileExt)}
|
||||
<div key={fileInfo.objid} className="group relative flex items-center justify-between rounded-xl border border-gray-200/60 bg-white/90 p-4 shadow-sm transition-all duration-200 hover:shadow-md hover:border-blue-300/60 hover:bg-blue-50/30">
|
||||
<div className="flex flex-1 items-center space-x-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-gray-50 to-gray-100/80 shadow-sm">
|
||||
{getFileIcon(fileInfo.fileExt)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-gray-900">{fileInfo.realFileName}</p>
|
||||
<div className="flex items-center space-x-2 text-xs text-gray-500">
|
||||
<span>{formatFileSize(fileInfo.fileSize)}</span>
|
||||
<span>•</span>
|
||||
<span>{fileInfo.fileExt.toUpperCase()}</span>
|
||||
<p className="truncate text-base font-semibold text-gray-800 group-hover:text-blue-600 transition-colors duration-200">
|
||||
{fileInfo.realFileName}
|
||||
</p>
|
||||
<div className="flex items-center space-x-3 text-sm text-gray-500 mt-1">
|
||||
<span className="flex items-center space-x-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-blue-400"></div>
|
||||
<span className="font-medium">{formatFileSize(fileInfo.fileSize)}</span>
|
||||
</span>
|
||||
<span className="flex items-center space-x-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-gray-400"></div>
|
||||
<span className="px-2 py-1 rounded-md bg-gray-100 text-xs font-medium">
|
||||
{fileInfo.fileExt.toUpperCase()}
|
||||
</span>
|
||||
</span>
|
||||
{fileInfo.writer && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{fileInfo.writer}</span>
|
||||
</>
|
||||
<span className="flex items-center space-x-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-400"></div>
|
||||
<span className="text-xs">{fileInfo.writer}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -784,35 +843,60 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* 상태 표시 */}
|
||||
{fileInfo.isUploading && <Loader2 className="h-4 w-4 animate-spin text-blue-500" />}
|
||||
{fileInfo.status === "ACTIVE" && <CheckCircle className="h-4 w-4 text-green-500" />}
|
||||
{fileInfo.hasError && <AlertCircle className="h-4 w-4 text-red-500" />}
|
||||
{fileInfo.isUploading && (
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-blue-50 px-3 py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-500" />
|
||||
<span className="text-xs font-medium text-blue-600">업로드 중...</span>
|
||||
</div>
|
||||
)}
|
||||
{fileInfo.status === "ACTIVE" && (
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-green-50 px-3 py-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
<span className="text-xs font-medium text-green-600">완료</span>
|
||||
</div>
|
||||
)}
|
||||
{fileInfo.hasError && (
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-red-50 px-3 py-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-500" />
|
||||
<span className="text-xs font-medium text-red-600">오류</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
{!fileInfo.isUploading && !fileInfo.hasError && (
|
||||
<>
|
||||
<div className="flex items-center space-x-1">
|
||||
{fileConfig.showPreview && (
|
||||
<Button variant="ghost" size="sm" onClick={() => previewFile(fileInfo)} className="h-8 w-8 p-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => previewFile(fileInfo)}
|
||||
className="h-9 w-9 rounded-lg hover:bg-blue-50 hover:text-blue-600 transition-all duration-200"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={() => previewFile(fileInfo)} className="h-8 w-8 p-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => previewFile(fileInfo)}
|
||||
className="h-9 w-9 rounded-lg hover:bg-green-50 hover:text-green-600 transition-all duration-200"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => deleteFile(fileInfo)}
|
||||
className="h-8 w-8 p-0 text-red-500 hover:text-red-700"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => deleteFile(fileInfo)}
|
||||
className="h-9 w-9 rounded-lg text-red-500 hover:bg-red-50 hover:text-red-700 transition-all duration-200"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -821,8 +905,18 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
|||
)}
|
||||
|
||||
{/* 문서 타입 정보 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline">{fileConfig.docTypeName}</Badge>
|
||||
<div className="flex items-center justify-center space-x-2 rounded-xl bg-gradient-to-r from-amber-50/80 to-orange-50/60 border border-amber-200/40 px-4 py-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-amber-100">
|
||||
<File className="h-3 w-3 text-amber-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-amber-700">
|
||||
파일명 클릭으로 미리보기 또는 "전체 자세히보기"로 파일 관리
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="bg-white/80 border-amber-200/60 text-amber-700">
|
||||
{fileConfig.docTypeName}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -134,17 +134,22 @@ export const FileWidget: React.FC<WebTypeComponentProps> = ({ component, value,
|
|||
<div className="h-full w-full space-y-2">
|
||||
{/* 파일 업로드 영역 */}
|
||||
<div
|
||||
className="border-gray-300/60 hover:border-blue-400/60 hover:bg-gray-50/50 cursor-pointer rounded-xl border-2 border-dashed p-6 text-center transition-all duration-300 hover:shadow-sm"
|
||||
className="group relative cursor-pointer rounded-2xl border-2 border-dashed border-gray-300/60 bg-gradient-to-br from-gray-50/80 to-blue-50/40 p-8 text-center transition-all duration-300 hover:border-blue-400/80 hover:bg-gradient-to-br hover:from-blue-50/90 hover:to-indigo-50/60 hover:shadow-lg hover:shadow-blue-500/10"
|
||||
onClick={handleFileSelect}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
style={style}
|
||||
>
|
||||
<Upload className="text-muted-foreground mx-auto mb-2 h-8 w-8" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<div className="relative">
|
||||
<Upload className="mx-auto mb-3 h-12 w-12 text-blue-400 transition-all duration-300 group-hover:scale-110 group-hover:text-blue-500" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-16 w-16 rounded-full bg-blue-100/50 opacity-0 transition-all duration-300 group-hover:opacity-100 group-hover:scale-110"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mb-2 text-lg font-semibold text-gray-700 transition-colors duration-300 group-hover:text-blue-600">
|
||||
{readonly ? "파일 업로드 불가" : "파일을 선택하거나 드래그하여 업로드"}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
<p className="text-sm text-gray-500 transition-colors duration-300 group-hover:text-blue-500">
|
||||
{config?.accept && `허용 형식: ${config.accept}`}
|
||||
{config?.maxSize && ` (최대 ${config.maxSize}MB)`}
|
||||
</p>
|
||||
|
|
@ -162,42 +167,83 @@ export const FileWidget: React.FC<WebTypeComponentProps> = ({ component, value,
|
|||
|
||||
{/* 업로드된 파일 목록 */}
|
||||
{files.length > 0 && (
|
||||
<div className="max-h-32 space-y-2 overflow-y-auto">
|
||||
{files.map((file, index) => (
|
||||
<div key={index} className="bg-muted flex items-center justify-between rounded-md p-2">
|
||||
<div className="flex min-w-0 flex-1 items-center space-x-2">
|
||||
<File className="text-muted-foreground h-4 w-4 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{file.name}</p>
|
||||
{file.size > 0 && <p className="text-muted-foreground text-xs">{formatFileSize(file.size)}</p>}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between rounded-xl bg-gradient-to-r from-blue-50/80 to-indigo-50/60 px-4 py-3 border border-blue-200/40">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100">
|
||||
<File className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-gray-800">
|
||||
업로드된 파일 ({files.length}/{config?.maxFiles || "∞"})
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
총 {formatFileSize(files.reduce((sum, file) => sum + file.size, 0))}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!readonly && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFile(index);
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="max-h-40 space-y-2 overflow-y-auto">
|
||||
{files.map((file, index) => (
|
||||
<div key={index} className="group flex items-center justify-between rounded-xl border border-gray-200/60 bg-white/90 p-3 shadow-sm transition-all duration-200 hover:shadow-md hover:border-blue-300/60 hover:bg-blue-50/30">
|
||||
<div className="flex flex-1 items-center space-x-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-gray-50 to-gray-100/80 shadow-sm">
|
||||
<File className="h-5 w-5 text-gray-500" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-gray-800 group-hover:text-blue-600 transition-colors duration-200">
|
||||
{file.name}
|
||||
</p>
|
||||
{file.size > 0 && (
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-blue-400"></div>
|
||||
<span className="text-xs font-medium text-gray-500">{formatFileSize(file.size)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!readonly && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFile(index);
|
||||
}}
|
||||
className="h-8 w-8 rounded-lg text-red-500 hover:bg-red-50 hover:text-red-700 transition-all duration-200"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 파일 개수 표시 */}
|
||||
{files.length > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{files.length}개 파일
|
||||
</Badge>
|
||||
{config?.maxFiles && <span className="text-muted-foreground text-xs">최대 {config.maxFiles}개</span>}
|
||||
<div className="flex items-center justify-center space-x-2 rounded-xl bg-gradient-to-r from-amber-50/80 to-orange-50/60 border border-amber-200/40 px-4 py-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-amber-100">
|
||||
<File className="h-3 w-3 text-amber-600" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-amber-700">
|
||||
파일명 클릭으로 미리보기 또는 "전체 자세히보기"로 파일 관리
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline" className="bg-white/80 border-amber-200/60 text-amber-700">
|
||||
{files.length}개 파일
|
||||
</Badge>
|
||||
{config?.maxFiles && (
|
||||
<Badge variant="outline" className="bg-white/80 border-gray-200/60 text-gray-600">
|
||||
최대 {config.maxFiles}개
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[9999] bg-black/80",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[999] bg-black/80",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
|
|||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-[9999] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-[1000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
|
|||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-background/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 backdrop-blur-sm",
|
||||
"bg-background/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[999] backdrop-blur-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
|||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-[9999] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-[1000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef<
|
|||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[99999] w-72 rounded-md border p-4 shadow-md outline-none",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[2000] w-72 rounded-md border p-4 shadow-md outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ function SelectContent({
|
|||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-[10000] max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-[2000] max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -253,7 +253,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
padding: "32px", // 패딩 대폭 증가
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "linear-gradient(to br, #f8fafc, #f1f5f9)", // 부드러운 그라데이션 배경
|
||||
background: "#f8fafc", // 연한 하늘색 배경 (채도 낮춤)
|
||||
overflow: "auto",
|
||||
borderRadius: "12px", // 컨테이너 자체도 라운드 처리
|
||||
};
|
||||
|
|
@ -336,7 +336,22 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
<>
|
||||
<style jsx>{`
|
||||
.card-hover {
|
||||
transition: all 0.2s ease-in-out;
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-hover::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
|
||||
transition: left 0.6s ease;
|
||||
}
|
||||
.card-hover:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
.card-hover:hover {
|
||||
transform: translateY(-2px);
|
||||
|
|
@ -345,6 +360,20 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
.card-container {
|
||||
position: relative;
|
||||
}
|
||||
.card-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: transparent;
|
||||
border-radius: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
className={className}
|
||||
|
|
@ -357,7 +386,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
onDragEnd={onDragEnd}
|
||||
{...safeDomProps}
|
||||
>
|
||||
<div style={containerStyle}>
|
||||
<div style={containerStyle} className="card-container">
|
||||
{displayData.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -392,7 +421,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
<div
|
||||
key={data.id || index}
|
||||
style={cardStyle}
|
||||
className="group cursor-pointer hover:transform hover:-translate-y-1 hover:shadow-xl transition-all duration-300 ease-out"
|
||||
className="card-hover group cursor-pointer"
|
||||
onClick={() => handleCardClick(data)}
|
||||
>
|
||||
{/* 카드 이미지 - 통일된 디자인 */}
|
||||
|
|
|
|||
|
|
@ -46,10 +46,11 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
|||
|
||||
return (
|
||||
<div
|
||||
className="relative h-full overflow-auto rounded-xl border border-gray-200/60 bg-gradient-to-br from-white to-gray-50/30 shadow-sm"
|
||||
className="relative h-full overflow-x-auto overflow-y-auto rounded-2xl border border-gray-200/40 bg-white shadow-sm backdrop-blur-sm"
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%", // 최대 높이 제한으로 스크롤 활성화
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
|
|
@ -57,12 +58,12 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
|||
className="w-full"
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
tableLayout: "fixed",
|
||||
minWidth: "100%",
|
||||
tableLayout: "auto", // 테이블 크기 자동 조정
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<TableHeader className={tableConfig.stickyHeader ? "sticky top-0 z-20 bg-gradient-to-r from-slate-50 to-blue-50/30 border-b border-gray-200/60" : "bg-gradient-to-r from-slate-50 to-blue-50/30 border-b border-gray-200/60"}>
|
||||
<TableHeader className={tableConfig.stickyHeader ? "sticky top-0 z-20 bg-gradient-to-r from-slate-50/90 to-gray-50/70 backdrop-blur-sm border-b border-gray-200/40" : "bg-gradient-to-r from-slate-50/90 to-gray-50/70 backdrop-blur-sm border-b border-gray-200/40"}>
|
||||
<TableRow className="border-b border-gray-200/40">
|
||||
{visibleColumns.map((column, colIndex) => {
|
||||
// 왼쪽 고정 컬럼들의 누적 너비 계산
|
||||
|
|
@ -84,23 +85,24 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
|||
key={column.columnName}
|
||||
className={cn(
|
||||
column.columnName === "__checkbox__"
|
||||
? "h-12 border-0 px-4 py-3 text-center align-middle"
|
||||
: "h-12 cursor-pointer border-0 px-4 py-3 text-left align-middle font-semibold whitespace-nowrap text-slate-700 select-none transition-all duration-200",
|
||||
? "h-12 border-0 px-6 py-4 text-center align-middle"
|
||||
: "h-12 cursor-pointer border-0 px-6 py-4 text-left align-middle font-semibold whitespace-nowrap text-gray-700 select-none transition-all duration-200 hover:text-gray-900",
|
||||
`text-${column.align}`,
|
||||
column.sortable && "hover:bg-blue-50/50 hover:text-blue-700",
|
||||
column.sortable && "hover:bg-orange-200/70",
|
||||
// 고정 컬럼 스타일
|
||||
column.fixed === "left" && "sticky z-10 border-r border-gray-200/60 bg-gradient-to-r from-slate-50 to-blue-50/30 shadow-sm",
|
||||
column.fixed === "right" && "sticky z-10 border-l border-gray-200/60 bg-gradient-to-r from-slate-50 to-blue-50/30 shadow-sm",
|
||||
column.fixed === "left" && "sticky z-10 border-r border-gray-200/40 bg-gradient-to-r from-slate-50/90 to-gray-50/70 shadow-sm",
|
||||
column.fixed === "right" && "sticky z-10 border-l border-gray-200/40 bg-gradient-to-r from-slate-50/90 to-gray-50/70 shadow-sm",
|
||||
// 숨김 컬럼 스타일 (디자인 모드에서만)
|
||||
isDesignMode && column.hidden && "bg-gray-100/50 opacity-40",
|
||||
)}
|
||||
style={{
|
||||
width: getColumnWidth(column),
|
||||
minWidth: getColumnWidth(column),
|
||||
maxWidth: getColumnWidth(column),
|
||||
minWidth: "100px", // 최소 너비 보장
|
||||
maxWidth: "300px", // 최대 너비 제한
|
||||
boxSizing: "border-box",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap", // 텍스트 줄바꿈 방지
|
||||
// sticky 위치 설정
|
||||
...(column.fixed === "left" && { left: leftFixedWidth }),
|
||||
...(column.fixed === "right" && { right: rightFixedWidth }),
|
||||
|
|
@ -117,16 +119,12 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
|||
<span className="flex-1 truncate">
|
||||
{columnLabels[column.columnName] || column.displayName || column.columnName}
|
||||
</span>
|
||||
{column.sortable && (
|
||||
{column.sortable && sortColumn === column.columnName && (
|
||||
<span className="ml-2 flex h-5 w-5 items-center justify-center rounded-md bg-white/50 shadow-sm">
|
||||
{sortColumn === column.columnName ? (
|
||||
sortDirection === "asc" ? (
|
||||
<ArrowUp className="h-3.5 w-3.5 text-blue-600" />
|
||||
) : (
|
||||
<ArrowDown className="h-3.5 w-3.5 text-blue-600" />
|
||||
)
|
||||
{sortDirection === "asc" ? (
|
||||
<ArrowUp className="h-3.5 w-3.5 text-blue-600" />
|
||||
) : (
|
||||
<ArrowUpDown className="h-3.5 w-3.5 text-gray-400" />
|
||||
<ArrowDown className="h-3.5 w-3.5 text-blue-600" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -159,9 +157,9 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
|||
<TableRow
|
||||
key={`row-${index}`}
|
||||
className={cn(
|
||||
"h-12 cursor-pointer border-b border-gray-100/60 leading-none transition-all duration-200",
|
||||
tableConfig.tableStyle?.hoverEffect && "hover:bg-gradient-to-r hover:from-blue-50/30 hover:to-indigo-50/20 hover:shadow-sm",
|
||||
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gradient-to-r from-slate-50/30 to-gray-50/20",
|
||||
"h-12 cursor-pointer border-b border-gray-100/40 leading-none transition-all duration-200",
|
||||
tableConfig.tableStyle?.hoverEffect && "hover:bg-gradient-to-r hover:from-orange-50/80 hover:to-orange-100/60 hover:shadow-sm",
|
||||
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-50/30",
|
||||
)}
|
||||
style={{ minHeight: "48px", height: "48px", lineHeight: "1" }}
|
||||
onClick={() => handleRowClick(row)}
|
||||
|
|
@ -185,17 +183,19 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
|||
<TableCell
|
||||
key={`cell-${column.columnName}`}
|
||||
className={cn(
|
||||
"h-12 px-4 py-3 align-middle text-sm whitespace-nowrap text-slate-600 transition-all duration-200",
|
||||
"h-12 px-6 py-4 align-middle text-sm whitespace-nowrap text-gray-600 transition-all duration-200",
|
||||
`text-${column.align}`,
|
||||
// 고정 컬럼 스타일
|
||||
column.fixed === "left" && "sticky z-10 border-r border-gray-200/60 bg-white/90 backdrop-blur-sm",
|
||||
column.fixed === "right" && "sticky z-10 border-l border-gray-200/60 bg-white/90 backdrop-blur-sm",
|
||||
column.fixed === "left" && "sticky z-10 border-r border-gray-200/40 bg-white/90 backdrop-blur-sm",
|
||||
column.fixed === "right" && "sticky z-10 border-l border-gray-200/40 bg-white/90 backdrop-blur-sm",
|
||||
)}
|
||||
style={{
|
||||
minHeight: "48px",
|
||||
height: "48px",
|
||||
verticalAlign: "middle",
|
||||
width: getColumnWidth(column),
|
||||
minWidth: "100px", // 최소 너비 보장
|
||||
maxWidth: "300px", // 최대 너비 제한
|
||||
boxSizing: "border-box",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
|
|
|
|||
|
|
@ -298,7 +298,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
width: "100%", // 컨테이너 전체 너비 사용
|
||||
maxWidth: "100%", // 최대 너비 제한
|
||||
height: "auto", // 항상 자동 높이로 테이블 크기에 맞춤
|
||||
minHeight: isDesignMode ? `${Math.min(optimalHeight, 400)}px` : `${optimalHeight}px`, // 최소 높이 보장
|
||||
minHeight: isDesignMode ? "200px" : "300px", // 최소 높이만 보장
|
||||
maxHeight: isDesignMode ? "600px" : "800px", // 최대 높이 제한으로 스크롤 활성화
|
||||
...component.style,
|
||||
...style,
|
||||
display: "flex",
|
||||
|
|
@ -1315,8 +1316,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
<div
|
||||
style={{ ...componentStyle, zIndex: 10 }} // 🎯 componentStyle + z-index 추가
|
||||
className={cn(
|
||||
"rounded-lg border border-gray-200 bg-white shadow-md shadow-blue-100/50",
|
||||
"relative overflow-hidden", // 🎯 항상 overflow-hidden 적용 + relative 추가
|
||||
"relative overflow-hidden",
|
||||
"bg-white border border-gray-200/60",
|
||||
"rounded-2xl shadow-sm",
|
||||
"backdrop-blur-sm",
|
||||
"transition-all duration-300 ease-out",
|
||||
isSelected && "ring-2 ring-blue-500/20 shadow-lg shadow-blue-500/10",
|
||||
className,
|
||||
)}
|
||||
{...domProps}
|
||||
|
|
@ -1324,7 +1329,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
{/* 헤더 */}
|
||||
{tableConfig.showHeader && (
|
||||
<div
|
||||
className="flex items-center justify-between border-b border-gray-200 bg-gray-100/80 px-6 py-4"
|
||||
className="flex items-center justify-between border-b border-gray-200/40 bg-gradient-to-r from-slate-50/80 to-gray-50/60 px-6 py-5"
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
|
|
@ -1333,14 +1338,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
{(tableConfig.title || tableLabel) && (
|
||||
<h3 className="text-lg font-semibold text-gray-900">{tableConfig.title || tableLabel}</h3>
|
||||
<h3 className="text-xl font-semibold text-gray-800 tracking-tight">{tableConfig.title || tableLabel}</h3>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* 선택된 항목 정보 표시 */}
|
||||
{selectedRows.size > 0 && (
|
||||
<div className="flex items-center space-x-2 rounded-md bg-blue-50 px-3 py-1">
|
||||
<div className="flex items-center space-x-2 rounded-full bg-blue-50/80 px-4 py-2 backdrop-blur-sm">
|
||||
<span className="text-sm font-medium text-blue-700">{selectedRows.size}개 선택됨</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1351,15 +1356,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
style={buttonStyle}
|
||||
className="group relative rounded-lg shadow-sm [&:hover]:opacity-90"
|
||||
className="group relative rounded-xl border-gray-200/60 bg-white/80 backdrop-blur-sm shadow-sm hover:shadow-md transition-all duration-200 hover:bg-gray-50/80"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative">
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} style={{ color: buttonTextColor }} />
|
||||
{loading && <div className="absolute -inset-1 animate-pulse rounded-full bg-blue-200/30"></div>}
|
||||
<RefreshCw className={cn("h-4 w-4 text-gray-600", loading && "animate-spin")} />
|
||||
{loading && <div className="absolute -inset-1 animate-pulse rounded-full bg-blue-100/40"></div>}
|
||||
</div>
|
||||
<span className="text-sm font-medium" style={{ color: buttonTextColor }}>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{loading ? "새로고침 중..." : "새로고침"}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -1399,7 +1403,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
{/* 테이블 컨텐츠 */}
|
||||
<div
|
||||
className={`w-full overflow-hidden ${localPageSize >= 50 ? "flex-1" : ""}`}
|
||||
className={`w-full overflow-auto flex-1`}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
|
|
@ -1478,20 +1482,20 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
/>
|
||||
</div>
|
||||
) : (
|
||||
// 기존 테이블 (가로 스크롤이 필요 없는 경우)
|
||||
<div className="w-full overflow-hidden">
|
||||
// 기존 테이블 (가로 스크롤이 필요한 경우)
|
||||
<div className="w-full overflow-x-auto overflow-y-auto">
|
||||
<Table
|
||||
className="w-full"
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: "100%",
|
||||
tableLayout: "fixed", // 테이블 크기 고정
|
||||
minWidth: "100%",
|
||||
tableLayout: "auto", // 테이블 크기 자동 조정
|
||||
}}
|
||||
>
|
||||
<TableHeader
|
||||
className={cn(
|
||||
tableConfig.stickyHeader ? "sticky top-0 z-20" : "",
|
||||
"border-b border-gray-200 bg-gray-100/80",
|
||||
"border-b border-gray-200/40 bg-gradient-to-r from-slate-50/90 to-gray-50/70 backdrop-blur-sm",
|
||||
)}
|
||||
>
|
||||
<TableRow
|
||||
|
|
@ -1508,7 +1512,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
<TableHead
|
||||
key={column.columnName}
|
||||
style={{
|
||||
width: column.width ? `${column.width}px` : undefined,
|
||||
width: column.width ? `${column.width}px` : "150px", // 기본 너비 설정
|
||||
minWidth: "100px", // 최소 너비 보장
|
||||
maxWidth: "300px", // 최대 너비 제한
|
||||
minHeight: "48px !important",
|
||||
height: "48px !important",
|
||||
verticalAlign: "middle",
|
||||
|
|
@ -1516,14 +1522,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
boxSizing: "border-box",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap", // 텍스트 줄바꿈 방지
|
||||
}}
|
||||
className={cn(
|
||||
"h-12 px-4 py-3 align-middle text-sm font-semibold text-gray-800",
|
||||
"h-12 px-6 py-4 align-middle text-sm font-semibold text-gray-700",
|
||||
"transition-colors duration-200 ease-out",
|
||||
column.columnName === "__checkbox__"
|
||||
? "text-center"
|
||||
: "cursor-pointer whitespace-nowrap select-none",
|
||||
: "cursor-pointer whitespace-nowrap select-none hover:text-gray-900",
|
||||
`text-${column.align}`,
|
||||
column.sortable && "transition-colors duration-150 hover:bg-orange-100",
|
||||
column.sortable && "hover:bg-orange-200/70",
|
||||
)}
|
||||
onClick={() => column.sortable && handleSort(column.columnName)}
|
||||
>
|
||||
|
|
@ -1532,16 +1540,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>{columnLabels[column.columnName] || column.displayName}</span>
|
||||
{column.sortable && (
|
||||
{column.sortable && sortColumn === column.columnName && (
|
||||
<div className="flex flex-col">
|
||||
{sortColumn === column.columnName ? (
|
||||
sortDirection === "asc" ? (
|
||||
<ArrowUp className="h-3 w-3 text-blue-600" />
|
||||
) : (
|
||||
<ArrowDown className="h-3 w-3 text-blue-600" />
|
||||
)
|
||||
{sortDirection === "asc" ? (
|
||||
<ArrowUp className="h-3 w-3 text-blue-600" />
|
||||
) : (
|
||||
<ArrowUpDown className="h-3 w-3 text-gray-400" />
|
||||
<ArrowDown className="h-3 w-3 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1572,14 +1576,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
onDragStart={(e) => handleRowDragStart(e, row, index)}
|
||||
onDragEnd={handleRowDragEnd}
|
||||
className={cn(
|
||||
"group relative h-12 cursor-pointer border-b border-gray-100 transition-all duration-200",
|
||||
"group relative h-12 cursor-pointer border-b border-gray-100/60 transition-all duration-200",
|
||||
// 기본 스타일
|
||||
tableConfig.tableStyle?.hoverEffect &&
|
||||
"hover:bg-gradient-to-r hover:from-orange-200 hover:to-orange-300/90 hover:shadow-sm",
|
||||
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-100/80",
|
||||
"hover:bg-gradient-to-r hover:from-orange-50/80 hover:to-orange-100/60 hover:shadow-sm",
|
||||
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-50/40",
|
||||
// 드래그 상태 스타일 (미묘하게)
|
||||
draggedRowIndex === index &&
|
||||
"border-blue-200 bg-gradient-to-r from-blue-50 to-blue-100/40 shadow-sm",
|
||||
"border-blue-200/60 bg-gradient-to-r from-blue-50/60 to-indigo-50/40 shadow-sm",
|
||||
isDragging && draggedRowIndex !== index && "opacity-70",
|
||||
// 드래그 가능 표시
|
||||
!isDesignMode && "hover:cursor-grab active:cursor-grabbing",
|
||||
|
|
@ -1597,14 +1601,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
<TableCell
|
||||
key={column.columnName}
|
||||
className={cn(
|
||||
"h-12 px-4 py-3 align-middle text-sm transition-all duration-200",
|
||||
"h-12 px-6 py-4 align-middle text-sm transition-all duration-200 text-gray-600",
|
||||
`text-${column.align}`,
|
||||
)}
|
||||
style={{
|
||||
minHeight: "48px",
|
||||
height: "48px",
|
||||
verticalAlign: "middle",
|
||||
width: column.width ? `${column.width}px` : undefined,
|
||||
width: column.width ? `${column.width}px` : "150px", // 기본 너비 설정
|
||||
minWidth: "100px", // 최소 너비 보장
|
||||
maxWidth: "300px", // 최대 너비 제한
|
||||
boxSizing: "border-box",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
|
|
@ -1722,7 +1728,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
// 데이터는 useEffect에서 자동으로 다시 로드됨
|
||||
}}
|
||||
className="rounded-lg border border-slate-200 bg-white/80 px-3 py-2 text-sm font-medium text-slate-700 shadow-sm transition-colors hover:border-slate-300 hover:bg-white"
|
||||
className="rounded-xl border border-gray-200/60 bg-white/80 backdrop-blur-sm px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300/60 hover:bg-white hover:shadow-md"
|
||||
>
|
||||
{(tableConfig.pagination?.pageSizeOptions || [10, 20, 50, 100]).map((size) => (
|
||||
<option key={size} value={size}>
|
||||
|
|
@ -1733,13 +1739,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
)}
|
||||
|
||||
{/* 페이지네이션 버튼 */}
|
||||
<div className="flex items-center space-x-2 rounded-lg border border-gray-200 bg-white p-1 shadow-sm">
|
||||
<div className="flex items-center space-x-2 rounded-xl border border-gray-200/60 bg-white/80 backdrop-blur-sm p-1 shadow-sm">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="h-8 w-8 p-0 hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-50"
|
||||
className="h-8 w-8 p-0 rounded-lg border-gray-200/60 hover:border-gray-300/60 hover:bg-gray-50/80 hover:text-gray-700 disabled:opacity-50 transition-all duration-200"
|
||||
>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -1748,13 +1754,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="h-8 w-8 p-0 hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-50"
|
||||
className="h-8 w-8 p-0 rounded-lg border-gray-200/60 hover:border-gray-300/60 hover:bg-gray-50/80 hover:text-gray-700 disabled:opacity-50 transition-all duration-200"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center rounded-md border border-blue-100 bg-gradient-to-r from-blue-50 to-indigo-50 px-4 py-1">
|
||||
<span className="text-sm font-semibold text-blue-800">{currentPage}</span>
|
||||
<div className="flex items-center rounded-lg border border-gray-200/40 bg-gradient-to-r from-gray-50/80 to-slate-50/60 px-4 py-2 backdrop-blur-sm">
|
||||
<span className="text-sm font-semibold text-gray-800">{currentPage}</span>
|
||||
<span className="mx-2 font-light text-gray-400">/</span>
|
||||
<span className="text-sm font-medium text-gray-600">{totalPages}</span>
|
||||
</div>
|
||||
|
|
@ -1764,7 +1770,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
size="sm"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="h-8 w-8 p-0 hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-50"
|
||||
className="h-8 w-8 p-0 rounded-lg border-gray-200/60 hover:border-gray-300/60 hover:bg-gray-50/80 hover:text-gray-700 disabled:opacity-50 transition-all duration-200"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -1773,7 +1779,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
size="sm"
|
||||
onClick={() => handlePageChange(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="h-8 w-8 p-0 hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-50"
|
||||
className="h-8 w-8 p-0 rounded-lg border-gray-200/60 hover:border-gray-300/60 hover:bg-gray-50/80 hover:text-gray-700 disabled:opacity-50 transition-all duration-200"
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
|
|||
Loading…
Reference in New Issue