merge: origin/main 최신 변경사항 병합

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
shin 2026-03-10 18:32:50 +09:00
commit afeca4fa00
121 changed files with 17424 additions and 7729 deletions

View File

@ -138,6 +138,7 @@ import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리
import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준 import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준
import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트 API 프록시 (같은 포트로 서비스) import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트 API 프록시 (같은 포트로 서비스)
import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력 import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력
import moldRoutes from "./routes/moldRoutes"; // 금형 관리
import { BatchSchedulerService } from "./services/batchSchedulerService"; import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@ -326,6 +327,7 @@ app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카
app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트) app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트)
app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준 app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준
app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력 app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력
app.use("/api/mold", moldRoutes); // 금형 관리
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리

View File

@ -3563,29 +3563,36 @@ export async function getTableSchema(
logger.info("테이블 스키마 조회", { tableName, companyCode }); logger.info("테이블 스키마 조회", { tableName, companyCode });
// information_schema와 table_type_columns를 JOIN하여 컬럼 정보와 라벨 정보 함께 가져오기 // information_schema와 table_type_columns를 JOIN하여 컬럼 정보 + 회사별 제약조건 함께 가져오기
// 회사별 설정 우선, 없으면 공통(*) 설정 사용
const schemaQuery = ` const schemaQuery = `
SELECT SELECT
ic.column_name, ic.column_name,
ic.data_type, ic.data_type,
ic.is_nullable, ic.is_nullable AS db_is_nullable,
ic.column_default, ic.column_default,
ic.character_maximum_length, ic.character_maximum_length,
ic.numeric_precision, ic.numeric_precision,
ic.numeric_scale, ic.numeric_scale,
ttc.column_label, COALESCE(ttc_company.column_label, ttc_common.column_label) AS column_label,
ttc.display_order COALESCE(ttc_company.display_order, ttc_common.display_order) AS display_order,
COALESCE(ttc_company.is_nullable, ttc_common.is_nullable) AS ttc_is_nullable,
COALESCE(ttc_company.is_unique, ttc_common.is_unique) AS ttc_is_unique
FROM information_schema.columns ic FROM information_schema.columns ic
LEFT JOIN table_type_columns ttc LEFT JOIN table_type_columns ttc_common
ON ttc.table_name = ic.table_name ON ttc_common.table_name = ic.table_name
AND ttc.column_name = ic.column_name AND ttc_common.column_name = ic.column_name
AND ttc.company_code = '*' AND ttc_common.company_code = '*'
LEFT JOIN table_type_columns ttc_company
ON ttc_company.table_name = ic.table_name
AND ttc_company.column_name = ic.column_name
AND ttc_company.company_code = $2
WHERE ic.table_schema = 'public' WHERE ic.table_schema = 'public'
AND ic.table_name = $1 AND ic.table_name = $1
ORDER BY COALESCE(ttc.display_order, ic.ordinal_position), ic.ordinal_position ORDER BY COALESCE(ttc_company.display_order, ttc_common.display_order, ic.ordinal_position), ic.ordinal_position
`; `;
const columns = await query<any>(schemaQuery, [tableName]); const columns = await query<any>(schemaQuery, [tableName, companyCode]);
if (columns.length === 0) { if (columns.length === 0) {
res.status(404).json({ res.status(404).json({
@ -3595,17 +3602,28 @@ export async function getTableSchema(
return; return;
} }
// 컬럼 정보를 간단한 형태로 변환 (라벨 정보 포함) // 컬럼 정보를 간단한 형태로 변환 (회사별 제약조건 반영)
const columnList = columns.map((col: any) => ({ const columnList = columns.map((col: any) => {
name: col.column_name, // DB level nullable + 회사별 table_type_columns 제약조건 통합
label: col.column_label || col.column_name, // 라벨이 없으면 컬럼명 사용 // table_type_columns에서 is_nullable = 'N'이면 필수 (DB가 nullable이어도)
type: col.data_type, const dbNullable = col.db_is_nullable === "YES";
nullable: col.is_nullable === "YES", const ttcNotNull = col.ttc_is_nullable === "N";
default: col.column_default, const effectiveNullable = ttcNotNull ? false : dbNullable;
maxLength: col.character_maximum_length,
precision: col.numeric_precision, const ttcUnique = col.ttc_is_unique === "Y";
scale: col.numeric_scale,
})); return {
name: col.column_name,
label: col.column_label || col.column_name,
type: col.data_type,
nullable: effectiveNullable,
unique: ttcUnique,
default: col.column_default,
maxLength: col.character_maximum_length,
precision: col.numeric_precision,
scale: col.numeric_scale,
};
});
logger.info(`테이블 스키마 조회 성공: ${columnList.length}개 컬럼`); logger.info(`테이블 스키마 조회 성공: ${columnList.length}개 컬럼`);

View File

@ -818,13 +818,13 @@ export const getCategoryValueCascadingParentOptions = async (
const group = groupResult.rows[0]; const group = groupResult.rows[0];
// 부모 카테고리 값 조회 (table_column_category_values에서) // 부모 카테고리 값 조회 (category_values에서)
let optionsQuery = ` let optionsQuery = `
SELECT SELECT
value_code as value, value_code as value,
value_label as label, value_label as label,
value_order as display_order value_order as display_order
FROM table_column_category_values FROM category_values
WHERE table_name = $1 WHERE table_name = $1
AND column_name = $2 AND column_name = $2
AND is_active = true AND is_active = true
@ -916,13 +916,13 @@ export const getCategoryValueCascadingChildOptions = async (
const group = groupResult.rows[0]; const group = groupResult.rows[0];
// 자식 카테고리 값 조회 (table_column_category_values에서) // 자식 카테고리 값 조회 (category_values에서)
let optionsQuery = ` let optionsQuery = `
SELECT SELECT
value_code as value, value_code as value,
value_label as label, value_label as label,
value_order as display_order value_order as display_order
FROM table_column_category_values FROM category_values
WHERE table_name = $1 WHERE table_name = $1
AND column_name = $2 AND column_name = $2
AND is_active = true AND is_active = true

View File

@ -8,6 +8,7 @@ import { Request, Response } from "express";
import { AuthenticatedRequest } from "../types/auth"; import { AuthenticatedRequest } from "../types/auth";
import { query } from "../database/db"; import { query } from "../database/db";
import logger from "../utils/logger"; import logger from "../utils/logger";
import { TableManagementService } from "../services/tableManagementService";
/** /**
* *
@ -81,6 +82,19 @@ async function executeMainDatabaseAction(
company_code: companyCode, company_code: companyCode,
}; };
// UNIQUE 제약조건 검증 (INSERT/UPDATE/UPSERT 전)
if (["insert", "update", "upsert"].includes(actionType.toLowerCase())) {
const tms = new TableManagementService();
const uniqueViolations = await tms.validateUniqueConstraints(
tableName,
dataWithCompany,
companyCode
);
if (uniqueViolations.length > 0) {
throw new Error(`중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`);
}
}
switch (actionType.toLowerCase()) { switch (actionType.toLowerCase()) {
case "insert": case "insert":
return await executeInsert(tableName, dataWithCompany); return await executeInsert(tableName, dataWithCompany);

View File

@ -1,7 +1,9 @@
import { Response } from "express"; import { Response } from "express";
import { dynamicFormService } from "../services/dynamicFormService"; import { dynamicFormService } from "../services/dynamicFormService";
import { enhancedDynamicFormService } from "../services/enhancedDynamicFormService"; import { enhancedDynamicFormService } from "../services/enhancedDynamicFormService";
import { TableManagementService } from "../services/tableManagementService";
import { AuthenticatedRequest } from "../types/auth"; import { AuthenticatedRequest } from "../types/auth";
import { formatPgError } from "../utils/pgErrorUtil";
// 폼 데이터 저장 (기존 버전 - 레거시 지원) // 폼 데이터 저장 (기존 버전 - 레거시 지원)
export const saveFormData = async ( export const saveFormData = async (
@ -47,6 +49,21 @@ export const saveFormData = async (
formDataWithMeta.company_code = companyCode; formDataWithMeta.company_code = companyCode;
} }
// UNIQUE 제약조건 검증 (INSERT 전)
const tms = new TableManagementService();
const uniqueViolations = await tms.validateUniqueConstraints(
tableName,
formDataWithMeta,
companyCode || "*"
);
if (uniqueViolations.length > 0) {
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
});
return;
}
// 클라이언트 IP 주소 추출 // 클라이언트 IP 주소 추출
const ipAddress = const ipAddress =
req.ip || req.ip ||
@ -68,9 +85,12 @@ export const saveFormData = async (
}); });
} catch (error: any) { } catch (error: any) {
console.error("❌ 폼 데이터 저장 실패:", error); console.error("❌ 폼 데이터 저장 실패:", error);
res.status(500).json({ const { companyCode } = req.user as any;
const friendlyMsg = await formatPgError(error, companyCode);
const statusCode = error.code?.startsWith("23") ? 400 : 500;
res.status(statusCode).json({
success: false, success: false,
message: error.message || "데이터 저장에 실패했습니다.", message: friendlyMsg,
}); });
} }
}; };
@ -108,6 +128,21 @@ export const saveFormDataEnhanced = async (
formDataWithMeta.company_code = companyCode; formDataWithMeta.company_code = companyCode;
} }
// UNIQUE 제약조건 검증 (INSERT 전)
const tmsEnhanced = new TableManagementService();
const uniqueViolations = await tmsEnhanced.validateUniqueConstraints(
tableName,
formDataWithMeta,
companyCode || "*"
);
if (uniqueViolations.length > 0) {
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
});
return;
}
// 개선된 서비스 사용 // 개선된 서비스 사용
const result = await enhancedDynamicFormService.saveFormData( const result = await enhancedDynamicFormService.saveFormData(
screenId, screenId,
@ -118,9 +153,12 @@ export const saveFormDataEnhanced = async (
res.json(result); res.json(result);
} catch (error: any) { } catch (error: any) {
console.error("❌ 개선된 폼 데이터 저장 실패:", error); console.error("❌ 개선된 폼 데이터 저장 실패:", error);
res.status(500).json({ const { companyCode } = req.user as any;
const friendlyMsg = await formatPgError(error, companyCode);
const statusCode = error.code?.startsWith("23") ? 400 : 500;
res.status(statusCode).json({
success: false, success: false,
message: error.message || "데이터 저장에 실패했습니다.", message: friendlyMsg,
}); });
} }
}; };
@ -146,12 +184,28 @@ export const updateFormData = async (
const formDataWithMeta = { const formDataWithMeta = {
...data, ...data,
updated_by: userId, updated_by: userId,
writer: data.writer || userId, // ✅ writer가 없으면 userId로 설정 writer: data.writer || userId,
updated_at: new Date(), updated_at: new Date(),
}; };
// UNIQUE 제약조건 검증 (UPDATE 시 자기 자신 제외)
const tmsUpdate = new TableManagementService();
const uniqueViolations = await tmsUpdate.validateUniqueConstraints(
tableName,
formDataWithMeta,
companyCode || "*",
id
);
if (uniqueViolations.length > 0) {
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
});
return;
}
const result = await dynamicFormService.updateFormData( const result = await dynamicFormService.updateFormData(
id, // parseInt 제거 - 문자열 ID 지원 id,
tableName, tableName,
formDataWithMeta formDataWithMeta
); );
@ -163,9 +217,12 @@ export const updateFormData = async (
}); });
} catch (error: any) { } catch (error: any) {
console.error("❌ 폼 데이터 업데이트 실패:", error); console.error("❌ 폼 데이터 업데이트 실패:", error);
res.status(500).json({ const { companyCode } = req.user as any;
const friendlyMsg = await formatPgError(error, companyCode);
const statusCode = error.code?.startsWith("23") ? 400 : 500;
res.status(statusCode).json({
success: false, success: false,
message: error.message || "데이터 업데이트에 실패했습니다.", message: friendlyMsg,
}); });
} }
}; };
@ -199,11 +256,27 @@ export const updateFormDataPartial = async (
const newDataWithMeta = { const newDataWithMeta = {
...newData, ...newData,
updated_by: userId, updated_by: userId,
writer: newData.writer || userId, // ✅ writer가 없으면 userId로 설정 writer: newData.writer || userId,
}; };
// UNIQUE 제약조건 검증 (부분 UPDATE 시 자기 자신 제외)
const tmsPartial = new TableManagementService();
const uniqueViolations = await tmsPartial.validateUniqueConstraints(
tableName,
newDataWithMeta,
companyCode || "*",
id
);
if (uniqueViolations.length > 0) {
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
});
return;
}
const result = await dynamicFormService.updateFormDataPartial( const result = await dynamicFormService.updateFormDataPartial(
id, // 🔧 parseInt 제거 - UUID 문자열도 지원 id,
tableName, tableName,
originalData, originalData,
newDataWithMeta newDataWithMeta
@ -216,9 +289,12 @@ export const updateFormDataPartial = async (
}); });
} catch (error: any) { } catch (error: any) {
console.error("❌ 부분 업데이트 실패:", error); console.error("❌ 부분 업데이트 실패:", error);
res.status(500).json({ const { companyCode } = req.user as any;
const friendlyMsg = await formatPgError(error, companyCode);
const statusCode = error.code?.startsWith("23") ? 400 : 500;
res.status(statusCode).json({
success: false, success: false,
message: error.message || "부분 업데이트에 실패했습니다.", message: friendlyMsg,
}); });
} }
}; };

View File

@ -417,10 +417,10 @@ export class EntityJoinController {
// 1. 현재 테이블의 Entity 조인 설정 조회 // 1. 현재 테이블의 Entity 조인 설정 조회
const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName, undefined, companyCode); const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName, undefined, companyCode);
// 🆕 화면 디자이너용: table_column_category_values는 카테고리 드롭다운용이므로 제외 // 🆕 화면 디자이너용: category_values는 카테고리 드롭다운용이므로 제외
// 카테고리 값은 엔티티 조인 컬럼이 아니라 셀렉트박스 옵션으로 사용됨 // 카테고리 값은 엔티티 조인 컬럼이 아니라 셀렉트박스 옵션으로 사용됨
const joinConfigs = allJoinConfigs.filter( const joinConfigs = allJoinConfigs.filter(
(config) => config.referenceTable !== "table_column_category_values" (config) => config.referenceTable !== "category_values"
); );
if (joinConfigs.length === 0) { if (joinConfigs.length === 0) {

View File

@ -181,20 +181,92 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
? `WHERE ${whereConditions.join(" AND ")}` ? `WHERE ${whereConditions.join(" AND ")}`
: ""; : "";
// DISTINCT 쿼리 실행 // 1단계: DISTINCT 값 조회
const query = ` const distinctQuery = `
SELECT DISTINCT "${columnName}" as value, "${effectiveLabelColumn}" as label SELECT DISTINCT "${columnName}" as value, "${effectiveLabelColumn}" as label
FROM "${tableName}" FROM "${tableName}"
${whereClause} ${whereClause}
ORDER BY "${effectiveLabelColumn}" ASC ORDER BY "${effectiveLabelColumn}" ASC
LIMIT 500 LIMIT 500
`; `;
const result = await pool.query(distinctQuery, params);
const result = await pool.query(query, params); // 2단계: 카테고리/코드 라벨 변환 (값이 있을 때만)
if (result.rows.length > 0) {
const rawValues = result.rows.map((r: any) => r.value);
const labelMap: Record<string, string> = {};
// category_values에서 라벨 조회
try {
const cvCompanyCondition = companyCode !== "*"
? `AND (company_code = $4 OR company_code = '*')`
: "";
const cvParams = companyCode !== "*"
? [tableName, columnName, rawValues, companyCode]
: [tableName, columnName, rawValues];
const cvResult = await pool.query(
`SELECT value_code, value_label FROM category_values
WHERE table_name = $1 AND column_name = $2
AND value_code = ANY($3) AND is_active = true
${cvCompanyCondition}`,
cvParams
);
cvResult.rows.forEach((r: any) => {
labelMap[r.value_code] = r.value_label;
});
} catch (e) {
// category_values 조회 실패 시 무시
}
// code_info에서 라벨 조회 (code_category 기반)
try {
const ttcResult = await pool.query(
`SELECT code_category FROM table_type_columns
WHERE table_name = $1 AND column_name = $2 AND code_category IS NOT NULL
LIMIT 1`,
[tableName, columnName]
);
const codeCategory = ttcResult.rows[0]?.code_category;
if (codeCategory) {
const ciCompanyCondition = companyCode !== "*"
? `AND (company_code = $3 OR company_code = '*')`
: "";
const ciParams = companyCode !== "*"
? [codeCategory, rawValues, companyCode]
: [codeCategory, rawValues];
const ciResult = await pool.query(
`SELECT code_value, code_name FROM code_info
WHERE code_category = $1 AND code_value = ANY($2) AND is_active = 'Y'
${ciCompanyCondition}`,
ciParams
);
ciResult.rows.forEach((r: any) => {
if (!labelMap[r.code_value]) {
labelMap[r.code_value] = r.code_name;
}
});
}
} catch (e) {
// code_info 조회 실패 시 무시
}
// 라벨 매핑 적용
if (Object.keys(labelMap).length > 0) {
result.rows.forEach((row: any) => {
if (labelMap[row.value]) {
row.label = labelMap[row.value];
}
});
}
}
logger.info("컬럼 DISTINCT 값 조회 성공", { logger.info("컬럼 DISTINCT 값 조회 성공", {
tableName, tableName,
columnName, columnName,
columnInputType: columnInputType || "none",
labelColumn: effectiveLabelColumn, labelColumn: effectiveLabelColumn,
companyCode, companyCode,
hasFilters: !!filtersParam, hasFilters: !!filtersParam,

View File

@ -0,0 +1,497 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query } from "../database/db";
import { logger } from "../utils/logger";
// ============================================
// 금형 마스터 CRUD
// ============================================
export async function getMoldList(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { mold_code, mold_name, mold_type, operation_status } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (companyCode === "*") {
// 최고 관리자: 전체 조회
} else {
conditions.push(`company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
if (mold_code) {
conditions.push(`mold_code ILIKE $${paramIndex}`);
params.push(`%${mold_code}%`);
paramIndex++;
}
if (mold_name) {
conditions.push(`mold_name ILIKE $${paramIndex}`);
params.push(`%${mold_name}%`);
paramIndex++;
}
if (mold_type) {
conditions.push(`mold_type = $${paramIndex}`);
params.push(mold_type);
paramIndex++;
}
if (operation_status) {
conditions.push(`operation_status = $${paramIndex}`);
params.push(operation_status);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const sql = `SELECT * FROM mold_mng ${whereClause} ORDER BY created_date DESC`;
const result = await query(sql, params);
logger.info("금형 목록 조회", { companyCode, count: result.length });
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("금형 목록 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function getMoldDetail(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { moldCode } = req.params;
let sql: string;
let params: any[];
if (companyCode === "*") {
sql = `SELECT * FROM mold_mng WHERE mold_code = $1 LIMIT 1`;
params = [moldCode];
} else {
sql = `SELECT * FROM mold_mng WHERE mold_code = $1 AND company_code = $2 LIMIT 1`;
params = [moldCode, companyCode];
}
const result = await query(sql, params);
if (result.length === 0) {
res.status(404).json({ success: false, message: "금형을 찾을 수 없습니다." });
return;
}
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("금형 상세 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function createMold(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const {
mold_code, mold_name, mold_type, category, manufacturer,
manufacturing_number, manufacturing_date, cavity_count,
shot_count, mold_quantity, base_input_qty, operation_status,
remarks, image_path, memo,
} = req.body;
if (!mold_code || !mold_name) {
res.status(400).json({ success: false, message: "금형코드와 금형명은 필수입니다." });
return;
}
const sql = `
INSERT INTO mold_mng (
company_code, mold_code, mold_name, mold_type, category,
manufacturer, manufacturing_number, manufacturing_date,
cavity_count, shot_count, mold_quantity, base_input_qty,
operation_status, remarks, image_path, memo, writer
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
RETURNING *
`;
const params = [
companyCode, mold_code, mold_name, mold_type || null, category || null,
manufacturer || null, manufacturing_number || null, manufacturing_date || null,
cavity_count || 0, shot_count || 0, mold_quantity || 1, base_input_qty || 0,
operation_status || "ACTIVE", remarks || null, image_path || null, memo || null, userId,
];
const result = await query(sql, params);
logger.info("금형 생성", { companyCode, moldCode: mold_code });
res.json({ success: true, data: result[0], message: "금형이 등록되었습니다." });
} catch (error: any) {
if (error.code === "23505") {
res.status(409).json({ success: false, message: "이미 존재하는 금형코드입니다." });
return;
}
logger.error("금형 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function updateMold(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { moldCode } = req.params;
const {
mold_name, mold_type, category, manufacturer,
manufacturing_number, manufacturing_date, cavity_count,
shot_count, mold_quantity, base_input_qty, operation_status,
remarks, image_path, memo,
} = req.body;
const sql = `
UPDATE mold_mng SET
mold_name = COALESCE($1, mold_name),
mold_type = $2, category = $3, manufacturer = $4,
manufacturing_number = $5, manufacturing_date = $6,
cavity_count = COALESCE($7, cavity_count),
shot_count = COALESCE($8, shot_count),
mold_quantity = COALESCE($9, mold_quantity),
base_input_qty = COALESCE($10, base_input_qty),
operation_status = COALESCE($11, operation_status),
remarks = $12, image_path = $13, memo = $14,
updated_date = NOW()
WHERE mold_code = $15 AND company_code = $16
RETURNING *
`;
const params = [
mold_name, mold_type, category, manufacturer,
manufacturing_number, manufacturing_date,
cavity_count, shot_count, mold_quantity, base_input_qty,
operation_status, remarks, image_path, memo,
moldCode, companyCode,
];
const result = await query(sql, params);
if (result.length === 0) {
res.status(404).json({ success: false, message: "금형을 찾을 수 없습니다." });
return;
}
logger.info("금형 수정", { companyCode, moldCode });
res.json({ success: true, data: result[0], message: "금형이 수정되었습니다." });
} catch (error: any) {
logger.error("금형 수정 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteMold(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { moldCode } = req.params;
// 관련 데이터 먼저 삭제
await query(`DELETE FROM mold_serial WHERE mold_code = $1 AND company_code = $2`, [moldCode, companyCode]);
await query(`DELETE FROM mold_inspection_item WHERE mold_code = $1 AND company_code = $2`, [moldCode, companyCode]);
await query(`DELETE FROM mold_part WHERE mold_code = $1 AND company_code = $2`, [moldCode, companyCode]);
const result = await query(
`DELETE FROM mold_mng WHERE mold_code = $1 AND company_code = $2 RETURNING id`,
[moldCode, companyCode]
);
if (result.length === 0) {
res.status(404).json({ success: false, message: "금형을 찾을 수 없습니다." });
return;
}
logger.info("금형 삭제", { companyCode, moldCode });
res.json({ success: true, message: "금형이 삭제되었습니다." });
} catch (error: any) {
logger.error("금형 삭제 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 일련번호 CRUD
// ============================================
export async function getMoldSerials(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { moldCode } = req.params;
const sql = `SELECT * FROM mold_serial WHERE mold_code = $1 AND company_code = $2 ORDER BY serial_number`;
const result = await query(sql, [moldCode, companyCode]);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("일련번호 목록 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function createMoldSerial(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { moldCode } = req.params;
const { serial_number, status, progress, work_description, manager, completion_date, remarks } = req.body;
let finalSerialNumber = serial_number;
// 일련번호가 비어있으면 채번 규칙으로 자동 생성
if (!finalSerialNumber) {
try {
const { numberingRuleService } = await import("../services/numberingRuleService");
const rule = await numberingRuleService.getNumberingRuleByColumn(
companyCode,
"mold_serial",
"serial_number"
);
if (rule) {
// formData에 mold_code를 포함 (reference 파트에서 참조)
const formData = { mold_code: moldCode, ...req.body };
finalSerialNumber = await numberingRuleService.allocateCode(
rule.ruleId,
companyCode,
formData
);
logger.info("일련번호 자동 채번 완료", { serialNumber: finalSerialNumber, ruleId: rule.ruleId });
}
} catch (numError: any) {
logger.error("일련번호 자동 채번 실패", { error: numError.message });
}
}
if (!finalSerialNumber) {
res.status(400).json({ success: false, message: "일련번호를 생성할 수 없습니다. 채번 규칙을 확인해주세요." });
return;
}
const sql = `
INSERT INTO mold_serial (company_code, mold_code, serial_number, status, progress, work_description, manager, completion_date, remarks, writer)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
RETURNING *
`;
const params = [
companyCode, moldCode, finalSerialNumber, status || "STORED",
progress || 0, work_description || null, manager || null,
completion_date || null, remarks || null, userId,
];
const result = await query(sql, params);
res.json({ success: true, data: result[0], message: "일련번호가 등록되었습니다." });
} catch (error: any) {
if (error.code === "23505") {
res.status(409).json({ success: false, message: "이미 존재하는 일련번호입니다." });
return;
}
logger.error("일련번호 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteMoldSerial(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const result = await query(
`DELETE FROM mold_serial WHERE id = $1 AND company_code = $2 RETURNING id`,
[id, companyCode]
);
if (result.length === 0) {
res.status(404).json({ success: false, message: "일련번호를 찾을 수 없습니다." });
return;
}
res.json({ success: true, message: "일련번호가 삭제되었습니다." });
} catch (error: any) {
logger.error("일련번호 삭제 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 점검항목 CRUD
// ============================================
export async function getMoldInspections(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { moldCode } = req.params;
const sql = `SELECT * FROM mold_inspection_item WHERE mold_code = $1 AND company_code = $2 ORDER BY created_date`;
const result = await query(sql, [moldCode, companyCode]);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("점검항목 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function createMoldInspection(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { moldCode } = req.params;
const {
inspection_item, inspection_cycle, inspection_method,
inspection_content, lower_limit, upper_limit, unit,
is_active, checklist, remarks,
} = req.body;
if (!inspection_item) {
res.status(400).json({ success: false, message: "점검항목명은 필수입니다." });
return;
}
const sql = `
INSERT INTO mold_inspection_item (
company_code, mold_code, inspection_item, inspection_cycle,
inspection_method, inspection_content, lower_limit, upper_limit,
unit, is_active, checklist, remarks, writer
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
RETURNING *
`;
const params = [
companyCode, moldCode, inspection_item, inspection_cycle || null,
inspection_method || null, inspection_content || null,
lower_limit || null, upper_limit || null, unit || null,
is_active || "Y", checklist || null, remarks || null, userId,
];
const result = await query(sql, params);
res.json({ success: true, data: result[0], message: "점검항목이 등록되었습니다." });
} catch (error: any) {
logger.error("점검항목 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteMoldInspection(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const result = await query(
`DELETE FROM mold_inspection_item WHERE id = $1 AND company_code = $2 RETURNING id`,
[id, companyCode]
);
if (result.length === 0) {
res.status(404).json({ success: false, message: "점검항목을 찾을 수 없습니다." });
return;
}
res.json({ success: true, message: "점검항목이 삭제되었습니다." });
} catch (error: any) {
logger.error("점검항목 삭제 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 부품 CRUD
// ============================================
export async function getMoldParts(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { moldCode } = req.params;
const sql = `SELECT * FROM mold_part WHERE mold_code = $1 AND company_code = $2 ORDER BY created_date`;
const result = await query(sql, [moldCode, companyCode]);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("부품 목록 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function createMoldPart(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { moldCode } = req.params;
const {
part_name, replacement_cycle, unit, specification,
manufacturer, manufacturer_code, image_path, remarks,
} = req.body;
if (!part_name) {
res.status(400).json({ success: false, message: "부품명은 필수입니다." });
return;
}
const sql = `
INSERT INTO mold_part (
company_code, mold_code, part_name, replacement_cycle,
unit, specification, manufacturer, manufacturer_code,
image_path, remarks, writer
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
RETURNING *
`;
const params = [
companyCode, moldCode, part_name, replacement_cycle || null,
unit || null, specification || null, manufacturer || null,
manufacturer_code || null, image_path || null, remarks || null, userId,
];
const result = await query(sql, params);
res.json({ success: true, data: result[0], message: "부품이 등록되었습니다." });
} catch (error: any) {
logger.error("부품 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteMoldPart(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const result = await query(
`DELETE FROM mold_part WHERE id = $1 AND company_code = $2 RETURNING id`,
[id, companyCode]
);
if (result.length === 0) {
res.status(404).json({ success: false, message: "부품을 찾을 수 없습니다." });
return;
}
res.json({ success: true, message: "부품이 삭제되었습니다." });
} catch (error: any) {
logger.error("부품 삭제 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 일련번호 현황 집계
// ============================================
export async function getMoldSerialSummary(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { moldCode } = req.params;
const sql = `
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE status = 'IN_USE') as in_use,
COUNT(*) FILTER (WHERE status = 'REPAIR') as repair,
COUNT(*) FILTER (WHERE status = 'STORED') as stored,
COUNT(*) FILTER (WHERE status = 'DISPOSED') as disposed
FROM mold_serial
WHERE mold_code = $1 AND company_code = $2
`;
const result = await query(sql, [moldCode, companyCode]);
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("일련번호 현황 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}

View File

@ -405,6 +405,30 @@ router.post(
} }
); );
// 테이블+컬럼 기반 채번 규칙 조회 (메인 API)
router.get(
"/by-column/:tableName/:columnName",
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { tableName, columnName } = req.params;
try {
const rule = await numberingRuleService.getNumberingRuleByColumn(
companyCode,
tableName,
columnName
);
return res.json({ success: true, data: rule });
} catch (error: any) {
logger.error("테이블+컬럼 기반 채번 규칙 조회 실패", {
error: error.message,
});
return res.status(500).json({ success: false, error: error.message });
}
}
);
// ==================== 테스트 테이블용 API ==================== // ==================== 테스트 테이블용 API ====================
// [테스트] 테스트 테이블에서 채번 규칙 목록 조회 // [테스트] 테스트 테이블에서 채번 규칙 목록 조회

View File

@ -2087,6 +2087,23 @@ export async function multiTableSave(
return; return;
} }
// UNIQUE 제약조건 검증 (트랜잭션 전에)
const tmsMulti = new TableManagementService();
const uniqueViolations = await tmsMulti.validateUniqueConstraints(
mainTable.tableName,
mainData,
companyCode,
isUpdate ? mainData[mainTable.primaryKeyColumn] : undefined
);
if (uniqueViolations.length > 0) {
client.release();
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
});
return;
}
await client.query("BEGIN"); await client.query("BEGIN");
// 1. 메인 테이블 저장 // 1. 메인 테이블 저장
@ -3019,3 +3036,72 @@ export async function toggleColumnUnique(
}); });
} }
} }
/**
* ( )
*
* @route GET /api/table-management/numbering-columns
*/
export async function getNumberingColumnsByCompany(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user?.companyCode;
logger.info("회사별 채번 컬럼 조회 요청", { companyCode });
if (!companyCode) {
res.status(400).json({
success: false,
message: "회사 코드를 확인할 수 없습니다.",
});
return;
}
const { getPool } = await import("../database/db");
const pool = getPool();
const targetCompanyCode = companyCode === "*" ? "*" : companyCode;
const columnsQuery = `
SELECT DISTINCT
ttc.table_name AS "tableName",
COALESCE(
tl.table_label,
initcap(replace(ttc.table_name, '_', ' '))
) AS "tableLabel",
ttc.column_name AS "columnName",
COALESCE(
ttc.column_label,
initcap(replace(ttc.column_name, '_', ' '))
) AS "columnLabel",
ttc.input_type AS "inputType"
FROM table_type_columns ttc
LEFT JOIN table_labels tl
ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'numbering'
AND ttc.company_code = $1
ORDER BY ttc.table_name, ttc.column_name
`;
const columnsResult = await pool.query(columnsQuery, [targetCompanyCode]);
logger.info("채번 컬럼 조회 완료", {
companyCode,
rowCount: columnsResult.rows.length,
});
res.json({
success: true,
data: columnsResult.rows,
});
} catch (error: any) {
logger.error("채번 컬럼 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "채번 컬럼 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}

View File

@ -41,13 +41,27 @@ export const errorHandler = (
// PostgreSQL 에러 코드 참조: https://www.postgresql.org/docs/current/errcodes-appendix.html // PostgreSQL 에러 코드 참조: https://www.postgresql.org/docs/current/errcodes-appendix.html
if (pgError.code === "23505") { if (pgError.code === "23505") {
// unique_violation // unique_violation
error = new AppError("중복된 데이터가 존재합니다.", 400); const constraint = pgError.constraint || "";
const tbl = pgError.table || "";
let col = "";
if (constraint && tbl) {
const prefix = `${tbl}_`;
const suffix = "_key";
if (constraint.startsWith(prefix) && constraint.endsWith(suffix)) {
col = constraint.slice(prefix.length, -suffix.length);
}
}
const detail = col ? ` [${col}]` : "";
error = new AppError(`중복된 데이터가 존재합니다.${detail}`, 400);
} else if (pgError.code === "23503") { } else if (pgError.code === "23503") {
// foreign_key_violation // foreign_key_violation
error = new AppError("참조 무결성 제약 조건 위반입니다.", 400); error = new AppError("참조 무결성 제약 조건 위반입니다.", 400);
} else if (pgError.code === "23502") { } else if (pgError.code === "23502") {
// not_null_violation // not_null_violation
error = new AppError("필수 입력값이 누락되었습니다.", 400); const colName = pgError.column || "";
const tableName = pgError.table || "";
const detail = colName ? ` [${tableName}.${colName}]` : "";
error = new AppError(`필수 입력값이 누락되었습니다.${detail}`, 400);
} else if (pgError.code.startsWith("23")) { } else if (pgError.code.startsWith("23")) {
// 기타 무결성 제약 조건 위반 // 기타 무결성 제약 조건 위반
error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400); error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400);
@ -84,6 +98,7 @@ export const errorHandler = (
// 응답 전송 // 응답 전송
res.status(statusCode).json({ res.status(statusCode).json({
success: false, success: false,
message: message,
error: { error: {
message: message, message: message,
...(process.env.NODE_ENV === "development" && { stack: error.stack }), ...(process.env.NODE_ENV === "development" && { stack: error.stack }),

View File

@ -5,6 +5,8 @@ import { multiTableExcelService, TableChainConfig } from "../services/multiTable
import { authenticateToken } from "../middleware/authMiddleware"; import { authenticateToken } from "../middleware/authMiddleware";
import { AuthenticatedRequest } from "../types/auth"; import { AuthenticatedRequest } from "../types/auth";
import { auditLogService } from "../services/auditLogService"; import { auditLogService } from "../services/auditLogService";
import { TableManagementService } from "../services/tableManagementService";
import { formatPgError } from "../utils/pgErrorUtil";
const router = express.Router(); const router = express.Router();
@ -950,6 +952,20 @@ router.post(
console.log(`🏢 company_name 자동 추가: ${req.user.companyName}`); console.log(`🏢 company_name 자동 추가: ${req.user.companyName}`);
} }
// UNIQUE 제약조건 검증
const tms = new TableManagementService();
const uniqueViolations = await tms.validateUniqueConstraints(
tableName,
enrichedData,
req.user?.companyCode || "*"
);
if (uniqueViolations.length > 0) {
return res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
});
}
// 레코드 생성 // 레코드 생성
const result = await dataService.createRecord(tableName, enrichedData); const result = await dataService.createRecord(tableName, enrichedData);
@ -1019,6 +1035,21 @@ router.put(
console.log(`✏️ 레코드 수정: ${tableName}/${id}`, data); console.log(`✏️ 레코드 수정: ${tableName}/${id}`, data);
// UNIQUE 제약조건 검증 (자기 자신 제외)
const tmsUpdate = new TableManagementService();
const uniqueViolations = await tmsUpdate.validateUniqueConstraints(
tableName,
data,
req.user?.companyCode || "*",
String(id)
);
if (uniqueViolations.length > 0) {
return res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
});
}
// 레코드 수정 // 레코드 수정
const result = await dataService.updateRecord(tableName, id, data); const result = await dataService.updateRecord(tableName, id, data);

View File

@ -0,0 +1,49 @@
import express from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import {
getMoldList,
getMoldDetail,
createMold,
updateMold,
deleteMold,
getMoldSerials,
createMoldSerial,
deleteMoldSerial,
getMoldInspections,
createMoldInspection,
deleteMoldInspection,
getMoldParts,
createMoldPart,
deleteMoldPart,
getMoldSerialSummary,
} from "../controllers/moldController";
const router = express.Router();
router.use(authenticateToken);
// 금형 마스터
router.get("/", getMoldList);
router.get("/:moldCode", getMoldDetail);
router.post("/", createMold);
router.put("/:moldCode", updateMold);
router.delete("/:moldCode", deleteMold);
// 일련번호
router.get("/:moldCode/serials", getMoldSerials);
router.post("/:moldCode/serials", createMoldSerial);
router.delete("/serials/:id", deleteMoldSerial);
// 일련번호 현황 집계
router.get("/:moldCode/serial-summary", getMoldSerialSummary);
// 점검항목
router.get("/:moldCode/inspections", getMoldInspections);
router.post("/:moldCode/inspections", createMoldInspection);
router.delete("/inspections/:id", deleteMoldInspection);
// 부품
router.get("/:moldCode/parts", getMoldParts);
router.post("/:moldCode/parts", createMoldPart);
router.delete("/parts/:id", deleteMoldPart);
export default router;

View File

@ -25,6 +25,7 @@ import {
toggleLogTable, toggleLogTable,
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회 getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
getCategoryColumnsByCompany, // 🆕 회사별 카테고리 컬럼 조회 getCategoryColumnsByCompany, // 🆕 회사별 카테고리 컬럼 조회
getNumberingColumnsByCompany, // 채번 타입 컬럼 조회
multiTableSave, // 🆕 범용 다중 테이블 저장 multiTableSave, // 🆕 범용 다중 테이블 저장
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회 getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회 getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회
@ -254,6 +255,12 @@ router.post("/tables/:tableName/log/toggle", toggleLogTable);
*/ */
router.get("/category-columns", getCategoryColumnsByCompany); router.get("/category-columns", getCategoryColumnsByCompany);
/**
*
* GET /api/table-management/numbering-columns
*/
router.get("/numbering-columns", getNumberingColumnsByCompany);
/** /**
* *
* GET /api/table-management/menu/:menuObjid/category-columns * GET /api/table-management/menu/:menuObjid/category-columns

View File

@ -92,7 +92,7 @@ export class EntityJoinService {
if (column.input_type === "category") { if (column.input_type === "category") {
// 카테고리 타입: reference 정보가 비어있어도 자동 설정 // 카테고리 타입: reference 정보가 비어있어도 자동 설정
referenceTable = referenceTable || "table_column_category_values"; referenceTable = referenceTable || "category_values";
referenceColumn = referenceColumn || "value_code"; referenceColumn = referenceColumn || "value_code";
displayColumn = displayColumn || "value_label"; displayColumn = displayColumn || "value_label";
@ -308,7 +308,7 @@ export class EntityJoinService {
const usedAliasesForColumns = new Set<string>(); const usedAliasesForColumns = new Set<string>();
// joinConfigs를 참조 테이블 + 소스 컬럼별로 중복 제거하여 별칭 생성 // joinConfigs를 참조 테이블 + 소스 컬럼별로 중복 제거하여 별칭 생성
// (table_column_category_values는 같은 테이블이라도 sourceColumn마다 별도 JOIN 필요) // (category_values는 같은 테이블이라도 sourceColumn마다 별도 JOIN 필요)
const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => { const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => {
if ( if (
!acc.some( !acc.some(
@ -336,7 +336,7 @@ export class EntityJoinService {
counter++; counter++;
} }
usedAliasesForColumns.add(alias); usedAliasesForColumns.add(alias);
// 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (table_column_category_values 대응) // 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (category_values 대응)
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
aliasMap.set(aliasKey, alias); aliasMap.set(aliasKey, alias);
logger.info( logger.info(
@ -455,9 +455,10 @@ export class EntityJoinService {
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
const alias = aliasMap.get(aliasKey); const alias = aliasMap.get(aliasKey);
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링) // category_values는 특별한 조인 조건 필요 (회사별 필터링)
if (config.referenceTable === "table_column_category_values") { // is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; if (config.referenceTable === "category_values") {
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code`;
} }
// user_info는 전역 테이블이므로 company_code 조건 없이 조인 // user_info는 전역 테이블이므로 company_code 조건 없이 조인
@ -528,10 +529,10 @@ export class EntityJoinService {
return "join"; return "join";
} }
// table_column_category_values는 특수 조인 조건이 필요하므로 캐시 불가 // category_values는 특수 조인 조건이 필요하므로 캐시 불가
if (config.referenceTable === "table_column_category_values") { if (config.referenceTable === "category_values") {
logger.info( logger.info(
`🎯 table_column_category_values는 캐시 전략 불가: ${config.sourceColumn}` `🎯 category_values는 캐시 전략 불가: ${config.sourceColumn}`
); );
return "join"; return "join";
} }
@ -723,10 +724,10 @@ export class EntityJoinService {
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
const alias = aliasMap.get(aliasKey); const alias = aliasMap.get(aliasKey);
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만) // category_values는 특별한 조인 조건 필요 (회사별 필터링만)
if (config.referenceTable === "table_column_category_values") { // is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외) if (config.referenceTable === "category_values") {
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code`;
} }
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT`; return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT`;

View File

@ -494,7 +494,7 @@ class MasterDetailExcelService {
/** /**
* , ID를 * , ID를
* , (*) fallback * numbering_rules table_name + column_name + company_code로
*/ */
private async detectNumberingRuleForColumn( private async detectNumberingRuleForColumn(
tableName: string, tableName: string,
@ -502,32 +502,58 @@ class MasterDetailExcelService {
companyCode?: string companyCode?: string
): Promise<{ numberingRuleId: string } | null> { ): Promise<{ numberingRuleId: string } | null> {
try { try {
// 회사별 설정 우선, 공통 설정 fallback (company_code DESC로 회사별이 먼저) // 1. table_type_columns에서 numbering 타입인지 확인
const companyCondition = companyCode && companyCode !== "*" const companyCondition = companyCode && companyCode !== "*"
? `AND company_code IN ($3, '*')` ? `AND company_code IN ($3, '*')`
: `AND company_code = '*'`; : `AND company_code = '*'`;
const params = companyCode && companyCode !== "*" const ttcParams = companyCode && companyCode !== "*"
? [tableName, columnName, companyCode] ? [tableName, columnName, companyCode]
: [tableName, columnName]; : [tableName, columnName];
const result = await query<any>( const ttcResult = await query<any>(
`SELECT input_type, detail_settings, company_code `SELECT input_type FROM table_type_columns
FROM table_type_columns
WHERE table_name = $1 AND column_name = $2 ${companyCondition} WHERE table_name = $1 AND column_name = $2 ${companyCondition}
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, AND input_type = 'numbering' LIMIT 1`,
params ttcParams
); );
// 채번 타입인 행 찾기 (회사별 우선) if (ttcResult.length === 0) return null;
for (const row of result) {
if (row.input_type === "numbering") {
const settings = typeof row.detail_settings === "string"
? JSON.parse(row.detail_settings || "{}")
: row.detail_settings;
if (settings?.numberingRuleId) { // 2. numbering_rules에서 table_name + column_name으로 규칙 조회
return { numberingRuleId: settings.numberingRuleId }; const ruleCompanyCondition = companyCode && companyCode !== "*"
} ? `AND company_code IN ($3, '*')`
: `AND company_code = '*'`;
const ruleParams = companyCode && companyCode !== "*"
? [tableName, columnName, companyCode]
: [tableName, columnName];
const ruleResult = await query<any>(
`SELECT rule_id FROM numbering_rules
WHERE table_name = $1 AND column_name = $2 ${ruleCompanyCondition}
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END
LIMIT 1`,
ruleParams
);
if (ruleResult.length > 0) {
return { numberingRuleId: ruleResult[0].rule_id };
}
// 3. fallback: detail_settings.numberingRuleId (하위 호환)
const fallbackResult = await query<any>(
`SELECT detail_settings FROM table_type_columns
WHERE table_name = $1 AND column_name = $2 ${companyCondition}
AND input_type = 'numbering'
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
ttcParams
);
for (const row of fallbackResult) {
const settings = typeof row.detail_settings === "string"
? JSON.parse(row.detail_settings || "{}")
: row.detail_settings;
if (settings?.numberingRuleId) {
return { numberingRuleId: settings.numberingRuleId };
} }
} }
@ -540,7 +566,7 @@ class MasterDetailExcelService {
/** /**
* *
* , (*) fallback * numbering_rules table_name + column_name으로
* @returns Map<columnName, numberingRuleId> * @returns Map<columnName, numberingRuleId>
*/ */
private async detectAllNumberingColumns( private async detectAllNumberingColumns(
@ -549,6 +575,7 @@ class MasterDetailExcelService {
): Promise<Map<string, string>> { ): Promise<Map<string, string>> {
const numberingCols = new Map<string, string>(); const numberingCols = new Map<string, string>();
try { try {
// 1. table_type_columns에서 numbering 타입 컬럼 목록 조회
const companyCondition = companyCode && companyCode !== "*" const companyCondition = companyCode && companyCode !== "*"
? `AND company_code IN ($2, '*')` ? `AND company_code IN ($2, '*')`
: `AND company_code = '*'`; : `AND company_code = '*'`;
@ -556,22 +583,26 @@ class MasterDetailExcelService {
? [tableName, companyCode] ? [tableName, companyCode]
: [tableName]; : [tableName];
const result = await query<any>( const ttcResult = await query<any>(
`SELECT column_name, detail_settings, company_code `SELECT DISTINCT column_name FROM table_type_columns
FROM table_type_columns WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition}`,
WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition}
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
params params
); );
// 컬럼별로 회사 설정 우선 적용 // 2. 각 컬럼에 대해 numbering_rules에서 규칙 조회
for (const row of result) { for (const row of ttcResult) {
if (numberingCols.has(row.column_name)) continue; // 이미 회사별 설정이 있으면 스킵 const ruleResult = await query<any>(
const settings = typeof row.detail_settings === "string" `SELECT rule_id FROM numbering_rules
? JSON.parse(row.detail_settings || "{}") WHERE table_name = $1 AND column_name = $2 ${companyCondition}
: row.detail_settings; ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END
if (settings?.numberingRuleId) { LIMIT 1`,
numberingCols.set(row.column_name, settings.numberingRuleId); companyCode && companyCode !== "*"
? [tableName, row.column_name, companyCode]
: [tableName, row.column_name]
);
if (ruleResult.length > 0) {
numberingCols.set(row.column_name, ruleResult[0].rule_id);
} }
} }

View File

@ -3098,7 +3098,7 @@ export class MenuCopyService {
} }
const allValuesResult = await client.query( const allValuesResult = await client.query(
`SELECT * FROM table_column_category_values `SELECT * FROM category_values
WHERE company_code = $1 WHERE company_code = $1
AND (${columnConditions.join(" OR ")}) AND (${columnConditions.join(" OR ")})
ORDER BY depth NULLS FIRST, parent_value_id NULLS FIRST, value_order`, ORDER BY depth NULLS FIRST, parent_value_id NULLS FIRST, value_order`,
@ -3115,7 +3115,7 @@ export class MenuCopyService {
// 5. 대상 회사에 이미 존재하는 값 한 번에 조회 // 5. 대상 회사에 이미 존재하는 값 한 번에 조회
const existingValuesResult = await client.query( const existingValuesResult = await client.query(
`SELECT value_id, table_name, column_name, value_code `SELECT value_id, table_name, column_name, value_code
FROM table_column_category_values WHERE company_code = $1`, FROM category_values WHERE company_code = $1`,
[targetCompanyCode] [targetCompanyCode]
); );
const existingValueKeys = new Map( const existingValueKeys = new Map(
@ -3194,7 +3194,7 @@ export class MenuCopyService {
}); });
const insertResult = await client.query( const insertResult = await client.query(
`INSERT INTO table_column_category_values ( `INSERT INTO category_values (
table_name, column_name, value_code, value_label, value_order, table_name, column_name, value_code, value_label, value_order,
parent_value_id, depth, description, color, icon, parent_value_id, depth, description, color, icon,
is_active, is_default, created_at, created_by, company_code, menu_objid is_active, is_default, created_at, created_by, company_code, menu_objid

View File

@ -970,10 +970,11 @@ class MultiTableExcelService {
const result = await pool.query( const result = await pool.query(
`SELECT `SELECT
c.column_name, c.column_name,
c.is_nullable, c.is_nullable AS db_is_nullable,
c.column_default, c.column_default,
COALESCE(ttc.column_label, cl.column_label) AS column_label, COALESCE(ttc.column_label, cl.column_label) AS column_label,
COALESCE(ttc.reference_table, cl.reference_table) AS reference_table COALESCE(ttc.reference_table, cl.reference_table) AS reference_table,
COALESCE(ttc.is_nullable, cl.is_nullable) AS ttc_is_nullable
FROM information_schema.columns c FROM information_schema.columns c
LEFT JOIN table_type_columns cl LEFT JOIN table_type_columns cl
ON c.table_name = cl.table_name AND c.column_name = cl.column_name AND cl.company_code = '*' ON c.table_name = cl.table_name AND c.column_name = cl.column_name AND cl.company_code = '*'
@ -991,13 +992,13 @@ class MultiTableExcelService {
// 시스템 컬럼 제외 // 시스템 컬럼 제외
if (MultiTableExcelService.SYSTEM_COLUMNS.has(colName)) continue; if (MultiTableExcelService.SYSTEM_COLUMNS.has(colName)) continue;
// FK 컬럼 제외 (reference_table이 있는 컬럼 = 다른 테이블의 PK를 참조) // FK 컬럼 제외
// 단, 비즈니스적으로 의미 있는 FK는 남길 수 있으므로,
// _id로 끝나면서 reference_table이 있는 경우만 제외
if (row.reference_table && colName.endsWith("_id")) continue; if (row.reference_table && colName.endsWith("_id")) continue;
const hasDefault = row.column_default !== null; const hasDefault = row.column_default !== null;
const isNullable = row.is_nullable === "YES"; const dbNullable = row.db_is_nullable === "YES";
const ttcNotNull = row.ttc_is_nullable === "N";
const isNullable = ttcNotNull ? false : dbNullable;
const isRequired = !isNullable && !hasDefault; const isRequired = !isNullable && !hasDefault;
columns.push({ columns.push({

View File

@ -172,6 +172,16 @@ class NumberingRuleService {
break; break;
} }
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
prefixParts.push(String(formData[refColumn]));
} else {
prefixParts.push("");
}
break;
}
default: default:
break; break;
} }
@ -1245,6 +1255,14 @@ class NumberingRuleService {
return ""; return "";
} }
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
return String(formData[refColumn]);
}
return "REF";
}
default: default:
logger.warn("알 수 없는 파트 타입", { partType: part.partType }); logger.warn("알 수 없는 파트 타입", { partType: part.partType });
return ""; return "";
@ -1375,6 +1393,13 @@ class NumberingRuleService {
return catMapping2?.format || "CATEGORY"; return catMapping2?.format || "CATEGORY";
} }
case "reference": {
const refCol2 = autoConfig.referenceColumnName;
if (refCol2 && formData && formData[refCol2]) {
return String(formData[refCol2]);
}
return "REF";
}
default: default:
return ""; return "";
} }
@ -1524,6 +1549,15 @@ class NumberingRuleService {
return ""; return "";
} }
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
return String(formData[refColumn]);
}
logger.warn("reference 파트: 참조 컬럼 값 없음", { refColumn, formDataKeys: formData ? Object.keys(formData) : [] });
return "";
}
default: default:
return ""; return "";
} }
@ -1747,7 +1781,53 @@ class NumberingRuleService {
`; `;
const params = [companyCode, tableName, columnName]; const params = [companyCode, tableName, columnName];
const result = await pool.query(query, params); let result = await pool.query(query, params);
// fallback: column_name이 비어있는 레거시 규칙 검색
if (result.rows.length === 0) {
const fallbackQuery = `
SELECT
r.rule_id AS "ruleId",
r.rule_name AS "ruleName",
r.description,
r.separator,
r.reset_period AS "resetPeriod",
r.current_sequence AS "currentSequence",
r.table_name AS "tableName",
r.column_name AS "columnName",
r.company_code AS "companyCode",
r.category_column AS "categoryColumn",
r.category_value_id AS "categoryValueId",
cv.value_label AS "categoryValueLabel",
r.created_at AS "createdAt",
r.updated_at AS "updatedAt",
r.created_by AS "createdBy"
FROM numbering_rules r
LEFT JOIN category_values cv ON r.category_value_id = cv.value_id
WHERE r.company_code = $1
AND r.table_name = $2
AND (r.column_name IS NULL OR r.column_name = '')
AND r.category_value_id IS NULL
ORDER BY r.updated_at DESC
LIMIT 1
`;
result = await pool.query(fallbackQuery, [companyCode, tableName]);
// 찾으면 column_name 자동 업데이트 (레거시 데이터 마이그레이션)
if (result.rows.length > 0) {
const foundRule = result.rows[0];
await pool.query(
`UPDATE numbering_rules SET column_name = $1 WHERE rule_id = $2 AND company_code = $3`,
[columnName, foundRule.ruleId, companyCode]
);
result.rows[0].columnName = columnName;
logger.info("레거시 채번 규칙 자동 매핑 완료", {
ruleId: foundRule.ruleId,
tableName,
columnName,
});
}
}
if (result.rows.length === 0) { if (result.rows.length === 0) {
logger.info("테이블+컬럼 기반 채번 규칙을 찾을 수 없음", { logger.info("테이블+컬럼 기반 채번 규칙을 찾을 수 없음", {
@ -1760,7 +1840,6 @@ class NumberingRuleService {
const rule = result.rows[0]; const rule = result.rows[0];
// 파트 정보 조회 (테스트 테이블)
const partsQuery = ` const partsQuery = `
SELECT SELECT
id, id,
@ -1779,7 +1858,7 @@ class NumberingRuleService {
]); ]);
rule.parts = extractSeparatorAfterFromParts(partsResult.rows); rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", { logger.info("테이블+컬럼 기반 채번 규칙 조회 성공", {
ruleId: rule.ruleId, ruleId: rule.ruleId,
ruleName: rule.ruleName, ruleName: rule.ruleName,
}); });

View File

@ -31,7 +31,7 @@ class TableCategoryValueService {
tc.column_name AS "columnLabel", tc.column_name AS "columnLabel",
COUNT(cv.value_id) AS "valueCount" COUNT(cv.value_id) AS "valueCount"
FROM table_type_columns tc FROM table_type_columns tc
LEFT JOIN table_column_category_values cv LEFT JOIN category_values cv
ON tc.table_name = cv.table_name ON tc.table_name = cv.table_name
AND tc.column_name = cv.column_name AND tc.column_name = cv.column_name
AND cv.is_active = true AND cv.is_active = true
@ -50,7 +50,7 @@ class TableCategoryValueService {
tc.column_name AS "columnLabel", tc.column_name AS "columnLabel",
COUNT(cv.value_id) AS "valueCount" COUNT(cv.value_id) AS "valueCount"
FROM table_type_columns tc FROM table_type_columns tc
LEFT JOIN table_column_category_values cv LEFT JOIN category_values cv
ON tc.table_name = cv.table_name ON tc.table_name = cv.table_name
AND tc.column_name = cv.column_name AND tc.column_name = cv.column_name
AND cv.is_active = true AND cv.is_active = true
@ -110,7 +110,7 @@ class TableCategoryValueService {
) tc ) tc
LEFT JOIN ( LEFT JOIN (
SELECT table_name, column_name, COUNT(*) as cnt SELECT table_name, column_name, COUNT(*) as cnt
FROM table_column_category_values FROM category_values
WHERE is_active = true WHERE is_active = true
GROUP BY table_name, column_name GROUP BY table_name, column_name
) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name ) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name
@ -133,7 +133,7 @@ class TableCategoryValueService {
) tc ) tc
LEFT JOIN ( LEFT JOIN (
SELECT table_name, column_name, COUNT(*) as cnt SELECT table_name, column_name, COUNT(*) as cnt
FROM table_column_category_values FROM category_values
WHERE is_active = true AND company_code = $1 WHERE is_active = true AND company_code = $1
GROUP BY table_name, column_name GROUP BY table_name, column_name
) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name ) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name
@ -207,7 +207,7 @@ class TableCategoryValueService {
is_active AS "isActive", is_active AS "isActive",
is_default AS "isDefault", is_default AS "isDefault",
company_code AS "companyCode", company_code AS "companyCode",
NULL::numeric AS "menuObjid", menu_objid AS "menuObjid",
created_at AS "createdAt", created_at AS "createdAt",
updated_at AS "updatedAt", updated_at AS "updatedAt",
created_by AS "createdBy", created_by AS "createdBy",
@ -289,7 +289,7 @@ class TableCategoryValueService {
// 최고 관리자: 모든 회사에서 중복 체크 // 최고 관리자: 모든 회사에서 중복 체크
duplicateQuery = ` duplicateQuery = `
SELECT value_id SELECT value_id
FROM table_column_category_values FROM category_values
WHERE table_name = $1 WHERE table_name = $1
AND column_name = $2 AND column_name = $2
AND value_code = $3 AND value_code = $3
@ -300,7 +300,7 @@ class TableCategoryValueService {
// 일반 회사: 자신의 회사에서만 중복 체크 // 일반 회사: 자신의 회사에서만 중복 체크
duplicateQuery = ` duplicateQuery = `
SELECT value_id SELECT value_id
FROM table_column_category_values FROM category_values
WHERE table_name = $1 WHERE table_name = $1
AND column_name = $2 AND column_name = $2
AND value_code = $3 AND value_code = $3
@ -316,8 +316,41 @@ class TableCategoryValueService {
throw new Error("이미 존재하는 코드입니다"); throw new Error("이미 존재하는 코드입니다");
} }
// 라벨 중복 체크 (같은 테이블+컬럼+회사에서 동일한 라벨명 방지)
let labelDupQuery: string;
let labelDupParams: any[];
if (companyCode === "*") {
labelDupQuery = `
SELECT value_id
FROM category_values
WHERE table_name = $1
AND column_name = $2
AND value_label = $3
AND is_active = true
`;
labelDupParams = [value.tableName, value.columnName, value.valueLabel];
} else {
labelDupQuery = `
SELECT value_id
FROM category_values
WHERE table_name = $1
AND column_name = $2
AND value_label = $3
AND company_code = $4
AND is_active = true
`;
labelDupParams = [value.tableName, value.columnName, value.valueLabel, companyCode];
}
const labelDupResult = await pool.query(labelDupQuery, labelDupParams);
if (labelDupResult.rows.length > 0) {
throw new Error(`이미 동일한 이름의 카테고리 값이 존재합니다: "${value.valueLabel}"`);
}
const insertQuery = ` const insertQuery = `
INSERT INTO table_column_category_values ( INSERT INTO category_values (
table_name, column_name, value_code, value_label, value_order, table_name, column_name, value_code, value_label, value_order,
parent_value_id, depth, description, color, icon, parent_value_id, depth, description, color, icon,
is_active, is_default, company_code, menu_objid, created_by is_active, is_default, company_code, menu_objid, created_by
@ -425,6 +458,32 @@ class TableCategoryValueService {
values.push(updates.isDefault); values.push(updates.isDefault);
} }
// 라벨 수정 시 중복 체크 (자기 자신 제외)
if (updates.valueLabel !== undefined) {
const currentRow = await pool.query(
`SELECT table_name, column_name, company_code FROM category_values WHERE value_id = $1`,
[valueId]
);
if (currentRow.rows.length > 0) {
const { table_name, column_name, company_code } = currentRow.rows[0];
const labelDupResult = await pool.query(
`SELECT value_id FROM category_values
WHERE table_name = $1
AND column_name = $2
AND value_label = $3
AND company_code = $4
AND is_active = true
AND value_id != $5`,
[table_name, column_name, updates.valueLabel, company_code, valueId]
);
if (labelDupResult.rows.length > 0) {
throw new Error(`이미 동일한 이름의 카테고리 값이 존재합니다: "${updates.valueLabel}"`);
}
}
}
setClauses.push(`updated_at = NOW()`); setClauses.push(`updated_at = NOW()`);
setClauses.push(`updated_by = $${paramIndex++}`); setClauses.push(`updated_by = $${paramIndex++}`);
values.push(userId); values.push(userId);
@ -436,7 +495,7 @@ class TableCategoryValueService {
// 최고 관리자: 모든 카테고리 값 수정 가능 // 최고 관리자: 모든 카테고리 값 수정 가능
values.push(valueId); values.push(valueId);
updateQuery = ` updateQuery = `
UPDATE table_column_category_values UPDATE category_values
SET ${setClauses.join(", ")} SET ${setClauses.join(", ")}
WHERE value_id = $${paramIndex++} WHERE value_id = $${paramIndex++}
RETURNING RETURNING
@ -459,7 +518,7 @@ class TableCategoryValueService {
// 일반 회사: 자신의 카테고리 값만 수정 가능 // 일반 회사: 자신의 카테고리 값만 수정 가능
values.push(valueId, companyCode); values.push(valueId, companyCode);
updateQuery = ` updateQuery = `
UPDATE table_column_category_values UPDATE category_values
SET ${setClauses.join(", ")} SET ${setClauses.join(", ")}
WHERE value_id = $${paramIndex++} WHERE value_id = $${paramIndex++}
AND company_code = $${paramIndex++} AND company_code = $${paramIndex++}
@ -516,14 +575,14 @@ class TableCategoryValueService {
if (companyCode === "*") { if (companyCode === "*") {
valueQuery = ` valueQuery = `
SELECT table_name, column_name, value_code SELECT table_name, column_name, value_code
FROM table_column_category_values FROM category_values
WHERE value_id = $1 WHERE value_id = $1
`; `;
valueParams = [valueId]; valueParams = [valueId];
} else { } else {
valueQuery = ` valueQuery = `
SELECT table_name, column_name, value_code SELECT table_name, column_name, value_code
FROM table_column_category_values FROM category_values
WHERE value_id = $1 WHERE value_id = $1
AND company_code = $2 AND company_code = $2
`; `;
@ -635,10 +694,10 @@ class TableCategoryValueService {
if (companyCode === "*") { if (companyCode === "*") {
query = ` query = `
WITH RECURSIVE category_tree AS ( WITH RECURSIVE category_tree AS (
SELECT value_id FROM table_column_category_values WHERE parent_value_id = $1 SELECT value_id FROM category_values WHERE parent_value_id = $1
UNION ALL UNION ALL
SELECT cv.value_id SELECT cv.value_id
FROM table_column_category_values cv FROM category_values cv
INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id
) )
SELECT value_id FROM category_tree SELECT value_id FROM category_tree
@ -647,11 +706,11 @@ class TableCategoryValueService {
} else { } else {
query = ` query = `
WITH RECURSIVE category_tree AS ( WITH RECURSIVE category_tree AS (
SELECT value_id FROM table_column_category_values SELECT value_id FROM category_values
WHERE parent_value_id = $1 AND company_code = $2 WHERE parent_value_id = $1 AND company_code = $2
UNION ALL UNION ALL
SELECT cv.value_id SELECT cv.value_id
FROM table_column_category_values cv FROM category_values cv
INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id
WHERE cv.company_code = $2 WHERE cv.company_code = $2
) )
@ -697,10 +756,10 @@ class TableCategoryValueService {
let labelParams: any[]; let labelParams: any[];
if (companyCode === "*") { if (companyCode === "*") {
labelQuery = `SELECT value_label FROM table_column_category_values WHERE value_id = $1`; labelQuery = `SELECT value_label FROM category_values WHERE value_id = $1`;
labelParams = [id]; labelParams = [id];
} else { } else {
labelQuery = `SELECT value_label FROM table_column_category_values WHERE value_id = $1 AND company_code = $2`; labelQuery = `SELECT value_label FROM category_values WHERE value_id = $1 AND company_code = $2`;
labelParams = [id, companyCode]; labelParams = [id, companyCode];
} }
@ -730,10 +789,10 @@ class TableCategoryValueService {
let deleteParams: any[]; let deleteParams: any[];
if (companyCode === "*") { if (companyCode === "*") {
deleteQuery = `DELETE FROM table_column_category_values WHERE value_id = $1`; deleteQuery = `DELETE FROM category_values WHERE value_id = $1`;
deleteParams = [id]; deleteParams = [id];
} else { } else {
deleteQuery = `DELETE FROM table_column_category_values WHERE value_id = $1 AND company_code = $2`; deleteQuery = `DELETE FROM category_values WHERE value_id = $1 AND company_code = $2`;
deleteParams = [id, companyCode]; deleteParams = [id, companyCode];
} }
@ -770,7 +829,7 @@ class TableCategoryValueService {
if (companyCode === "*") { if (companyCode === "*") {
// 최고 관리자: 모든 카테고리 값 일괄 삭제 가능 // 최고 관리자: 모든 카테고리 값 일괄 삭제 가능
deleteQuery = ` deleteQuery = `
UPDATE table_column_category_values UPDATE category_values
SET is_active = false, updated_at = NOW(), updated_by = $2 SET is_active = false, updated_at = NOW(), updated_by = $2
WHERE value_id = ANY($1::int[]) WHERE value_id = ANY($1::int[])
`; `;
@ -778,7 +837,7 @@ class TableCategoryValueService {
} else { } else {
// 일반 회사: 자신의 카테고리 값만 일괄 삭제 가능 // 일반 회사: 자신의 카테고리 값만 일괄 삭제 가능
deleteQuery = ` deleteQuery = `
UPDATE table_column_category_values UPDATE category_values
SET is_active = false, updated_at = NOW(), updated_by = $3 SET is_active = false, updated_at = NOW(), updated_by = $3
WHERE value_id = ANY($1::int[]) WHERE value_id = ANY($1::int[])
AND company_code = $2 AND company_code = $2
@ -819,7 +878,7 @@ class TableCategoryValueService {
if (companyCode === "*") { if (companyCode === "*") {
// 최고 관리자: 모든 카테고리 값 순서 변경 가능 // 최고 관리자: 모든 카테고리 값 순서 변경 가능
updateQuery = ` updateQuery = `
UPDATE table_column_category_values UPDATE category_values
SET value_order = $1, updated_at = NOW() SET value_order = $1, updated_at = NOW()
WHERE value_id = $2 WHERE value_id = $2
`; `;
@ -827,7 +886,7 @@ class TableCategoryValueService {
} else { } else {
// 일반 회사: 자신의 카테고리 값만 순서 변경 가능 // 일반 회사: 자신의 카테고리 값만 순서 변경 가능
updateQuery = ` updateQuery = `
UPDATE table_column_category_values UPDATE category_values
SET value_order = $1, updated_at = NOW() SET value_order = $1, updated_at = NOW()
WHERE value_id = $2 WHERE value_id = $2
AND company_code = $3 AND company_code = $3
@ -1379,48 +1438,23 @@ class TableCategoryValueService {
let query: string; let query: string;
let params: any[]; let params: any[];
// is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함
if (companyCode === "*") { if (companyCode === "*") {
// 최고 관리자: 두 테이블 모두에서 조회 (UNION으로 병합)
// 두 번째 쿼리용 플레이스홀더: $n+1 ~ $2n
const placeholders2 = valueCodes.map((_, i) => `$${n + i + 1}`).join(", ");
query = ` query = `
SELECT value_code, value_label FROM ( SELECT DISTINCT value_code, value_label
SELECT value_code, value_label FROM category_values
FROM table_column_category_values WHERE value_code IN (${placeholders1})
WHERE value_code IN (${placeholders1})
AND is_active = true
UNION ALL
SELECT value_code, value_label
FROM category_values
WHERE value_code IN (${placeholders2})
AND is_active = true
) combined
`; `;
params = [...valueCodes, ...valueCodes]; params = [...valueCodes];
} else { } else {
// 일반 회사: 두 테이블에서 자신의 카테고리 값 + 공통 카테고리 값 조회 const companyIdx = n + 1;
// 첫 번째: $1~$n (valueCodes), $n+1 (companyCode)
// 두 번째: $n+2~$2n+1 (valueCodes), $2n+2 (companyCode)
const companyIdx1 = n + 1;
const placeholders2 = valueCodes.map((_, i) => `$${n + 1 + i + 1}`).join(", ");
const companyIdx2 = 2 * n + 2;
query = ` query = `
SELECT value_code, value_label FROM ( SELECT DISTINCT value_code, value_label
SELECT value_code, value_label FROM category_values
FROM table_column_category_values WHERE value_code IN (${placeholders1})
WHERE value_code IN (${placeholders1}) AND (company_code = $${companyIdx} OR company_code = '*')
AND is_active = true
AND (company_code = $${companyIdx1} OR company_code = '*')
UNION ALL
SELECT value_code, value_label
FROM category_values
WHERE value_code IN (${placeholders2})
AND is_active = true
AND (company_code = $${companyIdx2} OR company_code = '*')
) combined
`; `;
params = [...valueCodes, companyCode, ...valueCodes, companyCode]; params = [...valueCodes, companyCode];
} }
const result = await pool.query(query, params); const result = await pool.query(query, params);
@ -1488,7 +1522,7 @@ class TableCategoryValueService {
// 최고 관리자: 모든 카테고리 값 조회 // 최고 관리자: 모든 카테고리 값 조회
query = ` query = `
SELECT value_code, value_label SELECT value_code, value_label
FROM table_column_category_values FROM category_values
WHERE table_name = $1 WHERE table_name = $1
AND column_name = $2 AND column_name = $2
AND is_active = true AND is_active = true
@ -1498,7 +1532,7 @@ class TableCategoryValueService {
// 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회 // 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회
query = ` query = `
SELECT value_code, value_label SELECT value_code, value_label
FROM table_column_category_values FROM category_values
WHERE table_name = $1 WHERE table_name = $1
AND column_name = $2 AND column_name = $2
AND is_active = true AND is_active = true

View File

@ -2691,6 +2691,32 @@ export class TableManagementService {
logger.info(`created_date 자동 추가: ${data.created_date}`); logger.info(`created_date 자동 추가: ${data.created_date}`);
} }
// 채번 자동 적용: input_type = 'numbering'인 컬럼에 값이 비어있으면 자동 채번
try {
const companyCode = data.company_code || "*";
const numberingColsResult = await query<any>(
`SELECT DISTINCT column_name FROM table_type_columns
WHERE table_name = $1 AND input_type = 'numbering'
AND company_code IN ($2, '*')`,
[tableName, companyCode]
);
for (const row of numberingColsResult) {
const col = row.column_name;
if (!data[col] || data[col] === "" || data[col] === "자동 생성됩니다") {
const { numberingRuleService } = await import("./numberingRuleService");
const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, tableName, col);
if (rule) {
const generatedCode = await numberingRuleService.allocateCode(rule.ruleId, companyCode, data);
data[col] = generatedCode;
logger.info(`채번 자동 적용: ${tableName}.${col} = ${generatedCode}`);
}
}
}
} catch (numErr: any) {
logger.warn(`채번 자동 적용 중 오류 (무시됨): ${numErr.message}`);
}
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시) // 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시)
const skippedColumns: string[] = []; const skippedColumns: string[] = [];
const existingColumns = Object.keys(data).filter((col) => { const existingColumns = Object.keys(data).filter((col) => {
@ -3437,10 +3463,12 @@ export class TableManagementService {
} }
// ORDER BY 절 구성 // ORDER BY 절 구성
// sortBy가 없으면 created_date 컬럼이 있는 경우에만 기본 정렬 적 // sortBy가 메인 테이블 컬럼이면 main. 접두사, 조인 별칭이면 접두사 없이 사
const hasCreatedDateColumn = selectColumns.includes("created_date"); const hasCreatedDateColumn = selectColumns.includes("created_date");
const orderBy = options.sortBy const orderBy = options.sortBy
? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}` ? selectColumns.includes(options.sortBy)
? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
: `"${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
: hasCreatedDateColumn : hasCreatedDateColumn
? `main."created_date" DESC` ? `main."created_date" DESC`
: ""; : "";
@ -3505,7 +3533,7 @@ export class TableManagementService {
const referenceTableColumns = new Map<string, string[]>(); const referenceTableColumns = new Map<string, string[]>();
const uniqueRefTables = new Set( const uniqueRefTables = new Set(
joinConfigs joinConfigs
.filter((c) => c.referenceTable !== "table_column_category_values") // 카테고리는 제외 .filter((c) => c.referenceTable !== "category_values") // 카테고리는 제외
.map((c) => `${c.referenceTable}:${c.sourceColumn}`) .map((c) => `${c.referenceTable}:${c.sourceColumn}`)
); );
@ -3684,7 +3712,9 @@ export class TableManagementService {
selectColumns, selectColumns,
"", // WHERE 절은 나중에 추가 "", // WHERE 절은 나중에 추가
options.sortBy options.sortBy
? `main."${options.sortBy}" ${options.sortOrder || "ASC"}` ? selectColumns.includes(options.sortBy)
? `main."${options.sortBy}" ${options.sortOrder || "ASC"}`
: `"${options.sortBy}" ${options.sortOrder || "ASC"}`
: hasCreatedDateForSearch : hasCreatedDateForSearch
? `main."created_date" DESC` ? `main."created_date" DESC`
: undefined, : undefined,
@ -3875,7 +3905,9 @@ export class TableManagementService {
const whereClause = whereConditions.join(" AND "); const whereClause = whereConditions.join(" AND ");
const hasCreatedDateForOrder = selectColumns.includes("created_date"); const hasCreatedDateForOrder = selectColumns.includes("created_date");
const orderBy = options.sortBy const orderBy = options.sortBy
? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}` ? selectColumns.includes(options.sortBy)
? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
: `"${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
: hasCreatedDateForOrder : hasCreatedDateForOrder
? `main."created_date" DESC` ? `main."created_date" DESC`
: ""; : "";
@ -4310,8 +4342,8 @@ export class TableManagementService {
]; ];
for (const config of joinConfigs) { for (const config of joinConfigs) {
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인 // category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
if (config.referenceTable === "table_column_category_values") { if (config.referenceTable === "category_values") {
dbJoins.push(config); dbJoins.push(config);
console.log(`🔗 DB 조인 (특수 조건): ${config.referenceTable}`); console.log(`🔗 DB 조인 (특수 조건): ${config.referenceTable}`);
continue; continue;

View File

@ -0,0 +1,80 @@
import { query } from "../database/db";
/**
* PostgreSQL
* table_type_columns의 column_label을
*/
export async function formatPgError(
error: any,
companyCode?: string
): Promise<string> {
if (!error || !error.code) {
return error?.message || "데이터 처리 중 오류가 발생했습니다.";
}
switch (error.code) {
case "23502": {
// not_null_violation
const colName = error.column || "";
const tblName = error.table || "";
if (colName && tblName && companyCode) {
try {
const rows = await query(
`SELECT column_label FROM table_type_columns
WHERE table_name = $1 AND column_name = $2 AND company_code = $3
LIMIT 1`,
[tblName, colName, companyCode]
);
const label = rows[0]?.column_label;
if (label) {
return `필수 입력값이 누락되었습니다: ${label}`;
}
} catch {
// 라벨 조회 실패 시 컬럼명으로 폴백
}
}
const detail = colName ? ` [${colName}]` : "";
return `필수 입력값이 누락되었습니다.${detail}`;
}
case "23505": {
// unique_violation
const constraint = error.constraint || "";
const tblName = error.table || "";
// constraint 이름에서 컬럼명 추출 시도 (예: item_mst_item_code_key → item_code)
let colName = "";
if (constraint && tblName) {
const prefix = `${tblName}_`;
const suffix = "_key";
if (constraint.startsWith(prefix) && constraint.endsWith(suffix)) {
colName = constraint.slice(prefix.length, -suffix.length);
}
}
if (colName && tblName && companyCode) {
try {
const rows = await query(
`SELECT column_label FROM table_type_columns
WHERE table_name = $1 AND column_name = $2 AND company_code = $3
LIMIT 1`,
[tblName, colName, companyCode]
);
const label = rows[0]?.column_label;
if (label) {
return `중복된 데이터가 존재합니다: ${label}`;
}
} catch {
// 폴백
}
}
const detail = colName ? ` [${colName}]` : "";
return `중복된 데이터가 존재합니다.${detail}`;
}
case "23503":
return "참조 무결성 제약 조건 위반입니다.";
default:
if (error.code.startsWith("23")) {
return "데이터 무결성 제약 조건 위반입니다.";
}
return error.message || "데이터 처리 중 오류가 발생했습니다.";
}
}

View File

@ -0,0 +1,340 @@
# [계획서] 버튼 아이콘화 - 화면 디자이너 버튼 표시 모드 확장
> 관련 문서: [맥락노트](./BIC[맥락]-버튼-아이콘화.md) | [체크리스트](./BIC[체크]-버튼-아이콘화.md)
## 개요
화면 디자이너에서 버튼을 텍스트 모드(현행), 아이콘 모드, 아이콘+텍스트 모드 중 선택할 수 있도록 확장한다.
아이콘 모드 선택 시 버튼 액션에 맞는 아이콘 후보군이 제시되고, 관리자가 원하는 아이콘을 선택한다.
아이콘 크기 비율(버튼 높이 대비 4단계 프리셋), 아이콘 색상, 텍스트 위치(4방향), 아이콘-텍스트 간격 설정을 제공한다.
관리자가 lucide 검색 또는 외부 SVG 붙여넣기로 커스텀 아이콘을 추가/삭제할 수 있다.
---
## 현재 동작
- 버튼은 항상 **텍스트 모드**로만 표시됨
- `ButtonConfigPanel.tsx`에서 "버튼 텍스트" 입력 → 실제 화면에서 해당 텍스트가 버튼에 표시
- 아이콘 표시 기능 없음
### 현재 코드 위치
| 구분 | 파일 | 설명 |
|------|------|------|
| 설정 패널 | `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | 버튼 텍스트, 액션 설정 (784~854행) |
| 뷰어 렌더링 | `frontend/components/screen/InteractiveScreenViewerDynamic.tsx` | 실제 버튼 렌더링 (961~983행) |
| 뷰어 렌더링 | `frontend/components/screen/InteractiveScreenViewer.tsx` | 실제 버튼 렌더링 (2041~2059행) |
| 위젯 렌더링 | `frontend/components/screen/widgets/types/ButtonWidget.tsx` | 위젯 기반 버튼 렌더링 (67~86행) |
| 최적화 컴포넌트 | `frontend/components/screen/OptimizedButtonComponent.tsx` | 최적화된 버튼 컴포넌트 (643~674행) |
---
## 변경 후 동작
### 1. 표시 모드 선택 (라디오 그룹)
ButtonConfigPanel에 "버튼 텍스트" 입력 위에 표시 모드 선택 UI 추가:
- **텍스트 모드** (기본값, 현행 유지): 버튼에 텍스트만 표시
- **아이콘 모드**: 버튼에 아이콘만 표시
- **아이콘+텍스트 모드**: 버튼에 아이콘과 텍스트를 함께 표시
```
[ 텍스트 | 아이콘 | 아이콘+텍스트 ] ← 라디오 그룹 (토글 형태)
```
### 2. 텍스트 모드 선택 시
- 현재와 동일하게 "버튼 텍스트" 입력 필드 표시
- 변경 사항 없음
### 2-1. 아이콘+텍스트 모드 선택 시
- 아이콘 선택 UI (3장과 동일) + 버튼 텍스트 입력 필드 **둘 다 표시**
- 렌더링: 텍스트 위치에 따라 아이콘과 텍스트 배치 방향이 달라짐
- 텍스트 위치 4방향: 오른쪽(기본), 왼쪽, 위쪽, 아래쪽
- 예시: `[ ✓ 저장 ]` (오른쪽), `[ 저장 ✓ ]` (왼쪽), 세로 배치 (위쪽/아래쪽)
- 아이콘과 텍스트 사이 간격: 기본 6px, 관리자가 0~무제한 조절 가능 (슬라이더 0~32px + 직접 입력)
### 3. 아이콘 모드 선택 시
#### 3-1. 버튼 액션별 추천 아이콘 목록
버튼 액션(`action.type`)에 따라 해당 액션에 어울리는 아이콘 후보군을 그리드로 표시:
| 버튼 액션 | 값 | 추천 아이콘 (lucide-react) |
|-----------|-----|---------------------------|
| 저장 | `save` | Check, Save, CheckCircle, CircleCheck, FileCheck, ShieldCheck |
| 삭제 | `delete` | Trash2, Trash, XCircle, X, Eraser, CircleX |
| 편집 | `edit` | Pencil, PenLine, Edit, SquarePen, FilePen, PenTool |
| 페이지 이동 | `navigate` | ArrowRight, ExternalLink, MoveRight, Navigation, CornerUpRight, Link |
| 모달 열기 | `modal` | Maximize2, PanelTop, AppWindow, LayoutGrid, Layers, FolderOpen |
| 데이터 전달 | `transferData` | SendHorizontal, ArrowRightLeft, Repeat, PackageCheck, Upload, Share2 |
| 엑셀 다운로드 | `excel_download` | Download, FileDown, FileSpreadsheet, Sheet, Table, FileOutput |
| 엑셀 업로드 | `excel_upload` | Upload, FileUp, FileSpreadsheet, Sheet, ImportIcon, FileInput |
| 즉시 저장 | `quickInsert` | Zap, Plus, PlusCircle, SquarePlus, FilePlus, BadgePlus |
| 제어 흐름 | `control` | Settings, SlidersHorizontal, ToggleLeft, Workflow, GitBranch, Cog |
| 바코드 스캔 | `barcode_scan` | ScanLine, QrCode, Camera, Scan, ScanBarcode, Focus |
| 운행알림 및 종료 | `operation_control` | Truck, Car, MapPin, Navigation2, Route, Bell |
| 이벤트 발송 | `event` | Send, Bell, Radio, Megaphone, Podcast, BellRing |
| 복사 | `copy` | Copy, ClipboardCopy, Files, CopyPlus, Duplicate, ClipboardList |
**적절한 아이콘이 없는 액션 (숨김 처리된 deprecated 액션들):**
| 버튼 액션 | 값 | 안내 문구 |
|-----------|-----|----------|
| 연관 데이터 버튼 모달 열기 | `openRelatedModal` | 안내 문구 표시 + 커스텀 아이콘 추가 가능 |
| (deprecated) 데이터 전달 + 모달 | `openModalWithData` | 안내 문구 표시 + 커스텀 아이콘 추가 가능 |
| 테이블 이력 보기 | `view_table_history` | 안내 문구 표시 + 커스텀 아이콘 추가 가능 |
| 코드 병합 | `code_merge` | 안내 문구 표시 + 커스텀 아이콘 추가 가능 |
| 공차등록 | `empty_vehicle` | 안내 문구 표시 + 커스텀 아이콘 추가 가능 |
> 안내 문구: "적절한 추천 아이콘이 없습니다. 텍스트 모드를 사용하거나 아래에서 아이콘을 직접 추가하세요."
> 안내 문구 아래에 커스텀 아이콘 목록 + lucide 검색/SVG 붙여넣기 버튼이 표시됨
#### 3-2. 아이콘 선택 UI
- 액션별 추천 아이콘을 4~6열 그리드로 표시
- 각 아이콘은 32x32 크기, 호버 시 하이라이트, 선택 시 ring 표시
- 아이콘 아래에 이름 표시 (`text-[10px]`)
- 관리자가 추가한 커스텀 아이콘이 있으면 "커스텀 아이콘" 구분선 아래 함께 표시
#### 3-3. 아이콘 크기 비율 설정
버튼 높이 대비 비율로 아이콘 크기를 설정 (정사각형 유지):
**프리셋 (ToggleGroup, 4단계):**
| 이름 | 버튼 높이 대비 | 설명 |
|------|--------------|------|
| 작게 | 40% | 컴팩트한 아이콘 |
| 보통 | 55% | 기본값, 대부분의 버튼에 적합 |
| 크게 | 70% | 존재감 있는 크기 |
| 매우 크게 | 85% | 아이콘 강조, 버튼에 꽉 차는 느낌 |
- px 직접 입력은 제거 (비율 기반이므로 버튼 크기 변경 시 아이콘도 자동 비례)
- 저장: `icon.size`에 프리셋 문자열(`"보통"`) 저장
- 렌더링: `height: N%` + `aspect-ratio: 1/1`로 정사각형 유지
#### 3-4. 아이콘 색상 설정
아이콘 크기 아래에 아이콘 전용 색상 설정:
- **컬러 피커**: 기존 버튼 색상 설정과 동일한 UI 사용
- **기본값**: 미설정 (= `textColor` 상속, 기존 동작과 동일)
- **설정 시**: lucide 아이콘은 지정한 색상으로 덮어쓰기
- **외부 SVG**: 고유 색상이 하드코딩된 SVG는 이 설정의 영향을 받지 않음 (원본 유지)
- **초기화 버튼**: "텍스트 색상과 동일" 버튼으로 별도 색상 해제 가능
| 상황 | iconColor 설정 | 결과 |
|------|---------------|------|
| lucide 아이콘, iconColor 미설정 | 없음 | textColor 상속 (기존 동작) |
| lucide 아이콘, iconColor 설정 | `#22c55e` | 초록색 아이콘 |
| 외부 SVG (고유 색상), iconColor 설정 | `#22c55e` | SVG 원본 색상 유지 (무시) |
| 외부 SVG (currentColor), iconColor 설정 | `#22c55e` | 초록색 아이콘 |
#### 3-5. 텍스트 위치 설정 (아이콘+텍스트 모드 전용)
아이콘 대비 텍스트의 배치 방향을 4방향으로 설정:
| 위치 | 값 | 레이아웃 | 설명 |
|------|-----|---------|------|
| 왼쪽 | `left` | `텍스트 ← 아이콘` | 텍스트가 아이콘 왼쪽 (가로) |
| 오른쪽 | `right` | `아이콘 → 텍스트` | 기본값, 아이콘 뒤에 텍스트 (가로) |
| 위쪽 | `top` | 텍스트 위, 아이콘 아래 | 세로 배치 |
| 아래쪽 | `bottom` | 아이콘 위, 텍스트 아래 | 세로 배치 |
- 기본값: `"right"` (아이콘 오른쪽에 텍스트)
- 저장: `componentConfig.iconTextPosition`
- 아이콘 모드에서는 이 옵션이 숨겨짐 (텍스트가 없으므로 불필요)
#### 3-6. 아이콘-텍스트 간격 설정 (아이콘+텍스트 모드 전용)
아이콘+텍스트 모드에서 아이콘과 텍스트 사이 간격을 조절:
- **슬라이더**: 0~32px 범위 시각적 조절
- **직접 입력**: px 수치 직접 입력 (최솟값 0, 최댓값 제한 없음)
- **기본값**: 6px
- 아이콘 모드에서는 이 옵션이 숨겨짐 (텍스트가 없으므로 불필요)
#### 3-7. 아이콘 모드 레이아웃 안내
아이콘만 표시하면 텍스트보다 좁은 공간으로 충분하므로 안내 문구 표시:
```
아이콘만 표시할 때는 버튼 영역의 가로 폭을 줄여 정사각형에 가깝게 만들면 더 깔끔합니다.
```
- `bg-blue-50 dark:bg-blue-950/20` 배경의 안내 박스
- 아이콘 모드(`"icon"`)에서만 표시, 아이콘+텍스트 모드에서는 숨김
#### 3-8. 디폴트 아이콘 자동 부여
아이콘/아이콘+텍스트 모드 전환 시 아이콘이 미선택 상태이면 **디폴트 아이콘을 자동으로 부여**한다.
| 상황 | 디폴트 아이콘 |
|------|-------------|
| 추천 아이콘이 있는 액션 (save, delete 등) | 해당 액션의 **첫 번째 추천 아이콘** (예: save → Check) |
| 추천 아이콘이 없는 액션 (deprecated 등) | 범용 폴백 아이콘: `SquareMousePointer` |
**커스텀 아이콘 삭제 시:**
- 현재 선택된 커스텀 아이콘을 삭제하면 **디폴트 아이콘으로 자동 복귀** (텍스트 모드로 빠지지 않음)
- 아이콘 모드를 유지한 채 디폴트 아이콘이 캔버스에 즉시 반영됨
#### 3-9. 커스텀 아이콘 추가/삭제
**방법 1: lucide 아이콘 검색으로 추가**
- "아이콘 추가" 버튼 클릭 시 lucide 아이콘 전체 검색 가능한 모달/팝오버 표시
- 검색 입력 → 아이콘 이름으로 필터링 → 선택하면 커스텀 목록에 추가
**방법 2: 외부 SVG 붙여넣기로 추가**
- "SVG 붙여넣기" 버튼 클릭 시 텍스트 입력 영역(textarea) 표시
- 외부에서 복사한 SVG 코드를 붙여넣기 → 미리보기로 확인 → "추가" 버튼으로 등록
- SVG 유효성 검사: `<svg` 태그가 포함된 올바른 SVG인지 확인, 아니면 에러 메시지
- 추가 시 관리자가 아이콘 이름을 직접 입력 (목록에서 구분용)
- 저장 형태: SVG 문자열을 `customSvgIcons` 배열에 `{ name, svg }` 객체로 저장
**공통 규칙:**
- 추가된 커스텀 아이콘(lucide/SVG 모두)은 **모든 버튼 액션의 아이콘 후보에 공통으로 노출**
- 커스텀 아이콘에 X 버튼으로 삭제 가능
---
## 데이터 구조
### componentConfig 확장
```typescript
interface ButtonComponentConfig {
text: string; // 기존: 버튼 텍스트
displayMode: "text" | "icon" | "icon-text"; // 신규: 표시 모드 (기본값: "text")
icon?: {
name: string; // lucide 아이콘 이름 또는 커스텀 SVG 아이콘 이름
type: "lucide" | "svg"; // 아이콘 출처 구분 (기본값: "lucide")
size: "작게" | "보통" | "크게" | "매우 크게"; // 버튼 높이 대비 비율 프리셋 (기본값: "보통")
color?: string; // 아이콘 색상 (미설정 시 textColor 상속)
};
iconGap?: number; // 아이콘-텍스트 간격 px (기본값: 6, 아이콘+텍스트 모드 전용)
iconTextPosition?: "right" | "left" | "top" | "bottom"; // 텍스트 위치 (기본값: "right", 아이콘+텍스트 모드 전용)
customIcons?: string[]; // 관리자가 추가한 lucide 커스텀 아이콘 이름 목록
customSvgIcons?: Array<{ // 관리자가 붙여넣기한 외부 SVG 아이콘 목록
name: string; // 관리자가 지정한 아이콘 이름
svg: string; // SVG 문자열 원본
}>;
action: {
type: string; // 기존: 버튼 액션 타입
// ...기존 action 속성들 유지
};
}
```
### 저장 예시
```json
{
"text": "저장",
"displayMode": "icon",
"icon": {
"name": "Check",
"type": "lucide",
"size": "보통",
"color": "#22c55e"
},
"customIcons": ["Rocket", "Star"],
"customSvgIcons": [
{
"name": "회사로고",
"svg": "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'>...</svg>"
}
],
"action": {
"type": "save"
}
}
```
---
## 시각적 동작 예시
### ButtonConfigPanel (디자이너 편집 모드)
```
표시 모드: [ 텍스트 | (아이콘) | 아이콘+텍스트 ] ← 아이콘 선택됨
아이콘 선택:
┌──────────────────────────────────┐
│ 추천 아이콘 (저장) │
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
│ │ ✓ │ │ 💾 │ │ ✓○ │ │ ○✓ │ │
│ │Check│ │Save│ │Chk○│ │○Chk│ │
│ └────┘ └────┘ └────┘ └────┘ │
│ ┌────┐ ┌────┐ │
│ │📄✓│ │🛡✓│ │
│ │FChk│ │ShCk│ │
│ └────┘ └────┘ │
│ │
│ ── 커스텀 아이콘 ── │
│ ┌────┐ ┌────┐ ┌────┐ │
│ │ 🚀 │ │ ⭐ │ │[로고]│ │
│ │Rckt │ │Star│ │회사 │ │
│ │ ✕ │ │ ✕│ │ ✕ │ │
│ └────┘ └────┘ └────┘ │
│ [+ lucide 검색] [+ SVG 붙여넣기]│
└──────────────────────────────────┘
아이콘 크기 비율: [ 작게 | (보통) | 크게 | 매우 크게 ]
텍스트 위치: [ 왼쪽 | (오른쪽) | 위쪽 | 아래쪽 ] ← 아이콘+텍스트 모드에서만 표시
아이콘-텍스트 간격: [━━━━━○━━] [6] px ← 아이콘+텍스트 모드에서만 표시
아이콘 색상: [■ #22c55e] [텍스트 색상과 동일]
아이콘만 표시할 때는 버튼 영역의 가로 폭을 줄여 정사각형에 가깝게 만들면 더 깔끔합니다.
```
### 실제 화면 렌더링
| 모드 | 표시 |
|------|------|
| 텍스트 모드 | `[ 저장 ]` |
| 아이콘 모드 (보통, 55%) | `[ ✓ ]` |
| 아이콘 모드 (매우 크게, 85%) | `[ ✓ ]` |
| 아이콘+텍스트 (텍스트 오른쪽) | `[ ✓ 저장 ]` (간격 6px) |
| 아이콘+텍스트 (텍스트 왼쪽) | `[ 저장 ✓ ]` |
| 아이콘+텍스트 (텍스트 아래쪽) | 아이콘 위, 텍스트 아래 (세로) |
| 아이콘+텍스트 (색상 분리) | `[ 초록✓ 검정저장 ]` |
---
## 변경 대상
### 수정 파일
| 파일 | 변경 내용 |
|------|----------|
| `ButtonConfigPanel.tsx` | 표시 모드 3종 라디오, 아이콘 그리드, 크기, 색상, 간격 설정, 레이아웃 안내, 커스텀 아이콘 UI |
| `InteractiveScreenViewerDynamic.tsx` | `displayMode` 3종 분기 → 아이콘/아이콘+텍스트/텍스트 렌더링 |
| `InteractiveScreenViewer.tsx` | 동일 분기 추가 |
| `ButtonWidget.tsx` | 동일 분기 추가 |
| `OptimizedButtonComponent.tsx` | 동일 분기 추가 |
| `ScreenDesigner.tsx` | 입력 필드 포커스 시 키보드 단축키 기본 동작 허용 (Ctrl+A/C/V/Z) |
| `RealtimePreviewDynamic.tsx` | 버튼 컴포넌트 position wrapper에서 border 속성 분리 (이중 테두리 방지) |
### 신규 파일
| 파일 | 내용 |
|------|------|
| `frontend/lib/button-icon-map.ts` | 버튼 액션별 추천 아이콘 매핑 + 아이콘 동적 렌더링 유틸 |
---
## 설계 원칙
- 기본값은 `"text"` 모드 → 기존 모든 버튼은 변경 없이 동작
- `displayMode`가 없거나 `"text"`이면 현행 텍스트 렌더링 유지
- 아이콘/아이콘+텍스트 모드 전환 시 아이콘 미선택이면 **디폴트 아이콘 자동 부여** (빈 상태 방지)
- 커스텀 아이콘 삭제 시 텍스트 모드로 빠지지 않고 **디폴트 아이콘으로 자동 복귀**
- 아이콘 모드에서도 `text` 값은 유지 (접근성 aria-label로 활용)
- 기본 아이콘은 lucide-react 사용 (프로젝트 일관성)
- 외부 SVG 붙여넣기도 지원 → 관리자가 회사 로고 등 자체 아이콘을 등록 가능
- lucide 커스텀 아이콘은 `componentConfig.customIcons`에, SVG 아이콘은 `componentConfig.customSvgIcons`에 저장
- lucide 아이콘 렌더링: 아이콘 이름 → 컴포넌트 매핑, SVG 아이콘 렌더링: `dangerouslySetInnerHTML` + DOMPurify 정화

View File

@ -0,0 +1,263 @@
# [맥락노트] 버튼 아이콘화 - 화면 디자이너 버튼 표시 모드 확장
> 관련 문서: [계획서](./BIC[계획]-버튼-아이콘화.md) | [체크리스트](./BIC[체크]-버튼-아이콘화.md)
---
## 왜 이 작업을 하는가
- 현재 모든 버튼은 텍스트로만 표시 → 버튼 영역이 넓어야 하고, 모바일/태블릿에서 공간 효율이 낮음
- "저장", "삭제", "추가" 같은 자주 쓰는 버튼은 아이콘만으로 충분히 인식 가능
- 관리자가 화면 레이아웃을 더 컴팩트하게 구성할 수 있도록 선택권 제공
- 단, "출하 계획" 같이 아이콘화가 어려운 특수 버튼이 존재하므로 텍스트 모드도 반드시 유지
---
## 핵심 결정 사항과 근거
### 1. 표시 모드는 3종 라디오 그룹(토글 형태)으로 구현
- **결정**: `ToggleGroup` 형태의 세 개 옵션 (텍스트 / 아이콘 / 아이콘+텍스트)
- **근거**: 세 모드는 상호 배타적. 아이콘+텍스트 병합 모드가 있어야 `[ + 추가 ]`, `[ 💾 저장 ]` 같은 실무 패턴을 지원. 아이콘만으로 의미 전달이 부족한 경우 텍스트를 병기하면 사용자 인식 속도가 빨라짐
- **대안 검토**: Switch(토글) → 기각 ("무엇이 켜지는지" 직관적이지 않음, 3종 불가)
### 2. 기본값은 텍스트 모드
- **결정**: `displayMode` 기본값 = `"text"`
- **근거**: 기존 모든 버튼은 텍스트로 동작 중. 아이콘 모드는 명시적으로 선택해야만 적용되어야 하위 호환성이 보장됨
- **중요**: `displayMode``undefined`이거나 `"text"`이면 현행 동작 그대로 유지
### 3. 아이콘은 버튼 액션(action.type)에 연동
- **결정**: 버튼 액션을 변경하면 해당 액션에 맞는 추천 아이콘 목록이 자동으로 갱신됨
- **근거**: 관리자가 "저장" 아이콘을 고른 뒤 액션을 "삭제"로 바꾸면 혼란 발생. 액션별로 적절한 아이콘 후보를 보여주는 것이 자연스러움
- **주의**: 액션 변경 시 이전에 선택한 아이콘이 새 액션의 추천 목록에 없으면 선택 초기화
### 4. 액션별 아이콘은 6개씩 제공, 적절한 아이콘이 없으면 안내 문구
- **결정**: 활성 액션 14개 각각에 6개의 lucide-react 아이콘 후보 제공
- **근거**: 너무 적으면 선택지 부족, 너무 많으면 선택 피로. 6개가 2행 그리드로 깔끔하게 표시됨
- **deprecated/숨김 액션**: UI에서 숨김 처리된 액션은 추천 아이콘 없이 안내 문구만 표시
### 5. 커스텀 아이콘 추가는 2가지 방법 제공
- **결정**: (1) lucide 아이콘 검색 + (2) 외부 SVG 붙여넣기 두 가지 경로 제공
- **근거**: lucide 내장 아이콘만으로는 부족한 경우 존재 (회사 로고, 업종별 특수 아이콘 등). 외부에서 가져온 SVG를 직접 붙여넣기로 등록할 수 있어야 실무 유연성 확보
- **lucide 추가**: "lucide 검색" 버튼 → 팝오버에서 검색 → 선택 → `customIcons` 배열에 이름 추가
- **SVG 추가**: "SVG 붙여넣기" 버튼 → textarea에 SVG 코드 붙여넣기 → 미리보기 확인 → 이름 입력 → `customSvgIcons` 배열에 `{ name, svg }` 저장
- **SVG 유효성**: `<svg` 태그 포함 여부로 기본 검증, XSS 방지를 위해 DOMPurify로 정화 후 저장
- **범위**: 모든 커스텀 아이콘은 **해당 버튼 컴포넌트에 저장** (lucide: `customIcons`, SVG: `customSvgIcons`)
- **노출**: 커스텀 아이콘(lucide/SVG 모두)은 어떤 버튼 액션에서도 추천 아이콘 아래에 함께 노출됨
- **삭제**: 커스텀 아이콘 위에 X 버튼으로 개별 삭제 가능
### 5-1. 외부 SVG 붙여넣기의 보안 고려
- **결정**: SVG 문자열을 DOMPurify로 정화(sanitize)한 뒤 저장
- **근거**: SVG에 `<script>`, `onload` 같은 악성 코드가 포함될 수 있으므로 XSS 방지 필수
- **렌더링**: 정화된 SVG를 `dangerouslySetInnerHTML`로 렌더링 (정화 후이므로 안전)
- **대안 검토**: SVG를 이미지 파일로 업로드 → 기각 (관리자 입장에서 복사-붙여넣기가 훨씬 간편)
### 6. 아이콘 색상은 별도 설정, 기본값은 textColor 상속
- **결정**: `icon.color` 옵션 추가. 미설정 시 `textColor`를 상속, 설정하면 아이콘만 해당 색상 적용
- **근거**: 아이콘+텍스트 모드에서 `[ 초록✓ 검정저장 ]` 같이 아이콘과 텍스트 색을 분리하고 싶은 경우 존재. 삭제 버튼에 빨간 아이콘 + 흰 텍스트 같은 세밀한 디자인도 가능
- **기본값**: 미설정 (= `textColor` 상속) → 설정하지 않으면 기존 동작과 100% 동일
- **외부 SVG**: `fill`이 하드코딩된 SVG는 이 설정 무시 (SVG 원본 색상 유지가 의도). `currentColor`를 사용하는 SVG만 영향받음
- **구현**: 아이콘을 `<span style={{ color: icon.color }}>`으로 감싸서 아이콘만 색상 분리
- **초기화**: "텍스트 색상과 동일" 버튼으로 별도 색상 해제 → `icon.color` 삭제
### 7. 아이콘 크기는 버튼 높이 대비 비율(%) 프리셋 4단계
- **결정**: 작게(40%) / 보통(55%) / 크게(70%) / 매우 크게(85%) — 버튼 높이 대비 비율
- **근거**: 절대 px 값은 버튼 크기가 바뀌면 비율이 깨짐. 비율 기반이면 버튼 크기를 조정해도 아이콘이 자동으로 비례하여 일관된 시각적 균형 유지
- **기본값**: `"보통"` (55%) — 대부분의 버튼 크기에 적합
- **px 직접 입력 제거**: 관리자에게 과도한 선택지를 주면 오히려 일관성이 깨짐. 4단계 프리셋만으로 충분
- **구현**: CSS `height: N%` + `aspect-ratio: 1/1`로 정사각형 유지, lucide 아이콘은 래핑 span으로 크기 제어
- **레거시 호환**: 기존 `"sm"`, `"md"` 등 레거시 값은 55%(보통)로 자동 폴백
### 8. 아이콘 동적 렌더링은 매핑 객체 방식
- **결정**: lucide-react 아이콘 이름(string) → 실제 컴포넌트 매핑 객체를 별도 파일로 관리
- **근거**: `import * from 'lucide-react'`는 번들 크기에 영향. 사용하는 아이콘만 명시적으로 매핑
- **파일**: `frontend/lib/button-icon-map.ts`
- **구현**: `Record<string, React.ComponentType>` 형태의 매핑 + `renderIcon(name, size)` 유틸 함수
### 9. 아이콘 모드에서도 text 값은 유지
- **결정**: `displayMode === "icon"`이어도 `text` 필드는 삭제하지 않음
- **근거**: 접근성(`aria-label`), 검색/필터링 등에 텍스트가 필요할 수 있음
- **렌더링**: 아이콘 모드에서는 `text``aria-label` 용도로만 보존
- **아이콘+텍스트 모드**: `text`가 아이콘 오른쪽에 함께 렌더링됨
### 10. 아이콘-텍스트 간격 설정 추가
- **결정**: 아이콘+텍스트 모드에서 아이콘과 텍스트 사이 간격을 관리자가 조절 가능 (`iconGap`)
- **근거**: 고정 `gap-1.5`(6px)로는 다양한 버튼 크기/디자인에 대응 불가. 간격이 좁으면 답답하고, 넓으면 분리되어 보이는 경우가 있어 관리자에게 조절 권한 제공
- **기본값**: 6px (기존 `gap-1.5`와 동일)
- **UI**: 슬라이더(0~32px) + 숫자 직접 입력(최댓값 제한 없음)
- **저장**: `componentConfig.iconGap` (숫자)
### 11. 키보드 단축키 입력 필드 충돌 해결
- **결정**: `ScreenDesigner`의 글로벌 키보드 핸들러에서 입력 필드 포커스 시 앱 단축키를 무시하도록 수정
- **근거**: SVG 붙여넣기 textarea에서 Ctrl+V/A/C/Z가 작동하지 않는 치명적 UX 문제 발견. 글로벌 `keydown` 핸들러가 `{ capture: true }`로 모든 키보드 이벤트를 가로채고 있었음
- **수정**: `browserShortcuts` 일괄 차단과 앱 전용 단축키 처리 앞에 `e.target`/`document.activeElement` 기반 입력 필드 감지 가드 추가
- **영향**: input, textarea, select, contentEditable 요소에서 텍스트 편집 단축키가 정상 동작
### 12. noIconAction에서 커스텀 아이콘 추가 허용
- **결정**: 추천 아이콘이 없는 deprecated 액션에서도 커스텀 아이콘(lucide 검색, SVG 붙여넣기) 추가 가능
- **근거**: "적절한 아이콘이 없습니다" 문구만 표시하고 아이콘 추가를 완전 차단하면 관리자가 필요한 아이콘을 직접 등록할 방법이 없음. 추천은 없지만 직접 추가는 허용해야 유연성 확보
- **안내 문구**: "적절한 추천 아이콘이 없습니다. 텍스트 모드를 사용하거나 아래에서 아이콘을 직접 추가하세요."
### 13. 아이콘 모드 레이아웃 안내 문구
- **결정**: 아이콘 모드(`"icon"`) 선택 시 "버튼 영역의 가로 폭을 줄여 정사각형에 가깝게 만들면 더 깔끔합니다" 안내 표시
- **근거**: 아이콘 자체는 항상 정사각형(24x24 viewBox)이지만, 디자이너에서 버튼 컨테이너는 가로로 넓은 직사각형이 기본. 아이콘만 넣으면 좌우 여백이 과다해 보이므로 버튼 영역을 줄이라는 안내가 필요. 자동 크기 조정은 기존 레이아웃을 깨뜨릴 위험이 있어 도입하지 않되, 관리자에게 팁을 제공하면 스스로 최적화할 수 있음
- **표시 조건**: `displayMode === "icon"`일 때만 (아이콘+텍스트 모드는 가로 공간이 필요하므로 해당 안내 불필요)
- **대안 검토**: 자동 정사각형 조정 → 기각 (관리자 수동 레이아웃 파괴 위험)
### 14. 디폴트 아이콘 자동 부여
- **결정**: 아이콘/아이콘+텍스트 모드 전환 시 아이콘이 미선택이면 디폴트 아이콘을 자동으로 부여. 커스텀 아이콘 삭제 시에도 텍스트 모드로 빠지지 않고 디폴트 아이콘으로 복귀
- **근거**: 아이콘 모드로 전환했는데 아무것도 안 보이면 "기능이 작동하지 않는다"는 착각을 유발. 또한 커스텀 아이콘을 삭제했을 때 갑자기 텍스트로 빠지면 관리자가 의도치 않은 모드 변경을 경험하게 됨
- **디폴트 선택 기준**: 해당 액션의 첫 번째 추천 아이콘 (예: save → Check). 추천 아이콘이 없는 액션은 범용 폴백 `SquareMousePointer` 사용
- **구현**: `getDefaultIconForAction(actionType)` 유틸 함수로 중앙화 (`button-icon-map.tsx`)
- **폴백 아이콘**: `SquareMousePointer` — 마우스 포인터 + 사각형 형태로 "버튼 클릭 동작"을 범용적으로 표현
### 15. 아이콘+텍스트 모드에서 텍스트 위치 4방향 지원
- **결정**: 아이콘 대비 텍스트 위치를 왼쪽/오른쪽/위쪽/아래쪽 4방향으로 설정 가능
- **근거**: 기존에는 아이콘 오른쪽에 텍스트 고정이었으나, 세로 배치(위/아래)가 필요한 경우도 존재 (좁고 높은 버튼, 툴바 스타일). 4방향을 제공하면 관리자가 버튼 모양에 맞게 레이아웃 선택 가능
- **기본값**: `"right"` (아이콘 오른쪽에 텍스트) — 가장 자연스러운 좌→우 읽기 방향
- **구현**: `flexDirection` (row/column) + 요소 순서 (textFirst) 조합으로 4방향 구현
- **저장**: `componentConfig.iconTextPosition`
- **표시 조건**: 아이콘+텍스트 모드에서만 표시 (아이콘 모드, 텍스트 모드에서는 숨김)
### 16. 버튼 컴포넌트 테두리 이중 적용 문제 해결
- **결정**: `RealtimePreviewDynamic`의 position wrapper에서 버튼 컴포넌트의 border 속성을 분리(strip)
- **근거**: StyleEditor에서 설정한 border가 (1) position wrapper와 (2) 내부 버튼 요소 두 곳에 모두 적용되어 이중 테두리 발생. border는 내부 버튼(`buttonElementStyle`)에서만 렌더링해야 함
- **수정 파일**: `RealtimePreviewDynamic.tsx``isButtonComponent` 조건에 `v2-button-primary` 추가하여 border strip 대상에 포함
- **수정 파일**: `ButtonPrimaryComponent.tsx` — 외부 wrapper(`componentStyle`)에서 border 속성 destructure로 제거, `border: "none"` shorthand 대신 개별 longhand 속성으로 변경 (borderStyle 미설정 시 기본 `"solid"` 적용)
### 17. 커스텀 아이콘 검색은 lucide 전체 목록 기반
- **결정**: lucide-react에서 export되는 전체 아이콘 이름 목록을 검색 가능
- **근거**: 관리자가 "어떤 아이콘이 있는지" 모르므로 검색 기능이 필수
- **구현**: lucide 아이콘 이름 배열을 상수로 관리하고, CommandInput으로 필터링
- **주의**: 전체 아이콘 컴포넌트를 import하지 않고, 이름 배열만 관리 → 선택 시에만 해당 아이콘을 매핑에 추가
---
## 관련 파일 위치
| 구분 | 파일 경로 | 설명 |
|------|----------|------|
| 설정 패널 (수정) | `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | 버튼 텍스트/액션 설정 (784~854행에 모드 선택 추가) |
| 뷰어 렌더링 (수정) | `frontend/components/screen/InteractiveScreenViewerDynamic.tsx` | 버튼 렌더링 분기 (961~983행) |
| 뷰어 렌더링 (수정) | `frontend/components/screen/InteractiveScreenViewer.tsx` | 버튼 렌더링 분기 (2041~2059행) |
| 위젯 (수정) | `frontend/components/screen/widgets/types/ButtonWidget.tsx` | 위젯 기반 버튼 렌더링 (67~86행) |
| 최적화 버튼 (수정) | `frontend/components/screen/OptimizedButtonComponent.tsx` | 최적화된 버튼 렌더링 (643~674행) |
| 아이콘 매핑 (신규) | `frontend/lib/button-icon-map.ts` | 액션별 추천 아이콘 + 동적 렌더링 유틸 |
| 타입 정의 (참고) | `frontend/types/screen.ts` | ComponentData, componentConfig 타입 |
---
## 기술 참고
### lucide-react 아이콘 동적 렌더링
```typescript
// button-icon-map.ts
import { Check, Save, Trash2, Pencil, ... } from "lucide-react";
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
Check, Save, Trash2, Pencil, ...
};
export function renderButtonIcon(name: string, size: string | number) {
const IconComponent = iconMap[name];
if (!IconComponent) return null;
return <IconComponent style={getIconSizeStyle(size)} />;
}
```
### 아이콘 크기 비율 매핑 (버튼 높이 대비 %)
```typescript
const iconSizePresets: Record<string, number> = {
"작게": 40,
"보통": 55,
"크게": 70,
"매우 크게": 85,
};
// 프리셋 문자열 → 비율(%) 반환. 레거시 값은 55(보통)로 폴백
export function getIconPercent(size: string | number): number {
if (typeof size === "number") return size;
return iconSizePresets[size] ?? 55;
}
// 버튼 높이 대비 비율 + 정사각형 유지
export function getIconSizeStyle(size: string | number): React.CSSProperties {
const pct = getIconPercent(size);
return { height: `${pct}%`, width: "auto", aspectRatio: "1 / 1" };
}
```
### 외부 SVG 아이콘 렌더링
```typescript
import DOMPurify from "dompurify";
export function renderSvgIcon(svgString: string, size: string | number) {
const clean = DOMPurify.sanitize(svgString, { USE_PROFILES: { svg: true } });
return (
<span
className="inline-flex items-center justify-center"
style={getIconSizeStyle(size)}
dangerouslySetInnerHTML={{ __html: clean }}
/>
);
}
```
### 버튼 액션별 추천 아이콘 구조
```typescript
const actionIconMap: Record<string, string[]> = {
save: ["Check", "Save", "CheckCircle", "CircleCheck", "FileCheck", "ShieldCheck"],
delete: ["Trash2", "Trash", "XCircle", "X", "Eraser", "CircleX"],
// ...
};
```
### 현재 버튼 액션 목록 (활성)
| 값 | 표시명 | 아이콘화 가능 |
|-----|--------|-------------|
| `save` | 저장 | O |
| `delete` | 삭제 | O |
| `edit` | 편집 | O |
| `navigate` | 페이지 이동 | O |
| `modal` | 모달 열기 | O |
| `transferData` | 데이터 전달 | O |
| `excel_download` | 엑셀 다운로드 | O |
| `excel_upload` | 엑셀 업로드 | O |
| `quickInsert` | 즉시 저장 | O |
| `control` | 제어 흐름 | O |
| `barcode_scan` | 바코드 스캔 | O |
| `operation_control` | 운행알림 및 종료 | O |
| `event` | 이벤트 발송 | O |
| `copy` | 복사 (품목코드 초기화) | O |
### 현재 버튼 액션 목록 (숨김/deprecated)
| 값 | 표시명 | 아이콘화 가능 |
|-----|--------|-------------|
| `openRelatedModal` | 연관 데이터 버튼 모달 열기 | X (적절한 아이콘 없음) |
| `openModalWithData` | (deprecated) 데이터 전달 + 모달 | X |
| `view_table_history` | 테이블 이력 보기 | X |
| `code_merge` | 코드 병합 | X |
| `empty_vehicle` | 공차등록 | X |

View File

@ -0,0 +1,158 @@
# [체크리스트] 버튼 아이콘화 - 화면 디자이너 버튼 표시 모드 확장
> 관련 문서: [계획서](./BIC[계획]-버튼-아이콘화.md) | [맥락노트](./BIC[맥락]-버튼-아이콘화.md)
---
## 공정 상태
- 전체 진행률: **100%** (전 단계 구현 및 검증 완료)
- 현재 단계: 완료
---
## 구현 체크리스트
### 1단계: 아이콘 매핑 파일 생성
- [x] `frontend/lib/button-icon-map.tsx` 생성
- [x] 버튼 액션별 추천 아이콘 매핑 (`actionIconMap`) 정의 (14개 액션 x 6개 아이콘)
- [x] 아이콘 크기 비율 매핑 (`iconSizePresets`) 정의 (작게/보통/크게/매우 크게, 버튼 높이 대비 %) + `getIconSizeStyle()` 유틸
- [x] lucide 아이콘 동적 렌더링 포함 `getButtonDisplayContent()` 구현
- [x] SVG 아이콘 렌더링 (DOMPurify 정화 via `isomorphic-dompurify`)
- [x] 아이콘 이름 → 컴포넌트 매핑 객체 (`iconMap`) + `addToIconMap()` 동적 추가
- [x] deprecated 액션용 안내 문구 상수 (`NO_ICON_MESSAGE`) 정의
- [x] `isomorphic-dompurify` 기존 설치 확인 (추가 설치 불필요)
- [x] `ButtonIconRenderer` 공용 컴포넌트 추가 (모든 렌더러에서 재사용)
- [x] `getDefaultIconForAction()` 디폴트 아이콘 유틸 함수 추가 (액션별 첫 번째 추천 / 범용 폴백)
- [x] `FALLBACK_ICON_NAME` 상수 + `SquareMousePointer` import/매핑 추가
### 2단계: ButtonConfigPanel 수정
- [x] 표시 모드 버튼 그룹 UI 추가 (텍스트 / 아이콘 / 아이콘+텍스트)
- [x] `displayMode` 상태 관리 및 `onUpdateProperty` 연동
- [x] 아이콘 모드 선택 시 조건부 UI 분기 (텍스트 입력 숨김 → 아이콘 선택 표시)
- [x] 아이콘+텍스트 모드 선택 시 아이콘 선택 + 텍스트 입력 **동시** 표시
- [x] 버튼 액션별 추천 아이콘 그리드 렌더링 (4열 그리드)
- [x] 선택된 아이콘 하이라이트 (`ring-2 ring-primary/30 border-primary`)
- [x] 아이콘 크기 비율 프리셋 버튼 그룹 (작게/보통/크게/매우 크게, 한글 라벨)
- [x] px 직접 입력 필드 제거 (비율 프리셋만 제공)
- [x] `icon.name`, `icon.size``onUpdateProperty`로 저장
- [x] 아이콘 색상 컬러 피커 구현 (`ColorPickerWithTransparent` 재사용)
- [x] "텍스트 색상과 동일" 초기화 버튼 구현
- [x] 텍스트 위치 4방향 설정 추가 (`iconTextPosition`, 왼쪽/오른쪽/위쪽/아래쪽)
- [x] 아이콘-텍스트 간격 설정 추가 (`iconGap`, 슬라이더 + 직접 입력, 아이콘+텍스트 모드 전용)
- [x] 아이콘 모드 레이아웃 안내 문구 표시 (Info 아이콘 + bg-blue-50 박스)
- [x] 액션 변경 시 선택 아이콘 자동 초기화 로직 (추천 목록에 없으면 해제)
- [x] deprecated 액션에서 안내 문구 + 커스텀 아이콘 추가 버튼 표시
- [x] 아이콘/아이콘+텍스트 모드 전환 시 아이콘 미선택이면 디폴트 아이콘 자동 부여
- [x] 커스텀 아이콘 삭제 시 디폴트 아이콘으로 자동 복귀 (텍스트 모드 전환 방지)
### 3단계: 커스텀 아이콘 추가/삭제 (lucide 검색)
- [x] "lucide 검색" 버튼 UI
- [x] lucide 아이콘 검색 팝오버 (Popover + Command + CommandInput)
- [x] `import { icons } from "lucide-react"` 기반 전체 아이콘 검색/필터링
- [x] 선택 시 `componentConfig.customIcons` 배열 추가 + `addToIconMap` 동적 등록
- [x] lucide 커스텀 아이콘 그리드 렌더링 (추천 아이콘 아래, 구분선 포함)
- [x] lucide 커스텀 아이콘 X 버튼으로 개별 삭제
### 3-1단계: 커스텀 아이콘 추가/삭제 (SVG 붙여넣기)
- [x] "SVG 붙여넣기" 버튼 UI (Popover)
- [x] SVG 입력 textarea + DOMPurify 실시간 미리보기
- [x] SVG 유효성 검사 (`<svg` 태그 포함 여부)
- [x] 아이콘 이름 입력 필드 (관리자가 구분용 이름 지정)
- [x] DOMPurify로 SVG 정화(sanitize) 후 저장
- [x] `componentConfig.customSvgIcons` 배열에 `{ name, svg }` 추가
- [x] SVG 커스텀 아이콘 그리드 렌더링 (lucide 커스텀 아이콘과 함께 표시)
- [x] SVG 커스텀 아이콘 X 버튼으로 개별 삭제
- [x] 커스텀 아이콘(lucide + SVG 모두)이 모든 버튼 액션에서 공통 노출
### 4단계: 버튼 렌더링 수정 (뷰어/위젯)
- [x] `InteractiveScreenViewerDynamic.tsx` - `ButtonIconRenderer` 적용
- [x] `InteractiveScreenViewer.tsx` - `ButtonIconRenderer` 적용
- [x] `ButtonWidget.tsx` - `ButtonIconRenderer` 적용 (디자인/실행 모드 모두)
- [x] `OptimizedButtonComponent.tsx` - `ButtonIconRenderer` 적용 (실행 중 "처리 중..." 유지)
- [x] `ButtonPrimaryComponent.tsx` - `ButtonIconRenderer` 적용 (v2-button-primary 캔버스 렌더링)
- [x] lucide 아이콘 렌더링 (`icon.type === "lucide"`, `getLucideIcon` 조회)
- [x] SVG 아이콘 렌더링 (`icon.type === "svg"`, DOMPurify 정화 후 innerHTML)
- [x] 아이콘+텍스트 모드: `inline-flex items-center` + 동적 `gap` (iconGap px)
- [x] `icon.color` 설정 시 아이콘만 별도 색상 적용 (inline style)
- [x] `icon.color` 미설정 시 textColor 상속 (currentColor 기본)
- [x] 아이콘 크기 비율 프리셋 `getIconSizeStyle()` 처리 (버튼 높이 대비 %)
- [x] 텍스트 위치 4방향 렌더링 (`flexDirection` + 요소 순서 조합)
### 4-2단계: 버튼 테두리 이중 적용 수정
- [x] `RealtimePreviewDynamic.tsx` — position wrapper에서 버튼 컴포넌트 border strip 추가
- [x] `ButtonPrimaryComponent.tsx` — 외부 wrapper에서 border 속성 destructure 제거
- [x] `ButtonPrimaryComponent.tsx``border: "none"` shorthand 제거, 개별 longhand 속성으로 변경
- [x] `isButtonComponent` 조건에 `"v2-button-primary"` 추가
### 4-1단계: 키보드 단축키 충돌 수정
- [x] `ScreenDesigner.tsx` 글로벌 keydown 핸들러에 입력 필드 감지 가드 추가
- [x] `browserShortcuts` 배열에서 `Ctrl+V` 제거
- [x] 입력 필드(input/textarea/select/contentEditable) 포커스 시 Ctrl+A/C/V/Z 기본 동작 허용
- [x] SVG 붙여넣기 textarea에 `onPaste`/`onKeyDown` stopPropagation 핸들러 추가
### 5단계: 검증
- [x] 텍스트 모드: 기존 동작 변화 없음 확인 (하위 호환성)
- [x] `displayMode` 없는 기존 버튼: 텍스트 모드로 정상 동작
- [x] 아이콘 모드 선택 → 추천 아이콘 6개 그리드 표시
- [x] 아이콘 선택 → 캔버스(오른쪽 프리뷰) 및 실제 화면에서 아이콘 렌더링 확인
- [x] 아이콘 크기 비율 프리셋 변경 → 버튼 높이 대비 비율 반영 확인
- [x] 텍스트 위치 4방향(왼/오른/위/아래) 변경 → 레이아웃 방향 반영 확인
- [x] 버튼 테두리 설정 → 내부 버튼에만 적용, 외부 wrapper에 이중 적용 없음 확인
- [x] 버튼 액션 변경 → 추천 아이콘 목록 갱신 확인
- [x] lucide 커스텀 아이콘 추가 → 모든 액션에서 노출 확인
- [x] SVG 커스텀 아이콘 붙여넣기 → 미리보기 → 추가 → 모든 액션에서 노출 확인
- [x] SVG에 악성 코드 삽입 시도 → DOMPurify 정화 후 안전 렌더링 확인
- [x] 커스텀 아이콘 삭제 → 목록에서 제거 확인
- [x] deprecated 액션에서 안내 문구 + 커스텀 아이콘 추가 가능 확인
- [x] 아이콘+텍스트 모드: 아이콘 + 텍스트 나란히 렌더링 확인
- [x] 아이콘+텍스트 간격 조절: 슬라이더/직접 입력으로 간격 변경 → 실시간 반영 확인
- [x] 아이콘 색상 미설정 → textColor와 동일한 색상 확인
- [x] 아이콘 색상 설정 → 아이콘만 해당 색상, 텍스트는 textColor 유지 확인
- [x] 외부 SVG (고유 색상) → icon.color 설정해도 SVG 원본 색상 유지 확인
- [x] "텍스트 색상과 동일" 버튼 → icon.color 해제되고 textColor 상속 복원 확인
- [x] 레이아웃 안내 문구: 아이콘 모드에서만 표시, 다른 모드에서 숨김 확인
- [x] 입력 필드에서 Ctrl+A/C/V/Z 단축키 정상 동작 확인
- [x] 아이콘 모드 전환 시 디폴트 아이콘 자동 선택 → 캔버스에 즉시 반영 확인
- [x] 커스텀 아이콘 삭제 시 디폴트 아이콘으로 복귀 → 아이콘 모드 유지 확인
- [x] deprecated 액션에서 디폴트 폴백 아이콘(SquareMousePointer) 표시 확인
### 6단계: 정리
- [x] TypeScript 컴파일 에러 없음 확인 (우리 파일 6개 모두 0 에러)
- [x] 불필요한 import 없음 확인
- [x] 이 체크리스트 완료 표시 업데이트
---
## 변경 이력
| 날짜 | 내용 |
|------|------|
| 2026-03-04 | 계획서, 맥락노트, 체크리스트 작성 완료 |
| 2026-03-04 | 외부 SVG 붙여넣기 기능 추가 (3개 문서 모두 반영) |
| 2026-03-04 | 아이콘+텍스트 모드, 레이아웃 안내 추가 |
| 2026-03-04 | 설정 패널 내 미리보기 제거 (오른쪽 캔버스 프리뷰로 대체) |
| 2026-03-04 | 아이콘 색상 설정 추가 (icon.color, 기본값 textColor 상속) |
| 2026-03-04 | 3개 문서 교차 검토 — 개요 누락 보완, 시각 예시 문구 통일, 렌더 함수 px 대응, 용어 명확화 |
| 2026-03-04 | 구현 완료 — 1~4단계 코드 작성, 6단계 린트/타입 검증 통과 |
| 2026-03-04 | 아이콘-텍스트 간격 설정 추가 (iconGap, 슬라이더+직접 입력) |
| 2026-03-04 | noIconAction에서 커스텀 아이콘 추가 허용 + 안내 문구 변경 |
| 2026-03-04 | ScreenDesigner 키보드 단축키 수정 — 입력 필드에서 텍스트 편집 단축키 허용 |
| 2026-03-04 | SVG 붙여넣기 textarea에 onPaste/onKeyDown 핸들러 추가 |
| 2026-03-04 | SVG 커스텀 아이콘 이름 중복 방지 (자동 넘버링) |
| 2026-03-04 | 디폴트 아이콘 자동 부여 — 모드 전환 시 자동 선택, 커스텀 삭제 시 디폴트 복귀 |
| 2026-03-04 | `getDefaultIconForAction()` 유틸 + `SquareMousePointer` 폴백 아이콘 추가 |
| 2026-03-04 | 3개 문서 변경사항 동기화 및 코드 정리 |
| 2026-03-04 | 아이콘 크기: 절대 px → 버튼 높이 대비 비율(%) 4단계 프리셋으로 변경, px 직접 입력 제거 |
| 2026-03-04 | 텍스트 위치 4방향 설정 추가 (왼쪽/오른쪽/위쪽/아래쪽) |
| 2026-03-04 | 버튼 테두리 이중 적용 수정 — position wrapper에서 border strip, border shorthand 제거 |
| 2026-03-04 | 프리셋 라벨 한글화 (작게/보통/크게/매우 크게), 라벨 "아이콘 크기 비율"로 변경 |

View File

@ -0,0 +1,146 @@
# [계획서] V2Select 다중 선택 드롭다운 - 선택 항목 표시 개선
> 관련 문서: [맥락노트](./MST[맥락노트]-v2select-multiselect-tooltip.md) | [체크리스트](./MST[체크리스트]-v2select-multiselect-tooltip.md)
## 개요
모든 화면에서 다중 선택 가능한 드롭다운(`V2Select` - `DropdownSelect`)의 선택 항목 표시 방식을 개선합니다.
---
## 현재 동작
- 다중 선택 시 `"3개 선택됨"` 같은 텍스트만 표시
- 어떤 항목이 선택되었는지 드롭다운을 열어야만 확인 가능
### 현재 코드 (V2Select.tsx - DropdownSelect, 174~178행)
```tsx
{selectedLabels.length > 0
? multiple
? `${selectedLabels.length}개 선택됨`
: selectedLabels[0]
: placeholder}
```
---
## 변경 후 동작
### 1. 선택된 항목 라벨을 쉼표로 연결하여 한 줄로 표시
- 예: `"구매품, 판매품, 재고품"`
- `truncate` (text-overflow: ellipsis)로 필드 너비를 넘으면 말줄임(`...`) 처리
- 무조건 한 줄 표시, 넘치면 `...`으로 숨김
### 2. 텍스트가 말줄임(`...`) 처리될 때만 호버 툴팁 표시
- 필드 너비를 넘어서 `...`으로 잘릴 때만 툴팁 활성화
- 필드 내에 전부 보이면 툴팁 불필요
- 툴팁 내용은 세로 나열로 각 항목을 한눈에 확인 가능
- 툴팁은 딜레이 없이 즉시 표시
---
## 시각적 동작 예시
| 상태 | 필드 내 표시 | 호버 시 툴팁 |
|------|-------------|-------------|
| 미선택 | `선택` (placeholder) | 없음 |
| 1개 선택 | `구매품` | 없음 |
| 3개 선택 (필드 내 수용) | `구매품, 판매품, 재고품` | 없음 (잘리지 않으므로) |
| 5개 선택 (필드 넘침) | `구매품, 판매품, 재고품, 외...` | 구매품 / 판매품 / 재고품 / 외주품 / 반제품 (세로 나열) |
---
## 변경 대상
- **파일**: `frontend/components/v2/V2Select.tsx`
- **컴포넌트**: `DropdownSelect` 내부 표시 텍스트 부분 (170~178행)
- **적용 범위**: `DropdownSelect`를 사용하는 모든 화면 (품목정보, 기타 모든 모달 포함)
- **변경 규모**: 약 30줄 내외 소규모 변경
---
## 코드 설계
### 추가 import
```tsx
import { useRef, useEffect, useState } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
```
### 말줄임 감지 로직
```tsx
// 텍스트가 잘리는지(truncated) 감지
const textRef = useRef<HTMLSpanElement>(null);
const [isTruncated, setIsTruncated] = useState(false);
useEffect(() => {
const el = textRef.current;
if (el) {
setIsTruncated(el.scrollWidth > el.clientWidth);
}
}, [selectedLabels]);
```
### 수정 코드 (DropdownSelect 내부, 170~178행 대체)
```tsx
const displayText = selectedLabels.length > 0
? (multiple ? selectedLabels.join(", ") : selectedLabels[0])
: placeholder;
const isPlaceholder = selectedLabels.length === 0;
// 렌더링 부분
{isTruncated && multiple ? (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<span
ref={textRef}
className={cn("truncate flex-1 text-left", isPlaceholder && "text-muted-foreground")}
{...(isPlaceholder ? { "data-placeholder": placeholder } : {})}
>
{displayText}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[300px]">
<div className="space-y-0.5 text-xs">
{selectedLabels.map((label, i) => (
<div key={i}>{label}</div>
))}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span
ref={textRef}
className={cn("truncate flex-1 text-left", isPlaceholder && "text-muted-foreground")}
{...(isPlaceholder ? { "data-placeholder": placeholder } : {})}
>
{displayText}
</span>
)}
```
---
## 설계 원칙
- 기존 단일 선택 동작은 변경하지 않음
- `DropdownSelect` 공통 컴포넌트 수정이므로 모든 화면에 자동 적용
- 무조건 한 줄 표시, 넘치면 `...`으로 말줄임
- 툴팁은 텍스트가 실제로 잘릴 때(`scrollWidth > clientWidth`)만 표시
- 툴팁 내용은 세로 나열로 각 항목 확인 용이
- 툴팁 딜레이 없음 (`delayDuration={0}`)
- shadcn 표준 Tooltip 컴포넌트 사용으로 프로젝트 일관성 유지

View File

@ -0,0 +1,95 @@
# [맥락노트] V2Select 다중 선택 드롭다운 - 선택 항목 표시 개선
> 관련 문서: [계획서](./MST[계획서]-v2select-multiselect-tooltip.md) | [체크리스트](./MST[체크리스트]-v2select-multiselect-tooltip.md)
---
## 왜 이 작업을 하는가
- 사용자가 수정 모달에서 다중 선택 드롭다운을 사용할 때 `"3개 선택됨"` 만 보임
- 드롭다운을 다시 열어봐야만 무엇이 선택됐는지 확인 가능 → UX 불편
- 선택 항목을 직접 보여주고, 넘치면 툴팁으로 확인할 수 있게 개선
---
## 핵심 결정 사항과 근거
### 1. "n개 선택됨" → 라벨 쉼표 나열
- **결정**: `"구매품, 판매품, 재고품"` 형태로 표시
- **근거**: 사용자가 드롭다운을 열지 않아도 선택 내용을 바로 확인 가능
### 2. 무조건 한 줄, 넘치면 말줄임(`...`)
- **결정**: 여러 줄 줄바꿈 없이 한 줄 고정, `truncate`로 오버플로우 처리
- **근거**: 드롭다운 필드 높이가 고정되어 있어 여러 줄 표시 시 레이아웃이 깨짐
### 3. 텍스트가 잘릴 때만 툴팁 표시
- **결정**: `scrollWidth > clientWidth` 비교로 실제 잘림 여부 감지 후 툴팁 활성화
- **근거**: 전부 보이는데 툴팁이 뜨면 오히려 방해. 필요할 때만 보여야 함
- **대안 검토**: "2개 이상이면 항상 툴팁" → 기각 (불필요한 툴팁 발생)
### 4. 툴팁 내용은 세로 나열
- **결정**: 툴팁 안에서 항목을 줄바꿈으로 세로 나열
- **근거**: 가로 나열 시 툴팁도 길어져서 읽기 어려움. 세로가 한눈에 파악하기 좋음
### 5. 툴팁 딜레이 0ms
- **결정**: `delayDuration={0}` 즉시 표시
- **근거**: 사용자가 "무엇을 선택했는지" 확인하려는 의도적 행동이므로 즉시 응답해야 함
### 6. Radix Tooltip 대신 커스텀 호버 툴팁 사용
- **결정**: Radix Tooltip을 사용하지 않고 `onMouseEnter`/`onMouseLeave`로 직접 제어
- **근거**: Radix Tooltip + Popover 조합은 이벤트 충돌 발생. 내부 배치든 외부 래핑이든 Popover가 호버를 가로챔
- **시도 1**: Tooltip을 Button 안에 배치 → Popover가 이벤트 가로챔 (실패)
- **시도 2**: Radix 공식 패턴 (TooltipTrigger > PopoverTrigger > Button 체이닝) → 여전히 동작 안 함 (실패)
- **최종**: wrapper div에 마우스 이벤트 + 절대 위치 div로 툴팁 렌더링 (성공)
- **추가**: Popover 열릴 때 `setHoverTooltip(false)`로 툴팁 자동 숨김
### 8. 선택 항목 표시 순서는 드롭다운 옵션 순서 기준
- **결정**: 사용자가 클릭한 순서가 아닌 드롭다운 옵션 목록 순서대로 표시
- **근거**: 선택 순서대로 보여주면 매번 순서가 달라져서 혼란. 옵션 순서 기준이 일관적이고 예측 가능
- **구현**: `selectedValues.map(...)``safeOptions.filter(...).map(...)` 으로 변경
### 9. DropdownSelect 공통 컴포넌트 수정
- **결정**: 특정 화면이 아닌 `DropdownSelect` 자체를 수정
- **근거**: 품목정보뿐 아니라 모든 화면에서 동일한 문제가 있으므로 공통 해결
---
## 관련 파일 위치
| 구분 | 파일 경로 | 설명 |
|------|----------|------|
| 수정 대상 | `frontend/components/v2/V2Select.tsx` | DropdownSelect 컴포넌트 (170~178행) |
| 타입 정의 | `frontend/types/v2-components.ts` | V2SelectProps, SelectOption 타입 |
| UI 컴포넌트 | `frontend/components/ui/tooltip.tsx` | shadcn Tooltip 컴포넌트 |
| 렌더러 | `frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx` | V2Select를 레지스트리에 연결 |
| 수정 모달 | `frontend/components/screen/EditModal.tsx` | 공통 편집 모달 |
---
## 기술 참고
### truncate 감지 방식
```
scrollWidth: 텍스트의 실제 전체 너비 (보이지 않는 부분 포함)
clientWidth: 요소의 보이는 너비
scrollWidth > clientWidth → 텍스트가 잘리고 있음 (... 표시 중)
```
### selectedLabels 계산 흐름
```
value (string[]) → selectedValues → safeOptions에서 label 매칭 → selectedLabels (string[])
```
- `selectedLabels`는 이미 `DropdownSelect` 내부에서 `useMemo`로 계산됨 (126~130행)
- 추가 데이터 fetching 불필요, 기존 값을 `.join(", ")`로 결합하면 됨

View File

@ -0,0 +1,54 @@
# [체크리스트] V2Select 다중 선택 드롭다운 - 선택 항목 표시 개선
> 관련 문서: [계획서](./MST[계획서]-v2select-multiselect-tooltip.md) | [맥락노트](./MST[맥락노트]-v2select-multiselect-tooltip.md)
---
## 공정 상태
- 전체 진행률: **100%** (완료)
- 현재 단계: 전체 완료
---
## 구현 체크리스트
### 1단계: 코드 수정
- [x] `V2Select.tsx`에 Tooltip 관련 import 추가
- [x] `DropdownSelect` 내부에 `textRef`, `isTruncated` 상태 추가
- [x] `useEffect``scrollWidth > clientWidth` 감지 로직 추가
- [x] 표시 텍스트를 `selectedLabels.join(", ")`로 변경
- [x] `isTruncated && multiple` 조건으로 Tooltip 래핑
- [x] 툴팁 내용을 세로 나열 (`space-y-0.5`)로 구성
- [x] `delayDuration={0}` 설정
- [x] Radix Tooltip → 커스텀 호버 툴팁으로 변경 (onMouseEnter/onMouseLeave + 절대 위치 div)
- [x] 선택 항목 표시 순서를 드롭다운 옵션 순서 기준으로 변경
### 2단계: 검증
- [x] 단일 선택 모드: 기존 동작 변화 없음 확인
- [x] 다중 선택 1개: 라벨 정상 표시, 툴팁 없음
- [x] 다중 선택 3개 (필드 내 수용): 쉼표 나열 표시, 툴팁 없음
- [x] 다중 선택 5개+ (필드 넘침): 말줄임 표시, 호버 시 툴팁 세로 나열
- [x] 품목정보 수정 모달에서 동작 확인
- [x] 다른 화면의 다중 선택 드롭다운에서도 동작 확인
### 3단계: 정리
- [x] 린트 에러 없음 확인
- [x] 이 체크리스트 완료 표시 업데이트
---
## 변경 이력
| 날짜 | 내용 |
|------|------|
| 2026-03-04 | 설계 문서 작성 완료 |
| 2026-03-04 | 맥락노트, 체크리스트 작성 완료 |
| 2026-03-04 | 파일명 MST 접두사 적용 |
| 2026-03-04 | 1단계 코드 수정 완료 (V2Select.tsx) |
| 2026-03-04 | Radix Tooltip이 Popover와 충돌 → 커스텀 호버 툴팁으로 변경 |
| 2026-03-04 | 사용자 검증 완료, 전체 작업 완료 |
| 2026-03-04 | 선택 항목 표시 순서를 옵션 순서 기준으로 변경 |

View File

@ -0,0 +1,241 @@
# 탭 시스템 아키텍처 및 구현 계획
## 1. 개요
사이드바 메뉴 클릭 시 `router.push()` 페이지 이동 방식에서 **탭 기반 멀티 화면 시스템**으로 전환한다.
```
┌──────────────────────────┐
│ Tab Data Layer (중앙) │
API 응답 ────────→│ │
│ 탭별 상태 저장소 │
│ ├─ formData │
│ ├─ selectedRows │
│ ├─ scrollPosition │
│ ├─ modalState │
│ ├─ sortState │
│ └─ cacheState │
│ │
│ 공통 규칙 엔진 │
│ ├─ 날짜 포맷 규칙 │
│ ├─ 숫자/통화 포맷 규칙 │
│ ├─ 로케일 처리 규칙 │
│ ├─ 유효성 검증 규칙 │
│ └─ 데이터 타입 변환 규칙 │
│ │
│ F5 복원 / 캐시 관리 │
│ (sessionStorage 중앙관리) │
└────────────┬─────────────┘
가공 완료된 데이터
┌────────────────┼────────────────┐
│ │ │
화면 A (경량) 화면 B (경량) 화면 C (경량)
렌더링만 담당 렌더링만 담당 렌더링만 담당
```
## 2. 레이어 구조
| 레이어 | 책임 |
|---|---|
| **Tab Data Layer** | 탭별 상태 보관, 캐시, 복원, 데이터 가공 |
| **공통 규칙 엔진** | 날짜/숫자/로케일 포맷, 유효성 검증 |
| **화면 컴포넌트** | 가공된 데이터를 받아서 렌더링만 담당 |
## 3. 파일 구성
| 파일 | 역할 |
|---|---|
| `stores/tabStore.ts` | Zustand 기반 탭 상태 관리 |
| `components/layout/TabBar.tsx` | 탭 바 UI (드래그, 우클릭, 오버플로우) |
| `components/layout/TabContent.tsx` | 탭별 콘텐츠 렌더링 (컨테이너) |
| `components/layout/EmptyDashboard.tsx` | 탭 없을 때 안내 화면 |
| `components/layout/AppLayout.tsx` | 전체 레이아웃 (사이드바 + 탭 + 콘텐츠) |
| `lib/tabStateCache.ts` | 탭별 상태 캐싱 엔진 |
| `lib/formatting/rules.ts` | 포맷 규칙 정의 |
| `lib/formatting/index.ts` | formatDate, formatNumber, formatCurrency |
| `app/(main)/screens/[screenId]/page.tsx` | 화면별 렌더링 |
## 4. 기술 스택
- Next.js 15, React 19, Zustand
- Tailwind CSS, shadcn/ui
---
## 5. Phase 1: 탭 껍데기
### 5-1. Zustand 탭 Store (`stores/tabStore.ts`)
- [ ] zustand 직접 의존성 추가
- [ ] Tab 인터페이스: id, type, title, screenId, menuObjid, adminUrl
- [ ] 탭 목록, 활성 탭 ID
- [ ] openTab, closeTab, switchTab, refreshTab
- [ ] closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs
- [ ] updateTabOrder (드래그 순서 변경)
- [ ] 중복 방지: 같은 탭이면 해당 탭으로 이동
- [ ] 닫기 후 왼쪽 탭으로 이동, 왼쪽 없으면 오른쪽
- [ ] sessionStorage 영속화 (persist middleware)
- [ ] 탭 ID 생성 규칙: V2 화면 `tab-{screenId}-{menuObjid}`, URL 탭 `tab-url-{menuObjid}`
### 5-2. TabBar 컴포넌트 (`components/layout/TabBar.tsx`)
- [ ] 고정 너비 탭, 화면 너비에 맞게 동적 개수
- [ ] 활성 탭: 새로고침 버튼 + X 버튼
- [ ] 비활성 탭: X 버튼만
- [ ] 오버플로우 시 +N 드롭다운 (ResizeObserver 감시)
- [ ] 드래그 순서 변경 (mousedown/move/up, DOM transform 직접 조작)
- [ ] 사이드바 메뉴 드래그 드롭 수신 (`application/tab-menu` 커스텀 데이터, 마우스 위치에 삽입)
- [ ] 우클릭 컨텍스트 메뉴 (새로고침/왼쪽닫기/오른쪽닫기/다른탭닫기/모든탭닫기)
- [ ] 휠 클릭: 탭 즉시 닫기
### 5-3. TabContent 컴포넌트 (`components/layout/TabContent.tsx`)
- [ ] display:none 방식 (비활성 탭 DOM 유지, 상태 보존)
- [ ] 지연 마운트 (한 번 활성화된 탭만 마운트)
- [ ] 안정적 순서 유지 (탭 순서 변경 시 리마운트 방지)
- [ ] 탭별 모달 격리 (DialogPortalContainerContext)
- [ ] tab.type === "screen" -> ScreenViewPageWrapper 임베딩
- [ ] tab.type === "admin" -> 동적 import로 관리자 페이지 렌더링
### 5-4. EmptyDashboard 컴포넌트 (`components/layout/EmptyDashboard.tsx`)
- [ ] 탭이 없을 때 "사이드바에서 메뉴를 선택하여 탭을 추가하세요" 표시
### 5-5. AppLayout 수정 (`components/layout/AppLayout.tsx`)
- [ ] handleMenuClick: router.push -> tabStore.openTab 호출
- [ ] 레이아웃: main 영역을 TabBar + TabContent로 교체
- [ ] children prop 제거 (탭이 콘텐츠 관리)
- [ ] 사이드바 메뉴 드래그 가능하게 (draggable)
### 5-6. 라우팅 연동
- [ ] `app/(main)/layout.tsx` 수정 - children 대신 탭 시스템
- [ ] URL 직접 접근 시 탭으로 열기 (북마크/공유 링크 대응)
---
## 6. Phase 2: F5 최대 복원
### 6-1. 탭 상태 캐싱 엔진 (`lib/tabStateCache.ts`)
- [ ] 탭별 상태 저장/복원 (sessionStorage)
- [ ] 저장 대상: formData, selectedRows, sortState, scrollPosition, modalState, checkboxState
- [ ] debounce 적용 (상태 변경마다 저장하지 않음)
### 6-2. 복원 로직
- [ ] 활성 탭: fresh API 호출 (캐시 데이터 무시)
- [ ] 비활성 탭: 캐시에서 복원
- [ ] 탭 닫기 시 해당 탭의 캐시 키 일괄 삭제
### 6-3. 캐시 키 관리 (clearTabStateCache)
탭 닫기/새로고침 시 관련 sessionStorage 키 일괄 제거:
- `tab-cache-{screenId}-{menuObjid}`
- `page-scroll-{screenId}-{menuObjid}`
- `tsp-{screenId}-*`, `table-state-{screenId}-*`
- `split-sel-{screenId}-*`, `catval-sel-{screenId}-*`
- `bom-tree-{screenId}-*`
- URL 탭: `tsp-{urlHash}-*`, `admin-scroll-{url}`
---
## 7. Phase 3: 포맷팅 중앙화
### 7-1. 포맷팅 규칙 엔진
```typescript
// lib/formatting/rules.ts
interface FormatRules {
date: {
display: string; // "YYYY-MM-DD"
datetime: string; // "YYYY-MM-DD HH:mm:ss"
input: string; // "YYYY-MM-DD"
};
number: {
locale: string; // 사용자 로케일 기반
decimals: number; // 기본 소수점 자릿수
};
currency: {
code: string; // 회사 설정 기반
locale: string;
};
}
export function formatValue(value: any, dataType: string, rules: FormatRules): string;
export function formatDate(value: any, format?: string): string;
export function formatNumber(value: any, locale?: string): string;
export function formatCurrency(value: any, currencyCode?: string): string;
```
### 7-2. 하드코딩 교체 대상
- [ ] V2DateRenderer.tsx
- [ ] EditModal.tsx
- [ ] InteractiveDataTable.tsx
- [ ] FlowWidget.tsx
- [ ] AggregationWidgetComponent.tsx
- [ ] aggregation.ts (피벗)
- [ ] 기타 하드코딩 파일들
---
## 8. Phase 4: ScreenViewPage 경량화
- [ ] 탭 데이터 레이어에서 받은 데이터로 렌더링만 담당
- [ ] API 호출, 캐시, 복원 로직 제거 (탭 레이어가 담당)
- [ ] 관리자 페이지도 동일한 데이터 레이어 패턴 적용
---
---
## 구현 완료: 다중 스크롤 영역 F5 복원
### 개요
split panel 등 한 탭 안에 **스크롤 영역이 여러 개**인 화면에서, F5 새로고침 후에도 각 영역의 스크롤 위치가 복원된다.
탭 전환 시에는 `display: none` 방식으로 DOM이 유지되므로 브라우저가 스크롤을 자연 보존한다. 이 기능은 **F5 새로고침** 전용이다.
### 동작 방식
탭 내 모든 스크롤 가능한 요소를 DOM 경로(`"0/1/0/2"` 형태)와 함께 저장한다.
```
scrollPositions: [
{ path: "0/1/0/2", top: 150, left: 0 }, // 예: 좌측 패널
{ path: "0/1/1/3/1", top: 420, left: 0 }, // 예: 우측 패널
]
```
- **실시간 추적**: 스크롤 이벤트 발생 시 해당 요소의 경로와 위치를 Map에 기록
- **저장 시점**: 탭 전환 시 + `beforeunload`(F5/닫기) 시 sessionStorage에 저장
- **복원 시점**: 탭 활성화 시 경로를 기반으로 각 요소를 찾아 개별 복원
### 관련 파일 및 주요 함수
| 파일 | 역할 |
|---|---|
| `lib/tabStateCache.ts` | 스크롤 캡처/복원 핵심 로직 |
| `components/layout/TabContent.tsx` | 스크롤 이벤트 감지, 저장/복원 호출 |
**`tabStateCache.ts` 핵심 함수**:
| 함수 | 설명 |
|---|---|
| `getElementPath(element, container)` | 요소의 DOM 경로를 자식 인덱스 문자열로 생성 |
| `captureAllScrollPositions(container)` | TreeWalker로 컨테이너 하위 모든 스크롤 요소의 위치를 일괄 캡처 |
| `restoreAllScrollPositions(container, positions)` | 경로 기반으로 각 요소를 찾아 스크롤 위치 복원 (콘텐츠 렌더링 대기 폴링 포함) |
**`TabContent.tsx` 핵심 Ref**:
| Ref | 설명 |
|---|---|
| `lastScrollMapRef` | `Map<tabId, Map<path, {top, left}>>` - 탭 내 요소별 최신 스크롤 위치 |
| `pathCacheRef` | `WeakMap<HTMLElement, string>` - 동일 요소의 경로 재계산 방지용 캐시 |
---
## 9. 참고 파일
| 파일 | 비고 |
|---|---|
| `frontend/components/layout/AppLayout.tsx` | 사이드바 + 콘텐츠 레이아웃 |
| `frontend/app/(main)/screens/[screenId]/page.tsx` | 화면 렌더링 (건드리지 않음) |
| `frontend/stores/modalDataStore.ts` | Zustand store 참고 패턴 |
| `frontend/lib/adminPageRegistry.tsx` | 관리자 페이지 레지스트리 |

View File

@ -0,0 +1,231 @@
# 모달 필수 입력 검증 설계
## 1. 목표
모든 모달에서 필수 입력값이 빈 상태로 저장 버튼을 클릭하면:
- 첫 번째 빈 필수 필드로 포커스 이동 + 하이라이트
- 우측 상단에 토스트 알림 ("○○ 항목을 입력해주세요")
- 버튼은 항상 활성 상태 (비활성화하지 않음)
---
## 2. 전체 구조
```
┌─────────────────────────────────────────────────────────────────┐
│ DialogContent (모든 모달의 공통 래퍼) │
│ │
│ useDialogAutoValidation(contentRef) │
│ │ │
│ ├─ 0단계: 모드 확인 │
│ │ └─ useTabStore.mode === "user" 일 때만 실행 │
│ │ │
│ ├─ 1단계: 필수 필드 탐지 │
│ │ └─ Label 내부 <span> 안에 * 문자 존재 여부 │
│ │ (라벨 텍스트 직접 매칭 X → span 태그 안의 * 만 감지) │
│ │ │
│ └─ 2단계: 저장 버튼 클릭 인터셉트 │
│ │ │
│ ├─ 저장/수정/확인 버튼 클릭 감지 │
│ │ (data-action-type="save"/"submit" │
│ │ 또는 data-variant="default") │
│ │ │
│ ├─ 빈 필수 필드 있음: │
│ │ ├─ 클릭 이벤트 차단 (stopPropagation + preventDefault) │
│ │ ├─ 첫 번째 빈 필드로 포커스 이동 │
│ │ ├─ 해당 필드 빨간 테두리 + 하이라이트 애니메이션 │
│ │ └─ 토스트 알림: "{필드명} 항목을 입력해주세요" │
│ │ │
│ └─ 모든 필수 필드 입력됨: │
│ └─ 클릭 이벤트 통과 (정상 저장 진행) │
│ │
│ 제외 조건: │
│ └─ 필수 필드가 0개인 모달 → 인터셉트 없음 │
└─────────────────────────────────────────────────────────────────┘
```
---
## 3. 필수 필드 감지: span 기반 * 감지
### 원리
화면 관리에서 필드를 "필수"로 체크하면 `component.required = true`가 저장된다.
V2 컴포넌트가 렌더링할 때 `required = true`이면 Label 안에 `<span>*</span>`을 추가한다.
훅은 이 span 안의 `*`를 감지하여 필수 필드를 식별한다.
### 오탐 방지
관리자가 라벨 텍스트에 직접 `*`를 입력해도 span 안에 들어가지 않으므로 오탐이 발생하지 않는다.
```
required = true → <label>품목코드<span class="text-orange-500">*</span></label>
→ span 안에 * 있음 → 감지 O
required = false → <label>품목코드</label>
→ span 없음 → 감지 X
라벨에 * 직접 입력 → <label>품목코드*</label>
→ span 없이 텍스트에 * → 감지 X (오탐 방지)
```
### 지원 필드 타입
| V2 컴포넌트 | 렌더링 요소 | 빈값 판정 |
|---|---|---|
| V2Input | `<input>`, `<textarea>` | `value.trim() === ""` |
| V2Select | `<button role="combobox">` | `querySelector("[data-placeholder]")` 존재 |
| V2Date | `<input>` (날짜/시간) | `value.trim() === ""` |
---
## 4. 저장 버튼 클릭 인터셉트
### 원리
버튼을 비활성화하지 않고, 클릭 이벤트를 캡처링 단계에서 가로챈다.
빈 필수 필드가 있으면 이벤트를 차단하고, 없으면 통과시킨다.
### 인터셉트 대상 버튼
| 조건 | 예시 |
|------|------|
| `data-action-type="save"` | ButtonPrimary 저장 버튼 |
| `data-action-type="submit"` | ButtonPrimary 제출 버튼 |
| `data-variant="default"` | shadcn Button 기본 (저장/확인/등록) |
### 인터셉트하지 않는 버튼
| 조건 | 예시 |
|------|------|
| `data-variant` = outline/ghost/destructive/secondary | 취소, 닫기, 삭제 |
| `role` = combobox/tab/switch 등 | 폼 컨트롤 |
| `data-action-type` != save/submit | 기타 액션 버튼 |
| `data-dialog-close` | 모달 닫기 X 버튼 |
---
## 5. 시각적 피드백
### 포커스 이동
첫 번째 빈 필수 필드로 커서를 이동한다.
- `<input>`, `<textarea>`: `input.focus()`
- `<button role="combobox">` (V2Select): `button.click()` → 드롭다운 열기
### 하이라이트 애니메이션
빈 필수 필드에 빨간 테두리 + 흔들림 효과를 준다.
```css
@keyframes validationShake {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-4px); }
40%, 80% { transform: translateX(4px); }
}
[data-validation-highlight] {
border-color: hsl(var(--destructive)) !important;
animation: validationShake 400ms ease-in-out;
}
```
애니메이션 종료 후 `data-validation-highlight` 속성 제거 (일회성).
### 토스트 알림
우측 상단에 토스트 메시지를 표시한다.
```typescript
toast.error(`${fieldLabel} 항목을 입력해주세요`);
```
---
## 6. 동작 흐름
```
모달 열림
DialogContent 마운트
useDialogAutoValidation 실행
모드 확인 (useTabStore.mode)
├─ mode !== "user"? → return
필수 필드 탐지 (Label 내 span에서 * 감지)
├─ 필수 필드 0개? → return
클릭 이벤트 리스너 등록 (캡처링 단계)
사용자가 저장 버튼 클릭
인터셉트 대상 버튼인가?
├─ 아니오 → 클릭 통과
빈 필수 필드 검사
├─ 모두 입력됨 → 클릭 통과 (정상 저장)
├─ 빈 필드 있음:
│ ├─ e.stopPropagation() + e.preventDefault()
│ ├─ 첫 번째 빈 필드에 포커스 이동
│ ├─ 해당 필드에 data-validation-highlight 속성 추가
│ ├─ 애니메이션 종료 후 속성 제거
│ └─ toast.error("{필드명} 항목을 입력해주세요")
모달 닫힘
클린업
├─ 이벤트 리스너 제거
└─ 하이라이트 속성 제거
```
---
## 7. 관련 파일
| 파일 | 역할 |
|------|------|
| `frontend/lib/hooks/useDialogAutoValidation.ts` | 검증 훅 본체 |
| `frontend/components/ui/dialog.tsx` | DialogContent에서 훅 호출 |
| `frontend/components/ui/button.tsx` | data-variant 속성 노출 |
| `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` | data-action-type 속성 노출 |
| `frontend/app/globals.css` | 하이라이트 애니메이션 |
---
## 8. 적용 범위
### 현재 (1단계): 사용자 모드만
| 모달 유형 | 동작 여부 | 이유 |
|---------------------------------------|:---:|-------------------------------|
| 사용자 모드 모달 (SaveModal 등) | O | mode === "user" + span * 있음 |
| 관리자 모드 모달 (CodeFormModal 등) | X | mode !== "user" → return |
| 확인/삭제 다이얼로그 (필수 필드 없음) | X | 필수 필드 0개 → 자동 제외 |
---
## 9. 이전 방식과 비교
| 항목 | 이전 (버튼 비활성화) | 현재 (클릭 인터셉트) |
|------|---|---|
| 버튼 상태 | 빈 필드 있으면 비활성화 | 항상 활성 |
| 피드백 시점 | 모달 열릴 때부터 | 저장 버튼 클릭 시 |
| 피드백 방식 | 빨간 테두리 + 에러 문구 | 포커스 이동 + 하이라이트 + 토스트 |
| 복잡도 | 높음 (MutationObserver, 폴링, CSS 지연) | 낮음 (클릭 이벤트 하나) |

File diff suppressed because it is too large Load Diff

View File

@ -29,8 +29,15 @@ import { evaluateConditional } from "@/lib/utils/conditionalEvaluator"; // 조
import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; // 화면 다국어 import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; // 화면 다국어
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; // V2 Zod 기반 변환 import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; // V2 Zod 기반 변환
import { useScheduleGenerator, ScheduleConfirmDialog } from "@/lib/v2-core/services/ScheduleGeneratorService"; // 스케줄 자동 생성 import { useScheduleGenerator, ScheduleConfirmDialog } from "@/lib/v2-core/services/ScheduleGeneratorService"; // 스케줄 자동 생성
import { useTabId } from "@/contexts/TabIdContext";
import { useTabStore } from "@/stores/tabStore";
function ScreenViewPage() { export interface ScreenViewPageProps {
screenIdProp?: number;
menuObjidProp?: number;
}
function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {}) {
// 스케줄 자동 생성 서비스 활성화 // 스케줄 자동 생성 서비스 활성화
const { const {
showConfirmDialog, showConfirmDialog,
@ -42,10 +49,10 @@ function ScreenViewPage() {
const params = useParams(); const params = useParams();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
const screenId = parseInt(params.screenId as string); const screenId = screenIdProp ?? parseInt(params.screenId as string);
// URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프) // props 우선, 없으면 URL 쿼리에서 menuObjid 가져오기
const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined; const menuObjid = menuObjidProp ?? (searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined);
// URL 쿼리에서 프리뷰용 company_code 가져오기 // URL 쿼리에서 프리뷰용 company_code 가져오기
const previewCompanyCode = searchParams.get("company_code"); const previewCompanyCode = searchParams.get("company_code");
@ -131,10 +138,13 @@ function ScreenViewPage() {
initComponents(); initComponents();
}, []); }, []);
// 편집 모달 이벤트 리스너 등록 // 편집 모달 이벤트 리스너 등록 (활성 탭에서만 처리)
const tabId = useTabId();
useEffect(() => { useEffect(() => {
const handleOpenEditModal = (event: CustomEvent) => { const handleOpenEditModal = (event: CustomEvent) => {
// console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail); const state = useTabStore.getState();
const currentActiveTabId = state[state.mode].activeTabId;
if (tabId && tabId !== currentActiveTabId) return;
setEditModalConfig({ setEditModalConfig({
screenId: event.detail.screenId, screenId: event.detail.screenId,
@ -154,7 +164,7 @@ function ScreenViewPage() {
// @ts-expect-error - CustomEvent type // @ts-expect-error - CustomEvent type
window.removeEventListener("openEditModal", handleOpenEditModal); window.removeEventListener("openEditModal", handleOpenEditModal);
}; };
}, []); }, [tabId]);
useEffect(() => { useEffect(() => {
const loadScreen = async () => { const loadScreen = async () => {
@ -1371,16 +1381,17 @@ function ScreenViewPage() {
} }
// 실제 컴포넌트를 Provider로 감싸기 // 실제 컴포넌트를 Provider로 감싸기
function ScreenViewPageWrapper() { function ScreenViewPageWrapper({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {}) {
return ( return (
<TableSearchWidgetHeightProvider> <TableSearchWidgetHeightProvider>
<ScreenContextProvider> <ScreenContextProvider>
<SplitPanelProvider> <SplitPanelProvider>
<ScreenViewPage /> <ScreenViewPage screenIdProp={screenIdProp} menuObjidProp={menuObjidProp} />
</SplitPanelProvider> </SplitPanelProvider>
</ScreenContextProvider> </ScreenContextProvider>
</TableSearchWidgetHeightProvider> </TableSearchWidgetHeightProvider>
); );
} }
export { ScreenViewPageWrapper };
export default ScreenViewPageWrapper; export default ScreenViewPageWrapper;

View File

@ -424,28 +424,38 @@ select {
} }
} }
/* ===== 리포트 관리 페이지 반응형 축소 ===== */ /* ===== 모달 필수 입력 검증 ===== */
.report-page-content { @keyframes validationShake {
zoom: 1; 0%, 100% { transform: translateX(0); }
transition: zoom 0.15s ease; 20%, 60% { transform: translateX(-4px); }
40%, 80% { transform: translateX(4px); }
} }
@media (max-width: 1399px) { /* 흔들림 애니메이션 (일회성) */
.report-page-content { [data-validation-highlight] {
zoom: 0.9; animation: validationShake 400ms ease-in-out;
}
} }
@media (max-width: 1099px) { /* 빨간 테두리 (값 입력 전까지 유지) */
.report-page-content { [data-validation-error] {
zoom: 0.75; border-color: hsl(var(--destructive)) !important;
}
} }
@media (max-width: 799px) { /* 필수 입력 경고 문구 (입력 필드 아래, 레이아웃 영향 없음) */
.report-page-content { .validation-error-msg-wrapper {
zoom: 0.6; height: 0;
} overflow: visible;
position: relative;
}
.validation-error-msg-wrapper > p {
position: absolute;
top: 1px;
left: 0;
font-size: 11px;
color: hsl(var(--destructive));
white-space: nowrap;
pointer-events: none;
} }
/* ===== End of Global Styles ===== */ /* ===== End of Global Styles ===== */

View File

@ -4,7 +4,7 @@ import "./globals.css";
import { QueryProvider } from "@/providers/QueryProvider"; import { QueryProvider } from "@/providers/QueryProvider";
import { RegistryProvider } from "./registry-provider"; import { RegistryProvider } from "./registry-provider";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import ScreenModal from "@/components/common/ScreenModal";
const inter = Inter({ const inter = Inter({
subsets: ["latin"], subsets: ["latin"],
@ -45,7 +45,6 @@ export default function RootLayout({
<QueryProvider> <QueryProvider>
<RegistryProvider>{children}</RegistryProvider> <RegistryProvider>{children}</RegistryProvider>
<Toaster position="top-right" /> <Toaster position="top-right" />
<ScreenModal />
</QueryProvider> </QueryProvider>
{/* Portal 컨테이너 */} {/* Portal 컨테이너 */}
<div id="portal-root" data-radix-portal="true" /> <div id="portal-root" data-radix-portal="true" />

File diff suppressed because it is too large Load Diff

View File

@ -36,6 +36,8 @@ import {
TableChainConfig, TableChainConfig,
uploadMultiTableExcel, uploadMultiTableExcel,
} from "@/lib/api/multiTableExcel"; } from "@/lib/api/multiTableExcel";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
import { getTableColumns } from "@/lib/api/tableManagement";
export interface MultiTableExcelUploadModalProps { export interface MultiTableExcelUploadModalProps {
open: boolean; open: boolean;
@ -79,6 +81,18 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
// 업로드 // 업로드
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
// 카테고리 검증 관련
const [showCategoryValidation, setShowCategoryValidation] = useState(false);
const [isCategoryValidating, setIsCategoryValidating] = useState(false);
const [categoryMismatches, setCategoryMismatches] = useState<
Record<string, Array<{
invalidValue: string;
replacement: string | null;
validOptions: Array<{ code: string; label: string }>;
rowIndices: number[];
}>>
>({});
const selectedMode = config.uploadModes.find((m) => m.id === selectedModeId); const selectedMode = config.uploadModes.find((m) => m.id === selectedModeId);
// 선택된 모드에서 활성화되는 컬럼 목록 // 선택된 모드에서 활성화되는 컬럼 목록
@ -302,8 +316,161 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
} }
}; };
// 카테고리 검증: 매핑된 컬럼 중 카테고리 타입인 것의 유효하지 않은 값 감지
const validateCategoryColumns = async () => {
try {
setIsCategoryValidating(true);
if (!selectedMode) return null;
const mismatches: typeof categoryMismatches = {};
// 활성 레벨별로 카테고리 컬럼 검증
for (const levelIdx of selectedMode.activeLevels) {
const level = config.levels[levelIdx];
if (!level) continue;
// 해당 테이블의 카테고리 타입 컬럼 조회
const colResponse = await getTableColumns(level.tableName);
if (!colResponse.success || !colResponse.data?.columns) continue;
const categoryColumns = colResponse.data.columns.filter(
(col: any) => col.inputType === "category"
);
if (categoryColumns.length === 0) continue;
// 매핑된 컬럼 중 카테고리 타입인 것 찾기
for (const catCol of categoryColumns) {
const catColName = catCol.columnName || catCol.column_name;
const catDisplayName = catCol.displayName || catCol.display_name || catColName;
// level.columns에서 해당 dbColumn 찾기
const levelCol = level.columns.find((lc) => lc.dbColumn === catColName);
if (!levelCol) continue;
// 매핑에서 해당 excelHeader에 연결된 엑셀 컬럼 찾기
const mapping = columnMappings.find((m) => m.targetColumn === levelCol.excelHeader);
if (!mapping) continue;
// 유효한 카테고리 값 조회
const valuesResponse = await getCategoryValues(level.tableName, catColName);
if (!valuesResponse.success || !valuesResponse.data) continue;
const validValues = valuesResponse.data as Array<{
valueCode: string;
valueLabel: string;
}>;
const validCodes = new Set(validValues.map((v) => v.valueCode));
const validLabels = new Set(validValues.map((v) => v.valueLabel));
const validLabelsLower = new Set(validValues.map((v) => v.valueLabel.toLowerCase()));
// 엑셀 데이터에서 유효하지 않은 값 수집
const invalidMap = new Map<string, number[]>();
allData.forEach((row, rowIdx) => {
const val = row[mapping.excelColumn];
if (val === undefined || val === null || String(val).trim() === "") return;
const strVal = String(val).trim();
if (validCodes.has(strVal)) return;
if (validLabels.has(strVal)) return;
if (validLabelsLower.has(strVal.toLowerCase())) return;
if (!invalidMap.has(strVal)) {
invalidMap.set(strVal, []);
}
invalidMap.get(strVal)!.push(rowIdx);
});
if (invalidMap.size > 0) {
const options = validValues.map((v) => ({
code: v.valueCode,
label: v.valueLabel,
}));
const key = `${catColName}|||[${level.label}] ${catDisplayName}`;
mismatches[key] = Array.from(invalidMap.entries()).map(
([invalidValue, rowIndices]) => ({
invalidValue,
replacement: null,
validOptions: options,
rowIndices,
})
);
}
}
}
if (Object.keys(mismatches).length > 0) {
return mismatches;
}
return null;
} catch (error) {
console.error("카테고리 검증 실패:", error);
return null;
} finally {
setIsCategoryValidating(false);
}
};
// 카테고리 대체값 적용
const applyCategoryReplacements = () => {
for (const [, items] of Object.entries(categoryMismatches)) {
for (const item of items) {
if (item.replacement === null) {
toast.error("모든 항목의 대체 값을 선택해주세요.");
return false;
}
}
}
// 시스템 컬럼명 → 엑셀 컬럼명 역매핑 구축
const dbColToExcelCol = new Map<string, string>();
if (selectedMode) {
for (const levelIdx of selectedMode.activeLevels) {
const level = config.levels[levelIdx];
if (!level) continue;
for (const lc of level.columns) {
const mapping = columnMappings.find((m) => m.targetColumn === lc.excelHeader);
if (mapping) {
dbColToExcelCol.set(lc.dbColumn, mapping.excelColumn);
}
}
}
}
const newData = allData.map((row) => ({ ...row }));
for (const [key, items] of Object.entries(categoryMismatches)) {
const systemCol = key.split("|||")[0];
const excelCol = dbColToExcelCol.get(systemCol);
if (!excelCol) continue;
for (const item of items) {
if (!item.replacement) continue;
const selectedOption = item.validOptions.find((opt) => opt.code === item.replacement);
const replacementLabel = selectedOption?.label || item.replacement;
for (const rowIdx of item.rowIndices) {
if (newData[rowIdx]) {
newData[rowIdx][excelCol] = replacementLabel;
}
}
}
}
setAllData(newData);
setDisplayData(newData);
setShowCategoryValidation(false);
setCategoryMismatches({});
toast.success("카테고리 값이 대체되었습니다.");
setCurrentStep(3);
return true;
};
// 다음/이전 단계 // 다음/이전 단계
const handleNext = () => { const handleNext = async () => {
if (currentStep === 1) { if (currentStep === 1) {
if (!file) { if (!file) {
toast.error("파일을 선택해주세요."); toast.error("파일을 선택해주세요.");
@ -328,6 +495,14 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
toast.error(`필수 컬럼이 매핑되지 않았습니다: ${unmappedRequired.join(", ")}`); toast.error(`필수 컬럼이 매핑되지 않았습니다: ${unmappedRequired.join(", ")}`);
return; return;
} }
// 카테고리 컬럼 검증
const mismatches = await validateCategoryColumns();
if (mismatches) {
setCategoryMismatches(mismatches);
setShowCategoryValidation(true);
return;
}
} }
setCurrentStep((prev) => Math.min(prev + 1, 3)); setCurrentStep((prev) => Math.min(prev + 1, 3));
@ -349,10 +524,14 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
setDisplayData([]); setDisplayData([]);
setExcelColumns([]); setExcelColumns([]);
setColumnMappings([]); setColumnMappings([]);
setShowCategoryValidation(false);
setCategoryMismatches({});
setIsCategoryValidating(false);
} }
}, [open, config.uploadModes]); }, [open, config.uploadModes]);
return ( return (
<>
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent <DialogContent
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]" className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
@ -758,10 +937,17 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
{currentStep < 3 ? ( {currentStep < 3 ? (
<Button <Button
onClick={handleNext} onClick={handleNext}
disabled={isUploading || (currentStep === 1 && !file)} disabled={isUploading || isCategoryValidating || (currentStep === 1 && !file)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm" className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
> >
{isCategoryValidating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"다음"
)}
</Button> </Button>
) : ( ) : (
<Button <Button
@ -782,5 +968,112 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* 카테고리 대체값 선택 다이얼로그 */}
<Dialog open={showCategoryValidation} onOpenChange={(open) => {
if (!open) {
setShowCategoryValidation(false);
setCategoryMismatches({});
}
}}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<AlertCircle className="h-5 w-5 text-warning" />
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
. .
</DialogDescription>
</DialogHeader>
<div className="max-h-[400px] space-y-4 overflow-y-auto pr-1">
{Object.entries(categoryMismatches).map(([key, items]) => {
const [, displayName] = key.split("|||");
return (
<div key={key} className="space-y-2">
<h4 className="text-sm font-semibold text-foreground">
{displayName}
</h4>
{items.map((item, idx) => (
<div
key={`${key}-${idx}`}
className="grid grid-cols-[1fr_auto_1fr] items-center gap-2 rounded-md border border-border bg-muted/30 p-2"
>
<div className="flex flex-col">
<span className="text-xs font-medium text-destructive line-through">
{item.invalidValue}
</span>
<span className="text-[10px] text-muted-foreground">
{item.rowIndices.length}
</span>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
<Select
value={item.replacement || ""}
onValueChange={(val) => {
setCategoryMismatches((prev) => {
const updated = { ...prev };
updated[key] = updated[key].map((it, i) =>
i === idx ? { ...it, replacement: val } : it
);
return updated;
});
}}
>
<SelectTrigger className="h-8 text-xs sm:h-9 sm:text-sm">
<SelectValue placeholder="대체 값 선택" />
</SelectTrigger>
<SelectContent>
{item.validOptions.map((opt) => (
<SelectItem
key={opt.code}
value={opt.code}
className="text-xs sm:text-sm"
>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
);
})}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
setShowCategoryValidation(false);
setCategoryMismatches({});
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
variant="outline"
onClick={() => {
setShowCategoryValidation(false);
setCategoryMismatches({});
setCurrentStep(3);
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={applyCategoryReplacements}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
); );
}; };

View File

@ -27,6 +27,8 @@ import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
import { ConditionalZone, LayerDefinition } from "@/types/screen-management"; import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
import { ScreenContextProvider } from "@/contexts/ScreenContext"; import { ScreenContextProvider } from "@/contexts/ScreenContext";
import { useTabStore } from "@/stores/tabStore";
import { useTabId } from "@/contexts/TabIdContext";
interface ScreenModalState { interface ScreenModalState {
isOpen: boolean; isOpen: boolean;
@ -43,6 +45,8 @@ interface ScreenModalProps {
export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => { export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const { userId, userName, user } = useAuth(); const { userId, userName, user } = useAuth();
const splitPanelContext = useSplitPanelContext(); const splitPanelContext = useSplitPanelContext();
const tabId = useTabId();
const activeTabId = useTabStore((s) => s[s.mode].activeTabId);
const [modalState, setModalState] = useState<ScreenModalState>({ const [modalState, setModalState] = useState<ScreenModalState>({
isOpen: false, isOpen: false,
@ -75,9 +79,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const [selectedData, setSelectedData] = useState<Record<string, any>[]>([]); const [selectedData, setSelectedData] = useState<Record<string, any>[]>([]);
// 🆕 조건부 레이어 상태 (Zone 기반) // 🆕 조건부 레이어 상태 (Zone 기반)
const [conditionalLayers, setConditionalLayers] = useState< const [conditionalLayers, setConditionalLayers] = useState<(LayerDefinition & { components: ComponentData[]; zone?: ConditionalZone })[]>([]);
(LayerDefinition & { components: ComponentData[]; zone?: ConditionalZone })[]
>([]);
// 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해) // 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해)
const [continuousMode, setContinuousMode] = useState(false); const [continuousMode, setContinuousMode] = useState(false);
@ -172,6 +174,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 전역 모달 이벤트 리스너 // 전역 모달 이벤트 리스너
useEffect(() => { useEffect(() => {
const handleOpenModal = (event: CustomEvent) => { const handleOpenModal = (event: CustomEvent) => {
// 활성 탭에서만 이벤트 처리 (다른 탭의 ScreenModal 인스턴스는 무시)
const storeState = useTabStore.getState();
const currentActiveTabId = storeState[storeState.mode].activeTabId;
if (tabId && tabId !== currentActiveTabId) return;
const { const {
screenId, screenId,
title, title,
@ -193,7 +200,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
isCreateMode, isCreateMode,
}); });
// 🆕 모달 열린 시간 기록 // 모달 열린 시간 기록
modalOpenedAtRef.current = Date.now(); modalOpenedAtRef.current = Date.now();
// 폼 변경 추적 초기화 // 폼 변경 추적 초기화
@ -259,8 +266,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// - selectedLeftData: customer_mng 데이터 (SplitPanel 좌측 선택) // - selectedLeftData: customer_mng 데이터 (SplitPanel 좌측 선택)
// - 기존 formData: 이전 모달에서 설정된 link 필드 (customer_code 등) // - 기존 formData: 이전 모달에서 설정된 link 필드 (customer_code 등)
const contextData = splitPanelContext?.selectedLeftData || {}; const contextData = splitPanelContext?.selectedLeftData || {};
const eventData = const eventData = splitPanelParentData && Object.keys(splitPanelParentData).length > 0
splitPanelParentData && Object.keys(splitPanelParentData).length > 0 ? splitPanelParentData : {}; ? splitPanelParentData
: {};
// 🆕 기존 formData에서 link 필드(_code, _id)를 가져와 base로 사용 // 🆕 기존 formData에서 link 필드(_code, _id)를 가져와 base로 사용
// 모달 체인(226→227)에서 이전 모달의 연결 필드가 유지됨 // 모달 체인(226→227)에서 이전 모달의 연결 필드가 유지됨
@ -444,7 +452,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
window.removeEventListener("closeSaveModal", handleCloseModal); window.removeEventListener("closeSaveModal", handleCloseModal);
window.removeEventListener("saveSuccessInModal", handleSaveSuccess); window.removeEventListener("saveSuccessInModal", handleSaveSuccess);
}; };
}, [continuousMode]); // continuousMode 의존성 추가 }, [tabId, continuousMode]);
// 화면 데이터 로딩 // 화면 데이터 로딩
useEffect(() => { useEffect(() => {
@ -680,17 +688,12 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
} }
} }
console.log( console.log("[ScreenModal] 조건부 레이어 로드 완료:", layerDefs.length, "개",
"[ScreenModal] 조건부 레이어 로드 완료:",
layerDefs.length,
"개",
layerDefs.map((l) => ({ layerDefs.map((l) => ({
id: l.id, id: l.id, name: l.name, conditionValue: l.conditionValue,
name: l.name,
conditionValue: l.conditionValue,
componentCount: l.components.length, componentCount: l.components.length,
condition: l.condition, condition: l.condition,
})), }))
); );
setConditionalLayers(layerDefs); setConditionalLayers(layerDefs);
@ -733,21 +736,15 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
if (Array.isArray(value)) { if (Array.isArray(value)) {
isMatch = value.some((v) => String(v) === String(targetValue ?? "")); isMatch = value.some((v) => String(v) === String(targetValue ?? ""));
} else if (typeof value === "string" && value.includes(",")) { } else if (typeof value === "string" && value.includes(",")) {
isMatch = value isMatch = value.split(",").map((v) => v.trim()).includes(String(targetValue ?? ""));
.split(",")
.map((v) => v.trim())
.includes(String(targetValue ?? ""));
} }
break; break;
} }
console.log("[ScreenModal] 레이어 조건 평가:", { console.log("[ScreenModal] 레이어 조건 평가:", {
layerName: layer.name, layerName: layer.name, fieldKey,
fieldKey,
targetValue: String(targetValue ?? "(없음)"), targetValue: String(targetValue ?? "(없음)"),
conditionValue: String(value), conditionValue: String(value), operator, isMatch,
operator,
isMatch,
}); });
if (isMatch) { if (isMatch) {
@ -804,10 +801,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value.some((v) => String(v) === String(targetValue ?? "")); return value.some((v) => String(v) === String(targetValue ?? ""));
} else if (typeof value === "string" && value.includes(",")) { } else if (typeof value === "string" && value.includes(",")) {
return value return value.split(",").map((v) => v.trim()).includes(String(targetValue ?? ""));
.split(",")
.map((v) => v.trim())
.includes(String(targetValue ?? ""));
} }
return false; return false;
default: default:
@ -829,7 +823,10 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
if (!layer) return; if (!layer) return;
layer.components.forEach((comp: any) => { layer.components.forEach((comp: any) => {
const fieldName = comp?.overrides?.columnName || comp?.columnName || comp?.componentConfig?.columnName; const fieldName =
comp?.overrides?.columnName ||
comp?.columnName ||
comp?.componentConfig?.columnName;
if (fieldName) { if (fieldName) {
fieldsToRemove.push(fieldName); fieldsToRemove.push(fieldName);
} }
@ -864,7 +861,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
} else { } else {
handleCloseInternal(); handleCloseInternal();
} }
}, []); }, [tabId]);
// 확인 후 실제로 모달을 닫는 함수 // 확인 후 실제로 모달을 닫는 함수
const handleConfirmClose = useCallback(() => { const handleConfirmClose = useCallback(() => {
@ -1002,14 +999,13 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
<Dialog <Dialog
open={modalState.isOpen} open={modalState.isOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
// X 버튼 클릭 시에도 확인 다이얼로그 표시
if (!open) { if (!open) {
handleCloseAttempt(); handleCloseAttempt();
} }
}} }}
> >
<DialogContent <DialogContent
className={`${modalStyle.className} ${className || ""} flex max-w-none flex-col`} className={`${modalStyle.className} ${className || ""} max-w-none flex flex-col`}
style={modalStyle.style} style={modalStyle.style}
// 바깥 클릭 시 바로 닫히지 않도록 방지 // 바깥 클릭 시 바로 닫히지 않도록 방지
onInteractOutside={(e) => { onInteractOutside={(e) => {
@ -1034,7 +1030,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
</div> </div>
</DialogHeader> </DialogHeader>
<div className="flex min-h-0 flex-1 items-start justify-center overflow-auto"> <div
className="flex-1 min-h-0 flex items-start justify-center overflow-auto"
>
{loading ? ( {loading ? (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="text-center"> <div className="text-center">
@ -1179,66 +1177,58 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
return null; return null;
} }
// 화면 관리 해상도를 사용하는 경우 offset 조정 불필요 // 화면 관리 해상도를 사용하는 경우 offset 조정 불필요
const offsetX = screenDimensions?.offsetX || 0; const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0; const offsetY = screenDimensions?.offsetY || 0;
// 🆕 동적 y 좌표 조정 (숨겨진 컴포넌트 높이만큼 위로 이동) // 🆕 동적 y 좌표 조정 (숨겨진 컴포넌트 높이만큼 위로 이동)
const compY = parseFloat(component.position?.y?.toString() || "0"); const compY = parseFloat(component.position?.y?.toString() || "0");
const yAdjustment = getYOffset(compY, component.id); const yAdjustment = getYOffset(compY, component.id);
// offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시) // offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시)
const adjustedComponent = { const adjustedComponent = {
...component, ...component,
position: { position: {
...component.position, ...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX, x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: compY - offsetY - yAdjustment, // 🆕 동적 조정 적용 y: compY - offsetY - yAdjustment, // 🆕 동적 조정 적용
}, },
}; };
return ( return (
<InteractiveScreenViewerDynamic <InteractiveScreenViewerDynamic
key={`${component.id}-${resetKey}`} key={`${component.id}-${resetKey}`}
component={adjustedComponent} component={adjustedComponent}
allComponents={screenData.components} allComponents={screenData.components}
formData={formData} formData={formData}
originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용) originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용)
onFormDataChange={(fieldName, value) => { onFormDataChange={(fieldName, value) => {
formDataChangedRef.current = true; formDataChangedRef.current = true;
setFormData((prev) => { setFormData((prev) => {
const newFormData = { const newFormData = {
...prev, ...prev,
[fieldName]: value, [fieldName]: value,
}; };
console.log( console.log("🟡 [ScreenModal] onFormDataChange:", fieldName, "→", value, "| formData keys:", Object.keys(newFormData), "| process_code:", newFormData.process_code);
"🟡 [ScreenModal] onFormDataChange:", return newFormData;
fieldName, });
"→", }}
value, onRefresh={() => {
"| formData keys:", // 부모 화면의 테이블 새로고침 이벤트 발송
Object.keys(newFormData), window.dispatchEvent(new CustomEvent("refreshTable"));
"| process_code:", }}
newFormData.process_code, screenInfo={{
); id: modalState.screenId!,
return newFormData; tableName: screenData.screenInfo?.tableName,
}); }}
}} groupedData={selectedData}
onRefresh={() => { userId={userId}
// 부모 화면의 테이블 새로고침 이벤트 발송 userName={userName}
window.dispatchEvent(new CustomEvent("refreshTable")); companyCode={user?.companyCode}
}} isInModal={true}
screenInfo={{ />
id: modalState.screenId!, );
tableName: screenData.screenInfo?.tableName, });
}}
groupedData={selectedData}
userId={userId}
userName={userName}
companyCode={user?.companyCode}
/>
);
});
})()} })()}
{/* 🆕 조건부 레이어 컴포넌트 렌더링 */} {/* 🆕 조건부 레이어 컴포넌트 렌더링 */}
@ -1279,6 +1269,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
userId={userId} userId={userId}
userName={userName} userName={userName}
companyCode={user?.companyCode} companyCode={user?.companyCode}
isInModal={true}
/> />
); );
})} })}
@ -1316,7 +1307,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
<AlertDialog open={showCloseConfirm} onOpenChange={setShowCloseConfirm}> <AlertDialog open={showCloseConfirm} onOpenChange={setShowCloseConfirm}>
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]"> <AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg"> ?</AlertDialogTitle> <AlertDialogTitle className="text-base sm:text-lg">
?
</AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm"> <AlertDialogDescription className="text-xs sm:text-sm">
. .
<br /> <br />
@ -1332,7 +1325,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={handleConfirmClose} onClick={handleConfirmClose}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm" className="h-8 flex-1 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm"
> >
</AlertDialogAction> </AlertDialogAction>

View File

@ -0,0 +1,109 @@
"use client";
import React, { useMemo } from "react";
import dynamic from "next/dynamic";
import { Loader2 } from "lucide-react";
const LoadingFallback = () => (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
/**
* URL .
* .
* URL은 catch-all fallback으로 .
*/
const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
// 관리자 메인
"/admin": dynamic(() => import("@/app/(main)/admin/page"), { ssr: false, loading: LoadingFallback }),
// 메뉴 관리
"/admin/menu": dynamic(() => import("@/app/(main)/admin/menu/page"), { ssr: false, loading: LoadingFallback }),
// 사용자 관리
"/admin/userMng/userMngList": dynamic(() => import("@/app/(main)/admin/userMng/userMngList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/userMng/rolesList": dynamic(() => import("@/app/(main)/admin/userMng/rolesList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/userMng/userAuthList": dynamic(() => import("@/app/(main)/admin/userMng/userAuthList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/userMng/companyList": dynamic(() => import("@/app/(main)/admin/userMng/companyList/page"), { ssr: false, loading: LoadingFallback }),
// 화면 관리
"/admin/screenMng/screenMngList": dynamic(() => import("@/app/(main)/admin/screenMng/screenMngList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/screenMng/popScreenMngList": dynamic(() => import("@/app/(main)/admin/screenMng/popScreenMngList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/screenMng/dashboardList": dynamic(() => import("@/app/(main)/admin/screenMng/dashboardList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/screenMng/reportList": dynamic(() => import("@/app/(main)/admin/screenMng/reportList/page"), { ssr: false, loading: LoadingFallback }),
// 시스템 관리
"/admin/systemMng/commonCodeList": dynamic(() => import("@/app/(main)/admin/systemMng/commonCodeList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/systemMng/tableMngList": dynamic(() => import("@/app/(main)/admin/systemMng/tableMngList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/systemMng/i18nList": dynamic(() => import("@/app/(main)/admin/systemMng/i18nList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/systemMng/collection-managementList": dynamic(() => import("@/app/(main)/admin/systemMng/collection-managementList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/systemMng/dataflow": dynamic(() => import("@/app/(main)/admin/systemMng/dataflow/page"), { ssr: false, loading: LoadingFallback }),
// 자동화 관리
"/admin/automaticMng/flowMgmtList": dynamic(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/batchmngList": dynamic(() => import("@/app/(main)/admin/automaticMng/batchmngList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/exconList": dynamic(() => import("@/app/(main)/admin/automaticMng/exconList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/exCallConfList": dynamic(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page"), { ssr: false, loading: LoadingFallback }),
// 메일
"/admin/automaticMng/mail/send": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/send/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/receive": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/receive/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/sent": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/sent/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/drafts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/drafts/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/trash": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/trash/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/accounts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/accounts/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/templates": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/templates/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/dashboardList": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/dashboardList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/mail/bulk-send": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/bulk-send/page"), { ssr: false, loading: LoadingFallback }),
// 배치 관리
"/admin/batch-management": dynamic(() => import("@/app/(main)/admin/batch-management/page"), { ssr: false, loading: LoadingFallback }),
"/admin/batch-management-new": dynamic(() => import("@/app/(main)/admin/batch-management-new/page"), { ssr: false, loading: LoadingFallback }),
// 기타
"/admin/cascading-management": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }),
"/admin/cascading-relations": dynamic(() => import("@/app/(main)/admin/cascading-relations/page"), { ssr: false, loading: LoadingFallback }),
"/admin/layouts": dynamic(() => import("@/app/(main)/admin/layouts/page"), { ssr: false, loading: LoadingFallback }),
"/admin/templates": dynamic(() => import("@/app/(main)/admin/templates/page"), { ssr: false, loading: LoadingFallback }),
"/admin/monitoring": dynamic(() => import("@/app/(main)/admin/monitoring/page"), { ssr: false, loading: LoadingFallback }),
"/admin/standards": dynamic(() => import("@/app/(main)/admin/standards/page"), { ssr: false, loading: LoadingFallback }),
"/admin/flow-external-db": dynamic(() => import("@/app/(main)/admin/flow-external-db/page"), { ssr: false, loading: LoadingFallback }),
"/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/auto-fill/page"), { ssr: false, loading: LoadingFallback }),
};
// 매핑되지 않은 URL용 Fallback
function AdminPageFallback({ url }: { url: string }) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-lg font-semibold text-foreground"> </p>
<p className="mt-1 text-sm text-muted-foreground">
: {url}
</p>
<p className="mt-2 text-xs text-muted-foreground">
AdminPageRenderer URL을 .
</p>
</div>
</div>
);
}
interface AdminPageRendererProps {
url: string;
}
export function AdminPageRenderer({ url }: AdminPageRendererProps) {
const PageComponent = useMemo(() => {
// URL에서 쿼리스트링/해시 제거 후 매칭
const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, "");
return ADMIN_PAGE_REGISTRY[cleanUrl] || null;
}, [url]);
if (!PageComponent) {
return <AdminPageFallback url={url} />;
}
return <PageComponent />;
}

View File

@ -30,6 +30,9 @@ import { toast } from "sonner";
import { ProfileModal } from "./ProfileModal"; import { ProfileModal } from "./ProfileModal";
import { Logo } from "./Logo"; import { Logo } from "./Logo";
import { SideMenu } from "./SideMenu"; import { SideMenu } from "./SideMenu";
import { TabBar } from "./TabBar";
import { TabContent } from "./TabContent";
import { useTabStore } from "@/stores/tabStore";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -97,7 +100,8 @@ const getMenuIcon = (menuName: string, dbIconName?: string | null) => {
}; };
// 메뉴 데이터를 UI용으로 변환하는 함수 (최상위 "사용자", "관리자" 제외) // 메뉴 데이터를 UI용으로 변환하는 함수 (최상위 "사용자", "관리자" 제외)
const convertMenuToUI = (menus: MenuItem[], userInfo: ExtendedUserInfo | null, parentId: string = "0"): any[] => { // parentPath: 탭 제목에 "기준정보 - 회사관리" 형태로 상위 카테고리를 포함하기 위한 경로
const convertMenuToUI = (menus: MenuItem[], userInfo: ExtendedUserInfo | null, parentId: string = "0", parentPath: string = ""): any[] => {
const filteredMenus = menus const filteredMenus = menus
.filter((menu) => (menu.parent_obj_id || menu.PARENT_OBJ_ID) === parentId) .filter((menu) => (menu.parent_obj_id || menu.PARENT_OBJ_ID) === parentId)
.filter((menu) => (menu.status || menu.STATUS) === "active") .filter((menu) => (menu.status || menu.STATUS) === "active")
@ -110,40 +114,34 @@ const convertMenuToUI = (menus: MenuItem[], userInfo: ExtendedUserInfo | null, p
for (const menu of filteredMenus) { for (const menu of filteredMenus) {
const menuName = (menu.menu_name_kor || menu.MENU_NAME_KOR || "").toLowerCase(); const menuName = (menu.menu_name_kor || menu.MENU_NAME_KOR || "").toLowerCase();
// "사용자" 또는 "관리자" 카테고리면 하위 메뉴들을 직접 추가
if (menuName.includes("사용자") || menuName.includes("관리자")) { if (menuName.includes("사용자") || menuName.includes("관리자")) {
const childMenus = convertMenuToUI(menus, userInfo, menu.objid || menu.OBJID); const childMenus = convertMenuToUI(menus, userInfo, menu.objid || menu.OBJID, "");
allMenus.push(...childMenus); allMenus.push(...childMenus);
} else { } else {
// 일반 메뉴는 그대로 추가 allMenus.push(convertSingleMenu(menu, menus, userInfo, ""));
allMenus.push(convertSingleMenu(menu, menus, userInfo));
} }
} }
return allMenus; return allMenus;
} }
return filteredMenus.map((menu) => convertSingleMenu(menu, menus, userInfo)); return filteredMenus.map((menu) => convertSingleMenu(menu, menus, userInfo, parentPath));
}; };
// 단일 메뉴 변환 함수 // 단일 메뉴 변환 함수
const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: ExtendedUserInfo | null): any => { const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: ExtendedUserInfo | null, parentPath: string = ""): any => {
const menuId = menu.objid || menu.OBJID; const menuId = menu.objid || menu.OBJID;
// 사용자 locale 기준으로 번역 처리 // 사용자 locale 기준으로 번역 처리
const getDisplayText = (menu: MenuItem) => { const getDisplayText = (m: MenuItem) => {
// 다국어 텍스트가 있으면 사용, 없으면 기본 텍스트 사용 if (m.translated_name || m.TRANSLATED_NAME) {
if (menu.translated_name || menu.TRANSLATED_NAME) { return m.translated_name || m.TRANSLATED_NAME;
return menu.translated_name || menu.TRANSLATED_NAME;
} }
const baseName = menu.menu_name_kor || menu.MENU_NAME_KOR || "메뉴명 없음"; const baseName = m.menu_name_kor || m.MENU_NAME_KOR || "메뉴명 없음";
// 사용자 정보에서 locale 가져오기
const userLocale = userInfo?.locale || "ko"; const userLocale = userInfo?.locale || "ko";
if (userLocale === "EN") { if (userLocale === "EN") {
// 영어 번역
const translations: { [key: string]: string } = { const translations: { [key: string]: string } = {
: "Administrator", : "Administrator",
: "User Management", : "User Management",
@ -163,7 +161,6 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten
} }
} }
} else if (userLocale === "JA") { } else if (userLocale === "JA") {
// 일본어 번역
const translations: { [key: string]: string } = { const translations: { [key: string]: string } = {
: "管理者", : "管理者",
: "ユーザー管理", : "ユーザー管理",
@ -183,7 +180,6 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten
} }
} }
} else if (userLocale === "ZH") { } else if (userLocale === "ZH") {
// 중국어 번역
const translations: { [key: string]: string } = { const translations: { [key: string]: string } = {
: "管理员", : "管理员",
: "用户管理", : "用户管理",
@ -207,11 +203,15 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten
return baseName; return baseName;
}; };
const children = convertMenuToUI(allMenus, userInfo, menuId); const displayName = getDisplayText(menu);
const tabTitle = parentPath ? `${parentPath} - ${displayName}` : displayName;
const children = convertMenuToUI(allMenus, userInfo, menuId, tabTitle);
return { return {
id: menuId, id: menuId,
name: getDisplayText(menu), name: displayName,
tabTitle,
icon: getMenuIcon(menu.menu_name_kor || menu.MENU_NAME_KOR || "", menu.menu_icon || menu.MENU_ICON), icon: getMenuIcon(menu.menu_name_kor || menu.MENU_NAME_KOR || "", menu.menu_icon || menu.MENU_ICON),
url: menu.menu_url || menu.MENU_URL || "#", url: menu.menu_url || menu.MENU_URL || "#",
children: children.length > 0 ? children : undefined, children: children.length > 0 ? children : undefined,
@ -231,6 +231,28 @@ function AppLayoutInner({ children }: AppLayoutProps) {
const [showCompanySwitcher, setShowCompanySwitcher] = useState(false); const [showCompanySwitcher, setShowCompanySwitcher] = useState(false);
const [currentCompanyName, setCurrentCompanyName] = useState<string>(""); const [currentCompanyName, setCurrentCompanyName] = useState<string>("");
// URL 직접 접근 시 탭 자동 열기 (북마크/공유 링크 대응)
useEffect(() => {
const store = useTabStore.getState();
const currentModeTabs = store[store.mode].tabs;
if (currentModeTabs.length > 0) return;
// /screens/[screenId] 패턴 감지
const screenMatch = pathname.match(/^\/screens\/(\d+)/);
if (screenMatch) {
const screenId = parseInt(screenMatch[1]);
const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined;
store.openTab({ type: "screen", title: `화면 ${screenId}`, screenId, menuObjid });
return;
}
// /admin/* 패턴 감지 -> admin 모드로 전환 후 탭 열기
if (pathname.startsWith("/admin") && pathname !== "/admin") {
store.setMode("admin");
store.openTab({ type: "admin", title: pathname.split("/").pop() || "관리자", adminUrl: pathname });
}
}, []); // 마운트 시 1회만 실행
// 현재 회사명 조회 (SUPER_ADMIN 전용) // 현재 회사명 조회 (SUPER_ADMIN 전용)
useEffect(() => { useEffect(() => {
const fetchCurrentCompanyName = async () => { const fetchCurrentCompanyName = async () => {
@ -306,8 +328,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
handleRegisterVehicle, handleRegisterVehicle,
} = useProfile(user, refreshUserData, refreshMenus); } = useProfile(user, refreshUserData, refreshMenus);
// 현재 경로에 따라 어드민 모드인지 판단 (쿼리 파라미터도 고려) // 탭 스토어에서 현재 모드 가져오기
const isAdminMode = pathname.startsWith("/admin") || searchParams.get("mode") === "admin"; const tabMode = useTabStore((s) => s.mode);
const setTabMode = useTabStore((s) => s.setMode);
const isAdminMode = tabMode === "admin";
// 프리뷰 모드: 사이드바/헤더 없이 화면만 표시 (iframe 임베드용) // 프리뷰 모드: 사이드바/헤더 없이 화면만 표시 (iframe 임베드용)
const isPreviewMode = searchParams.get("preview") === "true"; const isPreviewMode = searchParams.get("preview") === "true";
@ -327,67 +351,55 @@ function AppLayoutInner({ children }: AppLayoutProps) {
setExpandedMenus(newExpanded); setExpandedMenus(newExpanded);
}; };
// 메뉴 클릭 핸들러 const { openTab } = useTabStore();
// 메뉴 클릭 핸들러 (탭으로 열기)
const handleMenuClick = async (menu: any) => { const handleMenuClick = async (menu: any) => {
if (menu.hasChildren) { if (menu.hasChildren) {
toggleMenu(menu.id); toggleMenu(menu.id);
} else { } else {
// 메뉴 이름 저장 (엑셀 다운로드 파일명에 사용) // tabTitle: "기준정보 - 회사관리" 형태의 상위 포함 이름
const menuName = menu.label || menu.name || "메뉴"; const menuName = menu.tabTitle || menu.label || menu.name || "메뉴";
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
localStorage.setItem("currentMenuName", menuName); localStorage.setItem("currentMenuName", menuName);
} }
// 먼저 할당된 화면이 있는지 확인 (URL 유무와 관계없이)
try { try {
const menuObjid = menu.objid || menu.id; const menuObjid = menu.objid || menu.id;
const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid); const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid);
if (assignedScreens.length > 0) { if (assignedScreens.length > 0) {
// 할당된 화면이 있으면 첫 번째 화면으로 이동
const firstScreen = assignedScreens[0]; const firstScreen = assignedScreens[0];
openTab({
// 관리자 모드 상태와 menuObjid를 쿼리 파라미터로 전달 type: "screen",
const params = new URLSearchParams(); title: menuName,
if (isAdminMode) { screenId: firstScreen.screenId,
params.set("mode", "admin"); menuObjid: parseInt(menuObjid),
} });
params.set("menuObjid", menuObjid.toString()); if (isMobile) setSidebarOpen(false);
const screenPath = `/screens/${firstScreen.screenId}?${params.toString()}`;
router.push(screenPath);
if (isMobile) {
setSidebarOpen(false);
}
return; return;
} }
} catch (error) { } catch (error) {
console.warn("할당된 화면 조회 실패:", error); console.warn("할당된 화면 조회 실패:", error);
} }
// 할당된 화면이 없고 URL이 있으면 기존 URL로 이동
if (menu.url && menu.url !== "#") { if (menu.url && menu.url !== "#") {
router.push(menu.url); openTab({
if (isMobile) { type: "admin",
setSidebarOpen(false); title: menuName,
} adminUrl: menu.url,
});
if (isMobile) setSidebarOpen(false);
} else { } else {
// URL도 없고 할당된 화면도 없으면 경고 메시지
toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다."); toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다.");
} }
} }
}; };
// 모드 전환 핸들러 // 모드 전환 핸들러
// 모드 전환: 탭 스토어의 모드만 변경 (각 모드 탭은 독립 보존)
const handleModeSwitch = () => { const handleModeSwitch = () => {
if (isAdminMode) { setTabMode(isAdminMode ? "user" : "admin");
// 관리자 → 사용자 모드: 선택한 회사 유지
router.push("/main");
} else {
// 사용자 → 관리자 모드: 선택한 회사 유지 (회사 전환 없음)
router.push("/admin");
}
}; };
// 로그아웃 핸들러 // 로그아웃 핸들러
@ -400,13 +412,57 @@ function AppLayoutInner({ children }: AppLayoutProps) {
} }
}; };
// 메뉴 트리 렌더링 (기존 MainLayout 스타일 적용) // 사이드바 메뉴 -> 탭 바 드래그용 데이터 생성
const buildMenuDragData = async (menu: any): Promise<string | null> => {
const menuName = menu.label || menu.name || "메뉴";
const menuObjid = menu.objid || menu.id;
try {
const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid);
if (assignedScreens.length > 0) {
return JSON.stringify({
type: "screen" as const,
title: menuName,
screenId: assignedScreens[0].screenId,
menuObjid: parseInt(menuObjid),
});
}
} catch { /* ignore */ }
if (menu.url && menu.url !== "#") {
return JSON.stringify({
type: "admin" as const,
title: menuName,
adminUrl: menu.url,
});
}
return null;
};
const handleMenuDragStart = (e: React.DragEvent, menu: any) => {
if (menu.hasChildren) {
e.preventDefault();
return;
}
e.dataTransfer.effectAllowed = "copy";
const menuName = menu.tabTitle || menu.label || menu.name || "메뉴";
const menuObjid = menu.objid || menu.id;
const dragPayload = JSON.stringify({ menuName, menuObjid, url: menu.url });
e.dataTransfer.setData("application/tab-menu-pending", dragPayload);
e.dataTransfer.setData("text/plain", menuName);
};
// 메뉴 트리 렌더링 (드래그 가능)
const renderMenu = (menu: any, level: number = 0) => { const renderMenu = (menu: any, level: number = 0) => {
const isExpanded = expandedMenus.has(menu.id); const isExpanded = expandedMenus.has(menu.id);
const isLeaf = !menu.hasChildren;
return ( return (
<div key={menu.id}> <div key={menu.id}>
<div <div
draggable={isLeaf}
onDragStart={(e) => handleMenuDragStart(e, menu)}
className={`group flex h-10 cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 ease-in-out ${ className={`group flex h-10 cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 ease-in-out ${
pathname === menu.url pathname === menu.url
? "border-primary border-l-4 bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900" ? "border-primary border-l-4 bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900"
@ -435,6 +491,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
{menu.children?.map((child: any) => ( {menu.children?.map((child: any) => (
<div <div
key={child.id} key={child.id}
draggable={!child.hasChildren}
onDragStart={(e) => handleMenuDragStart(e, child)}
className={`flex cursor-pointer items-center rounded-lg px-3 py-2 text-sm transition-colors hover:cursor-pointer ${ className={`flex cursor-pointer items-center rounded-lg px-3 py-2 text-sm transition-colors hover:cursor-pointer ${
pathname === child.url pathname === child.url
? "border-primary border-l-4 bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900" ? "border-primary border-l-4 bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900"
@ -712,9 +770,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
</div> </div>
</aside> </aside>
{/* 가운데 컨텐츠 영역 - 스크롤 가능 */} {/* 가운데 컨텐츠 영역 - 탭 시스템 */}
<main className={`min-w-0 flex-1 overflow-auto bg-white ${isMobile ? "h-[calc(100vh-56px)]" : "h-screen"}`}> <main className={`flex min-w-0 flex-1 flex-col overflow-hidden bg-white ${isMobile ? "h-[calc(100vh-56px)]" : "h-screen"}`}>
{children} <TabBar />
<TabContent />
</main> </main>
</div> </div>

View File

@ -0,0 +1,23 @@
"use client";
import { LayoutGrid } from "lucide-react";
export function EmptyDashboard() {
return (
<div className="flex h-full items-center justify-center bg-white">
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted">
<LayoutGrid className="h-10 w-10 text-muted-foreground" />
</div>
<div className="space-y-2">
<h2 className="text-xl font-semibold text-foreground">
</h2>
<p className="max-w-sm text-sm text-muted-foreground">
.
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,698 @@
"use client";
import React, { useRef, useState, useEffect, useLayoutEffect, useCallback } from "react";
import { X, RotateCw, ChevronDown } from "lucide-react";
import { useTabStore, selectTabs, selectActiveTabId, Tab } from "@/stores/tabStore";
import { menuScreenApi } from "@/lib/api/screen";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
const TAB_WIDTH = 180;
const TAB_GAP = 2;
const TAB_UNIT = TAB_WIDTH + TAB_GAP;
const OVERFLOW_BTN_WIDTH = 48;
const DRAG_THRESHOLD = 5;
const SETTLE_MS = 70;
const DROP_SETTLE_MS = 180;
const BAR_PAD_X = 8;
interface DragState {
tabId: string;
pointerId: number;
startX: number;
currentX: number;
tabRect: DOMRect;
fromIndex: number;
targetIndex: number;
activated: boolean;
settling: boolean;
}
interface DropGhost {
title: string;
startX: number;
startY: number;
targetIdx: number;
tabCountAtCreation: number;
}
export function TabBar() {
const tabs = useTabStore(selectTabs);
const activeTabId = useTabStore(selectActiveTabId);
const {
switchTab,
closeTab,
refreshTab,
closeOtherTabs,
closeTabsToLeft,
closeTabsToRight,
closeAllTabs,
updateTabOrder,
openTab,
} = useTabStore();
// --- Refs ---
const containerRef = useRef<HTMLDivElement>(null);
const settleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const dragActiveRef = useRef(false);
const dragLeaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const dropGhostRef = useRef<HTMLDivElement>(null);
const prevTabCountRef = useRef(tabs.length);
// --- State ---
const [visibleCount, setVisibleCount] = useState(tabs.length);
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
tabId: string;
} | null>(null);
const [dragState, setDragState] = useState<DragState | null>(null);
const [externalDragIdx, setExternalDragIdx] = useState<number | null>(null);
const [dropGhost, setDropGhost] = useState<DropGhost | null>(null);
dragActiveRef.current = !!dragState;
// --- 타이머 정리 ---
useEffect(() => {
return () => {
if (settleTimer.current) clearTimeout(settleTimer.current);
if (dragLeaveTimerRef.current) clearTimeout(dragLeaveTimerRef.current);
};
}, []);
// --- 드롭 고스트: Web Animations API로 드롭 위치 → 목표 슬롯 이동 ---
useEffect(() => {
if (!dropGhost) return;
const el = dropGhostRef.current;
const bar = containerRef.current?.getBoundingClientRect();
if (!el || !bar) return;
const targetX = bar.left + BAR_PAD_X + dropGhost.targetIdx * TAB_UNIT;
const targetY = bar.bottom - 28;
const dx = dropGhost.startX - targetX;
const dy = dropGhost.startY - targetY;
const anim = el.animate(
[
{ transform: `translate(${dx}px, ${dy}px)`, opacity: 0.85 },
{ transform: "translate(0, 0)", opacity: 1 },
],
{
duration: DROP_SETTLE_MS,
easing: "cubic-bezier(0.25, 1, 0.5, 1)",
fill: "forwards",
},
);
anim.onfinish = () => {
setDropGhost(null);
setExternalDragIdx(null);
};
const safety = setTimeout(() => {
setDropGhost(null);
setExternalDragIdx(null);
}, DROP_SETTLE_MS + 500);
return () => {
anim.cancel();
clearTimeout(safety);
};
}, [dropGhost]);
// --- 오버플로우 계산 (드래그 중 재계산 방지) ---
const recalcVisible = useCallback(() => {
if (dragActiveRef.current) return;
if (!containerRef.current) return;
const w = containerRef.current.clientWidth;
setVisibleCount(Math.max(1, Math.floor((w - OVERFLOW_BTN_WIDTH) / TAB_UNIT)));
}, []);
useEffect(() => {
recalcVisible();
const obs = new ResizeObserver(recalcVisible);
if (containerRef.current) obs.observe(containerRef.current);
return () => obs.disconnect();
}, [recalcVisible]);
useLayoutEffect(() => {
recalcVisible();
}, [tabs.length, recalcVisible]);
const visibleTabs = tabs.slice(0, visibleCount);
const overflowTabs = tabs.slice(visibleCount);
const hasOverflow = overflowTabs.length > 0;
const activeInOverflow = activeTabId && overflowTabs.some((t) => t.id === activeTabId);
let displayVisible = visibleTabs;
let displayOverflow = overflowTabs;
if (activeInOverflow && activeTabId) {
const activeTab = tabs.find((t) => t.id === activeTabId)!;
displayVisible = [...visibleTabs.slice(0, -1), activeTab];
displayOverflow = overflowTabs.filter((t) => t.id !== activeTabId);
if (visibleTabs.length > 0) {
displayOverflow = [visibleTabs[visibleTabs.length - 1], ...displayOverflow];
}
}
// ============================================================
// 사이드바 -> 탭 바 드롭 (네이티브 DnD + 삽입 위치 애니메이션)
// ============================================================
useLayoutEffect(() => {
if (tabs.length !== prevTabCountRef.current && externalDragIdx !== null) {
setExternalDragIdx(null);
}
prevTabCountRef.current = tabs.length;
}, [tabs.length, externalDragIdx]);
const resolveMenuAndOpenTab = async (
menuName: string,
menuObjid: string | number,
url: string,
insertIndex?: number,
) => {
const numericObjid = typeof menuObjid === "string" ? parseInt(menuObjid) : menuObjid;
try {
const screens = await menuScreenApi.getScreensByMenu(numericObjid);
if (screens.length > 0) {
openTab(
{ type: "screen", title: menuName, screenId: screens[0].screenId, menuObjid: numericObjid },
insertIndex,
);
return;
}
} catch {
/* ignore */
}
if (url && url !== "#") {
openTab({ type: "admin", title: menuName, adminUrl: url }, insertIndex);
} else {
setExternalDragIdx(null);
}
};
const handleBarDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
if (dragLeaveTimerRef.current) {
clearTimeout(dragLeaveTimerRef.current);
dragLeaveTimerRef.current = null;
}
const bar = containerRef.current?.getBoundingClientRect();
if (bar) {
const idx = Math.max(
0,
Math.min(Math.round((e.clientX - bar.left - BAR_PAD_X) / TAB_UNIT), displayVisible.length),
);
setExternalDragIdx(idx);
}
};
const handleBarDragLeave = (e: React.DragEvent) => {
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
dragLeaveTimerRef.current = setTimeout(() => {
setExternalDragIdx(null);
dragLeaveTimerRef.current = null;
}, 50);
}
};
const createDropGhost = (e: React.DragEvent, title: string, targetIdx: number) => {
setDropGhost({
title,
startX: e.clientX - TAB_WIDTH / 2,
startY: e.clientY - 14,
targetIdx,
tabCountAtCreation: tabs.length,
});
};
const handleBarDrop = (e: React.DragEvent) => {
e.preventDefault();
if (dragLeaveTimerRef.current) {
clearTimeout(dragLeaveTimerRef.current);
dragLeaveTimerRef.current = null;
}
const insertIdx = externalDragIdx ?? undefined;
const ghostIdx = insertIdx ?? displayVisible.length;
const pending = e.dataTransfer.getData("application/tab-menu-pending");
if (pending) {
try {
const { menuName, menuObjid, url } = JSON.parse(pending);
createDropGhost(e, menuName, ghostIdx);
resolveMenuAndOpenTab(menuName, menuObjid, url, insertIdx);
} catch {
setExternalDragIdx(null);
}
return;
}
const menuData = e.dataTransfer.getData("application/tab-menu");
if (menuData && menuData.length > 2) {
try {
const parsed = JSON.parse(menuData);
createDropGhost(e, parsed.title || "새 탭", ghostIdx);
setExternalDragIdx(null);
openTab(parsed, insertIdx);
} catch {
setExternalDragIdx(null);
}
} else {
setExternalDragIdx(null);
}
};
// ============================================================
// 탭 드래그 (Pointer Events) - 임계값 + settling 애니메이션
// ============================================================
const calcTarget = useCallback(
(clientX: number, startX: number, fromIndex: number): number => {
const delta = Math.round((clientX - startX) / TAB_UNIT);
return Math.max(0, Math.min(fromIndex + delta, displayVisible.length - 1));
},
[displayVisible.length],
);
const handlePointerDown = (e: React.PointerEvent, tabId: string, idx: number) => {
if ((e.target as HTMLElement).closest("button")) return;
if (dragState?.settling) return;
if (settleTimer.current) {
clearTimeout(settleTimer.current);
settleTimer.current = null;
}
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
setDragState({
tabId,
pointerId: e.pointerId,
startX: e.clientX,
currentX: e.clientX,
tabRect: (e.currentTarget as HTMLElement).getBoundingClientRect(),
fromIndex: idx,
targetIndex: idx,
activated: false,
settling: false,
});
};
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!dragState || dragState.settling) return;
if (e.pointerId !== dragState.pointerId) return;
const bar = containerRef.current?.getBoundingClientRect();
if (!bar) return;
const clampedX = Math.max(bar.left, Math.min(e.clientX, bar.right));
if (!dragState.activated) {
if (Math.abs(clampedX - dragState.startX) < DRAG_THRESHOLD) return;
setDragState((p) =>
p
? {
...p,
activated: true,
currentX: clampedX,
targetIndex: calcTarget(clampedX, p.startX, p.fromIndex),
}
: null,
);
return;
}
setDragState((p) =>
p ? { ...p, currentX: clampedX, targetIndex: calcTarget(clampedX, p.startX, p.fromIndex) } : null,
);
},
[dragState, calcTarget],
);
const handlePointerUp = useCallback(
(e: React.PointerEvent) => {
if (!dragState || dragState.settling) return;
if (e.pointerId !== dragState.pointerId) return;
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
if (!dragState.activated) {
switchTab(dragState.tabId);
setDragState(null);
return;
}
const { fromIndex, targetIndex, tabId } = dragState;
setDragState((p) => (p ? { ...p, settling: true } : null));
if (targetIndex === fromIndex) {
settleTimer.current = setTimeout(() => setDragState(null), SETTLE_MS + 10);
return;
}
const actualFrom = tabs.findIndex((t) => t.id === tabId);
const tgtTab = displayVisible[targetIndex];
const actualTo = tgtTab ? tabs.findIndex((t) => t.id === tgtTab.id) : actualFrom;
settleTimer.current = setTimeout(() => {
setDragState(null);
if (actualFrom !== -1 && actualTo !== -1 && actualFrom !== actualTo) {
updateTabOrder(actualFrom, actualTo);
}
}, SETTLE_MS + 10);
},
[dragState, tabs, displayVisible, switchTab, updateTabOrder],
);
const handleLostPointerCapture = useCallback(() => {
if (dragState && !dragState.settling) {
setDragState(null);
if (settleTimer.current) {
clearTimeout(settleTimer.current);
settleTimer.current = null;
}
}
}, [dragState]);
// ============================================================
// 스타일 계산
// ============================================================
const getTabAnimStyle = (tabId: string, index: number): React.CSSProperties => {
if (externalDragIdx !== null && !dragState) {
return {
transform: index >= externalDragIdx ? `translateX(${TAB_UNIT}px)` : "none",
transition: `transform ${DROP_SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1)`,
};
}
if (!dragState || !dragState.activated) return {};
const { fromIndex, targetIndex, tabId: draggedId } = dragState;
if (tabId === draggedId) {
return { opacity: 0, transition: "none" };
}
let shift = 0;
if (fromIndex < targetIndex) {
if (index > fromIndex && index <= targetIndex) shift = -TAB_UNIT;
} else if (fromIndex > targetIndex) {
if (index >= targetIndex && index < fromIndex) shift = TAB_UNIT;
}
return {
transform: shift !== 0 ? `translateX(${shift}px)` : "none",
transition: `transform ${SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1)`,
};
};
const getGhostStyle = (): React.CSSProperties | null => {
if (!dragState || !dragState.activated) return null;
const bar = containerRef.current?.getBoundingClientRect();
if (!bar) return null;
const base: React.CSSProperties = {
position: "fixed",
top: dragState.tabRect.top,
width: TAB_WIDTH,
height: dragState.tabRect.height,
zIndex: 100,
pointerEvents: "none",
opacity: 0.9,
};
if (dragState.settling) {
return {
...base,
left: bar.left + BAR_PAD_X + dragState.targetIndex * TAB_UNIT,
opacity: 1,
boxShadow: "none",
transition: `left ${SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1), box-shadow 80ms ease-out`,
};
}
const offsetX = dragState.currentX - dragState.startX;
const rawLeft = dragState.tabRect.left + offsetX;
return {
...base,
left: Math.max(bar.left, Math.min(rawLeft, bar.right - TAB_WIDTH)),
transition: "none",
};
};
const ghostStyle = getGhostStyle();
const draggedTab = dragState ? tabs.find((t) => t.id === dragState.tabId) : null;
// ============================================================
// 우클릭 컨텍스트 메뉴
// ============================================================
const handleContextMenu = (e: React.MouseEvent, tabId: string) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, tabId });
};
useEffect(() => {
if (!contextMenu) return;
const close = () => setContextMenu(null);
window.addEventListener("click", close);
window.addEventListener("scroll", close);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("scroll", close);
};
}, [contextMenu]);
// ============================================================
// 렌더링
// ============================================================
const renderTab = (tab: Tab, displayIndex: number) => {
const isActive = tab.id === activeTabId;
const animStyle = getTabAnimStyle(tab.id, displayIndex);
const hiddenByGhost =
!!dropGhost && displayIndex === dropGhost.targetIdx && tabs.length > dropGhost.tabCountAtCreation;
return (
<div
key={tab.id}
onPointerDown={(e) => handlePointerDown(e, tab.id, displayIndex)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onLostPointerCapture={handleLostPointerCapture}
onContextMenu={(e) => handleContextMenu(e, tab.id)}
className={cn(
"group relative flex h-7 shrink-0 cursor-pointer items-center gap-0.5 rounded-t-md border border-b-0 px-3 select-none",
isActive
? "text-foreground z-10 -mb-px h-[30px] bg-white"
: "bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground border-transparent",
)}
style={{
width: TAB_WIDTH,
touchAction: "none",
...animStyle,
...(hiddenByGhost ? { opacity: 0 } : {}),
...(isActive ? {} : {}),
}}
title={tab.title}
>
<span className="min-w-0 flex-1 truncate text-[11px] font-medium">{tab.title}</span>
<div className="flex shrink-0 items-center">
{isActive && (
<button
onClick={(e) => {
e.stopPropagation();
refreshTab(tab.id);
}}
className="text-muted-foreground hover:bg-accent hover:text-foreground flex h-4 w-4 items-center justify-center rounded-sm transition-colors"
>
<RotateCw className="h-2.5 w-2.5" />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
closeTab(tab.id);
}}
className={cn(
"text-muted-foreground hover:bg-destructive/10 hover:text-destructive flex h-4 w-4 items-center justify-center rounded-sm transition-colors",
!isActive && "opacity-0 group-hover:opacity-100",
)}
>
<X className="h-2.5 w-2.5" />
</button>
</div>
</div>
);
};
if (tabs.length === 0) return null;
return (
<>
<div
ref={containerRef}
className="border-border bg-background relative flex h-[33px] shrink-0 items-end gap-[2px] overflow-hidden px-1.5"
onDragOver={handleBarDragOver}
onDragLeave={handleBarDragLeave}
onDrop={handleBarDrop}
>
<div className="border-border pointer-events-none absolute inset-x-0 bottom-0 z-0 border-b" />
<div className="pointer-events-none absolute inset-0 z-5" />
{displayVisible.map((tab, i) => renderTab(tab, i))}
{hasOverflow && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground flex h-7 shrink-0 items-center gap-0.5 rounded-t-md border border-b-0 border-transparent px-2 text-[11px] font-medium transition-colors">
+{displayOverflow.length}
<ChevronDown className="h-2.5 w-2.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-[300px] overflow-y-auto">
{displayOverflow.map((tab) => (
<DropdownMenuItem
key={tab.id}
onClick={() => switchTab(tab.id)}
className="flex items-center justify-between gap-2"
>
<span className="min-w-0 flex-1 truncate text-xs">{tab.title}</span>
<button
onClick={(e) => {
e.stopPropagation();
closeTab(tab.id);
}}
className="hover:bg-destructive/10 hover:text-destructive flex h-4 w-4 shrink-0 items-center justify-center rounded-sm"
>
<X className="h-3 w-3" />
</button>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{/* 탭 드래그 고스트 (내부 재정렬) */}
{ghostStyle && draggedTab && (
<div
style={ghostStyle}
className="border-primary/50 bg-background rounded-t-md border border-b-0 px-3"
>
<div className="flex h-full items-center">
<span className="truncate text-[11px] font-medium">{draggedTab.title}</span>
</div>
</div>
)}
{/* 사이드바 드롭 고스트 (드롭 지점 → 탭 슬롯 이동) */}
{dropGhost &&
(() => {
const bar = containerRef.current?.getBoundingClientRect();
if (!bar) return null;
const targetX = bar.left + BAR_PAD_X + dropGhost.targetIdx * TAB_UNIT;
const targetY = bar.bottom - 28;
return (
<div
ref={dropGhostRef}
style={{
position: "fixed",
left: targetX,
top: targetY,
width: TAB_WIDTH,
height: 28,
zIndex: 100,
pointerEvents: "none",
}}
className="border-border bg-background rounded-t-md border border-b-0 px-3"
>
<div className="flex h-full items-center">
<span className="truncate text-[11px] font-medium">{dropGhost.title}</span>
</div>
</div>
);
})()}
{/* 우클릭 컨텍스트 메뉴 */}
{contextMenu && (
<div
className="border-border bg-popover fixed z-50 min-w-[180px] rounded-md border p-1 shadow-md"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<ContextMenuItem
label="새로고침"
onClick={() => {
refreshTab(contextMenu.tabId);
setContextMenu(null);
}}
/>
<div className="bg-border my-1 h-px" />
<ContextMenuItem
label="왼쪽 탭 닫기"
onClick={() => {
closeTabsToLeft(contextMenu.tabId);
setContextMenu(null);
}}
/>
<ContextMenuItem
label="오른쪽 탭 닫기"
onClick={() => {
closeTabsToRight(contextMenu.tabId);
setContextMenu(null);
}}
/>
<ContextMenuItem
label="다른 탭 모두 닫기"
onClick={() => {
closeOtherTabs(contextMenu.tabId);
setContextMenu(null);
}}
/>
<div className="bg-border my-1 h-px" />
<ContextMenuItem
label="모든 탭 닫기"
onClick={() => {
closeAllTabs();
setContextMenu(null);
}}
destructive
/>
</div>
)}
</>
);
}
function ContextMenuItem({
label,
onClick,
destructive,
}: {
label: string;
onClick: () => void;
destructive?: boolean;
}) {
return (
<button
onClick={onClick}
className={cn(
"flex w-full items-center rounded-sm px-2 py-1.5 text-xs transition-colors",
destructive ? "text-destructive hover:bg-destructive/10" : "text-foreground hover:bg-accent",
)}
>
{label}
</button>
);
}

View File

@ -0,0 +1,248 @@
"use client";
import React, { useRef, useEffect, useCallback } from "react";
import { useTabStore, selectTabs, selectActiveTabId } from "@/stores/tabStore";
import { ScreenViewPageWrapper } from "@/app/(main)/screens/[screenId]/page";
import { AdminPageRenderer } from "./AdminPageRenderer";
import { EmptyDashboard } from "./EmptyDashboard";
import { TabIdProvider } from "@/contexts/TabIdContext";
import { registerModalPortal } from "@/lib/modalPortalRef";
import ScreenModal from "@/components/common/ScreenModal";
import {
saveTabCacheImmediate,
loadTabCache,
captureAllScrollPositions,
restoreAllScrollPositions,
getElementPath,
captureFormState,
restoreFormState,
clearTabCache,
} from "@/lib/tabStateCache";
export function TabContent() {
const tabs = useTabStore(selectTabs);
const activeTabId = useTabStore(selectActiveTabId);
const refreshKeys = useTabStore((s) => s.refreshKeys);
// 한 번이라도 활성화된 탭만 마운트 (지연 마운트)
const mountedTabIdsRef = useRef<Set<string>>(new Set());
// 각 탭의 스크롤 컨테이너 ref
const scrollRefsMap = useRef<Map<string, HTMLDivElement | null>>(new Map());
// 이전 활성 탭 ID 추적
const prevActiveTabIdRef = useRef<string | null>(null);
// 활성 탭의 스크롤 위치를 실시간 추적 (display:none 전에 캡처하기 위함)
// Map<tabId, Map<elementPath, {top, left}>> - 탭 내 여러 스크롤 영역을 각각 추적
const lastScrollMapRef = useRef<Map<string, Map<string, { top: number; left: number }>>>(new Map());
// 요소 → 경로 캐시 (매 스크롤 이벤트마다 경로를 재계산하지 않기 위함)
const pathCacheRef = useRef<WeakMap<HTMLElement, string | null>>(new WeakMap());
if (activeTabId) {
mountedTabIdsRef.current.add(activeTabId);
}
// 활성 탭의 scroll 이벤트를 감지하여 요소별 위치를 실시간 저장
useEffect(() => {
if (!activeTabId) return;
const container = scrollRefsMap.current.get(activeTabId);
if (!container) return;
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement;
let path = pathCacheRef.current.get(target);
if (path === undefined) {
path = getElementPath(target, container);
pathCacheRef.current.set(target, path);
}
if (path === null) return;
let tabMap = lastScrollMapRef.current.get(activeTabId);
if (!tabMap) {
tabMap = new Map();
lastScrollMapRef.current.set(activeTabId, tabMap);
}
if (target.scrollTop > 0 || target.scrollLeft > 0) {
tabMap.set(path, { top: target.scrollTop, left: target.scrollLeft });
} else {
tabMap.delete(path);
}
};
container.addEventListener("scroll", handleScroll, true);
return () => container.removeEventListener("scroll", handleScroll, true);
}, [activeTabId]);
// 복원 관련 cleanup ref
const scrollRestoreCleanupRef = useRef<(() => void) | null>(null);
const formRestoreCleanupRef = useRef<(() => void) | null>(null);
// 탭 전환 시: 이전 탭 상태 캐싱, 새 탭 상태 복원
useEffect(() => {
// 이전 복원 작업 취소
if (scrollRestoreCleanupRef.current) {
scrollRestoreCleanupRef.current();
scrollRestoreCleanupRef.current = null;
}
if (formRestoreCleanupRef.current) {
formRestoreCleanupRef.current();
formRestoreCleanupRef.current = null;
}
const prevId = prevActiveTabIdRef.current;
// 이전 활성 탭의 스크롤 + 폼 상태 저장
// 키를 항상 포함하여 이전 캐시의 오래된 값이 병합으로 살아남지 않도록 함
if (prevId && prevId !== activeTabId) {
const tabMap = lastScrollMapRef.current.get(prevId);
const scrollPositions =
tabMap && tabMap.size > 0
? Array.from(tabMap.entries()).map(([path, pos]) => ({ path, ...pos }))
: undefined;
const prevEl = scrollRefsMap.current.get(prevId);
const formFields = captureFormState(prevEl ?? null);
saveTabCacheImmediate(prevId, {
scrollPositions,
domFormFields: formFields ?? undefined,
});
}
// 새 활성 탭의 스크롤 + 폼 상태 복원
if (activeTabId) {
const cache = loadTabCache(activeTabId);
if (cache) {
const el = scrollRefsMap.current.get(activeTabId);
if (cache.scrollPositions) {
const cleanup = restoreAllScrollPositions(el ?? null, cache.scrollPositions);
if (cleanup) scrollRestoreCleanupRef.current = cleanup;
}
if (cache.domFormFields) {
const cleanup = restoreFormState(el ?? null, cache.domFormFields ?? null);
if (cleanup) formRestoreCleanupRef.current = cleanup;
}
}
}
prevActiveTabIdRef.current = activeTabId;
}, [activeTabId]);
// F5 새로고침 직전에 활성 탭의 스크롤/폼 상태를 저장
useEffect(() => {
const handleBeforeUnload = () => {
const currentActiveId = prevActiveTabIdRef.current;
if (!currentActiveId) return;
const el = scrollRefsMap.current.get(currentActiveId);
// 활성 탭은 display:block이므로 DOM에서 직접 캡처 (가장 정확)
const scrollPositions = captureAllScrollPositions(el ?? null);
// DOM 캡처 실패 시 실시간 추적 데이터 fallback
const tabMap = lastScrollMapRef.current.get(currentActiveId);
const trackedPositions =
!scrollPositions && tabMap && tabMap.size > 0
? Array.from(tabMap.entries()).map(([path, pos]) => ({ path, ...pos }))
: undefined;
const finalPositions = scrollPositions || trackedPositions;
const formFields = captureFormState(el ?? null);
saveTabCacheImmediate(currentActiveId, {
scrollPositions: finalPositions,
domFormFields: formFields ?? undefined,
});
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
if (scrollRestoreCleanupRef.current) scrollRestoreCleanupRef.current();
if (formRestoreCleanupRef.current) formRestoreCleanupRef.current();
};
}, []);
// 탭 닫기 시 캐시 정리 (tabs 배열 변화 감지)
useEffect(() => {
const currentTabIds = new Set(tabs.map((t) => t.id));
const mountedIds = mountedTabIdsRef.current;
mountedIds.forEach((id) => {
if (!currentTabIds.has(id)) {
clearTabCache(id);
scrollRefsMap.current.delete(id);
mountedIds.delete(id);
}
});
}, [tabs]);
const setScrollRef = useCallback((tabId: string, el: HTMLDivElement | null) => {
scrollRefsMap.current.set(tabId, el);
}, []);
// 포탈 컨테이너 ref callback: 전역 레퍼런스에 등록
const portalRefCallback = useCallback((el: HTMLDivElement | null) => {
registerModalPortal(el);
}, []);
if (tabs.length === 0) {
return <EmptyDashboard />;
}
const tabLookup = new Map(tabs.map((t) => [t.id, t]));
const stableIds = Array.from(mountedTabIdsRef.current);
return (
<div ref={portalRefCallback} className="relative min-h-0 flex-1 overflow-hidden">
{stableIds.map((tabId) => {
const tab = tabLookup.get(tabId);
if (!tab) return null;
const isActive = tab.id === activeTabId;
const refreshKey = refreshKeys[tab.id] || 0;
return (
<div
key={tab.id}
ref={(el) => setScrollRef(tab.id, el)}
className="absolute inset-0 overflow-hidden"
style={{ display: isActive ? "block" : "none" }}
>
<TabIdProvider value={tab.id}>
<TabPageRenderer tab={tab} refreshKey={refreshKey} />
<ScreenModal key={`modal-${tab.id}-${refreshKey}`} />
</TabIdProvider>
</div>
);
})}
</div>
);
}
function TabPageRenderer({
tab,
refreshKey,
}: {
tab: { id: string; type: string; screenId?: number; menuObjid?: number; adminUrl?: string };
refreshKey: number;
}) {
if (tab.type === "screen" && tab.screenId != null) {
return (
<ScreenViewPageWrapper
key={`${tab.id}-${refreshKey}`}
screenIdProp={tab.screenId}
menuObjidProp={tab.menuObjid}
/>
);
}
if (tab.type === "admin" && tab.adminUrl) {
return (
<div key={`${tab.id}-${refreshKey}`} className="h-full">
<AdminPageRenderer url={tab.adminUrl} />
</div>
);
}
return null;
}

View File

@ -18,6 +18,7 @@ interface AutoConfigPanelProps {
config?: any; config?: any;
onChange: (config: any) => void; onChange: (config: any) => void;
isPreview?: boolean; isPreview?: boolean;
tableName?: string;
} }
interface TableInfo { interface TableInfo {
@ -37,6 +38,7 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
config = {}, config = {},
onChange, onChange,
isPreview = false, isPreview = false,
tableName,
}) => { }) => {
// 1. 순번 (자동 증가) // 1. 순번 (자동 증가)
if (partType === "sequence") { if (partType === "sequence") {
@ -131,6 +133,18 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
return <CategoryConfigPanel config={config} onChange={onChange} isPreview={isPreview} />; return <CategoryConfigPanel config={config} onChange={onChange} isPreview={isPreview} />;
} }
// 6. 참조 (마스터-디테일 분번)
if (partType === "reference") {
return (
<ReferenceConfigSection
config={config}
onChange={onChange}
isPreview={isPreview}
tableName={tableName}
/>
);
}
return null; return null;
}; };
@ -1032,3 +1046,94 @@ const CategoryConfigPanel: React.FC<CategoryConfigPanelProps> = ({ config = {},
</div> </div>
); );
}; };
function ReferenceConfigSection({
config,
onChange,
isPreview,
tableName,
}: {
config: any;
onChange: (c: any) => void;
isPreview: boolean;
tableName?: string;
}) {
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const [loadingCols, setLoadingCols] = useState(false);
useEffect(() => {
if (!tableName) return;
setLoadingCols(true);
const loadEntityColumns = async () => {
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(
`/screen-management/tables/${tableName}/columns`
);
const allCols = response.data?.data || response.data || [];
const entityCols = allCols.filter(
(c: any) =>
(c.inputType || c.input_type) === "entity" ||
(c.inputType || c.input_type) === "numbering"
);
setColumns(
entityCols.map((c: any) => ({
columnName: c.columnName || c.column_name,
displayName:
c.columnLabel || c.column_label || c.columnName || c.column_name,
dataType: c.dataType || c.data_type || "",
inputType: c.inputType || c.input_type || "",
}))
);
} catch {
setColumns([]);
} finally {
setLoadingCols(false);
}
};
loadEntityColumns();
}, [tableName]);
return (
<div className="space-y-3">
<div>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<Select
value={config.referenceColumnName || ""}
onValueChange={(value) =>
onChange({ ...config, referenceColumnName: value })
}
disabled={isPreview || loadingCols}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue
placeholder={
loadingCols
? "로딩 중..."
: columns.length === 0
? "엔티티 컬럼 없음"
: "컬럼 선택"
}
/>
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem
key={col.columnName}
value={col.columnName}
className="text-xs"
>
{col.displayName} ({col.columnName})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
/
</p>
</div>
</div>
);
}

View File

@ -16,6 +16,7 @@ interface NumberingRuleCardProps {
onUpdate: (updates: Partial<NumberingRulePart>) => void; onUpdate: (updates: Partial<NumberingRulePart>) => void;
onDelete: () => void; onDelete: () => void;
isPreview?: boolean; isPreview?: boolean;
tableName?: string;
} }
export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
@ -23,6 +24,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
onUpdate, onUpdate,
onDelete, onDelete,
isPreview = false, isPreview = false,
tableName,
}) => { }) => {
return ( return (
<Card className="border-border bg-card flex-1"> <Card className="border-border bg-card flex-1">
@ -57,6 +59,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
date: { dateFormat: "YYYYMMDD" }, date: { dateFormat: "YYYYMMDD" },
text: { textValue: "CODE" }, text: { textValue: "CODE" },
category: { categoryKey: "", categoryMappings: [] }, category: { categoryKey: "", categoryMappings: [] },
reference: { referenceColumnName: "" },
}; };
onUpdate({ onUpdate({
partType: newPartType, partType: newPartType,
@ -105,6 +108,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
config={part.autoConfig} config={part.autoConfig}
onChange={(autoConfig) => onUpdate({ autoConfig })} onChange={(autoConfig) => onUpdate({ autoConfig })}
isPreview={isPreview} isPreview={isPreview}
tableName={tableName}
/> />
) : ( ) : (
<ManualConfigPanel <ManualConfigPanel

View File

@ -1,35 +1,30 @@
"use client"; "use client";
import React, { useState, useCallback, useEffect } from "react"; import React, { useState, useCallback, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Save, Edit2, Trash2, FolderTree, Check, ChevronsUpDown } from "lucide-react"; import { Plus, Save, Edit2, FolderTree } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule"; import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
import { NumberingRuleCard } from "./NumberingRuleCard"; import { NumberingRuleCard } from "./NumberingRuleCard";
import { NumberingRulePreview } from "./NumberingRulePreview"; import { NumberingRulePreview } from "./NumberingRulePreview";
import { import { saveNumberingRuleToTest } from "@/lib/api/numberingRule";
saveNumberingRuleToTest, import { apiClient } from "@/lib/api/client";
deleteNumberingRuleFromTest,
getNumberingRulesFromTest,
} from "@/lib/api/numberingRule";
import { getCategoryTree, getAllCategoryKeys } from "@/lib/api/categoryTree";
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 { cn } from "@/lib/utils";
// 카테고리 값 트리 노드 타입 interface NumberingColumn {
interface CategoryValueNode { tableName: string;
valueId: number; tableLabel: string;
valueCode: string; columnName: string;
valueLabel: string; columnLabel: string;
depth: number; }
path: string;
parentValueId: number | null; interface GroupedColumns {
children?: CategoryValueNode[]; tableLabel: string;
columns: NumberingColumn[];
} }
interface NumberingRuleDesignerProps { interface NumberingRuleDesignerProps {
@ -53,138 +48,100 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
currentTableName, currentTableName,
menuObjid, menuObjid,
}) => { }) => {
const [savedRules, setSavedRules] = useState<NumberingRuleConfig[]>([]); const [numberingColumns, setNumberingColumns] = useState<NumberingColumn[]>([]);
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null); const [selectedColumn, setSelectedColumn] = useState<{ tableName: string; columnName: string } | null>(null);
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null); const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [leftTitle, setLeftTitle] = useState("저장된 규칙 목록"); const [columnSearch, setColumnSearch] = useState("");
const [rightTitle, setRightTitle] = useState("규칙 편집"); const [rightTitle, setRightTitle] = useState("규칙 편집");
const [editingLeftTitle, setEditingLeftTitle] = useState(false);
const [editingRightTitle, setEditingRightTitle] = useState(false); const [editingRightTitle, setEditingRightTitle] = useState(false);
// 구분자 관련 상태 // 구분자 관련 상태 (개별 파트 사이 구분자)
const [separatorType, setSeparatorType] = useState<SeparatorType>("-"); const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
const [customSeparator, setCustomSeparator] = useState(""); const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
// 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회
interface CategoryOption {
tableName: string;
columnName: string;
displayName: string; // "테이블명.컬럼명" 형식
}
const [allCategoryOptions, setAllCategoryOptions] = useState<CategoryOption[]>([]);
const [selectedCategoryKey, setSelectedCategoryKey] = useState<string>(""); // "tableName.columnName"
const [categoryValues, setCategoryValues] = useState<CategoryValueNode[]>([]);
const [categoryKeyOpen, setCategoryKeyOpen] = useState(false);
const [categoryValueOpen, setCategoryValueOpen] = useState(false);
const [loadingCategories, setLoadingCategories] = useState(false);
// 좌측: 채번 타입 컬럼 목록 로드
useEffect(() => { useEffect(() => {
loadRules(); loadNumberingColumns();
loadAllCategoryOptions(); // 전체 카테고리 옵션 로드
}, []); }, []);
// currentRule의 categoryColumn이 변경되면 selectedCategoryKey 동기화 const loadNumberingColumns = async () => {
useEffect(() => {
if (currentRule?.categoryColumn) {
setSelectedCategoryKey(currentRule.categoryColumn);
} else {
setSelectedCategoryKey("");
}
}, [currentRule?.categoryColumn]);
// 카테고리 키 선택 시 해당 카테고리 값 로드
useEffect(() => {
if (selectedCategoryKey) {
const [tableName, columnName] = selectedCategoryKey.split(".");
if (tableName && columnName) {
loadCategoryValues(tableName, columnName);
}
} else {
setCategoryValues([]);
}
}, [selectedCategoryKey]);
// 전체 카테고리 옵션 로드 (모든 테이블의 category 타입 컬럼)
const loadAllCategoryOptions = async () => {
try {
// category_values 테이블에서 고유한 테이블.컬럼 조합 조회
const response = await getAllCategoryKeys();
if (response.success && response.data) {
const options: CategoryOption[] = response.data.map((item) => ({
tableName: item.tableName,
columnName: item.columnName,
displayName: `${item.tableName}.${item.columnName}`,
}));
setAllCategoryOptions(options);
console.log("전체 카테고리 옵션 로드:", options);
}
} catch (error) {
console.error("카테고리 옵션 목록 조회 실패:", error);
}
};
// 특정 카테고리 컬럼의 값 트리 조회
const loadCategoryValues = async (tableName: string, columnName: string) => {
setLoadingCategories(true);
try {
const response = await getCategoryTree(tableName, columnName);
if (response.success && response.data) {
setCategoryValues(response.data);
console.log("카테고리 값 로드:", { tableName, columnName, count: response.data.length });
} else {
setCategoryValues([]);
}
} catch (error) {
console.error("카테고리 값 트리 조회 실패:", error);
setCategoryValues([]);
} finally {
setLoadingCategories(false);
}
};
// 카테고리 값을 플랫 리스트로 변환 (UI에서 선택용)
const flattenCategoryValues = (nodes: CategoryValueNode[], result: CategoryValueNode[] = []): CategoryValueNode[] => {
for (const node of nodes) {
result.push(node);
if (node.children && node.children.length > 0) {
flattenCategoryValues(node.children, result);
}
}
return result;
};
const flatCategoryValues = flattenCategoryValues(categoryValues);
const loadRules = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
console.log("🔍 [NumberingRuleDesigner] 채번 규칙 목록 로드 시작 (test 테이블):", { const response = await apiClient.get("/table-management/numbering-columns");
menuObjid, if (response.data.success && response.data.data) {
hasMenuObjid: !!menuObjid, setNumberingColumns(response.data.data);
});
// test 테이블에서 조회
const response = await getNumberingRulesFromTest(menuObjid);
console.log("📦 [NumberingRuleDesigner] 채번 규칙 API 응답 (test 테이블):", {
menuObjid,
success: response.success,
rulesCount: response.data?.length || 0,
rules: response.data,
});
if (response.success && response.data) {
setSavedRules(response.data);
} else {
toast.error(response.error || "규칙 목록을 불러올 수 없습니다");
} }
} catch (error: any) { } catch (error: any) {
toast.error(`로딩 실패: ${error.message}`); console.error("채번 컬럼 목록 로드 실패:", error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [menuObjid]); };
// 컬럼 선택 시 해당 컬럼의 채번 규칙 로드
const handleSelectColumn = async (tableName: string, columnName: string) => {
setSelectedColumn({ tableName, columnName });
setLoading(true);
try {
const response = await apiClient.get(`/numbering-rules/by-column/${tableName}/${columnName}`);
if (response.data.success && response.data.data) {
const rule = response.data.data as NumberingRuleConfig;
setCurrentRule(JSON.parse(JSON.stringify(rule)));
} else {
// 규칙 없으면 신규 생성 모드
const newRule: NumberingRuleConfig = {
ruleId: `rule-${Date.now()}`,
ruleName: `${columnName} 채번`,
parts: [],
separator: "-",
resetPeriod: "none",
currentSequence: 1,
scopeType: "table",
tableName,
columnName,
};
setCurrentRule(newRule);
}
} catch {
const newRule: NumberingRuleConfig = {
ruleId: `rule-${Date.now()}`,
ruleName: `${columnName} 채번`,
parts: [],
separator: "-",
resetPeriod: "none",
currentSequence: 1,
scopeType: "table",
tableName,
columnName,
};
setCurrentRule(newRule);
} finally {
setLoading(false);
}
};
// 테이블별로 그룹화
const groupedColumns = numberingColumns.reduce<Record<string, GroupedColumns>>((acc, col) => {
if (!acc[col.tableName]) {
acc[col.tableName] = { tableLabel: col.tableLabel, columns: [] };
}
acc[col.tableName].columns.push(col);
return acc;
}, {});
// 검색 필터 적용
const filteredGroups = Object.entries(groupedColumns).filter(([tableName, group]) => {
if (!columnSearch) return true;
const search = columnSearch.toLowerCase();
return (
tableName.toLowerCase().includes(search) ||
group.tableLabel.toLowerCase().includes(search) ||
group.columns.some(
(c) => c.columnName.toLowerCase().includes(search) || c.columnLabel.toLowerCase().includes(search)
)
);
});
useEffect(() => { useEffect(() => {
if (currentRule) { if (currentRule) {
@ -192,48 +149,68 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
} }
}, [currentRule, onChange]); }, [currentRule, onChange]);
// currentRule이 변경될 때 구분자 상태 동기화 // currentRule이 변경될 때 파트별 구분자 상태 동기화
useEffect(() => { useEffect(() => {
if (currentRule) { if (currentRule && currentRule.parts.length > 0) {
const sep = currentRule.separator ?? "-"; const newSepTypes: Record<number, SeparatorType> = {};
// 빈 문자열이면 "none" const newCustomSeps: Record<number, string> = {};
if (sep === "") {
setSeparatorType("none");
setCustomSeparator("");
return;
}
// 미리 정의된 구분자인지 확인 (none, custom 제외)
const predefinedOption = SEPARATOR_OPTIONS.find(
(opt) => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep,
);
if (predefinedOption) {
setSeparatorType(predefinedOption.value);
setCustomSeparator("");
} else {
// 직접 입력된 구분자
setSeparatorType("custom");
setCustomSeparator(sep);
}
}
}, [currentRule?.ruleId]); // ruleId가 변경될 때만 실행 (규칙 선택/생성 시)
// 구분자 변경 핸들러 currentRule.parts.forEach((part) => {
const handleSeparatorChange = useCallback((type: SeparatorType) => { const sep = part.separatorAfter ?? currentRule.separator ?? "-";
setSeparatorType(type); if (sep === "") {
newSepTypes[part.order] = "none";
newCustomSeps[part.order] = "";
} else {
const predefinedOption = SEPARATOR_OPTIONS.find(
opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep
);
if (predefinedOption) {
newSepTypes[part.order] = predefinedOption.value;
newCustomSeps[part.order] = "";
} else {
newSepTypes[part.order] = "custom";
newCustomSeps[part.order] = sep;
}
}
});
setSeparatorTypes(newSepTypes);
setCustomSeparators(newCustomSeps);
}
}, [currentRule?.ruleId]);
// 개별 파트 구분자 변경 핸들러
const handlePartSeparatorChange = useCallback((partOrder: number, type: SeparatorType) => {
setSeparatorTypes(prev => ({ ...prev, [partOrder]: type }));
if (type !== "custom") { if (type !== "custom") {
const option = SEPARATOR_OPTIONS.find((opt) => opt.value === type); const option = SEPARATOR_OPTIONS.find(opt => opt.value === type);
const newSeparator = option?.displayValue ?? ""; const newSeparator = option?.displayValue ?? "";
setCurrentRule((prev) => (prev ? { ...prev, separator: newSeparator } : null)); setCustomSeparators(prev => ({ ...prev, [partOrder]: "" }));
setCustomSeparator(""); setCurrentRule((prev) => {
if (!prev) return null;
return {
...prev,
parts: prev.parts.map((part) =>
part.order === partOrder ? { ...part, separatorAfter: newSeparator } : part
),
};
});
} }
}, []); }, []);
// 직접 입력 구분자 변경 핸들러 // 개별 파트 직접 입력 구분자 변경 핸들러
const handleCustomSeparatorChange = useCallback((value: string) => { const handlePartCustomSeparatorChange = useCallback((partOrder: number, value: string) => {
// 최대 2자 제한
const trimmedValue = value.slice(0, 2); const trimmedValue = value.slice(0, 2);
setCustomSeparator(trimmedValue); setCustomSeparators(prev => ({ ...prev, [partOrder]: trimmedValue }));
setCurrentRule((prev) => (prev ? { ...prev, separator: trimmedValue } : null)); setCurrentRule((prev) => {
if (!prev) return null;
return {
...prev,
parts: prev.parts.map((part) =>
part.order === partOrder ? { ...part, separatorAfter: trimmedValue } : part
),
};
});
}, []); }, []);
const handleAddPart = useCallback(() => { const handleAddPart = useCallback(() => {
@ -250,6 +227,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
partType: "text", partType: "text",
generationMethod: "auto", generationMethod: "auto",
autoConfig: { textValue: "CODE" }, autoConfig: { textValue: "CODE" },
separatorAfter: "-",
}; };
setCurrentRule((prev) => { setCurrentRule((prev) => {
@ -257,6 +235,10 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
return { ...prev, parts: [...prev.parts, newPart] }; return { ...prev, parts: [...prev.parts, newPart] };
}); });
// 새 파트의 구분자 상태 초기화
setSeparatorTypes(prev => ({ ...prev, [newPart.order]: "-" }));
setCustomSeparators(prev => ({ ...prev, [newPart.order]: "" }));
toast.success(`규칙 ${newPart.order}가 추가되었습니다`); toast.success(`규칙 ${newPart.order}가 추가되었습니다`);
}, [currentRule, maxRules]); }, [currentRule, maxRules]);
@ -277,9 +259,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
if (!prev) return null; if (!prev) return null;
return { return {
...prev, ...prev,
parts: prev.parts parts: prev.parts.filter((part) => part.order !== partOrder).map((part, index) => ({ ...part, order: index + 1 })),
.filter((part) => part.order !== partOrder)
.map((part, index) => ({ ...part, order: index + 1 })),
}; };
}); });
@ -319,207 +299,86 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
return part; return part;
}); });
// 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정
// menuObjid가 있으면 menu 스코프, 없으면 기존 scopeType 유지
const effectiveMenuObjid = menuObjid || currentRule.menuObjid || null;
const effectiveScopeType = effectiveMenuObjid ? "menu" : currentRule.scopeType || "global";
const ruleToSave = { const ruleToSave = {
...currentRule, ...currentRule,
parts: partsWithDefaults, parts: partsWithDefaults,
scopeType: effectiveScopeType as "menu" | "global", // menuObjid 유무에 따라 결정 scopeType: "table" as const,
tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 (참고용) tableName: selectedColumn?.tableName || currentRule.tableName || "",
menuObjid: effectiveMenuObjid, // 메뉴 OBJID (필터링 기준) columnName: selectedColumn?.columnName || currentRule.columnName || "",
}; };
console.log("💾 채번 규칙 저장:", {
currentTableName,
menuObjid,
"currentRule.tableName": currentRule.tableName,
"currentRule.menuObjid": currentRule.menuObjid,
"ruleToSave.tableName": ruleToSave.tableName,
"ruleToSave.menuObjid": ruleToSave.menuObjid,
"ruleToSave.scopeType": ruleToSave.scopeType,
ruleToSave,
});
// 테스트 테이블에 저장 (numbering_rules) // 테스트 테이블에 저장 (numbering_rules)
const response = await saveNumberingRuleToTest(ruleToSave); const response = await saveNumberingRuleToTest(ruleToSave);
if (response.success && response.data) { if (response.success && response.data) {
// 깊은 복사하여 savedRules와 currentRule이 다른 객체를 참조하도록 함
const currentData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig; const currentData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
// setSavedRules 내부에서 prev를 사용해서 existing 확인 (클로저 문제 방지)
setSavedRules((prev) => {
const savedData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig;
const existsInPrev = prev.some((r) => r.ruleId === ruleToSave.ruleId);
console.log("🔍 [handleSave] setSavedRules:", {
ruleId: ruleToSave.ruleId,
existsInPrev,
prevCount: prev.length,
});
if (existsInPrev) {
// 기존 규칙 업데이트
return prev.map((r) => (r.ruleId === ruleToSave.ruleId ? savedData : r));
} else {
// 새 규칙 추가
return [...prev, savedData];
}
});
setCurrentRule(currentData); setCurrentRule(currentData);
setSelectedRuleId(response.data.ruleId);
await onSave?.(response.data); await onSave?.(response.data);
toast.success("채번 규칙이 저장되었습니다"); toast.success("채번 규칙이 저장되었습니다");
} else { } else {
toast.error(response.error || "저장 실패"); showErrorToast("채번 규칙 저장에 실패했습니다", response.error, { guidance: "설정을 확인하고 다시 시도해 주세요." });
} }
} catch (error: any) { } catch (error: any) {
toast.error(`저장 실패: ${error.message}`); showErrorToast("채번 규칙 저장에 실패했습니다", error, { guidance: "설정을 확인하고 다시 시도해 주세요." });
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [currentRule, onSave, currentTableName, menuObjid]); }, [currentRule, onSave, selectedColumn]);
const handleSelectRule = useCallback((rule: NumberingRuleConfig) => {
console.log("🔍 [handleSelectRule] 규칙 선택:", {
ruleId: rule.ruleId,
ruleName: rule.ruleName,
partsCount: rule.parts?.length || 0,
parts: rule.parts?.map((p) => ({ id: p.id, order: p.order, partType: p.partType })),
});
setSelectedRuleId(rule.ruleId);
// 깊은 복사하여 객체 참조 분리 (좌측 목록과 편집 영역의 객체가 공유되지 않도록)
const ruleCopy = JSON.parse(JSON.stringify(rule)) as NumberingRuleConfig;
console.log("🔍 [handleSelectRule] 깊은 복사 후:", {
ruleId: ruleCopy.ruleId,
partsCount: ruleCopy.parts?.length || 0,
parts: ruleCopy.parts?.map((p) => ({ id: p.id, order: p.order, partType: p.partType })),
});
setCurrentRule(ruleCopy);
toast.info(`"${rule.ruleName}" 규칙을 불러왔습니다`);
}, []);
const handleDeleteSavedRule = useCallback(
async (ruleId: string) => {
setLoading(true);
try {
const response = await deleteNumberingRuleFromTest(ruleId);
if (response.success) {
setSavedRules((prev) => prev.filter((r) => r.ruleId !== ruleId));
if (selectedRuleId === ruleId) {
setSelectedRuleId(null);
setCurrentRule(null);
}
toast.success("규칙이 삭제되었습니다");
} else {
toast.error(response.error || "삭제 실패");
}
} catch (error: any) {
toast.error(`삭제 실패: ${error.message}`);
} finally {
setLoading(false);
}
},
[selectedRuleId],
);
const handleNewRule = useCallback(() => {
console.log("📋 새 규칙 생성:", { currentTableName, menuObjid });
const newRule: NumberingRuleConfig = {
ruleId: `rule-${Date.now()}`,
ruleName: "새 채번 규칙",
parts: [],
separator: "-",
resetPeriod: "none",
currentSequence: 1,
scopeType: "table", // ⚠️ 임시: DB 제약 조건 때문에 table 유지
tableName: currentTableName || "", // 현재 화면의 테이블명 자동 설정
menuObjid: menuObjid || null, // 🆕 메뉴 OBJID 설정 (필터링용)
};
console.log("📋 생성된 규칙 정보:", newRule);
setSelectedRuleId(newRule.ruleId);
setCurrentRule(newRule);
toast.success("새 규칙이 생성되었습니다");
}, [currentTableName, menuObjid]);
return ( return (
<div className={`flex h-full gap-4 ${className}`}> <div className={`flex h-full gap-4 ${className}`}>
{/* 좌측: 저장된 규칙 목록 */} {/* 좌측: 채번 컬럼 목록 (카테고리 패턴) */}
<div className="flex w-80 flex-shrink-0 flex-col gap-4"> <div className="flex w-72 flex-shrink-0 flex-col gap-3">
<div className="flex items-center justify-between"> <h2 className="text-sm font-semibold sm:text-base"> </h2>
{editingLeftTitle ? (
<Input
value={leftTitle}
onChange={(e) => setLeftTitle(e.target.value)}
onBlur={() => setEditingLeftTitle(false)}
onKeyDown={(e) => e.key === "Enter" && setEditingLeftTitle(false)}
className="h-8 text-sm font-semibold"
autoFocus
/>
) : (
<h2 className="text-sm font-semibold sm:text-base">{leftTitle}</h2>
)}
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditingLeftTitle(true)}>
<Edit2 className="h-3 w-3" />
</Button>
</div>
<Button onClick={handleNewRule} variant="outline" className="h-9 w-full text-sm"> <Input
<Plus className="mr-2 h-4 w-4" /> value={columnSearch}
</Button> onChange={(e) => setColumnSearch(e.target.value)}
placeholder="검색..."
className="h-8 text-xs"
/>
<div className="flex-1 space-y-2 overflow-y-auto"> <div className="flex-1 space-y-1 overflow-y-auto">
{loading ? ( {loading && numberingColumns.length === 0 ? (
<div className="flex h-32 items-center justify-center"> <div className="flex h-32 items-center justify-center">
<p className="text-muted-foreground text-xs"> ...</p> <p className="text-muted-foreground text-xs"> ...</p>
</div> </div>
) : savedRules.length === 0 ? ( ) : filteredGroups.length === 0 ? (
<div className="border-border bg-muted/50 flex h-32 items-center justify-center rounded-lg border border-dashed"> <div className="border-border bg-muted/50 flex h-32 items-center justify-center rounded-lg border border-dashed">
<p className="text-muted-foreground text-xs"> </p> <p className="text-muted-foreground text-xs">
{numberingColumns.length === 0
? "채번 타입 컬럼이 없습니다"
: "검색 결과가 없습니다"}
</p>
</div> </div>
) : ( ) : (
savedRules.map((rule) => ( filteredGroups.map(([tableName, group]) => (
<Card <div key={tableName} className="mb-2">
key={rule.ruleId} <div className="text-muted-foreground mb-1 flex items-center gap-1 px-1 text-[11px] font-medium">
className={`border-border hover:bg-accent cursor-pointer py-2 transition-colors ${ <FolderTree className="h-3 w-3" />
selectedRuleId === rule.ruleId ? "border-primary bg-primary/5" : "bg-card" <span>{group.tableLabel}</span>
}`} <span className="text-muted-foreground/60">({group.columns.length})</span>
onClick={() => handleSelectRule(rule)} </div>
> {group.columns.map((col) => {
<CardHeader className="px-3 py-0"> const isSelected =
<div className="flex items-start justify-between"> selectedColumn?.tableName === col.tableName &&
<div className="flex-1"> selectedColumn?.columnName === col.columnName;
<CardTitle className="text-sm font-medium">{rule.ruleName}</CardTitle> return (
</div> <div
<Button key={`${col.tableName}.${col.columnName}`}
variant="ghost" className={cn(
size="icon" "cursor-pointer rounded-md px-3 py-1.5 text-xs transition-colors",
className="h-6 w-6" isSelected
onClick={(e) => { ? "bg-primary/10 text-primary border-primary border font-medium"
e.stopPropagation(); : "hover:bg-accent"
handleDeleteSavedRule(rule.ruleId); )}
}} onClick={() => handleSelectColumn(col.tableName, col.columnName)}
> >
<Trash2 className="text-destructive h-3 w-3" /> {col.columnLabel}
</Button> </div>
</div> );
</CardHeader> })}
</Card> </div>
)) ))
)} )}
</div> </div>
@ -533,8 +392,9 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
{!currentRule ? ( {!currentRule ? (
<div className="flex h-full flex-col items-center justify-center"> <div className="flex h-full flex-col items-center justify-center">
<div className="text-center"> <div className="text-center">
<p className="text-muted-foreground mb-2 text-lg font-medium"> </p> <FolderTree className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
<p className="text-muted-foreground text-sm"> </p> <p className="text-muted-foreground mb-2 text-lg font-medium"> </p>
<p className="text-muted-foreground text-sm"> </p>
</div> </div>
</div> </div>
) : ( ) : (
@ -575,40 +435,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
</div> </div>
</div> </div>
{/* 두 번째 줄: 구분자 설정 */}
<div className="flex items-end gap-3">
<div className="w-48 space-y-2">
<Label className="text-sm font-medium"></Label>
<Select
value={separatorType}
onValueChange={(value) => handleSeparatorChange(value as SeparatorType)}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="구분자 선택" />
</SelectTrigger>
<SelectContent>
{SEPARATOR_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{separatorType === "custom" && (
<div className="w-32 space-y-2">
<Label className="text-sm font-medium"> </Label>
<Input
value={customSeparator}
onChange={(e) => handleCustomSeparatorChange(e.target.value)}
className="h-9"
placeholder="최대 2자"
maxLength={2}
/>
</div>
)}
<p className="text-muted-foreground pb-2 text-xs"> </p>
</div>
</div> </div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
@ -624,15 +451,49 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
<p className="text-muted-foreground text-xs sm:text-sm"> </p> <p className="text-muted-foreground text-xs sm:text-sm"> </p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"> <div className="flex flex-wrap items-stretch gap-3">
{currentRule.parts.map((part, index) => ( {currentRule.parts.map((part, index) => (
<NumberingRuleCard <React.Fragment key={`part-${part.order}-${index}`}>
key={`part-${part.order}-${index}`} <div className="flex w-[200px] flex-col">
part={part} <NumberingRuleCard
onUpdate={(updates) => handleUpdatePart(part.order, updates)} part={part}
onDelete={() => handleDeletePart(part.order)} onUpdate={(updates) => handleUpdatePart(part.order, updates)}
isPreview={isPreview} onDelete={() => handleDeletePart(part.order)}
/> isPreview={isPreview}
tableName={selectedColumn?.tableName}
/>
{/* 카드 하단에 구분자 설정 (마지막 파트 제외) */}
{index < currentRule.parts.length - 1 && (
<div className="mt-2 flex items-center gap-1">
<span className="text-muted-foreground text-[10px] whitespace-nowrap"> </span>
<Select
value={separatorTypes[part.order] || "-"}
onValueChange={(value) => handlePartSeparatorChange(part.order, value as SeparatorType)}
>
<SelectTrigger className="h-6 flex-1 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SEPARATOR_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value} className="text-xs">
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{separatorTypes[part.order] === "custom" && (
<Input
value={customSeparators[part.order] || ""}
onChange={(e) => handlePartCustomSeparatorChange(part.order, e.target.value)}
className="h-6 w-14 text-center text-[10px]"
placeholder="2자"
maxLength={2}
/>
)}
</div>
)}
</div>
</React.Fragment>
))} ))}
</div> </div>
)} )}

View File

@ -17,6 +17,10 @@ import { dynamicFormApi } from "@/lib/api/dynamicForm";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { ConditionalZone, LayerDefinition } from "@/types/screen-management"; import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
import { ScreenContextProvider } from "@/contexts/ScreenContext";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { useTabId } from "@/contexts/TabIdContext";
import { useTabStore } from "@/stores/tabStore";
interface EditModalState { interface EditModalState {
isOpen: boolean; isOpen: boolean;
@ -78,6 +82,9 @@ const findSaveButtonInComponents = (components: any[]): any | null => {
export const EditModal: React.FC<EditModalProps> = ({ className }) => { export const EditModal: React.FC<EditModalProps> = ({ className }) => {
const { user } = useAuth(); const { user } = useAuth();
const tabId = useTabId();
const activeTabId = useTabStore((s) => s[s.mode].activeTabId);
const isTabActive = !tabId || tabId === activeTabId;
const [modalState, setModalState] = useState<EditModalState>({ const [modalState, setModalState] = useState<EditModalState>({
isOpen: false, isOpen: false,
screenId: null, screenId: null,
@ -242,9 +249,99 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
} }
}; };
// 전역 모달 이벤트 리스너 /**
* FK를
* - entity join FK
* - FK row
* - editData에 ( )
*/
const loadMasterDataForDetailRow = async (
editData: Record<string, any>,
targetScreenId: number,
eventTableName?: string,
): Promise<Record<string, any>> => {
try {
let detailTableName = eventTableName;
if (!detailTableName) {
const screenInfo = await screenApi.getScreen(targetScreenId);
detailTableName = screenInfo?.tableName;
}
if (!detailTableName) {
console.log("[EditModal:MasterLoad] 테이블명을 알 수 없음 - 스킵");
return {};
}
console.log("[EditModal:MasterLoad] 시작:", { detailTableName, editDataKeys: Object.keys(editData) });
const entityJoinRes = await entityJoinApi.getEntityJoinConfigs(detailTableName);
const joinConfigs = entityJoinRes?.joinConfigs || [];
if (joinConfigs.length === 0) {
console.log("[EditModal:MasterLoad] entity join 없음 - 스킵");
return {};
}
console.log(
"[EditModal:MasterLoad] entity join:",
joinConfigs.map((c) => `${c.sourceColumn}${c.referenceTable}`),
);
const masterDataResult: Record<string, any> = {};
const processedTables = new Set<string>();
const { apiClient } = await import("@/lib/api/client");
for (const joinConfig of joinConfigs) {
const { sourceColumn, referenceTable, referenceColumn } = joinConfig;
if (processedTables.has(referenceTable)) continue;
const fkValue = editData[sourceColumn];
if (!fkValue) continue;
try {
const response = await apiClient.post(`/table-management/tables/${referenceTable}/data`, {
search: { [referenceColumn || "id"]: fkValue },
size: 1,
page: 1,
autoFilter: true,
});
const rows = response.data?.data?.data || response.data?.data?.rows || [];
if (rows.length > 0) {
const masterRow = rows[0];
for (const [col, val] of Object.entries(masterRow)) {
if (val !== undefined && val !== null && editData[col] === undefined) {
masterDataResult[col] = val;
}
}
console.log("[EditModal:MasterLoad] 조회 성공:", {
table: referenceTable,
fk: `${sourceColumn}=${fkValue}`,
loadedFields: Object.keys(masterDataResult),
});
}
} catch (queryError) {
console.warn("[EditModal:MasterLoad] 조회 실패:", referenceTable, queryError);
}
processedTables.add(referenceTable);
}
console.log("[EditModal:MasterLoad] 최종 결과:", Object.keys(masterDataResult));
return masterDataResult;
} catch (error) {
console.warn("[EditModal:MasterLoad] 전체 오류:", error);
return {};
}
};
// 전역 모달 이벤트 리스너 (활성 탭에서만 처리)
useEffect(() => { useEffect(() => {
const handleOpenEditModal = async (event: CustomEvent) => { const handleOpenEditModal = async (event: CustomEvent) => {
const storeState = useTabStore.getState();
const currentActiveTabId = storeState[storeState.mode].activeTabId;
if (tabId && tabId !== currentActiveTabId) return;
const { const {
screenId, screenId,
title, title,
@ -286,7 +383,28 @@ 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];
}
}
}
});
}
// editData로 formData를 즉시 세팅 (채번 컴포넌트가 빈 formData로 마운트되어 새 번호 생성하는 것 방지)
setFormData(enriched);
// originalData: changedData 계산(PATCH)에만 사용 // originalData: changedData 계산(PATCH)에만 사용
// INSERT/UPDATE 판단에는 사용하지 않음 // INSERT/UPDATE 판단에는 사용하지 않음
setOriginalData(isCreateMode ? {} : editData || {}); setOriginalData(isCreateMode ? {} : editData || {});
@ -294,6 +412,21 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// isCreateMode=true(복사/등록) → INSERT, false/undefined(수정) → UPDATE // isCreateMode=true(복사/등록) → INSERT, false/undefined(수정) → UPDATE
setIsCreateModeFlag(!!isCreateMode); setIsCreateModeFlag(!!isCreateMode);
// 마스터 데이터 자동 조회 (수정 모드일 때, formData 세팅 이후 비동기로 병합)
// 디테일 행 선택 시 마스터 테이블의 컬럼 데이터를 자동으로 가져와서 추가
if (!isCreateMode && editData && screenId) {
loadMasterDataForDetailRow(editData, screenId, tableName)
.then((masterData) => {
if (Object.keys(masterData).length > 0) {
setFormData((prev) => ({ ...prev, ...masterData }));
console.log("[EditModal] 마스터 데이터 비동기 병합 완료:", Object.keys(masterData));
}
})
.catch((masterError) => {
console.warn("[EditModal] 마스터 데이터 자동 조회 중 오류 (무시):", masterError);
});
}
console.log("[EditModal] 모달 열림:", { console.log("[EditModal] 모달 열림:", {
mode: isCreateMode ? "INSERT (생성/복사)" : "UPDATE (수정)", mode: isCreateMode ? "INSERT (생성/복사)" : "UPDATE (수정)",
hasEditData: !!editData, hasEditData: !!editData,
@ -323,7 +456,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
window.removeEventListener("openEditModal", handleOpenEditModal as EventListener); window.removeEventListener("openEditModal", handleOpenEditModal as EventListener);
window.removeEventListener("closeEditModal", handleCloseEditModal); window.removeEventListener("closeEditModal", handleCloseEditModal);
}; };
}, [modalState.onSave]); // modalState.onSave를 의존성에 추가하여 최신 콜백 참조 }, [tabId, modalState.onSave]);
// 화면 데이터 로딩 // 화면 데이터 로딩
useEffect(() => { useEffect(() => {
@ -405,9 +538,28 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// V2 없으면 기존 API fallback // V2 없으면 기존 API fallback
if (!layoutData) { if (!layoutData) {
console.warn("[EditModal] V2 레이아웃 없음, getLayout fallback 시도:", screenId);
layoutData = await screenApi.getLayout(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) { if (screenInfo && layoutData) {
const components = layoutData.components || []; const components = layoutData.components || [];
@ -550,7 +702,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
(targetComponent as any)?.componentConfig?.columnName || (targetComponent as any)?.componentConfig?.columnName ||
targetComponentId; targetComponentId;
const currentFormData = groupData.length > 0 ? groupData[0] : formData; const currentFormData = groupData.length > 0 ? { ...formData, ...groupData[0] } : formData;
const targetValue = currentFormData[fieldKey]; const targetValue = currentFormData[fieldKey];
let isMatch = false; let isMatch = false;
@ -1176,22 +1328,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
if (response.success) { if (response.success) {
const masterRecordId = response.data?.id || formData.id; const masterRecordId = response.data?.id || formData.id;
// 🆕 리피터 데이터 저장 이벤트 발생 (V2Repeater 컴포넌트가 리스닝)
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: masterRecordId,
masterRecordId,
mainFormData: formData,
tableName: screenData.screenInfo.tableName,
},
}),
);
console.log("📋 [EditModal] repeaterSave 이벤트 발생:", {
masterRecordId,
tableName: screenData.screenInfo.tableName,
});
toast.success("데이터가 생성되었습니다."); toast.success("데이터가 생성되었습니다.");
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
@ -1237,14 +1373,45 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
} }
// V2Repeater 디테일 데이터 저장 (등록된 인스턴스가 있을 때만)
const hasRepeaterForInsert = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
if (hasRepeaterForInsert) {
try {
const repeaterSavePromise = new Promise<void>((resolve) => {
const fallbackTimeout = setTimeout(resolve, 5000);
const handler = () => {
clearTimeout(fallbackTimeout);
window.removeEventListener("repeaterSaveComplete", handler);
resolve();
};
window.addEventListener("repeaterSaveComplete", handler);
});
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: masterRecordId,
tableName: screenData.screenInfo.tableName,
mainFormData: formData,
masterRecordId,
},
}),
);
await repeaterSavePromise;
} catch (repeaterError) {
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
}
}
handleClose(); handleClose();
} else { } else {
throw new Error(response.message || "생성에 실패했습니다."); throw new Error(response.message || "생성에 실패했습니다.");
} }
} else { } else {
// UPDATE 모드 - PUT (전체 업데이트) // UPDATE 모드 - PUT (전체 업데이트)
// originalData 비교 없이 formData 전체를 보냄 // VIEW에서 온 데이터의 경우 master_id를 우선 사용 (마스터-디테일 구조)
const recordId = formData.id; const recordId = formData.master_id || formData.id;
if (!recordId) { if (!recordId) {
console.error("[EditModal] UPDATE 실패: formData에 id가 없습니다.", { console.error("[EditModal] UPDATE 실패: formData에 id가 없습니다.", {
@ -1298,15 +1465,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
if (response.success) { if (response.success) {
toast.success("데이터가 수정되었습니다."); toast.success("데이터가 수정되었습니다.");
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
if (modalState.onSave) {
try {
modalState.onSave();
} catch (callbackError) {
console.error("onSave 콜백 에러:", callbackError);
}
}
// 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어) // 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어)
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig) // 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
try { try {
@ -1341,6 +1499,43 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
} }
// V2Repeater 디테일 데이터 저장 (등록된 인스턴스가 있을 때만)
const hasRepeaterForUpdate = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
if (hasRepeaterForUpdate) {
try {
const repeaterSavePromise = new Promise<void>((resolve) => {
const fallbackTimeout = setTimeout(resolve, 5000);
const handler = () => {
clearTimeout(fallbackTimeout);
window.removeEventListener("repeaterSaveComplete", handler);
resolve();
};
window.addEventListener("repeaterSaveComplete", handler);
});
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: recordId,
tableName: screenData.screenInfo.tableName,
mainFormData: formData,
masterRecordId: recordId,
},
}),
);
await repeaterSavePromise;
} catch (repeaterError) {
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
}
}
// 리피터 저장 완료 후 메인 테이블 새로고침
if (modalState.onSave) {
try {
modalState.onSave();
} catch {}
}
handleClose(); handleClose();
} else { } else {
throw new Error(response.message || "수정에 실패했습니다."); throw new Error(response.message || "수정에 실패했습니다.");
@ -1398,7 +1593,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
</div> </div>
</DialogHeader> </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 ? ( {loading ? (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="text-center"> <div className="text-center">
@ -1407,165 +1602,175 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
</div> </div>
</div> </div>
) : screenData ? ( ) : screenData ? (
<div <ScreenContextProvider
data-screen-runtime="true" screenId={modalState.screenId || undefined}
className="relative bg-white" tableName={screenData.screenInfo?.tableName}
style={{
width: screenDimensions?.width || 800,
// 🆕 조건부 레이어가 활성화되면 높이 자동 확장
height: (() => {
const baseHeight = (screenDimensions?.height || 600) + 30;
if (activeConditionalComponents.length > 0) {
// 조건부 레이어 컴포넌트 중 가장 아래 위치 계산
const offsetY = screenDimensions?.offsetY || 0;
let maxBottom = 0;
activeConditionalComponents.forEach((comp) => {
const y = parseFloat(comp.position?.y?.toString() || "0") - offsetY + 30;
const h = parseFloat(comp.size?.height?.toString() || "40");
maxBottom = Math.max(maxBottom, y + h);
});
return Math.max(baseHeight, maxBottom + 20); // 20px 여백
}
return baseHeight;
})(),
transformOrigin: "center center",
maxWidth: "100%",
}}
> >
{/* 기본 레이어 컴포넌트 렌더링 */} <div
{screenData.components.map((component) => { data-screen-runtime="true"
// 컴포넌트 위치를 offset만큼 조정 className="relative m-auto bg-white"
const offsetX = screenDimensions?.offsetX || 0; style={{
const offsetY = screenDimensions?.offsetY || 0; width: screenDimensions?.width || 800,
const labelSpace = 30; // 라벨 공간 (입력 필드 위 -top-6 라벨용) // 조건부 레이어가 활성화되면 높이 자동 확장
height: (() => {
const baseHeight = (screenDimensions?.height || 600) + 30;
if (activeConditionalComponents.length > 0) {
// 조건부 레이어 컴포넌트 중 가장 아래 위치 계산
const offsetY = screenDimensions?.offsetY || 0;
let maxBottom = 0;
activeConditionalComponents.forEach((comp) => {
const y = parseFloat(comp.position?.y?.toString() || "0") - offsetY + 30;
const h = parseFloat(comp.size?.height?.toString() || "40");
maxBottom = Math.max(maxBottom, y + h);
});
return Math.max(baseHeight, maxBottom + 20); // 20px 여백
}
return baseHeight;
})(),
transformOrigin: "center center",
maxWidth: "100%",
}}
>
{/* 기본 레이어 컴포넌트 렌더링 */}
{screenData.components.map((component) => {
// 컴포넌트 위치를 offset만큼 조정
const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0;
const labelSpace = 30; // 라벨 공간 (입력 필드 위 -top-6 라벨용)
const adjustedComponent = { const adjustedComponent = {
...component, ...component,
position: { position: {
...component.position, ...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX, x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace, // 라벨 공간 추가 y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace, // 라벨 공간 추가
}, },
}; };
const groupedDataProp = groupData.length > 0 ? groupData : undefined; const groupedDataProp = groupData.length > 0 ? groupData : undefined;
const hasUniversalFormModal = screenData.components.some((c) => { const hasUniversalFormModal = screenData.components.some((c) => {
if (c.componentType === "universal-form-modal") return true; if (c.componentType === "universal-form-modal") return true;
return false; return false;
}); });
const hasTableSectionData = Object.keys(formData).some( const hasTableSectionData = Object.keys(formData).some(
(k) => k.startsWith("_tableSection_") || k.startsWith("__tableSection_"), (k) => k.startsWith("_tableSection_") || k.startsWith("__tableSection_"),
); );
const shouldUseEditModalSave = !hasTableSectionData && (groupData.length > 0 || !hasUniversalFormModal); const shouldUseEditModalSave =
!hasTableSectionData && (groupData.length > 0 || !hasUniversalFormModal);
const enrichedFormData = { const enrichedFormData = {
...(groupData.length > 0 ? groupData[0] : formData), // 마스터 데이터(formData)를 기본으로 깔고, groupData[0]으로 덮어쓰기
tableName: screenData.screenInfo?.tableName, // → 디테일 행 수정 시에도 마스터 폼 필드가 표시됨
screenId: modalState.screenId, ...formData,
}; ...(groupData.length > 0 ? groupData[0] : {}),
tableName: screenData.screenInfo?.tableName,
screenId: modalState.screenId,
};
return ( return (
<InteractiveScreenViewerDynamic <InteractiveScreenViewerDynamic
key={component.id} key={component.id}
component={adjustedComponent} component={adjustedComponent}
allComponents={[...screenData.components, ...activeConditionalComponents]} allComponents={[...screenData.components, ...activeConditionalComponents]}
formData={enrichedFormData} formData={enrichedFormData}
originalData={originalData} originalData={originalData}
onFormDataChange={(fieldName, value) => { onFormDataChange={(fieldName, value) => {
if (groupData.length > 0) { if (groupData.length > 0) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
setGroupData(value); setGroupData(value);
} else {
setGroupData((prev) =>
prev.map((item) => ({
...item,
[fieldName]: value,
})),
);
}
} else { } else {
setGroupData((prev) => setFormData((prev) => ({
prev.map((item) => ({ ...prev,
...item, [fieldName]: value,
[fieldName]: value, }));
})),
);
} }
} else { }}
setFormData((prev) => ({ screenInfo={{
...prev, id: modalState.screenId!,
[fieldName]: value, tableName: screenData.screenInfo?.tableName,
})); }}
} menuObjid={modalState.menuObjid}
}} onSave={shouldUseEditModalSave ? handleSave : undefined}
screenInfo={{ isInModal={true}
id: modalState.screenId!, groupedData={groupedDataProp}
tableName: screenData.screenInfo?.tableName, disabledFields={["order_no", "partner_id"]}
}} />
menuObjid={modalState.menuObjid} );
onSave={shouldUseEditModalSave ? handleSave : undefined} })}
isInModal={true}
groupedData={groupedDataProp}
disabledFields={["order_no", "partner_id"]}
/>
);
})}
{/* 🆕 조건부 레이어 컴포넌트 렌더링 */} {/* 🆕 조건부 레이어 컴포넌트 렌더링 */}
{activeConditionalComponents.map((component) => { {activeConditionalComponents.map((component) => {
const offsetX = screenDimensions?.offsetX || 0; const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0; const offsetY = screenDimensions?.offsetY || 0;
const labelSpace = 30; const labelSpace = 30;
const adjustedComponent = { const adjustedComponent = {
...component, ...component,
position: { position: {
...component.position, ...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX, x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace, y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace,
}, },
}; };
const enrichedFormData = { const enrichedFormData = {
...(groupData.length > 0 ? groupData[0] : formData), ...formData,
tableName: screenData.screenInfo?.tableName, ...(groupData.length > 0 ? groupData[0] : {}),
screenId: modalState.screenId, tableName: screenData.screenInfo?.tableName,
}; screenId: modalState.screenId,
};
const groupedDataProp = groupData.length > 0 ? groupData : undefined; const groupedDataProp = groupData.length > 0 ? groupData : undefined;
return ( return (
<InteractiveScreenViewerDynamic <InteractiveScreenViewerDynamic
key={`conditional-${component.id}`} key={`conditional-${component.id}`}
component={adjustedComponent} component={adjustedComponent}
allComponents={[...screenData.components, ...activeConditionalComponents]} allComponents={[...screenData.components, ...activeConditionalComponents]}
formData={enrichedFormData} formData={enrichedFormData}
originalData={originalData} originalData={originalData}
onFormDataChange={(fieldName, value) => { onFormDataChange={(fieldName, value) => {
if (groupData.length > 0) { if (groupData.length > 0) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
setGroupData(value); setGroupData(value);
} else {
setGroupData((prev) =>
prev.map((item) => ({
...item,
[fieldName]: value,
})),
);
}
} else { } else {
setGroupData((prev) => setFormData((prev) => ({
prev.map((item) => ({ ...prev,
...item, [fieldName]: value,
[fieldName]: value, }));
})),
);
} }
} else { }}
setFormData((prev) => ({ screenInfo={{
...prev, id: modalState.screenId!,
[fieldName]: value, tableName: screenData.screenInfo?.tableName,
})); }}
} menuObjid={modalState.menuObjid}
}} isInModal={true}
screenInfo={{ groupedData={groupedDataProp}
id: modalState.screenId!, />
tableName: screenData.screenInfo?.tableName, );
}} })}
menuObjid={modalState.menuObjid} </div>
isInModal={true} </ScreenContextProvider>
groupedData={groupedDataProp}
/>
);
})}
</div>
) : ( ) : (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<p className="text-muted-foreground"> .</p> <p className="text-muted-foreground"> .</p>

View File

@ -378,7 +378,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
for (const col of categoryColumns) { for (const col of categoryColumns) {
try { try {
// menuObjid가 있으면 쿼리 파라미터로 전달 (메뉴별 카테고리 색상 적용) // menuObjid가 있으면 쿼리 파라미터로 전달 (메뉴별 카테고리 색상 적용)
const queryParams = menuObjid ? `?menuObjid=${menuObjid}` : ""; const queryParams = menuObjid ? `?menuObjid=${menuObjid}&includeInactive=true` : "?includeInactive=true";
const response = await apiClient.get( const response = await apiClient.get(
`/table-categories/${component.tableName}/${col.columnName}/values${queryParams}`, `/table-categories/${component.tableName}/${col.columnName}/values${queryParams}`,
); );

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,7 @@ import { ComponentData, WidgetComponent, DataTableComponent, FileComponent, Butt
import { FileUploadComponent } from "@/lib/registry/components/file-upload/FileUploadComponent"; import { FileUploadComponent } from "@/lib/registry/components/file-upload/FileUploadComponent";
import { InteractiveDataTable } from "./InteractiveDataTable"; import { InteractiveDataTable } from "./InteractiveDataTable";
import { DynamicWebTypeRenderer } from "@/lib/registry"; import { DynamicWebTypeRenderer } from "@/lib/registry";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { DynamicComponentRenderer, isColumnRequiredByMeta } from "@/lib/registry/DynamicComponentRenderer";
import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils"; import { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils";
import { FlowButtonGroup } from "./widgets/FlowButtonGroup"; import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
@ -26,6 +26,7 @@ import {
getServerSnapshot as canvasSplitGetServerSnapshot, getServerSnapshot as canvasSplitGetServerSnapshot,
subscribeDom as canvasSplitSubscribeDom, subscribeDom as canvasSplitSubscribeDom,
} from "@/lib/registry/components/v2-split-line/canvasSplitStore"; } from "@/lib/registry/components/v2-split-line/canvasSplitStore";
import { ButtonIconRenderer } from "@/lib/button-icon-map";
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록 // 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
import "@/lib/registry/components/ButtonRenderer"; import "@/lib/registry/components/ButtonRenderer";
@ -447,6 +448,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
onClose={() => { onClose={() => {
// buttonActions.ts가 이미 처리함 // buttonActions.ts가 이미 처리함
}} }}
isInModal={isInModal}
// 탭 관련 정보 전달 // 탭 관련 정보 전달
parentTabId={parentTabId} parentTabId={parentTabId}
parentTabsComponentId={parentTabsComponentId} parentTabsComponentId={parentTabsComponentId}
@ -558,9 +560,13 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
if (onSave) { if (onSave) {
try { try {
await onSave(); await onSave();
} catch (error) { } catch (error: any) {
console.error("저장 오류:", error); console.error("저장 오류:", error);
toast.error("저장 중 오류가 발생했습니다."); const msg =
error?.response?.data?.message ||
error?.message ||
"저장에 실패했습니다.";
toast.error(msg);
} }
return; return;
} }
@ -571,32 +577,62 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
return; return;
} }
// 리피터가 화면과 동일 테이블을 사용하는지 감지 (useCustomTable 미설정 = 동일 테이블)
const hasRepeaterOnSameTable = allComponents.some((c: any) => {
const compType = c.componentType || c.overrides?.type;
if (compType !== "v2-repeater") return false;
const compConfig = c.componentConfig || c.overrides || {};
return !compConfig.useCustomTable;
});
if (hasRepeaterOnSameTable) {
// 동일 테이블 리피터: 마스터 저장 스킵, 리피터만 저장
// 리피터가 mainFormData를 각 행에 병합하여 N건 INSERT 처리
try {
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: null,
masterRecordId: null,
mainFormData: formData,
tableName: screenInfo.tableName,
},
}),
);
toast.success("데이터가 성공적으로 저장되었습니다.");
} catch (error: any) {
const msg =
error?.response?.data?.message ||
error?.message ||
"저장에 실패했습니다.";
toast.error(msg);
}
return;
}
try { try {
// 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장) // 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장)
// 단, 파일 업로드 컴포넌트의 파일 배열(objid 배열)은 포함 // 단, 파일 업로드 컴포넌트의 파일 배열(objid 배열)은 포함
const masterFormData: Record<string, any> = {}; const masterFormData: Record<string, any> = {};
// 파일 업로드 컴포넌트의 columnName 목록 수집 (v2-media, file-upload 모두 포함) // 파일 업로드 컴포넌트의 columnName 목록 수집 (v2-media, file-upload 모두 포함)
const mediaColumnNames = new Set( const mediaColumnNames = new Set(
allComponents allComponents
.filter( .filter((c: any) =>
(c: any) => c.componentType === "v2-media" ||
c.componentType === "v2-media" || c.componentType === "file-upload" ||
c.componentType === "file-upload" || c.url?.includes("v2-media") ||
c.url?.includes("v2-media") || c.url?.includes("file-upload")
c.url?.includes("file-upload"),
) )
.map((c: any) => c.columnName || c.componentConfig?.columnName) .map((c: any) => c.columnName || c.componentConfig?.columnName)
.filter(Boolean), .filter(Boolean)
); );
Object.entries(formData).forEach(([key, value]) => { Object.entries(formData).forEach(([key, value]) => {
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
// 배열이 아닌 값은 그대로 저장
masterFormData[key] = value; masterFormData[key] = value;
} else if (mediaColumnNames.has(key)) { } else if (mediaColumnNames.has(key)) {
// v2-media 컴포넌트의 배열은 첫 번째 값만 저장 (단일 파일 컬럼 대응)
// 또는 JSON 문자열로 변환하려면 JSON.stringify(value) 사용
masterFormData[key] = value.length > 0 ? value[0] : null; masterFormData[key] = value.length > 0 ? value[0] : null;
console.log(`📷 미디어 데이터 저장: ${key}, objid: ${masterFormData[key]}`); console.log(`📷 미디어 데이터 저장: ${key}, objid: ${masterFormData[key]}`);
} else { } else {
@ -609,7 +645,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
data: masterFormData, data: masterFormData,
}; };
// console.log("💾 저장 액션 실행:", saveData);
const response = await dynamicFormApi.saveData(saveData); const response = await dynamicFormApi.saveData(saveData);
if (response.success) { if (response.success) {
@ -620,7 +655,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
new CustomEvent("repeaterSave", { new CustomEvent("repeaterSave", {
detail: { detail: {
parentId: masterRecordId, parentId: masterRecordId,
masterRecordId, // 🆕 마스터 레코드 ID (FK 자동 연결용) masterRecordId,
mainFormData: formData, mainFormData: formData,
tableName: screenInfo.tableName, tableName: screenInfo.tableName,
}, },
@ -631,9 +666,12 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
} else { } else {
toast.error(response.message || "저장에 실패했습니다."); toast.error(response.message || "저장에 실패했습니다.");
} }
} catch (error) { } catch (error: any) {
// console.error("저장 오류:", error); const msg =
toast.error("저장 중 오류가 발생했습니다."); error?.response?.data?.message ||
error?.message ||
"저장에 실패했습니다.";
toast.error(msg);
} }
}; };
@ -931,15 +969,16 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
}; };
// 커스텀 색상이 있으면 Tailwind 클래스 대신 직접 스타일 적용 // 커스텀 색상이 있으면 Tailwind 클래스 대신 직접 스타일 적용
const hasCustomColors = const hasCustomColors = config?.backgroundColor || config?.textColor || comp.style?.backgroundColor || comp.style?.color;
config?.backgroundColor || config?.textColor || comp.style?.backgroundColor || comp.style?.color;
return ( return (
<button <button
onClick={handleClick} onClick={handleClick}
disabled={config?.disabled} disabled={config?.disabled}
className={`focus:ring-ring w-full rounded-md px-3 py-2 text-sm font-medium transition-colors focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none disabled:opacity-50 ${ className={`w-full rounded-md px-3 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ${
hasCustomColors ? "" : "bg-background border-foreground text-foreground hover:bg-muted/50 border shadow-xs" hasCustomColors
? ''
: 'bg-background border border-foreground text-foreground shadow-xs hover:bg-muted/50'
}`} }`}
style={{ style={{
// 컴포넌트 스타일 적용 // 컴포넌트 스타일 적용
@ -952,7 +991,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
height: "100%", height: "100%",
}} }}
> >
{label || "버튼"} <ButtonIconRenderer componentConfig={(comp as any).componentConfig} fallbackLabel={label || "버튼"} />
</button> </button>
); );
}; };
@ -1078,16 +1117,27 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// TableSearchWidget의 경우 높이를 자동으로 설정 // TableSearchWidget의 경우 높이를 자동으로 설정
const isTableSearchWidget = (component as any).componentId === "table-search-widget"; const isTableSearchWidget = (component as any).componentId === "table-search-widget";
// 🆕 라벨 표시 여부 확인 (V2 입력 컴포넌트) // 라벨 표시 여부 확인 (V2 입력 컴포넌트)
// labelDisplay가 false가 아니고, labelText 또는 label이 있으면 라벨 표시 const compType = (component as any).componentType || "";
const isV2InputComponent = type === "v2-input" || type === "v2-select" || type === "v2-date"; const isV2InputComponent =
const hasVisibleLabel = type === "v2-input" || type === "v2-select" || type === "v2-date" ||
isV2InputComponent && style?.labelDisplay !== false && (style?.labelText || (component as any).label); compType === "v2-input" || compType === "v2-select" || compType === "v2-date";
const hasVisibleLabel = isV2InputComponent &&
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 labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; 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 calculateCanvasSplitX = (): { x: number; w: number } => {
const compType = (component as any).componentType || ""; const compType = (component as any).componentType || "";
@ -1116,7 +1166,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
const origW = defaultW; const origW = defaultW;
if (canvasSplitSideRef.current === null) { if (canvasSplitSideRef.current === null) {
const componentCenterX = origX + origW / 2; const componentCenterX = origX + (origW / 2);
canvasSplitSideRef.current = componentCenterX < initialDividerX ? "left" : "right"; canvasSplitSideRef.current = componentCenterX < initialDividerX ? "left" : "right";
} }
@ -1164,22 +1214,28 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// styleWithoutSize에서 left/top 제거 (캔버스 분할 조정값 덮어쓰기 방지) // styleWithoutSize에서 left/top 제거 (캔버스 분할 조정값 덮어쓰기 방지)
const { left: _styleLeft, top: _styleTop, ...safeStyleWithoutSize } = styleWithoutSize as any; 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 = { const componentStyle = {
position: "absolute" as const, position: "absolute" as const,
...safeStyleWithoutSize, ...cleanedStyle,
// left/top은 반드시 마지막에 (styleWithoutSize가 덮어쓰지 못하게) // left/top은 반드시 마지막에 (styleWithoutSize가 덮어쓰지 못하게)
left: adjustedX, left: adjustedX,
top: position?.y || 0, top: position?.y || 0,
zIndex: position?.z || 1, zIndex: position?.z || 1,
width: isSplitActive ? adjustedW : size?.width || 200, width: isSplitActive ? adjustedW : (size?.width || 200),
height: isTableSearchWidget ? "auto" : size?.height || 10, height: isTableSearchWidget ? "auto" : size?.height || 10,
minHeight: isTableSearchWidget ? "48px" : undefined, minHeight: isTableSearchWidget ? "48px" : undefined,
overflow: isSplitActive && adjustedW < origW ? "hidden" : labelOffset > 0 ? "visible" : undefined, overflow: (isSplitActive && adjustedW < origW) ? "hidden" : (labelOffset > 0 ? "visible" : undefined),
willChange: canvasSplit.isDragging && isSplitActive ? ("left, width" as const) : undefined, willChange: canvasSplit.isDragging && isSplitActive ? "left, width" as const : undefined,
transition: isSplitActive transition: isSplitActive
? canvasSplit.isDragging ? (canvasSplit.isDragging ? "none" : "left 0.15s ease-out, width 0.15s ease-out")
? "none"
: "left 0.15s ease-out, width 0.15s ease-out"
: undefined, : undefined,
}; };
@ -1210,7 +1266,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
if (Math.abs(delta) < 1) return; if (Math.abs(delta) < 1) return;
if (canvasSplitSideRef.current === null) { if (canvasSplitSideRef.current === null) {
canvasSplitSideRef.current = origX + oW / 2 < initialDividerX ? "left" : "right"; canvasSplitSideRef.current = (origX + oW / 2) < initialDividerX ? "left" : "right";
} }
const GAP = 4; const GAP = 4;
@ -1239,10 +1295,101 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
return unsubscribe; return unsubscribe;
}, [component.id, position?.x, size?.width, type]); }, [component.id, position?.x, size?.width, type]);
// needsExternalLabel, isHorizLabel, labelText, labelGapValue는 위에서 선언됨
const externalLabelComponent = needsExternalLabel ? (
<label
htmlFor={component.id}
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 || isColumnRequiredByMeta((component as any).tableName, (component as any).columnName)) && (
<span className="text-orange-500">*</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 ( return (
<> <>
<div ref={elRef} id={`interactive-${component.id}`} className="absolute" style={componentStyle}> <div ref={elRef} id={`interactive-${component.id}`} className="absolute" style={componentStyle}>
{renderInteractiveWidget(splitAdjustedComponent)} {needsExternalLabel ? (
isHorizLabel ? (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<label
htmlFor={component.id}
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 || isColumnRequiredByMeta((component as any).tableName, (component as any).columnName)) && (
<span className="text-orange-500">*</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> </div>
{/* 팝업 화면 렌더링 */} {/* 팝업 화면 렌더링 */}

View File

@ -15,6 +15,7 @@ import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { executeButtonWithFlow, handleFlowExecutionResult } from "@/lib/utils/nodeFlowButtonExecutor"; import { executeButtonWithFlow, handleFlowExecutionResult } from "@/lib/utils/nodeFlowButtonExecutor";
import { useCurrentFlowStep } from "@/stores/flowStepStore"; import { useCurrentFlowStep } from "@/stores/flowStepStore";
import { ButtonIconRenderer, getButtonDisplayContent } from "@/lib/button-icon-map";
interface OptimizedButtonProps { interface OptimizedButtonProps {
component: ComponentData; component: ComponentData;
@ -165,12 +166,12 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
const isControlOnlyAction = config?.actionType === "control"; const isControlOnlyAction = config?.actionType === "control";
// console.log("🎯 OptimizedButtonComponent 실행:", { // console.log("🎯 OptimizedButtonComponent 실행:", {
// actionType: config?.actionType, // actionType: config?.actionType,
// isControlOnlyAction, // isControlOnlyAction,
// enableDataflowControl: config?.enableDataflowControl, // enableDataflowControl: config?.enableDataflowControl,
// hasDataflowConfig: !!config?.dataflowConfig, // hasDataflowConfig: !!config?.dataflowConfig,
// selectedRows, // selectedRows,
// selectedRowsData, // selectedRowsData,
// }); // });
if (config?.enableDataflowControl && config?.dataflowConfig) { if (config?.enableDataflowControl && config?.dataflowConfig) {
@ -386,7 +387,7 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
// console.log("✏️ Edit action completed:", result); // console.log("✏️ Edit action completed:", result);
break; break;
default: default:
// console.log(`✅ ${actionType} action completed:`, result); // console.log(`✅ ${actionType} action completed:`, result);
} }
}; };
@ -650,17 +651,14 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
<Button <Button
onClick={handleClick} onClick={handleClick}
disabled={isExecuting || disabled} disabled={isExecuting || disabled}
// 색상이 설정되어 있으면 variant를 적용하지 않아서 Tailwind 색상 클래스가 덮어씌우지 않도록 함 variant={hasCustomColors ? undefined : (config?.variant || "default")}
variant={hasCustomColors ? undefined : config?.variant || "default"}
className={cn( className={cn(
"transition-all duration-200", "transition-all duration-200",
isExecuting && "cursor-wait opacity-75", isExecuting && "cursor-wait opacity-75",
backgroundJobs.size > 0 && "border-primary/20 bg-accent", backgroundJobs.size > 0 && "border-primary/20 bg-accent",
// 커스텀 색상이 없을 때만 기본 스타일 적용
!hasCustomColors && "bg-primary text-primary-foreground hover:bg-primary/90", !hasCustomColors && "bg-primary text-primary-foreground hover:bg-primary/90",
)} )}
style={{ style={{
// 커스텀 색상이 있을 때만 인라인 스타일 적용
...(config?.backgroundColor && { backgroundColor: config.backgroundColor }), ...(config?.backgroundColor && { backgroundColor: config.backgroundColor }),
...(config?.textColor && { color: config.textColor }), ...(config?.textColor && { color: config.textColor }),
...(config?.borderColor && { borderColor: config.borderColor }), ...(config?.borderColor && { borderColor: config.borderColor }),
@ -669,7 +667,14 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
{/* 메인 버튼 내용 */} {/* 메인 버튼 내용 */}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{getStatusIcon()} {getStatusIcon()}
<span>{isExecuting ? "처리 중..." : buttonLabel}</span> <span>
{isExecuting ? "처리 중..." : (
<ButtonIconRenderer
componentConfig={component.componentConfig}
fallbackLabel={buttonLabel}
/>
)}
</span>
</div> </div>
{/* 개발 모드에서 성능 정보 표시 */} {/* 개발 모드에서 성능 정보 표시 */}
@ -683,7 +688,7 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
{/* 🆕 플로우 제어 활성화 표시 */} {/* 🆕 플로우 제어 활성화 표시 */}
{flowConfig?.enabled && ( {flowConfig?.enabled && (
<div className="absolute -top-1 -right-1"> <div className="absolute -right-1 -top-1">
<Badge variant="outline" className="h-4 bg-white px-1 text-xs" title="플로우 단계별 표시 제어 활성화"> <Badge variant="outline" className="h-4 bg-white px-1 text-xs" title="플로우 단계별 표시 제어 활성화">
<Workflow className="h-3 w-3" /> <Workflow className="h-3 w-3" />
</Badge> </Badge>

View File

@ -407,8 +407,8 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
const isButtonComponent = const isButtonComponent =
(type === "widget" && widgetType === "button") || (type === "widget" && widgetType === "button") ||
(type === "component" && (type === "component" &&
(["button-primary", "button-secondary"].includes(componentType) || (["button-primary", "button-secondary", "v2-button-primary"].includes(componentType) ||
["button-primary", "button-secondary"].includes(componentId))); ["button-primary", "button-secondary", "v2-button-primary"].includes(componentId)));
// 레거시 분할 패널용 refs // 레거시 분할 패널용 refs
const initialPanelRatioRef = React.useRef<number | null>(null); const initialPanelRatioRef = React.useRef<number | null>(null);
@ -572,13 +572,16 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
const origWidth = size?.width || 100; const origWidth = size?.width || 100;
const isSplitShrunk = splitAdjustedWidth !== null && splitAdjustedWidth < origWidth; const isSplitShrunk = splitAdjustedWidth !== null && splitAdjustedWidth < origWidth;
// v2 수평 라벨 컴포넌트: position wrapper에서 border 제거 (DynamicComponentRenderer가 내부에서 처리) // position wrapper에서 border 제거 (내부 컴포넌트가 자체적으로 border를 렌더링하는 경우)
// - v2 수평 라벨 컴포넌트: DynamicComponentRenderer가 내부에서 처리
// - 버튼 컴포넌트: buttonElementStyle에서 자체 border 적용
const isV2HorizLabel = !!( const isV2HorizLabel = !!(
componentStyle && componentStyle &&
(componentStyle.labelDisplay === true || componentStyle.labelDisplay === "true") && (componentStyle.labelDisplay === true || componentStyle.labelDisplay === "true") &&
(componentStyle.labelPosition === "left" || componentStyle.labelPosition === "right") (componentStyle.labelPosition === "left" || componentStyle.labelPosition === "right")
); );
const safeComponentStyle = isV2HorizLabel const needsStripBorder = isV2HorizLabel || isButtonComponent;
const safeComponentStyle = needsStripBorder
? (() => { ? (() => {
const { borderWidth, borderColor, borderStyle, border, borderRadius, ...rest } = componentStyle as any; const { borderWidth, borderColor, borderStyle, border, borderRadius, ...rest } = componentStyle as any;
return rest; return rest;

View File

@ -10,9 +10,9 @@ import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRendere
import { InteractiveScreenViewer } from "./InteractiveScreenViewer"; import { InteractiveScreenViewer } from "./InteractiveScreenViewer";
import { screenApi } from "@/lib/api/screen"; import { screenApi } from "@/lib/api/screen";
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm"; import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement";
import { ComponentData } from "@/lib/types/screen"; import { ComponentData } from "@/lib/types/screen";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
interface SaveModalProps { interface SaveModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@ -42,6 +42,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
const [originalData, setOriginalData] = useState<Record<string, any>>(initialData || {}); const [originalData, setOriginalData] = useState<Record<string, any>>(initialData || {});
const [screenData, setScreenData] = useState<any>(null); const [screenData, setScreenData] = useState<any>(null);
const [components, setComponents] = useState<ComponentData[]>([]); const [components, setComponents] = useState<ComponentData[]>([]);
const [tableColumnsInfo, setTableColumnsInfo] = useState<ColumnTypeInfo[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@ -70,6 +71,19 @@ export const SaveModal: React.FC<SaveModalProps> = ({
const layout = await screenApi.getLayout(screenId); const layout = await screenApi.getLayout(screenId);
setComponents(layout.components || []); setComponents(layout.components || []);
// 테이블 컬럼 정보 로드 (NOT NULL 필수값 자동 인식용)
const tblName = screen?.tableName || layout.components?.find((c: any) => c.columnName)?.tableName;
if (tblName) {
try {
const colResult = await getTableColumns(tblName);
if (colResult.success && colResult.data?.columns) {
setTableColumnsInfo(colResult.data.columns);
}
} catch (colErr) {
console.warn("테이블 컬럼 정보 로드 실패 (필수값 검증 시 기존 방식 사용):", colErr);
}
}
// initialData가 있으면 폼에 채우기 // initialData가 있으면 폼에 채우기
if (initialData) { if (initialData) {
setFormData(initialData); setFormData(initialData);
@ -106,34 +120,37 @@ export const SaveModal: React.FC<SaveModalProps> = ({
}; };
}, [onClose]); }, [onClose]);
// 필수 항목 검증 // 테이블 타입관리의 NOT NULL 설정 기반으로 필수 여부 판단
const isColumnRequired = (columnName: string): boolean => {
if (!columnName || tableColumnsInfo.length === 0) return false;
const colInfo = tableColumnsInfo.find((c) => c.columnName.toLowerCase() === columnName.toLowerCase());
if (!colInfo) return false;
// is_nullable가 "NO"이면 필수
return colInfo.isNullable === "NO" || colInfo.isNullable === "N";
};
// 필수 항목 검증 (테이블 타입관리 NOT NULL + 기존 required 속성 병합)
const validateRequiredFields = (): { isValid: boolean; missingFields: string[] } => { const validateRequiredFields = (): { isValid: boolean; missingFields: string[] } => {
const missingFields: string[] = []; const missingFields: string[] = [];
components.forEach((component) => { components.forEach((component) => {
// 컴포넌트의 required 속성 확인 (여러 위치에서 체크) const columnName = component.columnName || component.style?.columnName;
const isRequired = const label = component.label || component.style?.label || columnName;
// 기존 required 속성 (화면 디자이너에서 수동 설정한 것)
const manualRequired =
component.required === true || component.required === true ||
component.style?.required === true || component.style?.required === true ||
component.componentConfig?.required === true; component.componentConfig?.required === true;
const columnName = component.columnName || component.style?.columnName; // 테이블 타입관리 NOT NULL 기반 필수 (컬럼 정보가 있을 때만)
const label = component.label || component.style?.label || columnName; const notNullRequired = columnName ? isColumnRequired(columnName) : false;
console.log("🔍 필수 항목 검증:", { // 둘 중 하나라도 필수이면 검증
componentId: component.id, const isRequired = manualRequired || notNullRequired;
columnName,
label,
isRequired,
"component.required": component.required,
"style.required": component.style?.required,
"componentConfig.required": component.componentConfig?.required,
value: formData[columnName || ""],
});
if (isRequired && columnName) { if (isRequired && columnName) {
const value = formData[columnName]; const value = formData[columnName];
// 값이 비어있는지 확인 (null, undefined, 빈 문자열, 공백만 있는 문자열)
if (value === null || value === undefined || (typeof value === "string" && value.trim() === "")) { if (value === null || value === undefined || (typeof value === "string" && value.trim() === "")) {
missingFields.push(label || columnName); missingFields.push(label || columnName);
} }
@ -262,7 +279,8 @@ export const SaveModal: React.FC<SaveModalProps> = ({
}, 300); // 모달 닫힘 애니메이션 후 실행 }, 300); // 모달 닫힘 애니메이션 후 실행
} }
} else { } else {
throw new Error(result.message || "저장에 실패했습니다."); const errorMsg = result.message || result.error?.message || "저장에 실패했습니다.";
toast.error(errorMsg);
} }
} catch (error: any) { } catch (error: any) {
// ❌ 저장 실패 - 모달은 닫히지 않음 // ❌ 저장 실패 - 모달은 닫히지 않음
@ -407,7 +425,8 @@ export const SaveModal: React.FC<SaveModalProps> = ({
})); }));
}} }}
hideLabel={false} hideLabel={false}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (카테고리 스코프용) menuObjid={menuObjid}
tableColumns={tableColumnsInfo as any}
/> />
) : ( ) : (
<DynamicComponentRenderer <DynamicComponentRenderer

View File

@ -5446,7 +5446,6 @@ export default function ScreenDesigner({
{ ctrl: true, key: "s" }, // 저장 (필요시 차단 해제) { ctrl: true, key: "s" }, // 저장 (필요시 차단 해제)
{ ctrl: true, key: "p" }, // 인쇄 { ctrl: true, key: "p" }, // 인쇄
{ ctrl: true, key: "o" }, // 파일 열기 { ctrl: true, key: "o" }, // 파일 열기
{ ctrl: true, key: "v" }, // 붙여넣기 (브라우저 기본 동작 차단)
// 개발자 도구 // 개발자 도구
{ key: "F12" }, // 개발자 도구 { key: "F12" }, // 개발자 도구
@ -5471,7 +5470,20 @@ export default function ScreenDesigner({
return ctrlMatch && shiftMatch && keyMatch; return ctrlMatch && shiftMatch && keyMatch;
}); });
// 입력 필드(input, textarea 등)에 포커스 시 편집 단축키는 기본 동작 허용
const _target = e.target as HTMLElement;
const _activeEl = document.activeElement as HTMLElement;
const _isEditable = (el: HTMLElement | null) =>
el instanceof HTMLInputElement ||
el instanceof HTMLTextAreaElement ||
el instanceof HTMLSelectElement ||
el?.isContentEditable;
const isEditableFieldFocused = _isEditable(_target) || _isEditable(_activeEl);
if (isBrowserShortcut) { if (isBrowserShortcut) {
if (isEditableFieldFocused) {
return;
}
// console.log("🚫 브라우저 기본 단축키 차단:", e.key); // console.log("🚫 브라우저 기본 단축키 차단:", e.key);
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -5480,6 +5492,11 @@ export default function ScreenDesigner({
// ✅ 애플리케이션 전용 단축키 처리 // ✅ 애플리케이션 전용 단축키 처리
// 입력 필드 포커스 시 앱 단축키 무시 (텍스트 편집 우선)
if (isEditableFieldFocused && (e.ctrlKey || e.metaKey)) {
return;
}
// 1. 그룹 관련 단축키 // 1. 그룹 관련 단축키
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "g" && !e.shiftKey) { if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "g" && !e.shiftKey) {
// console.log("🔄 그룹 생성 단축키"); // console.log("🔄 그룹 생성 단축키");
@ -5556,7 +5573,6 @@ export default function ScreenDesigner({
// 5. 붙여넣기 (컴포넌트 붙여넣기) // 5. 붙여넣기 (컴포넌트 붙여넣기)
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "v") { if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "v") {
// console.log("🔄 컴포넌트 붙여넣기");
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
e.stopImmediatePropagation(); e.stopImmediatePropagation();

View File

@ -894,19 +894,31 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
{/* 옵션 - 입력 필드에서는 항상 표시, 기타 컴포넌트는 속성이 정의된 경우만 표시 */} {/* 옵션 - 입력 필드에서는 항상 표시, 기타 컴포넌트는 속성이 정의된 경우만 표시 */}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{(isInputField || widget.required !== undefined) && ( {(isInputField || widget.required !== undefined) && (() => {
<div className="flex items-center space-x-2"> const colName = widget.columnName || selectedComponent?.columnName;
<Checkbox const colMeta = colName ? currentTable?.columns?.find(
checked={widget.required === true || selectedComponent.componentConfig?.required === true} (c: any) => (c.columnName || c.column_name || "").toLowerCase() === colName.toLowerCase()
onCheckedChange={(checked) => { ) : null;
handleUpdate("required", checked); const isNotNull = colMeta && ((colMeta as any).isNullable === "NO" || (colMeta as any).isNullable === "N" || (colMeta as any).is_nullable === "NO" || (colMeta as any).is_nullable === "N");
handleUpdate("componentConfig.required", checked); return (
}} <div className="flex items-center space-x-2">
className="h-4 w-4" <Checkbox
/> checked={isNotNull || widget.required === true || selectedComponent.componentConfig?.required === true}
<Label className="text-xs"></Label> onCheckedChange={(checked) => {
</div> if (isNotNull) return;
)} handleUpdate("required", checked);
handleUpdate("componentConfig.required", checked);
}}
disabled={!!isNotNull}
className="h-4 w-4"
/>
<Label className="text-xs">
{isNotNull && <span className="text-muted-foreground ml-1">(NOT NULL)</span>}
</Label>
</div>
);
})()}
{(isInputField || widget.readonly !== undefined) && ( {(isInputField || widget.readonly !== undefined) && (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox

View File

@ -2,6 +2,7 @@
import React from "react"; import React from "react";
import { WebTypeComponentProps } from "@/lib/registry/types"; import { WebTypeComponentProps } from "@/lib/registry/types";
import { ButtonIconRenderer } from "@/lib/button-icon-map";
export const ButtonWidget: React.FC<WebTypeComponentProps> = ({ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
config, config,
@ -14,40 +15,36 @@ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
required, required,
className, className,
style, style,
isDesignMode = false, // 디자인 모드 플래그 isDesignMode = false,
...restProps ...restProps
}) => { }) => {
const handleClick = (e: React.MouseEvent) => { const handleClick = (e: React.MouseEvent) => {
// 디자인 모드에서는 아무것도 하지 않고 그냥 이벤트 전파
if (isDesignMode) { if (isDesignMode) {
return; return;
} }
// 버튼 클릭 시 동작 (추후 버튼 액션 시스템과 연동)
console.log("Button clicked:", config); console.log("Button clicked:", config);
// onChange를 통해 클릭 이벤트 전달
if (onChange) { if (onChange) {
onChange("clicked"); onChange("clicked");
} }
}; };
// 커스텀 색상 확인 (config 또는 style에서)
const hasCustomBg = config?.backgroundColor || style?.backgroundColor; const hasCustomBg = config?.backgroundColor || style?.backgroundColor;
const hasCustomColor = config?.textColor || style?.color; const hasCustomColor = config?.textColor || style?.color;
const hasCustomColors = hasCustomBg || hasCustomColor; const hasCustomColors = hasCustomBg || hasCustomColor;
// 실제 적용할 배경색과 글자색
const bgColor = config?.backgroundColor || style?.backgroundColor; const bgColor = config?.backgroundColor || style?.backgroundColor;
const textColor = config?.textColor || style?.color; const textColor = config?.textColor || style?.color;
// 디자인 모드에서는 div로 렌더링하여 버튼 동작 완전 차단 const fallbackLabel = config?.label || config?.text || (value as string) || placeholder || "버튼";
if (isDesignMode) { if (isDesignMode) {
return ( return (
<div <div
onClick={handleClick} // 클릭 핸들러 추가하여 이벤트 전파 onClick={handleClick}
className={`flex items-center justify-center rounded-md px-4 text-sm font-medium ${ className={`flex items-center justify-center rounded-md px-4 text-sm font-medium ${
hasCustomColors ? "" : "bg-blue-600 text-white" hasCustomColors ? '' : 'bg-blue-600 text-white'
} ${className || ""}`} } ${className || ""}`}
style={{ style={{
...style, ...style,
@ -55,11 +52,10 @@ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
color: textColor, color: textColor,
width: "100%", width: "100%",
height: "100%", height: "100%",
cursor: "pointer", // 선택 가능하도록 포인터 표시 cursor: "pointer",
}} }}
title={config?.tooltip || placeholder}
> >
{config?.label || config?.text || value || placeholder || "버튼"} <ButtonIconRenderer componentConfig={config} fallbackLabel={fallbackLabel} />
</div> </div>
); );
} }
@ -70,7 +66,7 @@ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
onClick={handleClick} onClick={handleClick}
disabled={disabled || readonly} disabled={disabled || readonly}
className={`flex items-center justify-center rounded-md px-4 text-sm font-medium transition-colors duration-200 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500 ${ className={`flex items-center justify-center rounded-md px-4 text-sm font-medium transition-colors duration-200 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500 ${
hasCustomColors ? "" : "bg-blue-600 text-white hover:bg-blue-700" hasCustomColors ? '' : 'bg-blue-600 text-white hover:bg-blue-700'
} ${className || ""}`} } ${className || ""}`}
style={{ style={{
...style, ...style,
@ -79,9 +75,8 @@ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
width: "100%", width: "100%",
height: "100%", height: "100%",
}} }}
title={config?.tooltip || placeholder}
> >
{config?.label || config?.text || value || placeholder || "버튼"} <ButtonIconRenderer componentConfig={config} fallbackLabel={fallbackLabel} />
</button> </button>
); );
}; };

View File

@ -4,6 +4,7 @@ import React from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { WebTypeComponentProps } from "@/lib/registry/types"; import { WebTypeComponentProps } from "@/lib/registry/types";
import { WidgetComponent, NumberTypeConfig } from "@/types/screen"; import { WidgetComponent, NumberTypeConfig } from "@/types/screen";
import { formatNumber as formatNum, formatCurrency } from "@/lib/formatting";
export const NumberWidget: React.FC<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => { export const NumberWidget: React.FC<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => {
const widget = component as WidgetComponent; const widget = component as WidgetComponent;
@ -21,10 +22,7 @@ export const NumberWidget: React.FC<WebTypeComponentProps> = ({ component, value
if (isNaN(numValue)) return ""; if (isNaN(numValue)) return "";
if (config?.format === "currency") { if (config?.format === "currency") {
return new Intl.NumberFormat("ko-KR", { return formatCurrency(numValue);
style: "currency",
currency: "KRW",
}).format(numValue);
} }
if (config?.format === "percentage") { if (config?.format === "percentage") {
@ -32,7 +30,7 @@ export const NumberWidget: React.FC<WebTypeComponentProps> = ({ component, value
} }
if (config?.thousandSeparator) { if (config?.thousandSeparator) {
return new Intl.NumberFormat("ko-KR").format(numValue); return formatNum(numValue);
} }
return numValue.toString(); return numValue.toString();

View File

@ -6,7 +6,7 @@
* - * -
*/ */
import React, { useState, useEffect, useCallback, useMemo } from "react"; import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { import {
ChevronRight, ChevronRight,
ChevronDown, ChevronDown,
@ -291,6 +291,10 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
const [editingValue, setEditingValue] = useState<CategoryValue | null>(null); const [editingValue, setEditingValue] = useState<CategoryValue | null>(null);
const [deletingValue, setDeletingValue] = useState<CategoryValue | null>(null); const [deletingValue, setDeletingValue] = useState<CategoryValue | null>(null);
// 추가 모달 input ref
const addNameRef = useRef<HTMLInputElement>(null);
const addDescRef = useRef<HTMLInputElement>(null);
// 폼 상태 // 폼 상태
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
valueCode: "", valueCode: "",
@ -508,7 +512,15 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
const response = await createCategoryValue(input); const response = await createCategoryValue(input);
if (response.success) { if (response.success) {
toast.success("카테고리가 추가되었습니다"); toast.success("카테고리가 추가되었습니다");
setIsAddModalOpen(false); // 폼 초기화 (모달은 닫지 않고 연속 입력)
setFormData((prev) => ({
...prev,
valueCode: "",
valueLabel: "",
description: "",
color: "",
}));
setTimeout(() => addNameRef.current?.focus(), 50);
// 기존 펼침 상태 유지하면서 데이터 새로고침 // 기존 펼침 상태 유지하면서 데이터 새로고침
await loadTree(true); await loadTree(true);
// 부모 노드만 펼치기 (하위 추가 시) // 부모 노드만 펼치기 (하위 추가 시)
@ -746,9 +758,17 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
<span className="text-destructive">*</span> <span className="text-destructive">*</span>
</Label> </Label>
<Input <Input
ref={addNameRef}
id="valueLabel" id="valueLabel"
value={formData.valueLabel} value={formData.valueLabel}
onChange={(e) => setFormData({ ...formData, valueLabel: e.target.value })} onChange={(e) => setFormData({ ...formData, valueLabel: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
addDescRef.current?.focus();
}
}}
placeholder="카테고리 이름을 입력하세요" placeholder="카테고리 이름을 입력하세요"
className="h-9 text-sm" className="h-9 text-sm"
/> />
@ -759,9 +779,17 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
</Label> </Label>
<Input <Input
ref={addDescRef}
id="description" id="description"
value={formData.description} value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
handleAdd();
}
}}
placeholder="선택 사항" placeholder="선택 사항"
className="h-9 text-sm" className="h-9 text-sm"
/> />
@ -784,7 +812,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
onClick={() => setIsAddModalOpen(false)} onClick={() => setIsAddModalOpen(false)}
className="h-9 flex-1 text-sm sm:flex-none" className="h-9 flex-1 text-sm sm:flex-none"
> >
</Button> </Button>
<Button onClick={handleAdd} className="h-9 flex-1 text-sm sm:flex-none"> <Button onClick={handleAdd} className="h-9 flex-1 text-sm sm:flex-none">

View File

@ -2,11 +2,64 @@
import * as React from "react"; import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { useModalPortal } from "@/lib/modalPortalRef";
import { useTabId } from "@/contexts/TabIdContext";
import { useTabStore } from "@/stores/tabStore";
const AlertDialog = AlertDialogPrimitive.Root; /**
* Context.
* scoped=true AlertDialogPrimitive DialogPrimitive .
*/
const ScopedAlertCtx = React.createContext(false);
const AlertDialog: React.FC<React.ComponentProps<typeof AlertDialogPrimitive.Root>> = ({
open,
children,
onOpenChange,
...props
}) => {
const autoContainer = useModalPortal();
const scoped = !!autoContainer;
const tabId = useTabId();
const activeTabId = useTabStore((s) => s[s.mode].activeTabId);
const isTabActive = !tabId || tabId === activeTabId;
const isTabActiveRef = React.useRef(isTabActive);
isTabActiveRef.current = isTabActive;
const effectiveOpen = open != null ? open && isTabActive : undefined;
const guardedOnOpenChange = React.useCallback(
(newOpen: boolean) => {
if (scoped && !newOpen && !isTabActiveRef.current) return;
onOpenChange?.(newOpen);
},
[scoped, onOpenChange],
);
if (scoped) {
return (
<ScopedAlertCtx.Provider value={true}>
<DialogPrimitive.Root open={effectiveOpen} onOpenChange={guardedOnOpenChange} modal={false}>
{children}
</DialogPrimitive.Root>
</ScopedAlertCtx.Provider>
);
}
return (
<ScopedAlertCtx.Provider value={false}>
<AlertDialogPrimitive.Root {...props} open={effectiveOpen} onOpenChange={guardedOnOpenChange}>
{children}
</AlertDialogPrimitive.Root>
</ScopedAlertCtx.Provider>
);
};
AlertDialog.displayName = "AlertDialog";
const AlertDialogTrigger = AlertDialogPrimitive.Trigger; const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
@ -16,26 +69,93 @@ const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>, React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay> React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay className={cn("fixed inset-0 z-[1001] bg-black/40", className)} {...props} ref={ref} /> <AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-1050 bg-black/80",
className,
)}
{...props}
ref={ref}
/>
)); ));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
interface ScopedAlertDialogContentProps
extends React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> {
container?: HTMLElement | null;
hidden?: boolean;
}
const AlertDialogContent = React.forwardRef< const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>, React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> ScopedAlertDialogContentProps
>(({ className, ...props }, ref) => ( >(({ className, container: explicitContainer, hidden: hiddenProp, style, ...props }, ref) => {
<AlertDialogPortal> const autoContainer = useModalPortal();
<AlertDialogOverlay /> const container = explicitContainer !== undefined ? explicitContainer : autoContainer;
<AlertDialogPrimitive.Content const scoped = React.useContext(ScopedAlertCtx);
ref={ref}
className={cn( const adjustedStyle = scoped && style
"bg-background fixed top-[50%] left-[50%] z-[1002] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg", ? { ...style, maxHeight: undefined, maxWidth: undefined }
className, : style;
)}
{...props} const handleInteractOutside = React.useCallback(
/> (e: any) => {
</AlertDialogPortal> if (scoped && container) {
)); const target = (e.detail?.originalEvent?.target ?? e.target) as HTMLElement | null;
if (target && !container.contains(target)) {
e.preventDefault();
return;
}
}
e.preventDefault();
},
[scoped, container],
);
if (scoped) {
return (
<DialogPrimitive.Portal container={container ?? undefined}>
<div
className="absolute inset-0 z-1050 flex items-center justify-center overflow-hidden p-4"
style={hiddenProp ? { display: "none" } : undefined}
>
<div className="absolute inset-0 bg-black/80" />
<DialogPrimitive.Content
ref={ref}
onInteractOutside={handleInteractOutside}
onFocusOutside={(e: any) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
className={cn(
"bg-background relative z-1 grid w-full max-w-lg max-h-full gap-4 border p-6 shadow-lg sm:rounded-lg",
className,
)}
style={adjustedStyle}
{...props}
/>
</div>
</DialogPrimitive.Portal>
);
}
return (
<AlertDialogPortal container={container ?? undefined}>
<div
style={hiddenProp ? { display: "none" } : undefined}
>
<AlertDialogPrimitive.Overlay className="fixed inset-0 z-1050 bg-black/80" />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"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,
)}
style={adjustedStyle}
{...props}
/>
</div>
</AlertDialogPortal>
);
});
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
@ -51,37 +171,47 @@ AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef< const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>, React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title> React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => {
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} /> const scoped = React.useContext(ScopedAlertCtx);
)); const Comp = scoped ? DialogPrimitive.Title : AlertDialogPrimitive.Title;
return <Comp ref={ref} className={cn("text-lg font-semibold", className)} {...props} />;
});
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef< const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>, React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description> React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => {
<AlertDialogPrimitive.Description ref={ref} className={cn("text-muted-foreground text-sm", className)} {...props} /> const scoped = React.useContext(ScopedAlertCtx);
)); const Comp = scoped ? DialogPrimitive.Description : AlertDialogPrimitive.Description;
return <Comp ref={ref} className={cn("text-muted-foreground text-sm", className)} {...props} />;
});
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName; AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef< const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>, React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action> React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => {
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} /> const scoped = React.useContext(ScopedAlertCtx);
)); const Comp = scoped ? DialogPrimitive.Close : AlertDialogPrimitive.Action;
return <Comp ref={ref} className={cn(buttonVariants(), className)} {...props} />;
});
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef< const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>, React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel> React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => {
<AlertDialogPrimitive.Cancel const scoped = React.useContext(ScopedAlertCtx);
ref={ref} const Comp = scoped ? DialogPrimitive.Close : AlertDialogPrimitive.Cancel;
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)} return (
{...props} <Comp
/> ref={ref}
)); className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...props}
/>
);
});
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export { export {

View File

@ -44,7 +44,14 @@ function Button({
}) { }) {
const Comp = asChild ? Slot : "button"; const Comp = asChild ? Slot : "button";
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />; return (
<Comp
data-slot="button"
data-variant={variant || "default"}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
} }
export { Button, buttonVariants }; export { Button, buttonVariants };

View File

@ -5,8 +5,46 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useModalPortal } from "@/lib/modalPortalRef";
import { useTabId } from "@/contexts/TabIdContext";
import { useTabStore } from "@/stores/tabStore";
import { useDialogAutoValidation } from "@/lib/hooks/useDialogAutoValidation";
const Dialog = DialogPrimitive.Root; // Dialog: 탭 시스템 내에서 자동으로 modal={false} + 비활성 탭이면 open={false} 처리
const Dialog: React.FC<React.ComponentProps<typeof DialogPrimitive.Root>> = ({
modal,
open,
onOpenChange,
...props
}) => {
const autoContainer = useModalPortal();
const scoped = !!autoContainer;
const tabId = useTabId();
const activeTabId = useTabStore((s) => s[s.mode].activeTabId);
const isTabActive = !tabId || tabId === activeTabId;
// ref로 최신 isTabActive를 동기적으로 추적 (useEffect보다 빠르게 업데이트)
const isTabActiveRef = React.useRef(isTabActive);
isTabActiveRef.current = isTabActive;
const effectiveModal = modal !== undefined ? modal : !scoped ? undefined : false;
const effectiveOpen = open != null ? open && isTabActive : undefined;
// 비활성 탭에서 발생하는 onOpenChange(false) 차단
// (탭 전환 시 content unmount → focus 이동 → Radix가 onOpenChange(false)를 호출하는 것을 방지)
const guardedOnOpenChange = React.useCallback(
(newOpen: boolean) => {
if (scoped && !newOpen && !isTabActiveRef.current) {
return;
}
onOpenChange?.(newOpen);
},
[scoped, onOpenChange, tabId],
);
return <DialogPrimitive.Root {...props} open={effectiveOpen} onOpenChange={guardedOnOpenChange} modal={effectiveModal} />;
};
Dialog.displayName = "Dialog";
const DialogTrigger = DialogPrimitive.Trigger; const DialogTrigger = DialogPrimitive.Trigger;
@ -18,44 +56,120 @@ const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>, React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay ref={ref} className={cn("fixed inset-0 z-[999] bg-black/50", className)} {...props} /> <DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-999 bg-black/60",
className,
)}
{...props}
/>
)); ));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
interface ScopedDialogContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
/** 포탈 대상 컨테이너. 명시적으로 전달하면 해당 값 사용, 미전달 시 탭 시스템 자동 감지 */
container?: HTMLElement | null;
/** 탭 비활성 시 포탈 내용 숨김 */
hidden?: boolean;
}
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { ScopedDialogContentProps
overlayClassName?: string; >(({ className, children, container: explicitContainer, hidden: hiddenProp, onInteractOutside, onFocusOutside, style, ...props }, ref) => {
container?: HTMLElement | null; const autoContainer = useModalPortal();
} const container = explicitContainer !== undefined ? explicitContainer : autoContainer;
>(({ className, children, overlayClassName, container, ...props }, ref) => ( const scoped = !!container;
<DialogPortal container={container}>
<DialogOverlay className={overlayClassName} /> // state 기반 ref: DialogPrimitive.Content 마운트/언마운트 시 useEffect 재실행 보장
<DialogPrimitive.Content const [contentNode, setContentNode] = React.useState<HTMLDivElement | null>(null);
ref={ref} const mergedRef = React.useCallback(
className={cn( (node: HTMLDivElement | null) => {
"bg-background fixed top-[50%] left-[50%] z-[1000] flex w-full max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border p-6 shadow-lg sm:rounded-lg", setContentNode(node);
className, if (typeof ref === "function") ref(node);
)} else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
{...props} },
> [ref],
{children} );
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 cursor-pointer rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none"> useDialogAutoValidation(contentNode);
<X className="h-4 w-4" />
<span className="sr-only">Close</span> const handleInteractOutside = React.useCallback(
</DialogPrimitive.Close> (e: any) => {
</DialogPrimitive.Content> if (scoped && container) {
</DialogPortal> const target = (e.detail?.originalEvent?.target ?? e.target) as HTMLElement | null;
)); if (target && !container.contains(target)) {
e.preventDefault();
return;
}
}
onInteractOutside?.(e);
},
[scoped, container, onInteractOutside],
);
// scoped 모드: content unmount 시 포커스 이동으로 인한 onOpenChange(false) 방지
const handleFocusOutside = React.useCallback(
(e: any) => {
if (scoped) {
e.preventDefault();
return;
}
onFocusOutside?.(e);
},
[scoped, onFocusOutside],
);
// scoped 모드: 뷰포트 기반 maxHeight/maxWidth 제거 → className의 max-h-full이 컨테이너 기준으로 적용됨
const adjustedStyle = scoped && style
? { ...style, maxHeight: undefined, maxWidth: undefined }
: style;
return (
<DialogPortal container={container ?? undefined}>
<div
className={scoped ? "absolute inset-0 z-999 flex items-center justify-center overflow-hidden p-4" : undefined}
style={hiddenProp ? { display: "none" } : undefined}
>
{scoped ? (
<div className="absolute inset-0 bg-black/60" />
) : (
<DialogPrimitive.Overlay className="fixed inset-0 z-999 bg-black/60" />
)}
<DialogPrimitive.Content
ref={mergedRef}
onInteractOutside={handleInteractOutside}
onFocusOutside={handleFocusOutside}
className={cn(
scoped
? "bg-background relative z-1 flex w-full max-w-lg max-h-full flex-col gap-4 border p-6 shadow-lg sm:rounded-lg"
: "bg-background fixed top-[50%] left-[50%] z-1000 flex w-full max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border p-6 shadow-lg sm:rounded-lg",
className,
scoped && "max-h-full",
)}
style={adjustedStyle}
{...props}
>
{children}
<DialogPrimitive.Close data-dialog-close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 cursor-pointer rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</div>
</DialogPortal>
);
});
DialogContent.displayName = DialogPrimitive.Content.displayName; DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex shrink-0 flex-col space-y-1.5 text-center sm:text-left", className)} {...props} /> <div className={cn("flex flex-col space-y-1.5 text-center sm:text-left shrink-0", className)} {...props} />
); );
DialogHeader.displayName = "DialogHeader"; DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex shrink-0 flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} /> <div data-slot="dialog-footer" className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 shrink-0", className)} {...props} />
); );
DialogFooter.displayName = "DialogFooter"; DialogFooter.displayName = "DialogFooter";

View File

@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -10,14 +10,13 @@
* - range 옵션: 범위 (~) * - range 옵션: 범위 (~)
*/ */
import React, { forwardRef, useCallback, useMemo, useState } from "react"; import React, { forwardRef, useCallback, useMemo, useState, useEffect } from "react";
import { format, parse, isValid } from "date-fns"; import { format, parse, isValid, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isSameDay, isToday as isTodayFn } from "date-fns";
import { ko } from "date-fns/locale"; 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 { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { V2DateProps, V2DateType } from "@/types/v2-components"; import { V2DateProps, V2DateType } from "@/types/v2-components";
@ -60,11 +59,24 @@ function formatDate(date: Date | undefined, formatStr: string): string {
return format(date, dateFnsFormat); 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< const SingleDatePicker = forwardRef<
HTMLButtonElement, HTMLDivElement,
{ {
value?: string; value?: string;
onChange?: (value: string) => void; onChange?: (value: string) => void;
@ -79,95 +91,231 @@ const SingleDatePicker = forwardRef<
} }
>( >(
( (
{ { value, onChange, dateFormat = "YYYY-MM-DD", showToday = true, minDate, maxDate, disabled, readonly, className, placeholder = "날짜 선택" },
value,
onChange,
dateFormat = "YYYY-MM-DD",
showToday = true,
minDate,
maxDate,
disabled,
readonly,
className,
placeholder = "날짜 선택",
},
ref, ref,
) => { ) => {
const [open, setOpen] = useState(false); 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 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(() => { const displayText = useMemo(() => {
if (!value) return ""; if (!value) return "";
// Date 객체로 변환 후 포맷팅 if (date && isValid(date)) return formatDate(date, dateFormat);
if (date && isValid(date)) {
return formatDate(date, dateFormat);
}
return value; return value;
}, [value, date, dateFormat]); }, [value, date, dateFormat]);
const handleSelect = useCallback( useEffect(() => {
(selectedDate: Date | undefined) => { if (open) {
if (selectedDate) { setViewMode("calendar");
onChange?.(formatDate(selectedDate, dateFormat)); if (date && isValid(date)) {
setOpen(false); 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);
} }
}, } else {
[dateFormat, onChange], setIsTyping(false);
); setTypingValue("");
}
}, [open]);
const handleDateClick = useCallback((clickedDate: Date) => {
onChange?.(formatDate(clickedDate, dateFormat));
setIsTyping(false);
setOpen(false);
}, [dateFormat, onChange]);
const handleToday = useCallback(() => { const handleToday = useCallback(() => {
onChange?.(formatDate(new Date(), dateFormat)); onChange?.(formatDate(new Date(), dateFormat));
setIsTyping(false);
setOpen(false); setOpen(false);
}, [dateFormat, onChange]); }, [dateFormat, onChange]);
const handleClear = useCallback(() => { const handleClear = useCallback(() => {
onChange?.(""); onChange?.("");
setIsTyping(false);
setOpen(false); setOpen(false);
}, [onChange]); }, [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 ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={(v) => { if (!v) { setOpen(false); setIsTyping(false); } }}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <div
ref={ref} ref={ref}
variant="outline"
disabled={disabled || readonly}
className={cn( className={cn(
"h-full w-full justify-start text-left font-normal", "border-input bg-background flex h-full w-full cursor-pointer items-center rounded-md border px-3",
!displayText && "text-muted-foreground", (disabled || readonly) && "cursor-not-allowed opacity-50",
className, className,
)} )}
onClick={() => { if (!disabled && !readonly) setOpen(true); }}
> >
<CalendarIcon className="mr-2 h-4 w-4 flex-shrink-0" /> <CalendarIcon className="text-muted-foreground mr-2 h-4 w-4 shrink-0" />
{displayText || placeholder} <input
</Button> 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> </PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start"> <PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
<Calendar <div className="p-4">
mode="single" <div className="mb-3 flex items-center gap-2">
selected={date} {showToday && (
onSelect={handleSelect} <Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleToday}>
initialFocus
locale={ko} </Button>
disabled={(date) => { )}
if (minDateObj && date < minDateObj) return true; <Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleClear}>
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}>
</Button> </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> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@ -179,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< const RangeDatePicker = forwardRef<
HTMLDivElement, HTMLDivElement,
{ {
@ -197,102 +488,38 @@ const RangeDatePicker = forwardRef<
const startDate = useMemo(() => parseDate(value[0], dateFormat), [value, dateFormat]); const startDate = useMemo(() => parseDate(value[0], dateFormat), [value, dateFormat]);
const endDate = useMemo(() => parseDate(value[1], 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( const handleStartSelect = useCallback(
(date: Date | undefined) => { (date: Date) => {
if (date) { const newStart = formatDate(date, dateFormat);
const newStart = formatDate(date, dateFormat); if (endDate && date > endDate) {
// 시작일이 종료일보다 크면 종료일도 같이 변경 onChange?.([newStart, newStart]);
if (endDate && date > endDate) { } else {
onChange?.([newStart, newStart]); onChange?.([newStart, value[1]]);
} else {
onChange?.([newStart, value[1]]);
}
setOpenStart(false);
} }
setOpenStart(false);
}, },
[value, dateFormat, endDate, onChange], [value, dateFormat, endDate, onChange],
); );
const handleEndSelect = useCallback( const handleEndSelect = useCallback(
(date: Date | undefined) => { (date: Date) => {
if (date) { const newEnd = formatDate(date, dateFormat);
const newEnd = formatDate(date, dateFormat); if (startDate && date < startDate) {
// 종료일이 시작일보다 작으면 시작일도 같이 변경 onChange?.([newEnd, newEnd]);
if (startDate && date < startDate) { } else {
onChange?.([newEnd, newEnd]); onChange?.([value[0], newEnd]);
} else {
onChange?.([value[0], newEnd]);
}
setOpenEnd(false);
} }
setOpenEnd(false);
}, },
[value, dateFormat, startDate, onChange], [value, dateFormat, startDate, onChange],
); );
return ( return (
<div ref={ref} className={cn("flex h-full items-center gap-2", className)}> <div ref={ref} className={cn("flex items-center gap-2 h-full", className)}>
{/* 시작 날짜 */} <RangeCalendarPopover open={openStart} onOpenChange={setOpenStart} selectedDate={startDate} onSelect={handleStartSelect} label="시작일" disabled={disabled} readonly={readonly} displayValue={value[0]} />
<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>
<span className="text-muted-foreground">~</span> <span className="text-muted-foreground">~</span>
<RangeCalendarPopover open={openEnd} onOpenChange={setOpenEnd} selectedDate={endDate} onSelect={handleEndSelect} label="종료일" disabled={disabled} readonly={readonly} displayValue={value[1]} />
{/* 종료 날짜 */}
<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>
</div> </div>
); );
}); });
@ -368,8 +595,8 @@ const DateTimePicker = forwardRef<
); );
return ( return (
<div ref={ref} className={cn("flex h-full gap-2", className)}> <div ref={ref} className={cn("flex gap-2 h-full", className)}>
<div className="h-full flex-1"> <div className="flex-1 h-full">
<SingleDatePicker <SingleDatePicker
value={datePart} value={datePart}
onChange={handleDateChange} onChange={handleDateChange}
@ -380,7 +607,7 @@ const DateTimePicker = forwardRef<
readonly={readonly} readonly={readonly}
/> />
</div> </div>
<div className="h-full w-1/3 min-w-[100px]"> <div className="w-1/3 min-w-[100px] h-full">
<TimePicker value={timePart} onChange={handleTimeChange} disabled={disabled} readonly={readonly} /> <TimePicker value={timePart} onChange={handleTimeChange} disabled={disabled} readonly={readonly} />
</div> </div>
</div> </div>
@ -473,14 +700,59 @@ 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 componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height; 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 labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; 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="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 ( return (
<div <div
@ -492,25 +764,8 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
height: componentHeight, height: componentHeight,
}} }}
> >
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */} {labelElement}
{showLabel && ( {dateContent}
<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>
</div> </div>
); );
}); });

View File

@ -29,26 +29,33 @@ import {
GripVertical, GripVertical,
User, User,
Users, Users,
Building, Building
} from "lucide-react"; } from "lucide-react";
/** /**
* *
*/ */
const TreeNode = forwardRef< const TreeNode = forwardRef<HTMLDivElement, {
HTMLDivElement, node: HierarchyNode;
{ level: number;
node: HierarchyNode; maxLevel?: number;
level: number; selectedNode?: HierarchyNode;
maxLevel?: number; onSelect?: (node: HierarchyNode) => void;
selectedNode?: HierarchyNode; editable?: boolean;
onSelect?: (node: HierarchyNode) => void; draggable?: boolean;
editable?: boolean; showQty?: boolean;
draggable?: boolean; className?: string;
showQty?: boolean; }>(({
className?: string; node,
} level,
>(({ node, level, maxLevel, selectedNode, onSelect, editable, draggable, showQty, className }, ref) => { maxLevel,
selectedNode,
onSelect,
editable,
draggable,
showQty,
className
}, ref) => {
const [isOpen, setIsOpen] = useState(level < 2); const [isOpen, setIsOpen] = useState(level < 2);
const hasChildren = node.children && node.children.length > 0; const hasChildren = node.children && node.children.length > 0;
const isSelected = selectedNode?.id === node.id; const isSelected = selectedNode?.id === node.id;
@ -63,20 +70,26 @@ const TreeNode = forwardRef<
<Collapsible open={isOpen} onOpenChange={setIsOpen}> <Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div <div
className={cn( className={cn(
"hover:bg-muted/50 flex cursor-pointer items-center gap-1 rounded px-2 py-1", "flex items-center gap-1 py-1 px-2 rounded cursor-pointer hover:bg-muted/50",
isSelected && "bg-primary/10 text-primary", isSelected && "bg-primary/10 text-primary"
)} )}
style={{ paddingLeft: `${level * 16 + 8}px` }} style={{ paddingLeft: `${level * 16 + 8}px` }}
onClick={() => onSelect?.(node)} onClick={() => onSelect?.(node)}
> >
{/* 드래그 핸들 */} {/* 드래그 핸들 */}
{draggable && <GripVertical className="text-muted-foreground h-4 w-4 cursor-grab" />} {draggable && (
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" />
)}
{/* 확장/축소 아이콘 */} {/* 확장/축소 아이콘 */}
{hasChildren ? ( {hasChildren ? (
<CollapsibleTrigger asChild onClick={(e) => e.stopPropagation()}> <CollapsibleTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="h-5 w-5 p-0"> <Button variant="ghost" size="icon" className="h-5 w-5 p-0">
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />} {isOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
) : ( ) : (
@ -91,15 +104,15 @@ const TreeNode = forwardRef<
<Folder className="h-4 w-4 text-amber-500" /> <Folder className="h-4 w-4 text-amber-500" />
) )
) : ( ) : (
<File className="text-muted-foreground h-4 w-4" /> <File className="h-4 w-4 text-muted-foreground" />
)} )}
{/* 라벨 */} {/* 라벨 */}
<span className="flex-1 truncate text-sm">{node.label}</span> <span className="flex-1 text-sm truncate">{node.label}</span>
{/* 수량 (BOM용) */} {/* 수량 (BOM용) */}
{showQty && node.data?.qty && ( {showQty && node.data?.qty && (
<span className="text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-xs"> <span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
x{String(node.data.qty)} x{String(node.data.qty)}
</span> </span>
)} )}
@ -111,9 +124,7 @@ const TreeNode = forwardRef<
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100" className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100"
onClick={(e) => { onClick={(e) => { e.stopPropagation(); }}
e.stopPropagation();
}}
> >
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
</Button> </Button>
@ -148,22 +159,21 @@ TreeNode.displayName = "TreeNode";
/** /**
* *
*/ */
const TreeView = forwardRef< const TreeView = forwardRef<HTMLDivElement, {
HTMLDivElement, data: HierarchyNode[];
{ selectedNode?: HierarchyNode;
data: HierarchyNode[]; onNodeSelect?: (node: HierarchyNode) => void;
selectedNode?: HierarchyNode; editable?: boolean;
onNodeSelect?: (node: HierarchyNode) => void; draggable?: boolean;
editable?: boolean; maxLevel?: number;
draggable?: boolean; className?: string;
maxLevel?: number; }>(({ data, selectedNode, onNodeSelect, editable, draggable, maxLevel, className }, ref) => {
className?: string;
}
>(({ data, selectedNode, onNodeSelect, editable, draggable, maxLevel, className }, ref) => {
return ( return (
<div ref={ref} className={cn("rounded-lg border p-2", className)}> <div ref={ref} className={cn("border rounded-lg p-2", className)}>
{data.length === 0 ? ( {data.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm"> </div> <div className="py-8 text-center text-muted-foreground text-sm">
</div>
) : ( ) : (
data.map((node) => ( data.map((node) => (
<TreeNode <TreeNode
@ -186,15 +196,12 @@ TreeView.displayName = "TreeView";
/** /**
* *
*/ */
const OrgView = forwardRef< const OrgView = forwardRef<HTMLDivElement, {
HTMLDivElement, data: HierarchyNode[];
{ selectedNode?: HierarchyNode;
data: HierarchyNode[]; onNodeSelect?: (node: HierarchyNode) => void;
selectedNode?: HierarchyNode; className?: string;
onNodeSelect?: (node: HierarchyNode) => void; }>(({ data, selectedNode, onNodeSelect, className }, ref) => {
className?: string;
}
>(({ data, selectedNode, onNodeSelect, className }, ref) => {
const renderOrgNode = (node: HierarchyNode, isRoot = false) => { const renderOrgNode = (node: HierarchyNode, isRoot = false) => {
const isSelected = selectedNode?.id === node.id; const isSelected = selectedNode?.id === node.id;
const hasChildren = node.children && node.children.length > 0; const hasChildren = node.children && node.children.length > 0;
@ -204,18 +211,16 @@ const OrgView = forwardRef<
{/* 노드 카드 */} {/* 노드 카드 */}
<div <div
className={cn( className={cn(
"hover:border-primary flex cursor-pointer flex-col items-center rounded-lg border p-3 transition-colors", "flex flex-col items-center p-3 border rounded-lg cursor-pointer hover:border-primary transition-colors",
isSelected && "border-primary bg-primary/5", isSelected && "border-primary bg-primary/5",
isRoot && "bg-primary/10", isRoot && "bg-primary/10"
)} )}
onClick={() => onNodeSelect?.(node)} onClick={() => onNodeSelect?.(node)}
> >
<div <div className={cn(
className={cn( "w-10 h-10 rounded-full flex items-center justify-center mb-2",
"mb-2 flex h-10 w-10 items-center justify-center rounded-full", isRoot ? "bg-primary text-primary-foreground" : "bg-muted"
isRoot ? "bg-primary text-primary-foreground" : "bg-muted", )}>
)}
>
{isRoot ? ( {isRoot ? (
<Building className="h-5 w-5" /> <Building className="h-5 w-5" />
) : hasChildren ? ( ) : hasChildren ? (
@ -225,8 +230,10 @@ const OrgView = forwardRef<
)} )}
</div> </div>
<div className="text-center"> <div className="text-center">
<div className="text-sm font-medium">{node.label}</div> <div className="font-medium text-sm">{node.label}</div>
{node.data?.title && <div className="text-muted-foreground text-xs">{String(node.data.title)}</div>} {node.data?.title && (
<div className="text-xs text-muted-foreground">{String(node.data.title)}</div>
)}
</div> </div>
</div> </div>
@ -234,7 +241,7 @@ const OrgView = forwardRef<
{hasChildren && ( {hasChildren && (
<> <>
{/* 연결선 */} {/* 연결선 */}
<div className="bg-border h-4 w-px" /> <div className="w-px h-4 bg-border" />
<div className="flex gap-4"> <div className="flex gap-4">
{node.children!.map((child, index) => ( {node.children!.map((child, index) => (
<React.Fragment key={child.id}> <React.Fragment key={child.id}>
@ -252,9 +259,13 @@ const OrgView = forwardRef<
return ( return (
<div ref={ref} className={cn("overflow-auto p-4", className)}> <div ref={ref} className={cn("overflow-auto p-4", className)}>
{data.length === 0 ? ( {data.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm"> </div> <div className="py-8 text-center text-muted-foreground text-sm">
</div>
) : ( ) : (
<div className="flex flex-col items-center gap-4">{data.map((node) => renderOrgNode(node, true))}</div> <div className="flex flex-col items-center gap-4">
{data.map((node) => renderOrgNode(node, true))}
</div>
)} )}
</div> </div>
); );
@ -264,20 +275,19 @@ OrgView.displayName = "OrgView";
/** /**
* BOM ( ) * BOM ( )
*/ */
const BomView = forwardRef< const BomView = forwardRef<HTMLDivElement, {
HTMLDivElement, data: HierarchyNode[];
{ selectedNode?: HierarchyNode;
data: HierarchyNode[]; onNodeSelect?: (node: HierarchyNode) => void;
selectedNode?: HierarchyNode; editable?: boolean;
onNodeSelect?: (node: HierarchyNode) => void; className?: string;
editable?: boolean; }>(({ data, selectedNode, onNodeSelect, editable, className }, ref) => {
className?: string;
}
>(({ data, selectedNode, onNodeSelect, editable, className }, ref) => {
return ( return (
<div ref={ref} className={cn("rounded-lg border p-2", className)}> <div ref={ref} className={cn("border rounded-lg p-2", className)}>
{data.length === 0 ? ( {data.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm">BOM </div> <div className="py-8 text-center text-muted-foreground text-sm">
BOM
</div>
) : ( ) : (
data.map((node) => ( data.map((node) => (
<TreeNode <TreeNode
@ -299,16 +309,13 @@ BomView.displayName = "BomView";
/** /**
* *
*/ */
const CascadingView = forwardRef< const CascadingView = forwardRef<HTMLDivElement, {
HTMLDivElement, data: HierarchyNode[];
{ selectedNode?: HierarchyNode;
data: HierarchyNode[]; onNodeSelect?: (node: HierarchyNode) => void;
selectedNode?: HierarchyNode; maxLevel?: number;
onNodeSelect?: (node: HierarchyNode) => void; className?: string;
maxLevel?: number; }>(({ data, selectedNode, onNodeSelect, maxLevel = 3, className }, ref) => {
className?: string;
}
>(({ data, selectedNode, onNodeSelect, maxLevel = 3, className }, ref) => {
const [selections, setSelections] = useState<string[]>([]); const [selections, setSelections] = useState<string[]>([]);
// 레벨별 옵션 가져오기 // 레벨별 옵션 가져오기
@ -377,94 +384,125 @@ CascadingView.displayName = "CascadingView";
/** /**
* V2Hierarchy * V2Hierarchy
*/ */
export const V2Hierarchy = forwardRef<HTMLDivElement, V2HierarchyProps>((props, ref) => { export const V2Hierarchy = forwardRef<HTMLDivElement, V2HierarchyProps>(
const { id, label, required, style, size, config: configProp, data = [], selectedNode, onNodeSelect } = props; (props, ref) => {
const {
id,
label,
required,
style,
size,
config: configProp,
data = [],
selectedNode,
onNodeSelect,
} = props;
// config가 없으면 기본값 사용 // config가 없으면 기본값 사용
const config = configProp || { type: "tree" as const, viewMode: "tree" as const, dataSource: "static" as const }; const config = configProp || { type: "tree" as const, viewMode: "tree" as const, dataSource: "static" as const };
// 뷰모드별 렌더링 // 뷰모드별 렌더링
const renderHierarchy = () => { const renderHierarchy = () => {
const viewMode = config.viewMode || config.type || "tree"; const viewMode = config.viewMode || config.type || "tree";
switch (viewMode) { switch (viewMode) {
case "tree": case "tree":
return ( return (
<TreeView <TreeView
data={data} data={data}
selectedNode={selectedNode} selectedNode={selectedNode}
onNodeSelect={onNodeSelect} onNodeSelect={onNodeSelect}
editable={config.editable} editable={config.editable}
draggable={config.draggable} draggable={config.draggable}
maxLevel={config.maxLevel} maxLevel={config.maxLevel}
/> />
); );
case "org": case "org":
return <OrgView data={data} selectedNode={selectedNode} onNodeSelect={onNodeSelect} />; return (
<OrgView
data={data}
selectedNode={selectedNode}
onNodeSelect={onNodeSelect}
/>
);
case "bom": case "bom":
return ( return (
<BomView data={data} selectedNode={selectedNode} onNodeSelect={onNodeSelect} editable={config.editable} /> <BomView
); data={data}
selectedNode={selectedNode}
onNodeSelect={onNodeSelect}
editable={config.editable}
/>
);
case "dropdown": case "dropdown":
case "cascading": case "cascading":
return ( return (
<CascadingView <CascadingView
data={data} data={data}
selectedNode={selectedNode} selectedNode={selectedNode}
onNodeSelect={onNodeSelect} onNodeSelect={onNodeSelect}
maxLevel={config.maxLevel} maxLevel={config.maxLevel}
/> />
); );
default: default:
return <TreeView data={data} selectedNode={selectedNode} onNodeSelect={onNodeSelect} />; return (
} <TreeView
}; data={data}
selectedNode={selectedNode}
onNodeSelect={onNodeSelect}
/>
);
}
};
const showLabel = label && style?.labelDisplay !== false; const showLabel = label && style?.labelDisplay !== false;
const componentWidth = size?.width || style?.width; const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height; const componentHeight = size?.height || style?.height;
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정) // 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14; const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
return ( return (
<div <div
ref={ref} ref={ref}
id={id} id={id}
className="relative" className="relative"
style={{ style={{
width: componentWidth, width: componentWidth,
height: componentHeight, height: componentHeight,
}} }}
> >
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */} {/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
{showLabel && ( {showLabel && (
<Label <Label
htmlFor={id} htmlFor={id}
style={{ style={{
position: "absolute", position: "absolute",
top: `-${estimatedLabelHeight}px`, top: `-${estimatedLabelHeight}px`,
left: 0, left: 0,
fontSize: style?.labelFontSize || "14px", fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b", color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500", fontWeight: style?.labelFontWeight || "500",
}} }}
className="text-sm font-medium whitespace-nowrap" className="text-sm font-medium whitespace-nowrap"
> >
{label} {label}{required && <span className="text-orange-500">*</span>}
{required && <span className="ml-0.5 text-orange-500">*</span>} </Label>
</Label> )}
)} <div className="h-full w-full">
<div className="h-full w-full">{renderHierarchy()}</div> {renderHierarchy()}
</div> </div>
); </div>
}); );
}
);
V2Hierarchy.displayName = "V2Hierarchy"; V2Hierarchy.displayName = "V2Hierarchy";
export default V2Hierarchy; export default V2Hierarchy;

View File

@ -25,27 +25,11 @@ import { previewNumberingCode } from "@/lib/api/numberingRule";
// 형식별 입력 마스크 및 검증 패턴 // 형식별 입력 마스크 및 검증 패턴
const FORMAT_PATTERNS: Record<V2InputFormat, { pattern: RegExp; placeholder: string; errorMessage: string }> = { const FORMAT_PATTERNS: Record<V2InputFormat, { pattern: RegExp; placeholder: string; errorMessage: string }> = {
none: { pattern: /.*/, placeholder: "", errorMessage: "" }, none: { pattern: /.*/, placeholder: "", errorMessage: "" },
email: { email: { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, placeholder: "example@email.com", errorMessage: "올바른 이메일 형식이 아닙니다" },
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, tel: { pattern: /^\d{2,3}-\d{3,4}-\d{4}$/, placeholder: "010-1234-5678", errorMessage: "올바른 전화번호 형식이 아닙니다" },
placeholder: "example@email.com", url: { pattern: /^https?:\/\/.+/, placeholder: "https://example.com", errorMessage: "올바른 URL 형식이 아닙니다 (https://로 시작)" },
errorMessage: "올바른 이메일 형식이 아닙니다",
},
tel: {
pattern: /^\d{2,3}-\d{3,4}-\d{4}$/,
placeholder: "010-1234-5678",
errorMessage: "올바른 전화번호 형식이 아닙니다",
},
url: {
pattern: /^https?:\/\/.+/,
placeholder: "https://example.com",
errorMessage: "올바른 URL 형식이 아닙니다 (https://로 시작)",
},
currency: { pattern: /^[\d,]+$/, placeholder: "1,000,000", errorMessage: "숫자만 입력 가능합니다" }, currency: { pattern: /^[\d,]+$/, placeholder: "1,000,000", errorMessage: "숫자만 입력 가능합니다" },
biz_no: { biz_no: { pattern: /^\d{3}-\d{2}-\d{5}$/, placeholder: "123-45-67890", errorMessage: "올바른 사업자번호 형식이 아닙니다" },
pattern: /^\d{3}-\d{2}-\d{5}$/,
placeholder: "123-45-67890",
errorMessage: "올바른 사업자번호 형식이 아닙니다",
},
}; };
// 형식 검증 함수 (외부에서도 사용 가능) // 형식 검증 함수 (외부에서도 사용 가능)
@ -212,7 +196,7 @@ const TextInput = forwardRef<
const hasError = hasBlurred && !!validationError; const hasError = hasBlurred && !!validationError;
return ( return (
<div className="flex h-full w-full flex-col"> <div className="relative h-full w-full">
<Input <Input
ref={ref} ref={ref}
type="text" type="text"
@ -222,10 +206,16 @@ const TextInput = forwardRef<
placeholder={inputPlaceholder} placeholder={inputPlaceholder}
readOnly={readonly} readOnly={readonly}
disabled={disabled} disabled={disabled}
className={cn("h-full w-full", hasError && "border-destructive focus-visible:ring-destructive", className)} className={cn(
"h-full w-full",
hasError && "border-destructive focus-visible:ring-destructive",
className,
)}
style={inputStyle} style={inputStyle}
/> />
{hasError && <p className="text-destructive mt-1 text-[11px]">{validationError}</p>} {hasError && (
<p className="text-destructive absolute left-0 top-full mt-0.5 text-[11px]">{validationError}</p>
)}
</div> </div>
); );
}); });
@ -629,46 +619,40 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
try { try {
// 채번 규칙 ID 캐싱 (한 번만 조회) // 채번 규칙 ID 캐싱 (한 번만 조회)
if (!numberingRuleIdRef.current) { if (!numberingRuleIdRef.current) {
const { getTableColumns } = await import("@/lib/api/tableManagement"); // table_name + column_name 기반으로 채번 규칙 조회
const columnsResponse = await getTableColumns(tableName); try {
const { apiClient } = await import("@/lib/api/client");
const ruleResponse = await apiClient.get(`/numbering-rules/by-column/${tableName}/${columnName}`);
if (ruleResponse.data?.success && ruleResponse.data?.data?.ruleId) {
numberingRuleIdRef.current = ruleResponse.data.data.ruleId;
if (!columnsResponse.success || !columnsResponse.data) { if (onFormDataChange && columnName) {
console.warn("테이블 컬럼 정보 조회 실패:", columnsResponse); onFormDataChange(`${columnName}_numberingRuleId`, ruleResponse.data.data.ruleId);
return;
}
const columns = columnsResponse.data.columns || columnsResponse.data;
const targetColumn = columns.find((col: { columnName: string }) => col.columnName === columnName);
if (!targetColumn) {
console.warn("컬럼 정보를 찾을 수 없습니다:", columnName);
return;
}
// detailSettings에서 numberingRuleId 추출
if (targetColumn.detailSettings) {
try {
// 문자열이면 파싱, 객체면 그대로 사용
const parsed =
typeof targetColumn.detailSettings === "string"
? JSON.parse(targetColumn.detailSettings)
: targetColumn.detailSettings;
numberingRuleIdRef.current = parsed.numberingRuleId || null;
// 🆕 채번 규칙 ID를 formData에 저장 (저장 시 allocateCode 호출을 위해)
if (parsed.numberingRuleId && onFormDataChange && columnName) {
onFormDataChange(`${columnName}_numberingRuleId`, parsed.numberingRuleId);
} }
} catch {
// JSON 파싱 실패
} }
} catch {
// by-column 조회 실패 시 detailSettings fallback
try {
const { getTableColumns } = await import("@/lib/api/tableManagement");
const columnsResponse = await getTableColumns(tableName);
if (columnsResponse.success && columnsResponse.data) {
const columns = columnsResponse.data.columns || columnsResponse.data;
const targetColumn = columns.find((col: { columnName: string }) => col.columnName === columnName);
if (targetColumn?.detailSettings) {
const parsed = typeof targetColumn.detailSettings === "string"
? JSON.parse(targetColumn.detailSettings)
: targetColumn.detailSettings;
numberingRuleIdRef.current = parsed.numberingRuleId || null;
}
}
} catch { /* ignore */ }
} }
} }
const numberingRuleId = numberingRuleIdRef.current; const numberingRuleId = numberingRuleIdRef.current;
if (!numberingRuleId) { if (!numberingRuleId) {
console.warn("채번 규칙 ID가 설정되지 않았습니다. 테이블 관리에서 설정하세요.", { tableName, columnName }); console.warn("채번 규칙을 찾을 수 없습니다. 옵션설정 > 채번설정에서 규칙을 생성하세요.", { tableName, columnName });
return; return;
} }
@ -792,12 +776,11 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
case "number": case "number":
// DB에서 문자열("325")로 반환되는 경우도 숫자로 변환하여 표시 // DB에서 문자열("325")로 반환되는 경우도 숫자로 변환하여 표시
const numValue = const numValue = typeof displayValue === "number"
typeof displayValue === "number" ? displayValue
? displayValue : (displayValue !== undefined && displayValue !== null && displayValue !== "" && !isNaN(Number(displayValue)))
: displayValue !== undefined && displayValue !== null && displayValue !== "" && !isNaN(Number(displayValue)) ? Number(displayValue)
? Number(displayValue) : undefined;
: undefined;
return ( return (
<NumberInput <NumberInput
value={numValue} value={numValue}
@ -832,12 +815,11 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
case "slider": case "slider":
// DB에서 문자열로 반환되는 경우도 숫자로 변환 // DB에서 문자열로 반환되는 경우도 숫자로 변환
const sliderValue = const sliderValue = typeof displayValue === "number"
typeof displayValue === "number" ? displayValue
? displayValue : (displayValue !== undefined && displayValue !== null && displayValue !== "" && !isNaN(Number(displayValue)))
: displayValue !== undefined && displayValue !== null && displayValue !== "" && !isNaN(Number(displayValue)) ? Number(displayValue)
? Number(displayValue) : (config.min ?? 0);
: (config.min ?? 0);
return ( return (
<SliderInput <SliderInput
value={sliderValue} value={sliderValue}
@ -935,11 +917,9 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
// 커스텀 이벤트로도 전달 (최후의 보루) // 커스텀 이벤트로도 전달 (최후의 보루)
if (typeof window !== "undefined" && columnName) { if (typeof window !== "undefined" && columnName) {
window.dispatchEvent( window.dispatchEvent(new CustomEvent("numberingValueChanged", {
new CustomEvent("numberingValueChanged", { detail: { columnName, value: newValue }
detail: { columnName, value: newValue }, }));
}),
);
} }
}} }}
placeholder="입력" placeholder="입력"
@ -976,36 +956,82 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
} }
}; };
// 라벨이 표시될 때 입력 필드가 차지할 높이 계산
// 🔧 label prop이 없어도 style.labelText에서 가져올 수 있도록 수정
const actualLabel = label || style?.labelText; const actualLabel = label || style?.labelText;
const showLabel = actualLabel && style?.labelDisplay === true; const showLabel = actualLabel && style?.labelDisplay !== false && style?.labelDisplay !== "false";
// size에서 우선 가져오고, 없으면 style에서 가져옴
const componentWidth = size?.width || style?.width; const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height; 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 labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; 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 hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border);
const hasCustomBackground = !!style?.backgroundColor; const hasCustomBackground = !!style?.backgroundColor;
const hasCustomRadius = !!style?.borderRadius; const hasCustomRadius = !!style?.borderRadius;
// 텍스트 스타일 오버라이드 (내부 input/textarea에 직접 전달)
const customTextStyle: React.CSSProperties = {}; const customTextStyle: React.CSSProperties = {};
if (style?.color) customTextStyle.color = style.color; if (style?.color) customTextStyle.color = style.color;
if (style?.fontSize) customTextStyle.fontSize = style.fontSize; if (style?.fontSize) customTextStyle.fontSize = style.fontSize;
if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight; if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight;
if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"]; if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"];
const hasCustomText = Object.keys(customTextStyle).length > 0; const hasCustomText = Object.keys(customTextStyle).length > 0;
// 내부 input에 직접 적용할 텍스트 스타일 (fontSize, color, fontWeight, textAlign)
const inputTextStyle: React.CSSProperties | undefined = hasCustomText ? customTextStyle : undefined; 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="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 ( return (
<div <div
ref={ref} ref={ref}
@ -1016,39 +1042,8 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
height: componentHeight, height: componentHeight,
}} }}
> >
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 (높이에 포함되지 않음) */} {labelElement}
{showLabel && ( {inputContent}
<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 && "[&_.border]:border-0! [&_input]:border-0! [&_textarea]:border-0!",
// 커스텀 모서리 설정 시, 내부 요소의 기본 모서리 제거 (외부 래퍼가 처리)
(hasCustomBorder || hasCustomRadius) &&
"[&_.rounded-md]:rounded-none! [&_input]:rounded-none! [&_textarea]:rounded-none!",
// 커스텀 배경 설정 시, 내부 input을 투명하게 (외부 배경이 보이도록)
hasCustomBackground && "[&_input]:bg-transparent! [&_textarea]:bg-transparent!",
)}
style={hasCustomText ? customTextStyle : undefined}
>
{renderInput()}
</div>
</div> </div>
); );
}); });

View File

@ -840,8 +840,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>((props, ref) =>
}} }}
className="shrink-0 text-sm font-medium" className="shrink-0 text-sm font-medium"
> >
{label} {label}{required && <span className="text-orange-500">*</span>}
{required && <span className="ml-0.5 text-orange-500">*</span>}
</Label> </Label>
)} )}

File diff suppressed because it is too large Load Diff

View File

@ -796,7 +796,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
console.log(`📡 [RepeaterInput] 카테고리 매핑 로드: ${tableName}/${columnName}`); console.log(`📡 [RepeaterInput] 카테고리 매핑 로드: ${tableName}/${columnName}`);
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`); const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values?includeInactive=true`);
if (response.data.success && response.data.data && Array.isArray(response.data.data)) { if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
const mapping: Record<string, { label: string; color: string }> = {}; const mapping: Record<string, { label: string; color: string }> = {};
@ -838,7 +838,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
try { try {
console.log(`📡 [RepeaterInput] 조인 테이블 카테고리 매핑 로드: ${joinedTableName}/${columnName}`); console.log(`📡 [RepeaterInput] 조인 테이블 카테고리 매핑 로드: ${joinedTableName}/${columnName}`);
const response = await apiClient.get(`/table-categories/${joinedTableName}/${columnName}/values`); const response = await apiClient.get(`/table-categories/${joinedTableName}/${columnName}/values?includeInactive=true`);
if (response.data.success && response.data.data && Array.isArray(response.data.data)) { if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
const mapping: Record<string, { label: string; color: string }> = {}; const mapping: Record<string, { label: string; color: string }> = {};

View File

@ -0,0 +1,11 @@
"use client";
import { createContext, useContext } from "react";
const TabIdContext = createContext<string | null>(null);
export const TabIdProvider = TabIdContext.Provider;
export function useTabId(): string | null {
return useContext(TabIdContext);
}

145
frontend/lib/api/mold.ts Normal file
View File

@ -0,0 +1,145 @@
import { apiClient } from "./client";
export interface MoldInfo {
id: string;
company_code: string;
mold_code: string;
mold_name: string;
mold_type: string | null;
category: string | null;
manufacturer: string | null;
manufacturing_number: string | null;
manufacturing_date: string | null;
cavity_count: number;
shot_count: number;
mold_quantity: number;
base_input_qty: number;
operation_status: string;
remarks: string | null;
image_path: string | null;
memo: string | null;
created_date: string;
updated_date: string;
writer: string | null;
}
export interface MoldSerial {
id: string;
company_code: string;
mold_code: string;
serial_number: string;
status: string;
progress: number;
work_description: string | null;
manager: string | null;
completion_date: string | null;
remarks: string | null;
created_date: string;
}
export interface MoldInspectionItem {
id: string;
company_code: string;
mold_code: string;
inspection_item: string;
inspection_cycle: string | null;
inspection_method: string | null;
inspection_content: string | null;
lower_limit: string | null;
upper_limit: string | null;
unit: string | null;
is_active: string;
checklist: string | null;
remarks: string | null;
created_date: string;
}
export interface MoldPart {
id: string;
company_code: string;
mold_code: string;
part_name: string;
replacement_cycle: string | null;
unit: string | null;
specification: string | null;
manufacturer: string | null;
manufacturer_code: string | null;
image_path: string | null;
remarks: string | null;
created_date: string;
}
export interface MoldSerialSummary {
total: number;
in_use: number;
repair: number;
stored: number;
disposed: number;
}
interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
}
const handleResponse = async <T>(promise: Promise<any>): Promise<ApiResponse<T>> => {
try {
const response = await promise;
return response.data;
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || error.message || "오류가 발생했습니다.",
};
}
};
// 금형 마스터
export const getMoldList = (params?: Record<string, string>) =>
handleResponse<MoldInfo[]>(apiClient.get("/mold", { params }));
export const getMoldDetail = (moldCode: string) =>
handleResponse<MoldInfo>(apiClient.get(`/mold/${moldCode}`));
export const createMold = (data: Partial<MoldInfo>) =>
handleResponse<MoldInfo>(apiClient.post("/mold", data));
export const updateMold = (moldCode: string, data: Partial<MoldInfo>) =>
handleResponse<MoldInfo>(apiClient.put(`/mold/${moldCode}`, data));
export const deleteMold = (moldCode: string) =>
handleResponse<void>(apiClient.delete(`/mold/${moldCode}`));
// 일련번호
export const getMoldSerials = (moldCode: string) =>
handleResponse<MoldSerial[]>(apiClient.get(`/mold/${moldCode}/serials`));
export const createMoldSerial = (moldCode: string, data: Partial<MoldSerial>) =>
handleResponse<MoldSerial>(apiClient.post(`/mold/${moldCode}/serials`, data));
export const deleteMoldSerial = (id: string) =>
handleResponse<void>(apiClient.delete(`/mold/serials/${id}`));
export const getMoldSerialSummary = (moldCode: string) =>
handleResponse<MoldSerialSummary>(apiClient.get(`/mold/${moldCode}/serial-summary`));
// 점검항목
export const getMoldInspections = (moldCode: string) =>
handleResponse<MoldInspectionItem[]>(apiClient.get(`/mold/${moldCode}/inspections`));
export const createMoldInspection = (moldCode: string, data: Partial<MoldInspectionItem>) =>
handleResponse<MoldInspectionItem>(apiClient.post(`/mold/${moldCode}/inspections`, data));
export const deleteMoldInspection = (id: string) =>
handleResponse<void>(apiClient.delete(`/mold/inspections/${id}`));
// 부품
export const getMoldParts = (moldCode: string) =>
handleResponse<MoldPart[]>(apiClient.get(`/mold/${moldCode}/parts`));
export const createMoldPart = (moldCode: string, data: Partial<MoldPart>) =>
handleResponse<MoldPart>(apiClient.post(`/mold/${moldCode}/parts`, data));
export const deleteMoldPart = (id: string) =>
handleResponse<void>(apiClient.delete(`/mold/parts/${id}`));

View File

@ -0,0 +1,213 @@
import React from "react";
import DOMPurify from "isomorphic-dompurify";
import {
Check, Save, CheckCircle, CircleCheck, FileCheck, ShieldCheck,
Trash2, Trash, XCircle, X, Eraser, CircleX,
Pencil, PenLine, Pen, SquarePen, FilePen, PenTool,
ArrowRight, ExternalLink, MoveRight, Navigation, CornerUpRight, Link,
Maximize2, PanelTop, AppWindow, LayoutGrid, Layers, FolderOpen,
SendHorizontal, ArrowRightLeft, Repeat, PackageCheck, Upload, Share2,
Download, FileDown, FileSpreadsheet, Sheet, Table, FileOutput,
FileUp, FileInput,
Zap, Plus, PlusCircle, SquarePlus, FilePlus, BadgePlus,
Settings, SlidersHorizontal, ToggleLeft, Workflow, GitBranch, Settings2,
ScanLine, QrCode, Camera, Scan, ScanBarcode, Focus,
Truck, Car, MapPin, Navigation2, Route, Bell,
Send, Radio, Megaphone, Podcast, BellRing,
Copy, ClipboardCopy, Files, CopyPlus, ClipboardList, Clipboard,
SquareMousePointer,
type LucideIcon,
} from "lucide-react";
// ---------------------------------------------------------------------------
// 아이콘 이름 → 컴포넌트 매핑 (추천 아이콘만 명시적 import)
// ---------------------------------------------------------------------------
export const iconMap: Record<string, LucideIcon> = {
Check, Save, CheckCircle, CircleCheck, FileCheck, ShieldCheck,
Trash2, Trash, XCircle, X, Eraser, CircleX,
Pencil, PenLine, Pen, SquarePen, FilePen, PenTool,
ArrowRight, ExternalLink, MoveRight, Navigation, CornerUpRight, Link,
Maximize2, PanelTop, AppWindow, LayoutGrid, Layers, FolderOpen,
SendHorizontal, ArrowRightLeft, Repeat, PackageCheck, Upload, Share2,
Download, FileDown, FileSpreadsheet, Sheet, Table, FileOutput,
FileUp, FileInput,
Zap, Plus, PlusCircle, SquarePlus, FilePlus, BadgePlus,
Settings, SlidersHorizontal, ToggleLeft, Workflow, GitBranch, Settings2,
ScanLine, QrCode, Camera, Scan, ScanBarcode, Focus,
Truck, Car, MapPin, Navigation2, Route, Bell,
Send, Radio, Megaphone, Podcast, BellRing,
Copy, ClipboardCopy, Files, CopyPlus, ClipboardList, Clipboard,
SquareMousePointer,
};
// ---------------------------------------------------------------------------
// 버튼 액션 → 추천 아이콘 이름 매핑
// ---------------------------------------------------------------------------
export const actionIconMap: Record<string, string[]> = {
save: ["Check", "Save", "CheckCircle", "CircleCheck", "FileCheck", "ShieldCheck"],
delete: ["Trash2", "Trash", "XCircle", "X", "Eraser", "CircleX"],
edit: ["Pencil", "PenLine", "Pen", "SquarePen", "FilePen", "PenTool"],
navigate: ["ArrowRight", "ExternalLink", "MoveRight", "Navigation", "CornerUpRight", "Link"],
modal: ["Maximize2", "PanelTop", "AppWindow", "LayoutGrid", "Layers", "FolderOpen"],
transferData: ["SendHorizontal", "ArrowRightLeft", "Repeat", "PackageCheck", "Upload", "Share2"],
excel_download: ["Download", "FileDown", "FileSpreadsheet", "Sheet", "Table", "FileOutput"],
excel_upload: ["Upload", "FileUp", "FileSpreadsheet", "Sheet", "FileInput", "FileOutput"],
quickInsert: ["Zap", "Plus", "PlusCircle", "SquarePlus", "FilePlus", "BadgePlus"],
control: ["Settings", "SlidersHorizontal", "ToggleLeft", "Workflow", "GitBranch", "Settings2"],
barcode_scan: ["ScanLine", "QrCode", "Camera", "Scan", "ScanBarcode", "Focus"],
operation_control: ["Truck", "Car", "MapPin", "Navigation2", "Route", "Bell"],
event: ["Send", "Bell", "Radio", "Megaphone", "Podcast", "BellRing"],
copy: ["Copy", "ClipboardCopy", "Files", "CopyPlus", "ClipboardList", "Clipboard"],
};
// 아이콘 추천이 불가능한 deprecated/숨김 액션
export const noIconActions = new Set([
"openRelatedModal",
"openModalWithData",
"view_table_history",
"code_merge",
"empty_vehicle",
]);
export const NO_ICON_MESSAGE = "적절한 추천 아이콘이 없습니다. 텍스트 모드를 사용하거나 아래에서 아이콘을 직접 추가하세요.";
// 범용 폴백 아이콘 (추천 아이콘이 없는 액션용)
export const FALLBACK_ICON_NAME = "SquareMousePointer";
/** 액션 타입에 대한 디폴트 아이콘(첫 번째 추천)을 반환. 없으면 범용 폴백. */
export function getDefaultIconForAction(actionType?: string): { name: string; type: "lucide" } {
if (actionType && actionIconMap[actionType]?.length) {
return { name: actionIconMap[actionType][0], type: "lucide" };
}
return { name: FALLBACK_ICON_NAME, type: "lucide" };
}
// ---------------------------------------------------------------------------
// 아이콘 크기 (버튼 높이 대비 비율)
// ---------------------------------------------------------------------------
export const iconSizePresets: Record<string, number> = {
"작게": 40,
"보통": 55,
"크게": 70,
"매우 크게": 85,
};
/** 프리셋 문자열 → 비율(%) 반환. 레거시 값은 55(보통)로 폴백 */
export function getIconPercent(size: string | number): number {
if (typeof size === "number") return size;
return iconSizePresets[size] ?? 55;
}
/** 아이콘 크기를 CSS로 변환 (버튼 높이 대비 비율, 정사각형 유지) */
export function getIconSizeStyle(size: string | number): React.CSSProperties {
const pct = getIconPercent(size);
return { height: `${pct}%`, width: "auto", aspectRatio: "1 / 1" };
}
// ---------------------------------------------------------------------------
// 아이콘 조회 / 동적 등록
// ---------------------------------------------------------------------------
export function getLucideIcon(name: string): LucideIcon | undefined {
return iconMap[name];
}
export function addToIconMap(name: string, component: LucideIcon): void {
iconMap[name] = component;
}
// ---------------------------------------------------------------------------
// SVG 정화
// ---------------------------------------------------------------------------
export function sanitizeSvg(svgString: string): string {
return DOMPurify.sanitize(svgString, { USE_PROFILES: { svg: true } });
}
// ---------------------------------------------------------------------------
// 버튼 아이콘 렌더러 컴포넌트 (모든 뷰어/위젯에서 공용)
// ---------------------------------------------------------------------------
export function ButtonIconRenderer({
componentConfig,
fallbackLabel,
}: {
componentConfig: any;
fallbackLabel: string;
}) {
const cfg = componentConfig || {};
const displayMode = cfg.displayMode || "text";
if (displayMode === "text" || !cfg.icon?.name) {
return <>{cfg.text || fallbackLabel}</>;
}
return <>{getButtonDisplayContent(cfg)}</>;
}
// ---------------------------------------------------------------------------
// 버튼 표시 콘텐츠 계산 (모든 렌더러 공용)
// ---------------------------------------------------------------------------
export function getButtonDisplayContent(componentConfig: any): React.ReactNode {
const displayMode = componentConfig?.displayMode || "text";
const text = componentConfig?.text || componentConfig?.label || "버튼";
const icon = componentConfig?.icon;
if (displayMode === "text" || !icon?.name) {
return text;
}
// 아이콘 노드 생성
const sizeStyle = getIconSizeStyle(icon.size || "보통");
const colorStyle: React.CSSProperties = icon.color ? { color: icon.color } : {};
let iconNode: React.ReactNode = null;
if (icon.type === "svg") {
const svgIcon = componentConfig?.customSvgIcons?.find(
(s: { name: string; svg: string }) => s.name === icon.name,
);
if (svgIcon) {
const clean = sanitizeSvg(svgIcon.svg);
iconNode = (
<span
className="inline-flex items-center justify-center [&>svg]:h-full [&>svg]:w-full"
style={{ ...sizeStyle, ...colorStyle }}
dangerouslySetInnerHTML={{ __html: clean }}
/>
);
}
} else {
const IconComponent = getLucideIcon(icon.name);
if (IconComponent) {
iconNode = (
<span className="inline-flex items-center justify-center" style={sizeStyle}>
<IconComponent className="h-full w-full" style={colorStyle} />
</span>
);
}
}
if (!iconNode) {
return text;
}
if (displayMode === "icon") {
return iconNode;
}
// icon-text 모드
const gap = componentConfig?.iconGap ?? 6;
const textPos = componentConfig?.iconTextPosition || "right";
const isVertical = textPos === "top" || textPos === "bottom";
const textFirst = textPos === "left" || textPos === "top";
return (
<span
className="inline-flex items-center justify-center"
style={{
gap: `${gap}px`,
flexDirection: isVertical ? "column" : "row",
}}
>
{textFirst ? <span>{text}</span> : iconNode}
{textFirst ? iconNode : <span>{text}</span>}
</span>
);
}

View File

@ -0,0 +1,137 @@
/**
* .
* // .
*
* :
* import { formatDate, formatNumber, formatCurrency } from "@/lib/formatting";
* formatDate("2025-01-01") // "2025-01-01"
* formatDate("2025-01-01T14:30:00Z", "datetime") // "2025-01-01 14:30:00"
* formatNumber(1234567) // "1,234,567"
* formatCurrency(50000) // "₩50,000"
*/
export { getFormatRules, setFormatRules, DEFAULT_FORMAT_RULES } from "./rules";
export type { FormatRules, DateFormatRules, NumberFormatRules, CurrencyFormatRules } from "./rules";
import { getFormatRules } from "./rules";
// --- 날짜 포맷 ---
type DateFormatType = "display" | "datetime" | "input" | "time";
/**
* .
* @param value - ISO , Date,
* @param type - "display" | "datetime" | "input" | "time"
* @returns ( )
*/
export function formatDate(value: unknown, type: DateFormatType = "display"): string {
if (value == null || value === "") return "";
const rules = getFormatRules();
const format = rules.date[type];
try {
const date = value instanceof Date ? value : new Date(String(value));
if (isNaN(date.getTime())) return String(value);
return applyDateFormat(date, format);
} catch {
return String(value);
}
}
/**
* YYYY-MM-DD HH:mm:ss Date
*/
function applyDateFormat(date: Date, pattern: string): string {
const y = date.getFullYear();
const M = date.getMonth() + 1;
const d = date.getDate();
const H = date.getHours();
const m = date.getMinutes();
const s = date.getSeconds();
return pattern
.replace("YYYY", String(y))
.replace("MM", String(M).padStart(2, "0"))
.replace("DD", String(d).padStart(2, "0"))
.replace("HH", String(H).padStart(2, "0"))
.replace("mm", String(m).padStart(2, "0"))
.replace("ss", String(s).padStart(2, "0"));
}
// --- 숫자 포맷 ---
/**
* ( ).
* @param value -
* @param decimals - 릿 ( )
* @returns
*/
export function formatNumber(value: unknown, decimals?: number): string {
if (value == null || value === "") return "";
const rules = getFormatRules();
const num = typeof value === "number" ? value : parseFloat(String(value));
if (isNaN(num)) return String(value);
const dec = decimals ?? rules.number.decimals;
return new Intl.NumberFormat(rules.number.locale, {
minimumFractionDigits: dec,
maximumFractionDigits: dec,
}).format(num);
}
// --- 통화 포맷 ---
/**
* .
* @param value -
* @param currencyCode - ( )
* @returns (: "₩50,000")
*/
export function formatCurrency(value: unknown, currencyCode?: string): string {
if (value == null || value === "") return "";
const rules = getFormatRules();
const num = typeof value === "number" ? value : parseFloat(String(value));
if (isNaN(num)) return String(value);
const code = currencyCode ?? rules.currency.code;
return new Intl.NumberFormat(rules.currency.locale, {
style: "currency",
currency: code,
maximumFractionDigits: code === "KRW" ? 0 : 2,
}).format(num);
}
// --- 범용 포맷 ---
/**
* .
* @param value -
* @param dataType - "date" | "datetime" | "number" | "currency" | "text"
*/
export function formatValue(value: unknown, dataType: string): string {
switch (dataType) {
case "date":
return formatDate(value, "display");
case "datetime":
return formatDate(value, "datetime");
case "time":
return formatDate(value, "time");
case "number":
case "integer":
case "float":
case "decimal":
return formatNumber(value);
case "currency":
case "money":
return formatCurrency(value);
default:
return value == null ? "" : String(value);
}
}

View File

@ -0,0 +1,71 @@
/**
* .
* // .
* .
*/
export interface DateFormatRules {
/** 날짜만 표시 (예: "2025-01-01") */
display: string;
/** 날짜+시간 표시 (예: "2025-01-01 14:30:00") */
datetime: string;
/** 입력 필드용 (예: "YYYY-MM-DD") */
input: string;
/** 시간만 표시 (예: "14:30") */
time: string;
}
export interface NumberFormatRules {
/** 숫자 로케일 (천단위 구분자 등) */
locale: string;
/** 기본 소수점 자릿수 */
decimals: number;
}
export interface CurrencyFormatRules {
/** 통화 코드 (예: "KRW", "USD") */
code: string;
/** 통화 로케일 */
locale: string;
}
export interface FormatRules {
date: DateFormatRules;
number: NumberFormatRules;
currency: CurrencyFormatRules;
}
/** 기본 포맷 규칙 (한국어 기준) */
export const DEFAULT_FORMAT_RULES: FormatRules = {
date: {
display: "YYYY-MM-DD",
datetime: "YYYY-MM-DD HH:mm:ss",
input: "YYYY-MM-DD",
time: "HH:mm",
},
number: {
locale: "ko-KR",
decimals: 0,
},
currency: {
code: "KRW",
locale: "ko-KR",
},
};
/** 현재 적용 중인 포맷 규칙 (런타임에 변경 가능) */
let currentRules: FormatRules = { ...DEFAULT_FORMAT_RULES };
export function getFormatRules(): FormatRules {
return currentRules;
}
export function setFormatRules(rules: Partial<FormatRules>): void {
currentRules = {
...currentRules,
...rules,
date: { ...currentRules.date, ...rules.date },
number: { ...currentRules.number, ...rules.number },
currency: { ...currentRules.currency, ...rules.currency },
};
}

View File

@ -0,0 +1,228 @@
"use client";
import { useEffect } from "react";
import { useTabStore } from "@/stores/tabStore";
import { toast } from "sonner";
const HIGHLIGHT_ATTR = "data-validation-highlight";
const ERROR_ATTR = "data-validation-error";
const MSG_WRAPPER_CLASS = "validation-error-msg-wrapper";
type TargetEl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLButtonElement;
/**
* ( )
*
* // :
* 1.
* 2. +
* 3. ( )
* 4.
*
* 설계: docs/ycshin-node/_자동검증_설계.md
*/
export function useDialogAutoValidation(contentEl: HTMLElement | null) {
const mode = useTabStore((s) => s.mode);
useEffect(() => {
if (mode !== "user") return;
const el = contentEl;
if (!el) return;
const errorFields = new Set<TargetEl>();
let activated = false; // 첫 저장 시도 이후 true
function findRequiredFields(): Map<TargetEl, string> {
const fields = new Map<TargetEl, string>();
if (!el) return fields;
el.querySelectorAll("label").forEach((label) => {
const hasRequiredMark = Array.from(label.querySelectorAll("span")).some(
(span) => span.textContent?.trim() === "*",
);
if (!hasRequiredMark) return;
const forId = label.getAttribute("for") || (label as HTMLLabelElement).htmlFor;
let target: TargetEl | null = null;
if (forId) {
try {
const found = el!.querySelector(`#${CSS.escape(forId)}`);
if (isFormElement(found)) {
target = found;
} else if (found) {
const inner = found.querySelector("input, textarea, select");
if (isFormElement(inner) && !isHiddenRadixSelect(inner)) {
target = inner;
}
// 숨겨진 Radix select이거나 폼 요소가 없으면 → 트리거 버튼 탐색
if (!target) {
const btn = found.querySelector('button[role="combobox"], button[data-slot="select-trigger"]');
if (btn instanceof HTMLButtonElement) target = btn;
}
}
} catch {
/* invalid id */
}
}
if (!target) {
const parent = label.closest('[class*="space-y"]') || label.parentElement;
if (parent) {
const inner = parent.querySelector("input, textarea, select");
if (isFormElement(inner) && !isHiddenRadixSelect(inner)) {
target = inner;
}
if (!target) {
const btn = parent.querySelector('button[role="combobox"], button[data-slot="select-trigger"]');
if (btn instanceof HTMLButtonElement) target = btn;
}
}
}
if (target) {
const labelText = label.textContent?.replace(/\*/g, "").trim() || "";
fields.set(target, labelText);
}
});
return fields;
}
function isFormElement(el: Element | null): el is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement {
return el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement;
}
function isHiddenRadixSelect(el: Element): boolean {
return el instanceof HTMLSelectElement && el.hasAttribute("aria-hidden");
}
function isEmpty(input: TargetEl): boolean {
if (input instanceof HTMLButtonElement) {
// Radix Select: data-placeholder 속성이 자식 span에 있으면 미선택 상태
return !!input.querySelector("[data-placeholder]");
}
return input.value.trim() === "";
}
function isSaveButton(target: HTMLElement): boolean {
const btn = target.closest("button");
if (!btn) return false;
const actionType = btn.getAttribute("data-action-type");
if (actionType === "save" || actionType === "submit") return true;
const variant = btn.getAttribute("data-variant");
if (variant === "default") return true;
return false;
}
function markError(input: TargetEl) {
input.setAttribute(ERROR_ATTR, "true");
errorFields.add(input);
showErrorMsg(input);
}
function clearError(input: TargetEl) {
input.removeAttribute(ERROR_ATTR);
errorFields.delete(input);
removeErrorMsg(input);
}
// 빈 필수 필드 아래에 경고 문구 삽입 (레이아웃 영향 없는 zero-height wrapper)
function showErrorMsg(input: TargetEl) {
if (input.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`)) return;
const wrapper = document.createElement("div");
wrapper.className = MSG_WRAPPER_CLASS;
const msg = document.createElement("p");
msg.textContent = "필수 입력 항목입니다";
wrapper.appendChild(msg);
input.insertAdjacentElement("afterend", wrapper);
}
function removeErrorMsg(input: TargetEl) {
const wrapper = input.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`);
if (wrapper) wrapper.remove();
}
function highlightField(input: TargetEl) {
input.setAttribute(HIGHLIGHT_ATTR, "true");
input.addEventListener("animationend", () => input.removeAttribute(HIGHLIGHT_ATTR), { once: true });
if (input instanceof HTMLButtonElement) {
input.click();
} else {
input.focus();
}
}
// 첫 저장 시도 이후: 빈 필드 → 에러 유지/재적용, 값 있으면 해제
function syncErrors() {
if (!activated) return;
const fields = findRequiredFields();
for (const [input] of fields) {
if (isEmpty(input)) {
markError(input);
} else {
clearError(input);
}
}
}
function handleClick(e: Event) {
const target = e.target as HTMLElement;
if (!isSaveButton(target)) return;
const fields = findRequiredFields();
if (fields.size === 0) return;
let firstEmpty: TargetEl | null = null;
let firstEmptyLabel = "";
for (const [input, label] of fields) {
if (isEmpty(input)) {
markError(input);
if (!firstEmpty) {
firstEmpty = input;
firstEmptyLabel = label;
}
} else {
clearError(input);
}
}
if (!firstEmpty) return;
activated = true;
e.stopPropagation();
e.preventDefault();
highlightField(firstEmpty);
toast.error(`${firstEmptyLabel} 항목을 입력해주세요`);
}
// V2Select는 input/change 이벤트가 없으므로 DOM 변경 감지로 에러 동기화
const observer = new MutationObserver(syncErrors);
observer.observe(el, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-placeholder"] });
el.addEventListener("click", handleClick, true);
el.addEventListener("input", syncErrors);
el.addEventListener("change", syncErrors);
return () => {
el.removeEventListener("click", handleClick, true);
el.removeEventListener("input", syncErrors);
el.removeEventListener("change", syncErrors);
observer.disconnect();
el.querySelectorAll(`[${HIGHLIGHT_ATTR}]`).forEach((node) => node.removeAttribute(HIGHLIGHT_ATTR));
el.querySelectorAll(`[${ERROR_ATTR}]`).forEach((node) => node.removeAttribute(ERROR_ATTR));
el.querySelectorAll(`.${MSG_WRAPPER_CLASS}`).forEach((node) => node.remove());
};
}, [mode, contentEl]);
}

View File

@ -0,0 +1,31 @@
"use client";
import { useState, useEffect } from "react";
/**
* .
* TabContent가 registerModalPortal(el) ,
* useModalPortal() .
* React .
*/
let _container: HTMLElement | null = null;
const _subscribers = new Set<(el: HTMLElement | null) => void>();
export function registerModalPortal(el: HTMLElement | null) {
_container = el;
_subscribers.forEach((fn) => fn(el));
}
export function useModalPortal(): HTMLElement | null {
const [el, setEl] = useState<HTMLElement | null>(_container);
useEffect(() => {
setEl(_container);
_subscribers.add(setEl);
return () => {
_subscribers.delete(setEl);
};
}, []);
return el;
}

View File

@ -8,6 +8,92 @@ import { filterDOMProps } from "@/lib/utils/domPropsFilter";
// 통합 폼 시스템 import // 통합 폼 시스템 import
import { useV2FormOptional } from "@/components/v2/V2FormContext"; import { useV2FormOptional } from "@/components/v2/V2FormContext";
import { apiClient } from "@/lib/api/client";
// 컬럼 메타데이터 캐시 (테이블명 → 컬럼 설정 맵)
const columnMetaCache: Record<string, Record<string, any>> = {};
const columnMetaLoading: Record<string, Promise<void>> = {};
async function loadColumnMeta(tableName: string): Promise<void> {
if (columnMetaCache[tableName]) return;
// 이미 로딩 중이면 해당 Promise를 대기 (race condition 방지)
if (columnMetaLoading[tableName]) {
await columnMetaLoading[tableName];
return;
}
columnMetaLoading[tableName] = (async () => {
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=1000`);
const data = response.data.data || response.data;
const columns = data.columns || data || [];
const map: Record<string, any> = {};
for (const col of columns) {
const name = col.column_name || col.columnName;
if (name) map[name] = col;
}
columnMetaCache[tableName] = map;
} catch (e) {
console.error(`[columnMeta] ${tableName} 로드 실패:`, e);
columnMetaCache[tableName] = {};
} finally {
delete columnMetaLoading[tableName];
}
})();
await columnMetaLoading[tableName];
}
// 테이블 타입관리 NOT NULL 기반 필수 여부 판단
export function isColumnRequiredByMeta(tableName?: string, columnName?: string): boolean {
if (!tableName || !columnName) return false;
const meta = columnMetaCache[tableName]?.[columnName];
if (!meta) return false;
const nullable = meta.is_nullable || meta.isNullable;
return nullable === "NO" || nullable === "N";
}
// table_type_columns 기반 componentConfig 병합 (기존 설정이 없을 때만 DB 메타데이터로 보완)
function mergeColumnMeta(tableName: string | undefined, columnName: string | undefined, componentConfig: any): any {
if (!tableName || !columnName) return componentConfig;
const meta = columnMetaCache[tableName]?.[columnName];
if (!meta) return componentConfig;
const inputType = meta.input_type || meta.inputType;
if (!inputType) return componentConfig;
// 이미 source가 올바르게 설정된 경우 건드리지 않음
const existingSource = componentConfig?.source;
if (existingSource && existingSource !== "static" && existingSource !== "distinct" && existingSource !== "select") {
return componentConfig;
}
const merged = { ...componentConfig };
// source가 미설정/기본값일 때만 DB 메타데이터로 보완
if (inputType === "entity") {
const refTable = meta.reference_table || meta.referenceTable;
const refColumn = meta.reference_column || meta.referenceColumn;
const displayCol = meta.display_column || meta.displayColumn;
if (refTable && !merged.entityTable) {
merged.source = "entity";
merged.entityTable = refTable;
merged.entityValueColumn = refColumn || "id";
merged.entityLabelColumn = displayCol || "name";
}
} else if (inputType === "category" && !existingSource) {
merged.source = "category";
} else if (inputType === "select" && !existingSource) {
const detail = typeof meta.detail_settings === "string" ? JSON.parse(meta.detail_settings || "{}") : (meta.detail_settings || {});
if (detail.options && !merged.options?.length) {
merged.options = detail.options;
}
}
return merged;
}
// 컴포넌트 렌더러 인터페이스 // 컴포넌트 렌더러 인터페이스
export interface ComponentRenderer { export interface ComponentRenderer {
@ -175,6 +261,15 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
children, children,
...props ...props
}) => { }) => {
// 컬럼 메타데이터 로드 트리거 (테이블명이 있으면 비동기 로드)
const screenTableName = props.tableName || (component as any).tableName;
const [, forceUpdate] = React.useState(0);
React.useEffect(() => {
if (screenTableName) {
loadColumnMeta(screenTableName).then(() => forceUpdate((v) => v + 1));
}
}, [screenTableName]);
// 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용 // 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용
// 🆕 V2 레이아웃의 경우 url에서 컴포넌트 타입 추출 (예: "@/lib/registry/components/v2-input" → "v2-input") // 🆕 V2 레이아웃의 경우 url에서 컴포넌트 타입 추출 (예: "@/lib/registry/components/v2-input" → "v2-input")
const extractTypeFromUrl = (url: string | undefined): string | undefined => { const extractTypeFromUrl = (url: string | undefined): string | undefined => {
@ -184,8 +279,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
return segments[segments.length - 1]; return segments[segments.length - 1];
}; };
const rawComponentType = const rawComponentType = (component as any).componentType || component.type || extractTypeFromUrl((component as any).url);
(component as any).componentType || component.type || extractTypeFromUrl((component as any).url);
// 레거시 타입을 v2 컴포넌트로 매핑 (v2 컴포넌트가 없으면 원본 유지) // 레거시 타입을 v2 컴포넌트로 매핑 (v2 컴포넌트가 없으면 원본 유지)
const mapToV2ComponentType = (type: string | undefined): string | undefined => { const mapToV2ComponentType = (type: string | undefined): string | undefined => {
@ -210,8 +304,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 🆕 조건부 렌더링 체크 (conditionalConfig) // 🆕 조건부 렌더링 체크 (conditionalConfig)
// componentConfig 또는 overrides에서 conditionalConfig를 가져와서 formData와 비교 // componentConfig 또는 overrides에서 conditionalConfig를 가져와서 formData와 비교
const conditionalConfig = const conditionalConfig = (component as any).componentConfig?.conditionalConfig || (component as any).overrides?.conditionalConfig;
(component as any).componentConfig?.conditionalConfig || (component as any).overrides?.conditionalConfig;
// 조건부 렌더링 처리 // 조건부 렌더링 처리
if (conditionalConfig?.enabled && props.formData) { if (conditionalConfig?.enabled && props.formData) {
@ -279,24 +372,33 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 1. componentType이 "select-basic" 또는 "v2-select"인 경우 // 1. componentType이 "select-basic" 또는 "v2-select"인 경우
// 2. config.mode가 dropdown이 아닌 경우 (radio, check, tagbox 등) // 2. config.mode가 dropdown이 아닌 경우 (radio, check, tagbox 등)
const componentMode = (component as any).componentConfig?.mode || (component as any).config?.mode; 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 nonDropdownModes = ["radio", "check", "checkbox", "tag", "tagbox", "toggle", "swap", "combobox"];
const isNonDropdownMode = componentMode && nonDropdownModes.includes(componentMode); 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") && tableName && columnName && shouldUseV2Select) { if (
(inputType === "category" || webType === "category") &&
tableName &&
columnName &&
shouldUseV2Select
) {
// V2SelectRenderer로 직접 렌더링 (카테고리 + 고급 모드) // V2SelectRenderer로 직접 렌더링 (카테고리 + 고급 모드)
try { try {
const { V2SelectRenderer } = require("@/lib/registry/components/v2-select/V2SelectRenderer"); const { V2SelectRenderer } = require("@/lib/registry/components/v2-select/V2SelectRenderer");
const fieldName = columnName || component.id; const fieldName = columnName || component.id;
const currentValue = props.formData?.[fieldName] || "";
const handleChange = (value: any) => { // 수평 라벨 감지
if (props.onFormDataChange) { const catLabelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay;
props.onFormDataChange(fieldName, value); 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")
);
// V2SelectRenderer용 컴포넌트 데이터 구성
const selectComponent = { const selectComponent = {
...component, ...component,
componentConfig: { componentConfig: {
@ -312,6 +414,24 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
webType: "category", 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 = { const rendererProps = {
component: selectComponent, component: selectComponent,
formData: props.formData, formData: props.formData,
@ -319,12 +439,46 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
isDesignMode: props.isDesignMode, isDesignMode: props.isDesignMode,
isInteractive: props.isInteractive ?? !props.isDesignMode, isInteractive: props.isInteractive ?? !props.isDesignMode,
tableName, tableName,
style: (component as any).style, style: catStyle,
size: (component as any).size, size: catSize,
}; };
const rendererInstance = new V2SelectRenderer(rendererProps); 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 || isColumnRequiredByMeta(tableName, columnName);
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">*</span>}
</label>
<div style={{ width: "100%", height: "100%" }}>
{renderedCatSelect}
</div>
</div>
);
}
return renderedCatSelect;
} catch (error) { } catch (error) {
console.error("❌ V2SelectRenderer 로드 실패:", error); console.error("❌ V2SelectRenderer 로드 실패:", error);
} }
@ -457,19 +611,18 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const safeProps = filterDOMProps(restProps); const safeProps = filterDOMProps(restProps);
// 컴포넌트의 columnName에 해당하는 formData 값 추출 // 컴포넌트의 columnName에 해당하는 formData 값 추출
const fieldName = const fieldName = (component as any).columnName || (component as any).componentConfig?.columnName || component.id;
(component as any).columnName || (component as any).componentConfig?.columnName || component.id;
// 다중 레코드를 다루는 컴포넌트는 배열 데이터로 초기화 // 다중 레코드를 다루는 컴포넌트는 배열 데이터로 초기화
let currentValue; let currentValue;
if ( if (componentType === "modal-repeater-table" ||
componentType === "modal-repeater-table" || componentType === "repeat-screen-modal" ||
componentType === "repeat-screen-modal" || componentType === "selected-items-detail-input") {
componentType === "selected-items-detail-input" ||
componentType === "v2-repeater"
) {
// EditModal/ScreenModal에서 전달된 groupedData가 있으면 우선 사용 // EditModal/ScreenModal에서 전달된 groupedData가 있으면 우선 사용
currentValue = props.groupedData || formData?.[fieldName] || []; currentValue = props.groupedData || formData?.[fieldName] || [];
} else if (componentType === "v2-repeater") {
// V2Repeater는 자체 데이터 관리 (groupedData는 메인 테이블 레코드이므로 사용하지 않음)
currentValue = formData?.[fieldName] || [];
} else { } else {
currentValue = formData?.[fieldName] || ""; currentValue = formData?.[fieldName] || "";
} }
@ -537,55 +690,90 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
componentType === "modal-repeater-table" || componentType === "modal-repeater-table" ||
componentType === "v2-input"; componentType === "v2-input";
// 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true일 때만 라벨 표시) // 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true/"true"일 때만 라벨 표시)
const labelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay; const labelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay;
const effectiveLabel = const effectiveLabel = (labelDisplay === true || labelDisplay === "true")
labelDisplay === true ? (component.style?.labelText || (component as any).label || component.componentConfig?.label)
? component.style?.labelText || (component as any).label || component.componentConfig?.label : undefined;
: 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 기반으로 덮어씀 // 🔧 순서 중요! component.style 먼저, CSS 크기 속성은 size 기반으로 덮어씀
const mergedStyle = { const mergedStyle = {
...component.style, // 원본 style (labelDisplay, labelText 등) - 먼저! ...component.style, // 원본 style (labelDisplay, labelText 등) - 먼저!
// CSS 크기 속성은 size에서 계산한 값으로 명시적 덮어쓰기 (우선순위 최고) // CSS 크기 속성은 size에서 계산한 값으로 명시적 덮어쓰기 (우선순위 최고)
width: finalStyle.width, width: finalStyle.width,
height: finalStyle.height, 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 최신 설정 우선)
const isEntityJoinColumn = fieldName?.includes(".");
const baseColumnName = isEntityJoinColumn ? undefined : fieldName;
const mergedComponentConfig = mergeColumnMeta(screenTableName, baseColumnName, component.componentConfig || {});
// NOT NULL 기반 필수 여부를 component.required에 반영
const notNullRequired = isColumnRequiredByMeta(screenTableName, baseColumnName);
const effectiveRequired = component.required || notNullRequired;
// 엔티티 조인 컬럼은 런타임에서 readonly/disabled 강제 해제
const effectiveComponent = isEntityJoinColumn
? { ...component, componentConfig: mergedComponentConfig, readonly: false, required: effectiveRequired }
: { ...component, componentConfig: mergedComponentConfig, required: effectiveRequired };
const rendererProps = { const rendererProps = {
component, component: effectiveComponent,
isSelected, isSelected,
onClick, onClick,
onDragStart, onDragStart,
onDragEnd, onDragEnd,
size: component.size || newComponent.defaultSize, size: needsExternalHorizLabel
? { ...(component.size || newComponent.defaultSize), width: undefined, height: undefined }
: (component.size || newComponent.defaultSize),
position: component.position, position: component.position,
config: component.componentConfig, config: mergedComponentConfig,
componentConfig: component.componentConfig, componentConfig: mergedComponentConfig,
// componentConfig의 모든 속성을 props로 spread (tableName, displayField 등) // componentConfig의 모든 속성을 props로 spread (tableName, displayField 등)
...(component.componentConfig || {}), ...(mergedComponentConfig || {}),
// 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선) // 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선)
style: mergedStyle, style: mergedStyle,
// 🆕 라벨 표시 (labelDisplay가 true일 때만) // 수평 라벨 → 외부에서 처리하므로 label 전달 안 함
label: effectiveLabel, label: needsExternalHorizLabel ? undefined : effectiveLabel,
// 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 // NOT NULL 메타데이터 포함된 필수 여부 (V2Hierarchy 등 직접 props.required 참조하는 컴포넌트용)
inputType: (component as any).inputType || component.componentConfig?.inputType, required: effectiveRequired,
// 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 (DB 메타데이터 우선)
inputType: (baseColumnName && columnMetaCache[screenTableName || ""]?.[baseColumnName]?.input_type) || (component as any).inputType || mergedComponentConfig?.inputType,
columnName: (component as any).columnName || component.componentConfig?.columnName, columnName: (component as any).columnName || component.componentConfig?.columnName,
value: currentValue, // formData에서 추출한 현재 값 전달 value: currentValue, // formData에서 추출한 현재 값 전달
// 새로운 기능들 전달 // 새로운 기능들 전달
// 🆕 webTypeConfig.numberingRuleId가 있으면 autoGeneration으로 변환 // 🆕 webTypeConfig.numberingRuleId가 있으면 autoGeneration으로 변환
autoGeneration: autoGeneration: component.autoGeneration ||
component.autoGeneration ||
component.componentConfig?.autoGeneration || component.componentConfig?.autoGeneration ||
((component as any).webTypeConfig?.numberingRuleId ((component as any).webTypeConfig?.numberingRuleId ? {
? { type: "numbering_rule" as const,
type: "numbering_rule" as const, enabled: true,
enabled: true, options: {
options: { numberingRuleId: (component as any).webTypeConfig.numberingRuleId,
numberingRuleId: (component as any).webTypeConfig.numberingRuleId, },
}, } : undefined),
}
: undefined),
hidden: hiddenValue, hidden: hiddenValue,
// React 전용 props들은 직접 전달 (DOM에 전달되지 않음) // React 전용 props들은 직접 전달 (DOM에 전달되지 않음)
isInteractive, isInteractive,
@ -612,9 +800,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// componentConfig.mode가 있으면 유지 (entity-search-input의 UI 모드) // componentConfig.mode가 있으면 유지 (entity-search-input의 UI 모드)
mode: component.componentConfig?.mode || mode, mode: component.componentConfig?.mode || mode,
isInModal, isInModal,
readonly: component.readonly, readonly: isEntityJoinColumn ? false : component.readonly,
// 🆕 disabledFields 체크 또는 기존 readonly disabled: isEntityJoinColumn ? false : (disabledFields?.includes(fieldName) || component.readonly),
disabled: disabledFields?.includes(fieldName) || component.readonly,
originalData, originalData,
allComponents, allComponents,
onUpdateLayout, onUpdateLayout,
@ -649,8 +836,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 🆕 UniversalFormModal용 initialData 전달 // 🆕 UniversalFormModal용 initialData 전달
// 우선순위: props.initialData > originalData > formData // 우선순위: props.initialData > originalData > formData
// 조건부 컨테이너에서 전달된 initialData가 있으면 그것을 사용 // 조건부 컨테이너에서 전달된 initialData가 있으면 그것을 사용
_initialData: _initialData: props.initialData || ((originalData && Object.keys(originalData).length > 0) ? originalData : formData),
props.initialData || (originalData && Object.keys(originalData).length > 0 ? originalData : formData),
_originalData: originalData, _originalData: originalData,
// 🆕 initialData도 직접 전달 (조건부 컨테이너 → 내부 컴포넌트) // 🆕 initialData도 직접 전달 (조건부 컨테이너 → 내부 컴포넌트)
initialData: props.initialData, initialData: props.initialData,
@ -673,16 +859,50 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
NewComponentRenderer.prototype && NewComponentRenderer.prototype &&
NewComponentRenderer.prototype.render; NewComponentRenderer.prototype.render;
let renderedElement: React.ReactElement;
if (isClass) { if (isClass) {
// 클래스 기반 렌더러 (AutoRegisteringComponentRenderer 상속)
const rendererInstance = new NewComponentRenderer(rendererProps); const rendererInstance = new NewComponentRenderer(rendererProps);
return rendererInstance.render(); renderedElement = rendererInstance.render();
} else { } else {
// 함수형 컴포넌트 renderedElement = <NewComponentRenderer key={refreshKey} {...rendererProps} />;
// refreshKey를 React key로 전달하여 컴포넌트 리마운트 강제
return <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 = effectiveComponent.required || isColumnRequiredByMeta(screenTableName, baseColumnName);
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">*</span>}
</label>
<div style={{ width: "100%", height: "100%" }}>
{renderedElement}
</div>
</div>
);
}
return renderedElement;
} }
} catch (error) { } catch (error) {
console.error(`❌ 새 컴포넌트 렌더링 실패 (${componentType}):`, error); console.error(`❌ 새 컴포넌트 렌더링 실패 (${componentType}):`, error);

View File

@ -3,6 +3,7 @@
import React, { useState, useEffect, useMemo } from "react"; import React, { useState, useEffect, useMemo } from "react";
import { ComponentRendererProps } from "@/types/component"; import { ComponentRendererProps } from "@/types/component";
import { AggregationWidgetConfig, AggregationItem, AggregationResult, AggregationType } from "./types"; import { AggregationWidgetConfig, AggregationItem, AggregationResult, AggregationType } from "./types";
import { formatNumber } from "@/lib/formatting";
import { Calculator, TrendingUp, Hash, ArrowUp, ArrowDown } from "lucide-react"; import { Calculator, TrendingUp, Hash, ArrowUp, ArrowDown } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
@ -136,11 +137,11 @@ export function AggregationWidgetComponent({
let formattedValue = value.toFixed(item.decimalPlaces ?? 0); let formattedValue = value.toFixed(item.decimalPlaces ?? 0);
if (item.format === "currency") { if (item.format === "currency") {
formattedValue = new Intl.NumberFormat("ko-KR").format(value); formattedValue = formatNumber(value);
} else if (item.format === "percent") { } else if (item.format === "percent") {
formattedValue = `${(value * 100).toFixed(item.decimalPlaces ?? 1)}%`; formattedValue = `${(value * 100).toFixed(item.decimalPlaces ?? 1)}%`;
} else if (item.format === "number") { } else if (item.format === "number") {
formattedValue = new Intl.NumberFormat("ko-KR").format(value); formattedValue = formatNumber(value);
} }
if (item.prefix) { if (item.prefix) {

View File

@ -28,7 +28,6 @@ import { useScreenContextOptional } from "@/contexts/ScreenContext";
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext"; import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
import { applyMappingRules } from "@/lib/utils/dataMapping"; import { applyMappingRules } from "@/lib/utils/dataMapping";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
export interface ButtonPrimaryComponentProps extends ComponentRendererProps { export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
config?: ButtonPrimaryConfig; config?: ButtonPrimaryConfig;
// 추가 props // 추가 props
@ -614,27 +613,16 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 실패한 경우 오류 처리 // 실패한 경우 오류 처리
if (!success) { if (!success) {
// UI 전환 액션 및 모달 액션은 에러도 조용히 처리 (모달 내부에서 자체 에러 표시) // UI 전환 액션 및 모달 액션은 에러도 조용히 처리 (모달 내부에서 자체 에러 표시)
const silentErrorActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"]; const silentErrorActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan", "save", "delete", "quickInsert"];
if (silentErrorActions.includes(actionConfig.type)) { if (silentErrorActions.includes(actionConfig.type)) {
return; return;
} }
// 기본 에러 메시지 결정
const defaultErrorMessage = const defaultErrorMessage =
actionConfig.type === "save" actionConfig.type === "submit"
? "저장 중 오류가 발생했습니다." ? "제출 중 오류가 발생했습니다."
: actionConfig.type === "delete" : "처리 중 오류가 발생했습니다.";
? "삭제 중 오류가 발생했습니다."
: actionConfig.type === "submit"
? "제출 중 오류가 발생했습니다."
: "처리 중 오류가 발생했습니다.";
// 커스텀 메시지 사용 조건: const errorMessage = actionConfig.errorMessage || defaultErrorMessage;
// 1. 커스텀 메시지가 있고
// 2. (액션 타입이 save이거나 OR 메시지에 "저장"이 포함되지 않은 경우)
const useCustomMessage =
actionConfig.errorMessage && (actionConfig.type === "save" || !actionConfig.errorMessage.includes("저장"));
const errorMessage = useCustomMessage ? actionConfig.errorMessage : defaultErrorMessage;
toast.error(errorMessage); toast.error(errorMessage);
return; return;
@ -1259,7 +1247,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
} }
} }
// 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화 + 행 선택 필수)
const finalDisabled = const finalDisabled =
componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading; componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading;

View File

@ -140,15 +140,16 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
// 삭제할 데이터를 배열로 감싸기 (API가 배열을 기대함) // 삭제할 데이터를 배열로 감싸기 (API가 배열을 기대함)
const deleteData = [data]; const deleteData = [data];
// API 호출로 데이터 삭제 (POST 방식으로 변경 - DELETE는 body 전달이 불안정) // API 호출로 데이터 삭제 (POST 방식으로 변경 - DELETE는 body 전달이 불안정)
// 백엔드 API는 DELETE /api/table-management/tables/:tableName/delete 이지만 // 백엔드 API는 DELETE /api/table-management/tables/:tableName/delete 이지만
// axios에서 DELETE body 전달 문제가 있어 직접 request 설정 사용 // axios에서 DELETE body 전달 문제가 있어 직접 request 설정 사용
const response = await apiClient.request({ const response = await apiClient.request({
method: "DELETE", method: 'DELETE',
url: `/table-management/tables/${tableNameToUse}/delete`, url: `/table-management/tables/${tableNameToUse}/delete`,
data: deleteData, data: deleteData,
headers: { headers: {
"Content-Type": "application/json", 'Content-Type': 'application/json',
}, },
}); });
@ -156,7 +157,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
alert("삭제되었습니다."); alert("삭제되었습니다.");
// 로컬 상태에서 삭제된 항목 제거 // 로컬 상태에서 삭제된 항목 제거
setLoadedTableData((prev) => prev.filter((item, idx) => idx !== index)); setLoadedTableData(prev => prev.filter((item, idx) => idx !== index));
// 선택된 항목이면 선택 해제 // 선택된 항목이면 선택 해제
const cardKey = getCardKey(data, index); const cardKey = getCardKey(data, index);
@ -178,7 +179,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
const handleEditFormChange = (key: string, value: string) => { const handleEditFormChange = (key: string, value: string) => {
setEditData((prev: any) => ({ setEditData((prev: any) => ({
...prev, ...prev,
[key]: value, [key]: value
})); }));
}; };
@ -199,6 +200,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
// 데이터 새로고침 (필요시) // 데이터 새로고침 (필요시)
// loadTableData(); // loadTableData();
} catch (error) { } catch (error) {
alert("저장에 실패했습니다."); alert("저장에 실패했습니다.");
} }
@ -217,8 +219,8 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
// 우측 패널인 경우, 좌측 데이터가 선택되지 않으면 데이터 로드하지 않음 (깜빡임 방지) // 우측 패널인 경우, 좌측 데이터가 선택되지 않으면 데이터 로드하지 않음 (깜빡임 방지)
// splitPanelPosition이 "right"이면 분할 패널 내부이므로 연결 필터가 있을 가능성이 높음 // splitPanelPosition이 "right"이면 분할 패널 내부이므로 연결 필터가 있을 가능성이 높음
const isRightPanelEarly = splitPanelPosition === "right"; const isRightPanelEarly = splitPanelPosition === "right";
const hasSelectedLeftDataEarly = const hasSelectedLeftDataEarly = splitPanelContext?.selectedLeftData &&
splitPanelContext?.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0; Object.keys(splitPanelContext.selectedLeftData).length > 0;
if (isRightPanelEarly && !hasSelectedLeftDataEarly) { if (isRightPanelEarly && !hasSelectedLeftDataEarly) {
// 우측 패널이고 좌측 데이터가 선택되지 않은 경우 - 기존 데이터 유지 (깜빡임 방지) // 우측 패널이고 좌측 데이터가 선택되지 않은 경우 - 기존 데이터 유지 (깜빡임 방지)
@ -232,7 +234,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
} }
// tableName 확인 (props에서 전달받은 tableName 사용) // tableName 확인 (props에서 전달받은 tableName 사용)
const tableNameToUse = tableName || component.componentConfig?.tableName || "user_info"; // 기본 테이블명 설정 const tableNameToUse = tableName || component.componentConfig?.tableName || 'user_info'; // 기본 테이블명 설정
if (!tableNameToUse) { if (!tableNameToUse) {
setLoading(false); setLoading(false);
@ -249,12 +251,13 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
// 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지) // 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지)
const linkedFiltersConfig = splitPanelContext.linkedFilters || []; const linkedFiltersConfig = splitPanelContext.linkedFilters || [];
hasLinkedFiltersConfigured = linkedFiltersConfig.some( hasLinkedFiltersConfigured = linkedFiltersConfig.some(
(filter) => filter.targetColumn?.startsWith(tableNameToUse + ".") || filter.targetColumn === tableNameToUse, (filter) => filter.targetColumn?.startsWith(tableNameToUse + ".") ||
filter.targetColumn === tableNameToUse
); );
// 좌측 데이터 선택 여부 확인 // 좌측 데이터 선택 여부 확인
hasSelectedLeftData = hasSelectedLeftData = splitPanelContext.selectedLeftData &&
splitPanelContext.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0; Object.keys(splitPanelContext.selectedLeftData).length > 0;
linkedFilterValues = splitPanelContext.getLinkedFilterValues(); linkedFilterValues = splitPanelContext.getLinkedFilterValues();
// 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서) // 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서)
@ -275,19 +278,20 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
} }
} }
linkedFilterValues = tableSpecificFilters; linkedFilterValues = tableSpecificFilters;
} }
// 우측 패널이고 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우 빈 데이터 표시 // 우측 패널이고 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우 빈 데이터 표시
// 또는 우측 패널이고 linkedFilters 설정이 있으면 좌측 선택 필수 // 또는 우측 패널이고 linkedFilters 설정이 있으면 좌측 선택 필수
// splitPanelPosition은 screenContext에서 가져오거나, splitPanelContext에서 screenId로 확인 // splitPanelPosition은 screenContext에서 가져오거나, splitPanelContext에서 screenId로 확인
const isRightPanelFromContext = splitPanelPosition === "right"; const isRightPanelFromContext = splitPanelPosition === "right";
const isRightPanelFromSplitContext = const isRightPanelFromSplitContext = screenId && splitPanelContext?.getPositionByScreenId
screenId && splitPanelContext?.getPositionByScreenId ? splitPanelContext.getPositionByScreenId(screenId as number) === "right"
? splitPanelContext.getPositionByScreenId(screenId as number) === "right" : false;
: false;
const isRightPanel = isRightPanelFromContext || isRightPanelFromSplitContext; const isRightPanel = isRightPanelFromContext || isRightPanelFromSplitContext;
const hasLinkedFiltersInConfig = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0; const hasLinkedFiltersInConfig = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0;
if (isRightPanel && (hasLinkedFiltersConfigured || hasLinkedFiltersInConfig) && !hasSelectedLeftData) { if (isRightPanel && (hasLinkedFiltersConfigured || hasLinkedFiltersInConfig) && !hasSelectedLeftData) {
setLoadedTableData([]); setLoadedTableData([]);
setLoading(false); setLoading(false);
@ -357,23 +361,24 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
.filter(([_, m]) => m.inputType === "category") .filter(([_, m]) => m.inputType === "category")
.map(([columnName]) => columnName); .map(([columnName]) => columnName);
if (categoryColumns.length > 0) { if (categoryColumns.length > 0) {
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {}; const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
for (const columnName of categoryColumns) { for (const columnName of categoryColumns) {
try { try {
const response = await apiClient.get(`/table-categories/${tableNameToUse}/${columnName}/values`); const response = await apiClient.get(`/table-categories/${tableNameToUse}/${columnName}/values?includeInactive=true`);
if (response.data.success && response.data.data) { if (response.data.success && response.data.data) {
const mapping: Record<string, { label: string; color?: string }> = {}; const mapping: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => { response.data.data.forEach((item: any) => {
// API 응답 형식: valueCode, valueLabel (camelCase) // API 응답 형식: valueCode, valueLabel (camelCase)
const code = item.valueCode || item.value_code || item.category_code || item.code || item.value; const code = item.valueCode || item.value_code || item.category_code || item.code || item.value;
const label = const label = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code;
item.valueLabel || item.value_label || item.category_name || item.name || item.label || code;
// color가 null/undefined/"none"이면 undefined로 유지 (배지 없음) // color가 null/undefined/"none"이면 undefined로 유지 (배지 없음)
const rawColor = item.color ?? item.badge_color; const rawColor = item.color ?? item.badge_color;
const color = rawColor && rawColor !== "none" ? rawColor : undefined; const color = (rawColor && rawColor !== "none") ? rawColor : undefined;
mapping[code] = { label, color }; mapping[code] = { label, color };
}); });
mappings[columnName] = mapping; mappings[columnName] = mapping;
@ -395,14 +400,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
}; };
loadTableData(); loadTableData();
}, [ }, [isDesignMode, tableName, component.componentConfig?.tableName, splitPanelContext?.selectedLeftData, splitPanelPosition, refreshKey]);
isDesignMode,
tableName,
component.componentConfig?.tableName,
splitPanelContext?.selectedLeftData,
splitPanelPosition,
refreshKey,
]);
// 컴포넌트 설정 (기본값 보장) // 컴포넌트 설정 (기본값 보장)
const componentConfig = { const componentConfig = {
@ -443,13 +441,12 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
} }
// 우측 패널 + 좌측 미선택 상태 체크를 위한 값들 (displayData 외부에서 계산) // 우측 패널 + 좌측 미선택 상태 체크를 위한 값들 (displayData 외부에서 계산)
const isRightPanelForDisplay = const isRightPanelForDisplay = splitPanelPosition === "right" ||
splitPanelPosition === "right" ||
(screenId && splitPanelContext?.getPositionByScreenId?.(screenId as number) === "right"); (screenId && splitPanelContext?.getPositionByScreenId?.(screenId as number) === "right");
const hasLinkedFiltersForDisplay = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0; const hasLinkedFiltersForDisplay = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0;
const selectedLeftDataForDisplay = splitPanelContext?.selectedLeftData; const selectedLeftDataForDisplay = splitPanelContext?.selectedLeftData;
const hasSelectedLeftDataForDisplay = const hasSelectedLeftDataForDisplay = selectedLeftDataForDisplay &&
selectedLeftDataForDisplay && Object.keys(selectedLeftDataForDisplay).length > 0; Object.keys(selectedLeftDataForDisplay).length > 0;
// 좌측 데이터가 한 번이라도 선택된 적이 있으면 기록 // 좌측 데이터가 한 번이라도 선택된 적이 있으면 기록
useEffect(() => { useEffect(() => {
@ -460,8 +457,9 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
// 우측 패널이고 연결 필터가 있고, 좌측 데이터가 한 번도 선택된 적이 없는 경우에만 "선택해주세요" 표시 // 우측 패널이고 연결 필터가 있고, 좌측 데이터가 한 번도 선택된 적이 없는 경우에만 "선택해주세요" 표시
// 한 번이라도 선택된 적이 있으면 깜빡임 방지를 위해 기존 데이터 유지 // 한 번이라도 선택된 적이 있으면 깜빡임 방지를 위해 기존 데이터 유지
const shouldHideDataForRightPanel = const shouldHideDataForRightPanel = isRightPanelForDisplay &&
isRightPanelForDisplay && !hasEverSelectedLeftData && !hasSelectedLeftDataForDisplay; !hasEverSelectedLeftData &&
!hasSelectedLeftDataForDisplay;
// 표시할 데이터 결정 (로드된 테이블 데이터 우선 사용) // 표시할 데이터 결정 (로드된 테이블 데이터 우선 사용)
const displayData = useMemo(() => { const displayData = useMemo(() => {
@ -497,100 +495,87 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
}, []); }, []);
// 카드 선택 핸들러 (단일 선택 - 다른 카드 선택 시 기존 선택 해제) // 카드 선택 핸들러 (단일 선택 - 다른 카드 선택 시 기존 선택 해제)
const handleCardSelection = useCallback( const handleCardSelection = useCallback((cardKey: string, data: any, checked: boolean) => {
(cardKey: string, data: any, checked: boolean) => { // 단일 선택: 새로운 Set 생성 (기존 선택 초기화)
// 단일 선택: 새로운 Set 생성 (기존 선택 초기화) const newSelectedRows = new Set<string>();
const newSelectedRows = new Set<string>();
if (checked) {
// 선택 시 해당 카드만 선택
newSelectedRows.add(cardKey);
}
// checked가 false면 빈 Set (선택 해제)
setSelectedRows(newSelectedRows);
// 선택된 카드 데이터 계산
const selectedRowsData = displayData.filter((item, index) =>
newSelectedRows.has(getCardKey(item, index))
);
// onFormDataChange 호출
if (onFormDataChange) {
onFormDataChange({
selectedRows: Array.from(newSelectedRows),
selectedRowsData,
});
}
// modalDataStore에 선택된 데이터 저장
const tableNameToUse = componentConfig.dataSource?.tableName || tableName;
if (tableNameToUse && selectedRowsData.length > 0) {
const modalItems = selectedRowsData.map((row, idx) => ({
id: getCardKey(row, idx),
originalData: row,
additionalData: {},
}));
useModalDataStore.getState().setData(tableNameToUse, modalItems);
} else if (tableNameToUse && selectedRowsData.length === 0) {
useModalDataStore.getState().clearData(tableNameToUse);
}
// 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달)
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
if (checked) { if (checked) {
// 선택 시 해당 카드만 선택 splitPanelContext.setSelectedLeftData(data);
newSelectedRows.add(cardKey); } else {
splitPanelContext.setSelectedLeftData(null);
} }
// checked가 false면 빈 Set (선택 해제) }
}, [displayData, getCardKey, onFormDataChange, componentConfig.dataSource?.tableName, tableName, splitPanelContext, splitPanelPosition]);
setSelectedRows(newSelectedRows); const handleCardClick = useCallback((data: any, index: number) => {
const cardKey = getCardKey(data, index);
const isCurrentlySelected = selectedRows.has(cardKey);
// 선택된 카드 데이터 계산 // 단일 선택: 이미 선택된 카드 클릭 시 선택 해제, 아니면 새로 선택
const selectedRowsData = displayData.filter((item, index) => newSelectedRows.has(getCardKey(item, index))); handleCardSelection(cardKey, data, !isCurrentlySelected);
// onFormDataChange 호출 if (componentConfig.onCardClick) {
if (onFormDataChange) { componentConfig.onCardClick(data);
onFormDataChange({ }
selectedRows: Array.from(newSelectedRows), }, [getCardKey, selectedRows, handleCardSelection, componentConfig.onCardClick]);
selectedRowsData,
});
}
// modalDataStore에 선택된 데이터 저장
const tableNameToUse = componentConfig.dataSource?.tableName || tableName;
if (tableNameToUse && selectedRowsData.length > 0) {
const modalItems = selectedRowsData.map((row, idx) => ({
id: getCardKey(row, idx),
originalData: row,
additionalData: {},
}));
useModalDataStore.getState().setData(tableNameToUse, modalItems);
} else if (tableNameToUse && selectedRowsData.length === 0) {
useModalDataStore.getState().clearData(tableNameToUse);
}
// 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달)
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
if (checked) {
splitPanelContext.setSelectedLeftData(data);
} else {
splitPanelContext.setSelectedLeftData(null);
}
}
},
[
displayData,
getCardKey,
onFormDataChange,
componentConfig.dataSource?.tableName,
tableName,
splitPanelContext,
splitPanelPosition,
],
);
const handleCardClick = useCallback(
(data: any, index: number) => {
const cardKey = getCardKey(data, index);
const isCurrentlySelected = selectedRows.has(cardKey);
// 단일 선택: 이미 선택된 카드 클릭 시 선택 해제, 아니면 새로 선택
handleCardSelection(cardKey, data, !isCurrentlySelected);
if (componentConfig.onCardClick) {
componentConfig.onCardClick(data);
}
},
[getCardKey, selectedRows, handleCardSelection, componentConfig.onCardClick],
);
// DataProvidable 인터페이스 구현 (테이블 리스트와 동일) // DataProvidable 인터페이스 구현 (테이블 리스트와 동일)
const dataProvider = useMemo( const dataProvider = useMemo(() => ({
() => ({ componentId: component.id,
componentId: component.id, componentType: "card-display" as const,
componentType: "card-display" as const,
getSelectedData: () => { getSelectedData: () => {
const selectedData = displayData.filter((item, index) => selectedRows.has(getCardKey(item, index))); const selectedData = displayData.filter((item, index) =>
return selectedData; selectedRows.has(getCardKey(item, index))
}, );
return selectedData;
},
getAllData: () => { getAllData: () => {
return displayData; return displayData;
}, },
clearSelection: () => { clearSelection: () => {
setSelectedRows(new Set()); setSelectedRows(new Set());
}, },
}), }), [component.id, displayData, selectedRows, getCardKey]);
[component.id, displayData, selectedRows, getCardKey],
);
// ScreenContext에 데이터 제공자로 등록 // ScreenContext에 데이터 제공자로 등록
useEffect(() => { useEffect(() => {
@ -605,7 +590,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
// TableOptionsContext에 테이블 등록 (검색 필터 위젯 연동용) // TableOptionsContext에 테이블 등록 (검색 필터 위젯 연동용)
const tableId = `card-display-${component.id}`; const tableId = `card-display-${component.id}`;
const tableNameToUse = tableName || component.componentConfig?.tableName || ""; const tableNameToUse = tableName || component.componentConfig?.tableName || '';
const tableLabel = component.componentConfig?.title || component.label || "카드 디스플레이"; const tableLabel = component.componentConfig?.title || component.label || "카드 디스플레이";
// ref로 최신 데이터 참조 (useCallback 의존성 문제 해결) // ref로 최신 데이터 참조 (useCallback 의존성 문제 해결)
@ -631,12 +616,11 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
if (!tableNameToUse || isDesignMode) return; if (!tableNameToUse || isDesignMode) return;
// 우측 패널이고 linkedFilters가 설정되어 있지만 좌측 데이터가 선택되지 않은 경우 스킵 // 우측 패널이고 linkedFilters가 설정되어 있지만 좌측 데이터가 선택되지 않은 경우 스킵
const isRightPanel = const isRightPanel = splitPanelPosition === "right" ||
splitPanelPosition === "right" ||
(screenId && splitPanelContext?.getPositionByScreenId?.(screenId as number) === "right"); (screenId && splitPanelContext?.getPositionByScreenId?.(screenId as number) === "right");
const hasLinkedFiltersInConfig = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0; const hasLinkedFiltersInConfig = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0;
const hasSelectedLeftData = const hasSelectedLeftData = splitPanelContext?.selectedLeftData &&
splitPanelContext?.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0; Object.keys(splitPanelContext.selectedLeftData).length > 0;
// 우측 패널이고 좌측 데이터가 선택되지 않은 경우 - 기존 데이터 유지 (깜빡임 방지) // 우측 패널이고 좌측 데이터가 선택되지 않은 경우 - 기존 데이터 유지 (깜빡임 방지)
if (isRightPanel && !hasSelectedLeftData) { if (isRightPanel && !hasSelectedLeftData) {
@ -657,8 +641,8 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
// 필터 값을 검색 파라미터로 변환 // 필터 값을 검색 파라미터로 변환
const searchParams: Record<string, any> = {}; const searchParams: Record<string, any> = {};
filters.forEach((filter) => { filters.forEach(filter => {
if (filter.value !== undefined && filter.value !== null && filter.value !== "") { if (filter.value !== undefined && filter.value !== null && filter.value !== '') {
searchParams[filter.columnName] = filter.value; searchParams[filter.columnName] = filter.value;
} }
}); });
@ -687,32 +671,29 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
}, [filters, tableNameToUse, isDesignMode, tableId, splitPanelContext?.selectedLeftData, splitPanelPosition]); }, [filters, tableNameToUse, isDesignMode, tableId, splitPanelContext?.selectedLeftData, splitPanelPosition]);
// 컬럼 고유 값 조회 함수 (select 타입 필터용) // 컬럼 고유 값 조회 함수 (select 타입 필터용)
const getColumnUniqueValues = useCallback( const getColumnUniqueValues = useCallback(async (columnName: string): Promise<Array<{ label: string; value: string }>> => {
async (columnName: string): Promise<Array<{ label: string; value: string }>> => { if (!tableNameToUse) return [];
if (!tableNameToUse) return [];
try { try {
// 현재 로드된 데이터에서 고유 값 추출 // 현재 로드된 데이터에서 고유 값 추출
const uniqueValues = new Set<string>(); const uniqueValues = new Set<string>();
loadedTableDataRef.current.forEach((row) => { loadedTableDataRef.current.forEach(row => {
const value = row[columnName]; const value = row[columnName];
if (value !== null && value !== undefined && value !== "") { if (value !== null && value !== undefined && value !== '') {
uniqueValues.add(String(value)); uniqueValues.add(String(value));
} }
}); });
// 카테고리 매핑이 있으면 라벨 적용 // 카테고리 매핑이 있으면 라벨 적용
const mapping = categoryMappingsRef.current[columnName]; const mapping = categoryMappingsRef.current[columnName];
return Array.from(uniqueValues).map((value) => ({ return Array.from(uniqueValues).map(value => ({
value, value,
label: mapping?.[value]?.label || value, label: mapping?.[value]?.label || value,
})); }));
} catch (error) { } catch (error) {
return []; return [];
} }
}, }, [tableNameToUse]);
[tableNameToUse],
);
// TableOptionsContext에 등록 // TableOptionsContext에 등록
// registerTable과 unregisterTable 함수 참조 저장 (의존성 안정화) // registerTable과 unregisterTable 함수 참조 저장 (의존성 안정화)
@ -747,7 +728,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
const columns: TableColumn[] = loadedTableColumns.map((col: any) => ({ const columns: TableColumn[] = loadedTableColumns.map((col: any) => ({
columnName: col.columnName || col.column_name, columnName: col.columnName || col.column_name,
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
inputType: columnMeta[col.columnName || col.column_name]?.inputType || "text", inputType: columnMeta[col.columnName || col.column_name]?.inputType || 'text',
visible: true, visible: true,
width: 200, width: 200,
sortable: true, sortable: true,
@ -810,7 +791,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
}} }}
> >
<div className="text-muted-foreground text-center"> <div className="text-muted-foreground text-center">
<div className="mb-2 text-lg"> </div> <div className="text-lg mb-2"> </div>
<div className="text-sm text-gray-400"> </div> <div className="text-sm text-gray-400"> </div>
</div> </div>
</div> </div>
@ -918,7 +899,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
backgroundColor: displayColor, backgroundColor: displayColor,
borderColor: displayColor, borderColor: displayColor,
}} }}
className="text-xs text-white" className="text-white text-xs"
> >
{displayLabel} {displayLabel}
</Badge> </Badge>
@ -934,7 +915,9 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
// 컬럼 정보가 없으면 컬럼명을 보기 좋게 변환 // 컬럼 정보가 없으면 컬럼명을 보기 좋게 변환
return formatColumnName(columnName); return formatColumnName(columnName);
} }
const column = actualTableColumns.find((col) => col.columnName === columnName || col.column_name === columnName); const column = actualTableColumns.find(
(col) => col.columnName === columnName || col.column_name === columnName
);
// 다양한 라벨 필드명 지원 (displayName이 API에서 반환하는 라벨) // 다양한 라벨 필드명 지원 (displayName이 API에서 반환하는 라벨)
const label = column?.displayName || column?.columnLabel || column?.column_label || column?.label; const label = column?.displayName || column?.columnLabel || column?.column_label || column?.label;
return label || formatColumnName(columnName); return label || formatColumnName(columnName);
@ -943,7 +926,9 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
// 컬럼명을 보기 좋은 형태로 변환 (snake_case -> 공백 구분) // 컬럼명을 보기 좋은 형태로 변환 (snake_case -> 공백 구분)
const formatColumnName = (columnName: string) => { const formatColumnName = (columnName: string) => {
// 언더스코어를 공백으로 변환하고 각 단어 첫 글자 대문자화 // 언더스코어를 공백으로 변환하고 각 단어 첫 글자 대문자화
return columnName.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()); return columnName
.replace(/_/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase());
}; };
// 자동 폴백 로직 - 컬럼이 설정되지 않은 경우 적절한 기본값 찾기 // 자동 폴백 로직 - 컬럼이 설정되지 않은 경우 적절한 기본값 찾기
@ -982,7 +967,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
overflow: hidden; overflow: hidden;
} }
.card-hover::before { .card-hover::before {
content: ""; content: '';
position: absolute; position: absolute;
top: 0; top: 0;
left: -100%; left: -100%;
@ -1005,7 +990,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
position: relative; position: relative;
} }
.card-container::before { .card-container::before {
content: ""; content: '';
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
@ -1044,8 +1029,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
displayData.map((data, index) => { displayData.map((data, index) => {
// 타이틀, 서브타이틀, 설명 값 결정 (문자열로 가져와서 표시) // 타이틀, 서브타이틀, 설명 값 결정 (문자열로 가져와서 표시)
const titleValue = const titleValue =
getColumnValueAsString(data, componentConfig.columnMapping?.titleColumn) || getColumnValueAsString(data, componentConfig.columnMapping?.titleColumn) || getAutoFallbackValue(data, "title");
getAutoFallbackValue(data, "title");
const subtitleValue = const subtitleValue =
getColumnValueAsString(data, componentConfig.columnMapping?.subtitleColumn) || getColumnValueAsString(data, componentConfig.columnMapping?.subtitleColumn) ||
@ -1056,24 +1040,18 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
getAutoFallbackValue(data, "description"); getAutoFallbackValue(data, "description");
// 이미지 컬럼 자동 감지 (image_path, photo 등) - 대소문자 무시 // 이미지 컬럼 자동 감지 (image_path, photo 등) - 대소문자 무시
const imageColumn = const imageColumn = componentConfig.columnMapping?.imageColumn ||
componentConfig.columnMapping?.imageColumn || Object.keys(data).find(key => {
Object.keys(data).find((key) => {
const lowerKey = key.toLowerCase(); const lowerKey = key.toLowerCase();
return ( return lowerKey.includes('image') || lowerKey.includes('photo') ||
lowerKey.includes("image") || lowerKey.includes('avatar') || lowerKey.includes('thumbnail') ||
lowerKey.includes("photo") || lowerKey.includes('picture') || lowerKey.includes('img');
lowerKey.includes("avatar") ||
lowerKey.includes("thumbnail") ||
lowerKey.includes("picture") ||
lowerKey.includes("img")
);
}); });
// 이미지 값 가져오기 (직접 접근 + 폴백) // 이미지 값 가져오기 (직접 접근 + 폴백)
const imageValue = imageColumn const imageValue = imageColumn
? data[imageColumn] ? data[imageColumn]
: data.image_path || data.imagePath || data.avatar || data.image || data.photo || ""; : (data.image_path || data.imagePath || data.avatar || data.image || data.photo || "");
// 이미지 표시 여부 결정: 이미지 값이 있거나, 설정에서 활성화된 경우 // 이미지 표시 여부 결정: 이미지 값이 있거나, 설정에서 활성화된 경우
const shouldShowImage = componentConfig.cardStyle?.showImage !== false; const shouldShowImage = componentConfig.cardStyle?.showImage !== false;
@ -1091,7 +1069,9 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
...cardStyle, ...cardStyle,
borderColor: isCardSelected ? "#000" : "#e5e7eb", borderColor: isCardSelected ? "#000" : "#e5e7eb",
borderWidth: isCardSelected ? "2px" : "1px", borderWidth: isCardSelected ? "2px" : "1px",
boxShadow: isCardSelected ? "0 4px 6px -1px rgba(0, 0, 0, 0.15)" : "0 1px 3px rgba(0, 0, 0, 0.08)", boxShadow: isCardSelected
? "0 4px 6px -1px rgba(0, 0, 0, 0.15)"
: "0 1px 3px rgba(0, 0, 0, 0.08)",
flexDirection: "row", // 가로 배치 flexDirection: "row", // 가로 배치
}} }}
className="card-hover group cursor-pointer transition-all duration-150" className="card-hover group cursor-pointer transition-all duration-150"
@ -1099,38 +1079,35 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
> >
{/* 카드 이미지 - 좌측 전체 높이 (이미지 컬럼이 있으면 자동 표시) */} {/* 카드 이미지 - 좌측 전체 높이 (이미지 컬럼이 있으면 자동 표시) */}
{shouldShowImage && ( {shouldShowImage && (
<div className="mr-4 flex flex-shrink-0 items-center justify-center"> <div className="flex-shrink-0 flex items-center justify-center mr-4">
{imageUrl ? ( {imageUrl ? (
<img <img
src={imageUrl} src={imageUrl}
alt={titleValue || "이미지"} alt={titleValue || "이미지"}
className="h-16 w-16 rounded-lg border border-gray-200 object-cover" className="h-16 w-16 rounded-lg object-cover border border-gray-200"
onError={(e) => { onError={(e) => {
// 이미지 로드 실패 시 기본 아이콘으로 대체 // 이미지 로드 실패 시 기본 아이콘으로 대체
e.currentTarget.src = e.currentTarget.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Crect width='64' height='64' fill='%23e0e7ff' rx='8'/%3E%3Ctext x='32' y='40' text-anchor='middle' fill='%236366f1' font-size='24'%3E👤%3C/text%3E%3C/svg%3E";
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Crect width='64' height='64' fill='%23e0e7ff' rx='8'/%3E%3Ctext x='32' y='40' text-anchor='middle' fill='%236366f1' font-size='24'%3E👤%3C/text%3E%3C/svg%3E";
}} }}
/> />
) : ( ) : (
<div className="bg-primary/10 flex h-16 w-16 items-center justify-center rounded-lg"> <div className="flex h-16 w-16 items-center justify-center rounded-lg bg-primary/10">
<span className="text-primary text-2xl">👤</span> <span className="text-2xl text-primary">👤</span>
</div> </div>
)} )}
</div> </div>
)} )}
{/* 우측 컨텐츠 영역 */} {/* 우측 컨텐츠 영역 */}
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex flex-col flex-1 min-w-0">
{/* 타이틀 + 서브타이틀 */} {/* 타이틀 + 서브타이틀 */}
{(componentConfig.cardStyle?.showTitle || componentConfig.cardStyle?.showSubtitle) && ( {(componentConfig.cardStyle?.showTitle || componentConfig.cardStyle?.showSubtitle) && (
<div className="mb-1 flex flex-wrap items-center gap-2"> <div className="mb-1 flex items-center gap-2 flex-wrap">
{componentConfig.cardStyle?.showTitle && ( {componentConfig.cardStyle?.showTitle && (
<h3 className="text-foreground text-base leading-tight font-semibold">{titleValue}</h3> <h3 className="text-base font-semibold text-foreground leading-tight">{titleValue}</h3>
)} )}
{componentConfig.cardStyle?.showSubtitle && subtitleValue && ( {componentConfig.cardStyle?.showSubtitle && subtitleValue && (
<span className="text-primary bg-primary/10 rounded-full px-2 py-0.5 text-xs font-medium"> <span className="text-xs font-medium text-primary bg-primary/10 px-2 py-0.5 rounded-full">{subtitleValue}</span>
{subtitleValue}
</span>
)} )}
</div> </div>
)} )}
@ -1138,7 +1115,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
{/* 추가 표시 컬럼들 - 가로 배치 */} {/* 추가 표시 컬럼들 - 가로 배치 */}
{componentConfig.columnMapping?.displayColumns && {componentConfig.columnMapping?.displayColumns &&
componentConfig.columnMapping.displayColumns.length > 0 && ( componentConfig.columnMapping.displayColumns.length > 0 && (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs"> <div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
{componentConfig.columnMapping.displayColumns.map((columnName, idx) => { {componentConfig.columnMapping.displayColumns.map((columnName, idx) => {
const value = getColumnValue(data, columnName); const value = getColumnValue(data, columnName);
if (!value) return null; if (!value) return null;
@ -1146,7 +1123,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
return ( return (
<div key={idx} className="flex items-center gap-1"> <div key={idx} className="flex items-center gap-1">
<span>{getColumnLabel(columnName)}:</span> <span>{getColumnLabel(columnName)}:</span>
<span className="text-foreground font-medium">{value}</span> <span className="font-medium text-foreground">{value}</span>
</div> </div>
); );
})} })}
@ -1156,7 +1133,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
{/* 카드 설명 */} {/* 카드 설명 */}
{componentConfig.cardStyle?.showDescription && descriptionValue && ( {componentConfig.cardStyle?.showDescription && descriptionValue && (
<div className="mt-1 flex-1"> <div className="mt-1 flex-1">
<p className="text-muted-foreground text-xs leading-relaxed"> <p className="text-xs text-muted-foreground leading-relaxed">
{truncateText(descriptionValue, componentConfig.cardStyle?.maxDescriptionLength || 100)} {truncateText(descriptionValue, componentConfig.cardStyle?.maxDescriptionLength || 100)}
</p> </p>
</div> </div>
@ -1167,7 +1144,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
<div className="mt-2 flex justify-end space-x-2"> <div className="mt-2 flex justify-end space-x-2">
{(componentConfig.cardStyle?.showViewButton ?? true) && ( {(componentConfig.cardStyle?.showViewButton ?? true) && (
<button <button
className="text-xs text-blue-600 transition-colors hover:text-blue-800" className="text-xs text-blue-600 hover:text-blue-800 transition-colors"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleCardView(data); handleCardView(data);
@ -1178,7 +1155,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
)} )}
{(componentConfig.cardStyle?.showEditButton ?? true) && ( {(componentConfig.cardStyle?.showEditButton ?? true) && (
<button <button
className="text-muted-foreground hover:text-foreground text-xs transition-colors" className="text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleCardEdit(data); handleCardEdit(data);
@ -1189,7 +1166,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
)} )}
{(componentConfig.cardStyle?.showDeleteButton ?? false) && ( {(componentConfig.cardStyle?.showDeleteButton ?? false) && (
<button <button
className="text-xs text-red-500 transition-colors hover:text-red-700" className="text-xs text-red-500 hover:text-red-700 transition-colors"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleCardDelete(data, index); handleCardDelete(data, index);
@ -1210,7 +1187,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
{/* 상세보기 모달 */} {/* 상세보기 모달 */}
<Dialog open={viewModalOpen} onOpenChange={setViewModalOpen}> <Dialog open={viewModalOpen} onOpenChange={setViewModalOpen}>
<DialogContent className="max-h-[80vh] max-w-2xl overflow-hidden"> <DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<span className="text-lg">📋</span> <span className="text-lg">📋</span>
@ -1220,9 +1197,9 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
{selectedData && ( {selectedData && (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(selectedData) {Object.entries(selectedData)
.filter(([key, value]) => value !== null && value !== undefined && value !== "") .filter(([key, value]) => value !== null && value !== undefined && value !== '')
.map(([key, value]) => { .map(([key, value]) => {
// 카테고리 타입인 경우 배지로 표시 // 카테고리 타입인 경우 배지로 표시
const meta = columnMeta[key]; const meta = columnMeta[key];
@ -1256,19 +1233,22 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
return ( return (
<div key={key} className="bg-muted rounded-lg p-3"> <div key={key} className="bg-muted rounded-lg p-3">
<div className="text-muted-foreground mb-1 text-xs font-medium tracking-wide uppercase"> <div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
{getColumnLabel(key)} {getColumnLabel(key)}
</div> </div>
<div className="text-foreground text-sm font-medium break-words">{displayValue}</div> <div className="text-sm font-medium text-foreground break-words">
{displayValue}
</div>
</div> </div>
); );
})} })
}
</div> </div>
<div className="flex justify-end border-t pt-4"> <div className="flex justify-end pt-4 border-t">
<button <button
onClick={() => setViewModalOpen(false)} onClick={() => setViewModalOpen(false)}
className="text-foreground bg-muted hover:bg-muted/80 rounded-md px-4 py-2 text-sm font-medium transition-colors" className="px-4 py-2 text-sm font-medium text-foreground bg-muted hover:bg-muted/80 rounded-md transition-colors"
> >
</button> </button>
@ -1280,7 +1260,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
{/* 편집 모달 */} {/* 편집 모달 */}
<Dialog open={editModalOpen} onOpenChange={setEditModalOpen}> <Dialog open={editModalOpen} onOpenChange={setEditModalOpen}>
<DialogContent className="max-h-[80vh] max-w-2xl overflow-hidden"> <DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<span className="text-lg"></span> <span className="text-lg"></span>
@ -1295,8 +1275,8 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
.filter(([key, value]) => value !== null && value !== undefined) .filter(([key, value]) => value !== null && value !== undefined)
.map(([key, value]) => ( .map(([key, value]) => (
<div key={key} className="space-y-2"> <div key={key} className="space-y-2">
<label className="text-foreground block text-sm font-medium"> <label className="text-sm font-medium text-foreground block">
{key.replace(/_/g, " ").toUpperCase()} {key.replace(/_/g, ' ').toUpperCase()}
</label> </label>
<Input <Input
type="text" type="text"
@ -1306,10 +1286,11 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
placeholder={`${key} 입력`} placeholder={`${key} 입력`}
/> />
</div> </div>
))} ))
}
</div> </div>
<div className="flex justify-end gap-2 border-t pt-4"> <div className="flex justify-end gap-2 pt-4 border-t">
<Button <Button
variant="outline" variant="outline"
onClick={() => { onClick={() => {
@ -1319,7 +1300,10 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
> >
</Button> </Button>
<Button onClick={handleEditSave} className="bg-primary hover:bg-primary/90"> <Button
onClick={handleEditSave}
className="bg-primary hover:bg-primary/90"
>
</Button> </Button>
</div> </div>

View File

@ -112,12 +112,13 @@ import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트
import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트 import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트
import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트 import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트
import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트 import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트
import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준
import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅
import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선 import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선
import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰 import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰
import "./v2-bom-item-editor/BomItemEditorRenderer"; // BOM 하위품목 편집기 import "./v2-bom-item-editor/BomItemEditorRenderer"; // BOM 하위품목 편집기
import "./v2-report-viewer/ReportViewerRenderer"; // 리포트 뷰어 import "./v2-approval-step/ApprovalStepRenderer"; // 결재 단계 시각화
import "./v2-status-count/StatusCountRenderer"; // 상태별 카운트 카드
import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준
import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅
/** /**
* *

View File

@ -10,6 +10,7 @@ import { useCalculation } from "./useCalculation";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { ComponentRendererProps } from "@/types/component"; import { ComponentRendererProps } from "@/types/component";
import { isColumnRequiredByMeta } from "@/lib/registry/DynamicComponentRenderer";
// ✅ ComponentRendererProps 상속으로 필수 props 자동 확보 // ✅ ComponentRendererProps 상속으로 필수 props 자동 확보
export interface ModalRepeaterTableComponentProps extends ComponentRendererProps { export interface ModalRepeaterTableComponentProps extends ComponentRendererProps {
@ -363,6 +364,15 @@ export function ModalRepeaterTableComponent({
return []; return [];
}, [componentConfig?.columns, propColumns, sourceColumns]); }, [componentConfig?.columns, propColumns, sourceColumns]);
// NOT NULL 메타데이터 기반 required 보강
const enhancedColumns = React.useMemo((): RepeaterColumnConfig[] => {
if (!targetTable) return columns;
return columns.map((col) => ({
...col,
required: col.required || isColumnRequiredByMeta(targetTable, col.field),
}));
}, [columns, targetTable]);
// 초기 props 검증 // 초기 props 검증
useEffect(() => { useEffect(() => {
if (rawSourceColumns.length !== sourceColumns.length) { if (rawSourceColumns.length !== sourceColumns.length) {
@ -856,7 +866,7 @@ export function ModalRepeaterTableComponent({
{/* Repeater 테이블 */} {/* Repeater 테이블 */}
<RepeaterTable <RepeaterTable
columns={columns} columns={enhancedColumns}
data={localValue} data={localValue}
onDataChange={handleChange} onDataChange={handleChange}
onRowChange={handleRowChange} onRowChange={handleRowChange}

View File

@ -133,7 +133,7 @@ export function RepeaterTable({
continue; continue;
} }
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`); const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values?includeInactive=true`);
if (response.data?.success && response.data.data) { if (response.data?.success && response.data.data) {
const options = response.data.data.map((item: any) => ({ const options = response.data.data.map((item: any) => ({
@ -835,8 +835,7 @@ export function RepeaterTable({
</Popover> </Popover>
) : ( ) : (
<> <>
{col.label} {col.label}{col.required && <span className="text-orange-500">*</span>}
{col.required && <span className="ml-1 text-red-500">*</span>}
</> </>
)} )}
</div> </div>

View File

@ -3,6 +3,8 @@
* . * .
*/ */
import { getFormatRules } from "@/lib/formatting";
import { AggregationType, PivotFieldFormat } from "../types"; import { AggregationType, PivotFieldFormat } from "../types";
// ==================== 집계 함수 ==================== // ==================== 집계 함수 ====================
@ -55,8 +57,13 @@ export function countDistinct(values: any[]): number {
/** /**
* *
*/ */
export function aggregate(values: any[], type: AggregationType = "sum"): number { export function aggregate(
const numericValues = values.map((v) => (typeof v === "number" ? v : parseFloat(v))).filter((v) => !isNaN(v)); values: any[],
type: AggregationType = "sum"
): number {
const numericValues = values
.map((v) => (typeof v === "number" ? v : parseFloat(v)))
.filter((v) => !isNaN(v));
switch (type) { switch (type) {
case "sum": case "sum":
@ -81,23 +88,34 @@ export function aggregate(values: any[], type: AggregationType = "sum"): number
/** /**
* *
*/ */
export function formatNumber(value: number | null | undefined, format?: PivotFieldFormat): string { export function formatNumber(
value: number | null | undefined,
format?: PivotFieldFormat
): string {
if (value === null || value === undefined) return "-"; if (value === null || value === undefined) return "-";
const { type = "number", precision = 0, thousandSeparator = true, prefix = "", suffix = "" } = format || {}; const {
type = "number",
precision = 0,
thousandSeparator = true,
prefix = "",
suffix = "",
} = format || {};
let formatted: string; let formatted: string;
const locale = getFormatRules().number.locale;
switch (type) { switch (type) {
case "currency": case "currency":
formatted = value.toLocaleString("ko-KR", { formatted = value.toLocaleString(locale, {
minimumFractionDigits: precision, minimumFractionDigits: precision,
maximumFractionDigits: precision, maximumFractionDigits: precision,
}); });
break; break;
case "percent": case "percent":
formatted = (value * 100).toLocaleString("ko-KR", { formatted = (value * 100).toLocaleString(locale, {
minimumFractionDigits: precision, minimumFractionDigits: precision,
maximumFractionDigits: precision, maximumFractionDigits: precision,
}); });
@ -106,7 +124,7 @@ export function formatNumber(value: number | null | undefined, format?: PivotFie
case "number": case "number":
default: default:
if (thousandSeparator) { if (thousandSeparator) {
formatted = value.toLocaleString("ko-KR", { formatted = value.toLocaleString(locale, {
minimumFractionDigits: precision, minimumFractionDigits: precision,
maximumFractionDigits: precision, maximumFractionDigits: precision,
}); });
@ -122,7 +140,10 @@ export function formatNumber(value: number | null | undefined, format?: PivotFie
/** /**
* *
*/ */
export function formatDate(value: Date | string | null | undefined, format: string = "YYYY-MM-DD"): string { export function formatDate(
value: Date | string | null | undefined,
format: string = getFormatRules().date.display
): string {
if (!value) return "-"; if (!value) return "-";
const date = typeof value === "string" ? new Date(value) : value; const date = typeof value === "string" ? new Date(value) : value;
@ -134,7 +155,11 @@ export function formatDate(value: Date | string | null | undefined, format: stri
const day = String(date.getDate()).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0");
const quarter = Math.ceil((date.getMonth() + 1) / 3); const quarter = Math.ceil((date.getMonth() + 1) / 3);
return format.replace("YYYY", String(year)).replace("MM", month).replace("DD", day).replace("Q", `Q${quarter}`); return format
.replace("YYYY", String(year))
.replace("MM", month)
.replace("DD", day)
.replace("Q", `Q${quarter}`);
} }
/** /**
@ -151,3 +176,5 @@ export function getAggregationLabel(type: AggregationType): string {
}; };
return labels[type] || "합계"; return labels[type] || "합계";
} }

View File

@ -45,7 +45,7 @@ function getFieldValue(row: Record<string, any>, field: PivotFieldConfig): strin
const weekNum = getWeekNumber(date); const weekNum = getWeekNumber(date);
return `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`; return `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`;
case "day": case "day":
return formatDate(date, "YYYY-MM-DD"); return formatDate(date);
default: default:
return String(rawValue); return String(rawValue);
} }

View File

@ -10,6 +10,7 @@ import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Loader2, Save, X, Layers, Table as TableIcon, Plus, Trash2, RotateCcw, Pencil } from "lucide-react"; import { Loader2, Save, X, Layers, Table as TableIcon, Plus, Trash2, RotateCcw, Pencil } from "lucide-react";
import { isColumnRequiredByMeta } from "@/lib/registry/DynamicComponentRenderer";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -3113,15 +3114,15 @@ function renderTableCell(
} }
// 컬럼 렌더링 함수 (Simple 모드) // 컬럼 렌더링 함수 (Simple 모드)
function renderColumn(col: CardColumnConfig, card: CardData, onChange: (value: any) => void) { function renderColumn(col: CardColumnConfig, card: CardData, onChange: (value: any) => void, tableName?: string) {
const value = card[col.field]; const value = card[col.field];
const isReadOnly = !col.editable; const isReadOnly = !col.editable;
const effectiveRequired = col.required || isColumnRequiredByMeta(tableName, col.field);
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs font-medium"> <Label className="text-xs font-medium">
{col.label} {col.label}{effectiveRequired && <span className="text-orange-500">*</span>}
{col.required && <span className="text-destructive ml-1">*</span>}
</Label> </Label>
{isReadOnly && ( {isReadOnly && (

View File

@ -139,6 +139,22 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
const cascadingRole = config?.cascadingRole || componentConfig?.cascadingRole || "child"; const cascadingRole = config?.cascadingRole || componentConfig?.cascadingRole || "child";
const cascadingParentField = config?.cascadingParentField || componentConfig?.cascadingParentField; const cascadingParentField = config?.cascadingParentField || componentConfig?.cascadingParentField;
if (component.columnName === "data_type" || component.columnName === "unit") {
console.log("🔥🔥🔥 [PLC 캐스케이딩 디버그]", {
columnName: component.columnName,
categoryRelationCode,
cascadingRole,
cascadingParentField,
configKeys: config ? Object.keys(config) : "null",
componentConfigKeys: componentConfig ? Object.keys(componentConfig) : "null",
configCategoryRelationCode: config?.categoryRelationCode,
componentConfigCategoryRelationCode: componentConfig?.categoryRelationCode,
webType,
formDataKeys: formData ? Object.keys(formData) : "null",
parentValue: cascadingParentField && formData ? formData[cascadingParentField] : "N/A",
});
}
// 🆕 계층구조 역할 설정 (대분류/중분류/소분류) // 🆕 계층구조 역할 설정 (대분류/중분류/소분류)
// 1순위: 동적으로 조회된 값 (테이블 타입관리에서 설정) // 1순위: 동적으로 조회된 값 (테이블 타입관리에서 설정)
// 2순위: config에서 전달된 값 // 2순위: config에서 전달된 값

View File

@ -37,28 +37,13 @@ import {
} from "lucide-react"; } from "lucide-react";
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = { const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
X, X, Check, Plus, Minus, Edit, Trash2, Search, Save, RefreshCw,
Check, AlertCircle, Info, Settings, ChevronDown, ChevronUp, ChevronRight,
Plus, Copy, Download, Upload, ExternalLink,
Minus,
Edit,
Trash2,
Search,
Save,
RefreshCw,
AlertCircle,
Info,
Settings,
ChevronDown,
ChevronUp,
ChevronRight,
Copy,
Download,
Upload,
ExternalLink,
}; };
import { commonCodeApi } from "@/lib/api/commonCode"; import { commonCodeApi } from "@/lib/api/commonCode";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { isColumnRequiredByMeta } from "@/lib/registry/DynamicComponentRenderer";
export interface SelectedItemsDetailInputComponentProps extends ComponentRendererProps { export interface SelectedItemsDetailInputComponentProps extends ComponentRendererProps {
config?: SelectedItemsDetailInputConfig; config?: SelectedItemsDetailInputConfig;
@ -109,9 +94,12 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
[config, component.config, component.id], [config, component.config, component.id],
); );
// 소스 테이블의 키 필드명 (기본값: "item_id" → 하위 호환) // 소스 테이블의 키 필드명
// 예: item_info 기반이면 "item_id", customer_mng 기반이면 "customer_id" // 우선순위: 1) config에서 명시적 설정 → 2) additionalFields에서 autoFillFrom:"id" 필드 감지 → 3) 하위 호환 "item_id"
const sourceKeyField = componentConfig.sourceKeyField || "item_id"; const sourceKeyField = useMemo(() => {
// sourceKeyField는 config에서 직접 지정 (ConfigPanel 자동 감지에서 설정됨)
return componentConfig.sourceKeyField || "item_id";
}, [componentConfig.sourceKeyField]);
// 🆕 dataSourceId 우선순위: URL 파라미터 > 컴포넌트 설정 > component.id // 🆕 dataSourceId 우선순위: URL 파라미터 > 컴포넌트 설정 > component.id
const dataSourceId = useMemo( const dataSourceId = useMemo(
@ -252,7 +240,9 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const mode = urlParams.get("mode"); const mode = urlParams.get("mode");
// 🔧 데이터 소스 우선순위: groupedData > formData (배열) > formData (객체) // 🔧 데이터 소스 우선순위: groupedData > formData (배열) > formData (객체)
const sourceData = groupedData && Array.isArray(groupedData) && groupedData.length > 0 ? groupedData : formData; const sourceData = groupedData && Array.isArray(groupedData) && groupedData.length > 0
? groupedData
: formData;
if (mode === "edit" && sourceData) { if (mode === "edit" && sourceData) {
const loadEditData = async () => { const loadEditData = async () => {
@ -353,7 +343,9 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
if (src && src[field.autoFillFrom] !== undefined) { if (src && src[field.autoFillFrom] !== undefined) {
fieldValue = src[field.autoFillFrom]; fieldValue = src[field.autoFillFrom];
} else { } else {
const possibleKeys = Object.keys(src || {}).filter((key) => key.endsWith(`_${field.autoFillFrom}`)); const possibleKeys = Object.keys(src || {}).filter((key) =>
key.endsWith(`_${field.autoFillFrom}`),
);
if (possibleKeys.length > 0) { if (possibleKeys.length > 0) {
fieldValue = src[possibleKeys[0]]; fieldValue = src[possibleKeys[0]];
} }
@ -418,6 +410,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
// 생성 모드: modalData에서 데이터 로드 // 생성 모드: modalData에서 데이터 로드
if (modalData && modalData.length > 0) { if (modalData && modalData.length > 0) {
// 🆕 각 품목마다 빈 fieldGroups 객체를 가진 ItemData 생성 // 🆕 각 품목마다 빈 fieldGroups 객체를 가진 ItemData 생성
const groups = componentConfig.fieldGroups || []; const groups = componentConfig.fieldGroups || [];
const newItems: ItemData[] = modalData.map((item) => { const newItems: ItemData[] = modalData.map((item) => {
@ -483,10 +476,16 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
if (allGroupsEmpty) { if (allGroupsEmpty) {
// 디테일 데이터가 없어도 기본 레코드 생성 (품목-거래처 매핑 유지) // 디테일 데이터가 없어도 기본 레코드 생성 (품목-거래처 매핑 유지)
// autoFillFrom 필드 (item_id 등)는 반드시 포함시켜야 나중에 식별 가능
const baseRecord: Record<string, any> = {}; 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) => { additionalFields.forEach((f) => {
if (f.autoFillFrom && item.originalData) { if (f.name !== sourceKeyField && f.autoFillFrom && item.originalData) {
const value = item.originalData[f.autoFillFrom]; const value = item.originalData[f.autoFillFrom];
if (value !== undefined && value !== null) { if (value !== undefined && value !== null) {
baseRecord[f.name] = value; baseRecord[f.name] = value;
@ -541,7 +540,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
return allRecords; return allRecords;
}, },
[componentConfig.fieldGroups, componentConfig.additionalFields], [componentConfig.fieldGroups, componentConfig.additionalFields, sourceKeyField],
); );
// 🆕 저장 요청 시에만 데이터 전달 (이벤트 리스너 방식) // 🆕 저장 요청 시에만 데이터 전달 (이벤트 리스너 방식)
@ -569,6 +568,13 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
if (hasParentMapping) { if (hasParentMapping) {
try { 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에서) // 부모 키 추출 (parentDataMapping에서)
const parentKeys: Record<string, any> = {}; const parentKeys: Record<string, any> = {};
@ -582,16 +588,25 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
} }
componentConfig.parentDataMapping.forEach((mapping) => { componentConfig.parentDataMapping.forEach((mapping) => {
// 1차: formData(sourceData)에서 찾기 let value: any;
let value = getFieldValue(sourceData, mapping.sourceField);
// 2차: formData에 없으면 dataRegistry[sourceTable]에서 찾기 // 수정 모드: originalData의 targetField 값 우선 사용
// v2-split-panel-layout에서 좌측 항목 선택 시 dataRegistry에 저장한 데이터 활용 // 로드(editFilters)와 동일한 방식으로 FK 값을 가져와야
if ((value === undefined || value === null) && mapping.sourceTable) { // 백엔드에서 기존 레코드를 정확히 매칭하여 UPDATE 수행 가능
const registryData = dataRegistry[mapping.sourceTable]; if (isEditMode && items.length > 0 && items[0].originalData) {
if (registryData && registryData.length > 0) { value = items[0].originalData[mapping.targetField];
const registryItem = registryData[0].originalData || registryData[0]; }
value = registryItem[mapping.sourceField];
// 신규 모드 또는 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];
}
} }
} }
@ -604,8 +619,8 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
// 🔒 parentKeys 유효성 검증 - 빈 값이 있으면 저장 중단 // 🔒 parentKeys 유효성 검증 - 빈 값이 있으면 저장 중단
const parentKeyValues = Object.values(parentKeys); const parentKeyValues = Object.values(parentKeys);
const hasEmptyParentKey = const hasEmptyParentKey = parentKeyValues.length === 0 ||
parentKeyValues.length === 0 || parentKeyValues.some((v) => v === null || v === undefined || v === ""); parentKeyValues.some(v => v === null || v === undefined || v === "");
if (hasEmptyParentKey) { if (hasEmptyParentKey) {
console.error("❌ parentKeys 비어있음:", parentKeys); console.error("❌ parentKeys 비어있음:", parentKeys);
@ -647,15 +662,6 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const additionalFields = componentConfig.additionalFields || []; const additionalFields = componentConfig.additionalFields || [];
const mainTable = componentConfig.targetTable!; 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] 수정 모드 감지:", { console.log("[SelectedItemsDetailInput] 수정 모드 감지:", {
urlEditMode, urlEditMode,
dataHasDbId, dataHasDbId,
@ -687,27 +693,14 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
for (const item of items) { for (const item of items) {
// sourceKeyField 값 추출 (예: item_id 또는 customer_id) // sourceKeyField 값 추출 (예: item_id 또는 customer_id)
// (수정 모드에서 autoFillFrom:"id"가 가격 레코드 PK를 반환하는 문제 방지)
let sourceKeyValue: string | null = null; let sourceKeyValue: string | null = null;
// 1순위: originalData에 sourceKeyField가 직접 있으면 사용 (수정 모드에서 정확한 값) // 1순위: originalData에 sourceKeyField가 직접 있으면 사용 (수정 모드)
if (item.originalData && item.originalData[sourceKeyField]) { if (item.originalData && item.originalData[sourceKeyField]) {
sourceKeyValue = item.originalData[sourceKeyField]; sourceKeyValue = item.originalData[sourceKeyField];
} }
// 2순위: autoFillFrom 로직 (신규 등록 모드에서 사용) // 2순위: 원본 데이터의 id를 sourceKeyField 값으로 사용 (신규 등록 모드)
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 (최후의 수단)
if (!sourceKeyValue && item.originalData) { if (!sourceKeyValue && item.originalData) {
sourceKeyValue = item.originalData.id || null; sourceKeyValue = item.originalData.id || null;
} }
@ -764,16 +757,19 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
mappingHasDbIds, mappingHasDbIds,
shouldDeleteOrphans, shouldDeleteOrphans,
recordCount: mappingRecords.length, recordCount: mappingRecords.length,
recordIds: mappingRecords.map((r) => r.id || "NEW"), recordIds: mappingRecords.map(r => r.id || "NEW"),
parentKeys: itemParentKeys, parentKeys: itemParentKeys,
}); });
// 저장된 매핑 ID를 추적 (디테일 테이블에 mapping_id 주입용) // 저장된 매핑 ID를 추적 (디테일 테이블에 mapping_id 주입용)
let savedMappingIds: string[] = []; let savedMappingIds: string[] = [];
try { try {
const mappingResult = await dataApi.upsertGroupedRecords(mainTable, itemParentKeys, mappingRecords, { const mappingResult = await dataApi.upsertGroupedRecords(
deleteOrphans: shouldDeleteOrphans, mainTable,
}); itemParentKeys,
mappingRecords,
{ deleteOrphans: shouldDeleteOrphans },
);
// 백엔드에서 반환된 저장된 레코드 ID 목록 // 백엔드에서 반환된 저장된 레코드 ID 목록
if (mappingResult.success && mappingResult.savedIds) { if (mappingResult.success && mappingResult.savedIds) {
savedMappingIds = mappingResult.savedIds; savedMappingIds = mappingResult.savedIds;
@ -858,14 +854,17 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
priceHasDbIds, priceHasDbIds,
shouldDeleteDetailOrphans, shouldDeleteDetailOrphans,
recordCount: priceRecords.length, recordCount: priceRecords.length,
recordIds: priceRecords.map((r) => r.id || "NEW"), recordIds: priceRecords.map(r => r.id || "NEW"),
parentKeys: itemParentKeys, parentKeys: itemParentKeys,
}); });
try { try {
const detailResult = await dataApi.upsertGroupedRecords(detailTable, itemParentKeys, priceRecords, { const detailResult = await dataApi.upsertGroupedRecords(
deleteOrphans: shouldDeleteDetailOrphans, detailTable,
}); itemParentKeys,
priceRecords,
{ deleteOrphans: shouldDeleteDetailOrphans },
);
if (!detailResult.success) { if (!detailResult.success) {
console.error(`${detailTable} 저장 실패:`, detailResult.error); console.error(`${detailTable} 저장 실패:`, detailResult.error);
@ -892,9 +891,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const singleHasDbIds = records.some((r) => !!r.id); const singleHasDbIds = records.some((r) => !!r.id);
const shouldDeleteSingleOrphans = isEditMode || singleHasDbIds; const shouldDeleteSingleOrphans = isEditMode || singleHasDbIds;
const result = await dataApi.upsertGroupedRecords(mainTable, parentKeys, records, { const result = await dataApi.upsertGroupedRecords(mainTable, parentKeys, records, { deleteOrphans: shouldDeleteSingleOrphans });
deleteOrphans: shouldDeleteSingleOrphans,
});
if (result.success) { if (result.success) {
window.dispatchEvent( window.dispatchEvent(
@ -1288,8 +1285,9 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
case "numeric": { case "numeric": {
// 숫자 포맷팅 헬퍼: 콤마 표시 + 실제 값은 숫자만 저장 // 숫자 포맷팅 헬퍼: 콤마 표시 + 실제 값은 숫자만 저장
const rawNum = value ? String(value).replace(/,/g, "") : ""; const rawNum = value ? String(value).replace(/,/g, "") : "";
const displayNum = const displayNum = rawNum && !isNaN(Number(rawNum))
rawNum && !isNaN(Number(rawNum)) ? new Intl.NumberFormat("ko-KR").format(Number(rawNum)) : rawNum; ? new Intl.NumberFormat("ko-KR").format(Number(rawNum))
: rawNum;
// 계산된 단가는 읽기 전용 + 강조 표시 // 계산된 단가는 읽기 전용 + 강조 표시
if (isCalculatedField) { if (isCalculatedField) {
@ -1486,8 +1484,9 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
if (displayItems.length === 0) { if (displayItems.length === 0) {
const fields = (componentConfig.additionalFields || []).filter((f) => { const fields = (componentConfig.additionalFields || []).filter((f) => {
const matchGroup = const matchGroup = componentConfig.fieldGroups && componentConfig.fieldGroups.length > 0
componentConfig.fieldGroups && componentConfig.fieldGroups.length > 0 ? f.groupId === groupId : true; ? f.groupId === groupId
: true;
const isVisible = f.width !== "0px"; const isVisible = f.width !== "0px";
return matchGroup && isVisible; return matchGroup && isVisible;
}); });
@ -1527,10 +1526,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
}); });
if (!hasAnyValue) { if (!hasAnyValue) {
const fieldLabels = fields const fieldLabels = fields.slice(0, 2).map(f => f.label).join("/");
.slice(0, 2)
.map((f) => f.label)
.join("/");
return `신규 ${fieldLabels} 입력`; return `신규 ${fieldLabels} 입력`;
} }
@ -1539,18 +1535,14 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const endDate = entry["end_date"] ? formatValue({ inputType: "date" }, entry["end_date"]) : ""; const endDate = entry["end_date"] ? formatValue({ inputType: "date" }, entry["end_date"]) : "";
// 기준단가(calculated_price) 또는 기준가(base_price) 표시 // 기준단가(calculated_price) 또는 기준가(base_price) 표시
const calcPrice = entry["calculated_price"] const calcPrice = entry["calculated_price"] ? formatValue({ inputType: "number" }, entry["calculated_price"]) : "";
? formatValue({ inputType: "number" }, entry["calculated_price"])
: "";
const basePrice = entry["base_price"] ? formatValue({ inputType: "number" }, entry["base_price"]) : ""; const basePrice = entry["base_price"] ? formatValue({ inputType: "number" }, entry["base_price"]) : "";
// 통화코드 // 통화코드
const currencyCode = entry["currency_code"] const currencyCode = entry["currency_code"] ? formatValue(
? formatValue( fields.find(f => f.name === "currency_code") || { inputType: "category", name: "currency_code" },
fields.find((f) => f.name === "currency_code") || { inputType: "category", name: "currency_code" }, entry["currency_code"]
entry["currency_code"], ) : "";
)
: "";
if (startDate || calcPrice || basePrice) { if (startDate || calcPrice || basePrice) {
// 날짜 + 단가 간결 표시 // 날짜 + 단가 간결 표시
@ -1752,9 +1744,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
// 디자인 모드: 샘플 데이터로 미리보기 표시 // 디자인 모드: 샘플 데이터로 미리보기 표시
if (isDesignMode) { if (isDesignMode) {
const sampleDisplayCols = componentConfig.displayColumns || []; const sampleDisplayCols = componentConfig.displayColumns || [];
const sampleFields = (componentConfig.additionalFields || []).filter( const sampleFields = (componentConfig.additionalFields || []).filter(f => f.name !== sourceKeyField && f.width !== "0px");
(f) => f.name !== sourceKeyField && f.width !== "0px",
);
const sampleGroups = componentConfig.fieldGroups || [{ id: "default", title: "입력 정보", order: 0 }]; const sampleGroups = componentConfig.fieldGroups || [{ id: "default", title: "입력 정보", order: 0 }];
const gridCols = sampleGroups.length === 1 ? "grid-cols-1" : "grid-cols-2"; const gridCols = sampleGroups.length === 1 ? "grid-cols-1" : "grid-cols-2";
@ -1773,10 +1763,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between text-sm font-semibold"> <CardTitle className="flex items-center justify-between text-sm font-semibold">
<span> <span>
{idx}.{" "} {idx}. {sampleDisplayCols.length > 0 ? `샘플 ${sampleDisplayCols[0]?.label || "품목"} ${idx}` : `샘플 항목 ${idx}`}
{sampleDisplayCols.length > 0
? `샘플 ${sampleDisplayCols[0]?.label || "품목"} ${idx}`
: `샘플 항목 ${idx}`}
</span> </span>
<Button type="button" variant="ghost" size="sm" className="h-6 w-6 p-0 text-red-400" disabled> <Button type="button" variant="ghost" size="sm" className="h-6 w-6 p-0 text-red-400" disabled>
<X className="h-3 w-3" /> <X className="h-3 w-3" />
@ -1796,8 +1783,8 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
<CardContent className="pt-0"> <CardContent className="pt-0">
<div className={`grid ${gridCols} gap-2`}> <div className={`grid ${gridCols} gap-2`}>
{sampleGroups.map((group) => { {sampleGroups.map((group) => {
const groupFields = sampleFields.filter( const groupFields = sampleFields.filter(f =>
(f) => sampleGroups.length <= 1 || f.groupId === group.id, sampleGroups.length <= 1 || f.groupId === group.id
); );
if (groupFields.length === 0) return null; if (groupFields.length === 0) return null;
const isSingle = group.maxEntries === 1; const isSingle = group.maxEntries === 1;
@ -1818,18 +1805,14 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
{isSingle ? ( {isSingle ? (
/* 1:1 그룹: 인라인 폼 미리보기 */ /* 1:1 그룹: 인라인 폼 미리보기 */
<div className="grid grid-cols-2 gap-1"> <div className="grid grid-cols-2 gap-1">
{groupFields.slice(0, 4).map((f) => ( {groupFields.slice(0, 4).map(f => (
<div key={f.name} className="space-y-0.5"> <div key={f.name} className="space-y-0.5">
<span className="text-muted-foreground text-[9px]">{f.label}</span> <span className="text-muted-foreground text-[9px]">{f.label}</span>
<div className="bg-muted/40 h-5 rounded border px-1 text-[10px] leading-5"> <div className="bg-muted/40 h-5 rounded border text-[10px] leading-5 px-1"></div>
</div>
</div> </div>
))} ))}
{groupFields.length > 4 && ( {groupFields.length > 4 && (
<div className="text-muted-foreground col-span-2 text-[9px]"> <div className="text-muted-foreground col-span-2 text-[9px]"> {groupFields.length - 4} </div>
{groupFields.length - 4}
</div>
)} )}
</div> </div>
) : ( ) : (
@ -1837,22 +1820,14 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
<> <>
<div className="bg-muted/30 flex items-center justify-between rounded border p-1.5 text-[10px]"> <div className="bg-muted/30 flex items-center justify-between rounded border p-1.5 text-[10px]">
<span className="truncate"> <span className="truncate">
1.{" "} 1. {groupFields.slice(0, 2).map(f => `${f.label}: 샘플`).join(" / ")}
{groupFields
.slice(0, 2)
.map((f) => `${f.label}: 샘플`)
.join(" / ")}
</span> </span>
<X className="h-2.5 w-2.5 shrink-0 text-gray-400" /> <X className="h-2.5 w-2.5 shrink-0 text-gray-400" />
</div> </div>
{idx === 1 && ( {idx === 1 && (
<div className="bg-muted/30 flex items-center justify-between rounded border p-1.5 text-[10px]"> <div className="bg-muted/30 flex items-center justify-between rounded border p-1.5 text-[10px]">
<span className="truncate"> <span className="truncate">
2.{" "} 2. {groupFields.slice(0, 2).map(f => `${f.label}: 샘플`).join(" / ")}
{groupFields
.slice(0, 2)
.map((f) => `${f.label}: 샘플`)
.join(" / ")}
</span> </span>
<X className="h-2.5 w-2.5 shrink-0 text-gray-400" /> <X className="h-2.5 w-2.5 shrink-0 text-gray-400" />
</div> </div>
@ -1908,7 +1883,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
// 1:1 관계 그룹 (maxEntries === 1): 인라인 폼으로 바로 표시 // 1:1 관계 그룹 (maxEntries === 1): 인라인 폼으로 바로 표시
const isSingleEntry = group.maxEntries === 1; const isSingleEntry = group.maxEntries === 1;
const singleEntry = isSingleEntry ? groupEntries[0] || { id: `${group.id}_auto_1` } : null; const singleEntry = isSingleEntry ? (groupEntries[0] || { id: `${group.id}_auto_1` }) : null;
// hidden 필드 제외 (width: "0px"인 필드) // hidden 필드 제외 (width: "0px"인 필드)
const visibleFields = groupFields.filter((f) => f.width !== "0px"); const visibleFields = groupFields.filter((f) => f.width !== "0px");
@ -1937,10 +1912,12 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
{isSingleEntry && singleEntry && ( {isSingleEntry && singleEntry && (
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5"> <div className="grid grid-cols-2 gap-x-2 gap-y-1.5">
{visibleFields.map((field) => ( {visibleFields.map((field) => (
<div key={field.name} className={cn("space-y-0.5", field.type === "textarea" && "col-span-2")}> <div key={field.name} className={cn(
<label className="text-[11px] leading-none font-medium"> "space-y-0.5",
{field.label} field.type === "textarea" && "col-span-2"
{field.required && <span className="text-destructive ml-0.5">*</span>} )}>
<label className="text-[11px] font-medium leading-none">
{field.label}{(field.required || isColumnRequiredByMeta(componentConfig.targetTable, field.name)) && <span className="text-orange-500">*</span>}
</label> </label>
{renderField(field, item.id, group.id, singleEntry.id, singleEntry)} {renderField(field, item.id, group.id, singleEntry.id, singleEntry)}
</div> </div>
@ -1992,16 +1969,15 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
{/* 폼 영역 (편집 시에만 아래로 펼침) - 컴팩트 */} {/* 폼 영역 (편집 시에만 아래로 펼침) - 컴팩트 */}
{isEditingThisEntry && ( {isEditingThisEntry && (
<div className="border-t px-2 pt-1.5 pb-2"> <div className="border-t px-2 pb-2 pt-1.5">
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5"> <div className="grid grid-cols-2 gap-x-2 gap-y-1.5">
{visibleFields.map((field) => ( {visibleFields.map((field) => (
<div <div key={field.name} className={cn(
key={field.name} "space-y-0.5",
className={cn("space-y-0.5", field.type === "textarea" && "col-span-2")} field.type === "textarea" && "col-span-2"
> )}>
<label className="text-[11px] leading-none font-medium"> <label className="text-[11px] font-medium leading-none">
{field.label} {field.label}{(field.required || isColumnRequiredByMeta(componentConfig.targetTable, field.name)) && <span className="text-orange-500">*</span>}
{field.required && <span className="text-destructive ml-0.5">*</span>}
</label> </label>
{renderField(field, item.id, group.id, entry.id, entry)} {renderField(field, item.id, group.id, entry.id, entry)}
</div> </div>
@ -2054,6 +2030,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
?.map((col) => getFieldValue(item.originalData, col.name)) ?.map((col) => getFieldValue(item.originalData, col.name))
.filter(Boolean); .filter(Boolean);
return ( return (
<Card key={item.id} className="border shadow-sm"> <Card key={item.id} className="border shadow-sm">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@ -2375,8 +2352,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
{/* 추가 입력 필드 컬럼 */} {/* 추가 입력 필드 컬럼 */}
{componentConfig.additionalFields?.map((field) => ( {componentConfig.additionalFields?.map((field) => (
<TableHead key={field.name} className="h-12 px-4 py-3 text-xs font-semibold sm:text-sm"> <TableHead key={field.name} className="h-12 px-4 py-3 text-xs font-semibold sm:text-sm">
{field.label} {field.label}{(field.required || isColumnRequiredByMeta(componentConfig.targetTable, field.name)) && <span className="text-orange-500">*</span>}
{field.required && <span className="text-destructive ml-1">*</span>}
</TableHead> </TableHead>
))} ))}

View File

@ -10,6 +10,7 @@ import { cn } from "@/lib/utils";
import { ComponentRendererProps } from "@/types/component"; import { ComponentRendererProps } from "@/types/component";
import { useCalculation } from "./useCalculation"; import { useCalculation } from "./useCalculation";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { isColumnRequiredByMeta } from "@/lib/registry/DynamicComponentRenderer";
export interface SimpleRepeaterTableComponentProps extends ComponentRendererProps { export interface SimpleRepeaterTableComponentProps extends ComponentRendererProps {
config?: SimpleRepeaterTableProps; config?: SimpleRepeaterTableProps;
@ -674,8 +675,7 @@ export function SimpleRepeaterTableComponent({
className="text-muted-foreground px-4 py-2 text-left font-medium" className="text-muted-foreground px-4 py-2 text-left font-medium"
style={{ width: col.width }} style={{ width: col.width }}
> >
{col.label} {col.label}{(col.required || isColumnRequiredByMeta(componentTargetTable, col.field)) && <span className="text-orange-500">*</span>}
{col.required && <span className="text-destructive ml-1">*</span>}
</th> </th>
))} ))}
{!readOnly && allowDelete && ( {!readOnly && allowDelete && (

View File

@ -1263,56 +1263,79 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const getLeftColumnUniqueValues = useCallback( const getLeftColumnUniqueValues = useCallback(
async (columnName: string) => { async (columnName: string) => {
const leftTableName = componentConfig.leftPanel?.tableName; const leftTableName = componentConfig.leftPanel?.tableName;
if (!leftTableName || leftData.length === 0) return []; if (!leftTableName) return [];
// 현재 로드된 데이터에서 고유값 추출 // 1단계: 카테고리 API 시도 (DB에서 라벨 조회)
const uniqueValues = new Set<string>(); try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/table-categories/${leftTableName}/${columnName}/values`);
if (response.data.success && response.data.data && response.data.data.length > 0) {
return response.data.data.map((item: any) => ({
value: item.valueCode,
label: item.valueLabel,
}));
}
} catch {
// 카테고리 API 실패 시 다음 단계로
}
// 2단계: DISTINCT API (백엔드 라벨 변환 포함)
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/entity/${leftTableName}/distinct/${columnName}`);
if (response.data.success && response.data.data && response.data.data.length > 0) {
return response.data.data.map((item: any) => ({
value: String(item.value),
label: String(item.label),
}));
}
} catch {
// DISTINCT API 실패 시 다음 단계로
}
// 3단계: 로컬 데이터에서 고유값 추출 (최종 fallback)
if (leftData.length === 0) return [];
const uniqueValuesMap = new Map<string, string>();
leftData.forEach((item) => { leftData.forEach((item) => {
// 🆕 조인 컬럼 처리 (item_info.standard → item_code_standard 또는 item_id_standard)
let value: any; let value: any;
if (columnName.includes(".")) { if (columnName.includes(".")) {
// 조인 컬럼: getEntityJoinValue와 동일한 로직 적용
const [refTable, fieldName] = columnName.split("."); const [refTable, fieldName] = columnName.split(".");
const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id"); const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id");
// 정확한 키로 먼저 시도
const exactKey = `${inferredSourceColumn}_${fieldName}`; const exactKey = `${inferredSourceColumn}_${fieldName}`;
value = item[exactKey]; value = item[exactKey];
// 🆕 item_id 패턴 시도
if (value === undefined) { if (value === undefined) {
const idPatternKey = `${refTable.replace("_info", "_id").replace("_mng", "_id")}_${fieldName}`; const idPatternKey = `${refTable.replace("_info", "_id").replace("_mng", "_id")}_${fieldName}`;
value = item[idPatternKey]; value = item[idPatternKey];
} }
// 기본 별칭 패턴 시도 (item_code_name 또는 item_id_name)
if (value === undefined && (fieldName === "item_name" || fieldName === "name")) { if (value === undefined && (fieldName === "item_name" || fieldName === "name")) {
const aliasKey = `${inferredSourceColumn}_name`; const aliasKey = `${inferredSourceColumn}_name`;
value = item[aliasKey]; value = item[aliasKey];
// item_id_name 패턴도 시도
if (value === undefined) { if (value === undefined) {
const idAliasKey = `${refTable.replace("_info", "_id").replace("_mng", "_id")}_name`; const idAliasKey = `${refTable.replace("_info", "_id").replace("_mng", "_id")}_name`;
value = item[idAliasKey]; value = item[idAliasKey];
} }
} }
} else { } else {
// 일반 컬럼
value = item[columnName]; value = item[columnName];
} }
if (value !== null && value !== undefined && value !== "") { if (value !== null && value !== undefined && value !== "") {
// _name 필드 우선 사용 (category/entity type) const strValue = String(value);
const displayValue = item[`${columnName}_name`] || value; const nameField = item[`${columnName}_name`];
uniqueValues.add(String(displayValue)); const label = nameField || strValue;
uniqueValuesMap.set(strValue, label);
} }
}); });
return Array.from(uniqueValues).map((value) => ({ return Array.from(uniqueValuesMap.entries())
value: value, .map(([value, label]) => ({ value, label }))
label: value, .sort((a, b) => a.label.localeCompare(b.label));
}));
}, },
[componentConfig.leftPanel?.tableName, leftData], [componentConfig.leftPanel?.tableName, leftData],
); );
@ -1488,7 +1511,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
for (const col of categoryColumns) { for (const col of categoryColumns) {
const columnName = col.columnName || col.column_name; const columnName = col.columnName || col.column_name;
try { try {
const response = await apiClient.get(`/table-categories/${leftTableName}/${columnName}/values`); const response = await apiClient.get(`/table-categories/${leftTableName}/${columnName}/values?includeInactive=true`);
if (response.data.success && response.data.data) { if (response.data.success && response.data.data) {
const valueMap: Record<string, { label: string; color?: string }> = {}; const valueMap: Record<string, { label: string; color?: string }> = {};
@ -1550,7 +1573,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
for (const col of categoryColumns) { for (const col of categoryColumns) {
const columnName = col.columnName || col.column_name; const columnName = col.columnName || col.column_name;
try { try {
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`); const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values?includeInactive=true`);
if (response.data.success && response.data.data) { if (response.data.success && response.data.data) {
const valueMap: Record<string, { label: string; color?: string }> = {}; const valueMap: Record<string, { label: string; color?: string }> = {};

View File

@ -919,66 +919,63 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 컬럼의 고유 값 조회 함수 // 컬럼의 고유 값 조회 함수
const getColumnUniqueValues = async (columnName: string) => { const getColumnUniqueValues = async (columnName: string) => {
const meta = columnMeta[columnName]; const { apiClient } = await import("@/lib/api/client");
const inputType = meta?.inputType || "text";
// 카테고리 타입인 경우 전체 정의된 값 조회 (백엔드 API) // 1단계: 카테고리 API 시도 (columnMeta 무관하게 항상 시도)
if (inputType === "category") {
try {
// API 클라이언트 사용 (쿠키 인증 자동 처리)
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/table-categories/${tableConfig.selectedTable}/${columnName}/values`);
if (response.data.success && response.data.data) {
const categoryOptions = response.data.data.map((item: any) => ({
value: item.valueCode, // 카멜케이스
label: item.valueLabel, // 카멜케이스
}));
return categoryOptions;
}
} catch {
// 에러 시 현재 데이터 기반으로 fallback
}
}
// 백엔드 DISTINCT API로 전체 고유값 조회 (페이징과 무관하게 모든 값 반환)
try { try {
const { apiClient } = await import("@/lib/api/client"); const response = await apiClient.get(`/table-categories/${tableConfig.selectedTable}/${columnName}/values`);
const response = await apiClient.get(`/entity/${tableConfig.selectedTable}/distinct/${columnName}`);
if (response.data.success && response.data.data && response.data.data.length > 0) { if (response.data.success && response.data.data && response.data.data.length > 0) {
return response.data.data.map((item: any) => ({ return response.data.data.map((item: any) => ({
value: String(item.value), value: item.valueCode,
label: String(item.label), label: item.valueLabel,
})); }));
} }
} catch { } catch {
// DISTINCT API 실패 시 현재 데이터 기반으로 fallback // 카테고리 API 실패 시 다음 단계로
} }
// fallback: 현재 로드된 데이터에서 고유 값 추출 // 2단계: DISTINCT API (백엔드에서 category_values/code_info 라벨 변환 포함)
const isLabelType = ["category", "entity", "code"].includes(inputType); try {
const labelField = isLabelType ? `${columnName}_name` : columnName; const response = await apiClient.get(`/entity/${tableConfig.selectedTable}/distinct/${columnName}`);
if (response.data.success && response.data.data && response.data.data.length > 0) {
let options = response.data.data.map((item: any) => ({
value: String(item.value),
label: String(item.label),
}));
// 프론트엔드 카테고리 매핑으로 추가 라벨 변환
const mapping = categoryMappings[columnName];
if (mapping && Object.keys(mapping).length > 0) {
options = options.map((opt) => ({
value: opt.value,
label: mapping[opt.value]?.label || opt.label,
}));
}
return options;
}
} catch {
// DISTINCT API 실패 시 다음 단계로
}
// 3단계: 현재 로드된 데이터에서 고유 값 추출 (최종 fallback)
const uniqueValuesMap = new Map<string, string>(); const uniqueValuesMap = new Map<string, string>();
const mapping = categoryMappings[columnName];
data.forEach((row) => { data.forEach((row) => {
const value = row[columnName]; const value = row[columnName];
if (value !== null && value !== undefined && value !== "") { if (value !== null && value !== undefined && value !== "") {
const label = isLabelType && row[labelField] ? row[labelField] : String(value); const strValue = String(value);
uniqueValuesMap.set(String(value), label); const nameField = row[`${columnName}_name`];
const mappedLabel = mapping?.[strValue]?.label;
const label = mappedLabel || nameField || strValue;
uniqueValuesMap.set(strValue, label);
} }
}); });
const result = Array.from(uniqueValuesMap.entries()) return Array.from(uniqueValuesMap.entries())
.map(([value, label]) => ({ .map(([value, label]) => ({ value, label }))
value: value,
label: label,
}))
.sort((a, b) => a.label.localeCompare(b.label)); .sort((a, b) => a.label.localeCompare(b.label));
return result;
}; };
const registration = { const registration = {
@ -1031,6 +1028,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
tableConfig.columns, tableConfig.columns,
columnLabels, columnLabels,
columnMeta, // columnMeta가 변경되면 재등록 (inputType 정보 필요) columnMeta, // columnMeta가 변경되면 재등록 (inputType 정보 필요)
categoryMappings, // 카테고리 매핑 변경 시 재등록 (필터 라벨 변환용)
columnWidths, columnWidths,
tableLabel, tableLabel,
data, // 데이터 자체가 변경되면 재등록 (고유 값 조회용) data, // 데이터 자체가 변경되면 재등록 (고유 값 조회용)
@ -1298,7 +1296,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
targetColumn = parts[1]; targetColumn = parts[1];
} }
const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`); // 비활성화된 카테고리도 라벨로 표시하기 위해 includeInactive=true
const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values?includeInactive=true`);
if (response.data.success && response.data.data && Array.isArray(response.data.data)) { if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
const mapping: Record<string, { label: string; color?: string }> = {}; const mapping: Record<string, { label: string; color?: string }> = {};
@ -1381,7 +1380,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// inputType이 category인 경우 카테고리 매핑 로드 // inputType이 category인 경우 카테고리 매핑 로드
if (inputTypeInfo?.inputType === "category" && !mappings[col.columnName]) { if (inputTypeInfo?.inputType === "category" && !mappings[col.columnName]) {
try { try {
const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/values`); const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/values?includeInactive=true`);
if (response.data.success && response.data.data && Array.isArray(response.data.data)) { if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
const mapping: Record<string, { label: string; color?: string }> = {}; const mapping: Record<string, { label: string; color?: string }> = {};

View File

@ -393,12 +393,14 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTable?.tableName, filterMode, screenId, currentTableTabId, JSON.stringify(presetFilters)]); }, [currentTable?.tableName, filterMode, screenId, currentTableTabId, JSON.stringify(presetFilters)]);
// select 옵션 초기 로드 (한 번만 실행, 이후 유지) // select 옵션 로드 (getColumnUniqueValues 변경 시 재로드 - columnMeta 갱신 반영)
useEffect(() => { useEffect(() => {
if (!currentTable?.getColumnUniqueValues || activeFilters.length === 0) { if (!currentTable?.getColumnUniqueValues || activeFilters.length === 0) {
return; return;
} }
let cancelled = false;
const loadSelectOptions = async () => { const loadSelectOptions = async () => {
const selectFilters = activeFilters.filter((f) => f.filterType === "select"); const selectFilters = activeFilters.filter((f) => f.filterType === "select");
@ -406,26 +408,28 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
return; return;
} }
const newOptions: Record<string, Array<{ label: string; value: string }>> = { ...selectOptions }; const newOptions: Record<string, Array<{ label: string; value: string }>> = {};
for (const filter of selectFilters) { for (const filter of selectFilters) {
// 이미 로드된 옵션이 있으면 스킵 (초기값 유지)
if (newOptions[filter.columnName] && newOptions[filter.columnName].length > 0) {
continue;
}
try { try {
const options = await currentTable.getColumnUniqueValues(filter.columnName); const options = await currentTable.getColumnUniqueValues(filter.columnName);
newOptions[filter.columnName] = options; if (options && options.length > 0) {
newOptions[filter.columnName] = options;
}
} catch (error) { } catch (error) {
console.error("❌ [TableSearchWidget] select 옵션 로드 실패:", filter.columnName, error); console.error("❌ [TableSearchWidget] select 옵션 로드 실패:", filter.columnName, error);
} }
} }
setSelectOptions(newOptions);
if (!cancelled && Object.keys(newOptions).length > 0) {
setSelectOptions((prev) => ({ ...prev, ...newOptions }));
}
}; };
loadSelectOptions(); loadSelectOptions();
}, [activeFilters, currentTable?.tableName, currentTable?.getColumnUniqueValues]); // dataCount 제거, tableName으로 변경
return () => { cancelled = true; };
}, [activeFilters, currentTable?.tableName, currentTable?.getColumnUniqueValues]);
// 높이 변화 감지 및 알림 (실제 화면에서만) // 높이 변화 감지 및 알림 (실제 화면에서만)
useEffect(() => { useEffect(() => {

View File

@ -42,6 +42,7 @@ import {
} from "./types"; } from "./types";
import { defaultConfig, generateUniqueId } from "./config"; import { defaultConfig, generateUniqueId } from "./config";
import { TableSectionRenderer } from "./TableSectionRenderer"; import { TableSectionRenderer } from "./TableSectionRenderer";
import { isColumnRequiredByMeta } from "@/lib/registry/DynamicComponentRenderer";
/** /**
* 🔗 Select * 🔗 Select
@ -1362,7 +1363,7 @@ export function UniversalFormModalComponent({
label: String(row[optionConfig.labelColumn || "name"]), label: String(row[optionConfig.labelColumn || "name"]),
})); }));
} else if (optionConfig.type === "code" && optionConfig.categoryKey) { } else if (optionConfig.type === "code" && optionConfig.categoryKey) {
// 공통코드(카테고리 컬럼): table_column_category_values 테이블에서 조회 // 공통코드(카테고리 컬럼): category_values 테이블에서 조회
// categoryKey 형식: "tableName.columnName" // categoryKey 형식: "tableName.columnName"
const [categoryTable, categoryColumn] = optionConfig.categoryKey.split("."); const [categoryTable, categoryColumn] = optionConfig.categoryKey.split(".");
if (categoryTable && categoryColumn) { if (categoryTable && categoryColumn) {
@ -1438,15 +1439,17 @@ export function UniversalFormModalComponent({
[linkedFieldDataCache], [linkedFieldDataCache],
); );
// 필수 필드 검증 // 필수 필드 검증 (수동 required + NOT NULL 메타데이터 통합)
const mainTableName = config.saveConfig?.customApiSave?.multiTable?.mainTable?.tableName || config.saveConfig?.tableName;
const validateRequiredFields = useCallback((): { valid: boolean; missingFields: string[] } => { const validateRequiredFields = useCallback((): { valid: boolean; missingFields: string[] } => {
const missingFields: string[] = []; const missingFields: string[] = [];
for (const section of config.sections) { for (const section of config.sections) {
if (section.repeatable || section.type === "table") continue; // 반복 섹션 및 테이블 섹션은 별도 검증 if (section.repeatable || section.type === "table") continue;
for (const field of section.fields || []) { for (const field of section.fields || []) {
if (field.required && !field.hidden && !field.numberingRule?.hidden) { const isRequired = field.required || isColumnRequiredByMeta(mainTableName, field.columnName);
if (isRequired && !field.hidden && !field.numberingRule?.hidden) {
const value = formData[field.columnName]; const value = formData[field.columnName];
if (value === undefined || value === null || value === "") { if (value === undefined || value === null || value === "") {
missingFields.push(field.label || field.columnName); missingFields.push(field.label || field.columnName);
@ -1456,7 +1459,7 @@ export function UniversalFormModalComponent({
} }
return { valid: missingFields.length === 0, missingFields }; return { valid: missingFields.length === 0, missingFields };
}, [config.sections, formData]); }, [config.sections, formData, mainTableName]);
// 다중 테이블 저장 (범용) // 다중 테이블 저장 (범용)
const saveWithMultiTable = useCallback(async () => { const saveWithMultiTable = useCallback(async () => {
@ -2007,8 +2010,7 @@ export function UniversalFormModalComponent({
return ( return (
<div key={fieldKey} className="space-y-1" style={{ gridColumn: `span ${actualGridSpan}` }}> <div key={fieldKey} className="space-y-1" style={{ gridColumn: `span ${actualGridSpan}` }}>
<Label htmlFor={fieldKey} className="text-sm font-medium"> <Label htmlFor={fieldKey} className="text-sm font-medium">
{field.label} {field.label}{(field.required || isColumnRequiredByMeta(mainTableName, field.columnName)) && <span className="text-orange-500">*</span>}
{field.required && <span className="text-destructive ml-1">*</span>}
{field.numberingRule?.enabled && <span className="text-muted-foreground ml-1 text-xs">()</span>} {field.numberingRule?.enabled && <span className="text-muted-foreground ml-1 text-xs">()</span>}
</Label> </Label>
{fieldElement} {fieldElement}

View File

@ -31,7 +31,7 @@ import {
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { getCascadingRelations, getCascadingRelationByCode, CascadingRelation } from "@/lib/api/cascadingRelation"; import { getCascadingRelations, getCascadingRelationByCode, CascadingRelation } from "@/lib/api/cascadingRelation";
// 카테고리 컬럼 타입 (table_column_category_values 용) // 카테고리 컬럼 타입 (category_values 용)
interface CategoryColumnOption { interface CategoryColumnOption {
tableName: string; tableName: string;
columnName: string; columnName: string;

View File

@ -2640,7 +2640,7 @@ interface TableSectionSettingsModalProps {
{ column_name: string; data_type: string; is_nullable: string; comment?: string; input_type?: string }[] { column_name: string; data_type: string; is_nullable: string; comment?: string; input_type?: string }[]
>; >;
onLoadTableColumns: (tableName: string) => void; onLoadTableColumns: (tableName: string) => void;
// 카테고리 목록 (table_column_category_values에서 가져옴) // 카테고리 목록 (category_values에서 가져옴)
categoryList?: { tableName: string; columnName: string; displayName?: string }[]; categoryList?: { tableName: string; columnName: string; displayName?: string }[];
onLoadCategoryList?: () => void; onLoadCategoryList?: () => void;
// 전체 섹션 목록 (다른 섹션 필드 참조용) // 전체 섹션 목록 (다른 섹션 필드 참조용)

View File

@ -16,7 +16,7 @@ export interface SelectOptionConfig {
labelColumn?: string; // 표시할 컬럼 (화면에 보여줄 텍스트) labelColumn?: string; // 표시할 컬럼 (화면에 보여줄 텍스트)
saveColumn?: string; // 저장할 컬럼 (실제로 DB에 저장할 값, 미지정 시 valueColumn 사용) saveColumn?: string; // 저장할 컬럼 (실제로 DB에 저장할 값, 미지정 시 valueColumn 사용)
filterCondition?: string; filterCondition?: string;
// 카테고리 컬럼 기반 옵션 (table_column_category_values 테이블) // 카테고리 컬럼 기반 옵션 (category_values 테이블)
// 형식: "tableName.columnName" (예: "sales_order_mng.incoterms") // 형식: "tableName.columnName" (예: "sales_order_mng.incoterms")
categoryKey?: string; categoryKey?: string;

View File

@ -13,6 +13,7 @@ import {
import { Calculator, TrendingUp, Hash, ArrowUp, ArrowDown, Loader2 } from "lucide-react"; import { Calculator, TrendingUp, Hash, ArrowUp, ArrowDown, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
import { formatNumber } from "@/lib/formatting";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core"; import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
@ -571,11 +572,11 @@ export function AggregationWidgetComponent({
let formattedValue = value.toFixed(item.decimalPlaces ?? 0); let formattedValue = value.toFixed(item.decimalPlaces ?? 0);
if (item.format === "currency") { if (item.format === "currency") {
formattedValue = new Intl.NumberFormat("ko-KR").format(value); formattedValue = formatNumber(value);
} else if (item.format === "percent") { } else if (item.format === "percent") {
formattedValue = `${(value * 100).toFixed(item.decimalPlaces ?? 1)}%`; formattedValue = `${(value * 100).toFixed(item.decimalPlaces ?? 1)}%`;
} else if (item.format === "number") { } else if (item.format === "number") {
formattedValue = new Intl.NumberFormat("ko-KR").format(value); formattedValue = formatNumber(value);
} }
if (item.prefix) { if (item.prefix) {

View File

@ -378,7 +378,7 @@ export function BomItemEditorComponent({
if (categoryOptionsMap[categoryRef]) continue; if (categoryOptionsMap[categoryRef]) continue;
try { try {
const response = await apiClient.get(`/table-categories/${mainTableName}/${col.key}/values`); const response = await apiClient.get(`/table-categories/${mainTableName}/${col.key}/values?includeInactive=true`);
if (response.data?.success && response.data.data) { if (response.data?.success && response.data.data) {
const options = response.data.data.map((item: any) => ({ const options = response.data.data.map((item: any) => ({
value: item.valueCode || item.value_code, value: item.valueCode || item.value_code,

View File

@ -22,6 +22,7 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils"; import { showErrorToast } from "@/lib/utils/toastUtils";
import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { ButtonIconRenderer } from "@/lib/button-icon-map";
import { useCurrentFlowStep } from "@/stores/flowStepStore"; import { useCurrentFlowStep } from "@/stores/flowStepStore";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext"; import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { useScreenContextOptional } from "@/contexts/ScreenContext";
@ -29,7 +30,6 @@ import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelC
import { applyMappingRules } from "@/lib/utils/dataMapping"; import { applyMappingRules } from "@/lib/utils/dataMapping";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { V2ErrorBoundary, v2EventBus, V2_EVENTS } from "@/lib/v2-core"; import { V2ErrorBoundary, v2EventBus, V2_EVENTS } from "@/lib/v2-core";
export interface ButtonPrimaryComponentProps extends ComponentRendererProps { export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
config?: ButtonPrimaryConfig; config?: ButtonPrimaryConfig;
// 추가 props // 추가 props
@ -556,13 +556,23 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
} }
// 스타일 계산 // 스타일 계산
// 🔧 사용자가 설정한 크기가 있으면 그대로 사용 // 외부 wrapper는 부모 컨테이너(RealtimePreviewDynamic)에 맞춰 100% 채움
const componentStyle: React.CSSProperties = { // border는 내부 버튼에서만 적용 (wrapper에 적용되면 이중 테두리 발생)
const {
border: _border, borderWidth: _bw, borderStyle: _bs, borderColor: _bc, borderRadius: _br,
...restComponentStyle
} = {
...component.style, ...component.style,
...style, ...style,
} as React.CSSProperties & Record<string, any>;
const componentStyle: React.CSSProperties = {
...restComponentStyle,
width: "100%",
height: "100%",
}; };
// 디자인 모드 스타일 (border 속성 분리하여 충돌 방지) // 디자인 모드 스타일
if (isDesignMode) { if (isDesignMode) {
componentStyle.borderWidth = "1px"; componentStyle.borderWidth = "1px";
componentStyle.borderStyle = "dashed"; componentStyle.borderStyle = "dashed";
@ -612,7 +622,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 실패한 경우 오류 처리 // 실패한 경우 오류 처리
if (!success) { if (!success) {
// UI 전환 액션 및 모달 액션은 에러도 조용히 처리 (모달 내부에서 자체 에러 표시) // UI 전환 액션 및 모달 액션은 에러도 조용히 처리 (모달 내부에서 자체 에러 표시)
const silentErrorActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"]; const silentErrorActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan", "save", "delete", "quickInsert"];
if (silentErrorActions.includes(actionConfig.type)) { if (silentErrorActions.includes(actionConfig.type)) {
return; return;
} }
@ -1217,15 +1227,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
effectiveFormData = { ...splitPanelParentData }; effectiveFormData = { ...splitPanelParentData };
} }
console.log("🔴 [ButtonPrimary] 저장 시 formData 디버그:", {
propsFormDataKeys: Object.keys(propsFormData),
screenContextFormDataKeys: Object.keys(screenContextFormData),
effectiveFormDataKeys: Object.keys(effectiveFormData),
process_code: effectiveFormData.process_code,
equipment_code: effectiveFormData.equipment_code,
fullData: JSON.stringify(effectiveFormData),
});
const context: ButtonActionContext = { const context: ButtonActionContext = {
formData: effectiveFormData, formData: effectiveFormData,
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용) originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
@ -1382,31 +1383,29 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
} }
} }
// 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화 + 행 선택 필수)
const finalDisabled = const finalDisabled =
componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading; componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading;
// 공통 버튼 스타일 // 공통 버튼 스타일
// 🔧 component.style에서 background/backgroundColor 충돌 방지 (width/height는 허용) // 크기는 부모 컨테이너(RealtimePreviewDynamic)에서 관리하므로 width/height 제외
const userStyle = component.style const userStyle = component.style
? Object.fromEntries( ? Object.fromEntries(
Object.entries(component.style).filter(([key]) => !["background", "backgroundColor"].includes(key)), Object.entries(component.style).filter(([key]) => !["background", "backgroundColor", "width", "height"].includes(key)),
) )
: {}; : {};
// 🔧 사용자가 설정한 크기 우선 사용, 없으면 100% // 버튼은 부모 컨테이너를 꽉 채움 (크기는 RealtimePreviewDynamic에서 관리)
const buttonWidth = component.size?.width ? `${component.size.width}px` : style?.width || "100%"; const buttonWidth = "100%";
const buttonHeight = component.size?.height ? `${component.size.height}px` : style?.height || "100%"; const buttonHeight = "100%";
const buttonElementStyle: React.CSSProperties = { const buttonElementStyle: React.CSSProperties = {
width: buttonWidth, width: buttonWidth,
height: buttonHeight, height: buttonHeight,
minHeight: "32px", // 🔧 최소 높이를 32px로 줄임 minHeight: "32px", // 🔧 최소 높이를 32px로 줄임
// 🔧 커스텀 테두리 스타일 (StyleEditor에서 설정한 값 우선) // 커스텀 테두리 스타일 (StyleEditor 설정 우선, shorthand 사용 안 함)
border: style?.border || (style?.borderWidth ? undefined : "none"), borderWidth: style?.borderWidth || "0",
borderWidth: style?.borderWidth || undefined, borderStyle: (style?.borderStyle as React.CSSProperties["borderStyle"]) || (style?.borderWidth ? "solid" : "none"),
borderStyle: (style?.borderStyle as React.CSSProperties["borderStyle"]) || undefined, borderColor: style?.borderColor || "transparent",
borderColor: style?.borderColor || undefined,
borderRadius: style?.borderRadius || "0.5rem", borderRadius: style?.borderRadius || "0.5rem",
backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor, backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor,
color: finalDisabled ? "#9ca3af" : style?.color || buttonTextColor, // 🔧 StyleEditor 텍스트 색상도 지원 color: finalDisabled ? "#9ca3af" : style?.color || buttonTextColor, // 🔧 StyleEditor 텍스트 색상도 지원
@ -1446,7 +1445,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
cancel: "취소", cancel: "취소",
}; };
const buttonContent = const buttonTextContent =
processedConfig.text || processedConfig.text ||
component.webTypeConfig?.text || component.webTypeConfig?.text ||
component.componentConfig?.text || component.componentConfig?.text ||
@ -1460,16 +1459,17 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
<> <>
<div style={componentStyle} className={className} {...safeDomProps}> <div style={componentStyle} className={className} {...safeDomProps}>
{isDesignMode ? ( {isDesignMode ? (
// 디자인 모드: div로 렌더링하여 선택 가능하게 함
<div <div
className="transition-colors duration-150 hover:opacity-90" className="transition-colors duration-150 hover:opacity-90"
style={buttonElementStyle} style={buttonElementStyle}
onClick={handleClick} onClick={handleClick}
> >
{buttonContent} <ButtonIconRenderer
componentConfig={componentConfig}
fallbackLabel={buttonTextContent as string}
/>
</div> </div>
) : ( ) : (
// 일반 모드: button으로 렌더링
<button <button
type={componentConfig.actionType || "button"} type={componentConfig.actionType || "button"}
disabled={finalDisabled} disabled={finalDisabled}
@ -1478,8 +1478,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
onClick={handleClick} onClick={handleClick}
onDragStart={onDragStart} onDragStart={onDragStart}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
{...(actionType ? { "data-action-type": actionType } : {})}
> >
{buttonContent} <ButtonIconRenderer
componentConfig={componentConfig}
fallbackLabel={buttonTextContent as string}
/>
</button> </button>
)} )}
</div> </div>

View File

@ -140,15 +140,16 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
// 삭제할 데이터를 배열로 감싸기 (API가 배열을 기대함) // 삭제할 데이터를 배열로 감싸기 (API가 배열을 기대함)
const deleteData = [data]; const deleteData = [data];
// API 호출로 데이터 삭제 (POST 방식으로 변경 - DELETE는 body 전달이 불안정) // API 호출로 데이터 삭제 (POST 방식으로 변경 - DELETE는 body 전달이 불안정)
// 백엔드 API는 DELETE /api/table-management/tables/:tableName/delete 이지만 // 백엔드 API는 DELETE /api/table-management/tables/:tableName/delete 이지만
// axios에서 DELETE body 전달 문제가 있어 직접 request 설정 사용 // axios에서 DELETE body 전달 문제가 있어 직접 request 설정 사용
const response = await apiClient.request({ const response = await apiClient.request({
method: "DELETE", method: 'DELETE',
url: `/table-management/tables/${tableNameToUse}/delete`, url: `/table-management/tables/${tableNameToUse}/delete`,
data: deleteData, data: deleteData,
headers: { headers: {
"Content-Type": "application/json", 'Content-Type': 'application/json',
}, },
}); });
@ -156,7 +157,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
alert("삭제되었습니다."); alert("삭제되었습니다.");
// 로컬 상태에서 삭제된 항목 제거 // 로컬 상태에서 삭제된 항목 제거
setLoadedTableData((prev) => prev.filter((item, idx) => idx !== index)); setLoadedTableData(prev => prev.filter((item, idx) => idx !== index));
// 선택된 항목이면 선택 해제 // 선택된 항목이면 선택 해제
const cardKey = getCardKey(data, index); const cardKey = getCardKey(data, index);
@ -178,7 +179,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
const handleEditFormChange = (key: string, value: string) => { const handleEditFormChange = (key: string, value: string) => {
setEditData((prev: any) => ({ setEditData((prev: any) => ({
...prev, ...prev,
[key]: value, [key]: value
})); }));
}; };
@ -199,6 +200,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
// 데이터 새로고침 (필요시) // 데이터 새로고침 (필요시)
// loadTableData(); // loadTableData();
} catch (error) { } catch (error) {
alert("저장에 실패했습니다."); alert("저장에 실패했습니다.");
} }
@ -217,8 +219,8 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
// 우측 패널인 경우, 좌측 데이터가 선택되지 않으면 데이터 로드하지 않음 (깜빡임 방지) // 우측 패널인 경우, 좌측 데이터가 선택되지 않으면 데이터 로드하지 않음 (깜빡임 방지)
// splitPanelPosition이 "right"이면 분할 패널 내부이므로 연결 필터가 있을 가능성이 높음 // splitPanelPosition이 "right"이면 분할 패널 내부이므로 연결 필터가 있을 가능성이 높음
const isRightPanelEarly = splitPanelPosition === "right"; const isRightPanelEarly = splitPanelPosition === "right";
const hasSelectedLeftDataEarly = const hasSelectedLeftDataEarly = splitPanelContext?.selectedLeftData &&
splitPanelContext?.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0; Object.keys(splitPanelContext.selectedLeftData).length > 0;
if (isRightPanelEarly && !hasSelectedLeftDataEarly) { if (isRightPanelEarly && !hasSelectedLeftDataEarly) {
// 우측 패널이고 좌측 데이터가 선택되지 않은 경우 - 기존 데이터 유지 (깜빡임 방지) // 우측 패널이고 좌측 데이터가 선택되지 않은 경우 - 기존 데이터 유지 (깜빡임 방지)
@ -249,12 +251,13 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
// 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지) // 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지)
const linkedFiltersConfig = splitPanelContext.linkedFilters || []; const linkedFiltersConfig = splitPanelContext.linkedFilters || [];
hasLinkedFiltersConfigured = linkedFiltersConfig.some( hasLinkedFiltersConfigured = linkedFiltersConfig.some(
(filter) => filter.targetColumn?.startsWith(tableNameToUse + ".") || filter.targetColumn === tableNameToUse, (filter) => filter.targetColumn?.startsWith(tableNameToUse + ".") ||
filter.targetColumn === tableNameToUse
); );
// 좌측 데이터 선택 여부 확인 // 좌측 데이터 선택 여부 확인
hasSelectedLeftData = hasSelectedLeftData = splitPanelContext.selectedLeftData &&
splitPanelContext.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0; Object.keys(splitPanelContext.selectedLeftData).length > 0;
linkedFilterValues = splitPanelContext.getLinkedFilterValues(); linkedFilterValues = splitPanelContext.getLinkedFilterValues();
// 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서) // 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서)
@ -275,19 +278,20 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
} }
} }
linkedFilterValues = tableSpecificFilters; linkedFilterValues = tableSpecificFilters;
} }
// 우측 패널이고 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우 빈 데이터 표시 // 우측 패널이고 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우 빈 데이터 표시
// 또는 우측 패널이고 linkedFilters 설정이 있으면 좌측 선택 필수 // 또는 우측 패널이고 linkedFilters 설정이 있으면 좌측 선택 필수
// splitPanelPosition은 screenContext에서 가져오거나, splitPanelContext에서 screenId로 확인 // splitPanelPosition은 screenContext에서 가져오거나, splitPanelContext에서 screenId로 확인
const isRightPanelFromContext = splitPanelPosition === "right"; const isRightPanelFromContext = splitPanelPosition === "right";
const isRightPanelFromSplitContext = const isRightPanelFromSplitContext = screenId && splitPanelContext?.getPositionByScreenId
screenId && splitPanelContext?.getPositionByScreenId ? splitPanelContext.getPositionByScreenId(screenId as number) === "right"
? splitPanelContext.getPositionByScreenId(screenId as number) === "right" : false;
: false;
const isRightPanel = isRightPanelFromContext || isRightPanelFromSplitContext; const isRightPanel = isRightPanelFromContext || isRightPanelFromSplitContext;
const hasLinkedFiltersInConfig = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0; const hasLinkedFiltersInConfig = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0;
if (isRightPanel && (hasLinkedFiltersConfigured || hasLinkedFiltersInConfig) && !hasSelectedLeftData) { if (isRightPanel && (hasLinkedFiltersConfigured || hasLinkedFiltersInConfig) && !hasSelectedLeftData) {
setLoadedTableData([]); setLoadedTableData([]);
setLoading(false); setLoading(false);
@ -357,23 +361,24 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
.filter(([_, m]) => m.inputType === "category") .filter(([_, m]) => m.inputType === "category")
.map(([columnName]) => columnName); .map(([columnName]) => columnName);
if (categoryColumns.length > 0) { if (categoryColumns.length > 0) {
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {}; const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
for (const columnName of categoryColumns) { for (const columnName of categoryColumns) {
try { try {
const response = await apiClient.get(`/table-categories/${tableNameToUse}/${columnName}/values`); const response = await apiClient.get(`/table-categories/${tableNameToUse}/${columnName}/values?includeInactive=true`);
if (response.data.success && response.data.data) { if (response.data.success && response.data.data) {
const mapping: Record<string, { label: string; color?: string }> = {}; const mapping: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => { response.data.data.forEach((item: any) => {
// API 응답 형식: valueCode, valueLabel (camelCase) // API 응답 형식: valueCode, valueLabel (camelCase)
const code = item.valueCode || item.value_code || item.category_code || item.code || item.value; const code = item.valueCode || item.value_code || item.category_code || item.code || item.value;
const label = const label = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code;
item.valueLabel || item.value_label || item.category_name || item.name || item.label || code;
// color가 null/undefined/"none"이면 undefined로 유지 (배지 없음) // color가 null/undefined/"none"이면 undefined로 유지 (배지 없음)
const rawColor = item.color ?? item.badge_color; const rawColor = item.color ?? item.badge_color;
const color = rawColor && rawColor !== "none" ? rawColor : undefined; const color = (rawColor && rawColor !== "none") ? rawColor : undefined;
mapping[code] = { label, color }; mapping[code] = { label, color };
}); });
mappings[columnName] = mapping; mappings[columnName] = mapping;
@ -395,14 +400,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
}; };
loadTableData(); loadTableData();
}, [ }, [isDesignMode, tableName, component.componentConfig?.tableName, splitPanelContext?.selectedLeftData, splitPanelPosition, refreshKey]);
isDesignMode,
tableName,
component.componentConfig?.tableName,
splitPanelContext?.selectedLeftData,
splitPanelPosition,
refreshKey,
]);
// 컴포넌트 설정 (기본값 보장) // 컴포넌트 설정 (기본값 보장)
const componentConfig = { const componentConfig = {
@ -443,13 +441,12 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
} }
// 우측 패널 + 좌측 미선택 상태 체크를 위한 값들 (displayData 외부에서 계산) // 우측 패널 + 좌측 미선택 상태 체크를 위한 값들 (displayData 외부에서 계산)
const isRightPanelForDisplay = const isRightPanelForDisplay = splitPanelPosition === "right" ||
splitPanelPosition === "right" ||
(screenId && splitPanelContext?.getPositionByScreenId?.(screenId as number) === "right"); (screenId && splitPanelContext?.getPositionByScreenId?.(screenId as number) === "right");
const hasLinkedFiltersForDisplay = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0; const hasLinkedFiltersForDisplay = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0;
const selectedLeftDataForDisplay = splitPanelContext?.selectedLeftData; const selectedLeftDataForDisplay = splitPanelContext?.selectedLeftData;
const hasSelectedLeftDataForDisplay = const hasSelectedLeftDataForDisplay = selectedLeftDataForDisplay &&
selectedLeftDataForDisplay && Object.keys(selectedLeftDataForDisplay).length > 0; Object.keys(selectedLeftDataForDisplay).length > 0;
// 좌측 데이터가 한 번이라도 선택된 적이 있으면 기록 // 좌측 데이터가 한 번이라도 선택된 적이 있으면 기록
useEffect(() => { useEffect(() => {
@ -460,8 +457,9 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
// 우측 패널이고 연결 필터가 있고, 좌측 데이터가 한 번도 선택된 적이 없는 경우에만 "선택해주세요" 표시 // 우측 패널이고 연결 필터가 있고, 좌측 데이터가 한 번도 선택된 적이 없는 경우에만 "선택해주세요" 표시
// 한 번이라도 선택된 적이 있으면 깜빡임 방지를 위해 기존 데이터 유지 // 한 번이라도 선택된 적이 있으면 깜빡임 방지를 위해 기존 데이터 유지
const shouldHideDataForRightPanel = const shouldHideDataForRightPanel = isRightPanelForDisplay &&
isRightPanelForDisplay && !hasEverSelectedLeftData && !hasSelectedLeftDataForDisplay; !hasEverSelectedLeftData &&
!hasSelectedLeftDataForDisplay;
// 표시할 데이터 결정 (로드된 테이블 데이터 우선 사용) // 표시할 데이터 결정 (로드된 테이블 데이터 우선 사용)
const displayData = useMemo(() => { const displayData = useMemo(() => {
@ -497,100 +495,87 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
}, []); }, []);
// 카드 선택 핸들러 (단일 선택 - 다른 카드 선택 시 기존 선택 해제) // 카드 선택 핸들러 (단일 선택 - 다른 카드 선택 시 기존 선택 해제)
const handleCardSelection = useCallback( const handleCardSelection = useCallback((cardKey: string, data: any, checked: boolean) => {
(cardKey: string, data: any, checked: boolean) => { // 단일 선택: 새로운 Set 생성 (기존 선택 초기화)
// 단일 선택: 새로운 Set 생성 (기존 선택 초기화) const newSelectedRows = new Set<string>();
const newSelectedRows = new Set<string>();
if (checked) {
// 선택 시 해당 카드만 선택
newSelectedRows.add(cardKey);
}
// checked가 false면 빈 Set (선택 해제)
setSelectedRows(newSelectedRows);
// 선택된 카드 데이터 계산
const selectedRowsData = displayData.filter((item, index) =>
newSelectedRows.has(getCardKey(item, index))
);
// onFormDataChange 호출
if (onFormDataChange) {
onFormDataChange({
selectedRows: Array.from(newSelectedRows),
selectedRowsData,
});
}
// modalDataStore에 선택된 데이터 저장
const tableNameToUse = componentConfig.dataSource?.tableName || tableName;
if (tableNameToUse && selectedRowsData.length > 0) {
const modalItems = selectedRowsData.map((row, idx) => ({
id: getCardKey(row, idx),
originalData: row,
additionalData: {},
}));
useModalDataStore.getState().setData(tableNameToUse, modalItems);
} else if (tableNameToUse && selectedRowsData.length === 0) {
useModalDataStore.getState().clearData(tableNameToUse);
}
// 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달)
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
if (checked) { if (checked) {
// 선택 시 해당 카드만 선택 splitPanelContext.setSelectedLeftData(data);
newSelectedRows.add(cardKey); } else {
splitPanelContext.setSelectedLeftData(null);
} }
// checked가 false면 빈 Set (선택 해제) }
}, [displayData, getCardKey, onFormDataChange, componentConfig.dataSource?.tableName, tableName, splitPanelContext, splitPanelPosition]);
setSelectedRows(newSelectedRows); const handleCardClick = useCallback((data: any, index: number) => {
const cardKey = getCardKey(data, index);
const isCurrentlySelected = selectedRows.has(cardKey);
// 선택된 카드 데이터 계산 // 단일 선택: 이미 선택된 카드 클릭 시 선택 해제, 아니면 새로 선택
const selectedRowsData = displayData.filter((item, index) => newSelectedRows.has(getCardKey(item, index))); handleCardSelection(cardKey, data, !isCurrentlySelected);
// onFormDataChange 호출 if (componentConfig.onCardClick) {
if (onFormDataChange) { componentConfig.onCardClick(data);
onFormDataChange({ }
selectedRows: Array.from(newSelectedRows), }, [getCardKey, selectedRows, handleCardSelection, componentConfig.onCardClick]);
selectedRowsData,
});
}
// modalDataStore에 선택된 데이터 저장
const tableNameToUse = componentConfig.dataSource?.tableName || tableName;
if (tableNameToUse && selectedRowsData.length > 0) {
const modalItems = selectedRowsData.map((row, idx) => ({
id: getCardKey(row, idx),
originalData: row,
additionalData: {},
}));
useModalDataStore.getState().setData(tableNameToUse, modalItems);
} else if (tableNameToUse && selectedRowsData.length === 0) {
useModalDataStore.getState().clearData(tableNameToUse);
}
// 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달)
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
if (checked) {
splitPanelContext.setSelectedLeftData(data);
} else {
splitPanelContext.setSelectedLeftData(null);
}
}
},
[
displayData,
getCardKey,
onFormDataChange,
componentConfig.dataSource?.tableName,
tableName,
splitPanelContext,
splitPanelPosition,
],
);
const handleCardClick = useCallback(
(data: any, index: number) => {
const cardKey = getCardKey(data, index);
const isCurrentlySelected = selectedRows.has(cardKey);
// 단일 선택: 이미 선택된 카드 클릭 시 선택 해제, 아니면 새로 선택
handleCardSelection(cardKey, data, !isCurrentlySelected);
if (componentConfig.onCardClick) {
componentConfig.onCardClick(data);
}
},
[getCardKey, selectedRows, handleCardSelection, componentConfig.onCardClick],
);
// DataProvidable 인터페이스 구현 (테이블 리스트와 동일) // DataProvidable 인터페이스 구현 (테이블 리스트와 동일)
const dataProvider = useMemo( const dataProvider = useMemo(() => ({
() => ({ componentId: component.id,
componentId: component.id, componentType: "card-display" as const,
componentType: "card-display" as const,
getSelectedData: () => { getSelectedData: () => {
const selectedData = displayData.filter((item, index) => selectedRows.has(getCardKey(item, index))); const selectedData = displayData.filter((item, index) =>
return selectedData; selectedRows.has(getCardKey(item, index))
}, );
return selectedData;
},
getAllData: () => { getAllData: () => {
return displayData; return displayData;
}, },
clearSelection: () => { clearSelection: () => {
setSelectedRows(new Set()); setSelectedRows(new Set());
}, },
}), }), [component.id, displayData, selectedRows, getCardKey]);
[component.id, displayData, selectedRows, getCardKey],
);
// ScreenContext에 데이터 제공자로 등록 // ScreenContext에 데이터 제공자로 등록
useEffect(() => { useEffect(() => {
@ -605,7 +590,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
// TableOptionsContext에 테이블 등록 (검색 필터 위젯 연동용) // TableOptionsContext에 테이블 등록 (검색 필터 위젯 연동용)
const tableId = `card-display-${component.id}`; const tableId = `card-display-${component.id}`;
const tableNameToUse = tableName || component.componentConfig?.tableName || ""; const tableNameToUse = tableName || component.componentConfig?.tableName || '';
const tableLabel = component.componentConfig?.title || component.label || "카드 디스플레이"; const tableLabel = component.componentConfig?.title || component.label || "카드 디스플레이";
// ref로 최신 데이터 참조 (useCallback 의존성 문제 해결) // ref로 최신 데이터 참조 (useCallback 의존성 문제 해결)
@ -631,12 +616,11 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
if (!tableNameToUse || isDesignMode) return; if (!tableNameToUse || isDesignMode) return;
// 우측 패널이고 linkedFilters가 설정되어 있지만 좌측 데이터가 선택되지 않은 경우 스킵 // 우측 패널이고 linkedFilters가 설정되어 있지만 좌측 데이터가 선택되지 않은 경우 스킵
const isRightPanel = const isRightPanel = splitPanelPosition === "right" ||
splitPanelPosition === "right" ||
(screenId && splitPanelContext?.getPositionByScreenId?.(screenId as number) === "right"); (screenId && splitPanelContext?.getPositionByScreenId?.(screenId as number) === "right");
const hasLinkedFiltersInConfig = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0; const hasLinkedFiltersInConfig = splitPanelContext?.linkedFilters && splitPanelContext.linkedFilters.length > 0;
const hasSelectedLeftData = const hasSelectedLeftData = splitPanelContext?.selectedLeftData &&
splitPanelContext?.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0; Object.keys(splitPanelContext.selectedLeftData).length > 0;
// 우측 패널이고 좌측 데이터가 선택되지 않은 경우 - 기존 데이터 유지 (깜빡임 방지) // 우측 패널이고 좌측 데이터가 선택되지 않은 경우 - 기존 데이터 유지 (깜빡임 방지)
if (isRightPanel && !hasSelectedLeftData) { if (isRightPanel && !hasSelectedLeftData) {
@ -657,8 +641,8 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
// 필터 값을 검색 파라미터로 변환 // 필터 값을 검색 파라미터로 변환
const searchParams: Record<string, any> = {}; const searchParams: Record<string, any> = {};
filters.forEach((filter) => { filters.forEach(filter => {
if (filter.value !== undefined && filter.value !== null && filter.value !== "") { if (filter.value !== undefined && filter.value !== null && filter.value !== '') {
searchParams[filter.columnName] = filter.value; searchParams[filter.columnName] = filter.value;
} }
}); });
@ -687,32 +671,29 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
}, [filters, tableNameToUse, isDesignMode, tableId, splitPanelContext?.selectedLeftData, splitPanelPosition]); }, [filters, tableNameToUse, isDesignMode, tableId, splitPanelContext?.selectedLeftData, splitPanelPosition]);
// 컬럼 고유 값 조회 함수 (select 타입 필터용) // 컬럼 고유 값 조회 함수 (select 타입 필터용)
const getColumnUniqueValues = useCallback( const getColumnUniqueValues = useCallback(async (columnName: string): Promise<Array<{ label: string; value: string }>> => {
async (columnName: string): Promise<Array<{ label: string; value: string }>> => { if (!tableNameToUse) return [];
if (!tableNameToUse) return [];
try { try {
// 현재 로드된 데이터에서 고유 값 추출 // 현재 로드된 데이터에서 고유 값 추출
const uniqueValues = new Set<string>(); const uniqueValues = new Set<string>();
loadedTableDataRef.current.forEach((row) => { loadedTableDataRef.current.forEach(row => {
const value = row[columnName]; const value = row[columnName];
if (value !== null && value !== undefined && value !== "") { if (value !== null && value !== undefined && value !== '') {
uniqueValues.add(String(value)); uniqueValues.add(String(value));
} }
}); });
// 카테고리 매핑이 있으면 라벨 적용 // 카테고리 매핑이 있으면 라벨 적용
const mapping = categoryMappingsRef.current[columnName]; const mapping = categoryMappingsRef.current[columnName];
return Array.from(uniqueValues).map((value) => ({ return Array.from(uniqueValues).map(value => ({
value, value,
label: mapping?.[value]?.label || value, label: mapping?.[value]?.label || value,
})); }));
} catch (error) { } catch (error) {
return []; return [];
} }
}, }, [tableNameToUse]);
[tableNameToUse],
);
// TableOptionsContext에 등록 // TableOptionsContext에 등록
// registerTable과 unregisterTable 함수 참조 저장 (의존성 안정화) // registerTable과 unregisterTable 함수 참조 저장 (의존성 안정화)
@ -747,7 +728,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
const columns: TableColumn[] = loadedTableColumns.map((col: any) => ({ const columns: TableColumn[] = loadedTableColumns.map((col: any) => ({
columnName: col.columnName || col.column_name, columnName: col.columnName || col.column_name,
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name, columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
inputType: columnMeta[col.columnName || col.column_name]?.inputType || "text", inputType: columnMeta[col.columnName || col.column_name]?.inputType || 'text',
visible: true, visible: true,
width: 200, width: 200,
sortable: true, sortable: true,
@ -810,7 +791,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
}} }}
> >
<div className="text-muted-foreground text-center"> <div className="text-muted-foreground text-center">
<div className="mb-2 text-lg"> </div> <div className="text-lg mb-2"> </div>
<div className="text-sm text-gray-400"> </div> <div className="text-sm text-gray-400"> </div>
</div> </div>
</div> </div>
@ -918,7 +899,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
backgroundColor: displayColor, backgroundColor: displayColor,
borderColor: displayColor, borderColor: displayColor,
}} }}
className="text-xs text-white" className="text-white text-xs"
> >
{displayLabel} {displayLabel}
</Badge> </Badge>
@ -934,7 +915,9 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
// 컬럼 정보가 없으면 컬럼명을 보기 좋게 변환 // 컬럼 정보가 없으면 컬럼명을 보기 좋게 변환
return formatColumnName(columnName); return formatColumnName(columnName);
} }
const column = actualTableColumns.find((col) => col.columnName === columnName || col.column_name === columnName); const column = actualTableColumns.find(
(col) => col.columnName === columnName || col.column_name === columnName
);
// 다양한 라벨 필드명 지원 (displayName이 API에서 반환하는 라벨) // 다양한 라벨 필드명 지원 (displayName이 API에서 반환하는 라벨)
const label = column?.displayName || column?.columnLabel || column?.column_label || column?.label; const label = column?.displayName || column?.columnLabel || column?.column_label || column?.label;
return label || formatColumnName(columnName); return label || formatColumnName(columnName);
@ -943,7 +926,9 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
// 컬럼명을 보기 좋은 형태로 변환 (snake_case -> 공백 구분) // 컬럼명을 보기 좋은 형태로 변환 (snake_case -> 공백 구분)
const formatColumnName = (columnName: string) => { const formatColumnName = (columnName: string) => {
// 언더스코어를 공백으로 변환하고 각 단어 첫 글자 대문자화 // 언더스코어를 공백으로 변환하고 각 단어 첫 글자 대문자화
return columnName.replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()); return columnName
.replace(/_/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase());
}; };
// 자동 폴백 로직 - 컬럼이 설정되지 않은 경우 적절한 기본값 찾기 // 자동 폴백 로직 - 컬럼이 설정되지 않은 경우 적절한 기본값 찾기
@ -982,7 +967,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
overflow: hidden; overflow: hidden;
} }
.card-hover::before { .card-hover::before {
content: ""; content: '';
position: absolute; position: absolute;
top: 0; top: 0;
left: -100%; left: -100%;
@ -1005,7 +990,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
position: relative; position: relative;
} }
.card-container::before { .card-container::before {
content: ""; content: '';
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
@ -1044,8 +1029,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
displayData.map((data, index) => { displayData.map((data, index) => {
// 타이틀, 서브타이틀, 설명 값 결정 (문자열로 가져와서 표시) // 타이틀, 서브타이틀, 설명 값 결정 (문자열로 가져와서 표시)
const titleValue = const titleValue =
getColumnValueAsString(data, componentConfig.columnMapping?.titleColumn) || getColumnValueAsString(data, componentConfig.columnMapping?.titleColumn) || getAutoFallbackValue(data, "title");
getAutoFallbackValue(data, "title");
const subtitleValue = const subtitleValue =
getColumnValueAsString(data, componentConfig.columnMapping?.subtitleColumn) || getColumnValueAsString(data, componentConfig.columnMapping?.subtitleColumn) ||
@ -1056,24 +1040,18 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
getAutoFallbackValue(data, "description"); getAutoFallbackValue(data, "description");
// 이미지 컬럼 자동 감지 (image_path, photo 등) - 대소문자 무시 // 이미지 컬럼 자동 감지 (image_path, photo 등) - 대소문자 무시
const imageColumn = const imageColumn = componentConfig.columnMapping?.imageColumn ||
componentConfig.columnMapping?.imageColumn || Object.keys(data).find(key => {
Object.keys(data).find((key) => {
const lowerKey = key.toLowerCase(); const lowerKey = key.toLowerCase();
return ( return lowerKey.includes('image') || lowerKey.includes('photo') ||
lowerKey.includes("image") || lowerKey.includes('avatar') || lowerKey.includes('thumbnail') ||
lowerKey.includes("photo") || lowerKey.includes('picture') || lowerKey.includes('img');
lowerKey.includes("avatar") ||
lowerKey.includes("thumbnail") ||
lowerKey.includes("picture") ||
lowerKey.includes("img")
);
}); });
// 이미지 값 가져오기 (직접 접근 + 폴백) // 이미지 값 가져오기 (직접 접근 + 폴백)
const imageValue = imageColumn const imageValue = imageColumn
? data[imageColumn] ? data[imageColumn]
: data.image_path || data.imagePath || data.avatar || data.image || data.photo || ""; : (data.image_path || data.imagePath || data.avatar || data.image || data.photo || "");
// 이미지 표시 여부 결정: 이미지 값이 있거나, 설정에서 활성화된 경우 // 이미지 표시 여부 결정: 이미지 값이 있거나, 설정에서 활성화된 경우
const shouldShowImage = componentConfig.cardStyle?.showImage !== false; const shouldShowImage = componentConfig.cardStyle?.showImage !== false;
@ -1091,7 +1069,9 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
...cardStyle, ...cardStyle,
borderColor: isCardSelected ? "#000" : "#e5e7eb", borderColor: isCardSelected ? "#000" : "#e5e7eb",
borderWidth: isCardSelected ? "2px" : "1px", borderWidth: isCardSelected ? "2px" : "1px",
boxShadow: isCardSelected ? "0 4px 6px -1px rgba(0, 0, 0, 0.15)" : "0 1px 3px rgba(0, 0, 0, 0.08)", boxShadow: isCardSelected
? "0 4px 6px -1px rgba(0, 0, 0, 0.15)"
: "0 1px 3px rgba(0, 0, 0, 0.08)",
flexDirection: "row", // 가로 배치 flexDirection: "row", // 가로 배치
}} }}
className="card-hover group cursor-pointer transition-all duration-150" className="card-hover group cursor-pointer transition-all duration-150"
@ -1099,38 +1079,35 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
> >
{/* 카드 이미지 - 좌측 전체 높이 (이미지 컬럼이 있으면 자동 표시) */} {/* 카드 이미지 - 좌측 전체 높이 (이미지 컬럼이 있으면 자동 표시) */}
{shouldShowImage && ( {shouldShowImage && (
<div className="mr-4 flex flex-shrink-0 items-center justify-center"> <div className="flex-shrink-0 flex items-center justify-center mr-4">
{imageUrl ? ( {imageUrl ? (
<img <img
src={imageUrl} src={imageUrl}
alt={titleValue || "이미지"} alt={titleValue || "이미지"}
className="h-16 w-16 rounded-lg border border-gray-200 object-cover" className="h-16 w-16 rounded-lg object-cover border border-gray-200"
onError={(e) => { onError={(e) => {
// 이미지 로드 실패 시 기본 아이콘으로 대체 // 이미지 로드 실패 시 기본 아이콘으로 대체
e.currentTarget.src = e.currentTarget.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Crect width='64' height='64' fill='%23e0e7ff' rx='8'/%3E%3Ctext x='32' y='40' text-anchor='middle' fill='%236366f1' font-size='24'%3E👤%3C/text%3E%3C/svg%3E";
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Crect width='64' height='64' fill='%23e0e7ff' rx='8'/%3E%3Ctext x='32' y='40' text-anchor='middle' fill='%236366f1' font-size='24'%3E👤%3C/text%3E%3C/svg%3E";
}} }}
/> />
) : ( ) : (
<div className="bg-primary/10 flex h-16 w-16 items-center justify-center rounded-lg"> <div className="flex h-16 w-16 items-center justify-center rounded-lg bg-primary/10">
<span className="text-primary text-2xl">👤</span> <span className="text-2xl text-primary">👤</span>
</div> </div>
)} )}
</div> </div>
)} )}
{/* 우측 컨텐츠 영역 */} {/* 우측 컨텐츠 영역 */}
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex flex-col flex-1 min-w-0">
{/* 타이틀 + 서브타이틀 */} {/* 타이틀 + 서브타이틀 */}
{(componentConfig.cardStyle?.showTitle || componentConfig.cardStyle?.showSubtitle) && ( {(componentConfig.cardStyle?.showTitle || componentConfig.cardStyle?.showSubtitle) && (
<div className="mb-1 flex flex-wrap items-center gap-2"> <div className="mb-1 flex items-center gap-2 flex-wrap">
{componentConfig.cardStyle?.showTitle && ( {componentConfig.cardStyle?.showTitle && (
<h3 className="text-foreground text-base leading-tight font-semibold">{titleValue}</h3> <h3 className="text-base font-semibold text-foreground leading-tight">{titleValue}</h3>
)} )}
{componentConfig.cardStyle?.showSubtitle && subtitleValue && ( {componentConfig.cardStyle?.showSubtitle && subtitleValue && (
<span className="text-primary bg-primary/10 rounded-full px-2 py-0.5 text-xs font-medium"> <span className="text-xs font-medium text-primary bg-primary/10 px-2 py-0.5 rounded-full">{subtitleValue}</span>
{subtitleValue}
</span>
)} )}
</div> </div>
)} )}
@ -1138,7 +1115,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
{/* 추가 표시 컬럼들 - 가로 배치 */} {/* 추가 표시 컬럼들 - 가로 배치 */}
{componentConfig.columnMapping?.displayColumns && {componentConfig.columnMapping?.displayColumns &&
componentConfig.columnMapping.displayColumns.length > 0 && ( componentConfig.columnMapping.displayColumns.length > 0 && (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs"> <div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
{componentConfig.columnMapping.displayColumns.map((columnName, idx) => { {componentConfig.columnMapping.displayColumns.map((columnName, idx) => {
const value = getColumnValue(data, columnName); const value = getColumnValue(data, columnName);
if (!value) return null; if (!value) return null;
@ -1146,7 +1123,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
return ( return (
<div key={idx} className="flex items-center gap-1"> <div key={idx} className="flex items-center gap-1">
<span>{getColumnLabel(columnName)}:</span> <span>{getColumnLabel(columnName)}:</span>
<span className="text-foreground font-medium">{value}</span> <span className="font-medium text-foreground">{value}</span>
</div> </div>
); );
})} })}
@ -1156,7 +1133,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
{/* 카드 설명 */} {/* 카드 설명 */}
{componentConfig.cardStyle?.showDescription && descriptionValue && ( {componentConfig.cardStyle?.showDescription && descriptionValue && (
<div className="mt-1 flex-1"> <div className="mt-1 flex-1">
<p className="text-muted-foreground text-xs leading-relaxed"> <p className="text-xs text-muted-foreground leading-relaxed">
{truncateText(descriptionValue, componentConfig.cardStyle?.maxDescriptionLength || 100)} {truncateText(descriptionValue, componentConfig.cardStyle?.maxDescriptionLength || 100)}
</p> </p>
</div> </div>
@ -1167,7 +1144,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
<div className="mt-2 flex justify-end space-x-2"> <div className="mt-2 flex justify-end space-x-2">
{(componentConfig.cardStyle?.showViewButton ?? true) && ( {(componentConfig.cardStyle?.showViewButton ?? true) && (
<button <button
className="text-xs text-blue-600 transition-colors hover:text-blue-800" className="text-xs text-blue-600 hover:text-blue-800 transition-colors"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleCardView(data); handleCardView(data);
@ -1178,7 +1155,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
)} )}
{(componentConfig.cardStyle?.showEditButton ?? true) && ( {(componentConfig.cardStyle?.showEditButton ?? true) && (
<button <button
className="text-muted-foreground hover:text-foreground text-xs transition-colors" className="text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleCardEdit(data); handleCardEdit(data);
@ -1189,7 +1166,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
)} )}
{(componentConfig.cardStyle?.showDeleteButton ?? false) && ( {(componentConfig.cardStyle?.showDeleteButton ?? false) && (
<button <button
className="text-xs text-red-500 transition-colors hover:text-red-700" className="text-xs text-red-500 hover:text-red-700 transition-colors"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleCardDelete(data, index); handleCardDelete(data, index);
@ -1210,7 +1187,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
{/* 상세보기 모달 */} {/* 상세보기 모달 */}
<Dialog open={viewModalOpen} onOpenChange={setViewModalOpen}> <Dialog open={viewModalOpen} onOpenChange={setViewModalOpen}>
<DialogContent className="max-h-[80vh] max-w-2xl overflow-hidden"> <DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<span className="text-lg">📋</span> <span className="text-lg">📋</span>
@ -1220,9 +1197,9 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
{selectedData && ( {selectedData && (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(selectedData) {Object.entries(selectedData)
.filter(([key, value]) => value !== null && value !== undefined && value !== "") .filter(([key, value]) => value !== null && value !== undefined && value !== '')
.map(([key, value]) => { .map(([key, value]) => {
// 카테고리 타입인 경우 배지로 표시 // 카테고리 타입인 경우 배지로 표시
const meta = columnMeta[key]; const meta = columnMeta[key];
@ -1256,19 +1233,22 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
return ( return (
<div key={key} className="bg-muted rounded-lg p-3"> <div key={key} className="bg-muted rounded-lg p-3">
<div className="text-muted-foreground mb-1 text-xs font-medium tracking-wide uppercase"> <div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
{getColumnLabel(key)} {getColumnLabel(key)}
</div> </div>
<div className="text-foreground text-sm font-medium break-words">{displayValue}</div> <div className="text-sm font-medium text-foreground break-words">
{displayValue}
</div>
</div> </div>
); );
})} })
}
</div> </div>
<div className="flex justify-end border-t pt-4"> <div className="flex justify-end pt-4 border-t">
<button <button
onClick={() => setViewModalOpen(false)} onClick={() => setViewModalOpen(false)}
className="text-foreground bg-muted hover:bg-muted/80 rounded-md px-4 py-2 text-sm font-medium transition-colors" className="px-4 py-2 text-sm font-medium text-foreground bg-muted hover:bg-muted/80 rounded-md transition-colors"
> >
</button> </button>
@ -1280,7 +1260,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
{/* 편집 모달 */} {/* 편집 모달 */}
<Dialog open={editModalOpen} onOpenChange={setEditModalOpen}> <Dialog open={editModalOpen} onOpenChange={setEditModalOpen}>
<DialogContent className="max-h-[80vh] max-w-2xl overflow-hidden"> <DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<span className="text-lg"></span> <span className="text-lg"></span>
@ -1295,8 +1275,8 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
.filter(([key, value]) => value !== null && value !== undefined) .filter(([key, value]) => value !== null && value !== undefined)
.map(([key, value]) => ( .map(([key, value]) => (
<div key={key} className="space-y-2"> <div key={key} className="space-y-2">
<label className="text-foreground block text-sm font-medium"> <label className="text-sm font-medium text-foreground block">
{key.replace(/_/g, " ").toUpperCase()} {key.replace(/_/g, ' ').toUpperCase()}
</label> </label>
<Input <Input
type="text" type="text"
@ -1306,10 +1286,11 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
placeholder={`${key} 입력`} placeholder={`${key} 입력`}
/> />
</div> </div>
))} ))
}
</div> </div>
<div className="flex justify-end gap-2 border-t pt-4"> <div className="flex justify-end gap-2 pt-4 border-t">
<Button <Button
variant="outline" variant="outline"
onClick={() => { onClick={() => {
@ -1319,7 +1300,10 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
> >
</Button> </Button>
<Button onClick={handleEditSave} className="bg-primary hover:bg-primary/90"> <Button
onClick={handleEditSave}
className="bg-primary hover:bg-primary/90"
>
</Button> </Button>
</div> </div>

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