Compare commits
18 Commits
11b71b788a
...
bf7fc6cfb8
| Author | SHA1 | Date |
|---|---|---|
|
|
bf7fc6cfb8 | |
|
|
9de0b4158c | |
|
|
f59fcb56b0 | |
|
|
58248db457 | |
|
|
5876f80265 | |
|
|
3600621554 | |
|
|
1fe401c7d6 | |
|
|
ee7c8e989e | |
|
|
ddd191b87b | |
|
|
74fdaea4ac | |
|
|
c28e27f3e8 | |
|
|
d69feef9da | |
|
|
a06f41dbdb | |
|
|
ecac852d7a | |
|
|
28485d6e5c | |
|
|
c0414abaa0 | |
|
|
1977e73c80 | |
|
|
cd84d9033c |
|
|
@ -274,3 +274,20 @@ out/
|
||||||
bin/
|
bin/
|
||||||
|
|
||||||
/src/generated/prisma
|
/src/generated/prisma
|
||||||
|
|
||||||
|
# 업로드된 파일들 제외
|
||||||
|
backend-node/uploads/
|
||||||
|
uploads/
|
||||||
|
*.jpg
|
||||||
|
*.jpeg
|
||||||
|
*.png
|
||||||
|
*.gif
|
||||||
|
*.pdf
|
||||||
|
*.doc
|
||||||
|
*.docx
|
||||||
|
*.xls
|
||||||
|
*.xlsx
|
||||||
|
*.ppt
|
||||||
|
*.pptx
|
||||||
|
*.hwp
|
||||||
|
*.hwpx
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import dataRoutes from "./routes/dataRoutes";
|
||||||
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
||||||
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
|
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
|
||||||
import multiConnectionRoutes from "./routes/multiConnectionRoutes";
|
import multiConnectionRoutes from "./routes/multiConnectionRoutes";
|
||||||
|
import screenFileRoutes from "./routes/screenFileRoutes";
|
||||||
import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes";
|
import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes";
|
||||||
import ddlRoutes from "./routes/ddlRoutes";
|
import ddlRoutes from "./routes/ddlRoutes";
|
||||||
import entityReferenceRoutes from "./routes/entityReferenceRoutes";
|
import entityReferenceRoutes from "./routes/entityReferenceRoutes";
|
||||||
|
|
@ -141,6 +142,7 @@ app.use("/api/data", dataRoutes);
|
||||||
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
|
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
|
||||||
app.use("/api/external-db-connections", externalDbConnectionRoutes);
|
app.use("/api/external-db-connections", externalDbConnectionRoutes);
|
||||||
app.use("/api/multi-connection", multiConnectionRoutes);
|
app.use("/api/multi-connection", multiConnectionRoutes);
|
||||||
|
app.use("/api/screen-files", screenFileRoutes);
|
||||||
app.use("/api/db-type-categories", dbTypeCategoryRoutes);
|
app.use("/api/db-type-categories", dbTypeCategoryRoutes);
|
||||||
app.use("/api/ddl", ddlRoutes);
|
app.use("/api/ddl", ddlRoutes);
|
||||||
app.use("/api/entity-reference", entityReferenceRoutes);
|
app.use("/api/entity-reference", entityReferenceRoutes);
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,41 @@ const storage = multer.diskStorage({
|
||||||
filename: (req, file, cb) => {
|
filename: (req, file, cb) => {
|
||||||
// 타임스탬프_원본파일명 형태로 저장 (회사코드는 디렉토리로 분리됨)
|
// 타임스탬프_원본파일명 형태로 저장 (회사코드는 디렉토리로 분리됨)
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, "_");
|
|
||||||
|
console.log("📁 파일명 처리:", {
|
||||||
|
originalname: file.originalname,
|
||||||
|
encoding: file.encoding,
|
||||||
|
mimetype: file.mimetype
|
||||||
|
});
|
||||||
|
|
||||||
|
// UTF-8 인코딩 문제 해결: Buffer를 통한 올바른 디코딩
|
||||||
|
let decodedName;
|
||||||
|
try {
|
||||||
|
// 파일명이 깨진 경우 Buffer를 통해 올바르게 디코딩
|
||||||
|
const buffer = Buffer.from(file.originalname, 'latin1');
|
||||||
|
decodedName = buffer.toString('utf8');
|
||||||
|
console.log("📁 파일명 디코딩:", { original: file.originalname, decoded: decodedName });
|
||||||
|
} catch (error) {
|
||||||
|
// 디코딩 실패 시 원본 사용
|
||||||
|
decodedName = file.originalname;
|
||||||
|
console.log("📁 파일명 디코딩 실패, 원본 사용:", file.originalname);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 한국어를 포함한 유니코드 문자 보존하면서 안전한 파일명 생성
|
||||||
|
// 위험한 문자만 제거: / \ : * ? " < > |
|
||||||
|
const sanitizedName = decodedName
|
||||||
|
.replace(/[\/\\:*?"<>|]/g, "_") // 파일시스템에서 금지된 문자만 치환
|
||||||
|
.replace(/\s+/g, "_") // 공백을 언더스코어로 치환
|
||||||
|
.replace(/_{2,}/g, "_"); // 연속된 언더스코어를 하나로 축약
|
||||||
|
|
||||||
const savedFileName = `${timestamp}_${sanitizedName}`;
|
const savedFileName = `${timestamp}_${sanitizedName}`;
|
||||||
|
|
||||||
|
console.log("📁 파일명 변환:", {
|
||||||
|
original: file.originalname,
|
||||||
|
sanitized: sanitizedName,
|
||||||
|
saved: savedFileName
|
||||||
|
});
|
||||||
|
|
||||||
cb(null, savedFileName);
|
cb(null, savedFileName);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -87,18 +120,64 @@ const upload = multer({
|
||||||
|
|
||||||
// 기본 허용 파일 타입
|
// 기본 허용 파일 타입
|
||||||
const defaultAllowedTypes = [
|
const defaultAllowedTypes = [
|
||||||
|
// 이미지 파일
|
||||||
"image/jpeg",
|
"image/jpeg",
|
||||||
"image/png",
|
"image/png",
|
||||||
"image/gif",
|
"image/gif",
|
||||||
"text/html", // HTML 파일 추가
|
"image/webp",
|
||||||
"text/plain", // 텍스트 파일 추가
|
"image/svg+xml",
|
||||||
|
// 텍스트 파일
|
||||||
|
"text/html",
|
||||||
|
"text/plain",
|
||||||
|
"text/markdown",
|
||||||
|
"text/csv",
|
||||||
|
"application/json",
|
||||||
|
"application/xml",
|
||||||
|
// PDF 파일
|
||||||
"application/pdf",
|
"application/pdf",
|
||||||
"application/msword",
|
// Microsoft Office 파일
|
||||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
"application/msword", // .doc
|
||||||
"application/vnd.ms-excel",
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
|
||||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
"application/vnd.ms-excel", // .xls
|
||||||
"application/zip", // ZIP 파일 추가
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx
|
||||||
"application/x-zip-compressed", // ZIP 파일 (다른 MIME 타입)
|
"application/vnd.ms-powerpoint", // .ppt
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation", // .pptx
|
||||||
|
// 한컴오피스 파일
|
||||||
|
"application/x-hwp", // .hwp (한글)
|
||||||
|
"application/haansofthwp", // .hwp (다른 MIME 타입)
|
||||||
|
"application/vnd.hancom.hwp", // .hwp (또 다른 MIME 타입)
|
||||||
|
"application/vnd.hancom.hwpx", // .hwpx (한글 2014+)
|
||||||
|
"application/x-hwpml", // .hwpml (한글 XML)
|
||||||
|
"application/vnd.hancom.hcdt", // .hcdt (한셀)
|
||||||
|
"application/vnd.hancom.hpt", // .hpt (한쇼)
|
||||||
|
"application/octet-stream", // .hwp, .hwpx (일반적인 바이너리 파일)
|
||||||
|
// 압축 파일
|
||||||
|
"application/zip",
|
||||||
|
"application/x-zip-compressed",
|
||||||
|
"application/x-rar-compressed",
|
||||||
|
"application/x-7z-compressed",
|
||||||
|
// 미디어 파일
|
||||||
|
"video/mp4",
|
||||||
|
"video/webm",
|
||||||
|
"video/ogg",
|
||||||
|
"audio/mp3",
|
||||||
|
"audio/mpeg",
|
||||||
|
"audio/wav",
|
||||||
|
"audio/ogg",
|
||||||
|
// Apple/맥 파일
|
||||||
|
"application/vnd.apple.pages", // .pages (Pages)
|
||||||
|
"application/vnd.apple.numbers", // .numbers (Numbers)
|
||||||
|
"application/vnd.apple.keynote", // .keynote (Keynote)
|
||||||
|
"application/x-iwork-pages-sffpages", // .pages (다른 MIME)
|
||||||
|
"application/x-iwork-numbers-sffnumbers", // .numbers (다른 MIME)
|
||||||
|
"application/x-iwork-keynote-sffkey", // .keynote (다른 MIME)
|
||||||
|
"application/vnd.apple.installer+xml", // .pkg (맥 설치 파일)
|
||||||
|
"application/x-apple-diskimage", // .dmg (맥 디스크 이미지)
|
||||||
|
// 기타 문서
|
||||||
|
"application/rtf", // .rtf
|
||||||
|
"application/vnd.oasis.opendocument.text", // .odt
|
||||||
|
"application/vnd.oasis.opendocument.spreadsheet", // .ods
|
||||||
|
"application/vnd.oasis.opendocument.presentation", // .odp
|
||||||
];
|
];
|
||||||
|
|
||||||
if (defaultAllowedTypes.includes(file.mimetype)) {
|
if (defaultAllowedTypes.includes(file.mimetype)) {
|
||||||
|
|
@ -161,9 +240,20 @@ export const uploadFiles = async (
|
||||||
const savedFiles = [];
|
const savedFiles = [];
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
|
// 파일명 디코딩 (파일 저장 시와 동일한 로직)
|
||||||
|
let decodedOriginalName;
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.from(file.originalname, 'latin1');
|
||||||
|
decodedOriginalName = buffer.toString('utf8');
|
||||||
|
console.log("💾 DB 저장용 파일명 디코딩:", { original: file.originalname, decoded: decodedOriginalName });
|
||||||
|
} catch (error) {
|
||||||
|
decodedOriginalName = file.originalname;
|
||||||
|
console.log("💾 DB 저장용 파일명 디코딩 실패, 원본 사용:", file.originalname);
|
||||||
|
}
|
||||||
|
|
||||||
// 파일 확장자 추출
|
// 파일 확장자 추출
|
||||||
const fileExt = path
|
const fileExt = path
|
||||||
.extname(file.originalname)
|
.extname(decodedOriginalName)
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(".", "");
|
.replace(".", "");
|
||||||
|
|
||||||
|
|
@ -196,7 +286,7 @@ export const uploadFiles = async (
|
||||||
),
|
),
|
||||||
target_objid: finalTargetObjid,
|
target_objid: finalTargetObjid,
|
||||||
saved_file_name: file.filename,
|
saved_file_name: file.filename,
|
||||||
real_file_name: file.originalname,
|
real_file_name: decodedOriginalName,
|
||||||
doc_type: docType,
|
doc_type: docType,
|
||||||
doc_type_name: docTypeName,
|
doc_type_name: docTypeName,
|
||||||
file_size: file.size,
|
file_size: file.size,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,145 @@
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { AuthenticatedRequest } from '../middleware/authMiddleware';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 컴포넌트별 파일 정보 조회 및 복원
|
||||||
|
*/
|
||||||
|
export const getScreenComponentFiles = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { screenId } = req.params;
|
||||||
|
|
||||||
|
logger.info(`화면 컴포넌트 파일 조회 시작: screenId=${screenId}`);
|
||||||
|
|
||||||
|
// screen_files: 접두사로 해당 화면의 모든 파일 조회
|
||||||
|
const targetObjidPattern = `screen_files:${screenId}:%`;
|
||||||
|
|
||||||
|
const files = await prisma.attach_file_info.findMany({
|
||||||
|
where: {
|
||||||
|
target_objid: {
|
||||||
|
startsWith: `screen_files:${screenId}:`
|
||||||
|
},
|
||||||
|
status: 'ACTIVE'
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
regdate: 'desc'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 컴포넌트별로 파일 그룹화
|
||||||
|
const componentFiles: { [componentId: string]: any[] } = {};
|
||||||
|
|
||||||
|
files.forEach(file => {
|
||||||
|
// target_objid 형식: screen_files:screenId:componentId:fieldName
|
||||||
|
const targetParts = file.target_objid?.split(':') || [];
|
||||||
|
if (targetParts.length >= 3) {
|
||||||
|
const componentId = targetParts[2];
|
||||||
|
|
||||||
|
if (!componentFiles[componentId]) {
|
||||||
|
componentFiles[componentId] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
componentFiles[componentId].push({
|
||||||
|
objid: file.objid.toString(),
|
||||||
|
savedFileName: file.saved_file_name,
|
||||||
|
realFileName: file.real_file_name,
|
||||||
|
fileSize: Number(file.file_size),
|
||||||
|
fileExt: file.file_ext,
|
||||||
|
filePath: file.file_path,
|
||||||
|
docType: file.doc_type,
|
||||||
|
docTypeName: file.doc_type_name,
|
||||||
|
targetObjid: file.target_objid,
|
||||||
|
parentTargetObjid: file.parent_target_objid,
|
||||||
|
writer: file.writer,
|
||||||
|
regdate: file.regdate?.toISOString(),
|
||||||
|
status: file.status
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`화면 컴포넌트 파일 조회 완료: ${Object.keys(componentFiles).length}개 컴포넌트, 총 ${files.length}개 파일`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
componentFiles: componentFiles,
|
||||||
|
totalFiles: files.length,
|
||||||
|
componentCount: Object.keys(componentFiles).length
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('화면 컴포넌트 파일 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '화면 컴포넌트 파일 조회 중 오류가 발생했습니다.',
|
||||||
|
error: error instanceof Error ? error.message : '알 수 없는 오류'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 컴포넌트의 파일 목록 조회
|
||||||
|
*/
|
||||||
|
export const getComponentFiles = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { screenId, componentId } = req.params;
|
||||||
|
|
||||||
|
logger.info(`컴포넌트 파일 조회: screenId=${screenId}, componentId=${componentId}`);
|
||||||
|
|
||||||
|
// target_objid 패턴: screen_files:screenId:componentId:*
|
||||||
|
const targetObjidPattern = `screen_files:${screenId}:${componentId}:`;
|
||||||
|
|
||||||
|
const files = await prisma.attach_file_info.findMany({
|
||||||
|
where: {
|
||||||
|
target_objid: {
|
||||||
|
startsWith: targetObjidPattern
|
||||||
|
},
|
||||||
|
status: 'ACTIVE'
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
regdate: 'desc'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileList = files.map(file => ({
|
||||||
|
objid: file.objid.toString(),
|
||||||
|
savedFileName: file.saved_file_name,
|
||||||
|
realFileName: file.real_file_name,
|
||||||
|
fileSize: Number(file.file_size),
|
||||||
|
fileExt: file.file_ext,
|
||||||
|
filePath: file.file_path,
|
||||||
|
docType: file.doc_type,
|
||||||
|
docTypeName: file.doc_type_name,
|
||||||
|
targetObjid: file.target_objid,
|
||||||
|
parentTargetObjid: file.parent_target_objid,
|
||||||
|
writer: file.writer,
|
||||||
|
regdate: file.regdate?.toISOString(),
|
||||||
|
status: file.status
|
||||||
|
}));
|
||||||
|
|
||||||
|
logger.info(`컴포넌트 파일 조회 완료: ${fileList.length}개 파일`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
files: fileList,
|
||||||
|
componentId: componentId,
|
||||||
|
screenId: screenId
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('컴포넌트 파일 조회 오류:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '컴포넌트 파일 조회 중 오류가 발생했습니다.',
|
||||||
|
error: error instanceof Error ? error.message : '알 수 없는 오류'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { authenticateToken } from '../middleware/authMiddleware';
|
||||||
|
import { getScreenComponentFiles, getComponentFiles } from '../controllers/screenFileController';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// 화면 컴포넌트별 파일 정보 조회
|
||||||
|
router.get('/screens/:screenId/components/files', authenticateToken, getScreenComponentFiles);
|
||||||
|
|
||||||
|
// 특정 컴포넌트의 파일 목록 조회
|
||||||
|
router.get('/screens/:screenId/components/:componentId/files', authenticateToken, getComponentFiles);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -8,10 +8,10 @@ import { ExternalDbConnectionService } from "./externalDbConnectionService";
|
||||||
import { TableManagementService } from "./tableManagementService";
|
import { TableManagementService } from "./tableManagementService";
|
||||||
import { ExternalDbConnection, ApiResponse } from "../types/externalDbTypes";
|
import { ExternalDbConnection, ApiResponse } from "../types/externalDbTypes";
|
||||||
import { ColumnTypeInfo, TableInfo } from "../types/tableManagement";
|
import { ColumnTypeInfo, TableInfo } from "../types/tableManagement";
|
||||||
|
import prisma from "../config/database";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
// 🔧 Prisma 클라이언트 중복 생성 방지 - 기존 인스턴스 재사용
|
// 🔧 Prisma 클라이언트 중복 생성 방지 - 기존 인스턴스 재사용
|
||||||
import prisma = require("../config/database");
|
|
||||||
|
|
||||||
export interface ValidationResult {
|
export interface ValidationResult {
|
||||||
isValid: boolean;
|
isValid: boolean;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { PrismaClient } from "@prisma/client";
|
import prisma from "../config/database";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
import { cache, CacheKeys } from "../utils/cache";
|
import { cache, CacheKeys } from "../utils/cache";
|
||||||
import {
|
import {
|
||||||
|
|
@ -15,7 +15,6 @@ import { entityJoinService } from "./entityJoinService";
|
||||||
import { referenceCacheService } from "./referenceCacheService";
|
import { referenceCacheService } from "./referenceCacheService";
|
||||||
|
|
||||||
// 🔧 Prisma 클라이언트 중복 생성 방지 - 기존 인스턴스 재사용
|
// 🔧 Prisma 클라이언트 중복 생성 방지 - 기존 인스턴스 재사용
|
||||||
import prisma = require("../config/database");
|
|
||||||
|
|
||||||
export class TableManagementService {
|
export class TableManagementService {
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,7 @@ export default function BatchManagementPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,7 @@ export default function CollectionManagementPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export default function CommonCodeManagementPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
{/* 페이지 제목 */}
|
{/* 페이지 제목 */}
|
||||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { CompanyManagement } from "@/components/admin/CompanyManagement";
|
||||||
export default function CompanyPage() {
|
export default function CompanyPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
{/* 페이지 제목 */}
|
{/* 페이지 제목 */}
|
||||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ export default function DataFlowEditPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,7 @@ export default function ExternalCallConfigsPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
{/* 페이지 헤더 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,7 @@ export default function ExternalConnectionsPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
{/* 페이지 제목 */}
|
{/* 페이지 제목 */}
|
||||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import MultiLang from "@/components/admin/MultiLang";
|
||||||
export default function I18nPage() {
|
export default function I18nPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="container mx-auto p-6">
|
<div className="w-full max-w-none px-4 py-8">
|
||||||
<MultiLang />
|
<MultiLang />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,7 @@ export default function LayoutManagementPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
{/* 페이지 제목 */}
|
{/* 페이지 제목 */}
|
||||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { MenuManagement } from "@/components/admin/MenuManagement";
|
||||||
export default function MenuPage() {
|
export default function MenuPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
{/* 페이지 제목 */}
|
{/* 페이지 제목 */}
|
||||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import MonitoringDashboard from "@/components/admin/MonitoringDashboard";
|
||||||
export default function MonitoringPage() {
|
export default function MonitoringPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">모니터링</h1>
|
<h1 className="text-2xl font-bold">모니터링</h1>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,27 @@
|
||||||
import { Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package } from "lucide-react";
|
import {
|
||||||
|
Users, Shield, Settings, BarChart3, Palette, Layout, Database, Package
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 관리자 메인 페이지
|
* 관리자 메인 페이지
|
||||||
*/
|
*/
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="w-full max-w-none px-4 pt-12 pb-16 space-y-16">
|
||||||
{/* 관리자 기능 카드들 */}
|
|
||||||
<div className="mx-auto max-w-7xl grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
{/* 주요 관리 기능 */}
|
||||||
|
<div className="mx-auto max-w-7xl space-y-10">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">주요 관리 기능</h2>
|
||||||
|
<p className="text-gray-600">시스템의 핵심 관리 기능들을 제공합니다</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Link href="/admin/userMng" className="block">
|
<Link href="/admin/userMng" className="block">
|
||||||
<div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50">
|
<div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-gradient-to-br from-blue-200 to-blue-300">
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-blue-100">
|
||||||
<Users className="h-6 w-6 text-blue-600" />
|
<Users className="h-6 w-6 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -25,8 +34,8 @@ export default function AdminPage() {
|
||||||
|
|
||||||
<div className="rounded-lg border bg-white p-6 shadow-sm">
|
<div className="rounded-lg border bg-white p-6 shadow-sm">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-gradient-to-br from-emerald-200 to-emerald-300">
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-emerald-100">
|
||||||
<Shield className="h-6 w-6 text-emerald-600" />
|
<Shield className="h-6 w-6 text-emerald-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-gray-900">권한 관리</h3>
|
<h3 className="font-semibold text-gray-900">권한 관리</h3>
|
||||||
|
|
@ -37,8 +46,8 @@ export default function AdminPage() {
|
||||||
|
|
||||||
<div className="rounded-lg border bg-white p-6 shadow-sm">
|
<div className="rounded-lg border bg-white p-6 shadow-sm">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-gradient-to-br from-violet-200 to-violet-300">
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-violet-100">
|
||||||
<Settings className="h-6 w-6 text-violet-600" />
|
<Settings className="h-6 w-6 text-violet-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-gray-900">시스템 설정</h3>
|
<h3 className="font-semibold text-gray-900">시스템 설정</h3>
|
||||||
|
|
@ -49,8 +58,8 @@ export default function AdminPage() {
|
||||||
|
|
||||||
<div className="rounded-lg border bg-white p-6 shadow-sm">
|
<div className="rounded-lg border bg-white p-6 shadow-sm">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-gradient-to-br from-amber-200 to-amber-300">
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-amber-100">
|
||||||
<BarChart3 className="h-6 w-6 text-amber-600" />
|
<BarChart3 className="h-6 w-6 text-amber-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-gray-900">통계 및 리포트</h3>
|
<h3 className="font-semibold text-gray-900">통계 및 리포트</h3>
|
||||||
|
|
@ -62,7 +71,7 @@ export default function AdminPage() {
|
||||||
<Link href="/admin/screenMng" className="block">
|
<Link href="/admin/screenMng" className="block">
|
||||||
<div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50">
|
<div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-gradient-to-br from-indigo-200 to-indigo-300">
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-100">
|
||||||
<Palette className="h-6 w-6 text-indigo-600" />
|
<Palette className="h-6 w-6 text-indigo-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -72,17 +81,21 @@ export default function AdminPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 표준 관리 섹션 */}
|
{/* 표준 관리 섹션 */}
|
||||||
<div className="mx-auto max-w-7xl space-y-4">
|
<div className="mx-auto max-w-7xl space-y-10">
|
||||||
<h2 className="text-xl font-semibold text-gray-900">표준 관리</h2>
|
<div className="text-center mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">표준 관리</h2>
|
||||||
|
<p className="text-gray-600">시스템 표준 및 컴포넌트를 통합 관리합니다</p>
|
||||||
|
</div>
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<Link href="/admin/standards" className="block">
|
<Link href="/admin/standards" className="block h-full">
|
||||||
<div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50">
|
<div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50 h-full">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-gradient-to-br from-teal-200 to-teal-300">
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-teal-100">
|
||||||
<Database className="h-6 w-6 text-teal-600" />
|
<Database className="h-6 w-6 text-teal-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-gray-900">웹타입 관리</h3>
|
<h3 className="font-semibold text-gray-900">웹타입 관리</h3>
|
||||||
|
|
@ -92,11 +105,11 @@ export default function AdminPage() {
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/admin/templates" className="block">
|
<Link href="/admin/templates" className="block h-full">
|
||||||
<div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50">
|
<div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50 h-full">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-gradient-to-br from-emerald-200 to-emerald-300">
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-emerald-100">
|
||||||
<Layout className="h-6 w-6 text-emerald-600" />
|
<Layout className="h-6 w-6 text-emerald-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-gray-900">템플릿 관리</h3>
|
<h3 className="font-semibold text-gray-900">템플릿 관리</h3>
|
||||||
|
|
@ -106,11 +119,11 @@ export default function AdminPage() {
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/admin/tableMng" className="block">
|
<Link href="/admin/tableMng" className="block h-full">
|
||||||
<div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50">
|
<div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50 h-full">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-gradient-to-br from-cyan-200 to-cyan-300">
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-cyan-100">
|
||||||
<Database className="h-6 w-6 text-cyan-600" />
|
<Database className="h-6 w-6 text-cyan-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-gray-900">테이블 관리</h3>
|
<h3 className="font-semibold text-gray-900">테이블 관리</h3>
|
||||||
|
|
@ -120,11 +133,11 @@ export default function AdminPage() {
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/admin/components" className="block">
|
<Link href="/admin/components" className="block h-full">
|
||||||
<div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50">
|
<div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50 h-full">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-gradient-to-br from-violet-200 to-violet-300">
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-violet-100">
|
||||||
<Package className="h-6 w-6 text-violet-600" />
|
<Package className="h-6 w-6 text-violet-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-gray-900">컴포넌트 관리</h3>
|
<h3 className="font-semibold text-gray-900">컴포넌트 관리</h3>
|
||||||
|
|
@ -136,31 +149,54 @@ export default function AdminPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 최근 활동 */}
|
{/* 빠른 액세스 */}
|
||||||
<div className="rounded-lg border bg-white p-6 shadow-sm">
|
<div className="mx-auto max-w-7xl space-y-10">
|
||||||
<h3 className="mb-4 text-lg font-semibold">최근 관리자 활동</h3>
|
<div className="text-center mb-8">
|
||||||
<div className="space-y-4">
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">빠른 액세스</h2>
|
||||||
<div className="flex items-center justify-between border-b border-gray-100 py-2 last:border-0">
|
<p className="text-gray-600">자주 사용하는 관리 기능에 빠르게 접근할 수 있습니다</p>
|
||||||
<div>
|
</div>
|
||||||
<p className="font-medium text-gray-900">새로운 사용자 추가</p>
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
<p className="text-sm text-gray-600">김철수 사용자가 생성되었습니다.</p>
|
<Link href="/admin/menu" className="block">
|
||||||
|
<div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-blue-100">
|
||||||
|
<Layout className="h-6 w-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">메뉴 관리</h3>
|
||||||
|
<p className="text-sm text-gray-600">시스템 메뉴 및 네비게이션 설정</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-gray-500">2분 전</span>
|
</Link>
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between border-b border-gray-100 py-2 last:border-0">
|
<Link href="/admin/external-connections" className="block">
|
||||||
<div>
|
<div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50">
|
||||||
<p className="font-medium text-gray-900">권한 변경</p>
|
<div className="flex items-center gap-4">
|
||||||
<p className="text-sm text-gray-600">이영희 사용자의 권한이 수정되었습니다.</p>
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-green-100">
|
||||||
|
<Database className="h-6 w-6 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">외부 연결 관리</h3>
|
||||||
|
<p className="text-sm text-gray-600">외부 데이터베이스 연결 설정</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-gray-500">15분 전</span>
|
</Link>
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between border-b border-gray-100 py-2 last:border-0">
|
<Link href="/admin/commonCode" className="block">
|
||||||
<div>
|
<div className="rounded-lg border bg-white p-6 shadow-sm transition-colors hover:bg-gray-50">
|
||||||
<p className="font-medium text-gray-900">시스템 설정 변경</p>
|
<div className="flex items-center gap-4">
|
||||||
<p className="text-sm text-gray-600">비밀번호 정책이 업데이트되었습니다.</p>
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-purple-100">
|
||||||
|
<Settings className="h-6 w-6 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">공통 코드 관리</h3>
|
||||||
|
<p className="text-sm text-gray-600">시스템 공통 코드 및 설정</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-gray-500">1시간 전</span>
|
</Link>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ export default function ScreenManagementPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="w-full max-w-none px-4 py-8 space-y-6">
|
||||||
{/* 페이지 제목 */}
|
{/* 페이지 제목 */}
|
||||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -80,7 +80,7 @@ export default function ScreenManagementPage() {
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
{/* 화면 목록 단계 */}
|
{/* 화면 목록 단계 */}
|
||||||
{currentStep === "list" && (
|
{currentStep === "list" && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-4">
|
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-4">
|
||||||
<h2 className="text-xl font-semibold text-gray-800">{stepConfig.list.title}</h2>
|
<h2 className="text-xl font-semibold text-gray-800">{stepConfig.list.title}</h2>
|
||||||
<Button className="bg-blue-600 hover:bg-blue-700 shadow-sm" onClick={() => goToNextStep("design")}>
|
<Button className="bg-blue-600 hover:bg-blue-700 shadow-sm" onClick={() => goToNextStep("design")}>
|
||||||
|
|
@ -100,7 +100,7 @@ export default function ScreenManagementPage() {
|
||||||
|
|
||||||
{/* 화면 설계 단계 */}
|
{/* 화면 설계 단계 */}
|
||||||
{currentStep === "design" && (
|
{currentStep === "design" && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-4">
|
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-4">
|
||||||
<h2 className="text-xl font-semibold text-gray-800">{stepConfig.design.title}</h2>
|
<h2 className="text-xl font-semibold text-gray-800">{stepConfig.design.title}</h2>
|
||||||
<Button variant="outline" className="shadow-sm" onClick={() => goToStep("list")}>
|
<Button variant="outline" className="shadow-sm" onClick={() => goToStep("list")}>
|
||||||
|
|
@ -113,7 +113,7 @@ export default function ScreenManagementPage() {
|
||||||
|
|
||||||
{/* 템플릿 관리 단계 */}
|
{/* 템플릿 관리 단계 */}
|
||||||
{currentStep === "template" && (
|
{currentStep === "template" && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-4">
|
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-4">
|
||||||
<h2 className="text-xl font-semibold text-gray-800">{stepConfig.template.title}</h2>
|
<h2 className="text-xl font-semibold text-gray-800">{stepConfig.template.title}</h2>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
|
||||||
|
|
@ -204,7 +204,7 @@ export default function EditWebTypePage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="mb-6 flex items-center gap-4">
|
<div className="mb-6 flex items-center gap-4">
|
||||||
<Link href={`/admin/standards/${webType}`}>
|
<Link href={`/admin/standards/${webType}`}>
|
||||||
|
|
@ -231,7 +231,7 @@ export default function EditWebTypePage() {
|
||||||
<CardTitle>기본 정보</CardTitle>
|
<CardTitle>기본 정보</CardTitle>
|
||||||
<CardDescription>웹타입의 기본적인 정보를 수정해주세요.</CardDescription>
|
<CardDescription>웹타입의 기본적인 정보를 수정해주세요.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-8">
|
||||||
{/* 웹타입 코드 (읽기 전용) */}
|
{/* 웹타입 코드 (읽기 전용) */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="web_type">웹타입 코드</Label>
|
<Label htmlFor="web_type">웹타입 코드</Label>
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ export default function WebTypeDetailPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|
@ -114,7 +114,7 @@ export default function WebTypeDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="overview" className="space-y-6">
|
<Tabs defaultValue="overview" className="space-y-8">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="overview" className="flex items-center gap-2">
|
<TabsTrigger value="overview" className="flex items-center gap-2">
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
|
|
@ -131,7 +131,7 @@ export default function WebTypeDetailPage() {
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* 개요 탭 */}
|
{/* 개요 탭 */}
|
||||||
<TabsContent value="overview" className="space-y-6">
|
<TabsContent value="overview" className="space-y-8">
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
{/* 기본 정보 */}
|
{/* 기본 정보 */}
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -212,7 +212,7 @@ export default function WebTypeDetailPage() {
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* 설정 탭 */}
|
{/* 설정 탭 */}
|
||||||
<TabsContent value="config" className="space-y-6">
|
<TabsContent value="config" className="space-y-8">
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
{/* 기본 설정 */}
|
{/* 기본 설정 */}
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -269,7 +269,7 @@ export default function WebTypeDetailPage() {
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* JSON 데이터 탭 */}
|
{/* JSON 데이터 탭 */}
|
||||||
<TabsContent value="json" className="space-y-6">
|
<TabsContent value="json" className="space-y-8">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>전체 JSON 데이터</CardTitle>
|
<CardTitle>전체 JSON 데이터</CardTitle>
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@ export default function NewWebTypePage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="mb-6 flex items-center gap-4">
|
<div className="mb-6 flex items-center gap-4">
|
||||||
<Link href="/admin/standards">
|
<Link href="/admin/standards">
|
||||||
|
|
@ -182,7 +182,7 @@ export default function NewWebTypePage() {
|
||||||
<CardTitle>기본 정보</CardTitle>
|
<CardTitle>기본 정보</CardTitle>
|
||||||
<CardDescription>웹타입의 기본적인 정보를 입력해주세요.</CardDescription>
|
<CardDescription>웹타입의 기본적인 정보를 입력해주세요.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-8">
|
||||||
{/* 웹타입 코드 */}
|
{/* 웹타입 코드 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="web_type">
|
<Label htmlFor="web_type">
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@ export default function WebTypesManagePage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
{/* 페이지 제목 */}
|
{/* 페이지 제목 */}
|
||||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -541,7 +541,7 @@ export default function TableManagementPage() {
|
||||||
}, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]);
|
}, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
{/* 페이지 제목 */}
|
{/* 페이지 제목 */}
|
||||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ export default function TemplatesManagePage() {
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6">
|
<div className="w-full max-w-none px-4 py-8">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-8">
|
<CardContent className="flex flex-col items-center justify-center py-8">
|
||||||
<p className="mb-4 text-red-600">템플릿 목록을 불러오는 중 오류가 발생했습니다.</p>
|
<p className="mb-4 text-red-600">템플릿 목록을 불러오는 중 오류가 발생했습니다.</p>
|
||||||
|
|
@ -146,7 +146,7 @@ export default function TemplatesManagePage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
{/* 페이지 제목 */}
|
{/* 페이지 제목 */}
|
||||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { UserManagement } from "@/components/admin/UserManagement";
|
||||||
export default function UserMngPage() {
|
export default function UserMngPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
{/* 페이지 제목 */}
|
{/* 페이지 제목 */}
|
||||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -357,7 +357,7 @@ export default function ValidationDemoPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto space-y-6 py-6">
|
<div className="container mx-auto space-y-8 py-6">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { Badge } from "@/components/ui/badge";
|
||||||
*/
|
*/
|
||||||
export default function MainPage() {
|
export default function MainPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="pt-10 space-y-6">
|
||||||
{/* 메인 컨텐츠 */}
|
{/* 메인 컨텐츠 */}
|
||||||
{/* Welcome Message */}
|
{/* Welcome Message */}
|
||||||
<Card>
|
<Card>
|
||||||
|
|
|
||||||
|
|
@ -219,7 +219,7 @@ export default function MultiLangPage() {
|
||||||
const filteredLangKeys = getFilteredLangKeys();
|
const filteredLangKeys = getFilteredLangKeys();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto space-y-6 p-6">
|
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-3xl font-bold">다국어 관리</h1>
|
<h1 className="text-3xl font-bold">다국어 관리</h1>
|
||||||
<Button>새 키 추가</Button>
|
<Button>새 키 추가</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export default function MainHomePage() {
|
export default function MainHomePage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="pt-10 space-y-6">
|
||||||
{/* 대시보드 컨텐츠 */}
|
{/* 대시보드 컨텐츠 */}
|
||||||
<div className="rounded-lg border bg-white p-6 shadow-sm">
|
<div className="rounded-lg border bg-white p-6 shadow-sm">
|
||||||
<h3 className="mb-4 text-lg font-semibold">WACE 솔루션에 오신 것을 환영합니다!</h3>
|
<h3 className="mb-4 text-lg font-semibold">WACE 솔루션에 오신 것을 환영합니다!</h3>
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,7 @@ export default function ScreenViewPage() {
|
||||||
const screenHeight = layout?.screenResolution?.height || 800;
|
const screenHeight = layout?.screenResolution?.height || 800;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full overflow-auto bg-white">
|
<div className="h-full w-full overflow-auto bg-white pt-10">
|
||||||
{layout && layout.components.length > 0 ? (
|
{layout && layout.components.length > 0 ? (
|
||||||
// 캔버스 컴포넌트들을 정확한 해상도로 표시
|
// 캔버스 컴포넌트들을 정확한 해상도로 표시
|
||||||
<div
|
<div
|
||||||
|
|
@ -275,16 +275,16 @@ export default function ScreenViewPage() {
|
||||||
zIndex: component.position.z || 1,
|
zIndex: component.position.z || 1,
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
console.log("🎯 할당된 화면 컴포넌트:", {
|
// console.log("🎯 할당된 화면 컴포넌트:", {
|
||||||
id: component.id,
|
// id: component.id,
|
||||||
type: component.type,
|
// type: component.type,
|
||||||
position: component.position,
|
// position: component.position,
|
||||||
size: component.size,
|
// size: component.size,
|
||||||
styleWidth: component.style?.width,
|
// styleWidth: component.style?.width,
|
||||||
styleHeight: component.style?.height,
|
// styleHeight: component.style?.height,
|
||||||
finalWidth: `${component.size.width}px`,
|
// finalWidth: `${component.size.width}px`,
|
||||||
finalHeight: `${component.size.height}px`,
|
// finalHeight: `${component.size.height}px`,
|
||||||
});
|
// });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 위젯 컴포넌트가 아닌 경우 DynamicComponentRenderer 사용 */}
|
{/* 위젯 컴포넌트가 아닌 경우 DynamicComponentRenderer 사용 */}
|
||||||
|
|
|
||||||
|
|
@ -240,7 +240,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
const isAdminMode = pathname.startsWith("/admin") || searchParams.get("mode") === "admin";
|
const isAdminMode = pathname.startsWith("/admin") || searchParams.get("mode") === "admin";
|
||||||
|
|
||||||
// 현재 모드에 따라 표시할 메뉴 결정
|
// 현재 모드에 따라 표시할 메뉴 결정
|
||||||
const currentMenus = isAdminMode ? adminMenus : userMenus;
|
// 관리자 모드에서는 관리자 메뉴 + 사용자 메뉴(툴 생성 메뉴 포함)를 모두 표시
|
||||||
|
const currentMenus = isAdminMode ? [...adminMenus, ...userMenus] : userMenus;
|
||||||
|
|
||||||
// 메뉴 토글 함수
|
// 메뉴 토글 함수
|
||||||
const toggleMenu = (menuId: string) => {
|
const toggleMenu = (menuId: string) => {
|
||||||
|
|
@ -451,8 +452,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* 가운데 컨텐츠 영역 */}
|
{/* 가운데 컨텐츠 영역 - overflow 문제 해결 */}
|
||||||
<main className="flex-1 min-w-0 bg-white overflow-hidden">{children}</main>
|
<main className="flex-1 min-w-0 bg-white overflow-auto">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 프로필 수정 모달 */}
|
{/* 프로필 수정 모달 */}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { LAYOUT_CONFIG } from "@/constants/layout";
|
import { LAYOUT_CONFIG } from "@/constants/layout";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 로고 컴포넌트
|
* 로고 컴포넌트
|
||||||
|
|
@ -6,10 +7,17 @@ import { LAYOUT_CONFIG } from "@/constants/layout";
|
||||||
export function Logo() {
|
export function Logo() {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="bg-primary flex h-8 w-8 items-center justify-center rounded-lg">
|
<div className="flex items-center justify-center">
|
||||||
<span className="text-primary-foreground text-sm font-bold">P</span>
|
<Image
|
||||||
|
src="/images/vexplor.png"
|
||||||
|
alt="WACE 솔루션 로고"
|
||||||
|
width={120}
|
||||||
|
height={32}
|
||||||
|
className="h-8 object-contain"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold">{LAYOUT_CONFIG.COMPANY_NAME}</span>
|
{/* <span className="font-semibold">{LAYOUT_CONFIG.COMPANY_NAME}</span> */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,611 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect } from "react";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Upload,
|
||||||
|
Download,
|
||||||
|
Trash2,
|
||||||
|
Eye,
|
||||||
|
FileText,
|
||||||
|
Image,
|
||||||
|
FileVideo,
|
||||||
|
FileAudio,
|
||||||
|
File,
|
||||||
|
X,
|
||||||
|
Plus,
|
||||||
|
Save,
|
||||||
|
AlertCircle
|
||||||
|
} from "lucide-react";
|
||||||
|
import { ComponentData, FileComponent } from "@/types/screen";
|
||||||
|
import { FileInfo } from "@/lib/registry/components/file-upload/types";
|
||||||
|
import { FileViewerModal } from "@/lib/registry/components/file-upload/FileViewerModal";
|
||||||
|
import { uploadFiles, downloadFile, deleteFile } from "@/lib/api/file";
|
||||||
|
import { formatFileSize } from "@/lib/utils";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface FileAttachmentDetailModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
component: FileComponent | null;
|
||||||
|
onUpdateComponent?: (updates: Partial<FileComponent>) => void;
|
||||||
|
screenId?: string;
|
||||||
|
tableName?: string;
|
||||||
|
recordId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일 타입별 아이콘 반환
|
||||||
|
*/
|
||||||
|
const getFileIcon = (fileExt: string) => {
|
||||||
|
const ext = fileExt.toLowerCase();
|
||||||
|
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) {
|
||||||
|
return <Image className="w-5 h-5" />;
|
||||||
|
}
|
||||||
|
if (['mp4', 'avi', 'mov', 'wmv', 'flv'].includes(ext)) {
|
||||||
|
return <FileVideo className="w-5 h-5" />;
|
||||||
|
}
|
||||||
|
if (['mp3', 'wav', 'aac', 'flac'].includes(ext)) {
|
||||||
|
return <FileAudio className="w-5 h-5" />;
|
||||||
|
}
|
||||||
|
if (['pdf', 'doc', 'docx', 'txt', 'rtf'].includes(ext)) {
|
||||||
|
return <FileText className="w-5 h-5" />;
|
||||||
|
}
|
||||||
|
return <File className="w-5 h-5" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일첨부 상세 모달
|
||||||
|
* 화면관리에서 템플릿의 파일첨부 버튼을 클릭했을 때 열리는 상세 관리 모달
|
||||||
|
*/
|
||||||
|
export const FileAttachmentDetailModal: React.FC<FileAttachmentDetailModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
component,
|
||||||
|
onUpdateComponent,
|
||||||
|
screenId,
|
||||||
|
tableName,
|
||||||
|
recordId,
|
||||||
|
}) => {
|
||||||
|
// State 관리
|
||||||
|
const [uploadedFiles, setUploadedFiles] = useState<FileInfo[]>([]);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [viewerFile, setViewerFile] = useState<FileInfo | null>(null);
|
||||||
|
const [isViewerOpen, setIsViewerOpen] = useState(false);
|
||||||
|
|
||||||
|
// 파일 설정 상태
|
||||||
|
const [fileConfig, setFileConfig] = useState({
|
||||||
|
docType: "DOCUMENT",
|
||||||
|
docTypeName: "일반 문서",
|
||||||
|
accept: "*/*",
|
||||||
|
maxSize: 10 * 1024 * 1024, // 10MB
|
||||||
|
multiple: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 컴포넌트가 변경될 때 파일 목록 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (component?.uploadedFiles) {
|
||||||
|
setUploadedFiles(component.uploadedFiles);
|
||||||
|
} else {
|
||||||
|
setUploadedFiles([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (component?.fileConfig) {
|
||||||
|
setFileConfig({
|
||||||
|
docType: component.fileConfig.docType || "DOCUMENT",
|
||||||
|
docTypeName: component.fileConfig.docTypeName || "일반 문서",
|
||||||
|
accept: component.fileConfig.accept?.join(",") || "*/*",
|
||||||
|
maxSize: (component.fileConfig.maxSize || 10) * 1024 * 1024,
|
||||||
|
multiple: component.fileConfig.multiple !== false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [component]);
|
||||||
|
|
||||||
|
// 파일 업로드 처리
|
||||||
|
const handleFileUpload = useCallback(async (files: FileList | File[]) => {
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
|
const fileArray = Array.from(files);
|
||||||
|
|
||||||
|
// 파일 검증
|
||||||
|
const validFiles: File[] = [];
|
||||||
|
for (const file of fileArray) {
|
||||||
|
// 크기 체크
|
||||||
|
if (file.size > fileConfig.maxSize) {
|
||||||
|
toast.error(`${file.name}: 파일 크기가 너무 큽니다. (최대 ${formatFileSize(fileConfig.maxSize)})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 타입 체크
|
||||||
|
if (fileConfig.accept && fileConfig.accept !== "*/*") {
|
||||||
|
const acceptedTypes = fileConfig.accept.split(",").map(type => type.trim());
|
||||||
|
const isValid = acceptedTypes.some(type => {
|
||||||
|
if (type.startsWith(".")) {
|
||||||
|
return file.name.toLowerCase().endsWith(type.toLowerCase());
|
||||||
|
} else {
|
||||||
|
return file.type.includes(type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
toast.error(`${file.name}: 지원하지 않는 파일 형식입니다.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validFiles.push(file);
|
||||||
|
if (!fileConfig.multiple) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validFiles.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setUploading(true);
|
||||||
|
toast.loading(`${validFiles.length}개 파일 업로드 중...`);
|
||||||
|
|
||||||
|
// API를 통한 파일 업로드
|
||||||
|
const response = await uploadFiles({
|
||||||
|
files: validFiles,
|
||||||
|
tableName: tableName || 'screen_files',
|
||||||
|
fieldName: component?.columnName || component?.id || 'file_attachment',
|
||||||
|
recordId: recordId || screenId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
const newFiles: FileInfo[] = response.data.map((file: any) => ({
|
||||||
|
objid: file.objid || `temp_${Date.now()}_${Math.random()}`,
|
||||||
|
savedFileName: file.saved_file_name || file.savedFileName,
|
||||||
|
realFileName: file.real_file_name || file.realFileName,
|
||||||
|
fileSize: file.file_size || file.fileSize,
|
||||||
|
fileExt: file.file_ext || file.fileExt,
|
||||||
|
filePath: file.file_path || file.filePath,
|
||||||
|
docType: fileConfig.docType,
|
||||||
|
docTypeName: fileConfig.docTypeName,
|
||||||
|
targetObjid: file.target_objid || file.targetObjid || recordId || screenId || '',
|
||||||
|
parentTargetObjid: file.parent_target_objid || file.parentTargetObjid,
|
||||||
|
companyCode: file.company_code || file.companyCode || 'DEFAULT',
|
||||||
|
writer: file.writer || 'user',
|
||||||
|
regdate: file.regdate || new Date().toISOString(),
|
||||||
|
status: file.status || 'ACTIVE',
|
||||||
|
// 호환성 속성들
|
||||||
|
path: file.file_path || file.filePath,
|
||||||
|
name: file.real_file_name || file.realFileName,
|
||||||
|
id: file.objid,
|
||||||
|
size: file.file_size || file.fileSize,
|
||||||
|
type: fileConfig.docType,
|
||||||
|
uploadedAt: file.regdate || new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const updatedFiles = fileConfig.multiple ? [...uploadedFiles, ...newFiles] : newFiles;
|
||||||
|
setUploadedFiles(updatedFiles);
|
||||||
|
|
||||||
|
// 컴포넌트 업데이트
|
||||||
|
if (onUpdateComponent) {
|
||||||
|
onUpdateComponent({
|
||||||
|
uploadedFiles: updatedFiles,
|
||||||
|
fileConfig: {
|
||||||
|
...component?.fileConfig,
|
||||||
|
docType: fileConfig.docType,
|
||||||
|
docTypeName: fileConfig.docTypeName,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.dismiss();
|
||||||
|
toast.success(`${validFiles.length}개 파일이 성공적으로 업로드되었습니다.`);
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '파일 업로드에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('파일 업로드 오류:', error);
|
||||||
|
toast.dismiss();
|
||||||
|
toast.error('파일 업로드에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}, [fileConfig, uploadedFiles, onUpdateComponent, component, tableName, recordId, screenId]);
|
||||||
|
|
||||||
|
// 파일 다운로드 처리
|
||||||
|
const handleFileDownload = useCallback(async (file: FileInfo) => {
|
||||||
|
try {
|
||||||
|
toast.loading(`${file.realFileName} 다운로드 중...`);
|
||||||
|
|
||||||
|
// 로컬 파일인 경우
|
||||||
|
if (file._file) {
|
||||||
|
const url = URL.createObjectURL(file._file);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = file.realFileName || file._file.name;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
toast.dismiss();
|
||||||
|
toast.success(`${file.realFileName} 다운로드가 완료되었습니다.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서버 파일인 경우
|
||||||
|
await downloadFile({
|
||||||
|
fileId: file.objid,
|
||||||
|
serverFilename: file.savedFileName,
|
||||||
|
originalName: file.realFileName,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.dismiss();
|
||||||
|
toast.success(`${file.realFileName} 다운로드가 완료되었습니다.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('파일 다운로드 오류:', error);
|
||||||
|
toast.dismiss();
|
||||||
|
toast.error('파일 다운로드에 실패했습니다.');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 파일 삭제 처리
|
||||||
|
const handleFileDelete = useCallback(async (file: FileInfo) => {
|
||||||
|
if (!confirm(`${file.realFileName}을(를) 삭제하시겠습니까?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
toast.loading(`${file.realFileName} 삭제 중...`);
|
||||||
|
|
||||||
|
// 서버 파일인 경우 API 호출
|
||||||
|
if (!file._file) {
|
||||||
|
await deleteFile(file.objid, file.savedFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태에서 파일 제거
|
||||||
|
const updatedFiles = uploadedFiles.filter(f => f.objid !== file.objid);
|
||||||
|
setUploadedFiles(updatedFiles);
|
||||||
|
|
||||||
|
// 컴포넌트 업데이트
|
||||||
|
if (onUpdateComponent) {
|
||||||
|
onUpdateComponent({
|
||||||
|
uploadedFiles: updatedFiles,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.dismiss();
|
||||||
|
toast.success(`${file.realFileName}이 삭제되었습니다.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('파일 삭제 오류:', error);
|
||||||
|
toast.dismiss();
|
||||||
|
toast.error('파일 삭제에 실패했습니다.');
|
||||||
|
}
|
||||||
|
}, [uploadedFiles, onUpdateComponent]);
|
||||||
|
|
||||||
|
// 파일뷰어 열기
|
||||||
|
const handleFileView = useCallback((file: FileInfo) => {
|
||||||
|
setViewerFile(file);
|
||||||
|
setIsViewerOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 파일뷰어 닫기
|
||||||
|
const handleViewerClose = useCallback(() => {
|
||||||
|
setIsViewerOpen(false);
|
||||||
|
setViewerFile(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 드래그 앤 드롭 처리
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragOver(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragOver(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragOver(false);
|
||||||
|
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
handleFileUpload(files);
|
||||||
|
}
|
||||||
|
}, [handleFileUpload]);
|
||||||
|
|
||||||
|
// 파일 선택 처리
|
||||||
|
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
handleFileUpload(files);
|
||||||
|
}
|
||||||
|
// 같은 파일을 다시 선택할 수 있도록 value 초기화
|
||||||
|
e.target.value = '';
|
||||||
|
}, [handleFileUpload]);
|
||||||
|
|
||||||
|
// 설정 저장
|
||||||
|
const handleSaveSettings = useCallback(() => {
|
||||||
|
if (onUpdateComponent) {
|
||||||
|
onUpdateComponent({
|
||||||
|
fileConfig: {
|
||||||
|
...component?.fileConfig,
|
||||||
|
docType: fileConfig.docType,
|
||||||
|
docTypeName: fileConfig.docTypeName,
|
||||||
|
accept: fileConfig.accept.split(",").map(type => type.trim()),
|
||||||
|
maxSize: Math.floor(fileConfig.maxSize / (1024 * 1024)), // MB로 변환
|
||||||
|
multiple: fileConfig.multiple,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toast.success('설정이 저장되었습니다.');
|
||||||
|
}, [fileConfig, onUpdateComponent, component]);
|
||||||
|
|
||||||
|
if (!component) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<DialogTitle className="text-xl font-semibold">
|
||||||
|
파일 첨부 관리 - {component.label || component.id}
|
||||||
|
</DialogTitle>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Tabs defaultValue="files" className="flex-1">
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="files">파일 관리</TabsTrigger>
|
||||||
|
<TabsTrigger value="settings">설정</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 파일 관리 탭 */}
|
||||||
|
<TabsContent value="files" className="flex-1 overflow-auto space-y-4">
|
||||||
|
{/* 파일 업로드 영역 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center space-x-2">
|
||||||
|
<Upload className="w-5 h-5" />
|
||||||
|
<span>파일 업로드</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors
|
||||||
|
${dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-300'}
|
||||||
|
${uploading ? 'opacity-50 cursor-not-allowed' : 'hover:border-gray-400'}
|
||||||
|
`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => !uploading && document.getElementById('file-input')?.click()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="file-input"
|
||||||
|
type="file"
|
||||||
|
multiple={fileConfig.multiple}
|
||||||
|
accept={fileConfig.accept}
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
disabled={uploading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center space-y-3">
|
||||||
|
{uploading ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||||
|
<p className="text-lg font-medium text-gray-700">업로드 중...</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="w-12 h-12 text-gray-400" />
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-medium text-gray-700">
|
||||||
|
파일을 선택하거나 드래그하세요
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
|
{fileConfig.accept && `지원 형식: ${fileConfig.accept}`}
|
||||||
|
{fileConfig.maxSize && ` • 최대 ${formatFileSize(fileConfig.maxSize)}`}
|
||||||
|
{fileConfig.multiple && ' • 여러 파일 선택 가능'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
파일 선택
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 업로드된 파일 목록 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center space-x-2">
|
||||||
|
<FileText className="w-5 h-5" />
|
||||||
|
<span>업로드된 파일 ({uploadedFiles.length})</span>
|
||||||
|
</CardTitle>
|
||||||
|
{uploadedFiles.length > 0 && (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
총 {formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{uploadedFiles.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
<FileText className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||||
|
<p>업로드된 파일이 없습니다.</p>
|
||||||
|
<p className="text-sm mt-1">위의 업로드 영역을 사용해 파일을 추가하세요.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 max-h-60 overflow-y-auto">
|
||||||
|
{uploadedFiles.map((file) => (
|
||||||
|
<div key={file.objid} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{getFileIcon(file.fileExt)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 truncate">
|
||||||
|
{file.realFileName}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center space-x-2 text-xs text-gray-500">
|
||||||
|
<span>{formatFileSize(file.fileSize)}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{file.fileExt.toUpperCase()}</span>
|
||||||
|
{file.uploadedAt && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{new Date(file.uploadedAt).toLocaleDateString()}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-1 flex-shrink-0">
|
||||||
|
{/* 파일뷰어 버튼 */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleFileView(file)}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
title="미리보기"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 다운로드 버튼 */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleFileDownload(file)}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
title="다운로드"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 삭제 버튼 */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleFileDelete(file)}
|
||||||
|
className="h-8 w-8 p-0 text-red-600 hover:text-red-700"
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 설정 탭 */}
|
||||||
|
<TabsContent value="settings" className="flex-1 overflow-auto space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>파일 첨부 설정</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="docType">문서 타입</Label>
|
||||||
|
<Input
|
||||||
|
id="docType"
|
||||||
|
value={fileConfig.docType}
|
||||||
|
onChange={(e) => setFileConfig(prev => ({ ...prev, docType: e.target.value }))}
|
||||||
|
placeholder="예: DOCUMENT, IMAGE, etc."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="docTypeName">문서 타입명</Label>
|
||||||
|
<Input
|
||||||
|
id="docTypeName"
|
||||||
|
value={fileConfig.docTypeName}
|
||||||
|
onChange={(e) => setFileConfig(prev => ({ ...prev, docTypeName: e.target.value }))}
|
||||||
|
placeholder="예: 일반 문서, 이미지 파일"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="accept">허용 파일 형식</Label>
|
||||||
|
<Input
|
||||||
|
id="accept"
|
||||||
|
value={fileConfig.accept}
|
||||||
|
onChange={(e) => setFileConfig(prev => ({ ...prev, accept: e.target.value }))}
|
||||||
|
placeholder="예: image/*,.pdf,.doc,.docx"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
쉼표로 구분하여 입력 (예: image/*,.pdf,.doc)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="maxSize">최대 파일 크기 (MB)</Label>
|
||||||
|
<Input
|
||||||
|
id="maxSize"
|
||||||
|
type="number"
|
||||||
|
value={Math.floor(fileConfig.maxSize / (1024 * 1024))}
|
||||||
|
onChange={(e) => setFileConfig(prev => ({
|
||||||
|
...prev,
|
||||||
|
maxSize: parseInt(e.target.value) * 1024 * 1024
|
||||||
|
}))}
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
id="multiple"
|
||||||
|
type="checkbox"
|
||||||
|
checked={fileConfig.multiple}
|
||||||
|
onChange={(e) => setFileConfig(prev => ({ ...prev, multiple: e.target.checked }))}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="multiple">여러 파일 업로드 허용</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t">
|
||||||
|
<Button onClick={handleSaveSettings} className="w-full">
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
설정 저장
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 파일뷰어 모달 */}
|
||||||
|
<FileViewerModal
|
||||||
|
file={viewerFile}
|
||||||
|
isOpen={isViewerOpen}
|
||||||
|
onClose={handleViewerClose}
|
||||||
|
onDownload={handleFileDownload}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -509,6 +509,48 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
fetchCurrentUser();
|
fetchCurrentUser();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 파일 상태 새로고침 이벤트 리스너
|
||||||
|
useEffect(() => {
|
||||||
|
const handleRefreshFileStatus = async (event: CustomEvent) => {
|
||||||
|
const { tableName, recordId, columnName, targetObjid, fileCount } = event.detail;
|
||||||
|
|
||||||
|
console.log("🔄 InteractiveDataTable 파일 상태 새로고침 이벤트 수신:", {
|
||||||
|
tableName,
|
||||||
|
recordId,
|
||||||
|
columnName,
|
||||||
|
targetObjid,
|
||||||
|
fileCount,
|
||||||
|
currentTableName: component.tableName
|
||||||
|
});
|
||||||
|
|
||||||
|
// 현재 테이블과 일치하는지 확인
|
||||||
|
if (tableName === component.tableName) {
|
||||||
|
// 해당 행의 파일 상태 업데이트
|
||||||
|
const columnKey = `${recordId}_${columnName}`;
|
||||||
|
setFileStatusMap(prev => ({
|
||||||
|
...prev,
|
||||||
|
[recordId]: { hasFiles: fileCount > 0, fileCount },
|
||||||
|
[columnKey]: { hasFiles: fileCount > 0, fileCount }
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log("✅ 파일 상태 업데이트 완료:", {
|
||||||
|
recordId,
|
||||||
|
columnKey,
|
||||||
|
hasFiles: fileCount > 0,
|
||||||
|
fileCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('refreshFileStatus', handleRefreshFileStatus as EventListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('refreshFileStatus', handleRefreshFileStatus as EventListener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [component.tableName]);
|
||||||
|
|
||||||
// 테이블 컬럼 정보 로드 (웹 타입 정보 포함)
|
// 테이블 컬럼 정보 로드 (웹 타입 정보 포함)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTableColumns = async () => {
|
const fetchTableColumns = async () => {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||||
import { uploadFilesAndCreateData } from "@/lib/api/file";
|
import { uploadFilesAndCreateData } from "@/lib/api/file";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ComponentData, WidgetComponent, DataTableComponent, FileComponent, ButtonTypeConfig } from "@/types/screen";
|
import { ComponentData, WidgetComponent, DataTableComponent, FileComponent, ButtonTypeConfig } from "@/types/screen";
|
||||||
|
import { FileUploadComponent } from "@/lib/registry/components/file-upload/FileUploadComponent";
|
||||||
import { InteractiveDataTable } from "./InteractiveDataTable";
|
import { InteractiveDataTable } from "./InteractiveDataTable";
|
||||||
import { DynamicWebTypeRenderer } from "@/lib/registry";
|
import { DynamicWebTypeRenderer } from "@/lib/registry";
|
||||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||||
|
|
@ -412,40 +413,46 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
const { label, readonly } = comp;
|
const { label, readonly } = comp;
|
||||||
const fieldName = comp.columnName || comp.id;
|
const fieldName = comp.columnName || comp.id;
|
||||||
|
|
||||||
const handleFileUpload = async (files: File[]) => {
|
|
||||||
if (!screenInfo?.tableName) {
|
|
||||||
toast.error("테이블명이 설정되지 않았습니다.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const uploadData = {
|
|
||||||
files,
|
|
||||||
tableName: screenInfo.tableName,
|
|
||||||
fieldName,
|
|
||||||
recordId: formData.id || undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await uploadFilesAndCreateData(uploadData);
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
toast.success("파일이 성공적으로 업로드되었습니다.");
|
|
||||||
handleFormDataChange(fieldName, response.data);
|
|
||||||
} else {
|
|
||||||
toast.error("파일 업로드에 실패했습니다.");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("파일 업로드 오류:", error);
|
|
||||||
toast.error("파일 업로드 중 오류가 발생했습니다.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
{/* 파일 업로드 컴포넌트는 기존 구현 사용 */}
|
{/* 실제 FileUploadComponent 사용 */}
|
||||||
<div className="rounded border border-dashed p-2 text-sm text-gray-500">
|
<FileUploadComponent
|
||||||
파일 업로드 영역 (동적 렌더링 예정)
|
component={comp}
|
||||||
</div>
|
componentConfig={{
|
||||||
|
...comp.fileConfig,
|
||||||
|
multiple: comp.fileConfig?.multiple !== false,
|
||||||
|
accept: comp.fileConfig?.accept || "*/*",
|
||||||
|
maxSize: (comp.fileConfig?.maxSize || 10) * 1024 * 1024, // MB to bytes
|
||||||
|
disabled: readonly,
|
||||||
|
}}
|
||||||
|
componentStyle={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
className="h-full w-full"
|
||||||
|
isInteractive={true}
|
||||||
|
isDesignMode={false}
|
||||||
|
formData={{
|
||||||
|
tableName: screenInfo?.tableName,
|
||||||
|
id: formData.id,
|
||||||
|
...formData
|
||||||
|
}}
|
||||||
|
onFormDataChange={(data) => {
|
||||||
|
console.log("📝 파일 업로드 완료:", data);
|
||||||
|
if (onFormDataChange) {
|
||||||
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
|
onFormDataChange(key, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onUpdate={(updates) => {
|
||||||
|
console.log("🔄 파일 컴포넌트 업데이트:", updates);
|
||||||
|
// 파일 업로드 완료 시 formData 업데이트
|
||||||
|
if (updates.uploadedFiles && onFormDataChange) {
|
||||||
|
onFormDataChange(fieldName, updates.uploadedFiles);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,12 @@ import {
|
||||||
SidebarOpen,
|
SidebarOpen,
|
||||||
Folder,
|
Folder,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
|
Image as ImageIcon,
|
||||||
|
FileText,
|
||||||
|
Video,
|
||||||
|
Music,
|
||||||
|
Archive,
|
||||||
|
Presentation,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
interface RealtimePreviewProps {
|
interface RealtimePreviewProps {
|
||||||
|
|
@ -303,17 +309,92 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 파일 타입 */}
|
{/* 파일 타입 */}
|
||||||
{type === "file" && (
|
{type === "file" && (() => {
|
||||||
<div className="flex h-full flex-col">
|
const fileComponent = component as any;
|
||||||
<div className="pointer-events-none flex-1 rounded border-2 border-dashed border-gray-300 bg-gray-50 p-4">
|
const uploadedFiles = fileComponent.uploadedFiles || [];
|
||||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
|
||||||
<File className="mb-2 h-8 w-8 text-gray-400" />
|
// 전역 상태에서 최신 파일 정보 가져오기
|
||||||
<p className="text-sm text-gray-600">파일 업로드 영역</p>
|
const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {};
|
||||||
<p className="mt-1 text-xs text-gray-400">미리보기 모드</p>
|
const globalFiles = globalFileState[component.id] || [];
|
||||||
|
|
||||||
|
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
|
||||||
|
const currentFiles = globalFiles.length > 0 ? globalFiles : uploadedFiles;
|
||||||
|
|
||||||
|
console.log("🔍 RealtimePreview 파일 컴포넌트 렌더링:", {
|
||||||
|
componentId: component.id,
|
||||||
|
uploadedFilesCount: uploadedFiles.length,
|
||||||
|
globalFilesCount: globalFiles.length,
|
||||||
|
currentFilesCount: currentFiles.length,
|
||||||
|
currentFiles: currentFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName || f.name })),
|
||||||
|
componentType: component.type,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="pointer-events-none flex-1 rounded border-2 border-dashed border-gray-300 bg-gray-50 p-2">
|
||||||
|
{currentFiles.length > 0 ? (
|
||||||
|
<div className="h-full overflow-y-auto">
|
||||||
|
<div className="mb-1 text-xs font-medium text-gray-700">
|
||||||
|
업로드된 파일 ({currentFiles.length})
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{currentFiles.map((file: any, index: number) => {
|
||||||
|
// 파일 확장자에 따른 아이콘 선택
|
||||||
|
const getFileIcon = (fileName: string) => {
|
||||||
|
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||||
|
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) {
|
||||||
|
return <ImageIcon className="h-4 w-4 text-green-500 flex-shrink-0" />;
|
||||||
|
}
|
||||||
|
if (['pdf', 'doc', 'docx', 'txt', 'rtf', 'hwp', 'hwpx', 'hwpml', 'pages'].includes(ext)) {
|
||||||
|
return <FileText className="h-4 w-4 text-red-500 flex-shrink-0" />;
|
||||||
|
}
|
||||||
|
if (['ppt', 'pptx', 'hpt', 'keynote'].includes(ext)) {
|
||||||
|
return <Presentation className="h-4 w-4 text-orange-600 flex-shrink-0" />;
|
||||||
|
}
|
||||||
|
if (['xls', 'xlsx', 'hcdt', 'numbers'].includes(ext)) {
|
||||||
|
return <FileText className="h-4 w-4 text-green-600 flex-shrink-0" />;
|
||||||
|
}
|
||||||
|
if (['mp4', 'avi', 'mov', 'wmv', 'webm', 'ogg'].includes(ext)) {
|
||||||
|
return <Video className="h-4 w-4 text-purple-500 flex-shrink-0" />;
|
||||||
|
}
|
||||||
|
if (['mp3', 'wav', 'flac', 'aac'].includes(ext)) {
|
||||||
|
return <Music className="h-4 w-4 text-orange-500 flex-shrink-0" />;
|
||||||
|
}
|
||||||
|
if (['zip', 'rar', '7z', 'tar'].includes(ext)) {
|
||||||
|
return <Archive className="h-4 w-4 text-yellow-500 flex-shrink-0" />;
|
||||||
|
}
|
||||||
|
return <File className="h-4 w-4 text-blue-500 flex-shrink-0" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={file.objid || index} className="flex items-center space-x-2 bg-white rounded p-2 text-xs">
|
||||||
|
{getFileIcon(file.realFileName || file.name || '')}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="truncate font-medium text-gray-900">
|
||||||
|
{file.realFileName || file.name || `파일 ${index + 1}`}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500">
|
||||||
|
{file.fileSize ? `${Math.round(file.fileSize / 1024)} KB` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||||
|
<File className="mb-2 h-8 w-8 text-gray-400" />
|
||||||
|
<p className="text-xs font-medium text-gray-700 mb-1">업로드된 파일 (0)</p>
|
||||||
|
<p className="text-sm text-gray-600">파일 업로드 영역</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-400">상세설정에서 파일을 업로드하세요</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
)}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 선택된 컴포넌트 정보 표시 */}
|
{/* 선택된 컴포넌트 정보 표시 */}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ interface RealtimePreviewProps {
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
isDesignMode?: boolean; // 편집 모드 여부
|
isDesignMode?: boolean; // 편집 모드 여부
|
||||||
onClick?: (e?: React.MouseEvent) => void;
|
onClick?: (e?: React.MouseEvent) => void;
|
||||||
|
onDoubleClick?: (e?: React.MouseEvent) => void; // 더블클릭 핸들러 추가
|
||||||
onDragStart?: (e: React.DragEvent) => void;
|
onDragStart?: (e: React.DragEvent) => void;
|
||||||
onDragEnd?: () => void;
|
onDragEnd?: () => void;
|
||||||
onGroupToggle?: (groupId: string) => void; // 그룹 접기/펼치기
|
onGroupToggle?: (groupId: string) => void; // 그룹 접기/펼치기
|
||||||
|
|
@ -67,6 +68,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
isDesignMode = true, // 기본값은 편집 모드
|
isDesignMode = true, // 기본값은 편집 모드
|
||||||
onClick,
|
onClick,
|
||||||
|
onDoubleClick,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
onGroupToggle,
|
onGroupToggle,
|
||||||
|
|
@ -92,10 +94,10 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
left: `${position.x}px`,
|
left: `${position.x}px`,
|
||||||
top: `${position.y}px`,
|
top: `${position.y}px`,
|
||||||
width: component.componentConfig?.type === "table-list"
|
width: component.componentConfig?.type === "table-list"
|
||||||
? `${Math.max(size?.width || 400, 400)}px` // table-list는 최소 400px
|
? `${Math.max(size?.width || 120, 120)}px` // table-list 디폴트를 그리드 1컬럼 크기로 축소 (120px)
|
||||||
: `${size?.width || 100}px`,
|
: `${size?.width || 100}px`,
|
||||||
height: component.componentConfig?.type === "table-list"
|
height: component.componentConfig?.type === "table-list"
|
||||||
? `${Math.max(size?.height || 300, 300)}px` // table-list는 최소 300px
|
? `${Math.max(size?.height || 200, 200)}px` // table-list 디폴트 높이도 축소 (200px)
|
||||||
: `${size?.height || 36}px`,
|
: `${size?.height || 36}px`,
|
||||||
zIndex: component.type === "layout" ? 1 : position.z || 2, // 레이아웃은 z-index 1, 다른 컴포넌트는 2 이상
|
zIndex: component.type === "layout" ? 1 : position.z || 2, // 레이아웃은 z-index 1, 다른 컴포넌트는 2 이상
|
||||||
...componentStyle,
|
...componentStyle,
|
||||||
|
|
@ -106,6 +108,11 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
onClick?.(e);
|
onClick?.(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDoubleClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDoubleClick?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDragStart = (e: React.DragEvent) => {
|
const handleDragStart = (e: React.DragEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDragStart?.(e);
|
onDragStart?.(e);
|
||||||
|
|
@ -121,6 +128,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
className="absolute cursor-pointer"
|
className="absolute cursor-pointer"
|
||||||
style={{ ...baseStyle, ...selectionStyle }}
|
style={{ ...baseStyle, ...selectionStyle }}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,9 @@ import { GroupingToolbar } from "./GroupingToolbar";
|
||||||
import { screenApi, tableTypeApi } from "@/lib/api/screen";
|
import { screenApi, tableTypeApi } from "@/lib/api/screen";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { MenuAssignmentModal } from "./MenuAssignmentModal";
|
import { MenuAssignmentModal } from "./MenuAssignmentModal";
|
||||||
|
import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal";
|
||||||
import { initializeComponents } from "@/lib/registry/components";
|
import { initializeComponents } from "@/lib/registry/components";
|
||||||
|
import { ScreenFileAPI } from "@/lib/api/screenFile";
|
||||||
|
|
||||||
import StyleEditor from "./StyleEditor";
|
import StyleEditor from "./StyleEditor";
|
||||||
import { RealtimePreview } from "./RealtimePreviewDynamic";
|
import { RealtimePreview } from "./RealtimePreviewDynamic";
|
||||||
|
|
@ -157,6 +159,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
// 메뉴 할당 모달 상태
|
// 메뉴 할당 모달 상태
|
||||||
const [showMenuAssignmentModal, setShowMenuAssignmentModal] = useState(false);
|
const [showMenuAssignmentModal, setShowMenuAssignmentModal] = useState(false);
|
||||||
|
|
||||||
|
// 파일첨부 상세 모달 상태
|
||||||
|
const [showFileAttachmentModal, setShowFileAttachmentModal] = useState(false);
|
||||||
|
const [selectedFileComponent, setSelectedFileComponent] = useState<ComponentData | null>(null);
|
||||||
|
|
||||||
// 해상도 설정 상태
|
// 해상도 설정 상태
|
||||||
const [screenResolution, setScreenResolution] = useState<ScreenResolution>(
|
const [screenResolution, setScreenResolution] = useState<ScreenResolution>(
|
||||||
SCREEN_RESOLUTIONS[0], // 기본값: Full HD
|
SCREEN_RESOLUTIONS[0], // 기본값: Full HD
|
||||||
|
|
@ -188,6 +194,88 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
justFinishedDrag: false, // 드래그 종료 직후 클릭 방지용
|
justFinishedDrag: false, // 드래그 종료 직후 클릭 방지용
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 전역 파일 상태 변경 시 강제 리렌더링을 위한 상태
|
||||||
|
const [forceRenderTrigger, setForceRenderTrigger] = useState(0);
|
||||||
|
|
||||||
|
// 파일 컴포넌트 데이터 복원 함수 (실제 DB에서 조회)
|
||||||
|
const restoreFileComponentsData = useCallback(async (components: ComponentData[]) => {
|
||||||
|
if (!selectedScreen?.screenId) return;
|
||||||
|
|
||||||
|
console.log("🔄 파일 컴포넌트 데이터 복원 시작:", components.length);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 실제 DB에서 화면의 모든 파일 정보 조회
|
||||||
|
const fileResponse = await ScreenFileAPI.getScreenComponentFiles(selectedScreen.screenId);
|
||||||
|
|
||||||
|
if (!fileResponse.success) {
|
||||||
|
console.warn("⚠️ 파일 정보 조회 실패:", fileResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { componentFiles } = fileResponse;
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
// 전역 파일 상태 초기화
|
||||||
|
const globalFileState: {[key: string]: any[]} = {};
|
||||||
|
let restoredCount = 0;
|
||||||
|
|
||||||
|
// DB에서 조회한 파일 정보를 전역 상태로 복원
|
||||||
|
Object.keys(componentFiles).forEach(componentId => {
|
||||||
|
const files = componentFiles[componentId];
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
globalFileState[componentId] = files;
|
||||||
|
restoredCount++;
|
||||||
|
|
||||||
|
// localStorage에도 백업
|
||||||
|
const backupKey = `fileComponent_${componentId}_files`;
|
||||||
|
localStorage.setItem(backupKey, JSON.stringify(files));
|
||||||
|
|
||||||
|
console.log("📁 DB에서 파일 컴포넌트 데이터 복원:", {
|
||||||
|
componentId: componentId,
|
||||||
|
fileCount: files.length,
|
||||||
|
files: files.map(f => ({ objid: f.objid, name: f.realFileName }))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 전역 상태 업데이트
|
||||||
|
(window as any).globalFileState = globalFileState;
|
||||||
|
|
||||||
|
// 모든 파일 컴포넌트에 복원 완료 이벤트 발생
|
||||||
|
Object.keys(globalFileState).forEach(componentId => {
|
||||||
|
const files = globalFileState[componentId];
|
||||||
|
const syncEvent = new CustomEvent('globalFileStateChanged', {
|
||||||
|
detail: {
|
||||||
|
componentId: componentId,
|
||||||
|
files: files,
|
||||||
|
fileCount: files.length,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
isRestore: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.dispatchEvent(syncEvent);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ DB 파일 컴포넌트 데이터 복원 완료:", {
|
||||||
|
totalComponents: components.length,
|
||||||
|
restoredFileComponents: restoredCount,
|
||||||
|
totalFiles: fileResponse.totalFiles,
|
||||||
|
globalFileState: Object.keys(globalFileState).map(id => ({
|
||||||
|
id,
|
||||||
|
fileCount: globalFileState[id]?.length || 0
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
if (restoredCount > 0) {
|
||||||
|
toast.success(`${restoredCount}개 파일 컴포넌트 데이터가 DB에서 복원되었습니다. (총 ${fileResponse.totalFiles}개 파일)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 파일 컴포넌트 데이터 복원 실패:", error);
|
||||||
|
toast.error("파일 데이터 복원 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
}, [selectedScreen?.screenId]);
|
||||||
|
|
||||||
// 드래그 선택 상태
|
// 드래그 선택 상태
|
||||||
const [selectionDrag, setSelectionDrag] = useState({
|
const [selectionDrag, setSelectionDrag] = useState({
|
||||||
isSelecting: false,
|
isSelecting: false,
|
||||||
|
|
@ -646,6 +734,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
initComponents();
|
initComponents();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 전역 파일 상태 변경 이벤트 리스너
|
||||||
|
useEffect(() => {
|
||||||
|
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
||||||
|
console.log("🔄 ScreenDesigner: 전역 파일 상태 변경 감지", event.detail);
|
||||||
|
setForceRenderTrigger(prev => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 테이블 데이터 로드 (성능 최적화: 선택된 테이블만 조회)
|
// 테이블 데이터 로드 (성능 최적화: 선택된 테이블만 조회)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedScreen?.tableName && selectedScreen.tableName.trim()) {
|
if (selectedScreen?.tableName && selectedScreen.tableName.trim()) {
|
||||||
|
|
@ -698,6 +802,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
// 화면 레이아웃 로드
|
// 화면 레이아웃 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedScreen?.screenId) {
|
if (selectedScreen?.screenId) {
|
||||||
|
// 현재 화면 ID를 전역 변수로 설정 (파일 업로드 시 사용)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
(window as any).__CURRENT_SCREEN_ID__ = selectedScreen.screenId;
|
||||||
|
}
|
||||||
|
|
||||||
const loadLayout = async () => {
|
const loadLayout = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await screenApi.getLayout(selectedScreen.screenId);
|
const response = await screenApi.getLayout(selectedScreen.screenId);
|
||||||
|
|
@ -732,6 +841,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
setLayout(layoutWithDefaultGrid);
|
setLayout(layoutWithDefaultGrid);
|
||||||
setHistory([layoutWithDefaultGrid]);
|
setHistory([layoutWithDefaultGrid]);
|
||||||
setHistoryIndex(0);
|
setHistoryIndex(0);
|
||||||
|
|
||||||
|
// 파일 컴포넌트 데이터 복원 (비동기)
|
||||||
|
restoreFileComponentsData(layoutWithDefaultGrid.components);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("레이아웃 로드 실패:", error);
|
console.error("레이아웃 로드 실패:", error);
|
||||||
|
|
@ -1518,12 +1630,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
defaultConfig: component.defaultConfig,
|
defaultConfig: component.defaultConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 카드 디스플레이 컴포넌트의 경우 gridColumns에 맞는 width 계산
|
// 컴포넌트별 gridColumns 설정 및 크기 계산
|
||||||
let componentSize = component.defaultSize;
|
let componentSize = component.defaultSize;
|
||||||
const isCardDisplay = component.id === "card-display";
|
const isCardDisplay = component.id === "card-display";
|
||||||
const gridColumns = isCardDisplay ? 8 : 1;
|
const isTableList = component.id === "table-list";
|
||||||
|
|
||||||
|
// 컴포넌트별 기본 그리드 컬럼 수 설정
|
||||||
|
const gridColumns = isCardDisplay ? 8 : isTableList ? 1 : 1;
|
||||||
|
|
||||||
if (isCardDisplay && layout.gridSettings?.snapToGrid && gridInfo) {
|
if ((isCardDisplay || isTableList) && layout.gridSettings?.snapToGrid && gridInfo) {
|
||||||
// gridColumns에 맞는 정확한 너비 계산
|
// gridColumns에 맞는 정확한 너비 계산
|
||||||
const calculatedWidth = calculateWidthFromColumns(
|
const calculatedWidth = calculateWidthFromColumns(
|
||||||
gridColumns,
|
gridColumns,
|
||||||
|
|
@ -1531,12 +1646,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
layout.gridSettings as GridUtilSettings,
|
layout.gridSettings as GridUtilSettings,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 컴포넌트별 최소 크기 보장
|
||||||
|
const minWidth = isTableList ? 120 : isCardDisplay ? 400 : 100;
|
||||||
|
|
||||||
componentSize = {
|
componentSize = {
|
||||||
...component.defaultSize,
|
...component.defaultSize,
|
||||||
width: calculatedWidth,
|
width: Math.max(calculatedWidth, minWidth),
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("📐 카드 디스플레이 초기 크기 자동 조정:", {
|
console.log(`📐 ${component.name} 초기 크기 자동 조정:`, {
|
||||||
componentId: component.id,
|
componentId: component.id,
|
||||||
gridColumns,
|
gridColumns,
|
||||||
defaultWidth: component.defaultSize.width,
|
defaultWidth: component.defaultSize.width,
|
||||||
|
|
@ -1554,7 +1672,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
componentType: component.id, // 새 컴포넌트 시스템의 ID (DynamicComponentRenderer용)
|
componentType: component.id, // 새 컴포넌트 시스템의 ID (DynamicComponentRenderer용)
|
||||||
position: snappedPosition,
|
position: snappedPosition,
|
||||||
size: componentSize,
|
size: componentSize,
|
||||||
gridColumns: gridColumns, // 카드 디스플레이 컴포넌트는 기본 8그리드
|
gridColumns: gridColumns, // 컴포넌트별 그리드 컬럼 수 적용
|
||||||
componentConfig: {
|
componentConfig: {
|
||||||
type: component.id, // 새 컴포넌트 시스템의 ID 사용
|
type: component.id, // 새 컴포넌트 시스템의 ID 사용
|
||||||
webType: component.webType, // 웹타입 정보 추가
|
webType: component.webType, // 웹타입 정보 추가
|
||||||
|
|
@ -1803,6 +1921,25 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
filters: [],
|
filters: [],
|
||||||
displayFormat: "simple" as const,
|
displayFormat: "simple" as const,
|
||||||
};
|
};
|
||||||
|
case "table":
|
||||||
|
return {
|
||||||
|
tableName: "",
|
||||||
|
displayMode: "table" as const,
|
||||||
|
showHeader: true,
|
||||||
|
showFooter: true,
|
||||||
|
pagination: {
|
||||||
|
enabled: true,
|
||||||
|
pageSize: 10,
|
||||||
|
showPageSizeSelector: true,
|
||||||
|
showPageInfo: true,
|
||||||
|
showFirstLast: true,
|
||||||
|
},
|
||||||
|
columns: [],
|
||||||
|
searchable: true,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
exportable: true,
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
@ -1961,6 +2098,56 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
[layout, gridInfo, saveToHistory, openPanel],
|
[layout, gridInfo, saveToHistory, openPanel],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 파일 컴포넌트 업데이트 처리
|
||||||
|
const handleFileComponentUpdate = useCallback(
|
||||||
|
(updates: Partial<ComponentData>) => {
|
||||||
|
if (!selectedFileComponent) return;
|
||||||
|
|
||||||
|
const updatedComponents = layout.components.map(comp =>
|
||||||
|
comp.id === selectedFileComponent.id
|
||||||
|
? { ...comp, ...updates }
|
||||||
|
: comp
|
||||||
|
);
|
||||||
|
|
||||||
|
const newLayout = { ...layout, components: updatedComponents };
|
||||||
|
setLayout(newLayout);
|
||||||
|
saveToHistory(newLayout);
|
||||||
|
|
||||||
|
// selectedFileComponent도 업데이트
|
||||||
|
setSelectedFileComponent(prev => prev ? { ...prev, ...updates } : null);
|
||||||
|
|
||||||
|
// selectedComponent가 같은 컴포넌트라면 업데이트
|
||||||
|
if (selectedComponent?.id === selectedFileComponent.id) {
|
||||||
|
setSelectedComponent(prev => prev ? { ...prev, ...updates } : null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedFileComponent, layout, saveToHistory, selectedComponent],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 파일첨부 모달 닫기
|
||||||
|
const handleFileAttachmentModalClose = useCallback(() => {
|
||||||
|
setShowFileAttachmentModal(false);
|
||||||
|
setSelectedFileComponent(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 컴포넌트 더블클릭 처리
|
||||||
|
const handleComponentDoubleClick = useCallback(
|
||||||
|
(component: ComponentData, event?: React.MouseEvent) => {
|
||||||
|
event?.stopPropagation();
|
||||||
|
|
||||||
|
// 파일 컴포넌트인 경우 상세 모달 열기
|
||||||
|
if (component.type === "file") {
|
||||||
|
setSelectedFileComponent(component);
|
||||||
|
setShowFileAttachmentModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다른 컴포넌트 타입의 더블클릭 처리는 여기에 추가
|
||||||
|
console.log("더블클릭된 컴포넌트:", component.type, component.id);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// 컴포넌트 클릭 처리 (다중선택 지원)
|
// 컴포넌트 클릭 처리 (다중선택 지원)
|
||||||
const handleComponentClick = useCallback(
|
const handleComponentClick = useCallback(
|
||||||
(component: ComponentData, event?: React.MouseEvent) => {
|
(component: ComponentData, event?: React.MouseEvent) => {
|
||||||
|
|
@ -1998,22 +2185,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
// 마지막 선택된 컴포넌트를 selectedComponent로 설정
|
// 마지막 선택된 컴포넌트를 selectedComponent로 설정
|
||||||
if (!isSelected) {
|
if (!isSelected) {
|
||||||
console.log("🎯 컴포넌트 선택 (다중 모드):", {
|
// console.log("🎯 컴포넌트 선택 (다중 모드):", component.id);
|
||||||
componentId: component.id,
|
|
||||||
componentType: component.type,
|
|
||||||
webTypeConfig: component.type === "widget" ? (component as any).webTypeConfig : null,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
setSelectedComponent(component);
|
setSelectedComponent(component);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 단일 선택 모드
|
// 단일 선택 모드
|
||||||
console.log("🎯 컴포넌트 선택 (단일 모드):", {
|
// console.log("🎯 컴포넌트 선택 (단일 모드):", component.id);
|
||||||
componentId: component.id,
|
|
||||||
componentType: component.type,
|
|
||||||
webTypeConfig: component.type === "widget" ? (component as any).webTypeConfig : null,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
setSelectedComponent(component);
|
setSelectedComponent(component);
|
||||||
setGroupState((prev) => ({
|
setGroupState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -3144,9 +3321,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) */}
|
{/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - 좌우 최소화, 위아래 넉넉한 여유 */}
|
||||||
<div className="relative flex-1 overflow-auto bg-gray-100 p-8">
|
<div className="relative flex-1 overflow-auto bg-gray-100 px-2 py-6">
|
||||||
{/* 해상도 정보 표시 */}
|
{/* 해상도 정보 표시 - 적당한 여백 */}
|
||||||
<div className="mb-4 flex items-center justify-center">
|
<div className="mb-4 flex items-center justify-center">
|
||||||
<div className="rounded-lg border bg-white px-4 py-2 shadow-sm">
|
<div className="rounded-lg border bg-white px-4 py-2 shadow-sm">
|
||||||
<span className="text-sm font-medium text-gray-700">
|
<span className="text-sm font-medium text-gray-700">
|
||||||
|
|
@ -3155,11 +3332,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 실제 작업 캔버스 (해상도 크기) */}
|
{/* 실제 작업 캔버스 (해상도 크기) - 반응형 개선 */}
|
||||||
<div
|
<div
|
||||||
className="mx-auto bg-white shadow-lg"
|
className="mx-auto bg-white shadow-lg"
|
||||||
style={{
|
style={{
|
||||||
width: screenResolution.width,
|
width: screenResolution.width,
|
||||||
height: Math.max(screenResolution.height, 800), // 최소 높이 보장
|
height: Math.max(screenResolution.height, 800), // 최소 높이 보장
|
||||||
minHeight: screenResolution.height
|
minHeight: screenResolution.height
|
||||||
}}
|
}}
|
||||||
|
|
@ -3261,15 +3438,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 전역 파일 상태도 key에 포함하여 실시간 리렌더링
|
||||||
|
const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {};
|
||||||
|
const globalFiles = globalFileState[component.id] || [];
|
||||||
|
const componentFiles = (component as any).uploadedFiles || [];
|
||||||
|
const fileStateKey = `${globalFiles.length}-${JSON.stringify(globalFiles.map((f: any) => f.objid) || [])}-${componentFiles.length}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RealtimePreview
|
<RealtimePreview
|
||||||
key={component.id}
|
key={`${component.id}-${fileStateKey}-${(component as any).lastFileUpdate || 0}-${forceRenderTrigger}`}
|
||||||
component={displayComponent}
|
component={displayComponent}
|
||||||
isSelected={
|
isSelected={
|
||||||
selectedComponent?.id === component.id || groupState.selectedComponents.includes(component.id)
|
selectedComponent?.id === component.id || groupState.selectedComponents.includes(component.id)
|
||||||
}
|
}
|
||||||
isDesignMode={true} // 편집 모드로 설정
|
isDesignMode={true} // 편집 모드로 설정
|
||||||
onClick={(e) => handleComponentClick(component, e)}
|
onClick={(e) => handleComponentClick(component, e)}
|
||||||
|
onDoubleClick={(e) => handleComponentDoubleClick(component, e)}
|
||||||
onDragStart={(e) => startComponentDrag(component, e)}
|
onDragStart={(e) => startComponentDrag(component, e)}
|
||||||
onDragEnd={endDrag}
|
onDragEnd={endDrag}
|
||||||
selectedScreen={selectedScreen}
|
selectedScreen={selectedScreen}
|
||||||
|
|
@ -3277,10 +3461,34 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
onZoneClick={handleZoneClick}
|
onZoneClick={handleZoneClick}
|
||||||
// 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영)
|
// 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영)
|
||||||
onConfigChange={(config) => {
|
onConfigChange={(config) => {
|
||||||
console.log("📤 테이블 설정 변경을 상세설정에 알림:", config);
|
console.log("📤 테이블 설정 변경을 상세설정에 반영:", config);
|
||||||
// 여기서 DetailSettingsPanel의 상태를 업데이트하거나
|
|
||||||
// 컴포넌트의 componentConfig를 업데이트할 수 있습니다
|
// 컴포넌트의 componentConfig 업데이트
|
||||||
// TODO: 실제 구현은 DetailSettingsPanel과의 연동 필요
|
const updatedComponents = layout.components.map(comp => {
|
||||||
|
if (comp.id === component.id) {
|
||||||
|
return {
|
||||||
|
...comp,
|
||||||
|
componentConfig: {
|
||||||
|
...comp.componentConfig,
|
||||||
|
...config
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return comp;
|
||||||
|
});
|
||||||
|
|
||||||
|
const newLayout = {
|
||||||
|
...layout,
|
||||||
|
components: updatedComponents
|
||||||
|
};
|
||||||
|
|
||||||
|
setLayout(newLayout);
|
||||||
|
saveToHistory(newLayout);
|
||||||
|
|
||||||
|
console.log("✅ 컴포넌트 설정 업데이트 완료:", {
|
||||||
|
componentId: component.id,
|
||||||
|
updatedConfig: config
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */}
|
{/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */}
|
||||||
|
|
@ -3350,13 +3558,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RealtimePreview
|
<RealtimePreview
|
||||||
key={child.id}
|
key={`${child.id}-${(child as any).uploadedFiles?.length || 0}-${JSON.stringify((child as any).uploadedFiles?.map((f: any) => f.objid) || [])}`}
|
||||||
component={relativeChildComponent}
|
component={relativeChildComponent}
|
||||||
isSelected={
|
isSelected={
|
||||||
selectedComponent?.id === child.id || groupState.selectedComponents.includes(child.id)
|
selectedComponent?.id === child.id || groupState.selectedComponents.includes(child.id)
|
||||||
}
|
}
|
||||||
isDesignMode={true} // 편집 모드로 설정
|
isDesignMode={true} // 편집 모드로 설정
|
||||||
onClick={(e) => handleComponentClick(child, e)}
|
onClick={(e) => handleComponentClick(child, e)}
|
||||||
|
onDoubleClick={(e) => handleComponentDoubleClick(child, e)}
|
||||||
onDragStart={(e) => startComponentDrag(child, e)}
|
onDragStart={(e) => startComponentDrag(child, e)}
|
||||||
onDragEnd={endDrag}
|
onDragEnd={endDrag}
|
||||||
selectedScreen={selectedScreen}
|
selectedScreen={selectedScreen}
|
||||||
|
|
@ -3686,6 +3895,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
}}
|
}}
|
||||||
onBackToList={onBackToList}
|
onBackToList={onBackToList}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 파일첨부 상세 모달 */}
|
||||||
|
<FileAttachmentDetailModal
|
||||||
|
isOpen={showFileAttachmentModal}
|
||||||
|
onClose={handleFileAttachmentModalClose}
|
||||||
|
component={selectedFileComponent}
|
||||||
|
onUpdateComponent={handleFileComponentUpdate}
|
||||||
|
screenId={selectedScreen?.screenId}
|
||||||
|
tableName={selectedScreen?.tableName}
|
||||||
|
recordId={selectedScreen?.screenId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,30 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
||||||
|
|
||||||
// 레지스트리에서 모든 컴포넌트 조회
|
// 레지스트리에서 모든 컴포넌트 조회
|
||||||
const allComponents = useMemo(() => {
|
const allComponents = useMemo(() => {
|
||||||
return ComponentRegistry.getAllComponents();
|
const components = ComponentRegistry.getAllComponents();
|
||||||
|
console.log("🔍 ComponentsPanel - 로드된 컴포넌트:", components.map(c => ({ id: c.id, name: c.name, category: c.category })));
|
||||||
|
|
||||||
|
// 수동으로 table-list 컴포넌트 추가 (임시)
|
||||||
|
const hasTableList = components.some(c => c.id === 'table-list');
|
||||||
|
if (!hasTableList) {
|
||||||
|
console.log("⚠️ table-list 컴포넌트가 없어서 수동 추가");
|
||||||
|
components.push({
|
||||||
|
id: "table-list",
|
||||||
|
name: "테이블 리스트",
|
||||||
|
nameEng: "TableList Component",
|
||||||
|
description: "데이터베이스 테이블의 데이터를 목록으로 표시하는 컴포넌트",
|
||||||
|
category: "display",
|
||||||
|
webType: "text",
|
||||||
|
defaultConfig: {},
|
||||||
|
defaultSize: { width: 800, height: 400 },
|
||||||
|
icon: "Table",
|
||||||
|
tags: ["테이블", "데이터", "목록", "그리드"],
|
||||||
|
version: "1.0.0",
|
||||||
|
author: "개발팀",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return components;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 카테고리별 분류 (input 카테고리 제외)
|
// 카테고리별 분류 (input 카테고리 제외)
|
||||||
|
|
|
||||||
|
|
@ -908,7 +908,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// 파일 컴포넌트인 경우 FileComponentConfigPanel 렌더링
|
// 파일 컴포넌트인 경우 FileComponentConfigPanel 렌더링
|
||||||
if (selectedComponent.type === "file") {
|
if (selectedComponent.type === "file" || (selectedComponent.type === "widget" && selectedComponent.widgetType === "file")) {
|
||||||
const fileComponent = selectedComponent as FileComponent;
|
const fileComponent = selectedComponent as FileComponent;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -923,7 +923,9 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
<span className="text-sm text-gray-600">타입:</span>
|
<span className="text-sm text-gray-600">타입:</span>
|
||||||
<span className="rounded bg-purple-100 px-2 py-1 text-xs font-medium text-purple-800">파일 업로드</span>
|
<span className="rounded bg-purple-100 px-2 py-1 text-xs font-medium text-purple-800">파일 업로드</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-gray-500">문서 타입: {fileComponent.fileConfig.docTypeName}</div>
|
<div className="mt-1 text-xs text-gray-500">
|
||||||
|
{selectedComponent.type === "widget" ? `위젯타입: ${selectedComponent.widgetType}` : `문서 타입: ${fileComponent.fileConfig?.docTypeName || "일반 문서"}`}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 파일 컴포넌트 설정 영역 */}
|
{/* 파일 컴포넌트 설정 영역 */}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -20,9 +20,73 @@ interface FileUploadProps {
|
||||||
export function FileUpload({ component, onUpdateComponent, onFileUpload, userInfo }: FileUploadProps) {
|
export function FileUpload({ component, onUpdateComponent, onFileUpload, userInfo }: FileUploadProps) {
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [uploadQueue, setUploadQueue] = useState<File[]>([]);
|
const [uploadQueue, setUploadQueue] = useState<File[]>([]);
|
||||||
const [localUploadedFiles, setLocalUploadedFiles] = useState<AttachedFileInfo[]>(component.uploadedFiles || []);
|
// 전역 파일 상태 관리 함수들
|
||||||
|
const getGlobalFileState = (): {[key: string]: any[]} => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return (window as any).globalFileState || {};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
const setGlobalFileState = (updater: (prev: {[key: string]: any[]}) => {[key: string]: any[]}) => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const currentState = getGlobalFileState();
|
||||||
|
const newState = updater(currentState);
|
||||||
|
(window as any).globalFileState = newState;
|
||||||
|
|
||||||
|
console.log("🌐 FileUpload 전역 파일 상태 업데이트:", {
|
||||||
|
componentId: component.id,
|
||||||
|
newFileCount: newState[component.id]?.length || 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// 강제 리렌더링을 위한 이벤트 발생
|
||||||
|
window.dispatchEvent(new CustomEvent('globalFileStateChanged', {
|
||||||
|
detail: { componentId: component.id, fileCount: newState[component.id]?.length || 0 }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기 파일 상태 설정 (전역 상태 우선)
|
||||||
|
const initializeFiles = () => {
|
||||||
|
const globalFiles = getGlobalFileState()[component.id] || [];
|
||||||
|
const componentFiles = component.uploadedFiles || [];
|
||||||
|
const finalFiles = globalFiles.length > 0 ? globalFiles : componentFiles;
|
||||||
|
|
||||||
|
console.log("🚀 FileUpload 파일 상태 초기화:", {
|
||||||
|
componentId: component.id,
|
||||||
|
globalFiles: globalFiles.length,
|
||||||
|
componentFiles: componentFiles.length,
|
||||||
|
finalFiles: finalFiles.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return finalFiles;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [localUploadedFiles, setLocalUploadedFiles] = useState<AttachedFileInfo[]>(initializeFiles());
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// 전역 상태 변경 감지
|
||||||
|
useEffect(() => {
|
||||||
|
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
||||||
|
if (event.detail.componentId === component.id) {
|
||||||
|
const globalFiles = getGlobalFileState()[component.id] || [];
|
||||||
|
console.log("🔄 FileUpload 전역 상태 변경 감지:", {
|
||||||
|
componentId: component.id,
|
||||||
|
newFileCount: globalFiles.length
|
||||||
|
});
|
||||||
|
setLocalUploadedFiles(globalFiles);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [component.id]);
|
||||||
|
|
||||||
const { fileConfig } = component;
|
const { fileConfig } = component;
|
||||||
const { user: authUser, isLoading, isLoggedIn } = useAuth(); // 인증 상태도 함께 가져오기
|
const { user: authUser, isLoading, isLoggedIn } = useAuth(); // 인증 상태도 함께 가져오기
|
||||||
|
|
||||||
|
|
@ -434,6 +498,12 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
// 로컬 상태 업데이트
|
// 로컬 상태 업데이트
|
||||||
setLocalUploadedFiles(updatedFiles);
|
setLocalUploadedFiles(updatedFiles);
|
||||||
|
|
||||||
|
// 전역 상태 업데이트
|
||||||
|
setGlobalFileState(prev => ({
|
||||||
|
...prev,
|
||||||
|
[component.id]: updatedFiles
|
||||||
|
}));
|
||||||
|
|
||||||
// 컴포넌트 업데이트 (옵셔널)
|
// 컴포넌트 업데이트 (옵셔널)
|
||||||
if (onUpdateComponent) {
|
if (onUpdateComponent) {
|
||||||
onUpdateComponent({
|
onUpdateComponent({
|
||||||
|
|
@ -507,6 +577,12 @@ export function FileUpload({ component, onUpdateComponent, onFileUpload, userInf
|
||||||
// 로컬 상태 업데이트
|
// 로컬 상태 업데이트
|
||||||
setLocalUploadedFiles(filteredFiles);
|
setLocalUploadedFiles(filteredFiles);
|
||||||
|
|
||||||
|
// 전역 상태 업데이트
|
||||||
|
setGlobalFileState(prev => ({
|
||||||
|
...prev,
|
||||||
|
[component.id]: filteredFiles
|
||||||
|
}));
|
||||||
|
|
||||||
onUpdateComponent({
|
onUpdateComponent({
|
||||||
uploadedFiles: filteredFiles,
|
uploadedFiles: filteredFiles,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -71,10 +71,7 @@ export const usePanelState = (panels: PanelConfig[]) => {
|
||||||
|
|
||||||
// 패널 열기
|
// 패널 열기
|
||||||
const openPanel = useCallback((panelId: string) => {
|
const openPanel = useCallback((panelId: string) => {
|
||||||
console.log("📂 패널 열기:", {
|
// console.log("📂 패널 열기:", panelId);
|
||||||
panelId,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
setPanelStates((prev) => ({
|
setPanelStates((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[panelId]: {
|
[panelId]: {
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,43 @@ export interface FileDownloadParams {
|
||||||
/**
|
/**
|
||||||
* 파일 업로드
|
* 파일 업로드
|
||||||
*/
|
*/
|
||||||
export const uploadFiles = async (files: FileList): Promise<FileUploadResponse> => {
|
export const uploadFiles = async (params: {
|
||||||
|
files: FileList | File[];
|
||||||
|
tableName?: string;
|
||||||
|
fieldName?: string;
|
||||||
|
recordId?: string;
|
||||||
|
docType?: string;
|
||||||
|
docTypeName?: string;
|
||||||
|
targetObjid?: string;
|
||||||
|
parentTargetObjid?: string;
|
||||||
|
linkedTable?: string;
|
||||||
|
linkedField?: string;
|
||||||
|
autoLink?: boolean;
|
||||||
|
columnName?: string;
|
||||||
|
isVirtualFileColumn?: boolean;
|
||||||
|
}): Promise<FileUploadResponse> => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
Array.from(files).forEach((file) => {
|
// 파일 추가
|
||||||
|
const fileArray = Array.isArray(params.files) ? params.files : Array.from(params.files);
|
||||||
|
fileArray.forEach((file) => {
|
||||||
formData.append("files", file);
|
formData.append("files", file);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 추가 파라미터들 추가
|
||||||
|
if (params.tableName) formData.append("tableName", params.tableName);
|
||||||
|
if (params.fieldName) formData.append("fieldName", params.fieldName);
|
||||||
|
if (params.recordId) formData.append("recordId", params.recordId);
|
||||||
|
if (params.docType) formData.append("docType", params.docType);
|
||||||
|
if (params.docTypeName) formData.append("docTypeName", params.docTypeName);
|
||||||
|
if (params.targetObjid) formData.append("targetObjid", params.targetObjid);
|
||||||
|
if (params.parentTargetObjid) formData.append("parentTargetObjid", params.parentTargetObjid);
|
||||||
|
if (params.linkedTable) formData.append("linkedTable", params.linkedTable);
|
||||||
|
if (params.linkedField) formData.append("linkedField", params.linkedField);
|
||||||
|
if (params.autoLink !== undefined) formData.append("autoLink", params.autoLink.toString());
|
||||||
|
if (params.columnName) formData.append("columnName", params.columnName);
|
||||||
|
if (params.isVirtualFileColumn !== undefined) formData.append("isVirtualFileColumn", params.isVirtualFileColumn.toString());
|
||||||
|
|
||||||
const response = await apiClient.post("/files/upload", formData, {
|
const response = await apiClient.post("/files/upload", formData, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": undefined, // axios가 자동으로 multipart/form-data를 설정하도록
|
"Content-Type": undefined, // axios가 자동으로 multipart/form-data를 설정하도록
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { apiClient } from './client';
|
||||||
|
|
||||||
|
export interface ScreenFileInfo {
|
||||||
|
objid: string;
|
||||||
|
savedFileName: string;
|
||||||
|
realFileName: string;
|
||||||
|
fileSize: number;
|
||||||
|
fileExt: string;
|
||||||
|
filePath: string;
|
||||||
|
docType: string;
|
||||||
|
docTypeName: string;
|
||||||
|
targetObjid: string;
|
||||||
|
parentTargetObjid?: string;
|
||||||
|
writer: string;
|
||||||
|
regdate: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScreenComponentFilesResponse {
|
||||||
|
success: boolean;
|
||||||
|
componentFiles: { [componentId: string]: ScreenFileInfo[] };
|
||||||
|
totalFiles: number;
|
||||||
|
componentCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComponentFilesResponse {
|
||||||
|
success: boolean;
|
||||||
|
files: ScreenFileInfo[];
|
||||||
|
componentId: string;
|
||||||
|
screenId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScreenFileAPI = {
|
||||||
|
/**
|
||||||
|
* 화면의 모든 컴포넌트별 파일 정보 조회
|
||||||
|
*/
|
||||||
|
async getScreenComponentFiles(screenId: number): Promise<ScreenComponentFilesResponse> {
|
||||||
|
const response = await apiClient.get(`/screen-files/screens/${screenId}/components/files`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 컴포넌트의 파일 목록 조회
|
||||||
|
*/
|
||||||
|
async getComponentFiles(screenId: number, componentId: string): Promise<ComponentFilesResponse> {
|
||||||
|
const response = await apiClient.get(`/screen-files/screens/${screenId}/components/${componentId}/files`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -52,11 +52,19 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
|
||||||
try {
|
try {
|
||||||
console.log(`웹타입 "${webType}" → DB 지정 컴포넌트 "${dbWebType.component_name}" 사용`);
|
console.log(`웹타입 "${webType}" → DB 지정 컴포넌트 "${dbWebType.component_name}" 사용`);
|
||||||
console.log(`DB 웹타입 정보:`, dbWebType);
|
console.log(`DB 웹타입 정보:`, dbWebType);
|
||||||
console.log(`웹타입 데이터 배열:`, webTypes);
|
|
||||||
|
// FileWidget의 경우 FileUploadComponent 직접 사용
|
||||||
|
if (dbWebType.component_name === "FileWidget" || webType === "file") {
|
||||||
|
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
|
||||||
|
console.log(`✅ FileWidget → FileUploadComponent 사용`);
|
||||||
|
return <FileUploadComponent {...props} {...finalProps} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다른 컴포넌트들은 기존 로직 유지
|
||||||
// const ComponentByName = getWidgetComponentByName(dbWebType.component_name);
|
// const ComponentByName = getWidgetComponentByName(dbWebType.component_name);
|
||||||
// console.log(`컴포넌트 "${dbWebType.component_name}" 성공적으로 로드됨:`, ComponentByName);
|
// console.log(`컴포넌트 "${dbWebType.component_name}" 성공적으로 로드됨:`, ComponentByName);
|
||||||
// return <ComponentByName {...props} {...finalProps} />;
|
// return <ComponentByName {...props} {...finalProps} />;
|
||||||
console.warn(`DB 지정 컴포넌트 "${dbWebType.component_name}" 기능 임시 비활성화`);
|
console.warn(`DB 지정 컴포넌트 "${dbWebType.component_name}" 기능 임시 비활성화 (FileWidget 제외)`);
|
||||||
return <div>컴포넌트 로딩 중...</div>;
|
return <div>컴포넌트 로딩 중...</div>;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`DB 지정 컴포넌트 "${dbWebType.component_name}" 렌더링 실패:`, error);
|
console.error(`DB 지정 컴포넌트 "${dbWebType.component_name}" 렌더링 실패:`, error);
|
||||||
|
|
@ -67,6 +75,13 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
|
||||||
if (webTypeDefinition) {
|
if (webTypeDefinition) {
|
||||||
console.log(`웹타입 "${webType}" → 레지스트리 컴포넌트 사용`);
|
console.log(`웹타입 "${webType}" → 레지스트리 컴포넌트 사용`);
|
||||||
|
|
||||||
|
// 파일 웹타입의 경우 FileUploadComponent 직접 사용
|
||||||
|
if (webType === "file") {
|
||||||
|
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
|
||||||
|
console.log(`✅ 파일 웹타입 → FileUploadComponent 사용`);
|
||||||
|
return <FileUploadComponent {...props} {...finalProps} />;
|
||||||
|
}
|
||||||
|
|
||||||
// 웹타입이 비활성화된 경우
|
// 웹타입이 비활성화된 경우
|
||||||
if (!webTypeDefinition.isActive) {
|
if (!webTypeDefinition.isActive) {
|
||||||
console.warn(`웹타입 "${webType}"이 비활성화되어 있습니다.`);
|
console.warn(`웹타입 "${webType}"이 비활성화되어 있습니다.`);
|
||||||
|
|
@ -91,6 +106,14 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
|
||||||
// 3순위: 웹타입명으로 자동 매핑 (폴백)
|
// 3순위: 웹타입명으로 자동 매핑 (폴백)
|
||||||
try {
|
try {
|
||||||
console.warn(`웹타입 "${webType}" → 자동 매핑 폴백 사용`);
|
console.warn(`웹타입 "${webType}" → 자동 매핑 폴백 사용`);
|
||||||
|
|
||||||
|
// 파일 웹타입의 경우 FileUploadComponent 직접 사용 (최종 폴백)
|
||||||
|
if (webType === "file") {
|
||||||
|
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
|
||||||
|
console.log(`✅ 폴백: 파일 웹타입 → FileUploadComponent 사용`);
|
||||||
|
return <FileUploadComponent {...props} {...finalProps} />;
|
||||||
|
}
|
||||||
|
|
||||||
// const FallbackComponent = getWidgetComponentByWebType(webType);
|
// const FallbackComponent = getWidgetComponentByWebType(webType);
|
||||||
// return <FallbackComponent {...props} />;
|
// return <FallbackComponent {...props} />;
|
||||||
console.warn(`웹타입 "${webType}" 폴백 기능 임시 비활성화`);
|
console.warn(`웹타입 "${webType}" 폴백 기능 임시 비활성화`);
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,12 @@ const WidgetRenderer: ComponentRenderer = ({ component, ...props }) => {
|
||||||
// 동적 웹타입 렌더링 사용
|
// 동적 웹타입 렌더링 사용
|
||||||
if (widgetType) {
|
if (widgetType) {
|
||||||
try {
|
try {
|
||||||
|
// 파일 위젯의 경우 인터랙션 허용 (pointer-events-none 제거)
|
||||||
|
const isFileWidget = widgetType === "file";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="pointer-events-none flex-1">
|
<div className={isFileWidget ? "flex-1" : "pointer-events-none flex-1"}>
|
||||||
<DynamicWebTypeRenderer
|
<DynamicWebTypeRenderer
|
||||||
webType={widgetType}
|
webType={widgetType}
|
||||||
props={{
|
props={{
|
||||||
|
|
@ -45,6 +48,7 @@ const WidgetRenderer: ComponentRenderer = ({ component, ...props }) => {
|
||||||
component: widget,
|
component: widget,
|
||||||
value: undefined, // 미리보기이므로 값은 없음
|
value: undefined, // 미리보기이므로 값은 없음
|
||||||
readonly: readonly,
|
readonly: readonly,
|
||||||
|
isDesignMode: true, // 디자인 모드임을 명시
|
||||||
}}
|
}}
|
||||||
config={widget.webTypeConfig}
|
config={widget.webTypeConfig}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -169,20 +169,21 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🔧 버튼 컴포넌트 설정:", {
|
// 디버그 로그 (필요시 주석 해제)
|
||||||
originalConfig: componentConfig,
|
// console.log("🔧 버튼 컴포넌트 설정:", {
|
||||||
processedConfig,
|
// originalConfig: componentConfig,
|
||||||
actionConfig: processedConfig.action,
|
// processedConfig,
|
||||||
webTypeConfig: component.webTypeConfig,
|
// actionConfig: processedConfig.action,
|
||||||
enableDataflowControl: component.webTypeConfig?.enableDataflowControl,
|
// webTypeConfig: component.webTypeConfig,
|
||||||
dataflowConfig: component.webTypeConfig?.dataflowConfig,
|
// enableDataflowControl: component.webTypeConfig?.enableDataflowControl,
|
||||||
screenId,
|
// dataflowConfig: component.webTypeConfig?.dataflowConfig,
|
||||||
tableName,
|
// screenId,
|
||||||
onRefresh,
|
// tableName,
|
||||||
onClose,
|
// onRefresh,
|
||||||
selectedRows,
|
// onClose,
|
||||||
selectedRowsData,
|
// selectedRows,
|
||||||
});
|
// selectedRowsData,
|
||||||
|
// });
|
||||||
|
|
||||||
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
|
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
|
||||||
const componentStyle: React.CSSProperties = {
|
const componentStyle: React.CSSProperties = {
|
||||||
|
|
@ -203,7 +204,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
|
|
||||||
// 실제 액션 실행 함수
|
// 실제 액션 실행 함수
|
||||||
const executeAction = async (actionConfig: any, context: ButtonActionContext) => {
|
const executeAction = async (actionConfig: any, context: ButtonActionContext) => {
|
||||||
console.log("🚀 executeAction 시작:", { actionConfig, context });
|
// console.log("🚀 executeAction 시작:", { actionConfig, context });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 기존 토스트가 있다면 먼저 제거
|
// 기존 토스트가 있다면 먼저 제거
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ import React, { useEffect, useState, useMemo } from "react";
|
||||||
import { ComponentRendererProps } from "@/types/component";
|
import { ComponentRendererProps } from "@/types/component";
|
||||||
import { CardDisplayConfig } from "./types";
|
import { CardDisplayConfig } from "./types";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
|
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export interface CardDisplayComponentProps extends ComponentRendererProps {
|
export interface CardDisplayComponentProps extends ComponentRendererProps {
|
||||||
config?: CardDisplayConfig;
|
config?: CardDisplayConfig;
|
||||||
|
|
@ -39,6 +43,59 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
const [loadedTableColumns, setLoadedTableColumns] = useState<any[]>([]);
|
const [loadedTableColumns, setLoadedTableColumns] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// 상세보기 모달 상태
|
||||||
|
const [viewModalOpen, setViewModalOpen] = useState(false);
|
||||||
|
const [selectedData, setSelectedData] = useState<any>(null);
|
||||||
|
|
||||||
|
// 편집 모달 상태
|
||||||
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
|
const [editData, setEditData] = useState<any>(null);
|
||||||
|
|
||||||
|
// 카드 액션 핸들러
|
||||||
|
const handleCardView = (data: any) => {
|
||||||
|
// console.log("👀 상세보기 클릭:", data);
|
||||||
|
setSelectedData(data);
|
||||||
|
setViewModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCardEdit = (data: any) => {
|
||||||
|
// console.log("✏️ 편집 클릭:", data);
|
||||||
|
setEditData({ ...data }); // 복사본 생성
|
||||||
|
setEditModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 편집 폼 데이터 변경 핸들러
|
||||||
|
const handleEditFormChange = (key: string, value: string) => {
|
||||||
|
setEditData((prev: any) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: value
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 편집 저장 핸들러
|
||||||
|
const handleEditSave = async () => {
|
||||||
|
// console.log("💾 편집 저장:", editData);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: 실제 API 호출로 데이터 업데이트
|
||||||
|
// await tableTypeApi.updateTableData(tableName, editData);
|
||||||
|
|
||||||
|
// console.log("✅ 편집 저장 완료");
|
||||||
|
alert("✅ 저장되었습니다!");
|
||||||
|
|
||||||
|
// 모달 닫기
|
||||||
|
setEditModalOpen(false);
|
||||||
|
setEditData(null);
|
||||||
|
|
||||||
|
// 데이터 새로고침 (필요시)
|
||||||
|
// loadTableData();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 편집 저장 실패:", error);
|
||||||
|
alert("❌ 저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 테이블 데이터 로딩
|
// 테이블 데이터 로딩
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadTableData = async () => {
|
const loadTableData = async () => {
|
||||||
|
|
@ -48,19 +105,25 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// tableName 확인 (props에서 전달받은 tableName 사용)
|
// tableName 확인 (props에서 전달받은 tableName 사용)
|
||||||
const tableNameToUse = tableName || component.componentConfig?.tableName;
|
const tableNameToUse = tableName || component.componentConfig?.tableName || 'user_info'; // 기본 테이블명 설정
|
||||||
|
|
||||||
if (!tableNameToUse) {
|
if (!tableNameToUse) {
|
||||||
console.log("📋 CardDisplay: 테이블명이 설정되지 않음", {
|
// console.log("📋 CardDisplay: 테이블명이 설정되지 않음", {
|
||||||
tableName,
|
// tableName,
|
||||||
componentTableName: component.componentConfig?.tableName,
|
// componentTableName: component.componentConfig?.tableName,
|
||||||
});
|
// });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// console.log("📋 CardDisplay: 사용할 테이블명", {
|
||||||
|
// tableName,
|
||||||
|
// componentTableName: component.componentConfig?.tableName,
|
||||||
|
// finalTableName: tableNameToUse,
|
||||||
|
// });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
console.log(`📋 CardDisplay: ${tableNameToUse} 테이블 데이터 로딩 시작`);
|
// console.log(`📋 CardDisplay: ${tableNameToUse} 테이블 데이터 로딩 시작`);
|
||||||
|
|
||||||
// 테이블 데이터와 컬럼 정보를 병렬로 로드
|
// 테이블 데이터와 컬럼 정보를 병렬로 로드
|
||||||
const [dataResponse, columnsResponse] = await Promise.all([
|
const [dataResponse, columnsResponse] = await Promise.all([
|
||||||
|
|
@ -71,13 +134,13 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
tableTypeApi.getColumns(tableNameToUse),
|
tableTypeApi.getColumns(tableNameToUse),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
console.log(`📋 CardDisplay: ${tableNameToUse} 데이터 로딩 완료`, {
|
// console.log(`📋 CardDisplay: ${tableNameToUse} 데이터 로딩 완료`, {
|
||||||
total: dataResponse.total,
|
// total: dataResponse.total,
|
||||||
dataLength: dataResponse.data.length,
|
// dataLength: dataResponse.data.length,
|
||||||
columnsLength: columnsResponse.length,
|
// columnsLength: columnsResponse.length,
|
||||||
sampleData: dataResponse.data.slice(0, 2),
|
// sampleData: dataResponse.data.slice(0, 2),
|
||||||
sampleColumns: columnsResponse.slice(0, 3),
|
// sampleColumns: columnsResponse.slice(0, 3),
|
||||||
});
|
// });
|
||||||
|
|
||||||
setLoadedTableData(dataResponse.data);
|
setLoadedTableData(dataResponse.data);
|
||||||
setLoadedTableColumns(columnsResponse);
|
setLoadedTableColumns(columnsResponse);
|
||||||
|
|
@ -130,32 +193,32 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
|
|
||||||
// 표시할 데이터 결정 (로드된 테이블 데이터 우선 사용)
|
// 표시할 데이터 결정 (로드된 테이블 데이터 우선 사용)
|
||||||
const displayData = useMemo(() => {
|
const displayData = useMemo(() => {
|
||||||
console.log("📋 CardDisplay: displayData 결정 중", {
|
// console.log("📋 CardDisplay: displayData 결정 중", {
|
||||||
dataSource: componentConfig.dataSource,
|
// dataSource: componentConfig.dataSource,
|
||||||
loadedTableDataLength: loadedTableData.length,
|
// loadedTableDataLength: loadedTableData.length,
|
||||||
tableDataLength: tableData.length,
|
// tableDataLength: tableData.length,
|
||||||
staticDataLength: componentConfig.staticData?.length || 0,
|
// staticDataLength: componentConfig.staticData?.length || 0,
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 로드된 테이블 데이터가 있으면 항상 우선 사용 (dataSource 설정 무시)
|
// 로드된 테이블 데이터가 있으면 항상 우선 사용 (dataSource 설정 무시)
|
||||||
if (loadedTableData.length > 0) {
|
if (loadedTableData.length > 0) {
|
||||||
console.log("📋 CardDisplay: 로드된 테이블 데이터 사용", loadedTableData.slice(0, 2));
|
// console.log("📋 CardDisplay: 로드된 테이블 데이터 사용", loadedTableData.slice(0, 2));
|
||||||
return loadedTableData;
|
return loadedTableData;
|
||||||
}
|
}
|
||||||
|
|
||||||
// props로 전달받은 테이블 데이터가 있으면 사용
|
// props로 전달받은 테이블 데이터가 있으면 사용
|
||||||
if (tableData.length > 0) {
|
if (tableData.length > 0) {
|
||||||
console.log("📋 CardDisplay: props 테이블 데이터 사용", tableData.slice(0, 2));
|
// console.log("📋 CardDisplay: props 테이블 데이터 사용", tableData.slice(0, 2));
|
||||||
return tableData;
|
return tableData;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (componentConfig.staticData && componentConfig.staticData.length > 0) {
|
if (componentConfig.staticData && componentConfig.staticData.length > 0) {
|
||||||
console.log("📋 CardDisplay: 정적 데이터 사용", componentConfig.staticData.slice(0, 2));
|
// console.log("📋 CardDisplay: 정적 데이터 사용", componentConfig.staticData.slice(0, 2));
|
||||||
return componentConfig.staticData;
|
return componentConfig.staticData;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데이터가 없으면 빈 배열 반환
|
// 데이터가 없으면 빈 배열 반환
|
||||||
console.log("📋 CardDisplay: 표시할 데이터가 없음");
|
// console.log("📋 CardDisplay: 표시할 데이터가 없음");
|
||||||
return [];
|
return [];
|
||||||
}, [componentConfig.dataSource, loadedTableData, tableData, componentConfig.staticData]);
|
}, [componentConfig.dataSource, loadedTableData, tableData, componentConfig.staticData]);
|
||||||
|
|
||||||
|
|
@ -260,23 +323,8 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// DOM에 전달하면 안 되는 React-specific props 필터링
|
// DOM 안전한 props만 필터링 (filterDOMProps 유틸리티 사용)
|
||||||
const {
|
const safeDomProps = filterDOMProps(props);
|
||||||
selectedScreen,
|
|
||||||
onZoneComponentDrop,
|
|
||||||
onZoneClick,
|
|
||||||
componentConfig: _componentConfig,
|
|
||||||
component: _component,
|
|
||||||
isSelected: _isSelected,
|
|
||||||
onClick: _onClick,
|
|
||||||
onDragStart: _onDragStart,
|
|
||||||
onDragEnd: _onDragEnd,
|
|
||||||
size: _size,
|
|
||||||
position: _position,
|
|
||||||
style: _style,
|
|
||||||
onRefresh: _onRefresh, // React DOM 속성이 아니므로 필터링
|
|
||||||
...domProps
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -301,7 +349,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
{...domProps}
|
{...safeDomProps}
|
||||||
>
|
>
|
||||||
<div style={containerStyle}>
|
<div style={containerStyle}>
|
||||||
{displayData.length === 0 ? (
|
{displayData.length === 0 ? (
|
||||||
|
|
@ -393,8 +441,24 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
|
|
||||||
{/* 카드 액션 (선택사항) */}
|
{/* 카드 액션 (선택사항) */}
|
||||||
<div className="mt-3 flex justify-end space-x-2">
|
<div className="mt-3 flex justify-end space-x-2">
|
||||||
<button className="text-xs font-medium text-blue-600 hover:text-blue-800">상세보기</button>
|
<button
|
||||||
<button className="text-xs font-medium text-gray-500 hover:text-gray-700">편집</button>
|
className="text-xs font-medium text-blue-600 hover:text-blue-800 transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCardView(data);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
상세보기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="text-xs font-medium text-gray-500 hover:text-gray-700 transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCardEdit(data);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
편집
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -402,6 +466,101 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 상세보기 모달 */}
|
||||||
|
<Dialog open={viewModalOpen} onOpenChange={setViewModalOpen}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<span className="text-lg">📋</span>
|
||||||
|
상세 정보
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{selectedData && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{Object.entries(selectedData)
|
||||||
|
.filter(([key, value]) => value !== null && value !== undefined && value !== '')
|
||||||
|
.map(([key, value]) => (
|
||||||
|
<div key={key} className="bg-gray-50 rounded-lg p-3">
|
||||||
|
<div className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">
|
||||||
|
{key.replace(/_/g, ' ')}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 break-words">
|
||||||
|
{String(value)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4 border-t">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewModalOpen(false)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 편집 모달 */}
|
||||||
|
<Dialog open={editModalOpen} onOpenChange={setEditModalOpen}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<span className="text-lg">✏️</span>
|
||||||
|
데이터 편집
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{editData && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
{Object.entries(editData)
|
||||||
|
.filter(([key, value]) => value !== null && value !== undefined)
|
||||||
|
.map(([key, value]) => (
|
||||||
|
<div key={key} className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-gray-700 block">
|
||||||
|
{key.replace(/_/g, ' ').toUpperCase()}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={String(value)}
|
||||||
|
onChange={(e) => handleEditFormChange(key, e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
placeholder={`${key} 입력`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setEditModalOpen(false);
|
||||||
|
setEditData(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleEditSave}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,81 +1,568 @@
|
||||||
"use client";
|
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { uploadFiles, downloadFile, deleteFile } from "@/lib/api/file";
|
||||||
|
import { formatFileSize } from "@/lib/utils";
|
||||||
|
import { FileViewerModal } from "./FileViewerModal";
|
||||||
|
import { FileUploadConfig, FileInfo, FileUploadStatus, FileUploadResponse } from "./types";
|
||||||
|
import {
|
||||||
|
Upload,
|
||||||
|
File,
|
||||||
|
FileText,
|
||||||
|
Image,
|
||||||
|
Video,
|
||||||
|
Music,
|
||||||
|
Archive,
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
Trash2,
|
||||||
|
AlertCircle,
|
||||||
|
FileImage,
|
||||||
|
FileVideo,
|
||||||
|
FileAudio,
|
||||||
|
Presentation,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import React from "react";
|
// 파일 아이콘 매핑
|
||||||
import { ComponentRendererProps } from "@/types/component";
|
const getFileIcon = (extension: string) => {
|
||||||
import { FileUploadConfig } from "./types";
|
const ext = extension.toLowerCase().replace('.', '');
|
||||||
|
|
||||||
|
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(ext)) {
|
||||||
|
return <FileImage className="w-6 h-6 text-blue-500" />;
|
||||||
|
}
|
||||||
|
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'].includes(ext)) {
|
||||||
|
return <FileVideo className="w-6 h-6 text-purple-500" />;
|
||||||
|
}
|
||||||
|
if (['mp3', 'wav', 'flac', 'aac', 'ogg'].includes(ext)) {
|
||||||
|
return <FileAudio className="w-6 h-6 text-green-500" />;
|
||||||
|
}
|
||||||
|
if (['pdf'].includes(ext)) {
|
||||||
|
return <FileText className="w-6 h-6 text-red-500" />;
|
||||||
|
}
|
||||||
|
if (['doc', 'docx', 'hwp', 'hwpx', 'pages'].includes(ext)) {
|
||||||
|
return <FileText className="w-6 h-6 text-blue-600" />;
|
||||||
|
}
|
||||||
|
if (['xls', 'xlsx', 'hcell', 'numbers'].includes(ext)) {
|
||||||
|
return <FileText className="w-6 h-6 text-green-600" />;
|
||||||
|
}
|
||||||
|
if (['ppt', 'pptx', 'hanshow', 'keynote'].includes(ext)) {
|
||||||
|
return <Presentation className="w-6 h-6 text-orange-500" />;
|
||||||
|
}
|
||||||
|
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) {
|
||||||
|
return <Archive className="w-6 h-6 text-gray-500" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <File className="w-6 h-6 text-gray-400" />;
|
||||||
|
};
|
||||||
|
|
||||||
export interface FileUploadComponentProps extends ComponentRendererProps {
|
export interface FileUploadComponentProps {
|
||||||
config?: FileUploadConfig;
|
component: any;
|
||||||
|
componentConfig: FileUploadConfig;
|
||||||
|
componentStyle: React.CSSProperties;
|
||||||
|
className: string;
|
||||||
|
isInteractive: boolean;
|
||||||
|
isDesignMode: boolean;
|
||||||
|
formData: any;
|
||||||
|
onFormDataChange: (data: any) => void;
|
||||||
|
onClick?: () => void;
|
||||||
|
onDragStart?: (e: React.DragEvent) => void;
|
||||||
|
onDragEnd?: (e: React.DragEvent) => void;
|
||||||
|
onUpdate?: (updates: Partial<any>) => void;
|
||||||
|
autoGeneration?: any;
|
||||||
|
hidden?: boolean;
|
||||||
|
onConfigChange?: (config: any) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* FileUpload 컴포넌트
|
|
||||||
* file-upload 컴포넌트입니다
|
|
||||||
*/
|
|
||||||
export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
component,
|
component,
|
||||||
isDesignMode = false,
|
componentConfig,
|
||||||
isSelected = false,
|
componentStyle,
|
||||||
isInteractive = false,
|
className,
|
||||||
|
isInteractive,
|
||||||
|
isDesignMode,
|
||||||
|
formData,
|
||||||
|
onFormDataChange,
|
||||||
onClick,
|
onClick,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
config,
|
onUpdate,
|
||||||
className,
|
|
||||||
style,
|
|
||||||
formData,
|
|
||||||
onFormDataChange,
|
|
||||||
...props
|
|
||||||
}) => {
|
}) => {
|
||||||
// 컴포넌트 설정
|
const [uploadedFiles, setUploadedFiles] = useState<FileInfo[]>([]);
|
||||||
const componentConfig = {
|
const [uploadStatus, setUploadStatus] = useState<FileUploadStatus>('idle');
|
||||||
...config,
|
const [dragOver, setDragOver] = useState(false);
|
||||||
...component.config,
|
const [viewerFile, setViewerFile] = useState<FileInfo | null>(null);
|
||||||
|
const [isViewerOpen, setIsViewerOpen] = useState(false);
|
||||||
|
const [forceUpdate, setForceUpdate] = useState(0);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// 컴포넌트 파일 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
const componentFiles = (component as any)?.uploadedFiles || [];
|
||||||
|
const lastUpdate = (component as any)?.lastFileUpdate;
|
||||||
|
|
||||||
|
// 전역 상태에서 최신 파일 정보 가져오기
|
||||||
|
const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {};
|
||||||
|
const globalFiles = globalFileState[component.id] || [];
|
||||||
|
|
||||||
|
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
|
||||||
|
const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles;
|
||||||
|
|
||||||
|
console.log("🔄 FileUploadComponent 파일 동기화:", {
|
||||||
|
componentId: component.id,
|
||||||
|
componentFiles: componentFiles.length,
|
||||||
|
globalFiles: globalFiles.length,
|
||||||
|
currentFiles: currentFiles.length,
|
||||||
|
uploadedFiles: uploadedFiles.length,
|
||||||
|
lastUpdate: lastUpdate
|
||||||
|
});
|
||||||
|
|
||||||
|
// localStorage에서 백업 파일 복원
|
||||||
|
try {
|
||||||
|
const backupKey = `fileUpload_${component.id}`;
|
||||||
|
const backupFiles = localStorage.getItem(backupKey);
|
||||||
|
if (backupFiles && currentFiles.length === 0) {
|
||||||
|
const parsedFiles = JSON.parse(backupFiles);
|
||||||
|
setUploadedFiles(parsedFiles);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("localStorage 백업 복원 실패:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최신 파일과 현재 파일 비교
|
||||||
|
if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) {
|
||||||
|
console.log("🔄 useEffect에서 파일 목록 변경 감지:", {
|
||||||
|
currentFiles: currentFiles.length,
|
||||||
|
uploadedFiles: uploadedFiles.length,
|
||||||
|
currentFilesData: currentFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })),
|
||||||
|
uploadedFilesData: uploadedFiles.map(f => ({ objid: f.objid, name: f.realFileName }))
|
||||||
|
});
|
||||||
|
setUploadedFiles(currentFiles);
|
||||||
|
setForceUpdate(prev => prev + 1);
|
||||||
|
}
|
||||||
|
}, [component.id, (component as any)?.uploadedFiles, (component as any)?.lastFileUpdate]);
|
||||||
|
|
||||||
|
// 전역 상태 변경 감지 (모든 파일 컴포넌트 동기화 + 화면 복원)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
||||||
|
const { componentId, files, fileCount, timestamp, isRestore } = event.detail;
|
||||||
|
|
||||||
|
console.log("🔄 FileUploadComponent 전역 상태 변경 감지:", {
|
||||||
|
currentComponentId: component.id,
|
||||||
|
eventComponentId: componentId,
|
||||||
|
isForThisComponent: componentId === component.id,
|
||||||
|
newFileCount: fileCount,
|
||||||
|
currentFileCount: uploadedFiles.length,
|
||||||
|
timestamp,
|
||||||
|
isRestore: !!isRestore
|
||||||
|
});
|
||||||
|
|
||||||
|
// 같은 컴포넌트 ID인 경우에만 업데이트
|
||||||
|
if (componentId === component.id) {
|
||||||
|
const logMessage = isRestore ? "🔄 화면 복원으로 파일 상태 동기화" : "✅ 파일 상태 동기화 적용";
|
||||||
|
console.log(logMessage, {
|
||||||
|
componentId: component.id,
|
||||||
|
이전파일수: uploadedFiles.length,
|
||||||
|
새파일수: files.length,
|
||||||
|
files: files.map((f: any) => ({ objid: f.objid, name: f.realFileName }))
|
||||||
|
});
|
||||||
|
|
||||||
|
setUploadedFiles(files);
|
||||||
|
setForceUpdate(prev => prev + 1);
|
||||||
|
|
||||||
|
// localStorage 백업도 업데이트
|
||||||
|
try {
|
||||||
|
const backupKey = `fileUpload_${component.id}`;
|
||||||
|
localStorage.setItem(backupKey, JSON.stringify(files));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("localStorage 백업 실패:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [component.id, uploadedFiles.length]);
|
||||||
|
|
||||||
|
// 파일 업로드 설정 - componentConfig가 undefined일 수 있으므로 안전하게 처리
|
||||||
|
const safeComponentConfig = componentConfig || {};
|
||||||
|
const fileConfig = {
|
||||||
|
accept: safeComponentConfig.accept || "*/*",
|
||||||
|
multiple: safeComponentConfig.multiple || false,
|
||||||
|
maxSize: safeComponentConfig.maxSize || 10 * 1024 * 1024, // 10MB
|
||||||
|
maxFiles: safeComponentConfig.maxFiles || 5,
|
||||||
|
...safeComponentConfig
|
||||||
} as FileUploadConfig;
|
} as FileUploadConfig;
|
||||||
|
|
||||||
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
|
// 파일 선택 핸들러
|
||||||
const componentStyle: React.CSSProperties = {
|
const handleFileSelect = useCallback(() => {
|
||||||
width: "100%",
|
if (fileInputRef.current) {
|
||||||
height: "100%",
|
fileInputRef.current.click();
|
||||||
...component.style,
|
}
|
||||||
...style,
|
}, []);
|
||||||
};
|
|
||||||
|
|
||||||
// 디자인 모드 스타일
|
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (isDesignMode) {
|
const files = Array.from(e.target.files || []);
|
||||||
componentStyle.border = "1px dashed #cbd5e1";
|
if (files.length > 0) {
|
||||||
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
|
handleFileUpload(files);
|
||||||
}
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 이벤트 핸들러
|
// 파일 업로드 처리
|
||||||
const handleClick = (e: React.MouseEvent) => {
|
const handleFileUpload = useCallback(async (files: File[]) => {
|
||||||
|
if (!files.length) return;
|
||||||
|
|
||||||
|
// 중복 파일 체크
|
||||||
|
const existingFileNames = uploadedFiles.map(f => f.realFileName.toLowerCase());
|
||||||
|
const duplicates: string[] = [];
|
||||||
|
const uniqueFiles: File[] = [];
|
||||||
|
|
||||||
|
console.log("🔍 중복 파일 체크:", {
|
||||||
|
uploadedFiles: uploadedFiles.length,
|
||||||
|
existingFileNames: existingFileNames,
|
||||||
|
newFiles: files.map(f => f.name.toLowerCase())
|
||||||
|
});
|
||||||
|
|
||||||
|
files.forEach(file => {
|
||||||
|
const fileName = file.name.toLowerCase();
|
||||||
|
if (existingFileNames.includes(fileName)) {
|
||||||
|
duplicates.push(file.name);
|
||||||
|
console.log("❌ 중복 파일 발견:", file.name);
|
||||||
|
} else {
|
||||||
|
uniqueFiles.push(file);
|
||||||
|
console.log("✅ 새로운 파일:", file.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("🔍 중복 체크 결과:", {
|
||||||
|
duplicates: duplicates,
|
||||||
|
uniqueFiles: uniqueFiles.map(f => f.name)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (duplicates.length > 0) {
|
||||||
|
toast.error(`중복된 파일이 있습니다: ${duplicates.join(', ')}`, {
|
||||||
|
description: "같은 이름의 파일이 이미 업로드되어 있습니다.",
|
||||||
|
duration: 4000
|
||||||
|
});
|
||||||
|
|
||||||
|
if (uniqueFiles.length === 0) {
|
||||||
|
return; // 모든 파일이 중복이면 업로드 중단
|
||||||
|
}
|
||||||
|
|
||||||
|
// 일부만 중복인 경우 고유한 파일만 업로드
|
||||||
|
toast.info(`${uniqueFiles.length}개의 새로운 파일만 업로드합니다.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filesToUpload = uniqueFiles.length > 0 ? uniqueFiles : files;
|
||||||
|
setUploadStatus('uploading');
|
||||||
|
toast.loading("파일을 업로드하는 중...", { id: 'file-upload' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// targetObjid 생성 (InteractiveDataTable과 호환)
|
||||||
|
const tableName = formData?.tableName || component.tableName || 'default_table';
|
||||||
|
const recordId = formData?.id || 'temp_record';
|
||||||
|
const columnName = component.columnName || component.id;
|
||||||
|
const targetObjid = `${tableName}:${recordId}:${columnName}`;
|
||||||
|
|
||||||
|
const uploadData = {
|
||||||
|
tableName: tableName,
|
||||||
|
fieldName: columnName,
|
||||||
|
recordId: recordId,
|
||||||
|
docType: component.fileConfig?.docType || 'DOCUMENT',
|
||||||
|
docTypeName: component.fileConfig?.docTypeName || '일반 문서',
|
||||||
|
targetObjid: targetObjid, // InteractiveDataTable 호환을 위한 targetObjid 추가
|
||||||
|
columnName: columnName, // 가상 파일 컬럼 지원
|
||||||
|
isVirtualFileColumn: true, // 가상 파일 컬럼으로 처리
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("📤 파일 업로드 시작:", {
|
||||||
|
originalFiles: files.length,
|
||||||
|
filesToUpload: filesToUpload.length,
|
||||||
|
files: filesToUpload.map(f => ({ name: f.name, size: f.size })),
|
||||||
|
uploadData
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await uploadFiles({
|
||||||
|
files: filesToUpload,
|
||||||
|
...uploadData
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("📤 파일 업로드 API 응답:", response);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
// FileUploadResponse 타입에 맞게 files 배열 사용
|
||||||
|
const fileData = response.files || (response as any).data || [];
|
||||||
|
console.log("📁 파일 데이터 확인:", {
|
||||||
|
hasFiles: !!response.files,
|
||||||
|
hasData: !!(response as any).data,
|
||||||
|
fileDataLength: fileData.length,
|
||||||
|
fileData: fileData,
|
||||||
|
responseKeys: Object.keys(response)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fileData.length === 0) {
|
||||||
|
throw new Error("업로드된 파일 데이터를 받지 못했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFiles = fileData.map((file: any) => ({
|
||||||
|
objid: file.objid || file.id,
|
||||||
|
savedFileName: file.saved_file_name || file.savedFileName,
|
||||||
|
realFileName: file.real_file_name || file.realFileName || file.name,
|
||||||
|
fileSize: file.file_size || file.fileSize || file.size,
|
||||||
|
fileExt: file.file_ext || file.fileExt || file.extension,
|
||||||
|
filePath: file.file_path || file.filePath || file.path,
|
||||||
|
docType: file.doc_type || file.docType,
|
||||||
|
docTypeName: file.doc_type_name || file.docTypeName,
|
||||||
|
targetObjid: file.target_objid || file.targetObjid,
|
||||||
|
parentTargetObjid: file.parent_target_objid || file.parentTargetObjid,
|
||||||
|
companyCode: file.company_code || file.companyCode,
|
||||||
|
writer: file.writer,
|
||||||
|
regdate: file.regdate,
|
||||||
|
status: file.status || 'ACTIVE',
|
||||||
|
uploadedAt: new Date().toISOString(),
|
||||||
|
...file
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log("📁 변환된 파일 데이터:", newFiles);
|
||||||
|
|
||||||
|
const updatedFiles = [...uploadedFiles, ...newFiles];
|
||||||
|
console.log("🔄 파일 상태 업데이트:", {
|
||||||
|
이전파일수: uploadedFiles.length,
|
||||||
|
새파일수: newFiles.length,
|
||||||
|
총파일수: updatedFiles.length,
|
||||||
|
updatedFiles: updatedFiles.map(f => ({ objid: f.objid, name: f.realFileName }))
|
||||||
|
});
|
||||||
|
|
||||||
|
setUploadedFiles(updatedFiles);
|
||||||
|
setUploadStatus('success');
|
||||||
|
|
||||||
|
// localStorage 백업
|
||||||
|
try {
|
||||||
|
const backupKey = `fileUpload_${component.id}`;
|
||||||
|
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("localStorage 백업 실패:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전역 상태 업데이트 (모든 파일 컴포넌트 동기화)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
// 전역 파일 상태 업데이트
|
||||||
|
const globalFileState = (window as any).globalFileState || {};
|
||||||
|
globalFileState[component.id] = updatedFiles;
|
||||||
|
(window as any).globalFileState = globalFileState;
|
||||||
|
|
||||||
|
// 모든 파일 컴포넌트에 동기화 이벤트 발생
|
||||||
|
const syncEvent = new CustomEvent('globalFileStateChanged', {
|
||||||
|
detail: {
|
||||||
|
componentId: component.id,
|
||||||
|
files: updatedFiles,
|
||||||
|
fileCount: updatedFiles.length,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.dispatchEvent(syncEvent);
|
||||||
|
|
||||||
|
console.log("🌐 전역 파일 상태 업데이트 및 동기화 이벤트 발생:", {
|
||||||
|
componentId: component.id,
|
||||||
|
fileCount: updatedFiles.length,
|
||||||
|
globalState: Object.keys(globalFileState).map(id => ({
|
||||||
|
id,
|
||||||
|
fileCount: globalFileState[id]?.length || 0
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컴포넌트 업데이트
|
||||||
|
if (onUpdate) {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
console.log("🔄 onUpdate 호출:", {
|
||||||
|
componentId: component.id,
|
||||||
|
uploadedFiles: updatedFiles.length,
|
||||||
|
timestamp: timestamp
|
||||||
|
});
|
||||||
|
onUpdate({
|
||||||
|
uploadedFiles: updatedFiles,
|
||||||
|
lastFileUpdate: timestamp
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ onUpdate 콜백이 없습니다!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그리드 파일 상태 새로고침 이벤트 발생
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const refreshEvent = new CustomEvent('refreshFileStatus', {
|
||||||
|
detail: {
|
||||||
|
tableName: tableName,
|
||||||
|
recordId: recordId,
|
||||||
|
columnName: columnName,
|
||||||
|
targetObjid: targetObjid,
|
||||||
|
fileCount: updatedFiles.length
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.dispatchEvent(refreshEvent);
|
||||||
|
console.log("🔄 그리드 파일 상태 새로고침 이벤트 발생:", {
|
||||||
|
tableName,
|
||||||
|
recordId,
|
||||||
|
columnName,
|
||||||
|
targetObjid,
|
||||||
|
fileCount: updatedFiles.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 폼 데이터 업데이트
|
||||||
|
if (onFormDataChange && component.columnName) {
|
||||||
|
const fileIds = updatedFiles.map(f => f.objid);
|
||||||
|
onFormDataChange({
|
||||||
|
...formData,
|
||||||
|
[component.columnName]: fileIds
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컴포넌트 설정 콜백
|
||||||
|
if (safeComponentConfig.onFileUpload) {
|
||||||
|
safeComponentConfig.onFileUpload(newFiles);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error("❌ 파일 업로드 실패:", response);
|
||||||
|
throw new Error(response.message || (response as any).error || '파일 업로드에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('파일 업로드 오류:', error);
|
||||||
|
setUploadStatus('error');
|
||||||
|
toast.dismiss();
|
||||||
|
toast.error(`파일 업로드 오류: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
|
||||||
|
}
|
||||||
|
}, [safeComponentConfig, uploadedFiles, onFormDataChange, component.columnName, component.id, formData]);
|
||||||
|
|
||||||
|
// 파일 다운로드
|
||||||
|
const handleFileDownload = useCallback(async (file: FileInfo) => {
|
||||||
|
try {
|
||||||
|
await downloadFile(file.objid, file.realFileName);
|
||||||
|
toast.success(`${file.realFileName} 다운로드 완료`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('파일 다운로드 오류:', error);
|
||||||
|
toast.error('파일 다운로드에 실패했습니다.');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 파일 삭제
|
||||||
|
const handleFileDelete = useCallback(async (file: FileInfo | string) => {
|
||||||
|
try {
|
||||||
|
const fileId = typeof file === 'string' ? file : file.objid;
|
||||||
|
const fileName = typeof file === 'string' ? '파일' : file.realFileName;
|
||||||
|
|
||||||
|
await deleteFile(fileId);
|
||||||
|
|
||||||
|
const updatedFiles = uploadedFiles.filter(f => f.objid !== fileId);
|
||||||
|
setUploadedFiles(updatedFiles);
|
||||||
|
|
||||||
|
// localStorage 백업 업데이트
|
||||||
|
try {
|
||||||
|
const backupKey = `fileUpload_${component.id}`;
|
||||||
|
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("localStorage 백업 업데이트 실패:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전역 상태 업데이트 (모든 파일 컴포넌트 동기화)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
// 전역 파일 상태 업데이트
|
||||||
|
const globalFileState = (window as any).globalFileState || {};
|
||||||
|
globalFileState[component.id] = updatedFiles;
|
||||||
|
(window as any).globalFileState = globalFileState;
|
||||||
|
|
||||||
|
// 모든 파일 컴포넌트에 동기화 이벤트 발생
|
||||||
|
const syncEvent = new CustomEvent('globalFileStateChanged', {
|
||||||
|
detail: {
|
||||||
|
componentId: component.id,
|
||||||
|
files: updatedFiles,
|
||||||
|
fileCount: updatedFiles.length,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.dispatchEvent(syncEvent);
|
||||||
|
|
||||||
|
console.log("🗑️ 파일 삭제 후 전역 상태 동기화:", {
|
||||||
|
componentId: component.id,
|
||||||
|
deletedFile: fileName,
|
||||||
|
remainingFiles: updatedFiles.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컴포넌트 업데이트
|
||||||
|
if (onUpdate) {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
onUpdate({
|
||||||
|
uploadedFiles: updatedFiles,
|
||||||
|
lastFileUpdate: timestamp
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(`${fileName} 삭제 완료`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('파일 삭제 오류:', error);
|
||||||
|
toast.error('파일 삭제에 실패했습니다.');
|
||||||
|
}
|
||||||
|
}, [uploadedFiles, onUpdate, component.id]);
|
||||||
|
|
||||||
|
// 파일 뷰어
|
||||||
|
const handleFileView = useCallback((file: FileInfo) => {
|
||||||
|
setViewerFile(file);
|
||||||
|
setIsViewerOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleViewerClose = useCallback(() => {
|
||||||
|
setIsViewerOpen(false);
|
||||||
|
setViewerFile(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 드래그 앤 드롭 핸들러
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onClick?.();
|
if (!safeComponentConfig.readonly && !safeComponentConfig.disabled) {
|
||||||
};
|
setDragOver(true);
|
||||||
|
}
|
||||||
|
}, [safeComponentConfig.readonly, safeComponentConfig.disabled]);
|
||||||
|
|
||||||
// DOM에 전달하면 안 되는 React-specific props 필터링
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
const {
|
e.preventDefault();
|
||||||
selectedScreen,
|
e.stopPropagation();
|
||||||
onZoneComponentDrop,
|
setDragOver(false);
|
||||||
onZoneClick,
|
}, []);
|
||||||
componentConfig: _componentConfig,
|
|
||||||
component: _component,
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
isSelected: _isSelected,
|
e.preventDefault();
|
||||||
onClick: _onClick,
|
e.stopPropagation();
|
||||||
onDragStart: _onDragStart,
|
setDragOver(false);
|
||||||
onDragEnd: _onDragEnd,
|
|
||||||
size: _size,
|
if (!safeComponentConfig.readonly && !safeComponentConfig.disabled) {
|
||||||
position: _position,
|
const files = Array.from(e.dataTransfer.files);
|
||||||
style: _style,
|
if (files.length > 0) {
|
||||||
screenId: _screenId,
|
handleFileUpload(files);
|
||||||
tableName: _tableName,
|
}
|
||||||
onRefresh: _onRefresh,
|
}
|
||||||
onClose: _onClose,
|
}, [safeComponentConfig.readonly, safeComponentConfig.disabled, handleFileUpload]);
|
||||||
...domProps
|
|
||||||
} = props;
|
// 클릭 핸들러
|
||||||
|
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!safeComponentConfig.readonly && !safeComponentConfig.disabled) {
|
||||||
|
handleFileSelect();
|
||||||
|
}
|
||||||
|
onClick?.();
|
||||||
|
}, [safeComponentConfig.readonly, safeComponentConfig.disabled, handleFileSelect, onClick]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={componentStyle} className={className} {...domProps}>
|
<div style={componentStyle} className={className}>
|
||||||
{/* 라벨 렌더링 */}
|
{/* 라벨 렌더링 */}
|
||||||
{component.label && component.style?.labelDisplay !== false && (
|
{component.label && component.style?.labelDisplay !== false && (
|
||||||
<label
|
<label
|
||||||
|
|
@ -86,115 +573,131 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||||
fontSize: component.style?.labelFontSize || "14px",
|
fontSize: component.style?.labelFontSize || "14px",
|
||||||
color: component.style?.labelColor || "#3b83f6",
|
color: component.style?.labelColor || "#3b83f6",
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
|
||||||
...(isInteractive && component.style ? component.style : {}),
|
...(isInteractive && component.style ? component.style : {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{component.label}
|
{component.label}
|
||||||
{component.required && (
|
{component.required && (
|
||||||
<span
|
<span style={{ color: "#ef4444" }}>*</span>
|
||||||
style={{
|
|
||||||
color: "#ef4444",
|
|
||||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
|
||||||
...(isInteractive && component.style ? component.style : {}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
*
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div className="w-full h-full flex flex-col space-y-2">
|
||||||
style={{
|
{/* 디자인 모드가 아닐 때만 파일 업로드 영역 표시 */}
|
||||||
width: "100%",
|
{!isDesignMode && (
|
||||||
height: "100%",
|
<div
|
||||||
border: "2px dashed #d1d5db",
|
className={`
|
||||||
borderRadius: "8px",
|
border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors
|
||||||
display: "flex",
|
${dragOver ? 'border-blue-400 bg-blue-50' : 'border-gray-300'}
|
||||||
flexDirection: "column",
|
${safeComponentConfig.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-gray-400'}
|
||||||
alignItems: "center",
|
${uploadStatus === 'uploading' ? 'opacity-75' : ''}
|
||||||
justifyContent: "center",
|
`}
|
||||||
cursor: "pointer",
|
|
||||||
backgroundColor: "#f9fafb",
|
|
||||||
position: "relative",
|
|
||||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
|
||||||
...(isInteractive && component.style ? component.style : {}),
|
|
||||||
}}
|
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
multiple={componentConfig.multiple || false}
|
multiple={safeComponentConfig.multiple}
|
||||||
accept={componentConfig.accept || "*/*"}
|
accept={safeComponentConfig.accept}
|
||||||
disabled={componentConfig.disabled || false}
|
onChange={handleInputChange}
|
||||||
required={componentConfig.required || false}
|
className="hidden"
|
||||||
style={{
|
disabled={safeComponentConfig.disabled}
|
||||||
position: "absolute",
|
/>
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
{uploadStatus === 'uploading' ? (
|
||||||
opacity: 0,
|
<div className="flex flex-col items-center space-y-2">
|
||||||
cursor: "pointer",
|
<div className="flex items-center space-x-2">
|
||||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
...(isInteractive && component.style ? component.style : {}),
|
<span className="text-blue-600 font-medium">업로드 중...</span>
|
||||||
}}
|
</div>
|
||||||
onChange={(e) => {
|
</div>
|
||||||
if (component.onChange) {
|
) : (
|
||||||
const files = Array.from(e.target.files || []);
|
<>
|
||||||
component.onChange(componentConfig.multiple ? files : files[0]);
|
<div>
|
||||||
}
|
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
||||||
}}
|
<p className="text-lg font-medium text-gray-900 mb-2">
|
||||||
/>
|
{safeComponentConfig.dragDropText || "파일을 드래그하거나 클릭하여 업로드하세요"}
|
||||||
<div
|
</p>
|
||||||
style={{
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
textAlign: "center",
|
{safeComponentConfig.accept && `지원 형식: ${safeComponentConfig.accept}`}
|
||||||
color: "#6b7280",
|
{safeComponentConfig.maxSize && ` • 최대 ${formatFileSize(safeComponentConfig.maxSize)}`}
|
||||||
fontSize: "14px",
|
{safeComponentConfig.multiple && ' • 여러 파일 선택 가능'}
|
||||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
</p>
|
||||||
...(isInteractive && component.style ? component.style : {}),
|
</div>
|
||||||
}}
|
</>
|
||||||
>
|
)}
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "24px",
|
|
||||||
marginBottom: "8px",
|
|
||||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
|
||||||
...(isInteractive && component.style ? component.style : {}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
📁
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
)}
|
||||||
style={{
|
|
||||||
fontWeight: "500",
|
{/* 업로드된 파일 목록 - 디자인 모드에서는 항상 표시 */}
|
||||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
{(uploadedFiles.length > 0 || isDesignMode) && (
|
||||||
...(isInteractive && component.style ? component.style : {}),
|
<div className="flex-1 overflow-y-auto">
|
||||||
}}
|
<div className="space-y-2">
|
||||||
>
|
<div className="flex items-center justify-between">
|
||||||
파일을 선택하거나 드래그하세요
|
<h4 className="text-sm font-medium text-gray-700">
|
||||||
|
업로드된 파일 ({uploadedFiles.length})
|
||||||
|
</h4>
|
||||||
|
{uploadedFiles.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
총 {formatFileSize(uploadedFiles.reduce((sum, file) => sum + file.fileSize, 0))}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{uploadedFiles.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{uploadedFiles.map((file) => (
|
||||||
|
<div key={file.objid} className="flex items-center space-x-2 p-2 bg-gray-50 rounded text-sm">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{getFileIcon(file.fileExt)}
|
||||||
|
</div>
|
||||||
|
<span className="flex-1 truncate text-gray-900">
|
||||||
|
{file.realFileName}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{formatFileSize(file.fileSize)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="text-xs text-gray-500 mt-2 text-center">
|
||||||
|
💡 파일 관리는 상세설정에서 가능합니다
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||||
|
<File className="w-12 h-12 mb-3 text-gray-300" />
|
||||||
|
<p className="text-sm font-medium">업로드된 파일이 없습니다</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">상세설정에서 파일을 업로드하세요</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "12px",
|
|
||||||
marginTop: "4px",
|
|
||||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
|
||||||
...(isInteractive && component.style ? component.style : {}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{componentConfig.accept && `지원 형식: ${componentConfig.accept}`}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{/* 도움말 텍스트 */}
|
||||||
|
{safeComponentConfig.helperText && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
{safeComponentConfig.helperText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 파일뷰어 모달 */}
|
||||||
|
<FileViewerModal
|
||||||
|
file={viewerFile}
|
||||||
|
isOpen={isViewerOpen}
|
||||||
|
onClose={handleViewerClose}
|
||||||
|
onDownload={handleFileDownload}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export { FileUploadComponent };
|
||||||
* FileUpload 래퍼 컴포넌트
|
export default FileUploadComponent;
|
||||||
* 추가적인 로직이나 상태 관리가 필요한 경우 사용
|
|
||||||
*/
|
|
||||||
export const FileUploadWrapper: React.FC<FileUploadComponentProps> = (props) => {
|
|
||||||
return <FileUploadComponent {...props} />;
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,310 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { FileInfo } from "./types";
|
||||||
|
import { Download, X, AlertTriangle, FileText, Image as ImageIcon } from "lucide-react";
|
||||||
|
import { formatFileSize } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface FileViewerModalProps {
|
||||||
|
file: FileInfo | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onDownload?: (file: FileInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일 뷰어 모달 컴포넌트
|
||||||
|
* 다양한 파일 타입에 대한 미리보기 기능 제공
|
||||||
|
*/
|
||||||
|
export const FileViewerModal: React.FC<FileViewerModalProps> = ({
|
||||||
|
file,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onDownload,
|
||||||
|
}) => {
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// 파일이 변경될 때마다 미리보기 URL 생성
|
||||||
|
useEffect(() => {
|
||||||
|
if (!file || !isOpen) {
|
||||||
|
setPreviewUrl(null);
|
||||||
|
setPreviewError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setPreviewError(null);
|
||||||
|
|
||||||
|
// 로컬 파일인 경우
|
||||||
|
if (file._file) {
|
||||||
|
const url = URL.createObjectURL(file._file);
|
||||||
|
setPreviewUrl(url);
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
|
return () => URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서버 파일인 경우 - 미리보기 API 호출
|
||||||
|
const generatePreviewUrl = async () => {
|
||||||
|
try {
|
||||||
|
const fileExt = file.fileExt.toLowerCase();
|
||||||
|
|
||||||
|
// 미리보기 지원 파일 타입 정의
|
||||||
|
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
|
||||||
|
const documentExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'rtf', 'odt', 'ods', 'odp', 'hwp', 'hwpx', 'hwpml', 'hcdt', 'hpt', 'pages', 'numbers', 'keynote'];
|
||||||
|
const textExtensions = ['txt', 'md', 'json', 'xml', 'csv'];
|
||||||
|
const mediaExtensions = ['mp4', 'webm', 'ogg', 'mp3', 'wav'];
|
||||||
|
|
||||||
|
const supportedExtensions = [
|
||||||
|
...imageExtensions,
|
||||||
|
...documentExtensions,
|
||||||
|
...textExtensions,
|
||||||
|
...mediaExtensions
|
||||||
|
];
|
||||||
|
|
||||||
|
if (supportedExtensions.includes(fileExt)) {
|
||||||
|
// 실제 환경에서는 파일 서빙 API 엔드포인트 사용
|
||||||
|
const url = `/api/files/preview/${file.objid}`;
|
||||||
|
setPreviewUrl(url);
|
||||||
|
} else {
|
||||||
|
// 지원하지 않는 파일 타입
|
||||||
|
setPreviewError(`${file.fileExt.toUpperCase()} 파일은 미리보기를 지원하지 않습니다.`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('미리보기 URL 생성 오류:', error);
|
||||||
|
setPreviewError('미리보기를 불러오는데 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
generatePreviewUrl();
|
||||||
|
}, [file, isOpen]);
|
||||||
|
|
||||||
|
if (!file) return null;
|
||||||
|
|
||||||
|
// 파일 타입별 미리보기 컴포넌트
|
||||||
|
const renderPreview = () => {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previewError) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-96 text-gray-500">
|
||||||
|
<AlertTriangle className="w-16 h-16 mb-4" />
|
||||||
|
<p className="text-lg font-medium mb-2">미리보기 불가</p>
|
||||||
|
<p className="text-sm text-center">{previewError}</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onDownload?.(file)}
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
파일 다운로드
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileExt = file.fileExt.toLowerCase();
|
||||||
|
|
||||||
|
// 이미지 파일
|
||||||
|
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(fileExt)) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center max-h-96 overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={previewUrl || ''}
|
||||||
|
alt={file.realFileName}
|
||||||
|
className="max-w-full max-h-full object-contain rounded-lg"
|
||||||
|
onError={() => setPreviewError('이미지를 불러올 수 없습니다.')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 텍스트 파일
|
||||||
|
if (['txt', 'md', 'json', 'xml', 'csv'].includes(fileExt)) {
|
||||||
|
return (
|
||||||
|
<div className="h-96 overflow-auto">
|
||||||
|
<iframe
|
||||||
|
src={previewUrl || ''}
|
||||||
|
className="w-full h-full border rounded-lg"
|
||||||
|
title={`${file.realFileName} 미리보기`}
|
||||||
|
onError={() => setPreviewError('텍스트 파일을 불러올 수 없습니다.')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PDF 파일
|
||||||
|
if (fileExt === 'pdf') {
|
||||||
|
return (
|
||||||
|
<div className="h-96">
|
||||||
|
<iframe
|
||||||
|
src={previewUrl || ''}
|
||||||
|
className="w-full h-full border rounded-lg"
|
||||||
|
title={`${file.realFileName} 미리보기`}
|
||||||
|
onError={() => setPreviewError('PDF 파일을 불러올 수 없습니다.')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Microsoft Office, 한컴오피스, Apple iWork 문서 파일
|
||||||
|
if (['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'hwp', 'hwpx', 'hwpml', 'hcdt', 'hpt', 'pages', 'numbers', 'keynote'].includes(fileExt)) {
|
||||||
|
// Office 파일은 Google Docs Viewer 또는 Microsoft Office Online을 통해 미리보기
|
||||||
|
const officeViewerUrl = `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(previewUrl || '')}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-96">
|
||||||
|
<iframe
|
||||||
|
src={officeViewerUrl}
|
||||||
|
className="w-full h-full border rounded-lg"
|
||||||
|
title={`${file.realFileName} 미리보기`}
|
||||||
|
onError={() => setPreviewError('Office 문서를 불러올 수 없습니다. 파일을 다운로드하여 확인해주세요.')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기타 문서 파일 (RTF, ODT 등)
|
||||||
|
if (['rtf', 'odt', 'ods', 'odp'].includes(fileExt)) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-96 text-gray-500">
|
||||||
|
<FileText className="w-16 h-16 mb-4 text-blue-500" />
|
||||||
|
<p className="text-lg font-medium mb-2">{file.fileExt.toUpperCase()} 문서</p>
|
||||||
|
<p className="text-sm text-center mb-4">
|
||||||
|
{file.realFileName}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col items-center space-y-2 text-xs text-gray-400">
|
||||||
|
<p>파일 크기: {formatFileSize(file.fileSize)}</p>
|
||||||
|
<p>문서 타입: {file.docTypeName || '일반 문서'}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onDownload?.(file)}
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
파일 다운로드
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비디오 파일
|
||||||
|
if (['mp4', 'webm', 'ogg'].includes(fileExt)) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<video
|
||||||
|
controls
|
||||||
|
className="max-w-full max-h-96 rounded-lg"
|
||||||
|
onError={() => setPreviewError('비디오를 재생할 수 없습니다.')}
|
||||||
|
>
|
||||||
|
<source src={previewUrl || ''} type={`video/${fileExt}`} />
|
||||||
|
브라우저가 비디오 재생을 지원하지 않습니다.
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 오디오 파일
|
||||||
|
if (['mp3', 'wav', 'ogg'].includes(fileExt)) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-96">
|
||||||
|
<div className="w-32 h-32 bg-gray-100 rounded-full flex items-center justify-center mb-6">
|
||||||
|
<svg className="w-16 h-16 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM15.657 6.343a1 1 0 011.414 0A9.972 9.972 0 0119 12a9.972 9.972 0 01-1.929 5.657 1 1 0 11-1.414-1.414A7.971 7.971 0 0017 12c0-1.594-.471-3.078-1.343-4.343a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 12a5.984 5.984 0 01-.757 2.829 1 1 0 01-1.415-1.414A3.987 3.987 0 0013 12a3.988 3.988 0 00-.172-1.171 1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<audio
|
||||||
|
controls
|
||||||
|
className="w-full max-w-md"
|
||||||
|
onError={() => setPreviewError('오디오를 재생할 수 없습니다.')}
|
||||||
|
>
|
||||||
|
<source src={previewUrl || ''} type={`audio/${fileExt}`} />
|
||||||
|
브라우저가 오디오 재생을 지원하지 않습니다.
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기타 파일 타입
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-96 text-gray-500">
|
||||||
|
<FileText className="w-16 h-16 mb-4" />
|
||||||
|
<p className="text-lg font-medium mb-2">미리보기 불가</p>
|
||||||
|
<p className="text-sm text-center mb-4">
|
||||||
|
{file.fileExt.toUpperCase()} 파일은 미리보기를 지원하지 않습니다.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onDownload?.(file)}
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
파일 다운로드
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<DialogTitle className="text-lg font-semibold truncate">
|
||||||
|
{file.realFileName}
|
||||||
|
</DialogTitle>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{file.fileExt.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDownload?.(file)}
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
다운로드
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 파일 정보 */}
|
||||||
|
<div className="flex items-center space-x-4 text-sm text-gray-500 mt-2">
|
||||||
|
<span>크기: {formatFileSize(file.fileSize)}</span>
|
||||||
|
{file.uploadedAt && (
|
||||||
|
<span>업로드: {new Date(file.uploadedAt).toLocaleString()}</span>
|
||||||
|
)}
|
||||||
|
{file.writer && <span>작성자: {file.writer}</span>}
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* 파일 미리보기 영역 */}
|
||||||
|
<div className="flex-1 overflow-auto py-4">
|
||||||
|
{renderPreview()}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -4,7 +4,7 @@ import React from "react";
|
||||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||||
import { ComponentCategory } from "@/types/component";
|
import { ComponentCategory } from "@/types/component";
|
||||||
import type { WebType } from "@/types/screen";
|
import type { WebType } from "@/types/screen";
|
||||||
import { FileUploadWrapper } from "./FileUploadComponent";
|
import { FileUploadComponent } from "./FileUploadComponent";
|
||||||
import { FileUploadConfigPanel } from "./FileUploadConfigPanel";
|
import { FileUploadConfigPanel } from "./FileUploadConfigPanel";
|
||||||
import { FileUploadConfig } from "./types";
|
import { FileUploadConfig } from "./types";
|
||||||
|
|
||||||
|
|
@ -19,7 +19,7 @@ export const FileUploadDefinition = createComponentDefinition({
|
||||||
description: "파일 업로드를 위한 파일 선택 컴포넌트",
|
description: "파일 업로드를 위한 파일 선택 컴포넌트",
|
||||||
category: ComponentCategory.INPUT,
|
category: ComponentCategory.INPUT,
|
||||||
webType: "file",
|
webType: "file",
|
||||||
component: FileUploadWrapper,
|
component: FileUploadComponent,
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
placeholder: "입력하세요",
|
placeholder: "입력하세요",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,50 @@
|
||||||
|
|
||||||
import { ComponentConfig } from "@/types/component";
|
import { ComponentConfig } from "@/types/component";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일 정보 인터페이스 (AttachedFileInfo와 호환)
|
||||||
|
*/
|
||||||
|
export interface FileInfo {
|
||||||
|
// AttachedFileInfo 기본 속성들
|
||||||
|
objid: string;
|
||||||
|
savedFileName: string;
|
||||||
|
realFileName: string;
|
||||||
|
fileSize: number;
|
||||||
|
fileExt: string;
|
||||||
|
filePath: string;
|
||||||
|
docType: string;
|
||||||
|
docTypeName: string;
|
||||||
|
targetObjid: string;
|
||||||
|
parentTargetObjid?: string;
|
||||||
|
companyCode: string;
|
||||||
|
writer: string;
|
||||||
|
regdate: string;
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
// 추가 호환성 속성들
|
||||||
|
path?: string; // filePath와 동일
|
||||||
|
name?: string; // realFileName과 동일
|
||||||
|
id?: string; // objid와 동일
|
||||||
|
size?: number; // fileSize와 동일
|
||||||
|
type?: string; // docType과 동일
|
||||||
|
uploadedAt?: string; // regdate와 동일
|
||||||
|
_file?: File; // 로컬 파일 객체 (업로드 전)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FileUpload 컴포넌트 설정 타입
|
* FileUpload 컴포넌트 설정 타입
|
||||||
*/
|
*/
|
||||||
export interface FileUploadConfig extends ComponentConfig {
|
export interface FileUploadConfig extends ComponentConfig {
|
||||||
// file 관련 설정
|
// file 관련 설정
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
multiple?: boolean;
|
||||||
|
accept?: string;
|
||||||
|
maxSize?: number; // bytes
|
||||||
|
|
||||||
// 공통 설정
|
// 공통 설정
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
placeholder?: string;
|
|
||||||
helperText?: string;
|
helperText?: string;
|
||||||
|
|
||||||
// 스타일 관련
|
// 스타일 관련
|
||||||
|
|
@ -25,6 +57,9 @@ export interface FileUploadConfig extends ComponentConfig {
|
||||||
onFocus?: () => void;
|
onFocus?: () => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
onFileUpload?: (files: FileInfo[]) => void;
|
||||||
|
onFileDelete?: (fileId: string) => void;
|
||||||
|
onFileDownload?: (file: FileInfo) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -38,9 +73,30 @@ export interface FileUploadProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
|
|
||||||
|
// 파일 관련
|
||||||
|
uploadedFiles?: FileInfo[];
|
||||||
|
|
||||||
// 이벤트 핸들러
|
// 이벤트 핸들러
|
||||||
onChange?: (value: any) => void;
|
onChange?: (value: any) => void;
|
||||||
onFocus?: () => void;
|
onFocus?: () => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
onFileUpload?: (files: FileInfo[]) => void;
|
||||||
|
onFileDelete?: (fileId: string) => void;
|
||||||
|
onFileDownload?: (file: FileInfo) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일 업로드 상태 타입
|
||||||
|
*/
|
||||||
|
export type FileUploadStatus = 'idle' | 'uploading' | 'success' | 'error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일 업로드 응답 타입
|
||||||
|
*/
|
||||||
|
export interface FileUploadResponse {
|
||||||
|
success: boolean;
|
||||||
|
data?: FileInfo[];
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Eye, Edit, Trash2, MoreHorizontal } from "lucide-react";
|
||||||
|
import { CardDisplayConfig, ColumnConfig } from "./types";
|
||||||
|
|
||||||
|
interface CardModeRendererProps {
|
||||||
|
data: Record<string, any>[];
|
||||||
|
cardConfig: CardDisplayConfig;
|
||||||
|
visibleColumns: ColumnConfig[];
|
||||||
|
onRowClick?: (row: Record<string, any>) => void;
|
||||||
|
onRowSelect?: (row: Record<string, any>, selected: boolean) => void;
|
||||||
|
selectedRows?: string[];
|
||||||
|
showActions?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카드 모드 렌더러
|
||||||
|
* 테이블 데이터를 카드 형태로 표시
|
||||||
|
*/
|
||||||
|
export const CardModeRenderer: React.FC<CardModeRendererProps> = ({
|
||||||
|
data,
|
||||||
|
cardConfig,
|
||||||
|
visibleColumns,
|
||||||
|
onRowClick,
|
||||||
|
onRowSelect,
|
||||||
|
selectedRows = [],
|
||||||
|
showActions = true,
|
||||||
|
}) => {
|
||||||
|
// 기본값 설정
|
||||||
|
const config = {
|
||||||
|
cardsPerRow: 3,
|
||||||
|
cardSpacing: 16,
|
||||||
|
showActions: true,
|
||||||
|
cardHeight: "auto",
|
||||||
|
...cardConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 카드 그리드 스타일 계산
|
||||||
|
const gridStyle: React.CSSProperties = {
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${config.cardsPerRow}, 1fr)`,
|
||||||
|
gap: `${config.cardSpacing}px`,
|
||||||
|
padding: `${config.cardSpacing}px`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 카드 높이 스타일
|
||||||
|
const cardStyle: React.CSSProperties = {
|
||||||
|
height: config.cardHeight === "auto" ? "auto" : `${config.cardHeight}px`,
|
||||||
|
cursor: onRowClick ? "pointer" : "default",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컬럼 값 가져오기 함수
|
||||||
|
const getColumnValue = (row: Record<string, any>, columnName?: string): string => {
|
||||||
|
if (!columnName || !row) return "";
|
||||||
|
return String(row[columnName] || "");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 액션 버튼 렌더링
|
||||||
|
const renderActions = (row: Record<string, any>) => {
|
||||||
|
if (!showActions || !config.showActions) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end space-x-1 mt-3 pt-3 border-t border-gray-100">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// 상세보기 액션
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// 편집 액션
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// 삭제 액션
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// 더보기 액션
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 데이터가 없는 경우
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div className="w-16 h-16 bg-gradient-to-br from-slate-100 to-slate-200 rounded-2xl flex items-center justify-center mb-4">
|
||||||
|
<div className="w-8 h-8 bg-slate-300 rounded-lg"></div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-slate-600 mb-1">표시할 데이터가 없습니다</div>
|
||||||
|
<div className="text-xs text-slate-400">조건을 변경하거나 새로운 데이터를 추가해보세요</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={gridStyle} className="w-full">
|
||||||
|
{data.map((row, index) => {
|
||||||
|
const idValue = getColumnValue(row, config.idColumn);
|
||||||
|
const titleValue = getColumnValue(row, config.titleColumn);
|
||||||
|
const subtitleValue = getColumnValue(row, config.subtitleColumn);
|
||||||
|
const descriptionValue = getColumnValue(row, config.descriptionColumn);
|
||||||
|
const imageValue = getColumnValue(row, config.imageColumn);
|
||||||
|
|
||||||
|
const isSelected = selectedRows.includes(idValue);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={`card-${index}-${idValue}`}
|
||||||
|
style={cardStyle}
|
||||||
|
className={`transition-all duration-200 hover:shadow-md ${
|
||||||
|
isSelected ? "ring-2 ring-blue-500 bg-blue-50/30" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => onRowClick?.(row)}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="text-sm font-medium truncate">
|
||||||
|
{titleValue || "제목 없음"}
|
||||||
|
</CardTitle>
|
||||||
|
{subtitleValue && (
|
||||||
|
<div className="text-xs text-gray-500 mt-1 truncate">
|
||||||
|
{subtitleValue}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ID 뱃지 */}
|
||||||
|
{idValue && (
|
||||||
|
<Badge variant="secondary" className="ml-2 text-xs">
|
||||||
|
{idValue}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
{/* 이미지 표시 */}
|
||||||
|
{imageValue && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<img
|
||||||
|
src={imageValue}
|
||||||
|
alt={titleValue}
|
||||||
|
className="w-full h-24 object-cover rounded-md bg-gray-100"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
target.style.display = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 설명 표시 */}
|
||||||
|
{descriptionValue && (
|
||||||
|
<div className="text-xs text-gray-600 line-clamp-2 mb-3">
|
||||||
|
{descriptionValue}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 추가 필드들 표시 (선택적) */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{visibleColumns
|
||||||
|
.filter(col =>
|
||||||
|
col.columnName !== config.idColumn &&
|
||||||
|
col.columnName !== config.titleColumn &&
|
||||||
|
col.columnName !== config.subtitleColumn &&
|
||||||
|
col.columnName !== config.descriptionColumn &&
|
||||||
|
col.columnName !== config.imageColumn &&
|
||||||
|
col.columnName !== "__checkbox__" &&
|
||||||
|
col.visible
|
||||||
|
)
|
||||||
|
.slice(0, 3) // 최대 3개 추가 필드만 표시
|
||||||
|
.map((col) => {
|
||||||
|
const value = getColumnValue(row, col.columnName);
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={col.columnName} className="flex justify-between items-center text-xs">
|
||||||
|
<span className="text-gray-500 truncate">{col.displayName}:</span>
|
||||||
|
<span className="font-medium truncate ml-2">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 버튼들 */}
|
||||||
|
{renderActions(row)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -24,6 +24,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearchFilters";
|
import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearchFilters";
|
||||||
import { SingleTableWithSticky } from "./SingleTableWithSticky";
|
import { SingleTableWithSticky } from "./SingleTableWithSticky";
|
||||||
|
import { CardModeRenderer } from "./CardModeRenderer";
|
||||||
|
|
||||||
export interface TableListComponentProps {
|
export interface TableListComponentProps {
|
||||||
component: any;
|
component: any;
|
||||||
|
|
@ -390,7 +391,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
console.log("🔗 Entity 조인 컬럼:", entityJoinColumns);
|
console.log("🔗 Entity 조인 컬럼:", entityJoinColumns);
|
||||||
console.log("🔗 조인 탭 컬럼:", joinTabColumns);
|
console.log("🔗 조인 탭 컬럼:", joinTabColumns);
|
||||||
console.log("🔗 추가 Entity 조인 컬럼:", additionalJoinColumns);
|
console.log("🔗 추가 Entity 조인 컬럼:", additionalJoinColumns);
|
||||||
console.log("🎯 화면별 엔티티 설정:", screenEntityConfigs);
|
// console.log("🎯 화면별 엔티티 설정:", screenEntityConfigs);
|
||||||
|
|
||||||
const result = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
const result = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
|
|
@ -463,10 +464,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
console.log("🎯 API 응답 결과:", result);
|
// console.log("🎯 API 응답 결과:", result);
|
||||||
console.log("🎯 데이터 개수:", result.data?.length || 0);
|
// console.log("🎯 데이터 개수:", result.data?.length || 0);
|
||||||
console.log("🎯 전체 페이지:", result.totalPages);
|
// console.log("🎯 전체 페이지:", result.totalPages);
|
||||||
console.log("🎯 총 아이템:", result.total);
|
// console.log("🎯 총 아이템:", result.total);
|
||||||
setData(result.data || []);
|
setData(result.data || []);
|
||||||
setTotalPages(result.totalPages || 1);
|
setTotalPages(result.totalPages || 1);
|
||||||
setTotalItems(result.total || 0);
|
setTotalItems(result.total || 0);
|
||||||
|
|
@ -642,9 +643,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
// 🎯 표시할 컬럼 상태 업데이트
|
// 🎯 표시할 컬럼 상태 업데이트
|
||||||
setDisplayColumns(processedColumns);
|
setDisplayColumns(processedColumns);
|
||||||
console.log("🎯 displayColumns 업데이트됨:", processedColumns);
|
// console.log("🎯 displayColumns 업데이트됨:", processedColumns);
|
||||||
console.log("🎯 데이터 개수:", result.data?.length || 0);
|
// console.log("🎯 데이터 개수:", result.data?.length || 0);
|
||||||
console.log("🎯 전체 데이터:", result.data);
|
// console.log("🎯 전체 데이터:", result.data);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("테이블 데이터 로딩 오류:", err);
|
console.error("테이블 데이터 로딩 오류:", err);
|
||||||
|
|
@ -661,7 +662,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
// 상세설정에 현재 페이지 정보 알림 (필요한 경우)
|
// 상세설정에 현재 페이지 정보 알림 (필요한 경우)
|
||||||
if (onConfigChange && tableConfig.pagination) {
|
if (onConfigChange && tableConfig.pagination) {
|
||||||
console.log("📤 테이블에서 페이지 변경을 상세설정에 알림:", newPage);
|
// console.log("📤 테이블에서 페이지 변경을 상세설정에 알림:", newPage);
|
||||||
onConfigChange({
|
onConfigChange({
|
||||||
...tableConfig,
|
...tableConfig,
|
||||||
pagination: {
|
pagination: {
|
||||||
|
|
@ -670,7 +671,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else if (!onConfigChange) {
|
} else if (!onConfigChange) {
|
||||||
console.warn("⚠️ onConfigChange가 정의되지 않음 - 페이지 변경 상세설정과 연동 불가");
|
// console.warn("⚠️ onConfigChange가 정의되지 않음 - 페이지 변경 상세설정과 연동 불가");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -839,14 +840,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 페이지 크기 동기화
|
// 페이지 크기 동기화
|
||||||
if (tableConfig.pagination?.pageSize && tableConfig.pagination.pageSize !== localPageSize) {
|
if (tableConfig.pagination?.pageSize && tableConfig.pagination.pageSize !== localPageSize) {
|
||||||
console.log("🔄 상세설정에서 페이지 크기 변경 감지:", tableConfig.pagination.pageSize);
|
// console.log("🔄 상세설정에서 페이지 크기 변경 감지:", tableConfig.pagination.pageSize);
|
||||||
setLocalPageSize(tableConfig.pagination.pageSize);
|
setLocalPageSize(tableConfig.pagination.pageSize);
|
||||||
setCurrentPage(1); // 페이지를 1로 리셋
|
setCurrentPage(1); // 페이지를 1로 리셋
|
||||||
}
|
}
|
||||||
|
|
||||||
// 현재 페이지 동기화 (상세설정에서 페이지를 직접 변경한 경우)
|
// 현재 페이지 동기화 (상세설정에서 페이지를 직접 변경한 경우)
|
||||||
if (tableConfig.pagination?.currentPage && tableConfig.pagination.currentPage !== currentPage) {
|
if (tableConfig.pagination?.currentPage && tableConfig.pagination.currentPage !== currentPage) {
|
||||||
console.log("🔄 상세설정에서 현재 페이지 변경 감지:", tableConfig.pagination.currentPage);
|
// console.log("🔄 상세설정에서 현재 페이지 변경 감지:", tableConfig.pagination.currentPage);
|
||||||
setCurrentPage(tableConfig.pagination.currentPage);
|
setCurrentPage(tableConfig.pagination.currentPage);
|
||||||
}
|
}
|
||||||
}, [tableConfig.pagination?.pageSize, tableConfig.pagination?.currentPage]);
|
}, [tableConfig.pagination?.pageSize, tableConfig.pagination?.currentPage]);
|
||||||
|
|
@ -890,7 +891,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
})
|
})
|
||||||
.sort((a, b) => a.order - b.order);
|
.sort((a, b) => a.order - b.order);
|
||||||
} else {
|
} else {
|
||||||
console.log("🎯 사용할 컬럼이 없음");
|
// console.log("🎯 사용할 컬럼이 없음");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1290,6 +1291,29 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<div className="mt-1 text-xs text-red-500 bg-red-50 px-3 py-1 rounded-full">{error}</div>
|
<div className="mt-1 text-xs text-red-500 bg-red-50 px-3 py-1 rounded-full">{error}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : tableConfig.displayMode === "card" ? (
|
||||||
|
// 카드 모드 렌더링
|
||||||
|
<div className="w-full h-full overflow-y-auto">
|
||||||
|
<CardModeRenderer
|
||||||
|
data={data}
|
||||||
|
cardConfig={tableConfig.cardConfig || {
|
||||||
|
idColumn: "id",
|
||||||
|
titleColumn: "name",
|
||||||
|
cardsPerRow: 3,
|
||||||
|
cardSpacing: 16,
|
||||||
|
showActions: true,
|
||||||
|
}}
|
||||||
|
visibleColumns={visibleColumns}
|
||||||
|
onRowClick={handleRowClick}
|
||||||
|
onRowSelect={(row, selected) => {
|
||||||
|
const rowIndex = data.findIndex(d => d === row);
|
||||||
|
const rowKey = getRowKey(row, rowIndex);
|
||||||
|
handleRowSelection(rowKey, selected);
|
||||||
|
}}
|
||||||
|
selectedRows={Array.from(selectedRows)}
|
||||||
|
showActions={tableConfig.actions?.showActions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : needsHorizontalScroll ? (
|
) : needsHorizontalScroll ? (
|
||||||
// 가로 스크롤이 필요한 경우 - 단일 테이블에서 sticky 컬럼 사용
|
// 가로 스크롤이 필요한 경우 - 단일 테이블에서 sticky 컬럼 사용
|
||||||
<div className="w-full overflow-hidden">
|
<div className="w-full overflow-hidden">
|
||||||
|
|
@ -1522,15 +1546,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<select
|
<select
|
||||||
value={localPageSize}
|
value={localPageSize}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
console.log("🚀 페이지 크기 드롭다운 변경 감지:", e.target.value);
|
// console.log("🚀 페이지 크기 드롭다운 변경 감지:", e.target.value);
|
||||||
const newPageSize = parseInt(e.target.value);
|
const newPageSize = parseInt(e.target.value);
|
||||||
|
|
||||||
console.log("🎯 페이지 크기 변경 이벤트:", {
|
// console.log("🎯 페이지 크기 변경 이벤트:", {
|
||||||
from: localPageSize,
|
// from: localPageSize,
|
||||||
to: newPageSize,
|
// to: newPageSize,
|
||||||
hasOnConfigChange: !!onConfigChange,
|
// hasOnConfigChange: !!onConfigChange,
|
||||||
onConfigChangeType: typeof onConfigChange
|
// onConfigChangeType: typeof onConfigChange
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 로컬 상태 업데이트
|
// 로컬 상태 업데이트
|
||||||
setLocalPageSize(newPageSize);
|
setLocalPageSize(newPageSize);
|
||||||
|
|
@ -1540,7 +1564,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
// 상세설정에 변경사항 알림 (pagination 설정 업데이트)
|
// 상세설정에 변경사항 알림 (pagination 설정 업데이트)
|
||||||
if (onConfigChange) {
|
if (onConfigChange) {
|
||||||
console.log("📤 테이블에서 페이지 크기 변경을 상세설정에 알림:", newPageSize);
|
// console.log("📤 테이블에서 페이지 크기 변경을 상세설정에 알림:", newPageSize);
|
||||||
onConfigChange({
|
onConfigChange({
|
||||||
...tableConfig,
|
...tableConfig,
|
||||||
pagination: {
|
pagination: {
|
||||||
|
|
@ -1549,7 +1573,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.warn("⚠️ onConfigChange가 정의되지 않음 - 상세설정과 연동 불가");
|
// console.warn("⚠️ onConfigChange가 정의되지 않음 - 상세설정과 연동 불가");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데이터는 useEffect에서 자동으로 다시 로드됨
|
// 데이터는 useEffect에서 자동으로 다시 로드됨
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { TableListConfig, ColumnConfig } from "./types";
|
import { TableListConfig, ColumnConfig } from "./types";
|
||||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
|
|
@ -32,18 +33,18 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
screenTableName,
|
screenTableName,
|
||||||
tableColumns,
|
tableColumns,
|
||||||
}) => {
|
}) => {
|
||||||
console.log("🔍 TableListConfigPanel props:", {
|
// console.log("🔍 TableListConfigPanel props:", {
|
||||||
config,
|
// config,
|
||||||
configType: typeof config,
|
// configType: typeof config,
|
||||||
configSelectedTable: config?.selectedTable,
|
// configSelectedTable: config?.selectedTable,
|
||||||
configPagination: config?.pagination,
|
// configPagination: config?.pagination,
|
||||||
paginationEnabled: config?.pagination?.enabled,
|
// paginationEnabled: config?.pagination?.enabled,
|
||||||
paginationPageSize: config?.pagination?.pageSize,
|
// paginationPageSize: config?.pagination?.pageSize,
|
||||||
configKeys: typeof config === 'object' ? Object.keys(config || {}) : 'not object',
|
// configKeys: typeof config === 'object' ? Object.keys(config || {}) : 'not object',
|
||||||
screenTableName,
|
// screenTableName,
|
||||||
tableColumns: tableColumns?.length,
|
// tableColumns: tableColumns?.length,
|
||||||
tableColumnsSample: tableColumns?.[0],
|
// tableColumnsSample: tableColumns?.[0],
|
||||||
});
|
// });
|
||||||
|
|
||||||
const [availableTables, setAvailableTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
const [availableTables, setAvailableTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||||||
const [loadingTables, setLoadingTables] = useState(false);
|
const [loadingTables, setLoadingTables] = useState(false);
|
||||||
|
|
@ -73,6 +74,16 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
|
|
||||||
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
|
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
|
||||||
|
|
||||||
|
// 🔄 외부에서 config가 변경될 때 내부 상태 동기화 (표의 페이지네이션 변경 감지)
|
||||||
|
useEffect(() => {
|
||||||
|
// console.log("🔄 TableListConfigPanel - 외부 config 변경 감지:", {
|
||||||
|
// configPagination: config?.pagination,
|
||||||
|
// configPageSize: config?.pagination?.pageSize,
|
||||||
|
// });
|
||||||
|
// 현재는 별도 내부 상태가 없어서 자동으로 UI가 업데이트됨
|
||||||
|
// 만약 내부 상태가 있다면 여기서 동기화 처리
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
// 🎯 엔티티 컬럼 표시 설정을 위한 상태
|
// 🎯 엔티티 컬럼 표시 설정을 위한 상태
|
||||||
const [entityDisplayConfigs, setEntityDisplayConfigs] = useState<
|
const [entityDisplayConfigs, setEntityDisplayConfigs] = useState<
|
||||||
Record<
|
Record<
|
||||||
|
|
@ -216,14 +227,14 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNestedChange = (parentKey: keyof TableListConfig, childKey: string, value: any) => {
|
const handleNestedChange = (parentKey: keyof TableListConfig, childKey: string, value: any) => {
|
||||||
console.log("🔧 TableListConfigPanel handleNestedChange:", {
|
// console.log("🔧 TableListConfigPanel handleNestedChange:", {
|
||||||
parentKey,
|
// parentKey,
|
||||||
childKey,
|
// childKey,
|
||||||
value,
|
// value,
|
||||||
parentValue: config[parentKey],
|
// parentValue: config[parentKey],
|
||||||
hasOnChange: !!onChange,
|
// hasOnChange: !!onChange,
|
||||||
onChangeType: typeof onChange,
|
// onChangeType: typeof onChange,
|
||||||
});
|
// });
|
||||||
|
|
||||||
const parentValue = config[parentKey] as any;
|
const parentValue = config[parentKey] as any;
|
||||||
const newConfig = {
|
const newConfig = {
|
||||||
|
|
@ -233,7 +244,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("📤 TableListConfigPanel onChange 호출:", newConfig);
|
// console.log("📤 TableListConfigPanel onChange 호출:", newConfig);
|
||||||
onChange(newConfig);
|
onChange(newConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -745,6 +756,188 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
{/* 기본 설정 탭 */}
|
{/* 기본 설정 탭 */}
|
||||||
<TabsContent value="basic" className="space-y-4">
|
<TabsContent value="basic" className="space-y-4">
|
||||||
<ScrollArea className="h-[600px] pr-4">
|
<ScrollArea className="h-[600px] pr-4">
|
||||||
|
{/* 표시 모드 설정 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">표시 모드</CardTitle>
|
||||||
|
<CardDescription>데이터를 어떤 형태로 표시할지 선택하세요</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>표시 형태</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={config.displayMode || "table"}
|
||||||
|
onValueChange={(value: "table" | "card") => handleChange("displayMode", value)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="table" id="table-mode" />
|
||||||
|
<Label htmlFor="table-mode" className="cursor-pointer">
|
||||||
|
테이블 형태 (기본)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="card" id="card-mode" />
|
||||||
|
<Label htmlFor="card-mode" className="cursor-pointer">
|
||||||
|
카드 형태
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카드 모드 설정 */}
|
||||||
|
{config.displayMode === "card" && (
|
||||||
|
<div className="space-y-4 border-t pt-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">카드 레이아웃 설정</Label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cards-per-row">한 행당 카드 수</Label>
|
||||||
|
<Select
|
||||||
|
value={config.cardConfig?.cardsPerRow?.toString() || "3"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleNestedChange("cardConfig", "cardsPerRow", parseInt(value))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">1개</SelectItem>
|
||||||
|
<SelectItem value="2">2개</SelectItem>
|
||||||
|
<SelectItem value="3">3개</SelectItem>
|
||||||
|
<SelectItem value="4">4개</SelectItem>
|
||||||
|
<SelectItem value="5">5개</SelectItem>
|
||||||
|
<SelectItem value="6">6개</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="card-spacing">카드 간격 (px)</Label>
|
||||||
|
<Input
|
||||||
|
id="card-spacing"
|
||||||
|
type="number"
|
||||||
|
value={config.cardConfig?.cardSpacing || 16}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleNestedChange("cardConfig", "cardSpacing", parseInt(e.target.value))
|
||||||
|
}
|
||||||
|
min="0"
|
||||||
|
max="50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">카드 필드 매핑</Label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="id-column">ID 컬럼 (사번 등)</Label>
|
||||||
|
<Select
|
||||||
|
value={config.cardConfig?.idColumn || ""}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleNestedChange("cardConfig", "idColumn", value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="ID 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableColumns.map((column) => (
|
||||||
|
<SelectItem key={column.columnName} value={column.columnName}>
|
||||||
|
{column.label || column.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title-column">제목 컬럼 (이름 등)</Label>
|
||||||
|
<Select
|
||||||
|
value={config.cardConfig?.titleColumn || ""}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleNestedChange("cardConfig", "titleColumn", value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="제목 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableColumns.map((column) => (
|
||||||
|
<SelectItem key={column.columnName} value={column.columnName}>
|
||||||
|
{column.label || column.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="subtitle-column">서브 제목 컬럼 (부서 등)</Label>
|
||||||
|
<Select
|
||||||
|
value={config.cardConfig?.subtitleColumn || ""}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleNestedChange("cardConfig", "subtitleColumn", value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="서브 제목 컬럼 선택 (선택사항)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">선택 안함</SelectItem>
|
||||||
|
{availableColumns.map((column) => (
|
||||||
|
<SelectItem key={column.columnName} value={column.columnName}>
|
||||||
|
{column.label || column.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description-column">설명 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={config.cardConfig?.descriptionColumn || ""}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleNestedChange("cardConfig", "descriptionColumn", value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="설명 컬럼 선택 (선택사항)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">선택 안함</SelectItem>
|
||||||
|
{availableColumns.map((column) => (
|
||||||
|
<SelectItem key={column.columnName} value={column.columnName}>
|
||||||
|
{column.label || column.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="show-card-actions"
|
||||||
|
checked={config.cardConfig?.showActions !== false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleNestedChange("cardConfig", "showActions", checked as boolean)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="show-card-actions" className="cursor-pointer">
|
||||||
|
카드에 액션 버튼 표시
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">연결된 테이블</CardTitle>
|
<CardTitle className="text-base">연결된 테이블</CardTitle>
|
||||||
|
|
@ -870,8 +1063,16 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="pageSize">페이지 크기</Label>
|
<Label htmlFor="pageSize">페이지 크기</Label>
|
||||||
<Select
|
<Select
|
||||||
|
key={`pageSize-${config.pagination?.pageSize}`}
|
||||||
value={config.pagination?.pageSize?.toString() || "20"}
|
value={config.pagination?.pageSize?.toString() || "20"}
|
||||||
onValueChange={(value) => handleNestedChange("pagination", "pageSize", parseInt(value))}
|
onValueChange={(value) => {
|
||||||
|
// console.log("🎯 상세설정에서 페이지 크기 변경:", {
|
||||||
|
// from: config.pagination?.pageSize,
|
||||||
|
// to: parseInt(value),
|
||||||
|
// currentConfigPageSize: config.pagination?.pageSize
|
||||||
|
// });
|
||||||
|
handleNestedChange("pagination", "pageSize", parseInt(value));
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|
|
||||||
|
|
@ -67,3 +67,16 @@ export class TableListRenderer extends AutoRegisteringComponentRenderer {
|
||||||
|
|
||||||
// 자동 등록 실행
|
// 자동 등록 실행
|
||||||
TableListRenderer.registerSelf();
|
TableListRenderer.registerSelf();
|
||||||
|
|
||||||
|
// 강제 등록 (디버깅용)
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
console.log("🔄 TableList 강제 등록 시도...");
|
||||||
|
TableListRenderer.registerSelf();
|
||||||
|
console.log("✅ TableList 강제 등록 완료");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ TableList 강제 등록 실패:", error);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,21 @@ export const TableListDefinition = createComponentDefinition({
|
||||||
nameEng: "TableList Component",
|
nameEng: "TableList Component",
|
||||||
description: "데이터베이스 테이블의 데이터를 목록으로 표시하는 컴포넌트",
|
description: "데이터베이스 테이블의 데이터를 목록으로 표시하는 컴포넌트",
|
||||||
category: ComponentCategory.DISPLAY,
|
category: ComponentCategory.DISPLAY,
|
||||||
webType: "table",
|
webType: "text",
|
||||||
component: TableListWrapper,
|
component: TableListWrapper,
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
|
// 표시 모드 설정
|
||||||
|
displayMode: "table" as const,
|
||||||
|
|
||||||
|
// 카드 모드 기본 설정
|
||||||
|
cardConfig: {
|
||||||
|
idColumn: "id",
|
||||||
|
titleColumn: "name",
|
||||||
|
cardsPerRow: 3,
|
||||||
|
cardSpacing: 16,
|
||||||
|
showActions: true,
|
||||||
|
},
|
||||||
|
|
||||||
// 테이블 기본 설정
|
// 테이블 기본 설정
|
||||||
showHeader: true,
|
showHeader: true,
|
||||||
showFooter: true,
|
showFooter: true,
|
||||||
|
|
@ -83,7 +95,7 @@ export const TableListDefinition = createComponentDefinition({
|
||||||
// 데이터 로딩
|
// 데이터 로딩
|
||||||
autoLoad: true,
|
autoLoad: true,
|
||||||
},
|
},
|
||||||
defaultSize: { width: 800, height: 960 },
|
defaultSize: { width: 120, height: 200 }, // 그리드 1컬럼 크기로 축소
|
||||||
configPanel: TableListConfigPanel,
|
configPanel: TableListConfigPanel,
|
||||||
icon: "Table",
|
icon: "Table",
|
||||||
tags: ["테이블", "데이터", "목록", "그리드"],
|
tags: ["테이블", "데이터", "목록", "그리드"],
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,21 @@ export interface ColumnConfig {
|
||||||
autoGeneration?: AutoGenerationConfig; // 자동생성 설정
|
autoGeneration?: AutoGenerationConfig; // 자동생성 설정
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카드 디스플레이 설정
|
||||||
|
*/
|
||||||
|
export interface CardDisplayConfig {
|
||||||
|
idColumn: string; // ID 컬럼 (사번 등)
|
||||||
|
titleColumn: string; // 제목 컬럼 (이름 등)
|
||||||
|
subtitleColumn?: string; // 부제목 컬럼 (부서 등)
|
||||||
|
descriptionColumn?: string; // 설명 컬럼
|
||||||
|
imageColumn?: string; // 이미지 컬럼
|
||||||
|
cardsPerRow: number; // 한 행당 카드 수 (기본: 3)
|
||||||
|
cardSpacing: number; // 카드 간격 (기본: 16px)
|
||||||
|
showActions: boolean; // 액션 버튼 표시 여부
|
||||||
|
cardHeight?: number; // 카드 높이 (기본: auto)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 필터 설정
|
* 필터 설정
|
||||||
*/
|
*/
|
||||||
|
|
@ -147,6 +162,12 @@ export interface CheckboxConfig {
|
||||||
* TableList 컴포넌트 설정 타입
|
* TableList 컴포넌트 설정 타입
|
||||||
*/
|
*/
|
||||||
export interface TableListConfig extends ComponentConfig {
|
export interface TableListConfig extends ComponentConfig {
|
||||||
|
// 표시 모드 설정
|
||||||
|
displayMode?: "table" | "card"; // 기본: "table"
|
||||||
|
|
||||||
|
// 카드 디스플레이 설정 (displayMode가 "card"일 때 사용)
|
||||||
|
cardConfig?: CardDisplayConfig;
|
||||||
|
|
||||||
// 테이블 기본 설정
|
// 테이블 기본 설정
|
||||||
selectedTable?: string;
|
selectedTable?: string;
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
|
|
@ -175,7 +196,9 @@ export interface TableListConfig extends ComponentConfig {
|
||||||
};
|
};
|
||||||
|
|
||||||
// 페이지네이션
|
// 페이지네이션
|
||||||
pagination: PaginationConfig;
|
pagination: PaginationConfig & {
|
||||||
|
currentPage?: number; // 현재 페이지 (추가)
|
||||||
|
};
|
||||||
|
|
||||||
// 필터 설정
|
// 필터 설정
|
||||||
filter: FilterConfig;
|
filter: FilterConfig;
|
||||||
|
|
|
||||||
|
|
@ -58,21 +58,22 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
}
|
}
|
||||||
: autoGeneration;
|
: autoGeneration;
|
||||||
|
|
||||||
console.log("🔧 텍스트 입력 컴포넌트 설정:", {
|
// 디버그 로그 (필요시 주석 해제)
|
||||||
config,
|
// console.log("🔧 텍스트 입력 컴포넌트 설정:", {
|
||||||
componentConfig,
|
// config,
|
||||||
component: component,
|
// componentConfig,
|
||||||
autoGeneration,
|
// component: component,
|
||||||
testAutoGeneration,
|
// autoGeneration,
|
||||||
isTestMode: component.label?.toLowerCase().includes("test"),
|
// testAutoGeneration,
|
||||||
isHidden,
|
// isTestMode: component.label?.toLowerCase().includes("test"),
|
||||||
isInteractive,
|
// isHidden,
|
||||||
formData,
|
// isInteractive,
|
||||||
columnName: component.columnName,
|
// formData,
|
||||||
currentFormValue: formData?.[component.columnName],
|
// columnName: component.columnName,
|
||||||
componentValue: component.value,
|
// currentFormValue: formData?.[component.columnName],
|
||||||
autoGeneratedValue,
|
// componentValue: component.value,
|
||||||
});
|
// autoGeneratedValue,
|
||||||
|
// });
|
||||||
|
|
||||||
// 자동생성 값 생성 (컴포넌트 마운트 시 또는 폼 데이터 변경 시)
|
// 자동생성 값 생성 (컴포넌트 마운트 시 또는 폼 데이터 변경 시)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -248,18 +249,18 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newValue = e.target.value;
|
const newValue = e.target.value;
|
||||||
console.log("🎯 TextInputComponent onChange 호출:", {
|
// console.log("🎯 TextInputComponent onChange 호출:", {
|
||||||
componentId: component.id,
|
// componentId: component.id,
|
||||||
columnName: component.columnName,
|
// columnName: component.columnName,
|
||||||
newValue,
|
// newValue,
|
||||||
isInteractive,
|
// isInteractive,
|
||||||
hasOnFormDataChange: !!onFormDataChange,
|
// hasOnFormDataChange: !!onFormDataChange,
|
||||||
hasOnChange: !!props.onChange,
|
// hasOnChange: !!props.onChange,
|
||||||
});
|
// });
|
||||||
|
|
||||||
// isInteractive 모드에서는 formData 업데이트
|
// isInteractive 모드에서는 formData 업데이트
|
||||||
if (isInteractive && onFormDataChange && component.columnName) {
|
if (isInteractive && onFormDataChange && component.columnName) {
|
||||||
console.log(`📤 TextInputComponent -> onFormDataChange 호출: ${component.columnName} = "${newValue}"`);
|
// console.log(`📤 TextInputComponent -> onFormDataChange 호출: ${component.columnName} = "${newValue}"`);
|
||||||
console.log("🔍 onFormDataChange 함수 정보:", {
|
console.log("🔍 onFormDataChange 함수 정보:", {
|
||||||
functionName: onFormDataChange.name,
|
functionName: onFormDataChange.name,
|
||||||
functionString: onFormDataChange.toString().substring(0, 200),
|
functionString: onFormDataChange.toString().substring(0, 200),
|
||||||
|
|
@ -275,7 +276,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
|
|
||||||
// 기존 onChange 핸들러도 호출
|
// 기존 onChange 핸들러도 호출
|
||||||
if (props.onChange) {
|
if (props.onChange) {
|
||||||
console.log(`📤 TextInputComponent -> props.onChange 호출: "${newValue}"`);
|
// console.log(`📤 TextInputComponent -> props.onChange 호출: "${newValue}"`);
|
||||||
props.onChange(newValue);
|
props.onChange(newValue);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,14 @@ import { twMerge } from "tailwind-merge";
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일 크기를 사람이 읽기 쉬운 형태로 포맷팅
|
||||||
|
*/
|
||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
import { Position, Size } from "@/types/screen";
|
import { Position, Size } from "@/types/screen";
|
||||||
|
import { GridSettings } from "@/types/screen-management";
|
||||||
export interface GridSettings {
|
|
||||||
columns: number;
|
|
||||||
gap: number;
|
|
||||||
padding: number;
|
|
||||||
snapToGrid: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GridInfo {
|
export interface GridInfo {
|
||||||
columnWidth: number;
|
columnWidth: number;
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,12 @@ export interface BaseComponent {
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
style?: ComponentStyle;
|
style?: ComponentStyle;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
// 새 컴포넌트 시스템에서 필요한 속성들
|
||||||
|
gridColumns?: number; // 그리드에서 차지할 컬럼 수 (1-12)
|
||||||
|
zoneId?: string; // 레이아웃 존 ID
|
||||||
|
componentConfig?: any; // 컴포넌트별 설정
|
||||||
|
componentType?: string; // 새 컴포넌트 시스템의 ID
|
||||||
|
webTypeConfig?: WebTypeConfig; // 웹타입별 설정
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -110,10 +116,20 @@ export interface FileComponent extends BaseComponent {
|
||||||
uploadedFiles?: UploadedFile[];
|
uploadedFiles?: UploadedFile[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새로운 컴포넌트 시스템 컴포넌트
|
||||||
|
*/
|
||||||
|
export interface ComponentComponent extends BaseComponent {
|
||||||
|
type: "component";
|
||||||
|
widgetType: WebType; // 웹타입 (기존 호환성)
|
||||||
|
componentType: string; // 새 컴포넌트 시스템의 ID
|
||||||
|
componentConfig: any; // 컴포넌트별 설정
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 통합 컴포넌트 데이터 타입
|
* 통합 컴포넌트 데이터 타입
|
||||||
*/
|
*/
|
||||||
export type ComponentData = WidgetComponent | ContainerComponent | GroupComponent | DataTableComponent | FileComponent;
|
export type ComponentData = WidgetComponent | ContainerComponent | GroupComponent | DataTableComponent | FileComponent | ComponentComponent;
|
||||||
|
|
||||||
// ===== 웹타입별 설정 인터페이스 =====
|
// ===== 웹타입별 설정 인터페이스 =====
|
||||||
|
|
||||||
|
|
@ -370,6 +386,13 @@ export interface GridSettings {
|
||||||
color: string;
|
color: string;
|
||||||
opacity: number;
|
opacity: number;
|
||||||
snapToGrid: boolean;
|
snapToGrid: boolean;
|
||||||
|
// gridUtils에서 필요한 속성들 추가
|
||||||
|
columns: number;
|
||||||
|
gap: number;
|
||||||
|
padding: number;
|
||||||
|
showGrid?: boolean;
|
||||||
|
gridColor?: string;
|
||||||
|
gridOpacity?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,8 @@ export interface CommonStyle {
|
||||||
lineHeight?: string;
|
lineHeight?: string;
|
||||||
|
|
||||||
// 라벨 스타일
|
// 라벨 스타일
|
||||||
|
labelDisplay?: boolean; // 라벨 표시 여부
|
||||||
|
labelText?: string; // 라벨 텍스트
|
||||||
labelFontSize?: string;
|
labelFontSize?: string;
|
||||||
labelColor?: string;
|
labelColor?: string;
|
||||||
labelFontWeight?: string;
|
labelFontWeight?: string;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue