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 aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트 API 프록시 (같은 포트로 서비스)
import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력
import moldRoutes from "./routes/moldRoutes"; // 금형 관리
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// 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/process-work-standard", processWorkStandardRoutes); // 공정 작업기준
app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력
app.use("/api/mold", moldRoutes); // 금형 관리
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import { Request, Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query } from "../database/db";
import logger from "../utils/logger";
import { TableManagementService } from "../services/tableManagementService";
/**
*
@ -81,6 +82,19 @@ async function executeMainDatabaseAction(
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()) {
case "insert":
return await executeInsert(tableName, dataWithCompany);

View File

@ -1,7 +1,9 @@
import { Response } from "express";
import { dynamicFormService } from "../services/dynamicFormService";
import { enhancedDynamicFormService } from "../services/enhancedDynamicFormService";
import { TableManagementService } from "../services/tableManagementService";
import { AuthenticatedRequest } from "../types/auth";
import { formatPgError } from "../utils/pgErrorUtil";
// 폼 데이터 저장 (기존 버전 - 레거시 지원)
export const saveFormData = async (
@ -47,6 +49,21 @@ export const saveFormData = async (
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 주소 추출
const ipAddress =
req.ip ||
@ -68,9 +85,12 @@ export const saveFormData = async (
});
} catch (error: any) {
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,
message: error.message || "데이터 저장에 실패했습니다.",
message: friendlyMsg,
});
}
};
@ -108,6 +128,21 @@ export const saveFormDataEnhanced = async (
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(
screenId,
@ -118,9 +153,12 @@ export const saveFormDataEnhanced = async (
res.json(result);
} catch (error: any) {
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,
message: error.message || "데이터 저장에 실패했습니다.",
message: friendlyMsg,
});
}
};
@ -146,12 +184,28 @@ export const updateFormData = async (
const formDataWithMeta = {
...data,
updated_by: userId,
writer: data.writer || userId, // ✅ writer가 없으면 userId로 설정
writer: data.writer || userId,
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(
id, // parseInt 제거 - 문자열 ID 지원
id,
tableName,
formDataWithMeta
);
@ -163,9 +217,12 @@ export const updateFormData = async (
});
} catch (error: any) {
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,
message: error.message || "데이터 업데이트에 실패했습니다.",
message: friendlyMsg,
});
}
};
@ -199,11 +256,27 @@ export const updateFormDataPartial = async (
const newDataWithMeta = {
...newData,
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(
id, // 🔧 parseInt 제거 - UUID 문자열도 지원
id,
tableName,
originalData,
newDataWithMeta
@ -216,9 +289,12 @@ export const updateFormDataPartial = async (
});
} catch (error: any) {
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,
message: error.message || "부분 업데이트에 실패했습니다.",
message: friendlyMsg,
});
}
};

View File

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

View File

@ -181,20 +181,92 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// DISTINCT 쿼리 실행
const query = `
// 1단계: DISTINCT 값 조회
const distinctQuery = `
SELECT DISTINCT "${columnName}" as value, "${effectiveLabelColumn}" as label
FROM "${tableName}"
${whereClause}
ORDER BY "${effectiveLabelColumn}" ASC
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 값 조회 성공", {
tableName,
columnName,
columnInputType: columnInputType || "none",
labelColumn: effectiveLabelColumn,
companyCode,
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 ====================
// [테스트] 테스트 테이블에서 채번 규칙 목록 조회

View File

@ -2087,6 +2087,23 @@ export async function multiTableSave(
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");
// 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
if (pgError.code === "23505") {
// 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") {
// foreign_key_violation
error = new AppError("참조 무결성 제약 조건 위반입니다.", 400);
} else if (pgError.code === "23502") {
// 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")) {
// 기타 무결성 제약 조건 위반
error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400);
@ -84,6 +98,7 @@ export const errorHandler = (
// 응답 전송
res.status(statusCode).json({
success: false,
message: message,
error: {
message: message,
...(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 { AuthenticatedRequest } from "../types/auth";
import { auditLogService } from "../services/auditLogService";
import { TableManagementService } from "../services/tableManagementService";
import { formatPgError } from "../utils/pgErrorUtil";
const router = express.Router();
@ -950,6 +952,20 @@ router.post(
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);
@ -1019,6 +1035,21 @@ router.put(
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);

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

View File

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

View File

@ -494,7 +494,7 @@ class MasterDetailExcelService {
/**
* , ID를
* , (*) fallback
* numbering_rules table_name + column_name + company_code로
*/
private async detectNumberingRuleForColumn(
tableName: string,
@ -502,32 +502,58 @@ class MasterDetailExcelService {
companyCode?: string
): Promise<{ numberingRuleId: string } | null> {
try {
// 회사별 설정 우선, 공통 설정 fallback (company_code DESC로 회사별이 먼저)
// 1. table_type_columns에서 numbering 타입인지 확인
const companyCondition = companyCode && companyCode !== "*"
? `AND company_code IN ($3, '*')`
: `AND company_code = '*'`;
const params = companyCode && companyCode !== "*"
const ttcParams = companyCode && companyCode !== "*"
? [tableName, columnName, companyCode]
: [tableName, columnName];
const result = await query<any>(
`SELECT input_type, detail_settings, company_code
FROM table_type_columns
const ttcResult = await query<any>(
`SELECT input_type FROM table_type_columns
WHERE table_name = $1 AND column_name = $2 ${companyCondition}
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
params
AND input_type = 'numbering' LIMIT 1`,
ttcParams
);
// 채번 타입인 행 찾기 (회사별 우선)
for (const row of result) {
if (row.input_type === "numbering") {
const settings = typeof row.detail_settings === "string"
? JSON.parse(row.detail_settings || "{}")
: row.detail_settings;
if (settings?.numberingRuleId) {
return { numberingRuleId: settings.numberingRuleId };
}
if (ttcResult.length === 0) return null;
// 2. numbering_rules에서 table_name + column_name으로 규칙 조회
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>
*/
private async detectAllNumberingColumns(
@ -549,6 +575,7 @@ class MasterDetailExcelService {
): Promise<Map<string, string>> {
const numberingCols = new Map<string, string>();
try {
// 1. table_type_columns에서 numbering 타입 컬럼 목록 조회
const companyCondition = companyCode && companyCode !== "*"
? `AND company_code IN ($2, '*')`
: `AND company_code = '*'`;
@ -556,22 +583,26 @@ class MasterDetailExcelService {
? [tableName, companyCode]
: [tableName];
const result = await query<any>(
`SELECT column_name, detail_settings, company_code
FROM table_type_columns
WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition}
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
const ttcResult = await query<any>(
`SELECT DISTINCT column_name FROM table_type_columns
WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition}`,
params
);
// 컬럼별로 회사 설정 우선 적용
for (const row of result) {
if (numberingCols.has(row.column_name)) continue; // 이미 회사별 설정이 있으면 스킵
const settings = typeof row.detail_settings === "string"
? JSON.parse(row.detail_settings || "{}")
: row.detail_settings;
if (settings?.numberingRuleId) {
numberingCols.set(row.column_name, settings.numberingRuleId);
// 2. 각 컬럼에 대해 numbering_rules에서 규칙 조회
for (const row of ttcResult) {
const ruleResult = await query<any>(
`SELECT rule_id FROM numbering_rules
WHERE table_name = $1 AND column_name = $2 ${companyCondition}
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END
LIMIT 1`,
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(
`SELECT * FROM table_column_category_values
`SELECT * FROM category_values
WHERE company_code = $1
AND (${columnConditions.join(" OR ")})
ORDER BY depth NULLS FIRST, parent_value_id NULLS FIRST, value_order`,
@ -3115,7 +3115,7 @@ export class MenuCopyService {
// 5. 대상 회사에 이미 존재하는 값 한 번에 조회
const existingValuesResult = await client.query(
`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]
);
const existingValueKeys = new Map(
@ -3194,7 +3194,7 @@ export class MenuCopyService {
});
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,
parent_value_id, depth, description, color, icon,
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(
`SELECT
c.column_name,
c.is_nullable,
c.is_nullable AS db_is_nullable,
c.column_default,
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
LEFT JOIN table_type_columns cl
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;
// FK 컬럼 제외 (reference_table이 있는 컬럼 = 다른 테이블의 PK를 참조)
// 단, 비즈니스적으로 의미 있는 FK는 남길 수 있으므로,
// _id로 끝나면서 reference_table이 있는 경우만 제외
// FK 컬럼 제외
if (row.reference_table && colName.endsWith("_id")) continue;
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;
columns.push({

View File

@ -172,6 +172,16 @@ class NumberingRuleService {
break;
}
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
prefixParts.push(String(formData[refColumn]));
} else {
prefixParts.push("");
}
break;
}
default:
break;
}
@ -1245,6 +1255,14 @@ class NumberingRuleService {
return "";
}
case "reference": {
const refColumn = autoConfig.referenceColumnName;
if (refColumn && formData && formData[refColumn]) {
return String(formData[refColumn]);
}
return "REF";
}
default:
logger.warn("알 수 없는 파트 타입", { partType: part.partType });
return "";
@ -1375,6 +1393,13 @@ class NumberingRuleService {
return catMapping2?.format || "CATEGORY";
}
case "reference": {
const refCol2 = autoConfig.referenceColumnName;
if (refCol2 && formData && formData[refCol2]) {
return String(formData[refCol2]);
}
return "REF";
}
default:
return "";
}
@ -1524,6 +1549,15 @@ class NumberingRuleService {
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:
return "";
}
@ -1747,7 +1781,53 @@ class NumberingRuleService {
`;
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) {
logger.info("테이블+컬럼 기반 채번 규칙을 찾을 수 없음", {
@ -1760,7 +1840,6 @@ class NumberingRuleService {
const rule = result.rows[0];
// 파트 정보 조회 (테스트 테이블)
const partsQuery = `
SELECT
id,
@ -1779,7 +1858,7 @@ class NumberingRuleService {
]);
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", {
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공", {
ruleId: rule.ruleId,
ruleName: rule.ruleName,
});

View File

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

View File

@ -2691,6 +2691,32 @@ export class TableManagementService {
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 existingColumns = Object.keys(data).filter((col) => {
@ -3437,10 +3463,12 @@ export class TableManagementService {
}
// ORDER BY 절 구성
// sortBy가 없으면 created_date 컬럼이 있는 경우에만 기본 정렬 적
// sortBy가 메인 테이블 컬럼이면 main. 접두사, 조인 별칭이면 접두사 없이 사
const hasCreatedDateColumn = selectColumns.includes("created_date");
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
? `main."created_date" DESC`
: "";
@ -3505,7 +3533,7 @@ export class TableManagementService {
const referenceTableColumns = new Map<string, string[]>();
const uniqueRefTables = new Set(
joinConfigs
.filter((c) => c.referenceTable !== "table_column_category_values") // 카테고리는 제외
.filter((c) => c.referenceTable !== "category_values") // 카테고리는 제외
.map((c) => `${c.referenceTable}:${c.sourceColumn}`)
);
@ -3684,7 +3712,9 @@ export class TableManagementService {
selectColumns,
"", // WHERE 절은 나중에 추가
options.sortBy
? `main."${options.sortBy}" ${options.sortOrder || "ASC"}`
? selectColumns.includes(options.sortBy)
? `main."${options.sortBy}" ${options.sortOrder || "ASC"}`
: `"${options.sortBy}" ${options.sortOrder || "ASC"}`
: hasCreatedDateForSearch
? `main."created_date" DESC`
: undefined,
@ -3875,7 +3905,9 @@ export class TableManagementService {
const whereClause = whereConditions.join(" AND ");
const hasCreatedDateForOrder = selectColumns.includes("created_date");
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
? `main."created_date" DESC`
: "";
@ -4310,8 +4342,8 @@ export class TableManagementService {
];
for (const config of joinConfigs) {
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
if (config.referenceTable === "table_column_category_values") {
// category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
if (config.referenceTable === "category_values") {
dbJoins.push(config);
console.log(`🔗 DB 조인 (특수 조건): ${config.referenceTable}`);
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 { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; // V2 Zod 기반 변환
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 {
showConfirmDialog,
@ -42,10 +49,10 @@ function ScreenViewPage() {
const params = useParams();
const searchParams = useSearchParams();
const router = useRouter();
const screenId = parseInt(params.screenId as string);
const screenId = screenIdProp ?? parseInt(params.screenId as string);
// URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프)
const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined;
// props 우선, 없으면 URL 쿼리에서 menuObjid 가져오기
const menuObjid = menuObjidProp ?? (searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined);
// URL 쿼리에서 프리뷰용 company_code 가져오기
const previewCompanyCode = searchParams.get("company_code");
@ -131,10 +138,13 @@ function ScreenViewPage() {
initComponents();
}, []);
// 편집 모달 이벤트 리스너 등록
// 편집 모달 이벤트 리스너 등록 (활성 탭에서만 처리)
const tabId = useTabId();
useEffect(() => {
const handleOpenEditModal = (event: CustomEvent) => {
// console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
const state = useTabStore.getState();
const currentActiveTabId = state[state.mode].activeTabId;
if (tabId && tabId !== currentActiveTabId) return;
setEditModalConfig({
screenId: event.detail.screenId,
@ -154,7 +164,7 @@ function ScreenViewPage() {
// @ts-expect-error - CustomEvent type
window.removeEventListener("openEditModal", handleOpenEditModal);
};
}, []);
}, [tabId]);
useEffect(() => {
const loadScreen = async () => {
@ -1371,16 +1381,17 @@ function ScreenViewPage() {
}
// 실제 컴포넌트를 Provider로 감싸기
function ScreenViewPageWrapper() {
function ScreenViewPageWrapper({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {}) {
return (
<TableSearchWidgetHeightProvider>
<ScreenContextProvider>
<SplitPanelProvider>
<ScreenViewPage />
<ScreenViewPage screenIdProp={screenIdProp} menuObjidProp={menuObjidProp} />
</SplitPanelProvider>
</ScreenContextProvider>
</TableSearchWidgetHeightProvider>
);
}
export { ScreenViewPageWrapper };
export default ScreenViewPageWrapper;

View File

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

View File

@ -4,7 +4,7 @@ import "./globals.css";
import { QueryProvider } from "@/providers/QueryProvider";
import { RegistryProvider } from "./registry-provider";
import { Toaster } from "sonner";
import ScreenModal from "@/components/common/ScreenModal";
const inter = Inter({
subsets: ["latin"],
@ -45,7 +45,6 @@ export default function RootLayout({
<QueryProvider>
<RegistryProvider>{children}</RegistryProvider>
<Toaster position="top-right" />
<ScreenModal />
</QueryProvider>
{/* Portal 컨테이너 */}
<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,
uploadMultiTableExcel,
} from "@/lib/api/multiTableExcel";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
import { getTableColumns } from "@/lib/api/tableManagement";
export interface MultiTableExcelUploadModalProps {
open: boolean;
@ -79,6 +81,18 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
// 업로드
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);
// 선택된 모드에서 활성화되는 컬럼 목록
@ -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 (!file) {
toast.error("파일을 선택해주세요.");
@ -328,6 +495,14 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
toast.error(`필수 컬럼이 매핑되지 않았습니다: ${unmappedRequired.join(", ")}`);
return;
}
// 카테고리 컬럼 검증
const mismatches = await validateCategoryColumns();
if (mismatches) {
setCategoryMismatches(mismatches);
setShowCategoryValidation(true);
return;
}
}
setCurrentStep((prev) => Math.min(prev + 1, 3));
@ -349,10 +524,14 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
setDisplayData([]);
setExcelColumns([]);
setColumnMappings([]);
setShowCategoryValidation(false);
setCategoryMismatches({});
setIsCategoryValidating(false);
}
}, [open, config.uploadModes]);
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
@ -758,10 +937,17 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
{currentStep < 3 ? (
<Button
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"
>
{isCategoryValidating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"다음"
)}
</Button>
) : (
<Button
@ -782,5 +968,112 @@ export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProp
</DialogFooter>
</DialogContent>
</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 { ConditionalZone, LayerDefinition } from "@/types/screen-management";
import { ScreenContextProvider } from "@/contexts/ScreenContext";
import { useTabStore } from "@/stores/tabStore";
import { useTabId } from "@/contexts/TabIdContext";
interface ScreenModalState {
isOpen: boolean;
@ -43,6 +45,8 @@ interface ScreenModalProps {
export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const { userId, userName, user } = useAuth();
const splitPanelContext = useSplitPanelContext();
const tabId = useTabId();
const activeTabId = useTabStore((s) => s[s.mode].activeTabId);
const [modalState, setModalState] = useState<ScreenModalState>({
isOpen: false,
@ -75,9 +79,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const [selectedData, setSelectedData] = useState<Record<string, any>[]>([]);
// 🆕 조건부 레이어 상태 (Zone 기반)
const [conditionalLayers, setConditionalLayers] = useState<
(LayerDefinition & { components: ComponentData[]; zone?: ConditionalZone })[]
>([]);
const [conditionalLayers, setConditionalLayers] = useState<(LayerDefinition & { components: ComponentData[]; zone?: ConditionalZone })[]>([]);
// 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해)
const [continuousMode, setContinuousMode] = useState(false);
@ -172,6 +174,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 전역 모달 이벤트 리스너
useEffect(() => {
const handleOpenModal = (event: CustomEvent) => {
// 활성 탭에서만 이벤트 처리 (다른 탭의 ScreenModal 인스턴스는 무시)
const storeState = useTabStore.getState();
const currentActiveTabId = storeState[storeState.mode].activeTabId;
if (tabId && tabId !== currentActiveTabId) return;
const {
screenId,
title,
@ -193,7 +200,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
isCreateMode,
});
// 🆕 모달 열린 시간 기록
// 모달 열린 시간 기록
modalOpenedAtRef.current = Date.now();
// 폼 변경 추적 초기화
@ -259,9 +266,10 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// - selectedLeftData: customer_mng 데이터 (SplitPanel 좌측 선택)
// - 기존 formData: 이전 모달에서 설정된 link 필드 (customer_code 등)
const contextData = splitPanelContext?.selectedLeftData || {};
const eventData =
splitPanelParentData && Object.keys(splitPanelParentData).length > 0 ? splitPanelParentData : {};
const eventData = splitPanelParentData && Object.keys(splitPanelParentData).length > 0
? splitPanelParentData
: {};
// 🆕 기존 formData에서 link 필드(_code, _id)를 가져와 base로 사용
// 모달 체인(226→227)에서 이전 모달의 연결 필드가 유지됨
const previousLinkFields: Record<string, any> = {};
@ -277,7 +285,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
}
}
}
const rawParentData = { ...previousLinkFields, ...contextData, ...eventData };
// 🔧 신규 등록 모드에서는 연결에 필요한 필드만 전달
@ -444,7 +452,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
window.removeEventListener("closeSaveModal", handleCloseModal);
window.removeEventListener("saveSuccessInModal", handleSaveSuccess);
};
}, [continuousMode]); // continuousMode 의존성 추가
}, [tabId, continuousMode]);
// 화면 데이터 로딩
useEffect(() => {
@ -472,7 +480,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
layoutData.screenResolution = v2LayoutData.screenResolution || layoutData.screenResolution;
}
}
// V2 레이아웃이 없으면 기존 API로 fallback
if (!layoutData) {
layoutData = await screenApi.getLayout(screenId);
@ -680,17 +688,12 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
}
}
console.log(
"[ScreenModal] 조건부 레이어 로드 완료:",
layerDefs.length,
"개",
console.log("[ScreenModal] 조건부 레이어 로드 완료:", layerDefs.length, "개",
layerDefs.map((l) => ({
id: l.id,
name: l.name,
conditionValue: l.conditionValue,
id: l.id, name: l.name, conditionValue: l.conditionValue,
componentCount: l.components.length,
condition: l.condition,
})),
}))
);
setConditionalLayers(layerDefs);
@ -733,21 +736,15 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
if (Array.isArray(value)) {
isMatch = value.some((v) => String(v) === String(targetValue ?? ""));
} else if (typeof value === "string" && value.includes(",")) {
isMatch = value
.split(",")
.map((v) => v.trim())
.includes(String(targetValue ?? ""));
isMatch = value.split(",").map((v) => v.trim()).includes(String(targetValue ?? ""));
}
break;
}
console.log("[ScreenModal] 레이어 조건 평가:", {
layerName: layer.name,
fieldKey,
layerName: layer.name, fieldKey,
targetValue: String(targetValue ?? "(없음)"),
conditionValue: String(value),
operator,
isMatch,
conditionValue: String(value), operator, isMatch,
});
if (isMatch) {
@ -804,10 +801,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
if (Array.isArray(value)) {
return value.some((v) => String(v) === String(targetValue ?? ""));
} else if (typeof value === "string" && value.includes(",")) {
return value
.split(",")
.map((v) => v.trim())
.includes(String(targetValue ?? ""));
return value.split(",").map((v) => v.trim()).includes(String(targetValue ?? ""));
}
return false;
default:
@ -829,7 +823,10 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
if (!layer) return;
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) {
fieldsToRemove.push(fieldName);
}
@ -864,7 +861,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
} else {
handleCloseInternal();
}
}, []);
}, [tabId]);
// 확인 후 실제로 모달을 닫는 함수
const handleConfirmClose = useCallback(() => {
@ -1002,14 +999,13 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
<Dialog
open={modalState.isOpen}
onOpenChange={(open) => {
// X 버튼 클릭 시에도 확인 다이얼로그 표시
if (!open) {
handleCloseAttempt();
}
}}
>
<DialogContent
className={`${modalStyle.className} ${className || ""} flex max-w-none flex-col`}
className={`${modalStyle.className} ${className || ""} max-w-none flex flex-col`}
style={modalStyle.style}
// 바깥 클릭 시 바로 닫히지 않도록 방지
onInteractOutside={(e) => {
@ -1034,7 +1030,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
</div>
</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 ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
@ -1078,10 +1076,10 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const isComponentHidden = (comp: any) => {
const cc = comp.componentConfig?.conditionalConfig || comp.conditionalConfig;
if (!cc?.enabled || !formData) return false;
const { field, operator, value, action } = cc;
const fieldValue = formData[field];
let conditionMet = false;
switch (operator) {
case "=":
@ -1096,10 +1094,10 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
default:
conditionMet = fieldValue === value;
}
return (action === "show" && !conditionMet) || (action === "hide" && conditionMet);
};
// 표시되는 컴포넌트들의 y 범위 수집
const visibleRanges: { y: number; bottom: number }[] = [];
screenData.components.forEach((comp: any) => {
@ -1109,13 +1107,13 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
visibleRanges.push({ y, bottom: y + height });
}
});
// 숨겨지는 컴포넌트의 "실제 빈 공간" 계산 (표시되는 컴포넌트와 겹치지 않는 영역)
const getActualGap = (hiddenY: number, hiddenBottom: number): number => {
// 숨겨지는 영역 중 표시되는 컴포넌트와 겹치는 부분을 제외
let gapStart = hiddenY;
let gapEnd = hiddenBottom;
for (const visible of visibleRanges) {
// 겹치는 영역 확인
if (visible.y < gapEnd && visible.bottom > gapStart) {
@ -1132,10 +1130,10 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
}
}
}
return Math.max(0, gapEnd - gapStart);
};
// 숨겨지는 컴포넌트들의 실제 빈 공간 수집
const hiddenGaps: { bottom: number; gap: number }[] = [];
screenData.components.forEach((comp: any) => {
@ -1149,18 +1147,18 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
}
}
});
// bottom 기준으로 정렬 및 중복 제거 (같은 bottom은 가장 큰 gap만 유지)
const mergedGaps = new Map<number, number>();
hiddenGaps.forEach(({ bottom, gap }) => {
const existing = mergedGaps.get(bottom) || 0;
mergedGaps.set(bottom, Math.max(existing, gap));
});
const sortedGaps = Array.from(mergedGaps.entries())
.map(([bottom, gap]) => ({ bottom, gap }))
.sort((a, b) => a.bottom - b.bottom);
// 각 컴포넌트의 y 조정값 계산 함수
const getYOffset = (compY: number, compId?: string) => {
let offset = 0;
@ -1172,73 +1170,65 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
}
return offset;
};
return screenData.components.map((component: any) => {
// 숨겨지는 컴포넌트는 렌더링 안함
if (isComponentHidden(component)) {
return null;
}
// 화면 관리 해상도를 사용하는 경우 offset 조정 불필요
const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0;
// 🆕 동적 y 좌표 조정 (숨겨진 컴포넌트 높이만큼 위로 이동)
const compY = parseFloat(component.position?.y?.toString() || "0");
const yAdjustment = getYOffset(compY, component.id);
// 화면 관리 해상도를 사용하는 경우 offset 조정 불필요
const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0;
// offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시)
const adjustedComponent = {
...component,
position: {
...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: compY - offsetY - yAdjustment, // 🆕 동적 조정 적용
},
};
// 🆕 동적 y 좌표 조정 (숨겨진 컴포넌트 높이만큼 위로 이동)
const compY = parseFloat(component.position?.y?.toString() || "0");
const yAdjustment = getYOffset(compY, component.id);
// offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시)
const adjustedComponent = {
...component,
position: {
...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: compY - offsetY - yAdjustment, // 🆕 동적 조정 적용
},
};
return (
<InteractiveScreenViewerDynamic
key={`${component.id}-${resetKey}`}
component={adjustedComponent}
allComponents={screenData.components}
formData={formData}
originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용)
onFormDataChange={(fieldName, value) => {
formDataChangedRef.current = true;
setFormData((prev) => {
const newFormData = {
...prev,
[fieldName]: value,
};
console.log(
"🟡 [ScreenModal] onFormDataChange:",
fieldName,
"→",
value,
"| formData keys:",
Object.keys(newFormData),
"| process_code:",
newFormData.process_code,
);
return newFormData;
});
}}
onRefresh={() => {
// 부모 화면의 테이블 새로고침 이벤트 발송
window.dispatchEvent(new CustomEvent("refreshTable"));
}}
screenInfo={{
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
groupedData={selectedData}
userId={userId}
userName={userName}
companyCode={user?.companyCode}
/>
);
});
return (
<InteractiveScreenViewerDynamic
key={`${component.id}-${resetKey}`}
component={adjustedComponent}
allComponents={screenData.components}
formData={formData}
originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용)
onFormDataChange={(fieldName, value) => {
formDataChangedRef.current = true;
setFormData((prev) => {
const newFormData = {
...prev,
[fieldName]: value,
};
console.log("🟡 [ScreenModal] onFormDataChange:", fieldName, "→", value, "| formData keys:", Object.keys(newFormData), "| process_code:", newFormData.process_code);
return newFormData;
});
}}
onRefresh={() => {
// 부모 화면의 테이블 새로고침 이벤트 발송
window.dispatchEvent(new CustomEvent("refreshTable"));
}}
screenInfo={{
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
groupedData={selectedData}
userId={userId}
userName={userName}
companyCode={user?.companyCode}
isInModal={true}
/>
);
});
})()}
{/* 🆕 조건부 레이어 컴포넌트 렌더링 */}
@ -1279,6 +1269,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
userId={userId}
userName={userName}
companyCode={user?.companyCode}
isInModal={true}
/>
);
})}
@ -1316,7 +1307,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
<AlertDialog open={showCloseConfirm} onOpenChange={setShowCloseConfirm}>
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
<AlertDialogHeader>
<AlertDialogTitle className="text-base sm:text-lg"> ?</AlertDialogTitle>
<AlertDialogTitle className="text-base sm:text-lg">
?
</AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm">
.
<br />
@ -1332,7 +1325,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
</AlertDialogCancel>
<AlertDialogAction
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>

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 { Logo } from "./Logo";
import { SideMenu } from "./SideMenu";
import { TabBar } from "./TabBar";
import { TabContent } from "./TabContent";
import { useTabStore } from "@/stores/tabStore";
import {
DropdownMenu,
DropdownMenuContent,
@ -97,7 +100,8 @@ const getMenuIcon = (menuName: string, dbIconName?: string | null) => {
};
// 메뉴 데이터를 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
.filter((menu) => (menu.parent_obj_id || menu.PARENT_OBJ_ID) === parentId)
.filter((menu) => (menu.status || menu.STATUS) === "active")
@ -110,40 +114,34 @@ const convertMenuToUI = (menus: MenuItem[], userInfo: ExtendedUserInfo | null, p
for (const menu of filteredMenus) {
const menuName = (menu.menu_name_kor || menu.MENU_NAME_KOR || "").toLowerCase();
// "사용자" 또는 "관리자" 카테고리면 하위 메뉴들을 직접 추가
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);
} else {
// 일반 메뉴는 그대로 추가
allMenus.push(convertSingleMenu(menu, menus, userInfo));
allMenus.push(convertSingleMenu(menu, menus, userInfo, ""));
}
}
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;
// 사용자 locale 기준으로 번역 처리
const getDisplayText = (menu: MenuItem) => {
// 다국어 텍스트가 있으면 사용, 없으면 기본 텍스트 사용
if (menu.translated_name || menu.TRANSLATED_NAME) {
return menu.translated_name || menu.TRANSLATED_NAME;
const getDisplayText = (m: MenuItem) => {
if (m.translated_name || m.TRANSLATED_NAME) {
return m.translated_name || m.TRANSLATED_NAME;
}
const baseName = menu.menu_name_kor || menu.MENU_NAME_KOR || "메뉴명 없음";
// 사용자 정보에서 locale 가져오기
const baseName = m.menu_name_kor || m.MENU_NAME_KOR || "메뉴명 없음";
const userLocale = userInfo?.locale || "ko";
if (userLocale === "EN") {
// 영어 번역
const translations: { [key: string]: string } = {
: "Administrator",
: "User Management",
@ -163,7 +161,6 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten
}
}
} else if (userLocale === "JA") {
// 일본어 번역
const translations: { [key: string]: string } = {
: "管理者",
: "ユーザー管理",
@ -183,7 +180,6 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten
}
}
} else if (userLocale === "ZH") {
// 중국어 번역
const translations: { [key: string]: string } = {
: "管理员",
: "用户管理",
@ -207,11 +203,15 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten
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 {
id: menuId,
name: getDisplayText(menu),
name: displayName,
tabTitle,
icon: getMenuIcon(menu.menu_name_kor || menu.MENU_NAME_KOR || "", menu.menu_icon || menu.MENU_ICON),
url: menu.menu_url || menu.MENU_URL || "#",
children: children.length > 0 ? children : undefined,
@ -231,6 +231,28 @@ function AppLayoutInner({ children }: AppLayoutProps) {
const [showCompanySwitcher, setShowCompanySwitcher] = useState(false);
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 전용)
useEffect(() => {
const fetchCurrentCompanyName = async () => {
@ -306,8 +328,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
handleRegisterVehicle,
} = 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 임베드용)
const isPreviewMode = searchParams.get("preview") === "true";
@ -327,67 +351,55 @@ function AppLayoutInner({ children }: AppLayoutProps) {
setExpandedMenus(newExpanded);
};
// 메뉴 클릭 핸들러
const { openTab } = useTabStore();
// 메뉴 클릭 핸들러 (탭으로 열기)
const handleMenuClick = async (menu: any) => {
if (menu.hasChildren) {
toggleMenu(menu.id);
} else {
// 메뉴 이름 저장 (엑셀 다운로드 파일명에 사용)
const menuName = menu.label || menu.name || "메뉴";
// tabTitle: "기준정보 - 회사관리" 형태의 상위 포함 이름
const menuName = menu.tabTitle || menu.label || menu.name || "메뉴";
if (typeof window !== "undefined") {
localStorage.setItem("currentMenuName", menuName);
}
// 먼저 할당된 화면이 있는지 확인 (URL 유무와 관계없이)
try {
const menuObjid = menu.objid || menu.id;
const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid);
if (assignedScreens.length > 0) {
// 할당된 화면이 있으면 첫 번째 화면으로 이동
const firstScreen = assignedScreens[0];
// 관리자 모드 상태와 menuObjid를 쿼리 파라미터로 전달
const params = new URLSearchParams();
if (isAdminMode) {
params.set("mode", "admin");
}
params.set("menuObjid", menuObjid.toString());
const screenPath = `/screens/${firstScreen.screenId}?${params.toString()}`;
router.push(screenPath);
if (isMobile) {
setSidebarOpen(false);
}
openTab({
type: "screen",
title: menuName,
screenId: firstScreen.screenId,
menuObjid: parseInt(menuObjid),
});
if (isMobile) setSidebarOpen(false);
return;
}
} catch (error) {
console.warn("할당된 화면 조회 실패:", error);
}
// 할당된 화면이 없고 URL이 있으면 기존 URL로 이동
if (menu.url && menu.url !== "#") {
router.push(menu.url);
if (isMobile) {
setSidebarOpen(false);
}
openTab({
type: "admin",
title: menuName,
adminUrl: menu.url,
});
if (isMobile) setSidebarOpen(false);
} else {
// URL도 없고 할당된 화면도 없으면 경고 메시지
toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다.");
}
}
};
// 모드 전환 핸들러
// 모드 전환: 탭 스토어의 모드만 변경 (각 모드 탭은 독립 보존)
const handleModeSwitch = () => {
if (isAdminMode) {
// 관리자 → 사용자 모드: 선택한 회사 유지
router.push("/main");
} else {
// 사용자 → 관리자 모드: 선택한 회사 유지 (회사 전환 없음)
router.push("/admin");
}
setTabMode(isAdminMode ? "user" : "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 isExpanded = expandedMenus.has(menu.id);
const isLeaf = !menu.hasChildren;
return (
<div key={menu.id}>
<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 ${
pathname === menu.url
? "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) => (
<div
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 ${
pathname === child.url
? "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>
</aside>
{/* 가운데 컨텐츠 영역 - 스크롤 가능 */}
<main className={`min-w-0 flex-1 overflow-auto bg-white ${isMobile ? "h-[calc(100vh-56px)]" : "h-screen"}`}>
{children}
{/* 가운데 컨텐츠 영역 - 탭 시스템 */}
<main className={`flex min-w-0 flex-1 flex-col overflow-hidden bg-white ${isMobile ? "h-[calc(100vh-56px)]" : "h-screen"}`}>
<TabBar />
<TabContent />
</main>
</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;
onChange: (config: any) => void;
isPreview?: boolean;
tableName?: string;
}
interface TableInfo {
@ -37,6 +38,7 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
config = {},
onChange,
isPreview = false,
tableName,
}) => {
// 1. 순번 (자동 증가)
if (partType === "sequence") {
@ -131,6 +133,18 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
return <CategoryConfigPanel config={config} onChange={onChange} isPreview={isPreview} />;
}
// 6. 참조 (마스터-디테일 분번)
if (partType === "reference") {
return (
<ReferenceConfigSection
config={config}
onChange={onChange}
isPreview={isPreview}
tableName={tableName}
/>
);
}
return null;
};
@ -1032,3 +1046,94 @@ const CategoryConfigPanel: React.FC<CategoryConfigPanelProps> = ({ config = {},
</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;
onDelete: () => void;
isPreview?: boolean;
tableName?: string;
}
export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
@ -23,6 +24,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
onUpdate,
onDelete,
isPreview = false,
tableName,
}) => {
return (
<Card className="border-border bg-card flex-1">
@ -57,6 +59,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
date: { dateFormat: "YYYYMMDD" },
text: { textValue: "CODE" },
category: { categoryKey: "", categoryMappings: [] },
reference: { referenceColumnName: "" },
};
onUpdate({
partType: newPartType,
@ -105,6 +108,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
config={part.autoConfig}
onChange={(autoConfig) => onUpdate({ autoConfig })}
isPreview={isPreview}
tableName={tableName}
/>
) : (
<ManualConfigPanel

View File

@ -1,35 +1,30 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
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 { showErrorToast } from "@/lib/utils/toastUtils";
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
import { NumberingRuleCard } from "./NumberingRuleCard";
import { NumberingRulePreview } from "./NumberingRulePreview";
import {
saveNumberingRuleToTest,
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 { saveNumberingRuleToTest } from "@/lib/api/numberingRule";
import { apiClient } from "@/lib/api/client";
import { cn } from "@/lib/utils";
// 카테고리 값 트리 노드 타입
interface CategoryValueNode {
valueId: number;
valueCode: string;
valueLabel: string;
depth: number;
path: string;
parentValueId: number | null;
children?: CategoryValueNode[];
interface NumberingColumn {
tableName: string;
tableLabel: string;
columnName: string;
columnLabel: string;
}
interface GroupedColumns {
tableLabel: string;
columns: NumberingColumn[];
}
interface NumberingRuleDesignerProps {
@ -53,138 +48,100 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
currentTableName,
menuObjid,
}) => {
const [savedRules, setSavedRules] = useState<NumberingRuleConfig[]>([]);
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
const [numberingColumns, setNumberingColumns] = useState<NumberingColumn[]>([]);
const [selectedColumn, setSelectedColumn] = useState<{ tableName: string; columnName: string } | null>(null);
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(null);
const [loading, setLoading] = useState(false);
const [leftTitle, setLeftTitle] = useState("저장된 규칙 목록");
const [columnSearch, setColumnSearch] = useState("");
const [rightTitle, setRightTitle] = useState("규칙 편집");
const [editingLeftTitle, setEditingLeftTitle] = useState(false);
const [editingRightTitle, setEditingRightTitle] = useState(false);
// 구분자 관련 상태 (개별 파트 사이 구분자)
const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
// 구분자 관련 상태
const [separatorType, setSeparatorType] = useState<SeparatorType>("-");
const [customSeparator, setCustomSeparator] = useState("");
// 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회
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(() => {
loadRules();
loadAllCategoryOptions(); // 전체 카테고리 옵션 로드
loadNumberingColumns();
}, []);
// currentRule의 categoryColumn이 변경되면 selectedCategoryKey 동기화
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 () => {
const loadNumberingColumns = async () => {
setLoading(true);
try {
console.log("🔍 [NumberingRuleDesigner] 채번 규칙 목록 로드 시작 (test 테이블):", {
menuObjid,
hasMenuObjid: !!menuObjid,
});
// 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 || "규칙 목록을 불러올 수 없습니다");
const response = await apiClient.get("/table-management/numbering-columns");
if (response.data.success && response.data.data) {
setNumberingColumns(response.data.data);
}
} catch (error: any) {
toast.error(`로딩 실패: ${error.message}`);
console.error("채번 컬럼 목록 로드 실패:", error);
} finally {
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(() => {
if (currentRule) {
@ -192,48 +149,68 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
}
}, [currentRule, onChange]);
// currentRule이 변경될 때 구분자 상태 동기화
// currentRule이 변경될 때 파트별 구분자 상태 동기화
useEffect(() => {
if (currentRule) {
const sep = currentRule.separator ?? "-";
// 빈 문자열이면 "none"
if (sep === "") {
setSeparatorType("none");
setCustomSeparator("");
return;
}
// 미리 정의된 구분자인지 확인 (none, custom 제외)
const predefinedOption = SEPARATOR_OPTIONS.find(
(opt) => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep,
);
if (predefinedOption) {
setSeparatorType(predefinedOption.value);
setCustomSeparator("");
} else {
// 직접 입력된 구분자
setSeparatorType("custom");
setCustomSeparator(sep);
}
if (currentRule && currentRule.parts.length > 0) {
const newSepTypes: Record<number, SeparatorType> = {};
const newCustomSeps: Record<number, string> = {};
currentRule.parts.forEach((part) => {
const sep = part.separatorAfter ?? currentRule.separator ?? "-";
if (sep === "") {
newSepTypes[part.order] = "none";
newCustomSeps[part.order] = "";
} else {
const predefinedOption = SEPARATOR_OPTIONS.find(
opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep
);
if (predefinedOption) {
newSepTypes[part.order] = predefinedOption.value;
newCustomSeps[part.order] = "";
} else {
newSepTypes[part.order] = "custom";
newCustomSeps[part.order] = sep;
}
}
});
setSeparatorTypes(newSepTypes);
setCustomSeparators(newCustomSeps);
}
}, [currentRule?.ruleId]); // ruleId가 변경될 때만 실행 (규칙 선택/생성 시)
}, [currentRule?.ruleId]);
// 구분자 변경 핸들러
const handleSeparatorChange = useCallback((type: SeparatorType) => {
setSeparatorType(type);
// 개별 파트 구분자 변경 핸들러
const handlePartSeparatorChange = useCallback((partOrder: number, type: SeparatorType) => {
setSeparatorTypes(prev => ({ ...prev, [partOrder]: type }));
if (type !== "custom") {
const option = SEPARATOR_OPTIONS.find((opt) => opt.value === type);
const option = SEPARATOR_OPTIONS.find(opt => opt.value === type);
const newSeparator = option?.displayValue ?? "";
setCurrentRule((prev) => (prev ? { ...prev, separator: newSeparator } : null));
setCustomSeparator("");
setCustomSeparators(prev => ({ ...prev, [partOrder]: "" }));
setCurrentRule((prev) => {
if (!prev) return null;
return {
...prev,
parts: prev.parts.map((part) =>
part.order === partOrder ? { ...part, separatorAfter: newSeparator } : part
),
};
});
}
}, []);
// 직접 입력 구분자 변경 핸들러
const handleCustomSeparatorChange = useCallback((value: string) => {
// 최대 2자 제한
// 개별 파트 직접 입력 구분자 변경 핸들러
const handlePartCustomSeparatorChange = useCallback((partOrder: number, value: string) => {
const trimmedValue = value.slice(0, 2);
setCustomSeparator(trimmedValue);
setCurrentRule((prev) => (prev ? { ...prev, separator: trimmedValue } : null));
setCustomSeparators(prev => ({ ...prev, [partOrder]: trimmedValue }));
setCurrentRule((prev) => {
if (!prev) return null;
return {
...prev,
parts: prev.parts.map((part) =>
part.order === partOrder ? { ...part, separatorAfter: trimmedValue } : part
),
};
});
}, []);
const handleAddPart = useCallback(() => {
@ -250,6 +227,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
partType: "text",
generationMethod: "auto",
autoConfig: { textValue: "CODE" },
separatorAfter: "-",
};
setCurrentRule((prev) => {
@ -257,6 +235,10 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
return { ...prev, parts: [...prev.parts, newPart] };
});
// 새 파트의 구분자 상태 초기화
setSeparatorTypes(prev => ({ ...prev, [newPart.order]: "-" }));
setCustomSeparators(prev => ({ ...prev, [newPart.order]: "" }));
toast.success(`규칙 ${newPart.order}가 추가되었습니다`);
}, [currentRule, maxRules]);
@ -277,9 +259,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
if (!prev) return null;
return {
...prev,
parts: prev.parts
.filter((part) => part.order !== partOrder)
.map((part, index) => ({ ...part, order: index + 1 })),
parts: prev.parts.filter((part) => part.order !== partOrder).map((part, index) => ({ ...part, order: index + 1 })),
};
});
@ -319,207 +299,86 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
return part;
});
// 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정
// menuObjid가 있으면 menu 스코프, 없으면 기존 scopeType 유지
const effectiveMenuObjid = menuObjid || currentRule.menuObjid || null;
const effectiveScopeType = effectiveMenuObjid ? "menu" : currentRule.scopeType || "global";
const ruleToSave = {
...currentRule,
parts: partsWithDefaults,
scopeType: effectiveScopeType as "menu" | "global", // menuObjid 유무에 따라 결정
tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 (참고용)
menuObjid: effectiveMenuObjid, // 메뉴 OBJID (필터링 기준)
scopeType: "table" as const,
tableName: selectedColumn?.tableName || currentRule.tableName || "",
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)
const response = await saveNumberingRuleToTest(ruleToSave);
if (response.success && response.data) {
// 깊은 복사하여 savedRules와 currentRule이 다른 객체를 참조하도록 함
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);
setSelectedRuleId(response.data.ruleId);
await onSave?.(response.data);
toast.success("채번 규칙이 저장되었습니다");
} else {
toast.error(response.error || "저장 실패");
showErrorToast("채번 규칙 저장에 실패했습니다", response.error, { guidance: "설정을 확인하고 다시 시도해 주세요." });
}
} catch (error: any) {
toast.error(`저장 실패: ${error.message}`);
showErrorToast("채번 규칙 저장에 실패했습니다", error, { guidance: "설정을 확인하고 다시 시도해 주세요." });
} finally {
setLoading(false);
}
}, [currentRule, onSave, currentTableName, menuObjid]);
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]);
}, [currentRule, onSave, selectedColumn]);
return (
<div className={`flex h-full gap-4 ${className}`}>
{/* 좌측: 저장된 규칙 목록 */}
<div className="flex w-80 flex-shrink-0 flex-col gap-4">
<div className="flex items-center justify-between">
{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>
{/* 좌측: 채번 컬럼 목록 (카테고리 패턴) */}
<div className="flex w-72 flex-shrink-0 flex-col gap-3">
<h2 className="text-sm font-semibold sm:text-base"> </h2>
<Button onClick={handleNewRule} variant="outline" className="h-9 w-full text-sm">
<Plus className="mr-2 h-4 w-4" />
</Button>
<Input
value={columnSearch}
onChange={(e) => setColumnSearch(e.target.value)}
placeholder="검색..."
className="h-8 text-xs"
/>
<div className="flex-1 space-y-2 overflow-y-auto">
{loading ? (
<div className="flex-1 space-y-1 overflow-y-auto">
{loading && numberingColumns.length === 0 ? (
<div className="flex h-32 items-center justify-center">
<p className="text-muted-foreground text-xs"> ...</p>
</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">
<p className="text-muted-foreground text-xs"> </p>
<p className="text-muted-foreground text-xs">
{numberingColumns.length === 0
? "채번 타입 컬럼이 없습니다"
: "검색 결과가 없습니다"}
</p>
</div>
) : (
savedRules.map((rule) => (
<Card
key={rule.ruleId}
className={`border-border hover:bg-accent cursor-pointer py-2 transition-colors ${
selectedRuleId === rule.ruleId ? "border-primary bg-primary/5" : "bg-card"
}`}
onClick={() => handleSelectRule(rule)}
>
<CardHeader className="px-3 py-0">
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-sm font-medium">{rule.ruleName}</CardTitle>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation();
handleDeleteSavedRule(rule.ruleId);
}}
filteredGroups.map(([tableName, group]) => (
<div key={tableName} className="mb-2">
<div className="text-muted-foreground mb-1 flex items-center gap-1 px-1 text-[11px] font-medium">
<FolderTree className="h-3 w-3" />
<span>{group.tableLabel}</span>
<span className="text-muted-foreground/60">({group.columns.length})</span>
</div>
{group.columns.map((col) => {
const isSelected =
selectedColumn?.tableName === col.tableName &&
selectedColumn?.columnName === col.columnName;
return (
<div
key={`${col.tableName}.${col.columnName}`}
className={cn(
"cursor-pointer rounded-md px-3 py-1.5 text-xs transition-colors",
isSelected
? "bg-primary/10 text-primary border-primary border font-medium"
: "hover:bg-accent"
)}
onClick={() => handleSelectColumn(col.tableName, col.columnName)}
>
<Trash2 className="text-destructive h-3 w-3" />
</Button>
</div>
</CardHeader>
</Card>
{col.columnLabel}
</div>
);
})}
</div>
))
)}
</div>
@ -533,8 +392,9 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
{!currentRule ? (
<div className="flex h-full flex-col items-center justify-center">
<div className="text-center">
<p className="text-muted-foreground mb-2 text-lg font-medium"> </p>
<p className="text-muted-foreground text-sm"> </p>
<FolderTree className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
<p className="text-muted-foreground mb-2 text-lg font-medium"> </p>
<p className="text-muted-foreground text-sm"> </p>
</div>
</div>
) : (
@ -575,40 +435,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
</div>
</div>
{/* 두 번째 줄: 구분자 설정 */}
<div className="flex items-end gap-3">
<div className="w-48 space-y-2">
<Label className="text-sm font-medium"></Label>
<Select
value={separatorType}
onValueChange={(value) => handleSeparatorChange(value as SeparatorType)}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="구분자 선택" />
</SelectTrigger>
<SelectContent>
{SEPARATOR_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{separatorType === "custom" && (
<div className="w-32 space-y-2">
<Label className="text-sm font-medium"> </Label>
<Input
value={customSeparator}
onChange={(e) => handleCustomSeparatorChange(e.target.value)}
className="h-9"
placeholder="최대 2자"
maxLength={2}
/>
</div>
)}
<p className="text-muted-foreground pb-2 text-xs"> </p>
</div>
</div>
<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>
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
<div className="flex flex-wrap items-stretch gap-3">
{currentRule.parts.map((part, index) => (
<NumberingRuleCard
key={`part-${part.order}-${index}`}
part={part}
onUpdate={(updates) => handleUpdatePart(part.order, updates)}
onDelete={() => handleDeletePart(part.order)}
isPreview={isPreview}
/>
<React.Fragment key={`part-${part.order}-${index}`}>
<div className="flex w-[200px] flex-col">
<NumberingRuleCard
part={part}
onUpdate={(updates) => handleUpdatePart(part.order, updates)}
onDelete={() => handleDeletePart(part.order)}
isPreview={isPreview}
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>
)}

View File

@ -17,6 +17,10 @@ import { dynamicFormApi } from "@/lib/api/dynamicForm";
import { useAuth } from "@/hooks/useAuth";
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
import { ScreenContextProvider } from "@/contexts/ScreenContext";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { useTabId } from "@/contexts/TabIdContext";
import { useTabStore } from "@/stores/tabStore";
interface EditModalState {
isOpen: boolean;
@ -78,6 +82,9 @@ const findSaveButtonInComponents = (components: any[]): any | null => {
export const EditModal: React.FC<EditModalProps> = ({ className }) => {
const { user } = useAuth();
const tabId = useTabId();
const activeTabId = useTabStore((s) => s[s.mode].activeTabId);
const isTabActive = !tabId || tabId === activeTabId;
const [modalState, setModalState] = useState<EditModalState>({
isOpen: false,
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(() => {
const handleOpenEditModal = async (event: CustomEvent) => {
const storeState = useTabStore.getState();
const currentActiveTabId = storeState[storeState.mode].activeTabId;
if (tabId && tabId !== currentActiveTabId) return;
const {
screenId,
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)에만 사용
// INSERT/UPDATE 판단에는 사용하지 않음
setOriginalData(isCreateMode ? {} : editData || {});
@ -294,6 +412,21 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// isCreateMode=true(복사/등록) → INSERT, false/undefined(수정) → UPDATE
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] 모달 열림:", {
mode: isCreateMode ? "INSERT (생성/복사)" : "UPDATE (수정)",
hasEditData: !!editData,
@ -323,7 +456,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
window.removeEventListener("openEditModal", handleOpenEditModal as EventListener);
window.removeEventListener("closeEditModal", handleCloseEditModal);
};
}, [modalState.onSave]); // modalState.onSave를 의존성에 추가하여 최신 콜백 참조
}, [tabId, modalState.onSave]);
// 화면 데이터 로딩
useEffect(() => {
@ -405,9 +538,28 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// V2 없으면 기존 API fallback
if (!layoutData) {
console.warn("[EditModal] V2 레이아웃 없음, getLayout fallback 시도:", screenId);
layoutData = await screenApi.getLayout(screenId);
}
// getLayout도 실패하면 기본 레이어(layer_id=1) 직접 로드
if (!layoutData || !layoutData.components || layoutData.components.length === 0) {
console.warn("[EditModal] getLayout도 실패, getLayerLayout(1) 최종 fallback:", screenId);
try {
const baseLayerData = await screenApi.getLayerLayout(screenId, 1);
if (baseLayerData && isValidV2Layout(baseLayerData)) {
layoutData = convertV2ToLegacy(baseLayerData);
if (layoutData) {
layoutData.screenResolution = baseLayerData.screenResolution || layoutData.screenResolution;
}
} else if (baseLayerData?.components) {
layoutData = baseLayerData;
}
} catch (fallbackErr) {
console.error("[EditModal] getLayerLayout(1) fallback 실패:", fallbackErr);
}
}
if (screenInfo && layoutData) {
const components = layoutData.components || [];
@ -550,7 +702,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
(targetComponent as any)?.componentConfig?.columnName ||
targetComponentId;
const currentFormData = groupData.length > 0 ? groupData[0] : formData;
const currentFormData = groupData.length > 0 ? { ...formData, ...groupData[0] } : formData;
const targetValue = currentFormData[fieldKey];
let isMatch = false;
@ -1176,22 +1328,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
if (response.success) {
const masterRecordId = response.data?.id || formData.id;
// 🆕 리피터 데이터 저장 이벤트 발생 (V2Repeater 컴포넌트가 리스닝)
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: masterRecordId,
masterRecordId,
mainFormData: formData,
tableName: screenData.screenInfo.tableName,
},
}),
);
console.log("📋 [EditModal] repeaterSave 이벤트 발생:", {
masterRecordId,
tableName: screenData.screenInfo.tableName,
});
toast.success("데이터가 생성되었습니다.");
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
@ -1237,14 +1373,45 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
}
// V2Repeater 디테일 데이터 저장 (등록된 인스턴스가 있을 때만)
const hasRepeaterForInsert = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
if (hasRepeaterForInsert) {
try {
const repeaterSavePromise = new Promise<void>((resolve) => {
const fallbackTimeout = setTimeout(resolve, 5000);
const handler = () => {
clearTimeout(fallbackTimeout);
window.removeEventListener("repeaterSaveComplete", handler);
resolve();
};
window.addEventListener("repeaterSaveComplete", handler);
});
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: masterRecordId,
tableName: screenData.screenInfo.tableName,
mainFormData: formData,
masterRecordId,
},
}),
);
await repeaterSavePromise;
} catch (repeaterError) {
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
}
}
handleClose();
} else {
throw new Error(response.message || "생성에 실패했습니다.");
}
} else {
// UPDATE 모드 - PUT (전체 업데이트)
// originalData 비교 없이 formData 전체를 보냄
const recordId = formData.id;
// VIEW에서 온 데이터의 경우 master_id를 우선 사용 (마스터-디테일 구조)
const recordId = formData.master_id || formData.id;
if (!recordId) {
console.error("[EditModal] UPDATE 실패: formData에 id가 없습니다.", {
@ -1298,15 +1465,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
if (response.success) {
toast.success("데이터가 수정되었습니다.");
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
if (modalState.onSave) {
try {
modalState.onSave();
} catch (callbackError) {
console.error("onSave 콜백 에러:", callbackError);
}
}
// 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어)
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
try {
@ -1341,6 +1499,43 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
}
// V2Repeater 디테일 데이터 저장 (등록된 인스턴스가 있을 때만)
const hasRepeaterForUpdate = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
if (hasRepeaterForUpdate) {
try {
const repeaterSavePromise = new Promise<void>((resolve) => {
const fallbackTimeout = setTimeout(resolve, 5000);
const handler = () => {
clearTimeout(fallbackTimeout);
window.removeEventListener("repeaterSaveComplete", handler);
resolve();
};
window.addEventListener("repeaterSaveComplete", handler);
});
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: recordId,
tableName: screenData.screenInfo.tableName,
mainFormData: formData,
masterRecordId: recordId,
},
}),
);
await repeaterSavePromise;
} catch (repeaterError) {
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
}
}
// 리피터 저장 완료 후 메인 테이블 새로고침
if (modalState.onSave) {
try {
modalState.onSave();
} catch {}
}
handleClose();
} else {
throw new Error(response.message || "수정에 실패했습니다.");
@ -1398,7 +1593,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
</div>
</DialogHeader>
<div className="flex flex-1 items-center justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
<div className="flex flex-1 justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
{loading ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
@ -1407,165 +1602,175 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
</div>
</div>
) : screenData ? (
<div
data-screen-runtime="true"
className="relative bg-white"
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%",
}}
<ScreenContextProvider
screenId={modalState.screenId || undefined}
tableName={screenData.screenInfo?.tableName}
>
{/* 기본 레이어 컴포넌트 렌더링 */}
{screenData.components.map((component) => {
// 컴포넌트 위치를 offset만큼 조정
const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0;
const labelSpace = 30; // 라벨 공간 (입력 필드 위 -top-6 라벨용)
<div
data-screen-runtime="true"
className="relative m-auto bg-white"
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%",
}}
>
{/* 기본 레이어 컴포넌트 렌더링 */}
{screenData.components.map((component) => {
// 컴포넌트 위치를 offset만큼 조정
const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0;
const labelSpace = 30; // 라벨 공간 (입력 필드 위 -top-6 라벨용)
const adjustedComponent = {
...component,
position: {
...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace, // 라벨 공간 추가
},
};
const adjustedComponent = {
...component,
position: {
...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
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) => {
if (c.componentType === "universal-form-modal") return true;
return false;
});
const hasUniversalFormModal = screenData.components.some((c) => {
if (c.componentType === "universal-form-modal") return true;
return false;
});
const hasTableSectionData = Object.keys(formData).some(
(k) => k.startsWith("_tableSection_") || k.startsWith("__tableSection_"),
);
const hasTableSectionData = Object.keys(formData).some(
(k) => k.startsWith("_tableSection_") || k.startsWith("__tableSection_"),
);
const shouldUseEditModalSave = !hasTableSectionData && (groupData.length > 0 || !hasUniversalFormModal);
const shouldUseEditModalSave =
!hasTableSectionData && (groupData.length > 0 || !hasUniversalFormModal);
const enrichedFormData = {
...(groupData.length > 0 ? groupData[0] : formData),
tableName: screenData.screenInfo?.tableName,
screenId: modalState.screenId,
};
const enrichedFormData = {
// 마스터 데이터(formData)를 기본으로 깔고, groupData[0]으로 덮어쓰기
// → 디테일 행 수정 시에도 마스터 폼 필드가 표시됨
...formData,
...(groupData.length > 0 ? groupData[0] : {}),
tableName: screenData.screenInfo?.tableName,
screenId: modalState.screenId,
};
return (
<InteractiveScreenViewerDynamic
key={component.id}
component={adjustedComponent}
allComponents={[...screenData.components, ...activeConditionalComponents]}
formData={enrichedFormData}
originalData={originalData}
onFormDataChange={(fieldName, value) => {
if (groupData.length > 0) {
if (Array.isArray(value)) {
setGroupData(value);
return (
<InteractiveScreenViewerDynamic
key={component.id}
component={adjustedComponent}
allComponents={[...screenData.components, ...activeConditionalComponents]}
formData={enrichedFormData}
originalData={originalData}
onFormDataChange={(fieldName, value) => {
if (groupData.length > 0) {
if (Array.isArray(value)) {
setGroupData(value);
} else {
setGroupData((prev) =>
prev.map((item) => ({
...item,
[fieldName]: value,
})),
);
}
} else {
setGroupData((prev) =>
prev.map((item) => ({
...item,
[fieldName]: value,
})),
);
setFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}
} else {
setFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}
}}
screenInfo={{
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
menuObjid={modalState.menuObjid}
onSave={shouldUseEditModalSave ? handleSave : undefined}
isInModal={true}
groupedData={groupedDataProp}
disabledFields={["order_no", "partner_id"]}
/>
);
})}
}}
screenInfo={{
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
menuObjid={modalState.menuObjid}
onSave={shouldUseEditModalSave ? handleSave : undefined}
isInModal={true}
groupedData={groupedDataProp}
disabledFields={["order_no", "partner_id"]}
/>
);
})}
{/* 🆕 조건부 레이어 컴포넌트 렌더링 */}
{activeConditionalComponents.map((component) => {
const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0;
const labelSpace = 30;
{/* 🆕 조건부 레이어 컴포넌트 렌더링 */}
{activeConditionalComponents.map((component) => {
const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0;
const labelSpace = 30;
const adjustedComponent = {
...component,
position: {
...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace,
},
};
const adjustedComponent = {
...component,
position: {
...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace,
},
};
const enrichedFormData = {
...(groupData.length > 0 ? groupData[0] : formData),
tableName: screenData.screenInfo?.tableName,
screenId: modalState.screenId,
};
const enrichedFormData = {
...formData,
...(groupData.length > 0 ? groupData[0] : {}),
tableName: screenData.screenInfo?.tableName,
screenId: modalState.screenId,
};
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
return (
<InteractiveScreenViewerDynamic
key={`conditional-${component.id}`}
component={adjustedComponent}
allComponents={[...screenData.components, ...activeConditionalComponents]}
formData={enrichedFormData}
originalData={originalData}
onFormDataChange={(fieldName, value) => {
if (groupData.length > 0) {
if (Array.isArray(value)) {
setGroupData(value);
return (
<InteractiveScreenViewerDynamic
key={`conditional-${component.id}`}
component={adjustedComponent}
allComponents={[...screenData.components, ...activeConditionalComponents]}
formData={enrichedFormData}
originalData={originalData}
onFormDataChange={(fieldName, value) => {
if (groupData.length > 0) {
if (Array.isArray(value)) {
setGroupData(value);
} else {
setGroupData((prev) =>
prev.map((item) => ({
...item,
[fieldName]: value,
})),
);
}
} else {
setGroupData((prev) =>
prev.map((item) => ({
...item,
[fieldName]: value,
})),
);
setFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}
} else {
setFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}
}}
screenInfo={{
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
menuObjid={modalState.menuObjid}
isInModal={true}
groupedData={groupedDataProp}
/>
);
})}
</div>
}}
screenInfo={{
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
menuObjid={modalState.menuObjid}
isInModal={true}
groupedData={groupedDataProp}
/>
);
})}
</div>
</ScreenContextProvider>
) : (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground"> .</p>

View File

@ -378,7 +378,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
for (const col of categoryColumns) {
try {
// menuObjid가 있으면 쿼리 파라미터로 전달 (메뉴별 카테고리 색상 적용)
const queryParams = menuObjid ? `?menuObjid=${menuObjid}` : "";
const queryParams = menuObjid ? `?menuObjid=${menuObjid}&includeInactive=true` : "?includeInactive=true";
const response = await apiClient.get(
`/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 { InteractiveDataTable } from "./InteractiveDataTable";
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 { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils";
import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
@ -26,6 +26,7 @@ import {
getServerSnapshot as canvasSplitGetServerSnapshot,
subscribeDom as canvasSplitSubscribeDom,
} from "@/lib/registry/components/v2-split-line/canvasSplitStore";
import { ButtonIconRenderer } from "@/lib/button-icon-map";
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
import "@/lib/registry/components/ButtonRenderer";
@ -353,13 +354,13 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
if (!conditionalResult.visible) {
return null;
}
// 🆕 conditionalConfig 시스템 체크 (V2 레이아웃용)
const conditionalConfig = (comp as any).componentConfig?.conditionalConfig;
if (conditionalConfig?.enabled && formData) {
const { field, operator, value, action } = conditionalConfig;
const fieldValue = formData[field];
let conditionMet = false;
switch (operator) {
case "=":
@ -374,7 +375,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
default:
conditionMet = fieldValue === value;
}
if (action === "show" && !conditionMet) {
return null;
}
@ -447,6 +448,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
onClose={() => {
// buttonActions.ts가 이미 처리함
}}
isInModal={isInModal}
// 탭 관련 정보 전달
parentTabId={parentTabId}
parentTabsComponentId={parentTabsComponentId}
@ -558,9 +560,13 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
if (onSave) {
try {
await onSave();
} catch (error) {
} catch (error: any) {
console.error("저장 오류:", error);
toast.error("저장 중 오류가 발생했습니다.");
const msg =
error?.response?.data?.message ||
error?.message ||
"저장에 실패했습니다.";
toast.error(msg);
}
return;
}
@ -571,56 +577,85 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
return;
}
// 리피터가 화면과 동일 테이블을 사용하는지 감지 (useCustomTable 미설정 = 동일 테이블)
const hasRepeaterOnSameTable = allComponents.some((c: any) => {
const compType = c.componentType || c.overrides?.type;
if (compType !== "v2-repeater") return false;
const compConfig = c.componentConfig || c.overrides || {};
return !compConfig.useCustomTable;
});
if (hasRepeaterOnSameTable) {
// 동일 테이블 리피터: 마스터 저장 스킵, 리피터만 저장
// 리피터가 mainFormData를 각 행에 병합하여 N건 INSERT 처리
try {
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: null,
masterRecordId: null,
mainFormData: formData,
tableName: screenInfo.tableName,
},
}),
);
toast.success("데이터가 성공적으로 저장되었습니다.");
} catch (error: any) {
const msg =
error?.response?.data?.message ||
error?.message ||
"저장에 실패했습니다.";
toast.error(msg);
}
return;
}
try {
// 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장)
// 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장)
// 단, 파일 업로드 컴포넌트의 파일 배열(objid 배열)은 포함
const masterFormData: Record<string, any> = {};
// 파일 업로드 컴포넌트의 columnName 목록 수집 (v2-media, file-upload 모두 포함)
const mediaColumnNames = new Set(
allComponents
.filter(
(c: any) =>
c.componentType === "v2-media" ||
c.componentType === "file-upload" ||
c.url?.includes("v2-media") ||
c.url?.includes("file-upload"),
.filter((c: any) =>
c.componentType === "v2-media" ||
c.componentType === "file-upload" ||
c.url?.includes("v2-media") ||
c.url?.includes("file-upload")
)
.map((c: any) => c.columnName || c.componentConfig?.columnName)
.filter(Boolean),
.filter(Boolean)
);
Object.entries(formData).forEach(([key, value]) => {
if (!Array.isArray(value)) {
// 배열이 아닌 값은 그대로 저장
masterFormData[key] = value;
} else if (mediaColumnNames.has(key)) {
// v2-media 컴포넌트의 배열은 첫 번째 값만 저장 (단일 파일 컬럼 대응)
// 또는 JSON 문자열로 변환하려면 JSON.stringify(value) 사용
masterFormData[key] = value.length > 0 ? value[0] : null;
console.log(`📷 미디어 데이터 저장: ${key}, objid: ${masterFormData[key]}`);
} else {
console.log(`🔄 리피터 데이터 제외 (별도 저장): ${key}, ${value.length}개 항목`);
}
});
const saveData: DynamicFormData = {
tableName: screenInfo.tableName,
data: masterFormData,
};
// console.log("💾 저장 액션 실행:", saveData);
const response = await dynamicFormApi.saveData(saveData);
if (response.success) {
const masterRecordId = response.data?.id || formData.id;
// 리피터 데이터 저장 이벤트 발생 (V2Repeater 컴포넌트가 리스닝)
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
detail: {
parentId: masterRecordId,
masterRecordId, // 🆕 마스터 레코드 ID (FK 자동 연결용)
masterRecordId,
mainFormData: formData,
tableName: screenInfo.tableName,
},
@ -631,9 +666,12 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
} else {
toast.error(response.message || "저장에 실패했습니다.");
}
} catch (error) {
// console.error("저장 오류:", error);
toast.error("저장 중 오류가 발생했습니다.");
} catch (error: any) {
const msg =
error?.response?.data?.message ||
error?.message ||
"저장에 실패했습니다.";
toast.error(msg);
}
};
@ -931,15 +969,16 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
};
// 커스텀 색상이 있으면 Tailwind 클래스 대신 직접 스타일 적용
const hasCustomColors =
config?.backgroundColor || config?.textColor || comp.style?.backgroundColor || comp.style?.color;
const hasCustomColors = config?.backgroundColor || config?.textColor || comp.style?.backgroundColor || comp.style?.color;
return (
<button
onClick={handleClick}
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 ${
hasCustomColors ? "" : "bg-background border-foreground text-foreground hover:bg-muted/50 border shadow-xs"
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 border-foreground text-foreground shadow-xs hover:bg-muted/50'
}`}
style={{
// 컴포넌트 스타일 적용
@ -952,7 +991,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
height: "100%",
}}
>
{label || "버튼"}
<ButtonIconRenderer componentConfig={(comp as any).componentConfig} fallbackLabel={label || "버튼"} />
</button>
);
};
@ -1078,16 +1117,27 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// TableSearchWidget의 경우 높이를 자동으로 설정
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
// 🆕 라벨 표시 여부 확인 (V2 입력 컴포넌트)
// labelDisplay가 false가 아니고, labelText 또는 label이 있으면 라벨 표시
const isV2InputComponent = type === "v2-input" || type === "v2-select" || type === "v2-date";
const hasVisibleLabel =
isV2InputComponent && style?.labelDisplay !== false && (style?.labelText || (component as any).label);
// 라벨이 있는 경우 상단 여백 계산 (라벨 폰트크기 + 여백)
// 라벨 표시 여부 확인 (V2 입력 컴포넌트)
const compType = (component as any).componentType || "";
const isV2InputComponent =
type === "v2-input" || type === "v2-select" || type === "v2-date" ||
compType === "v2-input" || compType === "v2-select" || compType === "v2-date";
const hasVisibleLabel = isV2InputComponent &&
style?.labelDisplay !== false && style?.labelDisplay !== "false" &&
(style?.labelText || (component as any).label);
// 라벨 위치에 따라 오프셋 계산 (좌/우 배치 시 세로 오프셋 불필요)
const labelPos = style?.labelPosition || "top";
const isVerticalLabel = labelPos === "top" || labelPos === "bottom";
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const labelOffset = hasVisibleLabel ? labelFontSize + labelMarginBottom + 2 : 0;
const labelOffset = (hasVisibleLabel && isVerticalLabel) ? (labelFontSize + labelMarginBottom + 2) : 0;
// 수평 라벨 관련 (componentStyle 계산보다 먼저 선언)
const needsExternalLabel = hasVisibleLabel && labelPos !== "top";
const isHorizLabel = labelPos === "left" || labelPos === "right";
const labelText = style?.labelText || (component as any).label || "";
const labelGapValue = style?.labelGap || "8px";
const calculateCanvasSplitX = (): { x: number; w: number } => {
const compType = (component as any).componentType || "";
@ -1116,7 +1166,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
const origW = defaultW;
if (canvasSplitSideRef.current === null) {
const componentCenterX = origX + origW / 2;
const componentCenterX = origX + (origW / 2);
canvasSplitSideRef.current = componentCenterX < initialDividerX ? "left" : "right";
}
@ -1164,22 +1214,28 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// styleWithoutSize에서 left/top 제거 (캔버스 분할 조정값 덮어쓰기 방지)
const { left: _styleLeft, top: _styleTop, ...safeStyleWithoutSize } = styleWithoutSize as any;
// 수평 라벨 컴포넌트: position wrapper에서 border 제거 (내부 V2 컴포넌트가 기본 border 사용)
const cleanedStyle = (isHorizLabel && needsExternalLabel)
? (() => {
const { borderWidth: _bw, borderColor: _bc, borderStyle: _bs, border: _b, borderRadius: _br, ...rest } = safeStyleWithoutSize;
return rest;
})()
: safeStyleWithoutSize;
const componentStyle = {
position: "absolute" as const,
...safeStyleWithoutSize,
...cleanedStyle,
// left/top은 반드시 마지막에 (styleWithoutSize가 덮어쓰지 못하게)
left: adjustedX,
top: position?.y || 0,
zIndex: position?.z || 1,
width: isSplitActive ? adjustedW : size?.width || 200,
width: isSplitActive ? adjustedW : (size?.width || 200),
height: isTableSearchWidget ? "auto" : size?.height || 10,
minHeight: isTableSearchWidget ? "48px" : undefined,
overflow: isSplitActive && adjustedW < origW ? "hidden" : labelOffset > 0 ? "visible" : undefined,
willChange: canvasSplit.isDragging && isSplitActive ? ("left, width" as const) : undefined,
overflow: (isSplitActive && adjustedW < origW) ? "hidden" : (labelOffset > 0 ? "visible" : undefined),
willChange: canvasSplit.isDragging && isSplitActive ? "left, width" as const : undefined,
transition: isSplitActive
? canvasSplit.isDragging
? "none"
: "left 0.15s ease-out, width 0.15s ease-out"
? (canvasSplit.isDragging ? "none" : "left 0.15s ease-out, width 0.15s ease-out")
: undefined,
};
@ -1210,7 +1266,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
if (Math.abs(delta) < 1) return;
if (canvasSplitSideRef.current === null) {
canvasSplitSideRef.current = origX + oW / 2 < initialDividerX ? "left" : "right";
canvasSplitSideRef.current = (origX + oW / 2) < initialDividerX ? "left" : "right";
}
const GAP = 4;
@ -1239,10 +1295,101 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
return unsubscribe;
}, [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 (
<>
<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>
{/* 팝업 화면 렌더링 */}

View File

@ -15,6 +15,7 @@ import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { executeButtonWithFlow, handleFlowExecutionResult } from "@/lib/utils/nodeFlowButtonExecutor";
import { useCurrentFlowStep } from "@/stores/flowStepStore";
import { ButtonIconRenderer, getButtonDisplayContent } from "@/lib/button-icon-map";
interface OptimizedButtonProps {
component: ComponentData;
@ -27,16 +28,16 @@ interface OptimizedButtonProps {
selectedRowsData?: any[];
flowSelectedData?: any[];
flowSelectedStepId?: number | null;
// 🆕 테이블 전체 데이터 (table-all 모드용)
tableAllData?: any[];
// 🆕 플로우 스텝 전체 데이터 (flow-step-all 모드용)
flowStepAllData?: any[];
// 🆕 테이블 전체 데이터 로드 콜백 (필요 시 부모에서 제공)
onRequestTableAllData?: () => Promise<any[]>;
// 🆕 플로우 스텝 전체 데이터 로드 콜백 (필요 시 부모에서 제공)
onRequestFlowStepAllData?: (stepId: number) => Promise<any[]>;
}
@ -165,12 +166,12 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
const isControlOnlyAction = config?.actionType === "control";
// console.log("🎯 OptimizedButtonComponent 실행:", {
// actionType: config?.actionType,
// isControlOnlyAction,
// enableDataflowControl: config?.enableDataflowControl,
// hasDataflowConfig: !!config?.dataflowConfig,
// selectedRows,
// selectedRowsData,
// actionType: config?.actionType,
// isControlOnlyAction,
// enableDataflowControl: config?.enableDataflowControl,
// hasDataflowConfig: !!config?.dataflowConfig,
// selectedRows,
// selectedRowsData,
// });
if (config?.enableDataflowControl && config?.dataflowConfig) {
@ -190,7 +191,7 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
let preparedFlowStepAllData = flowStepAllData;
const dataSource = config.dataflowConfig.controlDataSource;
// table-all 모드일 때 데이터 로드
if (dataSource === "table-all" || dataSource === "all-sources") {
if (tableAllData.length === 0 && onRequestTableAllData) {
@ -386,7 +387,7 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
// console.log("✏️ Edit action completed:", result);
break;
default:
// console.log(`✅ ${actionType} action completed:`, result);
// console.log(`✅ ${actionType} action completed:`, result);
}
};
@ -644,23 +645,20 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
// 색상이 설정되어 있으면 variant 스타일을 무시하고 직접 스타일 적용
const hasCustomColors = config?.backgroundColor || config?.textColor;
return (
<div className="relative">
<Button
onClick={handleClick}
disabled={isExecuting || disabled}
// 색상이 설정되어 있으면 variant를 적용하지 않아서 Tailwind 색상 클래스가 덮어씌우지 않도록 함
variant={hasCustomColors ? undefined : config?.variant || "default"}
variant={hasCustomColors ? undefined : (config?.variant || "default")}
className={cn(
"transition-all duration-200",
isExecuting && "cursor-wait opacity-75",
backgroundJobs.size > 0 && "border-primary/20 bg-accent",
// 커스텀 색상이 없을 때만 기본 스타일 적용
!hasCustomColors && "bg-primary text-primary-foreground hover:bg-primary/90",
)}
style={{
// 커스텀 색상이 있을 때만 인라인 스타일 적용
...(config?.backgroundColor && { backgroundColor: config.backgroundColor }),
...(config?.textColor && { color: config.textColor }),
...(config?.borderColor && { borderColor: config.borderColor }),
@ -669,7 +667,14 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
{/* 메인 버튼 내용 */}
<div className="flex items-center space-x-2">
{getStatusIcon()}
<span>{isExecuting ? "처리 중..." : buttonLabel}</span>
<span>
{isExecuting ? "처리 중..." : (
<ButtonIconRenderer
componentConfig={component.componentConfig}
fallbackLabel={buttonLabel}
/>
)}
</span>
</div>
{/* 개발 모드에서 성능 정보 표시 */}
@ -683,7 +688,7 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
{/* 🆕 플로우 제어 활성화 표시 */}
{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="플로우 단계별 표시 제어 활성화">
<Workflow className="h-3 w-3" />
</Badge>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import React from "react";
import { Input } from "@/components/ui/input";
import { WebTypeComponentProps } from "@/lib/registry/types";
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 }) => {
const widget = component as WidgetComponent;
@ -21,10 +22,7 @@ export const NumberWidget: React.FC<WebTypeComponentProps> = ({ component, value
if (isNaN(numValue)) return "";
if (config?.format === "currency") {
return new Intl.NumberFormat("ko-KR", {
style: "currency",
currency: "KRW",
}).format(numValue);
return formatCurrency(numValue);
}
if (config?.format === "percentage") {
@ -32,7 +30,7 @@ export const NumberWidget: React.FC<WebTypeComponentProps> = ({ component, value
}
if (config?.thousandSeparator) {
return new Intl.NumberFormat("ko-KR").format(numValue);
return formatNum(numValue);
}
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 {
ChevronRight,
ChevronDown,
@ -291,6 +291,10 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
const [editingValue, setEditingValue] = 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({
valueCode: "",
@ -508,7 +512,15 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
const response = await createCategoryValue(input);
if (response.success) {
toast.success("카테고리가 추가되었습니다");
setIsAddModalOpen(false);
// 폼 초기화 (모달은 닫지 않고 연속 입력)
setFormData((prev) => ({
...prev,
valueCode: "",
valueLabel: "",
description: "",
color: "",
}));
setTimeout(() => addNameRef.current?.focus(), 50);
// 기존 펼침 상태 유지하면서 데이터 새로고침
await loadTree(true);
// 부모 노드만 펼치기 (하위 추가 시)
@ -746,9 +758,17 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
<span className="text-destructive">*</span>
</Label>
<Input
ref={addNameRef}
id="valueLabel"
value={formData.valueLabel}
onChange={(e) => setFormData({ ...formData, valueLabel: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
addDescRef.current?.focus();
}
}}
placeholder="카테고리 이름을 입력하세요"
className="h-9 text-sm"
/>
@ -759,9 +779,17 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
</Label>
<Input
ref={addDescRef}
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
handleAdd();
}
}}
placeholder="선택 사항"
className="h-9 text-sm"
/>
@ -784,7 +812,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
onClick={() => setIsAddModalOpen(false)}
className="h-9 flex-1 text-sm sm:flex-none"
>
</Button>
<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 AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cn } from "@/lib/utils";
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;
@ -16,26 +69,93 @@ const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ 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;
interface ScopedAlertDialogContentProps
extends React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> {
container?: HTMLElement | null;
hidden?: boolean;
}
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"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",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
ScopedAlertDialogContentProps
>(({ className, container: explicitContainer, hidden: hiddenProp, style, ...props }, ref) => {
const autoContainer = useModalPortal();
const container = explicitContainer !== undefined ? explicitContainer : autoContainer;
const scoped = React.useContext(ScopedAlertCtx);
const adjustedStyle = scoped && style
? { ...style, maxHeight: undefined, maxWidth: undefined }
: style;
const handleInteractOutside = React.useCallback(
(e: any) => {
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;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
@ -51,37 +171,47 @@ AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
));
>(({ className, ...props }, ref) => {
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;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description ref={ref} className={cn("text-muted-foreground text-sm", className)} {...props} />
));
>(({ className, ...props }, ref) => {
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;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
>(({ className, ...props }, ref) => {
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;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...props}
/>
));
>(({ className, ...props }, ref) => {
const scoped = React.useContext(ScopedAlertCtx);
const Comp = scoped ? DialogPrimitive.Close : AlertDialogPrimitive.Cancel;
return (
<Comp
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...props}
/>
);
});
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {

View File

@ -44,7 +44,14 @@ function 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 };

View File

@ -5,8 +5,46 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
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;
@ -18,44 +56,120 @@ const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ 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;
interface ScopedDialogContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
/** 포탈 대상 컨테이너. 명시적으로 전달하면 해당 값 사용, 미전달 시 탭 시스템 자동 감지 */
container?: HTMLElement | null;
/** 탭 비활성 시 포탈 내용 숨김 */
hidden?: boolean;
}
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
overlayClassName?: string;
container?: HTMLElement | null;
}
>(({ className, children, overlayClassName, container, ...props }, ref) => (
<DialogPortal container={container}>
<DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content
ref={ref}
className={cn(
"bg-background fixed top-[50%] left-[50%] z-[1000] flex w-full max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border p-6 shadow-lg sm:rounded-lg",
className,
)}
{...props}
>
{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">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
ScopedDialogContentProps
>(({ className, children, container: explicitContainer, hidden: hiddenProp, onInteractOutside, onFocusOutside, style, ...props }, ref) => {
const autoContainer = useModalPortal();
const container = explicitContainer !== undefined ? explicitContainer : autoContainer;
const scoped = !!container;
// state 기반 ref: DialogPrimitive.Content 마운트/언마운트 시 useEffect 재실행 보장
const [contentNode, setContentNode] = React.useState<HTMLDivElement | null>(null);
const mergedRef = React.useCallback(
(node: HTMLDivElement | null) => {
setContentNode(node);
if (typeof ref === "function") ref(node);
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
},
[ref],
);
useDialogAutoValidation(contentNode);
const handleInteractOutside = React.useCallback(
(e: any) => {
if (scoped && container) {
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;
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";
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";

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

View File

@ -2,7 +2,7 @@
/**
* V2Hierarchy
*
*
*
* - tree: 트리
* - org: 조직도
@ -18,37 +18,44 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { V2HierarchyProps, HierarchyNode } from "@/types/v2-components";
import {
ChevronRight,
ChevronDown,
Folder,
FolderOpen,
File,
Plus,
import {
ChevronRight,
ChevronDown,
Folder,
FolderOpen,
File,
Plus,
Minus,
GripVertical,
User,
Users,
Building,
Building
} from "lucide-react";
/**
*
*/
const TreeNode = forwardRef<
HTMLDivElement,
{
node: HierarchyNode;
level: number;
maxLevel?: number;
selectedNode?: HierarchyNode;
onSelect?: (node: HierarchyNode) => void;
editable?: boolean;
draggable?: boolean;
showQty?: boolean;
className?: string;
}
>(({ node, level, maxLevel, selectedNode, onSelect, editable, draggable, showQty, className }, ref) => {
const TreeNode = forwardRef<HTMLDivElement, {
node: HierarchyNode;
level: number;
maxLevel?: number;
selectedNode?: HierarchyNode;
onSelect?: (node: HierarchyNode) => void;
editable?: boolean;
draggable?: boolean;
showQty?: boolean;
className?: string;
}>(({
node,
level,
maxLevel,
selectedNode,
onSelect,
editable,
draggable,
showQty,
className
}, ref) => {
const [isOpen, setIsOpen] = useState(level < 2);
const hasChildren = node.children && node.children.length > 0;
const isSelected = selectedNode?.id === node.id;
@ -63,20 +70,26 @@ const TreeNode = forwardRef<
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div
className={cn(
"hover:bg-muted/50 flex cursor-pointer items-center gap-1 rounded px-2 py-1",
isSelected && "bg-primary/10 text-primary",
"flex items-center gap-1 py-1 px-2 rounded cursor-pointer hover:bg-muted/50",
isSelected && "bg-primary/10 text-primary"
)}
style={{ paddingLeft: `${level * 16 + 8}px` }}
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 ? (
<CollapsibleTrigger asChild onClick={(e) => e.stopPropagation()}>
<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>
</CollapsibleTrigger>
) : (
@ -91,15 +104,15 @@ const TreeNode = forwardRef<
<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용) */}
{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)}
</span>
)}
@ -107,13 +120,11 @@ const TreeNode = forwardRef<
{/* 편집 버튼 */}
{editable && (
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
<Button
variant="ghost"
size="icon"
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
}}
onClick={(e) => { e.stopPropagation(); }}
>
<Plus className="h-3 w-3" />
</Button>
@ -148,22 +159,21 @@ TreeNode.displayName = "TreeNode";
/**
*
*/
const TreeView = forwardRef<
HTMLDivElement,
{
data: HierarchyNode[];
selectedNode?: HierarchyNode;
onNodeSelect?: (node: HierarchyNode) => void;
editable?: boolean;
draggable?: boolean;
maxLevel?: number;
className?: string;
}
>(({ data, selectedNode, onNodeSelect, editable, draggable, maxLevel, className }, ref) => {
const TreeView = forwardRef<HTMLDivElement, {
data: HierarchyNode[];
selectedNode?: HierarchyNode;
onNodeSelect?: (node: HierarchyNode) => void;
editable?: boolean;
draggable?: boolean;
maxLevel?: number;
className?: string;
}>(({ data, selectedNode, onNodeSelect, editable, draggable, maxLevel, className }, ref) => {
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 ? (
<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) => (
<TreeNode
@ -186,15 +196,12 @@ TreeView.displayName = "TreeView";
/**
*
*/
const OrgView = forwardRef<
HTMLDivElement,
{
data: HierarchyNode[];
selectedNode?: HierarchyNode;
onNodeSelect?: (node: HierarchyNode) => void;
className?: string;
}
>(({ data, selectedNode, onNodeSelect, className }, ref) => {
const OrgView = forwardRef<HTMLDivElement, {
data: HierarchyNode[];
selectedNode?: HierarchyNode;
onNodeSelect?: (node: HierarchyNode) => void;
className?: string;
}>(({ data, selectedNode, onNodeSelect, className }, ref) => {
const renderOrgNode = (node: HierarchyNode, isRoot = false) => {
const isSelected = selectedNode?.id === node.id;
const hasChildren = node.children && node.children.length > 0;
@ -204,18 +211,16 @@ const OrgView = forwardRef<
{/* 노드 카드 */}
<div
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",
isRoot && "bg-primary/10",
isRoot && "bg-primary/10"
)}
onClick={() => onNodeSelect?.(node)}
>
<div
className={cn(
"mb-2 flex h-10 w-10 items-center justify-center rounded-full",
isRoot ? "bg-primary text-primary-foreground" : "bg-muted",
)}
>
<div className={cn(
"w-10 h-10 rounded-full flex items-center justify-center mb-2",
isRoot ? "bg-primary text-primary-foreground" : "bg-muted"
)}>
{isRoot ? (
<Building className="h-5 w-5" />
) : hasChildren ? (
@ -225,8 +230,10 @@ const OrgView = forwardRef<
)}
</div>
<div className="text-center">
<div className="text-sm font-medium">{node.label}</div>
{node.data?.title && <div className="text-muted-foreground text-xs">{String(node.data.title)}</div>}
<div className="font-medium text-sm">{node.label}</div>
{node.data?.title && (
<div className="text-xs text-muted-foreground">{String(node.data.title)}</div>
)}
</div>
</div>
@ -234,7 +241,7 @@ const OrgView = forwardRef<
{hasChildren && (
<>
{/* 연결선 */}
<div className="bg-border h-4 w-px" />
<div className="w-px h-4 bg-border" />
<div className="flex gap-4">
{node.children!.map((child, index) => (
<React.Fragment key={child.id}>
@ -252,9 +259,13 @@ const OrgView = forwardRef<
return (
<div ref={ref} className={cn("overflow-auto p-4", className)}>
{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>
);
@ -264,20 +275,19 @@ OrgView.displayName = "OrgView";
/**
* BOM ( )
*/
const BomView = forwardRef<
HTMLDivElement,
{
data: HierarchyNode[];
selectedNode?: HierarchyNode;
onNodeSelect?: (node: HierarchyNode) => void;
editable?: boolean;
className?: string;
}
>(({ data, selectedNode, onNodeSelect, editable, className }, ref) => {
const BomView = forwardRef<HTMLDivElement, {
data: HierarchyNode[];
selectedNode?: HierarchyNode;
onNodeSelect?: (node: HierarchyNode) => void;
editable?: boolean;
className?: string;
}>(({ data, selectedNode, onNodeSelect, editable, className }, ref) => {
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 ? (
<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) => (
<TreeNode
@ -299,16 +309,13 @@ BomView.displayName = "BomView";
/**
*
*/
const CascadingView = forwardRef<
HTMLDivElement,
{
data: HierarchyNode[];
selectedNode?: HierarchyNode;
onNodeSelect?: (node: HierarchyNode) => void;
maxLevel?: number;
className?: string;
}
>(({ data, selectedNode, onNodeSelect, maxLevel = 3, className }, ref) => {
const CascadingView = forwardRef<HTMLDivElement, {
data: HierarchyNode[];
selectedNode?: HierarchyNode;
onNodeSelect?: (node: HierarchyNode) => void;
maxLevel?: number;
className?: string;
}>(({ data, selectedNode, onNodeSelect, maxLevel = 3, className }, ref) => {
const [selections, setSelections] = useState<string[]>([]);
// 레벨별 옵션 가져오기
@ -319,10 +326,10 @@ const CascadingView = forwardRef<
for (let i = 0; i < level; i++) {
const selectedId = selections[i];
if (!selectedId) return [];
const selectedNode = currentNodes.find((n) => n.id === selectedId);
if (!selectedNode?.children) return [];
currentNodes = selectedNode.children;
}
return currentNodes;
@ -377,94 +384,125 @@ CascadingView.displayName = "CascadingView";
/**
* V2Hierarchy
*/
export const V2Hierarchy = forwardRef<HTMLDivElement, V2HierarchyProps>((props, ref) => {
const { id, label, required, style, size, config: configProp, data = [], selectedNode, onNodeSelect } = props;
export const V2Hierarchy = forwardRef<HTMLDivElement, V2HierarchyProps>(
(props, ref) => {
const {
id,
label,
required,
style,
size,
config: configProp,
data = [],
selectedNode,
onNodeSelect,
} = props;
// config가 없으면 기본값 사용
const config = configProp || { type: "tree" as const, viewMode: "tree" as const, dataSource: "static" as const };
// config가 없으면 기본값 사용
const config = configProp || { type: "tree" as const, viewMode: "tree" as const, dataSource: "static" as const };
// 뷰모드별 렌더링
const renderHierarchy = () => {
const viewMode = config.viewMode || config.type || "tree";
switch (viewMode) {
case "tree":
return (
<TreeView
data={data}
selectedNode={selectedNode}
onNodeSelect={onNodeSelect}
editable={config.editable}
draggable={config.draggable}
maxLevel={config.maxLevel}
/>
);
// 뷰모드별 렌더링
const renderHierarchy = () => {
const viewMode = config.viewMode || config.type || "tree";
switch (viewMode) {
case "tree":
return (
<TreeView
data={data}
selectedNode={selectedNode}
onNodeSelect={onNodeSelect}
editable={config.editable}
draggable={config.draggable}
maxLevel={config.maxLevel}
/>
);
case "org":
return <OrgView data={data} selectedNode={selectedNode} onNodeSelect={onNodeSelect} />;
case "org":
return (
<OrgView
data={data}
selectedNode={selectedNode}
onNodeSelect={onNodeSelect}
/>
);
case "bom":
return (
<BomView data={data} selectedNode={selectedNode} onNodeSelect={onNodeSelect} editable={config.editable} />
);
case "bom":
return (
<BomView
data={data}
selectedNode={selectedNode}
onNodeSelect={onNodeSelect}
editable={config.editable}
/>
);
case "dropdown":
case "cascading":
return (
<CascadingView
data={data}
selectedNode={selectedNode}
onNodeSelect={onNodeSelect}
maxLevel={config.maxLevel}
/>
);
case "dropdown":
case "cascading":
return (
<CascadingView
data={data}
selectedNode={selectedNode}
onNodeSelect={onNodeSelect}
maxLevel={config.maxLevel}
/>
);
default:
return <TreeView data={data} selectedNode={selectedNode} onNodeSelect={onNodeSelect} />;
}
};
default:
return (
<TreeView
data={data}
selectedNode={selectedNode}
onNodeSelect={onNodeSelect}
/>
);
}
};
const showLabel = label && style?.labelDisplay !== false;
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
const showLabel = label && style?.labelDisplay !== false;
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
return (
<div
ref={ref}
id={id}
className="relative"
style={{
width: componentWidth,
height: componentHeight,
}}
>
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
{showLabel && (
<Label
htmlFor={id}
style={{
position: "absolute",
top: `-${estimatedLabelHeight}px`,
left: 0,
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}}
className="text-sm font-medium whitespace-nowrap"
>
{label}
{required && <span className="ml-0.5 text-orange-500">*</span>}
</Label>
)}
<div className="h-full w-full">{renderHierarchy()}</div>
</div>
);
});
return (
<div
ref={ref}
id={id}
className="relative"
style={{
width: componentWidth,
height: componentHeight,
}}
>
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
{showLabel && (
<Label
htmlFor={id}
style={{
position: "absolute",
top: `-${estimatedLabelHeight}px`,
left: 0,
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}}
className="text-sm font-medium whitespace-nowrap"
>
{label}{required && <span className="text-orange-500">*</span>}
</Label>
)}
<div className="h-full w-full">
{renderHierarchy()}
</div>
</div>
);
}
);
V2Hierarchy.displayName = "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 }> = {
none: { pattern: /.*/, placeholder: "", errorMessage: "" },
email: {
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
placeholder: "example@email.com",
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://로 시작)",
},
email: { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, placeholder: "example@email.com", 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: "숫자만 입력 가능합니다" },
biz_no: {
pattern: /^\d{3}-\d{2}-\d{5}$/,
placeholder: "123-45-67890",
errorMessage: "올바른 사업자번호 형식이 아닙니다",
},
biz_no: { pattern: /^\d{3}-\d{2}-\d{5}$/, placeholder: "123-45-67890", errorMessage: "올바른 사업자번호 형식이 아닙니다" },
};
// 형식 검증 함수 (외부에서도 사용 가능)
@ -212,7 +196,7 @@ const TextInput = forwardRef<
const hasError = hasBlurred && !!validationError;
return (
<div className="flex h-full w-full flex-col">
<div className="relative h-full w-full">
<Input
ref={ref}
type="text"
@ -222,10 +206,16 @@ const TextInput = forwardRef<
placeholder={inputPlaceholder}
readOnly={readonly}
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}
/>
{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>
);
});
@ -466,7 +456,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
// 채번 타입 자동생성 상태
const [isGeneratingNumbering, setIsGeneratingNumbering] = useState(false);
const hasGeneratedNumberingRef = useRef(false);
// formData를 ref로 관리하여 closure 문제 해결 (채번 코드 생성 시 최신 값 사용)
const formDataRef = useRef(formData);
formDataRef.current = formData;
@ -476,8 +466,8 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
// 2. config에서 설정된 값
// 3. 컴포넌트 overrides에서 설정된 값 (V2 레이아웃)
// 4. screenInfo에서 화면 테이블명
const tableName =
(props as any).tableName ||
const tableName =
(props as any).tableName ||
(config as any).tableName ||
(props as any).component?.tableName ||
(props as any).component?.overrides?.tableName ||
@ -607,7 +597,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
if (userEditedNumberingRef.current && !categoryChanged) {
return;
}
// 이미 생성되었고 카테고리 값이 변경되지 않았으면 스킵
if (hasGeneratedNumberingRef.current && !categoryChanged) {
return;
@ -629,46 +619,40 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
try {
// 채번 규칙 ID 캐싱 (한 번만 조회)
if (!numberingRuleIdRef.current) {
const { getTableColumns } = await import("@/lib/api/tableManagement");
const columnsResponse = await getTableColumns(tableName);
// table_name + column_name 기반으로 채번 규칙 조회
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) {
console.warn("테이블 컬럼 정보 조회 실패:", columnsResponse);
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);
if (onFormDataChange && columnName) {
onFormDataChange(`${columnName}_numberingRuleId`, ruleResponse.data.data.ruleId);
}
} 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;
if (!numberingRuleId) {
console.warn("채번 규칙 ID가 설정되지 않았습니다. 테이블 관리에서 설정하세요.", { tableName, columnName });
console.warn("채번 규칙을 찾을 수 없습니다. 옵션설정 > 채번설정에서 규칙을 생성하세요.", { tableName, columnName });
return;
}
@ -680,24 +664,24 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
const generatedCode = previewResponse.data.generatedCode;
hasGeneratedNumberingRef.current = true;
lastCategoryValuesRef.current = categoryValuesForNumbering;
// 수동 입력 부분이 있는 경우
if (generatedCode.includes("____")) {
hadManualPartRef.current = true;
const oldTemplate = numberingTemplateRef.current;
numberingTemplateRef.current = generatedCode;
// 카테고리 변경으로 템플릿이 바뀌었을 때 기존 사용자 입력값 유지
if (oldTemplate && oldTemplate !== generatedCode) {
// 템플릿이 변경되었지만 사용자 입력값은 유지
const templateParts = generatedCode.split("____");
const templatePrefix = templateParts[0] || "";
const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : "";
// 기존 manualInputValue를 사용하여 새 값 조합 (상태는 유지)
// 참고: setManualInputValue는 호출하지 않음 (기존 값 유지)
const finalValue = templatePrefix + (userEditedNumberingRef.current ? "" : "") + templateSuffix;
// 사용자가 입력한 적이 없으면 템플릿 그대로
if (!userEditedNumberingRef.current) {
setAutoGeneratedValue(generatedCode);
@ -717,7 +701,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
onChange?.(generatedCode);
userEditedNumberingRef.current = false;
}
// 채번 코드 생성 성공
} else {
console.warn("채번 코드 생성 실패:", previewResponse);
@ -746,10 +730,10 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
const templateParts = template.split("____");
const templatePrefix = templateParts[0] || "";
const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : "";
// 현재 조합된 값 생성
const currentValue = templatePrefix + manualInputValue + templateSuffix;
// formData에 직접 주입
if (event.detail?.formData && columnName) {
event.detail.formData[columnName] = currentValue;
@ -792,12 +776,11 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
case "number":
// DB에서 문자열("325")로 반환되는 경우도 숫자로 변환하여 표시
const numValue =
typeof displayValue === "number"
? displayValue
: displayValue !== undefined && displayValue !== null && displayValue !== "" && !isNaN(Number(displayValue))
? Number(displayValue)
: undefined;
const numValue = typeof displayValue === "number"
? displayValue
: (displayValue !== undefined && displayValue !== null && displayValue !== "" && !isNaN(Number(displayValue)))
? Number(displayValue)
: undefined;
return (
<NumberInput
value={numValue}
@ -832,12 +815,11 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
case "slider":
// DB에서 문자열로 반환되는 경우도 숫자로 변환
const sliderValue =
typeof displayValue === "number"
? displayValue
: displayValue !== undefined && displayValue !== null && displayValue !== "" && !isNaN(Number(displayValue))
? Number(displayValue)
: (config.min ?? 0);
const sliderValue = typeof displayValue === "number"
? displayValue
: (displayValue !== undefined && displayValue !== null && displayValue !== "" && !isNaN(Number(displayValue)))
? Number(displayValue)
: (config.min ?? 0);
return (
<SliderInput
value={sliderValue}
@ -884,9 +866,9 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
// 채번 타입: ____ 부분만 편집 가능하게 처리
const template = numberingTemplateRef.current;
const canEdit = hadManualPartRef.current && template;
// 채번 필드 렌더링
// 템플릿이 없으면 읽기 전용 (아직 생성 전이거나 수동 입력 부분 없음)
if (!canEdit) {
return (
@ -900,12 +882,12 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
/>
);
}
// 템플릿에서 prefix와 suffix 추출
const templateParts = template.split("____");
const templatePrefix = templateParts[0] || "";
const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : "";
return (
<div className="flex h-full items-center rounded-md border">
{/* 고정 접두어 */}
@ -921,25 +903,23 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
onChange={(e) => {
const newUserInput = e.target.value;
setManualInputValue(newUserInput);
// 전체 값 조합
const newValue = templatePrefix + newUserInput + templateSuffix;
userEditedNumberingRef.current = true;
setAutoGeneratedValue(newValue);
// 모든 방법으로 formData 업데이트 시도
onChange?.(newValue);
if (onFormDataChange && columnName) {
onFormDataChange(columnName, newValue);
}
// 커스텀 이벤트로도 전달 (최후의 보루)
if (typeof window !== "undefined" && columnName) {
window.dispatchEvent(
new CustomEvent("numberingValueChanged", {
detail: { columnName, value: newValue },
}),
);
window.dispatchEvent(new CustomEvent("numberingValueChanged", {
detail: { columnName, value: newValue }
}));
}
}}
placeholder="입력"
@ -976,36 +956,82 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
}
};
// 라벨이 표시될 때 입력 필드가 차지할 높이 계산
// 🔧 label prop이 없어도 style.labelText에서 가져올 수 있도록 수정
const actualLabel = label || style?.labelText;
const showLabel = actualLabel && style?.labelDisplay === true;
// size에서 우선 가져오고, 없으면 style에서 가져옴
const showLabel = actualLabel && style?.labelDisplay !== false && style?.labelDisplay !== "false";
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
// 라벨 위치 및 높이 계산
const labelPos = style?.labelPosition || "top";
const isHorizLabel = labelPos === "left" || labelPos === "right";
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; // 라벨 높이 + 여백
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
const labelGapValue = style?.labelGap || "8px";
// 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 테두리/배경/텍스트 스타일)
// RealtimePreview 래퍼가 외부 div에 스타일을 적용하지만,
// 내부 input/textarea가 자체 Tailwind 테두리를 가지므로 이를 제거하여 외부 스타일이 보이도록 함
// 커스텀 스타일 감지
const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border);
const hasCustomBackground = !!style?.backgroundColor;
const hasCustomRadius = !!style?.borderRadius;
// 텍스트 스타일 오버라이드 (내부 input/textarea에 직접 전달)
const customTextStyle: React.CSSProperties = {};
if (style?.color) customTextStyle.color = style.color;
if (style?.fontSize) customTextStyle.fontSize = style.fontSize;
if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight;
if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"];
const hasCustomText = Object.keys(customTextStyle).length > 0;
// 내부 input에 직접 적용할 텍스트 스타일 (fontSize, color, fontWeight, textAlign)
const inputTextStyle: React.CSSProperties | undefined = hasCustomText ? customTextStyle : undefined;
const labelElement = showLabel ? (
<Label
htmlFor={id}
style={{
...(labelPos === "top" ? { position: "absolute" as const, top: `-${estimatedLabelHeight}px`, left: 0 } : {}),
...(labelPos === "bottom" ? { position: "absolute" as const, bottom: `-${estimatedLabelHeight}px`, left: 0 } : {}),
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}}
className="text-sm font-medium whitespace-nowrap"
>
{actualLabel}{required && <span className="text-orange-500">*</span>}
</Label>
) : null;
const inputContent = (
<div
className={cn(
isHorizLabel ? "min-w-0 flex-1" : "h-full w-full",
hasCustomBorder && "[&_input]:border-0! [&_textarea]:border-0! [&_.border]:border-0!",
(hasCustomBorder || hasCustomRadius) && "[&_input]:rounded-none! [&_textarea]:rounded-none! [&_.rounded-md]:rounded-none!",
hasCustomBackground && "[&_input]:bg-transparent! [&_textarea]:bg-transparent!",
)}
style={{ ...(hasCustomText ? customTextStyle : {}), ...(isHorizLabel ? { height: "100%" } : {}) }}
>
{renderInput()}
</div>
);
if (isHorizLabel && showLabel) {
return (
<div
ref={ref}
id={id}
style={{
width: componentWidth,
height: componentHeight,
display: "flex",
flexDirection: labelPos === "left" ? "row" : "row-reverse",
alignItems: "center",
gap: labelGapValue,
}}
>
{labelElement}
{inputContent}
</div>
);
}
return (
<div
ref={ref}
@ -1016,39 +1042,8 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
height: componentHeight,
}}
>
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 (높이에 포함되지 않음) */}
{showLabel && (
<Label
htmlFor={id}
style={{
position: "absolute",
top: `-${estimatedLabelHeight}px`,
left: 0,
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}}
className="text-sm font-medium whitespace-nowrap"
>
{actualLabel}
{required && <span className="ml-0.5 text-orange-500">*</span>}
</Label>
)}
<div
className={cn(
"h-full w-full",
// 커스텀 테두리 설정 시, 내부 input/textarea의 기본 테두리 제거 (외부 래퍼 스타일이 보이도록)
hasCustomBorder && "[&_.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>
{labelElement}
{inputContent}
</div>
);
});

View File

@ -840,8 +840,7 @@ export const V2Media = forwardRef<HTMLDivElement, V2MediaProps>((props, ref) =>
}}
className="shrink-0 text-sm font-medium"
>
{label}
{required && <span className="ml-0.5 text-orange-500">*</span>}
{label}{required && <span className="text-orange-500">*</span>}
</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}`);
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)) {
const mapping: Record<string, { label: string; color: string }> = {};
@ -838,7 +838,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
try {
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)) {
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 { 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 {
@ -175,6 +261,15 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
children,
...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 사용
// 🆕 V2 레이아웃의 경우 url에서 컴포넌트 타입 추출 (예: "@/lib/registry/components/v2-input" → "v2-input")
const extractTypeFromUrl = (url: string | undefined): string | undefined => {
@ -183,17 +278,16 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const segments = url.split("/");
return segments[segments.length - 1];
};
const rawComponentType =
(component as any).componentType || component.type || extractTypeFromUrl((component as any).url);
const rawComponentType = (component as any).componentType || component.type || extractTypeFromUrl((component as any).url);
// 레거시 타입을 v2 컴포넌트로 매핑 (v2 컴포넌트가 없으면 원본 유지)
const mapToV2ComponentType = (type: string | undefined): string | undefined => {
if (!type) return type;
// 이미 v2- 접두사가 있으면 그대로 반환
if (type.startsWith("v2-")) return type;
// 레거시 타입을 v2로 매핑 시도
const v2Type = `v2-${type}`;
// v2 버전이 등록되어 있는지 확인
@ -203,21 +297,20 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// v2 버전이 없으면 원본 유지
return type;
};
const componentType = mapToV2ComponentType(rawComponentType);
// 컴포넌트 타입 변환 완료
// 🆕 조건부 렌더링 체크 (conditionalConfig)
// componentConfig 또는 overrides에서 conditionalConfig를 가져와서 formData와 비교
const conditionalConfig =
(component as any).componentConfig?.conditionalConfig || (component as any).overrides?.conditionalConfig;
const conditionalConfig = (component as any).componentConfig?.conditionalConfig || (component as any).overrides?.conditionalConfig;
// 조건부 렌더링 처리
if (conditionalConfig?.enabled && props.formData) {
const { field, operator, value, action } = conditionalConfig;
const fieldValue = props.formData[field];
// 조건 평가
let conditionMet = false;
switch (operator) {
@ -254,7 +347,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
default:
conditionMet = fieldValue === value;
}
// 액션에 따라 렌더링 결정
if (action === "show" && !conditionMet) {
return null;
@ -273,30 +366,39 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const webType = (component as any).componentConfig?.webType;
const tableName = (component as any).tableName;
const columnName = (component as any).columnName;
// 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만
// ⚠️ 단, 다음 경우는 V2SelectRenderer로 직접 처리 (고급 모드 지원):
// 1. componentType이 "select-basic" 또는 "v2-select"인 경우
// 2. config.mode가 dropdown이 아닌 경우 (radio, check, tagbox 등)
const componentMode = (component as any).componentConfig?.mode || (component as any).config?.mode;
const isMultipleSelect = (component as any).componentConfig?.multiple;
const nonDropdownModes = ["radio", "check", "checkbox", "tag", "tagbox", "toggle", "swap", "combobox"];
const isNonDropdownMode = componentMode && nonDropdownModes.includes(componentMode);
const shouldUseV2Select = componentType === "select-basic" || componentType === "v2-select" || isNonDropdownMode;
if ((inputType === "category" || webType === "category") && tableName && columnName && shouldUseV2Select) {
const shouldUseV2Select = componentType === "select-basic" || componentType === "v2-select" || isNonDropdownMode || isMultipleSelect;
if (
(inputType === "category" || webType === "category") &&
tableName &&
columnName &&
shouldUseV2Select
) {
// V2SelectRenderer로 직접 렌더링 (카테고리 + 고급 모드)
try {
const { V2SelectRenderer } = require("@/lib/registry/components/v2-select/V2SelectRenderer");
const fieldName = columnName || component.id;
const currentValue = props.formData?.[fieldName] || "";
// 수평 라벨 감지
const catLabelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay;
const catLabelPosition = component.style?.labelPosition;
const catLabelText = (catLabelDisplay === true || catLabelDisplay === "true")
? (component.style?.labelText || (component as any).label || component.componentConfig?.label)
: undefined;
const catNeedsExternalHorizLabel = !!(
catLabelText &&
(catLabelPosition === "left" || catLabelPosition === "right")
);
const handleChange = (value: any) => {
if (props.onFormDataChange) {
props.onFormDataChange(fieldName, value);
}
};
// V2SelectRenderer용 컴포넌트 데이터 구성
const selectComponent = {
...component,
componentConfig: {
@ -311,6 +413,24 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
inputType: "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 = {
component: selectComponent,
@ -319,12 +439,46 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
isDesignMode: props.isDesignMode,
isInteractive: props.isInteractive ?? !props.isDesignMode,
tableName,
style: (component as any).style,
size: (component as any).size,
style: catStyle,
size: catSize,
};
const rendererInstance = new V2SelectRenderer(rendererProps);
return rendererInstance.render();
const renderedCatSelect = rendererInstance.render();
if (catNeedsExternalHorizLabel) {
const labelGap = component.style?.labelGap || "8px";
const labelFontSize = component.style?.labelFontSize || "14px";
const labelColor = component.style?.labelColor || "#64748b";
const labelFontWeight = component.style?.labelFontWeight || "500";
const isRequired = component.required || (component as any).required || 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) {
console.error("❌ V2SelectRenderer 로드 실패:", error);
}
@ -457,19 +611,18 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const safeProps = filterDOMProps(restProps);
// 컴포넌트의 columnName에 해당하는 formData 값 추출
const fieldName =
(component as any).columnName || (component as any).componentConfig?.columnName || component.id;
const fieldName = (component as any).columnName || (component as any).componentConfig?.columnName || component.id;
// 다중 레코드를 다루는 컴포넌트는 배열 데이터로 초기화
let currentValue;
if (
componentType === "modal-repeater-table" ||
componentType === "repeat-screen-modal" ||
componentType === "selected-items-detail-input" ||
componentType === "v2-repeater"
) {
if (componentType === "modal-repeater-table" ||
componentType === "repeat-screen-modal" ||
componentType === "selected-items-detail-input") {
// EditModal/ScreenModal에서 전달된 groupedData가 있으면 우선 사용
currentValue = props.groupedData || formData?.[fieldName] || [];
} else if (componentType === "v2-repeater") {
// V2Repeater는 자체 데이터 관리 (groupedData는 메인 테이블 레코드이므로 사용하지 않음)
currentValue = formData?.[fieldName] || [];
} else {
currentValue = formData?.[fieldName] || "";
}
@ -537,55 +690,90 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
componentType === "modal-repeater-table" ||
componentType === "v2-input";
// 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true일 때만 라벨 표시)
// 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true/"true"일 때만 라벨 표시)
const labelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay;
const effectiveLabel =
labelDisplay === true
? component.style?.labelText || (component as any).label || component.componentConfig?.label
: undefined;
const effectiveLabel = (labelDisplay === true || labelDisplay === "true")
? (component.style?.labelText || (component as any).label || component.componentConfig?.label)
: undefined;
// 🔧 수평 라벨(left/right) 감지 → 외부 flex 컨테이너에서 라벨 처리
const labelPosition = component.style?.labelPosition;
const isV2Component = componentType?.startsWith("v2-");
const needsExternalHorizLabel = !!(
isV2Component &&
effectiveLabel &&
(labelPosition === "left" || labelPosition === "right")
);
// 🔧 순서 중요! component.style 먼저, CSS 크기 속성은 size 기반으로 덮어씀
const mergedStyle = {
...component.style, // 원본 style (labelDisplay, labelText 등) - 먼저!
...component.style, // 원본 style (labelDisplay, labelText 등) - 먼저!
// CSS 크기 속성은 size에서 계산한 값으로 명시적 덮어쓰기 (우선순위 최고)
width: finalStyle.width,
height: finalStyle.height,
// 수평 라벨 → V2 컴포넌트에는 라벨 비활성화 (외부에서 처리)
...(needsExternalHorizLabel ? {
labelDisplay: false,
labelPosition: "top" as const,
width: "100%",
height: "100%",
borderWidth: undefined,
borderColor: undefined,
borderStyle: undefined,
border: undefined,
borderRadius: undefined,
} : {}),
};
// 컬럼 메타데이터 기반 componentConfig 병합 (DB 최신 설정 우선)
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 = {
component,
component: effectiveComponent,
isSelected,
onClick,
onDragStart,
onDragEnd,
size: component.size || newComponent.defaultSize,
size: needsExternalHorizLabel
? { ...(component.size || newComponent.defaultSize), width: undefined, height: undefined }
: (component.size || newComponent.defaultSize),
position: component.position,
config: component.componentConfig,
componentConfig: component.componentConfig,
config: mergedComponentConfig,
componentConfig: mergedComponentConfig,
// componentConfig의 모든 속성을 props로 spread (tableName, displayField 등)
...(component.componentConfig || {}),
...(mergedComponentConfig || {}),
// 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선)
style: mergedStyle,
// 🆕 라벨 표시 (labelDisplay가 true일 때만)
label: effectiveLabel,
// 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달
inputType: (component as any).inputType || component.componentConfig?.inputType,
// 수평 라벨 → 외부에서 처리하므로 label 전달 안 함
label: needsExternalHorizLabel ? undefined : effectiveLabel,
// NOT NULL 메타데이터 포함된 필수 여부 (V2Hierarchy 등 직접 props.required 참조하는 컴포넌트용)
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,
value: currentValue, // formData에서 추출한 현재 값 전달
// 새로운 기능들 전달
// 🆕 webTypeConfig.numberingRuleId가 있으면 autoGeneration으로 변환
autoGeneration:
component.autoGeneration ||
autoGeneration: component.autoGeneration ||
component.componentConfig?.autoGeneration ||
((component as any).webTypeConfig?.numberingRuleId
? {
type: "numbering_rule" as const,
enabled: true,
options: {
numberingRuleId: (component as any).webTypeConfig.numberingRuleId,
},
}
: undefined),
((component as any).webTypeConfig?.numberingRuleId ? {
type: "numbering_rule" as const,
enabled: true,
options: {
numberingRuleId: (component as any).webTypeConfig.numberingRuleId,
},
} : undefined),
hidden: hiddenValue,
// React 전용 props들은 직접 전달 (DOM에 전달되지 않음)
isInteractive,
@ -594,8 +782,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
onChange: handleChange, // 개선된 onChange 핸들러 전달
// 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName 유지, 그 외는 화면 테이블명 사용
// 🆕 component.tableName도 확인 (V2 레이아웃에서 overrides.tableName이 복원됨)
tableName: useConfigTableName
? component.componentConfig?.tableName || (component as any).tableName || tableName
tableName: useConfigTableName
? component.componentConfig?.tableName || (component as any).tableName || tableName
: tableName,
menuId, // 🆕 메뉴 ID
menuObjid, // 🆕 메뉴 OBJID (메뉴 스코프)
@ -612,9 +800,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// componentConfig.mode가 있으면 유지 (entity-search-input의 UI 모드)
mode: component.componentConfig?.mode || mode,
isInModal,
readonly: component.readonly,
// 🆕 disabledFields 체크 또는 기존 readonly
disabled: disabledFields?.includes(fieldName) || component.readonly,
readonly: isEntityJoinColumn ? false : component.readonly,
disabled: isEntityJoinColumn ? false : (disabledFields?.includes(fieldName) || component.readonly),
originalData,
allComponents,
onUpdateLayout,
@ -649,8 +836,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 🆕 UniversalFormModal용 initialData 전달
// 우선순위: props.initialData > originalData > formData
// 조건부 컨테이너에서 전달된 initialData가 있으면 그것을 사용
_initialData:
props.initialData || (originalData && Object.keys(originalData).length > 0 ? originalData : formData),
_initialData: props.initialData || ((originalData && Object.keys(originalData).length > 0) ? originalData : formData),
_originalData: originalData,
// 🆕 initialData도 직접 전달 (조건부 컨테이너 → 내부 컴포넌트)
initialData: props.initialData,
@ -673,16 +859,50 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
NewComponentRenderer.prototype &&
NewComponentRenderer.prototype.render;
let renderedElement: React.ReactElement;
if (isClass) {
// 클래스 기반 렌더러 (AutoRegisteringComponentRenderer 상속)
const rendererInstance = new NewComponentRenderer(rendererProps);
return rendererInstance.render();
renderedElement = rendererInstance.render();
} else {
// 함수형 컴포넌트
// refreshKey를 React key로 전달하여 컴포넌트 리마운트 강제
return <NewComponentRenderer key={refreshKey} {...rendererProps} />;
renderedElement = <NewComponentRenderer key={refreshKey} {...rendererProps} />;
}
// 수평 라벨 → 라벨을 컴포넌트 영역 바깥에 absolute 배치, 입력은 100% 채움
if (needsExternalHorizLabel) {
const labelGap = component.style?.labelGap || "8px";
const labelFontSize = component.style?.labelFontSize || "14px";
const labelColor = component.style?.labelColor || "#64748b";
const labelFontWeight = component.style?.labelFontWeight || "500";
const isRequired = 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) {
console.error(`❌ 새 컴포넌트 렌더링 실패 (${componentType}):`, error);

View File

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

View File

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

View File

@ -112,12 +112,13 @@ import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트
import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트
import "./v2-date/V2DateRenderer"; // 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-bom-tree/BomTreeRenderer"; // 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 { apiClient } from "@/lib/api/client";
import { ComponentRendererProps } from "@/types/component";
import { isColumnRequiredByMeta } from "@/lib/registry/DynamicComponentRenderer";
// ✅ ComponentRendererProps 상속으로 필수 props 자동 확보
export interface ModalRepeaterTableComponentProps extends ComponentRendererProps {
@ -363,6 +364,15 @@ export function ModalRepeaterTableComponent({
return [];
}, [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 검증
useEffect(() => {
if (rawSourceColumns.length !== sourceColumns.length) {
@ -856,7 +866,7 @@ export function ModalRepeaterTableComponent({
{/* Repeater 테이블 */}
<RepeaterTable
columns={columns}
columns={enhancedColumns}
data={localValue}
onDataChange={handleChange}
onRowChange={handleRowChange}

View File

@ -133,7 +133,7 @@ export function RepeaterTable({
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) {
const options = response.data.data.map((item: any) => ({
@ -835,8 +835,7 @@ export function RepeaterTable({
</Popover>
) : (
<>
{col.label}
{col.required && <span className="ml-1 text-red-500">*</span>}
{col.label}{col.required && <span className="text-orange-500">*</span>}
</>
)}
</div>

View File

@ -3,6 +3,8 @@
* .
*/
import { getFormatRules } from "@/lib/formatting";
import { AggregationType, PivotFieldFormat } from "../types";
// ==================== 집계 함수 ====================
@ -55,8 +57,13 @@ export function countDistinct(values: any[]): number {
/**
*
*/
export function aggregate(values: any[], type: AggregationType = "sum"): number {
const numericValues = values.map((v) => (typeof v === "number" ? v : parseFloat(v))).filter((v) => !isNaN(v));
export function aggregate(
values: any[],
type: AggregationType = "sum"
): number {
const numericValues = values
.map((v) => (typeof v === "number" ? v : parseFloat(v)))
.filter((v) => !isNaN(v));
switch (type) {
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 "-";
const { type = "number", precision = 0, thousandSeparator = true, prefix = "", suffix = "" } = format || {};
const {
type = "number",
precision = 0,
thousandSeparator = true,
prefix = "",
suffix = "",
} = format || {};
let formatted: string;
const locale = getFormatRules().number.locale;
switch (type) {
case "currency":
formatted = value.toLocaleString("ko-KR", {
formatted = value.toLocaleString(locale, {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
});
break;
case "percent":
formatted = (value * 100).toLocaleString("ko-KR", {
formatted = (value * 100).toLocaleString(locale, {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
});
@ -106,7 +124,7 @@ export function formatNumber(value: number | null | undefined, format?: PivotFie
case "number":
default:
if (thousandSeparator) {
formatted = value.toLocaleString("ko-KR", {
formatted = value.toLocaleString(locale, {
minimumFractionDigits: 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 "-";
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 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] || "합계";
}

View File

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

View File

@ -10,6 +10,7 @@ import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
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 { isColumnRequiredByMeta } from "@/lib/registry/DynamicComponentRenderer";
import {
AlertDialog,
AlertDialogAction,
@ -3113,15 +3114,15 @@ function renderTableCell(
}
// 컬럼 렌더링 함수 (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 isReadOnly = !col.editable;
const effectiveRequired = col.required || isColumnRequiredByMeta(tableName, col.field);
return (
<div className="space-y-1.5">
<Label className="text-xs font-medium">
{col.label}
{col.required && <span className="text-destructive ml-1">*</span>}
{col.label}{effectiveRequired && <span className="text-orange-500">*</span>}
</Label>
{isReadOnly && (

View File

@ -139,6 +139,22 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
const cascadingRole = config?.cascadingRole || componentConfig?.cascadingRole || "child";
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순위: 동적으로 조회된 값 (테이블 타입관리에서 설정)
// 2순위: config에서 전달된 값

View File

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

View File

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

View File

@ -1263,56 +1263,79 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const getLeftColumnUniqueValues = useCallback(
async (columnName: string) => {
const leftTableName = componentConfig.leftPanel?.tableName;
if (!leftTableName || leftData.length === 0) return [];
if (!leftTableName) return [];
// 현재 로드된 데이터에서 고유값 추출
const uniqueValues = new Set<string>();
// 1단계: 카테고리 API 시도 (DB에서 라벨 조회)
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) => {
// 🆕 조인 컬럼 처리 (item_info.standard → item_code_standard 또는 item_id_standard)
let value: any;
if (columnName.includes(".")) {
// 조인 컬럼: getEntityJoinValue와 동일한 로직 적용
const [refTable, fieldName] = columnName.split(".");
const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id");
// 정확한 키로 먼저 시도
const exactKey = `${inferredSourceColumn}_${fieldName}`;
value = item[exactKey];
// 🆕 item_id 패턴 시도
if (value === undefined) {
const idPatternKey = `${refTable.replace("_info", "_id").replace("_mng", "_id")}_${fieldName}`;
value = item[idPatternKey];
}
// 기본 별칭 패턴 시도 (item_code_name 또는 item_id_name)
if (value === undefined && (fieldName === "item_name" || fieldName === "name")) {
const aliasKey = `${inferredSourceColumn}_name`;
value = item[aliasKey];
// item_id_name 패턴도 시도
if (value === undefined) {
const idAliasKey = `${refTable.replace("_info", "_id").replace("_mng", "_id")}_name`;
value = item[idAliasKey];
}
}
} else {
// 일반 컬럼
value = item[columnName];
}
if (value !== null && value !== undefined && value !== "") {
// _name 필드 우선 사용 (category/entity type)
const displayValue = item[`${columnName}_name`] || value;
uniqueValues.add(String(displayValue));
const strValue = String(value);
const nameField = item[`${columnName}_name`];
const label = nameField || strValue;
uniqueValuesMap.set(strValue, label);
}
});
return Array.from(uniqueValues).map((value) => ({
value: value,
label: value,
}));
return Array.from(uniqueValuesMap.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label));
},
[componentConfig.leftPanel?.tableName, leftData],
);
@ -1488,7 +1511,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
for (const col of categoryColumns) {
const columnName = col.columnName || col.column_name;
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) {
const valueMap: Record<string, { label: string; color?: string }> = {};
@ -1550,7 +1573,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
for (const col of categoryColumns) {
const columnName = col.columnName || col.column_name;
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) {
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 meta = columnMeta[columnName];
const inputType = meta?.inputType || "text";
const { apiClient } = await import("@/lib/api/client");
// 카테고리 타입인 경우 전체 정의된 값 조회 (백엔드 API)
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로 전체 고유값 조회 (페이징과 무관하게 모든 값 반환)
// 1단계: 카테고리 API 시도 (columnMeta 무관하게 항상 시도)
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/entity/${tableConfig.selectedTable}/distinct/${columnName}`);
const response = await apiClient.get(`/table-categories/${tableConfig.selectedTable}/${columnName}/values`);
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),
value: item.valueCode,
label: item.valueLabel,
}));
}
} catch {
// DISTINCT API 실패 시 현재 데이터 기반으로 fallback
// 카테고리 API 실패 시 다음 단계로
}
// fallback: 현재 로드된 데이터에서 고유 값 추출
const isLabelType = ["category", "entity", "code"].includes(inputType);
const labelField = isLabelType ? `${columnName}_name` : columnName;
// 2단계: DISTINCT API (백엔드에서 category_values/code_info 라벨 변환 포함)
try {
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 mapping = categoryMappings[columnName];
data.forEach((row) => {
const value = row[columnName];
if (value !== null && value !== undefined && value !== "") {
const label = isLabelType && row[labelField] ? row[labelField] : String(value);
uniqueValuesMap.set(String(value), label);
const strValue = String(value);
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())
.map(([value, label]) => ({
value: value,
label: label,
}))
return Array.from(uniqueValuesMap.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label));
return result;
};
const registration = {
@ -1031,6 +1028,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
tableConfig.columns,
columnLabels,
columnMeta, // columnMeta가 변경되면 재등록 (inputType 정보 필요)
categoryMappings, // 카테고리 매핑 변경 시 재등록 (필터 라벨 변환용)
columnWidths,
tableLabel,
data, // 데이터 자체가 변경되면 재등록 (고유 값 조회용)
@ -1298,7 +1296,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
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)) {
const mapping: Record<string, { label: string; color?: string }> = {};
@ -1381,7 +1380,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// inputType이 category인 경우 카테고리 매핑 로드
if (inputTypeInfo?.inputType === "category" && !mappings[col.columnName]) {
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)) {
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
}, [currentTable?.tableName, filterMode, screenId, currentTableTabId, JSON.stringify(presetFilters)]);
// select 옵션 초기 로드 (한 번만 실행, 이후 유지)
// select 옵션 로드 (getColumnUniqueValues 변경 시 재로드 - columnMeta 갱신 반영)
useEffect(() => {
if (!currentTable?.getColumnUniqueValues || activeFilters.length === 0) {
return;
}
let cancelled = false;
const loadSelectOptions = async () => {
const selectFilters = activeFilters.filter((f) => f.filterType === "select");
@ -406,26 +408,28 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
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) {
// 이미 로드된 옵션이 있으면 스킵 (초기값 유지)
if (newOptions[filter.columnName] && newOptions[filter.columnName].length > 0) {
continue;
}
try {
const options = await currentTable.getColumnUniqueValues(filter.columnName);
newOptions[filter.columnName] = options;
if (options && options.length > 0) {
newOptions[filter.columnName] = options;
}
} catch (error) {
console.error("❌ [TableSearchWidget] select 옵션 로드 실패:", filter.columnName, error);
}
}
setSelectOptions(newOptions);
if (!cancelled && Object.keys(newOptions).length > 0) {
setSelectOptions((prev) => ({ ...prev, ...newOptions }));
}
};
loadSelectOptions();
}, [activeFilters, currentTable?.tableName, currentTable?.getColumnUniqueValues]); // dataCount 제거, tableName으로 변경
return () => { cancelled = true; };
}, [activeFilters, currentTable?.tableName, currentTable?.getColumnUniqueValues]);
// 높이 변화 감지 및 알림 (실제 화면에서만)
useEffect(() => {

View File

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

View File

@ -31,7 +31,7 @@ import {
import { apiClient } from "@/lib/api/client";
import { getCascadingRelations, getCascadingRelationByCode, CascadingRelation } from "@/lib/api/cascadingRelation";
// 카테고리 컬럼 타입 (table_column_category_values 용)
// 카테고리 컬럼 타입 (category_values 용)
interface CategoryColumnOption {
tableName: 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 }[]
>;
onLoadTableColumns: (tableName: string) => void;
// 카테고리 목록 (table_column_category_values에서 가져옴)
// 카테고리 목록 (category_values에서 가져옴)
categoryList?: { tableName: string; columnName: string; displayName?: string }[];
onLoadCategoryList?: () => void;
// 전체 섹션 목록 (다른 섹션 필드 참조용)

View File

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

View File

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

View File

@ -378,7 +378,7 @@ export function BomItemEditorComponent({
if (categoryOptionsMap[categoryRef]) continue;
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) {
const options = response.data.data.map((item: any) => ({
value: item.valueCode || item.value_code,

View File

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

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