From cab2a3c01aba9d7b7face79291f77f7892d0c315 Mon Sep 17 00:00:00 2001 From: chpark Date: Thu, 5 Mar 2026 22:34:03 +0900 Subject: [PATCH 01/12] =?UTF-8?q?=EC=9C=88=EB=8F=84=EC=9A=B0=EC=9A=A9=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=ED=8C=8C=EC=9D=BC=20=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- run-windows.bat | 45 +++++++++++++++++++ start-all-separated.bat | 71 +++++++++++++++++++++++++++++ start-windows-simple.bat | 97 ++++++++++++++++++++++++++++++++++++++++ stop-all-separated.bat | 56 +++++++++++++++++++++++ test-backend-build.bat | 47 +++++++++++++++++++ 5 files changed, 316 insertions(+) create mode 100644 run-windows.bat create mode 100644 start-all-separated.bat create mode 100644 start-windows-simple.bat create mode 100644 stop-all-separated.bat create mode 100644 test-backend-build.bat diff --git a/run-windows.bat b/run-windows.bat new file mode 100644 index 00000000..b5490e27 --- /dev/null +++ b/run-windows.bat @@ -0,0 +1,45 @@ +@echo off + +REM 스크립트가 있는 디렉토리로 이동 +cd /d "%~dp0" + +echo ===================================== +echo PLM 솔루션 - Windows 시작 +echo ===================================== + +echo 기존 컨테이너 및 네트워크 정리 중... +docker-compose -f docker-compose.win.yml down -v 2>nul +docker network rm plm-network 2>nul + +echo PLM 서비스 시작 중... +docker-compose -f docker-compose.win.yml build --no-cache +docker-compose -f docker-compose.win.yml up -d + +if %errorlevel% equ 0 ( + echo. + echo ✅ PLM 서비스가 성공적으로 시작되었습니다! + echo. + echo 🌐 접속 URL: + echo • 프론트엔드 (Next.js): http://localhost:3000 + echo • 백엔드 (Spring/JSP): http://localhost:9090 + echo. + echo 📋 서비스 상태 확인: + echo docker-compose -f docker-compose.win.yml ps + echo. + echo 📊 로그 확인: + echo docker-compose -f docker-compose.win.yml logs + echo. + echo 5초 후 프론트엔드 페이지를 자동으로 엽니다... + timeout /t 5 /nobreak >nul + start http://localhost:3000 +) else ( + echo. + echo ❌ PLM 서비스 시작에 실패했습니다! + echo. + echo 🔍 문제 해결 방법: + echo 1. Docker Desktop이 실행 중인지 확인 + echo 2. 포트가 사용 중인지 확인 (3000, 9090) + echo 3. 로그 확인: docker-compose -f docker-compose.win.yml logs + echo. + pause +) \ No newline at end of file diff --git a/start-all-separated.bat b/start-all-separated.bat new file mode 100644 index 00000000..7c580aca --- /dev/null +++ b/start-all-separated.bat @@ -0,0 +1,71 @@ +@echo off +chcp 65001 >nul + +REM 스크립트가 있는 디렉토리로 이동 +cd /d "%~dp0" + +echo ============================================ +echo PLM 솔루션 - 전체 서비스 시작 (분리형) +echo ============================================ + +echo. +echo 🚀 백엔드와 프론트엔드를 순차적으로 시작합니다... +echo. + +REM 백엔드 먼저 시작 +echo ============================================ +echo 1. 백엔드 서비스 시작 중... +echo ============================================ + +REM 기존 컨테이너 및 네트워크 정리 +docker-compose -f docker-compose.backend.win.yml down -v 2>nul +docker-compose -f docker-compose.frontend.win.yml down -v 2>nul +docker network rm pms-network 2>nul + +REM 백엔드 빌드 및 시작 +docker-compose -f docker-compose.backend.win.yml build --no-cache +docker-compose -f docker-compose.backend.win.yml up -d + +echo. +echo ⏳ 백엔드 서비스 안정화 대기 중... (20초) +timeout /t 20 /nobreak >nul + +REM 프론트엔드 시작 +echo. +echo ============================================ +echo 2. 프론트엔드 서비스 시작 중... +echo ============================================ + +REM 프론트엔드 빌드 및 시작 +docker-compose -f docker-compose.frontend.win.yml build --no-cache +docker-compose -f docker-compose.frontend.win.yml up -d + +echo. +echo ⏳ 프론트엔드 서비스 안정화 대기 중... (10초) +timeout /t 10 /nobreak >nul + +echo. +echo ============================================ +echo 🎉 모든 서비스가 시작되었습니다! +echo ============================================ +echo. +echo [DATABASE] PostgreSQL: http://39.117.244.52:11132 +echo [BACKEND] Spring Boot: http://localhost:8080/api +echo [FRONTEND] Next.js: http://localhost:9771 +echo. +echo 서비스 상태 확인: +echo 백엔드: docker-compose -f docker-compose.backend.win.yml ps +echo 프론트엔드: docker-compose -f docker-compose.frontend.win.yml ps +echo. +echo 로그 확인: +echo 백엔드: docker-compose -f docker-compose.backend.win.yml logs -f +echo 프론트엔드: docker-compose -f docker-compose.frontend.win.yml logs -f +echo. +echo 서비스 중지: +echo 백엔드: docker-compose -f docker-compose.backend.win.yml down +echo 프론트엔드: docker-compose -f docker-compose.frontend.win.yml down +echo 전체: stop-all-separated.bat +echo. +echo ============================================ + +pause diff --git a/start-windows-simple.bat b/start-windows-simple.bat new file mode 100644 index 00000000..a5c96fa7 --- /dev/null +++ b/start-windows-simple.bat @@ -0,0 +1,97 @@ +@echo off +chcp 65001 >nul + +REM 스크립트가 있는 디렉토리로 이동 +cd /d "%~dp0" + +echo ============================================ +echo PLM 솔루션 - 윈도우 간편 시작 +echo ============================================ +echo. + +REM Docker Desktop 실행 확인 +echo 🔍 Docker Desktop 상태 확인 중... +docker --version >nul 2>&1 +if %errorlevel% neq 0 ( + echo ❌ Docker Desktop이 실행되지 않았습니다! + echo Docker Desktop을 먼저 실행해주세요. + echo. + pause + exit /b 1 +) + +echo ✅ Docker Desktop이 실행 중입니다. +echo. + +REM 기존 컨테이너 정리 +echo 🧹 기존 컨테이너 정리 중... +docker-compose -f docker-compose.backend.win.yml down -v 2>nul +docker-compose -f docker-compose.frontend.win.yml down -v 2>nul +docker network rm pms-network 2>nul +echo. + +REM 백엔드 시작 +echo ============================================ +echo 🚀 1단계: 백엔드 서비스 시작 중... +echo ============================================ +docker-compose -f docker-compose.backend.win.yml up -d --build + +if %errorlevel% neq 0 ( + echo ❌ 백엔드 시작 실패! + echo 로그를 확인하세요: docker-compose -f docker-compose.backend.win.yml logs + pause + exit /b 1 +) + +echo ✅ 백엔드 서비스 시작 완료 +echo ⏳ 백엔드 안정화 대기 중... (30초) +timeout /t 30 /nobreak >nul + +REM 프론트엔드 시작 +echo. +echo ============================================ +echo 🎨 2단계: 프론트엔드 서비스 시작 중... +echo ============================================ +docker-compose -f docker-compose.frontend.win.yml up -d --build + +if %errorlevel% neq 0 ( + echo ❌ 프론트엔드 시작 실패! + echo 로그를 확인하세요: docker-compose -f docker-compose.frontend.win.yml logs + pause + exit /b 1 +) + +echo ✅ 프론트엔드 서비스 시작 완료 +echo ⏳ 프론트엔드 안정화 대기 중... (15초) +timeout /t 15 /nobreak >nul + +echo. +echo ============================================ +echo 🎉 PLM 솔루션이 성공적으로 시작되었습니다! +echo ============================================ +echo. +echo 📱 접속 정보: +echo • 프론트엔드: http://localhost:9771 +echo • 백엔드 API: http://localhost:8080/api +echo • 데이터베이스: 39.117.244.52:11132 +echo. +echo 📊 서비스 상태 확인: +echo docker-compose -f docker-compose.backend.win.yml ps +echo docker-compose -f docker-compose.frontend.win.yml ps +echo. +echo 📋 로그 확인: +echo 백엔드: docker-compose -f docker-compose.backend.win.yml logs -f +echo 프론트엔드: docker-compose -f docker-compose.frontend.win.yml logs -f +echo. +echo 🛑 서비스 중지: +echo stop-all-separated.bat 실행 +echo. + +REM 브라우저 자동 열기 +echo 5초 후 브라우저에서 애플리케이션을 엽니다... +timeout /t 5 /nobreak >nul +start http://localhost:9771 + +echo. +echo 애플리케이션이 준비되었습니다! +pause diff --git a/stop-all-separated.bat b/stop-all-separated.bat new file mode 100644 index 00000000..f28bef1f --- /dev/null +++ b/stop-all-separated.bat @@ -0,0 +1,56 @@ +@echo off +chcp 65001 >nul + +echo ============================================ +echo PLM 솔루션 - 전체 서비스 중지 (분리형) +echo ============================================ + +echo. +echo 🛑 백엔드와 프론트엔드 서비스를 순차적으로 중지합니다... +echo. + +REM 프론트엔드 먼저 중지 +echo ============================================ +echo 1. 프론트엔드 서비스 중지 중... +echo ============================================ + +docker-compose -f docker-compose.frontend.win.yml down -v + +echo. +echo ⏳ 프론트엔드 서비스 완전 중지 대기 중... (5초) +timeout /t 5 /nobreak >nul + +REM 백엔드 중지 +echo. +echo ============================================ +echo 2. 백엔드 서비스 중지 중... +echo ============================================ + +docker-compose -f docker-compose.backend.win.yml down -v + +echo. +echo ⏳ 백엔드 서비스 완전 중지 대기 중... (5초) +timeout /t 5 /nobreak >nul + +REM 네트워크 정리 (선택사항) +echo. +echo ============================================ +echo 3. 네트워크 정리 중... +echo ============================================ + +docker network rm pms-network 2>nul || echo 네트워크가 이미 삭제되었습니다. + +echo. +echo ============================================ +echo ✅ 모든 서비스가 중지되었습니다! +echo ============================================ +echo. +echo 서비스 상태 확인: +echo docker ps +echo. +echo 서비스 시작: +echo start-all-separated.bat +echo. +echo ============================================ + +pause diff --git a/test-backend-build.bat b/test-backend-build.bat new file mode 100644 index 00000000..dad4aaee --- /dev/null +++ b/test-backend-build.bat @@ -0,0 +1,47 @@ +@echo off +chcp 65001 >nul + +REM 스크립트가 있는 디렉토리로 이동 +cd /d "%~dp0" + +echo ============================================ +echo 백엔드 빌드 테스트 (Windows 전용) +echo ============================================ +echo. + +echo 🔍 기존 컨테이너 정리 중... +docker-compose -f docker-compose.backend.win.yml down -v 2>nul + +echo. +echo 🚀 백엔드 빌드 시작... +docker-compose -f docker-compose.backend.win.yml build --no-cache + +if %errorlevel% equ 0 ( + echo. + echo ✅ 백엔드 빌드 성공! + echo. + echo 🚀 백엔드 시작 중... + docker-compose -f docker-compose.backend.win.yml up -d + + if %errorlevel% equ 0 ( + echo ✅ 백엔드 시작 완료! + echo. + echo 📊 컨테이너 상태: + docker-compose -f docker-compose.backend.win.yml ps + echo. + echo 📋 로그 확인: + echo docker-compose -f docker-compose.backend.win.yml logs -f + echo. + echo 🌐 헬스체크: + echo http://localhost:8080/health + ) else ( + echo ❌ 백엔드 시작 실패! + echo 로그를 확인하세요: docker-compose -f docker-compose.backend.win.yml logs + ) +) else ( + echo ❌ 백엔드 빌드 실패! + echo 위의 오류 메시지를 확인하세요. +) + +echo. +pause -- 2.43.0 From 7c96461f5946403ddbf173a78dd81217bbd9567a Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 17 Mar 2026 11:31:54 +0900 Subject: [PATCH 02/12] feat: enhance audit log functionality and file upload components - Updated the audit log controller to determine super admin status based on user type instead of company code. - Added detailed logging for column settings updates and batch updates in the table management controller, capturing user actions and changes made. - Implemented security measures in the audit log service to mask sensitive data for non-super admin users. - Introduced a new TableCellFile component to handle file attachments, supporting both objid and JSON array formats for file information. - Enhanced the file upload component to manage file states more effectively during record changes and mode transitions. These updates aim to improve the audit logging capabilities and file management features within the ERP system, ensuring better security and user experience. --- .../src/controllers/auditLogController.ts | 6 +- .../controllers/tableManagementController.ts | 48 ++++ backend-node/src/services/auditLogService.ts | 22 ++ frontend/components/admin/table-type/types.ts | 2 + frontend/components/v2/V2Input.tsx | 30 ++- .../file-upload/FileUploadComponent.tsx | 92 +++---- .../v2-file-upload/FileUploadComponent.tsx | 25 +- .../v2-table-list/TableListComponent.tsx | 233 +++++++++++++++--- 8 files changed, 364 insertions(+), 94 deletions(-) diff --git a/backend-node/src/controllers/auditLogController.ts b/backend-node/src/controllers/auditLogController.ts index cd59a435..30982af3 100644 --- a/backend-node/src/controllers/auditLogController.ts +++ b/backend-node/src/controllers/auditLogController.ts @@ -10,7 +10,7 @@ export const getAuditLogs = async ( ): Promise => { try { const userCompanyCode = req.user?.companyCode; - const isSuperAdmin = userCompanyCode === "*"; + const isSuperAdmin = req.user?.userType === "SUPER_ADMIN"; const { companyCode, @@ -63,7 +63,7 @@ export const getAuditLogStats = async ( ): Promise => { try { const userCompanyCode = req.user?.companyCode; - const isSuperAdmin = userCompanyCode === "*"; + const isSuperAdmin = req.user?.userType === "SUPER_ADMIN"; const { companyCode, days } = req.query; const targetCompany = isSuperAdmin @@ -91,7 +91,7 @@ export const getAuditLogUsers = async ( ): Promise => { try { const userCompanyCode = req.user?.companyCode; - const isSuperAdmin = userCompanyCode === "*"; + const isSuperAdmin = req.user?.userType === "SUPER_ADMIN"; const { companyCode } = req.query; const conditions: string[] = ["LOWER(u.status) = 'active'"]; diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 5c53094f..24ad771d 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -224,6 +224,31 @@ export async function updateColumnSettings( `컬럼 설정 업데이트 완료: ${tableName}.${columnName}, company: ${companyCode}` ); + auditLogService.log({ + companyCode: companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "UPDATE", + resourceType: "TABLE", + resourceId: `${tableName}.${columnName}`, + resourceName: settings.columnLabel || columnName, + tableName: "table_type_columns", + summary: `테이블 타입관리: ${tableName}.${columnName} 컬럼 설정 변경`, + changes: { + after: { + columnLabel: settings.columnLabel, + inputType: settings.inputType, + referenceTable: settings.referenceTable, + referenceColumn: settings.referenceColumn, + displayColumn: settings.displayColumn, + codeCategory: settings.codeCategory, + }, + fields: ["columnLabel", "inputType", "referenceTable", "referenceColumn", "displayColumn", "codeCategory"], + }, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + const response: ApiResponse = { success: true, message: "컬럼 설정을 성공적으로 저장했습니다.", @@ -339,6 +364,29 @@ export async function updateAllColumnSettings( `전체 컬럼 설정 일괄 업데이트 완료: ${tableName}, ${columnSettings.length}개, company: ${companyCode}` ); + const changedColumns = columnSettings + .filter((c) => c.columnName) + .map((c) => c.columnName) + .join(", "); + + auditLogService.log({ + companyCode: companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "BATCH_UPDATE", + resourceType: "TABLE", + resourceId: tableName, + resourceName: tableName, + tableName: "table_type_columns", + summary: `테이블 타입관리: ${tableName} 전체 컬럼 설정 일괄 변경 (${columnSettings.length}개)`, + changes: { + after: { columns: changedColumns, count: columnSettings.length }, + fields: columnSettings.filter((c) => c.columnName).map((c) => c.columnName!), + }, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + const response: ApiResponse = { success: true, message: "모든 컬럼 설정을 성공적으로 저장했습니다.", diff --git a/backend-node/src/services/auditLogService.ts b/backend-node/src/services/auditLogService.ts index c86a71fd..82c2566e 100644 --- a/backend-node/src/services/auditLogService.ts +++ b/backend-node/src/services/auditLogService.ts @@ -251,6 +251,28 @@ class AuditLogService { [...params, limit, offset] ); + const SECURITY_MASK = "(보안 항목 - 값 비공개)"; + const securedTables = ["table_type_columns"]; + + if (!isSuperAdmin) { + for (const entry of data) { + if (entry.table_name && securedTables.includes(entry.table_name) && entry.changes) { + const changes = typeof entry.changes === "string" ? JSON.parse(entry.changes) : entry.changes; + if (changes.before) { + for (const key of Object.keys(changes.before)) { + changes.before[key] = SECURITY_MASK; + } + } + if (changes.after) { + for (const key of Object.keys(changes.after)) { + changes.after[key] = SECURITY_MASK; + } + } + entry.changes = changes; + } + } + } + return { data, total }; } diff --git a/frontend/components/admin/table-type/types.ts b/frontend/components/admin/table-type/types.ts index 329b4049..c82f0d2b 100644 --- a/frontend/components/admin/table-type/types.ts +++ b/frontend/components/admin/table-type/types.ts @@ -66,6 +66,8 @@ export const INPUT_TYPE_COLORS: Record = { category: { color: "text-teal-600", bgColor: "bg-teal-50", barColor: "bg-teal-500", label: "카테고리", desc: "등록된 선택지", iconChar: "⊟" }, textarea: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-400", label: "여러 줄", desc: "긴 텍스트 입력", iconChar: "≡" }, radio: { color: "text-rose-600", bgColor: "bg-rose-50", barColor: "bg-rose-500", label: "라디오", desc: "하나만 선택", iconChar: "◉" }, + file: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "파일", desc: "파일 업로드", iconChar: "📎" }, + image: { color: "text-sky-600", bgColor: "bg-sky-50", barColor: "bg-sky-500", label: "이미지", desc: "이미지 표시", iconChar: "🖼" }, }; /** 컬럼 그룹 판별 */ diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index d6284b9b..3464f982 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -135,6 +135,14 @@ const TextInput = forwardRef< const [hasBlurred, setHasBlurred] = useState(false); const [validationError, setValidationError] = useState(""); + // 커서 위치 보존을 위한 내부 ref + const innerRef = useRef(null); + const combinedRef = (node: HTMLInputElement | null) => { + (innerRef as React.MutableRefObject).current = node; + if (typeof ref === "function") ref(node); + else if (ref) (ref as React.MutableRefObject).current = node; + }; + // 형식에 따른 값 포맷팅 const formatValue = useCallback( (val: string): string => { @@ -154,11 +162,15 @@ const TextInput = forwardRef< const handleChange = useCallback( (e: React.ChangeEvent) => { + const input = e.target; + const cursorPos = input.selectionStart ?? 0; let newValue = e.target.value; + const oldValue = input.value; + + const needsCursorFix = format === "biz_no" || format === "tel" || format === "currency"; // 형식에 따른 자동 포맷팅 if (format === "currency") { - // 숫자와 쉼표만 허용 newValue = newValue.replace(/[^\d,]/g, ""); newValue = formatCurrency(newValue); } else if (format === "biz_no") { @@ -167,6 +179,20 @@ const TextInput = forwardRef< newValue = formatTel(newValue); } + // 포맷팅 후 커서 위치 보정 (하이픈/쉼표 개수 차이 기반) + if (needsCursorFix) { + const separator = format === "currency" ? /,/g : /-/g; + const oldSeps = (oldValue.slice(0, cursorPos).match(separator) || []).length; + const newSeps = (newValue.slice(0, cursorPos).match(separator) || []).length; + const adjustedCursor = Math.min(cursorPos + (newSeps - oldSeps), newValue.length); + + requestAnimationFrame(() => { + if (innerRef.current) { + innerRef.current.setSelectionRange(adjustedCursor, adjustedCursor); + } + }); + } + // 입력 중 에러 표시 해제 (입력 중에는 관대하게) if (hasBlurred && validationError) { const { isValid } = validateInputFormat(newValue, format); @@ -244,7 +270,7 @@ const TextInput = forwardRef< return (
= ({ // 🆕 레코드 ID 변경 시 파일 목록 초기화 및 새로 로드 const prevRecordIdRef = useRef(null); + const prevIsRecordModeRef = useRef(null); useEffect(() => { - if (prevRecordIdRef.current !== recordId) { - console.log("📎 [FileUploadComponent] 레코드 ID 변경 감지:", { - prev: prevRecordIdRef.current, - current: recordId, - isRecordMode, - }); + const recordIdChanged = prevRecordIdRef.current !== null && prevRecordIdRef.current !== recordId; + const modeChanged = prevIsRecordModeRef.current !== null && prevIsRecordModeRef.current !== isRecordMode; + + if (recordIdChanged || modeChanged) { prevRecordIdRef.current = recordId; - - // 레코드 모드에서 레코드 ID가 변경되면 파일 목록 초기화 - if (isRecordMode) { - setUploadedFiles([]); + prevIsRecordModeRef.current = isRecordMode; + + // 레코드 변경 또는 등록 모드 전환 시 항상 파일 목록 초기화 + setUploadedFiles([]); + setRepresentativeImageUrl(null); + + // localStorage 캐시도 정리 (새 등록 모드 전환 시) + if (!isRecordMode) { + try { + const backupKey = getUniqueKey(); + localStorage.removeItem(backupKey); + if (typeof window !== "undefined") { + const globalFileState = (window as any).globalFileState || {}; + delete globalFileState[backupKey]; + (window as any).globalFileState = globalFileState; + } + } catch {} } + } else if (prevRecordIdRef.current === null) { + prevRecordIdRef.current = recordId; + prevIsRecordModeRef.current = isRecordMode; } - }, [recordId, isRecordMode]); + }, [recordId, isRecordMode, getUniqueKey]); // 컴포넌트 마운트 시 즉시 localStorage에서 파일 복원 useEffect(() => { if (!component?.id) return; + // 새 등록 모드(레코드 없음)에서는 localStorage 복원 스킵 - 빈 상태 유지 + if (!isRecordMode || !recordId) { + return; + } + try { // 🔑 레코드별 고유 키 사용 const backupKey = getUniqueKey(); const backupFiles = localStorage.getItem(backupKey); - console.log("🔎 [DEBUG-MOUNT] localStorage 확인:", { - backupKey, - hasBackup: !!backupFiles, - componentId: component.id, - recordId: recordId, - formDataId: formData?.id, - stackTrace: new Error().stack?.split('\n').slice(1, 4).join(' <- '), - }); if (backupFiles) { const parsedFiles = JSON.parse(backupFiles); if (parsedFiles.length > 0) { - console.log("🚀 [DEBUG-MOUNT] 파일 즉시 복원:", { - uniqueKey: backupKey, - componentId: component.id, - recordId: recordId, - restoredFiles: parsedFiles.length, - files: parsedFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })), - }); setUploadedFiles(parsedFiles); // 전역 상태에도 복원 (레코드별 고유 키 사용) @@ -210,7 +215,7 @@ const FileUploadComponent: React.FC = ({ } catch (e) { console.warn("컴포넌트 마운트 시 파일 복원 실패:", e); } - }, [component.id, getUniqueKey, recordId]); // 레코드별 고유 키 변경 시 재실행 + }, [component.id, getUniqueKey, recordId, isRecordMode]); // 🆕 모달 닫힘/저장 성공 시 localStorage 파일 캐시 정리 (등록 후 재등록 시 이전 파일 잔존 방지) useEffect(() => { @@ -325,9 +330,14 @@ const FileUploadComponent: React.FC = ({ const loadComponentFiles = useCallback(async () => { if (!component?.id) return false; + // 새 등록 모드(레코드 없음)에서는 파일 조회 스킵 - 빈 상태 유지 + if (!isRecordMode || !recordId) { + return false; + } + try { // 🔑 레코드 모드: 해당 행의 파일만 조회 - if (isRecordMode && recordTableName && recordId) { + if (recordTableName) { console.log("📂 [FileUploadComponent] 레코드 모드 파일 조회:", { tableName: recordTableName, recordId: recordId, @@ -457,17 +467,6 @@ const FileUploadComponent: React.FC = ({ // 컴포넌트 파일 동기화 (DB 우선, localStorage는 보조) useEffect(() => { const componentFiles = (component as any)?.uploadedFiles || []; - const lastUpdate = (component as any)?.lastFileUpdate; - - console.log("🔄 FileUploadComponent 파일 동기화 시작:", { - componentId: component.id, - componentFiles: componentFiles.length, - formData: formData, - screenId: formData?.screenId, - tableName: formData?.tableName, // 🔍 테이블명 확인 - recordId: formData?.id, // 🔍 레코드 ID 확인 - currentUploadedFiles: uploadedFiles.length, - }); // 🔒 항상 DB에서 최신 파일 목록을 조회 (멀티테넌시 격리) loadComponentFiles().then((dbLoadSuccess) => { @@ -475,15 +474,22 @@ const FileUploadComponent: React.FC = ({ return; // DB 로드 성공 시 localStorage 무시 } - // DB 로드 실패 시에만 기존 로직 사용 (하위 호환성) + // 새 등록 모드(레코드 없음)에서는 fallback 로드도 스킵 - 항상 빈 상태 유지 + if (!isRecordMode || !recordId) { + return; + } - // 전역 상태에서 최신 파일 정보 가져오기 + // DB 로드 실패 시에만 기존 로직 사용 (하위 호환성) const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {}; - const globalFiles = globalFileState[component.id] || []; + const uniqueKeyForFallback = getUniqueKey(); + const globalFiles = globalFileState[uniqueKeyForFallback] || globalFileState[component.id] || []; // 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성) const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles; + if (currentFiles.length === 0) { + return; + } // 최신 파일과 현재 파일 비교 if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) { @@ -491,7 +497,7 @@ const FileUploadComponent: React.FC = ({ setForceUpdate((prev) => prev + 1); } }); - }, [loadComponentFiles, component.id, (component as any)?.uploadedFiles, (component as any)?.lastFileUpdate]); + }, [loadComponentFiles, component.id, (component as any)?.uploadedFiles, (component as any)?.lastFileUpdate, isRecordMode, recordId, getUniqueKey]); // 전역 상태 변경 감지 (모든 파일 컴포넌트 동기화 + 화면 복원) useEffect(() => { diff --git a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx index 0ba94b5d..58db0ad2 100644 --- a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx @@ -147,13 +147,10 @@ const FileUploadComponent: React.FC = ({ prevRecordIdRef.current = recordId; prevIsRecordModeRef.current = isRecordMode; - // 레코드 ID가 변경되거나 등록 모드(isRecordMode=false)로 전환되면 파일 목록 초기화 - // 등록 모드에서는 항상 빈 상태로 시작해야 함 - if (isRecordMode || !recordId) { - setUploadedFiles([]); - setRepresentativeImageUrl(null); - filesLoadedFromObjidRef.current = false; - } + // 레코드 변경 또는 등록 모드 전환 시 항상 파일 목록 초기화 + setUploadedFiles([]); + setRepresentativeImageUrl(null); + filesLoadedFromObjidRef.current = false; } else if (prevIsRecordModeRef.current === null) { // 초기 마운트 시 모드 저장 prevIsRecordModeRef.current = isRecordMode; @@ -198,7 +195,17 @@ const FileUploadComponent: React.FC = ({ const imageObjidFromFormData = formData?.[columnName]; useEffect(() => { - if (!imageObjidFromFormData) return; + if (!imageObjidFromFormData) { + // formData에서 값이 사라지면 파일 목록도 초기화 (새 등록 시) + if (uploadedFiles.length > 0 && !isRecordMode) { + setUploadedFiles([]); + filesLoadedFromObjidRef.current = false; + } + return; + } + + // 등록 모드(새 레코드)일 때는 이전 파일을 로드하지 않음 + if (!isRecordMode) return; const rawValue = String(imageObjidFromFormData); // 콤마 구분 다중 objid 또는 단일 objid 모두 처리 @@ -255,7 +262,7 @@ const FileUploadComponent: React.FC = ({ console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error); } })(); - }, [imageObjidFromFormData, columnName, component.id]); + }, [imageObjidFromFormData, columnName, component.id, isRecordMode]); // 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너 // 🆕 columnName도 체크하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분 diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 15b7a13b..4649350b 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -123,6 +123,196 @@ const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => { }); TableCellImage.displayName = "TableCellImage"; +// 📎 테이블 셀 파일 컴포넌트 +// objid(콤마 구분 포함) 또는 JSON 배열 값을 받아 파일명 표시 + 클릭 시 읽기 전용 모달 +const TableCellFile: React.FC<{ value: string }> = React.memo(({ value }) => { + const [fileInfos, setFileInfos] = React.useState>([]); + const [loading, setLoading] = React.useState(true); + const [modalOpen, setModalOpen] = React.useState(false); + + React.useEffect(() => { + let mounted = true; + const rawValue = String(value).trim(); + if (!rawValue || rawValue === "-") { + setLoading(false); + return; + } + + // JSON 배열 형태인지 확인 + try { + const parsed = JSON.parse(rawValue); + if (Array.isArray(parsed)) { + const infos = parsed.map((f: any) => ({ + objid: String(f.objid || f.id || ""), + name: f.realFileName || f.real_file_name || f.name || "파일", + ext: f.fileExt || f.file_ext || "", + size: f.fileSize || f.file_size || 0, + })); + if (mounted) { + setFileInfos(infos); + setLoading(false); + } + return; + } + } catch { + // JSON 파싱 실패 → objid 문자열로 처리 + } + + // 콤마 구분 objid 또는 단일 objid + const objids = rawValue.split(",").map(s => s.trim()).filter(Boolean); + if (objids.length === 0) { + if (mounted) setLoading(false); + return; + } + + Promise.all( + objids.map(async (oid) => { + try { + const { getFileInfoByObjid } = await import("@/lib/api/file"); + const res = await getFileInfoByObjid(oid); + if (res.success && res.data) { + return { + objid: oid, + name: res.data.realFileName || "파일", + ext: res.data.fileExt || "", + size: res.data.fileSize || 0, + }; + } + } catch {} + return { objid: oid, name: `파일(${oid})`, ext: "" }; + }) + ).then((results) => { + if (mounted) { + setFileInfos(results); + setLoading(false); + } + }); + + return () => { mounted = false; }; + }, [value]); + + if (loading) { + return ...; + } + + if (fileInfos.length === 0) { + return -; + } + + const { Paperclip, Download: DownloadIcon, FileText: FileTextIcon } = require("lucide-react"); + const fileNames = fileInfos.map(f => f.name).join(", "); + + const getFileIconClass = (ext: string) => { + const e = (ext || "").toLowerCase().replace(".", ""); + if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(e)) return "text-primary"; + if (["pdf"].includes(e)) return "text-destructive"; + if (["doc", "docx", "hwp", "hwpx"].includes(e)) return "text-blue-500"; + if (["xls", "xlsx"].includes(e)) return "text-emerald-500"; + return "text-muted-foreground"; + }; + + const handleDownload = async (file: { objid: string; name: string }) => { + if (!file.objid) return; + try { + const { apiClient } = await import("@/lib/api/client"); + const response = await apiClient.get(`/files/download/${file.objid}`, { + responseType: "blob", + }); + const blob = new Blob([response.data]); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = file.name || "download"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + } catch (err) { + console.error("파일 다운로드 오류:", err); + } + }; + + + return ( + <> +
{ + e.stopPropagation(); + setModalOpen(true); + }} + > + + + {fileInfos.length === 1 ? fileNames : `첨부파일 ${fileInfos.length}건`} + +
+ + {modalOpen && ( +
{ + e.stopPropagation(); + setModalOpen(false); + }} + > +
e.stopPropagation()} + > +
+
+ + 첨부파일 ({fileInfos.length}) +
+ +
+
+ {fileInfos.map((file, idx) => ( +
+ +
+

{file.name}

+ {file.size ? ( +

+ {file.size > 1048576 + ? `${(file.size / 1048576).toFixed(1)} MB` + : `${(file.size / 1024).toFixed(0)} KB`} +

+ ) : null} +
+ +
+ ))} +
+
+
+ )} + + ); +}); +TableCellFile.displayName = "TableCellFile"; + // 이미지 blob 로딩 헬퍼 function loadImageBlob( objid: string, @@ -4303,8 +4493,7 @@ export const TableListComponent: React.FC = ({ return ; } - // 📎 첨부파일 타입: 파일 아이콘과 개수 표시 - // 컬럼명이 'attachments'를 포함하거나, inputType이 file/attachment인 경우 + // 📎 첨부파일 타입: TableCellFile 컴포넌트로 렌더링 (objid, JSON 배열 모두 지원) const isAttachmentColumn = inputType === "file" || inputType === "attachment" || @@ -4312,41 +4501,11 @@ export const TableListComponent: React.FC = ({ column.columnName?.toLowerCase().includes("attachment") || column.columnName?.toLowerCase().includes("file"); - if (isAttachmentColumn) { - // JSONB 배열 또는 JSON 문자열 파싱 - let files: any[] = []; - try { - if (typeof value === "string" && value.trim()) { - const parsed = JSON.parse(value); - files = Array.isArray(parsed) ? parsed : []; - } else if (Array.isArray(value)) { - files = value; - } else if (value && typeof value === "object") { - // 단일 객체인 경우 배열로 변환 - files = [value]; - } - } catch (e) { - // 파싱 실패 시 빈 배열 - console.warn("📎 [TableList] 첨부파일 파싱 실패:", { columnName: column.columnName, value, error: e }); - } - - if (!files || files.length === 0) { - return -; - } - - // 파일 이름 표시 (여러 개면 쉼표로 구분) - const { Paperclip } = require("lucide-react"); - const fileNames = files.map((f: any) => f.realFileName || f.real_file_name || f.name || "파일").join(", "); - - return ( -
- - - {fileNames} - - {files.length > 1 && ({files.length})} -
- ); + if (isAttachmentColumn && value) { + return ; + } + if (isAttachmentColumn && !value) { + return -; } // 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원) -- 2.43.0 From 837e0aca412db81848530df566720baeeff75c47 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 17 Mar 2026 11:34:15 +0900 Subject: [PATCH 03/12] refactor: update dynamic form service to utilize V2 layout structure - Replaced the previous screen layout query with a new query to fetch V2 layout data, ensuring compatibility with the updated layout structure. - Enhanced component extraction logic to handle button components from the V2 layout, improving the control management process. - Updated logging to provide clearer insights during component checks and control management execution. These changes aim to streamline the dynamic form service by leveraging the new V2 layout capabilities, enhancing overall functionality and maintainability. --- .../src/services/dynamicFormService.ts | 79 +++++++++---------- 1 file changed, 36 insertions(+), 43 deletions(-) diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 604405c3..bc65822c 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -1707,71 +1707,66 @@ export class DynamicFormService { try { console.log(`🎯 제어관리 설정 확인 중... (screenId: ${screenId})`); - // 화면의 저장 버튼에서 제어관리 설정 조회 - const screenLayouts = await query<{ - component_id: string; - properties: any; + // V2 레이아웃에서 layout_data jsonb 조회 + const v2Layouts = await query<{ + layout_id: number; + layout_data: any; }>( - `SELECT component_id, properties - FROM screen_layouts - WHERE screen_id = $1 - AND component_type IN ('component', 'v2-button-primary')`, - [screenId] + `SELECT layout_id, layout_data + FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2`, + [screenId, companyCode] ); - console.log(`📋 화면 컴포넌트 조회 결과:`, screenLayouts.length); + if (v2Layouts.length === 0) { + console.log(`ℹ️ V2 레이아웃이 없습니다. (화면 ID: ${screenId}, company: ${companyCode})`); + return; + } + + // layout_data.components 배열에서 버튼 컴포넌트 추출 + const layoutData = v2Layouts[0].layout_data; + const components: any[] = layoutData?.components || []; + + console.log(`📋 V2 컴포넌트 조회 결과: ${components.length}개`); - // 저장 버튼 중에서 제어관리가 활성화된 것 찾기 let controlConfigFound = false; - for (const layout of screenLayouts) { - const properties = layout.properties as any; + for (const comp of components) { + const overrides = comp?.overrides || {}; - // 디버깅: 모든 컴포넌트 정보 출력 - console.log(`🔍 컴포넌트 검사:`, { - componentId: layout.component_id, - componentType: properties?.componentType, - actionType: properties?.componentConfig?.action?.type, - enableDataflowControl: - properties?.webTypeConfig?.enableDataflowControl, - hasDataflowConfig: !!properties?.webTypeConfig?.dataflowConfig, - hasDiagramId: - !!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId, - hasFlowControls: - !!properties?.webTypeConfig?.dataflowConfig?.flowControls, - }); + const isButtonComponent = + overrides?.type === "v2-button-primary" || + (comp?.url || "").includes("v2-button-primary"); - // 버튼 컴포넌트이고 제어관리가 활성화된 경우 // triggerType에 맞는 액션 타입 매칭: insert/update -> save, delete -> delete - const buttonActionType = properties?.componentConfig?.action?.type; + const buttonActionType = overrides?.action?.type; const isMatchingAction = (triggerType === "delete" && buttonActionType === "delete") || ((triggerType === "insert" || triggerType === "update") && buttonActionType === "save"); - - const isButtonComponent = - properties?.componentType === "button-primary" || - properties?.componentType === "v2-button-primary"; - + + console.log(`🔍 V2 컴포넌트 검사:`, { + componentId: comp?.id, + type: overrides?.type, + actionType: buttonActionType, + enableDataflowControl: overrides?.enableDataflowControl, + hasDataflowConfig: !!overrides?.dataflowConfig, + }); + if ( isButtonComponent && isMatchingAction && - properties?.webTypeConfig?.enableDataflowControl === true + overrides?.enableDataflowControl === true ) { - const dataflowConfig = properties?.webTypeConfig?.dataflowConfig; - - // 다중 제어 설정 확인 (flowControls 배열) + const dataflowConfig = overrides?.dataflowConfig; const flowControls = dataflowConfig?.flowControls || []; - // flowControls가 있으면 다중 제어 실행, 없으면 기존 단일 제어 실행 if (flowControls.length > 0) { controlConfigFound = true; console.log(`🎯 다중 제어관리 설정 발견: ${flowControls.length}개`); - // 순서대로 정렬 const sortedControls = [...flowControls].sort( (a: any, b: any) => (a.order || 0) - (b.order || 0) ); - // 다중 제어 순차 실행 await this.executeMultipleFlowControls( sortedControls, savedData, @@ -1782,13 +1777,12 @@ export class DynamicFormService { companyCode ); } else if (dataflowConfig?.selectedDiagramId) { - // 기존 단일 제어 실행 (하위 호환성) controlConfigFound = true; const diagramId = dataflowConfig.selectedDiagramId; const relationshipId = dataflowConfig.selectedRelationshipId; console.log(`🎯 단일 제어관리 설정 발견:`, { - componentId: layout.component_id, + componentId: comp?.id, diagramId, relationshipId, triggerType, @@ -1806,7 +1800,6 @@ export class DynamicFormService { ); } - // 첫 번째 설정된 버튼의 제어관리만 실행 break; } } -- 2.43.0 From acc304ccd6516c032fde3922ae5444cd9999507e Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 17 Mar 2026 11:36:15 +0900 Subject: [PATCH 04/12] Merge branch 'gbpark-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node -- 2.43.0 From 9d7ec613db40ec364ced131e1a75eaed88f88230 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 17 Mar 2026 14:31:45 +0900 Subject: [PATCH 05/12] fix: update default visibility settings for buttons in V2SplitPanelLayoutConfigPanel - Changed default state for the search and add buttons in both left and right panels to false. - Updated the default state for the edit button in both panels to true. - Updated the default state for the delete button in both panels to true. These adjustments aim to improve the initial configuration experience for users by setting more appropriate defaults for button visibility. --- .../V2SplitPanelLayoutConfigPanel.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx b/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx index 7895a3d5..c788612e 100644 --- a/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx @@ -1125,28 +1125,28 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<
updateLeftPanel({ showSearch: checked }) } /> updateLeftPanel({ showAdd: checked }) } /> updateLeftPanel({ showEdit: checked }) } /> updateLeftPanel({ showDelete: checked }) } @@ -1574,28 +1574,28 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<
updateRightPanel({ showSearch: checked }) } /> updateRightPanel({ showAdd: checked }) } /> updateRightPanel({ showEdit: checked }) } /> updateRightPanel({ showDelete: checked }) } -- 2.43.0 From 764e0ae568642389710c063097428857e8f2c803 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 17 Mar 2026 16:20:07 +0900 Subject: [PATCH 06/12] style: adjust panel and text sizes in Table Management and Column Detail components - Increased the width of the left panel in the Table Management page from 240px to 280px for better visibility. - Updated text sizes in the Table Management page for table names and descriptions to enhance readability. - Expanded the width of the right panel in the Table Management page from 320px to 380px to accommodate additional content. - Adjusted text sizes in the Column Detail Panel for improved clarity and consistency. These changes aim to enhance the user interface and improve the overall user experience in the table management section. --- frontend/app/(main)/admin/systemMng/tableMngList/page.tsx | 8 ++++---- .../components/admin/table-type/ColumnDetailPanel.tsx | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index 44051e28..356d55c3 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -1382,7 +1382,7 @@ export default function TableManagementPage() { {/* 3패널 메인 */}
{/* 좌측: 테이블 목록 (240px) */} -
+
{/* 검색 */}
@@ -1482,13 +1482,13 @@ export default function TableManagementPage() {
{table.displayName || table.tableName}
-
+
{table.tableName}
@@ -1605,7 +1605,7 @@ export default function TableManagementPage() { {/* 우측: 상세 패널 (selectedColumn 있을 때만) */} {selectedColumn && ( -
+
c.columnName === selectedColumn) ?? null} tables={tables} diff --git a/frontend/components/admin/table-type/ColumnDetailPanel.tsx b/frontend/components/admin/table-type/ColumnDetailPanel.tsx index 77f5dedf..1d053775 100644 --- a/frontend/components/admin/table-type/ColumnDetailPanel.tsx +++ b/frontend/components/admin/table-type/ColumnDetailPanel.tsx @@ -126,12 +126,12 @@ export function ColumnDetailPanel({ {conf.iconChar} {conf.label} - + {conf.desc} -- 2.43.0 From ab98c655b5e802e4ea3d571edb6481f066857f80 Mon Sep 17 00:00:00 2001 From: kmh Date: Tue, 17 Mar 2026 16:25:42 +0900 Subject: [PATCH 07/12] feat: enhance EditModal and input components for improved layout and functionality - Updated EditModal to prioritize screen resolution settings from layout data, ensuring accurate dimension calculations. - Refined modal styling for better responsiveness and consistency with ScreenModal. - Adjusted V2Input and V2Select components to remove unnecessary gap styling, streamlining their layout. - Enhanced DynamicComponentRenderer to handle horizontal labels more effectively with an external wrapper. These changes aim to improve the user experience and visual consistency across the application. --- frontend/components/screen/EditModal.tsx | 69 ++++++++++--------- frontend/components/v2/V2Input.tsx | 10 +-- frontend/components/v2/V2Select.tsx | 2 +- .../lib/registry/DynamicComponentRenderer.tsx | 4 +- 4 files changed, 44 insertions(+), 41 deletions(-) diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index b3d0c1cd..c49dca58 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -563,8 +563,20 @@ export const EditModal: React.FC = ({ className }) => { if (screenInfo && layoutData) { const components = layoutData.components || []; - // 화면의 실제 크기 계산 - const dimensions = calculateScreenDimensions(components); + // 화면 관리에서 설정한 해상도 우선 사용 (ScreenModal과 동일) + const screenResolution = (layoutData as any).screenResolution || (screenInfo as any).screenResolution; + + let dimensions; + if (screenResolution && screenResolution.width && screenResolution.height) { + dimensions = { + width: screenResolution.width, + height: screenResolution.height, + offsetX: 0, + offsetY: 0, + }; + } else { + dimensions = calculateScreenDimensions(components); + } setScreenDimensions(dimensions); setScreenData({ @@ -1547,31 +1559,25 @@ export const EditModal: React.FC = ({ className }) => { } }; - // 모달 크기 설정 - 화면관리 설정 크기 + 헤더 + // 모달 크기 설정 - ScreenModal과 동일한 방식 (maxHeight로 유연 처리) const getModalStyle = () => { if (!screenDimensions) { return { - className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0", - style: undefined, // undefined로 변경 - defaultWidth/defaultHeight 사용 + className: "w-fit min-w-[400px] max-w-4xl overflow-hidden", + style: { padding: 0, gap: 0, maxHeight: "calc(100dvh - 8px)" }, }; } - // 화면관리에서 설정한 크기 = 컨텐츠 영역 크기 - // 실제 모달 크기 = 컨텐츠 + 헤더 + gap + padding + 라벨 공간 - const headerHeight = 52; // DialogHeader (타이틀 + border-b + py-3) - const dialogGap = 16; // DialogContent gap-4 - const extraPadding = 24; // 추가 여백 (안전 마진) - const labelSpace = 30; // 입력 필드 위 라벨 공간 (-top-6 = 24px + 여유) - - const totalHeight = screenDimensions.height + headerHeight + dialogGap + extraPadding + labelSpace; + const finalWidth = Math.min(screenDimensions.width, window.innerWidth * 0.98); return { className: "overflow-hidden p-0", style: { - width: `${Math.min(screenDimensions.width + 48, window.innerWidth * 0.98)}px`, // 좌우 패딩 추가 - height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`, + width: `${finalWidth}px`, + maxHeight: "calc(100dvh - 8px)", maxWidth: "98vw", - maxHeight: "95vh", + padding: 0, + gap: 0, }, }; }; @@ -1593,7 +1599,7 @@ export const EditModal: React.FC = ({ className }) => {
-
+
{loading ? (
@@ -1608,42 +1614,41 @@ export const EditModal: React.FC = ({ className }) => { >
{ - const baseHeight = (screenDimensions?.height || 600) + 30; + const baseHeight = screenDimensions?.height || 600; if (activeConditionalComponents.length > 0) { - // 조건부 레이어 컴포넌트 중 가장 아래 위치 계산 const offsetY = screenDimensions?.offsetY || 0; let maxBottom = 0; activeConditionalComponents.forEach((comp) => { - const y = parseFloat(comp.position?.y?.toString() || "0") - offsetY + 30; + const y = parseFloat(comp.position?.y?.toString() || "0") - offsetY; const h = parseFloat(comp.size?.height?.toString() || "40"); maxBottom = Math.max(maxBottom, y + h); }); - return Math.max(baseHeight, maxBottom + 20); // 20px 여백 + return `${Math.max(baseHeight, maxBottom + 20)}px`; } - return baseHeight; + return `${baseHeight}px`; })(), - transformOrigin: "center center", - maxWidth: "100%", + overflow: "visible", }} > {/* 기본 레이어 컴포넌트 렌더링 */} {screenData.components.map((component) => { - // 컴포넌트 위치를 offset만큼 조정 const offsetX = screenDimensions?.offsetX || 0; const offsetY = screenDimensions?.offsetY || 0; - const labelSpace = 30; // 라벨 공간 (입력 필드 위 -top-6 라벨용) + // screenResolution이 있으면 offsetY=0이므로 디자이너 좌표 그대로 사용 + // offsetY > 0 (자동 계산)일 때만 라벨 공간 보정 + const labelSpace = offsetY > 0 ? 30 : 0; const adjustedComponent = { ...component, position: { ...component.position, x: parseFloat(component.position?.x?.toString() || "0") - offsetX, - y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace, // 라벨 공간 추가 + y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace, }, }; @@ -1709,11 +1714,11 @@ export const EditModal: React.FC = ({ className }) => { ); })} - {/* 🆕 조건부 레이어 컴포넌트 렌더링 */} + {/* 조건부 레이어 컴포넌트 렌더링 */} {activeConditionalComponents.map((component) => { const offsetX = screenDimensions?.offsetX || 0; const offsetY = screenDimensions?.offsetY || 0; - const labelSpace = 30; + const labelSpace = offsetY > 0 ? 30 : 0; const adjustedComponent = { ...component, diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index d6284b9b..58d80be5 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -89,12 +89,12 @@ function formatTel(value: string): string { return `${digits.slice(0, 4)}-${digits.slice(4, 8)}`; } - // 서울: 02 → 2-4-4 + // 서울: 02 → 9자리 2-3-4, 10자리 2-4-4 if (digits.startsWith("02")) { if (digits.length <= 2) return digits; - if (digits.length <= 6) return `${digits.slice(0, 2)}-${digits.slice(2)}`; - if (digits.length <= 10) return `${digits.slice(0, 2)}-${digits.slice(2, 6)}-${digits.slice(6)}`; - return `${digits.slice(0, 2)}-${digits.slice(2, 6)}-${digits.slice(6, 10)}`; + if (digits.length <= 5) return `${digits.slice(0, 2)}-${digits.slice(2)}`; + const mid = digits.length >= 10 ? 4 : 3; + return `${digits.slice(0, 2)}-${digits.slice(2, 2 + mid)}-${digits.slice(2 + mid, 2 + mid + 4)}`; } // 안심번호: 050x → 4-4-4 @@ -1202,7 +1202,7 @@ export const V2Input = forwardRef((props, ref) => ref={ref} id={id} className={cn( - "flex gap-1", + "flex", labelPos === "left" ? "flex-row items-center" : "flex-row-reverse items-center", )} style={{ diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index 9ced9670..a078ea67 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -1291,7 +1291,7 @@ export const V2Select = forwardRef((props, ref) = ref={ref} id={id} className={cn( - "flex gap-1", + "flex", labelPos === "left" ? "flex-row items-center" : "flex-row-reverse items-center", isDesignMode && "pointer-events-none", )} diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 24a50cdd..f06a43fe 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -808,12 +808,10 @@ export const DynamicComponentRenderer: React.FC = ? component.style?.labelText || (component as any).label || component.componentConfig?.label : undefined; - // 🔧 수평 라벨(left/right) 감지 → 런타임에서만 외부 flex 컨테이너로 라벨 처리 - // 디자인 모드에서는 V2 컴포넌트가 자체적으로 라벨을 렌더링 (height 체인 문제 방지) + // 🔧 수평 라벨(left/right) 감지 → 외부 absolute 래퍼로 라벨 처리 (카테고리 셀렉트와 동일 방식) const labelPosition = component.style?.labelPosition; const isV2Component = componentType?.startsWith("v2-"); const needsExternalHorizLabel = !!( - !props.isDesignMode && isV2Component && effectiveLabel && (labelPosition === "left" || labelPosition === "right") -- 2.43.0 From 6d0c52e17a7c4c6914b20b1c8eaafe1996ebe1a9 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 17 Mar 2026 16:37:04 +0900 Subject: [PATCH 08/12] refactor: remove component count display in TabsWidget - Removed the display of the component count next to the tab labels in the TabsWidget. - This change simplifies the tab interface by eliminating unnecessary information, enhancing the overall user experience. These updates aim to streamline the visual presentation of the TabsWidget component. --- frontend/components/screen/widgets/TabsWidget.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx index 688a6ca7..efc28413 100644 --- a/frontend/components/screen/widgets/TabsWidget.tsx +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -477,9 +477,6 @@ export function TabsWidget({
{tab.label} - {tab.components && tab.components.length > 0 && ( - ({tab.components.length}) - )} {allowCloseable && ( + {extraContent}
@@ -378,13 +380,34 @@ export const V2ItemRoutingConfigPanel: React.FC = icon={} /> - {/* ─── 모달 표시 컬럼 (등록 모드에서만 의미 있지만 항상 설정 가능) ─── */} + {/* ─── 품목 추가 모달 (컬럼 + 크기 설정) ─── */} update({ modalDisplayColumns: cols })} tableName={config.dataSource.itemTable} - title="품목 추가 모달 컬럼" + title="품목 추가 모달" icon={} + extraContent={ +
+ 모달 크기 (px) +
+
+ 가로 (너비) + update({ addModalMaxWidth: `${e.target.value}px` })} + placeholder="600" className="h-7 text-xs" /> +
+
+ 세로 (목록 높이) + update({ addModalListMaxHeight: `${e.target.value}px` })} + placeholder="340" className="h-7 text-xs" /> +
+
+
+ } /> {/* ─── 품목 필터 조건 ─── */} diff --git a/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx b/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx index d0144d0b..53717003 100644 --- a/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx +++ b/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx @@ -458,7 +458,7 @@ export function ItemRoutingComponent({ {/* ════ 품목 추가 다이얼로그 (테이블 형태 + 검색) ════ */} - + 품목 추가 @@ -481,14 +481,14 @@ export function ItemRoutingComponent({
-
+
{allItems.length === 0 ? (

품목이 없습니다

) : ( - - +
+ {modalDisplayCols.map((col) => ( diff --git a/frontend/lib/registry/components/v2-item-routing/types.ts b/frontend/lib/registry/components/v2-item-routing/types.ts index 08fe73c2..23a3a39b 100644 --- a/frontend/lib/registry/components/v2-item-routing/types.ts +++ b/frontend/lib/registry/components/v2-item-routing/types.ts @@ -64,6 +64,10 @@ export interface ItemRoutingConfig { modalDisplayColumns?: ColumnDef[]; /** 품목 조회 시 사전 필터 조건 */ itemFilterConditions?: ItemFilterCondition[]; + /** 품목 추가 모달 최대 너비 (px 또는 vw, 기본: 600px) */ + addModalMaxWidth?: string; + /** 품목 추가 모달 목록 최대 높이 (px, 기본: 340px) */ + addModalListMaxHeight?: string; } // 컴포넌트 Props -- 2.43.0 From ae4fe7a66e7337bb93b53797c342dd56f3bec3ce Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 17 Mar 2026 17:37:40 +0900 Subject: [PATCH 10/12] chore: update .gitignore and remove quick insert options from button configurations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added new entries to .gitignore for multi-agent MCP task queue and related rules. - Removed "즉시 저장" (quick insert) options from the ScreenSettingModal and BasicTab components to streamline button configurations. - Cleaned up unused event options in the V2ButtonConfigPanel to enhance clarity and maintainability. These changes aim to improve project organization and simplify the user interface by eliminating redundant options. --- .gitignore | 6 ++++ backend-node/src/app.ts | 1 + backend-node/src/database/db.ts | 3 +- .../admin/dashboard/DashboardTopMenu.tsx | 6 ++-- .../admin/dashboard/utils/queryHelpers.ts | 19 ++++++------ .../components/dashboard/DashboardViewer.tsx | 6 ++-- .../widgets/DeliveryTodayStatsWidget.tsx | 3 +- .../dashboard/widgets/MapTestWidgetV2.tsx | 2 +- .../report/designer/ReportPreviewModal.tsx | 3 +- .../EnhancedInteractiveScreenViewer.tsx | 9 ++++-- .../screen/InteractiveDataTable.tsx | 9 ++++-- .../screen/InteractiveScreenViewer.tsx | 9 ++++-- .../screen/InteractiveScreenViewerDynamic.tsx | 9 ++++-- .../components/screen/ScreenSettingModal.tsx | 1 - .../screen/config-panels/DateConfigPanel.tsx | 6 ++-- .../config-panels/button-config/BasicTab.tsx | 4 --- .../components/screen/widgets/FlowWidget.tsx | 6 ++-- .../screen/widgets/types/DateWidget.tsx | 6 ++-- .../components/unified/UnifiedRepeater.tsx | 12 +++++--- frontend/components/v2/V2Repeater.tsx | 7 +++-- .../v2/config-panels/V2ButtonConfigPanel.tsx | 12 -------- frontend/components/vehicle/VehicleReport.tsx | 4 +-- .../date-input/DateInputComponent.tsx | 3 +- .../modal-repeater-table/RepeaterTable.tsx | 4 +-- .../SimpleRepeaterTableComponent.tsx | 3 +- .../table-list/TableListComponent.tsx | 3 +- .../v2-table-list/TableListComponent.tsx | 3 +- .../TimelineSchedulerComponent.tsx | 7 +++-- .../components/ItemTimelineCard.tsx | 2 +- .../hooks/useTimelineData.ts | 2 +- .../utils/conflictDetection.ts | 2 +- .../pop-components/pop-search/types.ts | 2 +- frontend/lib/services/enhancedFormService.ts | 2 +- frontend/lib/utils/autoGeneration.ts | 9 +++--- frontend/lib/utils/buttonActions.ts | 6 ++-- frontend/lib/utils/formValidation.ts | 2 +- frontend/lib/utils/localDate.ts | 30 +++++++++++++++++++ .../services/ScheduleGeneratorService.ts | 4 +-- 38 files changed, 144 insertions(+), 83 deletions(-) create mode 100644 frontend/lib/utils/localDate.ts diff --git a/.gitignore b/.gitignore index 552d1265..b6114eb7 100644 --- a/.gitignore +++ b/.gitignore @@ -182,3 +182,9 @@ scripts/browser-test-*.js # 개인 작업 문서 popdocs/ .cursor/rules/popdocs-safety.mdc + +# 멀티 에이전트 MCP 태스크 큐 +mcp-task-queue/ +.cursor/rules/multi-agent-pm.mdc +.cursor/rules/multi-agent-worker.mdc +.cursor/rules/multi-agent-tester.mdc diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 0cd44741..41926dd0 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -1,4 +1,5 @@ import "dotenv/config"; +process.env.TZ = "Asia/Seoul"; import "express-async-errors"; // async 라우트 핸들러의 에러를 Express 에러 핸들러로 자동 전달 import express from "express"; import cors from "cors"; diff --git a/backend-node/src/database/db.ts b/backend-node/src/database/db.ts index 6fc10cf1..d74e6cd8 100644 --- a/backend-node/src/database/db.ts +++ b/backend-node/src/database/db.ts @@ -66,8 +66,9 @@ export const initializePool = (): Pool => { // 연결 풀 이벤트 핸들러 pool.on("connect", (client) => { + client.query("SET timezone = 'Asia/Seoul'"); if (config.debug) { - console.log("✅ PostgreSQL 클라이언트 연결 생성"); + console.log("✅ PostgreSQL 클라이언트 연결 생성 (timezone: Asia/Seoul)"); } }); diff --git a/frontend/components/admin/dashboard/DashboardTopMenu.tsx b/frontend/components/admin/dashboard/DashboardTopMenu.tsx index 1f0419c7..eeaa73fc 100644 --- a/frontend/components/admin/dashboard/DashboardTopMenu.tsx +++ b/frontend/components/admin/dashboard/DashboardTopMenu.tsx @@ -82,7 +82,8 @@ export function DashboardTopMenu({ ) => { if (format === "png") { const link = document.createElement("a"); - const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.png`; + const _fd = new Date(); + const filename = `${dashboardTitle || "dashboard"}_${_fd.getFullYear()}-${String(_fd.getMonth() + 1).padStart(2, "0")}-${String(_fd.getDate()).padStart(2, "0")}.png`; link.download = filename; link.href = dataUrl; document.body.appendChild(link); @@ -111,7 +112,8 @@ export function DashboardTopMenu({ }); pdf.addImage(dataUrl, "PNG", 0, 0, imgWidth, imgHeight); - const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.pdf`; + const _pd = new Date(); + const filename = `${dashboardTitle || "dashboard"}_${_pd.getFullYear()}-${String(_pd.getMonth() + 1).padStart(2, "0")}-${String(_pd.getDate()).padStart(2, "0")}.pdf`; pdf.save(filename); } }; diff --git a/frontend/components/admin/dashboard/utils/queryHelpers.ts b/frontend/components/admin/dashboard/utils/queryHelpers.ts index b5220eb4..ef405d9f 100644 --- a/frontend/components/admin/dashboard/utils/queryHelpers.ts +++ b/frontend/components/admin/dashboard/utils/queryHelpers.ts @@ -100,36 +100,37 @@ export function getQuickDateRange(range: "today" | "week" | "month" | "year"): { } { const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const fmtDate = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; switch (range) { case "today": return { - startDate: today.toISOString().split("T")[0], - endDate: today.toISOString().split("T")[0], + startDate: fmtDate(today), + endDate: fmtDate(today), }; case "week": { const weekStart = new Date(today); - weekStart.setDate(today.getDate() - today.getDay()); // 일요일부터 + weekStart.setDate(today.getDate() - today.getDay()); return { - startDate: weekStart.toISOString().split("T")[0], - endDate: today.toISOString().split("T")[0], + startDate: fmtDate(weekStart), + endDate: fmtDate(today), }; } case "month": { const monthStart = new Date(today.getFullYear(), today.getMonth(), 1); return { - startDate: monthStart.toISOString().split("T")[0], - endDate: today.toISOString().split("T")[0], + startDate: fmtDate(monthStart), + endDate: fmtDate(today), }; } case "year": { const yearStart = new Date(today.getFullYear(), 0, 1); return { - startDate: yearStart.toISOString().split("T")[0], - endDate: today.toISOString().split("T")[0], + startDate: fmtDate(yearStart), + endDate: fmtDate(today), }; } diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index 94e6aa8a..a2e83b0c 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -217,7 +217,8 @@ export function DashboardViewer({ if (format === "png") { console.log("💾 PNG 다운로드 시작..."); const link = document.createElement("a"); - const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.png`; + const _dvd = new Date(); + const filename = `${dashboardTitle || "dashboard"}_${_dvd.getFullYear()}-${String(_dvd.getMonth() + 1).padStart(2, "0")}-${String(_dvd.getDate()).padStart(2, "0")}.png`; link.download = filename; link.href = dataUrl; document.body.appendChild(link); @@ -253,7 +254,8 @@ export function DashboardViewer({ }); pdf.addImage(dataUrl, "PNG", 0, 0, imgWidth, imgHeight); - const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.pdf`; + const _dvp = new Date(); + const filename = `${dashboardTitle || "dashboard"}_${_dvp.getFullYear()}-${String(_dvp.getMonth() + 1).padStart(2, "0")}-${String(_dvp.getDate()).padStart(2, "0")}.pdf`; pdf.save(filename); console.log("✅ PDF 다운로드 완료:", filename); } diff --git a/frontend/components/dashboard/widgets/DeliveryTodayStatsWidget.tsx b/frontend/components/dashboard/widgets/DeliveryTodayStatsWidget.tsx index ac01e4ef..5f8eb721 100644 --- a/frontend/components/dashboard/widgets/DeliveryTodayStatsWidget.tsx +++ b/frontend/components/dashboard/widgets/DeliveryTodayStatsWidget.tsx @@ -61,7 +61,8 @@ export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStats // 데이터 처리 if (result.success && result.data?.rows) { const rows = result.data.rows; - const today = new Date().toISOString().split("T")[0]; + const _td = new Date(); + const today = `${_td.getFullYear()}-${String(_td.getMonth() + 1).padStart(2, "0")}-${String(_td.getDate()).padStart(2, "0")}`; // 오늘 발송 건수 (created_at 기준) const shippedToday = rows.filter((row: any) => { diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index 5a546dbf..72133672 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -101,7 +101,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const [routePoints, setRoutePoints] = useState([]); const [selectedUserId, setSelectedUserId] = useState(null); const [routeLoading, setRouteLoading] = useState(false); - const [routeDate, setRouteDate] = useState(new Date().toISOString().split("T")[0]); // YYYY-MM-DD 형식 + const [routeDate, setRouteDate] = useState(() => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; }); // 공차/운행 정보 상태 const [tripInfo, setTripInfo] = useState>({}); diff --git a/frontend/components/report/designer/ReportPreviewModal.tsx b/frontend/components/report/designer/ReportPreviewModal.tsx index bc7fc774..e5fb16f7 100644 --- a/frontend/components/report/designer/ReportPreviewModal.tsx +++ b/frontend/components/report/designer/ReportPreviewModal.tsx @@ -1120,7 +1120,8 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) const blob = new Blob([response.data], { type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", }); - const timestamp = new Date().toISOString().slice(0, 10); + const _rpd = new Date(); + const timestamp = `${_rpd.getFullYear()}-${String(_rpd.getMonth() + 1).padStart(2, "0")}-${String(_rpd.getDate()).padStart(2, "0")}`; const url = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; diff --git a/frontend/components/screen/EnhancedInteractiveScreenViewer.tsx b/frontend/components/screen/EnhancedInteractiveScreenViewer.tsx index ea291a6e..160d6b20 100644 --- a/frontend/components/screen/EnhancedInteractiveScreenViewer.tsx +++ b/frontend/components/screen/EnhancedInteractiveScreenViewer.tsx @@ -86,13 +86,16 @@ export const EnhancedInteractiveScreenViewer: React.FC => { const now = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + const localDate = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; + const localTime = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; switch (autoValueType) { case "current_datetime": - return now.toISOString().slice(0, 19).replace("T", " "); + return `${localDate} ${localTime}`; case "current_date": - return now.toISOString().slice(0, 10); + return localDate; case "current_time": - return now.toTimeString().slice(0, 8); + return localTime; case "current_user": return userName || "사용자"; case "uuid": diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index 9b5f1693..177b83e2 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -1155,13 +1155,16 @@ export const InteractiveDataTable: React.FC = ({ const generateAutoValue = useCallback( (autoValueType: string): string => { const now = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + const localDate = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; + const localTime = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; switch (autoValueType) { case "current_datetime": - return now.toISOString().slice(0, 19); // YYYY-MM-DDTHH:mm:ss + return `${localDate} ${localTime}`; case "current_date": - return now.toISOString().slice(0, 10); // YYYY-MM-DD + return localDate; case "current_time": - return now.toTimeString().slice(0, 8); // HH:mm:ss + return localTime; case "current_user": return currentUser?.userName || currentUser?.userId || "unknown_user"; case "uuid": diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 17fd7616..4d215ae3 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -357,13 +357,16 @@ export const InteractiveScreenViewer: React.FC = ( // 자동값 생성 함수 const generateAutoValue = useCallback(async (autoValueType: string, ruleId?: string): Promise => { const now = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + const localDate = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; + const localTime = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; switch (autoValueType) { case "current_datetime": - return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss + return `${localDate} ${localTime}`; case "current_date": - return now.toISOString().slice(0, 10); // YYYY-MM-DD + return localDate; case "current_time": - return now.toTimeString().slice(0, 8); // HH:mm:ss + return localTime; case "current_user": // 실제 접속중인 사용자명 사용 return userName || "사용자"; // 사용자명이 없으면 기본값 diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index d6111b64..fe61d5cc 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -183,13 +183,16 @@ export const InteractiveScreenViewerDynamic: React.FC { const now = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + const localDate = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; + const localTime = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; switch (autoValueType) { case "current_datetime": - return now.toISOString().slice(0, 19).replace("T", " "); + return `${localDate} ${localTime}`; case "current_date": - return now.toISOString().slice(0, 10); + return localDate; case "current_time": - return now.toTimeString().slice(0, 8); + return localTime; case "current_user": return userName || "사용자"; case "uuid": diff --git a/frontend/components/screen/ScreenSettingModal.tsx b/frontend/components/screen/ScreenSettingModal.tsx index 52f4ebd5..9da3d182 100644 --- a/frontend/components/screen/ScreenSettingModal.tsx +++ b/frontend/components/screen/ScreenSettingModal.tsx @@ -3852,7 +3852,6 @@ function ControlManagementTab({ openModalWithData: "데이터+모달", openRelatedModal: "연관모달", transferData: "데이터전달", - quickInsert: "즉시저장", control: "제어흐름", view_table_history: "이력보기", excel_download: "엑셀다운", diff --git a/frontend/components/screen/config-panels/DateConfigPanel.tsx b/frontend/components/screen/config-panels/DateConfigPanel.tsx index cddac6cb..5b6b1303 100644 --- a/frontend/components/screen/config-panels/DateConfigPanel.tsx +++ b/frontend/components/screen/config-panels/DateConfigPanel.tsx @@ -56,9 +56,11 @@ export const DateConfigPanel: React.FC = ({ // 현재 날짜 설정 const setCurrentDate = (field: "minDate" | "maxDate" | "defaultValue") => { const now = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + const d = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; const dateString = localConfig.showTime - ? now.toISOString().slice(0, 16) // YYYY-MM-DDTHH:mm - : now.toISOString().slice(0, 10); // YYYY-MM-DD + ? `${d}T${pad(now.getHours())}:${pad(now.getMinutes())}` + : d; updateConfig(field, dateString); }; diff --git a/frontend/components/screen/config-panels/button-config/BasicTab.tsx b/frontend/components/screen/config-panels/button-config/BasicTab.tsx index 6bf05f70..f569c857 100644 --- a/frontend/components/screen/config-panels/button-config/BasicTab.tsx +++ b/frontend/components/screen/config-panels/button-config/BasicTab.tsx @@ -263,7 +263,6 @@ export const BasicTab: React.FC = ({ {/* 고급 기능 */} - 즉시 저장 제어 흐름 결재 요청 @@ -271,9 +270,6 @@ export const BasicTab: React.FC = ({ 바코드 스캔 운행알림 및 종료 - {/* 이벤트 버스 */} - 이벤트 발송 - {/* 복사 */} 복사 (품목코드 초기화) diff --git a/frontend/components/screen/widgets/FlowWidget.tsx b/frontend/components/screen/widgets/FlowWidget.tsx index 27e6a91b..fb2b9318 100644 --- a/frontend/components/screen/widgets/FlowWidget.tsx +++ b/frontend/components/screen/widgets/FlowWidget.tsx @@ -1018,7 +1018,8 @@ export function FlowWidget({ const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, "Data"); - const fileName = `${flowName || "flow"}_data_${new Date().toISOString().split("T")[0]}.xlsx`; + const _fxd = new Date(); + const fileName = `${flowName || "flow"}_data_${_fxd.getFullYear()}-${String(_fxd.getMonth() + 1).padStart(2, "0")}-${String(_fxd.getDate()).padStart(2, "0")}.xlsx`; XLSX.writeFile(wb, fileName); toast.success(`${exportData.length}개 행이 Excel로 내보내기 되었습니다.`); @@ -1183,7 +1184,8 @@ export function FlowWidget({ } } - const fileName = `${flowName || "flow"}_data_${new Date().toISOString().split("T")[0]}.pdf`; + const _fpd = new Date(); + const fileName = `${flowName || "flow"}_data_${_fpd.getFullYear()}-${String(_fpd.getMonth() + 1).padStart(2, "0")}-${String(_fpd.getDate()).padStart(2, "0")}.pdf`; doc.save(fileName); toast.success(`${exportData.length}개 행이 PDF로 내보내기 되었습니다.`, { id: "pdf-export" }); diff --git a/frontend/components/screen/widgets/types/DateWidget.tsx b/frontend/components/screen/widgets/types/DateWidget.tsx index 3b0f47e2..61643ce4 100644 --- a/frontend/components/screen/widgets/types/DateWidget.tsx +++ b/frontend/components/screen/widgets/types/DateWidget.tsx @@ -52,8 +52,10 @@ export const DateWidget: React.FC = ({ component, value, const getDefaultValue = (): string => { if (config?.defaultValue === "current") { const now = new Date(); - if (isDatetime) return now.toISOString().slice(0, 16); - return now.toISOString().slice(0, 10); + const pad = (n: number) => String(n).padStart(2, "0"); + const d = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; + if (isDatetime) return `${d}T${pad(now.getHours())}:${pad(now.getMinutes())}`; + return d; } return ""; }; diff --git a/frontend/components/unified/UnifiedRepeater.tsx b/frontend/components/unified/UnifiedRepeater.tsx index 2f521665..96ef868b 100644 --- a/frontend/components/unified/UnifiedRepeater.tsx +++ b/frontend/components/unified/UnifiedRepeater.tsx @@ -680,11 +680,15 @@ export const UnifiedRepeater: React.FC = ({ const now = new Date(); switch (col.autoFill.type) { - case "currentDate": - return now.toISOString().split("T")[0]; // YYYY-MM-DD + case "currentDate": { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; + } - case "currentDateTime": - return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss + case "currentDateTime": { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; + } case "sequence": return rowIndex + 1; // 1부터 시작하는 순번 diff --git a/frontend/components/v2/V2Repeater.tsx b/frontend/components/v2/V2Repeater.tsx index 3b36dd6b..b920e54f 100644 --- a/frontend/components/v2/V2Repeater.tsx +++ b/frontend/components/v2/V2Repeater.tsx @@ -1041,12 +1041,15 @@ export const V2Repeater: React.FC = ({ const now = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + const localDate = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; + const localTime = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; switch (col.autoFill.type) { case "currentDate": - return now.toISOString().split("T")[0]; // YYYY-MM-DD + return localDate; case "currentDateTime": - return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss + return `${localDate} ${localTime}`; case "sequence": return rowIndex + 1; // 1부터 시작하는 순번 diff --git a/frontend/components/v2/config-panels/V2ButtonConfigPanel.tsx b/frontend/components/v2/config-panels/V2ButtonConfigPanel.tsx index a36cd8c9..ab7f8732 100644 --- a/frontend/components/v2/config-panels/V2ButtonConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2ButtonConfigPanel.tsx @@ -130,12 +130,6 @@ const ACTION_TYPE_CARDS = [ title: "엑셀 업로드", description: "엑셀 파일을 올려요", }, - { - value: "quickInsert", - icon: Zap, - title: "즉시 저장", - description: "바로 저장해요", - }, { value: "approval", icon: Check, @@ -148,12 +142,6 @@ const ACTION_TYPE_CARDS = [ title: "제어 흐름", description: "흐름을 제어해요", }, - { - value: "event", - icon: Send, - title: "이벤트 발송", - description: "이벤트를 보내요", - }, { value: "copy", icon: Copy, diff --git a/frontend/components/vehicle/VehicleReport.tsx b/frontend/components/vehicle/VehicleReport.tsx index e075dd15..2bca91d3 100644 --- a/frontend/components/vehicle/VehicleReport.tsx +++ b/frontend/components/vehicle/VehicleReport.tsx @@ -56,10 +56,10 @@ export default function VehicleReport() { // 일별 통계 const [dailyData, setDailyData] = useState([]); const [dailyStartDate, setDailyStartDate] = useState( - new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0] + (() => { const d = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; })() ); const [dailyEndDate, setDailyEndDate] = useState( - new Date().toISOString().split("T")[0] + (() => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; })() ); const [dailyLoading, setDailyLoading] = useState(false); diff --git a/frontend/lib/registry/components/date-input/DateInputComponent.tsx b/frontend/lib/registry/components/date-input/DateInputComponent.tsx index 93efccf0..73dda0f7 100644 --- a/frontend/lib/registry/components/date-input/DateInputComponent.tsx +++ b/frontend/lib/registry/components/date-input/DateInputComponent.tsx @@ -53,7 +53,8 @@ export const DateInputComponent: React.FC = ({ // 자동생성 로직 useEffect(() => { if (finalAutoGeneration?.enabled) { - const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD + const n = new Date(); + const today = `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, "0")}-${String(n.getDate()).padStart(2, "0")}`; setAutoGeneratedValue(today); // 인터랙티브 모드에서 폼 데이터에도 설정 diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index 85db9002..4b8cb23d 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -653,9 +653,9 @@ export function RepeaterTable({ if (typeof val === "string" && val.includes("T")) { return val.split("T")[0]; } - // Date 객체이면 변환 + // Date 객체이면 로컬 날짜로 변환 if (val instanceof Date) { - return val.toISOString().split("T")[0]; + return `${val.getFullYear()}-${String(val.getMonth() + 1).padStart(2, "0")}-${String(val.getDate()).padStart(2, "0")}`; } return String(val); }; diff --git a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx index e4e172f6..f215b665 100644 --- a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx @@ -448,7 +448,8 @@ export function SimpleRepeaterTableComponent({ } else if (col.type === "number") { newRow[col.field] = 0; } else if (col.type === "date") { - newRow[col.field] = new Date().toISOString().split("T")[0]; + const _n = new Date(); + newRow[col.field] = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`; } else { newRow[col.field] = ""; } diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 119cca53..80d84b7a 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -2707,7 +2707,8 @@ export const TableListComponent: React.FC = ({ XLSX.utils.book_append_sheet(wb, ws, tableLabel || "데이터"); // 파일명 생성 - const fileName = `${tableLabel || tableConfig.selectedTable || "export"}_${new Date().toISOString().split("T")[0]}.xlsx`; + const _en = new Date(); + const fileName = `${tableLabel || tableConfig.selectedTable || "export"}_${_en.getFullYear()}-${String(_en.getMonth() + 1).padStart(2, "0")}-${String(_en.getDate()).padStart(2, "0")}.xlsx`; // 파일 다운로드 XLSX.writeFile(wb, fileName); diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 4649350b..d1faf281 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -3006,7 +3006,8 @@ export const TableListComponent: React.FC = ({ XLSX.utils.book_append_sheet(wb, ws, tableLabel || "데이터"); // 파일명 생성 - const fileName = `${tableLabel || tableConfig.selectedTable || "export"}_${new Date().toISOString().split("T")[0]}.xlsx`; + const _en = new Date(); + const fileName = `${tableLabel || tableConfig.selectedTable || "export"}_${_en.getFullYear()}-${String(_en.getMonth() + 1).padStart(2, "0")}-${String(_en.getDate()).padStart(2, "0")}.xlsx`; // 파일 다운로드 XLSX.writeFile(wb, fileName); diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx index 075e8eca..b80c1142 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx +++ b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx @@ -227,7 +227,7 @@ export function TimelineSchedulerComponent({ if (onCellClick) { onCellClick({ resourceId, - date: date.toISOString().split("T")[0], + date: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, }); } }, @@ -343,7 +343,7 @@ export function TimelineSchedulerComponent({ if (onAddSchedule && effectiveResources.length > 0) { onAddSchedule( effectiveResources[0].id, - new Date().toISOString().split("T")[0] + (() => { const _n = new Date(); return `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`; })() ); } }, [onAddSchedule, effectiveResources]); @@ -383,7 +383,8 @@ export function TimelineSchedulerComponent({ const items = Array.from(grouped.entries()).map(([code, rows]) => { const totalQty = rows.reduce((sum: number, r: any) => sum + (Number(r[qtyField]) || 0), 0); const dates = rows.map((r: any) => r[dateField]).filter(Boolean).sort(); - const earliestDate = dates[0] || new Date().toISOString().split("T")[0]; + const _dn = new Date(); + const earliestDate = dates[0] || `${_dn.getFullYear()}-${String(_dn.getMonth() + 1).padStart(2, "0")}-${String(_dn.getDate()).padStart(2, "0")}`; const first = rows[0]; return { item_code: code, diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/components/ItemTimelineCard.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/components/ItemTimelineCard.tsx index 01e72a1c..37fb46d8 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/components/ItemTimelineCard.tsx +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/ItemTimelineCard.tsx @@ -28,7 +28,7 @@ interface ItemTimelineCardProps { onScheduleClick?: (schedule: ScheduleItem) => void; } -const toDateString = (d: Date) => d.toISOString().split("T")[0]; +const toDateString = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; const addDays = (d: Date, n: number) => { const r = new Date(d); diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts b/frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts index 8e2e1b53..5e30633f 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts +++ b/frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts @@ -13,7 +13,7 @@ const SCHEDULE_TABLE = "schedule_mng"; * 날짜를 ISO 문자열로 변환 (시간 제외) */ const toDateString = (date: Date): string => { - return date.toISOString().split("T")[0]; + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; }; /** diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/utils/conflictDetection.ts b/frontend/lib/registry/components/v2-timeline-scheduler/utils/conflictDetection.ts index 98b9fbb1..27d16b6c 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/utils/conflictDetection.ts +++ b/frontend/lib/registry/components/v2-timeline-scheduler/utils/conflictDetection.ts @@ -54,5 +54,5 @@ export function detectConflicts(schedules: ScheduleItem[]): Set { export function addDaysToDateString(dateStr: string, days: number): string { const date = new Date(dateStr); date.setDate(date.getDate() + days); - return date.toISOString().split("T")[0]; + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; } diff --git a/frontend/lib/registry/pop-components/pop-search/types.ts b/frontend/lib/registry/pop-components/pop-search/types.ts index 6b284b60..5d455121 100644 --- a/frontend/lib/registry/pop-components/pop-search/types.ts +++ b/frontend/lib/registry/pop-components/pop-search/types.ts @@ -251,7 +251,7 @@ export function computeDateRange( preset: DatePresetOption ): { preset: DatePresetOption; from: string; to: string } | null { const now = new Date(); - const fmt = (d: Date) => d.toISOString().split("T")[0]; + const fmt = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; switch (preset) { case "today": diff --git a/frontend/lib/services/enhancedFormService.ts b/frontend/lib/services/enhancedFormService.ts index 70bcc106..946288e8 100644 --- a/frontend/lib/services/enhancedFormService.ts +++ b/frontend/lib/services/enhancedFormService.ts @@ -349,7 +349,7 @@ export class EnhancedFormService { if (lowerDataType.includes("date")) { const date = new Date(value); - return isNaN(date.getTime()) ? null : date.toISOString().split("T")[0]; + return isNaN(date.getTime()) ? null : `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; } if (lowerDataType.includes("time")) { diff --git a/frontend/lib/utils/autoGeneration.ts b/frontend/lib/utils/autoGeneration.ts index d9124bf9..be635370 100644 --- a/frontend/lib/utils/autoGeneration.ts +++ b/frontend/lib/utils/autoGeneration.ts @@ -1,6 +1,7 @@ "use client"; import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen"; +import { toLocalDate, toLocalTime, toLocalDateTime } from "@/lib/utils/localDate"; /** * 자동생성 값 생성 유틸리티 @@ -52,19 +53,19 @@ export class AutoGenerationUtils { let result: string; switch (format) { case "date": - result = now.toISOString().split("T")[0]; // YYYY-MM-DD + result = toLocalDate(now); break; case "time": - result = now.toTimeString().split(" ")[0]; // HH:mm:ss + result = toLocalTime(now); break; case "datetime": - result = now.toISOString().replace("T", " ").split(".")[0]; // YYYY-MM-DD HH:mm:ss + result = toLocalDateTime(now); break; case "timestamp": result = now.getTime().toString(); break; default: - result = now.toISOString(); // ISO 8601 format + result = toLocalDateTime(now); break; } diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 5ea616e2..60ed18e0 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -5156,7 +5156,8 @@ export class ButtonActionExecutor { const menuName = localStorage.getItem("currentMenuName"); if (menuName) defaultFileName = menuName; } - const fileName = config.excelFileName || `${defaultFileName}_${new Date().toISOString().split("T")[0]}.xlsx`; + const _xd = new Date(); + const fileName = config.excelFileName || `${defaultFileName}_${_xd.getFullYear()}-${String(_xd.getMonth() + 1).padStart(2, "0")}-${String(_xd.getDate()).padStart(2, "0")}.xlsx`; const sheetName = config.excelSheetName || "Sheet1"; await exportToExcel(dataToExport, fileName, sheetName, true); @@ -5262,7 +5263,8 @@ export class ButtonActionExecutor { } } - const fileName = config.excelFileName || `${defaultFileName}_${new Date().toISOString().split("T")[0]}.xlsx`; + const _xd2 = new Date(); + const fileName = config.excelFileName || `${defaultFileName}_${_xd2.getFullYear()}-${String(_xd2.getMonth() + 1).padStart(2, "0")}-${String(_xd2.getDate()).padStart(2, "0")}.xlsx`; const sheetName = config.excelSheetName || "Sheet1"; const includeHeaders = config.excelIncludeHeaders !== false; diff --git a/frontend/lib/utils/formValidation.ts b/frontend/lib/utils/formValidation.ts index 48cb60e1..d272d942 100644 --- a/frontend/lib/utils/formValidation.ts +++ b/frontend/lib/utils/formValidation.ts @@ -440,7 +440,7 @@ const validateDateField = (fieldName: string, value: any, config?: Record Date: Tue, 17 Mar 2026 18:19:08 +0900 Subject: [PATCH 11/12] feat: enhance V2 process work standard configuration panel - Introduced a new TableCombobox component for selecting tables, improving user experience by allowing table searches and selections. - Added a ColumnCombobox component to facilitate column selection based on the chosen table, enhancing the configurability of the process work standard settings. - Updated the V2ProcessWorkStandardConfigPanel to utilize the new combobox components, streamlining the configuration process for item tables and columns. - Removed the deprecated mcp.json file and updated .gitignore to reflect recent changes. These enhancements aim to improve the usability and flexibility of the configuration panel, making it easier for users to manage their process work standards. --- .cursor/mcp.json | 8 - .gitignore | 3 + .../V2ProcessWorkStandardConfigPanel.tsx | 549 +++++++++++------- .../ProcessWorkStandardConfigPanel.tsx | 167 ++++-- 4 files changed, 454 insertions(+), 273 deletions(-) delete mode 100644 .cursor/mcp.json diff --git a/.cursor/mcp.json b/.cursor/mcp.json deleted file mode 100644 index d5e0ca4b..00000000 --- a/.cursor/mcp.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "mcpServers": { - "Framelink Figma MCP": { - "command": "npx", - "args": ["-y", "figma-developer-mcp", "--figma-api-key=figd_NrYdIWf-CnC23NyH6eMym7sBdfbZTuXyS91tI3VS", "--stdio"] - } - } -} diff --git a/.gitignore b/.gitignore index b6114eb7..197ad216 100644 --- a/.gitignore +++ b/.gitignore @@ -185,6 +185,9 @@ popdocs/ # 멀티 에이전트 MCP 태스크 큐 mcp-task-queue/ +.cursor/mcp.json .cursor/rules/multi-agent-pm.mdc .cursor/rules/multi-agent-worker.mdc .cursor/rules/multi-agent-tester.mdc +.cursor/rules/multi-agent-reviewer.mdc +.cursor/rules/multi-agent-knowledge.mdc diff --git a/frontend/components/v2/config-panels/V2ProcessWorkStandardConfigPanel.tsx b/frontend/components/v2/config-panels/V2ProcessWorkStandardConfigPanel.tsx index 33a7ae33..a2b7abdc 100644 --- a/frontend/components/v2/config-panels/V2ProcessWorkStandardConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2ProcessWorkStandardConfigPanel.tsx @@ -1,17 +1,33 @@ "use client"; /** - * V2 공정 작업기준 설정 패널 - * Progressive Disclosure: 작업 단계 -> 상세 유형 -> 고급 설정(접힘) + * V2 공정 작업기준 설정 패널 (간소화) */ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { Button } from "@/components/ui/button"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Badge } from "@/components/ui/badge"; -import { Settings, ChevronDown, ChevronRight, Plus, Trash2, Database, Layers, List } from "lucide-react"; +import { + Settings, + ChevronDown, + ChevronRight, + Plus, + Trash2, + Check, + ChevronsUpDown, + Database, + Layers, + List, +} from "lucide-react"; import { cn } from "@/lib/utils"; import type { ProcessWorkStandardConfig, @@ -20,26 +36,87 @@ import type { } from "@/lib/registry/components/v2-process-work-standard/types"; import { defaultConfig } from "@/lib/registry/components/v2-process-work-standard/config"; +interface TableInfo { tableName: string; displayName?: string; } + +function TableCombobox({ value, onChange, tables, loading, label }: { + value: string; onChange: (v: string) => void; tables: TableInfo[]; loading: boolean; label: string; +}) { + const [open, setOpen] = useState(false); + const selected = tables.find((t) => t.tableName === value); + return ( +
+ {label} + + + + + + + + + 테이블을 찾을 수 없습니다. + + {tables.map((t) => ( + { onChange(t.tableName); setOpen(false); }} className="text-xs"> + +
+ {t.displayName || t.tableName} + {t.displayName && {t.tableName}} +
+
+ ))} +
+
+
+
+
+
+ ); +} + interface V2ProcessWorkStandardConfigPanelProps { config: Partial; onChange: (config: Partial) => void; } -export const V2ProcessWorkStandardConfigPanel: React.FC = ({ - config: configProp, - onChange, -}) => { +export const V2ProcessWorkStandardConfigPanel: React.FC< + V2ProcessWorkStandardConfigPanelProps +> = ({ config: configProp, onChange }) => { const [phasesOpen, setPhasesOpen] = useState(false); const [detailTypesOpen, setDetailTypesOpen] = useState(false); - const [advancedOpen, setAdvancedOpen] = useState(false); + const [layoutOpen, setLayoutOpen] = useState(false); const [dataSourceOpen, setDataSourceOpen] = useState(false); + const [tables, setTables] = useState([]); + const [loadingTables, setLoadingTables] = useState(false); + + useEffect(() => { + const loadTables = async () => { + setLoadingTables(true); + try { + const { tableManagementApi } = await import("@/lib/api/tableManagement"); + const res = await tableManagementApi.getTableList(); + if (res.success && res.data) { + setTables(res.data.map((t: any) => ({ tableName: t.tableName, displayName: t.displayName || t.tableName }))); + } + } catch { /* ignore */ } finally { setLoadingTables(false); } + }; + loadTables(); + }, []); const config: ProcessWorkStandardConfig = { ...defaultConfig, ...configProp, dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource }, - phases: configProp?.phases?.length ? configProp.phases : defaultConfig.phases, - detailTypes: configProp?.detailTypes?.length ? configProp.detailTypes : defaultConfig.detailTypes, + phases: configProp?.phases?.length + ? configProp.phases + : defaultConfig.phases, + detailTypes: configProp?.detailTypes?.length + ? configProp.detailTypes + : defaultConfig.detailTypes, }; const update = (partial: Partial) => { @@ -50,13 +127,16 @@ export const V2ProcessWorkStandardConfigPanel: React.FC { const nextOrder = config.phases.length + 1; update({ phases: [ ...config.phases, - { key: `PHASE_${nextOrder}`, label: `단계 ${nextOrder}`, sortOrder: nextOrder }, + { + key: `PHASE_${nextOrder}`, + label: `단계 ${nextOrder}`, + sortOrder: nextOrder, + }, ], }); }; @@ -65,18 +145,24 @@ export const V2ProcessWorkStandardConfigPanel: React.FC i !== idx) }); }; - const updatePhase = (idx: number, field: keyof WorkPhaseDefinition, value: string | number) => { + const updatePhase = ( + idx: number, + field: keyof WorkPhaseDefinition, + value: string | number, + ) => { const next = [...config.phases]; next[idx] = { ...next[idx], [field]: value }; update({ phases: next }); }; - // ─── 상세 유형 관리 ─── const addDetailType = () => { update({ detailTypes: [ ...config.detailTypes, - { value: `TYPE_${config.detailTypes.length + 1}`, label: "신규 유형" }, + { + value: `TYPE_${config.detailTypes.length + 1}`, + label: "신규 유형", + }, ], }); }; @@ -85,7 +171,11 @@ export const V2ProcessWorkStandardConfigPanel: React.FC i !== idx) }); }; - const updateDetailType = (idx: number, field: keyof DetailTypeDefinition, value: string) => { + const updateDetailType = ( + idx: number, + field: keyof DetailTypeDefinition, + value: string, + ) => { const next = [...config.detailTypes]; next[idx] = { ...next[idx], [field]: value }; update({ detailTypes: next }); @@ -93,31 +183,75 @@ export const V2ProcessWorkStandardConfigPanel: React.FC - {/* ─── 1단계: 작업 단계 설정 (Collapsible + 접이식 카드) ─── */} + {/* 품목 목록 모드 */} +
+ 품목 목록 모드 +
+ + +
+ {config.itemListMode === "registered" && ( +

+ 품목별 라우팅 탭에서 등록한 품목만 표시됩니다. +

+ )} +
+ + {/* 작업 단계 */} -
-

공정별 작업 단계(Phase)를 정의

+
+

+ 공정별 작업 단계를 정의 +

{config.phases.map((phase, idx) => ( @@ -125,18 +259,30 @@ export const V2ProcessWorkStandardConfigPanel: React.FC -
-

작업 항목의 상세 유형 드롭다운 옵션

+
+

+ 작업 항목의 상세 유형 옵션 +

{config.detailTypes.map((dt, idx) => ( @@ -225,18 +386,30 @@ export const V2ProcessWorkStandardConfigPanel: React.FC -
+
+

+ 테이블만 선택하면 컬럼 정보는 엔티티 설정에서 자동으로 가져옵니다. +

+ updateDataSource("itemTable", v)} tables={tables} loading={loadingTables} /> + updateDataSource("routingVersionTable", v)} tables={tables} loading={loadingTables} /> + updateDataSource("routingDetailTable", v)} tables={tables} loading={loadingTables} /> + updateDataSource("processTable", v)} tables={tables} loading={loadingTables} /> +
+ + - {/* 레이아웃 기본 설정 */} -
-
-
- 좌측 패널 비율 (%) -

품목/공정 선택 패널의 너비

-
- update({ splitRatio: parseInt(e.target.value) || 30 })} - className="h-7 w-[80px] text-xs" - /> -
-
- 좌측 패널 제목 - update({ leftPanelTitle: e.target.value })} - placeholder="품목 및 공정 선택" - className="h-7 w-[140px] text-xs" - /> -
-
-
-

읽기 전용

-

수정/삭제 버튼을 숨겨요

-
- update({ readonly: checked })} - /> -
+ {/* 레이아웃 & 기타 */} + + + + + +
+
+
+ + 좌측 패널 비율 (%) + +

+ 품목/공정 선택 패널의 너비 +

+
+ + update({ splitRatio: parseInt(e.target.value) || 30 }) + } + className="h-7 w-[80px] text-xs" + /> +
+
+ + 좌측 패널 제목 + + update({ leftPanelTitle: e.target.value })} + placeholder="품목 및 공정 선택" + className="h-7 w-[140px] text-xs" + /> +
+
+
+

읽기 전용

+

+ 수정/삭제 버튼을 숨겨요 +

+
+ update({ readonly: checked })} + />
- - {/* 데이터 소스 (서브 Collapsible) */} - - - - - -
- 품목 테이블 - updateDataSource("itemTable", e.target.value)} - className="h-7 w-full text-xs" - /> -
-
-
- 품목명 컬럼 - updateDataSource("itemNameColumn", e.target.value)} - className="h-7 text-xs" - /> -
-
- 품목코드 컬럼 - updateDataSource("itemCodeColumn", e.target.value)} - className="h-7 text-xs" - /> -
-
-
- 라우팅 버전 테이블 - updateDataSource("routingVersionTable", e.target.value)} - className="h-7 w-full text-xs" - /> -
-
-
- 품목 연결 FK - updateDataSource("routingFkColumn", e.target.value)} - className="h-7 text-xs" - /> -
-
- 버전명 컬럼 - updateDataSource("routingVersionNameColumn", e.target.value)} - className="h-7 text-xs" - /> -
-
-
- 라우팅 상세 테이블 - updateDataSource("routingDetailTable", e.target.value)} - className="h-7 w-full text-xs" - /> -
-
- 공정 마스터 테이블 - updateDataSource("processTable", e.target.value)} - className="h-7 w-full text-xs" - /> -
-
-
- 공정명 컬럼 - updateDataSource("processNameColumn", e.target.value)} - className="h-7 text-xs" - /> -
-
- 공정코드 컬럼 - updateDataSource("processCodeColumn", e.target.value)} - className="h-7 text-xs" - /> -
-
-
-
-
@@ -462,6 +564,7 @@ export const V2ProcessWorkStandardConfigPanel: React.FC void; tables: TableInfo[]; loading: boolean; +}) { + const [open, setOpen] = useState(false); + const selected = tables.find((t) => t.tableName === value); + return ( + + + + + + + + + 테이블을 찾을 수 없습니다. + + {tables.map((t) => ( + { onChange(t.tableName); setOpen(false); }} className="text-xs"> + +
+ {t.displayName || t.tableName} + {t.displayName && {t.tableName}} +
+
+ ))} +
+
+
+
+
+ ); +} + +function ColumnCombobox({ value, onChange, tableName, placeholder }: { + value: string; onChange: (v: string) => void; tableName: string; placeholder?: string; +}) { + const [open, setOpen] = useState(false); + const [columns, setColumns] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!tableName) { setColumns([]); return; } + const load = async () => { + setLoading(true); + try { + const { tableManagementApi } = await import("@/lib/api/tableManagement"); + const res = await tableManagementApi.getColumnList(tableName); + if (res.success && res.data?.columns) setColumns(res.data.columns); + } catch { /* ignore */ } finally { setLoading(false); } + }; + load(); + }, [tableName]); + + const selected = columns.find((c) => c.columnName === value); + return ( + + + + + + + + + 컬럼을 찾을 수 없습니다. + + {columns.map((c) => ( + { onChange(c.columnName); setOpen(false); }} className="text-xs"> + +
+ {c.displayName || c.columnName} + {c.displayName && {c.columnName}} +
+
+ ))} +
+
+
+
+
+ ); +} + interface ConfigPanelProps { config: Partial; onChange: (config: Partial) => void; @@ -19,6 +117,9 @@ export function ProcessWorkStandardConfigPanel({ config: configProp, onChange, }: ConfigPanelProps) { + const [tables, setTables] = useState([]); + const [loadingTables, setLoadingTables] = useState(false); + const config: ProcessWorkStandardConfig = { ...defaultConfig, ...configProp, @@ -27,6 +128,20 @@ export function ProcessWorkStandardConfigPanel({ detailTypes: configProp?.detailTypes?.length ? configProp.detailTypes : defaultConfig.detailTypes, }; + useEffect(() => { + const loadTables = async () => { + setLoadingTables(true); + try { + const { tableManagementApi } = await import("@/lib/api/tableManagement"); + const res = await tableManagementApi.getTableList(); + if (res.success && res.data) { + setTables(res.data.map((t: any) => ({ tableName: t.tableName, displayName: t.displayName || t.tableName }))); + } + } catch { /* ignore */ } finally { setLoadingTables(false); } + }; + loadTables(); + }, []); + const update = (partial: Partial) => { onChange({ ...configProp, ...partial }); }; @@ -112,72 +227,40 @@ export function ProcessWorkStandardConfigPanel({
- updateDataSource("itemTable", e.target.value)} - className="mt-1 h-8 text-xs" - /> + updateDataSource("itemTable", v)} tables={tables} loading={loadingTables} />
- updateDataSource("itemNameColumn", e.target.value)} - className="mt-1 h-8 text-xs" - /> + updateDataSource("itemNameColumn", v)} tableName={config.dataSource.itemTable} placeholder="품목명" />
- updateDataSource("itemCodeColumn", e.target.value)} - className="mt-1 h-8 text-xs" - /> + updateDataSource("itemCodeColumn", v)} tableName={config.dataSource.itemTable} placeholder="품목코드" />
- updateDataSource("routingVersionTable", e.target.value)} - className="mt-1 h-8 text-xs" - /> + updateDataSource("routingVersionTable", v)} tables={tables} loading={loadingTables} />
- updateDataSource("routingFkColumn", e.target.value)} - className="mt-1 h-8 text-xs" - /> + updateDataSource("routingFkColumn", v)} tableName={config.dataSource.routingVersionTable} placeholder="FK 컬럼" />
- updateDataSource("processTable", e.target.value)} - className="mt-1 h-8 text-xs" - /> + updateDataSource("processTable", v)} tables={tables} loading={loadingTables} />
- updateDataSource("processNameColumn", e.target.value)} - className="mt-1 h-8 text-xs" - /> + updateDataSource("processNameColumn", v)} tableName={config.dataSource.processTable} placeholder="공정명" />
- updateDataSource("processCodeColumn", e.target.value)} - className="mt-1 h-8 text-xs" - /> + updateDataSource("processCodeColumn", v)} tableName={config.dataSource.processTable} placeholder="공정코드" />
-- 2.43.0 From a3e6afa93e21bedbc02b05fecfcd4952a8e73722 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 17 Mar 2026 21:05:59 +0900 Subject: [PATCH 12/12] feat: enhance V2ButtonConfigPanel to support dynamic column mapping - Implemented logic to load reference table columns based on selected target table, improving the configurability of button mappings. - Added error handling for API calls to ensure robustness when fetching column data. - Updated dependencies in the useEffect hook to ensure proper reactivity when available tables change. These enhancements aim to provide users with a more flexible and dynamic configuration experience in the V2ButtonConfigPanel, allowing for better management of button mappings based on table relationships. --- .../v2/config-panels/V2ButtonConfigPanel.tsx | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/frontend/components/v2/config-panels/V2ButtonConfigPanel.tsx b/frontend/components/v2/config-panels/V2ButtonConfigPanel.tsx index ab7f8732..4df68117 100644 --- a/frontend/components/v2/config-panels/V2ButtonConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2ButtonConfigPanel.tsx @@ -387,13 +387,51 @@ export const V2ButtonConfigPanel: React.FC = ({ if (targetTable) { const cols = await loadTableColumns(targetTable); + + try { + const fullResponse = await apiClient.get(`/table-management/tables/${targetTable}/columns`); + let fullColumnData = fullResponse.data?.data; + if (!Array.isArray(fullColumnData) && fullColumnData?.columns) fullColumnData = fullColumnData.columns; + if (!Array.isArray(fullColumnData) && fullColumnData?.data) fullColumnData = fullColumnData.data; + + if (Array.isArray(fullColumnData)) { + const refTableSet = new Set(); + fullColumnData.forEach((col: any) => { + const inputType = col.inputType || col.input_type; + if (inputType !== "entity") return; + let refTable = col.referenceTable || col.reference_table; + if (!refTable && col.detailSettings) { + try { + const ds = typeof col.detailSettings === "string" ? JSON.parse(col.detailSettings) : col.detailSettings; + refTable = ds?.referenceTable; + } catch { /* ignore */ } + } + if (refTable) refTableSet.add(refTable); + }); + + const targetColumnNames = new Set(cols.map((c) => c.name)); + for (const refTable of refTableSet) { + const refCols = await loadTableColumns(refTable); + const refTableLabel = availableTables.find((t) => t.name === refTable)?.label || refTable; + refCols.forEach((rc) => { + if (!targetColumnNames.has(rc.name)) { + cols.push({ + name: rc.name, + label: `${rc.label} [${refTableLabel}]`, + }); + } + }); + } + } + } catch { /* ignore */ } + setMappingTargetColumns(cols); } else { setMappingTargetColumns([]); } }; loadAll(); - }, [actionType, config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.targetTable, loadTableColumns]); + }, [actionType, config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.targetTable, availableTables, loadTableColumns]); // 화면 목록 로드 (모달 액션용) useEffect(() => { -- 2.43.0