feat: Add image thumbnail rendering in SplitPanelLayoutComponent
- Introduced SplitPanelCellImage component to handle image rendering for table cells, supporting both object IDs and file paths. - Enhanced formatCellValue function to display image thumbnails for columns with input type "image". - Updated column input types loading logic to accommodate special rendering for images in the right panel. - Improved error handling for image loading failures, ensuring a better user experience when images cannot be displayed.
This commit is contained in:
commit
abb31a39bb
|
|
@ -105,6 +105,7 @@ import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRou
|
||||||
import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성
|
import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성
|
||||||
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
||||||
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
||||||
|
import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리
|
||||||
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
|
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
|
||||||
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
||||||
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
|
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
|
||||||
|
|
@ -289,6 +290,7 @@ app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여
|
||||||
app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성
|
app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성
|
||||||
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
||||||
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
||||||
|
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
|
||||||
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
||||||
app.use("/api/departments", departmentRoutes); // 부서 관리
|
app.use("/api/departments", departmentRoutes); // 부서 관리
|
||||||
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리
|
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -115,7 +115,7 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
|
||||||
export async function getEntityOptions(req: AuthenticatedRequest, res: Response) {
|
export async function getEntityOptions(req: AuthenticatedRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const { tableName } = req.params;
|
const { tableName } = req.params;
|
||||||
const { value = "id", label = "name" } = req.query;
|
const { value = "id", label = "name", fields } = req.query;
|
||||||
|
|
||||||
// tableName 유효성 검증
|
// tableName 유효성 검증
|
||||||
if (!tableName || tableName === "undefined" || tableName === "null") {
|
if (!tableName || tableName === "undefined" || tableName === "null") {
|
||||||
|
|
@ -167,9 +167,21 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response)
|
||||||
? `WHERE ${whereConditions.join(" AND ")}`
|
? `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개)
|
// 쿼리 실행 (최대 500개)
|
||||||
const query = `
|
const query = `
|
||||||
SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label
|
SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label${extraColumns}
|
||||||
FROM ${tableName}
|
FROM ${tableName}
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY ${effectiveLabelColumn} ASC
|
ORDER BY ${effectiveLabelColumn} ASC
|
||||||
|
|
@ -184,6 +196,7 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response)
|
||||||
labelColumn: effectiveLabelColumn,
|
labelColumn: effectiveLabelColumn,
|
||||||
companyCode,
|
companyCode,
|
||||||
rowCount: result.rowCount,
|
rowCount: result.rowCount,
|
||||||
|
extraFields: extraColumns ? true : false,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|
|
||||||
|
|
@ -958,13 +958,14 @@ export async function addTableData(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데이터 추가
|
// 데이터 추가
|
||||||
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,
|
success: true,
|
||||||
message: "테이블 데이터를 성공적으로 추가했습니다.",
|
message: "테이블 데이터를 성공적으로 추가했습니다.",
|
||||||
|
data: { id: result.insertedId },
|
||||||
};
|
};
|
||||||
|
|
||||||
res.status(201).json(response);
|
res.status(201).json(response);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -2636,7 +2636,7 @@ export class TableManagementService {
|
||||||
async addTableData(
|
async addTableData(
|
||||||
tableName: string,
|
tableName: string,
|
||||||
data: Record<string, any>
|
data: Record<string, any>
|
||||||
): Promise<{ skippedColumns: string[]; savedColumns: string[] }> {
|
): Promise<{ skippedColumns: string[]; savedColumns: string[]; insertedId: string | null }> {
|
||||||
try {
|
try {
|
||||||
logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`);
|
logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`);
|
||||||
logger.info(`추가할 데이터:`, data);
|
logger.info(`추가할 데이터:`, data);
|
||||||
|
|
@ -2749,19 +2749,21 @@ export class TableManagementService {
|
||||||
const insertQuery = `
|
const insertQuery = `
|
||||||
INSERT INTO "${tableName}" (${columnNames})
|
INSERT INTO "${tableName}" (${columnNames})
|
||||||
VALUES (${placeholders})
|
VALUES (${placeholders})
|
||||||
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
|
|
||||||
logger.info(`실행할 쿼리: ${insertQuery}`);
|
logger.info(`실행할 쿼리: ${insertQuery}`);
|
||||||
logger.info(`쿼리 파라미터:`, values);
|
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 {
|
return {
|
||||||
skippedColumns,
|
skippedColumns,
|
||||||
savedColumns: existingColumns,
|
savedColumns: existingColumns,
|
||||||
|
insertedId,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`테이블 데이터 추가 오류: ${tableName}`, error);
|
logger.error(`테이블 데이터 추가 오류: ${tableName}`, error);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useEffect, ReactNode } from "react";
|
import { useEffect, ReactNode } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { AuthLogger } from "@/lib/authLogger";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
interface AuthGuardProps {
|
interface AuthGuardProps {
|
||||||
|
|
@ -41,11 +42,13 @@ export function AuthGuard({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requireAuth && !isLoggedIn) {
|
if (requireAuth && !isLoggedIn) {
|
||||||
|
AuthLogger.log("AUTH_GUARD_BLOCK", `인증 필요하지만 비로그인 상태 → ${redirectTo} 리다이렉트`);
|
||||||
router.push(redirectTo);
|
router.push(redirectTo);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requireAdmin && !isAdmin) {
|
if (requireAdmin && !isAdmin) {
|
||||||
|
AuthLogger.log("AUTH_GUARD_BLOCK", `관리자 권한 필요하지만 일반 사용자 → ${redirectTo} 리다이렉트`);
|
||||||
router.push(redirectTo);
|
router.push(redirectTo);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3968,10 +3968,10 @@ export default function ScreenDesigner({
|
||||||
label: column.columnLabel || column.columnName,
|
label: column.columnLabel || column.columnName,
|
||||||
tableName: table.tableName,
|
tableName: table.tableName,
|
||||||
columnName: column.columnName,
|
columnName: column.columnName,
|
||||||
required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님
|
required: isEntityJoinColumn ? false : column.required,
|
||||||
readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용
|
readonly: false,
|
||||||
parentId: formContainerId, // 폼 컨테이너의 자식으로 설정
|
parentId: formContainerId,
|
||||||
componentType: v2Mapping.componentType, // v2-input, v2-select 등
|
componentType: v2Mapping.componentType,
|
||||||
position: { x: relativeX, y: relativeY, z: 1 } as Position,
|
position: { x: relativeX, y: relativeY, z: 1 } as Position,
|
||||||
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
||||||
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
|
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
|
||||||
|
|
@ -3995,12 +3995,11 @@ export default function ScreenDesigner({
|
||||||
},
|
},
|
||||||
componentConfig: {
|
componentConfig: {
|
||||||
type: v2Mapping.componentType, // v2-input, v2-select 등
|
type: v2Mapping.componentType, // v2-input, v2-select 등
|
||||||
...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정
|
...v2Mapping.componentConfig,
|
||||||
...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return; // 폼 컨테이너를 찾을 수 없으면 드롭 취소
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 일반 캔버스에 드롭한 경우 - 🆕 V2 컴포넌트 시스템 사용
|
// 일반 캔버스에 드롭한 경우 - 🆕 V2 컴포넌트 시스템 사용
|
||||||
|
|
@ -4036,9 +4035,9 @@ export default function ScreenDesigner({
|
||||||
label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명
|
label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명
|
||||||
tableName: table.tableName,
|
tableName: table.tableName,
|
||||||
columnName: column.columnName,
|
columnName: column.columnName,
|
||||||
required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님
|
required: isEntityJoinColumn ? false : column.required,
|
||||||
readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용
|
readonly: false,
|
||||||
componentType: v2Mapping.componentType, // v2-input, v2-select 등
|
componentType: v2Mapping.componentType,
|
||||||
position: { x, y, z: 1 } as Position,
|
position: { x, y, z: 1 } as Position,
|
||||||
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
||||||
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
|
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
|
||||||
|
|
@ -4062,8 +4061,7 @@ export default function ScreenDesigner({
|
||||||
},
|
},
|
||||||
componentConfig: {
|
componentConfig: {
|
||||||
type: v2Mapping.componentType, // v2-input, v2-select 등
|
type: v2Mapping.componentType, // v2-input, v2-select 등
|
||||||
...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정
|
...v2Mapping.componentConfig,
|
||||||
...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
"v2-biz": require("@/components/v2/config-panels/V2BizConfigPanel").V2BizConfigPanel,
|
"v2-biz": require("@/components/v2/config-panels/V2BizConfigPanel").V2BizConfigPanel,
|
||||||
"v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel").V2HierarchyConfigPanel,
|
"v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel").V2HierarchyConfigPanel,
|
||||||
"v2-bom-item-editor": require("@/components/v2/config-panels/V2BomItemEditorConfigPanel").V2BomItemEditorConfigPanel,
|
"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];
|
const V2ConfigPanel = v2ConfigPanels[componentId];
|
||||||
|
|
@ -240,7 +241,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||||
if (componentId === "v2-list") {
|
if (componentId === "v2-list") {
|
||||||
extraProps.currentTableName = currentTableName;
|
extraProps.currentTableName = currentTableName;
|
||||||
}
|
}
|
||||||
if (componentId === "v2-bom-item-editor") {
|
if (componentId === "v2-bom-item-editor" || componentId === "v2-bom-tree") {
|
||||||
extraProps.currentTableName = currentTableName;
|
extraProps.currentTableName = currentTableName;
|
||||||
extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
|
extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -622,6 +622,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||||
config: configProp,
|
config: configProp,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
onFormDataChange,
|
||||||
tableName,
|
tableName,
|
||||||
columnName,
|
columnName,
|
||||||
isDesignMode, // 🔧 디자인 모드 (클릭 방지)
|
isDesignMode, // 🔧 디자인 모드 (클릭 방지)
|
||||||
|
|
@ -630,6 +631,9 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||||
// config가 없으면 기본값 사용
|
// config가 없으면 기본값 사용
|
||||||
const config = configProp || { mode: "dropdown" as const, source: "static" as const, options: [] };
|
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 [options, setOptions] = useState<SelectOption[]>(config.options || []);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [optionsLoaded, setOptionsLoaded] = useState(false);
|
const [optionsLoaded, setOptionsLoaded] = useState(false);
|
||||||
|
|
@ -742,10 +746,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||||
const valueCol = entityValueColumn || "id";
|
const valueCol = entityValueColumn || "id";
|
||||||
const labelCol = entityLabelColumn || "name";
|
const labelCol = entityLabelColumn || "name";
|
||||||
const response = await apiClient.get(`/entity/${entityTable}/options`, {
|
const response = await apiClient.get(`/entity/${entityTable}/options`, {
|
||||||
params: {
|
params: { value: valueCol, label: labelCol },
|
||||||
value: valueCol,
|
|
||||||
label: labelCol,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (data.success && data.data) {
|
if (data.success && data.data) {
|
||||||
|
|
@ -819,6 +820,70 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||||
loadOptions();
|
loadOptions();
|
||||||
}, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue]);
|
}, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue]);
|
||||||
|
|
||||||
|
// 같은 폼에서 참조 테이블(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 = () => {
|
const renderSelect = () => {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
@ -876,12 +941,12 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||||
|
|
||||||
switch (config.mode) {
|
switch (config.mode) {
|
||||||
case "dropdown":
|
case "dropdown":
|
||||||
case "combobox": // 🔧 콤보박스는 검색 가능한 드롭다운
|
case "combobox":
|
||||||
return (
|
return (
|
||||||
<DropdownSelect
|
<DropdownSelect
|
||||||
options={options}
|
options={options}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={handleChangeWithAutoFill}
|
||||||
placeholder="선택"
|
placeholder="선택"
|
||||||
searchable={config.mode === "combobox" ? true : config.searchable}
|
searchable={config.mode === "combobox" ? true : config.searchable}
|
||||||
multiple={config.multiple}
|
multiple={config.multiple}
|
||||||
|
|
@ -897,18 +962,18 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||||
<RadioSelect
|
<RadioSelect
|
||||||
options={options}
|
options={options}
|
||||||
value={typeof value === "string" ? value : value?.[0]}
|
value={typeof value === "string" ? value : value?.[0]}
|
||||||
onChange={(v) => onChange?.(v)}
|
onChange={(v) => handleChangeWithAutoFill(v)}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "check":
|
case "check":
|
||||||
case "checkbox": // 🔧 기존 저장된 값 호환
|
case "checkbox":
|
||||||
return (
|
return (
|
||||||
<CheckSelect
|
<CheckSelect
|
||||||
options={options}
|
options={options}
|
||||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||||
onChange={onChange}
|
onChange={handleChangeWithAutoFill}
|
||||||
maxSelect={config.maxSelect}
|
maxSelect={config.maxSelect}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
|
|
@ -919,7 +984,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||||
<TagSelect
|
<TagSelect
|
||||||
options={options}
|
options={options}
|
||||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||||
onChange={onChange}
|
onChange={handleChangeWithAutoFill}
|
||||||
maxSelect={config.maxSelect}
|
maxSelect={config.maxSelect}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
|
|
@ -930,7 +995,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||||
<TagboxSelect
|
<TagboxSelect
|
||||||
options={options}
|
options={options}
|
||||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||||
onChange={onChange}
|
onChange={handleChangeWithAutoFill}
|
||||||
placeholder={config.placeholder || "선택하세요"}
|
placeholder={config.placeholder || "선택하세요"}
|
||||||
maxSelect={config.maxSelect}
|
maxSelect={config.maxSelect}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
|
|
@ -943,7 +1008,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||||
<ToggleSelect
|
<ToggleSelect
|
||||||
options={options}
|
options={options}
|
||||||
value={typeof value === "string" ? value : value?.[0]}
|
value={typeof value === "string" ? value : value?.[0]}
|
||||||
onChange={(v) => onChange?.(v)}
|
onChange={(v) => handleChangeWithAutoFill(v)}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -953,7 +1018,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||||
<SwapSelect
|
<SwapSelect
|
||||||
options={options}
|
options={options}
|
||||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||||
onChange={onChange}
|
onChange={handleChangeWithAutoFill}
|
||||||
maxSelect={config.maxSelect}
|
maxSelect={config.maxSelect}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
|
|
@ -964,7 +1029,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||||
<DropdownSelect
|
<DropdownSelect
|
||||||
options={options}
|
options={options}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={handleChangeWithAutoFill}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
style={heightStyle}
|
style={heightStyle}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -302,6 +302,15 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
|
||||||
테이블 컬럼을 조회할 수 없습니다. 테이블 타입 관리에서 참조 테이블을 설정해주세요.
|
테이블 컬럼을 조회할 수 없습니다. 테이블 타입 관리에서 참조 테이블을 설정해주세요.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 자동 채움 안내 */}
|
||||||
|
{config.entityTable && entityColumns.length > 0 && (
|
||||||
|
<div className="border-t pt-3">
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
같은 폼에 참조 테이블({config.entityTable})의 컬럼이 배치되어 있으면, 엔티티 선택 시 해당 필드가 자동으로 채워집니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { apiCall } from "@/lib/api/client";
|
import { apiCall } from "@/lib/api/client";
|
||||||
|
import { AuthLogger } from "@/lib/authLogger";
|
||||||
|
|
||||||
interface UserInfo {
|
interface UserInfo {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
@ -161,13 +162,15 @@ export const useAuth = () => {
|
||||||
|
|
||||||
const token = TokenManager.getToken();
|
const token = TokenManager.getToken();
|
||||||
if (!token || TokenManager.isTokenExpired(token)) {
|
if (!token || TokenManager.isTokenExpired(token)) {
|
||||||
|
AuthLogger.log("AUTH_CHECK_FAIL", `refreshUserData: 토큰 ${!token ? "없음" : "만료됨"}`);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 토큰이 유효하면 우선 인증된 상태로 설정
|
AuthLogger.log("AUTH_CHECK_START", "refreshUserData: API로 인증 상태 확인 시작");
|
||||||
|
|
||||||
setAuthStatus({
|
setAuthStatus({
|
||||||
isLoggedIn: true,
|
isLoggedIn: true,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
|
|
@ -186,15 +189,16 @@ export const useAuth = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
setAuthStatus(finalAuthStatus);
|
setAuthStatus(finalAuthStatus);
|
||||||
|
AuthLogger.log("AUTH_CHECK_SUCCESS", `사용자: ${userInfo.userId}, 인증: ${finalAuthStatus.isLoggedIn}`);
|
||||||
|
|
||||||
// API 결과가 비인증이면 상태만 업데이트 (리다이렉트는 client.ts가 처리)
|
|
||||||
if (!finalAuthStatus.isLoggedIn) {
|
if (!finalAuthStatus.isLoggedIn) {
|
||||||
|
AuthLogger.log("AUTH_CHECK_FAIL", "API 응답에서 비인증 상태 반환 → 토큰 제거");
|
||||||
TokenManager.removeToken();
|
TokenManager.removeToken();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// userInfo 조회 실패 → 토큰에서 최소 정보 추출하여 유지
|
AuthLogger.log("AUTH_CHECK_FAIL", "userInfo 조회 실패 → 토큰 기반 임시 인증 유지 시도");
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||||
const tempUser: UserInfo = {
|
const tempUser: UserInfo = {
|
||||||
|
|
@ -210,14 +214,14 @@ export const useAuth = () => {
|
||||||
isAdmin: tempUser.isAdmin,
|
isAdmin: tempUser.isAdmin,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// 토큰 파싱도 실패하면 비인증 상태로 전환
|
AuthLogger.log("AUTH_CHECK_FAIL", "토큰 파싱 실패 → 비인증 전환");
|
||||||
TokenManager.removeToken();
|
TokenManager.removeToken();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// API 호출 전체 실패 → 토큰 기반 임시 인증 유지 시도
|
AuthLogger.log("AUTH_CHECK_FAIL", "API 호출 전체 실패 → 토큰 기반 임시 인증 유지 시도");
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||||
const tempUser: UserInfo = {
|
const tempUser: UserInfo = {
|
||||||
|
|
@ -233,6 +237,7 @@ export const useAuth = () => {
|
||||||
isAdmin: tempUser.isAdmin,
|
isAdmin: tempUser.isAdmin,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
AuthLogger.log("AUTH_CHECK_FAIL", "최종 fallback 실패 → 비인증 전환");
|
||||||
TokenManager.removeToken();
|
TokenManager.removeToken();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||||
|
|
@ -408,19 +413,19 @@ export const useAuth = () => {
|
||||||
const token = TokenManager.getToken();
|
const token = TokenManager.getToken();
|
||||||
|
|
||||||
if (token && !TokenManager.isTokenExpired(token)) {
|
if (token && !TokenManager.isTokenExpired(token)) {
|
||||||
// 유효한 토큰 → 우선 인증 상태로 설정 후 API 확인
|
AuthLogger.log("AUTH_CHECK_START", `초기 인증 확인: 유효한 토큰 존재 (경로: ${window.location.pathname})`);
|
||||||
setAuthStatus({
|
setAuthStatus({
|
||||||
isLoggedIn: true,
|
isLoggedIn: true,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
});
|
});
|
||||||
refreshUserData();
|
refreshUserData();
|
||||||
} else if (token && TokenManager.isTokenExpired(token)) {
|
} else if (token && TokenManager.isTokenExpired(token)) {
|
||||||
// 만료된 토큰 → 정리 (리다이렉트는 AuthGuard에서 처리)
|
AuthLogger.log("TOKEN_EXPIRED_DETECTED", `초기 확인 시 만료된 토큰 발견 → 정리 (경로: ${window.location.pathname})`);
|
||||||
TokenManager.removeToken();
|
TokenManager.removeToken();
|
||||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} else {
|
} else {
|
||||||
// 토큰 없음 → 비인증 상태 (리다이렉트는 AuthGuard에서 처리)
|
AuthLogger.log("AUTH_CHECK_FAIL", `초기 확인: 토큰 없음 (경로: ${window.location.pathname})`);
|
||||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { MenuItem, MenuState } from "@/types/menu";
|
import { MenuItem, MenuState } from "@/types/menu";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { AuthLogger } from "@/lib/authLogger";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 메뉴 관련 비즈니스 로직을 관리하는 커스텀 훅
|
* 메뉴 관련 비즈니스 로직을 관리하는 커스텀 훅
|
||||||
|
|
@ -84,8 +85,8 @@ export const useMenu = (user: any, authLoading: boolean) => {
|
||||||
} else {
|
} else {
|
||||||
setMenuState((prev: MenuState) => ({ ...prev, isLoading: false }));
|
setMenuState((prev: MenuState) => ({ ...prev, isLoading: false }));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
// API 실패 시 빈 메뉴로 유지 (401은 client.ts 인터셉터가 리다이렉트 처리)
|
AuthLogger.log("MENU_LOAD_FAIL", `메뉴 로드 실패: ${err?.response?.status || err?.message || "unknown"}`);
|
||||||
setMenuState((prev: MenuState) => ({ ...prev, isLoading: false }));
|
setMenuState((prev: MenuState) => ({ ...prev, isLoading: false }));
|
||||||
}
|
}
|
||||||
}, [convertToUpperCaseKeys, buildMenuTree]);
|
}, [convertToUpperCaseKeys, buildMenuTree]);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,14 @@
|
||||||
import axios, { AxiosResponse, AxiosError, InternalAxiosRequestConfig } from "axios";
|
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 동적 설정 - 환경변수 우선 사용
|
// API URL 동적 설정 - 환경변수 우선 사용
|
||||||
const getApiBaseUrl = (): string => {
|
const getApiBaseUrl = (): string => {
|
||||||
|
|
@ -149,9 +159,12 @@ const refreshToken = async (): Promise<string | null> => {
|
||||||
try {
|
try {
|
||||||
const currentToken = TokenManager.getToken();
|
const currentToken = TokenManager.getToken();
|
||||||
if (!currentToken) {
|
if (!currentToken) {
|
||||||
|
authLog("TOKEN_REFRESH_FAIL", "갱신 시도했으나 토큰 자체가 없음");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authLog("TOKEN_REFRESH_START", `남은시간: ${Math.round(TokenManager.getTimeUntilExpiry(currentToken) / 60000)}분`);
|
||||||
|
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${API_BASE_URL}/auth/refresh`,
|
`${API_BASE_URL}/auth/refresh`,
|
||||||
{},
|
{},
|
||||||
|
|
@ -165,10 +178,13 @@ const refreshToken = async (): Promise<string | null> => {
|
||||||
if (response.data?.success && response.data?.data?.token) {
|
if (response.data?.success && response.data?.data?.token) {
|
||||||
const newToken = response.data.data.token;
|
const newToken = response.data.data.token;
|
||||||
TokenManager.setToken(newToken);
|
TokenManager.setToken(newToken);
|
||||||
|
authLog("TOKEN_REFRESH_SUCCESS", "토큰 갱신 완료");
|
||||||
return newToken;
|
return newToken;
|
||||||
}
|
}
|
||||||
|
authLog("TOKEN_REFRESH_FAIL", `API 응답 실패: success=${response.data?.success}`);
|
||||||
return null;
|
return null;
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
|
authLog("TOKEN_REFRESH_FAIL", `API 호출 에러: ${err?.response?.status || err?.message || "unknown"}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -210,16 +226,21 @@ const setupVisibilityRefresh = (): void => {
|
||||||
document.addEventListener("visibilitychange", () => {
|
document.addEventListener("visibilitychange", () => {
|
||||||
if (!document.hidden) {
|
if (!document.hidden) {
|
||||||
const token = TokenManager.getToken();
|
const token = TokenManager.getToken();
|
||||||
if (!token) return;
|
if (!token) {
|
||||||
|
authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 없음");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (TokenManager.isTokenExpired(token)) {
|
if (TokenManager.isTokenExpired(token)) {
|
||||||
// 만료됐으면 갱신 시도
|
authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 만료 감지 → 갱신 시도");
|
||||||
refreshToken().then((newToken) => {
|
refreshToken().then((newToken) => {
|
||||||
if (!newToken) {
|
if (!newToken) {
|
||||||
|
authLog("REDIRECT_TO_LOGIN", "탭 복귀 후 토큰 갱신 실패로 리다이렉트");
|
||||||
redirectToLogin();
|
redirectToLogin();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (TokenManager.isTokenExpiringSoon(token)) {
|
} else if (TokenManager.isTokenExpiringSoon(token)) {
|
||||||
|
authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 만료 임박 → 갱신 시도");
|
||||||
refreshToken();
|
refreshToken();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -268,6 +289,7 @@ const redirectToLogin = (): void => {
|
||||||
if (isRedirecting) return;
|
if (isRedirecting) return;
|
||||||
if (window.location.pathname === "/login") return;
|
if (window.location.pathname === "/login") return;
|
||||||
|
|
||||||
|
authLog("REDIRECT_TO_LOGIN", `리다이렉트 실행 (from: ${window.location.pathname})`);
|
||||||
isRedirecting = true;
|
isRedirecting = true;
|
||||||
TokenManager.removeToken();
|
TokenManager.removeToken();
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
|
|
@ -301,15 +323,13 @@ apiClient.interceptors.request.use(
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
if (!TokenManager.isTokenExpired(token)) {
|
if (!TokenManager.isTokenExpired(token)) {
|
||||||
// 유효한 토큰 → 그대로 사용
|
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
} else {
|
} else {
|
||||||
// 만료된 토큰 → 갱신 시도 후 사용
|
authLog("TOKEN_EXPIRED_DETECTED", `요청 전 토큰 만료 감지 (${config.url}) → 갱신 시도`);
|
||||||
const newToken = await refreshToken();
|
const newToken = await refreshToken();
|
||||||
if (newToken) {
|
if (newToken) {
|
||||||
config.headers.Authorization = `Bearer ${newToken}`;
|
config.headers.Authorization = `Bearer ${newToken}`;
|
||||||
}
|
}
|
||||||
// 갱신 실패해도 요청은 보냄 (401 응답 인터셉터에서 처리)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -378,12 +398,16 @@ apiClient.interceptors.response.use(
|
||||||
|
|
||||||
// 401 에러 처리 (핵심 개선)
|
// 401 에러 처리 (핵심 개선)
|
||||||
if (status === 401 && typeof window !== "undefined") {
|
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 errorCode = errorData?.error?.code;
|
||||||
|
const errorDetails = errorData?.error?.details;
|
||||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||||
|
|
||||||
|
authLog("API_401_RECEIVED", `URL: ${url} | 코드: ${errorCode || "없음"} | 상세: ${errorDetails || "없음"}`);
|
||||||
|
|
||||||
// 이미 재시도한 요청이면 로그인으로
|
// 이미 재시도한 요청이면 로그인으로
|
||||||
if (originalRequest?._retry) {
|
if (originalRequest?._retry) {
|
||||||
|
authLog("REDIRECT_TO_LOGIN", `재시도 후에도 401 (${url}) → 로그인 리다이렉트`);
|
||||||
redirectToLogin();
|
redirectToLogin();
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
@ -395,6 +419,7 @@ apiClient.interceptors.response.use(
|
||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
authLog("API_401_RETRY", `토큰 만료로 갱신 후 재시도 (${url})`);
|
||||||
const newToken = await refreshToken();
|
const newToken = await refreshToken();
|
||||||
if (newToken) {
|
if (newToken) {
|
||||||
isRefreshing = false;
|
isRefreshing = false;
|
||||||
|
|
@ -404,17 +429,18 @@ apiClient.interceptors.response.use(
|
||||||
} else {
|
} else {
|
||||||
isRefreshing = false;
|
isRefreshing = false;
|
||||||
onRefreshFailed(new Error("토큰 갱신 실패"));
|
onRefreshFailed(new Error("토큰 갱신 실패"));
|
||||||
|
authLog("REDIRECT_TO_LOGIN", `토큰 갱신 실패 (${url}) → 로그인 리다이렉트`);
|
||||||
redirectToLogin();
|
redirectToLogin();
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
isRefreshing = false;
|
isRefreshing = false;
|
||||||
onRefreshFailed(refreshError as Error);
|
onRefreshFailed(refreshError as Error);
|
||||||
|
authLog("REDIRECT_TO_LOGIN", `토큰 갱신 예외 (${url}) → 로그인 리다이렉트`);
|
||||||
redirectToLogin();
|
redirectToLogin();
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 다른 요청이 이미 갱신 중 → 갱신 완료 대기 후 재시도
|
|
||||||
try {
|
try {
|
||||||
const newToken = await waitForTokenRefresh();
|
const newToken = await waitForTokenRefresh();
|
||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
|
|
@ -427,6 +453,7 @@ apiClient.interceptors.response.use(
|
||||||
}
|
}
|
||||||
|
|
||||||
// TOKEN_MISSING, INVALID_TOKEN 등 → 로그인으로
|
// TOKEN_MISSING, INVALID_TOKEN 등 → 로그인으로
|
||||||
|
authLog("REDIRECT_TO_LOGIN", `복구 불가능한 인증 에러 (${errorCode || "UNKNOWN"}, ${url}) → 로그인 리다이렉트`);
|
||||||
redirectToLogin();
|
redirectToLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -8,6 +8,76 @@ import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||||
|
|
||||||
// 통합 폼 시스템 import
|
// 통합 폼 시스템 import
|
||||||
import { useV2FormOptional } from "@/components/v2/V2FormContext";
|
import { useV2FormOptional } from "@/components/v2/V2FormContext";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
|
// 컬럼 메타데이터 캐시 (테이블명 → 컬럼 설정 맵)
|
||||||
|
const columnMetaCache: Record<string, Record<string, any>> = {};
|
||||||
|
const columnMetaLoading: Record<string, Promise<void>> = {};
|
||||||
|
|
||||||
|
async function loadColumnMeta(tableName: string): Promise<void> {
|
||||||
|
if (columnMetaCache[tableName] || 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 {
|
export interface ComponentRenderer {
|
||||||
|
|
@ -175,6 +245,15 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
// 컬럼 메타데이터 로드 트리거 (테이블명이 있으면 비동기 로드)
|
||||||
|
const screenTableName = props.tableName || (component as any).tableName;
|
||||||
|
const [, forceUpdate] = React.useState(0);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (screenTableName && !columnMetaCache[screenTableName]) {
|
||||||
|
loadColumnMeta(screenTableName).then(() => forceUpdate((v) => v + 1));
|
||||||
|
}
|
||||||
|
}, [screenTableName]);
|
||||||
|
|
||||||
// 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용
|
// 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용
|
||||||
// 🆕 V2 레이아웃의 경우 url에서 컴포넌트 타입 추출 (예: "@/lib/registry/components/v2-input" → "v2-input")
|
// 🆕 V2 레이아웃의 경우 url에서 컴포넌트 타입 추출 (예: "@/lib/registry/components/v2-input" → "v2-input")
|
||||||
const extractTypeFromUrl = (url: string | undefined): string | undefined => {
|
const extractTypeFromUrl = (url: string | undefined): string | undefined => {
|
||||||
|
|
@ -551,24 +630,34 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
height: finalStyle.height,
|
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 = {
|
const rendererProps = {
|
||||||
component,
|
component: effectiveComponent,
|
||||||
isSelected,
|
isSelected,
|
||||||
onClick,
|
onClick,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
size: component.size || newComponent.defaultSize,
|
size: component.size || newComponent.defaultSize,
|
||||||
position: component.position,
|
position: component.position,
|
||||||
config: component.componentConfig,
|
config: mergedComponentConfig,
|
||||||
componentConfig: component.componentConfig,
|
componentConfig: mergedComponentConfig,
|
||||||
// componentConfig의 모든 속성을 props로 spread (tableName, displayField 등)
|
// componentConfig의 모든 속성을 props로 spread (tableName, displayField 등)
|
||||||
...(component.componentConfig || {}),
|
...(mergedComponentConfig || {}),
|
||||||
// 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선)
|
// 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선)
|
||||||
style: mergedStyle,
|
style: mergedStyle,
|
||||||
// 🆕 라벨 표시 (labelDisplay가 true일 때만)
|
// 🆕 라벨 표시 (labelDisplay가 true일 때만)
|
||||||
label: effectiveLabel,
|
label: effectiveLabel,
|
||||||
// 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달
|
// 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 (DB 메타데이터 우선)
|
||||||
inputType: (component as any).inputType || component.componentConfig?.inputType,
|
inputType: (baseColumnName && columnMetaCache[screenTableName || ""]?.[baseColumnName]?.input_type) || (component as any).inputType || mergedComponentConfig?.inputType,
|
||||||
columnName: (component as any).columnName || component.componentConfig?.columnName,
|
columnName: (component as any).columnName || component.componentConfig?.columnName,
|
||||||
value: currentValue, // formData에서 추출한 현재 값 전달
|
value: currentValue, // formData에서 추출한 현재 값 전달
|
||||||
// 새로운 기능들 전달
|
// 새로운 기능들 전달
|
||||||
|
|
@ -608,9 +697,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
// componentConfig.mode가 있으면 유지 (entity-search-input의 UI 모드)
|
// componentConfig.mode가 있으면 유지 (entity-search-input의 UI 모드)
|
||||||
mode: component.componentConfig?.mode || mode,
|
mode: component.componentConfig?.mode || mode,
|
||||||
isInModal,
|
isInModal,
|
||||||
readonly: component.readonly,
|
readonly: isEntityJoinColumn ? false : component.readonly,
|
||||||
// 🆕 disabledFields 체크 또는 기존 readonly
|
disabled: isEntityJoinColumn ? false : (disabledFields?.includes(fieldName) || component.readonly),
|
||||||
disabled: disabledFields?.includes(fieldName) || component.readonly,
|
|
||||||
originalData,
|
originalData,
|
||||||
allComponents,
|
allComponents,
|
||||||
onUpdateLayout,
|
onUpdateLayout,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -26,6 +27,7 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
@ -82,7 +84,7 @@ const generateTempId = () => `temp_${Date.now()}_${++tempIdCounter}`;
|
||||||
interface ItemSearchModalProps {
|
interface ItemSearchModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSelect: (item: ItemInfo) => void;
|
onSelect: (items: ItemInfo[]) => void;
|
||||||
companyCode?: string;
|
companyCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,6 +96,7 @@ function ItemSearchModal({
|
||||||
}: ItemSearchModalProps) {
|
}: ItemSearchModalProps) {
|
||||||
const [searchText, setSearchText] = useState("");
|
const [searchText, setSearchText] = useState("");
|
||||||
const [items, setItems] = useState<ItemInfo[]>([]);
|
const [items, setItems] = useState<ItemInfo[]>([]);
|
||||||
|
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const searchItems = useCallback(
|
const searchItems = useCallback(
|
||||||
|
|
@ -109,7 +112,7 @@ function ItemSearchModal({
|
||||||
enableEntityJoin: true,
|
enableEntityJoin: true,
|
||||||
companyCodeOverride: companyCode,
|
companyCodeOverride: companyCode,
|
||||||
});
|
});
|
||||||
setItems(result.data || []);
|
setItems((result.data || []) as ItemInfo[]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[BomItemEditor] 품목 검색 실패:", error);
|
console.error("[BomItemEditor] 품목 검색 실패:", error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -122,6 +125,7 @@ function ItemSearchModal({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setSearchText("");
|
setSearchText("");
|
||||||
|
setSelectedItems(new Set());
|
||||||
searchItems("");
|
searchItems("");
|
||||||
}
|
}
|
||||||
}, [open, searchItems]);
|
}, [open, searchItems]);
|
||||||
|
|
@ -180,6 +184,15 @@ function ItemSearchModal({
|
||||||
<table className="w-full text-xs sm:text-sm">
|
<table className="w-full text-xs sm:text-sm">
|
||||||
<thead className="bg-muted/50 sticky top-0">
|
<thead className="bg-muted/50 sticky top-0">
|
||||||
<tr>
|
<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>
|
<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
|
<tr
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSelect(item);
|
setSelectedItems((prev) => {
|
||||||
onClose();
|
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">
|
<td className="px-3 py-2 font-mono">
|
||||||
{item.item_number}
|
{item.item_number}
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -208,6 +241,25 @@ function ItemSearchModal({
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|
@ -227,6 +279,10 @@ interface TreeNodeRowProps {
|
||||||
onFieldChange: (tempId: string, field: string, value: string) => void;
|
onFieldChange: (tempId: string, field: string, value: string) => void;
|
||||||
onDelete: (tempId: string) => void;
|
onDelete: (tempId: string) => void;
|
||||||
onAddChild: (parentTempId: 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({
|
function TreeNodeRow({
|
||||||
|
|
@ -241,6 +297,10 @@ function TreeNodeRow({
|
||||||
onFieldChange,
|
onFieldChange,
|
||||||
onDelete,
|
onDelete,
|
||||||
onAddChild,
|
onAddChild,
|
||||||
|
onDragStart,
|
||||||
|
onDragOver,
|
||||||
|
onDrop,
|
||||||
|
isDragOver,
|
||||||
}: TreeNodeRowProps) {
|
}: TreeNodeRowProps) {
|
||||||
const indentPx = depth * 32;
|
const indentPx = depth * 32;
|
||||||
const visibleColumns = columns.filter((c) => c.visible !== false);
|
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",
|
"group flex items-center gap-2 rounded-md border px-2 py-1.5",
|
||||||
"transition-colors hover:bg-accent/30",
|
"transition-colors hover:bg-accent/30",
|
||||||
depth > 0 && "ml-2 border-l-2 border-l-primary/20",
|
depth > 0 && "ml-2 border-l-2 border-l-primary/20",
|
||||||
|
isDragOver && "border-primary bg-primary/5 border-dashed",
|
||||||
)}
|
)}
|
||||||
style={{ marginLeft: `${indentPx}px` }}
|
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" />
|
<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 cfg = useMemo(() => component?.componentConfig || {}, [component]);
|
||||||
const mainTableName = cfg.mainTableName || "bom_detail";
|
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 columns: BomColumnConfig[] = useMemo(() => cfg.columns || [], [cfg.columns]);
|
||||||
const visibleColumns = useMemo(() => columns.filter((c) => c.visible !== false), [columns]);
|
const visibleColumns = useMemo(() => columns.filter((c) => c.visible !== false), [columns]);
|
||||||
const fkColumn = cfg.foreignKeyColumn || "bom_id";
|
const fkColumn = cfg.foreignKeyColumn || "bom_id";
|
||||||
|
|
@ -431,7 +496,14 @@ export function BomItemEditorComponent({
|
||||||
|
|
||||||
for (const col of categoryColumns) {
|
for (const col of categoryColumns) {
|
||||||
const categoryRef = `${mainTableName}.${col.key}`;
|
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 {
|
try {
|
||||||
const response = await apiClient.get(`/table-categories/${mainTableName}/${col.key}/values`);
|
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(
|
const loadBomDetails = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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, {
|
const result = await entityJoinApi.getTableDataWithJoins(mainTableName, {
|
||||||
page: 1,
|
page: 1,
|
||||||
size: 500,
|
size: 500,
|
||||||
|
|
@ -467,9 +551,20 @@ export function BomItemEditorComponent({
|
||||||
sortBy: "seq_no",
|
sortBy: "seq_no",
|
||||||
sortOrder: "asc",
|
sortOrder: "asc",
|
||||||
enableEntityJoin: true,
|
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);
|
const tree = buildTree(rows);
|
||||||
setTreeData(tree);
|
setTreeData(tree);
|
||||||
|
|
||||||
|
|
@ -483,7 +578,7 @@ export function BomItemEditorComponent({
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mainTableName, fkColumn],
|
[mainTableName, fkColumn, sourceFk, sourceTable, columns],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -548,10 +643,13 @@ export function BomItemEditorComponent({
|
||||||
id: node.id,
|
id: node.id,
|
||||||
tempId: node.tempId,
|
tempId: node.tempId,
|
||||||
[parentKeyColumn]: parentId,
|
[parentKeyColumn]: parentId,
|
||||||
|
[fkColumn]: bomId,
|
||||||
seq_no: String(idx + 1),
|
seq_no: String(idx + 1),
|
||||||
level: String(level),
|
level: String(level),
|
||||||
_isNew: node._isNew,
|
_isNew: node._isNew,
|
||||||
_targetTable: mainTableName,
|
_targetTable: mainTableName,
|
||||||
|
_fkColumn: fkColumn,
|
||||||
|
_deferSave: true,
|
||||||
});
|
});
|
||||||
if (node.children.length > 0) {
|
if (node.children.length > 0) {
|
||||||
traverse(node.children, node.id || node.tempId, level + 1);
|
traverse(node.children, node.id || node.tempId, level + 1);
|
||||||
|
|
@ -560,7 +658,7 @@ export function BomItemEditorComponent({
|
||||||
};
|
};
|
||||||
traverse(nodes, null, 0);
|
traverse(nodes, null, 0);
|
||||||
return result;
|
return result;
|
||||||
}, [parentKeyColumn, mainTableName]);
|
}, [parentKeyColumn, mainTableName, fkColumn, bomId]);
|
||||||
|
|
||||||
// 트리 변경 시 부모에게 알림
|
// 트리 변경 시 부모에게 알림
|
||||||
const notifyChange = useCallback(
|
const notifyChange = useCallback(
|
||||||
|
|
@ -627,16 +725,17 @@ export function BomItemEditorComponent({
|
||||||
setItemSearchOpen(true);
|
setItemSearchOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 품목 선택 후 추가 (동적 데이터)
|
// 품목 선택 후 추가 (다중 선택 지원)
|
||||||
const handleItemSelect = useCallback(
|
const handleItemSelect = useCallback(
|
||||||
(item: ItemInfo) => {
|
(selectedItemsList: ItemInfo[]) => {
|
||||||
// 소스 테이블 데이터를 _display_ 접두사로 저장 (엔티티 조인 방식)
|
let newTree = [...treeData];
|
||||||
|
|
||||||
|
for (const item of selectedItemsList) {
|
||||||
const sourceData: Record<string, any> = {};
|
const sourceData: Record<string, any> = {};
|
||||||
const sourceTable = cfg.dataSource?.sourceTable;
|
const sourceTable = cfg.dataSource?.sourceTable;
|
||||||
if (sourceTable) {
|
if (sourceTable) {
|
||||||
const sourceFk = cfg.dataSource?.foreignKey || "child_item_id";
|
const sourceFk = cfg.dataSource?.foreignKey || "child_item_id";
|
||||||
sourceData[sourceFk] = item.id;
|
sourceData[sourceFk] = item.id;
|
||||||
// 소스 표시 컬럼의 데이터 병합
|
|
||||||
Object.keys(item).forEach((key) => {
|
Object.keys(item).forEach((key) => {
|
||||||
sourceData[`_display_${key}`] = (item as any)[key];
|
sourceData[`_display_${key}`] = (item as any)[key];
|
||||||
sourceData[key] = (item as any)[key];
|
sourceData[key] = (item as any)[key];
|
||||||
|
|
@ -658,14 +757,12 @@ export function BomItemEditorComponent({
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let newTree: BomItemNode[];
|
|
||||||
|
|
||||||
if (addTargetParentId === null) {
|
if (addTargetParentId === null) {
|
||||||
newNode.seq_no = treeData.length + 1;
|
newNode.seq_no = newTree.length + 1;
|
||||||
newNode.level = 0;
|
newNode.level = 0;
|
||||||
newTree = [...treeData, newNode];
|
newTree = [...newTree, newNode];
|
||||||
} else {
|
} else {
|
||||||
newTree = findAndUpdate(treeData, addTargetParentId, (parent) => {
|
newTree = findAndUpdate(newTree, addTargetParentId, (parent) => {
|
||||||
newNode.parent_detail_id = parent.id || parent.tempId;
|
newNode.parent_detail_id = parent.id || parent.tempId;
|
||||||
newNode.seq_no = parent.children.length + 1;
|
newNode.seq_no = parent.children.length + 1;
|
||||||
newNode.level = parent.level + 1;
|
newNode.level = parent.level + 1;
|
||||||
|
|
@ -674,6 +771,10 @@ export function BomItemEditorComponent({
|
||||||
children: [...parent.children, newNode],
|
children: [...parent.children, newNode],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addTargetParentId !== null) {
|
||||||
setExpandedNodes((prev) => new Set([...prev, addTargetParentId]));
|
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) => {
|
const renderNodes = (nodes: BomItemNode[], depth: number) => {
|
||||||
|
|
@ -711,6 +907,10 @@ export function BomItemEditorComponent({
|
||||||
onFieldChange={handleFieldChange}
|
onFieldChange={handleFieldChange}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onAddChild={handleAddChild}
|
onAddChild={handleAddChild}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
isDragOver={dragOverId === node.tempId}
|
||||||
/>
|
/>
|
||||||
{isExpanded &&
|
{isExpanded &&
|
||||||
node.children.length > 0 &&
|
node.children.length > 0 &&
|
||||||
|
|
@ -898,7 +1098,7 @@ export function BomItemEditorComponent({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 트리 목록 */}
|
{/* 트리 목록 */}
|
||||||
<div className="space-y-1">
|
<div className="max-h-[400px] space-y-1 overflow-y-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<span className="text-muted-foreground text-sm">로딩 중...</span>
|
<span className="text-muted-foreground text-sm">로딩 중...</span>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,7 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
|
||||||
static componentDefinition = V2SelectDefinition;
|
static componentDefinition = V2SelectDefinition;
|
||||||
|
|
||||||
render(): React.ReactElement {
|
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 || {};
|
const config = component.componentConfig || component.config || {};
|
||||||
|
|
@ -107,8 +107,7 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
|
||||||
// 디버깅 필요시 주석 해제
|
// 디버깅 필요시 주석 해제
|
||||||
// console.log("🔍 [V2SelectRenderer]", { componentId: component.id, effectiveStyle, effectiveSize });
|
// console.log("🔍 [V2SelectRenderer]", { componentId: component.id, effectiveStyle, effectiveSize });
|
||||||
|
|
||||||
// 🔧 restProps에서 style, size 제외 (effectiveStyle/effectiveSize가 우선되어야 함)
|
const { style: _style, size: _size, allComponents: _allComp, ...restPropsClean } = restProps as any;
|
||||||
const { style: _style, size: _size, ...restPropsClean } = restProps as any;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<V2Select
|
<V2Select
|
||||||
|
|
@ -119,9 +118,10 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
|
||||||
disabled={config.disabled || component.disabled}
|
disabled={config.disabled || component.disabled}
|
||||||
value={currentValue}
|
value={currentValue}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
onFormDataChange={isInteractive ? onFormDataChange : undefined}
|
||||||
|
allComponents={allComponents}
|
||||||
config={{
|
config={{
|
||||||
mode: config.mode || "dropdown",
|
mode: config.mode || "dropdown",
|
||||||
// 🔧 카테고리 타입이면 source를 무조건 "category"로 강제 (테이블 타입 관리 설정 우선)
|
|
||||||
source: isCategoryType ? "category" : (config.source || "distinct"),
|
source: isCategoryType ? "category" : (config.source || "distinct"),
|
||||||
multiple: config.multiple || false,
|
multiple: config.multiple || false,
|
||||||
searchable: config.searchable ?? true,
|
searchable: config.searchable ?? true,
|
||||||
|
|
@ -131,7 +131,6 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
|
||||||
entityTable: config.entityTable,
|
entityTable: config.entityTable,
|
||||||
entityLabelColumn: config.entityLabelColumn,
|
entityLabelColumn: config.entityLabelColumn,
|
||||||
entityValueColumn: config.entityValueColumn,
|
entityValueColumn: config.entityValueColumn,
|
||||||
// 🔧 카테고리 소스 지원 (tableName, columnName 폴백)
|
|
||||||
categoryTable: config.categoryTable || (isCategoryType ? tableName : undefined),
|
categoryTable: config.categoryTable || (isCategoryType ? tableName : undefined),
|
||||||
categoryColumn: config.categoryColumn || (isCategoryType ? columnName : undefined),
|
categoryColumn: config.categoryColumn || (isCategoryType ? columnName : undefined),
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -1300,6 +1300,9 @@ export class ButtonActionExecutor {
|
||||||
// _targetTable이 없거나, _repeatScreenModal_ 키면 스킵 (다른 로직에서 처리)
|
// _targetTable이 없거나, _repeatScreenModal_ 키면 스킵 (다른 로직에서 처리)
|
||||||
if (!repeaterTargetTable || fieldKey.startsWith("_repeatScreenModal_")) continue;
|
if (!repeaterTargetTable || fieldKey.startsWith("_repeatScreenModal_")) continue;
|
||||||
|
|
||||||
|
// _deferSave 플래그가 있으면 메인 저장 후 처리 (마스터-디테일 순차 저장)
|
||||||
|
if (firstItem?._deferSave) continue;
|
||||||
|
|
||||||
// 🆕 V2Repeater가 등록된 테이블이면 RepeaterFieldGroup 저장 스킵
|
// 🆕 V2Repeater가 등록된 테이블이면 RepeaterFieldGroup 저장 스킵
|
||||||
// V2Repeater가 repeaterSave 이벤트로 저장 처리함
|
// V2Repeater가 repeaterSave 이벤트로 저장 처리함
|
||||||
// @ts-ignore - window에 동적 속성 사용
|
// @ts-ignore - window에 동적 속성 사용
|
||||||
|
|
@ -1390,6 +1393,7 @@ export class ButtonActionExecutor {
|
||||||
_existingRecord: __,
|
_existingRecord: __,
|
||||||
_originalItemIds: ___,
|
_originalItemIds: ___,
|
||||||
_deletedItemIds: ____,
|
_deletedItemIds: ____,
|
||||||
|
_fkColumn: itemFkColumn,
|
||||||
...dataToSave
|
...dataToSave
|
||||||
} = item;
|
} = item;
|
||||||
|
|
||||||
|
|
@ -1398,12 +1402,18 @@ export class ButtonActionExecutor {
|
||||||
delete dataToSave.id;
|
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> = {
|
const dataWithMeta: Record<string, unknown> = {
|
||||||
...dataToSave, // RepeaterFieldGroup의 개별 항목 데이터
|
...dataToSave,
|
||||||
...commonFields, // 범용 폼 모달의 공통 필드 (outbound_status 등) - 공통 필드가 우선!
|
...commonFields,
|
||||||
created_by: context.userId,
|
created_by: context.userId,
|
||||||
updated_by: context.userId,
|
updated_by: context.userId,
|
||||||
company_code: context.companyCode,
|
company_code: context.companyCode,
|
||||||
|
|
@ -1781,6 +1791,65 @@ export class ButtonActionExecutor {
|
||||||
// 🔧 formData를 리피터에 전달하여 각 행에 병합 저장
|
// 🔧 formData를 리피터에 전달하여 각 행에 병합 저장
|
||||||
const savedId = saveResult?.data?.id || saveResult?.data?.data?.id || formData.id || context.formData?.id;
|
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 = {
|
const mainFormData = {
|
||||||
...formData,
|
...formData,
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,8 @@ export interface V2SelectProps extends V2BaseProps {
|
||||||
config: V2SelectConfig;
|
config: V2SelectConfig;
|
||||||
value?: string | string[];
|
value?: string | string[];
|
||||||
onChange?: (value: string | string[]) => void;
|
onChange?: (value: string | string[]) => void;
|
||||||
|
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||||
|
formData?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== V2Date =====
|
// ===== V2Date =====
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue