jskim-node #396
|
|
@ -123,6 +123,7 @@ import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRou
|
||||||
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
|
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
|
||||||
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
|
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
|
||||||
import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트)
|
import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트)
|
||||||
|
import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준
|
||||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||||
|
|
@ -304,6 +305,7 @@ app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호
|
||||||
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
|
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
|
||||||
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
|
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
|
||||||
app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트)
|
app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트)
|
||||||
|
app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준
|
||||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,574 @@
|
||||||
|
/**
|
||||||
|
* 공정 작업기준 컨트롤러
|
||||||
|
* 품목별 라우팅/공정에 대한 작업 항목 및 상세 관리
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Response } from "express";
|
||||||
|
import { getPool } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 품목/라우팅/공정 조회 (좌측 트리 데이터)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 라우팅이 있는 품목 목록 조회
|
||||||
|
* 요청 쿼리: tableName(품목테이블), nameColumn, codeColumn
|
||||||
|
*/
|
||||||
|
export async function getItemsWithRouting(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
tableName = "item_info",
|
||||||
|
nameColumn = "item_name",
|
||||||
|
codeColumn = "item_number",
|
||||||
|
routingTable = "item_routing_version",
|
||||||
|
routingFkColumn = "item_code",
|
||||||
|
search = "",
|
||||||
|
} = req.query as Record<string, string>;
|
||||||
|
|
||||||
|
const searchCondition = search
|
||||||
|
? `AND (i.${nameColumn} ILIKE $2 OR i.${codeColumn} ILIKE $2)`
|
||||||
|
: "";
|
||||||
|
const params: any[] = [companyCode];
|
||||||
|
if (search) params.push(`%${search}%`);
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT DISTINCT
|
||||||
|
i.id,
|
||||||
|
i.${nameColumn} AS item_name,
|
||||||
|
i.${codeColumn} AS item_code
|
||||||
|
FROM ${tableName} i
|
||||||
|
INNER JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
|
||||||
|
AND rv.company_code = i.company_code
|
||||||
|
WHERE i.company_code = $1
|
||||||
|
${searchCondition}
|
||||||
|
ORDER BY i.${codeColumn}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await getPool().query(query, params);
|
||||||
|
|
||||||
|
return res.json({ success: true, data: result.rows });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("품목 목록 조회 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 품목별 라우팅 버전 + 공정 목록 조회 (트리 하위 데이터)
|
||||||
|
*/
|
||||||
|
export async function getRoutingsWithProcesses(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { itemCode } = req.params;
|
||||||
|
const {
|
||||||
|
routingVersionTable = "item_routing_version",
|
||||||
|
routingDetailTable = "item_routing_detail",
|
||||||
|
routingFkColumn = "item_code",
|
||||||
|
processTable = "process_mng",
|
||||||
|
processNameColumn = "process_name",
|
||||||
|
processCodeColumn = "process_code",
|
||||||
|
} = req.query as Record<string, string>;
|
||||||
|
|
||||||
|
// 라우팅 버전 목록
|
||||||
|
const versionsQuery = `
|
||||||
|
SELECT id, version_name, description, created_date
|
||||||
|
FROM ${routingVersionTable}
|
||||||
|
WHERE ${routingFkColumn} = $1 AND company_code = $2
|
||||||
|
ORDER BY created_date DESC
|
||||||
|
`;
|
||||||
|
const versionsResult = await getPool().query(versionsQuery, [
|
||||||
|
itemCode,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 각 버전별 공정 목록
|
||||||
|
const routings = [];
|
||||||
|
for (const version of versionsResult.rows) {
|
||||||
|
const detailsQuery = `
|
||||||
|
SELECT
|
||||||
|
rd.id AS routing_detail_id,
|
||||||
|
rd.seq_no,
|
||||||
|
rd.process_code,
|
||||||
|
rd.is_required,
|
||||||
|
rd.work_type,
|
||||||
|
p.${processNameColumn} AS process_name
|
||||||
|
FROM ${routingDetailTable} rd
|
||||||
|
LEFT JOIN ${processTable} p ON p.${processCodeColumn} = rd.process_code
|
||||||
|
AND p.company_code = rd.company_code
|
||||||
|
WHERE rd.routing_version_id = $1 AND rd.company_code = $2
|
||||||
|
ORDER BY rd.seq_no::integer
|
||||||
|
`;
|
||||||
|
const detailsResult = await getPool().query(detailsQuery, [
|
||||||
|
version.id,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
routings.push({
|
||||||
|
...version,
|
||||||
|
processes: detailsResult.rows,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: routings });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("라우팅/공정 조회 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 작업 항목 CRUD
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공정별 작업 항목 목록 조회 (phase별 그룹)
|
||||||
|
*/
|
||||||
|
export async function getWorkItems(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { routingDetailId } = req.params;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
wi.id,
|
||||||
|
wi.routing_detail_id,
|
||||||
|
wi.work_phase,
|
||||||
|
wi.title,
|
||||||
|
wi.is_required,
|
||||||
|
wi.sort_order,
|
||||||
|
wi.description,
|
||||||
|
wi.created_date,
|
||||||
|
(SELECT COUNT(*) FROM process_work_item_detail d
|
||||||
|
WHERE d.work_item_id = wi.id AND d.company_code = wi.company_code
|
||||||
|
)::integer AS detail_count
|
||||||
|
FROM process_work_item wi
|
||||||
|
WHERE wi.routing_detail_id = $1 AND wi.company_code = $2
|
||||||
|
ORDER BY wi.work_phase, wi.sort_order, wi.created_date
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await getPool().query(query, [routingDetailId, companyCode]);
|
||||||
|
|
||||||
|
// phase별 그룹핑
|
||||||
|
const grouped: Record<string, any[]> = {};
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const phase = row.work_phase;
|
||||||
|
if (!grouped[phase]) grouped[phase] = [];
|
||||||
|
grouped[phase].push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, data: grouped, items: result.rows });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("작업 항목 조회 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 항목 추가
|
||||||
|
*/
|
||||||
|
export async function createWorkItem(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
const writer = req.user?.userId;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { routing_detail_id, work_phase, title, is_required, sort_order, description } = req.body;
|
||||||
|
|
||||||
|
if (!routing_detail_id || !work_phase || !title) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "routing_detail_id, work_phase, title은 필수입니다",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO process_work_item
|
||||||
|
(company_code, routing_detail_id, work_phase, title, is_required, sort_order, description, writer)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await getPool().query(query, [
|
||||||
|
companyCode,
|
||||||
|
routing_detail_id,
|
||||||
|
work_phase,
|
||||||
|
title,
|
||||||
|
is_required || "N",
|
||||||
|
sort_order || 0,
|
||||||
|
description || null,
|
||||||
|
writer,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("작업 항목 생성", { companyCode, id: result.rows[0].id });
|
||||||
|
return res.json({ success: true, data: result.rows[0] });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("작업 항목 생성 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 항목 수정
|
||||||
|
*/
|
||||||
|
export async function updateWorkItem(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const { title, is_required, sort_order, description } = req.body;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
UPDATE process_work_item
|
||||||
|
SET title = COALESCE($1, title),
|
||||||
|
is_required = COALESCE($2, is_required),
|
||||||
|
sort_order = COALESCE($3, sort_order),
|
||||||
|
description = COALESCE($4, description),
|
||||||
|
updated_date = NOW()
|
||||||
|
WHERE id = $5 AND company_code = $6
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await getPool().query(query, [
|
||||||
|
title,
|
||||||
|
is_required,
|
||||||
|
sort_order,
|
||||||
|
description,
|
||||||
|
id,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return res.status(404).json({ success: false, message: "항목을 찾을 수 없습니다" });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("작업 항목 수정", { companyCode, id });
|
||||||
|
return res.json({ success: true, data: result.rows[0] });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("작업 항목 수정 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 항목 삭제 (상세도 함께 삭제)
|
||||||
|
*/
|
||||||
|
export async function deleteWorkItem(req: AuthenticatedRequest, res: Response) {
|
||||||
|
const client = await getPool().connect();
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
// 상세 먼저 삭제
|
||||||
|
await client.query(
|
||||||
|
"DELETE FROM process_work_item_detail WHERE work_item_id = $1 AND company_code = $2",
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 항목 삭제
|
||||||
|
const result = await client.query(
|
||||||
|
"DELETE FROM process_work_item WHERE id = $1 AND company_code = $2 RETURNING id",
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
return res.status(404).json({ success: false, message: "항목을 찾을 수 없습니다" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
logger.info("작업 항목 삭제", { companyCode, id });
|
||||||
|
return res.json({ success: true });
|
||||||
|
} 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 작업 항목 상세 CRUD
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 항목 상세 목록 조회
|
||||||
|
*/
|
||||||
|
export async function getWorkItemDetails(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { workItemId } = req.params;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark, created_date
|
||||||
|
FROM process_work_item_detail
|
||||||
|
WHERE work_item_id = $1 AND company_code = $2
|
||||||
|
ORDER BY sort_order, created_date
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await getPool().query(query, [workItemId, companyCode]);
|
||||||
|
return res.json({ success: true, data: result.rows });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("작업 항목 상세 조회 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 항목 상세 추가
|
||||||
|
*/
|
||||||
|
export async function createWorkItemDetail(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
const writer = req.user?.userId;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { work_item_id, detail_type, content, is_required, sort_order, remark } = req.body;
|
||||||
|
|
||||||
|
if (!work_item_id || !content) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "work_item_id, content는 필수입니다",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// work_item이 같은 company_code인지 검증
|
||||||
|
const ownerCheck = await getPool().query(
|
||||||
|
"SELECT id FROM process_work_item WHERE id = $1 AND company_code = $2",
|
||||||
|
[work_item_id, companyCode]
|
||||||
|
);
|
||||||
|
if (ownerCheck.rowCount === 0) {
|
||||||
|
return res.status(403).json({ success: false, message: "권한이 없습니다" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO process_work_item_detail
|
||||||
|
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await getPool().query(query, [
|
||||||
|
companyCode,
|
||||||
|
work_item_id,
|
||||||
|
detail_type || null,
|
||||||
|
content,
|
||||||
|
is_required || "N",
|
||||||
|
sort_order || 0,
|
||||||
|
remark || null,
|
||||||
|
writer,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("작업 항목 상세 생성", { companyCode, id: result.rows[0].id });
|
||||||
|
return res.json({ success: true, data: result.rows[0] });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("작업 항목 상세 생성 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 항목 상세 수정
|
||||||
|
*/
|
||||||
|
export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
const { detail_type, content, is_required, sort_order, remark } = req.body;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
UPDATE process_work_item_detail
|
||||||
|
SET detail_type = COALESCE($1, detail_type),
|
||||||
|
content = COALESCE($2, content),
|
||||||
|
is_required = COALESCE($3, is_required),
|
||||||
|
sort_order = COALESCE($4, sort_order),
|
||||||
|
remark = COALESCE($5, remark),
|
||||||
|
updated_date = NOW()
|
||||||
|
WHERE id = $6 AND company_code = $7
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await getPool().query(query, [
|
||||||
|
detail_type,
|
||||||
|
content,
|
||||||
|
is_required,
|
||||||
|
sort_order,
|
||||||
|
remark,
|
||||||
|
id,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return res.status(404).json({ success: false, message: "상세를 찾을 수 없습니다" });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("작업 항목 상세 수정", { companyCode, id });
|
||||||
|
return res.json({ success: true, data: result.rows[0] });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("작업 항목 상세 수정 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 항목 상세 삭제
|
||||||
|
*/
|
||||||
|
export async function deleteWorkItemDetail(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const result = await getPool().query(
|
||||||
|
"DELETE FROM process_work_item_detail WHERE id = $1 AND company_code = $2 RETURNING id",
|
||||||
|
[id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return res.status(404).json({ success: false, message: "상세를 찾을 수 없습니다" });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("작업 항목 상세 삭제", { companyCode, id });
|
||||||
|
return res.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("작업 항목 상세 삭제 실패", { error: error.message });
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 전체 저장 (일괄)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 저장: 작업 항목 + 상세를 일괄 저장
|
||||||
|
* 기존 데이터를 삭제하고 새로 삽입하는 replace 방식
|
||||||
|
*/
|
||||||
|
export async function saveAll(req: AuthenticatedRequest, res: Response) {
|
||||||
|
const client = await getPool().connect();
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
const writer = req.user?.userId;
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { routing_detail_id, items } = req.body;
|
||||||
|
|
||||||
|
if (!routing_detail_id || !Array.isArray(items)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "routing_detail_id와 items 배열이 필요합니다",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
// 기존 상세 삭제
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM process_work_item_detail
|
||||||
|
WHERE work_item_id IN (
|
||||||
|
SELECT id FROM process_work_item
|
||||||
|
WHERE routing_detail_id = $1 AND company_code = $2
|
||||||
|
)`,
|
||||||
|
[routing_detail_id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 기존 항목 삭제
|
||||||
|
await client.query(
|
||||||
|
"DELETE FROM process_work_item WHERE routing_detail_id = $1 AND company_code = $2",
|
||||||
|
[routing_detail_id, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 새 항목 + 상세 삽입
|
||||||
|
for (const item of items) {
|
||||||
|
const itemResult = await client.query(
|
||||||
|
`INSERT INTO process_work_item
|
||||||
|
(company_code, routing_detail_id, work_phase, title, is_required, sort_order, description, writer)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
companyCode,
|
||||||
|
routing_detail_id,
|
||||||
|
item.work_phase,
|
||||||
|
item.title,
|
||||||
|
item.is_required || "N",
|
||||||
|
item.sort_order || 0,
|
||||||
|
item.description || null,
|
||||||
|
writer,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const workItemId = itemResult.rows[0].id;
|
||||||
|
|
||||||
|
if (Array.isArray(item.details)) {
|
||||||
|
for (const detail of item.details) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO process_work_item_detail
|
||||||
|
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
[
|
||||||
|
companyCode,
|
||||||
|
workItemId,
|
||||||
|
detail.detail_type || null,
|
||||||
|
detail.content,
|
||||||
|
detail.is_required || "N",
|
||||||
|
detail.sort_order || 0,
|
||||||
|
detail.remark || null,
|
||||||
|
writer,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
logger.info("작업기준 전체 저장", { companyCode, routing_detail_id, itemCount: items.length });
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
/**
|
||||||
|
* 공정 작업기준 라우트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from "express";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
import * as ctrl from "../controllers/processWorkStandardController";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// 품목/라우팅/공정 조회 (좌측 트리)
|
||||||
|
router.get("/items", ctrl.getItemsWithRouting);
|
||||||
|
router.get("/items/:itemCode/routings", ctrl.getRoutingsWithProcesses);
|
||||||
|
|
||||||
|
// 작업 항목 CRUD
|
||||||
|
router.get("/routing-detail/:routingDetailId/work-items", ctrl.getWorkItems);
|
||||||
|
router.post("/work-items", ctrl.createWorkItem);
|
||||||
|
router.put("/work-items/:id", ctrl.updateWorkItem);
|
||||||
|
router.delete("/work-items/:id", ctrl.deleteWorkItem);
|
||||||
|
|
||||||
|
// 작업 항목 상세 CRUD
|
||||||
|
router.get("/work-items/:workItemId/details", ctrl.getWorkItemDetails);
|
||||||
|
router.post("/work-item-details", ctrl.createWorkItemDetail);
|
||||||
|
router.put("/work-item-details/:id", ctrl.updateWorkItemDetail);
|
||||||
|
router.delete("/work-item-details/:id", ctrl.deleteWorkItemDetail);
|
||||||
|
|
||||||
|
// 전체 저장 (일괄)
|
||||||
|
router.put("/save-all", ctrl.saveAll);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# ============================================================
|
||||||
|
# 엘에스티라유텍(주) - 동부지사 (COMPANY_13) 전체 데이터 Export
|
||||||
|
#
|
||||||
|
# 사용법:
|
||||||
|
# 1. SOURCE_* / TARGET_* 변수를 수정
|
||||||
|
# 2. chmod +x migrate_company13_export.sh
|
||||||
|
# 3. ./migrate_company13_export.sh export → SQL 파일 생성
|
||||||
|
# 4. ./migrate_company13_export.sh import → 대상 DB에 적재
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
SOURCE_HOST="localhost"
|
||||||
|
SOURCE_PORT="5432"
|
||||||
|
SOURCE_DB="vexplor"
|
||||||
|
SOURCE_USER="postgres"
|
||||||
|
|
||||||
|
TARGET_HOST="대상_호스트"
|
||||||
|
TARGET_PORT="5432"
|
||||||
|
TARGET_DB="대상_DB명"
|
||||||
|
TARGET_USER="postgres"
|
||||||
|
|
||||||
|
OUTPUT_FILE="company13_migration_$(date '+%Y%m%d_%H%M%S').sql"
|
||||||
|
|
||||||
|
# 데이터가 있는 테이블 (의존성 순서)
|
||||||
|
TABLES=(
|
||||||
|
"company_mng"
|
||||||
|
"user_info"
|
||||||
|
"authority_master"
|
||||||
|
"menu_info"
|
||||||
|
"external_db_connections"
|
||||||
|
"external_rest_api_connections"
|
||||||
|
"screen_definitions"
|
||||||
|
"screen_groups"
|
||||||
|
"screen_layouts_v1"
|
||||||
|
"screen_layouts_v2"
|
||||||
|
"screen_layouts_v3"
|
||||||
|
"screen_menu_assignments"
|
||||||
|
"dashboards"
|
||||||
|
"dashboard_elements"
|
||||||
|
"flow_definition"
|
||||||
|
"node_flows"
|
||||||
|
"table_column_category_values"
|
||||||
|
"attach_file_info"
|
||||||
|
"tax_invoice"
|
||||||
|
"auth_tokens"
|
||||||
|
"batch_configs"
|
||||||
|
"batch_execution_logs"
|
||||||
|
"batch_mappings"
|
||||||
|
"digital_twin_layout"
|
||||||
|
"digital_twin_layout_template"
|
||||||
|
"dtg_management"
|
||||||
|
"transport_statistics"
|
||||||
|
"vehicles"
|
||||||
|
"vehicle_location_history"
|
||||||
|
)
|
||||||
|
|
||||||
|
do_export() {
|
||||||
|
echo "=========================================="
|
||||||
|
echo " COMPANY_13 데이터 Export 시작"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
cat > "$OUTPUT_FILE" <<'HEADER'
|
||||||
|
-- ============================================================
|
||||||
|
-- 엘에스티라유텍(주) - 동부지사 (COMPANY_13) 전체 데이터 마이그레이션
|
||||||
|
--
|
||||||
|
-- 총 29개 테이블, 약 11,500건 데이터
|
||||||
|
--
|
||||||
|
-- 실행 방법:
|
||||||
|
-- psql -h HOST -U USER -d DATABASE -f 이_파일명.sql
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
SET client_encoding TO 'UTF8';
|
||||||
|
SET standard_conforming_strings = on;
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
HEADER
|
||||||
|
|
||||||
|
for TABLE in "${TABLES[@]}"; do
|
||||||
|
COUNT=$(psql -h "$SOURCE_HOST" -p "$SOURCE_PORT" -U "$SOURCE_USER" -d "$SOURCE_DB" \
|
||||||
|
-t -A -c "SELECT COUNT(*) FROM $TABLE WHERE company_code = 'COMPANY_13'")
|
||||||
|
COUNT=$(echo "$COUNT" | tr -d '[:space:]')
|
||||||
|
|
||||||
|
if [ "$COUNT" -gt 0 ]; then
|
||||||
|
echo " $TABLE: ${COUNT}건 추출 중..."
|
||||||
|
|
||||||
|
echo "-- ----------------------------------------" >> "$OUTPUT_FILE"
|
||||||
|
echo "-- $TABLE (${COUNT}건)" >> "$OUTPUT_FILE"
|
||||||
|
echo "-- ----------------------------------------" >> "$OUTPUT_FILE"
|
||||||
|
echo "COPY $TABLE FROM stdin;" >> "$OUTPUT_FILE"
|
||||||
|
|
||||||
|
psql -h "$SOURCE_HOST" -p "$SOURCE_PORT" -U "$SOURCE_USER" -d "$SOURCE_DB" \
|
||||||
|
-t -A -c "COPY (SELECT * FROM $TABLE WHERE company_code = 'COMPANY_13') TO STDOUT" >> "$OUTPUT_FILE"
|
||||||
|
|
||||||
|
echo "\\." >> "$OUTPUT_FILE"
|
||||||
|
echo "" >> "$OUTPUT_FILE"
|
||||||
|
else
|
||||||
|
echo " $TABLE: 데이터 없음 (건너뜀)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "" >> "$OUTPUT_FILE"
|
||||||
|
echo "COMMIT;" >> "$OUTPUT_FILE"
|
||||||
|
echo "" >> "$OUTPUT_FILE"
|
||||||
|
echo "-- 마이그레이션 완료" >> "$OUTPUT_FILE"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Export 완료: $OUTPUT_FILE"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
echo "대상 DB에서 실행:"
|
||||||
|
echo " psql -h $TARGET_HOST -p $TARGET_PORT -U $TARGET_USER -d $TARGET_DB -f $OUTPUT_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
do_import() {
|
||||||
|
SQL_FILE=$(ls -t company13_migration_*.sql 2>/dev/null | head -1)
|
||||||
|
|
||||||
|
if [ -z "$SQL_FILE" ]; then
|
||||||
|
echo "마이그레이션 SQL 파일을 찾을 수 없습니다. 먼저 export를 실행하세요."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " COMPANY_13 데이터 Import 시작"
|
||||||
|
echo " 파일: $SQL_FILE"
|
||||||
|
echo " 대상: $TARGET_HOST:$TARGET_PORT/$TARGET_DB"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
psql -h "$TARGET_HOST" -p "$TARGET_PORT" -U "$TARGET_USER" -d "$TARGET_DB" -f "$SQL_FILE"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Import 완료"
|
||||||
|
echo "=========================================="
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-export}" in
|
||||||
|
export)
|
||||||
|
do_export
|
||||||
|
;;
|
||||||
|
import)
|
||||||
|
do_import
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "사용법: $0 {export|import}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
@ -12,7 +12,7 @@ services:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: "3001"
|
PORT: "3001"
|
||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
DATABASE_URL: postgresql://postgres:vexplor0909!!@211.115.91.141:11134/plm
|
DATABASE_URL: postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor
|
||||||
JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024
|
JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024
|
||||||
JWT_EXPIRES_IN: 24h
|
JWT_EXPIRES_IN: 24h
|
||||||
CORS_ORIGIN: https://v1.vexplor.com,https://api.vexplor.com
|
CORS_ORIGIN: https://v1.vexplor.com,https://api.vexplor.com
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
# formData 콘솔 로그 수동 테스트 가이드
|
||||||
|
|
||||||
|
## 테스트 시나리오
|
||||||
|
|
||||||
|
1. http://localhost:9771/screens/1599?menuObjid=1762422235300 접속
|
||||||
|
2. 로그인 필요 시: `topseal_admin` / `1234`
|
||||||
|
3. 5초 대기 (페이지 로드)
|
||||||
|
4. 첫 번째 탭 "공정 마스터" 확인
|
||||||
|
5. 좌측 패널에서 **P003** 행 클릭
|
||||||
|
6. 우측 패널에서 **추가** 버튼 클릭
|
||||||
|
7. 모달에서 설비(equipment) 드롭다운에서 항목 선택
|
||||||
|
8. **저장** 버튼 클릭 **전** 콘솔 스냅샷 확인
|
||||||
|
9. **저장** 버튼 클릭 **후** 콘솔 로그 확인
|
||||||
|
|
||||||
|
## 확인할 콘솔 로그
|
||||||
|
|
||||||
|
### 1. ADD 모드 formData 설정 (ScreenModal)
|
||||||
|
|
||||||
|
```
|
||||||
|
🔵 [ScreenModal] ADD모드 formData 설정: {...}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **위치**: `frontend/components/common/ScreenModal.tsx` 358행
|
||||||
|
- **의미**: 모달이 ADD 모드로 열릴 때 부모 데이터(splitPanelParentData)로 설정된 초기 formData
|
||||||
|
- **확인**: `process_code`가 P003으로 포함되어 있는지
|
||||||
|
|
||||||
|
### 2. formData 변경 시 (ScreenModal)
|
||||||
|
|
||||||
|
```
|
||||||
|
🟡 [ScreenModal] onFormDataChange: equipment_code → E001 | formData keys: [...] | process_code: P003
|
||||||
|
```
|
||||||
|
|
||||||
|
- **위치**: `frontend/components/common/ScreenModal.tsx` 1184행
|
||||||
|
- **의미**: 사용자가 설비를 선택할 때마다 발생
|
||||||
|
- **확인**: `process_code`가 유지되는지, `equipment_code`가 추가되는지
|
||||||
|
|
||||||
|
### 3. 저장 시 formData 디버그 (ButtonPrimary)
|
||||||
|
|
||||||
|
```
|
||||||
|
🔴 [ButtonPrimary] 저장 시 formData 디버그: {
|
||||||
|
propsFormDataKeys: [...],
|
||||||
|
screenContextFormDataKeys: [...],
|
||||||
|
effectiveFormDataKeys: [...],
|
||||||
|
process_code: "P003",
|
||||||
|
equipment_code: "E001",
|
||||||
|
fullData: "{...}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **위치**: `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` 1110행
|
||||||
|
- **의미**: 저장 버튼 클릭 시 실제로 API에 전달되는 formData
|
||||||
|
- **확인**: `process_code`, `equipment_code`가 모두 포함되어 있는지
|
||||||
|
|
||||||
|
## 추가로 확인할 로그
|
||||||
|
|
||||||
|
- `process_code` 포함 로그
|
||||||
|
- `splitPanelParentData` 포함 로그
|
||||||
|
- `🆕 [추가모달] screenId 기반 모달 열기:` (SplitPanelLayoutComponent 1639행)
|
||||||
|
|
||||||
|
## 에러 확인
|
||||||
|
|
||||||
|
콘솔에 빨간색으로 표시되는 에러 메시지가 있는지 확인하세요.
|
||||||
|
|
||||||
|
## 사전 조건
|
||||||
|
|
||||||
|
- **process_mng** 테이블에 P003 데이터가 있어야 함 (company_code = 로그인 사용자 회사)
|
||||||
|
- **equipment_mng** 테이블에 설비 데이터가 있어야 함
|
||||||
|
- 로그인 사용자가 해당 회사(COMPANY_7 등) 권한이 있어야 함
|
||||||
|
|
||||||
|
## 자동 테스트 스크립트
|
||||||
|
|
||||||
|
데이터가 준비된 환경에서:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npx tsx scripts/test-formdata-logs.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
데이터가 없으면 "좌측 테이블에 데이터가 없습니다" 오류가 발생합니다.
|
||||||
|
|
@ -0,0 +1,427 @@
|
||||||
|
# 공정 작업기준 컴포넌트 (v2-process-work-standard) 구현 계획
|
||||||
|
|
||||||
|
> **작성일**: 2026-02-24
|
||||||
|
> **컴포넌트 ID**: `v2-process-work-standard`
|
||||||
|
> **성격**: 도메인 특화 컴포넌트 (v2-rack-structure와 동일 패턴)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 현황 분석
|
||||||
|
|
||||||
|
### 1.1 기존 DB 테이블 (참조용, 이미 존재)
|
||||||
|
|
||||||
|
| 테이블 | 역할 | 핵심 컬럼 |
|
||||||
|
|--------|------|----------|
|
||||||
|
| `item_info` | 품목 마스터 | id, item_name, item_number, company_code |
|
||||||
|
| `item_routing_version` | 라우팅 버전 | id, item_code, version_name, company_code |
|
||||||
|
| `item_routing_detail` | 라우팅 상세 (공정 배정) | id, routing_version_id, seq_no, process_code, company_code |
|
||||||
|
| `process_mng` | 공정 마스터 | id, process_code, process_name, company_code |
|
||||||
|
|
||||||
|
### 1.2 신규 생성 필요 테이블
|
||||||
|
|
||||||
|
**`process_work_item`** - 작업 항목 (검사 장비 준비, 외관 검사 등)
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | VARCHAR PK | UUID |
|
||||||
|
| company_code | VARCHAR NOT NULL | 멀티테넌시 |
|
||||||
|
| routing_detail_id | VARCHAR NOT NULL | item_routing_detail.id FK |
|
||||||
|
| work_phase | VARCHAR NOT NULL | Config의 phases[].key 값 (예: 'PRE', 'IN', 'POST' 또는 사용자 정의) |
|
||||||
|
| title | VARCHAR NOT NULL | 항목 제목 (예: 검사 장비 준비) |
|
||||||
|
| is_required | VARCHAR | 'Y' / 'N' |
|
||||||
|
| sort_order | INTEGER | 표시 순서 |
|
||||||
|
| description | TEXT | 비고/설명 |
|
||||||
|
| created_date | TIMESTAMP | 생성일 |
|
||||||
|
| updated_date | TIMESTAMP | 수정일 |
|
||||||
|
| writer | VARCHAR | 작성자 |
|
||||||
|
|
||||||
|
**`process_work_item_detail`** - 작업 항목 상세 (버니어 캘리퍼스 상태 소정 등)
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | VARCHAR PK | UUID |
|
||||||
|
| company_code | VARCHAR NOT NULL | 멀티테넌시 |
|
||||||
|
| work_item_id | VARCHAR NOT NULL | process_work_item.id FK |
|
||||||
|
| detail_type | VARCHAR | 'CHECK' / 'INSPECTION' / 'MEASUREMENT' 등 |
|
||||||
|
| content | VARCHAR NOT NULL | 상세 내용 |
|
||||||
|
| is_required | VARCHAR | 'Y' / 'N' |
|
||||||
|
| sort_order | INTEGER | 표시 순서 |
|
||||||
|
| remark | TEXT | 비고 |
|
||||||
|
| created_date | TIMESTAMP | 생성일 |
|
||||||
|
| updated_date | TIMESTAMP | 수정일 |
|
||||||
|
| writer | VARCHAR | 작성자 |
|
||||||
|
|
||||||
|
### 1.3 데이터 흐름 (5단계 연쇄)
|
||||||
|
|
||||||
|
```
|
||||||
|
item_info (품목)
|
||||||
|
└─→ item_routing_version (라우팅 버전)
|
||||||
|
└─→ item_routing_detail (공정 배정) ← JOIN → process_mng (공정명)
|
||||||
|
└─→ process_work_item (작업 항목, phase별)
|
||||||
|
└─→ process_work_item_detail (상세)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 파일 구조 계획
|
||||||
|
|
||||||
|
### 2.1 프론트엔드 (컴포넌트 등록)
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/lib/registry/components/v2-process-work-standard/
|
||||||
|
├── index.ts # createComponentDefinition
|
||||||
|
├── types.ts # 타입 정의
|
||||||
|
├── config.ts # 기본 설정
|
||||||
|
├── ProcessWorkStandardRenderer.tsx # AutoRegisteringComponentRenderer
|
||||||
|
├── ProcessWorkStandardConfigPanel.tsx # 설정 패널
|
||||||
|
├── ProcessWorkStandardComponent.tsx # 메인 UI (좌우 분할)
|
||||||
|
├── components/
|
||||||
|
│ ├── ItemProcessSelector.tsx # 좌측: 품목/라우팅/공정 아코디언 트리
|
||||||
|
│ ├── WorkStandardEditor.tsx # 우측: 작업기준 편집 영역 전체
|
||||||
|
│ ├── WorkPhaseSection.tsx # Pre/In/Post 섹션 (3회 재사용)
|
||||||
|
│ ├── WorkItemCard.tsx # 작업 항목 카드
|
||||||
|
│ ├── WorkItemDetailList.tsx # 상세 리스트
|
||||||
|
│ └── WorkItemAddModal.tsx # 작업 항목 추가/수정 모달
|
||||||
|
├── hooks/
|
||||||
|
│ ├── useProcessWorkStandard.ts # 전체 데이터 관리 훅
|
||||||
|
│ ├── useItemProcessTree.ts # 좌측 트리 데이터 훅
|
||||||
|
│ └── useWorkItems.ts # 작업 항목 CRUD 훅
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 백엔드 (API)
|
||||||
|
|
||||||
|
```
|
||||||
|
backend-node/src/
|
||||||
|
├── routes/processWorkStandardRoutes.ts # 라우트 정의
|
||||||
|
└── controllers/processWorkStandardController.ts # 컨트롤러
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 DB 마이그레이션
|
||||||
|
|
||||||
|
```
|
||||||
|
db/migrations/XXX_create_process_work_standard_tables.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. API 설계
|
||||||
|
|
||||||
|
| Method | Endpoint | 설명 |
|
||||||
|
|--------|----------|------|
|
||||||
|
| GET | `/api/process-work-standard/items` | 품목 목록 (라우팅 있는 품목만) |
|
||||||
|
| GET | `/api/process-work-standard/items/:itemCode/routings` | 품목별 라우팅 버전 + 공정 목록 |
|
||||||
|
| GET | `/api/process-work-standard/routing-detail/:routingDetailId/work-items` | 공정별 작업 항목 목록 (phase별 그룹) |
|
||||||
|
| POST | `/api/process-work-standard/work-items` | 작업 항목 추가 |
|
||||||
|
| PUT | `/api/process-work-standard/work-items/:id` | 작업 항목 수정 |
|
||||||
|
| DELETE | `/api/process-work-standard/work-items/:id` | 작업 항목 삭제 |
|
||||||
|
| GET | `/api/process-work-standard/work-items/:workItemId/details` | 작업 항목 상세 목록 |
|
||||||
|
| POST | `/api/process-work-standard/work-item-details` | 상세 추가 |
|
||||||
|
| PUT | `/api/process-work-standard/work-item-details/:id` | 상세 수정 |
|
||||||
|
| DELETE | `/api/process-work-standard/work-item-details/:id` | 상세 삭제 |
|
||||||
|
| PUT | `/api/process-work-standard/save-all` | 전체 저장 (작업 항목 + 상세 일괄) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 구현 단계 (TDD 기반)
|
||||||
|
|
||||||
|
### Phase 1: DB + API 기반
|
||||||
|
|
||||||
|
- [ ] 1-1. 마이그레이션 SQL 작성 (process_work_item, process_work_item_detail)
|
||||||
|
- [ ] 1-2. 마이그레이션 실행 및 테이블 생성 확인
|
||||||
|
- [ ] 1-3. 백엔드 라우트/컨트롤러 작성 (CRUD API)
|
||||||
|
- [ ] 1-4. API 테스트 (품목 목록, 라우팅 조회, 작업항목 CRUD)
|
||||||
|
|
||||||
|
### Phase 2: 컴포넌트 기본 구조
|
||||||
|
|
||||||
|
- [ ] 2-1. types.ts, config.ts, index.ts 작성 (컴포넌트 정의)
|
||||||
|
- [ ] 2-2. Renderer, ConfigPanel 작성 (V2 시스템 등록)
|
||||||
|
- [ ] 2-3. components/index.ts에 import 추가
|
||||||
|
- [ ] 2-4. getComponentConfigPanel.tsx에 매핑 추가
|
||||||
|
- [ ] 2-5. 화면 디자이너에서 컴포넌트 배치 가능 확인
|
||||||
|
|
||||||
|
### Phase 3: 좌측 패널 (품목/공정 선택)
|
||||||
|
|
||||||
|
- [ ] 3-1. useItemProcessTree 훅 구현 (품목 목록 + 라우팅 조회)
|
||||||
|
- [ ] 3-2. ItemProcessSelector 컴포넌트 (아코디언 + 공정 리스트)
|
||||||
|
- [ ] 3-3. 검색 기능 (품목명/공정명 검색)
|
||||||
|
- [ ] 3-4. 선택 상태 관리 + 우측 패널 연동
|
||||||
|
|
||||||
|
### Phase 4: 우측 패널 (작업기준 편집)
|
||||||
|
|
||||||
|
- [ ] 4-1. WorkStandardEditor 기본 레이아웃 (Pre/In/Post 3단 섹션)
|
||||||
|
- [ ] 4-2. useWorkItems 훅 (작업 항목 + 상세 CRUD)
|
||||||
|
- [ ] 4-3. WorkPhaseSection 컴포넌트 (섹션 헤더 + 카드 영역 + 상세 영역)
|
||||||
|
- [ ] 4-4. WorkItemCard 컴포넌트 (카드 UI + 카운트 배지)
|
||||||
|
- [ ] 4-5. WorkItemDetailList 컴포넌트 (상세 목록 + 인라인 편집)
|
||||||
|
- [ ] 4-6. WorkItemAddModal (작업 항목 추가/수정 모달 + 상세 추가)
|
||||||
|
|
||||||
|
### Phase 5: 통합 + 전체 저장
|
||||||
|
|
||||||
|
- [ ] 5-1. 전체 저장 기능 (변경사항 일괄 저장 API 연동)
|
||||||
|
- [ ] 5-2. 공정 선택 시 데이터 로딩/전환 처리
|
||||||
|
- [ ] 5-3. Empty State 처리 (데이터 없을 때 안내 UI)
|
||||||
|
- [ ] 5-4. 로딩/에러 상태 처리
|
||||||
|
|
||||||
|
### Phase 6: 마무리
|
||||||
|
|
||||||
|
- [ ] 6-1. 멀티테넌시 검증 (company_code 필터링)
|
||||||
|
- [ ] 6-2. 반응형 디자인 점검
|
||||||
|
- [ ] 6-3. README.md 작성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 핵심 UI 설계
|
||||||
|
|
||||||
|
### 5.1 전체 레이아웃
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ v2-process-work-standard │
|
||||||
|
├────────────────────┬────────────────────────────────────────────────┤
|
||||||
|
│ 품목 및 공정 선택 │ [품목명] - [공정명] [전체 저장] │
|
||||||
|
│ │ │
|
||||||
|
│ [검색 입력] │ ── 작업 전 (Pre-Work) N개 항목 ── [+항목추가] │
|
||||||
|
│ │ ┌────────┐ ┌─────────────────────────────┐ │
|
||||||
|
│ ▼ 볼트 M8x20 │ │카드 │ │ 상세 리스트 (선택 시 표시) │ │
|
||||||
|
│ ★ 기본 라우팅 │ │ │ │ │ │
|
||||||
|
│ ◉ 재단 │ └────────┘ └─────────────────────────────┘ │
|
||||||
|
│ ◉ 검사 ← 선택 │ │
|
||||||
|
│ ★ 버전2 │ ── 작업 중 (In-Work) N개 항목 ── [+항목추가] │
|
||||||
|
│ │ ┌────────┐ ┌────────┐ │
|
||||||
|
│ ▶ 기어 50T │ │카드1 │ │카드2 │ (상세: 우측 표시) │
|
||||||
|
│ ▶ 샤프트 D30 │ └────────┘ └────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ │ ── 작업 후 (Post-Work) N개 항목 ── [+항목추가] │
|
||||||
|
│ │ (동일 구조) │
|
||||||
|
├────────────────────┴────────────────────────────────────────────────┤
|
||||||
|
│ 30% │ 70% │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 WorkPhaseSection 내부 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
── 작업 전 (Pre-Work) 4개 항목 ────────────────── [+ 작업항목 추가]
|
||||||
|
┌──────────────────────────────┬──────────────────────────────────────┐
|
||||||
|
│ 작업 항목 카드 목록 │ 선택된 항목 상세 │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────────────────────┐ │ [항목 제목] [+ 상세추가]│
|
||||||
|
│ │ ≡ 검사 장비 준비 ✏️ 🗑 │ │ ─────────────────────────────────── │
|
||||||
|
│ │ 4개 필수 │ │ 순서│유형 │내용 │필수│관리│
|
||||||
|
│ └──────────────────────┘ │ 1 │체크 │버니어 캘리퍼스... │필수│✏️🗑│
|
||||||
|
│ │ 2 │체크 │마이크로미터... │선택│✏️🗑│
|
||||||
|
│ ┌──────────────────────┐ │ 3 │체크 │검사대 청소 │선택│✏️🗑│
|
||||||
|
│ │ ≡ 측정 도구 확인 ✏️ 🗑 │ │ 4 │체크 │검사 기록지 준비 │필수│✏️🗑│
|
||||||
|
│ │ 2개 선택 │ │ │
|
||||||
|
│ └──────────────────────┘ │ │
|
||||||
|
└──────────────────────────────┴──────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 작업 항목 추가 모달
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ 작업 항목 추가 ✕ │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ 기본 정보 │
|
||||||
|
│ │
|
||||||
|
│ 항목 제목 * 필수 여부 │
|
||||||
|
│ [ ] [필수 ▼] │
|
||||||
|
│ │
|
||||||
|
│ 비고 │
|
||||||
|
│ [ ] │
|
||||||
|
│ │
|
||||||
|
│ 상세 항목 [+ 상세 추가] │
|
||||||
|
│ ┌───┬──────┬──────────────┬────┬────┐ │
|
||||||
|
│ │순서│유형 │내용 │필수│관리│ │
|
||||||
|
│ ├───┼──────┼──────────────┼────┼────┤ │
|
||||||
|
│ │ 1 │체크 │ │필수│ 🗑 │ │
|
||||||
|
│ └───┴──────┴──────────────┴────┴────┘ │
|
||||||
|
│ │
|
||||||
|
│ [취소] [저장] │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 컴포넌트 Config 설계
|
||||||
|
|
||||||
|
### 6.1 설정 패널 UI 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ 공정 작업기준 설정 │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ── 데이터 소스 설정 ────────────────────────── │
|
||||||
|
│ │
|
||||||
|
│ 품목 테이블 │
|
||||||
|
│ [item_info ▼] │
|
||||||
|
│ 품목명 컬럼 품목코드 컬럼 │
|
||||||
|
│ [item_name ▼] [item_number ▼] │
|
||||||
|
│ │
|
||||||
|
│ 라우팅 버전 테이블 │
|
||||||
|
│ [item_routing_version ▼] │
|
||||||
|
│ 품목 연결 컬럼 (FK) │
|
||||||
|
│ [item_code ▼] │
|
||||||
|
│ │
|
||||||
|
│ 라우팅 상세 테이블 │
|
||||||
|
│ [item_routing_detail ▼] │
|
||||||
|
│ │
|
||||||
|
│ 공정 마스터 테이블 │
|
||||||
|
│ [process_mng ▼] │
|
||||||
|
│ │
|
||||||
|
│ ── 작업 단계 설정 ────────────────────────── │
|
||||||
|
│ │
|
||||||
|
│ ┌────┬────────────────────┬─────────────┬───┐ │
|
||||||
|
│ │순서│ 단계 키(DB저장용) │ 표시 이름 │관리│ │
|
||||||
|
│ ├────┼────────────────────┼─────────────┼───┤ │
|
||||||
|
│ │ 1 │ PRE │ 작업 전 │ 🗑 │ │
|
||||||
|
│ │ 2 │ IN │ 작업 중 │ 🗑 │ │
|
||||||
|
│ │ 3 │ POST │ 작업 후 │ 🗑 │ │
|
||||||
|
│ └────┴────────────────────┴─────────────┴───┘ │
|
||||||
|
│ [+ 단계 추가] │
|
||||||
|
│ │
|
||||||
|
│ ── 상세 유형 옵션 ────────────────────────── │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────┬─────────────┬───┐ │
|
||||||
|
│ │ 유형 값(DB저장용) │ 표시 이름 │관리│ │
|
||||||
|
│ ├────────────────────┼─────────────┼───┤ │
|
||||||
|
│ │ CHECK │ 체크 │ 🗑 │ │
|
||||||
|
│ │ INSPECTION │ 검사 │ 🗑 │ │
|
||||||
|
│ │ MEASUREMENT │ 측정 │ 🗑 │ │
|
||||||
|
│ └────────────────────┴─────────────┴───┘ │
|
||||||
|
│ [+ 유형 추가] │
|
||||||
|
│ │
|
||||||
|
│ ── UI 설정 ────────────────────────── │
|
||||||
|
│ │
|
||||||
|
│ 좌우 분할 비율 │
|
||||||
|
│ [30 ] % │
|
||||||
|
│ │
|
||||||
|
│ 좌측 패널 제목 │
|
||||||
|
│ [품목 및 공정 선택 ] │
|
||||||
|
│ │
|
||||||
|
│ 읽기 전용 모드 │
|
||||||
|
│ [ ] 활성화 │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Config 타입 정의
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 작업 단계 정의 (사용자가 추가/삭제/이름변경 가능)
|
||||||
|
interface WorkPhaseDefinition {
|
||||||
|
key: string; // DB 저장용 키 (예: "PRE", "IN", "POST", "QC")
|
||||||
|
label: string; // 화면 표시명 (예: "작업 전 (Pre-Work)")
|
||||||
|
sortOrder: number; // 표시 순서
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상세 유형 정의 (사용자가 추가/삭제 가능)
|
||||||
|
interface DetailTypeDefinition {
|
||||||
|
value: string; // DB 저장용 값 (예: "CHECK")
|
||||||
|
label: string; // 화면 표시명 (예: "체크")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 소스 설정 (사용자가 테이블 지정 가능)
|
||||||
|
interface DataSourceConfig {
|
||||||
|
// 품목 테이블
|
||||||
|
itemTable: string; // 기본: "item_info"
|
||||||
|
itemNameColumn: string; // 기본: "item_name"
|
||||||
|
itemCodeColumn: string; // 기본: "item_number"
|
||||||
|
|
||||||
|
// 라우팅 버전 테이블
|
||||||
|
routingVersionTable: string; // 기본: "item_routing_version"
|
||||||
|
routingItemFkColumn: string; // 기본: "item_code" (품목과 연결하는 FK)
|
||||||
|
routingVersionNameColumn: string; // 기본: "version_name"
|
||||||
|
|
||||||
|
// 라우팅 상세 테이블
|
||||||
|
routingDetailTable: string; // 기본: "item_routing_detail"
|
||||||
|
|
||||||
|
// 공정 마스터 테이블
|
||||||
|
processTable: string; // 기본: "process_mng"
|
||||||
|
processNameColumn: string; // 기본: "process_name"
|
||||||
|
processCodeColumn: string; // 기본: "process_code"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전체 Config
|
||||||
|
interface ProcessWorkStandardConfig {
|
||||||
|
// 데이터 소스 설정
|
||||||
|
dataSource: DataSourceConfig;
|
||||||
|
|
||||||
|
// 작업 단계 정의 (기본 3개, 사용자가 추가/삭제/수정 가능)
|
||||||
|
phases: WorkPhaseDefinition[];
|
||||||
|
// 기본값: [
|
||||||
|
// { key: "PRE", label: "작업 전 (Pre-Work)", sortOrder: 1 },
|
||||||
|
// { key: "IN", label: "작업 중 (In-Work)", sortOrder: 2 },
|
||||||
|
// { key: "POST", label: "작업 후 (Post-Work)", sortOrder: 3 },
|
||||||
|
// ]
|
||||||
|
|
||||||
|
// 상세 유형 옵션 (사용자가 추가/삭제 가능)
|
||||||
|
detailTypes: DetailTypeDefinition[];
|
||||||
|
// 기본값: [
|
||||||
|
// { value: "CHECK", label: "체크" },
|
||||||
|
// { value: "INSPECTION", label: "검사" },
|
||||||
|
// { value: "MEASUREMENT", label: "측정" },
|
||||||
|
// ]
|
||||||
|
|
||||||
|
// UI 설정
|
||||||
|
splitRatio?: number; // 좌우 분할 비율, 기본: 30
|
||||||
|
leftPanelTitle?: string; // 좌측 패널 제목, 기본: "품목 및 공정 선택"
|
||||||
|
readonly?: boolean; // 읽기 전용 모드, 기본: false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 커스터마이징 시나리오 예시
|
||||||
|
|
||||||
|
**시나리오 A: 제조업 (기본)**
|
||||||
|
```
|
||||||
|
단계: 작업 전 → 작업 중 → 작업 후
|
||||||
|
유형: 체크, 검사, 측정
|
||||||
|
```
|
||||||
|
|
||||||
|
**시나리오 B: 품질검사 강화 회사**
|
||||||
|
```
|
||||||
|
단계: 준비 → 검사 → 판정 → 기록 → 보관
|
||||||
|
유형: 육안검사, 치수검사, 강도검사, 내구검사, 기능검사
|
||||||
|
```
|
||||||
|
|
||||||
|
**시나리오 C: 단순 2단계 회사**
|
||||||
|
```
|
||||||
|
단계: 사전점검 → 사후점검
|
||||||
|
유형: 확인, 기록
|
||||||
|
```
|
||||||
|
|
||||||
|
**시나리오 D: 다른 테이블 사용 회사**
|
||||||
|
```
|
||||||
|
품목 테이블: product_master (item_info 대신)
|
||||||
|
공정 테이블: operation_mng (process_mng 대신)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 DB 설계 반영 사항
|
||||||
|
|
||||||
|
`work_phase` 컬럼은 고정 ENUM이 아니라 **사용자 정의 키(VARCHAR)** 로 저장합니다.
|
||||||
|
- Config에서 `phases[].key` 로 정의한 값이 DB에 저장됨
|
||||||
|
- 예: "PRE", "IN", "POST" 또는 "PREPARE", "INSPECT", "JUDGE", "RECORD", "STORE"
|
||||||
|
- 회사별 Config에 따라 다른 값이 저장되므로, 조회 시 Config의 phases 정의를 기준으로 섹션을 렌더링
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 등록 체크리스트
|
||||||
|
|
||||||
|
| 항목 | 파일 | 작업 |
|
||||||
|
|------|------|------|
|
||||||
|
| 컴포넌트 정의 | `v2-process-work-standard/index.ts` | createComponentDefinition |
|
||||||
|
| 렌더러 등록 | `v2-process-work-standard/...Renderer.tsx` | registerSelf() |
|
||||||
|
| 컴포넌트 로드 | `components/index.ts` | import 추가 |
|
||||||
|
| 설정 패널 매핑 | `getComponentConfigPanel.tsx` | CONFIG_PANEL_MAP 추가 |
|
||||||
|
| 라우트 등록 | `backend-node/src/app.ts` | router.use() 추가 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 의존성
|
||||||
|
|
||||||
|
- 외부 라이브러리 추가: 없음 (기존 shadcn/ui + Lucide 아이콘만 사용)
|
||||||
|
- 기존 API 재사용: dataRoutes의 범용 CRUD는 사용하지 않고 전용 API 개발
|
||||||
|
- 이유: 5단계 JOIN + phase별 그룹핑 등 범용 API로는 처리 불가
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
> **목적**: 다양한 회사에서 V2 컴포넌트를 활용하여 화면을 개발할 때 참고하는 범용 가이드
|
> **목적**: 다양한 회사에서 V2 컴포넌트를 활용하여 화면을 개발할 때 참고하는 범용 가이드
|
||||||
> **대상**: 화면 설계자, 개발자
|
> **대상**: 화면 설계자, 개발자
|
||||||
> **버전**: 1.0.0
|
> **버전**: 1.1.0
|
||||||
> **작성일**: 2026-01-30
|
> **작성일**: 2026-02-23 (최종 업데이트)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -19,60 +19,63 @@
|
||||||
| 카드 뷰 | 이미지+정보 카드 형태 | 설비정보, 대시보드 |
|
| 카드 뷰 | 이미지+정보 카드 형태 | 설비정보, 대시보드 |
|
||||||
| 피벗 분석 | 다차원 집계 | 매출분석, 재고현황 |
|
| 피벗 분석 | 다차원 집계 | 매출분석, 재고현황 |
|
||||||
| 반복 컨테이너 | 데이터 수만큼 UI 반복 | 주문 상세, 항목 리스트 |
|
| 반복 컨테이너 | 데이터 수만큼 UI 반복 | 주문 상세, 항목 리스트 |
|
||||||
|
| 그룹화 테이블 | 그룹핑 기능 포함 테이블 | 카테고리별 집계, 부서별 현황 |
|
||||||
|
| 타임라인/스케줄 | 시간축 기반 일정 관리 | 생산일정, 작업스케줄 |
|
||||||
|
|
||||||
### 1.2 불가능한 화면 유형 (별도 개발 필요)
|
### 1.2 불가능한 화면 유형 (별도 개발 필요)
|
||||||
|
|
||||||
| 화면 유형 | 이유 | 해결 방안 |
|
| 화면 유형 | 이유 | 해결 방안 |
|
||||||
|-----------|------|----------|
|
|-----------|------|----------|
|
||||||
| 간트 차트 / 타임라인 | 시간축 기반 UI 없음 | 별도 컴포넌트 개발 or 외부 라이브러리 |
|
|
||||||
| 트리 뷰 (계층 구조) | 트리 컴포넌트 미존재 | `v2-tree-view` 개발 필요 |
|
| 트리 뷰 (계층 구조) | 트리 컴포넌트 미존재 | `v2-tree-view` 개발 필요 |
|
||||||
| 그룹화 테이블 | 그룹핑 기능 미지원 | `v2-grouped-table` 개발 필요 |
|
|
||||||
| 드래그앤드롭 보드 | 칸반 스타일 UI 없음 | 별도 개발 |
|
| 드래그앤드롭 보드 | 칸반 스타일 UI 없음 | 별도 개발 |
|
||||||
| 모바일 앱 스타일 | 네이티브 앱 UI | 별도 개발 |
|
| 모바일 앱 스타일 | 네이티브 앱 UI | 별도 개발 |
|
||||||
| 복잡한 차트 | 기본 집계 외 시각화 | 차트 라이브러리 연동 |
|
| 복잡한 차트 | 기본 집계 외 시각화 | 차트 라이브러리 연동 |
|
||||||
|
|
||||||
|
> **참고**: 그룹화 테이블(`v2-table-grouped`)과 타임라인 스케줄러(`v2-timeline-scheduler`)는 v1.1에서 추가되어 이제 지원됩니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. V2 컴포넌트 전체 목록 (23개)
|
## 2. V2 컴포넌트 전체 목록 (25개)
|
||||||
|
|
||||||
### 2.1 입력 컴포넌트 (3개)
|
### 2.1 입력 컴포넌트 (4개)
|
||||||
|
|
||||||
| ID | 이름 | 용도 | 주요 옵션 |
|
| ID | 이름 | 용도 | 주요 옵션 |
|
||||||
|----|------|------|----------|
|
|----|------|------|----------|
|
||||||
| `v2-input` | 입력 | 텍스트, 숫자, 비밀번호, 이메일, 전화번호, URL, 여러 줄 | inputType, required, readonly, maxLength |
|
| `v2-input` | 입력 | 텍스트, 숫자, 비밀번호, 슬라이더, 컬러 | inputType(text/number/password/slider/color/button), format(email/tel/url/currency/biz_no), required, readonly, maxLength, min, max, step |
|
||||||
| `v2-select` | 선택 | 드롭다운, 콤보박스, 라디오, 체크박스 | mode, source(distinct/static/code/entity), multiple |
|
| `v2-select` | 선택 | 드롭다운, 콤보박스, 라디오, 체크, 태그, 토글, 스왑 | mode(dropdown/combobox/radio/check/tag/tagbox/toggle/swap), source(static/code/db/api/entity/category/distinct/select), searchable, multiple, cascading |
|
||||||
| `v2-date` | 날짜 | 날짜, 시간, 날짜시간, 날짜범위, 월, 연도 | dateType, format, showTime |
|
| `v2-date` | 날짜 | 날짜, 시간, 날짜시간 | dateType(date/time/datetime), format, range, minDate, maxDate, showToday |
|
||||||
|
| `v2-file-upload` | 파일 업로드 | 파일/이미지 업로드 | - |
|
||||||
|
|
||||||
### 2.2 표시 컴포넌트 (3개)
|
### 2.2 표시 컴포넌트 (3개)
|
||||||
|
|
||||||
| ID | 이름 | 용도 | 주요 옵션 |
|
| ID | 이름 | 용도 | 주요 옵션 |
|
||||||
|----|------|------|----------|
|
|----|------|------|----------|
|
||||||
| `v2-text-display` | 텍스트 표시 | 라벨, 제목, 설명 텍스트 | fontSize, fontWeight, color, textAlign |
|
| `v2-text-display` | 텍스트 표시 | 라벨, 제목, 설명 텍스트 | fontSize, fontWeight, color, textAlign |
|
||||||
| `v2-card-display` | 카드 디스플레이 | 테이블 데이터를 카드 형태로 표시 | cardsPerRow, showImage, columnMapping |
|
| `v2-card-display` | 카드 디스플레이 | 테이블 데이터를 카드 형태로 표시 | cardsPerRow, cardSpacing, columnMapping(titleColumn/subtitleColumn/descriptionColumn/imageColumn), cardStyle(imagePosition/imageSize), dataSource(table/static/api) |
|
||||||
| `v2-aggregation-widget` | 집계 위젯 | 합계, 평균, 개수, 최대, 최소 | items, filters, layout |
|
| `v2-aggregation-widget` | 집계 위젯 | 합계, 평균, 개수, 최대, 최소 | items, filters, layout |
|
||||||
|
|
||||||
### 2.3 테이블/데이터 컴포넌트 (3개)
|
### 2.3 테이블/데이터 컴포넌트 (4개)
|
||||||
|
|
||||||
| ID | 이름 | 용도 | 주요 옵션 |
|
| ID | 이름 | 용도 | 주요 옵션 |
|
||||||
|----|------|------|----------|
|
|----|------|------|----------|
|
||||||
| `v2-table-list` | 테이블 리스트 | 데이터 조회/편집 테이블 | selectedTable, columns, pagination, filter |
|
| `v2-table-list` | 테이블 리스트 | 데이터 조회/편집 테이블 | selectedTable, columns, pagination, filter, displayMode(table/card), checkbox, horizontalScroll, linkedFilters, excludeFilter, toolbar, tableStyle, autoLoad |
|
||||||
| `v2-table-search-widget` | 검색 필터 | 테이블 검색/필터/그룹 | autoSelectFirstTable, showTableSelector |
|
| `v2-table-search-widget` | 검색 필터 | 테이블 검색/필터/그룹 | autoSelectFirstTable, showTableSelector, title |
|
||||||
| `v2-pivot-grid` | 피벗 그리드 | 다차원 분석 (행/열/데이터 영역) | fields, totals, aggregation |
|
| `v2-pivot-grid` | 피벗 그리드 | 다차원 분석 (행/열/데이터 영역) | fields(area: row/column/data/filter, summaryType: sum/avg/count/min/max/countDistinct, groupInterval: year/quarter/month/week/day), dataSource(type: table/api/static, joinConfigs, filterConditions) |
|
||||||
|
| `v2-table-grouped` | 그룹화 테이블 | 그룹핑 기능이 포함된 테이블 | - |
|
||||||
|
|
||||||
### 2.4 레이아웃 컴포넌트 (8개)
|
### 2.4 레이아웃 컴포넌트 (7개)
|
||||||
|
|
||||||
| ID | 이름 | 용도 | 주요 옵션 |
|
| ID | 이름 | 용도 | 주요 옵션 |
|
||||||
|----|------|------|----------|
|
|----|------|------|----------|
|
||||||
| `v2-split-panel-layout` | 분할 패널 | 마스터-디테일 좌우 분할 | splitRatio, resizable, relation, **displayMode: custom** |
|
| `v2-split-panel-layout` | 분할 패널 | 마스터-디테일 좌우 분할 | splitRatio, resizable, minLeftWidth, minRightWidth, syncSelection, panel별: displayMode(list/table/custom), relation(type/foreignKey), editButton, addButton, deleteButton, additionalTabs |
|
||||||
| `v2-tabs-widget` | 탭 위젯 | 탭 전환, 탭 내 컴포넌트 | tabs, activeTabId |
|
| `v2-tabs-widget` | 탭 위젯 | 탭 전환, 탭 내 컴포넌트 | tabs(id/label/order/disabled/components), defaultTab, orientation(horizontal/vertical), allowCloseable, persistSelection |
|
||||||
| `v2-section-card` | 섹션 카드 | 제목+테두리 그룹화 | title, collapsible, padding |
|
| `v2-section-card` | 섹션 카드 | 제목+테두리 그룹화 | title, collapsible, padding |
|
||||||
| `v2-section-paper` | 섹션 페이퍼 | 배경색 그룹화 | backgroundColor, padding, shadow |
|
| `v2-section-paper` | 섹션 페이퍼 | 배경색 그룹화 | backgroundColor, padding, shadow |
|
||||||
| `v2-divider-line` | 구분선 | 영역 구분 | orientation, thickness |
|
| `v2-divider-line` | 구분선 | 영역 구분 | orientation, thickness |
|
||||||
| `v2-repeat-container` | 리피터 컨테이너 | 데이터 수만큼 반복 렌더링 | dataSourceType, layout, gridColumns |
|
| `v2-repeat-container` | 리피터 컨테이너 | 데이터 수만큼 반복 렌더링 | dataSourceType, layout, gridColumns |
|
||||||
| `v2-repeater` | 리피터 | 반복 컨트롤 | - |
|
| `v2-repeater` | 리피터 | 반복 컨트롤 (inline/modal) | - |
|
||||||
| `v2-repeat-screen-modal` | 반복 화면 모달 | 모달 반복 | - |
|
|
||||||
|
|
||||||
### 2.5 액션/특수 컴포넌트 (6개)
|
### 2.5 액션/특수 컴포넌트 (7개)
|
||||||
|
|
||||||
| ID | 이름 | 용도 | 주요 옵션 |
|
| ID | 이름 | 용도 | 주요 옵션 |
|
||||||
|----|------|------|----------|
|
|----|------|------|----------|
|
||||||
|
|
@ -82,6 +85,7 @@
|
||||||
| `v2-location-swap-selector` | 위치 교환 | 위치 선택/교환 | - |
|
| `v2-location-swap-selector` | 위치 교환 | 위치 선택/교환 | - |
|
||||||
| `v2-rack-structure` | 랙 구조 | 창고 랙 시각화 | - |
|
| `v2-rack-structure` | 랙 구조 | 창고 랙 시각화 | - |
|
||||||
| `v2-media` | 미디어 | 이미지/동영상 표시 | - |
|
| `v2-media` | 미디어 | 이미지/동영상 표시 | - |
|
||||||
|
| `v2-timeline-scheduler` | 타임라인 스케줄러 | 시간축 기반 일정/작업 관리 | - |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -261,8 +265,26 @@
|
||||||
],
|
],
|
||||||
pagination: {
|
pagination: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
pageSize: 20
|
pageSize: 20,
|
||||||
}
|
showSizeSelector: true,
|
||||||
|
showPageInfo: true
|
||||||
|
},
|
||||||
|
displayMode: "table", // "table" | "card"
|
||||||
|
checkbox: {
|
||||||
|
enabled: true,
|
||||||
|
multiple: true,
|
||||||
|
position: "left",
|
||||||
|
selectAll: true
|
||||||
|
},
|
||||||
|
horizontalScroll: { // 가로 스크롤 설정
|
||||||
|
enabled: true,
|
||||||
|
maxVisibleColumns: 8
|
||||||
|
},
|
||||||
|
linkedFilters: [], // 연결 필터 (다른 컴포넌트와 연동)
|
||||||
|
excludeFilter: {}, // 제외 필터
|
||||||
|
autoLoad: true, // 자동 데이터 로드
|
||||||
|
stickyHeader: false, // 헤더 고정
|
||||||
|
autoWidth: true // 자동 너비 조정
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -271,16 +293,44 @@
|
||||||
```typescript
|
```typescript
|
||||||
{
|
{
|
||||||
leftPanel: {
|
leftPanel: {
|
||||||
tableName: "마스터_테이블명"
|
displayMode: "table", // "list" | "table" | "custom"
|
||||||
|
tableName: "마스터_테이블명",
|
||||||
|
columns: [], // 컬럼 설정
|
||||||
|
editButton: { // 수정 버튼 설정
|
||||||
|
enabled: true,
|
||||||
|
mode: "auto", // "auto" | "modal"
|
||||||
|
modalScreenId: "" // 모달 모드 시 화면 ID
|
||||||
|
},
|
||||||
|
addButton: { // 추가 버튼 설정
|
||||||
|
enabled: true,
|
||||||
|
mode: "auto",
|
||||||
|
modalScreenId: ""
|
||||||
|
},
|
||||||
|
deleteButton: { // 삭제 버튼 설정
|
||||||
|
enabled: true,
|
||||||
|
buttonLabel: "삭제",
|
||||||
|
confirmMessage: "삭제하시겠습니까?"
|
||||||
|
},
|
||||||
|
addModalColumns: [], // 추가 모달 전용 컬럼
|
||||||
|
additionalTabs: [] // 추가 탭 설정
|
||||||
},
|
},
|
||||||
rightPanel: {
|
rightPanel: {
|
||||||
|
displayMode: "table",
|
||||||
tableName: "디테일_테이블명",
|
tableName: "디테일_테이블명",
|
||||||
relation: {
|
relation: {
|
||||||
type: "detail", // join | detail | custom
|
type: "detail", // "join" | "detail" | "custom"
|
||||||
foreignKey: "master_id" // 연결 키
|
foreignKey: "master_id", // 연결 키
|
||||||
|
leftColumn: "", // 좌측 연결 컬럼
|
||||||
|
rightColumn: "", // 우측 연결 컬럼
|
||||||
|
keys: [] // 복합 키
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
splitRatio: 30 // 좌측 비율
|
splitRatio: 30, // 좌측 비율 (0-100)
|
||||||
|
resizable: true, // 리사이즈 가능
|
||||||
|
minLeftWidth: 200, // 좌측 최소 너비
|
||||||
|
minRightWidth: 300, // 우측 최소 너비
|
||||||
|
syncSelection: true, // 선택 동기화
|
||||||
|
autoLoad: true // 자동 로드
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -347,12 +397,12 @@
|
||||||
| 기능 | 상태 | 대안 |
|
| 기능 | 상태 | 대안 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 트리 뷰 (BOM, 조직도) | ❌ 미지원 | 테이블로 대체 or 별도 개발 |
|
| 트리 뷰 (BOM, 조직도) | ❌ 미지원 | 테이블로 대체 or 별도 개발 |
|
||||||
| 그룹화 테이블 | ❌ 미지원 | 일반 테이블로 대체 or 별도 개발 |
|
|
||||||
| 간트 차트 | ❌ 미지원 | 별도 개발 필요 |
|
|
||||||
| 드래그앤드롭 정렬 | ❌ 미지원 | 순서 컬럼으로 대체 |
|
| 드래그앤드롭 정렬 | ❌ 미지원 | 순서 컬럼으로 대체 |
|
||||||
| 인라인 편집 | ⚠️ 제한적 | 모달 편집으로 대체 |
|
| 인라인 편집 | ⚠️ 제한적 | 모달 편집으로 대체 |
|
||||||
| 복잡한 차트 | ❌ 미지원 | 외부 라이브러리 연동 |
|
| 복잡한 차트 | ❌ 미지원 | 외부 라이브러리 연동 |
|
||||||
|
|
||||||
|
> **v1.1 업데이트**: 그룹화 테이블(`v2-table-grouped`)과 타임라인 스케줄러(`v2-timeline-scheduler`)가 추가되어 해당 기능은 이제 지원됩니다.
|
||||||
|
|
||||||
### 5.2 권장하지 않는 조합
|
### 5.2 권장하지 않는 조합
|
||||||
|
|
||||||
| 조합 | 이유 |
|
| 조합 | 이유 |
|
||||||
|
|
@ -555,9 +605,10 @@
|
||||||
| 탭 화면 | ✅ 완전 | v2-tabs-widget |
|
| 탭 화면 | ✅ 완전 | v2-tabs-widget |
|
||||||
| 카드 뷰 | ✅ 완전 | v2-card-display |
|
| 카드 뷰 | ✅ 완전 | v2-card-display |
|
||||||
| 피벗 분석 | ✅ 완전 | v2-pivot-grid |
|
| 피벗 분석 | ✅ 완전 | v2-pivot-grid |
|
||||||
| 그룹화 테이블 | ❌ 미지원 | 개발 필요 |
|
| 그룹화 테이블 | ✅ 지원 | v2-table-grouped |
|
||||||
|
| 타임라인/스케줄 | ✅ 지원 | v2-timeline-scheduler |
|
||||||
|
| 파일 업로드 | ✅ 지원 | v2-file-upload |
|
||||||
| 트리 뷰 | ❌ 미지원 | 개발 필요 |
|
| 트리 뷰 | ❌ 미지원 | 개발 필요 |
|
||||||
| 간트 차트 | ❌ 미지원 | 개발 필요 |
|
|
||||||
|
|
||||||
### 개발 시 핵심 원칙
|
### 개발 시 핵심 원칙
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -178,10 +178,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
splitPanelParentData,
|
splitPanelParentData,
|
||||||
selectedData: eventSelectedData,
|
selectedData: eventSelectedData,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
isCreateMode, // 🆕 복사 모드 플래그 (true면 editData가 있어도 originalData 설정 안 함)
|
isCreateMode,
|
||||||
fieldMappings, // 🆕 필드 매핑 정보 (명시적 매핑이 있으면 모든 매핑된 필드 전달)
|
fieldMappings,
|
||||||
} = event.detail;
|
} = event.detail;
|
||||||
|
|
||||||
|
console.log("🟣 [ScreenModal] openScreenModal 이벤트 수신:", {
|
||||||
|
screenId,
|
||||||
|
splitPanelParentData: JSON.stringify(splitPanelParentData),
|
||||||
|
editData: !!editData,
|
||||||
|
isCreateMode,
|
||||||
|
});
|
||||||
|
|
||||||
// 🆕 모달 열린 시간 기록
|
// 🆕 모달 열린 시간 기록
|
||||||
modalOpenedAtRef.current = Date.now();
|
modalOpenedAtRef.current = Date.now();
|
||||||
|
|
||||||
|
|
@ -355,8 +362,10 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(parentData).length > 0) {
|
if (Object.keys(parentData).length > 0) {
|
||||||
|
console.log("🔵 [ScreenModal] ADD모드 formData 설정:", JSON.stringify(parentData));
|
||||||
setFormData(parentData);
|
setFormData(parentData);
|
||||||
} else {
|
} else {
|
||||||
|
console.log("🔵 [ScreenModal] ADD모드 formData 비어있음");
|
||||||
setFormData({});
|
setFormData({});
|
||||||
}
|
}
|
||||||
setOriginalData(null); // 신규 등록 모드
|
setOriginalData(null); // 신규 등록 모드
|
||||||
|
|
@ -1174,13 +1183,13 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
formData={formData}
|
formData={formData}
|
||||||
originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용)
|
originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용)
|
||||||
onFormDataChange={(fieldName, value) => {
|
onFormDataChange={(fieldName, value) => {
|
||||||
// 사용자가 실제로 데이터를 변경한 것으로 표시
|
|
||||||
formDataChangedRef.current = true;
|
formDataChangedRef.current = true;
|
||||||
setFormData((prev) => {
|
setFormData((prev) => {
|
||||||
const newFormData = {
|
const newFormData = {
|
||||||
...prev,
|
...prev,
|
||||||
[fieldName]: value,
|
[fieldName]: value,
|
||||||
};
|
};
|
||||||
|
console.log("🟡 [ScreenModal] onFormDataChange:", fieldName, "→", value, "| formData keys:", Object.keys(newFormData), "| process_code:", newFormData.process_code);
|
||||||
return newFormData;
|
return newFormData;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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 || [];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
onRowClick,
|
onRowClick,
|
||||||
className,
|
className,
|
||||||
formData: parentFormData,
|
formData: parentFormData,
|
||||||
|
...restProps
|
||||||
}) => {
|
}) => {
|
||||||
|
// ScreenModal에서 전달된 groupedData (모달 간 데이터 전달용)
|
||||||
|
const groupedData = (restProps as any).groupedData || (restProps as any)._groupedData;
|
||||||
// 설정 병합
|
// 설정 병합
|
||||||
const config: V2RepeaterConfig = useMemo(
|
const config: V2RepeaterConfig = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|
@ -681,6 +684,15 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
case "fixed":
|
case "fixed":
|
||||||
return col.autoFill.fixedValue ?? "";
|
return col.autoFill.fixedValue ?? "";
|
||||||
|
|
||||||
|
case "parentSequence": {
|
||||||
|
const parentField = col.autoFill.parentField;
|
||||||
|
const separator = col.autoFill.separator ?? "-";
|
||||||
|
const seqLength = col.autoFill.sequenceLength ?? 2;
|
||||||
|
const parentValue = parentField && mainFormData ? String(mainFormData[parentField] ?? "") : "";
|
||||||
|
const seqNum = String(rowIndex + 1).padStart(seqLength, "0");
|
||||||
|
return parentValue ? `${parentValue}${separator}${seqNum}` : seqNum;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
@ -707,7 +719,74 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🆕 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경
|
// 모달에서 전달된 groupedData를 초기 행 데이터로 변환 (컬럼 매핑 포함)
|
||||||
|
const groupedDataProcessedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!groupedData || !Array.isArray(groupedData) || groupedData.length === 0) return;
|
||||||
|
if (groupedDataProcessedRef.current) return;
|
||||||
|
|
||||||
|
groupedDataProcessedRef.current = true;
|
||||||
|
|
||||||
|
const newRows = groupedData.map((item: any, index: number) => {
|
||||||
|
const row: any = { _id: `grouped_${Date.now()}_${index}` };
|
||||||
|
|
||||||
|
for (const col of config.columns) {
|
||||||
|
const sourceValue = item[(col as any).sourceKey || col.key];
|
||||||
|
|
||||||
|
if (col.isSourceDisplay) {
|
||||||
|
row[col.key] = sourceValue ?? "";
|
||||||
|
row[`_display_${col.key}`] = sourceValue ?? "";
|
||||||
|
} else if (col.autoFill && col.autoFill.type !== "none") {
|
||||||
|
const autoValue = generateAutoFillValueSync(col, index, parentFormData);
|
||||||
|
if (autoValue !== undefined) {
|
||||||
|
row[col.key] = autoValue;
|
||||||
|
} else {
|
||||||
|
row[col.key] = "";
|
||||||
|
}
|
||||||
|
} else if (sourceValue !== undefined) {
|
||||||
|
row[col.key] = sourceValue;
|
||||||
|
} else {
|
||||||
|
row[col.key] = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
setData(newRows);
|
||||||
|
onDataChange?.(newRows);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [groupedData, config.columns, generateAutoFillValueSync]);
|
||||||
|
|
||||||
|
// parentSequence 컬럼의 부모 필드 값이 변경되면 행 데이터 갱신
|
||||||
|
useEffect(() => {
|
||||||
|
if (data.length === 0) return;
|
||||||
|
|
||||||
|
const parentSeqColumns = config.columns.filter(
|
||||||
|
(col) => col.autoFill?.type === "parentSequence" && col.autoFill.parentField,
|
||||||
|
);
|
||||||
|
if (parentSeqColumns.length === 0) return;
|
||||||
|
|
||||||
|
let needsUpdate = false;
|
||||||
|
const updatedData = data.map((row, index) => {
|
||||||
|
const updatedRow = { ...row };
|
||||||
|
for (const col of parentSeqColumns) {
|
||||||
|
const newValue = generateAutoFillValueSync(col, index, parentFormData);
|
||||||
|
if (newValue !== undefined && newValue !== row[col.key]) {
|
||||||
|
updatedRow[col.key] = newValue;
|
||||||
|
needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updatedRow;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (needsUpdate) {
|
||||||
|
setData(updatedData);
|
||||||
|
onDataChange?.(updatedData);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [parentFormData, config.columns, generateAutoFillValueSync]);
|
||||||
|
|
||||||
|
// 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경
|
||||||
const handleAddRow = useCallback(async () => {
|
const handleAddRow = useCallback(async () => {
|
||||||
if (isModalMode) {
|
if (isModalMode) {
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
|
|
@ -717,7 +796,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
|
|
||||||
// 먼저 동기적 자동 입력 값 적용
|
// 먼저 동기적 자동 입력 값 적용
|
||||||
for (const col of config.columns) {
|
for (const col of config.columns) {
|
||||||
const autoValue = generateAutoFillValueSync(col, currentRowCount);
|
const autoValue = generateAutoFillValueSync(col, currentRowCount, parentFormData);
|
||||||
if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) {
|
if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) {
|
||||||
// 채번 규칙: 즉시 API 호출
|
// 채번 규칙: 즉시 API 호출
|
||||||
newRow[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId);
|
newRow[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId);
|
||||||
|
|
@ -731,7 +810,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
const newData = [...data, newRow];
|
const newData = [...data, newRow];
|
||||||
handleDataChange(newData);
|
handleDataChange(newData);
|
||||||
}
|
}
|
||||||
}, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode]);
|
}, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode, parentFormData]);
|
||||||
|
|
||||||
// 모달에서 항목 선택 - 비동기로 변경
|
// 모달에서 항목 선택 - 비동기로 변경
|
||||||
const handleSelectItems = useCallback(
|
const handleSelectItems = useCallback(
|
||||||
|
|
@ -760,7 +839,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
row[`_display_${col.key}`] = item[col.key] || "";
|
row[`_display_${col.key}`] = item[col.key] || "";
|
||||||
} else {
|
} else {
|
||||||
// 자동 입력 값 적용
|
// 자동 입력 값 적용
|
||||||
const autoValue = generateAutoFillValueSync(col, currentRowCount + index);
|
const autoValue = generateAutoFillValueSync(col, currentRowCount + index, parentFormData);
|
||||||
if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) {
|
if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) {
|
||||||
// 채번 규칙: 즉시 API 호출
|
// 채번 규칙: 즉시 API 호출
|
||||||
row[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId);
|
row[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId);
|
||||||
|
|
@ -789,6 +868,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
handleDataChange,
|
handleDataChange,
|
||||||
generateAutoFillValueSync,
|
generateAutoFillValueSync,
|
||||||
generateNumberingCode,
|
generateNumberingCode,
|
||||||
|
parentFormData,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,7 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
||||||
{ value: "numbering", label: "채번 규칙" },
|
{ value: "numbering", label: "채번 규칙" },
|
||||||
{ value: "fromMainForm", label: "메인 폼에서 복사" },
|
{ value: "fromMainForm", label: "메인 폼에서 복사" },
|
||||||
{ value: "fixed", label: "고정값" },
|
{ value: "fixed", label: "고정값" },
|
||||||
|
{ value: "parentSequence", label: "부모채번+순번 (예: WO-001-01)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// 🆕 대상 메뉴 목록 로드 (사용자 메뉴의 레벨 2)
|
// 🆕 대상 메뉴 목록 로드 (사용자 메뉴의 레벨 2)
|
||||||
|
|
@ -1393,6 +1394,56 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 부모채번+순번 설정 */}
|
||||||
|
{col.autoFill?.type === "parentSequence" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] text-gray-600">부모 번호 필드명</Label>
|
||||||
|
<Input
|
||||||
|
value={col.autoFill?.parentField || ""}
|
||||||
|
onChange={(e) => updateColumnProp(col.key, "autoFill", {
|
||||||
|
...col.autoFill,
|
||||||
|
parentField: e.target.value,
|
||||||
|
})}
|
||||||
|
placeholder="work_order_no"
|
||||||
|
className="h-6 text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-gray-400">메인 폼에서 가져올 부모 채번 필드</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<Label className="text-[10px] text-gray-600">구분자</Label>
|
||||||
|
<Input
|
||||||
|
value={col.autoFill?.separator ?? "-"}
|
||||||
|
onChange={(e) => updateColumnProp(col.key, "autoFill", {
|
||||||
|
...col.autoFill,
|
||||||
|
separator: e.target.value,
|
||||||
|
})}
|
||||||
|
placeholder="-"
|
||||||
|
className="h-6 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<Label className="text-[10px] text-gray-600">순번 자릿수</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
value={col.autoFill?.sequenceLength ?? 2}
|
||||||
|
onChange={(e) => updateColumnProp(col.key, "autoFill", {
|
||||||
|
...col.autoFill,
|
||||||
|
sequenceLength: parseInt(e.target.value) || 2,
|
||||||
|
})}
|
||||||
|
className="h-6 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-green-600">
|
||||||
|
예시: WO-20260223-005{col.autoFill?.separator ?? "-"}{String(1).padStart(col.autoFill?.sequenceLength ?? 2, "0")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -466,7 +466,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
let currentValue;
|
let currentValue;
|
||||||
if (componentType === "modal-repeater-table" ||
|
if (componentType === "modal-repeater-table" ||
|
||||||
componentType === "repeat-screen-modal" ||
|
componentType === "repeat-screen-modal" ||
|
||||||
componentType === "selected-items-detail-input") {
|
componentType === "selected-items-detail-input" ||
|
||||||
|
componentType === "v2-repeater") {
|
||||||
// EditModal/ScreenModal에서 전달된 groupedData가 있으면 우선 사용
|
// EditModal/ScreenModal에서 전달된 groupedData가 있으면 우선 사용
|
||||||
currentValue = props.groupedData || formData?.[fieldName] || [];
|
currentValue = props.groupedData || formData?.[fieldName] || [];
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,8 @@ import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트
|
||||||
import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트
|
import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트
|
||||||
import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트
|
import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트
|
||||||
import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트
|
import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트
|
||||||
|
import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준
|
||||||
|
import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅
|
||||||
import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선
|
import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선
|
||||||
import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰
|
import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰
|
||||||
import "./v2-bom-item-editor/BomItemEditorRenderer"; // BOM 하위품목 편집기
|
import "./v2-bom-item-editor/BomItemEditorRenderer"; // BOM 하위품목 편집기
|
||||||
|
|
|
||||||
|
|
@ -1603,6 +1603,57 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const handleAddClick = useCallback(
|
const handleAddClick = useCallback(
|
||||||
(panel: "left" | "right") => {
|
(panel: "left" | "right") => {
|
||||||
console.log("🆕 [추가모달] handleAddClick 호출:", { panel, activeTabIndex });
|
console.log("🆕 [추가모달] handleAddClick 호출:", { panel, activeTabIndex });
|
||||||
|
|
||||||
|
// screenId 기반 모달 확인
|
||||||
|
const panelConfig = panel === "left" ? componentConfig.leftPanel : componentConfig.rightPanel;
|
||||||
|
const addModalConfig = panelConfig?.addModal;
|
||||||
|
|
||||||
|
if (addModalConfig?.screenId) {
|
||||||
|
if (panel === "right" && !selectedLeftItem) {
|
||||||
|
toast({
|
||||||
|
title: "항목을 선택해주세요",
|
||||||
|
description: "좌측 패널에서 항목을 먼저 선택한 후 추가해주세요.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableName = panelConfig?.tableName || "";
|
||||||
|
const urlParams: Record<string, any> = {
|
||||||
|
mode: "add",
|
||||||
|
tableName,
|
||||||
|
};
|
||||||
|
|
||||||
|
const parentData: Record<string, any> = {};
|
||||||
|
if (panel === "right" && selectedLeftItem) {
|
||||||
|
const relation = componentConfig.rightPanel?.relation;
|
||||||
|
console.log("🟢 [추가모달] selectedLeftItem:", JSON.stringify(selectedLeftItem));
|
||||||
|
console.log("🟢 [추가모달] relation:", JSON.stringify(relation));
|
||||||
|
if (relation?.keys && Array.isArray(relation.keys)) {
|
||||||
|
for (const key of relation.keys) {
|
||||||
|
console.log("🟢 [추가모달] key:", key, "leftValue:", selectedLeftItem[key.leftColumn]);
|
||||||
|
if (key.leftColumn && key.rightColumn && selectedLeftItem[key.leftColumn] != null) {
|
||||||
|
parentData[key.rightColumn] = selectedLeftItem[key.leftColumn];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🆕 [추가모달] screenId 기반 모달 열기:", { screenId: addModalConfig.screenId, tableName, parentData, parentDataStr: JSON.stringify(parentData) });
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("openScreenModal", {
|
||||||
|
detail: {
|
||||||
|
screenId: addModalConfig.screenId,
|
||||||
|
urlParams,
|
||||||
|
splitPanelParentData: parentData,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 인라인 모달 방식
|
||||||
setAddModalPanel(panel);
|
setAddModalPanel(panel);
|
||||||
|
|
||||||
// 우측 패널 추가 시, 좌측에서 선택된 항목의 조인 컬럼 값을 자동으로 채움
|
// 우측 패널 추가 시, 좌측에서 선택된 항목의 조인 컬럼 값을 자동으로 채움
|
||||||
|
|
|
||||||
|
|
@ -35,21 +35,23 @@ import { apiClient } from "@/lib/api/client";
|
||||||
interface BomItemNode {
|
interface BomItemNode {
|
||||||
tempId: string;
|
tempId: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
bom_id?: string;
|
|
||||||
parent_detail_id: string | null;
|
parent_detail_id: string | null;
|
||||||
seq_no: number;
|
seq_no: number;
|
||||||
level: number;
|
level: number;
|
||||||
child_item_id: string;
|
|
||||||
child_item_code: string;
|
|
||||||
child_item_name: string;
|
|
||||||
child_item_type: string;
|
|
||||||
quantity: string;
|
|
||||||
unit: string;
|
|
||||||
loss_rate: string;
|
|
||||||
remark: string;
|
|
||||||
children: BomItemNode[];
|
children: BomItemNode[];
|
||||||
_isNew?: boolean;
|
_isNew?: boolean;
|
||||||
_isDeleted?: boolean;
|
_isDeleted?: boolean;
|
||||||
|
data: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BomColumnConfig {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
width?: string;
|
||||||
|
visible?: boolean;
|
||||||
|
editable?: boolean;
|
||||||
|
isSourceDisplay?: boolean;
|
||||||
|
inputType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ItemInfo {
|
interface ItemInfo {
|
||||||
|
|
@ -211,13 +213,16 @@ function ItemSearchModal({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 트리 노드 행 렌더링 ───
|
// ─── 트리 노드 행 렌더링 (config.columns 기반 동적 셀) ───
|
||||||
|
|
||||||
interface TreeNodeRowProps {
|
interface TreeNodeRowProps {
|
||||||
node: BomItemNode;
|
node: BomItemNode;
|
||||||
depth: number;
|
depth: number;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
hasChildren: boolean;
|
hasChildren: boolean;
|
||||||
|
columns: BomColumnConfig[];
|
||||||
|
categoryOptionsMap: Record<string, { value: string; label: string }[]>;
|
||||||
|
mainTableName?: string;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onFieldChange: (tempId: string, field: string, value: string) => void;
|
onFieldChange: (tempId: string, field: string, value: string) => void;
|
||||||
onDelete: (tempId: string) => void;
|
onDelete: (tempId: string) => void;
|
||||||
|
|
@ -229,12 +234,84 @@ function TreeNodeRow({
|
||||||
depth,
|
depth,
|
||||||
expanded,
|
expanded,
|
||||||
hasChildren,
|
hasChildren,
|
||||||
|
columns,
|
||||||
|
categoryOptionsMap,
|
||||||
|
mainTableName,
|
||||||
onToggle,
|
onToggle,
|
||||||
onFieldChange,
|
onFieldChange,
|
||||||
onDelete,
|
onDelete,
|
||||||
onAddChild,
|
onAddChild,
|
||||||
}: TreeNodeRowProps) {
|
}: TreeNodeRowProps) {
|
||||||
const indentPx = depth * 32;
|
const indentPx = depth * 32;
|
||||||
|
const visibleColumns = columns.filter((c) => c.visible !== false);
|
||||||
|
|
||||||
|
const renderCell = (col: BomColumnConfig) => {
|
||||||
|
const value = node.data[col.key] ?? "";
|
||||||
|
|
||||||
|
// 소스 표시 컬럼 (읽기 전용)
|
||||||
|
if (col.isSourceDisplay) {
|
||||||
|
return (
|
||||||
|
<span className="truncate text-xs" title={String(value)}>
|
||||||
|
{value || "-"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카테고리 타입: API에서 로드한 옵션으로 Select 렌더링
|
||||||
|
if (col.inputType === "category") {
|
||||||
|
const categoryRef = mainTableName ? `${mainTableName}.${col.key}` : "";
|
||||||
|
const options = categoryOptionsMap[categoryRef] || [];
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={String(value || "")}
|
||||||
|
onValueChange={(val) => onFieldChange(node.tempId, col.key, val)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 w-full min-w-[70px] text-xs">
|
||||||
|
<SelectValue placeholder={col.title} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 편집 불가능 컬럼
|
||||||
|
if (col.editable === false) {
|
||||||
|
return (
|
||||||
|
<span className="text-muted-foreground truncate text-xs">
|
||||||
|
{value || "-"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숫자 입력
|
||||||
|
if (col.inputType === "number" || col.inputType === "decimal") {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={String(value)}
|
||||||
|
onChange={(e) => onFieldChange(node.tempId, col.key, e.target.value)}
|
||||||
|
className="h-7 w-full min-w-[50px] text-center text-xs"
|
||||||
|
placeholder={col.title}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 텍스트 입력
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
value={String(value)}
|
||||||
|
onChange={(e) => onFieldChange(node.tempId, col.key, e.target.value)}
|
||||||
|
className="h-7 w-full min-w-[50px] text-xs"
|
||||||
|
placeholder={col.title}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -245,10 +322,8 @@ function TreeNodeRow({
|
||||||
)}
|
)}
|
||||||
style={{ marginLeft: `${indentPx}px` }}
|
style={{ marginLeft: `${indentPx}px` }}
|
||||||
>
|
>
|
||||||
{/* 드래그 핸들 */}
|
|
||||||
<GripVertical className="text-muted-foreground h-4 w-4 shrink-0 cursor-grab" />
|
<GripVertical className="text-muted-foreground h-4 w-4 shrink-0 cursor-grab" />
|
||||||
|
|
||||||
{/* 펼침/접기 */}
|
|
||||||
<button
|
<button
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -266,57 +341,30 @@ function TreeNodeRow({
|
||||||
))}
|
))}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 순번 */}
|
|
||||||
<span className="text-muted-foreground w-6 shrink-0 text-center text-xs font-medium">
|
<span className="text-muted-foreground w-6 shrink-0 text-center text-xs font-medium">
|
||||||
{node.seq_no}
|
{node.seq_no}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* 품목코드 */}
|
|
||||||
<span className="w-24 shrink-0 truncate font-mono text-xs font-medium">
|
|
||||||
{node.child_item_code || "-"}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* 품목명 */}
|
|
||||||
<span className="min-w-[80px] flex-1 truncate text-xs">
|
|
||||||
{node.child_item_name || "-"}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* 레벨 뱃지 */}
|
|
||||||
{node.level > 0 && (
|
{node.level > 0 && (
|
||||||
<span className="bg-primary/10 text-primary shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold">
|
<span className="bg-primary/10 text-primary shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold">
|
||||||
L{node.level}
|
L{node.level}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 수량 */}
|
{/* config.columns 기반 동적 셀 렌더링 */}
|
||||||
<Input
|
{visibleColumns.map((col) => (
|
||||||
value={node.quantity}
|
<div
|
||||||
onChange={(e) =>
|
key={col.key}
|
||||||
onFieldChange(node.tempId, "quantity", e.target.value)
|
className={cn(
|
||||||
}
|
"shrink-0",
|
||||||
className="h-7 w-16 shrink-0 text-center text-xs"
|
col.isSourceDisplay ? "min-w-[60px] flex-1" : "min-w-[50px]",
|
||||||
placeholder="수량"
|
)}
|
||||||
/>
|
style={{ width: col.width && col.width !== "auto" ? col.width : undefined }}
|
||||||
|
>
|
||||||
|
{renderCell(col)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* 품목구분 셀렉트 */}
|
|
||||||
<Select
|
|
||||||
value={node.child_item_type || ""}
|
|
||||||
onValueChange={(val) =>
|
|
||||||
onFieldChange(node.tempId, "child_item_type", val)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-7 w-20 shrink-0 text-xs">
|
|
||||||
<SelectValue placeholder="구분" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="assembly">조립</SelectItem>
|
|
||||||
<SelectItem value="process">공정</SelectItem>
|
|
||||||
<SelectItem value="purchase">구매</SelectItem>
|
|
||||||
<SelectItem value="outsource">외주</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* 하위 추가 버튼 */}
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -327,7 +375,6 @@ function TreeNodeRow({
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* 삭제 버튼 */}
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -356,9 +403,16 @@ export function BomItemEditorComponent({
|
||||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [itemSearchOpen, setItemSearchOpen] = useState(false);
|
const [itemSearchOpen, setItemSearchOpen] = useState(false);
|
||||||
const [addTargetParentId, setAddTargetParentId] = useState<string | null>(
|
const [addTargetParentId, setAddTargetParentId] = useState<string | null>(null);
|
||||||
null,
|
const [categoryOptionsMap, setCategoryOptionsMap] = useState<Record<string, { value: string; label: string }[]>>({});
|
||||||
);
|
|
||||||
|
// 설정값 추출
|
||||||
|
const cfg = useMemo(() => component?.componentConfig || {}, [component]);
|
||||||
|
const mainTableName = cfg.mainTableName || "bom_detail";
|
||||||
|
const parentKeyColumn = cfg.parentKeyColumn || "parent_detail_id";
|
||||||
|
const columns: BomColumnConfig[] = useMemo(() => cfg.columns || [], [cfg.columns]);
|
||||||
|
const visibleColumns = useMemo(() => columns.filter((c) => c.visible !== false), [columns]);
|
||||||
|
const fkColumn = cfg.foreignKeyColumn || "bom_id";
|
||||||
|
|
||||||
// BOM ID 결정
|
// BOM ID 결정
|
||||||
const bomId = useMemo(() => {
|
const bomId = useMemo(() => {
|
||||||
|
|
@ -368,6 +422,37 @@ export function BomItemEditorComponent({
|
||||||
return null;
|
return null;
|
||||||
}, [propBomId, formData, selectedRowsData]);
|
}, [propBomId, formData, selectedRowsData]);
|
||||||
|
|
||||||
|
// ─── 카테고리 옵션 로드 (리피터 방식) ───
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadCategoryOptions = async () => {
|
||||||
|
const categoryColumns = visibleColumns.filter((col) => col.inputType === "category");
|
||||||
|
if (categoryColumns.length === 0) return;
|
||||||
|
|
||||||
|
for (const col of categoryColumns) {
|
||||||
|
const categoryRef = `${mainTableName}.${col.key}`;
|
||||||
|
if (categoryOptionsMap[categoryRef]) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/table-categories/${mainTableName}/${col.key}/values`);
|
||||||
|
if (response.data?.success && response.data.data) {
|
||||||
|
const options = response.data.data.map((item: any) => ({
|
||||||
|
value: item.valueCode || item.value_code,
|
||||||
|
label: item.valueLabel || item.value_label || item.displayValue || item.display_value || item.label,
|
||||||
|
}));
|
||||||
|
setCategoryOptionsMap((prev) => ({ ...prev, [categoryRef]: options }));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`카테고리 옵션 로드 실패 (${categoryRef}):`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isDesignMode) {
|
||||||
|
loadCategoryOptions();
|
||||||
|
}
|
||||||
|
}, [visibleColumns, mainTableName, isDesignMode]);
|
||||||
|
|
||||||
// ─── 데이터 로드 ───
|
// ─── 데이터 로드 ───
|
||||||
|
|
||||||
const loadBomDetails = useCallback(
|
const loadBomDetails = useCallback(
|
||||||
|
|
@ -375,10 +460,10 @@ export function BomItemEditorComponent({
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = await entityJoinApi.getTableDataWithJoins("bom_detail", {
|
const result = await entityJoinApi.getTableDataWithJoins(mainTableName, {
|
||||||
page: 1,
|
page: 1,
|
||||||
size: 500,
|
size: 500,
|
||||||
search: { bom_id: id },
|
search: { [fkColumn]: id },
|
||||||
sortBy: "seq_no",
|
sortBy: "seq_no",
|
||||||
sortOrder: "asc",
|
sortOrder: "asc",
|
||||||
enableEntityJoin: true,
|
enableEntityJoin: true,
|
||||||
|
|
@ -388,7 +473,6 @@ export function BomItemEditorComponent({
|
||||||
const tree = buildTree(rows);
|
const tree = buildTree(rows);
|
||||||
setTreeData(tree);
|
setTreeData(tree);
|
||||||
|
|
||||||
// 1레벨 기본 펼침
|
|
||||||
const firstLevelIds = new Set<string>(
|
const firstLevelIds = new Set<string>(
|
||||||
tree.map((n) => n.tempId || n.id || ""),
|
tree.map((n) => n.tempId || n.id || ""),
|
||||||
);
|
);
|
||||||
|
|
@ -399,7 +483,7 @@ export function BomItemEditorComponent({
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[mainTableName, fkColumn],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -408,7 +492,7 @@ export function BomItemEditorComponent({
|
||||||
}
|
}
|
||||||
}, [bomId, isDesignMode, loadBomDetails]);
|
}, [bomId, isDesignMode, loadBomDetails]);
|
||||||
|
|
||||||
// ─── 트리 빌드 ───
|
// ─── 트리 빌드 (동적 데이터) ───
|
||||||
|
|
||||||
const buildTree = (flatData: any[]): BomItemNode[] => {
|
const buildTree = (flatData: any[]): BomItemNode[] => {
|
||||||
const nodeMap = new Map<string, BomItemNode>();
|
const nodeMap = new Map<string, BomItemNode>();
|
||||||
|
|
@ -419,19 +503,11 @@ export function BomItemEditorComponent({
|
||||||
nodeMap.set(item.id || tempId, {
|
nodeMap.set(item.id || tempId, {
|
||||||
tempId,
|
tempId,
|
||||||
id: item.id,
|
id: item.id,
|
||||||
bom_id: item.bom_id,
|
parent_detail_id: item[parentKeyColumn] || null,
|
||||||
parent_detail_id: item.parent_detail_id || null,
|
|
||||||
seq_no: Number(item.seq_no) || 0,
|
seq_no: Number(item.seq_no) || 0,
|
||||||
level: Number(item.level) || 0,
|
level: Number(item.level) || 0,
|
||||||
child_item_id: item.child_item_id || "",
|
|
||||||
child_item_code: item.child_item_code || "",
|
|
||||||
child_item_name: item.child_item_name || "",
|
|
||||||
child_item_type: item.child_item_type || "",
|
|
||||||
quantity: item.quantity || "1",
|
|
||||||
unit: item.unit || "EA",
|
|
||||||
loss_rate: item.loss_rate || "0",
|
|
||||||
remark: item.remark || "",
|
|
||||||
children: [],
|
children: [],
|
||||||
|
data: { ...item },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -440,14 +516,14 @@ export function BomItemEditorComponent({
|
||||||
const node = nodeMap.get(nodeId);
|
const node = nodeMap.get(nodeId);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
|
||||||
if (item.parent_detail_id && nodeMap.has(item.parent_detail_id)) {
|
const parentId = item[parentKeyColumn];
|
||||||
nodeMap.get(item.parent_detail_id)!.children.push(node);
|
if (parentId && nodeMap.has(parentId)) {
|
||||||
|
nodeMap.get(parentId)!.children.push(node);
|
||||||
} else {
|
} else {
|
||||||
roots.push(node);
|
roots.push(node);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 순번 정렬
|
|
||||||
const sortChildren = (nodes: BomItemNode[]) => {
|
const sortChildren = (nodes: BomItemNode[]) => {
|
||||||
nodes.sort((a, b) => a.seq_no - b.seq_no);
|
nodes.sort((a, b) => a.seq_no - b.seq_no);
|
||||||
nodes.forEach((n) => sortChildren(n.children));
|
nodes.forEach((n) => sortChildren(n.children));
|
||||||
|
|
@ -468,22 +544,14 @@ export function BomItemEditorComponent({
|
||||||
) => {
|
) => {
|
||||||
items.forEach((node, idx) => {
|
items.forEach((node, idx) => {
|
||||||
result.push({
|
result.push({
|
||||||
|
...node.data,
|
||||||
id: node.id,
|
id: node.id,
|
||||||
tempId: node.tempId,
|
tempId: node.tempId,
|
||||||
bom_id: node.bom_id,
|
[parentKeyColumn]: parentId,
|
||||||
parent_detail_id: parentId,
|
|
||||||
seq_no: String(idx + 1),
|
seq_no: String(idx + 1),
|
||||||
level: String(level),
|
level: String(level),
|
||||||
child_item_id: node.child_item_id,
|
|
||||||
child_item_code: node.child_item_code,
|
|
||||||
child_item_name: node.child_item_name,
|
|
||||||
child_item_type: node.child_item_type,
|
|
||||||
quantity: node.quantity,
|
|
||||||
unit: node.unit,
|
|
||||||
loss_rate: node.loss_rate,
|
|
||||||
remark: node.remark,
|
|
||||||
_isNew: node._isNew,
|
_isNew: node._isNew,
|
||||||
_targetTable: "bom_detail",
|
_targetTable: mainTableName,
|
||||||
});
|
});
|
||||||
if (node.children.length > 0) {
|
if (node.children.length > 0) {
|
||||||
traverse(node.children, node.id || node.tempId, level + 1);
|
traverse(node.children, node.id || node.tempId, level + 1);
|
||||||
|
|
@ -492,7 +560,7 @@ export function BomItemEditorComponent({
|
||||||
};
|
};
|
||||||
traverse(nodes, null, 0);
|
traverse(nodes, null, 0);
|
||||||
return result;
|
return result;
|
||||||
}, []);
|
}, [parentKeyColumn, mainTableName]);
|
||||||
|
|
||||||
// 트리 변경 시 부모에게 알림
|
// 트리 변경 시 부모에게 알림
|
||||||
const notifyChange = useCallback(
|
const notifyChange = useCallback(
|
||||||
|
|
@ -526,12 +594,12 @@ export function BomItemEditorComponent({
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 필드 변경
|
// 필드 변경 (data Record 내부 업데이트)
|
||||||
const handleFieldChange = useCallback(
|
const handleFieldChange = useCallback(
|
||||||
(tempId: string, field: string, value: string) => {
|
(tempId: string, field: string, value: string) => {
|
||||||
const newTree = findAndUpdate(treeData, tempId, (node) => ({
|
const newTree = findAndUpdate(treeData, tempId, (node) => ({
|
||||||
...node,
|
...node,
|
||||||
[field]: value,
|
data: { ...node.data, [field]: value },
|
||||||
}));
|
}));
|
||||||
notifyChange(newTree);
|
notifyChange(newTree);
|
||||||
},
|
},
|
||||||
|
|
@ -559,35 +627,44 @@ export function BomItemEditorComponent({
|
||||||
setItemSearchOpen(true);
|
setItemSearchOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 품목 선택 후 추가
|
// 품목 선택 후 추가 (동적 데이터)
|
||||||
const handleItemSelect = useCallback(
|
const handleItemSelect = useCallback(
|
||||||
(item: ItemInfo) => {
|
(item: ItemInfo) => {
|
||||||
|
// 소스 테이블 데이터를 _display_ 접두사로 저장 (엔티티 조인 방식)
|
||||||
|
const sourceData: Record<string, any> = {};
|
||||||
|
const sourceTable = cfg.dataSource?.sourceTable;
|
||||||
|
if (sourceTable) {
|
||||||
|
const sourceFk = cfg.dataSource?.foreignKey || "child_item_id";
|
||||||
|
sourceData[sourceFk] = item.id;
|
||||||
|
// 소스 표시 컬럼의 데이터 병합
|
||||||
|
Object.keys(item).forEach((key) => {
|
||||||
|
sourceData[`_display_${key}`] = (item as any)[key];
|
||||||
|
sourceData[key] = (item as any)[key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const newNode: BomItemNode = {
|
const newNode: BomItemNode = {
|
||||||
tempId: generateTempId(),
|
tempId: generateTempId(),
|
||||||
parent_detail_id: null,
|
parent_detail_id: null,
|
||||||
seq_no: 0,
|
seq_no: 0,
|
||||||
level: 0,
|
level: 0,
|
||||||
child_item_id: item.id,
|
|
||||||
child_item_code: item.item_number || "",
|
|
||||||
child_item_name: item.item_name || "",
|
|
||||||
child_item_type: item.type || "",
|
|
||||||
quantity: "1",
|
|
||||||
unit: item.unit || "EA",
|
|
||||||
loss_rate: "0",
|
|
||||||
remark: "",
|
|
||||||
children: [],
|
children: [],
|
||||||
_isNew: true,
|
_isNew: true,
|
||||||
|
data: {
|
||||||
|
...sourceData,
|
||||||
|
quantity: "1",
|
||||||
|
loss_rate: "0",
|
||||||
|
remark: "",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let newTree: BomItemNode[];
|
let newTree: BomItemNode[];
|
||||||
|
|
||||||
if (addTargetParentId === null) {
|
if (addTargetParentId === null) {
|
||||||
// 루트에 추가
|
|
||||||
newNode.seq_no = treeData.length + 1;
|
newNode.seq_no = treeData.length + 1;
|
||||||
newNode.level = 0;
|
newNode.level = 0;
|
||||||
newTree = [...treeData, newNode];
|
newTree = [...treeData, newNode];
|
||||||
} else {
|
} else {
|
||||||
// 특정 노드 하위에 추가
|
|
||||||
newTree = findAndUpdate(treeData, addTargetParentId, (parent) => {
|
newTree = findAndUpdate(treeData, addTargetParentId, (parent) => {
|
||||||
newNode.parent_detail_id = parent.id || parent.tempId;
|
newNode.parent_detail_id = parent.id || parent.tempId;
|
||||||
newNode.seq_no = parent.children.length + 1;
|
newNode.seq_no = parent.children.length + 1;
|
||||||
|
|
@ -597,13 +674,12 @@ export function BomItemEditorComponent({
|
||||||
children: [...parent.children, newNode],
|
children: [...parent.children, newNode],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
// 부모 노드 펼침
|
|
||||||
setExpandedNodes((prev) => new Set([...prev, addTargetParentId]));
|
setExpandedNodes((prev) => new Set([...prev, addTargetParentId]));
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyChange(newTree);
|
notifyChange(newTree);
|
||||||
},
|
},
|
||||||
[addTargetParentId, treeData, notifyChange],
|
[addTargetParentId, treeData, notifyChange, cfg],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 펼침/접기 토글
|
// 펼침/접기 토글
|
||||||
|
|
@ -628,6 +704,9 @@ export function BomItemEditorComponent({
|
||||||
depth={depth}
|
depth={depth}
|
||||||
expanded={isExpanded}
|
expanded={isExpanded}
|
||||||
hasChildren={node.children.length > 0}
|
hasChildren={node.children.length > 0}
|
||||||
|
columns={visibleColumns}
|
||||||
|
categoryOptionsMap={categoryOptionsMap}
|
||||||
|
mainTableName={mainTableName}
|
||||||
onToggle={() => toggleExpand(node.tempId)}
|
onToggle={() => toggleExpand(node.tempId)}
|
||||||
onFieldChange={handleFieldChange}
|
onFieldChange={handleFieldChange}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
|
@ -648,9 +727,6 @@ export function BomItemEditorComponent({
|
||||||
const hasConfig =
|
const hasConfig =
|
||||||
cfg.mainTableName || cfg.dataSource?.sourceTable || (cfg.columns && cfg.columns.length > 0);
|
cfg.mainTableName || cfg.dataSource?.sourceTable || (cfg.columns && cfg.columns.length > 0);
|
||||||
|
|
||||||
const sourceColumns = (cfg.columns || []).filter((c: any) => c.isSourceDisplay);
|
|
||||||
const inputColumns = (cfg.columns || []).filter((c: any) => !c.isSourceDisplay);
|
|
||||||
|
|
||||||
if (!hasConfig) {
|
if (!hasConfig) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-dashed p-6 text-center">
|
<div className="rounded-md border border-dashed p-6 text-center">
|
||||||
|
|
@ -659,23 +735,36 @@ export function BomItemEditorComponent({
|
||||||
BOM 하위 품목 편집기
|
BOM 하위 품목 편집기
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
트리 구조로 하위 품목을 관리합니다
|
설정 패널에서 테이블과 컬럼을 지정하세요
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dummyRows = [
|
const visibleColumns = (cfg.columns || []).filter((c: any) => c.visible !== false);
|
||||||
{ depth: 0, code: "ASM-001", name: "본체 조립", type: "조립", qty: "1" },
|
|
||||||
{ depth: 1, code: "PRT-010", name: "프레임", type: "구매", qty: "2" },
|
const DUMMY_DATA: Record<string, string[]> = {
|
||||||
{ depth: 1, code: "PRT-011", name: "커버", type: "구매", qty: "1" },
|
item_name: ["본체 조립", "프레임", "커버", "전장 조립", "PCB 보드"],
|
||||||
{ depth: 0, code: "ASM-002", name: "전장 조립", type: "조립", qty: "1" },
|
item_number: ["ASM-001", "PRT-010", "PRT-011", "ASM-002", "PRT-020"],
|
||||||
{ depth: 1, code: "PRT-020", name: "PCB 보드", type: "구매", qty: "3" },
|
specification: ["100×50", "200mm", "ABS", "50×30", "4-Layer"],
|
||||||
];
|
material: ["AL6061", "SUS304", "ABS", "FR-4", "구리"],
|
||||||
|
stock_unit: ["EA", "EA", "EA", "EA", "EA"],
|
||||||
|
quantity: ["1", "2", "1", "1", "3"],
|
||||||
|
loss_rate: ["0", "5", "3", "0", "2"],
|
||||||
|
unit: ["EA", "EA", "EA", "EA", "EA"],
|
||||||
|
remark: ["", "외주", "", "", ""],
|
||||||
|
seq_no: ["1", "2", "3", "4", "5"],
|
||||||
|
};
|
||||||
|
const DUMMY_DEPTHS = [0, 1, 1, 0, 1];
|
||||||
|
|
||||||
|
const getDummyValue = (col: any, rowIdx: number): string => {
|
||||||
|
const vals = DUMMY_DATA[col.key];
|
||||||
|
if (vals) return vals[rowIdx % vals.length];
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h4 className="text-sm font-semibold">하위 품목 구성</h4>
|
<h4 className="text-sm font-semibold">하위 품목 구성</h4>
|
||||||
<Button size="sm" className="h-7 text-xs" disabled>
|
<Button size="sm" className="h-7 text-xs" disabled>
|
||||||
|
|
@ -701,78 +790,91 @@ export function BomItemEditorComponent({
|
||||||
트리: {cfg.parentKeyColumn}
|
트리: {cfg.parentKeyColumn}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{inputColumns.length > 0 && (
|
|
||||||
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600">
|
|
||||||
입력 {inputColumns.length}개
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{sourceColumns.length > 0 && (
|
|
||||||
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] text-blue-600">
|
|
||||||
표시 {sourceColumns.length}개
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 더미 트리 미리보기 */}
|
{/* 테이블 형태 미리보기 - config.columns 순서 그대로 */}
|
||||||
<div className="space-y-0.5 rounded-md border p-1.5">
|
<div className="overflow-hidden rounded-md border">
|
||||||
{dummyRows.map((row, i) => (
|
{visibleColumns.length === 0 ? (
|
||||||
<div
|
<div className="flex flex-col items-center justify-center py-6">
|
||||||
key={i}
|
<Package className="text-muted-foreground mb-1.5 h-6 w-6" />
|
||||||
className={cn(
|
<p className="text-muted-foreground text-xs">
|
||||||
"flex items-center gap-1.5 rounded px-1.5 py-1",
|
컬럼 탭에서 표시할 컬럼을 선택하세요
|
||||||
row.depth > 0 && "border-l-2 border-l-primary/20",
|
</p>
|
||||||
i === 0 && "bg-accent/30",
|
|
||||||
)}
|
|
||||||
style={{ marginLeft: `${row.depth * 20}px` }}
|
|
||||||
>
|
|
||||||
<GripVertical className="text-muted-foreground h-3 w-3 shrink-0 opacity-40" />
|
|
||||||
{row.depth === 0 ? (
|
|
||||||
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
|
|
||||||
) : (
|
|
||||||
<span className="w-3" />
|
|
||||||
)}
|
|
||||||
<span className="text-muted-foreground w-4 text-center text-[10px]">
|
|
||||||
{i + 1}
|
|
||||||
</span>
|
|
||||||
<span className="w-16 shrink-0 truncate font-mono text-[10px] font-medium">
|
|
||||||
{row.code}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-[50px] flex-1 truncate text-[10px]">
|
|
||||||
{row.name}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* 소스 표시 컬럼 미리보기 */}
|
|
||||||
{sourceColumns.slice(0, 2).map((col: any) => (
|
|
||||||
<span
|
|
||||||
key={col.key}
|
|
||||||
className="w-12 shrink-0 truncate text-center text-[10px] text-blue-500"
|
|
||||||
>
|
|
||||||
{col.title}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* 입력 컬럼 미리보기 */}
|
|
||||||
{inputColumns.slice(0, 2).map((col: any) => (
|
|
||||||
<div
|
|
||||||
key={col.key}
|
|
||||||
className="h-5 w-12 shrink-0 rounded border bg-background text-center text-[10px] leading-5"
|
|
||||||
>
|
|
||||||
{col.key === "quantity" || col.title === "수량"
|
|
||||||
? row.qty
|
|
||||||
: ""}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="flex shrink-0 gap-0.5">
|
|
||||||
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded opacity-40">
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded opacity-40">
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
) : (
|
||||||
|
<table className="w-full text-[10px]">
|
||||||
|
<thead className="bg-muted/60">
|
||||||
|
<tr>
|
||||||
|
<th className="w-6 px-1 py-1.5 text-center font-medium" />
|
||||||
|
<th className="w-5 px-0.5 py-1.5 text-center font-medium">#</th>
|
||||||
|
{visibleColumns.map((col: any) => (
|
||||||
|
<th
|
||||||
|
key={col.key}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-left font-medium",
|
||||||
|
col.isSourceDisplay && "text-blue-600",
|
||||||
|
)}
|
||||||
|
style={{ width: col.width && col.width !== "auto" ? col.width : undefined }}
|
||||||
|
>
|
||||||
|
{col.title}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="w-14 px-1 py-1.5 text-center font-medium">액션</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{DUMMY_DEPTHS.map((depth, rowIdx) => (
|
||||||
|
<tr
|
||||||
|
key={rowIdx}
|
||||||
|
className={cn(
|
||||||
|
"border-t transition-colors",
|
||||||
|
rowIdx === 0 && "bg-accent/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="px-1 py-1 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-0.5" style={{ paddingLeft: `${depth * 10}px` }}>
|
||||||
|
{depth === 0 ? (
|
||||||
|
<ChevronDown className="h-3 w-3 opacity-50" />
|
||||||
|
) : (
|
||||||
|
<span className="text-primary/40 text-[10px]">└</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="text-muted-foreground px-0.5 py-1 text-center">
|
||||||
|
{rowIdx + 1}
|
||||||
|
</td>
|
||||||
|
{visibleColumns.map((col: any) => (
|
||||||
|
<td key={col.key} className="px-1.5 py-0.5">
|
||||||
|
{col.isSourceDisplay ? (
|
||||||
|
<span className="truncate text-blue-600">
|
||||||
|
{getDummyValue(col, rowIdx) || col.title}
|
||||||
|
</span>
|
||||||
|
) : col.editable !== false ? (
|
||||||
|
<div className="h-5 rounded border bg-background px-1.5 text-[10px] leading-5">
|
||||||
|
{getDummyValue(col, rowIdx)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{getDummyValue(col, rowIdx)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="px-1 py-1 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-0.5">
|
||||||
|
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded opacity-40">
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded opacity-40">
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1107,6 +1107,15 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
effectiveFormData = { ...splitPanelParentData };
|
effectiveFormData = { ...splitPanelParentData };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("🔴 [ButtonPrimary] 저장 시 formData 디버그:", {
|
||||||
|
propsFormDataKeys: Object.keys(propsFormData),
|
||||||
|
screenContextFormDataKeys: Object.keys(screenContextFormData),
|
||||||
|
effectiveFormDataKeys: Object.keys(effectiveFormData),
|
||||||
|
process_code: effectiveFormData.process_code,
|
||||||
|
equipment_code: effectiveFormData.equipment_code,
|
||||||
|
fullData: JSON.stringify(effectiveFormData),
|
||||||
|
});
|
||||||
|
|
||||||
const context: ButtonActionContext = {
|
const context: ButtonActionContext = {
|
||||||
formData: effectiveFormData,
|
formData: effectiveFormData,
|
||||||
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
|
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,503 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Search, Plus, Trash2, Edit, ListOrdered, Package } 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,
|
||||||
|
} = 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]);
|
||||||
|
|
||||||
|
// 모달 저장 성공 감지 -> 데이터 새로고침
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSaveSuccess = () => {
|
||||||
|
refreshVersions();
|
||||||
|
refreshDetails();
|
||||||
|
};
|
||||||
|
window.addEventListener("saveSuccessInModal", handleSaveSuccess);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("saveSuccessInModal", handleSaveSuccess);
|
||||||
|
};
|
||||||
|
}, [refreshVersions, refreshDetails]);
|
||||||
|
|
||||||
|
// 품목 검색
|
||||||
|
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 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]);
|
||||||
|
|
||||||
|
// entity join으로 가져온 공정명 컬럼 이름 추정
|
||||||
|
const processNameKey = useMemo(() => {
|
||||||
|
const ds = config.dataSource;
|
||||||
|
return `${ds.processTable}_${ds.processNameColumn}`;
|
||||||
|
}, [config.dataSource]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
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"
|
||||||
|
)}
|
||||||
|
onClick={() => selectVersion(ver.id)}
|
||||||
|
>
|
||||||
|
{ver[config.dataSource.routingVersionNameColumn] || ver.version_name || ver.id}
|
||||||
|
</Badge>
|
||||||
|
{!config.readonly && (
|
||||||
|
<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 (
|
||||||
|
col.name === "process_code" &&
|
||||||
|
detail[processNameKey]
|
||||||
|
) {
|
||||||
|
cellValue = `${detail[col.name]} (${detail[processNameKey]})`;
|
||||||
|
}
|
||||||
|
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,239 @@
|
||||||
|
"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]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 공정 상세 목록 조회 (특정 버전의 공정들)
|
||||||
|
const fetchDetails = useCallback(
|
||||||
|
async (versionId: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const ds = configRef.current.dataSource;
|
||||||
|
const res = await apiClient.get("/table-data/entity-join-api/data-with-joins", {
|
||||||
|
params: {
|
||||||
|
tableName: ds.routingDetailTable,
|
||||||
|
searchConditions: JSON.stringify({
|
||||||
|
[ds.routingDetailFkColumn]: {
|
||||||
|
value: versionId,
|
||||||
|
operator: "equals",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
sortColumn: "seq_no",
|
||||||
|
sortDirection: "ASC",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.data?.success) {
|
||||||
|
setDetails(res.data.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 (config.autoSelectFirstVersion && versionList.length > 0) {
|
||||||
|
const firstVersion = versionList[0];
|
||||||
|
setSelectedVersionId(firstVersion.id);
|
||||||
|
await fetchDetails(firstVersion.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fetchVersions, fetchDetails, config.autoSelectFirstVersion]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 버전 선택
|
||||||
|
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-data/${ds.routingDetailTable}/${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-data/${ds.routingVersionTable}/${versionId}`
|
||||||
|
);
|
||||||
|
if (res.data?.success) {
|
||||||
|
if (selectedVersionId === versionId) {
|
||||||
|
setSelectedVersionId(null);
|
||||||
|
setDetails([]);
|
||||||
|
}
|
||||||
|
await refreshVersions();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("버전 삭제 실패", err);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[selectedVersionId, refreshVersions]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
items,
|
||||||
|
versions,
|
||||||
|
details,
|
||||||
|
loading,
|
||||||
|
selectedItemCode,
|
||||||
|
selectedItemName,
|
||||||
|
selectedVersionId,
|
||||||
|
fetchItems,
|
||||||
|
selectItem,
|
||||||
|
selectVersion,
|
||||||
|
refreshVersions,
|
||||||
|
refreshDetails,
|
||||||
|
deleteDetail,
|
||||||
|
deleteVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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,77 @@
|
||||||
|
/**
|
||||||
|
* 품목별 라우팅 관리 컴포넌트 타입 정의
|
||||||
|
*
|
||||||
|
* 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;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutingDetailData {
|
||||||
|
id: string;
|
||||||
|
routing_version_id: string;
|
||||||
|
seq_no: string;
|
||||||
|
process_code: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,241 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useMemo, useCallback } from "react";
|
||||||
|
import { Save, Loader2, ClipboardCheck } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ProcessWorkStandardConfig, WorkItem } from "./types";
|
||||||
|
import { defaultConfig } from "./config";
|
||||||
|
import { useProcessWorkStandard } from "./hooks/useProcessWorkStandard";
|
||||||
|
import { ItemProcessSelector } from "./components/ItemProcessSelector";
|
||||||
|
import { WorkPhaseSection } from "./components/WorkPhaseSection";
|
||||||
|
import { WorkItemAddModal } from "./components/WorkItemAddModal";
|
||||||
|
|
||||||
|
interface ProcessWorkStandardComponentProps {
|
||||||
|
config?: Partial<ProcessWorkStandardConfig>;
|
||||||
|
formData?: Record<string, any>;
|
||||||
|
isPreview?: boolean;
|
||||||
|
tableName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProcessWorkStandardComponent({
|
||||||
|
config: configProp,
|
||||||
|
isPreview,
|
||||||
|
}: ProcessWorkStandardComponentProps) {
|
||||||
|
const config: ProcessWorkStandardConfig = useMemo(
|
||||||
|
() => ({
|
||||||
|
...defaultConfig,
|
||||||
|
...configProp,
|
||||||
|
dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource },
|
||||||
|
phases: configProp?.phases?.length
|
||||||
|
? configProp.phases
|
||||||
|
: defaultConfig.phases,
|
||||||
|
detailTypes: configProp?.detailTypes?.length
|
||||||
|
? configProp.detailTypes
|
||||||
|
: defaultConfig.detailTypes,
|
||||||
|
}),
|
||||||
|
[configProp]
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
routings,
|
||||||
|
workItems,
|
||||||
|
selectedWorkItemDetails,
|
||||||
|
selectedWorkItemId,
|
||||||
|
selection,
|
||||||
|
loading,
|
||||||
|
fetchItems,
|
||||||
|
selectItem,
|
||||||
|
selectProcess,
|
||||||
|
fetchWorkItemDetails,
|
||||||
|
createWorkItem,
|
||||||
|
updateWorkItem,
|
||||||
|
deleteWorkItem,
|
||||||
|
createDetail,
|
||||||
|
updateDetail,
|
||||||
|
deleteDetail,
|
||||||
|
} = useProcessWorkStandard(config);
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [modalPhaseKey, setModalPhaseKey] = useState("");
|
||||||
|
const [editingItem, setEditingItem] = useState<WorkItem | null>(null);
|
||||||
|
|
||||||
|
// phase별 작업 항목 그룹핑
|
||||||
|
const workItemsByPhase = useMemo(() => {
|
||||||
|
const map: Record<string, WorkItem[]> = {};
|
||||||
|
for (const phase of config.phases) {
|
||||||
|
map[phase.key] = workItems.filter((wi) => wi.work_phase === phase.key);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [workItems, config.phases]);
|
||||||
|
|
||||||
|
const sortedPhases = useMemo(
|
||||||
|
() => [...config.phases].sort((a, b) => a.sortOrder - b.sortOrder),
|
||||||
|
[config.phases]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddWorkItem = useCallback((phaseKey: string) => {
|
||||||
|
setModalPhaseKey(phaseKey);
|
||||||
|
setEditingItem(null);
|
||||||
|
setModalOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEditWorkItem = useCallback((item: WorkItem) => {
|
||||||
|
setModalPhaseKey(item.work_phase);
|
||||||
|
setEditingItem(item);
|
||||||
|
setModalOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleModalSave = useCallback(
|
||||||
|
async (data: Parameters<typeof createWorkItem>[0]) => {
|
||||||
|
if (editingItem) {
|
||||||
|
await updateWorkItem(editingItem.id, {
|
||||||
|
title: data.title,
|
||||||
|
is_required: data.is_required,
|
||||||
|
description: data.description,
|
||||||
|
} as any);
|
||||||
|
} else {
|
||||||
|
await createWorkItem(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editingItem, createWorkItem, updateWorkItem]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectWorkItem = useCallback(
|
||||||
|
(workItemId: string) => {
|
||||||
|
fetchWorkItemDetails(workItemId);
|
||||||
|
},
|
||||||
|
[fetchWorkItemDetails]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInit = useCallback(() => {
|
||||||
|
fetchItems();
|
||||||
|
}, [fetchItems]);
|
||||||
|
|
||||||
|
const splitRatio = config.splitRatio || 30;
|
||||||
|
|
||||||
|
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">
|
||||||
|
<ClipboardCheck 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">
|
||||||
|
{sortedPhases.map((p) => p.label).join(" / ")}
|
||||||
|
</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="shrink-0 overflow-hidden">
|
||||||
|
<ItemProcessSelector
|
||||||
|
title={config.leftPanelTitle || "품목 및 공정 선택"}
|
||||||
|
items={items}
|
||||||
|
routings={routings}
|
||||||
|
selection={selection}
|
||||||
|
onSearch={(keyword) => fetchItems(keyword)}
|
||||||
|
onSelectItem={selectItem}
|
||||||
|
onSelectProcess={selectProcess}
|
||||||
|
onInit={handleInit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측 패널 */}
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
{/* 우측 헤더 */}
|
||||||
|
{selection.routingDetailId ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2.5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold">
|
||||||
|
{selection.itemName} - {selection.processName}
|
||||||
|
</h2>
|
||||||
|
<div className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>품목: {selection.itemCode}</span>
|
||||||
|
<span>공정: {selection.processName}</span>
|
||||||
|
<span>버전: {selection.routingVersionName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!config.readonly && (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
전체 저장
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 작업 단계별 섹션 */}
|
||||||
|
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||||
|
{sortedPhases.map((phase) => (
|
||||||
|
<WorkPhaseSection
|
||||||
|
key={phase.key}
|
||||||
|
phase={phase}
|
||||||
|
items={workItemsByPhase[phase.key] || []}
|
||||||
|
selectedWorkItemId={selectedWorkItemId}
|
||||||
|
selectedWorkItemDetails={selectedWorkItemDetails}
|
||||||
|
detailTypes={config.detailTypes}
|
||||||
|
readonly={config.readonly}
|
||||||
|
onSelectWorkItem={handleSelectWorkItem}
|
||||||
|
onAddWorkItem={handleAddWorkItem}
|
||||||
|
onEditWorkItem={handleEditWorkItem}
|
||||||
|
onDeleteWorkItem={deleteWorkItem}
|
||||||
|
onCreateDetail={createDetail}
|
||||||
|
onUpdateDetail={updateDetail}
|
||||||
|
onDeleteDetail={deleteDetail}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center text-center">
|
||||||
|
<ClipboardCheck 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>
|
||||||
|
|
||||||
|
{/* 작업 항목 추가/수정 모달 */}
|
||||||
|
<WorkItemAddModal
|
||||||
|
open={modalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditingItem(null);
|
||||||
|
}}
|
||||||
|
onSave={handleModalSave}
|
||||||
|
phaseKey={modalPhaseKey}
|
||||||
|
phaseLabel={
|
||||||
|
config.phases.find((p) => p.key === modalPhaseKey)?.label || ""
|
||||||
|
}
|
||||||
|
detailTypes={config.detailTypes}
|
||||||
|
editItem={editingItem}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,282 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Plus, Trash2, GripVertical } 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 { ProcessWorkStandardConfig, WorkPhaseDefinition, DetailTypeDefinition } from "./types";
|
||||||
|
import { defaultConfig } from "./config";
|
||||||
|
|
||||||
|
interface ConfigPanelProps {
|
||||||
|
config: Partial<ProcessWorkStandardConfig>;
|
||||||
|
onChange: (config: Partial<ProcessWorkStandardConfig>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProcessWorkStandardConfigPanel({
|
||||||
|
config: configProp,
|
||||||
|
onChange,
|
||||||
|
}: ConfigPanelProps) {
|
||||||
|
const config: ProcessWorkStandardConfig = {
|
||||||
|
...defaultConfig,
|
||||||
|
...configProp,
|
||||||
|
dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource },
|
||||||
|
phases: configProp?.phases?.length ? configProp.phases : defaultConfig.phases,
|
||||||
|
detailTypes: configProp?.detailTypes?.length ? configProp.detailTypes : defaultConfig.detailTypes,
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = (partial: Partial<ProcessWorkStandardConfig>) => {
|
||||||
|
onChange({ ...configProp, ...partial });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDataSource = (field: string, value: string) => {
|
||||||
|
update({
|
||||||
|
dataSource: { ...config.dataSource, [field]: value },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 작업 단계 관리
|
||||||
|
const addPhase = () => {
|
||||||
|
const nextOrder = config.phases.length + 1;
|
||||||
|
update({
|
||||||
|
phases: [
|
||||||
|
...config.phases,
|
||||||
|
{ key: `PHASE_${nextOrder}`, label: `단계 ${nextOrder}`, sortOrder: nextOrder },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePhase = (idx: number) => {
|
||||||
|
update({ phases: config.phases.filter((_, i) => i !== idx) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePhase = (idx: number, field: keyof WorkPhaseDefinition, value: string | number) => {
|
||||||
|
const next = [...config.phases];
|
||||||
|
next[idx] = { ...next[idx], [field]: value };
|
||||||
|
update({ phases: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 상세 유형 관리
|
||||||
|
const addDetailType = () => {
|
||||||
|
update({
|
||||||
|
detailTypes: [
|
||||||
|
...config.detailTypes,
|
||||||
|
{ value: `TYPE_${config.detailTypes.length + 1}`, label: "신규 유형" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDetailType = (idx: number) => {
|
||||||
|
update({ detailTypes: config.detailTypes.filter((_, i) => i !== idx) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDetailType = (idx: number, field: keyof DetailTypeDefinition, value: string) => {
|
||||||
|
const next = [...config.detailTypes];
|
||||||
|
next[idx] = { ...next[idx], [field]: value };
|
||||||
|
update({ detailTypes: 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>
|
||||||
|
<Input
|
||||||
|
value={config.dataSource.itemTable}
|
||||||
|
onChange={(e) => updateDataSource("itemTable", e.target.value)}
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">품목명 컬럼</Label>
|
||||||
|
<Input
|
||||||
|
value={config.dataSource.itemNameColumn}
|
||||||
|
onChange={(e) => updateDataSource("itemNameColumn", e.target.value)}
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">품목코드 컬럼</Label>
|
||||||
|
<Input
|
||||||
|
value={config.dataSource.itemCodeColumn}
|
||||||
|
onChange={(e) => updateDataSource("itemCodeColumn", e.target.value)}
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">라우팅 버전 테이블</Label>
|
||||||
|
<Input
|
||||||
|
value={config.dataSource.routingVersionTable}
|
||||||
|
onChange={(e) => updateDataSource("routingVersionTable", e.target.value)}
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">품목 연결 FK 컬럼</Label>
|
||||||
|
<Input
|
||||||
|
value={config.dataSource.routingFkColumn}
|
||||||
|
onChange={(e) => updateDataSource("routingFkColumn", e.target.value)}
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공정 마스터 테이블</Label>
|
||||||
|
<Input
|
||||||
|
value={config.dataSource.processTable}
|
||||||
|
onChange={(e) => updateDataSource("processTable", e.target.value)}
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공정명 컬럼</Label>
|
||||||
|
<Input
|
||||||
|
value={config.dataSource.processNameColumn}
|
||||||
|
onChange={(e) => updateDataSource("processNameColumn", e.target.value)}
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">공정코드 컬럼</Label>
|
||||||
|
<Input
|
||||||
|
value={config.dataSource.processCodeColumn}
|
||||||
|
onChange={(e) => updateDataSource("processCodeColumn", e.target.value)}
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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={addPhase}>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
단계 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{config.phases.map((phase, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="flex items-center gap-1.5 rounded border bg-muted/30 p-1.5"
|
||||||
|
>
|
||||||
|
<GripVertical className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
|
||||||
|
<Input
|
||||||
|
value={phase.key}
|
||||||
|
onChange={(e) => updatePhase(idx, "key", e.target.value)}
|
||||||
|
className="h-7 w-20 text-[10px]"
|
||||||
|
placeholder="키"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={phase.label}
|
||||||
|
onChange={(e) => updatePhase(idx, "label", e.target.value)}
|
||||||
|
className="h-7 flex-1 text-[10px]"
|
||||||
|
placeholder="표시명"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 shrink-0 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => removePhase(idx)}
|
||||||
|
disabled={config.phases.length <= 1}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</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={addDetailType}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
유형 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{config.detailTypes.map((dt, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-1.5 rounded border bg-muted/30 p-1.5">
|
||||||
|
<Input
|
||||||
|
value={dt.value}
|
||||||
|
onChange={(e) => updateDetailType(idx, "value", e.target.value)}
|
||||||
|
className="h-7 w-24 text-[10px]"
|
||||||
|
placeholder="값"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={dt.label}
|
||||||
|
onChange={(e) => updateDetailType(idx, "label", e.target.value)}
|
||||||
|
className="h-7 flex-1 text-[10px]"
|
||||||
|
placeholder="표시명"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 shrink-0 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => removeDetailType(idx)}
|
||||||
|
disabled={config.detailTypes.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 || 30}
|
||||||
|
onChange={(e) => update({ splitRatio: Number(e.target.value) })}
|
||||||
|
min={15}
|
||||||
|
max={50}
|
||||||
|
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 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 { V2ProcessWorkStandardDefinition } from "./index";
|
||||||
|
import { ProcessWorkStandardComponent } from "./ProcessWorkStandardComponent";
|
||||||
|
|
||||||
|
export class ProcessWorkStandardRenderer extends AutoRegisteringComponentRenderer {
|
||||||
|
static componentDefinition = V2ProcessWorkStandardDefinition;
|
||||||
|
|
||||||
|
render(): React.ReactElement {
|
||||||
|
const { formData, isPreview, config, tableName } = this.props as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProcessWorkStandardComponent
|
||||||
|
config={(config as object) || {}}
|
||||||
|
formData={formData as Record<string, unknown>}
|
||||||
|
tableName={tableName as string}
|
||||||
|
isPreview={isPreview as boolean}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ProcessWorkStandardRenderer.registerSelf();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
ProcessWorkStandardRenderer.enableHotReload();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Search, ChevronDown, ChevronRight, Package, GitBranch, Settings2, Star } from "lucide-react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ItemData, RoutingVersion, SelectionState } from "../types";
|
||||||
|
|
||||||
|
interface ItemProcessSelectorProps {
|
||||||
|
title: string;
|
||||||
|
items: ItemData[];
|
||||||
|
routings: RoutingVersion[];
|
||||||
|
selection: SelectionState;
|
||||||
|
onSearch: (keyword: string) => void;
|
||||||
|
onSelectItem: (itemCode: string, itemName: string) => void;
|
||||||
|
onSelectProcess: (
|
||||||
|
routingDetailId: string,
|
||||||
|
processName: string,
|
||||||
|
routingVersionId: string,
|
||||||
|
routingVersionName: string
|
||||||
|
) => void;
|
||||||
|
onInit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ItemProcessSelector({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
routings,
|
||||||
|
selection,
|
||||||
|
onSearch,
|
||||||
|
onSelectItem,
|
||||||
|
onSelectProcess,
|
||||||
|
onInit,
|
||||||
|
}: ItemProcessSelectorProps) {
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState("");
|
||||||
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onInit();
|
||||||
|
}, [onInit]);
|
||||||
|
|
||||||
|
const handleSearch = (value: string) => {
|
||||||
|
setSearchKeyword(value);
|
||||||
|
onSearch(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleItem = (itemCode: string, itemName: string) => {
|
||||||
|
const next = new Set(expandedItems);
|
||||||
|
if (next.has(itemCode)) {
|
||||||
|
next.delete(itemCode);
|
||||||
|
} else {
|
||||||
|
next.add(itemCode);
|
||||||
|
onSelectItem(itemCode, itemName);
|
||||||
|
}
|
||||||
|
setExpandedItems(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isItemExpanded = (itemCode: string) => expandedItems.has(itemCode);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col border-r bg-background">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="border-b p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<Package className="h-4 w-4 text-primary" />
|
||||||
|
<span className="text-sm font-semibold">{title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="품목 또는 공정 검색"
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
|
className="h-8 pl-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 트리 목록 */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-2">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<Package className="mb-2 h-8 w-8 text-muted-foreground/50" />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
라우팅이 등록된 품목이 없습니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
items.map((item) => (
|
||||||
|
<div key={item.item_code} className="mb-1">
|
||||||
|
{/* 품목 헤더 */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleItem(item.item_code, item.item_name)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs transition-colors",
|
||||||
|
"hover:bg-accent",
|
||||||
|
selection.itemCode === item.item_code && "bg-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isItemExpanded(item.item_code) ? (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<Package className="h-3.5 w-3.5 shrink-0 text-primary" />
|
||||||
|
<span className="truncate font-medium">
|
||||||
|
{item.item_name} ({item.item_code})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 라우팅 + 공정 */}
|
||||||
|
{isItemExpanded(item.item_code) &&
|
||||||
|
selection.itemCode === item.item_code && (
|
||||||
|
<div className="ml-4 mt-0.5 border-l pl-2">
|
||||||
|
{routings.length === 0 ? (
|
||||||
|
<p className="py-2 text-[10px] text-muted-foreground">
|
||||||
|
등록된 공정이 없습니다
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
routings.map((routing) => (
|
||||||
|
<div key={routing.id} className="mb-1">
|
||||||
|
{/* 라우팅 버전 */}
|
||||||
|
<div className="flex items-center gap-1.5 px-1 py-1">
|
||||||
|
<Star className="h-3 w-3 text-amber-500" />
|
||||||
|
<span className="text-[11px] font-medium text-muted-foreground">
|
||||||
|
{routing.version_name || "기본 라우팅"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 공정 목록 */}
|
||||||
|
{routing.processes.map((proc) => (
|
||||||
|
<button
|
||||||
|
key={proc.routing_detail_id}
|
||||||
|
onClick={() =>
|
||||||
|
onSelectProcess(
|
||||||
|
proc.routing_detail_id,
|
||||||
|
proc.process_name,
|
||||||
|
routing.id,
|
||||||
|
routing.version_name || "기본 라우팅"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"ml-3 flex w-[calc(100%-12px)] items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs transition-colors",
|
||||||
|
"hover:bg-primary/10",
|
||||||
|
selection.routingDetailId ===
|
||||||
|
proc.routing_detail_id &&
|
||||||
|
"bg-primary/15 font-semibold text-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Settings2 className="h-3 w-3 shrink-0" />
|
||||||
|
<span className="truncate">
|
||||||
|
{proc.process_name || proc.process_code}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,337 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
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 {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { DetailTypeDefinition, WorkItem } from "../types";
|
||||||
|
|
||||||
|
interface ModalDetail {
|
||||||
|
id: string;
|
||||||
|
detail_type: string;
|
||||||
|
content: string;
|
||||||
|
is_required: string;
|
||||||
|
sort_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkItemAddModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (data: {
|
||||||
|
work_phase: string;
|
||||||
|
title: string;
|
||||||
|
is_required: string;
|
||||||
|
description?: string;
|
||||||
|
details?: Array<{
|
||||||
|
detail_type?: string;
|
||||||
|
content: string;
|
||||||
|
is_required: string;
|
||||||
|
sort_order: number;
|
||||||
|
}>;
|
||||||
|
}) => void;
|
||||||
|
phaseKey: string;
|
||||||
|
phaseLabel: string;
|
||||||
|
detailTypes: DetailTypeDefinition[];
|
||||||
|
editItem?: WorkItem | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkItemAddModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
phaseKey,
|
||||||
|
phaseLabel,
|
||||||
|
detailTypes,
|
||||||
|
editItem,
|
||||||
|
}: WorkItemAddModalProps) {
|
||||||
|
const [title, setTitle] = useState(editItem?.title || "");
|
||||||
|
const [isRequired, setIsRequired] = useState(editItem?.is_required || "Y");
|
||||||
|
const [description, setDescription] = useState(editItem?.description || "");
|
||||||
|
const [details, setDetails] = useState<ModalDetail[]>([]);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setTitle("");
|
||||||
|
setIsRequired("Y");
|
||||||
|
setDescription("");
|
||||||
|
setDetails([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!title.trim()) return;
|
||||||
|
onSave({
|
||||||
|
work_phase: phaseKey,
|
||||||
|
title: title.trim(),
|
||||||
|
is_required: isRequired,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
details: details
|
||||||
|
.filter((d) => d.content.trim())
|
||||||
|
.map((d, idx) => ({
|
||||||
|
detail_type: d.detail_type || undefined,
|
||||||
|
content: d.content.trim(),
|
||||||
|
is_required: d.is_required,
|
||||||
|
sort_order: idx + 1,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
resetForm();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const addDetail = () => {
|
||||||
|
setDetails((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
detail_type: detailTypes[0]?.value || "",
|
||||||
|
content: "",
|
||||||
|
is_required: "N",
|
||||||
|
sort_order: prev.length + 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDetail = (id: string) => {
|
||||||
|
setDetails((prev) => prev.filter((d) => d.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDetailField = (
|
||||||
|
id: string,
|
||||||
|
field: keyof ModalDetail,
|
||||||
|
value: string | number
|
||||||
|
) => {
|
||||||
|
setDetails((prev) =>
|
||||||
|
prev.map((d) => (d.id === id ? { ...d, [field]: value } : d))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(v) => {
|
||||||
|
if (!v) {
|
||||||
|
resetForm();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">
|
||||||
|
작업 항목 {editItem ? "수정" : "추가"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
{phaseLabel} 단계에 {editItem ? "항목을 수정" : "새 항목을 추가"}합니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
<div className="space-y-3 rounded-lg border p-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
기본 정보
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">
|
||||||
|
항목 제목 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="예: 장비 점검, 품질 검사"
|
||||||
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">필수 여부</Label>
|
||||||
|
<Select value={isRequired} onValueChange={setIsRequired}>
|
||||||
|
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Y" className="text-xs sm:text-sm">
|
||||||
|
필수
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="N" className="text-xs sm:text-sm">
|
||||||
|
선택
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">비고</Label>
|
||||||
|
<Textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="추가 설명이나 주의사항"
|
||||||
|
className="mt-1 min-h-[60px] text-xs sm:text-sm"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상세 항목 (신규 추가 시에만) */}
|
||||||
|
{!editItem && (
|
||||||
|
<div className="space-y-2 rounded-lg border p-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={addDetail}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
상세 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{details.length > 0 ? (
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="w-10 py-1.5 text-center font-medium text-muted-foreground">
|
||||||
|
순서
|
||||||
|
</th>
|
||||||
|
<th className="w-20 px-1 py-1.5 text-left font-medium text-muted-foreground">
|
||||||
|
유형
|
||||||
|
</th>
|
||||||
|
<th className="px-1 py-1.5 text-left font-medium text-muted-foreground">
|
||||||
|
내용
|
||||||
|
</th>
|
||||||
|
<th className="w-16 px-1 py-1.5 text-center font-medium text-muted-foreground">
|
||||||
|
필수
|
||||||
|
</th>
|
||||||
|
<th className="w-10 py-1.5 text-center font-medium text-muted-foreground">
|
||||||
|
관리
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{details.map((detail, idx) => (
|
||||||
|
<tr key={detail.id} className="border-b">
|
||||||
|
<td className="py-1 text-center text-muted-foreground">
|
||||||
|
{idx + 1}
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-1">
|
||||||
|
<Select
|
||||||
|
value={detail.detail_type}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
updateDetailField(detail.id, "detail_type", v)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-[10px]">
|
||||||
|
<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-1 py-1">
|
||||||
|
<Input
|
||||||
|
value={detail.content}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateDetailField(
|
||||||
|
detail.id,
|
||||||
|
"content",
|
||||||
|
e.target.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="상세 내용"
|
||||||
|
className="h-7 text-[10px]"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-1">
|
||||||
|
<Select
|
||||||
|
value={detail.is_required}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
updateDetailField(detail.id, "is_required", v)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-[10px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Y" className="text-xs">
|
||||||
|
필수
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="N" className="text-xs">
|
||||||
|
선택
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</td>
|
||||||
|
<td className="py-1 text-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => removeDetail(detail.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<p className="py-3 text-center text-[10px] text-muted-foreground">
|
||||||
|
상세 항목이 없습니다. "상세 추가" 버튼을 클릭하여 추가하세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
resetForm();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!title.trim()}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { GripVertical, Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { WorkItem } from "../types";
|
||||||
|
|
||||||
|
interface WorkItemCardProps {
|
||||||
|
item: WorkItem;
|
||||||
|
isSelected: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkItemCard({
|
||||||
|
item,
|
||||||
|
isSelected,
|
||||||
|
readonly,
|
||||||
|
onClick,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: WorkItemCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"group flex cursor-pointer items-start gap-2 rounded-lg border p-3 transition-all",
|
||||||
|
"hover:border-primary/30 hover:shadow-sm",
|
||||||
|
isSelected
|
||||||
|
? "border-primary bg-primary/5 shadow-sm"
|
||||||
|
: "border-border bg-card"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<GripVertical className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground/50" />
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate text-sm font-medium">{item.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="h-5 px-1.5 text-[10px] font-normal"
|
||||||
|
>
|
||||||
|
{item.detail_count}개
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant={item.is_required === "Y" ? "default" : "outline"}
|
||||||
|
className="h-5 px-1.5 text-[10px] font-normal"
|
||||||
|
>
|
||||||
|
{item.is_required === "Y" ? "필수" : "선택"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!readonly && (
|
||||||
|
<div className="flex shrink-0 gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,380 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { Plus, Pencil, Trash2, Check, X, HandMetal } from "lucide-react";
|
||||||
|
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 { cn } from "@/lib/utils";
|
||||||
|
import { WorkItem, WorkItemDetail, DetailTypeDefinition } from "../types";
|
||||||
|
|
||||||
|
interface WorkItemDetailListProps {
|
||||||
|
workItem: WorkItem | null;
|
||||||
|
details: WorkItemDetail[];
|
||||||
|
detailTypes: DetailTypeDefinition[];
|
||||||
|
readonly?: boolean;
|
||||||
|
onCreateDetail: (data: Partial<WorkItemDetail>) => void;
|
||||||
|
onUpdateDetail: (id: string, data: Partial<WorkItemDetail>) => void;
|
||||||
|
onDeleteDetail: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkItemDetailList({
|
||||||
|
workItem,
|
||||||
|
details,
|
||||||
|
detailTypes,
|
||||||
|
readonly,
|
||||||
|
onCreateDetail,
|
||||||
|
onUpdateDetail,
|
||||||
|
onDeleteDetail,
|
||||||
|
}: WorkItemDetailListProps) {
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editData, setEditData] = useState<Partial<WorkItemDetail>>({});
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
const [newData, setNewData] = useState<Partial<WorkItemDetail>>({
|
||||||
|
detail_type: detailTypes[0]?.value || "",
|
||||||
|
content: "",
|
||||||
|
is_required: "N",
|
||||||
|
sort_order: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!workItem) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypeLabel = (value?: string) =>
|
||||||
|
detailTypes.find((t) => t.value === value)?.label || value || "-";
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (!newData.content?.trim()) return;
|
||||||
|
onCreateDetail({
|
||||||
|
...newData,
|
||||||
|
sort_order: details.length + 1,
|
||||||
|
});
|
||||||
|
setNewData({
|
||||||
|
detail_type: detailTypes[0]?.value || "",
|
||||||
|
content: "",
|
||||||
|
is_required: "N",
|
||||||
|
sort_order: 0,
|
||||||
|
});
|
||||||
|
setIsAdding(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = (id: string) => {
|
||||||
|
onUpdateDetail(id, editData);
|
||||||
|
setEditingId(null);
|
||||||
|
setEditData({});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold">{workItem.title}</span>
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
{details.length}개
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{!readonly && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1 text-xs"
|
||||||
|
onClick={() => setIsAdding(true)}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
상세 추가
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="sticky top-0 bg-muted/50">
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="w-12 px-2 py-2 text-center font-medium text-muted-foreground">
|
||||||
|
순서
|
||||||
|
</th>
|
||||||
|
<th className="w-20 px-2 py-2 text-left font-medium text-muted-foreground">
|
||||||
|
유형
|
||||||
|
</th>
|
||||||
|
<th className="px-2 py-2 text-left font-medium text-muted-foreground">
|
||||||
|
내용
|
||||||
|
</th>
|
||||||
|
<th className="w-14 px-2 py-2 text-center font-medium text-muted-foreground">
|
||||||
|
필수
|
||||||
|
</th>
|
||||||
|
{!readonly && (
|
||||||
|
<th className="w-16 px-2 py-2 text-center font-medium text-muted-foreground">
|
||||||
|
관리
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{details.map((detail, idx) => (
|
||||||
|
<tr
|
||||||
|
key={detail.id}
|
||||||
|
className="border-b transition-colors hover:bg-muted/30"
|
||||||
|
>
|
||||||
|
{editingId === detail.id ? (
|
||||||
|
<>
|
||||||
|
<td className="px-2 py-1.5 text-center">{idx + 1}</td>
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
<Select
|
||||||
|
value={editData.detail_type || detail.detail_type || ""}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setEditData((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
|
||||||
|
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">
|
||||||
|
<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={() => 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>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 추가 행 */}
|
||||||
|
{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>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{details.length === 0 && !isAdding && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
상세 항목이 없습니다. "상세 추가" 버튼을 클릭하여 추가하세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Plus, ClipboardList } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { WorkItemCard } from "./WorkItemCard";
|
||||||
|
import { WorkItemDetailList } from "./WorkItemDetailList";
|
||||||
|
import {
|
||||||
|
WorkItem,
|
||||||
|
WorkItemDetail,
|
||||||
|
WorkPhaseDefinition,
|
||||||
|
DetailTypeDefinition,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
interface WorkPhaseSectionProps {
|
||||||
|
phase: WorkPhaseDefinition;
|
||||||
|
items: WorkItem[];
|
||||||
|
selectedWorkItemId: string | null;
|
||||||
|
selectedWorkItemDetails: WorkItemDetail[];
|
||||||
|
detailTypes: DetailTypeDefinition[];
|
||||||
|
readonly?: boolean;
|
||||||
|
onSelectWorkItem: (workItemId: string) => void;
|
||||||
|
onAddWorkItem: (phase: string) => void;
|
||||||
|
onEditWorkItem: (item: WorkItem) => void;
|
||||||
|
onDeleteWorkItem: (id: string) => void;
|
||||||
|
onCreateDetail: (workItemId: string, data: Partial<WorkItemDetail>) => void;
|
||||||
|
onUpdateDetail: (id: string, data: Partial<WorkItemDetail>) => void;
|
||||||
|
onDeleteDetail: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkPhaseSection({
|
||||||
|
phase,
|
||||||
|
items,
|
||||||
|
selectedWorkItemId,
|
||||||
|
selectedWorkItemDetails,
|
||||||
|
detailTypes,
|
||||||
|
readonly,
|
||||||
|
onSelectWorkItem,
|
||||||
|
onAddWorkItem,
|
||||||
|
onEditWorkItem,
|
||||||
|
onDeleteWorkItem,
|
||||||
|
onCreateDetail,
|
||||||
|
onUpdateDetail,
|
||||||
|
onDeleteDetail,
|
||||||
|
}: WorkPhaseSectionProps) {
|
||||||
|
const selectedItem = items.find((i) => i.id === selectedWorkItemId) || null;
|
||||||
|
const isThisSectionSelected = items.some(
|
||||||
|
(i) => i.id === selectedWorkItemId
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-card">
|
||||||
|
{/* 섹션 헤더 */}
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-sm font-semibold">{phase.label}</h3>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="h-5 rounded-full px-2 text-[10px]"
|
||||||
|
>
|
||||||
|
{items.length}개 항목
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{!readonly && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1 text-xs"
|
||||||
|
onClick={() => onAddWorkItem(phase.key)}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
작업항목 추가
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 콘텐츠 영역 */}
|
||||||
|
<div className="flex min-h-[140px]">
|
||||||
|
{/* 좌측: 작업 항목 카드 목록 */}
|
||||||
|
<div className="w-[240px] shrink-0 border-r p-2">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||||
|
<ClipboardList className="mb-1 h-6 w-6 text-muted-foreground/40" />
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
등록된 항목이 없습니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{items.map((item) => (
|
||||||
|
<WorkItemCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
isSelected={selectedWorkItemId === item.id}
|
||||||
|
readonly={readonly}
|
||||||
|
onClick={() => onSelectWorkItem(item.id)}
|
||||||
|
onEdit={() => onEditWorkItem(item)}
|
||||||
|
onDelete={() => onDeleteWorkItem(item.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 상세 리스트 */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<WorkItemDetailList
|
||||||
|
workItem={isThisSectionSelected ? selectedItem : null}
|
||||||
|
details={isThisSectionSelected ? selectedWorkItemDetails : []}
|
||||||
|
detailTypes={detailTypes}
|
||||||
|
readonly={readonly}
|
||||||
|
onCreateDetail={(data) =>
|
||||||
|
selectedWorkItemId && onCreateDetail(selectedWorkItemId, data)
|
||||||
|
}
|
||||||
|
onUpdateDetail={onUpdateDetail}
|
||||||
|
onDeleteDetail={onDeleteDetail}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
/**
|
||||||
|
* 공정 작업기준 기본 설정
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ProcessWorkStandardConfig } from "./types";
|
||||||
|
|
||||||
|
export const defaultConfig: ProcessWorkStandardConfig = {
|
||||||
|
dataSource: {
|
||||||
|
itemTable: "item_info",
|
||||||
|
itemNameColumn: "item_name",
|
||||||
|
itemCodeColumn: "item_number",
|
||||||
|
routingVersionTable: "item_routing_version",
|
||||||
|
routingFkColumn: "item_code",
|
||||||
|
routingVersionNameColumn: "version_name",
|
||||||
|
routingDetailTable: "item_routing_detail",
|
||||||
|
processTable: "process_mng",
|
||||||
|
processNameColumn: "process_name",
|
||||||
|
processCodeColumn: "process_code",
|
||||||
|
},
|
||||||
|
phases: [
|
||||||
|
{ key: "PRE", label: "작업 전 (Pre-Work)", sortOrder: 1 },
|
||||||
|
{ key: "IN", label: "작업 중 (In-Work)", sortOrder: 2 },
|
||||||
|
{ key: "POST", label: "작업 후 (Post-Work)", sortOrder: 3 },
|
||||||
|
],
|
||||||
|
detailTypes: [
|
||||||
|
{ value: "CHECK", label: "체크" },
|
||||||
|
{ value: "INSPECTION", label: "검사" },
|
||||||
|
{ value: "MEASUREMENT", label: "측정" },
|
||||||
|
],
|
||||||
|
splitRatio: 30,
|
||||||
|
leftPanelTitle: "품목 및 공정 선택",
|
||||||
|
readonly: false,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,336 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import {
|
||||||
|
ProcessWorkStandardConfig,
|
||||||
|
ItemData,
|
||||||
|
RoutingVersion,
|
||||||
|
WorkItem,
|
||||||
|
WorkItemDetail,
|
||||||
|
SelectionState,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
const API_BASE = "/api/process-work-standard";
|
||||||
|
|
||||||
|
export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
|
||||||
|
const [items, setItems] = useState<ItemData[]>([]);
|
||||||
|
const [routings, setRoutings] = useState<RoutingVersion[]>([]);
|
||||||
|
const [workItems, setWorkItems] = useState<WorkItem[]>([]);
|
||||||
|
const [selectedWorkItemDetails, setSelectedWorkItemDetails] = useState<WorkItemDetail[]>([]);
|
||||||
|
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const [selection, setSelection] = useState<SelectionState>({
|
||||||
|
itemCode: null,
|
||||||
|
itemName: null,
|
||||||
|
routingVersionId: null,
|
||||||
|
routingVersionName: null,
|
||||||
|
routingDetailId: null,
|
||||||
|
processName: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 품목 목록 조회
|
||||||
|
const fetchItems = useCallback(
|
||||||
|
async (search?: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const ds = config.dataSource;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
tableName: ds.itemTable,
|
||||||
|
nameColumn: ds.itemNameColumn,
|
||||||
|
codeColumn: ds.itemCodeColumn,
|
||||||
|
routingTable: ds.routingVersionTable,
|
||||||
|
routingFkColumn: ds.routingFkColumn,
|
||||||
|
...(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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[config.dataSource]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 라우팅 + 공정 조회
|
||||||
|
const fetchRoutings = useCallback(
|
||||||
|
async (itemCode: string) => {
|
||||||
|
try {
|
||||||
|
const ds = config.dataSource;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
routingVersionTable: ds.routingVersionTable,
|
||||||
|
routingDetailTable: ds.routingDetailTable,
|
||||||
|
routingFkColumn: ds.routingFkColumn,
|
||||||
|
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) {
|
||||||
|
setRoutings(res.data.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("라우팅 조회 실패", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[config.dataSource]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 작업 항목 조회
|
||||||
|
const fetchWorkItems = useCallback(async (routingDetailId: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await apiClient.get(
|
||||||
|
`${API_BASE}/routing-detail/${routingDetailId}/work-items`
|
||||||
|
);
|
||||||
|
if (res.data?.success) {
|
||||||
|
setWorkItems(res.data.items || []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("작업 항목 조회 실패", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 작업 항목 상세 조회
|
||||||
|
const fetchWorkItemDetails = useCallback(async (workItemId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(
|
||||||
|
`${API_BASE}/work-items/${workItemId}/details`
|
||||||
|
);
|
||||||
|
if (res.data?.success) {
|
||||||
|
setSelectedWorkItemDetails(res.data.data);
|
||||||
|
setSelectedWorkItemId(workItemId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("상세 조회 실패", err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 품목 선택
|
||||||
|
const selectItem = useCallback(
|
||||||
|
async (itemCode: string, itemName: string) => {
|
||||||
|
setSelection((prev) => ({
|
||||||
|
...prev,
|
||||||
|
itemCode,
|
||||||
|
itemName,
|
||||||
|
routingVersionId: null,
|
||||||
|
routingVersionName: null,
|
||||||
|
routingDetailId: null,
|
||||||
|
processName: null,
|
||||||
|
}));
|
||||||
|
setWorkItems([]);
|
||||||
|
setSelectedWorkItemDetails([]);
|
||||||
|
setSelectedWorkItemId(null);
|
||||||
|
await fetchRoutings(itemCode);
|
||||||
|
},
|
||||||
|
[fetchRoutings]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 공정 선택
|
||||||
|
const selectProcess = useCallback(
|
||||||
|
async (
|
||||||
|
routingDetailId: string,
|
||||||
|
processName: string,
|
||||||
|
routingVersionId: string,
|
||||||
|
routingVersionName: string
|
||||||
|
) => {
|
||||||
|
setSelection((prev) => ({
|
||||||
|
...prev,
|
||||||
|
routingVersionId,
|
||||||
|
routingVersionName,
|
||||||
|
routingDetailId,
|
||||||
|
processName,
|
||||||
|
}));
|
||||||
|
setSelectedWorkItemDetails([]);
|
||||||
|
setSelectedWorkItemId(null);
|
||||||
|
await fetchWorkItems(routingDetailId);
|
||||||
|
},
|
||||||
|
[fetchWorkItems]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 작업 항목 추가
|
||||||
|
const createWorkItem = useCallback(
|
||||||
|
async (data: {
|
||||||
|
work_phase: string;
|
||||||
|
title: string;
|
||||||
|
is_required: string;
|
||||||
|
description?: string;
|
||||||
|
details?: Array<{
|
||||||
|
detail_type?: string;
|
||||||
|
content: string;
|
||||||
|
is_required: string;
|
||||||
|
sort_order: number;
|
||||||
|
}>;
|
||||||
|
}) => {
|
||||||
|
if (!selection.routingDetailId) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextOrder =
|
||||||
|
workItems.filter((wi) => wi.work_phase === data.work_phase).length + 1;
|
||||||
|
|
||||||
|
const res = await apiClient.post(`${API_BASE}/work-items`, {
|
||||||
|
routing_detail_id: selection.routingDetailId,
|
||||||
|
work_phase: data.work_phase,
|
||||||
|
title: data.title,
|
||||||
|
is_required: data.is_required,
|
||||||
|
sort_order: nextOrder,
|
||||||
|
description: data.description,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.data?.success && res.data.data) {
|
||||||
|
const newItem = res.data.data;
|
||||||
|
|
||||||
|
// 상세 항목도 함께 생성
|
||||||
|
if (data.details && data.details.length > 0) {
|
||||||
|
for (const detail of data.details) {
|
||||||
|
await apiClient.post(`${API_BASE}/work-item-details`, {
|
||||||
|
work_item_id: newItem.id,
|
||||||
|
...detail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchWorkItems(selection.routingDetailId);
|
||||||
|
return newItem;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("작업 항목 생성 실패", err);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[selection.routingDetailId, workItems, fetchWorkItems]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 작업 항목 수정
|
||||||
|
const updateWorkItem = useCallback(
|
||||||
|
async (id: string, data: Partial<WorkItem>) => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.put(`${API_BASE}/work-items/${id}`, data);
|
||||||
|
if (res.data?.success && selection.routingDetailId) {
|
||||||
|
await fetchWorkItems(selection.routingDetailId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("작업 항목 수정 실패", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selection.routingDetailId, fetchWorkItems]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 작업 항목 삭제
|
||||||
|
const deleteWorkItem = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.delete(`${API_BASE}/work-items/${id}`);
|
||||||
|
if (res.data?.success && selection.routingDetailId) {
|
||||||
|
await fetchWorkItems(selection.routingDetailId);
|
||||||
|
if (selectedWorkItemId === id) {
|
||||||
|
setSelectedWorkItemDetails([]);
|
||||||
|
setSelectedWorkItemId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("작업 항목 삭제 실패", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selection.routingDetailId, selectedWorkItemId, fetchWorkItems]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 상세 추가
|
||||||
|
const createDetail = useCallback(
|
||||||
|
async (workItemId: string, data: Partial<WorkItemDetail>) => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post(`${API_BASE}/work-item-details`, {
|
||||||
|
work_item_id: workItemId,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
if (res.data?.success) {
|
||||||
|
await fetchWorkItemDetails(workItemId);
|
||||||
|
if (selection.routingDetailId) {
|
||||||
|
await fetchWorkItems(selection.routingDetailId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("상세 생성 실패", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fetchWorkItemDetails, fetchWorkItems, selection.routingDetailId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 상세 수정
|
||||||
|
const updateDetail = useCallback(
|
||||||
|
async (id: string, data: Partial<WorkItemDetail>) => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.put(
|
||||||
|
`${API_BASE}/work-item-details/${id}`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
if (res.data?.success && selectedWorkItemId) {
|
||||||
|
await fetchWorkItemDetails(selectedWorkItemId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("상세 수정 실패", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedWorkItemId, fetchWorkItemDetails]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 상세 삭제
|
||||||
|
const deleteDetail = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.delete(
|
||||||
|
`${API_BASE}/work-item-details/${id}`
|
||||||
|
);
|
||||||
|
if (res.data?.success) {
|
||||||
|
if (selectedWorkItemId) {
|
||||||
|
await fetchWorkItemDetails(selectedWorkItemId);
|
||||||
|
}
|
||||||
|
if (selection.routingDetailId) {
|
||||||
|
await fetchWorkItems(selection.routingDetailId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("상세 삭제 실패", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
selectedWorkItemId,
|
||||||
|
selection.routingDetailId,
|
||||||
|
fetchWorkItemDetails,
|
||||||
|
fetchWorkItems,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
routings,
|
||||||
|
workItems,
|
||||||
|
selectedWorkItemDetails,
|
||||||
|
selectedWorkItemId,
|
||||||
|
selection,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
fetchItems,
|
||||||
|
selectItem,
|
||||||
|
selectProcess,
|
||||||
|
fetchWorkItems,
|
||||||
|
fetchWorkItemDetails,
|
||||||
|
setSelectedWorkItemId,
|
||||||
|
createWorkItem,
|
||||||
|
updateWorkItem,
|
||||||
|
deleteWorkItem,
|
||||||
|
createDetail,
|
||||||
|
updateDetail,
|
||||||
|
deleteDetail,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||||
|
import { ComponentCategory } from "@/types/component";
|
||||||
|
import { ProcessWorkStandardComponent } from "./ProcessWorkStandardComponent";
|
||||||
|
import { ProcessWorkStandardConfigPanel } from "./ProcessWorkStandardConfigPanel";
|
||||||
|
import { defaultConfig } from "./config";
|
||||||
|
|
||||||
|
export const V2ProcessWorkStandardDefinition = createComponentDefinition({
|
||||||
|
id: "v2-process-work-standard",
|
||||||
|
name: "공정 작업기준",
|
||||||
|
nameEng: "Process Work Standard",
|
||||||
|
description: "품목별 라우팅/공정에 대한 작업 전·중·후 기준 항목을 관리하는 컴포넌트",
|
||||||
|
category: ComponentCategory.INPUT,
|
||||||
|
webType: "component",
|
||||||
|
component: ProcessWorkStandardComponent,
|
||||||
|
defaultConfig: defaultConfig,
|
||||||
|
defaultSize: {
|
||||||
|
width: 1400,
|
||||||
|
height: 800,
|
||||||
|
gridColumnSpan: "12",
|
||||||
|
},
|
||||||
|
configPanel: ProcessWorkStandardConfigPanel,
|
||||||
|
icon: "ClipboardCheck",
|
||||||
|
tags: ["공정", "작업기준", "품질", "검사", "체크리스트", "라우팅", "제조"],
|
||||||
|
version: "1.0.0",
|
||||||
|
author: "개발팀",
|
||||||
|
documentation: `
|
||||||
|
공정별 작업기준(Pre-Work / In-Work / Post-Work)을 관리하는 컴포넌트입니다.
|
||||||
|
|
||||||
|
## 주요 기능
|
||||||
|
- 좌측: 품목 → 라우팅 버전 → 공정 아코디언 트리 선택
|
||||||
|
- 우측: 작업 단계별 작업 항목 및 상세 관리
|
||||||
|
- 작업 항목 추가/수정/삭제 (모달)
|
||||||
|
- 항목별 상세 체크리스트 관리
|
||||||
|
- 전체 저장 기능
|
||||||
|
|
||||||
|
## 커스터마이징
|
||||||
|
- 작업 단계(Phase) 추가/삭제/이름변경 가능
|
||||||
|
- 상세 유형 옵션 커스텀 가능
|
||||||
|
- 데이터 소스 테이블 변경 가능
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ProcessWorkStandardConfig,
|
||||||
|
ProcessWorkStandardComponentProps,
|
||||||
|
WorkPhaseDefinition,
|
||||||
|
DetailTypeDefinition,
|
||||||
|
DataSourceConfig,
|
||||||
|
WorkItem,
|
||||||
|
WorkItemDetail,
|
||||||
|
SelectionState,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export { ProcessWorkStandardComponent } from "./ProcessWorkStandardComponent";
|
||||||
|
export { ProcessWorkStandardRenderer } from "./ProcessWorkStandardRenderer";
|
||||||
|
export { ProcessWorkStandardConfigPanel } from "./ProcessWorkStandardConfigPanel";
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
/**
|
||||||
|
* 공정 작업기준 컴포넌트 타입 정의
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 작업 단계 정의 (사용자가 추가/삭제/이름변경 가능)
|
||||||
|
export interface WorkPhaseDefinition {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상세 유형 정의 (사용자가 추가/삭제 가능)
|
||||||
|
export interface DetailTypeDefinition {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 소스 설정
|
||||||
|
export interface DataSourceConfig {
|
||||||
|
itemTable: string;
|
||||||
|
itemNameColumn: string;
|
||||||
|
itemCodeColumn: string;
|
||||||
|
routingVersionTable: string;
|
||||||
|
routingFkColumn: string;
|
||||||
|
routingVersionNameColumn: string;
|
||||||
|
routingDetailTable: string;
|
||||||
|
processTable: string;
|
||||||
|
processNameColumn: string;
|
||||||
|
processCodeColumn: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전체 Config
|
||||||
|
export interface ProcessWorkStandardConfig {
|
||||||
|
dataSource: DataSourceConfig;
|
||||||
|
phases: WorkPhaseDefinition[];
|
||||||
|
detailTypes: DetailTypeDefinition[];
|
||||||
|
splitRatio?: number;
|
||||||
|
leftPanelTitle?: string;
|
||||||
|
readonly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 데이터 모델
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface ItemData {
|
||||||
|
id: string;
|
||||||
|
item_name: string;
|
||||||
|
item_code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutingVersion {
|
||||||
|
id: string;
|
||||||
|
version_name: string;
|
||||||
|
description?: string;
|
||||||
|
created_date?: string;
|
||||||
|
processes: RoutingProcess[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutingProcess {
|
||||||
|
routing_detail_id: string;
|
||||||
|
seq_no: string;
|
||||||
|
process_code: string;
|
||||||
|
process_name: string;
|
||||||
|
is_required?: string;
|
||||||
|
work_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkItem {
|
||||||
|
id: string;
|
||||||
|
routing_detail_id: string;
|
||||||
|
work_phase: string;
|
||||||
|
title: string;
|
||||||
|
is_required: string;
|
||||||
|
sort_order: number;
|
||||||
|
description?: string;
|
||||||
|
detail_count: number;
|
||||||
|
created_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkItemDetail {
|
||||||
|
id: string;
|
||||||
|
work_item_id: string;
|
||||||
|
detail_type?: string;
|
||||||
|
content: string;
|
||||||
|
is_required: string;
|
||||||
|
sort_order: number;
|
||||||
|
remark?: string;
|
||||||
|
created_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 컴포넌트 Props
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface ProcessWorkStandardComponentProps {
|
||||||
|
config: ProcessWorkStandardConfig;
|
||||||
|
formData?: Record<string, any>;
|
||||||
|
isPreview?: boolean;
|
||||||
|
tableName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택 상태
|
||||||
|
export interface SelectionState {
|
||||||
|
itemCode: string | null;
|
||||||
|
itemName: string | null;
|
||||||
|
routingVersionId: string | null;
|
||||||
|
routingVersionName: string | null;
|
||||||
|
routingDetailId: string | null;
|
||||||
|
processName: string | null;
|
||||||
|
}
|
||||||
|
|
@ -1241,7 +1241,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" };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1271,11 +1271,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" };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -2035,20 +2035,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: {
|
||||||
|
|
@ -2056,19 +2083,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,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -2076,6 +2092,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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
|
||||||
"v2-numbering-rule": () => import("@/lib/registry/components/v2-numbering-rule/NumberingRuleConfigPanel"),
|
"v2-numbering-rule": () => import("@/lib/registry/components/v2-numbering-rule/NumberingRuleConfigPanel"),
|
||||||
"category-manager": () => import("@/lib/registry/components/category-manager/CategoryManagerConfigPanel"),
|
"category-manager": () => import("@/lib/registry/components/category-manager/CategoryManagerConfigPanel"),
|
||||||
"universal-form-modal": () => import("@/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel"),
|
"universal-form-modal": () => import("@/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel"),
|
||||||
|
"v2-process-work-standard": () => import("@/lib/registry/components/v2-process-work-standard/ProcessWorkStandardConfigPanel"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ConfigPanel 컴포넌트 캐시
|
// ConfigPanel 컴포넌트 캐시
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,7 @@
|
||||||
"eslint-config-next": "15.4.4",
|
"eslint-config-next": "15.4.4",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.5.4",
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
|
"playwright": "^1.58.2",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||||
"prisma": "^6.14.0",
|
"prisma": "^6.14.0",
|
||||||
|
|
@ -10001,6 +10002,21 @@
|
||||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fstream": {
|
"node_modules/fstream": {
|
||||||
"version": "1.0.12",
|
"version": "1.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz",
|
||||||
|
|
@ -12497,6 +12513,38 @@
|
||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.58.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pngjs": {
|
"node_modules/pngjs": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@
|
||||||
"eslint-config-next": "15.4.4",
|
"eslint-config-next": "15.4.4",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.5.4",
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
|
"playwright": "^1.58.2",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||||
"prisma": "^6.14.0",
|
"prisma": "^6.14.0",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
/**
|
||||||
|
* formData 로그 테스트 스크립트
|
||||||
|
* - http://localhost:9771/screens/1599 접속
|
||||||
|
* - P003 행 선택 → 추가 버튼 클릭 → 장비 선택 → 저장 전/후 콘솔 로그 수집
|
||||||
|
*
|
||||||
|
* 실행: npx tsx scripts/test-formdata-logs.ts
|
||||||
|
* (Playwright 필요: npx playwright install chromium)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
|
||||||
|
const TARGET_URL = "http://localhost:9771/screens/1599?menuObjid=1762422235300";
|
||||||
|
const LOGIN = { userId: "topseal_admin", password: "1234" };
|
||||||
|
|
||||||
|
const TARGET_LOGS = ["🔵", "🟡", "🔴", "process_code", "splitPanelParentData"];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const browser = await chromium.launch({ headless: false });
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
const consoleLogs: string[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
page.on("console", (msg) => {
|
||||||
|
const text = msg.text();
|
||||||
|
const type = msg.type();
|
||||||
|
if (type === "error") {
|
||||||
|
errors.push(`[CONSOLE ERROR] ${text}`);
|
||||||
|
}
|
||||||
|
const hasTarget = TARGET_LOGS.some((t) => text.includes(t));
|
||||||
|
if (hasTarget || type === "error") {
|
||||||
|
consoleLogs.push(`[${type}] ${text}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("1. 페이지 이동:", TARGET_URL);
|
||||||
|
await page.goto(TARGET_URL, { waitUntil: "domcontentloaded", timeout: 20000 });
|
||||||
|
|
||||||
|
// 로그인 필요 여부 확인
|
||||||
|
const userIdInput = page.locator('input[name="userId"]').first();
|
||||||
|
if (await userIdInput.isVisible().catch(() => false)) {
|
||||||
|
console.log("2. 로그인 페이지 감지 - 로그인 진행");
|
||||||
|
await page.fill('input[name="userId"]', LOGIN.userId);
|
||||||
|
await page.fill('input[name="password"]', LOGIN.password);
|
||||||
|
await page.click('button[type="submit"]').catch(() => page.click('button:has-text("로그인")'));
|
||||||
|
await page.waitForTimeout(4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("3. 5초 대기 (페이지 로드)");
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
|
||||||
|
// 탭 확인 - 공정 마스터 (첫 번째 탭)
|
||||||
|
const firstTab = page.getByRole("tab", { name: /공정 마스터/i }).or(page.locator('button:has-text("공정 마스터")')).first();
|
||||||
|
if (await firstTab.isVisible().catch(() => false)) {
|
||||||
|
console.log("4. '공정 마스터' 탭 클릭");
|
||||||
|
await firstTab.click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 좌측 패널 테이블 데이터 로드 대기
|
||||||
|
console.log("5. 좌측 패널 데이터 로드 대기");
|
||||||
|
await page.locator("table tbody tr").first().waitFor({ state: "visible", timeout: 25000 }).catch(() => {
|
||||||
|
throw new Error("좌측 테이블에 데이터가 없습니다. process_mng에 P003 등 데이터가 있는지 확인하세요.");
|
||||||
|
});
|
||||||
|
|
||||||
|
// P003 행 또는 첫 번째 행 클릭
|
||||||
|
let rowToClick = page.locator('table tbody tr:has(td:has-text("P003"))').first();
|
||||||
|
const hasP003 = await rowToClick.isVisible().catch(() => false);
|
||||||
|
if (!hasP003) {
|
||||||
|
console.log(" P003 미발견 - 첫 번째 행 클릭");
|
||||||
|
rowToClick = page.locator("table tbody tr").first();
|
||||||
|
}
|
||||||
|
await rowToClick.click();
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
|
||||||
|
// 우측 패널에서 '추가' 버튼 클릭 (모달 열기)
|
||||||
|
console.log("6. '추가' 버튼 클릭");
|
||||||
|
const addBtn = page.locator('button:has-text("추가")').first();
|
||||||
|
await addBtn.click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// 모달이 열렸는지 확인
|
||||||
|
const modal = page.locator('[role="dialog"], [data-state="open"]').first();
|
||||||
|
await modal.waitFor({ state: "visible", timeout: 5000 }).catch(() => {});
|
||||||
|
|
||||||
|
// 모달 내 설비 드롭다운/콤보박스 선택 (v2-select, entity-search-input 등)
|
||||||
|
console.log("7. 모달 내 설비 선택");
|
||||||
|
const trigger = page.locator('[role="combobox"], button:has-text("선택"), button:has-text("설비")').first();
|
||||||
|
if (await trigger.isVisible().catch(() => false)) {
|
||||||
|
await trigger.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const option = page.locator('[role="option"], li[role="option"]').first();
|
||||||
|
if (await option.isVisible().catch(() => false)) {
|
||||||
|
await option.click();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// select 태그인 경우
|
||||||
|
const selectEl = page.locator('select').first();
|
||||||
|
if (await selectEl.isVisible().catch(() => false)) {
|
||||||
|
await selectEl.selectOption({ index: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
|
||||||
|
// 저장 전 콘솔 스냅샷
|
||||||
|
console.log("\n=== 저장 전 콘솔 로그 (formData 관련) ===");
|
||||||
|
consoleLogs.forEach((l) => console.log(l));
|
||||||
|
if (errors.length) {
|
||||||
|
console.log("\n=== 에러 ===");
|
||||||
|
errors.forEach((e) => console.log(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 저장 버튼 클릭 (모달 내부의 저장 버튼)
|
||||||
|
console.log("\n8. '저장' 버튼 클릭");
|
||||||
|
const saveBtn = page.locator('[role="dialog"] button:has-text("저장"), [data-state="open"] button:has-text("저장")').first();
|
||||||
|
await saveBtn.click();
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
|
// 저장 후 로그 수집
|
||||||
|
console.log("\n=== 저장 후 콘솔 로그 (formData 관련) ===");
|
||||||
|
consoleLogs.forEach((l) => console.log(l));
|
||||||
|
if (errors.length) {
|
||||||
|
console.log("\n=== 에러 ===");
|
||||||
|
errors.forEach((e) => console.log(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("테스트 실패:", e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -24,7 +24,8 @@ export type AutoFillType =
|
||||||
| "sequence" // 순번 (1, 2, 3...)
|
| "sequence" // 순번 (1, 2, 3...)
|
||||||
| "numbering" // 채번 규칙 (관리자가 등록한 규칙 선택)
|
| "numbering" // 채번 규칙 (관리자가 등록한 규칙 선택)
|
||||||
| "fromMainForm" // 메인 폼에서 값 복사
|
| "fromMainForm" // 메인 폼에서 값 복사
|
||||||
| "fixed"; // 고정값
|
| "fixed" // 고정값
|
||||||
|
| "parentSequence"; // 부모 채번 + 순번 (예: WO-20260223-005-01)
|
||||||
|
|
||||||
// 자동 입력 설정
|
// 자동 입력 설정
|
||||||
export interface AutoFillConfig {
|
export interface AutoFillConfig {
|
||||||
|
|
@ -36,6 +37,10 @@ export interface AutoFillConfig {
|
||||||
// numbering 타입용 - 기존 채번 규칙 ID를 참조
|
// numbering 타입용 - 기존 채번 규칙 ID를 참조
|
||||||
numberingRuleId?: string; // 채번 규칙 ID (numbering_rules 테이블)
|
numberingRuleId?: string; // 채번 규칙 ID (numbering_rules 테이블)
|
||||||
selectedMenuObjid?: number; // 🆕 채번 규칙 선택을 위한 대상 메뉴 OBJID
|
selectedMenuObjid?: number; // 🆕 채번 규칙 선택을 위한 대상 메뉴 OBJID
|
||||||
|
// parentSequence 타입용
|
||||||
|
parentField?: string; // 메인 폼에서 부모 번호를 가져올 필드명
|
||||||
|
separator?: string; // 부모 번호와 순번 사이 구분자 (기본: "-")
|
||||||
|
sequenceLength?: number; // 순번 자릿수 (기본: 2 → 01, 02, 03)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 컬럼 설정
|
// 컬럼 설정
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue