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 552d1265..197ad216 100644 --- a/.gitignore +++ b/.gitignore @@ -182,3 +182,12 @@ scripts/browser-test-*.js # 개인 작업 문서 popdocs/ .cursor/rules/popdocs-safety.mdc + +# 멀티 에이전트 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/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/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/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/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/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; } } 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/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/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} 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/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/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/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/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/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 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 }) } 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/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") 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/file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx index 11432e4a..f0ee3594 100644 --- a/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx @@ -154,48 +154,53 @@ const FileUploadComponent: React.FC = ({ // 🆕 레코드 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/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-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-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 diff --git a/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardConfigPanel.tsx b/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardConfigPanel.tsx index 27af18f4..c37bedbb 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardConfigPanel.tsx @@ -1,15 +1,113 @@ "use client"; -import React from "react"; -import { Plus, Trash2, GripVertical } from "lucide-react"; +import React, { useState, useEffect } from "react"; +import { Plus, Trash2, GripVertical, Check, ChevronsUpDown } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { cn } from "@/lib/utils"; import { ProcessWorkStandardConfig, WorkPhaseDefinition, DetailTypeDefinition } from "./types"; import { defaultConfig } from "./config"; +interface TableInfo { tableName: string; displayName?: string; } +interface ColumnInfo { columnName: string; displayName?: string; dataType?: string; } + +function TableCombobox({ value, onChange, tables, loading }: { + value: string; onChange: (v: string) => 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="공정코드" />
diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 15b7a13b..d1faf281 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, @@ -2816,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); @@ -4303,8 +4494,7 @@ export const TableListComponent: React.FC = ({ return ; } - // 📎 첨부파일 타입: 파일 아이콘과 개수 표시 - // 컬럼명이 'attachments'를 포함하거나, inputType이 file/attachment인 경우 + // 📎 첨부파일 타입: TableCellFile 컴포넌트로 렌더링 (objid, JSON 배열 모두 지원) const isAttachmentColumn = inputType === "file" || inputType === "attachment" || @@ -4312,41 +4502,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 -; } // 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원, 다중 값 지원) 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?: Recordnul +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