jskim-node #396

Merged
kjs merged 44 commits from jskim-node into main 2026-02-28 14:37:11 +09:00
81 changed files with 7951 additions and 1438 deletions

View File

@ -92,9 +92,9 @@ export async function createBomVersion(req: Request, res: Response) {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const createdBy = (req as any).user?.userName || (req as any).user?.userId || "";
const { tableName, detailTable } = req.body || {};
const { tableName, detailTable, versionName } = req.body || {};
const result = await bomService.createBomVersion(bomId, companyCode, createdBy, tableName, detailTable);
const result = await bomService.createBomVersion(bomId, companyCode, createdBy, tableName, detailTable, versionName);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 버전 생성 실패", { error: error.message });
@ -129,6 +129,84 @@ export async function activateBomVersion(req: Request, res: Response) {
}
}
export async function initializeBomVersion(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const createdBy = (req as any).user?.userName || (req as any).user?.userId || "";
const result = await bomService.initializeBomVersion(bomId, companyCode, createdBy);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 초기 버전 생성 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
// ─── BOM 엑셀 업로드/다운로드 ─────────────────────────
export async function createBomFromExcel(req: Request, res: Response) {
try {
const companyCode = (req as any).user?.companyCode || "*";
const userId = (req as any).user?.userName || (req as any).user?.userId || "";
const { rows } = req.body;
if (!rows || !Array.isArray(rows) || rows.length === 0) {
res.status(400).json({ success: false, message: "업로드할 데이터가 없습니다" });
return;
}
const result = await bomService.createBomFromExcel(companyCode, userId, rows);
if (!result.success) {
res.status(400).json({ success: false, message: result.errors.join(", "), data: result });
return;
}
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 엑셀 업로드 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function createBomVersionFromExcel(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const userId = (req as any).user?.userName || (req as any).user?.userId || "";
const { rows, versionName } = req.body;
if (!rows || !Array.isArray(rows) || rows.length === 0) {
res.status(400).json({ success: false, message: "업로드할 데이터가 없습니다" });
return;
}
const result = await bomService.createBomVersionFromExcel(bomId, companyCode, userId, rows, versionName);
if (!result.success) {
res.status(400).json({ success: false, message: result.errors.join(", "), data: result });
return;
}
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 버전 엑셀 업로드 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function downloadBomExcelData(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const data = await bomService.downloadBomExcelData(bomId, companyCode);
res.json({ success: true, data });
} catch (error: any) {
logger.error("BOM 엑셀 다운로드 데이터 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteBomVersion(req: Request, res: Response) {
try {
const { bomId, versionId } = req.params;

View File

@ -3,16 +3,115 @@ import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
/**
* WHERE절에
* filters JSON : [{ column, operator, value }]
*/
function applyFilters(
filtersJson: string | undefined,
existingColumns: Set<string>,
whereConditions: string[],
params: any[],
startParamIndex: number,
tableName: string,
): number {
let paramIndex = startParamIndex;
if (!filtersJson) return paramIndex;
let filters: Array<{ column: string; operator: string; value: unknown }>;
try {
filters = JSON.parse(filtersJson as string);
} catch {
logger.warn("filters JSON 파싱 실패", { tableName, filtersJson });
return paramIndex;
}
if (!Array.isArray(filters)) return paramIndex;
for (const filter of filters) {
const { column, operator = "=", value } = filter;
if (!column || !existingColumns.has(column)) {
logger.warn("필터 컬럼 미존재 제외", { tableName, column });
continue;
}
switch (operator) {
case "=":
whereConditions.push(`"${column}" = $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "!=":
whereConditions.push(`"${column}" != $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case ">":
case "<":
case ">=":
case "<=":
whereConditions.push(`"${column}" ${operator} $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "in": {
const inVals = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
if (inVals.length > 0) {
const ph = inVals.map((_, i) => `$${paramIndex + i}`).join(", ");
whereConditions.push(`"${column}" IN (${ph})`);
params.push(...inVals);
paramIndex += inVals.length;
}
break;
}
case "notIn": {
const notInVals = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
if (notInVals.length > 0) {
const ph = notInVals.map((_, i) => `$${paramIndex + i}`).join(", ");
whereConditions.push(`"${column}" NOT IN (${ph})`);
params.push(...notInVals);
paramIndex += notInVals.length;
}
break;
}
case "like":
whereConditions.push(`"${column}"::text ILIKE $${paramIndex}`);
params.push(`%${value}%`);
paramIndex++;
break;
case "isNull":
whereConditions.push(`"${column}" IS NULL`);
break;
case "isNotNull":
whereConditions.push(`"${column}" IS NOT NULL`);
break;
default:
whereConditions.push(`"${column}" = $${paramIndex}`);
params.push(value);
paramIndex++;
break;
}
}
return paramIndex;
}
/**
* DISTINCT API (inputType: select )
* GET /api/entity/:tableName/distinct/:columnName
*
* DISTINCT
*
* Query Params:
* - labelColumn: 별도의 ()
* - filters: JSON ()
* : [{"column":"status","operator":"=","value":"active"}]
*/
export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Response) {
try {
const { tableName, columnName } = req.params;
const { labelColumn } = req.query; // 선택적: 별도의 라벨 컬럼
const { labelColumn, filters: filtersParam } = req.query;
// 유효성 검증
if (!tableName || tableName === "undefined" || tableName === "null") {
@ -68,6 +167,16 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
whereConditions.push(`"${columnName}" IS NOT NULL`);
whereConditions.push(`"${columnName}" != ''`);
// 필터 조건 적용
paramIndex = applyFilters(
filtersParam as string | undefined,
existingColumns,
whereConditions,
params,
paramIndex,
tableName,
);
const whereClause = whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
@ -88,6 +197,7 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
columnName,
labelColumn: effectiveLabelColumn,
companyCode,
hasFilters: !!filtersParam,
rowCount: result.rowCount,
});
@ -111,11 +221,14 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
* Query Params:
* - value: (기본: id)
* - label: 표시 (기본: name)
* - fields: 추가 ( )
* - filters: JSON ()
* : [{"column":"status","operator":"=","value":"active"}]
*/
export async function getEntityOptions(req: AuthenticatedRequest, res: Response) {
try {
const { tableName } = req.params;
const { value = "id", label = "name", fields } = req.query;
const { value = "id", label = "name", fields, filters: filtersParam } = req.query;
// tableName 유효성 검증
if (!tableName || tableName === "undefined" || tableName === "null") {
@ -163,6 +276,16 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response)
paramIndex++;
}
// 필터 조건 적용
paramIndex = applyFilters(
filtersParam as string | undefined,
existingColumns,
whereConditions,
params,
paramIndex,
tableName,
);
const whereClause = whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
@ -195,6 +318,7 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response)
valueColumn,
labelColumn: effectiveLabelColumn,
companyCode,
hasFilters: !!filtersParam,
rowCount: result.rowCount,
extraFields: extraColumns ? true : false,
});

View File

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

View File

@ -17,9 +17,15 @@ router.get("/:bomId/header", bomController.getBomHeader);
router.get("/:bomId/history", bomController.getBomHistory);
router.post("/:bomId/history", bomController.addBomHistory);
// 엑셀 업로드/다운로드
router.post("/excel-upload", bomController.createBomFromExcel);
router.post("/:bomId/excel-upload-version", bomController.createBomVersionFromExcel);
router.get("/:bomId/excel-download", bomController.downloadBomExcelData);
// 버전
router.get("/:bomId/versions", bomController.getBomVersions);
router.post("/:bomId/versions", bomController.createBomVersion);
router.post("/:bomId/initialize-version", bomController.initializeBomVersion);
router.post("/:bomId/versions/:versionId/load", bomController.loadBomVersion);
router.post("/:bomId/versions/:versionId/activate", bomController.activateBomVersion);
router.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion);

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

@ -179,7 +179,25 @@ function ScreenViewPage() {
} else {
// V1 레이아웃 또는 빈 레이아웃
const layoutData = await screenApi.getLayout(screenId);
setLayout(layoutData);
if (layoutData?.components?.length > 0) {
setLayout(layoutData);
} else {
console.warn("[ScreenViewPage] getLayout 실패, getLayerLayout(1) fallback:", screenId);
const baseLayerData = await screenApi.getLayerLayout(screenId, 1);
if (baseLayerData && isValidV2Layout(baseLayerData)) {
const converted = convertV2ToLegacy(baseLayerData);
if (converted) {
setLayout({
...converted,
screenResolution: baseLayerData.screenResolution || converted.screenResolution,
} as LayoutData);
} else {
setLayout(layoutData);
}
} else {
setLayout(layoutData);
}
}
}
} catch (layoutError) {
console.warn("레이아웃 로드 실패, 빈 레이아웃 사용:", layoutError);

View File

@ -903,7 +903,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
}
}
for (const row of filteredData) {
for (let rowIdx = 0; rowIdx < filteredData.length; rowIdx++) {
const row = filteredData[rowIdx];
try {
let dataToSave = { ...row };
let shouldSkip = false;
@ -925,15 +926,16 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
if (existingDataMap.has(key)) {
existingRow = existingDataMap.get(key);
// 중복 발견 - 전역 설정에 따라 처리
if (duplicateAction === "skip") {
shouldSkip = true;
skipCount++;
console.log(`⏭️ 중복으로 건너뛰기: ${key}`);
console.log(`⏭️ [행 ${rowIdx + 1}] 중복으로 건너뛰기: ${key}`);
} else {
shouldUpdate = true;
console.log(`🔄 중복으로 덮어쓰기: ${key}`);
console.log(`🔄 [행 ${rowIdx + 1}] 중복으로 덮어쓰기: ${key}`);
}
} else {
console.log(`✅ [행 ${rowIdx + 1}] 중복 아님 (신규 데이터): ${key}`);
}
}
@ -943,7 +945,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
}
// 채번 적용: 엑셀에 값이 없거나 빈 값이면 채번 규칙으로 자동 생성, 값이 있으면 그대로 사용
if (hasNumbering && numberingInfo && uploadMode === "insert" && !shouldUpdate) {
if (hasNumbering && numberingInfo && (uploadMode === "insert" || uploadMode === "upsert") && !shouldUpdate) {
const existingValue = dataToSave[numberingInfo.columnName];
const hasExcelValue = existingValue !== undefined && existingValue !== null && String(existingValue).trim() !== "";
@ -968,24 +970,34 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
tableName,
data: dataToSave,
};
console.log(`📝 [행 ${rowIdx + 1}] 덮어쓰기 시도: id=${existingRow.id}`, dataToSave);
const result = await DynamicFormApi.updateFormData(existingRow.id, formData);
if (result.success) {
overwriteCount++;
successCount++;
} else {
console.error(`❌ [행 ${rowIdx + 1}] 덮어쓰기 실패:`, result.message);
failCount++;
}
} else if (uploadMode === "insert") {
// 신규 등록
} else if (uploadMode === "insert" || uploadMode === "upsert") {
// 신규 등록 (insert, upsert 모드)
const formData = { screenId: 0, tableName, data: dataToSave };
console.log(`📝 [행 ${rowIdx + 1}] 신규 등록 시도 (mode: ${uploadMode}):`, dataToSave);
const result = await DynamicFormApi.saveFormData(formData);
if (result.success) {
successCount++;
console.log(`✅ [행 ${rowIdx + 1}] 신규 등록 성공`);
} else {
console.error(`❌ [행 ${rowIdx + 1}] 신규 등록 실패:`, result.message);
failCount++;
}
} else if (uploadMode === "update") {
// update 모드에서 기존 데이터가 없는 행은 건너뛰기
console.log(`⏭️ [행 ${rowIdx + 1}] update 모드: 기존 데이터 없음, 건너뛰기`);
skipCount++;
}
} catch (error) {
} catch (error: any) {
console.error(`❌ [행 ${rowIdx + 1}] 업로드 처리 오류:`, error?.response?.data || error?.message || error);
failCount++;
}
}
@ -1008,8 +1020,9 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
}
}
console.log(`📊 엑셀 업로드 결과 요약: 성공=${successCount}, 건너뛰기=${skipCount}, 덮어쓰기=${overwriteCount}, 실패=${failCount}`);
if (successCount > 0 || skipCount > 0) {
// 상세 결과 메시지 생성
let message = "";
if (successCount > 0) {
message += `${successCount}개 행 업로드`;
@ -1022,15 +1035,23 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
message += `중복 건너뛰기 ${skipCount}`;
}
if (failCount > 0) {
message += ` (실패: ${failCount})`;
message += `, 실패 ${failCount}`;
}
toast.success(message);
if (failCount > 0 && successCount === 0) {
toast.warning(message);
} else {
toast.success(message);
}
// 매핑 템플릿 저장
await saveMappingTemplateInternal();
onSuccess?.();
if (successCount > 0 || overwriteCount > 0) {
onSuccess?.();
}
} else if (failCount > 0) {
toast.error(`업로드 실패: ${failCount}개 행 저장에 실패했습니다. 브라우저 콘솔에서 상세 오류를 확인하세요.`);
} else {
toast.error("업로드에 실패했습니다.");
}

View File

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

View File

@ -275,7 +275,26 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
});
// 편집 데이터로 폼 데이터 초기화
setFormData(editData || {});
// entity join 필드(xxx_yyy)를 dot notation(table.column)으로도 매핑
const enriched = { ...(editData || {}) };
if (editData) {
Object.keys(editData).forEach((key) => {
// item_id_item_name → item_info.item_name 패턴 변환
const match = key.match(/^(.+?)_([a-z_]+)$/);
if (match && editData[key] != null) {
const [, fkCol, fieldName] = match;
// FK가 _id로 끝나면 참조 테이블명 추론 (item_id → item_info)
if (fkCol.endsWith("_id")) {
const refTable = fkCol.replace(/_id$/, "_info");
const dotKey = `${refTable}.${fieldName}`;
if (!(dotKey in enriched)) {
enriched[dotKey] = editData[key];
}
}
}
});
}
setFormData(enriched);
// originalData: changedData 계산(PATCH)에만 사용
// INSERT/UPDATE 판단에는 사용하지 않음
setOriginalData(isCreateMode ? {} : editData || {});
@ -394,9 +413,28 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// V2 없으면 기존 API fallback
if (!layoutData) {
console.warn("[EditModal] V2 레이아웃 없음, getLayout fallback 시도:", screenId);
layoutData = await screenApi.getLayout(screenId);
}
// getLayout도 실패하면 기본 레이어(layer_id=1) 직접 로드
if (!layoutData || !layoutData.components || layoutData.components.length === 0) {
console.warn("[EditModal] getLayout도 실패, getLayerLayout(1) 최종 fallback:", screenId);
try {
const baseLayerData = await screenApi.getLayerLayout(screenId, 1);
if (baseLayerData && isValidV2Layout(baseLayerData)) {
layoutData = convertV2ToLegacy(baseLayerData);
if (layoutData) {
layoutData.screenResolution = baseLayerData.screenResolution || layoutData.screenResolution;
}
} else if (baseLayerData?.components) {
layoutData = baseLayerData;
}
} catch (fallbackErr) {
console.error("[EditModal] getLayerLayout(1) fallback 실패:", fallbackErr);
}
}
if (screenInfo && layoutData) {
const components = layoutData.components || [];
@ -1202,38 +1240,35 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
}
// V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행)
try {
const repeaterSavePromise = new Promise<void>((resolve) => {
const fallbackTimeout = setTimeout(resolve, 5000);
const handler = () => {
clearTimeout(fallbackTimeout);
window.removeEventListener("repeaterSaveComplete", handler);
resolve();
};
window.addEventListener("repeaterSaveComplete", handler);
});
// V2Repeater 디테일 데이터 저장 (등록된 인스턴스가 있을 때만)
const hasRepeaterForInsert = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
if (hasRepeaterForInsert) {
try {
const repeaterSavePromise = new Promise<void>((resolve) => {
const fallbackTimeout = setTimeout(resolve, 5000);
const handler = () => {
clearTimeout(fallbackTimeout);
window.removeEventListener("repeaterSaveComplete", handler);
resolve();
};
window.addEventListener("repeaterSaveComplete", handler);
});
console.log("🟢 [EditModal] INSERT 후 repeaterSave 이벤트 발행:", {
parentId: masterRecordId,
tableName: screenData.screenInfo.tableName,
});
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: masterRecordId,
tableName: screenData.screenInfo.tableName,
mainFormData: formData,
masterRecordId,
},
}),
);
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: masterRecordId,
tableName: screenData.screenInfo.tableName,
mainFormData: formData,
masterRecordId,
},
}),
);
await repeaterSavePromise;
console.log("✅ [EditModal] INSERT 후 repeaterSave 완료");
} catch (repeaterError) {
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
await repeaterSavePromise;
} catch (repeaterError) {
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
}
}
handleClose();
@ -1242,8 +1277,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
}
} else {
// UPDATE 모드 - PUT (전체 업데이트)
// originalData 비교 없이 formData 전체를 보냄
const recordId = formData.id;
// VIEW에서 온 데이터의 경우 master_id를 우선 사용 (마스터-디테일 구조)
const recordId = formData.master_id || formData.id;
if (!recordId) {
console.error("[EditModal] UPDATE 실패: formData에 id가 없습니다.", {
@ -1296,15 +1331,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
if (response.success) {
toast.success("데이터가 수정되었습니다.");
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
if (modalState.onSave) {
try {
modalState.onSave();
} catch (callbackError) {
console.error("onSave 콜백 에러:", callbackError);
}
}
// 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어)
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
try {
@ -1341,40 +1367,41 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
}
// V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행)
try {
const repeaterSavePromise = new Promise<void>((resolve) => {
const fallbackTimeout = setTimeout(resolve, 5000);
const handler = () => {
clearTimeout(fallbackTimeout);
window.removeEventListener("repeaterSaveComplete", handler);
resolve();
};
window.addEventListener("repeaterSaveComplete", handler);
});
// V2Repeater 디테일 데이터 저장 (등록된 인스턴스가 있을 때만)
const hasRepeaterForUpdate = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
if (hasRepeaterForUpdate) {
try {
const repeaterSavePromise = new Promise<void>((resolve) => {
const fallbackTimeout = setTimeout(resolve, 5000);
const handler = () => {
clearTimeout(fallbackTimeout);
window.removeEventListener("repeaterSaveComplete", handler);
resolve();
};
window.addEventListener("repeaterSaveComplete", handler);
});
console.log("🟢 [EditModal] UPDATE 후 repeaterSave 이벤트 발행:", {
parentId: recordId,
tableName: screenData.screenInfo.tableName,
});
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: recordId,
tableName: screenData.screenInfo.tableName,
mainFormData: formData,
masterRecordId: recordId,
},
}),
);
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: recordId,
tableName: screenData.screenInfo.tableName,
mainFormData: formData,
masterRecordId: recordId,
},
}),
);
await repeaterSavePromise;
console.log("✅ [EditModal] UPDATE 후 repeaterSave 완료");
} catch (repeaterError) {
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
await repeaterSavePromise;
} catch (repeaterError) {
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
}
}
// 리피터 저장 완료 후 메인 테이블 새로고침
if (modalState.onSave) {
try { modalState.onSave(); } catch {}
}
handleClose();
} else {
throw new Error(response.message || "수정에 실패했습니다.");
@ -1432,7 +1459,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
</div>
</DialogHeader>
<div className="flex flex-1 items-center justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
<div className="flex flex-1 justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
{loading ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
@ -1447,7 +1474,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
>
<div
data-screen-runtime="true"
className="relative bg-white"
className="relative m-auto bg-white"
style={{
width: screenDimensions?.width || 800,
// 조건부 레이어가 활성화되면 높이 자동 확장

View File

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

View File

@ -2191,10 +2191,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 라벨 표시 여부 계산
const shouldShowLabel =
!hideLabel && // hideLabel이 true면 라벨 숨김
(component.style?.labelDisplay ?? true) &&
!hideLabel &&
(component.style?.labelDisplay ?? true) !== false &&
component.style?.labelDisplay !== "false" &&
(component.label || component.style?.labelText) &&
!templateTypes.includes(component.type); // 템플릿 컴포넌트는 라벨 표시 안함
!templateTypes.includes(component.type);
const labelText = component.style?.labelText || component.label || "";
@ -2208,15 +2209,21 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
});
}
// 라벨 스타일 적용
const labelStyle = {
// 라벨 위치 및 스타일
const labelPosition = component.style?.labelPosition || "top";
const isHorizontalLabel = labelPosition === "left" || labelPosition === "right";
const labelGap = component.style?.labelGap || "8px";
const labelStyle: React.CSSProperties = {
fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#212121",
fontWeight: component.style?.labelFontWeight || "500",
backgroundColor: component.style?.labelBackgroundColor || "transparent",
padding: component.style?.labelPadding || "0",
borderRadius: component.style?.labelBorderRadius || "0",
marginBottom: component.style?.labelMarginBottom || "4px",
...(isHorizontalLabel
? { whiteSpace: "nowrap" as const, display: "flex", alignItems: "center" }
: { marginBottom: component.style?.labelMarginBottom || "4px" }),
};
@ -2226,8 +2233,17 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
...component,
style: {
...component.style,
labelDisplay: false, // 상위에서 라벨을 표시했으므로 컴포넌트 내부에서는 숨김
labelDisplay: false,
labelPosition: "top" as const,
...(isHorizontalLabel ? { width: "100%", height: "100%" } : {}),
},
...(isHorizontalLabel ? {
size: {
...component.size,
width: undefined as unknown as number,
height: undefined as unknown as number,
},
} : {}),
}
: component;
@ -2452,18 +2468,45 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
{/* 테이블 옵션 툴바 */}
<TableOptionsToolbar />
{/* 메인 컨텐츠 */}
<div className="h-full flex-1" style={{ width: '100%' }}>
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
{shouldShowLabel && (
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{/* 메인 컨텐츠 - 라벨 위치에 따라 flex 방향 변경 */}
<div
className="h-full flex-1"
style={{
width: '100%',
...(shouldShowLabel && isHorizontalLabel
? { display: 'flex', flexDirection: labelPosition === 'left' ? 'row' : 'row-reverse', alignItems: 'center', gap: labelGap }
: {}),
}}
>
{/* 라벨: top 또는 left일 때 위젯보다 먼저 렌더링 */}
{shouldShowLabel && (labelPosition === "top" || labelPosition === "left") && (
<label
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
style={labelStyle}
>
{labelText}
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
</label>
)}
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */}
<div className="h-full" style={{ width: '100%', height: '100%' }}>{renderInteractiveWidget(componentForRendering)}</div>
{/* 실제 위젯 */}
<div className="h-full" style={{ width: '100%', height: '100%', ...(isHorizontalLabel ? { flex: 1, minWidth: 0 } : {}) }}>
{renderInteractiveWidget(componentForRendering)}
</div>
{/* 라벨: bottom 또는 right일 때 위젯 뒤에 렌더링 */}
{shouldShowLabel && (labelPosition === "bottom" || labelPosition === "right") && (
<label
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
style={{
...labelStyle,
...(labelPosition === "bottom" ? { marginBottom: 0, marginTop: component.style?.labelMarginBottom || "4px" } : {}),
}}
>
{labelText}
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
</label>
)}
</div>
</div>

View File

@ -1103,17 +1103,27 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// TableSearchWidget의 경우 높이를 자동으로 설정
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
// 🆕 라벨 표시 여부 확인 (V2 입력 컴포넌트)
// labelDisplay가 false가 아니고, labelText 또는 label이 있으면 라벨 표시
const isV2InputComponent = type === "v2-input" || type === "v2-select" || type === "v2-date";
// 라벨 표시 여부 확인 (V2 입력 컴포넌트)
const compType = (component as any).componentType || "";
const isV2InputComponent =
type === "v2-input" || type === "v2-select" || type === "v2-date" ||
compType === "v2-input" || compType === "v2-select" || compType === "v2-date";
const hasVisibleLabel = isV2InputComponent &&
style?.labelDisplay !== false &&
style?.labelDisplay !== false && style?.labelDisplay !== "false" &&
(style?.labelText || (component as any).label);
// 라벨이 있는 경우 상단 여백 계산 (라벨 폰트크기 + 여백)
// 라벨 위치에 따라 오프셋 계산 (좌/우 배치 시 세로 오프셋 불필요)
const labelPos = style?.labelPosition || "top";
const isVerticalLabel = labelPos === "top" || labelPos === "bottom";
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const labelOffset = hasVisibleLabel ? (labelFontSize + labelMarginBottom + 2) : 0;
const labelOffset = (hasVisibleLabel && isVerticalLabel) ? (labelFontSize + labelMarginBottom + 2) : 0;
// 수평 라벨 관련 (componentStyle 계산보다 먼저 선언)
const needsExternalLabel = hasVisibleLabel && labelPos !== "top";
const isHorizLabel = labelPos === "left" || labelPos === "right";
const labelText = style?.labelText || (component as any).label || "";
const labelGapValue = style?.labelGap || "8px";
const calculateCanvasSplitX = (): { x: number; w: number } => {
const compType = (component as any).componentType || "";
@ -1190,9 +1200,17 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// styleWithoutSize에서 left/top 제거 (캔버스 분할 조정값 덮어쓰기 방지)
const { left: _styleLeft, top: _styleTop, ...safeStyleWithoutSize } = styleWithoutSize as any;
// 수평 라벨 컴포넌트: position wrapper에서 border 제거 (내부 V2 컴포넌트가 기본 border 사용)
const cleanedStyle = (isHorizLabel && needsExternalLabel)
? (() => {
const { borderWidth: _bw, borderColor: _bc, borderStyle: _bs, border: _b, borderRadius: _br, ...rest } = safeStyleWithoutSize;
return rest;
})()
: safeStyleWithoutSize;
const componentStyle = {
position: "absolute" as const,
...safeStyleWithoutSize,
...cleanedStyle,
// left/top은 반드시 마지막에 (styleWithoutSize가 덮어쓰지 못하게)
left: adjustedX,
top: position?.y || 0,
@ -1263,10 +1281,101 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
return unsubscribe;
}, [component.id, position?.x, size?.width, type]);
// needsExternalLabel, isHorizLabel, labelText, labelGapValue는 위에서 선언됨
const externalLabelComponent = needsExternalLabel ? (
<label
className="text-sm font-medium leading-none"
style={{
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#212121",
fontWeight: style?.labelFontWeight || "500",
...(isHorizLabel ? { whiteSpace: "nowrap" as const, display: "flex", alignItems: "center" } : {}),
...(labelPos === "bottom" ? { marginTop: style?.labelMarginBottom || "4px" } : {}),
}}
>
{labelText}
{((component as any).required || (component as any).componentConfig?.required) && (
<span className="ml-1 text-destructive">*</span>
)}
</label>
) : null;
const componentToRender = needsExternalLabel
? {
...splitAdjustedComponent,
style: {
...splitAdjustedComponent.style,
labelDisplay: false,
labelPosition: "top" as const,
...(isHorizLabel ? {
width: "100%",
height: "100%",
borderWidth: undefined,
borderColor: undefined,
borderStyle: undefined,
border: undefined,
borderRadius: undefined,
} : {}),
},
...(isHorizLabel ? {
size: {
...splitAdjustedComponent.size,
width: undefined as unknown as number,
height: undefined as unknown as number,
},
} : {}),
}
: splitAdjustedComponent;
return (
<>
<div ref={elRef} id={`interactive-${component.id}`} className="absolute" style={componentStyle}>
{renderInteractiveWidget(splitAdjustedComponent)}
{needsExternalLabel ? (
isHorizLabel ? (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<label
className="text-sm font-medium leading-none"
style={{
position: "absolute",
top: "50%",
transform: "translateY(-50%)",
...(labelPos === "left"
? { right: "100%", marginRight: labelGapValue }
: { left: "100%", marginLeft: labelGapValue }),
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#212121",
fontWeight: style?.labelFontWeight || "500",
whiteSpace: "nowrap",
}}
>
{labelText}
{((component as any).required || (component as any).componentConfig?.required) && (
<span className="ml-1 text-destructive">*</span>
)}
</label>
<div style={{ width: "100%", height: "100%" }}>
{renderInteractiveWidget(componentToRender)}
</div>
</div>
) : (
<div
style={{
display: "flex",
flexDirection: "column-reverse",
width: "100%",
height: "100%",
}}
>
{externalLabelComponent}
<div style={{ flex: 1, minWidth: 0 }}>
{renderInteractiveWidget(componentToRender)}
</div>
</div>
)
) : (
renderInteractiveWidget(componentToRender)
)}
</div>
{/* 팝업 화면 렌더링 */}

View File

@ -548,10 +548,23 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
const origWidth = size?.width || 100;
const isSplitShrunk = splitAdjustedWidth !== null && splitAdjustedWidth < origWidth;
// v2 수평 라벨 컴포넌트: position wrapper에서 border 제거 (DynamicComponentRenderer가 내부에서 처리)
const isV2HorizLabel = !!(
componentStyle &&
(componentStyle.labelDisplay === true || componentStyle.labelDisplay === "true") &&
(componentStyle.labelPosition === "left" || componentStyle.labelPosition === "right")
);
const safeComponentStyle = isV2HorizLabel
? (() => {
const { borderWidth, borderColor, borderStyle, border, borderRadius, ...rest } = componentStyle as any;
return rest;
})()
: componentStyle;
const baseStyle = {
left: `${adjustedPositionX}px`,
top: `${position.y}px`,
...componentStyle,
...safeComponentStyle,
width: splitAdjustedWidth !== null ? `${splitAdjustedWidth}px` : displayWidth,
height: displayHeight,
zIndex: component.type === "layout" ? 1 : position.z || 2,

View File

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

View File

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

View File

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

View File

@ -841,6 +841,44 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
className="h-6 w-full px-2 py-0 text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"></Label>
<Select
value={selectedComponent.style?.labelPosition || "top"}
onValueChange={(value) => handleUpdate("style.labelPosition", value)}
>
<SelectTrigger className="h-6 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="top"></SelectItem>
<SelectItem value="bottom"></SelectItem>
<SelectItem value="left"></SelectItem>
<SelectItem value="right"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
value={
(selectedComponent.style?.labelPosition === "left" || selectedComponent.style?.labelPosition === "right")
? (selectedComponent.style?.labelGap || "8px")
: (selectedComponent.style?.labelMarginBottom || "4px")
}
onChange={(e) => {
const pos = selectedComponent.style?.labelPosition;
if (pos === "left" || pos === "right") {
handleUpdate("style.labelGap", e.target.value);
} else {
handleUpdate("style.labelMarginBottom", e.target.value);
}
}}
className="h-6 w-full px-2 py-0 text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"></Label>
@ -862,12 +900,21 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
value={selectedComponent.style?.labelMarginBottom || "4px"}
onChange={(e) => handleUpdate("style.labelMarginBottom", e.target.value)}
className="h-6 w-full px-2 py-0 text-xs"
/>
<Label className="text-xs"></Label>
<Select
value={selectedComponent.style?.labelFontWeight || "500"}
onValueChange={(value) => handleUpdate("style.labelFontWeight", value)}
>
<SelectTrigger className="h-6 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="400"></SelectItem>
<SelectItem value="500"></SelectItem>
<SelectItem value="600"></SelectItem>
<SelectItem value="700"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2 pt-5">
<Checkbox

View File

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

View File

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

View File

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

View File

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

View File

@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-[999] bg-black/80",
"fixed inset-0 z-[1050] bg-black/80",
className,
)}
{...props}
@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"bg-background fixed top-[50%] left-[50%] z-[1000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg",
"bg-background fixed top-[50%] left-[50%] z-[1100] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg",
className,
)}
{...props}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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