Compare commits

...

45 Commits

Author SHA1 Message Date
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
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
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
kmh 5cff85d260 Merge branch 'feature/v2-renewal' into jskim-node
Made-with: Cursor
2026-02-26 09:45:41 +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
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
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
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
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
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
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
81 changed files with 7951 additions and 1438 deletions

View File

@ -92,9 +92,9 @@ export async function createBomVersion(req: Request, res: Response) {
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 } = req.body || {};
const { tableName, detailTable, versionName } = req.body || {};
const result = await bomService.createBomVersion(bomId, companyCode, createdBy, tableName, detailTable);
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 });
@ -129,6 +129,84 @@ export async function activateBomVersion(req: Request, res: Response) {
}
}
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;

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", fields } = req.query;
const { value = "id", label = "name", fields, filters: filtersParam } = req.query;
// tableName 유효성 검증
if (!tableName || tableName === "undefined" || tableName === "null") {
@ -163,6 +276,16 @@ 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 ")}`
: "";
@ -195,6 +318,7 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response)
valueColumn,
labelColumn: effectiveLabelColumn,
companyCode,
hasFilters: !!filtersParam,
rowCount: result.rowCount,
extraFields: extraColumns ? true : false,
});

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

@ -17,9 +17,15 @@ 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);

View File

@ -59,7 +59,10 @@ 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, i.unit
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
@ -98,6 +101,7 @@ export async function getBomVersions(bomId: string, companyCode: string, tableNa
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");
@ -107,17 +111,24 @@ export async function createBomVersion(
if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다");
const bomData = bomRow.rows[0];
// 다음 버전 번호 결정
const lastVersion = await client.query(
`SELECT version_name FROM ${vTable} WHERE bom_id = $1 ORDER BY created_date DESC LIMIT 1`,
[bomId],
);
let nextVersionNum = 1;
if (lastVersion.rows.length > 0) {
const parsed = parseFloat(lastVersion.rows[0].version_name);
if (!isNaN(parsed)) nextVersionNum = Math.floor(parsed) + 1;
// 버전명: 사용자 입력 > 순번 자동 생성
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}`);
}
const versionName = `${nextVersionNum}.0`;
// 새 버전 레코드 생성 (snapshot_data 없이)
const insertSql = `
@ -249,6 +260,547 @@ export async function activateBomVersion(bomId: string, versionId: string, table
});
}
/**
* 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
*/

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 *
`;
@ -1292,7 +1348,7 @@ export class DynamicFormService {
// 삭제된 행이 없으면 레코드를 찾을 수 없는 것
if (!result || !Array.isArray(result) || result.length === 0) {
throw new Error(`테이블 ${tableName}에서 ID '${id}'에 해당하는 레코드를 찾을 수 없습니다.`);
throw new Error(`테이블 ${actualTable}에서 ID '${id}'에 해당하는 레코드를 찾을 수 없습니다.`);
}
console.log("✅ 서비스: 실제 테이블 삭제 성공:", result);

View File

@ -5083,8 +5083,8 @@ export class ScreenManagementService {
let layout: { layout_data: any } | null = null;
// 🆕 기본 레이어(layer_id=1)를 우선 로드
// SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회
if (isSuperAdmin) {
// SUPER_ADMIN이거나 companyCode가 "*"인 경우: 화면의 회사 코드로 레이아웃 조회
if (isSuperAdmin || companyCode === "*") {
// 1. 화면 정의의 회사 코드 + 기본 레이어
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2

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

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,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

@ -179,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);

View File

@ -903,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;
@ -925,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}`);
}
}
@ -943,7 +945,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
}
// 채번 적용: 엑셀에 값이 없거나 빈 값이면 채번 규칙으로 자동 생성, 값이 있으면 그대로 사용
if (hasNumbering && numberingInfo && uploadMode === "insert" && !shouldUpdate) {
if (hasNumbering && numberingInfo && (uploadMode === "insert" || uploadMode === "upsert") && !shouldUpdate) {
const existingValue = dataToSave[numberingInfo.columnName];
const hasExcelValue = existingValue !== undefined && existingValue !== null && String(existingValue).trim() !== "";
@ -968,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++;
}
}
@ -1008,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}개 행 업로드`;
@ -1022,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("업로드에 실패했습니다.");
}

View File

@ -1288,7 +1288,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
{/* 모달 닫기 확인 다이얼로그 */}
<AlertDialog open={showCloseConfirm} onOpenChange={setShowCloseConfirm}>
<AlertDialogContent className="!z-[1100] max-w-[95vw] sm:max-w-[400px]">
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
<AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg">
?

View File

@ -275,7 +275,26 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
});
// 편집 데이터로 폼 데이터 초기화
setFormData(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 || {});
@ -394,9 +413,28 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// 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 || [];
@ -1202,38 +1240,35 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
}
// V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행)
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);
});
// 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);
});
console.log("🟢 [EditModal] INSERT 후 repeaterSave 이벤트 발행:", {
parentId: masterRecordId,
tableName: screenData.screenInfo.tableName,
});
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: masterRecordId,
tableName: screenData.screenInfo.tableName,
mainFormData: formData,
masterRecordId,
},
}),
);
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: masterRecordId,
tableName: screenData.screenInfo.tableName,
mainFormData: formData,
masterRecordId,
},
}),
);
await repeaterSavePromise;
console.log("✅ [EditModal] INSERT 후 repeaterSave 완료");
} catch (repeaterError) {
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
await repeaterSavePromise;
} catch (repeaterError) {
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
}
}
handleClose();
@ -1242,8 +1277,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
}
} else {
// UPDATE 모드 - PUT (전체 업데이트)
// originalData 비교 없이 formData 전체를 보냄
const recordId = formData.id;
// VIEW에서 온 데이터의 경우 master_id를 우선 사용 (마스터-디테일 구조)
const recordId = formData.master_id || formData.id;
if (!recordId) {
console.error("[EditModal] UPDATE 실패: formData에 id가 없습니다.", {
@ -1296,15 +1331,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
if (response.success) {
toast.success("데이터가 수정되었습니다.");
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
if (modalState.onSave) {
try {
modalState.onSave();
} catch (callbackError) {
console.error("onSave 콜백 에러:", callbackError);
}
}
// 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어)
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
try {
@ -1341,40 +1367,41 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
}
// V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행)
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);
});
// 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);
});
console.log("🟢 [EditModal] UPDATE 후 repeaterSave 이벤트 발행:", {
parentId: recordId,
tableName: screenData.screenInfo.tableName,
});
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: recordId,
tableName: screenData.screenInfo.tableName,
mainFormData: formData,
masterRecordId: recordId,
},
}),
);
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: recordId,
tableName: screenData.screenInfo.tableName,
mainFormData: formData,
masterRecordId: recordId,
},
}),
);
await repeaterSavePromise;
console.log("✅ [EditModal] UPDATE 후 repeaterSave 완료");
} catch (repeaterError) {
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
await repeaterSavePromise;
} catch (repeaterError) {
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
}
}
// 리피터 저장 완료 후 메인 테이블 새로고침
if (modalState.onSave) {
try { modalState.onSave(); } catch {}
}
handleClose();
} else {
throw new Error(response.message || "수정에 실패했습니다.");
@ -1432,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">
@ -1447,7 +1474,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
>
<div
data-screen-runtime="true"
className="relative bg-white"
className="relative m-auto bg-white"
style={{
width: screenDimensions?.width || 800,
// 조건부 레이어가 활성화되면 높이 자동 확장

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

@ -2191,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 || "";
@ -2208,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" }),
};
@ -2226,8 +2233,17 @@ 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;
@ -2452,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>

View File

@ -1103,17 +1103,27 @@ 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 || "";
@ -1190,9 +1200,17 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// 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,
...safeStyleWithoutSize,
...cleanedStyle,
// left/top은 반드시 마지막에 (styleWithoutSize가 덮어쓰지 못하게)
left: adjustedX,
top: position?.y || 0,
@ -1263,10 +1281,101 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
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 ref={elRef} id={`interactive-${component.id}`} className="absolute" style={componentStyle}>
{renderInteractiveWidget(splitAdjustedComponent)}
{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

@ -548,10 +548,23 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
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`,
top: `${position.y}px`,
...componentStyle,
...safeComponentStyle,
width: splitAdjustedWidth !== null ? `${splitAdjustedWidth}px` : displayWidth,
height: displayHeight,
zIndex: component.type === "layout" ? 1 : position.z || 2,

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

@ -841,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>
@ -862,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,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();
@ -279,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(
"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 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 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

@ -10,14 +10,13 @@
* - range 옵션: 범위 (~)
*/
import React, { forwardRef, useCallback, useMemo, useState } from "react";
import { format, parse, isValid } from "date-fns";
import React, { forwardRef, useCallback, useMemo, useState, useEffect } from "react";
import { format, parse, isValid, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isSameDay, isToday as isTodayFn } from "date-fns";
import { ko } from "date-fns/locale";
import { Calendar as CalendarIcon, Clock } from "lucide-react";
import { Calendar as CalendarIcon, Clock, ChevronLeft, ChevronRight } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { V2DateProps, V2DateType } from "@/types/v2-components";
@ -60,11 +59,24 @@ function formatDate(date: Date | undefined, formatStr: string): string {
return format(date, dateFnsFormat);
}
// YYYYMMDD 또는 YYYY-MM-DD 문자열 → 유효한 Date 객체 반환 (유효하지 않으면 null)
function parseManualDateInput(raw: string): Date | null {
const digits = raw.replace(/\D/g, "");
if (digits.length !== 8) return null;
const y = parseInt(digits.slice(0, 4), 10);
const m = parseInt(digits.slice(4, 6), 10) - 1;
const d = parseInt(digits.slice(6, 8), 10);
const date = new Date(y, m, d);
if (date.getFullYear() !== y || date.getMonth() !== m || date.getDate() !== d) return null;
if (y < 1900 || y > 2100) return null;
return date;
}
/**
*
*/
const SingleDatePicker = forwardRef<
HTMLButtonElement,
HTMLDivElement,
{
value?: string;
onChange?: (value: string) => void;
@ -83,80 +95,227 @@ const SingleDatePicker = forwardRef<
ref,
) => {
const [open, setOpen] = 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 [isTyping, setIsTyping] = useState(false);
const [typingValue, setTypingValue] = useState("");
const inputRef = React.useRef<HTMLInputElement>(null);
const date = useMemo(() => parseDate(value, dateFormat), [value, dateFormat]);
const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]);
const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]);
// 표시할 날짜 텍스트 계산 (ISO 형식이면 포맷팅, 아니면 그대로)
const displayText = useMemo(() => {
if (!value) return "";
// Date 객체로 변환 후 포맷팅
if (date && isValid(date)) {
return formatDate(date, dateFormat);
}
if (date && isValid(date)) return formatDate(date, dateFormat);
return value;
}, [value, date, dateFormat]);
const handleSelect = useCallback(
(selectedDate: Date | undefined) => {
if (selectedDate) {
onChange?.(formatDate(selectedDate, dateFormat));
setOpen(false);
useEffect(() => {
if (open) {
setViewMode("calendar");
if (date && isValid(date)) {
setCurrentMonth(new Date(date.getFullYear(), date.getMonth(), 1));
setYearRangeStart(Math.floor(date.getFullYear() / 12) * 12);
} else {
setCurrentMonth(new Date());
setYearRangeStart(Math.floor(new Date().getFullYear() / 12) * 12);
}
},
[dateFormat, onChange],
);
} else {
setIsTyping(false);
setTypingValue("");
}
}, [open]);
const handleDateClick = useCallback((clickedDate: Date) => {
onChange?.(formatDate(clickedDate, dateFormat));
setIsTyping(false);
setOpen(false);
}, [dateFormat, onChange]);
const handleToday = useCallback(() => {
onChange?.(formatDate(new Date(), dateFormat));
setIsTyping(false);
setOpen(false);
}, [dateFormat, onChange]);
const handleClear = useCallback(() => {
onChange?.("");
setIsTyping(false);
setOpen(false);
}, [onChange]);
const handleTriggerInput = useCallback((raw: string) => {
setIsTyping(true);
setTypingValue(raw);
if (!open) setOpen(true);
const digitsOnly = raw.replace(/\D/g, "");
if (digitsOnly.length === 8) {
const parsed = parseManualDateInput(digitsOnly);
if (parsed) {
onChange?.(formatDate(parsed, dateFormat));
setCurrentMonth(new Date(parsed.getFullYear(), parsed.getMonth(), 1));
setTimeout(() => { setIsTyping(false); setOpen(false); }, 400);
}
}
}, [dateFormat, onChange, open]);
const mStart = startOfMonth(currentMonth);
const mEnd = endOfMonth(currentMonth);
const days = eachDayOfInterval({ start: mStart, end: mEnd });
const dow = mStart.getDay();
const padding = dow === 0 ? 6 : dow - 1;
const allDays = [...Array(padding).fill(null), ...days];
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={(v) => { if (!v) { setOpen(false); setIsTyping(false); } }}>
<PopoverTrigger asChild>
<Button
<div
ref={ref}
variant="outline"
disabled={disabled || readonly}
className={cn(
"h-full w-full justify-start text-left font-normal",
!displayText && "text-muted-foreground",
"border-input bg-background flex h-full w-full cursor-pointer items-center rounded-md border px-3",
(disabled || readonly) && "cursor-not-allowed opacity-50",
className,
)}
onClick={() => { if (!disabled && !readonly) setOpen(true); }}
>
<CalendarIcon className="mr-2 h-4 w-4 flex-shrink-0" />
{displayText || placeholder}
</Button>
<CalendarIcon className="text-muted-foreground mr-2 h-4 w-4 shrink-0" />
<input
ref={inputRef}
type="text"
inputMode="numeric"
value={isTyping ? typingValue : (displayText || "")}
placeholder={placeholder}
disabled={disabled || readonly}
onChange={(e) => handleTriggerInput(e.target.value)}
onClick={(e) => e.stopPropagation()}
onFocus={() => { if (!disabled && !readonly && !open) setOpen(true); }}
onBlur={() => { if (!open) setIsTyping(false); }}
className={cn(
"h-full w-full bg-transparent text-sm outline-none",
"placeholder:text-muted-foreground disabled:cursor-not-allowed",
!displayText && !isTyping && "text-muted-foreground",
)}
/>
</div>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={date}
onSelect={handleSelect}
initialFocus
locale={ko}
disabled={(date) => {
if (minDateObj && date < minDateObj) return true;
if (maxDateObj && date > maxDateObj) return true;
return false;
}}
/>
<div className="flex gap-2 p-3 pt-0">
{showToday && (
<Button variant="outline" size="sm" onClick={handleToday}>
<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">
{showToday && (
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleToday}>
</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((d) => (
<div key={d} className="text-muted-foreground p-2 text-center text-xs font-medium">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{allDays.map((d, idx) => {
if (!d) return <div key={idx} className="p-2" />;
const isCur = isSameMonth(d, currentMonth);
const isSel = date ? isSameDay(d, date) : false;
const isT = isTodayFn(d);
return (
<Button
key={d.toISOString()}
variant="ghost"
size="sm"
className={cn(
"h-8 w-8 p-0 text-xs",
!isCur && "text-muted-foreground opacity-50",
isSel && "bg-primary text-primary-foreground hover:bg-primary",
isT && !isSel && "border-primary border",
)}
onClick={() => handleDateClick(d)}
disabled={!isCur}
>
{format(d, "d")}
</Button>
);
})}
</div>
</>
)}
<Button variant="ghost" size="sm" onClick={handleClear}>
</Button>
</div>
</PopoverContent>
</Popover>
@ -168,6 +327,149 @@ SingleDatePicker.displayName = "SingleDatePicker";
/**
*
*/
/**
* (drill-down )
*/
const RangeCalendarPopover: React.FC<{
open: boolean;
onOpenChange: (open: boolean) => void;
selectedDate?: Date;
onSelect: (date: Date) => void;
label: string;
disabled?: boolean;
readonly?: boolean;
displayValue?: string;
}> = ({ open, onOpenChange, selectedDate, onSelect, label, disabled, readonly, displayValue }) => {
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 [isTyping, setIsTyping] = useState(false);
const [typingValue, setTypingValue] = useState("");
useEffect(() => {
if (open) {
setViewMode("calendar");
if (selectedDate && isValid(selectedDate)) {
setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
setYearRangeStart(Math.floor(selectedDate.getFullYear() / 12) * 12);
} else {
setCurrentMonth(new Date());
setYearRangeStart(Math.floor(new Date().getFullYear() / 12) * 12);
}
} else {
setIsTyping(false);
setTypingValue("");
}
}, [open]);
const handleTriggerInput = (raw: string) => {
setIsTyping(true);
setTypingValue(raw);
const digitsOnly = raw.replace(/\D/g, "");
if (digitsOnly.length === 8) {
const parsed = parseManualDateInput(digitsOnly);
if (parsed) {
setIsTyping(false);
onSelect(parsed);
}
}
};
const mStart = startOfMonth(currentMonth);
const mEnd = endOfMonth(currentMonth);
const days = eachDayOfInterval({ start: mStart, end: mEnd });
const dow = mStart.getDay();
const padding = dow === 0 ? 6 : dow - 1;
const allDays = [...Array(padding).fill(null), ...days];
return (
<Popover open={open} onOpenChange={(v) => { if (!v) { setIsTyping(false); } onOpenChange(v); }}>
<PopoverTrigger asChild>
<div
className={cn(
"border-input bg-background flex h-full flex-1 cursor-pointer items-center rounded-md border px-3",
(disabled || readonly) && "cursor-not-allowed opacity-50",
!displayValue && !isTyping && "text-muted-foreground",
)}
onClick={() => { if (!disabled && !readonly) onOpenChange(true); }}
>
<CalendarIcon className="text-muted-foreground mr-2 h-4 w-4 shrink-0" />
<input
type="text"
inputMode="numeric"
value={isTyping ? typingValue : (displayValue || "")}
placeholder={label}
disabled={disabled || readonly}
onChange={(e) => handleTriggerInput(e.target.value)}
onClick={(e) => e.stopPropagation()}
onFocus={() => { if (!disabled && !readonly && !open) onOpenChange(true); }}
onBlur={() => { if (!open) setIsTyping(false); }}
className="h-full w-full bg-transparent text-sm font-normal outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
/>
</div>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
<div className="p-4">
{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((d) => (
<div key={d} className="text-muted-foreground p-2 text-center text-xs font-medium">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{allDays.map((d, idx) => {
if (!d) return <div key={idx} className="p-2" />;
const isCur = isSameMonth(d, currentMonth);
const isSel = selectedDate ? isSameDay(d, selectedDate) : false;
const isT = isTodayFn(d);
return (
<Button key={d.toISOString()} variant="ghost" size="sm" className={cn("h-8 w-8 p-0 text-xs", !isCur && "text-muted-foreground opacity-50", isSel && "bg-primary text-primary-foreground hover:bg-primary", isT && !isSel && "border-primary border")}
onClick={() => onSelect(d)} disabled={!isCur}>{format(d, "d")}</Button>
);
})}
</div>
</>
)}
</div>
</PopoverContent>
</Popover>
);
};
const RangeDatePicker = forwardRef<
HTMLDivElement,
{
@ -186,102 +488,38 @@ const RangeDatePicker = forwardRef<
const startDate = useMemo(() => parseDate(value[0], dateFormat), [value, dateFormat]);
const endDate = useMemo(() => parseDate(value[1], dateFormat), [value, dateFormat]);
const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]);
const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]);
const handleStartSelect = useCallback(
(date: Date | undefined) => {
if (date) {
const newStart = formatDate(date, dateFormat);
// 시작일이 종료일보다 크면 종료일도 같이 변경
if (endDate && date > endDate) {
onChange?.([newStart, newStart]);
} else {
onChange?.([newStart, value[1]]);
}
setOpenStart(false);
(date: Date) => {
const newStart = formatDate(date, dateFormat);
if (endDate && date > endDate) {
onChange?.([newStart, newStart]);
} else {
onChange?.([newStart, value[1]]);
}
setOpenStart(false);
},
[value, dateFormat, endDate, onChange],
);
const handleEndSelect = useCallback(
(date: Date | undefined) => {
if (date) {
const newEnd = formatDate(date, dateFormat);
// 종료일이 시작일보다 작으면 시작일도 같이 변경
if (startDate && date < startDate) {
onChange?.([newEnd, newEnd]);
} else {
onChange?.([value[0], newEnd]);
}
setOpenEnd(false);
(date: Date) => {
const newEnd = formatDate(date, dateFormat);
if (startDate && date < startDate) {
onChange?.([newEnd, newEnd]);
} else {
onChange?.([value[0], newEnd]);
}
setOpenEnd(false);
},
[value, dateFormat, startDate, onChange],
);
return (
<div ref={ref} className={cn("flex items-center gap-2 h-full", className)}>
{/* 시작 날짜 */}
<Popover open={openStart} onOpenChange={setOpenStart}>
<PopoverTrigger asChild>
<Button
variant="outline"
disabled={disabled || readonly}
className={cn("h-full flex-1 justify-start text-left font-normal", !value[0] && "text-muted-foreground")}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{value[0] || "시작일"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={startDate}
onSelect={handleStartSelect}
initialFocus
locale={ko}
disabled={(date) => {
if (minDateObj && date < minDateObj) return true;
if (maxDateObj && date > maxDateObj) return true;
return false;
}}
/>
</PopoverContent>
</Popover>
<RangeCalendarPopover open={openStart} onOpenChange={setOpenStart} selectedDate={startDate} onSelect={handleStartSelect} label="시작일" disabled={disabled} readonly={readonly} displayValue={value[0]} />
<span className="text-muted-foreground">~</span>
{/* 종료 날짜 */}
<Popover open={openEnd} onOpenChange={setOpenEnd}>
<PopoverTrigger asChild>
<Button
variant="outline"
disabled={disabled || readonly}
className={cn("h-full flex-1 justify-start text-left font-normal", !value[1] && "text-muted-foreground")}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{value[1] || "종료일"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={endDate}
onSelect={handleEndSelect}
initialFocus
locale={ko}
disabled={(date) => {
if (minDateObj && date < minDateObj) return true;
if (maxDateObj && date > maxDateObj) return true;
// 시작일보다 이전 날짜는 선택 불가
if (startDate && date < startDate) return true;
return false;
}}
/>
</PopoverContent>
</Popover>
<RangeCalendarPopover open={openEnd} onOpenChange={setOpenEnd} selectedDate={endDate} onSelect={handleEndSelect} label="종료일" disabled={disabled} readonly={readonly} displayValue={value[1]} />
</div>
);
});
@ -462,14 +700,60 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
}
};
const showLabel = label && style?.labelDisplay !== false;
const showLabel = label && style?.labelDisplay !== false && style?.labelDisplay !== "false";
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
// 라벨 위치 및 높이 계산
const labelPos = style?.labelPosition || "top";
const isHorizLabel = labelPos === "left" || labelPos === "right";
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
const labelGapValue = style?.labelGap || "8px";
const labelElement = showLabel ? (
<Label
htmlFor={id}
style={{
...(labelPos === "top" ? { position: "absolute" as const, top: `-${estimatedLabelHeight}px`, left: 0 } : {}),
...(labelPos === "bottom" ? { position: "absolute" as const, bottom: `-${estimatedLabelHeight}px`, left: 0 } : {}),
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}}
className="text-sm font-medium whitespace-nowrap"
>
{label}
{required && <span className="ml-0.5 text-orange-500">*</span>}
</Label>
) : null;
const dateContent = (
<div className={isHorizLabel ? "min-w-0 flex-1" : "h-full w-full"} style={isHorizLabel ? { height: "100%" } : undefined}>
{renderDatePicker()}
</div>
);
if (isHorizLabel && showLabel) {
return (
<div
ref={ref}
id={id}
style={{
width: componentWidth,
height: componentHeight,
display: "flex",
flexDirection: labelPos === "left" ? "row" : "row-reverse",
alignItems: "center",
gap: labelGapValue,
}}
>
{labelElement}
{dateContent}
</div>
);
}
return (
<div
@ -481,27 +765,8 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
height: componentHeight,
}}
>
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
{showLabel && (
<Label
htmlFor={id}
style={{
position: "absolute",
top: `-${estimatedLabelHeight}px`,
left: 0,
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}}
className="text-sm font-medium whitespace-nowrap"
>
{label}
{required && <span className="ml-0.5 text-orange-500">*</span>}
</Label>
)}
<div className="h-full w-full">
{renderDatePicker()}
</div>
{labelElement}
{dateContent}
</div>
);
});

View File

@ -961,36 +961,83 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
}
};
// 라벨이 표시될 때 입력 필드가 차지할 높이 계산
// 🔧 label prop이 없어도 style.labelText에서 가져올 수 있도록 수정
const actualLabel = label || style?.labelText;
const showLabel = actualLabel && style?.labelDisplay === true;
// size에서 우선 가져오고, 없으면 style에서 가져옴
const showLabel = actualLabel && style?.labelDisplay !== false && style?.labelDisplay !== "false";
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
// 라벨 위치 및 높이 계산
const labelPos = style?.labelPosition || "top";
const isHorizLabel = labelPos === "left" || labelPos === "right";
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; // 라벨 높이 + 여백
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
const labelGapValue = style?.labelGap || "8px";
// 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 테두리/배경/텍스트 스타일)
// RealtimePreview 래퍼가 외부 div에 스타일을 적용하지만,
// 내부 input/textarea가 자체 Tailwind 테두리를 가지므로 이를 제거하여 외부 스타일이 보이도록 함
// 커스텀 스타일 감지
const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border);
const hasCustomBackground = !!style?.backgroundColor;
const hasCustomRadius = !!style?.borderRadius;
// 텍스트 스타일 오버라이드 (내부 input/textarea에 직접 전달)
const customTextStyle: React.CSSProperties = {};
if (style?.color) customTextStyle.color = style.color;
if (style?.fontSize) customTextStyle.fontSize = style.fontSize;
if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight;
if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"];
const hasCustomText = Object.keys(customTextStyle).length > 0;
// 내부 input에 직접 적용할 텍스트 스타일 (fontSize, color, fontWeight, textAlign)
const inputTextStyle: React.CSSProperties | undefined = hasCustomText ? customTextStyle : undefined;
const labelElement = showLabel ? (
<Label
htmlFor={id}
style={{
...(labelPos === "top" ? { position: "absolute" as const, top: `-${estimatedLabelHeight}px`, left: 0 } : {}),
...(labelPos === "bottom" ? { position: "absolute" as const, bottom: `-${estimatedLabelHeight}px`, left: 0 } : {}),
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}}
className="text-sm font-medium whitespace-nowrap"
>
{actualLabel}
{required && <span className="ml-0.5 text-orange-500">*</span>}
</Label>
) : null;
const inputContent = (
<div
className={cn(
isHorizLabel ? "min-w-0 flex-1" : "h-full w-full",
hasCustomBorder && "[&_input]:border-0! [&_textarea]:border-0! [&_.border]:border-0!",
(hasCustomBorder || hasCustomRadius) && "[&_input]:rounded-none! [&_textarea]:rounded-none! [&_.rounded-md]:rounded-none!",
hasCustomBackground && "[&_input]:bg-transparent! [&_textarea]:bg-transparent!",
)}
style={{ ...(hasCustomText ? customTextStyle : {}), ...(isHorizLabel ? { height: "100%" } : {}) }}
>
{renderInput()}
</div>
);
if (isHorizLabel && showLabel) {
return (
<div
ref={ref}
id={id}
style={{
width: componentWidth,
height: componentHeight,
display: "flex",
flexDirection: labelPos === "left" ? "row" : "row-reverse",
alignItems: "center",
gap: labelGapValue,
}}
>
{labelElement}
{inputContent}
</div>
);
}
return (
<div
ref={ref}
@ -1001,38 +1048,8 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
height: componentHeight,
}}
>
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 (높이에 포함되지 않음) */}
{showLabel && (
<Label
htmlFor={id}
style={{
position: "absolute",
top: `-${estimatedLabelHeight}px`,
left: 0,
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}}
className="text-sm font-medium whitespace-nowrap"
>
{actualLabel}
{required && <span className="ml-0.5 text-orange-500">*</span>}
</Label>
)}
<div
className={cn(
"h-full w-full",
// 커스텀 테두리 설정 시, 내부 input/textarea의 기본 테두리 제거 (외부 래퍼 스타일이 보이도록)
hasCustomBorder && "[&_input]:border-0! [&_textarea]:border-0! [&_.border]:border-0!",
// 커스텀 모서리 설정 시, 내부 요소의 기본 모서리 제거 (외부 래퍼가 처리)
(hasCustomBorder || hasCustomRadius) && "[&_input]:rounded-none! [&_textarea]:rounded-none! [&_.rounded-md]:rounded-none!",
// 커스텀 배경 설정 시, 내부 input을 투명하게 (외부 배경이 보이도록)
hasCustomBackground && "[&_input]:bg-transparent! [&_textarea]:bg-transparent!",
)}
style={hasCustomText ? customTextStyle : undefined}
>
{renderInput()}
</div>
{labelElement}
{inputContent}
</div>
);
});

View File

