화면관리 쪽 파일첨부 수정

This commit is contained in:
leeheejin 2025-09-26 17:12:03 +09:00
parent 1fe401c7d6
commit 3600621554
11 changed files with 762 additions and 109 deletions

View File

@ -32,6 +32,7 @@ import dataRoutes from "./routes/dataRoutes";
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
import multiConnectionRoutes from "./routes/multiConnectionRoutes";
import screenFileRoutes from "./routes/screenFileRoutes";
import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes";
import ddlRoutes from "./routes/ddlRoutes";
import entityReferenceRoutes from "./routes/entityReferenceRoutes";
@ -132,6 +133,7 @@ app.use("/api/data", dataRoutes);
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
app.use("/api/external-db-connections", externalDbConnectionRoutes);
app.use("/api/multi-connection", multiConnectionRoutes);
app.use("/api/screen-files", screenFileRoutes);
app.use("/api/db-type-categories", dbTypeCategoryRoutes);
app.use("/api/ddl", ddlRoutes);
app.use("/api/entity-reference", entityReferenceRoutes);

View File

@ -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 : '알 수 없는 오류'
});
}
};

View File

@ -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;

View File

@ -1,4 +1,5 @@
import { LAYOUT_CONFIG } from "@/constants/layout";
import Image from "next/image";
/**
*
@ -6,10 +7,17 @@ import { LAYOUT_CONFIG } from "@/constants/layout";
export function Logo() {
return (
<div className="flex items-center gap-2">
<div className="bg-primary flex h-8 w-8 items-center justify-center rounded-lg">
<span className="text-primary-foreground text-sm font-bold">P</span>
<div className="flex items-center justify-center">
<Image
src="/images/vexplor.png"
alt="WACE 솔루션 로고"
width={120}
height={32}
className="h-8 object-contain"
priority
/>
</div>
<span className="font-semibold">{LAYOUT_CONFIG.COMPANY_NAME}</span>
{/* <span className="font-semibold">{LAYOUT_CONFIG.COMPANY_NAME}</span> */}
</div>
);
}

View File

