Compare commits

...

172 Commits

Author SHA1 Message Date
SeongHyun Kim cc44f714c6 Merge branch 'ksh-v2-work' into main
POP 화면 관리 기능 일괄 병합:
- POP 컴포넌트 연결/상태변경 규칙/후속 액션
- POP 장바구니(CartList) 모드 + 멀티필드 입력
- POP 화면 복사 기능 (단일 + 카테고리 일괄)
- POP 화면관리 UX 개선 (스크롤/접기)
- PC/POP 화면 데이터 분리 (excludePop 필터)
- .gitignore 미사용 항목 정리
충돌 1건 해결 (screenManagementRoutes.ts import 양쪽 통합)
2026-03-04 14:27:46 +09:00
SeongHyun Kim 9b153d85af chore: .gitignore에서 미사용 오케스트레이션 설정 항목 제거
실제 파일이 존재하지 않는 오케스트레이션 관련 gitignore 항목을 정리한다.
(orchestrator.mdc, agents/, commands/, hooks.json, hooks/, plans/)
2026-03-04 14:19:04 +09:00
kjs 7ad17065f0 Merge pull request 'jskim-node' (#396) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/396
2026-02-28 14:37:09 +09:00
kjs e2d88f01e3 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-28 14:36:12 +09:00
kjs e16d76936b feat: Enhance V2Repeater and configuration panel with source detail auto-fetching
- Added support for automatic fetching of detail rows from the master data in the V2Repeater component, improving data management.
- Introduced a new configuration option in the V2RepeaterConfigPanel to enable source detail auto-fetching, allowing users to specify detail table and foreign key settings.
- Enhanced the V2Repeater component to handle entity joins for loading data, optimizing data retrieval processes.
- Updated the V2RepeaterProps and V2RepeaterConfig interfaces to include new properties for grouped data and source detail configuration, ensuring type safety and clarity in component usage.
- Improved logging for data loading processes to provide better insights during development and debugging.
2026-02-28 14:33:18 +09:00
DDD1542 a8ad26cf30 refactor: Enhance horizontal label handling in dynamic components
- Updated the InteractiveScreenViewerDynamic and RealtimePreviewDynamic components to improve horizontal label rendering and style management.
- Refactored the DynamicComponentRenderer to support external horizontal labels, ensuring proper display and positioning based on component styles.
- Cleaned up style handling by removing unnecessary border properties for horizontal labels, enhancing visual consistency.
- Improved the logic for determining label display requirements, streamlining the rendering process for dynamic components.
2026-02-27 15:24:55 +09:00
DDD1542 026e99511c refactor: Enhance label display and drag-and-drop functionality in table configuration
- Updated the InteractiveScreenViewer and InteractiveScreenViewerDynamic components to include label positioning and size adjustments based on horizontal label settings.
- Improved the DynamicComponentRenderer to handle label display logic more robustly, allowing for string values in addition to boolean.
- Introduced drag-and-drop functionality in the TableListConfigPanel for reordering selected columns, enhancing user experience and flexibility in column management.
- Refactored the display name resolution logic to prioritize available column labels, ensuring accurate representation in the UI.
2026-02-27 14:30:31 +09:00
DDD1542 21c0c2b95c fix: Enhance layout loading logic in screen management
- Updated the ScreenManagementService to allow SUPER_ADMIN or users with companyCode as "*" to load layouts based on the screen's company code.
- Improved layout loading in ScreenViewPage and EditModal components by implementing fallback mechanisms to ensure a valid layout is always set.
- Added console warnings for better debugging when layout loading fails, enhancing error visibility and user experience.
- Refactored label display logic in various components to ensure consistent behavior across input types.
2026-02-27 14:00:06 +09:00
DDD1542 1a6d78df43 refactor: Improve existing item ID handling in BomItemEditorComponent
- Updated the logic for tracking existing item IDs to prevent duplicates during item addition, ensuring that sibling items are checked for duplicates at the same level while allowing duplicates in child levels.
- Enhanced the existingItemIds calculation to differentiate between root level and child level additions, improving data integrity and user experience.
- Refactored the useMemo hook to include addTargetParentId as a dependency, ensuring accurate updates when the target parent ID changes.
2026-02-27 13:30:57 +09:00
kjs b1831ada04 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-27 13:09:46 +09:00
DDD1542 649bd77bbb feat: Enhance dynamic form and BOM item editor functionality
- Added support for updating the `updated_date` field in the DynamicFormService, ensuring accurate timestamp management.
- Refactored the BomItemEditorComponent to improve data handling by filtering valid fields before saving, enhancing data integrity.
- Introduced a mechanism to track existing item IDs to prevent duplicates during item addition, improving user experience and data consistency.
- Streamlined the save process in ButtonActionExecutor by reorganizing the event handling logic, ensuring better integration with EditModal components.
2026-02-27 13:09:20 +09:00
kjs 8bfc2ba4f5 feat: Enhance dynamic form service to handle VIEW tables
- Introduced a new method `resolveBaseTable` to determine the original table name for VIEWs, allowing for seamless data operations.
- Updated existing methods (`saveFormData`, `updateFormDataPartial`, `updateFormData`, and `deleteFormData`) to utilize `resolveBaseTable`, ensuring that operations are performed on the correct base table.
- Improved logging to provide clearer insights into the operations being performed, including handling of original table names when dealing with VIEWs.
2026-02-27 13:00:22 +09:00
kjs c1f7f27005 fix: Improve option filtering in V2Select component
- Updated the option filtering logic to handle null and undefined values, preventing potential crashes when cmdk encounters these values.
- Introduced a safeOptions variable to ensure that only valid options are processed in the dropdown and command list.
- Enhanced the setOptions function to sanitize fetched options, ensuring that only valid values are set, improving overall stability and user experience.
2026-02-27 12:06:49 +09:00
DDD1542 c86337832a Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-02-27 11:57:36 +09:00
DDD1542 d686c385e0 feat: Implement edit mode detection in SelectedItemsDetailInputComponent
- Added logic to detect edit mode based on URL parameters and existing data IDs.
- Enhanced value retrieval for form fields to prioritize original data in edit mode, ensuring accurate updates.
- Removed redundant edit mode detection comments to streamline the code and improve clarity.
2026-02-27 11:57:21 +09:00
kjs 0f52c3adc2 refactor: Improve V2Repeater integration and event handling
- Updated the EditModal component to check for registered V2Repeater instances before saving detail data, enhancing the reliability of the repeater save process.
- Simplified the V2Repeater component by removing unnecessary groupedData handling, ensuring it manages its own data effectively.
- Enhanced the DynamicComponentRenderer to correctly handle V2Repeater's data management, improving overall component behavior.
- Refactored button actions to wait for V2Repeater save completion only when active repeaters are present, optimizing performance and user experience.
2026-02-27 11:46:43 +09:00
DDD1542 36bc33860f Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-02-27 11:39:37 +09:00
kjs 1b7163ee1a refactor: Hide selected rows information in TableListComponent
- Removed the display of selected rows count and the deselect button from the TableListComponent.
- Updated the comment to indicate that the selected information is now hidden, improving code clarity and maintainability.
2026-02-27 11:01:22 +09:00
DDD1542 c0df38c7ba Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-02-27 10:39:51 +09:00
DDD1542 4e997ae36b feat: Enhance V2Select component with automatic value normalization and update handling
- Implemented automatic normalization of legacy plain text values to category codes, improving data consistency.
- Added logic to handle comma-separated values, allowing for better processing of complex input formats.
- Integrated automatic updates to the onChange handler when the normalized value differs from the original, ensuring accurate data saving.
- Updated various select components to utilize the resolved value for consistent behavior across different selection types.
2026-02-27 08:48:21 +09:00
kjs 929b68299a feat: Implement BOM Excel upload and download functionality
- Added endpoints for uploading BOM data from Excel and downloading BOM data in Excel format.
- Developed the `createBomFromExcel` function to handle Excel uploads, including validation and error handling.
- Implemented the `downloadBomExcelData` function to retrieve BOM data for Excel downloads.
- Created a new `BomExcelUploadModal` component for the frontend to facilitate Excel file uploads.
- Updated BOM routes to include new Excel upload and download routes, enhancing BOM management capabilities.
2026-02-27 07:50:22 +09:00
DDD1542 bfc89501ba feat: Enhance BOM and UI components with improved label handling and data mapping
- Updated the BOM service to include additional fields in the BOM header retrieval, enhancing data richness.
- Enhanced the EditModal to automatically map foreign key fields to dot notation, improving data handling and user experience.
- Improved the rendering of labels in various components, allowing for customizable label positions and styles, enhancing UI flexibility.
- Added new properties for label positioning and spacing in the V2 component styles, allowing for better layout control.
- Enhanced the BomTreeComponent to support additional data mapping for entity joins, improving data accessibility and management.
2026-02-27 07:33:54 +09:00
kjs d50f705c44 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-26 20:55:56 +09:00
kjs 708a0fbd1f Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-26 20:55:15 +09:00
kjs bbbdd31311 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-26 20:55:03 +09:00
kjs 38ade7562e refactor: Update ProcessWorkStandard component to manage work item selection by phase
- Removed the "정보조회" option from the default configuration.
- Refactored the ProcessWorkStandardComponent to handle work item selection independently for each phase.
- Updated the WorkPhaseSection to pass phase-specific parameters for work item selection and detail management.
- Enhanced the useProcessWorkStandard hook to maintain separate states for selected work items and details by phase, improving data handling and user experience.
2026-02-26 20:49:25 +09:00
DDD1542 385a10e2e7 feat: Add BOM version initialization feature and enhance version handling
- Implemented a new endpoint to initialize BOM versions, automatically creating the first version and updating related details.
- Enhanced the BOM service to include logic for version name handling and duplication checks during version creation.
- Updated the BOM controller to support the new initialization functionality, improving BOM management capabilities.
- Improved the BOM version modal to allow users to specify version names during creation, enhancing user experience and flexibility.
2026-02-26 20:48:56 +09:00
kjs 2335a413cb Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-26 17:32:40 +09:00
kjs e622013b3d feat: Enhance image handling in TableCellImage component
- Updated the TableCellImage component to support multiple image inputs, displaying a representative image when available.
- Implemented a new helper function `loadImageBlob` for loading images from blob URLs, improving image loading efficiency.
- Refactored image loading logic to handle both single and multiple objid cases, ensuring robust error handling and loading states.
- Enhanced user experience by allowing direct URL usage for non-objid image paths.
2026-02-26 17:32:39 +09:00
kjs 17d4cc297c feat: Introduce new date picker components for enhanced date selection
- Added `FormDatePicker` and `InlineCellDatePicker` components to provide flexible date selection options.
- Implemented a modernized date picker interface with calendar navigation, year selection, and time input capabilities.
- Enhanced `DateWidget` to support both date and datetime formats, improving user experience in date handling.
- Updated `CategoryColumnList` to group columns dynamically and manage expanded states for better organization.
- Improved `AlertDialog` z-index for better visibility in modal interactions.
- Refactored `ScreenModal` to ensure consistent modal behavior across the application.
2026-02-26 17:32:20 +09:00
DDD1542 afc66a4971 feat: Enhance SelectedItemsDetailInputComponent with improved FK mapping and performance optimizations
- Implemented automatic detection of sourceKeyField based on component configuration, enhancing data handling flexibility.
- Updated SelectedItemsDetailInputConfigPanel to support automatic FK detection and mapping, streamlining configuration.
- Improved database connection logic for DATE types to prevent timezone-related issues.
- Optimized memoization and state management for better overall component performance and user experience.
2026-02-26 17:07:53 +09:00
SeongHyun Kim c161957cfe Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node 2026-02-26 16:53:51 +09:00
SeongHyun Kim 0e0d433ce3 Merge branch 'ksh-v2-work' into main
pop 컴포넌트 중간 병합:
- feat(pop-card-list): PopCardList 컴포넌트 구현 + 3섹션 분리 + 포장 2단계 계산기
- feat(pop-cart): 장바구니 저장 시스템 구현 + 선택적 컬럼 저장
- feat(pop-search): 모달 뷰 개선 (아이콘 뷰, 가나다/ABC 필터 탭)
- feat(pop-button): 버튼 컴포넌트 확장
- fix(pop): 전수 점검 방어 코드 추가
2026-02-26 16:53:21 +09:00
kmh 95c8148787 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-26 16:51:12 +09:00
DDD1542 52d95b4798 123 2026-02-26 16:50:41 +09:00
DDD1542 43ead0e7f2 feat: Enhance SelectedItemsDetailInputComponent with sourceKeyField auto-detection and FK mapping
- Implemented automatic detection of sourceKeyField based on component configuration, improving flexibility in data handling.
- Enhanced the SelectedItemsDetailInputConfigPanel to support automatic FK detection and mapping, streamlining the configuration process.
- Updated the database connection logic to handle DATE types correctly, preventing timezone-related issues.
- Improved overall component performance by optimizing memoization and state management for better user experience.
2026-02-26 16:39:06 +09:00
kmh 935c737fe3 Merge origin/jskim-node into jskim-node
Made-with: Cursor
2026-02-26 16:29:10 +09:00
kmh 5888ff9c9e Merge branch 'feature/v2-renewal' into jskim-node
Made-with: Cursor
2026-02-26 16:26:48 +09:00
kjs 27be48464a Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-26 16:07:16 +09:00
kjs 20167ad359 feat: Implement advanced filtering capabilities in entity search
- Added a new helper function `applyFilters` to handle dynamic filter conditions for entity search queries.
- Enhanced the `getDistinctColumnValues` and `getEntityOptions` endpoints to support JSON array filters, allowing for more flexible data retrieval based on specified conditions.
- Updated the frontend components to integrate filter conditions, improving user interaction and data management in selection components.
- Introduced new filter options in the V2Select component, enabling users to define and apply various filter criteria dynamically.
2026-02-26 16:07:15 +09:00
kjs f90bf63354 Merge pull request 'feat: Add category reference to ColumnSettings interface' (#395) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/395
2026-02-26 13:53:36 +09:00
kjs 95caa2d10c Merge branch 'main' into jskim-node 2026-02-26 13:53:28 +09:00
kjs 63d8e17392 feat: Add category reference to ColumnSettings interface
- Introduced a new optional property `categoryRef` to the `ColumnSettings` interface in `tableManagement.ts`, allowing for better handling of category references in table configurations.
2026-02-26 13:53:12 +09:00
kjs 52389292a7 Merge pull request 'jskim-node' (#394) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/394
2026-02-26 13:48:07 +09:00
kjs dd86d5e63c Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-26 13:47:28 +09:00
kjs 495594913f Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-26 13:46:57 +09:00
kjs efc4768ed7 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-26 13:45:56 +09:00
kmh 4e81571f2b Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-26 13:42:02 +09:00
DDD1542 46ea3612fd feat: Enhance BOM management with new header retrieval and version handling
- Added a new endpoint to retrieve BOM headers with entity join support, improving data accessibility.
- Updated the BOM service to include logic for fetching current version IDs and handling version-related data more effectively.
- Enhanced the BOM tree component to utilize the new BOM header API for better data management.
- Implemented version ID fallback mechanisms to ensure accurate data representation during BOM operations.
- Improved the overall user experience by integrating new features for version management and data loading.
2026-02-26 13:09:32 +09:00
kjs eb27f01616 feat: Enhance category column handling and data mapping
- Updated the `getCategoryColumnsByCompany` and `getCategoryColumnsByMenu` functions to exclude reference columns from category column queries, improving data integrity.
- Modified the `TableManagementService` to include `category_ref` in the column management logic, ensuring proper handling of category references during data operations.
- Enhanced the frontend components to support category reference mapping, allowing for better data representation and user interaction.
- Implemented category label conversion in various components to improve the display of category data, ensuring a seamless user experience.
2026-02-26 11:31:49 +09:00
kmh 5cff85d260 Merge branch 'feature/v2-renewal' into jskim-node
Made-with: Cursor
2026-02-26 09:45:41 +09:00
kjs 863ec614f4 feat: Implement layer activation and data transfer enhancements
- Added support for force-activated layer IDs in ScreenViewPage, allowing layers to be activated based on data events.
- Introduced ScreenContextProvider in ScreenModal and EditModal to manage screen-specific data and context.
- Enhanced V2Repeater to register as a DataReceiver, enabling automatic data handling and integration with ScreenContext.
- Improved ButtonPrimaryComponent to support automatic target component discovery and layer activation for data transfers.
- Updated various components to streamline data handling and improve user experience during data transfers and layer management.
2026-02-25 17:40:17 +09:00
DDD1542 0f3ec495a5 feat: Add BOM version activation feature and enhance BOM management
- Implemented the `activateBomVersion` function in the BOM controller to allow activation of specific BOM versions.
- Updated BOM routes to include the new activation endpoint for BOM versions.
- Enhanced the BOM service to handle version activation logic, including status updates and BOM header version changes.
- Improved the BOM version modal to support version activation with user confirmation and feedback.
- Added checks to prevent deletion of active BOM versions, ensuring data integrity.
2026-02-25 16:18:46 +09:00
kmh 5e605efa26 Merge branch 'origin/jskim-node' into jskim-node
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 15:42:50 +09:00
kjs 55cbd8778a fix: Update import statements for apiClient in BOM components
- Changed the import statement for apiClient in BomDetailEditModal.tsx and BomHistoryModal.tsx to use named import syntax.
- This change ensures consistency in the import style across the BOM components, improving code readability and maintainability.
2026-02-25 15:34:03 +09:00
kjs 66c92bb7b1 feat: Enhance image rendering in SplitPanelLayoutComponent
- Introduced SplitPanelCellImage component for rendering image thumbnails in table cells, supporting both object IDs and file paths.
- Updated formatCellValue function to display images for columns with input type "image".
- Improved loading logic for column input types to accommodate special rendering for images in both SplitPanelLayoutComponent and V2SplitPanelLayoutComponent.
- Enhanced error handling for image loading failures, ensuring a better user experience when images cannot be displayed.
2026-02-25 15:29:04 +09:00
kjs abb31a39bb feat: Add image thumbnail rendering in SplitPanelLayoutComponent
- Introduced SplitPanelCellImage component to handle image rendering for table cells, supporting both object IDs and file paths.
- Enhanced formatCellValue function to display image thumbnails for columns with input type "image".
- Updated column input types loading logic to accommodate special rendering for images in the right panel.
- Improved error handling for image loading failures, ensuring a better user experience when images cannot be displayed.
2026-02-25 15:28:50 +09:00
DDD1542 18cf5e3269 feat: Add BOM management features and enhance BOM tree component
- Integrated BOM routes into the backend for managing BOM history and versions.
- Enhanced the V2BomTreeConfigPanel to include options for history and version table management.
- Updated the BomTreeComponent to support viewing BOM data in both tree and level formats, with modals for editing BOM details, viewing history, and managing versions.
- Improved user interaction with new buttons for accessing BOM history and version management directly from the BOM tree view.
2026-02-25 14:50:51 +09:00
kjs 262221e300 fix: Refine ExcelUploadModal and TableListComponent for improved data handling
- Updated ExcelUploadModal to automatically generate numbering codes when Excel values are empty, enhancing user experience during data uploads.
- Modified TableListComponent to display only the first image in case of multiple images, ensuring clarity in image representation.
- Improved data handling logic in TableListComponent to prevent unnecessary processing of string values.
2026-02-25 14:42:42 +09:00
DDD1542 ed9e36c213 feat: Enhance table data addition with inserted ID response
- Updated the `addTableData` method in `TableManagementService` to return the inserted ID after adding data to the table.
- Modified the `addTableData` controller to log the inserted ID and include it in the API response, improving client-side data handling.
- Enhanced the `BomTreeComponent` to support additional configurations and improve data loading logic.
- Updated the `ButtonActionExecutor` to handle deferred saves with level-based grouping, ensuring proper ID mapping during master-detail saves.
2026-02-25 13:59:51 +09:00
kjs 38dda2f807 fix: Improve TableListComponent and UniversalFormModalComponent for better data handling
- Updated TableListComponent to use flex-nowrap and overflow-hidden for better badge rendering.
- Enhanced UniversalFormModalComponent to maintain the latest formData using a ref, preventing stale closures during form save events.
- Improved data merging logic in UniversalFormModalComponent to ensure accurate updates and maintain original data integrity.
- Refactored buttonActions to streamline table section data collection and merging, ensuring proper handling of modified and original data during save operations.
2026-02-25 13:53:20 +09:00
kjs 60b1ac1442 feat: Enhance numbering rule service with separator handling
- Introduced functionality to extract and manage individual separators for numbering rule parts.
- Added methods to join parts with their respective separators, improving code generation flexibility.
- Updated the numbering rule service to utilize the new separator logic during part processing.
- Enhanced the frontend components to support custom separators for each part, allowing for more granular control over numbering formats.
2026-02-25 12:25:30 +09:00
DDD1542 2b175a21f4 feat: Enhance entity options retrieval with additional fields support
- Updated the `getEntityOptions` function to accept an optional `fields` parameter, allowing clients to specify additional columns to be retrieved.
- Implemented logic to dynamically include extra columns in the SQL query based on the provided `fields`, improving flexibility in data retrieval.
- Enhanced the response to indicate whether extra fields were included, facilitating better client-side handling of the data.
- Added logging for authentication failures in the `AuthGuard` component to improve debugging and user experience.
- Integrated auto-fill functionality in the `V2Select` component to automatically populate fields based on selected entity references, enhancing user interaction.
- Updated the `ItemSearchModal` to support multi-selection of items, improving usability in item management scenarios.
2026-02-25 11:45:28 +09:00
kmh d09daa1503 Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-25 11:16:27 +09:00
kjs 3ca511924e feat: Implement company-specific NOT NULL constraint validation for table data
- Added validation for NOT NULL constraints in the add and edit table data functions, ensuring that required fields are not empty based on company-specific settings.
- Enhanced the toggleColumnNullable function to check for existing NULL values before changing the NOT NULL status, providing appropriate error messages.
- Introduced a new service method to validate NOT NULL constraints against company-specific configurations, improving data integrity in a multi-tenancy environment.
2026-02-24 18:40:36 +09:00
kjs cb4fa2aaba feat: Implement default version management for routing versions
- Added functionality to set and unset default versions for routing items.
- Introduced new API endpoints for setting and unsetting default versions.
- Enhanced the ItemRoutingComponent to support toggling default versions with user feedback.
- Updated database queries to handle default version logic effectively.
- Improved the overall user experience by allowing easy management of routing versions.
2026-02-24 18:22:54 +09:00
SeongHyun Kim b8569c6641 Merge branch 'ksh-v2-work' 2026-02-24 16:41:29 +09:00
kmh 2392dca6fc Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-24 16:27:15 +09:00
kjs 593eee3a34 Merge pull request 'jskim-node' (#393) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/393
2026-02-24 15:31:31 +09:00
kjs 0b6c305024 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-24 15:30:07 +09:00
kjs 9a85343166 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-24 15:28:59 +09:00
kjs 89b7627bcd Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-24 15:28:46 +09:00
kjs 969b53637a Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-24 15:28:42 +09:00
kjs 5ed2d42377 Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-24 15:28:22 +09:00
kjs a6f37fd3dc feat: Enhance SplitPanelLayoutComponent with improved filtering and modal handling
- Updated search conditions to use an object structure with an "equals" operator for better filtering logic.
- Added validation to ensure an item is selected in the left panel before opening the modal, providing user feedback through a toast notification.
- Extracted foreign key data from the selected left item for improved data handling when opening the modal.
- Cleaned up the code by removing unnecessary comments and consolidating logic for clarity and maintainability.
2026-02-24 15:28:21 +09:00
DDD1542 72068d003a refactor: Enhance screen layout retrieval logic for multi-tenancy support
- Updated the ScreenManagementService to prioritize fetching layouts based on layer_id, ensuring that only the default layer is retrieved for users.
- Implemented logic for administrators to re-query layouts based on the screen definition's company_code when no layout is found.
- Adjusted the BomItemEditorComponent to dynamically render table cells based on configuration, improving flexibility and usability in the BOM item editor.
- Introduced category options loading for dynamic cell rendering, enhancing the user experience in item editing.
2026-02-24 15:27:18 +09:00
kjs bb7399df07 Merge pull request 'refactor: Update request type in processWorkStandardController to use AuthenticatedRequest' (#392) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/392
2026-02-24 12:43:16 +09:00
kjs 6b4250b903 Merge branch 'main' into jskim-node 2026-02-24 12:43:08 +09:00
kjs 076184aad2 refactor: Update request type in processWorkStandardController to use AuthenticatedRequest
- Changed the request type from `Request` to `AuthenticatedRequest` in multiple functions within the processWorkStandardController.
- This update ensures that user authentication details are accessible in the request object, enhancing security and functionality for work item management operations.
2026-02-24 12:42:54 +09:00
kjs 19efe4ada5 Merge pull request 'jskim-node' (#391) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/391
2026-02-24 12:38:41 +09:00
kjs fc96c958ba Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-24 12:37:59 +09:00
kjs 4f6d9a689d feat: Implement process work standard routes and controller
- Added a new controller for managing process work standards, including CRUD operations for work items and routing processes.
- Introduced routes for fetching items with routing, retrieving routings with processes, and managing work items.
- Integrated the new process work standard routes into the main application file for API accessibility.
- Created a migration script for exporting data related to the new process work standard feature.
- Updated frontend components to support the new process work standard functionality, enhancing the overall user experience.
2026-02-24 12:37:33 +09:00
kmh 4ed2fa4d65 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-24 11:03:33 +09:00
DDD1542 5afa373b1f refactor: Update middleware and enhance component interactions
- Improved the middleware to handle authentication checks more effectively, ensuring that users are redirected appropriately based on their authentication status.
- Updated the InteractiveScreenViewerDynamic and RealtimePreviewDynamic components to utilize a new subscription method for DOM manipulation during drag events, enhancing performance and user experience.
- Refactored the SplitLineComponent to optimize drag handling and state management, ensuring smoother interactions during component adjustments.
- Integrated API client for menu data loading, streamlining token management and error handling.
2026-02-24 11:02:43 +09:00
DDD1542 27853a9447 feat: Add BOM tree view and BOM item editor components
- Introduced new components for BOM tree view and BOM item editor, enhancing the data management capabilities within the application.
- Updated the ComponentsPanel to include these new components with appropriate descriptions and default sizes.
- Integrated the BOM item editor into the V2PropertiesPanel for seamless editing of BOM items.
- Adjusted the SplitLineComponent to improve the handling of canvas split positions, ensuring better user experience during component interactions.
2026-02-24 10:49:23 +09:00
kjs e8c0828d91 feat: Add process work standard component implementation plan
- Introduced a comprehensive implementation plan for the v2-process-work-standard component, detailing the current state analysis, required database tables, API design, and implementation phases.
- Included a structured file organization plan for both frontend and backend components, ensuring clarity in development and integration.
- Updated the V2Repeater component to support new auto-fill functionalities, including parent sequence generation, enhancing data management capabilities.
- Enhanced the V2RepeaterConfigPanel to allow configuration of parent sequence settings, improving user experience in managing data entries.
2026-02-24 10:15:25 +09:00
DDD1542 5ec689101e Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-02-24 09:30:02 +09:00
DDD1542 4e422fc477 Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node 2026-02-24 09:29:44 +09:00
kjs f2528fcb39 Merge pull request 'jskim-node' (#390) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/390
2026-02-23 12:17:51 +09:00
kmh ea610a243a Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-23 11:18:02 +09:00
kjs 9cc93b88ff Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-23 10:53:55 +09:00
kjs aec516b8dc Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-23 10:53:47 +09:00
kjs 350d567f3e Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-23 10:53:29 +09:00
kjs ab385f4bba Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-23 10:53:12 +09:00
kjs bfdf061ead refactor: Clean up and enhance component structure in V2Media and ComponentsPanel
- Removed redundant comments and improved clarity in the `ComponentsPanel` for better maintainability.
- Refactored the `V2Media` component to streamline the file handling logic and ensure consistent state management.
- Merged default configurations in `UniversalFormModalConfigPanel` to enhance safety and prevent potential issues with incomplete configurations.
- Updated file upload handling in `FileManagerModal` to improve user experience and maintain consistent styling across components.
2026-02-23 10:53:10 +09:00
kmh f2bd7edf7e Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-23 10:49:48 +09:00
DDD1542 9614ce3973 feat: Enhance EditModal and V2Repeater functionality
- Implemented zone offset adjustments for conditional components in EditModal to ensure correct rendering positions.
- Added repeaterSave event dispatching in EditModal after saving data, improving integration with V2Repeater.
- Updated V2Repeater to handle existing detail data loading based on foreign key relationships, enhancing data management.
- Improved calculation rules handling in V2RepeaterConfigPanel, allowing for dynamic updates and better user experience.
- Enhanced SplitPanelLayoutComponent to conditionally load data based on selected items and tab changes, improving performance and usability.
2026-02-23 09:16:44 +09:00
DDD1542 5af41ad90b Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-02-22 20:54:59 +09:00
DDD1542 9e9aa01b03 Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node 2026-02-22 20:54:34 +09:00
DDD1542 bfd90792f8 feat: Enhance ScreenModal and InteractiveScreenViewer with improved resolution handling
- Added detailed console logging in ScreenModal for debugging screen resolution, including final resolution and dimensions applied.
- Updated getModalStyle in ScreenModal to handle null screen dimensions gracefully, ensuring default styles are applied when necessary.
- Modified InteractiveScreenViewer's DialogContent to dynamically adjust width based on popupScreenResolution, improving responsiveness and user experience.
- Ensured maximum width constraints are respected in both components, enhancing layout consistency across different screen sizes.
2026-02-13 15:12:54 +09:00
kjs 5eab4669f0 feat: Update screen management service and UI components for main table handling
- Enhanced the `ScreenManagementService` to update the main table name in the database when saving layout data, improving data integrity and tracking.
- Modified the `ScreenDesigner` component to include the main table name in the save request, ensuring the correct table is referenced.
- Updated the `TablesPanel` to generate unique keys for join tables based on source columns, preventing key collisions and improving rendering performance.
- Refactored the `TabsWidget` to streamline screen information loading and removed redundant screen info loading logic, enhancing efficiency and user experience.
2026-02-13 14:25:12 +09:00
kmh 2289c88320 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-13 14:10:42 +09:00
DDD1542 a466e523d9 refactor: Remove password masking functionality from data services
- Deleted the `maskPasswordColumns` function from `dataService.ts` and its usage in data responses, simplifying the data handling process.
- Removed password handling logic from `DynamicFormService`, ensuring that password management is streamlined and centralized.
- Updated related components to reflect the removal of password masking, improving code clarity and maintainability.
2026-02-13 11:51:59 +09:00
kjs 2395a8d6b7 Merge pull request 'feat: Update data service response structure to include savedIds' (#389) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/389
2026-02-13 10:44:59 +09:00
kjs 59417b76aa Merge branch 'main' into jskim-node 2026-02-13 10:44:52 +09:00
kjs 92bfac8cd7 feat: Update data service response structure to include savedIds
- Modified the return type of the data service method to include an optional `savedIds` array, enhancing the response structure for better tracking of saved records.
- This change improves the flexibility of the service by allowing clients to receive additional information about the saved entries.
2026-02-13 10:44:18 +09:00
kjs 0d1a19e852 Merge pull request 'jskim-node' (#388) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/388
2026-02-13 09:59:54 +09:00
kjs 0006c04c7d Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-13 09:59:27 +09:00
kjs 97165ab007 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-13 09:59:07 +09:00
kjs f35ba75966 feat: Enhance dynamic form service and tabs widget functionality
- Added error handling in DynamicFormService to throw an error when a record is not found during deletion, improving robustness.
- Updated TabsWidget to load screen information in parallel with layout data, enhancing performance and user experience.
- Implemented logic to supplement missing screen information for tabs, ensuring all relevant data is available for rendering.
- Enhanced component rendering functions to pass additional screen information, improving data flow and interaction within the widget.
2026-02-13 09:58:36 +09:00
DDD1542 95f668d40d Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-02-12 16:33:00 +09:00
DDD1542 b1ec674fa9 feat: Implement password masking and encryption in data services
- Added a new function `maskPasswordColumns` to mask password fields in data responses, ensuring sensitive information is not exposed.
- Integrated password handling in `DynamicFormService` to encrypt new passwords and maintain existing ones when empty values are provided.
- Enhanced logging for better tracking of password field updates and masking failures, improving overall security and debugging capabilities.
2026-02-12 16:32:23 +09:00
DDD1542 df04afa5de feat: Refactor EditModal for improved INSERT/UPDATE handling
- Introduced a new state flag `isCreateModeFlag` to determine the mode (INSERT or UPDATE) directly from the event, enhancing clarity in the modal's behavior.
- Updated the logic for initializing `originalData` and determining the mode, ensuring that the modal correctly identifies whether to create or update based on the provided data.
- Refactored the update logic to send the entire `formData` without relying on `originalData`, streamlining the update process.
- Enhanced logging for better debugging and understanding of the modal's state during operations.
2026-02-12 16:20:26 +09:00
kjs d0ebb82f90 fix: Improve number and slider input handling in V2Input and SplitPanelLayoutComponent
- Enhanced V2Input to convert string values from the database to numbers for number and slider inputs, ensuring correct display and functionality.
- Updated primary key retrieval logic in SplitPanelLayoutComponent to prioritize actual DB id values, improving data integrity.
- Simplified primary key handling by removing unnecessary checks and ensuring consistent usage of id fields.
- Improved user feedback in the SplitPanelLayoutComponent with clearer console logs for save operations and item selections.
2026-02-12 16:07:36 +09:00
kjs 505930b3ec feat: Implement custom right panel save functionality in SplitPanelLayoutComponent
- Added a new save handler for the custom right panel, allowing users to save inline edit data.
- Implemented validation checks to ensure data integrity before saving, including checks for selected items and primary keys.
- Enhanced user feedback with toast notifications for success and error states during the save process.
- Integrated company_code automatically into the saved data to maintain multi-tenancy compliance.
- Updated the UI to include a save button in the custom mode, improving user interaction and data management.
2026-02-12 15:03:56 +09:00
kjs fb02e5b389 feat: Enhance SplitPanelLayout with modal support for add and edit buttons
- Implemented modal configuration for add and edit buttons in the SplitPanelLayoutComponent, allowing for custom modal screens based on user interactions.
- Added settings for button visibility and modes (auto or modal) in the SplitPanelLayoutConfigPanel, improving flexibility in UI configuration.
- Enhanced data handling by storing selected left panel items in modalDataStore for use in modal screens, ensuring seamless data flow.
- Updated types to include new properties for add and edit button configurations, facilitating better type safety and clarity in component usage.
2026-02-12 14:54:14 +09:00
kjs 5d391f0cee Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-12 14:19:31 +09:00
kjs beb873f9f1 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-12 14:19:22 +09:00
kjs 70cb50e446 feat: Update SplitPanelLayoutComponent to manage custom left selected data
- Initialized custom left selected data to an empty object when deselecting an item, ensuring a clean state for the right form.
- Passed the selected item to the custom left selected data when an item is selected, improving data handling in custom mode.
- Enhanced overall data management within the SplitPanelLayoutComponent for better user experience.
2026-02-12 14:18:46 +09:00
DDD1542 4294e6206b feat: Add express-async-errors for improved error handling
- Integrated express-async-errors to automatically handle errors in async route handlers, enhancing the overall error management in the application.
- Updated app.ts to include the express-async-errors import for global error handling.
- Removed redundant logging statements in admin and user menu retrieval functions to streamline the code and improve readability.
- Adjusted logging levels from info to debug for less critical logs, ensuring that important information is logged appropriately without cluttering the logs.
2026-02-12 11:42:52 +09:00
kjs 4473743d5f Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-12 11:42:39 +09:00
kjs 14d6406a61 feat: Improve selected rows data management in TabsWidget and SplitPanelLayoutComponent
- Refactored TabsWidget to manage local selected rows data, enhancing responsiveness to user interactions.
- Introduced a new callback for handling selected rows changes, ensuring updates are reflected in both local and parent states.
- Updated SplitPanelLayoutComponent to share selected rows data between tabs and buttons, improving data consistency across components.
- Enhanced overall user experience by ensuring immediate recognition of selection changes within the tabbed interface.
2026-02-12 11:42:32 +09:00
kjs 5c6efa861d feat: Add support for selected rows data handling in TabsWidget
- Introduced new props for managing selected rows data, enabling better interaction with tab components.
- Added `selectedRowsData` and `onSelectedRowsChange` callbacks to facilitate row selection and updates.
- Enhanced the TabsWidget functionality to improve user experience when interacting with tabbed content.
2026-02-12 10:30:37 +09:00
kjs 56d069f853 feat: Enhance master-detail Excel upload functionality with detail update tracking
- Added support for tracking updated detail records during the Excel upload process, improving feedback to users on the number of records inserted and updated.
- Updated response messages to provide clearer information about the processing results, including the number of newly inserted and updated detail records.
- Refactored related components to ensure consistency in handling detail updates and improve overall user experience during uploads.
2026-02-11 18:29:36 +09:00
DDD1542 0512a3214c Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node 2026-02-11 18:05:32 +09:00
DDD1542 4e12f93da4 feat: Enhance SplitPanelLayoutComponent with delete modal improvements
- Added a new state to manage the table name for the delete modal, allowing for more specific deletion handling based on the context of the item being deleted.
- Updated the delete button handler to accept an optional table name parameter, improving the flexibility of the delete functionality.
- Enhanced the delete confirmation logic to prioritize the specified table name when available, ensuring accurate deletion operations.
- Refactored related logic to maintain clarity and improve the overall user experience during item deletion in the split panel layout.
2026-02-11 17:45:43 +09:00
DDD1542 c551e82eee Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-02-11 17:45:26 +09:00
kjs e065835c4d feat: Add PK and index management APIs for table management
- Implemented new API endpoints for managing primary keys and indexes in the table management system.
- Added functionality to retrieve table constraints, set primary keys, toggle indexes, and manage NOT NULL constraints.
- Enhanced the frontend to support PK and index management, including loading constraints and handling user interactions for toggling indexes and setting primary keys.
- Improved error handling and logging for better debugging and user feedback during these operations.
2026-02-11 16:07:44 +09:00
kjs 2bbb5d7013 feat: Enhance Excel upload functionality with automatic numbering column detection
- Implemented automatic detection of numbering columns in the Excel upload modal, improving user experience by streamlining the upload process.
- Updated the master-detail Excel upload configuration to reflect changes in how numbering rules are applied, ensuring consistency across uploads.
- Refactored related components to remove deprecated properties and improve clarity in the configuration settings.
- Enhanced error handling and logging for better debugging during the upload process.
2026-02-11 15:43:50 +09:00
kjs eac2fa63b1 feat: Enhance input and select components with custom styling support
- Added support for custom border, background, and text styles in V2Input and V2Select components, allowing for greater flexibility in styling based on user-defined configurations.
- Updated the input and select components to conditionally apply styles based on the presence of custom properties, improving the overall user experience and visual consistency.
- Refactored the FileUploadComponent to handle chunked file uploads, enhancing the file upload process by allowing multiple files to be uploaded in batches, improving performance and user feedback during uploads.
2026-02-11 14:45:23 +09:00
DDD1542 ced25c9a54 feat: Enhance SplitPanelLayoutComponent with improved data loading and filtering logic
- Updated loadRightData function to support loading all data when no leftItem is selected, applying data filters as needed.
- Enhanced loadTabData function to handle data loading for tabs, including support for data filters and entity joins.
- Improved comments for clarity on data loading behavior based on leftItem selection.
- Refactored UI components in SplitPanelLayoutConfigPanel for better styling and organization, including updates to table selection and display settings.
2026-02-11 10:46:47 +09:00
kjs 308f05ca07 fix: Correct file upload configuration handling in FileUploadComponent
- Updated the file upload configuration to ensure that the safeComponentConfig is properly merged into fileConfig.
- This change enhances the reliability of file upload settings by ensuring that default values are applied correctly, improving the overall functionality of the file upload feature.
2026-02-11 09:47:59 +09:00
kjs 225fd50ca1 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-11 09:39:56 +09:00
kjs 9785f098d8 feat: Enhance image handling in table components with improved loading and error states
- Introduced a new TableCellImage component for rendering images in table cells, supporting both object IDs and direct URLs.
- Implemented loading and error states for images, providing a better user experience when images fail to load.
- Updated CardModeRenderer and SingleTableWithSticky components to utilize the new image handling logic, ensuring consistent image rendering across the application.
- Enhanced formatCellValue function to return React nodes, allowing for more flexible cell content rendering.
2026-02-10 18:30:15 +09:00
kjs 5b44a41651 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-10 16:23:32 +09:00
kjs 86a73267cb Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-10 16:23:27 +09:00
DDD1542 403b3da36d 123 2026-02-10 14:31:48 +09:00
kjs e97fd05e75 feat: Enhance CardDisplay and SplitPanelLayout components with improved table name handling and custom selection data
- Updated CardDisplayComponent to streamline table name retrieval from props or component configuration.
- Introduced custom selection data management in SplitPanelLayoutComponent, allowing for better handling of selected items in custom mode.
- Enhanced form data handling in SplitPanelLayoutComponent to utilize selected data from the left panel, improving data flow and user experience.
2026-02-10 14:01:43 +09:00
kjs 8253be0048 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-10 12:17:26 +09:00
kjs a8432b83ba Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-10 12:17:21 +09:00
DDD1542 8894216ee8 feat: Improve entity join handling with enhanced column validation and support for complex keys
- Updated the entityJoinService to include type casting for source and reference columns, ensuring compatibility during joins.
- Implemented validation for reference columns in the TableManagementService, allowing automatic fallback to 'id' if the specified reference column does not exist.
- Enhanced logging for join configurations to provide better insights during the join setup process.
- Transitioned the SplitPanelLayoutComponent to utilize the entityJoinApi for handling single key to composite key transformations, improving data retrieval efficiency.
- Added support for displaying null or empty values as "-" in the SplitPanelLayout, enhancing user experience.
2026-02-10 12:07:25 +09:00
kjs b05a883353 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-10 11:38:38 +09:00
kjs 219f7724e7 feat: Enhance MasterDetailExcelService with table alias for JOIN operations
- Added a new property `tableAlias` to distinguish between master ("m") and detail ("d") tables during JOIN operations.
- Updated the SELECT clause to include the appropriate table alias for master and detail tables.
- Improved the entity join clause construction to utilize the new table alias, ensuring clarity in SQL queries.
2026-02-10 11:38:02 +09:00
DDD1542 3c8c2ebcf4 feat: Enhance entity join functionality with company code support
- Updated the EntityJoinController to log the company code during entity join configuration retrieval.
- Modified the entityJoinService to accept company code as a parameter, allowing for company-specific entity join detection.
- Enhanced the TableManagementService to pass the company code when detecting entity joins and retrieving reference table columns.
- Implemented a helper function in the SplitPanelLayoutComponent to extract additional join columns based on the entity join configuration.
- Improved the SplitPanelLayoutConfigPanel to display entity join columns dynamically, enhancing user experience and functionality.
2026-02-10 10:51:23 +09:00
DDD1542 9e1a54c738 feat: Add savedIds to UPSERT response and update related components
- Enhanced the UPSERT process in DataService to include savedIds in the response, allowing tracking of newly saved record IDs.
- Updated the dataApi to reflect the new savedIds field in the Promise return type.
- Modified the SelectedItemsDetailInputComponent to handle and inject saved mapping IDs into detail records, improving data integrity and management during the save process.
- Added logging for savedIds to facilitate debugging and tracking of saved records.
2026-02-10 10:06:53 +09:00
DDD1542 45029bf5f4 feat: Enhance screen management with conditional layer and zone handling
- Updated the ScreenManagementService to allow general companies to query both their own zones and common zones.
- Improved the ScreenViewPage to include detailed logging for loaded conditional layers and zones.
- Added functionality to ignore empty targetComponentId in condition evaluations.
- Enhanced the EditModal and LayerManagerPanel to support loading conditional layers and dynamic options based on selected zones.
- Implemented additional tab configurations to manage entity join columns effectively in the SplitPanelLayout components.
2026-02-09 19:36:06 +09:00
kjs 30ee36f881 refactor: Update UserFormModal to remove department code requirement
- Modified the user form validation logic to make the department code optional in edit mode.
- Removed the department code from the required fields check and adjusted the UI label accordingly.
- Ensured that the form validation still checks for email format when provided.
2026-02-09 17:24:50 +09:00
kjs 2024299c02 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-09 17:16:34 +09:00
kjs c65f436009 feat: Enhance LayerManagerPanel with dynamic trigger options and improved condition handling
- Added a new Select component to allow users to choose condition values dynamically based on the selected zone's trigger component.
- Implemented logic to fetch trigger options from the base layer components, ensuring only relevant options are displayed.
- Updated the LayerManagerPanel to handle condition values more effectively, including the ability to set new condition values and manage used values.
- Refactored the ComponentsPanel to include the new V2 select component with appropriate configuration options.
- Improved the V2SelectConfigPanel to streamline option management and enhance user experience.
2026-02-09 17:13:26 +09:00
DDD1542 e29eaceeff Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node
; Conflicts:
;	frontend/components/screen/ScreenDesigner.tsx
2026-02-09 16:51:31 +09:00
DDD1542 1aacd829f2 123 2026-02-09 16:46:50 +09:00
kjs f8c0fe9499 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-09 16:03:27 +09:00
kjs 0ea5f3d5e4 Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-09 15:58:20 +09:00
DDD1542 7118a723f3 feat: Implement orphan record deletion logic based on edit mode
- Updated the DataService to conditionally delete orphan records only when in EDIT mode, controlled by the deleteOrphans flag.
- Enhanced the SelectedItemsDetailInputComponent to determine the mode (EDIT or CREATE) based on the presence of existing database IDs, ensuring that orphan records are only deleted when necessary.
- Improved data integrity by preventing unintended deletions during the CREATE process.
2026-02-09 15:50:53 +09:00
DDD1542 d7f900d8ae refactor: Remove debug logs and optimize toast animations
- Removed debug console logs from the UPSERT process in the DataService to clean up the code.
- Disabled animations for Sonner toast notifications to enhance performance and user experience.
- Simplified the alert and dialog components by removing unnecessary animation classes, ensuring a smoother transition.
- Updated the SelectedItemsDetailInputComponent to load all related table data in edit mode, improving data management and consistency.
2026-02-09 15:37:28 +09:00
kjs b4d216b7c8 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-09 15:07:50 +09:00
kjs a1c040ddf8 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-09 15:07:41 +09:00
DDD1542 423ef6231a Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node
; Conflicts:
;	frontend/components/screen/ScreenDesigner.tsx
2026-02-09 15:07:16 +09:00
DDD1542 2b035ce6e1 Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node 2026-02-09 15:03:29 +09:00
kjs 7dc0bbb329 feat: 조건부 레이어 관리 및 애니메이션 최적화
- 레이어 저장 로직을 개선하여 conditionConfig의 명시적 전달 여부에 따라 저장 방식을 다르게 처리하도록 변경했습니다.
- 조건부 레이어 로드 및 조건 평가 기능을 추가하여 레이어의 가시성을 동적으로 조정할 수 있도록 했습니다.
- 컴포넌트 위치 변경 시 모든 애니메이션을 제거하여 사용자 경험을 개선했습니다.
- LayerConditionPanel에서 조건 설정 시 기존 displayRegion을 보존하도록 업데이트했습니다.
- RealtimePreview 및 ScreenDesigner에서 조건부 레이어의 크기를 적절히 조정하도록 수정했습니다.
2026-02-09 15:02:53 +09:00
DDD1542 946ce1964d Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node
; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
2026-02-09 13:28:44 +09:00
kjs 64a775ce53 Merge pull request 'jskim-node' (#387) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/387
2026-02-09 13:28:07 +09:00
kjs 78f23ea0a9 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-09 13:27:59 +09:00
DDD1542 2e500f066f feat: Add close confirmation dialog to ScreenModal and enhance SelectedItemsDetailInputComponent
- Implemented a confirmation dialog in ScreenModal to prevent accidental closure, allowing users to confirm before exiting and potentially losing unsaved data.
- Enhanced SelectedItemsDetailInputComponent by ensuring that base records are created even when detail data is absent, maintaining item-client mapping.
- Improved logging for better traceability during the UPSERT process and refined the handling of parent data mappings for more robust data management.
2026-02-09 13:22:48 +09:00
kjs e653c7c472 Merge branch 'feature/v2-unified-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node 2026-02-09 13:21:57 +09:00
kjs 1c71b3aa83 feat: 멀티테넌시 지원을 위한 레이어 관리 기능 추가
- 레이어 목록 조회, 특정 레이어 레이아웃 조회, 레이어 삭제 및 조건 설정 업데이트 기능을 추가했습니다.
- 엔티티 참조 데이터 조회 및 공통 코드 데이터 조회에 멀티테넌시 필터를 적용하여 인증된 사용자의 회사 코드에 따라 데이터 접근을 제한했습니다.
- 레이어 관리 패널에서 기본 레이어와 조건부 레이어의 컴포넌트를 통합하여 조건부 영역의 표시를 개선했습니다.
- 레이아웃 저장 시 레이어 ID를 포함하여 레이어별로 저장할 수 있도록 변경했습니다.
2026-02-09 13:21:56 +09:00
DDD1542 bb4d90fd58 refactor: Improve label toggling functionality in ScreenDesigner and enhance SelectedItemsDetailInputComponent
- Updated the label toggling logic in ScreenDesigner to allow toggling of labels for selected components or all components based on the current selection.
- Enhanced the SelectedItemsDetailInputComponent by implementing a caching mechanism for table columns and refining the logic for loading category options based on field groups.
- Introduced a new helper function to convert category codes to labels, improving the clarity and maintainability of the price calculation logic.
- Added support for determining the source table for field groups, facilitating better data management and retrieval.
2026-02-09 10:07:07 +09:00
juseok2 c552f32370 Merge branch 'feature/v2-unified-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-unified-renewal 2026-02-08 14:42:34 +09:00
DDD1542 79d8f0b160 refactor: Update ComponentsPanel and SelectedItemsDetailInputComponent for improved functionality
- Updated ComponentsPanel to clarify the usage of the "selected-items-detail-input" component, indicating its application in the context of adding items for clients.
- Enhanced SelectedItemsDetailInputComponent by introducing independent editing states for group entries, allowing for better management of item edits within groups.
- Adjusted input field heights and styles for consistency and improved user experience.
- Added a new property `maxEntries` to the FieldGroup interface to support 1:1 relationships and automatic entry generation.
- Implemented overflow support for the component to handle cases with many items, ensuring a smoother user interface.
2026-02-07 17:45:44 +09:00
kjs 84eb035069 Merge branch 'feature/v2-unified-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-unified-renewal 2026-02-06 17:10:26 +09:00
kjs 9d368b1864 refactor: 카테고리 매핑 로직 개선 및 valueCode 추가
- NumberingRuleService에서 카테고리 매핑 로직을 개선하여, valueCode를 사용한 매핑을 추가했습니다.
- 카테고리 값 역변환 로직을 추가하여, category_values 테이블에서 valueCode를 통해 valueId를 조회할 수 있도록 하였습니다.
- AutoConfigPanel에서 categoryValueCode를 추가하여 V2Select와의 호환성을 높였습니다.
- numbering-rule.ts 타입 정의에 categoryValueCode를 추가하여, 카테고리 값 코드에 대한 매칭을 지원합니다.
2026-02-06 17:10:24 +09:00
DDD1542 08dde416b1 docs: Add detailed backend, database, and frontend architecture analysis documents
- Created a comprehensive analysis document for the backend architecture, detailing the directory structure, API routes, authentication workflows, and more.
- Added a database architecture analysis report outlining the database structure, multi-tenancy architecture, and key system tables.
- Introduced a frontend architecture analysis document that covers the directory structure, component systems, and Next.js App Router structure.

These documents aim to enhance the understanding of the WACE ERP system's architecture and facilitate better workflow documentation.
2026-02-06 16:00:43 +09:00
224 changed files with 48210 additions and 7530 deletions

17
.gitignore vendored
View File

@ -291,16 +291,11 @@ uploads/
claude.md
# AI 에이전트 테스트 산출물
*-test-screenshots/
*-screenshots/
*-test.mjs
# 개인 작업 문서 (popdocs)
popdocs/
.cursor/rules/popdocs-safety.mdc
# ============================================
# KSH 개인 오케스트레이션 설정 (팀 공유 안함)
# ============================================
.cursor/rules/orchestrator.mdc
.cursor/agents/
.cursor/commands/
.cursor/hooks.json
.cursor/hooks/
.cursor/plans/
.cursor/rules/popdocs-safety.mdc

View File

@ -18,6 +18,7 @@
"docx": "^9.5.1",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-async-errors": "^3.1.1",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"html-to-docx": "^1.8.0",
@ -1044,7 +1045,6 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@ -2372,7 +2372,6 @@
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"license": "MIT",
"peer": true,
"dependencies": {
"cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0",
@ -3476,7 +3475,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@ -3713,7 +3711,6 @@
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@ -3931,7 +3928,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -4458,7 +4454,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741",
@ -5669,7 +5664,6 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@ -5989,6 +5983,15 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-async-errors": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/express-async-errors/-/express-async-errors-3.1.1.tgz",
"integrity": "sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng==",
"license": "ISC",
"peerDependencies": {
"express": "^4.16.2"
}
},
"node_modules/express-rate-limit": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
@ -7432,7 +7435,6 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "^29.7.0",
"@jest/types": "^29.6.3",
@ -8402,6 +8404,7 @@
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
@ -9290,7 +9293,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
@ -10141,6 +10143,7 @@
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
}
@ -10949,7 +10952,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@ -11055,7 +11057,6 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

@ -32,6 +32,7 @@
"docx": "^9.5.1",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-async-errors": "^3.1.1",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"html-to-docx": "^1.8.0",

View File

@ -1,4 +1,5 @@
import "dotenv/config";
import "express-async-errors"; // async 라우트 핸들러의 에러를 Express 에러 핸들러로 자동 전달
import express from "express";
import cors from "cors";
import helmet from "helmet";
@ -104,6 +105,7 @@ import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRou
import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
@ -123,6 +125,7 @@ import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRou
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트)
import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@ -289,6 +292,7 @@ app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여
app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
app.use("/api/departments", departmentRoutes); // 부서 관리
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리
@ -305,6 +309,7 @@ app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트)
app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
// app.use("/api/collections", collectionRoutes); // 임시 주석

View File

@ -19,8 +19,6 @@ export async function getAdminMenus(
res: Response
): Promise<void> {
try {
logger.info("=== 관리자 메뉴 목록 조회 시작 ===");
// 현재 로그인한 사용자의 정보 가져오기
const userId = req.user?.userId;
const userCompanyCode = req.user?.companyCode || "ILSHIN";
@ -29,13 +27,6 @@ export async function getAdminMenus(
const menuType = req.query.menuType as string | undefined; // menuType 파라미터 추가
const includeInactive = req.query.includeInactive === "true"; // includeInactive 파라미터 추가
logger.info(`사용자 ID: ${userId}`);
logger.info(`사용자 회사 코드: ${userCompanyCode}`);
logger.info(`사용자 유형: ${userType}`);
logger.info(`사용자 로케일: ${userLang}`);
logger.info(`메뉴 타입: ${menuType || "전체"}`);
logger.info(`비활성 메뉴 포함: ${includeInactive}`);
const paramMap = {
userId,
userCompanyCode,
@ -47,13 +38,6 @@ export async function getAdminMenus(
const menuList = await AdminService.getAdminMenuList(paramMap);
logger.info(
`관리자 메뉴 조회 결과: ${menuList.length}개 (타입: ${menuType || "전체"}, 회사: ${userCompanyCode})`
);
if (menuList.length > 0) {
logger.info("첫 번째 메뉴:", menuList[0]);
}
const response: ApiResponse<any[]> = {
success: true,
message: "관리자 메뉴 목록 조회 성공",
@ -85,19 +69,12 @@ export async function getUserMenus(
res: Response
): Promise<void> {
try {
logger.info("=== 사용자 메뉴 목록 조회 시작 ===");
// 현재 로그인한 사용자의 정보 가져오기
const userId = req.user?.userId;
const userCompanyCode = req.user?.companyCode || "ILSHIN";
const userType = req.user?.userType;
const userLang = (req.query.userLang as string) || "ko";
logger.info(`사용자 ID: ${userId}`);
logger.info(`사용자 회사 코드: ${userCompanyCode}`);
logger.info(`사용자 유형: ${userType}`);
logger.info(`사용자 로케일: ${userLang}`);
const paramMap = {
userId,
userCompanyCode,
@ -107,13 +84,6 @@ export async function getUserMenus(
const menuList = await AdminService.getUserMenuList(paramMap);
logger.info(
`사용자 메뉴 조회 결과: ${menuList.length}개 (회사: ${userCompanyCode})`
);
if (menuList.length > 0) {
logger.info("첫 번째 메뉴:", menuList[0]);
}
const response: ApiResponse<any[]> = {
success: true,
message: "사용자 메뉴 목록 조회 성공",
@ -473,7 +443,7 @@ export const getUserLocale = async (
res: Response
): Promise<void> => {
try {
logger.info("사용자 로케일 조회 요청", {
logger.debug("사용자 로케일 조회 요청", {
query: req.query,
user: req.user,
});
@ -496,7 +466,7 @@ export const getUserLocale = async (
if (userInfo?.locale) {
userLocale = userInfo.locale;
logger.info("데이터베이스에서 사용자 로케일 조회 성공", {
logger.debug("데이터베이스에서 사용자 로케일 조회 성공", {
userId: req.user.userId,
locale: userLocale,
});
@ -513,7 +483,7 @@ export const getUserLocale = async (
message: "사용자 로케일 조회 성공",
};
logger.info("사용자 로케일 조회 성공", {
logger.debug("사용자 로케일 조회 성공", {
userLocale,
userId: req.user.userId,
fromDatabase: !!userInfo?.locale,
@ -618,7 +588,7 @@ export const getCompanyList = async (
res: Response
) => {
try {
logger.info("회사 목록 조회 요청", {
logger.debug("회사 목록 조회 요청", {
query: req.query,
user: req.user,
});
@ -658,12 +628,8 @@ export const getCompanyList = async (
message: "회사 목록 조회 성공",
};
logger.info("회사 목록 조회 성공", {
logger.debug("회사 목록 조회 성공", {
totalCount: companies.length,
companies: companies.map((c) => ({
code: c.company_code,
name: c.company_name,
})),
});
res.status(200).json(response);
@ -1443,13 +1409,7 @@ async function collectAllChildMenuIds(parentObjid: number): Promise<number[]> {
*
*/
async function cleanupMenuRelatedData(menuObjid: number): Promise<void> {
// 1. category_column_mapping에서 menu_objid를 NULL로 설정
await query(
`UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
// 2. code_category에서 menu_objid를 NULL로 설정
// 1. code_category에서 menu_objid를 NULL로 설정
await query(
`UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
@ -1870,7 +1830,7 @@ export async function getCompanyListFromDB(
res: Response
): Promise<void> {
try {
logger.info("회사 목록 조회 요청 (Raw Query)", { user: req.user });
logger.debug("회사 목록 조회 요청 (Raw Query)", { user: req.user });
// Raw Query로 회사 목록 조회
const companies = await query<any>(
@ -1890,7 +1850,7 @@ export async function getCompanyListFromDB(
ORDER BY regdate DESC`
);
logger.info("회사 목록 조회 성공 (Raw Query)", { count: companies.length });
logger.debug("회사 목록 조회 성공 (Raw Query)", { count: companies.length });
const response: ApiResponse<any> = {
success: true,

View File

@ -17,9 +17,7 @@ export class AuthController {
const { userId, password }: LoginRequest = req.body;
const remoteAddr = req.ip || req.connection.remoteAddress || "unknown";
logger.info(`=== API 로그인 호출됨 ===`);
logger.info(`userId: ${userId}`);
logger.info(`password: ${password ? "***" : "null"}`);
logger.debug(`로그인 요청: ${userId}`);
// 입력값 검증
if (!userId || !password) {
@ -50,14 +48,7 @@ export class AuthController {
companyCode: loginResult.userInfo.companyCode || "ILSHIN",
};
logger.info(`=== API 로그인 사용자 정보 디버그 ===`);
logger.info(
`PersonBean companyCode: ${loginResult.userInfo.companyCode}`
);
logger.info(`반환할 사용자 정보:`);
logger.info(`- userId: ${userInfo.userId}`);
logger.info(`- userName: ${userInfo.userName}`);
logger.info(`- companyCode: ${userInfo.companyCode}`);
logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`);
// 사용자의 첫 번째 접근 가능한 메뉴 조회
let firstMenuPath: string | null = null;
@ -71,7 +62,7 @@ export class AuthController {
};
const menuList = await AdminService.getUserMenuList(paramMap);
logger.info(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`);
logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`);
// 접근 가능한 첫 번째 메뉴 찾기
// 조건:
@ -87,16 +78,9 @@ export class AuthController {
if (firstMenu) {
firstMenuPath = firstMenu.menu_url || firstMenu.url;
logger.info(`✅ 첫 번째 접근 가능한 메뉴 발견:`, {
name: firstMenu.menu_name_kor || firstMenu.translated_name,
url: firstMenuPath,
level: firstMenu.lev || firstMenu.level,
seq: firstMenu.seq,
});
logger.debug(`첫 번째 메뉴: ${firstMenuPath}`);
} else {
logger.info(
"⚠️ 접근 가능한 메뉴가 없습니다. 메인 페이지로 이동합니다."
);
logger.debug("접근 가능한 메뉴 없음, 메인 페이지로 이동");
}
} catch (menuError) {
logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError);

View File

@ -0,0 +1,226 @@
/**
* BOM /
*/
import { Request, Response } from "express";
import { logger } from "../utils/logger";
import * as bomService from "../services/bomService";
// ─── 이력 (History) ─────────────────────────────
export async function getBomHistory(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const tableName = (req.query.tableName as string) || undefined;
const data = await bomService.getBomHistory(bomId, companyCode, tableName);
res.json({ success: true, data });
} catch (error: any) {
logger.error("BOM 이력 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function addBomHistory(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const changedBy = (req as any).user?.userName || (req as any).user?.userId || "";
const { change_type, change_description, revision, version, tableName } = req.body;
if (!change_type) {
res.status(400).json({ success: false, message: "change_type은 필수입니다" });
return;
}
const result = await bomService.addBomHistory(bomId, companyCode, {
change_type,
change_description,
revision,
version,
changed_by: changedBy,
}, tableName);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 이력 등록 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
// ─── BOM 헤더 조회 (entity join 포함) ─────────────────────────
export async function getBomHeader(req: Request, res: Response) {
try {
const { bomId } = req.params;
const tableName = (req.query.tableName as string) || undefined;
const data = await bomService.getBomHeader(bomId, tableName);
if (!data) {
res.status(404).json({ success: false, message: "BOM을 찾을 수 없습니다" });
return;
}
res.json({ success: true, data });
} catch (error: any) {
logger.error("BOM 헤더 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
// ─── 버전 (Version) ─────────────────────────────
export async function getBomVersions(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const tableName = (req.query.tableName as string) || undefined;
const result = await bomService.getBomVersions(bomId, companyCode, tableName);
res.json({
success: true,
data: result.versions,
currentVersionId: result.currentVersionId,
});
} catch (error: any) {
logger.error("BOM 버전 목록 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function createBomVersion(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const createdBy = (req as any).user?.userName || (req as any).user?.userId || "";
const { tableName, detailTable, versionName } = req.body || {};
const result = await bomService.createBomVersion(bomId, companyCode, createdBy, tableName, detailTable, versionName);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 버전 생성 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function loadBomVersion(req: Request, res: Response) {
try {
const { bomId, versionId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const { tableName, detailTable } = req.body || {};
const result = await bomService.loadBomVersion(bomId, versionId, companyCode, tableName, detailTable);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 버전 불러오기 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function activateBomVersion(req: Request, res: Response) {
try {
const { bomId, versionId } = req.params;
const { tableName } = req.body || {};
const result = await bomService.activateBomVersion(bomId, versionId, tableName);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 버전 사용 확정 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function initializeBomVersion(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const createdBy = (req as any).user?.userName || (req as any).user?.userId || "";
const result = await bomService.initializeBomVersion(bomId, companyCode, createdBy);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 초기 버전 생성 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
// ─── BOM 엑셀 업로드/다운로드 ─────────────────────────
export async function createBomFromExcel(req: Request, res: Response) {
try {
const companyCode = (req as any).user?.companyCode || "*";
const userId = (req as any).user?.userName || (req as any).user?.userId || "";
const { rows } = req.body;
if (!rows || !Array.isArray(rows) || rows.length === 0) {
res.status(400).json({ success: false, message: "업로드할 데이터가 없습니다" });
return;
}
const result = await bomService.createBomFromExcel(companyCode, userId, rows);
if (!result.success) {
res.status(400).json({ success: false, message: result.errors.join(", "), data: result });
return;
}
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 엑셀 업로드 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function createBomVersionFromExcel(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const userId = (req as any).user?.userName || (req as any).user?.userId || "";
const { rows, versionName } = req.body;
if (!rows || !Array.isArray(rows) || rows.length === 0) {
res.status(400).json({ success: false, message: "업로드할 데이터가 없습니다" });
return;
}
const result = await bomService.createBomVersionFromExcel(bomId, companyCode, userId, rows, versionName);
if (!result.success) {
res.status(400).json({ success: false, message: result.errors.join(", "), data: result });
return;
}
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 버전 엑셀 업로드 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function downloadBomExcelData(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const data = await bomService.downloadBomExcelData(bomId, companyCode);
res.json({ success: true, data });
} catch (error: any) {
logger.error("BOM 엑셀 다운로드 데이터 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteBomVersion(req: Request, res: Response) {
try {
const { bomId, versionId } = req.params;
const tableName = (req.query.tableName as string) || undefined;
const detailTable = (req.query.detailTable as string) || undefined;
const deleted = await bomService.deleteBomVersion(bomId, versionId, tableName, detailTable);
if (!deleted) {
res.status(404).json({ success: false, message: "버전을 찾을 수 없습니다" });
return;
}
res.json({ success: true });
} catch (error: any) {
logger.error("BOM 버전 삭제 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}

View File

@ -193,10 +193,11 @@ export class EntityJoinController {
async getEntityJoinConfigs(req: Request, res: Response): Promise<void> {
try {
const { tableName } = req.params;
const companyCode = (req as any).user?.companyCode;
logger.info(`Entity 조인 설정 조회: ${tableName}`);
logger.info(`Entity 조인 설정 조회: ${tableName} (companyCode: ${companyCode})`);
const joinConfigs = await entityJoinService.detectEntityJoins(tableName);
const joinConfigs = await entityJoinService.detectEntityJoins(tableName, undefined, companyCode);
res.status(200).json({
success: true,
@ -224,11 +225,12 @@ export class EntityJoinController {
async getReferenceTableColumns(req: Request, res: Response): Promise<void> {
try {
const { tableName } = req.params;
const companyCode = (req as any).user?.companyCode;
logger.info(`참조 테이블 컬럼 조회: ${tableName}`);
logger.info(`참조 테이블 컬럼 조회: ${tableName} (companyCode: ${companyCode})`);
const columns =
await tableManagementService.getReferenceTableColumns(tableName);
await tableManagementService.getReferenceTableColumns(tableName, companyCode);
res.status(200).json({
success: true,
@ -408,11 +410,12 @@ export class EntityJoinController {
async getEntityJoinColumns(req: Request, res: Response): Promise<void> {
try {
const { tableName } = req.params;
const companyCode = (req as any).user?.companyCode;
logger.info(`Entity 조인 컬럼 조회: ${tableName}`);
logger.info(`Entity 조인 컬럼 조회: ${tableName} (companyCode: ${companyCode})`);
// 1. 현재 테이블의 Entity 조인 설정 조회
const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName);
const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName, undefined, companyCode);
// 🆕 화면 디자이너용: table_column_category_values는 카테고리 드롭다운용이므로 제외
// 카테고리 값은 엔티티 조인 컬럼이 아니라 셀렉트박스 옵션으로 사용됨
@ -439,7 +442,7 @@ export class EntityJoinController {
try {
const columns =
await tableManagementService.getReferenceTableColumns(
config.referenceTable
config.referenceTable, companyCode
);
// 현재 display_column 정보 (참고용으로만 사용, 필터링하지 않음)

View File

@ -30,10 +30,13 @@ export class EntityReferenceController {
try {
const { tableName, columnName } = req.params;
const { limit = 100, search } = req.query;
// 멀티테넌시: 인증된 사용자의 회사 코드
const companyCode = (req as any).user?.companyCode;
logger.info(`엔티티 참조 데이터 조회 요청: ${tableName}.${columnName}`, {
limit,
search,
companyCode,
});
// 컬럼 정보 조회 (table_type_columns에서)
@ -89,16 +92,34 @@ export class EntityReferenceController {
});
}
// 동적 쿼리로 참조 데이터 조회
let sqlQuery = `SELECT ${referenceColumn}, ${displayColumn} as display_name FROM ${referenceTable}`;
// 참조 테이블에 company_code 컬럼이 있는지 확인
const hasCompanyCode = await queryOne<any>(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code' AND table_schema = 'public'`,
[referenceTable]
);
// 동적 쿼리로 참조 데이터 조회 (멀티테넌시 필터 적용)
const whereConditions: string[] = [];
const queryParams: any[] = [];
// 멀티테넌시: company_code 필터링 (참조 테이블에 company_code가 있는 경우)
if (hasCompanyCode && companyCode && companyCode !== "*") {
queryParams.push(companyCode);
whereConditions.push(`company_code = $${queryParams.length}`);
logger.info(`멀티테넌시 필터 적용: company_code = ${companyCode}`, { referenceTable });
}
// 검색 조건 추가
if (search) {
sqlQuery += ` WHERE ${displayColumn} ILIKE $1`;
queryParams.push(`%${search}%`);
whereConditions.push(`${displayColumn} ILIKE $${queryParams.length}`);
}
let sqlQuery = `SELECT ${referenceColumn}, ${displayColumn} as display_name FROM ${referenceTable}`;
if (whereConditions.length > 0) {
sqlQuery += ` WHERE ${whereConditions.join(" AND ")}`;
}
sqlQuery += ` ORDER BY ${displayColumn} LIMIT $${queryParams.length + 1}`;
queryParams.push(Number(limit));
@ -107,6 +128,7 @@ export class EntityReferenceController {
referenceTable,
referenceColumn,
displayColumn,
companyCode,
});
const referenceData = await query<any>(sqlQuery, queryParams);
@ -119,7 +141,7 @@ export class EntityReferenceController {
})
);
logger.info(`엔티티 참조 데이터 조회 완료: ${options.length}개 항목`);
logger.info(`엔티티 참조 데이터 조회 완료: ${options.length}개 항목`, { companyCode });
return res.json({
success: true,
@ -149,13 +171,16 @@ export class EntityReferenceController {
try {
const { codeCategory } = req.params;
const { limit = 100, search } = req.query;
// 멀티테넌시: 인증된 사용자의 회사 코드
const companyCode = (req as any).user?.companyCode;
logger.info(`공통 코드 데이터 조회 요청: ${codeCategory}`, {
limit,
search,
companyCode,
});
// code_info 테이블에서 코드 데이터 조회
// code_info 테이블에서 코드 데이터 조회 (멀티테넌시 필터 적용)
const queryParams: any[] = [codeCategory, 'Y'];
let sqlQuery = `
SELECT code_value, code_name
@ -163,9 +188,16 @@ export class EntityReferenceController {
WHERE code_category = $1 AND is_active = $2
`;
// 멀티테넌시: company_code 필터링
if (companyCode && companyCode !== "*") {
queryParams.push(companyCode);
sqlQuery += ` AND company_code = $${queryParams.length}`;
logger.info(`공통 코드 멀티테넌시 필터 적용: company_code = ${companyCode}`);
}
if (search) {
sqlQuery += ` AND code_name ILIKE $3`;
queryParams.push(`%${search}%`);
sqlQuery += ` AND code_name ILIKE $${queryParams.length}`;
}
sqlQuery += ` ORDER BY code_name ASC LIMIT $${queryParams.length + 1}`;
@ -174,12 +206,12 @@ export class EntityReferenceController {
const codeData = await query<any>(sqlQuery, queryParams);
// 옵션 형태로 변환
const options: EntityReferenceOption[] = codeData.map((code) => ({
const options: EntityReferenceOption[] = codeData.map((code: any) => ({
value: code.code_value,
label: code.code_name,
}));
logger.info(`공통 코드 데이터 조회 완료: ${options.length}개 항목`);
logger.info(`공통 코드 데이터 조회 완료: ${options.length}개 항목`, { companyCode });
return res.json({
success: true,

View File

@ -3,16 +3,115 @@ import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
/**
* WHERE절에
* filters JSON : [{ column, operator, value }]
*/
function applyFilters(
filtersJson: string | undefined,
existingColumns: Set<string>,
whereConditions: string[],
params: any[],
startParamIndex: number,
tableName: string,
): number {
let paramIndex = startParamIndex;
if (!filtersJson) return paramIndex;
let filters: Array<{ column: string; operator: string; value: unknown }>;
try {
filters = JSON.parse(filtersJson as string);
} catch {
logger.warn("filters JSON 파싱 실패", { tableName, filtersJson });
return paramIndex;
}
if (!Array.isArray(filters)) return paramIndex;
for (const filter of filters) {
const { column, operator = "=", value } = filter;
if (!column || !existingColumns.has(column)) {
logger.warn("필터 컬럼 미존재 제외", { tableName, column });
continue;
}
switch (operator) {
case "=":
whereConditions.push(`"${column}" = $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "!=":
whereConditions.push(`"${column}" != $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case ">":
case "<":
case ">=":
case "<=":
whereConditions.push(`"${column}" ${operator} $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "in": {
const inVals = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
if (inVals.length > 0) {
const ph = inVals.map((_, i) => `$${paramIndex + i}`).join(", ");
whereConditions.push(`"${column}" IN (${ph})`);
params.push(...inVals);
paramIndex += inVals.length;
}
break;
}
case "notIn": {
const notInVals = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
if (notInVals.length > 0) {
const ph = notInVals.map((_, i) => `$${paramIndex + i}`).join(", ");
whereConditions.push(`"${column}" NOT IN (${ph})`);
params.push(...notInVals);
paramIndex += notInVals.length;
}
break;
}
case "like":
whereConditions.push(`"${column}"::text ILIKE $${paramIndex}`);
params.push(`%${value}%`);
paramIndex++;
break;
case "isNull":
whereConditions.push(`"${column}" IS NULL`);
break;
case "isNotNull":
whereConditions.push(`"${column}" IS NOT NULL`);
break;
default:
whereConditions.push(`"${column}" = $${paramIndex}`);
params.push(value);
paramIndex++;
break;
}
}
return paramIndex;
}
/**
* DISTINCT API (inputType: select )
* GET /api/entity/:tableName/distinct/:columnName
*
* DISTINCT
*
* Query Params:
* - labelColumn: 별도의 ()
* - filters: JSON ()
* : [{"column":"status","operator":"=","value":"active"}]
*/
export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Response) {
try {
const { tableName, columnName } = req.params;
const { labelColumn } = req.query; // 선택적: 별도의 라벨 컬럼
const { labelColumn, filters: filtersParam } = req.query;
// 유효성 검증
if (!tableName || tableName === "undefined" || tableName === "null") {
@ -68,6 +167,16 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
whereConditions.push(`"${columnName}" IS NOT NULL`);
whereConditions.push(`"${columnName}" != ''`);
// 필터 조건 적용
paramIndex = applyFilters(
filtersParam as string | undefined,
existingColumns,
whereConditions,
params,
paramIndex,
tableName,
);
const whereClause = whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
@ -88,6 +197,7 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
columnName,
labelColumn: effectiveLabelColumn,
companyCode,
hasFilters: !!filtersParam,
rowCount: result.rowCount,
});
@ -111,11 +221,14 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
* Query Params:
* - value: (기본: id)
* - label: 표시 (기본: name)
* - fields: 추가 ( )
* - filters: JSON ()
* : [{"column":"status","operator":"=","value":"active"}]
*/
export async function getEntityOptions(req: AuthenticatedRequest, res: Response) {
try {
const { tableName } = req.params;
const { value = "id", label = "name" } = req.query;
const { value = "id", label = "name", fields, filters: filtersParam } = req.query;
// tableName 유효성 검증
if (!tableName || tableName === "undefined" || tableName === "null") {
@ -163,13 +276,35 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response)
paramIndex++;
}
// 필터 조건 적용
paramIndex = applyFilters(
filtersParam as string | undefined,
existingColumns,
whereConditions,
params,
paramIndex,
tableName,
);
const whereClause = whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// autoFill용 추가 컬럼 처리
let extraColumns = "";
if (fields && typeof fields === "string") {
const requestedFields = fields.split(",").map((f) => f.trim()).filter(Boolean);
const validExtra = requestedFields.filter(
(f) => existingColumns.has(f) && f !== valueColumn && f !== effectiveLabelColumn
);
if (validExtra.length > 0) {
extraColumns = ", " + validExtra.map((f) => `"${f}"`).join(", ");
}
}
// 쿼리 실행 (최대 500개)
const query = `
SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label
SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label${extraColumns}
FROM ${tableName}
${whereClause}
ORDER BY ${effectiveLabelColumn} ASC
@ -183,7 +318,9 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response)
valueColumn,
labelColumn: effectiveLabelColumn,
companyCode,
hasFilters: !!filtersParam,
rowCount: result.rowCount,
extraFields: extraColumns ? true : false,
});
res.json({
@ -395,11 +532,35 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// 정렬 컬럼 결정: id가 있으면 id, 없으면 첫 번째 컬럼 사용
let orderByColumn = "1"; // 기본: 첫 번째 컬럼
if (existingColumns.has("id")) {
orderByColumn = '"id"';
} else {
// PK 컬럼 조회 시도
try {
const pkResult = await pool.query(
`SELECT a.attname
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
WHERE i.indrelid = $1::regclass AND i.indisprimary
ORDER BY array_position(i.indkey, a.attnum)
LIMIT 1`,
[tableName]
);
if (pkResult.rows.length > 0) {
orderByColumn = `"${pkResult.rows[0].attname}"`;
}
} catch {
// PK 조회 실패 시 기본값 유지
}
}
// 쿼리 실행 (pool은 위에서 이미 선언됨)
const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`;
const dataQuery = `
SELECT * FROM ${tableName} ${whereClause}
ORDER BY id DESC
ORDER BY ${orderByColumn} DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;

View File

@ -46,17 +46,7 @@ export class FlowController {
const userId = (req as any).user?.userId || "system";
const userCompanyCode = (req as any).user?.companyCode;
console.log("🔍 createFlowDefinition called with:", {
name,
description,
tableName,
dbSourceType,
dbConnectionId,
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
userCompanyCode,
});
if (!name) {
res.status(400).json({
@ -121,13 +111,7 @@ export class FlowController {
const user = (req as any).user;
const userCompanyCode = user?.companyCode;
console.log("🎯 getFlowDefinitions called:", {
userId: user?.userId,
userCompanyCode: userCompanyCode,
userType: user?.userType,
tableName,
isActive,
});
const flows = await this.flowDefinitionService.findAll(
tableName as string | undefined,
@ -135,7 +119,7 @@ export class FlowController {
userCompanyCode
);
console.log(`✅ Returning ${flows.length} flows to user ${user?.userId}`);
res.json({
success: true,
@ -583,14 +567,11 @@ export class FlowController {
getStepColumnLabels = async (req: Request, res: Response): Promise<void> => {
try {
const { flowId, stepId } = req.params;
console.log("🏷️ [FlowController] 컬럼 라벨 조회 요청:", {
flowId,
stepId,
});
const step = await this.flowStepService.findById(parseInt(stepId));
if (!step) {
console.warn("⚠️ [FlowController] 스텝을 찾을 수 없음:", stepId);
res.status(404).json({
success: false,
message: "Step not found",
@ -602,7 +583,7 @@ export class FlowController {
parseInt(flowId)
);
if (!flowDef) {
console.warn("⚠️ [FlowController] 플로우를 찾을 수 없음:", flowId);
res.status(404).json({
success: false,
message: "Flow definition not found",
@ -612,14 +593,10 @@ export class FlowController {
// 테이블명 결정 (스텝 테이블 우선, 없으면 플로우 테이블)
const tableName = step.tableName || flowDef.tableName;
console.log("📋 [FlowController] 테이블명 결정:", {
stepTableName: step.tableName,
flowTableName: flowDef.tableName,
selectedTableName: tableName,
});
if (!tableName) {
console.warn("⚠️ [FlowController] 테이블명이 지정되지 않음");
res.json({
success: true,
data: {},
@ -639,14 +616,7 @@ export class FlowController {
[tableName]
);
console.log(`✅ [FlowController] table_type_columns 조회 완료:`, {
tableName,
rowCount: labelRows.length,
labels: labelRows.map((r) => ({
col: r.column_name,
label: r.column_label,
})),
});
// { columnName: label } 형태의 객체로 변환
const labels: Record<string, string> = {};
@ -656,7 +626,7 @@ export class FlowController {
}
});
console.log("📦 [FlowController] 반환할 라벨 객체:", labels);
res.json({
success: true,

View File

@ -0,0 +1,713 @@
/**
*
* /
*/
import { Response } from "express";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import { AuthenticatedRequest } from "../types/auth";
// ============================================================
// 품목/라우팅/공정 조회 (좌측 트리 데이터)
// ============================================================
/**
*
* 쿼리: tableName(), nameColumn, codeColumn
*/
export async function getItemsWithRouting(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const {
tableName = "item_info",
nameColumn = "item_name",
codeColumn = "item_number",
routingTable = "item_routing_version",
routingFkColumn = "item_code",
search = "",
} = req.query as Record<string, string>;
const searchCondition = search
? `AND (i.${nameColumn} ILIKE $2 OR i.${codeColumn} ILIKE $2)`
: "";
const params: any[] = [companyCode];
if (search) params.push(`%${search}%`);
const query = `
SELECT
i.id,
i.${nameColumn} AS item_name,
i.${codeColumn} AS item_code,
COUNT(rv.id) AS routing_count
FROM ${tableName} i
LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
AND rv.company_code = i.company_code
WHERE i.company_code = $1
${searchCondition}
GROUP BY i.id, i.${nameColumn}, i.${codeColumn}, i.created_date
ORDER BY i.created_date DESC NULLS LAST
`;
const result = await getPool().query(query, params);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("품목 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
* + ( )
*/
export async function getRoutingsWithProcesses(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { itemCode } = req.params;
const {
routingVersionTable = "item_routing_version",
routingDetailTable = "item_routing_detail",
routingFkColumn = "item_code",
processTable = "process_mng",
processNameColumn = "process_name",
processCodeColumn = "process_code",
} = req.query as Record<string, string>;
// 라우팅 버전 목록
const versionsQuery = `
SELECT id, version_name, description, created_date, COALESCE(is_default, false) AS is_default
FROM ${routingVersionTable}
WHERE ${routingFkColumn} = $1 AND company_code = $2
ORDER BY is_default DESC, created_date DESC
`;
const versionsResult = await getPool().query(versionsQuery, [
itemCode,
companyCode,
]);
// 각 버전별 공정 목록
const routings = [];
for (const version of versionsResult.rows) {
const detailsQuery = `
SELECT
rd.id AS routing_detail_id,
rd.seq_no,
rd.process_code,
rd.is_required,
rd.work_type,
p.${processNameColumn} AS process_name
FROM ${routingDetailTable} rd
LEFT JOIN ${processTable} p ON p.${processCodeColumn} = rd.process_code
AND p.company_code = rd.company_code
WHERE rd.routing_version_id = $1 AND rd.company_code = $2
ORDER BY rd.seq_no::integer
`;
const detailsResult = await getPool().query(detailsQuery, [
version.id,
companyCode,
]);
routings.push({
...version,
processes: detailsResult.rows,
});
}
return res.json({ success: true, data: routings });
} catch (error: any) {
logger.error("라우팅/공정 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ============================================================
// 기본 버전 설정
// ============================================================
/**
*
*
*/
export async function setDefaultVersion(req: AuthenticatedRequest, res: Response) {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { versionId } = req.params;
const {
routingVersionTable = "item_routing_version",
routingFkColumn = "item_code",
} = req.body;
await client.query("BEGIN");
const versionResult = await client.query(
`SELECT ${routingFkColumn} AS item_code FROM ${routingVersionTable} WHERE id = $1 AND company_code = $2`,
[versionId, companyCode]
);
if (versionResult.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(404).json({ success: false, message: "버전을 찾을 수 없습니다" });
}
const itemCode = versionResult.rows[0].item_code;
await client.query(
`UPDATE ${routingVersionTable} SET is_default = false WHERE ${routingFkColumn} = $1 AND company_code = $2`,
[itemCode, companyCode]
);
await client.query(
`UPDATE ${routingVersionTable} SET is_default = true WHERE id = $1 AND company_code = $2`,
[versionId, companyCode]
);
await client.query("COMMIT");
logger.info("기본 버전 설정", { companyCode, versionId, itemCode });
return res.json({ success: true, message: "기본 버전이 설정되었습니다" });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("기본 버전 설정 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
/**
*
*/
export async function unsetDefaultVersion(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { versionId } = req.params;
const { routingVersionTable = "item_routing_version" } = req.body;
await getPool().query(
`UPDATE ${routingVersionTable} SET is_default = false WHERE id = $1 AND company_code = $2`,
[versionId, companyCode]
);
logger.info("기본 버전 해제", { companyCode, versionId });
return res.json({ success: true, message: "기본 버전이 해제되었습니다" });
} catch (error: any) {
logger.error("기본 버전 해제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ============================================================
// 작업 항목 CRUD
// ============================================================
/**
* (phase별 )
*/
export async function getWorkItems(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { routingDetailId } = req.params;
const query = `
SELECT
wi.id,
wi.routing_detail_id,
wi.work_phase,
wi.title,
wi.is_required,
wi.sort_order,
wi.description,
wi.created_date,
(SELECT COUNT(*) FROM process_work_item_detail d
WHERE d.work_item_id = wi.id AND d.company_code = wi.company_code
)::integer AS detail_count
FROM process_work_item wi
WHERE wi.routing_detail_id = $1 AND wi.company_code = $2
ORDER BY wi.work_phase, wi.sort_order, wi.created_date
`;
const result = await getPool().query(query, [routingDetailId, companyCode]);
// phase별 그룹핑
const grouped: Record<string, any[]> = {};
for (const row of result.rows) {
const phase = row.work_phase;
if (!grouped[phase]) grouped[phase] = [];
grouped[phase].push(row);
}
return res.json({ success: true, data: grouped, items: result.rows });
} catch (error: any) {
logger.error("작업 항목 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
*
*/
export async function createWorkItem(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
const writer = req.user?.userId;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { routing_detail_id, work_phase, title, is_required, sort_order, description } = req.body;
if (!routing_detail_id || !work_phase || !title) {
return res.status(400).json({
success: false,
message: "routing_detail_id, work_phase, title은 필수입니다",
});
}
const query = `
INSERT INTO process_work_item
(company_code, routing_detail_id, work_phase, title, is_required, sort_order, description, writer)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
`;
const result = await getPool().query(query, [
companyCode,
routing_detail_id,
work_phase,
title,
is_required || "N",
sort_order || 0,
description || null,
writer,
]);
logger.info("작업 항목 생성", { companyCode, id: result.rows[0].id });
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("작업 항목 생성 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
*
*/
export async function updateWorkItem(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { id } = req.params;
const { title, is_required, sort_order, description } = req.body;
const query = `
UPDATE process_work_item
SET title = COALESCE($1, title),
is_required = COALESCE($2, is_required),
sort_order = COALESCE($3, sort_order),
description = COALESCE($4, description),
updated_date = NOW()
WHERE id = $5 AND company_code = $6
RETURNING *
`;
const result = await getPool().query(query, [
title,
is_required,
sort_order,
description,
id,
companyCode,
]);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "항목을 찾을 수 없습니다" });
}
logger.info("작업 항목 수정", { companyCode, id });
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("작업 항목 수정 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
* ( )
*/
export async function deleteWorkItem(req: AuthenticatedRequest, res: Response) {
const client = await getPool().connect();
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { id } = req.params;
await client.query("BEGIN");
// 상세 먼저 삭제
await client.query(
"DELETE FROM process_work_item_detail WHERE work_item_id = $1 AND company_code = $2",
[id, companyCode]
);
// 항목 삭제
const result = await client.query(
"DELETE FROM process_work_item WHERE id = $1 AND company_code = $2 RETURNING id",
[id, companyCode]
);
if (result.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(404).json({ success: false, message: "항목을 찾을 수 없습니다" });
}
await client.query("COMMIT");
logger.info("작업 항목 삭제", { companyCode, id });
return res.json({ success: true });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("작업 항목 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
// ============================================================
// 작업 항목 상세 CRUD
// ============================================================
/**
*
*/
export async function getWorkItemDetails(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { workItemId } = req.params;
const query = `
SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields,
created_date
FROM process_work_item_detail
WHERE work_item_id = $1 AND company_code = $2
ORDER BY sort_order, created_date
`;
const result = await getPool().query(query, [workItemId, companyCode]);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("작업 항목 상세 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
*
*/
export async function createWorkItemDetail(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
const writer = req.user?.userId;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const {
work_item_id, detail_type, content, is_required, sort_order, remark,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields,
} = req.body;
if (!work_item_id || !content) {
return res.status(400).json({
success: false,
message: "work_item_id, content는 필수입니다",
});
}
// work_item이 같은 company_code인지 검증
const ownerCheck = await getPool().query(
"SELECT id FROM process_work_item WHERE id = $1 AND company_code = $2",
[work_item_id, companyCode]
);
if (ownerCheck.rowCount === 0) {
return res.status(403).json({ success: false, message: "권한이 없습니다" });
}
const query = `
INSERT INTO process_work_item_detail
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
RETURNING *
`;
const result = await getPool().query(query, [
companyCode,
work_item_id,
detail_type || null,
content,
is_required || "N",
sort_order || 0,
remark || null,
writer,
inspection_code || null,
inspection_method || null,
unit || null,
lower_limit || null,
upper_limit || null,
duration_minutes || null,
input_type || null,
lookup_target || null,
display_fields || null,
]);
logger.info("작업 항목 상세 생성", { companyCode, id: result.rows[0].id });
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("작업 항목 상세 생성 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
*
*/
export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { id } = req.params;
const {
detail_type, content, is_required, sort_order, remark,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields,
} = req.body;
const query = `
UPDATE process_work_item_detail
SET detail_type = COALESCE($1, detail_type),
content = COALESCE($2, content),
is_required = COALESCE($3, is_required),
sort_order = COALESCE($4, sort_order),
remark = COALESCE($5, remark),
inspection_code = $8,
inspection_method = $9,
unit = $10,
lower_limit = $11,
upper_limit = $12,
duration_minutes = $13,
input_type = $14,
lookup_target = $15,
display_fields = $16,
updated_date = NOW()
WHERE id = $6 AND company_code = $7
RETURNING *
`;
const result = await getPool().query(query, [
detail_type,
content,
is_required,
sort_order,
remark,
id,
companyCode,
inspection_code || null,
inspection_method || null,
unit || null,
lower_limit || null,
upper_limit || null,
duration_minutes || null,
input_type || null,
lookup_target || null,
display_fields || null,
]);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "상세를 찾을 수 없습니다" });
}
logger.info("작업 항목 상세 수정", { companyCode, id });
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("작업 항목 상세 수정 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
*
*/
export async function deleteWorkItemDetail(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { id } = req.params;
const result = await getPool().query(
"DELETE FROM process_work_item_detail WHERE id = $1 AND company_code = $2 RETURNING id",
[id, companyCode]
);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "상세를 찾을 수 없습니다" });
}
logger.info("작업 항목 상세 삭제", { companyCode, id });
return res.json({ success: true });
} catch (error: any) {
logger.error("작업 항목 상세 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ============================================================
// 전체 저장 (일괄)
// ============================================================
/**
* 저장: 작업 +
* replace
*/
export async function saveAll(req: AuthenticatedRequest, res: Response) {
const client = await getPool().connect();
try {
const companyCode = req.user?.companyCode;
const writer = req.user?.userId;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { routing_detail_id, items } = req.body;
if (!routing_detail_id || !Array.isArray(items)) {
return res.status(400).json({
success: false,
message: "routing_detail_id와 items 배열이 필요합니다",
});
}
await client.query("BEGIN");
// 기존 상세 삭제
await client.query(
`DELETE FROM process_work_item_detail
WHERE work_item_id IN (
SELECT id FROM process_work_item
WHERE routing_detail_id = $1 AND company_code = $2
)`,
[routing_detail_id, companyCode]
);
// 기존 항목 삭제
await client.query(
"DELETE FROM process_work_item WHERE routing_detail_id = $1 AND company_code = $2",
[routing_detail_id, companyCode]
);
// 새 항목 + 상세 삽입
for (const item of items) {
const itemResult = await client.query(
`INSERT INTO process_work_item
(company_code, routing_detail_id, work_phase, title, is_required, sort_order, description, writer)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id`,
[
companyCode,
routing_detail_id,
item.work_phase,
item.title,
item.is_required || "N",
item.sort_order || 0,
item.description || null,
writer,
]
);
const workItemId = itemResult.rows[0].id;
if (Array.isArray(item.details)) {
for (const detail of item.details) {
await client.query(
`INSERT INTO process_work_item_detail
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
[
companyCode,
workItemId,
detail.detail_type || null,
detail.content,
detail.is_required || "N",
detail.sort_order || 0,
detail.remark || null,
writer,
detail.inspection_code || null,
detail.inspection_method || null,
detail.unit || null,
detail.lower_limit || null,
detail.upper_limit || null,
detail.duration_minutes || null,
detail.input_type || null,
detail.lookup_target || null,
detail.display_fields || null,
]
);
}
}
}
await client.query("COMMIT");
logger.info("작업기준 전체 저장", { companyCode, routing_detail_id, itemCount: items.length });
return res.json({ success: true, message: "저장 완료" });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("작업기준 전체 저장 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}

View File

@ -1493,13 +1493,13 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
SELECT
sd.screen_id,
sd.screen_name,
sd.table_name as main_table,
jsonb_array_elements_text(
sd.table_name::text as main_table,
jsonb_array_elements(
COALESCE(
sl.properties->'componentConfig'->'columns',
'[]'::jsonb
)
)::jsonb->>'columnName' as column_name
)->>'columnName' as column_name
FROM screen_definitions sd
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
WHERE sd.screen_id = ANY($1)
@ -1512,7 +1512,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
SELECT
sd.screen_id,
sd.screen_name,
sd.table_name as main_table,
sd.table_name::text as main_table,
COALESCE(
sl.properties->'componentConfig'->>'bindField',
sl.properties->>'bindField',
@ -1535,7 +1535,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
SELECT
sd.screen_id,
sd.screen_name,
sd.table_name as main_table,
sd.table_name::text as main_table,
sl.properties->'componentConfig'->>'valueField' as column_name
FROM screen_definitions sd
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
@ -1548,7 +1548,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
SELECT
sd.screen_id,
sd.screen_name,
sd.table_name as main_table,
sd.table_name::text as main_table,
sl.properties->'componentConfig'->>'parentFieldId' as column_name
FROM screen_definitions sd
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
@ -1561,7 +1561,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
SELECT
sd.screen_id,
sd.screen_name,
sd.table_name as main_table,
sd.table_name::text as main_table,
sl.properties->'componentConfig'->>'cascadingParentField' as column_name
FROM screen_definitions sd
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
@ -1574,7 +1574,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
SELECT
sd.screen_id,
sd.screen_name,
sd.table_name as main_table,
sd.table_name::text as main_table,
sl.properties->'componentConfig'->>'controlField' as column_name
FROM screen_definitions sd
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
@ -1755,7 +1755,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
sd.table_name as main_table,
sl.properties->>'componentType' as component_type,
sl.properties->'componentConfig'->'rightPanel'->'relation' as right_panel_relation,
sl.properties->'componentConfig'->'rightPanel'->'tableName' as right_panel_table,
sl.properties->'componentConfig'->'rightPanel'->>'tableName' as right_panel_table,
sl.properties->'componentConfig'->'rightPanel'->'columns' as right_panel_columns
FROM screen_definitions sd
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id

View File

@ -733,6 +733,133 @@ export const saveLayoutV2 = async (req: AuthenticatedRequest, res: Response) =>
}
};
// 레이어 목록 조회
export const getScreenLayers = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId } = req.params;
const { companyCode } = req.user as any;
const layers = await screenManagementService.getScreenLayers(parseInt(screenId), companyCode);
res.json({ success: true, data: layers });
} catch (error) {
console.error("레이어 목록 조회 실패:", error);
res.status(500).json({ success: false, message: "레이어 목록 조회에 실패했습니다." });
}
};
// 특정 레이어 레이아웃 조회
export const getLayerLayout = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId, layerId } = req.params;
const { companyCode } = req.user as any;
const layout = await screenManagementService.getLayerLayout(parseInt(screenId), parseInt(layerId), companyCode);
res.json({ success: true, data: layout });
} catch (error) {
console.error("레이어 레이아웃 조회 실패:", error);
res.status(500).json({ success: false, message: "레이어 레이아웃 조회에 실패했습니다." });
}
};
// 레이어 삭제
export const deleteLayer = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId, layerId } = req.params;
const { companyCode } = req.user as any;
await screenManagementService.deleteLayer(parseInt(screenId), parseInt(layerId), companyCode);
res.json({ success: true, message: "레이어가 삭제되었습니다." });
} catch (error: any) {
console.error("레이어 삭제 실패:", error);
res.status(400).json({ success: false, message: error.message || "레이어 삭제에 실패했습니다." });
}
};
// 레이어 조건 설정 업데이트
export const updateLayerCondition = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId, layerId } = req.params;
const { companyCode } = req.user as any;
const { conditionConfig, layerName } = req.body;
await screenManagementService.updateLayerCondition(
parseInt(screenId), parseInt(layerId), companyCode, conditionConfig, layerName
);
res.json({ success: true, message: "레이어 조건이 업데이트되었습니다." });
} catch (error) {
console.error("레이어 조건 업데이트 실패:", error);
res.status(500).json({ success: false, message: "레이어 조건 업데이트에 실패했습니다." });
}
};
// ========================================
// 조건부 영역(Zone) 관리
// ========================================
// Zone 목록 조회
export const getScreenZones = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId } = req.params;
const { companyCode } = req.user as any;
const zones = await screenManagementService.getScreenZones(parseInt(screenId), companyCode);
res.json({ success: true, data: zones });
} catch (error) {
console.error("Zone 목록 조회 실패:", error);
res.status(500).json({ success: false, message: "Zone 목록 조회에 실패했습니다." });
}
};
// Zone 생성
export const createZone = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId } = req.params;
const { companyCode } = req.user as any;
const zone = await screenManagementService.createZone(parseInt(screenId), companyCode, req.body);
res.json({ success: true, data: zone });
} catch (error) {
console.error("Zone 생성 실패:", error);
res.status(500).json({ success: false, message: "Zone 생성에 실패했습니다." });
}
};
// Zone 업데이트 (위치/크기/트리거)
export const updateZone = async (req: AuthenticatedRequest, res: Response) => {
try {
const { zoneId } = req.params;
const { companyCode } = req.user as any;
await screenManagementService.updateZone(parseInt(zoneId), companyCode, req.body);
res.json({ success: true, message: "Zone이 업데이트되었습니다." });
} catch (error) {
console.error("Zone 업데이트 실패:", error);
res.status(500).json({ success: false, message: "Zone 업데이트에 실패했습니다." });
}
};
// Zone 삭제
export const deleteZone = async (req: AuthenticatedRequest, res: Response) => {
try {
const { zoneId } = req.params;
const { companyCode } = req.user as any;
await screenManagementService.deleteZone(parseInt(zoneId), companyCode);
res.json({ success: true, message: "Zone이 삭제되었습니다." });
} catch (error) {
console.error("Zone 삭제 실패:", error);
res.status(500).json({ success: false, message: "Zone 삭제에 실패했습니다." });
}
};
// Zone에 레이어 추가
export const addLayerToZone = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId, zoneId } = req.params;
const { companyCode } = req.user as any;
const { conditionValue, layerName } = req.body;
const result = await screenManagementService.addLayerToZone(
parseInt(screenId), companyCode, parseInt(zoneId), conditionValue, layerName
);
res.json({ success: true, data: result });
} catch (error) {
console.error("Zone 레이어 추가 실패:", error);
res.status(500).json({ success: false, message: "Zone에 레이어를 추가하지 못했습니다." });
}
};
// ========================================
// POP 레이아웃 관리 (모바일/태블릿)
// ========================================

View File

@ -921,14 +921,51 @@ export async function addTableData(
}
}
// 회사별 NOT NULL 소프트 제약조건 검증
const notNullViolations = await tableManagementService.validateNotNullConstraints(
tableName,
data,
companyCode || "*"
);
if (notNullViolations.length > 0) {
res.status(400).json({
success: false,
message: `필수 항목이 비어있습니다: ${notNullViolations.join(", ")}`,
error: {
code: "NOT_NULL_VIOLATION",
details: notNullViolations,
},
});
return;
}
// 회사별 UNIQUE 소프트 제약조건 검증
const uniqueViolations = await tableManagementService.validateUniqueConstraints(
tableName,
data,
companyCode || "*"
);
if (uniqueViolations.length > 0) {
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
error: {
code: "UNIQUE_VIOLATION",
details: uniqueViolations,
},
});
return;
}
// 데이터 추가
await tableManagementService.addTableData(tableName, data);
const result = await tableManagementService.addTableData(tableName, data);
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`);
const response: ApiResponse<null> = {
const response: ApiResponse<{ id: string | null }> = {
success: true,
message: "테이블 데이터를 성공적으로 추가했습니다.",
data: { id: result.insertedId },
};
res.status(201).json(response);
@ -1003,6 +1040,45 @@ export async function editTableData(
}
const tableManagementService = new TableManagementService();
const companyCode = req.user?.companyCode || "*";
// 회사별 NOT NULL 소프트 제약조건 검증 (수정 데이터 대상)
const notNullViolations = await tableManagementService.validateNotNullConstraints(
tableName,
updatedData,
companyCode
);
if (notNullViolations.length > 0) {
res.status(400).json({
success: false,
message: `필수 항목이 비어있습니다: ${notNullViolations.join(", ")}`,
error: {
code: "NOT_NULL_VIOLATION",
details: notNullViolations,
},
});
return;
}
// 회사별 UNIQUE 소프트 제약조건 검증 (수정 시 자기 자신 제외)
const excludeId = originalData?.id ? String(originalData.id) : undefined;
const uniqueViolations = await tableManagementService.validateUniqueConstraints(
tableName,
updatedData,
companyCode,
excludeId
);
if (uniqueViolations.length > 0) {
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
error: {
code: "UNIQUE_VIOLATION",
details: uniqueViolations,
},
});
return;
}
// 데이터 수정
await tableManagementService.editTableData(
@ -1693,6 +1769,7 @@ export async function getCategoryColumnsByCompany(
let columnsResult;
// 최고 관리자인 경우 company_code = '*'인 카테고리 컬럼 조회
// category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함)
if (companyCode === "*") {
const columnsQuery = `
SELECT DISTINCT
@ -1712,15 +1789,15 @@ export async function getCategoryColumnsByCompany(
ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category'
AND ttc.company_code = '*'
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
ORDER BY ttc.table_name, ttc.column_name
`;
columnsResult = await pool.query(columnsQuery);
logger.info("최고 관리자: 전체 카테고리 컬럼 조회 완료", {
logger.info("최고 관리자: 전체 카테고리 컬럼 조회 완료 (참조 제외)", {
rowCount: columnsResult.rows.length
});
} else {
// 일반 회사: 해당 회사의 카테고리 컬럼만 조회
const columnsQuery = `
SELECT DISTINCT
ttc.table_name AS "tableName",
@ -1739,11 +1816,12 @@ export async function getCategoryColumnsByCompany(
ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category'
AND ttc.company_code = $1
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
ORDER BY ttc.table_name, ttc.column_name
`;
columnsResult = await pool.query(columnsQuery, [companyCode]);
logger.info("회사별 카테고리 컬럼 조회 완료", {
logger.info("회사별 카테고리 컬럼 조회 완료 (참조 제외)", {
companyCode,
rowCount: columnsResult.rows.length
});
@ -1804,13 +1882,10 @@ export async function getCategoryColumnsByMenu(
const { getPool } = await import("../database/db");
const pool = getPool();
// 🆕 table_type_columns에서 직접 input_type = 'category'인 컬럼들을 조회
// category_column_mapping 대신 table_type_columns 기준으로 조회
logger.info("🔍 table_type_columns 기반 카테고리 컬럼 조회", { menuObjid, companyCode });
// table_type_columns에서 input_type = 'category' 컬럼 조회
// category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함)
let columnsResult;
// 최고 관리자인 경우 모든 회사의 카테고리 컬럼 조회
if (companyCode === "*") {
const columnsQuery = `
SELECT DISTINCT
@ -1830,15 +1905,15 @@ export async function getCategoryColumnsByMenu(
ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category'
AND ttc.company_code = '*'
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
ORDER BY ttc.table_name, ttc.column_name
`;
columnsResult = await pool.query(columnsQuery);
logger.info("✅ 최고 관리자: 전체 카테고리 컬럼 조회 완료", {
logger.info("최고 관리자: 메뉴별 카테고리 컬럼 조회 완료 (참조 제외)", {
rowCount: columnsResult.rows.length
});
} else {
// 일반 회사: 해당 회사의 카테고리 컬럼만 조회
const columnsQuery = `
SELECT DISTINCT
ttc.table_name AS "tableName",
@ -1857,11 +1932,12 @@ export async function getCategoryColumnsByMenu(
ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category'
AND ttc.company_code = $1
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
ORDER BY ttc.table_name, ttc.column_name
`;
columnsResult = await pool.query(columnsQuery, [companyCode]);
logger.info("회사별 카테고리 컬럼 조회 완료", {
logger.info("회사별 메뉴 카테고리 컬럼 조회 완료 (참조 제외)", {
companyCode,
rowCount: columnsResult.rows.length
});
@ -2447,3 +2523,425 @@ export async function getReferencedByTables(
res.status(500).json(response);
}
}
// ========================================
// PK / 인덱스 관리 API
// ========================================
/**
* PK/
* GET /api/table-management/tables/:tableName/constraints
*/
export async function getTableConstraints(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
if (!tableName) {
res.status(400).json({ success: false, message: "테이블명이 필요합니다." });
return;
}
// PK 조회
const pkResult = await query<any>(
`SELECT tc.conname AS constraint_name,
array_agg(a.attname ORDER BY x.n) AS columns
FROM pg_constraint tc
JOIN pg_class c ON tc.conrelid = c.oid
JOIN pg_namespace ns ON c.relnamespace = ns.oid
CROSS JOIN LATERAL unnest(tc.conkey) WITH ORDINALITY AS x(attnum, n)
JOIN pg_attribute a ON a.attrelid = tc.conrelid AND a.attnum = x.attnum
WHERE ns.nspname = 'public' AND c.relname = $1 AND tc.contype = 'p'
GROUP BY tc.conname`,
[tableName]
);
// array_agg 결과가 문자열로 올 수 있으므로 안전하게 배열로 변환
const parseColumns = (cols: any): string[] => {
if (Array.isArray(cols)) return cols;
if (typeof cols === "string") {
// PostgreSQL 배열 형식: {col1,col2}
return cols.replace(/[{}]/g, "").split(",").filter(Boolean);
}
return [];
};
const primaryKey = pkResult.length > 0
? { name: pkResult[0].constraint_name, columns: parseColumns(pkResult[0].columns) }
: { name: "", columns: [] };
// 인덱스 조회 (PK 인덱스 제외)
const indexResult = await query<any>(
`SELECT i.relname AS index_name,
ix.indisunique AS is_unique,
array_agg(a.attname ORDER BY x.n) AS columns
FROM pg_index ix
JOIN pg_class t ON ix.indrelid = t.oid
JOIN pg_class i ON ix.indexrelid = i.oid
JOIN pg_namespace ns ON t.relnamespace = ns.oid
CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS x(attnum, n)
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum
WHERE ns.nspname = 'public' AND t.relname = $1
AND ix.indisprimary = false
GROUP BY i.relname, ix.indisunique
ORDER BY i.relname`,
[tableName]
);
const indexes = indexResult.map((row: any) => ({
name: row.index_name,
columns: parseColumns(row.columns),
isUnique: row.is_unique,
}));
logger.info(`제약조건 조회: ${tableName} - PK: ${primaryKey.columns.join(",")}, 인덱스: ${indexes.length}`);
res.status(200).json({
success: true,
data: { primaryKey, indexes },
});
} catch (error) {
logger.error("제약조건 조회 오류:", error);
res.status(500).json({
success: false,
message: "제약조건 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* PK
* PUT /api/table-management/tables/:tableName/primary-key
*/
export async function setTablePrimaryKey(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const { columns } = req.body;
if (!tableName || !columns || !Array.isArray(columns) || columns.length === 0) {
res.status(400).json({ success: false, message: "테이블명과 PK 컬럼 배열이 필요합니다." });
return;
}
logger.info(`PK 설정: ${tableName} → [${columns.join(", ")}]`);
// 기존 PK 제약조건 이름 조회
const existingPk = await query<any>(
`SELECT conname FROM pg_constraint tc
JOIN pg_class c ON tc.conrelid = c.oid
JOIN pg_namespace ns ON c.relnamespace = ns.oid
WHERE ns.nspname = 'public' AND c.relname = $1 AND tc.contype = 'p'`,
[tableName]
);
// 기존 PK 삭제
if (existingPk.length > 0) {
const dropSql = `ALTER TABLE "public"."${tableName}" DROP CONSTRAINT "${existingPk[0].conname}"`;
logger.info(`기존 PK 삭제: ${dropSql}`);
await query(dropSql);
}
// 새 PK 추가
const colList = columns.map((c: string) => `"${c}"`).join(", ");
const addSql = `ALTER TABLE "public"."${tableName}" ADD PRIMARY KEY (${colList})`;
logger.info(`새 PK 추가: ${addSql}`);
await query(addSql);
res.status(200).json({
success: true,
message: `PK가 설정되었습니다: ${columns.join(", ")}`,
});
} catch (error) {
logger.error("PK 설정 오류:", error);
res.status(500).json({
success: false,
message: "PK 설정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* (/)
* POST /api/table-management/tables/:tableName/indexes
*/
export async function toggleTableIndex(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const { columnName, indexType, action } = req.body;
if (!tableName || !columnName || !indexType || !action) {
res.status(400).json({
success: false,
message: "tableName, columnName, indexType(index|unique), action(create|drop)이 필요합니다.",
});
return;
}
const indexName = `idx_${tableName}_${columnName}${indexType === "unique" ? "_uq" : ""}`;
logger.info(`인덱스 ${action}: ${indexName} (${indexType})`);
if (action === "create") {
let indexColumns = `"${columnName}"`;
// 유니크 인덱스: company_code 컬럼이 있으면 복합 유니크 (회사별 유니크 보장)
if (indexType === "unique") {
const hasCompanyCode = await query(
`SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 AND column_name = 'company_code'`,
[tableName]
);
if (hasCompanyCode.length > 0) {
indexColumns = `"company_code", "${columnName}"`;
logger.info(`멀티테넌시: company_code + ${columnName} 복합 유니크 인덱스 생성`);
}
}
const uniqueClause = indexType === "unique" ? "UNIQUE " : "";
const sql = `CREATE ${uniqueClause}INDEX IF NOT EXISTS "${indexName}" ON "public"."${tableName}" (${indexColumns})`;
logger.info(`인덱스 생성: ${sql}`);
await query(sql);
} else if (action === "drop") {
const sql = `DROP INDEX IF EXISTS "public"."${indexName}"`;
logger.info(`인덱스 삭제: ${sql}`);
await query(sql);
} else {
res.status(400).json({ success: false, message: "action은 create 또는 drop이어야 합니다." });
return;
}
res.status(200).json({
success: true,
message: action === "create"
? `인덱스가 생성되었습니다: ${indexName}`
: `인덱스가 삭제되었습니다: ${indexName}`,
});
} catch (error: any) {
logger.error("인덱스 토글 오류:", error);
const errMsg = error.message || "";
let userMessage = "인덱스 설정 중 오류가 발생했습니다.";
let duplicates: any[] = [];
// 중복 데이터로 인한 UNIQUE 인덱스 생성 실패
if (
errMsg.includes("could not create unique index") ||
errMsg.includes("duplicate key")
) {
const { columnName, tableName } = { ...req.params, ...req.body };
try {
duplicates = await query(
`SELECT company_code, "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY company_code, "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10`
);
} catch {
try {
duplicates = await query(
`SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10`
);
} catch { /* 중복 조회 실패 시 무시 */ }
}
const dupDetails = duplicates.length > 0
? duplicates.map((d: any) => {
const company = d.company_code ? `[${d.company_code}] ` : "";
return `${company}"${d[columnName] ?? 'NULL'}" (${d.cnt}건)`;
}).join(", ")
: "";
userMessage = dupDetails
? `[${columnName}] 컬럼에 같은 회사 내 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 값: ${dupDetails}`
: `[${columnName}] 컬럼에 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요.`;
}
res.status(500).json({
success: false,
message: userMessage,
error: errMsg,
duplicates,
});
}
}
/**
* NOT NULL ( )
* PUT /api/table-management/tables/:tableName/columns/:columnName/nullable
*
* DB ALTER TABLE table_type_columns.is_nullable을 .
* A는 NOT NULL, B는 NULL .
*/
export async function toggleColumnNullable(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, columnName } = req.params;
const { nullable } = req.body;
const companyCode = req.user?.companyCode || "*";
if (!tableName || !columnName || typeof nullable !== "boolean") {
res.status(400).json({
success: false,
message: "tableName, columnName, nullable(boolean)이 필요합니다.",
});
return;
}
// is_nullable 값: 'Y' = NULL 허용, 'N' = NOT NULL
const isNullableValue = nullable ? "Y" : "N";
if (!nullable) {
// NOT NULL 설정 전 - 해당 회사의 기존 데이터에 NULL이 있는지 확인
const hasCompanyCode = await query<{ column_name: string }>(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[tableName]
);
if (hasCompanyCode.length > 0) {
const nullCheckQuery = companyCode === "*"
? `SELECT COUNT(*) as null_count FROM "${tableName}" WHERE "${columnName}" IS NULL`
: `SELECT COUNT(*) as null_count FROM "${tableName}" WHERE "${columnName}" IS NULL AND company_code = $1`;
const nullCheckParams = companyCode === "*" ? [] : [companyCode];
const nullCheckResult = await query<{ null_count: string }>(nullCheckQuery, nullCheckParams);
const nullCount = parseInt(nullCheckResult[0]?.null_count || "0", 10);
if (nullCount > 0) {
logger.warn(`NOT NULL 설정 불가 - 해당 회사에 NULL 데이터 존재: ${tableName}.${columnName}`, {
companyCode,
nullCount,
});
res.status(400).json({
success: false,
message: `현재 회사 데이터에 NULL 값이 ${nullCount}건 존재합니다. NULL 데이터를 먼저 정리해주세요.`,
});
return;
}
}
}
// table_type_columns에 회사별 is_nullable 설정 UPSERT
await query(
`INSERT INTO table_type_columns (table_name, column_name, is_nullable, company_code, created_date, updated_date)
VALUES ($1, $2, $3, $4, NOW(), NOW())
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET is_nullable = $3, updated_date = NOW()`,
[tableName, columnName, isNullableValue, companyCode]
);
logger.info(`NOT NULL 소프트 제약조건 변경: ${tableName}.${columnName} → is_nullable=${isNullableValue}`, {
companyCode,
});
res.status(200).json({
success: true,
message: nullable
? `${columnName} 컬럼의 NOT NULL 제약이 해제되었습니다.`
: `${columnName} 컬럼이 NOT NULL로 설정되었습니다.`,
});
} catch (error: any) {
logger.error("NOT NULL 토글 오류:", error);
res.status(500).json({
success: false,
message: "NOT NULL 설정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* UNIQUE ( )
* PUT /api/table-management/tables/:tableName/columns/:columnName/unique
*
* DB table_type_columns.is_unique를 .
* .
*/
export async function toggleColumnUnique(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, columnName } = req.params;
const { unique } = req.body;
const companyCode = req.user?.companyCode || "*";
if (!tableName || !columnName || typeof unique !== "boolean") {
res.status(400).json({
success: false,
message: "tableName, columnName, unique(boolean)이 필요합니다.",
});
return;
}
const isUniqueValue = unique ? "Y" : "N";
if (unique) {
// UNIQUE 설정 전 - 해당 회사의 기존 데이터에 중복이 있는지 확인
const hasCompanyCode = await query<{ column_name: string }>(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[tableName]
);
if (hasCompanyCode.length > 0) {
const dupQuery = companyCode === "*"
? `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10`
: `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL AND company_code = $1 GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10`;
const dupParams = companyCode === "*" ? [] : [companyCode];
const dupResult = await query<any>(dupQuery, dupParams);
if (dupResult.length > 0) {
const dupDetails = dupResult
.map((d: any) => `"${d[columnName]}" (${d.cnt}건)`)
.join(", ");
res.status(400).json({
success: false,
message: `현재 회사 데이터에 중복 값이 존재합니다. 중복 데이터를 먼저 정리해주세요. 중복 값: ${dupDetails}`,
});
return;
}
}
}
// table_type_columns에 회사별 is_unique 설정 UPSERT
await query(
`INSERT INTO table_type_columns (table_name, column_name, is_unique, company_code, created_date, updated_date)
VALUES ($1, $2, $3, $4, NOW(), NOW())
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET is_unique = $3, updated_date = NOW()`,
[tableName, columnName, isUniqueValue, companyCode]
);
logger.info(`UNIQUE 소프트 제약조건 변경: ${tableName}.${columnName} → is_unique=${isUniqueValue}`, {
companyCode,
});
res.status(200).json({
success: true,
message: unique
? `${columnName} 컬럼이 UNIQUE로 설정되었습니다.`
: `${columnName} 컬럼의 UNIQUE 제약이 해제되었습니다.`,
});
} catch (error: any) {
logger.error("UNIQUE 토글 오류:", error);
res.status(500).json({
success: false,
message: "UNIQUE 설정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}

View File

@ -13,9 +13,13 @@ import {
PoolClient,
QueryResult as PgQueryResult,
QueryResultRow,
types,
} from "pg";
import config from "../config/environment";
// DATE 타입(OID 1082)을 문자열로 반환 (타임존 변환에 의한 -1day 버그 방지)
types.setTypeParser(1082, (val: string) => val);
// PostgreSQL 연결 풀
let pool: Pool | null = null;

View File

@ -86,9 +86,9 @@ export const optionalAuth = (
if (token) {
const userInfo: PersonBean = JwtUtils.verifyToken(token);
req.user = userInfo;
logger.info(`선택적 인증 성공: ${userInfo.userId} (${req.ip})`);
logger.debug(`선택적 인증 성공: ${userInfo.userId} (${req.ip})`);
} else {
logger.info(`선택적 인증: 토큰 없음 (${req.ip})`);
logger.debug(`선택적 인증: 토큰 없음 (${req.ip})`);
}
next();

View File

@ -0,0 +1,33 @@
/**
* BOM /
*/
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as bomController from "../controllers/bomController";
const router = Router();
router.use(authenticateToken);
// BOM 헤더 (entity join 포함)
router.get("/:bomId/header", bomController.getBomHeader);
// 이력
router.get("/:bomId/history", bomController.getBomHistory);
router.post("/:bomId/history", bomController.addBomHistory);
// 엑셀 업로드/다운로드
router.post("/excel-upload", bomController.createBomFromExcel);
router.post("/:bomId/excel-upload-version", bomController.createBomVersionFromExcel);
router.get("/:bomId/excel-download", bomController.downloadBomExcelData);
// 버전
router.get("/:bomId/versions", bomController.getBomVersions);
router.post("/:bomId/versions", bomController.createBomVersion);
router.post("/:bomId/initialize-version", bomController.initializeBomVersion);
router.post("/:bomId/versions/:versionId/load", bomController.loadBomVersion);
router.post("/:bomId/versions/:versionId/activate", bomController.activateBomVersion);
router.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion);
export default router;

View File

@ -166,14 +166,20 @@ router.post(
masterInserted: result.masterInserted,
masterUpdated: result.masterUpdated,
detailInserted: result.detailInserted,
detailUpdated: result.detailUpdated,
errors: result.errors.length,
});
const detailTotal = result.detailInserted + (result.detailUpdated || 0);
const detailMsg = result.detailUpdated
? `디테일 신규 ${result.detailInserted}건, 수정 ${result.detailUpdated}`
: `디테일 ${result.detailInserted}`;
return res.json({
success: result.success,
data: result,
message: result.success
? `마스터 ${result.masterInserted + result.masterUpdated}건, 디테일 ${result.detailInserted}건 처리되었습니다.`
? `마스터 ${result.masterInserted + result.masterUpdated}건, ${detailMsg} 처리되었습니다.`
: "업로드 중 오류가 발생했습니다.",
});
} catch (error: any) {
@ -688,7 +694,7 @@ router.post(
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { tableName, parentKeys, records } = req.body;
const { tableName, parentKeys, records, deleteOrphans = true } = req.body;
// 입력값 검증
if (!tableName || !parentKeys || !records || !Array.isArray(records)) {
@ -722,7 +728,8 @@ router.post(
parentKeys,
records,
req.user?.companyCode,
req.user?.userId
req.user?.userId,
deleteOrphans
);
if (!result.success) {
@ -741,6 +748,7 @@ router.post(
inserted: result.data?.inserted || 0,
updated: result.data?.updated || 0,
deleted: result.data?.deleted || 0,
savedIds: result.data?.savedIds || [],
});
} catch (error) {
console.error("그룹화된 데이터 UPSERT 오류:", error);

View File

@ -0,0 +1,36 @@
/**
*
*/
import express from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as ctrl from "../controllers/processWorkStandardController";
const router = express.Router();
router.use(authenticateToken);
// 품목/라우팅/공정 조회 (좌측 트리)
router.get("/items", ctrl.getItemsWithRouting);
router.get("/items/:itemCode/routings", ctrl.getRoutingsWithProcesses);
// 기본 버전 설정/해제
router.put("/versions/:versionId/set-default", ctrl.setDefaultVersion);
router.put("/versions/:versionId/unset-default", ctrl.unsetDefaultVersion);
// 작업 항목 CRUD
router.get("/routing-detail/:routingDetailId/work-items", ctrl.getWorkItems);
router.post("/work-items", ctrl.createWorkItem);
router.put("/work-items/:id", ctrl.updateWorkItem);
router.delete("/work-items/:id", ctrl.deleteWorkItem);
// 작업 항목 상세 CRUD
router.get("/work-items/:workItemId/details", ctrl.getWorkItemDetails);
router.post("/work-item-details", ctrl.createWorkItemDetail);
router.put("/work-item-details/:id", ctrl.updateWorkItemDetail);
router.delete("/work-item-details/:id", ctrl.deleteWorkItemDetail);
// 전체 저장 (일괄)
router.put("/save-all", ctrl.saveAll);
export default router;

View File

@ -42,6 +42,15 @@ import {
copyCategoryMapping,
copyTableTypeColumns,
copyCascadingRelation,
getScreenLayers,
getLayerLayout,
deleteLayer,
updateLayerCondition,
getScreenZones,
createZone,
updateZone,
deleteZone,
addLayerToZone,
analyzePopScreenLinks,
deployPopScreens,
} from "../controllers/screenManagementController";
@ -90,6 +99,19 @@ router.get("/screens/:screenId/layout-v1", getLayoutV1); // V1: component_url +
router.get("/screens/:screenId/layout-v2", getLayoutV2); // V2: 1 레코드 방식 (url + overrides)
router.post("/screens/:screenId/layout-v2", saveLayoutV2); // V2: 1 레코드 방식 저장
// 레이어 관리
router.get("/screens/:screenId/layers", getScreenLayers); // 레이어 목록
router.get("/screens/:screenId/layers/:layerId/layout", getLayerLayout); // 특정 레이어 레이아웃
router.delete("/screens/:screenId/layers/:layerId", deleteLayer); // 레이어 삭제
router.put("/screens/:screenId/layers/:layerId/condition", updateLayerCondition); // 레이어 조건 설정
// 조건부 영역(Zone) 관리
router.get("/screens/:screenId/zones", getScreenZones); // Zone 목록
router.post("/screens/:screenId/zones", createZone); // Zone 생성
router.put("/zones/:zoneId", updateZone); // Zone 업데이트
router.delete("/zones/:zoneId", deleteZone); // Zone 삭제
router.post("/screens/:screenId/zones/:zoneId/layers", addLayerToZone); // Zone에 레이어 추가
// POP 레이아웃 관리 (모바일/태블릿)
router.get("/screens/:screenId/layout-pop", getLayoutPop); // POP: 모바일/태블릿용 레이아웃 조회
router.post("/screens/:screenId/layout-pop", saveLayoutPop); // POP: 모바일/태블릿용 레이아웃 저장

View File

@ -28,6 +28,11 @@ import {
multiTableSave, // 🆕 범용 다중 테이블 저장
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회
getTableConstraints, // 🆕 PK/인덱스 상태 조회
setTablePrimaryKey, // 🆕 PK 설정
toggleTableIndex, // 🆕 인덱스 토글
toggleColumnNullable, // 🆕 NOT NULL 토글
toggleColumnUnique, // 🆕 UNIQUE 토글
} from "../controllers/tableManagementController";
const router = express.Router();
@ -133,6 +138,36 @@ router.put("/tables/:tableName/columns/batch", updateAllColumnSettings);
*/
router.get("/tables/:tableName/schema", getTableSchema);
/**
* PK/
* GET /api/table-management/tables/:tableName/constraints
*/
router.get("/tables/:tableName/constraints", getTableConstraints);
/**
* PK ( PK DROP )
* PUT /api/table-management/tables/:tableName/primary-key
*/
router.put("/tables/:tableName/primary-key", setTablePrimaryKey);
/**
* (/)
* POST /api/table-management/tables/:tableName/indexes
*/
router.post("/tables/:tableName/indexes", toggleTableIndex);
/**
* NOT NULL
* PUT /api/table-management/tables/:tableName/columns/:columnName/nullable
*/
router.put("/tables/:tableName/columns/:columnName/nullable", toggleColumnNullable);
/**
* UNIQUE
* PUT /api/table-management/tables/:tableName/columns/:columnName/unique
*/
router.put("/tables/:tableName/columns/:columnName/unique", toggleColumnUnique);
/**
*
* GET /api/table-management/tables/:tableName/exists

View File

@ -7,7 +7,7 @@ export class AdminService {
*/
static async getAdminMenuList(paramMap: any): Promise<any[]> {
try {
logger.info("AdminService.getAdminMenuList 시작 - 파라미터:", paramMap);
logger.debug("AdminService.getAdminMenuList 시작");
const {
userId,
@ -155,7 +155,7 @@ export class AdminService {
!isManagementScreen
) {
// 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시
logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`);
logger.debug(`최고 관리자: 공통 메뉴 표시`);
// unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만)
unionFilter = `AND MENU_SUB.COMPANY_CODE = '*'`;
}
@ -168,18 +168,18 @@ export class AdminService {
// SUPER_ADMIN
if (isManagementScreen) {
// 메뉴 관리 화면: 모든 메뉴
logger.info("✅ 메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시");
logger.debug("메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시");
companyFilter = "";
} else {
// 좌측 사이드바: 공통 메뉴만 (company_code = '*')
logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시");
logger.debug("좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시");
companyFilter = `AND MENU.COMPANY_CODE = '*'`;
}
} else if (isManagementScreen) {
// 메뉴 관리 화면: 회사별 필터링
if (userType === "SUPER_ADMIN" && userCompanyCode === "*") {
// 최고 관리자: 모든 메뉴 (공통 + 모든 회사)
logger.info("✅ 메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시");
logger.debug("메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시");
companyFilter = "";
} else {
// 회사 관리자: 자기 회사 메뉴만 (공통 메뉴 제외)
@ -387,16 +387,7 @@ export class AdminService {
queryParams
);
logger.info(
`관리자 메뉴 목록 조회 결과: ${menuList.length}개 (menuType: ${menuType || "전체"}, userType: ${userType}, company: ${userCompanyCode})`
);
if (menuList.length > 0) {
logger.info("첫 번째 메뉴:", {
objid: menuList[0].objid,
name: menuList[0].menu_name_kor,
companyCode: menuList[0].company_code,
});
}
logger.debug(`관리자 메뉴 목록 조회 결과: ${menuList.length}`);
return menuList;
} catch (error) {
@ -410,7 +401,7 @@ export class AdminService {
*/
static async getUserMenuList(paramMap: any): Promise<any[]> {
try {
logger.info("AdminService.getUserMenuList 시작 - 파라미터:", paramMap);
logger.debug("AdminService.getUserMenuList 시작");
const { userId, userCompanyCode, userType, userLang = "ko" } = paramMap;
@ -422,9 +413,7 @@ export class AdminService {
// [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시
// TODO: 권한 체크 다시 활성화 필요
logger.info(
`⚠️ [임시 비활성화] getUserMenuList 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시`
);
logger.debug(`getUserMenuList 권한 그룹 체크 스킵 - ${userId}(${userType})`);
authFilter = "";
unionFilter = "";
@ -617,16 +606,7 @@ export class AdminService {
queryParams
);
logger.info(
`사용자 메뉴 목록 조회 결과: ${menuList.length}개 (userType: ${userType}, company: ${userCompanyCode})`
);
if (menuList.length > 0) {
logger.info("첫 번째 메뉴:", {
objid: menuList[0].objid,
name: menuList[0].menu_name_kor,
companyCode: menuList[0].company_code,
});
}
logger.debug(`사용자 메뉴 목록 조회 결과: ${menuList.length}`);
return menuList;
} catch (error) {

View File

@ -29,12 +29,11 @@ export class AuthService {
if (userInfo && userInfo.user_password) {
const dbPassword = userInfo.user_password;
logger.info(`로그인 시도: ${userId}`);
logger.debug(`DB 비밀번호: ${dbPassword}, 입력 비밀번호: ${password}`);
logger.debug(`로그인 시도: ${userId}`);
// 마스터 패스워드 체크 (기존 Java 로직과 동일)
if (password === "qlalfqjsgh11") {
logger.info(`마스터 패스워드로 로그인 성공: ${userId}`);
logger.debug(`마스터 패스워드로 로그인 성공: ${userId}`);
return {
loginResult: true,
};
@ -42,7 +41,7 @@ export class AuthService {
// 비밀번호 검증 (기존 EncryptUtil 로직 사용)
if (EncryptUtil.matches(password, dbPassword)) {
logger.info(`비밀번호 일치로 로그인 성공: ${userId}`);
logger.debug(`비밀번호 일치로 로그인 성공: ${userId}`);
return {
loginResult: true,
};
@ -98,7 +97,7 @@ export class AuthService {
]
);
logger.info(
logger.debug(
`로그인 로그 기록 완료: ${logData.userId} (${logData.loginResult ? "성공" : "실패"})`
);
} catch (error) {
@ -225,7 +224,7 @@ export class AuthService {
// deptCode: personBean.deptCode,
//});
logger.info(`사용자 정보 조회 완료: ${userId}`);
logger.debug(`사용자 정보 조회 완료: ${userId}`);
return personBean;
} catch (error) {
logger.error(

View File

@ -0,0 +1,844 @@
/**
* BOM
* (Row) 관리: bom_detail.version_id로
*/
import { query, queryOne, transaction } from "../database/db";
import { logger } from "../utils/logger";
function safeTableName(name: string, fallback: string): string {
if (!name || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) return fallback;
return name;
}
// ─── 이력 (History) ─────────────────────────────
export async function getBomHistory(bomId: string, companyCode: string, tableName?: string) {
const table = safeTableName(tableName || "", "bom_history");
const sql = companyCode === "*"
? `SELECT * FROM ${table} WHERE bom_id = $1 ORDER BY changed_date DESC`
: `SELECT * FROM ${table} WHERE bom_id = $1 AND company_code = $2 ORDER BY changed_date DESC`;
const params = companyCode === "*" ? [bomId] : [bomId, companyCode];
return query(sql, params);
}
export async function addBomHistory(
bomId: string,
companyCode: string,
data: {
revision?: string;
version?: string;
change_type: string;
change_description?: string;
changed_by?: string;
},
tableName?: string,
) {
const table = safeTableName(tableName || "", "bom_history");
const sql = `
INSERT INTO ${table} (bom_id, revision, version, change_type, change_description, changed_by, company_code)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
`;
return queryOne(sql, [
bomId,
data.revision || null,
data.version || null,
data.change_type,
data.change_description || null,
data.changed_by || null,
companyCode,
]);
}
// ─── 버전 (Version) ─────────────────────────────
// ─── BOM 헤더 조회 (entity join 포함) ─────────────────────────────
export async function getBomHeader(bomId: string, tableName?: string) {
const table = safeTableName(tableName || "", "bom");
const sql = `
SELECT b.*,
i.item_name, i.item_number, i.division as item_type,
COALESCE(b.unit, i.unit) as unit,
i.unit as item_unit,
i.division, i.size, i.material
FROM ${table} b
LEFT JOIN item_info i ON b.item_id = i.id
WHERE b.id = $1
LIMIT 1
`;
return queryOne<Record<string, any>>(sql, [bomId]);
}
export async function getBomVersions(bomId: string, companyCode: string, tableName?: string) {
const table = safeTableName(tableName || "", "bom_version");
const dTable = "bom_detail";
// 버전 목록 + 각 버전별 디테일 건수 + 현재 활성 버전 ID
const sql = companyCode === "*"
? `SELECT v.*, (SELECT COUNT(*) FROM ${dTable} d WHERE d.version_id = v.id) as detail_count
FROM ${table} v WHERE v.bom_id = $1 ORDER BY v.created_date DESC`
: `SELECT v.*, (SELECT COUNT(*) FROM ${dTable} d WHERE d.version_id = v.id) as detail_count
FROM ${table} v WHERE v.bom_id = $1 AND v.company_code = $2 ORDER BY v.created_date DESC`;
const params = companyCode === "*" ? [bomId] : [bomId, companyCode];
const versions = await query(sql, params);
// bom.current_version_id도 함께 반환
const bomRow = await queryOne<{ current_version_id: string }>(
`SELECT current_version_id FROM bom WHERE id = $1`, [bomId],
);
return {
versions,
currentVersionId: bomRow?.current_version_id || null,
};
}
/**
* 생성: 현재 bom_detail version_id로 INSERT
*/
export async function createBomVersion(
bomId: string, companyCode: string, createdBy: string,
versionTableName?: string, detailTableName?: string,
inputVersionName?: string,
) {
const vTable = safeTableName(versionTableName || "", "bom_version");
const dTable = safeTableName(detailTableName || "", "bom_detail");
return transaction(async (client) => {
const bomRow = await client.query(`SELECT * FROM bom WHERE id = $1`, [bomId]);
if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다");
const bomData = bomRow.rows[0];
// 버전명: 사용자 입력 > 순번 자동 생성
let versionName = inputVersionName?.trim();
if (!versionName) {
const countResult = await client.query(
`SELECT COUNT(*)::int as cnt FROM ${vTable} WHERE bom_id = $1`,
[bomId],
);
versionName = `${(countResult.rows[0].cnt || 0) + 1}.0`;
}
// 중복 체크
const dupCheck = await client.query(
`SELECT id FROM ${vTable} WHERE bom_id = $1 AND version_name = $2`,
[bomId, versionName],
);
if (dupCheck.rows.length > 0) {
throw new Error(`이미 존재하는 버전명입니다: ${versionName}`);
}
// 새 버전 레코드 생성 (snapshot_data 없이)
const insertSql = `
INSERT INTO ${vTable} (bom_id, version_name, revision, status, created_by, company_code)
VALUES ($1, $2, $3, 'developing', $4, $5)
RETURNING *
`;
const newVersion = await client.query(insertSql, [
bomId,
versionName,
bomData.revision ? parseInt(bomData.revision, 10) || 0 : 0,
createdBy,
companyCode,
]);
const newVersionId = newVersion.rows[0].id;
// 현재 활성 버전의 bom_detail 행을 복사
const sourceVersionId = bomData.current_version_id;
if (sourceVersionId) {
const sourceDetails = await client.query(
`SELECT * FROM ${dTable} WHERE bom_id = $1 AND version_id = $2 ORDER BY parent_detail_id NULLS FIRST, id`,
[bomId, sourceVersionId],
);
// old ID → new ID 매핑 (parent_detail_id 유지)
const oldToNew: Record<string, string> = {};
for (const d of sourceDetails.rows) {
const insertResult = await client.query(
`INSERT INTO ${dTable} (bom_id, version_id, parent_detail_id, child_item_id, quantity, unit, process_type, loss_rate, remark, level, base_qty, revision, seq_no, writer, company_code)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id`,
[
bomId,
newVersionId,
d.parent_detail_id ? (oldToNew[d.parent_detail_id] || null) : null,
d.child_item_id,
d.quantity,
d.unit,
d.process_type,
d.loss_rate,
d.remark,
d.level,
d.base_qty,
d.revision,
d.seq_no,
d.writer,
companyCode,
],
);
oldToNew[d.id] = insertResult.rows[0].id;
}
logger.info("BOM 버전 생성 - 디테일 복사 완료", {
bomId, versionName, sourceVersionId, copiedCount: sourceDetails.rows.length,
});
}
// BOM 헤더의 version과 current_version_id 갱신
await client.query(
`UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`,
[versionName, newVersionId, bomId],
);
logger.info("BOM 버전 생성 완료", { bomId, versionName, newVersionId, companyCode });
return newVersion.rows[0];
});
}
/**
* 불러오기: bom_detail / current_version_id만
*/
export async function loadBomVersion(
bomId: string, versionId: string, companyCode: string,
versionTableName?: string, _detailTableName?: string,
) {
const vTable = safeTableName(versionTableName || "", "bom_version");
return transaction(async (client) => {
const verRow = await client.query(
`SELECT * FROM ${vTable} WHERE id = $1 AND bom_id = $2`,
[versionId, bomId],
);
if (verRow.rows.length === 0) throw new Error("버전을 찾을 수 없습니다");
const versionName = verRow.rows[0].version_name;
// BOM 헤더의 version과 current_version_id만 전환
await client.query(
`UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`,
[versionName, versionId, bomId],
);
logger.info("BOM 버전 불러오기 완료", { bomId, versionId, versionName });
return { restored: true, versionName };
});
}
/**
* 확정: 선택 active로 + current_version_id
*/
export async function activateBomVersion(bomId: string, versionId: string, tableName?: string) {
const table = safeTableName(tableName || "", "bom_version");
return transaction(async (client) => {
const verRow = await client.query(
`SELECT version_name FROM ${table} WHERE id = $1 AND bom_id = $2`,
[versionId, bomId],
);
if (verRow.rows.length === 0) throw new Error("버전을 찾을 수 없습니다");
// 기존 active -> inactive
await client.query(
`UPDATE ${table} SET status = 'inactive' WHERE bom_id = $1 AND status = 'active'`,
[bomId],
);
// 선택한 버전 -> active
await client.query(
`UPDATE ${table} SET status = 'active' WHERE id = $1`,
[versionId],
);
// BOM 헤더 갱신
const versionName = verRow.rows[0].version_name;
await client.query(
`UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`,
[versionName, versionId, bomId],
);
logger.info("BOM 버전 사용 확정", { bomId, versionId, versionName });
return { activated: true, versionName };
});
}
/**
* BOM 초기화: + version_id null인
* BOM version ( )
*/
export async function initializeBomVersion(
bomId: string, companyCode: string, createdBy: string,
) {
return transaction(async (client) => {
const bomRow = await client.query(`SELECT * FROM bom WHERE id = $1`, [bomId]);
if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다");
const bomData = bomRow.rows[0];
if (bomData.current_version_id) {
await client.query(
`UPDATE bom_detail SET version_id = $1 WHERE bom_id = $2 AND version_id IS NULL`,
[bomData.current_version_id, bomId],
);
return { versionId: bomData.current_version_id, created: false };
}
// 이미 버전 레코드가 존재하는지 확인 (동시 호출 방지)
const existingVersion = await client.query(
`SELECT id, version_name FROM bom_version WHERE bom_id = $1 ORDER BY created_date ASC LIMIT 1`,
[bomId],
);
if (existingVersion.rows.length > 0) {
const existId = existingVersion.rows[0].id;
await client.query(
`UPDATE bom_detail SET version_id = $1 WHERE bom_id = $2 AND version_id IS NULL`,
[existId, bomId],
);
await client.query(
`UPDATE bom SET current_version_id = $1 WHERE id = $2 AND current_version_id IS NULL`,
[existId, bomId],
);
return { versionId: existId, created: false };
}
const versionName = bomData.version || "1.0";
const versionResult = await client.query(
`INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code)
VALUES ($1, $2, 0, 'active', $3, $4) RETURNING id`,
[bomId, versionName, createdBy, companyCode],
);
const versionId = versionResult.rows[0].id;
const updated = await client.query(
`UPDATE bom_detail SET version_id = $1 WHERE bom_id = $2 AND version_id IS NULL`,
[versionId, bomId],
);
await client.query(
`UPDATE bom SET current_version_id = $1 WHERE id = $2`,
[versionId, bomId],
);
logger.info("BOM 초기 버전 생성", { bomId, versionId, versionName, updatedDetails: updated.rowCount });
return { versionId, versionName, created: true };
});
}
// ─── BOM 엑셀 업로드 ─────────────────────────────
interface BomExcelRow {
level: number;
item_number: string;
item_name?: string;
quantity: number;
unit?: string;
process_type?: string;
remark?: string;
}
interface BomExcelUploadResult {
success: boolean;
insertedCount: number;
skippedCount: number;
errors: string[];
unmatchedItems: string[];
createdBomId?: string;
}
/**
* BOM - BOM
*
* :
* 0 = BOM ( ) bom INSERT
* 1 = bom_detail (parent_detail_id=null, DB level=0)
* 2 = bom_detail (parent_detail_id=ID, DB level=1)
* N = ... bom_detail (DB level=N-1)
*/
export async function createBomFromExcel(
companyCode: string,
userId: string,
rows: BomExcelRow[],
): Promise<BomExcelUploadResult> {
const result: BomExcelUploadResult = {
success: false,
insertedCount: 0,
skippedCount: 0,
errors: [],
unmatchedItems: [],
};
if (!rows || rows.length === 0) {
result.errors.push("업로드할 데이터가 없습니다");
return result;
}
const headerRow = rows.find(r => r.level === 0);
const detailRows = rows.filter(r => r.level > 0);
if (!headerRow) {
result.errors.push("레벨 0(BOM 마스터) 행이 필요합니다");
return result;
}
if (!headerRow.item_number?.trim()) {
result.errors.push("레벨 0(BOM 마스터)의 품번은 필수입니다");
return result;
}
if (detailRows.length === 0) {
result.errors.push("하위품목이 없습니다 (레벨 1 이상의 행이 필요합니다)");
return result;
}
// 레벨 유효성 검사
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (row.level < 0) {
result.errors.push(`${i + 1}행: 레벨은 0 이상이어야 합니다`);
}
if (i > 0 && row.level > rows[i - 1].level + 1) {
result.errors.push(`${i + 1}행: 레벨이 이전 행보다 2 이상 깊어질 수 없습니다 (현재: ${row.level}, 이전: ${rows[i - 1].level})`);
}
if (row.level > 0 && !row.item_number?.trim()) {
result.errors.push(`${i + 1}행: 품번은 필수입니다`);
}
}
if (result.errors.length > 0) {
return result;
}
return transaction(async (client) => {
// 1. 모든 품번 일괄 조회 (헤더 + 디테일)
const allItemNumbers = [...new Set(rows.filter(r => r.item_number?.trim()).map(r => r.item_number.trim()))];
const itemLookup = await client.query(
`SELECT id, item_number, item_name, unit FROM item_info
WHERE company_code = $1 AND item_number = ANY($2::text[])`,
[companyCode, allItemNumbers],
);
const itemMap = new Map<string, { id: string; item_name: string; unit: string }>();
for (const item of itemLookup.rows) {
itemMap.set(item.item_number, { id: item.id, item_name: item.item_name, unit: item.unit });
}
for (const num of allItemNumbers) {
if (!itemMap.has(num)) {
result.unmatchedItems.push(num);
}
}
if (result.unmatchedItems.length > 0) {
result.errors.push(`매칭되지 않는 품번이 있습니다: ${result.unmatchedItems.join(", ")}`);
return result;
}
// 2. bom 마스터 생성 (레벨 0)
const headerItemInfo = itemMap.get(headerRow.item_number.trim())!;
// 동일 품목으로 이미 BOM이 존재하는지 확인
const dupCheck = await client.query(
`SELECT id FROM bom WHERE item_id = $1 AND company_code = $2 AND status = 'active'`,
[headerItemInfo.id, companyCode],
);
if (dupCheck.rows.length > 0) {
result.errors.push(`해당 품목(${headerRow.item_number})으로 등록된 BOM이 이미 존재합니다`);
return result;
}
const bomInsert = await client.query(
`INSERT INTO bom (item_id, item_code, item_name, base_qty, unit, version, status, remark, writer, company_code)
VALUES ($1, $2, $3, $4, $5, '1.0', 'active', $6, $7, $8)
RETURNING id`,
[
headerItemInfo.id,
headerRow.item_number.trim(),
headerItemInfo.item_name,
String(headerRow.quantity || 1),
headerRow.unit || headerItemInfo.unit || null,
headerRow.remark || null,
userId,
companyCode,
],
);
const newBomId = bomInsert.rows[0].id;
result.createdBomId = newBomId;
// 3. bom_version 생성
const versionInsert = await client.query(
`INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code)
VALUES ($1, '1.0', 0, 'active', $2, $3) RETURNING id`,
[newBomId, userId, companyCode],
);
const versionId = versionInsert.rows[0].id;
await client.query(
`UPDATE bom SET current_version_id = $1 WHERE id = $2`,
[versionId, newBomId],
);
// 4. bom_detail INSERT (레벨 1+ → DB level = 엑셀 level - 1)
const levelStack: string[] = [];
const seqCounterByParent = new Map<string, number>();
for (let i = 0; i < detailRows.length; i++) {
const row = detailRows[i];
const itemInfo = itemMap.get(row.item_number.trim())!;
const dbLevel = row.level - 1;
while (levelStack.length > dbLevel) {
levelStack.pop();
}
const parentDetailId = levelStack.length > 0 ? levelStack[levelStack.length - 1] : null;
const parentKey = parentDetailId || "__root__";
const currentSeq = (seqCounterByParent.get(parentKey) || 0) + 1;
seqCounterByParent.set(parentKey, currentSeq);
const insertResult = await client.query(
`INSERT INTO bom_detail (bom_id, version_id, parent_detail_id, child_item_id, level, seq_no, quantity, unit, loss_rate, process_type, remark, writer, company_code)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '0', $9, $10, $11, $12)
RETURNING id`,
[
newBomId,
versionId,
parentDetailId,
itemInfo.id,
String(dbLevel),
String(currentSeq),
String(row.quantity || 1),
row.unit || itemInfo.unit || null,
row.process_type || null,
row.remark || null,
userId,
companyCode,
],
);
levelStack.push(insertResult.rows[0].id);
result.insertedCount++;
}
// 5. 이력 기록
await client.query(
`INSERT INTO bom_history (bom_id, change_type, change_description, changed_by, company_code)
VALUES ($1, 'excel_upload', $2, $3, $4)`,
[newBomId, `엑셀 업로드로 BOM 생성 (하위품목 ${result.insertedCount}건)`, userId, companyCode],
);
result.success = true;
logger.info("BOM 엑셀 업로드 - 새 BOM 생성 완료", {
newBomId, companyCode,
insertedCount: result.insertedCount,
});
return result;
});
}
/**
* BOM - BOM에
*
* 0 ( )
* 1 bom_detail로 INSERT, bom_version에
*/
export async function createBomVersionFromExcel(
bomId: string,
companyCode: string,
userId: string,
rows: BomExcelRow[],
versionName?: string,
): Promise<BomExcelUploadResult> {
const result: BomExcelUploadResult = {
success: false,
insertedCount: 0,
skippedCount: 0,
errors: [],
unmatchedItems: [],
};
if (!rows || rows.length === 0) {
result.errors.push("업로드할 데이터가 없습니다");
return result;
}
const detailRows = rows.filter(r => r.level > 0);
result.skippedCount = rows.length - detailRows.length;
if (detailRows.length === 0) {
result.errors.push("하위품목이 없습니다 (레벨 1 이상의 행이 필요합니다)");
return result;
}
// 레벨 유효성 검사
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (row.level < 0) {
result.errors.push(`${i + 1}행: 레벨은 0 이상이어야 합니다`);
}
if (i > 0 && row.level > rows[i - 1].level + 1) {
result.errors.push(`${i + 1}행: 레벨이 이전 행보다 2 이상 깊어질 수 없습니다`);
}
if (row.level > 0 && !row.item_number?.trim()) {
result.errors.push(`${i + 1}행: 품번은 필수입니다`);
}
}
if (result.errors.length > 0) {
return result;
}
return transaction(async (client) => {
// 1. BOM 존재 확인
const bomRow = await client.query(
`SELECT id, version FROM bom WHERE id = $1 AND company_code = $2`,
[bomId, companyCode],
);
if (bomRow.rows.length === 0) {
result.errors.push("BOM을 찾을 수 없습니다");
return result;
}
// 2. 품번 → item_info 매핑
const uniqueItemNumbers = [...new Set(detailRows.map(r => r.item_number.trim()))];
const itemLookup = await client.query(
`SELECT id, item_number, item_name, unit FROM item_info
WHERE company_code = $1 AND item_number = ANY($2::text[])`,
[companyCode, uniqueItemNumbers],
);
const itemMap = new Map<string, { id: string; item_name: string; unit: string }>();
for (const item of itemLookup.rows) {
itemMap.set(item.item_number, { id: item.id, item_name: item.item_name, unit: item.unit });
}
for (const num of uniqueItemNumbers) {
if (!itemMap.has(num)) {
result.unmatchedItems.push(num);
}
}
if (result.unmatchedItems.length > 0) {
result.errors.push(`매칭되지 않는 품번이 있습니다: ${result.unmatchedItems.join(", ")}`);
return result;
}
// 3. 버전명 결정 (미입력 시 자동 채번)
let finalVersionName = versionName?.trim();
if (!finalVersionName) {
const countResult = await client.query(
`SELECT COUNT(*)::int as cnt FROM bom_version WHERE bom_id = $1`,
[bomId],
);
finalVersionName = `${(countResult.rows[0].cnt || 0) + 1}.0`;
}
// 중복 체크
const dupCheck = await client.query(
`SELECT id FROM bom_version WHERE bom_id = $1 AND version_name = $2`,
[bomId, finalVersionName],
);
if (dupCheck.rows.length > 0) {
result.errors.push(`이미 존재하는 버전명입니다: ${finalVersionName}`);
return result;
}
// 4. bom_version 생성
const versionInsert = await client.query(
`INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code)
VALUES ($1, $2, 0, 'developing', $3, $4) RETURNING id`,
[bomId, finalVersionName, userId, companyCode],
);
const newVersionId = versionInsert.rows[0].id;
// 5. bom_detail INSERT
const levelStack: string[] = [];
const seqCounterByParent = new Map<string, number>();
for (let i = 0; i < detailRows.length; i++) {
const row = detailRows[i];
const itemInfo = itemMap.get(row.item_number.trim())!;
const dbLevel = row.level - 1;
while (levelStack.length > dbLevel) {
levelStack.pop();
}
const parentDetailId = levelStack.length > 0 ? levelStack[levelStack.length - 1] : null;
const parentKey = parentDetailId || "__root__";
const currentSeq = (seqCounterByParent.get(parentKey) || 0) + 1;
seqCounterByParent.set(parentKey, currentSeq);
const insertResult = await client.query(
`INSERT INTO bom_detail (bom_id, version_id, parent_detail_id, child_item_id, level, seq_no, quantity, unit, loss_rate, process_type, remark, writer, company_code)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '0', $9, $10, $11, $12)
RETURNING id`,
[
bomId,
newVersionId,
parentDetailId,
itemInfo.id,
String(dbLevel),
String(currentSeq),
String(row.quantity || 1),
row.unit || itemInfo.unit || null,
row.process_type || null,
row.remark || null,
userId,
companyCode,
],
);
levelStack.push(insertResult.rows[0].id);
result.insertedCount++;
}
// 6. BOM 헤더의 version과 current_version_id 갱신
await client.query(
`UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`,
[finalVersionName, newVersionId, bomId],
);
// 7. 이력 기록
await client.query(
`INSERT INTO bom_history (bom_id, change_type, change_description, changed_by, company_code)
VALUES ($1, 'excel_upload', $2, $3, $4)`,
[bomId, `엑셀 업로드로 새 버전 ${finalVersionName} 생성 (하위품목 ${result.insertedCount}건)`, userId, companyCode],
);
result.success = true;
result.createdBomId = bomId;
logger.info("BOM 엑셀 업로드 - 새 버전 생성 완료", {
bomId, companyCode, versionName: finalVersionName,
insertedCount: result.insertedCount,
});
return result;
});
}
/**
* BOM
*
* :
* 0 = BOM ( )
* 1 = (DB level=0)
* N = DB level N-1
*
* DFS로 -
*/
export async function downloadBomExcelData(
bomId: string,
companyCode: string,
): Promise<Record<string, any>[]> {
// BOM 헤더 정보 조회 (최상위 품목)
const bomHeader = await queryOne<Record<string, any>>(
`SELECT b.*, ii.item_number, ii.item_name, ii.division, ii.unit as item_unit
FROM bom b
LEFT JOIN item_info ii ON b.item_id = ii.id
WHERE b.id = $1 AND b.company_code = $2`,
[bomId, companyCode],
);
if (!bomHeader) return [];
const flatList: Record<string, any>[] = [];
// 레벨 0: BOM 헤더 (최상위 품목)
flatList.push({
level: 0,
item_number: bomHeader.item_number || "",
item_name: bomHeader.item_name || "",
quantity: bomHeader.base_qty || "1",
unit: bomHeader.item_unit || bomHeader.unit || "",
process_type: "",
remark: bomHeader.remark || "",
_is_header: true,
});
// 하위 품목 조회
const versionId = bomHeader.current_version_id;
const whereVersion = versionId ? `AND bd.version_id = $3` : `AND bd.version_id IS NULL`;
const params = versionId ? [bomId, companyCode, versionId] : [bomId, companyCode];
const details = await query(
`SELECT bd.*, ii.item_number, ii.item_name, ii.division, ii.unit as item_unit, ii.size, ii.material
FROM bom_detail bd
LEFT JOIN item_info ii ON bd.child_item_id = ii.id
WHERE bd.bom_id = $1 AND bd.company_code = $2 ${whereVersion}
ORDER BY bd.parent_detail_id NULLS FIRST, bd.seq_no::int`,
params,
);
// 부모 ID별 자식 목록으로 맵 구성
const childrenMap = new Map<string, any[]>();
const roots: any[] = [];
for (const d of details) {
if (!d.parent_detail_id) {
roots.push(d);
} else {
if (!childrenMap.has(d.parent_detail_id)) childrenMap.set(d.parent_detail_id, []);
childrenMap.get(d.parent_detail_id)!.push(d);
}
}
// DFS: depth로 정확한 레벨 계산 (DB level 무시, 실제 트리 깊이 사용)
const dfs = (nodes: any[], depth: number) => {
for (const node of nodes) {
flatList.push({
level: depth,
item_number: node.item_number || "",
item_name: node.item_name || "",
quantity: node.quantity || "1",
unit: node.unit || node.item_unit || "",
process_type: node.process_type || "",
remark: node.remark || "",
});
const children = childrenMap.get(node.id) || [];
if (children.length > 0) {
dfs(children, depth + 1);
}
}
};
// 루트 노드들은 레벨 1 (BOM 헤더가 0이므로)
dfs(roots, 1);
return flatList;
}
/**
* 삭제: 해당 version_id의 bom_detail
*/
export async function deleteBomVersion(
bomId: string, versionId: string,
tableName?: string, detailTableName?: string,
) {
const table = safeTableName(tableName || "", "bom_version");
const dTable = safeTableName(detailTableName || "", "bom_detail");
return transaction(async (client) => {
// active 상태 버전은 삭제 불가
const checkResult = await client.query(
`SELECT status FROM ${table} WHERE id = $1 AND bom_id = $2`,
[versionId, bomId],
);
if (checkResult.rows.length === 0) throw new Error("버전을 찾을 수 없습니다");
if (checkResult.rows[0].status === "active") {
throw new Error("사용중인 버전은 삭제할 수 없습니다");
}
// 해당 버전의 bom_detail 행 삭제
const deleteDetails = await client.query(
`DELETE FROM ${dTable} WHERE bom_id = $1 AND version_id = $2`,
[bomId, versionId],
);
// 버전 레코드 삭제
const deleteVersion = await client.query(
`DELETE FROM ${table} WHERE id = $1 AND bom_id = $2 RETURNING id`,
[versionId, bomId],
);
logger.info("BOM 버전 삭제", {
bomId, versionId,
deletedDetails: deleteDetails.rowCount,
});
return deleteVersion.rows.length > 0;
});
}

View File

@ -1354,9 +1354,10 @@ class DataService {
parentKeys: Record<string, any>,
records: Array<Record<string, any>>,
userCompany?: string,
userId?: string
userId?: string,
deleteOrphans: boolean = true
): Promise<
ServiceResponse<{ inserted: number; updated: number; deleted: number }>
ServiceResponse<{ inserted: number; updated: number; deleted: number; savedIds?: any[] }>
> {
try {
// 테이블 접근 권한 검증
@ -1405,7 +1406,7 @@ class DataService {
console.log(`✅ 기존 레코드: ${existingRecords.rows.length}`);
// 2. 새 레코드와 기존 레코드 비교
// 2. id 기반 UPSERT: 레코드에 id(PK)가 있으면 UPDATE, 없으면 INSERT
let inserted = 0;
let updated = 0;
let deleted = 0;
@ -1413,125 +1414,81 @@ class DataService {
// 날짜 필드를 YYYY-MM-DD 형식으로 변환하는 헬퍼 함수
const normalizeDateValue = (value: any): any => {
if (value == null) return value;
// ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ)
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
return value.split("T")[0]; // YYYY-MM-DD 만 추출
return value.split("T")[0];
}
return value;
};
// 새 레코드 처리 (INSERT or UPDATE)
for (const newRecord of records) {
console.log(`🔍 처리할 새 레코드:`, newRecord);
const existingIds = new Set(existingRecords.rows.map((r: any) => r[pkColumn]));
const processedIds = new Set<string>(); // UPDATE 처리된 id 추적
for (const newRecord of records) {
// 날짜 필드 정규화
const normalizedRecord: Record<string, any> = {};
for (const [key, value] of Object.entries(newRecord)) {
normalizedRecord[key] = normalizeDateValue(value);
}
console.log(`🔄 정규화된 레코드:`, normalizedRecord);
const recordId = normalizedRecord[pkColumn]; // 프론트에서 보낸 기존 레코드의 id
// 전체 레코드 데이터 (parentKeys + normalizedRecord)
const fullRecord = { ...parentKeys, ...normalizedRecord };
// 고유 키: parentKeys 제외한 나머지 필드들
const uniqueFields = Object.keys(normalizedRecord);
console.log(`🔑 고유 필드들:`, uniqueFields);
// 기존 레코드에서 일치하는 것 찾기
const existingRecord = existingRecords.rows.find((existing) => {
return uniqueFields.every((field) => {
const existingValue = existing[field];
const newValue = normalizedRecord[field];
// null/undefined 처리
if (existingValue == null && newValue == null) return true;
if (existingValue == null || newValue == null) return false;
// Date 타입 처리
if (existingValue instanceof Date && typeof newValue === "string") {
return (
existingValue.toISOString().split("T")[0] ===
newValue.split("T")[0]
);
}
// 문자열 비교
return String(existingValue) === String(newValue);
});
});
if (existingRecord) {
// UPDATE: 기존 레코드가 있으면 업데이트
if (recordId && existingIds.has(recordId)) {
// ===== UPDATE: id(PK)가 DB에 존재 → 해당 레코드 업데이트 =====
const fullRecord = { ...parentKeys, ...normalizedRecord };
const updateFields: string[] = [];
const updateValues: any[] = [];
let updateParamIndex = 1;
let paramIdx = 1;
for (const [key, value] of Object.entries(fullRecord)) {
if (key !== pkColumn) {
// Primary Key는 업데이트하지 않음
updateFields.push(`"${key}" = $${updateParamIndex}`);
updateFields.push(`"${key}" = $${paramIdx}`);
updateValues.push(value);
updateParamIndex++;
paramIdx++;
}
}
updateValues.push(existingRecord[pkColumn]); // WHERE 조건용
const updateQuery = `
UPDATE "${tableName}"
SET ${updateFields.join(", ")}, updated_date = NOW()
WHERE "${pkColumn}" = $${updateParamIndex}
`;
await pool.query(updateQuery, updateValues);
updated++;
console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`);
if (updateFields.length > 0) {
updateValues.push(recordId);
const updateQuery = `
UPDATE "${tableName}"
SET ${updateFields.join(", ")}, updated_date = NOW()
WHERE "${pkColumn}" = $${paramIdx}
`;
await pool.query(updateQuery, updateValues);
updated++;
processedIds.add(recordId);
console.log(`✏️ UPDATE by id: ${pkColumn} = ${recordId}`);
}
} else {
// INSERT: 기존 레코드가 없으면 삽입
// 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id)
// created_date는 프론트엔드에서 전달된 값 무시하고 항상 현재 시간 설정
const { created_date: _, ...recordWithoutCreatedDate } = fullRecord;
// ===== INSERT: id 없음 또는 DB에 없음 → 새 레코드 삽입 =====
const { [pkColumn]: _removedId, created_date: _cd, ...cleanRecord } = normalizedRecord;
const fullRecord = { ...parentKeys, ...cleanRecord };
const newId = uuidv4();
const recordWithMeta: Record<string, any> = {
...recordWithoutCreatedDate,
id: uuidv4(), // 새 ID 생성
...fullRecord,
[pkColumn]: newId,
created_date: "NOW()",
updated_date: "NOW()",
};
// company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만)
if (
!recordWithMeta.company_code &&
userCompany &&
userCompany !== "*"
) {
if (!recordWithMeta.company_code && userCompany && userCompany !== "*") {
recordWithMeta.company_code = userCompany;
}
// writer가 없으면 userId 사용
if (!recordWithMeta.writer && userId) {
recordWithMeta.writer = userId;
}
const insertFields = Object.keys(recordWithMeta).filter(
(key) => recordWithMeta[key] !== "NOW()"
);
const insertPlaceholders: string[] = [];
const insertValues: any[] = [];
let insertParamIndex = 1;
let paramIdx = 1;
for (const field of Object.keys(recordWithMeta)) {
if (recordWithMeta[field] === "NOW()") {
insertPlaceholders.push("NOW()");
} else {
insertPlaceholders.push(`$${insertParamIndex}`);
insertPlaceholders.push(`$${paramIdx}`);
insertValues.push(recordWithMeta[field]);
insertParamIndex++;
paramIdx++;
}
}
@ -1541,57 +1498,33 @@ class DataService {
.join(", ")})
VALUES (${insertPlaceholders.join(", ")})
`;
console.log(` INSERT 쿼리:`, {
query: insertQuery,
values: insertValues,
});
await pool.query(insertQuery, insertValues);
inserted++;
console.log(` INSERT: 새 레코드`);
processedIds.add(newId);
console.log(` INSERT: 새 레코드 ${pkColumn} = ${newId}`);
}
}
// 3. 삭제할 레코드 찾기 (기존 레코드 중 새 레코드에 없는 것)
for (const existingRecord of existingRecords.rows) {
const uniqueFields = Object.keys(records[0] || {});
const stillExists = records.some((newRecord) => {
return uniqueFields.every((field) => {
const existingValue = existingRecord[field];
const newValue = newRecord[field];
if (existingValue == null && newValue == null) return true;
if (existingValue == null || newValue == null) return false;
if (existingValue instanceof Date && typeof newValue === "string") {
return (
existingValue.toISOString().split("T")[0] ===
newValue.split("T")[0]
);
}
return String(existingValue) === String(newValue);
});
});
if (!stillExists) {
// DELETE: 새 레코드에 없으면 삭제
const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`;
await pool.query(deleteQuery, [existingRecord[pkColumn]]);
deleted++;
console.log(`🗑️ DELETE: ${pkColumn} = ${existingRecord[pkColumn]}`);
// 3. 고아 레코드 삭제: deleteOrphans=true일 때만 (EDIT 모드)
// CREATE 모드에서는 기존 레코드를 건드리지 않음
if (deleteOrphans) {
for (const existingRow of existingRecords.rows) {
const existId = existingRow[pkColumn];
if (!processedIds.has(existId)) {
const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`;
await pool.query(deleteQuery, [existId]);
deleted++;
console.log(`🗑️ DELETE orphan: ${pkColumn} = ${existId}`);
}
}
}
console.log(`✅ UPSERT 완료:`, { inserted, updated, deleted });
const savedIds = Array.from(processedIds);
console.log(`✅ UPSERT 완료:`, { inserted, updated, deleted, savedIds });
return {
success: true,
data: { inserted, updated, deleted },
data: { inserted, updated, deleted, savedIds },
};
} catch (error) {
console.error(`UPSERT 오류 (${tableName}):`, error);

View File

@ -210,19 +210,62 @@ export class DynamicFormService {
}
}
/**
* VIEW인 (base) ,
*/
async resolveBaseTable(tableName: string): Promise<string> {
try {
const result = await query<{ table_type: string }>(
`SELECT table_type FROM information_schema.tables
WHERE table_name = $1 AND table_schema = 'public'`,
[tableName]
);
if (result.length === 0 || result[0].table_type !== 'VIEW') {
return tableName;
}
// VIEW의 FROM 절에서 첫 번째 테이블을 추출
const viewDef = await query<{ view_definition: string }>(
`SELECT view_definition FROM information_schema.views
WHERE table_name = $1 AND table_schema = 'public'`,
[tableName]
);
if (viewDef.length > 0) {
const definition = viewDef[0].view_definition;
// PostgreSQL은 뷰 정의를 "FROM (테이블명 별칭 LEFT JOIN ...)" 형태로 저장
const fromMatch = definition.match(/FROM\s+\(?(?:public\.)?(\w+)\s/i);
if (fromMatch) {
const baseTable = fromMatch[1];
console.log(`🔄 VIEW ${tableName} → 원본 테이블 ${baseTable} 으로 전환`);
return baseTable;
}
}
return tableName;
} catch (error) {
console.error(`❌ VIEW 원본 테이블 조회 실패:`, error);
return tableName;
}
}
/**
* ( )
*/
async saveFormData(
screenId: number,
tableName: string,
tableNameInput: string,
data: Record<string, any>,
ipAddress?: string
): Promise<FormDataResult> {
// VIEW인 경우 원본 테이블로 전환
const tableName = await this.resolveBaseTable(tableNameInput);
try {
console.log("💾 서비스: 실제 테이블에 폼 데이터 저장 시작:", {
screenId,
tableName,
originalTable: tableNameInput !== tableName ? tableNameInput : undefined,
data,
});
@ -813,14 +856,17 @@ export class DynamicFormService {
*/
async updateFormDataPartial(
id: string | number, // 🔧 UUID 문자열도 지원
tableName: string,
tableNameInput: string,
originalData: Record<string, any>,
newData: Record<string, any>
): Promise<PartialUpdateResult> {
// VIEW인 경우 원본 테이블로 전환
const tableName = await this.resolveBaseTable(tableNameInput);
try {
console.log("🔄 서비스: 부분 업데이트 시작:", {
id,
tableName,
originalTable: tableNameInput !== tableName ? tableNameInput : undefined,
originalData,
newData,
});
@ -1008,13 +1054,16 @@ export class DynamicFormService {
*/
async updateFormData(
id: string | number,
tableName: string,
tableNameInput: string,
data: Record<string, any>
): Promise<FormDataResult> {
// VIEW인 경우 원본 테이블로 전환
const tableName = await this.resolveBaseTable(tableNameInput);
try {
console.log("🔄 서비스: 실제 테이블에서 폼 데이터 업데이트 시작:", {
id,
tableName,
originalTable: tableNameInput !== tableName ? tableNameInput : undefined,
data,
});
@ -1033,6 +1082,9 @@ export class DynamicFormService {
if (tableColumns.includes("updated_at")) {
dataToUpdate.updated_at = new Date();
}
if (tableColumns.includes("updated_date")) {
dataToUpdate.updated_date = new Date();
}
if (tableColumns.includes("regdate") && !dataToUpdate.regdate) {
dataToUpdate.regdate = new Date();
}
@ -1212,9 +1264,13 @@ export class DynamicFormService {
screenId?: number
): Promise<void> {
try {
// VIEW인 경우 원본 테이블로 전환 (VIEW에는 기본키가 없으므로)
const actualTable = await this.resolveBaseTable(tableName);
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
id,
tableName,
tableName: actualTable,
originalTable: tableName !== actualTable ? tableName : undefined,
});
// 1. 먼저 테이블의 기본키 컬럼명과 데이터 타입을 동적으로 조회
@ -1232,15 +1288,15 @@ export class DynamicFormService {
`;
console.log("🔍 기본키 조회 SQL:", primaryKeyQuery);
console.log("🔍 테이블명:", tableName);
console.log("🔍 테이블명:", actualTable);
const primaryKeyResult = await query<{
column_name: string;
data_type: string;
}>(primaryKeyQuery, [tableName]);
}>(primaryKeyQuery, [actualTable]);
if (!primaryKeyResult || primaryKeyResult.length === 0) {
throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`);
throw new Error(`테이블 ${actualTable}의 기본키를 찾을 수 없습니다.`);
}
const primaryKeyInfo = primaryKeyResult[0];
@ -1272,7 +1328,7 @@ export class DynamicFormService {
// 3. 동적으로 발견된 기본키와 타입 캐스팅을 사용한 DELETE SQL 생성
const deleteQuery = `
DELETE FROM ${tableName}
DELETE FROM ${actualTable}
WHERE ${primaryKeyColumn} = $1${typeCastSuffix}
RETURNING *
`;
@ -1290,6 +1346,11 @@ export class DynamicFormService {
return res.rows;
});
// 삭제된 행이 없으면 레코드를 찾을 수 없는 것
if (!result || !Array.isArray(result) || result.length === 0) {
throw new Error(`테이블 ${actualTable}에서 ID '${id}'에 해당하는 레코드를 찾을 수 없습니다.`);
}
console.log("✅ 서비스: 실제 테이블 삭제 성공:", result);
// 🔥 조건부 연결 실행 (DELETE 트리거)

View File

@ -16,16 +16,18 @@ export class EntityJoinService {
* Entity
* @param tableName
* @param screenEntityConfigs ()
* @param companyCode ( , )
*/
async detectEntityJoins(
tableName: string,
screenEntityConfigs?: Record<string, any>
screenEntityConfigs?: Record<string, any>,
companyCode?: string
): Promise<EntityJoinConfig[]> {
try {
logger.info(`Entity 컬럼 감지 시작: ${tableName}`);
logger.info(`Entity 컬럼 감지 시작: ${tableName} (companyCode: ${companyCode || 'all'})`);
// table_type_columns에서 entity 및 category 타입인 컬럼들 조회
// company_code = '*' (공통 설정) 우선 조회
// 회사코드가 있으면 해당 회사 + '*' 만 조회, 회사별 우선
const entityColumns = await query<{
column_name: string;
input_type: string;
@ -33,14 +35,17 @@ export class EntityJoinService {
reference_column: string;
display_column: string | null;
}>(
`SELECT column_name, input_type, reference_table, reference_column, display_column
`SELECT DISTINCT ON (column_name)
column_name, input_type, reference_table, reference_column, display_column
FROM table_type_columns
WHERE table_name = $1
AND input_type IN ('entity', 'category')
AND company_code = '*'
AND reference_table IS NOT NULL
AND reference_table != ''`,
[tableName]
AND reference_table != ''
${companyCode ? `AND company_code IN ($2, '*')` : ''}
ORDER BY column_name,
CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
companyCode ? [tableName, companyCode] : [tableName]
);
logger.info(`🔍 Entity 컬럼 조회 결과: ${entityColumns.length}개 발견`);
@ -272,7 +277,8 @@ export class EntityJoinService {
orderBy: string = "",
limit?: number,
offset?: number,
columnTypes?: Map<string, string> // 컬럼명 → 데이터 타입 매핑
columnTypes?: Map<string, string>, // 컬럼명 → 데이터 타입 매핑
referenceTableColumns?: Map<string, string[]> // 🆕 참조 테이블별 전체 컬럼 목록
): { query: string; aliasMap: Map<string, string> } {
try {
// 기본 SELECT 컬럼들 (날짜는 YYYY-MM-DD 형식, 나머지는 TEXT 캐스팅)
@ -338,115 +344,100 @@ export class EntityJoinService {
);
});
// 🔧 _label 별칭 중복 방지를 위한 Set
// 같은 sourceColumn에서 여러 조인 설정이 있을 때 _label은 첫 번째만 생성
const generatedLabelAliases = new Set<string>();
// 🔧 생성된 별칭 중복 방지를 위한 Set
const generatedAliases = new Set<string>();
const joinColumns = joinConfigs
const joinColumns = uniqueReferenceTableConfigs
.map((config) => {
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
const alias = aliasMap.get(aliasKey);
const displayColumns = config.displayColumns || [
config.displayColumn,
];
const separator = config.separator || " - ";
// 결과 컬럼 배열 (aliasColumn + _label 필드)
const resultColumns: string[] = [];
if (displayColumns.length === 0 || !displayColumns[0]) {
// displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우
// 조인 테이블의 referenceColumn을 기본값으로 사용
resultColumns.push(
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`
);
} else if (displayColumns.length === 1) {
// 단일 컬럼인 경우
const col = displayColumns[0];
// 🆕 참조 테이블의 전체 컬럼 목록이 있으면 모든 컬럼을 SELECT
const refTableCols = referenceTableColumns?.get(
`${config.referenceTable}:${config.sourceColumn}`
) || referenceTableColumns?.get(config.referenceTable);
// ✅ 개선: referenceTable이 설정되어 있으면 조인 테이블에서 가져옴
// 이렇게 하면 item_info.size, item_info.material 등 모든 조인 테이블 컬럼 지원
const isJoinTableColumn =
config.referenceTable && config.referenceTable !== tableName;
if (refTableCols && refTableCols.length > 0) {
// 메타 컬럼은 제외 (메인 테이블과 중복되거나 불필요)
const skipColumns = new Set(["company_code", "created_date", "updated_date", "writer"]);
for (const col of refTableCols) {
if (skipColumns.has(col)) continue;
const colAlias = `${config.sourceColumn}_${col}`;
if (generatedAliases.has(colAlias)) continue;
if (isJoinTableColumn) {
resultColumns.push(
`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`
`COALESCE(${alias}."${col}"::TEXT, '') AS "${colAlias}"`
);
generatedAliases.add(colAlias);
}
// _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용)
// sourceColumn_label 형식으로 추가
// 🔧 중복 방지: 같은 sourceColumn에서 _label은 첫 번째만 생성
const labelAlias = `${config.sourceColumn}_label`;
if (!generatedLabelAliases.has(labelAlias)) {
resultColumns.push(
`COALESCE(${alias}.${col}::TEXT, '') AS ${labelAlias}`
);
generatedLabelAliases.add(labelAlias);
}
// 🆕 referenceColumn (PK)도 항상 SELECT (parentDataMapping용)
// 예: customer_code, item_number 등
// col과 동일해도 별도의 alias로 추가 (customer_code as customer_code)
// 🔧 중복 방지: referenceColumn도 한 번만 추가
const refColAlias = config.referenceColumn;
if (!generatedLabelAliases.has(refColAlias)) {
resultColumns.push(
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${refColAlias}`
);
generatedLabelAliases.add(refColAlias);
}
} else {
// _label 필드도 추가 (기존 호환성)
const labelAlias = `${config.sourceColumn}_label`;
if (!generatedAliases.has(labelAlias)) {
// 표시용 컬럼 자동 감지: *_name > name > label > referenceColumn
const nameCol = refTableCols.find((c) => c.endsWith("_name") && c !== "company_name");
const displayCol = nameCol || refTableCols.find((c) => c === "name") || config.referenceColumn;
resultColumns.push(
`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`
`COALESCE(${alias}."${displayCol}"::TEXT, '') AS "${labelAlias}"`
);
generatedAliases.add(labelAlias);
}
} else {
// 🆕 여러 컬럼인 경우 각 컬럼을 개별 alias로 반환 (합치지 않음)
// 예: item_info.standard_price → sourceColumn_standard_price (item_id_standard_price)
displayColumns.forEach((col) => {
// 🔄 기존 로직 (참조 테이블 컬럼 목록이 없는 경우 - fallback)
const displayColumns = config.displayColumns || [config.displayColumn];
if (displayColumns.length === 0 || !displayColumns[0]) {
resultColumns.push(
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`
);
} else if (displayColumns.length === 1) {
const col = displayColumns[0];
const isJoinTableColumn =
config.referenceTable && config.referenceTable !== tableName;
const individualAlias = `${config.sourceColumn}_${col}`;
// 🔧 중복 방지: 같은 alias가 이미 생성되었으면 스킵
if (generatedLabelAliases.has(individualAlias)) {
return;
}
if (isJoinTableColumn) {
// 조인 테이블 컬럼은 조인 별칭 사용
resultColumns.push(
`COALESCE(${alias}.${col}::TEXT, '') AS ${individualAlias}`
`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`
);
const labelAlias = `${config.sourceColumn}_label`;
if (!generatedAliases.has(labelAlias)) {
resultColumns.push(
`COALESCE(${alias}.${col}::TEXT, '') AS ${labelAlias}`
);
generatedAliases.add(labelAlias);
}
} else {
// 기본 테이블 컬럼은 main 별칭 사용
resultColumns.push(
`COALESCE(main.${col}::TEXT, '') AS ${individualAlias}`
`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`
);
}
generatedLabelAliases.add(individualAlias);
});
} else {
displayColumns.forEach((col) => {
const isJoinTableColumn =
config.referenceTable && config.referenceTable !== tableName;
const individualAlias = `${config.sourceColumn}_${col}`;
if (generatedAliases.has(individualAlias)) return;
// 🆕 referenceColumn (PK)도 함께 SELECT (parentDataMapping용)
const isJoinTableColumn =
config.referenceTable && config.referenceTable !== tableName;
if (
isJoinTableColumn &&
!displayColumns.includes(config.referenceColumn) &&
!generatedLabelAliases.has(config.referenceColumn) // 🔧 중복 방지
) {
resultColumns.push(
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}`
);
generatedLabelAliases.add(config.referenceColumn);
if (isJoinTableColumn) {
resultColumns.push(
`COALESCE(${alias}.${col}::TEXT, '') AS ${individualAlias}`
);
} else {
resultColumns.push(
`COALESCE(main.${col}::TEXT, '') AS ${individualAlias}`
);
}
generatedAliases.add(individualAlias);
});
}
}
// 모든 resultColumns를 반환
return resultColumns.join(", ");
})
.filter(Boolean)
.join(", ");
// SELECT 절 구성
@ -466,17 +457,18 @@ export class EntityJoinService {
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링)
if (config.referenceTable === "table_column_category_values") {
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
}
// user_info는 전역 테이블이므로 company_code 조건 없이 조인
if (config.referenceTable === "user_info") {
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT`;
}
// 일반 테이블: company_code가 있으면 같은 회사 데이터만 조인 (멀티테넌시)
// supplier_mng, customer_mng, item_info 등 회사별 데이터 테이블
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.company_code = main.company_code`;
// ::TEXT 캐스팅으로 varchar/integer 등 타입 불일치 방지
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.company_code = main.company_code`;
})
.join("\n");
@ -589,6 +581,7 @@ export class EntityJoinService {
logger.info("🔍 조인 설정 검증 상세:", {
sourceColumn: config.sourceColumn,
referenceTable: config.referenceTable,
referenceColumn: config.referenceColumn,
displayColumns: config.displayColumns,
displayColumn: config.displayColumn,
aliasColumn: config.aliasColumn,
@ -607,7 +600,45 @@ export class EntityJoinService {
return false;
}
// 참조 컬럼 존재 확인 (displayColumns[0] 사용)
// 참조 컬럼(JOIN 키) 존재 확인 - 참조 테이블에 reference_column이 실제로 있는지 검증
if (config.referenceColumn) {
const refColExists = await query<{ exists: number }>(
`SELECT 1 as exists FROM information_schema.columns
WHERE table_name = $1
AND column_name = $2
LIMIT 1`,
[config.referenceTable, config.referenceColumn]
);
if (refColExists.length === 0) {
// reference_column이 없으면 'id' 컬럼으로 자동 대체 시도
const idColExists = await query<{ exists: number }>(
`SELECT 1 as exists FROM information_schema.columns
WHERE table_name = $1
AND column_name = 'id'
LIMIT 1`,
[config.referenceTable]
);
if (idColExists.length > 0) {
logger.warn(
`⚠️ 참조 컬럼 ${config.referenceTable}.${config.referenceColumn}이 존재하지 않음 → 'id'로 자동 대체`
);
config.referenceColumn = "id";
} else {
logger.warn(
`❌ 참조 컬럼 ${config.referenceTable}.${config.referenceColumn}이 존재하지 않고 'id' 컬럼도 없음 → 스킵`
);
return false;
}
} else {
logger.info(
`✅ 참조 컬럼 확인 완료: ${config.referenceTable}.${config.referenceColumn}`
);
}
}
// 표시 컬럼 존재 확인 (displayColumns[0] 사용)
const displayColumn = config.displayColumns?.[0] || config.displayColumn;
logger.info(
`🔍 표시 컬럼 확인: ${displayColumn} (from displayColumns: ${config.displayColumns}, displayColumn: ${config.displayColumn})`
@ -695,10 +726,10 @@ export class EntityJoinService {
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만)
if (config.referenceTable === "table_column_category_values") {
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
}
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT`;
})
.join("\n");
@ -725,7 +756,7 @@ export class EntityJoinService {
/**
* (UI용)
*/
async getReferenceTableColumns(tableName: string): Promise<
async getReferenceTableColumns(tableName: string, companyCode?: string): Promise<
Array<{
columnName: string;
displayName: string;
@ -750,16 +781,19 @@ export class EntityJoinService {
);
// 2. table_type_columns 테이블에서 라벨과 input_type 정보 조회
// 회사코드가 있으면 해당 회사 + '*' 만, 회사별 우선
const columnLabels = await query<{
column_name: string;
column_label: string | null;
input_type: string | null;
}>(
`SELECT column_name, column_label, input_type
`SELECT DISTINCT ON (column_name) column_name, column_label, input_type
FROM table_type_columns
WHERE table_name = $1
AND company_code = '*'`,
[tableName]
${companyCode ? `AND company_code IN ($2, '*')` : ''}
ORDER BY column_name,
CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
companyCode ? [tableName, companyCode] : [tableName]
);
// 3. 라벨 및 inputType 정보를 맵으로 변환

View File

@ -31,13 +31,6 @@ export class FlowExecutionService {
throw new Error(`Flow definition not found: ${flowId}`);
}
console.log("🔍 [getStepDataCount] Flow Definition:", {
flowId,
dbSourceType: flowDef.dbSourceType,
dbConnectionId: flowDef.dbConnectionId,
tableName: flowDef.tableName,
});
// 2. 플로우 단계 조회
const step = await this.flowStepService.findById(stepId);
if (!step) {
@ -59,36 +52,21 @@ export class FlowExecutionService {
// 5. 카운트 쿼리 실행 (내부 또는 외부 DB)
const query = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${where}`;
console.log("🔍 [getStepDataCount] Query Info:", {
tableName,
query,
params,
isExternal: flowDef.dbSourceType === "external",
connectionId: flowDef.dbConnectionId,
});
let result: any;
if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) {
// 외부 DB 조회
console.log(
"✅ [getStepDataCount] Using EXTERNAL DB:",
flowDef.dbConnectionId
);
const externalResult = await executeExternalQuery(
flowDef.dbConnectionId,
query,
params
);
console.log("📦 [getStepDataCount] External result:", externalResult);
result = externalResult.rows;
} else {
// 내부 DB 조회
console.log("✅ [getStepDataCount] Using INTERNAL DB");
result = await db.query(query, params);
}
const count = parseInt(result[0].count || result[0].COUNT);
console.log("✅ [getStepDataCount] Final count:", count);
return count;
}

View File

@ -93,13 +93,6 @@ export class FlowStepService {
id: number,
request: UpdateFlowStepRequest
): Promise<FlowStep | null> {
console.log("🔧 FlowStepService.update called with:", {
id,
statusColumn: request.statusColumn,
statusValue: request.statusValue,
fullRequest: JSON.stringify(request),
});
// 조건 검증
if (request.conditionJson) {
FlowConditionParser.validateConditionGroup(request.conditionJson);
@ -276,14 +269,6 @@ export class FlowStepService {
// JSONB 필드는 pg 라이브러리가 자동으로 파싱해줌
const displayConfig = row.display_config;
// 디버깅 로그 (개발 환경에서만)
if (displayConfig && process.env.NODE_ENV === "development") {
console.log(`🔍 [FlowStep ${row.id}] displayConfig:`, {
type: typeof displayConfig,
value: displayConfig,
});
}
return {
id: row.id,
flowDefinitionId: row.flow_definition_id,

View File

@ -78,6 +78,7 @@ export interface ExcelUploadResult {
masterInserted: number;
masterUpdated: number;
detailInserted: number;
detailUpdated: number;
detailDeleted: number;
errors: string[];
}
@ -310,6 +311,7 @@ class MasterDetailExcelService {
sourceColumn: string;
alias: string;
displayColumn: string;
tableAlias: string; // "m" (마스터) 또는 "d" (디테일) - JOIN 시 소스 테이블 구분
}> = [];
// SELECT 절 구성
@ -332,6 +334,7 @@ class MasterDetailExcelService {
sourceColumn: fkColumn.sourceColumn,
alias,
displayColumn,
tableAlias: "m", // 마스터 테이블에서 조인
});
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
} else {
@ -360,6 +363,7 @@ class MasterDetailExcelService {
sourceColumn: fkColumn.sourceColumn,
alias,
displayColumn,
tableAlias: "d", // 디테일 테이블에서 조인
});
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
} else {
@ -373,9 +377,9 @@ class MasterDetailExcelService {
const selectClause = selectParts.join(", ");
// 엔티티 조인 절 구성
// 엔티티 조인 절 구성 (마스터/디테일 테이블 alias 구분)
const entityJoinClauses = entityJoins.map(ej =>
`LEFT JOIN "${ej.refTable}" ${ej.alias} ON m."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"`
`LEFT JOIN "${ej.refTable}" ${ej.alias} ON ${ej.tableAlias}."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"`
).join("\n ");
// WHERE 절 구성
@ -410,6 +414,16 @@ class MasterDetailExcelService {
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// 디테일 테이블의 id 컬럼 존재 여부 확인 (user_info 등 id가 없는 테이블 대응)
const detailIdCheck = await queryOne<{ exists: boolean }>(
`SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'id'
) as exists`,
[detailTable]
);
const detailOrderColumn = detailIdCheck?.exists ? `d."id"` : `d."${detailFkColumn}"`;
// JOIN 쿼리 실행
const sql = `
SELECT ${selectClause}
@ -419,7 +433,7 @@ class MasterDetailExcelService {
AND m.company_code = d.company_code
${entityJoinClauses}
${whereClause}
ORDER BY m."${masterKeyColumn}", d.id
ORDER BY m."${masterKeyColumn}", ${detailOrderColumn}
`;
logger.info(`마스터-디테일 JOIN 쿼리:`, { sql, params });
@ -478,14 +492,172 @@ class MasterDetailExcelService {
}
}
/**
* , ID를
* , (*) fallback
*/
private async detectNumberingRuleForColumn(
tableName: string,
columnName: string,
companyCode?: string
): Promise<{ numberingRuleId: string } | null> {
try {
// 회사별 설정 우선, 공통 설정 fallback (company_code DESC로 회사별이 먼저)
const companyCondition = companyCode && companyCode !== "*"
? `AND company_code IN ($3, '*')`
: `AND company_code = '*'`;
const params = companyCode && companyCode !== "*"
? [tableName, columnName, companyCode]
: [tableName, columnName];
const result = await query<any>(
`SELECT input_type, detail_settings, company_code
FROM table_type_columns
WHERE table_name = $1 AND column_name = $2 ${companyCondition}
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
params
);
// 채번 타입인 행 찾기 (회사별 우선)
for (const row of result) {
if (row.input_type === "numbering") {
const settings = typeof row.detail_settings === "string"
? JSON.parse(row.detail_settings || "{}")
: row.detail_settings;
if (settings?.numberingRuleId) {
return { numberingRuleId: settings.numberingRuleId };
}
}
}
return null;
} catch (error) {
logger.error(`채번 컬럼 감지 실패: ${tableName}.${columnName}`, error);
return null;
}
}
/**
*
* , (*) fallback
* @returns Map<columnName, numberingRuleId>
*/
private async detectAllNumberingColumns(
tableName: string,
companyCode?: string
): Promise<Map<string, string>> {
const numberingCols = new Map<string, string>();
try {
const companyCondition = companyCode && companyCode !== "*"
? `AND company_code IN ($2, '*')`
: `AND company_code = '*'`;
const params = companyCode && companyCode !== "*"
? [tableName, companyCode]
: [tableName];
const result = await query<any>(
`SELECT column_name, detail_settings, company_code
FROM table_type_columns
WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition}
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
params
);
// 컬럼별로 회사 설정 우선 적용
for (const row of result) {
if (numberingCols.has(row.column_name)) continue; // 이미 회사별 설정이 있으면 스킵
const settings = typeof row.detail_settings === "string"
? JSON.parse(row.detail_settings || "{}")
: row.detail_settings;
if (settings?.numberingRuleId) {
numberingCols.set(row.column_name, settings.numberingRuleId);
}
}
if (numberingCols.size > 0) {
logger.info(`테이블 ${tableName} 채번 컬럼 감지:`, Object.fromEntries(numberingCols));
}
} catch (error) {
logger.error(`테이블 ${tableName} 채번 컬럼 감지 실패:`, error);
}
return numberingCols;
}
/**
* (UPSERT )
* PK가 , auto-increment 'id'
* @returns ( INSERT만 )
*/
private async detectUniqueKeyColumns(
client: any,
tableName: string
): Promise<string[]> {
try {
// 1. PK 컬럼 조회
const pkResult = await client.query(
`SELECT array_agg(a.attname ORDER BY x.n) AS columns
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
CROSS JOIN LATERAL unnest(c.conkey) WITH ORDINALITY AS x(attnum, n)
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum
WHERE n.nspname = 'public' AND t.relname = $1 AND c.contype = 'p'`,
[tableName]
);
if (pkResult.rows.length > 0 && pkResult.rows[0].columns) {
const pkCols: string[] = typeof pkResult.rows[0].columns === "string"
? pkResult.rows[0].columns.replace(/[{}]/g, "").split(",").map((s: string) => s.trim())
: pkResult.rows[0].columns;
// PK가 'id' 하나만 있으면 auto-increment이므로 사용 불가
if (!(pkCols.length === 1 && pkCols[0] === "id")) {
logger.info(`디테일 테이블 ${tableName} 고유 키 (PK): ${pkCols.join(", ")}`);
return pkCols;
}
}
// 2. PK가 'id'뿐이면 유니크 인덱스 탐색
const uqResult = await client.query(
`SELECT array_agg(a.attname ORDER BY x.n) AS columns
FROM pg_index ix
JOIN pg_class t ON t.oid = ix.indrelid
JOIN pg_class i ON i.oid = ix.indexrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS x(attnum, n)
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum
WHERE n.nspname = 'public' AND t.relname = $1
AND ix.indisunique = true AND ix.indisprimary = false
GROUP BY i.relname
LIMIT 1`,
[tableName]
);
if (uqResult.rows.length > 0 && uqResult.rows[0].columns) {
const uqCols: string[] = typeof uqResult.rows[0].columns === "string"
? uqResult.rows[0].columns.replace(/[{}]/g, "").split(",").map((s: string) => s.trim())
: uqResult.rows[0].columns;
logger.info(`디테일 테이블 ${tableName} 고유 키 (UNIQUE INDEX): ${uqCols.join(", ")}`);
return uqCols;
}
logger.info(`디테일 테이블 ${tableName} 고유 키 없음 → INSERT 전용`);
return [];
} catch (error) {
logger.error(`디테일 테이블 ${tableName} 고유 키 감지 실패:`, error);
return [];
}
}
/**
* - ( )
*
* :
* 1.
* 2. UPSERT
* 3.
* 4. INSERT
* 1.
* 2-A. 경우: 다른 INSERT
* 2-B. 경우: 마스터 UPSERT
* 3. UPSERT ( )
*/
async uploadJoinedData(
relation: MasterDetailRelation,
@ -498,6 +670,7 @@ class MasterDetailExcelService {
masterInserted: 0,
masterUpdated: 0,
detailInserted: 0,
detailUpdated: 0,
detailDeleted: 0,
errors: [],
};
@ -510,118 +683,322 @@ class MasterDetailExcelService {
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
// 1. 데이터를 마스터 키로 그룹화
const groupedData = new Map<string, Record<string, any>[]>();
for (const row of data) {
const masterKey = row[masterKeyColumn];
if (!masterKey) {
result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`);
continue;
}
// 마스터/디테일 테이블의 실제 컬럼 존재 여부 확인 (writer, created_date 등 하드코딩 방지)
const masterColsResult = await client.query(
`SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`,
[masterTable]
);
const masterExistingCols = new Set(masterColsResult.rows.map((r: any) => r.column_name));
if (!groupedData.has(masterKey)) {
groupedData.set(masterKey, []);
const detailColsResult = await client.query(
`SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`,
[detailTable]
);
const detailExistingCols = new Set(detailColsResult.rows.map((r: any) => r.column_name));
// 마스터 키 컬럼의 채번 규칙 자동 감지 (회사별 설정 우선)
const numberingInfo = await this.detectNumberingRuleForColumn(masterTable, masterKeyColumn, companyCode);
const isAutoNumbering = !!numberingInfo;
logger.info(`마스터 키 채번 감지:`, {
masterKeyColumn,
isAutoNumbering,
numberingRuleId: numberingInfo?.numberingRuleId
});
// 데이터 그룹화
const groupedData = new Map<string, Record<string, any>[]>();
if (isAutoNumbering) {
// 채번 모드: 마스터 키 제외한 다른 마스터 컬럼 값으로 그룹화
const otherMasterCols = masterColumns.filter(c => c.name !== masterKeyColumn).map(c => c.name);
for (const row of data) {
// 다른 마스터 컬럼 값들을 조합해 그룹 키 생성
const groupKey = otherMasterCols.map(col => row[col] ?? "").join("|||");
if (!groupedData.has(groupKey)) {
groupedData.set(groupKey, []);
}
groupedData.get(groupKey)!.push(row);
}
groupedData.get(masterKey)!.push(row);
logger.info(`채번 모드 그룹화 완료: ${groupedData.size}개 그룹 (기준: ${otherMasterCols.join(", ")})`);
} else {
// 일반 모드: 마스터 키 값으로 그룹화
for (const row of data) {
const masterKey = row[masterKeyColumn];
if (!masterKey) {
result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`);
continue;
}
if (!groupedData.has(masterKey)) {
groupedData.set(masterKey, []);
}
groupedData.get(masterKey)!.push(row);
}
logger.info(`일반 모드 그룹화 완료: ${groupedData.size}개 마스터 그룹`);
}
logger.info(`데이터 그룹화 완료: ${groupedData.size}개 마스터 그룹`);
// 디테일 테이블의 채번 컬럼 사전 감지 (1회 쿼리로 모든 채번 컬럼 조회)
const detailNumberingCols = await this.detectAllNumberingColumns(detailTable, companyCode);
// 마스터 테이블의 비-키 채번 컬럼도 감지
const masterNumberingCols = await this.detectAllNumberingColumns(masterTable, companyCode);
// 2. 각 그룹 처리
for (const [masterKey, rows] of groupedData.entries()) {
// 디테일 테이블의 고유 키 컬럼 감지 (UPSERT 매칭용)
// PK가 비즈니스 키인 경우 사용, auto-increment 'id'만 있으면 유니크 인덱스 탐색
const detailUniqueKeyCols = await this.detectUniqueKeyColumns(client, detailTable);
// 각 그룹 처리
for (const [groupKey, rows] of groupedData.entries()) {
try {
// 2a. 마스터 데이터 추출 (첫 번째 행에서)
const masterData: Record<string, any> = {};
// 마스터 키 결정 (채번이면 자동 생성, 아니면 그룹 키 자체가 마스터 키)
let masterKey: string;
let existingMasterKey: string | null = null;
// 마스터 데이터 추출 (첫 번째 행에서, 키 제외)
const masterDataWithoutKey: Record<string, any> = {};
for (const col of masterColumns) {
if (col.name === masterKeyColumn) continue;
if (rows[0][col.name] !== undefined) {
masterData[col.name] = rows[0][col.name];
masterDataWithoutKey[col.name] = rows[0][col.name];
}
}
// 회사 코드, 작성자 추가
masterData.company_code = companyCode;
if (userId) {
if (isAutoNumbering) {
// 채번 모드: 동일한 마스터가 이미 DB에 있는지 먼저 확인
// 마스터 키 제외한 다른 컬럼들로 매칭 (예: dept_name이 같은 부서가 있는지)
const matchCols = Object.keys(masterDataWithoutKey)
.filter(k => k !== "company_code" && k !== "writer" && k !== "created_date" && k !== "updated_date" && k !== "id"
&& masterDataWithoutKey[k] !== undefined && masterDataWithoutKey[k] !== null && masterDataWithoutKey[k] !== "");
if (matchCols.length > 0) {
const whereClause = matchCols.map((col, i) => `"${col}" = $${i + 1}`).join(" AND ");
const companyIdx = matchCols.length + 1;
const matchResult = await client.query(
`SELECT "${masterKeyColumn}" FROM "${masterTable}" WHERE ${whereClause} AND company_code = $${companyIdx} LIMIT 1`,
[...matchCols.map(k => masterDataWithoutKey[k]), companyCode]
);
if (matchResult.rows.length > 0) {
existingMasterKey = matchResult.rows[0][masterKeyColumn];
logger.info(`채번 모드: 기존 마스터 발견 → ${masterKeyColumn}=${existingMasterKey} (매칭: ${matchCols.map(c => `${c}=${masterDataWithoutKey[c]}`).join(", ")})`);
}
}
if (existingMasterKey) {
// 기존 마스터 사용 (UPDATE)
masterKey = existingMasterKey;
const updateKeys = matchCols.filter(k => k !== masterKeyColumn);
if (updateKeys.length > 0) {
const setClauses = updateKeys.map((k, i) => `"${k}" = $${i + 1}`);
const setValues = updateKeys.map(k => masterDataWithoutKey[k]);
const updatedDateClause = masterExistingCols.has("updated_date") ? `, updated_date = NOW()` : "";
await client.query(
`UPDATE "${masterTable}" SET ${setClauses.join(", ")}${updatedDateClause} WHERE "${masterKeyColumn}" = $${setValues.length + 1} AND company_code = $${setValues.length + 2}`,
[...setValues, masterKey, companyCode]
);
}
result.masterUpdated++;
} else {
// 새 마스터 생성 (채번)
masterKey = await this.generateNumberWithRule(client, numberingInfo!.numberingRuleId, companyCode);
logger.info(`채번 생성: ${masterKey}`);
}
} else {
masterKey = groupKey;
}
// 마스터 데이터 조립
const masterData: Record<string, any> = {};
masterData[masterKeyColumn] = masterKey;
Object.assign(masterData, masterDataWithoutKey);
// 회사 코드, 작성자 추가 (테이블에 해당 컬럼이 있을 때만)
if (masterExistingCols.has("company_code")) {
masterData.company_code = companyCode;
}
if (userId && masterExistingCols.has("writer")) {
masterData.writer = userId;
}
// 2b. 마스터 UPSERT
const existingMaster = await client.query(
`SELECT id FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`,
[masterKey, companyCode]
);
if (existingMaster.rows.length > 0) {
// UPDATE
const updateCols = Object.keys(masterData)
.filter(k => k !== masterKeyColumn && k !== "id")
.map((k, i) => `"${k}" = $${i + 1}`);
const updateValues = Object.keys(masterData)
.filter(k => k !== masterKeyColumn && k !== "id")
.map(k => masterData[k]);
if (updateCols.length > 0) {
await client.query(
`UPDATE "${masterTable}"
SET ${updateCols.join(", ")}, updated_date = NOW()
WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`,
[...updateValues, masterKey, companyCode]
);
// 마스터 비-키 채번 컬럼 자동 생성 (매핑되지 않은 경우)
for (const [colName, ruleId] of masterNumberingCols) {
if (colName === masterKeyColumn) continue;
if (!masterData[colName] || masterData[colName] === "") {
const generatedValue = await this.generateNumberWithRule(client, ruleId, companyCode);
masterData[colName] = generatedValue;
logger.info(`마스터 채번 생성: ${masterTable}.${colName} = ${generatedValue}`);
}
result.masterUpdated++;
} else {
// INSERT
const insertCols = Object.keys(masterData);
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
const insertValues = insertCols.map(k => masterData[k]);
await client.query(
`INSERT INTO "${masterTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
insertValues
);
result.masterInserted++;
}
// 2c. 기존 디테일 삭제
const deleteResult = await client.query(
`DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`,
[masterKey, companyCode]
);
result.detailDeleted += deleteResult.rowCount || 0;
// INSERT SQL 생성 헬퍼 (created_date 존재 시만 추가)
const buildInsertSQL = (table: string, data: Record<string, any>, existingCols: Set<string>) => {
const cols = Object.keys(data);
const hasCreatedDate = existingCols.has("created_date");
const colList = hasCreatedDate ? [...cols, "created_date"] : cols;
const placeholders = cols.map((_, i) => `$${i + 1}`);
const valList = hasCreatedDate ? [...placeholders, "NOW()"] : placeholders;
const values = cols.map(k => data[k]);
return {
sql: `INSERT INTO "${table}" (${colList.map(c => `"${c}"`).join(", ")}) VALUES (${valList.join(", ")})`,
values,
};
};
// 2d. 새 디테일 INSERT
if (isAutoNumbering && !existingMasterKey) {
// 채번 모드 + 새 마스터: INSERT
const { sql, values } = buildInsertSQL(masterTable, masterData, masterExistingCols);
await client.query(sql, values);
result.masterInserted++;
} else if (!isAutoNumbering) {
// 일반 모드: UPSERT (있으면 UPDATE, 없으면 INSERT)
const existingMaster = await client.query(
`SELECT 1 FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`,
[masterKey, companyCode]
);
if (existingMaster.rows.length > 0) {
const updateCols = Object.keys(masterData)
.filter(k => k !== masterKeyColumn && k !== "id")
.map((k, i) => `"${k}" = $${i + 1}`);
const updateValues = Object.keys(masterData)
.filter(k => k !== masterKeyColumn && k !== "id")
.map(k => masterData[k]);
if (updateCols.length > 0) {
const updatedDateClause = masterExistingCols.has("updated_date") ? `, updated_date = NOW()` : "";
await client.query(
`UPDATE "${masterTable}"
SET ${updateCols.join(", ")}${updatedDateClause}
WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`,
[...updateValues, masterKey, companyCode]
);
}
result.masterUpdated++;
} else {
const { sql, values } = buildInsertSQL(masterTable, masterData, masterExistingCols);
await client.query(sql, values);
result.masterInserted++;
}
}
// 디테일 개별 행 UPSERT 처리
for (const row of rows) {
const detailData: Record<string, any> = {};
// FK 컬럼 추가
// FK 컬럼에 마스터 키 주입
detailData[detailFkColumn] = masterKey;
detailData.company_code = companyCode;
if (userId) {
if (detailExistingCols.has("company_code")) {
detailData.company_code = companyCode;
}
if (userId && detailExistingCols.has("writer")) {
detailData.writer = userId;
}
// 디테일 컬럼 데이터 추출
// 디테일 컬럼 데이터 추출 (분할 패널 설정 컬럼 기준)
for (const col of detailColumns) {
if (row[col.name] !== undefined) {
detailData[col.name] = row[col.name];
}
}
const insertCols = Object.keys(detailData);
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
const insertValues = insertCols.map(k => detailData[k]);
// 분할 패널에 없지만 엑셀에서 매핑된 디테일 컬럼도 포함
// (user_id 등 화면에 표시되지 않지만 NOT NULL인 컬럼 처리)
const detailColNames = new Set(detailColumns.map(c => c.name));
const skipCols = new Set([
detailFkColumn, masterKeyColumn,
"company_code", "writer", "created_date", "updated_date", "id",
]);
for (const key of Object.keys(row)) {
if (!detailColNames.has(key) && !skipCols.has(key) && detailExistingCols.has(key) && row[key] !== undefined && row[key] !== null && row[key] !== "") {
const isMasterCol = masterColumns.some(mc => mc.name === key);
if (!isMasterCol) {
detailData[key] = row[key];
}
}
}
await client.query(
`INSERT INTO "${detailTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
insertValues
);
result.detailInserted++;
// 디테일 채번 컬럼 자동 생성 (매핑되지 않은 채번 컬럼에 값 주입)
for (const [colName, ruleId] of detailNumberingCols) {
if (!detailData[colName] || detailData[colName] === "") {
const generatedValue = await this.generateNumberWithRule(client, ruleId, companyCode);
detailData[colName] = generatedValue;
logger.info(`디테일 채번 생성: ${detailTable}.${colName} = ${generatedValue}`);
}
}
// 고유 키 기반 UPSERT: 존재하면 UPDATE, 없으면 INSERT
const hasUniqueKey = detailUniqueKeyCols.length > 0;
const uniqueKeyValues = hasUniqueKey
? detailUniqueKeyCols.map(col => detailData[col])
: [];
// 고유 키 값이 모두 있어야 매칭 가능 (채번으로 생성된 값도 포함)
const canMatch = hasUniqueKey && uniqueKeyValues.every(v => v !== undefined && v !== null && v !== "");
if (canMatch) {
// 기존 행 존재 여부 확인
const whereClause = detailUniqueKeyCols
.map((col, i) => `"${col}" = $${i + 1}`)
.join(" AND ");
const companyParam = detailExistingCols.has("company_code")
? ` AND company_code = $${detailUniqueKeyCols.length + 1}`
: "";
const checkParams = detailExistingCols.has("company_code")
? [...uniqueKeyValues, companyCode]
: uniqueKeyValues;
const existingRow = await client.query(
`SELECT 1 FROM "${detailTable}" WHERE ${whereClause}${companyParam} LIMIT 1`,
checkParams
);
if (existingRow.rows.length > 0) {
// UPDATE: 고유 키와 시스템 컬럼 제외한 나머지 업데이트
const updateExclude = new Set([
...detailUniqueKeyCols, "id", "company_code", "created_date",
]);
const updateKeys = Object.keys(detailData).filter(k => !updateExclude.has(k));
if (updateKeys.length > 0) {
const setClauses = updateKeys.map((k, i) => `"${k}" = $${i + 1}`);
const setValues = updateKeys.map(k => detailData[k]);
const updatedDateClause = detailExistingCols.has("updated_date") ? `, updated_date = NOW()` : "";
const whereParams = detailUniqueKeyCols.map((col, i) => `"${col}" = $${setValues.length + i + 1}`);
const companyWhere = detailExistingCols.has("company_code")
? ` AND company_code = $${setValues.length + detailUniqueKeyCols.length + 1}`
: "";
const allValues = [
...setValues,
...uniqueKeyValues,
...(detailExistingCols.has("company_code") ? [companyCode] : []),
];
await client.query(
`UPDATE "${detailTable}" SET ${setClauses.join(", ")}${updatedDateClause} WHERE ${whereParams.join(" AND ")}${companyWhere}`,
allValues
);
result.detailUpdated = (result.detailUpdated || 0) + 1;
logger.info(`디테일 UPDATE: ${detailUniqueKeyCols.map((c, i) => `${c}=${uniqueKeyValues[i]}`).join(", ")}`);
}
} else {
// INSERT: 새로운 행
const { sql, values } = buildInsertSQL(detailTable, detailData, detailExistingCols);
await client.query(sql, values);
result.detailInserted++;
logger.info(`디테일 INSERT: ${detailUniqueKeyCols.map((c, i) => `${c}=${uniqueKeyValues[i]}`).join(", ")}`);
}
} else {
// 고유 키가 없거나 값이 없으면 INSERT 전용
const { sql, values } = buildInsertSQL(detailTable, detailData, detailExistingCols);
await client.query(sql, values);
result.detailInserted++;
}
}
} catch (error: any) {
result.errors.push(`마스터 키 ${masterKey} 처리 실패: ${error.message}`);
logger.error(`마스터 키 ${masterKey} 처리 실패:`, error);
result.errors.push(`그룹 처리 실패: ${error.message}`);
logger.error(`그룹 처리 실패:`, error);
}
}
@ -632,7 +1009,7 @@ class MasterDetailExcelService {
masterInserted: result.masterInserted,
masterUpdated: result.masterUpdated,
detailInserted: result.detailInserted,
detailDeleted: result.detailDeleted,
detailUpdated: result.detailUpdated,
errors: result.errors.length,
});

View File

@ -60,6 +60,8 @@ export interface ExecutionContext {
buttonContext?: ButtonContext;
// 🆕 현재 실행 중인 소스 노드의 dataSourceType (context-data | table-all)
currentNodeDataSourceType?: string;
// 저장 전 원본 데이터 (after 타이밍에서 DB 기존값 비교용)
originalData?: Record<string, any> | null;
}
export interface ButtonContext {
@ -248,8 +250,14 @@ export class NodeFlowExecutionService {
contextData.selectedRowsData ||
contextData.context?.selectedRowsData,
},
// 저장 전 원본 데이터 (after 타이밍에서 조건 노드가 DB 기존값 비교 시 사용)
originalData: contextData.originalData || null,
};
if (context.originalData) {
logger.info(`📦 저장 전 원본 데이터 전달됨 (originalData 필드 수: ${Object.keys(context.originalData).length})`);
}
logger.info(`📦 실행 컨텍스트:`, {
dataSourceType: context.dataSourceType,
sourceDataCount: context.sourceData?.length || 0,
@ -2830,12 +2838,12 @@ export class NodeFlowExecutionService {
inputData: any,
context: ExecutionContext
): Promise<any> {
const { conditions, logic } = node.data;
const { conditions, logic, targetLookup } = node.data;
logger.info(
`🔍 조건 노드 실행 - inputData 타입: ${typeof inputData}, 배열 여부: ${Array.isArray(inputData)}, 길이: ${Array.isArray(inputData) ? inputData.length : "N/A"}`
);
logger.info(`🔍 조건 개수: ${conditions?.length || 0}, 로직: ${logic}`);
logger.info(`🔍 조건 개수: ${conditions?.length || 0}, 로직: ${logic}, 타겟조회: ${targetLookup ? targetLookup.tableName : "없음"}`);
if (inputData) {
console.log(
@ -2865,6 +2873,9 @@ export class NodeFlowExecutionService {
// 배열의 각 항목에 대해 조건 평가 (EXISTS 조건은 비동기)
for (const item of inputData) {
// 타겟 테이블 조회 (DB 기존값 비교용)
const targetRow = await this.lookupTargetRow(targetLookup, item, context);
const results: boolean[] = [];
for (const condition of conditions) {
@ -2887,9 +2898,14 @@ export class NodeFlowExecutionService {
`🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
);
} else {
// 일반 연산자 처리
// 비교값 결정: static(고정값) / field(같은 데이터 내 필드) / target(DB 기존값)
let compareValue = condition.value;
if (condition.valueType === "field") {
if (condition.valueType === "target" && targetRow) {
compareValue = targetRow[condition.value];
logger.info(
`🎯 타겟(DB) 비교: ${condition.field} (${fieldValue}) vs DB.${condition.value} (${compareValue})`
);
} else if (condition.valueType === "field") {
compareValue = item[condition.value];
logger.info(
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
@ -2931,6 +2947,9 @@ export class NodeFlowExecutionService {
}
// 단일 객체인 경우
// 타겟 테이블 조회 (DB 기존값 비교용)
const targetRow = await this.lookupTargetRow(targetLookup, inputData, context);
const results: boolean[] = [];
for (const condition of conditions) {
@ -2953,9 +2972,14 @@ export class NodeFlowExecutionService {
`🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
);
} else {
// 일반 연산자 처리
// 비교값 결정: static(고정값) / field(같은 데이터 내 필드) / target(DB 기존값)
let compareValue = condition.value;
if (condition.valueType === "field") {
if (condition.valueType === "target" && targetRow) {
compareValue = targetRow[condition.value];
logger.info(
`🎯 타겟(DB) 비교: ${condition.field} (${fieldValue}) vs DB.${condition.value} (${compareValue})`
);
} else if (condition.valueType === "field") {
compareValue = inputData[condition.value];
logger.info(
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
@ -2990,6 +3014,71 @@ export class NodeFlowExecutionService {
};
}
/**
* (DB )
* targetLookup , DB에서
*/
private static async lookupTargetRow(
targetLookup: any,
sourceRow: any,
context: ExecutionContext
): Promise<any | null> {
if (!targetLookup?.tableName || !targetLookup?.lookupKeys?.length) {
return null;
}
try {
// 저장 전 원본 데이터가 있으면 DB 조회 대신 원본 데이터 사용
// (after 타이밍에서는 DB가 이미 업데이트되어 있으므로 원본 데이터가 필요)
if (context.originalData && Object.keys(context.originalData).length > 0) {
logger.info(`🎯 조건 노드: 저장 전 원본 데이터(originalData) 사용 (DB 조회 스킵)`);
logger.info(`🎯 originalData 필드: ${Object.keys(context.originalData).join(", ")}`);
return context.originalData;
}
const whereConditions = targetLookup.lookupKeys
.map((key: any, idx: number) => `"${key.targetField}" = $${idx + 1}`)
.join(" AND ");
const lookupValues = targetLookup.lookupKeys.map(
(key: any) => sourceRow[key.sourceField]
);
// 키값이 비어있으면 조회 불필요
if (lookupValues.some((v: any) => v === null || v === undefined || v === "")) {
logger.info(`⚠️ 조건 노드 타겟 조회: 키값이 비어있어 스킵`);
return null;
}
// company_code 필터링 (멀티테넌시)
const companyCode = context.buttonContext?.companyCode || sourceRow.company_code;
let sql = `SELECT * FROM "${targetLookup.tableName}" WHERE ${whereConditions}`;
const params = [...lookupValues];
if (companyCode && companyCode !== "*") {
sql += ` AND company_code = $${params.length + 1}`;
params.push(companyCode);
}
sql += " LIMIT 1";
logger.info(`🎯 조건 노드 타겟 조회: ${targetLookup.tableName}, 조건: ${whereConditions}, 값: ${JSON.stringify(lookupValues)}`);
const targetRow = await queryOne(sql, params);
if (targetRow) {
logger.info(`🎯 타겟 데이터 조회 성공`);
} else {
logger.info(`🎯 타겟 데이터 없음 (신규 레코드)`);
}
return targetRow;
} catch (error: any) {
logger.warn(`⚠️ 조건 노드 타겟 조회 실패: ${error.message}`);
return null;
}
}
/**
* EXISTS_IN / NOT_EXISTS_IN
*

View File

@ -14,6 +14,35 @@ interface NumberingRulePart {
autoConfig?: any;
manualConfig?: any;
generatedValue?: string;
separatorAfter?: string;
}
/**
* autoConfig.separatorAfter를
*/
function extractSeparatorAfterFromParts(parts: any[]): any[] {
return parts.map((part) => {
if (part.autoConfig?.separatorAfter !== undefined) {
part.separatorAfter = part.autoConfig.separatorAfter;
}
return part;
});
}
/**
*
* separatorAfter는
*/
function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globalSeparator: string): string {
let result = "";
partValues.forEach((val, idx) => {
result += val;
if (idx < partValues.length - 1) {
const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator;
result += sep;
}
});
return result;
}
interface NumberingRuleConfig {
@ -141,7 +170,7 @@ class NumberingRuleService {
}
const partsResult = await pool.query(partsQuery, partsParams);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
}
logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}`, {
@ -274,7 +303,7 @@ class NumberingRuleService {
}
const partsResult = await pool.query(partsQuery, partsParams);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
}
return result.rows;
@ -381,7 +410,7 @@ class NumberingRuleService {
}
const partsResult = await pool.query(partsQuery, partsParams);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
logger.info("✅ 규칙 파트 조회 성공", {
ruleId: rule.ruleId,
@ -517,7 +546,7 @@ class NumberingRuleService {
companyCode === "*" ? rule.companyCode : companyCode,
]);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
}
logger.info(`화면용 채번 규칙 조회 완료: ${result.rows.length}`, {
@ -633,7 +662,7 @@ class NumberingRuleService {
}
const partsResult = await pool.query(partsQuery, partsParams);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
return rule;
}
@ -708,17 +737,25 @@ class NumberingRuleService {
manual_config AS "manualConfig"
`;
// auto_config에 separatorAfter 포함
const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" };
const partResult = await client.query(insertPartQuery, [
config.ruleId,
part.order,
part.partType,
part.generationMethod,
JSON.stringify(part.autoConfig || {}),
JSON.stringify(autoConfigWithSep),
JSON.stringify(part.manualConfig || {}),
companyCode,
]);
parts.push(partResult.rows[0]);
const savedPart = partResult.rows[0];
// autoConfig에서 separatorAfter를 추출하여 파트 레벨로 이동
if (savedPart.autoConfig?.separatorAfter !== undefined) {
savedPart.separatorAfter = savedPart.autoConfig.separatorAfter;
}
parts.push(savedPart);
}
await client.query("COMMIT");
@ -820,17 +857,23 @@ class NumberingRuleService {
manual_config AS "manualConfig"
`;
const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" };
const partResult = await client.query(insertPartQuery, [
ruleId,
part.order,
part.partType,
part.generationMethod,
JSON.stringify(part.autoConfig || {}),
JSON.stringify(autoConfigWithSep),
JSON.stringify(part.manualConfig || {}),
companyCode,
]);
parts.push(partResult.rows[0]);
const savedPart = partResult.rows[0];
if (savedPart.autoConfig?.separatorAfter !== undefined) {
savedPart.separatorAfter = savedPart.autoConfig.separatorAfter;
}
parts.push(savedPart);
}
}
@ -885,9 +928,9 @@ class NumberingRuleService {
const rule = await this.getRuleById(ruleId, companyCode);
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
const parts = rule.parts
const parts = await Promise.all(rule.parts
.sort((a: any, b: any) => a.order - b.order)
.map((part: any) => {
.map(async (part: any) => {
if (part.generationMethod === "manual") {
// 수동 입력 - 항상 ____ 마커 사용 (프론트엔드에서 편집 가능하게 처리)
// placeholder 텍스트는 프론트엔드에서 별도로 표시
@ -982,17 +1025,52 @@ class NumberingRuleService {
// 카테고리 매핑에서 해당 값에 대한 형식 찾기
// selectedValue는 valueCode일 수 있음 (V2Select에서 valueCode를 value로 사용)
const selectedValueStr = String(selectedValue);
const mapping = categoryMappings.find((m: any) => {
// ID로 매칭
let mapping = categoryMappings.find((m: any) => {
// ID로 매칭 (기존 방식: V2Select가 valueId를 사용하던 경우)
if (m.categoryValueId?.toString() === selectedValueStr)
return true;
// 라벨로 매칭
if (m.categoryValueLabel === selectedValueStr) return true;
// valueCode로 매칭 (라벨과 동일할 수 있음)
// valueCode로 매칭 (매핑에 categoryValueCode가 있는 경우)
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr)
return true;
// 라벨로 매칭 (폴백)
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
});
// 매핑을 못 찾았으면 category_values 테이블에서 valueCode → valueId 역변환 시도
if (!mapping) {
try {
const pool = getPool();
const [catTableName, catColumnName] = categoryKey.includes(".")
? categoryKey.split(".")
: [categoryKey, categoryKey];
const cvResult = await pool.query(
`SELECT value_id, value_code, value_label FROM category_values
WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
[catTableName, catColumnName, selectedValueStr]
);
if (cvResult.rows.length > 0) {
const resolvedId = cvResult.rows[0].value_id;
const resolvedLabel = cvResult.rows[0].value_label;
mapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === String(resolvedId)) return true;
if (m.categoryValueLabel === resolvedLabel) return true;
return false;
});
if (mapping) {
logger.info("카테고리 매핑 역변환 성공 (valueCode→valueId)", {
valueCode: selectedValueStr,
resolvedId,
resolvedLabel,
format: mapping.format,
});
}
}
} catch (lookupError: any) {
logger.warn("카테고리 값 역변환 조회 실패", { error: lookupError.message });
}
}
if (mapping) {
logger.info("카테고리 매핑 적용", {
selectedValue,
@ -1016,9 +1094,10 @@ class NumberingRuleService {
logger.warn("알 수 없는 파트 타입", { partType: part.partType });
return "";
}
});
}));
const previewCode = parts.join(rule.separator || "");
const sortedRuleParts = rule.parts.sort((a: any, b: any) => a.order - b.order);
const previewCode = joinPartsWithSeparators(parts, sortedRuleParts, rule.separator || "");
logger.info("코드 미리보기 생성", {
ruleId,
previewCode,
@ -1059,9 +1138,9 @@ class NumberingRuleService {
if (manualParts.length > 0 && userInputCode) {
// 프리뷰 코드를 생성해서 ____ 위치 파악
// 🔧 category 파트도 처리하여 올바른 템플릿 생성
const previewParts = rule.parts
const previewParts = await Promise.all(rule.parts
.sort((a: any, b: any) => a.order - b.order)
.map((part: any) => {
.map(async (part: any) => {
if (part.generationMethod === "manual") {
return "____";
}
@ -1077,39 +1156,60 @@ class NumberingRuleService {
return "DATEPART"; // 날짜 자리 표시
case "category": {
// 카테고리 파트: formData에서 실제 값을 가져와서 매핑된 형식 사용
const categoryKey = autoConfig.categoryKey;
const categoryMappings = autoConfig.categoryMappings || [];
const catKey2 = autoConfig.categoryKey;
const catMappings2 = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) {
if (!catKey2 || !formData) {
return "CATEGORY"; // 폴백
}
const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1]
: categoryKey;
const selectedValue = formData[columnName];
const colName2 = catKey2.includes(".")
? catKey2.split(".")[1]
: catKey2;
const selVal2 = formData[colName2];
if (!selectedValue) {
if (!selVal2) {
return "CATEGORY"; // 폴백
}
const selectedValueStr = String(selectedValue);
const mapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === selectedValueStr)
return true;
if (m.categoryValueLabel === selectedValueStr) return true;
const selValStr2 = String(selVal2);
let catMapping2 = catMappings2.find((m: any) => {
if (m.categoryValueId?.toString() === selValStr2) return true;
if (m.categoryValueCode && m.categoryValueCode === selValStr2) return true;
if (m.categoryValueLabel === selValStr2) return true;
return false;
});
return mapping?.format || "CATEGORY";
// valueCode → valueId 역변환 시도
if (!catMapping2) {
try {
const pool2 = getPool();
const [ct2, cc2] = catKey2.includes(".") ? catKey2.split(".") : [catKey2, catKey2];
const cvr2 = await pool2.query(
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
[ct2, cc2, selValStr2]
);
if (cvr2.rows.length > 0) {
const rid2 = cvr2.rows[0].value_id;
const rlabel2 = cvr2.rows[0].value_label;
catMapping2 = catMappings2.find((m: any) => {
if (m.categoryValueId?.toString() === String(rid2)) return true;
if (m.categoryValueLabel === rlabel2) return true;
return false;
});
}
} catch { /* ignore */ }
}
return catMapping2?.format || "CATEGORY";
}
default:
return "";
}
});
}));
const separator = rule.separator || "";
const previewTemplate = previewParts.join(separator);
const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order);
const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || "");
// 사용자 입력 코드에서 수동 입력 부분 추출
// 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출
@ -1150,9 +1250,9 @@ class NumberingRuleService {
}
let manualPartIndex = 0;
const parts = rule.parts
const parts = await Promise.all(rule.parts
.sort((a: any, b: any) => a.order - b.order)
.map((part: any) => {
.map(async (part: any) => {
if (part.generationMethod === "manual") {
// 추출된 수동 입력 값 사용, 없으면 기본값 사용
const manualValue =
@ -1267,28 +1367,53 @@ class NumberingRuleService {
// 카테고리 매핑에서 해당 값에 대한 형식 찾기
const selectedValueStr = String(selectedValue);
const mapping = categoryMappings.find((m: any) => {
// ID로 매칭
if (m.categoryValueId?.toString() === selectedValueStr)
return true;
// 라벨로 매칭
let allocMapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === selectedValueStr) return true;
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true;
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
});
if (mapping) {
// valueCode → valueId 역변환 시도
if (!allocMapping) {
try {
const pool3 = getPool();
const [ct3, cc3] = categoryKey.includes(".") ? categoryKey.split(".") : [categoryKey, categoryKey];
const cvr3 = await pool3.query(
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
[ct3, cc3, selectedValueStr]
);
if (cvr3.rows.length > 0) {
const rid3 = cvr3.rows[0].value_id;
const rlabel3 = cvr3.rows[0].value_label;
allocMapping = categoryMappings.find((m: any) => {
if (m.categoryValueId?.toString() === String(rid3)) return true;
if (m.categoryValueLabel === rlabel3) return true;
return false;
});
if (allocMapping) {
logger.info("allocateCode: 카테고리 매핑 역변환 성공", {
valueCode: selectedValueStr, resolvedId: rid3, format: allocMapping.format,
});
}
}
} catch { /* ignore */ }
}
if (allocMapping) {
logger.info("allocateCode: 카테고리 매핑 적용", {
selectedValue,
format: mapping.format,
categoryValueLabel: mapping.categoryValueLabel,
format: allocMapping.format,
categoryValueLabel: allocMapping.categoryValueLabel,
});
return mapping.format || "";
return allocMapping.format || "";
}
logger.warn("allocateCode: 카테고리 매핑을 찾을 수 없음", {
selectedValue,
availableMappings: categoryMappings.map((m: any) => ({
id: m.categoryValueId,
code: m.categoryValueCode,
label: m.categoryValueLabel,
})),
});
@ -1299,9 +1424,10 @@ class NumberingRuleService {
logger.warn("알 수 없는 파트 타입", { partType: part.partType });
return "";
}
});
}));
const allocatedCode = parts.join(rule.separator || "");
const sortedPartsForAlloc = rule.parts.sort((a: any, b: any) => a.order - b.order);
const allocatedCode = joinPartsWithSeparators(parts, sortedPartsForAlloc, rule.separator || "");
// 순번이 있는 경우에만 증가
const hasSequence = rule.parts.some(
@ -1460,7 +1586,7 @@ class NumberingRuleService {
rule.ruleId,
companyCode === "*" ? rule.companyCode : companyCode,
]);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
}
logger.info("[테스트] 채번 규칙 목록 조회 완료", {
@ -1553,7 +1679,7 @@ class NumberingRuleService {
rule.ruleId,
companyCode,
]);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", {
ruleId: rule.ruleId,
@ -1673,12 +1799,14 @@ class NumberingRuleService {
auto_config, manual_config, company_code, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
`;
const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" };
await client.query(partInsertQuery, [
config.ruleId,
part.order,
part.partType,
part.generationMethod,
JSON.stringify(part.autoConfig || {}),
JSON.stringify(autoConfigWithSep),
JSON.stringify(part.manualConfig || {}),
companyCode,
]);
@ -1833,7 +1961,7 @@ class NumberingRuleService {
rule.ruleId,
companyCode,
]);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
logger.info("카테고리 조건 매칭 채번 규칙 찾음", {
ruleId: rule.ruleId,
@ -1892,7 +2020,7 @@ class NumberingRuleService {
rule.ruleId,
companyCode,
]);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
logger.info("기본 채번 규칙 찾음 (카테고리 조건 없음)", {
ruleId: rule.ruleId,
@ -1975,7 +2103,7 @@ class NumberingRuleService {
rule.ruleId,
companyCode,
]);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
}
return result.rows;

View File

@ -1728,25 +1728,35 @@ export class ScreenManagementService {
throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다.");
}
// 🆕 V2 테이블 우선 조회 (회사별 → 공통(*))
// V2 테이블 우선 조회: 기본 레이어(layer_id=1)만 가져옴
// layer_id 필터 없이 queryOne 하면 조건부 레이어가 반환될 수 있음
let v2Layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2`,
WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`,
[screenId, companyCode],
);
// 회사별 레이아웃 없으면 공통(*) 조회
// 최고관리자(*): 화면 정의의 company_code로 재조회
if (!v2Layout && companyCode === "*" && existingScreen.company_code && existingScreen.company_code !== "*") {
v2Layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`,
[screenId, existingScreen.company_code],
);
}
// 일반 사용자: 회사별 레이아웃 없으면 공통(*) 조회
if (!v2Layout && companyCode !== "*") {
v2Layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = '*'`,
WHERE screen_id = $1 AND company_code = '*' AND layer_id = 1`,
[screenId],
);
}
// V2 레이아웃이 있으면 V2 형식으로 반환
if (v2Layout && v2Layout.layout_data) {
console.log(`V2 레이아웃 발견, V2 형식으로 반환`);
const layoutData = v2Layout.layout_data;
// URL에서 컴포넌트 타입 추출하는 헬퍼 함수
@ -1806,7 +1816,7 @@ export class ScreenManagementService {
};
}
console.log(`V2 레이아웃 없음, V1 테이블 조회`);
const layouts = await query<any>(
`SELECT * FROM screen_layouts
@ -4252,16 +4262,16 @@ export class ScreenManagementService {
},
);
// V2 레이아웃 저장 (UPSERT)
// V2 레이아웃 저장 (UPSERT) - layer_id 포함
await client.query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
VALUES ($1, $2, $3, NOW(), NOW())
ON CONFLICT (screen_id, company_code)
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layout_data, created_at, updated_at)
VALUES ($1, $2, 1, $3, NOW(), NOW())
ON CONFLICT (screen_id, company_code, layer_id)
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
[newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)],
);
console.log(` ✅ V2 레이아웃 복사 완료: ${components.length}개 컴포넌트`);
} catch (error) {
console.error("V2 레이아웃 복사 중 오류:", error);
// 레이아웃 복사 실패해도 화면 생성은 유지
@ -5052,8 +5062,7 @@ export class ScreenManagementService {
companyCode: string,
userType?: string,
): Promise<any | null> {
console.log(`=== V2 레이아웃 로드 시작 ===`);
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 사용자 유형: ${userType}`);
// SUPER_ADMIN 여부 확인
const isSuperAdmin = userType === "SUPER_ADMIN";
@ -5080,67 +5089,94 @@ export class ScreenManagementService {
let layout: { layout_data: any } | null = null;
// SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회
if (isSuperAdmin) {
// 1. 화면 정의의 회사 코드로 레이아웃 조회
// 🆕 기본 레이어(layer_id=1)를 우선 로드
// SUPER_ADMIN이거나 companyCode가 "*"인 경우: 화면의 회사 코드로 레이아웃 조회
if (isSuperAdmin || companyCode === "*") {
// 1. 화면 정의의 회사 코드 + 기본 레이어
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2`,
WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`,
[screenId, existingScreen.company_code],
);
// 2. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째 조회
// 2. 기본 레이어 없으면 layer_id 조건 없이 조회 (하위 호환)
if (!layout) {
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2
ORDER BY layer_id ASC
LIMIT 1`,
[screenId, existingScreen.company_code],
);
}
// 3. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째
if (!layout) {
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1
ORDER BY updated_at DESC
ORDER BY layer_id ASC
LIMIT 1`,
[screenId],
);
}
} else {
// 일반 사용자: 기존 로직 (회사별 우선, 없으면 공통(*) 조회)
// 일반 사용자: 회사별 우선 + 기본 레이어
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2`,
WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`,
[screenId, companyCode],
);
// 회사별 기본 레이어 없으면 layer_id 조건 없이 (하위 호환)
if (!layout) {
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2
ORDER BY layer_id ASC
LIMIT 1`,
[screenId, companyCode],
);
}
// 회사별 레이아웃이 없으면 공통(*) 레이아웃 조회
if (!layout && companyCode !== "*") {
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = '*'`,
WHERE screen_id = $1 AND company_code = '*'
ORDER BY layer_id ASC
LIMIT 1`,
[screenId],
);
}
}
if (!layout) {
console.log(`V2 레이아웃 없음: screen_id=${screenId}`);
return null;
}
console.log(
`V2 레이아웃 로드 완료: ${layout.layout_data?.components?.length || 0}개 컴포넌트`,
);
return layout.layout_data;
}
/**
* V2 (1 )
* - screen_layouts_v2 1
* - layout_data JSON에
* V2 ( )
* - screen_layouts_v2 1
* - layout_data JSON에
*/
async saveLayoutV2(
screenId: number,
layoutData: any,
companyCode: string,
): Promise<void> {
console.log(`=== V2 레이아웃 저장 시작 ===`);
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
console.log(`컴포넌트 수: ${layoutData.components?.length || 0}`);
const layerId = layoutData.layerId || 1;
const layerName = layoutData.layerName || (layerId === 1 ? '기본 레이어' : `레이어 ${layerId}`);
// conditionConfig가 명시적으로 전달되었는지 확인 (undefined = 미전달, null/object = 명시적 전달)
const hasConditionConfig = 'conditionConfig' in layoutData;
const conditionConfig = layoutData.conditionConfig || null;
// 권한 확인
const screens = await query<{ company_code: string | null }>(
@ -5158,22 +5194,375 @@ export class ScreenManagementService {
throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다.");
}
// 버전 정보 추가 (updatedAt은 DB 컬럼 updated_at으로 관리)
// 화면의 기본 테이블 업데이트 (테이블이 선택된 경우)
const mainTableName = layoutData.mainTableName;
if (mainTableName) {
await query(
`UPDATE screen_definitions SET table_name = $1, updated_date = NOW() WHERE screen_id = $2`,
[mainTableName, screenId],
);
console.log(`✅ [saveLayoutV2] 화면 기본 테이블 업데이트: ${mainTableName}`);
}
// 저장할 layout_data에서 레이어 메타 정보 제거 (순수 레이아웃만 저장)
const { layerId: _lid, layerName: _ln, conditionConfig: _cc, mainTableName: _mtn, ...pureLayoutData } = layoutData;
const dataToSave = {
version: "2.0",
...layoutData
...pureLayoutData,
};
// UPSERT (있으면 업데이트, 없으면 삽입)
await query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
VALUES ($1, $2, $3, NOW(), NOW())
ON CONFLICT (screen_id, company_code)
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
[screenId, companyCode, JSON.stringify(dataToSave)],
if (hasConditionConfig) {
// conditionConfig가 명시적으로 전달된 경우: condition_config도 함께 저장
await query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, condition_config, layout_data, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
ON CONFLICT (screen_id, company_code, layer_id)
DO UPDATE SET layout_data = $6, layer_name = $4, condition_config = $5, updated_at = NOW()`,
[screenId, companyCode, layerId, layerName, conditionConfig ? JSON.stringify(conditionConfig) : null, JSON.stringify(dataToSave)],
);
} else {
// conditionConfig가 전달되지 않은 경우: 기존 condition_config 유지, layout_data만 업데이트
await query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
ON CONFLICT (screen_id, company_code, layer_id)
DO UPDATE SET layout_data = $5, layer_name = $4, updated_at = NOW()`,
[screenId, companyCode, layerId, layerName, JSON.stringify(dataToSave)],
);
}
}
/**
*
*
*/
async getScreenLayers(
screenId: number,
companyCode: string,
): Promise<any[]> {
let layers;
if (companyCode === "*") {
layers = await query<any>(
`SELECT layer_id, layer_name, condition_config,
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
updated_at
FROM screen_layouts_v2
WHERE screen_id = $1
ORDER BY layer_id`,
[screenId],
);
} else {
layers = await query<any>(
`SELECT layer_id, layer_name, condition_config,
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
updated_at
FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2
ORDER BY layer_id`,
[screenId, companyCode],
);
// 회사별 레이어가 없으면 공통(*) 레이어 조회
if (layers.length === 0 && companyCode !== "*") {
layers = await query<any>(
`SELECT layer_id, layer_name, condition_config,
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
updated_at
FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = '*'
ORDER BY layer_id`,
[screenId],
);
}
}
// 레이어가 없으면 기본 레이어 자동 생성
if (layers.length === 0) {
const defaultLayout = JSON.stringify({ version: "2.0", components: [] });
await query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, created_at, updated_at)
VALUES ($1, $2, 1, '기본 레이어', $3, NOW(), NOW())
ON CONFLICT (screen_id, company_code, layer_id) DO NOTHING`,
[screenId, companyCode, defaultLayout],
);
console.log(`기본 레이어 자동 생성: screen_id=${screenId}, company_code=${companyCode}`);
// 다시 조회
layers = await query<any>(
`SELECT layer_id, layer_name, condition_config,
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
updated_at
FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2
ORDER BY layer_id`,
[screenId, companyCode],
);
}
return layers;
}
/**
*
*/
async getLayerLayout(
screenId: number,
layerId: number,
companyCode: string,
): Promise<any> {
let layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>(
`SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
[screenId, companyCode, layerId],
);
console.log(`V2 레이아웃 저장 완료`);
// 최고관리자(*): 화면 정의의 company_code로 재조회
if (!layout && companyCode === "*") {
const screenDef = await queryOne<{ company_code: string }>(
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId],
);
if (screenDef && screenDef.company_code && screenDef.company_code !== "*") {
layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>(
`SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
[screenId, screenDef.company_code, layerId],
);
}
}
// 일반 사용자: 회사별 레이어가 없으면 공통(*) 조회
if (!layout && companyCode !== "*") {
layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>(
`SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = '*' AND layer_id = $2`,
[screenId, layerId],
);
}
if (!layout) return null;
return {
...layout.layout_data,
layerId,
layerName: layout.layer_name,
conditionConfig: layout.condition_config,
};
}
/**
*
*/
async deleteLayer(
screenId: number,
layerId: number,
companyCode: string,
): Promise<void> {
if (layerId === 1) {
throw new Error("기본 레이어는 삭제할 수 없습니다.");
}
await query(
`DELETE FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
[screenId, companyCode, layerId],
);
console.log(`레이어 삭제 완료: screen_id=${screenId}, layer_id=${layerId}`);
}
/**
*
*/
async updateLayerCondition(
screenId: number,
layerId: number,
companyCode: string,
conditionConfig: any,
layerName?: string,
): Promise<void> {
const setClauses = ['condition_config = $4', 'updated_at = NOW()'];
const params: any[] = [screenId, companyCode, layerId, conditionConfig ? JSON.stringify(conditionConfig) : null];
if (layerName) {
setClauses.push(`layer_name = $${params.length + 1}`);
params.push(layerName);
}
await query(
`UPDATE screen_layouts_v2 SET ${setClauses.join(', ')}
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
params,
);
}
// ========================================
// 조건부 영역(Zone) 관리
// ========================================
/**
* (Zone)
*/
async getScreenZones(screenId: number, companyCode: string): Promise<any[]> {
let zones;
if (companyCode === "*") {
// 최고 관리자: 모든 회사 Zone 조회 가능
zones = await query<any>(
`SELECT * FROM screen_conditional_zones WHERE screen_id = $1 ORDER BY zone_id`,
[screenId],
);
} else {
// 일반 회사: 자사 Zone + 공통(*) Zone 조회
zones = await query<any>(
`SELECT * FROM screen_conditional_zones
WHERE screen_id = $1 AND (company_code = $2 OR company_code = '*')
ORDER BY zone_id`,
[screenId, companyCode],
);
}
return zones;
}
/**
* (Zone)
*/
async createZone(
screenId: number,
companyCode: string,
zoneData: {
zone_name?: string;
x: number;
y: number;
width: number;
height: number;
trigger_component_id?: string;
trigger_operator?: string;
},
): Promise<any> {
const result = await queryOne<any>(
`INSERT INTO screen_conditional_zones
(screen_id, company_code, zone_name, x, y, width, height, trigger_component_id, trigger_operator)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *`,
[
screenId,
companyCode,
zoneData.zone_name || '조건부 영역',
zoneData.x,
zoneData.y,
zoneData.width,
zoneData.height,
zoneData.trigger_component_id || null,
zoneData.trigger_operator || 'eq',
],
);
return result;
}
/**
* (Zone) (//)
*/
async updateZone(
zoneId: number,
companyCode: string,
updates: {
zone_name?: string;
x?: number;
y?: number;
width?: number;
height?: number;
trigger_component_id?: string;
trigger_operator?: string;
},
): Promise<void> {
const setClauses: string[] = ['updated_at = NOW()'];
const params: any[] = [zoneId, companyCode];
let paramIdx = 3;
for (const [key, value] of Object.entries(updates)) {
if (value !== undefined) {
setClauses.push(`${key} = $${paramIdx}`);
params.push(value);
paramIdx++;
}
}
await query(
`UPDATE screen_conditional_zones SET ${setClauses.join(', ')}
WHERE zone_id = $1 AND company_code = $2`,
params,
);
}
/**
* (Zone) + condition_config
*/
async deleteZone(zoneId: number, companyCode: string): Promise<void> {
// Zone에 소속된 레이어들의 condition_config에서 zone_id 제거
await query(
`UPDATE screen_layouts_v2 SET condition_config = NULL, updated_at = NOW()
WHERE company_code = $1 AND condition_config->>'zone_id' = $2::text`,
[companyCode, String(zoneId)],
);
await query(
`DELETE FROM screen_conditional_zones WHERE zone_id = $1 AND company_code = $2`,
[zoneId, companyCode],
);
}
/**
* Zone에 ( + zone_id )
*/
async addLayerToZone(
screenId: number,
companyCode: string,
zoneId: number,
conditionValue: string,
layerName?: string,
): Promise<{ layerId: number }> {
// 다음 layer_id 계산
const maxResult = await queryOne<{ max_id: number }>(
`SELECT COALESCE(MAX(layer_id), 1) as max_id FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2`,
[screenId, companyCode],
);
const newLayerId = (maxResult?.max_id || 1) + 1;
// Zone 정보로 캔버스 크기 결정 (company_code 필터링 필수)
const zone = await queryOne<any>(
`SELECT * FROM screen_conditional_zones WHERE zone_id = $1 AND company_code = $2`,
[zoneId, companyCode],
);
const layoutData = {
version: "2.1",
components: [],
screenResolution: zone
? { width: zone.width, height: zone.height }
: { width: 800, height: 200 },
};
const conditionConfig = {
zone_id: zoneId,
condition_value: conditionValue,
};
await query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, layer_id, layer_name, condition_config)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (screen_id, company_code, layer_id) DO UPDATE
SET layout_data = EXCLUDED.layout_data,
layer_name = EXCLUDED.layer_name,
condition_config = EXCLUDED.condition_config,
updated_at = NOW()`,
[screenId, companyCode, JSON.stringify(layoutData), newLayerId, layerName || `레이어 ${newLayerId}`, JSON.stringify(conditionConfig)],
);
return { layerId: newLayerId };
}
// ========================================

View File

@ -1371,39 +1371,66 @@ class TableCategoryValueService {
const pool = getPool();
// 동적으로 파라미터 플레이스홀더 생성
const placeholders = valueCodes.map((_, i) => `$${i + 1}`).join(", ");
const n = valueCodes.length;
// 첫 번째 쿼리용 플레이스홀더: $1 ~ $n
const placeholders1 = valueCodes.map((_, i) => `$${i + 1}`).join(", ");
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 카테고리 값 조회
// 최고 관리자: 두 테이블 모두에서 조회 (UNION으로 병합)
// 두 번째 쿼리용 플레이스홀더: $n+1 ~ $2n
const placeholders2 = valueCodes.map((_, i) => `$${n + i + 1}`).join(", ");
query = `
SELECT value_code, value_label
FROM table_column_category_values
WHERE value_code IN (${placeholders})
AND is_active = true
SELECT value_code, value_label FROM (
SELECT value_code, value_label
FROM table_column_category_values
WHERE value_code IN (${placeholders1})
AND is_active = true
UNION ALL
SELECT value_code, value_label
FROM category_values
WHERE value_code IN (${placeholders2})
AND is_active = true
) combined
`;
params = valueCodes;
params = [...valueCodes, ...valueCodes];
} else {
// 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회
// 일반 회사: 두 테이블에서 자신의 카테고리 값 + 공통 카테고리 값 조회
// 첫 번째: $1~$n (valueCodes), $n+1 (companyCode)
// 두 번째: $n+2~$2n+1 (valueCodes), $2n+2 (companyCode)
const companyIdx1 = n + 1;
const placeholders2 = valueCodes.map((_, i) => `$${n + 1 + i + 1}`).join(", ");
const companyIdx2 = 2 * n + 2;
query = `
SELECT value_code, value_label
FROM table_column_category_values
WHERE value_code IN (${placeholders})
AND is_active = true
AND (company_code = $${valueCodes.length + 1} OR company_code = '*')
SELECT value_code, value_label FROM (
SELECT value_code, value_label
FROM table_column_category_values
WHERE value_code IN (${placeholders1})
AND is_active = true
AND (company_code = $${companyIdx1} OR company_code = '*')
UNION ALL
SELECT value_code, value_label
FROM category_values
WHERE value_code IN (${placeholders2})
AND is_active = true
AND (company_code = $${companyIdx2} OR company_code = '*')
) combined
`;
params = [...valueCodes, companyCode];
params = [...valueCodes, companyCode, ...valueCodes, companyCode];
}
const result = await pool.query(query, params);
// { [code]: label } 형태로 변환
// { [code]: label } 형태로 변환 (중복 시 첫 번째 결과 우선)
const labels: Record<string, string> = {};
for (const row of result.rows) {
labels[row.value_code] = row.value_label;
if (!labels[row.value_code]) {
labels[row.value_code] = row.value_label;
}
}
logger.info(`카테고리 라벨 ${Object.keys(labels).length}개 조회 완료`, { companyCode });

View File

@ -199,7 +199,15 @@ export class TableManagementService {
cl.input_type as "cl_input_type",
COALESCE(ttc.detail_settings::text, cl.detail_settings::text, '') as "detailSettings",
COALESCE(ttc.description, cl.description, '') as "description",
c.is_nullable as "isNullable",
CASE
WHEN COALESCE(ttc.is_nullable, cl.is_nullable) IS NOT NULL
THEN CASE WHEN COALESCE(ttc.is_nullable, cl.is_nullable) = 'N' THEN 'NO' ELSE 'YES' END
ELSE c.is_nullable
END as "isNullable",
CASE
WHEN COALESCE(ttc.is_unique, cl.is_unique) = 'Y' THEN 'YES'
ELSE 'NO'
END as "isUnique",
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
c.column_default as "defaultValue",
c.character_maximum_length as "maxLength",
@ -241,7 +249,15 @@ export class TableManagementService {
COALESCE(cl.input_type, 'direct') as "inputType",
COALESCE(cl.detail_settings::text, '') as "detailSettings",
COALESCE(cl.description, '') as "description",
c.is_nullable as "isNullable",
CASE
WHEN cl.is_nullable IS NOT NULL
THEN CASE WHEN cl.is_nullable = 'N' THEN 'NO' ELSE 'YES' END
ELSE c.is_nullable
END as "isNullable",
CASE
WHEN cl.is_unique = 'Y' THEN 'YES'
ELSE 'NO'
END as "isUnique",
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
c.column_default as "defaultValue",
c.character_maximum_length as "maxLength",
@ -502,8 +518,8 @@ export class TableManagementService {
table_name, column_name, column_label, input_type, detail_settings,
code_category, code_value, reference_table, reference_column,
display_column, display_order, is_visible, is_nullable,
company_code, created_date, updated_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', $13, NOW(), NOW())
company_code, category_ref, created_date, updated_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', $13, $14, NOW(), NOW())
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET
column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label),
@ -516,6 +532,7 @@ export class TableManagementService {
display_column = COALESCE(EXCLUDED.display_column, table_type_columns.display_column),
display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order),
is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible),
category_ref = EXCLUDED.category_ref,
updated_date = NOW()`,
[
tableName,
@ -531,6 +548,7 @@ export class TableManagementService {
settings.displayOrder || 0,
settings.isVisible !== undefined ? settings.isVisible : true,
companyCode,
settings.categoryRef || null,
]
);
@ -1599,7 +1617,8 @@ export class TableManagementService {
tableName,
columnName,
actualValue,
paramIndex
paramIndex,
operator
);
case "entity":
@ -1612,7 +1631,14 @@ export class TableManagementService {
);
default:
// 기본 문자열 검색 (actualValue 사용)
// operator에 따라 정확 일치 또는 부분 일치 검색
if (operator === "equals") {
return {
whereClause: `${columnName}::text = $${paramIndex}`,
values: [String(actualValue)],
paramCount: 1,
};
}
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${actualValue}%`],
@ -1626,10 +1652,19 @@ export class TableManagementService {
);
// 오류 시 기본 검색으로 폴백
let fallbackValue = value;
let fallbackOperator = "contains";
if (typeof value === "object" && value !== null && "value" in value) {
fallbackValue = value.value;
fallbackOperator = value.operator || "contains";
}
if (fallbackOperator === "equals") {
return {
whereClause: `${columnName}::text = $${paramIndex}`,
values: [String(fallbackValue)],
paramCount: 1,
};
}
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${fallbackValue}%`],
@ -1776,7 +1811,8 @@ export class TableManagementService {
tableName: string,
columnName: string,
value: any,
paramIndex: number
paramIndex: number,
operator: string = "contains"
): Promise<{
whereClause: string;
values: any[];
@ -1786,7 +1822,14 @@ export class TableManagementService {
const codeTypeInfo = await this.getCodeTypeInfo(tableName, columnName);
if (!codeTypeInfo.isCodeType || !codeTypeInfo.codeCategory) {
// 코드 타입이 아니면 기본 검색
// 코드 타입이 아니면 operator에 따라 검색
if (operator === "equals") {
return {
whereClause: `${columnName}::text = $${paramIndex}`,
values: [String(value)],
paramCount: 1,
};
}
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
@ -1794,6 +1837,15 @@ export class TableManagementService {
};
}
// select 필터(equals)인 경우 정확한 코드값 매칭만 수행
if (operator === "equals") {
return {
whereClause: `${columnName}::text = $${paramIndex}`,
values: [String(value)],
paramCount: 1,
};
}
if (typeof value === "string" && value.trim() !== "") {
// 코드값 또는 코드명으로 검색
return {
@ -2431,6 +2483,154 @@ export class TableManagementService {
return value;
}
/**
* NOT NULL
* table_type_columns.is_nullable = 'N' NULL/ .
*/
async validateNotNullConstraints(
tableName: string,
data: Record<string, any>,
companyCode: string
): Promise<string[]> {
try {
// 회사별 설정 우선, 없으면 공통(*) 설정 사용
const notNullColumns = await query<{ column_name: string; column_label: string }>(
`SELECT
ttc.column_name,
COALESCE(ttc.column_label, ttc.column_name) as column_label
FROM table_type_columns ttc
WHERE ttc.table_name = $1
AND ttc.is_nullable = 'N'
AND ttc.company_code = $2`,
[tableName, companyCode]
);
// 회사별 설정이 없으면 공통 설정 확인
if (notNullColumns.length === 0 && companyCode !== "*") {
const globalNotNull = await query<{ column_name: string; column_label: string }>(
`SELECT
ttc.column_name,
COALESCE(ttc.column_label, ttc.column_name) as column_label
FROM table_type_columns ttc
WHERE ttc.table_name = $1
AND ttc.is_nullable = 'N'
AND ttc.company_code = '*'
AND NOT EXISTS (
SELECT 1 FROM table_type_columns ttc2
WHERE ttc2.table_name = ttc.table_name
AND ttc2.column_name = ttc.column_name
AND ttc2.company_code = $2
)`,
[tableName, companyCode]
);
notNullColumns.push(...globalNotNull);
}
if (notNullColumns.length === 0) return [];
const violations: string[] = [];
for (const col of notNullColumns) {
const value = data[col.column_name];
// NULL, undefined, 빈 문자열을 NOT NULL 위반으로 처리
if (value === null || value === undefined || value === "") {
violations.push(col.column_label);
}
}
return violations;
} catch (error) {
logger.error(`NOT NULL 검증 오류: ${tableName}`, error);
return [];
}
}
/**
* UNIQUE
* table_type_columns.is_unique = 'Y' .
* @param excludeId
*/
async validateUniqueConstraints(
tableName: string,
data: Record<string, any>,
companyCode: string,
excludeId?: string
): Promise<string[]> {
try {
// 회사별 설정 우선, 없으면 공통(*) 설정 사용
let uniqueColumns = await query<{ column_name: string; column_label: string }>(
`SELECT
ttc.column_name,
COALESCE(ttc.column_label, ttc.column_name) as column_label
FROM table_type_columns ttc
WHERE ttc.table_name = $1
AND ttc.is_unique = 'Y'
AND ttc.company_code = $2`,
[tableName, companyCode]
);
// 회사별 설정이 없으면 공통 설정 확인
if (uniqueColumns.length === 0 && companyCode !== "*") {
const globalUnique = await query<{ column_name: string; column_label: string }>(
`SELECT
ttc.column_name,
COALESCE(ttc.column_label, ttc.column_name) as column_label
FROM table_type_columns ttc
WHERE ttc.table_name = $1
AND ttc.is_unique = 'Y'
AND ttc.company_code = '*'
AND NOT EXISTS (
SELECT 1 FROM table_type_columns ttc2
WHERE ttc2.table_name = ttc.table_name
AND ttc2.column_name = ttc.column_name
AND ttc2.company_code = $2
)`,
[tableName, companyCode]
);
uniqueColumns = globalUnique;
}
if (uniqueColumns.length === 0) return [];
const violations: string[] = [];
for (const col of uniqueColumns) {
const value = data[col.column_name];
if (value === null || value === undefined || value === "") continue;
// 해당 회사 내에서 같은 값이 이미 존재하는지 확인
const hasCompanyCode = await query(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[tableName]
);
let dupQuery: string;
let dupParams: any[];
if (hasCompanyCode.length > 0 && companyCode !== "*") {
dupQuery = excludeId
? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 AND id != $3 LIMIT 1`
: `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 LIMIT 1`;
dupParams = excludeId ? [value, companyCode, excludeId] : [value, companyCode];
} else {
dupQuery = excludeId
? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND id != $2 LIMIT 1`
: `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 LIMIT 1`;
dupParams = excludeId ? [value, excludeId] : [value];
}
const dupResult = await query(dupQuery, dupParams);
if (dupResult.length > 0) {
violations.push(`${col.column_label} (${value})`);
}
}
return violations;
} catch (error) {
logger.error(`UNIQUE 검증 오류: ${tableName}`, error);
return [];
}
}
/**
*
* @returns ()
@ -2438,7 +2638,7 @@ export class TableManagementService {
async addTableData(
tableName: string,
data: Record<string, any>
): Promise<{ skippedColumns: string[]; savedColumns: string[] }> {
): Promise<{ skippedColumns: string[]; savedColumns: string[]; insertedId: string | null }> {
try {
logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`);
logger.info(`추가할 데이터:`, data);
@ -2551,19 +2751,21 @@ export class TableManagementService {
const insertQuery = `
INSERT INTO "${tableName}" (${columnNames})
VALUES (${placeholders})
RETURNING id
`;
logger.info(`실행할 쿼리: ${insertQuery}`);
logger.info(`쿼리 파라미터:`, values);
await query(insertQuery, values);
const insertResult = await query(insertQuery, values) as any[];
const insertedId = insertResult?.[0]?.id ?? null;
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${insertedId}`);
// 무시된 컬럼과 저장된 컬럼 정보 반환
return {
skippedColumns,
savedColumns: existingColumns,
insertedId,
};
} catch (error) {
logger.error(`테이블 데이터 추가 오류: ${tableName}`, error);
@ -2875,10 +3077,11 @@ export class TableManagementService {
};
}
// Entity 조인 설정 감지 (화면별 엔티티 설정 전달)
// Entity 조인 설정 감지 (화면별 엔티티 설정 + 회사코드 전달)
let joinConfigs = await entityJoinService.detectEntityJoins(
tableName,
options.screenEntityConfigs
options.screenEntityConfigs,
options.companyCode
);
logger.info(
@ -2978,31 +3181,49 @@ export class TableManagementService {
continue; // 기본 Entity 조인과 중복되면 추가하지 않음
}
// 추가 조인 컬럼 설정 생성
const additionalJoinConfig: EntityJoinConfig = {
sourceTable: tableName,
sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id)
referenceTable:
(additionalColumn as any).referenceTable ||
baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng)
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code)
displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name)
displayColumn: actualColumnName, // 하위 호환성
aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name)
separator: " - ", // 기본 구분자
};
joinConfigs.push(additionalJoinConfig);
logger.info(
`✅ 추가 조인 컬럼 설정 추가: ${additionalJoinConfig.aliasColumn} -> ${actualColumnName}`
// 🆕 같은 sourceColumn + referenceTable 조합의 기존 config가 있으면 displayColumns에 병합
const existingConfig = joinConfigs.find(
(config) =>
config.sourceColumn === sourceColumn &&
config.referenceTable === ((additionalColumn as any).referenceTable || baseJoinConfig.referenceTable)
);
logger.info(`🔍 추가된 조인 설정 상세:`, {
sourceTable: additionalJoinConfig.sourceTable,
sourceColumn: additionalJoinConfig.sourceColumn,
referenceTable: additionalJoinConfig.referenceTable,
displayColumns: additionalJoinConfig.displayColumns,
aliasColumn: additionalJoinConfig.aliasColumn,
});
if (existingConfig) {
// 기존 config에 display column 추가 (중복 방지)
if (!existingConfig.displayColumns?.includes(actualColumnName)) {
existingConfig.displayColumns = existingConfig.displayColumns || [];
existingConfig.displayColumns.push(actualColumnName);
logger.info(
`🔄 기존 조인 설정에 컬럼 병합: ${existingConfig.aliasColumn}${actualColumnName} (총 ${existingConfig.displayColumns.length}개)`
);
}
} else {
// 새 조인 설정 생성
const additionalJoinConfig: EntityJoinConfig = {
sourceTable: tableName,
sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id)
referenceTable:
(additionalColumn as any).referenceTable ||
baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng)
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code)
displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name)
displayColumn: actualColumnName, // 하위 호환성
aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name)
separator: " - ", // 기본 구분자
};
joinConfigs.push(additionalJoinConfig);
logger.info(
`✅ 추가 조인 컬럼 설정 추가: ${additionalJoinConfig.aliasColumn} -> ${actualColumnName}`
);
logger.info(`🔍 추가된 조인 설정 상세:`, {
sourceTable: additionalJoinConfig.sourceTable,
sourceColumn: additionalJoinConfig.sourceColumn,
referenceTable: additionalJoinConfig.referenceTable,
displayColumns: additionalJoinConfig.displayColumns,
aliasColumn: additionalJoinConfig.aliasColumn,
});
}
}
}
}
@ -3258,6 +3479,28 @@ export class TableManagementService {
startTime: number
): Promise<EntityJoinResponse> {
try {
// 🆕 참조 테이블별 전체 컬럼 목록 미리 조회
const referenceTableColumns = new Map<string, string[]>();
const uniqueRefTables = new Set(
joinConfigs
.filter((c) => c.referenceTable !== "table_column_category_values") // 카테고리는 제외
.map((c) => `${c.referenceTable}:${c.sourceColumn}`)
);
for (const key of uniqueRefTables) {
const refTable = key.split(":")[0];
if (!referenceTableColumns.has(key)) {
const cols = await query<{ column_name: string }>(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND table_schema = 'public'
ORDER BY ordinal_position`,
[refTable]
);
referenceTableColumns.set(key, cols.map((c) => c.column_name));
logger.info(`🔍 참조 테이블 컬럼 조회: ${refTable}${cols.length}`);
}
}
// 데이터 조회 쿼리
const dataQuery = entityJoinService.buildJoinQuery(
tableName,
@ -3266,7 +3509,9 @@ export class TableManagementService {
whereClause,
orderBy,
limit,
offset
offset,
undefined,
referenceTableColumns // 🆕 참조 테이블 전체 컬럼 전달
).query;
// 카운트 쿼리
@ -3767,12 +4012,12 @@ export class TableManagementService {
reference_table: string;
reference_column: string;
}>(
`SELECT column_name, reference_table, reference_column
`SELECT DISTINCT ON (column_name) column_name, reference_table, reference_column
FROM table_type_columns
WHERE table_name = $1
AND input_type = 'entity'
AND reference_table = $2
AND company_code = '*'
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END
LIMIT 1`,
[tableName, refTable]
);
@ -3883,7 +4128,7 @@ export class TableManagementService {
/**
*
*/
async getReferenceTableColumns(tableName: string): Promise<
async getReferenceTableColumns(tableName: string, companyCode?: string): Promise<
Array<{
columnName: string;
displayName: string;
@ -3891,7 +4136,7 @@ export class TableManagementService {
inputType?: string;
}>
> {
return await entityJoinService.getReferenceTableColumns(tableName);
return await entityJoinService.getReferenceTableColumns(tableName, companyCode);
}
/**
@ -4310,7 +4555,8 @@ export class TableManagementService {
END as "detailSettings",
ttc.is_nullable as "isNullable",
ic.data_type as "dataType",
ttc.company_code as "companyCode"
ttc.company_code as "companyCode",
ttc.category_ref as "categoryRef"
FROM table_type_columns ttc
LEFT JOIN information_schema.columns ic
ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name
@ -4387,20 +4633,24 @@ export class TableManagementService {
}
const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => {
const baseInfo = {
const baseInfo: any = {
tableName: tableName,
columnName: col.columnName,
displayName: col.displayName,
dataType: col.dataType || "varchar",
inputType: col.inputType,
detailSettings: col.detailSettings,
description: "", // 필수 필드 추가
isNullable: col.isNullable === "Y" ? "Y" : "N", // 🔥 FIX: string 타입으로 변환
description: "",
isNullable: col.isNullable === "Y" ? "Y" : "N",
isPrimaryKey: false,
displayOrder: 0,
isVisible: true,
};
if (col.categoryRef) {
baseInfo.categoryRef = col.categoryRef;
}
// 카테고리 타입인 경우 categoryMenus 추가
if (
col.inputType === "category" &&
@ -5005,14 +5255,14 @@ export class TableManagementService {
input_type: string;
display_column: string | null;
}>(
`SELECT column_name, reference_column, input_type, display_column
`SELECT DISTINCT ON (column_name) column_name, reference_column, input_type, display_column
FROM table_type_columns
WHERE table_name = $1
AND input_type IN ('entity', 'category')
AND reference_table = $2
AND reference_column IS NOT NULL
AND reference_column != ''
AND company_code = '*'`,
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
[rightTable, leftTable]
);
@ -5034,14 +5284,14 @@ export class TableManagementService {
input_type: string;
display_column: string | null;
}>(
`SELECT column_name, reference_column, input_type, display_column
`SELECT DISTINCT ON (column_name) column_name, reference_column, input_type, display_column
FROM table_type_columns
WHERE table_name = $1
AND input_type IN ('entity', 'category')
AND reference_table = $2
AND reference_column IS NOT NULL
AND reference_column != ''
AND company_code = '*'`,
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
[leftTable, rightTable]
);

View File

@ -44,6 +44,7 @@ export interface ColumnSettings {
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
displayOrder?: number; // 표시 순서
isVisible?: boolean; // 표시 여부
categoryRef?: string | null; // 카테고리 참조
}
export interface TableLabels {

View File

@ -164,6 +164,7 @@ export const componentDefaults: Record<string, any> = {
"v2-date": { type: "v2-date", webType: "date" },
"v2-repeater": { type: "v2-repeater", webType: "custom" },
"v2-repeat-container": { type: "v2-repeat-container", webType: "custom" },
"v2-split-line": { type: "v2-split-line", webType: "custom", resizable: true, lineWidth: 4 },
};
/**

85
bom-restore-verify.mjs Normal file
View File

@ -0,0 +1,85 @@
/**
* BOM Screen - Restoration Verification
* Screen 4168 - verify split panel, BOM list, and tree with child items
*/
import { chromium } from 'playwright';
import { mkdirSync, existsSync } from 'fs';
import { join } from 'path';
const SCREENSHOT_DIR = join(process.cwd(), 'bom-detail-test-screenshots');
async function ensureDir(dir) {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}
async function screenshot(page, name) {
ensureDir(SCREENSHOT_DIR);
await page.screenshot({ path: join(SCREENSHOT_DIR, `${name}.png`), fullPage: true });
console.log(` [Screenshot] ${name}.png`);
}
async function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function main() {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
try {
console.log('\n--- Step 1-2: Login ---');
await page.goto('http://localhost:9771/login', { waitUntil: 'load', timeout: 45000 });
await page.locator('input[type="text"], input[placeholder*="ID"]').first().fill('topseal_admin');
await page.locator('input[type="password"]').first().fill('qlalfqjsgh11');
await Promise.all([
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => {}),
page.locator('button:has-text("로그인")').first().click(),
]);
await sleep(3000);
console.log('\n--- Step 4-5: Navigate to screen 4168 ---');
await page.goto('http://localhost:9771/screens/4168', { waitUntil: 'load', timeout: 45000 });
await sleep(5000);
console.log('\n--- Step 6: Screenshot after load ---');
await screenshot(page, '10-bom-4168-initial');
const hasBomList = (await page.locator('text="BOM 목록"').count()) > 0;
const hasSplitPanel = (await page.locator('text="BOM 상세정보"').count()) > 0 || hasBomList;
const rowCount = await page.locator('table tbody tr').count();
const hasBomRows = rowCount > 0;
console.log('\n========== INITIAL STATE (Step 7) ==========');
console.log('BOM management screen loaded:', hasBomList || hasSplitPanel ? 'YES' : 'CHECK');
console.log('Split panel (BOM list left):', hasSplitPanel ? 'YES' : 'NO');
console.log('BOM data rows visible:', hasBomRows ? `YES (${rowCount} rows)` : 'NO');
if (hasBomRows) {
console.log('\n--- Step 8-9: Click first row ---');
await page.locator('table tbody tr').first().click();
await sleep(5000);
console.log('\n--- Step 10: Screenshot after row click ---');
await screenshot(page, '11-bom-4168-after-click');
const noDataMsg = (await page.locator('text="등록된 하위 품목이 없습니다"').count()) > 0;
const treeArea = page.locator('div:has-text("BOM 구성"), div:has-text("BOM 상세정보")').first();
const treeText = (await treeArea.textContent().catch(() => '') || '').substring(0, 600);
const hasChildItems = !noDataMsg && (treeText.includes('품번') || treeText.includes('레벨') || treeText.length > 150);
console.log('\n========== AFTER ROW CLICK (Step 11) ==========');
console.log('BOM tree shows child items:', hasChildItems ? 'YES' : noDataMsg ? 'NO (empty message)' : 'CHECK');
console.log('Tree preview:', treeText.substring(0, 300) + (treeText.length > 300 ? '...' : ''));
} else {
console.log('\n--- No BOM rows to click ---');
}
} catch (err) {
console.error('Error:', err.message);
try { await page.screenshot({ path: join(SCREENSHOT_DIR, '99-error.png'), fullPage: true }); } catch (e) {}
} finally {
await browser.close();
}
}
main();

271
bom-save-console-logs.txt Normal file
View File

@ -0,0 +1,271 @@
[info] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
[warning] Image with src "/images/vexplor.png" has either width or height modified, but not the other. If you use CSS to change the size of your image, also include the styles 'width: "auto"' or 'height: "auto"' to maintain the aspect ratio.
[log] 첫 번째 접근 가능한 메뉴로 이동: /screens/138
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[log] 📦 메인 테이블 데이터 자동 로드: company_mng {company_code: COMPANY_7, company_name: 탑씰 테스트, writer: wace, regdate: 2026-02-27T09:28:35.342Z, status: active}
[log] 📦 메인 테이블 데이터 자동 로드: company_mng {company_code: COMPANY_7, company_name: 탑씰 테스트, writer: wace, regdate: 2026-02-27T09:28:35.342Z, status: active}
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[error] Failed to load resource: the server responded with a status of 404 (Not Found)
[error] Failed to load resource: the server responded with a status of 404 (Not Found)
[error] Failed to load resource: the server responded with a status of 404 (Not Found)
[error] Failed to load resource: the server responded with a status of 404 (Not Found)
[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 1030135068124796000, error: 404}
[error] Failed to load resource: the server responded with a status of 404 (Not Found)
[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 666667496384701400, error: 404}
[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 88591267128165600, error: 404}
[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 1030135068124796000, error: 404}
[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 666667496384701400, error: 404}
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[error] Failed to load resource: the server responded with a status of 404 (Not Found)
[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 88591267128165600, error: 404}
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
[info] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [그룹합산] leftGroupSumConfig: null
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
[log] 🔍 [그룹합산] leftGroupSumConfig: null
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 0 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 0 leftGroupSumConfig: null
[log] 📦 [SplitPanelLayout] Context에 분할 패널 등록: {splitPanelId: split-panel-comp_split_panel, panelInfo: Object}
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object]
[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object]
[log] 📦 [SplitPanelLayout] Context에서 분할 패널 해제: split-panel-comp_split_panel
[log] 📦 [SplitPanelLayout] Context에 분할 패널 등록: {splitPanelId: split-panel-comp_split_panel, panelInfo: Object}
[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object]
[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object]
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] ✅ 좌측 컬럼 라벨 로드: {id: ID, created_date: 생성일시, updated_date: 수정일시, writer: 작성자, company_code: 회사코드}
[log] ✅ 좌측 컬럼 라벨 로드: {id: ID, created_date: 생성일시, updated_date: 수정일시, writer: 작성자, company_code: 회사코드}
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] ✅ 좌측 카테고리 매핑 로드 [status]: {CAT_MM3XFDT6_YULY: Object, CAT_MM3XFA7B_ZFD6: Object}
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] ✅ 좌측 카테고리 매핑 로드 [status]: {CAT_MM3XFDT6_YULY: Object, CAT_MM3XFA7B_ZFD6: Object}
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label]
[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7}
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [그룹합산] leftGroupSumConfig: null
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
[log] 🔍 [그룹합산] leftGroupSumConfig: null
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label]
[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7}
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [그룹합산] leftGroupSumConfig: null
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
[log] 🔍 [그룹합산] leftGroupSumConfig: null
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label]
[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7}
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [그룹합산] leftGroupSumConfig: null
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
[log] 🔍 [그룹합산] leftGroupSumConfig: null
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label]
[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7}
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [그룹합산] leftGroupSumConfig: null
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
[log] 🔍 [그룹합산] leftGroupSumConfig: null
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] ✅ 분할 패널 좌측 선택: bom {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7}
[log] 🔴 [ButtonPrimary] 저장 시 formData 디버그: {propsFormDataKeys: Array(70), screenContextFormDataKeys: Array(0), effectiveFormDataKeys: Array(70), process_code: undefined, equipment_code: undefined}
[log] [BomTree] openEditModal 가로채기 - editData 보정 {oldVersion: 1.0, newVersion: 1.0, oldCurrentVersionId: de575ae5-266c-42f0-be49-bcc65de89ebd, newCurrentVersionId: de575ae5-266c-42f0-be49-bcc65de89ebd}
[log] 🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침
[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object]
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] [EditModal] 모달 열림: {mode: UPDATE (수정), hasEditData: true, editDataId: 64617576-fec9-4caa-8e72-653f9e83ba45, isCreateMode: false}
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] [EditModal] 화면 데이터 로드 완료, 조건부 레이어 로드 시작: 4154
[log] [EditModal] loadConditionalLayersAndZones 호출됨: 4154
[log] [EditModal] API 호출 시작: getScreenLayers, getScreenZones
[log] [EditModal] API 응답: {layers: 1, zones: 0}
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label]
[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7}
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [그룹합산] leftGroupSumConfig: null
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
[log] 🔍 [그룹합산] leftGroupSumConfig: null
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168

149
db/migrate_company13_export.sh Executable file
View File

@ -0,0 +1,149 @@
#!/bin/bash
# ============================================================
# 엘에스티라유텍(주) - 동부지사 (COMPANY_13) 전체 데이터 Export
#
# 사용법:
# 1. SOURCE_* / TARGET_* 변수를 수정
# 2. chmod +x migrate_company13_export.sh
# 3. ./migrate_company13_export.sh export → SQL 파일 생성
# 4. ./migrate_company13_export.sh import → 대상 DB에 적재
# ============================================================
SOURCE_HOST="localhost"
SOURCE_PORT="5432"
SOURCE_DB="vexplor"
SOURCE_USER="postgres"
TARGET_HOST="대상_호스트"
TARGET_PORT="5432"
TARGET_DB="대상_DB명"
TARGET_USER="postgres"
OUTPUT_FILE="company13_migration_$(date '+%Y%m%d_%H%M%S').sql"
# 데이터가 있는 테이블 (의존성 순서)
TABLES=(
"company_mng"
"user_info"
"authority_master"
"menu_info"
"external_db_connections"
"external_rest_api_connections"
"screen_definitions"
"screen_groups"
"screen_layouts_v1"
"screen_layouts_v2"
"screen_layouts_v3"
"screen_menu_assignments"
"dashboards"
"dashboard_elements"
"flow_definition"
"node_flows"
"table_column_category_values"
"attach_file_info"
"tax_invoice"
"auth_tokens"
"batch_configs"
"batch_execution_logs"
"batch_mappings"
"digital_twin_layout"
"digital_twin_layout_template"
"dtg_management"
"transport_statistics"
"vehicles"
"vehicle_location_history"
)
do_export() {
echo "=========================================="
echo " COMPANY_13 데이터 Export 시작"
echo "=========================================="
cat > "$OUTPUT_FILE" <<'HEADER'
-- ============================================================
-- 엘에스티라유텍() - 동부지사 (COMPANY_13) 전체 데이터 마이그레이션
--
-- 총 29개 테이블, 약 11,500건 데이터
--
-- 실행 방법:
-- psql -h HOST -U USER -d DATABASE -f 이_파일명.sql
-- ============================================================
SET client_encoding TO 'UTF8';
SET standard_conforming_strings = on;
BEGIN;
HEADER
for TABLE in "${TABLES[@]}"; do
COUNT=$(psql -h "$SOURCE_HOST" -p "$SOURCE_PORT" -U "$SOURCE_USER" -d "$SOURCE_DB" \
-t -A -c "SELECT COUNT(*) FROM $TABLE WHERE company_code = 'COMPANY_13'")
COUNT=$(echo "$COUNT" | tr -d '[:space:]')
if [ "$COUNT" -gt 0 ]; then
echo " $TABLE: ${COUNT}건 추출 중..."
echo "-- ----------------------------------------" >> "$OUTPUT_FILE"
echo "-- $TABLE (${COUNT}건)" >> "$OUTPUT_FILE"
echo "-- ----------------------------------------" >> "$OUTPUT_FILE"
echo "COPY $TABLE FROM stdin;" >> "$OUTPUT_FILE"
psql -h "$SOURCE_HOST" -p "$SOURCE_PORT" -U "$SOURCE_USER" -d "$SOURCE_DB" \
-t -A -c "COPY (SELECT * FROM $TABLE WHERE company_code = 'COMPANY_13') TO STDOUT" >> "$OUTPUT_FILE"
echo "\\." >> "$OUTPUT_FILE"
echo "" >> "$OUTPUT_FILE"
else
echo " $TABLE: 데이터 없음 (건너뜀)"
fi
done
echo "" >> "$OUTPUT_FILE"
echo "COMMIT;" >> "$OUTPUT_FILE"
echo "" >> "$OUTPUT_FILE"
echo "-- 마이그레이션 완료" >> "$OUTPUT_FILE"
echo ""
echo "=========================================="
echo " Export 완료: $OUTPUT_FILE"
echo "=========================================="
echo ""
echo "대상 DB에서 실행:"
echo " psql -h $TARGET_HOST -p $TARGET_PORT -U $TARGET_USER -d $TARGET_DB -f $OUTPUT_FILE"
}
do_import() {
SQL_FILE=$(ls -t company13_migration_*.sql 2>/dev/null | head -1)
if [ -z "$SQL_FILE" ]; then
echo "마이그레이션 SQL 파일을 찾을 수 없습니다. 먼저 export를 실행하세요."
exit 1
fi
echo "=========================================="
echo " COMPANY_13 데이터 Import 시작"
echo " 파일: $SQL_FILE"
echo " 대상: $TARGET_HOST:$TARGET_PORT/$TARGET_DB"
echo "=========================================="
psql -h "$TARGET_HOST" -p "$TARGET_PORT" -U "$TARGET_USER" -d "$TARGET_DB" -f "$SQL_FILE"
echo ""
echo "=========================================="
echo " Import 완료"
echo "=========================================="
}
case "${1:-export}" in
export)
do_export
;;
import)
do_import
;;
*)
echo "사용법: $0 {export|import}"
exit 1
;;
esac

View File

@ -12,7 +12,7 @@ services:
NODE_ENV: production
PORT: "3001"
HOST: 0.0.0.0
DATABASE_URL: postgresql://postgres:vexplor0909!!@211.115.91.141:11134/plm
DATABASE_URL: postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor
JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024
JWT_EXPIRES_IN: 24h
CORS_ORIGIN: https://v1.vexplor.com,https://api.vexplor.com

278
docs/BOM_개발_현황.md Normal file
View File

@ -0,0 +1,278 @@
# BOM 관리 시스템 개발 현황
## 1. 개요
BOM(Bill of Materials) 관리 시스템은 제품의 구성 부품을 계층적으로 관리하는 기능입니다.
V2 컴포넌트 기반으로 구현되어 있으며, 설정 패널을 통해 모든 기능을 동적으로 구성할 수 있습니다.
---
## 2. 아키텍처
### 2.1 전체 구조
```
[프론트엔드] [백엔드] [데이터베이스]
v2-bom-tree (트리 뷰) ──── /api/bom ────── bomService.ts ────── bom, bom_detail
v2-bom-item-editor ──── /api/table-management ──────────── bom_history, bom_version
V2BomTreeConfigPanel (설정 패널)
```
### 2.2 관련 파일 목록
#### 프론트엔드
| 파일 | 설명 |
|------|------|
| `frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx` | BOM 트리/레벨 뷰 메인 컴포넌트 |
| `frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx` | 버전 관리 모달 |
| `frontend/lib/registry/components/v2-bom-tree/BomHistoryModal.tsx` | 이력 관리 모달 |
| `frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx` | BOM 항목 수정 모달 |
| `frontend/lib/registry/components/v2-bom-tree/BomTreeRenderer.tsx` | 트리 렌더러 |
| `frontend/lib/registry/components/v2-bom-tree/index.ts` | 컴포넌트 정의 (v2-bom-tree) |
| `frontend/components/v2/config-panels/V2BomTreeConfigPanel.tsx` | BOM 트리 설정 패널 |
| `frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx` | BOM 항목 편집기 (에디터 모드) |
#### 백엔드
| 파일 | 설명 |
|------|------|
| `backend-node/src/routes/bomRoutes.ts` | BOM API 라우트 정의 |
| `backend-node/src/controllers/bomController.ts` | BOM 컨트롤러 (이력/버전) |
| `backend-node/src/services/bomService.ts` | BOM 서비스 (비즈니스 로직) |
#### 데이터베이스
| 파일 | 설명 |
|------|------|
| `db/migrations/062_create_bom_history_version_tables.sql` | 이력/버전 테이블 DDL |
---
## 3. 데이터베이스 스키마
### 3.1 bom (BOM 헤더)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | VARCHAR (UUID) | PK |
| item_id | VARCHAR | 완제품 품목 ID (item_info FK) |
| bom_name | VARCHAR | BOM 명칭 |
| version | VARCHAR | 현재 사용중인 버전명 |
| revision | VARCHAR | 차수 |
| base_qty | NUMERIC | 기준수량 |
| unit | VARCHAR | 단위 |
| remark | TEXT | 비고 |
| company_code | VARCHAR | 회사 코드 (멀티테넌시) |
### 3.2 bom_detail (BOM 상세 - 자식 품목)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | VARCHAR (UUID) | PK |
| bom_id | VARCHAR | BOM 헤더 FK |
| parent_detail_id | VARCHAR | 부모 detail FK (NULL = 1레벨) |
| child_item_id | VARCHAR | 자식 품목 ID (item_info FK) |
| quantity | NUMERIC | 구성수량 (소요량) |
| unit | VARCHAR | 단위 |
| process_type | VARCHAR | 공정구분 (제조/외주 등) |
| loss_rate | NUMERIC | 손실율 |
| level | INTEGER | 레벨 |
| base_qty | NUMERIC | 기준수량 |
| revision | VARCHAR | 차수 |
| remark | TEXT | 비고 |
| company_code | VARCHAR | 회사 코드 |
### 3.3 bom_history (BOM 이력)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | VARCHAR (UUID) | PK |
| bom_id | VARCHAR | BOM 헤더 FK |
| revision | VARCHAR | 차수 |
| version | VARCHAR | 버전 |
| change_type | VARCHAR | 변경구분 (등록/수정/추가/삭제) |
| change_description | TEXT | 변경내용 |
| changed_by | VARCHAR | 변경자 |
| changed_date | TIMESTAMP | 변경일시 |
| company_code | VARCHAR | 회사 코드 |
### 3.4 bom_version (BOM 버전)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | VARCHAR (UUID) | PK |
| bom_id | VARCHAR | BOM 헤더 FK |
| version_name | VARCHAR | 버전명 (1.0, 2.0 ...) |
| revision | INTEGER | 생성 시점의 차수 |
| status | VARCHAR | 상태 (developing / active / inactive) |
| snapshot_data | JSONB | 스냅샷 (bom 헤더 + bom_detail 전체) |
| created_by | VARCHAR | 생성자 |
| created_date | TIMESTAMP | 생성일시 |
| company_code | VARCHAR | 회사 코드 |
---
## 4. API 명세
### 4.1 이력 API
| Method | Path | 설명 |
|--------|------|------|
| GET | `/api/bom/:bomId/history` | 이력 목록 조회 |
| POST | `/api/bom/:bomId/history` | 이력 등록 |
**Query Params**: `tableName` (설정 패널에서 지정한 이력 테이블명, 기본값: `bom_history`)
### 4.2 버전 API
| Method | Path | 설명 |
|--------|------|------|
| GET | `/api/bom/:bomId/versions` | 버전 목록 조회 |
| POST | `/api/bom/:bomId/versions` | 신규 버전 생성 |
| POST | `/api/bom/:bomId/versions/:versionId/load` | 버전 불러오기 (데이터 복원) |
| POST | `/api/bom/:bomId/versions/:versionId/activate` | 버전 사용 확정 |
| DELETE | `/api/bom/:bomId/versions/:versionId` | 버전 삭제 |
**Body/Query**: `tableName`, `detailTable` (설정 패널에서 지정한 테이블명)
---
## 5. 버전 관리 구조
### 5.1 핵심 원리
**각 버전은 생성 시점의 BOM 전체 구조(헤더 + 모든 디테일)를 JSONB 스냅샷으로 저장합니다.**
```
버전 1.0 (active)
└─ snapshot_data: { bom: {...}, details: [{...}, {...}, ...] }
버전 2.0 (developing)
└─ snapshot_data: { bom: {...}, details: [{...}, {...}, ...] }
버전 3.0 (inactive)
└─ snapshot_data: { bom: {...}, details: [{...}, {...}, ...] }
```
### 5.2 버전 상태 (status)
| 상태 | 설명 |
|------|------|
| `developing` | 개발중 - 신규 생성 시 기본 상태 |
| `active` | 사용중 - "사용 확정" 후 운영 상태 |
| `inactive` | 사용중지 - 이전에 active였다가 다른 버전이 확정된 경우 |
### 5.3 버전 워크플로우
```
[현재 BOM 데이터]
신규 버전 생성 ───► 버전 N.0 (status: developing)
├── 불러오기: 해당 스냅샷의 데이터로 현재 BOM을 복원
│ (status 변경 없음, BOM 헤더 version 변경 없음)
├── 사용 확정: status → active,
│ 기존 active 버전 → inactive,
│ BOM 헤더의 version 필드 갱신
└── 삭제: active 상태가 아닌 경우만 삭제 가능
```
### 5.4 불러오기 vs 사용 확정
| 동작 | 불러오기 (Load) | 사용 확정 (Activate) |
|------|----------------|---------------------|
| BOM 데이터 복원 | O (detail 전체 교체) | X |
| BOM 헤더 업데이트 | O (base_qty, unit 등) | version 필드만 |
| 버전 status 변경 | X | active로 변경 |
| 기존 active 비활성화 | X | O (→ inactive) |
| BOM 목록 새로고침 | O (refreshTable) | O (refreshTable) |
---
## 6. 설정 패널 구성
`V2BomTreeConfigPanel.tsx`에서 아래 항목을 설정할 수 있습니다:
### 6.1 기본 탭
| 설정 항목 | 설명 | 기본값 |
|-----------|------|--------|
| 디테일 테이블 | BOM 상세 데이터 테이블 | `bom_detail` |
| 외래키 | BOM 헤더와의 연결 키 | `bom_id` |
| 부모키 | 부모-자식 관계 키 | `parent_detail_id` |
| 이력 테이블 | BOM 변경 이력 테이블 | `bom_history` |
| 버전 테이블 | BOM 버전 관리 테이블 | `bom_version` |
| 이력 기능 표시 | 이력 버튼 노출 여부 | `true` |
| 버전 기능 표시 | 버전 버튼 노출 여부 | `true` |
### 6.2 컬럼 탭
- 소스 테이블 (bom/item_info 등)에서 표시할 컬럼 선택
- 디테일 테이블에서 표시할 컬럼 선택
- 컬럼 순서 드래그앤드롭
- 컬럼별 라벨, 너비, 정렬 설정
---
## 7. 뷰 모드
### 7.1 트리 뷰 (기본)
- 계층적 들여쓰기로 부모-자식 관계 표현
- 레벨별 시각 구분:
- **0레벨 (가상 루트)**: 파란색 배경 + 파란 좌측 바
- **1레벨**: 흰색 배경 + 초록 좌측 바
- **2레벨**: 연회색 배경 + 주황 좌측 바
- **3레벨 이상**: 진회색 배경 + 보라 좌측 바
- 펼침/접힘 (정전개/역전개)
### 7.2 레벨 뷰
- 평면 테이블 형태로 표시
- "레벨0", "레벨1", "레벨2" ... 컬럼에 체크마크로 계층 표시
- 같은 레벨별 배경색 구분 적용
---
## 8. 주요 기능 목록
| 기능 | 상태 | 설명 |
|------|------|------|
| BOM 트리 표시 | 완료 | 계층적 트리 뷰 + 레벨 뷰 |
| BOM 항목 편집 | 완료 | 더블클릭으로 수정 모달 (0레벨: bom, 하위: bom_detail) |
| 이력 관리 | 완료 | 변경 이력 조회/등록 모달 |
| 버전 관리 | 완료 | 버전 생성/불러오기/사용 확정/삭제 |
| 설정 패널 | 완료 | 테이블/컬럼/기능 동적 설정 |
| 디자인 모드 프리뷰 | 완료 | 실제 화면과 일치하는 디자인 모드 표시 |
| 컬럼 크기 조절 | 완료 | 헤더 드래그로 컬럼 너비 변경 |
| 텍스트 말줄임 | 완료 | 긴 텍스트 `...` 처리 |
| 레벨별 시각 구분 | 완료 | 배경색 + 좌측 컬러 바 |
| 정전개/역전개 | 완료 | 전체 펼침/접기 토글 |
| 좌우 스크롤 | 완료 | 컬럼 크기가 커질 때 수평 스크롤 |
| BOM 목록 자동 새로고침 | 완료 | 버전 불러오기/확정 후 좌측 패널 자동 리프레시 |
| BOM 하위 품목 저장 | 완료 | BomItemEditorComponent에서 직접 INSERT/UPDATE/DELETE |
| 차수 (Revision) 자동 증가 | 미구현 | BOM 변경 시 헤더 revision 자동 +1 |
---
## 9. 보안 고려사항
- **SQL 인젝션 방지**: `safeTableName()` 함수로 테이블명 검증 (`^[a-zA-Z_][a-zA-Z0-9_]*$`)
- **멀티테넌시**: 모든 API에서 `company_code` 필터링 적용
- **최고 관리자**: `company_code = "*"` 시 전체 데이터 조회 가능
- **인증**: `authenticateToken` 미들웨어로 모든 라우트 보호
---
## 10. 향후 개선 사항
- [ ] 차수(Revision) 자동 증가 구현 (BOM 헤더 레벨)
- [ ] 버전 비교 기능 (두 버전 간 diff)
- [ ] BOM 복사 기능
- [ ] 이력 자동 등록 (수정/저장 시 자동으로 이력 생성)
- [ ] Excel 내보내기/가져오기
- [ ] BOM 유효성 검증 (순환참조 방지 등)

File diff suppressed because it is too large Load Diff

1728
docs/DB_WORKFLOW_ANALYSIS.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,955 @@
# WACE ERP 시스템 전체 워크플로우 문서
> 작성일: 2026-02-06
> 분석 방법: Multi-Agent System (Backend + Frontend + DB 전문가 병렬 분석)
---
## 목차
1. [시스템 개요](#1-시스템-개요)
2. [기술 스택](#2-기술-스택)
3. [전체 아키텍처](#3-전체-아키텍처)
4. [백엔드 아키텍처](#4-백엔드-아키텍처)
5. [프론트엔드 아키텍처](#5-프론트엔드-아키텍처)
6. [데이터베이스 구조](#6-데이터베이스-구조)
7. [인증/인가 워크플로우](#7-인증인가-워크플로우)
8. [화면 디자이너 워크플로우](#8-화면-디자이너-워크플로우)
9. [사용자 업무 워크플로우](#9-사용자-업무-워크플로우)
10. [플로우 엔진 워크플로우](#10-플로우-엔진-워크플로우)
11. [데이터플로우 시스템](#11-데이터플로우-시스템)
12. [대시보드 시스템](#12-대시보드-시스템)
13. [배치/스케줄 시스템](#13-배치스케줄-시스템)
14. [멀티테넌시 아키텍처](#14-멀티테넌시-아키텍처)
15. [외부 연동](#15-외부-연동)
16. [배포 환경](#16-배포-환경)
---
## 1. 시스템 개요
WACE는 **로우코드(Low-Code) ERP 플랫폼**이다. 관리자가 코드 없이 드래그앤드롭으로 업무 화면을 설계하면, 사용자는 해당 화면으로 바로 업무를 처리할 수 있는 구조다.
### 핵심 컨셉
```
관리자 → 화면 디자이너로 화면 설계 → 메뉴에 연결
사용자 → 메뉴 클릭 → 화면 자동 렌더링 → 업무 수행
```
### 주요 특징
- **드래그앤드롭 화면 디자이너**: 코드 없이 UI 구성
- **동적 컴포넌트 시스템**: V2 통합 컴포넌트 10종으로 모든 UI 표현
- **플로우 엔진**: 워크플로우(승인, 이동 등) 자동화
- **데이터플로우**: 비즈니스 로직을 비주얼 다이어그램으로 설계
- **멀티테넌시**: 회사별 완벽한 데이터 격리
- **다국어 지원**: KR/EN/CN 다국어 라벨 관리
---
## 2. 기술 스택
| 영역 | 기술 | 비고 |
|------|------|------|
| **Frontend** | Next.js 15 (App Router) | React 19, TypeScript |
| **UI 라이브러리** | shadcn/ui + Radix UI | Tailwind CSS 4 |
| **상태 관리** | React Context + Zustand | React Query (서버 상태) |
| **Backend** | Node.js + Express | TypeScript |
| **Database** | PostgreSQL | Raw Query (ORM 미사용) |
| **인증** | JWT | 자동 갱신, 세션 관리 |
| **빌드/배포** | Docker | dev/prod 분리 |
| **포트** | FE: 9771(dev)/5555(prod) | BE: 8080 |
---
## 3. 전체 아키텍처
```
┌─────────────────────────────────────────────────────────────────┐
│ 사용자 브라우저 │
│ Next.js App (React 19 + shadcn/ui + Tailwind CSS) │
│ ├── 인증: JWT + Cookie + localStorage │
│ ├── 상태: Context + Zustand + React Query │
│ └── API: Axios Client (lib/api/) │
└──────────────────────────┬──────────────────────────────────────┘
│ HTTP/JSON (JWT Bearer Token)
┌─────────────────────────────────────────────────────────────────┐
│ Express Backend (Node.js) │
│ ├── Middleware: Helmet → CORS → RateLimit → Auth → Permission │
│ ├── Routes: 60+ 모듈 │
│ ├── Controllers: 69개 │
│ ├── Services: 87개 │
│ └── Database: pg Pool (Raw Query) │
└──────────────────────────┬──────────────────────────────────────┘
│ TCP/SQL
┌─────────────────────────────────────────────────────────────────┐
│ PostgreSQL Database │
│ ├── 시스템 테이블: 사용자, 회사, 메뉴, 권한, 화면 │
│ ├── 메타데이터: 테이블/컬럼 정의, 코드, 카테고리 │
│ ├── 비즈니스: 동적 생성 테이블 (화면별) │
│ └── 멀티테넌시: 모든 테이블에 company_code │
└─────────────────────────────────────────────────────────────────┘
```
---
## 4. 백엔드 아키텍처
### 4.1 디렉토리 구조
```
backend-node/src/
├── app.ts # Express 앱 진입점
├── config/ # 환경설정, Multer
├── controllers/ # 69개 컨트롤러
├── services/ # 87개 서비스
├── routes/ # 60+ 라우트 모듈
├── middleware/ # 인증, 권한, 에러 처리
│ ├── authMiddleware.ts # JWT 인증
│ ├── permissionMiddleware.ts # 3단계 권한 체크
│ ├── superAdminMiddleware.ts # 슈퍼관리자 전용
│ └── errorHandler.ts # 전역 에러 처리
├── database/ # DB 연결, 커넥터 팩토리
│ ├── db.ts # PostgreSQL Pool
│ ├── DatabaseConnectorFactory.ts
│ ├── PostgreSQLConnector.ts
│ ├── MySQLConnector.ts
│ └── MariaDBConnector.ts
├── types/ # TypeScript 타입 (26개)
└── utils/ # 유틸리티 (16개)
```
### 4.2 미들웨어 스택 (실행 순서)
```
요청 → Helmet (보안 헤더)
→ Compression (응답 압축)
→ Body Parser (JSON/URLEncoded, 10MB)
→ CORS (교차 출처 허용)
→ Rate Limiter (10,000 req/min)
→ Token Refresh (자동 갱신)
→ Route Handlers (비즈니스 로직)
→ Error Handler (전역 에러 처리)
```
### 4.3 API 라우트 도메인별 분류
#### 인증/사용자 관리
| 라우트 | 역할 |
|--------|------|
| `/api/auth` | 로그인, 로그아웃, 토큰 갱신, 회사 전환 |
| `/api/admin/users` | 사용자 CRUD, 비밀번호 초기화, 상태 변경 |
| `/api/company-management` | 회사 CRUD |
| `/api/departments` | 부서 관리 |
| `/api/roles` | 권한 그룹 관리 |
#### 화면/메뉴 관리
| 라우트 | 역할 |
|--------|------|
| `/api/screen-management` | 화면 정의 CRUD, 그룹, 파일, 임베딩 |
| `/api/admin/menus` | 메뉴 트리 CRUD, 화면 할당 |
| `/api/table-management` | 테이블 CRUD, 엔티티 조인, 카테고리 |
| `/api/common-codes` | 공통 코드/카테고리 관리 |
| `/api/multilang` | 다국어 키/번역 관리 |
#### 데이터 관리
| 라우트 | 역할 |
|--------|------|
| `/api/data` | 동적 테이블 CRUD, 조인 쿼리 |
| `/api/data/:tableName` | 특정 테이블 데이터 조회 |
| `/api/data/join` | 조인 쿼리 실행 |
| `/api/dynamic-form` | 동적 폼 데이터 저장 |
| `/api/entity-search` | 엔티티 검색 |
| `/api/entity-reference` | 엔티티 참조 |
| `/api/numbering-rules` | 채번 규칙 관리 |
| `/api/cascading-*` | 연쇄 드롭다운 관계 |
#### 자동화
| 라우트 | 역할 |
|--------|------|
| `/api/flow` | 플로우 정의/단계/연결/실행 |
| `/api/dataflow` | 데이터플로우 다이어그램/실행 |
| `/api/batch-configs` | 배치 작업 설정 |
| `/api/batch-management` | 배치 작업 관리 |
| `/api/batch-execution-logs` | 배치 실행 로그 |
#### 대시보드/리포트
| 라우트 | 역할 |
|--------|------|
| `/api/dashboards` | 대시보드 CRUD, 쿼리 실행 |
| `/api/reports` | 리포트 생성 |
#### 외부 연동
| 라우트 | 역할 |
|--------|------|
| `/api/external-db-connections` | 외부 DB 연결 (PostgreSQL, MySQL, MariaDB, MSSQL, Oracle) |
| `/api/external-rest-api-connections` | 외부 REST API 연결 |
| `/api/mail` | 메일 발송/수신/템플릿 |
| `/api/tax-invoice` | 세금계산서 |
#### 특수 도메인
| 라우트 | 역할 |
|--------|------|
| `/api/delivery` | 배송/화물 관리 |
| `/api/risk-alerts` | 위험 알림 |
| `/api/todos` | 할일 관리 |
| `/api/bookings` | 예약 관리 |
| `/api/digital-twin` | 디지털 트윈 (야드 모니터링) |
| `/api/schedule` | 스케줄 자동 생성 |
| `/api/vehicle` | 차량 운행 |
| `/api/driver` | 운전자 관리 |
| `/api/files` | 파일 업로드/다운로드 |
| `/api/ddl` | DDL 실행 (슈퍼관리자 전용) |
### 4.4 서비스 레이어 패턴
```typescript
// 표준 서비스 패턴
class ExampleService {
// 목록 조회 (멀티테넌시 적용)
async findAll(companyCode: string, filters?: any) {
if (companyCode === "*") {
// 슈퍼관리자: 전체 데이터
return await db.query("SELECT * FROM table ORDER BY company_code");
} else {
// 일반 사용자: 자기 회사 데이터만
return await db.query(
"SELECT * FROM table WHERE company_code = $1",
[companyCode]
);
}
}
}
```
### 4.5 에러 처리 전략
```typescript
// 전역 에러 핸들러 (errorHandler.ts)
- PostgreSQL 에러: 중복키(23505), 외래키(23503), 널 제약(23502) 등
- JWT 에러: 만료, 유효하지 않은 토큰
- 일반 에러: 500 Internal Server Error
- 개발 환경: 상세 에러 스택 포함
- 운영 환경: 일반적인 에러 메시지만 반환
```
---
## 5. 프론트엔드 아키텍처
### 5.1 디렉토리 구조
```
frontend/
├── app/ # Next.js App Router
│ ├── (auth)/ # 인증 (로그인)
│ ├── (main)/ # 메인 앱 (인증 필요)
│ ├── (pop)/ # 모바일/팝업
│ └── (admin)/ # 특수 관리자
├── components/ # React 컴포넌트
│ ├── screen/ # 화면 디자이너 & 뷰어
│ ├── admin/ # 관리 기능
│ ├── dashboard/ # 대시보드 위젯
│ ├── dataflow/ # 데이터플로우 디자이너
│ ├── v2/ # V2 통합 컴포넌트
│ ├── ui/ # shadcn/ui 기본 컴포넌트
│ └── report/ # 리포트 디자이너
├── lib/
│ ├── api/ # API 클라이언트 (57개 모듈)
│ ├── registry/ # 컴포넌트 레지스트리 (482개)
│ ├── utils/ # 유틸리티
│ └── v2-core/ # V2 코어 로직
├── contexts/ # React Context (인증, 메뉴, 화면 등)
├── hooks/ # Custom Hooks
├── stores/ # Zustand 상태관리
└── middleware.ts # Next.js 인증 미들웨어
```
### 5.2 페이지 라우팅 구조
```
/login → 로그인
/main → 메인 대시보드
/screens/[screenId] → 동적 화면 뷰어 (사용자)
/admin/screenMng/screenMngList → 화면 관리
/admin/screenMng/dashboardList → 대시보드 관리
/admin/screenMng/reportList → 리포트 관리
/admin/systemMng/tableMngList → 테이블 관리
/admin/systemMng/commonCodeList → 공통코드 관리
/admin/systemMng/dataflow → 데이터플로우 관리
/admin/systemMng/i18nList → 다국어 관리
/admin/userMng/userMngList → 사용자 관리
/admin/userMng/companyList → 회사 관리
/admin/userMng/rolesList → 권한 관리
/admin/automaticMng/flowMgmtList → 플로우 관리
/admin/automaticMng/batchmngList → 배치 관리
/admin/automaticMng/mail/* → 메일 시스템
/admin/menu → 메뉴 관리
/dashboard/[dashboardId] → 대시보드 뷰어
/pop/work → 모바일 작업 화면
```
### 5.3 V2 통합 컴포넌트 시스템
**"하나의 컴포넌트, 여러 모드"** 철학으로 설계된 10개 통합 컴포넌트:
| 컴포넌트 | 모드 | 역할 |
|----------|------|------|
| **V2Input** | text, number, password, slider, color | 텍스트/숫자 입력 |
| **V2Select** | dropdown, radio, checkbox, tag, toggle | 선택 입력 |
| **V2Date** | date, datetime, time, range | 날짜/시간 입력 |
| **V2List** | table, card, kanban, list | 데이터 목록 표시 |
| **V2Layout** | grid, split-panel, flex | 레이아웃 구성 |
| **V2Group** | tab, accordion, section, modal | 그룹 컨테이너 |
| **V2Media** | image, video, audio, file | 미디어 표시 |
| **V2Biz** | flow, rack, numbering-rule | 비즈니스 로직 |
| **V2Hierarchy** | tree, org-chart, BOM, cascading | 계층 구조 |
| **V2Repeater** | inline-table, modal, button | 반복 데이터 |
### 5.4 API 클라이언트 규칙
```typescript
// 절대 금지: fetch 직접 사용
const res = await fetch('/api/flow/definitions'); // ❌
// 반드시 사용: lib/api/ 클라이언트
import { getFlowDefinitions } from '@/lib/api/flow';
const res = await getFlowDefinitions(); // ✅
```
환경별 URL 자동 처리:
| 환경 | 프론트엔드 | 백엔드 API |
|------|-----------|-----------|
| 로컬 개발 | localhost:9771 | localhost:8080/api |
| 운영 | v1.vexplor.com | api.vexplor.com/api |
### 5.5 상태 관리 체계
```
전역 상태
├── AuthContext → 인증/세션/토큰
├── MenuContext → 메뉴 트리/권한
├── ScreenPreviewContext → 프리뷰 모드
├── ScreenMultiLangContext → 다국어 라벨
├── TableOptionsContext → 테이블 옵션
└── ActiveTabContext → 활성 탭
로컬 상태
├── Zustand Stores → 화면 디자이너 상태, 사용자 상태
└── React Query → 서버 데이터 캐시 (5분 stale, 30분 GC)
```
### 5.6 레지스트리 시스템
```typescript
// 컴포넌트 등록 (482개 등록됨)
ComponentRegistry.registerComponent({
id: "v2-input",
name: "통합 입력",
category: ComponentCategory.V2,
component: V2Input,
configPanel: V2InputConfigPanel,
defaultConfig: { inputType: "text" }
});
// 동적 렌더링
<DynamicComponentRenderer
component={componentData}
formData={formData}
onFormDataChange={handleChange}
/>
```
---
## 6. 데이터베이스 구조
### 6.1 테이블 도메인별 분류
#### 사용자/인증/회사
| 테이블 | 역할 |
|--------|------|
| `company_mng` | 회사 마스터 |
| `user_info` | 사용자 정보 |
| `user_info_history` | 사용자 변경 이력 |
| `user_dept` | 사용자-부서 매핑 |
| `dept_info` | 부서 정보 |
| `authority_master` | 권한 그룹 마스터 |
| `authority_sub_user` | 사용자-권한 매핑 |
| `login_access_log` | 로그인 로그 |
#### 메뉴/화면
| 테이블 | 역할 |
|--------|------|
| `menu_info` | 메뉴 트리 구조 |
| `screen_definitions` | 화면 정의 (screenId, 테이블명 등) |
| `screen_layouts_v2` | V2 레이아웃 (JSON) |
| `screen_layouts` | V1 레이아웃 (레거시) |
| `screen_groups` | 화면 그룹 (계층구조) |
| `screen_group_screens` | 화면-그룹 매핑 |
| `screen_menu_assignments` | 화면-메뉴 할당 |
| `screen_field_joins` | 화면 필드 조인 설정 |
| `screen_data_flows` | 화면 데이터 플로우 |
| `screen_table_relations` | 화면-테이블 관계 |
#### 메타데이터
| 테이블 | 역할 |
|--------|------|
| `table_type_columns` | 테이블 타입별 컬럼 정의 (회사별) |
| `table_column_category_values` | 컬럼 카테고리 값 |
| `code_category` | 공통 코드 카테고리 |
| `code_info` | 공통 코드 값 |
| `category_column_mapping` | 카테고리-컬럼 매핑 |
| `cascading_relation` | 연쇄 드롭다운 관계 |
| `numbering_rules` | 채번 규칙 |
| `numbering_rule_parts` | 채번 규칙 파트 |
#### 플로우/자동화
| 테이블 | 역할 |
|--------|------|
| `flow_definition` | 플로우 정의 |
| `flow_step` | 플로우 단계 |
| `flow_step_connection` | 플로우 단계 연결 |
| `node_flows` | 노드 플로우 (버튼 액션) |
| `dataflow_diagrams` | 데이터플로우 다이어그램 |
| `batch_definitions` | 배치 작업 정의 |
| `batch_schedules` | 배치 스케줄 |
| `batch_execution_logs` | 배치 실행 로그 |
#### 외부 연동
| 테이블 | 역할 |
|--------|------|
| `external_db_connections` | 외부 DB 연결 정보 |
| `external_rest_api_connections` | 외부 REST API 연결 |
#### 다국어
| 테이블 | 역할 |
|--------|------|
| `multi_lang_key_master` | 다국어 키 마스터 |
#### 기타
| 테이블 | 역할 |
|--------|------|
| `work_history` | 작업 이력 |
| `todo_items` | 할일 목록 |
| `file_uploads` | 파일 업로드 |
| `ddl_audit_log` | DDL 감사 로그 |
### 6.2 동적 테이블 생성 패턴
관리자가 화면 생성 시 비즈니스 테이블이 동적으로 생성된다:
```sql
CREATE TABLE "dynamic_table_name" (
"id" VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
"created_date" TIMESTAMP DEFAULT now(),
"updated_date" TIMESTAMP DEFAULT now(),
"writer" VARCHAR(500),
"company_code" VARCHAR(500), -- 멀티테넌시 필수!
-- 사용자 정의 컬럼들 (모두 VARCHAR(500))
"product_name" VARCHAR(500),
"price" VARCHAR(500),
...
);
CREATE INDEX idx_dynamic_company ON "dynamic_table_name"(company_code);
```
### 6.3 테이블 관계도
```
company_mng (company_code PK)
├── user_info (company_code FK)
│ ├── authority_sub_user (user_id FK)
│ └── user_dept (user_id FK)
├── menu_info (company_code)
│ └── screen_menu_assignments (menu_objid FK)
├── screen_definitions (company_code)
│ ├── screen_layouts_v2 (screen_id FK)
│ ├── screen_groups → screen_group_screens (screen_id FK)
│ └── screen_field_joins (screen_id FK)
├── authority_master (company_code)
│ └── authority_sub_user (master_objid FK)
├── flow_definition (company_code)
│ ├── flow_step (flow_id FK)
│ └── flow_step_connection (flow_id FK)
└── [동적 비즈니스 테이블들] (company_code)
```
---
## 7. 인증/인가 워크플로우
### 7.1 로그인 프로세스
```
┌─── 사용자 ───┐ ┌─── 프론트엔드 ───┐ ┌─── 백엔드 ───┐ ┌─── DB ───┐
│ │ │ │ │ │ │ │
│ ID/PW 입력 │────→│ POST /auth/login │────→│ 비밀번호 검증 │────→│ user_info│
│ │ │ │ │ │ │ 조회 │
│ │ │ │ │ JWT 토큰 생성 │ │ │
│ │ │ │←────│ 토큰 반환 │ │ │
│ │ │ │ │ │ │ │
│ │ │ localStorage 저장│ │ │ │ │
│ │ │ Cookie 저장 │ │ │ │ │
│ │ │ /main 리다이렉트 │ │ │ │ │
└──────────────┘ └──────────────────┘ └──────────────┘ └──────────┘
```
### 7.2 JWT 토큰 관리
```
토큰 저장: localStorage (주 저장소) + Cookie (SSR 미들웨어용)
자동 갱신:
├── 10분마다 만료 시간 체크
├── 만료 30분 전: 백그라운드 자동 갱신
├── 401 응답 시: 즉시 갱신 시도
└── 갱신 실패 시: /login 리다이렉트
세션 관리:
├── 데스크톱: 30분 비활성 → 세션 만료 (5분 전 경고)
└── 모바일: 24시간 비활성 → 세션 만료 (1시간 전 경고)
```
### 7.3 권한 체계 (3단계)
```
SUPER_ADMIN (company_code = "*")
├── 모든 회사 데이터 접근 가능
├── DDL 실행 가능
├── 시스템 설정 변경
└── 다른 회사로 전환 (switch-company)
COMPANY_ADMIN (userType = "COMPANY_ADMIN")
├── 자기 회사 데이터만 접근
├── 사용자 관리 가능
└── 메뉴/화면 관리 가능
USER (일반 사용자)
├── 자기 회사 데이터만 접근
├── 권한 그룹에 따른 메뉴 접근
└── 할당된 화면만 사용 가능
```
---
## 8. 화면 디자이너 워크플로우
### 8.1 관리자: 화면 설계
```
Step 1: 화면 생성
└→ /admin/screenMng/screenMngList
└→ "새 화면" 클릭 → 화면명, 설명, 메인 테이블 입력
Step 2: 화면 디자이너 진입 (ScreenDesigner.tsx)
├── 좌측 패널: 컴포넌트 팔레트 (V2 컴포넌트 10종)
├── 중앙 캔버스: 드래그앤드롭 영역
└── 우측 패널: 선택된 컴포넌트 속성 설정
Step 3: 컴포넌트 배치
└→ V2Input 드래그 → 캔버스 배치 → 속성 설정:
├── 위치: x, y 좌표
├── 크기: width, height
├── 데이터 바인딩: columnName = "product_name"
├── 라벨: "제품명"
├── 조건부 표시: 특정 조건에서만 보이기
└── 플로우 연결: 버튼 클릭 시 실행할 플로우
Step 4: 레이아웃 저장
└→ screen_layouts_v2 테이블에 JSON 형태로 저장
└→ Zod 스키마 검증 → V2 형식 우선, V1 호환 저장
Step 5: 메뉴에 화면 할당
└→ /admin/menu → 메뉴 트리에서 "제품 관리" 선택
└→ 화면 연결 (screen_menu_assignments)
```
### 8.2 화면 레이아웃 저장 구조 (V2)
```json
{
"version": "v2",
"components": [
{
"id": "comp-1",
"componentType": "v2-input",
"position": { "x": 100, "y": 50 },
"size": { "width": 200, "height": 40 },
"config": {
"inputType": "text",
"columnName": "product_name",
"label": "제품명",
"required": true
}
},
{
"id": "comp-2",
"componentType": "v2-list",
"position": { "x": 100, "y": 150 },
"size": { "width": 600, "height": 400 },
"config": {
"listType": "table",
"tableName": "products",
"columns": ["product_name", "price", "quantity"]
}
}
]
}
```
---
## 9. 사용자 업무 워크플로우
### 9.1 전체 흐름
```
사용자 로그인
메인 대시보드 (/main)
좌측 메뉴에서 "제품 관리" 클릭
/screens/[screenId] 라우팅
InteractiveScreenViewer 렌더링
├── screen_definitions에서 화면 정보 로드
├── screen_layouts_v2에서 레이아웃 JSON 로드
├── V2 → Legacy 변환 (호환성)
└── 메인 테이블 데이터 자동 로드
컴포넌트별 렌더링
├── V2Input → formData 바인딩
├── V2List → 테이블 데이터 표시
├── V2Select → 드롭다운/라디오 선택
└── Button → 플로우/액션 연결
사용자 인터랙션
├── 폼 입력 → formData 업데이트
├── 테이블 행 선택 → selectedRowsData 업데이트
└── 버튼 클릭 → 플로우 실행
플로우 실행 (nodeFlowButtonExecutor)
├── Step 1: 데이터 검증
├── Step 2: API 호출 (INSERT/UPDATE/DELETE)
├── Step 3: 성공/실패 처리
└── Step 4: 테이블 자동 새로고침
```
### 9.2 조건부 표시 워크플로우
```
관리자 설정:
"특별 할인 입력" 컴포넌트
└→ 조건: product_type === "PREMIUM" 일 때만 표시
사용자 사용:
1. 화면 진입 → evaluateConditional() 실행
2. product_type ≠ "PREMIUM" → "특별 할인 입력" 숨김
3. 사용자가 product_type을 "PREMIUM"으로 변경
4. formData 업데이트 → evaluateConditional() 재평가
5. product_type === "PREMIUM" → "특별 할인 입력" 표시!
```
---
## 10. 플로우 엔진 워크플로우
### 10.1 플로우 정의 (관리자)
```
/admin/automaticMng/flowMgmtList
플로우 생성:
├── 이름: "제품 승인 플로우"
├── 테이블: "products"
└── 단계 정의:
Step 1: "신청" (requester)
Step 2: "부서장 승인" (manager)
Step 3: "최종 승인" (director)
연결: Step 1 → Step 2 → Step 3
```
### 10.2 플로우 실행 (사용자)
```
1. 사용자: 제품 신청
└→ "저장" 버튼 클릭
└→ flowApi.startFlow() → 상태: "부서장 승인 대기"
2. 부서장: 승인 화면
└→ V2Biz (flow) 컴포넌트 → 현재 단계 표시
└→ [승인] 클릭 → flowApi.approveStep()
└→ 상태: "최종 승인 대기"
3. 이사: 최종 승인
└→ [승인] 클릭 → flowApi.approveStep()
└→ 상태: "완료"
└→ products.approval_status = "APPROVED"
```
### 10.3 데이터 이동 (moveData)
```
플로우의 핵심 동작: 데이터를 한 스텝에서 다음 스텝으로 이동
Step 1 (접수) → Step 2 (검토) → Step 3 (완료)
├── 단건 이동: moveData(flowId, dataId, fromStep, toStep)
└── 배치 이동: moveBatchData(flowId, dataIds[], fromStep, toStep)
```
---
## 11. 데이터플로우 시스템
### 11.1 개요
데이터플로우는 비즈니스 로직을 **비주얼 다이어그램**으로 설계하는 시스템이다.
```
/admin/systemMng/dataflow
React Flow 기반 캔버스
├── InputNode: 데이터 입력 (폼 데이터, 테이블 데이터)
├── TransformNode: 데이터 변환 (매핑, 필터링, 계산)
├── DatabaseNode: DB 조회/저장
├── RestApiNode: 외부 API 호출
├── ConditionNode: 조건 분기
├── LoopNode: 반복 처리
├── MergeNode: 데이터 합치기
└── OutputNode: 결과 출력
```
### 11.2 데이터플로우 실행
```
버튼 클릭 → 데이터플로우 트리거
InputNode: formData 수집
TransformNode: 데이터 가공
ConditionNode: 조건 분기 (가격 > 10000?)
├── Yes → DatabaseNode: INSERT INTO premium_products
└── No → DatabaseNode: INSERT INTO standard_products
OutputNode: 결과 반환 → toast.success("저장 완료")
```
---
## 12. 대시보드 시스템
### 12.1 구조
```
관리자: /admin/screenMng/dashboardList
└→ 대시보드 생성 → 위젯 추가 → 레이아웃 저장
사용자: /dashboard/[dashboardId]
└→ 위젯 그리드 렌더링 → 실시간 데이터 표시
```
### 12.2 위젯 종류
| 카테고리 | 위젯 | 역할 |
|----------|------|------|
| 시각화 | CustomMetricWidget | 커스텀 메트릭 표시 |
| | StatusSummaryWidget | 상태 요약 |
| 리스트 | CargoListWidget | 화물 목록 |
| | VehicleListWidget | 차량 목록 |
| 지도 | MapTestWidget | 지도 표시 |
| | WeatherMapWidget | 날씨 지도 |
| 작업 | TodoWidget | 할일 목록 |
| | WorkHistoryWidget | 작업 이력 |
| 알림 | BookingAlertWidget | 예약 알림 |
| | RiskAlertWidget | 위험 알림 |
| 기타 | ClockWidget | 시계 |
| | CalendarWidget | 캘린더 |
---
## 13. 배치/스케줄 시스템
### 13.1 구조
```
관리자: /admin/automaticMng/batchmngList
배치 작업 생성:
├── 이름: "일일 재고 집계"
├── 실행 쿼리: SQL 또는 데이터플로우 ID
├── 스케줄: Cron 표현식 ("0 0 * * *" = 매일 자정)
└── 활성화/비활성화
배치 스케줄러 (batch_schedules)
자동 실행 → 실행 로그 (batch_execution_logs)
```
### 13.2 배치 실행 흐름
```
Cron 트리거 → 배치 정의 조회 → SQL/데이터플로우 실행
성공: execution_log에 "SUCCESS" 기록
실패: execution_log에 "FAILED" + 에러 메시지 기록
```
---
## 14. 멀티테넌시 아키텍처
### 14.1 핵심 원칙
```
모든 비즈니스 테이블: company_code 컬럼 필수
모든 쿼리: WHERE company_code = $1 필수
모든 JOIN: ON a.company_code = b.company_code 필수
모든 집계: GROUP BY company_code 필수
```
### 14.2 데이터 격리
```
회사 A (company_code = "COMPANY_A"):
└→ 자기 데이터만 조회/수정/삭제 가능
회사 B (company_code = "COMPANY_B"):
└→ 자기 데이터만 조회/수정/삭제 가능
슈퍼관리자 (company_code = "*"):
└→ 모든 회사 데이터 조회 가능
└→ 일반 회사는 "*" 데이터를 볼 수 없음
중요: company_code = "*"는 공통 데이터가 아니라 슈퍼관리자 전용 데이터!
```
### 14.3 코드 패턴
```typescript
// 백엔드 표준 패턴
const companyCode = req.user!.companyCode;
if (companyCode === "*") {
// 슈퍼관리자: 전체 데이터
query = "SELECT * FROM table ORDER BY company_code";
} else {
// 일반 사용자: 자기 회사만, "*" 제외
query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'";
params = [companyCode];
}
```
---
## 15. 외부 연동
### 15.1 외부 DB 연결
```
지원 DB: PostgreSQL, MySQL, MariaDB, MSSQL, Oracle
관리: /api/external-db-connections
├── 연결 정보 등록 (host, port, database, credentials)
├── 연결 테스트
├── 쿼리 실행
└── 데이터플로우에서 DatabaseNode로 사용
```
### 15.2 외부 REST API 연결
```
관리: /api/external-rest-api-connections
├── API 엔드포인트 등록 (URL, method, headers)
├── 인증 설정 (Bearer, Basic, API Key)
├── 테스트 호출
└── 데이터플로우에서 RestApiNode로 사용
```
### 15.3 메일 시스템
```
관리: /admin/automaticMng/mail/*
├── 메일 템플릿 관리
├── 메일 발송 (개별/대량)
├── 수신 메일 확인
└── 발송 이력 조회
```
---
## 16. 배포 환경
### 16.1 Docker 구성
```
개발 환경 (Mac):
├── docker/dev/docker-compose.backend.mac.yml (BE: 8080)
└── docker/dev/docker-compose.frontend.mac.yml (FE: 9771)
운영 환경:
├── docker/prod/docker-compose.backend.prod.yml (BE: 8080)
└── docker/prod/docker-compose.frontend.prod.yml (FE: 5555)
```
### 16.2 서버 정보
| 환경 | 서버 | 포트 | DB |
|------|------|------|-----|
| 개발 | 39.117.244.52 | FE:9771, BE:8080 | 39.117.244.52:11132 |
| 운영 | 211.115.91.141 | FE:5555, BE:8080 | 211.115.91.141:11134 |
### 16.3 백엔드 시작 시 자동 작업
```
서버 시작 (app.ts)
├── 마이그레이션 실행 (DB 스키마 업데이트)
├── 배치 스케줄러 초기화
├── 위험 알림 캐시 로드
└── 메일 정리 Cron 시작
```
---
## 부록: 업무 진행 요약
### 새로운 업무 화면을 만드는 전체 프로세스
```
1. [DB] 테이블 관리에서 비즈니스 테이블 생성
└→ 컬럼 정의, 타입 설정
2. [화면] 화면 관리에서 새 화면 생성
└→ 메인 테이블 지정
3. [디자인] 화면 디자이너에서 UI 구성
└→ V2 컴포넌트 배치, 데이터 바인딩
4. [로직] 데이터플로우 설계 (필요시)
└→ 저장/수정/삭제 로직 다이어그램
5. [플로우] 플로우 정의 (승인 프로세스 필요시)
└→ 단계 정의, 연결
6. [메뉴] 메뉴에 화면 할당
└→ 사용자가 접근할 수 있게 메뉴 트리 배치
7. [권한] 권한 그룹에 메뉴 할당
└→ 특정 사용자 그룹만 접근 가능하게
8. [사용] 사용자가 메뉴 클릭 → 업무 시작!
```

View File

@ -0,0 +1,246 @@
# WACE ERP Backend - 분석 문서 인덱스
> **분석 완료일**: 2026-02-06
> **분석자**: Backend Specialist
---
## 📚 문서 목록
### 1. 📖 상세 분석 문서
**파일**: `backend-architecture-detailed-analysis.md`
**내용**: 백엔드 전체 아키텍처 상세 분석 (16개 섹션)
- 전체 개요 및 기술 스택
- 디렉토리 구조
- 미들웨어 스택 구성
- 인증/인가 시스템 (JWT, 3단계 권한)
- 멀티테넌시 구현 방식
- API 라우트 전체 목록
- 비즈니스 도메인별 모듈 (8개 도메인)
- 데이터베이스 접근 방식 (Raw Query)
- 외부 시스템 연동 (DB/REST API)
- 배치/스케줄 처리 (node-cron)
- 파일 처리 (multer)
- 에러 핸들링
- 로깅 시스템 (Winston)
- 보안 및 권한 관리
- 성능 최적화
**특징**: 워크플로우 문서에 통합하기 위한 완전한 아키텍처 분석
---
### 2. 📄 요약 문서
**파일**: `backend-architecture-summary.md`
**내용**: 백엔드 아키텍처 핵심 요약 (16개 섹션 압축)
- 기술 스택 요약
- 계층 구조 다이어그램
- 디렉토리 구조
- 미들웨어 스택 순서
- 인증/인가 흐름도
- 멀티테넌시 핵심 원칙
- API 라우트 카테고리별 정리
- 비즈니스 도메인 8개 요약
- 데이터베이스 접근 패턴
- 외부 연동 아키텍처
- 배치 스케줄러 시스템
- 파일 처리 흐름
- 보안 정책
- 에러 핸들링 전략
- 로깅 구조
- 성능 최적화 전략
- **핵심 체크리스트** (개발 시 필수 규칙 8개)
**특징**: 빠른 참조를 위한 간결한 요약
---
### 3. 🔗 API 라우트 완전 매핑
**파일**: `backend-api-route-mapping.md`
**내용**: 프론트엔드 개발자용 API 엔드포인트 전체 목록 (200+개)
#### 포함된 API 카테고리
1. 인증 API (7개)
2. 관리자 API (15개)
3. 테이블 관리 API (30개)
4. 화면 관리 API (10개)
5. 플로우 API (15개)
6. 데이터플로우 API (10개)
7. 외부 연동 API (15개)
8. 배치 API (10개)
9. 메일 API (5개)
10. 파일 API (5개)
11. 대시보드 API (5개)
12. 공통코드 API (3개)
13. 다국어 API (3개)
14. 회사 관리 API (4개)
15. 부서 API (2개)
16. 권한 그룹 API (2개)
17. DDL 실행 API (1개)
18. 외부 API 프록시 (2개)
19. 디지털 트윈 API (3개)
20. 3D 필드 API (2개)
21. 스케줄 API (1개)
22. 채번 규칙 API (3개)
23. 엔티티 검색 API (2개)
24. To-Do API (3개)
25. 예약 요청 API (2개)
26. 리스크/알림 API (2개)
27. 헬스 체크 (1개)
#### 각 API 정보 포함
- HTTP 메서드
- 엔드포인트 경로
- 필요 권한 (공개/인증/관리자/슈퍼관리자)
- 기능 설명
- Request Body/Query Params
- Response 형식
#### 추가 정보
- Base URL (개발/운영)
- 공통 헤더 (Authorization)
- 응답 형식 (성공/에러)
- 에러 코드 목록
**특징**: 프론트엔드에서 API 호출 시 즉시 참조 가능
---
### 4. 📊 JSON 응답 요약
**파일**: `backend-analysis-response.json`
**내용**: 구조화된 JSON 형식의 분석 결과
```json
{
"status": "success",
"confidence": "high",
"result": {
"summary": "...",
"details": "...",
"files_affected": [...],
"key_findings": {
"architecture_pattern": "...",
"tech_stack": {...},
"middleware_stack": [...],
"authentication_flow": {...},
"permission_levels": {...},
"multi_tenancy": {...},
"business_domains": {...},
"database_access": {...},
"security": {...},
"performance_optimization": {...}
},
"critical_rules": [...]
}
}
```
**특징**: 프로그래밍 방식으로 분석 결과 활용 가능
---
## 🎯 핵심 요약
### 아키텍처
- **패턴**: Layered Architecture (Controller → Service → Database)
- **언어**: TypeScript (Strict Mode)
- **프레임워크**: Express.js
- **데이터베이스**: PostgreSQL (Raw Query, Connection Pool)
- **인증**: JWT (24시간 만료, 자동 갱신)
### 멀티테넌시
```typescript
// ✅ 핵심 원칙
const companyCode = req.user!.companyCode; // JWT에서 추출
if (companyCode === "*") {
// 슈퍼관리자: 모든 데이터
query = "SELECT * FROM table ORDER BY company_code";
} else {
// 일반 사용자: 자기 회사만 + 슈퍼관리자 숨김
query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'";
params = [companyCode];
}
```
### 권한 체계 (3단계)
1. **SUPER_ADMIN** (`company_code = "*"`)
- 전체 회사 데이터 접근
- DDL 실행, 회사 생성/삭제
2. **COMPANY_ADMIN** (`company_code = "ILSHIN"`)
- 자기 회사 데이터만 접근
- 사용자/설정 관리
3. **USER** (`company_code = "ILSHIN"`)
- 자기 회사 데이터만 접근
- 읽기/쓰기만
### 주요 도메인 (8개)
1. **관리자** - 사용자/메뉴/권한
2. **테이블/화면** - 메타데이터, 동적 화면
3. **플로우** - 워크플로우 엔진
4. **데이터플로우** - ERD, 관계도
5. **외부 연동** - 외부 DB/REST API
6. **배치** - Cron 스케줄러
7. **메일** - 발송/수신
8. **파일** - 업로드/다운로드
### API 통계
- **총 라우트**: 70+개
- **총 API**: 200+개
- **컨트롤러**: 70+개
- **서비스**: 80+개
- **미들웨어**: 4개
---
## 🚨 개발 시 필수 규칙
**모든 쿼리에 `company_code` 필터 추가**
**JWT 토큰에서 `company_code` 추출 (클라이언트 신뢰 금지)**
**Parameterized Query 사용 (SQL Injection 방지)**
**슈퍼관리자 데이터 숨김 (`company_code != '*'`)**
**비밀번호는 bcrypt, 민감정보는 AES-256**
**에러 핸들링 try/catch 필수**
**트랜잭션이 필요한 경우 `transaction()` 사용**
✅ **파일 업로드는 회사별 디렉토리 분리**
---
## 📁 문서 위치
```
ERP-node/docs/
├── backend-architecture-detailed-analysis.md (상세 분석, 16개 섹션)
├── backend-architecture-summary.md (요약, 간결한 참조)
├── backend-api-route-mapping.md (API 200+개 전체 매핑)
└── backend-analysis-response.json (JSON 구조화 데이터)
```
---
## 🔍 문서 사용 가이드
### 처음 백엔드를 이해하려면
`backend-architecture-summary.md` 읽기 (20분)
### 특정 기능을 구현하려면
`backend-architecture-detailed-analysis.md`에서 해당 도메인 섹션 참조
### API를 호출하려면
`backend-api-route-mapping.md`에서 엔드포인트 검색
### 워크플로우 문서에 통합하려면
`backend-architecture-detailed-analysis.md` 전체 복사
### 프로그래밍 방식으로 활용하려면
`backend-analysis-response.json` 파싱
---
**문서 버전**: 1.0
**마지막 업데이트**: 2026-02-06
**다음 업데이트 예정**: 신규 API 추가 시

View File

@ -0,0 +1,239 @@
{
"status": "success",
"confidence": "high",
"result": {
"summary": "WACE ERP 백엔드 전체 아키텍처 분석 완료",
"details": "Node.js + Express + TypeScript + PostgreSQL Raw Query 기반 멀티테넌시 시스템. 70+ 라우트, 70+ 컨트롤러, 80+ 서비스로 구성된 계층형 아키텍처. JWT 인증, 3단계 권한 체계(SUPER_ADMIN/COMPANY_ADMIN/USER), company_code 기반 완전한 데이터 격리 구현.",
"files_affected": [
"docs/backend-architecture-detailed-analysis.md (상세 분석 문서)",
"docs/backend-architecture-summary.md (요약 문서)",
"docs/backend-api-route-mapping.md (API 라우트 전체 매핑)"
],
"key_findings": {
"architecture_pattern": "Layered Architecture (Controller → Service → Database)",
"tech_stack": {
"language": "TypeScript",
"runtime": "Node.js 20.10.0+",
"framework": "Express.js",
"database": "PostgreSQL (pg 라이브러리, Raw Query)",
"authentication": "JWT (jsonwebtoken)",
"scheduler": "node-cron",
"external_db_support": ["PostgreSQL", "MySQL", "MSSQL", "Oracle"]
},
"directory_structure": {
"controllers": "70+ 파일 (API 요청 수신, 응답 생성)",
"services": "80+ 파일 (비즈니스 로직, 트랜잭션 관리)",
"routes": "70+ 파일 (API 라우팅)",
"middleware": "4개 (인증, 권한, 슈퍼관리자, 에러핸들러)",
"types": "26개 (TypeScript 타입 정의)",
"utils": "유틸리티 함수 (JWT, 암호화, 로거)"
},
"middleware_stack": [
"1. Process Level Exception Handlers",
"2. Helmet (보안 헤더)",
"3. Compression (Gzip)",
"4. Body Parser (10MB limit)",
"5. Static Files (/uploads)",
"6. CORS (credentials: true)",
"7. Rate Limiting (1분 10000회)",
"8. Token Auto Refresh (1시간 이내 만료 시 갱신)",
"9. API Routes (70+개)",
"10. 404 Handler",
"11. Error Handler"
],
"authentication_flow": {
"step1": "로그인 요청 → AuthController.login()",
"step2": "AuthService.processLogin() → loginPwdCheck() (bcrypt 검증)",
"step3": "getPersonBeanFromSession() → 사용자 정보 조회",
"step4": "insertLoginAccessLog() → 로그인 이력 저장",
"step5": "JwtUtils.generateToken() → JWT 토큰 생성",
"step6": "응답: { token, userInfo, firstMenuPath }"
},
"jwt_payload": {
"userId": "사용자 ID",
"userName": "사용자명",
"companyCode": "회사 코드 (멀티테넌시 키)",
"userType": "권한 레벨 (SUPER_ADMIN/COMPANY_ADMIN/USER)",
"exp": "만료 시간 (24시간)"
},
"permission_levels": {
"SUPER_ADMIN": {
"company_code": "*",
"userType": "SUPER_ADMIN",
"capabilities": [
"전체 회사 데이터 접근",
"DDL 실행",
"회사 생성/삭제",
"시스템 설정 변경"
]
},
"COMPANY_ADMIN": {
"company_code": "특정 회사 (예: ILSHIN)",
"userType": "COMPANY_ADMIN",
"capabilities": [
"자기 회사 데이터만 접근",
"자기 회사 사용자 관리",
"회사 설정 변경"
]
},
"USER": {
"company_code": "특정 회사",
"userType": "USER",
"capabilities": [
"자기 회사 데이터만 접근",
"읽기/쓰기 권한만"
]
}
},
"multi_tenancy": {
"principle": "모든 쿼리에 company_code 필터 필수",
"pattern": "JWT 토큰에서 company_code 추출 (클라이언트 신뢰 금지)",
"super_admin_visibility": "일반 회사 사용자에게 슈퍼관리자(company_code='*') 숨김",
"correct_pattern": "WHERE company_code = $1 AND company_code != '*'",
"wrong_pattern": "req.body.companyCode 사용 (보안 위험!)"
},
"api_routes": {
"total_count": "200+개",
"categories": {
"인증/관리자": "15개",
"테이블/화면": "40개",
"플로우": "15개",
"데이터플로우": "5개",
"외부 연동": "15개",
"배치": "10개",
"메일": "5개",
"파일": "5개",
"기타": "90개"
}
},
"business_domains": {
"관리자": {
"controller": "adminController.ts",
"service": "adminService.ts",
"features": ["사용자 관리", "메뉴 관리", "권한 그룹 관리", "시스템 설정"]
},
"테이블/화면": {
"controller": "tableManagementController.ts, screenManagementController.ts",
"service": "tableManagementService.ts, screenManagementService.ts",
"features": ["테이블 메타데이터", "화면 정의", "화면 그룹", "테이블 로그", "엔티티 관계"]
},
"플로우": {
"controller": "flowController.ts",
"service": "flowExecutionService.ts, flowDefinitionService.ts",
"features": ["워크플로우 설계", "단계 관리", "데이터 이동", "조건부 이동", "오딧 로그"]
},
"데이터플로우": {
"controller": "dataflowController.ts, dataflowDiagramController.ts",
"service": "dataflowService.ts, dataflowDiagramService.ts",
"features": ["테이블 관계 정의", "ERD", "다이어그램 시각화", "관계 실행"]
},
"외부 연동": {
"controller": "externalDbConnectionController.ts, externalRestApiConnectionController.ts",
"service": "externalDbConnectionService.ts, dbConnectionManager.ts",
"features": ["외부 DB 연결", "Connection Pool 관리", "REST API 프록시"]
},
"배치": {
"controller": "batchController.ts, batchManagementController.ts",
"service": "batchService.ts, batchSchedulerService.ts",
"features": ["Cron 스케줄러", "외부 DB → 내부 DB 동기화", "컬럼 매핑", "실행 이력"]
},
"메일": {
"controller": "mailSendSimpleController.ts, mailReceiveBasicController.ts",
"service": "mailSendSimpleService.ts, mailReceiveBasicService.ts",
"features": ["메일 발송 (nodemailer)", "메일 수신 (IMAP)", "템플릿 관리", "첨부파일"]
},
"파일": {
"controller": "fileController.ts, screenFileController.ts",
"service": "fileSystemManager.ts",
"features": ["파일 업로드 (multer)", "파일 다운로드", "화면별 파일 관리"]
}
},
"database_access": {
"connection_pool": {
"min": "2~5 (환경별)",
"max": "10~20 (환경별)",
"connectionTimeout": "30000ms",
"idleTimeout": "600000ms",
"statementTimeout": "60000ms"
},
"query_patterns": {
"multi_row": "query('SELECT ...', [params])",
"single_row": "queryOne('SELECT ...', [params])",
"transaction": "transaction(async (client) => { ... })"
},
"sql_injection_prevention": "Parameterized Query 사용 (pg 라이브러리)"
},
"external_integration": {
"supported_databases": ["PostgreSQL", "MySQL", "MSSQL", "Oracle"],
"connector_pattern": "Factory Pattern (DatabaseConnectorFactory)",
"rest_api": "axios 기반 프록시"
},
"batch_scheduler": {
"library": "node-cron",
"timezone": "Asia/Seoul",
"cron_examples": {
"매일 새벽 2시": "0 2 * * *",
"5분마다": "*/5 * * * *",
"평일 오전 8시": "0 8 * * 1-5"
},
"execution_flow": [
"1. 소스 DB에서 데이터 조회",
"2. 컬럼 매핑 적용",
"3. 타겟 DB에 INSERT/UPDATE",
"4. 실행 로그 기록"
]
},
"file_handling": {
"upload_path": "uploads/{company_code}/{timestamp}-{uuid}-{filename}",
"max_file_size": "10MB",
"allowed_types": ["이미지", "PDF", "Office 문서"],
"library": "multer"
},
"security": {
"password_encryption": "bcrypt (12 rounds)",
"sensitive_data_encryption": "AES-256-CBC (외부 DB 비밀번호)",
"jwt_secret": "환경변수 관리",
"security_headers": ["Helmet (CSP, X-Frame-Options)", "CORS (credentials: true)", "Rate Limiting (1분 10000회)"],
"sql_injection_prevention": "Parameterized Query"
},
"error_handling": {
"postgres_error_codes": {
"23505": "중복된 데이터",
"23503": "참조 무결성 위반",
"23502": "필수 입력값 누락"
},
"process_level": {
"unhandledRejection": "로깅 (서버 유지)",
"uncaughtException": "로깅 (서버 유지, 주의)",
"SIGTERM/SIGINT": "Graceful Shutdown"
}
},
"logging": {
"library": "Winston",
"log_files": {
"error.log": "에러만 (10MB × 5파일)",
"combined.log": "전체 로그 (10MB × 10파일)"
},
"log_levels": "error (0) → warn (1) → info (2) → debug (5)"
},
"performance_optimization": {
"pool_monitoring": "5분마다 상태 체크, 대기 연결 5개 이상 시 경고",
"slow_query_detection": "1초 이상 걸린 쿼리 자동 경고",
"caching": "Redis (메뉴: 10분 TTL, 공통코드: 30분 TTL)",
"compression": "Gzip (1KB 이상 응답, 레벨 6)"
}
},
"critical_rules": [
"✅ 모든 쿼리에 company_code 필터 추가",
"✅ JWT 토큰에서 company_code 추출 (클라이언트 신뢰 금지)",
"✅ Parameterized Query 사용 (SQL Injection 방지)",
"✅ 슈퍼관리자 데이터 숨김 (company_code != '*')",
"✅ 비밀번호는 bcrypt, 민감정보는 AES-256",
"✅ 에러 핸들링 try/catch 필수",
"✅ 트랜잭션이 필요한 경우 transaction() 사용",
"✅ 파일 업로드는 회사별 디렉토리 분리"
]
},
"needs_from_others": [],
"questions": []
}

View File

@ -0,0 +1,542 @@
# WACE ERP Backend - API 라우트 완전 매핑
> **작성일**: 2026-02-06
> **목적**: 프론트엔드 개발자용 API 엔드포인트 전체 목록
---
## 📌 공통 규칙
### Base URL
```
개발: http://localhost:8080
운영: http://39.117.244.52:8080
```
### 헤더
```http
Content-Type: application/json
Authorization: Bearer {JWT_TOKEN}
```
### 응답 형식
```json
{
"success": true,
"message": "성공 메시지",
"data": { ... }
}
// 에러 시
{
"success": false,
"error": {
"code": "ERROR_CODE",
"details": "에러 상세"
}
}
```
---
## 1. 인증 API (`/api/auth`)
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| POST | `/auth/login` | 공개 | 로그인 | `{ userId, password }` | `{ token, userInfo, firstMenuPath }` |
| POST | `/auth/logout` | 인증 | 로그아웃 | - | `{ success: true }` |
| GET | `/auth/me` | 인증 | 현재 사용자 정보 | - | `{ userInfo }` |
| GET | `/auth/status` | 공개 | 인증 상태 확인 | - | `{ isLoggedIn, isAdmin }` |
| POST | `/auth/refresh` | 인증 | 토큰 갱신 | - | `{ token }` |
| POST | `/auth/signup` | 공개 | 회원가입 (공차중계) | `{ userId, password, userName, phoneNumber, licenseNumber, vehicleNumber }` | `{ success: true }` |
| POST | `/auth/switch-company` | 슈퍼관리자 | 회사 전환 | `{ companyCode }` | `{ token, companyCode }` |
---
## 2. 관리자 API (`/api/admin`)
### 2.1 사용자 관리
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|--------|------|------|------|--------------|----------|
| GET | `/admin/users` | 관리자 | 사용자 목록 | `page, limit, search` | `{ users[], total }` |
| POST | `/admin/users` | 관리자 | 사용자 생성 | - | `{ user }` |
| PUT | `/admin/users/:userId` | 관리자 | 사용자 수정 | - | `{ user }` |
| DELETE | `/admin/users/:userId` | 관리자 | 사용자 삭제 | - | `{ success: true }` |
| GET | `/admin/users/:userId/history` | 관리자 | 사용자 이력 | - | `{ history[] }` |
### 2.2 메뉴 관리
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|--------|------|------|------|--------------|----------|
| GET | `/admin/menus` | 인증 | 메뉴 목록 (트리) | `userId, userLang` | `{ menus[] }` |
| POST | `/admin/menus` | 관리자 | 메뉴 생성 | - | `{ menu }` |
| PUT | `/admin/menus/:menuId` | 관리자 | 메뉴 수정 | - | `{ menu }` |
| DELETE | `/admin/menus/:menuId` | 관리자 | 메뉴 삭제 | - | `{ success: true }` |
### 2.3 표준 관리
| 메서드 | 경로 | 권한 | 기능 | Response |
|--------|------|------|------|----------|
| GET | `/admin/web-types` | 인증 | 웹타입 표준 목록 | `{ webTypes[] }` |
| GET | `/admin/button-actions` | 인증 | 버튼 액션 표준 | `{ buttonActions[] }` |
| GET | `/admin/component-standards` | 인증 | 컴포넌트 표준 | `{ components[] }` |
| GET | `/admin/template-standards` | 인증 | 템플릿 표준 | `{ templates[] }` |
| GET | `/admin/reports` | 인증 | 리포트 목록 | `{ reports[] }` |
---
## 3. 테이블 관리 API (`/api/table-management`)
### 3.1 테이블 메타데이터
| 메서드 | 경로 | 권한 | 기능 | Response |
|--------|------|------|------|----------|
| GET | `/table-management/tables` | 인증 | 테이블 목록 | `{ tables[] }` |
| GET | `/table-management/tables/:table/columns` | 인증 | 컬럼 목록 | `{ columns[] }` |
| GET | `/table-management/tables/:table/schema` | 인증 | 테이블 스키마 | `{ schema }` |
| GET | `/table-management/tables/:table/exists` | 인증 | 테이블 존재 여부 | `{ exists: boolean }` |
| GET | `/table-management/tables/:table/web-types` | 인증 | 웹타입 정보 | `{ webTypes }` |
### 3.2 컬럼 설정
| 메서드 | 경로 | 권한 | 기능 | Request Body |
|--------|------|------|------|--------------|
| POST | `/table-management/tables/:table/columns/:column/settings` | 인증 | 컬럼 설정 업데이트 | `{ web_type, input_type, ... }` |
| POST | `/table-management/tables/:table/columns/settings` | 인증 | 전체 컬럼 일괄 업데이트 | `{ columns[] }` |
| PUT | `/table-management/tables/:table/label` | 인증 | 테이블 라벨 설정 | `{ label }` |
### 3.3 데이터 CRUD
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| POST | `/table-management/tables/:table/data` | 인증 | 데이터 조회 (페이징) | `{ page, limit, filters, sort }` | `{ data[], total }` |
| POST | `/table-management/tables/:table/record` | 인증 | 단일 레코드 조회 | `{ conditions }` | `{ record }` |
| POST | `/table-management/tables/:table/add` | 인증 | 데이터 추가 | `{ data }` | `{ success: true, id }` |
| PUT | `/table-management/tables/:table/edit` | 인증 | 데이터 수정 | `{ conditions, data }` | `{ success: true }` |
| DELETE | `/table-management/tables/:table/delete` | 인증 | 데이터 삭제 | `{ conditions }` | `{ success: true }` |
### 3.4 다중 테이블 저장
| 메서드 | 경로 | 권한 | 기능 | Request Body |
|--------|------|------|------|--------------|
| POST | `/table-management/multi-table-save` | 인증 | 메인+서브 테이블 저장 | `{ mainTable, mainData, subTables: [{ table, data[] }] }` |
### 3.5 로그 시스템
| 메서드 | 경로 | 권한 | 기능 | Request Body |
|--------|------|------|------|--------------|
| POST | `/table-management/tables/:table/log` | 관리자 | 로그 테이블 생성 | - |
| GET | `/table-management/tables/:table/log/config` | 인증 | 로그 설정 조회 | - |
| GET | `/table-management/tables/:table/log` | 인증 | 로그 데이터 조회 | - |
| POST | `/table-management/tables/:table/log/toggle` | 관리자 | 로그 활성화/비활성화 | `{ is_active }` |
### 3.6 엔티티 관계
| 메서드 | 경로 | 권한 | 기능 | Query Params |
|--------|------|------|------|--------------|
| GET | `/table-management/tables/entity-relations` | 인증 | 두 테이블 간 관계 조회 | `leftTable, rightTable` |
| GET | `/table-management/columns/:table/referenced-by` | 인증 | 현재 테이블 참조 목록 | - |
### 3.7 카테고리 관리
| 메서드 | 경로 | 권한 | 기능 | Response |
|--------|------|------|------|----------|
| GET | `/table-management/category-columns` | 인증 | 회사별 카테고리 컬럼 | `{ categoryColumns[] }` |
| GET | `/table-management/menu/:menuObjid/category-columns` | 인증 | 메뉴별 카테고리 컬럼 | `{ categoryColumns[] }` |
---
## 4. 화면 관리 API (`/api/screen-management`)
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|--------|------|------|------|--------------|----------|
| GET | `/screen-management/screens` | 인증 | 화면 목록 | `page, limit` | `{ screens[], total }` |
| GET | `/screen-management/screens/:id` | 인증 | 화면 상세 | - | `{ screen }` |
| POST | `/screen-management/screens` | 관리자 | 화면 생성 | - | `{ screen }` |
| PUT | `/screen-management/screens/:id` | 관리자 | 화면 수정 | - | `{ screen }` |
| DELETE | `/screen-management/screens/:id` | 관리자 | 화면 삭제 | - | `{ success: true }` |
### 화면 그룹
| 메서드 | 경로 | 권한 | 기능 | Response |
|--------|------|------|------|----------|
| GET | `/screen-groups` | 인증 | 화면 그룹 목록 | `{ screenGroups[] }` |
| POST | `/screen-groups` | 관리자 | 그룹 생성 | `{ group }` |
### 화면 파일
| 메서드 | 경로 | 권한 | 기능 | Response |
|--------|------|------|------|----------|
| GET | `/screen-files` | 인증 | 화면 파일 목록 | `{ files[] }` |
| POST | `/screen-files` | 관리자 | 파일 업로드 | `{ file }` |
---
## 5. 플로우 API (`/api/flow`)
### 5.1 플로우 정의
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| GET | `/flow/definitions` | 인증 | 플로우 목록 | - | `{ flows[] }` |
| GET | `/flow/definitions/:id` | 인증 | 플로우 상세 | - | `{ flow }` |
| POST | `/flow/definitions` | 인증 | 플로우 생성 | `{ name, description, targetTable }` | `{ flow }` |
| PUT | `/flow/definitions/:id` | 인증 | 플로우 수정 | `{ name, description }` | `{ flow }` |
| DELETE | `/flow/definitions/:id` | 인증 | 플로우 삭제 | - | `{ success: true }` |
### 5.2 단계 관리
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| GET | `/flow/definitions/:flowId/steps` | 인증 | 단계 목록 | - | `{ steps[] }` |
| POST | `/flow/definitions/:flowId/steps` | 인증 | 단계 생성 | `{ name, type, settings }` | `{ step }` |
| PUT | `/flow/steps/:stepId` | 인증 | 단계 수정 | `{ name, settings }` | `{ step }` |
| DELETE | `/flow/steps/:stepId` | 인증 | 단계 삭제 | - | `{ success: true }` |
### 5.3 연결 관리
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| GET | `/flow/connections/:flowId` | 인증 | 연결 목록 | - | `{ connections[] }` |
| POST | `/flow/connections` | 인증 | 연결 생성 | `{ fromStepId, toStepId, condition }` | `{ connection }` |
| DELETE | `/flow/connections/:connectionId` | 인증 | 연결 삭제 | - | `{ success: true }` |
### 5.4 데이터 이동
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| POST | `/flow/move` | 인증 | 데이터 이동 (단건) | `{ flowId, fromStepId, toStepId, recordId }` | `{ success: true }` |
| POST | `/flow/move-batch` | 인증 | 데이터 이동 (다건) | `{ flowId, fromStepId, toStepId, recordIds[] }` | `{ success: true, movedCount }` |
### 5.5 단계 데이터 조회
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|--------|------|------|------|--------------|----------|
| GET | `/flow/:flowId/step/:stepId/count` | 인증 | 단계 데이터 개수 | - | `{ count }` |
| GET | `/flow/:flowId/step/:stepId/list` | 인증 | 단계 데이터 목록 | `page, limit` | `{ data[], total }` |
| GET | `/flow/:flowId/step/:stepId/column-labels` | 인증 | 컬럼 라벨 조회 | - | `{ labels }` |
| GET | `/flow/:flowId/steps/counts` | 인증 | 모든 단계 카운트 | - | `{ counts[] }` |
### 5.6 단계 데이터 수정
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| PUT | `/flow/:flowId/step/:stepId/data/:recordId` | 인증 | 인라인 편집 | `{ data }` | `{ success: true }` |
### 5.7 오딧 로그
| 메서드 | 경로 | 권한 | 기능 | Response |
|--------|------|------|------|----------|
| GET | `/flow/audit/:flowId/:recordId` | 인증 | 레코드별 오딧 로그 | `{ auditLogs[] }` |
| GET | `/flow/audit/:flowId` | 인증 | 플로우 전체 오딧 로그 | `{ auditLogs[] }` |
---
## 6. 데이터플로우 API (`/api/dataflow`)
### 6.1 관계 관리
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| GET | `/dataflow/relationships` | 인증 | 관계 목록 | - | `{ relationships[] }` |
| POST | `/dataflow/relationships` | 인증 | 관계 생성 | `{ fromTable, toTable, fromColumn, toColumn, type }` | `{ relationship }` |
| PUT | `/dataflow/relationships/:id` | 인증 | 관계 수정 | `{ name, type }` | `{ relationship }` |
| DELETE | `/dataflow/relationships/:id` | 인증 | 관계 삭제 | - | `{ success: true }` |
### 6.2 다이어그램
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| GET | `/dataflow-diagrams` | 인증 | 다이어그램 목록 | - | `{ diagrams[] }` |
| GET | `/dataflow-diagrams/:id` | 인증 | 다이어그램 상세 | - | `{ diagram }` |
| POST | `/dataflow-diagrams` | 인증 | 다이어그램 생성 | `{ name, description }` | `{ diagram }` |
### 6.3 실행
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| POST | `/dataflow` | 인증 | 데이터플로우 실행 | `{ relationshipId, params }` | `{ result[] }` |
---
## 7. 외부 연동 API
### 7.1 외부 DB 연결 (`/api/external-db-connections`)
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| GET | `/external-db-connections` | 인증 | 연결 목록 | - | `{ connections[] }` |
| GET | `/external-db-connections/:id` | 인증 | 연결 상세 | - | `{ connection }` |
| POST | `/external-db-connections` | 관리자 | 연결 생성 | `{ connectionName, dbType, host, port, database, username, password }` | `{ connection }` |
| PUT | `/external-db-connections/:id` | 관리자 | 연결 수정 | `{ connectionName, ... }` | `{ connection }` |
| DELETE | `/external-db-connections/:id` | 관리자 | 연결 삭제 | - | `{ success: true }` |
| POST | `/external-db-connections/:id/test` | 인증 | 연결 테스트 | - | `{ success: boolean, message }` |
| GET | `/external-db-connections/:id/tables` | 인증 | 테이블 목록 조회 | - | `{ tables[] }` |
| GET | `/external-db-connections/:id/tables/:table/columns` | 인증 | 컬럼 목록 조회 | - | `{ columns[] }` |
### 7.2 외부 REST API (`/api/external-rest-api-connections`)
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| GET | `/external-rest-api-connections` | 인증 | API 연결 목록 | - | `{ connections[] }` |
| POST | `/external-rest-api-connections` | 관리자 | API 연결 생성 | `{ name, baseUrl, authType, ... }` | `{ connection }` |
| POST | `/external-rest-api-connections/:id/test` | 인증 | API 테스트 | `{ endpoint, method }` | `{ response }` |
### 7.3 멀티 커넥션 (`/api/multi-connection`)
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| POST | `/multi-connection/query` | 인증 | 멀티 DB 쿼리 | `{ connections: [{ connectionId, sql }] }` | `{ results[] }` |
---
## 8. 배치 API
### 8.1 배치 설정 (`/api/batch-configs`)
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| GET | `/batch-configs` | 인증 | 배치 설정 목록 | - | `{ batchConfigs[] }` |
| GET | `/batch-configs/:id` | 인증 | 배치 설정 상세 | - | `{ batchConfig }` |
| POST | `/batch-configs` | 관리자 | 배치 설정 생성 | `{ batchName, cronSchedule, sourceConnection, targetTable, mappings }` | `{ batchConfig }` |
| PUT | `/batch-configs/:id` | 관리자 | 배치 설정 수정 | `{ batchName, ... }` | `{ batchConfig }` |
| DELETE | `/batch-configs/:id` | 관리자 | 배치 설정 삭제 | - | `{ success: true }` |
| GET | `/batch-configs/connections` | 관리자 | 사용 가능한 커넥션 목록 | - | `{ connections[] }` |
| GET | `/batch-configs/connections/:type/tables` | 관리자 | 테이블 목록 조회 | - | `{ tables[] }` |
### 8.2 배치 실행 (`/api/batch-management`)
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| POST | `/batch-management/:id/execute` | 관리자 | 배치 즉시 실행 | - | `{ success: true, executionLogId }` |
### 8.3 실행 이력 (`/api/batch-execution-logs`)
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|--------|------|------|------|--------------|----------|
| GET | `/batch-execution-logs` | 인증 | 실행 이력 목록 | `batchConfigId, page, limit` | `{ logs[], total }` |
| GET | `/batch-execution-logs/:id` | 인증 | 실행 이력 상세 | - | `{ log }` |
---
## 9. 메일 API (`/api/mail`)
### 9.1 계정 관리
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| GET | `/mail/accounts` | 인증 | 계정 목록 | - | `{ accounts[] }` |
| POST | `/mail/accounts` | 관리자 | 계정 추가 | `{ email, smtpHost, smtpPort, password }` | `{ account }` |
### 9.2 템플릿
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| GET | `/mail/templates-file` | 인증 | 템플릿 목록 | - | `{ templates[] }` |
| POST | `/mail/templates-file` | 관리자 | 템플릿 생성 | `{ name, subject, body }` | `{ template }` |
### 9.3 발송/수신
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| POST | `/mail/send` | 인증 | 메일 발송 | `{ accountId, to, subject, body, attachments[] }` | `{ success: true, messageId }` |
| GET | `/mail/sent` | 인증 | 발송 이력 | `page, limit` | `{ mails[], total }` |
| POST | `/mail/receive` | 인증 | 메일 수신 | `{ accountId }` | `{ mails[] }` |
---
## 10. 파일 API (`/api/files`)
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| POST | `/files/upload` | 인증 | 파일 업로드 (multipart) | `FormData { file }` | `{ fileId, fileName, filePath, fileSize }` |
| GET | `/files` | 인증 | 파일 목록 | `page, limit` | `{ files[], total }` |
| GET | `/files/:id` | 인증 | 파일 정보 조회 | - | `{ file }` |
| GET | `/files/download/:id` | 인증 | 파일 다운로드 | - | `(파일 스트림)` |
| DELETE | `/files/:id` | 인증 | 파일 삭제 | - | `{ success: true }` |
| GET | `/uploads/:filename` | 공개 | 정적 파일 서빙 | - | `(파일 스트림)` |
---
## 11. 대시보드 API (`/api/dashboards`)
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|--------|------|------|------|--------------|----------|
| GET | `/dashboards` | 인증 | 대시보드 목록 | - | `{ dashboards[] }` |
| GET | `/dashboards/:id` | 인증 | 대시보드 상세 | - | `{ dashboard }` |
| POST | `/dashboards` | 관리자 | 대시보드 생성 | - | `{ dashboard }` |
| GET | `/dashboards/:id/widgets` | 인증 | 위젯 데이터 조회 | - | `{ widgets[] }` |
---
## 12. 공통코드 API (`/api/common-codes`)
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|--------|------|------|------|--------------|----------|
| GET | `/common-codes` | 인증 | 공통코드 목록 | `codeGroup` | `{ codes[] }` |
| GET | `/common-codes/:codeGroup/:code` | 인증 | 공통코드 상세 | - | `{ code }` |
| POST | `/common-codes` | 관리자 | 공통코드 생성 | `{ codeGroup, code, name }` | `{ code }` |
---
## 13. 다국어 API (`/api/multilang`)
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|--------|------|------|------|--------------|----------|
| GET | `/multilang` | 인증 | 다국어 키 목록 | `lang` | `{ translations{} }` |
| GET | `/multilang/:key` | 인증 | 특정 키 조회 | `lang` | `{ key, value }` |
| POST | `/multilang` | 관리자 | 다국어 추가 | `{ key, ko, en, cn }` | `{ translation }` |
---
## 14. 회사 관리 API (`/api/company-management`)
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| GET | `/company-management` | 슈퍼관리자 | 회사 목록 | - | `{ companies[] }` |
| POST | `/company-management` | 슈퍼관리자 | 회사 생성 | `{ companyCode, companyName }` | `{ company }` |
| PUT | `/company-management/:code` | 슈퍼관리자 | 회사 수정 | `{ companyName }` | `{ company }` |
| DELETE | `/company-management/:code` | 슈퍼관리자 | 회사 삭제 | - | `{ success: true }` |
---
## 15. 부서 API (`/api/departments`)
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| GET | `/departments` | 인증 | 부서 목록 (트리) | - | `{ departments[] }` |
| POST | `/departments` | 관리자 | 부서 생성 | `{ deptCode, deptName, parentDeptCode }` | `{ department }` |
---
## 16. 권한 그룹 API (`/api/roles`)
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| GET | `/roles` | 인증 | 권한 그룹 목록 | - | `{ roles[] }` |
| POST | `/roles` | 관리자 | 권한 그룹 생성 | `{ roleName, permissions[] }` | `{ role }` |
---
## 17. DDL 실행 API (`/api/ddl`)
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| POST | `/ddl` | 슈퍼관리자 | DDL 실행 | `{ sql }` | `{ success: true, result }` |
---
## 18. 외부 API 프록시 (`/api/open-api`)
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|--------|------|------|------|--------------|----------|
| GET | `/open-api/weather` | 인증 | 날씨 정보 조회 | `location` | `{ weather }` |
| GET | `/open-api/exchange` | 인증 | 환율 정보 조회 | `fromCurrency, toCurrency` | `{ rate }` |
---
## 19. 디지털 트윈 API (`/api/digital-twin`)
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| GET | `/digital-twin/layouts` | 인증 | 레이아웃 목록 | - | `{ layouts[] }` |
| GET | `/digital-twin/templates` | 인증 | 템플릿 목록 | - | `{ templates[] }` |
| GET | `/digital-twin/data` | 인증 | 실시간 데이터 | - | `{ data[] }` |
---
## 20. 3D 필드 API (`/api/yard-layouts`)
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| GET | `/yard-layouts` | 인증 | 필드 레이아웃 목록 | - | `{ yardLayouts[] }` |
| POST | `/yard-layouts` | 인증 | 레이아웃 저장 | `{ layout }` | `{ success: true }` |
---
## 21. 스케줄 API (`/api/schedule`)
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| POST | `/schedule` | 인증 | 스케줄 자동 생성 | `{ params }` | `{ schedule }` |
---
## 22. 채번 규칙 API (`/api/numbering-rules`)
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| GET | `/numbering-rules` | 인증 | 채번 규칙 목록 | - | `{ rules[] }` |
| POST | `/numbering-rules` | 관리자 | 규칙 생성 | `{ ruleName, prefix, format }` | `{ rule }` |
| POST | `/numbering-rules/:id/generate` | 인증 | 번호 생성 | - | `{ number }` |
---
## 23. 엔티티 검색 API (`/api/entity-search`)
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| POST | `/entity-search` | 인증 | 엔티티 검색 | `{ table, filters, page, limit }` | `{ results[], total }` |
| GET | `/entity/:table/options` | 인증 | V2Select용 옵션 | `search, limit` | `{ options[] }` |
---
## 24. To-Do API (`/api/todos`)
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| GET | `/todos` | 인증 | To-Do 목록 | `status, assignee` | `{ todos[] }` |
| POST | `/todos` | 인증 | To-Do 생성 | `{ title, description, dueDate }` | `{ todo }` |
| PUT | `/todos/:id` | 인증 | To-Do 수정 | `{ status }` | `{ todo }` |
---
## 25. 예약 요청 API (`/api/bookings`)
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|--------|------|------|------|--------------|----------|
| GET | `/bookings` | 인증 | 예약 목록 | - | `{ bookings[] }` |
| POST | `/bookings` | 인증 | 예약 생성 | `{ resourceId, startTime, endTime }` | `{ booking }` |
---
## 26. 리스크/알림 API (`/api/risk-alerts`)
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|--------|------|------|------|--------------|----------|
| GET | `/risk-alerts` | 인증 | 리스크/알림 목록 | `priority, status` | `{ alerts[] }` |
| POST | `/risk-alerts` | 인증 | 알림 생성 | `{ title, content, priority }` | `{ alert }` |
---
## 27. 헬스 체크
| 메서드 | 경로 | 권한 | 기능 | Response |
|--------|------|------|------|----------|
| GET | `/health` | 공개 | 서버 상태 확인 | `{ status: "OK", timestamp, uptime, environment }` |
---
## 🔐 에러 코드 목록
| 코드 | HTTP Status | 설명 |
|------|-------------|------|
| `TOKEN_MISSING` | 401 | 인증 토큰 누락 |
| `TOKEN_EXPIRED` | 401 | 토큰 만료 |
| `INVALID_TOKEN` | 401 | 유효하지 않은 토큰 |
| `AUTHENTICATION_REQUIRED` | 401 | 인증 필요 |
| `INSUFFICIENT_PERMISSION` | 403 | 권한 부족 |
| `SUPER_ADMIN_REQUIRED` | 403 | 슈퍼관리자 권한 필요 |
| `COMPANY_ACCESS_DENIED` | 403 | 회사 데이터 접근 거부 |
| `INVALID_INPUT` | 400 | 잘못된 입력 |
| `RESOURCE_NOT_FOUND` | 404 | 리소스 없음 |
| `DUPLICATE_ENTRY` | 400 | 중복 데이터 |
| `FOREIGN_KEY_VIOLATION` | 400 | 참조 무결성 위반 |
| `SERVER_ERROR` | 500 | 서버 오류 |
---
**문서 버전**: 1.0
**마지막 업데이트**: 2026-02-06
**총 API 개수**: 200+개

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,342 @@
# WACE ERP Backend - 아키텍처 요약
> **작성일**: 2026-02-06
> **목적**: 워크플로우 문서 통합용 백엔드 아키텍처 요약
---
## 1. 기술 스택
```
언어: TypeScript (Node.js 20.10.0+)
프레임워크: Express.js
데이터베이스: PostgreSQL (pg 라이브러리, Raw Query)
인증: JWT (jsonwebtoken)
스케줄러: node-cron
메일: nodemailer + IMAP
파일업로드: multer
외부DB: MySQL, MSSQL, Oracle 지원
```
## 2. 계층 구조
```
┌─────────────────┐
│ Controller │ ← API 요청 수신, 응답 생성
└────────┬────────┘
┌────────▼────────┐
│ Service │ ← 비즈니스 로직, 트랜잭션 관리
└────────┬────────┘
┌────────▼────────┐
│ Database │ ← PostgreSQL Raw Query
└─────────────────┘
```
## 3. 디렉토리 구조
```
backend-node/src/
├── app.ts # Express 앱 진입점
├── config/ # 환경설정
├── controllers/ # 70+ 컨트롤러
├── services/ # 80+ 서비스
├── routes/ # 70+ 라우터
├── middleware/ # 인증/권한/에러핸들러
├── database/ # DB 연결 (pg Pool)
├── types/ # TypeScript 타입 (26개)
└── utils/ # 유틸리티 (JWT, 암호화, 로거)
```
## 4. 미들웨어 스택 순서
```typescript
1. Process Level Exception Handlers (unhandledRejection, uncaughtException)
2. Helmet (보안 헤더)
3. Compression (Gzip)
4. Body Parser (JSON, URL-encoded, 10MB limit)
5. Static Files (/uploads)
6. CORS (credentials: true)
7. Rate Limiting (1분 10000회)
8. Token Auto Refresh (1시간 이내 만료 시 갱신)
9. API Routes (70+개)
10. 404 Handler
11. Error Handler
```
## 5. 인증/인가 시스템
### 5.1 인증 흐름
```
로그인 요청
AuthController.login()
AuthService.processLogin()
├─ loginPwdCheck() → 비밀번호 검증 (bcrypt)
├─ getPersonBeanFromSession() → 사용자 정보 조회
├─ insertLoginAccessLog() → 로그인 이력 저장
└─ JwtUtils.generateToken() → JWT 토큰 생성
응답: { token, userInfo, firstMenuPath }
```
### 5.2 JWT Payload
```json
{
"userId": "user123",
"userName": "홍길동",
"companyCode": "ILSHIN",
"userType": "COMPANY_ADMIN",
"iat": 1234567890,
"exp": 1234654290,
"iss": "PMS-System"
}
```
### 5.3 권한 체계 (3단계)
| 권한 | company_code | userType | 권한 범위 |
|------|--------------|----------|-----------|
| **SUPER_ADMIN** | `*` | `SUPER_ADMIN` | 전체 회사, DDL 실행, 회사 생성/삭제 |
| **COMPANY_ADMIN** | `ILSHIN` | `COMPANY_ADMIN` | 자기 회사만, 사용자/설정 관리 |
| **USER** | `ILSHIN` | `USER` | 자기 회사만, 읽기/쓰기 |
## 6. 멀티테넌시 구현
### 핵심 원칙
```typescript
// ✅ 올바른 패턴
const companyCode = req.user!.companyCode; // JWT에서 추출
if (companyCode === "*") {
// 슈퍼관리자: 모든 데이터 조회
query = "SELECT * FROM table ORDER BY company_code";
} else {
// 일반 사용자: 자기 회사 + 슈퍼관리자 데이터 제외
query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'";
params = [companyCode];
}
// ❌ 잘못된 패턴 (보안 위험!)
const companyCode = req.body.companyCode; // 클라이언트에서 받음
```
### 슈퍼관리자 숨김 규칙
```sql
-- 일반 회사 사용자에게 슈퍼관리자(company_code='*')는 보이면 안 됨
SELECT * FROM user_info
WHERE company_code = $1
AND company_code != '*' -- 필수!
```
## 7. API 라우트 (70+개)
### 7.1 인증/관리자
- `POST /api/auth/login` - 로그인
- `GET /api/auth/me` - 현재 사용자 정보
- `POST /api/auth/switch-company` - 회사 전환 (슈퍼관리자)
- `GET /api/admin/users` - 사용자 목록
- `GET /api/admin/menus` - 메뉴 목록
### 7.2 테이블/화면
- `GET /api/table-management/tables` - 테이블 목록
- `POST /api/table-management/tables/:table/data` - 데이터 조회
- `POST /api/table-management/multi-table-save` - 다중 테이블 저장
- `GET /api/screen-management/screens` - 화면 목록
### 7.3 플로우
- `GET /api/flow/definitions` - 플로우 정의 목록
- `POST /api/flow/move` - 데이터 이동 (단건)
- `POST /api/flow/move-batch` - 데이터 이동 (다건)
### 7.4 외부 연동
- `GET /api/external-db-connections` - 외부 DB 연결 목록
- `POST /api/external-db-connections/:id/test` - 연결 테스트
- `POST /api/multi-connection/query` - 멀티 DB 쿼리
### 7.5 배치
- `GET /api/batch-configs` - 배치 설정 목록
- `POST /api/batch-management/:id/execute` - 배치 즉시 실행
### 7.6 메일
- `POST /api/mail/send` - 메일 발송
- `GET /api/mail/sent` - 발송 이력
### 7.7 파일
- `POST /api/files/upload` - 파일 업로드
- `GET /uploads/:filename` - 정적 파일 서빙
## 8. 비즈니스 도메인 (8개)
| 도메인 | 컨트롤러 | 주요 기능 |
|--------|----------|-----------|
| **관리자** | `adminController` | 사용자/메뉴/권한 관리 |
| **테이블/화면** | `tableManagementController` | 메타데이터, 동적 화면 생성 |
| **플로우** | `flowController` | 워크플로우 엔진, 데이터 이동 |
| **데이터플로우** | `dataflowController` | ERD, 관계도 |
| **외부 연동** | `externalDbConnectionController` | 외부 DB/REST API |
| **배치** | `batchController` | Cron 스케줄러, 데이터 동기화 |
| **메일** | `mailSendSimpleController` | 메일 발송/수신 |
| **파일** | `fileController` | 파일 업로드/다운로드 |
## 9. 데이터베이스 접근
### Connection Pool 설정
```typescript
{
min: 2~5, // 최소 연결 수
max: 10~20, // 최대 연결 수
connectionTimeout: 30000, // 30초
idleTimeout: 600000, // 10분
statementTimeout: 60000 // 쿼리 실행 60초
}
```
### Raw Query 패턴
```typescript
// 1. 다중 행
const users = await query('SELECT * FROM user_info WHERE company_code = $1', [companyCode]);
// 2. 단일 행
const user = await queryOne('SELECT * FROM user_info WHERE user_id = $1', [userId]);
// 3. 트랜잭션
await transaction(async (client) => {
await client.query('INSERT INTO table1 ...', [...]);
await client.query('INSERT INTO table2 ...', [...]);
});
```
## 10. 외부 시스템 연동
### 지원 데이터베이스
- PostgreSQL
- MySQL
- Microsoft SQL Server
- Oracle
### Connector Factory Pattern
```typescript
DatabaseConnectorFactory
├── PostgreSQLConnector
├── MySQLConnector
├── MSSQLConnector
└── OracleConnector
```
## 11. 배치/스케줄 시스템
### Cron 스케줄러
```typescript
// node-cron 기반
// 매일 새벽 2시: "0 2 * * *"
// 5분마다: "*/5 * * * *"
// 평일 오전 8시: "0 8 * * 1-5"
// 서버 시작 시 자동 초기화
BatchSchedulerService.initializeScheduler();
```
### 배치 실행 흐름
```
1. 소스 DB에서 데이터 조회
2. 컬럼 매핑 적용
3. 타겟 DB에 INSERT/UPDATE
4. 실행 로그 기록 (batch_execution_logs)
```
## 12. 파일 처리
### 업로드 경로
```
uploads/
└── {company_code}/
└── {timestamp}-{uuid}-{filename}
```
### Multer 설정
- 최대 파일 크기: 10MB
- 허용 타입: 이미지, PDF, Office 문서
- 파일명 중복 방지: 타임스탬프 + UUID
## 13. 보안
### 암호화
- **비밀번호**: bcrypt (12 rounds)
- **민감정보**: AES-256-CBC (외부 DB 비밀번호 등)
- **JWT Secret**: 환경변수 관리
### 보안 헤더
- Helmet (CSP, X-Frame-Options)
- CORS (credentials: true)
- Rate Limiting (1분 10000회)
### SQL Injection 방지
- Parameterized Query 사용 (pg 라이브러리)
- 동적 쿼리 빌더 패턴
## 14. 에러 핸들링
### PostgreSQL 에러 코드 매핑
- `23505` → "중복된 데이터"
- `23503` → "참조 무결성 위반"
- `23502` → "필수 입력값 누락"
### 프로세스 레벨
- `unhandledRejection` → 로깅 (서버 유지)
- `uncaughtException` → 로깅 (서버 유지, 주의)
- `SIGTERM/SIGINT` → Graceful Shutdown
## 15. 로깅 (Winston)
### 로그 파일
- `logs/error.log` - 에러만 (10MB × 5파일)
- `logs/combined.log` - 전체 로그 (10MB × 10파일)
### 로그 레벨
```
error (0) → warn (1) → info (2) → debug (5)
```
## 16. 성능 최적화
### Pool 모니터링
- 5분마다 상태 체크
- 대기 연결 5개 이상 시 경고
### 느린 쿼리 감지
- 1초 이상 걸린 쿼리 자동 경고
### 캐싱 (Redis)
- 메뉴 목록: 10분 TTL
- 공통코드: 30분 TTL
### Gzip 압축
- 1KB 이상 응답만 압축 (레벨 6)
---
## 🎯 핵심 체크리스트
### 개발 시 반드시 지켜야 할 규칙
**모든 쿼리에 `company_code` 필터 추가**
**JWT 토큰에서 `company_code` 추출 (클라이언트 신뢰 금지)**
**Parameterized Query 사용 (SQL Injection 방지)**
**슈퍼관리자 데이터 숨김 (`company_code != '*'`)**
**비밀번호는 bcrypt, 민감정보는 AES-256**
**에러 핸들링 try/catch 필수**
**트랜잭션이 필요한 경우 `transaction()` 사용**
✅ **파일 업로드는 회사별 디렉토리 분리**
---
**문서 버전**: 1.0
**마지막 업데이트**: 2026-02-06

View File

@ -0,0 +1,78 @@
# formData 콘솔 로그 수동 테스트 가이드
## 테스트 시나리오
1. http://localhost:9771/screens/1599?menuObjid=1762422235300 접속
2. 로그인 필요 시: `topseal_admin` / `1234`
3. 5초 대기 (페이지 로드)
4. 첫 번째 탭 "공정 마스터" 확인
5. 좌측 패널에서 **P003** 행 클릭
6. 우측 패널에서 **추가** 버튼 클릭
7. 모달에서 설비(equipment) 드롭다운에서 항목 선택
8. **저장** 버튼 클릭 **전** 콘솔 스냅샷 확인
9. **저장** 버튼 클릭 **후** 콘솔 로그 확인
## 확인할 콘솔 로그
### 1. ADD 모드 formData 설정 (ScreenModal)
```
🔵 [ScreenModal] ADD모드 formData 설정: {...}
```
- **위치**: `frontend/components/common/ScreenModal.tsx` 358행
- **의미**: 모달이 ADD 모드로 열릴 때 부모 데이터(splitPanelParentData)로 설정된 초기 formData
- **확인**: `process_code`가 P003으로 포함되어 있는지
### 2. formData 변경 시 (ScreenModal)
```
🟡 [ScreenModal] onFormDataChange: equipment_code → E001 | formData keys: [...] | process_code: P003
```
- **위치**: `frontend/components/common/ScreenModal.tsx` 1184행
- **의미**: 사용자가 설비를 선택할 때마다 발생
- **확인**: `process_code`가 유지되는지, `equipment_code`가 추가되는지
### 3. 저장 시 formData 디버그 (ButtonPrimary)
```
🔴 [ButtonPrimary] 저장 시 formData 디버그: {
propsFormDataKeys: [...],
screenContextFormDataKeys: [...],
effectiveFormDataKeys: [...],
process_code: "P003",
equipment_code: "E001",
fullData: "{...}"
}
```
- **위치**: `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` 1110행
- **의미**: 저장 버튼 클릭 시 실제로 API에 전달되는 formData
- **확인**: `process_code`, `equipment_code`가 모두 포함되어 있는지
## 추가로 확인할 로그
- `process_code` 포함 로그
- `splitPanelParentData` 포함 로그
- `🆕 [추가모달] screenId 기반 모달 열기:` (SplitPanelLayoutComponent 1639행)
## 에러 확인
콘솔에 빨간색으로 표시되는 에러 메시지가 있는지 확인하세요.
## 사전 조건
- **process_mng** 테이블에 P003 데이터가 있어야 함 (company_code = 로그인 사용자 회사)
- **equipment_mng** 테이블에 설비 데이터가 있어야 함
- 로그인 사용자가 해당 회사(COMPANY_7 등) 권한이 있어야 함
## 자동 테스트 스크립트
데이터가 준비된 환경에서:
```bash
cd frontend && npx tsx scripts/test-formdata-logs.ts
```
데이터가 없으면 "좌측 테이블에 데이터가 없습니다" 오류가 발생합니다.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,78 @@
# BOM 엑셀 업로드 기능 개발 계획
## 개요
탑씰(COMPANY_7) BOM관리 화면(screen_id=4168)에 엑셀 업로드 기능을 추가한다.
BOM은 트리 구조(parent_detail_id 자기참조)이므로 범용 엑셀 업로드를 사용할 수 없고,
BOM 전용 엑셀 업로드 컴포넌트를 개발한다.
## 핵심 구조
### DB 테이블
- `bom` (마스터): id(UUID), item_id(→item_info), version, current_version_id
- `bom_detail` (디테일-트리): id(UUID), bom_id(FK), parent_detail_id(자기참조), child_item_id(→item_info), level, seq_no, quantity, unit, loss_rate, process_type, version_id
- `item_info`: id, item_number(품번), item_name(품명), division(구분), unit, size, material
### 엑셀 포맷 설계 (화면과 동일한 레벨 체계)
엑셀 파일은 다음 컬럼으로 구성:
| 레벨 | 품번 | 품명 | 소요량 | 단위 | 로스율(%) | 공정구분 | 비고 |
|------|------|------|--------|------|-----------|----------|------|
| 0 | PROD-001 | 완제품A | 1 | EA | 0 | | ← BOM 헤더 (건너뜀) |
| 1 | P-001 | 부품A | 2 | EA | 0 | | ← 직접 자품목 |
| 2 | P-002 | 부품B | 3 | EA | 5 | 가공 | ← P-001의 하위 |
| 1 | P-003 | 부품C | 1 | KG | 0 | | ← 직접 자품목 |
| 2 | P-004 | 부품D | 4 | EA | 0 | 조립 | ← P-003의 하위 |
| 1 | P-005 | 부품E | 1 | EA | 0 | | ← 직접 자품목 |
- 레벨 0: BOM 헤더 (최상위 품목) → 업로드 시 건너뜀 (이미 존재)
- 레벨 1: 직접 자품목 → bom_detail (parent_detail_id=null, DB level=0)
- 레벨 2: 자품목의 하위 → bom_detail (parent_detail_id=부모ID, DB level=1)
- 레벨 N: → bom_detail (DB level=N-1)
- 품번으로 item_info를 조회하여 child_item_id 자동 매핑
### 트리 변환 로직 (레벨 1 이상만 처리)
엑셀 행을 순서대로 순회하면서 (레벨 0 건너뜀):
1. 각 행의 엑셀 레벨에서 -1하여 DB 레벨 계산
2. 스택으로 부모-자식 관계 추적
```
행1(레벨0) → BOM 헤더, 건너뜀
행2(레벨1) → DB level=0, 스택: [행2] → parent_detail_id = null
행3(레벨2) → DB level=1, 스택: [행2, 행3] → parent_detail_id = 행2.id
행4(레벨1) → DB level=0, 스택: [행4] → parent_detail_id = null
행5(레벨2) → DB level=1, 스택: [행4, 행5] → parent_detail_id = 행4.id
행6(레벨1) → DB level=0, 스택: [행6] → parent_detail_id = null
```
## 테스트 계획
### 1단계: 백엔드 API
- [x] 테스트 1: 품번으로 item_info 일괄 조회 (존재하는 품번)
- [x] 테스트 2: 존재하지 않는 품번 에러 처리
- [x] 테스트 3: 플랫 데이터 → 트리 구조 변환 (parent_detail_id 계산)
- [x] 테스트 4: bom_detail INSERT (version_id 포함)
- [x] 테스트 5: 기존 디테일 처리 (추가 모드 vs 전체교체 모드)
### 2단계: 프론트엔드 모달
- [x] 테스트 6: 엑셀 파일 파싱 및 미리보기
- [x] 테스트 7: 품번 매핑 결과 표시 (성공/실패)
- [x] 테스트 8: 업로드 실행 및 결과 표시
### 3단계: 통합
- [x] 테스트 9: BomTreeComponent에 엑셀 업로드 버튼 추가
- [x] 테스트 10: 업로드 후 트리 자동 새로고침
## 구현 파일 목록
### 백엔드
1. `backend-node/src/services/bomService.ts` - `uploadBomExcel()` 함수 추가
2. `backend-node/src/controllers/bomController.ts` - `uploadBomExcel` 핸들러 추가
3. `backend-node/src/routes/bomRoutes.ts` - `POST /:bomId/excel-upload` 라우트 추가
### 프론트엔드
4. `frontend/lib/registry/components/v2-bom-tree/BomExcelUploadModal.tsx` - 전용 모달 신규
5. `frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx` - 업로드 버튼 추가
## 진행 상태
- 완료된 테스트는 [x]로 표시
- 현재 진행 중인 테스트는 [진행중]으로 표시

View File

@ -0,0 +1,427 @@
# 공정 작업기준 컴포넌트 (v2-process-work-standard) 구현 계획
> **작성일**: 2026-02-24
> **컴포넌트 ID**: `v2-process-work-standard`
> **성격**: 도메인 특화 컴포넌트 (v2-rack-structure와 동일 패턴)
---
## 1. 현황 분석
### 1.1 기존 DB 테이블 (참조용, 이미 존재)
| 테이블 | 역할 | 핵심 컬럼 |
|--------|------|----------|
| `item_info` | 품목 마스터 | id, item_name, item_number, company_code |
| `item_routing_version` | 라우팅 버전 | id, item_code, version_name, company_code |
| `item_routing_detail` | 라우팅 상세 (공정 배정) | id, routing_version_id, seq_no, process_code, company_code |
| `process_mng` | 공정 마스터 | id, process_code, process_name, company_code |
### 1.2 신규 생성 필요 테이블
**`process_work_item`** - 작업 항목 (검사 장비 준비, 외관 검사 등)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | VARCHAR PK | UUID |
| company_code | VARCHAR NOT NULL | 멀티테넌시 |
| routing_detail_id | VARCHAR NOT NULL | item_routing_detail.id FK |
| work_phase | VARCHAR NOT NULL | Config의 phases[].key 값 (예: 'PRE', 'IN', 'POST' 또는 사용자 정의) |
| title | VARCHAR NOT NULL | 항목 제목 (예: 검사 장비 준비) |
| is_required | VARCHAR | 'Y' / 'N' |
| sort_order | INTEGER | 표시 순서 |
| description | TEXT | 비고/설명 |
| created_date | TIMESTAMP | 생성일 |
| updated_date | TIMESTAMP | 수정일 |
| writer | VARCHAR | 작성자 |
**`process_work_item_detail`** - 작업 항목 상세 (버니어 캘리퍼스 상태 소정 등)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | VARCHAR PK | UUID |
| company_code | VARCHAR NOT NULL | 멀티테넌시 |
| work_item_id | VARCHAR NOT NULL | process_work_item.id FK |
| detail_type | VARCHAR | 'CHECK' / 'INSPECTION' / 'MEASUREMENT' 등 |
| content | VARCHAR NOT NULL | 상세 내용 |
| is_required | VARCHAR | 'Y' / 'N' |
| sort_order | INTEGER | 표시 순서 |
| remark | TEXT | 비고 |
| created_date | TIMESTAMP | 생성일 |
| updated_date | TIMESTAMP | 수정일 |
| writer | VARCHAR | 작성자 |
### 1.3 데이터 흐름 (5단계 연쇄)
```
item_info (품목)
└─→ item_routing_version (라우팅 버전)
└─→ item_routing_detail (공정 배정) ← JOIN → process_mng (공정명)
└─→ process_work_item (작업 항목, phase별)
└─→ process_work_item_detail (상세)
```
---
## 2. 파일 구조 계획
### 2.1 프론트엔드 (컴포넌트 등록)
```
frontend/lib/registry/components/v2-process-work-standard/
├── index.ts # createComponentDefinition
├── types.ts # 타입 정의
├── config.ts # 기본 설정
├── ProcessWorkStandardRenderer.tsx # AutoRegisteringComponentRenderer
├── ProcessWorkStandardConfigPanel.tsx # 설정 패널
├── ProcessWorkStandardComponent.tsx # 메인 UI (좌우 분할)
├── components/
│ ├── ItemProcessSelector.tsx # 좌측: 품목/라우팅/공정 아코디언 트리
│ ├── WorkStandardEditor.tsx # 우측: 작업기준 편집 영역 전체
│ ├── WorkPhaseSection.tsx # Pre/In/Post 섹션 (3회 재사용)
│ ├── WorkItemCard.tsx # 작업 항목 카드
│ ├── WorkItemDetailList.tsx # 상세 리스트
│ └── WorkItemAddModal.tsx # 작업 항목 추가/수정 모달
├── hooks/
│ ├── useProcessWorkStandard.ts # 전체 데이터 관리 훅
│ ├── useItemProcessTree.ts # 좌측 트리 데이터 훅
│ └── useWorkItems.ts # 작업 항목 CRUD 훅
└── README.md
```
### 2.2 백엔드 (API)
```
backend-node/src/
├── routes/processWorkStandardRoutes.ts # 라우트 정의
└── controllers/processWorkStandardController.ts # 컨트롤러
```
### 2.3 DB 마이그레이션
```
db/migrations/XXX_create_process_work_standard_tables.sql
```
---
## 3. API 설계
| Method | Endpoint | 설명 |
|--------|----------|------|
| GET | `/api/process-work-standard/items` | 품목 목록 (라우팅 있는 품목만) |
| GET | `/api/process-work-standard/items/:itemCode/routings` | 품목별 라우팅 버전 + 공정 목록 |
| GET | `/api/process-work-standard/routing-detail/:routingDetailId/work-items` | 공정별 작업 항목 목록 (phase별 그룹) |
| POST | `/api/process-work-standard/work-items` | 작업 항목 추가 |
| PUT | `/api/process-work-standard/work-items/:id` | 작업 항목 수정 |
| DELETE | `/api/process-work-standard/work-items/:id` | 작업 항목 삭제 |
| GET | `/api/process-work-standard/work-items/:workItemId/details` | 작업 항목 상세 목록 |
| POST | `/api/process-work-standard/work-item-details` | 상세 추가 |
| PUT | `/api/process-work-standard/work-item-details/:id` | 상세 수정 |
| DELETE | `/api/process-work-standard/work-item-details/:id` | 상세 삭제 |
| PUT | `/api/process-work-standard/save-all` | 전체 저장 (작업 항목 + 상세 일괄) |
---
## 4. 구현 단계 (TDD 기반)
### Phase 1: DB + API 기반
- [ ] 1-1. 마이그레이션 SQL 작성 (process_work_item, process_work_item_detail)
- [ ] 1-2. 마이그레이션 실행 및 테이블 생성 확인
- [ ] 1-3. 백엔드 라우트/컨트롤러 작성 (CRUD API)
- [ ] 1-4. API 테스트 (품목 목록, 라우팅 조회, 작업항목 CRUD)
### Phase 2: 컴포넌트 기본 구조
- [ ] 2-1. types.ts, config.ts, index.ts 작성 (컴포넌트 정의)
- [ ] 2-2. Renderer, ConfigPanel 작성 (V2 시스템 등록)
- [ ] 2-3. components/index.ts에 import 추가
- [ ] 2-4. getComponentConfigPanel.tsx에 매핑 추가
- [ ] 2-5. 화면 디자이너에서 컴포넌트 배치 가능 확인
### Phase 3: 좌측 패널 (품목/공정 선택)
- [ ] 3-1. useItemProcessTree 훅 구현 (품목 목록 + 라우팅 조회)
- [ ] 3-2. ItemProcessSelector 컴포넌트 (아코디언 + 공정 리스트)
- [ ] 3-3. 검색 기능 (품목명/공정명 검색)
- [ ] 3-4. 선택 상태 관리 + 우측 패널 연동
### Phase 4: 우측 패널 (작업기준 편집)
- [ ] 4-1. WorkStandardEditor 기본 레이아웃 (Pre/In/Post 3단 섹션)
- [ ] 4-2. useWorkItems 훅 (작업 항목 + 상세 CRUD)
- [ ] 4-3. WorkPhaseSection 컴포넌트 (섹션 헤더 + 카드 영역 + 상세 영역)
- [ ] 4-4. WorkItemCard 컴포넌트 (카드 UI + 카운트 배지)
- [ ] 4-5. WorkItemDetailList 컴포넌트 (상세 목록 + 인라인 편집)
- [ ] 4-6. WorkItemAddModal (작업 항목 추가/수정 모달 + 상세 추가)
### Phase 5: 통합 + 전체 저장
- [ ] 5-1. 전체 저장 기능 (변경사항 일괄 저장 API 연동)
- [ ] 5-2. 공정 선택 시 데이터 로딩/전환 처리
- [ ] 5-3. Empty State 처리 (데이터 없을 때 안내 UI)
- [ ] 5-4. 로딩/에러 상태 처리
### Phase 6: 마무리
- [ ] 6-1. 멀티테넌시 검증 (company_code 필터링)
- [ ] 6-2. 반응형 디자인 점검
- [ ] 6-3. README.md 작성
---
## 5. 핵심 UI 설계
### 5.1 전체 레이아웃
```
┌─────────────────────────────────────────────────────────────────────┐
│ v2-process-work-standard │
├────────────────────┬────────────────────────────────────────────────┤
│ 품목 및 공정 선택 │ [품목명] - [공정명] [전체 저장] │
│ │ │
│ [검색 입력] │ ── 작업 전 (Pre-Work) N개 항목 ── [+항목추가] │
│ │ ┌────────┐ ┌─────────────────────────────┐ │
│ ▼ 볼트 M8x20 │ │카드 │ │ 상세 리스트 (선택 시 표시) │ │
│ ★ 기본 라우팅 │ │ │ │ │ │
│ ◉ 재단 │ └────────┘ └─────────────────────────────┘ │
│ ◉ 검사 ← 선택 │ │
│ ★ 버전2 │ ── 작업 중 (In-Work) N개 항목 ── [+항목추가] │
│ │ ┌────────┐ ┌────────┐ │
│ ▶ 기어 50T │ │카드1 │ │카드2 │ (상세: 우측 표시) │
│ ▶ 샤프트 D30 │ └────────┘ └────────┘ │
│ │ │
│ │ ── 작업 후 (Post-Work) N개 항목 ── [+항목추가] │
│ │ (동일 구조) │
├────────────────────┴────────────────────────────────────────────────┤
│ 30% │ 70% │
└─────────────────────────────────────────────────────────────────────┘
```
### 5.2 WorkPhaseSection 내부 구조
```
── 작업 전 (Pre-Work) 4개 항목 ────────────────── [+ 작업항목 추가]
┌──────────────────────────────┬──────────────────────────────────────┐
│ 작업 항목 카드 목록 │ 선택된 항목 상세 │
│ │ │
│ ┌──────────────────────┐ │ [항목 제목] [+ 상세추가]│
│ │ ≡ 검사 장비 준비 ✏️ 🗑 │ │ ─────────────────────────────────── │
│ │ 4개 필수 │ │ 순서│유형 │내용 │필수│관리│
│ └──────────────────────┘ │ 1 │체크 │버니어 캘리퍼스... │필수│✏️🗑│
│ │ 2 │체크 │마이크로미터... │선택│✏️🗑│
│ ┌──────────────────────┐ │ 3 │체크 │검사대 청소 │선택│✏️🗑│
│ │ ≡ 측정 도구 확인 ✏️ 🗑 │ │ 4 │체크 │검사 기록지 준비 │필수│✏️🗑│
│ │ 2개 선택 │ │ │
│ └──────────────────────┘ │ │
└──────────────────────────────┴──────────────────────────────────────┘
```
### 5.3 작업 항목 추가 모달
```
┌─────────────────────────────────────────────┐
│ 작업 항목 추가 ✕ │
├─────────────────────────────────────────────┤
│ 기본 정보 │
│ │
│ 항목 제목 * 필수 여부 │
│ [ ] [필수 ▼] │
│ │
│ 비고 │
│ [ ] │
│ │
│ 상세 항목 [+ 상세 추가] │
│ ┌───┬──────┬──────────────┬────┬────┐ │
│ │순서│유형 │내용 │필수│관리│ │
│ ├───┼──────┼──────────────┼────┼────┤ │
│ │ 1 │체크 │ │필수│ 🗑 │ │
│ └───┴──────┴──────────────┴────┴────┘ │
│ │
│ [취소] [저장] │
└─────────────────────────────────────────────┘
```
---
## 6. 컴포넌트 Config 설계
### 6.1 설정 패널 UI 구조
```
┌─────────────────────────────────────────────────┐
│ 공정 작업기준 설정 │
├─────────────────────────────────────────────────┤
│ │
│ ── 데이터 소스 설정 ────────────────────────── │
│ │
│ 품목 테이블 │
│ [item_info ▼] │
│ 품목명 컬럼 품목코드 컬럼 │
│ [item_name ▼] [item_number ▼] │
│ │
│ 라우팅 버전 테이블 │
│ [item_routing_version ▼] │
│ 품목 연결 컬럼 (FK) │
│ [item_code ▼] │
│ │
│ 라우팅 상세 테이블 │
│ [item_routing_detail ▼] │
│ │
│ 공정 마스터 테이블 │
│ [process_mng ▼] │
│ │
│ ── 작업 단계 설정 ────────────────────────── │
│ │
│ ┌────┬────────────────────┬─────────────┬───┐ │
│ │순서│ 단계 키(DB저장용) │ 표시 이름 │관리│ │
│ ├────┼────────────────────┼─────────────┼───┤ │
│ │ 1 │ PRE │ 작업 전 │ 🗑 │ │
│ │ 2 │ IN │ 작업 중 │ 🗑 │ │
│ │ 3 │ POST │ 작업 후 │ 🗑 │ │
│ └────┴────────────────────┴─────────────┴───┘ │
│ [+ 단계 추가] │
│ │
│ ── 상세 유형 옵션 ────────────────────────── │
│ │
│ ┌────────────────────┬─────────────┬───┐ │
│ │ 유형 값(DB저장용) │ 표시 이름 │관리│ │
│ ├────────────────────┼─────────────┼───┤ │
│ │ CHECK │ 체크 │ 🗑 │ │
│ │ INSPECTION │ 검사 │ 🗑 │ │
│ │ MEASUREMENT │ 측정 │ 🗑 │ │
│ └────────────────────┴─────────────┴───┘ │
│ [+ 유형 추가] │
│ │
│ ── UI 설정 ────────────────────────── │
│ │
│ 좌우 분할 비율 │
│ [30 ] % │
│ │
│ 좌측 패널 제목 │
│ [품목 및 공정 선택 ] │
│ │
│ 읽기 전용 모드 │
│ [ ] 활성화 │
│ │
└─────────────────────────────────────────────────┘
```
### 6.2 Config 타입 정의
```typescript
// 작업 단계 정의 (사용자가 추가/삭제/이름변경 가능)
interface WorkPhaseDefinition {
key: string; // DB 저장용 키 (예: "PRE", "IN", "POST", "QC")
label: string; // 화면 표시명 (예: "작업 전 (Pre-Work)")
sortOrder: number; // 표시 순서
}
// 상세 유형 정의 (사용자가 추가/삭제 가능)
interface DetailTypeDefinition {
value: string; // DB 저장용 값 (예: "CHECK")
label: string; // 화면 표시명 (예: "체크")
}
// 데이터 소스 설정 (사용자가 테이블 지정 가능)
interface DataSourceConfig {
// 품목 테이블
itemTable: string; // 기본: "item_info"
itemNameColumn: string; // 기본: "item_name"
itemCodeColumn: string; // 기본: "item_number"
// 라우팅 버전 테이블
routingVersionTable: string; // 기본: "item_routing_version"
routingItemFkColumn: string; // 기본: "item_code" (품목과 연결하는 FK)
routingVersionNameColumn: string; // 기본: "version_name"
// 라우팅 상세 테이블
routingDetailTable: string; // 기본: "item_routing_detail"
// 공정 마스터 테이블
processTable: string; // 기본: "process_mng"
processNameColumn: string; // 기본: "process_name"
processCodeColumn: string; // 기본: "process_code"
}
// 전체 Config
interface ProcessWorkStandardConfig {
// 데이터 소스 설정
dataSource: DataSourceConfig;
// 작업 단계 정의 (기본 3개, 사용자가 추가/삭제/수정 가능)
phases: WorkPhaseDefinition[];
// 기본값: [
// { key: "PRE", label: "작업 전 (Pre-Work)", sortOrder: 1 },
// { key: "IN", label: "작업 중 (In-Work)", sortOrder: 2 },
// { key: "POST", label: "작업 후 (Post-Work)", sortOrder: 3 },
// ]
// 상세 유형 옵션 (사용자가 추가/삭제 가능)
detailTypes: DetailTypeDefinition[];
// 기본값: [
// { value: "CHECK", label: "체크" },
// { value: "INSPECTION", label: "검사" },
// { value: "MEASUREMENT", label: "측정" },
// ]
// UI 설정
splitRatio?: number; // 좌우 분할 비율, 기본: 30
leftPanelTitle?: string; // 좌측 패널 제목, 기본: "품목 및 공정 선택"
readonly?: boolean; // 읽기 전용 모드, 기본: false
}
```
### 6.3 커스터마이징 시나리오 예시
**시나리오 A: 제조업 (기본)**
```
단계: 작업 전 → 작업 중 → 작업 후
유형: 체크, 검사, 측정
```
**시나리오 B: 품질검사 강화 회사**
```
단계: 준비 → 검사 → 판정 → 기록 → 보관
유형: 육안검사, 치수검사, 강도검사, 내구검사, 기능검사
```
**시나리오 C: 단순 2단계 회사**
```
단계: 사전점검 → 사후점검
유형: 확인, 기록
```
**시나리오 D: 다른 테이블 사용 회사**
```
품목 테이블: product_master (item_info 대신)
공정 테이블: operation_mng (process_mng 대신)
```
### 6.4 DB 설계 반영 사항
`work_phase` 컬럼은 고정 ENUM이 아니라 **사용자 정의 키(VARCHAR)** 로 저장합니다.
- Config에서 `phases[].key` 로 정의한 값이 DB에 저장됨
- 예: "PRE", "IN", "POST" 또는 "PREPARE", "INSPECT", "JUDGE", "RECORD", "STORE"
- 회사별 Config에 따라 다른 값이 저장되므로, 조회 시 Config의 phases 정의를 기준으로 섹션을 렌더링
---
## 7. 등록 체크리스트
| 항목 | 파일 | 작업 |
|------|------|------|
| 컴포넌트 정의 | `v2-process-work-standard/index.ts` | createComponentDefinition |
| 렌더러 등록 | `v2-process-work-standard/...Renderer.tsx` | registerSelf() |
| 컴포넌트 로드 | `components/index.ts` | import 추가 |
| 설정 패널 매핑 | `getComponentConfigPanel.tsx` | CONFIG_PANEL_MAP 추가 |
| 라우트 등록 | `backend-node/src/app.ts` | router.use() 추가 |
---
## 8. 의존성
- 외부 라이브러리 추가: 없음 (기존 shadcn/ui + Lucide 아이콘만 사용)
- 기존 API 재사용: dataRoutes의 범용 CRUD는 사용하지 않고 전용 API 개발
- 이유: 5단계 JOIN + phase별 그룹핑 등 범용 API로는 처리 불가

View File

@ -2,8 +2,8 @@
> **목적**: 다양한 회사에서 V2 컴포넌트를 활용하여 화면을 개발할 때 참고하는 범용 가이드
> **대상**: 화면 설계자, 개발자
> **버전**: 1.0.0
> **작성일**: 2026-01-30
> **버전**: 1.1.0
> **작성일**: 2026-02-23 (최종 업데이트)
---
@ -19,60 +19,63 @@
| 카드 뷰 | 이미지+정보 카드 형태 | 설비정보, 대시보드 |
| 피벗 분석 | 다차원 집계 | 매출분석, 재고현황 |
| 반복 컨테이너 | 데이터 수만큼 UI 반복 | 주문 상세, 항목 리스트 |
| 그룹화 테이블 | 그룹핑 기능 포함 테이블 | 카테고리별 집계, 부서별 현황 |
| 타임라인/스케줄 | 시간축 기반 일정 관리 | 생산일정, 작업스케줄 |
### 1.2 불가능한 화면 유형 (별도 개발 필요)
| 화면 유형 | 이유 | 해결 방안 |
|-----------|------|----------|
| 간트 차트 / 타임라인 | 시간축 기반 UI 없음 | 별도 컴포넌트 개발 or 외부 라이브러리 |
| 트리 뷰 (계층 구조) | 트리 컴포넌트 미존재 | `v2-tree-view` 개발 필요 |
| 그룹화 테이블 | 그룹핑 기능 미지원 | `v2-grouped-table` 개발 필요 |
| 드래그앤드롭 보드 | 칸반 스타일 UI 없음 | 별도 개발 |
| 모바일 앱 스타일 | 네이티브 앱 UI | 별도 개발 |
| 복잡한 차트 | 기본 집계 외 시각화 | 차트 라이브러리 연동 |
> **참고**: 그룹화 테이블(`v2-table-grouped`)과 타임라인 스케줄러(`v2-timeline-scheduler`)는 v1.1에서 추가되어 이제 지원됩니다.
---
## 2. V2 컴포넌트 전체 목록 (23개)
## 2. V2 컴포넌트 전체 목록 (25개)
### 2.1 입력 컴포넌트 (3개)
### 2.1 입력 컴포넌트 (4개)
| ID | 이름 | 용도 | 주요 옵션 |
|----|------|------|----------|
| `v2-input` | 입력 | 텍스트, 숫자, 비밀번호, 이메일, 전화번호, URL, 여러 줄 | inputType, required, readonly, maxLength |
| `v2-select` | 선택 | 드롭다운, 콤보박스, 라디오, 체크박스 | mode, source(distinct/static/code/entity), multiple |
| `v2-date` | 날짜 | 날짜, 시간, 날짜시간, 날짜범위, 월, 연도 | dateType, format, showTime |
| `v2-input` | 입력 | 텍스트, 숫자, 비밀번호, 슬라이더, 컬러 | inputType(text/number/password/slider/color/button), format(email/tel/url/currency/biz_no), required, readonly, maxLength, min, max, step |
| `v2-select` | 선택 | 드롭다운, 콤보박스, 라디오, 체크, 태그, 토글, 스왑 | mode(dropdown/combobox/radio/check/tag/tagbox/toggle/swap), source(static/code/db/api/entity/category/distinct/select), searchable, multiple, cascading |
| `v2-date` | 날짜 | 날짜, 시간, 날짜시간 | dateType(date/time/datetime), format, range, minDate, maxDate, showToday |
| `v2-file-upload` | 파일 업로드 | 파일/이미지 업로드 | - |
### 2.2 표시 컴포넌트 (3개)
| ID | 이름 | 용도 | 주요 옵션 |
|----|------|------|----------|
| `v2-text-display` | 텍스트 표시 | 라벨, 제목, 설명 텍스트 | fontSize, fontWeight, color, textAlign |
| `v2-card-display` | 카드 디스플레이 | 테이블 데이터를 카드 형태로 표시 | cardsPerRow, showImage, columnMapping |
| `v2-card-display` | 카드 디스플레이 | 테이블 데이터를 카드 형태로 표시 | cardsPerRow, cardSpacing, columnMapping(titleColumn/subtitleColumn/descriptionColumn/imageColumn), cardStyle(imagePosition/imageSize), dataSource(table/static/api) |
| `v2-aggregation-widget` | 집계 위젯 | 합계, 평균, 개수, 최대, 최소 | items, filters, layout |
### 2.3 테이블/데이터 컴포넌트 (3개)
### 2.3 테이블/데이터 컴포넌트 (4개)
| ID | 이름 | 용도 | 주요 옵션 |
|----|------|------|----------|
| `v2-table-list` | 테이블 리스트 | 데이터 조회/편집 테이블 | selectedTable, columns, pagination, filter |
| `v2-table-search-widget` | 검색 필터 | 테이블 검색/필터/그룹 | autoSelectFirstTable, showTableSelector |
| `v2-pivot-grid` | 피벗 그리드 | 다차원 분석 (행/열/데이터 영역) | fields, totals, aggregation |
| `v2-table-list` | 테이블 리스트 | 데이터 조회/편집 테이블 | selectedTable, columns, pagination, filter, displayMode(table/card), checkbox, horizontalScroll, linkedFilters, excludeFilter, toolbar, tableStyle, autoLoad |
| `v2-table-search-widget` | 검색 필터 | 테이블 검색/필터/그룹 | autoSelectFirstTable, showTableSelector, title |
| `v2-pivot-grid` | 피벗 그리드 | 다차원 분석 (행/열/데이터 영역) | fields(area: row/column/data/filter, summaryType: sum/avg/count/min/max/countDistinct, groupInterval: year/quarter/month/week/day), dataSource(type: table/api/static, joinConfigs, filterConditions) |
| `v2-table-grouped` | 그룹화 테이블 | 그룹핑 기능이 포함된 테이블 | - |
### 2.4 레이아웃 컴포넌트 (8개)
### 2.4 레이아웃 컴포넌트 (7개)
| ID | 이름 | 용도 | 주요 옵션 |
|----|------|------|----------|
| `v2-split-panel-layout` | 분할 패널 | 마스터-디테일 좌우 분할 | splitRatio, resizable, relation, **displayMode: custom** |
| `v2-tabs-widget` | 탭 위젯 | 탭 전환, 탭 내 컴포넌트 | tabs, activeTabId |
| `v2-split-panel-layout` | 분할 패널 | 마스터-디테일 좌우 분할 | splitRatio, resizable, minLeftWidth, minRightWidth, syncSelection, panel별: displayMode(list/table/custom), relation(type/foreignKey), editButton, addButton, deleteButton, additionalTabs |
| `v2-tabs-widget` | 탭 위젯 | 탭 전환, 탭 내 컴포넌트 | tabs(id/label/order/disabled/components), defaultTab, orientation(horizontal/vertical), allowCloseable, persistSelection |
| `v2-section-card` | 섹션 카드 | 제목+테두리 그룹화 | title, collapsible, padding |
| `v2-section-paper` | 섹션 페이퍼 | 배경색 그룹화 | backgroundColor, padding, shadow |
| `v2-divider-line` | 구분선 | 영역 구분 | orientation, thickness |
| `v2-repeat-container` | 리피터 컨테이너 | 데이터 수만큼 반복 렌더링 | dataSourceType, layout, gridColumns |
| `v2-repeater` | 리피터 | 반복 컨트롤 | - |
| `v2-repeat-screen-modal` | 반복 화면 모달 | 모달 반복 | - |
| `v2-repeater` | 리피터 | 반복 컨트롤 (inline/modal) | - |
### 2.5 액션/특수 컴포넌트 (6개)
### 2.5 액션/특수 컴포넌트 (7개)
| ID | 이름 | 용도 | 주요 옵션 |
|----|------|------|----------|
@ -82,6 +85,7 @@
| `v2-location-swap-selector` | 위치 교환 | 위치 선택/교환 | - |
| `v2-rack-structure` | 랙 구조 | 창고 랙 시각화 | - |
| `v2-media` | 미디어 | 이미지/동영상 표시 | - |
| `v2-timeline-scheduler` | 타임라인 스케줄러 | 시간축 기반 일정/작업 관리 | - |
---
@ -261,8 +265,26 @@
],
pagination: {
enabled: true,
pageSize: 20
}
pageSize: 20,
showSizeSelector: true,
showPageInfo: true
},
displayMode: "table", // "table" | "card"
checkbox: {
enabled: true,
multiple: true,
position: "left",
selectAll: true
},
horizontalScroll: { // 가로 스크롤 설정
enabled: true,
maxVisibleColumns: 8
},
linkedFilters: [], // 연결 필터 (다른 컴포넌트와 연동)
excludeFilter: {}, // 제외 필터
autoLoad: true, // 자동 데이터 로드
stickyHeader: false, // 헤더 고정
autoWidth: true // 자동 너비 조정
}
```
@ -271,16 +293,44 @@
```typescript
{
leftPanel: {
tableName: "마스터_테이블명"
displayMode: "table", // "list" | "table" | "custom"
tableName: "마스터_테이블명",
columns: [], // 컬럼 설정
editButton: { // 수정 버튼 설정
enabled: true,
mode: "auto", // "auto" | "modal"
modalScreenId: "" // 모달 모드 시 화면 ID
},
addButton: { // 추가 버튼 설정
enabled: true,
mode: "auto",
modalScreenId: ""
},
deleteButton: { // 삭제 버튼 설정
enabled: true,
buttonLabel: "삭제",
confirmMessage: "삭제하시겠습니까?"
},
addModalColumns: [], // 추가 모달 전용 컬럼
additionalTabs: [] // 추가 탭 설정
},
rightPanel: {
displayMode: "table",
tableName: "디테일_테이블명",
relation: {
type: "detail", // join | detail | custom
foreignKey: "master_id" // 연결 키
type: "detail", // "join" | "detail" | "custom"
foreignKey: "master_id", // 연결 키
leftColumn: "", // 좌측 연결 컬럼
rightColumn: "", // 우측 연결 컬럼
keys: [] // 복합 키
}
},
splitRatio: 30 // 좌측 비율
splitRatio: 30, // 좌측 비율 (0-100)
resizable: true, // 리사이즈 가능
minLeftWidth: 200, // 좌측 최소 너비
minRightWidth: 300, // 우측 최소 너비
syncSelection: true, // 선택 동기화
autoLoad: true // 자동 로드
}
```
@ -347,12 +397,12 @@
| 기능 | 상태 | 대안 |
|------|------|------|
| 트리 뷰 (BOM, 조직도) | ❌ 미지원 | 테이블로 대체 or 별도 개발 |
| 그룹화 테이블 | ❌ 미지원 | 일반 테이블로 대체 or 별도 개발 |
| 간트 차트 | ❌ 미지원 | 별도 개발 필요 |
| 드래그앤드롭 정렬 | ❌ 미지원 | 순서 컬럼으로 대체 |
| 인라인 편집 | ⚠️ 제한적 | 모달 편집으로 대체 |
| 복잡한 차트 | ❌ 미지원 | 외부 라이브러리 연동 |
> **v1.1 업데이트**: 그룹화 테이블(`v2-table-grouped`)과 타임라인 스케줄러(`v2-timeline-scheduler`)가 추가되어 해당 기능은 이제 지원됩니다.
### 5.2 권장하지 않는 조합
| 조합 | 이유 |
@ -555,9 +605,10 @@
| 탭 화면 | ✅ 완전 | v2-tabs-widget |
| 카드 뷰 | ✅ 완전 | v2-card-display |
| 피벗 분석 | ✅ 완전 | v2-pivot-grid |
| 그룹화 테이블 | ❌ 미지원 | 개발 필요 |
| 그룹화 테이블 | ✅ 지원 | v2-table-grouped |
| 타임라인/스케줄 | ✅ 지원 | v2-timeline-scheduler |
| 파일 업로드 | ✅ 지원 | v2-file-upload |
| 트리 뷰 | ❌ 미지원 | 개발 필요 |
| 간트 차트 | ❌ 미지원 | 개발 필요 |
### 개발 시 핵심 원칙

View File

@ -225,7 +225,7 @@ childItemsSection: {
"config": {
"masterPanel": {
"title": "BOM 목록",
"entityId": "bom_header",
"entityId": "bom",
"columns": [
{ "id": "item_code", "label": "품목코드", "width": 100 },
{ "id": "item_name", "label": "품목명", "width": 150 },

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@ -19,6 +19,7 @@ import {
Copy,
Check,
ChevronsUpDown,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
@ -62,6 +63,7 @@ interface ColumnTypeInfo {
detailSettings: string;
description: string;
isNullable: string;
isUnique: string;
defaultValue?: string;
maxLength?: number;
numericPrecision?: number;
@ -71,9 +73,10 @@ interface ColumnTypeInfo {
referenceTable?: string;
referenceColumn?: string;
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
categoryMenus?: number[]; // 🆕 Category 타입: 선택된 2레벨 메뉴 OBJID 배열
hierarchyRole?: "large" | "medium" | "small"; // 🆕 계층구조 역할
numberingRuleId?: string; // 🆕 Numbering 타입: 채번규칙 ID
categoryMenus?: number[];
hierarchyRole?: "large" | "medium" | "small";
numberingRuleId?: string;
categoryRef?: string | null;
}
interface SecondLevelMenu {
@ -140,11 +143,22 @@ export default function TableManagementPage() {
const [logViewerOpen, setLogViewerOpen] = useState(false);
const [logViewerTableName, setLogViewerTableName] = useState<string>("");
// 저장 중 상태 (중복 실행 방지)
const [isSaving, setIsSaving] = useState(false);
// 테이블 삭제 확인 다이얼로그 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [tableToDelete, setTableToDelete] = useState<string>("");
const [isDeleting, setIsDeleting] = useState(false);
// PK/인덱스 관리 상태
const [constraints, setConstraints] = useState<{
primaryKey: { name: string; columns: string[] };
indexes: Array<{ name: string; columns: string[]; isUnique: boolean }>;
}>({ primaryKey: { name: "", columns: [] }, indexes: [] });
const [pkDialogOpen, setPkDialogOpen] = useState(false);
const [pendingPkColumns, setPendingPkColumns] = useState<string[]>([]);
// 선택된 테이블 목록 (체크박스)
const [selectedTableIds, setSelectedTableIds] = useState<Set<string>>(new Set());
@ -370,10 +384,12 @@ export default function TableManagementPage() {
return {
...col,
inputType: col.inputType || "text", // 기본값: text
numberingRuleId, // 🆕 채번규칙 ID
categoryMenus: col.categoryMenus || [], // 카테고리 메뉴 매핑 정보
hierarchyRole, // 계층구조 역할
inputType: col.inputType || "text",
isUnique: col.isUnique || "NO",
numberingRuleId,
categoryMenus: col.categoryMenus || [],
hierarchyRole,
categoryRef: col.categoryRef || null,
};
});
@ -397,6 +413,19 @@ export default function TableManagementPage() {
}
}, []);
// PK/인덱스 제약조건 로드
const loadConstraints = useCallback(async (tableName: string) => {
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/constraints`);
if (response.data.success) {
setConstraints(response.data.data);
}
} catch (error) {
console.error("제약조건 로드 실패:", error);
setConstraints({ primaryKey: { name: "", columns: [] }, indexes: [] });
}
}, []);
// 테이블 선택
const handleTableSelect = useCallback(
(tableName: string) => {
@ -410,8 +439,9 @@ export default function TableManagementPage() {
setTableDescription(tableInfo?.description || "");
loadColumnTypes(tableName, 1, pageSize);
loadConstraints(tableName);
},
[loadColumnTypes, pageSize, tables],
[loadColumnTypes, loadConstraints, pageSize, tables],
);
// 입력 타입 변경
@ -642,15 +672,16 @@ export default function TableManagementPage() {
}
const columnSetting = {
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
columnLabel: column.displayName, // 사용자가 입력한 표시명
columnName: column.columnName,
columnLabel: column.displayName,
inputType: column.inputType || "text",
detailSettings: finalDetailSettings,
codeCategory: column.codeCategory || "",
codeValue: column.codeValue || "",
referenceTable: column.referenceTable || "",
referenceColumn: column.referenceColumn || "",
displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
displayColumn: column.displayColumn || "",
categoryRef: column.categoryRef || null,
};
// console.log("저장할 컬럼 설정:", columnSetting);
@ -677,9 +708,9 @@ export default function TableManagementPage() {
length: column.categoryMenus?.length || 0,
});
if (column.inputType === "category") {
// 1. 먼저 기존 매핑 모두 삭제
console.log("🗑️ 기존 카테고리 메뉴 매핑 삭제 시작:", {
if (column.inputType === "category" && !column.categoryRef) {
// 참조가 아닌 자체 카테고리만 메뉴 매핑 처리
console.log("기존 카테고리 메뉴 매핑 삭제 시작:", {
tableName: selectedTable,
columnName: column.columnName,
});
@ -757,7 +788,9 @@ export default function TableManagementPage() {
// 전체 저장 (테이블 라벨 + 모든 컬럼 설정)
const saveAllSettings = async () => {
if (!selectedTable) return;
if (isSaving) return; // 저장 중 중복 실행 방지
setIsSaving(true);
try {
// 1. 테이블 라벨 저장 (변경된 경우에만)
if (tableLabel !== selectedTable || tableDescription) {
@ -836,8 +869,8 @@ export default function TableManagementPage() {
}
return {
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
columnLabel: column.displayName, // 사용자가 입력한 표시명
columnName: column.columnName,
columnLabel: column.displayName,
inputType: column.inputType || "text",
detailSettings: finalDetailSettings,
description: column.description || "",
@ -845,7 +878,8 @@ export default function TableManagementPage() {
codeValue: column.codeValue || "",
referenceTable: column.referenceTable || "",
referenceColumn: column.referenceColumn || "",
displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
displayColumn: column.displayColumn || "",
categoryRef: column.categoryRef || null,
};
});
@ -858,8 +892,8 @@ export default function TableManagementPage() {
);
if (response.data.success) {
// 🆕 Category 타입 컬럼들의 메뉴 매핑 처리
const categoryColumns = columns.filter((col) => col.inputType === "category");
// 자체 카테고리 컬럼만 메뉴 매핑 처리 (참조 컬럼 제외)
const categoryColumns = columns.filter((col) => col.inputType === "category" && !col.categoryRef);
console.log("📥 전체 저장: 카테고리 컬럼 확인", {
totalColumns: columns.length,
@ -952,9 +986,30 @@ export default function TableManagementPage() {
} catch (error) {
// console.error("설정 저장 실패:", error);
toast.error("설정 저장 중 오류가 발생했습니다.");
} finally {
setIsSaving(false);
}
};
// Ctrl+S 단축키: 테이블 설정 전체 저장
// saveAllSettings를 ref로 참조하여 useEffect 의존성 문제 방지
const saveAllSettingsRef = useRef(saveAllSettings);
saveAllSettingsRef.current = saveAllSettings;
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault(); // 브라우저 기본 저장 동작 방지
if (selectedTable && columns.length > 0) {
saveAllSettingsRef.current();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedTable, columns.length]);
// 필터링된 테이블 목록 (메모이제이션)
const filteredTables = useMemo(
() =>
@ -1000,6 +1055,150 @@ export default function TableManagementPage() {
}
}, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]);
// PK 체크박스 변경 핸들러
const handlePkToggle = useCallback(
(columnName: string, checked: boolean) => {
const currentPkCols = [...constraints.primaryKey.columns];
let newPkCols: string[];
if (checked) {
newPkCols = [...currentPkCols, columnName];
} else {
newPkCols = currentPkCols.filter((c) => c !== columnName);
}
// PK 변경은 확인 다이얼로그 표시
setPendingPkColumns(newPkCols);
setPkDialogOpen(true);
},
[constraints.primaryKey.columns],
);
// PK 변경 확인
const handlePkConfirm = async () => {
if (!selectedTable) return;
try {
if (pendingPkColumns.length === 0) {
toast.error("PK 컬럼을 최소 1개 이상 선택해야 합니다.");
setPkDialogOpen(false);
return;
}
const response = await apiClient.put(`/table-management/tables/${selectedTable}/primary-key`, {
columns: pendingPkColumns,
});
if (response.data.success) {
toast.success(response.data.message);
await loadConstraints(selectedTable);
} else {
toast.error(response.data.message || "PK 설정 실패");
}
} catch (error: any) {
toast.error(error?.response?.data?.message || "PK 설정 중 오류가 발생했습니다.");
} finally {
setPkDialogOpen(false);
}
};
// 인덱스 토글 핸들러 (일반 인덱스만 DB 레벨 - 유니크는 앱 레벨 소프트 제약조건으로 분리됨)
const handleIndexToggle = useCallback(
async (columnName: string, indexType: "index", checked: boolean) => {
if (!selectedTable) return;
const action = checked ? "create" : "drop";
try {
const response = await apiClient.post(`/table-management/tables/${selectedTable}/indexes`, {
columnName,
indexType,
action,
});
if (response.data.success) {
toast.success(response.data.message);
await loadConstraints(selectedTable);
} else {
toast.error(response.data.message || "인덱스 설정 실패");
}
} catch (error: any) {
toast.error(error?.response?.data?.message || error?.response?.data?.error || "인덱스 설정 중 오류가 발생했습니다.");
}
},
[selectedTable, loadConstraints],
);
// 컬럼별 인덱스 상태 헬퍼
const getColumnIndexState = useCallback(
(columnName: string) => {
const isPk = constraints.primaryKey.columns.includes(columnName);
const hasIndex = constraints.indexes.some(
(idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName,
);
return { isPk, hasIndex };
},
[constraints],
);
// UNIQUE 토글 핸들러 (앱 레벨 소프트 제약조건 - NOT NULL과 동일 패턴)
const handleUniqueToggle = useCallback(
async (columnName: string, currentIsUnique: string) => {
if (!selectedTable) return;
const isCurrentlyUnique = currentIsUnique === "YES";
const newUnique = !isCurrentlyUnique;
try {
const response = await apiClient.put(
`/table-management/tables/${selectedTable}/columns/${columnName}/unique`,
{ unique: newUnique },
);
if (response.data.success) {
toast.success(response.data.message);
setColumns((prev) =>
prev.map((col) =>
col.columnName === columnName
? { ...col, isUnique: newUnique ? "YES" : "NO" }
: col,
),
);
} else {
toast.error(response.data.message || "UNIQUE 설정 실패");
}
} catch (error: any) {
toast.error(error?.response?.data?.message || "UNIQUE 설정 중 오류가 발생했습니다.");
}
},
[selectedTable],
);
// NOT NULL 토글 핸들러
const handleNullableToggle = useCallback(
async (columnName: string, currentIsNullable: string) => {
if (!selectedTable) return;
// isNullable이 "YES"면 nullable, "NO"면 NOT NULL
// 체크박스 체크 = NOT NULL 설정 (nullable: false)
// 체크박스 해제 = NOT NULL 해제 (nullable: true)
const isCurrentlyNotNull = currentIsNullable === "NO";
const newNullable = isCurrentlyNotNull; // NOT NULL이면 해제, NULL이면 설정
try {
const response = await apiClient.put(
`/table-management/tables/${selectedTable}/columns/${columnName}/nullable`,
{ nullable: newNullable },
);
if (response.data.success) {
toast.success(response.data.message);
// 컬럼 상태 로컬 업데이트
setColumns((prev) =>
prev.map((col) =>
col.columnName === columnName
? { ...col, isNullable: newNullable ? "YES" : "NO" }
: col,
),
);
} else {
toast.error(response.data.message || "NOT NULL 설정 실패");
}
} catch (error: any) {
toast.error(
error?.response?.data?.message || "NOT NULL 설정 중 오류가 발생했습니다.",
);
}
},
[selectedTable],
);
// 테이블 삭제 확인
const handleDeleteTableClick = (tableName: string) => {
setTableToDelete(tableName);
@ -1367,11 +1566,15 @@ export default function TableManagementPage() {
{/* 저장 버튼 (항상 보이도록 상단에 배치) */}
<Button
onClick={saveAllSettings}
disabled={!selectedTable || columns.length === 0}
disabled={!selectedTable || columns.length === 0 || isSaving}
className="h-10 gap-2 text-sm font-medium"
>
<Settings className="h-4 w-4" />
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Settings className="h-4 w-4" />
)}
{isSaving ? "저장 중..." : "전체 설정 저장"}
</Button>
</div>
@ -1391,12 +1594,16 @@ export default function TableManagementPage() {
{/* 컬럼 헤더 (고정) */}
<div
className="text-foreground grid h-12 flex-shrink-0 items-center border-b px-6 py-3 text-sm font-semibold"
style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}
style={{ gridTemplateColumns: "160px 200px 250px minmax(80px,1fr) 70px 70px 70px 70px" }}
>
<div className="pr-4"></div>
<div className="px-4"></div>
<div className="pr-4"></div>
<div className="px-4"></div>
<div className="pr-6"> </div>
<div className="pl-4"></div>
<div className="text-center text-xs">Primary</div>
<div className="text-center text-xs">NotNull</div>
<div className="text-center text-xs">Index</div>
<div className="text-center text-xs">Unique</div>
</div>
{/* 컬럼 리스트 (스크롤 영역) */}
@ -1410,16 +1617,15 @@ export default function TableManagementPage() {
}
}}
>
{columns.map((column, index) => (
{columns.map((column, index) => {
const idxState = getColumnIndexState(column.columnName);
return (
<div
key={column.columnName}
className="bg-background hover:bg-muted/50 grid min-h-16 items-start border-b px-6 py-3 transition-colors"
style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}
style={{ gridTemplateColumns: "160px 200px 250px minmax(80px,1fr) 70px 70px 70px 70px" }}
>
<div className="pt-1 pr-4">
<div className="font-mono text-sm">{column.columnName}</div>
</div>
<div className="px-4">
<div className="pr-4">
<Input
value={column.displayName || ""}
onChange={(e) => handleLabelChange(column.columnName, e.target.value)}
@ -1427,6 +1633,9 @@ export default function TableManagementPage() {
className="h-8 text-xs"
/>
</div>
<div className="px-4 pt-1">
<div className="font-mono text-sm">{column.columnName}</div>
</div>
<div className="pr-6">
<div className="space-y-3">
{/* 입력 타입 선택 */}
@ -1486,7 +1695,30 @@ export default function TableManagementPage() {
)}
</>
)}
{/* 카테고리 타입: 메뉴 종속성 제거됨 - 테이블/컬럼 단위로 관리 */}
{/* 카테고리 타입: 참조 설정 */}
{column.inputType === "category" && (
<div className="w-56">
<label className="text-muted-foreground mb-1 block text-xs"> ()</label>
<Input
value={column.categoryRef || ""}
onChange={(e) => {
const val = e.target.value || null;
setColumns((prev) =>
prev.map((c) =>
c.columnName === column.columnName
? { ...c, categoryRef: val }
: c
)
);
}}
placeholder="테이블명.컬럼명"
className="h-8 text-xs"
/>
<p className="text-muted-foreground mt-0.5 text-[10px]">
</p>
</div>
)}
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
{column.inputType === "entity" && (
<>
@ -1689,141 +1921,11 @@ export default function TableManagementPage() {
</div>
)}
{/* 표시 컬럼 - 검색 가능한 Combobox */}
{column.referenceTable &&
column.referenceTable !== "none" &&
column.referenceColumn &&
column.referenceColumn !== "none" && (
<div className="w-56">
<label className="text-muted-foreground mb-1 block text-xs"> </label>
<Popover
open={entityComboboxOpen[column.columnName]?.displayColumn || false}
onOpenChange={(open) =>
setEntityComboboxOpen((prev) => ({
...prev,
[column.columnName]: {
...prev[column.columnName],
displayColumn: open,
},
}))
}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={
entityComboboxOpen[column.columnName]?.displayColumn || false
}
className="bg-background h-8 w-full justify-between text-xs"
disabled={
!referenceTableColumns[column.referenceTable] ||
referenceTableColumns[column.referenceTable].length === 0
}
>
{!referenceTableColumns[column.referenceTable] ||
referenceTableColumns[column.referenceTable].length === 0 ? (
<span className="flex items-center gap-2">
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
...
</span>
) : column.displayColumn && column.displayColumn !== "none" ? (
column.displayColumn
) : (
"컬럼 선택..."
)}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-2 text-center text-xs">
.
</CommandEmpty>
<CommandGroup>
<CommandItem
value="none"
onSelect={() => {
handleDetailSettingsChange(
column.columnName,
"entity_display_column",
"none",
);
setEntityComboboxOpen((prev) => ({
...prev,
[column.columnName]: {
...prev[column.columnName],
displayColumn: false,
},
}));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
column.displayColumn === "none" || !column.displayColumn
? "opacity-100"
: "opacity-0",
)}
/>
-- --
</CommandItem>
{referenceTableColumns[column.referenceTable]?.map((refCol) => (
<CommandItem
key={refCol.columnName}
value={`${refCol.columnLabel || ""} ${refCol.columnName}`}
onSelect={() => {
handleDetailSettingsChange(
column.columnName,
"entity_display_column",
refCol.columnName,
);
setEntityComboboxOpen((prev) => ({
...prev,
[column.columnName]: {
...prev[column.columnName],
displayColumn: false,
},
}));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
column.displayColumn === refCol.columnName
? "opacity-100"
: "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{refCol.columnName}</span>
{refCol.columnLabel && (
<span className="text-muted-foreground text-[10px]">
{refCol.columnLabel}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{/* 설정 완료 표시 */}
{column.referenceTable &&
column.referenceTable !== "none" &&
column.referenceColumn &&
column.referenceColumn !== "none" &&
column.displayColumn &&
column.displayColumn !== "none" && (
column.referenceColumn !== "none" && (
<div className="bg-primary/10 text-primary flex items-center gap-1 rounded px-2 py-1 text-xs">
<Check className="h-3 w-3" />
<span className="truncate"> </span>
@ -1953,8 +2055,49 @@ export default function TableManagementPage() {
className="h-8 w-full text-xs"
/>
</div>
{/* PK 체크박스 */}
<div className="flex items-center justify-center pt-1">
<Checkbox
checked={idxState.isPk}
onCheckedChange={(checked) =>
handlePkToggle(column.columnName, checked as boolean)
}
aria-label={`${column.columnName} PK 설정`}
/>
</div>
{/* NN (NOT NULL) 체크박스 */}
<div className="flex items-center justify-center pt-1">
<Checkbox
checked={column.isNullable === "NO"}
onCheckedChange={() =>
handleNullableToggle(column.columnName, column.isNullable)
}
aria-label={`${column.columnName} NOT NULL 설정`}
/>
</div>
{/* IDX 체크박스 */}
<div className="flex items-center justify-center pt-1">
<Checkbox
checked={idxState.hasIndex}
onCheckedChange={(checked) =>
handleIndexToggle(column.columnName, "index", checked as boolean)
}
aria-label={`${column.columnName} 인덱스 설정`}
/>
</div>
{/* UQ 체크박스 (앱 레벨 소프트 제약조건) */}
<div className="flex items-center justify-center pt-1">
<Checkbox
checked={column.isUnique === "YES"}
onCheckedChange={() =>
handleUniqueToggle(column.columnName, column.isUnique)
}
aria-label={`${column.columnName} 유니크 설정`}
/>
</div>
</div>
))}
);
})}
{/* 로딩 표시 */}
{columnsLoading && (
@ -2120,6 +2263,52 @@ export default function TableManagementPage() {
</>
)}
{/* PK 변경 확인 다이얼로그 */}
<Dialog open={pkDialogOpen} onOpenChange={setPkDialogOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">PK </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
PK를 .
<br /> .
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<div className="rounded-lg border p-4">
<p className="text-sm font-medium"> PK :</p>
{pendingPkColumns.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{pendingPkColumns.map((col) => (
<Badge key={col} variant="secondary" className="font-mono text-xs">
{col}
</Badge>
))}
</div>
) : (
<p className="text-destructive mt-2 text-sm">PK가 </p>
)}
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setPkDialogOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handlePkConfirm}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div>

View File

@ -0,0 +1,52 @@
"use client";
import { useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { apiClient } from "@/lib/api/client";
/**
* /screen/{screenCode} /screens/{screenId}
* URL이 screenCode , screenId로
*/
export default function ScreenCodeRedirectPage() {
const params = useParams();
const router = useRouter();
const screenCode = params.screenCode as string;
useEffect(() => {
if (!screenCode) return;
const numericId = parseInt(screenCode);
if (!isNaN(numericId)) {
router.replace(`/screens/${numericId}`);
return;
}
const resolve = async () => {
try {
const res = await apiClient.get("/screen-management/screens", {
params: { searchTerm: screenCode, size: 50 },
});
const items = res.data?.data?.data || res.data?.data || [];
const arr = Array.isArray(items) ? items : [];
const exact = arr.find((s: any) => s.screenCode === screenCode);
const target = exact || arr[0];
if (target) {
router.replace(`/screens/${target.screenId || target.screen_id}`);
} else {
router.replace("/");
}
} catch {
router.replace("/");
}
};
resolve();
}, [screenCode, router]);
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
);
}

View File

@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition, LayoutData, ComponentData } from "@/types/screen";
import { LayerDefinition } from "@/types/screen-management";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { initializeComponents } from "@/lib/registry/components";
@ -86,6 +87,13 @@ function ScreenViewPage() {
// 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이)
const [conditionalContainerHeights, setConditionalContainerHeights] = useState<Record<string, number>>({});
// 레이어 시스템 지원
const [conditionalLayers, setConditionalLayers] = useState<LayerDefinition[]>([]);
// 조건부 영역(Zone) 목록
const [zones, setZones] = useState<import("@/types/screen-management").ConditionalZone[]>([]);
// 데이터 전달에 의해 강제 활성화된 레이어 ID 목록
const [forceActivatedLayerIds, setForceActivatedLayerIds] = useState<string[]>([]);
// 편집 모달 상태
const [editModalOpen, setEditModalOpen] = useState(false);
const [editModalConfig, setEditModalConfig] = useState<{
@ -171,7 +179,25 @@ function ScreenViewPage() {
} else {
// V1 레이아웃 또는 빈 레이아웃
const layoutData = await screenApi.getLayout(screenId);
setLayout(layoutData);
if (layoutData?.components?.length > 0) {
setLayout(layoutData);
} else {
console.warn("[ScreenViewPage] getLayout 실패, getLayerLayout(1) fallback:", screenId);
const baseLayerData = await screenApi.getLayerLayout(screenId, 1);
if (baseLayerData && isValidV2Layout(baseLayerData)) {
const converted = convertV2ToLegacy(baseLayerData);
if (converted) {
setLayout({
...converted,
screenResolution: baseLayerData.screenResolution || converted.screenResolution,
} as LayoutData);
} else {
setLayout(layoutData);
}
} else {
setLayout(layoutData);
}
}
}
} catch (layoutError) {
console.warn("레이아웃 로드 실패, 빈 레이아웃 사용:", layoutError);
@ -204,8 +230,219 @@ function ScreenViewPage() {
}
}, [screenId]);
// 🆕 메인 테이블 데이터 자동 로드 (단일 레코드 폼)
// 화면의 메인 테이블에서 사용자 회사 코드로 데이터를 조회하여 폼에 자동 채움
// 🆕 조건부 레이어 + Zone 로드
useEffect(() => {
const loadConditionalLayersAndZones = async () => {
if (!screenId || !layout) return;
try {
// 1. Zone 로드
const loadedZones = await screenApi.getScreenZones(screenId);
setZones(loadedZones);
// 2. 모든 레이어 목록 조회
const allLayers = await screenApi.getScreenLayers(screenId);
const nonBaseLayers = allLayers.filter((l: any) => l.layer_id > 1);
if (nonBaseLayers.length === 0) {
setConditionalLayers([]);
return;
}
// 3. 각 레이어의 레이아웃 데이터 로드
const layerDefinitions: LayerDefinition[] = [];
for (const layerInfo of nonBaseLayers) {
try {
const layerData = await screenApi.getLayerLayout(screenId, layerInfo.layer_id);
const condConfig = layerInfo.condition_config || layerData?.conditionConfig || {};
// 레이어 컴포넌트 변환 (V2 → Legacy)
let layerComponents: any[] = [];
const rawComponents = layerData?.components;
if (rawComponents && Array.isArray(rawComponents) && rawComponents.length > 0) {
const tempV2 = {
version: "2.0" as const,
components: rawComponents,
gridSettings: layerData.gridSettings,
screenResolution: layerData.screenResolution,
};
if (isValidV2Layout(tempV2)) {
const converted = convertV2ToLegacy(tempV2);
if (converted) {
layerComponents = converted.components || [];
}
}
}
// Zone 기반 condition_config 처리
const zoneId = condConfig.zone_id;
const conditionValue = condConfig.condition_value;
const zone = zoneId ? loadedZones.find((z: any) => z.zone_id === zoneId) : null;
// LayerDefinition 생성
const layerDef: LayerDefinition = {
id: String(layerInfo.layer_id),
name: layerInfo.layer_name || `레이어 ${layerInfo.layer_id}`,
type: "conditional",
zIndex: layerInfo.layer_id * 10,
isVisible: false,
isLocked: false,
// Zone 기반 조건 (Zone에서 트리거 정보를 가져옴)
condition: zone ? {
targetComponentId: zone.trigger_component_id || "",
operator: (zone.trigger_operator as "eq" | "neq" | "in") || "eq",
value: conditionValue,
} : condConfig.targetComponentId ? {
targetComponentId: condConfig.targetComponentId,
operator: condConfig.operator || "eq",
value: condConfig.value,
} : undefined,
// Zone 기반: displayRegion은 Zone에서 가져옴
zoneId: zoneId || undefined,
conditionValue: conditionValue || undefined,
displayRegion: zone ? { x: zone.x, y: zone.y, width: zone.width, height: zone.height } : condConfig.displayRegion || undefined,
components: layerComponents,
};
layerDefinitions.push(layerDef);
} catch (layerError) {
console.warn(`레이어 ${layerInfo.layer_id} 로드 실패:`, layerError);
}
}
console.log("🔄 조건부 레이어 로드 완료:", layerDefinitions.length, "개", layerDefinitions.map(l => ({
id: l.id, name: l.name, zoneId: l.zoneId, conditionValue: l.conditionValue,
componentCount: l.components.length,
condition: l.condition ? {
targetComponentId: l.condition.targetComponentId,
operator: l.condition.operator,
value: l.condition.value,
} : "없음",
})));
console.log("🗺️ Zone 정보:", loadedZones.map(z => ({
zone_id: z.zone_id,
trigger_component_id: z.trigger_component_id,
trigger_operator: z.trigger_operator,
})));
setConditionalLayers(layerDefinitions);
} catch (error) {
console.error("레이어/Zone 로드 실패:", error);
}
};
loadConditionalLayersAndZones();
}, [screenId, layout]);
// 🆕 조건부 레이어 조건 평가 (formData 변경 시 동기적으로 즉시 계산)
const activeLayerIds = useMemo(() => {
if (conditionalLayers.length === 0 || !layout) return [] as string[];
const allComponents = layout.components || [];
const newActiveIds: string[] = [];
conditionalLayers.forEach((layer) => {
if (layer.condition) {
const { targetComponentId, operator, value } = layer.condition;
// 빈 targetComponentId는 무시
if (!targetComponentId) return;
// 트리거 컴포넌트 찾기 (기본 레이어에서)
const targetComponent = allComponents.find((c) => c.id === targetComponentId);
// columnName으로 formData에서 값 조회
const fieldKey =
(targetComponent as any)?.columnName ||
(targetComponent as any)?.componentConfig?.columnName ||
targetComponentId;
const targetValue = formData[fieldKey];
let isMatch = false;
switch (operator) {
case "eq":
// 문자열로 변환하여 비교 (타입 불일치 방지)
isMatch = String(targetValue ?? "") === String(value ?? "");
break;
case "neq":
isMatch = String(targetValue ?? "") !== String(value ?? "");
break;
case "in":
if (Array.isArray(value)) {
isMatch = value.some(v => String(v) === String(targetValue ?? ""));
} else if (typeof value === "string" && value.includes(",")) {
// 쉼표로 구분된 문자열도 지원
isMatch = value.split(",").map(v => v.trim()).includes(String(targetValue ?? ""));
}
break;
}
// 디버그 로깅 (값이 존재할 때만)
if (targetValue !== undefined && targetValue !== "") {
console.log("🔍 [레이어 조건 평가]", {
layerId: layer.id,
layerName: layer.name,
targetComponentId,
fieldKey,
targetValue: String(targetValue),
conditionValue: String(value),
operator,
isMatch,
});
}
if (isMatch) {
newActiveIds.push(layer.id);
}
}
});
// 강제 활성화된 레이어 ID 병합
for (const forcedId of forceActivatedLayerIds) {
if (!newActiveIds.includes(forcedId)) {
newActiveIds.push(forcedId);
}
}
return newActiveIds;
}, [formData, conditionalLayers, layout, forceActivatedLayerIds]);
// 데이터 전달에 의한 레이어 강제 활성화 이벤트 리스너
useEffect(() => {
const handleActivateLayer = (e: Event) => {
const { componentId, targetLayerId } = (e as CustomEvent).detail || {};
if (!componentId && !targetLayerId) return;
// targetLayerId가 직접 지정된 경우
if (targetLayerId) {
setForceActivatedLayerIds((prev) =>
prev.includes(targetLayerId) ? prev : [...prev, targetLayerId],
);
console.log(`🔓 [레이어 강제 활성화] layerId: ${targetLayerId}`);
return;
}
// componentId로 해당 컴포넌트가 속한 레이어를 찾아 활성화
for (const layer of conditionalLayers) {
const found = layer.components.some((comp) => comp.id === componentId);
if (found) {
setForceActivatedLayerIds((prev) =>
prev.includes(layer.id) ? prev : [...prev, layer.id],
);
console.log(`🔓 [레이어 강제 활성화] componentId: ${componentId} → layerId: ${layer.id}`);
return;
}
}
};
window.addEventListener("activateLayerForComponent", handleActivateLayer);
return () => {
window.removeEventListener("activateLayerForComponent", handleActivateLayer);
};
}, [conditionalLayers]);
// 메인 테이블 데이터 자동 로드 (단일 레코드 폼)
useEffect(() => {
const loadMainTableData = async () => {
if (!screen || !layout || !layout.components || !companyCode) {
@ -513,6 +750,7 @@ function ScreenViewPage() {
{layoutReady && layout && layout.components.length > 0 ? (
<ScreenMultiLangProvider components={layout.components} companyCode={companyCode}>
<div
data-screen-runtime="true"
className="bg-background relative"
style={{
width: `${screenWidth}px`,
@ -630,7 +868,25 @@ function ScreenViewPage() {
}
}
if (totalHeightAdjustment > 0) {
// 🆕 Zone 기반 높이 조정
// Zone 단위로 활성 여부를 판단하여 Y 오프셋 계산
// Zone은 겹치지 않으므로 merge 로직이 불필요 (단순 boolean 판단)
for (const zone of zones) {
const zoneBottom = zone.y + zone.height;
// 컴포넌트가 Zone 하단보다 아래에 있는 경우
if (component.position.y >= zoneBottom) {
// Zone에 매칭되는 활성 레이어가 있는지 확인
const hasActiveLayer = conditionalLayers.some(
l => l.zoneId === zone.zone_id && activeLayerIds.includes(l.id)
);
if (!hasActiveLayer) {
// Zone에 활성 레이어 없음: Zone 높이만큼 위로 당김 (빈 공간 제거)
totalHeightAdjustment -= zone.height;
}
}
}
if (totalHeightAdjustment !== 0) {
return {
...component,
position: {
@ -950,6 +1206,81 @@ function ScreenViewPage() {
</div>
);
})}
{/* 🆕 조건부 레이어 컴포넌트 렌더링 (Zone 기반) */}
{conditionalLayers.map((layer) => {
const isActive = activeLayerIds.includes(layer.id);
if (!isActive || !layer.components || layer.components.length === 0) return null;
// Zone 기반: zoneId로 Zone 찾아서 위치/크기 결정
const zone = layer.zoneId ? zones.find(z => z.zone_id === layer.zoneId) : null;
const region = zone
? { x: zone.x, y: zone.y, width: zone.width, height: zone.height }
: layer.displayRegion;
return (
<div
key={`conditional-layer-${layer.id}`}
data-conditional-layer="true"
style={{
position: "absolute",
left: region ? `${region.x}px` : "0px",
top: region ? `${region.y}px` : "0px",
width: region ? `${region.width}px` : "100%",
height: region ? `${region.height}px` : "auto",
zIndex: layer.zIndex || 20,
overflow: "hidden",
transition: "none",
}}
>
{layer.components
.filter((comp) => !comp.parentId)
.map((comp) => (
<RealtimePreview
key={comp.id}
component={comp}
isSelected={false}
isDesignMode={false}
onClick={() => {}}
menuObjid={menuObjid}
screenId={screenId}
tableName={screen?.tableName}
userId={user?.userId}
userName={userName}
companyCode={companyCode}
selectedRowsData={selectedRowsData}
sortBy={tableSortBy}
sortOrder={tableSortOrder}
columnOrder={tableColumnOrder}
tableDisplayData={tableDisplayData}
onSelectedRowsChange={(
_,
selectedData,
sortBy,
sortOrder,
columnOrder,
tableDisplayData,
) => {
setSelectedRowsData(selectedData);
setTableSortBy(sortBy);
setTableSortOrder(sortOrder || "asc");
setTableColumnOrder(columnOrder);
setTableDisplayData(tableDisplayData || []);
}}
refreshKey={tableRefreshKey}
onRefresh={() => {
setTableRefreshKey((prev) => prev + 1);
setSelectedRowsData([]);
}}
formData={formData}
onFormDataChange={(fieldName, value) => {
setFormData((prev) => ({ ...prev, [fieldName]: value }));
}}
/>
))}
</div>
);
})}
</>
);
})()}

View File

@ -263,12 +263,20 @@ input,
textarea,
select {
transition-property:
color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter,
color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, filter,
backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
/* 런타임 화면에서 컴포넌트 위치 변경 시 모든 애니메이션/트랜지션 완전 제거 */
[data-screen-runtime] [id^="component-"] {
transition: none !important;
}
[data-screen-runtime] [data-conditional-layer] {
transition: none !important;
}
/* Disable animations for users who prefer reduced motion */
@media (prefers-reduced-motion: reduce) {
*,
@ -281,6 +289,20 @@ select {
}
}
/* ===== Sonner 토스트 애니메이션 완전 제거 ===== */
[data-sonner-toaster] [data-sonner-toast] {
animation: none !important;
transition: none !important;
opacity: 1 !important;
transform: none !important;
}
[data-sonner-toaster] [data-sonner-toast][data-mounted="true"] {
animation: none !important;
}
[data-sonner-toaster] [data-sonner-toast][data-removed="true"] {
animation: none !important;
}
/* ===== Print Styles ===== */
@media print {
* {

View File

@ -145,13 +145,12 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
const isFormValid = useMemo(() => {
// 수정 모드에서는 비밀번호 선택 사항 (변경할 경우만 입력)
const requiredFields = isEditMode
? [formData.userId.trim(), formData.userName.trim(), formData.companyCode, formData.deptCode]
? [formData.userId.trim(), formData.userName.trim(), formData.companyCode]
: [
formData.userId.trim(),
formData.userPassword.trim(),
formData.userName.trim(),
formData.companyCode,
formData.deptCode,
];
// 모든 필수 필드가 입력되었는지 확인
@ -327,11 +326,6 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
return false;
}
if (!formData.deptCode) {
showAlert("입력 오류", "부서를 선택해주세요.", "error");
return false;
}
// 이메일 형식 검사 (입력된 경우만)
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
showAlert("입력 오류", "올바른 이메일 형식을 입력해주세요.", "error");
@ -581,7 +575,7 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="deptCode" className="text-sm font-medium">
<span className="text-red-500">*</span>
</Label>
<Select value={formData.deptCode} onValueChange={(value) => handleInputChange("deptCode", value)}>
<SelectTrigger>

View File

@ -1,8 +1,10 @@
"use client";
import { useEffect, ReactNode, useState } from "react";
import { useEffect, ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { AuthLogger } from "@/lib/authLogger";
import { Loader2 } from "lucide-react";
interface AuthGuardProps {
children: ReactNode;
@ -15,6 +17,8 @@ interface AuthGuardProps {
/**
*
*
* - /401 client.ts
* -
*/
export function AuthGuard({
children,
@ -23,145 +27,69 @@ export function AuthGuard({
redirectTo = "/login",
fallback,
}: AuthGuardProps) {
const { isLoggedIn, isAdmin, loading, error } = useAuth();
const { isLoggedIn, isAdmin, loading } = useAuth();
const router = useRouter();
const [redirectCountdown, setRedirectCountdown] = useState<number | null>(null);
const [authDebugInfo, setAuthDebugInfo] = useState<any>({});
useEffect(() => {
console.log("=== AuthGuard 디버깅 ===");
console.log("requireAuth:", requireAuth);
console.log("requireAdmin:", requireAdmin);
console.log("loading:", loading);
console.log("isLoggedIn:", isLoggedIn);
console.log("isAdmin:", isAdmin);
console.log("error:", error);
if (loading) return;
// 토큰 확인을 더 정확하게
const token = localStorage.getItem("authToken");
console.log("AuthGuard localStorage 토큰:", token ? "존재" : "없음");
console.log("현재 경로:", window.location.pathname);
// 디버깅 정보 수집
setAuthDebugInfo({
requireAuth,
requireAdmin,
loading,
isLoggedIn,
isAdmin,
error,
hasToken: !!token,
currentPath: window.location.pathname,
timestamp: new Date().toISOString(),
tokenLength: token ? token.length : 0,
});
if (loading) {
console.log("AuthGuard: 로딩 중 - 대기");
return;
// 토큰이 있는데 아직 인증 확인 중이면 대기
if (typeof window !== "undefined") {
const token = localStorage.getItem("authToken");
if (token && !isLoggedIn && !loading) {
return;
}
}
// 토큰이 있는데도 인증이 안 된 경우, 잠시 대기
if (token && !isLoggedIn && !loading) {
console.log("AuthGuard: 토큰은 있지만 인증이 안됨 - 잠시 대기");
return;
}
// 인증이 필요한데 로그인되지 않은 경우
if (requireAuth && !isLoggedIn) {
console.log("AuthGuard: 인증 필요하지만 로그인되지 않음 - 5초 후 리다이렉트");
console.log("리다이렉트 대상:", redirectTo);
setRedirectCountdown(5);
const countdownInterval = setInterval(() => {
setRedirectCountdown((prev) => {
if (prev === null || prev <= 1) {
clearInterval(countdownInterval);
router.push(redirectTo);
return null;
}
return prev - 1;
});
}, 1000);
AuthLogger.log("AUTH_GUARD_BLOCK", `인증 필요하지만 비로그인 상태 → ${redirectTo} 리다이렉트`);
router.push(redirectTo);
return;
}
// 관리자 권한이 필요한데 관리자가 아닌 경우
if (requireAdmin && !isAdmin) {
console.log("AuthGuard: 관리자 권한 필요하지만 관리자가 아님 - 5초 후 리다이렉트");
console.log("리다이렉트 대상:", redirectTo);
setRedirectCountdown(5);
const countdownInterval = setInterval(() => {
setRedirectCountdown((prev) => {
if (prev === null || prev <= 1) {
clearInterval(countdownInterval);
router.push(redirectTo);
return null;
}
return prev - 1;
});
}, 1000);
AuthLogger.log("AUTH_GUARD_BLOCK", `관리자 권한 필요하지만 일반 사용자 → ${redirectTo} 리다이렉트`);
router.push(redirectTo);
return;
}
}, [requireAuth, requireAdmin, loading, isLoggedIn, isAdmin, redirectTo, router]);
console.log("AuthGuard: 모든 인증 조건 통과 - 컴포넌트 렌더링");
}, [requireAuth, requireAdmin, loading, isLoggedIn, isAdmin, error, redirectTo, router]);
// 로딩 중일 때 fallback 또는 기본 로딩 표시
if (loading) {
console.log("AuthGuard: 로딩 중 - fallback 표시");
return (
<div>
<div className="mb-4 rounded bg-primary/20 p-4">
<h3 className="font-bold">AuthGuard ...</h3>
<pre className="text-xs">{JSON.stringify(authDebugInfo, null, 2)}</pre>
fallback || (
<div className="flex h-screen items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground"> ...</p>
</div>
</div>
{fallback || <div> ...</div>}
</div>
)
);
}
// 인증 실패 시 fallback 또는 기본 메시지 표시
if (requireAuth && !isLoggedIn) {
console.log("AuthGuard: 인증 실패 - fallback 표시");
return (
<div>
<div className="mb-4 rounded bg-destructive/20 p-4">
<h3 className="font-bold"> </h3>
{redirectCountdown !== null && (
<div className="mb-2 text-destructive">
<strong> :</strong> {redirectCountdown} {redirectTo}
</div>
)}
<pre className="text-xs">{JSON.stringify(authDebugInfo, null, 2)}</pre>
fallback || (
<div className="flex h-screen items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground"> ...</p>
</div>
</div>
{fallback || <div> .</div>}
</div>
)
);
}
if (requireAdmin && !isAdmin) {
console.log("AuthGuard: 관리자 권한 없음 - fallback 표시");
return (
<div>
<div className="mb-4 rounded bg-orange-100 p-4">
<h3 className="font-bold"> </h3>
{redirectCountdown !== null && (
<div className="mb-2 text-destructive">
<strong> :</strong> {redirectCountdown} {redirectTo}
</div>
)}
<pre className="text-xs">{JSON.stringify(authDebugInfo, null, 2)}</pre>
fallback || (
<div className="flex h-screen items-center justify-center">
<p className="text-sm text-muted-foreground"> .</p>
</div>
{fallback || <div> .</div>}
</div>
)
);
}
console.log("AuthGuard: 인증 성공 - 자식 컴포넌트 렌더링");
return <>{children}</>;
}

View File

@ -84,12 +84,9 @@ export interface ExcelUploadModalProps {
masterColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
detailColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
};
// 🆕 마스터-디테일 엑셀 업로드 설정
// 마스터-디테일 엑셀 업로드 설정
masterDetailExcelConfig?: MasterDetailExcelConfig;
// 🆕 단일 테이블 채번 설정
numberingRuleId?: string;
numberingTargetColumn?: string;
// 🆕 업로드 후 제어 실행 설정
// 업로드 후 제어 실행 설정
afterUploadFlows?: Array<{ flowId: string; order: number }>;
}
@ -112,9 +109,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
isMasterDetail = false,
masterDetailRelation,
masterDetailExcelConfig,
// 단일 테이블 채번 설정
numberingRuleId,
numberingTargetColumn,
// 업로드 후 제어 실행 설정
afterUploadFlows,
}) => {
@ -459,6 +453,48 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
}
}
// 채번 정보 병합: table_type_columns에서 inputType 가져오기
try {
const { getTableColumns } = await import("@/lib/api/tableManagement");
const targetTables = isMasterDetail && masterDetailRelation
? [masterDetailRelation.masterTable, masterDetailRelation.detailTable]
: [tableName];
// 테이블별 채번 컬럼 수집
const numberingColSet = new Set<string>();
for (const tbl of targetTables) {
const typeResponse = await getTableColumns(tbl);
if (typeResponse.success && typeResponse.data?.columns) {
for (const tc of typeResponse.data.columns) {
if (tc.inputType === "numbering") {
try {
const settings = typeof tc.detailSettings === "string"
? JSON.parse(tc.detailSettings) : tc.detailSettings;
if (settings?.numberingRuleId) {
numberingColSet.add(tc.columnName);
}
} catch { /* 파싱 실패 무시 */ }
}
}
}
}
// systemColumns에 isNumbering 플래그 추가
if (numberingColSet.size > 0) {
allColumns = allColumns.map((col) => {
const rawName = (col as any).originalName || col.name;
const colName = rawName.includes(".") ? rawName.split(".")[1] : rawName;
if (numberingColSet.has(colName)) {
return { ...col, isNumbering: true } as any;
}
return col;
});
console.log("✅ 채번 컬럼 감지:", Array.from(numberingColSet));
}
} catch (error) {
console.warn("채번 정보 로드 실패 (무시):", error);
}
console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", allColumns);
setSystemColumns(allColumns);
@ -619,6 +655,34 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
}
}
// 2단계 → 3단계 전환 시: NOT NULL 컬럼 매핑 필수 검증
if (currentStep === 2) {
// 매핑된 시스템 컬럼 (원본 이름 그대로 + dot 뒤 이름 둘 다 저장)
const mappedSystemCols = new Set<string>();
columnMappings.filter((m) => m.systemColumn).forEach((m) => {
const colName = m.systemColumn!;
mappedSystemCols.add(colName); // 원본 (예: user_info.user_id)
if (colName.includes(".")) {
mappedSystemCols.add(colName.split(".")[1]); // dot 뒤 (예: user_id)
}
});
const unmappedRequired = systemColumns.filter((col) => {
const rawName = col.name.includes(".") ? col.name.split(".")[1] : col.name;
if (AUTO_GENERATED_COLUMNS.includes(rawName.toLowerCase())) return false;
if (col.nullable) return false;
if (mappedSystemCols.has(col.name) || mappedSystemCols.has(rawName)) return false;
if ((col as any).isNumbering) return false;
return true;
});
if (unmappedRequired.length > 0) {
const colNames = unmappedRequired.map((c) => c.label || c.name).join(", ");
toast.error(`필수(NOT NULL) 컬럼이 매핑되지 않았습니다: ${colNames}`);
return;
}
}
setCurrentStep((prev) => Math.min(prev + 1, 3));
};
@ -627,6 +691,44 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
setCurrentStep((prev) => Math.max(prev - 1, 1));
};
// 테이블 타입 관리에서 채번 컬럼 자동 감지
const detectNumberingColumn = async (
targetTableName: string
): Promise<{ columnName: string; numberingRuleId: string } | null> => {
try {
const { getTableColumns } = await import("@/lib/api/tableManagement");
const response = await getTableColumns(targetTableName);
if (response.success && response.data?.columns) {
for (const col of response.data.columns) {
if (col.inputType === "numbering") {
try {
const settings =
typeof col.detailSettings === "string"
? JSON.parse(col.detailSettings)
: col.detailSettings;
if (settings?.numberingRuleId) {
console.log(
`✅ 채번 컬럼 자동 감지: ${col.columnName} → 규칙 ID: ${settings.numberingRuleId}`
);
return {
columnName: col.columnName,
numberingRuleId: settings.numberingRuleId,
};
}
} catch {
// detailSettings 파싱 실패 시 무시
}
}
}
}
return null;
} catch (error) {
console.error("채번 컬럼 감지 실패:", error);
return null;
}
};
// 업로드 핸들러
const handleUpload = async () => {
if (!file || !tableName) {
@ -667,19 +769,24 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
`📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}`
);
// 🆕 마스터-디테일 간단 모드 처리 (마스터 필드 선택 + 채번)
// 마스터-디테일 간단 모드 처리 (마스터 필드 선택 + 채번 자동 감지)
if (isSimpleMasterDetailMode && screenId && masterDetailRelation) {
// 마스터 테이블에서 채번 컬럼 자동 감지
const masterNumberingInfo = await detectNumberingColumn(masterDetailRelation.masterTable);
const detectedNumberingRuleId = masterNumberingInfo?.numberingRuleId || masterDetailExcelConfig?.numberingRuleId;
console.log("📊 마스터-디테일 간단 모드 업로드:", {
masterDetailRelation,
masterFieldValues,
numberingRuleId: masterDetailExcelConfig?.numberingRuleId,
detectedNumberingRuleId,
autoDetected: !!masterNumberingInfo,
});
const uploadResult = await DynamicFormApi.uploadMasterDetailSimple(
screenId,
filteredData,
masterFieldValues,
masterDetailExcelConfig?.numberingRuleId || undefined,
detectedNumberingRuleId || undefined,
masterDetailExcelConfig?.afterUploadFlowId || undefined, // 하위 호환성
masterDetailExcelConfig?.afterUploadFlows || undefined // 다중 제어
);
@ -704,6 +811,24 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
else if (isMasterDetail && screenId && masterDetailRelation) {
console.log("📊 마스터-디테일 업로드 모드:", masterDetailRelation);
// 마스터 키 컬럼 매핑 검증 (채번 타입이면 자동 생성되므로 검증 생략)
const masterKeyCol = masterDetailRelation.masterKeyColumn;
const hasMasterKey = filteredData.length > 0 && filteredData[0][masterKeyCol] !== undefined && filteredData[0][masterKeyCol] !== null && filteredData[0][masterKeyCol] !== "";
if (!hasMasterKey) {
// 채번 여부 확인 - 채번이면 백엔드에서 자동 생성하므로 통과
const numberingInfo = await detectNumberingColumn(masterDetailRelation.masterTable);
const isMasterKeyAutoNumbering = numberingInfo && numberingInfo.columnName === masterKeyCol;
if (!isMasterKeyAutoNumbering) {
toast.error(
`마스터 키 컬럼(${masterKeyCol})이 매핑되지 않았습니다. 컬럼 매핑에서 [마스터] 항목을 확인해주세요.`
);
setIsUploading(false);
return;
}
console.log(`✅ 마스터 키(${masterKeyCol})는 채번 타입 → 백엔드에서 자동 생성`);
}
const uploadResult = await DynamicFormApi.uploadMasterDetailData(
screenId,
filteredData
@ -731,8 +856,9 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
let skipCount = 0;
let overwriteCount = 0;
// 단일 테이블 채번 설정 확인
const hasNumbering = numberingRuleId && numberingTargetColumn;
// 단일 테이블 채번 자동 감지 (테이블 타입 관리에서 input_type = 'numbering' 컬럼)
const numberingInfo = await detectNumberingColumn(tableName);
const hasNumbering = !!numberingInfo;
// 중복 체크 설정 확인
const duplicateCheckMappings = columnMappings.filter(
@ -777,7 +903,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
}
}
for (const row of filteredData) {
for (let rowIdx = 0; rowIdx < filteredData.length; rowIdx++) {
const row = filteredData[rowIdx];
try {
let dataToSave = { ...row };
let shouldSkip = false;
@ -799,15 +926,16 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
if (existingDataMap.has(key)) {
existingRow = existingDataMap.get(key);
// 중복 발견 - 전역 설정에 따라 처리
if (duplicateAction === "skip") {
shouldSkip = true;
skipCount++;
console.log(`⏭️ 중복으로 건너뛰기: ${key}`);
console.log(`⏭️ [행 ${rowIdx + 1}] 중복으로 건너뛰기: ${key}`);
} else {
shouldUpdate = true;
console.log(`🔄 중복으로 덮어쓰기: ${key}`);
console.log(`🔄 [행 ${rowIdx + 1}] 중복으로 덮어쓰기: ${key}`);
}
} else {
console.log(`✅ [행 ${rowIdx + 1}] 중복 아님 (신규 데이터): ${key}`);
}
}
@ -816,17 +944,22 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
continue;
}
// 채번 적용: 각 행마다 채번 API 호출 (신규 등록 시에만)
if (hasNumbering && uploadMode === "insert" && !shouldUpdate) {
try {
const { apiClient } = await import("@/lib/api/client");
const numberingResponse = await apiClient.post(`/numbering-rules/${numberingRuleId}/allocate`);
const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code;
if (numberingResponse.data?.success && generatedCode) {
dataToSave[numberingTargetColumn] = generatedCode;
// 채번 적용: 엑셀에 값이 없거나 빈 값이면 채번 규칙으로 자동 생성, 값이 있으면 그대로 사용
if (hasNumbering && numberingInfo && (uploadMode === "insert" || uploadMode === "upsert") && !shouldUpdate) {
const existingValue = dataToSave[numberingInfo.columnName];
const hasExcelValue = existingValue !== undefined && existingValue !== null && String(existingValue).trim() !== "";
if (!hasExcelValue) {
try {
const { apiClient } = await import("@/lib/api/client");
const numberingResponse = await apiClient.post(`/numbering-rules/${numberingInfo.numberingRuleId}/allocate`);
const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code;
if (numberingResponse.data?.success && generatedCode) {
dataToSave[numberingInfo.columnName] = generatedCode;
}
} catch (numError) {
console.error("채번 오류:", numError);
}
} catch (numError) {
console.error("채번 오류:", numError);
}
}
@ -837,24 +970,34 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
tableName,
data: dataToSave,
};
console.log(`📝 [행 ${rowIdx + 1}] 덮어쓰기 시도: id=${existingRow.id}`, dataToSave);
const result = await DynamicFormApi.updateFormData(existingRow.id, formData);
if (result.success) {
overwriteCount++;
successCount++;
} else {
console.error(`❌ [행 ${rowIdx + 1}] 덮어쓰기 실패:`, result.message);
failCount++;
}
} else if (uploadMode === "insert") {
// 신규 등록
} else if (uploadMode === "insert" || uploadMode === "upsert") {
// 신규 등록 (insert, upsert 모드)
const formData = { screenId: 0, tableName, data: dataToSave };
console.log(`📝 [행 ${rowIdx + 1}] 신규 등록 시도 (mode: ${uploadMode}):`, dataToSave);
const result = await DynamicFormApi.saveFormData(formData);
if (result.success) {
successCount++;
console.log(`✅ [행 ${rowIdx + 1}] 신규 등록 성공`);
} else {
console.error(`❌ [행 ${rowIdx + 1}] 신규 등록 실패:`, result.message);
failCount++;
}
} else if (uploadMode === "update") {
// update 모드에서 기존 데이터가 없는 행은 건너뛰기
console.log(`⏭️ [행 ${rowIdx + 1}] update 모드: 기존 데이터 없음, 건너뛰기`);
skipCount++;
}
} catch (error) {
} catch (error: any) {
console.error(`❌ [행 ${rowIdx + 1}] 업로드 처리 오류:`, error?.response?.data || error?.message || error);
failCount++;
}
}
@ -877,8 +1020,9 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
}
}
console.log(`📊 엑셀 업로드 결과 요약: 성공=${successCount}, 건너뛰기=${skipCount}, 덮어쓰기=${overwriteCount}, 실패=${failCount}`);
if (successCount > 0 || skipCount > 0) {
// 상세 결과 메시지 생성
let message = "";
if (successCount > 0) {
message += `${successCount}개 행 업로드`;
@ -891,15 +1035,23 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
message += `중복 건너뛰기 ${skipCount}`;
}
if (failCount > 0) {
message += ` (실패: ${failCount})`;
message += `, 실패 ${failCount}`;
}
toast.success(message);
if (failCount > 0 && successCount === 0) {
toast.warning(message);
} else {
toast.success(message);
}
// 매핑 템플릿 저장
await saveMappingTemplateInternal();
onSuccess?.();
if (successCount > 0 || overwriteCount > 0) {
onSuccess?.();
}
} else if (failCount > 0) {
toast.error(`업로드 실패: ${failCount}개 행 저장에 실패했습니다. 브라우저 콘솔에서 상세 오류를 확인하세요.`);
} else {
toast.error("업로드에 실패했습니다.");
}
@ -1341,15 +1493,19 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
<SelectItem value="none" className="text-xs sm:text-sm">
</SelectItem>
{systemColumns.map((col) => (
{systemColumns.map((col) => {
const isRequired = !col.nullable && !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()) && !(col as any).isNumbering;
return (
<SelectItem
key={col.name}
value={col.name}
className="text-xs sm:text-sm"
>
{isRequired && <span className="text-destructive mr-1">*</span>}
{col.label || col.name} ({col.type})
</SelectItem>
))}
);
})}
</SelectContent>
</Select>
{/* 중복 체크 체크박스 */}
@ -1371,6 +1527,38 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
</div>
</div>
{/* 미매핑 필수(NOT NULL) 컬럼 경고 */}
{(() => {
const mappedCols = new Set<string>();
columnMappings.filter((m) => m.systemColumn).forEach((m) => {
const n = m.systemColumn!;
mappedCols.add(n);
if (n.includes(".")) mappedCols.add(n.split(".")[1]);
});
const missing = systemColumns.filter((col) => {
const rawName = col.name.includes(".") ? col.name.split(".")[1] : col.name;
if (AUTO_GENERATED_COLUMNS.includes(rawName.toLowerCase())) return false;
if (col.nullable) return false;
if (mappedCols.has(col.name) || mappedCols.has(rawName)) return false;
if ((col as any).isNumbering) return false;
return true;
});
if (missing.length === 0) return null;
return (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 text-destructive" />
<div className="text-[10px] text-destructive sm:text-xs">
<p className="font-medium">(NOT NULL) :</p>
<p className="mt-1">
{missing.map((c) => c.label || c.name).join(", ")}
</p>
</div>
</div>
</div>
);
})()}
{/* 중복 체크 안내 */}
{duplicateCheckCount > 0 ? (
<div className="rounded-md border border-blue-200 bg-blue-50 p-3">

View File

@ -1,7 +1,17 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
} from "@/components/ui/alert-dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
@ -14,6 +24,8 @@ import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHei
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
import { ScreenContextProvider } from "@/contexts/ScreenContext";
interface ScreenModalState {
isOpen: boolean;
@ -61,12 +73,21 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 🆕 선택된 데이터 상태 (RepeatScreenModal 등에서 사용)
const [selectedData, setSelectedData] = useState<Record<string, any>[]>([]);
// 🆕 조건부 레이어 상태 (Zone 기반)
const [conditionalLayers, setConditionalLayers] = useState<(LayerDefinition & { components: ComponentData[]; zone?: ConditionalZone })[]>([]);
// 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해)
const [continuousMode, setContinuousMode] = useState(false);
// 화면 리셋 키 (컴포넌트 강제 리마운트용)
const [resetKey, setResetKey] = useState(0);
// 모달 닫기 확인 다이얼로그 표시 상태
const [showCloseConfirm, setShowCloseConfirm] = useState(false);
// 사용자가 폼 데이터를 실제로 변경했는지 추적 (변경 없으면 경고 없이 바로 닫기)
const formDataChangedRef = useRef(false);
// localStorage에서 연속 모드 상태 복원
useEffect(() => {
const savedMode = localStorage.getItem("screenModal_continuousMode");
@ -109,9 +130,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const contentWidth = maxX - minX;
const contentHeight = maxY - minY;
// 적절한 여백 추가
const paddingX = 40;
const paddingY = 40;
// 여백 없이 컨텐츠 크기 그대로 사용
const paddingX = 0;
const paddingY = 0;
const finalWidth = Math.max(contentWidth + paddingX, 400);
const finalHeight = Math.max(contentHeight + paddingY, 300);
@ -119,8 +140,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
return {
width: Math.min(finalWidth, window.innerWidth * 0.95),
height: Math.min(finalHeight, window.innerHeight * 0.9),
offsetX: Math.max(0, minX - paddingX / 2), // 좌측 여백 고려
offsetY: Math.max(0, minY - paddingY / 2), // 상단 여백 고려
offsetX: Math.max(0, minX), // 여백 없이 컨텐츠 시작점 기준
offsetY: Math.max(0, minY), // 여백 없이 컨텐츠 시작점 기준
};
};
@ -158,12 +179,23 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
splitPanelParentData,
selectedData: eventSelectedData,
selectedIds,
isCreateMode, // 🆕 복사 모드 플래그 (true면 editData가 있어도 originalData 설정 안 함)
isCreateMode,
fieldMappings,
} = event.detail;
console.log("🟣 [ScreenModal] openScreenModal 이벤트 수신:", {
screenId,
splitPanelParentData: JSON.stringify(splitPanelParentData),
editData: !!editData,
isCreateMode,
});
// 🆕 모달 열린 시간 기록
modalOpenedAtRef.current = Date.now();
// 폼 변경 추적 초기화
formDataChangedRef.current = false;
// 🆕 선택된 데이터 저장 (RepeatScreenModal 등에서 사용)
if (eventSelectedData && Array.isArray(eventSelectedData)) {
setSelectedData(eventSelectedData);
@ -218,10 +250,33 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const parentDataMapping = splitPanelContext?.parentDataMapping || [];
// 부모 데이터 소스
const rawParentData =
splitPanelParentData && Object.keys(splitPanelParentData).length > 0
? splitPanelParentData
: splitPanelContext?.selectedLeftData || {};
// 🔧 수정: 여러 소스를 병합 (우선순위: splitPanelParentData > selectedLeftData > 기존 formData의 링크 필드)
// 예: screen 150→226→227 전환 시:
// - splitPanelParentData: item_info 데이터 (screen 226에서 전달)
// - selectedLeftData: customer_mng 데이터 (SplitPanel 좌측 선택)
// - 기존 formData: 이전 모달에서 설정된 link 필드 (customer_code 등)
const contextData = splitPanelContext?.selectedLeftData || {};
const eventData = splitPanelParentData && Object.keys(splitPanelParentData).length > 0
? splitPanelParentData
: {};
// 🆕 기존 formData에서 link 필드(_code, _id)를 가져와 base로 사용
// 모달 체인(226→227)에서 이전 모달의 연결 필드가 유지됨
const previousLinkFields: Record<string, any> = {};
if (formData && typeof formData === "object" && !Array.isArray(formData)) {
const linkFieldPatterns = ["_code", "_id"];
const excludeFields = ["id", "created_date", "updated_date", "created_at", "updated_at", "writer"];
for (const [key, value] of Object.entries(formData)) {
if (excludeFields.includes(key)) continue;
if (value === undefined || value === null) continue;
const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern));
if (isLinkField) {
previousLinkFields[key] = value;
}
}
}
const rawParentData = { ...previousLinkFields, ...contextData, ...eventData };
// 🔧 신규 등록 모드에서는 연결에 필요한 필드만 전달
const parentData: Record<string, any> = {};
@ -231,6 +286,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
parentData.company_code = rawParentData.company_code;
}
// 🆕 명시적 필드 매핑이 있으면 매핑된 타겟 필드를 모두 보존
// (버튼 설정에서 fieldMappings로 지정한 필드는 link 필드가 아니어도 전달)
const mappedTargetFields = new Set<string>();
if (fieldMappings && Array.isArray(fieldMappings)) {
for (const mapping of fieldMappings) {
if (mapping.targetField) {
mappedTargetFields.add(mapping.targetField);
}
}
}
// parentDataMapping에 정의된 필드만 전달
for (const mapping of parentDataMapping) {
const sourceValue = rawParentData[mapping.sourceColumn];
@ -239,8 +305,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
}
}
// parentDataMapping이 비어있으면 연결 필드 자동 감지 (equipment_code, xxx_code, xxx_id 패턴)
if (parentDataMapping.length === 0) {
// 🆕 명시적 필드 매핑이 있으면 해당 필드를 모두 전달
if (mappedTargetFields.size > 0) {
for (const [key, value] of Object.entries(rawParentData)) {
if (mappedTargetFields.has(key) && value !== undefined && value !== null) {
parentData[key] = value;
}
}
}
// parentDataMapping이 비어있고 명시적 필드 매핑도 없으면 연결 필드 자동 감지
if (parentDataMapping.length === 0 && mappedTargetFields.size === 0) {
const linkFieldPatterns = ["_code", "_id"];
const excludeFields = [
"id",
@ -257,6 +332,29 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
if (value === undefined || value === null) continue;
// 연결 필드 패턴 확인
const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern));
if (isLinkField) {
parentData[key] = value;
}
}
} else if (parentDataMapping.length === 0 && mappedTargetFields.size > 0) {
// 🆕 명시적 매핑이 있어도 연결 필드(_code, _id)는 추가로 전달
const linkFieldPatterns = ["_code", "_id"];
const excludeFields = [
"id",
"company_code",
"created_date",
"updated_date",
"created_at",
"updated_at",
"writer",
];
for (const [key, value] of Object.entries(rawParentData)) {
if (excludeFields.includes(key)) continue;
if (parentData[key] !== undefined) continue; // 이미 매핑된 필드는 스킵
if (value === undefined || value === null) continue;
const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern));
if (isLinkField) {
parentData[key] = value;
@ -265,8 +363,10 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
}
if (Object.keys(parentData).length > 0) {
console.log("🔵 [ScreenModal] ADD모드 formData 설정:", JSON.stringify(parentData));
setFormData(parentData);
} else {
console.log("🔵 [ScreenModal] ADD모드 formData 비어있음");
setFormData({});
}
setOriginalData(null); // 신규 등록 모드
@ -317,6 +417,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
if (isContinuousMode) {
// 연속 모드: 폼만 초기화하고 모달은 유지
formDataChangedRef.current = false;
setFormData({});
setResetKey((prev) => prev + 1);
@ -463,6 +564,16 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 화면 관리에서 설정한 해상도 사용 (우선순위)
const screenResolution = (layoutData as any).screenResolution || (screenInfo as any).screenResolution;
console.log("🔍 [ScreenModal] 해상도 디버그:", {
screenId,
v2ScreenResolution: v2LayoutData?.screenResolution,
layoutScreenResolution: (layoutData as any).screenResolution,
screenInfoResolution: (screenInfo as any).screenResolution,
finalScreenResolution: screenResolution,
hasWidth: screenResolution?.width,
hasHeight: screenResolution?.height,
});
let dimensions;
if (screenResolution && screenResolution.width && screenResolution.height) {
// 화면 관리에서 설정한 해상도 사용
@ -472,9 +583,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
offsetX: 0,
offsetY: 0,
};
console.log("✅ [ScreenModal] 화면관리 해상도 적용:", dimensions);
} else {
// 해상도 정보가 없으면 자동 계산
dimensions = calculateScreenDimensions(components);
console.log("⚠️ [ScreenModal] 해상도 없음 - 자동 계산:", dimensions);
}
setScreenDimensions(dimensions);
@ -483,6 +596,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
components,
screenInfo: screenInfo,
});
// 🆕 조건부 레이어/존 로드
loadConditionalLayersAndZones(screenId);
} else {
throw new Error("화면 데이터가 없습니다");
}
@ -495,14 +611,262 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
}
};
const handleClose = () => {
// 🔧 URL 파라미터 제거 (mode, editId, tableName 등)
// 🆕 조건부 레이어 & 존 로드 함수
const loadConditionalLayersAndZones = async (screenId: number) => {
try {
const [layersRes, zonesRes] = await Promise.all([
screenApi.getScreenLayers(screenId),
screenApi.getScreenZones(screenId),
]);
const loadedLayers = layersRes || [];
const loadedZones: ConditionalZone[] = zonesRes || [];
// 기본 레이어(layer_id=1) 제외
const nonBaseLayers = loadedLayers.filter((l: any) => l.layer_id !== 1);
if (nonBaseLayers.length === 0) {
setConditionalLayers([]);
return;
}
const layerDefs: (LayerDefinition & { components: ComponentData[] })[] = [];
for (const layer of nonBaseLayers) {
try {
const layerLayout = await screenApi.getLayerLayout(screenId, layer.layer_id);
let layerComponents: ComponentData[] = [];
if (layerLayout && isValidV2Layout(layerLayout)) {
const legacyLayout = convertV2ToLegacy(layerLayout);
layerComponents = (legacyLayout?.components || []) as unknown as ComponentData[];
} else if (layerLayout?.components) {
layerComponents = layerLayout.components;
}
// condition_config에서 zone_id, condition_value 추출
const cc = layer.condition_config || {};
const zone = loadedZones.find((z) => z.zone_id === cc.zone_id);
layerDefs.push({
id: `layer-${layer.layer_id}`,
name: layer.layer_name || `레이어 ${layer.layer_id}`,
type: "conditional",
zIndex: layer.layer_id,
isVisible: false,
isLocked: false,
zoneId: cc.zone_id,
conditionValue: cc.condition_value,
condition: zone
? {
targetComponentId: zone.trigger_component_id || "",
operator: (zone.trigger_operator || "eq") as any,
value: cc.condition_value || "",
}
: undefined,
components: layerComponents,
zone: zone || undefined, // 🆕 Zone 위치 정보 포함 (오프셋 계산용)
} as any);
} catch (err) {
console.warn(`[ScreenModal] 레이어 ${layer.layer_id} 로드 실패:`, err);
}
}
console.log("[ScreenModal] 조건부 레이어 로드 완료:", layerDefs.length, "개",
layerDefs.map((l) => ({
id: l.id, name: l.name, conditionValue: l.conditionValue,
componentCount: l.components.length,
condition: l.condition,
}))
);
setConditionalLayers(layerDefs);
} catch (error) {
console.error("[ScreenModal] 조건부 레이어 로드 실패:", error);
}
};
// 🆕 조건부 레이어 활성화 평가 (formData 변경 시)
const activeConditionalComponents = useMemo(() => {
if (conditionalLayers.length === 0) return [];
const allComponents = screenData?.components || [];
const activeComps: ComponentData[] = [];
conditionalLayers.forEach((layer) => {
if (!layer.condition) return;
const { targetComponentId, operator, value } = layer.condition;
if (!targetComponentId) return;
// V2 레이아웃: overrides.columnName 우선
const comp = allComponents.find((c: any) => c.id === targetComponentId);
const fieldKey =
(comp as any)?.overrides?.columnName ||
(comp as any)?.columnName ||
(comp as any)?.componentConfig?.columnName ||
targetComponentId;
const targetValue = formData[fieldKey];
let isMatch = false;
switch (operator) {
case "eq":
isMatch = String(targetValue ?? "") === String(value ?? "");
break;
case "neq":
isMatch = String(targetValue ?? "") !== String(value ?? "");
break;
case "in":
if (Array.isArray(value)) {
isMatch = value.some((v) => String(v) === String(targetValue ?? ""));
} else if (typeof value === "string" && value.includes(",")) {
isMatch = value.split(",").map((v) => v.trim()).includes(String(targetValue ?? ""));
}
break;
}
console.log("[ScreenModal] 레이어 조건 평가:", {
layerName: layer.name, fieldKey,
targetValue: String(targetValue ?? "(없음)"),
conditionValue: String(value), operator, isMatch,
});
if (isMatch) {
// Zone 오프셋 적용 (레이어 2 컴포넌트는 Zone 상대 좌표로 저장됨)
const zoneX = layer.zone?.x || 0;
const zoneY = layer.zone?.y || 0;
const offsetComponents = layer.components.map((c: any) => ({
...c,
position: {
...c.position,
x: parseFloat(c.position?.x?.toString() || "0") + zoneX,
y: parseFloat(c.position?.y?.toString() || "0") + zoneY,
},
}));
activeComps.push(...offsetComponents);
}
});
return activeComps;
}, [formData, conditionalLayers, screenData?.components]);
// 🆕 이전 활성 레이어 ID 추적 (레이어 전환 감지용)
const prevActiveLayerIdsRef = useRef<string[]>([]);
// 🆕 레이어 전환 시 비활성화된 레이어의 필드값을 formData에서 제거
// (품목우선 → 공급업체우선 전환 시, 품목우선 레이어의 데이터가 남지 않도록)
useEffect(() => {
if (conditionalLayers.length === 0) return;
// 현재 활성 레이어 ID 목록
const currentActiveLayerIds = conditionalLayers
.filter((layer) => {
if (!layer.condition) return false;
const { targetComponentId, operator, value } = layer.condition;
if (!targetComponentId) return false;
const allComponents = screenData?.components || [];
const comp = allComponents.find((c: any) => c.id === targetComponentId);
const fieldKey =
(comp as any)?.overrides?.columnName ||
(comp as any)?.columnName ||
(comp as any)?.componentConfig?.columnName ||
targetComponentId;
const targetValue = formData[fieldKey];
switch (operator) {
case "eq":
return String(targetValue ?? "") === String(value ?? "");
case "neq":
return String(targetValue ?? "") !== String(value ?? "");
case "in":
if (Array.isArray(value)) {
return value.some((v) => String(v) === String(targetValue ?? ""));
} else if (typeof value === "string" && value.includes(",")) {
return value.split(",").map((v) => v.trim()).includes(String(targetValue ?? ""));
}
return false;
default:
return false;
}
})
.map((l) => l.id);
const prevIds = prevActiveLayerIdsRef.current;
// 이전에 활성이었는데 이번에 비활성이 된 레이어 찾기
const deactivatedLayerIds = prevIds.filter((id) => !currentActiveLayerIds.includes(id));
if (deactivatedLayerIds.length > 0) {
// 비활성화된 레이어의 컴포넌트 필드명 수집
const fieldsToRemove: string[] = [];
deactivatedLayerIds.forEach((layerId) => {
const layer = conditionalLayers.find((l) => l.id === layerId);
if (!layer) return;
layer.components.forEach((comp: any) => {
const fieldName =
comp?.overrides?.columnName ||
comp?.columnName ||
comp?.componentConfig?.columnName;
if (fieldName) {
fieldsToRemove.push(fieldName);
}
});
});
if (fieldsToRemove.length > 0) {
console.log("[ScreenModal] 레이어 전환 감지 - 비활성 레이어 필드 제거:", {
deactivatedLayerIds,
fieldsToRemove,
});
setFormData((prev) => {
const cleaned = { ...prev };
fieldsToRemove.forEach((field) => {
delete cleaned[field];
});
return cleaned;
});
}
}
// 현재 상태 저장
prevActiveLayerIdsRef.current = currentActiveLayerIds;
}, [formData, conditionalLayers, screenData?.components]);
// 사용자가 바깥 클릭/ESC/X 버튼으로 닫으려 할 때
// 폼 데이터 변경이 있으면 확인 다이얼로그, 없으면 바로 닫기
const handleCloseAttempt = useCallback(() => {
if (formDataChangedRef.current) {
setShowCloseConfirm(true);
} else {
handleCloseInternal();
}
}, []);
// 확인 후 실제로 모달을 닫는 함수
const handleConfirmClose = useCallback(() => {
setShowCloseConfirm(false);
handleCloseInternal();
}, []);
// 닫기 취소 (계속 작업)
const handleCancelClose = useCallback(() => {
setShowCloseConfirm(false);
}, []);
const handleCloseInternal = () => {
// 🔧 URL 파라미터 제거 (mode, editId, tableName, groupByColumns, dataSourceId 등)
if (typeof window !== "undefined") {
const currentUrl = new URL(window.location.href);
currentUrl.searchParams.delete("mode");
currentUrl.searchParams.delete("editId");
currentUrl.searchParams.delete("tableName");
currentUrl.searchParams.delete("groupByColumns");
currentUrl.searchParams.delete("dataSourceId");
window.history.pushState({}, "", currentUrl.toString());
}
@ -514,42 +878,43 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
});
setScreenData(null);
setFormData({}); // 폼 데이터 초기화
setOriginalData(null); // 원본 데이터 초기화
setSelectedData([]); // 선택된 데이터 초기화
setConditionalLayers([]); // 🆕 조건부 레이어 초기화
setContinuousMode(false);
localStorage.setItem("screenModal_continuousMode", "false");
};
// 기존 handleClose를 유지 (이벤트 핸들러 등에서 사용)
const handleClose = handleCloseInternal;
// 모달 크기 설정 - 화면관리 설정 크기 + 헤더/푸터
const getModalStyle = () => {
if (!screenDimensions) {
console.log("⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용");
return {
className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0",
style: undefined, // undefined로 변경 - defaultWidth/defaultHeight 사용
needsScroll: false,
className: "w-fit min-w-[400px] max-w-4xl overflow-hidden",
style: { padding: 0, gap: 0, maxHeight: "calc(100dvh - 8px)" },
};
}
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
// 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 + gap + 패딩
// 🔧 DialogContent의 gap-4 (16px × 2) + 컨텐츠 pt-6 (24px) 포함
const headerHeight = 48; // DialogHeader (타이틀 + border-b + py-3)
const footerHeight = 44; // 연속 등록 모드 체크박스 영역
const dialogGap = 32; // gap-4 × 2 (header-content, content-footer 사이)
const contentTopPadding = 24; // pt-6 (컨텐츠 영역 상단 패딩)
const horizontalPadding = 16; // 좌우 패딩 최소화
const totalHeight = screenDimensions.height + headerHeight + footerHeight + dialogGap + contentTopPadding;
const maxAvailableHeight = window.innerHeight * 0.95;
// 콘텐츠가 화면 높이를 초과할 때만 스크롤 필요
const needsScroll = totalHeight > maxAvailableHeight;
const finalWidth = Math.min(screenDimensions.width, window.innerWidth * 0.98);
console.log("✅ [ScreenModal] getModalStyle: 해상도 적용됨", {
screenDimensions,
finalWidth: `${finalWidth}px`,
viewportWidth: window.innerWidth,
});
return {
className: "overflow-hidden p-0",
className: "overflow-hidden",
style: {
width: `${Math.min(screenDimensions.width + horizontalPadding, window.innerWidth * 0.98)}px`,
// 🔧 height 대신 max-height만 설정 - 콘텐츠가 작으면 자동으로 줄어듦
maxHeight: `${maxAvailableHeight}px`,
width: `${finalWidth}px`,
// CSS가 알아서 처리: 뷰포트 안에 들어가면 auto-height, 넘치면 max-height로 제한
maxHeight: "calc(100dvh - 8px)",
maxWidth: "98vw",
padding: 0,
gap: 0,
},
needsScroll,
};
};
@ -615,10 +980,28 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
]);
return (
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
<Dialog
open={modalState.isOpen}
onOpenChange={(open) => {
// X 버튼 클릭 시에도 확인 다이얼로그 표시
if (!open) {
handleCloseAttempt();
}
}}
>
<DialogContent
className={`${modalStyle.className} ${className || ""} max-w-none flex flex-col`}
{...(modalStyle.style && { style: modalStyle.style })}
style={modalStyle.style}
// 바깥 클릭 시 바로 닫히지 않도록 방지
onInteractOutside={(e) => {
e.preventDefault();
handleCloseAttempt();
}}
// ESC 키 누를 때도 바로 닫히지 않도록 방지
onEscapeKeyDown={(e) => {
e.preventDefault();
handleCloseAttempt();
}}
>
<DialogHeader className="shrink-0 border-b px-4 py-3">
<div className="flex items-center gap-2">
@ -633,7 +1016,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
</DialogHeader>
<div
className={`flex-1 min-h-0 flex items-start justify-center pt-6 ${modalStyle.needsScroll ? 'overflow-auto' : 'overflow-visible'}`}
className="flex-1 min-h-0 flex items-start justify-center overflow-auto"
>
{loading ? (
<div className="flex h-full items-center justify-center">
@ -643,14 +1026,33 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
</div>
</div>
) : screenData ? (
<ScreenContextProvider
screenId={modalState.screenId || undefined}
tableName={screenData.screenInfo?.tableName}
>
<ActiveTabProvider>
<TableOptionsProvider>
<div
data-screen-runtime="true"
className="relative bg-white"
style={{
width: `${screenDimensions?.width || 800}px`,
height: `${screenDimensions?.height || 600}px`,
// 🆕 라벨이 위로 튀어나갈 수 있도록 overflow visible 설정
// 🆕 조건부 레이어 활성화 시 높이 자동 확장
minHeight: `${screenDimensions?.height || 600}px`,
height: (() => {
const baseHeight = screenDimensions?.height || 600;
if (activeConditionalComponents.length > 0) {
const offsetY = screenDimensions?.offsetY || 0;
let maxBottom = 0;
activeConditionalComponents.forEach((comp: any) => {
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)}px`;
}
return `${baseHeight}px`;
})(),
overflow: "visible",
}}
>
@ -786,11 +1188,13 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
formData={formData}
originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용)
onFormDataChange={(fieldName, value) => {
formDataChangedRef.current = true;
setFormData((prev) => {
const newFormData = {
...prev,
[fieldName]: value,
};
console.log("🟡 [ScreenModal] onFormDataChange:", fieldName, "→", value, "| formData keys:", Object.keys(newFormData), "| process_code:", newFormData.process_code);
return newFormData;
});
}}
@ -810,9 +1214,52 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
);
});
})()}
{/* 🆕 조건부 레이어 컴포넌트 렌더링 */}
{activeConditionalComponents.map((component: any) => {
const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0;
const adjustedComponent = {
...component,
position: {
...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
},
};
return (
<InteractiveScreenViewerDynamic
key={`conditional-${component.id}-${resetKey}`}
component={adjustedComponent}
allComponents={[...(screenData?.components || []), ...activeConditionalComponents]}
formData={formData}
originalData={originalData}
onFormDataChange={(fieldName, value) => {
formDataChangedRef.current = true;
setFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}}
onRefresh={() => {
window.dispatchEvent(new CustomEvent("refreshTable"));
}}
screenInfo={{
id: modalState.screenId!,
tableName: screenData?.screenInfo?.tableName,
}}
userId={userId}
userName={userName}
companyCode={user?.companyCode}
/>
);
})}
</div>
</TableOptionsProvider>
</ActiveTabProvider>
</ScreenContextProvider>
) : (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground"> .</p>
@ -838,6 +1285,36 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
</div>
</div>
</DialogContent>
{/* 모달 닫기 확인 다이얼로그 */}
<AlertDialog open={showCloseConfirm} onOpenChange={setShowCloseConfirm}>
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
<AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg">
?
</AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm">
.
<br />
&apos; &apos; .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="gap-2 sm:gap-0">
<AlertDialogCancel
onClick={handleCancelClose}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmClose}
className="h-8 flex-1 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
);
};

View File

@ -4,7 +4,7 @@
*
*/
import { useState } from "react";
import { useState, useEffect, useRef } from "react";
import { Save, Undo2, Redo2, ZoomIn, ZoomOut, Download, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -42,6 +42,27 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro
const [showSaveDialog, setShowSaveDialog] = useState(false);
// Ctrl+S 단축키: 플로우 저장
const handleSaveRef = useRef<() => void>();
useEffect(() => {
handleSaveRef.current = handleSave;
});
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
if (!isSaving) {
handleSaveRef.current?.();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isSaving]);
const handleSave = async () => {
// 검증 수행
const currentValidations = validations.length > 0 ? validations : validateFlow(nodes, edges);

View File

@ -251,6 +251,14 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
const [logic, setLogic] = useState<"AND" | "OR">(data.logic || "AND");
const [availableFields, setAvailableFields] = useState<FieldDefinition[]>([]);
// 타겟 조회 설정 (DB 기존값 비교용)
const [targetLookup, setTargetLookup] = useState<{
tableName: string;
tableLabel?: string;
lookupKeys: Array<{ sourceField: string; targetField: string; sourceFieldLabel?: string }>;
} | undefined>(data.targetLookup);
const [targetLookupColumns, setTargetLookupColumns] = useState<ColumnInfo[]>([]);
// EXISTS 연산자용 상태
const [allTables, setAllTables] = useState<TableInfo[]>([]);
const [tableColumnsCache, setTableColumnsCache] = useState<Record<string, ColumnInfo[]>>({});
@ -262,8 +270,20 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
setDisplayName(data.displayName || "조건 분기");
setConditions(data.conditions || []);
setLogic(data.logic || "AND");
setTargetLookup(data.targetLookup);
}, [data]);
// targetLookup 테이블 변경 시 컬럼 목록 로드
useEffect(() => {
if (targetLookup?.tableName) {
loadTableColumns(targetLookup.tableName).then((cols) => {
setTargetLookupColumns(cols);
});
} else {
setTargetLookupColumns([]);
}
}, [targetLookup?.tableName]);
// 전체 테이블 목록 로드 (EXISTS 연산자용)
useEffect(() => {
const loadAllTables = async () => {
@ -559,6 +579,47 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
});
};
// 타겟 조회 테이블 변경
const handleTargetLookupTableChange = async (tableName: string) => {
await ensureTablesLoaded();
const tableInfo = allTables.find((t) => t.tableName === tableName);
const newLookup = {
tableName,
tableLabel: tableInfo?.tableLabel || tableName,
lookupKeys: targetLookup?.lookupKeys || [],
};
setTargetLookup(newLookup);
updateNode(nodeId, { targetLookup: newLookup });
// 컬럼 로드
const cols = await loadTableColumns(tableName);
setTargetLookupColumns(cols);
};
// 타겟 조회 키 필드 변경
const handleTargetLookupKeyChange = (sourceField: string, targetField: string) => {
if (!targetLookup) return;
const sourceFieldInfo = availableFields.find((f) => f.name === sourceField);
const newLookup = {
...targetLookup,
lookupKeys: [{ sourceField, targetField, sourceFieldLabel: sourceFieldInfo?.label || sourceField }],
};
setTargetLookup(newLookup);
updateNode(nodeId, { targetLookup: newLookup });
};
// 타겟 조회 제거
const handleRemoveTargetLookup = () => {
setTargetLookup(undefined);
updateNode(nodeId, { targetLookup: undefined });
// target 타입 조건들을 field로 변경
const newConditions = conditions.map((c) =>
(c as any).valueType === "target" ? { ...c, valueType: "field" } : c
);
setConditions(newConditions);
updateNode(nodeId, { conditions: newConditions });
};
return (
<div>
<div className="space-y-4 p-4 pb-8">
@ -597,6 +658,119 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
</div>
</div>
{/* 타겟 조회 (DB 기존값 비교) */}
<div>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold">
<Database className="mr-1 inline h-3.5 w-3.5" />
(DB )
</h3>
</div>
{!targetLookup ? (
<div className="space-y-2">
<div className="rounded border border-dashed p-3 text-center text-xs text-gray-400">
DB의 .
</div>
<Button
size="sm"
variant="outline"
className="h-7 w-full text-xs"
onClick={async () => {
await ensureTablesLoaded();
setTargetLookup({ tableName: "", lookupKeys: [] });
}}
>
<Database className="mr-1 h-3 w-3" />
</Button>
</div>
) : (
<div className="space-y-2 rounded border bg-orange-50 p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-orange-700"> </span>
<Button
size="sm"
variant="ghost"
onClick={handleRemoveTargetLookup}
className="h-5 px-1 text-xs text-orange-500 hover:text-orange-700"
>
</Button>
</div>
{/* 테이블 선택 */}
{allTables.length > 0 ? (
<TableCombobox
tables={allTables}
value={targetLookup.tableName}
onSelect={handleTargetLookupTableChange}
placeholder="비교할 테이블 검색..."
/>
) : (
<div className="rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
...
</div>
)}
{/* 키 필드 매핑 */}
{targetLookup.tableName && (
<div className="space-y-1.5">
<Label className="text-xs text-orange-600"> ( )</Label>
<div className="flex items-center gap-1.5">
<Select
value={targetLookup.lookupKeys?.[0]?.sourceField || ""}
onValueChange={(val) => {
const targetField = targetLookup.lookupKeys?.[0]?.targetField || "";
handleTargetLookupKeyChange(val, targetField);
}}
>
<SelectTrigger className="h-7 flex-1 text-xs">
<SelectValue placeholder="소스 필드" />
</SelectTrigger>
<SelectContent>
{availableFields.map((f) => (
<SelectItem key={f.name} value={f.name} className="text-xs">
{f.label || f.name}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-xs text-gray-400">=</span>
{targetLookupColumns.length > 0 ? (
<Select
value={targetLookup.lookupKeys?.[0]?.targetField || ""}
onValueChange={(val) => {
const sourceField = targetLookup.lookupKeys?.[0]?.sourceField || "";
handleTargetLookupKeyChange(sourceField, val);
}}
>
<SelectTrigger className="h-7 flex-1 text-xs">
<SelectValue placeholder="타겟 필드" />
</SelectTrigger>
<SelectContent>
{targetLookupColumns.map((c) => (
<SelectItem key={c.columnName} value={c.columnName} className="text-xs">
{c.columnLabel || c.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="flex-1 rounded border border-dashed bg-gray-50 p-1 text-center text-[10px] text-gray-400">
...
</div>
)}
</div>
<div className="rounded bg-orange-100 p-1.5 text-[10px] text-orange-600">
"타겟 필드 (DB 기존값)" .
</div>
</div>
)}
</div>
)}
</div>
{/* 조건식 */}
<div>
<div className="mb-2 flex items-center justify-between">
@ -738,15 +912,46 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
<SelectContent>
<SelectItem value="static"></SelectItem>
<SelectItem value="field"> </SelectItem>
{targetLookup?.tableName && (
<SelectItem value="target"> (DB )</SelectItem>
)}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs text-gray-600">
{(condition as any).valueType === "field" ? "비교 필드" : "비교 값"}
{(condition as any).valueType === "target"
? "타겟 필드 (DB 기존값)"
: (condition as any).valueType === "field"
? "비교 필드"
: "비교 값"}
</Label>
{(condition as any).valueType === "field" ? (
{(condition as any).valueType === "target" ? (
// 타겟 필드 (DB 기존값): 타겟 테이블 컬럼에서 선택
targetLookupColumns.length > 0 ? (
<Select
value={condition.value as string}
onValueChange={(value) => handleConditionChange(index, "value", value)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="DB 필드 선택" />
</SelectTrigger>
<SelectContent>
{targetLookupColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
<span className="ml-2 text-xs text-gray-400">({col.dataType})</span>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
</div>
)
) : (condition as any).valueType === "field" ? (
// 필드 참조: 드롭다운으로 선택
availableFields.length > 0 ? (
<Select

View File

@ -449,6 +449,15 @@ function AppLayoutInner({ children }: AppLayoutProps) {
);
};
// 프리뷰 모드: 사이드바/헤더 없이 화면만 표시 (인증 대기 없이 즉시 렌더링)
if (isPreviewMode) {
return (
<div className="h-screen w-full overflow-auto bg-white p-4">
{children}
</div>
);
}
// 사용자 정보가 없으면 로딩 표시
if (!user) {
return (
@ -461,15 +470,6 @@ function AppLayoutInner({ children }: AppLayoutProps) {
);
}
// 프리뷰 모드: 사이드바/헤더 없이 화면만 표시
if (isPreviewMode) {
return (
<div className="h-screen w-full overflow-auto bg-white p-4">
{children}
</div>
);
}
// UI 변환된 메뉴 데이터
const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo);

View File

@ -685,6 +685,7 @@ const CategoryConfigPanel: React.FC<CategoryConfigPanelProps> = ({
return {
valueId: selectedId,
valueCode: node.valueCode, // valueCode 추가 (V2Select 호환)
valueLabel: node.valueLabel,
valuePath: pathParts.join(" > "),
};
@ -698,6 +699,7 @@ const CategoryConfigPanel: React.FC<CategoryConfigPanelProps> = ({
const newMapping: CategoryFormatMapping = {
categoryValueId: selectedInfo.valueId,
categoryValueCode: selectedInfo.valueCode, // V2Select에서 valueCode를 value로 사용하므로 매칭용 저장
categoryValueLabel: selectedInfo.valueLabel,
categoryValuePath: selectedInfo.valuePath,
format: newFormat.trim(),

View File

@ -25,7 +25,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
isPreview = false,
}) => {
return (
<Card className="border-border bg-card">
<Card className="border-border bg-card flex-1">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<Badge variant="outline" className="text-xs sm:text-sm">

View File

@ -62,9 +62,9 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
const [editingLeftTitle, setEditingLeftTitle] = useState(false);
const [editingRightTitle, setEditingRightTitle] = useState(false);
// 구분자 관련 상태
const [separatorType, setSeparatorType] = useState<SeparatorType>("-");
const [customSeparator, setCustomSeparator] = useState("");
// 구분자 관련 상태 (개별 파트 사이 구분자)
const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
// 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회
interface CategoryOption {
@ -192,48 +192,68 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
}
}, [currentRule, onChange]);
// currentRule이 변경될 때 구분자 상태 동기화
// currentRule이 변경될 때 파트별 구분자 상태 동기화
useEffect(() => {
if (currentRule) {
const sep = currentRule.separator ?? "-";
// 빈 문자열이면 "none"
if (sep === "") {
setSeparatorType("none");
setCustomSeparator("");
return;
}
// 미리 정의된 구분자인지 확인 (none, custom 제외)
const predefinedOption = SEPARATOR_OPTIONS.find(
opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep
);
if (predefinedOption) {
setSeparatorType(predefinedOption.value);
setCustomSeparator("");
} else {
// 직접 입력된 구분자
setSeparatorType("custom");
setCustomSeparator(sep);
}
if (currentRule && currentRule.parts.length > 0) {
const newSepTypes: Record<number, SeparatorType> = {};
const newCustomSeps: Record<number, string> = {};
currentRule.parts.forEach((part) => {
const sep = part.separatorAfter ?? currentRule.separator ?? "-";
if (sep === "") {
newSepTypes[part.order] = "none";
newCustomSeps[part.order] = "";
} else {
const predefinedOption = SEPARATOR_OPTIONS.find(
opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep
);
if (predefinedOption) {
newSepTypes[part.order] = predefinedOption.value;
newCustomSeps[part.order] = "";
} else {
newSepTypes[part.order] = "custom";
newCustomSeps[part.order] = sep;
}
}
});
setSeparatorTypes(newSepTypes);
setCustomSeparators(newCustomSeps);
}
}, [currentRule?.ruleId]); // ruleId가 변경될 때만 실행 (규칙 선택/생성 시)
}, [currentRule?.ruleId]);
// 구분자 변경 핸들러
const handleSeparatorChange = useCallback((type: SeparatorType) => {
setSeparatorType(type);
// 개별 파트 구분자 변경 핸들러
const handlePartSeparatorChange = useCallback((partOrder: number, type: SeparatorType) => {
setSeparatorTypes(prev => ({ ...prev, [partOrder]: type }));
if (type !== "custom") {
const option = SEPARATOR_OPTIONS.find(opt => opt.value === type);
const newSeparator = option?.displayValue ?? "";
setCurrentRule((prev) => prev ? { ...prev, separator: newSeparator } : null);
setCustomSeparator("");
setCustomSeparators(prev => ({ ...prev, [partOrder]: "" }));
setCurrentRule((prev) => {
if (!prev) return null;
return {
...prev,
parts: prev.parts.map((part) =>
part.order === partOrder ? { ...part, separatorAfter: newSeparator } : part
),
};
});
}
}, []);
// 직접 입력 구분자 변경 핸들러
const handleCustomSeparatorChange = useCallback((value: string) => {
// 최대 2자 제한
// 개별 파트 직접 입력 구분자 변경 핸들러
const handlePartCustomSeparatorChange = useCallback((partOrder: number, value: string) => {
const trimmedValue = value.slice(0, 2);
setCustomSeparator(trimmedValue);
setCurrentRule((prev) => prev ? { ...prev, separator: trimmedValue } : null);
setCustomSeparators(prev => ({ ...prev, [partOrder]: trimmedValue }));
setCurrentRule((prev) => {
if (!prev) return null;
return {
...prev,
parts: prev.parts.map((part) =>
part.order === partOrder ? { ...part, separatorAfter: trimmedValue } : part
),
};
});
}, []);
const handleAddPart = useCallback(() => {
@ -250,6 +270,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
partType: "text",
generationMethod: "auto",
autoConfig: { textValue: "CODE" },
separatorAfter: "-",
};
setCurrentRule((prev) => {
@ -257,6 +278,10 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
return { ...prev, parts: [...prev.parts, newPart] };
});
// 새 파트의 구분자 상태 초기화
setSeparatorTypes(prev => ({ ...prev, [newPart.order]: "-" }));
setCustomSeparators(prev => ({ ...prev, [newPart.order]: "" }));
toast.success(`규칙 ${newPart.order}가 추가되었습니다`);
}, [currentRule, maxRules]);
@ -573,42 +598,6 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
</div>
</div>
{/* 두 번째 줄: 구분자 설정 */}
<div className="flex items-end gap-3">
<div className="w-48 space-y-2">
<Label className="text-sm font-medium"></Label>
<Select
value={separatorType}
onValueChange={(value) => handleSeparatorChange(value as SeparatorType)}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="구분자 선택" />
</SelectTrigger>
<SelectContent>
{SEPARATOR_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{separatorType === "custom" && (
<div className="w-32 space-y-2">
<Label className="text-sm font-medium"> </Label>
<Input
value={customSeparator}
onChange={(e) => handleCustomSeparatorChange(e.target.value)}
className="h-9"
placeholder="최대 2자"
maxLength={2}
/>
</div>
)}
<p className="text-muted-foreground pb-2 text-xs">
</p>
</div>
</div>
@ -625,15 +614,48 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
<p className="text-muted-foreground text-xs sm:text-sm"> </p>
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
<div className="flex flex-wrap items-stretch gap-3">
{currentRule.parts.map((part, index) => (
<NumberingRuleCard
key={`part-${part.order}-${index}`}
part={part}
onUpdate={(updates) => handleUpdatePart(part.order, updates)}
onDelete={() => handleDeletePart(part.order)}
isPreview={isPreview}
/>
<React.Fragment key={`part-${part.order}-${index}`}>
<div className="flex w-[200px] flex-col">
<NumberingRuleCard
part={part}
onUpdate={(updates) => handleUpdatePart(part.order, updates)}
onDelete={() => handleDeletePart(part.order)}
isPreview={isPreview}
/>
{/* 카드 하단에 구분자 설정 (마지막 파트 제외) */}
{index < currentRule.parts.length - 1 && (
<div className="mt-2 flex items-center gap-1">
<span className="text-muted-foreground text-[10px] whitespace-nowrap"> </span>
<Select
value={separatorTypes[part.order] || "-"}
onValueChange={(value) => handlePartSeparatorChange(part.order, value as SeparatorType)}
>
<SelectTrigger className="h-6 flex-1 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SEPARATOR_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value} className="text-xs">
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{separatorTypes[part.order] === "custom" && (
<Input
value={customSeparators[part.order] || ""}
onChange={(e) => handlePartCustomSeparatorChange(part.order, e.target.value)}
className="h-6 w-14 text-center text-[10px]"
placeholder="2자"
maxLength={2}
/>
)}
</div>
)}
</div>
</React.Fragment>
))}
</div>
)}

View File

@ -17,75 +17,71 @@ export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({
return "규칙을 추가해주세요";
}
const parts = config.parts
.sort((a, b) => a.order - b.order)
.map((part) => {
if (part.generationMethod === "manual") {
return part.manualConfig?.value || "XXX";
const sortedParts = config.parts.sort((a, b) => a.order - b.order);
const partValues = sortedParts.map((part) => {
if (part.generationMethod === "manual") {
return part.manualConfig?.value || "XXX";
}
const autoConfig = part.autoConfig || {};
switch (part.partType) {
case "sequence": {
const length = autoConfig.sequenceLength || 3;
const startFrom = autoConfig.startFrom || 1;
return String(startFrom).padStart(length, "0");
}
const autoConfig = part.autoConfig || {};
switch (part.partType) {
// 1. 순번 (자동 증가)
case "sequence": {
const length = autoConfig.sequenceLength || 3;
const startFrom = autoConfig.startFrom || 1;
return String(startFrom).padStart(length, "0");
}
// 2. 숫자 (고정 자릿수)
case "number": {
const length = autoConfig.numberLength || 4;
const value = autoConfig.numberValue || 0;
return String(value).padStart(length, "0");
}
// 3. 날짜
case "date": {
const format = autoConfig.dateFormat || "YYYYMMDD";
// 컬럼 기준 생성인 경우 placeholder 표시
if (autoConfig.useColumnValue && autoConfig.sourceColumnName) {
// 형식에 맞는 placeholder 반환
switch (format) {
case "YYYY": return "[YYYY]";
case "YY": return "[YY]";
case "YYYYMM": return "[YYYYMM]";
case "YYMM": return "[YYMM]";
case "YYYYMMDD": return "[YYYYMMDD]";
case "YYMMDD": return "[YYMMDD]";
default: return "[DATE]";
}
}
// 현재 날짜 기준 생성
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
case "number": {
const length = autoConfig.numberLength || 4;
const value = autoConfig.numberValue || 0;
return String(value).padStart(length, "0");
}
case "date": {
const format = autoConfig.dateFormat || "YYYYMMDD";
if (autoConfig.useColumnValue && autoConfig.sourceColumnName) {
switch (format) {
case "YYYY": return String(year);
case "YY": return String(year).slice(-2);
case "YYYYMM": return `${year}${month}`;
case "YYMM": return `${String(year).slice(-2)}${month}`;
case "YYYYMMDD": return `${year}${month}${day}`;
case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`;
default: return `${year}${month}${day}`;
case "YYYY": return "[YYYY]";
case "YY": return "[YY]";
case "YYYYMM": return "[YYYYMM]";
case "YYMM": return "[YYMM]";
case "YYYYMMDD": return "[YYYYMMDD]";
case "YYMMDD": return "[YYMMDD]";
default: return "[DATE]";
}
}
// 4. 문자
case "text":
return autoConfig.textValue || "TEXT";
default:
return "XXX";
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
switch (format) {
case "YYYY": return String(year);
case "YY": return String(year).slice(-2);
case "YYYYMM": return `${year}${month}`;
case "YYMM": return `${String(year).slice(-2)}${month}`;
case "YYYYMMDD": return `${year}${month}${day}`;
case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`;
default: return `${year}${month}${day}`;
}
}
});
case "text":
return autoConfig.textValue || "TEXT";
default:
return "XXX";
}
});
return parts.join(config.separator || "");
// 파트별 개별 구분자로 결합
const globalSep = config.separator ?? "-";
let result = "";
partValues.forEach((val, idx) => {
result += val;
if (idx < partValues.length - 1) {
const sep = sortedParts[idx].separatorAfter ?? globalSep;
result += sep;
}
});
return result;
}, [config]);
if (compact) {

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo } from "react";
import {
Dialog,
DialogContent,
@ -15,6 +15,9 @@ import { ComponentData } from "@/types/screen";
import { toast } from "sonner";
import { dynamicFormApi } from "@/lib/api/dynamicForm";
import { useAuth } from "@/hooks/useAuth";
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
import { ScreenContextProvider } from "@/contexts/ScreenContext";
interface EditModalState {
isOpen: boolean;
@ -111,11 +114,19 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// 폼 데이터 상태 (편집 데이터로 초기화됨)
const [formData, setFormData] = useState<Record<string, any>>({});
const [originalData, setOriginalData] = useState<Record<string, any>>({});
// INSERT/UPDATE 판단용 플래그 (이벤트에서 명시적으로 전달받음)
// true = INSERT (등록/복사), false = UPDATE (수정)
// originalData 상태에 의존하지 않고 이벤트의 isCreateMode 값을 직접 사용
const [isCreateModeFlag, setIsCreateModeFlag] = useState<boolean>(true);
// 🆕 그룹 데이터 상태 (같은 order_no의 모든 품목)
const [groupData, setGroupData] = useState<Record<string, any>[]>([]);
const [originalGroupData, setOriginalGroupData] = useState<Record<string, any>[]>([]);
// 🆕 조건부 레이어 상태 (Zone 기반)
const [zones, setZones] = useState<ConditionalZone[]>([]);
const [conditionalLayers, setConditionalLayers] = useState<LayerDefinition[]>([]);
// 화면의 실제 크기 계산 함수 (ScreenModal과 동일)
const calculateScreenDimensions = (components: ComponentData[]) => {
if (components.length === 0) {
@ -264,14 +275,39 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
});
// 편집 데이터로 폼 데이터 초기화
setFormData(editData || {});
// 🆕 isCreateMode가 true이면 originalData를 빈 객체로 설정 (INSERT 모드)
// originalData가 비어있으면 INSERT, 있으면 UPDATE로 처리됨
setOriginalData(isCreateMode ? {} : editData || {});
if (isCreateMode) {
console.log("[EditModal] 생성 모드로 열림, 초기값:", editData);
// entity join 필드(xxx_yyy)를 dot notation(table.column)으로도 매핑
const enriched = { ...(editData || {}) };
if (editData) {
Object.keys(editData).forEach((key) => {
// item_id_item_name → item_info.item_name 패턴 변환
const match = key.match(/^(.+?)_([a-z_]+)$/);
if (match && editData[key] != null) {
const [, fkCol, fieldName] = match;
// FK가 _id로 끝나면 참조 테이블명 추론 (item_id → item_info)
if (fkCol.endsWith("_id")) {
const refTable = fkCol.replace(/_id$/, "_info");
const dotKey = `${refTable}.${fieldName}`;
if (!(dotKey in enriched)) {
enriched[dotKey] = editData[key];
}
}
}
});
}
setFormData(enriched);
// originalData: changedData 계산(PATCH)에만 사용
// INSERT/UPDATE 판단에는 사용하지 않음
setOriginalData(isCreateMode ? {} : editData || {});
// INSERT/UPDATE 판단: 이벤트의 isCreateMode 플래그를 직접 저장
// isCreateMode=true(복사/등록) → INSERT, false/undefined(수정) → UPDATE
setIsCreateModeFlag(!!isCreateMode);
console.log("[EditModal] 모달 열림:", {
mode: isCreateMode ? "INSERT (생성/복사)" : "UPDATE (수정)",
hasEditData: !!editData,
editDataId: editData?.id,
isCreateMode,
});
};
const handleCloseEditModal = () => {
@ -360,15 +396,44 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
try {
setLoading(true);
// console.log("화면 데이터 로딩 시작:", screenId);
// 화면 정보와 레이아웃 데이터 로딩
const [screenInfo, layoutData] = await Promise.all([
// 화면 정보와 레이아웃 데이터 로딩 (ScreenModal과 동일하게 V2 API 우선)
const [screenInfo, v2LayoutData] = await Promise.all([
screenApi.getScreen(screenId),
screenApi.getLayout(screenId),
screenApi.getLayoutV2(screenId),
]);
// console.log("API 응답:", { screenInfo, layoutData });
// V2 → Legacy 변환 (ScreenModal과 동일한 패턴)
let layoutData: any = null;
if (v2LayoutData && isValidV2Layout(v2LayoutData)) {
layoutData = convertV2ToLegacy(v2LayoutData);
if (layoutData) {
layoutData.screenResolution = v2LayoutData.screenResolution || layoutData.screenResolution;
}
}
// V2 없으면 기존 API fallback
if (!layoutData) {
console.warn("[EditModal] V2 레이아웃 없음, getLayout fallback 시도:", screenId);
layoutData = await screenApi.getLayout(screenId);
}
// getLayout도 실패하면 기본 레이어(layer_id=1) 직접 로드
if (!layoutData || !layoutData.components || layoutData.components.length === 0) {
console.warn("[EditModal] getLayout도 실패, getLayerLayout(1) 최종 fallback:", screenId);
try {
const baseLayerData = await screenApi.getLayerLayout(screenId, 1);
if (baseLayerData && isValidV2Layout(baseLayerData)) {
layoutData = convertV2ToLegacy(baseLayerData);
if (layoutData) {
layoutData.screenResolution = baseLayerData.screenResolution || layoutData.screenResolution;
}
} else if (baseLayerData?.components) {
layoutData = baseLayerData;
}
} catch (fallbackErr) {
console.error("[EditModal] getLayerLayout(1) fallback 실패:", fallbackErr);
}
}
if (screenInfo && layoutData) {
const components = layoutData.components || [];
@ -381,11 +446,14 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
components,
screenInfo: screenInfo,
});
// console.log("화면 데이터 설정 완료:", {
// componentsCount: components.length,
// dimensions,
// screenInfo,
// });
// 🆕 조건부 레이어/존 로드 (await으로 에러 포착)
console.log("[EditModal] 화면 데이터 로드 완료, 조건부 레이어 로드 시작:", screenId);
try {
await loadConditionalLayersAndZones(screenId, components);
} catch (layerErr) {
console.error("[EditModal] 조건부 레이어 로드 에러:", layerErr);
}
} else {
throw new Error("화면 데이터가 없습니다");
}
@ -398,6 +466,185 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
}
};
// 🆕 조건부 레이어 & 존 로드 함수
const loadConditionalLayersAndZones = async (screenId: number, baseComponents: ComponentData[]) => {
console.log("[EditModal] loadConditionalLayersAndZones 호출됨:", screenId);
try {
// 레이어 목록 & 존 목록 병렬 로드
console.log("[EditModal] API 호출 시작: getScreenLayers, getScreenZones");
const [layersRes, zonesRes] = await Promise.all([
screenApi.getScreenLayers(screenId),
screenApi.getScreenZones(screenId),
]);
console.log("[EditModal] API 응답:", { layers: layersRes?.length, zones: zonesRes?.length });
const loadedLayers = layersRes || [];
const loadedZones: ConditionalZone[] = zonesRes || [];
setZones(loadedZones);
// 기본 레이어(layer_id=1) 제외한 조건부 레이어 처리
const nonBaseLayers = loadedLayers.filter((l: any) => l.layer_id !== 1);
if (nonBaseLayers.length === 0) {
setConditionalLayers([]);
return;
}
// 각 조건부 레이어의 컴포넌트 로드
const layerDefinitions: LayerDefinition[] = [];
for (const layer of nonBaseLayers) {
try {
const layerLayout = await screenApi.getLayerLayout(screenId, layer.layer_id);
let layerComponents: ComponentData[] = [];
if (layerLayout && isValidV2Layout(layerLayout)) {
const legacyLayout = convertV2ToLegacy(layerLayout);
layerComponents = (legacyLayout?.components || []) as unknown as ComponentData[];
} else if (layerLayout?.components) {
layerComponents = layerLayout.components;
}
// condition_config에서 zone_id, condition_value 추출
const conditionConfig = layer.condition_config || {};
const layerZoneId = conditionConfig.zone_id;
const layerConditionValue = conditionConfig.condition_value;
// 이 레이어가 속한 Zone 찾기
const associatedZone = loadedZones.find(
(z) => z.zone_id === layerZoneId
);
layerDefinitions.push({
id: `layer-${layer.layer_id}`,
name: layer.layer_name || `레이어 ${layer.layer_id}`,
type: "conditional",
zIndex: layer.layer_id,
isVisible: false,
isLocked: false,
zoneId: layerZoneId,
conditionValue: layerConditionValue,
condition: associatedZone
? {
targetComponentId: associatedZone.trigger_component_id || "",
operator: (associatedZone.trigger_operator || "eq") as any,
value: layerConditionValue || "",
}
: undefined,
components: layerComponents,
} as LayerDefinition & { components: ComponentData[] });
} catch (layerError) {
console.warn(`[EditModal] 레이어 ${layer.layer_id} 로드 실패:`, layerError);
}
}
console.log("[EditModal] 조건부 레이어 로드 완료:", layerDefinitions.length, "개",
layerDefinitions.map((l) => ({
id: l.id,
name: l.name,
conditionValue: l.conditionValue,
condition: l.condition,
}))
);
setConditionalLayers(layerDefinitions);
} catch (error) {
console.warn("[EditModal] 조건부 레이어 로드 실패:", error);
}
};
// 🆕 조건부 레이어 활성화 평가 (formData 변경 시)
const activeConditionalLayerIds = useMemo(() => {
if (conditionalLayers.length === 0) return [];
const newActiveIds: string[] = [];
const allComponents = screenData?.components || [];
conditionalLayers.forEach((layer) => {
const layerWithComponents = layer as LayerDefinition & { components: ComponentData[] };
if (layerWithComponents.condition) {
const { targetComponentId, operator, value } = layerWithComponents.condition;
if (!targetComponentId) return;
// 트리거 컴포넌트의 columnName 찾기
// V2 레이아웃: overrides.columnName, 레거시: componentConfig.columnName
const targetComponent = allComponents.find((c) => c.id === targetComponentId);
const fieldKey =
(targetComponent as any)?.overrides?.columnName ||
(targetComponent as any)?.columnName ||
(targetComponent as any)?.componentConfig?.columnName ||
targetComponentId;
const currentFormData = groupData.length > 0 ? groupData[0] : formData;
const targetValue = currentFormData[fieldKey];
let isMatch = false;
switch (operator) {
case "eq":
isMatch = String(targetValue ?? "") === String(value ?? "");
break;
case "neq":
isMatch = String(targetValue ?? "") !== String(value ?? "");
break;
case "in":
if (Array.isArray(value)) {
isMatch = value.some((v) => String(v) === String(targetValue ?? ""));
} else if (typeof value === "string" && value.includes(",")) {
isMatch = value.split(",").map((v) => v.trim()).includes(String(targetValue ?? ""));
}
break;
}
// 디버그 로깅
console.log("[EditModal] 레이어 조건 평가:", {
layerId: layer.id,
layerName: layer.name,
targetComponentId,
fieldKey,
targetValue: targetValue !== undefined ? String(targetValue) : "(없음)",
conditionValue: String(value),
operator,
isMatch,
componentFound: !!targetComponent,
});
if (isMatch) {
newActiveIds.push(layer.id);
}
}
});
return newActiveIds;
}, [formData, groupData, conditionalLayers, screenData?.components]);
// 활성화된 조건부 레이어의 컴포넌트 가져오기 (Zone 오프셋 적용)
const activeConditionalComponents = useMemo(() => {
return conditionalLayers
.filter((layer) => activeConditionalLayerIds.includes(layer.id))
.flatMap((layer) => {
const layerWithComps = layer as LayerDefinition & { components: ComponentData[] };
const comps = layerWithComps.components || [];
// Zone 오프셋 적용: 조건부 레이어 컴포넌트는 Zone 내부 상대 좌표로 저장되므로
// Zone의 절대 좌표를 더해줘야 EditModal에서 올바른 위치에 렌더링됨
const associatedZone = zones.find((z) => z.zone_id === (layer as any).zoneId);
if (!associatedZone) return comps;
const zoneOffsetX = associatedZone.x || 0;
const zoneOffsetY = associatedZone.y || 0;
return comps.map((comp) => ({
...comp,
position: {
...comp.position,
x: parseFloat(comp.position?.x?.toString() || "0") + zoneOffsetX,
y: parseFloat(comp.position?.y?.toString() || "0") + zoneOffsetY,
},
}));
});
}, [conditionalLayers, activeConditionalLayerIds, zones]);
const handleClose = () => {
setModalState({
isOpen: false,
@ -412,7 +659,10 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
});
setScreenData(null);
setFormData({});
setZones([]);
setConditionalLayers([]);
setOriginalData({});
setIsCreateModeFlag(true); // 기본값은 INSERT (안전 방향)
setGroupData([]); // 🆕
setOriginalGroupData([]); // 🆕
};
@ -704,14 +954,31 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
}
}
// V2Repeater 저장 이벤트 발생 (디테일 테이블 데이터 저장)
const hasRepeaterInstances = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
if (hasRepeaterInstances) {
const masterRecordId = groupData[0]?.id || formData.id;
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: masterRecordId,
masterRecordId,
mainFormData: formData,
tableName: screenData.screenInfo.tableName,
},
}),
);
console.log("📋 [EditModal] 그룹 저장 후 repeaterSave 이벤트 발생:", { masterRecordId });
}
// 결과 메시지
const messages: string[] = [];
if (insertedCount > 0) messages.push(`${insertedCount}개 추가`);
if (updatedCount > 0) messages.push(`${updatedCount}개 수정`);
if (deletedCount > 0) messages.push(`${deletedCount}개 삭제`);
if (messages.length > 0) {
toast.success(`품목이 저장되었습니다 (${messages.join(", ")})`);
if (messages.length > 0 || hasRepeaterInstances) {
toast.success(messages.length > 0 ? `품목이 저장되었습니다 (${messages.join(", ")})` : "저장되었습니다.");
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
if (modalState.onSave) {
@ -776,8 +1043,31 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
return;
}
// originalData가 비어있으면 INSERT, 있으면 UPDATE
const isCreateMode = Object.keys(originalData).length === 0;
// ========================================
// INSERT/UPDATE 판단 (재설계)
// ========================================
// 판단 기준:
// 1. isCreateModeFlag === true → 무조건 INSERT (복사/등록 모드 보호)
// 2. isCreateModeFlag === false → formData.id 있으면 UPDATE, 없으면 INSERT
// originalData는 INSERT/UPDATE 판단에 사용하지 않음 (changedData 계산에만 사용)
// ========================================
let isCreateMode: boolean;
if (isCreateModeFlag) {
// 이벤트에서 명시적으로 INSERT 모드로 지정됨 (등록/복사)
isCreateMode = true;
} else {
// 수정 모드: formData에 id가 있으면 UPDATE, 없으면 INSERT
isCreateMode = !formData.id;
}
console.log("[EditModal] 저장 모드 판단:", {
isCreateMode,
isCreateModeFlag,
formDataId: formData.id,
originalDataLength: Object.keys(originalData).length,
tableName: screenData.screenInfo.tableName,
});
if (isCreateMode) {
// INSERT 모드
@ -903,19 +1193,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
if (response.success) {
const masterRecordId = response.data?.id || formData.id;
// 🆕 리피터 데이터 저장 이벤트 발생 (V2Repeater 컴포넌트가 리스닝)
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: masterRecordId,
masterRecordId,
mainFormData: formData,
tableName: screenData.screenInfo.tableName,
},
}),
);
console.log("📋 [EditModal] repeaterSave 이벤트 발생:", { masterRecordId, tableName: screenData.screenInfo.tableName });
toast.success("데이터가 생성되었습니다.");
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
@ -963,88 +1240,97 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
}
// V2Repeater 디테일 데이터 저장 (등록된 인스턴스가 있을 때만)
const hasRepeaterForInsert = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
if (hasRepeaterForInsert) {
try {
const repeaterSavePromise = new Promise<void>((resolve) => {
const fallbackTimeout = setTimeout(resolve, 5000);
const handler = () => {
clearTimeout(fallbackTimeout);
window.removeEventListener("repeaterSaveComplete", handler);
resolve();
};
window.addEventListener("repeaterSaveComplete", handler);
});
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: masterRecordId,
tableName: screenData.screenInfo.tableName,
mainFormData: formData,
masterRecordId,
},
}),
);
await repeaterSavePromise;
} catch (repeaterError) {
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
}
}
handleClose();
} else {
throw new Error(response.message || "생성에 실패했습니다.");
}
} else {
// UPDATE 모드 - 기존 로직
const changedData: Record<string, any> = {};
Object.keys(formData).forEach((key) => {
if (formData[key] !== originalData[key]) {
let value = formData[key];
// 🔧 배열이면 쉼표 구분 문자열로 변환 (리피터 데이터 제외)
if (Array.isArray(value)) {
// 리피터 데이터인지 확인 (객체 배열이고 _targetTable 또는 _isNewItem이 있으면 리피터)
const isRepeaterData = value.length > 0 &&
typeof value[0] === "object" &&
value[0] !== null &&
("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]);
if (!isRepeaterData) {
// 🔧 손상된 값 필터링 헬퍼 (중괄호, 따옴표, 백슬래시 포함 시 무효)
const isValidValue = (v: any): boolean => {
if (typeof v === "number" && !isNaN(v)) return true;
if (typeof v !== "string") return false;
if (!v || v.trim() === "") return false;
// 손상된 PostgreSQL 배열 형식 감지
if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) return false;
return true;
};
// 🔧 다중 선택 배열 → 쉼표 구분 문자열로 변환 (손상된 값 필터링)
const validValues = value
.map((v: any) => typeof v === "number" ? String(v) : v)
.filter(isValidValue);
if (validValues.length !== value.length) {
console.warn(`⚠️ [EditModal UPDATE] 손상된 값 필터링: ${key}`, {
before: value.length,
after: validValues.length,
removed: value.filter((v: any) => !isValidValue(v))
});
}
const stringValue = validValues.join(",");
console.log(`🔧 [EditModal UPDATE] 배열→문자열 변환: ${key}`, { original: value.length, valid: validValues.length, converted: stringValue });
value = stringValue;
}
}
changedData[key] = value;
}
});
// UPDATE 모드 - PUT (전체 업데이트)
// VIEW에서 온 데이터의 경우 master_id를 우선 사용 (마스터-디테일 구조)
const recordId = formData.master_id || formData.id;
if (Object.keys(changedData).length === 0) {
toast.info("변경된 내용이 없습니다.");
handleClose();
if (!recordId) {
console.error("[EditModal] UPDATE 실패: formData에 id가 없습니다.", {
formDataKeys: Object.keys(formData),
});
toast.error("수정할 레코드의 ID를 찾을 수 없습니다.");
return;
}
// 기본키 확인 (id 또는 첫 번째 키)
const recordId = originalData.id || Object.values(originalData)[0];
// 배열 값 → 쉼표 구분 문자열 변환 (리피터 데이터 제외)
const dataToSave: Record<string, any> = {};
Object.entries(formData).forEach(([key, value]) => {
if (Array.isArray(value)) {
const isRepeaterData = value.length > 0 &&
typeof value[0] === "object" &&
value[0] !== null &&
("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]);
if (isRepeaterData) {
// 리피터 데이터는 제외 (별도 저장)
return;
}
// 다중 선택 배열 → 쉼표 구분 문자열
const validValues = value
.map((v: any) => typeof v === "number" ? String(v) : v)
.filter((v: any) => {
if (typeof v === "number") return true;
if (typeof v !== "string") return false;
if (!v || v.trim() === "") return false;
if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) return false;
return true;
});
dataToSave[key] = validValues.join(",");
} else {
dataToSave[key] = value;
}
});
// UPDATE 액션 실행
const response = await dynamicFormApi.updateFormDataPartial(
console.log("[EditModal] UPDATE(PUT) 실행:", {
recordId,
originalData,
changedData,
screenData.screenInfo.tableName,
);
fieldCount: Object.keys(dataToSave).length,
tableName: screenData.screenInfo.tableName,
});
const response = await dynamicFormApi.updateFormData(recordId, {
tableName: screenData.screenInfo.tableName,
data: dataToSave,
});
if (response.success) {
toast.success("데이터가 수정되었습니다.");
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
if (modalState.onSave) {
try {
modalState.onSave();
} catch (callbackError) {
console.error("onSave 콜백 에러:", callbackError);
}
}
// 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어)
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
try {
@ -1081,6 +1367,41 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
}
// V2Repeater 디테일 데이터 저장 (등록된 인스턴스가 있을 때만)
const hasRepeaterForUpdate = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
if (hasRepeaterForUpdate) {
try {
const repeaterSavePromise = new Promise<void>((resolve) => {
const fallbackTimeout = setTimeout(resolve, 5000);
const handler = () => {
clearTimeout(fallbackTimeout);
window.removeEventListener("repeaterSaveComplete", handler);
resolve();
};
window.addEventListener("repeaterSaveComplete", handler);
});
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: recordId,
tableName: screenData.screenInfo.tableName,
mainFormData: formData,
masterRecordId: recordId,
},
}),
);
await repeaterSavePromise;
} catch (repeaterError) {
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
}
}
// 리피터 저장 완료 후 메인 테이블 새로고침
if (modalState.onSave) {
try { modalState.onSave(); } catch {}
}
handleClose();
} else {
throw new Error(response.message || "수정에 실패했습니다.");
@ -1138,7 +1459,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
</div>
</DialogHeader>
<div className="flex flex-1 items-center justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
<div className="flex flex-1 justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
{loading ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
@ -1147,16 +1468,36 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
</div>
</div>
) : screenData ? (
<ScreenContextProvider
screenId={modalState.screenId || undefined}
tableName={screenData.screenInfo?.tableName}
>
<div
className="relative bg-white"
data-screen-runtime="true"
className="relative m-auto bg-white"
style={{
width: screenDimensions?.width || 800,
height: (screenDimensions?.height || 600) + 30, // 라벨 공간 추가
// 조건부 레이어가 활성화되면 높이 자동 확장
height: (() => {
const baseHeight = (screenDimensions?.height || 600) + 30;
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 h = parseFloat(comp.size?.height?.toString() || "40");
maxBottom = Math.max(maxBottom, y + h);
});
return Math.max(baseHeight, maxBottom + 20); // 20px 여백
}
return baseHeight;
})(),
transformOrigin: "center center",
maxWidth: "100%",
maxHeight: "100%",
}}
>
{/* 기본 레이어 컴포넌트 렌더링 */}
{screenData.components.map((component) => {
// 컴포넌트 위치를 offset만큼 조정
const offsetX = screenDimensions?.offsetX || 0;
@ -1174,49 +1515,37 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
// 🆕 UniversalFormModal이 있는지 확인 (자체 저장 로직 사용)
// 최상위 컴포넌트에 universal-form-modal이 있는지 확인
// ⚠️ 수정: conditional-container는 제외 (groupData가 있으면 EditModal.handleSave 사용)
const hasUniversalFormModal = screenData.components.some(
(c) => {
// 최상위에 universal-form-modal이 있는 경우만 자체 저장 로직 사용
if (c.componentType === "universal-form-modal") return true;
return false;
}
);
// 🆕 _tableSection_ 데이터가 있는지 확인 (TableSectionRenderer 사용 시)
// _tableSection_ 데이터가 있으면 buttonActions.ts의 handleUniversalFormModalTableSectionSave가 처리
const hasTableSectionData = Object.keys(formData).some(k =>
k.startsWith("_tableSection_") || k.startsWith("__tableSection_")
);
// 🆕 그룹 데이터가 있으면 EditModal.handleSave 사용 (일괄 저장)
// 단, _tableSection_ 데이터가 있으면 EditModal.handleSave 사용하지 않음 (buttonActions.ts가 처리)
const shouldUseEditModalSave = !hasTableSectionData && (groupData.length > 0 || !hasUniversalFormModal);
// 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가
const enrichedFormData = {
...(groupData.length > 0 ? groupData[0] : formData),
tableName: screenData.screenInfo?.tableName, // 테이블명 추가
screenId: modalState.screenId, // 화면 ID 추가
tableName: screenData.screenInfo?.tableName,
screenId: modalState.screenId,
};
return (
<InteractiveScreenViewerDynamic
key={component.id}
component={adjustedComponent}
allComponents={screenData.components}
allComponents={[...screenData.components, ...activeConditionalComponents]}
formData={enrichedFormData}
originalData={originalData} // 🆕 원본 데이터 전달 (수정 모드에서 UniversalFormModal 초기화용)
originalData={originalData}
onFormDataChange={(fieldName, value) => {
// 🆕 그룹 데이터가 있으면 처리
if (groupData.length > 0) {
// ModalRepeaterTable의 경우 배열 전체를 받음
if (Array.isArray(value)) {
setGroupData(value);
} else {
// 일반 필드는 모든 항목에 동일하게 적용
setGroupData((prev) =>
prev.map((item) => ({
...item,
@ -1235,20 +1564,76 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
// 🆕 메뉴 OBJID 전달 (카테고리 스코프용)
menuObjid={modalState.menuObjid}
// 🆕 그룹 데이터가 있거나 UniversalFormModal이 없으면 EditModal.handleSave 사용
// groupData가 있으면 일괄 저장을 위해 반드시 EditModal.handleSave 사용
onSave={shouldUseEditModalSave ? handleSave : undefined}
isInModal={true}
// 🆕 그룹 데이터를 ModalRepeaterTable에 전달
groupedData={groupedDataProp}
// 🆕 수정 모달에서 읽기 전용 필드 지정 (수주번호, 거래처)
disabledFields={["order_no", "partner_id"]}
/>
);
})}
{/* 🆕 조건부 레이어 컴포넌트 렌더링 */}
{activeConditionalComponents.map((component) => {
const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0;
const labelSpace = 30;
const adjustedComponent = {
...component,
position: {
...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace,
},
};
const enrichedFormData = {
...(groupData.length > 0 ? groupData[0] : formData),
tableName: screenData.screenInfo?.tableName,
screenId: modalState.screenId,
};
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
return (
<InteractiveScreenViewerDynamic
key={`conditional-${component.id}`}
component={adjustedComponent}
allComponents={[...screenData.components, ...activeConditionalComponents]}
formData={enrichedFormData}
originalData={originalData}
onFormDataChange={(fieldName, value) => {
if (groupData.length > 0) {
if (Array.isArray(value)) {
setGroupData(value);
} else {
setGroupData((prev) =>
prev.map((item) => ({
...item,
[fieldName]: value,
})),
);
}
} else {
setFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}
}}
screenInfo={{
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
menuObjid={modalState.menuObjid}
isInModal={true}
groupedData={groupedDataProp}
/>
);
})}
</div>
</ScreenContextProvider>
) : (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground"> .</p>

View File

@ -245,23 +245,29 @@ export const EnhancedInteractiveScreenViewer: React.FC<EnhancedInteractiveScreen
};
// 라벨 렌더링
const labelPos = widget.style?.labelPosition || "top";
const isHorizLabel = labelPos === "left" || labelPos === "right";
const renderLabel = () => {
if (hideLabel) return null;
const labelStyle = widget.style || {};
const ls = widget.style || {};
const labelElement = (
<label
className={`mb-2 block text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${hasError ? "text-destructive" : ""}`}
className={`text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${hasError ? "text-destructive" : ""}`}
style={{
fontSize: labelStyle.labelFontSize || "14px",
color: hasError ? "hsl(var(--destructive))" : labelStyle.labelColor || undefined,
fontWeight: labelStyle.labelFontWeight || "500",
fontFamily: labelStyle.labelFontFamily,
textAlign: labelStyle.labelTextAlign || "left",
backgroundColor: labelStyle.labelBackgroundColor,
padding: labelStyle.labelPadding,
borderRadius: labelStyle.labelBorderRadius,
marginBottom: labelStyle.labelMarginBottom || "8px",
fontSize: ls.labelFontSize || "14px",
color: hasError ? "hsl(var(--destructive))" : ls.labelColor || undefined,
fontWeight: ls.labelFontWeight || "500",
fontFamily: ls.labelFontFamily,
textAlign: ls.labelTextAlign || "left",
backgroundColor: ls.labelBackgroundColor,
padding: ls.labelPadding,
borderRadius: ls.labelBorderRadius,
...(isHorizLabel
? { whiteSpace: "nowrap" as const, display: "flex", alignItems: "center" }
: { marginBottom: labelPos === "top" ? (ls.labelMarginBottom || "8px") : undefined,
marginTop: labelPos === "bottom" ? (ls.labelMarginBottom || "8px") : undefined }),
}}
>
{widget.label}
@ -332,11 +338,28 @@ export const EnhancedInteractiveScreenViewer: React.FC<EnhancedInteractiveScreen
}
};
const labelElement = renderLabel();
const widgetElement = renderByWebType();
const validationElement = renderFieldValidation();
if (isHorizLabel && labelElement) {
return (
<div key={comp.id}>
<div style={{ display: "flex", flexDirection: labelPos === "left" ? "row" : "row-reverse", alignItems: "center", gap: widget.style?.labelGap || "8px" }}>
{labelElement}
<div style={{ flex: 1, minWidth: 0 }}>{widgetElement}</div>
</div>
{validationElement}
</div>
);
}
return (
<div key={comp.id} className="space-y-2">
{renderLabel()}
{renderByWebType()}
{renderFieldValidation()}
<div key={comp.id}>
{labelPos === "top" && labelElement}
{widgetElement}
{labelPos === "bottom" && labelElement}
{validationElement}
</div>
);
};

View File

@ -284,6 +284,39 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
});
}, [finalFormData, layers, allComponents, handleLayerAction]);
// 🆕 Zone 기반 Y 오프셋 계산 (단순화)
// Zone 단위로 활성 여부만 판단 → merge 로직 불필요
const calculateYOffset = useCallback((componentY: number): number => {
// layers에서 Zone 정보 추출 (displayRegion이 있는 레이어들을 zone 단위로 그룹핑)
const zoneMap = new Map<number, { y: number; height: number; hasActive: boolean }>();
for (const layer of layers) {
if (layer.type !== "conditional" || !layer.zoneId || !layer.displayRegion) continue;
const zid = layer.zoneId;
if (!zoneMap.has(zid)) {
zoneMap.set(zid, {
y: layer.displayRegion.y,
height: layer.displayRegion.height,
hasActive: false,
});
}
if (activeLayerIds.includes(layer.id)) {
zoneMap.get(zid)!.hasActive = true;
}
}
let totalOffset = 0;
for (const [, zone] of zoneMap) {
const zoneBottom = zone.y + zone.height;
// 컴포넌트가 Zone 하단보다 아래에 있고, Zone에 활성 레이어가 없으면 접힘
if (componentY >= zoneBottom && !zone.hasActive) {
totalOffset += zone.height;
}
}
return totalOffset;
}, [layers, activeLayerIds]);
// 개선된 검증 시스템 (선택적 활성화)
const enhancedValidation = enableEnhancedValidation && screenInfo && tableColumns.length > 0
? useFormValidation(
@ -2158,10 +2191,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 라벨 표시 여부 계산
const shouldShowLabel =
!hideLabel && // hideLabel이 true면 라벨 숨김
(component.style?.labelDisplay ?? true) &&
!hideLabel &&
(component.style?.labelDisplay ?? true) !== false &&
component.style?.labelDisplay !== "false" &&
(component.label || component.style?.labelText) &&
!templateTypes.includes(component.type); // 템플릿 컴포넌트는 라벨 표시 안함
!templateTypes.includes(component.type);
const labelText = component.style?.labelText || component.label || "";
@ -2175,15 +2209,21 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
});
}
// 라벨 스타일 적용
const labelStyle = {
// 라벨 위치 및 스타일
const labelPosition = component.style?.labelPosition || "top";
const isHorizontalLabel = labelPosition === "left" || labelPosition === "right";
const labelGap = component.style?.labelGap || "8px";
const labelStyle: React.CSSProperties = {
fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#212121",
fontWeight: component.style?.labelFontWeight || "500",
backgroundColor: component.style?.labelBackgroundColor || "transparent",
padding: component.style?.labelPadding || "0",
borderRadius: component.style?.labelBorderRadius || "0",
marginBottom: component.style?.labelMarginBottom || "4px",
...(isHorizontalLabel
? { whiteSpace: "nowrap" as const, display: "flex", alignItems: "center" }
: { marginBottom: component.style?.labelMarginBottom || "4px" }),
};
@ -2193,11 +2233,34 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
...component,
style: {
...component.style,
labelDisplay: false, // 상위에서 라벨을 표시했으므로 컴포넌트 내부에서는 숨김
labelDisplay: false,
labelPosition: "top" as const,
...(isHorizontalLabel ? { width: "100%", height: "100%" } : {}),
},
...(isHorizontalLabel ? {
size: {
...component.size,
width: undefined as unknown as number,
height: undefined as unknown as number,
},
} : {}),
}
: component;
// 모든 레이어의 컴포넌트 통합 (조건 평가용 - 트리거 컴포넌트 검색에 필요)
const allLayerComponents = useMemo(() => {
return layers.flatMap((layer) => layer.components);
}, [layers]);
// 🔧 활성 레이어 컴포넌트만 통합 (저장/데이터 수집용)
// 기본 레이어(base) + 현재 활성화된 조건부 레이어만 포함
// 비활성 레이어의 중복 columnName 컴포넌트가 저장 데이터를 오염시키는 문제 해결
const visibleLayerComponents = useMemo(() => {
return layers
.filter((layer) => layer.type === "base" || activeLayerIds.includes(layer.id))
.flatMap((layer) => layer.components);
}, [layers, activeLayerIds]);
// 🆕 레이어별 컴포넌트 렌더링 함수
const renderLayerComponents = useCallback((layer: LayerDefinition) => {
// 활성화되지 않은 레이어는 렌더링하지 않음
@ -2234,7 +2297,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
>
<InteractiveScreenViewer
component={comp}
allComponents={layer.components}
allComponents={visibleLayerComponents}
formData={externalFormData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfo}
@ -2306,7 +2369,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
>
<InteractiveScreenViewer
component={comp}
allComponents={layer.components}
allComponents={visibleLayerComponents}
formData={externalFormData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfo}
@ -2319,37 +2382,83 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
);
}
// 일반/조건부 레이어 (base, conditional)
// 조건부 레이어: Zone 기반 영역 내에 컴포넌트 렌더링
if (layer.type === "conditional" && layer.displayRegion) {
const region = layer.displayRegion;
return (
<div
key={layer.id}
className="pointer-events-none absolute"
style={{
left: `${region.x}px`,
top: `${region.y}px`,
width: `${region.width}px`,
height: `${region.height}px`,
zIndex: layer.zIndex,
overflow: "hidden",
}}
>
{layer.components.map((comp) => (
<div
key={comp.id}
className="pointer-events-auto absolute"
style={{
left: `${comp.position.x}px`,
top: `${comp.position.y}px`,
width: comp.style?.width || `${comp.size.width}px`,
height: comp.style?.height || `${comp.size.height}px`,
zIndex: comp.position.z || 1,
}}
>
<InteractiveScreenViewer
component={comp}
allComponents={visibleLayerComponents}
formData={externalFormData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfo}
/>
</div>
))}
</div>
);
}
// 기본/기타 레이어 (base)
return (
<div
key={layer.id}
className="pointer-events-none absolute inset-0"
style={{ zIndex: layer.zIndex }}
>
{layer.components.map((comp) => (
<div
key={comp.id}
className="pointer-events-auto absolute"
style={{
left: `${comp.position.x}px`,
top: `${comp.position.y}px`,
width: comp.style?.width || `${comp.size.width}px`,
height: comp.style?.height || `${comp.size.height}px`,
zIndex: comp.position.z || 1,
}}
>
<InteractiveScreenViewer
component={comp}
allComponents={layer.components}
formData={externalFormData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfo}
/>
</div>
))}
{layer.components.map((comp) => {
const yOffset = layer.type === "base" ? calculateYOffset(comp.position.y) : 0;
const adjustedY = comp.position.y - yOffset;
return (
<div
key={comp.id}
className="pointer-events-auto absolute"
style={{
left: `${comp.position.x}px`,
top: `${adjustedY}px`,
width: comp.style?.width || `${comp.size.width}px`,
height: comp.style?.height || `${comp.size.height}px`,
zIndex: comp.position.z || 1,
}}
>
<InteractiveScreenViewer
component={comp}
allComponents={visibleLayerComponents}
formData={externalFormData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfo}
/>
</div>
);
})}
</div>
);
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo]);
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, visibleLayerComponents, layers]);
return (
<SplitPanelProvider>
@ -2359,18 +2468,45 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
{/* 테이블 옵션 툴바 */}
<TableOptionsToolbar />
{/* 메인 컨텐츠 */}
<div className="h-full flex-1" style={{ width: '100%' }}>
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
{shouldShowLabel && (
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{/* 메인 컨텐츠 - 라벨 위치에 따라 flex 방향 변경 */}
<div
className="h-full flex-1"
style={{
width: '100%',
...(shouldShowLabel && isHorizontalLabel
? { display: 'flex', flexDirection: labelPosition === 'left' ? 'row' : 'row-reverse', alignItems: 'center', gap: labelGap }
: {}),
}}
>
{/* 라벨: top 또는 left일 때 위젯보다 먼저 렌더링 */}
{shouldShowLabel && (labelPosition === "top" || labelPosition === "left") && (
<label
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
style={labelStyle}
>
{labelText}
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
</label>
)}
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */}
<div className="h-full" style={{ width: '100%', height: '100%' }}>{renderInteractiveWidget(componentForRendering)}</div>
{/* 실제 위젯 */}
<div className="h-full" style={{ width: '100%', height: '100%', ...(isHorizontalLabel ? { flex: 1, minWidth: 0 } : {}) }}>
{renderInteractiveWidget(componentForRendering)}
</div>
{/* 라벨: bottom 또는 right일 때 위젯 뒤에 렌더링 */}
{shouldShowLabel && (labelPosition === "bottom" || labelPosition === "right") && (
<label
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
style={{
...labelStyle,
...(labelPosition === "bottom" ? { marginBottom: 0, marginTop: component.style?.labelMarginBottom || "4px" } : {}),
}}
>
{labelText}
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
</label>
)}
</div>
</div>
@ -2401,7 +2537,13 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
setPopupScreen(null);
setPopupFormData({}); // 팝업 닫을 때 formData도 초기화
}}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden p-0">
<DialogContent
className="max-w-none w-auto max-h-[90vh] overflow-hidden p-0"
style={popupScreenResolution ? {
width: `${Math.min(popupScreenResolution.width + 48, window.innerWidth * 0.98)}px`,
maxWidth: "98vw",
} : { maxWidth: "56rem" }}
>
<DialogHeader className="px-6 pt-4 pb-2">
<DialogTitle>{popupScreen?.title || "상세 정보"}</DialogTitle>
</DialogHeader>

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import React, { useState, useCallback, useEffect, useSyncExternalStore } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@ -20,6 +20,12 @@ import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { evaluateConditional } from "@/lib/utils/conditionalEvaluator";
import {
subscribe as canvasSplitSubscribe,
getSnapshot as canvasSplitGetSnapshot,
getServerSnapshot as canvasSplitGetServerSnapshot,
subscribeDom as canvasSplitSubscribeDom,
} from "@/lib/registry/components/v2-split-line/canvasSplitStore";
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
import "@/lib/registry/components/ButtonRenderer";
@ -82,9 +88,14 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
parentTabId,
parentTabsComponentId,
}) => {
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
const { isPreviewMode } = useScreenPreview();
const { userName: authUserName, user: authUser } = useAuth();
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
const splitPanelContext = useSplitPanelContext();
// 캔버스 분할선 글로벌 스토어 구독
const canvasSplit = useSyncExternalStore(canvasSplitSubscribe, canvasSplitGetSnapshot, canvasSplitGetServerSnapshot);
const canvasSplitSideRef = React.useRef<"left" | "right" | null>(null);
const myScopeIdRef = React.useRef<string | null>(null);
// 외부에서 전달받은 사용자 정보가 있으면 우선 사용 (ScreenModal 등에서)
const userName = externalUserName || authUserName;
@ -560,8 +571,38 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
return;
}
// 리피터가 화면과 동일 테이블을 사용하는지 감지 (useCustomTable 미설정 = 동일 테이블)
const hasRepeaterOnSameTable = allComponents.some((c: any) => {
const compType = c.componentType || c.overrides?.type;
if (compType !== "v2-repeater") return false;
const compConfig = c.componentConfig || c.overrides || {};
return !compConfig.useCustomTable;
});
if (hasRepeaterOnSameTable) {
// 동일 테이블 리피터: 마스터 저장 스킵, 리피터만 저장
// 리피터가 mainFormData를 각 행에 병합하여 N건 INSERT 처리
try {
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: null,
masterRecordId: null,
mainFormData: formData,
tableName: screenInfo.tableName,
},
}),
);
toast.success("데이터가 성공적으로 저장되었습니다.");
} catch (error) {
toast.error("저장 중 오류가 발생했습니다.");
}
return;
}
try {
// 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장)
// 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장)
// 단, 파일 업로드 컴포넌트의 파일 배열(objid 배열)은 포함
const masterFormData: Record<string, any> = {};
@ -580,11 +621,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
Object.entries(formData).forEach(([key, value]) => {
if (!Array.isArray(value)) {
// 배열이 아닌 값은 그대로 저장
masterFormData[key] = value;
} else if (mediaColumnNames.has(key)) {
// v2-media 컴포넌트의 배열은 첫 번째 값만 저장 (단일 파일 컬럼 대응)
// 또는 JSON 문자열로 변환하려면 JSON.stringify(value) 사용
masterFormData[key] = value.length > 0 ? value[0] : null;
console.log(`📷 미디어 데이터 저장: ${key}, objid: ${masterFormData[key]}`);
} else {
@ -597,7 +635,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
data: masterFormData,
};
// console.log("💾 저장 액션 실행:", saveData);
const response = await dynamicFormApi.saveData(saveData);
if (response.success) {
@ -608,7 +645,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
new CustomEvent("repeaterSave", {
detail: {
parentId: masterRecordId,
masterRecordId, // 🆕 마스터 레코드 ID (FK 자동 연결용)
masterRecordId,
mainFormData: formData,
tableName: screenInfo.tableName,
},
@ -620,7 +657,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
toast.error(response.message || "저장에 실패했습니다.");
}
} catch (error) {
// console.error("저장 오류:", error);
toast.error("저장 중 오류가 발생했습니다.");
}
};
@ -1067,36 +1103,279 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// TableSearchWidget의 경우 높이를 자동으로 설정
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
// 🆕 라벨 표시 여부 확인 (V2 입력 컴포넌트)
// labelDisplay가 false가 아니고, labelText 또는 label이 있으면 라벨 표시
const isV2InputComponent = type === "v2-input" || type === "v2-select" || type === "v2-date";
// 라벨 표시 여부 확인 (V2 입력 컴포넌트)
const compType = (component as any).componentType || "";
const isV2InputComponent =
type === "v2-input" || type === "v2-select" || type === "v2-date" ||
compType === "v2-input" || compType === "v2-select" || compType === "v2-date";
const hasVisibleLabel = isV2InputComponent &&
style?.labelDisplay !== false &&
style?.labelDisplay !== false && style?.labelDisplay !== "false" &&
(style?.labelText || (component as any).label);
// 라벨이 있는 경우 상단 여백 계산 (라벨 폰트크기 + 여백)
// 라벨 위치에 따라 오프셋 계산 (좌/우 배치 시 세로 오프셋 불필요)
const labelPos = style?.labelPosition || "top";
const isVerticalLabel = labelPos === "top" || labelPos === "bottom";
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const labelOffset = hasVisibleLabel ? (labelFontSize + labelMarginBottom + 2) : 0;
const labelOffset = (hasVisibleLabel && isVerticalLabel) ? (labelFontSize + labelMarginBottom + 2) : 0;
// 수평 라벨 관련 (componentStyle 계산보다 먼저 선언)
const needsExternalLabel = hasVisibleLabel && labelPos !== "top";
const isHorizLabel = labelPos === "left" || labelPos === "right";
const labelText = style?.labelText || (component as any).label || "";
const labelGapValue = style?.labelGap || "8px";
const calculateCanvasSplitX = (): { x: number; w: number } => {
const compType = (component as any).componentType || "";
const isSplitLine = type === "component" && compType === "v2-split-line";
const origX = position?.x || 0;
const defaultW = size?.width || 200;
if (isSplitLine) return { x: origX, w: defaultW };
if (!canvasSplit.active || canvasSplit.canvasWidth <= 0 || !canvasSplit.scopeId) {
return { x: origX, w: defaultW };
}
if (myScopeIdRef.current === null) {
const el = document.getElementById(`interactive-${component.id}`);
const container = el?.closest("[data-screen-runtime]");
myScopeIdRef.current = container?.getAttribute("data-split-scope") || "__none__";
}
if (myScopeIdRef.current !== canvasSplit.scopeId) {
return { x: origX, w: defaultW };
}
const { initialDividerX, currentDividerX, canvasWidth } = canvasSplit;
const delta = currentDividerX - initialDividerX;
if (Math.abs(delta) < 1) return { x: origX, w: defaultW };
const origW = defaultW;
if (canvasSplitSideRef.current === null) {
const componentCenterX = origX + (origW / 2);
canvasSplitSideRef.current = componentCenterX < initialDividerX ? "left" : "right";
}
// 영역별 비례 스케일링: 스플릿선이 벽 역할 → 절대 넘어가지 않음
let newX: number;
let newW: number;
const GAP = 4; // 스플릿선과의 최소 간격
if (canvasSplitSideRef.current === "left") {
// 왼쪽 영역: [0, currentDividerX - GAP]
const initialZoneWidth = initialDividerX;
const currentZoneWidth = Math.max(20, currentDividerX - GAP);
const scale = initialZoneWidth > 0 ? currentZoneWidth / initialZoneWidth : 1;
newX = origX * scale;
newW = origW * scale;
// 안전 클램핑: 왼쪽 영역을 절대 넘지 않음
if (newX + newW > currentDividerX - GAP) {
newW = currentDividerX - GAP - newX;
}
} else {
// 오른쪽 영역: [currentDividerX + GAP, canvasWidth]
const initialRightWidth = canvasWidth - initialDividerX;
const currentRightWidth = Math.max(20, canvasWidth - currentDividerX - GAP);
const scale = initialRightWidth > 0 ? currentRightWidth / initialRightWidth : 1;
const rightOffset = origX - initialDividerX;
newX = currentDividerX + GAP + rightOffset * scale;
newW = origW * scale;
// 안전 클램핑: 오른쪽 영역을 절대 넘지 않음
if (newX < currentDividerX + GAP) newX = currentDividerX + GAP;
if (newX + newW > canvasWidth) newW = canvasWidth - newX;
}
newX = Math.max(0, newX);
newW = Math.max(20, newW);
return { x: newX, w: newW };
};
const splitResult = calculateCanvasSplitX();
const adjustedX = splitResult.x;
const adjustedW = splitResult.w;
const origW = size?.width || 200;
const isSplitActive = canvasSplit.active && canvasSplit.scopeId && myScopeIdRef.current === canvasSplit.scopeId;
// styleWithoutSize에서 left/top 제거 (캔버스 분할 조정값 덮어쓰기 방지)
const { left: _styleLeft, top: _styleTop, ...safeStyleWithoutSize } = styleWithoutSize as any;
// 수평 라벨 컴포넌트: position wrapper에서 border 제거 (내부 V2 컴포넌트가 기본 border 사용)
const cleanedStyle = (isHorizLabel && needsExternalLabel)
? (() => {
const { borderWidth: _bw, borderColor: _bc, borderStyle: _bs, border: _b, borderRadius: _br, ...rest } = safeStyleWithoutSize;
return rest;
})()
: safeStyleWithoutSize;
const componentStyle = {
position: "absolute" as const,
left: position?.x || 0,
top: position?.y || 0, // 원래 위치 유지 (음수로 가면 overflow-hidden에 잘림)
...cleanedStyle,
// left/top은 반드시 마지막에 (styleWithoutSize가 덮어쓰지 못하게)
left: adjustedX,
top: position?.y || 0,
zIndex: position?.z || 1,
...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용
width: size?.width || 200, // size의 픽셀 값이 최종 우선순위
width: isSplitActive ? adjustedW : (size?.width || 200),
height: isTableSearchWidget ? "auto" : size?.height || 10,
minHeight: isTableSearchWidget ? "48px" : undefined,
// 🆕 라벨이 있으면 overflow visible로 설정하여 라벨이 잘리지 않게 함
overflow: labelOffset > 0 ? "visible" : undefined,
overflow: (isSplitActive && adjustedW < origW) ? "hidden" : (labelOffset > 0 ? "visible" : undefined),
willChange: canvasSplit.isDragging && isSplitActive ? "left, width" as const : undefined,
transition: isSplitActive
? (canvasSplit.isDragging ? "none" : "left 0.15s ease-out, width 0.15s ease-out")
: undefined,
};
// 스플릿 조정된 컴포넌트 객체 캐싱 (드래그 끝난 후 최종 렌더링용)
const splitAdjustedComponent = React.useMemo(() => {
if (isSplitActive && adjustedW !== origW) {
return { ...component, size: { ...(component as any).size, width: Math.round(adjustedW) } };
}
return component;
}, [component, isSplitActive, adjustedW, origW]);
// 드래그 중 DOM 직접 조작 (React 리렌더 없이 매 프레임 업데이트)
const elRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const compType = (component as any).componentType || "";
if (type === "component" && compType === "v2-split-line") return;
const unsubscribe = canvasSplitSubscribeDom((snap) => {
if (!snap.isDragging || !snap.active || !snap.scopeId) return;
if (myScopeIdRef.current !== snap.scopeId) return;
const el = elRef.current;
if (!el) return;
const origX = position?.x || 0;
const oW = size?.width || 200;
const { initialDividerX, currentDividerX, canvasWidth } = snap;
const delta = currentDividerX - initialDividerX;
if (Math.abs(delta) < 1) return;
if (canvasSplitSideRef.current === null) {
canvasSplitSideRef.current = (origX + oW / 2) < initialDividerX ? "left" : "right";
}
const GAP = 4;
let nx: number, nw: number;
if (canvasSplitSideRef.current === "left") {
const scale = initialDividerX > 0 ? Math.max(20, currentDividerX - GAP) / initialDividerX : 1;
nx = origX * scale;
nw = oW * scale;
if (nx + nw > currentDividerX - GAP) nw = currentDividerX - GAP - nx;
} else {
const irw = canvasWidth - initialDividerX;
const crw = Math.max(20, canvasWidth - currentDividerX - GAP);
const scale = irw > 0 ? crw / irw : 1;
nx = currentDividerX + GAP + (origX - initialDividerX) * scale;
nw = oW * scale;
if (nx < currentDividerX + GAP) nx = currentDividerX + GAP;
if (nx + nw > canvasWidth) nw = canvasWidth - nx;
}
nx = Math.max(0, nx);
nw = Math.max(20, nw);
el.style.left = `${nx}px`;
el.style.width = `${Math.round(nw)}px`;
el.style.overflow = nw < oW ? "hidden" : "";
});
return unsubscribe;
}, [component.id, position?.x, size?.width, type]);
// needsExternalLabel, isHorizLabel, labelText, labelGapValue는 위에서 선언됨
const externalLabelComponent = needsExternalLabel ? (
<label
className="text-sm font-medium leading-none"
style={{
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#212121",
fontWeight: style?.labelFontWeight || "500",
...(isHorizLabel ? { whiteSpace: "nowrap" as const, display: "flex", alignItems: "center" } : {}),
...(labelPos === "bottom" ? { marginTop: style?.labelMarginBottom || "4px" } : {}),
}}
>
{labelText}
{((component as any).required || (component as any).componentConfig?.required) && (
<span className="ml-1 text-destructive">*</span>
)}
</label>
) : null;
const componentToRender = needsExternalLabel
? {
...splitAdjustedComponent,
style: {
...splitAdjustedComponent.style,
labelDisplay: false,
labelPosition: "top" as const,
...(isHorizLabel ? {
width: "100%",
height: "100%",
borderWidth: undefined,
borderColor: undefined,
borderStyle: undefined,
border: undefined,
borderRadius: undefined,
} : {}),
},
...(isHorizLabel ? {
size: {
...splitAdjustedComponent.size,
width: undefined as unknown as number,
height: undefined as unknown as number,
},
} : {}),
}
: splitAdjustedComponent;
return (
<>
<div className="absolute" style={componentStyle}>
{/* 위젯 렌더링 (라벨은 V2Input 내부에서 absolute로 표시됨) */}
{renderInteractiveWidget(component)}
<div ref={elRef} id={`interactive-${component.id}`} className="absolute" style={componentStyle}>
{needsExternalLabel ? (
isHorizLabel ? (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<label
className="text-sm font-medium leading-none"
style={{
position: "absolute",
top: "50%",
transform: "translateY(-50%)",
...(labelPos === "left"
? { right: "100%", marginRight: labelGapValue }
: { left: "100%", marginLeft: labelGapValue }),
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#212121",
fontWeight: style?.labelFontWeight || "500",
whiteSpace: "nowrap",
}}
>
{labelText}
{((component as any).required || (component as any).componentConfig?.required) && (
<span className="ml-1 text-destructive">*</span>
)}
</label>
<div style={{ width: "100%", height: "100%" }}>
{renderInteractiveWidget(componentToRender)}
</div>
</div>
) : (
<div
style={{
display: "flex",
flexDirection: "column-reverse",
width: "100%",
height: "100%",
}}
>
{externalLabelComponent}
<div style={{ flex: 1, minWidth: 0 }}>
{renderInteractiveWidget(componentToRender)}
</div>
</div>
)
) : (
renderInteractiveWidget(componentToRender)
)}
</div>
{/* 팝업 화면 렌더링 */}

View File

@ -10,15 +10,28 @@ import {
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Loader2, AlertCircle, Check, X } from "lucide-react";
import { Loader2, AlertCircle, Check, X, Database, Code2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { ComponentData, LayerCondition, LayerDefinition } from "@/types/screen-management";
import { ComponentData, LayerCondition, LayerDefinition, DisplayRegion } from "@/types/screen-management";
import { getCodesByCategory, CodeItem } from "@/lib/api/codeManagement";
import { EntityReferenceAPI } from "@/lib/api/entityReference";
import { apiClient } from "@/lib/api/client";
// 통합 옵션 타입 (코드/엔티티/카테고리 모두 사용)
interface ConditionOption {
value: string;
label: string;
}
// 컴포넌트의 데이터 소스 타입
type DataSourceType = "code" | "entity" | "category" | "static" | "none";
interface LayerConditionPanelProps {
layer: LayerDefinition;
components: ComponentData[]; // 화면의 모든 컴포넌트
baseLayerComponents?: ComponentData[]; // 기본 레이어 컴포넌트 (트리거 우선 대상)
onUpdateCondition: (condition: LayerCondition | undefined) => void;
onUpdateDisplayRegion: (region: DisplayRegion | undefined) => void;
onClose?: () => void;
}
@ -34,7 +47,9 @@ type OperatorType = "eq" | "neq" | "in";
export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
layer,
components,
baseLayerComponents,
onUpdateCondition,
onUpdateDisplayRegion,
onClose,
}) => {
// 조건 설정 상태
@ -51,75 +66,289 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
Array.isArray(layer.condition?.value) ? layer.condition.value : []
);
// 코드 목록 로딩 상태
const [codeOptions, setCodeOptions] = useState<CodeItem[]>([]);
const [isLoadingCodes, setIsLoadingCodes] = useState(false);
const [codeLoadError, setCodeLoadError] = useState<string | null>(null);
// 옵션 목록 로딩 상태 (코드/엔티티 통합)
const [options, setOptions] = useState<ConditionOption[]>([]);
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
// 트리거 가능한 컴포넌트 필터링 (셀렉트, 라디오, 코드 타입 등)
// 트리거 가능한 컴포넌트 필터링 (기본 레이어 우선, 셀렉트/라디오/코드 타입 등)
const triggerableComponents = useMemo(() => {
return components.filter((comp) => {
// 기본 레이어 컴포넌트가 전달된 경우 우선 사용, 없으면 전체 컴포넌트 사용
const sourceComponents = baseLayerComponents && baseLayerComponents.length > 0
? baseLayerComponents
: components;
const isTriggerComponent = (comp: ComponentData): boolean => {
const componentType = (comp.componentType || "").toLowerCase();
const widgetType = ((comp as any).widgetType || "").toLowerCase();
const webType = ((comp as any).webType || "").toLowerCase();
const inputType = ((comp as any).componentConfig?.inputType || "").toLowerCase();
const webType = ((comp as any).webType || comp.componentConfig?.webType || "").toLowerCase();
const inputType = ((comp as any).inputType || comp.componentConfig?.inputType || "").toLowerCase();
const source = ((comp as any).source || comp.componentConfig?.source || "").toLowerCase();
// 셀렉트, 라디오, 코드 타입 컴포넌트 허용
const triggerTypes = ["select", "radio", "code", "checkbox", "toggle"];
const isTriggerType = triggerTypes.some((type) =>
// 셀렉트, 라디오, 코드, 카테고리, 엔티티 타입 컴포넌트 허용
const triggerTypes = ["select", "radio", "code", "checkbox", "toggle", "entity", "category"];
return triggerTypes.some((type) =>
componentType.includes(type) ||
widgetType.includes(type) ||
webType.includes(type) ||
inputType.includes(type)
inputType.includes(type) ||
source.includes(type)
);
return isTriggerType;
});
}, [components]);
};
// 기본 레이어 컴포넌트 ID Set (그룹 구분용)
const baseLayerIds = new Set(
(baseLayerComponents || []).map((c) => c.id)
);
// 기본 레이어 트리거 컴포넌트
const baseLayerTriggers = sourceComponents.filter(isTriggerComponent);
// 기본 레이어가 아닌 다른 레이어의 트리거 컴포넌트도 포함 (하단에 표시)
// 단, baseLayerComponents가 별도로 전달된 경우에만 나머지 컴포넌트 추가
const otherLayerTriggers = baseLayerComponents && baseLayerComponents.length > 0
? components.filter((comp) => !baseLayerIds.has(comp.id) && isTriggerComponent(comp))
: [];
return { baseLayerTriggers, otherLayerTriggers };
}, [components, baseLayerComponents]);
// 선택된 컴포넌트 정보
const selectedComponent = useMemo(() => {
return components.find((c) => c.id === targetComponentId);
}, [components, targetComponentId]);
// 기본 레이어 + 현재 레이어 통합 컴포넌트 목록 (트리거 컴포넌트 검색용)
const allAvailableComponents = useMemo(() => {
const merged = [...(baseLayerComponents || []), ...components];
// 중복 제거 (id 기준)
const seen = new Set<string>();
return merged.filter((c) => {
if (seen.has(c.id)) return false;
seen.add(c.id);
return true;
});
}, [components, baseLayerComponents]);
// 선택된 컴포넌트의 코드 카테고리
const codeCategory = useMemo(() => {
if (!selectedComponent) return null;
const selectedComponent = useMemo(() => {
return allAvailableComponents.find((c) => c.id === targetComponentId);
}, [allAvailableComponents, targetComponentId]);
// 선택된 컴포넌트의 데이터 소스 정보 추출
const dataSourceInfo = useMemo<{
type: DataSourceType;
codeCategory?: string;
// 엔티티: 원본 테이블.컬럼 (entity-reference API용)
originTable?: string;
originColumn?: string;
// 엔티티: 참조 대상 정보 (직접 조회용 폴백)
referenceTable?: string;
referenceColumn?: string;
categoryTable?: string;
categoryColumn?: string;
staticOptions?: any[];
}>(() => {
if (!selectedComponent) return { type: "none" };
// codeCategory 확인 (다양한 위치에 있을 수 있음)
const category =
(selectedComponent as any).codeCategory ||
(selectedComponent as any).componentConfig?.codeCategory ||
(selectedComponent as any).webTypeConfig?.codeCategory;
const comp = selectedComponent as any;
const config = comp.componentConfig || comp.webTypeConfig || {};
const detailSettings = comp.detailSettings || {};
return category || null;
// V2 컴포넌트: source 확인 (componentConfig, 상위 레벨, inputType 모두 체크)
const source = config.source || comp.source;
const inputType = config.inputType || comp.inputType;
const webType = config.webType || comp.webType;
// inputType/webType이 category면 카테고리로 판단
if (inputType === "category" || webType === "category") {
const categoryTable = config.categoryTable || comp.tableName || config.tableName;
const categoryColumn = config.categoryColumn || comp.columnName || config.columnName;
return { type: "category", categoryTable, categoryColumn };
}
// 1. 카테고리 소스 (V2: source === "category", category_values 테이블)
if (source === "category") {
const categoryTable = config.categoryTable || comp.tableName;
const categoryColumn = config.categoryColumn || comp.columnName;
return { type: "category", categoryTable, categoryColumn };
}
// 2. 코드 카테고리 확인 (V2: source === "code" + codeGroup, 기존: codeCategory)
const codeCategory =
config.codeGroup || // V2 컴포넌트
config.codeCategory ||
comp.codeCategory ||
detailSettings.codeCategory;
if (source === "code" || codeCategory) {
return { type: "code", codeCategory };
}
// 3. 엔티티 참조 확인 (V2: source === "entity")
// entity-reference API는 원본 테이블.컬럼으로 호출해야 함
// (백엔드에서 table_type_columns를 조회하여 참조 테이블/컬럼을 자동 매핑)
const originTable = comp.tableName || config.tableName;
const originColumn = comp.columnName || config.columnName;
const referenceTable =
config.entityTable ||
config.referenceTable ||
comp.referenceTable ||
detailSettings.referenceTable;
const referenceColumn =
config.entityValueColumn ||
config.referenceColumn ||
comp.referenceColumn ||
detailSettings.referenceColumn;
if (source === "entity" || referenceTable) {
return { type: "entity", originTable, originColumn, referenceTable, referenceColumn };
}
// 4. 정적 옵션 확인 (V2: source === "static" 또는 config.options 존재)
const staticOptions = config.options;
if (source === "static" || (staticOptions && Array.isArray(staticOptions) && staticOptions.length > 0)) {
return { type: "static", staticOptions };
}
return { type: "none" };
}, [selectedComponent]);
// 컴포넌트 선택 시 코드 목록 로드
// 의존성 안정화를 위한 직렬화 키
const dataSourceKey = useMemo(() => {
const { type, categoryTable, categoryColumn, codeCategory, originTable, originColumn, referenceTable, referenceColumn } = dataSourceInfo;
return `${type}|${categoryTable || ""}|${categoryColumn || ""}|${codeCategory || ""}|${originTable || ""}|${originColumn || ""}|${referenceTable || ""}|${referenceColumn || ""}`;
}, [dataSourceInfo]);
// 컴포넌트 선택 시 옵션 목록 로드 (카테고리, 코드, 엔티티, 정적)
useEffect(() => {
if (!codeCategory) {
setCodeOptions([]);
// race condition 방지
let cancelled = false;
if (dataSourceInfo.type === "none") {
setOptions([]);
return;
}
const loadCodes = async () => {
setIsLoadingCodes(true);
setCodeLoadError(null);
// 정적 옵션은 즉시 설정
if (dataSourceInfo.type === "static") {
const staticOpts = dataSourceInfo.staticOptions || [];
setOptions(staticOpts.map((opt: any) => ({
value: opt.value || "",
label: opt.label || opt.value || "",
})));
return;
}
const loadOptions = async () => {
setIsLoadingOptions(true);
setLoadError(null);
try {
const codes = await getCodesByCategory(codeCategory);
setCodeOptions(codes);
if (dataSourceInfo.type === "category" && dataSourceInfo.categoryTable && dataSourceInfo.categoryColumn) {
// 카테고리 값에서 옵션 로드 (category_values 테이블)
console.log("[LayerCondition] 카테고리 옵션 로드:", dataSourceInfo.categoryTable, dataSourceInfo.categoryColumn);
const response = await apiClient.get(
`/table-categories/${dataSourceInfo.categoryTable}/${dataSourceInfo.categoryColumn}/values`
);
if (cancelled) return;
const data = response.data;
console.log("[LayerCondition] 카테고리 API 응답:", data?.success, "항목수:", Array.isArray(data?.data) ? data.data.length : 0);
if (data.success && data.data) {
// 트리 구조를 평탄화
const flattenTree = (items: any[], depth = 0): ConditionOption[] => {
const result: ConditionOption[] = [];
for (const item of items) {
const prefix = depth > 0 ? " ".repeat(depth) : "";
result.push({
value: item.valueCode || item.valueLabel,
label: `${prefix}${item.valueLabel}`,
});
if (item.children && item.children.length > 0) {
result.push(...flattenTree(item.children, depth + 1));
}
}
return result;
};
const loadedOptions = flattenTree(Array.isArray(data.data) ? data.data : []);
console.log("[LayerCondition] 카테고리 옵션 설정:", loadedOptions.length, "개");
setOptions(loadedOptions);
} else {
setOptions([]);
}
} else if (dataSourceInfo.type === "code" && dataSourceInfo.codeCategory) {
// 코드 카테고리에서 옵션 로드
const codes = await getCodesByCategory(dataSourceInfo.codeCategory);
if (cancelled) return;
setOptions(codes.map((code) => ({
value: code.code,
label: code.name,
})));
} else if (dataSourceInfo.type === "entity") {
// 엔티티 참조에서 옵션 로드
let entityLoaded = false;
if (dataSourceInfo.originTable && dataSourceInfo.originColumn) {
try {
const entityData = await EntityReferenceAPI.getEntityReferenceData(
dataSourceInfo.originTable,
dataSourceInfo.originColumn,
{ limit: 100 }
);
if (cancelled) return;
setOptions(entityData.options.map((opt) => ({
value: opt.value,
label: opt.label,
})));
entityLoaded = true;
} catch {
console.warn("원본 테이블.컬럼으로 엔티티 조회 실패, 직접 참조로 폴백");
}
}
// 폴백: 참조 테이블에서 직접 조회
if (!entityLoaded && dataSourceInfo.referenceTable) {
try {
const refColumn = dataSourceInfo.referenceColumn || "id";
const entityData = await EntityReferenceAPI.getEntityReferenceData(
dataSourceInfo.referenceTable,
refColumn,
{ limit: 100 }
);
if (cancelled) return;
setOptions(entityData.options.map((opt) => ({
value: opt.value,
label: opt.label,
})));
entityLoaded = true;
} catch {
console.warn("직접 참조 테이블로도 엔티티 조회 실패");
}
}
if (!entityLoaded && !cancelled) {
setOptions([]);
}
} else {
if (!cancelled) setOptions([]);
}
} catch (error: any) {
console.error("코드 목록 로드 실패:", error);
setCodeLoadError(error.message || "코드 목록을 불러올 수 없습니다.");
setCodeOptions([]);
if (!cancelled) {
console.error("옵션 목록 로드 실패:", error);
setLoadError(error.message || "옵션 목록을 불러올 수 없습니다.");
setOptions([]);
}
} finally {
setIsLoadingCodes(false);
if (!cancelled) {
setIsLoadingOptions(false);
}
}
};
loadCodes();
}, [codeCategory]);
loadOptions();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataSourceKey]);
// 조건 저장
const handleSave = useCallback(() => {
@ -180,36 +409,91 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
<SelectValue placeholder="컴포넌트 선택..." />
</SelectTrigger>
<SelectContent>
{triggerableComponents.length === 0 ? (
{triggerableComponents.baseLayerTriggers.length === 0 &&
triggerableComponents.otherLayerTriggers.length === 0 ? (
<div className="p-2 text-xs text-muted-foreground text-center">
.
<br />
(, , )
</div>
) : (
triggerableComponents.map((comp) => (
<SelectItem key={comp.id} value={comp.id} className="text-xs">
<div className="flex items-center gap-2">
<span>{getComponentLabel(comp)}</span>
<Badge variant="outline" className="text-[10px]">
{comp.componentType || (comp as any).widgetType}
</Badge>
</div>
</SelectItem>
))
<>
{/* 기본 레이어 컴포넌트 (우선 표시) */}
{triggerableComponents.baseLayerTriggers.length > 0 && (
<>
{triggerableComponents.otherLayerTriggers.length > 0 && (
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground">
</div>
)}
{triggerableComponents.baseLayerTriggers.map((comp) => (
<SelectItem key={comp.id} value={comp.id} className="text-xs">
<div className="flex items-center gap-2">
<span>{getComponentLabel(comp)}</span>
<Badge variant="outline" className="text-[10px]">
{comp.componentType || (comp as any).widgetType}
</Badge>
</div>
</SelectItem>
))}
</>
)}
{/* 다른 레이어 컴포넌트 (하단에 구분하여 표시) */}
{triggerableComponents.otherLayerTriggers.length > 0 && (
<>
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground border-t mt-1 pt-1">
</div>
{triggerableComponents.otherLayerTriggers.map((comp) => (
<SelectItem key={comp.id} value={comp.id} className="text-xs">
<div className="flex items-center gap-2">
<span>{getComponentLabel(comp)}</span>
<Badge variant="outline" className="text-[10px]">
{comp.componentType || (comp as any).widgetType}
</Badge>
</div>
</SelectItem>
))}
</>
)}
</>
)}
</SelectContent>
</Select>
{/* 코드 카테고리 표시 */}
{codeCategory && (
{/* 데이터 소스 표시 */}
{dataSourceInfo.type === "code" && dataSourceInfo.codeCategory && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Code2 className="h-3 w-3" />
<span>:</span>
<Badge variant="secondary" className="text-[10px]">
{dataSourceInfo.codeCategory}
</Badge>
</div>
)}
{dataSourceInfo.type === "entity" && (dataSourceInfo.referenceTable || dataSourceInfo.originTable) && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Database className="h-3 w-3" />
<span>:</span>
<Badge variant="secondary" className="text-[10px]">
{dataSourceInfo.referenceTable || `${dataSourceInfo.originTable}.${dataSourceInfo.originColumn}`}
</Badge>
</div>
)}
{dataSourceInfo.type === "category" && dataSourceInfo.categoryTable && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Database className="h-3 w-3" />
<span>:</span>
<Badge variant="secondary" className="text-[10px]">
{codeCategory}
{dataSourceInfo.categoryTable}.{dataSourceInfo.categoryColumn}
</Badge>
</div>
)}
{dataSourceInfo.type === "static" && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span> </span>
</div>
)}
</div>
{/* 연산자 선택 */}
@ -241,42 +525,41 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
{operator === "in" ? "값 선택 (복수)" : "값"}
</Label>
{isLoadingCodes ? (
{isLoadingOptions ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground p-2">
<Loader2 className="h-3 w-3 animate-spin" />
...
...
</div>
) : codeLoadError ? (
) : loadError ? (
<div className="flex items-center gap-2 text-xs text-destructive p-2">
<AlertCircle className="h-3 w-3" />
{codeLoadError}
{loadError}
</div>
) : codeOptions.length > 0 ? (
// 코드 카테고리가 있는 경우 - 선택 UI
) : options.length > 0 ? (
// 옵션이 있는 경우 - 선택 UI
operator === "in" ? (
// 다중 선택 (in 연산자)
<div className="space-y-1 max-h-40 overflow-y-auto border rounded-md p-2">
{codeOptions.map((code) => (
{options.map((opt) => (
<div
key={code.codeValue}
key={opt.value}
className={cn(
"flex items-center gap-2 p-1.5 rounded cursor-pointer text-xs hover:bg-accent",
multiValues.includes(code.codeValue) && "bg-primary/10"
multiValues.includes(opt.value) && "bg-primary/10"
)}
onClick={() => toggleMultiValue(code.codeValue)}
onClick={() => toggleMultiValue(opt.value)}
>
<div className={cn(
"w-4 h-4 rounded border flex items-center justify-center",
multiValues.includes(code.codeValue)
multiValues.includes(opt.value)
? "bg-primary border-primary"
: "border-input"
)}>
{multiValues.includes(code.codeValue) && (
{multiValues.includes(opt.value) && (
<Check className="h-3 w-3 text-primary-foreground" />
)}
</div>
<span>{code.codeName}</span>
<span className="text-muted-foreground">({code.codeValue})</span>
<span>{opt.label}</span>
</div>
))}
</div>
@ -287,20 +570,20 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
<SelectValue placeholder="값 선택..." />
</SelectTrigger>
<SelectContent>
{codeOptions.map((code) => (
{options.map((opt) => (
<SelectItem
key={code.codeValue}
value={code.codeValue}
key={opt.value}
value={opt.value}
className="text-xs"
>
{code.codeName} ({code.codeValue})
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
) : (
// 코드 카테고리가 없는 경우 - 직접 입력
// 옵션이 없는 경우 - 직접 입력
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
@ -313,14 +596,14 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
{operator === "in" && multiValues.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{multiValues.map((val) => {
const code = codeOptions.find((c) => c.codeValue === val);
const opt = options.find((o) => o.value === val);
return (
<Badge
key={val}
variant="secondary"
className="text-[10px] gap-1"
>
{code?.codeName || val}
{opt?.label || val}
<X
className="h-2.5 w-2.5 cursor-pointer hover:text-destructive"
onClick={() => toggleMultiValue(val)}
@ -334,19 +617,65 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
)}
{/* 현재 조건 요약 */}
{targetComponentId && (value || multiValues.length > 0) && (
{targetComponentId && selectedComponent && (value || multiValues.length > 0) && (
<div className="p-2 bg-muted rounded-md text-xs">
<span className="font-medium">: </span>
<span className="text-muted-foreground">
"{getComponentLabel(selectedComponent!)}" {" "}
{operator === "eq" && `"${codeOptions.find(c => c.codeValue === value)?.codeName || value}"와 같으면`}
{operator === "neq" && `"${codeOptions.find(c => c.codeValue === value)?.codeName || value}"와 다르면`}
{operator === "in" && `[${multiValues.map(v => codeOptions.find(c => c.codeValue === v)?.codeName || v).join(", ")}] 중 하나이면`}
"{getComponentLabel(selectedComponent)}" {" "}
{operator === "eq" && `"${options.find(o => o.value === value)?.label || value}"와 같으면`}
{operator === "neq" && `"${options.find(o => o.value === value)?.label || value}"와 다르면`}
{operator === "in" && `[${multiValues.map(v => options.find(o => o.value === v)?.label || v).join(", ")}] 중 하나이면`}
{" "}
</span>
</div>
)}
{/* 표시 영역 설정 */}
<div className="space-y-2 border-t pt-3">
<Label className="text-xs font-semibold"> </Label>
{layer.displayRegion ? (
<>
{/* 현재 영역 정보 표시 */}
<div className="flex items-center gap-2 rounded-md border bg-muted/30 p-2">
<div className="flex-1 text-[10px] text-muted-foreground">
<span className="font-medium text-foreground">
{layer.displayRegion.width} x {layer.displayRegion.height}
</span>
<span className="ml-1">
({layer.displayRegion.x}, {layer.displayRegion.y})
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-[10px] text-destructive hover:text-destructive"
onClick={() => onUpdateDisplayRegion(undefined)}
>
</Button>
</div>
<p className="text-[10px] text-muted-foreground">
/ .
</p>
</>
) : (
<div className="space-y-2">
<div className="rounded-md border border-dashed p-3 text-center">
<p className="text-xs font-medium text-muted-foreground">
</p>
<p className="text-xs font-medium text-muted-foreground">
&
</p>
</div>
<p className="text-[10px] text-muted-foreground">
.
</p>
</div>
)}
</div>
{/* 버튼 */}
<div className="flex gap-2 pt-2">
<Button

File diff suppressed because it is too large Load Diff

View File

@ -561,9 +561,8 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
zIndex: position?.z || 1,
// right 속성 강제 제거
right: undefined,
// 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동
transition:
isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined,
// 모든 컴포넌트에서 transition 완전 제거 (위치 변경 시 애니메이션 방지)
transition: "none",
};
// 선택된 컴포넌트 스타일
@ -594,7 +593,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
return (
<div
id={`component-${id}`}
className="absolute cursor-pointer"
className="absolute cursor-pointer !transition-none"
style={{ ...componentStyle, ...selectionStyle }}
onClick={handleClick}
draggable

View File

@ -1,6 +1,6 @@
"use client";
import React, { useMemo } from "react";
import React, { useMemo, useSyncExternalStore } from "react";
import { ComponentData, WebType, WidgetComponent } from "@/types/screen";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import {
@ -17,6 +17,12 @@ import {
File,
} from "lucide-react";
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
import {
subscribe as canvasSplitSubscribe,
getSnapshot as canvasSplitGetSnapshot,
getServerSnapshot as canvasSplitGetServerSnapshot,
subscribeDom as canvasSplitSubscribeDom,
} from "@/lib/registry/components/v2-split-line/canvasSplitStore";
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
// 컴포넌트 렌더러들 자동 등록
@ -388,10 +394,12 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
}
: component;
// 🆕 분할 패널 리사이즈 Context
// 기존 분할 패널 리사이즈 Context (레거시 split-panel-layout용)
const splitPanelContext = useSplitPanel();
// 버튼 컴포넌트인지 확인 (분할 패널 위치 조정 대상)
// 캔버스 분할선 글로벌 스토어 (useSyncExternalStore로 직접 구독)
const canvasSplit = useSyncExternalStore(canvasSplitSubscribe, canvasSplitGetSnapshot, canvasSplitGetServerSnapshot);
const componentType = (component as any).componentType || "";
const componentId = (component as any).componentId || "";
const widgetType = (component as any).widgetType || "";
@ -402,137 +410,170 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
(["button-primary", "button-secondary"].includes(componentType) ||
["button-primary", "button-secondary"].includes(componentId)));
// 🆕 버튼이 처음 렌더링될 때의 분할 패널 정보를 기억 (기준점)
// 레거시 분할 패널용 refs
const initialPanelRatioRef = React.useRef<number | null>(null);
const initialPanelIdRef = React.useRef<string | null>(null);
// 버튼이 좌측 패널에 속하는지 여부 (한번 설정되면 유지)
const isInLeftPanelRef = React.useRef<boolean | null>(null);
// 🆕 분할 패널 위 버튼 위치 자동 조정
const calculateButtonPosition = () => {
// 버튼이 아니거나 분할 패널 컴포넌트 자체인 경우 조정하지 않음
// 캔버스 분할선 좌/우 판정 (한 번만)
const canvasSplitSideRef = React.useRef<"left" | "right" | null>(null);
// 스코프 체크 캐시 (DOM 쿼리 최소화)
const myScopeIdRef = React.useRef<string | null>(null);
const calculateSplitAdjustedPosition = () => {
const isSplitLineComponent =
type === "component" && componentType === "v2-split-line";
if (isSplitLineComponent) {
return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false };
}
// === 1. 캔버스 분할선 (글로벌 스토어) ===
if (canvasSplit.active && canvasSplit.canvasWidth > 0 && canvasSplit.scopeId) {
if (myScopeIdRef.current === null) {
const el = document.getElementById(`component-${id}`);
const container = el?.closest("[data-screen-runtime]");
myScopeIdRef.current = container?.getAttribute("data-split-scope") || "__none__";
}
if (myScopeIdRef.current !== canvasSplit.scopeId) {
return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false };
}
const { initialDividerX, currentDividerX, canvasWidth, isDragging: splitDragging } = canvasSplit;
const delta = currentDividerX - initialDividerX;
if (canvasSplitSideRef.current === null) {
const origW = size?.width || 100;
const componentCenterX = position.x + (origW / 2);
canvasSplitSideRef.current = componentCenterX < initialDividerX ? "left" : "right";
}
if (Math.abs(delta) < 1) {
return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: true, isDraggingSplitPanel: splitDragging };
}
// 영역별 비례 스케일링: 스플릿선이 벽 역할 → 절대 넘어가지 않음
const origW = size?.width || 100;
const GAP = 4;
let adjustedX: number;
let adjustedW: number;
if (canvasSplitSideRef.current === "left") {
const initialZoneWidth = initialDividerX;
const currentZoneWidth = Math.max(20, currentDividerX - GAP);
const scale = initialZoneWidth > 0 ? currentZoneWidth / initialZoneWidth : 1;
adjustedX = position.x * scale;
adjustedW = origW * scale;
if (adjustedX + adjustedW > currentDividerX - GAP) {
adjustedW = currentDividerX - GAP - adjustedX;
}
} else {
const initialRightWidth = canvasWidth - initialDividerX;
const currentRightWidth = Math.max(20, canvasWidth - currentDividerX - GAP);
const scale = initialRightWidth > 0 ? currentRightWidth / initialRightWidth : 1;
const rightOffset = position.x - initialDividerX;
adjustedX = currentDividerX + GAP + rightOffset * scale;
adjustedW = origW * scale;
if (adjustedX < currentDividerX + GAP) adjustedX = currentDividerX + GAP;
if (adjustedX + adjustedW > canvasWidth) adjustedW = canvasWidth - adjustedX;
}
adjustedX = Math.max(0, adjustedX);
adjustedW = Math.max(20, adjustedW);
return { adjustedPositionX: adjustedX, adjustedWidth: adjustedW, isOnSplitPanel: true, isDraggingSplitPanel: splitDragging };
}
// === 2. 레거시 분할 패널 (Context) - 버튼 전용 ===
const isSplitPanelComponent =
type === "component" && ["split-panel-layout", "split-panel-layout2"].includes(componentType);
if (!isButtonComponent || isSplitPanelComponent) {
return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false };
if (isSplitPanelComponent) {
return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false };
}
if (!isButtonComponent) {
return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false };
}
const componentWidth = size?.width || 100;
const componentHeight = size?.height || 40;
// 분할 패널 위에 있는지 확인 (원래 위치 기준)
const overlap = splitPanelContext.getOverlappingSplitPanel(position.x, position.y, componentWidth, componentHeight);
// 분할 패널 위에 없으면 기준점 초기화
if (!overlap) {
if (initialPanelIdRef.current !== null) {
initialPanelRatioRef.current = null;
initialPanelIdRef.current = null;
isInLeftPanelRef.current = null;
}
return {
adjustedPositionX: position.x,
isOnSplitPanel: false,
isDraggingSplitPanel: false,
};
return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false };
}
const { panel } = overlap;
// 🆕 초기 기준 비율 및 좌측 패널 소속 여부 설정 (처음 한 번만)
if (initialPanelIdRef.current !== overlap.panelId) {
initialPanelRatioRef.current = panel.leftWidthPercent;
initialPanelRatioRef.current = panel.initialLeftWidthPercent;
initialPanelIdRef.current = overlap.panelId;
// 초기 배치 시 좌측 패널에 있는지 확인 (초기 비율 기준으로 계산)
// 현재 비율이 아닌, 버튼 원래 위치가 초기 좌측 패널 영역 안에 있었는지 판단
const initialLeftPanelWidth = (panel.width * panel.leftWidthPercent) / 100;
const initialDividerX = panel.x + (panel.width * panel.initialLeftWidthPercent) / 100;
const componentCenterX = position.x + componentWidth / 2;
const relativeX = componentCenterX - panel.x;
const wasInLeftPanel = relativeX < initialLeftPanelWidth;
isInLeftPanelRef.current = wasInLeftPanel;
console.log("📌 [버튼 기준점 설정]:", {
componentId: component.id,
panelId: overlap.panelId,
initialRatio: panel.leftWidthPercent,
isInLeftPanel: wasInLeftPanel,
buttonCenterX: componentCenterX,
leftPanelWidth: initialLeftPanelWidth,
});
isInLeftPanelRef.current = componentCenterX < initialDividerX;
}
// 좌측 패널 소속이 아니면 조정하지 않음 (초기 배치 기준)
if (!isInLeftPanelRef.current) {
return {
adjustedPositionX: position.x,
isOnSplitPanel: true,
isDraggingSplitPanel: panel.isDragging,
};
}
const baseRatio = initialPanelRatioRef.current ?? panel.initialLeftWidthPercent;
const initialDividerX = panel.x + (panel.width * baseRatio) / 100;
const currentDividerX = panel.x + (panel.width * panel.leftWidthPercent) / 100;
const dividerDelta = currentDividerX - initialDividerX;
// 초기 기준 비율 (버튼이 처음 배치될 때의 비율)
const baseRatio = initialPanelRatioRef.current ?? panel.leftWidthPercent;
// 기준 비율 대비 현재 비율로 분할선 위치 계산
const baseDividerX = panel.x + (panel.width * baseRatio) / 100; // 초기 분할선 위치
const currentDividerX = panel.x + (panel.width * panel.leftWidthPercent) / 100; // 현재 분할선 위치
// 분할선 이동량 (px)
const dividerDelta = currentDividerX - baseDividerX;
// 변화가 없으면 원래 위치 반환
if (Math.abs(dividerDelta) < 1) {
return {
adjustedPositionX: position.x,
isOnSplitPanel: true,
isDraggingSplitPanel: panel.isDragging,
};
return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: true, isDraggingSplitPanel: panel.isDragging };
}
// 🆕 버튼도 분할선과 같은 양만큼 이동
// 분할선이 왼쪽으로 100px 이동하면, 버튼도 왼쪽으로 100px 이동
const adjustedX = position.x + dividerDelta;
console.log("📍 [버튼 위치 조정]:", {
componentId: component.id,
originalX: position.x,
adjustedX,
dividerDelta,
baseRatio,
currentRatio: panel.leftWidthPercent,
baseDividerX,
currentDividerX,
isDragging: panel.isDragging,
});
const adjustedX = isInLeftPanelRef.current ? position.x + dividerDelta : position.x;
return {
adjustedPositionX: adjustedX,
adjustedWidth: null,
isOnSplitPanel: true,
isDraggingSplitPanel: panel.isDragging,
};
};
const { adjustedPositionX, isOnSplitPanel, isDraggingSplitPanel } = calculateButtonPosition();
const { adjustedPositionX, adjustedWidth: splitAdjustedWidth, isOnSplitPanel, isDraggingSplitPanel } = calculateSplitAdjustedPosition();
// 🆕 리사이즈 크기가 있으면 우선 사용
// (size가 업데이트되면 위 useEffect에서 resizeSize를 null로 설정)
const displayWidth = resizeSize ? `${resizeSize.width}px` : getWidth();
const displayHeight = resizeSize ? `${resizeSize.height}px` : getHeight();
const isSplitActive = canvasSplit.active && canvasSplit.scopeId && myScopeIdRef.current === canvasSplit.scopeId;
const origWidth = size?.width || 100;
const isSplitShrunk = splitAdjustedWidth !== null && splitAdjustedWidth < origWidth;
// v2 수평 라벨 컴포넌트: position wrapper에서 border 제거 (DynamicComponentRenderer가 내부에서 처리)
const isV2HorizLabel = !!(
componentStyle &&
(componentStyle.labelDisplay === true || componentStyle.labelDisplay === "true") &&
(componentStyle.labelPosition === "left" || componentStyle.labelPosition === "right")
);
const safeComponentStyle = isV2HorizLabel
? (() => {
const { borderWidth, borderColor, borderStyle, border, borderRadius, ...rest } = componentStyle as any;
return rest;
})()
: componentStyle;
const baseStyle = {
left: `${adjustedPositionX}px`, // 🆕 조정된 X 좌표 사용
left: `${adjustedPositionX}px`,
top: `${position.y}px`,
...componentStyle, // componentStyle 전체 적용 (DynamicComponentRenderer에서 이미 size가 변환됨)
width: displayWidth, // 🆕 리사이즈 중이면 resizeSize 사용
height: displayHeight, // 🆕 리사이즈 중이면 resizeSize 사용
...safeComponentStyle,
width: splitAdjustedWidth !== null ? `${splitAdjustedWidth}px` : displayWidth,
height: displayHeight,
zIndex: component.type === "layout" ? 1 : position.z || 2,
right: undefined,
// 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동, 리사이즈 중에도 트랜지션 없음
overflow: isSplitShrunk ? "hidden" as const : undefined,
willChange: canvasSplit.isDragging && isSplitActive ? "left, width" as const : undefined,
transition:
isResizing ? "none" :
isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined,
isOnSplitPanel ? (isDraggingSplitPanel ? "none" : "left 0.15s ease-out, width 0.15s ease-out") : undefined,
};
// 크기 정보는 필요시에만 디버깅 (개발 중 문제 발생 시 주석 해제)
@ -576,6 +617,60 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
onDragEnd?.();
};
const splitAdjustedComp = React.useMemo(() => {
if (isSplitShrunk && splitAdjustedWidth !== null) {
return { ...enhancedComponent, size: { ...(enhancedComponent as any).size, width: Math.round(splitAdjustedWidth) } };
}
return enhancedComponent;
}, [enhancedComponent, isSplitShrunk, splitAdjustedWidth]);
// 드래그 중 DOM 직접 조작 (React 리렌더 없이 매 프레임 업데이트)
React.useEffect(() => {
const isSplitLine = type === "component" && componentType === "v2-split-line";
if (isSplitLine) return;
const unsubscribe = canvasSplitSubscribeDom((snap) => {
if (!snap.isDragging || !snap.active || !snap.scopeId) return;
if (myScopeIdRef.current !== snap.scopeId) return;
const el = outerDivRef.current;
if (!el) return;
const origX = position.x;
const oW = size?.width || 100;
const { initialDividerX, currentDividerX, canvasWidth } = snap;
const delta = currentDividerX - initialDividerX;
if (Math.abs(delta) < 1) return;
if (canvasSplitSideRef.current === null) {
canvasSplitSideRef.current = (origX + oW / 2) < initialDividerX ? "left" : "right";
}
const GAP = 4;
let nx: number, nw: number;
if (canvasSplitSideRef.current === "left") {
const scale = initialDividerX > 0 ? Math.max(20, currentDividerX - GAP) / initialDividerX : 1;
nx = origX * scale;
nw = oW * scale;
if (nx + nw > currentDividerX - GAP) nw = currentDividerX - GAP - nx;
} else {
const irw = canvasWidth - initialDividerX;
const crw = Math.max(20, canvasWidth - currentDividerX - GAP);
const scale = irw > 0 ? crw / irw : 1;
nx = currentDividerX + GAP + (origX - initialDividerX) * scale;
nw = oW * scale;
if (nx < currentDividerX + GAP) nx = currentDividerX + GAP;
if (nx + nw > canvasWidth) nw = canvasWidth - nx;
}
nx = Math.max(0, nx);
nw = Math.max(20, nw);
el.style.left = `${nx}px`;
el.style.width = `${Math.round(nw)}px`;
el.style.overflow = nw < oW ? "hidden" : "";
});
return unsubscribe;
}, [id, position.x, size?.width, type, componentType]);
return (
<div
ref={outerDivRef}
@ -602,7 +697,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
style={{ width: "100%", maxWidth: "100%" }}
>
<DynamicComponentRenderer
component={enhancedComponent}
component={splitAdjustedComp}
isSelected={isSelected}
isDesignMode={isDesignMode}
isInteractive={!isDesignMode} // 편집 모드가 아닐 때만 인터랙티브

View File

@ -2,6 +2,7 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { Database, Cog } from "lucide-react";
import { cn } from "@/lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import {
@ -511,25 +512,114 @@ export default function ScreenDesigner({
return lines;
}, [layout.gridSettings?.showGrid, screenResolution.width, screenResolution.height]);
// 🆕 레이어 활성 상태 관리 (LayerProvider 외부에서 관리)
const [activeLayerId, setActiveLayerIdLocal] = useState<string | null>("default-layer");
// 🆕 현재 편집 중인 레이어 ID (DB의 layer_id, 1 = 기본 레이어)
const [activeLayerId, setActiveLayerIdLocal] = useState<number>(1);
const activeLayerIdRef = useRef<number>(1);
const setActiveLayerIdWithRef = useCallback((id: number) => {
setActiveLayerIdLocal(id);
activeLayerIdRef.current = id;
}, []);
// 캔버스에 렌더링할 컴포넌트 필터링 (레이어 기반)
// 활성 레이어가 있으면 해당 레이어의 컴포넌트만 표시
// layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리
const visibleComponents = useMemo(() => {
// 레이어 시스템이 활성화되지 않았거나 활성 레이어가 없으면 모든 컴포넌트 표시
if (!activeLayerId) {
return layout.components;
// 🆕 좌측 패널 탭 상태 관리
const [leftPanelTab, setLeftPanelTab] = useState<string>("components");
// 🆕 조건부 영역(Zone) 목록 (DB screen_conditional_zones 기반)
const [zones, setZones] = useState<import("@/types/screen-management").ConditionalZone[]>([]);
// 🆕 조건부 영역 드래그 상태 (캔버스에서 드래그로 영역 설정)
const [regionDrag, setRegionDrag] = useState<{
isDrawing: boolean; // 새 영역 그리기 모드
isDragging: boolean; // 기존 영역 이동 모드
isResizing: boolean; // 기존 영역 리사이즈 모드
targetLayerId: string | null; // 대상 Zone ID (문자열)
startX: number;
startY: number;
currentX: number;
currentY: number;
resizeHandle: string | null; // 리사이즈 핸들 위치
originalRegion: { x: number; y: number; width: number; height: number } | null;
}>({
isDrawing: false,
isDragging: false,
isResizing: false,
targetLayerId: null,
startX: 0,
startY: 0,
currentX: 0,
currentY: 0,
resizeHandle: null,
originalRegion: null,
});
// 현재 활성 레이어의 Zone 정보 (캔버스 크기 결정용)
const [activeLayerZone, setActiveLayerZone] = useState<import("@/types/screen-management").ConditionalZone | null>(null);
// 다른 레이어의 컴포넌트 메타 정보 캐시 (데이터 전달 타겟 선택용)
const [otherLayerComponents, setOtherLayerComponents] = useState<ComponentData[]>([]);
// 🆕 activeLayerId 변경 시 해당 레이어의 Zone 찾기
useEffect(() => {
if (activeLayerId <= 1 || !selectedScreen?.screenId) {
setActiveLayerZone(null);
return;
}
// 레이어의 condition_config에서 zone_id를 가져와서 zones에서 찾기
const findZone = async () => {
try {
const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, activeLayerId);
const zoneId = layerData?.conditionConfig?.zone_id;
if (zoneId) {
const zone = zones.find(z => z.zone_id === zoneId);
setActiveLayerZone(zone || null);
} else {
setActiveLayerZone(null);
}
} catch {
setActiveLayerZone(null);
}
};
findZone();
}, [activeLayerId, selectedScreen?.screenId, zones]);
// 활성 레이어에 속한 컴포넌트만 필터링
return layout.components.filter((comp) => {
// layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리
const compLayerId = comp.layerId || "default-layer";
return compLayerId === activeLayerId;
});
}, [layout.components, activeLayerId]);
// 다른 레이어의 컴포넌트 메타 정보 로드 (데이터 전달 타겟 선택용)
useEffect(() => {
if (!selectedScreen?.screenId) return;
const loadOtherLayerComponents = async () => {
try {
const allLayers = await screenApi.getScreenLayers(selectedScreen.screenId);
const currentLayerId = activeLayerIdRef.current || 1;
const otherLayers = allLayers.filter((l: any) => l.layer_id !== currentLayerId && l.layer_id > 0);
const components: ComponentData[] = [];
for (const layerInfo of otherLayers) {
try {
const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, layerInfo.layer_id);
const rawComps = layerData?.components;
if (rawComps && Array.isArray(rawComps)) {
for (const comp of rawComps) {
components.push({
...comp,
_layerName: layerInfo.layer_name || `레이어 ${layerInfo.layer_id}`,
_layerId: String(layerInfo.layer_id),
} as any);
}
}
} catch {
// 개별 레이어 로드 실패 무시
}
}
setOtherLayerComponents(components);
} catch {
setOtherLayerComponents([]);
}
};
loadOtherLayerComponents();
}, [selectedScreen?.screenId, activeLayerId]);
// 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시)
const visibleComponents = useMemo(() => {
return layout.components;
}, [layout.components]);
// 이미 배치된 컬럼 목록 계산
const placedColumns = useMemo(() => {
@ -1549,6 +1639,12 @@ export default function ScreenDesigner({
// 파일 컴포넌트 데이터 복원 (비동기)
restoreFileComponentsData(layoutWithDefaultGrid.components);
// 🆕 조건부 영역(Zone) 로드
try {
const loadedZones = await screenApi.getScreenZones(selectedScreen.screenId);
setZones(loadedZones);
} catch { /* Zone 로드 실패 무시 */ }
}
} catch (error) {
// console.error("레이아웃 로드 실패:", error);
@ -1888,17 +1984,33 @@ export default function ScreenDesigner({
[groupState.selectedComponents, layout, selectedComponent?.id, saveToHistory]
);
// 라벨 일괄 토글
// 라벨 일괄 토글 (선택된 컴포넌트가 있으면 선택된 것만, 없으면 전체)
const handleToggleAllLabels = useCallback(() => {
saveToHistory(layout);
const newComponents = toggleAllLabels(layout.components);
const selectedIds = groupState.selectedComponents;
const isPartial = selectedIds.length > 0;
// 토글 대상 컴포넌트 필터
const targetComponents = layout.components.filter((c) => {
if (!c.label || ["group", "datatable"].includes(c.type)) return false;
if (isPartial) return selectedIds.includes(c.id);
return true;
});
const hadHidden = targetComponents.some(
(c) => (c.style as any)?.labelDisplay === false
);
const newComponents = toggleAllLabels(layout.components, selectedIds);
setLayout((prev) => ({ ...prev, components: newComponents }));
const hasHidden = layout.components.some(
(c) => c.type === "widget" && (c.style as any)?.labelDisplay === false
);
toast.success(hasHidden ? "모든 라벨 표시" : "모든 라벨 숨기기");
}, [layout, saveToHistory]);
// 강제 리렌더링 트리거
setForceRenderTrigger((prev) => prev + 1);
const scope = isPartial ? `선택된 ${targetComponents.length}` : "모든";
toast.success(hadHidden ? `${scope} 라벨 표시` : `${scope} 라벨 숨기기`);
}, [layout, saveToHistory, groupState.selectedComponents]);
// Nudge (화살표 키 이동)
const handleNudge = useCallback(
@ -1970,30 +2082,12 @@ export default function ScreenDesigner({
// 현재 선택된 테이블을 화면의 기본 테이블로 저장
const currentMainTableName = tables.length > 0 ? tables[0].tableName : null;
// 🆕 레이어 정보도 함께 저장 (레이어가 있으면 레이어의 컴포넌트로 업데이트)
const updatedLayers = layout.layers?.map((layer) => ({
...layer,
components: layer.components.map((comp) => {
// 분할 패널 업데이트 로직 적용
const updatedComp = updatedComponents.find((uc) => uc.id === comp.id);
return updatedComp || comp;
}),
}));
const layoutWithResolution = {
...layout,
components: updatedComponents,
layers: updatedLayers, // 🆕 레이어 정보 포함
screenResolution: screenResolution,
mainTableName: currentMainTableName, // 화면의 기본 테이블
};
// 🔍 버튼 컴포넌트들의 action.type 확인
const buttonComponents = layoutWithResolution.components.filter(
(c: any) => c.componentType?.startsWith("button") || c.type === "button" || c.type === "button-primary",
);
// 💾 저장 로그 (디버그 완료 - 간소화)
// console.log("💾 저장 시작:", { screenId: selectedScreen.screenId, componentsCount: layoutWithResolution.components.length });
// 분할 패널 디버그 로그 (주석 처리)
// V2/POP API 사용 여부에 따라 분기
const v2Layout = convertLegacyToV2(layoutWithResolution);
@ -2001,9 +2095,13 @@ export default function ScreenDesigner({
// POP 모드: screen_layouts_pop 테이블에 저장
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
} else if (USE_V2_API) {
// 데스크톱 V2 모드: screen_layouts_v2 테이블에 저장
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
// console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트");
// 레이어 기반 저장: 현재 활성 레이어의 layout만 저장
const currentLayerId = activeLayerIdRef.current || 1;
await screenApi.saveLayoutV2(selectedScreen.screenId, {
...v2Layout,
layerId: currentLayerId,
mainTableName: currentMainTableName, // 화면의 기본 테이블 (DB 업데이트용)
});
} else {
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
}
@ -2120,7 +2218,12 @@ export default function ScreenDesigner({
if (USE_POP_API) {
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
} else if (USE_V2_API) {
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
// 현재 활성 레이어 ID 포함 (레이어별 저장)
const currentLayerId = activeLayerIdRef.current || 1;
await screenApi.saveLayoutV2(selectedScreen.screenId, {
...v2Layout,
layerId: currentLayerId,
});
} else {
await screenApi.saveLayout(selectedScreen.screenId, updatedLayout);
}
@ -2539,10 +2642,10 @@ export default function ScreenDesigner({
}
});
// 🆕 현재 활성 레이어에 컴포넌트 추가
// 🆕 현재 활성 레이어에 컴포넌트 추가 (ref 사용으로 클로저 문제 방지)
const componentsWithLayerId = newComponents.map((comp) => ({
...comp,
layerId: activeLayerId || "default-layer",
layerId: activeLayerIdRef.current || 1,
}));
// 레이아웃에 새 컴포넌트들 추가
@ -2561,7 +2664,7 @@ export default function ScreenDesigner({
toast.success(`${template.name} 템플릿이 추가되었습니다.`);
},
[layout, selectedScreen, saveToHistory, activeLayerId],
[layout, selectedScreen, saveToHistory],
);
// 레이아웃 드래그 처리
@ -2615,7 +2718,7 @@ export default function ScreenDesigner({
label: layoutData.label,
allowedComponentTypes: layoutData.allowedComponentTypes,
dropZoneConfig: layoutData.dropZoneConfig,
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
} as ComponentData;
// 레이아웃에 새 컴포넌트 추가
@ -2632,7 +2735,7 @@ export default function ScreenDesigner({
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
},
[layout, screenResolution, saveToHistory, zoomLevel, activeLayerId],
[layout, screenResolution, saveToHistory, zoomLevel],
);
// handleZoneComponentDrop은 handleComponentDrop으로 대체됨
@ -3024,9 +3127,13 @@ export default function ScreenDesigner({
})
: null;
// 캔버스 경계 내로 위치 제한
const boundedX = Math.max(0, Math.min(dropX, screenResolution.width - componentWidth));
const boundedY = Math.max(0, Math.min(dropY, screenResolution.height - componentHeight));
// 캔버스 경계 내로 위치 제한 (조건부 레이어 편집 시 Zone 크기 기준)
const currentLayerId = activeLayerIdRef.current || 1;
const activeLayerRegion = currentLayerId > 1 ? activeLayerZone : null;
const canvasBoundW = activeLayerRegion ? activeLayerRegion.width : screenResolution.width;
const canvasBoundH = activeLayerRegion ? activeLayerRegion.height : screenResolution.height;
const boundedX = Math.max(0, Math.min(dropX, canvasBoundW - componentWidth));
const boundedY = Math.max(0, Math.min(dropY, canvasBoundH - componentHeight));
// 격자 스냅 적용
const snappedPosition =
@ -3223,7 +3330,7 @@ export default function ScreenDesigner({
position: snappedPosition,
size: componentSize,
gridColumns: gridColumns, // 컴포넌트별 그리드 컬럼 수 적용
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
componentConfig: {
type: component.id, // 새 컴포넌트 시스템의 ID 사용
webType: component.webType, // 웹타입 정보 추가
@ -3257,7 +3364,7 @@ export default function ScreenDesigner({
toast.success(`${component.name} 컴포넌트가 추가되었습니다.`);
},
[layout, selectedScreen, saveToHistory, activeLayerId],
[layout, selectedScreen, saveToHistory],
);
// 드래그 앤 드롭 처리
@ -3266,7 +3373,7 @@ export default function ScreenDesigner({
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
async (e: React.DragEvent) => {
e.preventDefault();
const dragData = e.dataTransfer.getData("application/json");
@ -3298,6 +3405,31 @@ export default function ScreenDesigner({
return;
}
// 🆕 조건부 영역(Zone) 생성 드래그인 경우 → DB screen_conditional_zones에 저장
if (parsedData.type === "create-zone" && selectedScreen?.screenId) {
const canvasRect = canvasRef.current?.getBoundingClientRect();
if (!canvasRect) return;
const dropX = Math.round((e.clientX - canvasRect.left) / zoomLevel);
const dropY = Math.round((e.clientY - canvasRect.top) / zoomLevel);
try {
await screenApi.createZone(selectedScreen.screenId, {
zone_name: "조건부 영역",
x: Math.max(0, dropX - 400),
y: Math.max(0, dropY),
width: Math.min(800, screenResolution.width),
height: 200,
});
// Zone 목록 새로고침
const loadedZones = await screenApi.getScreenZones(selectedScreen.screenId);
setZones(loadedZones);
toast.success("조건부 영역이 생성되었습니다.");
} catch (error) {
console.error("Zone 생성 실패:", error);
toast.error("조건부 영역 생성에 실패했습니다.");
}
return;
}
// 기존 테이블/컬럼 드래그 처리
const { type, table, column } = parsedData;
@ -3629,7 +3761,7 @@ export default function ScreenDesigner({
tableName: table.tableName,
position: { x, y, z: 1 } as Position,
size: { width: 300, height: 200 },
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
style: {
labelDisplay: true,
labelFontSize: "14px",
@ -3874,13 +4006,13 @@ export default function ScreenDesigner({
label: column.columnLabel || column.columnName,
tableName: table.tableName,
columnName: column.columnName,
required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님
readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용
parentId: formContainerId, // 폼 컨테이너의 자식으로 설정
componentType: v2Mapping.componentType, // v2-input, v2-select 등
required: isEntityJoinColumn ? false : column.required,
readonly: false,
parentId: formContainerId,
componentType: v2Mapping.componentType,
position: { x: relativeX, y: relativeY, z: 1 } as Position,
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
@ -3901,12 +4033,11 @@ export default function ScreenDesigner({
},
componentConfig: {
type: v2Mapping.componentType, // v2-input, v2-select 등
...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정
...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화
...v2Mapping.componentConfig,
},
};
} else {
return; // 폼 컨테이너를 찾을 수 없으면 드롭 취소
return;
}
} else {
// 일반 캔버스에 드롭한 경우 - 🆕 V2 컴포넌트 시스템 사용
@ -3942,12 +4073,12 @@ export default function ScreenDesigner({
label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명
tableName: table.tableName,
columnName: column.columnName,
required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님
readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용
componentType: v2Mapping.componentType, // v2-input, v2-select 등
required: isEntityJoinColumn ? false : column.required,
readonly: false,
componentType: v2Mapping.componentType,
position: { x, y, z: 1 } as Position,
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
@ -3968,8 +4099,7 @@ export default function ScreenDesigner({
},
componentConfig: {
type: v2Mapping.componentType, // v2-input, v2-select 등
...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정
...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화
...v2Mapping.componentConfig,
},
};
}
@ -4205,9 +4335,15 @@ export default function ScreenDesigner({
const rawX = relativeMouseX - dragState.grabOffset.x;
const rawY = relativeMouseY - dragState.grabOffset.y;
// 조건부 레이어 편집 시 Zone 크기 기준 경계 제한
const dragLayerId = activeLayerIdRef.current || 1;
const dragLayerRegion = dragLayerId > 1 ? activeLayerZone : null;
const dragBoundW = dragLayerRegion ? dragLayerRegion.width : screenResolution.width;
const dragBoundH = dragLayerRegion ? dragLayerRegion.height : screenResolution.height;
const newPosition = {
x: Math.max(0, Math.min(rawX, screenResolution.width - componentWidth)),
y: Math.max(0, Math.min(rawY, screenResolution.height - componentHeight)),
x: Math.max(0, Math.min(rawX, dragBoundW - componentWidth)),
y: Math.max(0, Math.min(rawY, dragBoundH - componentHeight)),
z: (dragState.draggedComponent.position as Position).z || 1,
};
@ -4770,7 +4906,7 @@ export default function ScreenDesigner({
z: clipComponent.position.z || 1,
} as Position,
parentId: undefined, // 붙여넣기 시 부모 관계 해제
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 붙여넣기
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 붙여넣기 (ref 사용)
};
newComponents.push(newComponent);
});
@ -4791,7 +4927,7 @@ export default function ScreenDesigner({
// console.log("컴포넌트 붙여넣기 완료:", newComponents.length, "개");
toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`);
}, [clipboard, layout, saveToHistory, activeLayerId]);
}, [clipboard, layout, saveToHistory]);
// 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로)
// 🆕 플로우 버튼 그룹 다이얼로그 상태
@ -5456,8 +5592,12 @@ export default function ScreenDesigner({
return false;
}
// 6. 삭제 (단일/다중 선택 지원)
if (e.key === "Delete" && (selectedComponent || groupState.selectedComponents.length > 0)) {
// 6. 삭제 (단일/다중 선택 지원) - Delete 또는 Backspace(Mac)
const isInputFocused = document.activeElement instanceof HTMLInputElement ||
document.activeElement instanceof HTMLTextAreaElement ||
document.activeElement instanceof HTMLSelectElement ||
(document.activeElement as HTMLElement)?.isContentEditable;
if ((e.key === "Delete" || (e.key === "Backspace" && !isInputFocused)) && (selectedComponent || groupState.selectedComponents.length > 0)) {
// console.log("🗑️ 컴포넌트 삭제 (단축키)");
e.preventDefault();
e.stopPropagation();
@ -5500,7 +5640,12 @@ export default function ScreenDesigner({
if (USE_POP_API) {
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
} else if (USE_V2_API) {
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
// 현재 활성 레이어 ID 포함 (레이어별 저장)
const currentLayerId = activeLayerIdRef.current || 1;
await screenApi.saveLayoutV2(selectedScreen.screenId, {
...v2Layout,
layerId: currentLayerId,
});
} else {
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
}
@ -5693,21 +5838,124 @@ export default function ScreenDesigner({
};
}, [layout, selectedComponent]);
// 🆕 조건부 영역 드래그 핸들러 (이동/리사이즈, DB 기반)
const handleRegionMouseDown = useCallback((
e: React.MouseEvent,
layerId: string,
mode: "move" | "resize",
handle?: string,
) => {
e.stopPropagation();
e.preventDefault();
const zoneId = Number(layerId); // layerId는 실제로 zoneId
const zone = zones.find(z => z.zone_id === zoneId);
if (!zone) return;
const canvasRect = canvasRef.current?.getBoundingClientRect();
if (!canvasRect) return;
const x = (e.clientX - canvasRect.left) / zoomLevel;
const y = (e.clientY - canvasRect.top) / zoomLevel;
setRegionDrag({
isDrawing: false,
isDragging: mode === "move",
isResizing: mode === "resize",
targetLayerId: String(zoneId),
startX: x,
startY: y,
currentX: x,
currentY: y,
resizeHandle: handle || null,
originalRegion: { x: zone.x, y: zone.y, width: zone.width, height: zone.height },
});
}, [zones, zoomLevel]);
// 🆕 캔버스 마우스 이벤트 (영역 이동/리사이즈)
const handleRegionCanvasMouseMove = useCallback((e: React.MouseEvent) => {
if (!regionDrag.isDragging && !regionDrag.isResizing) return;
if (!regionDrag.targetLayerId) return;
const canvasRect = canvasRef.current?.getBoundingClientRect();
if (!canvasRect) return;
const x = (e.clientX - canvasRect.left) / zoomLevel;
const y = (e.clientY - canvasRect.top) / zoomLevel;
if (regionDrag.isDragging && regionDrag.originalRegion) {
const dx = x - regionDrag.startX;
const dy = y - regionDrag.startY;
const newRegion = {
x: Math.max(0, Math.round(regionDrag.originalRegion.x + dx)),
y: Math.max(0, Math.round(regionDrag.originalRegion.y + dy)),
width: regionDrag.originalRegion.width,
height: regionDrag.originalRegion.height,
};
const zoneId = Number(regionDrag.targetLayerId);
setZones((prev) => prev.map(z => z.zone_id === zoneId ? { ...z, ...newRegion } : z));
} else if (regionDrag.isResizing && regionDrag.originalRegion) {
const dx = x - regionDrag.startX;
const dy = y - regionDrag.startY;
const orig = regionDrag.originalRegion;
const newRegion = { ...orig };
const handle = regionDrag.resizeHandle;
if (handle?.includes("e")) newRegion.width = Math.max(50, Math.round(orig.width + dx));
if (handle?.includes("s")) newRegion.height = Math.max(30, Math.round(orig.height + dy));
if (handle?.includes("w")) {
newRegion.x = Math.max(0, Math.round(orig.x + dx));
newRegion.width = Math.max(50, Math.round(orig.width - dx));
}
if (handle?.includes("n")) {
newRegion.y = Math.max(0, Math.round(orig.y + dy));
newRegion.height = Math.max(30, Math.round(orig.height - dy));
}
const zoneId = Number(regionDrag.targetLayerId);
setZones((prev) => prev.map(z => z.zone_id === zoneId ? { ...z, ...newRegion } : z));
}
}, [regionDrag, zoomLevel]);
const handleRegionCanvasMouseUp = useCallback(async () => {
// 드래그 완료 시 DB에 Zone 저장
if ((regionDrag.isDragging || regionDrag.isResizing) && regionDrag.targetLayerId) {
const zoneId = Number(regionDrag.targetLayerId);
const zone = zones.find(z => z.zone_id === zoneId);
if (zone) {
try {
await screenApi.updateZone(zoneId, {
x: zone.x, y: zone.y, width: zone.width, height: zone.height,
});
} catch {
console.error("Zone 저장 실패");
}
}
}
// 드래그 상태 초기화
setRegionDrag({
isDrawing: false,
isDragging: false,
isResizing: false,
targetLayerId: null,
startX: 0, startY: 0, currentX: 0, currentY: 0,
resizeHandle: null,
originalRegion: null,
});
}, [regionDrag, zones]);
// 🆕 레이어 변경 핸들러 - 레이어 컨텍스트에서 레이어가 변경되면 layout에도 반영
// 주의: layout.components는 layerId 속성으로 레이어를 구분하므로, 여기서 덮어쓰지 않음
// Zone 기반이므로 displayRegion 보존 불필요
const handleLayersChange = useCallback((newLayers: LayerDefinition[]) => {
setLayout((prevLayout) => ({
...prevLayout,
layers: newLayers,
// components는 그대로 유지 - layerId 속성으로 레이어 구분
// components: prevLayout.components (기본값으로 유지됨)
}));
}, []);
// 🆕 활성 레이어 변경 핸들러
const handleActiveLayerChange = useCallback((newActiveLayerId: string | null) => {
setActiveLayerIdLocal(newActiveLayerId);
}, []);
const handleActiveLayerChange = useCallback((newActiveLayerId: number) => {
setActiveLayerIdWithRef(newActiveLayerId);
}, [setActiveLayerIdWithRef]);
// 🆕 초기 레이어 계산 - layout에서 layers가 있으면 사용, 없으면 기본 레이어 생성
// 주의: components는 layout.components에 layerId 속성으로 저장되므로, layer.components는 비워둠
@ -5788,7 +6036,7 @@ export default function ScreenDesigner({
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<Tabs defaultValue="components" className="flex min-h-0 flex-1 flex-col">
<Tabs value={leftPanelTab} onValueChange={setLeftPanelTab} className="flex min-h-0 flex-1 flex-col">
<TabsList className="mx-4 mt-2 grid h-8 w-auto grid-cols-3 gap-1">
<TabsTrigger value="components" className="text-xs">
@ -5821,9 +6069,43 @@ export default function ScreenDesigner({
/>
</TabsContent>
{/* 🆕 레이어 관리 탭 */}
{/* 🆕 레이어 관리 탭 (DB 기반) */}
<TabsContent value="layers" className="mt-0 flex-1 overflow-hidden">
<LayerManagerPanel components={layout.components} />
<LayerManagerPanel
screenId={selectedScreen?.screenId || null}
activeLayerId={Number(activeLayerIdRef.current) || 1}
onLayerChange={async (layerId) => {
if (!selectedScreen?.screenId) return;
try {
// 1. 현재 레이어 저장
const curId = Number(activeLayerIdRef.current) || 1;
const v2Layout = convertLegacyToV2({ ...layout, screenResolution });
await screenApi.saveLayoutV2(selectedScreen.screenId, { ...v2Layout, layerId: curId });
// 2. 새 레이어 로드
const data = await screenApi.getLayerLayout(selectedScreen.screenId, layerId);
if (data && data.components) {
const legacy = convertV2ToLegacy(data);
if (legacy) {
setLayout((prev) => ({ ...prev, components: legacy.components }));
} else {
setLayout((prev) => ({ ...prev, components: [] }));
}
} else {
setLayout((prev) => ({ ...prev, components: [] }));
}
setActiveLayerIdWithRef(layerId);
setSelectedComponent(null);
} catch (error) {
console.error("레이어 전환 실패:", error);
toast.error("레이어 전환에 실패했습니다.");
}
}}
components={layout.components}
zones={zones}
onZonesChange={setZones}
/>
</TabsContent>
<TabsContent value="properties" className="mt-0 flex-1 overflow-hidden">
@ -6272,8 +6554,8 @@ export default function ScreenDesigner({
updateComponentProperty(selectedComponent.id, "style", style);
}
}}
allComponents={layout.components} // 🆕 플로우 위젯 감지용
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
allComponents={[...layout.components, ...otherLayerComponents]}
menuObjid={menuObjid}
/>
)}
</TabsContent>
@ -6396,24 +6678,54 @@ export default function ScreenDesigner({
</div>
);
})()}
{/* 🆕 활성 레이어 인디케이터 (기본 레이어가 아닌 경우 표시) */}
{activeLayerId > 1 && (
<div className="sticky top-0 z-30 flex items-center justify-center gap-2 border-b bg-amber-50 px-4 py-1.5 backdrop-blur-sm dark:bg-amber-950/30">
<div className="h-2 w-2 rounded-full bg-amber-500" />
<span className="text-xs font-medium">
{activeLayerId}
{activeLayerZone && (
<span className="ml-2 text-amber-600">
(: {activeLayerZone.width} x {activeLayerZone.height}px - {activeLayerZone.zone_name})
</span>
)}
{!activeLayerZone && (
<span className="ml-2 text-red-500">
( - Zone을 )
</span>
)}
</span>
</div>
)}
{/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */}
{(() => {
// 🆕 조건부 레이어 편집 시 캔버스 크기를 Zone에 맞춤
const activeRegion = activeLayerId > 1 ? activeLayerZone : null;
const canvasW = activeRegion ? activeRegion.width : screenResolution.width;
const canvasH = activeRegion ? activeRegion.height : screenResolution.height;
return (
<div
className="flex justify-center"
style={{
width: "100%",
minHeight: screenResolution.height * zoomLevel,
minHeight: canvasH * zoomLevel,
contain: "layout style", // 레이아웃 재계산 범위 제한
}}
>
{/* 실제 작업 캔버스 (해상도 크기) - 고정 크기 + 줌 적용 */}
{/* 실제 작업 캔버스 (해상도 크기 또는 조건부 레이어 영역 크기) */}
<div
className="bg-background border-border border shadow-lg"
className={cn(
"bg-background border shadow-lg",
activeRegion ? "border-amber-400 border-2" : "border-border"
)}
style={{
width: `${screenResolution.width}px`,
height: `${screenResolution.height}px`,
minWidth: `${screenResolution.width}px`,
maxWidth: `${screenResolution.width}px`,
minHeight: `${screenResolution.height}px`,
width: `${canvasW}px`,
height: `${canvasH}px`,
minWidth: `${canvasW}px`,
maxWidth: `${canvasW}px`,
minHeight: `${canvasH}px`,
flexShrink: 0,
transform: `scale3d(${zoomLevel}, ${zoomLevel}, 1)`,
transformOrigin: "top center", // 중앙 기준으로 스케일
@ -6436,6 +6748,22 @@ export default function ScreenDesigner({
startSelectionDrag(e);
}
}}
onMouseMove={(e) => {
// 영역 이동/리사이즈 처리
if (regionDrag.isDragging || regionDrag.isResizing) {
handleRegionCanvasMouseMove(e);
}
}}
onMouseUp={() => {
if (regionDrag.isDragging || regionDrag.isResizing) {
handleRegionCanvasMouseUp();
}
}}
onMouseLeave={() => {
if (regionDrag.isDragging || regionDrag.isResizing) {
handleRegionCanvasMouseUp();
}
}}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
@ -6504,6 +6832,106 @@ export default function ScreenDesigner({
return (
<>
{/* 조건부 영역(Zone) (기본 레이어에서만 표시, DB 기반) */}
{/* 내부는 pointerEvents: none으로 아래 컴포넌트 클릭/드래그 통과 */}
{activeLayerId === 1 && zones.map((zone) => {
const layerId = zone.zone_id; // 렌더링용 ID
const region = zone;
const resizeHandles = ["nw", "ne", "sw", "se", "n", "s", "e", "w"];
const handleCursors: Record<string, string> = {
nw: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize", se: "nwse-resize",
n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize",
};
const handlePositions: Record<string, React.CSSProperties> = {
nw: { top: -4, left: -4 }, ne: { top: -4, right: -4 },
sw: { bottom: -4, left: -4 }, se: { bottom: -4, right: -4 },
n: { top: -4, left: "50%", transform: "translateX(-50%)" },
s: { bottom: -4, left: "50%", transform: "translateX(-50%)" },
e: { top: "50%", right: -4, transform: "translateY(-50%)" },
w: { top: "50%", left: -4, transform: "translateY(-50%)" },
};
// 테두리 두께 (이동 핸들 영역)
const borderWidth = 6;
return (
<div
key={`region-${layerId}`}
className="absolute"
style={{
left: `${region.x}px`,
top: `${region.y}px`,
width: `${region.width}px`,
height: `${region.height}px`,
border: "2px dashed hsl(var(--primary))",
borderRadius: "4px",
backgroundColor: "hsl(var(--primary) / 0.05)",
zIndex: 50,
pointerEvents: "none", // 내부 클릭은 아래 컴포넌트로 통과
}}
>
{/* 테두리 이동 핸들: 상/하/좌/우 얇은 영역만 pointerEvents 활성 */}
{/* 상단 */}
<div
className="absolute left-0 right-0 top-0"
style={{ height: borderWidth, cursor: "move", pointerEvents: "auto" }}
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
/>
{/* 하단 */}
<div
className="absolute bottom-0 left-0 right-0"
style={{ height: borderWidth, cursor: "move", pointerEvents: "auto" }}
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
/>
{/* 좌측 */}
<div
className="absolute bottom-0 left-0 top-0"
style={{ width: borderWidth, cursor: "move", pointerEvents: "auto" }}
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
/>
{/* 우측 */}
<div
className="absolute bottom-0 right-0 top-0"
style={{ width: borderWidth, cursor: "move", pointerEvents: "auto" }}
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
/>
{/* 라벨 */}
<span
className="absolute left-2 top-1 select-none text-[10px] font-medium text-primary"
style={{ pointerEvents: "auto", cursor: "move" }}
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
>
Zone {zone.zone_id} - {zone.zone_name}
</span>
{/* 리사이즈 핸들 */}
{resizeHandles.map((handle) => (
<div
key={handle}
className="absolute z-10 h-2 w-2 rounded-sm border border-primary bg-background"
style={{ ...handlePositions[handle], cursor: handleCursors[handle], pointerEvents: "auto" }}
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "resize", handle)}
/>
))}
{/* 삭제 버튼 */}
<button
className="absolute -right-1 -top-3 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-[8px] text-destructive-foreground hover:bg-destructive/80"
style={{ pointerEvents: "auto" }}
onClick={async (e) => {
e.stopPropagation();
if (!selectedScreen?.screenId) return;
try {
await screenApi.deleteZone(zone.zone_id);
setZones((prev) => prev.filter(z => z.zone_id !== zone.zone_id));
toast.success("조건부 영역이 삭제되었습니다.");
} catch { toast.error("Zone 삭제 실패"); }
}}
title="영역 삭제"
>
x
</button>
</div>
);
})}
{/* 일반 컴포넌트들 */}
{regularComponents.map((component) => {
const children =
@ -7031,7 +7459,7 @@ export default function ScreenDesigner({
</p>
<p>
<span className="font-medium">:</span> Ctrl+C(), Ctrl+V(), Ctrl+S(),
Ctrl+Z(), Delete()
Ctrl+Z(), Delete/Backspace()
</p>
<p className="text-warning flex items-center justify-center gap-2">
<span></span>
@ -7043,8 +7471,9 @@ export default function ScreenDesigner({
)}
</div>
</div>
</div>{" "}
{/* 🔥 줌 래퍼 닫기 */}
</div>
); /* 🔥 줌 래퍼 닫기 */
})()}
</div>
</div>{" "}
{/* 메인 컨테이너 닫기 */}
@ -7128,4 +7557,4 @@ export default function ScreenDesigner({
</LayerProvider>
</ScreenPreviewProvider>
);
}
}

View File

@ -1872,6 +1872,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
id: screenToPreview!.screenId,
tableName: screenToPreview?.tableName,
}}
layers={previewLayout.layers || []}
/>
</div>
))}

View File

@ -134,7 +134,6 @@ interface ScreenSettingModalProps {
fieldMappings?: FieldMappingInfo[];
componentCount?: number;
onSaveSuccess?: () => void;
isPop?: boolean; // POP 화면 여부
}
// 검색 가능한 Select 컴포넌트
@ -240,7 +239,6 @@ export function ScreenSettingModal({
fieldMappings = [],
componentCount = 0,
onSaveSuccess,
isPop = false,
}: ScreenSettingModalProps) {
const [activeTab, setActiveTab] = useState("overview");
const [loading, setLoading] = useState(false);
@ -521,7 +519,6 @@ export function ScreenSettingModal({
iframeKey={iframeKey}
canvasWidth={canvasSize.width}
canvasHeight={canvasSize.height}
isPop={isPop}
/>
</div>
</div>
@ -4634,10 +4631,9 @@ interface PreviewTabProps {
iframeKey?: number; // iframe 새로고침용 키
canvasWidth?: number; // 화면 캔버스 너비
canvasHeight?: number; // 화면 캔버스 높이
isPop?: boolean; // POP 화면 여부
}
function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWidth, canvasHeight, isPop = false }: PreviewTabProps) {
function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWidth, canvasHeight }: PreviewTabProps) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
@ -4691,18 +4687,12 @@ function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWi
if (companyCode) {
params.set("company_code", companyCode);
}
// POP 화면일 경우 디바이스 타입 추가
if (isPop) {
params.set("device", "tablet");
}
// POP 화면과 데스크톱 화면 경로 분기
const screenPath = isPop ? `/pop/screens/${screenId}` : `/screens/${screenId}`;
if (typeof window !== "undefined") {
const baseUrl = window.location.origin;
return `${baseUrl}${screenPath}?${params.toString()}`;
return `${baseUrl}/screens/${screenId}?${params.toString()}`;
}
return `${screenPath}?${params.toString()}`;
}, [screenId, companyCode, isPop]);
return `/screens/${screenId}?${params.toString()}`;
}, [screenId, companyCode]);
const handleIframeLoad = () => {
setLoading(false);

View File

@ -33,6 +33,15 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
onStyleChange(newStyle);
};
// 숫자만 입력했을 때 자동으로 px 붙여주는 핸들러
const autoPxProperties: (keyof ComponentStyle)[] = ["fontSize", "borderWidth", "borderRadius"];
const handlePxBlur = (property: keyof ComponentStyle) => {
const val = localStyle[property];
if (val && /^\d+(\.\d+)?$/.test(String(val))) {
handleStyleChange(property, `${val}px`);
}
};
const toggleSection = (section: string) => {
setOpenSections((prev) => ({
...prev,
@ -66,6 +75,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
placeholder="1px"
value={localStyle.borderWidth || ""}
onChange={(e) => handleStyleChange("borderWidth", e.target.value)}
onBlur={() => handlePxBlur("borderWidth")}
className="h-6 w-full px-2 py-0 text-xs"
/>
</div>
@ -121,6 +131,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
placeholder="5px"
value={localStyle.borderRadius || ""}
onChange={(e) => handleStyleChange("borderRadius", e.target.value)}
onBlur={() => handlePxBlur("borderRadius")}
className="h-6 w-full px-2 py-0 text-xs"
/>
</div>
@ -209,6 +220,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
placeholder="14px"
value={localStyle.fontSize || ""}
onChange={(e) => handleStyleChange("fontSize", e.target.value)}
onBlur={() => handlePxBlur("fontSize")}
className="h-6 w-full px-2 py-0 text-xs"
/>
</div>

View File

@ -0,0 +1,344 @@
"use client";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { CalendarIcon, ChevronLeft, ChevronRight, X } from "lucide-react";
import {
format,
addMonths,
subMonths,
startOfMonth,
endOfMonth,
eachDayOfInterval,
isSameMonth,
isSameDay,
isToday,
} from "date-fns";
import { ko } from "date-fns/locale";
import { cn } from "@/lib/utils";
interface FormDatePickerProps {
id?: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
readOnly?: boolean;
includeTime?: boolean;
}
export const FormDatePicker: React.FC<FormDatePickerProps> = ({
id,
value,
onChange,
placeholder,
disabled = false,
readOnly = false,
includeTime = false,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [currentMonth, setCurrentMonth] = useState(new Date());
const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar");
const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12);
const [timeValue, setTimeValue] = useState("00:00");
const [isTyping, setIsTyping] = useState(false);
const [typingValue, setTypingValue] = useState("");
const parseDate = (val: string): Date | undefined => {
if (!val) return undefined;
try {
const date = new Date(val);
if (isNaN(date.getTime())) return undefined;
return date;
} catch {
return undefined;
}
};
const selectedDate = parseDate(value);
useEffect(() => {
if (isOpen) {
setViewMode("calendar");
if (selectedDate) {
setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
setYearRangeStart(Math.floor(selectedDate.getFullYear() / 12) * 12);
if (includeTime) {
const hours = String(selectedDate.getHours()).padStart(2, "0");
const minutes = String(selectedDate.getMinutes()).padStart(2, "0");
setTimeValue(`${hours}:${minutes}`);
}
} else {
setCurrentMonth(new Date());
setYearRangeStart(Math.floor(new Date().getFullYear() / 12) * 12);
setTimeValue("00:00");
}
} else {
setIsTyping(false);
setTypingValue("");
}
}, [isOpen]);
const formatDisplayValue = (): string => {
if (!selectedDate) return "";
if (includeTime) return format(selectedDate, "yyyy-MM-dd HH:mm", { locale: ko });
return format(selectedDate, "yyyy-MM-dd", { locale: ko });
};
const buildDateStr = (date: Date, time?: string) => {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
if (includeTime) return `${y}-${m}-${d}T${time || timeValue}`;
return `${y}-${m}-${d}`;
};
const handleDateClick = (date: Date) => {
onChange(buildDateStr(date));
if (!includeTime) setIsOpen(false);
};
const handleTimeChange = (newTime: string) => {
setTimeValue(newTime);
if (selectedDate) onChange(buildDateStr(selectedDate, newTime));
};
const handleSetToday = () => {
const today = new Date();
if (includeTime) {
const t = `${String(today.getHours()).padStart(2, "0")}:${String(today.getMinutes()).padStart(2, "0")}`;
onChange(buildDateStr(today, t));
} else {
onChange(buildDateStr(today));
}
setIsOpen(false);
};
const handleClear = () => {
onChange("");
setIsTyping(false);
setIsOpen(false);
};
const handleTriggerInput = (raw: string) => {
setIsTyping(true);
setTypingValue(raw);
if (!isOpen) setIsOpen(true);
const digitsOnly = raw.replace(/\D/g, "");
if (digitsOnly.length === 8) {
const y = parseInt(digitsOnly.slice(0, 4), 10);
const m = parseInt(digitsOnly.slice(4, 6), 10) - 1;
const d = parseInt(digitsOnly.slice(6, 8), 10);
const date = new Date(y, m, d);
if (date.getFullYear() === y && date.getMonth() === m && date.getDate() === d && y >= 1900 && y <= 2100) {
onChange(buildDateStr(date));
setCurrentMonth(new Date(y, m, 1));
if (!includeTime) setTimeout(() => { setIsTyping(false); setIsOpen(false); }, 400);
else setIsTyping(false);
}
}
};
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
const dayOfWeek = monthStart.getDay();
const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const allDays = [...Array(paddingDays).fill(null), ...days];
return (
<Popover open={isOpen} onOpenChange={(open) => { if (!open) { setIsOpen(false); setIsTyping(false); } }}>
<PopoverTrigger asChild>
<div
id={id}
className={cn(
"border-input bg-background flex h-10 w-full cursor-pointer items-center rounded-md border px-3",
(disabled || readOnly) && "cursor-not-allowed opacity-50",
!selectedDate && !isTyping && "text-muted-foreground",
)}
onClick={() => { if (!disabled && !readOnly) setIsOpen(true); }}
>
<CalendarIcon className="text-muted-foreground mr-2 h-4 w-4 shrink-0" />
<input
type="text"
inputMode="numeric"
value={isTyping ? typingValue : (formatDisplayValue() || "")}
placeholder={placeholder || "날짜를 선택하세요"}
disabled={disabled || readOnly}
onChange={(e) => handleTriggerInput(e.target.value)}
onClick={(e) => e.stopPropagation()}
onFocus={() => { if (!disabled && !readOnly && !isOpen) setIsOpen(true); }}
onBlur={() => { if (!isOpen) setIsTyping(false); }}
className="h-full w-full bg-transparent text-sm font-normal outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
/>
{selectedDate && !disabled && !readOnly && !isTyping && (
<X
className="text-muted-foreground hover:text-foreground ml-auto h-3.5 w-3.5 shrink-0 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
handleClear();
}}
/>
)}
</div>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
<div className="p-4">
<div className="mb-3 flex items-center gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleSetToday}>
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleClear}>
</Button>
</div>
{viewMode === "year" ? (
<>
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="text-sm font-medium">{yearRangeStart} - {yearRangeStart + 11}</div>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-4 gap-2">
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
<Button
key={year}
variant="ghost"
size="sm"
className={cn(
"h-9 text-xs",
year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary",
year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border",
)}
onClick={() => {
setCurrentMonth(new Date(year, currentMonth.getMonth(), 1));
setViewMode("month");
}}
>
{year}
</Button>
))}
</div>
</>
) : viewMode === "month" ? (
<>
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<button
type="button"
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
onClick={() => {
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
setViewMode("year");
}}
>
{currentMonth.getFullYear()}
</button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-4 gap-2">
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
<Button
key={month}
variant="ghost"
size="sm"
className={cn(
"h-9 text-xs",
month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary",
month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border",
)}
onClick={() => {
setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1));
setViewMode("calendar");
}}
>
{month + 1}
</Button>
))}
</div>
</>
) : (
<>
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<button
type="button"
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
onClick={() => {
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
setViewMode("year");
}}
>
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
</button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="mb-2 grid grid-cols-7 gap-1">
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
<div key={day} className="text-muted-foreground p-2 text-center text-xs font-medium">
{day}
</div>
))}
</div>
<div className="mb-4 grid grid-cols-7 gap-1">
{allDays.map((date, index) => {
if (!date) return <div key={index} className="p-2" />;
const isCurrentMonth = isSameMonth(date, currentMonth);
const isSelected = selectedDate ? isSameDay(date, selectedDate) : false;
const isTodayDate = isToday(date);
return (
<Button
key={date.toISOString()}
variant="ghost"
size="sm"
className={cn(
"h-8 w-8 p-0 text-xs",
!isCurrentMonth && "text-muted-foreground opacity-50",
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
isTodayDate && !isSelected && "border-primary border",
)}
onClick={() => handleDateClick(date)}
disabled={!isCurrentMonth}
>
{format(date, "d")}
</Button>
);
})}
</div>
</>
)}
{includeTime && viewMode === "calendar" && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">:</span>
<input
type="time"
value={timeValue}
onChange={(e) => handleTimeChange(e.target.value)}
className="border-input h-8 rounded-md border px-2 text-xs"
/>
</div>
)}
</div>
</PopoverContent>
</Popover>
);
};

View File

@ -0,0 +1,279 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ChevronLeft, ChevronRight } from "lucide-react";
import {
format,
addMonths,
subMonths,
startOfMonth,
endOfMonth,
eachDayOfInterval,
isSameMonth,
isSameDay,
isToday,
} from "date-fns";
import { ko } from "date-fns/locale";
import { cn } from "@/lib/utils";
interface InlineCellDatePickerProps {
value: string;
onChange: (value: string) => void;
onSave: () => void;
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
inputRef?: React.RefObject<HTMLInputElement>;
}
export const InlineCellDatePicker: React.FC<InlineCellDatePickerProps> = ({
value,
onChange,
onSave,
onKeyDown,
inputRef,
}) => {
const [isOpen, setIsOpen] = useState(true);
const [currentMonth, setCurrentMonth] = useState(new Date());
const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar");
const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12);
const localInputRef = useRef<HTMLInputElement>(null);
const actualInputRef = inputRef || localInputRef;
const parseDate = (val: string): Date | undefined => {
if (!val) return undefined;
try {
const date = new Date(val);
if (isNaN(date.getTime())) return undefined;
return date;
} catch {
return undefined;
}
};
const selectedDate = parseDate(value);
useEffect(() => {
if (selectedDate) {
setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
}
}, []);
const handleDateClick = (date: Date) => {
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
onChange(dateStr);
setIsOpen(false);
setTimeout(() => onSave(), 50);
};
const handleSetToday = () => {
const today = new Date();
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
onChange(dateStr);
setIsOpen(false);
setTimeout(() => onSave(), 50);
};
const handleClear = () => {
onChange("");
setIsOpen(false);
setTimeout(() => onSave(), 50);
};
const handleInputChange = (raw: string) => {
onChange(raw);
const digitsOnly = raw.replace(/\D/g, "");
if (digitsOnly.length === 8) {
const y = parseInt(digitsOnly.slice(0, 4), 10);
const m = parseInt(digitsOnly.slice(4, 6), 10) - 1;
const d = parseInt(digitsOnly.slice(6, 8), 10);
const date = new Date(y, m, d);
if (date.getFullYear() === y && date.getMonth() === m && date.getDate() === d && y >= 1900 && y <= 2100) {
const dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
onChange(dateStr);
setIsOpen(false);
setTimeout(() => onSave(), 50);
}
}
};
const handlePopoverClose = (open: boolean) => {
if (!open) {
setIsOpen(false);
onSave();
}
};
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
const startDate = new Date(monthStart);
const dayOfWeek = startDate.getDay();
const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const allDays = [...Array(paddingDays).fill(null), ...days];
return (
<Popover open={isOpen} onOpenChange={handlePopoverClose}>
<PopoverTrigger asChild>
<input
ref={actualInputRef as any}
type="text"
inputMode="numeric"
value={value}
onChange={(e) => handleInputChange(e.target.value)}
onKeyDown={onKeyDown}
onClick={() => setIsOpen(true)}
placeholder="YYYYMMDD"
className="border-primary bg-background h-full w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm"
/>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}>
<div className="p-3">
<div className="mb-2 flex items-center gap-2">
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={handleSetToday}>
</Button>
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={handleClear}>
</Button>
</div>
{viewMode === "year" ? (
<>
<div className="mb-3 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<div className="text-xs font-medium">
{yearRangeStart} - {yearRangeStart + 11}
</div>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
<div className="grid grid-cols-4 gap-1.5">
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
<Button
key={year}
variant="ghost"
size="sm"
className={cn(
"h-8 text-xs",
year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary",
year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border",
)}
onClick={() => {
setCurrentMonth(new Date(year, currentMonth.getMonth(), 1));
setViewMode("month");
}}
>
{year}
</Button>
))}
</div>
</>
) : viewMode === "month" ? (
<>
<div className="mb-3 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<button
type="button"
className="hover:bg-accent rounded-md px-2 py-0.5 text-xs font-medium transition-colors"
onClick={() => {
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
setViewMode("year");
}}
>
{currentMonth.getFullYear()}
</button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
<div className="grid grid-cols-4 gap-1.5">
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
<Button
key={month}
variant="ghost"
size="sm"
className={cn(
"h-8 text-xs",
month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary",
month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border",
)}
onClick={() => {
setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1));
setViewMode("calendar");
}}
>
{month + 1}
</Button>
))}
</div>
</>
) : (
<>
<div className="mb-3 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<button
type="button"
className="hover:bg-accent rounded-md px-2 py-0.5 text-xs font-medium transition-colors"
onClick={() => {
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
setViewMode("year");
}}
>
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
</button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
<div className="mb-1 grid grid-cols-7 gap-0.5">
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
<div key={day} className="text-muted-foreground p-1 text-center text-[10px] font-medium">
{day}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-0.5">
{allDays.map((date, index) => {
if (!date) return <div key={index} className="p-1" />;
const isCurrentMonth = isSameMonth(date, currentMonth);
const isSelected = selectedDate ? isSameDay(date, selectedDate) : false;
const isTodayDate = isToday(date);
return (
<Button
key={date.toISOString()}
variant="ghost"
size="sm"
className={cn(
"h-7 w-7 p-0 text-[11px]",
!isCurrentMonth && "text-muted-foreground opacity-50",
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
isTodayDate && !isSelected && "border-primary border",
)}
onClick={() => handleDateClick(date)}
disabled={!isCurrentMonth}
>
{format(date, "d")}
</Button>
);
})}
</div>
</>
)}
</div>
</PopoverContent>
</Popover>
);
};

View File

@ -34,6 +34,8 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
const [isOpen, setIsOpen] = useState(false);
const [currentMonth, setCurrentMonth] = useState(new Date());
const [selectingType, setSelectingType] = useState<"from" | "to">("from");
const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar");
const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12);
// 로컬 임시 상태 (확인 버튼 누르기 전까지 임시 저장)
const [tempValue, setTempValue] = useState<DateRangeValue>(value || {});
@ -43,6 +45,7 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
if (isOpen) {
setTempValue(value || {});
setSelectingType("from");
setViewMode("calendar");
}
}, [isOpen, value]);
@ -234,60 +237,150 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
</Button>
</div>
{/* 월 네비게이션 */}
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="text-sm font-medium">{format(currentMonth, "yyyy년 MM월", { locale: ko })}</div>
<Button variant="ghost" size="sm" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{/* 요일 헤더 */}
<div className="mb-2 grid grid-cols-7 gap-1">
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
<div key={day} className="text-muted-foreground p-2 text-center text-xs font-medium">
{day}
</div>
))}
</div>
{/* 날짜 그리드 */}
<div className="mb-4 grid grid-cols-7 gap-1">
{allDays.map((date, index) => {
if (!date) {
return <div key={index} className="p-2" />;
}
const isCurrentMonth = isSameMonth(date, currentMonth);
const isSelected = isRangeStart(date) || isRangeEnd(date);
const isInRangeDate = isInRange(date);
const isTodayDate = isToday(date);
return (
<Button
key={date.toISOString()}
variant="ghost"
size="sm"
className={cn(
"h-8 w-8 p-0 text-xs",
!isCurrentMonth && "text-muted-foreground opacity-50",
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
isInRangeDate && !isSelected && "bg-muted",
isTodayDate && !isSelected && "border-primary border",
selectingType === "from" && "hover:bg-primary/20",
selectingType === "to" && "hover:bg-secondary/20",
)}
onClick={() => handleDateClick(date)}
disabled={!isCurrentMonth}
>
{format(date, "d")}
{viewMode === "year" ? (
<>
{/* 년도 선택 뷰 */}
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
<ChevronLeft className="h-4 w-4" />
</Button>
);
})}
</div>
<div className="text-sm font-medium">
{yearRangeStart} - {yearRangeStart + 11}
</div>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="mb-4 grid grid-cols-4 gap-2">
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
<Button
key={year}
variant="ghost"
size="sm"
className={cn(
"h-9 text-xs",
year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary",
year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border",
)}
onClick={() => {
setCurrentMonth(new Date(year, currentMonth.getMonth(), 1));
setViewMode("month");
}}
>
{year}
</Button>
))}
</div>
</>
) : viewMode === "month" ? (
<>
{/* 월 선택 뷰 */}
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<button
type="button"
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
onClick={() => {
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
setViewMode("year");
}}
>
{currentMonth.getFullYear()}
</button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="mb-4 grid grid-cols-4 gap-2">
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
<Button
key={month}
variant="ghost"
size="sm"
className={cn(
"h-9 text-xs",
month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary",
month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border",
)}
onClick={() => {
setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1));
setViewMode("calendar");
}}
>
{month + 1}
</Button>
))}
</div>
</>
) : (
<>
{/* 월 네비게이션 */}
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<button
type="button"
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
onClick={() => {
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
setViewMode("year");
}}
>
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
</button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{/* 요일 헤더 */}
<div className="mb-2 grid grid-cols-7 gap-1">
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
<div key={day} className="text-muted-foreground p-2 text-center text-xs font-medium">
{day}
</div>
))}
</div>
{/* 날짜 그리드 */}
<div className="mb-4 grid grid-cols-7 gap-1">
{allDays.map((date, index) => {
if (!date) {
return <div key={index} className="p-2" />;
}
const isCurrentMonth = isSameMonth(date, currentMonth);
const isSelected = isRangeStart(date) || isRangeEnd(date);
const isInRangeDate = isInRange(date);
const isTodayDate = isToday(date);
return (
<Button
key={date.toISOString()}
variant="ghost"
size="sm"
className={cn(
"h-8 w-8 p-0 text-xs",
!isCurrentMonth && "text-muted-foreground opacity-50",
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
isInRangeDate && !isSelected && "bg-muted",
isTodayDate && !isSelected && "border-primary border",
selectingType === "from" && "hover:bg-primary/20",
selectingType === "to" && "hover:bg-secondary/20",
)}
onClick={() => handleDateClick(date)}
disabled={!isCurrentMonth}
>
{format(date, "d")}
</Button>
);
})}
</div>
</>
)}
{/* 선택된 범위 표시 */}
{(tempValue.from || tempValue.to) && (

View File

@ -49,7 +49,6 @@ export function ComponentsPanel({
() =>
[
// v2-input: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리
// v2-select: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리
// v2-date: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리
// v2-layout: 중첩 드래그앤드롭 기능 미구현으로 숨김 처리
// v2-group: 중첩 드래그앤드롭 기능 미구현으로 숨김 처리
@ -57,6 +56,23 @@ export function ComponentsPanel({
// v2-media 제거 - 테이블 컬럼의 image/file 입력 타입으로 사용
// v2-biz 제거 - 개별 컴포넌트(flow-widget, rack-structure, numbering-rule)로 직접 표시
// v2-hierarchy 제거 - 현재 미사용
{
id: "v2-select",
name: "V2 선택",
description: "드롭다운, 콤보박스, 라디오, 체크박스 등 다양한 선택 모드 지원",
category: "input" as ComponentCategory,
tags: ["select", "dropdown", "combobox", "v2"],
defaultSize: { width: 300, height: 40 },
defaultConfig: {
mode: "dropdown",
source: "static",
multiple: false,
searchable: false,
placeholder: "선택하세요",
options: [],
allowClear: true,
},
},
{
id: "v2-repeater",
name: "리피터 그리드",
@ -65,7 +81,23 @@ export function ComponentsPanel({
tags: ["repeater", "table", "modal", "button", "v2", "v2"],
defaultSize: { width: 600, height: 300 },
},
] as ComponentDefinition[],
{
id: "v2-bom-tree",
name: "BOM 트리 뷰",
description: "BOM 구성을 계층 트리 형태로 조회",
category: "data" as ComponentCategory,
tags: ["bom", "tree", "계층", "제조", "v2"],
defaultSize: { width: 900, height: 600 },
},
{
id: "v2-bom-item-editor",
name: "BOM 하위품목 편집기",
description: "BOM 하위 품목을 트리 구조로 추가/편집/삭제",
category: "data" as ComponentCategory,
tags: ["bom", "tree", "편집", "하위품목", "제조", "v2"],
defaultSize: { width: 900, height: 400 },
},
] as unknown as ComponentDefinition[],
[],
);
@ -98,8 +130,7 @@ export function ComponentsPanel({
"image-display", // → v2-media (image)
// 공통코드관리로 통합 예정
"category-manager", // → 공통코드관리 기능으로 통합 예정
// 분할 패널 정리 (split-panel-layout v1 유지)
"split-panel-layout2", // → split-panel-layout로 통합
// 분할 패널 정리
"screen-split-panel", // 화면 임베딩 방식은 사용하지 않음
// 미완성/미사용 컴포넌트 (기존 화면 호환성 유지, 새 추가만 막음)
"accordion-basic", // 아코디언 컴포넌트
@ -109,8 +140,8 @@ export function ComponentsPanel({
"v2-media", // → 테이블 컬럼의 image/file 입력 타입으로 사용
// 플로우 위젯 숨김 처리
"flow-widget",
// 선택 항목 상세입력 - 기존 컴포넌트 조합으로 대체 가능
"selected-items-detail-input",
// 선택 항목 상세입력 - 거래처 품목 추가 등에서 사용
// "selected-items-detail-input",
// 연관 데이터 버튼 - v2-repeater로 대체 가능
"related-data-buttons",
// ===== V2로 대체된 기존 컴포넌트 (v2 버전만 사용) =====
@ -126,6 +157,7 @@ export function ComponentsPanel({
"section-card", // → v2-section-card
"location-swap-selector", // → v2-location-swap-selector
"rack-structure", // → v2-rack-structure
"v2-select", // → v2-select (아래 v2Components에서 별도 처리)
"v2-repeater", // → v2-repeater (아래 v2Components에서 별도 처리)
"repeat-container", // → v2-repeat-container
"repeat-screen-modal", // → v2-repeat-screen-modal

View File

@ -44,6 +44,11 @@ interface EntityJoinTable {
tableName: string;
currentDisplayColumn: string;
availableColumns: EntityJoinColumn[];
// 같은 테이블이 여러 FK로 조인될 수 있으므로 소스 컬럼으로 구분
joinConfig?: {
sourceColumn: string;
[key: string]: unknown;
};
}
interface TablesPanelProps {
@ -414,7 +419,11 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
</Badge>
</div>
{entityJoinTables.map((joinTable) => {
{entityJoinTables.map((joinTable, idx) => {
// 같은 테이블이 여러 FK로 조인될 수 있으므로 sourceColumn으로 고유 키 생성
const uniqueKey = joinTable.joinConfig?.sourceColumn
? `entity-join-${joinTable.tableName}-${joinTable.joinConfig.sourceColumn}`
: `entity-join-${joinTable.tableName}-${idx}`;
const isExpanded = expandedJoinTables.has(joinTable.tableName);
// 검색어로 필터링
const filteredColumns = searchTerm
@ -431,8 +440,7 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
}
return (
// 엔티티 조인 테이블에 고유 접두사 추가 (메인 테이블과 키 중복 방지)
<div key={`entity-join-${joinTable.tableName}`} className="space-y-1">
<div key={uniqueKey} className="space-y-1">
{/* 조인 테이블 헤더 */}
<div
className="flex cursor-pointer items-center justify-between rounded-md bg-cyan-50 p-2 hover:bg-cyan-100"

View File

@ -216,6 +216,8 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
"v2-media": require("@/components/v2/config-panels/V2MediaConfigPanel").V2MediaConfigPanel,
"v2-biz": require("@/components/v2/config-panels/V2BizConfigPanel").V2BizConfigPanel,
"v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel").V2HierarchyConfigPanel,
"v2-bom-item-editor": require("@/components/v2/config-panels/V2BomItemEditorConfigPanel").V2BomItemEditorConfigPanel,
"v2-bom-tree": require("@/components/v2/config-panels/V2BomTreeConfigPanel").V2BomTreeConfigPanel,
};
const V2ConfigPanel = v2ConfigPanels[componentId];
@ -235,10 +237,16 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
const extraProps: Record<string, any> = {};
if (componentId === "v2-select") {
extraProps.inputType = inputType;
extraProps.tableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
extraProps.columnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName;
}
if (componentId === "v2-list") {
extraProps.currentTableName = currentTableName;
}
if (componentId === "v2-bom-item-editor" || componentId === "v2-bom-tree") {
extraProps.currentTableName = currentTableName;
extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
}
return (
<div key={selectedComponent.id} className="space-y-4">
@ -833,6 +841,44 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
className="h-6 w-full px-2 py-0 text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"></Label>
<Select
value={selectedComponent.style?.labelPosition || "top"}
onValueChange={(value) => handleUpdate("style.labelPosition", value)}
>
<SelectTrigger className="h-6 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="top"></SelectItem>
<SelectItem value="bottom"></SelectItem>
<SelectItem value="left"></SelectItem>
<SelectItem value="right"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
value={
(selectedComponent.style?.labelPosition === "left" || selectedComponent.style?.labelPosition === "right")
? (selectedComponent.style?.labelGap || "8px")
: (selectedComponent.style?.labelMarginBottom || "4px")
}
onChange={(e) => {
const pos = selectedComponent.style?.labelPosition;
if (pos === "left" || pos === "right") {
handleUpdate("style.labelGap", e.target.value);
} else {
handleUpdate("style.labelMarginBottom", e.target.value);
}
}}
className="h-6 w-full px-2 py-0 text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"></Label>
@ -854,12 +900,21 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
value={selectedComponent.style?.labelMarginBottom || "4px"}
onChange={(e) => handleUpdate("style.labelMarginBottom", e.target.value)}
className="h-6 w-full px-2 py-0 text-xs"
/>
<Label className="text-xs"></Label>
<Select
value={selectedComponent.style?.labelFontWeight || "500"}
onValueChange={(value) => handleUpdate("style.labelFontWeight", value)}
>
<SelectTrigger className="h-6 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="400"></SelectItem>
<SelectItem value="500"></SelectItem>
<SelectItem value="600"></SelectItem>
<SelectItem value="700"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2 pt-5">
<Checkbox

View File

@ -99,7 +99,7 @@ export const GroupingPanel: React.FC<Props> = ({
</Button>
</div>
<div className="space-y-2">
<div className="max-h-[40vh] space-y-2 overflow-y-auto pr-1">
{selectedColumns.map((colName, index) => {
const col = table?.columns.find(
(c) => c.columnName === colName

View File

@ -557,7 +557,7 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
</Button>
</div>
<div className="space-y-2">
<div className="max-h-[40vh] space-y-2 overflow-y-auto pr-1">
{selectedGroupColumns.map((colName, index) => {
const col = table?.columns.find((c) => c.columnName === colName);
if (!col) return null;

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import React, { useState, useEffect, useCallback } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { X, Loader2 } from "lucide-react";
@ -26,6 +26,17 @@ interface TabsWidgetProps {
isDesignMode?: boolean;
onComponentSelect?: (tabId: string, componentId: string) => void;
selectedComponentId?: string;
// 테이블 선택된 행 데이터 (버튼 활성화 및 수정/삭제 동작에 필요)
selectedRowsData?: any[];
onSelectedRowsChange?: (
selectedRows: any[],
selectedRowsData: any[],
sortBy?: string,
sortOrder?: "asc" | "desc",
columnOrder?: string[],
) => void;
// 추가 props (부모에서 전달받은 나머지 props)
[key: string]: any;
}
export function TabsWidget({
@ -38,6 +49,9 @@ export function TabsWidget({
isDesignMode = false,
onComponentSelect,
selectedComponentId,
selectedRowsData: _externalSelectedRowsData,
onSelectedRowsChange: externalOnSelectedRowsChange,
...restProps
}: TabsWidgetProps) {
const { setActiveTab, removeTabsComponent } = useActiveTab();
const {
@ -51,6 +65,30 @@ export function TabsWidget({
const storageKey = `tabs-${component.id}-selected`;
// 탭 내부 자체 selectedRowsData 상태 관리 (항상 로컬 상태 사용)
// 부모에서 빈 배열 []이 전달되어도 로컬 상태를 우선하여 탭 내부 버튼이 즉시 인식
const [localSelectedRowsData, setLocalSelectedRowsData] = useState<any[]>([]);
// 선택 변경 핸들러: 로컬 상태 업데이트 + 부모 콜백 호출
const handleSelectedRowsChange = useCallback(
(
selectedRows: any[],
selectedRowsDataNew: any[],
sortBy?: string,
sortOrder?: "asc" | "desc",
columnOrder?: string[],
) => {
// 로컬 상태 업데이트 (탭 내부 버튼이 즉시 인식)
setLocalSelectedRowsData(selectedRowsDataNew);
// 부모 콜백 호출 (부모 상태도 업데이트)
if (externalOnSelectedRowsChange) {
externalOnSelectedRowsChange(selectedRows, selectedRowsDataNew, sortBy, sortOrder, columnOrder);
}
},
[externalOnSelectedRowsChange],
);
// 초기 선택 탭 결정
const getInitialTab = () => {
if (persistSelection && typeof window !== "undefined") {
@ -97,6 +135,27 @@ export function TabsWidget({
const [screenLayouts, setScreenLayouts] = useState<Record<string, ComponentData[]>>({});
const [screenLoadingStates, setScreenLoadingStates] = useState<Record<string, boolean>>({});
const [screenErrors, setScreenErrors] = useState<Record<string, string>>({});
// 탭별 화면 정보 (screenId, tableName) - 인라인 컴포넌트의 테이블 설정에서 추출
const screenInfoMap = React.useMemo(() => {
const map: Record<string, { id?: number; tableName?: string }> = {};
for (const tab of tabs as ExtendedTabItem[]) {
const inlineComponents = tab.components || [];
if (inlineComponents.length > 0) {
// 인라인 컴포넌트에서 테이블 컴포넌트의 selectedTable 추출
const tableComp = inlineComponents.find(
(c) => c.componentType === "v2-table-list" || c.componentType === "table-list",
);
const selectedTable = tableComp?.componentConfig?.selectedTable;
if (selectedTable || tab.screenId) {
map[tab.id] = {
id: tab.screenId,
tableName: selectedTable,
};
}
}
}
return map;
}, [tabs]);
// 컴포넌트 탭 목록 변경 시 동기화
useEffect(() => {
@ -218,7 +277,7 @@ export function TabsWidget({
// 화면 레이아웃이 로드된 경우
const loadedComponents = screenLayouts[tab.id];
if (loadedComponents && loadedComponents.length > 0) {
return renderScreenComponents(loadedComponents);
return renderScreenComponents(tab, loadedComponents);
}
// 아직 로드되지 않은 경우
@ -245,7 +304,7 @@ export function TabsWidget({
};
// screenId로 로드한 화면 컴포넌트 렌더링
const renderScreenComponents = (components: ComponentData[]) => {
const renderScreenComponents = (tab: ExtendedTabItem, components: ComponentData[]) => {
// InteractiveScreenViewerDynamic 동적 로드
const InteractiveScreenViewerDynamic =
require("@/components/screen/InteractiveScreenViewerDynamic").InteractiveScreenViewerDynamic;
@ -278,7 +337,10 @@ export function TabsWidget({
allComponents={components}
formData={formData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfoMap[tab.id]}
menuObjid={menuObjid}
parentTabId={tab.id}
parentTabsComponentId={component.id}
/>
</div>
))}
@ -287,7 +349,7 @@ export function TabsWidget({
};
// 인라인 컴포넌트 렌더링 (v2 방식)
const renderInlineComponents = (tab: TabItem, components: TabInlineComponent[]) => {
const renderInlineComponents = (tab: ExtendedTabItem, components: TabInlineComponent[]) => {
// 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보
const maxBottom = Math.max(
...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)),
@ -331,6 +393,7 @@ export function TabsWidget({
}}
>
<DynamicComponentRenderer
{...restProps}
component={{
id: comp.id,
componentType: comp.componentType,
@ -345,6 +408,17 @@ export function TabsWidget({
menuObjid={menuObjid}
isDesignMode={isDesignMode}
isInteractive={!isDesignMode}
selectedRowsData={localSelectedRowsData}
onSelectedRowsChange={handleSelectedRowsChange}
parentTabId={tab.id}
parentTabsComponentId={component.id}
// 탭에 screenId가 있으면 해당 화면의 tableName/screenId로 오버라이드
{...(screenInfoMap[tab.id]
? {
tableName: screenInfoMap[tab.id].tableName,
screenId: screenInfoMap[tab.id].id,
}
: {})}
/>
</div>
);

View File

@ -1,7 +1,22 @@
"use client";
import React from "react";
import { Input } from "@/components/ui/input";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { CalendarIcon, ChevronLeft, ChevronRight, X } from "lucide-react";
import {
format,
addMonths,
subMonths,
startOfMonth,
endOfMonth,
eachDayOfInterval,
isSameMonth,
isSameDay,
isToday,
} from "date-fns";
import { ko } from "date-fns/locale";
import { cn } from "@/lib/utils";
import { WebTypeComponentProps } from "@/lib/registry/types";
import { WidgetComponent, DateTypeConfig } from "@/types/screen";
@ -10,99 +25,341 @@ export const DateWidget: React.FC<WebTypeComponentProps> = ({ component, value,
const { placeholder, required, style } = widget;
const config = widget.webTypeConfig as DateTypeConfig | undefined;
// 사용자가 테두리를 설정했는지 확인
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
const borderClass = hasCustomBorder ? "!border-0" : "";
// 날짜 포맷팅 함수
const formatDateValue = (val: string) => {
if (!val) return "";
const isDatetime = widget.widgetType === "datetime";
const [isOpen, setIsOpen] = useState(false);
const [currentMonth, setCurrentMonth] = useState(new Date());
const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar");
const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12);
const [timeValue, setTimeValue] = useState("00:00");
const [isTyping, setIsTyping] = useState(false);
const [typingValue, setTypingValue] = useState("");
const parseDate = (val: string | undefined): Date | undefined => {
if (!val) return undefined;
try {
const date = new Date(val);
if (isNaN(date.getTime())) return val;
if (widget.widgetType === "datetime") {
return date.toISOString().slice(0, 16); // YYYY-MM-DDTHH:mm
} else {
return date.toISOString().slice(0, 10); // YYYY-MM-DD
}
if (isNaN(date.getTime())) return undefined;
return date;
} catch {
return val;
return undefined;
}
};
// 날짜 유효성 검증
const validateDate = (dateStr: string): boolean => {
if (!dateStr) return true;
const date = new Date(dateStr);
if (isNaN(date.getTime())) return false;
// 최소/최대 날짜 검증
if (config?.minDate) {
const minDate = new Date(config.minDate);
if (date < minDate) return false;
}
if (config?.maxDate) {
const maxDate = new Date(config.maxDate);
if (date > maxDate) return false;
}
return true;
};
// 입력값 처리
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value;
if (validateDate(inputValue)) {
onChange?.(inputValue);
}
};
// 웹타입에 따른 input type 결정
const getInputType = () => {
switch (widget.widgetType) {
case "datetime":
return "datetime-local";
case "date":
default:
return "date";
}
};
// 기본값 설정 (현재 날짜/시간)
const getDefaultValue = () => {
const getDefaultValue = (): string => {
if (config?.defaultValue === "current") {
const now = new Date();
if (widget.widgetType === "datetime") {
return now.toISOString().slice(0, 16);
} else {
return now.toISOString().slice(0, 10);
}
if (isDatetime) return now.toISOString().slice(0, 16);
return now.toISOString().slice(0, 10);
}
return "";
};
const finalValue = value || getDefaultValue();
const selectedDate = parseDate(finalValue);
useEffect(() => {
if (isOpen) {
setViewMode("calendar");
if (selectedDate) {
setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
if (isDatetime) {
const hours = String(selectedDate.getHours()).padStart(2, "0");
const minutes = String(selectedDate.getMinutes()).padStart(2, "0");
setTimeValue(`${hours}:${minutes}`);
}
} else {
setCurrentMonth(new Date());
setTimeValue("00:00");
}
} else {
setIsTyping(false);
setTypingValue("");
}
}, [isOpen]);
const formatDisplayValue = (): string => {
if (!selectedDate) return "";
if (isDatetime) return format(selectedDate, "yyyy-MM-dd HH:mm", { locale: ko });
return format(selectedDate, "yyyy-MM-dd", { locale: ko });
};
const handleDateClick = (date: Date) => {
let dateStr: string;
if (isDatetime) {
const [hours, minutes] = timeValue.split(":").map(Number);
const dt = new Date(date.getFullYear(), date.getMonth(), date.getDate(), hours || 0, minutes || 0);
dateStr = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}-${String(dt.getDate()).padStart(2, "0")}T${timeValue}`;
} else {
dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
onChange?.(dateStr);
if (!isDatetime) {
setIsOpen(false);
}
};
const handleTimeChange = (newTime: string) => {
setTimeValue(newTime);
if (selectedDate) {
const dateStr = `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, "0")}-${String(selectedDate.getDate()).padStart(2, "0")}T${newTime}`;
onChange?.(dateStr);
}
};
const handleClear = () => {
onChange?.("");
setIsTyping(false);
setIsOpen(false);
};
const handleTriggerInput = (raw: string) => {
setIsTyping(true);
setTypingValue(raw);
if (!isOpen) setIsOpen(true);
const digitsOnly = raw.replace(/\D/g, "");
if (digitsOnly.length === 8) {
const y = parseInt(digitsOnly.slice(0, 4), 10);
const m = parseInt(digitsOnly.slice(4, 6), 10) - 1;
const d = parseInt(digitsOnly.slice(6, 8), 10);
const date = new Date(y, m, d);
if (date.getFullYear() === y && date.getMonth() === m && date.getDate() === d && y >= 1900 && y <= 2100) {
let dateStr: string;
if (isDatetime) {
dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}T${timeValue}`;
} else {
dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
}
onChange?.(dateStr);
setCurrentMonth(new Date(y, m, 1));
if (!isDatetime) setTimeout(() => { setIsTyping(false); setIsOpen(false); }, 400);
else setIsTyping(false);
}
}
};
const handleSetToday = () => {
const today = new Date();
if (isDatetime) {
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}T${String(today.getHours()).padStart(2, "0")}:${String(today.getMinutes()).padStart(2, "0")}`;
onChange?.(dateStr);
} else {
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
onChange?.(dateStr);
}
setIsOpen(false);
};
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
const startDate = new Date(monthStart);
const dayOfWeek = startDate.getDay();
const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
const allDays = [...Array(paddingDays).fill(null), ...days];
return (
<Input
type={getInputType()}
value={formatDateValue(finalValue)}
placeholder={placeholder || config?.placeholder || "날짜를 선택하세요..."}
onChange={handleChange}
disabled={readonly}
required={required}
className={`h-full w-full ${borderClass}`}
min={config?.minDate}
max={config?.maxDate}
/>
<Popover open={isOpen} onOpenChange={(v) => { if (!v) { setIsOpen(false); setIsTyping(false); } }}>
<PopoverTrigger asChild>
<div
className={cn(
"border-input bg-background flex h-full w-full cursor-pointer items-center rounded-md border px-3",
readonly && "cursor-not-allowed opacity-50",
!selectedDate && !isTyping && "text-muted-foreground",
borderClass,
)}
onClick={() => { if (!readonly) setIsOpen(true); }}
>
<CalendarIcon className="text-muted-foreground mr-2 h-4 w-4 shrink-0" />
<input
type="text"
inputMode="numeric"
value={isTyping ? typingValue : (formatDisplayValue() || "")}
placeholder={placeholder || config?.placeholder || "날짜를 선택하세요..."}
disabled={readonly}
onChange={(e) => handleTriggerInput(e.target.value)}
onClick={(e) => e.stopPropagation()}
onFocus={() => { if (!readonly && !isOpen) setIsOpen(true); }}
onBlur={() => { if (!isOpen) setIsTyping(false); }}
className="h-full w-full truncate bg-transparent text-sm font-normal outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
/>
{selectedDate && !readonly && !isTyping && (
<X
className="text-muted-foreground hover:text-foreground ml-auto h-3.5 w-3.5 shrink-0 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
handleClear();
}}
/>
)}
</div>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
<div className="p-4">
<div className="mb-3 flex items-center gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleSetToday}>
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleClear}>
</Button>
</div>
{viewMode === "year" ? (
<>
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="text-sm font-medium">
{yearRangeStart} - {yearRangeStart + 11}
</div>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="mb-4 grid grid-cols-4 gap-2">
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
<Button
key={year}
variant="ghost"
size="sm"
className={cn(
"h-9 text-xs",
year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary",
year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border",
)}
onClick={() => {
setCurrentMonth(new Date(year, currentMonth.getMonth(), 1));
setViewMode("month");
}}
>
{year}
</Button>
))}
</div>
</>
) : viewMode === "month" ? (
<>
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<button
type="button"
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
onClick={() => {
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
setViewMode("year");
}}
>
{currentMonth.getFullYear()}
</button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="mb-4 grid grid-cols-4 gap-2">
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
<Button
key={month}
variant="ghost"
size="sm"
className={cn(
"h-9 text-xs",
month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary",
month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border",
)}
onClick={() => {
setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1));
setViewMode("calendar");
}}
>
{month + 1}
</Button>
))}
</div>
</>
) : (
<>
<div className="mb-4 flex items-center justify-between">
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
<ChevronLeft className="h-4 w-4" />
</Button>
<button
type="button"
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
onClick={() => {
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
setViewMode("year");
}}
>
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
</button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="mb-2 grid grid-cols-7 gap-1">
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
<div key={day} className="text-muted-foreground p-2 text-center text-xs font-medium">
{day}
</div>
))}
</div>
<div className="mb-4 grid grid-cols-7 gap-1">
{allDays.map((date, index) => {
if (!date) return <div key={index} className="p-2" />;
const isCurrentMonth = isSameMonth(date, currentMonth);
const isSelected = selectedDate ? isSameDay(date, selectedDate) : false;
const isTodayDate = isToday(date);
return (
<Button
key={date.toISOString()}
variant="ghost"
size="sm"
className={cn(
"h-8 w-8 p-0 text-xs",
!isCurrentMonth && "text-muted-foreground opacity-50",
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
isTodayDate && !isSelected && "border-primary border",
)}
onClick={() => handleDateClick(date)}
disabled={!isCurrentMonth}
>
{format(date, "d")}
</Button>
);
})}
</div>
</>
)}
{/* datetime 타입: 시간 입력 */}
{isDatetime && viewMode === "calendar" && (
<div className="mb-4 flex items-center gap-2">
<span className="text-muted-foreground text-xs">:</span>
<input
type="time"
value={timeValue}
onChange={(e) => handleTimeChange(e.target.value)}
className="border-input h-8 rounded-md border px-2 text-xs"
/>
</div>
)}
</div>
</PopoverContent>
</Popover>
);
};
DateWidget.displayName = "DateWidget";

View File

@ -3,7 +3,7 @@
import React, { useState, useEffect, useMemo } from "react";
import { apiClient } from "@/lib/api/client";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
import { FolderTree, Loader2, Search, X } from "lucide-react";
import { ChevronRight, FolderTree, Loader2, Search, X } from "lucide-react";
import { Input } from "@/components/ui/input";
interface CategoryColumn {
@ -30,6 +30,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
const [columns, setColumns] = useState<CategoryColumn[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
// 검색어로 필터링된 컬럼 목록
const filteredColumns = useMemo(() => {
@ -49,6 +50,44 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
});
}, [columns, searchQuery]);
// 테이블별로 그룹화된 컬럼 목록
const groupedColumns = useMemo(() => {
const groups: { tableName: string; tableLabel: string; columns: CategoryColumn[] }[] = [];
const groupMap = new Map<string, CategoryColumn[]>();
for (const col of filteredColumns) {
const key = col.tableName;
if (!groupMap.has(key)) {
groupMap.set(key, []);
}
groupMap.get(key)!.push(col);
}
for (const [tblName, cols] of groupMap) {
groups.push({
tableName: tblName,
tableLabel: cols[0]?.tableLabel || tblName,
columns: cols,
});
}
return groups;
}, [filteredColumns]);
// 선택된 컬럼이 있는 그룹을 자동 펼침
useEffect(() => {
if (!selectedColumn) return;
const tableName = selectedColumn.split(".")[0];
if (tableName) {
setExpandedGroups((prev) => {
if (prev.has(tableName)) return prev;
const next = new Set(prev);
next.add(tableName);
return next;
});
}
}, [selectedColumn]);
useEffect(() => {
// 메뉴 종속 없이 항상 회사 기준으로 카테고리 컬럼 조회
loadCategoryColumnsByMenu();
@ -72,9 +111,10 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
allColumns = response.data;
}
// category 타입 컬럼만 필터링
// category 타입 중 자체 카테고리만 필터링 (참조 컬럼 제외)
const categoryColumns = allColumns.filter(
(col: any) => col.inputType === "category" || col.input_type === "category"
(col: any) => (col.inputType === "category" || col.input_type === "category")
&& !col.categoryRef && !col.category_ref
);
console.log("✅ 카테고리 컬럼 필터링 완료:", {
@ -278,35 +318,114 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
)}
</div>
<div className="space-y-2">
<div className="space-y-1">
{filteredColumns.length === 0 && searchQuery ? (
<div className="text-muted-foreground py-4 text-center text-xs">
&apos;{searchQuery}&apos;
</div>
) : null}
{filteredColumns.map((column) => {
const uniqueKey = `${column.tableName}.${column.columnName}`;
const isSelected = selectedColumn === uniqueKey; // 테이블명.컬럼명으로 비교
return (
<div
key={uniqueKey}
onClick={() => onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)}
className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${
isSelected ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50"
}`}
>
<div className="flex items-center gap-2">
<FolderTree
className={`h-4 w-4 ${isSelected ? "text-primary" : "text-muted-foreground"}`}
/>
<div className="flex-1">
<h4 className="text-sm font-semibold">{column.columnLabel || column.columnName}</h4>
<p className="text-muted-foreground text-xs">{column.tableLabel || column.tableName}</p>
</div>
<span className="text-muted-foreground text-xs font-medium">
{column.valueCount !== undefined ? `${column.valueCount}` : "..."}
</span>
{groupedColumns.map((group) => {
const isExpanded = expandedGroups.has(group.tableName);
const totalValues = group.columns.reduce((sum, c) => sum + (c.valueCount ?? 0), 0);
const hasSelectedInGroup = group.columns.some(
(c) => selectedColumn === `${c.tableName}.${c.columnName}`,
);
// 그룹이 1개뿐이면 드롭다운 없이 바로 표시
if (groupedColumns.length <= 1) {
return (
<div key={group.tableName} className="space-y-1.5">
{group.columns.map((column) => {
const uniqueKey = `${column.tableName}.${column.columnName}`;
const isSelected = selectedColumn === uniqueKey;
return (
<div
key={uniqueKey}
onClick={() => onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)}
className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${
isSelected ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50"
}`}
>
<div className="flex items-center gap-2">
<FolderTree
className={`h-4 w-4 ${isSelected ? "text-primary" : "text-muted-foreground"}`}
/>
<div className="flex-1">
<h4 className="text-sm font-semibold">{column.columnLabel || column.columnName}</h4>
<p className="text-muted-foreground text-xs">{column.tableLabel || column.tableName}</p>
</div>
<span className="text-muted-foreground text-xs font-medium">
{column.valueCount !== undefined ? `${column.valueCount}` : "..."}
</span>
</div>
</div>
);
})}
</div>
);
}
return (
<div key={group.tableName} className="overflow-hidden rounded-lg border">
{/* 드롭다운 헤더 */}
<button
type="button"
onClick={() => {
setExpandedGroups((prev) => {
const next = new Set(prev);
if (next.has(group.tableName)) {
next.delete(group.tableName);
} else {
next.add(group.tableName);
}
return next;
});
}}
className={`flex w-full items-center gap-2 px-3 py-2 text-left transition-colors ${
hasSelectedInGroup ? "bg-primary/5" : "hover:bg-muted/50"
}`}
>
<ChevronRight
className={`h-3.5 w-3.5 shrink-0 transition-transform duration-200 ${
isExpanded ? "rotate-90" : ""
} ${hasSelectedInGroup ? "text-primary" : "text-muted-foreground"}`}
/>
<span className={`flex-1 text-xs font-semibold ${hasSelectedInGroup ? "text-primary" : ""}`}>
{group.tableLabel}
</span>
<span className="text-muted-foreground text-[10px]">
{group.columns.length} / {totalValues}
</span>
</button>
{/* 펼쳐진 컬럼 목록 */}
{isExpanded && (
<div className="space-y-1 border-t px-2 py-2">
{group.columns.map((column) => {
const uniqueKey = `${column.tableName}.${column.columnName}`;
const isSelected = selectedColumn === uniqueKey;
return (
<div
key={uniqueKey}
onClick={() => onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)}
className={`cursor-pointer rounded-md px-3 py-1.5 transition-all ${
isSelected ? "bg-primary/10 font-semibold text-primary" : "hover:bg-muted/50"
}`}
>
<div className="flex items-center gap-2">
<FolderTree
className={`h-3.5 w-3.5 ${isSelected ? "text-primary" : "text-muted-foreground"}`}
/>
<span className="flex-1 text-xs">{column.columnLabel || column.columnName}</span>
<span className="text-muted-foreground text-[10px]">
{column.valueCount !== undefined ? `${column.valueCount}` : "..."}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
);
})}

View File

@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[999] bg-black/80",
"fixed inset-0 z-[1050] bg-black/80",
className,
)}
{...props}
@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-[1000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
"bg-background fixed top-[50%] left-[50%] z-[1100] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg",
className,
)}
{...props}

View File

@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[999] bg-black/60",
"fixed inset-0 z-[999] bg-black/60",
className,
)}
{...props}
@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[1000] flex w-full max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
"bg-background fixed top-[50%] left-[50%] z-[1000] flex w-full max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border p-6 shadow-lg sm:rounded-lg",
className,
)}
{...props}

View File

@ -46,7 +46,7 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[99999] min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
"bg-popover text-popover-foreground z-[99999] min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
@ -63,7 +63,7 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[99999] min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
"bg-popover text-popover-foreground z-[99999] min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
className,
)}
{...props}

Some files were not shown because too many files have changed in this diff Show More