Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into ycshin-node
Made-with: Cursor # Conflicts: # frontend/lib/registry/DynamicComponentRenderer.tsx # frontend/lib/registry/components/index.ts # frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx # frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx
This commit is contained in:
commit
c0c5806004
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -39,16 +39,18 @@ export async function getItemsWithRouting(req: AuthenticatedRequest, res: Respon
|
||||||
if (search) params.push(`%${search}%`);
|
if (search) params.push(`%${search}%`);
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
SELECT DISTINCT
|
SELECT
|
||||||
i.id,
|
i.id,
|
||||||
i.${nameColumn} AS item_name,
|
i.${nameColumn} AS item_name,
|
||||||
i.${codeColumn} AS item_code
|
i.${codeColumn} AS item_code,
|
||||||
|
COUNT(rv.id) AS routing_count
|
||||||
FROM ${tableName} i
|
FROM ${tableName} i
|
||||||
INNER JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
|
LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
|
||||||
AND rv.company_code = i.company_code
|
AND rv.company_code = i.company_code
|
||||||
WHERE i.company_code = $1
|
WHERE i.company_code = $1
|
||||||
${searchCondition}
|
${searchCondition}
|
||||||
ORDER BY i.${codeColumn}
|
GROUP BY i.id, i.${nameColumn}, i.${codeColumn}, i.created_date
|
||||||
|
ORDER BY i.created_date DESC NULLS LAST
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await getPool().query(query, params);
|
const result = await getPool().query(query, params);
|
||||||
|
|
@ -82,10 +84,10 @@ export async function getRoutingsWithProcesses(req: AuthenticatedRequest, res: R
|
||||||
|
|
||||||
// 라우팅 버전 목록
|
// 라우팅 버전 목록
|
||||||
const versionsQuery = `
|
const versionsQuery = `
|
||||||
SELECT id, version_name, description, created_date
|
SELECT id, version_name, description, created_date, COALESCE(is_default, false) AS is_default
|
||||||
FROM ${routingVersionTable}
|
FROM ${routingVersionTable}
|
||||||
WHERE ${routingFkColumn} = $1 AND company_code = $2
|
WHERE ${routingFkColumn} = $1 AND company_code = $2
|
||||||
ORDER BY created_date DESC
|
ORDER BY is_default DESC, created_date DESC
|
||||||
`;
|
`;
|
||||||
const versionsResult = await getPool().query(versionsQuery, [
|
const versionsResult = await getPool().query(versionsQuery, [
|
||||||
itemCode,
|
itemCode,
|
||||||
|
|
@ -127,6 +129,92 @@ export async function getRoutingsWithProcesses(req: AuthenticatedRequest, res: R
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 기본 버전 설정
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 라우팅 버전을 기본 버전으로 설정
|
||||||
|
* 같은 품목의 다른 버전은 기본 해제
|
||||||
|
*/
|
||||||
|
export async function setDefaultVersion(req: AuthenticatedRequest, res: Response) {
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { versionId } = req.params;
|
||||||
|
const {
|
||||||
|
routingVersionTable = "item_routing_version",
|
||||||
|
routingFkColumn = "item_code",
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
const versionResult = await client.query(
|
||||||
|
`SELECT ${routingFkColumn} AS item_code FROM ${routingVersionTable} WHERE id = $1 AND company_code = $2`,
|
||||||
|
[versionId, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (versionResult.rowCount === 0) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
return res.status(404).json({ success: false, message: "버전을 찾을 수 없습니다" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemCode = versionResult.rows[0].item_code;
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE ${routingVersionTable} SET is_default = false WHERE ${routingFkColumn} = $1 AND company_code = $2`,
|
||||||
|
[itemCode, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE ${routingVersionTable} SET is_default = true WHERE id = $1 AND company_code = $2`,
|
||||||
|
[versionId, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
|
||||||
|
logger.info("기본 버전 설정", { companyCode, versionId, itemCode });
|
||||||
|
return res.json({ success: true, message: "기본 버전이 설정되었습니다" });
|
||||||
|
} catch (error: any) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
logger.error("기본 버전 설정 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 버전 해제
|
||||||
|
*/
|
||||||
|
export async function unsetDefaultVersion(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { versionId } = req.params;
|
||||||
|
const { routingVersionTable = "item_routing_version" } = req.body;
|
||||||
|
|
||||||
|
await getPool().query(
|
||||||
|
`UPDATE ${routingVersionTable} SET is_default = false WHERE id = $1 AND company_code = $2`,
|
||||||
|
[versionId, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("기본 버전 해제", { companyCode, versionId });
|
||||||
|
return res.json({ success: true, message: "기본 버전이 해제되었습니다" });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("기본 버전 해제 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 작업 항목 CRUD
|
// 작업 항목 CRUD
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
@ -330,7 +418,10 @@ export async function getWorkItemDetails(req: AuthenticatedRequest, res: Respons
|
||||||
const { workItemId } = req.params;
|
const { workItemId } = req.params;
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark, created_date
|
SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark,
|
||||||
|
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||||
|
duration_minutes, input_type, lookup_target, display_fields,
|
||||||
|
created_date
|
||||||
FROM process_work_item_detail
|
FROM process_work_item_detail
|
||||||
WHERE work_item_id = $1 AND company_code = $2
|
WHERE work_item_id = $1 AND company_code = $2
|
||||||
ORDER BY sort_order, created_date
|
ORDER BY sort_order, created_date
|
||||||
|
|
@ -355,7 +446,11 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo
|
||||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { work_item_id, detail_type, content, is_required, sort_order, remark } = req.body;
|
const {
|
||||||
|
work_item_id, detail_type, content, is_required, sort_order, remark,
|
||||||
|
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||||
|
duration_minutes, input_type, lookup_target, display_fields,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
if (!work_item_id || !content) {
|
if (!work_item_id || !content) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
|
|
@ -375,8 +470,10 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
INSERT INTO process_work_item_detail
|
INSERT INTO process_work_item_detail
|
||||||
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer)
|
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||||
|
duration_minutes, input_type, lookup_target, display_fields)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -389,6 +486,15 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo
|
||||||
sort_order || 0,
|
sort_order || 0,
|
||||||
remark || null,
|
remark || null,
|
||||||
writer,
|
writer,
|
||||||
|
inspection_code || null,
|
||||||
|
inspection_method || null,
|
||||||
|
unit || null,
|
||||||
|
lower_limit || null,
|
||||||
|
upper_limit || null,
|
||||||
|
duration_minutes || null,
|
||||||
|
input_type || null,
|
||||||
|
lookup_target || null,
|
||||||
|
display_fields || null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
logger.info("작업 항목 상세 생성", { companyCode, id: result.rows[0].id });
|
logger.info("작업 항목 상세 생성", { companyCode, id: result.rows[0].id });
|
||||||
|
|
@ -410,7 +516,11 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { detail_type, content, is_required, sort_order, remark } = req.body;
|
const {
|
||||||
|
detail_type, content, is_required, sort_order, remark,
|
||||||
|
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||||
|
duration_minutes, input_type, lookup_target, display_fields,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
UPDATE process_work_item_detail
|
UPDATE process_work_item_detail
|
||||||
|
|
@ -419,6 +529,15 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo
|
||||||
is_required = COALESCE($3, is_required),
|
is_required = COALESCE($3, is_required),
|
||||||
sort_order = COALESCE($4, sort_order),
|
sort_order = COALESCE($4, sort_order),
|
||||||
remark = COALESCE($5, remark),
|
remark = COALESCE($5, remark),
|
||||||
|
inspection_code = $8,
|
||||||
|
inspection_method = $9,
|
||||||
|
unit = $10,
|
||||||
|
lower_limit = $11,
|
||||||
|
upper_limit = $12,
|
||||||
|
duration_minutes = $13,
|
||||||
|
input_type = $14,
|
||||||
|
lookup_target = $15,
|
||||||
|
display_fields = $16,
|
||||||
updated_date = NOW()
|
updated_date = NOW()
|
||||||
WHERE id = $6 AND company_code = $7
|
WHERE id = $6 AND company_code = $7
|
||||||
RETURNING *
|
RETURNING *
|
||||||
|
|
@ -432,6 +551,15 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo
|
||||||
remark,
|
remark,
|
||||||
id,
|
id,
|
||||||
companyCode,
|
companyCode,
|
||||||
|
inspection_code || null,
|
||||||
|
inspection_method || null,
|
||||||
|
unit || null,
|
||||||
|
lower_limit || null,
|
||||||
|
upper_limit || null,
|
||||||
|
duration_minutes || null,
|
||||||
|
input_type || null,
|
||||||
|
lookup_target || null,
|
||||||
|
display_fields || null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (result.rowCount === 0) {
|
if (result.rowCount === 0) {
|
||||||
|
|
@ -544,8 +672,10 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) {
|
||||||
for (const detail of item.details) {
|
for (const detail of item.details) {
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO process_work_item_detail
|
`INSERT INTO process_work_item_detail
|
||||||
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer)
|
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||||
|
duration_minutes, input_type, lookup_target, display_fields)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
|
||||||
[
|
[
|
||||||
companyCode,
|
companyCode,
|
||||||
workItemId,
|
workItemId,
|
||||||
|
|
@ -555,6 +685,15 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) {
|
||||||
detail.sort_order || 0,
|
detail.sort_order || 0,
|
||||||
detail.remark || null,
|
detail.remark || null,
|
||||||
writer,
|
writer,
|
||||||
|
detail.inspection_code || null,
|
||||||
|
detail.inspection_method || null,
|
||||||
|
detail.unit || null,
|
||||||
|
detail.lower_limit || null,
|
||||||
|
detail.upper_limit || null,
|
||||||
|
detail.duration_minutes || null,
|
||||||
|
detail.input_type || null,
|
||||||
|
detail.lookup_target || null,
|
||||||
|
detail.display_fields || null,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -921,14 +921,51 @@ export async function addTableData(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 회사별 NOT NULL 소프트 제약조건 검증
|
||||||
|
const notNullViolations = await tableManagementService.validateNotNullConstraints(
|
||||||
|
tableName,
|
||||||
|
data,
|
||||||
|
companyCode || "*"
|
||||||
|
);
|
||||||
|
if (notNullViolations.length > 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `필수 항목이 비어있습니다: ${notNullViolations.join(", ")}`,
|
||||||
|
error: {
|
||||||
|
code: "NOT_NULL_VIOLATION",
|
||||||
|
details: notNullViolations,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 회사별 UNIQUE 소프트 제약조건 검증
|
||||||
|
const uniqueViolations = await tableManagementService.validateUniqueConstraints(
|
||||||
|
tableName,
|
||||||
|
data,
|
||||||
|
companyCode || "*"
|
||||||
|
);
|
||||||
|
if (uniqueViolations.length > 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
|
||||||
|
error: {
|
||||||
|
code: "UNIQUE_VIOLATION",
|
||||||
|
details: uniqueViolations,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 데이터 추가
|
// 데이터 추가
|
||||||
await tableManagementService.addTableData(tableName, data);
|
const result = await tableManagementService.addTableData(tableName, data);
|
||||||
|
|
||||||
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
|
logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`);
|
||||||
|
|
||||||
const response: ApiResponse<null> = {
|
const response: ApiResponse<{ id: string | null }> = {
|
||||||
success: true,
|
success: true,
|
||||||
message: "테이블 데이터를 성공적으로 추가했습니다.",
|
message: "테이블 데이터를 성공적으로 추가했습니다.",
|
||||||
|
data: { id: result.insertedId },
|
||||||
};
|
};
|
||||||
|
|
||||||
res.status(201).json(response);
|
res.status(201).json(response);
|
||||||
|
|
@ -1003,6 +1040,45 @@ export async function editTableData(
|
||||||
}
|
}
|
||||||
|
|
||||||
const tableManagementService = new TableManagementService();
|
const tableManagementService = new TableManagementService();
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
// 회사별 NOT NULL 소프트 제약조건 검증 (수정 데이터 대상)
|
||||||
|
const notNullViolations = await tableManagementService.validateNotNullConstraints(
|
||||||
|
tableName,
|
||||||
|
updatedData,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
if (notNullViolations.length > 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `필수 항목이 비어있습니다: ${notNullViolations.join(", ")}`,
|
||||||
|
error: {
|
||||||
|
code: "NOT_NULL_VIOLATION",
|
||||||
|
details: notNullViolations,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 회사별 UNIQUE 소프트 제약조건 검증 (수정 시 자기 자신 제외)
|
||||||
|
const excludeId = originalData?.id ? String(originalData.id) : undefined;
|
||||||
|
const uniqueViolations = await tableManagementService.validateUniqueConstraints(
|
||||||
|
tableName,
|
||||||
|
updatedData,
|
||||||
|
companyCode,
|
||||||
|
excludeId
|
||||||
|
);
|
||||||
|
if (uniqueViolations.length > 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
|
||||||
|
error: {
|
||||||
|
code: "UNIQUE_VIOLATION",
|
||||||
|
details: uniqueViolations,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 데이터 수정
|
// 데이터 수정
|
||||||
await tableManagementService.editTableData(
|
await tableManagementService.editTableData(
|
||||||
|
|
@ -2616,8 +2692,22 @@ export async function toggleTableIndex(
|
||||||
logger.info(`인덱스 ${action}: ${indexName} (${indexType})`);
|
logger.info(`인덱스 ${action}: ${indexName} (${indexType})`);
|
||||||
|
|
||||||
if (action === "create") {
|
if (action === "create") {
|
||||||
|
let indexColumns = `"${columnName}"`;
|
||||||
|
|
||||||
|
// 유니크 인덱스: company_code 컬럼이 있으면 복합 유니크 (회사별 유니크 보장)
|
||||||
|
if (indexType === "unique") {
|
||||||
|
const hasCompanyCode = await query(
|
||||||
|
`SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 AND column_name = 'company_code'`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
if (hasCompanyCode.length > 0) {
|
||||||
|
indexColumns = `"company_code", "${columnName}"`;
|
||||||
|
logger.info(`멀티테넌시: company_code + ${columnName} 복합 유니크 인덱스 생성`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const uniqueClause = indexType === "unique" ? "UNIQUE " : "";
|
const uniqueClause = indexType === "unique" ? "UNIQUE " : "";
|
||||||
const sql = `CREATE ${uniqueClause}INDEX "${indexName}" ON "public"."${tableName}" ("${columnName}")`;
|
const sql = `CREATE ${uniqueClause}INDEX IF NOT EXISTS "${indexName}" ON "public"."${tableName}" (${indexColumns})`;
|
||||||
logger.info(`인덱스 생성: ${sql}`);
|
logger.info(`인덱스 생성: ${sql}`);
|
||||||
await query(sql);
|
await query(sql);
|
||||||
} else if (action === "drop") {
|
} else if (action === "drop") {
|
||||||
|
|
@ -2638,22 +2728,55 @@ export async function toggleTableIndex(
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error("인덱스 토글 오류:", error);
|
logger.error("인덱스 토글 오류:", error);
|
||||||
|
|
||||||
// 중복 데이터로 인한 UNIQUE 인덱스 생성 실패 안내
|
const errMsg = error.message || "";
|
||||||
const errorMsg = error.message?.includes("duplicate key")
|
let userMessage = "인덱스 설정 중 오류가 발생했습니다.";
|
||||||
? "중복 데이터가 있어 UNIQUE 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요."
|
let duplicates: any[] = [];
|
||||||
: "인덱스 설정 중 오류가 발생했습니다.";
|
|
||||||
|
// 중복 데이터로 인한 UNIQUE 인덱스 생성 실패
|
||||||
|
if (
|
||||||
|
errMsg.includes("could not create unique index") ||
|
||||||
|
errMsg.includes("duplicate key")
|
||||||
|
) {
|
||||||
|
const { columnName, tableName } = { ...req.params, ...req.body };
|
||||||
|
try {
|
||||||
|
duplicates = await query(
|
||||||
|
`SELECT company_code, "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY company_code, "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
duplicates = await query(
|
||||||
|
`SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10`
|
||||||
|
);
|
||||||
|
} catch { /* 중복 조회 실패 시 무시 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const dupDetails = duplicates.length > 0
|
||||||
|
? duplicates.map((d: any) => {
|
||||||
|
const company = d.company_code ? `[${d.company_code}] ` : "";
|
||||||
|
return `${company}"${d[columnName] ?? 'NULL'}" (${d.cnt}건)`;
|
||||||
|
}).join(", ")
|
||||||
|
: "";
|
||||||
|
|
||||||
|
userMessage = dupDetails
|
||||||
|
? `[${columnName}] 컬럼에 같은 회사 내 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 값: ${dupDetails}`
|
||||||
|
: `[${columnName}] 컬럼에 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요.`;
|
||||||
|
}
|
||||||
|
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: errorMsg,
|
message: userMessage,
|
||||||
error: error instanceof Error ? error.message : "Unknown error",
|
error: errMsg,
|
||||||
|
duplicates,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NOT NULL 토글
|
* NOT NULL 토글 (회사별 소프트 제약조건)
|
||||||
* PUT /api/table-management/tables/:tableName/columns/:columnName/nullable
|
* PUT /api/table-management/tables/:tableName/columns/:columnName/nullable
|
||||||
|
*
|
||||||
|
* DB 레벨 ALTER TABLE 대신 table_type_columns.is_nullable을 회사별로 관리한다.
|
||||||
|
* 멀티테넌시 환경에서 회사 A는 NOT NULL, 회사 B는 NULL 허용이 가능하다.
|
||||||
*/
|
*/
|
||||||
export async function toggleColumnNullable(
|
export async function toggleColumnNullable(
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
|
|
@ -2662,6 +2785,7 @@ export async function toggleColumnNullable(
|
||||||
try {
|
try {
|
||||||
const { tableName, columnName } = req.params;
|
const { tableName, columnName } = req.params;
|
||||||
const { nullable } = req.body;
|
const { nullable } = req.body;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
if (!tableName || !columnName || typeof nullable !== "boolean") {
|
if (!tableName || !columnName || typeof nullable !== "boolean") {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
|
|
@ -2671,18 +2795,54 @@ export async function toggleColumnNullable(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nullable) {
|
// is_nullable 값: 'Y' = NULL 허용, 'N' = NOT NULL
|
||||||
// NOT NULL 해제
|
const isNullableValue = nullable ? "Y" : "N";
|
||||||
const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" DROP NOT NULL`;
|
|
||||||
logger.info(`NOT NULL 해제: ${sql}`);
|
if (!nullable) {
|
||||||
await query(sql);
|
// NOT NULL 설정 전 - 해당 회사의 기존 데이터에 NULL이 있는지 확인
|
||||||
} else {
|
const hasCompanyCode = await query<{ column_name: string }>(
|
||||||
// NOT NULL 설정
|
`SELECT column_name FROM information_schema.columns
|
||||||
const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" SET NOT NULL`;
|
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||||
logger.info(`NOT NULL 설정: ${sql}`);
|
[tableName]
|
||||||
await query(sql);
|
);
|
||||||
|
|
||||||
|
if (hasCompanyCode.length > 0) {
|
||||||
|
const nullCheckQuery = companyCode === "*"
|
||||||
|
? `SELECT COUNT(*) as null_count FROM "${tableName}" WHERE "${columnName}" IS NULL`
|
||||||
|
: `SELECT COUNT(*) as null_count FROM "${tableName}" WHERE "${columnName}" IS NULL AND company_code = $1`;
|
||||||
|
const nullCheckParams = companyCode === "*" ? [] : [companyCode];
|
||||||
|
|
||||||
|
const nullCheckResult = await query<{ null_count: string }>(nullCheckQuery, nullCheckParams);
|
||||||
|
const nullCount = parseInt(nullCheckResult[0]?.null_count || "0", 10);
|
||||||
|
|
||||||
|
if (nullCount > 0) {
|
||||||
|
logger.warn(`NOT NULL 설정 불가 - 해당 회사에 NULL 데이터 존재: ${tableName}.${columnName}`, {
|
||||||
|
companyCode,
|
||||||
|
nullCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `현재 회사 데이터에 NULL 값이 ${nullCount}건 존재합니다. NULL 데이터를 먼저 정리해주세요.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// table_type_columns에 회사별 is_nullable 설정 UPSERT
|
||||||
|
await query(
|
||||||
|
`INSERT INTO table_type_columns (table_name, column_name, is_nullable, company_code, created_date, updated_date)
|
||||||
|
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||||
|
ON CONFLICT (table_name, column_name, company_code)
|
||||||
|
DO UPDATE SET is_nullable = $3, updated_date = NOW()`,
|
||||||
|
[tableName, columnName, isNullableValue, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`NOT NULL 소프트 제약조건 변경: ${tableName}.${columnName} → is_nullable=${isNullableValue}`, {
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: nullable
|
message: nullable
|
||||||
|
|
@ -2692,14 +2852,95 @@ export async function toggleColumnNullable(
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error("NOT NULL 토글 오류:", error);
|
logger.error("NOT NULL 토글 오류:", error);
|
||||||
|
|
||||||
// NULL 데이터가 있는 컬럼에 NOT NULL 설정 시 안내
|
|
||||||
const errorMsg = error.message?.includes("contains null values")
|
|
||||||
? "해당 컬럼에 NULL 값이 있어 NOT NULL 설정이 불가합니다. NULL 데이터를 먼저 정리해주세요."
|
|
||||||
: "NOT NULL 설정 중 오류가 발생했습니다.";
|
|
||||||
|
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: errorMsg,
|
message: "NOT NULL 설정 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UNIQUE 토글 (회사별 소프트 제약조건)
|
||||||
|
* PUT /api/table-management/tables/:tableName/columns/:columnName/unique
|
||||||
|
*
|
||||||
|
* DB 레벨 인덱스 대신 table_type_columns.is_unique를 회사별로 관리한다.
|
||||||
|
* 저장 시 앱 레벨에서 중복 검증을 수행한다.
|
||||||
|
*/
|
||||||
|
export async function toggleColumnUnique(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { tableName, columnName } = req.params;
|
||||||
|
const { unique } = req.body;
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
|
||||||
|
if (!tableName || !columnName || typeof unique !== "boolean") {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "tableName, columnName, unique(boolean)이 필요합니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUniqueValue = unique ? "Y" : "N";
|
||||||
|
|
||||||
|
if (unique) {
|
||||||
|
// UNIQUE 설정 전 - 해당 회사의 기존 데이터에 중복이 있는지 확인
|
||||||
|
const hasCompanyCode = await query<{ column_name: string }>(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasCompanyCode.length > 0) {
|
||||||
|
const dupQuery = companyCode === "*"
|
||||||
|
? `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10`
|
||||||
|
: `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL AND company_code = $1 GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10`;
|
||||||
|
const dupParams = companyCode === "*" ? [] : [companyCode];
|
||||||
|
|
||||||
|
const dupResult = await query<any>(dupQuery, dupParams);
|
||||||
|
|
||||||
|
if (dupResult.length > 0) {
|
||||||
|
const dupDetails = dupResult
|
||||||
|
.map((d: any) => `"${d[columnName]}" (${d.cnt}건)`)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `현재 회사 데이터에 중복 값이 존재합니다. 중복 데이터를 먼저 정리해주세요. 중복 값: ${dupDetails}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// table_type_columns에 회사별 is_unique 설정 UPSERT
|
||||||
|
await query(
|
||||||
|
`INSERT INTO table_type_columns (table_name, column_name, is_unique, company_code, created_date, updated_date)
|
||||||
|
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||||
|
ON CONFLICT (table_name, column_name, company_code)
|
||||||
|
DO UPDATE SET is_unique = $3, updated_date = NOW()`,
|
||||||
|
[tableName, columnName, isUniqueValue, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`UNIQUE 소프트 제약조건 변경: ${tableName}.${columnName} → is_unique=${isUniqueValue}`, {
|
||||||
|
companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: unique
|
||||||
|
? `${columnName} 컬럼이 UNIQUE로 설정되었습니다.`
|
||||||
|
: `${columnName} 컬럼의 UNIQUE 제약이 해제되었습니다.`,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("UNIQUE 토글 오류:", error);
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "UNIQUE 설정 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "Unknown error",
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -3,14 +3,21 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
import * as ctrl from "../controllers/processWorkStandardController";
|
import * as ctrl from "../controllers/processWorkStandardController";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
// 품목/라우팅/공정 조회 (좌측 트리)
|
// 품목/라우팅/공정 조회 (좌측 트리)
|
||||||
router.get("/items", ctrl.getItemsWithRouting);
|
router.get("/items", ctrl.getItemsWithRouting);
|
||||||
router.get("/items/:itemCode/routings", ctrl.getRoutingsWithProcesses);
|
router.get("/items/:itemCode/routings", ctrl.getRoutingsWithProcesses);
|
||||||
|
|
||||||
|
// 기본 버전 설정/해제
|
||||||
|
router.put("/versions/:versionId/set-default", ctrl.setDefaultVersion);
|
||||||
|
router.put("/versions/:versionId/unset-default", ctrl.unsetDefaultVersion);
|
||||||
|
|
||||||
// 작업 항목 CRUD
|
// 작업 항목 CRUD
|
||||||
router.get("/routing-detail/:routingDetailId/work-items", ctrl.getWorkItems);
|
router.get("/routing-detail/:routingDetailId/work-items", ctrl.getWorkItems);
|
||||||
router.post("/work-items", ctrl.createWorkItem);
|
router.post("/work-items", ctrl.createWorkItem);
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import {
|
||||||
setTablePrimaryKey, // 🆕 PK 설정
|
setTablePrimaryKey, // 🆕 PK 설정
|
||||||
toggleTableIndex, // 🆕 인덱스 토글
|
toggleTableIndex, // 🆕 인덱스 토글
|
||||||
toggleColumnNullable, // 🆕 NOT NULL 토글
|
toggleColumnNullable, // 🆕 NOT NULL 토글
|
||||||
|
toggleColumnUnique, // 🆕 UNIQUE 토글
|
||||||
} from "../controllers/tableManagementController";
|
} from "../controllers/tableManagementController";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -161,6 +162,12 @@ router.post("/tables/:tableName/indexes", toggleTableIndex);
|
||||||
*/
|
*/
|
||||||
router.put("/tables/:tableName/columns/:columnName/nullable", toggleColumnNullable);
|
router.put("/tables/:tableName/columns/:columnName/nullable", toggleColumnNullable);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UNIQUE 토글
|
||||||
|
* PUT /api/table-management/tables/:tableName/columns/:columnName/unique
|
||||||
|
*/
|
||||||
|
router.put("/tables/:tableName/columns/:columnName/unique", toggleColumnUnique);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블 존재 여부 확인
|
* 테이블 존재 여부 확인
|
||||||
* GET /api/table-management/tables/:tableName/exists
|
* GET /api/table-management/tables/:tableName/exists
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,35 @@ interface NumberingRulePart {
|
||||||
autoConfig?: any;
|
autoConfig?: any;
|
||||||
manualConfig?: any;
|
manualConfig?: any;
|
||||||
generatedValue?: string;
|
generatedValue?: string;
|
||||||
|
separatorAfter?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파트 배열에서 autoConfig.separatorAfter를 파트 레벨로 추출
|
||||||
|
*/
|
||||||
|
function extractSeparatorAfterFromParts(parts: any[]): any[] {
|
||||||
|
return parts.map((part) => {
|
||||||
|
if (part.autoConfig?.separatorAfter !== undefined) {
|
||||||
|
part.separatorAfter = part.autoConfig.separatorAfter;
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파트별 개별 구분자를 사용하여 코드 결합
|
||||||
|
* 마지막 파트의 separatorAfter는 무시됨
|
||||||
|
*/
|
||||||
|
function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globalSeparator: string): string {
|
||||||
|
let result = "";
|
||||||
|
partValues.forEach((val, idx) => {
|
||||||
|
result += val;
|
||||||
|
if (idx < partValues.length - 1) {
|
||||||
|
const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator;
|
||||||
|
result += sep;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NumberingRuleConfig {
|
interface NumberingRuleConfig {
|
||||||
|
|
@ -141,7 +170,7 @@ class NumberingRuleService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const partsResult = await pool.query(partsQuery, partsParams);
|
const partsResult = await pool.query(partsQuery, partsParams);
|
||||||
rule.parts = partsResult.rows;
|
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}개`, {
|
logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}개`, {
|
||||||
|
|
@ -274,7 +303,7 @@ class NumberingRuleService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const partsResult = await pool.query(partsQuery, partsParams);
|
const partsResult = await pool.query(partsQuery, partsParams);
|
||||||
rule.parts = partsResult.rows;
|
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.rows;
|
return result.rows;
|
||||||
|
|
@ -381,7 +410,7 @@ class NumberingRuleService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const partsResult = await pool.query(partsQuery, partsParams);
|
const partsResult = await pool.query(partsQuery, partsParams);
|
||||||
rule.parts = partsResult.rows;
|
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||||
|
|
||||||
logger.info("✅ 규칙 파트 조회 성공", {
|
logger.info("✅ 규칙 파트 조회 성공", {
|
||||||
ruleId: rule.ruleId,
|
ruleId: rule.ruleId,
|
||||||
|
|
@ -517,7 +546,7 @@ class NumberingRuleService {
|
||||||
companyCode === "*" ? rule.companyCode : companyCode,
|
companyCode === "*" ? rule.companyCode : companyCode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
rule.parts = partsResult.rows;
|
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`화면용 채번 규칙 조회 완료: ${result.rows.length}개`, {
|
logger.info(`화면용 채번 규칙 조회 완료: ${result.rows.length}개`, {
|
||||||
|
|
@ -633,7 +662,7 @@ class NumberingRuleService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const partsResult = await pool.query(partsQuery, partsParams);
|
const partsResult = await pool.query(partsQuery, partsParams);
|
||||||
rule.parts = partsResult.rows;
|
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||||
|
|
||||||
return rule;
|
return rule;
|
||||||
}
|
}
|
||||||
|
|
@ -708,17 +737,25 @@ class NumberingRuleService {
|
||||||
manual_config AS "manualConfig"
|
manual_config AS "manualConfig"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// auto_config에 separatorAfter 포함
|
||||||
|
const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" };
|
||||||
|
|
||||||
const partResult = await client.query(insertPartQuery, [
|
const partResult = await client.query(insertPartQuery, [
|
||||||
config.ruleId,
|
config.ruleId,
|
||||||
part.order,
|
part.order,
|
||||||
part.partType,
|
part.partType,
|
||||||
part.generationMethod,
|
part.generationMethod,
|
||||||
JSON.stringify(part.autoConfig || {}),
|
JSON.stringify(autoConfigWithSep),
|
||||||
JSON.stringify(part.manualConfig || {}),
|
JSON.stringify(part.manualConfig || {}),
|
||||||
companyCode,
|
companyCode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
parts.push(partResult.rows[0]);
|
const savedPart = partResult.rows[0];
|
||||||
|
// autoConfig에서 separatorAfter를 추출하여 파트 레벨로 이동
|
||||||
|
if (savedPart.autoConfig?.separatorAfter !== undefined) {
|
||||||
|
savedPart.separatorAfter = savedPart.autoConfig.separatorAfter;
|
||||||
|
}
|
||||||
|
parts.push(savedPart);
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.query("COMMIT");
|
await client.query("COMMIT");
|
||||||
|
|
@ -820,17 +857,23 @@ class NumberingRuleService {
|
||||||
manual_config AS "manualConfig"
|
manual_config AS "manualConfig"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" };
|
||||||
|
|
||||||
const partResult = await client.query(insertPartQuery, [
|
const partResult = await client.query(insertPartQuery, [
|
||||||
ruleId,
|
ruleId,
|
||||||
part.order,
|
part.order,
|
||||||
part.partType,
|
part.partType,
|
||||||
part.generationMethod,
|
part.generationMethod,
|
||||||
JSON.stringify(part.autoConfig || {}),
|
JSON.stringify(autoConfigWithSep),
|
||||||
JSON.stringify(part.manualConfig || {}),
|
JSON.stringify(part.manualConfig || {}),
|
||||||
companyCode,
|
companyCode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
parts.push(partResult.rows[0]);
|
const savedPart = partResult.rows[0];
|
||||||
|
if (savedPart.autoConfig?.separatorAfter !== undefined) {
|
||||||
|
savedPart.separatorAfter = savedPart.autoConfig.separatorAfter;
|
||||||
|
}
|
||||||
|
parts.push(savedPart);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1053,7 +1096,8 @@ class NumberingRuleService {
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const previewCode = parts.join(rule.separator || "");
|
const sortedRuleParts = rule.parts.sort((a: any, b: any) => a.order - b.order);
|
||||||
|
const previewCode = joinPartsWithSeparators(parts, sortedRuleParts, rule.separator || "");
|
||||||
logger.info("코드 미리보기 생성", {
|
logger.info("코드 미리보기 생성", {
|
||||||
ruleId,
|
ruleId,
|
||||||
previewCode,
|
previewCode,
|
||||||
|
|
@ -1164,8 +1208,8 @@ class NumberingRuleService {
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const separator = rule.separator || "";
|
const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order);
|
||||||
const previewTemplate = previewParts.join(separator);
|
const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || "");
|
||||||
|
|
||||||
// 사용자 입력 코드에서 수동 입력 부분 추출
|
// 사용자 입력 코드에서 수동 입력 부분 추출
|
||||||
// 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출
|
// 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출
|
||||||
|
|
@ -1382,7 +1426,8 @@ class NumberingRuleService {
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const allocatedCode = parts.join(rule.separator || "");
|
const sortedPartsForAlloc = rule.parts.sort((a: any, b: any) => a.order - b.order);
|
||||||
|
const allocatedCode = joinPartsWithSeparators(parts, sortedPartsForAlloc, rule.separator || "");
|
||||||
|
|
||||||
// 순번이 있는 경우에만 증가
|
// 순번이 있는 경우에만 증가
|
||||||
const hasSequence = rule.parts.some(
|
const hasSequence = rule.parts.some(
|
||||||
|
|
@ -1541,7 +1586,7 @@ class NumberingRuleService {
|
||||||
rule.ruleId,
|
rule.ruleId,
|
||||||
companyCode === "*" ? rule.companyCode : companyCode,
|
companyCode === "*" ? rule.companyCode : companyCode,
|
||||||
]);
|
]);
|
||||||
rule.parts = partsResult.rows;
|
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("[테스트] 채번 규칙 목록 조회 완료", {
|
logger.info("[테스트] 채번 규칙 목록 조회 완료", {
|
||||||
|
|
@ -1634,7 +1679,7 @@ class NumberingRuleService {
|
||||||
rule.ruleId,
|
rule.ruleId,
|
||||||
companyCode,
|
companyCode,
|
||||||
]);
|
]);
|
||||||
rule.parts = partsResult.rows;
|
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||||
|
|
||||||
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", {
|
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", {
|
||||||
ruleId: rule.ruleId,
|
ruleId: rule.ruleId,
|
||||||
|
|
@ -1754,12 +1799,14 @@ class NumberingRuleService {
|
||||||
auto_config, manual_config, company_code, created_at
|
auto_config, manual_config, company_code, created_at
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||||
`;
|
`;
|
||||||
|
const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" };
|
||||||
|
|
||||||
await client.query(partInsertQuery, [
|
await client.query(partInsertQuery, [
|
||||||
config.ruleId,
|
config.ruleId,
|
||||||
part.order,
|
part.order,
|
||||||
part.partType,
|
part.partType,
|
||||||
part.generationMethod,
|
part.generationMethod,
|
||||||
JSON.stringify(part.autoConfig || {}),
|
JSON.stringify(autoConfigWithSep),
|
||||||
JSON.stringify(part.manualConfig || {}),
|
JSON.stringify(part.manualConfig || {}),
|
||||||
companyCode,
|
companyCode,
|
||||||
]);
|
]);
|
||||||
|
|
@ -1914,7 +1961,7 @@ class NumberingRuleService {
|
||||||
rule.ruleId,
|
rule.ruleId,
|
||||||
companyCode,
|
companyCode,
|
||||||
]);
|
]);
|
||||||
rule.parts = partsResult.rows;
|
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||||
|
|
||||||
logger.info("카테고리 조건 매칭 채번 규칙 찾음", {
|
logger.info("카테고리 조건 매칭 채번 규칙 찾음", {
|
||||||
ruleId: rule.ruleId,
|
ruleId: rule.ruleId,
|
||||||
|
|
@ -1973,7 +2020,7 @@ class NumberingRuleService {
|
||||||
rule.ruleId,
|
rule.ruleId,
|
||||||
companyCode,
|
companyCode,
|
||||||
]);
|
]);
|
||||||
rule.parts = partsResult.rows;
|
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||||
|
|
||||||
logger.info("기본 채번 규칙 찾음 (카테고리 조건 없음)", {
|
logger.info("기본 채번 규칙 찾음 (카테고리 조건 없음)", {
|
||||||
ruleId: rule.ruleId,
|
ruleId: rule.ruleId,
|
||||||
|
|
@ -2056,7 +2103,7 @@ class NumberingRuleService {
|
||||||
rule.ruleId,
|
rule.ruleId,
|
||||||
companyCode,
|
companyCode,
|
||||||
]);
|
]);
|
||||||
rule.parts = partsResult.rows;
|
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.rows;
|
return result.rows;
|
||||||
|
|
|
||||||
|
|
@ -1721,18 +1721,28 @@ export class ScreenManagementService {
|
||||||
throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다.");
|
throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 V2 테이블 우선 조회 (회사별 → 공통(*))
|
// V2 테이블 우선 조회: 기본 레이어(layer_id=1)만 가져옴
|
||||||
|
// layer_id 필터 없이 queryOne 하면 조건부 레이어가 반환될 수 있음
|
||||||
let v2Layout = await queryOne<{ layout_data: any }>(
|
let v2Layout = await queryOne<{ layout_data: any }>(
|
||||||
`SELECT layout_data FROM screen_layouts_v2
|
`SELECT layout_data FROM screen_layouts_v2
|
||||||
WHERE screen_id = $1 AND company_code = $2`,
|
WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`,
|
||||||
[screenId, companyCode],
|
[screenId, companyCode],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 회사별 레이아웃 없으면 공통(*) 조회
|
// 최고관리자(*): 화면 정의의 company_code로 재조회
|
||||||
|
if (!v2Layout && companyCode === "*" && existingScreen.company_code && existingScreen.company_code !== "*") {
|
||||||
|
v2Layout = await queryOne<{ layout_data: any }>(
|
||||||
|
`SELECT layout_data FROM screen_layouts_v2
|
||||||
|
WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`,
|
||||||
|
[screenId, existingScreen.company_code],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 일반 사용자: 회사별 레이아웃 없으면 공통(*) 조회
|
||||||
if (!v2Layout && companyCode !== "*") {
|
if (!v2Layout && companyCode !== "*") {
|
||||||
v2Layout = await queryOne<{ layout_data: any }>(
|
v2Layout = await queryOne<{ layout_data: any }>(
|
||||||
`SELECT layout_data FROM screen_layouts_v2
|
`SELECT layout_data FROM screen_layouts_v2
|
||||||
WHERE screen_id = $1 AND company_code = '*'`,
|
WHERE screen_id = $1 AND company_code = '*' AND layer_id = 1`,
|
||||||
[screenId],
|
[screenId],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -5302,7 +5312,22 @@ export class ScreenManagementService {
|
||||||
[screenId, companyCode, layerId],
|
[screenId, companyCode, layerId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 회사별 레이어가 없으면 공통(*) 조회
|
// 최고관리자(*): 화면 정의의 company_code로 재조회
|
||||||
|
if (!layout && companyCode === "*") {
|
||||||
|
const screenDef = await queryOne<{ company_code: string }>(
|
||||||
|
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
|
||||||
|
[screenId],
|
||||||
|
);
|
||||||
|
if (screenDef && screenDef.company_code && screenDef.company_code !== "*") {
|
||||||
|
layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>(
|
||||||
|
`SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2
|
||||||
|
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
|
||||||
|
[screenId, screenDef.company_code, layerId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 일반 사용자: 회사별 레이어가 없으면 공통(*) 조회
|
||||||
if (!layout && companyCode !== "*") {
|
if (!layout && companyCode !== "*") {
|
||||||
layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>(
|
layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>(
|
||||||
`SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2
|
`SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,15 @@ export class TableManagementService {
|
||||||
cl.input_type as "cl_input_type",
|
cl.input_type as "cl_input_type",
|
||||||
COALESCE(ttc.detail_settings::text, cl.detail_settings::text, '') as "detailSettings",
|
COALESCE(ttc.detail_settings::text, cl.detail_settings::text, '') as "detailSettings",
|
||||||
COALESCE(ttc.description, cl.description, '') as "description",
|
COALESCE(ttc.description, cl.description, '') as "description",
|
||||||
c.is_nullable as "isNullable",
|
CASE
|
||||||
|
WHEN COALESCE(ttc.is_nullable, cl.is_nullable) IS NOT NULL
|
||||||
|
THEN CASE WHEN COALESCE(ttc.is_nullable, cl.is_nullable) = 'N' THEN 'NO' ELSE 'YES' END
|
||||||
|
ELSE c.is_nullable
|
||||||
|
END as "isNullable",
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(ttc.is_unique, cl.is_unique) = 'Y' THEN 'YES'
|
||||||
|
ELSE 'NO'
|
||||||
|
END as "isUnique",
|
||||||
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
|
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
|
||||||
c.column_default as "defaultValue",
|
c.column_default as "defaultValue",
|
||||||
c.character_maximum_length as "maxLength",
|
c.character_maximum_length as "maxLength",
|
||||||
|
|
@ -241,7 +249,15 @@ export class TableManagementService {
|
||||||
COALESCE(cl.input_type, 'direct') as "inputType",
|
COALESCE(cl.input_type, 'direct') as "inputType",
|
||||||
COALESCE(cl.detail_settings::text, '') as "detailSettings",
|
COALESCE(cl.detail_settings::text, '') as "detailSettings",
|
||||||
COALESCE(cl.description, '') as "description",
|
COALESCE(cl.description, '') as "description",
|
||||||
c.is_nullable as "isNullable",
|
CASE
|
||||||
|
WHEN cl.is_nullable IS NOT NULL
|
||||||
|
THEN CASE WHEN cl.is_nullable = 'N' THEN 'NO' ELSE 'YES' END
|
||||||
|
ELSE c.is_nullable
|
||||||
|
END as "isNullable",
|
||||||
|
CASE
|
||||||
|
WHEN cl.is_unique = 'Y' THEN 'YES'
|
||||||
|
ELSE 'NO'
|
||||||
|
END as "isUnique",
|
||||||
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
|
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
|
||||||
c.column_default as "defaultValue",
|
c.column_default as "defaultValue",
|
||||||
c.character_maximum_length as "maxLength",
|
c.character_maximum_length as "maxLength",
|
||||||
|
|
@ -1599,7 +1615,8 @@ export class TableManagementService {
|
||||||
tableName,
|
tableName,
|
||||||
columnName,
|
columnName,
|
||||||
actualValue,
|
actualValue,
|
||||||
paramIndex
|
paramIndex,
|
||||||
|
operator
|
||||||
);
|
);
|
||||||
|
|
||||||
case "entity":
|
case "entity":
|
||||||
|
|
@ -1612,7 +1629,14 @@ export class TableManagementService {
|
||||||
);
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// 기본 문자열 검색 (actualValue 사용)
|
// operator에 따라 정확 일치 또는 부분 일치 검색
|
||||||
|
if (operator === "equals") {
|
||||||
|
return {
|
||||||
|
whereClause: `${columnName}::text = $${paramIndex}`,
|
||||||
|
values: [String(actualValue)],
|
||||||
|
paramCount: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||||
values: [`%${actualValue}%`],
|
values: [`%${actualValue}%`],
|
||||||
|
|
@ -1626,10 +1650,19 @@ export class TableManagementService {
|
||||||
);
|
);
|
||||||
// 오류 시 기본 검색으로 폴백
|
// 오류 시 기본 검색으로 폴백
|
||||||
let fallbackValue = value;
|
let fallbackValue = value;
|
||||||
|
let fallbackOperator = "contains";
|
||||||
if (typeof value === "object" && value !== null && "value" in value) {
|
if (typeof value === "object" && value !== null && "value" in value) {
|
||||||
fallbackValue = value.value;
|
fallbackValue = value.value;
|
||||||
|
fallbackOperator = value.operator || "contains";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fallbackOperator === "equals") {
|
||||||
|
return {
|
||||||
|
whereClause: `${columnName}::text = $${paramIndex}`,
|
||||||
|
values: [String(fallbackValue)],
|
||||||
|
paramCount: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||||
values: [`%${fallbackValue}%`],
|
values: [`%${fallbackValue}%`],
|
||||||
|
|
@ -1776,7 +1809,8 @@ export class TableManagementService {
|
||||||
tableName: string,
|
tableName: string,
|
||||||
columnName: string,
|
columnName: string,
|
||||||
value: any,
|
value: any,
|
||||||
paramIndex: number
|
paramIndex: number,
|
||||||
|
operator: string = "contains"
|
||||||
): Promise<{
|
): Promise<{
|
||||||
whereClause: string;
|
whereClause: string;
|
||||||
values: any[];
|
values: any[];
|
||||||
|
|
@ -1786,7 +1820,14 @@ export class TableManagementService {
|
||||||
const codeTypeInfo = await this.getCodeTypeInfo(tableName, columnName);
|
const codeTypeInfo = await this.getCodeTypeInfo(tableName, columnName);
|
||||||
|
|
||||||
if (!codeTypeInfo.isCodeType || !codeTypeInfo.codeCategory) {
|
if (!codeTypeInfo.isCodeType || !codeTypeInfo.codeCategory) {
|
||||||
// 코드 타입이 아니면 기본 검색
|
// 코드 타입이 아니면 operator에 따라 검색
|
||||||
|
if (operator === "equals") {
|
||||||
|
return {
|
||||||
|
whereClause: `${columnName}::text = $${paramIndex}`,
|
||||||
|
values: [String(value)],
|
||||||
|
paramCount: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||||
values: [`%${value}%`],
|
values: [`%${value}%`],
|
||||||
|
|
@ -1794,6 +1835,15 @@ export class TableManagementService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// select 필터(equals)인 경우 정확한 코드값 매칭만 수행
|
||||||
|
if (operator === "equals") {
|
||||||
|
return {
|
||||||
|
whereClause: `${columnName}::text = $${paramIndex}`,
|
||||||
|
values: [String(value)],
|
||||||
|
paramCount: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof value === "string" && value.trim() !== "") {
|
if (typeof value === "string" && value.trim() !== "") {
|
||||||
// 코드값 또는 코드명으로 검색
|
// 코드값 또는 코드명으로 검색
|
||||||
return {
|
return {
|
||||||
|
|
@ -2431,6 +2481,154 @@ export class TableManagementService {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회사별 NOT NULL 소프트 제약조건 검증
|
||||||
|
* table_type_columns.is_nullable = 'N'인 컬럼에 NULL/빈값이 들어오면 위반 목록을 반환한다.
|
||||||
|
*/
|
||||||
|
async validateNotNullConstraints(
|
||||||
|
tableName: string,
|
||||||
|
data: Record<string, any>,
|
||||||
|
companyCode: string
|
||||||
|
): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
// 회사별 설정 우선, 없으면 공통(*) 설정 사용
|
||||||
|
const notNullColumns = await query<{ column_name: string; column_label: string }>(
|
||||||
|
`SELECT
|
||||||
|
ttc.column_name,
|
||||||
|
COALESCE(ttc.column_label, ttc.column_name) as column_label
|
||||||
|
FROM table_type_columns ttc
|
||||||
|
WHERE ttc.table_name = $1
|
||||||
|
AND ttc.is_nullable = 'N'
|
||||||
|
AND ttc.company_code = $2`,
|
||||||
|
[tableName, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 회사별 설정이 없으면 공통 설정 확인
|
||||||
|
if (notNullColumns.length === 0 && companyCode !== "*") {
|
||||||
|
const globalNotNull = await query<{ column_name: string; column_label: string }>(
|
||||||
|
`SELECT
|
||||||
|
ttc.column_name,
|
||||||
|
COALESCE(ttc.column_label, ttc.column_name) as column_label
|
||||||
|
FROM table_type_columns ttc
|
||||||
|
WHERE ttc.table_name = $1
|
||||||
|
AND ttc.is_nullable = 'N'
|
||||||
|
AND ttc.company_code = '*'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM table_type_columns ttc2
|
||||||
|
WHERE ttc2.table_name = ttc.table_name
|
||||||
|
AND ttc2.column_name = ttc.column_name
|
||||||
|
AND ttc2.company_code = $2
|
||||||
|
)`,
|
||||||
|
[tableName, companyCode]
|
||||||
|
);
|
||||||
|
notNullColumns.push(...globalNotNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notNullColumns.length === 0) return [];
|
||||||
|
|
||||||
|
const violations: string[] = [];
|
||||||
|
for (const col of notNullColumns) {
|
||||||
|
const value = data[col.column_name];
|
||||||
|
// NULL, undefined, 빈 문자열을 NOT NULL 위반으로 처리
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
violations.push(col.column_label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return violations;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`NOT NULL 검증 오류: ${tableName}`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회사별 UNIQUE 소프트 제약조건 검증
|
||||||
|
* table_type_columns.is_unique = 'Y'인 컬럼에 중복 값이 들어오면 위반 목록을 반환한다.
|
||||||
|
* @param excludeId 수정 시 자기 자신은 제외
|
||||||
|
*/
|
||||||
|
async validateUniqueConstraints(
|
||||||
|
tableName: string,
|
||||||
|
data: Record<string, any>,
|
||||||
|
companyCode: string,
|
||||||
|
excludeId?: string
|
||||||
|
): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
// 회사별 설정 우선, 없으면 공통(*) 설정 사용
|
||||||
|
let uniqueColumns = await query<{ column_name: string; column_label: string }>(
|
||||||
|
`SELECT
|
||||||
|
ttc.column_name,
|
||||||
|
COALESCE(ttc.column_label, ttc.column_name) as column_label
|
||||||
|
FROM table_type_columns ttc
|
||||||
|
WHERE ttc.table_name = $1
|
||||||
|
AND ttc.is_unique = 'Y'
|
||||||
|
AND ttc.company_code = $2`,
|
||||||
|
[tableName, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 회사별 설정이 없으면 공통 설정 확인
|
||||||
|
if (uniqueColumns.length === 0 && companyCode !== "*") {
|
||||||
|
const globalUnique = await query<{ column_name: string; column_label: string }>(
|
||||||
|
`SELECT
|
||||||
|
ttc.column_name,
|
||||||
|
COALESCE(ttc.column_label, ttc.column_name) as column_label
|
||||||
|
FROM table_type_columns ttc
|
||||||
|
WHERE ttc.table_name = $1
|
||||||
|
AND ttc.is_unique = 'Y'
|
||||||
|
AND ttc.company_code = '*'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM table_type_columns ttc2
|
||||||
|
WHERE ttc2.table_name = ttc.table_name
|
||||||
|
AND ttc2.column_name = ttc.column_name
|
||||||
|
AND ttc2.company_code = $2
|
||||||
|
)`,
|
||||||
|
[tableName, companyCode]
|
||||||
|
);
|
||||||
|
uniqueColumns = globalUnique;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uniqueColumns.length === 0) return [];
|
||||||
|
|
||||||
|
const violations: string[] = [];
|
||||||
|
for (const col of uniqueColumns) {
|
||||||
|
const value = data[col.column_name];
|
||||||
|
if (value === null || value === undefined || value === "") continue;
|
||||||
|
|
||||||
|
// 해당 회사 내에서 같은 값이 이미 존재하는지 확인
|
||||||
|
const hasCompanyCode = await query(
|
||||||
|
`SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
|
||||||
|
let dupQuery: string;
|
||||||
|
let dupParams: any[];
|
||||||
|
|
||||||
|
if (hasCompanyCode.length > 0 && companyCode !== "*") {
|
||||||
|
dupQuery = excludeId
|
||||||
|
? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 AND id != $3 LIMIT 1`
|
||||||
|
: `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 LIMIT 1`;
|
||||||
|
dupParams = excludeId ? [value, companyCode, excludeId] : [value, companyCode];
|
||||||
|
} else {
|
||||||
|
dupQuery = excludeId
|
||||||
|
? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND id != $2 LIMIT 1`
|
||||||
|
: `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 LIMIT 1`;
|
||||||
|
dupParams = excludeId ? [value, excludeId] : [value];
|
||||||
|
}
|
||||||
|
|
||||||
|
const dupResult = await query(dupQuery, dupParams);
|
||||||
|
if (dupResult.length > 0) {
|
||||||
|
violations.push(`${col.column_label} (${value})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return violations;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`UNIQUE 검증 오류: ${tableName}`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블에 데이터 추가
|
* 테이블에 데이터 추가
|
||||||
* @returns 무시된 컬럼 정보 (디버깅용)
|
* @returns 무시된 컬럼 정보 (디버깅용)
|
||||||
|
|
@ -2438,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);
|
||||||
|
|
@ -2551,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);
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ interface ColumnTypeInfo {
|
||||||
detailSettings: string;
|
detailSettings: string;
|
||||||
description: string;
|
description: string;
|
||||||
isNullable: string;
|
isNullable: string;
|
||||||
|
isUnique: string;
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
numericPrecision?: number;
|
numericPrecision?: number;
|
||||||
|
|
@ -382,10 +383,11 @@ export default function TableManagementPage() {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...col,
|
...col,
|
||||||
inputType: col.inputType || "text", // 기본값: text
|
inputType: col.inputType || "text",
|
||||||
numberingRuleId, // 🆕 채번규칙 ID
|
isUnique: col.isUnique || "NO",
|
||||||
categoryMenus: col.categoryMenus || [], // 카테고리 메뉴 매핑 정보
|
numberingRuleId,
|
||||||
hierarchyRole, // 계층구조 역할
|
categoryMenus: col.categoryMenus || [],
|
||||||
|
hierarchyRole,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1091,9 +1093,9 @@ export default function TableManagementPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 인덱스 토글 핸들러
|
// 인덱스 토글 핸들러 (일반 인덱스만 DB 레벨 - 유니크는 앱 레벨 소프트 제약조건으로 분리됨)
|
||||||
const handleIndexToggle = useCallback(
|
const handleIndexToggle = useCallback(
|
||||||
async (columnName: string, indexType: "index" | "unique", checked: boolean) => {
|
async (columnName: string, indexType: "index", checked: boolean) => {
|
||||||
if (!selectedTable) return;
|
if (!selectedTable) return;
|
||||||
const action = checked ? "create" : "drop";
|
const action = checked ? "create" : "drop";
|
||||||
try {
|
try {
|
||||||
|
|
@ -1122,14 +1124,41 @@ export default function TableManagementPage() {
|
||||||
const hasIndex = constraints.indexes.some(
|
const hasIndex = constraints.indexes.some(
|
||||||
(idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName,
|
(idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName,
|
||||||
);
|
);
|
||||||
const hasUnique = constraints.indexes.some(
|
return { isPk, hasIndex };
|
||||||
(idx) => idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName,
|
|
||||||
);
|
|
||||||
return { isPk, hasIndex, hasUnique };
|
|
||||||
},
|
},
|
||||||
[constraints],
|
[constraints],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// UNIQUE 토글 핸들러 (앱 레벨 소프트 제약조건 - NOT NULL과 동일 패턴)
|
||||||
|
const handleUniqueToggle = useCallback(
|
||||||
|
async (columnName: string, currentIsUnique: string) => {
|
||||||
|
if (!selectedTable) return;
|
||||||
|
const isCurrentlyUnique = currentIsUnique === "YES";
|
||||||
|
const newUnique = !isCurrentlyUnique;
|
||||||
|
try {
|
||||||
|
const response = await apiClient.put(
|
||||||
|
`/table-management/tables/${selectedTable}/columns/${columnName}/unique`,
|
||||||
|
{ unique: newUnique },
|
||||||
|
);
|
||||||
|
if (response.data.success) {
|
||||||
|
toast.success(response.data.message);
|
||||||
|
setColumns((prev) =>
|
||||||
|
prev.map((col) =>
|
||||||
|
col.columnName === columnName
|
||||||
|
? { ...col, isUnique: newUnique ? "YES" : "NO" }
|
||||||
|
: col,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(response.data.message || "UNIQUE 설정 실패");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.response?.data?.message || "UNIQUE 설정 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedTable],
|
||||||
|
);
|
||||||
|
|
||||||
// NOT NULL 토글 핸들러
|
// NOT NULL 토글 핸들러
|
||||||
const handleNullableToggle = useCallback(
|
const handleNullableToggle = useCallback(
|
||||||
async (columnName: string, currentIsNullable: string) => {
|
async (columnName: string, currentIsNullable: string) => {
|
||||||
|
|
@ -2029,12 +2058,12 @@ export default function TableManagementPage() {
|
||||||
aria-label={`${column.columnName} 인덱스 설정`}
|
aria-label={`${column.columnName} 인덱스 설정`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* UQ 체크박스 */}
|
{/* UQ 체크박스 (앱 레벨 소프트 제약조건) */}
|
||||||
<div className="flex items-center justify-center pt-1">
|
<div className="flex items-center justify-center pt-1">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={idxState.hasUnique}
|
checked={column.isUnique === "YES"}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={() =>
|
||||||
handleIndexToggle(column.columnName, "unique", checked as boolean)
|
handleUniqueToggle(column.columnName, column.isUnique)
|
||||||
}
|
}
|
||||||
aria-label={`${column.columnName} 유니크 설정`}
|
aria-label={`${column.columnName} 유니크 설정`}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -942,17 +942,22 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 채번 적용: 각 행마다 채번 API 호출 (신규 등록 시에만, 자동 감지된 채번 규칙 사용)
|
// 채번 적용: 엑셀에 값이 없거나 빈 값이면 채번 규칙으로 자동 생성, 값이 있으면 그대로 사용
|
||||||
if (hasNumbering && numberingInfo && uploadMode === "insert" && !shouldUpdate) {
|
if (hasNumbering && numberingInfo && uploadMode === "insert" && !shouldUpdate) {
|
||||||
try {
|
const existingValue = dataToSave[numberingInfo.columnName];
|
||||||
const { apiClient } = await import("@/lib/api/client");
|
const hasExcelValue = existingValue !== undefined && existingValue !== null && String(existingValue).trim() !== "";
|
||||||
const numberingResponse = await apiClient.post(`/numbering-rules/${numberingInfo.numberingRuleId}/allocate`);
|
|
||||||
const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code;
|
if (!hasExcelValue) {
|
||||||
if (numberingResponse.data?.success && generatedCode) {
|
try {
|
||||||
dataToSave[numberingInfo.columnName] = generatedCode;
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
|
const numberingResponse = await apiClient.post(`/numbering-rules/${numberingInfo.numberingRuleId}/allocate`);
|
||||||
|
const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code;
|
||||||
|
if (numberingResponse.data?.success && generatedCode) {
|
||||||
|
dataToSave[numberingInfo.columnName] = generatedCode;
|
||||||
|
}
|
||||||
|
} catch (numError) {
|
||||||
|
console.error("채번 오류:", numError);
|
||||||
}
|
}
|
||||||
} catch (numError) {
|
|
||||||
console.error("채번 오류:", numError);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
|
||||||
isPreview = false,
|
isPreview = false,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Card className="border-border bg-card">
|
<Card className="border-border bg-card flex-1">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Badge variant="outline" className="text-xs sm:text-sm">
|
<Badge variant="outline" className="text-xs sm:text-sm">
|
||||||
|
|
|
||||||
|
|
@ -62,9 +62,9 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
const [editingLeftTitle, setEditingLeftTitle] = useState(false);
|
const [editingLeftTitle, setEditingLeftTitle] = useState(false);
|
||||||
const [editingRightTitle, setEditingRightTitle] = useState(false);
|
const [editingRightTitle, setEditingRightTitle] = useState(false);
|
||||||
|
|
||||||
// 구분자 관련 상태
|
// 구분자 관련 상태 (개별 파트 사이 구분자)
|
||||||
const [separatorType, setSeparatorType] = useState<SeparatorType>("-");
|
const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
|
||||||
const [customSeparator, setCustomSeparator] = useState("");
|
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
// 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회
|
// 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회
|
||||||
interface CategoryOption {
|
interface CategoryOption {
|
||||||
|
|
@ -192,48 +192,68 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
}
|
}
|
||||||
}, [currentRule, onChange]);
|
}, [currentRule, onChange]);
|
||||||
|
|
||||||
// currentRule이 변경될 때 구분자 상태 동기화
|
// currentRule이 변경될 때 파트별 구분자 상태 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentRule) {
|
if (currentRule && currentRule.parts.length > 0) {
|
||||||
const sep = currentRule.separator ?? "-";
|
const newSepTypes: Record<number, SeparatorType> = {};
|
||||||
// 빈 문자열이면 "none"
|
const newCustomSeps: Record<number, string> = {};
|
||||||
if (sep === "") {
|
|
||||||
setSeparatorType("none");
|
currentRule.parts.forEach((part) => {
|
||||||
setCustomSeparator("");
|
const sep = part.separatorAfter ?? currentRule.separator ?? "-";
|
||||||
return;
|
if (sep === "") {
|
||||||
}
|
newSepTypes[part.order] = "none";
|
||||||
// 미리 정의된 구분자인지 확인 (none, custom 제외)
|
newCustomSeps[part.order] = "";
|
||||||
const predefinedOption = SEPARATOR_OPTIONS.find(
|
} else {
|
||||||
opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep
|
const predefinedOption = SEPARATOR_OPTIONS.find(
|
||||||
);
|
opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep
|
||||||
if (predefinedOption) {
|
);
|
||||||
setSeparatorType(predefinedOption.value);
|
if (predefinedOption) {
|
||||||
setCustomSeparator("");
|
newSepTypes[part.order] = predefinedOption.value;
|
||||||
} else {
|
newCustomSeps[part.order] = "";
|
||||||
// 직접 입력된 구분자
|
} else {
|
||||||
setSeparatorType("custom");
|
newSepTypes[part.order] = "custom";
|
||||||
setCustomSeparator(sep);
|
newCustomSeps[part.order] = sep;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setSeparatorTypes(newSepTypes);
|
||||||
|
setCustomSeparators(newCustomSeps);
|
||||||
}
|
}
|
||||||
}, [currentRule?.ruleId]); // ruleId가 변경될 때만 실행 (규칙 선택/생성 시)
|
}, [currentRule?.ruleId]);
|
||||||
|
|
||||||
// 구분자 변경 핸들러
|
// 개별 파트 구분자 변경 핸들러
|
||||||
const handleSeparatorChange = useCallback((type: SeparatorType) => {
|
const handlePartSeparatorChange = useCallback((partOrder: number, type: SeparatorType) => {
|
||||||
setSeparatorType(type);
|
setSeparatorTypes(prev => ({ ...prev, [partOrder]: type }));
|
||||||
if (type !== "custom") {
|
if (type !== "custom") {
|
||||||
const option = SEPARATOR_OPTIONS.find(opt => opt.value === type);
|
const option = SEPARATOR_OPTIONS.find(opt => opt.value === type);
|
||||||
const newSeparator = option?.displayValue ?? "";
|
const newSeparator = option?.displayValue ?? "";
|
||||||
setCurrentRule((prev) => prev ? { ...prev, separator: newSeparator } : null);
|
setCustomSeparators(prev => ({ ...prev, [partOrder]: "" }));
|
||||||
setCustomSeparator("");
|
setCurrentRule((prev) => {
|
||||||
|
if (!prev) return null;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
parts: prev.parts.map((part) =>
|
||||||
|
part.order === partOrder ? { ...part, separatorAfter: newSeparator } : part
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 직접 입력 구분자 변경 핸들러
|
// 개별 파트 직접 입력 구분자 변경 핸들러
|
||||||
const handleCustomSeparatorChange = useCallback((value: string) => {
|
const handlePartCustomSeparatorChange = useCallback((partOrder: number, value: string) => {
|
||||||
// 최대 2자 제한
|
|
||||||
const trimmedValue = value.slice(0, 2);
|
const trimmedValue = value.slice(0, 2);
|
||||||
setCustomSeparator(trimmedValue);
|
setCustomSeparators(prev => ({ ...prev, [partOrder]: trimmedValue }));
|
||||||
setCurrentRule((prev) => prev ? { ...prev, separator: trimmedValue } : null);
|
setCurrentRule((prev) => {
|
||||||
|
if (!prev) return null;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
parts: prev.parts.map((part) =>
|
||||||
|
part.order === partOrder ? { ...part, separatorAfter: trimmedValue } : part
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleAddPart = useCallback(() => {
|
const handleAddPart = useCallback(() => {
|
||||||
|
|
@ -250,6 +270,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
partType: "text",
|
partType: "text",
|
||||||
generationMethod: "auto",
|
generationMethod: "auto",
|
||||||
autoConfig: { textValue: "CODE" },
|
autoConfig: { textValue: "CODE" },
|
||||||
|
separatorAfter: "-",
|
||||||
};
|
};
|
||||||
|
|
||||||
setCurrentRule((prev) => {
|
setCurrentRule((prev) => {
|
||||||
|
|
@ -257,6 +278,10 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
return { ...prev, parts: [...prev.parts, newPart] };
|
return { ...prev, parts: [...prev.parts, newPart] };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 새 파트의 구분자 상태 초기화
|
||||||
|
setSeparatorTypes(prev => ({ ...prev, [newPart.order]: "-" }));
|
||||||
|
setCustomSeparators(prev => ({ ...prev, [newPart.order]: "" }));
|
||||||
|
|
||||||
toast.success(`규칙 ${newPart.order}가 추가되었습니다`);
|
toast.success(`규칙 ${newPart.order}가 추가되었습니다`);
|
||||||
}, [currentRule, maxRules]);
|
}, [currentRule, maxRules]);
|
||||||
|
|
||||||
|
|
@ -573,42 +598,6 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 두 번째 줄: 구분자 설정 */}
|
|
||||||
<div className="flex items-end gap-3">
|
|
||||||
<div className="w-48 space-y-2">
|
|
||||||
<Label className="text-sm font-medium">구분자</Label>
|
|
||||||
<Select
|
|
||||||
value={separatorType}
|
|
||||||
onValueChange={(value) => handleSeparatorChange(value as SeparatorType)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-9">
|
|
||||||
<SelectValue placeholder="구분자 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{SEPARATOR_OPTIONS.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
{separatorType === "custom" && (
|
|
||||||
<div className="w-32 space-y-2">
|
|
||||||
<Label className="text-sm font-medium">직접 입력</Label>
|
|
||||||
<Input
|
|
||||||
value={customSeparator}
|
|
||||||
onChange={(e) => handleCustomSeparatorChange(e.target.value)}
|
|
||||||
className="h-9"
|
|
||||||
placeholder="최대 2자"
|
|
||||||
maxLength={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="text-muted-foreground pb-2 text-xs">
|
|
||||||
규칙 사이에 들어갈 문자입니다
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -625,15 +614,48 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
||||||
<p className="text-muted-foreground text-xs sm:text-sm">규칙을 추가하여 코드를 구성하세요</p>
|
<p className="text-muted-foreground text-xs sm:text-sm">규칙을 추가하여 코드를 구성하세요</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
<div className="flex flex-wrap items-stretch gap-3">
|
||||||
{currentRule.parts.map((part, index) => (
|
{currentRule.parts.map((part, index) => (
|
||||||
<NumberingRuleCard
|
<React.Fragment key={`part-${part.order}-${index}`}>
|
||||||
key={`part-${part.order}-${index}`}
|
<div className="flex w-[200px] flex-col">
|
||||||
part={part}
|
<NumberingRuleCard
|
||||||
onUpdate={(updates) => handleUpdatePart(part.order, updates)}
|
part={part}
|
||||||
onDelete={() => handleDeletePart(part.order)}
|
onUpdate={(updates) => handleUpdatePart(part.order, updates)}
|
||||||
isPreview={isPreview}
|
onDelete={() => handleDeletePart(part.order)}
|
||||||
/>
|
isPreview={isPreview}
|
||||||
|
/>
|
||||||
|
{/* 카드 하단에 구분자 설정 (마지막 파트 제외) */}
|
||||||
|
{index < currentRule.parts.length - 1 && (
|
||||||
|
<div className="mt-2 flex items-center gap-1">
|
||||||
|
<span className="text-muted-foreground text-[10px] whitespace-nowrap">뒤 구분자</span>
|
||||||
|
<Select
|
||||||
|
value={separatorTypes[part.order] || "-"}
|
||||||
|
onValueChange={(value) => handlePartSeparatorChange(part.order, value as SeparatorType)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 flex-1 text-[10px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SEPARATOR_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value} className="text-xs">
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{separatorTypes[part.order] === "custom" && (
|
||||||
|
<Input
|
||||||
|
value={customSeparators[part.order] || ""}
|
||||||
|
onChange={(e) => handlePartCustomSeparatorChange(part.order, e.target.value)}
|
||||||
|
className="h-6 w-14 text-center text-[10px]"
|
||||||
|
placeholder="2자"
|
||||||
|
maxLength={2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -17,75 +17,71 @@ export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({
|
||||||
return "규칙을 추가해주세요";
|
return "규칙을 추가해주세요";
|
||||||
}
|
}
|
||||||
|
|
||||||
const parts = config.parts
|
const sortedParts = config.parts.sort((a, b) => a.order - b.order);
|
||||||
.sort((a, b) => a.order - b.order)
|
|
||||||
.map((part) => {
|
const partValues = sortedParts.map((part) => {
|
||||||
if (part.generationMethod === "manual") {
|
if (part.generationMethod === "manual") {
|
||||||
return part.manualConfig?.value || "XXX";
|
return part.manualConfig?.value || "XXX";
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoConfig = part.autoConfig || {};
|
||||||
|
|
||||||
|
switch (part.partType) {
|
||||||
|
case "sequence": {
|
||||||
|
const length = autoConfig.sequenceLength || 3;
|
||||||
|
const startFrom = autoConfig.startFrom || 1;
|
||||||
|
return String(startFrom).padStart(length, "0");
|
||||||
}
|
}
|
||||||
|
case "number": {
|
||||||
const autoConfig = part.autoConfig || {};
|
const length = autoConfig.numberLength || 4;
|
||||||
|
const value = autoConfig.numberValue || 0;
|
||||||
switch (part.partType) {
|
return String(value).padStart(length, "0");
|
||||||
// 1. 순번 (자동 증가)
|
}
|
||||||
case "sequence": {
|
case "date": {
|
||||||
const length = autoConfig.sequenceLength || 3;
|
const format = autoConfig.dateFormat || "YYYYMMDD";
|
||||||
const startFrom = autoConfig.startFrom || 1;
|
if (autoConfig.useColumnValue && autoConfig.sourceColumnName) {
|
||||||
return String(startFrom).padStart(length, "0");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 숫자 (고정 자릿수)
|
|
||||||
case "number": {
|
|
||||||
const length = autoConfig.numberLength || 4;
|
|
||||||
const value = autoConfig.numberValue || 0;
|
|
||||||
return String(value).padStart(length, "0");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 날짜
|
|
||||||
case "date": {
|
|
||||||
const format = autoConfig.dateFormat || "YYYYMMDD";
|
|
||||||
|
|
||||||
// 컬럼 기준 생성인 경우 placeholder 표시
|
|
||||||
if (autoConfig.useColumnValue && autoConfig.sourceColumnName) {
|
|
||||||
// 형식에 맞는 placeholder 반환
|
|
||||||
switch (format) {
|
|
||||||
case "YYYY": return "[YYYY]";
|
|
||||||
case "YY": return "[YY]";
|
|
||||||
case "YYYYMM": return "[YYYYMM]";
|
|
||||||
case "YYMM": return "[YYMM]";
|
|
||||||
case "YYYYMMDD": return "[YYYYMMDD]";
|
|
||||||
case "YYMMDD": return "[YYMMDD]";
|
|
||||||
default: return "[DATE]";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 현재 날짜 기준 생성
|
|
||||||
const now = new Date();
|
|
||||||
const year = now.getFullYear();
|
|
||||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(now.getDate()).padStart(2, "0");
|
|
||||||
|
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case "YYYY": return String(year);
|
case "YYYY": return "[YYYY]";
|
||||||
case "YY": return String(year).slice(-2);
|
case "YY": return "[YY]";
|
||||||
case "YYYYMM": return `${year}${month}`;
|
case "YYYYMM": return "[YYYYMM]";
|
||||||
case "YYMM": return `${String(year).slice(-2)}${month}`;
|
case "YYMM": return "[YYMM]";
|
||||||
case "YYYYMMDD": return `${year}${month}${day}`;
|
case "YYYYMMDD": return "[YYYYMMDD]";
|
||||||
case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`;
|
case "YYMMDD": return "[YYMMDD]";
|
||||||
default: return `${year}${month}${day}`;
|
default: return "[DATE]";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const now = new Date();
|
||||||
// 4. 문자
|
const year = now.getFullYear();
|
||||||
case "text":
|
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||||
return autoConfig.textValue || "TEXT";
|
const day = String(now.getDate()).padStart(2, "0");
|
||||||
|
switch (format) {
|
||||||
default:
|
case "YYYY": return String(year);
|
||||||
return "XXX";
|
case "YY": return String(year).slice(-2);
|
||||||
|
case "YYYYMM": return `${year}${month}`;
|
||||||
|
case "YYMM": return `${String(year).slice(-2)}${month}`;
|
||||||
|
case "YYYYMMDD": return `${year}${month}${day}`;
|
||||||
|
case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`;
|
||||||
|
default: return `${year}${month}${day}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
case "text":
|
||||||
|
return autoConfig.textValue || "TEXT";
|
||||||
|
default:
|
||||||
|
return "XXX";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return parts.join(config.separator || "");
|
// 파트별 개별 구분자로 결합
|
||||||
|
const globalSep = config.separator ?? "-";
|
||||||
|
let result = "";
|
||||||
|
partValues.forEach((val, idx) => {
|
||||||
|
result += val;
|
||||||
|
if (idx < partValues.length - 1) {
|
||||||
|
const sep = sortedParts[idx].separatorAfter ?? globalSep;
|
||||||
|
result += sep;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
|
|
|
||||||
|
|
@ -376,12 +376,26 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// 화면 정보와 레이아웃 데이터 로딩
|
// 화면 정보와 레이아웃 데이터 로딩 (ScreenModal과 동일하게 V2 API 우선)
|
||||||
const [screenInfo, layoutData] = await Promise.all([
|
const [screenInfo, v2LayoutData] = await Promise.all([
|
||||||
screenApi.getScreen(screenId),
|
screenApi.getScreen(screenId),
|
||||||
screenApi.getLayout(screenId),
|
screenApi.getLayoutV2(screenId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// V2 → Legacy 변환 (ScreenModal과 동일한 패턴)
|
||||||
|
let layoutData: any = null;
|
||||||
|
if (v2LayoutData && isValidV2Layout(v2LayoutData)) {
|
||||||
|
layoutData = convertV2ToLegacy(v2LayoutData);
|
||||||
|
if (layoutData) {
|
||||||
|
layoutData.screenResolution = v2LayoutData.screenResolution || layoutData.screenResolution;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// V2 없으면 기존 API fallback
|
||||||
|
if (!layoutData) {
|
||||||
|
layoutData = await screenApi.getLayout(screenId);
|
||||||
|
}
|
||||||
|
|
||||||
if (screenInfo && layoutData) {
|
if (screenInfo && layoutData) {
|
||||||
const components = layoutData.components || [];
|
const components = layoutData.components || [];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -10,6 +10,74 @@ import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||||
import { useV2FormOptional } from "@/components/v2/V2FormContext";
|
import { useV2FormOptional } from "@/components/v2/V2FormContext";
|
||||||
// TSP (Tab State Persistence) - 컴포넌트별 상태 보존
|
// TSP (Tab State Persistence) - 컴포넌트별 상태 보존
|
||||||
import { TSPProvider } from "@/hooks/usePersistedState";
|
import { TSPProvider } from "@/hooks/usePersistedState";
|
||||||
|
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;
|
||||||
|
|
||||||
|
const existingSource = componentConfig?.source;
|
||||||
|
if (existingSource && existingSource !== "static" && existingSource !== "distinct" && existingSource !== "select") {
|
||||||
|
return componentConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = { ...componentConfig };
|
||||||
|
|
||||||
|
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 {
|
||||||
|
|
@ -177,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 => {
|
||||||
|
|
@ -553,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에서 추출한 현재 값 전달
|
||||||
// 새로운 기능들 전달
|
// 새로운 기능들 전달
|
||||||
|
|
@ -610,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,
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@ import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선
|
||||||
import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰
|
import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰
|
||||||
import "./v2-bom-item-editor/BomItemEditorRenderer"; // BOM 하위품목 편집기
|
import "./v2-bom-item-editor/BomItemEditorRenderer"; // BOM 하위품목 편집기
|
||||||
import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준
|
import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준
|
||||||
|
import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 초기화 함수
|
* 컴포넌트 초기화 함수
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,8 @@ import { dataApi } from "@/lib/api/data";
|
||||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient, getFullImageUrl } from "@/lib/api/client";
|
||||||
|
import { getFilePreviewUrl } from "@/lib/api/file";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -40,6 +41,80 @@ import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-opt
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { useSplitPanel } from "./SplitPanelContext";
|
import { useSplitPanel } from "./SplitPanelContext";
|
||||||
|
|
||||||
|
// 테이블 셀 이미지 썸네일 컴포넌트
|
||||||
|
const SplitPanelCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
|
||||||
|
const [imgSrc, setImgSrc] = React.useState<string | null>(null);
|
||||||
|
const [error, setError] = React.useState(false);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
const rawValue = String(value);
|
||||||
|
const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue;
|
||||||
|
const isObjid = /^\d+$/.test(strValue);
|
||||||
|
|
||||||
|
if (isObjid) {
|
||||||
|
const loadImage = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/files/preview/${strValue}`, { responseType: "blob" });
|
||||||
|
if (mounted) {
|
||||||
|
const blob = new Blob([response.data]);
|
||||||
|
setImgSrc(window.URL.createObjectURL(blob));
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (mounted) { setError(true); setLoading(false); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadImage();
|
||||||
|
} else {
|
||||||
|
setImgSrc(getFullImageUrl(strValue));
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => { mounted = false; };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
|
||||||
|
<div className="h-8 w-8 animate-pulse rounded bg-muted sm:h-10 sm:w-10" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !imgSrc) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
|
||||||
|
<div className="bg-muted text-muted-foreground flex h-8 w-8 items-center justify-center rounded sm:h-10 sm:w-10" title="이미지를 불러올 수 없습니다">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="2" y1="2" x2="22" y2="22"/><path d="M10.41 10.41a2 2 0 1 1-2.83-2.83"/><line x1="13.5" y1="13.5" x2="6" y2="21"/><line x1="18" y1="12" x2="21" y2="15"/><path d="M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59"/><path d="M21 15V5a2 2 0 0 0-2-2H9"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
|
||||||
|
<img
|
||||||
|
src={imgSrc}
|
||||||
|
alt="이미지"
|
||||||
|
className="h-8 w-8 cursor-pointer rounded object-cover transition-opacity hover:opacity-80 sm:h-10 sm:w-10"
|
||||||
|
style={{ maxWidth: "40px", maxHeight: "40px" }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const rawValue = String(value);
|
||||||
|
const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue;
|
||||||
|
const isObjid = /^\d+$/.test(strValue);
|
||||||
|
window.open(isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue), "_blank");
|
||||||
|
}}
|
||||||
|
onError={() => setError(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
SplitPanelCellImage.displayName = "SplitPanelCellImage";
|
||||||
|
|
||||||
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
||||||
// 추가 props
|
// 추가 props
|
||||||
}
|
}
|
||||||
|
|
@ -183,6 +258,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
|
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
|
||||||
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
|
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
|
||||||
const [rightColumnLabels, setRightColumnLabels] = useState<Record<string, string>>({}); // 우측 컬럼 라벨
|
const [rightColumnLabels, setRightColumnLabels] = useState<Record<string, string>>({}); // 우측 컬럼 라벨
|
||||||
|
const [columnInputTypes, setColumnInputTypes] = useState<Record<string, string>>({}); // 테이블별 컬럼 inputType
|
||||||
const [leftCategoryMappings, setLeftCategoryMappings] = useState<
|
const [leftCategoryMappings, setLeftCategoryMappings] = useState<
|
||||||
Record<string, Record<string, { label: string; color?: string }>>
|
Record<string, Record<string, { label: string; color?: string }>>
|
||||||
>({}); // 좌측 카테고리 매핑
|
>({}); // 좌측 카테고리 매핑
|
||||||
|
|
@ -620,7 +696,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
return result;
|
return result;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷)
|
// 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷 + 이미지)
|
||||||
const formatCellValue = useCallback(
|
const formatCellValue = useCallback(
|
||||||
(
|
(
|
||||||
columnName: string,
|
columnName: string,
|
||||||
|
|
@ -637,6 +713,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
) => {
|
) => {
|
||||||
if (value === null || value === undefined) return "-";
|
if (value === null || value === undefined) return "-";
|
||||||
|
|
||||||
|
// 이미지 타입: 썸네일 표시
|
||||||
|
const colInputType = columnInputTypes[columnName];
|
||||||
|
if (colInputType === "image" && value) {
|
||||||
|
return <SplitPanelCellImage value={String(value)} />;
|
||||||
|
}
|
||||||
|
|
||||||
// 🆕 날짜 포맷 적용
|
// 🆕 날짜 포맷 적용
|
||||||
if (format?.type === "date" || format?.dateFormat) {
|
if (format?.type === "date" || format?.dateFormat) {
|
||||||
return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD");
|
return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD");
|
||||||
|
|
@ -703,7 +785,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
// 일반 값
|
// 일반 값
|
||||||
return String(value);
|
return String(value);
|
||||||
},
|
},
|
||||||
[formatDateValue, formatNumberValue],
|
[formatDateValue, formatNumberValue, columnInputTypes],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 좌측 데이터 로드
|
// 좌측 데이터 로드
|
||||||
|
|
@ -1454,14 +1536,36 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setRightColumnLabels(labels);
|
setRightColumnLabels(labels);
|
||||||
console.log("✅ 우측 컬럼 라벨 로드:", labels);
|
|
||||||
|
// 우측 테이블 + 추가 탭 테이블의 inputType 로드
|
||||||
|
const tablesToLoad = new Set<string>([rightTableName]);
|
||||||
|
const additionalTabs = componentConfig.rightPanel?.additionalTabs || [];
|
||||||
|
additionalTabs.forEach((tab: any) => {
|
||||||
|
if (tab.tableName) tablesToLoad.add(tab.tableName);
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputTypes: Record<string, string> = {};
|
||||||
|
for (const tbl of tablesToLoad) {
|
||||||
|
try {
|
||||||
|
const inputTypesResponse = await tableTypeApi.getColumnInputTypes(tbl);
|
||||||
|
inputTypesResponse.forEach((col: any) => {
|
||||||
|
const colName = col.columnName || col.column_name;
|
||||||
|
if (colName) {
|
||||||
|
inputTypes[colName] = col.inputType || "text";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// inputType 로드 실패 시 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setColumnInputTypes(inputTypes);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("우측 테이블 컬럼 정보 로드 실패:", error);
|
console.error("우측 테이블 컬럼 정보 로드 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadRightTableColumns();
|
loadRightTableColumns();
|
||||||
}, [componentConfig.rightPanel?.tableName, isDesignMode]);
|
}, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.additionalTabs, isDesignMode]);
|
||||||
|
|
||||||
// 좌측 테이블 카테고리 매핑 로드
|
// 좌측 테이블 카테고리 매핑 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -2548,14 +2652,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
<tbody className="divide-y divide-gray-200 bg-white">
|
<tbody className="divide-y divide-gray-200 bg-white">
|
||||||
{group.items.map((item, idx) => {
|
{group.items.map((item, idx) => {
|
||||||
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
|
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
|
||||||
const itemId = item[sourceColumn] || item.id || item.ID || idx;
|
const itemId = item[sourceColumn] || item.id || item.ID;
|
||||||
const isSelected =
|
const isSelected =
|
||||||
selectedLeftItem &&
|
selectedLeftItem &&
|
||||||
(selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
|
(selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={itemId}
|
key={itemId != null ? `${itemId}-${idx}` : idx}
|
||||||
onClick={() => handleLeftItemSelect(item)}
|
onClick={() => handleLeftItemSelect(item)}
|
||||||
className={`hover:bg-accent cursor-pointer transition-colors ${
|
className={`hover:bg-accent cursor-pointer transition-colors ${
|
||||||
isSelected ? "bg-primary/10" : ""
|
isSelected ? "bg-primary/10" : ""
|
||||||
|
|
@ -2610,14 +2714,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
<tbody className="divide-y divide-gray-200 bg-white">
|
<tbody className="divide-y divide-gray-200 bg-white">
|
||||||
{filteredData.map((item, idx) => {
|
{filteredData.map((item, idx) => {
|
||||||
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
|
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
|
||||||
const itemId = item[sourceColumn] || item.id || item.ID || idx;
|
const itemId = item[sourceColumn] || item.id || item.ID;
|
||||||
const isSelected =
|
const isSelected =
|
||||||
selectedLeftItem &&
|
selectedLeftItem &&
|
||||||
(selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
|
(selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={itemId}
|
key={itemId != null ? `${itemId}-${idx}` : idx}
|
||||||
onClick={() => handleLeftItemSelect(item)}
|
onClick={() => handleLeftItemSelect(item)}
|
||||||
className={`hover:bg-accent cursor-pointer transition-colors ${
|
className={`hover:bg-accent cursor-pointer transition-colors ${
|
||||||
isSelected ? "bg-primary/10" : ""
|
isSelected ? "bg-primary/10" : ""
|
||||||
|
|
@ -2712,7 +2816,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
// 재귀 렌더링 함수
|
// 재귀 렌더링 함수
|
||||||
const renderTreeItem = (item: any, index: number): React.ReactNode => {
|
const renderTreeItem = (item: any, index: number): React.ReactNode => {
|
||||||
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
|
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
|
||||||
const itemId = item[sourceColumn] || item.id || item.ID || index;
|
const rawItemId = item[sourceColumn] || item.id || item.ID;
|
||||||
|
const itemId = rawItemId != null ? rawItemId : index;
|
||||||
const isSelected =
|
const isSelected =
|
||||||
selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
|
selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
|
||||||
const hasChildren = item.children && item.children.length > 0;
|
const hasChildren = item.children && item.children.length > 0;
|
||||||
|
|
@ -2763,7 +2868,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const displaySubtitle = displayFields[1]?.value || null;
|
const displaySubtitle = displayFields[1]?.value || null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={itemId}>
|
<React.Fragment key={`${itemId}-${index}`}>
|
||||||
{/* 현재 항목 */}
|
{/* 현재 항목 */}
|
||||||
<div
|
<div
|
||||||
className={`group hover:bg-muted relative cursor-pointer rounded-md p-3 transition-colors ${
|
className={`group hover:bg-muted relative cursor-pointer rounded-md p-3 transition-colors ${
|
||||||
|
|
@ -3095,7 +3200,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{currentTabData.map((item: any, idx: number) => {
|
{currentTabData.map((item: any, idx: number) => {
|
||||||
const itemId = item.id || idx;
|
const itemId = item.id ?? idx;
|
||||||
const isExpanded = expandedRightItems.has(itemId);
|
const isExpanded = expandedRightItems.has(itemId);
|
||||||
|
|
||||||
// 표시할 컬럼 결정
|
// 표시할 컬럼 결정
|
||||||
|
|
@ -3111,7 +3216,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const detailColumns = columnsToShow.slice(summaryCount);
|
const detailColumns = columnsToShow.slice(summaryCount);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={itemId} className="rounded-lg border bg-white p-3">
|
<div key={`${itemId}-${idx}`} className="rounded-lg border bg-white p-3">
|
||||||
<div
|
<div
|
||||||
className="flex cursor-pointer items-start justify-between"
|
className="flex cursor-pointer items-start justify-between"
|
||||||
onClick={() => toggleRightItemExpansion(itemId)}
|
onClick={() => toggleRightItemExpansion(itemId)}
|
||||||
|
|
@ -3301,10 +3406,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 bg-white">
|
<tbody className="divide-y divide-gray-200 bg-white">
|
||||||
{filteredData.map((item, idx) => {
|
{filteredData.map((item, idx) => {
|
||||||
const itemId = item.id || item.ID || idx;
|
const itemId = item.id || item.ID;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={itemId} className="hover:bg-accent transition-colors">
|
<tr key={itemId != null ? `${itemId}-${idx}` : idx} className="hover:bg-accent transition-colors">
|
||||||
{columnsToShow.map((col, colIdx) => (
|
{columnsToShow.map((col, colIdx) => (
|
||||||
<td
|
<td
|
||||||
key={colIdx}
|
key={colIdx}
|
||||||
|
|
@ -3418,7 +3523,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={itemId}
|
key={`${itemId}-${index}`}
|
||||||
className="bg-card overflow-hidden rounded-lg border shadow-sm transition-all hover:shadow-md"
|
className="bg-card overflow-hidden rounded-lg border shadow-sm transition-all hover:shadow-md"
|
||||||
>
|
>
|
||||||
{/* 요약 정보 */}
|
{/* 요약 정보 */}
|
||||||
|
|
|
||||||
|
|
@ -940,23 +940,35 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 일반 타입 또는 카테고리 조회 실패 시: 현재 데이터 기반
|
// 백엔드 DISTINCT API로 전체 고유값 조회 (페이징과 무관하게 모든 값 반환)
|
||||||
|
try {
|
||||||
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
|
const response = await apiClient.get(`/entity/${tableConfig.selectedTable}/distinct/${columnName}`);
|
||||||
|
|
||||||
|
if (response.data.success && response.data.data && response.data.data.length > 0) {
|
||||||
|
return response.data.data.map((item: any) => ({
|
||||||
|
value: String(item.value),
|
||||||
|
label: String(item.label),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// DISTINCT API 실패 시 현재 데이터 기반으로 fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback: 현재 로드된 데이터에서 고유 값 추출
|
||||||
const isLabelType = ["category", "entity", "code"].includes(inputType);
|
const isLabelType = ["category", "entity", "code"].includes(inputType);
|
||||||
const labelField = isLabelType ? `${columnName}_name` : columnName;
|
const labelField = isLabelType ? `${columnName}_name` : columnName;
|
||||||
|
|
||||||
// 현재 로드된 데이터에서 고유 값 추출
|
const uniqueValuesMap = new Map<string, string>();
|
||||||
const uniqueValuesMap = new Map<string, string>(); // value -> label
|
|
||||||
|
|
||||||
data.forEach((row) => {
|
data.forEach((row) => {
|
||||||
const value = row[columnName];
|
const value = row[columnName];
|
||||||
if (value !== null && value !== undefined && value !== "") {
|
if (value !== null && value !== undefined && value !== "") {
|
||||||
// 백엔드 조인된 _name 필드 사용 (없으면 원본 값)
|
|
||||||
const label = isLabelType && row[labelField] ? row[labelField] : String(value);
|
const label = isLabelType && row[labelField] ? row[labelField] : String(value);
|
||||||
uniqueValuesMap.set(String(value), label);
|
uniqueValuesMap.set(String(value), label);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Map을 배열로 변환하고 라벨 기준으로 정렬
|
|
||||||
const result = Array.from(uniqueValuesMap.entries())
|
const result = Array.from(uniqueValuesMap.entries())
|
||||||
.map(([value, label]) => ({
|
.map(([value, label]) => ({
|
||||||
value: value,
|
value: value,
|
||||||
|
|
@ -4192,9 +4204,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
|
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
|
||||||
const inputType = meta?.inputType || column.inputType;
|
const inputType = meta?.inputType || column.inputType;
|
||||||
|
|
||||||
// 🖼️ 이미지 타입: 작은 썸네일 표시
|
// 🖼️ 이미지 타입: 작은 썸네일 표시 (다중 이미지인 경우 대표 이미지 1개만)
|
||||||
if (inputType === "image" && value && typeof value === "string") {
|
if (inputType === "image" && value && typeof value === "string") {
|
||||||
const imageUrl = getFullImageUrl(value);
|
const firstImage = value.includes(",") ? value.split(",")[0].trim() : value;
|
||||||
|
const imageUrl = getFullImageUrl(firstImage);
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
|
|
@ -4307,7 +4320,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
// 다중 값인 경우: 여러 배지 렌더링
|
// 다중 값인 경우: 여러 배지 렌더링
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-nowrap gap-1 overflow-hidden">
|
||||||
{values.map((val, idx) => {
|
{values.map((val, idx) => {
|
||||||
const categoryData = mapping?.[val];
|
const categoryData = mapping?.[val];
|
||||||
const displayLabel = categoryData?.label || val;
|
const displayLabel = categoryData?.label || val;
|
||||||
|
|
@ -4316,7 +4329,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
|
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
|
||||||
if (!displayColor || displayColor === "none" || !categoryData) {
|
if (!displayColor || displayColor === "none" || !categoryData) {
|
||||||
return (
|
return (
|
||||||
<span key={idx} className="text-sm">
|
<span key={idx} className="shrink-0 whitespace-nowrap text-sm">
|
||||||
{displayLabel}
|
{displayLabel}
|
||||||
{idx < values.length - 1 && ", "}
|
{idx < values.length - 1 && ", "}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -4330,7 +4343,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
backgroundColor: displayColor,
|
backgroundColor: displayColor,
|
||||||
borderColor: displayColor,
|
borderColor: displayColor,
|
||||||
}}
|
}}
|
||||||
className="text-white"
|
className="shrink-0 whitespace-nowrap text-white"
|
||||||
>
|
>
|
||||||
{displayLabel}
|
{displayLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
|
||||||
|
|
@ -247,6 +247,10 @@ export function UniversalFormModalComponent({
|
||||||
|
|
||||||
// 폼 데이터 상태
|
// 폼 데이터 상태
|
||||||
const [formData, setFormData] = useState<FormDataState>({});
|
const [formData, setFormData] = useState<FormDataState>({});
|
||||||
|
// formDataRef: 항상 최신 formData를 유지하는 ref
|
||||||
|
// React 상태 업데이트는 비동기적이므로, handleBeforeFormSave 등에서
|
||||||
|
// 클로저의 formData가 오래된 값을 참조하는 문제를 방지
|
||||||
|
const formDataRef = useRef<FormDataState>({});
|
||||||
const [, setOriginalData] = useState<Record<string, any>>({});
|
const [, setOriginalData] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
// 반복 섹션 데이터
|
// 반복 섹션 데이터
|
||||||
|
|
@ -398,18 +402,19 @@ export function UniversalFormModalComponent({
|
||||||
console.log("[UniversalFormModal] beforeFormSave 이벤트 수신");
|
console.log("[UniversalFormModal] beforeFormSave 이벤트 수신");
|
||||||
console.log("[UniversalFormModal] 설정된 필드 목록:", Array.from(configuredFields));
|
console.log("[UniversalFormModal] 설정된 필드 목록:", Array.from(configuredFields));
|
||||||
|
|
||||||
|
// formDataRef.current 사용: blur+click 동시 처리 시 클로저의 formData가 오래된 문제 방지
|
||||||
|
const latestFormData = formDataRef.current;
|
||||||
|
|
||||||
// 🆕 시스템 필드 병합: id는 설정 여부와 관계없이 항상 전달 (UPDATE/INSERT 판단용)
|
// 🆕 시스템 필드 병합: id는 설정 여부와 관계없이 항상 전달 (UPDATE/INSERT 판단용)
|
||||||
// - 신규 등록: formData.id가 없으므로 영향 없음
|
if (latestFormData.id !== undefined && latestFormData.id !== null && latestFormData.id !== "") {
|
||||||
// - 편집 모드: formData.id가 있으면 메인 테이블 UPDATE에 사용
|
event.detail.formData.id = latestFormData.id;
|
||||||
if (formData.id !== undefined && formData.id !== null && formData.id !== "") {
|
console.log(`[UniversalFormModal] 시스템 필드 병합: id =`, latestFormData.id);
|
||||||
event.detail.formData.id = formData.id;
|
|
||||||
console.log(`[UniversalFormModal] 시스템 필드 병합: id =`, formData.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UniversalFormModal에 설정된 필드만 병합 (채번 규칙 포함)
|
// UniversalFormModal에 설정된 필드만 병합 (채번 규칙 포함)
|
||||||
// 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀
|
// 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀
|
||||||
// (UniversalFormModal이 해당 필드의 주인이므로)
|
// (UniversalFormModal이 해당 필드의 주인이므로)
|
||||||
for (const [key, value] of Object.entries(formData)) {
|
for (const [key, value] of Object.entries(latestFormData)) {
|
||||||
// 설정에 정의된 필드 또는 채번 규칙 ID 필드만 병합
|
// 설정에 정의된 필드 또는 채번 규칙 ID 필드만 병합
|
||||||
const isConfiguredField = configuredFields.has(key);
|
const isConfiguredField = configuredFields.has(key);
|
||||||
const isNumberingRuleId = key.endsWith("_numberingRuleId");
|
const isNumberingRuleId = key.endsWith("_numberingRuleId");
|
||||||
|
|
@ -432,17 +437,13 @@ export function UniversalFormModalComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 테이블 섹션 데이터 병합 (품목 리스트 등)
|
// 🆕 테이블 섹션 데이터 병합 (품목 리스트 등)
|
||||||
// 참고: initializeForm에서 DB 로드 시 __tableSection_ (더블),
|
// formDataRef.current(= latestFormData) 사용: React 상태 커밋 전에도 최신 데이터 보장
|
||||||
// handleTableDataChange에서 수정 시 _tableSection_ (싱글) 사용
|
for (const [key, value] of Object.entries(latestFormData)) {
|
||||||
for (const [key, value] of Object.entries(formData)) {
|
// _tableSection_ 과 __tableSection_ 모두 원본 키 그대로 전달
|
||||||
// 싱글/더블 언더스코어 모두 처리
|
// buttonActions.ts에서 DB데이터(__tableSection_)와 수정데이터(_tableSection_)를 구분하여 병합
|
||||||
if ((key.startsWith("_tableSection_") || key.startsWith("__tableSection_")) && Array.isArray(value)) {
|
if ((key.startsWith("_tableSection_") || key.startsWith("__tableSection_")) && Array.isArray(value)) {
|
||||||
// 저장 시에는 _tableSection_ 키로 통일 (buttonActions.ts에서 이 키를 기대)
|
event.detail.formData[key] = value;
|
||||||
const normalizedKey = key.startsWith("__tableSection_")
|
console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key}, ${value.length}개 항목`);
|
||||||
? key.replace("__tableSection_", "_tableSection_")
|
|
||||||
: key;
|
|
||||||
event.detail.formData[normalizedKey] = value;
|
|
||||||
console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key} → ${normalizedKey}, ${value.length}개 항목`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 원본 테이블 섹션 데이터도 병합 (삭제 추적용)
|
// 🆕 원본 테이블 섹션 데이터도 병합 (삭제 추적용)
|
||||||
|
|
@ -457,6 +458,22 @@ export function UniversalFormModalComponent({
|
||||||
event.detail.formData._originalGroupedData = originalGroupedData;
|
event.detail.formData._originalGroupedData = originalGroupedData;
|
||||||
console.log(`[UniversalFormModal] 원본 그룹 데이터 병합: ${originalGroupedData.length}개`);
|
console.log(`[UniversalFormModal] 원본 그룹 데이터 병합: ${originalGroupedData.length}개`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 부모 formData의 중첩 객체(modalKey)도 최신 데이터로 업데이트
|
||||||
|
// onChange(setTimeout)가 아직 부모에 전파되지 않았을 수 있으므로 직접 업데이트
|
||||||
|
for (const parentKey of Object.keys(event.detail.formData)) {
|
||||||
|
const parentValue = event.detail.formData[parentKey];
|
||||||
|
if (parentValue && typeof parentValue === "object" && !Array.isArray(parentValue)) {
|
||||||
|
const hasTableSection = Object.keys(parentValue).some(
|
||||||
|
(k) => k.startsWith("_tableSection_") || k.startsWith("__tableSection_"),
|
||||||
|
);
|
||||||
|
if (hasTableSection) {
|
||||||
|
event.detail.formData[parentKey] = { ...latestFormData };
|
||||||
|
console.log(`[UniversalFormModal] 부모 중첩 객체 업데이트: ${parentKey}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
|
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
|
||||||
|
|
@ -482,10 +499,11 @@ export function UniversalFormModalComponent({
|
||||||
|
|
||||||
// 테이블 섹션 데이터 설정
|
// 테이블 섹션 데이터 설정
|
||||||
const tableSectionKey = `_tableSection_${tableSection.id}`;
|
const tableSectionKey = `_tableSection_${tableSection.id}`;
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => {
|
||||||
...prev,
|
const newData = { ...prev, [tableSectionKey]: _groupedData };
|
||||||
[tableSectionKey]: _groupedData,
|
formDataRef.current = newData;
|
||||||
}));
|
return newData;
|
||||||
|
});
|
||||||
|
|
||||||
groupedDataInitializedRef.current = true;
|
groupedDataInitializedRef.current = true;
|
||||||
}, [_groupedData, config.sections]);
|
}, [_groupedData, config.sections]);
|
||||||
|
|
@ -965,6 +983,7 @@ export function UniversalFormModalComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormData(newFormData);
|
setFormData(newFormData);
|
||||||
|
formDataRef.current = newFormData;
|
||||||
setRepeatSections(newRepeatSections);
|
setRepeatSections(newRepeatSections);
|
||||||
setCollapsedSections(newCollapsed);
|
setCollapsedSections(newCollapsed);
|
||||||
setActivatedOptionalFieldGroups(newActivatedGroups);
|
setActivatedOptionalFieldGroups(newActivatedGroups);
|
||||||
|
|
@ -1132,6 +1151,9 @@ export function UniversalFormModalComponent({
|
||||||
console.log(`[연쇄 드롭다운] 부모 ${columnName} 변경 → 자식 ${childField} 초기화`);
|
console.log(`[연쇄 드롭다운] 부모 ${columnName} 변경 → 자식 ${childField} 초기화`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ref 즉시 업데이트 (React 상태 커밋 전에도 최신 데이터 접근 가능)
|
||||||
|
formDataRef.current = newData;
|
||||||
|
|
||||||
// onChange는 렌더링 외부에서 호출해야 함 (setTimeout 사용)
|
// onChange는 렌더링 외부에서 호출해야 함 (setTimeout 사용)
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
setTimeout(() => onChange(newData), 0);
|
setTimeout(() => onChange(newData), 0);
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,544 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Search, Plus, Trash2, Edit, ListOrdered, Package, Star } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { ItemRoutingConfig, ItemRoutingComponentProps } from "./types";
|
||||||
|
import { defaultConfig } from "./config";
|
||||||
|
import { useItemRouting } from "./hooks/useItemRouting";
|
||||||
|
|
||||||
|
export function ItemRoutingComponent({
|
||||||
|
config: configProp,
|
||||||
|
isPreview,
|
||||||
|
}: ItemRoutingComponentProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const {
|
||||||
|
config,
|
||||||
|
items,
|
||||||
|
versions,
|
||||||
|
details,
|
||||||
|
loading,
|
||||||
|
selectedItemCode,
|
||||||
|
selectedItemName,
|
||||||
|
selectedVersionId,
|
||||||
|
fetchItems,
|
||||||
|
selectItem,
|
||||||
|
selectVersion,
|
||||||
|
refreshVersions,
|
||||||
|
refreshDetails,
|
||||||
|
deleteDetail,
|
||||||
|
deleteVersion,
|
||||||
|
setDefaultVersion,
|
||||||
|
unsetDefaultVersion,
|
||||||
|
} = useItemRouting(configProp || {});
|
||||||
|
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<{
|
||||||
|
type: "version" | "detail";
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// 초기 로딩 (마운트 시 1회만)
|
||||||
|
const mountedRef = React.useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mountedRef.current) {
|
||||||
|
mountedRef.current = true;
|
||||||
|
fetchItems();
|
||||||
|
}
|
||||||
|
}, [fetchItems]);
|
||||||
|
|
||||||
|
// 모달 저장 성공 감지 -> 데이터 새로고침
|
||||||
|
const refreshVersionsRef = React.useRef(refreshVersions);
|
||||||
|
const refreshDetailsRef = React.useRef(refreshDetails);
|
||||||
|
refreshVersionsRef.current = refreshVersions;
|
||||||
|
refreshDetailsRef.current = refreshDetails;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSaveSuccess = () => {
|
||||||
|
refreshVersionsRef.current();
|
||||||
|
refreshDetailsRef.current();
|
||||||
|
};
|
||||||
|
window.addEventListener("saveSuccessInModal", handleSaveSuccess);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("saveSuccessInModal", handleSaveSuccess);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 품목 검색
|
||||||
|
const handleSearch = useCallback(() => {
|
||||||
|
fetchItems(searchText || undefined);
|
||||||
|
}, [fetchItems, searchText]);
|
||||||
|
|
||||||
|
const handleSearchKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") handleSearch();
|
||||||
|
},
|
||||||
|
[handleSearch]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 버전 추가 모달
|
||||||
|
const handleAddVersion = useCallback(() => {
|
||||||
|
if (!selectedItemCode) {
|
||||||
|
toast({ title: "품목을 먼저 선택해주세요", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const screenId = config.modals.versionAddScreenId;
|
||||||
|
if (!screenId) return;
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("openScreenModal", {
|
||||||
|
detail: {
|
||||||
|
screenId,
|
||||||
|
urlParams: { mode: "add", tableName: config.dataSource.routingVersionTable },
|
||||||
|
splitPanelParentData: {
|
||||||
|
[config.dataSource.routingVersionFkColumn]: selectedItemCode,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [selectedItemCode, config, toast]);
|
||||||
|
|
||||||
|
// 공정 추가 모달
|
||||||
|
const handleAddProcess = useCallback(() => {
|
||||||
|
if (!selectedVersionId) {
|
||||||
|
toast({ title: "라우팅 버전을 먼저 선택해주세요", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const screenId = config.modals.processAddScreenId;
|
||||||
|
if (!screenId) return;
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("openScreenModal", {
|
||||||
|
detail: {
|
||||||
|
screenId,
|
||||||
|
urlParams: { mode: "add", tableName: config.dataSource.routingDetailTable },
|
||||||
|
splitPanelParentData: {
|
||||||
|
[config.dataSource.routingDetailFkColumn]: selectedVersionId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [selectedVersionId, config, toast]);
|
||||||
|
|
||||||
|
// 공정 수정 모달
|
||||||
|
const handleEditProcess = useCallback(
|
||||||
|
(detail: Record<string, any>) => {
|
||||||
|
const screenId = config.modals.processEditScreenId;
|
||||||
|
if (!screenId) return;
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("openScreenModal", {
|
||||||
|
detail: {
|
||||||
|
screenId,
|
||||||
|
urlParams: { mode: "edit", tableName: config.dataSource.routingDetailTable },
|
||||||
|
editData: detail,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[config]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 기본 버전 토글
|
||||||
|
const handleToggleDefault = useCallback(
|
||||||
|
async (versionId: string, currentIsDefault: boolean) => {
|
||||||
|
let success: boolean;
|
||||||
|
if (currentIsDefault) {
|
||||||
|
success = await unsetDefaultVersion(versionId);
|
||||||
|
if (success) toast({ title: "기본 버전이 해제되었습니다" });
|
||||||
|
} else {
|
||||||
|
success = await setDefaultVersion(versionId);
|
||||||
|
if (success) toast({ title: "기본 버전으로 설정되었습니다" });
|
||||||
|
}
|
||||||
|
if (!success) {
|
||||||
|
toast({ title: "기본 버전 변경 실패", variant: "destructive" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setDefaultVersion, unsetDefaultVersion, toast]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 삭제 확인
|
||||||
|
const handleConfirmDelete = useCallback(async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
|
||||||
|
let success = false;
|
||||||
|
if (deleteTarget.type === "version") {
|
||||||
|
success = await deleteVersion(deleteTarget.id);
|
||||||
|
} else {
|
||||||
|
success = await deleteDetail(deleteTarget.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
toast({ title: `${deleteTarget.name} 삭제 완료` });
|
||||||
|
} else {
|
||||||
|
toast({ title: "삭제 실패", variant: "destructive" });
|
||||||
|
}
|
||||||
|
setDeleteTarget(null);
|
||||||
|
}, [deleteTarget, deleteVersion, deleteDetail, toast]);
|
||||||
|
|
||||||
|
const splitRatio = config.splitRatio || 40;
|
||||||
|
|
||||||
|
if (isPreview) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/10 p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<ListOrdered className="mx-auto mb-2 h-8 w-8 text-muted-foreground/50" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
품목별 라우팅 관리
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground/70">
|
||||||
|
품목 선택 - 라우팅 버전 - 공정 순서
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-background">
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* 좌측 패널: 품목 목록 */}
|
||||||
|
<div
|
||||||
|
style={{ width: `${splitRatio}%` }}
|
||||||
|
className="flex shrink-0 flex-col overflow-hidden border-r"
|
||||||
|
>
|
||||||
|
<div className="border-b px-3 py-2">
|
||||||
|
<h3 className="text-sm font-semibold">
|
||||||
|
{config.leftPanelTitle || "품목 목록"}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="flex gap-1.5 border-b px-3 py-2">
|
||||||
|
<Input
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
onKeyDown={handleSearchKeyDown}
|
||||||
|
placeholder="품목명/품번 검색"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="icon" className="h-8 w-8 shrink-0" onClick={handleSearch}>
|
||||||
|
<Search className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 품목 리스트 */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{loading ? "로딩 중..." : "품목이 없습니다"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{items.map((item) => {
|
||||||
|
const itemCode =
|
||||||
|
item[config.dataSource.itemCodeColumn] || item.item_code || item.item_number;
|
||||||
|
const itemName =
|
||||||
|
item[config.dataSource.itemNameColumn] || item.item_name;
|
||||||
|
const isSelected = selectedItemCode === itemCode;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-muted/50",
|
||||||
|
isSelected && "bg-primary/10 font-medium"
|
||||||
|
)}
|
||||||
|
onClick={() => selectItem(itemCode, itemName)}
|
||||||
|
>
|
||||||
|
<Package className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium">{itemName}</p>
|
||||||
|
<p className="truncate text-muted-foreground">{itemCode}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측 패널: 버전 + 공정 */}
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
{selectedItemCode ? (
|
||||||
|
<>
|
||||||
|
{/* 헤더: 선택된 품목 + 버전 추가 */}
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">{selectedItemName}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">{selectedItemCode}</p>
|
||||||
|
</div>
|
||||||
|
{!config.readonly && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1 text-xs"
|
||||||
|
onClick={handleAddVersion}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
{config.versionAddButtonText || "+ 라우팅 버전 추가"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버전 선택 버튼들 */}
|
||||||
|
{versions.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 border-b px-4 py-2">
|
||||||
|
<span className="mr-1 text-xs text-muted-foreground">버전:</span>
|
||||||
|
{versions.map((ver) => {
|
||||||
|
const isActive = selectedVersionId === ver.id;
|
||||||
|
const isDefault = ver.is_default === true;
|
||||||
|
return (
|
||||||
|
<div key={ver.id} className="flex items-center gap-0.5">
|
||||||
|
<Badge
|
||||||
|
variant={isActive ? "default" : "outline"}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer px-2.5 py-0.5 text-xs transition-colors",
|
||||||
|
isActive && "bg-primary text-primary-foreground",
|
||||||
|
isDefault && !isActive && "border-amber-400 bg-amber-50 text-amber-700"
|
||||||
|
)}
|
||||||
|
onClick={() => selectVersion(ver.id)}
|
||||||
|
>
|
||||||
|
{isDefault && <Star className="mr-1 h-3 w-3 fill-current" />}
|
||||||
|
{ver[config.dataSource.routingVersionNameColumn] || ver.version_name || ver.id}
|
||||||
|
</Badge>
|
||||||
|
{!config.readonly && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"h-5 w-5",
|
||||||
|
isDefault
|
||||||
|
? "text-amber-500 hover:text-amber-600"
|
||||||
|
: "text-muted-foreground hover:text-amber-500"
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleToggleDefault(ver.id, isDefault);
|
||||||
|
}}
|
||||||
|
title={isDefault ? "기본 버전 해제" : "기본 버전으로 설정"}
|
||||||
|
>
|
||||||
|
<Star className={cn("h-3 w-3", isDefault && "fill-current")} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDeleteTarget({
|
||||||
|
type: "version",
|
||||||
|
id: ver.id,
|
||||||
|
name: ver.version_name || ver.id,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border-b px-4 py-3 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
라우팅 버전이 없습니다. 버전을 추가해주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 공정 테이블 */}
|
||||||
|
{selectedVersionId ? (
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
{/* 공정 테이블 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2">
|
||||||
|
<h4 className="text-xs font-medium text-muted-foreground">
|
||||||
|
{config.rightPanelTitle || "공정 순서"} ({details.length}건)
|
||||||
|
</h4>
|
||||||
|
{!config.readonly && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1 text-xs"
|
||||||
|
onClick={handleAddProcess}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
{config.processAddButtonText || "+ 공정 추가"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto px-4 pb-4">
|
||||||
|
{details.length === 0 ? (
|
||||||
|
<div className="flex h-32 items-center justify-center">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{loading ? "로딩 중..." : "등록된 공정이 없습니다"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
{config.processColumns.map((col) => (
|
||||||
|
<TableHead
|
||||||
|
key={col.name}
|
||||||
|
style={{ width: col.width ? `${col.width}px` : undefined }}
|
||||||
|
className={cn(
|
||||||
|
"text-xs",
|
||||||
|
col.align === "center" && "text-center",
|
||||||
|
col.align === "right" && "text-right"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{col.label}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
{!config.readonly && (
|
||||||
|
<TableHead className="w-[80px] text-center text-xs">
|
||||||
|
관리
|
||||||
|
</TableHead>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{details.map((detail) => (
|
||||||
|
<TableRow key={detail.id}>
|
||||||
|
{config.processColumns.map((col) => {
|
||||||
|
let cellValue = detail[col.name];
|
||||||
|
if (cellValue == null) {
|
||||||
|
const aliasKey = Object.keys(detail).find(
|
||||||
|
(k) => k.endsWith(`_${col.name}`)
|
||||||
|
);
|
||||||
|
if (aliasKey) cellValue = detail[aliasKey];
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
key={col.name}
|
||||||
|
className={cn(
|
||||||
|
"text-xs",
|
||||||
|
col.align === "center" && "text-center",
|
||||||
|
col.align === "right" && "text-right"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{cellValue ?? "-"}
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!config.readonly && (
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => handleEditProcess(detail)}
|
||||||
|
>
|
||||||
|
<Edit className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||||
|
onClick={() =>
|
||||||
|
setDeleteTarget({
|
||||||
|
type: "detail",
|
||||||
|
id: detail.id,
|
||||||
|
name: `공정 ${detail.seq_no || detail.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
versions.length > 0 && (
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
라우팅 버전을 선택해주세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center text-center">
|
||||||
|
<ListOrdered className="mb-3 h-12 w-12 text-muted-foreground/30" />
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
좌측에서 품목을 선택하세요
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground/70">
|
||||||
|
품목을 선택하면 라우팅 버전별 공정 순서를 관리할 수 있습니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="text-base">삭제 확인</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="text-sm">
|
||||||
|
{deleteTarget?.name}을(를) 삭제하시겠습니까?
|
||||||
|
{deleteTarget?.type === "version" && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
해당 버전에 포함된 모든 공정 정보도 함께 삭제됩니다.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleConfirmDelete}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,780 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Plus, Trash2, Check, ChevronsUpDown } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ItemRoutingConfig, ProcessColumnDef } from "./types";
|
||||||
|
import { defaultConfig } from "./config";
|
||||||
|
|
||||||
|
interface TableInfo {
|
||||||
|
tableName: string;
|
||||||
|
displayName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnInfo {
|
||||||
|
columnName: string;
|
||||||
|
displayName?: string;
|
||||||
|
dataType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScreenInfo {
|
||||||
|
screenId: number;
|
||||||
|
screenName: string;
|
||||||
|
screenCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테이블 셀렉터 Combobox
|
||||||
|
function TableSelector({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
tables,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
tables: TableInfo[];
|
||||||
|
loading: boolean;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const selected = tables.find((t) => t.tableName === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? "로딩 중..."
|
||||||
|
: selected
|
||||||
|
? selected.displayName || selected.tableName
|
||||||
|
: "테이블 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[280px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-4 text-center text-xs">
|
||||||
|
테이블을 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
|
{tables.map((t) => (
|
||||||
|
<CommandItem
|
||||||
|
key={t.tableName}
|
||||||
|
value={`${t.displayName || ""} ${t.tableName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(t.tableName);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
value === t.tableName ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">
|
||||||
|
{t.displayName || t.tableName}
|
||||||
|
</span>
|
||||||
|
{t.displayName && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{t.tableName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼 셀렉터 Combobox
|
||||||
|
function ColumnSelector({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
tableName,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
tableName: string;
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tableName) {
|
||||||
|
setColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { tableManagementApi } = await import(
|
||||||
|
"@/lib/api/tableManagement"
|
||||||
|
);
|
||||||
|
const res = await tableManagementApi.getColumnList(tableName);
|
||||||
|
if (res.success && res.data?.columns) {
|
||||||
|
setColumns(res.data.columns);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, [tableName]);
|
||||||
|
|
||||||
|
const selected = columns.find((c) => c.columnName === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
disabled={loading || !tableName}
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? "로딩..."
|
||||||
|
: !tableName
|
||||||
|
? "테이블 먼저 선택"
|
||||||
|
: selected
|
||||||
|
? selected.displayName || selected.columnName
|
||||||
|
: label || "컬럼 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[260px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-4 text-center text-xs">
|
||||||
|
컬럼을 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
|
{columns.map((c) => (
|
||||||
|
<CommandItem
|
||||||
|
key={c.columnName}
|
||||||
|
value={`${c.displayName || ""} ${c.columnName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(c.columnName);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
value === c.columnName ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">
|
||||||
|
{c.displayName || c.columnName}
|
||||||
|
</span>
|
||||||
|
{c.displayName && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{c.columnName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 화면 셀렉터 Combobox
|
||||||
|
function ScreenSelector({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value?: number;
|
||||||
|
onChange: (v?: number) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [screens, setScreens] = useState<ScreenInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { screenApi } = await import("@/lib/api/screen");
|
||||||
|
const res = await screenApi.getScreens({ page: 1, size: 1000 });
|
||||||
|
setScreens(
|
||||||
|
res.data.map((s: any) => ({
|
||||||
|
screenId: s.screenId,
|
||||||
|
screenName: s.screenName,
|
||||||
|
screenCode: s.screenCode,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selected = screens.find((s) => s.screenId === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? "로딩 중..."
|
||||||
|
: selected
|
||||||
|
? `${selected.screenName} (${selected.screenId})`
|
||||||
|
: "화면 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[350px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="화면 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-4 text-center text-xs">
|
||||||
|
화면을 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[300px] overflow-auto">
|
||||||
|
{screens.map((s) => (
|
||||||
|
<CommandItem
|
||||||
|
key={s.screenId}
|
||||||
|
value={`${s.screenName.toLowerCase()} ${s.screenCode.toLowerCase()} ${s.screenId}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(s.screenId === value ? undefined : s.screenId);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
value === s.screenId ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{s.screenName}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{s.screenCode} (ID: {s.screenId})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공정 테이블 컬럼 셀렉터 (routingDetailTable의 컬럼 목록에서 선택)
|
||||||
|
function ProcessColumnSelector({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
tableName,
|
||||||
|
processTable,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
tableName: string;
|
||||||
|
processTable: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadAll = async () => {
|
||||||
|
if (!tableName) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { tableManagementApi } = await import(
|
||||||
|
"@/lib/api/tableManagement"
|
||||||
|
);
|
||||||
|
const res = await tableManagementApi.getColumnList(tableName);
|
||||||
|
const cols: ColumnInfo[] = [];
|
||||||
|
if (res.success && res.data?.columns) {
|
||||||
|
cols.push(...res.data.columns);
|
||||||
|
}
|
||||||
|
if (processTable && processTable !== tableName) {
|
||||||
|
const res2 = await tableManagementApi.getColumnList(processTable);
|
||||||
|
if (res2.success && res2.data?.columns) {
|
||||||
|
cols.push(
|
||||||
|
...res2.data.columns.map((c: any) => ({
|
||||||
|
...c,
|
||||||
|
columnName: c.columnName,
|
||||||
|
displayName: `[${processTable}] ${c.displayName || c.columnName}`,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setColumns(cols);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadAll();
|
||||||
|
}, [tableName, processTable]);
|
||||||
|
|
||||||
|
const selected = columns.find((c) => c.columnName === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="h-7 w-24 justify-between text-[10px]"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{selected ? selected.displayName || selected.columnName : value || "선택"}
|
||||||
|
<ChevronsUpDown className="ml-1 h-2.5 w-2.5 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[250px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-3 text-center text-xs">
|
||||||
|
없음
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
|
{columns.map((c) => (
|
||||||
|
<CommandItem
|
||||||
|
key={c.columnName}
|
||||||
|
value={`${c.displayName || ""} ${c.columnName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(c.columnName);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-1 h-3 w-3",
|
||||||
|
value === c.columnName ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{c.displayName || c.columnName}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigPanelProps {
|
||||||
|
config: Partial<ItemRoutingConfig>;
|
||||||
|
onChange: (config: Partial<ItemRoutingConfig>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ItemRoutingConfigPanel({
|
||||||
|
config: configProp,
|
||||||
|
onChange,
|
||||||
|
}: ConfigPanelProps) {
|
||||||
|
const config: ItemRoutingConfig = {
|
||||||
|
...defaultConfig,
|
||||||
|
...configProp,
|
||||||
|
dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource },
|
||||||
|
modals: { ...defaultConfig.modals, ...configProp?.modals },
|
||||||
|
processColumns: configProp?.processColumns?.length
|
||||||
|
? configProp.processColumns
|
||||||
|
: defaultConfig.processColumns,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [allTables, setAllTables] = useState<TableInfo[]>([]);
|
||||||
|
const [tablesLoading, setTablesLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
setTablesLoading(true);
|
||||||
|
try {
|
||||||
|
const { tableManagementApi } = await import(
|
||||||
|
"@/lib/api/tableManagement"
|
||||||
|
);
|
||||||
|
const res = await tableManagementApi.getTableList();
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setAllTables(res.data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
setTablesLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const update = (partial: Partial<ItemRoutingConfig>) => {
|
||||||
|
onChange({ ...configProp, ...partial });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDataSource = (field: string, value: string) => {
|
||||||
|
update({ dataSource: { ...config.dataSource, [field]: value } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateModals = (field: string, value: number | undefined) => {
|
||||||
|
update({ modals: { ...config.modals, [field]: value } });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컬럼 관리
|
||||||
|
const addColumn = () => {
|
||||||
|
update({
|
||||||
|
processColumns: [
|
||||||
|
...config.processColumns,
|
||||||
|
{ name: "", label: "새 컬럼", width: 100 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeColumn = (idx: number) => {
|
||||||
|
update({
|
||||||
|
processColumns: config.processColumns.filter((_, i) => i !== idx),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateColumn = (
|
||||||
|
idx: number,
|
||||||
|
field: keyof ProcessColumnDef,
|
||||||
|
value: any
|
||||||
|
) => {
|
||||||
|
const next = [...config.processColumns];
|
||||||
|
next[idx] = { ...next[idx], [field]: value };
|
||||||
|
update({ processColumns: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5 p-4">
|
||||||
|
<h3 className="text-sm font-semibold">품목별 라우팅 설정</h3>
|
||||||
|
|
||||||
|
{/* 데이터 소스 설정 */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
데이터 소스
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">품목 테이블</Label>
|
||||||
|
<TableSelector
|
||||||
|
value={config.dataSource.itemTable}
|
||||||
|
onChange={(v) => updateDataSource("itemTable", v)}
|
||||||
|
tables={allTables}
|
||||||
|
loading={tablesLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">품목명 컬럼</Label>
|
||||||
|
<ColumnSelector
|
||||||
|
value={config.dataSource.itemNameColumn}
|
||||||
|
onChange={(v) => updateDataSource("itemNameColumn", v)}
|
||||||
|
tableName={config.dataSource.itemTable}
|
||||||
|
label="품목명"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">품목코드 컬럼</Label>
|
||||||
|
<ColumnSelector
|
||||||
|
value={config.dataSource.itemCodeColumn}
|
||||||
|
onChange={(v) => updateDataSource("itemCodeColumn", v)}
|
||||||
|
tableName={config.dataSource.itemTable}
|
||||||
|
label="품목코드"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">라우팅 버전 테이블</Label>
|
||||||
|
<TableSelector
|
||||||
|
value={config.dataSource.routingVersionTable}
|
||||||
|
onChange={(v) => updateDataSource("routingVersionTable", v)}
|
||||||
|
tables={allTables}
|
||||||
|
loading={tablesLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">버전 FK 컬럼</Label>
|
||||||
|
<ColumnSelector
|
||||||
|
value={config.dataSource.routingVersionFkColumn}
|
||||||
|
onChange={(v) => updateDataSource("routingVersionFkColumn", v)}
|
||||||
|
tableName={config.dataSource.routingVersionTable}
|
||||||
|
label="FK 컬럼"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">버전명 컬럼</Label>
|
||||||
|
<ColumnSelector
|
||||||
|
value={config.dataSource.routingVersionNameColumn}
|
||||||
|
onChange={(v) =>
|
||||||
|
updateDataSource("routingVersionNameColumn", v)
|
||||||
|
}
|
||||||
|
tableName={config.dataSource.routingVersionTable}
|
||||||
|
label="버전명"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공정 상세 테이블</Label>
|
||||||
|
<TableSelector
|
||||||
|
value={config.dataSource.routingDetailTable}
|
||||||
|
onChange={(v) => updateDataSource("routingDetailTable", v)}
|
||||||
|
tables={allTables}
|
||||||
|
loading={tablesLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공정 상세 FK 컬럼</Label>
|
||||||
|
<ColumnSelector
|
||||||
|
value={config.dataSource.routingDetailFkColumn}
|
||||||
|
onChange={(v) => updateDataSource("routingDetailFkColumn", v)}
|
||||||
|
tableName={config.dataSource.routingDetailTable}
|
||||||
|
label="FK 컬럼"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공정 마스터 테이블</Label>
|
||||||
|
<TableSelector
|
||||||
|
value={config.dataSource.processTable}
|
||||||
|
onChange={(v) => updateDataSource("processTable", v)}
|
||||||
|
tables={allTables}
|
||||||
|
loading={tablesLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공정명 컬럼</Label>
|
||||||
|
<ColumnSelector
|
||||||
|
value={config.dataSource.processNameColumn}
|
||||||
|
onChange={(v) => updateDataSource("processNameColumn", v)}
|
||||||
|
tableName={config.dataSource.processTable}
|
||||||
|
label="공정명"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공정코드 컬럼</Label>
|
||||||
|
<ColumnSelector
|
||||||
|
value={config.dataSource.processCodeColumn}
|
||||||
|
onChange={(v) => updateDataSource("processCodeColumn", v)}
|
||||||
|
tableName={config.dataSource.processTable}
|
||||||
|
label="공정코드"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 모달 설정 */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">모달 연동</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">버전 추가 모달</Label>
|
||||||
|
<ScreenSelector
|
||||||
|
value={config.modals.versionAddScreenId}
|
||||||
|
onChange={(v) => updateModals("versionAddScreenId", v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공정 추가 모달</Label>
|
||||||
|
<ScreenSelector
|
||||||
|
value={config.modals.processAddScreenId}
|
||||||
|
onChange={(v) => updateModals("processAddScreenId", v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공정 수정 모달</Label>
|
||||||
|
<ScreenSelector
|
||||||
|
value={config.modals.processEditScreenId}
|
||||||
|
onChange={(v) => updateModals("processEditScreenId", v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 공정 테이블 컬럼 설정 */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
공정 테이블 컬럼
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 gap-1 text-[10px]"
|
||||||
|
onClick={addColumn}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
컬럼 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{config.processColumns.map((col, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="flex items-center gap-1.5 rounded border bg-muted/30 p-1.5"
|
||||||
|
>
|
||||||
|
<ProcessColumnSelector
|
||||||
|
value={col.name}
|
||||||
|
onChange={(v) => updateColumn(idx, "name", v)}
|
||||||
|
tableName={config.dataSource.routingDetailTable}
|
||||||
|
processTable={config.dataSource.processTable}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={col.label}
|
||||||
|
onChange={(e) => updateColumn(idx, "label", e.target.value)}
|
||||||
|
className="h-7 flex-1 text-[10px]"
|
||||||
|
placeholder="표시명"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={col.width || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateColumn(
|
||||||
|
idx,
|
||||||
|
"width",
|
||||||
|
e.target.value ? Number(e.target.value) : undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-7 w-14 text-[10px]"
|
||||||
|
placeholder="너비"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 shrink-0 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => removeColumn(idx)}
|
||||||
|
disabled={config.processColumns.length <= 1}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* UI 설정 */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">UI 설정</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">좌우 분할 비율 (%)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.splitRatio || 40}
|
||||||
|
onChange={(e) => update({ splitRatio: Number(e.target.value) })}
|
||||||
|
min={20}
|
||||||
|
max={60}
|
||||||
|
className="mt-1 h-8 w-20 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">좌측 패널 제목</Label>
|
||||||
|
<Input
|
||||||
|
value={config.leftPanelTitle || ""}
|
||||||
|
onChange={(e) => update({ leftPanelTitle: e.target.value })}
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">우측 패널 제목</Label>
|
||||||
|
<Input
|
||||||
|
value={config.rightPanelTitle || ""}
|
||||||
|
onChange={(e) => update({ rightPanelTitle: e.target.value })}
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">버전 추가 버튼 텍스트</Label>
|
||||||
|
<Input
|
||||||
|
value={config.versionAddButtonText || ""}
|
||||||
|
onChange={(e) => update({ versionAddButtonText: e.target.value })}
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공정 추가 버튼 텍스트</Label>
|
||||||
|
<Input
|
||||||
|
value={config.processAddButtonText || ""}
|
||||||
|
onChange={(e) => update({ processAddButtonText: e.target.value })}
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={config.autoSelectFirstVersion ?? true}
|
||||||
|
onCheckedChange={(v) => update({ autoSelectFirstVersion: v })}
|
||||||
|
/>
|
||||||
|
<Label className="text-xs">첫 번째 버전 자동 선택</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={config.readonly || false}
|
||||||
|
onCheckedChange={(v) => update({ readonly: v })}
|
||||||
|
/>
|
||||||
|
<Label className="text-xs">읽기 전용 모드</Label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||||
|
import { V2ItemRoutingDefinition } from "./index";
|
||||||
|
import { ItemRoutingComponent } from "./ItemRoutingComponent";
|
||||||
|
|
||||||
|
export class ItemRoutingRenderer extends AutoRegisteringComponentRenderer {
|
||||||
|
static componentDefinition = V2ItemRoutingDefinition;
|
||||||
|
|
||||||
|
render(): React.ReactElement {
|
||||||
|
const { formData, isPreview, config, tableName } = this.props as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ItemRoutingComponent
|
||||||
|
config={(config as object) || {}}
|
||||||
|
formData={formData as Record<string, unknown>}
|
||||||
|
tableName={tableName as string}
|
||||||
|
isPreview={isPreview as boolean}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ItemRoutingRenderer.registerSelf();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
ItemRoutingRenderer.enableHotReload();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { ItemRoutingConfig } from "./types";
|
||||||
|
|
||||||
|
export const defaultConfig: ItemRoutingConfig = {
|
||||||
|
dataSource: {
|
||||||
|
itemTable: "item_info",
|
||||||
|
itemNameColumn: "item_name",
|
||||||
|
itemCodeColumn: "item_number",
|
||||||
|
routingVersionTable: "item_routing_version",
|
||||||
|
routingVersionFkColumn: "item_code",
|
||||||
|
routingVersionNameColumn: "version_name",
|
||||||
|
routingDetailTable: "item_routing_detail",
|
||||||
|
routingDetailFkColumn: "routing_version_id",
|
||||||
|
processTable: "process_mng",
|
||||||
|
processNameColumn: "process_name",
|
||||||
|
processCodeColumn: "process_code",
|
||||||
|
},
|
||||||
|
modals: {
|
||||||
|
versionAddScreenId: 1613,
|
||||||
|
processAddScreenId: 1614,
|
||||||
|
processEditScreenId: 1615,
|
||||||
|
},
|
||||||
|
processColumns: [
|
||||||
|
{ name: "seq_no", label: "순서", width: 60, align: "center" },
|
||||||
|
{ name: "process_code", label: "공정코드", width: 120 },
|
||||||
|
{ name: "work_type", label: "작업유형", width: 100 },
|
||||||
|
{ name: "standard_time", label: "표준시간(분)", width: 100, align: "right" },
|
||||||
|
{ name: "is_required", label: "필수여부", width: 80, align: "center" },
|
||||||
|
{ name: "is_fixed_order", label: "순서고정", width: 80, align: "center" },
|
||||||
|
{ name: "outsource_supplier", label: "외주업체", width: 120 },
|
||||||
|
],
|
||||||
|
splitRatio: 40,
|
||||||
|
leftPanelTitle: "품목 목록",
|
||||||
|
rightPanelTitle: "공정 순서",
|
||||||
|
readonly: false,
|
||||||
|
autoSelectFirstVersion: true,
|
||||||
|
versionAddButtonText: "+ 라우팅 버전 추가",
|
||||||
|
processAddButtonText: "+ 공정 추가",
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,293 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { ItemRoutingConfig, ItemData, RoutingVersionData, RoutingDetailData } from "../types";
|
||||||
|
import { defaultConfig } from "../config";
|
||||||
|
|
||||||
|
const API_BASE = "/process-work-standard";
|
||||||
|
|
||||||
|
export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
|
||||||
|
const configKey = useMemo(
|
||||||
|
() => JSON.stringify(configPartial),
|
||||||
|
[configPartial]
|
||||||
|
);
|
||||||
|
|
||||||
|
const config: ItemRoutingConfig = useMemo(() => ({
|
||||||
|
...defaultConfig,
|
||||||
|
...configPartial,
|
||||||
|
dataSource: { ...defaultConfig.dataSource, ...configPartial?.dataSource },
|
||||||
|
modals: { ...defaultConfig.modals, ...configPartial?.modals },
|
||||||
|
processColumns: configPartial?.processColumns?.length
|
||||||
|
? configPartial.processColumns
|
||||||
|
: defaultConfig.processColumns,
|
||||||
|
}), [configKey]);
|
||||||
|
|
||||||
|
const configRef = useRef(config);
|
||||||
|
configRef.current = config;
|
||||||
|
|
||||||
|
const [items, setItems] = useState<ItemData[]>([]);
|
||||||
|
const [versions, setVersions] = useState<RoutingVersionData[]>([]);
|
||||||
|
const [details, setDetails] = useState<RoutingDetailData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// 선택 상태
|
||||||
|
const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
|
||||||
|
const [selectedItemName, setSelectedItemName] = useState<string | null>(null);
|
||||||
|
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 품목 목록 조회
|
||||||
|
const fetchItems = useCallback(
|
||||||
|
async (search?: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const ds = configRef.current.dataSource;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
tableName: ds.itemTable,
|
||||||
|
nameColumn: ds.itemNameColumn,
|
||||||
|
codeColumn: ds.itemCodeColumn,
|
||||||
|
routingTable: ds.routingVersionTable,
|
||||||
|
routingFkColumn: ds.routingVersionFkColumn,
|
||||||
|
...(search ? { search } : {}),
|
||||||
|
});
|
||||||
|
const res = await apiClient.get(`${API_BASE}/items?${params}`);
|
||||||
|
if (res.data?.success) {
|
||||||
|
setItems(res.data.data || []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("품목 조회 실패", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[configKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 라우팅 버전 목록 조회
|
||||||
|
const fetchVersions = useCallback(
|
||||||
|
async (itemCode: string) => {
|
||||||
|
try {
|
||||||
|
const ds = configRef.current.dataSource;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
routingVersionTable: ds.routingVersionTable,
|
||||||
|
routingDetailTable: ds.routingDetailTable,
|
||||||
|
routingFkColumn: ds.routingVersionFkColumn,
|
||||||
|
processTable: ds.processTable,
|
||||||
|
processNameColumn: ds.processNameColumn,
|
||||||
|
processCodeColumn: ds.processCodeColumn,
|
||||||
|
});
|
||||||
|
const res = await apiClient.get(
|
||||||
|
`${API_BASE}/items/${encodeURIComponent(itemCode)}/routings?${params}`
|
||||||
|
);
|
||||||
|
if (res.data?.success) {
|
||||||
|
const routingData = res.data.data || [];
|
||||||
|
setVersions(routingData);
|
||||||
|
return routingData;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("라우팅 버전 조회 실패", err);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[configKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 공정 상세 목록 조회 (특정 버전의 공정들) - entity join 포함
|
||||||
|
const fetchDetails = useCallback(
|
||||||
|
async (versionId: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const ds = configRef.current.dataSource;
|
||||||
|
const searchConditions = {
|
||||||
|
[ds.routingDetailFkColumn]: { value: versionId, operator: "equals" },
|
||||||
|
};
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: "1",
|
||||||
|
size: "1000",
|
||||||
|
search: JSON.stringify(searchConditions),
|
||||||
|
sortBy: "seq_no",
|
||||||
|
sortOrder: "ASC",
|
||||||
|
enableEntityJoin: "true",
|
||||||
|
});
|
||||||
|
const res = await apiClient.get(
|
||||||
|
`/table-management/tables/${ds.routingDetailTable}/data-with-joins?${params}`
|
||||||
|
);
|
||||||
|
if (res.data?.success) {
|
||||||
|
const result = res.data.data;
|
||||||
|
setDetails(Array.isArray(result) ? result : result?.data || []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("공정 상세 조회 실패", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[configKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 품목 선택
|
||||||
|
const selectItem = useCallback(
|
||||||
|
async (itemCode: string, itemName: string) => {
|
||||||
|
setSelectedItemCode(itemCode);
|
||||||
|
setSelectedItemName(itemName);
|
||||||
|
setSelectedVersionId(null);
|
||||||
|
setDetails([]);
|
||||||
|
|
||||||
|
const versionList = await fetchVersions(itemCode);
|
||||||
|
|
||||||
|
if (versionList.length > 0) {
|
||||||
|
// 기본 버전 우선, 없으면 첫번째 버전 선택
|
||||||
|
const defaultVersion = versionList.find((v: RoutingVersionData) => v.is_default);
|
||||||
|
const targetVersion = defaultVersion || (configRef.current.autoSelectFirstVersion ? versionList[0] : null);
|
||||||
|
if (targetVersion) {
|
||||||
|
setSelectedVersionId(targetVersion.id);
|
||||||
|
await fetchDetails(targetVersion.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fetchVersions, fetchDetails]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 버전 선택
|
||||||
|
const selectVersion = useCallback(
|
||||||
|
async (versionId: string) => {
|
||||||
|
setSelectedVersionId(versionId);
|
||||||
|
await fetchDetails(versionId);
|
||||||
|
},
|
||||||
|
[fetchDetails]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 모달에서 데이터 변경 후 새로고침
|
||||||
|
const refreshVersions = useCallback(async () => {
|
||||||
|
if (selectedItemCode) {
|
||||||
|
const versionList = await fetchVersions(selectedItemCode);
|
||||||
|
if (selectedVersionId) {
|
||||||
|
await fetchDetails(selectedVersionId);
|
||||||
|
} else if (versionList.length > 0) {
|
||||||
|
const lastVersion = versionList[versionList.length - 1];
|
||||||
|
setSelectedVersionId(lastVersion.id);
|
||||||
|
await fetchDetails(lastVersion.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedItemCode, selectedVersionId, fetchVersions, fetchDetails]);
|
||||||
|
|
||||||
|
const refreshDetails = useCallback(async () => {
|
||||||
|
if (selectedVersionId) {
|
||||||
|
await fetchDetails(selectedVersionId);
|
||||||
|
}
|
||||||
|
}, [selectedVersionId, fetchDetails]);
|
||||||
|
|
||||||
|
// 공정 삭제
|
||||||
|
const deleteDetail = useCallback(
|
||||||
|
async (detailId: string) => {
|
||||||
|
try {
|
||||||
|
const ds = configRef.current.dataSource;
|
||||||
|
const res = await apiClient.delete(
|
||||||
|
`/table-management/tables/${ds.routingDetailTable}/delete`,
|
||||||
|
{ data: [{ id: detailId }] }
|
||||||
|
);
|
||||||
|
if (res.data?.success) {
|
||||||
|
await refreshDetails();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("공정 삭제 실패", err);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[refreshDetails]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 버전 삭제
|
||||||
|
const deleteVersion = useCallback(
|
||||||
|
async (versionId: string) => {
|
||||||
|
try {
|
||||||
|
const ds = configRef.current.dataSource;
|
||||||
|
const res = await apiClient.delete(
|
||||||
|
`/table-management/tables/${ds.routingVersionTable}/delete`,
|
||||||
|
{ data: [{ id: versionId }] }
|
||||||
|
);
|
||||||
|
if (res.data?.success) {
|
||||||
|
if (selectedVersionId === versionId) {
|
||||||
|
setSelectedVersionId(null);
|
||||||
|
setDetails([]);
|
||||||
|
}
|
||||||
|
await refreshVersions();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("버전 삭제 실패", err);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[selectedVersionId, refreshVersions]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 기본 버전 설정
|
||||||
|
const setDefaultVersion = useCallback(
|
||||||
|
async (versionId: string) => {
|
||||||
|
try {
|
||||||
|
const ds = configRef.current.dataSource;
|
||||||
|
const res = await apiClient.put(`${API_BASE}/versions/${versionId}/set-default`, {
|
||||||
|
routingVersionTable: ds.routingVersionTable,
|
||||||
|
routingFkColumn: ds.routingVersionFkColumn,
|
||||||
|
});
|
||||||
|
if (res.data?.success) {
|
||||||
|
if (selectedItemCode) {
|
||||||
|
await fetchVersions(selectedItemCode);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("기본 버전 설정 실패", err);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[selectedItemCode, fetchVersions]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 기본 버전 해제
|
||||||
|
const unsetDefaultVersion = useCallback(
|
||||||
|
async (versionId: string) => {
|
||||||
|
try {
|
||||||
|
const ds = configRef.current.dataSource;
|
||||||
|
const res = await apiClient.put(`${API_BASE}/versions/${versionId}/unset-default`, {
|
||||||
|
routingVersionTable: ds.routingVersionTable,
|
||||||
|
});
|
||||||
|
if (res.data?.success) {
|
||||||
|
if (selectedItemCode) {
|
||||||
|
await fetchVersions(selectedItemCode);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("기본 버전 해제 실패", err);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[selectedItemCode, fetchVersions]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
items,
|
||||||
|
versions,
|
||||||
|
details,
|
||||||
|
loading,
|
||||||
|
selectedItemCode,
|
||||||
|
selectedItemName,
|
||||||
|
selectedVersionId,
|
||||||
|
fetchItems,
|
||||||
|
selectItem,
|
||||||
|
selectVersion,
|
||||||
|
refreshVersions,
|
||||||
|
refreshDetails,
|
||||||
|
deleteDetail,
|
||||||
|
deleteVersion,
|
||||||
|
setDefaultVersion,
|
||||||
|
unsetDefaultVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||||
|
import { ComponentCategory } from "@/types/component";
|
||||||
|
import { ItemRoutingComponent } from "./ItemRoutingComponent";
|
||||||
|
import { ItemRoutingConfigPanel } from "./ItemRoutingConfigPanel";
|
||||||
|
import { defaultConfig } from "./config";
|
||||||
|
|
||||||
|
export const V2ItemRoutingDefinition = createComponentDefinition({
|
||||||
|
id: "v2-item-routing",
|
||||||
|
name: "품목별 라우팅",
|
||||||
|
nameEng: "Item Routing",
|
||||||
|
description: "품목별 라우팅 버전과 공정 순서를 관리하는 3단계 계층 컴포넌트",
|
||||||
|
category: ComponentCategory.INPUT,
|
||||||
|
webType: "component",
|
||||||
|
component: ItemRoutingComponent,
|
||||||
|
defaultConfig: defaultConfig,
|
||||||
|
defaultSize: {
|
||||||
|
width: 1400,
|
||||||
|
height: 800,
|
||||||
|
gridColumnSpan: "12",
|
||||||
|
},
|
||||||
|
configPanel: ItemRoutingConfigPanel,
|
||||||
|
icon: "ListOrdered",
|
||||||
|
tags: ["라우팅", "공정", "품목", "버전", "제조", "생산"],
|
||||||
|
version: "1.0.0",
|
||||||
|
author: "개발팀",
|
||||||
|
documentation: `
|
||||||
|
품목별 라우팅 버전과 공정 순서를 관리하는 전용 컴포넌트입니다.
|
||||||
|
|
||||||
|
## 주요 기능
|
||||||
|
- 좌측: 품목 목록 검색 및 선택
|
||||||
|
- 우측 상단: 라우팅 버전 선택 (Badge 버튼) 및 추가/삭제
|
||||||
|
- 우측 하단: 선택된 버전의 공정 순서 테이블 (추가/수정/삭제)
|
||||||
|
- 기존 모달 화면 재활용 (1613, 1614, 1615)
|
||||||
|
|
||||||
|
## 커스터마이징
|
||||||
|
- 데이터 소스 테이블/컬럼 변경 가능
|
||||||
|
- 모달 화면 ID 변경 가능
|
||||||
|
- 공정 테이블 컬럼 추가/삭제 가능
|
||||||
|
- 좌우 분할 비율, 패널 제목, 버튼 텍스트 변경 가능
|
||||||
|
- 읽기 전용 모드 지원
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ItemRoutingConfig,
|
||||||
|
ItemRoutingComponentProps,
|
||||||
|
ItemRoutingDataSource,
|
||||||
|
ItemRoutingModals,
|
||||||
|
ProcessColumnDef,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export { ItemRoutingComponent } from "./ItemRoutingComponent";
|
||||||
|
export { ItemRoutingRenderer } from "./ItemRoutingRenderer";
|
||||||
|
export { ItemRoutingConfigPanel } from "./ItemRoutingConfigPanel";
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
/**
|
||||||
|
* 품목별 라우팅 관리 컴포넌트 타입 정의
|
||||||
|
*
|
||||||
|
* 3단계 계층: item_info → item_routing_version → item_routing_detail
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 데이터 소스 설정
|
||||||
|
export interface ItemRoutingDataSource {
|
||||||
|
itemTable: string;
|
||||||
|
itemNameColumn: string;
|
||||||
|
itemCodeColumn: string;
|
||||||
|
routingVersionTable: string;
|
||||||
|
routingVersionFkColumn: string; // item_routing_version에서 item_code를 가리키는 FK
|
||||||
|
routingVersionNameColumn: string;
|
||||||
|
routingDetailTable: string;
|
||||||
|
routingDetailFkColumn: string; // item_routing_detail에서 routing_version_id를 가리키는 FK
|
||||||
|
processTable: string;
|
||||||
|
processNameColumn: string;
|
||||||
|
processCodeColumn: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모달 연동 설정
|
||||||
|
export interface ItemRoutingModals {
|
||||||
|
versionAddScreenId?: number;
|
||||||
|
processAddScreenId?: number;
|
||||||
|
processEditScreenId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공정 테이블 컬럼 정의
|
||||||
|
export interface ProcessColumnDef {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
width?: number;
|
||||||
|
align?: "left" | "center" | "right";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전체 Config
|
||||||
|
export interface ItemRoutingConfig {
|
||||||
|
dataSource: ItemRoutingDataSource;
|
||||||
|
modals: ItemRoutingModals;
|
||||||
|
processColumns: ProcessColumnDef[];
|
||||||
|
splitRatio?: number;
|
||||||
|
leftPanelTitle?: string;
|
||||||
|
rightPanelTitle?: string;
|
||||||
|
readonly?: boolean;
|
||||||
|
autoSelectFirstVersion?: boolean;
|
||||||
|
versionAddButtonText?: string;
|
||||||
|
processAddButtonText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컴포넌트 Props
|
||||||
|
export interface ItemRoutingComponentProps {
|
||||||
|
config: Partial<ItemRoutingConfig>;
|
||||||
|
formData?: Record<string, any>;
|
||||||
|
isPreview?: boolean;
|
||||||
|
tableName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 모델
|
||||||
|
export interface ItemData {
|
||||||
|
id: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutingVersionData {
|
||||||
|
id: string;
|
||||||
|
version_name: string;
|
||||||
|
is_default?: boolean;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutingDetailData {
|
||||||
|
id: string;
|
||||||
|
routing_version_id: string;
|
||||||
|
seq_no: string;
|
||||||
|
process_code: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,445 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { WorkItemDetail, DetailTypeDefinition, InspectionStandard } from "../types";
|
||||||
|
import { InspectionStandardLookup } from "./InspectionStandardLookup";
|
||||||
|
|
||||||
|
interface DetailFormModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (data: Partial<WorkItemDetail>) => void;
|
||||||
|
detailTypes: DetailTypeDefinition[];
|
||||||
|
editData?: WorkItemDetail | null;
|
||||||
|
mode: "add" | "edit";
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOOKUP_TARGETS = [
|
||||||
|
{ value: "equipment", label: "설비정보" },
|
||||||
|
{ value: "material", label: "자재정보" },
|
||||||
|
{ value: "worker", label: "작업자정보" },
|
||||||
|
{ value: "tool", label: "공구정보" },
|
||||||
|
{ value: "document", label: "문서정보" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const INPUT_TYPES = [
|
||||||
|
{ value: "text", label: "텍스트" },
|
||||||
|
{ value: "number", label: "숫자" },
|
||||||
|
{ value: "date", label: "날짜" },
|
||||||
|
{ value: "textarea", label: "장문텍스트" },
|
||||||
|
{ value: "select", label: "선택형" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DetailFormModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
detailTypes,
|
||||||
|
editData,
|
||||||
|
mode,
|
||||||
|
}: DetailFormModalProps) {
|
||||||
|
const [formData, setFormData] = useState<Partial<WorkItemDetail>>({});
|
||||||
|
const [inspectionLookupOpen, setInspectionLookupOpen] = useState(false);
|
||||||
|
const [selectedInspection, setSelectedInspection] = useState<InspectionStandard | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
if (mode === "edit" && editData) {
|
||||||
|
setFormData({ ...editData });
|
||||||
|
if (editData.inspection_code) {
|
||||||
|
setSelectedInspection({
|
||||||
|
id: "",
|
||||||
|
inspection_code: editData.inspection_code,
|
||||||
|
inspection_item: editData.content || "",
|
||||||
|
inspection_method: editData.inspection_method || "",
|
||||||
|
unit: editData.unit || "",
|
||||||
|
lower_limit: editData.lower_limit || "",
|
||||||
|
upper_limit: editData.upper_limit || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setFormData({
|
||||||
|
detail_type: detailTypes[0]?.value || "",
|
||||||
|
content: "",
|
||||||
|
is_required: "Y",
|
||||||
|
});
|
||||||
|
setSelectedInspection(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open, mode, editData, detailTypes]);
|
||||||
|
|
||||||
|
const updateField = (field: string, value: any) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInspectionSelect = (item: InspectionStandard) => {
|
||||||
|
setSelectedInspection(item);
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
inspection_code: item.inspection_code,
|
||||||
|
content: item.inspection_item,
|
||||||
|
inspection_method: item.inspection_method,
|
||||||
|
unit: item.unit,
|
||||||
|
lower_limit: item.lower_limit || "",
|
||||||
|
upper_limit: item.upper_limit || "",
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!formData.detail_type) return;
|
||||||
|
|
||||||
|
const type = formData.detail_type;
|
||||||
|
|
||||||
|
if (type === "check" && !formData.content?.trim()) return;
|
||||||
|
if (type === "inspect" && !formData.content?.trim()) return;
|
||||||
|
if (type === "procedure" && !formData.content?.trim()) return;
|
||||||
|
if (type === "input" && !formData.content?.trim()) return;
|
||||||
|
if (type === "info" && !formData.lookup_target) return;
|
||||||
|
|
||||||
|
onSubmit(formData);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentType = formData.detail_type || "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">
|
||||||
|
상세 항목 {mode === "add" ? "추가" : "수정"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
상세 항목의 유형을 선택하고 내용을 입력하세요
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 유형 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">
|
||||||
|
유형 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={currentType}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
updateField("detail_type", v);
|
||||||
|
setSelectedInspection(null);
|
||||||
|
setFormData((prev) => ({
|
||||||
|
detail_type: v,
|
||||||
|
is_required: prev.is_required || "Y",
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue placeholder="선택하세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{detailTypes.map((t) => (
|
||||||
|
<SelectItem key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 체크리스트 */}
|
||||||
|
{currentType === "check" && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">
|
||||||
|
체크 내용 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.content || ""}
|
||||||
|
onChange={(e) => updateField("content", e.target.value)}
|
||||||
|
placeholder="예: 전원 상태 확인"
|
||||||
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 검사항목 */}
|
||||||
|
{currentType === "inspect" && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">
|
||||||
|
검사기준 선택 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<div className="mt-1 flex gap-2">
|
||||||
|
<Select value="_placeholder" disabled>
|
||||||
|
<SelectTrigger className="h-8 flex-1 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue>
|
||||||
|
{selectedInspection
|
||||||
|
? `${selectedInspection.inspection_code} - ${selectedInspection.inspection_item}`
|
||||||
|
: "검사기준을 선택하세요"}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_placeholder">선택</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="h-8 shrink-0 gap-1 text-xs sm:h-10 sm:text-sm"
|
||||||
|
onClick={() => setInspectionLookupOpen(true)}
|
||||||
|
>
|
||||||
|
<Search className="h-3.5 w-3.5" />
|
||||||
|
조회
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedInspection && (
|
||||||
|
<div className="rounded border bg-muted/30 p-3">
|
||||||
|
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||||
|
선택된 검사기준 정보
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||||
|
<p>
|
||||||
|
<strong>검사코드:</strong> {selectedInspection.inspection_code}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>검사항목:</strong> {selectedInspection.inspection_item}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>검사방법:</strong> {selectedInspection.inspection_method || "-"}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>단위:</strong> {selectedInspection.unit || "-"}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>하한값:</strong> {selectedInspection.lower_limit || "-"}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>상한값:</strong> {selectedInspection.upper_limit || "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">
|
||||||
|
검사 항목명 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.content || ""}
|
||||||
|
onChange={(e) => updateField("content", e.target.value)}
|
||||||
|
placeholder="예: 외경 치수"
|
||||||
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">검사 방법</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.inspection_method || ""}
|
||||||
|
onChange={(e) => updateField("inspection_method", 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
|
||||||
|
value={formData.unit || ""}
|
||||||
|
onChange={(e) => updateField("unit", e.target.value)}
|
||||||
|
placeholder="예: mm"
|
||||||
|
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={formData.lower_limit || ""}
|
||||||
|
onChange={(e) => updateField("lower_limit", e.target.value)}
|
||||||
|
placeholder="예: 7.95"
|
||||||
|
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.upper_limit || ""}
|
||||||
|
onChange={(e) => updateField("upper_limit", e.target.value)}
|
||||||
|
placeholder="예: 8.05"
|
||||||
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 작업절차 */}
|
||||||
|
{currentType === "procedure" && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">
|
||||||
|
작업 내용 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.content || ""}
|
||||||
|
onChange={(e) => updateField("content", 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.duration_minutes ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateField(
|
||||||
|
"duration_minutes",
|
||||||
|
e.target.value ? Number(e.target.value) : undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="예: 5"
|
||||||
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 직접입력 */}
|
||||||
|
{currentType === "input" && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">
|
||||||
|
입력 항목명 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.content || ""}
|
||||||
|
onChange={(e) => updateField("content", 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>
|
||||||
|
<Select
|
||||||
|
value={formData.input_type || "text"}
|
||||||
|
onValueChange={(v) => updateField("input_type", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{INPUT_TYPES.map((t) => (
|
||||||
|
<SelectItem key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 정보조회 */}
|
||||||
|
{currentType === "info" && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">
|
||||||
|
조회 대상 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.lookup_target || ""}
|
||||||
|
onValueChange={(v) => updateField("lookup_target", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue placeholder="선택하세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{LOOKUP_TARGETS.map((t) => (
|
||||||
|
<SelectItem key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">표시 항목</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.display_fields || ""}
|
||||||
|
onChange={(e) => updateField("display_fields", e.target.value)}
|
||||||
|
placeholder="예: 설비명, 설비코드"
|
||||||
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 필수 여부 (모든 유형 공통) */}
|
||||||
|
{currentType && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">필수 여부</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.is_required || "Y"}
|
||||||
|
onValueChange={(v) => updateField("is_required", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Y">필수</SelectItem>
|
||||||
|
<SelectItem value="N">선택</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
{mode === "add" ? "추가" : "수정"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<InspectionStandardLookup
|
||||||
|
open={inspectionLookupOpen}
|
||||||
|
onClose={() => setInspectionLookupOpen(false)}
|
||||||
|
onSelect={handleInspectionSelect}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Search, X } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { InspectionStandard } from "../types";
|
||||||
|
|
||||||
|
interface InspectionStandardLookupProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (item: InspectionStandard) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InspectionStandardLookup({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSelect,
|
||||||
|
}: InspectionStandardLookupProps) {
|
||||||
|
const [data, setData] = useState<any[]>([]);
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const search: Record<string, any> = {};
|
||||||
|
if (searchText.trim()) {
|
||||||
|
search.inspection_item = searchText.trim();
|
||||||
|
search.inspection_code = searchText.trim();
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: "1",
|
||||||
|
size: "100",
|
||||||
|
enableEntityJoin: "true",
|
||||||
|
...(searchText.trim() ? { search: JSON.stringify(search) } : {}),
|
||||||
|
});
|
||||||
|
const res = await apiClient.get(
|
||||||
|
`/table-management/tables/inspection_standard/data-with-joins?${params}`
|
||||||
|
);
|
||||||
|
if (res.data?.success) {
|
||||||
|
const result = res.data.data;
|
||||||
|
setData(Array.isArray(result) ? result : result?.data || []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("검사기준 조회 실패", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [searchText]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
}, [open, fetchData]);
|
||||||
|
|
||||||
|
const handleSelect = (item: any) => {
|
||||||
|
onSelect({
|
||||||
|
id: item.id,
|
||||||
|
inspection_code: item.inspection_code || "",
|
||||||
|
inspection_item: item.inspection_item || item.inspection_criteria || "",
|
||||||
|
inspection_method: item.inspection_method || "",
|
||||||
|
unit: item.unit || "",
|
||||||
|
lower_limit: item.lower_limit || "",
|
||||||
|
upper_limit: item.upper_limit || "",
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[700px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||||
|
<Search className="h-5 w-5" />
|
||||||
|
검사기준 조회
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
검사기준을 검색하여 선택하세요
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="검사항목명 또는 검사코드로 검색..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && fetchData()}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[400px] overflow-auto rounded border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="sticky top-0 bg-muted">
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
|
||||||
|
검사코드
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
|
||||||
|
검사항목
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
|
||||||
|
검사방법
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-center font-medium text-muted-foreground">
|
||||||
|
하한
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-center font-medium text-muted-foreground">
|
||||||
|
상한
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-center font-medium text-muted-foreground">
|
||||||
|
단위
|
||||||
|
</th>
|
||||||
|
<th className="w-16 px-3 py-2 text-center font-medium text-muted-foreground">
|
||||||
|
선택
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="py-8 text-center text-muted-foreground">
|
||||||
|
조회 중...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : data.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="py-8 text-center text-muted-foreground">
|
||||||
|
검사기준이 없습니다
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
data.map((item, idx) => (
|
||||||
|
<tr
|
||||||
|
key={item.id || idx}
|
||||||
|
className="border-b transition-colors hover:bg-muted/30"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2">{item.inspection_code || "-"}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{item.inspection_item || item.inspection_criteria || "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">{item.inspection_method || "-"}</td>
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
{item.lower_limit || "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
{item.upper_limit || "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-center">{item.unit || "-"}</td>
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-3 text-xs"
|
||||||
|
onClick={() => handleSelect(item)}
|
||||||
|
>
|
||||||
|
선택
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose} className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
닫기
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -87,7 +87,7 @@ export function ItemProcessSelector({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
items.map((item) => (
|
items.map((item) => (
|
||||||
<div key={item.item_code} className="mb-1">
|
<div key={item.id} className="mb-1">
|
||||||
{/* 품목 헤더 */}
|
{/* 품목 헤더 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleItem(item.item_code, item.item_name)}
|
onClick={() => toggleItem(item.item_code, item.item_name)}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Plus, Pencil, Trash2, Check, X, HandMetal } from "lucide-react";
|
import { Plus, Pencil, Trash2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { WorkItem, WorkItemDetail, DetailTypeDefinition } from "../types";
|
import { WorkItem, WorkItemDetail, DetailTypeDefinition } from "../types";
|
||||||
|
import { DetailFormModal } from "./DetailFormModal";
|
||||||
|
|
||||||
interface WorkItemDetailListProps {
|
interface WorkItemDetailListProps {
|
||||||
workItem: WorkItem | null;
|
workItem: WorkItem | null;
|
||||||
|
|
@ -34,20 +27,13 @@ export function WorkItemDetailList({
|
||||||
onUpdateDetail,
|
onUpdateDetail,
|
||||||
onDeleteDetail,
|
onDeleteDetail,
|
||||||
}: WorkItemDetailListProps) {
|
}: WorkItemDetailListProps) {
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editData, setEditData] = useState<Partial<WorkItemDetail>>({});
|
const [modalMode, setModalMode] = useState<"add" | "edit">("add");
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [editTarget, setEditTarget] = useState<WorkItemDetail | null>(null);
|
||||||
const [newData, setNewData] = useState<Partial<WorkItemDetail>>({
|
|
||||||
detail_type: detailTypes[0]?.value || "",
|
|
||||||
content: "",
|
|
||||||
is_required: "N",
|
|
||||||
sort_order: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!workItem) {
|
if (!workItem) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||||
<HandMetal className="mb-2 h-10 w-10 text-amber-400" />
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
왼쪽에서 항목을 선택하세요
|
왼쪽에서 항목을 선택하세요
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -58,25 +44,60 @@ export function WorkItemDetailList({
|
||||||
const getTypeLabel = (value?: string) =>
|
const getTypeLabel = (value?: string) =>
|
||||||
detailTypes.find((t) => t.value === value)?.label || value || "-";
|
detailTypes.find((t) => t.value === value)?.label || value || "-";
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleOpenAdd = () => {
|
||||||
if (!newData.content?.trim()) return;
|
setModalMode("add");
|
||||||
onCreateDetail({
|
setEditTarget(null);
|
||||||
...newData,
|
setModalOpen(true);
|
||||||
sort_order: details.length + 1,
|
|
||||||
});
|
|
||||||
setNewData({
|
|
||||||
detail_type: detailTypes[0]?.value || "",
|
|
||||||
content: "",
|
|
||||||
is_required: "N",
|
|
||||||
sort_order: 0,
|
|
||||||
});
|
|
||||||
setIsAdding(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveEdit = (id: string) => {
|
const handleOpenEdit = (detail: WorkItemDetail) => {
|
||||||
onUpdateDetail(id, editData);
|
setModalMode("edit");
|
||||||
setEditingId(null);
|
setEditTarget(detail);
|
||||||
setEditData({});
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (data: Partial<WorkItemDetail>) => {
|
||||||
|
if (modalMode === "add") {
|
||||||
|
onCreateDetail({ ...data, sort_order: details.length + 1 });
|
||||||
|
} else if (editTarget) {
|
||||||
|
onUpdateDetail(editTarget.id, data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContentSummary = (detail: WorkItemDetail): string => {
|
||||||
|
const type = detail.detail_type;
|
||||||
|
if (type === "inspect" && detail.inspection_code) {
|
||||||
|
const parts = [detail.content];
|
||||||
|
if (detail.inspection_method) parts.push(`[${detail.inspection_method}]`);
|
||||||
|
if (detail.lower_limit || detail.upper_limit) {
|
||||||
|
parts.push(`(${detail.lower_limit || "-"} ~ ${detail.upper_limit || "-"} ${detail.unit || ""})`);
|
||||||
|
}
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
if (type === "procedure" && detail.duration_minutes) {
|
||||||
|
return `${detail.content} (${detail.duration_minutes}분)`;
|
||||||
|
}
|
||||||
|
if (type === "input" && detail.input_type) {
|
||||||
|
const typeMap: Record<string, string> = {
|
||||||
|
text: "텍스트",
|
||||||
|
number: "숫자",
|
||||||
|
date: "날짜",
|
||||||
|
textarea: "장문",
|
||||||
|
select: "선택형",
|
||||||
|
};
|
||||||
|
return `${detail.content} [${typeMap[detail.input_type] || detail.input_type}]`;
|
||||||
|
}
|
||||||
|
if (type === "info" && detail.lookup_target) {
|
||||||
|
const targetMap: Record<string, string> = {
|
||||||
|
equipment: "설비정보",
|
||||||
|
material: "자재정보",
|
||||||
|
worker: "작업자정보",
|
||||||
|
tool: "공구정보",
|
||||||
|
document: "문서정보",
|
||||||
|
};
|
||||||
|
return `${targetMap[detail.lookup_target] || detail.lookup_target} 조회`;
|
||||||
|
}
|
||||||
|
return detail.content || "-";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -94,7 +115,7 @@ export function WorkItemDetailList({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 gap-1 text-xs"
|
className="h-7 gap-1 text-xs"
|
||||||
onClick={() => setIsAdding(true)}
|
onClick={handleOpenAdd}
|
||||||
>
|
>
|
||||||
<Plus className="h-3 w-3" />
|
<Plus className="h-3 w-3" />
|
||||||
상세 추가
|
상세 추가
|
||||||
|
|
@ -132,242 +153,51 @@ export function WorkItemDetailList({
|
||||||
key={detail.id}
|
key={detail.id}
|
||||||
className="border-b transition-colors hover:bg-muted/30"
|
className="border-b transition-colors hover:bg-muted/30"
|
||||||
>
|
>
|
||||||
{editingId === detail.id ? (
|
<td className="px-2 py-1.5 text-center text-muted-foreground">
|
||||||
<>
|
{idx + 1}
|
||||||
<td className="px-2 py-1.5 text-center">{idx + 1}</td>
|
</td>
|
||||||
<td className="px-2 py-1.5">
|
<td className="px-2 py-1.5">
|
||||||
<Select
|
<Badge variant="outline" className="text-[10px] font-normal">
|
||||||
value={editData.detail_type || detail.detail_type || ""}
|
{getTypeLabel(detail.detail_type)}
|
||||||
onValueChange={(v) =>
|
</Badge>
|
||||||
setEditData((prev) => ({
|
</td>
|
||||||
...prev,
|
<td className="px-2 py-1.5">{getContentSummary(detail)}</td>
|
||||||
detail_type: v,
|
<td className="px-2 py-1.5 text-center">
|
||||||
}))
|
<Badge
|
||||||
}
|
variant={detail.is_required === "Y" ? "default" : "secondary"}
|
||||||
|
className="text-[10px] font-normal"
|
||||||
|
>
|
||||||
|
{detail.is_required === "Y" ? "필수" : "선택"}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
{!readonly && (
|
||||||
|
<td className="px-2 py-1.5 text-center">
|
||||||
|
<div className="flex justify-center gap-0.5">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={() => handleOpenEdit(detail)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-7 text-xs">
|
<Pencil className="h-3 w-3" />
|
||||||
<SelectValue />
|
</Button>
|
||||||
</SelectTrigger>
|
<Button
|
||||||
<SelectContent>
|
variant="ghost"
|
||||||
{detailTypes.map((t) => (
|
size="icon"
|
||||||
<SelectItem
|
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||||
key={t.value}
|
onClick={() => onDeleteDetail(detail.id)}
|
||||||
value={t.value}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{t.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1.5">
|
|
||||||
<Input
|
|
||||||
value={editData.content ?? detail.content}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
content: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1.5 text-center">
|
|
||||||
<Select
|
|
||||||
value={editData.is_required ?? detail.is_required}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setEditData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
is_required: v,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-7 w-14 text-xs">
|
<Trash2 className="h-3 w-3" />
|
||||||
<SelectValue />
|
</Button>
|
||||||
</SelectTrigger>
|
</div>
|
||||||
<SelectContent>
|
</td>
|
||||||
<SelectItem value="Y" className="text-xs">
|
|
||||||
필수
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="N" className="text-xs">
|
|
||||||
선택
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1.5 text-center">
|
|
||||||
<div className="flex justify-center gap-0.5">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 text-green-600"
|
|
||||||
onClick={() => handleSaveEdit(detail.id)}
|
|
||||||
>
|
|
||||||
<Check className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingId(null);
|
|
||||||
setEditData({});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<td className="px-2 py-1.5 text-center text-muted-foreground">
|
|
||||||
{idx + 1}
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1.5">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-[10px] font-normal"
|
|
||||||
>
|
|
||||||
{getTypeLabel(detail.detail_type)}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1.5">{detail.content}</td>
|
|
||||||
<td className="px-2 py-1.5 text-center">
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
detail.is_required === "Y" ? "default" : "secondary"
|
|
||||||
}
|
|
||||||
className="text-[10px] font-normal"
|
|
||||||
>
|
|
||||||
{detail.is_required === "Y" ? "필수" : "선택"}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
{!readonly && (
|
|
||||||
<td className="px-2 py-1.5 text-center">
|
|
||||||
<div className="flex justify-center gap-0.5">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingId(detail.id);
|
|
||||||
setEditData({
|
|
||||||
detail_type: detail.detail_type,
|
|
||||||
content: detail.content,
|
|
||||||
is_required: detail.is_required,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pencil className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
|
||||||
onClick={() => onDeleteDetail(detail.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* 추가 행 */}
|
|
||||||
{isAdding && (
|
|
||||||
<tr className="border-b bg-primary/5">
|
|
||||||
<td className="px-2 py-1.5 text-center text-muted-foreground">
|
|
||||||
{details.length + 1}
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1.5">
|
|
||||||
<Select
|
|
||||||
value={newData.detail_type || ""}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setNewData((prev) => ({ ...prev, detail_type: v }))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-7 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{detailTypes.map((t) => (
|
|
||||||
<SelectItem
|
|
||||||
key={t.value}
|
|
||||||
value={t.value}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{t.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1.5">
|
|
||||||
<Input
|
|
||||||
autoFocus
|
|
||||||
placeholder="상세 내용 입력"
|
|
||||||
value={newData.content || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNewData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
content: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1.5 text-center">
|
|
||||||
<Select
|
|
||||||
value={newData.is_required || "N"}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setNewData((prev) => ({ ...prev, is_required: v }))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-7 w-14 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="Y" className="text-xs">
|
|
||||||
필수
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="N" className="text-xs">
|
|
||||||
선택
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1.5 text-center">
|
|
||||||
<div className="flex justify-center gap-0.5">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 text-green-600"
|
|
||||||
onClick={handleAdd}
|
|
||||||
>
|
|
||||||
<Check className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6"
|
|
||||||
onClick={() => setIsAdding(false)}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
{details.length === 0 && !isAdding && (
|
{details.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
상세 항목이 없습니다. "상세 추가" 버튼을 클릭하여 추가하세요.
|
상세 항목이 없습니다. "상세 추가" 버튼을 클릭하여 추가하세요.
|
||||||
|
|
@ -375,6 +205,16 @@ export function WorkItemDetailList({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 추가/수정 모달 */}
|
||||||
|
<DetailFormModal
|
||||||
|
open={modalOpen}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
detailTypes={detailTypes}
|
||||||
|
editData={editTarget}
|
||||||
|
mode={modalMode}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,11 @@ export const defaultConfig: ProcessWorkStandardConfig = {
|
||||||
{ key: "POST", label: "작업 후 (Post-Work)", sortOrder: 3 },
|
{ key: "POST", label: "작업 후 (Post-Work)", sortOrder: 3 },
|
||||||
],
|
],
|
||||||
detailTypes: [
|
detailTypes: [
|
||||||
{ value: "CHECK", label: "체크" },
|
{ value: "check", label: "체크리스트" },
|
||||||
{ value: "INSPECTION", label: "검사" },
|
{ value: "inspect", label: "검사항목" },
|
||||||
{ value: "MEASUREMENT", label: "측정" },
|
{ value: "procedure", label: "작업절차" },
|
||||||
|
{ value: "input", label: "직접입력" },
|
||||||
|
{ value: "info", label: "정보조회" },
|
||||||
],
|
],
|
||||||
splitRatio: 30,
|
splitRatio: 30,
|
||||||
leftPanelTitle: "품목 및 공정 선택",
|
leftPanelTitle: "품목 및 공정 선택",
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
SelectionState,
|
SelectionState,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
const API_BASE = "/api/process-work-standard";
|
const API_BASE = "/process-work-standard";
|
||||||
|
|
||||||
export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
||||||
const [items, setItems] = useState<ItemData[]>([]);
|
const [items, setItems] = useState<ItemData[]>([]);
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,29 @@ export interface WorkItemDetail {
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
created_date?: string;
|
created_date?: string;
|
||||||
|
// 검사항목 전용
|
||||||
|
inspection_code?: string;
|
||||||
|
inspection_method?: string;
|
||||||
|
unit?: string;
|
||||||
|
lower_limit?: string;
|
||||||
|
upper_limit?: string;
|
||||||
|
// 작업절차 전용
|
||||||
|
duration_minutes?: number;
|
||||||
|
// 직접입력 전용
|
||||||
|
input_type?: string;
|
||||||
|
// 정보조회 전용
|
||||||
|
lookup_target?: string;
|
||||||
|
display_fields?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InspectionStandard {
|
||||||
|
id: string;
|
||||||
|
inspection_code: string;
|
||||||
|
inspection_item: string;
|
||||||
|
inspection_method: string;
|
||||||
|
unit: string;
|
||||||
|
lower_limit?: string;
|
||||||
|
upper_limit?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@ import { dataApi } from "@/lib/api/data";
|
||||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient, getFullImageUrl } from "@/lib/api/client";
|
||||||
|
import { getFilePreviewUrl } from "@/lib/api/file";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -52,6 +53,42 @@ export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
||||||
selectedPanelComponentId?: string;
|
selectedPanelComponentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 이미지 셀 렌더링 컴포넌트 (objid 또는 파일 경로 지원)
|
||||||
|
const SplitPanelCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
|
||||||
|
const [imgSrc, setImgSrc] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!value) return;
|
||||||
|
const strVal = String(value).trim();
|
||||||
|
if (!strVal || strVal === "-") return;
|
||||||
|
|
||||||
|
if (strVal.startsWith("http") || strVal.startsWith("/uploads/") || strVal.startsWith("/api/")) {
|
||||||
|
setImgSrc(getFullImageUrl(strVal));
|
||||||
|
} else {
|
||||||
|
const previewUrl = getFilePreviewUrl(strVal);
|
||||||
|
fetch(previewUrl, { credentials: "include" })
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error("fetch failed");
|
||||||
|
return res.blob();
|
||||||
|
})
|
||||||
|
.then((blob) => setImgSrc(URL.createObjectURL(blob)))
|
||||||
|
.catch(() => setImgSrc(null));
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
if (!imgSrc) return <span className="text-muted-foreground text-xs">-</span>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={imgSrc}
|
||||||
|
alt=""
|
||||||
|
className="h-8 w-8 rounded object-cover"
|
||||||
|
onError={() => setImgSrc(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
SplitPanelCellImage.displayName = "SplitPanelCellImage";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SplitPanelLayout 컴포넌트
|
* SplitPanelLayout 컴포넌트
|
||||||
* 마스터-디테일 패턴의 좌우 분할 레이아웃
|
* 마스터-디테일 패턴의 좌우 분할 레이아웃
|
||||||
|
|
@ -241,6 +278,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const [isLoadingLeft, setIsLoadingLeft] = useState(false);
|
const [isLoadingLeft, setIsLoadingLeft] = useState(false);
|
||||||
const [isLoadingRight, setIsLoadingRight] = useState(false);
|
const [isLoadingRight, setIsLoadingRight] = useState(false);
|
||||||
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]);
|
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]);
|
||||||
|
const [columnInputTypes, setColumnInputTypes] = useState<Record<string, string>>({});
|
||||||
const [expandedItems, setExpandedItems] = usePersistedState<Set<any>>('expandedItems', new Set());
|
const [expandedItems, setExpandedItems] = usePersistedState<Set<any>>('expandedItems', new Set());
|
||||||
|
|
||||||
// 추가 탭 관련 상태
|
// 추가 탭 관련 상태
|
||||||
|
|
@ -938,6 +976,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
) => {
|
) => {
|
||||||
if (value === null || value === undefined) return "-";
|
if (value === null || value === undefined) return "-";
|
||||||
|
|
||||||
|
// 이미지 타입 컬럼 처리
|
||||||
|
const colInputType = columnInputTypes[columnName];
|
||||||
|
if (colInputType === "image" && value) {
|
||||||
|
return <SplitPanelCellImage value={String(value)} />;
|
||||||
|
}
|
||||||
|
|
||||||
// 🆕 날짜 포맷 적용
|
// 🆕 날짜 포맷 적용
|
||||||
if (format?.type === "date" || format?.dateFormat) {
|
if (format?.type === "date" || format?.dateFormat) {
|
||||||
return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD");
|
return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD");
|
||||||
|
|
@ -1004,7 +1048,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
// 일반 값
|
// 일반 값
|
||||||
return String(value);
|
return String(value);
|
||||||
},
|
},
|
||||||
[formatDateValue, formatNumberValue],
|
[formatDateValue, formatNumberValue, columnInputTypes],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🆕 패널 config의 columns에서 additionalJoinColumns 추출하는 헬퍼
|
// 🆕 패널 config의 columns에서 additionalJoinColumns 추출하는 헬퍼
|
||||||
|
|
@ -1274,7 +1318,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const searchConditions: Record<string, any> = {};
|
const searchConditions: Record<string, any> = {};
|
||||||
keys?.forEach((key: any) => {
|
keys?.forEach((key: any) => {
|
||||||
if (key.leftColumn && key.rightColumn && originalItem[key.leftColumn] !== undefined) {
|
if (key.leftColumn && key.rightColumn && originalItem[key.leftColumn] !== undefined) {
|
||||||
searchConditions[key.rightColumn] = originalItem[key.leftColumn];
|
searchConditions[key.rightColumn] = { value: originalItem[key.leftColumn], operator: "equals" };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1304,11 +1348,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
// 복합키: 여러 조건으로 필터링
|
// 복합키: 여러 조건으로 필터링
|
||||||
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||||
|
|
||||||
// 복합키 조건 생성
|
// 복합키 조건 생성 (FK 필터링이므로 equals 연산자 사용)
|
||||||
const searchConditions: Record<string, any> = {};
|
const searchConditions: Record<string, any> = {};
|
||||||
keys.forEach((key) => {
|
keys.forEach((key) => {
|
||||||
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
|
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
|
||||||
searchConditions[key.rightColumn] = leftItem[key.leftColumn];
|
searchConditions[key.rightColumn] = { value: leftItem[key.leftColumn], operator: "equals" };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1875,14 +1919,36 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setRightColumnLabels(labels);
|
setRightColumnLabels(labels);
|
||||||
console.log("✅ 우측 컬럼 라벨 로드:", labels);
|
|
||||||
|
// 컬럼 inputType 로드 (이미지 등 특수 렌더링을 위해)
|
||||||
|
const tablesToLoad = new Set<string>([rightTableName]);
|
||||||
|
const additionalTabs = componentConfig.rightPanel?.additionalTabs || [];
|
||||||
|
additionalTabs.forEach((tab: any) => {
|
||||||
|
if (tab.tableName) tablesToLoad.add(tab.tableName);
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputTypes: Record<string, string> = {};
|
||||||
|
for (const tbl of tablesToLoad) {
|
||||||
|
try {
|
||||||
|
const inputTypesResponse = await tableTypeApi.getColumnInputTypes(tbl);
|
||||||
|
inputTypesResponse.forEach((col: any) => {
|
||||||
|
const colName = col.columnName || col.column_name;
|
||||||
|
if (colName) {
|
||||||
|
inputTypes[colName] = col.inputType || "text";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setColumnInputTypes(inputTypes);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("우측 테이블 컬럼 정보 로드 실패:", error);
|
console.error("우측 테이블 컬럼 정보 로드 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadRightTableColumns();
|
loadRightTableColumns();
|
||||||
}, [componentConfig.rightPanel?.tableName, isDesignMode]);
|
}, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.additionalTabs, isDesignMode]);
|
||||||
|
|
||||||
// 좌측 테이블 카테고리 매핑 로드
|
// 좌측 테이블 카테고리 매핑 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -2075,20 +2141,47 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.addButton;
|
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.addButton;
|
||||||
|
|
||||||
if (addButtonConfig?.mode === "modal" && addButtonConfig?.modalScreenId) {
|
if (addButtonConfig?.mode === "modal" && addButtonConfig?.modalScreenId) {
|
||||||
// 커스텀 모달 화면 열기
|
if (!selectedLeftItem) {
|
||||||
|
toast({
|
||||||
|
title: "항목을 선택해주세요",
|
||||||
|
description: "좌측 패널에서 항목을 먼저 선택한 후 추가해주세요.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const currentTableName =
|
const currentTableName =
|
||||||
activeTabIndex === 0
|
activeTabIndex === 0
|
||||||
? componentConfig.rightPanel?.tableName || ""
|
? componentConfig.rightPanel?.tableName || ""
|
||||||
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.tableName || "";
|
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.tableName || "";
|
||||||
|
|
||||||
// 좌측 선택 데이터를 modalDataStore에 저장 (추가 화면에서 참조 가능)
|
// 좌측 선택 데이터를 modalDataStore에 저장
|
||||||
if (selectedLeftItem && componentConfig.leftPanel?.tableName) {
|
if (selectedLeftItem && componentConfig.leftPanel?.tableName) {
|
||||||
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
||||||
useModalDataStore.getState().setData(componentConfig.leftPanel!.tableName!, [selectedLeftItem]);
|
useModalDataStore.getState().setData(componentConfig.leftPanel!.tableName!, [selectedLeftItem]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScreenModal 열기 이벤트 발생
|
// relation.keys에서 FK 데이터 추출
|
||||||
|
const parentData: Record<string, any> = {};
|
||||||
|
const relation = activeTabIndex === 0
|
||||||
|
? componentConfig.rightPanel?.relation
|
||||||
|
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.relation;
|
||||||
|
|
||||||
|
if (relation?.keys && Array.isArray(relation.keys)) {
|
||||||
|
for (const key of relation.keys) {
|
||||||
|
if (key.leftColumn && key.rightColumn && selectedLeftItem[key.leftColumn] != null) {
|
||||||
|
parentData[key.rightColumn] = selectedLeftItem[key.leftColumn];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (relation) {
|
||||||
|
const leftColumn = relation.leftColumn;
|
||||||
|
const rightColumn = relation.foreignKey || relation.rightColumn;
|
||||||
|
if (leftColumn && rightColumn && selectedLeftItem[leftColumn] != null) {
|
||||||
|
parentData[rightColumn] = selectedLeftItem[leftColumn];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("openScreenModal", {
|
new CustomEvent("openScreenModal", {
|
||||||
detail: {
|
detail: {
|
||||||
|
|
@ -2096,19 +2189,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
urlParams: {
|
urlParams: {
|
||||||
mode: "add",
|
mode: "add",
|
||||||
tableName: currentTableName,
|
tableName: currentTableName,
|
||||||
// 좌측 선택 항목의 연결 키 값 전달
|
|
||||||
...(selectedLeftItem && (() => {
|
|
||||||
const relation = activeTabIndex === 0
|
|
||||||
? componentConfig.rightPanel?.relation
|
|
||||||
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.relation;
|
|
||||||
const leftColumn = relation?.keys?.[0]?.leftColumn || relation?.leftColumn;
|
|
||||||
const rightColumn = relation?.keys?.[0]?.rightColumn || relation?.foreignKey;
|
|
||||||
if (leftColumn && rightColumn && selectedLeftItem[leftColumn] !== undefined) {
|
|
||||||
return { [rightColumn]: selectedLeftItem[leftColumn] };
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
})()),
|
|
||||||
},
|
},
|
||||||
|
splitPanelParentData: parentData,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -2116,6 +2198,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
console.log("✅ [SplitPanel] 추가 모달 화면 열기:", {
|
console.log("✅ [SplitPanel] 추가 모달 화면 열기:", {
|
||||||
screenId: addButtonConfig.modalScreenId,
|
screenId: addButtonConfig.modalScreenId,
|
||||||
tableName: currentTableName,
|
tableName: currentTableName,
|
||||||
|
parentData,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,9 @@ const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
const strValue = String(value);
|
// 다중 이미지인 경우 대표 이미지(첫 번째)만 사용
|
||||||
|
const rawValue = String(value);
|
||||||
|
const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue;
|
||||||
const isObjid = /^\d+$/.test(strValue);
|
const isObjid = /^\d+$/.test(strValue);
|
||||||
|
|
||||||
if (isObjid) {
|
if (isObjid) {
|
||||||
|
|
@ -90,8 +92,8 @@ const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
|
||||||
style={{ maxWidth: "40px", maxHeight: "40px" }}
|
style={{ maxWidth: "40px", maxHeight: "40px" }}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
// objid인 경우 preview URL로 열기, 아니면 full URL로 열기
|
const rawValue = String(value);
|
||||||
const strValue = String(value);
|
const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue;
|
||||||
const isObjid = /^\d+$/.test(strValue);
|
const isObjid = /^\d+$/.test(strValue);
|
||||||
const openUrl = isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue);
|
const openUrl = isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue);
|
||||||
window.open(openUrl, "_blank");
|
window.open(openUrl, "_blank");
|
||||||
|
|
@ -1044,23 +1046,35 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 일반 타입 또는 카테고리 조회 실패 시: 현재 데이터 기반
|
// 백엔드 DISTINCT API로 전체 고유값 조회 (페이징과 무관하게 모든 값 반환)
|
||||||
|
try {
|
||||||
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
|
const response = await apiClient.get(`/entity/${tableConfig.selectedTable}/distinct/${columnName}`);
|
||||||
|
|
||||||
|
if (response.data.success && response.data.data && response.data.data.length > 0) {
|
||||||
|
return response.data.data.map((item: any) => ({
|
||||||
|
value: String(item.value),
|
||||||
|
label: String(item.label),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// DISTINCT API 실패 시 현재 데이터 기반으로 fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback: 현재 로드된 데이터에서 고유 값 추출
|
||||||
const isLabelType = ["category", "entity", "code"].includes(inputType);
|
const isLabelType = ["category", "entity", "code"].includes(inputType);
|
||||||
const labelField = isLabelType ? `${columnName}_name` : columnName;
|
const labelField = isLabelType ? `${columnName}_name` : columnName;
|
||||||
|
|
||||||
// 현재 로드된 데이터에서 고유 값 추출
|
const uniqueValuesMap = new Map<string, string>();
|
||||||
const uniqueValuesMap = new Map<string, string>(); // value -> label
|
|
||||||
|
|
||||||
data.forEach((row) => {
|
data.forEach((row) => {
|
||||||
const value = row[columnName];
|
const value = row[columnName];
|
||||||
if (value !== null && value !== undefined && value !== "") {
|
if (value !== null && value !== undefined && value !== "") {
|
||||||
// 백엔드 조인된 _name 필드 사용 (없으면 원본 값)
|
|
||||||
const label = isLabelType && row[labelField] ? row[labelField] : String(value);
|
const label = isLabelType && row[labelField] ? row[labelField] : String(value);
|
||||||
uniqueValuesMap.set(String(value), label);
|
uniqueValuesMap.set(String(value), label);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Map을 배열로 변환하고 라벨 기준으로 정렬
|
|
||||||
const result = Array.from(uniqueValuesMap.entries())
|
const result = Array.from(uniqueValuesMap.entries())
|
||||||
.map(([value, label]) => ({
|
.map(([value, label]) => ({
|
||||||
value: value,
|
value: value,
|
||||||
|
|
@ -4288,7 +4302,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
// 다중 값인 경우: 여러 배지 렌더링
|
// 다중 값인 경우: 여러 배지 렌더링
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-nowrap gap-1 overflow-hidden">
|
||||||
{values.map((val, idx) => {
|
{values.map((val, idx) => {
|
||||||
const categoryData = mapping?.[val];
|
const categoryData = mapping?.[val];
|
||||||
const displayLabel = categoryData?.label || val;
|
const displayLabel = categoryData?.label || val;
|
||||||
|
|
@ -4297,7 +4311,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
|
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
|
||||||
if (!displayColor || displayColor === "none" || !categoryData) {
|
if (!displayColor || displayColor === "none" || !categoryData) {
|
||||||
return (
|
return (
|
||||||
<span key={idx} className="text-sm">
|
<span key={idx} className="shrink-0 whitespace-nowrap text-sm">
|
||||||
{displayLabel}
|
{displayLabel}
|
||||||
{idx < values.length - 1 && ", "}
|
{idx < values.length - 1 && ", "}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -4311,7 +4325,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
backgroundColor: displayColor,
|
backgroundColor: displayColor,
|
||||||
borderColor: displayColor,
|
borderColor: displayColor,
|
||||||
}}
|
}}
|
||||||
className="text-white"
|
className="shrink-0 whitespace-nowrap text-white"
|
||||||
>
|
>
|
||||||
{displayLabel}
|
{displayLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -2054,11 +2123,11 @@ export class ButtonActionExecutor {
|
||||||
const { tableName, screenId } = context;
|
const { tableName, screenId } = context;
|
||||||
|
|
||||||
// 범용_폼_모달 키 찾기 (컬럼명에 따라 다를 수 있음)
|
// 범용_폼_모달 키 찾기 (컬럼명에 따라 다를 수 있음)
|
||||||
|
// initializeForm에서 __tableSection_ (더블), 수정 시 _tableSection_ (싱글) 사용
|
||||||
const universalFormModalKey = Object.keys(formData).find((key) => {
|
const universalFormModalKey = Object.keys(formData).find((key) => {
|
||||||
const value = formData[key];
|
const value = formData[key];
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||||
// _tableSection_ 키가 있는지 확인
|
return Object.keys(value).some((k) => k.startsWith("_tableSection_") || k.startsWith("__tableSection_"));
|
||||||
return Object.keys(value).some((k) => k.startsWith("_tableSection_"));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!universalFormModalKey) {
|
if (!universalFormModalKey) {
|
||||||
|
|
@ -2108,24 +2177,72 @@ export class ButtonActionExecutor {
|
||||||
const sections: any[] = modalComponentConfig?.sections || [];
|
const sections: any[] = modalComponentConfig?.sections || [];
|
||||||
const saveConfig = modalComponentConfig?.saveConfig || {};
|
const saveConfig = modalComponentConfig?.saveConfig || {};
|
||||||
|
|
||||||
// _tableSection_ 데이터 추출
|
// 테이블 섹션 데이터 수집: DB 전체 데이터를 베이스로, 수정 데이터를 오버라이드
|
||||||
const tableSectionData: Record<string, any[]> = {};
|
const tableSectionData: Record<string, any[]> = {};
|
||||||
const commonFieldsData: Record<string, any> = {};
|
const commonFieldsData: Record<string, any> = {};
|
||||||
|
|
||||||
// 🆕 원본 그룹 데이터 추출 (수정 모드에서 UPDATE/DELETE 추적용)
|
// 원본 그룹 데이터 추출 (수정 모드에서 UPDATE/DELETE 추적용)
|
||||||
// modalData 내부 또는 최상위 formData에서 찾음
|
|
||||||
const originalGroupedData: any[] = modalData._originalGroupedData || formData._originalGroupedData || [];
|
const originalGroupedData: any[] = modalData._originalGroupedData || formData._originalGroupedData || [];
|
||||||
|
|
||||||
|
// 1단계: DB 데이터(__tableSection_)와 수정 데이터(_tableSection_)를 별도로 수집
|
||||||
|
const dbSectionData: Record<string, any[]> = {};
|
||||||
|
const modifiedSectionData: Record<string, any[]> = {};
|
||||||
|
|
||||||
|
// 1-1: modalData(부모의 중첩 객체)에서 수집
|
||||||
for (const [key, value] of Object.entries(modalData)) {
|
for (const [key, value] of Object.entries(modalData)) {
|
||||||
if (key.startsWith("_tableSection_")) {
|
if (key.startsWith("__tableSection_") && Array.isArray(value)) {
|
||||||
|
const sectionId = key.replace("__tableSection_", "");
|
||||||
|
dbSectionData[sectionId] = value;
|
||||||
|
} else if (key.startsWith("_tableSection_") && Array.isArray(value)) {
|
||||||
const sectionId = key.replace("_tableSection_", "");
|
const sectionId = key.replace("_tableSection_", "");
|
||||||
tableSectionData[sectionId] = value as any[];
|
modifiedSectionData[sectionId] = value;
|
||||||
} else if (!key.startsWith("_")) {
|
} else if (!key.startsWith("_")) {
|
||||||
// _로 시작하지 않는 필드는 공통 필드로 처리
|
|
||||||
commonFieldsData[key] = value;
|
commonFieldsData[key] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1-2: top-level formData에서도 수집 (handleBeforeFormSave가 직접 설정한 최신 데이터)
|
||||||
|
// modalData(중첩 객체)가 아직 업데이트되지 않았을 수 있으므로 보완
|
||||||
|
for (const [key, value] of Object.entries(formData)) {
|
||||||
|
if (key === universalFormModalKey) continue;
|
||||||
|
if (key.startsWith("__tableSection_") && Array.isArray(value)) {
|
||||||
|
const sectionId = key.replace("__tableSection_", "");
|
||||||
|
if (!dbSectionData[sectionId]) {
|
||||||
|
dbSectionData[sectionId] = value;
|
||||||
|
}
|
||||||
|
} else if (key.startsWith("_tableSection_") && Array.isArray(value)) {
|
||||||
|
const sectionId = key.replace("_tableSection_", "");
|
||||||
|
if (!modifiedSectionData[sectionId]) {
|
||||||
|
modifiedSectionData[sectionId] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2단계: DB 데이터를 베이스로, 수정 데이터를 아이템별로 병합하여 전체 데이터 구성
|
||||||
|
// - DB 데이터(__tableSection_): initializeForm에서 로드한 전체 컬럼 데이터
|
||||||
|
// - 수정 데이터(_tableSection_): _groupedData 또는 사용자 UI 수정을 통해 설정된 데이터 (일부 컬럼만 포함 가능)
|
||||||
|
// - 병합: { ...dbItem, ...modItem } → DB 전체 컬럼 유지 + 수정된 필드만 오버라이드
|
||||||
|
const allSectionIds = new Set([...Object.keys(dbSectionData), ...Object.keys(modifiedSectionData)]);
|
||||||
|
|
||||||
|
for (const sectionId of allSectionIds) {
|
||||||
|
const dbItems = dbSectionData[sectionId] || [];
|
||||||
|
const modItems = modifiedSectionData[sectionId];
|
||||||
|
|
||||||
|
if (modItems) {
|
||||||
|
tableSectionData[sectionId] = modItems.map((modItem) => {
|
||||||
|
if (modItem.id) {
|
||||||
|
const dbItem = dbItems.find((db) => String(db.id) === String(modItem.id));
|
||||||
|
if (dbItem) {
|
||||||
|
return { ...dbItem, ...modItem };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return modItem;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
tableSectionData[sectionId] = dbItems;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 테이블 섹션 데이터가 없고 원본 데이터도 없으면 처리하지 않음
|
// 테이블 섹션 데이터가 없고 원본 데이터도 없으면 처리하지 않음
|
||||||
const hasTableSectionData = Object.values(tableSectionData).some((arr) => arr.length > 0);
|
const hasTableSectionData = Object.values(tableSectionData).some((arr) => arr.length > 0);
|
||||||
if (!hasTableSectionData && originalGroupedData.length === 0) {
|
if (!hasTableSectionData && originalGroupedData.length === 0) {
|
||||||
|
|
@ -2255,28 +2372,26 @@ export class ButtonActionExecutor {
|
||||||
|
|
||||||
// 각 테이블 섹션 처리
|
// 각 테이블 섹션 처리
|
||||||
for (const [sectionId, currentItems] of Object.entries(tableSectionData)) {
|
for (const [sectionId, currentItems] of Object.entries(tableSectionData)) {
|
||||||
// 🆕 해당 섹션의 설정 찾기
|
|
||||||
const sectionConfig = sections.find((s) => s.id === sectionId);
|
const sectionConfig = sections.find((s) => s.id === sectionId);
|
||||||
const targetTableName = sectionConfig?.tableConfig?.saveConfig?.targetTable;
|
const targetTableName = sectionConfig?.tableConfig?.saveConfig?.targetTable;
|
||||||
|
|
||||||
// 🆕 실제 저장할 테이블 결정
|
|
||||||
// - targetTable이 있으면 해당 테이블에 저장
|
|
||||||
// - targetTable이 없으면 메인 테이블에 저장
|
|
||||||
const saveTableName = targetTableName || tableName!;
|
const saveTableName = targetTableName || tableName!;
|
||||||
|
|
||||||
|
// 섹션별 DB 원본 데이터 조회 (전체 컬럼 보장)
|
||||||
|
// _originalTableSectionData_: initializeForm에서 DB 로드 시 저장한 원본 데이터
|
||||||
|
const sectionOriginalKey = `_originalTableSectionData_${sectionId}`;
|
||||||
|
const sectionOriginalData: any[] = modalData[sectionOriginalKey] || formData[sectionOriginalKey] || [];
|
||||||
|
|
||||||
// 1️⃣ 신규 품목 INSERT (id가 없는 항목)
|
// 1️⃣ 신규 품목 INSERT (id가 없는 항목)
|
||||||
const newItems = currentItems.filter((item) => !item.id);
|
const newItems = currentItems.filter((item) => !item.id);
|
||||||
for (const item of newItems) {
|
for (const item of newItems) {
|
||||||
const rowToSave = { ...commonFieldsData, ...item, ...userInfo };
|
const rowToSave = { ...commonFieldsData, ...item, ...userInfo };
|
||||||
|
|
||||||
// 내부 메타데이터 제거
|
|
||||||
Object.keys(rowToSave).forEach((key) => {
|
Object.keys(rowToSave).forEach((key) => {
|
||||||
if (key.startsWith("_")) {
|
if (key.startsWith("_")) {
|
||||||
delete rowToSave[key];
|
delete rowToSave[key];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🆕 메인 레코드 ID 연결 (별도 테이블에 저장하는 경우)
|
|
||||||
if (targetTableName && mainRecordId && saveConfig.primaryKeyColumn) {
|
if (targetTableName && mainRecordId && saveConfig.primaryKeyColumn) {
|
||||||
rowToSave[saveConfig.primaryKeyColumn] = mainRecordId;
|
rowToSave[saveConfig.primaryKeyColumn] = mainRecordId;
|
||||||
}
|
}
|
||||||
|
|
@ -2296,27 +2411,30 @@ export class ButtonActionExecutor {
|
||||||
insertedCount++;
|
insertedCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2️⃣ 기존 품목 UPDATE (id가 있는 항목, 변경된 경우만)
|
// 2️⃣ 기존 품목 UPDATE (id가 있는 항목)
|
||||||
|
// 전체 데이터 기반 저장: DB 데이터(__tableSection_)를 베이스로 수정 데이터가 병합된 완전한 item 사용
|
||||||
const existingItems = currentItems.filter((item) => item.id);
|
const existingItems = currentItems.filter((item) => item.id);
|
||||||
for (const item of existingItems) {
|
for (const item of existingItems) {
|
||||||
const originalItem = originalGroupedData.find((orig) => orig.id === item.id);
|
// DB 원본 데이터 우선 사용 (전체 컬럼 보장), 없으면 originalGroupedData에서 탐색
|
||||||
|
const originalItem =
|
||||||
|
sectionOriginalData.find((orig) => String(orig.id) === String(item.id)) ||
|
||||||
|
originalGroupedData.find((orig) => String(orig.id) === String(item.id));
|
||||||
|
|
||||||
|
// 마스터/디테일 분리 시: 디테일 데이터만 사용 (마스터 필드 병합 안 함)
|
||||||
|
// 같은 테이블 시: 공통 필드도 병합 (공유 필드 업데이트 필요)
|
||||||
|
const dataToSave = hasSeparateTargetTable ? item : { ...commonFieldsData, ...item };
|
||||||
|
|
||||||
if (!originalItem) {
|
if (!originalItem) {
|
||||||
// 🆕 폴백 로직: 원본 데이터가 없어도 id가 있으면 UPDATE 시도
|
// 원본 없음: 전체 데이터로 UPDATE 실행
|
||||||
// originalGroupedData 전달이 누락된 경우를 처리
|
console.warn(`⚠️ [UPDATE] 원본 데이터 없음 - 전체 데이터로 UPDATE: id=${item.id}`);
|
||||||
console.warn(`⚠️ [UPDATE] 원본 데이터 없음 - id가 있으므로 UPDATE 시도 (폴백): id=${item.id}`);
|
|
||||||
|
|
||||||
// ⚠️ 중요: commonFieldsData가 item보다 우선순위가 높아야 함
|
const rowToUpdate = { ...dataToSave, ...userInfo };
|
||||||
// item에 있는 기존 값(예: manager_id=123)이 commonFieldsData의 새 값(manager_id=234)을 덮어쓰지 않도록
|
|
||||||
// 순서: item(기존) → commonFieldsData(새로 입력) → userInfo(메타데이터)
|
|
||||||
const rowToUpdate = { ...item, ...commonFieldsData, ...userInfo };
|
|
||||||
Object.keys(rowToUpdate).forEach((key) => {
|
Object.keys(rowToUpdate).forEach((key) => {
|
||||||
if (key.startsWith("_")) {
|
if (key.startsWith("_")) {
|
||||||
delete rowToUpdate[key];
|
delete rowToUpdate[key];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// id를 유지하고 UPDATE 실행
|
|
||||||
const updateResult = await DynamicFormApi.updateFormData(item.id, {
|
const updateResult = await DynamicFormApi.updateFormData(item.id, {
|
||||||
tableName: saveTableName,
|
tableName: saveTableName,
|
||||||
data: rowToUpdate,
|
data: rowToUpdate,
|
||||||
|
|
@ -2330,17 +2448,14 @@ export class ButtonActionExecutor {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 변경 사항 확인 (공통 필드 포함)
|
// 변경 사항 확인: 원본(DB) vs 현재(병합된 전체 데이터)
|
||||||
// ⚠️ 중요: commonFieldsData가 item보다 우선순위가 높아야 함 (새로 입력한 값이 기존 값을 덮어씀)
|
const hasChanges = this.checkForChanges(originalItem, dataToSave);
|
||||||
const currentDataWithCommon = { ...item, ...commonFieldsData };
|
|
||||||
const hasChanges = this.checkForChanges(originalItem, currentDataWithCommon);
|
|
||||||
|
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
// 변경된 필드만 추출하여 부분 업데이트
|
|
||||||
const updateResult = await DynamicFormApi.updateFormDataPartial(
|
const updateResult = await DynamicFormApi.updateFormDataPartial(
|
||||||
item.id,
|
item.id,
|
||||||
originalItem,
|
originalItem,
|
||||||
currentDataWithCommon,
|
dataToSave,
|
||||||
saveTableName,
|
saveTableName,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -2349,16 +2464,11 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
|
|
||||||
updatedCount++;
|
updatedCount++;
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3️⃣ 삭제된 품목 DELETE (원본에는 있지만 현재에는 없는 항목)
|
// 3️⃣ 삭제된 품목 DELETE (원본에는 있지만 현재에는 없는 항목)
|
||||||
// 🆕 테이블 섹션별 원본 데이터 사용 (우선), 없으면 전역 originalGroupedData 사용
|
// 섹션별 DB 원본 데이터 사용 (위에서 이미 조회), 없으면 전역 originalGroupedData 사용
|
||||||
const sectionOriginalKey = `_originalTableSectionData_${sectionId}`;
|
|
||||||
const sectionOriginalData: any[] = modalData[sectionOriginalKey] || formData[sectionOriginalKey] || [];
|
|
||||||
|
|
||||||
// 섹션별 원본 데이터가 있으면 사용, 없으면 전역 originalGroupedData 사용
|
|
||||||
const originalDataForDelete = sectionOriginalData.length > 0 ? sectionOriginalData : originalGroupedData;
|
const originalDataForDelete = sectionOriginalData.length > 0 ? sectionOriginalData : originalGroupedData;
|
||||||
|
|
||||||
// ⚠️ id 타입 통일: 문자열로 변환하여 비교 (숫자 vs 문자열 불일치 방지)
|
// ⚠️ id 타입 통일: 문자열로 변환하여 비교 (숫자 vs 문자열 불일치 방지)
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,9 @@ export interface NumberingRulePart {
|
||||||
partType: CodePartType; // 파트 유형
|
partType: CodePartType; // 파트 유형
|
||||||
generationMethod: GenerationMethod; // 생성 방식
|
generationMethod: GenerationMethod; // 생성 방식
|
||||||
|
|
||||||
|
// 이 파트 뒤에 붙을 구분자 (마지막 파트는 무시됨)
|
||||||
|
separatorAfter?: string;
|
||||||
|
|
||||||
// 자동 생성 설정
|
// 자동 생성 설정
|
||||||
autoConfig?: {
|
autoConfig?: {
|
||||||
// 순번용
|
// 순번용
|
||||||
|
|
|
||||||
|
|
@ -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