@ -509,6 +509,48 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
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(() => {
const fetchTableColumns = async () => {

View File

@ -417,24 +417,40 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
<div className="h-full w-full">
{/* 실제 FileUploadComponent 사용 */}
<FileUploadComponent
component={{
...comp,
config: {
...comp.fileConfig,
multiple: comp.fileConfig?.multiple !== false,
accept: comp.fileConfig?.accept || "*/*",
maxSize: (comp.fileConfig?.maxSize || 10) * 1024 * 1024, // MB to bytes
disabled: readonly,
}
component={comp}
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={(fieldName, value) => {
console.log("📝 파일 업로드 완료:", { fieldName, value });
handleFormDataChange(fieldName, value);
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>

View File

@ -41,6 +41,7 @@ import { toast } from "sonner";
import { MenuAssignmentModal } from "./MenuAssignmentModal";
import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal";
import { initializeComponents } from "@/lib/registry/components";
import { ScreenFileAPI } from "@/lib/api/screenFile";
import StyleEditor from "./StyleEditor";
import { RealtimePreview } from "./RealtimePreviewDynamic";
@ -196,6 +197,85 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 전역 파일 상태 변경 시 강제 리렌더링을 위한 상태
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({
isSelecting: false,
@ -722,6 +802,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 화면 레이아웃 로드
useEffect(() => {
if (selectedScreen?.screenId) {
// 현재 화면 ID를 전역 변수로 설정 (파일 업로드 시 사용)
if (typeof window !== 'undefined') {
(window as any).__CURRENT_SCREEN_ID__ = selectedScreen.screenId;
}
const loadLayout = async () => {
try {
const response = await screenApi.getLayout(selectedScreen.screenId);
@ -756,6 +841,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
setLayout(layoutWithDefaultGrid);
setHistory([layoutWithDefaultGrid]);
setHistoryIndex(0);
// 파일 컴포넌트 데이터 복원 (비동기)
restoreFileComponentsData(layoutWithDefaultGrid.components);
}
} catch (error) {
console.error("레이아웃 로드 실패:", error);

View File

@ -238,18 +238,76 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
if (validFiles.length === 0) return;
// 중복 파일 체크
const existingFiles = uploadedFiles;
const existingFileNames = existingFiles.map(f => f.realFileName.toLowerCase());
const duplicates: string[] = [];
const uniqueFiles: File[] = [];
console.log("🔍 중복 파일 체크:", {
uploadedFiles: existingFiles.length,
existingFileNames: existingFileNames,
newFiles: validFiles.map(f => f.name.toLowerCase())
});
validFiles.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 : validFiles;
try {
console.log("🔄 파일 업로드 시작:", { fileCount: validFiles.length, uploading });
console.log("🔄 파일 업로드 시작:", {
originalFiles: validFiles.length,
filesToUpload: filesToUpload.length,
uploading
});
setUploading(true);
toast.loading(`${validFiles.length}개 파일 업로드 중...`);
toast.loading(`${filesToUpload.length}개 파일 업로드 중...`);
// 그리드와 연동되는 targetObjid 생성 (화면 복원 시스템과 통일)
const tableName = 'screen_files';
const screenId = (window as any).__CURRENT_SCREEN_ID__ || 'unknown'; // 현재 화면 ID
const componentId = component.id;
const fieldName = component.columnName || component.id || 'file_attachment';
const targetObjid = `${tableName}:${screenId}:${componentId}:${fieldName}`;
const response = await uploadFiles({
files: validFiles,
tableName: currentTableName || 'screen_files',
fieldName: component.columnName || component.id || 'file_attachment',
recordId: component.id,
files: filesToUpload,
tableName: tableName,
fieldName: fieldName,
recordId: `${screenId}:${componentId}`, // 화면ID:컴포넌트ID 형태
docType: localInputs.docType,
docTypeName: localInputs.docTypeName,
targetObjid: targetObjid, // 그리드 연동을 위한 targetObjid
columnName: fieldName,
isVirtualFileColumn: true, // 가상 파일 컬럼으로 처리
});
console.log("📤 파일 업로드 응답:", response);
@ -309,6 +367,27 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
localStorageBackup: localStorage.getItem(`fileComponent_${component.id}_files`) ? 'saved' : 'not saved'
});
// 그리드 파일 상태 새로고침 이벤트 발생
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("🔄 FileComponentConfigPanel 그리드 새로고침 이벤트 발생:", {
tableName,
recordId,
columnName,
targetObjid,
fileCount: updatedFiles.length
});
}
toast.dismiss();
toast.success(`${validFiles.length}개 파일이 성공적으로 업로드되었습니다.`);
console.log("✅ 파일 업로드 성공:", {
@ -375,6 +454,32 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
remainingFiles: updatedFiles.length,
timestamp: timestamp
});
// 그리드 파일 상태 새로고침 이벤트 발생
if (typeof window !== 'undefined') {
const tableName = currentTableName || 'screen_files';
const recordId = component.id;
const columnName = component.columnName || component.id || 'file_attachment';
const targetObjid = `${tableName}:${recordId}:${columnName}`;
const refreshEvent = new CustomEvent('refreshFileStatus', {
detail: {
tableName: tableName,
recordId: recordId,
columnName: columnName,
targetObjid: targetObjid,
fileCount: updatedFiles.length
}
});
window.dispatchEvent(refreshEvent);
console.log("🔄 FileComponentConfigPanel 파일 삭제 후 그리드 새로고침:", {
tableName,
recordId,
columnName,
targetObjid,
fileCount: updatedFiles.length
});
}
toast.success('파일이 삭제되었습니다.');
} catch (error) {
@ -541,6 +646,41 @@ export const FileComponentConfigPanel: React.FC<FileComponentConfigPanelProps> =
}
}, [component.id]); // 컴포넌트 ID가 변경될 때만 초기화
// 전역 파일 상태 변경 감지 (화면 복원 포함)
useEffect(() => {
const handleGlobalFileStateChange = (event: CustomEvent) => {
const { componentId, files, fileCount, isRestore } = event.detail;
if (componentId === component.id) {
console.log("🌐 FileComponentConfigPanel 전역 상태 변경 감지:", {
componentId,
fileCount,
isRestore: !!isRestore,
files: files?.map((f: any) => ({ objid: f.objid, name: f.realFileName }))
});
if (files && Array.isArray(files)) {
setUploadedFiles(files);
if (isRestore) {
console.log("✅ 파일 컴포넌트 설정 패널 데이터 복원 완료:", {
componentId,
restoredFileCount: files.length
});
}
}
}
};
if (typeof window !== 'undefined') {
window.addEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
return () => {
window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
};
}
}, [component.id]);
// 미리 정의된 문서 타입들
const docTypeOptions = [
{ value: "CONTRACT", label: "계약서" },

View File

@ -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;
}
};

View File

@ -54,7 +54,7 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
console.log(`DB 웹타입 정보:`, dbWebType);
// FileWidget의 경우 FileUploadComponent 직접 사용
if (dbWebType.component_name === "FileWidget" && webType === "file") {
if (dbWebType.component_name === "FileWidget" || webType === "file") {
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
console.log(`✅ FileWidget → FileUploadComponent 사용`);
return <FileUploadComponent {...props} {...finalProps} />;
@ -75,6 +75,13 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
if (webTypeDefinition) {
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) {
console.warn(`웹타입 "${webType}"이 비활성화되어 있습니다.`);
@ -99,6 +106,14 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
// 3순위: 웹타입명으로 자동 매핑 (폴백)
try {
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);
// return <FallbackComponent {...props} />;
console.warn(`웹타입 "${webType}" 폴백 기능 임시 비활성화`);

View File

@ -102,10 +102,19 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
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,
currentFiles: uploadedFiles.length,
globalFiles: globalFiles.length,
currentFiles: currentFiles.length,
uploadedFiles: uploadedFiles.length,
lastUpdate: lastUpdate
});
@ -113,7 +122,7 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
try {
const backupKey = `fileUpload_${component.id}`;
const backupFiles = localStorage.getItem(backupKey);
if (backupFiles && componentFiles.length === 0) {
if (backupFiles && currentFiles.length === 0) {
const parsedFiles = JSON.parse(backupFiles);
setUploadedFiles(parsedFiles);
return;
@ -122,19 +131,66 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
console.warn("localStorage 백업 복원 실패:", e);
}
// 컴포넌트 파일과 현재 파일 비교
if (JSON.stringify(componentFiles) !== JSON.stringify(uploadedFiles)) {
// 최신 파일과 현재 파일 비교
if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) {
console.log("🔄 useEffect에서 파일 목록 변경 감지:", {
componentFiles: componentFiles.length,
currentFiles: currentFiles.length,
uploadedFiles: uploadedFiles.length,
componentFilesData: componentFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })),
currentFilesData: currentFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })),
uploadedFilesData: uploadedFiles.map(f => ({ objid: f.objid, name: f.realFileName }))
});
setUploadedFiles(componentFiles);
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 = {
@ -163,24 +219,80 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
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: component.tableName || 'default_table',
fieldName: component.columnName || component.id,
recordId: formData?.id || 'temp_record',
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("📤 파일 업로드 시작:", {
files: files.map(f => ({ name: f.name, size: f.size })),
originalFiles: files.length,
filesToUpload: filesToUpload.length,
files: filesToUpload.map(f => ({ name: f.name, size: f.size })),
uploadData
});
const response = await uploadFiles(files, uploadData);
const response = await uploadFiles({
files: filesToUpload,
...uploadData
});
console.log("📤 파일 업로드 API 응답:", response);
@ -239,6 +351,34 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
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();
@ -255,6 +395,27 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
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);
@ -310,6 +471,31 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
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();
@ -407,16 +593,16 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
${safeComponentConfig.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-gray-400'}
${uploadStatus === 'uploading' ? 'opacity-75' : ''}
`}
onClick={handleClick}
onClick={handleClick}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<input
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<input
ref={fileInputRef}
type="file"
type="file"
multiple={safeComponentConfig.multiple}
accept={safeComponentConfig.accept}
onChange={handleInputChange}
@ -465,75 +651,24 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
</div>
{uploadedFiles.length > 0 ? (
uploadedFiles.map((file) => (
<Card key={file.objid} className="p-3">
<CardContent className="p-0">
<div className="flex items-center justify-between">
<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="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleFileDownload(file)}
className="h-8 w-8 p-0"
title="다운로드"
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleFileDelete(file)}
className="h-8 w-8 p-0 text-red-500 hover:text-red-700"
title="삭제"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<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>
{/* 파일 상태 표시 */}
{file.status !== 'ACTIVE' && (
<div className="mt-2 flex items-center space-x-2">
<AlertCircle className="w-4 h-4 text-yellow-500" />
<span className="text-xs text-yellow-600">
: {file.status}
</span>
</div>
)}
</CardContent>
</Card>
))
<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" />
@ -541,7 +676,7 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
<p className="text-xs text-gray-400 mt-1"> </p>
</div>
)}
</div>
</div>
</div>
)}