@ -48,11 +48,9 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
onRowClick,
className,
formData: parentFormData,
groupedData,
...restProps
}) => {
// ScreenModal에서 전달된 groupedData (모달 간 데이터 전달용)
const groupedData = (restProps as any).groupedData || (restProps as any)._groupedData;
// componentId 결정: 직접 전달 또는 component 객체에서 추출
const effectiveComponentId = componentId || (restProps as any).component?.id;
@ -214,21 +212,20 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
const isModalMode = config.renderMode === "modal";
// 전역 리피터 등록
// 🆕 useCustomTable이 설정된 경우 mainTableName 사용 (실제 저장될 테이블)
// tableName이 비어있어도 반드시 등록 (repeaterSave 이벤트 발행 가드에 필요)
useEffect(() => {
const targetTableName =
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
const registrationKey = targetTableName || "__v2_repeater_same_table__";
if (targetTableName) {
if (!window.__v2RepeaterInstances) {
window.__v2RepeaterInstances = new Set();
}
window.__v2RepeaterInstances.add(targetTableName);
if (!window.__v2RepeaterInstances) {
window.__v2RepeaterInstances = new Set();
}
window.__v2RepeaterInstances.add(registrationKey);
return () => {
if (targetTableName && window.__v2RepeaterInstances) {
window.__v2RepeaterInstances.delete(targetTableName);
if (window.__v2RepeaterInstances) {
window.__v2RepeaterInstances.delete(registrationKey);
}
};
}, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]);
@ -423,62 +420,113 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
fkValue,
});
const response = await apiClient.post(
`/table-management/tables/${config.mainTableName}/data`,
{
let rows: any[] = [];
const useEntityJoinForLoad = config.sourceDetailConfig?.useEntityJoin;
if (useEntityJoinForLoad) {
// 엔티티 조인을 사용하여 데이터 로드 (part_code → item_info 자동 조인)
const searchParam = JSON.stringify({ [config.foreignKeyColumn!]: fkValue });
const params: Record<string, any> = {
page: 1,
size: 1000,
search: { [config.foreignKeyColumn]: fkValue },
autoFilter: true,
search: searchParam,
enableEntityJoin: true,
autoFilter: JSON.stringify({ enabled: true }),
};
const addJoinCols = config.sourceDetailConfig?.additionalJoinColumns;
if (addJoinCols && addJoinCols.length > 0) {
params.additionalJoinColumns = JSON.stringify(addJoinCols);
}
);
const response = await apiClient.get(
`/table-management/tables/${config.mainTableName}/data-with-joins`,
{ params }
);
const resultData = response.data?.data;
const rawRows = Array.isArray(resultData)
? resultData
: resultData?.data || resultData?.rows || [];
// 엔티티 조인 시 참조 테이블에 중복 레코드가 있으면 행이 늘어날 수 있으므로 id 기준 중복 제거
const seenIds = new Set<string>();
rows = rawRows.filter((row: any) => {
if (!row.id || seenIds.has(row.id)) return false;
seenIds.add(row.id);
return true;
});
} else {
const response = await apiClient.post(
`/table-management/tables/${config.mainTableName}/data`,
{
page: 1,
size: 1000,
dataFilter: {
enabled: true,
filters: [{ columnName: config.foreignKeyColumn, operator: "equals", value: fkValue }],
},
autoFilter: true,
}
);
rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || [];
}
const rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || [];
if (Array.isArray(rows) && rows.length > 0) {
console.log(`✅ [V2Repeater] 기존 데이터 ${rows.length}건 로드 완료`);
console.log(`✅ [V2Repeater] 기존 데이터 ${rows.length}건 로드 완료`, useEntityJoinForLoad ? "(엔티티 조인)" : "");
// isSourceDisplay 컬럼이 있으면 소스 테이블에서 표시 데이터 보강
const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay);
const sourceTable = config.dataSource?.sourceTable;
const fkColumn = config.dataSource?.foreignKey;
const refKey = config.dataSource?.referenceKey || "id";
// 엔티티 조인 사용 시: columnMapping으로 _display_ 필드 보강
const columnMapping = config.sourceDetailConfig?.columnMapping;
if (useEntityJoinForLoad && columnMapping) {
const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay);
rows.forEach((row: any) => {
sourceDisplayColumns.forEach((col) => {
const mappedKey = columnMapping[col.key];
const value = mappedKey ? row[mappedKey] : row[col.key];
row[`_display_${col.key}`] = value ?? "";
});
});
console.log("✅ [V2Repeater] 엔티티 조인 표시 데이터 보강 완료");
}
if (sourceDisplayColumns.length > 0 && sourceTable && fkColumn) {
try {
const fkValues = rows.map((row) => row[fkColumn]).filter(Boolean);
const uniqueValues = [...new Set(fkValues)];
// isSourceDisplay 컬럼이 있으면 소스 테이블에서 표시 데이터 보강 (엔티티 조인 미사용 시)
if (!useEntityJoinForLoad) {
const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay);
const sourceTable = config.dataSource?.sourceTable;
const fkColumn = config.dataSource?.foreignKey;
const refKey = config.dataSource?.referenceKey || "id";
if (uniqueValues.length > 0) {
// FK 값 기반으로 소스 테이블에서 해당 레코드만 조회
const sourcePromises = uniqueValues.map((val) =>
apiClient.post(`/table-management/tables/${sourceTable}/data`, {
page: 1, size: 1,
search: { [refKey]: val },
autoFilter: true,
}).then((r) => r.data?.data?.data || r.data?.data?.rows || [])
.catch(() => [])
);
const sourceResults = await Promise.all(sourcePromises);
const sourceMap = new Map<string, any>();
sourceResults.flat().forEach((sr: any) => {
if (sr[refKey]) sourceMap.set(String(sr[refKey]), sr);
});
if (sourceDisplayColumns.length > 0 && sourceTable && fkColumn) {
try {
const fkValues = rows.map((row) => row[fkColumn]).filter(Boolean);
const uniqueValues = [...new Set(fkValues)];
// 각 행에 소스 테이블의 표시 데이터 병합
rows.forEach((row: any) => {
const sourceRecord = sourceMap.get(String(row[fkColumn]));
if (sourceRecord) {
sourceDisplayColumns.forEach((col) => {
const displayValue = sourceRecord[col.key] ?? null;
row[col.key] = displayValue;
row[`_display_${col.key}`] = displayValue;
});
}
});
console.log("✅ [V2Repeater] 소스 테이블 표시 데이터 보강 완료");
if (uniqueValues.length > 0) {
const sourcePromises = uniqueValues.map((val) =>
apiClient.post(`/table-management/tables/${sourceTable}/data`, {
page: 1, size: 1,
search: { [refKey]: val },
autoFilter: true,
}).then((r) => r.data?.data?.data || r.data?.data?.rows || [])
.catch(() => [])
);
const sourceResults = await Promise.all(sourcePromises);
const sourceMap = new Map<string, any>();
sourceResults.flat().forEach((sr: any) => {
if (sr[refKey]) sourceMap.set(String(sr[refKey]), sr);
});
rows.forEach((row: any) => {
const sourceRecord = sourceMap.get(String(row[fkColumn]));
if (sourceRecord) {
sourceDisplayColumns.forEach((col) => {
const displayValue = sourceRecord[col.key] ?? null;
row[col.key] = displayValue;
row[`_display_${col.key}`] = displayValue;
});
}
});
console.log("✅ [V2Repeater] 소스 테이블 표시 데이터 보강 완료");
}
} catch (sourceError) {
console.warn("⚠️ [V2Repeater] 소스 테이블 조회 실패 (표시만 영향):", sourceError);
}
} catch (sourceError) {
console.warn("⚠️ [V2Repeater] 소스 테이블 조회 실패 (표시만 영향):", sourceError);
}
}
@ -965,90 +1013,113 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
[],
);
// 모달에서 전달된 groupedData를 초기 행 데이터로 변환 (컬럼 매핑 포함)
const groupedDataProcessedRef = useRef(false);
// sourceDetailConfig가 설정되고 groupedData(모달에서 전달된 마스터 데이터)가 있으면
// 마스터의 키를 추출하여 디테일 테이블에서 행을 조회 → 리피터에 자동 세팅
const sourceDetailLoadedRef = useRef(false);
useEffect(() => {
if (!groupedData || !Array.isArray(groupedData) || groupedData.length === 0) return;
if (groupedDataProcessedRef.current) return;
if (sourceDetailLoadedRef.current) return;
if (!groupedData || groupedData.length === 0) return;
if (!config.sourceDetailConfig) return;
groupedDataProcessedRef.current = true;
const { tableName, foreignKey, parentKey } = config.sourceDetailConfig;
if (!tableName || !foreignKey || !parentKey) return;
const newRows = groupedData.map((item: any, index: number) => {
const row: any = { _id: `grouped_${Date.now()}_${index}` };
const parentKeys = groupedData
.map((row) => row[parentKey])
.filter((v) => v !== undefined && v !== null && v !== "");
for (const col of config.columns) {
let sourceValue = item[(col as any).sourceKey || col.key];
if (parentKeys.length === 0) return;
// 카테고리 코드 → 라벨 변환 (접두사 무관, categoryLabelMap 기반)
if (typeof sourceValue === "string" && categoryLabelMap[sourceValue]) {
sourceValue = categoryLabelMap[sourceValue];
}
sourceDetailLoadedRef.current = true;
if (col.isSourceDisplay) {
row[col.key] = sourceValue ?? "";
row[`_display_${col.key}`] = sourceValue ?? "";
} else if (col.autoFill && col.autoFill.type !== "none") {
const autoValue = generateAutoFillValueSync(col, index, parentFormData);
if (autoValue !== undefined) {
row[col.key] = autoValue;
} else {
row[col.key] = "";
const loadSourceDetails = async () => {
try {
const uniqueKeys = [...new Set(parentKeys)] as string[];
const { useEntityJoin, columnMapping, additionalJoinColumns } = config.sourceDetailConfig!;
let detailRows: any[] = [];
if (useEntityJoin) {
// data-with-joins GET API 사용 (엔티티 조인 자동 적용)
const searchParam = JSON.stringify({ [foreignKey]: uniqueKeys.join("|") });
const params: Record<string, any> = {
page: 1,
size: 9999,
search: searchParam,
enableEntityJoin: true,
autoFilter: JSON.stringify({ enabled: true }),
};
if (additionalJoinColumns && additionalJoinColumns.length > 0) {
params.additionalJoinColumns = JSON.stringify(additionalJoinColumns);
}
} else if (sourceValue !== undefined) {
row[col.key] = sourceValue;
} else {
row[col.key] = "";
}
}
return row;
});
// 카테고리 컬럼의 코드 → 라벨 변환 (접두사 무관)
const categoryColSet = new Set(allCategoryColumns);
const codesToResolve = new Set<string>();
for (const row of newRows) {
for (const col of config.columns) {
const val = row[col.key] || row[`_display_${col.key}`];
if (typeof val === "string" && val && (categoryColSet.has(col.key) || col.autoFill?.type === "fromMainForm")) {
if (!categoryLabelMap[val]) {
codesToResolve.add(val);
}
}
}
}
if (codesToResolve.size > 0) {
apiClient.post("/table-categories/labels-by-codes", {
valueCodes: Array.from(codesToResolve),
}).then((resp) => {
if (resp.data?.success && resp.data.data) {
const labelData = resp.data.data as Record<string, string>;
setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
const convertedRows = newRows.map((row) => {
const updated = { ...row };
for (const col of config.columns) {
const val = updated[col.key];
if (typeof val === "string" && labelData[val]) {
updated[col.key] = labelData[val];
}
const dispKey = `_display_${col.key}`;
const dispVal = updated[dispKey];
if (typeof dispVal === "string" && labelData[dispVal]) {
updated[dispKey] = labelData[dispVal];
}
}
return updated;
const resp = await apiClient.get(`/table-management/tables/${tableName}/data-with-joins`, { params });
const resultData = resp.data?.data;
const rawRows = Array.isArray(resultData)
? resultData
: resultData?.data || resultData?.rows || [];
// 엔티티 조인 시 참조 테이블에 중복 레코드가 있으면 행이 늘어나므로 id 기준 중복 제거
const seenIds = new Set<string>();
detailRows = rawRows.filter((row: any) => {
if (!row.id || seenIds.has(row.id)) return false;
seenIds.add(row.id);
return true;
});
setData(convertedRows);
onDataChange?.(convertedRows);
} else {
// 기존 POST API 사용
const resp = await apiClient.post(`/table-management/tables/${tableName}/data`, {
page: 1,
size: 9999,
search: { [foreignKey]: uniqueKeys },
});
const resultData = resp.data?.data;
detailRows = Array.isArray(resultData)
? resultData
: resultData?.data || resultData?.rows || [];
}
}).catch(() => {});
}
setData(newRows);
onDataChange?.(newRows);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [groupedData, config.columns, generateAutoFillValueSync]);
if (detailRows.length === 0) {
console.warn("[V2Repeater] sourceDetail 조회 결과 없음:", { tableName, uniqueKeys });
return;
}
console.log("[V2Repeater] sourceDetail 조회 완료:", detailRows.length, "건", useEntityJoin ? "(엔티티 조인)" : "");
// 디테일 행을 리피터 컬럼에 매핑
const newRows = detailRows.map((detail, index) => {
const row: any = { _id: `src_detail_${Date.now()}_${index}` };
for (const col of config.columns) {
if (col.isSourceDisplay) {
// columnMapping이 있으면 조인 alias에서 값 가져오기 (표시용)
const mappedKey = columnMapping?.[col.key];
const value = mappedKey ? detail[mappedKey] : detail[col.key];
row[`_display_${col.key}`] = value ?? "";
// 원본 값도 저장 (DB persist용 - _display_ 접두사 없이)
if (detail[col.key] !== undefined) {
row[col.key] = detail[col.key];
}
} else if (col.autoFill) {
const autoValue = generateAutoFillValueSync(col, index, parentFormData);
row[col.key] = autoValue ?? "";
} else if (col.sourceKey && detail[col.sourceKey] !== undefined) {
row[col.key] = detail[col.sourceKey];
} else if (detail[col.key] !== undefined) {
row[col.key] = detail[col.key];
} else {
row[col.key] = "";
}
}
return row;
});
setData(newRows);
onDataChange?.(newRows);
} catch (error) {
console.error("[V2Repeater] sourceDetail 조회 실패:", error);
}
};
loadSourceDetails();
}, [groupedData, config.sourceDetailConfig, config.columns, generateAutoFillValueSync, parentFormData, onDataChange]);
// parentSequence 컬럼의 부모 필드 값이 변경되면 행 데이터 갱신
useEffect(() => {

View File

@ -23,7 +23,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { V2SelectProps, SelectOption } from "@/types/v2-components";
import { V2SelectProps, SelectOption, V2SelectFilter } from "@/types/v2-components";
import { Check, ChevronsUpDown, X, ArrowLeftRight } from "lucide-react";
import { apiClient } from "@/lib/api/client";
import V2FormContext from "./V2FormContext";
@ -80,7 +80,7 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
</SelectTrigger>
<SelectContent>
{options
.filter((option) => option.value !== "")
.filter((option) => option.value != null && option.value !== "")
.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
@ -112,6 +112,12 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
}
// 검색 가능 또는 다중 선택 → Combobox 사용
// null/undefined value를 가진 옵션 필터링 (cmdk가 value={null}일 때 크래시 발생)
const safeOptions = useMemo(() =>
options.filter((o) => o.value != null && o.value !== ""),
[options]
);
const selectedValues = useMemo(() => {
if (!value) return [];
return Array.isArray(value) ? value : [value];
@ -119,9 +125,9 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
const selectedLabels = useMemo(() => {
return selectedValues
.map((v) => options.find((o) => o.value === v)?.label)
.map((v) => safeOptions.find((o) => o.value === v)?.label)
.filter(Boolean) as string[];
}, [selectedValues, options]);
}, [selectedValues, safeOptions]);
const handleSelect = useCallback((selectedValue: string) => {
if (multiple) {
@ -191,7 +197,7 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
<Command
filter={(itemValue, search) => {
if (!search) return 1;
const option = options.find((o) => o.value === itemValue);
const option = safeOptions.find((o) => o.value === itemValue);
const label = (option?.label || option?.value || "").toLowerCase();
if (label.includes(search.toLowerCase())) return 1;
return 0;
@ -201,7 +207,7 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{options.map((option) => {
{safeOptions.map((option) => {
const displayLabel = option.label || option.value || "(빈 값)";
return (
<CommandItem
@ -655,6 +661,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
const labelColumn = config.labelColumn;
const apiEndpoint = config.apiEndpoint;
const staticOptions = config.options;
const configFilters = config.filters;
// 계층 코드 연쇄 선택 관련
const hierarchical = config.hierarchical;
@ -662,6 +669,54 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
// FormContext에서 부모 필드 값 가져오기 (Context가 없으면 null)
const formContext = useContext(V2FormContext);
/**
* API JSON으로
* field/user
*/
const resolvedFiltersJson = useMemo(() => {
if (!configFilters || configFilters.length === 0) return undefined;
const resolved: Array<{ column: string; operator: string; value: unknown }> = [];
for (const f of configFilters) {
const vt = f.valueType || "static";
// isNull/isNotNull은 값 불필요
if (f.operator === "isNull" || f.operator === "isNotNull") {
resolved.push({ column: f.column, operator: f.operator, value: null });
continue;
}
let resolvedValue: unknown = f.value;
if (vt === "field" && f.fieldRef) {
// 다른 폼 필드 참조
if (formContext) {
resolvedValue = formContext.getValue(f.fieldRef);
} else {
const fd = (props as any).formData;
resolvedValue = fd?.[f.fieldRef];
}
// 참조 필드 값이 비어있으면 이 필터 건너뜀
if (resolvedValue === undefined || resolvedValue === null || resolvedValue === "") continue;
} else if (vt === "user" && f.userField) {
// 로그인 사용자 정보 참조 (props에서 가져옴)
const userMap: Record<string, string | undefined> = {
companyCode: (props as any).companyCode,
userId: (props as any).userId,
deptCode: (props as any).deptCode,
userName: (props as any).userName,
};
resolvedValue = userMap[f.userField];
if (!resolvedValue) continue;
}
resolved.push({ column: f.column, operator: f.operator, value: resolvedValue });
}
return resolved.length > 0 ? JSON.stringify(resolved) : undefined;
}, [configFilters, formContext, props]);
// 부모 필드의 값 계산
const parentValue = useMemo(() => {
@ -684,6 +739,13 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
}
}, [parentValue, hierarchical, source]);
// 필터 조건이 변경되면 옵션 다시 로드
useEffect(() => {
if (resolvedFiltersJson !== undefined) {
setOptionsLoaded(false);
}
}, [resolvedFiltersJson]);
useEffect(() => {
// 이미 로드된 경우 스킵 (static 제외, 계층 구조 제외)
if (optionsLoaded && source !== "static") {
@ -731,11 +793,13 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
}
} else if (source === "db" && table) {
// DB 테이블에서 로드
const dbParams: Record<string, any> = {
value: valueColumn || "id",
label: labelColumn || "name",
};
if (resolvedFiltersJson) dbParams.filters = resolvedFiltersJson;
const response = await apiClient.get(`/entity/${table}/options`, {
params: {
value: valueColumn || "id",
label: labelColumn || "name",
},
params: dbParams,
});
const data = response.data;
if (data.success && data.data) {
@ -745,8 +809,10 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
// 엔티티(참조 테이블)에서 로드
const valueCol = entityValueColumn || "id";
const labelCol = entityLabelColumn || "name";
const entityParams: Record<string, any> = { value: valueCol, label: labelCol };
if (resolvedFiltersJson) entityParams.filters = resolvedFiltersJson;
const response = await apiClient.get(`/entity/${entityTable}/options`, {
params: { value: valueCol, label: labelCol },
params: entityParams,
});
const data = response.data;
if (data.success && data.data) {
@ -790,11 +856,13 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
}
} else if (source === "select" || source === "distinct") {
// 해당 테이블의 해당 컬럼에서 DISTINCT 값 조회
// tableName, columnName은 props에서 가져옴
// 🆕 columnName이 컴포넌트 ID 형식(comp_xxx)이면 유효하지 않으므로 건너뜀
const isValidColumnName = columnName && !columnName.startsWith("comp_");
if (tableName && isValidColumnName) {
const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`);
const distinctParams: Record<string, any> = {};
if (resolvedFiltersJson) distinctParams.filters = resolvedFiltersJson;
const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`, {
params: distinctParams,
});
const data = response.data;
if (data.success && data.data) {
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
@ -807,7 +875,11 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
}
}
setOptions(fetchedOptions);
// null/undefined value 필터링 (cmdk 크래시 방지)
const sanitized = fetchedOptions.filter(
(o) => o.value != null && String(o.value) !== ""
).map((o) => ({ ...o, value: String(o.value), label: o.label || String(o.value) }));
setOptions(sanitized);
setOptionsLoaded(true);
} catch (error) {
console.error("옵션 로딩 실패:", error);
@ -818,7 +890,43 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
};
loadOptions();
}, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue]);
}, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue, resolvedFiltersJson]);
// 레거시 평문값 → 카테고리 코드 자동 정규화 (한글 텍스트로 저장된 데이터 대응)
const resolvedValue = useMemo(() => {
if (!value || options.length === 0) return value;
const resolveOne = (v: string): string => {
if (options.some(o => o.value === v)) return v;
const trimmed = v.trim();
const match = options.find(o => {
const cleanLabel = o.label.replace(/^[\s└]+/, '').trim();
return cleanLabel === trimmed;
});
return match ? match.value : v;
};
if (Array.isArray(value)) {
const resolved = value.map(resolveOne);
return resolved.every((v, i) => v === value[i]) ? value : resolved;
}
// 콤마 구분 복합값 처리 (e.g., "구매품,판매품,CAT_xxx")
if (typeof value === "string" && value.includes(",")) {
const parts = value.split(",");
const resolved = parts.map(p => resolveOne(p.trim()));
const result = resolved.join(",");
return result === value ? value : result;
}
return resolveOne(value);
}, [value, options]);
// 정규화 결과가 원본과 다르면 onChange로 자동 업데이트 (저장 시 코드 변환)
useEffect(() => {
if (!onChange || options.length === 0 || !value || value === resolvedValue) return;
onChange(resolvedValue as string | string[]);
}, [resolvedValue]); // eslint-disable-line react-hooks/exhaustive-deps
// 같은 폼에서 참조 테이블(entityTable) 컬럼을 사용하는 다른 컴포넌트 자동 감지
const autoFillTargets = useMemo(() => {
@ -945,7 +1053,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
return (
<DropdownSelect
options={options}
value={value}
value={resolvedValue}
onChange={handleChangeWithAutoFill}
placeholder="선택"
searchable={config.mode === "combobox" ? true : config.searchable}
@ -961,7 +1069,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
return (
<RadioSelect
options={options}
value={typeof value === "string" ? value : value?.[0]}
value={typeof resolvedValue === "string" ? resolvedValue : resolvedValue?.[0]}
onChange={(v) => handleChangeWithAutoFill(v)}
disabled={isDisabled}
/>
@ -972,7 +1080,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
return (
<CheckSelect
options={options}
value={Array.isArray(value) ? value : value ? [value] : []}
value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
onChange={handleChangeWithAutoFill}
maxSelect={config.maxSelect}
disabled={isDisabled}
@ -983,7 +1091,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
return (
<TagSelect
options={options}
value={Array.isArray(value) ? value : value ? [value] : []}
value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
onChange={handleChangeWithAutoFill}
maxSelect={config.maxSelect}
disabled={isDisabled}
@ -994,7 +1102,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
return (
<TagboxSelect
options={options}
value={Array.isArray(value) ? value : value ? [value] : []}
value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
onChange={handleChangeWithAutoFill}
placeholder={config.placeholder || "선택하세요"}
maxSelect={config.maxSelect}
@ -1007,7 +1115,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
return (
<ToggleSelect
options={options}
value={typeof value === "string" ? value : value?.[0]}
value={typeof resolvedValue === "string" ? resolvedValue : resolvedValue?.[0]}
onChange={(v) => handleChangeWithAutoFill(v)}
disabled={isDisabled}
/>
@ -1017,7 +1125,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
return (
<SwapSelect
options={options}
value={Array.isArray(value) ? value : value ? [value] : []}
value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
onChange={handleChangeWithAutoFill}
maxSelect={config.maxSelect}
disabled={isDisabled}
@ -1028,7 +1136,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
return (
<DropdownSelect
options={options}
value={value}
value={resolvedValue}
onChange={handleChangeWithAutoFill}
disabled={isDisabled}
style={heightStyle}
@ -1037,21 +1145,23 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
}
};
const showLabel = label && style?.labelDisplay !== false;
const showLabel = label && style?.labelDisplay !== false && style?.labelDisplay !== "false";
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
// 라벨 위치 및 높이 계산
const labelPos = style?.labelPosition || "top";
const isHorizLabel = labelPos === "left" || labelPos === "right";
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
const labelGapValue = style?.labelGap || "8px";
// 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 테두리/배경/텍스트 스타일)
// 커스텀 스타일 감지
const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border);
const hasCustomBackground = !!style?.backgroundColor;
const hasCustomRadius = !!style?.borderRadius;
// 텍스트 스타일 오버라이드 (CSS 상속)
const customTextStyle: React.CSSProperties = {};
if (style?.color) customTextStyle.color = style.color;
if (style?.fontSize) customTextStyle.fontSize = style.fontSize;
@ -1059,6 +1169,58 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"];
const hasCustomText = Object.keys(customTextStyle).length > 0;
const labelElement = showLabel ? (
<Label
htmlFor={id}
style={{
...(labelPos === "top" ? { position: "absolute" as const, top: `-${estimatedLabelHeight}px`, left: 0 } : {}),
...(labelPos === "bottom" ? { position: "absolute" as const, bottom: `-${estimatedLabelHeight}px`, left: 0 } : {}),
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}}
className="text-sm font-medium whitespace-nowrap"
>
{label}
{required && <span className="text-orange-500 ml-0.5">*</span>}
</Label>
) : null;
const selectContent = (
<div
className={cn(
isHorizLabel ? "min-w-0 flex-1" : "h-full w-full",
hasCustomBorder && "[&_button]:border-0! **:data-[slot=select-trigger]:border-0! [&_.border]:border-0!",
(hasCustomBorder || hasCustomRadius) && "[&_button]:rounded-none! **:data-[slot=select-trigger]:rounded-none! [&_.rounded-md]:rounded-none!",
hasCustomBackground && "[&_button]:bg-transparent! **:data-[slot=select-trigger]:bg-transparent!",
)}
style={{ ...(hasCustomText ? customTextStyle : {}), ...(isHorizLabel ? { height: "100%" } : {}) }}
>
{renderSelect()}
</div>
);
if (isHorizLabel && showLabel) {
return (
<div
ref={ref}
id={id}
className={cn(isDesignMode && "pointer-events-none")}
style={{
width: componentWidth,
height: componentHeight,
display: "flex",
flexDirection: labelPos === "left" ? "row" : "row-reverse",
alignItems: "center",
gap: labelGapValue,
}}
>
{labelElement}
{selectContent}
</div>
);
}
return (
<div
ref={ref}
@ -1069,38 +1231,8 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
height: componentHeight,
}}
>
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
{showLabel && (
<Label
htmlFor={id}
style={{
position: "absolute",
top: `-${estimatedLabelHeight}px`,
left: 0,
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}}
className="text-sm font-medium whitespace-nowrap"
>
{label}
{required && <span className="text-orange-500 ml-0.5">*</span>}
</Label>
)}
<div
className={cn(
"h-full w-full",
// 커스텀 테두리 설정 시, 내부 select trigger의 기본 테두리 제거
hasCustomBorder && "[&_button]:border-0! **:data-[slot=select-trigger]:border-0! [&_.border]:border-0!",
// 커스텀 모서리 설정 시, 내부 요소의 기본 모서리 제거
(hasCustomBorder || hasCustomRadius) && "[&_button]:rounded-none! **:data-[slot=select-trigger]:rounded-none! [&_.rounded-md]:rounded-none!",
// 커스텀 배경 설정 시, 내부 요소를 투명하게
hasCustomBackground && "[&_button]:bg-transparent! **:data-[slot=select-trigger]:bg-transparent!",
)}
style={hasCustomText ? customTextStyle : undefined}
>
{renderSelect()}
</div>
{labelElement}
{selectContent}
</div>
);
}

View File

@ -31,6 +31,7 @@ import {
Wand2,
Check,
ChevronsUpDown,
ListTree,
} from "lucide-react";
import {
Command,
@ -983,6 +984,133 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
<Separator />
{/* 소스 디테일 자동 조회 설정 */}
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="enableSourceDetail"
checked={!!config.sourceDetailConfig}
onCheckedChange={(checked) => {
if (checked) {
updateConfig({
sourceDetailConfig: {
tableName: "",
foreignKey: "",
parentKey: "",
},
});
} else {
updateConfig({ sourceDetailConfig: undefined });
}
}}
/>
<label htmlFor="enableSourceDetail" className="text-xs font-medium flex items-center gap-1">
<ListTree className="h-3 w-3" />
</label>
</div>
<p className="text-[10px] text-muted-foreground">
.
</p>
{config.sourceDetailConfig && (
<div className="space-y-2 rounded border border-violet-200 bg-violet-50 p-2">
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs"
>
{config.sourceDetailConfig.tableName
? (allTables.find(t => t.tableName === config.sourceDetailConfig!.tableName)?.displayName || config.sourceDetailConfig.tableName)
: "테이블 선택..."
}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList className="max-h-48">
<CommandEmpty className="text-xs py-3 text-center"> .</CommandEmpty>
<CommandGroup>
{allTables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.tableName} ${table.displayName}`}
onSelect={() => {
updateConfig({
sourceDetailConfig: {
...config.sourceDetailConfig!,
tableName: table.tableName,
},
});
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", config.sourceDetailConfig!.tableName === table.tableName ? "opacity-100" : "opacity-0")} />
<span>{table.displayName}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px]"> FK </Label>
<Input
value={config.sourceDetailConfig.foreignKey || ""}
onChange={(e) =>
updateConfig({
sourceDetailConfig: {
...config.sourceDetailConfig!,
foreignKey: e.target.value,
},
})
}
placeholder="예: order_no"
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={config.sourceDetailConfig.parentKey || ""}
onChange={(e) =>
updateConfig({
sourceDetailConfig: {
...config.sourceDetailConfig!,
parentKey: e.target.value,
},
})
}
placeholder="예: order_no"
className="h-7 text-xs"
/>
</div>
</div>
<p className="text-[10px] text-violet-600">
[{config.sourceDetailConfig.parentKey || "?"}]
{" "}{config.sourceDetailConfig.tableName || "?"}.{config.sourceDetailConfig.foreignKey || "?"}
</p>
</div>
)}
</div>
<Separator />
{/* 기능 옵션 */}
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>

View File

@ -5,15 +5,16 @@
* .
*/
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Plus, Trash2, Loader2 } from "lucide-react";
import { Plus, Trash2, Loader2, Filter } from "lucide-react";
import { apiClient } from "@/lib/api/client";
import type { V2SelectFilter } from "@/types/v2-components";
interface ColumnOption {
columnName: string;
@ -25,6 +26,238 @@ interface CategoryValueOption {
valueLabel: string;
}
const OPERATOR_OPTIONS = [
{ value: "=", label: "같음 (=)" },
{ value: "!=", label: "다름 (!=)" },
{ value: ">", label: "초과 (>)" },
{ value: "<", label: "미만 (<)" },
{ value: ">=", label: "이상 (>=)" },
{ value: "<=", label: "이하 (<=)" },
{ value: "in", label: "포함 (IN)" },
{ value: "notIn", label: "미포함 (NOT IN)" },
{ value: "like", label: "유사 (LIKE)" },
{ value: "isNull", label: "NULL" },
{ value: "isNotNull", label: "NOT NULL" },
] as const;
const VALUE_TYPE_OPTIONS = [
{ value: "static", label: "고정값" },
{ value: "field", label: "폼 필드 참조" },
{ value: "user", label: "로그인 사용자" },
] as const;
const USER_FIELD_OPTIONS = [
{ value: "companyCode", label: "회사코드" },
{ value: "userId", label: "사용자ID" },
{ value: "deptCode", label: "부서코드" },
{ value: "userName", label: "사용자명" },
] as const;
/**
*
*/
const FilterConditionsSection: React.FC<{
filters: V2SelectFilter[];
columns: ColumnOption[];
loadingColumns: boolean;
targetTable: string;
onFiltersChange: (filters: V2SelectFilter[]) => void;
}> = ({ filters, columns, loadingColumns, targetTable, onFiltersChange }) => {
const addFilter = () => {
onFiltersChange([
...filters,
{ column: "", operator: "=", valueType: "static", value: "" },
]);
};
const updateFilter = (index: number, patch: Partial<V2SelectFilter>) => {
const updated = [...filters];
updated[index] = { ...updated[index], ...patch };
// valueType 변경 시 관련 필드 초기화
if (patch.valueType) {
if (patch.valueType === "static") {
updated[index].fieldRef = undefined;
updated[index].userField = undefined;
} else if (patch.valueType === "field") {
updated[index].value = undefined;
updated[index].userField = undefined;
} else if (patch.valueType === "user") {
updated[index].value = undefined;
updated[index].fieldRef = undefined;
}
}
// isNull/isNotNull 연산자는 값 불필요
if (patch.operator === "isNull" || patch.operator === "isNotNull") {
updated[index].value = undefined;
updated[index].fieldRef = undefined;
updated[index].userField = undefined;
updated[index].valueType = "static";
}
onFiltersChange(updated);
};
const removeFilter = (index: number) => {
onFiltersChange(filters.filter((_, i) => i !== index));
};
const needsValue = (op: string) => op !== "isNull" && op !== "isNotNull";
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
<Label className="text-xs font-medium"> </Label>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={addFilter}
className="h-6 px-2 text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<p className="text-muted-foreground text-[10px]">
{targetTable}
</p>
{loadingColumns && (
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
)}
{filters.length === 0 && (
<p className="text-muted-foreground py-2 text-center text-xs">
</p>
)}
<div className="space-y-3">
{filters.map((filter, index) => (
<div key={index} className="space-y-1.5 rounded-md border p-2">
{/* 행 1: 컬럼 + 연산자 + 삭제 */}
<div className="flex items-center gap-1.5">
{/* 컬럼 선택 */}
<Select
value={filter.column || ""}
onValueChange={(v) => updateFilter(index, { column: v })}
>
<SelectTrigger className="h-7 flex-1 text-[11px]">
<SelectValue placeholder="컬럼" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 연산자 선택 */}
<Select
value={filter.operator || "="}
onValueChange={(v) => updateFilter(index, { operator: v as V2SelectFilter["operator"] })}
>
<SelectTrigger className="h-7 w-[90px] shrink-0 text-[11px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPERATOR_OPTIONS.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 삭제 버튼 */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeFilter(index)}
className="text-destructive h-7 w-7 shrink-0 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 행 2: 값 유형 + 값 입력 (isNull/isNotNull 제외) */}
{needsValue(filter.operator) && (
<div className="flex items-center gap-1.5">
{/* 값 유형 */}
<Select
value={filter.valueType || "static"}
onValueChange={(v) => updateFilter(index, { valueType: v as V2SelectFilter["valueType"] })}
>
<SelectTrigger className="h-7 w-[100px] shrink-0 text-[11px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{VALUE_TYPE_OPTIONS.map((vt) => (
<SelectItem key={vt.value} value={vt.value}>
{vt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 값 입력 영역 */}
{(filter.valueType || "static") === "static" && (
<Input
value={String(filter.value ?? "")}
onChange={(e) => updateFilter(index, { value: e.target.value })}
placeholder={filter.operator === "in" || filter.operator === "notIn" ? "값1, 값2, ..." : "값 입력"}
className="h-7 flex-1 text-[11px]"
/>
)}
{filter.valueType === "field" && (
<Input
value={filter.fieldRef || ""}
onChange={(e) => updateFilter(index, { fieldRef: e.target.value })}
placeholder="참조할 필드명 (columnName)"
className="h-7 flex-1 text-[11px]"
/>
)}
{filter.valueType === "user" && (
<Select
value={filter.userField || ""}
onValueChange={(v) => updateFilter(index, { userField: v as V2SelectFilter["userField"] })}
>
<SelectTrigger className="h-7 flex-1 text-[11px]">
<SelectValue placeholder="사용자 필드" />
</SelectTrigger>
<SelectContent>
{USER_FIELD_OPTIONS.map((uf) => (
<SelectItem key={uf.value} value={uf.value}>
{uf.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
</div>
))}
</div>
</div>
);
};
interface V2SelectConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
@ -53,10 +286,52 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
const [categoryValues, setCategoryValues] = useState<CategoryValueOption[]>([]);
const [loadingCategoryValues, setLoadingCategoryValues] = useState(false);
// 필터용 컬럼 목록 (옵션 데이터 소스 테이블의 컬럼)
const [filterColumns, setFilterColumns] = useState<ColumnOption[]>([]);
const [loadingFilterColumns, setLoadingFilterColumns] = useState(false);
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
// 필터 대상 테이블 결정
const filterTargetTable = useMemo(() => {
const src = config.source || "static";
if (src === "entity") return config.entityTable;
if (src === "db") return config.table;
if (src === "distinct" || src === "select") return tableName;
return null;
}, [config.source, config.entityTable, config.table, tableName]);
// 필터 대상 테이블의 컬럼 로드
useEffect(() => {
if (!filterTargetTable) {
setFilterColumns([]);
return;
}
const loadFilterColumns = async () => {
setLoadingFilterColumns(true);
try {
const response = await apiClient.get(`/table-management/tables/${filterTargetTable}/columns?size=500`);
const data = response.data.data || response.data;
const columns = data.columns || data || [];
setFilterColumns(
columns.map((col: any) => ({
columnName: col.columnName || col.column_name || col.name,
columnLabel: col.displayName || col.display_name || col.columnLabel || col.column_label || col.columnName || col.column_name || col.name,
}))
);
} catch {
setFilterColumns([]);
} finally {
setLoadingFilterColumns(false);
}
};
loadFilterColumns();
}, [filterTargetTable]);
// 카테고리 타입이면 source를 자동으로 category로 설정
useEffect(() => {
if (isCategoryType && config.source !== "category") {
@ -518,6 +793,20 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
/>
</div>
)}
{/* 데이터 필터 조건 - static 소스 외 모든 소스에서 사용 */}
{effectiveSource !== "static" && filterTargetTable && (
<>
<Separator />
<FilterConditionsSection
filters={(config.filters as V2SelectFilter[]) || []}
columns={filterColumns}
loadingColumns={loadingFilterColumns}
targetTable={filterTargetTable}
onFiltersChange={(filters) => updateConfig("filters", filters)}
/>
</>
)}
</div>
);
};

View File

@ -356,9 +356,10 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 1. componentType이 "select-basic" 또는 "v2-select"인 경우
// 2. config.mode가 dropdown이 아닌 경우 (radio, check, tagbox 등)
const componentMode = (component as any).componentConfig?.mode || (component as any).config?.mode;
const isMultipleSelect = (component as any).componentConfig?.multiple;
const nonDropdownModes = ["radio", "check", "checkbox", "tag", "tagbox", "toggle", "swap", "combobox"];
const isNonDropdownMode = componentMode && nonDropdownModes.includes(componentMode);
const shouldUseV2Select = componentType === "select-basic" || componentType === "v2-select" || isNonDropdownMode;
const shouldUseV2Select = componentType === "select-basic" || componentType === "v2-select" || isNonDropdownMode || isMultipleSelect;
if (
(inputType === "category" || webType === "category") &&
@ -370,15 +371,18 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
try {
const { V2SelectRenderer } = require("@/lib/registry/components/v2-select/V2SelectRenderer");
const fieldName = columnName || component.id;
const currentValue = props.formData?.[fieldName] || "";
const handleChange = (value: any) => {
if (props.onFormDataChange) {
props.onFormDataChange(fieldName, value);
}
};
// V2SelectRenderer용 컴포넌트 데이터 구성
// 수평 라벨 감지
const catLabelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay;
const catLabelPosition = component.style?.labelPosition;
const catLabelText = (catLabelDisplay === true || catLabelDisplay === "true")
? (component.style?.labelText || (component as any).label || component.componentConfig?.label)
: undefined;
const catNeedsExternalHorizLabel = !!(
catLabelText &&
(catLabelPosition === "left" || catLabelPosition === "right")
);
const selectComponent = {
...component,
componentConfig: {
@ -394,6 +398,24 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
webType: "category",
};
const catStyle = catNeedsExternalHorizLabel
? {
...(component as any).style,
labelDisplay: false,
labelPosition: "top" as const,
width: "100%",
height: "100%",
borderWidth: undefined,
borderColor: undefined,
borderStyle: undefined,
border: undefined,
borderRadius: undefined,
}
: (component as any).style;
const catSize = catNeedsExternalHorizLabel
? { ...(component as any).size, width: undefined, height: undefined }
: (component as any).size;
const rendererProps = {
component: selectComponent,
formData: props.formData,
@ -401,12 +423,47 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
isDesignMode: props.isDesignMode,
isInteractive: props.isInteractive ?? !props.isDesignMode,
tableName,
style: (component as any).style,
size: (component as any).size,
style: catStyle,
size: catSize,
};
const rendererInstance = new V2SelectRenderer(rendererProps);
return rendererInstance.render();
const renderedCatSelect = rendererInstance.render();
if (catNeedsExternalHorizLabel) {
const labelGap = component.style?.labelGap || "8px";
const labelFontSize = component.style?.labelFontSize || "14px";
const labelColor = component.style?.labelColor || "#64748b";
const labelFontWeight = component.style?.labelFontWeight || "500";
const isRequired = component.required || (component as any).required;
const isLeft = catLabelPosition === "left";
return (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<label
style={{
position: "absolute",
top: "50%",
transform: "translateY(-50%)",
...(isLeft
? { right: "100%", marginRight: labelGap }
: { left: "100%", marginLeft: labelGap }),
fontSize: labelFontSize,
color: labelColor,
fontWeight: labelFontWeight,
whiteSpace: "nowrap",
}}
className="text-sm font-medium"
>
{catLabelText}
{isRequired && <span className="text-orange-500 ml-0.5">*</span>}
</label>
<div style={{ width: "100%", height: "100%" }}>
{renderedCatSelect}
</div>
</div>
);
}
return renderedCatSelect;
} catch (error) {
console.error("❌ V2SelectRenderer 로드 실패:", error);
}
@ -545,10 +602,12 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
let currentValue;
if (componentType === "modal-repeater-table" ||
componentType === "repeat-screen-modal" ||
componentType === "selected-items-detail-input" ||
componentType === "v2-repeater") {
componentType === "selected-items-detail-input") {
// EditModal/ScreenModal에서 전달된 groupedData가 있으면 우선 사용
currentValue = props.groupedData || formData?.[fieldName] || [];
} else if (componentType === "v2-repeater") {
// V2Repeater는 자체 데이터 관리 (groupedData는 메인 테이블 레코드이므로 사용하지 않음)
currentValue = formData?.[fieldName] || [];
} else {
currentValue = formData?.[fieldName] || "";
}
@ -616,18 +675,39 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
componentType === "modal-repeater-table" ||
componentType === "v2-input";
// 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true일 때만 라벨 표시)
// 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true/"true"일 때만 라벨 표시)
const labelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay;
const effectiveLabel = labelDisplay === true
const effectiveLabel = (labelDisplay === true || labelDisplay === "true")
? (component.style?.labelText || (component as any).label || component.componentConfig?.label)
: undefined;
// 🔧 수평 라벨(left/right) 감지 → 외부 flex 컨테이너에서 라벨 처리
const labelPosition = component.style?.labelPosition;
const isV2Component = componentType?.startsWith("v2-");
const needsExternalHorizLabel = !!(
isV2Component &&
effectiveLabel &&
(labelPosition === "left" || labelPosition === "right")
);
// 🔧 순서 중요! component.style 먼저, CSS 크기 속성은 size 기반으로 덮어씀
const mergedStyle = {
...component.style, // 원본 style (labelDisplay, labelText 등) - 먼저!
// CSS 크기 속성은 size에서 계산한 값으로 명시적 덮어쓰기 (우선순위 최고)
width: finalStyle.width,
height: finalStyle.height,
// 수평 라벨 → V2 컴포넌트에는 라벨 비활성화 (외부에서 처리)
...(needsExternalHorizLabel ? {
labelDisplay: false,
labelPosition: "top" as const,
width: "100%",
height: "100%",
borderWidth: undefined,
borderColor: undefined,
borderStyle: undefined,
border: undefined,
borderRadius: undefined,
} : {}),
};
// 컬럼 메타데이터 기반 componentConfig 병합 (DB 최신 설정 우선)
@ -646,7 +726,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
onClick,
onDragStart,
onDragEnd,
size: component.size || newComponent.defaultSize,
size: needsExternalHorizLabel
? { ...(component.size || newComponent.defaultSize), width: undefined, height: undefined }
: (component.size || newComponent.defaultSize),
position: component.position,
config: mergedComponentConfig,
componentConfig: mergedComponentConfig,
@ -654,8 +736,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
...(mergedComponentConfig || {}),
// 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선)
style: mergedStyle,
// 🆕 라벨 표시 (labelDisplay가 true일 때만)
label: effectiveLabel,
// 수평 라벨 → 외부에서 처리하므로 label 전달 안 함
label: needsExternalHorizLabel ? undefined : effectiveLabel,
// 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 (DB 메타데이터 우선)
inputType: (baseColumnName && columnMetaCache[screenTableName || ""]?.[baseColumnName]?.input_type) || (component as any).inputType || mergedComponentConfig?.inputType,
columnName: (component as any).columnName || component.componentConfig?.columnName,
@ -756,16 +838,51 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
NewComponentRenderer.prototype &&
NewComponentRenderer.prototype.render;
let renderedElement: React.ReactElement;
if (isClass) {
// 클래스 기반 렌더러 (AutoRegisteringComponentRenderer 상속)
const rendererInstance = new NewComponentRenderer(rendererProps);
return rendererInstance.render();
renderedElement = rendererInstance.render();
} else {
// 함수형 컴포넌트
// refreshKey를 React key로 전달하여 컴포넌트 리마운트 강제
return <NewComponentRenderer key={refreshKey} {...rendererProps} />;
renderedElement = <NewComponentRenderer key={refreshKey} {...rendererProps} />;
}
// 수평 라벨 → 라벨을 컴포넌트 영역 바깥에 absolute 배치, 입력은 100% 채움
if (needsExternalHorizLabel) {
const labelGap = component.style?.labelGap || "8px";
const labelFontSize = component.style?.labelFontSize || "14px";
const labelColor = component.style?.labelColor || "#64748b";
const labelFontWeight = component.style?.labelFontWeight || "500";
const isRequired = component.required || (component as any).required;
const isLeft = labelPosition === "left";
return (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<label
style={{
position: "absolute",
top: "50%",
transform: "translateY(-50%)",
...(isLeft
? { right: "100%", marginRight: labelGap }
: { left: "100%", marginLeft: labelGap }),
fontSize: labelFontSize,
color: labelColor,
fontWeight: labelFontWeight,
whiteSpace: "nowrap",
}}
className="text-sm font-medium"
>
{effectiveLabel}
{isRequired && <span className="text-orange-500 ml-0.5">*</span>}
</label>
<div style={{ width: "100%", height: "100%" }}>
{renderedElement}
</div>
</div>
);
}
return renderedElement;
}
} catch (error) {
console.error(`❌ 새 컴포넌트 렌더링 실패 (${componentType}):`, error);

View File

@ -1328,9 +1328,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
)}
</div>
{/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */}
{/* 확인 다이얼로그 */}
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent className="z-[99999]">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{getConfirmTitle()}</AlertDialogTitle>
<AlertDialogDescription>{getConfirmMessage()}</AlertDialogDescription>

View File

@ -24,29 +24,33 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
style,
...props
}) => {
// 컴포넌트 설정
const componentConfig = {
...config,
...component.config,
} as ImageDisplayConfig;
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
const objectFit = componentConfig.objectFit || "contain";
const altText = componentConfig.altText || "이미지";
const borderRadius = componentConfig.borderRadius ?? 8;
const showBorder = componentConfig.showBorder ?? true;
const backgroundColor = componentConfig.backgroundColor || "#f9fafb";
const placeholder = componentConfig.placeholder || "이미지 없음";
const imageSrc = component.value || componentConfig.imageUrl || "";
const componentStyle: React.CSSProperties = {
width: "100%",
height: "100%",
...component.style,
...style,
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
width: "100%",
};
// 디자인 모드 스타일
if (isDesignMode) {
componentStyle.border = "1px dashed #cbd5e1";
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
}
// 이벤트 핸들러
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
@ -88,7 +92,9 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
}}
>
{component.label}
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
{(component.required || componentConfig.required) && (
<span style={{ color: "#ef4444" }}>*</span>
)}
</label>
)}
@ -96,43 +102,53 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
style={{
width: "100%",
height: "100%",
border: "1px solid #d1d5db",
borderRadius: "8px",
border: showBorder ? "1px solid #d1d5db" : "none",
borderRadius: `${borderRadius}px`,
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f9fafb",
backgroundColor,
transition: "all 0.2s ease-in-out",
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
boxShadow: showBorder ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" : "none",
opacity: componentConfig.disabled ? 0.5 : 1,
cursor: componentConfig.disabled ? "not-allowed" : "default",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "#f97316";
e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)";
if (!componentConfig.disabled) {
if (showBorder) {
e.currentTarget.style.borderColor = "#f97316";
}
e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)";
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "#d1d5db";
e.currentTarget.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)";
if (showBorder) {
e.currentTarget.style.borderColor = "#d1d5db";
}
e.currentTarget.style.boxShadow = showBorder
? "0 1px 2px 0 rgba(0, 0, 0, 0.05)"
: "none";
}}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
{component.value || componentConfig.imageUrl ? (
{imageSrc ? (
<img
src={component.value || componentConfig.imageUrl}
alt={componentConfig.altText || "이미지"}
src={imageSrc}
alt={altText}
style={{
maxWidth: "100%",
maxHeight: "100%",
objectFit: componentConfig.objectFit || "contain",
objectFit,
}}
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
if (e.target?.parentElement) {
e.target.parentElement.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: center; gap: 8px; color: #6b7280; font-size: 14px;">
<div style="font-size: 24px;">🖼</div>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="2" x2="22" y2="22"/><path d="M10.41 10.41a2 2 0 1 1-2.83-2.83"/><line x1="13.5" y1="13.5" x2="6" y2="21"/><line x1="18" y1="12" x2="21" y2="15"/><path d="M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59"/><path d="M21 15V5a2 2 0 0 0-2-2H9"/></svg>
<div> </div>
</div>
`;
@ -150,8 +166,22 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
fontSize: "14px",
}}
>
<div style={{ fontSize: "32px" }}>🖼</div>
<div> </div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
<div>{placeholder}</div>
</div>
)}
</div>
@ -161,7 +191,6 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
/**
* ImageDisplay
*
*/
export const ImageDisplayWrapper: React.FC<ImageDisplayComponentProps> = (props) => {
return <ImageDisplayComponent {...props} />;

View File

@ -9,63 +9,166 @@ import { ImageDisplayConfig } from "./types";
export interface ImageDisplayConfigPanelProps {
config: ImageDisplayConfig;
onChange: (config: Partial<ImageDisplayConfig>) => void;
onChange?: (config: Partial<ImageDisplayConfig>) => void;
onConfigChange?: (config: Partial<ImageDisplayConfig>) => void;
}
/**
* ImageDisplay
* UI
*/
export const ImageDisplayConfigPanel: React.FC<ImageDisplayConfigPanelProps> = ({
config,
onChange,
onConfigChange,
}) => {
const handleChange = (key: keyof ImageDisplayConfig, value: any) => {
onChange({ [key]: value });
const update = { ...config, [key]: value };
onChange?.(update);
onConfigChange?.(update);
};
return (
<div className="space-y-4">
<div className="text-sm font-medium">
image-display
<div className="text-sm font-medium"> </div>
{/* 이미지 URL */}
<div className="space-y-2">
<Label htmlFor="imageUrl" className="text-xs">
URL
</Label>
<Input
id="imageUrl"
value={config.imageUrl || ""}
onChange={(e) => handleChange("imageUrl", e.target.value)}
placeholder="https://..."
className="h-8 text-xs"
/>
<p className="text-muted-foreground text-[10px]">
</p>
</div>
{/* file 관련 설정 */}
{/* 대체 텍스트 */}
<div className="space-y-2">
<Label htmlFor="placeholder"></Label>
<Label htmlFor="altText" className="text-xs">
(alt)
</Label>
<Input
id="altText"
value={config.altText || ""}
onChange={(e) => handleChange("altText", e.target.value)}
placeholder="이미지 설명"
className="h-8 text-xs"
/>
</div>
{/* 이미지 맞춤 */}
<div className="space-y-2">
<Label htmlFor="objectFit" className="text-xs">
(Object Fit)
</Label>
<Select
value={config.objectFit || "contain"}
onValueChange={(value) => handleChange("objectFit", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="contain">Contain ( , )</SelectItem>
<SelectItem value="cover">Cover ( , )</SelectItem>
<SelectItem value="fill">Fill ( )</SelectItem>
<SelectItem value="none">None ( )</SelectItem>
<SelectItem value="scale-down">Scale Down ()</SelectItem>
</SelectContent>
</Select>
</div>
{/* 테두리 둥글기 */}
<div className="space-y-2">
<Label htmlFor="borderRadius" className="text-xs">
(px)
</Label>
<Input
id="borderRadius"
type="number"
min="0"
max="50"
value={config.borderRadius ?? 8}
onChange={(e) => handleChange("borderRadius", parseInt(e.target.value) || 0)}
className="h-8 text-xs"
/>
</div>
{/* 배경 색상 */}
<div className="space-y-2">
<Label htmlFor="backgroundColor" className="text-xs">
</Label>
<div className="flex items-center gap-2">
<input
type="color"
value={config.backgroundColor || "#f9fafb"}
onChange={(e) => handleChange("backgroundColor", e.target.value)}
className="h-8 w-8 cursor-pointer rounded border"
/>
<Input
id="backgroundColor"
value={config.backgroundColor || "#f9fafb"}
onChange={(e) => handleChange("backgroundColor", e.target.value)}
className="h-8 flex-1 text-xs"
/>
</div>
</div>
{/* 플레이스홀더 */}
<div className="space-y-2">
<Label htmlFor="placeholder" className="text-xs">
</Label>
<Input
id="placeholder"
value={config.placeholder || ""}
onChange={(e) => handleChange("placeholder", e.target.value)}
placeholder="이미지 없음"
className="h-8 text-xs"
/>
</div>
{/* 공통 설정 */}
<div className="space-y-2">
<Label htmlFor="disabled"></Label>
{/* 테두리 표시 */}
<div className="flex items-center gap-2">
<Checkbox
id="disabled"
checked={config.disabled || false}
onCheckedChange={(checked) => handleChange("disabled", checked)}
id="showBorder"
checked={config.showBorder ?? true}
onCheckedChange={(checked) => handleChange("showBorder", checked)}
/>
<Label htmlFor="showBorder" className="text-xs cursor-pointer">
</Label>
</div>
<div className="space-y-2">
<Label htmlFor="required"> </Label>
<Checkbox
id="required"
checked={config.required || false}
onCheckedChange={(checked) => handleChange("required", checked)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="readonly"> </Label>
{/* 읽기 전용 */}
<div className="flex items-center gap-2">
<Checkbox
id="readonly"
checked={config.readonly || false}
onCheckedChange={(checked) => handleChange("readonly", checked)}
/>
<Label htmlFor="readonly" className="text-xs cursor-pointer">
</Label>
</div>
{/* 필수 입력 */}
<div className="flex items-center gap-2">
<Checkbox
id="required"
checked={config.required || false}
onCheckedChange={(checked) => handleChange("required", checked)}
/>
<Label htmlFor="required" className="text-xs cursor-pointer">
</Label>
</div>
</div>
);

View File

@ -6,9 +6,14 @@ import { ImageDisplayConfig } from "./types";
* ImageDisplay
*/
export const ImageDisplayDefaultConfig: ImageDisplayConfig = {
placeholder: "입력하세요",
// 공통 기본값
imageUrl: "",
altText: "이미지",
objectFit: "contain",
borderRadius: 8,
showBorder: true,
backgroundColor: "#f9fafb",
placeholder: "이미지 없음",
disabled: false,
required: false,
readonly: false,
@ -18,23 +23,31 @@ export const ImageDisplayDefaultConfig: ImageDisplayConfig = {
/**
* ImageDisplay
*
*/
export const ImageDisplayConfigSchema = {
placeholder: { type: "string", default: "" },
// 공통 스키마
imageUrl: { type: "string", default: "" },
altText: { type: "string", default: "이미지" },
objectFit: {
type: "enum",
values: ["contain", "cover", "fill", "none", "scale-down"],
default: "contain",
},
borderRadius: { type: "number", default: 8 },
showBorder: { type: "boolean", default: true },
backgroundColor: { type: "string", default: "#f9fafb" },
placeholder: { type: "string", default: "이미지 없음" },
disabled: { type: "boolean", default: false },
required: { type: "boolean", default: false },
readonly: { type: "boolean", default: false },
variant: {
type: "enum",
values: ["default", "outlined", "filled"],
default: "default"
variant: {
type: "enum",
values: ["default", "outlined", "filled"],
default: "default",
},
size: {
type: "enum",
values: ["sm", "md", "lg"],
default: "md"
size: {
type: "enum",
values: ["sm", "md", "lg"],
default: "md",
},
};

View File

@ -21,7 +21,13 @@ export const ImageDisplayDefinition = createComponentDefinition({
webType: "file",
component: ImageDisplayWrapper,
defaultConfig: {
placeholder: "입력하세요",
imageUrl: "",
altText: "이미지",
objectFit: "contain",
borderRadius: 8,
showBorder: true,
backgroundColor: "#f9fafb",
placeholder: "이미지 없음",
},
defaultSize: { width: 200, height: 200 },
configPanel: ImageDisplayConfigPanel,

View File

@ -6,20 +6,24 @@ import { ComponentConfig } from "@/types/component";
* ImageDisplay
*/
export interface ImageDisplayConfig extends ComponentConfig {
// file 관련 설정
// 이미지 관련 설정
imageUrl?: string;
altText?: string;
objectFit?: "contain" | "cover" | "fill" | "none" | "scale-down";
borderRadius?: number;
showBorder?: boolean;
backgroundColor?: string;
placeholder?: string;
// 공통 설정
disabled?: boolean;
required?: boolean;
readonly?: boolean;
placeholder?: string;
helperText?: string;
// 스타일 관련
variant?: "default" | "outlined" | "filled";
size?: "sm" | "md" | "lg";
// 이벤트 관련
onChange?: (value: any) => void;
onFocus?: () => void;
@ -37,7 +41,7 @@ export interface ImageDisplayProps {
config?: ImageDisplayConfig;
className?: string;
style?: React.CSSProperties;
// 이벤트 핸들러
onChange?: (value: any) => void;
onFocus?: () => void;

View File

@ -162,6 +162,79 @@ export function RepeaterTable({
// 초기 균등 분배 실행 여부 (마운트 시 한 번만 실행)
const initializedRef = useRef(false);
// 편집 가능한 컬럼 인덱스 목록 (방향키 네비게이션용)
const editableColIndices = useMemo(
() => visibleColumns.reduce<number[]>((acc, col, idx) => {
if (col.editable && !col.calculated) acc.push(idx);
return acc;
}, []),
[visibleColumns],
);
// 방향키로 리피터 셀 간 이동
const handleArrowNavigation = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const key = e.key;
if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)) return;
const target = e.target as HTMLElement;
const cell = target.closest("[data-repeater-row]") as HTMLElement | null;
if (!cell) return;
const row = Number(cell.dataset.repeaterRow);
const col = Number(cell.dataset.repeaterCol);
if (isNaN(row) || isNaN(col)) return;
// 텍스트 입력 중 좌/우 방향키는 커서 이동에 사용하므로 무시
if ((key === "ArrowLeft" || key === "ArrowRight") && target.tagName === "INPUT") {
const input = target as HTMLInputElement;
const len = input.value?.length ?? 0;
const pos = input.selectionStart ?? 0;
// 커서가 끝에 있을 때만 오른쪽 이동, 처음에 있을 때만 왼쪽 이동
if (key === "ArrowRight" && pos < len) return;
if (key === "ArrowLeft" && pos > 0) return;
}
let nextRow = row;
let nextColPos = editableColIndices.indexOf(col);
switch (key) {
case "ArrowUp":
nextRow = Math.max(0, row - 1);
break;
case "ArrowDown":
nextRow = Math.min(data.length - 1, row + 1);
break;
case "ArrowLeft":
nextColPos = Math.max(0, nextColPos - 1);
break;
case "ArrowRight":
nextColPos = Math.min(editableColIndices.length - 1, nextColPos + 1);
break;
}
const nextCol = editableColIndices[nextColPos];
if (nextRow === row && nextCol === col) return;
e.preventDefault();
const selector = `[data-repeater-row="${nextRow}"][data-repeater-col="${nextCol}"]`;
const nextCell = containerRef.current?.querySelector(selector) as HTMLElement | null;
if (!nextCell) return;
const focusable = nextCell.querySelector<HTMLElement>(
'input:not([disabled]), select:not([disabled]), [role="combobox"]:not([disabled]), button:not([disabled])',
);
if (focusable) {
focusable.focus();
if (focusable.tagName === "INPUT") {
(focusable as HTMLInputElement).select();
}
}
},
[editableColIndices, data.length],
);
// DnD 센서 설정
const sensors = useSensors(
useSensor(PointerSensor, {
@ -480,14 +553,20 @@ export function RepeaterTable({
const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
const value = row[column.field];
// 카테고리 라벨 변환 함수
// 카테고리/셀렉트 라벨 변환 함수
const getCategoryDisplayValue = (val: any): string => {
if (!val || typeof val !== "string") return val || "-";
// select 타입 컬럼의 selectOptions에서 라벨 찾기
if (column.selectOptions && column.selectOptions.length > 0) {
const matchedOption = column.selectOptions.find((opt) => opt.value === val);
if (matchedOption) return matchedOption.label;
}
const fieldName = column.field.replace(/^_display_/, "");
const isCategoryColumn = categoryColumns.includes(fieldName);
// categoryLabelMap에 직접 매핑이 있으면 바로 변환 (접두사 무관)
// categoryLabelMap에 직접 매핑이 있으면 바로 변환
if (categoryLabelMap[val]) return categoryLabelMap[val];
// 카테고리 컬럼이 아니면 원래 값 반환
@ -648,7 +727,7 @@ export function RepeaterTable({
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<div ref={containerRef} className="flex h-full flex-col border border-gray-200 bg-white">
<div ref={containerRef} className="flex h-full flex-col border border-gray-200 bg-white" onKeyDown={handleArrowNavigation}>
<div className="min-h-0 flex-1 overflow-x-auto overflow-y-auto">
<table
className="border-collapse text-xs"
@ -840,6 +919,8 @@ export function RepeaterTable({
width: `${columnWidths[col.field]}px`,
maxWidth: `${columnWidths[col.field]}px`,
}}
data-repeater-row={rowIndex}
data-repeater-col={colIndex}
>
{renderCell(row, col, rowIndex)}
</td>

View File

@ -40,6 +40,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
// 🆕 그룹화 설정 (예: groupByColumn: "inbound_number")
const groupByColumn = rawConfig.groupByColumn;
const groupBySourceColumn = rawConfig.groupBySourceColumn || rawConfig.groupByColumn;
const targetTable = rawConfig.targetTable;
// 🆕 DB 컬럼 정보를 적용한 config 생성 (webType → type 매핑)
@ -86,8 +87,8 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
// 🆕 formData와 config.fields의 필드 이름 매칭 확인
const matchingFields = configFields.filter((field: any) => formData?.[field.name] !== undefined);
// 🆕 그룹 키 값 (예: formData.inbound_number)
const groupKeyValue = groupByColumn ? formData?.[groupByColumn] : null;
// 🆕 그룹 키 값: groupBySourceColumn(formData 키)과 groupByColumn(DB 컬럼)을 분리
const groupKeyValue = groupBySourceColumn ? formData?.[groupBySourceColumn] : null;
// 🆕 분할 패널 위치 및 좌측 선택 데이터 확인
const splitPanelPosition = screenContext?.splitPanelPosition;

View File

@ -93,9 +93,12 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
[config, component.config, component.id],
);
// 소스 테이블의 키 필드명 (기본값: "item_id" → 하위 호환)
// 예: item_info 기반이면 "item_id", customer_mng 기반이면 "customer_id"
const sourceKeyField = componentConfig.sourceKeyField || "item_id";
// 소스 테이블의 키 필드명
// 우선순위: 1) config에서 명시적 설정 → 2) additionalFields에서 autoFillFrom:"id" 필드 감지 → 3) 하위 호환 "item_id"
const sourceKeyField = useMemo(() => {
// sourceKeyField는 config에서 직접 지정 (ConfigPanel 자동 감지에서 설정됨)
return componentConfig.sourceKeyField || "item_id";
}, [componentConfig.sourceKeyField]);
// 🆕 dataSourceId 우선순위: URL 파라미터 > 컴포넌트 설정 > component.id
const dataSourceId = useMemo(
@ -472,10 +475,16 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
if (allGroupsEmpty) {
// 디테일 데이터가 없어도 기본 레코드 생성 (품목-거래처 매핑 유지)
// autoFillFrom 필드 (item_id 등)는 반드시 포함시켜야 나중에 식별 가능
const baseRecord: Record<string, any> = {};
// sourceKeyField 자동 매핑 (item_id = originalData.id)
if (sourceKeyField && item.originalData?.id) {
baseRecord[sourceKeyField] = item.originalData.id;
}
// 나머지 autoFillFrom 필드 (sourceKeyField 제외)
additionalFields.forEach((f) => {
if (f.autoFillFrom && item.originalData) {
if (f.name !== sourceKeyField && f.autoFillFrom && item.originalData) {
const value = item.originalData[f.autoFillFrom];
if (value !== undefined && value !== null) {
baseRecord[f.name] = value;
@ -530,7 +539,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
return allRecords;
},
[componentConfig.fieldGroups, componentConfig.additionalFields],
[componentConfig.fieldGroups, componentConfig.additionalFields, sourceKeyField],
);
// 🆕 저장 요청 시에만 데이터 전달 (이벤트 리스너 방식)
@ -559,6 +568,12 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
if (hasParentMapping) {
try {
// 수정 모드 감지 (parentKeys 구성 전에 필요)
const urlParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null;
const urlEditMode = urlParams?.get("mode") === "edit";
const dataHasDbId = items.some(item => !!item.originalData?.id);
const isEditMode = urlEditMode || dataHasDbId;
// 부모 키 추출 (parentDataMapping에서)
const parentKeys: Record<string, any> = {};
@ -572,16 +587,25 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
}
componentConfig.parentDataMapping.forEach((mapping) => {
// 1차: formData(sourceData)에서 찾기
let value = getFieldValue(sourceData, mapping.sourceField);
let value: any;
// 2차: formData에 없으면 dataRegistry[sourceTable]에서 찾기
// v2-split-panel-layout에서 좌측 항목 선택 시 dataRegistry에 저장한 데이터 활용
if ((value === undefined || value === null) && mapping.sourceTable) {
const registryData = dataRegistry[mapping.sourceTable];
if (registryData && registryData.length > 0) {
const registryItem = registryData[0].originalData || registryData[0];
value = registryItem[mapping.sourceField];
// 수정 모드: originalData의 targetField 값 우선 사용
// 로드(editFilters)와 동일한 방식으로 FK 값을 가져와야
// 백엔드에서 기존 레코드를 정확히 매칭하여 UPDATE 수행 가능
if (isEditMode && items.length > 0 && items[0].originalData) {
value = items[0].originalData[mapping.targetField];
}
// 신규 모드 또는 originalData에 값 없으면 기존 로직
if (value === undefined || value === null) {
value = getFieldValue(sourceData, mapping.sourceField);
if ((value === undefined || value === null) && mapping.sourceTable) {
const registryData = dataRegistry[mapping.sourceTable];
if (registryData && registryData.length > 0) {
const registryItem = registryData[0].originalData || registryData[0];
value = registryItem[mapping.sourceField];
}
}
}
@ -637,15 +661,6 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const additionalFields = componentConfig.additionalFields || [];
const mainTable = componentConfig.targetTable!;
// 수정 모드 감지 (2가지 방법으로 확인)
// 1. URL에 mode=edit 파라미터 확인
// 2. 로드된 데이터에 DB id(PK)가 존재하는지 확인
// 수정 모드에서는 항상 deleteOrphans=true (기존 레코드 교체, 복제 방지)
const urlParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null;
const urlEditMode = urlParams?.get("mode") === "edit";
const dataHasDbId = items.some(item => !!item.originalData?.id);
const isEditMode = urlEditMode || dataHasDbId;
console.log("[SelectedItemsDetailInput] 수정 모드 감지:", {
urlEditMode,
dataHasDbId,
@ -677,27 +692,14 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
for (const item of items) {
// sourceKeyField 값 추출 (예: item_id 또는 customer_id)
// (수정 모드에서 autoFillFrom:"id"가 가격 레코드 PK를 반환하는 문제 방지)
let sourceKeyValue: string | null = null;
// 1순위: originalData에 sourceKeyField가 직접 있으면 사용 (수정 모드에서 정확한 값)
// 1순위: originalData에 sourceKeyField가 직접 있으면 사용 (수정 모드)
if (item.originalData && item.originalData[sourceKeyField]) {
sourceKeyValue = item.originalData[sourceKeyField];
}
// 2순위: autoFillFrom 로직 (신규 등록 모드에서 사용)
if (!sourceKeyValue) {
mainGroups.forEach((group) => {
const groupFields = additionalFields.filter((f) => f.groupId === group.id);
groupFields.forEach((field) => {
if (field.name === sourceKeyField && field.autoFillFrom && item.originalData) {
sourceKeyValue = item.originalData[field.autoFillFrom] || null;
}
});
});
}
// 3순위: fallback (최후의 수단)
// 2순위: 원본 데이터의 id를 sourceKeyField 값으로 사용 (신규 등록 모드)
if (!sourceKeyValue && item.originalData) {
sourceKeyValue = item.originalData.id || null;
}

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useMemo, useEffect } from "react";
import React, { useState, useMemo, useEffect, useRef } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
@ -10,7 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Card, CardContent } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Plus, X, ChevronDown, ChevronRight } from "lucide-react";
import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, FieldGroup, DisplayItem, DisplayItemType, EmptyBehavior, DisplayFieldFormat } from "./types";
import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, FieldGroup, DisplayItem, DisplayItemType, EmptyBehavior, DisplayFieldFormat, AutoDetectedFk } from "./types";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Check, ChevronsUpDown } from "lucide-react";
@ -97,7 +97,10 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
// 🆕 원본/대상 테이블 컬럼 상태 (내부에서 로드)
const [loadedSourceTableColumns, setLoadedSourceTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string; inputType?: string }>>([]);
const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string; inputType?: string; codeCategory?: string }>>([]);
const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string; inputType?: string; codeCategory?: string; referenceTable?: string; referenceColumn?: string }>>([]);
// FK 자동 감지 결과
const [autoDetectedFks, setAutoDetectedFks] = useState<AutoDetectedFk[]>([]);
// 🆕 원본 테이블 컬럼 로드
useEffect(() => {
@ -130,10 +133,11 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
loadColumns();
}, [config.sourceTable]);
// 🆕 대상 테이블 컬럼 로드
// 🆕 대상 테이블 컬럼 로드 (referenceTable/referenceColumn 포함)
useEffect(() => {
if (!config.targetTable) {
setLoadedTargetTableColumns([]);
setAutoDetectedFks([]);
return;
}
@ -149,7 +153,10 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
columnName: col.columnName,
columnLabel: col.displayName || col.columnLabel || col.columnName,
dataType: col.dataType,
inputType: col.inputType, // 🔧 inputType 추가
inputType: col.inputType,
codeCategory: col.codeCategory,
referenceTable: col.referenceTable,
referenceColumn: col.referenceColumn,
})));
console.log("✅ 대상 테이블 컬럼 로드 성공:", columns.length);
}
@ -161,6 +168,76 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
loadColumns();
}, [config.targetTable]);
// FK 자동 감지 (ref로 무한 루프 방지)
const fkAutoAppliedRef = useRef(false);
// targetTable 컬럼이 로드되면 entity FK 컬럼 감지
const detectedFks = useMemo<AutoDetectedFk[]>(() => {
if (!config.targetTable || loadedTargetTableColumns.length === 0) return [];
const entityFkColumns = loadedTargetTableColumns.filter(
(col) => col.inputType === "entity" && col.referenceTable
);
if (entityFkColumns.length === 0) return [];
return entityFkColumns.map((col) => {
let mappingType: "source" | "parent" | "unknown" = "unknown";
if (config.sourceTable && col.referenceTable === config.sourceTable) {
mappingType = "source";
} else if (config.sourceTable && col.referenceTable !== config.sourceTable) {
mappingType = "parent";
}
return {
columnName: col.columnName,
columnLabel: col.columnLabel,
referenceTable: col.referenceTable!,
referenceColumn: col.referenceColumn || "id",
mappingType,
};
});
}, [config.targetTable, config.sourceTable, loadedTargetTableColumns]);
// 감지 결과를 state에 반영
useEffect(() => {
setAutoDetectedFks(detectedFks);
}, [detectedFks]);
// 자동 매핑 적용 (최초 1회만, targetTable 변경 시 리셋)
useEffect(() => {
fkAutoAppliedRef.current = false;
}, [config.targetTable]);
useEffect(() => {
if (fkAutoAppliedRef.current || detectedFks.length === 0) return;
const sourceFk = detectedFks.find((fk) => fk.mappingType === "source");
const parentFks = detectedFks.filter((fk) => fk.mappingType === "parent");
let changed = false;
// sourceKeyField 자동 설정
if (sourceFk && !config.sourceKeyField) {
console.log("🔗 sourceKeyField 자동 설정:", sourceFk.columnName);
handleChange("sourceKeyField", sourceFk.columnName);
changed = true;
}
// parentDataMapping 자동 생성 (기존에 없을 때만)
if (parentFks.length > 0 && (!config.parentDataMapping || config.parentDataMapping.length === 0)) {
const autoMappings = parentFks.map((fk) => ({
sourceTable: fk.referenceTable,
sourceField: "id",
targetField: fk.columnName,
}));
console.log("🔗 parentDataMapping 자동 생성:", autoMappings);
handleChange("parentDataMapping", autoMappings);
changed = true;
}
if (changed) {
fkAutoAppliedRef.current = true;
}
}, [detectedFks]);
// 🆕 필드 그룹 변경 시 로컬 입력 상태 동기화
useEffect(() => {
setLocalFieldGroups(config.fieldGroups || []);
@ -898,6 +975,37 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
<p className="text-[10px] text-gray-500 sm:text-xs"> </p>
</div>
{/* FK 자동 감지 결과 표시 */}
{autoDetectedFks.length > 0 && (
<div className="rounded-md border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-950">
<p className="mb-2 text-xs font-medium text-blue-700 dark:text-blue-300">
FK ({autoDetectedFks.length})
</p>
<div className="space-y-1">
{autoDetectedFks.map((fk) => (
<div key={fk.columnName} className="flex items-center gap-2 text-[10px] sm:text-xs">
<span className={cn(
"rounded px-1.5 py-0.5 font-mono text-[9px]",
fk.mappingType === "source"
? "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300"
: fk.mappingType === "parent"
? "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300"
: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
)}>
{fk.mappingType === "source" ? "원본" : fk.mappingType === "parent" ? "부모" : "미분류"}
</span>
<span className="font-mono text-muted-foreground">{fk.columnName}</span>
<span className="text-muted-foreground">-&gt;</span>
<span className="font-mono">{fk.referenceTable}</span>
</div>
))}
</div>
<p className="mt-2 text-[9px] text-blue-600 dark:text-blue-400">
. sourceKeyField와 parentDataMapping이 .
</p>
</div>
)}
{/* 표시할 원본 데이터 컬럼 */}
<div className="space-y-2">
<Label className="text-xs font-semibold sm:text-sm"> </Label>
@ -961,7 +1069,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
<div className="space-y-2">
<Label className="text-xs font-semibold sm:text-sm"> </Label>
{localFields.map((field, index) => (
{localFields.map((field, index) => {
return (
<Card key={index} className="border-2">
<CardContent className="space-y-2 pt-3 sm:space-y-3 sm:pt-4">
<div className="flex items-center justify-between">
@ -1255,7 +1364,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
</div>
</CardContent>
</Card>
))}
);
})}
<Button
type="button"
@ -2392,9 +2502,18 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
</p>
<div className="space-y-2">
{(config.parentDataMapping || []).map((mapping, index) => (
<Card key={index} className="p-3">
{(config.parentDataMapping || []).map((mapping, index) => {
const isAutoDetected = autoDetectedFks.some(
(fk) => fk.mappingType === "parent" && fk.columnName === mapping.targetField
);
return (
<Card key={index} className={cn("p-3", isAutoDetected && "border-orange-200 bg-orange-50/30 dark:border-orange-800 dark:bg-orange-950/30")}>
<div className="space-y-2">
{isAutoDetected && (
<span className="inline-block rounded bg-orange-100 px-1.5 py-0.5 text-[9px] font-medium text-orange-700 dark:bg-orange-900 dark:text-orange-300">
FK
</span>
)}
{/* 소스 테이블 선택 */}
<div className="space-y-1">
<Label className="text-[9px] sm:text-[10px]"> </Label>
@ -2637,7 +2756,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
</div>
</div>
</Card>
))}
);
})}
</div>
</div>

View File

@ -139,6 +139,23 @@ export interface ParentDataMapping {
defaultValue?: any;
}
/**
* FK
* table_type_columns의 entity
*/
export interface AutoDetectedFk {
/** 대상 테이블의 FK 컬럼명 (예: item_id, customer_id) */
columnName: string;
/** 컬럼 라벨 (예: 품목 ID) */
columnLabel?: string;
/** 참조 테이블명 (예: item_info, customer_mng) */
referenceTable: string;
/** 참조 컬럼명 (예: item_number, customer_code) */
referenceColumn: string;
/** 매핑 유형: source(원본 데이터 FK) 또는 parent(부모 화면 FK) */
mappingType: "source" | "parent" | "unknown";
}
/**
* SelectedItemsDetailInput
*/
@ -155,6 +172,13 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig {
*/
sourceTable?: string;
/**
* ( FK )
* : item_info "item_id", customer_mng "customer_id"
*
*/
sourceKeyField?: string;
/**
* (name, label, width)
*

View File

@ -36,6 +36,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { toast } from "sonner";
import { useScreenContextOptional } from "@/contexts/ScreenContext";
import { apiClient } from "@/lib/api/client";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
export interface SplitPanelLayout2ComponentProps extends ComponentRendererProps {
// 추가 props
@ -92,6 +93,9 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
const [leftActiveTab, setLeftActiveTab] = useState<string | null>(null);
const [rightActiveTab, setRightActiveTab] = useState<string | null>(null);
// 카테고리 코드→라벨 매핑
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
// 프론트엔드 그룹핑 함수
const groupData = useCallback(
(data: Record<string, any>[], groupingConfig: GroupingConfig, columns: ColumnConfig[]): Record<string, any>[] => {
@ -185,17 +189,17 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
}
});
// 탭 목록 생성
// 탭 목록 생성 (카테고리 라벨 변환 적용)
const tabs = Array.from(valueCount.entries()).map(([value, count]) => ({
id: value,
label: value,
label: categoryLabelMap[value] || value,
count: tabConfig.showCount ? count : 0,
}));
console.log(`[SplitPanelLayout2] 탭 생성: ${tabs.length}개 탭 (컬럼: ${sourceColumn})`);
return tabs;
},
[],
[categoryLabelMap],
);
// 탭으로 필터링된 데이터 반환
@ -1000,10 +1004,38 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
console.log("[SplitPanelLayout2] 좌측 액션 버튼 모달 열기:", modalScreenId);
break;
case "edit":
// 좌측 패널에서 수정 (필요시 구현)
console.log("[SplitPanelLayout2] 좌측 수정 액션:", btn);
case "edit": {
if (!selectedLeftItem) {
toast.error("수정할 항목을 선택해주세요.");
return;
}
const editModalScreenId = btn.modalScreenId || config.leftPanel?.editModalScreenId || config.leftPanel?.addModalScreenId;
if (!editModalScreenId) {
toast.error("연결된 모달 화면이 없습니다.");
return;
}
const editEvent = new CustomEvent("openEditModal", {
detail: {
screenId: editModalScreenId,
title: btn.label || "수정",
modalSize: "lg",
editData: selectedLeftItem,
isCreateMode: false,
onSave: () => {
loadLeftData();
if (selectedLeftItem) {
loadRightData(selectedLeftItem);
}
},
},
});
window.dispatchEvent(editEvent);
console.log("[SplitPanelLayout2] 좌측 수정 모달 열기:", selectedLeftItem);
break;
}
case "delete":
// 좌측 패널에서 삭제 (필요시 구현)
@ -1018,7 +1050,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
break;
}
},
[config.leftPanel?.addModalScreenId, loadLeftData],
[config.leftPanel?.addModalScreenId, config.leftPanel?.editModalScreenId, loadLeftData, loadRightData, selectedLeftItem],
);
// 컬럼 라벨 로드
@ -1241,6 +1273,55 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
config.rightPanel?.tableName,
]);
// 카테고리 컬럼에 대한 라벨 매핑 로드
useEffect(() => {
if (isDesignMode) return;
const loadCategoryLabels = async () => {
const allColumns = new Set<string>();
const tableName = config.leftPanel?.tableName || config.rightPanel?.tableName;
if (!tableName) return;
// 좌우 패널의 표시 컬럼에서 카테고리 후보 수집
for (const col of config.leftPanel?.displayColumns || []) {
allColumns.add(col.name);
}
for (const col of config.rightPanel?.displayColumns || []) {
allColumns.add(col.name);
}
// 탭 소스 컬럼도 추가
if (config.rightPanel?.tabConfig?.tabSourceColumn) {
allColumns.add(config.rightPanel.tabConfig.tabSourceColumn);
}
if (config.leftPanel?.tabConfig?.tabSourceColumn) {
allColumns.add(config.leftPanel.tabConfig.tabSourceColumn);
}
const labelMap: Record<string, string> = {};
for (const columnName of allColumns) {
try {
const result = await getCategoryValues(tableName, columnName);
if (result.success && Array.isArray(result.data) && result.data.length > 0) {
for (const item of result.data) {
if (item.valueCode && item.valueLabel) {
labelMap[item.valueCode] = item.valueLabel;
}
}
}
} catch {
// 카테고리가 아닌 컬럼은 무시
}
}
if (Object.keys(labelMap).length > 0) {
setCategoryLabelMap(labelMap);
}
};
loadCategoryLabels();
}, [isDesignMode, config.leftPanel?.tableName, config.rightPanel?.tableName, config.leftPanel?.displayColumns, config.rightPanel?.displayColumns, config.rightPanel?.tabConfig?.tabSourceColumn, config.leftPanel?.tabConfig?.tabSourceColumn]);
// 컴포넌트 언마운트 시 DataProvider 해제
useEffect(() => {
return () => {
@ -1250,6 +1331,23 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
};
}, [screenContext, component.id]);
// 카테고리 코드를 라벨로 변환
const resolveCategoryLabel = useCallback(
(value: any): string => {
if (value === null || value === undefined) return "";
const strVal = String(value);
if (categoryLabelMap[strVal]) return categoryLabelMap[strVal];
// 콤마 구분 다중 값 처리
if (strVal.includes(",")) {
const codes = strVal.split(",").map((c) => c.trim()).filter(Boolean);
const labels = codes.map((code) => categoryLabelMap[code] || code);
return labels.join(", ");
}
return strVal;
},
[categoryLabelMap],
);
// 컬럼 값 가져오기 (sourceTable 및 엔티티 참조 고려)
const getColumnValue = useCallback(
(item: any, col: ColumnConfig): any => {
@ -1547,7 +1645,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
const displayColumns = config.leftPanel?.displayColumns || [];
const pkColumn = getLeftPrimaryKeyColumn();
// 값 렌더링 (배지 지원)
// 값 렌더링 (배지 지원 + 카테고리 라벨 변환)
const renderCellValue = (item: any, col: ColumnConfig) => {
const value = item[col.name];
if (value === null || value === undefined) return "-";
@ -1558,7 +1656,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
<div className="flex flex-wrap gap-1">
{value.map((v, vIdx) => (
<Badge key={vIdx} variant="secondary" className="text-xs">
{formatValue(v, col.format)}
{resolveCategoryLabel(v) || formatValue(v, col.format)}
</Badge>
))}
</div>
@ -1567,14 +1665,17 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
// 배지 타입이지만 단일 값인 경우
if (col.displayConfig?.displayType === "badge") {
const label = resolveCategoryLabel(value);
return (
<Badge variant="secondary" className="text-xs">
{formatValue(value, col.format)}
{label !== String(value) ? label : formatValue(value, col.format)}
</Badge>
);
}
// 기본 텍스트
// 카테고리 라벨 변환 시도 후 기본 텍스트
const label = resolveCategoryLabel(value);
if (label !== String(value)) return label;
return formatValue(value, col.format);
};
@ -1821,9 +1922,12 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
/>
</TableCell>
)}
{displayColumns.map((col, colIdx) => (
<TableCell key={colIdx}>{formatValue(getColumnValue(item, col), col.format)}</TableCell>
))}
{displayColumns.map((col, colIdx) => {
const rawVal = getColumnValue(item, col);
const resolved = resolveCategoryLabel(rawVal);
const display = resolved !== String(rawVal ?? "") ? resolved : formatValue(rawVal, col.format);
return <TableCell key={colIdx}>{display || "-"}</TableCell>;
})}
{(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
<TableCell className="text-center">
<div className="flex justify-center gap-1">
@ -2133,7 +2237,12 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
// 새로운 actionButtons 배열 기반 렌더링 (빈 배열이면 버튼 없음)
config.leftPanel.actionButtons.length > 0 && (
<div className="flex items-center gap-2">
{config.leftPanel.actionButtons.map((btn, idx) => (
{config.leftPanel.actionButtons
.filter((btn) => {
if (btn.showCondition === "selected") return !!selectedLeftItem;
return true;
})
.map((btn, idx) => (
<Button
key={idx}
size="sm"

View File

@ -10,11 +10,74 @@ import { TableListConfig, ColumnConfig } from "./types";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { tableTypeApi } from "@/lib/api/screen";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check, Lock, Unlock, Database, Table2, Link2 } from "lucide-react";
import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check, Lock, Unlock, Database, Table2, Link2, GripVertical, X } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { cn } from "@/lib/utils";
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
/**
* (v2-split-panel-layout의 SortableColumnRow )
*/
function SortableColumnRow({
id,
col,
index,
isEntityJoin,
onLabelChange,
onWidthChange,
onRemove,
}: {
id: string;
col: ColumnConfig;
index: number;
isEntityJoin?: boolean;
onLabelChange: (value: string) => void;
onWidthChange: (value: number) => void;
onRemove: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style = { transform: CSS.Transform.toString(transform), transition };
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"bg-card flex items-center gap-1.5 rounded-md border px-2 py-1.5",
isDragging && "z-50 opacity-50 shadow-md",
isEntityJoin && "border-blue-200 bg-blue-50/30",
)}
>
<div {...attributes} {...listeners} className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
<GripVertical className="h-3 w-3" />
</div>
{isEntityJoin ? (
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
) : (
<span className="text-muted-foreground w-5 shrink-0 text-center text-[10px] font-medium">#{index + 1}</span>
)}
<Input
value={col.displayName || col.columnName}
onChange={(e) => onLabelChange(e.target.value)}
placeholder="표시명"
className="h-6 min-w-0 flex-1 text-xs"
/>
<Input
value={col.width || ""}
onChange={(e) => onWidthChange(parseInt(e.target.value) || 100)}
placeholder="너비"
className="h-6 w-14 shrink-0 text-xs"
/>
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0">
<X className="h-3 w-3" />
</Button>
</div>
);
}
export interface TableListConfigPanelProps {
config: TableListConfig;
@ -348,11 +411,11 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
const existingColumn = config.columns?.find((col) => col.columnName === columnName);
if (existingColumn) return;
// tableColumns에서 해당 컬럼의 라벨 정보 찾기
// tableColumns → availableColumns 순서로 한국어 라벨 찾기
const columnInfo = tableColumns?.find((col: any) => (col.columnName || col.name) === columnName);
const availableColumnInfo = availableColumns.find((col) => col.columnName === columnName);
// 라벨명 우선 사용, 없으면 컬럼명 사용
const displayName = columnInfo?.label || columnInfo?.displayName || columnName;
const displayName = columnInfo?.label || columnInfo?.displayName || availableColumnInfo?.label || columnName;
const newColumn: ColumnConfig = {
columnName,
@ -1213,6 +1276,62 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</>
)}
{/* 선택된 컬럼 순서 변경 (DnD) */}
{config.columns && config.columns.length > 0 && (
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> ({config.columns.length} )</h3>
<p className="text-muted-foreground text-[10px]">
/
</p>
</div>
<hr className="border-border" />
<DndContext
collisionDetection={closestCenter}
onDragEnd={(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const columns = [...(config.columns || [])];
const oldIndex = columns.findIndex((c) => c.columnName === active.id);
const newIndex = columns.findIndex((c) => c.columnName === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
const reordered = arrayMove(columns, oldIndex, newIndex);
reordered.forEach((col, idx) => { col.order = idx; });
handleChange("columns", reordered);
}
}}
>
<SortableContext
items={(config.columns || []).map((c) => c.columnName)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{(config.columns || []).map((column, idx) => {
const resolvedLabel =
column.displayName && column.displayName !== column.columnName
? column.displayName
: availableColumns.find((c) => c.columnName === column.columnName)?.label || column.displayName || column.columnName;
const colWithLabel = { ...column, displayName: resolvedLabel };
return (
<SortableColumnRow
key={column.columnName}
id={column.columnName}
col={colWithLabel}
index={idx}
isEntityJoin={!!column.isEntityJoin}
onLabelChange={(value) => updateColumn(column.columnName, { displayName: value })}
onWidthChange={(value) => updateColumn(column.columnName, { width: value })}
onRemove={() => removeColumn(column.columnName)}
/>
);
})}
</div>
</SortableContext>
</DndContext>
</div>
)}
{/* 🆕 데이터 필터링 설정 */}
<div className="space-y-3">
<div>
@ -1240,3 +1359,4 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</div>
);
};

View File

@ -514,29 +514,38 @@ export function TableSectionRenderer({
loadColumnLabels();
}, [tableConfig.source.tableName, tableConfig.source.columnLabels]);
// 카테고리 타입 컬럼의 옵션 로드
// 카테고리 타입 컬럼 + referenceDisplay 소스 카테고리 컬럼의 옵션 로드
useEffect(() => {
const loadCategoryOptions = async () => {
const sourceTableName = tableConfig.source.tableName;
if (!sourceTableName) return;
if (!tableConfig.columns) return;
// 카테고리 타입인 컬럼만 필터링
const categoryColumns = tableConfig.columns.filter((col) => col.type === "category");
if (categoryColumns.length === 0) return;
const newOptionsMap: Record<string, { value: string; label: string }[]> = {};
const loadedSourceColumns = new Set<string>();
for (const col of categoryColumns) {
// 소스 필드 또는 필드명으로 카테고리 값 조회
const actualColumnName = col.sourceField || col.field;
if (!actualColumnName) continue;
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
for (const col of tableConfig.columns) {
let sourceColumnName: string | undefined;
if (col.type === "category") {
sourceColumnName = col.sourceField || col.field;
} else {
// referenceDisplay로 소스 카테고리 컬럼을 참조하는 컬럼도 포함
const refSource = (col as any).saveConfig?.referenceDisplay?.sourceColumn;
if (refSource && sourceCategoryColumns.includes(refSource)) {
sourceColumnName = refSource;
}
}
if (!sourceColumnName || loadedSourceColumns.has(`${col.field}:${sourceColumnName}`)) continue;
loadedSourceColumns.add(`${col.field}:${sourceColumnName}`);
try {
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
const result = await getCategoryValues(sourceTableName, actualColumnName, false);
if (result && result.success && Array.isArray(result.data)) {
const result = await getCategoryValues(sourceTableName, sourceColumnName, false);
if (result?.success && Array.isArray(result.data)) {
const options = result.data.map((item: any) => ({
value: item.valueCode || item.value_code || item.value || "",
label: item.valueLabel || item.displayLabel || item.display_label || item.label || item.valueCode || item.value_code || item.value || "",
@ -548,11 +557,13 @@ export function TableSectionRenderer({
}
}
setCategoryOptionsMap((prev) => ({ ...prev, ...newOptionsMap }));
if (Object.keys(newOptionsMap).length > 0) {
setCategoryOptionsMap((prev) => ({ ...prev, ...newOptionsMap }));
}
};
loadCategoryOptions();
}, [tableConfig.source.tableName, tableConfig.columns]);
}, [tableConfig.source.tableName, tableConfig.columns, sourceCategoryColumns]);
// receiveFromParent / internal 매핑으로 넘어오는 formData 값의 라벨 사전 로드
useEffect(() => {
@ -630,42 +641,81 @@ export function TableSectionRenderer({
const loadDynamicOptions = async () => {
setDynamicOptionsLoading(true);
try {
// DISTINCT 값을 가져오기 위한 API 호출
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
search: filterCondition ? { _raw: filterCondition } : {},
size: 1000,
page: 1,
});
if (response.data.success && response.data.data?.data) {
const rows = response.data.data.data;
// 중복 제거하여 고유 값 추출
const uniqueValues = new Map<string, string>();
for (const row of rows) {
const value = row[valueColumn];
if (value && !uniqueValues.has(value)) {
const label = labelColumn ? row[labelColumn] || value : value;
uniqueValues.set(value, label);
// 카테고리 값이 있는 컬럼인지 확인 (category_values 테이블에서 라벨 해결)
let categoryLabelMap: Record<string, string> = {};
try {
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
const catResult = await getCategoryValues(tableName, valueColumn, false);
if (catResult?.success && Array.isArray(catResult.data)) {
for (const item of catResult.data) {
const code = item.valueCode || item.value_code || item.value || "";
const label = item.valueLabel || item.displayLabel || item.display_label || item.label || code;
if (code) categoryLabelMap[code] = label;
}
}
} catch {
// 카테고리 값이 없으면 무시
}
// 옵션 배열로 변환
const options = Array.from(uniqueValues.entries()).map(([value, label], index) => ({
const hasCategoryValues = Object.keys(categoryLabelMap).length > 0;
if (hasCategoryValues) {
// 카테고리 값이 정의되어 있으면 그대로 옵션으로 사용
const options = Object.entries(categoryLabelMap).map(([code, label], index) => ({
id: `dynamic_${index}`,
value,
value: code,
label,
}));
console.log("[TableSectionRenderer] 동적 옵션 로드 완료:", {
console.log("[TableSectionRenderer] 카테고리 기반 옵션 로드 완료:", {
tableName,
valueColumn,
optionCount: options.length,
options,
});
setDynamicOptions(options);
dynamicOptionsLoadedRef.current = true;
} else {
// 카테고리 값이 없으면 기존 방식: DISTINCT 값에서 추출 (쉼표 다중값 분리)
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
search: filterCondition ? { _raw: filterCondition } : {},
size: 1000,
page: 1,
});
if (response.data.success && response.data.data?.data) {
const rows = response.data.data.data;
const uniqueValues = new Map<string, string>();
for (const row of rows) {
const rawValue = row[valueColumn];
if (!rawValue) continue;
// 쉼표 구분 다중값을 개별로 분리
const values = String(rawValue).split(",").map((v: string) => v.trim()).filter(Boolean);
for (const v of values) {
if (!uniqueValues.has(v)) {
const label = labelColumn ? row[labelColumn] || v : v;
uniqueValues.set(v, label);
}
}
}
const options = Array.from(uniqueValues.entries()).map(([value, label], index) => ({
id: `dynamic_${index}`,
value,
label,
}));
console.log("[TableSectionRenderer] DISTINCT 기반 옵션 로드 완료:", {
tableName,
valueColumn,
optionCount: options.length,
});
setDynamicOptions(options);
dynamicOptionsLoadedRef.current = true;
}
}
} catch (error) {
console.error("[TableSectionRenderer] 동적 옵션 로드 실패:", error);
@ -1019,34 +1069,24 @@ export function TableSectionRenderer({
);
// formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시)
// 조건부 테이블은 별도 useEffect에서 applyConditionalGrouping으로 처리
useEffect(() => {
// 이미 초기화되었으면 스킵
if (initialDataLoadedRef.current) return;
if (isConditionalMode) return;
const tableSectionKey = `__tableSection_${sectionId}`;
const initialData = formData[tableSectionKey];
console.log("[TableSectionRenderer] 초기 데이터 확인:", {
sectionId,
tableSectionKey,
hasInitialData: !!initialData,
initialDataLength: Array.isArray(initialData) ? initialData.length : 0,
formDataKeys: Object.keys(formData).filter(k => k.startsWith("__tableSection_")),
});
if (Array.isArray(initialData) && initialData.length > 0) {
console.log("[TableSectionRenderer] 초기 데이터 로드:", {
console.warn("[TableSectionRenderer] 비조건부 초기 데이터 로드:", {
sectionId,
itemCount: initialData.length,
firstItem: initialData[0],
});
setTableData(initialData);
initialDataLoadedRef.current = true;
// 참조 컬럼 값 조회 (saveToTarget: false인 컬럼)
loadReferenceColumnValues(initialData);
}
}, [sectionId, formData, loadReferenceColumnValues]);
}, [sectionId, formData, isConditionalMode, loadReferenceColumnValues]);
// RepeaterColumnConfig로 변환 (동적 Select 옵션 반영)
const columns: RepeaterColumnConfig[] = useMemo(() => {
@ -1068,10 +1108,23 @@ export function TableSectionRenderer({
});
}, [tableConfig.columns, dynamicSelectOptionsMap, categoryOptionsMap]);
// categoryOptionsMap에서 RepeaterTable용 카테고리 정보 파생
// categoryOptionsMap + dynamicOptions에서 RepeaterTable용 카테고리 정보 파생
const tableCategoryColumns = useMemo(() => {
return Object.keys(categoryOptionsMap);
}, [categoryOptionsMap]);
const cols = new Set(Object.keys(categoryOptionsMap));
// 조건부 테이블의 conditionColumn과 매핑된 컬럼도 카테고리 컬럼으로 추가
if (isConditionalMode && conditionalConfig?.conditionColumn && dynamicOptions.length > 0) {
// 조건 컬럼 자체
cols.add(conditionalConfig.conditionColumn);
// referenceDisplay로 조건 컬럼의 소스를 참조하는 컬럼도 추가
for (const col of tableConfig.columns || []) {
const refDisplay = (col as any).saveConfig?.referenceDisplay;
if (refDisplay?.sourceColumn === conditionalConfig.conditionColumn) {
cols.add(col.field);
}
}
}
return Array.from(cols);
}, [categoryOptionsMap, isConditionalMode, conditionalConfig?.conditionColumn, dynamicOptions, tableConfig.columns]);
const tableCategoryLabelMap = useMemo(() => {
const map: Record<string, string> = {};
@ -1082,8 +1135,14 @@ export function TableSectionRenderer({
}
}
}
// 조건부 테이블 동적 옵션의 카테고리 코드→라벨 매핑도 추가
for (const opt of dynamicOptions) {
if (opt.value && opt.label && opt.value !== opt.label) {
map[opt.value] = opt.label;
}
}
return map;
}, [categoryOptionsMap]);
}, [categoryOptionsMap, dynamicOptions]);
// 원본 계산 규칙 (조건부 계산 포함)
const originalCalculationRules: TableCalculationRule[] = useMemo(
@ -1606,10 +1665,9 @@ export function TableSectionRenderer({
const multiSelect = uiConfig?.multiSelect ?? true;
// 버튼 표시 설정 (두 버튼 동시 표시 가능)
// 레거시 호환: 기존 addButtonType 설정이 있으면 그에 맞게 변환
const legacyAddButtonType = uiConfig?.addButtonType;
const showSearchButton = legacyAddButtonType === "addRow" ? false : (uiConfig?.showSearchButton ?? true);
const showAddRowButton = legacyAddButtonType === "addRow" ? true : (uiConfig?.showAddRowButton ?? false);
// showSearchButton/showAddRowButton 신규 필드 우선, 레거시 addButtonType은 신규 필드 없을 때만 참고
const showSearchButton = uiConfig?.showSearchButton ?? true;
const showAddRowButton = uiConfig?.showAddRowButton ?? false;
const searchButtonText = uiConfig?.searchButtonText || uiConfig?.addButtonText || "품목 검색";
const addRowButtonText = uiConfig?.addRowButtonText || "직접 입력";
@ -1641,8 +1699,9 @@ export function TableSectionRenderer({
const filter = { ...baseFilterCondition };
// 조건부 테이블의 소스 필터 설정이 있고, 모달에서 선택된 조건이 있으면 적용
// __like 연산자로 ILIKE 포함 검색 (쉼표 구분 다중값 매칭 지원)
if (conditionalConfig?.sourceFilter?.enabled && modalCondition) {
filter[conditionalConfig.sourceFilter.filterColumn] = modalCondition;
filter[`${conditionalConfig.sourceFilter.filterColumn}__like`] = modalCondition;
}
return filter;
@ -1771,7 +1830,29 @@ export function TableSectionRenderer({
async (items: any[]) => {
if (!modalCondition) return;
// 기존 handleAddItems 로직을 재사용하여 매핑된 아이템 생성
// autoFillColumns 매핑 빌드: targetField → sourceColumn
const autoFillMap: Record<string, string> = {};
for (const col of tableConfig.columns) {
const dso = (col as any).dynamicSelectOptions;
if (dso?.sourceField) {
autoFillMap[col.field] = dso.sourceField;
}
if (dso?.rowSelectionMode?.autoFillColumns) {
for (const af of dso.rowSelectionMode.autoFillColumns) {
autoFillMap[af.targetField] = af.sourceColumn;
}
}
}
// referenceDisplay에서도 매핑 추가
for (const col of tableConfig.columns) {
if (!autoFillMap[col.field]) {
const refDisplay = (col as any).saveConfig?.referenceDisplay;
if (refDisplay?.sourceColumn) {
autoFillMap[col.field] = refDisplay.sourceColumn;
}
}
}
const mappedItems = await Promise.all(
items.map(async (sourceItem) => {
const newItem: any = {};
@ -1779,6 +1860,15 @@ export function TableSectionRenderer({
for (const col of tableConfig.columns) {
const mapping = col.valueMapping;
// autoFill 또는 referenceDisplay 매핑이 있으면 우선 사용
const autoFillSource = autoFillMap[col.field];
if (!mapping && autoFillSource) {
if (sourceItem[autoFillSource] !== undefined) {
newItem[col.field] = sourceItem[autoFillSource];
}
continue;
}
// 소스 필드에서 값 복사 (기본)
if (!mapping) {
const sourceField = col.sourceField || col.field;
@ -1896,45 +1986,146 @@ export function TableSectionRenderer({
[addEmptyRowToCondition],
);
// 조건부 테이블: 초기 데이터를 그룹핑하여 표시하는 헬퍼
const applyConditionalGrouping = useCallback((data: any[]) => {
const conditionColumn = conditionalConfig?.conditionColumn;
console.warn(`[applyConditionalGrouping] 호출됨:`, {
conditionColumn,
dataLength: data.length,
sampleConditions: data.slice(0, 3).map(r => r[conditionColumn || ""]),
});
if (!conditionColumn || data.length === 0) return;
const grouped: ConditionalTableData = {};
const conditions = new Set<string>();
for (const row of data) {
const conditionValue = row[conditionColumn] || "";
if (conditionValue) {
if (!grouped[conditionValue]) {
grouped[conditionValue] = [];
}
grouped[conditionValue].push(row);
conditions.add(conditionValue);
}
}
setConditionalTableData(grouped);
setSelectedConditions(Array.from(conditions));
if (conditions.size > 0) {
setActiveConditionTab(Array.from(conditions)[0]);
}
initialDataLoadedRef.current = true;
}, [conditionalConfig?.conditionColumn]);
// 조건부 테이블: 초기 데이터 로드 (수정 모드)
useEffect(() => {
if (!isConditionalMode) return;
if (initialDataLoadedRef.current) return;
const tableSectionKey = `_tableSection_${sectionId}`;
const initialData = formData[tableSectionKey];
const initialData =
formData[`_tableSection_${sectionId}`] ||
formData[`__tableSection_${sectionId}`];
console.warn(`[TableSectionRenderer] 초기 데이터 로드 체크:`, {
sectionId,
hasUnderscoreData: !!formData[`_tableSection_${sectionId}`],
hasDoubleUnderscoreData: !!formData[`__tableSection_${sectionId}`],
dataLength: Array.isArray(initialData) ? initialData.length : "not array",
initialDataLoaded: initialDataLoadedRef.current,
});
if (Array.isArray(initialData) && initialData.length > 0) {
const conditionColumn = conditionalConfig?.conditionColumn;
if (conditionColumn) {
// 조건별로 데이터 그룹핑
const grouped: ConditionalTableData = {};
const conditions = new Set<string>();
for (const row of initialData) {
const conditionValue = row[conditionColumn] || "";
if (conditionValue) {
if (!grouped[conditionValue]) {
grouped[conditionValue] = [];
}
grouped[conditionValue].push(row);
conditions.add(conditionValue);
}
}
setConditionalTableData(grouped);
setSelectedConditions(Array.from(conditions));
// 첫 번째 조건을 활성 탭으로 설정
if (conditions.size > 0) {
setActiveConditionTab(Array.from(conditions)[0]);
}
initialDataLoadedRef.current = true;
}
applyConditionalGrouping(initialData);
}
}, [isConditionalMode, sectionId, formData, conditionalConfig?.conditionColumn]);
}, [isConditionalMode, sectionId, formData, applyConditionalGrouping]);
// 조건부 테이블: formData에 데이터가 없으면 editConfig 기반으로 직접 API 로드
const selfLoadAttemptedRef = React.useRef(false);
useEffect(() => {
if (!isConditionalMode) return;
if (initialDataLoadedRef.current) return;
if (selfLoadAttemptedRef.current) return;
const editConfig = (tableConfig as any).editConfig;
const saveConfig = tableConfig.saveConfig;
const linkColumn = editConfig?.linkColumn;
const targetTable = saveConfig?.targetTable;
console.warn(`[TableSectionRenderer] 자체 로드 체크:`, {
sectionId,
hasEditConfig: !!editConfig,
linkColumn,
targetTable,
masterField: linkColumn?.masterField,
masterValue: linkColumn?.masterField ? formData[linkColumn.masterField] : "N/A",
formDataKeys: Object.keys(formData).slice(0, 15),
initialDataLoaded: initialDataLoadedRef.current,
selfLoadAttempted: selfLoadAttemptedRef.current,
existingTableData_: !!formData[`_tableSection_${sectionId}`],
existingTableData__: !!formData[`__tableSection_${sectionId}`],
});
if (!linkColumn?.masterField || !linkColumn?.detailField || !targetTable) {
console.warn(`[TableSectionRenderer] 자체 로드 스킵: linkColumn/targetTable 미설정`);
return;
}
const masterValue = formData[linkColumn.masterField];
if (!masterValue) {
console.warn(`[TableSectionRenderer] 자체 로드 대기: masterField=${linkColumn.masterField} 값 없음`);
return;
}
// formData에 테이블 섹션 데이터가 이미 있으면 해당 데이터 사용
const existingData =
formData[`_tableSection_${sectionId}`] ||
formData[`__tableSection_${sectionId}`];
if (Array.isArray(existingData) && existingData.length > 0) {
console.warn(`[TableSectionRenderer] 기존 데이터 발견, applyConditionalGrouping 호출: ${existingData.length}`);
applyConditionalGrouping(existingData);
return;
}
selfLoadAttemptedRef.current = true;
console.warn(`[TableSectionRenderer] 자체 API 로드 시작: ${targetTable}, ${linkColumn.detailField}=${masterValue}`);
const loadDetailData = async () => {
try {
const response = await apiClient.post(`/table-management/tables/${targetTable}/data`, {
search: {
[linkColumn.detailField]: { value: masterValue, operator: "equals" },
},
page: 1,
size: 1000,
autoFilter: { enabled: true },
});
if (response.data?.success) {
let items: any[] = [];
const data = response.data.data;
if (Array.isArray(data)) items = data;
else if (data?.items && Array.isArray(data.items)) items = data.items;
else if (data?.rows && Array.isArray(data.rows)) items = data.rows;
else if (data?.data && Array.isArray(data.data)) items = data.data;
console.warn(`[TableSectionRenderer] 자체 데이터 로드 완료: ${items.length}`);
if (items.length > 0) {
applyConditionalGrouping(items);
}
} else {
console.warn(`[TableSectionRenderer] API 응답 실패:`, response.data);
}
} catch (error) {
console.error(`[TableSectionRenderer] 자체 데이터 로드 실패:`, error);
}
};
loadDetailData();
}, [isConditionalMode, sectionId, formData, tableConfig, applyConditionalGrouping]);
// 조건부 테이블: 전체 항목 수 계산
const totalConditionalItems = useMemo(() => {

View File

@ -28,6 +28,7 @@ import { apiClient } from "@/lib/api/client";
import { allocateNumberingCode, previewNumberingCode } from "@/lib/api/numberingRule";
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
import { CascadingDropdownConfig } from "@/types/screen-management";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import {
UniversalFormModalComponentProps,
@ -223,23 +224,38 @@ export function UniversalFormModalComponent({
// 설정 병합
const config: UniversalFormModalConfig = useMemo(() => {
const componentConfig = component?.config || {};
// V2 레이아웃에서 overrides 전체가 config로 전달되는 경우
// 실제 설정이 propConfig.componentConfig에 이중 중첩되어 있을 수 있음
const nestedPropConfig = propConfig?.componentConfig;
const hasFlatPropConfig = propConfig?.modal !== undefined || propConfig?.sections !== undefined;
const effectivePropConfig = hasFlatPropConfig
? propConfig
: (nestedPropConfig?.modal ? nestedPropConfig : propConfig);
const nestedCompConfig = componentConfig?.componentConfig;
const hasFlatCompConfig = componentConfig?.modal !== undefined || componentConfig?.sections !== undefined;
const effectiveCompConfig = hasFlatCompConfig
? componentConfig
: (nestedCompConfig?.modal ? nestedCompConfig : componentConfig);
return {
...defaultConfig,
...propConfig,
...componentConfig,
...effectivePropConfig,
...effectiveCompConfig,
modal: {
...defaultConfig.modal,
...propConfig?.modal,
...componentConfig.modal,
...effectivePropConfig?.modal,
...effectiveCompConfig?.modal,
},
saveConfig: {
...defaultConfig.saveConfig,
...propConfig?.saveConfig,
...componentConfig.saveConfig,
...effectivePropConfig?.saveConfig,
...effectiveCompConfig?.saveConfig,
afterSave: {
...defaultConfig.saveConfig.afterSave,
...propConfig?.saveConfig?.afterSave,
...componentConfig.saveConfig?.afterSave,
...effectivePropConfig?.saveConfig?.afterSave,
...effectiveCompConfig?.saveConfig?.afterSave,
},
},
};
@ -294,6 +310,7 @@ export function UniversalFormModalComponent({
const hasInitialized = useRef(false);
// 마지막으로 초기화된 데이터의 ID를 추적 (수정 모달에서 다른 항목 선택 시 재초기화 필요)
const lastInitializedId = useRef<string | undefined>(undefined);
const tableSectionLoadedRef = useRef(false);
// 초기화 - 최초 마운트 시 또는 initialData가 변경되었을 때 실행
useEffect(() => {
@ -315,7 +332,7 @@ export function UniversalFormModalComponent({
if (hasInitialized.current && lastInitializedId.current === currentIdString) {
// 생성 모드에서 데이터가 새로 전달된 경우는 재초기화 필요
if (!createModeDataHash || capturedInitialData.current) {
// console.log("[UniversalFormModal] 초기화 스킵 - 이미 초기화됨");
// console.log("[UniversalFormModal] 초기화 스킵 - 이미 초기화됨", { currentIdString });
// 🆕 채번 플래그가 true인데 formData에 값이 없으면 재생성 필요
// (컴포넌트 remount로 인해 state가 초기화된 경우)
return;
@ -349,21 +366,13 @@ export function UniversalFormModalComponent({
// console.log("[UniversalFormModal] 초기 데이터 캡처:", capturedInitialData.current);
}
// console.log("[UniversalFormModal] initializeForm 호출 예정");
// console.log("[UniversalFormModal] initializeForm 호출 예정", { currentIdString });
hasInitialized.current = true;
tableSectionLoadedRef.current = false;
initializeForm();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialData]); // initialData 전체 변경 시 재초기화
// config 변경 시에만 재초기화 (initialData 변경은 무시) - 채번규칙 제외
useEffect(() => {
if (!hasInitialized.current) return; // 최초 초기화 전이면 스킵
// console.log("[useEffect config 변경] 재초기화 스킵 (채번 중복 방지)");
// initializeForm(); // 주석 처리 - config 변경 시 재초기화 안 함 (채번 중복 방지)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
// 컴포넌트 unmount 시 채번 플래그 초기화
useEffect(() => {
return () => {
@ -727,9 +736,13 @@ export function UniversalFormModalComponent({
// 🆕 테이블 섹션(type: "table") 디테일 데이터 로드 (마스터-디테일 구조)
// 수정 모드일 때 디테일 테이블에서 데이터 가져오기
if (effectiveInitialData) {
console.log("[initializeForm] 테이블 섹션 디테일 로드 시작", {
sectionsCount: config.sections.length,
effectiveInitialDataKeys: Object.keys(effectiveInitialData),
// console.log("[initializeForm] 테이블 섹션 디테일 로드 시작", { sectionsCount: config.sections.length });
console.warn("[initializeForm] 테이블 섹션 순회 시작:", {
sectionCount: config.sections.length,
tableSections: config.sections.filter(s => s.type === "table").map(s => s.id),
hasInitialData: !!effectiveInitialData,
initialDataKeys: effectiveInitialData ? Object.keys(effectiveInitialData).slice(0, 10) : [],
});
for (const section of config.sections) {
@ -738,16 +751,14 @@ export function UniversalFormModalComponent({
}
const tableConfig = section.tableConfig;
// editConfig는 타입에 정의되지 않았지만 런타임에 존재할 수 있음
const editConfig = (tableConfig as any).editConfig;
const saveConfig = tableConfig.saveConfig;
console.log(`[initializeForm] 테이블 섹션 ${section.id} 검사:`, {
hasEditConfig: !!editConfig,
loadOnEdit: editConfig?.loadOnEdit,
hasSaveConfig: !!saveConfig,
console.warn(`[initializeForm] 테이블 섹션 ${section.id}:`, {
editConfig,
targetTable: saveConfig?.targetTable,
linkColumn: editConfig?.linkColumn,
masterField: editConfig?.linkColumn?.masterField,
masterValue: effectiveInitialData?.[editConfig?.linkColumn?.masterField],
});
// 수정 모드 로드 설정 확인 (기본값: true)
@ -1072,6 +1083,25 @@ export function UniversalFormModalComponent({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용)
// config 변경 시 테이블 섹션 데이터 로드 보완
// initializeForm은 initialData useEffect에서 호출되지만, config(화면 설정)이
// 비동기 로드로 늦게 도착하면 테이블 섹션 로드를 놓칠 수 있음
useEffect(() => {
if (!hasInitialized.current) return;
const hasTableSection = config.sections.some(s => s.type === "table" && s.tableConfig?.saveConfig?.targetTable);
if (!hasTableSection) return;
const editData = capturedInitialData.current || initialData;
if (!editData || Object.keys(editData).length === 0) return;
if (tableSectionLoadedRef.current) return;
tableSectionLoadedRef.current = true;
initializeForm();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.sections, initializeForm]);
// 반복 섹션 아이템 생성
const createRepeatItem = (section: FormSectionConfig, index: number): RepeatSectionItem => {
const item: RepeatSectionItem = {
@ -1835,11 +1865,11 @@ export function UniversalFormModalComponent({
case "date":
return (
<Input
<FormDatePicker
id={fieldKey}
type="date"
value={value || ""}
onChange={(e) => onChangeHandler(e.target.value)}
onChange={onChangeHandler}
placeholder={field.placeholder || "날짜를 선택하세요"}
disabled={isDisabled}
readOnly={field.readOnly}
/>
@ -1847,13 +1877,14 @@ export function UniversalFormModalComponent({
case "datetime":
return (
<Input
<FormDatePicker
id={fieldKey}
type="datetime-local"
value={value || ""}
onChange={(e) => onChangeHandler(e.target.value)}
onChange={onChangeHandler}
placeholder={field.placeholder || "날짜/시간을 선택하세요"}
disabled={isDisabled}
readOnly={field.readOnly}
includeTime
/>
);

View File

@ -47,14 +47,22 @@ export function UniversalFormModalConfigPanel({
onChange,
allComponents = [],
}: UniversalFormModalConfigPanelProps) {
// config가 불완전할 수 있으므로 defaultConfig와 병합하여 안전하게 사용
// V2 레이아웃에서 overrides 전체가 componentConfig로 전달되는 경우
// 실제 설정이 rawConfig.componentConfig에 이중 중첩되어 있을 수 있음
// 평탄화된 구조(save 후)가 있으면 우선, 아니면 중첩 구조에서 추출
const nestedConfig = rawConfig?.componentConfig;
const hasFlatConfig = rawConfig?.modal !== undefined || rawConfig?.sections !== undefined;
const effectiveConfig = hasFlatConfig
? rawConfig
: (nestedConfig?.modal ? nestedConfig : rawConfig);
const config: UniversalFormModalConfig = {
...defaultConfig,
...rawConfig,
modal: { ...defaultConfig.modal, ...rawConfig?.modal },
sections: rawConfig?.sections ?? defaultConfig.sections,
saveConfig: { ...defaultConfig.saveConfig, ...rawConfig?.saveConfig },
editMode: { ...defaultConfig.editMode, ...rawConfig?.editMode },
...effectiveConfig,
modal: { ...defaultConfig.modal, ...effectiveConfig?.modal },
sections: effectiveConfig?.sections ?? defaultConfig.sections,
saveConfig: { ...defaultConfig.saveConfig, ...effectiveConfig?.saveConfig },
editMode: { ...defaultConfig.editMode, ...effectiveConfig?.editMode },
};
// 테이블 목록

View File

@ -2721,9 +2721,12 @@ export function TableSectionSettingsModal({
};
const updateUiConfig = (updates: Partial<NonNullable<TableSectionConfig["uiConfig"]>>) => {
updateTableConfig({
uiConfig: { ...tableConfig.uiConfig, ...updates },
});
const newUiConfig = { ...tableConfig.uiConfig, ...updates };
// 새 버튼 설정이 사용되면 레거시 addButtonType 제거
if ("showSearchButton" in updates || "showAddRowButton" in updates) {
delete (newUiConfig as any).addButtonType;
}
updateTableConfig({ uiConfig: newUiConfig });
};
const updateSaveConfig = (updates: Partial<NonNullable<TableSectionConfig["saveConfig"]>>) => {

View File

@ -86,6 +86,7 @@ interface ItemSearchModalProps {
onClose: () => void;
onSelect: (items: ItemInfo[]) => void;
companyCode?: string;
existingItemIds?: Set<string>;
}
function ItemSearchModal({
@ -93,6 +94,7 @@ function ItemSearchModal({
onClose,
onSelect,
companyCode,
existingItemIds,
}: ItemSearchModalProps) {
const [searchText, setSearchText] = useState("");
const [items, setItems] = useState<ItemInfo[]>([]);
@ -182,7 +184,7 @@ function ItemSearchModal({
</div>
) : (
<table className="w-full text-xs sm:text-sm">
<thead className="bg-muted/50 sticky top-0">
<thead className="bg-muted sticky top-0 z-10">
<tr>
<th className="w-8 px-2 py-2 text-center">
<Checkbox
@ -200,43 +202,53 @@ function ItemSearchModal({
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr
key={item.id}
onClick={() => {
setSelectedItems((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id);
else next.add(item.id);
return next;
});
}}
className={cn(
"cursor-pointer border-t transition-colors",
selectedItems.has(item.id) ? "bg-primary/10" : "hover:bg-accent",
)}
>
<td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedItems.has(item.id)}
onCheckedChange={(checked) => {
setSelectedItems((prev) => {
const next = new Set(prev);
if (checked) next.add(item.id);
else next.delete(item.id);
return next;
});
}}
/>
</td>
<td className="px-3 py-2 font-mono">
{item.item_number}
</td>
<td className="px-3 py-2">{item.item_name}</td>
<td className="px-3 py-2">{item.type}</td>
<td className="px-3 py-2">{item.unit}</td>
</tr>
))}
{items.map((item) => {
const alreadyAdded = existingItemIds?.has(item.id) || false;
return (
<tr
key={item.id}
onClick={() => {
if (alreadyAdded) return;
setSelectedItems((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id);
else next.add(item.id);
return next;
});
}}
className={cn(
"border-t transition-colors",
alreadyAdded
? "cursor-not-allowed opacity-40"
: "cursor-pointer",
!alreadyAdded && selectedItems.has(item.id) ? "bg-primary/10" : !alreadyAdded ? "hover:bg-accent" : "",
)}
>
<td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedItems.has(item.id)}
disabled={alreadyAdded}
onCheckedChange={(checked) => {
if (alreadyAdded) return;
setSelectedItems((prev) => {
const next = new Set(prev);
if (checked) next.add(item.id);
else next.delete(item.id);
return next;
});
}}
/>
</td>
<td className="px-3 py-2 font-mono">
{item.item_number}
{alreadyAdded && <span className="text-muted-foreground ml-1 text-[10px]">()</span>}
</td>
<td className="px-3 py-2">{item.item_name}</td>
<td className="px-3 py-2">{item.type}</td>
<td className="px-3 py-2">{item.unit}</td>
</tr>
);
})}
</tbody>
</table>
)}
@ -739,37 +751,40 @@ export function BomItemEditorComponent({
[originalNotifyChange, markChanged],
);
const handleSaveAllRef = React.useRef<(() => Promise<void>) | null>(null);
// EditModal 저장 시 beforeFormSave 이벤트로 디테일 데이터도 함께 저장
useEffect(() => {
if (isDesignMode || !bomId) return;
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
console.log("[BomItemEditor] beforeFormSave 이벤트 수신:", {
bomId,
treeDataLength: treeData.length,
hasRef: !!handleSaveAllRef.current,
});
if (treeData.length > 0 && handleSaveAllRef.current) {
if (handleSaveAllRef.current) {
const savePromise = handleSaveAllRef.current();
if (detail?.pendingPromises) {
detail.pendingPromises.push(savePromise);
console.log("[BomItemEditor] pendingPromises에 저장 Promise 등록 완료");
}
}
};
window.addEventListener("beforeFormSave", handler);
console.log("[BomItemEditor] beforeFormSave 리스너 등록:", { bomId, isDesignMode });
return () => window.removeEventListener("beforeFormSave", handler);
}, [isDesignMode, bomId, treeData.length]);
const handleSaveAllRef = React.useRef<(() => Promise<void>) | null>(null);
}, [isDesignMode, bomId]);
const handleSaveAll = useCallback(async () => {
if (!bomId) return;
setSaving(true);
try {
// 저장 시점에도 최신 version_id 조회
const saveVersionId = await fetchCurrentVersionId(bomId) || propsVersionId;
// version_id 확보: 없으면 서버에서 자동 초기화
let saveVersionId = await fetchCurrentVersionId(bomId) || propsVersionId;
if (!saveVersionId) {
try {
const initRes = await apiClient.post(`/bom/${bomId}/initialize-version`);
if (initRes.data?.success && initRes.data.data?.versionId) {
saveVersionId = initRes.data.data.versionId;
}
} catch (e) {
console.error("[BomItemEditor] 버전 초기화 실패:", e);
}
}
const collectAll = (nodes: BomItemNode[], parentRealId: string | null, level: number): any[] => {
const result: any[] = [];
@ -797,7 +812,7 @@ export function BomItemEditorComponent({
: null;
if (node._isNew) {
const payload: Record<string, any> = {
const raw: Record<string, any> = {
...node.data,
[fkColumn]: bomId,
[parentKeyColumn]: realParentId,
@ -806,10 +821,16 @@ export function BomItemEditorComponent({
company_code: companyCode || undefined,
version_id: saveVersionId || undefined,
};
delete payload.id;
delete payload.tempId;
delete payload._isNew;
delete payload._isDeleted;
// bom_detail에 유효한 필드만 남기기 (item_info 조인 필드 제거)
const payload: Record<string, any> = {};
const validKeys = new Set([
fkColumn, parentKeyColumn, "seq_no", "level", "child_item_id",
"quantity", "unit", "loss_rate", "remark", "process_type",
"base_qty", "revision", "version_id", "company_code", "writer",
]);
Object.keys(raw).forEach((k) => {
if (validKeys.has(k)) payload[k] = raw[k];
});
const resp = await apiClient.post(
`/table-management/tables/${mainTableName}/add`,
@ -820,17 +841,14 @@ export function BomItemEditorComponent({
savedCount++;
} else if (node.id) {
const updatedData: Record<string, any> = {
...node.data,
id: node.id,
[fkColumn]: bomId,
[parentKeyColumn]: realParentId,
seq_no: String(seqNo),
level: String(level),
};
delete updatedData.tempId;
delete updatedData._isNew;
delete updatedData._isDeleted;
Object.keys(updatedData).forEach((k) => {
if (k.startsWith(`${sourceFk}_`)) delete updatedData[k];
["quantity", "unit", "loss_rate", "remark", "process_type", "base_qty", "revision", "child_item_id", "version_id", "company_code"].forEach((k) => {
if (node.data[k] !== undefined) updatedData[k] = node.data[k];
});
await apiClient.put(
@ -919,6 +937,39 @@ export function BomItemEditorComponent({
setItemSearchOpen(true);
}, []);
// 같은 레벨(형제) 품목 ID 목록 (동일 레벨 중복 방지, 하위 레벨은 허용)
const existingItemIds = useMemo(() => {
const ids = new Set<string>();
const fkField = cfg.dataSource?.foreignKey || "child_item_id";
if (addTargetParentId === null) {
// 루트 레벨 추가: 루트 노드의 형제들만 체크
for (const n of treeData) {
const fk = n.data[fkField];
if (fk) ids.add(fk);
}
} else {
// 하위 추가: 해당 부모의 직속 자식들만 체크
const findParent = (nodes: BomItemNode[]): BomItemNode | null => {
for (const n of nodes) {
if (n.tempId === addTargetParentId) return n;
const found = findParent(n.children);
if (found) return found;
}
return null;
};
const parent = findParent(treeData);
if (parent) {
for (const child of parent.children) {
const fk = child.data[fkField];
if (fk) ids.add(fk);
}
}
}
return ids;
}, [treeData, cfg, addTargetParentId]);
// 루트 품목 추가 시작
const handleAddRoot = useCallback(() => {
setAddTargetParentId(null);
@ -1338,6 +1389,7 @@ export function BomItemEditorComponent({
onClose={() => setItemSearchOpen(false)}
onSelect={handleItemSelect}
companyCode={companyCode}
existingItemIds={existingItemIds}
/>
</div>
);

View File

@ -13,6 +13,13 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2 } from "lucide-react";
import { apiClient } from "@/lib/api/client";
@ -35,6 +42,20 @@ export function BomDetailEditModal({
}: BomDetailEditModalProps) {
const [formData, setFormData] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
const [processOptions, setProcessOptions] = useState<{ value: string; label: string }[]>([]);
useEffect(() => {
if (open && !isRootNode) {
apiClient.get("/table-categories/bom_detail/process_type/values")
.then((res) => {
const values = res.data?.data || [];
if (values.length > 0) {
setProcessOptions(values.map((v: any) => ({ value: v.value_code, label: v.value_label })));
}
})
.catch(() => { /* 카테고리 없으면 빈 배열 유지 */ });
}
}, [open, isRootNode]);
useEffect(() => {
if (node && open) {
@ -47,9 +68,7 @@ export function BomDetailEditModal({
} else {
setFormData({
quantity: node.quantity || "",
unit: node.unit || node.detail_unit || "",
process_type: node.process_type || "",
base_qty: node.base_qty || "",
loss_rate: node.loss_rate || "",
remark: node.remark || "",
});
@ -67,11 +86,15 @@ export function BomDetailEditModal({
try {
const targetTable = isRootNode ? "bom" : tableName;
const realId = isRootNode ? node.id?.replace("__root_", "") : node.id;
await apiClient.put(`/table-management/tables/${targetTable}/${realId}`, formData);
await apiClient.put(`/table-management/tables/${targetTable}/edit`, {
originalData: { id: realId },
updatedData: { id: realId, ...formData },
});
onSaved?.();
onOpenChange(false);
} catch (error) {
console.error("[BomDetailEdit] 저장 실패:", error);
alert("저장 중 오류가 발생했습니다.");
} finally {
setSaving(false);
}
@ -126,11 +149,19 @@ export function BomDetailEditModal({
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={formData.unit}
onChange={(e) => handleChange("unit", e.target.value)}
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
{isRootNode ? (
<Input
value={formData.unit}
onChange={(e) => handleChange("unit", e.target.value)}
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
) : (
<Input
value={node?.child_unit || node?.unit || "-"}
disabled
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
)}
</div>
</div>
@ -139,12 +170,28 @@ export function BomDetailEditModal({
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={formData.process_type}
onChange={(e) => handleChange("process_type", e.target.value)}
placeholder="예: 조립공정"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
{processOptions.length > 0 ? (
<Select
value={formData.process_type || ""}
onValueChange={(v) => handleChange("process_type", v)}
>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="공정 선택" />
</SelectTrigger>
<SelectContent>
{processOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={formData.process_type}
onChange={(e) => handleChange("process_type", e.target.value)}
placeholder="예: 조립공정"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
)}
</div>
<div>
<Label className="text-xs sm:text-sm"> (%)</Label>

View File

@ -0,0 +1,609 @@
"use client";
import React, { useState, useRef, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import {
Upload,
FileSpreadsheet,
AlertCircle,
CheckCircle2,
Download,
Loader2,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { importFromExcel } from "@/lib/utils/excelExport";
import { apiClient } from "@/lib/api/client";
interface BomExcelUploadModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
/** bomId가 있으면 "새 버전 등록" 모드, 없으면 "새 BOM 생성" 모드 */
bomId?: string;
bomName?: string;
}
interface ParsedRow {
rowIndex: number;
level: number;
item_number: string;
item_name: string;
quantity: number;
unit: string;
process_type: string;
remark: string;
valid: boolean;
error?: string;
isHeader?: boolean;
}
type UploadStep = "upload" | "preview" | "result";
const EXPECTED_HEADERS = ["레벨", "품번", "품명", "소요량", "단위", "공정구분", "비고"];
const HEADER_MAP: Record<string, string> = {
"레벨": "level",
"level": "level",
"품번": "item_number",
"품목코드": "item_number",
"item_number": "item_number",
"item_code": "item_number",
"품명": "item_name",
"품목명": "item_name",
"item_name": "item_name",
"소요량": "quantity",
"수량": "quantity",
"quantity": "quantity",
"qty": "quantity",
"단위": "unit",
"unit": "unit",
"공정구분": "process_type",
"공정": "process_type",
"process_type": "process_type",
"비고": "remark",
"remark": "remark",
};
export function BomExcelUploadModal({
open,
onOpenChange,
onSuccess,
bomId,
bomName,
}: BomExcelUploadModalProps) {
const isVersionMode = !!bomId;
const [step, setStep] = useState<UploadStep>("upload");
const [parsedRows, setParsedRows] = useState<ParsedRow[]>([]);
const [fileName, setFileName] = useState<string>("");
const [uploading, setUploading] = useState(false);
const [uploadResult, setUploadResult] = useState<any>(null);
const [downloading, setDownloading] = useState(false);
const [versionName, setVersionName] = useState<string>("");
const fileInputRef = useRef<HTMLInputElement>(null);
const reset = useCallback(() => {
setStep("upload");
setParsedRows([]);
setFileName("");
setUploadResult(null);
setUploading(false);
setVersionName("");
if (fileInputRef.current) fileInputRef.current.value = "";
}, []);
const handleClose = useCallback(() => {
reset();
onOpenChange(false);
}, [reset, onOpenChange]);
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setFileName(file.name);
try {
const rawData = await importFromExcel(file);
if (!rawData || rawData.length === 0) {
toast.error("엑셀 파일에 데이터가 없습니다");
return;
}
const firstRow = rawData[0];
const excelHeaders = Object.keys(firstRow);
const fieldMap: Record<string, string> = {};
for (const header of excelHeaders) {
const normalized = header.trim().toLowerCase();
const mapped = HEADER_MAP[normalized] || HEADER_MAP[header.trim()];
if (mapped) {
fieldMap[header] = mapped;
}
}
const hasItemNumber = excelHeaders.some(h => {
const n = h.trim().toLowerCase();
return HEADER_MAP[n] === "item_number" || HEADER_MAP[h.trim()] === "item_number";
});
if (!hasItemNumber) {
toast.error("품번 컬럼을 찾을 수 없습니다. 컬럼명을 확인해주세요.");
return;
}
const parsed: ParsedRow[] = [];
for (let index = 0; index < rawData.length; index++) {
const row = rawData[index];
const getField = (fieldName: string): any => {
for (const [excelKey, mappedField] of Object.entries(fieldMap)) {
if (mappedField === fieldName) return row[excelKey];
}
return undefined;
};
const levelRaw = getField("level");
const level = typeof levelRaw === "number" ? levelRaw : parseInt(String(levelRaw || "0"), 10);
const itemNumber = String(getField("item_number") || "").trim();
const itemName = String(getField("item_name") || "").trim();
const quantityRaw = getField("quantity");
const quantity = typeof quantityRaw === "number" ? quantityRaw : parseFloat(String(quantityRaw || "1"));
const unit = String(getField("unit") || "").trim();
const processType = String(getField("process_type") || "").trim();
const remark = String(getField("remark") || "").trim();
let valid = true;
let error = "";
const isHeader = level === 0;
if (!itemNumber) {
valid = false;
error = "품번 필수";
} else if (isNaN(level) || level < 0) {
valid = false;
error = "레벨 오류";
} else if (index > 0) {
const prevLevel = parsed[index - 1]?.level ?? 0;
if (level > prevLevel + 1) {
valid = false;
error = `레벨 점프 (이전: ${prevLevel})`;
}
}
parsed.push({
rowIndex: index + 1,
isHeader,
level,
item_number: itemNumber,
item_name: itemName,
quantity: isNaN(quantity) ? 1 : quantity,
unit,
process_type: processType,
remark,
valid,
error,
});
}
const filtered = parsed.filter(r => r.item_number !== "");
// 새 BOM 생성 모드: 레벨 0 필수
if (!isVersionMode) {
const hasHeader = filtered.some(r => r.level === 0);
if (!hasHeader) {
toast.error("레벨 0(BOM 마스터) 행이 필요합니다. 첫 행에 최상위 품목을 입력해주세요.");
return;
}
}
setParsedRows(filtered);
setStep("preview");
} catch (err: any) {
toast.error(`파일 파싱 실패: ${err.message}`);
}
}, [isVersionMode]);
const handleUpload = useCallback(async () => {
const invalidRows = parsedRows.filter(r => !r.valid);
if (invalidRows.length > 0) {
toast.error(`유효하지 않은 행이 ${invalidRows.length}건 있습니다.`);
return;
}
setUploading(true);
try {
const rowPayload = parsedRows.map(r => ({
level: r.level,
item_number: r.item_number,
item_name: r.item_name,
quantity: r.quantity,
unit: r.unit,
process_type: r.process_type,
remark: r.remark,
}));
let res;
if (isVersionMode) {
res = await apiClient.post(`/bom/${bomId}/excel-upload-version`, {
rows: rowPayload,
versionName: versionName.trim() || undefined,
});
} else {
res = await apiClient.post("/bom/excel-upload", { rows: rowPayload });
}
if (res.data?.success) {
setUploadResult(res.data.data);
setStep("result");
const msg = isVersionMode
? `새 버전 생성 완료: 하위품목 ${res.data.data.insertedCount}`
: `BOM 생성 완료: 하위품목 ${res.data.data.insertedCount}`;
toast.success(msg);
onSuccess?.();
} else {
const errData = res.data?.data;
if (errData?.unmatchedItems?.length > 0) {
toast.error(`매칭 안 되는 품번: ${errData.unmatchedItems.join(", ")}`);
setParsedRows(prev => prev.map(r => {
if (errData.unmatchedItems.includes(r.item_number)) {
return { ...r, valid: false, error: "품번 미등록" };
}
return r;
}));
} else {
toast.error(res.data?.message || "업로드 실패");
}
}
} catch (err: any) {
toast.error(`업로드 오류: ${err.response?.data?.message || err.message}`);
} finally {
setUploading(false);
}
}, [parsedRows, isVersionMode, bomId, versionName, onSuccess]);
const handleDownloadTemplate = useCallback(async () => {
setDownloading(true);
try {
const XLSX = await import("xlsx");
let data: Record<string, any>[] = [];
if (isVersionMode && bomId) {
// 기존 BOM 데이터를 템플릿으로 다운로드
try {
const res = await apiClient.get(`/bom/${bomId}/excel-download`);
if (res.data?.success && res.data.data?.length > 0) {
data = res.data.data.map((row: any) => ({
"레벨": row.level,
"품번": row.item_number,
"품명": row.item_name,
"소요량": row.quantity,
"단위": row.unit,
"공정구분": row.process_type,
"비고": row.remark,
}));
}
} catch { /* 데이터 없으면 빈 템플릿 */ }
}
if (data.length === 0) {
if (isVersionMode) {
data = [
{ "레벨": 1, "품번": "(자품목 품번)", "품명": "(자품목 품명)", "소요량": 2, "단위": "EA", "공정구분": "", "비고": "" },
{ "레벨": 2, "품번": "(하위품목 품번)", "품명": "(하위 품명)", "소요량": 1, "단위": "EA", "공정구분": "", "비고": "" },
];
} else {
data = [
{ "레벨": 0, "품번": "(최상위 품번)", "품명": "(최상위 품명)", "소요량": 1, "단위": "EA", "공정구분": "", "비고": "BOM 마스터" },
{ "레벨": 1, "품번": "(자품목 품번)", "품명": "(자품목 품명)", "소요량": 2, "단위": "EA", "공정구분": "", "비고": "" },
{ "레벨": 2, "품번": "(하위품목 품번)", "품명": "(하위 품명)", "소요량": 1, "단위": "EA", "공정구분": "", "비고": "" },
];
}
}
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "BOM");
ws["!cols"] = [
{ wch: 6 }, { wch: 18 }, { wch: 20 }, { wch: 10 },
{ wch: 8 }, { wch: 12 }, { wch: 20 },
];
const filename = bomName ? `BOM_${bomName}.xlsx` : "BOM_template.xlsx";
XLSX.writeFile(wb, filename);
toast.success("템플릿 다운로드 완료");
} catch (err: any) {
toast.error(`다운로드 실패: ${err.message}`);
} finally {
setDownloading(false);
}
}, [isVersionMode, bomId, bomName]);
const headerRow = parsedRows.find(r => r.isHeader);
const detailRows = parsedRows.filter(r => !r.isHeader);
const validCount = parsedRows.filter(r => r.valid).length;
const invalidCount = parsedRows.filter(r => !r.valid).length;
const title = isVersionMode ? "BOM 새 버전 엑셀 업로드" : "BOM 엑셀 업로드";
const description = isVersionMode
? `${bomName || "선택된 BOM"}의 새 버전을 엑셀 파일로 생성합니다. 레벨 0 행은 건너뜁니다.`
: "엑셀 파일로 새 BOM을 생성합니다. 레벨 0 = BOM 마스터, 레벨 1 이상 = 하위품목.";
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) handleClose(); }}>
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">{title}</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">{description}</DialogDescription>
</DialogHeader>
{/* Step 1: 파일 업로드 */}
{step === "upload" && (
<div className="space-y-4">
{/* 새 버전 모드: 버전명 입력 */}
{isVersionMode && (
<div>
<Label className="text-xs sm:text-sm"> ( )</Label>
<Input
value={versionName}
onChange={(e) => setVersionName(e.target.value)}
placeholder="예: 2.0"
className="h-8 text-xs sm:h-10 sm:text-sm mt-1"
/>
</div>
)}
<div
className={cn(
"border-2 border-dashed rounded-lg p-8 text-center cursor-pointer",
"hover:border-primary/50 hover:bg-muted/50 transition-colors",
"border-muted-foreground/25",
)}
onClick={() => fileInputRef.current?.click()}
>
<FileSpreadsheet className="mx-auto h-10 w-10 text-muted-foreground mb-3" />
<p className="text-sm font-medium"> </p>
<p className="text-xs text-muted-foreground mt-1">.xlsx, .xls, .csv </p>
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls,.csv"
className="hidden"
onChange={handleFileSelect}
/>
</div>
<div className="rounded-md bg-muted/50 p-3">
<p className="text-xs font-medium mb-2"> </p>
<div className="flex flex-wrap gap-1">
{EXPECTED_HEADERS.map((h, i) => (
<span
key={h}
className={cn(
"text-[10px] px-2 py-0.5 rounded-full",
i < 2 ? "bg-primary/10 text-primary font-medium" : "bg-muted text-muted-foreground",
)}
>
{h}{i < 2 ? " *" : ""}
</span>
))}
</div>
<p className="text-[10px] text-muted-foreground mt-1.5">
{isVersionMode
? "* 레벨 1 = 직접 자품목, 레벨 2 = 자품목의 자품목. 레벨 0 행이 있으면 건너뜁니다."
: "* 레벨 0 = BOM 마스터(최상위 품목, 1행), 레벨 1 = 직접 자품목, 레벨 2 = 자품목의 자품목."
}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleDownloadTemplate}
disabled={downloading}
className="w-full"
>
{downloading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
{isVersionMode && bomName ? `현재 BOM 데이터로 템플릿 다운로드` : "빈 템플릿 다운로드"}
</Button>
</div>
)}
{/* Step 2: 미리보기 */}
{step === "preview" && (
<div className="flex flex-col flex-1 min-h-0 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">{fileName}</span>
{!isVersionMode && headerRow && (
<span className="text-xs font-medium">: {headerRow.item_number}</span>
)}
<span className="text-xs">
<span className="font-medium">{detailRows.length}</span>
</span>
{invalidCount > 0 && (
<span className="text-xs text-destructive flex items-center gap-1">
<AlertCircle className="h-3 w-3" /> {invalidCount}
</span>
)}
</div>
<Button variant="ghost" size="sm" onClick={reset} className="h-7 text-xs">
<X className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="flex-1 min-h-0 overflow-auto border rounded-md">
<table className="w-full text-xs">
<thead className="bg-muted/50 sticky top-0">
<tr>
<th className="px-2 py-1.5 text-left font-medium w-8">#</th>
<th className="px-2 py-1.5 text-left font-medium w-12"></th>
<th className="px-2 py-1.5 text-center font-medium w-12"></th>
<th className="px-2 py-1.5 text-left font-medium"></th>
<th className="px-2 py-1.5 text-left font-medium"></th>
<th className="px-2 py-1.5 text-right font-medium w-16"></th>
<th className="px-2 py-1.5 text-left font-medium w-14"></th>
<th className="px-2 py-1.5 text-left font-medium w-20"></th>
<th className="px-2 py-1.5 text-left font-medium"></th>
</tr>
</thead>
<tbody>
{parsedRows.map((row) => (
<tr
key={row.rowIndex}
className={cn(
"border-t hover:bg-muted/30",
row.isHeader && "bg-blue-50/50",
!row.valid && "bg-destructive/5",
)}
>
<td className="px-2 py-1 text-muted-foreground">{row.rowIndex}</td>
<td className="px-2 py-1">
{row.isHeader ? (
<span className="text-[10px] text-blue-600 font-medium bg-blue-50 px-1.5 py-0.5 rounded">
{isVersionMode ? "건너뜀" : "마스터"}
</span>
) : row.valid ? (
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
) : (
<span className="flex items-center gap-1" title={row.error}>
<AlertCircle className="h-3.5 w-3.5 text-destructive" />
</span>
)}
</td>
<td className="px-2 py-1 text-center">
<span
className={cn(
"inline-block rounded px-1.5 py-0.5 text-[10px] font-mono",
row.isHeader ? "bg-blue-100 text-blue-700 font-medium" : "bg-muted",
)}
style={{ marginLeft: `${row.level * 8}px` }}
>
{row.level}
</span>
</td>
<td className={cn("px-2 py-1 font-mono", row.isHeader && "font-semibold")}>{row.item_number}</td>
<td className={cn("px-2 py-1", row.isHeader && "font-semibold")}>{row.item_name}</td>
<td className="px-2 py-1 text-right font-mono">{row.quantity}</td>
<td className="px-2 py-1">{row.unit}</td>
<td className="px-2 py-1">{row.process_type}</td>
<td className="px-2 py-1 text-muted-foreground truncate max-w-[100px]">{row.remark}</td>
</tr>
))}
</tbody>
</table>
</div>
{invalidCount > 0 && (
<div className="rounded-md bg-destructive/10 p-2.5 text-xs text-destructive">
<div className="font-medium mb-1"> ({invalidCount})</div>
<ul className="space-y-0.5 ml-3 list-disc">
{parsedRows.filter(r => !r.valid).slice(0, 5).map(r => (
<li key={r.rowIndex}>{r.rowIndex}: {r.error}</li>
))}
{invalidCount > 5 && <li>... {invalidCount - 5}</li>}
</ul>
</div>
)}
<div className="text-xs text-muted-foreground">
{isVersionMode
? "레벨 1 이상의 하위품목으로 새 버전을 생성합니다."
: "레벨 0 품목으로 새 BOM 마스터를 생성하고, 레벨 1 이상은 하위품목으로 등록합니다."
}
</div>
</div>
)}
{/* Step 3: 결과 */}
{step === "result" && uploadResult && (
<div className="space-y-4 py-4">
<div className="flex flex-col items-center text-center">
<div className="w-14 h-14 rounded-full bg-green-100 flex items-center justify-center mb-3">
<CheckCircle2 className="h-7 w-7 text-green-600" />
</div>
<h3 className="text-lg font-semibold">
{isVersionMode ? "새 버전 생성 완료" : "BOM 생성 완료"}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{uploadResult.insertedCount} .
</p>
</div>
<div className={cn("grid gap-3 max-w-xs mx-auto", isVersionMode ? "grid-cols-1" : "grid-cols-2")}>
{!isVersionMode && (
<div className="rounded-lg bg-muted/50 p-3 text-center">
<div className="text-2xl font-bold text-blue-600">1</div>
<div className="text-xs text-muted-foreground">BOM </div>
</div>
)}
<div className="rounded-lg bg-muted/50 p-3 text-center">
<div className="text-2xl font-bold text-green-600">{uploadResult.insertedCount}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
</div>
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
{step === "upload" && (
<Button
variant="outline"
onClick={handleClose}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
)}
{step === "preview" && (
<>
<Button
variant="outline"
onClick={reset}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleUpload}
disabled={uploading || invalidCount > 0}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{uploading ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> ...</>
) : (
<><Upload className="mr-2 h-4 w-4" />
{isVersionMode ? `새 버전 생성 (${detailRows.length}건)` : `BOM 생성 (${detailRows.length}건)`}
</>
)}
</Button>
</>
)}
{step === "result" && (
<Button
onClick={handleClose}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -14,6 +14,7 @@ import {
History,
GitBranch,
Check,
FileSpreadsheet,
} from "lucide-react";
import { cn } from "@/lib/utils";
@ -22,6 +23,7 @@ import { Button } from "@/components/ui/button";
import { BomDetailEditModal } from "./BomDetailEditModal";
import { BomHistoryModal } from "./BomHistoryModal";
import { BomVersionModal } from "./BomVersionModal";
import { BomExcelUploadModal } from "./BomExcelUploadModal";
interface BomTreeNode {
id: string;
@ -77,6 +79,7 @@ export function BomTreeComponent({
const [editTargetNode, setEditTargetNode] = useState<BomTreeNode | null>(null);
const [historyModalOpen, setHistoryModalOpen] = useState(false);
const [versionModalOpen, setVersionModalOpen] = useState(false);
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [colWidths, setColWidths] = useState<Record<string, number>>({});
const handleResizeStart = useCallback((colKey: string, e: React.MouseEvent) => {
@ -138,6 +141,23 @@ export function BomTreeComponent({
const showHistory = features.showHistory !== false;
const showVersion = features.showVersion !== false;
// 카테고리 라벨 캐시 (process_type 등)
const [categoryLabels, setCategoryLabels] = useState<Record<string, Record<string, string>>>({});
useEffect(() => {
const loadLabels = async () => {
try {
const res = await apiClient.get(`/table-categories/${detailTable}/process_type/values`);
const vals = res.data?.data || [];
if (vals.length > 0) {
const map: Record<string, string> = {};
vals.forEach((v: any) => { map[v.value_code] = v.value_label; });
setCategoryLabels((prev) => ({ ...prev, process_type: map }));
}
} catch { /* 무시 */ }
};
loadLabels();
}, [detailTable]);
// ─── 데이터 로드 ───
// BOM 헤더 데이터로 가상 0레벨 루트 노드 생성
@ -168,7 +188,18 @@ export function BomTreeComponent({
setLoading(true);
try {
const searchFilter: Record<string, any> = { [foreignKey]: bomId };
const versionId = headerData?.current_version_id;
let versionId = headerData?.current_version_id;
// version_id가 없으면 서버에서 자동 초기화
if (!versionId) {
try {
const initRes = await apiClient.post(`/bom/${bomId}/initialize-version`);
if (initRes.data?.success && initRes.data.data?.versionId) {
versionId = initRes.data.data.versionId;
}
} catch { /* 무시 */ }
}
if (versionId) {
searchFilter.version_id = versionId;
}
@ -263,6 +294,7 @@ export function BomTreeComponent({
item_name: raw.item_name || "",
item_code: raw.item_number || raw.item_code || "",
item_type: raw.item_type || raw.division || "",
unit: raw.unit || raw.item_unit || "",
} as BomHeaderInfo;
}
} catch (e) {
@ -348,6 +380,18 @@ export function BomTreeComponent({
detail.editData[key] = (headerInfo as any)[key];
}
});
// entity join된 필드를 dot notation으로도 매핑 (item_info.xxx 형식)
const h = headerInfo as Record<string, any>;
if (h.item_name) detail.editData["item_info.item_name"] = h.item_name;
if (h.item_type) detail.editData["item_info.division"] = h.item_type;
if (h.item_code || h.item_number) detail.editData["item_info.item_number"] = h.item_code || h.item_number;
if (h.unit) detail.editData["item_info.unit"] = h.unit;
// entity join alias 형식도 매핑
if (h.item_name) detail.editData["item_id_item_name"] = h.item_name;
if (h.item_type) detail.editData["item_id_division"] = h.item_type;
if (h.item_code || h.item_number) detail.editData["item_id_item_number"] = h.item_code || h.item_number;
if (h.unit) detail.editData["item_id_unit"] = h.unit;
};
// capture: true → EditModal 리스너(bubble)보다 반드시 먼저 실행
window.addEventListener("openEditModal", handler, true);
@ -461,6 +505,11 @@ export function BomTreeComponent({
return <span className="font-medium text-gray-900">{value || "-"}</span>;
}
if (col.key === "status") {
const statusMap: Record<string, string> = { active: "사용", inactive: "미사용", developing: "개발중" };
return <span>{statusMap[String(value)] || value || "-"}</span>;
}
if (col.key === "quantity" || col.key === "base_qty") {
return (
<span className="font-medium tabular-nums text-gray-800">
@ -469,6 +518,11 @@ export function BomTreeComponent({
);
}
if (col.key === "process_type" && value) {
const label = categoryLabels.process_type?.[String(value)] || String(value);
return <span>{label}</span>;
}
if (col.key === "loss_rate") {
const num = Number(value);
if (!num) return <span className="text-gray-300">-</span>;
@ -786,6 +840,15 @@ export function BomTreeComponent({
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => setExcelUploadOpen(true)}
className="h-6 gap-1 px-2 text-[10px]"
>
<FileSpreadsheet className="h-3 w-3" />
</Button>
<div className="mx-1 h-4 w-px bg-gray-200" />
<div className="flex overflow-hidden rounded-md border">
<button
@ -1098,6 +1161,18 @@ export function BomTreeComponent({
}}
/>
)}
{selectedBomId && (
<BomExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
bomId={selectedBomId}
bomName={headerInfo?.item_name || ""}
onSuccess={() => {
window.dispatchEvent(new CustomEvent("refreshTable"));
}}
/>
)}
</div>
);
}

View File

@ -43,6 +43,8 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [actionId, setActionId] = useState<string | null>(null);
const [newVersionName, setNewVersionName] = useState("");
const [showNewInput, setShowNewInput] = useState(false);
useEffect(() => {
if (open && bomId) loadVersions();
@ -63,11 +65,26 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve
const handleCreateVersion = async () => {
if (!bomId) return;
const trimmed = newVersionName.trim();
if (!trimmed) {
alert("버전명을 입력해주세요.");
return;
}
setCreating(true);
try {
const res = await apiClient.post(`/bom/${bomId}/versions`, { tableName, detailTable });
if (res.data?.success) loadVersions();
} catch (error) {
const res = await apiClient.post(`/bom/${bomId}/versions`, {
tableName, detailTable, versionName: trimmed,
});
if (res.data?.success) {
setNewVersionName("");
setShowNewInput(false);
loadVersions();
} else {
alert(res.data?.message || "버전 생성 실패");
}
} catch (error: any) {
const msg = error.response?.data?.message || "버전 생성 실패";
alert(msg);
console.error("[BomVersion] 생성 실패:", error);
} finally {
setCreating(false);
@ -230,15 +247,46 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve
)}
</div>
{showNewInput && (
<div className="flex items-center gap-2 border-t pt-3">
<input
type="text"
value={newVersionName}
onChange={(e) => setNewVersionName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateVersion()}
placeholder="버전명 입력 (예: 2.0, B, 개선판)"
className="h-8 flex-1 rounded-md border px-3 text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:h-10 sm:text-sm"
autoFocus
/>
<Button
onClick={handleCreateVersion}
disabled={creating}
size="sm"
className="h-8 text-xs sm:h-10 sm:text-sm"
>
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : "생성"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => { setShowNewInput(false); setNewVersionName(""); }}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
</Button>
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button
onClick={handleCreateVersion}
disabled={creating}
className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{creating ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Plus className="mr-1 h-4 w-4" />}
</Button>
{!showNewInput && (
<Button
onClick={() => setShowNewInput(true)}
className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
<Plus className="mr-1 h-4 w-4" />
</Button>
)}
<Button
variant="outline"
onClick={() => onOpenChange(false)}

View File

@ -1481,9 +1481,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
)}
</div>
{/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */}
{/* 확인 다이얼로그 */}
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent className="z-[99999]">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{getConfirmTitle()}</AlertDialogTitle>
<AlertDialogDescription>{getConfirmMessage()}</AlertDialogDescription>

View File

@ -247,14 +247,12 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
</DialogHeader>
<div className="flex h-[75vh] flex-col space-y-3">
{/* 파일 업로드 영역 - 높이 축소 */}
{!isDesignMode && (
{/* 파일 업로드 영역 - readonly/disabled이면 숨김 */}
{!isDesignMode && !config.readonly && !config.disabled && (
<div
className={`cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors ${dragOver ? "border-blue-400 bg-blue-50" : "border-gray-300"} ${config.disabled ? "cursor-not-allowed opacity-50" : "hover:border-gray-400"} ${uploading ? "opacity-75" : ""} `}
className={`cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors ${dragOver ? "border-blue-400 bg-blue-50" : "border-gray-300"} hover:border-gray-400 ${uploading ? "opacity-75" : ""} `}
onClick={() => {
if (!config.disabled && !isDesignMode) {
fileInputRef.current?.click();
}
fileInputRef.current?.click();
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@ -267,7 +265,6 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
accept={config.accept}
onChange={handleFileInputChange}
className="hidden"
disabled={config.disabled}
/>
{uploading ? (
@ -286,8 +283,8 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
{/* 좌우 분할 레이아웃 - 좌측 넓게, 우측 고정 너비 */}
<div className="flex min-h-0 flex-1 gap-4">
{/* 좌측: 이미지 미리보기 (확대/축소 가능) */}
<div className="relative flex flex-1 flex-col overflow-hidden rounded-lg border border-gray-200 bg-gray-900">
{/* 좌측: 이미지 미리보기 (확대/축소 가능) - showPreview가 false면 숨김 */}
{(config.showPreview !== false) && <div className="relative flex flex-1 flex-col overflow-hidden rounded-lg border border-gray-200 bg-gray-900">
{/* 확대/축소 컨트롤 */}
{selectedFile && previewImageUrl && (
<div className="absolute top-3 left-3 z-10 flex items-center gap-1 rounded-lg bg-black/60 p-1">
@ -369,10 +366,10 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
{selectedFile.realFileName}
</div>
)}
</div>
</div>}
{/* 우측: 파일 목록 (고정 너비) */}
<div className="flex w-[400px] shrink-0 flex-col overflow-hidden rounded-lg border border-gray-200">
{/* 우측: 파일 목록 - showFileList가 false면 숨김, showPreview가 false면 전체 너비 */}
{(config.showFileList !== false) && <div className={`flex shrink-0 flex-col overflow-hidden rounded-lg border border-gray-200 ${config.showPreview !== false ? "w-[400px]" : "flex-1"}`}>
<div className="border-b border-gray-200 bg-gray-50 p-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-700"> </h3>
@ -404,7 +401,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
)}
</div>
<p className="text-xs text-gray-500">
{formatFileSize(file.fileSize)} {file.fileExt.toUpperCase()}
{config.showFileSize !== false && <>{formatFileSize(file.fileSize)} </>}{file.fileExt.toUpperCase()}
</p>
</div>
<div className="flex items-center space-x-1">
@ -434,19 +431,21 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
>
<Eye className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation();
onFileDownload(file);
}}
title="다운로드"
>
<Download className="h-3 w-3" />
</Button>
{!isDesignMode && (
{config.allowDownload !== false && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation();
onFileDownload(file);
}}
title="다운로드"
>
<Download className="h-3 w-3" />
</Button>
)}
{!isDesignMode && config.allowDelete !== false && (
<Button
variant="ghost"
size="sm"
@ -476,7 +475,7 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
</div>
)}
</div>
</div>
</div>}
</div>
</div>
</DialogContent>
@ -487,8 +486,8 @@ export const FileManagerModal: React.FC<FileManagerModalProps> = ({
file={viewerFile}
isOpen={isViewerOpen}
onClose={handleViewerClose}
onDownload={onFileDownload}
onDelete={!isDesignMode ? onFileDelete : undefined}
onDownload={config.allowDownload !== false ? onFileDownload : undefined}
onDelete={!isDesignMode && config.allowDelete !== false ? onFileDelete : undefined}
/>
</>
);

View File

@ -105,6 +105,8 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const [forceUpdate, setForceUpdate] = useState(0);
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// objid 기반으로 파일이 로드되었는지 추적 (다른 이펙트가 덮어쓰지 않도록 방지)
const filesLoadedFromObjidRef = useRef(false);
// 🔑 레코드 모드 판단: formData에 id가 있으면 특정 행에 연결된 파일 관리
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_'));
@ -150,6 +152,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
if (isRecordMode || !recordId) {
setUploadedFiles([]);
setRepresentativeImageUrl(null);
filesLoadedFromObjidRef.current = false;
}
} else if (prevIsRecordModeRef.current === null) {
// 초기 마운트 시 모드 저장
@ -191,63 +194,68 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
}, [component.id, getUniqueKey, recordId, isRecordMode]); // 레코드별 고유 키 변경 시 재실행
// 🔑 수정 모드: formData[columnName]에 저장된 objid로 이미지 로드
// 🆕 formData 전체가 아닌 특정 컬럼 값만 의존하도록 수정 (다른 컴포넌트 영향 방지)
// 콤마로 구분된 다중 objid도 처리 (예: "123,456")
const imageObjidFromFormData = formData?.[columnName];
useEffect(() => {
// 이미지 objid가 있고, 숫자 문자열인 경우에만 처리
if (imageObjidFromFormData && /^\d+$/.test(String(imageObjidFromFormData))) {
const objidStr = String(imageObjidFromFormData);
// 이미 같은 objid의 파일이 로드되어 있으면 스킵
const alreadyLoaded = uploadedFiles.some(f => String(f.objid) === objidStr);
if (alreadyLoaded) {
return;
}
// 🔑 실제 파일 정보 조회 (previewUrl 제거 - apiClient blob 다운로드 방식으로 통일)
(async () => {
try {
const fileInfoResponse = await getFileInfoByObjid(objidStr);
if (!imageObjidFromFormData) return;
const rawValue = String(imageObjidFromFormData);
// 콤마 구분 다중 objid 또는 단일 objid 모두 처리
const objids = rawValue.split(',').map(s => s.trim()).filter(s => /^\d+$/.test(s));
if (objids.length === 0) return;
// 모든 objid가 이미 로드되어 있으면 스킵
const allLoaded = objids.every(id => uploadedFiles.some(f => String(f.objid) === id));
if (allLoaded) return;
(async () => {
try {
const loadedFiles: FileInfo[] = [];
for (const objid of objids) {
// 이미 로드된 파일은 스킵
if (uploadedFiles.some(f => String(f.objid) === objid)) continue;
const fileInfoResponse = await getFileInfoByObjid(objid);
if (fileInfoResponse.success && fileInfoResponse.data) {
const { realFileName, fileSize, fileExt, regdate, isRepresentative } = fileInfoResponse.data;
const fileInfo = {
objid: objidStr,
realFileName: realFileName,
fileExt: fileExt,
fileSize: fileSize,
filePath: getFilePreviewUrl(objidStr),
regdate: regdate,
loadedFiles.push({
objid,
realFileName,
fileExt,
fileSize,
filePath: getFilePreviewUrl(objid),
regdate,
isImage: true,
isRepresentative: isRepresentative,
};
setUploadedFiles([fileInfo]);
// representativeImageUrl은 loadRepresentativeImage에서 blob으로 로드됨
isRepresentative,
} as FileInfo);
} else {
// 파일 정보 조회 실패 시 최소 정보로 추가
console.warn("🖼️ [FileUploadComponent] 파일 정보 조회 실패, 최소 정보 사용");
const minimalFileInfo = {
objid: objidStr,
realFileName: `image_${objidStr}.jpg`,
loadedFiles.push({
objid,
realFileName: `file_${objid}`,
fileExt: '.jpg',
fileSize: 0,
filePath: getFilePreviewUrl(objidStr),
filePath: getFilePreviewUrl(objid),
regdate: new Date().toISOString(),
isImage: true,
};
setUploadedFiles([minimalFileInfo]);
// representativeImageUrl은 loadRepresentativeImage에서 blob으로 로드됨
} as FileInfo);
}
} catch (error) {
console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error);
}
})();
}
}, [imageObjidFromFormData, columnName, component.id]); // 🆕 formData 대신 특정 컬럼 값만 의존
if (loadedFiles.length > 0) {
setUploadedFiles(loadedFiles);
filesLoadedFromObjidRef.current = true;
}
} catch (error) {
console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error);
}
})();
}, [imageObjidFromFormData, columnName, component.id]);
// 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너
// 🆕 columnName도 체크하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분
@ -365,6 +373,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
...file,
}));
// 서버에서 0개 반환 + objid 기반 로딩이 이미 완료된 경우 덮어쓰지 않음
if (formattedFiles.length === 0 && filesLoadedFromObjidRef.current) {
return false;
}
// 🔄 localStorage의 기존 파일과 서버 파일 병합 (레코드별 고유 키 사용)
let finalFiles = formattedFiles;
@ -427,14 +439,19 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
return; // DB 로드 성공 시 localStorage 무시
}
// 🆕 등록 모드(새 레코드)인 경우 fallback 로드도 스킵 - 항상 빈 상태 유지
// objid 기반으로 이미 파일이 로드된 경우 빈 데이터로 덮어쓰지 않음
if (filesLoadedFromObjidRef.current) {
return;
}
// 등록 모드(새 레코드)인 경우 fallback 로드도 스킵 - 항상 빈 상태 유지
if (!isRecordMode || !recordId) {
return;
}
// DB 로드 실패 시에만 기존 로직 사용 (하위 호환성)
// 전역 상태에서 최신 파일 정보 가져오기 (🆕 고유 키 사용)
// 전역 상태에서 최신 파일 정보 가져오기 (고유 키 사용)
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
const uniqueKeyForFallback = getUniqueKey();
const globalFiles = globalFileState[uniqueKeyForFallback] || globalFileState[component.id] || [];
@ -442,6 +459,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles;
// 빈 데이터로 기존 파일을 덮어쓰지 않음
if (currentFiles.length === 0) {
return;
}
// 최신 파일과 현재 파일 비교
if (JSON.stringify(currentFiles) !== JSON.stringify(uploadedFiles)) {
@ -1147,8 +1168,8 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
file={viewerFile}
isOpen={isViewerOpen}
onClose={handleViewerClose}
onDownload={handleFileDownload}
onDelete={!isDesignMode ? handleFileDelete : undefined}
onDownload={safeComponentConfig.allowDownload !== false ? handleFileDownload : undefined}
onDelete={!isDesignMode && safeComponentConfig.allowDelete !== false ? handleFileDelete : undefined}
/>
{/* 파일 관리 모달 */}

View File

@ -42,8 +42,8 @@ export function ProcessWorkStandardComponent({
items,
routings,
workItems,
selectedWorkItemDetails,
selectedWorkItemId,
selectedWorkItemIdByPhase,
selectedDetailsByPhase,
selection,
loading,
fetchItems,
@ -105,8 +105,8 @@ export function ProcessWorkStandardComponent({
);
const handleSelectWorkItem = useCallback(
(workItemId: string) => {
fetchWorkItemDetails(workItemId);
(workItemId: string, phaseKey: string) => {
fetchWorkItemDetails(workItemId, phaseKey);
},
[fetchWorkItemDetails]
);
@ -191,8 +191,8 @@ export function ProcessWorkStandardComponent({
key={phase.key}
phase={phase}
items={workItemsByPhase[phase.key] || []}
selectedWorkItemId={selectedWorkItemId}
selectedWorkItemDetails={selectedWorkItemDetails}
selectedWorkItemId={selectedWorkItemIdByPhase[phase.key] || null}
selectedWorkItemDetails={selectedDetailsByPhase[phase.key] || []}
detailTypes={config.detailTypes}
readonly={config.readonly}
onSelectWorkItem={handleSelectWorkItem}

View File

@ -114,7 +114,14 @@ export function DetailFormModal({
if (type === "input" && !formData.content?.trim()) return;
if (type === "info" && !formData.lookup_target) return;
onSubmit(formData);
const submitData = { ...formData };
if (type === "info" && !submitData.content?.trim()) {
const targetLabel = LOOKUP_TARGETS.find(t => t.value === submitData.lookup_target)?.label || submitData.lookup_target;
submitData.content = `${targetLabel} 조회`;
}
onSubmit(submitData);
onClose();
};

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -61,11 +61,24 @@ export function WorkItemAddModal({
detailTypes,
editItem,
}: WorkItemAddModalProps) {
const [title, setTitle] = useState(editItem?.title || "");
const [isRequired, setIsRequired] = useState(editItem?.is_required || "Y");
const [description, setDescription] = useState(editItem?.description || "");
const [title, setTitle] = useState("");
const [isRequired, setIsRequired] = useState("Y");
const [description, setDescription] = useState("");
const [details, setDetails] = useState<ModalDetail[]>([]);
useEffect(() => {
if (open && editItem) {
setTitle(editItem.title || "");
setIsRequired(editItem.is_required || "Y");
setDescription(editItem.description || "");
} else if (open && !editItem) {
setTitle("");
setIsRequired("Y");
setDescription("");
setDetails([]);
}
}, [open, editItem]);
const resetForm = () => {
setTitle("");
setIsRequired("Y");

View File

@ -20,13 +20,13 @@ interface WorkPhaseSectionProps {
selectedWorkItemDetails: WorkItemDetail[];
detailTypes: DetailTypeDefinition[];
readonly?: boolean;
onSelectWorkItem: (workItemId: string) => void;
onSelectWorkItem: (workItemId: string, phaseKey: string) => void;
onAddWorkItem: (phase: string) => void;
onEditWorkItem: (item: WorkItem) => void;
onDeleteWorkItem: (id: string) => void;
onCreateDetail: (workItemId: string, data: Partial<WorkItemDetail>) => void;
onUpdateDetail: (id: string, data: Partial<WorkItemDetail>) => void;
onDeleteDetail: (id: string) => void;
onCreateDetail: (workItemId: string, data: Partial<WorkItemDetail>, phaseKey: string) => void;
onUpdateDetail: (id: string, data: Partial<WorkItemDetail>, phaseKey: string) => void;
onDeleteDetail: (id: string, phaseKey: string) => void;
}
export function WorkPhaseSection({
@ -45,9 +45,6 @@ export function WorkPhaseSection({
onDeleteDetail,
}: WorkPhaseSectionProps) {
const selectedItem = items.find((i) => i.id === selectedWorkItemId) || null;
const isThisSectionSelected = items.some(
(i) => i.id === selectedWorkItemId
);
return (
<div className="rounded-lg border bg-card">
@ -94,7 +91,7 @@ export function WorkPhaseSection({
item={item}
isSelected={selectedWorkItemId === item.id}
readonly={readonly}
onClick={() => onSelectWorkItem(item.id)}
onClick={() => onSelectWorkItem(item.id, phase.key)}
onEdit={() => onEditWorkItem(item)}
onDelete={() => onDeleteWorkItem(item.id)}
/>
@ -106,15 +103,15 @@ export function WorkPhaseSection({
{/* 우측: 상세 리스트 */}
<div className="flex-1">
<WorkItemDetailList
workItem={isThisSectionSelected ? selectedItem : null}
details={isThisSectionSelected ? selectedWorkItemDetails : []}
workItem={selectedItem}
details={selectedWorkItemDetails}
detailTypes={detailTypes}
readonly={readonly}
onCreateDetail={(data) =>
selectedWorkItemId && onCreateDetail(selectedWorkItemId, data)
selectedWorkItemId && onCreateDetail(selectedWorkItemId, data, phase.key)
}
onUpdateDetail={onUpdateDetail}
onDeleteDetail={onDeleteDetail}
onUpdateDetail={(id, data) => onUpdateDetail(id, data, phase.key)}
onDeleteDetail={(id) => onDeleteDetail(id, phase.key)}
/>
</div>
</div>

View File

@ -27,7 +27,6 @@ export const defaultConfig: ProcessWorkStandardConfig = {
{ value: "inspect", label: "검사항목" },
{ value: "procedure", label: "작업절차" },
{ value: "input", label: "직접입력" },
{ value: "info", label: "정보조회" },
],
splitRatio: 30,
leftPanelTitle: "품목 및 공정 선택",

View File

@ -17,8 +17,9 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
const [items, setItems] = useState<ItemData[]>([]);
const [routings, setRoutings] = useState<RoutingVersion[]>([]);
const [workItems, setWorkItems] = useState<WorkItem[]>([]);
const [selectedWorkItemDetails, setSelectedWorkItemDetails] = useState<WorkItemDetail[]>([]);
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(null);
// 섹션(phase)별 독립적인 선택 상태 관리
const [selectedWorkItemIdByPhase, setSelectedWorkItemIdByPhase] = useState<Record<string, string | null>>({});
const [selectedDetailsByPhase, setSelectedDetailsByPhase] = useState<Record<string, WorkItemDetail[]>>({});
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
@ -101,15 +102,15 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
}
}, []);
// 작업 항목 상세 조회
const fetchWorkItemDetails = useCallback(async (workItemId: string) => {
// 작업 항목 상세 조회 (phase별 독립 저장)
const fetchWorkItemDetails = useCallback(async (workItemId: string, phaseKey: string) => {
try {
const res = await apiClient.get(
`${API_BASE}/work-items/${workItemId}/details`
);
if (res.data?.success) {
setSelectedWorkItemDetails(res.data.data);
setSelectedWorkItemId(workItemId);
setSelectedDetailsByPhase(prev => ({ ...prev, [phaseKey]: res.data.data }));
setSelectedWorkItemIdByPhase(prev => ({ ...prev, [phaseKey]: workItemId }));
}
} catch (err) {
console.error("상세 조회 실패", err);
@ -129,8 +130,8 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
processName: null,
}));
setWorkItems([]);
setSelectedWorkItemDetails([]);
setSelectedWorkItemId(null);
setSelectedDetailsByPhase({});
setSelectedWorkItemIdByPhase({});
await fetchRoutings(itemCode);
},
[fetchRoutings]
@ -151,8 +152,8 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
routingDetailId,
processName,
}));
setSelectedWorkItemDetails([]);
setSelectedWorkItemId(null);
setSelectedDetailsByPhase({});
setSelectedWorkItemIdByPhase({});
await fetchWorkItems(routingDetailId);
},
[fetchWorkItems]
@ -233,28 +234,43 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
const res = await apiClient.delete(`${API_BASE}/work-items/${id}`);
if (res.data?.success && selection.routingDetailId) {
await fetchWorkItems(selection.routingDetailId);
if (selectedWorkItemId === id) {
setSelectedWorkItemDetails([]);
setSelectedWorkItemId(null);
}
// 삭제된 항목이 선택되어 있던 phase의 선택 상태 초기화
setSelectedWorkItemIdByPhase(prev => {
const next = { ...prev };
for (const phaseKey of Object.keys(next)) {
if (next[phaseKey] === id) {
next[phaseKey] = null;
}
}
return next;
});
setSelectedDetailsByPhase(prev => {
const next = { ...prev };
for (const phaseKey of Object.keys(next)) {
if (selectedWorkItemIdByPhase[phaseKey] === id) {
next[phaseKey] = [];
}
}
return next;
});
}
} catch (err) {
console.error("작업 항목 삭제 실패", err);
}
},
[selection.routingDetailId, selectedWorkItemId, fetchWorkItems]
[selection.routingDetailId, selectedWorkItemIdByPhase, fetchWorkItems]
);
// 상세 추가
const createDetail = useCallback(
async (workItemId: string, data: Partial<WorkItemDetail>) => {
async (workItemId: string, data: Partial<WorkItemDetail>, phaseKey: string) => {
try {
const res = await apiClient.post(`${API_BASE}/work-item-details`, {
work_item_id: workItemId,
...data,
});
if (res.data?.success) {
await fetchWorkItemDetails(workItemId);
await fetchWorkItemDetails(workItemId, phaseKey);
if (selection.routingDetailId) {
await fetchWorkItems(selection.routingDetailId);
}
@ -268,32 +284,36 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
// 상세 수정
const updateDetail = useCallback(
async (id: string, data: Partial<WorkItemDetail>) => {
async (id: string, data: Partial<WorkItemDetail>, phaseKey: string) => {
try {
const res = await apiClient.put(
`${API_BASE}/work-item-details/${id}`,
data
);
if (res.data?.success && selectedWorkItemId) {
await fetchWorkItemDetails(selectedWorkItemId);
if (res.data?.success) {
const workItemId = selectedWorkItemIdByPhase[phaseKey];
if (workItemId) {
await fetchWorkItemDetails(workItemId, phaseKey);
}
}
} catch (err) {
console.error("상세 수정 실패", err);
}
},
[selectedWorkItemId, fetchWorkItemDetails]
[selectedWorkItemIdByPhase, fetchWorkItemDetails]
);
// 상세 삭제
const deleteDetail = useCallback(
async (id: string) => {
async (id: string, phaseKey: string) => {
try {
const res = await apiClient.delete(
`${API_BASE}/work-item-details/${id}`
);
if (res.data?.success) {
if (selectedWorkItemId) {
await fetchWorkItemDetails(selectedWorkItemId);
const workItemId = selectedWorkItemIdByPhase[phaseKey];
if (workItemId) {
await fetchWorkItemDetails(workItemId, phaseKey);
}
if (selection.routingDetailId) {
await fetchWorkItems(selection.routingDetailId);
@ -304,7 +324,7 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
}
},
[
selectedWorkItemId,
selectedWorkItemIdByPhase,
selection.routingDetailId,
fetchWorkItemDetails,
fetchWorkItems,
@ -315,8 +335,8 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
items,
routings,
workItems,
selectedWorkItemDetails,
selectedWorkItemId,
selectedWorkItemIdByPhase,
selectedDetailsByPhase,
selection,
loading,
saving,
@ -325,7 +345,6 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
selectProcess,
fetchWorkItems,
fetchWorkItemDetails,
setSelectedWorkItemId,
createWorkItem,
updateWorkItem,
deleteWorkItem,

View File

@ -21,6 +21,7 @@ interface V2RepeaterRendererProps {
onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void;
parentId?: string | number;
formData?: Record<string, any>;
groupedData?: Record<string, any>[];
}
const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
@ -33,6 +34,7 @@ const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
onButtonClick,
parentId,
formData,
groupedData,
}) => {
// component.componentConfig 또는 component.config에서 V2RepeaterConfig 추출
const config: V2RepeaterConfig = React.useMemo(() => {
@ -105,6 +107,7 @@ const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
onButtonClick={onButtonClick}
className={component?.className}
formData={formData}
groupedData={groupedData}
/>
);
};

View File

@ -20,6 +20,7 @@ import {
Trash2,
Settings,
Move,
FileSpreadsheet,
} from "lucide-react";
import { dataApi } from "@/lib/api/data";
import { entityJoinApi } from "@/lib/api/entityJoin";
@ -43,6 +44,7 @@ import { useSplitPanel } from "./SplitPanelContext";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { PanelInlineComponent } from "./types";
import { cn } from "@/lib/utils";
import { BomExcelUploadModal } from "../v2-bom-tree/BomExcelUploadModal";
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
// 추가 props
@ -500,6 +502,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [showAddModal, setShowAddModal] = useState(false);
const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | "left-item" | null>(null);
const [addModalFormData, setAddModalFormData] = useState<Record<string, any>>({});
const [bomExcelUploadOpen, setBomExcelUploadOpen] = useState(false);
// 수정 모달 상태
const [showEditModal, setShowEditModal] = useState(false);
@ -3010,12 +3013,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<CardTitle className="text-base font-semibold">
{componentConfig.leftPanel?.title || "좌측 패널"}
</CardTitle>
{!isDesignMode && componentConfig.leftPanel?.showAdd && (
<Button size="sm" variant="outline" onClick={() => handleAddClick("left")}>
<Plus className="mr-1 h-4 w-4" />
</Button>
)}
<div className="flex items-center gap-1">
{!isDesignMode && (componentConfig.leftPanel as any)?.showBomExcelUpload && (
<Button size="sm" variant="outline" onClick={() => setBomExcelUploadOpen(true)}>
<FileSpreadsheet className="mr-1 h-4 w-4" />
</Button>
)}
{!isDesignMode && componentConfig.leftPanel?.showAdd && (
<Button size="sm" variant="outline" onClick={() => handleAddClick("left")}>
<Plus className="mr-1 h-4 w-4" />
</Button>
)}
</div>
</div>
</CardHeader>
{componentConfig.leftPanel?.showSearch && (
@ -3361,6 +3372,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}));
// 🔧 그룹화된 데이터 렌더링
const hasGroupedLeftActions = !isDesignMode && (
(componentConfig.leftPanel?.showEdit !== false) ||
(componentConfig.leftPanel?.showDelete !== false)
);
if (groupedLeftData.length > 0) {
return (
<div className="overflow-auto">
@ -3385,6 +3400,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
{col.label}
</th>
))}
{hasGroupedLeftActions && (
<th className="px-3 py-2 text-right text-xs font-medium tracking-wider text-gray-500 uppercase whitespace-nowrap" style={{ width: "80px" }}>
</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
@ -3399,7 +3418,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<tr
key={itemId}
onClick={() => handleLeftItemSelect(item)}
className={`hover:bg-accent cursor-pointer transition-colors ${
className={`group hover:bg-accent cursor-pointer transition-colors ${
isSelected ? "bg-primary/10" : ""
}`}
>
@ -3417,6 +3436,34 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
)}
</td>
))}
{hasGroupedLeftActions && (
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{(componentConfig.leftPanel?.showEdit !== false) && (
<button
onClick={(e) => {
e.stopPropagation();
handleEditClick("left", item);
}}
className="rounded p-1 transition-colors hover:bg-gray-200"
>
<Pencil className="h-3.5 w-3.5 text-gray-500" />
</button>
)}
{(componentConfig.leftPanel?.showDelete !== false) && (
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteClick("left", item);
}}
className="rounded p-1 transition-colors hover:bg-red-100"
>
<Trash2 className="h-3.5 w-3.5 text-red-500" />
</button>
)}
</div>
</td>
)}
</tr>
);
})}
@ -3429,6 +3476,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}
// 🔧 일반 테이블 렌더링 (그룹화 없음)
const hasLeftTableActions = !isDesignMode && (
(componentConfig.leftPanel?.showEdit !== false) ||
(componentConfig.leftPanel?.showDelete !== false)
);
return (
<div className="overflow-auto">
<table className="min-w-full divide-y divide-gray-200">
@ -3447,6 +3498,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
{col.label}
</th>
))}
{hasLeftTableActions && (
<th className="px-3 py-2 text-right text-xs font-medium tracking-wider text-gray-500 uppercase whitespace-nowrap" style={{ width: "80px" }}>
</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
@ -3461,7 +3516,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<tr
key={itemId}
onClick={() => handleLeftItemSelect(item)}
className={`hover:bg-accent cursor-pointer transition-colors ${
className={`group hover:bg-accent cursor-pointer transition-colors ${
isSelected ? "bg-primary/10" : ""
}`}
>
@ -3479,6 +3534,34 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
)}
</td>
))}
{hasLeftTableActions && (
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{(componentConfig.leftPanel?.showEdit !== false) && (
<button
onClick={(e) => {
e.stopPropagation();
handleEditClick("left", item);
}}
className="rounded p-1 transition-colors hover:bg-gray-200"
>
<Pencil className="h-3.5 w-3.5 text-gray-500" />
</button>
)}
{(componentConfig.leftPanel?.showDelete !== false) && (
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteClick("left", item);
}}
className="rounded p-1 transition-colors hover:bg-red-100"
>
<Trash2 className="h-3.5 w-3.5 text-red-500" />
</button>
)}
</div>
</td>
)}
</tr>
);
})}
@ -4998,6 +5081,16 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</DialogFooter>
</DialogContent>
</Dialog>
{(componentConfig.leftPanel as any)?.showBomExcelUpload && (
<BomExcelUploadModal
open={bomExcelUploadOpen}
onOpenChange={setBomExcelUploadOpen}
onSuccess={() => {
loadLeftData();
}}
/>
)}
</div>
);
};

View File

@ -14,53 +14,71 @@ import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
// 🖼️ 테이블 셀 이미지 썸네일 컴포넌트
// objid인 경우 인증된 API로 blob URL 생성, 경로인 경우 직접 URL 사용
// 다중 이미지(콤마 구분)인 경우 대표 이미지를 우선 표시
const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
const [imgSrc, setImgSrc] = React.useState<string | null>(null);
const [displayObjid, setDisplayObjid] = React.useState<string>("");
const [error, setError] = React.useState(false);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
let mounted = true;
// 다중 이미지인 경우 대표 이미지(첫 번째)만 사용
const rawValue = String(value);
const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue;
const isObjid = /^\d+$/.test(strValue);
const parts = rawValue.split(",").map(s => s.trim()).filter(Boolean);
if (isObjid) {
// objid인 경우: 인증된 API로 blob 다운로드
const loadImage = async () => {
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/files/preview/${strValue}`, {
responseType: "blob",
});
if (mounted) {
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
setImgSrc(url);
setLoading(false);
}
} catch {
if (mounted) {
setError(true);
setLoading(false);
}
}
};
loadImage();
} else {
// 경로인 경우: 직접 URL 사용
setImgSrc(getFullImageUrl(strValue));
setLoading(false);
// 단일 값 또는 경로인 경우
if (parts.length <= 1) {
const strValue = parts[0] || rawValue;
setDisplayObjid(strValue);
const isObjid = /^\d+$/.test(strValue);
if (isObjid) {
loadImageBlob(strValue, mounted, setImgSrc, setError, setLoading);
} else {
setImgSrc(getFullImageUrl(strValue));
setLoading(false);
}
return () => { mounted = false; };
}
return () => {
mounted = false;
// blob URL 해제
if (imgSrc && imgSrc.startsWith("blob:")) {
window.URL.revokeObjectURL(imgSrc);
// 다중 objid: 대표 이미지를 찾아서 표시
const objids = parts.filter(s => /^\d+$/.test(s));
if (objids.length === 0) {
setLoading(false);
setError(true);
return () => { mounted = false; };
}
(async () => {
try {
const { getFileInfoByObjid } = await import("@/lib/api/file");
let representativeId: string | null = null;
// 각 objid의 대표 여부를 확인
for (const objid of objids) {
const info = await getFileInfoByObjid(objid);
if (info.success && info.data?.isRepresentative) {
representativeId = objid;
break;
}
}
// 대표 이미지가 없으면 첫 번째 사용
const targetObjid = representativeId || objids[0];
if (mounted) {
setDisplayObjid(targetObjid);
loadImageBlob(targetObjid, mounted, setImgSrc, setError, setLoading);
}
} catch {
if (mounted) {
// 대표 조회 실패 시 첫 번째 사용
setDisplayObjid(objids[0]);
loadImageBlob(objids[0], mounted, setImgSrc, setError, setLoading);
}
}
};
})();
return () => { mounted = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
@ -91,10 +109,8 @@ const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
style={{ maxWidth: "40px", maxHeight: "40px" }}
onClick={(e) => {
e.stopPropagation();
const rawValue = String(value);
const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue;
const isObjid = /^\d+$/.test(strValue);
const openUrl = isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue);
const isObjid = /^\d+$/.test(displayObjid);
const openUrl = isObjid ? getFilePreviewUrl(displayObjid) : getFullImageUrl(displayObjid);
window.open(openUrl, "_blank");
}}
onError={() => setError(true)}
@ -104,6 +120,32 @@ const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
});
TableCellImage.displayName = "TableCellImage";
// 이미지 blob 로딩 헬퍼
function loadImageBlob(
objid: string,
mounted: boolean,
setImgSrc: (url: string) => void,
setError: (err: boolean) => void,
setLoading: (loading: boolean) => void,
) {
import("@/lib/api/client").then(({ apiClient }) => {
apiClient.get(`/files/preview/${objid}`, { responseType: "blob" })
.then((response) => {
if (mounted) {
const blob = new Blob([response.data]);
setImgSrc(window.URL.createObjectURL(blob));
setLoading(false);
}
})
.catch(() => {
if (mounted) {
setError(true);
setLoading(false);
}
});
});
}
// 🆕 RelatedDataButtons 전역 레지스트리 타입 선언
declare global {
interface Window {
@ -2172,7 +2214,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const handleRowClick = (row: any, index: number, e: React.MouseEvent) => {
// 체크박스 클릭은 무시 (이미 handleRowSelection에서 처리됨)
const target = e.target as HTMLElement;
if (target.closest('input[type="checkbox"]')) {
if (target.closest('input[type="checkbox"]') || target.closest('button[role="checkbox"]')) {
return;
}
@ -2198,35 +2240,38 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
};
// 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택)
// 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택/해제 토글)
const handleCellClick = (rowIndex: number, colIndex: number, e: React.MouseEvent) => {
e.stopPropagation();
// 현재 편집 중인 셀을 클릭한 경우 포커스 이동 방지 (select 드롭다운 등이 닫히는 것 방지)
if (editingCell?.rowIndex === rowIndex && editingCell?.colIndex === colIndex) {
return;
}
setFocusedCell({ rowIndex, colIndex });
// 테이블 컨테이너에 포커스 설정 (키보드 이벤트 수신용)
tableContainerRef.current?.focus();
// 🆕 분할 패널 내에서 셀 클릭 시에도 해당 행 선택 처리
// filteredData에서 해당 행의 데이터 가져오기
const row = filteredData[rowIndex];
if (!row) return;
// 체크박스 컬럼은 Checkbox의 onCheckedChange에서 이미 처리되므로 스킵
const column = visibleColumns[colIndex];
if (column?.columnName === "__checkbox__") return;
const rowKey = getRowKey(row, rowIndex);
const isCurrentlySelected = selectedRows.has(rowKey);
// 분할 패널 컨텍스트가 있고, 좌측 화면인 경우에만 행 선택 및 데이터 전달
const effectiveSplitPosition = splitPanelPosition || currentSplitPosition;
if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
// 이미 선택된 행과 다른 행을 클릭한 경우에만 처리
// 분할 패널 좌측: 단일 행 선택 모드
if (!isCurrentlySelected) {
// 기존 선택 해제하고 새 행 선택
setSelectedRows(new Set([rowKey]));
setIsAllSelected(false);
// 분할 패널 컨텍스트에 데이터 저장
splitPanelContext.setSelectedLeftData(row);
// onSelectedRowsChange 콜백 호출
if (onSelectedRowsChange) {
onSelectedRowsChange([rowKey], [row], sortColumn || undefined, sortDirection);
}
@ -2234,6 +2279,17 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
onFormDataChange({ selectedRows: [rowKey], selectedRowsData: [row] });
}
}
} else {
// 일반 모드: 행 선택/해제 토글
handleRowSelection(rowKey, !isCurrentlySelected);
if (splitPanelContext && effectiveSplitPosition === "left") {
if (!isCurrentlySelected) {
splitPanelContext.setSelectedLeftData(row);
} else {
splitPanelContext.setSelectedLeftData(null);
}
}
}
};
@ -5412,23 +5468,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</div>
)}
{/* 선택 정보 */}
{selectedRows.size > 0 && (
<div className="border-border flex items-center gap-1 border-r pr-2">
<span className="bg-primary/10 text-primary rounded px-2 py-0.5 text-xs">
{selectedRows.size}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedRows(new Set())}
className="h-6 w-6 p-0"
title="선택 해제"
>
<X className="h-3 w-3" />
</Button>
</div>
)}
{/* 선택 정보 - 숨김 처리 */}
{/* 🆕 통합 검색 패널 */}
{(tableConfig.toolbar?.showSearch ?? false) && (
@ -5777,12 +5817,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
renderCheckboxHeader()
) : (
<div style={{ display: "flex", alignItems: "center", gap: "4px", justifyContent: "center" }}>
{/* 🆕 편집 불가 컬럼 표시 */}
{column.editable === false && (
<span title="편집 불가">
<Lock className="text-muted-foreground h-3 w-3" />
</span>
)}
<span>{columnLabels[column.columnName] || column.displayName}</span>
{column.sortable !== false && sortColumn === column.columnName && (
<span>{sortDirection === "asc" ? "↑" : "↓"}</span>
@ -6315,6 +6349,21 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
);
}
// 날짜 타입: 캘린더 피커
const isDateType = colMeta?.inputType === "date" || colMeta?.inputType === "datetime";
if (isDateType) {
const { InlineCellDatePicker } = require("@/components/screen/filters/InlineCellDatePicker");
return (
<InlineCellDatePicker
value={editingValue}
onChange={setEditingValue}
onSave={saveEditing}
onKeyDown={handleEditKeyDown}
inputRef={editInputRef}
/>
);
}
// 일반 입력 필드
return (
<input

View File

@ -22,11 +22,76 @@ import {
Database,
Table2,
Link2,
GripVertical,
X,
} from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { cn } from "@/lib/utils";
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
/**
* (v2-split-panel-layout의 SortableColumnRow )
*/
function SortableColumnRow({
id,
col,
index,
isEntityJoin,
onLabelChange,
onWidthChange,
onRemove,
}: {
id: string;
col: ColumnConfig;
index: number;
isEntityJoin?: boolean;
onLabelChange: (value: string) => void;
onWidthChange: (value: number) => void;
onRemove: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style = { transform: CSS.Transform.toString(transform), transition };
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"bg-card flex items-center gap-1.5 rounded-md border px-2 py-1.5",
isDragging && "z-50 opacity-50 shadow-md",
isEntityJoin && "border-blue-200 bg-blue-50/30",
)}
>
<div {...attributes} {...listeners} className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
<GripVertical className="h-3 w-3" />
</div>
{isEntityJoin ? (
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
) : (
<span className="text-muted-foreground w-5 shrink-0 text-center text-[10px] font-medium">#{index + 1}</span>
)}
<Input
value={col.displayName || col.columnName}
onChange={(e) => onLabelChange(e.target.value)}
placeholder="표시명"
className="h-6 min-w-0 flex-1 text-xs"
/>
<Input
value={col.width || ""}
onChange={(e) => onWidthChange(parseInt(e.target.value) || 100)}
placeholder="너비"
className="h-6 w-14 shrink-0 text-xs"
/>
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0">
<X className="h-3 w-3" />
</Button>
</div>
);
}
export interface TableListConfigPanelProps {
config: TableListConfig;
@ -366,11 +431,11 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
const existingColumn = config.columns?.find((col) => col.columnName === columnName);
if (existingColumn) return;
// tableColumns에서 해당 컬럼의 라벨 정보 찾기
// tableColumns → availableColumns 순서로 한국어 라벨 찾기
const columnInfo = tableColumns?.find((col: any) => (col.columnName || col.name) === columnName);
const availableColumnInfo = availableColumns.find((col) => col.columnName === columnName);
// 라벨명 우선 사용, 없으면 컬럼명 사용
const displayName = columnInfo?.label || columnInfo?.displayName || columnName;
const displayName = columnInfo?.label || columnInfo?.displayName || availableColumnInfo?.label || columnName;
const newColumn: ColumnConfig = {
columnName,
@ -1333,7 +1398,38 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
/>
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
<span className="truncate text-xs">{column.label || column.columnName}</span>
<span className="ml-auto text-[10px] text-gray-400">
{isAdded && (
<button
type="button"
title={
config.columns?.find((c) => c.columnName === column.columnName)?.editable === false
? "편집 잠금 (클릭하여 해제)"
: "편집 가능 (클릭하여 잠금)"
}
className={cn(
"ml-auto flex-shrink-0 rounded p-0.5 transition-colors",
config.columns?.find((c) => c.columnName === column.columnName)?.editable === false
? "text-destructive hover:bg-destructive/10"
: "text-muted-foreground hover:bg-muted",
)}
onClick={(e) => {
e.stopPropagation();
const currentCol = config.columns?.find((c) => c.columnName === column.columnName);
if (currentCol) {
updateColumn(column.columnName, {
editable: currentCol.editable === false ? undefined : false,
});
}
}}
>
{config.columns?.find((c) => c.columnName === column.columnName)?.editable === false ? (
<Lock className="h-3 w-3" />
) : (
<Unlock className="h-3 w-3" />
)}
</button>
)}
<span className={cn("text-[10px] text-gray-400", !isAdded && "ml-auto")}>
{column.input_type || column.dataType}
</span>
</div>
@ -1427,6 +1523,63 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</>
)}
{/* 선택된 컬럼 순서 변경 (DnD) */}
{config.columns && config.columns.length > 0 && (
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> ({config.columns.length} )</h3>
<p className="text-muted-foreground text-[10px]">
/
</p>
</div>
<hr className="border-border" />
<DndContext
collisionDetection={closestCenter}
onDragEnd={(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const columns = [...(config.columns || [])];
const oldIndex = columns.findIndex((c) => c.columnName === active.id);
const newIndex = columns.findIndex((c) => c.columnName === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
const reordered = arrayMove(columns, oldIndex, newIndex);
reordered.forEach((col, idx) => { col.order = idx; });
handleChange("columns", reordered);
}
}}
>
<SortableContext
items={(config.columns || []).map((c) => c.columnName)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{(config.columns || []).map((column, idx) => {
// displayName이 columnName과 같으면 한국어 라벨 미설정 → availableColumns에서 찾기
const resolvedLabel =
column.displayName && column.displayName !== column.columnName
? column.displayName
: availableColumns.find((c) => c.columnName === column.columnName)?.label || column.displayName || column.columnName;
const colWithLabel = { ...column, displayName: resolvedLabel };
return (
<SortableColumnRow
key={column.columnName}
id={column.columnName}
col={colWithLabel}
index={idx}
isEntityJoin={!!column.isEntityJoin}
onLabelChange={(value) => updateColumn(column.columnName, { displayName: value })}
onWidthChange={(value) => updateColumn(column.columnName, { width: value })}
onRemove={() => removeColumn(column.columnName)}
/>
);
})}
</div>
</SortableContext>
</DndContext>
</div>
)}
{/* 🆕 데이터 필터링 설정 */}
<div className="space-y-3">
<div>
@ -1453,3 +1606,4 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</div>
);
};

View File

@ -437,7 +437,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTable?.tableName, filterMode, screenId, currentTableTabId, JSON.stringify(presetFilters)]);
// select 옵션 초기 로드 (한 번만 실행, 이후 유지)
// select 옵션 로드 (데이터 변경 시 빈 옵션 재조회)
useEffect(() => {
if (!currentTable?.getColumnUniqueValues || activeFilters.length === 0) {
return;
@ -450,26 +450,37 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
return;
}
const newOptions: Record<string, Array<{ label: string; value: string }>> = { ...selectOptions };
const loadedOptions: Record<string, Array<{ label: string; value: string }>> = {};
let hasNewOptions = false;
for (const filter of selectFilters) {
// 이미 로드된 옵션이 있으면 스킵 (초기값 유지)
if (newOptions[filter.columnName] && newOptions[filter.columnName].length > 0) {
continue;
}
try {
const options = await currentTable.getColumnUniqueValues(filter.columnName);
newOptions[filter.columnName] = options;
if (options && options.length > 0) {
loadedOptions[filter.columnName] = options;
hasNewOptions = true;
}
} catch (error) {
console.error("❌ [TableSearchWidget] select 옵션 로드 실패:", filter.columnName, error);
}
}
setSelectOptions(newOptions);
if (hasNewOptions) {
setSelectOptions((prev) => {
// 이미 로드된 옵션은 유지, 새로 로드된 옵션만 병합
const merged = { ...prev };
for (const [key, value] of Object.entries(loadedOptions)) {
if (!merged[key] || merged[key].length === 0) {
merged[key] = value;
}
}
return merged;
});
}
};
loadSelectOptions();
}, [activeFilters, currentTable?.tableName, currentTable?.getColumnUniqueValues]); // dataCount 제거, tableName으로 변경
}, [activeFilters, currentTable?.tableName, currentTable?.getColumnUniqueValues, currentTable?.dataCount]);
// 높이 변화 감지 및 알림 (실제 화면에서만)
useEffect(() => {
@ -722,7 +733,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: `${width}px` }} align="start">
<PopoverContent className="w-auto p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<div className="max-h-60 overflow-auto">
{uniqueOptions.length === 0 ? (
<div className="text-muted-foreground px-3 py-2 text-xs"> </div>
@ -739,7 +750,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
onCheckedChange={(checked) => handleMultiSelectChange(option.value, checked as boolean)}
onClick={(e) => e.stopPropagation()}
/>
<span className="text-xs sm:text-sm">{option.label}</span>
<span className="truncate text-xs sm:text-sm">{option.label}</span>
</div>
))}
</div>

View File

@ -558,31 +558,7 @@ export class ButtonActionExecutor {
return false;
}
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
// EditModal이 내부에서 직접 repeaterSave 이벤트를 발행하고 완료를 기다림
if (onSave) {
try {
await onSave();
return true;
} catch (error) {
console.error("❌ [handleSave] onSave 콜백 실행 오류:", error);
throw error;
}
}
console.log("⚠️ [handleSave] onSave 콜백 없음 - 기본 저장 로직 실행");
// 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집)
// context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함
// skipDefaultSave 플래그를 통해 기본 저장 로직을 건너뛸 수 있음
// 🔧 디버그: beforeFormSave 이벤트 전 formData 확인
console.log("🔍 [handleSave] beforeFormSave 이벤트 전:", {
keys: Object.keys(context.formData || {}),
hasCompanyImage: "company_image" in (context.formData || {}),
companyImageValue: context.formData?.company_image,
});
// beforeFormSave 이벤트 발송 (BomItemEditor 등 서브 컴포넌트의 저장 처리)
const beforeSaveEventDetail = {
formData: context.formData,
skipDefaultSave: false,
@ -596,22 +572,28 @@ export class ButtonActionExecutor {
}),
);
// 비동기 핸들러가 등록한 Promise들 대기 + 동기 핸들러를 위한 최소 대기
if (beforeSaveEventDetail.pendingPromises.length > 0) {
console.log(
`[handleSave] 비동기 beforeFormSave 핸들러 ${beforeSaveEventDetail.pendingPromises.length}건 대기 중...`,
);
await Promise.all(beforeSaveEventDetail.pendingPromises);
} else {
await new Promise((resolve) => setTimeout(resolve, 100));
}
// 검증 실패 시 저장 중단
if (beforeSaveEventDetail.validationFailed) {
console.log("❌ [handleSave] 검증 실패로 저장 중단:", beforeSaveEventDetail.validationErrors);
return false;
}
// EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
if (onSave) {
try {
await onSave();
return true;
} catch (error) {
console.error("❌ [handleSave] onSave 콜백 실행 오류:", error);
throw error;
}
}
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
// 단, _tableSection_ 데이터가 있으면 건너뛰기 (handleUniversalFormModalTableSectionSave가 처리)
// EditModal이 내부에서 직접 repeaterSave 이벤트를 발행하고 완료를 기다림
@ -1893,29 +1875,34 @@ export class ButtonActionExecutor {
mainFormDataKeys: Object.keys(mainFormData),
});
// V2Repeater 저장 완료를 기다리기 위한 Promise
const repeaterSavePromise = new Promise<void>((resolve) => {
const fallbackTimeout = setTimeout(resolve, 5000);
const handler = () => {
clearTimeout(fallbackTimeout);
window.removeEventListener("repeaterSaveComplete", handler);
resolve();
};
window.addEventListener("repeaterSaveComplete", handler);
});
// V2Repeater가 등록된 경우에만 저장 완료를 기다림
// @ts-ignore
const hasActiveRepeaters = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: savedId,
tableName: context.tableName,
mainFormData,
masterRecordId: savedId,
},
}),
);
if (hasActiveRepeaters) {
const repeaterSavePromise = new Promise<void>((resolve) => {
const fallbackTimeout = setTimeout(resolve, 5000);
const handler = () => {
clearTimeout(fallbackTimeout);
window.removeEventListener("repeaterSaveComplete", handler);
resolve();
};
window.addEventListener("repeaterSaveComplete", handler);
});
await repeaterSavePromise;
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: savedId,
tableName: context.tableName,
mainFormData,
masterRecordId: savedId,
},
}),
);
await repeaterSavePromise;
}
// 테이블과 플로우 새로고침 (모달 닫기 전에 실행)
context.onRefresh?.();
@ -1951,29 +1938,33 @@ export class ButtonActionExecutor {
formDataKeys: Object.keys(formData),
});
const repeaterSavePromise = new Promise<void>((resolve) => {
const fallbackTimeout = setTimeout(resolve, 5000);
const handler = () => {
clearTimeout(fallbackTimeout);
window.removeEventListener("repeaterSaveComplete", handler);
resolve();
};
window.addEventListener("repeaterSaveComplete", handler);
});
// @ts-ignore
const hasActiveRepeaters = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: savedId,
tableName: context.tableName,
mainFormData: formData,
masterRecordId: savedId,
},
}),
);
if (hasActiveRepeaters) {
const repeaterSavePromise = new Promise<void>((resolve) => {
const fallbackTimeout = setTimeout(resolve, 5000);
const handler = () => {
clearTimeout(fallbackTimeout);
window.removeEventListener("repeaterSaveComplete", handler);
resolve();
};
window.addEventListener("repeaterSaveComplete", handler);
});
await repeaterSavePromise;
console.log("✅ [dispatchRepeaterSave] repeaterSave 완료");
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: savedId,
tableName: context.tableName,
mainFormData: formData,
masterRecordId: savedId,
},
}),
);
await repeaterSavePromise;
}
}
/**
@ -3182,16 +3173,16 @@ export class ButtonActionExecutor {
return false;
}
// 1. 화면 설명 가져오기
let description = config.modalDescription || "";
if (!description) {
// 1. 화면 정보 가져오기 (제목/설명이 미설정 시 화면명에서 가져옴)
let screenInfo: any = null;
if (!config.modalTitle || !config.modalDescription) {
try {
const screenInfo = await screenApi.getScreen(config.targetScreenId);
description = screenInfo?.description || "";
screenInfo = await screenApi.getScreen(config.targetScreenId);
} catch (error) {
console.warn("화면 설명을 가져오지 못했습니다:", error);
console.warn("화면 정보를 가져오지 못했습니다:", error);
}
}
let description = config.modalDescription || screenInfo?.description || "";
// 2. 데이터 소스 및 선택된 데이터 수집
let selectedData: any[] = [];
@ -3297,7 +3288,7 @@ export class ButtonActionExecutor {
}
// 3. 동적 모달 제목 생성
let finalTitle = config.modalTitle || "화면";
let finalTitle = config.modalTitle || screenInfo?.screenName || "데이터 등록";
// 블록 기반 제목 처리
if (config.modalTitleBlocks?.length) {

View File

@ -262,7 +262,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -304,7 +303,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -338,7 +336,6 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@ -2669,7 +2666,6 @@
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.17.8",
"@types/react-reconciler": "^0.32.0",
@ -3323,7 +3319,6 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz",
"integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.90.6"
},
@ -3391,7 +3386,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@ -3705,7 +3699,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
@ -6206,7 +6199,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -6217,7 +6209,6 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -6260,7 +6251,6 @@
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
@ -6343,7 +6333,6 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@ -6976,7 +6965,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -8127,8 +8115,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/d3": {
"version": "7.9.0",
@ -8450,7 +8437,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@ -9210,7 +9196,6 @@
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -9299,7 +9284,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@ -9401,7 +9385,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -10573,7 +10556,6 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@ -11354,8 +11336,7 @@
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause",
"peer": true
"license": "BSD-2-Clause"
},
"node_modules/levn": {
"version": "0.4.1",
@ -12684,7 +12665,6 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@ -12978,7 +12958,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"peer": true,
"dependencies": {
"orderedmap": "^2.0.0"
}
@ -13008,7 +12987,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
@ -13057,7 +13035,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
@ -13184,7 +13161,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -13254,7 +13230,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@ -13305,7 +13280,6 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@ -13338,8 +13312,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/react-leaflet": {
"version": "5.0.0",
@ -13647,7 +13620,6 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@ -13670,8 +13642,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/recharts/node_modules/redux-thunk": {
"version": "3.1.0",
@ -14701,8 +14672,7 @@
"version": "0.180.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/three-mesh-bvh": {
"version": "0.8.3",
@ -14790,7 +14760,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -15139,7 +15108,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

@ -139,6 +139,23 @@ export interface SelectOption {
label: string;
}
/**
* V2Select
* WHERE
*/
export interface V2SelectFilter {
column: string;
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "in" | "notIn" | "like" | "isNull" | "isNotNull";
/** 값 유형: static=고정값, field=다른 폼 필드 참조, user=로그인 사용자 정보 */
valueType?: "static" | "field" | "user";
/** static일 때 고정값 */
value?: unknown;
/** field일 때 참조할 폼 필드명 (columnName) */
fieldRef?: string;
/** user일 때 참조할 사용자 필드 */
userField?: "companyCode" | "userId" | "deptCode" | "userName";
}
export interface V2SelectConfig {
mode: V2SelectMode;
source: V2SelectSource | "distinct" | "select"; // distinct/select 추가 (테이블 컬럼에서 자동 로드)
@ -151,7 +168,8 @@ export interface V2SelectConfig {
table?: string;
valueColumn?: string;
labelColumn?: string;
filters?: Array<{ column: string; operator: string; value: unknown }>;
// 옵션 필터 조건 (모든 source에서 사용 가능)
filters?: V2SelectFilter[];
// 엔티티 연결 (source: entity)
entityTable?: string;
entityValueField?: string;

View File

@ -153,10 +153,12 @@ export interface CommonStyle {
// 라벨 스타일
labelDisplay?: boolean; // 라벨 표시 여부
labelText?: string; // 라벨 텍스트
labelPosition?: "top" | "left" | "right" | "bottom"; // 라벨 위치 (기본: top)
labelFontSize?: string;
labelColor?: string;
labelFontWeight?: string;
labelMarginBottom?: string;
labelGap?: string; // 라벨-위젯 간격 (좌/우 배치 시 사용)
// 레이아웃
display?: string;

View File

@ -50,11 +50,13 @@ export interface RepeaterColumnConfig {
width: ColumnWidthOption;
visible: boolean;
editable?: boolean; // 편집 가능 여부 (inline 모드)
hidden?: boolean; // 🆕 히든 처리 (화면에 안 보이지만 저장됨)
hidden?: boolean; // 히든 처리 (화면에 안 보이지만 저장됨)
isJoinColumn?: boolean;
sourceTable?: string;
// 🆕 소스 테이블 표시 컬럼 여부 (modal 모드에서 읽기 전용으로 표시)
// 소스 테이블 표시 컬럼 여부 (modal 모드에서 읽기 전용으로 표시)
isSourceDisplay?: boolean;
// 소스 데이터의 다른 컬럼명에서 값을 매핑 (예: qty ← order_qty)
sourceKey?: string;
// 입력 타입 (테이블 타입 관리의 inputType을 따름)
inputType?: string; // text, number, date, code, entity 등
// 🆕 자동 입력 설정
@ -140,6 +142,20 @@ export interface CalculationRule {
label?: string;
}
// 소스 디테일 설정 (모달에서 전달받은 마스터 데이터의 디테일을 자동 조회)
export interface SourceDetailConfig {
tableName: string; // 디테일 테이블명 (예: "sales_order_detail")
foreignKey: string; // 디테일 테이블의 FK 컬럼 (예: "order_no")
parentKey: string; // 전달받은 마스터 데이터에서 추출할 키 (예: "order_no")
useEntityJoin?: boolean; // 엔티티 조인 사용 여부 (data-with-joins API)
columnMapping?: Record<string, string>; // 리피터 컬럼 ← 조인 alias 매핑 (예: { "part_name": "part_code_item_name" })
additionalJoinColumns?: Array<{
sourceColumn: string;
sourceTable: string;
joinAlias: string;
}>;
}
// 메인 설정 타입
export interface V2RepeaterConfig {
// 렌더링 모드
@ -151,6 +167,9 @@ export interface V2RepeaterConfig {
foreignKeyColumn?: string; // 마스터 테이블과 연결할 FK 컬럼명 (예: receiving_id)
foreignKeySourceColumn?: string; // 마스터 테이블의 PK 컬럼명 (예: id) - 자동 연결용
// 소스 디테일 자동 조회 설정 (선택된 마스터의 디테일 행을 리피터로 로드)
sourceDetailConfig?: SourceDetailConfig;
// 데이터 소스 설정
dataSource: RepeaterDataSource;
@ -189,6 +208,7 @@ export interface V2RepeaterProps {
onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void;
className?: string;
formData?: Record<string, any>; // 수정 모드에서 FK 기반 데이터 로드용
groupedData?: Record<string, any>[]; // 모달에서 전달받은 선택 데이터 (소스 디테일 조회용)
}
// 기본 설정값

View File

@ -0,0 +1,170 @@
/**
* 프로덕션에서 "관리자 메뉴로 전환" 버튼 가시성 테스트
* 계정 (topseal_admin, rsw1206)으로 로그인하여 버튼 표시 여부 확인
*
* 실행: node scripts/browser-test-admin-switch-button.js
* 브라우저 표시: HEADLESS=0 node scripts/browser-test-admin-switch-button.js
*/
const { chromium } = require("playwright");
const fs = require("fs");
const BASE_URL = "https://v1.vexplor.com";
const SCREENSHOT_DIR = "test-screenshots/admin-switch-test";
const ACCOUNTS = [
{ userId: "topseal_admin", password: "qlalfqjsgh11", name: "topseal_admin" },
{ userId: "rsw1206", password: "qlalfqjsgh11", name: "rsw1206" },
];
async function runTest() {
const results = { topseal_admin: {}, rsw1206: {} };
const browser = await chromium.launch({
headless: process.env.HEADLESS !== "0",
});
const context = await browser.newContext({
viewport: { width: 1280, height: 900 },
ignoreHTTPSErrors: true,
});
const page = await context.newPage();
try {
if (!fs.existsSync(SCREENSHOT_DIR)) {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
}
const screenshot = async (name) => {
const path = `${SCREENSHOT_DIR}/${name}.png`;
await page.screenshot({ path, fullPage: true });
console.log(` [스크린샷] ${path}`);
return path;
};
for (let i = 0; i < ACCOUNTS.length; i++) {
const acc = ACCOUNTS[i];
console.log(`\n========== ${acc.name} 테스트 (${i + 1}/${ACCOUNTS.length}) ==========\n`);
// 로그인 페이지로 이동
await page.goto(`${BASE_URL}/login`, {
waitUntil: "networkidle",
timeout: 20000,
});
await page.waitForTimeout(1000);
// 로그인
await page.fill("#userId", acc.userId);
await page.fill("#password", acc.password);
await page.click('button[type="submit"]');
await page.waitForTimeout(3000);
// 로그인 성공 시 대시보드 또는 메인으로 리다이렉트될 것임
const currentUrl = page.url();
if (currentUrl.includes("/login") && !currentUrl.includes("error")) {
// 아직 로그인 페이지에 있다면 조금 더 대기
await page.waitForTimeout(3000);
}
const afterLoginUrl = page.url();
const screenshotPath = await screenshot(`01_${acc.name}_after_login`);
// "관리자 메뉴로 전환" 버튼 찾기
const buttonSelectors = [
'button:has-text("관리자 메뉴로 전환")',
'[class*="button"]:has-text("관리자 메뉴로 전환")',
'button >> text=관리자 메뉴로 전환',
];
let buttonVisible = false;
for (const sel of buttonSelectors) {
try {
const btn = page.locator(sel).first();
const count = await btn.count();
if (count > 0) {
const isVisible = await btn.isVisible();
if (isVisible) {
buttonVisible = true;
break;
}
}
} catch (_) {}
}
// 추가: 페이지 내 텍스트로 버튼 존재 여부 확인
if (!buttonVisible) {
const pageText = await page.textContent("body");
buttonVisible = pageText && pageText.includes("관리자 메뉴로 전환");
}
results[acc.name] = {
buttonVisible,
screenshotPath,
afterLoginUrl,
};
console.log(` 버튼 가시성: ${buttonVisible ? "표시됨" : "표시 안 됨"}`);
console.log(` URL: ${afterLoginUrl}`);
// 로그아웃 (다음 계정 테스트 전)
if (i < ACCOUNTS.length - 1) {
console.log("\n 로그아웃 중...");
try {
// 프로필 드롭다운 클릭 (좌측 하단)
const profileBtn = page.locator(
'button:has-text("로그아웃"), [class*="dropdown"]:has-text("로그아웃"), [data-radix-collection-item]:has-text("로그아웃")'
);
const profileTrigger = page.locator(
'button[class*="flex w-full"][class*="gap-3"]'
).first();
if (await profileTrigger.count() > 0) {
await profileTrigger.click();
await page.waitForTimeout(500);
const logoutItem = page.locator('text=로그아웃').first();
if (await logoutItem.count() > 0) {
await logoutItem.click();
await page.waitForTimeout(2000);
}
}
// 또는 직접 로그아웃 URL
if (page.url().includes("/login") === false) {
await page.goto(`${BASE_URL}/api/auth/logout`, {
waitUntil: "networkidle",
timeout: 5000,
}).catch(() => {});
await page.goto(`${BASE_URL}/login`, {
waitUntil: "networkidle",
timeout: 10000,
});
}
} catch (e) {
console.log(" 로그아웃 대체: 로그인 페이지로 직접 이동");
await page.goto(`${BASE_URL}/login`, {
waitUntil: "networkidle",
timeout: 10000,
});
}
await page.waitForTimeout(1500);
}
}
console.log("\n========== 최종 결과 ==========\n");
console.log("topseal_admin: 관리자 메뉴로 전환 버튼 =", results.topseal_admin.buttonVisible ? "표시됨" : "표시 안 됨");
console.log("rsw1206: 관리자 메뉴로 전환 버튼 =", results.rsw1206.buttonVisible ? "표시됨" : "표시 안 됨");
console.log("\n스크린샷:", SCREENSHOT_DIR);
return results;
} catch (err) {
console.error("테스트 오류:", err);
throw err;
} finally {
await browser.close();
}
}
runTest()
.then((r) => {
console.log("\n테스트 완료.");
process.exit(0);
})
.catch((e) => {
console.error(e);
process.exit(1);
});

View File

@ -0,0 +1,167 @@
/**
* 거래처관리 화면 CRUD 브라우저 테스트
* 실행: node scripts/browser-test-customer-crud.js
* 브라우저 표시: HEADLESS=0 node scripts/browser-test-customer-crud.js
*/
const { chromium } = require("playwright");
const BASE_URL = "http://localhost:9771";
const SCREENSHOT_DIR = "test-screenshots";
async function runTest() {
const results = { success: [], failed: [], screenshots: [] };
const browser = await chromium.launch({ headless: process.env.HEADLESS !== "0" });
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
try {
// 스크린샷 디렉토리
const fs = require("fs");
if (!fs.existsSync(SCREENSHOT_DIR)) fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
const screenshot = async (name) => {
const path = `${SCREENSHOT_DIR}/${name}.png`;
await page.screenshot({ path, fullPage: true });
results.screenshots.push(path);
console.log(` [스크린샷] ${path}`);
};
console.log("\n=== 1단계: 로그인 ===\n");
await page.goto(`${BASE_URL}/login`, { waitUntil: "networkidle", timeout: 15000 });
await page.fill('#userId', 'topseal_admin');
await page.fill('#password', 'qlalfqjsgh11');
await page.click('button[type="submit"]');
await page.waitForTimeout(3000);
await screenshot("01_after_login");
results.success.push("로그인 완료");
console.log("\n=== 2단계: 거래처관리 화면 이동 ===\n");
await page.goto(`${BASE_URL}/screens/227`, { waitUntil: "domcontentloaded", timeout: 20000 });
// 테이블 또는 메인 콘텐츠 로딩 대기 (API 호출 후 React 렌더링)
try {
await page.waitForSelector('table, tbody, [role="row"], .rt-tbody', { timeout: 25000 });
results.success.push("테이블 로드 감지");
} catch (e) {
console.log(" [경고] 테이블 대기 타임아웃, 계속 진행");
}
await page.waitForTimeout(3000);
await screenshot("02_screen_227");
results.success.push("화면 227 로드");
console.log("\n=== 3단계: 거래처 선택 (READ 테스트) ===\n");
// 좌측 테이블 행 선택 - 다양한 레이아웃 대응
const rowSelectors = [
'table tbody tr.cursor-pointer',
'tbody tr.hover\\:bg-accent',
'table tbody tr:has(td)',
'tbody tr',
];
let rows = [];
for (const sel of rowSelectors) {
rows = await page.$$(sel);
if (rows.length > 0) break;
}
if (rows.length > 0) {
await rows[0].click();
results.success.push("거래처 행 클릭");
} else {
results.failed.push("거래처 테이블 행을 찾을 수 없음");
// 디버그: 페이지 구조 저장
const bodyHtml = await page.evaluate(() => {
const tables = document.querySelectorAll('table, tbody, [role="grid"], [role="table"]');
return `Tables found: ${tables.length}\n` + document.body.innerHTML.slice(0, 8000);
});
require("fs").writeFileSync(`${SCREENSHOT_DIR}/debug_body.html`, bodyHtml);
console.log(" [디버그] body HTML 일부 저장: debug_body.html");
}
await page.waitForTimeout(3000);
await screenshot("03_after_customer_select");
// SelectedItemsDetailInput 영역 확인
const detailArea = await page.$('[data-component="selected-items-detail-input"], [class*="selected-items"], .selected-items-detail');
if (detailArea) {
results.success.push("SelectedItemsDetailInput 컴포넌트 렌더링 확인");
} else {
// 품목/입력 관련 영역이 있는지
const hasInputArea = await page.$('input[placeholder*="품번"], input[placeholder*="품목"], [class*="detail"]');
results.success.push(hasInputArea ? "입력 영역 확인됨" : "SelectedItemsDetailInput 영역 미확인");
}
console.log("\n=== 4단계: 품목 추가 (CREATE 테스트) ===\n");
const addBtnLoc = page.locator('button').filter({ hasText: /추가|품목/ }).first();
const addBtnExists = await addBtnLoc.count() > 0;
if (addBtnExists) {
await addBtnLoc.click();
await page.waitForTimeout(1500);
await screenshot("04_after_add_click");
// 모달/팝업에서 품목 선택
const modalItem = await page.$('[role="dialog"] tr, [role="listbox"] [role="option"], .modal tbody tr');
if (modalItem) {
await modalItem.click();
await page.waitForTimeout(1000);
}
// 필수 필드 입력
const itemCodeInput = await page.$('input[name*="품번"], input[placeholder*="품번"], input[id*="item"]');
if (itemCodeInput) {
await itemCodeInput.fill("TEST_BROWSER");
}
await screenshot("04_before_save");
const saveBtnLoc = page.locator('button').filter({ hasText: /저장/ }).first();
if (await saveBtnLoc.count() > 0) {
await saveBtnLoc.click();
await page.waitForTimeout(3000);
await screenshot("05_after_save");
results.success.push("저장 버튼 클릭");
const toast = await page.$('[data-sonner-toast], .toast, [role="alert"]');
if (toast) {
const toastText = await toast.textContent();
results.success.push(`토스트 메시지: ${toastText?.slice(0, 50)}`);
}
} else {
results.failed.push("저장 버튼을 찾을 수 없음");
}
} else {
results.failed.push("품목 추가/추가 버튼을 찾을 수 없음");
await screenshot("04_no_add_button");
}
console.log("\n=== 5단계: 최종 결과 ===\n");
await screenshot("06_final_state");
// 콘솔 에러 수집
const consoleErrors = [];
page.on("console", (msg) => {
const type = msg.type();
if (type === "error") {
consoleErrors.push(msg.text());
}
});
} catch (err) {
results.failed.push(`예외: ${err.message}`);
try {
await page.screenshot({ path: `${SCREENSHOT_DIR}/error.png`, fullPage: true });
results.screenshots.push(`${SCREENSHOT_DIR}/error.png`);
} catch (_) {}
} finally {
await browser.close();
}
// 결과 출력
console.log("\n========== 테스트 결과 ==========\n");
console.log("성공:", results.success);
console.log("실패:", results.failed);
console.log("스크린샷:", results.screenshots);
return results;
}
runTest().then((r) => {
process.exit(r.failed.length > 0 ? 1 : 0);
}).catch((e) => {
console.error(e);
process.exit(1);
});

View File

@ -0,0 +1,157 @@
/**
* 거래처관리 메뉴 경유 브라우저 테스트
* 영업관리 > 거래처관리 메뉴 클릭 상세 화면 진입
* 실행: node scripts/browser-test-customer-via-menu.js
* 브라우저 표시: HEADLESS=0 node scripts/browser-test-customer-via-menu.js
*/
const { chromium } = require("playwright");
const BASE_URL = "http://localhost:9771";
const SCREENSHOT_DIR = "test-screenshots";
const CREDENTIALS = { userId: "topseal_admin", password: "qlalfqjsgh11" };
async function runTest() {
const results = { success: [], failed: [], screenshots: [] };
const browser = await chromium.launch({ headless: process.env.HEADLESS !== "0" });
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
const fs = require("fs");
if (!fs.existsSync(SCREENSHOT_DIR)) fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
const screenshot = async (name) => {
const path = `${SCREENSHOT_DIR}/${name}.png`;
await page.screenshot({ path, fullPage: true });
results.screenshots.push(path);
console.log(` [스크린샷] ${path}`);
};
try {
// 로그인 (이미 로그인된 상태면 자동 리다이렉트됨)
console.log("\n=== 로그인 확인 ===\n");
await page.goto(`${BASE_URL}/login`, { waitUntil: "networkidle", timeout: 15000 });
const currentUrl = page.url();
if (currentUrl.includes("/login") && !(await page.$('input#userId'))) {
// 로그인 폼이 있으면 로그인
await page.fill("#userId", CREDENTIALS.userId);
await page.fill("#password", CREDENTIALS.password);
await page.click('button[type="submit"]');
await page.waitForTimeout(3000);
} else if (currentUrl.includes("/login")) {
await page.fill("#userId", CREDENTIALS.userId);
await page.fill("#password", CREDENTIALS.password);
await page.click('button[type="submit"]');
await page.waitForTimeout(3000);
}
results.success.push("로그인/세션 확인");
// 단계 1: 영업관리 메뉴 클릭
console.log("\n=== 단계 1: 영업관리 메뉴 클릭 ===\n");
const salesMenu = page.locator('nav, aside').getByText('영업관리', { exact: true }).first();
if (await salesMenu.count() > 0) {
await salesMenu.click();
await page.waitForTimeout(2000);
results.success.push("영업관리 메뉴 클릭");
} else {
const salesAlt = page.getByRole('button', { name: /영업관리/ }).or(page.getByText('영업관리').first());
if (await salesAlt.count() > 0) {
await salesAlt.first().click();
await page.waitForTimeout(2000);
results.success.push("영업관리 메뉴 클릭 (대안)");
} else {
results.failed.push("영업관리 메뉴를 찾을 수 없음");
}
}
await screenshot("01_after_sales_menu");
// 단계 2: 거래처관리 서브메뉴 클릭
console.log("\n=== 단계 2: 거래처관리 서브메뉴 클릭 ===\n");
const customerMenu = page.getByText("거래처관리", { exact: true }).first();
if (await customerMenu.count() > 0) {
await customerMenu.click();
await page.waitForTimeout(5000);
results.success.push("거래처관리 메뉴 클릭");
} else {
results.failed.push("거래처관리 메뉴를 찾을 수 없음");
}
await screenshot("02_after_customer_menu");
// 단계 3: 거래처 목록 확인 및 행 클릭
console.log("\n=== 단계 3: 거래처 목록 확인 ===\n");
const rows = await page.$$('tbody tr, table tr, [role="row"]');
const clickableRows = rows.length > 0 ? rows : [];
if (clickableRows.length > 0) {
await clickableRows[0].click();
await page.waitForTimeout(5000);
results.success.push(`거래처 행 클릭 (${clickableRows.length}개 행 중 첫 번째)`);
} else {
results.failed.push("거래처 테이블 행을 찾을 수 없음");
}
await screenshot("03_after_row_click");
// 단계 4: 편집/수정 버튼 또는 더블클릭 (분할 패널이면 행 선택만으로 우측에 상세 표시될 수 있음)
console.log("\n=== 단계 4: 상세 화면 진입 시도 ===\n");
const editBtn = page.locator('button').filter({ hasText: /편집|수정|상세/ }).first();
let editEnabled = false;
try {
if (await editBtn.count() > 0) {
editEnabled = !(await editBtn.isDisabled());
}
} catch (_) {}
try {
if (editEnabled) {
await editBtn.click();
results.success.push("편집/수정 버튼 클릭");
} else {
const row = await page.$('tbody tr, table tr');
if (row) {
await row.dblclick();
results.success.push("행 더블클릭 시도");
} else if (await editBtn.count() > 0) {
results.success.push("수정 버튼 비활성화 - 분할 패널 우측 상세 확인");
} else {
results.failed.push("편집 버튼/행을 찾을 수 없음");
}
}
} catch (e) {
results.success.push("상세 진입 스킵 - 우측 패널에 상세 표시 여부 확인");
}
await page.waitForTimeout(5000);
await screenshot("04_after_detail_enter");
// 단계 5: 품목 관련 영역 확인
console.log("\n=== 단계 5: 품목 관련 영역 확인 ===\n");
const hasItemSection = await page.getByText(/품목|납품품목|거래처 품번|거래처 품명/).first().count() > 0;
const hasDetailInput = await page.$('input[placeholder*="품번"], input[name*="품번"], [class*="selected-items"]');
if (hasItemSection || hasDetailInput) {
results.success.push("품목 관련 UI 확인됨");
} else {
results.failed.push("품목 관련 영역 미확인");
}
await screenshot("05_item_section");
console.log("\n========== 테스트 결과 ==========\n");
console.log("성공:", results.success);
console.log("실패:", results.failed);
console.log("스크린샷:", results.screenshots);
} catch (err) {
results.failed.push(`예외: ${err.message}`);
try {
await page.screenshot({ path: `${SCREENSHOT_DIR}/error.png`, fullPage: true });
results.screenshots.push(`${SCREENSHOT_DIR}/error.png`);
} catch (_) {}
console.error(err);
} finally {
await browser.close();
}
return results;
}
runTest()
.then((r) => process.exit(r.failed.length > 0 ? 1 : 0))
.catch((e) => {
console.error(e);
process.exit(1);
});

View File

@ -0,0 +1,196 @@
/**
* 구매관리 - 공급업체관리 / 구매품목정보 CRUD 브라우저 테스트
* 실행: node scripts/browser-test-purchase-supplier.js
* 브라우저 표시: HEADLESS=0 node scripts/browser-test-purchase-supplier.js
*/
const { chromium } = require("playwright");
const BASE_URL = "http://localhost:9771";
const SCREENSHOT_DIR = "test-screenshots";
const CREDENTIALS = { userId: "topseal_admin", password: "qlalfqjsgh11" };
async function runTest() {
const results = { success: [], failed: [], screenshots: [] };
const browser = await chromium.launch({ headless: process.env.HEADLESS !== "0" });
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
const fs = require("fs");
if (!fs.existsSync(SCREENSHOT_DIR)) fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
const screenshot = async (name) => {
const path = `${SCREENSHOT_DIR}/${name}.png`;
await page.screenshot({ path, fullPage: true });
results.screenshots.push(path);
console.log(` [스크린샷] ${path}`);
return path;
};
const clickMenu = async (text) => {
const loc = page.getByText(text, { exact: true }).first();
if ((await loc.count()) > 0) {
await loc.click();
return true;
}
const alt = page.getByRole("link", { name: text }).or(page.locator(`a:has-text("${text}")`)).first();
if ((await alt.count()) > 0) {
await alt.click();
return true;
}
return false;
};
const clickRow = async () => {
const rows = await page.$$('tbody tr, table tr, [role="row"]');
for (const r of rows) {
const t = await r.textContent();
if (t && !t.includes("데이터가 없습니다") && !t.includes("로딩")) {
await r.click();
return true;
}
}
if (rows.length > 0) {
await rows[0].click();
return true;
}
return false;
};
const clickButton = async (regex) => {
const btn = page.locator("button").filter({ hasText: regex }).first();
try {
if ((await btn.count()) > 0 && !(await btn.isDisabled())) {
await btn.click();
return true;
}
} catch (_) {}
return false;
};
try {
console.log("\n=== 로그인 확인 ===\n");
await page.goto(`${BASE_URL}/login`, { waitUntil: "networkidle", timeout: 15000 });
if (page.url().includes("/login")) {
await page.fill("#userId", CREDENTIALS.userId);
await page.fill("#password", CREDENTIALS.password);
await page.click('button[type="submit"]');
await page.waitForTimeout(3000);
}
results.success.push("세션 확인");
// ========== 테스트 1: 공급업체관리 ==========
console.log("\n=== 테스트 1: 공급업체관리 ===\n");
console.log("단계 1: 구매관리 메뉴 열기");
if (await clickMenu("구매관리")) {
await page.waitForTimeout(3000);
results.success.push("구매관리 메뉴 클릭");
} else {
results.failed.push("구매관리 메뉴 미발견");
}
await screenshot("p1_01_purchase_menu");
console.log("단계 2: 공급업체관리 서브메뉴 클릭");
if (await clickMenu("공급업체관리")) {
await page.waitForTimeout(8000);
results.success.push("공급업체관리 메뉴 클릭");
} else {
results.failed.push("공급업체관리 메뉴 미발견");
}
await screenshot("p1_02_supplier_screen");
console.log("단계 3: 공급업체 선택");
if (await clickRow()) {
await page.waitForTimeout(5000);
results.success.push("공급업체 행 클릭");
} else {
results.failed.push("공급업체 테이블 행 미발견");
}
await screenshot("p1_03_after_supplier_select");
console.log("단계 4: 납품품목 탭/영역 확인");
const itemTab = page.getByText(/납품품목|품목/).first();
if ((await itemTab.count()) > 0) {
await itemTab.click();
await page.waitForTimeout(3000);
results.success.push("납품품목/품목 탭 클릭");
} else {
results.failed.push("납품품목 탭 미발견");
}
await screenshot("p1_04_item_tab");
console.log("단계 5: 품목 추가 시도");
const addBtn = page.locator("button").filter({ hasText: /추가|\+ 추가/ }).first();
let addBtnEnabled = false;
try {
addBtnEnabled = (await addBtn.count()) > 0 && !(await addBtn.isDisabled());
} catch (_) {}
if (addBtnEnabled) {
await addBtn.click();
await page.waitForTimeout(2000);
const modal = await page.$('[role="dialog"], .modal, [class*="modal"]');
if (modal) {
const modalRow = await page.$('[role="dialog"] tbody tr, .modal tbody tr');
if (modalRow) {
await modalRow.click();
await page.waitForTimeout(1500);
}
}
await page.waitForTimeout(1500);
results.success.push("추가 버튼 클릭 및 품목 선택 시도");
} else {
results.failed.push("추가 버튼 미발견 또는 비활성화");
}
await screenshot("p1_05_add_item");
// ========== 테스트 2: 구매품목정보 ==========
console.log("\n=== 테스트 2: 구매품목정보 ===\n");
console.log("단계 6: 구매품목정보 메뉴 클릭");
if (await clickMenu("구매품목정보")) {
await page.waitForTimeout(8000);
results.success.push("구매품목정보 메뉴 클릭");
} else {
results.failed.push("구매품목정보 메뉴 미발견");
}
await screenshot("p2_01_item_screen");
console.log("단계 7: 품목 선택 및 공급업체 확인");
if (await clickRow()) {
await page.waitForTimeout(5000);
results.success.push("구매품목 행 클릭");
} else {
results.failed.push("구매품목 테이블 행 미발견");
}
await screenshot("p2_02_after_item_select");
// SelectedItemsDetailInput 컴포넌트 확인
const hasDetailInput = await page.$('input[placeholder*="품번"], [class*="selected-items"], input[name*="품번"]');
results.success.push(hasDetailInput ? "SelectedItemsDetailInput 렌더링 확인" : "SelectedItemsDetailInput 미확인");
await screenshot("p2_03_final");
console.log("\n========== 테스트 결과 ==========\n");
console.log("성공:", results.success);
console.log("실패:", results.failed);
console.log("스크린샷:", results.screenshots);
} catch (err) {
results.failed.push(`예외: ${err.message}`);
try {
await page.screenshot({ path: `${SCREENSHOT_DIR}/error.png`, fullPage: true });
results.screenshots.push(`${SCREENSHOT_DIR}/error.png`);
} catch (_) {}
console.error(err);
} finally {
await browser.close();
}
return results;
}
runTest()
.then((r) => process.exit(r.failed.length > 0 ? 1 : 0))
.catch((e) => {
console.error(e);
process.exit(1);
});

66
scripts/dev/start-npm.sh Executable file
View File

@ -0,0 +1,66 @@
#!/bin/bash
echo "============================================"
echo "WACE 솔루션 - npm 직접 실행 (Docker 없이)"
echo "============================================"
echo ""
PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
LOG_DIR="$PROJECT_ROOT/scripts/dev/logs"
mkdir -p "$LOG_DIR"
BACKEND_LOG="$LOG_DIR/backend.log"
FRONTEND_LOG="$LOG_DIR/frontend.log"
# 기존 프로세스 정리
echo "[1/4] 기존 프로세스 정리 중..."
lsof -ti:8080 | xargs kill -9 2>/dev/null
lsof -ti:9771 | xargs kill -9 2>/dev/null
echo " 완료"
echo ""
# 백엔드 npm install + 실행
echo "[2/4] 백엔드 의존성 설치 중..."
cd "$PROJECT_ROOT/backend-node"
npm install --silent
echo " 완료"
echo ""
echo "[3/4] 백엔드 서버 시작 중 (포트 8080)..."
npm run dev > "$BACKEND_LOG" 2>&1 &
BACKEND_PID=$!
echo " PID: $BACKEND_PID"
echo ""
# 프론트엔드 npm install + 실행
echo "[4/4] 프론트엔드 의존성 설치 + 서버 시작 중 (포트 9771)..."
cd "$PROJECT_ROOT/frontend"
npm install --silent
npm run dev > "$FRONTEND_LOG" 2>&1 &
FRONTEND_PID=$!
echo " PID: $FRONTEND_PID"
echo ""
sleep 3
echo "============================================"
echo "모든 서비스가 시작되었습니다!"
echo "============================================"
echo ""
echo " [BACKEND] http://localhost:8080/api"
echo " [FRONTEND] http://localhost:9771"
echo ""
echo " 백엔드 PID: $BACKEND_PID"
echo " 프론트엔드 PID: $FRONTEND_PID"
echo ""
echo " 프론트엔드 로그: tail -f $FRONTEND_LOG"
echo ""
echo "Ctrl+C 로 종료하면 백엔드/프론트엔드 모두 종료됩니다."
echo "============================================"
echo ""
echo "--- 백엔드 로그 출력 시작 ---"
echo ""
trap "echo ''; echo '서비스를 종료합니다...'; kill $BACKEND_PID $FRONTEND_PID 2>/dev/null; exit 0" SIGINT SIGTERM
tail -f "$BACKEND_LOG"