diff --git a/backend-node/src/controllers/bomController.ts b/backend-node/src/controllers/bomController.ts index 8355b148..b98baad1 100644 --- a/backend-node/src/controllers/bomController.ts +++ b/backend-node/src/controllers/bomController.ts @@ -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; diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts index 3ece2ce7..62fc8bbe 100644 --- a/backend-node/src/controllers/entitySearchController.ts +++ b/backend-node/src/controllers/entitySearchController.ts @@ -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, + 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, }); diff --git a/backend-node/src/database/db.ts b/backend-node/src/database/db.ts index 4c249ac3..6fc10cf1 100644 --- a/backend-node/src/database/db.ts +++ b/backend-node/src/database/db.ts @@ -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; diff --git a/backend-node/src/routes/bomRoutes.ts b/backend-node/src/routes/bomRoutes.ts index f6e3ee62..ccdbad64 100644 --- a/backend-node/src/routes/bomRoutes.ts +++ b/backend-node/src/routes/bomRoutes.ts @@ -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); diff --git a/backend-node/src/services/bomService.ts b/backend-node/src/services/bomService.ts index 89da38a9..4178dc92 100644 --- a/backend-node/src/services/bomService.ts +++ b/backend-node/src/services/bomService.ts @@ -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 { + 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(); + 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(); + + 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 { + 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(); + 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(); + + 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[]> { + // BOM 헤더 정보 조회 (최상위 품목) + const bomHeader = await queryOne>( + `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[] = []; + + // 레벨 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(); + 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 행도 함께 삭제 */ diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index e1242afd..1b183074 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -210,19 +210,62 @@ export class DynamicFormService { } } + /** + * VIEW인 경우 원본(base) 테이블명을 반환, 일반 테이블이면 그대로 반환 + */ + async resolveBaseTable(tableName: string): Promise { + 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, ipAddress?: string ): Promise { + // 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, newData: Record ): Promise { + // 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 ): Promise { + // 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 { 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); diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 6f412de5..74506a39 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -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 diff --git a/bom-restore-verify.mjs b/bom-restore-verify.mjs new file mode 100644 index 00000000..15407bde --- /dev/null +++ b/bom-restore-verify.mjs @@ -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(); diff --git a/bom-save-console-logs.txt b/bom-save-console-logs.txt new file mode 100644 index 00000000..f962f536 --- /dev/null +++ b/bom-save-console-logs.txt @@ -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 \ No newline at end of file diff --git a/docs/plan-bom-excel-upload.md b/docs/plan-bom-excel-upload.md new file mode 100644 index 00000000..d4c91afd --- /dev/null +++ b/docs/plan-bom-excel-upload.md @@ -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]로 표시 +- 현재 진행 중인 테스트는 [진행중]으로 표시 diff --git a/frontend/app/(main)/screen/[screenCode]/page.tsx b/frontend/app/(main)/screen/[screenCode]/page.tsx new file mode 100644 index 00000000..64c1bb34 --- /dev/null +++ b/frontend/app/(main)/screen/[screenCode]/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 160883ad..d1e07abe 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -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); diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index 81b5ed61..f3c2ff2d 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -903,7 +903,8 @@ export const ExcelUploadModal: React.FC = ({ } } - 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 = ({ 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 = ({ } // 채번 적용: 엑셀에 값이 없거나 빈 값이면 채번 규칙으로 자동 생성, 값이 있으면 그대로 사용 - 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 = ({ 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 = ({ } } + 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 = ({ 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("업로드에 실패했습니다."); } diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index a79f26e3..854b1159 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -1288,7 +1288,7 @@ export const ScreenModal: React.FC = ({ className }) => { {/* 모달 닫기 확인 다이얼로그 */} - + 화면을 닫으시겠습니까? diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 49aed98b..ec36096d 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -275,7 +275,26 @@ export const EditModal: React.FC = ({ 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 = ({ 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 = ({ className }) => { toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); } - // V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행) - try { - const repeaterSavePromise = new Promise((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((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 = ({ 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 = ({ 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 = ({ className }) => { toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); } - // V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행) - try { - const repeaterSavePromise = new Promise((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((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 = ({ className }) => { -
+
{loading ? (
@@ -1447,7 +1474,7 @@ export const EditModal: React.FC = ({ className }) => { >
{ if (hideLabel) return null; - const labelStyle = widget.style || {}; + const ls = widget.style || {}; const labelElement = (
+ ); + } + return ( -
- {renderLabel()} - {renderByWebType()} - {renderFieldValidation()} +
+ {labelPos === "top" && labelElement} + {widgetElement} + {labelPos === "bottom" && labelElement} + {validationElement}
); }; diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 252f5c2b..7a9a3ff3 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -2191,10 +2191,11 @@ export const InteractiveScreenViewer: React.FC = ( // 라벨 표시 여부 계산 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 = ( }); } - // 라벨 스타일 적용 - 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 = ( ...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 = ( {/* 테이블 옵션 툴바 */} - {/* 메인 컨텐츠 */} -
- {/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */} - {shouldShowLabel && ( -
diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 05d228f4..1bb04e97 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -1103,17 +1103,27 @@ export const InteractiveScreenViewerDynamic: React.FC { const compType = (component as any).componentType || ""; @@ -1190,9 +1200,17 @@ export const InteractiveScreenViewerDynamic: React.FC { + 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 + {labelText} + {((component as any).required || (component as any).componentConfig?.required) && ( + * + )} + + ) : 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 ( <>
- {renderInteractiveWidget(splitAdjustedComponent)} + {needsExternalLabel ? ( + isHorizLabel ? ( +
+ +
+ {renderInteractiveWidget(componentToRender)} +
+
+ ) : ( +
+ {externalLabelComponent} +
+ {renderInteractiveWidget(componentToRender)} +
+
+ ) + ) : ( + renderInteractiveWidget(componentToRender) + )}
{/* 팝업 화면 렌더링 */} diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index b95506d9..dcca4d0d 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -548,10 +548,23 @@ const RealtimePreviewDynamicComponent: React.FC = ({ 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, diff --git a/frontend/components/screen/filters/FormDatePicker.tsx b/frontend/components/screen/filters/FormDatePicker.tsx new file mode 100644 index 00000000..5ab5a1ff --- /dev/null +++ b/frontend/components/screen/filters/FormDatePicker.tsx @@ -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 = ({ + 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 ( + { if (!open) { setIsOpen(false); setIsTyping(false); } }}> + +
{ if (!disabled && !readOnly) setIsOpen(true); }} + > + + 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 && ( + { + e.stopPropagation(); + handleClear(); + }} + /> + )} +
+
+ e.preventDefault()}> +
+
+ + +
+ + {viewMode === "year" ? ( + <> +
+ +
{yearRangeStart} - {yearRangeStart + 11}
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+ +
+ {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( +
+ {day} +
+ ))} +
+ +
+ {allDays.map((date, index) => { + if (!date) return
; + + const isCurrentMonth = isSameMonth(date, currentMonth); + const isSelected = selectedDate ? isSameDay(date, selectedDate) : false; + const isTodayDate = isToday(date); + + return ( + + ); + })} +
+ + )} + + {includeTime && viewMode === "calendar" && ( +
+ 시간: + handleTimeChange(e.target.value)} + className="border-input h-8 rounded-md border px-2 text-xs" + /> +
+ )} +
+ + + ); +}; diff --git a/frontend/components/screen/filters/InlineCellDatePicker.tsx b/frontend/components/screen/filters/InlineCellDatePicker.tsx new file mode 100644 index 00000000..f47546b4 --- /dev/null +++ b/frontend/components/screen/filters/InlineCellDatePicker.tsx @@ -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) => void; + inputRef?: React.RefObject; +} + +export const InlineCellDatePicker: React.FC = ({ + 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(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 ( + + + 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" + /> + + e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}> +
+
+ + +
+ + {viewMode === "year" ? ( + <> +
+ +
+ {yearRangeStart} - {yearRangeStart + 11} +
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+ +
+ {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( +
+ {day} +
+ ))} +
+ +
+ {allDays.map((date, index) => { + if (!date) return
; + + const isCurrentMonth = isSameMonth(date, currentMonth); + const isSelected = selectedDate ? isSameDay(date, selectedDate) : false; + const isTodayDate = isToday(date); + + return ( + + ); + })} +
+ + )} +
+ + + ); +}; diff --git a/frontend/components/screen/filters/ModernDatePicker.tsx b/frontend/components/screen/filters/ModernDatePicker.tsx index 54fdcfed..79f16a41 100644 --- a/frontend/components/screen/filters/ModernDatePicker.tsx +++ b/frontend/components/screen/filters/ModernDatePicker.tsx @@ -34,6 +34,8 @@ export const ModernDatePicker: React.FC = ({ 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(value || {}); @@ -43,6 +45,7 @@ export const ModernDatePicker: React.FC = ({ label, value if (isOpen) { setTempValue(value || {}); setSelectingType("from"); + setViewMode("calendar"); } }, [isOpen, value]); @@ -234,60 +237,150 @@ export const ModernDatePicker: React.FC = ({ label, value
- {/* 월 네비게이션 */} -
- -
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
- -
- - {/* 요일 헤더 */} -
- {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( -
- {day} -
- ))} -
- - {/* 날짜 그리드 */} -
- {allDays.map((date, index) => { - if (!date) { - return
; - } - - const isCurrentMonth = isSameMonth(date, currentMonth); - const isSelected = isRangeStart(date) || isRangeEnd(date); - const isInRangeDate = isInRange(date); - const isTodayDate = isToday(date); - - return ( - - ); - })} -
+
+ {yearRangeStart} - {yearRangeStart + 11} +
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> + {/* 월 선택 뷰 */} +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> + {/* 월 네비게이션 */} +
+ + + +
+ + {/* 요일 헤더 */} +
+ {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( +
+ {day} +
+ ))} +
+ + {/* 날짜 그리드 */} +
+ {allDays.map((date, index) => { + if (!date) { + return
; + } + + const isCurrentMonth = isSameMonth(date, currentMonth); + const isSelected = isRangeStart(date) || isRangeEnd(date); + const isInRangeDate = isInRange(date); + const isTodayDate = isToday(date); + + return ( + + ); + })} +
+ + )} {/* 선택된 범위 표시 */} {(tempValue.from || tempValue.to) && ( diff --git a/frontend/components/screen/panels/V2PropertiesPanel.tsx b/frontend/components/screen/panels/V2PropertiesPanel.tsx index 6242cd89..90e44c8e 100644 --- a/frontend/components/screen/panels/V2PropertiesPanel.tsx +++ b/frontend/components/screen/panels/V2PropertiesPanel.tsx @@ -841,6 +841,44 @@ export const V2PropertiesPanel: React.FC = ({ className="h-6 w-full px-2 py-0 text-xs" />
+
+
+ + +
+
+ + { + 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" + /> +
+
@@ -862,12 +900,21 @@ export const V2PropertiesPanel: React.FC = ({
- - handleUpdate("style.labelMarginBottom", e.target.value)} - className="h-6 w-full px-2 py-0 text-xs" - /> + +
= ({ 전체 해제
-
+
{selectedColumns.map((colName, index) => { const col = table?.columns.find( (c) => c.columnName === colName diff --git a/frontend/components/screen/table-options/TableSettingsModal.tsx b/frontend/components/screen/table-options/TableSettingsModal.tsx index ef07e017..4f9325cb 100644 --- a/frontend/components/screen/table-options/TableSettingsModal.tsx +++ b/frontend/components/screen/table-options/TableSettingsModal.tsx @@ -557,7 +557,7 @@ export const TableSettingsModal: React.FC = ({ isOpen, onClose, onFilters 전체 해제
-
+
{selectedGroupColumns.map((colName, index) => { const col = table?.columns.find((c) => c.columnName === colName); if (!col) return null; diff --git a/frontend/components/screen/widgets/types/DateWidget.tsx b/frontend/components/screen/widgets/types/DateWidget.tsx index edb78df9..3b0f47e2 100644 --- a/frontend/components/screen/widgets/types/DateWidget.tsx +++ b/frontend/components/screen/widgets/types/DateWidget.tsx @@ -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 = ({ 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) => { - 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 ( - + { if (!v) { setIsOpen(false); setIsTyping(false); } }}> + +
{ if (!readonly) setIsOpen(true); }} + > + + 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 && ( + { + e.stopPropagation(); + handleClear(); + }} + /> + )} +
+
+ e.preventDefault()}> +
+
+ + +
+ + {viewMode === "year" ? ( + <> +
+ +
+ {yearRangeStart} - {yearRangeStart + 11} +
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+ +
+ {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( +
+ {day} +
+ ))} +
+ +
+ {allDays.map((date, index) => { + if (!date) return
; + + const isCurrentMonth = isSameMonth(date, currentMonth); + const isSelected = selectedDate ? isSameDay(date, selectedDate) : false; + const isTodayDate = isToday(date); + + return ( + + ); + })} +
+ + )} + + {/* datetime 타입: 시간 입력 */} + {isDatetime && viewMode === "calendar" && ( +
+ 시간: + handleTimeChange(e.target.value)} + className="border-input h-8 rounded-md border px-2 text-xs" + /> +
+ )} + +
+ + ); }; DateWidget.displayName = "DateWidget"; - - diff --git a/frontend/components/table-category/CategoryColumnList.tsx b/frontend/components/table-category/CategoryColumnList.tsx index d6ed8c62..872e7d57 100644 --- a/frontend/components/table-category/CategoryColumnList.tsx +++ b/frontend/components/table-category/CategoryColumnList.tsx @@ -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([]); const [isLoading, setIsLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const [expandedGroups, setExpandedGroups] = useState>(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(); + + 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, )}
-
+
{filteredColumns.length === 0 && searchQuery ? (
'{searchQuery}'에 대한 검색 결과가 없습니다
) : null} - {filteredColumns.map((column) => { - const uniqueKey = `${column.tableName}.${column.columnName}`; - const isSelected = selectedColumn === uniqueKey; // 테이블명.컬럼명으로 비교 - return ( -
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" - }`} - > -
- -
-

{column.columnLabel || column.columnName}

-

{column.tableLabel || column.tableName}

-
- - {column.valueCount !== undefined ? `${column.valueCount}개` : "..."} - + {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 ( +
+ {group.columns.map((column) => { + const uniqueKey = `${column.tableName}.${column.columnName}`; + const isSelected = selectedColumn === uniqueKey; + return ( +
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" + }`} + > +
+ +
+

{column.columnLabel || column.columnName}

+

{column.tableLabel || column.tableName}

+
+ + {column.valueCount !== undefined ? `${column.valueCount}개` : "..."} + +
+
+ ); + })}
+ ); + } + + return ( +
+ {/* 드롭다운 헤더 */} + + + {/* 펼쳐진 컬럼 목록 */} + {isExpanded && ( +
+ {group.columns.map((column) => { + const uniqueKey = `${column.tableName}.${column.columnName}`; + const isSelected = selectedColumn === uniqueKey; + return ( +
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" + }`} + > +
+ + {column.columnLabel || column.columnName} + + {column.valueCount !== undefined ? `${column.valueCount}개` : "..."} + +
+
+ ); + })} +
+ )}
); })} diff --git a/frontend/components/ui/alert-dialog.tsx b/frontend/components/ui/alert-dialog.tsx index 3c7a9239..2da0647f 100644 --- a/frontend/components/ui/alert-dialog.tsx +++ b/frontend/components/ui/alert-dialog.tsx @@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( 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(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 ( - + { if (!v) { setOpen(false); setIsTyping(false); } }}> - + + 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", + )} + /> +
- - { - if (minDateObj && date < minDateObj) return true; - if (maxDateObj && date > maxDateObj) return true; - return false; - }} - /> -
- {showToday && ( - + )} + +
+ + {viewMode === "year" ? ( + <> +
+ +
{yearRangeStart} - {yearRangeStart + 11}
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+
+ {["월", "화", "수", "목", "금", "토", "일"].map((d) => ( +
{d}
+ ))} +
+
+ {allDays.map((d, idx) => { + if (!d) return
; + const isCur = isSameMonth(d, currentMonth); + const isSel = date ? isSameDay(d, date) : false; + const isT = isTodayFn(d); + return ( + + ); + })} +
+ )} -
@@ -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 ( + { if (!v) { setIsTyping(false); } onOpenChange(v); }}> + +
{ if (!disabled && !readonly) onOpenChange(true); }} + > + + 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" + /> +
+
+ e.preventDefault()}> +
+ {viewMode === "year" ? ( + <> +
+ +
{yearRangeStart} - {yearRangeStart + 11}
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+
+ {["월", "화", "수", "목", "금", "토", "일"].map((d) => ( +
{d}
+ ))} +
+
+ {allDays.map((d, idx) => { + if (!d) return
; + const isCur = isSameMonth(d, currentMonth); + const isSel = selectedDate ? isSameDay(d, selectedDate) : false; + const isT = isTodayFn(d); + return ( + + ); + })} +
+ + )} +
+ + + ); +}; + 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 (
- {/* 시작 날짜 */} - - - - - - { - if (minDateObj && date < minDateObj) return true; - if (maxDateObj && date > maxDateObj) return true; - return false; - }} - /> - - - + ~ - - {/* 종료 날짜 */} - - - - - - { - if (minDateObj && date < minDateObj) return true; - if (maxDateObj && date > maxDateObj) return true; - // 시작일보다 이전 날짜는 선택 불가 - if (startDate && date < startDate) return true; - return false; - }} - /> - - +
); }); @@ -462,14 +700,60 @@ export const V2Date = forwardRef((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 ? ( + + ) : null; + + const dateContent = ( +
+ {renderDatePicker()} +
+ ); + + if (isHorizLabel && showLabel) { + return ( +
+ {labelElement} + {dateContent} +
+ ); + } return (
((props, ref) => { height: componentHeight, }} > - {/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */} - {showLabel && ( - - )} -
- {renderDatePicker()} -
+ {labelElement} + {dateContent}
); }); diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index 09a43ca9..219fa275 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -961,36 +961,83 @@ export const V2Input = forwardRef((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 ? ( + + ) : null; + + const inputContent = ( +
+ {renderInput()} +
+ ); + + if (isHorizLabel && showLabel) { + return ( +
+ {labelElement} + {inputContent} +
+ ); + } + return (
((props, ref) => height: componentHeight, }} > - {/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 (높이에 포함되지 않음) */} - {showLabel && ( - - )} -
- {renderInput()} -
+ {labelElement} + {inputContent}
); }); diff --git a/frontend/components/v2/V2Repeater.tsx b/frontend/components/v2/V2Repeater.tsx index 1853ebe7..f6f1fc6b 100644 --- a/frontend/components/v2/V2Repeater.tsx +++ b/frontend/components/v2/V2Repeater.tsx @@ -48,11 +48,9 @@ export const V2Repeater: React.FC = ({ 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 = ({ 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 = ({ 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 = { 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(); + 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(); - 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(); + 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 = ({ [], ); - // 모달에서 전달된 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 = { + 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(); - 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; - 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(); + 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(() => { diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index fe21b790..538d33be 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -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 {options - .filter((option) => option.value !== "") + .filter((option) => option.value != null && option.value !== "") .map((option) => ( {option.label} @@ -112,6 +112,12 @@ const DropdownSelect = forwardRef + 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 { 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 { 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 검색 결과가 없습니다. - {options.map((option) => { + {safeOptions.map((option) => { const displayLabel = option.label || option.value || "(빈 값)"; return ( ( 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( // 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 = { + 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( } }, [parentValue, hierarchical, source]); + // 필터 조건이 변경되면 옵션 다시 로드 + useEffect(() => { + if (resolvedFiltersJson !== undefined) { + setOptionsLoaded(false); + } + }, [resolvedFiltersJson]); + useEffect(() => { // 이미 로드된 경우 스킵 (static 제외, 계층 구조 제외) if (optionsLoaded && source !== "static") { @@ -731,11 +793,13 @@ export const V2Select = forwardRef( } } else if (source === "db" && table) { // DB 테이블에서 로드 + const dbParams: Record = { + 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( // 엔티티(참조 테이블)에서 로드 const valueCol = entityValueColumn || "id"; const labelCol = entityLabelColumn || "name"; + const entityParams: Record = { 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( } } 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 = {}; + 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( } } - 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( }; 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( return ( ( return ( handleChangeWithAutoFill(v)} disabled={isDisabled} /> @@ -972,7 +1080,7 @@ export const V2Select = forwardRef( return ( ( return ( ( return ( ( return ( handleChangeWithAutoFill(v)} disabled={isDisabled} /> @@ -1017,7 +1125,7 @@ export const V2Select = forwardRef( return ( ( return ( ( } }; - 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( if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"]; const hasCustomText = Object.keys(customTextStyle).length > 0; + const labelElement = showLabel ? ( + + ) : null; + + const selectContent = ( +
+ {renderSelect()} +
+ ); + + if (isHorizLabel && showLabel) { + return ( +
+ {labelElement} + {selectContent} +
+ ); + } + return (
( height: componentHeight, }} > - {/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */} - {showLabel && ( - - )} -
- {renderSelect()} -
+ {labelElement} + {selectContent}
); } diff --git a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx index 1f89ae12..66f0f18b 100644 --- a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx @@ -31,6 +31,7 @@ import { Wand2, Check, ChevronsUpDown, + ListTree, } from "lucide-react"; import { Command, @@ -983,6 +984,133 @@ export const V2RepeaterConfigPanel: React.FC = ({ + {/* 소스 디테일 자동 조회 설정 */} +
+
+ { + if (checked) { + updateConfig({ + sourceDetailConfig: { + tableName: "", + foreignKey: "", + parentKey: "", + }, + }); + } else { + updateConfig({ sourceDetailConfig: undefined }); + } + }} + /> + +
+

+ 모달에서 전달받은 마스터 데이터의 디테일 행을 자동으로 조회하여 리피터에 채웁니다. +

+ + {config.sourceDetailConfig && ( +
+
+ + + + + + + + + + 테이블을 찾을 수 없습니다. + + {allTables.map((table) => ( + { + updateConfig({ + sourceDetailConfig: { + ...config.sourceDetailConfig!, + tableName: table.tableName, + }, + }); + }} + className="text-xs" + > + + {table.displayName} + + ))} + + + + + +
+ +
+
+ + + updateConfig({ + sourceDetailConfig: { + ...config.sourceDetailConfig!, + foreignKey: e.target.value, + }, + }) + } + placeholder="예: order_no" + className="h-7 text-xs" + /> +
+
+ + + updateConfig({ + sourceDetailConfig: { + ...config.sourceDetailConfig!, + parentKey: e.target.value, + }, + }) + } + placeholder="예: order_no" + className="h-7 text-xs" + /> +
+
+ +

+ 마스터에서 [{config.sourceDetailConfig.parentKey || "?"}] 추출 → + {" "}{config.sourceDetailConfig.tableName || "?"}.{config.sourceDetailConfig.foreignKey || "?"} 로 조회 +

+
+ )} +
+ + + {/* 기능 옵션 */}
diff --git a/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx b/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx index d631f454..66ebb369 100644 --- a/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx @@ -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) => { + 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 ( +
+
+
+ + +
+ +
+ +

+ {targetTable} 테이블에서 옵션을 불러올 때 적용할 조건 +

+ + {loadingColumns && ( +
+ + 컬럼 목록 로딩 중... +
+ )} + + {filters.length === 0 && ( +

+ 필터 조건이 없습니다 +

+ )} + +
+ {filters.map((filter, index) => ( +
+ {/* 행 1: 컬럼 + 연산자 + 삭제 */} +
+ {/* 컬럼 선택 */} + + + {/* 연산자 선택 */} + + + {/* 삭제 버튼 */} + +
+ + {/* 행 2: 값 유형 + 값 입력 (isNull/isNotNull 제외) */} + {needsValue(filter.operator) && ( +
+ {/* 값 유형 */} + + + {/* 값 입력 영역 */} + {(filter.valueType || "static") === "static" && ( + 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" && ( + updateFilter(index, { fieldRef: e.target.value })} + placeholder="참조할 필드명 (columnName)" + className="h-7 flex-1 text-[11px]" + /> + )} + + {filter.valueType === "user" && ( + + )} +
+ )} +
+ ))} +
+
+ ); +}; + interface V2SelectConfigPanelProps { config: Record; onChange: (config: Record) => void; @@ -53,10 +286,52 @@ export const V2SelectConfigPanel: React.FC = ({ const [categoryValues, setCategoryValues] = useState([]); const [loadingCategoryValues, setLoadingCategoryValues] = useState(false); + // 필터용 컬럼 목록 (옵션 데이터 소스 테이블의 컬럼) + const [filterColumns, setFilterColumns] = useState([]); + 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 = ({ />
)} + + {/* 데이터 필터 조건 - static 소스 외 모든 소스에서 사용 */} + {effectiveSource !== "static" && filterTargetTable && ( + <> + + updateConfig("filters", filters)} + /> + + )}
); }; diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 72af2a34..50c4bee4 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -356,9 +356,10 @@ export const DynamicComponentRenderer: React.FC = // 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 = 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 = 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 = 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 ( +
+ +
+ {renderedCatSelect} +
+
+ ); + } + return renderedCatSelect; } catch (error) { console.error("❌ V2SelectRenderer 로드 실패:", error); } @@ -545,10 +602,12 @@ export const DynamicComponentRenderer: React.FC = 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 = 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 = 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 = ...(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 = 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 ; + renderedElement = ; } + + // 수평 라벨 → 라벨을 컴포넌트 영역 바깥에 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 ( +
+ +
+ {renderedElement} +
+
+ ); + } + + return renderedElement; } } catch (error) { console.error(`❌ 새 컴포넌트 렌더링 실패 (${componentType}):`, error); diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 173a67ad..f753a240 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -1328,9 +1328,9 @@ export const ButtonPrimaryComponent: React.FC = ({ )}
- {/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */} + {/* 확인 다이얼로그 */} - + {getConfirmTitle()} {getConfirmMessage()} diff --git a/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx b/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx index 13a7ac4f..2f35c799 100644 --- a/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx +++ b/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx @@ -24,29 +24,33 @@ export const ImageDisplayComponent: React.FC = ({ 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 = ({ }} > {component.label} - {component.required && *} + {(component.required || componentConfig.required) && ( + * + )} )} @@ -96,43 +102,53 @@ export const ImageDisplayComponent: React.FC = ({ 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 ? ( {componentConfig.altText { (e.target as HTMLImageElement).style.display = "none"; if (e.target?.parentElement) { e.target.parentElement.innerHTML = `
-
🖼️
+
이미지 로드 실패
`; @@ -150,8 +166,22 @@ export const ImageDisplayComponent: React.FC = ({ fontSize: "14px", }} > -
🖼️
-
이미지 없음
+ + + + + +
{placeholder}
)}
@@ -161,7 +191,6 @@ export const ImageDisplayComponent: React.FC = ({ /** * ImageDisplay 래퍼 컴포넌트 - * 추가적인 로직이나 상태 관리가 필요한 경우 사용 */ export const ImageDisplayWrapper: React.FC = (props) => { return ; diff --git a/frontend/lib/registry/components/image-display/ImageDisplayConfigPanel.tsx b/frontend/lib/registry/components/image-display/ImageDisplayConfigPanel.tsx index 6c73e1d9..7f36f51b 100644 --- a/frontend/lib/registry/components/image-display/ImageDisplayConfigPanel.tsx +++ b/frontend/lib/registry/components/image-display/ImageDisplayConfigPanel.tsx @@ -9,63 +9,166 @@ import { ImageDisplayConfig } from "./types"; export interface ImageDisplayConfigPanelProps { config: ImageDisplayConfig; - onChange: (config: Partial) => void; + onChange?: (config: Partial) => void; + onConfigChange?: (config: Partial) => void; } /** * ImageDisplay 설정 패널 - * 컴포넌트의 설정값들을 편집할 수 있는 UI 제공 */ export const ImageDisplayConfigPanel: React.FC = ({ config, onChange, + onConfigChange, }) => { const handleChange = (key: keyof ImageDisplayConfig, value: any) => { - onChange({ [key]: value }); + const update = { ...config, [key]: value }; + onChange?.(update); + onConfigChange?.(update); }; return (
-
- image-display 설정 +
이미지 표시 설정
+ + {/* 이미지 URL */} +
+ + handleChange("imageUrl", e.target.value)} + placeholder="https://..." + className="h-8 text-xs" + /> +

+ 데이터 바인딩 값이 없을 때 표시할 기본 이미지 +

- {/* file 관련 설정 */} + {/* 대체 텍스트 */}
- + + handleChange("altText", e.target.value)} + placeholder="이미지 설명" + className="h-8 text-xs" + /> +
+ + {/* 이미지 맞춤 */} +
+ + +
+ + {/* 테두리 둥글기 */} +
+ + handleChange("borderRadius", parseInt(e.target.value) || 0)} + className="h-8 text-xs" + /> +
+ + {/* 배경 색상 */} +
+ +
+ handleChange("backgroundColor", e.target.value)} + className="h-8 w-8 cursor-pointer rounded border" + /> + handleChange("backgroundColor", e.target.value)} + className="h-8 flex-1 text-xs" + /> +
+
+ + {/* 플레이스홀더 */} +
+ handleChange("placeholder", e.target.value)} + placeholder="이미지 없음" + className="h-8 text-xs" />
- {/* 공통 설정 */} -
- + {/* 테두리 표시 */} +
handleChange("disabled", checked)} + id="showBorder" + checked={config.showBorder ?? true} + onCheckedChange={(checked) => handleChange("showBorder", checked)} /> +
-
- - handleChange("required", checked)} - /> -
- -
- + {/* 읽기 전용 */} +
handleChange("readonly", checked)} /> + +
+ + {/* 필수 입력 */} +
+ handleChange("required", checked)} + /> +
); diff --git a/frontend/lib/registry/components/image-display/config.ts b/frontend/lib/registry/components/image-display/config.ts index 268382f0..bae67e14 100644 --- a/frontend/lib/registry/components/image-display/config.ts +++ b/frontend/lib/registry/components/image-display/config.ts @@ -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", }, }; diff --git a/frontend/lib/registry/components/image-display/index.ts b/frontend/lib/registry/components/image-display/index.ts index ddb38f95..ffa5712a 100644 --- a/frontend/lib/registry/components/image-display/index.ts +++ b/frontend/lib/registry/components/image-display/index.ts @@ -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, diff --git a/frontend/lib/registry/components/image-display/types.ts b/frontend/lib/registry/components/image-display/types.ts index f2b6971d..e882ebe4 100644 --- a/frontend/lib/registry/components/image-display/types.ts +++ b/frontend/lib/registry/components/image-display/types.ts @@ -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; diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index 5ad6d0eb..532881b7 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -162,6 +162,79 @@ export function RepeaterTable({ // 초기 균등 분배 실행 여부 (마운트 시 한 번만 실행) const initializedRef = useRef(false); + // 편집 가능한 컬럼 인덱스 목록 (방향키 네비게이션용) + const editableColIndices = useMemo( + () => visibleColumns.reduce((acc, col, idx) => { + if (col.editable && !col.calculated) acc.push(idx); + return acc; + }, []), + [visibleColumns], + ); + + // 방향키로 리피터 셀 간 이동 + const handleArrowNavigation = useCallback( + (e: React.KeyboardEvent) => { + 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( + '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 ( -
+
{renderCell(row, col, rowIndex)} diff --git a/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx b/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx index 63e1cbb9..57fe91d7 100644 --- a/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx +++ b/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx @@ -40,6 +40,7 @@ const RepeaterFieldGroupComponent: React.FC = (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 = (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; diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index 949cd74b..ff94b8dc 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -93,9 +93,12 @@ export const SelectedItemsDetailInputComponent: React.FC { + // 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 = {}; + + // 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 !!item.originalData?.id); + const isEditMode = urlEditMode || dataHasDbId; + // 부모 키 추출 (parentDataMapping에서) const parentKeys: Record = {}; @@ -572,16 +587,25 @@ export const SelectedItemsDetailInputComponent: React.FC { - // 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 !!item.originalData?.id); - const isEditMode = urlEditMode || dataHasDbId; - console.log("[SelectedItemsDetailInput] 수정 모드 감지:", { urlEditMode, dataHasDbId, @@ -677,27 +692,14 @@ export const SelectedItemsDetailInputComponent: React.FC { - 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; } diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx index 61f755a4..1f70e7e0 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx @@ -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>([]); - const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState>([]); + const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState>([]); + + // FK 자동 감지 결과 + const [autoDetectedFks, setAutoDetectedFks] = useState([]); // 🆕 원본 테이블 컬럼 로드 useEffect(() => { @@ -130,10 +133,11 @@ export const SelectedItemsDetailInputConfigPanel: React.FC { if (!config.targetTable) { setLoadedTargetTableColumns([]); + setAutoDetectedFks([]); return; } @@ -149,7 +153,10 @@ export const SelectedItemsDetailInputConfigPanel: React.FC(() => { + 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최종 데이터를 저장할 테이블

+ {/* FK 자동 감지 결과 표시 */} + {autoDetectedFks.length > 0 && ( +
+

+ FK 자동 감지됨 ({autoDetectedFks.length}건) +

+
+ {autoDetectedFks.map((fk) => ( +
+ + {fk.mappingType === "source" ? "원본" : fk.mappingType === "parent" ? "부모" : "미분류"} + + {fk.columnName} + -> + {fk.referenceTable} +
+ ))} +
+

+ 엔티티 설정 기반 자동 매핑. sourceKeyField와 parentDataMapping이 자동으로 설정됩니다. +

+
+ )} + {/* 표시할 원본 데이터 컬럼 */}
@@ -961,7 +1069,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC - {localFields.map((field, index) => ( + {localFields.map((field, index) => { + return (
@@ -1255,7 +1364,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC - ))} + ); + })}
@@ -1567,14 +1665,17 @@ export const SplitPanelLayout2Component: React.FC - {formatValue(value, col.format)} + {label !== String(value) ? label : formatValue(value, col.format)} ); } - // 기본 텍스트 + // 카테고리 라벨 변환 시도 후 기본 텍스트 + const label = resolveCategoryLabel(value); + if (label !== String(value)) return label; return formatValue(value, col.format); }; @@ -1821,9 +1922,12 @@ export const SplitPanelLayout2Component: React.FC )} - {displayColumns.map((col, colIdx) => ( - {formatValue(getColumnValue(item, col), col.format)} - ))} + {displayColumns.map((col, colIdx) => { + const rawVal = getColumnValue(item, col); + const resolved = resolveCategoryLabel(rawVal); + const display = resolved !== String(rawVal ?? "") ? resolved : formatValue(rawVal, col.format); + return {display || "-"}; + })} {(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
@@ -2133,7 +2237,12 @@ export const SplitPanelLayout2Component: React.FC 0 && (
- {config.leftPanel.actionButtons.map((btn, idx) => ( + {config.leftPanel.actionButtons + .filter((btn) => { + if (btn.showCondition === "selected") return !!selectedLeftItem; + return true; + }) + .map((btn, idx) => ( +
+ ); +} export interface TableListConfigPanelProps { config: TableListConfig; @@ -348,11 +411,11 @@ export const TableListConfigPanel: React.FC = ({ 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 = ({ )} + {/* 선택된 컬럼 순서 변경 (DnD) */} + {config.columns && config.columns.length > 0 && ( +
+
+

표시할 컬럼 ({config.columns.length}개 선택)

+

+ 드래그하여 순서를 변경하거나 표시명/너비를 수정할 수 있습니다 +

+
+
+ { + 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); + } + }} + > + c.columnName)} + strategy={verticalListSortingStrategy} + > +
+ {(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 ( + updateColumn(column.columnName, { displayName: value })} + onWidthChange={(value) => updateColumn(column.columnName, { width: value })} + onRemove={() => removeColumn(column.columnName)} + /> + ); + })} +
+
+
+
+ )} + {/* 🆕 데이터 필터링 설정 */}
@@ -1240,3 +1359,4 @@ export const TableListConfigPanel: React.FC = ({
); }; + diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx index 06226c9e..0b7aa47f 100644 --- a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx +++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx @@ -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 = {}; + const loadedSourceColumns = new Set(); - 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(); - 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 = {}; + 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(); + 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 = {}; @@ -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 = {}; + 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(); + + 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(); - - 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(() => { diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index c806e0df..7e91d8b9 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -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(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 ( - onChangeHandler(e.target.value)} + onChange={onChangeHandler} + placeholder={field.placeholder || "날짜를 선택하세요"} disabled={isDisabled} readOnly={field.readOnly} /> @@ -1847,13 +1877,14 @@ export function UniversalFormModalComponent({ case "datetime": return ( - onChangeHandler(e.target.value)} + onChange={onChangeHandler} + placeholder={field.placeholder || "날짜/시간을 선택하세요"} disabled={isDisabled} readOnly={field.readOnly} + includeTime /> ); diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index 16b0fc81..575b9482 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -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 }, }; // 테이블 목록 diff --git a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx index 1970f1a5..7bda67b2 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx @@ -2721,9 +2721,12 @@ export function TableSectionSettingsModal({ }; const updateUiConfig = (updates: Partial>) => { - 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>) => { diff --git a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx index e4521ac0..bd5f3d92 100644 --- a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx @@ -86,6 +86,7 @@ interface ItemSearchModalProps { onClose: () => void; onSelect: (items: ItemInfo[]) => void; companyCode?: string; + existingItemIds?: Set; } function ItemSearchModal({ @@ -93,6 +94,7 @@ function ItemSearchModal({ onClose, onSelect, companyCode, + existingItemIds, }: ItemSearchModalProps) { const [searchText, setSearchText] = useState(""); const [items, setItems] = useState([]); @@ -182,7 +184,7 @@ function ItemSearchModal({
) : (
- + - {items.map((item) => ( - { - 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", - )} - > - - - - - - - ))} + {items.map((item) => { + const alreadyAdded = existingItemIds?.has(item.id) || false; + return ( + { + 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" : "", + )} + > + + + + + + + ); + })}
e.stopPropagation()}> - { - setSelectedItems((prev) => { - const next = new Set(prev); - if (checked) next.add(item.id); - else next.delete(item.id); - return next; - }); - }} - /> - - {item.item_number} - {item.item_name}{item.type}{item.unit}
e.stopPropagation()}> + { + if (alreadyAdded) return; + setSelectedItems((prev) => { + const next = new Set(prev); + if (checked) next.add(item.id); + else next.delete(item.id); + return next; + }); + }} + /> + + {item.item_number} + {alreadyAdded && (추가됨)} + {item.item_name}{item.type}{item.unit}
)} @@ -739,37 +751,40 @@ export function BomItemEditorComponent({ [originalNotifyChange, markChanged], ); + const handleSaveAllRef = React.useRef<(() => Promise) | 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) | 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 = { + const raw: Record = { ...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 = {}; + 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 = { - ...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(); + 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} />
); diff --git a/frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx b/frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx index cfff4a0c..399a1801 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx @@ -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>({}); 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({
- handleChange("unit", e.target.value)} - className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" - /> + {isRootNode ? ( + handleChange("unit", e.target.value)} + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> + ) : ( + + )}
@@ -139,12 +170,28 @@ export function BomDetailEditModal({
- handleChange("process_type", e.target.value)} - placeholder="예: 조립공정" - className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" - /> + {processOptions.length > 0 ? ( + + ) : ( + handleChange("process_type", e.target.value)} + placeholder="예: 조립공정" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> + )}
diff --git a/frontend/lib/registry/components/v2-bom-tree/BomExcelUploadModal.tsx b/frontend/lib/registry/components/v2-bom-tree/BomExcelUploadModal.tsx new file mode 100644 index 00000000..98a9e823 --- /dev/null +++ b/frontend/lib/registry/components/v2-bom-tree/BomExcelUploadModal.tsx @@ -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 = { + "레벨": "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("upload"); + const [parsedRows, setParsedRows] = useState([]); + const [fileName, setFileName] = useState(""); + const [uploading, setUploading] = useState(false); + const [uploadResult, setUploadResult] = useState(null); + const [downloading, setDownloading] = useState(false); + const [versionName, setVersionName] = useState(""); + const fileInputRef = useRef(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) => { + 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 = {}; + + 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[] = []; + + 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 ( + { if (!v) handleClose(); }}> + + + {title} + {description} + + + {/* Step 1: 파일 업로드 */} + {step === "upload" && ( +
+ {/* 새 버전 모드: 버전명 입력 */} + {isVersionMode && ( +
+ + setVersionName(e.target.value)} + placeholder="예: 2.0" + className="h-8 text-xs sm:h-10 sm:text-sm mt-1" + /> +
+ )} + +
fileInputRef.current?.click()} + > + +

엑셀 파일을 선택하세요

+

.xlsx, .xls, .csv 형식 지원

+ +
+ +
+

엑셀 컬럼 형식

+
+ {EXPECTED_HEADERS.map((h, i) => ( + + {h}{i < 2 ? " *" : ""} + + ))} +
+

+ {isVersionMode + ? "* 레벨 1 = 직접 자품목, 레벨 2 = 자품목의 자품목. 레벨 0 행이 있으면 건너뜁니다." + : "* 레벨 0 = BOM 마스터(최상위 품목, 1행), 레벨 1 = 직접 자품목, 레벨 2 = 자품목의 자품목." + } +

+
+ + +
+ )} + + {/* Step 2: 미리보기 */} + {step === "preview" && ( +
+
+
+ {fileName} + {!isVersionMode && headerRow && ( + 마스터: {headerRow.item_number} + )} + + 하위품목 {detailRows.length}건 + + {invalidCount > 0 && ( + + {invalidCount}건 오류 + + )} +
+ +
+ +
+ + + + + + + + + + + + + + + + {parsedRows.map((row) => ( + + + + + + + + + + + + ))} + +
#구분레벨품번품명소요량단위공정비고
{row.rowIndex} + {row.isHeader ? ( + + {isVersionMode ? "건너뜀" : "마스터"} + + ) : row.valid ? ( + + ) : ( + + + + )} + + + {row.level} + + {row.item_number}{row.item_name}{row.quantity}{row.unit}{row.process_type}{row.remark}
+
+ + {invalidCount > 0 && ( +
+
유효하지 않은 행 ({invalidCount}건)
+
    + {parsedRows.filter(r => !r.valid).slice(0, 5).map(r => ( +
  • {r.rowIndex}행: {r.error}
  • + ))} + {invalidCount > 5 &&
  • ...외 {invalidCount - 5}건
  • } +
+
+ )} + +
+ {isVersionMode + ? "레벨 1 이상의 하위품목으로 새 버전을 생성합니다." + : "레벨 0 품목으로 새 BOM 마스터를 생성하고, 레벨 1 이상은 하위품목으로 등록합니다." + } +
+
+ )} + + {/* Step 3: 결과 */} + {step === "result" && uploadResult && ( +
+
+
+ +
+

+ {isVersionMode ? "새 버전 생성 완료" : "BOM 생성 완료"} +

+

+ 하위품목 {uploadResult.insertedCount}건이 등록되었습니다. +

+
+ +
+ {!isVersionMode && ( +
+
1
+
BOM 마스터
+
+ )} +
+
{uploadResult.insertedCount}
+
하위품목
+
+
+
+ )} + + + {step === "upload" && ( + + )} + {step === "preview" && ( + <> + + + + )} + {step === "result" && ( + + )} + +
+
+ ); +} diff --git a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx index 957b8d85..e98dbf88 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx @@ -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(null); const [historyModalOpen, setHistoryModalOpen] = useState(false); const [versionModalOpen, setVersionModalOpen] = useState(false); + const [excelUploadOpen, setExcelUploadOpen] = useState(false); const [colWidths, setColWidths] = useState>({}); 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>>({}); + 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 = {}; + 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 = { [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; + 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 {value || "-"}; } + if (col.key === "status") { + const statusMap: Record = { active: "사용", inactive: "미사용", developing: "개발중" }; + return {statusMap[String(value)] || value || "-"}; + } + if (col.key === "quantity" || col.key === "base_qty") { return ( @@ -469,6 +518,11 @@ export function BomTreeComponent({ ); } + if (col.key === "process_type" && value) { + const label = categoryLabels.process_type?.[String(value)] || String(value); + return {label}; + } + if (col.key === "loss_rate") { const num = Number(value); if (!num) return -; @@ -786,6 +840,15 @@ export function BomTreeComponent({ 버전 )} +
); } diff --git a/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx b/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx index d36bfe6e..48c27cc9 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx @@ -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(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 )}
+ {showNewInput && ( +
+ 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 + /> + + +
+ )} + - + {!showNewInput && ( + + )}
- {/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */} + {/* 확인 다이얼로그 */} - + {getConfirmTitle()} {getConfirmMessage()} diff --git a/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx b/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx index e8b0dba9..58554c9d 100644 --- a/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx +++ b/frontend/lib/registry/components/v2-file-upload/FileManagerModal.tsx @@ -247,14 +247,12 @@ export const FileManagerModal: React.FC = ({
- {/* 파일 업로드 영역 - 높이 축소 */} - {!isDesignMode && ( + {/* 파일 업로드 영역 - readonly/disabled이면 숨김 */} + {!isDesignMode && !config.readonly && !config.disabled && (
{ - if (!config.disabled && !isDesignMode) { - fileInputRef.current?.click(); - } + fileInputRef.current?.click(); }} onDragOver={handleDragOver} onDragLeave={handleDragLeave} @@ -267,7 +265,6 @@ export const FileManagerModal: React.FC = ({ accept={config.accept} onChange={handleFileInputChange} className="hidden" - disabled={config.disabled} /> {uploading ? ( @@ -286,8 +283,8 @@ export const FileManagerModal: React.FC = ({ {/* 좌우 분할 레이아웃 - 좌측 넓게, 우측 고정 너비 */}
- {/* 좌측: 이미지 미리보기 (확대/축소 가능) */} -
+ {/* 좌측: 이미지 미리보기 (확대/축소 가능) - showPreview가 false면 숨김 */} + {(config.showPreview !== false) &&
{/* 확대/축소 컨트롤 */} {selectedFile && previewImageUrl && (
@@ -369,10 +366,10 @@ export const FileManagerModal: React.FC = ({ {selectedFile.realFileName}
)} -
+
} - {/* 우측: 파일 목록 (고정 너비) */} -
+ {/* 우측: 파일 목록 - showFileList가 false면 숨김, showPreview가 false면 전체 너비 */} + {(config.showFileList !== false) &&

업로드된 파일

@@ -404,7 +401,7 @@ export const FileManagerModal: React.FC = ({ )}

- {formatFileSize(file.fileSize)} • {file.fileExt.toUpperCase()} + {config.showFileSize !== false && <>{formatFileSize(file.fileSize)} • }{file.fileExt.toUpperCase()}

@@ -434,19 +431,21 @@ export const FileManagerModal: React.FC = ({ > - - {!isDesignMode && ( + {config.allowDownload !== false && ( + + )} + {!isDesignMode && config.allowDelete !== false && (
)}
-
+
}
@@ -487,8 +486,8 @@ export const FileManagerModal: React.FC = ({ 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} /> ); diff --git a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx index fc39458a..de55bf2a 100644 --- a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx @@ -105,6 +105,8 @@ const FileUploadComponent: React.FC = ({ const [forceUpdate, setForceUpdate] = useState(0); const [representativeImageUrl, setRepresentativeImageUrl] = useState(null); const fileInputRef = useRef(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 = ({ if (isRecordMode || !recordId) { setUploadedFiles([]); setRepresentativeImageUrl(null); + filesLoadedFromObjidRef.current = false; } } else if (prevIsRecordModeRef.current === null) { // 초기 마운트 시 모드 저장 @@ -191,63 +194,68 @@ const FileUploadComponent: React.FC = ({ }, [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 = ({ ...file, })); + // 서버에서 0개 반환 + objid 기반 로딩이 이미 완료된 경우 덮어쓰지 않음 + if (formattedFiles.length === 0 && filesLoadedFromObjidRef.current) { + return false; + } // 🔄 localStorage의 기존 파일과 서버 파일 병합 (레코드별 고유 키 사용) let finalFiles = formattedFiles; @@ -427,14 +439,19 @@ const FileUploadComponent: React.FC = ({ 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 = ({ // 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성) 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 = ({ 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} /> {/* 파일 관리 모달 */} diff --git a/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent.tsx b/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent.tsx index c859f108..cf7b306f 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent.tsx +++ b/frontend/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent.tsx @@ -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} diff --git a/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx b/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx index d9828aa0..ec22d200 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx +++ b/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx @@ -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(); }; diff --git a/frontend/lib/registry/components/v2-process-work-standard/components/WorkItemAddModal.tsx b/frontend/lib/registry/components/v2-process-work-standard/components/WorkItemAddModal.tsx index 6a907f58..e9f97c02 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/components/WorkItemAddModal.tsx +++ b/frontend/lib/registry/components/v2-process-work-standard/components/WorkItemAddModal.tsx @@ -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([]); + 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"); diff --git a/frontend/lib/registry/components/v2-process-work-standard/components/WorkPhaseSection.tsx b/frontend/lib/registry/components/v2-process-work-standard/components/WorkPhaseSection.tsx index c8acb04d..1f2e7e4a 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/components/WorkPhaseSection.tsx +++ b/frontend/lib/registry/components/v2-process-work-standard/components/WorkPhaseSection.tsx @@ -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) => void; - onUpdateDetail: (id: string, data: Partial) => void; - onDeleteDetail: (id: string) => void; + onCreateDetail: (workItemId: string, data: Partial, phaseKey: string) => void; + onUpdateDetail: (id: string, data: Partial, 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 (
@@ -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({ {/* 우측: 상세 리스트 */}
- 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)} />
diff --git a/frontend/lib/registry/components/v2-process-work-standard/config.ts b/frontend/lib/registry/components/v2-process-work-standard/config.ts index 8c73ffd7..43ad60cd 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/config.ts +++ b/frontend/lib/registry/components/v2-process-work-standard/config.ts @@ -27,7 +27,6 @@ export const defaultConfig: ProcessWorkStandardConfig = { { value: "inspect", label: "검사항목" }, { value: "procedure", label: "작업절차" }, { value: "input", label: "직접입력" }, - { value: "info", label: "정보조회" }, ], splitRatio: 30, leftPanelTitle: "품목 및 공정 선택", diff --git a/frontend/lib/registry/components/v2-process-work-standard/hooks/useProcessWorkStandard.ts b/frontend/lib/registry/components/v2-process-work-standard/hooks/useProcessWorkStandard.ts index 759eb9c7..e909d291 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/hooks/useProcessWorkStandard.ts +++ b/frontend/lib/registry/components/v2-process-work-standard/hooks/useProcessWorkStandard.ts @@ -17,8 +17,9 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { const [items, setItems] = useState([]); const [routings, setRoutings] = useState([]); const [workItems, setWorkItems] = useState([]); - const [selectedWorkItemDetails, setSelectedWorkItemDetails] = useState([]); - const [selectedWorkItemId, setSelectedWorkItemId] = useState(null); + // 섹션(phase)별 독립적인 선택 상태 관리 + const [selectedWorkItemIdByPhase, setSelectedWorkItemIdByPhase] = useState>({}); + const [selectedDetailsByPhase, setSelectedDetailsByPhase] = useState>({}); 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) => { + async (workItemId: string, data: Partial, 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) => { + async (id: string, data: Partial, 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, diff --git a/frontend/lib/registry/components/v2-repeater/V2RepeaterRenderer.tsx b/frontend/lib/registry/components/v2-repeater/V2RepeaterRenderer.tsx index a238acb3..a756cf6c 100644 --- a/frontend/lib/registry/components/v2-repeater/V2RepeaterRenderer.tsx +++ b/frontend/lib/registry/components/v2-repeater/V2RepeaterRenderer.tsx @@ -21,6 +21,7 @@ interface V2RepeaterRendererProps { onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void; parentId?: string | number; formData?: Record; + groupedData?: Record[]; } const V2RepeaterRenderer: React.FC = ({ @@ -33,6 +34,7 @@ const V2RepeaterRenderer: React.FC = ({ onButtonClick, parentId, formData, + groupedData, }) => { // component.componentConfig 또는 component.config에서 V2RepeaterConfig 추출 const config: V2RepeaterConfig = React.useMemo(() => { @@ -105,6 +107,7 @@ const V2RepeaterRenderer: React.FC = ({ onButtonClick={onButtonClick} className={component?.className} formData={formData} + groupedData={groupedData} /> ); }; diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 5e8bee69..5a839620 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -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 const [showAddModal, setShowAddModal] = useState(false); const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | "left-item" | null>(null); const [addModalFormData, setAddModalFormData] = useState>({}); + const [bomExcelUploadOpen, setBomExcelUploadOpen] = useState(false); // 수정 모달 상태 const [showEditModal, setShowEditModal] = useState(false); @@ -3010,12 +3013,20 @@ export const SplitPanelLayoutComponent: React.FC {componentConfig.leftPanel?.title || "좌측 패널"} - {!isDesignMode && componentConfig.leftPanel?.showAdd && ( - - )} +
+ {!isDesignMode && (componentConfig.leftPanel as any)?.showBomExcelUpload && ( + + )} + {!isDesignMode && componentConfig.leftPanel?.showAdd && ( + + )} +
{componentConfig.leftPanel?.showSearch && ( @@ -3361,6 +3372,10 @@ export const SplitPanelLayoutComponent: React.FC })); // 🔧 그룹화된 데이터 렌더링 + const hasGroupedLeftActions = !isDesignMode && ( + (componentConfig.leftPanel?.showEdit !== false) || + (componentConfig.leftPanel?.showDelete !== false) + ); if (groupedLeftData.length > 0) { return (
@@ -3385,6 +3400,10 @@ export const SplitPanelLayoutComponent: React.FC {col.label} ))} + {hasGroupedLeftActions && ( + + + )} @@ -3399,7 +3418,7 @@ export const SplitPanelLayoutComponent: React.FC 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 )} ))} + {hasGroupedLeftActions && ( + +
+ {(componentConfig.leftPanel?.showEdit !== false) && ( + + )} + {(componentConfig.leftPanel?.showDelete !== false) && ( + + )} +
+ + )} ); })} @@ -3429,6 +3476,10 @@ export const SplitPanelLayoutComponent: React.FC } // 🔧 일반 테이블 렌더링 (그룹화 없음) + const hasLeftTableActions = !isDesignMode && ( + (componentConfig.leftPanel?.showEdit !== false) || + (componentConfig.leftPanel?.showDelete !== false) + ); return (
@@ -3447,6 +3498,10 @@ export const SplitPanelLayoutComponent: React.FC {col.label} ))} + {hasLeftTableActions && ( + + )} @@ -3461,7 +3516,7 @@ export const SplitPanelLayoutComponent: React.FC 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 )} ))} + {hasLeftTableActions && ( + + )} ); })} @@ -4998,6 +5081,16 @@ export const SplitPanelLayoutComponent: React.FC + + {(componentConfig.leftPanel as any)?.showBomExcelUpload && ( + { + loadLeftData(); + }} + /> + )} ); }; diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 1eaef469..717ea6ef 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -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(null); + const [displayObjid, setDisplayObjid] = React.useState(""); 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 = ({ 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 = ({ } }; - // 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택) + // 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택/해제 토글) 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 = ({ 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 = ({ )} - {/* 선택 정보 */} - {selectedRows.size > 0 && ( -
- - {selectedRows.size}개 선택됨 - - -
- )} + {/* 선택 정보 - 숨김 처리 */} {/* 🆕 통합 검색 패널 */} {(tableConfig.toolbar?.showSearch ?? false) && ( @@ -5777,12 +5817,6 @@ export const TableListComponent: React.FC = ({ renderCheckboxHeader() ) : (
- {/* 🆕 편집 불가 컬럼 표시 */} - {column.editable === false && ( - - - - )} {columnLabels[column.columnName] || column.displayName} {column.sortable !== false && sortColumn === column.columnName && ( {sortDirection === "asc" ? "↑" : "↓"} @@ -6315,6 +6349,21 @@ export const TableListComponent: React.FC = ({ ); } + // 날짜 타입: 캘린더 피커 + const isDateType = colMeta?.inputType === "date" || colMeta?.inputType === "datetime"; + if (isDateType) { + const { InlineCellDatePicker } = require("@/components/screen/filters/InlineCellDatePicker"); + return ( + + ); + } + // 일반 입력 필드 return ( 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 ( +
+
+ +
+ {isEntityJoin ? ( + + ) : ( + #{index + 1} + )} + onLabelChange(e.target.value)} + placeholder="표시명" + className="h-6 min-w-0 flex-1 text-xs" + /> + onWidthChange(parseInt(e.target.value) || 100)} + placeholder="너비" + className="h-6 w-14 shrink-0 text-xs" + /> + +
+ ); +} export interface TableListConfigPanelProps { config: TableListConfig; @@ -366,11 +431,11 @@ export const TableListConfigPanel: React.FC = ({ 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 = ({ /> {column.label || column.columnName} - + {isAdded && ( + + )} + {column.input_type || column.dataType}
@@ -1427,6 +1523,63 @@ export const TableListConfigPanel: React.FC = ({ )} + {/* 선택된 컬럼 순서 변경 (DnD) */} + {config.columns && config.columns.length > 0 && ( +
+
+

표시할 컬럼 ({config.columns.length}개 선택)

+

+ 드래그하여 순서를 변경하거나 표시명/너비를 수정할 수 있습니다 +

+
+
+ { + 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); + } + }} + > + c.columnName)} + strategy={verticalListSortingStrategy} + > +
+ {(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 ( + updateColumn(column.columnName, { displayName: value })} + onWidthChange={(value) => updateColumn(column.columnName, { width: value })} + onRemove={() => removeColumn(column.columnName)} + /> + ); + })} +
+
+
+
+ )} + {/* 🆕 데이터 필터링 설정 */}
@@ -1453,3 +1606,4 @@ export const TableListConfigPanel: React.FC = ({
); }; + diff --git a/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx b/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx index 061ac6f0..39e888ab 100644 --- a/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx +++ b/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx @@ -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> = { ...selectOptions }; + const loadedOptions: Record> = {}; + 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 - +
{uniqueOptions.length === 0 ? (
옵션 없음
@@ -739,7 +750,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table onCheckedChange={(checked) => handleMultiSelectChange(option.value, checked as boolean)} onClick={(e) => e.stopPropagation()} /> - {option.label} + {option.label}
))}
diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 390404ce..2ed4db87 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -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((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((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((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((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) { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d8b262a1..01edd32d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" diff --git a/frontend/types/v2-components.ts b/frontend/types/v2-components.ts index 88ac1691..6ce5a974 100644 --- a/frontend/types/v2-components.ts +++ b/frontend/types/v2-components.ts @@ -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; diff --git a/frontend/types/v2-core.ts b/frontend/types/v2-core.ts index b3cbc1cd..e8edc09b 100644 --- a/frontend/types/v2-core.ts +++ b/frontend/types/v2-core.ts @@ -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; diff --git a/frontend/types/v2-repeater.ts b/frontend/types/v2-repeater.ts index 96441d48..2d9199b4 100644 --- a/frontend/types/v2-repeater.ts +++ b/frontend/types/v2-repeater.ts @@ -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; // 리피터 컬럼 ← 조인 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; // 수정 모드에서 FK 기반 데이터 로드용 + groupedData?: Record[]; // 모달에서 전달받은 선택 데이터 (소스 디테일 조회용) } // 기본 설정값 diff --git a/scripts/browser-test-admin-switch-button.js b/scripts/browser-test-admin-switch-button.js new file mode 100644 index 00000000..e43a139e --- /dev/null +++ b/scripts/browser-test-admin-switch-button.js @@ -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); + }); diff --git a/scripts/browser-test-customer-crud.js b/scripts/browser-test-customer-crud.js new file mode 100644 index 00000000..ebfcde0c --- /dev/null +++ b/scripts/browser-test-customer-crud.js @@ -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); +}); diff --git a/scripts/browser-test-customer-via-menu.js b/scripts/browser-test-customer-via-menu.js new file mode 100644 index 00000000..7199741b --- /dev/null +++ b/scripts/browser-test-customer-via-menu.js @@ -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); + }); diff --git a/scripts/browser-test-purchase-supplier.js b/scripts/browser-test-purchase-supplier.js new file mode 100644 index 00000000..b2b51718 --- /dev/null +++ b/scripts/browser-test-purchase-supplier.js @@ -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); + }); diff --git a/scripts/dev/start-npm.sh b/scripts/dev/start-npm.sh new file mode 100755 index 00000000..7b7fc54a --- /dev/null +++ b/scripts/dev/start-npm.sh @@ -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"
+
+
+ {(componentConfig.leftPanel?.showEdit !== false) && ( + + )} + {(componentConfig.leftPanel?.showDelete !== false) && ( + + )} +
+