Merge branch 'origin/jskim-node' into jskim-node

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
kmh 2026-02-25 15:42:50 +09:00
commit 5e605efa26
39 changed files with 4456 additions and 710 deletions

View File

@ -105,6 +105,7 @@ import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRou
import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
@ -289,6 +290,7 @@ app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여
app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
app.use("/api/departments", departmentRoutes); // 부서 관리
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리

View File

@ -0,0 +1,111 @@
/**
* BOM /
*/
import { Request, Response } from "express";
import { logger } from "../utils/logger";
import * as bomService from "../services/bomService";
// ─── 이력 (History) ─────────────────────────────
export async function getBomHistory(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const tableName = (req.query.tableName as string) || undefined;
const data = await bomService.getBomHistory(bomId, companyCode, tableName);
res.json({ success: true, data });
} catch (error: any) {
logger.error("BOM 이력 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function addBomHistory(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const changedBy = (req as any).user?.userName || (req as any).user?.userId || "";
const { change_type, change_description, revision, version, tableName } = req.body;
if (!change_type) {
res.status(400).json({ success: false, message: "change_type은 필수입니다" });
return;
}
const result = await bomService.addBomHistory(bomId, companyCode, {
change_type,
change_description,
revision,
version,
changed_by: changedBy,
}, tableName);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 이력 등록 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
// ─── 버전 (Version) ─────────────────────────────
export async function getBomVersions(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const tableName = (req.query.tableName as string) || undefined;
const data = await bomService.getBomVersions(bomId, companyCode, tableName);
res.json({ success: true, data });
} catch (error: any) {
logger.error("BOM 버전 목록 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function createBomVersion(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const createdBy = (req as any).user?.userName || (req as any).user?.userId || "";
const { tableName, detailTable } = req.body || {};
const result = await bomService.createBomVersion(bomId, companyCode, createdBy, tableName, detailTable);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 버전 생성 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function loadBomVersion(req: Request, res: Response) {
try {
const { bomId, versionId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const { tableName, detailTable } = req.body || {};
const result = await bomService.loadBomVersion(bomId, versionId, companyCode, tableName, detailTable);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 버전 불러오기 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteBomVersion(req: Request, res: Response) {
try {
const { bomId, versionId } = req.params;
const tableName = (req.query.tableName as string) || undefined;
const deleted = await bomService.deleteBomVersion(bomId, versionId, tableName);
if (!deleted) {
res.status(404).json({ success: false, message: "버전을 찾을 수 없습니다" });
return;
}
res.json({ success: true });
} catch (error: any) {
logger.error("BOM 버전 삭제 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}

View File

@ -115,7 +115,7 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
export async function getEntityOptions(req: AuthenticatedRequest, res: Response) {
try {
const { tableName } = req.params;
const { value = "id", label = "name" } = req.query;
const { value = "id", label = "name", fields } = req.query;
// tableName 유효성 검증
if (!tableName || tableName === "undefined" || tableName === "null") {
@ -167,9 +167,21 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response)
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// autoFill용 추가 컬럼 처리
let extraColumns = "";
if (fields && typeof fields === "string") {
const requestedFields = fields.split(",").map((f) => f.trim()).filter(Boolean);
const validExtra = requestedFields.filter(
(f) => existingColumns.has(f) && f !== valueColumn && f !== effectiveLabelColumn
);
if (validExtra.length > 0) {
extraColumns = ", " + validExtra.map((f) => `"${f}"`).join(", ");
}
}
// 쿼리 실행 (최대 500개)
const query = `
SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label
SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label${extraColumns}
FROM ${tableName}
${whereClause}
ORDER BY ${effectiveLabelColumn} ASC
@ -184,6 +196,7 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response)
labelColumn: effectiveLabelColumn,
companyCode,
rowCount: result.rowCount,
extraFields: extraColumns ? true : false,
});
res.json({

View File

@ -939,14 +939,33 @@ export async function addTableData(
return;
}
// 회사별 UNIQUE 소프트 제약조건 검증
const uniqueViolations = await tableManagementService.validateUniqueConstraints(
tableName,
data,
companyCode || "*"
);
if (uniqueViolations.length > 0) {
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
error: {
code: "UNIQUE_VIOLATION",
details: uniqueViolations,
},
});
return;
}
// 데이터 추가
await tableManagementService.addTableData(tableName, data);
const result = await tableManagementService.addTableData(tableName, data);
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`);
const response: ApiResponse<null> = {
const response: ApiResponse<{ id: string | null }> = {
success: true,
message: "테이블 데이터를 성공적으로 추가했습니다.",
data: { id: result.insertedId },
};
res.status(201).json(response);
@ -1041,6 +1060,26 @@ export async function editTableData(
return;
}
// 회사별 UNIQUE 소프트 제약조건 검증 (수정 시 자기 자신 제외)
const excludeId = originalData?.id ? String(originalData.id) : undefined;
const uniqueViolations = await tableManagementService.validateUniqueConstraints(
tableName,
updatedData,
companyCode,
excludeId
);
if (uniqueViolations.length > 0) {
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
error: {
code: "UNIQUE_VIOLATION",
details: uniqueViolations,
},
});
return;
}
// 데이터 수정
await tableManagementService.editTableData(
tableName,
@ -2653,8 +2692,22 @@ export async function toggleTableIndex(
logger.info(`인덱스 ${action}: ${indexName} (${indexType})`);
if (action === "create") {
let indexColumns = `"${columnName}"`;
// 유니크 인덱스: company_code 컬럼이 있으면 복합 유니크 (회사별 유니크 보장)
if (indexType === "unique") {
const hasCompanyCode = await query(
`SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 AND column_name = 'company_code'`,
[tableName]
);
if (hasCompanyCode.length > 0) {
indexColumns = `"company_code", "${columnName}"`;
logger.info(`멀티테넌시: company_code + ${columnName} 복합 유니크 인덱스 생성`);
}
}
const uniqueClause = indexType === "unique" ? "UNIQUE " : "";
const sql = `CREATE ${uniqueClause}INDEX "${indexName}" ON "public"."${tableName}" ("${columnName}")`;
const sql = `CREATE ${uniqueClause}INDEX IF NOT EXISTS "${indexName}" ON "public"."${tableName}" (${indexColumns})`;
logger.info(`인덱스 생성: ${sql}`);
await query(sql);
} else if (action === "drop") {
@ -2675,15 +2728,45 @@ export async function toggleTableIndex(
} catch (error: any) {
logger.error("인덱스 토글 오류:", error);
// 중복 데이터로 인한 UNIQUE 인덱스 생성 실패 안내
const errorMsg = error.message?.includes("duplicate key")
? "중복 데이터가 있어 UNIQUE 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요."
: "인덱스 설정 중 오류가 발생했습니다.";
const errMsg = error.message || "";
let userMessage = "인덱스 설정 중 오류가 발생했습니다.";
let duplicates: any[] = [];
// 중복 데이터로 인한 UNIQUE 인덱스 생성 실패
if (
errMsg.includes("could not create unique index") ||
errMsg.includes("duplicate key")
) {
const { columnName, tableName } = { ...req.params, ...req.body };
try {
duplicates = await query(
`SELECT company_code, "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY company_code, "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10`
);
} catch {
try {
duplicates = await query(
`SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10`
);
} catch { /* 중복 조회 실패 시 무시 */ }
}
const dupDetails = duplicates.length > 0
? duplicates.map((d: any) => {
const company = d.company_code ? `[${d.company_code}] ` : "";
return `${company}"${d[columnName] ?? 'NULL'}" (${d.cnt}건)`;
}).join(", ")
: "";
userMessage = dupDetails
? `[${columnName}] 컬럼에 같은 회사 내 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 값: ${dupDetails}`
: `[${columnName}] 컬럼에 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요.`;
}
res.status(500).json({
success: false,
message: errorMsg,
error: error instanceof Error ? error.message : "Unknown error",
message: userMessage,
error: errMsg,
duplicates,
});
}
}
@ -2776,3 +2859,89 @@ export async function toggleColumnNullable(
});
}
}
/**
* UNIQUE ( )
* PUT /api/table-management/tables/:tableName/columns/:columnName/unique
*
* DB table_type_columns.is_unique를 .
* .
*/
export async function toggleColumnUnique(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, columnName } = req.params;
const { unique } = req.body;
const companyCode = req.user?.companyCode || "*";
if (!tableName || !columnName || typeof unique !== "boolean") {
res.status(400).json({
success: false,
message: "tableName, columnName, unique(boolean)이 필요합니다.",
});
return;
}
const isUniqueValue = unique ? "Y" : "N";
if (unique) {
// UNIQUE 설정 전 - 해당 회사의 기존 데이터에 중복이 있는지 확인
const hasCompanyCode = await query<{ column_name: string }>(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[tableName]
);
if (hasCompanyCode.length > 0) {
const dupQuery = companyCode === "*"
? `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10`
: `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL AND company_code = $1 GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10`;
const dupParams = companyCode === "*" ? [] : [companyCode];
const dupResult = await query<any>(dupQuery, dupParams);
if (dupResult.length > 0) {
const dupDetails = dupResult
.map((d: any) => `"${d[columnName]}" (${d.cnt}건)`)
.join(", ");
res.status(400).json({
success: false,
message: `현재 회사 데이터에 중복 값이 존재합니다. 중복 데이터를 먼저 정리해주세요. 중복 값: ${dupDetails}`,
});
return;
}
}
}
// table_type_columns에 회사별 is_unique 설정 UPSERT
await query(
`INSERT INTO table_type_columns (table_name, column_name, is_unique, company_code, created_date, updated_date)
VALUES ($1, $2, $3, $4, NOW(), NOW())
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET is_unique = $3, updated_date = NOW()`,
[tableName, columnName, isUniqueValue, companyCode]
);
logger.info(`UNIQUE 소프트 제약조건 변경: ${tableName}.${columnName} → is_unique=${isUniqueValue}`, {
companyCode,
});
res.status(200).json({
success: true,
message: unique
? `${columnName} 컬럼이 UNIQUE로 설정되었습니다.`
: `${columnName} 컬럼의 UNIQUE 제약이 해제되었습니다.`,
});
} catch (error: any) {
logger.error("UNIQUE 토글 오류:", error);
res.status(500).json({
success: false,
message: "UNIQUE 설정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}

View File

@ -0,0 +1,23 @@
/**
* BOM /
*/
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as bomController from "../controllers/bomController";
const router = Router();
router.use(authenticateToken);
// 이력
router.get("/:bomId/history", bomController.getBomHistory);
router.post("/:bomId/history", bomController.addBomHistory);
// 버전
router.get("/:bomId/versions", bomController.getBomVersions);
router.post("/:bomId/versions", bomController.createBomVersion);
router.post("/:bomId/versions/:versionId/load", bomController.loadBomVersion);
router.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion);
export default router;

View File

@ -32,6 +32,7 @@ import {
setTablePrimaryKey, // 🆕 PK 설정
toggleTableIndex, // 🆕 인덱스 토글
toggleColumnNullable, // 🆕 NOT NULL 토글
toggleColumnUnique, // 🆕 UNIQUE 토글
} from "../controllers/tableManagementController";
const router = express.Router();
@ -161,6 +162,12 @@ router.post("/tables/:tableName/indexes", toggleTableIndex);
*/
router.put("/tables/:tableName/columns/:columnName/nullable", toggleColumnNullable);
/**
* UNIQUE
* PUT /api/table-management/tables/:tableName/columns/:columnName/unique
*/
router.put("/tables/:tableName/columns/:columnName/unique", toggleColumnUnique);
/**
*
* GET /api/table-management/tables/:tableName/exists

View File

@ -0,0 +1,181 @@
/**
* BOM
*
*/
import { query, queryOne, transaction } from "../database/db";
import { logger } from "../utils/logger";
// SQL 인젝션 방지: 테이블명은 알파벳, 숫자, 언더스코어만 허용
function safeTableName(name: string, fallback: string): string {
if (!name || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) return fallback;
return name;
}
// ─── 이력 (History) ─────────────────────────────
export async function getBomHistory(bomId: string, companyCode: string, tableName?: string) {
const table = safeTableName(tableName || "", "bom_history");
const sql = companyCode === "*"
? `SELECT * FROM ${table} WHERE bom_id = $1 ORDER BY changed_date DESC`
: `SELECT * FROM ${table} WHERE bom_id = $1 AND company_code = $2 ORDER BY changed_date DESC`;
const params = companyCode === "*" ? [bomId] : [bomId, companyCode];
return query(sql, params);
}
export async function addBomHistory(
bomId: string,
companyCode: string,
data: {
revision?: string;
version?: string;
change_type: string;
change_description?: string;
changed_by?: string;
},
tableName?: string,
) {
const table = safeTableName(tableName || "", "bom_history");
const sql = `
INSERT INTO ${table} (bom_id, revision, version, change_type, change_description, changed_by, company_code)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
`;
return queryOne(sql, [
bomId,
data.revision || null,
data.version || null,
data.change_type,
data.change_description || null,
data.changed_by || null,
companyCode,
]);
}
// ─── 버전 (Version) ─────────────────────────────
export async function getBomVersions(bomId: string, companyCode: string, tableName?: string) {
const table = safeTableName(tableName || "", "bom_version");
const sql = companyCode === "*"
? `SELECT * FROM ${table} WHERE bom_id = $1 ORDER BY created_date DESC`
: `SELECT * FROM ${table} WHERE bom_id = $1 AND company_code = $2 ORDER BY created_date DESC`;
const params = companyCode === "*" ? [bomId] : [bomId, companyCode];
return query(sql, params);
}
export async function createBomVersion(
bomId: string, companyCode: string, createdBy: string,
versionTableName?: string, detailTableName?: string,
) {
const vTable = safeTableName(versionTableName || "", "bom_version");
const dTable = safeTableName(detailTableName || "", "bom_detail");
return transaction(async (client) => {
const bomRow = await client.query(`SELECT * FROM bom WHERE id = $1`, [bomId]);
if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다");
const bomData = bomRow.rows[0];
const detailRows = await client.query(
`SELECT * FROM ${dTable} WHERE bom_id = $1 ORDER BY parent_detail_id NULLS FIRST, id`,
[bomId],
);
const lastVersion = await client.query(
`SELECT version_name FROM ${vTable} WHERE bom_id = $1 ORDER BY created_date DESC LIMIT 1`,
[bomId],
);
let nextVersionNum = 1;
if (lastVersion.rows.length > 0) {
const parsed = parseFloat(lastVersion.rows[0].version_name);
if (!isNaN(parsed)) nextVersionNum = Math.floor(parsed) + 1;
}
const versionName = `${nextVersionNum}.0`;
const snapshot = {
bom: bomData,
details: detailRows.rows,
detailTable: dTable,
created_at: new Date().toISOString(),
};
const insertSql = `
INSERT INTO ${vTable} (bom_id, version_name, revision, status, snapshot_data, created_by, company_code)
VALUES ($1, $2, $3, 'developing', $4, $5, $6)
RETURNING *
`;
const result = await client.query(insertSql, [
bomId,
versionName,
bomData.revision ? parseInt(bomData.revision, 10) || 0 : 0,
JSON.stringify(snapshot),
createdBy,
companyCode,
]);
logger.info("BOM 버전 생성", { bomId, versionName, companyCode, vTable, dTable });
return result.rows[0];
});
}
export async function loadBomVersion(
bomId: string, versionId: string, companyCode: string,
versionTableName?: string, detailTableName?: string,
) {
const vTable = safeTableName(versionTableName || "", "bom_version");
const dTable = safeTableName(detailTableName || "", "bom_detail");
return transaction(async (client) => {
const verRow = await client.query(
`SELECT * FROM ${vTable} WHERE id = $1 AND bom_id = $2`,
[versionId, bomId],
);
if (verRow.rows.length === 0) throw new Error("버전을 찾을 수 없습니다");
const snapshot = verRow.rows[0].snapshot_data;
if (!snapshot || !snapshot.bom) throw new Error("스냅샷 데이터가 없습니다");
// 스냅샷에 기록된 detailTable을 우선 사용, 없으면 파라미터 사용
const snapshotDetailTable = safeTableName(snapshot.detailTable || "", dTable);
await client.query(`DELETE FROM ${snapshotDetailTable} WHERE bom_id = $1`, [bomId]);
const b = snapshot.bom;
await client.query(
`UPDATE bom SET base_qty = $1, unit = $2, revision = $3, remark = $4 WHERE id = $5`,
[b.base_qty || null, b.unit || null, b.revision || null, b.remark || null, bomId],
);
const oldToNew: Record<string, string> = {};
for (const d of snapshot.details || []) {
const insertResult = await client.query(
`INSERT INTO ${snapshotDetailTable} (bom_id, parent_detail_id, child_item_id, quantity, unit, process_type, loss_rate, remark, level, base_qty, revision, company_code)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id`,
[
bomId,
d.parent_detail_id ? (oldToNew[d.parent_detail_id] || null) : null,
d.child_item_id,
d.quantity,
d.unit,
d.process_type,
d.loss_rate,
d.remark,
d.level,
d.base_qty,
d.revision,
companyCode,
],
);
oldToNew[d.id] = insertResult.rows[0].id;
}
logger.info("BOM 버전 불러오기 완료", { bomId, versionId, vTable, snapshotDetailTable });
return { restored: true, versionName: verRow.rows[0].version_name };
});
}
export async function deleteBomVersion(bomId: string, versionId: string, tableName?: string) {
const table = safeTableName(tableName || "", "bom_version");
const sql = `DELETE FROM ${table} WHERE id = $1 AND bom_id = $2 RETURNING id`;
const result = await query(sql, [versionId, bomId]);
return result.length > 0;
}

View File

@ -14,6 +14,35 @@ interface NumberingRulePart {
autoConfig?: any;
manualConfig?: any;
generatedValue?: string;
separatorAfter?: string;
}
/**
* autoConfig.separatorAfter를
*/
function extractSeparatorAfterFromParts(parts: any[]): any[] {
return parts.map((part) => {
if (part.autoConfig?.separatorAfter !== undefined) {
part.separatorAfter = part.autoConfig.separatorAfter;
}
return part;
});
}
/**
*
* separatorAfter는
*/
function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globalSeparator: string): string {
let result = "";
partValues.forEach((val, idx) => {
result += val;
if (idx < partValues.length - 1) {
const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator;
result += sep;
}
});
return result;
}
interface NumberingRuleConfig {
@ -141,7 +170,7 @@ class NumberingRuleService {
}
const partsResult = await pool.query(partsQuery, partsParams);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
}
logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}`, {
@ -274,7 +303,7 @@ class NumberingRuleService {
}
const partsResult = await pool.query(partsQuery, partsParams);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
}
return result.rows;
@ -381,7 +410,7 @@ class NumberingRuleService {
}
const partsResult = await pool.query(partsQuery, partsParams);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
logger.info("✅ 규칙 파트 조회 성공", {
ruleId: rule.ruleId,
@ -517,7 +546,7 @@ class NumberingRuleService {
companyCode === "*" ? rule.companyCode : companyCode,
]);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
}
logger.info(`화면용 채번 규칙 조회 완료: ${result.rows.length}`, {
@ -633,7 +662,7 @@ class NumberingRuleService {
}
const partsResult = await pool.query(partsQuery, partsParams);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
return rule;
}
@ -708,17 +737,25 @@ class NumberingRuleService {
manual_config AS "manualConfig"
`;
// auto_config에 separatorAfter 포함
const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" };
const partResult = await client.query(insertPartQuery, [
config.ruleId,
part.order,
part.partType,
part.generationMethod,
JSON.stringify(part.autoConfig || {}),
JSON.stringify(autoConfigWithSep),
JSON.stringify(part.manualConfig || {}),
companyCode,
]);
parts.push(partResult.rows[0]);
const savedPart = partResult.rows[0];
// autoConfig에서 separatorAfter를 추출하여 파트 레벨로 이동
if (savedPart.autoConfig?.separatorAfter !== undefined) {
savedPart.separatorAfter = savedPart.autoConfig.separatorAfter;
}
parts.push(savedPart);
}
await client.query("COMMIT");
@ -820,17 +857,23 @@ class NumberingRuleService {
manual_config AS "manualConfig"
`;
const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" };
const partResult = await client.query(insertPartQuery, [
ruleId,
part.order,
part.partType,
part.generationMethod,
JSON.stringify(part.autoConfig || {}),
JSON.stringify(autoConfigWithSep),
JSON.stringify(part.manualConfig || {}),
companyCode,
]);
parts.push(partResult.rows[0]);
const savedPart = partResult.rows[0];
if (savedPart.autoConfig?.separatorAfter !== undefined) {
savedPart.separatorAfter = savedPart.autoConfig.separatorAfter;
}
parts.push(savedPart);
}
}
@ -1053,7 +1096,8 @@ class NumberingRuleService {
}
}));
const previewCode = parts.join(rule.separator || "");
const sortedRuleParts = rule.parts.sort((a: any, b: any) => a.order - b.order);
const previewCode = joinPartsWithSeparators(parts, sortedRuleParts, rule.separator || "");
logger.info("코드 미리보기 생성", {
ruleId,
previewCode,
@ -1164,8 +1208,8 @@ class NumberingRuleService {
}
}));
const separator = rule.separator || "";
const previewTemplate = previewParts.join(separator);
const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order);
const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || "");
// 사용자 입력 코드에서 수동 입력 부분 추출
// 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출
@ -1382,7 +1426,8 @@ class NumberingRuleService {
}
}));
const allocatedCode = parts.join(rule.separator || "");
const sortedPartsForAlloc = rule.parts.sort((a: any, b: any) => a.order - b.order);
const allocatedCode = joinPartsWithSeparators(parts, sortedPartsForAlloc, rule.separator || "");
// 순번이 있는 경우에만 증가
const hasSequence = rule.parts.some(
@ -1541,7 +1586,7 @@ class NumberingRuleService {
rule.ruleId,
companyCode === "*" ? rule.companyCode : companyCode,
]);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
}
logger.info("[테스트] 채번 규칙 목록 조회 완료", {
@ -1634,7 +1679,7 @@ class NumberingRuleService {
rule.ruleId,
companyCode,
]);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", {
ruleId: rule.ruleId,
@ -1754,12 +1799,14 @@ class NumberingRuleService {
auto_config, manual_config, company_code, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
`;
const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" };
await client.query(partInsertQuery, [
config.ruleId,
part.order,
part.partType,
part.generationMethod,
JSON.stringify(part.autoConfig || {}),
JSON.stringify(autoConfigWithSep),
JSON.stringify(part.manualConfig || {}),
companyCode,
]);
@ -1914,7 +1961,7 @@ class NumberingRuleService {
rule.ruleId,
companyCode,
]);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
logger.info("카테고리 조건 매칭 채번 규칙 찾음", {
ruleId: rule.ruleId,
@ -1973,7 +2020,7 @@ class NumberingRuleService {
rule.ruleId,
companyCode,
]);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
logger.info("기본 채번 규칙 찾음 (카테고리 조건 없음)", {
ruleId: rule.ruleId,
@ -2056,7 +2103,7 @@ class NumberingRuleService {
rule.ruleId,
companyCode,
]);
rule.parts = partsResult.rows;
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
}
return result.rows;

View File

@ -204,6 +204,10 @@ export class TableManagementService {
THEN CASE WHEN COALESCE(ttc.is_nullable, cl.is_nullable) = 'N' THEN 'NO' ELSE 'YES' END
ELSE c.is_nullable
END as "isNullable",
CASE
WHEN COALESCE(ttc.is_unique, cl.is_unique) = 'Y' THEN 'YES'
ELSE 'NO'
END as "isUnique",
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
c.column_default as "defaultValue",
c.character_maximum_length as "maxLength",
@ -250,6 +254,10 @@ export class TableManagementService {
THEN CASE WHEN cl.is_nullable = 'N' THEN 'NO' ELSE 'YES' END
ELSE c.is_nullable
END as "isNullable",
CASE
WHEN cl.is_unique = 'Y' THEN 'YES'
ELSE 'NO'
END as "isUnique",
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
c.column_default as "defaultValue",
c.character_maximum_length as "maxLength",
@ -1607,7 +1615,8 @@ export class TableManagementService {
tableName,
columnName,
actualValue,
paramIndex
paramIndex,
operator
);
case "entity":
@ -1620,7 +1629,14 @@ export class TableManagementService {
);
default:
// 기본 문자열 검색 (actualValue 사용)
// operator에 따라 정확 일치 또는 부분 일치 검색
if (operator === "equals") {
return {
whereClause: `${columnName}::text = $${paramIndex}`,
values: [String(actualValue)],
paramCount: 1,
};
}
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${actualValue}%`],
@ -1634,10 +1650,19 @@ export class TableManagementService {
);
// 오류 시 기본 검색으로 폴백
let fallbackValue = value;
let fallbackOperator = "contains";
if (typeof value === "object" && value !== null && "value" in value) {
fallbackValue = value.value;
fallbackOperator = value.operator || "contains";
}
if (fallbackOperator === "equals") {
return {
whereClause: `${columnName}::text = $${paramIndex}`,
values: [String(fallbackValue)],
paramCount: 1,
};
}
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${fallbackValue}%`],
@ -1784,7 +1809,8 @@ export class TableManagementService {
tableName: string,
columnName: string,
value: any,
paramIndex: number
paramIndex: number,
operator: string = "contains"
): Promise<{
whereClause: string;
values: any[];
@ -1794,7 +1820,14 @@ export class TableManagementService {
const codeTypeInfo = await this.getCodeTypeInfo(tableName, columnName);
if (!codeTypeInfo.isCodeType || !codeTypeInfo.codeCategory) {
// 코드 타입이 아니면 기본 검색
// 코드 타입이 아니면 operator에 따라 검색
if (operator === "equals") {
return {
whereClause: `${columnName}::text = $${paramIndex}`,
values: [String(value)],
paramCount: 1,
};
}
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
@ -1802,6 +1835,15 @@ export class TableManagementService {
};
}
// select 필터(equals)인 경우 정확한 코드값 매칭만 수행
if (operator === "equals") {
return {
whereClause: `${columnName}::text = $${paramIndex}`,
values: [String(value)],
paramCount: 1,
};
}
if (typeof value === "string" && value.trim() !== "") {
// 코드값 또는 코드명으로 검색
return {
@ -2500,6 +2542,93 @@ export class TableManagementService {
}
}
/**
* UNIQUE
* table_type_columns.is_unique = 'Y' .
* @param excludeId
*/
async validateUniqueConstraints(
tableName: string,
data: Record<string, any>,
companyCode: string,
excludeId?: string
): Promise<string[]> {
try {
// 회사별 설정 우선, 없으면 공통(*) 설정 사용
let uniqueColumns = await query<{ column_name: string; column_label: string }>(
`SELECT
ttc.column_name,
COALESCE(ttc.column_label, ttc.column_name) as column_label
FROM table_type_columns ttc
WHERE ttc.table_name = $1
AND ttc.is_unique = 'Y'
AND ttc.company_code = $2`,
[tableName, companyCode]
);
// 회사별 설정이 없으면 공통 설정 확인
if (uniqueColumns.length === 0 && companyCode !== "*") {
const globalUnique = await query<{ column_name: string; column_label: string }>(
`SELECT
ttc.column_name,
COALESCE(ttc.column_label, ttc.column_name) as column_label
FROM table_type_columns ttc
WHERE ttc.table_name = $1
AND ttc.is_unique = 'Y'
AND ttc.company_code = '*'
AND NOT EXISTS (
SELECT 1 FROM table_type_columns ttc2
WHERE ttc2.table_name = ttc.table_name
AND ttc2.column_name = ttc.column_name
AND ttc2.company_code = $2
)`,
[tableName, companyCode]
);
uniqueColumns = globalUnique;
}
if (uniqueColumns.length === 0) return [];
const violations: string[] = [];
for (const col of uniqueColumns) {
const value = data[col.column_name];
if (value === null || value === undefined || value === "") continue;
// 해당 회사 내에서 같은 값이 이미 존재하는지 확인
const hasCompanyCode = await query(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[tableName]
);
let dupQuery: string;
let dupParams: any[];
if (hasCompanyCode.length > 0 && companyCode !== "*") {
dupQuery = excludeId
? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 AND id != $3 LIMIT 1`
: `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 LIMIT 1`;
dupParams = excludeId ? [value, companyCode, excludeId] : [value, companyCode];
} else {
dupQuery = excludeId
? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND id != $2 LIMIT 1`
: `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 LIMIT 1`;
dupParams = excludeId ? [value, excludeId] : [value];
}
const dupResult = await query(dupQuery, dupParams);
if (dupResult.length > 0) {
violations.push(`${col.column_label} (${value})`);
}
}
return violations;
} catch (error) {
logger.error(`UNIQUE 검증 오류: ${tableName}`, error);
return [];
}
}
/**
*
* @returns ()
@ -2507,7 +2636,7 @@ export class TableManagementService {
async addTableData(
tableName: string,
data: Record<string, any>
): Promise<{ skippedColumns: string[]; savedColumns: string[] }> {
): Promise<{ skippedColumns: string[]; savedColumns: string[]; insertedId: string | null }> {
try {
logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`);
logger.info(`추가할 데이터:`, data);
@ -2620,19 +2749,21 @@ export class TableManagementService {
const insertQuery = `
INSERT INTO "${tableName}" (${columnNames})
VALUES (${placeholders})
RETURNING id
`;
logger.info(`실행할 쿼리: ${insertQuery}`);
logger.info(`쿼리 파라미터:`, values);
await query(insertQuery, values);
const insertResult = await query(insertQuery, values) as any[];
const insertedId = insertResult?.[0]?.id ?? null;
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${insertedId}`);
// 무시된 컬럼과 저장된 컬럼 정보 반환
return {
skippedColumns,
savedColumns: existingColumns,
insertedId,
};
} catch (error) {
logger.error(`테이블 데이터 추가 오류: ${tableName}`, error);

View File

@ -63,6 +63,7 @@ interface ColumnTypeInfo {
detailSettings: string;
description: string;
isNullable: string;
isUnique: string;
defaultValue?: string;
maxLength?: number;
numericPrecision?: number;
@ -382,10 +383,11 @@ export default function TableManagementPage() {
return {
...col,
inputType: col.inputType || "text", // 기본값: text
numberingRuleId, // 🆕 채번규칙 ID
categoryMenus: col.categoryMenus || [], // 카테고리 메뉴 매핑 정보
hierarchyRole, // 계층구조 역할
inputType: col.inputType || "text",
isUnique: col.isUnique || "NO",
numberingRuleId,
categoryMenus: col.categoryMenus || [],
hierarchyRole,
};
});
@ -1091,9 +1093,9 @@ export default function TableManagementPage() {
}
};
// 인덱스 토글 핸들러
// 인덱스 토글 핸들러 (일반 인덱스만 DB 레벨 - 유니크는 앱 레벨 소프트 제약조건으로 분리됨)
const handleIndexToggle = useCallback(
async (columnName: string, indexType: "index" | "unique", checked: boolean) => {
async (columnName: string, indexType: "index", checked: boolean) => {
if (!selectedTable) return;
const action = checked ? "create" : "drop";
try {
@ -1122,14 +1124,41 @@ export default function TableManagementPage() {
const hasIndex = constraints.indexes.some(
(idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName,
);
const hasUnique = constraints.indexes.some(
(idx) => idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName,
);
return { isPk, hasIndex, hasUnique };
return { isPk, hasIndex };
},
[constraints],
);
// UNIQUE 토글 핸들러 (앱 레벨 소프트 제약조건 - NOT NULL과 동일 패턴)
const handleUniqueToggle = useCallback(
async (columnName: string, currentIsUnique: string) => {
if (!selectedTable) return;
const isCurrentlyUnique = currentIsUnique === "YES";
const newUnique = !isCurrentlyUnique;
try {
const response = await apiClient.put(
`/table-management/tables/${selectedTable}/columns/${columnName}/unique`,
{ unique: newUnique },
);
if (response.data.success) {
toast.success(response.data.message);
setColumns((prev) =>
prev.map((col) =>
col.columnName === columnName
? { ...col, isUnique: newUnique ? "YES" : "NO" }
: col,
),
);
} else {
toast.error(response.data.message || "UNIQUE 설정 실패");
}
} catch (error: any) {
toast.error(error?.response?.data?.message || "UNIQUE 설정 중 오류가 발생했습니다.");
}
},
[selectedTable],
);
// NOT NULL 토글 핸들러
const handleNullableToggle = useCallback(
async (columnName: string, currentIsNullable: string) => {
@ -2029,12 +2058,12 @@ export default function TableManagementPage() {
aria-label={`${column.columnName} 인덱스 설정`}
/>
</div>
{/* UQ 체크박스 */}
{/* UQ 체크박스 (앱 레벨 소프트 제약조건) */}
<div className="flex items-center justify-center pt-1">
<Checkbox
checked={idxState.hasUnique}
onCheckedChange={(checked) =>
handleIndexToggle(column.columnName, "unique", checked as boolean)
checked={column.isUnique === "YES"}
onCheckedChange={() =>
handleUniqueToggle(column.columnName, column.isUnique)
}
aria-label={`${column.columnName} 유니크 설정`}
/>

View File

@ -3,6 +3,7 @@
import { useEffect, ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { AuthLogger } from "@/lib/authLogger";
import { Loader2 } from "lucide-react";
interface AuthGuardProps {
@ -41,11 +42,13 @@ export function AuthGuard({
}
if (requireAuth && !isLoggedIn) {
AuthLogger.log("AUTH_GUARD_BLOCK", `인증 필요하지만 비로그인 상태 → ${redirectTo} 리다이렉트`);
router.push(redirectTo);
return;
}
if (requireAdmin && !isAdmin) {
AuthLogger.log("AUTH_GUARD_BLOCK", `관리자 권한 필요하지만 일반 사용자 → ${redirectTo} 리다이렉트`);
router.push(redirectTo);
return;
}

View File

@ -942,8 +942,12 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
continue;
}
// 채번 적용: 각 행마다 채번 API 호출 (신규 등록 시에만, 자동 감지된 채번 규칙 사용)
// 채번 적용: 엑셀에 값이 없거나 빈 값이면 채번 규칙으로 자동 생성, 값이 있으면 그대로 사용
if (hasNumbering && numberingInfo && uploadMode === "insert" && !shouldUpdate) {
const existingValue = dataToSave[numberingInfo.columnName];
const hasExcelValue = existingValue !== undefined && existingValue !== null && String(existingValue).trim() !== "";
if (!hasExcelValue) {
try {
const { apiClient } = await import("@/lib/api/client");
const numberingResponse = await apiClient.post(`/numbering-rules/${numberingInfo.numberingRuleId}/allocate`);
@ -955,6 +959,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
console.error("채번 오류:", numError);
}
}
}
if (shouldUpdate && existingRow) {
// 덮어쓰기: 기존 데이터 업데이트

View File

@ -25,7 +25,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
isPreview = false,
}) => {
return (
<Card className="border-border bg-card">
<Card className="border-border bg-card flex-1">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<Badge variant="outline" className="text-xs sm:text-sm">

View File

@ -62,9 +62,9 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
const [editingLeftTitle, setEditingLeftTitle] = useState(false);
const [editingRightTitle, setEditingRightTitle] = useState(false);
// 구분자 관련 상태
const [separatorType, setSeparatorType] = useState<SeparatorType>("-");
const [customSeparator, setCustomSeparator] = useState("");
// 구분자 관련 상태 (개별 파트 사이 구분자)
const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
// 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회
interface CategoryOption {
@ -192,48 +192,68 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
}
}, [currentRule, onChange]);
// currentRule이 변경될 때 구분자 상태 동기화
// currentRule이 변경될 때 파트별 구분자 상태 동기화
useEffect(() => {
if (currentRule) {
const sep = currentRule.separator ?? "-";
// 빈 문자열이면 "none"
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 === "") {
setSeparatorType("none");
setCustomSeparator("");
return;
}
// 미리 정의된 구분자인지 확인 (none, custom 제외)
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) {
setSeparatorType(predefinedOption.value);
setCustomSeparator("");
newSepTypes[part.order] = predefinedOption.value;
newCustomSeps[part.order] = "";
} else {
// 직접 입력된 구분자
setSeparatorType("custom");
setCustomSeparator(sep);
newSepTypes[part.order] = "custom";
newCustomSeps[part.order] = sep;
}
}
}, [currentRule?.ruleId]); // ruleId가 변경될 때만 실행 (규칙 선택/생성 시)
});
// 구분자 변경 핸들러
const handleSeparatorChange = useCallback((type: SeparatorType) => {
setSeparatorType(type);
setSeparatorTypes(newSepTypes);
setCustomSeparators(newCustomSeps);
}
}, [currentRule?.ruleId]);
// 개별 파트 구분자 변경 핸들러
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 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 +270,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
partType: "text",
generationMethod: "auto",
autoConfig: { textValue: "CODE" },
separatorAfter: "-",
};
setCurrentRule((prev) => {
@ -257,6 +278,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]);
@ -573,42 +598,6 @@ 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>
@ -625,15 +614,48 @@ 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) => (
<React.Fragment key={`part-${part.order}-${index}`}>
<div className="flex w-[200px] flex-col">
<NumberingRuleCard
key={`part-${part.order}-${index}`}
part={part}
onUpdate={(updates) => handleUpdatePart(part.order, updates)}
onDelete={() => handleDeletePart(part.order)}
isPreview={isPreview}
/>
{/* 카드 하단에 구분자 설정 (마지막 파트 제외) */}
{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,9 +17,9 @@ export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({
return "규칙을 추가해주세요";
}
const parts = config.parts
.sort((a, b) => a.order - b.order)
.map((part) => {
const sortedParts = config.parts.sort((a, b) => a.order - b.order);
const partValues = sortedParts.map((part) => {
if (part.generationMethod === "manual") {
return part.manualConfig?.value || "XXX";
}
@ -27,27 +27,19 @@ export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({
const autoConfig = part.autoConfig || {};
switch (part.partType) {
// 1. 순번 (자동 증가)
case "sequence": {
const length = autoConfig.sequenceLength || 3;
const startFrom = autoConfig.startFrom || 1;
return String(startFrom).padStart(length, "0");
}
// 2. 숫자 (고정 자릿수)
case "number": {
const length = autoConfig.numberLength || 4;
const value = autoConfig.numberValue || 0;
return String(value).padStart(length, "0");
}
// 3. 날짜
case "date": {
const format = autoConfig.dateFormat || "YYYYMMDD";
// 컬럼 기준 생성인 경우 placeholder 표시
if (autoConfig.useColumnValue && autoConfig.sourceColumnName) {
// 형식에 맞는 placeholder 반환
switch (format) {
case "YYYY": return "[YYYY]";
case "YY": return "[YY]";
@ -58,13 +50,10 @@ export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({
default: return "[DATE]";
}
}
// 현재 날짜 기준 생성
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
switch (format) {
case "YYYY": return String(year);
case "YY": return String(year).slice(-2);
@ -75,17 +64,24 @@ export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({
default: return `${year}${month}${day}`;
}
}
// 4. 문자
case "text":
return autoConfig.textValue || "TEXT";
default:
return "XXX";
}
});
return parts.join(config.separator || "");
// 파트별 개별 구분자로 결합
const globalSep = config.separator ?? "-";
let result = "";
partValues.forEach((val, idx) => {
result += val;
if (idx < partValues.length - 1) {
const sep = sortedParts[idx].separatorAfter ?? globalSep;
result += sep;
}
});
return result;
}, [config]);
if (compact) {

View File

@ -3968,10 +3968,10 @@ export default function ScreenDesigner({
label: column.columnLabel || column.columnName,
tableName: table.tableName,
columnName: column.columnName,
required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님
readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용
parentId: formContainerId, // 폼 컨테이너의 자식으로 설정
componentType: v2Mapping.componentType, // v2-input, v2-select 등
required: isEntityJoinColumn ? false : column.required,
readonly: false,
parentId: formContainerId,
componentType: v2Mapping.componentType,
position: { x: relativeX, y: relativeY, z: 1 } as Position,
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
@ -3995,12 +3995,11 @@ export default function ScreenDesigner({
},
componentConfig: {
type: v2Mapping.componentType, // v2-input, v2-select 등
...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정
...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화
...v2Mapping.componentConfig,
},
};
} else {
return; // 폼 컨테이너를 찾을 수 없으면 드롭 취소
return;
}
} else {
// 일반 캔버스에 드롭한 경우 - 🆕 V2 컴포넌트 시스템 사용
@ -4036,9 +4035,9 @@ export default function ScreenDesigner({
label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명
tableName: table.tableName,
columnName: column.columnName,
required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님
readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용
componentType: v2Mapping.componentType, // v2-input, v2-select 등
required: isEntityJoinColumn ? false : column.required,
readonly: false,
componentType: v2Mapping.componentType,
position: { x, y, z: 1 } as Position,
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
@ -4062,8 +4061,7 @@ export default function ScreenDesigner({
},
componentConfig: {
type: v2Mapping.componentType, // v2-input, v2-select 등
...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정
...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화
...v2Mapping.componentConfig,
},
};
}

View File

@ -217,6 +217,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
"v2-biz": require("@/components/v2/config-panels/V2BizConfigPanel").V2BizConfigPanel,
"v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel").V2HierarchyConfigPanel,
"v2-bom-item-editor": require("@/components/v2/config-panels/V2BomItemEditorConfigPanel").V2BomItemEditorConfigPanel,
"v2-bom-tree": require("@/components/v2/config-panels/V2BomTreeConfigPanel").V2BomTreeConfigPanel,
};
const V2ConfigPanel = v2ConfigPanels[componentId];
@ -240,7 +241,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
if (componentId === "v2-list") {
extraProps.currentTableName = currentTableName;
}
if (componentId === "v2-bom-item-editor") {
if (componentId === "v2-bom-item-editor" || componentId === "v2-bom-tree") {
extraProps.currentTableName = currentTableName;
extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
}

View File

@ -622,6 +622,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
config: configProp,
value,
onChange,
onFormDataChange,
tableName,
columnName,
isDesignMode, // 🔧 디자인 모드 (클릭 방지)
@ -630,6 +631,9 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
// config가 없으면 기본값 사용
const config = configProp || { mode: "dropdown" as const, source: "static" as const, options: [] };
// 엔티티 자동 채움: 같은 폼의 다른 컴포넌트 중 참조 테이블 컬럼을 자동 감지
const allComponents = (props as any).allComponents as any[] | undefined;
const [options, setOptions] = useState<SelectOption[]>(config.options || []);
const [loading, setLoading] = useState(false);
const [optionsLoaded, setOptionsLoaded] = useState(false);
@ -742,10 +746,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
const valueCol = entityValueColumn || "id";
const labelCol = entityLabelColumn || "name";
const response = await apiClient.get(`/entity/${entityTable}/options`, {
params: {
value: valueCol,
label: labelCol,
},
params: { value: valueCol, label: labelCol },
});
const data = response.data;
if (data.success && data.data) {
@ -819,6 +820,70 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
loadOptions();
}, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue]);
// 같은 폼에서 참조 테이블(entityTable) 컬럼을 사용하는 다른 컴포넌트 자동 감지
const autoFillTargets = useMemo(() => {
if (source !== "entity" || !entityTable || !allComponents) return [];
const targets: Array<{ sourceField: string; targetColumnName: string }> = [];
for (const comp of allComponents) {
if (comp.id === id) continue;
// overrides 구조 지원 (DB에서 로드 시 overrides 안에 데이터가 있음)
const ov = (comp as any).overrides || {};
const compColumnName = comp.columnName || ov.columnName || comp.componentConfig?.columnName || "";
// 방법1: entityJoinTable 속성이 있는 경우
const joinTable = comp.entityJoinTable || ov.entityJoinTable || comp.componentConfig?.entityJoinTable;
const joinColumn = comp.entityJoinColumn || ov.entityJoinColumn || comp.componentConfig?.entityJoinColumn;
if (joinTable === entityTable && joinColumn) {
targets.push({ sourceField: joinColumn, targetColumnName: compColumnName });
continue;
}
// 방법2: columnName이 "테이블명.컬럼명" 형식인 경우 (예: item_info.unit)
if (compColumnName.includes(".")) {
const [prefix, actualColumn] = compColumnName.split(".");
if (prefix === entityTable && actualColumn) {
targets.push({ sourceField: actualColumn, targetColumnName: compColumnName });
}
}
}
return targets;
}, [source, entityTable, allComponents, id]);
// 엔티티 autoFill 적용 래퍼
const handleChangeWithAutoFill = useCallback((newValue: string | string[]) => {
onChange?.(newValue);
if (autoFillTargets.length === 0 || !onFormDataChange || !entityTable) return;
const selectedKey = typeof newValue === "string" ? newValue : newValue[0];
if (!selectedKey) return;
const valueCol = entityValueColumn || "id";
apiClient.get(`/table-management/tables/${entityTable}/data-with-joins`, {
params: {
page: 1,
size: 1,
search: JSON.stringify({ [valueCol]: selectedKey }),
autoFilter: JSON.stringify({ enabled: true, filterColumn: "company_code", userField: "companyCode" }),
},
}).then((res) => {
const responseData = res.data?.data;
const rows = responseData?.data || responseData?.rows || [];
if (rows.length > 0) {
const fullData = rows[0];
for (const target of autoFillTargets) {
const sourceValue = fullData[target.sourceField];
if (sourceValue !== undefined) {
onFormDataChange(target.targetColumnName, sourceValue);
}
}
}
}).catch((err) => console.error("autoFill 조회 실패:", err));
}, [onChange, autoFillTargets, onFormDataChange, entityTable, entityValueColumn]);
// 모드별 컴포넌트 렌더링
const renderSelect = () => {
if (loading) {
@ -876,12 +941,12 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
switch (config.mode) {
case "dropdown":
case "combobox": // 🔧 콤보박스는 검색 가능한 드롭다운
case "combobox":
return (
<DropdownSelect
options={options}
value={value}
onChange={onChange}
onChange={handleChangeWithAutoFill}
placeholder="선택"
searchable={config.mode === "combobox" ? true : config.searchable}
multiple={config.multiple}
@ -897,18 +962,18 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
<RadioSelect
options={options}
value={typeof value === "string" ? value : value?.[0]}
onChange={(v) => onChange?.(v)}
onChange={(v) => handleChangeWithAutoFill(v)}
disabled={isDisabled}
/>
);
case "check":
case "checkbox": // 🔧 기존 저장된 값 호환
case "checkbox":
return (
<CheckSelect
options={options}
value={Array.isArray(value) ? value : value ? [value] : []}
onChange={onChange}
onChange={handleChangeWithAutoFill}
maxSelect={config.maxSelect}
disabled={isDisabled}
/>
@ -919,7 +984,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
<TagSelect
options={options}
value={Array.isArray(value) ? value : value ? [value] : []}
onChange={onChange}
onChange={handleChangeWithAutoFill}
maxSelect={config.maxSelect}
disabled={isDisabled}
/>
@ -930,7 +995,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
<TagboxSelect
options={options}
value={Array.isArray(value) ? value : value ? [value] : []}
onChange={onChange}
onChange={handleChangeWithAutoFill}
placeholder={config.placeholder || "선택하세요"}
maxSelect={config.maxSelect}
disabled={isDisabled}
@ -943,7 +1008,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
<ToggleSelect
options={options}
value={typeof value === "string" ? value : value?.[0]}
onChange={(v) => onChange?.(v)}
onChange={(v) => handleChangeWithAutoFill(v)}
disabled={isDisabled}
/>
);
@ -953,7 +1018,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
<SwapSelect
options={options}
value={Array.isArray(value) ? value : value ? [value] : []}
onChange={onChange}
onChange={handleChangeWithAutoFill}
maxSelect={config.maxSelect}
disabled={isDisabled}
/>
@ -964,7 +1029,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
<DropdownSelect
options={options}
value={value}
onChange={onChange}
onChange={handleChangeWithAutoFill}
disabled={isDisabled}
style={heightStyle}
/>

File diff suppressed because it is too large Load Diff

View File

@ -302,6 +302,15 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
. .
</p>
)}
{/* 자동 채움 안내 */}
{config.entityTable && entityColumns.length > 0 && (
<div className="border-t pt-3">
<p className="text-muted-foreground text-[10px]">
({config.entityTable}) , .
</p>
</div>
)}
</div>
)}

View File

@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useRouter } from "next/navigation";
import { apiCall } from "@/lib/api/client";
import { AuthLogger } from "@/lib/authLogger";
interface UserInfo {
userId: string;
@ -161,13 +162,15 @@ export const useAuth = () => {
const token = TokenManager.getToken();
if (!token || TokenManager.isTokenExpired(token)) {
AuthLogger.log("AUTH_CHECK_FAIL", `refreshUserData: 토큰 ${!token ? "없음" : "만료됨"}`);
setUser(null);
setAuthStatus({ isLoggedIn: false, isAdmin: false });
setLoading(false);
return;
}
// 토큰이 유효하면 우선 인증된 상태로 설정
AuthLogger.log("AUTH_CHECK_START", "refreshUserData: API로 인증 상태 확인 시작");
setAuthStatus({
isLoggedIn: true,
isAdmin: false,
@ -186,15 +189,16 @@ export const useAuth = () => {
};
setAuthStatus(finalAuthStatus);
AuthLogger.log("AUTH_CHECK_SUCCESS", `사용자: ${userInfo.userId}, 인증: ${finalAuthStatus.isLoggedIn}`);
// API 결과가 비인증이면 상태만 업데이트 (리다이렉트는 client.ts가 처리)
if (!finalAuthStatus.isLoggedIn) {
AuthLogger.log("AUTH_CHECK_FAIL", "API 응답에서 비인증 상태 반환 → 토큰 제거");
TokenManager.removeToken();
setUser(null);
setAuthStatus({ isLoggedIn: false, isAdmin: false });
}
} else {
// userInfo 조회 실패 → 토큰에서 최소 정보 추출하여 유지
AuthLogger.log("AUTH_CHECK_FAIL", "userInfo 조회 실패 → 토큰 기반 임시 인증 유지 시도");
try {
const payload = JSON.parse(atob(token.split(".")[1]));
const tempUser: UserInfo = {
@ -210,14 +214,14 @@ export const useAuth = () => {
isAdmin: tempUser.isAdmin,
});
} catch {
// 토큰 파싱도 실패하면 비인증 상태로 전환
AuthLogger.log("AUTH_CHECK_FAIL", "토큰 파싱 실패 → 비인증 전환");
TokenManager.removeToken();
setUser(null);
setAuthStatus({ isLoggedIn: false, isAdmin: false });
}
}
} catch {
// API 호출 전체 실패 → 토큰 기반 임시 인증 유지 시도
AuthLogger.log("AUTH_CHECK_FAIL", "API 호출 전체 실패 → 토큰 기반 임시 인증 유지 시도");
try {
const payload = JSON.parse(atob(token.split(".")[1]));
const tempUser: UserInfo = {
@ -233,6 +237,7 @@ export const useAuth = () => {
isAdmin: tempUser.isAdmin,
});
} catch {
AuthLogger.log("AUTH_CHECK_FAIL", "최종 fallback 실패 → 비인증 전환");
TokenManager.removeToken();
setUser(null);
setAuthStatus({ isLoggedIn: false, isAdmin: false });
@ -408,19 +413,19 @@ export const useAuth = () => {
const token = TokenManager.getToken();
if (token && !TokenManager.isTokenExpired(token)) {
// 유효한 토큰 → 우선 인증 상태로 설정 후 API 확인
AuthLogger.log("AUTH_CHECK_START", `초기 인증 확인: 유효한 토큰 존재 (경로: ${window.location.pathname})`);
setAuthStatus({
isLoggedIn: true,
isAdmin: false,
});
refreshUserData();
} else if (token && TokenManager.isTokenExpired(token)) {
// 만료된 토큰 → 정리 (리다이렉트는 AuthGuard에서 처리)
AuthLogger.log("TOKEN_EXPIRED_DETECTED", `초기 확인 시 만료된 토큰 발견 → 정리 (경로: ${window.location.pathname})`);
TokenManager.removeToken();
setAuthStatus({ isLoggedIn: false, isAdmin: false });
setLoading(false);
} else {
// 토큰 없음 → 비인증 상태 (리다이렉트는 AuthGuard에서 처리)
AuthLogger.log("AUTH_CHECK_FAIL", `초기 확인: 토큰 없음 (경로: ${window.location.pathname})`);
setAuthStatus({ isLoggedIn: false, isAdmin: false });
setLoading(false);
}

View File

@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { MenuItem, MenuState } from "@/types/menu";
import { apiClient } from "@/lib/api/client";
import { AuthLogger } from "@/lib/authLogger";
/**
*
@ -84,8 +85,8 @@ export const useMenu = (user: any, authLoading: boolean) => {
} else {
setMenuState((prev: MenuState) => ({ ...prev, isLoading: false }));
}
} catch {
// API 실패 시 빈 메뉴로 유지 (401은 client.ts 인터셉터가 리다이렉트 처리)
} catch (err: any) {
AuthLogger.log("MENU_LOAD_FAIL", `메뉴 로드 실패: ${err?.response?.status || err?.message || "unknown"}`);
setMenuState((prev: MenuState) => ({ ...prev, isLoading: false }));
}
}, [convertToUpperCaseKeys, buildMenuTree]);

View File

@ -1,4 +1,14 @@
import axios, { AxiosResponse, AxiosError, InternalAxiosRequestConfig } from "axios";
import { AuthLogger } from "@/lib/authLogger";
const authLog = (event: string, detail: string) => {
if (typeof window === "undefined") return;
try {
AuthLogger.log(event as any, detail);
} catch {
// 로거 실패해도 앱 동작에 영향 없음
}
};
// API URL 동적 설정 - 환경변수 우선 사용
const getApiBaseUrl = (): string => {
@ -149,9 +159,12 @@ const refreshToken = async (): Promise<string | null> => {
try {
const currentToken = TokenManager.getToken();
if (!currentToken) {
authLog("TOKEN_REFRESH_FAIL", "갱신 시도했으나 토큰 자체가 없음");
return null;
}
authLog("TOKEN_REFRESH_START", `남은시간: ${Math.round(TokenManager.getTimeUntilExpiry(currentToken) / 60000)}`);
const response = await axios.post(
`${API_BASE_URL}/auth/refresh`,
{},
@ -165,10 +178,13 @@ const refreshToken = async (): Promise<string | null> => {
if (response.data?.success && response.data?.data?.token) {
const newToken = response.data.data.token;
TokenManager.setToken(newToken);
authLog("TOKEN_REFRESH_SUCCESS", "토큰 갱신 완료");
return newToken;
}
authLog("TOKEN_REFRESH_FAIL", `API 응답 실패: success=${response.data?.success}`);
return null;
} catch {
} catch (err: any) {
authLog("TOKEN_REFRESH_FAIL", `API 호출 에러: ${err?.response?.status || err?.message || "unknown"}`);
return null;
}
};
@ -210,16 +226,21 @@ const setupVisibilityRefresh = (): void => {
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
const token = TokenManager.getToken();
if (!token) return;
if (!token) {
authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 없음");
return;
}
if (TokenManager.isTokenExpired(token)) {
// 만료됐으면 갱신 시도
authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 만료 감지 → 갱신 시도");
refreshToken().then((newToken) => {
if (!newToken) {
authLog("REDIRECT_TO_LOGIN", "탭 복귀 후 토큰 갱신 실패로 리다이렉트");
redirectToLogin();
}
});
} else if (TokenManager.isTokenExpiringSoon(token)) {
authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 만료 임박 → 갱신 시도");
refreshToken();
}
}
@ -268,6 +289,7 @@ const redirectToLogin = (): void => {
if (isRedirecting) return;
if (window.location.pathname === "/login") return;
authLog("REDIRECT_TO_LOGIN", `리다이렉트 실행 (from: ${window.location.pathname})`);
isRedirecting = true;
TokenManager.removeToken();
window.location.href = "/login";
@ -301,15 +323,13 @@ apiClient.interceptors.request.use(
if (token) {
if (!TokenManager.isTokenExpired(token)) {
// 유효한 토큰 → 그대로 사용
config.headers.Authorization = `Bearer ${token}`;
} else {
// 만료된 토큰 → 갱신 시도 후 사용
authLog("TOKEN_EXPIRED_DETECTED", `요청 전 토큰 만료 감지 (${config.url}) → 갱신 시도`);
const newToken = await refreshToken();
if (newToken) {
config.headers.Authorization = `Bearer ${newToken}`;
}
// 갱신 실패해도 요청은 보냄 (401 응답 인터셉터에서 처리)
}
}
@ -378,12 +398,16 @@ apiClient.interceptors.response.use(
// 401 에러 처리 (핵심 개선)
if (status === 401 && typeof window !== "undefined") {
const errorData = error.response?.data as { error?: { code?: string } };
const errorData = error.response?.data as { error?: { code?: string; details?: string } };
const errorCode = errorData?.error?.code;
const errorDetails = errorData?.error?.details;
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
authLog("API_401_RECEIVED", `URL: ${url} | 코드: ${errorCode || "없음"} | 상세: ${errorDetails || "없음"}`);
// 이미 재시도한 요청이면 로그인으로
if (originalRequest?._retry) {
authLog("REDIRECT_TO_LOGIN", `재시도 후에도 401 (${url}) → 로그인 리다이렉트`);
redirectToLogin();
return Promise.reject(error);
}
@ -395,6 +419,7 @@ apiClient.interceptors.response.use(
originalRequest._retry = true;
try {
authLog("API_401_RETRY", `토큰 만료로 갱신 후 재시도 (${url})`);
const newToken = await refreshToken();
if (newToken) {
isRefreshing = false;
@ -404,17 +429,18 @@ apiClient.interceptors.response.use(
} else {
isRefreshing = false;
onRefreshFailed(new Error("토큰 갱신 실패"));
authLog("REDIRECT_TO_LOGIN", `토큰 갱신 실패 (${url}) → 로그인 리다이렉트`);
redirectToLogin();
return Promise.reject(error);
}
} catch (refreshError) {
isRefreshing = false;
onRefreshFailed(refreshError as Error);
authLog("REDIRECT_TO_LOGIN", `토큰 갱신 예외 (${url}) → 로그인 리다이렉트`);
redirectToLogin();
return Promise.reject(error);
}
} else {
// 다른 요청이 이미 갱신 중 → 갱신 완료 대기 후 재시도
try {
const newToken = await waitForTokenRefresh();
originalRequest._retry = true;
@ -427,6 +453,7 @@ apiClient.interceptors.response.use(
}
// TOKEN_MISSING, INVALID_TOKEN 등 → 로그인으로
authLog("REDIRECT_TO_LOGIN", `복구 불가능한 인증 에러 (${errorCode || "UNKNOWN"}, ${url}) → 로그인 리다이렉트`);
redirectToLogin();
}

225
frontend/lib/authLogger.ts Normal file
View File

@ -0,0 +1,225 @@
/**
*
* - //
* - localStorage에
* - window.__AUTH_LOG.show()
*/
const STORAGE_KEY = "auth_debug_log";
const MAX_ENTRIES = 200;
export type AuthEventType =
| "TOKEN_SET"
| "TOKEN_REMOVED"
| "TOKEN_EXPIRED_DETECTED"
| "TOKEN_REFRESH_START"
| "TOKEN_REFRESH_SUCCESS"
| "TOKEN_REFRESH_FAIL"
| "REDIRECT_TO_LOGIN"
| "API_401_RECEIVED"
| "API_401_RETRY"
| "AUTH_CHECK_START"
| "AUTH_CHECK_SUCCESS"
| "AUTH_CHECK_FAIL"
| "AUTH_GUARD_BLOCK"
| "AUTH_GUARD_PASS"
| "MENU_LOAD_FAIL"
| "VISIBILITY_CHANGE"
| "MIDDLEWARE_REDIRECT";
interface AuthLogEntry {
timestamp: string;
event: AuthEventType;
detail: string;
tokenStatus: string;
url: string;
stack?: string;
}
function getTokenSummary(): string {
if (typeof window === "undefined") return "SSR";
const token = localStorage.getItem("authToken");
if (!token) return "없음";
try {
const payload = JSON.parse(atob(token.split(".")[1]));
const exp = payload.exp * 1000;
const now = Date.now();
const remainMs = exp - now;
if (remainMs <= 0) {
return `만료됨(${Math.abs(Math.round(remainMs / 60000))}분 전)`;
}
const remainMin = Math.round(remainMs / 60000);
const remainHour = Math.floor(remainMin / 60);
const min = remainMin % 60;
return `유효(${remainHour}h${min}m 남음, user:${payload.userId})`;
} catch {
return "파싱실패";
}
}
function getCallStack(): string {
try {
const stack = new Error().stack || "";
const lines = stack.split("\n").slice(3, 7);
return lines.map((l) => l.trim()).join(" <- ");
} catch {
return "";
}
}
function writeLog(event: AuthEventType, detail: string) {
if (typeof window === "undefined") return;
const entry: AuthLogEntry = {
timestamp: new Date().toISOString(),
event,
detail,
tokenStatus: getTokenSummary(),
url: window.location.pathname + window.location.search,
stack: getCallStack(),
};
// 콘솔 출력 (그룹)
const isError = ["TOKEN_REMOVED", "REDIRECT_TO_LOGIN", "API_401_RECEIVED", "TOKEN_REFRESH_FAIL", "AUTH_GUARD_BLOCK"].includes(event);
const logFn = isError ? console.warn : console.debug;
logFn(`[AuthLog] ${event}: ${detail} | 토큰: ${entry.tokenStatus} | ${entry.url}`);
// localStorage에 저장
try {
const stored = localStorage.getItem(STORAGE_KEY);
const logs: AuthLogEntry[] = stored ? JSON.parse(stored) : [];
logs.push(entry);
// 최대 개수 초과 시 오래된 것 제거
while (logs.length > MAX_ENTRIES) {
logs.shift();
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(logs));
} catch {
// localStorage 공간 부족 등의 경우 무시
}
}
/**
*
*/
function getLogs(): AuthLogEntry[] {
if (typeof window === "undefined") return [];
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
/**
*
*/
function clearLogs() {
if (typeof window === "undefined") return;
localStorage.removeItem(STORAGE_KEY);
}
/**
*
*/
function showLogs(filter?: AuthEventType | "ERROR") {
const logs = getLogs();
if (logs.length === 0) {
console.log("[AuthLog] 저장된 로그가 없습니다.");
return;
}
let filtered = logs;
if (filter === "ERROR") {
filtered = logs.filter((l) =>
["TOKEN_REMOVED", "REDIRECT_TO_LOGIN", "API_401_RECEIVED", "TOKEN_REFRESH_FAIL", "AUTH_GUARD_BLOCK", "AUTH_CHECK_FAIL", "TOKEN_EXPIRED_DETECTED"].includes(l.event)
);
} else if (filter) {
filtered = logs.filter((l) => l.event === filter);
}
console.log(`\n[AuthLog] 총 ${filtered.length}건 (전체 ${logs.length}건)`);
console.log("─".repeat(120));
filtered.forEach((entry, i) => {
const time = entry.timestamp.replace("T", " ").split(".")[0];
console.log(
`${i + 1}. [${time}] ${entry.event}\n 상세: ${entry.detail}\n 토큰: ${entry.tokenStatus}\n 경로: ${entry.url}${entry.stack ? `\n 호출: ${entry.stack}` : ""}\n`
);
});
}
/**
*
*/
function getLastRedirectReason(): AuthLogEntry | null {
const logs = getLogs();
for (let i = logs.length - 1; i >= 0; i--) {
if (logs[i].event === "REDIRECT_TO_LOGIN") {
return logs[i];
}
}
return null;
}
/**
*
*/
function downloadLogs() {
if (typeof window === "undefined") return;
const logs = getLogs();
if (logs.length === 0) {
console.log("[AuthLog] 저장된 로그가 없습니다.");
return;
}
const text = logs
.map((entry, i) => {
const time = entry.timestamp.replace("T", " ").split(".")[0];
return `[${i + 1}] ${time} | ${entry.event}\n 상세: ${entry.detail}\n 토큰: ${entry.tokenStatus}\n 경로: ${entry.url}${entry.stack ? `\n 호출: ${entry.stack}` : ""}`;
})
.join("\n\n");
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `auth-debug-log_${new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-")}.txt`;
a.click();
URL.revokeObjectURL(url);
console.log("[AuthLog] 로그 파일 다운로드 완료");
}
// 전역 접근 가능하게 등록
if (typeof window !== "undefined") {
(window as any).__AUTH_LOG = {
show: showLogs,
errors: () => showLogs("ERROR"),
clear: clearLogs,
download: downloadLogs,
lastRedirect: getLastRedirectReason,
raw: getLogs,
};
}
export const AuthLogger = {
log: writeLog,
getLogs,
clearLogs,
showLogs,
downloadLogs,
getLastRedirectReason,
};
export default AuthLogger;

View File

@ -8,6 +8,76 @@ 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] || 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 {
columnMetaCache[tableName] = {};
} finally {
delete columnMetaLoading[tableName];
}
})();
await columnMetaLoading[tableName];
}
// 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 +245,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 && !columnMetaCache[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 => {
@ -551,24 +630,34 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
height: finalStyle.height,
};
// 컬럼 메타데이터 기반 componentConfig 병합 (DB 최신 설정 우선)
const isEntityJoinColumn = fieldName?.includes(".");
const baseColumnName = isEntityJoinColumn ? undefined : fieldName;
const mergedComponentConfig = mergeColumnMeta(screenTableName, baseColumnName, component.componentConfig || {});
// 엔티티 조인 컬럼은 런타임에서 readonly/disabled 강제 해제
const effectiveComponent = isEntityJoinColumn
? { ...component, componentConfig: mergedComponentConfig, readonly: false }
: { ...component, componentConfig: mergedComponentConfig };
const rendererProps = {
component,
component: effectiveComponent,
isSelected,
onClick,
onDragStart,
onDragEnd,
size: 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,
// 🆕 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에서 추출한 현재 값 전달
// 새로운 기능들 전달
@ -608,9 +697,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,

View File

@ -23,7 +23,8 @@ import { dataApi } from "@/lib/api/data";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { useToast } from "@/hooks/use-toast";
import { tableTypeApi } from "@/lib/api/screen";
import { apiClient } from "@/lib/api/client";
import { apiClient, getFullImageUrl } from "@/lib/api/client";
import { getFilePreviewUrl } from "@/lib/api/file";
import {
Dialog,
DialogContent,
@ -39,6 +40,80 @@ import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-opt
import { useAuth } from "@/hooks/useAuth";
import { useSplitPanel } from "./SplitPanelContext";
// 테이블 셀 이미지 썸네일 컴포넌트
const SplitPanelCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
const [imgSrc, setImgSrc] = React.useState<string | null>(null);
const [error, setError] = React.useState(false);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
let mounted = true;
const rawValue = String(value);
const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue;
const isObjid = /^\d+$/.test(strValue);
if (isObjid) {
const loadImage = async () => {
try {
const response = await apiClient.get(`/files/preview/${strValue}`, { responseType: "blob" });
if (mounted) {
const blob = new Blob([response.data]);
setImgSrc(window.URL.createObjectURL(blob));
setLoading(false);
}
} catch {
if (mounted) { setError(true); setLoading(false); }
}
};
loadImage();
} else {
setImgSrc(getFullImageUrl(strValue));
setLoading(false);
}
return () => { mounted = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
if (loading) {
return (
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
<div className="h-8 w-8 animate-pulse rounded bg-muted sm:h-10 sm:w-10" />
</div>
);
}
if (error || !imgSrc) {
return (
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
<div className="bg-muted text-muted-foreground flex h-8 w-8 items-center justify-center rounded sm:h-10 sm:w-10" title="이미지를 불러올 수 없습니다">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="2" y1="2" x2="22" y2="22"/><path d="M10.41 10.41a2 2 0 1 1-2.83-2.83"/><line x1="13.5" y1="13.5" x2="6" y2="21"/><line x1="18" y1="12" x2="21" y2="15"/><path d="M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59"/><path d="M21 15V5a2 2 0 0 0-2-2H9"/></svg>
</div>
</div>
);
}
return (
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
<img
src={imgSrc}
alt="이미지"
className="h-8 w-8 cursor-pointer rounded object-cover transition-opacity hover:opacity-80 sm:h-10 sm:w-10"
style={{ maxWidth: "40px", maxHeight: "40px" }}
onClick={(e) => {
e.stopPropagation();
const rawValue = String(value);
const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue;
const isObjid = /^\d+$/.test(strValue);
window.open(isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue), "_blank");
}}
onError={() => setError(true)}
/>
</div>
);
});
SplitPanelCellImage.displayName = "SplitPanelCellImage";
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
// 추가 props
}
@ -182,6 +257,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
const [rightColumnLabels, setRightColumnLabels] = useState<Record<string, string>>({}); // 우측 컬럼 라벨
const [columnInputTypes, setColumnInputTypes] = useState<Record<string, string>>({}); // 테이블별 컬럼 inputType
const [leftCategoryMappings, setLeftCategoryMappings] = useState<
Record<string, Record<string, { label: string; color?: string }>>
>({}); // 좌측 카테고리 매핑
@ -619,7 +695,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return result;
}, []);
// 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷)
// 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷 + 이미지)
const formatCellValue = useCallback(
(
columnName: string,
@ -636,6 +712,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
) => {
if (value === null || value === undefined) return "-";
// 이미지 타입: 썸네일 표시
const colInputType = columnInputTypes[columnName];
if (colInputType === "image" && value) {
return <SplitPanelCellImage value={String(value)} />;
}
// 🆕 날짜 포맷 적용
if (format?.type === "date" || format?.dateFormat) {
return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD");
@ -702,7 +784,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 일반 값
return String(value);
},
[formatDateValue, formatNumberValue],
[formatDateValue, formatNumberValue, columnInputTypes],
);
// 좌측 데이터 로드
@ -1453,14 +1535,36 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}
});
setRightColumnLabels(labels);
console.log("✅ 우측 컬럼 라벨 로드:", labels);
// 우측 테이블 + 추가 탭 테이블의 inputType 로드
const tablesToLoad = new Set<string>([rightTableName]);
const additionalTabs = componentConfig.rightPanel?.additionalTabs || [];
additionalTabs.forEach((tab: any) => {
if (tab.tableName) tablesToLoad.add(tab.tableName);
});
const inputTypes: Record<string, string> = {};
for (const tbl of tablesToLoad) {
try {
const inputTypesResponse = await tableTypeApi.getColumnInputTypes(tbl);
inputTypesResponse.forEach((col: any) => {
const colName = col.columnName || col.column_name;
if (colName) {
inputTypes[colName] = col.inputType || "text";
}
});
} catch {
// inputType 로드 실패 시 무시
}
}
setColumnInputTypes(inputTypes);
} catch (error) {
console.error("우측 테이블 컬럼 정보 로드 실패:", error);
}
};
loadRightTableColumns();
}, [componentConfig.rightPanel?.tableName, isDesignMode]);
}, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.additionalTabs, isDesignMode]);
// 좌측 테이블 카테고리 매핑 로드
useEffect(() => {

View File

@ -940,23 +940,35 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
}
// 일반 타입 또는 카테고리 조회 실패 시: 현재 데이터 기반
// 백엔드 DISTINCT API로 전체 고유값 조회 (페이징과 무관하게 모든 값 반환)
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/entity/${tableConfig.selectedTable}/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 실패 시 현재 데이터 기반으로 fallback
}
// fallback: 현재 로드된 데이터에서 고유 값 추출
const isLabelType = ["category", "entity", "code"].includes(inputType);
const labelField = isLabelType ? `${columnName}_name` : columnName;
// 현재 로드된 데이터에서 고유 값 추출
const uniqueValuesMap = new Map<string, string>(); // value -> label
const uniqueValuesMap = new Map<string, string>();
data.forEach((row) => {
const value = row[columnName];
if (value !== null && value !== undefined && value !== "") {
// 백엔드 조인된 _name 필드 사용 (없으면 원본 값)
const label = isLabelType && row[labelField] ? row[labelField] : String(value);
uniqueValuesMap.set(String(value), label);
}
});
// Map을 배열로 변환하고 라벨 기준으로 정렬
const result = Array.from(uniqueValuesMap.entries())
.map(([value, label]) => ({
value: value,
@ -4192,9 +4204,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
const inputType = meta?.inputType || column.inputType;
// 🖼️ 이미지 타입: 작은 썸네일 표시
// 🖼️ 이미지 타입: 작은 썸네일 표시 (다중 이미지인 경우 대표 이미지 1개만)
if (inputType === "image" && value && typeof value === "string") {
const imageUrl = getFullImageUrl(value);
const firstImage = value.includes(",") ? value.split(",")[0].trim() : value;
const imageUrl = getFullImageUrl(firstImage);
return (
<img
src={imageUrl}
@ -4307,7 +4320,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 다중 값인 경우: 여러 배지 렌더링
return (
<div className="flex flex-wrap gap-1">
<div className="flex flex-nowrap gap-1 overflow-hidden">
{values.map((val, idx) => {
const categoryData = mapping?.[val];
const displayLabel = categoryData?.label || val;
@ -4316,7 +4329,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
if (!displayColor || displayColor === "none" || !categoryData) {
return (
<span key={idx} className="text-sm">
<span key={idx} className="shrink-0 whitespace-nowrap text-sm">
{displayLabel}
{idx < values.length - 1 && ", "}
</span>
@ -4330,7 +4343,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
backgroundColor: displayColor,
borderColor: displayColor,
}}
className="text-white"
className="shrink-0 whitespace-nowrap text-white"
>
{displayLabel}
</Badge>

View File

@ -247,6 +247,10 @@ export function UniversalFormModalComponent({
// 폼 데이터 상태
const [formData, setFormData] = useState<FormDataState>({});
// formDataRef: 항상 최신 formData를 유지하는 ref
// React 상태 업데이트는 비동기적이므로, handleBeforeFormSave 등에서
// 클로저의 formData가 오래된 값을 참조하는 문제를 방지
const formDataRef = useRef<FormDataState>({});
const [, setOriginalData] = useState<Record<string, any>>({});
// 반복 섹션 데이터
@ -398,18 +402,19 @@ export function UniversalFormModalComponent({
console.log("[UniversalFormModal] beforeFormSave 이벤트 수신");
console.log("[UniversalFormModal] 설정된 필드 목록:", Array.from(configuredFields));
// formDataRef.current 사용: blur+click 동시 처리 시 클로저의 formData가 오래된 문제 방지
const latestFormData = formDataRef.current;
// 🆕 시스템 필드 병합: id는 설정 여부와 관계없이 항상 전달 (UPDATE/INSERT 판단용)
// - 신규 등록: formData.id가 없으므로 영향 없음
// - 편집 모드: formData.id가 있으면 메인 테이블 UPDATE에 사용
if (formData.id !== undefined && formData.id !== null && formData.id !== "") {
event.detail.formData.id = formData.id;
console.log(`[UniversalFormModal] 시스템 필드 병합: id =`, formData.id);
if (latestFormData.id !== undefined && latestFormData.id !== null && latestFormData.id !== "") {
event.detail.formData.id = latestFormData.id;
console.log(`[UniversalFormModal] 시스템 필드 병합: id =`, latestFormData.id);
}
// UniversalFormModal에 설정된 필드만 병합 (채번 규칙 포함)
// 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀
// (UniversalFormModal이 해당 필드의 주인이므로)
for (const [key, value] of Object.entries(formData)) {
for (const [key, value] of Object.entries(latestFormData)) {
// 설정에 정의된 필드 또는 채번 규칙 ID 필드만 병합
const isConfiguredField = configuredFields.has(key);
const isNumberingRuleId = key.endsWith("_numberingRuleId");
@ -432,17 +437,13 @@ export function UniversalFormModalComponent({
}
// 🆕 테이블 섹션 데이터 병합 (품목 리스트 등)
// 참고: initializeForm에서 DB 로드 시 __tableSection_ (더블),
// handleTableDataChange에서 수정 시 _tableSection_ (싱글) 사용
for (const [key, value] of Object.entries(formData)) {
// 싱글/더블 언더스코어 모두 처리
// formDataRef.current(= latestFormData) 사용: React 상태 커밋 전에도 최신 데이터 보장
for (const [key, value] of Object.entries(latestFormData)) {
// _tableSection_ 과 __tableSection_ 모두 원본 키 그대로 전달
// buttonActions.ts에서 DB데이터(__tableSection_)와 수정데이터(_tableSection_)를 구분하여 병합
if ((key.startsWith("_tableSection_") || key.startsWith("__tableSection_")) && Array.isArray(value)) {
// 저장 시에는 _tableSection_ 키로 통일 (buttonActions.ts에서 이 키를 기대)
const normalizedKey = key.startsWith("__tableSection_")
? key.replace("__tableSection_", "_tableSection_")
: key;
event.detail.formData[normalizedKey] = value;
console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key}${normalizedKey}, ${value.length}개 항목`);
event.detail.formData[key] = value;
console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key}, ${value.length}개 항목`);
}
// 🆕 원본 테이블 섹션 데이터도 병합 (삭제 추적용)
@ -457,6 +458,22 @@ export function UniversalFormModalComponent({
event.detail.formData._originalGroupedData = originalGroupedData;
console.log(`[UniversalFormModal] 원본 그룹 데이터 병합: ${originalGroupedData.length}`);
}
// 🆕 부모 formData의 중첩 객체(modalKey)도 최신 데이터로 업데이트
// onChange(setTimeout)가 아직 부모에 전파되지 않았을 수 있으므로 직접 업데이트
for (const parentKey of Object.keys(event.detail.formData)) {
const parentValue = event.detail.formData[parentKey];
if (parentValue && typeof parentValue === "object" && !Array.isArray(parentValue)) {
const hasTableSection = Object.keys(parentValue).some(
(k) => k.startsWith("_tableSection_") || k.startsWith("__tableSection_"),
);
if (hasTableSection) {
event.detail.formData[parentKey] = { ...latestFormData };
console.log(`[UniversalFormModal] 부모 중첩 객체 업데이트: ${parentKey}`);
break;
}
}
}
};
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
@ -482,10 +499,11 @@ export function UniversalFormModalComponent({
// 테이블 섹션 데이터 설정
const tableSectionKey = `_tableSection_${tableSection.id}`;
setFormData((prev) => ({
...prev,
[tableSectionKey]: _groupedData,
}));
setFormData((prev) => {
const newData = { ...prev, [tableSectionKey]: _groupedData };
formDataRef.current = newData;
return newData;
});
groupedDataInitializedRef.current = true;
}, [_groupedData, config.sections]);
@ -965,6 +983,7 @@ export function UniversalFormModalComponent({
}
setFormData(newFormData);
formDataRef.current = newFormData;
setRepeatSections(newRepeatSections);
setCollapsedSections(newCollapsed);
setActivatedOptionalFieldGroups(newActivatedGroups);
@ -1132,6 +1151,9 @@ export function UniversalFormModalComponent({
console.log(`[연쇄 드롭다운] 부모 ${columnName} 변경 → 자식 ${childField} 초기화`);
}
// ref 즉시 업데이트 (React 상태 커밋 전에도 최신 데이터 접근 가능)
formDataRef.current = newData;
// onChange는 렌더링 외부에서 호출해야 함 (setTimeout 사용)
if (onChange) {
setTimeout(() => onChange(newData), 0);

View File

@ -13,6 +13,7 @@ import {
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
@ -26,6 +27,7 @@ import {
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { apiClient } from "@/lib/api/client";
@ -82,7 +84,7 @@ const generateTempId = () => `temp_${Date.now()}_${++tempIdCounter}`;
interface ItemSearchModalProps {
open: boolean;
onClose: () => void;
onSelect: (item: ItemInfo) => void;
onSelect: (items: ItemInfo[]) => void;
companyCode?: string;
}
@ -94,6 +96,7 @@ function ItemSearchModal({
}: ItemSearchModalProps) {
const [searchText, setSearchText] = useState("");
const [items, setItems] = useState<ItemInfo[]>([]);
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const searchItems = useCallback(
@ -109,7 +112,7 @@ function ItemSearchModal({
enableEntityJoin: true,
companyCodeOverride: companyCode,
});
setItems(result.data || []);
setItems((result.data || []) as ItemInfo[]);
} catch (error) {
console.error("[BomItemEditor] 품목 검색 실패:", error);
} finally {
@ -122,6 +125,7 @@ function ItemSearchModal({
useEffect(() => {
if (open) {
setSearchText("");
setSelectedItems(new Set());
searchItems("");
}
}, [open, searchItems]);
@ -180,6 +184,15 @@ function ItemSearchModal({
<table className="w-full text-xs sm:text-sm">
<thead className="bg-muted/50 sticky top-0">
<tr>
<th className="w-8 px-2 py-2 text-center">
<Checkbox
checked={selectedItems.size > 0 && selectedItems.size === items.length}
onCheckedChange={(checked) => {
if (checked) setSelectedItems(new Set(items.map((i) => i.id)));
else setSelectedItems(new Set());
}}
/>
</th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th>
@ -191,11 +204,31 @@ function ItemSearchModal({
<tr
key={item.id}
onClick={() => {
onSelect(item);
onClose();
setSelectedItems((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id);
else next.add(item.id);
return next;
});
}}
className="hover:bg-accent cursor-pointer border-t transition-colors"
className={cn(
"cursor-pointer border-t transition-colors",
selectedItems.has(item.id) ? "bg-primary/10" : "hover:bg-accent",
)}
>
<td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedItems.has(item.id)}
onCheckedChange={(checked) => {
setSelectedItems((prev) => {
const next = new Set(prev);
if (checked) next.add(item.id);
else next.delete(item.id);
return next;
});
}}
/>
</td>
<td className="px-3 py-2 font-mono">
{item.item_number}
</td>
@ -208,6 +241,25 @@ function ItemSearchModal({
</table>
)}
</div>
{selectedItems.size > 0 && (
<DialogFooter className="gap-2 sm:gap-0">
<span className="text-muted-foreground text-xs sm:text-sm">
{selectedItems.size}
</span>
<Button
onClick={() => {
const selected = items.filter((i) => selectedItems.has(i.id));
onSelect(selected);
onClose();
}}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
<Plus className="mr-1 h-4 w-4" />
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
@ -227,6 +279,10 @@ interface TreeNodeRowProps {
onFieldChange: (tempId: string, field: string, value: string) => void;
onDelete: (tempId: string) => void;
onAddChild: (parentTempId: string) => void;
onDragStart: (e: React.DragEvent, tempId: string) => void;
onDragOver: (e: React.DragEvent, tempId: string) => void;
onDrop: (e: React.DragEvent, tempId: string) => void;
isDragOver?: boolean;
}
function TreeNodeRow({
@ -241,6 +297,10 @@ function TreeNodeRow({
onFieldChange,
onDelete,
onAddChild,
onDragStart,
onDragOver,
onDrop,
isDragOver,
}: TreeNodeRowProps) {
const indentPx = depth * 32;
const visibleColumns = columns.filter((c) => c.visible !== false);
@ -319,8 +379,13 @@ function TreeNodeRow({
"group flex items-center gap-2 rounded-md border px-2 py-1.5",
"transition-colors hover:bg-accent/30",
depth > 0 && "ml-2 border-l-2 border-l-primary/20",
isDragOver && "border-primary bg-primary/5 border-dashed",
)}
style={{ marginLeft: `${indentPx}px` }}
draggable
onDragStart={(e) => onDragStart(e, node.tempId)}
onDragOver={(e) => onDragOver(e, node.tempId)}
onDrop={(e) => onDrop(e, node.tempId)}
>
<GripVertical className="text-muted-foreground h-4 w-4 shrink-0 cursor-grab" />
@ -409,7 +474,7 @@ export function BomItemEditorComponent({
// 설정값 추출
const cfg = useMemo(() => component?.componentConfig || {}, [component]);
const mainTableName = cfg.mainTableName || "bom_detail";
const parentKeyColumn = cfg.parentKeyColumn || "parent_detail_id";
const parentKeyColumn = (cfg.parentKeyColumn && cfg.parentKeyColumn !== "id") ? cfg.parentKeyColumn : "parent_detail_id";
const columns: BomColumnConfig[] = useMemo(() => cfg.columns || [], [cfg.columns]);
const visibleColumns = useMemo(() => columns.filter((c) => c.visible !== false), [columns]);
const fkColumn = cfg.foreignKeyColumn || "bom_id";
@ -431,7 +496,14 @@ export function BomItemEditorComponent({
for (const col of categoryColumns) {
const categoryRef = `${mainTableName}.${col.key}`;
if (categoryOptionsMap[categoryRef]) continue;
const alreadyLoaded = await new Promise<boolean>((resolve) => {
setCategoryOptionsMap((prev) => {
resolve(!!prev[categoryRef]);
return prev;
});
});
if (alreadyLoaded) continue;
try {
const response = await apiClient.get(`/table-categories/${mainTableName}/${col.key}/values`);
@ -455,11 +527,23 @@ export function BomItemEditorComponent({
// ─── 데이터 로드 ───
const sourceFk = cfg.dataSource?.foreignKey || "child_item_id";
const sourceTable = cfg.dataSource?.sourceTable || "item_info";
const loadBomDetails = useCallback(
async (id: string) => {
if (!id) return;
setLoading(true);
try {
// isSourceDisplay 컬럼을 추가 조인 컬럼으로 요청
const displayCols = columns.filter((c) => c.isSourceDisplay);
const additionalJoinColumns = displayCols.map((col) => ({
sourceTable,
sourceColumn: sourceFk,
joinAlias: `${sourceFk}_${col.key}`,
referenceTable: sourceTable,
}));
const result = await entityJoinApi.getTableDataWithJoins(mainTableName, {
page: 1,
size: 500,
@ -467,9 +551,20 @@ export function BomItemEditorComponent({
sortBy: "seq_no",
sortOrder: "asc",
enableEntityJoin: true,
additionalJoinColumns: additionalJoinColumns.length > 0 ? additionalJoinColumns : undefined,
});
const rows = (result.data || []).map((row: Record<string, any>) => {
const mapped = { ...row };
for (const key of Object.keys(row)) {
if (key.startsWith(`${sourceFk}_`)) {
const shortKey = key.replace(`${sourceFk}_`, "");
if (!mapped[shortKey]) mapped[shortKey] = row[key];
}
}
return mapped;
});
const rows = result.data || [];
const tree = buildTree(rows);
setTreeData(tree);
@ -483,7 +578,7 @@ export function BomItemEditorComponent({
setLoading(false);
}
},
[mainTableName, fkColumn],
[mainTableName, fkColumn, sourceFk, sourceTable, columns],
);
useEffect(() => {
@ -548,10 +643,13 @@ export function BomItemEditorComponent({
id: node.id,
tempId: node.tempId,
[parentKeyColumn]: parentId,
[fkColumn]: bomId,
seq_no: String(idx + 1),
level: String(level),
_isNew: node._isNew,
_targetTable: mainTableName,
_fkColumn: fkColumn,
_deferSave: true,
});
if (node.children.length > 0) {
traverse(node.children, node.id || node.tempId, level + 1);
@ -560,7 +658,7 @@ export function BomItemEditorComponent({
};
traverse(nodes, null, 0);
return result;
}, [parentKeyColumn, mainTableName]);
}, [parentKeyColumn, mainTableName, fkColumn, bomId]);
// 트리 변경 시 부모에게 알림
const notifyChange = useCallback(
@ -627,16 +725,17 @@ export function BomItemEditorComponent({
setItemSearchOpen(true);
}, []);
// 품목 선택 후 추가 (동적 데이터)
// 품목 선택 후 추가 (다중 선택 지원)
const handleItemSelect = useCallback(
(item: ItemInfo) => {
// 소스 테이블 데이터를 _display_ 접두사로 저장 (엔티티 조인 방식)
(selectedItemsList: ItemInfo[]) => {
let newTree = [...treeData];
for (const item of selectedItemsList) {
const sourceData: Record<string, any> = {};
const sourceTable = cfg.dataSource?.sourceTable;
if (sourceTable) {
const sourceFk = cfg.dataSource?.foreignKey || "child_item_id";
sourceData[sourceFk] = item.id;
// 소스 표시 컬럼의 데이터 병합
Object.keys(item).forEach((key) => {
sourceData[`_display_${key}`] = (item as any)[key];
sourceData[key] = (item as any)[key];
@ -658,14 +757,12 @@ export function BomItemEditorComponent({
},
};
let newTree: BomItemNode[];
if (addTargetParentId === null) {
newNode.seq_no = treeData.length + 1;
newNode.seq_no = newTree.length + 1;
newNode.level = 0;
newTree = [...treeData, newNode];
newTree = [...newTree, newNode];
} else {
newTree = findAndUpdate(treeData, addTargetParentId, (parent) => {
newTree = findAndUpdate(newTree, addTargetParentId, (parent) => {
newNode.parent_detail_id = parent.id || parent.tempId;
newNode.seq_no = parent.children.length + 1;
newNode.level = parent.level + 1;
@ -674,6 +771,10 @@ export function BomItemEditorComponent({
children: [...parent.children, newNode],
};
});
}
}
if (addTargetParentId !== null) {
setExpandedNodes((prev) => new Set([...prev, addTargetParentId]));
}
@ -692,6 +793,101 @@ export function BomItemEditorComponent({
});
}, []);
// ─── 드래그 재정렬 ───
const [dragId, setDragId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
// 트리에서 노드를 제거하고 반환
const removeNode = (nodes: BomItemNode[], tempId: string): { tree: BomItemNode[]; removed: BomItemNode | null } => {
const result: BomItemNode[] = [];
let removed: BomItemNode | null = null;
for (const node of nodes) {
if (node.tempId === tempId) {
removed = node;
} else {
const childResult = removeNode(node.children, tempId);
if (childResult.removed) removed = childResult.removed;
result.push({ ...node, children: childResult.tree });
}
}
return { tree: result, removed };
};
// 노드가 대상의 자손인지 확인 (자기 자신의 하위로 드래그 방지)
const isDescendant = (nodes: BomItemNode[], parentId: string, childId: string): boolean => {
const find = (list: BomItemNode[]): BomItemNode | null => {
for (const n of list) {
if (n.tempId === parentId) return n;
const found = find(n.children);
if (found) return found;
}
return null;
};
const parent = find(nodes);
if (!parent) return false;
const check = (children: BomItemNode[]): boolean => {
for (const c of children) {
if (c.tempId === childId) return true;
if (check(c.children)) return true;
}
return false;
};
return check(parent.children);
};
const handleDragStart = useCallback((e: React.DragEvent, tempId: string) => {
setDragId(tempId);
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", tempId);
}, []);
const handleDragOver = useCallback((e: React.DragEvent, tempId: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOverId(tempId);
}, []);
const handleDrop = useCallback((e: React.DragEvent, targetTempId: string) => {
e.preventDefault();
setDragOverId(null);
if (!dragId || dragId === targetTempId) return;
// 자기 자신의 하위로 드래그 방지
if (isDescendant(treeData, dragId, targetTempId)) return;
const { tree: treeWithout, removed } = removeNode(treeData, dragId);
if (!removed) return;
// 대상 노드 바로 뒤에 같은 레벨로 삽입
const insertAfter = (nodes: BomItemNode[], afterId: string, node: BomItemNode): { result: BomItemNode[]; inserted: boolean } => {
const result: BomItemNode[] = [];
let inserted = false;
for (const n of nodes) {
result.push(n);
if (n.tempId === afterId) {
result.push({ ...node, level: n.level, parent_detail_id: n.parent_detail_id });
inserted = true;
} else if (!inserted) {
const childResult = insertAfter(n.children, afterId, node);
if (childResult.inserted) {
result[result.length - 1] = { ...n, children: childResult.result };
inserted = true;
}
}
}
return { result, inserted };
};
const { result, inserted } = insertAfter(treeWithout, targetTempId, removed);
if (inserted) {
const reindex = (nodes: BomItemNode[], depth = 0): BomItemNode[] =>
nodes.map((n, i) => ({ ...n, seq_no: i + 1, level: depth, children: reindex(n.children, depth + 1) }));
notifyChange(reindex(result));
}
setDragId(null);
}, [dragId, treeData, notifyChange]);
// ─── 재귀 렌더링 ───
const renderNodes = (nodes: BomItemNode[], depth: number) => {
@ -711,6 +907,10 @@ export function BomItemEditorComponent({
onFieldChange={handleFieldChange}
onDelete={handleDelete}
onAddChild={handleAddChild}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
isDragOver={dragOverId === node.tempId}
/>
{isExpanded &&
node.children.length > 0 &&
@ -898,7 +1098,7 @@ export function BomItemEditorComponent({
</div>
{/* 트리 목록 */}
<div className="space-y-1">
<div className="max-h-[400px] space-y-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-8">
<span className="text-muted-foreground text-sm"> ...</span>

View File

@ -0,0 +1,212 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Loader2 } from "lucide-react";
import { apiClient } from "@/lib/api/client";
interface BomDetailEditModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
node: Record<string, any> | null;
isRootNode?: boolean;
tableName: string;
onSaved?: () => void;
}
export function BomDetailEditModal({
open,
onOpenChange,
node,
isRootNode = false,
tableName,
onSaved,
}: BomDetailEditModalProps) {
const [formData, setFormData] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
useEffect(() => {
if (node && open) {
if (isRootNode) {
setFormData({
base_qty: node.base_qty || "",
unit: node.unit || "",
remark: node.remark || "",
});
} else {
setFormData({
quantity: node.quantity || "",
unit: node.unit || node.detail_unit || "",
process_type: node.process_type || "",
base_qty: node.base_qty || "",
loss_rate: node.loss_rate || "",
remark: node.remark || "",
});
}
}
}, [node, open, isRootNode]);
const handleChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSave = async () => {
if (!node) return;
setSaving(true);
try {
const targetTable = isRootNode ? "bom" : tableName;
const realId = isRootNode ? node.id?.replace("__root_", "") : node.id;
await apiClient.put(`/table-management/tables/${targetTable}/${realId}`, formData);
onSaved?.();
onOpenChange(false);
} catch (error) {
console.error("[BomDetailEdit] 저장 실패:", error);
} finally {
setSaving(false);
}
};
if (!node) return null;
const itemCode = isRootNode
? node.child_item_code || node.item_code || node.bom_number || "-"
: node.child_item_code || "-";
const itemName = isRootNode
? node.child_item_name || node.item_name || "-"
: node.child_item_name || "-";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{isRootNode ? "BOM 헤더 수정" : "품목 수정"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{isRootNode
? "BOM 기본 정보를 수정합니다"
: "선택한 품목의 BOM 구성 정보를 수정합니다"}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input value={itemCode} disabled className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" />
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input value={itemName} disabled className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs sm:text-sm">
{isRootNode ? "기준수량" : "구성수량"} *
</Label>
<Input
type="number"
value={isRootNode ? formData.base_qty : formData.quantity}
onChange={(e) => handleChange(isRootNode ? "base_qty" : "quantity", e.target.value)}
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={formData.unit}
onChange={(e) => handleChange("unit", e.target.value)}
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
{!isRootNode && (
<>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={formData.process_type}
onChange={(e) => handleChange("process_type", e.target.value)}
placeholder="예: 조립공정"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> (%)</Label>
<Input
type="number"
value={formData.loss_rate}
onChange={(e) => handleChange("loss_rate", e.target.value)}
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={node.child_specification || node.specification || "-"}
disabled
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={node.child_material || node.material || "-"}
disabled
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
</>
)}
<div>
<Label className="text-xs sm:text-sm"></Label>
<Textarea
value={formData.remark}
onChange={(e) => handleChange("remark", e.target.value)}
placeholder="비고 사항을 입력하세요"
className="mt-1 min-h-[60px] text-xs sm:text-sm"
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSave}
disabled={saving}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,147 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
interface BomHistoryItem {
id: string;
revision: string;
version: string;
change_type: string;
change_description: string;
changed_by: string;
changed_date: string;
}
interface BomHistoryModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
bomId: string | null;
tableName?: string;
}
const CHANGE_TYPE_STYLE: Record<string, string> = {
"등록": "bg-blue-50 text-blue-600 ring-blue-200",
"수정": "bg-amber-50 text-amber-600 ring-amber-200",
"추가": "bg-emerald-50 text-emerald-600 ring-emerald-200",
"삭제": "bg-red-50 text-red-600 ring-red-200",
};
export function BomHistoryModal({ open, onOpenChange, bomId, tableName = "bom_history" }: BomHistoryModalProps) {
const [history, setHistory] = useState<BomHistoryItem[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (open && bomId) {
loadHistory();
}
}, [open, bomId]);
const loadHistory = async () => {
if (!bomId) return;
setLoading(true);
try {
const res = await apiClient.get(`/bom/${bomId}/history`, { params: { tableName } });
if (res.data?.success) {
setHistory(res.data.data || []);
}
} catch (error) {
console.error("[BomHistory] 로드 실패:", error);
} finally {
setLoading(false);
}
};
const formatDate = (dateStr: string) => {
if (!dateStr) return "-";
try {
return new Date(dateStr).toLocaleString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return dateStr;
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[650px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
BOM
</DialogDescription>
</DialogHeader>
<div className="max-h-[400px] overflow-auto">
{loading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
</div>
) : history.length === 0 ? (
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-gray-400"> </p>
</div>
) : (
<table className="w-full text-xs">
<thead className="sticky top-0 bg-gray-50">
<tr className="border-b">
<th className="px-3 py-2.5 text-center text-[11px] font-semibold text-gray-500" style={{ width: "50px" }}></th>
<th className="px-3 py-2.5 text-center text-[11px] font-semibold text-gray-500" style={{ width: "50px" }}></th>
<th className="px-3 py-2.5 text-center text-[11px] font-semibold text-gray-500" style={{ width: "70px" }}></th>
<th className="px-3 py-2.5 text-left text-[11px] font-semibold text-gray-500"></th>
<th className="px-3 py-2.5 text-center text-[11px] font-semibold text-gray-500" style={{ width: "70px" }}></th>
<th className="px-3 py-2.5 text-center text-[11px] font-semibold text-gray-500" style={{ width: "130px" }}></th>
</tr>
</thead>
<tbody>
{history.map((item, idx) => (
<tr key={item.id} className={cn("border-b border-gray-100", idx % 2 === 0 ? "bg-white" : "bg-gray-50/30")}>
<td className="px-3 py-2.5 text-center tabular-nums">{item.revision || "-"}</td>
<td className="px-3 py-2.5 text-center tabular-nums">{item.version || "-"}</td>
<td className="px-3 py-2.5 text-center">
<span className={cn(
"inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset",
CHANGE_TYPE_STYLE[item.change_type] || "bg-gray-50 text-gray-500 ring-gray-200",
)}>
{item.change_type}
</span>
</td>
<td className="px-3 py-2.5 text-gray-700">{item.change_description || "-"}</td>
<td className="px-3 py-2.5 text-center text-gray-600">{item.changed_by || "-"}</td>
<td className="px-3 py-2.5 text-center text-gray-400">{formatDate(item.changed_date)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,221 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Loader2, Plus, Trash2, Download } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
interface BomVersion {
id: string;
version_name: string;
revision: number;
status: string;
created_by: string;
created_date: string;
}
interface BomVersionModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
bomId: string | null;
tableName?: string;
detailTable?: string;
onVersionLoaded?: () => void;
}
const STATUS_STYLE: Record<string, { label: string; className: string }> = {
developing: { label: "개발중", className: "bg-red-50 text-red-600 ring-red-200" },
active: { label: "사용", className: "bg-emerald-50 text-emerald-600 ring-emerald-200" },
inactive: { label: "사용중지", className: "bg-gray-100 text-gray-500 ring-gray-200" },
};
export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_version", detailTable = "bom_detail", onVersionLoaded }: BomVersionModalProps) {
const [versions, setVersions] = useState<BomVersion[]>([]);
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [actionId, setActionId] = useState<string | null>(null);
useEffect(() => {
if (open && bomId) loadVersions();
}, [open, bomId]);
const loadVersions = async () => {
if (!bomId) return;
setLoading(true);
try {
const res = await apiClient.get(`/bom/${bomId}/versions`, { params: { tableName } });
if (res.data?.success) setVersions(res.data.data || []);
} catch (error) {
console.error("[BomVersion] 로드 실패:", error);
} finally {
setLoading(false);
}
};
const handleCreateVersion = async () => {
if (!bomId) return;
setCreating(true);
try {
const res = await apiClient.post(`/bom/${bomId}/versions`, { tableName, detailTable });
if (res.data?.success) loadVersions();
} catch (error) {
console.error("[BomVersion] 생성 실패:", error);
} finally {
setCreating(false);
}
};
const handleLoadVersion = async (versionId: string) => {
if (!bomId) return;
setActionId(versionId);
try {
const res = await apiClient.post(`/bom/${bomId}/versions/${versionId}/load`, { tableName, detailTable });
if (res.data?.success) {
onVersionLoaded?.();
onOpenChange(false);
}
} catch (error) {
console.error("[BomVersion] 불러오기 실패:", error);
} finally {
setActionId(null);
}
};
const handleDeleteVersion = async (versionId: string) => {
if (!bomId || !confirm("이 버전을 삭제하시겠습니까?")) return;
setActionId(versionId);
try {
const res = await apiClient.delete(`/bom/${bomId}/versions/${versionId}`, { params: { tableName } });
if (res.data?.success) loadVersions();
} catch (error) {
console.error("[BomVersion] 삭제 실패:", error);
} finally {
setActionId(null);
}
};
const formatDate = (dateStr: string) => {
if (!dateStr) return "-";
try {
return new Date(dateStr).toLocaleDateString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
} catch {
return dateStr;
}
};
const getStatus = (status: string) => STATUS_STYLE[status] || STATUS_STYLE.inactive;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[550px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
BOM . .
</DialogDescription>
</DialogHeader>
<div className="max-h-[400px] space-y-2 overflow-auto">
{loading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
</div>
) : versions.length === 0 ? (
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-gray-400"> </p>
</div>
) : (
versions.map((ver) => {
const st = getStatus(ver.status);
const isActing = actionId === ver.id;
return (
<div
key={ver.id}
className={cn(
"flex items-center gap-3 rounded-lg border p-3 transition-colors",
ver.status === "active" ? "border-emerald-200 bg-emerald-50/30" : "border-gray-200",
)}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-900">
Version {ver.version_name}
</span>
<span className={cn(
"inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset",
st.className,
)}>
{st.label}
</span>
</div>
<div className="mt-0.5 flex gap-3 text-[11px] text-gray-400">
<span>: {ver.revision}</span>
<span>: {formatDate(ver.created_date)}</span>
{ver.created_by && <span>: {ver.created_by}</span>}
</div>
</div>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
onClick={() => handleLoadVersion(ver.id)}
disabled={isActing}
className="h-7 gap-1 px-2 text-[10px]"
>
{isActing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
</Button>
{ver.status !== "active" && (
<Button
variant="destructive"
size="sm"
onClick={() => handleDeleteVersion(ver.id)}
disabled={isActing}
className="h-7 gap-1 px-2 text-[10px]"
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</div>
);
})
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
onClick={handleCreateVersion}
disabled={creating}
className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{creating ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Plus className="mr-1 h-4 w-4" />}
</Button>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -13,7 +13,7 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2SelectDefinition;
render(): React.ReactElement {
const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props;
const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, allComponents, ...restProps } = this.props as any;
// 컴포넌트 설정 추출
const config = component.componentConfig || component.config || {};
@ -107,8 +107,7 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
// 디버깅 필요시 주석 해제
// console.log("🔍 [V2SelectRenderer]", { componentId: component.id, effectiveStyle, effectiveSize });
// 🔧 restProps에서 style, size 제외 (effectiveStyle/effectiveSize가 우선되어야 함)
const { style: _style, size: _size, ...restPropsClean } = restProps as any;
const { style: _style, size: _size, allComponents: _allComp, ...restPropsClean } = restProps as any;
return (
<V2Select
@ -119,9 +118,10 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
disabled={config.disabled || component.disabled}
value={currentValue}
onChange={handleChange}
onFormDataChange={isInteractive ? onFormDataChange : undefined}
allComponents={allComponents}
config={{
mode: config.mode || "dropdown",
// 🔧 카테고리 타입이면 source를 무조건 "category"로 강제 (테이블 타입 관리 설정 우선)
source: isCategoryType ? "category" : (config.source || "distinct"),
multiple: config.multiple || false,
searchable: config.searchable ?? true,
@ -131,7 +131,6 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
entityTable: config.entityTable,
entityLabelColumn: config.entityLabelColumn,
entityValueColumn: config.entityValueColumn,
// 🔧 카테고리 소스 지원 (tableName, columnName 폴백)
categoryTable: config.categoryTable || (isCategoryType ? tableName : undefined),
categoryColumn: config.categoryColumn || (isCategoryType ? columnName : undefined),
}}

View File

@ -25,7 +25,8 @@ import { dataApi } from "@/lib/api/data";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { useToast } from "@/hooks/use-toast";
import { tableTypeApi } from "@/lib/api/screen";
import { apiClient } from "@/lib/api/client";
import { apiClient, getFullImageUrl } from "@/lib/api/client";
import { getFilePreviewUrl } from "@/lib/api/file";
import {
Dialog,
DialogContent,
@ -51,6 +52,42 @@ export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
selectedPanelComponentId?: string;
}
// 이미지 셀 렌더링 컴포넌트 (objid 또는 파일 경로 지원)
const SplitPanelCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
const [imgSrc, setImgSrc] = React.useState<string | null>(null);
React.useEffect(() => {
if (!value) return;
const strVal = String(value).trim();
if (!strVal || strVal === "-") return;
if (strVal.startsWith("http") || strVal.startsWith("/uploads/") || strVal.startsWith("/api/")) {
setImgSrc(getFullImageUrl(strVal));
} else {
const previewUrl = getFilePreviewUrl(strVal);
fetch(previewUrl, { credentials: "include" })
.then((res) => {
if (!res.ok) throw new Error("fetch failed");
return res.blob();
})
.then((blob) => setImgSrc(URL.createObjectURL(blob)))
.catch(() => setImgSrc(null));
}
}, [value]);
if (!imgSrc) return <span className="text-muted-foreground text-xs">-</span>;
return (
<img
src={imgSrc}
alt=""
className="h-8 w-8 rounded object-cover"
onError={() => setImgSrc(null)}
/>
);
});
SplitPanelCellImage.displayName = "SplitPanelCellImage";
/**
* SplitPanelLayout
* -
@ -210,6 +247,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [isLoadingLeft, setIsLoadingLeft] = useState(false);
const [isLoadingRight, setIsLoadingRight] = useState(false);
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
const [columnInputTypes, setColumnInputTypes] = useState<Record<string, string>>({});
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
// 추가 탭 관련 상태
@ -905,6 +943,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
) => {
if (value === null || value === undefined) return "-";
// 이미지 타입 컬럼 처리
const colInputType = columnInputTypes[columnName];
if (colInputType === "image" && value) {
return <SplitPanelCellImage value={String(value)} />;
}
// 🆕 날짜 포맷 적용
if (format?.type === "date" || format?.dateFormat) {
return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD");
@ -971,7 +1015,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 일반 값
return String(value);
},
[formatDateValue, formatNumberValue],
[formatDateValue, formatNumberValue, columnInputTypes],
);
// 🆕 패널 config의 columns에서 additionalJoinColumns 추출하는 헬퍼
@ -1835,14 +1879,36 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}
});
setRightColumnLabels(labels);
console.log("✅ 우측 컬럼 라벨 로드:", labels);
// 컬럼 inputType 로드 (이미지 등 특수 렌더링을 위해)
const tablesToLoad = new Set<string>([rightTableName]);
const additionalTabs = componentConfig.rightPanel?.additionalTabs || [];
additionalTabs.forEach((tab: any) => {
if (tab.tableName) tablesToLoad.add(tab.tableName);
});
const inputTypes: Record<string, string> = {};
for (const tbl of tablesToLoad) {
try {
const inputTypesResponse = await tableTypeApi.getColumnInputTypes(tbl);
inputTypesResponse.forEach((col: any) => {
const colName = col.columnName || col.column_name;
if (colName) {
inputTypes[colName] = col.inputType || "text";
}
});
} catch {
// ignore
}
}
setColumnInputTypes(inputTypes);
} catch (error) {
console.error("우측 테이블 컬럼 정보 로드 실패:", error);
}
};
loadRightTableColumns();
}, [componentConfig.rightPanel?.tableName, isDesignMode]);
}, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.additionalTabs, isDesignMode]);
// 좌측 테이블 카테고리 매핑 로드
useEffect(() => {

View File

@ -21,7 +21,9 @@ const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
React.useEffect(() => {
let mounted = true;
const strValue = String(value);
// 다중 이미지인 경우 대표 이미지(첫 번째)만 사용
const rawValue = String(value);
const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue;
const isObjid = /^\d+$/.test(strValue);
if (isObjid) {
@ -89,8 +91,8 @@ const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
style={{ maxWidth: "40px", maxHeight: "40px" }}
onClick={(e) => {
e.stopPropagation();
// objid인 경우 preview URL로 열기, 아니면 full URL로 열기
const strValue = String(value);
const rawValue = String(value);
const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue;
const isObjid = /^\d+$/.test(strValue);
const openUrl = isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue);
window.open(openUrl, "_blank");
@ -1015,23 +1017,35 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
}
// 일반 타입 또는 카테고리 조회 실패 시: 현재 데이터 기반
// 백엔드 DISTINCT API로 전체 고유값 조회 (페이징과 무관하게 모든 값 반환)
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/entity/${tableConfig.selectedTable}/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 (error: any) {
// DISTINCT API 실패 시 현재 데이터 기반으로 fallback
}
// fallback: 현재 로드된 데이터에서 고유 값 추출
const isLabelType = ["category", "entity", "code"].includes(inputType);
const labelField = isLabelType ? `${columnName}_name` : columnName;
// 현재 로드된 데이터에서 고유 값 추출
const uniqueValuesMap = new Map<string, string>(); // value -> label
const uniqueValuesMap = new Map<string, string>();
data.forEach((row) => {
const value = row[columnName];
if (value !== null && value !== undefined && value !== "") {
// 백엔드 조인된 _name 필드 사용 (없으면 원본 값)
const label = isLabelType && row[labelField] ? row[labelField] : String(value);
uniqueValuesMap.set(String(value), label);
}
});
// Map을 배열로 변환하고 라벨 기준으로 정렬
const result = Array.from(uniqueValuesMap.entries())
.map(([value, label]) => ({
value: value,
@ -4255,7 +4269,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 다중 값인 경우: 여러 배지 렌더링
return (
<div className="flex flex-wrap gap-1">
<div className="flex flex-nowrap gap-1 overflow-hidden">
{values.map((val, idx) => {
const categoryData = mapping?.[val];
const displayLabel = categoryData?.label || val;
@ -4264,7 +4278,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
if (!displayColor || displayColor === "none" || !categoryData) {
return (
<span key={idx} className="text-sm">
<span key={idx} className="shrink-0 whitespace-nowrap text-sm">
{displayLabel}
{idx < values.length - 1 && ", "}
</span>
@ -4278,7 +4292,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
backgroundColor: displayColor,
borderColor: displayColor,
}}
className="text-white"
className="shrink-0 whitespace-nowrap text-white"
>
{displayLabel}
</Badge>

View File

@ -1300,6 +1300,9 @@ export class ButtonActionExecutor {
// _targetTable이 없거나, _repeatScreenModal_ 키면 스킵 (다른 로직에서 처리)
if (!repeaterTargetTable || fieldKey.startsWith("_repeatScreenModal_")) continue;
// _deferSave 플래그가 있으면 메인 저장 후 처리 (마스터-디테일 순차 저장)
if (firstItem?._deferSave) continue;
// 🆕 V2Repeater가 등록된 테이블이면 RepeaterFieldGroup 저장 스킵
// V2Repeater가 repeaterSave 이벤트로 저장 처리함
// @ts-ignore - window에 동적 속성 사용
@ -1390,6 +1393,7 @@ export class ButtonActionExecutor {
_existingRecord: __,
_originalItemIds: ___,
_deletedItemIds: ____,
_fkColumn: itemFkColumn,
...dataToSave
} = item;
@ -1398,12 +1402,18 @@ export class ButtonActionExecutor {
delete dataToSave.id;
}
// BOM 에디터 등 마스터-디테일: FK 값이 없으면 메인 저장 결과의 ID 주입
if (itemFkColumn && (!dataToSave[itemFkColumn] || dataToSave[itemFkColumn] === null)) {
const mainSavedId = saveResult?.data?.id || saveResult?.data?.data?.id || context.formData?.id;
if (mainSavedId) {
dataToSave[itemFkColumn] = mainSavedId;
}
}
// 🆕 공통 필드 병합 + 사용자 정보 추가
// 개별 항목 데이터를 먼저 넣고, 공통 필드로 덮어씀 (공통 필드가 우선)
// 이유: 사용자가 공통 필드(출고상태 등)를 변경하면 모든 항목에 적용되어야 함
const dataWithMeta: Record<string, unknown> = {
...dataToSave, // RepeaterFieldGroup의 개별 항목 데이터
...commonFields, // 범용 폼 모달의 공통 필드 (outbound_status 등) - 공통 필드가 우선!
...dataToSave,
...commonFields,
created_by: context.userId,
updated_by: context.userId,
company_code: context.companyCode,
@ -1781,6 +1791,65 @@ export class ButtonActionExecutor {
// 🔧 formData를 리피터에 전달하여 각 행에 병합 저장
const savedId = saveResult?.data?.id || saveResult?.data?.data?.id || formData.id || context.formData?.id;
// _deferSave 데이터 처리 (마스터-디테일 순차 저장: 레벨별 저장 + temp→real ID 매핑)
if (savedId) {
for (const [fieldKey, fieldValue] of Object.entries(context.formData)) {
let parsedData = fieldValue;
if (typeof fieldValue === "string" && fieldValue.startsWith("[")) {
try { parsedData = JSON.parse(fieldValue); } catch { continue; }
}
if (!Array.isArray(parsedData) || parsedData.length === 0) continue;
if (!parsedData[0]?._deferSave) continue;
const targetTable = parsedData[0]?._targetTable;
if (!targetTable) continue;
// 레벨별 그룹핑 (레벨 0 먼저 저장 → 레벨 1 → ...)
const maxLevel = Math.max(...parsedData.map((item: any) => Number(item.level) || 0));
const tempIdToRealId = new Map<string, string>();
for (let lvl = 0; lvl <= maxLevel; lvl++) {
const levelItems = parsedData.filter((item: any) => (Number(item.level) || 0) === lvl);
for (const item of levelItems) {
const { _targetTable: _, _isNew, _deferSave: __, _fkColumn: fkCol, tempId, ...data } = item;
if (!data.id || data.id === "") delete data.id;
// FK 주입 (bom_id 등)
if (fkCol) data[fkCol] = savedId;
// parent_detail_id의 temp 참조를 실제 ID로 교체
if (data.parent_detail_id && tempIdToRealId.has(data.parent_detail_id)) {
data.parent_detail_id = tempIdToRealId.get(data.parent_detail_id);
}
// 시스템 필드 추가
data.created_by = context.userId;
data.updated_by = context.userId;
data.company_code = context.companyCode;
try {
const isNew = _isNew || !item.id || item.id === "";
if (isNew) {
const res = await apiClient.post(`/table-management/tables/${targetTable}/add`, data);
const newId = res.data?.data?.id || res.data?.id;
if (newId && tempId) {
tempIdToRealId.set(tempId, newId);
}
} else {
await apiClient.put(`/table-management/tables/${targetTable}/${item.id}`, data);
if (item.id && tempId) {
tempIdToRealId.set(tempId, item.id);
}
}
} catch (err: any) {
console.error(`[handleSave] 디테일 저장 실패 (${targetTable}):`, err.response?.data || err.message);
}
}
}
}
}
// 메인 폼 데이터 구성 (사용자 정보 포함)
const mainFormData = {
...formData,
@ -2054,11 +2123,11 @@ export class ButtonActionExecutor {
const { tableName, screenId } = context;
// 범용_폼_모달 키 찾기 (컬럼명에 따라 다를 수 있음)
// initializeForm에서 __tableSection_ (더블), 수정 시 _tableSection_ (싱글) 사용
const universalFormModalKey = Object.keys(formData).find((key) => {
const value = formData[key];
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
// _tableSection_ 키가 있는지 확인
return Object.keys(value).some((k) => k.startsWith("_tableSection_"));
return Object.keys(value).some((k) => k.startsWith("_tableSection_") || k.startsWith("__tableSection_"));
});
if (!universalFormModalKey) {
@ -2108,24 +2177,72 @@ export class ButtonActionExecutor {
const sections: any[] = modalComponentConfig?.sections || [];
const saveConfig = modalComponentConfig?.saveConfig || {};
// _tableSection_ 데이터 추출
// 테이블 섹션 데이터 수집: DB 전체 데이터를 베이스로, 수정 데이터를 오버라이드
const tableSectionData: Record<string, any[]> = {};
const commonFieldsData: Record<string, any> = {};
// 🆕 원본 그룹 데이터 추출 (수정 모드에서 UPDATE/DELETE 추적용)
// modalData 내부 또는 최상위 formData에서 찾음
// 원본 그룹 데이터 추출 (수정 모드에서 UPDATE/DELETE 추적용)
const originalGroupedData: any[] = modalData._originalGroupedData || formData._originalGroupedData || [];
// 1단계: DB 데이터(__tableSection_)와 수정 데이터(_tableSection_)를 별도로 수집
const dbSectionData: Record<string, any[]> = {};
const modifiedSectionData: Record<string, any[]> = {};
// 1-1: modalData(부모의 중첩 객체)에서 수집
for (const [key, value] of Object.entries(modalData)) {
if (key.startsWith("_tableSection_")) {
if (key.startsWith("__tableSection_") && Array.isArray(value)) {
const sectionId = key.replace("__tableSection_", "");
dbSectionData[sectionId] = value;
} else if (key.startsWith("_tableSection_") && Array.isArray(value)) {
const sectionId = key.replace("_tableSection_", "");
tableSectionData[sectionId] = value as any[];
modifiedSectionData[sectionId] = value;
} else if (!key.startsWith("_")) {
// _로 시작하지 않는 필드는 공통 필드로 처리
commonFieldsData[key] = value;
}
}
// 1-2: top-level formData에서도 수집 (handleBeforeFormSave가 직접 설정한 최신 데이터)
// modalData(중첩 객체)가 아직 업데이트되지 않았을 수 있으므로 보완
for (const [key, value] of Object.entries(formData)) {
if (key === universalFormModalKey) continue;
if (key.startsWith("__tableSection_") && Array.isArray(value)) {
const sectionId = key.replace("__tableSection_", "");
if (!dbSectionData[sectionId]) {
dbSectionData[sectionId] = value;
}
} else if (key.startsWith("_tableSection_") && Array.isArray(value)) {
const sectionId = key.replace("_tableSection_", "");
if (!modifiedSectionData[sectionId]) {
modifiedSectionData[sectionId] = value;
}
}
}
// 2단계: DB 데이터를 베이스로, 수정 데이터를 아이템별로 병합하여 전체 데이터 구성
// - DB 데이터(__tableSection_): initializeForm에서 로드한 전체 컬럼 데이터
// - 수정 데이터(_tableSection_): _groupedData 또는 사용자 UI 수정을 통해 설정된 데이터 (일부 컬럼만 포함 가능)
// - 병합: { ...dbItem, ...modItem } → DB 전체 컬럼 유지 + 수정된 필드만 오버라이드
const allSectionIds = new Set([...Object.keys(dbSectionData), ...Object.keys(modifiedSectionData)]);
for (const sectionId of allSectionIds) {
const dbItems = dbSectionData[sectionId] || [];
const modItems = modifiedSectionData[sectionId];
if (modItems) {
tableSectionData[sectionId] = modItems.map((modItem) => {
if (modItem.id) {
const dbItem = dbItems.find((db) => String(db.id) === String(modItem.id));
if (dbItem) {
return { ...dbItem, ...modItem };
}
}
return modItem;
});
} else {
tableSectionData[sectionId] = dbItems;
}
}
// 테이블 섹션 데이터가 없고 원본 데이터도 없으면 처리하지 않음
const hasTableSectionData = Object.values(tableSectionData).some((arr) => arr.length > 0);
if (!hasTableSectionData && originalGroupedData.length === 0) {
@ -2255,28 +2372,26 @@ export class ButtonActionExecutor {
// 각 테이블 섹션 처리
for (const [sectionId, currentItems] of Object.entries(tableSectionData)) {
// 🆕 해당 섹션의 설정 찾기
const sectionConfig = sections.find((s) => s.id === sectionId);
const targetTableName = sectionConfig?.tableConfig?.saveConfig?.targetTable;
// 🆕 실제 저장할 테이블 결정
// - targetTable이 있으면 해당 테이블에 저장
// - targetTable이 없으면 메인 테이블에 저장
const saveTableName = targetTableName || tableName!;
// 섹션별 DB 원본 데이터 조회 (전체 컬럼 보장)
// _originalTableSectionData_: initializeForm에서 DB 로드 시 저장한 원본 데이터
const sectionOriginalKey = `_originalTableSectionData_${sectionId}`;
const sectionOriginalData: any[] = modalData[sectionOriginalKey] || formData[sectionOriginalKey] || [];
// 1⃣ 신규 품목 INSERT (id가 없는 항목)
const newItems = currentItems.filter((item) => !item.id);
for (const item of newItems) {
const rowToSave = { ...commonFieldsData, ...item, ...userInfo };
// 내부 메타데이터 제거
Object.keys(rowToSave).forEach((key) => {
if (key.startsWith("_")) {
delete rowToSave[key];
}
});
// 🆕 메인 레코드 ID 연결 (별도 테이블에 저장하는 경우)
if (targetTableName && mainRecordId && saveConfig.primaryKeyColumn) {
rowToSave[saveConfig.primaryKeyColumn] = mainRecordId;
}
@ -2296,27 +2411,30 @@ export class ButtonActionExecutor {
insertedCount++;
}
// 2⃣ 기존 품목 UPDATE (id가 있는 항목, 변경된 경우만)
// 2⃣ 기존 품목 UPDATE (id가 있는 항목)
// 전체 데이터 기반 저장: DB 데이터(__tableSection_)를 베이스로 수정 데이터가 병합된 완전한 item 사용
const existingItems = currentItems.filter((item) => item.id);
for (const item of existingItems) {
const originalItem = originalGroupedData.find((orig) => orig.id === item.id);
// DB 원본 데이터 우선 사용 (전체 컬럼 보장), 없으면 originalGroupedData에서 탐색
const originalItem =
sectionOriginalData.find((orig) => String(orig.id) === String(item.id)) ||
originalGroupedData.find((orig) => String(orig.id) === String(item.id));
// 마스터/디테일 분리 시: 디테일 데이터만 사용 (마스터 필드 병합 안 함)
// 같은 테이블 시: 공통 필드도 병합 (공유 필드 업데이트 필요)
const dataToSave = hasSeparateTargetTable ? item : { ...commonFieldsData, ...item };
if (!originalItem) {
// 🆕 폴백 로직: 원본 데이터가 없어도 id가 있으면 UPDATE 시도
// originalGroupedData 전달이 누락된 경우를 처리
console.warn(`⚠️ [UPDATE] 원본 데이터 없음 - id가 있으므로 UPDATE 시도 (폴백): id=${item.id}`);
// 원본 없음: 전체 데이터로 UPDATE 실행
console.warn(`⚠️ [UPDATE] 원본 데이터 없음 - 전체 데이터로 UPDATE: id=${item.id}`);
// ⚠️ 중요: commonFieldsData가 item보다 우선순위가 높아야 함
// item에 있는 기존 값(예: manager_id=123)이 commonFieldsData의 새 값(manager_id=234)을 덮어쓰지 않도록
// 순서: item(기존) → commonFieldsData(새로 입력) → userInfo(메타데이터)
const rowToUpdate = { ...item, ...commonFieldsData, ...userInfo };
const rowToUpdate = { ...dataToSave, ...userInfo };
Object.keys(rowToUpdate).forEach((key) => {
if (key.startsWith("_")) {
delete rowToUpdate[key];
}
});
// id를 유지하고 UPDATE 실행
const updateResult = await DynamicFormApi.updateFormData(item.id, {
tableName: saveTableName,
data: rowToUpdate,
@ -2330,17 +2448,14 @@ export class ButtonActionExecutor {
continue;
}
// 변경 사항 확인 (공통 필드 포함)
// ⚠️ 중요: commonFieldsData가 item보다 우선순위가 높아야 함 (새로 입력한 값이 기존 값을 덮어씀)
const currentDataWithCommon = { ...item, ...commonFieldsData };
const hasChanges = this.checkForChanges(originalItem, currentDataWithCommon);
// 변경 사항 확인: 원본(DB) vs 현재(병합된 전체 데이터)
const hasChanges = this.checkForChanges(originalItem, dataToSave);
if (hasChanges) {
// 변경된 필드만 추출하여 부분 업데이트
const updateResult = await DynamicFormApi.updateFormDataPartial(
item.id,
originalItem,
currentDataWithCommon,
dataToSave,
saveTableName,
);
@ -2349,16 +2464,11 @@ export class ButtonActionExecutor {
}
updatedCount++;
} else {
}
}
// 3⃣ 삭제된 품목 DELETE (원본에는 있지만 현재에는 없는 항목)
// 🆕 테이블 섹션별 원본 데이터 사용 (우선), 없으면 전역 originalGroupedData 사용
const sectionOriginalKey = `_originalTableSectionData_${sectionId}`;
const sectionOriginalData: any[] = modalData[sectionOriginalKey] || formData[sectionOriginalKey] || [];
// 섹션별 원본 데이터가 있으면 사용, 없으면 전역 originalGroupedData 사용
// 섹션별 DB 원본 데이터 사용 (위에서 이미 조회), 없으면 전역 originalGroupedData 사용
const originalDataForDelete = sectionOriginalData.length > 0 ? sectionOriginalData : originalGroupedData;
// ⚠️ id 타입 통일: 문자열로 변환하여 비교 (숫자 vs 문자열 불일치 방지)

View File

@ -52,6 +52,9 @@ export interface NumberingRulePart {
partType: CodePartType; // 파트 유형
generationMethod: GenerationMethod; // 생성 방식
// 이 파트 뒤에 붙을 구분자 (마지막 파트는 무시됨)
separatorAfter?: string;
// 자동 생성 설정
autoConfig?: {
// 순번용

View File

@ -182,6 +182,8 @@ export interface V2SelectProps extends V2BaseProps {
config: V2SelectConfig;
value?: string | string[];
onChange?: (value: string | string[]) => void;
onFormDataChange?: (fieldName: string, value: any) => void;
formData?: Record<string, any>;
}
// ===== V2Date =====